add ArgConfigSource(), 3 helpers to .sqlalchemy, add .waiter

This commit is contained in:
Yusur 2025-09-04 01:25:25 +02:00
parent 97194b2b85
commit eb8371757d
6 changed files with 177 additions and 4 deletions

View file

@ -2,7 +2,10 @@
## 0.6.0 ## 0.6.0
... + `.sqlalchemy` has been made a subpackage and split; `sqlalchemy_async` has been deprecated. Update your imports.
+ Add `.waiter` module. For now, non-functional.
+ Add those new utilities to `.sqlalchemy`: `BitSelector`, `secret_column`, `a_relationship`. Also removed dead batteries.
+ Add `ArgConfigSource` to `.configparse`
## 0.5.3 ## 0.5.3

View file

@ -23,6 +23,8 @@ import os
from typing import Any, Callable, Iterator, override from typing import Any, Callable, Iterator, override
from collections import OrderedDict from collections import OrderedDict
from argparse import Namespace
from .classtools import ValueSource, ValueProperty from .classtools import ValueSource, ValueProperty
from .functools import deprecated from .functools import deprecated
from .exceptions import MissingConfigError, MissingConfigWarning from .exceptions import MissingConfigError, MissingConfigWarning
@ -105,6 +107,28 @@ class DictConfigSource(ConfigSource):
def __len__(self) -> int: def __len__(self) -> int:
return len(self._d) return len(self._d)
class ArgConfigSource(ValueSource):
"""
It assumes arguments have already been parsed
NEW 0.6"""
_ns: Namespace
def __init__(self, ns: Namespace):
super().__init__()
self._ns = ns
def __getitem__(self, key):
return getattr(self._ns, key)
def get(self, key, value):
return getattr(self._ns, key, value)
def __contains__(self, key: str, /) -> bool:
return hasattr(self._ns, key)
@deprecated('Here for Mapping() implementation. Untested and unused')
def __iter__(self) -> Iterator[str]:
yield from self._ns._get_args()
@deprecated('Here for Mapping() implementation. Untested and unused')
def __len__(self) -> int:
return len(self._ns._get_args())
class ConfigValue(ValueProperty): class ConfigValue(ValueProperty):
""" """
A single config property. A single config property.
@ -205,7 +229,8 @@ class ConfigOptions:
__all__ = ( __all__ = (
'MissingConfigError', 'MissingConfigWarning', 'ConfigOptions', 'EnvConfigSource', 'ConfigParserConfigSource', 'DictConfigSource', 'ConfigSource', 'ConfigValue' 'MissingConfigError', 'MissingConfigWarning', 'ConfigOptions', 'EnvConfigSource', 'ConfigParserConfigSource', 'DictConfigSource', 'ConfigSource', 'ConfigValue',
'ArgConfigSource'
) )

View file

@ -20,10 +20,12 @@ from typing import Any, Callable, Never
from flask import abort, request from flask import abort, request
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import DeclarativeBase, Session from sqlalchemy.orm import DeclarativeBase, Session
from .functools import deprecated
from .codecs import want_bytes from .codecs import want_bytes
from .sqlalchemy import AuthSrc, require_auth_base from .sqlalchemy import AuthSrc, require_auth_base
@deprecated('inherits from deprecated and unused class')
class FlaskAuthSrc(AuthSrc): class FlaskAuthSrc(AuthSrc):
''' '''
@ -45,6 +47,7 @@ class FlaskAuthSrc(AuthSrc):
def required_exc(self): def required_exc(self):
abort(401, 'Login required') abort(401, 'Login required')
@deprecated('not intuitive to use')
def require_auth(cls: type[DeclarativeBase], db: SQLAlchemy) -> Callable[Any, Callable]: def require_auth(cls: type[DeclarativeBase], db: SQLAlchemy) -> Callable[Any, Callable]:
""" """
Make an auth_required() decorator for Flask views. Make an auth_required() decorator for Flask views.
@ -77,4 +80,4 @@ def require_auth(cls: type[DeclarativeBase], db: SQLAlchemy) -> Callable[Any, Ca
return auth_required return auth_required
# Optional dependency: do not import into __init__.py # Optional dependency: do not import into __init__.py
__all__ = ('require_auth', ) __all__ = ()

View file

@ -157,13 +157,17 @@ def require_auth_base(cls: type[DeclarativeBase], *, src: AuthSrc, column: str |
from .asyncio import SQLAlchemy, AsyncSelectPagination, async_query from .asyncio import SQLAlchemy, AsyncSelectPagination, async_query
from .orm import id_column, snowflake_column, match_column, match_constraint, bool_column, declarative_base, author_pair, age_pair, bound_fk, unbound_fk, want_column from .orm import (
id_column, snowflake_column, match_column, match_constraint, bool_column, declarative_base,
author_pair, age_pair, bound_fk, unbound_fk, want_column, a_relationship, BitSelector, secret_column
)
# Optional dependency: do not import into __init__.py # Optional dependency: do not import into __init__.py
__all__ = ( __all__ = (
'IdType', 'id_column', 'snowflake_column', 'entity_base', 'declarative_base', 'token_signer', 'IdType', 'id_column', 'snowflake_column', 'entity_base', 'declarative_base', 'token_signer',
'match_column', 'match_constraint', 'bool_column', 'parent_children', 'match_column', 'match_constraint', 'bool_column', 'parent_children',
'author_pair', 'age_pair', 'bound_fk', 'unbound_fk', 'want_column', 'author_pair', 'age_pair', 'bound_fk', 'unbound_fk', 'want_column',
'a_relationship', 'BitSelector', 'secret_column',
# .asyncio # .asyncio
'SQLAlchemy', 'AsyncSelectPagination', 'async_query' 'SQLAlchemy', 'AsyncSelectPagination', 'async_query'
) )

View file

@ -19,6 +19,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
from binascii import Incomplete from binascii import Incomplete
import os
from typing import Any, Callable from typing import Any, Callable
import warnings import warnings
from sqlalchemy import BigInteger, Boolean, CheckConstraint, Column, Date, ForeignKey, LargeBinary, MetaData, SmallInteger, String, text from sqlalchemy import BigInteger, Boolean, CheckConstraint, Column, Date, ForeignKey, LargeBinary, MetaData, SmallInteger, String, text
@ -167,6 +168,16 @@ def age_pair(*, nullable: bool = False, **ka) -> tuple[Column, Column]:
return (date_col, acc_col) return (date_col, acc_col)
def secret_column(length: int = 64, max_length: int | None = None, gen: Callable[[int], bytes] = os.urandom, nullable=False, **kwargs):
"""
Column filled in by default with random bits (64 by default). Useful for secrets.
NEW 0.6.0
"""
max_length = max_length or length
return Column(LargeBinary(max_length), default=lambda: gen(length), nullable=nullable, **kwargs)
def parent_children(keyword: str, /, *, lazy='selectin', **kwargs) -> tuple[Incomplete[Relationship[Any]], Incomplete[Relationship[Any]]]: def parent_children(keyword: str, /, *, lazy='selectin', **kwargs) -> tuple[Incomplete[Relationship[Any]], Incomplete[Relationship[Any]]]:
""" """
@ -191,6 +202,17 @@ def parent_children(keyword: str, /, *, lazy='selectin', **kwargs) -> tuple[Inco
return parent, child return parent, child
def a_relationship(primary = None, /, j=None, *, lazy='selectin', **kwargs):
"""
Shorthand for relationship() that sets lazy='selectin' automatically.
NEW 0.6.0
"""
if j:
kwargs['primaryjoin'] = j
return relationship(primary, lazy=lazy, **kwargs) # pyright: ignore[reportArgumentType]
def unbound_fk(target: str | Column | InstrumentedAttribute, typ: _T | None = None, **kwargs) -> Column[_T | IdType]: def unbound_fk(target: str | Column | InstrumentedAttribute, typ: _T | None = None, **kwargs) -> Column[_T | IdType]:
""" """
Shorthand for creating a "unbound" foreign key column from a column name, the referenced column. Shorthand for creating a "unbound" foreign key column from a column name, the referenced column.
@ -232,3 +254,62 @@ def bound_fk(target: str | Column | InstrumentedAttribute, typ: _T = None, **kwa
return Column(typ, ForeignKey(target_name, ondelete='CASCADE'), nullable=False, **kwargs) return Column(typ, ForeignKey(target_name, ondelete='CASCADE'), nullable=False, **kwargs)
class _BitComparator(Comparator):
"""
Comparator object for BitSelector()
NEW 0.6.0
"""
_column: Column
_flag: int
def __init__(self, col, flag):
self._column = col
self._flag = flag
def _bulk_update_tuples(self, value):
return [ (self._column, self._upd_exp(value)) ]
def operate(self, op, other, **kwargs):
return op(self._sel_exp(), self._flag if other else 0, **kwargs)
def __clause_element__(self):
return self._column
def __str__(self):
return self._column
def _sel_exp(self):
return self._column.op('&')(self._flag)
def _upd_exp(self, value):
return self._column.op('|')(self._flag) if value else self._column.op('&')(~self._flag)
class BitSelector:
"""
"Virtual" column representing a single bit in an integer column (usually a BigInteger).
Mimicks peewee's 'BitField()' behavior, with SQLAlchemy.
NEW 0.6.0
"""
_column: Column
_flag: int
_name: str
def __init__(self, column, flag: int):
if bin(flag := int(flag))[2:].rstrip('0') != '1':
warnings.warn('using non-powers of 2 as flags may cause errors or undefined behavior', FutureWarning)
self._column = column
self._flag = flag
def __set_name__(self, name, owner=None):
self._name = name
def __get__(self, obj, objtype=None):
if obj:
return getattr(obj, self._column.name) & self._flag > 0
else:
return _BitComparator(self._column, self._flag)
def __set__(self, obj, val):
if obj:
orig = getattr(obj, self._column.name)
if val:
orig |= self._flag
else:
orig &= ~(self._flag)
setattr(obj, self._column.name, orig)
else:
raise NotImplementedError

57
src/suou/waiter.py Normal file
View file

@ -0,0 +1,57 @@
"""
Content serving API over HTTP, based on Starlette.
NEW 0.6.0
---
Copyright (c) 2025 Sakuragasaki46.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
See LICENSE for the specific language governing permissions and
limitations under the License.
This software is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
import warnings
from starlette.applications import Starlette
from starlette.responses import JSONResponse, PlainTextResponse, Response
from starlette.routing import Route
class Waiter():
def __init__(self):
self.routes: list[Route] = []
self.production = False
def _build_app(self) -> Starlette:
return Starlette(
debug = not self.production,
routes= self.routes
)
## TODO get, post, etc.
def ok(content = None, **ka):
if content is None:
return Response(status_code=204, **ka)
elif isinstance(content, dict):
return JSONResponse(content, **ka)
elif isinstance(content, str):
return PlainTextResponse(content, **ka)
return content
def ko(status: int, /, content = None, **ka):
if status < 400 or status > 599:
warnings.warn(f'HTTP {status} is not an error status', UserWarning)
if content is None:
return Response(status_code=status, **ka)
elif isinstance(content, dict):
return JSONResponse(content, status_code=status, **ka)
elif isinstance(content, str):
return PlainTextResponse(content, status_code=status, **ka)
return content
__all__ = ('ko', 'ok', 'Waiter')