From bc4ea9b10191fcbd9f7769e47a485d665826ba33 Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Thu, 19 Jun 2025 14:39:26 +0200 Subject: [PATCH 001/121] fix missing imports --- src/suou/__init__.py | 2 +- src/suou/flask_sqlalchemy.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 97743c8..39a7b30 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -27,7 +27,7 @@ from .itertools import makelist, kwargs_prefix, ltuple, rtuple, additem from .i18n import I18n, JsonI18n, TomlI18n from .snowflake import Snowflake, SnowflakeGen -__version__ = "0.3.3" +__version__ = "0.3.4-dev24" __all__ = ( 'Siq', 'SiqCache', 'SiqType', 'SiqGen', 'StringCase', diff --git a/src/suou/flask_sqlalchemy.py b/src/suou/flask_sqlalchemy.py index 8ef0d12..6af4cec 100644 --- a/src/suou/flask_sqlalchemy.py +++ b/src/suou/flask_sqlalchemy.py @@ -22,7 +22,7 @@ from flask_sqlalchemy import SQLAlchemy from sqlalchemy.orm import DeclarativeBase, Session from .codecs import want_bytes -from .sqlalchemy import require_auth_base +from .sqlalchemy import AuthSrc, require_auth_base class FlaskAuthSrc(AuthSrc): ''' From 1c2bd11212fc2c333c969855f87c29cac433a7e7 Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Thu, 19 Jun 2025 14:58:30 +0200 Subject: [PATCH 002/121] bugfix #2 --- CHANGELOG.md | 4 ++++ src/suou/flask_sqlalchemy.py | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eab0c55..543a1d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.3.4 + +- Bug fixes in `.flask_sqlalchemy` + ## 0.3.3 - Fixed leftovers in `snowflake` module from unchecked code copying — i.e. `SnowflakeGen.generate_one()` used to require an unused typ= parameter diff --git a/src/suou/flask_sqlalchemy.py b/src/suou/flask_sqlalchemy.py index 6af4cec..7cd5b41 100644 --- a/src/suou/flask_sqlalchemy.py +++ b/src/suou/flask_sqlalchemy.py @@ -35,7 +35,8 @@ class FlaskAuthSrc(AuthSrc): def get_session(self) -> Session: return self.db.session def get_token(self): - return request.authorization.token + if request.authorization: + return request.authorization.token def get_signature(self) -> bytes: sig = request.headers.get('authorization-signature', None) return want_bytes(sig) if sig else None @@ -51,6 +52,9 @@ def require_auth(cls: type[DeclarativeBase], db: SQLAlchemy) -> Callable[Any, Ca This looks for a token in the Authorization header, validates it, loads the appropriate object, and injects it as the user= parameter. + NOTE: the actual decorator to be used on routes is **auth_required()**, + NOT require_auth() which is the **constructor** for it. + cls is a SQLAlchemy table. db is a flask_sqlalchemy.SQLAlchemy() binding. From 04ce86a43e15164906104e5c3b9ceed3fdaece0e Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 27 Jun 2025 22:28:38 +0200 Subject: [PATCH 003/121] release candidate for staging. BUGS ARE NOT FIXED YET. --- CHANGELOG.md | 2 +- src/suou/__init__.py | 2 +- src/suou/flask_restx.py | 3 +++ src/suou/flask_sqlalchemy.py | 11 ++++++++--- src/suou/sqlalchemy.py | 9 ++++----- 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 543a1d8..4011856 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 0.3.4 -- Bug fixes in `.flask_sqlalchemy` +- Bug fixes in `.flask_sqlalchemy` and `.sqlalchemy` — `require_auth()` is unusable before this point! ## 0.3.3 diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 39a7b30..51253f2 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -27,7 +27,7 @@ from .itertools import makelist, kwargs_prefix, ltuple, rtuple, additem from .i18n import I18n, JsonI18n, TomlI18n from .snowflake import Snowflake, SnowflakeGen -__version__ = "0.3.4-dev24" +__version__ = "0.3.4.rc1" __all__ = ( 'Siq', 'SiqCache', 'SiqType', 'SiqGen', 'StringCase', diff --git a/src/suou/flask_restx.py b/src/suou/flask_restx.py index 9d4955a..ecdf3da 100644 --- a/src/suou/flask_restx.py +++ b/src/suou/flask_restx.py @@ -54,7 +54,10 @@ class Api(_Api): Notably, all JSON is whitespace-free and .message is remapped to .error """ def handle_error(self, e): + ### XXX apparently this handle_error does not get called AT ALL. + print(e) res = super().handle_error(e) + print(res) if isinstance(res, Mapping) and 'message' in res: res['error'] = res['message'] del res['message'] diff --git a/src/suou/flask_sqlalchemy.py b/src/suou/flask_sqlalchemy.py index 7cd5b41..508e296 100644 --- a/src/suou/flask_sqlalchemy.py +++ b/src/suou/flask_sqlalchemy.py @@ -43,9 +43,9 @@ class FlaskAuthSrc(AuthSrc): def invalid_exc(self, msg: str = 'validation failed') -> Never: abort(400, msg) def required_exc(self): - abort(401) + abort(401, 'Login required') -def require_auth(cls: type[DeclarativeBase], db: SQLAlchemy) -> Callable[Any, Callable]: +def require_auth(cls: type[DeclarativeBase], db: SQLAlchemy) -> Callable: """ Make an auth_required() decorator for Flask views. @@ -67,7 +67,12 @@ def require_auth(cls: type[DeclarativeBase], db: SQLAlchemy) -> Callable[Any, Ca def super_secret_stuff(user): pass """ - return partial(require_auth_base, cls=cls, src=FlaskAuthSrc(db)) + def auth_required(**kwargs): + return require_auth_base(cls=cls, src=FlaskAuthSrc(db), **kwargs) + + auth_required.__doc__ = require_auth_base.__doc__ + + return auth_required __all__ = ('require_auth', ) diff --git a/src/suou/sqlalchemy.py b/src/suou/sqlalchemy.py index 2019424..8ff32a4 100644 --- a/src/suou/sqlalchemy.py +++ b/src/suou/sqlalchemy.py @@ -253,8 +253,7 @@ class AuthSrc(metaclass=ABCMeta): def require_auth_base(cls: type[DeclarativeBase], *, src: AuthSrc, column: str | Column[_T] = 'id', dest: str = 'user', - required: bool = False, signed: bool = False, sig_dest: str = 'signature', validators: Callable | Iterable[Callable] | None = None, - invalid_exc: Callable | None = None, required_exc: Callable | None = None): + required: bool = False, signed: bool = False, sig_dest: str = 'signature', validators: Callable | Iterable[Callable] | None = None): ''' Inject the current user into a view, given the Authorization: Bearer header. @@ -275,11 +274,11 @@ def require_auth_base(cls: type[DeclarativeBase], *, src: AuthSrc, column: str | except Exception: return None - def _default_invalid(msg: str): + def _default_invalid(msg: str = 'validation failed'): raise ValueError(msg) - invalid_exc = invalid_exc or _default_invalid - required_exc = required_exc or (lambda: _default_invalid()) + invalid_exc = src.invalid_exc or _default_invalid + required_exc = src.required_exc or (lambda: _default_invalid()) def decorator(func: Callable): @wraps(func) From 96d25e0e85b83cc2c1f010716aacf80698fe0cee Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Mon, 30 Jun 2025 13:51:03 +0200 Subject: [PATCH 004/121] fixes in .configparse --- CHANGELOG.md | 1 + src/suou/__init__.py | 2 +- src/suou/configparse.py | 12 ++++++++---- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4011856..3d5856c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.3.4 - Bug fixes in `.flask_sqlalchemy` and `.sqlalchemy` — `require_auth()` is unusable before this point! +- Fixed a bug in `.configparse` dealing with unset values from multiple sources ## 0.3.3 diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 51253f2..0f45634 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -27,7 +27,7 @@ from .itertools import makelist, kwargs_prefix, ltuple, rtuple, additem from .i18n import I18n, JsonI18n, TomlI18n from .snowflake import Snowflake, SnowflakeGen -__version__ = "0.3.4.rc1" +__version__ = "0.3.4.rc2" __all__ = ( 'Siq', 'SiqCache', 'SiqType', 'SiqGen', 'StringCase', diff --git a/src/suou/configparse.py b/src/suou/configparse.py index ace075f..d9a0506 100644 --- a/src/suou/configparse.py +++ b/src/suou/configparse.py @@ -20,7 +20,7 @@ from ast import TypeVar from collections.abc import Mapping from configparser import ConfigParser as _ConfigParser import os -from typing import Any, Callable, Iterable, Iterator +from typing import Any, Callable, Iterator from collections import OrderedDict from .functools import deprecated_alias @@ -78,6 +78,8 @@ class ConfigParserConfigSource(ConfigSource): _cfp: _ConfigParser def __init__(self, cfp: _ConfigParser): + if not isinstance(cfp, _ConfigParser): + raise TypeError(f'a ConfigParser object is required (got {cfp.__class__.__name__!r})') self._cfp = cfp def __getitem__(self, key: str, /) -> str: k1, _, k2 = key.partition('.') @@ -174,10 +176,12 @@ class ConfigValue: owner.expose(self._pub_name, name) def __get__(self, obj: ConfigOptions, owner=False): if self._val is MISSING: + v = MISSING for srckey, src in obj._srcs.items(): - v = src.get(self._srcs[srckey], MISSING) - if self._required and not v: - raise MissingConfigError(f'required config {self._src} not set!') + if srckey in self._srcs: + v = src.get(self._srcs[srckey], v) + if self._required and (not v or v is MISSING): + raise MissingConfigError(f'required config {self._srcs['default']} not set!') if v is MISSING: v = self._default if callable(self._cast): From f0a109983bf78d901b1bf196f887cad48656aa6a Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Tue, 1 Jul 2025 14:48:58 +0200 Subject: [PATCH 005/121] fix flask_restx error handler, update README.md, give up on fixing require_auth() on flask_restx --- CHANGELOG.md | 2 +- README.md | 2 +- src/suou/__init__.py | 2 +- src/suou/codecs.py | 2 +- src/suou/flask_restx.py | 18 +++++++++++++----- src/suou/flask_sqlalchemy.py | 2 ++ 6 files changed, 19 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d5856c..0895211 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 0.3.4 -- Bug fixes in `.flask_sqlalchemy` and `.sqlalchemy` — `require_auth()` is unusable before this point! +- Bug fixes in `.flask_restx` regarding error handling - Fixed a bug in `.configparse` dealing with unset values from multiple sources ## 0.3.3 diff --git a/README.md b/README.md index 5ae6d56..29ee187 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Good morning, my brother! Welcome the SUOU (SIS Unified Object Underarmor), a library for the management of the storage of objects into a database. -It provides utilities such as [SIQ](https://sakux.moe/protocols/siq.html), signing and generation of access tokens (on top of [ItsDangerous](https://github.com/pallets/itsdangerous)) and various utilities, including helpers for use in Flask and SQLAlchemy. +It provides utilities such as [SIQ](https://yusur.moe/protocols/siq.html), signing and generation of access tokens (on top of [ItsDangerous](https://github.com/pallets/itsdangerous)) and various utilities, including helpers for use in Flask and SQLAlchemy. **It is not an ORM** nor a replacement of it; it works along existing ORMs (currently only SQLAlchemy is supported lol). diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 0f45634..a0aa794 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -27,7 +27,7 @@ from .itertools import makelist, kwargs_prefix, ltuple, rtuple, additem from .i18n import I18n, JsonI18n, TomlI18n from .snowflake import Snowflake, SnowflakeGen -__version__ = "0.3.4.rc2" +__version__ = "0.3.4" __all__ = ( 'Siq', 'SiqCache', 'SiqType', 'SiqGen', 'StringCase', diff --git a/src/suou/codecs.py b/src/suou/codecs.py index 2bee255..3efe53f 100644 --- a/src/suou/codecs.py +++ b/src/suou/codecs.py @@ -227,7 +227,7 @@ def jsonencode(obj: dict, *, skipkeys: bool = True, separators: tuple[str, str] ''' return json.dumps(obj, skipkeys=skipkeys, separators=separators, default=_json_default(default), **kwargs) -jsondecode = deprecated('just use json.loads()')(json.loads) +jsondecode: Callable[Any, dict] = deprecated('just use json.loads()')(json.loads) def ssv_list(s: str, *, sep_chars = ',;') -> list[str]: """ diff --git a/src/suou/flask_restx.py b/src/suou/flask_restx.py index ecdf3da..8dd9d86 100644 --- a/src/suou/flask_restx.py +++ b/src/suou/flask_restx.py @@ -16,10 +16,10 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. from typing import Any, Mapping import warnings -from flask import current_app, make_response +from flask import current_app, Response, make_response from flask_restx import Api as _Api -from .codecs import jsonencode +from .codecs import jsondecode, jsonencode, want_bytes, want_str def output_json(data, code, headers=None): @@ -54,13 +54,21 @@ class Api(_Api): Notably, all JSON is whitespace-free and .message is remapped to .error """ def handle_error(self, e): - ### XXX apparently this handle_error does not get called AT ALL. - print(e) + ### XXX in order for errors to get handled the correct way, import + ### suou.flask_restx.Api() NOT flask_restx.Api() !!!! res = super().handle_error(e) - print(res) if isinstance(res, Mapping) and 'message' in res: res['error'] = res['message'] del res['message'] + elif isinstance(res, Response): + try: + body = want_str(res.response[0]) + bodj = jsondecode(body) + if 'message' in bodj: + bodj['error'] = bodj.pop('message') + res.response = [want_bytes(jsonencode(bodj))] + except (IndexError, KeyError): + pass return res def __init__(self, *a, **ka): super().__init__(*a, **ka) diff --git a/src/suou/flask_sqlalchemy.py b/src/suou/flask_sqlalchemy.py index 508e296..8c587e1 100644 --- a/src/suou/flask_sqlalchemy.py +++ b/src/suou/flask_sqlalchemy.py @@ -66,6 +66,8 @@ def require_auth(cls: type[DeclarativeBase], db: SQLAlchemy) -> Callable: @auth_required(validators=[lambda x: x.is_administrator]) def super_secret_stuff(user): pass + + NOTE: require_auth() DOES NOT work with flask_restx. """ def auth_required(**kwargs): return require_auth_base(cls=cls, src=FlaskAuthSrc(db), **kwargs) From 4ccf4fb7a074d1573614306b0b6a2ff68de638cd Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sun, 6 Jul 2025 22:40:17 +0200 Subject: [PATCH 006/121] backport bug fixes from 0.3.4 into master, reimplement ConfigSource() and add a superclass --- src/suou/forms.py | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/suou/forms.py diff --git a/src/suou/forms.py b/src/suou/forms.py new file mode 100644 index 0000000..552aed8 --- /dev/null +++ b/src/suou/forms.py @@ -0,0 +1,6 @@ +""" +Form validation, done right. + +Why this? Why not, let's say, WTForms or Marshmallow? Well, I have my reasons. +""" + From 4919edc87185d207caeeaffd33aa03d58a30451c Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sun, 6 Jul 2025 22:40:43 +0200 Subject: [PATCH 007/121] backport bug fixes from 0.3.4 into master, reimplement ConfigSource() and add a superclass, part 2 --- CHANGELOG.md | 9 ++ README.md | 2 +- pyproject.toml | 9 +- src/suou/__init__.py | 2 +- src/suou/classtools.py | 87 ++++++++++++- src/suou/codecs.py | 2 +- src/suou/configparse.py | 106 ++++++---------- src/suou/configparsev0_3.py | 239 +++++++++++++++++++++++++++++++++++ src/suou/exceptions.py | 33 +++++ src/suou/flask_restx.py | 15 ++- src/suou/flask_sqlalchemy.py | 19 ++- src/suou/forms.py | 2 + src/suou/iding.py | 9 +- src/suou/sqlalchemy.py | 9 +- 14 files changed, 452 insertions(+), 91 deletions(-) create mode 100644 src/suou/configparsev0_3.py create mode 100644 src/suou/exceptions.py diff --git a/CHANGELOG.md b/CHANGELOG.md index eab0c55..3536f9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.4.0 + ++ Added `ValueProperty`, abstract superclass for `ConfigProperty`. + +## 0.3.4 + +- Bug fixes in `.flask_restx` regarding error handling +- Fixed a bug in `.configparse` dealing with unset values from multiple sources + ## 0.3.3 - Fixed leftovers in `snowflake` module from unchecked code copying — i.e. `SnowflakeGen.generate_one()` used to require an unused typ= parameter diff --git a/README.md b/README.md index 5ae6d56..29ee187 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Good morning, my brother! Welcome the SUOU (SIS Unified Object Underarmor), a library for the management of the storage of objects into a database. -It provides utilities such as [SIQ](https://sakux.moe/protocols/siq.html), signing and generation of access tokens (on top of [ItsDangerous](https://github.com/pallets/itsdangerous)) and various utilities, including helpers for use in Flask and SQLAlchemy. +It provides utilities such as [SIQ](https://yusur.moe/protocols/siq.html), signing and generation of access tokens (on top of [ItsDangerous](https://github.com/pallets/itsdangerous)) and various utilities, including helpers for use in Flask and SQLAlchemy. **It is not an ORM** nor a replacement of it; it works along existing ORMs (currently only SQLAlchemy is supported lol). diff --git a/pyproject.toml b/pyproject.toml index 36540a6..58766fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,8 @@ readme = "README.md" dependencies = [ "itsdangerous", - "toml" + "toml", + "pydantic" ] # - further devdependencies below - # @@ -36,10 +37,12 @@ sqlalchemy = [ ] flask = [ "Flask>=2.0.0", - "Flask-RestX" + "Flask-RestX", + "Quart", + "Quart-Schema" ] flask_sqlalchemy = [ - "Flask-SqlAlchemy" + "Flask-SqlAlchemy", ] peewee = [ "peewee>=3.0.0, <4.0" diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 97743c8..4a14073 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -27,7 +27,7 @@ from .itertools import makelist, kwargs_prefix, ltuple, rtuple, additem from .i18n import I18n, JsonI18n, TomlI18n from .snowflake import Snowflake, SnowflakeGen -__version__ = "0.3.3" +__version__ = "0.4.0-dev26" __all__ = ( 'Siq', 'SiqCache', 'SiqType', 'SiqGen', 'StringCase', diff --git a/src/suou/classtools.py b/src/suou/classtools.py index ebe673b..34ad58b 100644 --- a/src/suou/classtools.py +++ b/src/suou/classtools.py @@ -14,10 +14,17 @@ This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. """ -from typing import Any, Callable, Generic, Iterable, TypeVar +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from typing import Any, Callable, Generic, Iterable, Mapping, TypeVar + +from suou.codecs import StringCase _T = TypeVar('_T') +MISSING = object() + class Wanted(Generic[_T]): """ Placeholder for parameters wanted by Incomplete(). @@ -98,6 +105,78 @@ class Incomplete(Generic[_T]): clsdict[k] = v.instance() return clsdict -__all__ = ( - 'Wanted', 'Incomplete' -) \ No newline at end of file + +class ValueSource(Mapping): + """ + Abstract value source. + """ + pass + + +class ValueProperty(Generic[_T]): + _name: str | None + _srcs: dict[str, str] + _val: Any | MISSING + _default: Any | None + _cast: Callable | None + _required: bool + _pub_name: str | bool = False + _not_found = LookupError + + def __init__(self, /, src: str | None = None, *, + default = None, cast: Callable | None = None, + required: bool = False, public: str | bool = False, + **kwargs + ): + self._srcs = dict() + if src: + self._srcs['default'] = src + self._default = default + self._cast = cast + self._required = required + self._pub_name = public + self._val = MISSING + for k, v in kwargs.items(): + if k.endswith('_src'): + self._srcs[k[:-4]] = v + else: + raise TypeError(f'unknown keyword argument {k!r}') + + def __set_name__(self, owner, name: str, *, src_name: str | None = None): + self._name = name + self._srcs.setdefault('default', src_name or name) + nsrcs = dict() + for k, v in self._srcs.items(): + if v.endswith('?'): + nsrcs[k] = v.rstrip('?') + (src_name or name) + self._srcs.update(nsrcs) + if self._pub_name is True: + self._pub_name = name + def __get__(self, obj: Any, owner = None): + if self._val is MISSING: + v = MISSING + for srckey, src in self._srcs.items(): + if (getter := self._getter(obj, srckey)): + v = getter.get(src, v) + if self._required and (not v or v is MISSING): + raise self._not_found(f'required config {self._srcs['default']} not set!') + if v is MISSING: + v = self._default + if callable(self._cast): + v = self._cast(v) if v is not None else self._cast() + self._val = v + return self._val + + @abstractmethod + def _getter(self, obj: Any, name: str = 'default') -> ValueSource: + pass + + @property + def name(self): + return self._name + + @property + def source(self, /): + return self._srcs['default'] + + diff --git a/src/suou/codecs.py b/src/suou/codecs.py index 2bee255..3efe53f 100644 --- a/src/suou/codecs.py +++ b/src/suou/codecs.py @@ -227,7 +227,7 @@ def jsonencode(obj: dict, *, skipkeys: bool = True, separators: tuple[str, str] ''' return json.dumps(obj, skipkeys=skipkeys, separators=separators, default=_json_default(default), **kwargs) -jsondecode = deprecated('just use json.loads()')(json.loads) +jsondecode: Callable[Any, dict] = deprecated('just use json.loads()')(json.loads) def ssv_list(s: str, *, sep_chars = ',;') -> list[str]: """ diff --git a/src/suou/configparse.py b/src/suou/configparse.py index ace075f..9709b0a 100644 --- a/src/suou/configparse.py +++ b/src/suou/configparse.py @@ -15,41 +15,27 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. """ from __future__ import annotations -from abc import abstractmethod + from ast import TypeVar from collections.abc import Mapping from configparser import ConfigParser as _ConfigParser import os -from typing import Any, Callable, Iterable, Iterator +from typing import Any, Callable, Iterator, override from collections import OrderedDict -from .functools import deprecated_alias +from .classtools import ValueSource, ValueProperty +from .functools import deprecated +from .exceptions import MissingConfigError, MissingConfigWarning + -MISSING = object() _T = TypeVar('T') -class MissingConfigError(LookupError): - """ - Config variable not found. - Raised when a config property is marked as required, but no property with - that name is found. - """ - pass - - -class MissingConfigWarning(MissingConfigError, Warning): - """ - A required config property is missing, and the application is assuming a default value. - """ - pass - - -class ConfigSource(Mapping): +class ConfigSource(ValueSource): ''' - Abstract config source. + Abstract config value source. ''' __slots__ = () @@ -78,6 +64,8 @@ class ConfigParserConfigSource(ConfigSource): _cfp: _ConfigParser def __init__(self, cfp: _ConfigParser): + if not isinstance(cfp, _ConfigParser): + raise TypeError(f'a ConfigParser object is required (got {cfp.__class__.__name__!r})') self._cfp = cfp def __getitem__(self, key: str, /) -> str: k1, _, k2 = key.partition('.') @@ -117,7 +105,7 @@ class DictConfigSource(ConfigSource): def __len__(self) -> int: return len(self._d) -class ConfigValue: +class ConfigValue(ValueProperty): """ A single config property. @@ -133,61 +121,43 @@ class ConfigValue: - preserve_case: if True, src is not CAPITALIZED. Useful for parsing from Python dictionaries or ConfigParser's - required: throw an error if empty or not supplied """ - # XXX disabled for https://stackoverflow.com/questions/45864273/slots-conflicts-with-a-class-variable-in-a-generic-class - #__slots__ = ('_srcs', '_val', '_default', '_cast', '_required', '_preserve_case') - - _srcs: dict[str, str] | None + _preserve_case: bool = False - _val: Any | MISSING = MISSING - _default: Any | None - _cast: Callable | None - _required: bool - _pub_name: str | bool = False + _prefix: str | None = None + _not_found = MissingConfigError + def __init__(self, /, src: str | None = None, *, default = None, cast: Callable | None = None, required: bool = False, preserve_case: bool = False, prefix: str | None = None, public: str | bool = False, **kwargs): - self._srcs = dict() self._preserve_case = preserve_case - if src: - self._srcs['default'] = src if preserve_case else src.upper() - elif prefix: - self._srcs['default'] = f'{prefix if preserve_case else prefix.upper}?' - self._default = default - self._cast = cast - self._required = required - self._pub_name = public - for k, v in kwargs.items(): - if k.endswith('_src'): - self._srcs[k[:-4]] = v + if src and not preserve_case: + src = src.upper() + if not src and prefix: + self._prefix = prefix + if not preserve_case: + src = f'{prefix.upper()}?' else: - raise TypeError(f'unknown keyword argument {k!r}') - def __set_name__(self, owner, name: str): - if 'default' not in self._srcs: - self._srcs['default'] = name if self._preserve_case else name.upper() - elif self._srcs['default'].endswith('?'): - self._srcs['default'] = self._srcs['default'].rstrip('?') + (name if self._preserve_case else name.upper() ) + src = f'{prefix}?' + + super().__init__(src, default=default, cast=cast, + required=required, public=public, **kwargs + ) - if self._pub_name is True: - self._pub_name = name + def __set_name__(self, owner, name: str): + src_name = name if self._preserve_case else name.upper() + + super().__set_name__(owner, name, src_name=src_name) + if self._pub_name and isinstance(owner, ConfigOptions): owner.expose(self._pub_name, name) - def __get__(self, obj: ConfigOptions, owner=False): - if self._val is MISSING: - for srckey, src in obj._srcs.items(): - v = src.get(self._srcs[srckey], MISSING) - if self._required and not v: - raise MissingConfigError(f'required config {self._src} not set!') - if v is MISSING: - v = self._default - if callable(self._cast): - v = self._cast(v) if v is not None else self._cast() - self._val = v - return self._val + - @property - def source(self, /): - return self._srcs['default'] + @override + def _getter(self, obj: ConfigOptions, name: str = 'default') -> ConfigSource: + if not isinstance(obj._srcs, Mapping): + raise RuntimeError('attempt to get config value with no source configured') + return obj._srcs.get(name) class ConfigOptions: @@ -216,7 +186,7 @@ class ConfigOptions: if first: self._srcs.move_to_end(key, False) - add_config_source = deprecated_alias(add_source) + add_config_source = deprecated('use add_source() instead')(add_source) def expose(self, public_name: str, attr_name: str | None = None) -> None: ''' diff --git a/src/suou/configparsev0_3.py b/src/suou/configparsev0_3.py new file mode 100644 index 0000000..282e248 --- /dev/null +++ b/src/suou/configparsev0_3.py @@ -0,0 +1,239 @@ +""" +Utilities for parsing config variables. + +BREAKING older, non-generalized version, kept for backwards compability +in case 0.4+ version happens to break. + +WILL BE removed in 0.5.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. +""" + +from __future__ import annotations +from ast import TypeVar +from collections.abc import Mapping +from configparser import ConfigParser as _ConfigParser +import os +from typing import Any, Callable, Iterator +from collections import OrderedDict +import warnings + +from .functools import deprecated +from .exceptions import MissingConfigError, MissingConfigWarning + +warnings.warn('This module will be removed in 0.5.0 and is kept only in case new implementation breaks!\n'\ + 'Do not use unless you know what you are doing.', DeprecationWarning) + +MISSING = object() +_T = TypeVar('T') + + +@deprecated('use configparse') +class ConfigSource(Mapping): + ''' + Abstract config source. + ''' + __slots__ = () + +@deprecated('use configparse') +class EnvConfigSource(ConfigSource): + ''' + Config source from os.environ aka .env + ''' + def __getitem__(self, key: str, /) -> str: + return os.environ[key] + def get(self, key: str, fallback = None, /): + return os.getenv(key, fallback) + def __contains__(self, key: str, /) -> bool: + return key in os.environ + def __iter__(self) -> Iterator[str]: + yield from os.environ + def __len__(self) -> int: + return len(os.environ) + +@deprecated('use configparse') +class ConfigParserConfigSource(ConfigSource): + ''' + Config source from ConfigParser + ''' + __slots__ = ('_cfp', ) + _cfp: _ConfigParser + + def __init__(self, cfp: _ConfigParser): + if not isinstance(cfp, _ConfigParser): + raise TypeError(f'a ConfigParser object is required (got {cfp.__class__.__name__!r})') + self._cfp = cfp + def __getitem__(self, key: str, /) -> str: + k1, _, k2 = key.partition('.') + return self._cfp.get(k1, k2) + def get(self, key: str, fallback = None, /): + k1, _, k2 = key.partition('.') + return self._cfp.get(k1, k2, fallback=fallback) + def __contains__(self, key: str, /) -> bool: + k1, _, k2 = key.partition('.') + return self._cfp.has_option(k1, k2) + def __iter__(self) -> Iterator[str]: + for k1, v1 in self._cfp.items(): + for k2 in v1: + yield f'{k1}.{k2}' + def __len__(self) -> int: + ## XXX might be incorrect but who cares + return sum(len(x) for x in self._cfp) + +@deprecated('use configparse') +class DictConfigSource(ConfigSource): + ''' + Config source from Python mappings. Useful with JSON/TOML config + ''' + __slots__ = ('_d',) + + _d: dict[str, Any] + + def __init__(self, mapping: dict[str, Any]): + self._d = mapping + def __getitem__(self, key: str, /) -> str: + return self._d[key] + def get(self, key: str, fallback: _T = None, /): + return self._d.get(key, fallback) + def __contains__(self, key: str, /) -> bool: + return key in self._d + def __iter__(self) -> Iterator[str]: + yield from self._d + def __len__(self) -> int: + return len(self._d) + +@deprecated('use configparse') +class ConfigValue: + """ + A single config property. + + By default, it is sourced from os.environ — i.e. environment variables, + and property name is upper cased. + + You can specify further sources, if the parent ConfigOptions class + supports them. + + Arguments: + - public: mark value as public, making it available across the app (e.g. in Jinja2 templates). + - prefix: src but for the lazy + - preserve_case: if True, src is not CAPITALIZED. Useful for parsing from Python dictionaries or ConfigParser's + - required: throw an error if empty or not supplied + """ + # XXX disabled per https://stackoverflow.com/questions/45864273/slots-conflicts-with-a-class-variable-in-a-generic-class + #__slots__ = ('_srcs', '_val', '_default', '_cast', '_required', '_preserve_case') + + _srcs: dict[str, str] | None + _preserve_case: bool = False + _val: Any | MISSING = MISSING + _default: Any | None + _cast: Callable | None + _required: bool + _pub_name: str | bool = False + def __init__(self, /, + src: str | None = None, *, default = None, cast: Callable | None = None, + required: bool = False, preserve_case: bool = False, prefix: str | None = None, + public: str | bool = False, **kwargs): + self._srcs = dict() + self._preserve_case = preserve_case + if src: + self._srcs['default'] = src if preserve_case else src.upper() + elif prefix: + self._srcs['default'] = f'{prefix if preserve_case else prefix.upper}?' + self._default = default + self._cast = cast + self._required = required + self._pub_name = public + for k, v in kwargs.items(): + if k.endswith('_src'): + self._srcs[k[:-4]] = v + else: + raise TypeError(f'unknown keyword argument {k!r}') + def __set_name__(self, owner, name: str): + if 'default' not in self._srcs: + self._srcs['default'] = name if self._preserve_case else name.upper() + elif self._srcs['default'].endswith('?'): + self._srcs['default'] = self._srcs['default'].rstrip('?') + (name if self._preserve_case else name.upper() ) + + if self._pub_name is True: + self._pub_name = name + if self._pub_name and isinstance(owner, ConfigOptions): + owner.expose(self._pub_name, name) + def __get__(self, obj: ConfigOptions, owner=False): + if self._val is MISSING: + v = MISSING + for srckey, src in obj._srcs.items(): + if srckey in self._srcs: + v = src.get(self._srcs[srckey], v) + if self._required and (not v or v is MISSING): + raise MissingConfigError(f'required config {self._srcs['default']} not set!') + if v is MISSING: + v = self._default + if callable(self._cast): + v = self._cast(v) if v is not None else self._cast() + self._val = v + return self._val + + @property + def source(self, /): + return self._srcs['default'] + +@deprecated('use configparse') +class ConfigOptions: + """ + Base class for loading config values. + + It is intended to get subclassed; config values must be defined as + ConfigValue() properties. + + Further config sources can be added with .add_source() + """ + + __slots__ = ('_srcs', '_pub') + + _srcs: OrderedDict[str, ConfigSource] + _pub: dict[str, str] + + def __init__(self, /): + self._srcs = OrderedDict( + default = EnvConfigSource() + ) + self._pub = dict() + + def add_source(self, key: str, csrc: ConfigSource, /, *, first: bool = False): + self._srcs[key] = csrc + if first: + self._srcs.move_to_end(key, False) + + add_config_source = deprecated_alias(add_source) + + def expose(self, public_name: str, attr_name: str | None = None) -> None: + ''' + Mark a config value as public. + + Called automatically by ConfigValue.__set_name__(). + ''' + attr_name = attr_name or public_name + self._pub[public_name] = attr_name + + def to_dict(self, /): + d = dict() + for k, v in self._pub.items(): + d[k] = getattr(self, v) + return d + + +__all__ = ( + 'MissingConfigError', 'MissingConfigWarning', 'ConfigOptions', 'EnvConfigSource', 'ConfigParserConfigSource', 'DictConfigSource', 'ConfigSource', 'ConfigValue' +) + + diff --git a/src/suou/exceptions.py b/src/suou/exceptions.py new file mode 100644 index 0000000..bc71037 --- /dev/null +++ b/src/suou/exceptions.py @@ -0,0 +1,33 @@ +""" +Exceptions and throwables for various purposes + +--- + +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. +""" + + + +class MissingConfigError(LookupError): + """ + Config variable not found. + + Raised when a config property is marked as required, but no property with + that name is found. + """ + pass + + +class MissingConfigWarning(MissingConfigError, Warning): + """ + A required config property is missing, and the application is assuming a default value. + """ + pass \ No newline at end of file diff --git a/src/suou/flask_restx.py b/src/suou/flask_restx.py index 9d4955a..cef777e 100644 --- a/src/suou/flask_restx.py +++ b/src/suou/flask_restx.py @@ -16,10 +16,10 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. from typing import Any, Mapping import warnings -from flask import current_app, make_response +from flask import Response, current_app, make_response from flask_restx import Api as _Api -from .codecs import jsonencode +from .codecs import jsondecode, jsonencode, want_bytes, want_str def output_json(data, code, headers=None): @@ -54,10 +54,21 @@ class Api(_Api): Notably, all JSON is whitespace-free and .message is remapped to .error """ def handle_error(self, e): + ### XXX in order for errors to get handled the correct way, import + ### suou.flask_restx.Api() NOT flask_restx.Api() !!!! res = super().handle_error(e) if isinstance(res, Mapping) and 'message' in res: res['error'] = res['message'] del res['message'] + elif isinstance(res, Response): + try: + body = want_str(res.response[0]) + bodj = jsondecode(body) + if 'message' in bodj: + bodj['error'] = bodj.pop('message') + res.response = [want_bytes(jsonencode(bodj))] + except (IndexError, KeyError): + pass return res def __init__(self, *a, **ka): super().__init__(*a, **ka) diff --git a/src/suou/flask_sqlalchemy.py b/src/suou/flask_sqlalchemy.py index 8ef0d12..5af6a8c 100644 --- a/src/suou/flask_sqlalchemy.py +++ b/src/suou/flask_sqlalchemy.py @@ -22,7 +22,7 @@ from flask_sqlalchemy import SQLAlchemy from sqlalchemy.orm import DeclarativeBase, Session from .codecs import want_bytes -from .sqlalchemy import require_auth_base +from .sqlalchemy import AuthSrc, require_auth_base class FlaskAuthSrc(AuthSrc): ''' @@ -35,14 +35,15 @@ class FlaskAuthSrc(AuthSrc): def get_session(self) -> Session: return self.db.session def get_token(self): - return request.authorization.token + if request.authorization: + return request.authorization.token def get_signature(self) -> bytes: sig = request.headers.get('authorization-signature', None) return want_bytes(sig) if sig else None def invalid_exc(self, msg: str = 'validation failed') -> Never: abort(400, msg) def required_exc(self): - abort(401) + abort(401, 'Login required') def require_auth(cls: type[DeclarativeBase], db: SQLAlchemy) -> Callable[Any, Callable]: """ @@ -51,6 +52,9 @@ def require_auth(cls: type[DeclarativeBase], db: SQLAlchemy) -> Callable[Any, Ca This looks for a token in the Authorization header, validates it, loads the appropriate object, and injects it as the user= parameter. + NOTE: the actual decorator to be used on routes is **auth_required()**, + NOT require_auth() which is the **constructor** for it. + cls is a SQLAlchemy table. db is a flask_sqlalchemy.SQLAlchemy() binding. @@ -62,8 +66,15 @@ def require_auth(cls: type[DeclarativeBase], db: SQLAlchemy) -> Callable[Any, Ca @auth_required(validators=[lambda x: x.is_administrator]) def super_secret_stuff(user): pass + + NOTE: require_auth() DOES NOT work with flask_restx. """ - return partial(require_auth_base, cls=cls, src=FlaskAuthSrc(db)) + def auth_required(**kwargs): + return require_auth_base(cls=cls, src=FlaskAuthSrc(db), **kwargs) + + auth_required.__doc__ = require_auth_base.__doc__ + + return auth_required __all__ = ('require_auth', ) diff --git a/src/suou/forms.py b/src/suou/forms.py index 552aed8..8f5318f 100644 --- a/src/suou/forms.py +++ b/src/suou/forms.py @@ -2,5 +2,7 @@ Form validation, done right. Why this? Why not, let's say, WTForms or Marshmallow? Well, I have my reasons. + +TODO """ diff --git a/src/suou/iding.py b/src/suou/iding.py index 6cfcd5e..2fe2364 100644 --- a/src/suou/iding.py +++ b/src/suou/iding.py @@ -41,7 +41,7 @@ from typing import Iterable, override import warnings from .functools import deprecated -from .codecs import b32lencode, b64encode, cb32encode +from .codecs import b32lencode, b64encode, cb32decode, cb32encode, want_str class SiqType(enum.Enum): @@ -225,6 +225,7 @@ class Siq(int): """ Representation of a SIQ as an integer. """ + def to_bytes(self, length: int = 14, byteorder = 'big', *, signed: bool = False) -> bytes: return super().to_bytes(length, byteorder, signed=signed) @classmethod @@ -236,7 +237,7 @@ class Siq(int): def to_base64(self, length: int = 15, *, strip: bool = True) -> str: return b64encode(self.to_bytes(length), strip=strip) def to_cb32(self) -> str: - return cb32encode(self.to_bytes(15, 'big')) + return cb32encode(self.to_bytes(15, 'big')).lstrip('0') to_crockford = to_cb32 def to_hex(self) -> str: return f'{self:x}' @@ -291,6 +292,10 @@ class Siq(int): raise ValueError('checksum mismatch') return cls(int.from_bytes(b, 'big')) + @classmethod + def from_cb32(cls, val: str | bytes): + return cls.from_bytes(cb32decode(want_str(val).zfill(24))) + def to_mastodon(self, /, domain: str | None = None): return f'@{self:u}{"@" if domain else ""}{domain}' def to_matrix(self, /, domain: str): diff --git a/src/suou/sqlalchemy.py b/src/suou/sqlalchemy.py index 2019424..249b104 100644 --- a/src/suou/sqlalchemy.py +++ b/src/suou/sqlalchemy.py @@ -253,8 +253,7 @@ class AuthSrc(metaclass=ABCMeta): def require_auth_base(cls: type[DeclarativeBase], *, src: AuthSrc, column: str | Column[_T] = 'id', dest: str = 'user', - required: bool = False, signed: bool = False, sig_dest: str = 'signature', validators: Callable | Iterable[Callable] | None = None, - invalid_exc: Callable | None = None, required_exc: Callable | None = None): + required: bool = False, signed: bool = False, sig_dest: str = 'signature', validators: Callable | Iterable[Callable] | None = None): ''' Inject the current user into a view, given the Authorization: Bearer header. @@ -275,11 +274,11 @@ def require_auth_base(cls: type[DeclarativeBase], *, src: AuthSrc, column: str | except Exception: return None - def _default_invalid(msg: str): + def _default_invalid(msg: str = 'Validation failed'): raise ValueError(msg) - invalid_exc = invalid_exc or _default_invalid - required_exc = required_exc or (lambda: _default_invalid()) + invalid_exc = src.invalid_exc or _default_invalid + required_exc = src.required_exc or (lambda: _default_invalid('Login required')) def decorator(func: Callable): @wraps(func) From 9c3755637a1d62981ac045e3594ed5f171f7f390 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Wed, 9 Jul 2025 16:17:26 +0200 Subject: [PATCH 008/121] fix bug with multiple sources in classtools --- src/suou/__init__.py | 2 +- src/suou/classtools.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 4a14073..9d12a76 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -27,7 +27,7 @@ from .itertools import makelist, kwargs_prefix, ltuple, rtuple, additem from .i18n import I18n, JsonI18n, TomlI18n from .snowflake import Snowflake, SnowflakeGen -__version__ = "0.4.0-dev26" +__version__ = "0.4.0-dev27" __all__ = ( 'Siq', 'SiqCache', 'SiqType', 'SiqGen', 'StringCase', diff --git a/src/suou/classtools.py b/src/suou/classtools.py index 34ad58b..6362b81 100644 --- a/src/suou/classtools.py +++ b/src/suou/classtools.py @@ -18,13 +18,19 @@ from __future__ import annotations from abc import ABCMeta, abstractmethod from typing import Any, Callable, Generic, Iterable, Mapping, TypeVar +import logging from suou.codecs import StringCase _T = TypeVar('_T') +logger = logging.getLogger(__name__) + MISSING = object() +def _not_missing(v) -> bool: + return v and v is not MISSING + class Wanted(Generic[_T]): """ Placeholder for parameters wanted by Incomplete(). @@ -106,6 +112,8 @@ class Incomplete(Generic[_T]): return clsdict +## Base classes for declarative argument / option parsers below + class ValueSource(Mapping): """ Abstract value source. @@ -158,6 +166,10 @@ class ValueProperty(Generic[_T]): for srckey, src in self._srcs.items(): if (getter := self._getter(obj, srckey)): v = getter.get(src, v) + if _not_missing(v): + if srckey != 'default': + logger.info(f'value {self._name} found in {srckey} source') + break if self._required and (not v or v is MISSING): raise self._not_found(f'required config {self._srcs['default']} not set!') if v is MISSING: From d9690ea3a5e832ee0b2fbfbc3db9fdae9ac506c9 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Wed, 9 Jul 2025 17:15:42 +0200 Subject: [PATCH 009/121] backport bugfixes from 0.3.6 --- CHANGELOG.md | 8 ++++++++ src/suou/classtools.py | 9 +++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3536f9b..134fd66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ + Added `ValueProperty`, abstract superclass for `ConfigProperty`. +## 0.3.6 + +- Fixed `ConfigValue` behavior with multiple sources. It used to iterate through all the sources, possibly overwriting; now, iteration stops at first non-missing value. + +## 0.3.5 + +- Fixed cb32 handling. Now leading zeros in SIQ's are stripped, and `.from_cb32()` was implemented. + ## 0.3.4 - Bug fixes in `.flask_restx` regarding error handling diff --git a/src/suou/classtools.py b/src/suou/classtools.py index 6362b81..eefdba3 100644 --- a/src/suou/classtools.py +++ b/src/suou/classtools.py @@ -170,10 +170,11 @@ class ValueProperty(Generic[_T]): if srckey != 'default': logger.info(f'value {self._name} found in {srckey} source') break - if self._required and (not v or v is MISSING): - raise self._not_found(f'required config {self._srcs['default']} not set!') - if v is MISSING: - v = self._default + if not _not_missing(v): + if self._required: + raise self._not_found(f'required config {self._srcs['default']} not set!') + else: + v = self._default if callable(self._cast): v = self._cast(v) if v is not None else self._cast() self._val = v From 4a2e8d3343213397143f3afdbb3c4125fcbf4247 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 11 Jul 2025 09:58:08 +0200 Subject: [PATCH 010/121] add addattr() --- CHANGELOG.md | 8 +++++--- src/suou/itertools.py | 26 +++++++++++++++++++++++--- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 134fd66..6cc0bfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,17 @@ ## 0.4.0 -+ Added `ValueProperty`, abstract superclass for `ConfigProperty`. ++ Added `ValueProperty`, abstract superclass for `ConfigProperty` ++ Changed the behavior of `makelist()`: now it can also decorate a callable, converting its return type to a list ++ Added `addattr()` ## 0.3.6 -- Fixed `ConfigValue` behavior with multiple sources. It used to iterate through all the sources, possibly overwriting; now, iteration stops at first non-missing value. +- Fixed `ConfigValue` behavior with multiple sources. It used to iterate through all the sources, possibly overwriting; now, iteration stops at first non-missing value ## 0.3.5 -- Fixed cb32 handling. Now leading zeros in SIQ's are stripped, and `.from_cb32()` was implemented. +- Fixed cb32 handling. Now leading zeros in SIQ's are stripped, and `.from_cb32()` was implemented ## 0.3.4 diff --git a/src/suou/itertools.py b/src/suou/itertools.py index 9f80faa..db1243c 100644 --- a/src/suou/itertools.py +++ b/src/suou/itertools.py @@ -14,20 +14,28 @@ This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ''' +from functools import wraps from typing import Any, Iterable, MutableMapping, TypeVar import warnings +from suou.classtools import MISSING + _T = TypeVar('_T') -def makelist(l: Any) -> list: +def makelist(l: Any, *, wrap: bool = True) -> list: ''' Make a list out of an iterable or a single value. + + NEW 0.4.0: Now supports a callable: can be used to decorate generators and turn them into lists. + Pass wrap=False to return instead the unwrapped function in a list. ''' + if callable(l) and wrap: + return wraps(l)(lambda *a, **k: makelist(l(*a, **k), wrap=False)) if isinstance(l, (str, bytes, bytearray)): return [l] elif isinstance(l, Iterable): return list(l) - elif l in (None, NotImplemented, Ellipsis): + elif l in (None, NotImplemented, Ellipsis, MISSING): return [] else: return [l] @@ -83,6 +91,18 @@ def additem(obj: MutableMapping, /, name: str = None): return func return decorator +def addattr(obj: Any, /, name: str = None): + """ + Same as additem() but setting as attribute instead. + """ + def decorator(func): + key = name or func.__name__ + if hasattr(obj, key): + warnings.warn(f'object does already have attribute {key!r}') + setattr(obj, key, func) + return func + return decorator -__all__ = ('makelist', 'kwargs_prefix', 'ltuple', 'rtuple', 'additem') + +__all__ = ('makelist', 'kwargs_prefix', 'ltuple', 'rtuple', 'additem', 'addattr') From ee36616b43a50dbfd566c63a295b29ae47f32fae Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Thu, 17 Jul 2025 19:45:43 +0200 Subject: [PATCH 011/121] new module lex --- CHANGELOG.md | 3 +- src/suou/__init__.py | 17 +++++---- src/suou/exceptions.py | 15 +++++++- src/suou/lex.py | 84 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 10 deletions(-) create mode 100644 src/suou/lex.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cc0bfb..b92eb48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ ## 0.4.0 + Added `ValueProperty`, abstract superclass for `ConfigProperty` -+ Changed the behavior of `makelist()`: now it can also decorate a callable, converting its return type to a list ++ \[BREAKING] Changed the behavior of `makelist()`: now it's also a decorator, converting its return type to a list (revertable with `wrap=False`) ++ New module `lex` with functions `symbol_table()` and `lex()` — make tokenization more affordable + Added `addattr()` ## 0.3.6 diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 9d12a76..94d793b 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -27,13 +27,16 @@ from .itertools import makelist, kwargs_prefix, ltuple, rtuple, additem from .i18n import I18n, JsonI18n, TomlI18n from .snowflake import Snowflake, SnowflakeGen -__version__ = "0.4.0-dev27" +__version__ = "0.4.0-dev28" __all__ = ( - 'Siq', 'SiqCache', 'SiqType', 'SiqGen', 'StringCase', - 'MissingConfigError', 'MissingConfigWarning', 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', 'EnvConfigSource', 'DictConfigSource', - 'deprecated', 'not_implemented', 'Wanted', 'Incomplete', 'jsonencode', 'ltuple', 'rtuple', - 'makelist', 'kwargs_prefix', 'I18n', 'JsonI18n', 'TomlI18n', 'cb32encode', 'cb32decode', 'count_ones', 'mask_shift', - 'want_bytes', 'want_str', 'version', 'b2048encode', 'split_bits', 'join_bits', 'b2048decode', - 'Snowflake', 'SnowflakeGen', 'ssv_list', 'additem', 'b32lencode', 'b32ldecode', 'b64encode', 'b64decode' + 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', + 'DictConfigSource', 'EnvConfigSource', 'I18n', 'Incomplete', 'JsonI18n', + 'MissingConfigError', 'MissingConfigWarning', 'Siq', 'SiqCache', 'SiqGen', + 'SiqType', 'Snowflake', 'SnowflakeGen', 'StringCase', 'TomlI18n', 'Wanted', + 'additem', 'b2048decode', 'b2048encode', 'b32ldecode', 'b32lencode', + 'b64encode', 'b64decode', 'cb32encode', 'cb32decode', 'count_ones', + 'deprecated', 'join_bits', 'jsonencode', 'kwargs_prefix', 'ltuple', + 'makelist', 'mask_shift', 'not_implemented', 'rtuple', 'split_bits', + 'ssv_list', 'want_bytes', 'want_str' ) diff --git a/src/suou/exceptions.py b/src/suou/exceptions.py index bc71037..e6382c0 100644 --- a/src/suou/exceptions.py +++ b/src/suou/exceptions.py @@ -14,7 +14,7 @@ This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. """ - +from .functools import deprecated class MissingConfigError(LookupError): """ @@ -30,4 +30,15 @@ class MissingConfigWarning(MissingConfigError, Warning): """ A required config property is missing, and the application is assuming a default value. """ - pass \ No newline at end of file + pass + + +class LexError(SyntaxError): + """ + Illegal character or sequence found in the token stream. + """ + +class InconsistencyError(RuntimeError): + """ + This program is in a state which it's not supposed to be in. + """ diff --git a/src/suou/lex.py b/src/suou/lex.py new file mode 100644 index 0000000..086023f --- /dev/null +++ b/src/suou/lex.py @@ -0,0 +1,84 @@ +""" +Utilities for tokenization of text. + +--- +""" + +from re import Match + + +from dataclasses import dataclass +import re +from typing import Any, Callable, Iterable + +from .exceptions import InconsistencyError, LexError + +from .itertools import makelist + + +@dataclass +class TokenSym: + pattern: str + label: str + cast: Callable[[str], Any] | None = None + discard: bool = False + + # convenience methods below + def match(self, s: str, index: int = 0) -> Match[str] | None: + return re.compile(self.pattern, 0).match(s, index) + +@makelist +def symbol_table(*args: Iterable[tuple | TokenSym], whitespace: str | None = None): + """ + Make a symbol table from a list of tuples. + + Tokens are in form (pattern, label[, cast]) where: + - [] means optional + - pattern is a regular expression (r-string syntax advised) + - label is a constant string + - cast is a function + + Need to strip whitespace? Pass the whitespace= keyword parameter. + """ + for arg in args: + if isinstance(arg, TokenSym): + pass + elif isinstance(arg, tuple): + arg = TokenSym(*arg) + else: + raise TypeError(f'invalid type {arg.__class__.__name__!r}') + yield arg + if whitespace: + yield TokenSym('[' + re.escape(whitespace) + ']+', '', discard=True) + + + +def ilex(text: str, table: Iterable[TokenSym], *, whitespace = False): + """ + Return a text as a list of tokens, given a token table (iterable of TokenSym). + + ilex() returns a generator; lex() returns a list. + + table must be a result from symbol_table(). + """ + i = 0 + while i < len(text): + mo = None + for sym in table: + if mo := re.compile(sym.pattern).match(text, i): + if not sym.discard: + mtext = mo.group(0) + if callable(sym.cast): + mtext = sym.cast(mtext) + yield (sym.label, mtext) + elif whitespace: + yield (None, mo.group(0)) + break + if mo is None: + raise LexError(f'illegal character near {text[i:i+5]!r}') + if i == mo.end(0): + raise InconsistencyError + i = mo.end(0) + +lex = makelist(ilex) + From e5ca63953d038cc2d591ce025c19cb2e421eec08 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Thu, 17 Jul 2025 21:33:11 +0200 Subject: [PATCH 012/121] add module .dorks and flask.harden() --- .gitignore | 1 + CHANGELOG.md | 1 + src/suou/__init__.py | 5 +++-- src/suou/dorks.py | 28 ++++++++++++++++++++++++++++ src/suou/exceptions.py | 6 ++++-- src/suou/flask.py | 20 ++++++++++++++++++-- src/suou/flask_restx.py | 2 +- src/suou/flask_sqlalchemy.py | 2 +- src/suou/itertools.py | 4 ++-- src/suou/lex.py | 4 +++- src/suou/peewee.py | 2 +- src/suou/sqlalchemy.py | 2 +- src/suou/validators.py | 2 +- 13 files changed, 65 insertions(+), 14 deletions(-) create mode 100644 src/suou/dorks.py diff --git a/.gitignore b/.gitignore index 2e2c6b7..7201aa6 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ dist/ .err .vscode /run.sh +ROADMAP.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b92eb48..ce764a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ + Added `ValueProperty`, abstract superclass for `ConfigProperty` + \[BREAKING] Changed the behavior of `makelist()`: now it's also a decorator, converting its return type to a list (revertable with `wrap=False`) + New module `lex` with functions `symbol_table()` and `lex()` — make tokenization more affordable ++ Add `dorks` module and `flask.harden()` + Added `addattr()` ## 0.3.6 diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 94d793b..a3dfff1 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -26,6 +26,7 @@ from .classtools import Wanted, Incomplete from .itertools import makelist, kwargs_prefix, ltuple, rtuple, additem from .i18n import I18n, JsonI18n, TomlI18n from .snowflake import Snowflake, SnowflakeGen +from .lex import symbol_table, lex, ilex __version__ = "0.4.0-dev28" @@ -36,7 +37,7 @@ __all__ = ( 'SiqType', 'Snowflake', 'SnowflakeGen', 'StringCase', 'TomlI18n', 'Wanted', 'additem', 'b2048decode', 'b2048encode', 'b32ldecode', 'b32lencode', 'b64encode', 'b64decode', 'cb32encode', 'cb32decode', 'count_ones', - 'deprecated', 'join_bits', 'jsonencode', 'kwargs_prefix', 'ltuple', + 'deprecated', 'ilex', 'join_bits', 'jsonencode', 'kwargs_prefix', 'lex', 'ltuple', 'makelist', 'mask_shift', 'not_implemented', 'rtuple', 'split_bits', - 'ssv_list', 'want_bytes', 'want_str' + 'ssv_list', 'symbol_table', 'want_bytes', 'want_str' ) diff --git a/src/suou/dorks.py b/src/suou/dorks.py new file mode 100644 index 0000000..cf03ca5 --- /dev/null +++ b/src/suou/dorks.py @@ -0,0 +1,28 @@ +""" +Web app hardening and PT utilities. + +--- + +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. +""" + +SENSITIVE_ENDPOINTS = """ +/.git +/.gitignore +/node_modules +/wp-admin +/wp-login.php +/.ht +/package.json +/package-lock.json +/composer. +""".split() + diff --git a/src/suou/exceptions.py b/src/suou/exceptions.py index e6382c0..170125f 100644 --- a/src/suou/exceptions.py +++ b/src/suou/exceptions.py @@ -14,8 +14,6 @@ This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. """ -from .functools import deprecated - class MissingConfigError(LookupError): """ Config variable not found. @@ -42,3 +40,7 @@ class InconsistencyError(RuntimeError): """ This program is in a state which it's not supposed to be in. """ + +__all__ = ( + 'MissingConfigError', 'MissingConfigWarning', 'LexError', 'InconsistencyError' +) \ No newline at end of file diff --git a/src/suou/flask.py b/src/suou/flask.py index 97f1b16..a2ce4f9 100644 --- a/src/suou/flask.py +++ b/src/suou/flask.py @@ -15,9 +15,10 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. """ from typing import Any -from flask import Flask, current_app, g, request +from flask import Flask, abort, current_app, g, request from .i18n import I18n from .configparse import ConfigOptions +from .dorks import SENSITIVE_ENDPOINTS def add_context_from_config(app: Flask, config: ConfigOptions) -> Flask: @@ -66,6 +67,21 @@ def get_flask_conf(key: str, default = None, *, app: Flask | None = None) -> Any app = current_app return app.config.get(key, default) -__all__ = ('add_context_from_config', 'add_i18n', 'get_flask_conf') +## XXX UNTESTED! +def harden(app: Flask): + """ + Make common "dork" endpoints unavailable + """ + i = 1 + for ep in SENSITIVE_ENDPOINTS: + @app.route(f'{ep}', name=f'unavailable_{i}') + def unavailable(rest): + abort(403) + i += 1 + + return app + +# Optional dependency: do not import into __init__.py +__all__ = ('add_context_from_config', 'add_i18n', 'get_flask_conf', 'harden') diff --git a/src/suou/flask_restx.py b/src/suou/flask_restx.py index cef777e..bdddf04 100644 --- a/src/suou/flask_restx.py +++ b/src/suou/flask_restx.py @@ -74,5 +74,5 @@ class Api(_Api): super().__init__(*a, **ka) self.representations['application/json'] = output_json - +# Optional dependency: do not import into __init__.py __all__ = ('Api',) \ No newline at end of file diff --git a/src/suou/flask_sqlalchemy.py b/src/suou/flask_sqlalchemy.py index 5af6a8c..0704460 100644 --- a/src/suou/flask_sqlalchemy.py +++ b/src/suou/flask_sqlalchemy.py @@ -76,5 +76,5 @@ def require_auth(cls: type[DeclarativeBase], db: SQLAlchemy) -> Callable[Any, Ca return auth_required - +# Optional dependency: do not import into __init__.py __all__ = ('require_auth', ) diff --git a/src/suou/itertools.py b/src/suou/itertools.py index db1243c..abcfdfe 100644 --- a/src/suou/itertools.py +++ b/src/suou/itertools.py @@ -15,14 +15,14 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ''' from functools import wraps -from typing import Any, Iterable, MutableMapping, TypeVar +from typing import Any, Callable, Iterable, MutableMapping, TypeVar import warnings from suou.classtools import MISSING _T = TypeVar('_T') -def makelist(l: Any, *, wrap: bool = True) -> list: +def makelist(l: Any, *, wrap: bool = True) -> list | Callable[Any, list]: ''' Make a list out of an iterable or a single value. diff --git a/src/suou/lex.py b/src/suou/lex.py index 086023f..15791c3 100644 --- a/src/suou/lex.py +++ b/src/suou/lex.py @@ -52,6 +52,7 @@ def symbol_table(*args: Iterable[tuple | TokenSym], whitespace: str | None = Non yield TokenSym('[' + re.escape(whitespace) + ']+', '', discard=True) +symbol_table: Callable[..., list] def ilex(text: str, table: Iterable[TokenSym], *, whitespace = False): """ @@ -80,5 +81,6 @@ def ilex(text: str, table: Iterable[TokenSym], *, whitespace = False): raise InconsistencyError i = mo.end(0) -lex = makelist(ilex) +lex: Callable[..., list] = makelist(ilex) +__all__ = ('symbol_table', 'lex', 'ilex') \ No newline at end of file diff --git a/src/suou/peewee.py b/src/suou/peewee.py index f5b9403..f1a3f1e 100644 --- a/src/suou/peewee.py +++ b/src/suou/peewee.py @@ -117,6 +117,6 @@ class SiqField(Field): def python_value(self, value: bytes) -> Siq: return Siq.from_bytes(value) - +# Optional dependency: do not import into __init__.py __all__ = ('connect_reconnect', 'RegexCharField', 'SiqField') diff --git a/src/suou/sqlalchemy.py b/src/suou/sqlalchemy.py index 249b104..edd1b02 100644 --- a/src/suou/sqlalchemy.py +++ b/src/suou/sqlalchemy.py @@ -295,7 +295,7 @@ def require_auth_base(cls: type[DeclarativeBase], *, src: AuthSrc, column: str | return wrapper return decorator - +# Optional dependency: do not import into __init__.py __all__ = ( 'IdType', 'id_column', 'entity_base', 'declarative_base', 'token_signer', 'match_column', 'match_constraint', 'author_pair', 'age_pair', 'require_auth_base', 'want_column' diff --git a/src/suou/validators.py b/src/suou/validators.py index b79882a..037d2b6 100644 --- a/src/suou/validators.py +++ b/src/suou/validators.py @@ -1,5 +1,5 @@ """ -Utilities for marshmallow, a schema-agnostic serializer/deserializer. +Miscellaneous validator closures. --- From 8a16fe159f85161ef4bca1ae460a13381636117d Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sat, 19 Jul 2025 11:31:01 +0200 Subject: [PATCH 013/121] add PrefixIdentifier() and some tests --- CHANGELOG.md | 3 ++- pyproject.toml | 19 +++++++++++++--- src/suou/sqlalchemy.py | 14 ++++++++++-- src/suou/strtools.py | 44 +++++++++++++++++++++++++++++++++++++ tests/test_codecs.py | 50 ++++++++++++++++++++++++++++++++++++++++++ tests/test_strtools.py | 38 ++++++++++++++++++++++++++++++++ 6 files changed, 162 insertions(+), 6 deletions(-) create mode 100644 src/suou/strtools.py create mode 100644 tests/test_codecs.py create mode 100644 tests/test_strtools.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ce764a9..6b70a17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,8 @@ + \[BREAKING] Changed the behavior of `makelist()`: now it's also a decorator, converting its return type to a list (revertable with `wrap=False`) + New module `lex` with functions `symbol_table()` and `lex()` — make tokenization more affordable + Add `dorks` module and `flask.harden()` -+ Added `addattr()` ++ Add `sqlalchemy.bool_column()`: make making flags painless ++ Added `addattr()`, `PrefixIdentifier()` ## 0.3.6 diff --git a/pyproject.toml b/pyproject.toml index 58766fa..9ef670d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,9 +37,7 @@ sqlalchemy = [ ] flask = [ "Flask>=2.0.0", - "Flask-RestX", - "Quart", - "Quart-Schema" + "Flask-RestX" ] flask_sqlalchemy = [ "Flask-SqlAlchemy", @@ -50,6 +48,21 @@ peewee = [ markdown = [ "markdown>=3.0.0" ] +quart = [ + "Flask>=2.0.0", + "Quart", + "Quart-Schema", + "uvloop; os_name=='posix'" +] + +full = [ + "sakuragasaki46-suou[sqlalchemy]", + "sakuragasaki46-suou[flask]", + "sakuragasaki46-suou[quart]", + "sakuragasaki46-suou[peewee]", + "sakuragasaki46-suou[markdown]" +] + [tool.setuptools.dynamic] version = { attr = "suou.__version__" } diff --git a/src/suou/sqlalchemy.py b/src/suou/sqlalchemy.py index edd1b02..b16cae7 100644 --- a/src/suou/sqlalchemy.py +++ b/src/suou/sqlalchemy.py @@ -20,7 +20,7 @@ from abc import ABCMeta, abstractmethod from functools import wraps from typing import Callable, Iterable, Never, TypeVar import warnings -from sqlalchemy import BigInteger, CheckConstraint, Date, Dialect, ForeignKey, LargeBinary, Column, MetaData, SmallInteger, String, create_engine, select, text +from sqlalchemy import BigInteger, Boolean, CheckConstraint, Date, Dialect, ForeignKey, LargeBinary, Column, MetaData, SmallInteger, String, create_engine, select, text from sqlalchemy.orm import DeclarativeBase, Session, declarative_base as _declarative_base, relationship from .snowflake import SnowflakeGen @@ -120,7 +120,17 @@ def match_column(length: int, regex: str, /, case: StringCase = StringCase.AS_IS constraint_name=constraint_name or f'{x.__tablename__}_{n}_valid')), *args, **kwargs) -def declarative_base(domain_name: str, master_secret: bytes, metadata: dict | None = None, **kwargs): +def bool_column(value: bool = False, nullable: bool = False, **kwargs): + """ + Column for a single boolean value. + + NEW in 0.4.0 + """ + def_val = text('true') if value else text('false') + return Column(Boolean, server_default=def_val, nullable=nullable, **kwargs) + + +def declarative_base(domain_name: str, master_secret: bytes, metadata: dict | None = None, **kwargs) -> DeclarativeBase: """ Drop-in replacement for sqlalchemy.orm.declarative_base() taking in account requirements for SIQ generation (i.e. domain name). diff --git a/src/suou/strtools.py b/src/suou/strtools.py new file mode 100644 index 0000000..2381953 --- /dev/null +++ b/src/suou/strtools.py @@ -0,0 +1,44 @@ +""" +Utilities for string manipulation. + +Why `strtools`? Why not `string`? I just~ happen to not like it + +--- + +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. +""" + + +from typing import Callable, Iterable + +from .itertools import makelist + +class PrefixIdentifier: + _prefix: str + + def __init__(self, prefix: str | None, validators: Iterable[Callable[[str], bool]] | Callable[[str], bool] | None = None): + prefix = '' if prefix is None else prefix + if not isinstance(prefix, str): + raise TypeError + validators = makelist(validators, wrap=False) + for validator in validators: + if not validator(prefix): + raise ValueError('invalid prefix') + self._prefix = prefix + + def __getattr__(self, key: str): + return f'{self._prefix}{key}' + + def __getitem__(self, key: str) -> str: + return f'{self._prefix}{key}' + +__all__ = ('PrefixIdentifier',) + diff --git a/tests/test_codecs.py b/tests/test_codecs.py new file mode 100644 index 0000000..7ac70d6 --- /dev/null +++ b/tests/test_codecs.py @@ -0,0 +1,50 @@ + + +import binascii +import unittest +from suou.codecs import b64encode, b64decode + +B1 = b'N\xf0\xb4\xc3\x85\n\xf9\xb6\x9a\x0f\x82\xa6\x99G\x07#' +B2 = b'\xbcXiF,@|{\xbe\xe3\x0cz\xa8\xcbQ\x82' +B3 = b"\xe9\x18)\xcb'\xc2\x96\xae\xde\x86" +B4 = B1[-2:] + B2[:-2] +B5 = b'\xff\xf8\xa7\x8a\xdf\xff' + + +class TestCodecs(unittest.TestCase): + def setUp(self) -> None: + ... + def tearDown(self) -> None: + ... + + #def runTest(self): + # self.test_b64encode() + # self.test_b64decode() + + def test_b64encode(self): + self.assertEqual(b64encode(B1), 'TvC0w4UK-baaD4KmmUcHIw') + self.assertEqual(b64encode(B2), 'vFhpRixAfHu-4wx6qMtRgg') + self.assertEqual(b64encode(B3), '6RgpyyfClq7ehg') + self.assertEqual(b64encode(B4), 'ByO8WGlGLEB8e77jDHqoyw') + self.assertEqual(b64encode(B5), '__init__') + self.assertEqual(b64encode(B1[:4]), 'TvC0ww') + self.assertEqual(b64encode(b'\0' + B1[:4]), 'AE7wtMM') + self.assertEqual(b64encode(b'\0\0\0\0\0' + B1[:4]), 'AAAAAABO8LTD') + self.assertEqual(b64encode(b'\xff'), '_w') + self.assertEqual(b64encode(b''), '') + + def test_b64decode(self): + self.assertEqual(b64decode('TvC0w4UK-baaD4KmmUcHIw'), B1) + self.assertEqual(b64decode('vFhpRixAfHu-4wx6qMtRgg'), B2) + self.assertEqual(b64decode('6RgpyyfClq7ehg'), B3) + self.assertEqual(b64decode('ByO8WGlGLEB8e77jDHqoyw'), B4) + self.assertEqual(b64decode('__init__'), B5) + self.assertEqual(b64decode('TvC0ww'), B1[:4]) + self.assertEqual(b64decode('AE7wtMM'), b'\0' + B1[:4]) + self.assertEqual(b64decode('AAAAAABO8LTD'), b'\0\0\0\0\0' + B1[:4]) + self.assertEqual(b64decode('_w'), b'\xff') + self.assertEqual(b64decode(''), b'') + + self.assertRaises(binascii.Error, b64decode, 'C') + + diff --git a/tests/test_strtools.py b/tests/test_strtools.py new file mode 100644 index 0000000..d07ed88 --- /dev/null +++ b/tests/test_strtools.py @@ -0,0 +1,38 @@ + + + +import unittest + +from suou.strtools import PrefixIdentifier + +class TestStrtools(unittest.TestCase): + def setUp(self) -> None: + ... + + def tearDown(self) -> None: + ... + + def test_PrefixIdentifier_empty(self): + pi = PrefixIdentifier(None) + self.assertEqual(pi.hello, 'hello') + self.assertEqual(pi['with spaces'], 'with spaces') + self.assertEqual(pi['\x1b\x00'], '\x1b\0') + self.assertEqual(pi.same_thing, pi['same_thing']) + + with self.assertRaises(TypeError): + pi[0] + + self.assertEqual(PrefixIdentifier(None), PrefixIdentifier('')) + + def test_PrefixIdentifier_invalid(self): + with self.assertRaises(TypeError): + pi = PrefixIdentifier(1) + pi.hello + + with self.assertRaises(TypeError): + PrefixIdentifier([99182]) + + with self.assertRaises(TypeError): + PrefixIdentifier(b'alpha_') + + \ No newline at end of file From 3188b59c1507a6dcc528a4efb595b9d5b8ad62ad Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sat, 19 Jul 2025 23:09:16 +0200 Subject: [PATCH 014/121] add mod_floor() and mod_ceil(), fix b64*() taking a wrong turn --- CHANGELOG.md | 8 +++++++- src/suou/__init__.py | 11 ++++++----- src/suou/bits.py | 18 ++++++++++++++++-- src/suou/codecs.py | 43 ++++++++++++++++++++++++++++++++++++++---- src/suou/strtools.py | 6 ++++++ tests/test_codecs.py | 8 ++++++-- tests/test_strtools.py | 10 ++++++---- 7 files changed, 86 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b70a17..f767253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,13 @@ + New module `lex` with functions `symbol_table()` and `lex()` — make tokenization more affordable + Add `dorks` module and `flask.harden()` + Add `sqlalchemy.bool_column()`: make making flags painless -+ Added `addattr()`, `PrefixIdentifier()` ++ Introduce `rb64encode()` and `rb64decode()` to deal with issues about Base64 and padding ++ Added `addattr()`, `PrefixIdentifier()`, `mod_floor()`, `mod_ceil()` ++ First version to have unit tests! + +## 0.3.7 + +- Fixed a bug in `b64decode()` padding handling which made the function inconsistent and non injective. Now, leading `'A'` is NEVER stripped. ## 0.3.6 diff --git a/src/suou/__init__.py b/src/suou/__init__.py index a3dfff1..1a8a077 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -18,8 +18,8 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. from .iding import Siq, SiqCache, SiqType, SiqGen from .codecs import (StringCase, cb32encode, cb32decode, b32lencode, b32ldecode, b64encode, b64decode, b2048encode, b2048decode, - jsonencode, want_bytes, want_str, ssv_list) -from .bits import count_ones, mask_shift, split_bits, join_bits + jsonencode, want_bytes, want_str, ssv_list, want_urlsafe) +from .bits import count_ones, mask_shift, split_bits, join_bits, mod_ceil, mod_floor from .configparse import MissingConfigError, MissingConfigWarning, ConfigOptions, ConfigParserConfigSource, ConfigSource, DictConfigSource, ConfigValue, EnvConfigSource from .functools import deprecated, not_implemented from .classtools import Wanted, Incomplete @@ -37,7 +37,8 @@ __all__ = ( 'SiqType', 'Snowflake', 'SnowflakeGen', 'StringCase', 'TomlI18n', 'Wanted', 'additem', 'b2048decode', 'b2048encode', 'b32ldecode', 'b32lencode', 'b64encode', 'b64decode', 'cb32encode', 'cb32decode', 'count_ones', - 'deprecated', 'ilex', 'join_bits', 'jsonencode', 'kwargs_prefix', 'lex', 'ltuple', - 'makelist', 'mask_shift', 'not_implemented', 'rtuple', 'split_bits', - 'ssv_list', 'symbol_table', 'want_bytes', 'want_str' + 'deprecated', 'ilex', 'join_bits', 'jsonencode', 'kwargs_prefix', 'lex', + 'ltuple', 'makelist', 'mask_shift', 'mod_ceil', 'mod_floor', + 'not_implemented', 'rtuple', 'split_bits', 'ssv_list', 'symbol_table', + 'want_bytes', 'want_str', 'want_urlsafe' ) diff --git a/src/suou/bits.py b/src/suou/bits.py index 0288c0f..86f55ba 100644 --- a/src/suou/bits.py +++ b/src/suou/bits.py @@ -1,5 +1,5 @@ ''' -Utilities for working with bits +Utilities for working with bits & handy arithmetics --- @@ -93,5 +93,19 @@ def join_bits(l: list[int], nbits: int) -> bytes: return ou +## arithmetics because yes -__all__ = ('count_ones', 'mask_shift', 'split_bits', 'join_bits') +def mod_floor(x: int, y: int) -> int: + """ + Greatest integer smaller than x and divisible by y + """ + return x - x % y + +def mod_ceil(x: int, y: int) -> int: + """ + Smallest integer greater than x and divisible by y + """ + return x + (y - x % y) % y + + +__all__ = ('count_ones', 'mask_shift', 'split_bits', 'join_bits', 'mod_floor', 'mod_ceil') diff --git a/src/suou/codecs.py b/src/suou/codecs.py index 3efe53f..f8dbf13 100644 --- a/src/suou/codecs.py +++ b/src/suou/codecs.py @@ -22,7 +22,7 @@ import math import re from typing import Any, Callable -from .bits import split_bits, join_bits +from .bits import mod_ceil, split_bits, join_bits from .functools import deprecated # yes, I know ItsDangerous implements that as well, but remember @@ -49,6 +49,25 @@ def want_str(s: str | bytes, encoding: str = "utf-8", errors: str = "strict") -> s = s.decode(encoding, errors) return s + +BASE64_TO_URLSAFE = str.maketrans('+/', '-_', ' ') + +def want_urlsafe(s: str | bytes) -> str: + """ + Force a Base64 string into its urlsafe representation. + + Behavior is unchecked and undefined with anything else than Base64 strings. + + Used by b64encode() and b64decode(). + """ + return want_str(s).translate(BASE64_TO_URLSAFE) + +def want_urlsafe_bytes(s: str | bytes) -> bytes: + """ + Shorthand for want_bytes(want_urlsafe(s)). + """ + return want_bytes(want_urlsafe(s)) + B32_TO_CROCKFORD = str.maketrans( 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', '0123456789ABCDEFGHJKMNPQRSTVWXYZ', @@ -59,6 +78,7 @@ CROCKFORD_TO_B32 = str.maketrans( 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', '=') + BIP39_WORD_LIST = """ abandon ability able about above absent absorb abstract absurd abuse access accident account accuse achieve acid acoustic acquire across act action actor actress actual adapt add addict address adjust admit adult advance advice aerobic affair afford afraid again age agent agree ahead aim air airport @@ -178,16 +198,31 @@ def b32ldecode(val: bytes | str) -> bytes: def b64encode(val: bytes, *, strip: bool = True) -> str: ''' - Wrapper around base64.urlsafe_b64encode() which also strips trailing '=' and leading 'A'. + Wrapper around base64.urlsafe_b64encode() which also strips trailing '='. ''' b = want_str(base64.urlsafe_b64encode(val)) - return b.lstrip('A').rstrip('=') if strip else b + return b.rstrip('=') if strip else b def b64decode(val: bytes | str) -> bytes: ''' Wrapper around base64.urlsafe_b64decode() which deals with padding. ''' - return base64.urlsafe_b64decode(want_bytes(val).replace(b'/', b'_').replace(b'+', b'-') + b'=' * ((4 - len(val) % 4) % 4)) + val = want_urlsafe(val) + return base64.urlsafe_b64decode(val.ljust(mod_ceil(len(val), 4), '=')) + +def rb64encode(val: bytes, *, strip: bool = True) -> str: + ''' + Call base64.urlsafe_b64encode() with null bytes i.e. '\\0' padding to the start. Leading 'A' are stripped from result. + ''' + b = want_str(base64.urlsafe_b64encode(val.rjust(mod_ceil(len(val), 3), '\0'))) + return b.lstrip('A') if strip else b + +def rb64decode(val: bytes | str) -> bytes: + ''' + Wrapper around base64.urlsafe_b64decode() which deals with padding. + ''' + val = want_urlsafe(val) + return base64.urlsafe_b64decode(val.rjust(mod_ceil(len(val), 4), 'A')) def b2048encode(val: bytes) -> str: ''' diff --git a/src/suou/strtools.py b/src/suou/strtools.py index 2381953..ee5264b 100644 --- a/src/suou/strtools.py +++ b/src/suou/strtools.py @@ -18,6 +18,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. from typing import Callable, Iterable +from pydantic import validate_call from .itertools import makelist @@ -34,11 +35,16 @@ class PrefixIdentifier: raise ValueError('invalid prefix') self._prefix = prefix + @validate_call() def __getattr__(self, key: str): return f'{self._prefix}{key}' + @validate_call() def __getitem__(self, key: str) -> str: return f'{self._prefix}{key}' + def __str__(self): + return f'{self._prefix}' + __all__ = ('PrefixIdentifier',) diff --git a/tests/test_codecs.py b/tests/test_codecs.py index 7ac70d6..0e23296 100644 --- a/tests/test_codecs.py +++ b/tests/test_codecs.py @@ -2,7 +2,7 @@ import binascii import unittest -from suou.codecs import b64encode, b64decode +from suou.codecs import b64encode, b64decode, want_urlsafe B1 = b'N\xf0\xb4\xc3\x85\n\xf9\xb6\x9a\x0f\x82\xa6\x99G\x07#' B2 = b'\xbcXiF,@|{\xbe\xe3\x0cz\xa8\xcbQ\x82' @@ -47,4 +47,8 @@ class TestCodecs(unittest.TestCase): self.assertRaises(binascii.Error, b64decode, 'C') - + def test_want_urlsafe(self): + self.assertEqual('__init__', want_urlsafe('//init_/')) + self.assertEqual('Disney-', want_urlsafe('Disney+')) + self.assertEqual('spaziocosenza', want_urlsafe('spazio cosenza')) + self.assertEqual('=======', want_urlsafe('=======')) diff --git a/tests/test_strtools.py b/tests/test_strtools.py index d07ed88..e3ef328 100644 --- a/tests/test_strtools.py +++ b/tests/test_strtools.py @@ -4,6 +4,7 @@ import unittest from suou.strtools import PrefixIdentifier +from pydantic import ValidationError class TestStrtools(unittest.TestCase): def setUp(self) -> None: @@ -19,12 +20,12 @@ class TestStrtools(unittest.TestCase): self.assertEqual(pi['\x1b\x00'], '\x1b\0') self.assertEqual(pi.same_thing, pi['same_thing']) - with self.assertRaises(TypeError): + with self.assertRaises(ValidationError): pi[0] - self.assertEqual(PrefixIdentifier(None), PrefixIdentifier('')) + self.assertEqual(f'{PrefixIdentifier(None)}', f'{PrefixIdentifier("")}') - def test_PrefixIdentifier_invalid(self): + def test_PrefixIdentifier_get_nostr(self): with self.assertRaises(TypeError): pi = PrefixIdentifier(1) pi.hello @@ -35,4 +36,5 @@ class TestStrtools(unittest.TestCase): with self.assertRaises(TypeError): PrefixIdentifier(b'alpha_') - \ No newline at end of file + + \ No newline at end of file From b4ef56f2609aa23ca7896816d2d78a27e23dc535 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Tue, 22 Jul 2025 02:28:44 +0200 Subject: [PATCH 015/121] 0.4.0: release notes --- CHANGELOG.md | 15 ++++++++++----- README.md | 8 ++++++-- src/suou/__init__.py | 5 +++-- src/suou/dorks.py | 14 ++++++++++++++ src/suou/forms.py | 8 -------- tests/test_codecs.py | 4 ---- 6 files changed, 33 insertions(+), 21 deletions(-) delete mode 100644 src/suou/forms.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f767253..4a52d1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,19 @@ ## 0.4.0 -+ Added `ValueProperty`, abstract superclass for `ConfigProperty` -+ \[BREAKING] Changed the behavior of `makelist()`: now it's also a decorator, converting its return type to a list (revertable with `wrap=False`) -+ New module `lex` with functions `symbol_table()` and `lex()` — make tokenization more affordable -+ Add `dorks` module and `flask.harden()` ++ `pydantic` is now a hard dependency ++ `ConfigProperty` has now been generalized: check out `classtools.ValueProperty` ++ **BREAKING**: Changed the behavior of `makelist()`: **different behavior when used with callables**. + * When applied as a decorator on callable, it converts its return type to a list. + * Pass `wrap=False` to treat callables as simple objects, restoring the 0.3 behavior. ++ New module `lex` to make tokenization more affordable — with functions `symbol_table()` and `lex()` ++ Add `dorks` module and `flask.harden()`. `dorks` contains common endpoints which may be target by hackers + Add `sqlalchemy.bool_column()`: make making flags painless + Introduce `rb64encode()` and `rb64decode()` to deal with issues about Base64 and padding + * `b64encode()` and `b64decode()` pad to the right + * `rb64encode()` and `rb64decode()` pad to the left, then strip leading `'A'` in output + Added `addattr()`, `PrefixIdentifier()`, `mod_floor()`, `mod_ceil()` -+ First version to have unit tests! ++ First version to have unit tests! (Coverage is not yet complete) ## 0.3.7 diff --git a/README.md b/README.md index 29ee187..948b4af 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,12 @@ # SIS Unified Object Underarmor -Good morning, my brother! Welcome the SUOU (SIS Unified Object Underarmor), a library for the management of the storage of objects into a database. +Good morning, my brother! Welcome the SUOU (SIS Unified Object Underarmor), an utility library for developing API's, database schemas and stuff in Python. -It provides utilities such as [SIQ](https://yusur.moe/protocols/siq.html), signing and generation of access tokens (on top of [ItsDangerous](https://github.com/pallets/itsdangerous)) and various utilities, including helpers for use in Flask and SQLAlchemy. +It provides utilities such as: +* [SIQ](https://yusur.moe/protocols/siq.html) +* signing and generation of access tokens, on top of [ItsDangerous](https://github.com/pallets/itsdangerous) +* helpers for use in Flask and SQLAlchemy +* ... **It is not an ORM** nor a replacement of it; it works along existing ORMs (currently only SQLAlchemy is supported lol). diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 1a8a077..98834c2 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -27,13 +27,14 @@ from .itertools import makelist, kwargs_prefix, ltuple, rtuple, additem from .i18n import I18n, JsonI18n, TomlI18n from .snowflake import Snowflake, SnowflakeGen from .lex import symbol_table, lex, ilex +from .strtools import PrefixIdentifier -__version__ = "0.4.0-dev28" +__version__ = "0.4.0" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', 'DictConfigSource', 'EnvConfigSource', 'I18n', 'Incomplete', 'JsonI18n', - 'MissingConfigError', 'MissingConfigWarning', 'Siq', 'SiqCache', 'SiqGen', + 'MissingConfigError', 'MissingConfigWarning', 'PrefixIdentifier', 'Siq', 'SiqCache', 'SiqGen', 'SiqType', 'Snowflake', 'SnowflakeGen', 'StringCase', 'TomlI18n', 'Wanted', 'additem', 'b2048decode', 'b2048encode', 'b32ldecode', 'b32lencode', 'b64encode', 'b64decode', 'cb32encode', 'cb32decode', 'count_ones', diff --git a/src/suou/dorks.py b/src/suou/dorks.py index cf03ca5..1d56c6e 100644 --- a/src/suou/dorks.py +++ b/src/suou/dorks.py @@ -24,5 +24,19 @@ SENSITIVE_ENDPOINTS = """ /package.json /package-lock.json /composer. +/docker-compose. +/config/ +/config. +/secrets. +/credentials. +/.idea/ +/.vscode/ +/storage/ +/logs/ +/.DS_Store +/backup +/.backup +/db.sql +/database.sql """.split() diff --git a/src/suou/forms.py b/src/suou/forms.py deleted file mode 100644 index 8f5318f..0000000 --- a/src/suou/forms.py +++ /dev/null @@ -1,8 +0,0 @@ -""" -Form validation, done right. - -Why this? Why not, let's say, WTForms or Marshmallow? Well, I have my reasons. - -TODO -""" - diff --git a/tests/test_codecs.py b/tests/test_codecs.py index 0e23296..7716aa8 100644 --- a/tests/test_codecs.py +++ b/tests/test_codecs.py @@ -17,10 +17,6 @@ class TestCodecs(unittest.TestCase): def tearDown(self) -> None: ... - #def runTest(self): - # self.test_b64encode() - # self.test_b64decode() - def test_b64encode(self): self.assertEqual(b64encode(B1), 'TvC0w4UK-baaD4KmmUcHIw') self.assertEqual(b64encode(B2), 'vFhpRixAfHu-4wx6qMtRgg') From 303e9e2b2dbc63a1541bce149a4eec916d2a769b Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Tue, 22 Jul 2025 22:15:11 +0200 Subject: [PATCH 016/121] add timed_cache() --- CHANGELOG.md | 5 ++++ src/suou/__init__.py | 6 ++--- src/suou/functools.py | 30 +++++++++++++++++++--- src/suou/markdown.py | 6 ++--- src/suou/obsolete/__init__.py | 16 ++++++++++++ src/suou/{ => obsolete}/configparsev0_3.py | 4 +-- tests/test_codecs.py | 1 + 7 files changed, 57 insertions(+), 11 deletions(-) create mode 100644 src/suou/obsolete/__init__.py rename src/suou/{ => obsolete}/configparsev0_3.py (98%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a52d1d..d959872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.5.0 + ++ Add `timed_cache()` ++ Move obsolete stuff to `obsolete` package + ## 0.4.0 + `pydantic` is now a hard dependency diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 98834c2..d2f7b76 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -21,7 +21,7 @@ from .codecs import (StringCase, cb32encode, cb32decode, b32lencode, b32ldecode, jsonencode, want_bytes, want_str, ssv_list, want_urlsafe) from .bits import count_ones, mask_shift, split_bits, join_bits, mod_ceil, mod_floor from .configparse import MissingConfigError, MissingConfigWarning, ConfigOptions, ConfigParserConfigSource, ConfigSource, DictConfigSource, ConfigValue, EnvConfigSource -from .functools import deprecated, not_implemented +from .functools import deprecated, not_implemented, timed_cache from .classtools import Wanted, Incomplete from .itertools import makelist, kwargs_prefix, ltuple, rtuple, additem from .i18n import I18n, JsonI18n, TomlI18n @@ -29,7 +29,7 @@ from .snowflake import Snowflake, SnowflakeGen from .lex import symbol_table, lex, ilex from .strtools import PrefixIdentifier -__version__ = "0.4.0" +__version__ = "0.5.0-dev29" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', @@ -41,5 +41,5 @@ __all__ = ( 'deprecated', 'ilex', 'join_bits', 'jsonencode', 'kwargs_prefix', 'lex', 'ltuple', 'makelist', 'mask_shift', 'mod_ceil', 'mod_floor', 'not_implemented', 'rtuple', 'split_bits', 'ssv_list', 'symbol_table', - 'want_bytes', 'want_str', 'want_urlsafe' + 'timed_cache', 'want_bytes', 'want_str', 'want_urlsafe' ) diff --git a/src/suou/functools.py b/src/suou/functools.py index 9041f92..a34f023 100644 --- a/src/suou/functools.py +++ b/src/suou/functools.py @@ -14,15 +14,17 @@ This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. """ +import math +import time from typing import Callable import warnings -from functools import wraps +from functools import wraps, lru_cache try: from warnings import deprecated except ImportError: # Python <=3.12 does not implement warnings.deprecated - def deprecated(message: str, /, *, category=DeprecationWarning, stacklevel:int=1): + def deprecated(message: str, /, *, category=DeprecationWarning, stacklevel: int = 1): """ Backport of PEP 702 for Python <=3.12. The stack_level stuff is not reimplemented on purpose because @@ -64,6 +66,28 @@ def not_implemented(msg: Callable | str | None = None): return decorator(msg) return decorator + +def timed_cache(ttl: int, maxsize: int = 128, typed: bool = False) -> Callable[[Callable], Callable]: + """ + LRU cache which expires after the TTL in seconds passed as argument. + """ + def decorator(func): + start_time = None + + @lru_cache(maxsize, typed) + def inner_wrapper(ttl_period: int, *a, **k): + return func(*a, **k) + + @wraps(func) + def wrapper(*a, **k): + nonlocal start_time + if not start_time: + start_time = int(time.time()) + return inner_wrapper(math.floor((time.time() - start_time) // ttl), *a, **k) + return wrapper + return decorator + + __all__ = ( - 'deprecated', 'not_implemented' + 'deprecated', 'not_implemented', 'timed_cache' ) \ No newline at end of file diff --git a/src/suou/markdown.py b/src/suou/markdown.py index 58ce15d..acd4ab5 100644 --- a/src/suou/markdown.py +++ b/src/suou/markdown.py @@ -43,9 +43,9 @@ class SpoilerExtension(markdown.extensions.Extension): """ Add spoiler tags to text, using >!Reddit syntax!<. - XXX remember to call SpoilerExtension.patch_blockquote_processor() - to clear conflicts with the blockquote processor and allow - spoiler tags to start at beginning of line. + If blockquotes interfer with rendered markup, you might want to call + SpoilerExtension.patch_blockquote_processor() to clear conflicts with + the blockquote processor and allow spoiler tags to start at beginning of line. """ def extendMarkdown(self, md: markdown.Markdown, md_globals=None): md.inlinePatterns.register(SimpleTagInlineProcessor(r'()>!(.*?)!<', 'span class="spoiler"'), 'spoiler', 14) diff --git a/src/suou/obsolete/__init__.py b/src/suou/obsolete/__init__.py new file mode 100644 index 0000000..108c334 --- /dev/null +++ b/src/suou/obsolete/__init__.py @@ -0,0 +1,16 @@ +""" +This stuff might still be good, but it's out of support. + +--- + +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. +""" + diff --git a/src/suou/configparsev0_3.py b/src/suou/obsolete/configparsev0_3.py similarity index 98% rename from src/suou/configparsev0_3.py rename to src/suou/obsolete/configparsev0_3.py index 282e248..1563813 100644 --- a/src/suou/configparsev0_3.py +++ b/src/suou/obsolete/configparsev0_3.py @@ -28,8 +28,8 @@ from typing import Any, Callable, Iterator from collections import OrderedDict import warnings -from .functools import deprecated -from .exceptions import MissingConfigError, MissingConfigWarning +from ..functools import deprecated +from ..exceptions import MissingConfigError, MissingConfigWarning warnings.warn('This module will be removed in 0.5.0 and is kept only in case new implementation breaks!\n'\ 'Do not use unless you know what you are doing.', DeprecationWarning) diff --git a/tests/test_codecs.py b/tests/test_codecs.py index 7716aa8..035a605 100644 --- a/tests/test_codecs.py +++ b/tests/test_codecs.py @@ -35,6 +35,7 @@ class TestCodecs(unittest.TestCase): self.assertEqual(b64decode('6RgpyyfClq7ehg'), B3) self.assertEqual(b64decode('ByO8WGlGLEB8e77jDHqoyw'), B4) self.assertEqual(b64decode('__init__'), B5) + self.assertEqual(b64decode('//init//'), B5) self.assertEqual(b64decode('TvC0ww'), B1[:4]) self.assertEqual(b64decode('AE7wtMM'), b'\0' + B1[:4]) self.assertEqual(b64decode('AAAAAABO8LTD'), b'\0\0\0\0\0' + B1[:4]) From 002dbb0579e4c95ac8e2fba9b607552230c650fe Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Thu, 24 Jul 2025 09:48:01 +0200 Subject: [PATCH 017/121] add bound_fk(), unbound_fk(), TimedDict() --- CHANGELOG.md | 3 +- src/suou/__init__.py | 3 +- src/suou/collections.py | 71 +++++++++++++++++++++++++++++++++++++++++ src/suou/legal.py | 31 ++++++++++++++++++ src/suou/sqlalchemy.py | 44 +++++++++++++++++++++++-- 5 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 src/suou/collections.py create mode 100644 src/suou/legal.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d959872..9c53a42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ## 0.5.0 -+ Add `timed_cache()` ++ `sqlalchemy`: add `unbound_fk()`, `bound_fk()` ++ Add `timed_cache()`, `TimedDict()` + Move obsolete stuff to `obsolete` package ## 0.4.0 diff --git a/src/suou/__init__.py b/src/suou/__init__.py index d2f7b76..eb2e0e0 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -21,6 +21,7 @@ from .codecs import (StringCase, cb32encode, cb32decode, b32lencode, b32ldecode, jsonencode, want_bytes, want_str, ssv_list, want_urlsafe) from .bits import count_ones, mask_shift, split_bits, join_bits, mod_ceil, mod_floor from .configparse import MissingConfigError, MissingConfigWarning, ConfigOptions, ConfigParserConfigSource, ConfigSource, DictConfigSource, ConfigValue, EnvConfigSource +from .collections import TimedDict from .functools import deprecated, not_implemented, timed_cache from .classtools import Wanted, Incomplete from .itertools import makelist, kwargs_prefix, ltuple, rtuple, additem @@ -35,7 +36,7 @@ __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', 'DictConfigSource', 'EnvConfigSource', 'I18n', 'Incomplete', 'JsonI18n', 'MissingConfigError', 'MissingConfigWarning', 'PrefixIdentifier', 'Siq', 'SiqCache', 'SiqGen', - 'SiqType', 'Snowflake', 'SnowflakeGen', 'StringCase', 'TomlI18n', 'Wanted', + 'SiqType', 'Snowflake', 'SnowflakeGen', 'StringCase', 'TimedDict', 'TomlI18n', 'Wanted', 'additem', 'b2048decode', 'b2048encode', 'b32ldecode', 'b32lencode', 'b64encode', 'b64decode', 'cb32encode', 'cb32decode', 'count_ones', 'deprecated', 'ilex', 'join_bits', 'jsonencode', 'kwargs_prefix', 'lex', diff --git a/src/suou/collections.py b/src/suou/collections.py new file mode 100644 index 0000000..a11dd87 --- /dev/null +++ b/src/suou/collections.py @@ -0,0 +1,71 @@ +""" +Miscellaneous iterables + +--- + +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. +""" + + + +from __future__ import annotations +import time +from typing import TypeVar + + +_KT = TypeVar('_KT') + +class TimedDict(dict): + _expires: dict[_KT, int] + _ttl: int + + def __init__(self, ttl: int, /, *args, **kwargs): + super().__init__(*args, **kwargs) + self._ttl = ttl + self._expires = dict() + + def check_ex(self, key): + if super().__contains__(key): + ex = self._expires[key] + now = int(time.time()) + if ex < now: + del self._expires[key] + super().__delitem__(key) + elif key in self._expires: + del self._expires[key] + + def __getitem__(self, key: _KT, /): + self.check_ex(key) + return super().__getitem__(key) + + def get(self, key, default=None, /): + self.check_ex(key) + return super().get(key) + + def __setitem__(self, key: _KT, value, /) -> None: + self._expires = int(time.time() + self._ttl) + super().__setitem__(key, value) + + def setdefault(self, key, default, /): + self.check_ex(key) + self._expires = int(time.time() + self._ttl) + return super().setdefault(key, default) + + def __delitem__(self, key, /): + del self._expires[key] + super().__delitem__(key) + + def __iter__(self): + for k in super(): + self.check_ex(k) + return super().__iter__() + +__all__ = ('TimedDict',) diff --git a/src/suou/legal.py b/src/suou/legal.py new file mode 100644 index 0000000..f1da0ea --- /dev/null +++ b/src/suou/legal.py @@ -0,0 +1,31 @@ +""" +TOS / policy building blocks for the lazy. + +XXX DANGER! This is not replacement for legal advice. Contact your lawyer. + +--- + +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. +""" + + +INDEMNIFY = """ +You agree to indemnify and hold harmless {0} from any and all claims, damages, liabilities, costs and expenses, including reasonable and unreasonable counsel and attorney’s fees, arising out of any breach of this agreement. +""" + +NO_WARRANTY = """ +Except as represented in this agreement, the {0} is provided “AS IS”. Other than as provided in this agreement, {1} makes no other warranties, express or implied, and hereby disclaims all implied warranties, including any warranty of merchantability and warranty of fitness for a particular purpose. +""" + +GOVERNING_LAW = """ +These terms of services are governed by, and shall be interpreted in accordance with, the laws of {0}. You consent to the sole jurisdiction of {1} for all disputes between You and , and You consent to the sole application of {2} law for all such disputes. +""" + diff --git a/src/suou/sqlalchemy.py b/src/suou/sqlalchemy.py index b16cae7..282fa78 100644 --- a/src/suou/sqlalchemy.py +++ b/src/suou/sqlalchemy.py @@ -21,7 +21,8 @@ from functools import wraps from typing import Callable, Iterable, Never, TypeVar import warnings from sqlalchemy import BigInteger, Boolean, CheckConstraint, Date, Dialect, ForeignKey, LargeBinary, Column, MetaData, SmallInteger, String, create_engine, select, text -from sqlalchemy.orm import DeclarativeBase, Session, declarative_base as _declarative_base, relationship +from sqlalchemy.orm import DeclarativeBase, InstrumentedAttribute, Session, declarative_base as _declarative_base, relationship +from sqlalchemy.types import TypeEngine from .snowflake import SnowflakeGen from .itertools import kwargs_prefix, makelist @@ -223,6 +224,44 @@ def parent_children(keyword: str, /, **kwargs): return parent, child + +def unbound_fk(target: str | Column | InstrumentedAttribute, typ: TypeEngine | None = None, **kwargs): + """ + Shorthand for creating a "unbound" foreign key column from a column name, the referenced column. + + "Unbound" foreign keys are nullable and set to null when referenced object is deleted. + + If target is a string, make sure to pass the column type at typ= (default: IdType aka varbinary(16))! + """ + if isinstance(target, (Column, InstrumentedAttribute)): + target_name = f'{target.table.name}.{target.name}' + typ = target.type + elif isinstance(target, str): + target_name = target + if typ is None: + typ = IdType + + return Column(typ, ForeignKey(target_name, ondelete='SET NULL'), nullable=True, **kwargs) + +def bound_fk(target: str | Column | InstrumentedAttribute, typ: TypeEngine | None = None, **kwargs): + """ + Shorthand for creating a "bound" foreign key column from a column name, the referenced column. + + "Bound" foreign keys are not nullable and cascade when referenced object is deleted. It means, + parent deleted -> all children deleted. + + If target is a string, make sure to pass the column type at typ= (default: IdType aka varbinary(16))! + """ + if isinstance(target, (Column, InstrumentedAttribute)): + target_name = f'{target.table.name}.{target.name}' + typ = target.type + elif isinstance(target, str): + target_name = target + if typ is None: + typ = IdType + + return Column(typ, ForeignKey(target_name, ondelete='CASCADE'), nullable=False, **kwargs) + def want_column(cls: type[DeclarativeBase], col: Column[_T] | str) -> Column[_T]: """ Return a table's column given its name. @@ -238,6 +277,7 @@ def want_column(cls: type[DeclarativeBase], col: Column[_T] | str) -> Column[_T] else: raise TypeError +## Utilities for use in web apps below class AuthSrc(metaclass=ABCMeta): ''' @@ -308,5 +348,5 @@ def require_auth_base(cls: type[DeclarativeBase], *, src: AuthSrc, column: str | # Optional dependency: do not import into __init__.py __all__ = ( 'IdType', 'id_column', 'entity_base', 'declarative_base', 'token_signer', 'match_column', 'match_constraint', - 'author_pair', 'age_pair', 'require_auth_base', 'want_column' + 'author_pair', 'age_pair', 'require_auth_base', 'bound_fk', 'unbound_fk', 'want_column' ) \ No newline at end of file From 589d4b3b13395e4af983abb6c07395ae8a38f1bd Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Thu, 24 Jul 2025 11:44:24 +0200 Subject: [PATCH 018/121] type annotations, NotFoundError --- CHANGELOG.md | 3 ++- src/suou/collections.py | 22 +++++++++++++--------- src/suou/exceptions.py | 7 ++++++- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c53a42..da8638d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,8 @@ + `sqlalchemy`: add `unbound_fk()`, `bound_fk()` + Add `timed_cache()`, `TimedDict()` -+ Move obsolete stuff to `obsolete` package ++ Move obsolete stuff to `obsolete` package (includes configparse 0.3 as of now) ++ Add more exceptions: `NotFoundError()` ## 0.4.0 diff --git a/src/suou/collections.py b/src/suou/collections.py index a11dd87..37097d2 100644 --- a/src/suou/collections.py +++ b/src/suou/collections.py @@ -18,12 +18,16 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. from __future__ import annotations import time -from typing import TypeVar +from typing import Iterator, TypeVar _KT = TypeVar('_KT') +_VT = TypeVar('_VT') -class TimedDict(dict): +class TimedDict(dict[_KT, _VT]): + """ + Dictionary where keys expire after the defined time to live, expressed in seconds. + """ _expires: dict[_KT, int] _ttl: int @@ -32,7 +36,7 @@ class TimedDict(dict): self._ttl = ttl self._expires = dict() - def check_ex(self, key): + def check_ex(self, key: _KT): if super().__contains__(key): ex = self._expires[key] now = int(time.time()) @@ -42,28 +46,28 @@ class TimedDict(dict): elif key in self._expires: del self._expires[key] - def __getitem__(self, key: _KT, /): + def __getitem__(self, key: _KT, /) -> _VT: self.check_ex(key) return super().__getitem__(key) - def get(self, key, default=None, /): + def get(self, key: _KT, default: _VT | None = None, /) -> _VT | None: self.check_ex(key) return super().get(key) - def __setitem__(self, key: _KT, value, /) -> None: + def __setitem__(self, key: _KT, value: _VT, /) -> None: self._expires = int(time.time() + self._ttl) super().__setitem__(key, value) - def setdefault(self, key, default, /): + def setdefault(self, key: _KT, default: _VT, /) -> _VT: self.check_ex(key) self._expires = int(time.time() + self._ttl) return super().setdefault(key, default) - def __delitem__(self, key, /): + def __delitem__(self, key: _KT, /) -> None: del self._expires[key] super().__delitem__(key) - def __iter__(self): + def __iter__(self) -> Iterator[_KT]: for k in super(): self.check_ex(k) return super().__iter__() diff --git a/src/suou/exceptions.py b/src/suou/exceptions.py index 170125f..c1fe496 100644 --- a/src/suou/exceptions.py +++ b/src/suou/exceptions.py @@ -41,6 +41,11 @@ class InconsistencyError(RuntimeError): This program is in a state which it's not supposed to be in. """ +class NotFoundError(LookupError): + """ + The requested item was not found. + """ + __all__ = ( - 'MissingConfigError', 'MissingConfigWarning', 'LexError', 'InconsistencyError' + 'MissingConfigError', 'MissingConfigWarning', 'LexError', 'InconsistencyError', 'NotFoundError' ) \ No newline at end of file From 38ff59c76a4f8d0bf1df7c54674f1e905aec6aa3 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 25 Jul 2025 08:24:46 +0200 Subject: [PATCH 019/121] add calendar module, drop Quart-SQLAlchemy --- CHANGELOG.md | 3 +- pyproject.toml | 17 +++++++---- src/suou/__init__.py | 18 +++++++----- src/suou/calendar.py | 66 ++++++++++++++++++++++++++++++++++++++++++ src/suou/codecs.py | 3 +- tests/test_calendar.py | 27 +++++++++++++++++ 6 files changed, 118 insertions(+), 16 deletions(-) create mode 100644 src/suou/calendar.py create mode 100644 tests/test_calendar.py diff --git a/CHANGELOG.md b/CHANGELOG.md index da8638d..cce9c06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ ## 0.5.0 + `sqlalchemy`: add `unbound_fk()`, `bound_fk()` -+ Add `timed_cache()`, `TimedDict()` ++ Add `timed_cache()`, `TimedDict()`, `age_and_days()` ++ Add date conversion utilities + Move obsolete stuff to `obsolete` package (includes configparse 0.3 as of now) + Add more exceptions: `NotFoundError()` diff --git a/pyproject.toml b/pyproject.toml index 9ef670d..e73689a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,8 @@ readme = "README.md" dependencies = [ "itsdangerous", "toml", - "pydantic" + "pydantic", + "uvloop; os_name=='posix'" ] # - further devdependencies below - # @@ -43,16 +44,17 @@ flask_sqlalchemy = [ "Flask-SqlAlchemy", ] peewee = [ - "peewee>=3.0.0, <4.0" + "peewee>=3.0.0" ] markdown = [ "markdown>=3.0.0" ] quart = [ - "Flask>=2.0.0", "Quart", - "Quart-Schema", - "uvloop; os_name=='posix'" + "Quart-Schema" +] +quart_sqlalchemy = [ + "Quart_SQLALchemy>=3.0.0, <4.0" ] full = [ @@ -60,7 +62,10 @@ full = [ "sakuragasaki46-suou[flask]", "sakuragasaki46-suou[quart]", "sakuragasaki46-suou[peewee]", - "sakuragasaki46-suou[markdown]" + "sakuragasaki46-suou[markdown]", + "sakuragasaki46-suou[flask-sqlalchemy]" + # disabled: quart-sqlalchemy causes issues with anyone else. WON'T BE IMPLEMENTED + #"sakuragasaki46-suou[quart-sqlalchemy]" ] diff --git a/src/suou/__init__.py b/src/suou/__init__.py index eb2e0e0..81955cf 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -18,13 +18,14 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. from .iding import Siq, SiqCache, SiqType, SiqGen from .codecs import (StringCase, cb32encode, cb32decode, b32lencode, b32ldecode, b64encode, b64decode, b2048encode, b2048decode, - jsonencode, want_bytes, want_str, ssv_list, want_urlsafe) + jsonencode, want_bytes, want_str, ssv_list, want_urlsafe, want_urlsafe_bytes) from .bits import count_ones, mask_shift, split_bits, join_bits, mod_ceil, mod_floor +from .calendar import want_datetime, want_isodate, want_timestamp, age_and_days from .configparse import MissingConfigError, MissingConfigWarning, ConfigOptions, ConfigParserConfigSource, ConfigSource, DictConfigSource, ConfigValue, EnvConfigSource from .collections import TimedDict from .functools import deprecated, not_implemented, timed_cache from .classtools import Wanted, Incomplete -from .itertools import makelist, kwargs_prefix, ltuple, rtuple, additem +from .itertools import makelist, kwargs_prefix, ltuple, rtuple, additem, addattr from .i18n import I18n, JsonI18n, TomlI18n from .snowflake import Snowflake, SnowflakeGen from .lex import symbol_table, lex, ilex @@ -37,10 +38,11 @@ __all__ = ( 'DictConfigSource', 'EnvConfigSource', 'I18n', 'Incomplete', 'JsonI18n', 'MissingConfigError', 'MissingConfigWarning', 'PrefixIdentifier', 'Siq', 'SiqCache', 'SiqGen', 'SiqType', 'Snowflake', 'SnowflakeGen', 'StringCase', 'TimedDict', 'TomlI18n', 'Wanted', - 'additem', 'b2048decode', 'b2048encode', 'b32ldecode', 'b32lencode', - 'b64encode', 'b64decode', 'cb32encode', 'cb32decode', 'count_ones', - 'deprecated', 'ilex', 'join_bits', 'jsonencode', 'kwargs_prefix', 'lex', - 'ltuple', 'makelist', 'mask_shift', 'mod_ceil', 'mod_floor', - 'not_implemented', 'rtuple', 'split_bits', 'ssv_list', 'symbol_table', - 'timed_cache', 'want_bytes', 'want_str', 'want_urlsafe' + 'addattr', 'additem', 'age_and_days', 'b2048decode', 'b2048encode', + 'b32ldecode', 'b32lencode', 'b64encode', 'b64decode', 'cb32encode', + 'cb32decode', 'count_ones', 'deprecated', 'ilex', 'join_bits', + 'jsonencode', 'kwargs_prefix', 'lex', 'ltuple', 'makelist', 'mask_shift', + 'mod_ceil', 'mod_floor', 'not_implemented', 'rtuple', 'split_bits', + 'ssv_list', 'symbol_table', 'timed_cache', 'want_bytes', 'want_datetime', + 'want_isodate', 'want_str', 'want_timestamp', 'want_urlsafe', 'want_urlsafe_bytes' ) diff --git a/src/suou/calendar.py b/src/suou/calendar.py new file mode 100644 index 0000000..1733853 --- /dev/null +++ b/src/suou/calendar.py @@ -0,0 +1,66 @@ +""" +Calendar utilities (mainly Gregorian oof) + +--- + +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 datetime + +from suou.functools import not_implemented + + +def want_isodate(d: datetime.datetime | str | float | int, *, tz = None) -> str: + """ + Convert a date into ISO timestamp (e.g. 2020-01-01T02:03:04) + """ + if isinstance(d, (int, float)): + d = datetime.datetime.fromtimestamp(d, tz=tz) + if isinstance(d, str): + return d + return d.isoformat() + + +def want_datetime(d: datetime.datetime | str | float | int, *, tz = None) -> datetime.datetime: + """ + Convert a date into Python datetime.datetime (e.g. datetime.datetime(2020, 1, 1, 2, 3, 4)). + + If a string is passed, ISO format is assumed + """ + if isinstance(d, str): + d = datetime.datetime.fromisoformat(d) + elif isinstance(d, (int, float)): + d = datetime.datetime.fromtimestamp(d, tz=tz) + return d + +def want_timestamp(d: datetime.datetime | str | float | int, *, tz = None) -> float: + """ + Convert a date into UNIX timestamp (e.g. 1577840584.0). Returned as a float; decimals are milliseconds. + """ + if isinstance(d, str): + d = want_datetime(d, tz=tz) + if isinstance(d, (int, float)): + return d + return d.timestamp() + +def age_and_days(date: datetime.datetime, now: datetime.datetime | None = None) -> tuple[int, int]: + """ + Compute age / duration of a timespan in years and days. + """ + if now is None: + now = datetime.date.today() + y = now.year - date.year - ((now.month, now.day) < (date.month, date.day)) + d = (now - datetime.date(date.year + y, date.month, date.day)).days + return y, d + +__all__ = ('want_datetime', 'want_timestamp', 'want_isodate', 'age_and_days') \ No newline at end of file diff --git a/src/suou/codecs.py b/src/suou/codecs.py index f8dbf13..9740024 100644 --- a/src/suou/codecs.py +++ b/src/suou/codecs.py @@ -57,6 +57,7 @@ def want_urlsafe(s: str | bytes) -> str: Force a Base64 string into its urlsafe representation. Behavior is unchecked and undefined with anything else than Base64 strings. + In particular, this is NOT an URL encoder. Used by b64encode() and b64decode(). """ @@ -328,5 +329,5 @@ class StringCase(enum.Enum): __all__ = ( 'cb32encode', 'cb32decode', 'b32lencode', 'b32ldecode', 'b64encode', 'b64decode', 'jsonencode' - 'StringCase', 'want_bytes', 'want_str', 'jsondecode', 'ssv_list' + 'StringCase', 'want_bytes', 'want_str', 'jsondecode', 'ssv_list', 'want_urlsafe', 'want_urlsafe_bytes' ) \ No newline at end of file diff --git a/tests/test_calendar.py b/tests/test_calendar.py new file mode 100644 index 0000000..14c0aca --- /dev/null +++ b/tests/test_calendar.py @@ -0,0 +1,27 @@ + + +from datetime import timezone +import datetime +from suou.calendar import want_datetime, want_isodate + +import unittest + + +class TestCalendar(unittest.TestCase): + def setUp(self) -> None: + ... + def tearDown(self) -> None: + ... + + def test_want_isodate(self): + ## if test fails, make sure time zone is set to UTC. + self.assertEqual(want_isodate(0, tz=timezone.utc), '1970-01-01T00:00:00+00:00') + self.assertEqual(want_isodate(86400, tz=timezone.utc), '1970-01-02T00:00:00+00:00') + self.assertEqual(want_isodate(1577840584.0, tz=timezone.utc), '2020-01-01T01:03:04+00:00') + # TODO + + def test_want_datetime(self): + self.assertEqual(want_datetime('2017-04-10T19:00:01', tz=timezone.utc) - want_datetime('2017-04-10T18:00:00', tz=timezone.utc), datetime.timedelta(seconds=3601)) + # TODO + + \ No newline at end of file From a3330d43407e677a9ffc3650f13ac045e7baeade Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Wed, 30 Jul 2025 02:01:11 +0200 Subject: [PATCH 020/121] code style, minor fixes --- CHANGELOG.md | 4 ++-- src/suou/__init__.py | 10 ++++++---- src/suou/classtools.py | 8 +++----- src/suou/dorks.py | 1 + src/suou/legal.py | 1 + src/suou/quart.py | 17 +++++++++++++++++ 6 files changed, 30 insertions(+), 11 deletions(-) create mode 100644 src/suou/quart.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cce9c06..4df9892 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,8 @@ ## 0.5.0 + `sqlalchemy`: add `unbound_fk()`, `bound_fk()` -+ Add `timed_cache()`, `TimedDict()`, `age_and_days()` -+ Add date conversion utilities ++ Add `timed_cache()`, `TimedDict()` ++ Add module `calendar` with `want_*` date type conversion utilities and `age_and_days()` + Move obsolete stuff to `obsolete` package (includes configparse 0.3 as of now) + Add more exceptions: `NotFoundError()` diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 81955cf..d00606e 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -30,19 +30,21 @@ from .i18n import I18n, JsonI18n, TomlI18n from .snowflake import Snowflake, SnowflakeGen from .lex import symbol_table, lex, ilex from .strtools import PrefixIdentifier +from .validators import matches -__version__ = "0.5.0-dev29" +__version__ = "0.5.0-dev30" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', 'DictConfigSource', 'EnvConfigSource', 'I18n', 'Incomplete', 'JsonI18n', - 'MissingConfigError', 'MissingConfigWarning', 'PrefixIdentifier', 'Siq', 'SiqCache', 'SiqGen', - 'SiqType', 'Snowflake', 'SnowflakeGen', 'StringCase', 'TimedDict', 'TomlI18n', 'Wanted', + 'MissingConfigError', 'MissingConfigWarning', 'PrefixIdentifier', + 'Siq', 'SiqCache', 'SiqGen', 'SiqType', 'Snowflake', 'SnowflakeGen', + 'StringCase', 'TimedDict', 'TomlI18n', 'Wanted', 'addattr', 'additem', 'age_and_days', 'b2048decode', 'b2048encode', 'b32ldecode', 'b32lencode', 'b64encode', 'b64decode', 'cb32encode', 'cb32decode', 'count_ones', 'deprecated', 'ilex', 'join_bits', 'jsonencode', 'kwargs_prefix', 'lex', 'ltuple', 'makelist', 'mask_shift', - 'mod_ceil', 'mod_floor', 'not_implemented', 'rtuple', 'split_bits', + 'matches', 'mod_ceil', 'mod_floor', 'not_implemented', 'rtuple', 'split_bits', 'ssv_list', 'symbol_table', 'timed_cache', 'want_bytes', 'want_datetime', 'want_isodate', 'want_str', 'want_timestamp', 'want_urlsafe', 'want_urlsafe_bytes' ) diff --git a/src/suou/classtools.py b/src/suou/classtools.py index eefdba3..c27fa61 100644 --- a/src/suou/classtools.py +++ b/src/suou/classtools.py @@ -16,12 +16,10 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. from __future__ import annotations -from abc import ABCMeta, abstractmethod +from abc import abstractmethod from typing import Any, Callable, Generic, Iterable, Mapping, TypeVar import logging -from suou.codecs import StringCase - _T = TypeVar('_T') logger = logging.getLogger(__name__) @@ -69,8 +67,6 @@ class Incomplete(Generic[_T]): Missing arguments must be passed in the appropriate positions (positional or keyword) as a Wanted() object. """ - # XXX disabled for https://stackoverflow.com/questions/45864273/slots-conflicts-with-a-class-variable-in-a-generic-class - #__slots__ = ('_obj', '_args', '_kwargs') _obj = Callable[Any, _T] _args: Iterable _kwargs: dict @@ -193,3 +189,5 @@ class ValueProperty(Generic[_T]): return self._srcs['default'] +__all__ = ('Wanted', 'Incomplete', 'ValueSource', 'ValueProperty') + diff --git a/src/suou/dorks.py b/src/suou/dorks.py index 1d56c6e..81f860a 100644 --- a/src/suou/dorks.py +++ b/src/suou/dorks.py @@ -38,5 +38,6 @@ SENSITIVE_ENDPOINTS = """ /.backup /db.sql /database.sql +/.vite """.split() diff --git a/src/suou/legal.py b/src/suou/legal.py index f1da0ea..422486d 100644 --- a/src/suou/legal.py +++ b/src/suou/legal.py @@ -16,6 +16,7 @@ This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. """ +# TODO more snippets INDEMNIFY = """ You agree to indemnify and hold harmless {0} from any and all claims, damages, liabilities, costs and expenses, including reasonable and unreasonable counsel and attorney’s fees, arising out of any breach of this agreement. diff --git a/src/suou/quart.py b/src/suou/quart.py new file mode 100644 index 0000000..9caaee1 --- /dev/null +++ b/src/suou/quart.py @@ -0,0 +1,17 @@ +""" +Utilities for Quart, asynchronous successor of Flask + +--- + +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. +""" + +# TODO everything \ No newline at end of file From 73d3088d86797e92a05a3369f587d1fe3df0dd42 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Wed, 30 Jul 2025 10:54:09 +0200 Subject: [PATCH 021/121] add modules redact, sqlalchemy_async, functions none_pass() --- CHANGELOG.md | 4 +- pyproject.toml | 2 +- src/suou/__init__.py | 10 +-- src/suou/collections.py | 2 + src/suou/functools.py | 18 +++++- src/suou/redact.py | 21 ++++++ src/suou/sqlalchemy.py | 6 ++ src/suou/sqlalchemy_async.py | 121 +++++++++++++++++++++++++++++++++++ 8 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 src/suou/redact.py create mode 100644 src/suou/sqlalchemy_async.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4df9892..8510622 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,11 @@ ## 0.5.0 + `sqlalchemy`: add `unbound_fk()`, `bound_fk()` -+ Add `timed_cache()`, `TimedDict()` ++ Add `sqlalchemy_async` module with `SQLAlchemy()` ++ Add `timed_cache()`, `TimedDict()`, `none_pass` + Add module `calendar` with `want_*` date type conversion utilities and `age_and_days()` + Move obsolete stuff to `obsolete` package (includes configparse 0.3 as of now) ++ Add `redact` module with `redact_url_password()` + Add more exceptions: `NotFoundError()` ## 0.4.0 diff --git a/pyproject.toml b/pyproject.toml index e73689a..b1132f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ Repository = "https://github.com/sakuragasaki46/suou" [project.optional-dependencies] # the below are all dev dependencies (and probably already installed) sqlalchemy = [ - "SQLAlchemy>=2.0.0" + "SQLAlchemy>=2.0.0[asyncio]" ] flask = [ "Flask>=2.0.0", diff --git a/src/suou/__init__.py b/src/suou/__init__.py index d00606e..b769b40 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -23,7 +23,7 @@ from .bits import count_ones, mask_shift, split_bits, join_bits, mod_ceil, mod_f from .calendar import want_datetime, want_isodate, want_timestamp, age_and_days from .configparse import MissingConfigError, MissingConfigWarning, ConfigOptions, ConfigParserConfigSource, ConfigSource, DictConfigSource, ConfigValue, EnvConfigSource from .collections import TimedDict -from .functools import deprecated, not_implemented, timed_cache +from .functools import deprecated, not_implemented, timed_cache, none_pass from .classtools import Wanted, Incomplete from .itertools import makelist, kwargs_prefix, ltuple, rtuple, additem, addattr from .i18n import I18n, JsonI18n, TomlI18n @@ -31,6 +31,7 @@ from .snowflake import Snowflake, SnowflakeGen from .lex import symbol_table, lex, ilex from .strtools import PrefixIdentifier from .validators import matches +from .redact import redact_url_password __version__ = "0.5.0-dev30" @@ -44,7 +45,8 @@ __all__ = ( 'b32ldecode', 'b32lencode', 'b64encode', 'b64decode', 'cb32encode', 'cb32decode', 'count_ones', 'deprecated', 'ilex', 'join_bits', 'jsonencode', 'kwargs_prefix', 'lex', 'ltuple', 'makelist', 'mask_shift', - 'matches', 'mod_ceil', 'mod_floor', 'not_implemented', 'rtuple', 'split_bits', - 'ssv_list', 'symbol_table', 'timed_cache', 'want_bytes', 'want_datetime', - 'want_isodate', 'want_str', 'want_timestamp', 'want_urlsafe', 'want_urlsafe_bytes' + 'matches', 'mod_ceil', 'mod_floor', 'none_pass', 'not_implemented', + 'redact_url_password', 'rtuple', 'split_bits', 'ssv_list', 'symbol_table', + 'timed_cache', 'want_bytes', 'want_datetime', 'want_isodate', 'want_str', + 'want_timestamp', 'want_urlsafe', 'want_urlsafe_bytes' ) diff --git a/src/suou/collections.py b/src/suou/collections.py index 37097d2..090659d 100644 --- a/src/suou/collections.py +++ b/src/suou/collections.py @@ -27,6 +27,8 @@ _VT = TypeVar('_VT') class TimedDict(dict[_KT, _VT]): """ Dictionary where keys expire after the defined time to live, expressed in seconds. + + NEW 0.5.0 """ _expires: dict[_KT, int] _ttl: int diff --git a/src/suou/functools.py b/src/suou/functools.py index a34f023..128a1ec 100644 --- a/src/suou/functools.py +++ b/src/suou/functools.py @@ -70,6 +70,8 @@ def not_implemented(msg: Callable | str | None = None): def timed_cache(ttl: int, maxsize: int = 128, typed: bool = False) -> Callable[[Callable], Callable]: """ LRU cache which expires after the TTL in seconds passed as argument. + + NEW 0.5.0 """ def decorator(func): start_time = None @@ -87,7 +89,21 @@ def timed_cache(ttl: int, maxsize: int = 128, typed: bool = False) -> Callable[[ return wrapper return decorator +def none_pass(func: Callable, *args, **kwargs): + """ + Wrap callable so that gets called only on not None values. + + Shorthand for func(x) if x is not None else None + + NEW 0.5.0 + """ + @wraps(func) + def wrapper(x): + if x is None: + return x + return func(x, *args, **kwargs) + return wrapper __all__ = ( - 'deprecated', 'not_implemented', 'timed_cache' + 'deprecated', 'not_implemented', 'timed_cache', 'none_pass' ) \ No newline at end of file diff --git a/src/suou/redact.py b/src/suou/redact.py new file mode 100644 index 0000000..e8ee104 --- /dev/null +++ b/src/suou/redact.py @@ -0,0 +1,21 @@ +""" +"Security through obscurity" helpers for less sensitive logging +""" + +import re + + +def redact_url_password(u: str) -> str: + """ + Remove password from URIs. + + The password part in URIs is: + scheme://username:password@hostname/path?query + ^------^ + + NEW 0.5.0 + """ + return re.sub(r':[^@:/ ]+@', ':***@', u) + + +__all__ = ('redact_url_password', ) \ No newline at end of file diff --git a/src/suou/sqlalchemy.py b/src/suou/sqlalchemy.py index 282fa78..b297352 100644 --- a/src/suou/sqlalchemy.py +++ b/src/suou/sqlalchemy.py @@ -232,6 +232,8 @@ def unbound_fk(target: str | Column | InstrumentedAttribute, typ: TypeEngine | N "Unbound" foreign keys are nullable and set to null when referenced object is deleted. If target is a string, make sure to pass the column type at typ= (default: IdType aka varbinary(16))! + + NEW 0.5.0 """ if isinstance(target, (Column, InstrumentedAttribute)): target_name = f'{target.table.name}.{target.name}' @@ -251,6 +253,8 @@ def bound_fk(target: str | Column | InstrumentedAttribute, typ: TypeEngine | Non parent deleted -> all children deleted. If target is a string, make sure to pass the column type at typ= (default: IdType aka varbinary(16))! + + NEW 0.5.0 """ if isinstance(target, (Column, InstrumentedAttribute)): target_name = f'{target.table.name}.{target.name}' @@ -284,6 +288,8 @@ class AuthSrc(metaclass=ABCMeta): AuthSrc object required for require_auth_base(). This is an abstract class and is NOT usable directly. + + This is not part of the public API ''' def required_exc(self) -> Never: raise ValueError('required field missing') diff --git a/src/suou/sqlalchemy_async.py b/src/suou/sqlalchemy_async.py new file mode 100644 index 0000000..9ddd24f --- /dev/null +++ b/src/suou/sqlalchemy_async.py @@ -0,0 +1,121 @@ +""" +Helpers for asynchronous user of SQLAlchemy + +--- + +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. +""" + +from sqlalchemy import Engine, Select, func, select +from sqlalchemy.orm import DeclarativeBase, Session, lazyload +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from flask_sqlalchemy.pagination import Pagination + +from suou.exceptions import NotFoundError + +class SQLAlchemy: + """ + Drop-in (?) replacement for flask_sqlalchemy.SQLAlchemy() + eligible for async environments + + NEW 0.5.0 + """ + base: DeclarativeBase + engine: Engine + NotFound = NotFoundError + + def __init__(self, base: DeclarativeBase): + self.base = base + self.engine = None + def bind(self, url: str): + self.engine = create_async_engine(url) + async def begin(self): + if self.engine is None: + raise RuntimeError('database is not connected') + return await self.engine.begin() + __aenter__ = begin + async def __aexit__(self, e1, e2, e3): + return await self.engine.__aexit__(e1, e2, e3) + async def paginate(self, select: Select, *, + page: int | None = None, per_page: int | None = None, + max_per_page: int | None = None, error_out: bool = True, + count: bool = True): + """ + """ + async with self as session: + return AsyncSelectPagination( + select = select, + session = session, + page = page, + per_page=per_page, max_per_page=max_per_page, + error_out=self.NotFound if error_out else None, count=count + ) + + + +class AsyncSelectPagination(Pagination): + """ + flask_sqlalchemy.SelectPagination but asynchronous + """ + + async def _query_items(self) -> list: + select_q: Select = self._query_args["select"] + select = select_q.limit(self.per_page).offset(self._query_offset) + session: AsyncSession = self._query_args["session"] + out = (await session.execute(select)).scalars() + + async def _query_count(self) -> int: + select_q: Select = self._query_args["select"] + sub = select_q.options(lazyload("*")).order_by(None).subquery() + session: AsyncSession = self._query_args["session"] + out = await session.execute(select(func.count()).select_from(sub)) + return out + + def __init__(self, + page: int | None = None, + per_page: int | None = None, + max_per_page: int | None = 100, + error_out: Exception | None = NotFoundError, + count: bool = True, + **kwargs): + ## XXX flask-sqlalchemy says Pagination() is not public API. + ## Things may break; beware. + self._query_args = kwargs + page, per_page = self._prepare_page_args( + page=page, + per_page=per_page, + max_per_page=max_per_page, + error_out=error_out, + ) + + self.page: int = page + """The current page.""" + + self.per_page: int = per_page + """The maximum number of items on a page.""" + + self.max_per_page: int | None = max_per_page + """The maximum allowed value for ``per_page``.""" + + self.items = None + self.total = None + self.error_out = error_out + self.has_count = count + + async def __await__(self): + self.items = await self._query_items() + if not self.items and self.page != 1 and self.error_out: + raise self.error_out + if self.has_count: + self.total = await self._query_count() + return self + +__all__ = ('SQLAlchemy', ) \ No newline at end of file From d30e1086f3767bfe29923829629e000f1f9b7812 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Wed, 30 Jul 2025 13:00:41 +0200 Subject: [PATCH 022/121] add BabelTowerError --- CHANGELOG.md | 2 +- src/suou/exceptions.py | 7 +++++++ src/suou/i18n.py | 6 +++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8510622..b89014a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ + Add module `calendar` with `want_*` date type conversion utilities and `age_and_days()` + Move obsolete stuff to `obsolete` package (includes configparse 0.3 as of now) + Add `redact` module with `redact_url_password()` -+ Add more exceptions: `NotFoundError()` ++ Add more exceptions: `NotFoundError()`, `BabelTowerError()` ## 0.4.0 diff --git a/src/suou/exceptions.py b/src/suou/exceptions.py index c1fe496..7952bc7 100644 --- a/src/suou/exceptions.py +++ b/src/suou/exceptions.py @@ -45,6 +45,13 @@ class NotFoundError(LookupError): """ The requested item was not found. """ + # Werkzeug et al. + code = 404 + +class BabelTowerError(NotFoundError): + """ + The user requested a language that cannot be understood. + """ __all__ = ( 'MissingConfigError', 'MissingConfigWarning', 'LexError', 'InconsistencyError', 'NotFoundError' diff --git a/src/suou/i18n.py b/src/suou/i18n.py index 7080019..254c104 100644 --- a/src/suou/i18n.py +++ b/src/suou/i18n.py @@ -23,6 +23,7 @@ import os import toml from typing import Mapping +from .exceptions import BabelTowerError class IdentityLang: ''' @@ -81,7 +82,10 @@ class I18n(metaclass=ABCMeta): def load_lang(self, name: str, filename: str | None = None) -> I18nLang: if not filename: filename = self.filename_tmpl.format(lang=name, ext=self.EXT) - data = self.load_file(filename) + try: + data = self.load_file(filename) + except OSError as e: + raise BabelTowerError(f'unknown language: {name}') from e l = self.langs.setdefault(name, I18nLang()) l.update(data[name] if name in data else data) if name != self.default_lang: From 18b31c988993581d7eba0734eb6d5881290c2c64 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Wed, 30 Jul 2025 18:23:17 +0200 Subject: [PATCH 023/121] fix pyproject --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b1132f7..d5ae206 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ Repository = "https://github.com/sakuragasaki46/suou" [project.optional-dependencies] # the below are all dev dependencies (and probably already installed) sqlalchemy = [ - "SQLAlchemy>=2.0.0[asyncio]" + "SQLAlchemy[asyncio]>=2.0.0" ] flask = [ "Flask>=2.0.0", From 2c52f9b5612540070db8e08f566d79f865b8e645 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Wed, 30 Jul 2025 20:19:31 +0200 Subject: [PATCH 024/121] add twocolon_list() --- CHANGELOG.md | 2 +- src/suou/__init__.py | 6 +++--- src/suou/codecs.py | 12 +++++++++++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b89014a..6dafe7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ + `sqlalchemy`: add `unbound_fk()`, `bound_fk()` + Add `sqlalchemy_async` module with `SQLAlchemy()` -+ Add `timed_cache()`, `TimedDict()`, `none_pass` ++ Add `timed_cache()`, `TimedDict()`, `none_pass()`, `twocolon_list()` + Add module `calendar` with `want_*` date type conversion utilities and `age_and_days()` + Move obsolete stuff to `obsolete` package (includes configparse 0.3 as of now) + Add `redact` module with `redact_url_password()` diff --git a/src/suou/__init__.py b/src/suou/__init__.py index b769b40..9108eac 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -18,7 +18,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. from .iding import Siq, SiqCache, SiqType, SiqGen from .codecs import (StringCase, cb32encode, cb32decode, b32lencode, b32ldecode, b64encode, b64decode, b2048encode, b2048decode, - jsonencode, want_bytes, want_str, ssv_list, want_urlsafe, want_urlsafe_bytes) + jsonencode, twocolon_list, want_bytes, want_str, ssv_list, want_urlsafe, want_urlsafe_bytes) from .bits import count_ones, mask_shift, split_bits, join_bits, mod_ceil, mod_floor from .calendar import want_datetime, want_isodate, want_timestamp, age_and_days from .configparse import MissingConfigError, MissingConfigWarning, ConfigOptions, ConfigParserConfigSource, ConfigSource, DictConfigSource, ConfigValue, EnvConfigSource @@ -47,6 +47,6 @@ __all__ = ( 'jsonencode', 'kwargs_prefix', 'lex', 'ltuple', 'makelist', 'mask_shift', 'matches', 'mod_ceil', 'mod_floor', 'none_pass', 'not_implemented', 'redact_url_password', 'rtuple', 'split_bits', 'ssv_list', 'symbol_table', - 'timed_cache', 'want_bytes', 'want_datetime', 'want_isodate', 'want_str', - 'want_timestamp', 'want_urlsafe', 'want_urlsafe_bytes' + 'timed_cache', 'twocolon_list', 'want_bytes', 'want_datetime', 'want_isodate', + 'want_str', 'want_timestamp', 'want_urlsafe', 'want_urlsafe_bytes' ) diff --git a/src/suou/codecs.py b/src/suou/codecs.py index 9740024..22e52f5 100644 --- a/src/suou/codecs.py +++ b/src/suou/codecs.py @@ -294,6 +294,16 @@ def ssv_list(s: str, *, sep_chars = ',;') -> list[str]: l.pop() return l +def twocolon_list(s: str | None) -> list[str]: + """ + Parse a string on a single line as multiple lines, each line separated by double colon (::). + + Returns a list. + """ + if not s: + return [] + return [x.strip() for x in s.split('::')] + class StringCase(enum.Enum): """ Enum values used by regex validators and storage converters. @@ -329,5 +339,5 @@ class StringCase(enum.Enum): __all__ = ( 'cb32encode', 'cb32decode', 'b32lencode', 'b32ldecode', 'b64encode', 'b64decode', 'jsonencode' - 'StringCase', 'want_bytes', 'want_str', 'jsondecode', 'ssv_list', 'want_urlsafe', 'want_urlsafe_bytes' + 'StringCase', 'want_bytes', 'want_str', 'jsondecode', 'ssv_list', 'twocolon_list', 'want_urlsafe', 'want_urlsafe_bytes' ) \ No newline at end of file From 73c105d5cb6fb19df81dff4bb32a332e0515347f Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Wed, 30 Jul 2025 23:00:46 +0200 Subject: [PATCH 025/121] typing whitespace --- src/suou/functools.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/suou/functools.py b/src/suou/functools.py index 128a1ec..90f807e 100644 --- a/src/suou/functools.py +++ b/src/suou/functools.py @@ -16,21 +16,24 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. import math import time -from typing import Callable +from typing import Callable, TypeVar import warnings from functools import wraps, lru_cache +_T = TypeVar('_T') +_U = TypeVar('_U') + try: from warnings import deprecated except ImportError: # Python <=3.12 does not implement warnings.deprecated - def deprecated(message: str, /, *, category=DeprecationWarning, stacklevel: int = 1): + def deprecated(message: str, /, *, category=DeprecationWarning, stacklevel: int = 1) -> Callable[[Callable[_T, _U]], Callable[_T, _U]]: """ Backport of PEP 702 for Python <=3.12. The stack_level stuff is not reimplemented on purpose because too obscure for the average programmer. """ - def decorator(func: Callable) -> Callable: + def decorator(func: Callable[_T, _U]) -> Callable[_T, _U]: @wraps(func) def wrapper(*a, **ka): if category is not None: @@ -89,7 +92,7 @@ def timed_cache(ttl: int, maxsize: int = 128, typed: bool = False) -> Callable[[ return wrapper return decorator -def none_pass(func: Callable, *args, **kwargs): +def none_pass(func: Callable, *args, **kwargs) -> Callable: """ Wrap callable so that gets called only on not None values. From 6daedc3dee29bdaec1cd35ee37e27a65dea41c24 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Thu, 31 Jul 2025 22:53:44 +0200 Subject: [PATCH 026/121] add sass module, update README --- CHANGELOG.md | 3 +- README.md | 24 ++++++- pyproject.toml | 15 +++-- src/suou/asgi.py | 25 ++++++++ src/suou/codecs.py | 6 ++ src/suou/sass.py | 138 +++++++++++++++++++++++++++++++++++++++++ src/suou/validators.py | 12 ++++ 7 files changed, 213 insertions(+), 10 deletions(-) create mode 100644 src/suou/asgi.py create mode 100644 src/suou/sass.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dafe7d..dfdeaef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,12 @@ + `sqlalchemy`: add `unbound_fk()`, `bound_fk()` + Add `sqlalchemy_async` module with `SQLAlchemy()` -+ Add `timed_cache()`, `TimedDict()`, `none_pass()`, `twocolon_list()` ++ Add `timed_cache()`, `TimedDict()`, `none_pass()`, `twocolon_list()`, `quote_css_string()`, `must_be()` + Add module `calendar` with `want_*` date type conversion utilities and `age_and_days()` + Move obsolete stuff to `obsolete` package (includes configparse 0.3 as of now) + Add `redact` module with `redact_url_password()` + Add more exceptions: `NotFoundError()`, `BabelTowerError()` ++ Add `sass` module ## 0.4.0 diff --git a/README.md b/README.md index 948b4af..56da550 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # SIS Unified Object Underarmor -Good morning, my brother! Welcome the SUOU (SIS Unified Object Underarmor), an utility library for developing API's, database schemas and stuff in Python. +Good morning, my brother! Welcome **SUOU** (**S**IS **U**nified **O**bject **U**nderarmor), the Python library which makes API development faster for developing API's, database schemas and stuff in Python. It provides utilities such as: * [SIQ](https://yusur.moe/protocols/siq.html) * signing and generation of access tokens, on top of [ItsDangerous](https://github.com/pallets/itsdangerous) -* helpers for use in Flask and SQLAlchemy -* ... +* helpers for use in Flask, SQLAlchemy, and other popular frameworks +* i forgor 💀 **It is not an ORM** nor a replacement of it; it works along existing ORMs (currently only SQLAlchemy is supported lol). @@ -26,6 +26,22 @@ $ pip install sakuragasaki46-suou[sqlalchemy] Please note that you probably already have those dependencies, if you just use the library. +## Features + +... + +## Support + +Just a heads up: SUOU was made to support Sakuragasaki46 (me)'s own selfish, egoistic needs. Not to provide a service to the public. + +As a consequence, 'add this add that' stuff is best-effort. + +Expect breaking changes, disruptive renames in bugfix releases, sudden deprecations, years of unmantainment, or sudden removal of SUOU from GH or pip. + +Don't want to depend on my codebase for moral reasons (albeit unrelated)? It's fine. I did not ask you. + +**DO NOT ASK TO MAKE SUOU SAFE FOR CHILDREN**. Enjoy having your fingers cut. + ## License Licensed under the [Apache License, Version 2.0](LICENSE), a non-copyleft free and open source license. @@ -36,3 +52,5 @@ I (sakuragasaki46) may NOT be held accountable for Your use of my code. > It's pointless to file a lawsuit because you feel damaged, and it's only going to turn against you. What a waste of money you could have spent on a vacation or charity, or invested in stocks. +Happy hacking. + diff --git a/pyproject.toml b/pyproject.toml index d5ae206..2194046 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "itsdangerous", "toml", "pydantic", + "setuptools>=78.0.0", "uvloop; os_name=='posix'" ] # - further devdependencies below - # @@ -44,6 +45,7 @@ flask_sqlalchemy = [ "Flask-SqlAlchemy", ] peewee = [ + ## HEADS UP! peewee has setup.py, may slow down installation "peewee>=3.0.0" ] markdown = [ @@ -51,10 +53,12 @@ markdown = [ ] quart = [ "Quart", - "Quart-Schema" + "Quart-Schema", + "starlette>=0.47.2" ] -quart_sqlalchemy = [ - "Quart_SQLALchemy>=3.0.0, <4.0" +sass = [ + ## HEADS UP!! libsass carries a C extension + uses setup.py + "libsass" ] full = [ @@ -63,9 +67,8 @@ full = [ "sakuragasaki46-suou[quart]", "sakuragasaki46-suou[peewee]", "sakuragasaki46-suou[markdown]", - "sakuragasaki46-suou[flask-sqlalchemy]" - # disabled: quart-sqlalchemy causes issues with anyone else. WON'T BE IMPLEMENTED - #"sakuragasaki46-suou[quart-sqlalchemy]" + "sakuragasaki46-suou[flask-sqlalchemy]", + "sakuragasaki46-suou[sass]" ] diff --git a/src/suou/asgi.py b/src/suou/asgi.py new file mode 100644 index 0000000..3f3fd70 --- /dev/null +++ b/src/suou/asgi.py @@ -0,0 +1,25 @@ +""" + +""" + +from typing import Any, Awaitable, Callable, MutableMapping, ParamSpec, Protocol + + +## TYPES ## + +# all the following is copied from Starlette +# available in starlette.types as of starlette==0.47.2 +P = ParamSpec("P") + +ASGIScope = MutableMapping[str, Any] +ASGIMessage = MutableMapping[str, Any] + +ASGIReceive = Callable[[], Awaitable[ASGIMessage]] +ASGISend = Callable[[ASGIMessage], Awaitable[None]] +ASGIApp = Callable[[ASGIScope, ASGIReceive, ASGISend], Awaitable[None]] + +class _MiddlewareFactory(Protocol[P]): + def __call__(self, app: ASGIApp, /, *args: P.args, **kwargs: P.kwargs) -> ASGIApp: ... # pragma: no cover + +## end TYPES ## + diff --git a/src/suou/codecs.py b/src/suou/codecs.py index 22e52f5..e5f94af 100644 --- a/src/suou/codecs.py +++ b/src/suou/codecs.py @@ -304,6 +304,12 @@ def twocolon_list(s: str | None) -> list[str]: return [] return [x.strip() for x in s.split('::')] +def quote_css_string(s): + """Quotes a string as CSS string literal. + + Source: libsass==0.23.0""" + return "'" + ''.join(('\\%06x' % ord(c)) for c in s) + "'" + class StringCase(enum.Enum): """ Enum values used by regex validators and storage converters. diff --git a/src/suou/sass.py b/src/suou/sass.py new file mode 100644 index 0000000..c986d63 --- /dev/null +++ b/src/suou/sass.py @@ -0,0 +1,138 @@ +""" + +""" + + +import datetime +import logging +import os +from typing import Callable, Mapping +from sass import CompileError +from sassutils.builder import Manifest +from importlib.metadata import version as _get_version + +from .codecs import quote_css_string +from .validators import must_be +from .asgi import _MiddlewareFactory, ASGIApp, ASGIReceive, ASGIScope, ASGISend +from . import __version__ as _suou_version + +from pkg_resources import resource_filename + +logger = logging.getLogger(__name__) + +## NOTE Python/PSF recommends use of importlib.metadata for version checks. +_libsass_version = _get_version('libsass') + +class SassAsyncMiddleware(_MiddlewareFactory): + """ + ASGI middleware for development purpose. + Every time a CSS file has requested it finds a matched + Sass/SCSS source file andm then compiled it into CSS. + + Eventual syntax errors are displayed in three ways: + - heading CSS comment (i.e. `/* Error: invalid pro*/`) + - **red text** in `body::before` (in most cases very evident, since every other + style fails to render!) + - server-side logging (level is *error*, remember to enable logging!) + + app = ASGI application to wrap + manifests = a Mapping of build settings, see sass_manifests= option + in `setup.py` + + Shamelessly adapted from libsass==0.23.0 with modifications + + XXX experimental and untested! + """ + + def __init__( + self, app: ASGIApp, manifests: Mapping, package_dir = {}, + error_status = '200 OK' + ): + self.app = must_be(app, Callable, 'app must be a ASGI-compliant callable') + self.manifests = Manifest.normalize_manifests(manifests) + self.package_dir = dict(must_be(package_dir, Mapping, 'package_dir must be a mapping')) + ## ??? + self.error_status = error_status + for package_name in self.manifests: + if package_name in self.package_dir: + continue + self.package_dir[package_name] = resource_filename(package_name, '') + self.paths: list[tuple[str, str, Manifest]] = [] + for pkgname, manifest in self.manifests.items(): + ## WSGI path — is it valid for ASGI as well?? + asgi_path = f'/{manifest.wsgi_path.strip('/')}/' + pkg_dir = self.package_dir[pkgname] + self.paths.append((asgi_path, package_dir, manifest)) + + async def __call__(self, /, scope: ASGIScope, receive: ASGIReceive, send: ASGISend): + path: str = scope.get('path') + if path.endswith('.css'): + for prefix, package_dir, manifest in self.paths: + if not path.startswith(prefix): + continue + css_filename = path[len(prefix):] + sass_filename = manifest.unresolve_filename(package_dir, css_filename) + try: + ## TODO consider async?? + result = manifest.build_one( + package_dir, + sass_filename, + source_map=True + ) + except OSError: + break + except CompileError as e: + logger.error(str(e)) + await send({ + 'type': 'http.response.start', + 'status': self.error_status, + 'headers': [ + 'Content-Type: text/css; charset=utf-8' + ] + }) + await send({ + 'type': 'http.response.body', + 'body': '\n'.join([ + '/*', + str(e), + '***', + f'libsass {_libsass_version} + suou {_suou_version} {datetime.datetime.now().isoformat()}', + '*/', + '', + 'body::before {', + f' content: {quote_css_string(str(e))};', + ' color: maroon;', + ' background-color: white;', + ' white-space: pre-wrap;', + ' display: block;', + ' font-family: monospace;', + ' user-select: text;' + '}' + ]).encode('utf-8') + }) + + async def _read_file(path): + with open(path, 'rb') as f: + while True: + chunk = f.read(4096) + if chunk: + yield chunk + else: + break + + await send({ + 'type': 'http.response.start', + 'status': 200, + 'headers': [ + 'Content-Type: text/css; charset=utf-8' + ] + }) + async for chunk in _read_file(os.path.join(package_dir, result)): + await send({ + 'type': 'http.response.body', + 'body': chunk + }) + + await self.app(scope, receive, send) + + diff --git a/src/suou/validators.py b/src/suou/validators.py index 037d2b6..609b99e 100644 --- a/src/suou/validators.py +++ b/src/suou/validators.py @@ -16,6 +16,10 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. import re +from typing import Any, Iterable, TypeVar + +_T = TypeVar('_T') + def matches(regex: str | int, /, length: int = 0, *, flags=0): """ Return a function which returns true if X is shorter than length and matches the given regex. @@ -27,5 +31,13 @@ def matches(regex: str | int, /, length: int = 0, *, flags=0): return (not length or len(s) < length) and bool(re.fullmatch(regex, s, flags=flags)) return validator +def must_be(obj: _T | Any, typ: type[_T] | Iterable[type], message: str, *, exc = TypeError) -> _T: + """ + Raise TypeError if the requested object is not of the desired type(s), with a nice message. + """ + if not isinstance(obj, typ): + raise TypeError(f'{message}, not {obj.__class__.__name__!r}') + return obj + __all__ = ('matches', ) \ No newline at end of file From 8a209a729c2ee91e5295422c28e82125211078ef Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Thu, 31 Jul 2025 23:14:16 +0200 Subject: [PATCH 027/121] typing --- src/suou/sqlalchemy_async.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/suou/sqlalchemy_async.py b/src/suou/sqlalchemy_async.py index 9ddd24f..461c67f 100644 --- a/src/suou/sqlalchemy_async.py +++ b/src/suou/sqlalchemy_async.py @@ -37,7 +37,7 @@ class SQLAlchemy: self.engine = None def bind(self, url: str): self.engine = create_async_engine(url) - async def begin(self): + async def begin(self) -> Session: if self.engine is None: raise RuntimeError('database is not connected') return await self.engine.begin() From 9286a01de0bb3458a611aee5ddf79227f4dc7bc8 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Thu, 31 Jul 2025 23:16:07 +0200 Subject: [PATCH 028/121] typo --- src/suou/sqlalchemy_async.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/suou/sqlalchemy_async.py b/src/suou/sqlalchemy_async.py index 461c67f..6d908d2 100644 --- a/src/suou/sqlalchemy_async.py +++ b/src/suou/sqlalchemy_async.py @@ -32,8 +32,8 @@ class SQLAlchemy: engine: Engine NotFound = NotFoundError - def __init__(self, base: DeclarativeBase): - self.base = base + def __init__(self, model_class: DeclarativeBase): + self.base = model_class self.engine = None def bind(self, url: str): self.engine = create_async_engine(url) From 7478c8e404936d1a587d591857868cef6a3e0d42 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Thu, 31 Jul 2025 23:27:59 +0200 Subject: [PATCH 029/121] types, again --- src/suou/sqlalchemy_async.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/suou/sqlalchemy_async.py b/src/suou/sqlalchemy_async.py index 6d908d2..cd082b8 100644 --- a/src/suou/sqlalchemy_async.py +++ b/src/suou/sqlalchemy_async.py @@ -14,8 +14,9 @@ This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. """ +from __future__ import annotations from sqlalchemy import Engine, Select, func, select -from sqlalchemy.orm import DeclarativeBase, Session, lazyload +from sqlalchemy.orm import DeclarativeBase, lazyload from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from flask_sqlalchemy.pagination import Pagination @@ -37,7 +38,7 @@ class SQLAlchemy: self.engine = None def bind(self, url: str): self.engine = create_async_engine(url) - async def begin(self) -> Session: + async def begin(self) -> AsyncSession: if self.engine is None: raise RuntimeError('database is not connected') return await self.engine.begin() @@ -47,8 +48,9 @@ class SQLAlchemy: async def paginate(self, select: Select, *, page: int | None = None, per_page: int | None = None, max_per_page: int | None = None, error_out: bool = True, - count: bool = True): + count: bool = True) -> AsyncSelectPagination: """ + ... """ async with self as session: return AsyncSelectPagination( @@ -60,7 +62,6 @@ class SQLAlchemy: ) - class AsyncSelectPagination(Pagination): """ flask_sqlalchemy.SelectPagination but asynchronous From 6846c763f2582464b6ca94dfa4c1b3b96a5134f5 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Mon, 4 Aug 2025 14:33:03 +0200 Subject: [PATCH 030/121] apparently engine.begin() does not need to be awaited --- src/suou/__init__.py | 2 +- src/suou/sqlalchemy_async.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 9108eac..ceeb709 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -33,7 +33,7 @@ from .strtools import PrefixIdentifier from .validators import matches from .redact import redact_url_password -__version__ = "0.5.0-dev30" +__version__ = "0.5.0-dev31" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', diff --git a/src/suou/sqlalchemy_async.py b/src/suou/sqlalchemy_async.py index cd082b8..42045fc 100644 --- a/src/suou/sqlalchemy_async.py +++ b/src/suou/sqlalchemy_async.py @@ -41,8 +41,9 @@ class SQLAlchemy: async def begin(self) -> AsyncSession: if self.engine is None: raise RuntimeError('database is not connected') - return await self.engine.begin() - __aenter__ = begin + return self.engine.begin() + async def __aenter__(self): + return self.begin() async def __aexit__(self, e1, e2, e3): return await self.engine.__aexit__(e1, e2, e3) async def paginate(self, select: Select, *, From daa9f6de0c38fa1e7c57eec402f6d24ec5e26b19 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Mon, 4 Aug 2025 14:39:24 +0200 Subject: [PATCH 031/121] fixed context manager --- src/suou/sqlalchemy_async.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/suou/sqlalchemy_async.py b/src/suou/sqlalchemy_async.py index 42045fc..e7af810 100644 --- a/src/suou/sqlalchemy_async.py +++ b/src/suou/sqlalchemy_async.py @@ -31,21 +31,31 @@ class SQLAlchemy: """ base: DeclarativeBase engine: Engine + _sessions: list[AsyncSession] NotFound = NotFoundError def __init__(self, model_class: DeclarativeBase): self.base = model_class self.engine = None + self._sessions = [] def bind(self, url: str): self.engine = create_async_engine(url) async def begin(self) -> AsyncSession: if self.engine is None: raise RuntimeError('database is not connected') - return self.engine.begin() - async def __aenter__(self): - return self.begin() + ## XXX is it accurate? + s = self.engine.begin() + self._sessions.append(s) + return s + async def __aenter__(self) -> AsyncSession: + return await self.begin() async def __aexit__(self, e1, e2, e3): - return await self.engine.__aexit__(e1, e2, e3) + ## XXX is it accurate? + s = self._sessions.pop() + if e1: + await s.rollback() + else: + await s.commit() async def paginate(self, select: Select, *, page: int | None = None, per_page: int | None = None, max_per_page: int | None = None, error_out: bool = True, From dbf85f5369def04864be4430e77db6c34c35e39e Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Mon, 4 Aug 2025 14:43:06 +0200 Subject: [PATCH 032/121] make it work --- src/suou/sqlalchemy_async.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/suou/sqlalchemy_async.py b/src/suou/sqlalchemy_async.py index e7af810..1079f6b 100644 --- a/src/suou/sqlalchemy_async.py +++ b/src/suou/sqlalchemy_async.py @@ -14,6 +14,12 @@ This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. """ +from contextlib import _GeneratorContextManager + + +from sqlalchemy.engine.base import Connection + + from __future__ import annotations from sqlalchemy import Engine, Select, func, select from sqlalchemy.orm import DeclarativeBase, lazyload @@ -44,7 +50,7 @@ class SQLAlchemy: if self.engine is None: raise RuntimeError('database is not connected') ## XXX is it accurate? - s = self.engine.begin() + s = AsyncSession(self.engine) self._sessions.append(s) return s async def __aenter__(self) -> AsyncSession: From 7953ff4847a28d5fe12aaa798376149d05875415 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Mon, 4 Aug 2025 14:44:34 +0200 Subject: [PATCH 033/121] remove VSC artifacts --- src/suou/sqlalchemy_async.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/suou/sqlalchemy_async.py b/src/suou/sqlalchemy_async.py index 1079f6b..0732552 100644 --- a/src/suou/sqlalchemy_async.py +++ b/src/suou/sqlalchemy_async.py @@ -14,13 +14,9 @@ This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. """ -from contextlib import _GeneratorContextManager - - -from sqlalchemy.engine.base import Connection - - from __future__ import annotations + + from sqlalchemy import Engine, Select, func, select from sqlalchemy.orm import DeclarativeBase, lazyload from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine From 1ab0982dc3de0f7ffb64673aec48b1ef00db19ad Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Tue, 5 Aug 2025 11:11:43 +0200 Subject: [PATCH 034/121] add session cleanup --- src/suou/sqlalchemy_async.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/suou/sqlalchemy_async.py b/src/suou/sqlalchemy_async.py index 0732552..2f9274f 100644 --- a/src/suou/sqlalchemy_async.py +++ b/src/suou/sqlalchemy_async.py @@ -58,6 +58,7 @@ class SQLAlchemy: await s.rollback() else: await s.commit() + await s.close() async def paginate(self, select: Select, *, page: int | None = None, per_page: int | None = None, max_per_page: int | None = None, error_out: bool = True, From 5bdf13b104758fbac3a1be71745d33c036e06f8c Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 8 Aug 2025 07:58:17 +0200 Subject: [PATCH 035/121] add SQLAlchemy.create_all() --- CHANGELOG.md | 3 ++- src/suou/sass.py | 2 +- src/suou/sqlalchemy_async.py | 23 ++++++++++++++++++----- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfdeaef..7815fb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ ## 0.5.0 + `sqlalchemy`: add `unbound_fk()`, `bound_fk()` -+ Add `sqlalchemy_async` module with `SQLAlchemy()` ++ Add `sqlalchemy_async` module with `SQLAlchemy()` async database binding. + * Supports being used as an async context manager + Add `timed_cache()`, `TimedDict()`, `none_pass()`, `twocolon_list()`, `quote_css_string()`, `must_be()` + Add module `calendar` with `want_*` date type conversion utilities and `age_and_days()` + Move obsolete stuff to `obsolete` package (includes configparse 0.3 as of now) diff --git a/src/suou/sass.py b/src/suou/sass.py index c986d63..7c2b82d 100644 --- a/src/suou/sass.py +++ b/src/suou/sass.py @@ -62,7 +62,7 @@ class SassAsyncMiddleware(_MiddlewareFactory): ## WSGI path — is it valid for ASGI as well?? asgi_path = f'/{manifest.wsgi_path.strip('/')}/' pkg_dir = self.package_dir[pkgname] - self.paths.append((asgi_path, package_dir, manifest)) + self.paths.append((asgi_path, pkg_dir, manifest)) async def __call__(self, /, scope: ASGIScope, receive: ASGIReceive, send: ASGISend): path: str = scope.get('path') diff --git a/src/suou/sqlalchemy_async.py b/src/suou/sqlalchemy_async.py index 2f9274f..6838329 100644 --- a/src/suou/sqlalchemy_async.py +++ b/src/suou/sqlalchemy_async.py @@ -19,7 +19,7 @@ from __future__ import annotations from sqlalchemy import Engine, Select, func, select from sqlalchemy.orm import DeclarativeBase, lazyload -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine from flask_sqlalchemy.pagination import Pagination from suou.exceptions import NotFoundError @@ -32,7 +32,7 @@ class SQLAlchemy: NEW 0.5.0 """ base: DeclarativeBase - engine: Engine + engine: AsyncEngine _sessions: list[AsyncSession] NotFound = NotFoundError @@ -42,9 +42,11 @@ class SQLAlchemy: self._sessions = [] def bind(self, url: str): self.engine = create_async_engine(url) - async def begin(self) -> AsyncSession: + def _ensure_engine(self): if self.engine is None: raise RuntimeError('database is not connected') + async def begin(self) -> AsyncSession: + self._ensure_engine() ## XXX is it accurate? s = AsyncSession(self.engine) self._sessions.append(s) @@ -64,7 +66,7 @@ class SQLAlchemy: max_per_page: int | None = None, error_out: bool = True, count: bool = True) -> AsyncSelectPagination: """ - ... + Return a pagination. Analogous to flask_sqlalchemy.SQLAlchemy.paginate(). """ async with self as session: return AsyncSelectPagination( @@ -74,11 +76,22 @@ class SQLAlchemy: per_page=per_page, max_per_page=max_per_page, error_out=self.NotFound if error_out else None, count=count ) + async def create_all(self, *, checkfirst = True): + """ + Initialize database + """ + self._ensure_engine() + self.base.metadata.create_all( + self.engine, checkfirst=checkfirst + ) + class AsyncSelectPagination(Pagination): """ - flask_sqlalchemy.SelectPagination but asynchronous + flask_sqlalchemy.SelectPagination but asynchronous. + + Pagination is not part of the public API, therefore expect that it may break """ async def _query_items(self) -> list: From a23cad2e457516f6b1bef0f428de89a3bf6575cf Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sun, 10 Aug 2025 09:09:20 +0200 Subject: [PATCH 036/121] add Quart utilities add_i18n(), negotiate() add_rest() --- CHANGELOG.md | 1 + src/suou/flask.py | 4 ++- src/suou/http.py | 24 +++++++++++++++++ src/suou/quart.py | 68 ++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 src/suou/http.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7815fb1..20f0a6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ + Add `redact` module with `redact_url_password()` + Add more exceptions: `NotFoundError()`, `BabelTowerError()` + Add `sass` module ++ Add `quart` module with `negotiate()`, `add_rest()`, `add_i18n()`, `WantsContentType` ## 0.4.0 diff --git a/src/suou/flask.py b/src/suou/flask.py index a2ce4f9..f097c8e 100644 --- a/src/suou/flask.py +++ b/src/suou/flask.py @@ -67,10 +67,11 @@ def get_flask_conf(key: str, default = None, *, app: Flask | None = None) -> Any app = current_app return app.config.get(key, default) -## XXX UNTESTED! def harden(app: Flask): """ Make common "dork" endpoints unavailable + + XXX UNTESTED! """ i = 1 for ep in SENSITIVE_ENDPOINTS: @@ -81,6 +82,7 @@ def harden(app: Flask): return app + # Optional dependency: do not import into __init__.py __all__ = ('add_context_from_config', 'add_i18n', 'get_flask_conf', 'harden') diff --git a/src/suou/http.py b/src/suou/http.py new file mode 100644 index 0000000..30c4c50 --- /dev/null +++ b/src/suou/http.py @@ -0,0 +1,24 @@ +""" +Framework-agnostic utilities for web app development. + +--- + +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. +""" + +from __future__ import annotations +import enum + +class WantsContentType(enum.Enum): + PLAIN = 'text/plain' + JSON = 'application/json' + HTML = 'text/html' + diff --git a/src/suou/quart.py b/src/suou/quart.py index 9caaee1..6f393bc 100644 --- a/src/suou/quart.py +++ b/src/suou/quart.py @@ -14,4 +14,70 @@ This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. """ -# TODO everything \ No newline at end of file +from __future__ import annotations + +from flask import current_app +from quart import Quart, request, g +from quart_schema import QuartSchema + +from suou.http import WantsContentType + +from .i18n import I18n +from .itertools import makelist + +def add_i18n(app: Quart, i18n: I18n, var_name: str = 'T', *, + query_arg: str = 'lang', default_lang = 'en'): + ''' + Integrate a I18n() object with a Quart application: + - set g.lang + - add T() to Jinja templates + + XXX UNTESTED + ''' + def _get_lang(): + lang = request.args.get(query_arg) + if not lang: + for lp in request.headers.get('accept-language', 'en').split(','): + l = lp.split(';')[0] + lang = l + break + else: + lang = default_lang + return lang + + @app.context_processor + def _add_i18n(): + return {var_name: i18n.lang(_get_lang()).t} + + @app.before_request + def _add_language_code(): + g.lang = _get_lang() + + return app + + +def negotiate() -> WantsContentType: + """ + Return an appropriate MIME type for content negotiation. + """ + if 'application/json' in request.accept_mimetypes or any(request.path.startswith(f'/{p.strip('/')}/') for p in current_app.config.get('REST_PATHS')): + return WantsContentType.JSON + elif request.user_agent.string.startswith('Mozilla/'): + return WantsContentType.HTML + else: + return WantsContentType.PLAIN + + +def add_rest(app: Quart, *bases: str, **kwargs) -> QuartSchema: + """ + Construct a REST ... + + The rest of ... + """ + + schema = QuartSchema(app, **kwargs) + app.config['REST_PATHS'] = makelist(bases, wrap=False) + return schema + + +__all__ = ('add_i18n', 'negotiate', 'add_rest') \ No newline at end of file From 55c9f5fee2af61db584705b2328c91edfca90a24 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sun, 10 Aug 2025 09:40:01 +0200 Subject: [PATCH 037/121] export WantsContentType --- src/suou/__init__.py | 3 ++- src/suou/http.py | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/suou/__init__.py b/src/suou/__init__.py index ceeb709..38ba718 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -32,6 +32,7 @@ from .lex import symbol_table, lex, ilex from .strtools import PrefixIdentifier from .validators import matches from .redact import redact_url_password +from .http import WantsContentType __version__ = "0.5.0-dev31" @@ -40,7 +41,7 @@ __all__ = ( 'DictConfigSource', 'EnvConfigSource', 'I18n', 'Incomplete', 'JsonI18n', 'MissingConfigError', 'MissingConfigWarning', 'PrefixIdentifier', 'Siq', 'SiqCache', 'SiqGen', 'SiqType', 'Snowflake', 'SnowflakeGen', - 'StringCase', 'TimedDict', 'TomlI18n', 'Wanted', + 'StringCase', 'TimedDict', 'TomlI18n', 'Wanted', 'WantsContentType', 'addattr', 'additem', 'age_and_days', 'b2048decode', 'b2048encode', 'b32ldecode', 'b32lencode', 'b64encode', 'b64decode', 'cb32encode', 'cb32decode', 'count_ones', 'deprecated', 'ilex', 'join_bits', diff --git a/src/suou/http.py b/src/suou/http.py index 30c4c50..007076b 100644 --- a/src/suou/http.py +++ b/src/suou/http.py @@ -22,3 +22,6 @@ class WantsContentType(enum.Enum): JSON = 'application/json' HTML = 'text/html' + + +__all__ = ('WantsContentType',) \ No newline at end of file From ac66f3632c11d0a0d27571efa52693779a78c2a4 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sun, 10 Aug 2025 10:48:32 +0200 Subject: [PATCH 038/121] bugfix to negotiate(), port to Flask --- src/suou/flask.py | 15 ++++++++++++++- src/suou/quart.py | 9 ++++----- src/suou/sass.py | 4 ++-- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/suou/flask.py b/src/suou/flask.py index f097c8e..1a2ffef 100644 --- a/src/suou/flask.py +++ b/src/suou/flask.py @@ -16,6 +16,8 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. from typing import Any from flask import Flask, abort, current_app, g, request + +from suou.http import WantsContentType from .i18n import I18n from .configparse import ConfigOptions from .dorks import SENSITIVE_ENDPOINTS @@ -82,8 +84,19 @@ def harden(app: Flask): return app +def negotiate() -> WantsContentType: + """ + Return an appropriate MIME type for the sake of content negotiation. + """ + if any(request.path.startswith(f'/{p.strip('/')}/') for p in current_app.config.get('REST_PATHS', [])): + return WantsContentType.JSON + elif request.user_agent.string.startswith('Mozilla/'): + return WantsContentType.HTML + else: + return request.accept_mimetypes.best_match([WantsContentType.PLAIN, WantsContentType.JSON, WantsContentType.HTML]) + # Optional dependency: do not import into __init__.py -__all__ = ('add_context_from_config', 'add_i18n', 'get_flask_conf', 'harden') +__all__ = ('add_context_from_config', 'add_i18n', 'get_flask_conf', 'harden', 'negotiate') diff --git a/src/suou/quart.py b/src/suou/quart.py index 6f393bc..36b4a5f 100644 --- a/src/suou/quart.py +++ b/src/suou/quart.py @@ -16,8 +16,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. from __future__ import annotations -from flask import current_app -from quart import Quart, request, g +from quart import current_app, Quart, request, g from quart_schema import QuartSchema from suou.http import WantsContentType @@ -58,14 +57,14 @@ def add_i18n(app: Quart, i18n: I18n, var_name: str = 'T', *, def negotiate() -> WantsContentType: """ - Return an appropriate MIME type for content negotiation. + Return an appropriate MIME type for the sake of content negotiation. """ - if 'application/json' in request.accept_mimetypes or any(request.path.startswith(f'/{p.strip('/')}/') for p in current_app.config.get('REST_PATHS')): + if any(request.path.startswith(f'/{p.strip('/')}/') for p in current_app.config.get('REST_PATHS', [])): return WantsContentType.JSON elif request.user_agent.string.startswith('Mozilla/'): return WantsContentType.HTML else: - return WantsContentType.PLAIN + return request.accept_mimetypes.best_match([WantsContentType.PLAIN, WantsContentType.JSON, WantsContentType.HTML]) def add_rest(app: Quart, *bases: str, **kwargs) -> QuartSchema: diff --git a/src/suou/sass.py b/src/suou/sass.py index 7c2b82d..f727f9d 100644 --- a/src/suou/sass.py +++ b/src/suou/sass.py @@ -87,7 +87,7 @@ class SassAsyncMiddleware(_MiddlewareFactory): 'type': 'http.response.start', 'status': self.error_status, 'headers': [ - 'Content-Type: text/css; charset=utf-8' + ('Content-Type', 'text/css; charset=utf-8'), ] }) await send({ @@ -124,7 +124,7 @@ class SassAsyncMiddleware(_MiddlewareFactory): 'type': 'http.response.start', 'status': 200, 'headers': [ - 'Content-Type: text/css; charset=utf-8' + ('Content-Type', 'text/css; charset=utf-8'), ] }) async for chunk in _read_file(os.path.join(package_dir, result)): From 7041c19b57e3ff30589c49b1abd913b201d73d6e Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sun, 10 Aug 2025 10:55:47 +0200 Subject: [PATCH 039/121] bug --- src/suou/sass.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/suou/sass.py b/src/suou/sass.py index f727f9d..5124530 100644 --- a/src/suou/sass.py +++ b/src/suou/sass.py @@ -87,7 +87,7 @@ class SassAsyncMiddleware(_MiddlewareFactory): 'type': 'http.response.start', 'status': self.error_status, 'headers': [ - ('Content-Type', 'text/css; charset=utf-8'), + (b'Content-Type', b'text/css; charset=utf-8'), ] }) await send({ @@ -124,7 +124,7 @@ class SassAsyncMiddleware(_MiddlewareFactory): 'type': 'http.response.start', 'status': 200, 'headers': [ - ('Content-Type', 'text/css; charset=utf-8'), + (b'Content-Type', b'text/css; charset=utf-8'), ] }) async for chunk in _read_file(os.path.join(package_dir, result)): From 9cd2345c8071e53315dd6b8c851cb602709ae6c5 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sun, 10 Aug 2025 11:08:05 +0200 Subject: [PATCH 040/121] ASGI requires the whole body --- src/suou/sass.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/suou/sass.py b/src/suou/sass.py index 5124530..d96621a 100644 --- a/src/suou/sass.py +++ b/src/suou/sass.py @@ -120,6 +120,10 @@ class SassAsyncMiddleware(_MiddlewareFactory): else: break + resp_body = b'' + async for chunk in _read_file(os.path.join(package_dir, result)): + resp_body += chunk + await send({ 'type': 'http.response.start', 'status': 200, @@ -127,11 +131,11 @@ class SassAsyncMiddleware(_MiddlewareFactory): (b'Content-Type', b'text/css; charset=utf-8'), ] }) - async for chunk in _read_file(os.path.join(package_dir, result)): - await send({ - 'type': 'http.response.body', - 'body': chunk - }) + + await send({ + 'type': 'http.response.body', + 'body': resp_body + }) await self.app(scope, receive, send) From b035c86b3127ba5fe8f035c83e464652dcdeef8a Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sun, 10 Aug 2025 11:18:12 +0200 Subject: [PATCH 041/121] http.response.start goes AFTER http.response.body --- src/suou/sass.py | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/src/suou/sass.py b/src/suou/sass.py index d96621a..21dfc7c 100644 --- a/src/suou/sass.py +++ b/src/suou/sass.py @@ -11,7 +11,7 @@ from sass import CompileError from sassutils.builder import Manifest from importlib.metadata import version as _get_version -from .codecs import quote_css_string +from .codecs import quote_css_string, want_bytes from .validators import must_be from .asgi import _MiddlewareFactory, ASGIApp, ASGIReceive, ASGIScope, ASGISend from . import __version__ as _suou_version @@ -83,13 +83,6 @@ class SassAsyncMiddleware(_MiddlewareFactory): break except CompileError as e: logger.error(str(e)) - await send({ - 'type': 'http.response.start', - 'status': self.error_status, - 'headers': [ - (b'Content-Type', b'text/css; charset=utf-8'), - ] - }) await send({ 'type': 'http.response.body', 'body': '\n'.join([ @@ -110,6 +103,13 @@ class SassAsyncMiddleware(_MiddlewareFactory): '}' ]).encode('utf-8') }) + await send({ + 'type': 'http.response.start', + 'status': self.error_status, + 'headers': [ + (b'Content-Type', b'text/css; charset=utf-8'), + ] + }) async def _read_file(path): with open(path, 'rb') as f: @@ -120,22 +120,19 @@ class SassAsyncMiddleware(_MiddlewareFactory): else: break - resp_body = b'' async for chunk in _read_file(os.path.join(package_dir, result)): - resp_body += chunk + await send({ + 'type': 'http.response.body', + 'body': chunk + }) await send({ 'type': 'http.response.start', 'status': 200, 'headers': [ - (b'Content-Type', b'text/css; charset=utf-8'), + (b'Content-Type', b'text/css; charset=utf-8') ] }) - - await send({ - 'type': 'http.response.body', - 'body': resp_body - }) await self.app(scope, receive, send) From 13589ab8195761dac99b4368ff22611d039deb7b Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sun, 10 Aug 2025 11:35:36 +0200 Subject: [PATCH 042/121] add Content-Length header --- src/suou/sass.py | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/src/suou/sass.py b/src/suou/sass.py index d96621a..63442d4 100644 --- a/src/suou/sass.py +++ b/src/suou/sass.py @@ -11,7 +11,7 @@ from sass import CompileError from sassutils.builder import Manifest from importlib.metadata import version as _get_version -from .codecs import quote_css_string +from .codecs import quote_css_string, want_bytes from .validators import must_be from .asgi import _MiddlewareFactory, ASGIApp, ASGIReceive, ASGIScope, ASGISend from . import __version__ as _suou_version @@ -83,32 +83,35 @@ class SassAsyncMiddleware(_MiddlewareFactory): break except CompileError as e: logger.error(str(e)) + resp_body = '\n'.join([ + '/*', + str(e), + '***', + f'libsass {_libsass_version} + suou {_suou_version} {datetime.datetime.now().isoformat()}', + '*/', + '', + 'body::before {', + f' content: {quote_css_string(str(e))};', + ' color: maroon;', + ' background-color: white;', + ' white-space: pre-wrap;', + ' display: block;', + ' font-family: monospace;', + ' user-select: text;' + '}' + ]).encode('utf-8') + await send({ 'type': 'http.response.start', 'status': self.error_status, 'headers': [ (b'Content-Type', b'text/css; charset=utf-8'), + (b'Content-Length', want_bytes(f'{len(resp_body)}')) ] }) await send({ 'type': 'http.response.body', - 'body': '\n'.join([ - '/*', - str(e), - '***', - f'libsass {_libsass_version} + suou {_suou_version} {datetime.datetime.now().isoformat()}', - '*/', - '', - 'body::before {', - f' content: {quote_css_string(str(e))};', - ' color: maroon;', - ' background-color: white;', - ' white-space: pre-wrap;', - ' display: block;', - ' font-family: monospace;', - ' user-select: text;' - '}' - ]).encode('utf-8') + 'body': resp_body }) async def _read_file(path): @@ -129,6 +132,7 @@ class SassAsyncMiddleware(_MiddlewareFactory): 'status': 200, 'headers': [ (b'Content-Type', b'text/css; charset=utf-8'), + (b'Content-Length', want_bytes(f'{len(resp_body)}')) ] }) From ee97319a598a753a85fbd8e78a51fc1c51962c7b Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sun, 10 Aug 2025 11:43:35 +0200 Subject: [PATCH 043/121] remove the mess caused by merge conflict --- src/suou/sass.py | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/src/suou/sass.py b/src/suou/sass.py index fcf5241..a73923e 100644 --- a/src/suou/sass.py +++ b/src/suou/sass.py @@ -113,13 +113,6 @@ class SassAsyncMiddleware(_MiddlewareFactory): 'type': 'http.response.body', 'body': resp_body }) - await send({ - 'type': 'http.response.start', - 'status': self.error_status, - 'headers': [ - (b'Content-Type', b'text/css; charset=utf-8'), - ] - }) async def _read_file(path): with open(path, 'rb') as f: @@ -129,22 +122,22 @@ class SassAsyncMiddleware(_MiddlewareFactory): yield chunk else: break - - async for chunk in _read_file(os.path.join(package_dir, result)): - await send({ - 'type': 'http.response.body', - 'body': chunk - }) await send({ 'type': 'http.response.start', 'status': 200, 'headers': [ (b'Content-Type', b'text/css; charset=utf-8'), - (b'Content-Length', want_bytes(f'{len(resp_body)}')) + (b'Content-Length', want_bytes(f'{os.path.getsize(path)}')) ] }) + async for chunk in _read_file(os.path.join(package_dir, result)): + await send({ + 'type': 'http.response.body', + 'body': chunk + }) + await self.app(scope, receive, send) From add9230a5f23ecfe0a9ddc2ee7a67c590153e826 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sun, 10 Aug 2025 11:45:55 +0200 Subject: [PATCH 044/121] fix wrong path --- src/suou/sass.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/suou/sass.py b/src/suou/sass.py index a73923e..0b7477c 100644 --- a/src/suou/sass.py +++ b/src/suou/sass.py @@ -122,17 +122,19 @@ class SassAsyncMiddleware(_MiddlewareFactory): yield chunk else: break + + file_path = os.path.join(package_dir, result) await send({ 'type': 'http.response.start', 'status': 200, 'headers': [ (b'Content-Type', b'text/css; charset=utf-8'), - (b'Content-Length', want_bytes(f'{os.path.getsize(path)}')) + (b'Content-Length', want_bytes(f'{os.path.getsize(file_path)}')) ] }) - async for chunk in _read_file(os.path.join(package_dir, result)): + async for chunk in _read_file(file_path): await send({ 'type': 'http.response.body', 'body': chunk From 3edf8d37b5f572b6a5bd4f27106c34600160fa75 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sun, 10 Aug 2025 11:47:27 +0200 Subject: [PATCH 045/121] unfortunately, response body is still needed in full --- src/suou/sass.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/suou/sass.py b/src/suou/sass.py index 0b7477c..88b885f 100644 --- a/src/suou/sass.py +++ b/src/suou/sass.py @@ -134,11 +134,14 @@ class SassAsyncMiddleware(_MiddlewareFactory): ] }) + resp_body = b'' async for chunk in _read_file(file_path): - await send({ - 'type': 'http.response.body', - 'body': chunk - }) + resp_body += chunk + + await send({ + 'type': 'http.response.body', + 'body': resp_body + }) await self.app(scope, receive, send) From e370172826bb3e02fd8a4bbe8ac1e73d3f1488d2 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sun, 10 Aug 2025 11:51:42 +0200 Subject: [PATCH 046/121] make SassAsyncMiddleware return after handling SASS --- src/suou/sass.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/suou/sass.py b/src/suou/sass.py index 88b885f..092179b 100644 --- a/src/suou/sass.py +++ b/src/suou/sass.py @@ -114,6 +114,8 @@ class SassAsyncMiddleware(_MiddlewareFactory): 'body': resp_body }) + return + async def _read_file(path): with open(path, 'rb') as f: while True: @@ -143,6 +145,8 @@ class SassAsyncMiddleware(_MiddlewareFactory): 'body': resp_body }) + return + await self.app(scope, receive, send) From 6ff27f98587c8a07d3f53837b9d71dd9153ed043 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Mon, 11 Aug 2025 09:53:01 +0200 Subject: [PATCH 047/121] add pronouns --- CHANGELOG.md | 1 + src/suou/dei.py | 110 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 src/suou/dei.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 20f0a6b..2e340e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ + Add more exceptions: `NotFoundError()`, `BabelTowerError()` + Add `sass` module + Add `quart` module with `negotiate()`, `add_rest()`, `add_i18n()`, `WantsContentType` ++ Add `dei` module: it implements a compact and standardized representation for pronouns, inspired by the one in use at PronounDB ## 0.4.0 diff --git a/src/suou/dei.py b/src/suou/dei.py new file mode 100644 index 0000000..c485216 --- /dev/null +++ b/src/suou/dei.py @@ -0,0 +1,110 @@ +""" +Utilities for Diversity, Equity, Inclusion. + +This implements a cool compact representation for pronouns, inspired by the one in use at + +--- + +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. +""" + + +from __future__ import annotations + + +BRICKS = '@abcdefghijklmnopqrstuvwxyz+?-\'/' +""" +Legend: +a through z, ' (apostrophe) and - (hyphen/dash) mean what they mean. +? is an unknown symbol or non-ASCII/non-alphabetic character. ++ is a suffix separator (like / but allows for a more compact notation). +/ is the separator. + +Except for the presets (see Pronoun.PRESETS below), pronouns expand to the +given notation: e.g. ae+r is ae/aer. +""" + +class Pronoun(int): + """ + Implementation of pronouns in a compact style. + A pronoun is first normalized, then furtherly compressed by turning it + into an integer (see Pronoun.from_short()). + + Subclass of int, ideal for databases. Short form is recommended in + transfer (e.g. if writing a REST). + """ + PRESETS = { + 'hh': 'he/him', + 'sh': 'she/her', + 'tt': 'they/them', + 'ii': 'it/its', + 'hs': 'he/she', + 'ht': 'he/they', + 'hi': 'he/it', + 'shh': 'she/he', + 'st': 'she/they', + 'si': 'she/it', + 'th': 'they/he', + 'ts': 'they/she', + 'ti': 'they/it', + } + + UNSPECIFIED = 0 + + ## presets from PronounDB + ## DO NOT TOUCH the values unless you know their exact correspondence!! + ## hint: Pronoun.from_short() + HE = HE_HIM = 264 + SHE = SHE_HER = 275 + THEY = THEY_THEM = 660 + IT = IT_ITS = 297 + HE_SHE = 616 + HE_THEY = 648 + HE_IT = 296 + SHE_HE = 8467 + SHE_THEY = 657 + SHE_IT = 307 + THEY_HE = 276 + THEY_SHE = 628 + THEY_IT = 308 + ANY = 26049 + OTHER = 19047055 + ASK = 11873 + AVOID = NAME_ONLY = 4505281 + + def short(self) -> str: + i = self + s = '' + while i > 0: + s += BRICKS[i % 32] + i >>= 5 + return s + + def full(self): + s = self.short() + + if s in self.PRESETS: + return self.PRESETS[s] + + if '+' in s: + s1, s2 = s.rsplit('+') + s = s1 + '/' + s1 + s2 + + return s + __str__ = full + + @classmethod + def from_short(self, s: str) -> Pronoun: + i = 0 + for j, ch in enumerate(s): + i += BRICKS.index(ch) << (5 * j) + return Pronoun(i) + From 1384bdfc5b81789f8eb7600b543de5f8d26bf184 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Tue, 12 Aug 2025 21:38:58 +0200 Subject: [PATCH 048/121] support async iterator on AsyncSelectPagination --- src/suou/__init__.py | 2 +- src/suou/sqlalchemy_async.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 38ba718..d11ad6f 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -34,7 +34,7 @@ from .validators import matches from .redact import redact_url_password from .http import WantsContentType -__version__ = "0.5.0-dev31" +__version__ = "0.5.0-dev32" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', diff --git a/src/suou/sqlalchemy_async.py b/src/suou/sqlalchemy_async.py index 6838329..99f0777 100644 --- a/src/suou/sqlalchemy_async.py +++ b/src/suou/sqlalchemy_async.py @@ -146,4 +146,9 @@ class AsyncSelectPagination(Pagination): self.total = await self._query_count() return self + async def __aiter__(self): + await self + for i in self.items: + yield i + __all__ = ('SQLAlchemy', ) \ No newline at end of file From d1dd0a3ee07f1c514a4b520be5b02578081c08b2 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Tue, 12 Aug 2025 21:41:25 +0200 Subject: [PATCH 049/121] fix, it does not need awaiture --- src/suou/sqlalchemy_async.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/suou/sqlalchemy_async.py b/src/suou/sqlalchemy_async.py index 99f0777..8955f4f 100644 --- a/src/suou/sqlalchemy_async.py +++ b/src/suou/sqlalchemy_async.py @@ -138,16 +138,12 @@ class AsyncSelectPagination(Pagination): self.error_out = error_out self.has_count = count - async def __await__(self): + async def __aiter__(self): self.items = await self._query_items() if not self.items and self.page != 1 and self.error_out: raise self.error_out if self.has_count: self.total = await self._query_count() - return self - - async def __aiter__(self): - await self for i in self.items: yield i From 6055c4ed3b3496c5ac777e1c3057dab01b279073 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Tue, 12 Aug 2025 21:44:03 +0200 Subject: [PATCH 050/121] fix internals not returning the query --- src/suou/sqlalchemy_async.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/suou/sqlalchemy_async.py b/src/suou/sqlalchemy_async.py index 8955f4f..7a710bc 100644 --- a/src/suou/sqlalchemy_async.py +++ b/src/suou/sqlalchemy_async.py @@ -99,12 +99,13 @@ class AsyncSelectPagination(Pagination): select = select_q.limit(self.per_page).offset(self._query_offset) session: AsyncSession = self._query_args["session"] out = (await session.execute(select)).scalars() + return out async def _query_count(self) -> int: select_q: Select = self._query_args["select"] sub = select_q.options(lazyload("*")).order_by(None).subquery() session: AsyncSession = self._query_args["session"] - out = await session.execute(select(func.count()).select_from(sub)) + out = (await session.execute(select(func.count()).select_from(sub))).scalar() return out def __init__(self, @@ -140,6 +141,8 @@ class AsyncSelectPagination(Pagination): async def __aiter__(self): self.items = await self._query_items() + if self.items is None: + raise RuntimeError('query returned None') if not self.items and self.page != 1 and self.error_out: raise self.error_out if self.has_count: From da6c767698960e6b7b001cb7b412b0b6c132ed76 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 15 Aug 2025 11:57:51 +0200 Subject: [PATCH 051/121] set expire_on_commit= to False by default --- src/suou/sqlalchemy_async.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/suou/sqlalchemy_async.py b/src/suou/sqlalchemy_async.py index 7a710bc..b361046 100644 --- a/src/suou/sqlalchemy_async.py +++ b/src/suou/sqlalchemy_async.py @@ -45,10 +45,10 @@ class SQLAlchemy: def _ensure_engine(self): if self.engine is None: raise RuntimeError('database is not connected') - async def begin(self) -> AsyncSession: + async def begin(self, *, expire_on_commit = False, **kw) -> AsyncSession: self._ensure_engine() ## XXX is it accurate? - s = AsyncSession(self.engine) + s = AsyncSession(self.engine, expire_on_commit=expire_on_commit, **kw) self._sessions.append(s) return s async def __aenter__(self) -> AsyncSession: From 76921a28417c4714f0edcdc135771d15b61b6cc9 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 15 Aug 2025 12:08:03 +0200 Subject: [PATCH 052/121] parent_children() now uses lazy='selectin' --- src/suou/sqlalchemy.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/suou/sqlalchemy.py b/src/suou/sqlalchemy.py index b297352..0b4b66b 100644 --- a/src/suou/sqlalchemy.py +++ b/src/suou/sqlalchemy.py @@ -204,7 +204,7 @@ def age_pair(*, nullable: bool = False, **ka) -> tuple[Column, Column]: return (date_col, acc_col) -def parent_children(keyword: str, /, **kwargs): +def parent_children(keyword: str, /, *, lazy: str = 'selectin', **kwargs): """ Self-referential one-to-many relationship pair. Parent comes first, children come later. @@ -214,13 +214,15 @@ def parent_children(keyword: str, /, **kwargs): Additional keyword arguments can be sourced with parent_ and child_ argument prefixes, obviously. + + CHANGED 0.5.0: the both relationship()s use lazy='selectin' attribute now by default. """ parent_kwargs = kwargs_prefix(kwargs, 'parent_') child_kwargs = kwargs_prefix(kwargs, 'child_') - parent = Incomplete(relationship, Wanted(lambda o, n: o.__name__), back_populates=f'child_{keyword}s', **parent_kwargs) - child = Incomplete(relationship, Wanted(lambda o, n: o.__name__), back_populates=f'parent_{keyword}', **child_kwargs) + parent = Incomplete(relationship, Wanted(lambda o, n: o.__name__), back_populates=f'child_{keyword}s', lazy=lazy, **parent_kwargs) + child = Incomplete(relationship, Wanted(lambda o, n: o.__name__), back_populates=f'parent_{keyword}', lazy=lazy, **child_kwargs) return parent, child From c3215c7c8b2113c54bb724914c7ad5fcd814cebe Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 15 Aug 2025 14:13:35 +0200 Subject: [PATCH 053/121] add sqlalchemy.async_query() --- CHANGELOG.md | 3 +++ src/suou/redact.py | 14 ++++++++++++++ src/suou/sqlalchemy_async.py | 26 ++++++++++++++++++++++++-- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e340e3..51aa500 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ + `sqlalchemy`: add `unbound_fk()`, `bound_fk()` + Add `sqlalchemy_async` module with `SQLAlchemy()` async database binding. * Supports being used as an async context manager + * Automatically handles commit and rollback ++ `sqlalchemy_async` also offers `async_query()` ++ Changed `sqlalchemy.parent_children()` to use `lazy='selectin'` by default + Add `timed_cache()`, `TimedDict()`, `none_pass()`, `twocolon_list()`, `quote_css_string()`, `must_be()` + Add module `calendar` with `want_*` date type conversion utilities and `age_and_days()` + Move obsolete stuff to `obsolete` package (includes configparse 0.3 as of now) diff --git a/src/suou/redact.py b/src/suou/redact.py index e8ee104..cef86e7 100644 --- a/src/suou/redact.py +++ b/src/suou/redact.py @@ -1,5 +1,19 @@ """ "Security through obscurity" helpers for less sensitive logging + +NEW 0.5.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 re diff --git a/src/suou/sqlalchemy_async.py b/src/suou/sqlalchemy_async.py index b361046..575f239 100644 --- a/src/suou/sqlalchemy_async.py +++ b/src/suou/sqlalchemy_async.py @@ -1,5 +1,7 @@ """ -Helpers for asynchronous user of SQLAlchemy +Helpers for asynchronous use of SQLAlchemy. + +NEW 0.5.0 --- @@ -15,6 +17,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. """ from __future__ import annotations +from functools import wraps from sqlalchemy import Engine, Select, func, select @@ -150,4 +153,23 @@ class AsyncSelectPagination(Pagination): for i in self.items: yield i -__all__ = ('SQLAlchemy', ) \ No newline at end of file + +def async_query(db: SQLAlchemy, multi: False): + """ + Wraps a query returning function into an executor coroutine. + + The query function remains available as the .q or .query attribute. + """ + def decorator(func): + @wraps(func) + async def executor(*args, **kwargs): + async with db as session: + result = await session.execute(func(*args, **kwargs)) + return result.scalars() if multi else result.scalar() + executor.query = executor.q = func + return executor + return decorator + + +# Optional dependency: do not import into __init__.py +__all__ = ('SQLAlchemy', 'async_query') \ No newline at end of file From ab6dbbade624a4eb4029c7cb35a81a9b950da5df Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 15 Aug 2025 20:37:33 +0200 Subject: [PATCH 054/121] add alru_cache(), async_= to timed_cache() --- CHANGELOG.md | 2 +- src/suou/functools.py | 232 +++++++++++++++++++++++++++++++++++++++--- src/suou/itertools.py | 21 ++++ 3 files changed, 241 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51aa500..db198c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ * Automatically handles commit and rollback + `sqlalchemy_async` also offers `async_query()` + Changed `sqlalchemy.parent_children()` to use `lazy='selectin'` by default -+ Add `timed_cache()`, `TimedDict()`, `none_pass()`, `twocolon_list()`, `quote_css_string()`, `must_be()` ++ Add `timed_cache()`, `alru_cache()`, `TimedDict()`, `none_pass()`, `twocolon_list()`, `quote_css_string()`, `must_be()` + Add module `calendar` with `want_*` date type conversion utilities and `age_and_days()` + Move obsolete stuff to `obsolete` package (includes configparse 0.3 as of now) + Add `redact` module with `redact_url_password()` diff --git a/src/suou/functools.py b/src/suou/functools.py index 90f807e..9cc264a 100644 --- a/src/suou/functools.py +++ b/src/suou/functools.py @@ -14,11 +14,16 @@ This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. """ +from collections import namedtuple import math +from threading import RLock import time -from typing import Callable, TypeVar +from types import CoroutineType, NoneType +from typing import Callable, Iterable, Mapping, TypeVar import warnings -from functools import wraps, lru_cache +from functools import update_wrapper, wraps, lru_cache + +from suou.itertools import hashed_list _T = TypeVar('_T') _U = TypeVar('_U') @@ -70,26 +75,227 @@ def not_implemented(msg: Callable | str | None = None): return decorator -def timed_cache(ttl: int, maxsize: int = 128, typed: bool = False) -> Callable[[Callable], Callable]: +def flat_args(args: Iterable, kwds: Mapping, typed, + kwd_mark = (object(),), + fasttypes = {int, str, frozenset, NoneType}, + sorted=sorted, tuple=tuple, type=type, len=len): + '''Turn optionally positional and keyword arguments into a hashable key for use in caches. + + Shamelessly copied from functools._make_key() from the Python Standard Library. + Never trust underscores, you know. + + This assumes all argument types are hashable!''' + key = args + if kwds: + sorted_items = sorted(kwds.items()) + key += kwd_mark + for item in sorted_items: + key += item + if typed: + key += tuple(type(v) for v in args) + if kwds: + key += tuple(type(v) for k, v in sorted_items) + elif len(key) == 1 and type(key[0]) in fasttypes: + return key[0] + return hashed_list(key) + +def _make_alru_cache(_CacheInfo): + def alru_cache(maxsize: int = 128, typed: bool = False): + """ + Reimplementation of lru_cache(). In fact it's lru_cache() from Python==3.13.7 Standard + Library with just three lines modified. + + Shamelessly adapted from the Python Standard Library with modifications. + + PSA there is no C speed up. Unlike PSL. Sorry. + + NEW 0.5.0 + """ + + # Users should only access the lru_cache through its public API: + # cache_info, cache_clear, and f.__wrapped__ + # The internals of the lru_cache are encapsulated for thread safety and + # to allow the implementation to change (including a possible C version). + # suou.alru_cache is based on pure-Python functools.lru_cache() as of Python 3.13.7. + + if isinstance(maxsize, int): + # Negative maxsize is treated as 0 + if maxsize < 0: + maxsize = 0 + elif callable(maxsize) and isinstance(typed, bool): + # The user_function was passed in directly via the maxsize argument + user_function, maxsize = maxsize, 128 + wrapper = _alru_cache_wrapper(user_function, maxsize, typed, _CacheInfo) + wrapper.cache_parameters = lambda : {'maxsize': maxsize, 'typed': typed} + return update_wrapper(wrapper, user_function) + elif maxsize is not None: + raise TypeError( + 'Expected first argument to be an integer, a callable, or None') + + def decorating_function(user_function: CoroutineType): + wrapper = _alru_cache_wrapper(user_function, maxsize, typed, _CacheInfo) + wrapper.cache_parameters = lambda : {'maxsize': maxsize, 'typed': typed} + return update_wrapper(wrapper, user_function) + + return decorating_function + + def _alru_cache_wrapper(user_function, maxsize, typed): + # Constants shared by all lru cache instances: + sentinel = object() # unique object used to signal cache misses + make_key = flat_args # build a key from the function arguments + PREV, NEXT, KEY, RESULT = 0, 1, 2, 3 # names for the link fields + + cache = {} + hits = misses = 0 + full = False + cache_get = cache.get # bound method to lookup a key or return None + cache_len = cache.__len__ # get cache size without calling len() + lock = RLock() # because linkedlist updates aren't threadsafe + root = [] # root of the circular doubly linked list + root[:] = [root, root, None, None] # initialize by pointing to self + + if maxsize == 0: + + async def wrapper(*args, **kwds): + # No caching -- just a statistics update + nonlocal misses + misses += 1 + result = await user_function(*args, **kwds) + return result + + elif maxsize is None: + + async def wrapper(*args, **kwds): + # Simple caching without ordering or size limit + nonlocal hits, misses + key = make_key(args, kwds, typed) + result = cache_get(key, sentinel) + if result is not sentinel: + hits += 1 + return result + misses += 1 + result = await user_function(*args, **kwds) + cache[key] = result + return result + + else: + + async def wrapper(*args, **kwds): + # Size limited caching that tracks accesses by recency + nonlocal root, hits, misses, full + key = make_key(args, kwds, typed) + with lock: + link = cache_get(key) + if link is not None: + # Move the link to the front of the circular queue + link_prev, link_next, _key, result = link + link_prev[NEXT] = link_next + link_next[PREV] = link_prev + last = root[PREV] + last[NEXT] = root[PREV] = link + link[PREV] = last + link[NEXT] = root + hits += 1 + return result + misses += 1 + result = await user_function(*args, **kwds) + with lock: + if key in cache: + # Getting here means that this same key was added to the + # cache while the lock was released. Since the link + # update is already done, we need only return the + # computed result and update the count of misses. + pass + elif full: + # Use the old root to store the new key and result. + oldroot = root + oldroot[KEY] = key + oldroot[RESULT] = result + # Empty the oldest link and make it the new root. + # Keep a reference to the old key and old result to + # prevent their ref counts from going to zero during the + # update. That will prevent potentially arbitrary object + # clean-up code (i.e. __del__) from running while we're + # still adjusting the links. + root = oldroot[NEXT] + oldkey = root[KEY] + oldresult = root[RESULT] + root[KEY] = root[RESULT] = None + # Now update the cache dictionary. + del cache[oldkey] + # Save the potentially reentrant cache[key] assignment + # for last, after the root and links have been put in + # a consistent state. + cache[key] = oldroot + else: + # Put result in a new link at the front of the queue. + last = root[PREV] + link = [last, root, key, result] + last[NEXT] = root[PREV] = cache[key] = link + # Use the cache_len bound method instead of the len() function + # which could potentially be wrapped in an lru_cache itself. + full = (cache_len() >= maxsize) + return result + + def cache_info(): + """Report cache statistics""" + with lock: + return _CacheInfo(hits, misses, maxsize, cache_len()) + + def cache_clear(): + """Clear the cache and cache statistics""" + nonlocal hits, misses, full + with lock: + cache.clear() + root[:] = [root, root, None, None] + hits = misses = 0 + full = False + + wrapper.cache_info = cache_info + wrapper.cache_clear = cache_clear + return wrapper + + return alru_cache + +alru_cache = _make_alru_cache(namedtuple("CacheInfo", ["hits", "misses", "maxsize", "currsize"])) +del _make_alru_cache + +def timed_cache(ttl: int, maxsize: int = 128, typed: bool = False, *, async_: bool = False) -> Callable[[Callable], Callable]: """ LRU cache which expires after the TTL in seconds passed as argument. + + Supports coroutines with async_=True. NEW 0.5.0 """ def decorator(func): start_time = None - @lru_cache(maxsize, typed) - def inner_wrapper(ttl_period: int, *a, **k): - return func(*a, **k) + if async_: + @alru_cache(maxsize, typed) + async def inner_wrapper(ttl_period: int, /, *a, **k): + return await func(*a, **k) - @wraps(func) - def wrapper(*a, **k): - nonlocal start_time - if not start_time: - start_time = int(time.time()) - return inner_wrapper(math.floor((time.time() - start_time) // ttl), *a, **k) - return wrapper + @wraps(func) + async def wrapper(*a, **k): + nonlocal start_time + if not start_time: + start_time = int(time.time()) + return await inner_wrapper(math.floor((time.time() - start_time) // ttl), *a, **k) + + return wrapper + else: + @lru_cache(maxsize, typed) + def inner_wrapper(ttl_period: int, /, *a, **k): + return func(*a, **k) + + @wraps(func) + def wrapper(*a, **k): + nonlocal start_time + if not start_time: + start_time = int(time.time()) + return inner_wrapper(math.floor((time.time() - start_time) // ttl), *a, **k) + return wrapper return decorator def none_pass(func: Callable, *args, **kwargs) -> Callable: diff --git a/src/suou/itertools.py b/src/suou/itertools.py index abcfdfe..084cf25 100644 --- a/src/suou/itertools.py +++ b/src/suou/itertools.py @@ -103,6 +103,27 @@ def addattr(obj: Any, /, name: str = None): return func return decorator +class hashed_list(list): + """ + Used by lru_cache() functions. + + This class guarantees that hash() will be called no more than once + per element. This is important because the lru_cache() will hash + the key multiple times on a cache miss. + + Shamelessly copied from functools._HashedSeq() from the Python Standard Library. + Never trust underscores, you know. + """ + + __slots__ = 'hashvalue' + + def __init__(self, tup, hash=hash): + self[:] = tup + self.hashvalue = hash(tup) + + def __hash__(self): + return self.hashvalue + __all__ = ('makelist', 'kwargs_prefix', 'ltuple', 'rtuple', 'additem', 'addattr') From 0fc01bc2fb9314548b6b26c811ae54cac5b079b3 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 15 Aug 2025 20:41:07 +0200 Subject: [PATCH 055/121] add exports --- src/suou/__init__.py | 4 ++-- src/suou/functools.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/suou/__init__.py b/src/suou/__init__.py index d11ad6f..248c149 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -23,7 +23,7 @@ from .bits import count_ones, mask_shift, split_bits, join_bits, mod_ceil, mod_f from .calendar import want_datetime, want_isodate, want_timestamp, age_and_days from .configparse import MissingConfigError, MissingConfigWarning, ConfigOptions, ConfigParserConfigSource, ConfigSource, DictConfigSource, ConfigValue, EnvConfigSource from .collections import TimedDict -from .functools import deprecated, not_implemented, timed_cache, none_pass +from .functools import deprecated, not_implemented, timed_cache, none_pass, alru_cache from .classtools import Wanted, Incomplete from .itertools import makelist, kwargs_prefix, ltuple, rtuple, additem, addattr from .i18n import I18n, JsonI18n, TomlI18n @@ -42,7 +42,7 @@ __all__ = ( 'MissingConfigError', 'MissingConfigWarning', 'PrefixIdentifier', 'Siq', 'SiqCache', 'SiqGen', 'SiqType', 'Snowflake', 'SnowflakeGen', 'StringCase', 'TimedDict', 'TomlI18n', 'Wanted', 'WantsContentType', - 'addattr', 'additem', 'age_and_days', 'b2048decode', 'b2048encode', + 'addattr', 'additem', 'age_and_days', 'alru_cache', 'b2048decode', 'b2048encode', 'b32ldecode', 'b32lencode', 'b64encode', 'b64decode', 'cb32encode', 'cb32decode', 'count_ones', 'deprecated', 'ilex', 'join_bits', 'jsonencode', 'kwargs_prefix', 'lex', 'ltuple', 'makelist', 'mask_shift', diff --git a/src/suou/functools.py b/src/suou/functools.py index 9cc264a..bad69bc 100644 --- a/src/suou/functools.py +++ b/src/suou/functools.py @@ -314,5 +314,5 @@ def none_pass(func: Callable, *args, **kwargs) -> Callable: return wrapper __all__ = ( - 'deprecated', 'not_implemented', 'timed_cache', 'none_pass' + 'deprecated', 'not_implemented', 'timed_cache', 'none_pass', 'alru_cache' ) \ No newline at end of file From db4aacef35c6f3e3cc58a3bcf6153e1469c581ae Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 15 Aug 2025 20:46:05 +0200 Subject: [PATCH 056/121] typo --- src/suou/functools.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/suou/functools.py b/src/suou/functools.py index bad69bc..b702fe7 100644 --- a/src/suou/functools.py +++ b/src/suou/functools.py @@ -125,7 +125,7 @@ def _make_alru_cache(_CacheInfo): elif callable(maxsize) and isinstance(typed, bool): # The user_function was passed in directly via the maxsize argument user_function, maxsize = maxsize, 128 - wrapper = _alru_cache_wrapper(user_function, maxsize, typed, _CacheInfo) + wrapper = _alru_cache_wrapper(user_function, maxsize, typed) wrapper.cache_parameters = lambda : {'maxsize': maxsize, 'typed': typed} return update_wrapper(wrapper, user_function) elif maxsize is not None: @@ -133,7 +133,7 @@ def _make_alru_cache(_CacheInfo): 'Expected first argument to be an integer, a callable, or None') def decorating_function(user_function: CoroutineType): - wrapper = _alru_cache_wrapper(user_function, maxsize, typed, _CacheInfo) + wrapper = _alru_cache_wrapper(user_function, maxsize, typed) wrapper.cache_parameters = lambda : {'maxsize': maxsize, 'typed': typed} return update_wrapper(wrapper, user_function) From 1a9fa55dd8b0752924fef1af2e86adf7e3cef5be Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 15 Aug 2025 21:04:13 +0200 Subject: [PATCH 057/121] release 0.5.0 --- src/suou/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 248c149..4c3f2d7 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -34,7 +34,7 @@ from .validators import matches from .redact import redact_url_password from .http import WantsContentType -__version__ = "0.5.0-dev32" +__version__ = "0.5.0" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', From f1f81312998e4a0ded68965d2d94f0cde49aaa62 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sat, 23 Aug 2025 14:43:28 +0200 Subject: [PATCH 058/121] fix type return in declarative_base() --- src/suou/sqlalchemy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/suou/sqlalchemy.py b/src/suou/sqlalchemy.py index 0b4b66b..9f84610 100644 --- a/src/suou/sqlalchemy.py +++ b/src/suou/sqlalchemy.py @@ -131,7 +131,7 @@ def bool_column(value: bool = False, nullable: bool = False, **kwargs): return Column(Boolean, server_default=def_val, nullable=nullable, **kwargs) -def declarative_base(domain_name: str, master_secret: bytes, metadata: dict | None = None, **kwargs) -> DeclarativeBase: +def declarative_base(domain_name: str, master_secret: bytes, metadata: dict | None = None, **kwargs) -> type[DeclarativeBase]: """ Drop-in replacement for sqlalchemy.orm.declarative_base() taking in account requirements for SIQ generation (i.e. domain name). From 0345ee58fc47a758587cc8ba19745e14f7bc1980 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sat, 23 Aug 2025 14:43:54 +0200 Subject: [PATCH 059/121] version advance --- src/suou/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 4c3f2d7..21608a4 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -34,7 +34,7 @@ from .validators import matches from .redact import redact_url_password from .http import WantsContentType -__version__ = "0.5.0" +__version__ = "0.5.1" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', From 958779bc8a0ade908df95f283c33b07995b890fd Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sat, 23 Aug 2025 14:54:58 +0200 Subject: [PATCH 060/121] update changelog, add lazy= to parent_children() --- CHANGELOG.md | 5 +++++ src/suou/sqlalchemy.py | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db198c0..253f01d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,11 @@ + Added `addattr()`, `PrefixIdentifier()`, `mod_floor()`, `mod_ceil()` + First version to have unit tests! (Coverage is not yet complete) +## 0.3.8 + +- Fixed return types for `.sqlalchemy` module. +- `sqlalchemy.parent_children()` now takes a `lazy` parameter. Backported from 0.5.0. + ## 0.3.7 - Fixed a bug in `b64decode()` padding handling which made the function inconsistent and non injective. Now, leading `'A'` is NEVER stripped. diff --git a/src/suou/sqlalchemy.py b/src/suou/sqlalchemy.py index 9f84610..6be1d8a 100644 --- a/src/suou/sqlalchemy.py +++ b/src/suou/sqlalchemy.py @@ -20,9 +20,14 @@ from abc import ABCMeta, abstractmethod from functools import wraps from typing import Callable, Iterable, Never, TypeVar import warnings +<<<<<<< HEAD from sqlalchemy import BigInteger, Boolean, CheckConstraint, Date, Dialect, ForeignKey, LargeBinary, Column, MetaData, SmallInteger, String, create_engine, select, text from sqlalchemy.orm import DeclarativeBase, InstrumentedAttribute, Session, declarative_base as _declarative_base, relationship from sqlalchemy.types import TypeEngine +======= +from sqlalchemy import BigInteger, CheckConstraint, Date, Dialect, ForeignKey, LargeBinary, Column, MetaData, SmallInteger, String, create_engine, select, text +from sqlalchemy.orm import DeclarativeBase, Relationship, Session, declarative_base as _declarative_base, relationship +>>>>>>> a66f591 (update changelog, add lazy= to parent_children()) from .snowflake import SnowflakeGen from .itertools import kwargs_prefix, makelist @@ -204,7 +209,7 @@ def age_pair(*, nullable: bool = False, **ka) -> tuple[Column, Column]: return (date_col, acc_col) -def parent_children(keyword: str, /, *, lazy: str = 'selectin', **kwargs): +def parent_children(keyword: str, /, *, lazy='selectin', **kwargs) -> tuple[Incomplete[Relationship], Incomplete[Relationship]]: """ Self-referential one-to-many relationship pair. Parent comes first, children come later. From 94faac88638b67ec2d06f53c2af2c640f9b7accd Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sat, 23 Aug 2025 15:01:51 +0200 Subject: [PATCH 061/121] update changelog --- CHANGELOG.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 253f01d..0a9d03d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.5.1 + +- Fixed return types for `.sqlalchemy` module + ## 0.5.0 + `sqlalchemy`: add `unbound_fk()`, `bound_fk()` @@ -17,6 +21,11 @@ + Add `quart` module with `negotiate()`, `add_rest()`, `add_i18n()`, `WantsContentType` + Add `dei` module: it implements a compact and standardized representation for pronouns, inspired by the one in use at PronounDB +## 0.4.1 + +- Fixed return types for `.sqlalchemy` module. +- `sqlalchemy.parent_children()` now takes a `lazy` parameter. Backported from 0.5.1. + ## 0.4.0 + `pydantic` is now a hard dependency @@ -36,7 +45,7 @@ ## 0.3.8 - Fixed return types for `.sqlalchemy` module. -- `sqlalchemy.parent_children()` now takes a `lazy` parameter. Backported from 0.5.0. +- `sqlalchemy.parent_children()` now takes a `lazy` parameter. Backported from 0.5.1. ## 0.3.7 From a127c8815930c7f2e92b047a80c50b43fc63144e Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Mon, 25 Aug 2025 07:27:07 +0200 Subject: [PATCH 062/121] fix merge conflict artifacts making library unusable --- CHANGELOG.md | 4 ++++ src/suou/__init__.py | 2 +- src/suou/sqlalchemy.py | 18 ++++++------------ 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a9d03d..3e60ffb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.5.2 + +- Fixed poorly handled merge conflict leaving `.sqlalchemy` modulem unusable + ## 0.5.1 - Fixed return types for `.sqlalchemy` module diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 21608a4..59b3aa0 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -34,7 +34,7 @@ from .validators import matches from .redact import redact_url_password from .http import WantsContentType -__version__ = "0.5.1" +__version__ = "0.5.2" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', diff --git a/src/suou/sqlalchemy.py b/src/suou/sqlalchemy.py index 6be1d8a..2f434e2 100644 --- a/src/suou/sqlalchemy.py +++ b/src/suou/sqlalchemy.py @@ -20,14 +20,8 @@ from abc import ABCMeta, abstractmethod from functools import wraps from typing import Callable, Iterable, Never, TypeVar import warnings -<<<<<<< HEAD from sqlalchemy import BigInteger, Boolean, CheckConstraint, Date, Dialect, ForeignKey, LargeBinary, Column, MetaData, SmallInteger, String, create_engine, select, text -from sqlalchemy.orm import DeclarativeBase, InstrumentedAttribute, Session, declarative_base as _declarative_base, relationship -from sqlalchemy.types import TypeEngine -======= -from sqlalchemy import BigInteger, CheckConstraint, Date, Dialect, ForeignKey, LargeBinary, Column, MetaData, SmallInteger, String, create_engine, select, text -from sqlalchemy.orm import DeclarativeBase, Relationship, Session, declarative_base as _declarative_base, relationship ->>>>>>> a66f591 (update changelog, add lazy= to parent_children()) +from sqlalchemy.orm import DeclarativeBase, InstrumentedAttribute, Relationship, Session, declarative_base as _declarative_base, relationship from .snowflake import SnowflakeGen from .itertools import kwargs_prefix, makelist @@ -41,7 +35,7 @@ _T = TypeVar('_T') # SIQs are 14 bytes long. Storage is padded for alignment # Not to be confused with SiqType. -IdType = LargeBinary(16) +IdType: type[LargeBinary] = LargeBinary(16) @not_implemented def sql_escape(s: str, /, dialect: Dialect) -> str: @@ -114,7 +108,7 @@ match_constraint.TEXT_DIALECTS = { 'mariadb': ':n RLIKE :re' } -def match_column(length: int, regex: str, /, case: StringCase = StringCase.AS_IS, *args, constraint_name: str | None = None, **kwargs): +def match_column(length: int, regex: str, /, case: StringCase = StringCase.AS_IS, *args, constraint_name: str | None = None, **kwargs) -> Incomplete[Column[str]]: """ Syntactic sugar to create a String() column with a check constraint matching the given regular expression. @@ -126,7 +120,7 @@ def match_column(length: int, regex: str, /, case: StringCase = StringCase.AS_IS constraint_name=constraint_name or f'{x.__tablename__}_{n}_valid')), *args, **kwargs) -def bool_column(value: bool = False, nullable: bool = False, **kwargs): +def bool_column(value: bool = False, nullable: bool = False, **kwargs) -> Column[bool]: """ Column for a single boolean value. @@ -232,7 +226,7 @@ def parent_children(keyword: str, /, *, lazy='selectin', **kwargs) -> tuple[Inco return parent, child -def unbound_fk(target: str | Column | InstrumentedAttribute, typ: TypeEngine | None = None, **kwargs): +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. @@ -252,7 +246,7 @@ def unbound_fk(target: str | Column | InstrumentedAttribute, typ: TypeEngine | N return Column(typ, ForeignKey(target_name, ondelete='SET NULL'), nullable=True, **kwargs) -def bound_fk(target: str | Column | InstrumentedAttribute, typ: TypeEngine | None = None, **kwargs): +def bound_fk(target: str | Column | InstrumentedAttribute, typ: _T = None, **kwargs) -> Column[_T | IdType]: """ Shorthand for creating a "bound" foreign key column from a column name, the referenced column. From c860d9ffe15eb6f67e68845693b7f01f1dadeb25 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Thu, 28 Aug 2025 00:45:16 +0200 Subject: [PATCH 063/121] attempt fixing types --- src/suou/__init__.py | 2 +- src/suou/classtools.py | 18 +++++++++++------- src/suou/sqlalchemy.py | 19 ++++++++++++------- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 59b3aa0..ac4ddaa 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -34,7 +34,7 @@ from .validators import matches from .redact import redact_url_password from .http import WantsContentType -__version__ = "0.5.2" +__version__ = "0.5.3-dev34" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', diff --git a/src/suou/classtools.py b/src/suou/classtools.py index c27fa61..c58a123 100644 --- a/src/suou/classtools.py +++ b/src/suou/classtools.py @@ -17,6 +17,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. from __future__ import annotations from abc import abstractmethod +from types import EllipsisType from typing import Any, Callable, Generic, Iterable, Mapping, TypeVar import logging @@ -24,7 +25,10 @@ _T = TypeVar('_T') logger = logging.getLogger(__name__) -MISSING = object() +class MissingType(object): + __slots__ = () + +MISSING = MissingType() def _not_missing(v) -> bool: return v and v is not MISSING @@ -43,10 +47,10 @@ class Wanted(Generic[_T]): Owner class will call .__set_name__() on the parent Incomplete instance; the __set_name__ parameters (owner class and name) will be passed down here. """ - _target: Callable | str | None | Ellipsis - def __init__(self, getter: Callable | str | None | Ellipsis): + _target: Callable | str | None | EllipsisType + def __init__(self, getter: Callable | str | None | EllipsisType): self._target = getter - def __call__(self, owner: type, name: str | None = None) -> _T: + def __call__(self, owner: type, name: str | None = None) -> _T | str | None: if self._target is None or self._target is Ellipsis: return name elif isinstance(self._target, str): @@ -67,10 +71,10 @@ class Incomplete(Generic[_T]): Missing arguments must be passed in the appropriate positions (positional or keyword) as a Wanted() object. """ - _obj = Callable[Any, _T] + _obj: Callable[..., _T] _args: Iterable _kwargs: dict - def __init__(self, obj: Callable[Any, _T] | Wanted, *args, **kwargs): + def __init__(self, obj: Callable[..., _T] | Wanted, *args, **kwargs): if isinstance(obj, Wanted): self._obj = lambda x: x self._args = (obj, ) @@ -120,7 +124,7 @@ class ValueSource(Mapping): class ValueProperty(Generic[_T]): _name: str | None _srcs: dict[str, str] - _val: Any | MISSING + _val: Any | MissingType _default: Any | None _cast: Callable | None _required: bool diff --git a/src/suou/sqlalchemy.py b/src/suou/sqlalchemy.py index 2f434e2..4e27f7a 100644 --- a/src/suou/sqlalchemy.py +++ b/src/suou/sqlalchemy.py @@ -18,10 +18,11 @@ from __future__ import annotations from abc import ABCMeta, abstractmethod from functools import wraps -from typing import Callable, Iterable, Never, TypeVar +from typing import Any, Callable, Iterable, Never, TypeVar import warnings from sqlalchemy import BigInteger, Boolean, CheckConstraint, Date, Dialect, ForeignKey, LargeBinary, Column, MetaData, SmallInteger, String, create_engine, select, text from sqlalchemy.orm import DeclarativeBase, InstrumentedAttribute, Relationship, Session, declarative_base as _declarative_base, relationship +from sqlalchemy.types import TypeEngine from .snowflake import SnowflakeGen from .itertools import kwargs_prefix, makelist @@ -35,7 +36,7 @@ _T = TypeVar('_T') # SIQs are 14 bytes long. Storage is padded for alignment # Not to be confused with SiqType. -IdType: type[LargeBinary] = LargeBinary(16) +IdType: TypeEngine = LargeBinary(16) @not_implemented def sql_escape(s: str, /, dialect: Dialect) -> str: @@ -158,6 +159,7 @@ def token_signer(id_attr: Column | str, secret_attr: Column | str) -> Incomplete Requires a master secret (taken from Base.metadata), a user id (visible in the token) and a user secret. """ + id_val: Column | Wanted[Column] if isinstance(id_attr, Column): id_val = id_attr elif isinstance(id_attr, str): @@ -168,13 +170,16 @@ def token_signer(id_attr: Column | str, secret_attr: Column | str) -> Incomplete secret_val = Wanted(secret_attr) def token_signer_factory(owner: DeclarativeBase, name: str): def my_signer(self): - return UserSigner(owner.metadata.info['secret_key'], id_val.__get__(self, owner), secret_val.__get__(self, owner)) + return UserSigner( + owner.metadata.info['secret_key'], + id_val.__get__(self, owner), secret_val.__get__(self, owner) # pyright: ignore[reportAttributeAccessIssue] + ) my_signer.__name__ = name return my_signer return Incomplete(Wanted(token_signer_factory)) -def author_pair(fk_name: str, *, id_type: type = IdType, sig_type: type | None = None, nullable: bool = False, sig_length: int | None = 2048, **ka) -> tuple[Column, Column]: +def author_pair(fk_name: str, *, id_type: type | TypeEngine = IdType, sig_type: type | None = None, nullable: bool = False, sig_length: int | None = 2048, **ka) -> tuple[Column, Column]: """ Return an owner ID/signature column pair, for authenticated values. """ @@ -203,7 +208,7 @@ def age_pair(*, nullable: bool = False, **ka) -> tuple[Column, Column]: return (date_col, acc_col) -def parent_children(keyword: str, /, *, lazy='selectin', **kwargs) -> tuple[Incomplete[Relationship], Incomplete[Relationship]]: +def parent_children(keyword: str, /, *, lazy='selectin', **kwargs) -> tuple[Incomplete[Relationship[Any]], Incomplete[Relationship[Any]]]: """ Self-referential one-to-many relationship pair. Parent comes first, children come later. @@ -220,8 +225,8 @@ def parent_children(keyword: str, /, *, lazy='selectin', **kwargs) -> tuple[Inco parent_kwargs = kwargs_prefix(kwargs, 'parent_') child_kwargs = kwargs_prefix(kwargs, 'child_') - parent = Incomplete(relationship, Wanted(lambda o, n: o.__name__), back_populates=f'child_{keyword}s', lazy=lazy, **parent_kwargs) - child = Incomplete(relationship, Wanted(lambda o, n: o.__name__), back_populates=f'parent_{keyword}', lazy=lazy, **child_kwargs) + parent: Incomplete[Relationship[Any]] = Incomplete(relationship, Wanted(lambda o, n: o.__name__), back_populates=f'child_{keyword}s', lazy=lazy, **parent_kwargs) + child: Incomplete[Relationship[Any]] = Incomplete(relationship, Wanted(lambda o, n: o.__name__), back_populates=f'parent_{keyword}', lazy=lazy, **child_kwargs) return parent, child From f7807ff05aff1b9d4eb34563564d254757ecbf78 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Mon, 1 Sep 2025 22:36:55 +0200 Subject: [PATCH 064/121] version advance --- CHANGELOG.md | 8 ++++++++ src/suou/__init__.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e60ffb..fffb0ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.6.0 + +... + +## 0.5.3 + +... + ## 0.5.2 - Fixed poorly handled merge conflict leaving `.sqlalchemy` modulem unusable diff --git a/src/suou/__init__.py b/src/suou/__init__.py index ac4ddaa..96db617 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -34,7 +34,7 @@ from .validators import matches from .redact import redact_url_password from .http import WantsContentType -__version__ = "0.5.3-dev34" +__version__ = "0.6.0-dev35" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', From 97194b2b8522ee243050466d48d7f0db37a8210a Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Mon, 1 Sep 2025 23:03:44 +0200 Subject: [PATCH 065/121] split sqlalchemy modules --- src/suou/sqlalchemy/__init__.py | 169 ++++++++++++++++ src/suou/sqlalchemy/asyncio.py | 176 +++++++++++++++++ src/suou/{sqlalchemy.py => sqlalchemy/orm.py} | 184 +++--------------- src/suou/sqlalchemy_async.py | 157 +-------------- 4 files changed, 379 insertions(+), 307 deletions(-) create mode 100644 src/suou/sqlalchemy/__init__.py create mode 100644 src/suou/sqlalchemy/asyncio.py rename src/suou/{sqlalchemy.py => sqlalchemy/orm.py} (61%) diff --git a/src/suou/sqlalchemy/__init__.py b/src/suou/sqlalchemy/__init__.py new file mode 100644 index 0000000..81f61f8 --- /dev/null +++ b/src/suou/sqlalchemy/__init__.py @@ -0,0 +1,169 @@ +""" +Utilities for SQLAlchemy + +--- + +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. +""" + +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from functools import wraps +from typing import Any, Callable, Iterable, Never, TypeVar +import warnings +from sqlalchemy import BigInteger, Boolean, CheckConstraint, Date, Dialect, ForeignKey, LargeBinary, Column, MetaData, SmallInteger, String, create_engine, select, text +from sqlalchemy.orm import DeclarativeBase, InstrumentedAttribute, Relationship, Session, declarative_base as _declarative_base, relationship +from sqlalchemy.types import TypeEngine + +from ..snowflake import SnowflakeGen +from ..itertools import kwargs_prefix, makelist +from ..signing import HasSigner, UserSigner +from ..codecs import StringCase +from ..functools import deprecated, not_implemented +from ..iding import Siq, SiqGen, SiqType, SiqCache +from ..classtools import Incomplete, Wanted + + + +_T = TypeVar('_T') + +# SIQs are 14 bytes long. Storage is padded for alignment +# Not to be confused with SiqType. +IdType: TypeEngine = LargeBinary(16) + +def create_session(url: str) -> Session: + """ + Create a session on the fly, given a database URL. Useful for + contextless environments, such as Python REPL. + + Heads up: a function with the same name exists in core sqlalchemy, but behaves + completely differently!! + """ + engine = create_engine(url) + return Session(bind = engine) + + + +def token_signer(id_attr: Column | str, secret_attr: Column | str) -> Incomplete[UserSigner]: + """ + Generate a user signing function. + + Requires a master secret (taken from Base.metadata), a user id (visible in the token) + and a user secret. + """ + id_val: Column | Wanted[Column] + if isinstance(id_attr, Column): + id_val = id_attr + elif isinstance(id_attr, str): + id_val = Wanted(id_attr) + if isinstance(secret_attr, Column): + secret_val = secret_attr + elif isinstance(secret_attr, str): + secret_val = Wanted(secret_attr) + def token_signer_factory(owner: DeclarativeBase, name: str): + def my_signer(self): + return UserSigner( + owner.metadata.info['secret_key'], + id_val.__get__(self, owner), secret_val.__get__(self, owner) # pyright: ignore[reportAttributeAccessIssue] + ) + my_signer.__name__ = name + return my_signer + return Incomplete(Wanted(token_signer_factory)) + + + + + +## Utilities for use in web apps below + +@deprecated('not part of the public API and not even working') +class AuthSrc(metaclass=ABCMeta): + ''' + AuthSrc object required for require_auth_base(). + + This is an abstract class and is NOT usable directly. + + This is not part of the public API + ''' + def required_exc(self) -> Never: + raise ValueError('required field missing') + def invalid_exc(self, msg: str = 'validation failed') -> Never: + raise ValueError(msg) + @abstractmethod + def get_session(self) -> Session: + pass + def get_user(self, getter: Callable): + return getter(self.get_token()) + @abstractmethod + def get_token(self): + pass + @abstractmethod + def get_signature(self): + pass + + +@deprecated('not working and too complex to use') +def require_auth_base(cls: type[DeclarativeBase], *, src: AuthSrc, column: str | Column[_T] = 'id', dest: str = 'user', + required: bool = False, signed: bool = False, sig_dest: str = 'signature', validators: Callable | Iterable[Callable] | None = None): + ''' + Inject the current user into a view, given the Authorization: Bearer header. + + For portability reasons, this is a partial, two-component function, requiring a AuthSrc() object. + ''' + col = want_column(cls, column) + validators = makelist(validators) + + def get_user(token) -> DeclarativeBase: + if token is None: + return None + tok_parts = UserSigner.split_token(token) + user: HasSigner = src.get_session().execute(select(cls).where(col == tok_parts[0])).scalar() + try: + signer: UserSigner = user.signer() + signer.unsign(token) + return user + except Exception: + return None + + def _default_invalid(msg: str = 'Validation failed'): + raise ValueError(msg) + + invalid_exc = src.invalid_exc or _default_invalid + required_exc = src.required_exc or (lambda: _default_invalid('Login required')) + + def decorator(func: Callable): + @wraps(func) + def wrapper(*a, **ka): + ka[dest] = get_user(src.get_token()) + if not ka[dest] and required: + required_exc() + if signed: + ka[sig_dest] = src.get_signature() + for valid in validators: + if not valid(ka[dest]): + invalid_exc(getattr(valid, 'message', 'validation failed').format(user=ka[dest])) + return func(*a, **ka) + return wrapper + return decorator + + +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 + +# Optional dependency: do not import into __init__.py +__all__ = ( + 'IdType', 'id_column', 'snowflake_column', 'entity_base', 'declarative_base', 'token_signer', + 'match_column', 'match_constraint', 'bool_column', 'parent_children', + 'author_pair', 'age_pair', 'bound_fk', 'unbound_fk', 'want_column', + # .asyncio + 'SQLAlchemy', 'AsyncSelectPagination', 'async_query' +) \ No newline at end of file diff --git a/src/suou/sqlalchemy/asyncio.py b/src/suou/sqlalchemy/asyncio.py new file mode 100644 index 0000000..786d9f8 --- /dev/null +++ b/src/suou/sqlalchemy/asyncio.py @@ -0,0 +1,176 @@ + +""" +Helpers for asynchronous use of SQLAlchemy. + +NEW 0.5.0; moved to current location 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. +""" + +from __future__ import annotations +from functools import wraps + + +from sqlalchemy import Engine, Select, func, select +from sqlalchemy.orm import DeclarativeBase, lazyload +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine +from flask_sqlalchemy.pagination import Pagination + +from suou.exceptions import NotFoundError + +class SQLAlchemy: + """ + Drop-in (?) replacement for flask_sqlalchemy.SQLAlchemy() + eligible for async environments + + NEW 0.5.0 + """ + base: DeclarativeBase + engine: AsyncEngine + _sessions: list[AsyncSession] + NotFound = NotFoundError + + def __init__(self, model_class: DeclarativeBase): + self.base = model_class + self.engine = None + self._sessions = [] + def bind(self, url: str): + self.engine = create_async_engine(url) + def _ensure_engine(self): + if self.engine is None: + raise RuntimeError('database is not connected') + async def begin(self, *, expire_on_commit = False, **kw) -> AsyncSession: + self._ensure_engine() + ## XXX is it accurate? + s = AsyncSession(self.engine, expire_on_commit=expire_on_commit, **kw) + self._sessions.append(s) + return s + async def __aenter__(self) -> AsyncSession: + return await self.begin() + async def __aexit__(self, e1, e2, e3): + ## XXX is it accurate? + s = self._sessions.pop() + if e1: + await s.rollback() + else: + await s.commit() + await s.close() + async def paginate(self, select: Select, *, + page: int | None = None, per_page: int | None = None, + max_per_page: int | None = None, error_out: bool = True, + count: bool = True) -> AsyncSelectPagination: + """ + Return a pagination. Analogous to flask_sqlalchemy.SQLAlchemy.paginate(). + """ + async with self as session: + return AsyncSelectPagination( + select = select, + session = session, + page = page, + per_page=per_page, max_per_page=max_per_page, + error_out=self.NotFound if error_out else None, count=count + ) + async def create_all(self, *, checkfirst = True): + """ + Initialize database + """ + self._ensure_engine() + self.base.metadata.create_all( + self.engine, checkfirst=checkfirst + ) + + + +class AsyncSelectPagination(Pagination): + """ + flask_sqlalchemy.SelectPagination but asynchronous. + + Pagination is not part of the public API, therefore expect that it may break + """ + + async def _query_items(self) -> list: + select_q: Select = self._query_args["select"] + select = select_q.limit(self.per_page).offset(self._query_offset) + session: AsyncSession = self._query_args["session"] + out = (await session.execute(select)).scalars() + return out + + async def _query_count(self) -> int: + select_q: Select = self._query_args["select"] + sub = select_q.options(lazyload("*")).order_by(None).subquery() + session: AsyncSession = self._query_args["session"] + out = (await session.execute(select(func.count()).select_from(sub))).scalar() + return out + + def __init__(self, + page: int | None = None, + per_page: int | None = None, + max_per_page: int | None = 100, + error_out: Exception | None = NotFoundError, + count: bool = True, + **kwargs): + ## XXX flask-sqlalchemy says Pagination() is not public API. + ## Things may break; beware. + self._query_args = kwargs + page, per_page = self._prepare_page_args( + page=page, + per_page=per_page, + max_per_page=max_per_page, + error_out=error_out, + ) + + self.page: int = page + """The current page.""" + + self.per_page: int = per_page + """The maximum number of items on a page.""" + + self.max_per_page: int | None = max_per_page + """The maximum allowed value for ``per_page``.""" + + self.items = None + self.total = None + self.error_out = error_out + self.has_count = count + + async def __aiter__(self): + self.items = await self._query_items() + if self.items is None: + raise RuntimeError('query returned None') + if not self.items and self.page != 1 and self.error_out: + raise self.error_out + if self.has_count: + self.total = await self._query_count() + for i in self.items: + yield i + + +def async_query(db: SQLAlchemy, multi: False): + """ + Wraps a query returning function into an executor coroutine. + + The query function remains available as the .q or .query attribute. + """ + def decorator(func): + @wraps(func) + async def executor(*args, **kwargs): + async with db as session: + result = await session.execute(func(*args, **kwargs)) + return result.scalars() if multi else result.scalar() + executor.query = executor.q = func + return executor + return decorator + + +# Optional dependency: do not import into __init__.py +__all__ = ('SQLAlchemy', 'AsyncSelectPagination', 'async_query') \ No newline at end of file diff --git a/src/suou/sqlalchemy.py b/src/suou/sqlalchemy/orm.py similarity index 61% rename from src/suou/sqlalchemy.py rename to src/suou/sqlalchemy/orm.py index 4e27f7a..6d75a4f 100644 --- a/src/suou/sqlalchemy.py +++ b/src/suou/sqlalchemy/orm.py @@ -1,5 +1,7 @@ """ -Utilities for SQLAlchemy +Utilities for SQLAlchemy; ORM + +NEW 0.6.0 --- @@ -14,54 +16,38 @@ This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. """ -from __future__ import annotations -from abc import ABCMeta, abstractmethod -from functools import wraps -from typing import Any, Callable, Iterable, Never, TypeVar + +from binascii import Incomplete +from typing import Any, Callable import warnings -from sqlalchemy import BigInteger, Boolean, CheckConstraint, Date, Dialect, ForeignKey, LargeBinary, Column, MetaData, SmallInteger, String, create_engine, select, text -from sqlalchemy.orm import DeclarativeBase, InstrumentedAttribute, Relationship, Session, declarative_base as _declarative_base, relationship +from sqlalchemy import BigInteger, Boolean, CheckConstraint, Column, Date, ForeignKey, LargeBinary, MetaData, SmallInteger, String, text +from sqlalchemy.orm import DeclarativeBase, InstrumentedAttribute, Relationship, declarative_base as _declarative_base, relationship from sqlalchemy.types import TypeEngine +from suou.classtools import Wanted +from suou.codecs import StringCase +from suou.iding import Siq, SiqCache, SiqGen, SiqType +from suou.itertools import kwargs_prefix +from suou.snowflake import SnowflakeGen +from suou.sqlalchemy import IdType -from .snowflake import SnowflakeGen -from .itertools import kwargs_prefix, makelist -from .signing import HasSigner, UserSigner -from .codecs import StringCase -from .functools import deprecated, not_implemented -from .iding import Siq, SiqGen, SiqType, SiqCache -from .classtools import Incomplete, Wanted -_T = TypeVar('_T') - -# SIQs are 14 bytes long. Storage is padded for alignment -# Not to be confused with SiqType. -IdType: TypeEngine = LargeBinary(16) - -@not_implemented -def sql_escape(s: str, /, dialect: Dialect) -> str: +def want_column(cls: type[DeclarativeBase], col: Column[_T] | str) -> Column[_T]: """ - Escape a value for SQL embedding, using SQLAlchemy's literal processors. - Requires a dialect argument. + Return a table's column given its name. - XXX this function is not mature yet, do not use + XXX does it belong outside any scopes? """ - if isinstance(s, str): - return String().literal_processor(dialect=dialect)(s) - raise TypeError('invalid data type') + if isinstance(col, Incomplete): + raise TypeError('attempt to pass an uninstanced column. Pass the column name as a string instead.') + elif isinstance(col, Column): + return col + elif isinstance(col, str): + return getattr(cls, col) + else: + raise TypeError -def create_session(url: str) -> Session: - """ - Create a session on the fly, given a database URL. Useful for - contextless environments, such as Python REPL. - - Heads up: a function with the same name exists in core sqlalchemy, but behaves - completely differently!! - """ - engine = create_engine(url) - return Session(bind = engine) - def id_column(typ: SiqType, *, primary_key: bool = True, **kwargs): """ Marks a column which contains a SIQ. @@ -120,7 +106,6 @@ def match_column(length: int, regex: str, /, case: StringCase = StringCase.AS_IS return Incomplete(Column, String(length), Wanted(lambda x, n: match_constraint(n, regex, #dialect=x.metadata.engine.dialect.name, constraint_name=constraint_name or f'{x.__tablename__}_{n}_valid')), *args, **kwargs) - def bool_column(value: bool = False, nullable: bool = False, **kwargs) -> Column[bool]: """ Column for a single boolean value. @@ -149,35 +134,9 @@ def declarative_base(domain_name: str, master_secret: bytes, metadata: dict | No ) Base = _declarative_base(metadata=MetaData(**metadata), **kwargs) return Base -entity_base = deprecated('use declarative_base() instead')(declarative_base) +entity_base = warnings.deprecated('use declarative_base() instead')(declarative_base) -def token_signer(id_attr: Column | str, secret_attr: Column | str) -> Incomplete[UserSigner]: - """ - Generate a user signing function. - - Requires a master secret (taken from Base.metadata), a user id (visible in the token) - and a user secret. - """ - id_val: Column | Wanted[Column] - if isinstance(id_attr, Column): - id_val = id_attr - elif isinstance(id_attr, str): - id_val = Wanted(id_attr) - if isinstance(secret_attr, Column): - secret_val = secret_attr - elif isinstance(secret_attr, str): - secret_val = Wanted(secret_attr) - def token_signer_factory(owner: DeclarativeBase, name: str): - def my_signer(self): - return UserSigner( - owner.metadata.info['secret_key'], - id_val.__get__(self, owner), secret_val.__get__(self, owner) # pyright: ignore[reportAttributeAccessIssue] - ) - my_signer.__name__ = name - return my_signer - return Incomplete(Wanted(token_signer_factory)) - def author_pair(fk_name: str, *, id_type: type | TypeEngine = IdType, sig_type: type | None = None, nullable: bool = False, sig_length: int | None = 2048, **ka) -> tuple[Column, Column]: """ @@ -208,6 +167,7 @@ def age_pair(*, nullable: bool = False, **ka) -> tuple[Column, Column]: return (date_col, acc_col) + def parent_children(keyword: str, /, *, lazy='selectin', **kwargs) -> tuple[Incomplete[Relationship[Any]], Incomplete[Relationship[Any]]]: """ Self-referential one-to-many relationship pair. @@ -272,93 +232,3 @@ def bound_fk(target: str | Column | InstrumentedAttribute, typ: _T = None, **kwa return Column(typ, ForeignKey(target_name, ondelete='CASCADE'), nullable=False, **kwargs) -def want_column(cls: type[DeclarativeBase], col: Column[_T] | str) -> Column[_T]: - """ - Return a table's column given its name. - - XXX does it belong outside any scopes? - """ - if isinstance(col, Incomplete): - raise TypeError('attempt to pass an uninstanced column. Pass the column name as a string instead.') - elif isinstance(col, Column): - return col - elif isinstance(col, str): - return getattr(cls, col) - else: - raise TypeError - -## Utilities for use in web apps below - -class AuthSrc(metaclass=ABCMeta): - ''' - AuthSrc object required for require_auth_base(). - - This is an abstract class and is NOT usable directly. - - This is not part of the public API - ''' - def required_exc(self) -> Never: - raise ValueError('required field missing') - def invalid_exc(self, msg: str = 'validation failed') -> Never: - raise ValueError(msg) - @abstractmethod - def get_session(self) -> Session: - pass - def get_user(self, getter: Callable): - return getter(self.get_token()) - @abstractmethod - def get_token(self): - pass - @abstractmethod - def get_signature(self): - pass - - -def require_auth_base(cls: type[DeclarativeBase], *, src: AuthSrc, column: str | Column[_T] = 'id', dest: str = 'user', - required: bool = False, signed: bool = False, sig_dest: str = 'signature', validators: Callable | Iterable[Callable] | None = None): - ''' - Inject the current user into a view, given the Authorization: Bearer header. - - For portability reasons, this is a partial, two-component function, requiring a AuthSrc() object. - ''' - col = want_column(cls, column) - validators = makelist(validators) - - def get_user(token) -> DeclarativeBase: - if token is None: - return None - tok_parts = UserSigner.split_token(token) - user: HasSigner = src.get_session().execute(select(cls).where(col == tok_parts[0])).scalar() - try: - signer: UserSigner = user.signer() - signer.unsign(token) - return user - except Exception: - return None - - def _default_invalid(msg: str = 'Validation failed'): - raise ValueError(msg) - - invalid_exc = src.invalid_exc or _default_invalid - required_exc = src.required_exc or (lambda: _default_invalid('Login required')) - - def decorator(func: Callable): - @wraps(func) - def wrapper(*a, **ka): - ka[dest] = get_user(src.get_token()) - if not ka[dest] and required: - required_exc() - if signed: - ka[sig_dest] = src.get_signature() - for valid in validators: - if not valid(ka[dest]): - invalid_exc(getattr(valid, 'message', 'validation failed').format(user=ka[dest])) - return func(*a, **ka) - return wrapper - return decorator - -# Optional dependency: do not import into __init__.py -__all__ = ( - 'IdType', 'id_column', 'entity_base', 'declarative_base', 'token_signer', 'match_column', 'match_constraint', - 'author_pair', 'age_pair', 'require_auth_base', 'bound_fk', 'unbound_fk', 'want_column' -) \ No newline at end of file diff --git a/src/suou/sqlalchemy_async.py b/src/suou/sqlalchemy_async.py index 575f239..47b3396 100644 --- a/src/suou/sqlalchemy_async.py +++ b/src/suou/sqlalchemy_async.py @@ -1,7 +1,7 @@ """ Helpers for asynchronous use of SQLAlchemy. -NEW 0.5.0 +NEW 0.5.0; MOVED to sqlalchemy.asyncio in 0.6.0 --- @@ -17,159 +17,16 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. """ from __future__ import annotations -from functools import wraps - -from sqlalchemy import Engine, Select, func, select -from sqlalchemy.orm import DeclarativeBase, lazyload -from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine -from flask_sqlalchemy.pagination import Pagination - -from suou.exceptions import NotFoundError - -class SQLAlchemy: - """ - Drop-in (?) replacement for flask_sqlalchemy.SQLAlchemy() - eligible for async environments - - NEW 0.5.0 - """ - base: DeclarativeBase - engine: AsyncEngine - _sessions: list[AsyncSession] - NotFound = NotFoundError - - def __init__(self, model_class: DeclarativeBase): - self.base = model_class - self.engine = None - self._sessions = [] - def bind(self, url: str): - self.engine = create_async_engine(url) - def _ensure_engine(self): - if self.engine is None: - raise RuntimeError('database is not connected') - async def begin(self, *, expire_on_commit = False, **kw) -> AsyncSession: - self._ensure_engine() - ## XXX is it accurate? - s = AsyncSession(self.engine, expire_on_commit=expire_on_commit, **kw) - self._sessions.append(s) - return s - async def __aenter__(self) -> AsyncSession: - return await self.begin() - async def __aexit__(self, e1, e2, e3): - ## XXX is it accurate? - s = self._sessions.pop() - if e1: - await s.rollback() - else: - await s.commit() - await s.close() - async def paginate(self, select: Select, *, - page: int | None = None, per_page: int | None = None, - max_per_page: int | None = None, error_out: bool = True, - count: bool = True) -> AsyncSelectPagination: - """ - Return a pagination. Analogous to flask_sqlalchemy.SQLAlchemy.paginate(). - """ - async with self as session: - return AsyncSelectPagination( - select = select, - session = session, - page = page, - per_page=per_page, max_per_page=max_per_page, - error_out=self.NotFound if error_out else None, count=count - ) - async def create_all(self, *, checkfirst = True): - """ - Initialize database - """ - self._ensure_engine() - self.base.metadata.create_all( - self.engine, checkfirst=checkfirst - ) +from .functools import deprecated -class AsyncSelectPagination(Pagination): - """ - flask_sqlalchemy.SelectPagination but asynchronous. +from .sqlalchemy.asyncio import SQLAlchemy, AsyncSelectPagination, async_query - Pagination is not part of the public API, therefore expect that it may break - """ - - async def _query_items(self) -> list: - select_q: Select = self._query_args["select"] - select = select_q.limit(self.per_page).offset(self._query_offset) - session: AsyncSession = self._query_args["session"] - out = (await session.execute(select)).scalars() - return out - - async def _query_count(self) -> int: - select_q: Select = self._query_args["select"] - sub = select_q.options(lazyload("*")).order_by(None).subquery() - session: AsyncSession = self._query_args["session"] - out = (await session.execute(select(func.count()).select_from(sub))).scalar() - return out - - def __init__(self, - page: int | None = None, - per_page: int | None = None, - max_per_page: int | None = 100, - error_out: Exception | None = NotFoundError, - count: bool = True, - **kwargs): - ## XXX flask-sqlalchemy says Pagination() is not public API. - ## Things may break; beware. - self._query_args = kwargs - page, per_page = self._prepare_page_args( - page=page, - per_page=per_page, - max_per_page=max_per_page, - error_out=error_out, - ) - - self.page: int = page - """The current page.""" - - self.per_page: int = per_page - """The maximum number of items on a page.""" - - self.max_per_page: int | None = max_per_page - """The maximum allowed value for ``per_page``.""" - - self.items = None - self.total = None - self.error_out = error_out - self.has_count = count - - async def __aiter__(self): - self.items = await self._query_items() - if self.items is None: - raise RuntimeError('query returned None') - if not self.items and self.page != 1 and self.error_out: - raise self.error_out - if self.has_count: - self.total = await self._query_count() - for i in self.items: - yield i - - -def async_query(db: SQLAlchemy, multi: False): - """ - Wraps a query returning function into an executor coroutine. - - The query function remains available as the .q or .query attribute. - """ - def decorator(func): - @wraps(func) - async def executor(*args, **kwargs): - async with db as session: - result = await session.execute(func(*args, **kwargs)) - return result.scalars() if multi else result.scalar() - executor.query = executor.q = func - return executor - return decorator - +SQLAlchemy = deprecated('import from suou.sqlalchemy.asyncio instead')(SQLAlchemy) +AsyncSelectPagination = deprecated('import from suou.sqlalchemy.asyncio instead')(AsyncSelectPagination) +async_query = deprecated('import from suou.sqlalchemy.asyncio instead')(async_query) # Optional dependency: do not import into __init__.py -__all__ = ('SQLAlchemy', 'async_query') \ No newline at end of file +__all__ = ('SQLAlchemy', 'AsyncSelectPagination', 'async_query') \ No newline at end of file From eb8371757dcd87b1d3675963726352d16f274323 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Thu, 4 Sep 2025 01:25:25 +0200 Subject: [PATCH 066/121] add ArgConfigSource(), 3 helpers to .sqlalchemy, add .waiter --- CHANGELOG.md | 5 +- src/suou/configparse.py | 27 ++++++++++- src/suou/flask_sqlalchemy.py | 5 +- src/suou/sqlalchemy/__init__.py | 6 ++- src/suou/sqlalchemy/orm.py | 81 +++++++++++++++++++++++++++++++++ src/suou/waiter.py | 57 +++++++++++++++++++++++ 6 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 src/suou/waiter.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fffb0ea..a31ce90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,10 @@ ## 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 diff --git a/src/suou/configparse.py b/src/suou/configparse.py index 9709b0a..8687cb4 100644 --- a/src/suou/configparse.py +++ b/src/suou/configparse.py @@ -23,6 +23,8 @@ import os from typing import Any, Callable, Iterator, override from collections import OrderedDict +from argparse import Namespace + from .classtools import ValueSource, ValueProperty from .functools import deprecated from .exceptions import MissingConfigError, MissingConfigWarning @@ -105,6 +107,28 @@ class DictConfigSource(ConfigSource): def __len__(self) -> int: 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): """ A single config property. @@ -205,7 +229,8 @@ class ConfigOptions: __all__ = ( - 'MissingConfigError', 'MissingConfigWarning', 'ConfigOptions', 'EnvConfigSource', 'ConfigParserConfigSource', 'DictConfigSource', 'ConfigSource', 'ConfigValue' + 'MissingConfigError', 'MissingConfigWarning', 'ConfigOptions', 'EnvConfigSource', 'ConfigParserConfigSource', 'DictConfigSource', 'ConfigSource', 'ConfigValue', + 'ArgConfigSource' ) diff --git a/src/suou/flask_sqlalchemy.py b/src/suou/flask_sqlalchemy.py index 0704460..94afc6f 100644 --- a/src/suou/flask_sqlalchemy.py +++ b/src/suou/flask_sqlalchemy.py @@ -20,10 +20,12 @@ from typing import Any, Callable, Never from flask import abort, request from flask_sqlalchemy import SQLAlchemy from sqlalchemy.orm import DeclarativeBase, Session +from .functools import deprecated from .codecs import want_bytes from .sqlalchemy import AuthSrc, require_auth_base +@deprecated('inherits from deprecated and unused class') class FlaskAuthSrc(AuthSrc): ''' @@ -45,6 +47,7 @@ class FlaskAuthSrc(AuthSrc): def required_exc(self): abort(401, 'Login required') +@deprecated('not intuitive to use') def require_auth(cls: type[DeclarativeBase], db: SQLAlchemy) -> Callable[Any, Callable]: """ 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 # Optional dependency: do not import into __init__.py -__all__ = ('require_auth', ) +__all__ = () diff --git a/src/suou/sqlalchemy/__init__.py b/src/suou/sqlalchemy/__init__.py index 81f61f8..2603d0b 100644 --- a/src/suou/sqlalchemy/__init__.py +++ b/src/suou/sqlalchemy/__init__.py @@ -157,13 +157,17 @@ def require_auth_base(cls: type[DeclarativeBase], *, src: AuthSrc, column: str | 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 __all__ = ( 'IdType', 'id_column', 'snowflake_column', 'entity_base', 'declarative_base', 'token_signer', 'match_column', 'match_constraint', 'bool_column', 'parent_children', 'author_pair', 'age_pair', 'bound_fk', 'unbound_fk', 'want_column', + 'a_relationship', 'BitSelector', 'secret_column', # .asyncio 'SQLAlchemy', 'AsyncSelectPagination', 'async_query' ) \ No newline at end of file diff --git a/src/suou/sqlalchemy/orm.py b/src/suou/sqlalchemy/orm.py index 6d75a4f..063bcef 100644 --- a/src/suou/sqlalchemy/orm.py +++ b/src/suou/sqlalchemy/orm.py @@ -19,6 +19,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. from binascii import Incomplete +import os from typing import Any, Callable import warnings 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) +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]]]: """ @@ -191,6 +202,17 @@ def parent_children(keyword: str, /, *, lazy='selectin', **kwargs) -> tuple[Inco 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]: """ 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) + +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 + diff --git a/src/suou/waiter.py b/src/suou/waiter.py new file mode 100644 index 0000000..74d2d0e --- /dev/null +++ b/src/suou/waiter.py @@ -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') \ No newline at end of file From 9e386c4f71677770fae778110f31ca0250cea2d4 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Thu, 4 Sep 2025 09:29:38 +0200 Subject: [PATCH 067/121] add SessionWrapper --- CHANGELOG.md | 3 +- src/suou/sqlalchemy/__init__.py | 2 +- src/suou/sqlalchemy/asyncio.py | 74 ++++++++++++++++++++++++++++++--- 3 files changed, 71 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a31ce90..c70d776 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,9 @@ ## 0.6.0 + `.sqlalchemy` has been made a subpackage and split; `sqlalchemy_async` has been deprecated. Update your imports. ++ Add several new utilities to `.sqlalchemy`: `BitSelector`, `secret_column`, `a_relationship`, `SessionWrapper`, + `wrap=` argument to SQLAlchemy. Also removed dead batteries. + 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 diff --git a/src/suou/sqlalchemy/__init__.py b/src/suou/sqlalchemy/__init__.py index 2603d0b..b207438 100644 --- a/src/suou/sqlalchemy/__init__.py +++ b/src/suou/sqlalchemy/__init__.py @@ -169,5 +169,5 @@ __all__ = ( 'author_pair', 'age_pair', 'bound_fk', 'unbound_fk', 'want_column', 'a_relationship', 'BitSelector', 'secret_column', # .asyncio - 'SQLAlchemy', 'AsyncSelectPagination', 'async_query' + 'SQLAlchemy', 'AsyncSelectPagination', 'async_query', 'SessionWrapper' ) \ No newline at end of file diff --git a/src/suou/sqlalchemy/asyncio.py b/src/suou/sqlalchemy/asyncio.py index 786d9f8..e513652 100644 --- a/src/suou/sqlalchemy/asyncio.py +++ b/src/suou/sqlalchemy/asyncio.py @@ -21,7 +21,7 @@ from __future__ import annotations from functools import wraps -from sqlalchemy import Engine, Select, func, select +from sqlalchemy import Engine, Select, Table, func, select from sqlalchemy.orm import DeclarativeBase, lazyload from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine from flask_sqlalchemy.pagination import Pagination @@ -30,29 +30,44 @@ from suou.exceptions import NotFoundError class SQLAlchemy: """ - Drop-in (?) replacement for flask_sqlalchemy.SQLAlchemy() - eligible for async environments + Drop-in (in fact, almost) replacement for flask_sqlalchemy.SQLAlchemy() + eligible for async environments. + + Notable changes: + + You have to create the session yourself. Easiest use case: + + async def handler (userid): + async with db as session: + # do something + user = (await session.execute(select(User).where(User.id == userid))).scalar() + # ... NEW 0.5.0 + + UPDATED 0.6.0: added wrap=True """ base: DeclarativeBase engine: AsyncEngine _sessions: list[AsyncSession] + _wrapsessions: bool NotFound = NotFoundError - def __init__(self, model_class: DeclarativeBase): + def __init__(self, model_class: DeclarativeBase, *, wrap = False): self.base = model_class self.engine = None + self._wrapsessions = wrap self._sessions = [] def bind(self, url: str): self.engine = create_async_engine(url) def _ensure_engine(self): if self.engine is None: raise RuntimeError('database is not connected') - async def begin(self, *, expire_on_commit = False, **kw) -> AsyncSession: + async def begin(self, *, expire_on_commit = False, wrap = False, **kw) -> AsyncSession: self._ensure_engine() ## XXX is it accurate? s = AsyncSession(self.engine, expire_on_commit=expire_on_commit, **kw) + if wrap: + s = SessionWrapper(s) self._sessions.append(s) return s async def __aenter__(self) -> AsyncSession: @@ -171,6 +186,53 @@ def async_query(db: SQLAlchemy, multi: False): return executor return decorator +class SessionWrapper: + """ + Wrap a SQLAlchemy() session (context manager) adding several QoL utilitites. + + It can be applied to: + + sessions created by SQLAlchemy() - it must receive a wrap=True argument in constructor; + + sessions created manually - by constructing SessionWrapper(session). + + This works in async context; DO NOT USE with regular SQLAlchemy. + + NEW 0.6.0 + """ + + def __init__(self, db_or_session: SQLAlchemy | AsyncSession): + self._wrapped = db_or_session + async def __aenter__(self): + if isinstance(self._wrapped, SQLAlchemy): + self._wrapped = await self._wrapped.begin() + return self + + async def __aexit__(self, *exc_info): + await self._wrapped.__aexit__(*exc_info) + + @property + def _session(self): + if isinstance(self._wrapped, AsyncSession): + return self._wrapped + raise RuntimeError('active session is required') + + async def get_one(self, query: Select): + result = await self._session.execute(query) + return result.scalar() + + async def get_by_id(self, table: Table, key) : + return await self.get_one(select(table).where(table.id == key)) # pyright: ignore[reportAttributeAccessIssue] + + async def get_list(self, query: Select, limit: int | None = None): + if limit: + query = query.limit(limit) + result = await self._session.execute(query) + return list(result.scalars()) + + def __getattr__(self, key): + """ + Fall back to the wrapped session + """ + return getattr(self._session, key) # Optional dependency: do not import into __init__.py -__all__ = ('SQLAlchemy', 'AsyncSelectPagination', 'async_query') \ No newline at end of file +__all__ = ('SQLAlchemy', 'AsyncSelectPagination', 'async_query', 'SessionWrapper') \ No newline at end of file From 1c809a9930837e05e23efcdda30bd56271a665f5 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Thu, 4 Sep 2025 09:49:31 +0200 Subject: [PATCH 068/121] changelog for 0.5.3 --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c70d776..9d6dd79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,8 @@ ## 0.5.3 -... +- Added docstring to `SQLAlchemy()`. +- More type fixes. ## 0.5.2 From 3d6d44e4a13d51ad73baa8c5b0f076d0921f11ba Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 5 Sep 2025 21:50:17 +0200 Subject: [PATCH 069/121] add strings to .legal, change some docstrings --- CHANGELOG.md | 1 + src/suou/legal.py | 19 ++++++++++++++++++- src/suou/sqlalchemy/__init__.py | 2 +- src/suou/sqlalchemy/orm.py | 2 +- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d6dd79..271b50c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ `wrap=` argument to SQLAlchemy. Also removed dead batteries. + Add `.waiter` module. For now, non-functional. + Add `ArgConfigSource` to `.configparse` ++ Add more strings to `.legal` module ## 0.5.3 diff --git a/src/suou/legal.py b/src/suou/legal.py index 422486d..d1ba18e 100644 --- a/src/suou/legal.py +++ b/src/suou/legal.py @@ -1,5 +1,5 @@ """ -TOS / policy building blocks for the lazy. +TOS / policy building blocks for the lazy, in English language. XXX DANGER! This is not replacement for legal advice. Contact your lawyer. @@ -30,3 +30,20 @@ GOVERNING_LAW = """ These terms of services are governed by, and shall be interpreted in accordance with, the laws of {0}. You consent to the sole jurisdiction of {1} for all disputes between You and , and You consent to the sole application of {2} law for all such disputes. """ +ENGLISH_FIRST = """ +In case there is any inconsistency between these Terms and any translation into other languages, the English language version takes precedence. +""" + +EXPECT_UPDATES = """ +{0} may periodically update these Terms of Service. Every time this happens, {0} will make its best efforts to notify You of such changes. + +Whenever {0} updates these Terms of Service, Your continued use of the {0} platform constitutes Your agreement to the updated Terms of Service. +""" + +SEVERABILITY = """ +If one clause of these Terms of Service or any policy incorporated here by reference is determined by a court to be unenforceable, the remainder of the Terms and Content Policy shall remain in force. +""" + +COMPLETENESS = """ +These Terms, together with the other policies incorporated into them by reference, contain all the terms and conditions agreed upon by You and {{ app_name }} regarding Your use of the {{ app_name }} service. No other agreement, oral or otherwise, will be deemed to exist or to bind either of the parties to this Agreement. +""" \ No newline at end of file diff --git a/src/suou/sqlalchemy/__init__.py b/src/suou/sqlalchemy/__init__.py index b207438..f691d8c 100644 --- a/src/suou/sqlalchemy/__init__.py +++ b/src/suou/sqlalchemy/__init__.py @@ -83,7 +83,7 @@ def token_signer(id_attr: Column | str, secret_attr: Column | str) -> Incomplete -## Utilities for use in web apps below +## (in)Utilities for use in web apps below @deprecated('not part of the public API and not even working') class AuthSrc(metaclass=ABCMeta): diff --git a/src/suou/sqlalchemy/orm.py b/src/suou/sqlalchemy/orm.py index 063bcef..06a876a 100644 --- a/src/suou/sqlalchemy/orm.py +++ b/src/suou/sqlalchemy/orm.py @@ -204,7 +204,7 @@ def parent_children(keyword: str, /, *, lazy='selectin', **kwargs) -> tuple[Inco def a_relationship(primary = None, /, j=None, *, lazy='selectin', **kwargs): """ - Shorthand for relationship() that sets lazy='selectin' automatically. + Shorthand for relationship() that sets lazy='selectin' by default. NEW 0.6.0 """ From bfc6cb8e8556590d1c13f53ef107e1be1b86c592 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Tue, 9 Sep 2025 16:45:12 +0200 Subject: [PATCH 070/121] add tests to .signing + z85 encoding support --- CHANGELOG.md | 6 ++++-- src/suou/__init__.py | 8 +++++--- src/suou/classtools.py | 2 ++ src/suou/codecs.py | 25 ++++++++++++++++++++++++- src/suou/signing.py | 24 ++++++++++++++++++------ tests/test_codecs.py | 11 ++++++++++- tests/test_signing.py | 41 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 104 insertions(+), 13 deletions(-) create mode 100644 tests/test_signing.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 271b50c..07c5769 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,12 @@ + `.sqlalchemy` has been made a subpackage and split; `sqlalchemy_async` has been deprecated. Update your imports. + Add several new utilities to `.sqlalchemy`: `BitSelector`, `secret_column`, `a_relationship`, `SessionWrapper`, - `wrap=` argument to SQLAlchemy. Also removed dead batteries. -+ Add `.waiter` module. For now, non-functional. + `wrap=` argument to SQLAlchemy. Also removed dead batteries ++ Add `.waiter` module. For now, non-functional + Add `ArgConfigSource` to `.configparse` ++ Add Z85 (`z85encode()` `z85decode()`) encoding support + Add more strings to `.legal` module ++ `.signing` module is now covered by tests ## 0.5.3 diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 96db617..02d96d8 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -27,6 +27,7 @@ from .functools import deprecated, not_implemented, timed_cache, none_pass, alru from .classtools import Wanted, Incomplete from .itertools import makelist, kwargs_prefix, ltuple, rtuple, additem, addattr from .i18n import I18n, JsonI18n, TomlI18n +from .signing import UserSigner from .snowflake import Snowflake, SnowflakeGen from .lex import symbol_table, lex, ilex from .strtools import PrefixIdentifier @@ -34,14 +35,14 @@ from .validators import matches from .redact import redact_url_password from .http import WantsContentType -__version__ = "0.6.0-dev35" +__version__ = "0.6.0-dev36" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', 'DictConfigSource', 'EnvConfigSource', 'I18n', 'Incomplete', 'JsonI18n', 'MissingConfigError', 'MissingConfigWarning', 'PrefixIdentifier', 'Siq', 'SiqCache', 'SiqGen', 'SiqType', 'Snowflake', 'SnowflakeGen', - 'StringCase', 'TimedDict', 'TomlI18n', 'Wanted', 'WantsContentType', + 'StringCase', 'TimedDict', 'TomlI18n', 'UserSigner', 'Wanted', 'WantsContentType', 'addattr', 'additem', 'age_and_days', 'alru_cache', 'b2048decode', 'b2048encode', 'b32ldecode', 'b32lencode', 'b64encode', 'b64decode', 'cb32encode', 'cb32decode', 'count_ones', 'deprecated', 'ilex', 'join_bits', @@ -49,5 +50,6 @@ __all__ = ( 'matches', 'mod_ceil', 'mod_floor', 'none_pass', 'not_implemented', 'redact_url_password', 'rtuple', 'split_bits', 'ssv_list', 'symbol_table', 'timed_cache', 'twocolon_list', 'want_bytes', 'want_datetime', 'want_isodate', - 'want_str', 'want_timestamp', 'want_urlsafe', 'want_urlsafe_bytes' + 'want_str', 'want_timestamp', 'want_urlsafe', 'want_urlsafe_bytes', + 'z85encode', 'z85decode' ) diff --git a/src/suou/classtools.py b/src/suou/classtools.py index c58a123..458a498 100644 --- a/src/suou/classtools.py +++ b/src/suou/classtools.py @@ -27,6 +27,8 @@ logger = logging.getLogger(__name__) class MissingType(object): __slots__ = () + def __bool__(self): + return False MISSING = MissingType() diff --git a/src/suou/codecs.py b/src/suou/codecs.py index e5f94af..c617160 100644 --- a/src/suou/codecs.py +++ b/src/suou/codecs.py @@ -225,6 +225,28 @@ def rb64decode(val: bytes | str) -> bytes: val = want_urlsafe(val) return base64.urlsafe_b64decode(val.rjust(mod_ceil(len(val), 4), 'A')) + +B85_TO_Z85 = str.maketrans( + '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~', + '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-:+=^!/*?&<>()[]{}@%$#' +) +Z85_TO_B85 = str.maketrans( + '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-:+=^!/*?&<>()[]{}@%$#', + '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~' +) + +if hasattr(base64, 'z85encode'): + # Python >=3.13 + def z85encode(val: bytes) -> str: + return want_str(base64.z85encode(val)) + z85decode = base64.z85decode +else: + # Python <=3.12 + def z85encode(val: bytes) -> str: + return want_str(base64.b85encode(val)).translate(B85_TO_Z85) + def z85decode(val: bytes | str) -> bytes: + return base64.b85decode(want_str(val).translate(Z85_TO_B85)) + def b2048encode(val: bytes) -> str: ''' Encode a bytestring using the BIP-39 wordlist. @@ -345,5 +367,6 @@ class StringCase(enum.Enum): __all__ = ( 'cb32encode', 'cb32decode', 'b32lencode', 'b32ldecode', 'b64encode', 'b64decode', 'jsonencode' - 'StringCase', 'want_bytes', 'want_str', 'jsondecode', 'ssv_list', 'twocolon_list', 'want_urlsafe', 'want_urlsafe_bytes' + 'StringCase', 'want_bytes', 'want_str', 'jsondecode', 'ssv_list', 'twocolon_list', 'want_urlsafe', 'want_urlsafe_bytes', + 'z85encode', 'z85decode' ) \ No newline at end of file diff --git a/src/suou/signing.py b/src/suou/signing.py index 6f2dcb9..90ca7fa 100644 --- a/src/suou/signing.py +++ b/src/suou/signing.py @@ -15,15 +15,19 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. """ from abc import ABC -from base64 import b64decode from typing import Any, Callable, Sequence +import warnings from itsdangerous import TimestampSigner +from itsdangerous import Signer as _Signer +from itsdangerous.encoding import int_to_bytes as _int_to_bytes + from suou.itertools import rtuple from .functools import not_implemented -from .codecs import jsondecode, jsonencode, want_bytes, want_str +from .codecs import jsondecode, jsonencode, rb64decode, want_bytes, want_str, b64decode, b64encode from .iding import Siq +from .classtools import MISSING class UserSigner(TimestampSigner): """ @@ -33,12 +37,21 @@ class UserSigner(TimestampSigner): def __init__(self, master_secret: bytes, user_id: int, user_secret: bytes, **kwargs): super().__init__(master_secret + user_secret, salt=Siq(user_id).to_bytes(), **kwargs) self.user_id = user_id - def token(self) -> str: - return self.sign(Siq(self.user_id).to_base64()).decode('ascii') + def token(self, *, test_timestamp=MISSING) -> str: + payload = Siq(self.user_id).to_base64() + ## The following is not intended for general use + if test_timestamp is not MISSING: + warnings.warn('timestamp= parameter is intended for testing only!\n\x1b[31mDO NOT use it in production or you might get consequences\x1b[0m, just saying', UserWarning) + ts_payload = b64encode(_int_to_bytes(test_timestamp)) + payload = want_bytes(payload) + want_bytes(self.sep) + want_bytes(ts_payload) + return want_str(_Signer.sign(self, payload)) + ## END the following is not intended for general use + + return want_str(self.sign(payload)) @classmethod def split_token(cls, /, token: str | bytes) : a, b, c = want_str(token).rsplit('.', 2) - return b64decode(a), b, b64decode(c) + return b64decode(a), int.from_bytes(b64decode(b), 'big'), b64decode(c) def sign_object(self, obj: dict, /, *, encoder=jsonencode, **kwargs): """ Return a signed JSON payload of an object. @@ -54,7 +67,6 @@ class UserSigner(TimestampSigner): def split_signed(self, payload: str | bytes) -> Sequence[bytes]: return rtuple(want_bytes(payload).rsplit(b'.', 2), 3, b'') - class HasSigner(ABC): ''' Abstract base class for INTERNAL USE. diff --git a/tests/test_codecs.py b/tests/test_codecs.py index 035a605..95eb10f 100644 --- a/tests/test_codecs.py +++ b/tests/test_codecs.py @@ -2,7 +2,7 @@ import binascii import unittest -from suou.codecs import b64encode, b64decode, want_urlsafe +from suou.codecs import b64encode, b64decode, want_urlsafe, z85decode B1 = b'N\xf0\xb4\xc3\x85\n\xf9\xb6\x9a\x0f\x82\xa6\x99G\x07#' B2 = b'\xbcXiF,@|{\xbe\xe3\x0cz\xa8\xcbQ\x82' @@ -49,3 +49,12 @@ class TestCodecs(unittest.TestCase): self.assertEqual('Disney-', want_urlsafe('Disney+')) self.assertEqual('spaziocosenza', want_urlsafe('spazio cosenza')) self.assertEqual('=======', want_urlsafe('=======')) + + def test_z85decode(self): + self.assertEqual(z85decode('pvLTdG:NT:NH+1ENmvGb'), B1) + self.assertEqual(z85decode('YJw(qei[PfZt/SFSln4&'), B2) + self.assertEqual(z85decode('>[>>)c=hgL?I8'), B3) + self.assertEqual(z85decode('2p3(-x*%TsE0-P/40[>}'), B4) + self.assertEqual(z85decode('%m&HH?#r'), B5) + self.assertEqual(z85decode('%m&HH?#uEvW8mO8}l(.5F#j@a2o%'), B5 + B1) + \ No newline at end of file diff --git a/tests/test_signing.py b/tests/test_signing.py new file mode 100644 index 0000000..e56c976 --- /dev/null +++ b/tests/test_signing.py @@ -0,0 +1,41 @@ + + + + +import time +import unittest + +from suou.codecs import want_bytes, b64decode, z85decode +from suou.iding import Siq +from suou.signing import UserSigner + + +class TestSigning(unittest.TestCase): + def setUp(self) -> None: + # use deterministic secrets in testing + self.signer = UserSigner( + z85decode('suou-test!'), # master secret + Siq(1907492221233425151961830768246784), # user id + b64decode('e7YXG4ob22mBCxoPvgewlAsfiZE2MFu50aP_gtnXW2v2') + ) + def tearDown(self) -> None: + ... + + def test_UserSigner_token(self): + # self coherence test + TIMESTAMP = 1757426896 + with self.assertWarns(UserWarning): + tok = self.signer.token(test_timestamp=TIMESTAMP) + self.assertIsInstance(tok, str) + self.assertEqual(tok, 'AF4L78gAAAAAAAAAAAAA.aMA00A.0au9HDfOJZv-gpudEevT6Squ8go') + + tok2 = self.signer.token() + tim = int(time.time()) + if tim != TIMESTAMP: + self.assertNotEqual(tok2, tok) + + tokp = UserSigner.split_token(tok) + self.assertEqual(tokp[0], z85decode('0a364:n=hu000000000')) + self.assertEqual(tokp[1], TIMESTAMP) + + \ No newline at end of file From dcb2ce79955a2b05f01d99b06f6cc98a7b73356d Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Tue, 9 Sep 2025 22:05:57 +0200 Subject: [PATCH 071/121] add dei_args(), fix missing imports in .sqlalchemy --- CHANGELOG.md | 1 + src/suou/__init__.py | 3 ++- src/suou/dei.py | 27 +++++++++++++++++++++++++++ src/suou/signing.py | 2 ++ src/suou/sqlalchemy/orm.py | 10 ++++++++-- 5 files changed, 40 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07c5769..f01e55e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ + Add Z85 (`z85encode()` `z85decode()`) encoding support + Add more strings to `.legal` module + `.signing` module is now covered by tests ++ New decorator `dei_args()`. Now offensive naming is no more a worry! ## 0.5.3 diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 02d96d8..f73b414 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -23,6 +23,7 @@ from .bits import count_ones, mask_shift, split_bits, join_bits, mod_ceil, mod_f from .calendar import want_datetime, want_isodate, want_timestamp, age_and_days from .configparse import MissingConfigError, MissingConfigWarning, ConfigOptions, ConfigParserConfigSource, ConfigSource, DictConfigSource, ConfigValue, EnvConfigSource from .collections import TimedDict +from .dei import dei_args from .functools import deprecated, not_implemented, timed_cache, none_pass, alru_cache from .classtools import Wanted, Incomplete from .itertools import makelist, kwargs_prefix, ltuple, rtuple, additem, addattr @@ -45,7 +46,7 @@ __all__ = ( 'StringCase', 'TimedDict', 'TomlI18n', 'UserSigner', 'Wanted', 'WantsContentType', 'addattr', 'additem', 'age_and_days', 'alru_cache', 'b2048decode', 'b2048encode', 'b32ldecode', 'b32lencode', 'b64encode', 'b64decode', 'cb32encode', - 'cb32decode', 'count_ones', 'deprecated', 'ilex', 'join_bits', + 'cb32decode', 'count_ones', 'dei_args', 'deprecated', 'ilex', 'join_bits', 'jsonencode', 'kwargs_prefix', 'lex', 'ltuple', 'makelist', 'mask_shift', 'matches', 'mod_ceil', 'mod_floor', 'none_pass', 'not_implemented', 'redact_url_password', 'rtuple', 'split_bits', 'ssv_list', 'symbol_table', diff --git a/src/suou/dei.py b/src/suou/dei.py index c485216..0f7a7a0 100644 --- a/src/suou/dei.py +++ b/src/suou/dei.py @@ -18,6 +18,8 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. from __future__ import annotations +from functools import wraps +from typing import Callable BRICKS = '@abcdefghijklmnopqrstuvwxyz+?-\'/' @@ -108,3 +110,28 @@ class Pronoun(int): i += BRICKS.index(ch) << (5 * j) return Pronoun(i) + + +def dei_args(**renames): + """ + Allow for aliases in the keyword argument names, in form alias='real_name'. + + DEI utility for those programmers who don't want to have to do with + potentially offensive variable naming. + + Dear conservatives, this does not influence the ability to call the wrapped function + with the original parameter names. + """ + def decorator(func: Callable): + @wraps(func) + def wrapper(*args, **kwargs): + for alias_name, actual_name in renames.items(): + if alias_name in kwargs: + val = kwargs.pop(alias_name) + kwargs[actual_name] = val + + return func(*args, **kwargs) + return wrapper + return decorator + + diff --git a/src/suou/signing.py b/src/suou/signing.py index 90ca7fa..c2011ce 100644 --- a/src/suou/signing.py +++ b/src/suou/signing.py @@ -22,6 +22,7 @@ from itsdangerous import TimestampSigner from itsdangerous import Signer as _Signer from itsdangerous.encoding import int_to_bytes as _int_to_bytes +from suou.dei import dei_args from suou.itertools import rtuple from .functools import not_implemented @@ -34,6 +35,7 @@ class UserSigner(TimestampSigner): itsdangerous.TimestampSigner() instanced from a user ID, with token generation and validation capabilities. """ user_id: int + @dei_args(primary_secret='master_secret') def __init__(self, master_secret: bytes, user_id: int, user_secret: bytes, **kwargs): super().__init__(master_secret + user_secret, salt=Siq(user_id).to_bytes(), **kwargs) self.user_id = user_id diff --git a/src/suou/sqlalchemy/orm.py b/src/suou/sqlalchemy/orm.py index 06a876a..403e762 100644 --- a/src/suou/sqlalchemy/orm.py +++ b/src/suou/sqlalchemy/orm.py @@ -20,19 +20,24 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. from binascii import Incomplete import os -from typing import Any, Callable +from typing import Any, Callable, TypeVar import warnings from sqlalchemy import BigInteger, Boolean, CheckConstraint, Column, Date, ForeignKey, LargeBinary, MetaData, SmallInteger, String, text from sqlalchemy.orm import DeclarativeBase, InstrumentedAttribute, Relationship, declarative_base as _declarative_base, relationship from sqlalchemy.types import TypeEngine -from suou.classtools import Wanted +from sqlalchemy.ext.hybrid import Comparator +from suou.classtools import Wanted, Incomplete from suou.codecs import StringCase +from suou.dei import dei_args from suou.iding import Siq, SiqCache, SiqGen, SiqType from suou.itertools import kwargs_prefix from suou.snowflake import SnowflakeGen from suou.sqlalchemy import IdType +_T = TypeVar('_T') + + def want_column(cls: type[DeclarativeBase], col: Column[_T] | str) -> Column[_T]: """ Return a table's column given its name. @@ -117,6 +122,7 @@ def bool_column(value: bool = False, nullable: bool = False, **kwargs) -> Column return Column(Boolean, server_default=def_val, nullable=nullable, **kwargs) +@dei_args(primary_secret='master_secret') def declarative_base(domain_name: str, master_secret: bytes, metadata: dict | None = None, **kwargs) -> type[DeclarativeBase]: """ Drop-in replacement for sqlalchemy.orm.declarative_base() From 886da11aded99315c16184001dd6f4c1bc63896c Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Thu, 11 Sep 2025 20:38:27 +0200 Subject: [PATCH 072/121] 0.6.0 release --- CHANGELOG.md | 2 +- src/suou/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f01e55e..6ddd7a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ + `.sqlalchemy` has been made a subpackage and split; `sqlalchemy_async` has been deprecated. Update your imports. + Add several new utilities to `.sqlalchemy`: `BitSelector`, `secret_column`, `a_relationship`, `SessionWrapper`, `wrap=` argument to SQLAlchemy. Also removed dead batteries -+ Add `.waiter` module. For now, non-functional ++ Add `.waiter` module. For now, non-functional ~ + Add `ArgConfigSource` to `.configparse` + Add Z85 (`z85encode()` `z85decode()`) encoding support + Add more strings to `.legal` module diff --git a/src/suou/__init__.py b/src/suou/__init__.py index f73b414..e47d233 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -36,7 +36,7 @@ from .validators import matches from .redact import redact_url_password from .http import WantsContentType -__version__ = "0.6.0-dev36" +__version__ = "0.6.0" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', From da0caadf08e096c8fe928c312463a1a4734ca757 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sat, 13 Sep 2025 21:04:22 +0200 Subject: [PATCH 073/121] 0.6.1 pypi name change --- CHANGELOG.md | 7 ++++- aliases/sakuragasaki46-suou/pyproject.toml | 1 + pyproject.toml | 19 +++++++------- src/suou/__init__.py | 2 +- src/suou/sqlalchemy/__init__.py | 2 +- src/suou/sqlalchemy/asyncio.py | 30 ++++++++++++++-------- 6 files changed, 39 insertions(+), 22 deletions(-) create mode 100644 aliases/sakuragasaki46-suou/pyproject.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ddd7a5..37975e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,13 @@ # Changelog +## 0.6.1 + +- First release on PyPI under the name `suou`. +- Fix `sqlalchemy.asyncio.SQLAlchemy()` to use context vars; `expire_on_commit=` is now configurable at instantiation. Fix some missing re-exports. + ## 0.6.0 -+ `.sqlalchemy` has been made a subpackage and split; `sqlalchemy_async` has been deprecated. Update your imports. ++ `.sqlalchemy` has been made a subpackage and split; `sqlalchemy_async` (moved to `sqlalchemy.asyncio`) has been deprecated. Update your imports. + Add several new utilities to `.sqlalchemy`: `BitSelector`, `secret_column`, `a_relationship`, `SessionWrapper`, `wrap=` argument to SQLAlchemy. Also removed dead batteries + Add `.waiter` module. For now, non-functional ~ diff --git a/aliases/sakuragasaki46-suou/pyproject.toml b/aliases/sakuragasaki46-suou/pyproject.toml new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/aliases/sakuragasaki46-suou/pyproject.toml @@ -0,0 +1 @@ + diff --git a/pyproject.toml b/pyproject.toml index 2194046..984ae9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,6 @@ [project] -name = "sakuragasaki46_suou" +name = "suou" +description = "casual utility library for coding QoL" authors = [ { name = "Sakuragasaki46" } ] @@ -30,7 +31,7 @@ classifiers = [ ] [project.urls] -Repository = "https://github.com/sakuragasaki46/suou" +Repository = "https://nekode.yusur.moe/yusur/suou" [project.optional-dependencies] # the below are all dev dependencies (and probably already installed) @@ -62,13 +63,13 @@ sass = [ ] full = [ - "sakuragasaki46-suou[sqlalchemy]", - "sakuragasaki46-suou[flask]", - "sakuragasaki46-suou[quart]", - "sakuragasaki46-suou[peewee]", - "sakuragasaki46-suou[markdown]", - "sakuragasaki46-suou[flask-sqlalchemy]", - "sakuragasaki46-suou[sass]" + "suou[sqlalchemy]", + "suou[flask]", + "suou[quart]", + "suou[peewee]", + "suou[markdown]", + "suou[flask-sqlalchemy]", + "suou[sass]" ] diff --git a/src/suou/__init__.py b/src/suou/__init__.py index e47d233..3c0efd3 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -36,7 +36,7 @@ from .validators import matches from .redact import redact_url_password from .http import WantsContentType -__version__ = "0.6.0" +__version__ = "0.6.1" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', diff --git a/src/suou/sqlalchemy/__init__.py b/src/suou/sqlalchemy/__init__.py index f691d8c..63216b6 100644 --- a/src/suou/sqlalchemy/__init__.py +++ b/src/suou/sqlalchemy/__init__.py @@ -158,7 +158,7 @@ def require_auth_base(cls: type[DeclarativeBase], *, src: AuthSrc, column: str | from .asyncio import SQLAlchemy, AsyncSelectPagination, async_query from .orm import ( - id_column, snowflake_column, match_column, match_constraint, bool_column, declarative_base, + id_column, snowflake_column, match_column, match_constraint, bool_column, declarative_base, parent_children, author_pair, age_pair, bound_fk, unbound_fk, want_column, a_relationship, BitSelector, secret_column ) diff --git a/src/suou/sqlalchemy/asyncio.py b/src/suou/sqlalchemy/asyncio.py index e513652..02a8949 100644 --- a/src/suou/sqlalchemy/asyncio.py +++ b/src/suou/sqlalchemy/asyncio.py @@ -20,12 +20,13 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. from __future__ import annotations from functools import wraps - -from sqlalchemy import Engine, Select, Table, func, select +from contextvars import ContextVar, Token +from sqlalchemy import Select, Table, func, select from sqlalchemy.orm import DeclarativeBase, lazyload from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine from flask_sqlalchemy.pagination import Pagination +from suou.classtools import MISSING from suou.exceptions import NotFoundError class SQLAlchemy: @@ -45,36 +46,44 @@ class SQLAlchemy: NEW 0.5.0 UPDATED 0.6.0: added wrap=True + + UPDATED 0.6.1: expire_on_commit is now configurable per-SQLAlchemy(); + now sessions are stored as context variables """ base: DeclarativeBase engine: AsyncEngine - _sessions: list[AsyncSession] + _session_tok: list[Token[AsyncSession]] _wrapsessions: bool + _xocommit: bool NotFound = NotFoundError - def __init__(self, model_class: DeclarativeBase, *, wrap = False): + def __init__(self, model_class: DeclarativeBase, *, expire_on_commit = False, wrap = False): self.base = model_class self.engine = None self._wrapsessions = wrap - self._sessions = [] + self._xocommit = expire_on_commit def bind(self, url: str): self.engine = create_async_engine(url) def _ensure_engine(self): if self.engine is None: raise RuntimeError('database is not connected') - async def begin(self, *, expire_on_commit = False, wrap = False, **kw) -> AsyncSession: + async def begin(self, *, expire_on_commit = None, wrap = False, **kw) -> AsyncSession: self._ensure_engine() ## XXX is it accurate? - s = AsyncSession(self.engine, expire_on_commit=expire_on_commit, **kw) + s = AsyncSession(self.engine, + expire_on_commit=expire_on_commit if expire_on_commit is not None else self._xocommit, + **kw) if wrap: s = SessionWrapper(s) - self._sessions.append(s) + current_session.set(s) return s async def __aenter__(self) -> AsyncSession: return await self.begin() async def __aexit__(self, e1, e2, e3): ## XXX is it accurate? - s = self._sessions.pop() + s = current_session.get() + if not s: + raise RuntimeError('session not closed') if e1: await s.rollback() else: @@ -104,7 +113,8 @@ class SQLAlchemy: self.engine, checkfirst=checkfirst ) - +# XXX NOT public API! DO NOT USE +current_session: ContextVar[AsyncSession] = ContextVar('current_session') class AsyncSelectPagination(Pagination): """ From 3de5a3629d9ad333fb8602819c552d3a300452a8 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sat, 13 Sep 2025 21:14:31 +0200 Subject: [PATCH 074/121] fix build artifacts --- aliases/sakuragasaki46-suou/pyproject.toml | 1 - aliases/sakuragasaki46_suou/README.md | 1 + aliases/sakuragasaki46_suou/pyproject.toml | 8 ++++++++ 3 files changed, 9 insertions(+), 1 deletion(-) delete mode 100644 aliases/sakuragasaki46-suou/pyproject.toml create mode 100644 aliases/sakuragasaki46_suou/README.md create mode 100644 aliases/sakuragasaki46_suou/pyproject.toml diff --git a/aliases/sakuragasaki46-suou/pyproject.toml b/aliases/sakuragasaki46-suou/pyproject.toml deleted file mode 100644 index 8b13789..0000000 --- a/aliases/sakuragasaki46-suou/pyproject.toml +++ /dev/null @@ -1 +0,0 @@ - diff --git a/aliases/sakuragasaki46_suou/README.md b/aliases/sakuragasaki46_suou/README.md new file mode 100644 index 0000000..2b29355 --- /dev/null +++ b/aliases/sakuragasaki46_suou/README.md @@ -0,0 +1 @@ +moved to [suou](https://pypi.org/project/suou) diff --git a/aliases/sakuragasaki46_suou/pyproject.toml b/aliases/sakuragasaki46_suou/pyproject.toml new file mode 100644 index 0000000..035fdb5 --- /dev/null +++ b/aliases/sakuragasaki46_suou/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "sakuragasaki46_suou" +authors = [ { name = "Sakuragasaki46" } ] +version = "0.6.1" +requires-python = ">=3.10" +dependencies = [ "suou==0.6.1" ] +readme = "README.md" + From a2fdc9166fb505a2b590e9d87c825455f68f9bc9 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 19 Sep 2025 13:34:51 +0200 Subject: [PATCH 075/121] 0.7.x: @lucky, @rng_overload and more exceptions --- CHANGELOG.md | 5 ++ src/suou/__init__.py | 2 +- src/suou/exceptions.py | 42 +++++++++++++++- src/suou/lex.py | 10 ++++ src/suou/luck.py | 112 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 src/suou/luck.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 37975e7..80f1123 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.7.0 "The Lucky Update" + ++ Add RNG/random selection overloads such as `luck()`, `rng_overload()`. ++ Add 7 new throwable exceptions. + ## 0.6.1 - First release on PyPI under the name `suou`. diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 3c0efd3..9f80088 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -36,7 +36,7 @@ from .validators import matches from .redact import redact_url_password from .http import WantsContentType -__version__ = "0.6.1" +__version__ = "0.7.0-dev37" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', diff --git a/src/suou/exceptions.py b/src/suou/exceptions.py index 7952bc7..74ea7ec 100644 --- a/src/suou/exceptions.py +++ b/src/suou/exceptions.py @@ -1,5 +1,5 @@ """ -Exceptions and throwables for various purposes +Exceptions and throwables for all purposes! --- @@ -14,6 +14,17 @@ This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. """ + +class PoliticalError(Exception): + """ + Base class for anything that is refused to be executed for political reasons. + """ + +class PoliticalWarning(PoliticalError, Warning): + """ + Base class for politically suspicious behaviors. + """ + class MissingConfigError(LookupError): """ Config variable not found. @@ -53,6 +64,35 @@ class BabelTowerError(NotFoundError): The user requested a language that cannot be understood. """ +class BadLuckError(Exception): + """ + Stuff did not go as expected. + + Raised by @lucky decorator. + """ + +class TerminalRequiredError(OSError): + """ + Raised by terminal_required() decorator when a function is called from a non-interactive environment. + """ + +class BrokenStringsError(OSError): + """ + Issues related to audio happened, i.e. appropriate executables/libraries/drivers are not installed. + """ + +class Fahrenheit451Error(PoliticalError): + """ + Base class for thought crimes related to arts (e.g. writing, visual arts, music) + """ + +class FuckAroundFindOutError(PoliticalError): + """ + Raised when there is no actual grounds to raise an exception, but you did something in the past to deserve this outcome. + + Ideal for permanent service bans or something. + """ + __all__ = ( 'MissingConfigError', 'MissingConfigWarning', 'LexError', 'InconsistencyError', 'NotFoundError' ) \ No newline at end of file diff --git a/src/suou/lex.py b/src/suou/lex.py index 15791c3..5655eea 100644 --- a/src/suou/lex.py +++ b/src/suou/lex.py @@ -2,6 +2,16 @@ Utilities for tokenization of text. --- + +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. """ from re import Match diff --git a/src/suou/luck.py b/src/suou/luck.py new file mode 100644 index 0000000..c22b552 --- /dev/null +++ b/src/suou/luck.py @@ -0,0 +1,112 @@ +""" +Fortune and esoterism helpers. + +--- + +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. +""" + +from typing import Callable, Generic, Iterable, TypeVar +import random +from suou.exceptions import BadLuckError + +_T = TypeVar('_T') +_U = TypeVar('_U') + + +def lucky(validators: Iterable[Callable[[_U], bool]] = ()): + """ + Add one or more constraint on a function's return value. + Each validator must return a boolean. If false, the result is considered + unlucky and BadLuckError() is raised. + + UNTESTED + + NEW 0.7.0 + """ + def decorator(func: Callable[_T, _U]): + @wraps(func) + def wrapper(*args, **kwargs) -> _U: + try: + result = func(*args, **kwargs) + except Exception as e: + raise BadLuckError(f'exception happened: {e}') from e + for v in validators: + try: + if not v(result): + raise BadLuckError(f'result not expected: {result!r}') + except BadLuckError: + raise + except Exception as e: + raise BadLuckError(f'cannot validate: {e}') from e + return result + return wrapper + return decorator + +class RngCallable(Callable, Generic[_T, _U]): + """ + Overloaded ... randomly chosen callable. + + UNTESTED + + NEW 0.7.0 + """ + def __init__(self, /, func: Callable[_T, _U] | None = None, weight: int = 1): + self._callables = [] + self._max_weight = 0 + if callable(func): + self.add_callable(func, weight) + def add_callable(self, func: Callable[_T, _U], weight: int = 1): + """ + """ + weight = int(weight) + if weight <= 0: + return + self._callables.append((func, weight)) + self._max_weight += weight + def __call__(self, *a, **ka) -> _U: + choice = random.randrange(self._max_weight) + for w, c in self._callables: + if choice < w: + return c(*a, **ka) + elif choice < 0: + raise RuntimeError('inconsistent state') + else: + choice -= w + + +def rng_overload(prev_func: RngCallable[_T, _U] | int | None, /, *, weight: int = 1) -> RngCallable[_T, _U]: + """ + Decorate the first function with @rng_overload and the weight= parameter + (default 1, must be an integer) to create a "RNG" overloaded callable. + + Each call chooses randomly one candidate (weight is taken in consideration) + , calls it, and returns the result. + + UNTESTED + + NEW 0.7.0 + """ + if isinstance(prev_func, int) and weight == 1: + weight, prev_func = prev_func, None + + def decorator(func: Callable[_T, _U]): + nonlocal prev_func + if prev_func is None: + prev_func = RngCallable(func, weight=weight) + else: + prev_func.add_callable(func, weight=weight) + return prev_func + return decorator + + + + \ No newline at end of file From 83ab616e13ffa4d5505e5cc23d462a5ce3627566 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 19 Sep 2025 15:39:44 +0200 Subject: [PATCH 076/121] add chalk --- CHANGELOG.md | 5 +-- src/suou/__init__.py | 3 +- src/suou/asgi.py | 12 +++++++ src/suou/color.py | 79 ++++++++++++++++++++++++++++++++++++++++++++ src/suou/luck.py | 8 +++-- src/suou/waiter.py | 1 + 6 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 src/suou/color.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 80f1123..c1fee43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,9 @@ ## 0.7.0 "The Lucky Update" -+ Add RNG/random selection overloads such as `luck()`, `rng_overload()`. -+ Add 7 new throwable exceptions. ++ Add RNG/random selection overloads such as `luck()`, `rng_overload()` ++ Add 7 new throwable exceptions ++ Add color utilities: `chalk` module ## 0.6.1 diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 9f80088..a33a879 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -35,6 +35,7 @@ from .strtools import PrefixIdentifier from .validators import matches from .redact import redact_url_password from .http import WantsContentType +from .color import chalk __version__ = "0.7.0-dev37" @@ -46,7 +47,7 @@ __all__ = ( 'StringCase', 'TimedDict', 'TomlI18n', 'UserSigner', 'Wanted', 'WantsContentType', 'addattr', 'additem', 'age_and_days', 'alru_cache', 'b2048decode', 'b2048encode', 'b32ldecode', 'b32lencode', 'b64encode', 'b64decode', 'cb32encode', - 'cb32decode', 'count_ones', 'dei_args', 'deprecated', 'ilex', 'join_bits', + 'cb32decode', 'chalk', 'count_ones', 'dei_args', 'deprecated', 'ilex', 'join_bits', 'jsonencode', 'kwargs_prefix', 'lex', 'ltuple', 'makelist', 'mask_shift', 'matches', 'mod_ceil', 'mod_floor', 'none_pass', 'not_implemented', 'redact_url_password', 'rtuple', 'split_bits', 'ssv_list', 'symbol_table', diff --git a/src/suou/asgi.py b/src/suou/asgi.py index 3f3fd70..7229920 100644 --- a/src/suou/asgi.py +++ b/src/suou/asgi.py @@ -1,5 +1,17 @@ """ +ASGI stuff +--- + +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. """ from typing import Any, Awaitable, Callable, MutableMapping, ParamSpec, Protocol diff --git a/src/suou/color.py b/src/suou/color.py new file mode 100644 index 0000000..31ea031 --- /dev/null +++ b/src/suou/color.py @@ -0,0 +1,79 @@ +""" +Colors for coding artists + +NEW 0.7.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. + +""" + + +from functools import lru_cache + + +class Chalk: + """ + ANSI escape codes for terminal colors, similar to JavaScript's `chalk` library. + + Best used with Python 3.12+ that allows arbitrary nesting of f-strings. + + Yes, I am aware colorama exists. + + UNTESTED + + NEW 0.7.0 + """ + CSI = '\x1b[' + RED = CSI + "31m" + GREEN = CSI + "32m" + YELLOW = CSI + "33m" + BLUE = CSI + "34m" + CYAN = CSI + "36m" + PURPLE = CSI + "35m" + GREY = CSI + "90m" + END_COLOR = CSI + "39m" + BOLD = CSI + "1m" + END_BOLD = CSI + "22m" + FAINT = CSI + "2m" + def __init__(self, flags = (), ends = ()): + self._flags = tuple(flags) + self._ends = tuple(ends) + @lru_cache() + def _wrap(self, beg, end): + return Chalk(self._flags + (beg,), self._ends + (end,)) + def __call__(self, s: str) -> str: + return ''.join(self._flags) + s + ''.join(reversed(self._ends)) + def red(self): + return self._wrap(self.RED, self.END_COLOR) + def green(self): + return self._wrap(self.GREEN, self.END_COLOR) + def blue(self): + return self._wrap(self.BLUE, self.END_COLOR) + def yellow(self): + return self._wrap(self.YELLOW, self.END_COLOR) + def cyan(self): + return self._wrap(self.CYAN, self.END_COLOR) + def purple(self): + return self._wrap(self.PURPLE, self.END_COLOR) + def grey(self): + return self._wrap(self.GREY, self.END_COLOR) + gray = grey + marine = blue + def bold(self): + return self._wrap(self.BOLD, self.END_BOLD) + def faint(self): + return self._wrap(self.FAINT, self.END_BOLD) + + +## TODO make it lazy? +chalk = Chalk() diff --git a/src/suou/luck.py b/src/suou/luck.py index c22b552..7b8192e 100644 --- a/src/suou/luck.py +++ b/src/suou/luck.py @@ -1,5 +1,7 @@ """ -Fortune and esoterism helpers. +Fortune' RNG and esoterism. + +NEW 0.7.0 --- @@ -108,5 +110,5 @@ def rng_overload(prev_func: RngCallable[_T, _U] | int | None, /, *, weight: int return decorator - - \ No newline at end of file +# This module is experimental and therefore not re-exported into __init__ +__all__ = ('lucky', 'rng_overload') \ No newline at end of file diff --git a/src/suou/waiter.py b/src/suou/waiter.py index 74d2d0e..a210f45 100644 --- a/src/suou/waiter.py +++ b/src/suou/waiter.py @@ -54,4 +54,5 @@ def ko(status: int, /, content = None, **ka): return PlainTextResponse(content, status_code=status, **ka) return content +# This module is experimental and therefore not re-exported into __init__ __all__ = ('ko', 'ok', 'Waiter') \ No newline at end of file From 18950c34450eb2b4c8bf4cf6f3e70b87cb3cda99 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 19 Sep 2025 16:01:10 +0200 Subject: [PATCH 077/121] add @terminal_required --- CHANGELOG.md | 1 + src/suou/exceptions.py | 9 +++++++-- src/suou/terminal.py | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 src/suou/terminal.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c1fee43..2717927 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ + Add RNG/random selection overloads such as `luck()`, `rng_overload()` + Add 7 new throwable exceptions + Add color utilities: `chalk` module ++ Add `.terminal` module, to ease TUI development. ## 0.6.1 diff --git a/src/suou/exceptions.py b/src/suou/exceptions.py index 74ea7ec..f9c6b3f 100644 --- a/src/suou/exceptions.py +++ b/src/suou/exceptions.py @@ -86,13 +86,18 @@ class Fahrenheit451Error(PoliticalError): Base class for thought crimes related to arts (e.g. writing, visual arts, music) """ + # Werkzeug + code = 451 + class FuckAroundFindOutError(PoliticalError): """ Raised when there is no actual grounds to raise an exception, but you did something in the past to deserve this outcome. - Ideal for permanent service bans or something. + Ideal for permanent service bans or similar. """ __all__ = ( - 'MissingConfigError', 'MissingConfigWarning', 'LexError', 'InconsistencyError', 'NotFoundError' + 'MissingConfigError', 'MissingConfigWarning', 'LexError', 'InconsistencyError', 'NotFoundError', + 'TerminalRequiredError', 'PoliticalError', 'PoliticalWarning', 'Fahrenheit451Error', 'FuckAroundFindOutError', + 'BrokenStringsError', 'BadLuckError' ) \ No newline at end of file diff --git a/src/suou/terminal.py b/src/suou/terminal.py new file mode 100644 index 0000000..3ab7f4f --- /dev/null +++ b/src/suou/terminal.py @@ -0,0 +1,37 @@ +""" +Utilities for console I/O and text user interfaces (TUI) + +--- + +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. +""" + + +from __future__ import annotations +from functools import wraps +import sys +from suou.exceptions import TerminalRequiredError + + +def terminal_required(func): + """ + Requires the decorated callable to be fully connected to a terminal. + + NEW 0.7.0 + """ + @wraps(func) + def wrapper(*a, **ka): + if not (sys.stdin.isatty() and sys.stdout.isatty() and sys.stderr.isatty()): + raise TerminalRequiredError('this program must be run from a terminal') + return func(*a, **ka) + return wrapper + +__all__ = ('terminal_required',) \ No newline at end of file From f07d691004f51b05cf888787f7a89714476671b4 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 19 Sep 2025 16:52:23 +0200 Subject: [PATCH 078/121] add parse_time(), validators.not_greater_than() --- CHANGELOG.md | 2 ++ src/suou/calendar.py | 28 +++++++++++++++++++++++++++- src/suou/luck.py | 3 ++- src/suou/validators.py | 14 ++++++++++++-- 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2717927..5e92377 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ + Add 7 new throwable exceptions + Add color utilities: `chalk` module + Add `.terminal` module, to ease TUI development. ++ `calendar`: add `parse_time()`. ++ Add validator `not_greater_than()`. ## 0.6.1 diff --git a/src/suou/calendar.py b/src/suou/calendar.py index 1733853..d2af051 100644 --- a/src/suou/calendar.py +++ b/src/suou/calendar.py @@ -18,6 +18,8 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. import datetime from suou.functools import not_implemented +from suou.luck import lucky +from suou.validators import not_greater_than def want_isodate(d: datetime.datetime | str | float | int, *, tz = None) -> str: @@ -63,4 +65,28 @@ def age_and_days(date: datetime.datetime, now: datetime.datetime | None = None) d = (now - datetime.date(date.year + y, date.month, date.day)).days return y, d -__all__ = ('want_datetime', 'want_timestamp', 'want_isodate', 'age_and_days') \ No newline at end of file +@lucky([not_greater_than(259200)]) +def parse_time(timestr: str, /) -> int: + """ + Parse a number-suffix (es. 3s, 15m) or colon (1:30) time expression. + + Returns seconds as an integer. + """ + if timestr.isdigit(): + return int(timestr) + elif ':' in timestr: + timeparts = timestr.split(':') + if not timeparts[0].isdigit() and not all(x.isdigit() and len(x) == 2 for x in timeparts[1:]): + raise ValueError('invalid time format') + return sum(int(x) * 60 ** (len(timeparts) - 1 - i) for i, x in enumerate(timeparts)) + elif timestr.endswith('s') and timestr[:-1].isdigit(): + return int(timestr[:-1]) + elif timestr.endswith('m') and timestr[:-1].isdigit(): + return int(timestr[:-1]) * 60 + elif timestr.endswith('h') and timestr[:-1].isdigit(): + return int(float(timestr[:-1]) * 3600) + else: + raise ValueError('invalid time format') + + +__all__ = ('want_datetime', 'want_timestamp', 'want_isodate', 'age_and_days', 'parse_time') \ No newline at end of file diff --git a/src/suou/luck.py b/src/suou/luck.py index 7b8192e..04d4291 100644 --- a/src/suou/luck.py +++ b/src/suou/luck.py @@ -44,7 +44,8 @@ def lucky(validators: Iterable[Callable[[_U], bool]] = ()): for v in validators: try: if not v(result): - raise BadLuckError(f'result not expected: {result!r}') + message = 'result not expected' + raise BadLuckError(f'{message}: {result!r}') except BadLuckError: raise except Exception as e: diff --git a/src/suou/validators.py b/src/suou/validators.py index 609b99e..9fd0a3c 100644 --- a/src/suou/validators.py +++ b/src/suou/validators.py @@ -22,7 +22,7 @@ _T = TypeVar('_T') def matches(regex: str | int, /, length: int = 0, *, flags=0): """ - Return a function which returns true if X is shorter than length and matches the given regex. + Return a function which returns True if X is shorter than length and matches the given regex. """ if isinstance(regex, int): length = regex @@ -31,13 +31,23 @@ def matches(regex: str | int, /, length: int = 0, *, flags=0): return (not length or len(s) < length) and bool(re.fullmatch(regex, s, flags=flags)) return validator + def must_be(obj: _T | Any, typ: type[_T] | Iterable[type], message: str, *, exc = TypeError) -> _T: """ Raise TypeError if the requested object is not of the desired type(s), with a nice message. + + (Not properly a validator.) """ if not isinstance(obj, typ): raise TypeError(f'{message}, not {obj.__class__.__name__!r}') return obj -__all__ = ('matches', ) \ No newline at end of file +def not_greater_than(y): + """ + Return a function that returns True if X is not greater than (i.e. lesser than or equal to) the given value. + """ + return lambda x: x <= y + +__all__ = ('matches', 'not_greater_than') + From e7726328d3acb4791a6b106309807e3fb283281b Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 19 Sep 2025 17:22:00 +0200 Subject: [PATCH 079/121] add @future --- CHANGELOG.md | 7 +++--- src/suou/__init__.py | 5 ++-- src/suou/functools.py | 56 +++++++++++++++++++++++++++++-------------- 3 files changed, 45 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e92377..23c6186 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,10 @@ + Add RNG/random selection overloads such as `luck()`, `rng_overload()` + Add 7 new throwable exceptions + Add color utilities: `chalk` module -+ Add `.terminal` module, to ease TUI development. -+ `calendar`: add `parse_time()`. -+ Add validator `not_greater_than()`. ++ Add `.terminal` module, to ease TUI development ++ `calendar`: add `parse_time()` ++ Add validator `not_greater_than()` ++ Add `@future()` decorator ## 0.6.1 diff --git a/src/suou/__init__.py b/src/suou/__init__.py index a33a879..7abb543 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -24,7 +24,7 @@ from .calendar import want_datetime, want_isodate, want_timestamp, age_and_days from .configparse import MissingConfigError, MissingConfigWarning, ConfigOptions, ConfigParserConfigSource, ConfigSource, DictConfigSource, ConfigValue, EnvConfigSource from .collections import TimedDict from .dei import dei_args -from .functools import deprecated, not_implemented, timed_cache, none_pass, alru_cache +from .functools import deprecated, not_implemented, timed_cache, none_pass, alru_cache, future from .classtools import Wanted, Incomplete from .itertools import makelist, kwargs_prefix, ltuple, rtuple, additem, addattr from .i18n import I18n, JsonI18n, TomlI18n @@ -47,7 +47,8 @@ __all__ = ( 'StringCase', 'TimedDict', 'TomlI18n', 'UserSigner', 'Wanted', 'WantsContentType', 'addattr', 'additem', 'age_and_days', 'alru_cache', 'b2048decode', 'b2048encode', 'b32ldecode', 'b32lencode', 'b64encode', 'b64decode', 'cb32encode', - 'cb32decode', 'chalk', 'count_ones', 'dei_args', 'deprecated', 'ilex', 'join_bits', + 'cb32decode', 'chalk', 'count_ones', 'dei_args', 'deprecated', + 'future', 'ilex', 'join_bits', 'jsonencode', 'kwargs_prefix', 'lex', 'ltuple', 'makelist', 'mask_shift', 'matches', 'mod_ceil', 'mod_floor', 'none_pass', 'not_implemented', 'redact_url_password', 'rtuple', 'split_bits', 'ssv_list', 'symbol_table', diff --git a/src/suou/functools.py b/src/suou/functools.py index b702fe7..b841a00 100644 --- a/src/suou/functools.py +++ b/src/suou/functools.py @@ -28,37 +28,43 @@ from suou.itertools import hashed_list _T = TypeVar('_T') _U = TypeVar('_U') + +def _suou_deprecated(message: str, /, *, category=DeprecationWarning, stacklevel: int = 1) -> Callable[[Callable[_T, _U]], Callable[_T, _U]]: + """ + Backport of PEP 702 for Python <=3.12. + The stack_level stuff is used by warnings.warn() btw + """ + def decorator(func: Callable[_T, _U]) -> Callable[_T, _U]: + @wraps(func) + def wrapper(*a, **ka): + if category is not None: + warnings.warn(message, category, stacklevel=stacklevel) + return func(*a, **ka) + func.__deprecated__ = True + wrapper.__deprecated__ = True + return wrapper + return decorator + try: from warnings import deprecated except ImportError: # Python <=3.12 does not implement warnings.deprecated - def deprecated(message: str, /, *, category=DeprecationWarning, stacklevel: int = 1) -> Callable[[Callable[_T, _U]], Callable[_T, _U]]: - """ - Backport of PEP 702 for Python <=3.12. - The stack_level stuff is not reimplemented on purpose because - too obscure for the average programmer. - """ - def decorator(func: Callable[_T, _U]) -> Callable[_T, _U]: - @wraps(func) - def wrapper(*a, **ka): - if category is not None: - warnings.warn(message, category, stacklevel=stacklevel) - return func(*a, **ka) - func.__deprecated__ = True - wrapper.__deprecated__ = True - return wrapper - return decorator + deprecated = _suou_deprecated ## this syntactic sugar for deprecated() is ... deprecated, which is ironic. ## Needed move because VSCode seems to not sense deprecated_alias()es as deprecated. @deprecated('use deprecated(message)(func) instead') -def deprecated_alias(func: Callable, /, message='use .{name}() instead', *, category=DeprecationWarning) -> Callable: +def deprecated_alias(func: Callable[_T, _U], /, message='use .{name}() instead', *, category=DeprecationWarning) -> Callable[_T, _U]: """ Syntactic sugar helper for renaming functions. DEPRECATED use deprecated(message)(func) instead """ - return deprecated(message.format(name=func.__name__), category=category)(func) + @deprecated(message.format(name=func.__name__), category=category) + @wraps(func) + def deprecated_wrapper(*a, **k) -> _U: + return func(*a, **k) + return deprecated_wrapper def not_implemented(msg: Callable | str | None = None): """ @@ -74,6 +80,20 @@ def not_implemented(msg: Callable | str | None = None): return decorator(msg) return decorator +def future(message: str | None = None): + """ + Describes experimental or future API's introduced as bug fixes (including as backports) + but not yet intended for general use (mostly to keep semver consistent). + + NEW 0.7.0 + """ + def decorator(func: Callable[_T, _U]) -> Callable[_T, _U]: + @wraps(func) + def wrapper(*a, **k) -> _U: + warnings.warn(message or f'{func.__name__}() is intended for a future release and not intended for use right now', FutureWarning) + return func(*a, **k) + return wrapper + return decorator def flat_args(args: Iterable, kwds: Mapping, typed, kwd_mark = (object(),), From 17cab8e257a51a72973388342ea1fc285de4edd8 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 19 Sep 2025 19:02:46 +0200 Subject: [PATCH 080/121] update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23c6186..326455a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ## 0.6.1 - First release on PyPI under the name `suou`. + - **BREAKING**: if you installed `sakuragasaki46-suou<=0.6.0` you need to uninstall and reinstall or things may break. - Fix `sqlalchemy.asyncio.SQLAlchemy()` to use context vars; `expire_on_commit=` is now configurable at instantiation. Fix some missing re-exports. ## 0.6.0 From 25697ee9589df1636e1cd8e43def9312483764a4 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Tue, 23 Sep 2025 12:52:11 +0200 Subject: [PATCH 081/121] add not_lesser_than(), WebColor(), annotations and documentation changes --- CHANGELOG.md | 6 ++--- README.md | 2 +- src/suou/__init__.py | 2 +- src/suou/color.py | 55 +++++++++++++++++++++++++++++++++++++++++- src/suou/luck.py | 11 +++++---- src/suou/validators.py | 6 +++++ 6 files changed, 71 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 326455a..fe54664 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,11 @@ + Add RNG/random selection overloads such as `luck()`, `rng_overload()` + Add 7 new throwable exceptions -+ Add color utilities: `chalk` module ++ Add color utilities: `chalk` object and `WebColor()` + Add `.terminal` module, to ease TUI development + `calendar`: add `parse_time()` -+ Add validator `not_greater_than()` -+ Add `@future()` decorator ++ Add validators `not_greater_than()`, `not_less_than()` ++ Add `@future()` decorator: it signals features not yet intended to be public, for instance, backported as a part of a bug fix. ## 0.6.1 diff --git a/README.md b/README.md index 56da550..8f931dc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # SIS Unified Object Underarmor -Good morning, my brother! Welcome **SUOU** (**S**IS **U**nified **O**bject **U**nderarmor), the Python library which makes API development faster for developing API's, database schemas and stuff in Python. +Good morning, my brother! Welcome **SUOU** (**S**IS **U**nified **O**bject **U**nderarmor), the Python library which speeds up and makes it pleasing to develop API, database schemas and stuff in Python. It provides utilities such as: * [SIQ](https://yusur.moe/protocols/siq.html) diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 7abb543..171d4e5 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -37,7 +37,7 @@ from .redact import redact_url_password from .http import WantsContentType from .color import chalk -__version__ = "0.7.0-dev37" +__version__ = "0.7.0-dev38" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', diff --git a/src/suou/color.py b/src/suou/color.py index 31ea031..07241ba 100644 --- a/src/suou/color.py +++ b/src/suou/color.py @@ -17,7 +17,9 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. """ +from __future__ import annotations +from collections import namedtuple from functools import lru_cache @@ -75,5 +77,56 @@ class Chalk: return self._wrap(self.FAINT, self.END_BOLD) -## TODO make it lazy? +## TODO make it lazy / an instance variable? chalk = Chalk() + + +## Utilities for web colors + +class WebColor(namedtuple('_WebColor', 'red green blue')): + """ + Representation of a color in the TrueColor space (aka rgb). + + Useful for theming. + """ + def lighten(self, *, factor = .75): + """ + Return a whitened shade of the color. + Factor stands between 0 and 1: 0 = total white, 1 = no change. Default is .75 + """ + return WebColor( + 255 - int((255 - self.red) * factor), + 255 - int((255 - self.green) * factor), + 255 - int((255 - self.blue) * factor), + ) + def darken(self, *, factor = .75): + """ + Return a darkened shade of the color. + Factor stands between 0 and 1: 0 = total black, 1 = no change. Default is .75 + """ + return WebColor( + int(self.red * factor), + int(self.green * factor), + int(self.blue * factor) + ) + def greyen(self, *, factor = .75): + """ + Return a desaturated shade of the color. + Factor stands between 0 and 1: 0 = gray, 1 = no change. Default is .75 + """ + return self.darken(factor=factor) + self.lighten(factor=factor) + + def blend_with(self, other: WebColor): + """ + Mix two colors, returning the average. + """ + return WebColor ( + (self.red + other.red) // 2, + (self.green + other.green) // 2, + (self.blue + other.blue) // 2 + ) + + __add__ = blend_with + + def __str__(self): + return f"rgb({self.red}, {self.green}, {self.blue})" diff --git a/src/suou/luck.py b/src/suou/luck.py index 04d4291..669025c 100644 --- a/src/suou/luck.py +++ b/src/suou/luck.py @@ -16,6 +16,7 @@ This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. """ +from functools import wraps from typing import Callable, Generic, Iterable, TypeVar import random from suou.exceptions import BadLuckError @@ -34,7 +35,7 @@ def lucky(validators: Iterable[Callable[[_U], bool]] = ()): NEW 0.7.0 """ - def decorator(func: Callable[_T, _U]): + def decorator(func: Callable[..., _U]): @wraps(func) def wrapper(*args, **kwargs) -> _U: try: @@ -56,7 +57,7 @@ def lucky(validators: Iterable[Callable[[_U], bool]] = ()): class RngCallable(Callable, Generic[_T, _U]): """ - Overloaded ... randomly chosen callable. + Overloaded ...randomly chosen callable. UNTESTED @@ -86,13 +87,13 @@ class RngCallable(Callable, Generic[_T, _U]): choice -= w -def rng_overload(prev_func: RngCallable[_T, _U] | int | None, /, *, weight: int = 1) -> RngCallable[_T, _U]: +def rng_overload(prev_func: RngCallable[..., _U] | int | None, /, *, weight: int = 1) -> RngCallable[..., _U]: """ Decorate the first function with @rng_overload and the weight= parameter (default 1, must be an integer) to create a "RNG" overloaded callable. - Each call chooses randomly one candidate (weight is taken in consideration) - , calls it, and returns the result. + Each call chooses randomly one candidate (weight is taken in consideration), + calls it, and returns the result. UNTESTED diff --git a/src/suou/validators.py b/src/suou/validators.py index 9fd0a3c..53a7be3 100644 --- a/src/suou/validators.py +++ b/src/suou/validators.py @@ -49,5 +49,11 @@ def not_greater_than(y): """ return lambda x: x <= y +def not_less_than(y): + """ + Return a function that returns True if X is not less than (i.e. greater than or equal to) the given value. + """ + return lambda x: x >= y + __all__ = ('matches', 'not_greater_than') From 9c0e8897500e3b118b64f1430dce53db138920d6 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Tue, 30 Sep 2025 20:09:21 +0200 Subject: [PATCH 082/121] version advance --- src/suou/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 171d4e5..5715b14 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -37,7 +37,7 @@ from .redact import redact_url_password from .http import WantsContentType from .color import chalk -__version__ = "0.7.0-dev38" +__version__ = "0.7.0-dev39" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', From 646ac2e1bf10f73b8c81a2c08b5572be8271c807 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Tue, 30 Sep 2025 20:34:38 +0200 Subject: [PATCH 083/121] 0.7.0 "The Lucky Update" --- .gitignore | 1 + aliases/sakuragasaki46_suou/pyproject.toml | 76 +++++++++++++++++++++- src/suou/__init__.py | 2 +- 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 7201aa6..31cd9f7 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ dist/ .vscode /run.sh ROADMAP.md +aliases/*/src diff --git a/aliases/sakuragasaki46_suou/pyproject.toml b/aliases/sakuragasaki46_suou/pyproject.toml index 035fdb5..668a46b 100644 --- a/aliases/sakuragasaki46_suou/pyproject.toml +++ b/aliases/sakuragasaki46_suou/pyproject.toml @@ -1,8 +1,78 @@ [project] name = "sakuragasaki46_suou" -authors = [ { name = "Sakuragasaki46" } ] -version = "0.6.1" +description = "casual utility library for coding QoL" +authors = [ + { name = "Sakuragasaki46" } +] +dynamic = [ "version" ] requires-python = ">=3.10" -dependencies = [ "suou==0.6.1" ] +license = "Apache-2.0" readme = "README.md" +dependencies = [ + "suou==0.7.0", + "itsdangerous", + "toml", + "pydantic", + "setuptools>=78.0.0", + "uvloop; os_name=='posix'" +] +# - further devdependencies below - # + +# - publishing - +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + + # actively supported Pythons + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13" +] + +[project.urls] +Repository = "https://nekode.yusur.moe/yusur/suou" + +[project.optional-dependencies] +# the below are all dev dependencies (and probably already installed) +sqlalchemy = [ + "SQLAlchemy[asyncio]>=2.0.0" +] +flask = [ + "Flask>=2.0.0", + "Flask-RestX" +] +flask_sqlalchemy = [ + "Flask-SqlAlchemy", +] +peewee = [ + ## HEADS UP! peewee has setup.py, may slow down installation + "peewee>=3.0.0" +] +markdown = [ + "markdown>=3.0.0" +] +quart = [ + "Quart", + "Quart-Schema", + "starlette>=0.47.2" +] +sass = [ + ## HEADS UP!! libsass carries a C extension + uses setup.py + "libsass" +] + +full = [ + "sakuragasaki46_suou[sqlalchemy]", + "sakuragasaki46_suou[flask]", + "sakuragasaki46_suou[quart]", + "sakuragasaki46_suou[peewee]", + "sakuragasaki46_suou[markdown]", + "sakuragasaki46_suou[flask-sqlalchemy]", + "sakuragasaki46_suou[sass]" +] + + +[tool.setuptools.dynamic] +version = { attr = "suou.__version__" } diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 5715b14..dd48e57 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -37,7 +37,7 @@ from .redact import redact_url_password from .http import WantsContentType from .color import chalk -__version__ = "0.7.0-dev39" +__version__ = "0.7.0" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', From efb5ab1a5bacb0a9d69a0e7b7d665ca83446ab26 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 10 Oct 2025 19:52:48 +0200 Subject: [PATCH 084/121] add docs --- .gitignore | 3 ++ .readthedocs.yaml | 10 ++++++ docs/Makefile | 20 ++++++++++++ docs/api.rst | 41 ++++++++++++++++++++++++ docs/conf.py | 30 +++++++++++++++++ docs/generated/suou.asgi.rst | 6 ++++ docs/generated/suou.bits.rst | 17 ++++++++++ docs/generated/suou.calendar.rst | 16 +++++++++ docs/generated/suou.classtools.rst | 16 +++++++++ docs/generated/suou.codecs.rst | 36 +++++++++++++++++++++ docs/generated/suou.collections.rst | 12 +++++++ docs/generated/suou.color.rst | 13 ++++++++ docs/generated/suou.configparse.rst | 18 +++++++++++ docs/generated/suou.dei.rst | 24 ++++++++++++++ docs/generated/suou.dorks.rst | 6 ++++ docs/generated/suou.exceptions.rst | 24 ++++++++++++++ docs/generated/suou.flask.rst | 16 +++++++++ docs/generated/suou.flask_restx.rst | 18 +++++++++++ docs/generated/suou.flask_sqlalchemy.rst | 18 +++++++++++ docs/generated/suou.functools.rst | 18 +++++++++++ docs/generated/suou.http.rst | 12 +++++++ docs/generated/suou.i18n.rst | 16 +++++++++ docs/generated/suou.iding.rst | 22 +++++++++++++ docs/generated/suou.itertools.rst | 23 +++++++++++++ docs/generated/suou.legal.rst | 6 ++++ docs/generated/suou.lex.rst | 6 ++++ docs/generated/suou.luck.rst | 19 +++++++++++ docs/generated/suou.markdown.rst | 16 +++++++++ docs/generated/suou.migrate.rst | 14 ++++++++ docs/generated/suou.quart.rst | 14 ++++++++ docs/generated/suou.redact.rst | 12 +++++++ docs/generated/suou.rst | 6 ++++ docs/generated/suou.sass.rst | 12 +++++++ docs/generated/suou.signing.rst | 13 ++++++++ docs/generated/suou.snowflake.rst | 13 ++++++++ docs/generated/suou.sqlalchemy.rst | 20 ++++++++++++ docs/generated/suou.strtools.rst | 12 +++++++ docs/generated/suou.terminal.rst | 12 +++++++ docs/generated/suou.validators.rst | 15 +++++++++ docs/generated/suou.waiter.rst | 19 +++++++++++ docs/index.rst | 18 +++++++++++ docs/make.bat | 35 ++++++++++++++++++++ src/suou/__init__.py | 2 +- 43 files changed, 698 insertions(+), 1 deletion(-) create mode 100644 .readthedocs.yaml create mode 100644 docs/Makefile create mode 100644 docs/api.rst create mode 100644 docs/conf.py create mode 100644 docs/generated/suou.asgi.rst create mode 100644 docs/generated/suou.bits.rst create mode 100644 docs/generated/suou.calendar.rst create mode 100644 docs/generated/suou.classtools.rst create mode 100644 docs/generated/suou.codecs.rst create mode 100644 docs/generated/suou.collections.rst create mode 100644 docs/generated/suou.color.rst create mode 100644 docs/generated/suou.configparse.rst create mode 100644 docs/generated/suou.dei.rst create mode 100644 docs/generated/suou.dorks.rst create mode 100644 docs/generated/suou.exceptions.rst create mode 100644 docs/generated/suou.flask.rst create mode 100644 docs/generated/suou.flask_restx.rst create mode 100644 docs/generated/suou.flask_sqlalchemy.rst create mode 100644 docs/generated/suou.functools.rst create mode 100644 docs/generated/suou.http.rst create mode 100644 docs/generated/suou.i18n.rst create mode 100644 docs/generated/suou.iding.rst create mode 100644 docs/generated/suou.itertools.rst create mode 100644 docs/generated/suou.legal.rst create mode 100644 docs/generated/suou.lex.rst create mode 100644 docs/generated/suou.luck.rst create mode 100644 docs/generated/suou.markdown.rst create mode 100644 docs/generated/suou.migrate.rst create mode 100644 docs/generated/suou.quart.rst create mode 100644 docs/generated/suou.redact.rst create mode 100644 docs/generated/suou.rst create mode 100644 docs/generated/suou.sass.rst create mode 100644 docs/generated/suou.signing.rst create mode 100644 docs/generated/suou.snowflake.rst create mode 100644 docs/generated/suou.sqlalchemy.rst create mode 100644 docs/generated/suou.strtools.rst create mode 100644 docs/generated/suou.terminal.rst create mode 100644 docs/generated/suou.validators.rst create mode 100644 docs/generated/suou.waiter.rst create mode 100644 docs/index.rst create mode 100644 docs/make.bat diff --git a/.gitignore b/.gitignore index 31cd9f7..5736b26 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ dist/ /run.sh ROADMAP.md aliases/*/src +docs/_build +docs/_static +docs/templates \ No newline at end of file diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..3403857 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,10 @@ +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: "3.13" + +sphinx: + configuration: docs/conf.py + diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d4bb2cb --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 0000000..1a515aa --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,41 @@ +API +=== + +.. autosummary:: + :toctree: generated + + suou.sqlalchemy + suou.asgi + suou.bits + suou.calendar + suou.classtools + suou.codecs + suou.collections + suou.color + suou.configparse + suou.dei + suou.dorks + suou.exceptions + suou.flask_restx + suou.flask_sqlalchemy + suou.flask + suou.functools + suou.http + suou.i18n + suou.iding + suou.itertools + suou.legal + suou.lex + suou.luck + suou.markdown + suou.migrate + suou.peewee + suou.quart + suou.redact + suou.sass + suou.signing + suou.snowflake + suou.strtools + suou.terminal + suou.validators + suou.waiter \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..102befd --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,30 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path("..", "src").resolve())) + +project = 'suou' +copyright = '2025 Sakuragasaki46' +author = 'Sakuragasaki46' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = ["sphinx.ext.autodoc", 'sphinx.ext.autosummary', 'myst_parser'] + +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = 'alabaster' +html_static_path = ['_static'] diff --git a/docs/generated/suou.asgi.rst b/docs/generated/suou.asgi.rst new file mode 100644 index 0000000..00c5839 --- /dev/null +++ b/docs/generated/suou.asgi.rst @@ -0,0 +1,6 @@ +suou.asgi +========= + +.. automodule:: suou.asgi + + \ No newline at end of file diff --git a/docs/generated/suou.bits.rst b/docs/generated/suou.bits.rst new file mode 100644 index 0000000..d8b4a3e --- /dev/null +++ b/docs/generated/suou.bits.rst @@ -0,0 +1,17 @@ +suou.bits +========= + +.. automodule:: suou.bits + + + .. rubric:: Functions + + .. autosummary:: + + count_ones + join_bits + mask_shift + mod_ceil + mod_floor + split_bits + \ No newline at end of file diff --git a/docs/generated/suou.calendar.rst b/docs/generated/suou.calendar.rst new file mode 100644 index 0000000..510743d --- /dev/null +++ b/docs/generated/suou.calendar.rst @@ -0,0 +1,16 @@ +suou.calendar +============= + +.. automodule:: suou.calendar + + + .. rubric:: Functions + + .. autosummary:: + + age_and_days + parse_time + want_datetime + want_isodate + want_timestamp + \ No newline at end of file diff --git a/docs/generated/suou.classtools.rst b/docs/generated/suou.classtools.rst new file mode 100644 index 0000000..db0a16a --- /dev/null +++ b/docs/generated/suou.classtools.rst @@ -0,0 +1,16 @@ +suou.classtools +=============== + +.. automodule:: suou.classtools + + + .. rubric:: Classes + + .. autosummary:: + + Incomplete + MissingType + ValueProperty + ValueSource + Wanted + \ No newline at end of file diff --git a/docs/generated/suou.codecs.rst b/docs/generated/suou.codecs.rst new file mode 100644 index 0000000..bfd0260 --- /dev/null +++ b/docs/generated/suou.codecs.rst @@ -0,0 +1,36 @@ +suou.codecs +=========== + +.. automodule:: suou.codecs + + + .. rubric:: Functions + + .. autosummary:: + + b2048decode + b2048encode + b32ldecode + b32lencode + b64decode + b64encode + cb32decode + cb32encode + jsonencode + quote_css_string + rb64decode + rb64encode + ssv_list + twocolon_list + want_bytes + want_str + want_urlsafe + want_urlsafe_bytes + z85encode + + .. rubric:: Classes + + .. autosummary:: + + StringCase + \ No newline at end of file diff --git a/docs/generated/suou.collections.rst b/docs/generated/suou.collections.rst new file mode 100644 index 0000000..57899fd --- /dev/null +++ b/docs/generated/suou.collections.rst @@ -0,0 +1,12 @@ +suou.collections +================ + +.. automodule:: suou.collections + + + .. rubric:: Classes + + .. autosummary:: + + TimedDict + \ No newline at end of file diff --git a/docs/generated/suou.color.rst b/docs/generated/suou.color.rst new file mode 100644 index 0000000..b87aaa4 --- /dev/null +++ b/docs/generated/suou.color.rst @@ -0,0 +1,13 @@ +suou.color +========== + +.. automodule:: suou.color + + + .. rubric:: Classes + + .. autosummary:: + + Chalk + WebColor + \ No newline at end of file diff --git a/docs/generated/suou.configparse.rst b/docs/generated/suou.configparse.rst new file mode 100644 index 0000000..ea6c6ad --- /dev/null +++ b/docs/generated/suou.configparse.rst @@ -0,0 +1,18 @@ +suou.configparse +================ + +.. automodule:: suou.configparse + + + .. rubric:: Classes + + .. autosummary:: + + ArgConfigSource + ConfigOptions + ConfigParserConfigSource + ConfigSource + ConfigValue + DictConfigSource + EnvConfigSource + \ No newline at end of file diff --git a/docs/generated/suou.dei.rst b/docs/generated/suou.dei.rst new file mode 100644 index 0000000..5604f14 --- /dev/null +++ b/docs/generated/suou.dei.rst @@ -0,0 +1,24 @@ +suou.dei +======== + +.. automodule:: suou.dei + + + .. rubric:: Module Attributes + + .. autosummary:: + + BRICKS + + .. rubric:: Functions + + .. autosummary:: + + dei_args + + .. rubric:: Classes + + .. autosummary:: + + Pronoun + \ No newline at end of file diff --git a/docs/generated/suou.dorks.rst b/docs/generated/suou.dorks.rst new file mode 100644 index 0000000..ec092e7 --- /dev/null +++ b/docs/generated/suou.dorks.rst @@ -0,0 +1,6 @@ +suou.dorks +========== + +.. automodule:: suou.dorks + + \ No newline at end of file diff --git a/docs/generated/suou.exceptions.rst b/docs/generated/suou.exceptions.rst new file mode 100644 index 0000000..8f42c2d --- /dev/null +++ b/docs/generated/suou.exceptions.rst @@ -0,0 +1,24 @@ +suou.exceptions +=============== + +.. automodule:: suou.exceptions + + + .. rubric:: Exceptions + + .. autosummary:: + + BabelTowerError + BadLuckError + BrokenStringsError + Fahrenheit451Error + FuckAroundFindOutError + InconsistencyError + LexError + MissingConfigError + MissingConfigWarning + NotFoundError + PoliticalError + PoliticalWarning + TerminalRequiredError + \ No newline at end of file diff --git a/docs/generated/suou.flask.rst b/docs/generated/suou.flask.rst new file mode 100644 index 0000000..42193eb --- /dev/null +++ b/docs/generated/suou.flask.rst @@ -0,0 +1,16 @@ +suou.flask +========== + +.. automodule:: suou.flask + + + .. rubric:: Functions + + .. autosummary:: + + add_context_from_config + add_i18n + get_flask_conf + harden + negotiate + \ No newline at end of file diff --git a/docs/generated/suou.flask_restx.rst b/docs/generated/suou.flask_restx.rst new file mode 100644 index 0000000..e02d399 --- /dev/null +++ b/docs/generated/suou.flask_restx.rst @@ -0,0 +1,18 @@ +suou.flask\_restx +================= + +.. automodule:: suou.flask_restx + + + .. rubric:: Functions + + .. autosummary:: + + output_json + + .. rubric:: Classes + + .. autosummary:: + + Api + \ No newline at end of file diff --git a/docs/generated/suou.flask_sqlalchemy.rst b/docs/generated/suou.flask_sqlalchemy.rst new file mode 100644 index 0000000..e89c0bf --- /dev/null +++ b/docs/generated/suou.flask_sqlalchemy.rst @@ -0,0 +1,18 @@ +suou.flask\_sqlalchemy +====================== + +.. automodule:: suou.flask_sqlalchemy + + + .. rubric:: Functions + + .. autosummary:: + + require_auth + + .. rubric:: Classes + + .. autosummary:: + + FlaskAuthSrc + \ No newline at end of file diff --git a/docs/generated/suou.functools.rst b/docs/generated/suou.functools.rst new file mode 100644 index 0000000..6035dff --- /dev/null +++ b/docs/generated/suou.functools.rst @@ -0,0 +1,18 @@ +suou.functools +============== + +.. automodule:: suou.functools + + + .. rubric:: Functions + + .. autosummary:: + + alru_cache + deprecated_alias + flat_args + future + none_pass + not_implemented + timed_cache + \ No newline at end of file diff --git a/docs/generated/suou.http.rst b/docs/generated/suou.http.rst new file mode 100644 index 0000000..82c9332 --- /dev/null +++ b/docs/generated/suou.http.rst @@ -0,0 +1,12 @@ +suou.http +========= + +.. automodule:: suou.http + + + .. rubric:: Classes + + .. autosummary:: + + WantsContentType + \ No newline at end of file diff --git a/docs/generated/suou.i18n.rst b/docs/generated/suou.i18n.rst new file mode 100644 index 0000000..7f92f78 --- /dev/null +++ b/docs/generated/suou.i18n.rst @@ -0,0 +1,16 @@ +suou.i18n +========= + +.. automodule:: suou.i18n + + + .. rubric:: Classes + + .. autosummary:: + + I18n + I18nLang + IdentityLang + JsonI18n + TomlI18n + \ No newline at end of file diff --git a/docs/generated/suou.iding.rst b/docs/generated/suou.iding.rst new file mode 100644 index 0000000..a47d428 --- /dev/null +++ b/docs/generated/suou.iding.rst @@ -0,0 +1,22 @@ +suou.iding +========== + +.. automodule:: suou.iding + + + .. rubric:: Functions + + .. autosummary:: + + make_domain_hash + + .. rubric:: Classes + + .. autosummary:: + + Siq + SiqCache + SiqFormatType + SiqGen + SiqType + \ No newline at end of file diff --git a/docs/generated/suou.itertools.rst b/docs/generated/suou.itertools.rst new file mode 100644 index 0000000..3ee27ab --- /dev/null +++ b/docs/generated/suou.itertools.rst @@ -0,0 +1,23 @@ +suou.itertools +============== + +.. automodule:: suou.itertools + + + .. rubric:: Functions + + .. autosummary:: + + addattr + additem + kwargs_prefix + ltuple + makelist + rtuple + + .. rubric:: Classes + + .. autosummary:: + + hashed_list + \ No newline at end of file diff --git a/docs/generated/suou.legal.rst b/docs/generated/suou.legal.rst new file mode 100644 index 0000000..e119f97 --- /dev/null +++ b/docs/generated/suou.legal.rst @@ -0,0 +1,6 @@ +suou.legal +========== + +.. automodule:: suou.legal + + \ No newline at end of file diff --git a/docs/generated/suou.lex.rst b/docs/generated/suou.lex.rst new file mode 100644 index 0000000..6a49b80 --- /dev/null +++ b/docs/generated/suou.lex.rst @@ -0,0 +1,6 @@ +suou.lex +======== + +.. currentmodule:: suou + +.. autofunction:: lex \ No newline at end of file diff --git a/docs/generated/suou.luck.rst b/docs/generated/suou.luck.rst new file mode 100644 index 0000000..9480188 --- /dev/null +++ b/docs/generated/suou.luck.rst @@ -0,0 +1,19 @@ +suou.luck +========= + +.. automodule:: suou.luck + + + .. rubric:: Functions + + .. autosummary:: + + lucky + rng_overload + + .. rubric:: Classes + + .. autosummary:: + + RngCallable + \ No newline at end of file diff --git a/docs/generated/suou.markdown.rst b/docs/generated/suou.markdown.rst new file mode 100644 index 0000000..5476d20 --- /dev/null +++ b/docs/generated/suou.markdown.rst @@ -0,0 +1,16 @@ +suou.markdown +============= + +.. automodule:: suou.markdown + + + .. rubric:: Classes + + .. autosummary:: + + MentionPattern + PingExtension + SpoilerExtension + StrikethroughExtension + StrikethroughPostprocessor + \ No newline at end of file diff --git a/docs/generated/suou.migrate.rst b/docs/generated/suou.migrate.rst new file mode 100644 index 0000000..2b35157 --- /dev/null +++ b/docs/generated/suou.migrate.rst @@ -0,0 +1,14 @@ +suou.migrate +============ + +.. automodule:: suou.migrate + + + .. rubric:: Classes + + .. autosummary:: + + SiqMigrator + SnowflakeSiqMigrator + UlidSiqMigrator + \ No newline at end of file diff --git a/docs/generated/suou.quart.rst b/docs/generated/suou.quart.rst new file mode 100644 index 0000000..9be2817 --- /dev/null +++ b/docs/generated/suou.quart.rst @@ -0,0 +1,14 @@ +suou.quart +========== + +.. automodule:: suou.quart + + + .. rubric:: Functions + + .. autosummary:: + + add_i18n + add_rest + negotiate + \ No newline at end of file diff --git a/docs/generated/suou.redact.rst b/docs/generated/suou.redact.rst new file mode 100644 index 0000000..b5208ec --- /dev/null +++ b/docs/generated/suou.redact.rst @@ -0,0 +1,12 @@ +suou.redact +=========== + +.. automodule:: suou.redact + + + .. rubric:: Functions + + .. autosummary:: + + redact_url_password + \ No newline at end of file diff --git a/docs/generated/suou.rst b/docs/generated/suou.rst new file mode 100644 index 0000000..5533886 --- /dev/null +++ b/docs/generated/suou.rst @@ -0,0 +1,6 @@ +suou +==== + +.. automodule:: suou + + \ No newline at end of file diff --git a/docs/generated/suou.sass.rst b/docs/generated/suou.sass.rst new file mode 100644 index 0000000..6ad4426 --- /dev/null +++ b/docs/generated/suou.sass.rst @@ -0,0 +1,12 @@ +suou.sass +========= + +.. automodule:: suou.sass + + + .. rubric:: Classes + + .. autosummary:: + + SassAsyncMiddleware + \ No newline at end of file diff --git a/docs/generated/suou.signing.rst b/docs/generated/suou.signing.rst new file mode 100644 index 0000000..b57968d --- /dev/null +++ b/docs/generated/suou.signing.rst @@ -0,0 +1,13 @@ +suou.signing +============ + +.. automodule:: suou.signing + + + .. rubric:: Classes + + .. autosummary:: + + HasSigner + UserSigner + \ No newline at end of file diff --git a/docs/generated/suou.snowflake.rst b/docs/generated/suou.snowflake.rst new file mode 100644 index 0000000..fa072d3 --- /dev/null +++ b/docs/generated/suou.snowflake.rst @@ -0,0 +1,13 @@ +suou.snowflake +============== + +.. automodule:: suou.snowflake + + + .. rubric:: Classes + + .. autosummary:: + + Snowflake + SnowflakeGen + \ No newline at end of file diff --git a/docs/generated/suou.sqlalchemy.rst b/docs/generated/suou.sqlalchemy.rst new file mode 100644 index 0000000..118bf3a --- /dev/null +++ b/docs/generated/suou.sqlalchemy.rst @@ -0,0 +1,20 @@ +suou.sqlalchemy +=============== + +.. automodule:: suou.sqlalchemy + + + .. rubric:: Functions + + .. autosummary:: + + create_session + require_auth_base + token_signer + + .. rubric:: Classes + + .. autosummary:: + + AuthSrc + \ No newline at end of file diff --git a/docs/generated/suou.strtools.rst b/docs/generated/suou.strtools.rst new file mode 100644 index 0000000..c9df5c0 --- /dev/null +++ b/docs/generated/suou.strtools.rst @@ -0,0 +1,12 @@ +suou.strtools +============= + +.. automodule:: suou.strtools + + + .. rubric:: Classes + + .. autosummary:: + + PrefixIdentifier + \ No newline at end of file diff --git a/docs/generated/suou.terminal.rst b/docs/generated/suou.terminal.rst new file mode 100644 index 0000000..71d531f --- /dev/null +++ b/docs/generated/suou.terminal.rst @@ -0,0 +1,12 @@ +suou.terminal +============= + +.. automodule:: suou.terminal + + + .. rubric:: Functions + + .. autosummary:: + + terminal_required + \ No newline at end of file diff --git a/docs/generated/suou.validators.rst b/docs/generated/suou.validators.rst new file mode 100644 index 0000000..673e889 --- /dev/null +++ b/docs/generated/suou.validators.rst @@ -0,0 +1,15 @@ +suou.validators +=============== + +.. automodule:: suou.validators + + + .. rubric:: Functions + + .. autosummary:: + + matches + must_be + not_greater_than + not_less_than + \ No newline at end of file diff --git a/docs/generated/suou.waiter.rst b/docs/generated/suou.waiter.rst new file mode 100644 index 0000000..c668f25 --- /dev/null +++ b/docs/generated/suou.waiter.rst @@ -0,0 +1,19 @@ +suou.waiter +=========== + +.. automodule:: suou.waiter + + + .. rubric:: Functions + + .. autosummary:: + + ko + ok + + .. rubric:: Classes + + .. autosummary:: + + Waiter + \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..3bc5bd4 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,18 @@ +.. suou documentation master file, created by + sphinx-quickstart on Fri Oct 10 19:24:23 2025. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +suou documentation +================== + +SUOU (acronym for ) is a casual Python library providing utilities to +ease programmer's QoL. + + + + +.. toctree:: + :maxdepth: 2 + + api \ No newline at end of file diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..32bb245 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/src/suou/__init__.py b/src/suou/__init__.py index dd48e57..66f3fcd 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -37,7 +37,7 @@ from .redact import redact_url_password from .http import WantsContentType from .color import chalk -__version__ = "0.7.0" +__version__ = "0.7.1-dev40" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', From 3151948dd09fa9612a92c15f33f4a060fcfd2e44 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 10 Oct 2025 20:05:17 +0200 Subject: [PATCH 085/121] add requirements.txt (provisional) to make build succeed --- pyproject.toml | 5 +++++ requirements.txt | 12 ++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 requirements.txt diff --git a/pyproject.toml b/pyproject.toml index 984ae9b..4cbf39a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,11 @@ full = [ "suou[sass]" ] +docs = [ + "sphinx>=2.1", + "myst_parser" +] + [tool.setuptools.dynamic] version = { attr = "suou.__version__" } diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..01ed1f0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +# for sphinx/readthedocs + +itsdangerous +libsass +peewee +pydantic +quart_schema +setuptools +starlette +toml +sphinx +myst_parser \ No newline at end of file From 84af601a6ff745b17f80bd10d74fb4ccb86a8f0d Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 10 Oct 2025 20:08:47 +0200 Subject: [PATCH 086/121] remove myst_parser --- docs/conf.py | 2 +- requirements.txt | 12 ------------ 2 files changed, 1 insertion(+), 13 deletions(-) delete mode 100644 requirements.txt diff --git a/docs/conf.py b/docs/conf.py index 102befd..8afdfac 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,7 +18,7 @@ author = 'Sakuragasaki46' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -extensions = ["sphinx.ext.autodoc", 'sphinx.ext.autosummary', 'myst_parser'] +extensions = ["sphinx.ext.autodoc", 'sphinx.ext.autosummary'] templates_path = ['_templates'] exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 01ed1f0..0000000 --- a/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -# for sphinx/readthedocs - -itsdangerous -libsass -peewee -pydantic -quart_schema -setuptools -starlette -toml -sphinx -myst_parser \ No newline at end of file From d48767c6032000b10b37017b7a74e640363acb10 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 10 Oct 2025 20:12:12 +0200 Subject: [PATCH 087/121] readd requirements.txt --- requirements.txt | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..33fed21 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +itsdangerous==2.2.0 +libsass==0.23.0 +peewee==3.18.1 +pydantic==2.12.0 +quart_schema==0.22.0 +setuptools==80.9.0 +starlette==0.48.0 +toml==0.10.2 From 47ac53ea9b28bac7efa4c0849e17f625e9c659e8 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 10 Oct 2025 20:17:09 +0200 Subject: [PATCH 088/121] try to fix doc building --- docs/conf.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 8afdfac..0a18d0f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,6 +23,14 @@ extensions = ["sphinx.ext.autodoc", 'sphinx.ext.autosummary'] templates_path = ['_templates'] exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +autodoc_mock_imports = [ + "toml", + "starlette", + "itsdangerous", + "pydantic", + "quart_schema" +] + # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output From 484e46b2f90229e960a63467fa2e483d65af3843 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 10 Oct 2025 20:20:23 +0200 Subject: [PATCH 089/121] change to rtd theme --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 0a18d0f..3562850 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -34,5 +34,5 @@ autodoc_mock_imports = [ # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -html_theme = 'alabaster' +html_theme = 'sphinx_rtd_theme' html_static_path = ['_static'] From 18f6a78524e8e913c55d8d4f72d4e1b4f9616dce Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 10 Oct 2025 20:22:09 +0200 Subject: [PATCH 090/121] fix import --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 33fed21..1efa301 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ quart_schema==0.22.0 setuptools==80.9.0 starlette==0.48.0 toml==0.10.2 +sphinx_rtd_theme From 1b03f3b2e9080cf3b11757e912d6194cd8defcd4 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 10 Oct 2025 20:24:38 +0200 Subject: [PATCH 091/121] again fix --- docs/conf.py | 1 + pyproject.toml | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 3562850..2cdc8dd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -35,4 +35,5 @@ autodoc_mock_imports = [ # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output html_theme = 'sphinx_rtd_theme' +html_theme_path = ["_themes", ] html_static_path = ['_static'] diff --git a/pyproject.toml b/pyproject.toml index 4cbf39a..d81123b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,8 @@ full = [ docs = [ "sphinx>=2.1", - "myst_parser" + "myst_parser", + "sphinx_rtd_theme" ] From 21021875c89c8061966184be9a71a8467a14a4e2 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 10 Oct 2025 20:29:05 +0200 Subject: [PATCH 092/121] fix again --- docs/conf.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 2cdc8dd..a071892 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,7 +18,11 @@ author = 'Sakuragasaki46' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -extensions = ["sphinx.ext.autodoc", 'sphinx.ext.autosummary'] +extensions = [ + "sphinx.ext.autodoc", + 'sphinx.ext.autosummary', + 'sphinx_rtd_theme' +] templates_path = ['_templates'] exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] From fca91bdc54c38f9d4e1244b19fcdd638cd48e56e Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sat, 11 Oct 2025 10:22:49 +0200 Subject: [PATCH 093/121] improve decorator typing --- src/suou/dei.py | 7 +++++-- src/suou/functools.py | 8 ++++---- src/suou/luck.py | 4 ++-- src/suou/sqlalchemy/__init__.py | 20 +++++++++++--------- src/suou/sqlalchemy/asyncio.py | 7 +++++-- 5 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/suou/dei.py b/src/suou/dei.py index 0f7a7a0..d114289 100644 --- a/src/suou/dei.py +++ b/src/suou/dei.py @@ -19,7 +19,10 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. from __future__ import annotations from functools import wraps -from typing import Callable +from typing import Callable, TypeVar + +_T = TypeVar('_T') +_U = TypeVar('_U') BRICKS = '@abcdefghijklmnopqrstuvwxyz+?-\'/' @@ -122,7 +125,7 @@ def dei_args(**renames): Dear conservatives, this does not influence the ability to call the wrapped function with the original parameter names. """ - def decorator(func: Callable): + def decorator(func: Callable[_T, _U]) -> Callable[_T, _U]: @wraps(func) def wrapper(*args, **kwargs): for alias_name, actual_name in renames.items(): diff --git a/src/suou/functools.py b/src/suou/functools.py index b841a00..f07ea81 100644 --- a/src/suou/functools.py +++ b/src/suou/functools.py @@ -19,7 +19,7 @@ import math from threading import RLock import time from types import CoroutineType, NoneType -from typing import Callable, Iterable, Mapping, TypeVar +from typing import Any, Callable, Iterable, Mapping, Never, TypeVar import warnings from functools import update_wrapper, wraps, lru_cache @@ -70,7 +70,7 @@ def not_implemented(msg: Callable | str | None = None): """ A more elegant way to say a method is not implemented, but may get in the future. """ - def decorator(func: Callable) -> Callable: + def decorator(func: Callable[_T, Any]) -> Callable[_T, Never]: da_msg = msg if isinstance(msg, str) else 'method {name}() is not implemented'.format(name=func.__name__) @wraps(func) def wrapper(*a, **k): @@ -288,7 +288,7 @@ def timed_cache(ttl: int, maxsize: int = 128, typed: bool = False, *, async_: bo NEW 0.5.0 """ - def decorator(func): + def decorator(func: Callable[_T, _U]) -> Callable[_T, _U]: start_time = None if async_: @@ -318,7 +318,7 @@ def timed_cache(ttl: int, maxsize: int = 128, typed: bool = False, *, async_: bo return wrapper return decorator -def none_pass(func: Callable, *args, **kwargs) -> Callable: +def none_pass(func: Callable[_T, _U], *args, **kwargs) -> Callable[_T, _U]: """ Wrap callable so that gets called only on not None values. diff --git a/src/suou/luck.py b/src/suou/luck.py index 669025c..1ea9039 100644 --- a/src/suou/luck.py +++ b/src/suou/luck.py @@ -35,7 +35,7 @@ def lucky(validators: Iterable[Callable[[_U], bool]] = ()): NEW 0.7.0 """ - def decorator(func: Callable[..., _U]): + def decorator(func: Callable[_T, _U]) -> Callable[_T, _U]: @wraps(func) def wrapper(*args, **kwargs) -> _U: try: @@ -102,7 +102,7 @@ def rng_overload(prev_func: RngCallable[..., _U] | int | None, /, *, weight: int if isinstance(prev_func, int) and weight == 1: weight, prev_func = prev_func, None - def decorator(func: Callable[_T, _U]): + def decorator(func: Callable[_T, _U]) -> RngCallable[_T, _U]: nonlocal prev_func if prev_func is None: prev_func = RngCallable(func, weight=weight) diff --git a/src/suou/sqlalchemy/__init__.py b/src/suou/sqlalchemy/__init__.py index 63216b6..7794f39 100644 --- a/src/suou/sqlalchemy/__init__.py +++ b/src/suou/sqlalchemy/__init__.py @@ -1,5 +1,5 @@ """ -Utilities for SQLAlchemy +Utilities for SQLAlchemy. --- @@ -33,12 +33,16 @@ from ..iding import Siq, SiqGen, SiqType, SiqCache from ..classtools import Incomplete, Wanted - _T = TypeVar('_T') +_U = TypeVar('_U') -# SIQs are 14 bytes long. Storage is padded for alignment -# Not to be confused with SiqType. IdType: TypeEngine = LargeBinary(16) +""" +Database type for SIQ. + +SIQs are 14 bytes long. Storage is padded for alignment +Not to be confused with SiqType. +""" def create_session(url: str) -> Session: """ @@ -52,7 +56,6 @@ def create_session(url: str) -> Session: return Session(bind = engine) - def token_signer(id_attr: Column | str, secret_attr: Column | str) -> Incomplete[UserSigner]: """ Generate a user signing function. @@ -80,9 +83,6 @@ def token_signer(id_attr: Column | str, secret_attr: Column | str) -> Incomplete return Incomplete(Wanted(token_signer_factory)) - - - ## (in)Utilities for use in web apps below @deprecated('not part of the public API and not even working') @@ -93,6 +93,8 @@ class AuthSrc(metaclass=ABCMeta): This is an abstract class and is NOT usable directly. This is not part of the public API + + DEPRECATED ''' def required_exc(self) -> Never: raise ValueError('required field missing') @@ -140,7 +142,7 @@ def require_auth_base(cls: type[DeclarativeBase], *, src: AuthSrc, column: str | invalid_exc = src.invalid_exc or _default_invalid required_exc = src.required_exc or (lambda: _default_invalid('Login required')) - def decorator(func: Callable): + def decorator(func: Callable[_T, _U]) -> Callable[_T, _U]: @wraps(func) def wrapper(*a, **ka): ka[dest] = get_user(src.get_token()) diff --git a/src/suou/sqlalchemy/asyncio.py b/src/suou/sqlalchemy/asyncio.py index 02a8949..331407b 100644 --- a/src/suou/sqlalchemy/asyncio.py +++ b/src/suou/sqlalchemy/asyncio.py @@ -21,14 +21,17 @@ from __future__ import annotations from functools import wraps from contextvars import ContextVar, Token +from typing import Callable, TypeVar from sqlalchemy import Select, Table, func, select from sqlalchemy.orm import DeclarativeBase, lazyload from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine from flask_sqlalchemy.pagination import Pagination -from suou.classtools import MISSING from suou.exceptions import NotFoundError +_T = TypeVar('_T') +_U = TypeVar('_U') + class SQLAlchemy: """ Drop-in (in fact, almost) replacement for flask_sqlalchemy.SQLAlchemy() @@ -186,7 +189,7 @@ def async_query(db: SQLAlchemy, multi: False): The query function remains available as the .q or .query attribute. """ - def decorator(func): + def decorator(func: Callable[_T, _U]) -> Callable[_T, _U]: @wraps(func) async def executor(*args, **kwargs): async with db as session: From 6c00217095e0c73566f6c3a30fbdfb9e00a49c7a Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sat, 11 Oct 2025 10:35:40 +0200 Subject: [PATCH 094/121] update requirements for Sphinx --- requirements.txt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1efa301..e818cd6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,6 @@ +# This file is only used for Sphinx. +# End users should use pyproject.toml instead + itsdangerous==2.2.0 libsass==0.23.0 peewee==3.18.1 @@ -5,5 +8,7 @@ pydantic==2.12.0 quart_schema==0.22.0 setuptools==80.9.0 starlette==0.48.0 +SQLAlchemy==2.0.40 toml==0.10.2 -sphinx_rtd_theme +sphinx_rtd_theme==3.0.2 + From 2719f71b0670cf74e7cbad293521768e982b8252 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sat, 11 Oct 2025 10:40:58 +0200 Subject: [PATCH 095/121] update readthedocs.yaml --- .readthedocs.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 3403857..d7d423e 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -8,3 +8,10 @@ build: sphinx: configuration: docs/conf.py +python: + install: + - method: pip + path: . + extra_requirements: + - docs + - full \ No newline at end of file From 72b759504b7d637594fe0de9b9e7af13fc6b2c47 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sat, 11 Oct 2025 11:00:50 +0200 Subject: [PATCH 096/121] improve auto doc --- docs/api.rst | 6 ++-- docs/conf.py | 10 +++++++ docs/generated/suou.asgi.rst | 2 +- docs/generated/suou.bits.rst | 2 +- docs/generated/suou.calendar.rst | 2 +- docs/generated/suou.classtools.rst | 2 +- docs/generated/suou.codecs.rst | 2 +- docs/generated/suou.collections.rst | 2 +- docs/generated/suou.color.rst | 2 +- docs/generated/suou.configparse.rst | 2 +- docs/generated/suou.dei.rst | 2 +- docs/generated/suou.dorks.rst | 2 +- docs/generated/suou.exceptions.rst | 2 +- docs/generated/suou.flask.rst | 2 +- docs/generated/suou.flask_restx.rst | 2 +- docs/generated/suou.flask_sqlalchemy.rst | 2 +- docs/generated/suou.functools.rst | 2 +- docs/generated/suou.http.rst | 2 +- docs/generated/suou.i18n.rst | 2 +- docs/generated/suou.iding.rst | 2 +- docs/generated/suou.itertools.rst | 2 +- docs/generated/suou.legal.rst | 2 +- docs/generated/suou.lex.rst | 2 +- docs/generated/suou.luck.rst | 2 +- docs/generated/suou.markdown.rst | 2 +- docs/generated/suou.migrate.rst | 2 +- docs/generated/suou.quart.rst | 2 +- docs/generated/suou.redact.rst | 2 +- docs/generated/suou.sass.rst | 2 +- docs/generated/suou.signing.rst | 2 +- docs/generated/suou.snowflake.rst | 2 +- docs/generated/suou.sqlalchemy.asyncio.rst | 20 ++++++++++++++ docs/generated/suou.sqlalchemy.orm.rst | 32 ++++++++++++++++++++++ docs/generated/suou.sqlalchemy.rst | 18 ++++++++++-- docs/generated/suou.strtools.rst | 2 +- docs/generated/suou.terminal.rst | 2 +- docs/generated/suou.validators.rst | 2 +- docs/generated/suou.waiter.rst | 2 +- 38 files changed, 115 insertions(+), 37 deletions(-) create mode 100644 docs/generated/suou.sqlalchemy.asyncio.rst create mode 100644 docs/generated/suou.sqlalchemy.orm.rst diff --git a/docs/api.rst b/docs/api.rst index 1a515aa..72b76e8 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3,7 +3,8 @@ API .. autosummary:: :toctree: generated - + :recursive: + suou.sqlalchemy suou.asgi suou.bits @@ -38,4 +39,5 @@ API suou.strtools suou.terminal suou.validators - suou.waiter \ No newline at end of file + suou.waiter + diff --git a/docs/conf.py b/docs/conf.py index a071892..5d415f2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -41,3 +41,13 @@ autodoc_mock_imports = [ html_theme = 'sphinx_rtd_theme' html_theme_path = ["_themes", ] html_static_path = ['_static'] + +def polish_module_docstring(app, what, name, obj, options, lines): + if what == "module" and 'members' in options: + try: + del lines[lines.index('---'):] + except Exception: + pass + +def setup(app): + app.connect("autodoc-process-docstring", polish_module_docstring) \ No newline at end of file diff --git a/docs/generated/suou.asgi.rst b/docs/generated/suou.asgi.rst index 00c5839..544a166 100644 --- a/docs/generated/suou.asgi.rst +++ b/docs/generated/suou.asgi.rst @@ -1,4 +1,4 @@ -suou.asgi +suou.asgi ========= .. automodule:: suou.asgi diff --git a/docs/generated/suou.bits.rst b/docs/generated/suou.bits.rst index d8b4a3e..7c1be4d 100644 --- a/docs/generated/suou.bits.rst +++ b/docs/generated/suou.bits.rst @@ -1,4 +1,4 @@ -suou.bits +suou.bits ========= .. automodule:: suou.bits diff --git a/docs/generated/suou.calendar.rst b/docs/generated/suou.calendar.rst index 510743d..ba4d0b5 100644 --- a/docs/generated/suou.calendar.rst +++ b/docs/generated/suou.calendar.rst @@ -1,4 +1,4 @@ -suou.calendar +suou.calendar ============= .. automodule:: suou.calendar diff --git a/docs/generated/suou.classtools.rst b/docs/generated/suou.classtools.rst index db0a16a..2e87377 100644 --- a/docs/generated/suou.classtools.rst +++ b/docs/generated/suou.classtools.rst @@ -1,4 +1,4 @@ -suou.classtools +suou.classtools =============== .. automodule:: suou.classtools diff --git a/docs/generated/suou.codecs.rst b/docs/generated/suou.codecs.rst index bfd0260..0112a23 100644 --- a/docs/generated/suou.codecs.rst +++ b/docs/generated/suou.codecs.rst @@ -1,4 +1,4 @@ -suou.codecs +suou.codecs =========== .. automodule:: suou.codecs diff --git a/docs/generated/suou.collections.rst b/docs/generated/suou.collections.rst index 57899fd..6382120 100644 --- a/docs/generated/suou.collections.rst +++ b/docs/generated/suou.collections.rst @@ -1,4 +1,4 @@ -suou.collections +suou.collections ================ .. automodule:: suou.collections diff --git a/docs/generated/suou.color.rst b/docs/generated/suou.color.rst index b87aaa4..03365c4 100644 --- a/docs/generated/suou.color.rst +++ b/docs/generated/suou.color.rst @@ -1,4 +1,4 @@ -suou.color +suou.color ========== .. automodule:: suou.color diff --git a/docs/generated/suou.configparse.rst b/docs/generated/suou.configparse.rst index ea6c6ad..5bdf99c 100644 --- a/docs/generated/suou.configparse.rst +++ b/docs/generated/suou.configparse.rst @@ -1,4 +1,4 @@ -suou.configparse +suou.configparse ================ .. automodule:: suou.configparse diff --git a/docs/generated/suou.dei.rst b/docs/generated/suou.dei.rst index 5604f14..9c9c870 100644 --- a/docs/generated/suou.dei.rst +++ b/docs/generated/suou.dei.rst @@ -1,4 +1,4 @@ -suou.dei +suou.dei ======== .. automodule:: suou.dei diff --git a/docs/generated/suou.dorks.rst b/docs/generated/suou.dorks.rst index ec092e7..8da809b 100644 --- a/docs/generated/suou.dorks.rst +++ b/docs/generated/suou.dorks.rst @@ -1,4 +1,4 @@ -suou.dorks +suou.dorks ========== .. automodule:: suou.dorks diff --git a/docs/generated/suou.exceptions.rst b/docs/generated/suou.exceptions.rst index 8f42c2d..056200c 100644 --- a/docs/generated/suou.exceptions.rst +++ b/docs/generated/suou.exceptions.rst @@ -1,4 +1,4 @@ -suou.exceptions +suou.exceptions =============== .. automodule:: suou.exceptions diff --git a/docs/generated/suou.flask.rst b/docs/generated/suou.flask.rst index 42193eb..6adf3f8 100644 --- a/docs/generated/suou.flask.rst +++ b/docs/generated/suou.flask.rst @@ -1,4 +1,4 @@ -suou.flask +suou.flask ========== .. automodule:: suou.flask diff --git a/docs/generated/suou.flask_restx.rst b/docs/generated/suou.flask_restx.rst index e02d399..7267c4a 100644 --- a/docs/generated/suou.flask_restx.rst +++ b/docs/generated/suou.flask_restx.rst @@ -1,4 +1,4 @@ -suou.flask\_restx +suou.flask\_restx ================= .. automodule:: suou.flask_restx diff --git a/docs/generated/suou.flask_sqlalchemy.rst b/docs/generated/suou.flask_sqlalchemy.rst index e89c0bf..458fa6f 100644 --- a/docs/generated/suou.flask_sqlalchemy.rst +++ b/docs/generated/suou.flask_sqlalchemy.rst @@ -1,4 +1,4 @@ -suou.flask\_sqlalchemy +suou.flask\_sqlalchemy ====================== .. automodule:: suou.flask_sqlalchemy diff --git a/docs/generated/suou.functools.rst b/docs/generated/suou.functools.rst index 6035dff..ae9e871 100644 --- a/docs/generated/suou.functools.rst +++ b/docs/generated/suou.functools.rst @@ -1,4 +1,4 @@ -suou.functools +suou.functools ============== .. automodule:: suou.functools diff --git a/docs/generated/suou.http.rst b/docs/generated/suou.http.rst index 82c9332..ac2ce02 100644 --- a/docs/generated/suou.http.rst +++ b/docs/generated/suou.http.rst @@ -1,4 +1,4 @@ -suou.http +suou.http ========= .. automodule:: suou.http diff --git a/docs/generated/suou.i18n.rst b/docs/generated/suou.i18n.rst index 7f92f78..ecc0cd7 100644 --- a/docs/generated/suou.i18n.rst +++ b/docs/generated/suou.i18n.rst @@ -1,4 +1,4 @@ -suou.i18n +suou.i18n ========= .. automodule:: suou.i18n diff --git a/docs/generated/suou.iding.rst b/docs/generated/suou.iding.rst index a47d428..a8e2f06 100644 --- a/docs/generated/suou.iding.rst +++ b/docs/generated/suou.iding.rst @@ -1,4 +1,4 @@ -suou.iding +suou.iding ========== .. automodule:: suou.iding diff --git a/docs/generated/suou.itertools.rst b/docs/generated/suou.itertools.rst index 3ee27ab..80c8f2b 100644 --- a/docs/generated/suou.itertools.rst +++ b/docs/generated/suou.itertools.rst @@ -1,4 +1,4 @@ -suou.itertools +suou.itertools ============== .. automodule:: suou.itertools diff --git a/docs/generated/suou.legal.rst b/docs/generated/suou.legal.rst index e119f97..f19f6f0 100644 --- a/docs/generated/suou.legal.rst +++ b/docs/generated/suou.legal.rst @@ -1,4 +1,4 @@ -suou.legal +suou.legal ========== .. automodule:: suou.legal diff --git a/docs/generated/suou.lex.rst b/docs/generated/suou.lex.rst index 6a49b80..83ad3a1 100644 --- a/docs/generated/suou.lex.rst +++ b/docs/generated/suou.lex.rst @@ -1,4 +1,4 @@ -suou.lex +suou.lex ======== .. currentmodule:: suou diff --git a/docs/generated/suou.luck.rst b/docs/generated/suou.luck.rst index 9480188..7dc64b3 100644 --- a/docs/generated/suou.luck.rst +++ b/docs/generated/suou.luck.rst @@ -1,4 +1,4 @@ -suou.luck +suou.luck ========= .. automodule:: suou.luck diff --git a/docs/generated/suou.markdown.rst b/docs/generated/suou.markdown.rst index 5476d20..e968de5 100644 --- a/docs/generated/suou.markdown.rst +++ b/docs/generated/suou.markdown.rst @@ -1,4 +1,4 @@ -suou.markdown +suou.markdown ============= .. automodule:: suou.markdown diff --git a/docs/generated/suou.migrate.rst b/docs/generated/suou.migrate.rst index 2b35157..9f439a9 100644 --- a/docs/generated/suou.migrate.rst +++ b/docs/generated/suou.migrate.rst @@ -1,4 +1,4 @@ -suou.migrate +suou.migrate ============ .. automodule:: suou.migrate diff --git a/docs/generated/suou.quart.rst b/docs/generated/suou.quart.rst index 9be2817..f0c93c5 100644 --- a/docs/generated/suou.quart.rst +++ b/docs/generated/suou.quart.rst @@ -1,4 +1,4 @@ -suou.quart +suou.quart ========== .. automodule:: suou.quart diff --git a/docs/generated/suou.redact.rst b/docs/generated/suou.redact.rst index b5208ec..71e8607 100644 --- a/docs/generated/suou.redact.rst +++ b/docs/generated/suou.redact.rst @@ -1,4 +1,4 @@ -suou.redact +suou.redact =========== .. automodule:: suou.redact diff --git a/docs/generated/suou.sass.rst b/docs/generated/suou.sass.rst index 6ad4426..52d6fdd 100644 --- a/docs/generated/suou.sass.rst +++ b/docs/generated/suou.sass.rst @@ -1,4 +1,4 @@ -suou.sass +suou.sass ========= .. automodule:: suou.sass diff --git a/docs/generated/suou.signing.rst b/docs/generated/suou.signing.rst index b57968d..eec63ad 100644 --- a/docs/generated/suou.signing.rst +++ b/docs/generated/suou.signing.rst @@ -1,4 +1,4 @@ -suou.signing +suou.signing ============ .. automodule:: suou.signing diff --git a/docs/generated/suou.snowflake.rst b/docs/generated/suou.snowflake.rst index fa072d3..be112da 100644 --- a/docs/generated/suou.snowflake.rst +++ b/docs/generated/suou.snowflake.rst @@ -1,4 +1,4 @@ -suou.snowflake +suou.snowflake ============== .. automodule:: suou.snowflake diff --git a/docs/generated/suou.sqlalchemy.asyncio.rst b/docs/generated/suou.sqlalchemy.asyncio.rst new file mode 100644 index 0000000..872ac14 --- /dev/null +++ b/docs/generated/suou.sqlalchemy.asyncio.rst @@ -0,0 +1,20 @@ +suou.sqlalchemy.asyncio +======================= + +.. automodule:: suou.sqlalchemy.asyncio + + + .. rubric:: Functions + + .. autosummary:: + + async_query + + .. rubric:: Classes + + .. autosummary:: + + AsyncSelectPagination + SQLAlchemy + SessionWrapper + \ No newline at end of file diff --git a/docs/generated/suou.sqlalchemy.orm.rst b/docs/generated/suou.sqlalchemy.orm.rst new file mode 100644 index 0000000..41cb2c0 --- /dev/null +++ b/docs/generated/suou.sqlalchemy.orm.rst @@ -0,0 +1,32 @@ +suou.sqlalchemy.orm +=================== + +.. automodule:: suou.sqlalchemy.orm + + + .. rubric:: Functions + + .. autosummary:: + + a_relationship + age_pair + author_pair + bool_column + bound_fk + declarative_base + entity_base + id_column + match_column + match_constraint + parent_children + secret_column + snowflake_column + unbound_fk + want_column + + .. rubric:: Classes + + .. autosummary:: + + BitSelector + \ No newline at end of file diff --git a/docs/generated/suou.sqlalchemy.rst b/docs/generated/suou.sqlalchemy.rst index 118bf3a..9a28efa 100644 --- a/docs/generated/suou.sqlalchemy.rst +++ b/docs/generated/suou.sqlalchemy.rst @@ -1,9 +1,15 @@ -suou.sqlalchemy +suou.sqlalchemy =============== .. automodule:: suou.sqlalchemy + .. rubric:: Module Attributes + + .. autosummary:: + + IdType + .. rubric:: Functions .. autosummary:: @@ -17,4 +23,12 @@ .. autosummary:: AuthSrc - \ No newline at end of file + +.. rubric:: Modules + +.. autosummary:: + :toctree: + :recursive: + + asyncio + orm diff --git a/docs/generated/suou.strtools.rst b/docs/generated/suou.strtools.rst index c9df5c0..1bc81a1 100644 --- a/docs/generated/suou.strtools.rst +++ b/docs/generated/suou.strtools.rst @@ -1,4 +1,4 @@ -suou.strtools +suou.strtools ============= .. automodule:: suou.strtools diff --git a/docs/generated/suou.terminal.rst b/docs/generated/suou.terminal.rst index 71d531f..7655533 100644 --- a/docs/generated/suou.terminal.rst +++ b/docs/generated/suou.terminal.rst @@ -1,4 +1,4 @@ -suou.terminal +suou.terminal ============= .. automodule:: suou.terminal diff --git a/docs/generated/suou.validators.rst b/docs/generated/suou.validators.rst index 673e889..b7974a0 100644 --- a/docs/generated/suou.validators.rst +++ b/docs/generated/suou.validators.rst @@ -1,4 +1,4 @@ -suou.validators +suou.validators =============== .. automodule:: suou.validators diff --git a/docs/generated/suou.waiter.rst b/docs/generated/suou.waiter.rst index c668f25..e420b75 100644 --- a/docs/generated/suou.waiter.rst +++ b/docs/generated/suou.waiter.rst @@ -1,4 +1,4 @@ -suou.waiter +suou.waiter =========== .. automodule:: suou.waiter From ef8ce327cd50f025148387cddca521a14c5f73e2 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sat, 11 Oct 2025 11:07:50 +0200 Subject: [PATCH 097/121] prepare for release --- CHANGELOG.md | 5 +++++ pyproject.toml | 1 + src/suou/__init__.py | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe54664..79cce18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.7.1 + ++ Add documentation ([Read The Docs](https://suou.readthedocs.io/)) ++ Improved decorator typing + ## 0.7.0 "The Lucky Update" + Add RNG/random selection overloads such as `luck()`, `rng_overload()` diff --git a/pyproject.toml b/pyproject.toml index d81123b..e9b7c97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ classifiers = [ [project.urls] Repository = "https://nekode.yusur.moe/yusur/suou" +Documentation = "https://suou.readthedocs.io" [project.optional-dependencies] # the below are all dev dependencies (and probably already installed) diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 66f3fcd..acbb164 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -37,7 +37,7 @@ from .redact import redact_url_password from .http import WantsContentType from .color import chalk -__version__ = "0.7.1-dev40" +__version__ = "0.7.1-rc1" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', From be4404c52028d4f9418ec79e92999aff2012c65b Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sat, 11 Oct 2025 11:09:37 +0200 Subject: [PATCH 098/121] 0.7.1 --- aliases/sakuragasaki46_suou/pyproject.toml | 9 ++++++++- src/suou/__init__.py | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/aliases/sakuragasaki46_suou/pyproject.toml b/aliases/sakuragasaki46_suou/pyproject.toml index 668a46b..4e19614 100644 --- a/aliases/sakuragasaki46_suou/pyproject.toml +++ b/aliases/sakuragasaki46_suou/pyproject.toml @@ -10,7 +10,7 @@ license = "Apache-2.0" readme = "README.md" dependencies = [ - "suou==0.7.0", + "suou==0.7.1", "itsdangerous", "toml", "pydantic", @@ -33,6 +33,7 @@ classifiers = [ [project.urls] Repository = "https://nekode.yusur.moe/yusur/suou" +Documentation = "https://suou.readthedocs.io" [project.optional-dependencies] # the below are all dev dependencies (and probably already installed) @@ -73,6 +74,12 @@ full = [ "sakuragasaki46_suou[sass]" ] +docs = [ + "sphinx>=2.1", + "myst_parser", + "sphinx_rtd_theme" +] + [tool.setuptools.dynamic] version = { attr = "suou.__version__" } diff --git a/src/suou/__init__.py b/src/suou/__init__.py index acbb164..6eb7546 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -37,7 +37,7 @@ from .redact import redact_url_password from .http import WantsContentType from .color import chalk -__version__ = "0.7.1-rc1" +__version__ = "0.7.1" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', From 7e80c84de695473e969bcd55466cbca8f506d4df Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sat, 11 Oct 2025 18:39:06 +0200 Subject: [PATCH 099/121] 0.7.2 add version= to @future(), support Py3.14, mark .waiter as future --- CHANGELOG.md | 7 +++++++ pyproject.toml | 3 ++- src/suou/__init__.py | 2 +- src/suou/functools.py | 10 ++++++++-- src/suou/sqlalchemy/orm.py | 24 ++++++++++++++++++++++-- src/suou/waiter.py | 3 +++ 6 files changed, 43 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79cce18..1e68baf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.7.2 + ++ `@future()` now can take a `version=` argument ++ `Waiter()` got marked `@future` indefinitely ++ Stage `username_column()` for release in 0.8.0 ++ Explicit support for Python 3.14 (aka python pi) + ## 0.7.1 + Add documentation ([Read The Docs](https://suou.readthedocs.io/)) diff --git a/pyproject.toml b/pyproject.toml index e9b7c97..d29c3df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,8 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13" + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14" ] [project.urls] diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 6eb7546..60eff4b 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -37,7 +37,7 @@ from .redact import redact_url_password from .http import WantsContentType from .color import chalk -__version__ = "0.7.1" +__version__ = "0.7.2" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', diff --git a/src/suou/functools.py b/src/suou/functools.py index f07ea81..91eb916 100644 --- a/src/suou/functools.py +++ b/src/suou/functools.py @@ -80,17 +80,23 @@ def not_implemented(msg: Callable | str | None = None): return decorator(msg) return decorator -def future(message: str | None = None): +def future(message: str | None = None, *, version: str = None): """ Describes experimental or future API's introduced as bug fixes (including as backports) but not yet intended for general use (mostly to keep semver consistent). + version= is the intended version release. + NEW 0.7.0 """ def decorator(func: Callable[_T, _U]) -> Callable[_T, _U]: @wraps(func) def wrapper(*a, **k) -> _U: - warnings.warn(message or f'{func.__name__}() is intended for a future release and not intended for use right now', FutureWarning) + warnings.warn(message or ( + f'{func.__name__}() is intended for release on {version} and not ready for use right now' + if version else + f'{func.__name__}() is intended for a future release and not ready for use right now' + ), FutureWarning) return func(*a, **k) return wrapper return decorator diff --git a/src/suou/sqlalchemy/orm.py b/src/suou/sqlalchemy/orm.py index 403e762..37b4def 100644 --- a/src/suou/sqlalchemy/orm.py +++ b/src/suou/sqlalchemy/orm.py @@ -1,7 +1,7 @@ """ Utilities for SQLAlchemy; ORM -NEW 0.6.0 +NEW 0.6.0 (moved) --- @@ -20,12 +20,14 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. from binascii import Incomplete import os +import re from typing import Any, Callable, TypeVar import warnings from sqlalchemy import BigInteger, Boolean, CheckConstraint, Column, Date, ForeignKey, LargeBinary, MetaData, SmallInteger, String, text from sqlalchemy.orm import DeclarativeBase, InstrumentedAttribute, Relationship, declarative_base as _declarative_base, relationship from sqlalchemy.types import TypeEngine from sqlalchemy.ext.hybrid import Comparator +from suou.functools import future from suou.classtools import Wanted, Incomplete from suou.codecs import StringCase from suou.dei import dei_args @@ -101,7 +103,7 @@ match_constraint.TEXT_DIALECTS = { 'mariadb': ':n RLIKE :re' } -def match_column(length: int, regex: str, /, case: StringCase = StringCase.AS_IS, *args, constraint_name: str | None = None, **kwargs) -> Incomplete[Column[str]]: +def match_column(length: int, regex: str | re.Pattern, /, case: StringCase = StringCase.AS_IS, *args, constraint_name: str | None = None, **kwargs) -> Incomplete[Column[str]]: """ Syntactic sugar to create a String() column with a check constraint matching the given regular expression. @@ -112,6 +114,24 @@ def match_column(length: int, regex: str, /, case: StringCase = StringCase.AS_IS return Incomplete(Column, String(length), Wanted(lambda x, n: match_constraint(n, regex, #dialect=x.metadata.engine.dialect.name, constraint_name=constraint_name or f'{x.__tablename__}_{n}_valid')), *args, **kwargs) + +@future(version='0.8.0') +def username_column( + length: int = 32, regex: str | re.Pattern = '[a-z_][a-z0-9_-]+', *args, case: StringCase = StringCase.LOWER, + nullable : bool = False, **kwargs) -> Incomplete[Column[str] | Column[str | None]]: + """ + Construct a column containing a unique handle / username. + + Username must match the given `regex` and be at most `length` characters long. + + NEW 0.8.0 + """ + if case is StringCase.AS_IS: + warnings.warn('case sensitive usernames may lead to impersonation and unexpected behavior', UserWarning) + + return match_column(length, regex, case=case, nullable=nullable, unique=True, *args, **kwargs) + + def bool_column(value: bool = False, nullable: bool = False, **kwargs) -> Column[bool]: """ Column for a single boolean value. diff --git a/src/suou/waiter.py b/src/suou/waiter.py index a210f45..897062f 100644 --- a/src/suou/waiter.py +++ b/src/suou/waiter.py @@ -21,6 +21,9 @@ from starlette.applications import Starlette from starlette.responses import JSONResponse, PlainTextResponse, Response from starlette.routing import Route +from suou.functools import future + +@future() class Waiter(): def __init__(self): self.routes: list[Route] = [] From 10e6c202f02e3e15e242a6d254dc3cdac2387da5 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sat, 18 Oct 2025 14:48:32 +0200 Subject: [PATCH 100/121] 0.7.3 fix imports (?) in .sqlalchemy, add experimental .glue, docs for .sqlalchemy --- CHANGELOG.md | 6 ++ aliases/sakuragasaki46_suou/pyproject.toml | 5 +- docs/generated/suou.sqlalchemy.orm.rst | 3 +- docs/generated/suou.waiter.rst | 9 +- docs/index.rst | 9 +- docs/sqlalchemy.rst | 45 ++++++++ src/suou/__init__.py | 2 +- src/suou/glue.py | 59 +++++++++++ src/suou/sqlalchemy/asyncio.py | 116 +++++++++++---------- 9 files changed, 184 insertions(+), 70 deletions(-) create mode 100644 docs/sqlalchemy.rst create mode 100644 src/suou/glue.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e68baf..5a9562f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.7.3 + ++ Fixed some broken imports in `.sqlalchemy` ++ Stage `@glue()` for release in 0.8.0 ++ Add docs to `.sqlalchemy` + ## 0.7.2 + `@future()` now can take a `version=` argument diff --git a/aliases/sakuragasaki46_suou/pyproject.toml b/aliases/sakuragasaki46_suou/pyproject.toml index 4e19614..52e3d6b 100644 --- a/aliases/sakuragasaki46_suou/pyproject.toml +++ b/aliases/sakuragasaki46_suou/pyproject.toml @@ -10,7 +10,7 @@ license = "Apache-2.0" readme = "README.md" dependencies = [ - "suou==0.7.1", + "suou==0.7.2", "itsdangerous", "toml", "pydantic", @@ -28,7 +28,8 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13" + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14" ] [project.urls] diff --git a/docs/generated/suou.sqlalchemy.orm.rst b/docs/generated/suou.sqlalchemy.orm.rst index 41cb2c0..cdd75b0 100644 --- a/docs/generated/suou.sqlalchemy.orm.rst +++ b/docs/generated/suou.sqlalchemy.orm.rst @@ -1,4 +1,4 @@ -suou.sqlalchemy.orm +suou.sqlalchemy.orm =================== .. automodule:: suou.sqlalchemy.orm @@ -22,6 +22,7 @@ suou.sqlalchemy.orm secret_column snowflake_column unbound_fk + username_column want_column .. rubric:: Classes diff --git a/docs/generated/suou.waiter.rst b/docs/generated/suou.waiter.rst index e420b75..e0270c5 100644 --- a/docs/generated/suou.waiter.rst +++ b/docs/generated/suou.waiter.rst @@ -1,4 +1,4 @@ -suou.waiter +suou.waiter =========== .. automodule:: suou.waiter @@ -8,12 +8,7 @@ suou.waiter .. autosummary:: + Waiter ko ok - - .. rubric:: Classes - - .. autosummary:: - - Waiter \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 3bc5bd4..b84454f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -3,16 +3,15 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -suou documentation +SUOU ================== -SUOU (acronym for ) is a casual Python library providing utilities to -ease programmer's QoL. - - +SUOU (acronym for **SIS Unified Object Underarmour**) is a casual Python library providing utilities to +ease programmer's QoL and write shorter and cleaner code that works. .. toctree:: :maxdepth: 2 + sqlalchemy api \ No newline at end of file diff --git a/docs/sqlalchemy.rst b/docs/sqlalchemy.rst new file mode 100644 index 0000000..7cea449 --- /dev/null +++ b/docs/sqlalchemy.rst @@ -0,0 +1,45 @@ + +sqlalchemy helpers +================== + +.. currentmodule:: suou.sqlalchemy + +SUOU provides several helpers to make sqlalchemy learning curve less steep. + +In fact, there are pre-made column presets for a specific purpose. + + +Columns +------- + +.. autofunction:: id_column + +.. warning:: + ``id_column()`` expects SIQ's! + +.. autofunction:: snowflake_column + +.. autofunction:: match_column + +.. autofunction:: secret_column + +.. autofunction:: bool_column + +.. autofunction:: unbound_fk +.. autofunction:: bound_fk + +Column pairs +------------ + +.. autofunction:: age_pair +.. autofunction:: author_pair +.. autofunction:: parent_children + +Misc +---- + +.. autofunction:: BitSelector +.. autofunction:: match_constraint +.. autofunction:: a_relationship +.. autofunction:: declarative_base +.. autofunction:: want_column \ No newline at end of file diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 60eff4b..96bc8aa 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -37,7 +37,7 @@ from .redact import redact_url_password from .http import WantsContentType from .color import chalk -__version__ = "0.7.2" +__version__ = "0.7.3" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', diff --git a/src/suou/glue.py b/src/suou/glue.py new file mode 100644 index 0000000..1d97318 --- /dev/null +++ b/src/suou/glue.py @@ -0,0 +1,59 @@ +""" +Helpers for "Glue" code, aka code meant to adapt or patch other libraries + +--- + +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 importlib +from types import ModuleType + +from functools import wraps +from suou.functools import future + + +class FakeModule(ModuleType): + """ + Fake module used in @glue() in case of import error + """ + def __init__(self, name: str, exc: Exception): + super().__init__(name) + self._exc = exc + def __getattr__(self, name: str): + raise AttributeError(f'Module {self.__name__} not found; this feature is not available ({self._exc})') from self._exc + + +@future(version = "0.8.0") +def glue(*modules): + """ + Helper for "glue" code -- it imports the given modules and passes them as keyword arguments to the wrapped functions. + + NEW 0.8.0 + """ + module_dict = dict() + + for module in modules: + try: + module_dict[module] = importlib.import_module(module) + except Exception as e: + module_dict[module] = FakeModule(module, e) + + def decorator(func): + @wraps(func) + def wrapper(*a, **k): + k.update(module_dict) + return func(*a, **k) + return wrapper + return decorator + +# This module is experimental and therefore not re-exported into __init__ +__all__ = ('glue',) \ No newline at end of file diff --git a/src/suou/sqlalchemy/asyncio.py b/src/suou/sqlalchemy/asyncio.py index 331407b..605ec93 100644 --- a/src/suou/sqlalchemy/asyncio.py +++ b/src/suou/sqlalchemy/asyncio.py @@ -25,9 +25,9 @@ from typing import Callable, TypeVar from sqlalchemy import Select, Table, func, select from sqlalchemy.orm import DeclarativeBase, lazyload from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine -from flask_sqlalchemy.pagination import Pagination from suou.exceptions import NotFoundError +from suou.glue import glue _T = TypeVar('_T') _U = TypeVar('_U') @@ -119,68 +119,76 @@ class SQLAlchemy: # XXX NOT public API! DO NOT USE current_session: ContextVar[AsyncSession] = ContextVar('current_session') -class AsyncSelectPagination(Pagination): - """ - flask_sqlalchemy.SelectPagination but asynchronous. +## experimental +@glue('flask_sqlalchemy') +def _make_AsyncSelectPagination(flask_sqlalchemy): + class AsyncSelectPagination(flask_sqlalchemy.pagination.Pagination): + """ + flask_sqlalchemy.SelectPagination but asynchronous. - Pagination is not part of the public API, therefore expect that it may break - """ + Pagination is not part of the public API, therefore expect that it may break + """ - async def _query_items(self) -> list: - select_q: Select = self._query_args["select"] - select = select_q.limit(self.per_page).offset(self._query_offset) - session: AsyncSession = self._query_args["session"] - out = (await session.execute(select)).scalars() - return out + async def _query_items(self) -> list: + select_q: Select = self._query_args["select"] + select = select_q.limit(self.per_page).offset(self._query_offset) + session: AsyncSession = self._query_args["session"] + out = (await session.execute(select)).scalars() + return out - async def _query_count(self) -> int: - select_q: Select = self._query_args["select"] - sub = select_q.options(lazyload("*")).order_by(None).subquery() - session: AsyncSession = self._query_args["session"] - out = (await session.execute(select(func.count()).select_from(sub))).scalar() - return out + async def _query_count(self) -> int: + select_q: Select = self._query_args["select"] + sub = select_q.options(lazyload("*")).order_by(None).subquery() + session: AsyncSession = self._query_args["session"] + out = (await session.execute(select(func.count()).select_from(sub))).scalar() + return out - def __init__(self, - page: int | None = None, - per_page: int | None = None, - max_per_page: int | None = 100, - error_out: Exception | None = NotFoundError, - count: bool = True, - **kwargs): - ## XXX flask-sqlalchemy says Pagination() is not public API. - ## Things may break; beware. - self._query_args = kwargs - page, per_page = self._prepare_page_args( - page=page, - per_page=per_page, - max_per_page=max_per_page, - error_out=error_out, - ) + def __init__(self, + page: int | None = None, + per_page: int | None = None, + max_per_page: int | None = 100, + error_out: Exception | None = NotFoundError, + count: bool = True, + **kwargs): + ## XXX flask-sqlalchemy says Pagination() is not public API. + ## Things may break; beware. + self._query_args = kwargs + page, per_page = self._prepare_page_args( + page=page, + per_page=per_page, + max_per_page=max_per_page, + error_out=error_out, + ) - self.page: int = page - """The current page.""" + self.page: int = page + """The current page.""" - self.per_page: int = per_page - """The maximum number of items on a page.""" + self.per_page: int = per_page + """The maximum number of items on a page.""" - self.max_per_page: int | None = max_per_page - """The maximum allowed value for ``per_page``.""" + self.max_per_page: int | None = max_per_page + """The maximum allowed value for ``per_page``.""" - self.items = None - self.total = None - self.error_out = error_out - self.has_count = count + self.items = None + self.total = None + self.error_out = error_out + self.has_count = count - async def __aiter__(self): - self.items = await self._query_items() - if self.items is None: - raise RuntimeError('query returned None') - if not self.items and self.page != 1 and self.error_out: - raise self.error_out - if self.has_count: - self.total = await self._query_count() - for i in self.items: - yield i + async def __aiter__(self): + self.items = await self._query_items() + if self.items is None: + raise RuntimeError('query returned None') + if not self.items and self.page != 1 and self.error_out: + raise self.error_out + if self.has_count: + self.total = await self._query_count() + for i in self.items: + yield i + + return AsyncSelectPagination + +AsyncSelectPagination = _make_AsyncSelectPagination() +del _make_AsyncSelectPagination def async_query(db: SQLAlchemy, multi: False): From c27630c3d6b3e37b66b355ed53705f05bbb885ce Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Wed, 29 Oct 2025 09:28:59 +0100 Subject: [PATCH 101/121] 0.7.4 add test and docs to .iding --- CHANGELOG.md | 8 +- README.md | 6 +- aliases/sakuragasaki46_suou/pyproject.toml | 2 +- docs/conf.py | 2 +- docs/iding.rst | 197 +++++++++++++++++++++ docs/index.rst | 1 + docs/sqlalchemy.rst | 3 +- src/suou/__init__.py | 2 +- src/suou/glue.py | 19 +- src/suou/iding.py | 17 +- src/suou/luck.py | 2 +- tests/test_iding.py | 35 ++++ 12 files changed, 278 insertions(+), 16 deletions(-) create mode 100644 docs/iding.rst create mode 100644 tests/test_iding.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a9562f..e4ebb12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,15 @@ # Changelog +## 0.7.4 + ++ Delay release of `@glue()` ++ Add docs and some tests to `.iding` ++ Fix bug in `SiqGen()` that may prevent generation in short amounts of time + ## 0.7.3 + Fixed some broken imports in `.sqlalchemy` -+ Stage `@glue()` for release in 0.8.0 ++ Stage `@glue()` for release in 0.9.0 + Add docs to `.sqlalchemy` ## 0.7.2 diff --git a/README.md b/README.md index 8f931dc..5b9a797 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ Good morning, my brother! Welcome **SUOU** (**S**IS **U**nified **O**bject **U**nderarmor), the Python library which speeds up and makes it pleasing to develop API, database schemas and stuff in Python. It provides utilities such as: -* [SIQ](https://yusur.moe/protocols/siq.html) -* signing and generation of access tokens, on top of [ItsDangerous](https://github.com/pallets/itsdangerous) -* helpers for use in Flask, SQLAlchemy, and other popular frameworks +* SIQ ([specification](https://yusur.moe/protocols/siq.html) - [copy](https://suou.readthedocs.io/en/latest/iding.html)) +* signing and generation of access tokens, on top of [ItsDangerous](https://github.com/pallets/itsdangerous) *not tested and not working* +* helpers for use in Flask, [SQLAlchemy](https://suou.readthedocs.io/en/latest/sqlalchemy.html), and other popular frameworks * i forgor 💀 **It is not an ORM** nor a replacement of it; it works along existing ORMs (currently only SQLAlchemy is supported lol). diff --git a/aliases/sakuragasaki46_suou/pyproject.toml b/aliases/sakuragasaki46_suou/pyproject.toml index 52e3d6b..f53eeb0 100644 --- a/aliases/sakuragasaki46_suou/pyproject.toml +++ b/aliases/sakuragasaki46_suou/pyproject.toml @@ -10,7 +10,7 @@ license = "Apache-2.0" readme = "README.md" dependencies = [ - "suou==0.7.2", + "suou==0.7.4", "itsdangerous", "toml", "pydantic", diff --git a/docs/conf.py b/docs/conf.py index 5d415f2..8ff904e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,7 +31,7 @@ autodoc_mock_imports = [ "toml", "starlette", "itsdangerous", - "pydantic", + #"pydantic", "quart_schema" ] diff --git a/docs/iding.rst b/docs/iding.rst new file mode 100644 index 0000000..38eaa69 --- /dev/null +++ b/docs/iding.rst @@ -0,0 +1,197 @@ + +IDing +===== + +.. currentmodule:: suou.iding + +... + +SIQ +--- + +The main point of the SUOU library is to provide an implementation for the methods of SIS, a protocol for information exchange in phase of definition, +and of which SUOU is the reference implementation. + +The key element is the ID format called SIQ, a 112-bit identifier format. + +Here follow an extract from the `specification`_: + +.. _specification: + +Why SIQ? +******** + +.. highlights:: + I needed unique, compact, decentralized, reproducible and sortable identifiers for my applications. + + Something I could reliably use as database key, as long as being fit for my purposes, in the context of a larger project, a federated protocol. + +Why not ... +*********** + +.. highlights:: + * **Serial numbers**? They are relative. If they needed to be absolute, they would have to be issued by a single central authority for everyone else. Unacceptable for a decentralized protocol. + * **Username-domain identifiers**? Despite them being in use in other decentralized protocols (such as ActivityPub and Matrix), they are immutable and bound to a single domain. It means, the system sees different domains or usernames as different users. Users can't change their username after registration, therefore forcing them to carry an unpleasant or cringe handle for the rest of their life. + * **UUID**'s? UUIDs are unreliable. Most services use UUIDv4's, which are just opaque sequences of random bytes, and definitely not optimal as database keys. Other versions exist (such as the timestamp-based [UUIDv7](https://uuidv7.org)), however they still miss something needed for cross-domain uniqueness. In any case, UUIDs need to waste some bits to specify their "protocol". + * **Snowflake**s? Snowflakes would be a good choice, and are the inspiration for SIQ themselves. However, 64 bits are not enough for our use case, and Snowflake is *already making the necessary sacrifices* to ensure everything fits into 64 bits (i.e. the epoch got significantly moved forward). + * **Content hashes**? They are based on content, therefore they require content to be immutable and undeletable. Also: collisions. + * **PLC**'s (i.e. the ones in use at BlueSky)? [The implementation is cryptic](https://github.com/did-method-plc/did-method-plc). Moreover, it requires a central authority, and BlueSky is, as of now, holding the role of the sole authority. The resulting identifier as well is apparently random, therefore unorderable. + * **ULID**'s? They are just UUIDv4's with a timestamp. Sortable? Yes. Predictable? No, random bits rely on the assumption of being generated on a single host — i.e. centralization. Think of them as yet another attempt to UUIDv7's. + +Anatomy of a SIQ +**************** + + +SIQ's are **112 bit** binary strings. Why 112? Why not 128? Idk, felt like it. Maybe to save space. Maybe because I could fit it into UUID some day — UUID already reserves some bits for the protocol. + +Those 112 bits split up into: + +* 56 bits of **timestamp**; +* 8 bits of process ("**shard**") information; +* 32 bits of **domain** hash; +* 16 bits of **serial** and **qualifier**. + +Here is a graph of a typical SIQ layout: + +``` +0: tttttttt tttttttt tttttttt tttttttt tttttttt +40: uuuuuuuu uuuuuuuu ssssssss dddddddd dddddddd +80: dddddddd dddddddd nnnnnnnn nnqqqqqq + +where: +t : timestamp -- seconds +u : timestamp -- fraction seconds +s : shard +d : domain hash +n : progressive +q : qualifier (variable width, in fact) +``` + +Timestamp +********* + +SIQ uses 56 bits for storing timestamp: + +- **40 bits** for **seconds**; +- **16 bits** for **fraction seconds**. + +There is no need to explain [why I need no less than 40 bits for seconds](https://en.wikipedia.org/wiki/Year_2038_problem). + +Most standards — including Snowflake and ULID — store timestamp in *milliseconds*. It means the system needs to make a division by 1000 to retrieve second value. + +But 1000 is almost 1024, right? So the last ten bits can safely be ignored and we easily obtain a UNIX timestamp by doing a right shi-  wait. + +It's more comfortable to assume that 1024 is nearly 1000. *Melius abundare quam deficere*. And injective mapping is there. + +But rounding? Truncation? Here comes the purpose of the 6 additional trailing bits: precision control. Bits from dividing milliseconds o'clock are different from those from rounding microseconds. + +Yes, most systems can't go beyond milliseconds for accuracy — standard Java is like that. But detecting platform accuracy is beyond my scope. + +There are other factors to ensure uniqueness: *domain* and *shard* bits. + +Domain, shard +************* + +The temporal uniqueness is ensured by timestamp. However, in a distributed, federated system there is the chance for the same ID to get generated twice by two different subjects. + +Therefore, *spacial* uniqueness must be enforced in some way. + +Since SIQ's are going to be used the most in web applications, a way to differentiate *spacially* different applications is via the **domain name**. + +I decided to reserve **32 bits** for the domain hash. + +The algorithm of choice is **SHA-256** for its well-known diffusion and collision resistance. However, 256 bits are too much to fit into a SIQ! So, the last 4 bytes are taken. + +*...* + +Development and testing environments may safely set all the domain bits to 0. + +Qualifiers +********** + +The last 16 bits are special, in a way that makes those identifiers unique, and you can tell what is what just by looking at them. + +Inspired by programming language implementations, such as OCaml and early JavaScript, a distinguishing bit affix differentiates among types of heterogeneous entities: + +* terminal entities (leaves) end in ``1``. This includes content blobs, array elements, and relationships; +* non-leaves end in ``0``. + +The full assigment scheme (managed by me) looks like this: + +------------------------------------------------------- +Suffix Usage +======================================================= +``x00000`` user account +``x10000`` application (e.g. API, client, bot, form) +``x01000`` event, task +``x11000`` product, subscription +``x00100`` user group, membership, role +``x10100`` collection, feed +``x01100`` invite +``x11100`` *unassigned* +``x00010`` tag, category +``x10010`` *unassigned* +``x01010`` channel (guild, live chat, forum, wiki~) +``x11010`` *unassigned* +``xx0110`` thread, page +``xx1110`` message, post, revision +``xxx001`` 3+ fk relationship +``xxx101`` many-to-many, hash array element +``xxx011`` array element (one to many) +``xxx111`` content +-------------------------------------------------------- + + +The leftover bits are used as progressive serials, incremented as generation continues, and usually reset when timestamp is incremented. + +Like with snowflakes and ULID's, if you happen to run out with serials, you need to wait till timestamp changes. Usually around 15 microseconds. + +Storage +******* + +It is advised to store in databases as *16 byte binary strings*. + +- In MySQL/MariaDB, it's ``VARBINARY(16)``. + +The two extra bytes are to ease alignment, and possible expansion of timestamp range — even though it would not be an issue until some years after 10,000 CE. + +It is possible to fit them into UUID's (specifically, UUIDv8's — custom ones), taking advantage from databases and libraries implementing a UUID type — e.g. PostgreSQL. + +Unfortunately, nobody wants to deal with storing arbitrarily long integers — lots of issues pop up by going beyond 64. 128 bit integers are not natively supported in most places. Let alone 112 bit ones. + +(end of extract) + +Implementation +************** + +.. autoclass:: Siq + +.. autoclass:: SiqGen + +.. automethod:: SiqGen.__init__ +.. automethod:: SiqGen.generate + +Snowflake +--------- + +SUOU also implements \[the Discord flavor of\] Snowflake ID's. + +This flavor of Snowflake requires an epoch date, and consists of: +* 42 bits of timestamp, with millisecond precision; +* 10 bits for, respectively, worker ID (5 bits) and shard ID (5 bits); +* 12 bits incremented progressively. + + +.. autoclass:: suou.snowflake.Snowflake + +.. autoclass:: suou.snowflake.SnowflakeGen + + +Other ID formats +---------------- + +Other ID formats (such as UUID's, ULID's) are implemented by other libraries. + +In particular, Python itself has support for UUID in the Standard Library. + + diff --git a/docs/index.rst b/docs/index.rst index b84454f..12e5d40 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,4 +14,5 @@ ease programmer's QoL and write shorter and cleaner code that works. :maxdepth: 2 sqlalchemy + iding api \ No newline at end of file diff --git a/docs/sqlalchemy.rst b/docs/sqlalchemy.rst index 7cea449..a1a78ac 100644 --- a/docs/sqlalchemy.rst +++ b/docs/sqlalchemy.rst @@ -38,7 +38,8 @@ Column pairs Misc ---- -.. autofunction:: BitSelector +.. autoclass:: BitSelector + .. autofunction:: match_constraint .. autofunction:: a_relationship .. autofunction:: declarative_base diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 96bc8aa..db3e41a 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -37,7 +37,7 @@ from .redact import redact_url_password from .http import WantsContentType from .color import chalk -__version__ = "0.7.3" +__version__ = "0.7.4" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', diff --git a/src/suou/glue.py b/src/suou/glue.py index 1d97318..db08aaf 100644 --- a/src/suou/glue.py +++ b/src/suou/glue.py @@ -18,6 +18,7 @@ import importlib from types import ModuleType from functools import wraps +from suou.classtools import MISSING from suou.functools import future @@ -32,28 +33,36 @@ class FakeModule(ModuleType): raise AttributeError(f'Module {self.__name__} not found; this feature is not available ({self._exc})') from self._exc -@future(version = "0.8.0") +@future(version = "0.9.0") def glue(*modules): """ Helper for "glue" code -- it imports the given modules and passes them as keyword arguments to the wrapped functions. - NEW 0.8.0 + NEW 0.9.0 """ module_dict = dict() + imports_succeeded = True for module in modules: try: module_dict[module] = importlib.import_module(module) except Exception as e: + imports_succeeded = False module_dict[module] = FakeModule(module, e) def decorator(func): @wraps(func) def wrapper(*a, **k): - k.update(module_dict) - return func(*a, **k) + try: + result = func(*a, **k) + except Exception: + if not imports_succeeded: + ## XXX return an iterable? A Fake****? + return MISSING + raise + return result return wrapper return decorator # This module is experimental and therefore not re-exported into __init__ -__all__ = ('glue',) \ No newline at end of file +__all__ = ('glue', 'FakeModule') \ No newline at end of file diff --git a/src/suou/iding.py b/src/suou/iding.py index 2fe2364..a2e0c37 100644 --- a/src/suou/iding.py +++ b/src/suou/iding.py @@ -31,6 +31,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. from __future__ import annotations import base64 import binascii +import datetime import enum from functools import cached_property import hashlib @@ -40,6 +41,8 @@ import os from typing import Iterable, override import warnings +from suou.calendar import want_timestamp + from .functools import deprecated from .codecs import b32lencode, b64encode, cb32decode, cb32encode, want_str @@ -120,20 +123,30 @@ class SiqGen: """ Implement a SIS-compliant SIQ generator. """ - __slots__ = ('domain_hash', 'last_gen_ts', 'counters', 'shard_id', '__weakref__') + __slots__ = ('domain_hash', 'last_gen_ts', 'counters', 'shard_id', '_test_cur_ts', '__weakref__') domain_hash: int last_gen_ts: int shard_id: int counters: dict[SiqType, int] + _test_cur_timestamp: int | None def __init__(self, domain: str, last_siq: int = 0, local_id: int | None = None, shard_id: int | None = None): self.domain_hash = make_domain_hash(domain, local_id) + self._test_cur_ts = None ## test only self.last_gen_ts = min(last_siq >> 56, self.cur_timestamp()) self.counters = dict() self.shard_id = (shard_id or os.getpid()) % 256 def cur_timestamp(self) -> int: + if self._test_cur_ts is not None: + return self._test_cur_ts return int(time.time() * (1 << 16)) + def set_cur_timestamp(self, value: datetime.datetime): + """ + Intended to be used by tests only! Do not use in production! + """ + self._test_cur_ts = int(want_timestamp(value) * 2 ** 16) + self.last_gen_ts = int(want_timestamp(value) * 2 ** 16) def generate(self, /, typ: SiqType, n: int = 1) -> Iterable[int]: """ Generate one or more SIQ's. @@ -152,7 +165,7 @@ class SiqGen: elif now > self.last_gen_ts: self.counters[typ] = 0 while n: - idseq = typ.prepend(self.counters[typ]) + idseq = typ.prepend(self.counters.setdefault(typ, 0)) if idseq >= (1 << 16): while (now := self.cur_timestamp()) <= self.last_gen_ts: time.sleep(1 / (1 << 16)) diff --git a/src/suou/luck.py b/src/suou/luck.py index 1ea9039..78b58f8 100644 --- a/src/suou/luck.py +++ b/src/suou/luck.py @@ -1,5 +1,5 @@ """ -Fortune' RNG and esoterism. +Fortune, RNG and esoterism. NEW 0.7.0 diff --git a/tests/test_iding.py b/tests/test_iding.py new file mode 100644 index 0000000..630b180 --- /dev/null +++ b/tests/test_iding.py @@ -0,0 +1,35 @@ + + +import datetime +import unittest + +from suou.iding import Siq, SiqType, SiqGen, make_domain_hash + + +class TestIding(unittest.TestCase): + def setUp(self) -> None: + ... + def tearDown(self) -> None: + ... + def test_generation(self): + gen = SiqGen('0', shard_id=256) + gen.set_cur_timestamp(datetime.datetime(2020,1,1)) + i1 = gen.generate_one(SiqType.CONTENT) + self.assertEqual(i1, 7451106619238957490390643507207) + i2_16 = gen.generate_list(SiqType.CONTENT, 15) + self.assertIsInstance(i2_16, list) + self.assertEqual(i2_16[0], i1 + 8) + self.assertEqual(i2_16[14], i1 + 120) + + gen.set_cur_timestamp(datetime.datetime(2021, 1, 1)) + i17 = gen.generate_one(SiqType.CONTENT) + self.assertEqual(i17, 7600439181106854559196223897735) + + def test_domain_hash(self): + self.assertEqual(make_domain_hash('0'), 0) + self.assertEqual(make_domain_hash('example.com'), 2261653831) + + def test_representation(self): + i1 = Siq(7451106619238957490390643507207) + self.assertEqual(i1.to_hex(), "5e0bd2f0000000000000000007") + self.assertEqual(i1.to_did(), "did:siq:iuxvojaaf4c6s6aaaaaaaaaaaaaah") \ No newline at end of file From 556019e0bdaa9ac87bfaf90127d10ce6ab946f7f Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sat, 1 Nov 2025 09:29:20 +0100 Subject: [PATCH 102/121] 0.7.5 update sqlalchemy module to require flask_sqlalchemy --- CHANGELOG.md | 7 ++++++- pyproject.toml | 7 ++++--- src/suou/__init__.py | 2 +- src/suou/glue.py | 1 + 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4ebb12..6c3b718 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.7.5 + ++ Delay release of `FakeModule` to 0.9.0 ++ Update dependencies: `.sqlalchemy` now requires `flask_sqlalchemy` regardless of use of Flask + ## 0.7.4 + Delay release of `@glue()` @@ -9,7 +14,7 @@ ## 0.7.3 + Fixed some broken imports in `.sqlalchemy` -+ Stage `@glue()` for release in 0.9.0 ++ Stage `@glue()` for release in ~~0.8.0~~ 0.9.0 + Add docs to `.sqlalchemy` ## 0.7.2 diff --git a/pyproject.toml b/pyproject.toml index d29c3df..eecfddd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,14 +38,16 @@ Documentation = "https://suou.readthedocs.io" [project.optional-dependencies] # the below are all dev dependencies (and probably already installed) sqlalchemy = [ - "SQLAlchemy[asyncio]>=2.0.0" + "SQLAlchemy[asyncio]>=2.0.0", + "flask-sqlalchemy" ] flask = [ "Flask>=2.0.0", "Flask-RestX" ] flask_sqlalchemy = [ - "Flask-SqlAlchemy", + "suou[sqlalchemy]", + "suou[flask]" ] peewee = [ ## HEADS UP! peewee has setup.py, may slow down installation @@ -70,7 +72,6 @@ full = [ "suou[quart]", "suou[peewee]", "suou[markdown]", - "suou[flask-sqlalchemy]", "suou[sass]" ] diff --git a/src/suou/__init__.py b/src/suou/__init__.py index db3e41a..28b51b3 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -37,7 +37,7 @@ from .redact import redact_url_password from .http import WantsContentType from .color import chalk -__version__ = "0.7.4" +__version__ = "0.7.5" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', diff --git a/src/suou/glue.py b/src/suou/glue.py index db08aaf..3f1a799 100644 --- a/src/suou/glue.py +++ b/src/suou/glue.py @@ -22,6 +22,7 @@ from suou.classtools import MISSING from suou.functools import future +@future(version="0.9.0") class FakeModule(ModuleType): """ Fake module used in @glue() in case of import error From 96a65c38e32a3bab3530bbea49b9e18b3f352355 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sat, 1 Nov 2025 10:05:22 +0100 Subject: [PATCH 103/121] 0.7.6 fix @glue() stray usage --- aliases/sakuragasaki46_suou/pyproject.toml | 9 +- src/suou/__init__.py | 2 +- src/suou/sqlalchemy/asyncio.py | 116 ++++++++++----------- 3 files changed, 62 insertions(+), 65 deletions(-) diff --git a/aliases/sakuragasaki46_suou/pyproject.toml b/aliases/sakuragasaki46_suou/pyproject.toml index f53eeb0..99a55fd 100644 --- a/aliases/sakuragasaki46_suou/pyproject.toml +++ b/aliases/sakuragasaki46_suou/pyproject.toml @@ -10,7 +10,7 @@ license = "Apache-2.0" readme = "README.md" dependencies = [ - "suou==0.7.4", + "suou==0.7.5", "itsdangerous", "toml", "pydantic", @@ -39,14 +39,16 @@ Documentation = "https://suou.readthedocs.io" [project.optional-dependencies] # the below are all dev dependencies (and probably already installed) sqlalchemy = [ - "SQLAlchemy[asyncio]>=2.0.0" + "SQLAlchemy[asyncio]>=2.0.0", + "flask-sqlalchemy" ] flask = [ "Flask>=2.0.0", "Flask-RestX" ] flask_sqlalchemy = [ - "Flask-SqlAlchemy", + "sakuragasaki46_suou[sqlalchemy]", + "sakuragasaki46_suou[flask]" ] peewee = [ ## HEADS UP! peewee has setup.py, may slow down installation @@ -71,7 +73,6 @@ full = [ "sakuragasaki46_suou[quart]", "sakuragasaki46_suou[peewee]", "sakuragasaki46_suou[markdown]", - "sakuragasaki46_suou[flask-sqlalchemy]", "sakuragasaki46_suou[sass]" ] diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 28b51b3..e52b765 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -37,7 +37,7 @@ from .redact import redact_url_password from .http import WantsContentType from .color import chalk -__version__ = "0.7.5" +__version__ = "0.7.6" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', diff --git a/src/suou/sqlalchemy/asyncio.py b/src/suou/sqlalchemy/asyncio.py index 605ec93..db090be 100644 --- a/src/suou/sqlalchemy/asyncio.py +++ b/src/suou/sqlalchemy/asyncio.py @@ -119,76 +119,72 @@ class SQLAlchemy: # XXX NOT public API! DO NOT USE current_session: ContextVar[AsyncSession] = ContextVar('current_session') -## experimental -@glue('flask_sqlalchemy') -def _make_AsyncSelectPagination(flask_sqlalchemy): - class AsyncSelectPagination(flask_sqlalchemy.pagination.Pagination): - """ - flask_sqlalchemy.SelectPagination but asynchronous. - Pagination is not part of the public API, therefore expect that it may break - """ - async def _query_items(self) -> list: - select_q: Select = self._query_args["select"] - select = select_q.limit(self.per_page).offset(self._query_offset) - session: AsyncSession = self._query_args["session"] - out = (await session.execute(select)).scalars() - return out - async def _query_count(self) -> int: - select_q: Select = self._query_args["select"] - sub = select_q.options(lazyload("*")).order_by(None).subquery() - session: AsyncSession = self._query_args["session"] - out = (await session.execute(select(func.count()).select_from(sub))).scalar() - return out +class AsyncSelectPagination(flask_sqlalchemy.pagination.Pagination): + """ + flask_sqlalchemy.SelectPagination but asynchronous. - def __init__(self, - page: int | None = None, - per_page: int | None = None, - max_per_page: int | None = 100, - error_out: Exception | None = NotFoundError, - count: bool = True, - **kwargs): - ## XXX flask-sqlalchemy says Pagination() is not public API. - ## Things may break; beware. - self._query_args = kwargs - page, per_page = self._prepare_page_args( - page=page, - per_page=per_page, - max_per_page=max_per_page, - error_out=error_out, - ) + Pagination is not part of the public API, therefore expect that it may break + """ - self.page: int = page - """The current page.""" + async def _query_items(self) -> list: + select_q: Select = self._query_args["select"] + select = select_q.limit(self.per_page).offset(self._query_offset) + session: AsyncSession = self._query_args["session"] + out = (await session.execute(select)).scalars() + return out - self.per_page: int = per_page - """The maximum number of items on a page.""" + async def _query_count(self) -> int: + select_q: Select = self._query_args["select"] + sub = select_q.options(lazyload("*")).order_by(None).subquery() + session: AsyncSession = self._query_args["session"] + out = (await session.execute(select(func.count()).select_from(sub))).scalar() + return out - self.max_per_page: int | None = max_per_page - """The maximum allowed value for ``per_page``.""" + def __init__(self, + page: int | None = None, + per_page: int | None = None, + max_per_page: int | None = 100, + error_out: Exception | None = NotFoundError, + count: bool = True, + **kwargs): + ## XXX flask-sqlalchemy says Pagination() is not public API. + ## Things may break; beware. + self._query_args = kwargs + page, per_page = self._prepare_page_args( + page=page, + per_page=per_page, + max_per_page=max_per_page, + error_out=error_out, + ) - self.items = None - self.total = None - self.error_out = error_out - self.has_count = count + self.page: int = page + """The current page.""" - async def __aiter__(self): - self.items = await self._query_items() - if self.items is None: - raise RuntimeError('query returned None') - if not self.items and self.page != 1 and self.error_out: - raise self.error_out - if self.has_count: - self.total = await self._query_count() - for i in self.items: - yield i + self.per_page: int = per_page + """The maximum number of items on a page.""" - return AsyncSelectPagination + self.max_per_page: int | None = max_per_page + """The maximum allowed value for ``per_page``.""" + + self.items = None + self.total = None + self.error_out = error_out + self.has_count = count + + async def __aiter__(self): + self.items = await self._query_items() + if self.items is None: + raise RuntimeError('query returned None') + if not self.items and self.page != 1 and self.error_out: + raise self.error_out + if self.has_count: + self.total = await self._query_count() + for i in self.items: + yield i -AsyncSelectPagination = _make_AsyncSelectPagination() -del _make_AsyncSelectPagination def async_query(db: SQLAlchemy, multi: False): @@ -256,4 +252,4 @@ class SessionWrapper: return getattr(self._session, key) # Optional dependency: do not import into __init__.py -__all__ = ('SQLAlchemy', 'AsyncSelectPagination', 'async_query', 'SessionWrapper') \ No newline at end of file +__all__ = ('SQLAlchemy', 'AsyncSelectPagination', 'async_query', 'SessionWrapper') From 0ca2fde687fd306e7c96ef3f8297b3686e51ba88 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sat, 1 Nov 2025 22:43:07 +0100 Subject: [PATCH 104/121] 0.7.7 forgot what the actual fix was --- aliases/sakuragasaki46_suou/pyproject.toml | 2 +- src/suou/__init__.py | 2 +- src/suou/sqlalchemy/asyncio.py | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/aliases/sakuragasaki46_suou/pyproject.toml b/aliases/sakuragasaki46_suou/pyproject.toml index 99a55fd..91b035a 100644 --- a/aliases/sakuragasaki46_suou/pyproject.toml +++ b/aliases/sakuragasaki46_suou/pyproject.toml @@ -10,7 +10,7 @@ license = "Apache-2.0" readme = "README.md" dependencies = [ - "suou==0.7.5", + "suou==0.7.6", "itsdangerous", "toml", "pydantic", diff --git a/src/suou/__init__.py b/src/suou/__init__.py index e52b765..7411deb 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -37,7 +37,7 @@ from .redact import redact_url_password from .http import WantsContentType from .color import chalk -__version__ = "0.7.6" +__version__ = "0.7.7" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', diff --git a/src/suou/sqlalchemy/asyncio.py b/src/suou/sqlalchemy/asyncio.py index db090be..43a9cef 100644 --- a/src/suou/sqlalchemy/asyncio.py +++ b/src/suou/sqlalchemy/asyncio.py @@ -25,6 +25,7 @@ from typing import Callable, TypeVar from sqlalchemy import Select, Table, func, select from sqlalchemy.orm import DeclarativeBase, lazyload from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine +from flask_sqlalchemy.pagination import Pagination from suou.exceptions import NotFoundError from suou.glue import glue @@ -122,7 +123,7 @@ current_session: ContextVar[AsyncSession] = ContextVar('current_session') -class AsyncSelectPagination(flask_sqlalchemy.pagination.Pagination): +class AsyncSelectPagination(Pagination): """ flask_sqlalchemy.SelectPagination but asynchronous. From 4a31fbc14f7cbe89351c83d189032655421302dd Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Wed, 5 Nov 2025 10:47:08 +0100 Subject: [PATCH 105/121] 0.8.0 improve (experimental) Waiter + add sqlalchemy.username_column() --- CHANGELOG.md | 9 +++++++++ aliases/sakuragasaki46_suou/pyproject.toml | 2 +- docs/sqlalchemy.rst | 2 ++ src/suou/__init__.py | 2 +- src/suou/sqlalchemy/__init__.py | 6 +++--- src/suou/sqlalchemy/orm.py | 1 - src/suou/waiter.py | 23 ++++++++++++++++++++++ 7 files changed, 39 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c3b718..16c47fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.8.0 + ++ Add `username_column()` to `.sqlalchemy` ++ Improve (experimental) `Waiter` + +## 0.7.7 + ++ Fix imports in `.sqlalchemy` + ## 0.7.5 + Delay release of `FakeModule` to 0.9.0 diff --git a/aliases/sakuragasaki46_suou/pyproject.toml b/aliases/sakuragasaki46_suou/pyproject.toml index 91b035a..6764e6f 100644 --- a/aliases/sakuragasaki46_suou/pyproject.toml +++ b/aliases/sakuragasaki46_suou/pyproject.toml @@ -10,7 +10,7 @@ license = "Apache-2.0" readme = "README.md" dependencies = [ - "suou==0.7.6", + "suou==0.7.7", "itsdangerous", "toml", "pydantic", diff --git a/docs/sqlalchemy.rst b/docs/sqlalchemy.rst index a1a78ac..197ebe1 100644 --- a/docs/sqlalchemy.rst +++ b/docs/sqlalchemy.rst @@ -25,6 +25,8 @@ Columns .. autofunction:: bool_column +.. autofunction:: username_column + .. autofunction:: unbound_fk .. autofunction:: bound_fk diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 7411deb..a0f3a65 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -37,7 +37,7 @@ from .redact import redact_url_password from .http import WantsContentType from .color import chalk -__version__ = "0.7.7" +__version__ = "0.8.0" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', diff --git a/src/suou/sqlalchemy/__init__.py b/src/suou/sqlalchemy/__init__.py index 7794f39..4b606fc 100644 --- a/src/suou/sqlalchemy/__init__.py +++ b/src/suou/sqlalchemy/__init__.py @@ -113,7 +113,7 @@ class AuthSrc(metaclass=ABCMeta): pass -@deprecated('not working and too complex to use') +@deprecated('not working and too complex to use. Will be removed in 0.9.0') def require_auth_base(cls: type[DeclarativeBase], *, src: AuthSrc, column: str | Column[_T] = 'id', dest: str = 'user', required: bool = False, signed: bool = False, sig_dest: str = 'signature', validators: Callable | Iterable[Callable] | None = None): ''' @@ -161,7 +161,7 @@ def require_auth_base(cls: type[DeclarativeBase], *, src: AuthSrc, column: str | from .asyncio import SQLAlchemy, AsyncSelectPagination, async_query from .orm import ( id_column, snowflake_column, match_column, match_constraint, bool_column, declarative_base, parent_children, - author_pair, age_pair, bound_fk, unbound_fk, want_column, a_relationship, BitSelector, secret_column + author_pair, age_pair, bound_fk, unbound_fk, want_column, a_relationship, BitSelector, secret_column, username_column ) # Optional dependency: do not import into __init__.py @@ -169,7 +169,7 @@ __all__ = ( 'IdType', 'id_column', 'snowflake_column', 'entity_base', 'declarative_base', 'token_signer', 'match_column', 'match_constraint', 'bool_column', 'parent_children', 'author_pair', 'age_pair', 'bound_fk', 'unbound_fk', 'want_column', - 'a_relationship', 'BitSelector', 'secret_column', + 'a_relationship', 'BitSelector', 'secret_column', 'username_column', # .asyncio 'SQLAlchemy', 'AsyncSelectPagination', 'async_query', 'SessionWrapper' ) \ No newline at end of file diff --git a/src/suou/sqlalchemy/orm.py b/src/suou/sqlalchemy/orm.py index 37b4def..9e0ee91 100644 --- a/src/suou/sqlalchemy/orm.py +++ b/src/suou/sqlalchemy/orm.py @@ -115,7 +115,6 @@ def match_column(length: int, regex: str | re.Pattern, /, case: StringCase = Str constraint_name=constraint_name or f'{x.__tablename__}_{n}_valid')), *args, **kwargs) -@future(version='0.8.0') def username_column( length: int = 32, regex: str | re.Pattern = '[a-z_][a-z0-9_-]+', *args, case: StringCase = StringCase.LOWER, nullable : bool = False, **kwargs) -> Incomplete[Column[str] | Column[str | None]]: diff --git a/src/suou/waiter.py b/src/suou/waiter.py index 897062f..51e4590 100644 --- a/src/suou/waiter.py +++ b/src/suou/waiter.py @@ -16,11 +16,13 @@ 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 +from suou.itertools import makelist from suou.functools import future @future() @@ -35,6 +37,27 @@ class Waiter(): routes= self.routes ) + def get(self, endpoint: str, *a, **k): + return self._route('GET', endpoint, *a, **k) + + def post(self, endpoint: str, *a, **k): + return self._route('POST', endpoint, *a, **k) + + def delete(self, endpoint: str, *a, **k): + return self._route('DELETE', endpoint, *a, **k) + + def put(self, endpoint: str, *a, **k): + return self._route('PUT', endpoint, *a, **k) + + def patch(self, endpoint: str, *a, **k): + return self._route('PATCH', endpoint, *a, **k) + + def _route(self, methods: list[str], endpoint: str, **kwargs): + def decorator(func): + self.routes.append(Route(endpoint, func, methods=makelist(methods, False), **kwargs)) + return func + return decorator + ## TODO get, post, etc. def ok(content = None, **ka): From 9471fc338f47f6c2fd1f99918428628f30ec955c Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Wed, 5 Nov 2025 18:08:49 +0100 Subject: [PATCH 106/121] 0.8.1 missing type guard in *bound_fk() --- aliases/sakuragasaki46_suou/pyproject.toml | 2 +- src/suou/__init__.py | 2 +- src/suou/sqlalchemy/orm.py | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/aliases/sakuragasaki46_suou/pyproject.toml b/aliases/sakuragasaki46_suou/pyproject.toml index 6764e6f..39ad228 100644 --- a/aliases/sakuragasaki46_suou/pyproject.toml +++ b/aliases/sakuragasaki46_suou/pyproject.toml @@ -10,7 +10,7 @@ license = "Apache-2.0" readme = "README.md" dependencies = [ - "suou==0.7.7", + "suou==0.8.0", "itsdangerous", "toml", "pydantic", diff --git a/src/suou/__init__.py b/src/suou/__init__.py index a0f3a65..342e5c7 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -37,7 +37,7 @@ from .redact import redact_url_password from .http import WantsContentType from .color import chalk -__version__ = "0.8.0" +__version__ = "0.8.1" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', diff --git a/src/suou/sqlalchemy/orm.py b/src/suou/sqlalchemy/orm.py index 9e0ee91..05271eb 100644 --- a/src/suou/sqlalchemy/orm.py +++ b/src/suou/sqlalchemy/orm.py @@ -255,6 +255,8 @@ def unbound_fk(target: str | Column | InstrumentedAttribute, typ: _T | None = No target_name = target if typ is None: typ = IdType + else: + raise TypeError('target must be a str, a Column or a InstrumentedAttribute') return Column(typ, ForeignKey(target_name, ondelete='SET NULL'), nullable=True, **kwargs) @@ -276,6 +278,8 @@ def bound_fk(target: str | Column | InstrumentedAttribute, typ: _T = None, **kwa target_name = target if typ is None: typ = IdType + else: + raise TypeError('target must be a str, a Column or a InstrumentedAttribute') return Column(typ, ForeignKey(target_name, ondelete='CASCADE'), nullable=False, **kwargs) From 305f193f93b1d905132288fcec13335d1bef6d9d Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Mon, 10 Nov 2025 17:18:13 +0100 Subject: [PATCH 107/121] 0.8.2 fix chalk behavior --- CHANGELOG.md | 8 +++++++ aliases/sakuragasaki46_suou/pyproject.toml | 2 +- src/suou/__init__.py | 5 ++-- src/suou/color.py | 14 +++++++++++ tests/test_color.py | 27 ++++++++++++++++++++++ 5 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 tests/test_color.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 16c47fd..58ef000 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.8.2 and 0.7.9 + ++ `.color`: fix `chalk` not behaving as expected + +## 0.8.1 and 0.7.8 + ++ Fix missing type guard in `unbound_fk()` and `bound_fk()` + ## 0.8.0 + Add `username_column()` to `.sqlalchemy` diff --git a/aliases/sakuragasaki46_suou/pyproject.toml b/aliases/sakuragasaki46_suou/pyproject.toml index 39ad228..fae9d91 100644 --- a/aliases/sakuragasaki46_suou/pyproject.toml +++ b/aliases/sakuragasaki46_suou/pyproject.toml @@ -10,7 +10,7 @@ license = "Apache-2.0" readme = "README.md" dependencies = [ - "suou==0.8.0", + "suou==0.8.1", "itsdangerous", "toml", "pydantic", diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 342e5c7..a74a37a 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -35,9 +35,9 @@ from .strtools import PrefixIdentifier from .validators import matches from .redact import redact_url_password from .http import WantsContentType -from .color import chalk +from .color import chalk, WebColor -__version__ = "0.8.1" +__version__ = "0.8.2" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', @@ -45,6 +45,7 @@ __all__ = ( 'MissingConfigError', 'MissingConfigWarning', 'PrefixIdentifier', 'Siq', 'SiqCache', 'SiqGen', 'SiqType', 'Snowflake', 'SnowflakeGen', 'StringCase', 'TimedDict', 'TomlI18n', 'UserSigner', 'Wanted', 'WantsContentType', + 'WebColor', 'addattr', 'additem', 'age_and_days', 'alru_cache', 'b2048decode', 'b2048encode', 'b32ldecode', 'b32lencode', 'b64encode', 'b64decode', 'cb32encode', 'cb32decode', 'chalk', 'count_ones', 'dei_args', 'deprecated', diff --git a/src/suou/color.py b/src/suou/color.py index 07241ba..633bfaa 100644 --- a/src/suou/color.py +++ b/src/suou/color.py @@ -55,24 +55,34 @@ class Chalk: return Chalk(self._flags + (beg,), self._ends + (end,)) def __call__(self, s: str) -> str: return ''.join(self._flags) + s + ''.join(reversed(self._ends)) + @property def red(self): return self._wrap(self.RED, self.END_COLOR) + @property def green(self): return self._wrap(self.GREEN, self.END_COLOR) + @property def blue(self): return self._wrap(self.BLUE, self.END_COLOR) + @property def yellow(self): return self._wrap(self.YELLOW, self.END_COLOR) + @property def cyan(self): return self._wrap(self.CYAN, self.END_COLOR) + @property def purple(self): return self._wrap(self.PURPLE, self.END_COLOR) + @property def grey(self): return self._wrap(self.GREY, self.END_COLOR) gray = grey marine = blue + magenta = purple + @property def bold(self): return self._wrap(self.BOLD, self.END_BOLD) + @property def faint(self): return self._wrap(self.FAINT, self.END_BOLD) @@ -130,3 +140,7 @@ class WebColor(namedtuple('_WebColor', 'red green blue')): def __str__(self): return f"rgb({self.red}, {self.green}, {self.blue})" + + +__all__ = ('chalk', 'WebColor') + diff --git a/tests/test_color.py b/tests/test_color.py new file mode 100644 index 0000000..9b20478 --- /dev/null +++ b/tests/test_color.py @@ -0,0 +1,27 @@ + + + +import unittest +from suou import chalk + +class TestColor(unittest.TestCase): + def setUp(self) -> None: + ... + def tearDown(self) -> None: + ... + + def test_chalk_colors(self): + strg = "The quick brown fox jumps over the lazy dog" + + self.assertEqual(f'\x1b[31m{strg}\x1b[39m', chalk.red(strg)) + self.assertEqual(f'\x1b[32m{strg}\x1b[39m', chalk.green(strg)) + self.assertEqual(f'\x1b[34m{strg}\x1b[39m', chalk.blue(strg)) + self.assertEqual(f'\x1b[36m{strg}\x1b[39m', chalk.cyan(strg)) + self.assertEqual(f'\x1b[33m{strg}\x1b[39m', chalk.yellow(strg)) + self.assertEqual(f'\x1b[35m{strg}\x1b[39m', chalk.purple(strg)) + + def test_chalk_bold(self): + strg = "The quick brown fox jumps over the lazy dog" + self.assertEqual(f'\x1b[1m{strg}\x1b[22m', chalk.bold(strg)) + self.assertEqual(f'\x1b[2m{strg}\x1b[22m', chalk.faint(strg)) + self.assertEqual(f'\x1b[1m\x1b[33m{strg}\x1b[39m\x1b[22m', chalk.bold.yellow(strg)) \ No newline at end of file From 8e3da632169de68271e84c201bf85ec88af2e01c Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Mon, 10 Nov 2025 17:29:35 +0100 Subject: [PATCH 108/121] CD/CI gitignore fix --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5736b26..5f9c2fc 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,7 @@ ROADMAP.md aliases/*/src docs/_build docs/_static -docs/templates \ No newline at end of file +docs/templates + +# changes during CD/CI +aliases/*/pyproject.toml \ No newline at end of file From f1f9a9518919170421aa1b982f7de1b7eed12553 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Mon, 10 Nov 2025 17:30:48 +0100 Subject: [PATCH 109/121] CD/CI gitignore fix pt.2 --- aliases/sakuragasaki46_suou/pyproject.toml | 87 ---------------------- 1 file changed, 87 deletions(-) delete mode 100644 aliases/sakuragasaki46_suou/pyproject.toml diff --git a/aliases/sakuragasaki46_suou/pyproject.toml b/aliases/sakuragasaki46_suou/pyproject.toml deleted file mode 100644 index fae9d91..0000000 --- a/aliases/sakuragasaki46_suou/pyproject.toml +++ /dev/null @@ -1,87 +0,0 @@ -[project] -name = "sakuragasaki46_suou" -description = "casual utility library for coding QoL" -authors = [ - { name = "Sakuragasaki46" } -] -dynamic = [ "version" ] -requires-python = ">=3.10" -license = "Apache-2.0" -readme = "README.md" - -dependencies = [ - "suou==0.8.1", - "itsdangerous", - "toml", - "pydantic", - "setuptools>=78.0.0", - "uvloop; os_name=='posix'" -] -# - further devdependencies below - # - -# - publishing - -classifiers = [ - "Development Status :: 2 - Pre-Alpha", - - # actively supported Pythons - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.14" -] - -[project.urls] -Repository = "https://nekode.yusur.moe/yusur/suou" -Documentation = "https://suou.readthedocs.io" - -[project.optional-dependencies] -# the below are all dev dependencies (and probably already installed) -sqlalchemy = [ - "SQLAlchemy[asyncio]>=2.0.0", - "flask-sqlalchemy" -] -flask = [ - "Flask>=2.0.0", - "Flask-RestX" -] -flask_sqlalchemy = [ - "sakuragasaki46_suou[sqlalchemy]", - "sakuragasaki46_suou[flask]" -] -peewee = [ - ## HEADS UP! peewee has setup.py, may slow down installation - "peewee>=3.0.0" -] -markdown = [ - "markdown>=3.0.0" -] -quart = [ - "Quart", - "Quart-Schema", - "starlette>=0.47.2" -] -sass = [ - ## HEADS UP!! libsass carries a C extension + uses setup.py - "libsass" -] - -full = [ - "sakuragasaki46_suou[sqlalchemy]", - "sakuragasaki46_suou[flask]", - "sakuragasaki46_suou[quart]", - "sakuragasaki46_suou[peewee]", - "sakuragasaki46_suou[markdown]", - "sakuragasaki46_suou[sass]" -] - -docs = [ - "sphinx>=2.1", - "myst_parser", - "sphinx_rtd_theme" -] - - -[tool.setuptools.dynamic] -version = { attr = "suou.__version__" } From def2634f217eeb0d6c3552ac153d1de4c43e8908 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sun, 16 Nov 2025 10:34:49 +0100 Subject: [PATCH 110/121] 0.9.0 add yesno() + make Waiter usable + document validators --- CHANGELOG.md | 7 +++++++ README.md | 4 ++-- docs/index.rst | 1 + docs/validators.rst | 15 +++++++++++++++ src/suou/__init__.py | 9 +++++---- src/suou/calendar.py | 1 - src/suou/glue.py | 6 +++--- src/suou/validators.py | 14 +++++++++++++- src/suou/waiter.py | 18 +++++++++++++----- tests/test_validators.py | 24 ++++++++++++++++++++++++ 10 files changed, 83 insertions(+), 16 deletions(-) create mode 100644 docs/validators.rst create mode 100644 tests/test_validators.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 58ef000..150dfde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.9.0 + ++ Fix to make experimental `Waiter` usable ++ Suspend `glue()` release indefinitely ++ Add `yesno()` ++ Document validators + ## 0.8.2 and 0.7.9 + `.color`: fix `chalk` not behaving as expected diff --git a/README.md b/README.md index 5b9a797..3a08b1e 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,11 @@ Please note that you probably already have those dependencies, if you just use t ## Features -... +Read the [documentation](https://suou.readthedocs.io/). ## Support -Just a heads up: SUOU was made to support Sakuragasaki46 (me)'s own selfish, egoistic needs. Not to provide a service to the public. +Just a heads up: SUOU was made to support Sakuragasaki46 (me)'s own selfish, egoistic needs. Not certainly to provide a service to the public. As a consequence, 'add this add that' stuff is best-effort. diff --git a/docs/index.rst b/docs/index.rst index 12e5d40..9c3d855 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -15,4 +15,5 @@ ease programmer's QoL and write shorter and cleaner code that works. sqlalchemy iding + validators api \ No newline at end of file diff --git a/docs/validators.rst b/docs/validators.rst new file mode 100644 index 0000000..e878900 --- /dev/null +++ b/docs/validators.rst @@ -0,0 +1,15 @@ + +validators +================== + +.. currentmodule:: suou.validators + +Validators for use in frameworks such as Pydantic or Marshmallow. + +.. autofunction:: matches + +.. autofunction:: not_greater_than + +.. autofunction:: not_less_than + +.. autofunction:: yesno \ No newline at end of file diff --git a/src/suou/__init__.py b/src/suou/__init__.py index a74a37a..a7c96c8 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -32,12 +32,12 @@ from .signing import UserSigner from .snowflake import Snowflake, SnowflakeGen from .lex import symbol_table, lex, ilex from .strtools import PrefixIdentifier -from .validators import matches +from .validators import matches, not_less_than, not_greater_than, yesno from .redact import redact_url_password from .http import WantsContentType from .color import chalk, WebColor -__version__ = "0.8.2" +__version__ = "0.9.0" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', @@ -51,9 +51,10 @@ __all__ = ( 'cb32decode', 'chalk', 'count_ones', 'dei_args', 'deprecated', 'future', 'ilex', 'join_bits', 'jsonencode', 'kwargs_prefix', 'lex', 'ltuple', 'makelist', 'mask_shift', - 'matches', 'mod_ceil', 'mod_floor', 'none_pass', 'not_implemented', + 'matches', 'mod_ceil', 'mod_floor', 'must_be', 'none_pass', 'not_implemented', + 'not_less_than', 'not_greater_than', 'redact_url_password', 'rtuple', 'split_bits', 'ssv_list', 'symbol_table', 'timed_cache', 'twocolon_list', 'want_bytes', 'want_datetime', 'want_isodate', - 'want_str', 'want_timestamp', 'want_urlsafe', 'want_urlsafe_bytes', + 'want_str', 'want_timestamp', 'want_urlsafe', 'want_urlsafe_bytes', 'yesno', 'z85encode', 'z85decode' ) diff --git a/src/suou/calendar.py b/src/suou/calendar.py index d2af051..f738b88 100644 --- a/src/suou/calendar.py +++ b/src/suou/calendar.py @@ -17,7 +17,6 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. import datetime -from suou.functools import not_implemented from suou.luck import lucky from suou.validators import not_greater_than diff --git a/src/suou/glue.py b/src/suou/glue.py index 3f1a799..6368deb 100644 --- a/src/suou/glue.py +++ b/src/suou/glue.py @@ -22,7 +22,7 @@ from suou.classtools import MISSING from suou.functools import future -@future(version="0.9.0") +@future() class FakeModule(ModuleType): """ Fake module used in @glue() in case of import error @@ -34,12 +34,12 @@ class FakeModule(ModuleType): raise AttributeError(f'Module {self.__name__} not found; this feature is not available ({self._exc})') from self._exc -@future(version = "0.9.0") +@future() def glue(*modules): """ Helper for "glue" code -- it imports the given modules and passes them as keyword arguments to the wrapped functions. - NEW 0.9.0 + EXPERIMENTAL """ module_dict = dict() imports_succeeded = True diff --git a/src/suou/validators.py b/src/suou/validators.py index 53a7be3..e8b366f 100644 --- a/src/suou/validators.py +++ b/src/suou/validators.py @@ -18,6 +18,10 @@ import re from typing import Any, Iterable, TypeVar +from suou.classtools import MISSING + +from .functools import future + _T = TypeVar('_T') def matches(regex: str | int, /, length: int = 0, *, flags=0): @@ -55,5 +59,13 @@ def not_less_than(y): """ return lambda x: x >= y -__all__ = ('matches', 'not_greater_than') +def yesno(x: str) -> bool: + """ + Returns False if x.lower() is in '0', '', 'no', 'n', 'false' or 'off'. + + *New in 0.9.0* + """ + return x not in (None, MISSING) and x.lower() not in ('', '0', 'off', 'n', 'no', 'false', 'f') + +__all__ = ('matches', 'must_be', 'not_greater_than', 'not_less_than', 'yesno') diff --git a/src/suou/waiter.py b/src/suou/waiter.py index 51e4590..a959c88 100644 --- a/src/suou/waiter.py +++ b/src/suou/waiter.py @@ -17,6 +17,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. """ +from typing import Callable import warnings from starlette.applications import Starlette from starlette.responses import JSONResponse, PlainTextResponse, Response @@ -27,15 +28,22 @@ from suou.functools import future @future() class Waiter(): + _cached_app: Callable | None = None + def __init__(self): self.routes: list[Route] = [] self.production = False - + + async def __call__(self, *args): + return await self._build_app()(*args) + def _build_app(self) -> Starlette: - return Starlette( - debug = not self.production, - routes= self.routes - ) + if not self._cached_app: + self._cached_app = Starlette( + debug = not self.production, + routes= self.routes + ) + return self._cached_app def get(self, endpoint: str, *a, **k): return self._route('GET', endpoint, *a, **k) diff --git a/tests/test_validators.py b/tests/test_validators.py new file mode 100644 index 0000000..0064128 --- /dev/null +++ b/tests/test_validators.py @@ -0,0 +1,24 @@ + + +import unittest +from suou.validators import yesno + +class TestValidators(unittest.TestCase): + def setUp(self): + ... + def tearDown(self): + ... + def test_yesno(self): + self.assertFalse(yesno('false')) + self.assertFalse(yesno('FALSe')) + self.assertTrue(yesno('fasle')) + self.assertTrue(yesno('falso')) + self.assertTrue(yesno('zero')) + self.assertTrue(yesno('true')) + self.assertFalse(yesno('0')) + self.assertTrue(yesno('00')) + self.assertTrue(yesno('.')) + self.assertTrue(yesno('2')) + self.assertTrue(yesno('o')) + self.assertFalse(yesno('oFF')) + self.assertFalse(yesno('no')) \ No newline at end of file From 5c9a6f2c7e62500a0dce9ffc2e09c906cf43089b Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sun, 23 Nov 2025 19:13:14 +0100 Subject: [PATCH 111/121] 0.10.0 add peewee.SnowflakeField() --- CHANGELOG.md | 4 ++++ src/suou/__init__.py | 2 +- src/suou/peewee.py | 27 ++++++++++++++++++++++++--- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 150dfde..56d279b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.10.0 + ++ `peewee`: add `SnowflakeField` class + ## 0.9.0 + Fix to make experimental `Waiter` usable diff --git a/src/suou/__init__.py b/src/suou/__init__.py index a7c96c8..df20b7b 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -37,7 +37,7 @@ from .redact import redact_url_password from .http import WantsContentType from .color import chalk, WebColor -__version__ = "0.9.0" +__version__ = "0.10.0" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', diff --git a/src/suou/peewee.py b/src/suou/peewee.py index f1a3f1e..830ffaf 100644 --- a/src/suou/peewee.py +++ b/src/suou/peewee.py @@ -18,10 +18,11 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. from contextvars import ContextVar from typing import Iterable from playhouse.shortcuts import ReconnectMixin -from peewee import CharField, Database, MySQLDatabase, _ConnectionState +from peewee import BigIntegerField, CharField, Database, MySQLDatabase, _ConnectionState import re from suou.iding import Siq +from suou.snowflake import Snowflake from .codecs import StringCase @@ -117,6 +118,26 @@ class SiqField(Field): def python_value(self, value: bytes) -> Siq: return Siq.from_bytes(value) -# Optional dependency: do not import into __init__.py -__all__ = ('connect_reconnect', 'RegexCharField', 'SiqField') + +class SnowflakeField(BigIntegerField): + ''' + Field holding a snowflake. + + Stored as bigint. + + XXX UNTESTED! + ''' + field_type = 'bigint' + + def db_value(self, value: int | Snowflake) -> int: + if isinstance(value, Snowflake): + value = int(value) + if not isinstance(value, int): + raise TypeError + return value + def python_value(self, value: int) -> Snowflake: + return Snowflake(value) + +# Optional dependency: do not import into __init__.py +__all__ = ('connect_reconnect', 'RegexCharField', 'SiqField', 'Snowflake') From 7e6a46c654e5eb58e715bd49053c936a764fb190 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sun, 23 Nov 2025 21:52:01 +0100 Subject: [PATCH 112/121] 0.10.1 fix missing imports --- CHANGELOG.md | 4 ++++ src/suou/__init__.py | 2 +- src/suou/peewee.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56d279b..aa40a97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.10.1 and 0.7.10 + ++ `peewee`: fix missing imports + ## 0.10.0 + `peewee`: add `SnowflakeField` class diff --git a/src/suou/__init__.py b/src/suou/__init__.py index df20b7b..a5a166b 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -37,7 +37,7 @@ from .redact import redact_url_password from .http import WantsContentType from .color import chalk, WebColor -__version__ = "0.10.0" +__version__ = "0.10.1" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', diff --git a/src/suou/peewee.py b/src/suou/peewee.py index 830ffaf..2ef623c 100644 --- a/src/suou/peewee.py +++ b/src/suou/peewee.py @@ -18,7 +18,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. from contextvars import ContextVar from typing import Iterable from playhouse.shortcuts import ReconnectMixin -from peewee import BigIntegerField, CharField, Database, MySQLDatabase, _ConnectionState +from peewee import BigIntegerField, CharField, Database, Field, MySQLDatabase, _ConnectionState import re from suou.iding import Siq From 855299c6d5d7b29c959e01f99d8bc78cab73e55c Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Thu, 27 Nov 2025 19:50:33 +0100 Subject: [PATCH 113/121] 0.10.2 fix types on cb32decode() --- CHANGELOG.md | 4 ++++ src/suou/__init__.py | 2 +- src/suou/codecs.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa40a97..75183e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.10.2 and 0.7.11 + ++ fix incorrect types on `cb32decode()` + ## 0.10.1 and 0.7.10 + `peewee`: fix missing imports diff --git a/src/suou/__init__.py b/src/suou/__init__.py index a5a166b..fd467bc 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -37,7 +37,7 @@ from .redact import redact_url_password from .http import WantsContentType from .color import chalk, WebColor -__version__ = "0.10.1" +__version__ = "0.10.2" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', diff --git a/src/suou/codecs.py b/src/suou/codecs.py index c617160..043af57 100644 --- a/src/suou/codecs.py +++ b/src/suou/codecs.py @@ -179,7 +179,7 @@ def cb32encode(val: bytes) -> str: ''' return want_str(base64.b32encode(val)).translate(B32_TO_CROCKFORD) -def cb32decode(val: bytes | str) -> str: +def cb32decode(val: bytes | str) -> bytes: ''' Decode bytes from Crockford Base32. ''' From 04600628672d619015ec7b1600ec34a09957870b Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 28 Nov 2025 10:21:26 +0100 Subject: [PATCH 114/121] 0.11.0 wrap SQLAlchemy() sessions by default, add Lawyer(), SpitText(), cb32lencode(), more Snowflake.from_*(), docstring changes --- CHANGELOG.md | 10 +++++ src/suou/__init__.py | 2 +- src/suou/codecs.py | 6 +++ src/suou/collections.py | 2 +- src/suou/color.py | 4 +- src/suou/configparse.py | 5 ++- src/suou/flask_sqlalchemy.py | 24 ++---------- src/suou/functools.py | 8 ++-- src/suou/itertools.py | 6 ++- src/suou/legal.py | 55 ++++++++++++++++++++++++-- src/suou/luck.py | 8 ++-- src/suou/redact.py | 4 +- src/suou/snowflake.py | 69 +++++++++++++++++++++------------ src/suou/sqlalchemy/__init__.py | 4 +- src/suou/sqlalchemy/asyncio.py | 23 ++++++----- src/suou/sqlalchemy/orm.py | 20 +++++----- src/suou/sqlalchemy_async.py | 2 +- src/suou/strtools.py | 13 +++++++ src/suou/terminal.py | 2 +- src/suou/waiter.py | 2 +- tests/test_legal.py | 42 ++++++++++++++++++++ 21 files changed, 220 insertions(+), 91 deletions(-) create mode 100644 tests/test_legal.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 75183e2..cd0115b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.11.0 + ++ **Breaking**: sessions returned by `SQLAlchemy()` are now wrapped by default. Restore original behavior by passing `wrap=False` to the constructor or to `begin()` ++ Slate unused `require_auth()` and derivatives for removal in 0.14.0 ++ Add `cb32lencode()` ++ `Snowflake()`: add `.from_cb32()`, `.from_base64()`, `.from_oct()`, `.from_hex()` classmethods ++ Add `SpitText()` ++ Add `Lawyer()` with seven methods ++ Style changes to docstrings + ## 0.10.2 and 0.7.11 + fix incorrect types on `cb32decode()` diff --git a/src/suou/__init__.py b/src/suou/__init__.py index fd467bc..63b6d18 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -37,7 +37,7 @@ from .redact import redact_url_password from .http import WantsContentType from .color import chalk, WebColor -__version__ = "0.10.2" +__version__ = "0.11.0" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', diff --git a/src/suou/codecs.py b/src/suou/codecs.py index 043af57..b5ac9b7 100644 --- a/src/suou/codecs.py +++ b/src/suou/codecs.py @@ -179,6 +179,12 @@ def cb32encode(val: bytes) -> str: ''' return want_str(base64.b32encode(val)).translate(B32_TO_CROCKFORD) +def cb32lencode(val: bytes) -> str: + ''' + Encode bytes in Crockford Base32, lowercased. + ''' + return want_str(base64.b32encode(val)).translate(B32_TO_CROCKFORD).lower() + def cb32decode(val: bytes | str) -> bytes: ''' Decode bytes from Crockford Base32. diff --git a/src/suou/collections.py b/src/suou/collections.py index 090659d..d7b2611 100644 --- a/src/suou/collections.py +++ b/src/suou/collections.py @@ -28,7 +28,7 @@ class TimedDict(dict[_KT, _VT]): """ Dictionary where keys expire after the defined time to live, expressed in seconds. - NEW 0.5.0 + *New in 0.5.0* """ _expires: dict[_KT, int] _ttl: int diff --git a/src/suou/color.py b/src/suou/color.py index 633bfaa..5a8b899 100644 --- a/src/suou/color.py +++ b/src/suou/color.py @@ -1,7 +1,7 @@ """ Colors for coding artists -NEW 0.7.0 +*New in 0.7.0* --- @@ -33,7 +33,7 @@ class Chalk: UNTESTED - NEW 0.7.0 + *New in 0.7.0* """ CSI = '\x1b[' RED = CSI + "31m" diff --git a/src/suou/configparse.py b/src/suou/configparse.py index 8687cb4..ec5006b 100644 --- a/src/suou/configparse.py +++ b/src/suou/configparse.py @@ -109,9 +109,10 @@ class DictConfigSource(ConfigSource): class ArgConfigSource(ValueSource): """ - It assumes arguments have already been parsed + Config source that assumes arguments have already been parsed. - NEW 0.6""" + *New in 0.6.0* + """ _ns: Namespace def __init__(self, ns: Namespace): super().__init__() diff --git a/src/suou/flask_sqlalchemy.py b/src/suou/flask_sqlalchemy.py index 94afc6f..88122d2 100644 --- a/src/suou/flask_sqlalchemy.py +++ b/src/suou/flask_sqlalchemy.py @@ -1,6 +1,8 @@ """ Utilities for Flask-SQLAlchemy binding. +This module is deprecated and will be REMOVED in 0.14.0. + --- Copyright (c) 2025 Sakuragasaki46. @@ -50,27 +52,7 @@ class FlaskAuthSrc(AuthSrc): @deprecated('not intuitive to use') def require_auth(cls: type[DeclarativeBase], db: SQLAlchemy) -> Callable[Any, Callable]: """ - Make an auth_required() decorator for Flask views. - - This looks for a token in the Authorization header, validates it, loads the - appropriate object, and injects it as the user= parameter. - - NOTE: the actual decorator to be used on routes is **auth_required()**, - NOT require_auth() which is the **constructor** for it. - - cls is a SQLAlchemy table. - db is a flask_sqlalchemy.SQLAlchemy() binding. - - Usage: - - auth_required = require_auth(User, db) - - @route('/admin') - @auth_required(validators=[lambda x: x.is_administrator]) - def super_secret_stuff(user): - pass - - NOTE: require_auth() DOES NOT work with flask_restx. + """ def auth_required(**kwargs): return require_auth_base(cls=cls, src=FlaskAuthSrc(db), **kwargs) diff --git a/src/suou/functools.py b/src/suou/functools.py index 91eb916..c68c6b6 100644 --- a/src/suou/functools.py +++ b/src/suou/functools.py @@ -87,7 +87,7 @@ def future(message: str | None = None, *, version: str = None): version= is the intended version release. - NEW 0.7.0 + *New in 0.7.0* """ def decorator(func: Callable[_T, _U]) -> Callable[_T, _U]: @wraps(func) @@ -135,7 +135,7 @@ def _make_alru_cache(_CacheInfo): PSA there is no C speed up. Unlike PSL. Sorry. - NEW 0.5.0 + *New in 0.5.0* """ # Users should only access the lru_cache through its public API: @@ -292,7 +292,7 @@ def timed_cache(ttl: int, maxsize: int = 128, typed: bool = False, *, async_: bo Supports coroutines with async_=True. - NEW 0.5.0 + *New in 0.5.0* """ def decorator(func: Callable[_T, _U]) -> Callable[_T, _U]: start_time = None @@ -330,7 +330,7 @@ def none_pass(func: Callable[_T, _U], *args, **kwargs) -> Callable[_T, _U]: Shorthand for func(x) if x is not None else None - NEW 0.5.0 + *New in 0.5.0* """ @wraps(func) def wrapper(x): diff --git a/src/suou/itertools.py b/src/suou/itertools.py index 084cf25..881e30a 100644 --- a/src/suou/itertools.py +++ b/src/suou/itertools.py @@ -22,12 +22,14 @@ from suou.classtools import MISSING _T = TypeVar('_T') -def makelist(l: Any, *, wrap: bool = True) -> list | Callable[Any, list]: +def makelist(l: Any, wrap: bool = True) -> list | Callable[Any, list]: ''' Make a list out of an iterable or a single value. - NEW 0.4.0: Now supports a callable: can be used to decorate generators and turn them into lists. + *Changed in 0.4.0* Now supports a callable: can be used to decorate generators and turn them into lists. Pass wrap=False to return instead the unwrapped function in a list. + + *Changed in 0.11.0*: ``wrap`` argument is now no more keyword only. ''' if callable(l) and wrap: return wraps(l)(lambda *a, **k: makelist(l(*a, **k), wrap=False)) diff --git a/src/suou/legal.py b/src/suou/legal.py index d1ba18e..8046435 100644 --- a/src/suou/legal.py +++ b/src/suou/legal.py @@ -18,6 +18,9 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # TODO more snippets +from .strtools import SpitText + + INDEMNIFY = """ You agree to indemnify and hold harmless {0} from any and all claims, damages, liabilities, costs and expenses, including reasonable and unreasonable counsel and attorney’s fees, arising out of any breach of this agreement. """ @@ -27,7 +30,7 @@ Except as represented in this agreement, the {0} is provided “AS IS”. Other """ GOVERNING_LAW = """ -These terms of services are governed by, and shall be interpreted in accordance with, the laws of {0}. You consent to the sole jurisdiction of {1} for all disputes between You and , and You consent to the sole application of {2} law for all such disputes. +These terms of services are governed by, and shall be interpreted in accordance with, the laws of {0}. You consent to the sole jurisdiction of {1} for all disputes between You and {2}, and You consent to the sole application of {3} law for all such disputes. """ ENGLISH_FIRST = """ @@ -45,5 +48,51 @@ If one clause of these Terms of Service or any policy incorporated here by refer """ COMPLETENESS = """ -These Terms, together with the other policies incorporated into them by reference, contain all the terms and conditions agreed upon by You and {{ app_name }} regarding Your use of the {{ app_name }} service. No other agreement, oral or otherwise, will be deemed to exist or to bind either of the parties to this Agreement. -""" \ No newline at end of file +These Terms, together with the other policies incorporated into them by reference, contain all the terms and conditions agreed upon by You and {0} regarding Your use of the {0} service. No other agreement, oral or otherwise, will be deemed to exist or to bind either of the parties to this Agreement. +""" + + +class Lawyer(SpitText): + """ + A tool to ease the writing of Terms of Service for web apps. + + NOT A REPLACEMENT FOR A REAL LAWYER AND NOT LEGAL ADVICE + + *New in 0.11.0* + """ + + def __init__(self, /, + app_name: str, domain_name: str, + company_name: str, jurisdiction: str, + country: str, country_adjective: str + ): + self.app_name = app_name + self.domain_name = domain_name + self.company_name = company_name + self.jurisdiction = jurisdiction + self.country = country + self.country_adjective = country_adjective + + def indemnify(self): + return self.format(INDEMNIFY, 'app_name') + + def no_warranty(self): + return self.format(NO_WARRANTY, 'app_name', 'company_name') + + def governing_law(self) -> str: + return self.format(GOVERNING_LAW, 'country', 'jurisdiction', 'app_name', 'country_adjective') + + def english_first(self) -> str: + return ENGLISH_FIRST + + def expect_updates(self) -> str: + return self.format(EXPECT_UPDATES, 'app_name') + + def severability(self) -> str: + return SEVERABILITY + + def completeness(self) -> str: + return self.format(COMPLETENESS, 'app_name') + +# This module is experimental and therefore not re-exported into __init__ +__all__ = ('Lawyer',) \ No newline at end of file diff --git a/src/suou/luck.py b/src/suou/luck.py index 78b58f8..c4ec49e 100644 --- a/src/suou/luck.py +++ b/src/suou/luck.py @@ -1,7 +1,7 @@ """ Fortune, RNG and esoterism. -NEW 0.7.0 +*New in 0.7.0* --- @@ -33,7 +33,7 @@ def lucky(validators: Iterable[Callable[[_U], bool]] = ()): UNTESTED - NEW 0.7.0 + *New in 0.7.0* """ def decorator(func: Callable[_T, _U]) -> Callable[_T, _U]: @wraps(func) @@ -61,7 +61,7 @@ class RngCallable(Callable, Generic[_T, _U]): UNTESTED - NEW 0.7.0 + *New in 0.7.0* """ def __init__(self, /, func: Callable[_T, _U] | None = None, weight: int = 1): self._callables = [] @@ -97,7 +97,7 @@ def rng_overload(prev_func: RngCallable[..., _U] | int | None, /, *, weight: int UNTESTED - NEW 0.7.0 + *New in 0.7.0* """ if isinstance(prev_func, int) and weight == 1: weight, prev_func = prev_func, None diff --git a/src/suou/redact.py b/src/suou/redact.py index cef86e7..ea0658f 100644 --- a/src/suou/redact.py +++ b/src/suou/redact.py @@ -1,7 +1,7 @@ """ "Security through obscurity" helpers for less sensitive logging -NEW 0.5.0 +*New in 0.5.0* --- @@ -27,7 +27,7 @@ def redact_url_password(u: str) -> str: scheme://username:password@hostname/path?query ^------^ - NEW 0.5.0 + *New in 0.5.0* """ return re.sub(r':[^@:/ ]+@', ':***@', u) diff --git a/src/suou/snowflake.py b/src/suou/snowflake.py index 3f9190e..743a703 100644 --- a/src/suou/snowflake.py +++ b/src/suou/snowflake.py @@ -20,6 +20,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. from __future__ import annotations +from binascii import unhexlify import os from threading import Lock import time @@ -28,7 +29,7 @@ import warnings from .migrate import SnowflakeSiqMigrator from .iding import SiqType -from .codecs import b32ldecode, b32lencode, b64encode, cb32encode +from .codecs import b32ldecode, b32lencode, b64encode, b64decode, cb32encode, cb32decode from .functools import deprecated @@ -121,27 +122,46 @@ class Snowflake(int): def to_bytes(self, length: int = 14, byteorder = "big", *, signed: bool = False) -> bytes: return super().to_bytes(length, byteorder, signed=signed) - def to_base64(self, length: int = 9, *, strip: bool = True) -> str: - return b64encode(self.to_bytes(length), strip=strip) - def to_cb32(self)-> str: - return cb32encode(self.to_bytes(8, 'big')) - to_crockford = to_cb32 - def to_hex(self) -> str: - return f'{self:x}' - def to_oct(self) -> str: - return f'{self:o}' - def to_b32l(self) -> str: - # PSA Snowflake Base32 representations are padded to 10 bytes! - if self < 0: - return '_' + Snowflake.to_b32l(-self) - return b32lencode(self.to_bytes(10, 'big')).lstrip('a') - @classmethod def from_bytes(cls, b: bytes, byteorder = 'big', *, signed: bool = False) -> Snowflake: if len(b) not in (8, 10): warnings.warn('Snowflakes are exactly 8 bytes long', BytesWarning) return super().from_bytes(b, byteorder, signed=signed) + + def to_base64(self, length: int = 9, *, strip: bool = True) -> str: + return b64encode(self.to_bytes(length), strip=strip) + @classmethod + def from_base64(cls, val:str) -> Snowflake: + return Snowflake.from_bytes(b64decode(val)) + + def to_cb32(self)-> str: + return cb32encode(self.to_bytes(8, 'big')) + to_crockford = to_cb32 + @classmethod + def from_cb32(cls, val:str) -> Snowflake: + return Snowflake.from_bytes(cb32decode(val)) + def to_hex(self) -> str: + return f'{self:x}' + @classmethod + def from_hex(cls, val:str) -> Snowflake: + if val.startswith('_'): + return -cls.from_hex(val.lstrip('_')) + return Snowflake.from_bytes(unhexlify(val)) + + def to_oct(self) -> str: + return f'{self:o}' + @classmethod + def from_oct(cls, val:str) -> Snowflake: + if val.startswith('_'): + return -cls.from_hex(val.lstrip('_')) + return Snowflake(int(val, base=8)) + + def to_b32l(self) -> str: + # PSA Snowflake Base32 representations are padded to 10 bytes! + if self < 0: + return '_' + Snowflake.to_b32l(-self) + return b32lencode(self.to_bytes(10, 'big')).lstrip('a') @classmethod def from_b32l(cls, val: str) -> Snowflake: if val.startswith('_'): @@ -149,6 +169,14 @@ class Snowflake(int): return -cls.from_b32l(val.lstrip('_')) return Snowflake.from_bytes(b32ldecode(val.rjust(16, 'a'))) + def to_siq(self, domain: str, epoch: int, target_type: SiqType, **kwargs): + """ + Convenience method for conversion to SIQ. + + (!) This does not check for existence! Always do the check yourself. + """ + return SnowflakeSiqMigrator(domain, epoch, **kwargs).to_siq(self, target_type) + @override def __format__(self, opt: str, /) -> str: try: @@ -179,15 +207,6 @@ class Snowflake(int): def __repr__(self): return f'{self.__class__.__name__}({super().__repr__()})' - def to_siq(self, domain: str, epoch: int, target_type: SiqType, **kwargs): - """ - Convenience method for conversion to SIQ. - - (!) This does not check for existence! Always do the check yourself. - """ - return SnowflakeSiqMigrator(domain, epoch, **kwargs).to_siq(self, target_type) - - __all__ = ( 'Snowflake', 'SnowflakeGen' diff --git a/src/suou/sqlalchemy/__init__.py b/src/suou/sqlalchemy/__init__.py index 4b606fc..c3e9856 100644 --- a/src/suou/sqlalchemy/__init__.py +++ b/src/suou/sqlalchemy/__init__.py @@ -85,7 +85,7 @@ def token_signer(id_attr: Column | str, secret_attr: Column | str) -> Incomplete ## (in)Utilities for use in web apps below -@deprecated('not part of the public API and not even working') +@deprecated('not part of the public API and not even working. Will be removed in 0.14.0') class AuthSrc(metaclass=ABCMeta): ''' AuthSrc object required for require_auth_base(). @@ -113,7 +113,7 @@ class AuthSrc(metaclass=ABCMeta): pass -@deprecated('not working and too complex to use. Will be removed in 0.9.0') +@deprecated('not working and too complex to use. Will be removed in 0.14.0') def require_auth_base(cls: type[DeclarativeBase], *, src: AuthSrc, column: str | Column[_T] = 'id', dest: str = 'user', required: bool = False, signed: bool = False, sig_dest: str = 'signature', validators: Callable | Iterable[Callable] | None = None): ''' diff --git a/src/suou/sqlalchemy/asyncio.py b/src/suou/sqlalchemy/asyncio.py index 43a9cef..72578bd 100644 --- a/src/suou/sqlalchemy/asyncio.py +++ b/src/suou/sqlalchemy/asyncio.py @@ -2,7 +2,7 @@ """ Helpers for asynchronous use of SQLAlchemy. -NEW 0.5.0; moved to current location 0.6.0 +*New in 0.5.0; moved to current location in 0.6.0* --- @@ -47,21 +47,23 @@ class SQLAlchemy: user = (await session.execute(select(User).where(User.id == userid))).scalar() # ... - NEW 0.5.0 + *New in 0.5.0* - UPDATED 0.6.0: added wrap=True + *Changed in 0.6.0*: added wrap=True - UPDATED 0.6.1: expire_on_commit is now configurable per-SQLAlchemy(); + *Changed in 0.6.1*: expire_on_commit is now configurable per-SQLAlchemy(); now sessions are stored as context variables + + *Changed in 0.11.0*: sessions are now wrapped by default; turn it off by instantiating it with wrap=False """ base: DeclarativeBase engine: AsyncEngine _session_tok: list[Token[AsyncSession]] - _wrapsessions: bool - _xocommit: bool + _wrapsessions: bool | None + _xocommit: bool | None NotFound = NotFoundError - def __init__(self, model_class: DeclarativeBase, *, expire_on_commit = False, wrap = False): + def __init__(self, model_class: DeclarativeBase, *, expire_on_commit = False, wrap = True): self.base = model_class self.engine = None self._wrapsessions = wrap @@ -71,13 +73,13 @@ class SQLAlchemy: def _ensure_engine(self): if self.engine is None: raise RuntimeError('database is not connected') - async def begin(self, *, expire_on_commit = None, wrap = False, **kw) -> AsyncSession: + async def begin(self, *, expire_on_commit = None, wrap = None, **kw) -> AsyncSession: self._ensure_engine() ## XXX is it accurate? s = AsyncSession(self.engine, expire_on_commit=expire_on_commit if expire_on_commit is not None else self._xocommit, **kw) - if wrap: + if (wrap if wrap is not None else self._wrapsessions): s = SessionWrapper(s) current_session.set(s) return s @@ -252,5 +254,8 @@ class SessionWrapper: """ return getattr(self._session, key) + def __del__(self): + self._session.close() + # Optional dependency: do not import into __init__.py __all__ = ('SQLAlchemy', 'AsyncSelectPagination', 'async_query', 'SessionWrapper') diff --git a/src/suou/sqlalchemy/orm.py b/src/suou/sqlalchemy/orm.py index 05271eb..ada5e94 100644 --- a/src/suou/sqlalchemy/orm.py +++ b/src/suou/sqlalchemy/orm.py @@ -1,7 +1,7 @@ """ Utilities for SQLAlchemy; ORM -NEW 0.6.0 (moved) +*New in 0.6.0 (moved)* --- @@ -123,7 +123,7 @@ def username_column( Username must match the given `regex` and be at most `length` characters long. - NEW 0.8.0 + *New in 0.8.0* """ if case is StringCase.AS_IS: warnings.warn('case sensitive usernames may lead to impersonation and unexpected behavior', UserWarning) @@ -135,7 +135,7 @@ def bool_column(value: bool = False, nullable: bool = False, **kwargs) -> Column """ Column for a single boolean value. - NEW in 0.4.0 + *New in 0.4.0* """ def_val = text('true') if value else text('false') return Column(Boolean, server_default=def_val, nullable=nullable, **kwargs) @@ -197,7 +197,7 @@ def secret_column(length: int = 64, max_length: int | None = None, gen: Callable """ Column filled in by default with random bits (64 by default). Useful for secrets. - NEW 0.6.0 + *New in 0.6.0* """ max_length = max_length or length return Column(LargeBinary(max_length), default=lambda: gen(length), nullable=nullable, **kwargs) @@ -215,7 +215,7 @@ def parent_children(keyword: str, /, *, lazy='selectin', **kwargs) -> tuple[Inco Additional keyword arguments can be sourced with parent_ and child_ argument prefixes, obviously. - CHANGED 0.5.0: the both relationship()s use lazy='selectin' attribute now by default. + *Changed in 0.5.0*: the both relationship()s use lazy='selectin' attribute now by default. """ parent_kwargs = kwargs_prefix(kwargs, 'parent_') @@ -231,7 +231,7 @@ def a_relationship(primary = None, /, j=None, *, lazy='selectin', **kwargs): """ Shorthand for relationship() that sets lazy='selectin' by default. - NEW 0.6.0 + *New in 0.6.0* """ if j: kwargs['primaryjoin'] = j @@ -246,7 +246,7 @@ def unbound_fk(target: str | Column | InstrumentedAttribute, typ: _T | None = No If target is a string, make sure to pass the column type at typ= (default: IdType aka varbinary(16))! - NEW 0.5.0 + *New in 0.5.0* """ if isinstance(target, (Column, InstrumentedAttribute)): target_name = f'{target.table.name}.{target.name}' @@ -269,7 +269,7 @@ def bound_fk(target: str | Column | InstrumentedAttribute, typ: _T = None, **kwa If target is a string, make sure to pass the column type at typ= (default: IdType aka varbinary(16))! - NEW 0.5.0 + *New in 0.5.0* """ if isinstance(target, (Column, InstrumentedAttribute)): target_name = f'{target.table.name}.{target.name}' @@ -288,7 +288,7 @@ class _BitComparator(Comparator): """ Comparator object for BitSelector() - NEW 0.6.0 + *New in 0.6.0* """ _column: Column _flag: int @@ -314,7 +314,7 @@ class BitSelector: Mimicks peewee's 'BitField()' behavior, with SQLAlchemy. - NEW 0.6.0 + *New in 0.6.0* """ _column: Column _flag: int diff --git a/src/suou/sqlalchemy_async.py b/src/suou/sqlalchemy_async.py index 47b3396..7812ac5 100644 --- a/src/suou/sqlalchemy_async.py +++ b/src/suou/sqlalchemy_async.py @@ -1,7 +1,7 @@ """ Helpers for asynchronous use of SQLAlchemy. -NEW 0.5.0; MOVED to sqlalchemy.asyncio in 0.6.0 +*New in 0.5.0; moved to ``sqlalchemy.asyncio`` in 0.6.0* --- diff --git a/src/suou/strtools.py b/src/suou/strtools.py index ee5264b..3694314 100644 --- a/src/suou/strtools.py +++ b/src/suou/strtools.py @@ -46,5 +46,18 @@ class PrefixIdentifier: def __str__(self): return f'{self._prefix}' + +class SpitText: + """ + A formatter for pre-compiled strings. + + *New in 0.11.0* + """ + + def format(self, templ: str, *attrs: Iterable[str]) -> str: + attrs = [getattr(self, attr, f'{{{{ {attr} }}}}') for attr in attrs] + return templ.format(*attrs).strip() + + __all__ = ('PrefixIdentifier',) diff --git a/src/suou/terminal.py b/src/suou/terminal.py index 3ab7f4f..f8af08d 100644 --- a/src/suou/terminal.py +++ b/src/suou/terminal.py @@ -25,7 +25,7 @@ def terminal_required(func): """ Requires the decorated callable to be fully connected to a terminal. - NEW 0.7.0 + *New in 0.7.0* """ @wraps(func) def wrapper(*a, **ka): diff --git a/src/suou/waiter.py b/src/suou/waiter.py index a959c88..9a5e3bd 100644 --- a/src/suou/waiter.py +++ b/src/suou/waiter.py @@ -1,7 +1,7 @@ """ Content serving API over HTTP, based on Starlette. -NEW 0.6.0 +*New in 0.6.0* --- diff --git a/tests/test_legal.py b/tests/test_legal.py new file mode 100644 index 0000000..4ba6a36 --- /dev/null +++ b/tests/test_legal.py @@ -0,0 +1,42 @@ + + + +import unittest + +from suou.legal import Lawyer + + +EXPECTED_INDEMNIFY = """ +You agree to indemnify and hold harmless TNT from any and all claims, damages, liabilities, costs and expenses, including reasonable and unreasonable counsel and attorney’s fees, arising out of any breach of this agreement. +""".strip() + +EXPECTED_GOVERNING_LAW = """ +These terms of services are governed by, and shall be interpreted in accordance with, the laws of Wakanda. You consent to the sole jurisdiction of Asgard, Wakanda for all disputes between You and TNT, and You consent to the sole application of Wakandan law for all such disputes. +""".strip() + +class TestLegal(unittest.TestCase): + def setUp(self) -> None: + self.lawyer = Lawyer( + app_name = "TNT", + company_name= "ACME, Ltd.", + country = "Wakanda", + domain_name= "example.com", + jurisdiction= "Asgard, Wakanda", + country_adjective= "Wakandan" + ) + + def tearDown(self) -> None: + ... + + def test_indemnify(self): + self.assertEqual( + self.lawyer.indemnify(), + EXPECTED_INDEMNIFY + ) + + def test_governing_law(self): + self.assertEqual( + self.lawyer.governing_law(), + EXPECTED_GOVERNING_LAW + ) + From 3af9d6c9fb5e75a8ce9ed00fe5ce6f2c0564b81b Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Mon, 1 Dec 2025 10:23:59 +0100 Subject: [PATCH 115/121] 0.11.1 make `yesno()` accept boolean types --- CHANGELOG.md | 4 ++++ src/suou/__init__.py | 2 +- src/suou/validators.py | 17 +++++++++++++---- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd0115b..d42d190 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.11.1 + ++ make `yesno()` accept boolean types + ## 0.11.0 + **Breaking**: sessions returned by `SQLAlchemy()` are now wrapped by default. Restore original behavior by passing `wrap=False` to the constructor or to `begin()` diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 63b6d18..9097a9b 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -37,7 +37,7 @@ from .redact import redact_url_password from .http import WantsContentType from .color import chalk, WebColor -__version__ = "0.11.0" +__version__ = "0.11.1" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', diff --git a/src/suou/validators.py b/src/suou/validators.py index e8b366f..349172a 100644 --- a/src/suou/validators.py +++ b/src/suou/validators.py @@ -20,8 +20,6 @@ from typing import Any, Iterable, TypeVar from suou.classtools import MISSING -from .functools import future - _T = TypeVar('_T') def matches(regex: str | int, /, length: int = 0, *, flags=0): @@ -59,13 +57,24 @@ def not_less_than(y): """ return lambda x: x >= y -def yesno(x: str) -> bool: +def yesno(x: str | int | bool | None) -> bool: """ Returns False if x.lower() is in '0', '', 'no', 'n', 'false' or 'off'. *New in 0.9.0* + + *Changed in 0.11.1*: now accepts None and bool. """ - return x not in (None, MISSING) and x.lower() not in ('', '0', 'off', 'n', 'no', 'false', 'f') + if x in (None, MISSING): + return False + if isinstance(x, bool): + return x + if isinstance(x, int): + return x != 0 + if isinstance(x, str): + return x.lower() not in ('', '0', 'off', 'n', 'no', 'false', 'f') + return True + __all__ = ('matches', 'must_be', 'not_greater_than', 'not_less_than', 'yesno') From eca16d781fb4d3f099f6a3b6e32e7ce957e41fdc Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 5 Dec 2025 17:45:15 +0100 Subject: [PATCH 116/121] 0.11.2 add tests for not_*_than() --- .gitignore | 3 ++- CHANGELOG.md | 4 ++++ src/suou/__init__.py | 2 +- tests/test_validators.py | 18 ++++++++++++++++-- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 5f9c2fc..96fd286 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ aliases/*/src docs/_build docs/_static docs/templates +.coverage # changes during CD/CI -aliases/*/pyproject.toml \ No newline at end of file +aliases/*/pyproject.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index d42d190..b4ba99a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.11.2 + ++ increase test coverage of `validators` + ## 0.11.1 + make `yesno()` accept boolean types diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 9097a9b..c3c8724 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -37,7 +37,7 @@ from .redact import redact_url_password from .http import WantsContentType from .color import chalk, WebColor -__version__ = "0.11.1" +__version__ = "0.11.2" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', diff --git a/tests/test_validators.py b/tests/test_validators.py index 0064128..2d3cc89 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -1,7 +1,8 @@ import unittest -from suou.validators import yesno +from suou.calendar import not_greater_than +from suou.validators import not_less_than, yesno class TestValidators(unittest.TestCase): def setUp(self): @@ -21,4 +22,17 @@ class TestValidators(unittest.TestCase): self.assertTrue(yesno('2')) self.assertTrue(yesno('o')) self.assertFalse(yesno('oFF')) - self.assertFalse(yesno('no')) \ No newline at end of file + self.assertFalse(yesno('no')) + self.assertFalse(yesno(False)) + self.assertTrue(yesno(True)) + self.assertFalse(yesno('')) + + def test_not_greater_than(self): + self.assertTrue(not_greater_than(5)(5)) + self.assertTrue(not_greater_than(5)(3)) + self.assertFalse(not_greater_than(3)(8)) + + def test_not_less_than(self): + self.assertTrue(not_less_than(5)(5)) + self.assertFalse(not_less_than(5)(3)) + self.assertTrue(not_less_than(3)(8)) \ No newline at end of file From d123b9c19665c80a57dc585433ec180c54bb2d0a Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 12 Dec 2025 11:03:10 +0100 Subject: [PATCH 117/121] 0.12.0a1 add Matrix() --- CHANGELOG.md | 4 ++ src/suou/__init__.py | 2 +- src/suou/mat.py | 121 +++++++++++++++++++++++++++++++++++++++++++ tests/test_mat.py | 47 +++++++++++++++++ 4 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 src/suou/mat.py create mode 100644 tests/test_mat.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b4ba99a..f449db1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.12.0 + +* New module `mat` adds a shallow reimplementation of `Matrix()` in order to implement matrix multiplication + ## 0.11.2 + increase test coverage of `validators` diff --git a/src/suou/__init__.py b/src/suou/__init__.py index c3c8724..2d79b4b 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -37,7 +37,7 @@ from .redact import redact_url_password from .http import WantsContentType from .color import chalk, WebColor -__version__ = "0.11.2" +__version__ = "0.12.0a1" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', diff --git a/src/suou/mat.py b/src/suou/mat.py new file mode 100644 index 0000000..fa60f08 --- /dev/null +++ b/src/suou/mat.py @@ -0,0 +1,121 @@ +""" +Matrix (not the movie...) + +*New in 0.12.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. +""" + +from __future__ import annotations +from typing import Collection, Iterable, TypeVar +from .functools import deprecated + +_T = TypeVar('_T') + +class Matrix(Collection[_T]): + """ + Shallow reimplementation of numpy's matrices in pure Python. + + *New in 0.12.0* + """ + _shape: tuple[int, int] + _elements: list[_T] + + def shape(self): + return self._shape + + def __init__(self, iterable: Iterable[_T] | Iterable[Collection[_T]], shape: tuple[int, int] | None = None): + elements = [] + boundary_x = boundary_y = 0 + for row in iterable: + if isinstance(row, Collection): + if not boundary_y: + boundary_y = len(row) + elements.extend(row) + boundary_x += 1 + elif boundary_y != len(row): + raise ValueError('row length mismatch') + else: + elements.extend(row) + boundary_x += 1 + elif shape: + if not boundary_x: + boundary_x, boundary_y = shape + elements.append(row) + self._shape = boundary_x, boundary_y + self._elements = elements + assert len(self._elements) == boundary_x * boundary_y + + def __getitem__(self, key: tuple[int, int]) -> _T: + (x, y), (_, sy) = key, self.shape() + + return self._elements[x * sy + y] + + @property + def T(self): + sx, sy = self.shape() + return Matrix( + [ + [ + self[j, i] for j in range(sx) + ] for i in range(sy) + ] + ) + + def __matmul__(self, other: Matrix) -> Matrix: + (ax, ay), (bx, by) = self.shape(), other.shape() + + if ay != bx: + raise ValueError('cannot multiply matrices with incompatible shape') + + return Matrix([ + [ + sum(self[i, k] * other[k, j] for k in range(ay)) for j in range(by) + ] for i in range(ax) + ]) + + def __eq__(self, other: Matrix): + try: + return self._elements == other._elements and self._shape == other._shape + except Exception: + return False + + def __len__(self): + ax, ay = self.shape() + return ax * ay + + @deprecated('please use .rows() or .columns() instead') + def __iter__(self): + return iter(self._elements) + + def __contains__(self, x: object, /) -> bool: + return x in self._elements + + def __repr__(self): + return f'{self.__class__.__name__}({list(self.rows())})' + + def rows(self): + sx, sy = self.shape() + return ( + [self[j, i] for j in range(sy)] for i in range(sx) + ) + + def columns(self): + sx, sy = self.shape() + return ( + [self[j, i] for j in range(sx)] for i in range(sy) + ) + +## TODO write tests! + + diff --git a/tests/test_mat.py b/tests/test_mat.py new file mode 100644 index 0000000..ac1e00c --- /dev/null +++ b/tests/test_mat.py @@ -0,0 +1,47 @@ + + +import unittest + +from suou.mat import Matrix + + +class TestMat(unittest.TestCase): + def setUp(self): + self.m_a = Matrix([ + [2, 2], + [1, 3] + ]) + self.m_b = Matrix([ + [1], [-4] + ]) + def tearDown(self) -> None: + ... + def test_transpose(self): + self.assertEqual( + self.m_a.T, + Matrix([ + [2, 1], + [2, 3] + ]) + ) + self.assertEqual( + self.m_b.T, + Matrix([[1, -4]]) + ) + def test_mul(self): + self.assertEqual( + self.m_b.T @ self.m_a, + Matrix([ + [-2, -10] + ]) + ) + self.assertEqual( + self.m_a @ self.m_b, + Matrix([ + [-6], [-11] + ]) + ) + def test_shape(self): + self.assertEqual(self.m_a.shape(), (2, 2)) + self.assertEqual(self.m_b.shape(), (2, 1)) + self.assertEqual(self.m_b.T.shape(), (1, 2)) \ No newline at end of file From 75adb9fbfffed03bcb7a4cbf6ae9295bcaf93b35 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 12 Dec 2025 11:34:37 +0100 Subject: [PATCH 118/121] 0.12.0a2 remove deprecated configparse from 0.3.0 and AuthSrc derivatives --- CHANGELOG.md | 2 + src/suou/__init__.py | 4 +- src/suou/flask_sqlalchemy.py | 46 +----- src/suou/iding.py | 11 +- src/suou/mat.py | 3 +- src/suou/obsolete/configparsev0_3.py | 239 --------------------------- 6 files changed, 16 insertions(+), 289 deletions(-) delete mode 100644 src/suou/obsolete/configparsev0_3.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f449db1..21a7882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ ## 0.12.0 +* All `AuthSrc()` derivatives, deprecated and never used, have been removed. * New module `mat` adds a shallow reimplementation of `Matrix()` in order to implement matrix multiplication +* Removed obsolete `configparse` implementation that has been around since 0.3 and shelved since 0.4. ## 0.11.2 diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 2d79b4b..a0fbed1 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -36,12 +36,14 @@ from .validators import matches, not_less_than, not_greater_than, yesno from .redact import redact_url_password from .http import WantsContentType from .color import chalk, WebColor +from .mat import Matrix -__version__ = "0.12.0a1" +__version__ = "0.12.0a2" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', 'DictConfigSource', 'EnvConfigSource', 'I18n', 'Incomplete', 'JsonI18n', + 'Matrix', 'MissingConfigError', 'MissingConfigWarning', 'PrefixIdentifier', 'Siq', 'SiqCache', 'SiqGen', 'SiqType', 'Snowflake', 'SnowflakeGen', 'StringCase', 'TimedDict', 'TomlI18n', 'UserSigner', 'Wanted', 'WantsContentType', diff --git a/src/suou/flask_sqlalchemy.py b/src/suou/flask_sqlalchemy.py index 88122d2..3f1caf0 100644 --- a/src/suou/flask_sqlalchemy.py +++ b/src/suou/flask_sqlalchemy.py @@ -1,7 +1,7 @@ """ Utilities for Flask-SQLAlchemy binding. -This module is deprecated and will be REMOVED in 0.14.0. +This module has been emptied in 0.12.0 following deprecation removals. --- @@ -16,50 +16,6 @@ This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. """ -from functools import partial -from typing import Any, Callable, Never - -from flask import abort, request -from flask_sqlalchemy import SQLAlchemy -from sqlalchemy.orm import DeclarativeBase, Session -from .functools import deprecated - -from .codecs import want_bytes -from .sqlalchemy import AuthSrc, require_auth_base - -@deprecated('inherits from deprecated and unused class') -class FlaskAuthSrc(AuthSrc): - ''' - - ''' - db: SQLAlchemy - def __init__(self, db: SQLAlchemy): - super().__init__() - self.db = db - def get_session(self) -> Session: - return self.db.session - def get_token(self): - if request.authorization: - return request.authorization.token - def get_signature(self) -> bytes: - sig = request.headers.get('authorization-signature', None) - return want_bytes(sig) if sig else None - def invalid_exc(self, msg: str = 'validation failed') -> Never: - abort(400, msg) - def required_exc(self): - abort(401, 'Login required') - -@deprecated('not intuitive to use') -def require_auth(cls: type[DeclarativeBase], db: SQLAlchemy) -> Callable[Any, Callable]: - """ - - """ - def auth_required(**kwargs): - return require_auth_base(cls=cls, src=FlaskAuthSrc(db), **kwargs) - - auth_required.__doc__ = require_auth_base.__doc__ - - return auth_required # Optional dependency: do not import into __init__.py __all__ = () diff --git a/src/suou/iding.py b/src/suou/iding.py index a2e0c37..7997da3 100644 --- a/src/suou/iding.py +++ b/src/suou/iding.py @@ -249,13 +249,20 @@ class Siq(int): def to_base64(self, length: int = 15, *, strip: bool = True) -> str: return b64encode(self.to_bytes(length), strip=strip) + def to_cb32(self) -> str: return cb32encode(self.to_bytes(15, 'big')).lstrip('0') to_crockford = to_cb32 + @classmethod + def from_cb32(cls, val: str | bytes): + return cls.from_bytes(cb32decode(want_str(val).zfill(24))) + def to_hex(self) -> str: return f'{self:x}' + def to_oct(self) -> str: return f'{self:o}' + def to_b32l(self) -> str: """ This is NOT the URI serializer! @@ -305,12 +312,10 @@ class Siq(int): raise ValueError('checksum mismatch') return cls(int.from_bytes(b, 'big')) - @classmethod - def from_cb32(cls, val: str | bytes): - return cls.from_bytes(cb32decode(want_str(val).zfill(24))) def to_mastodon(self, /, domain: str | None = None): return f'@{self:u}{"@" if domain else ""}{domain}' + def to_matrix(self, /, domain: str): return f'@{self:u}:{domain}' diff --git a/src/suou/mat.py b/src/suou/mat.py index fa60f08..4f8f2b9 100644 --- a/src/suou/mat.py +++ b/src/suou/mat.py @@ -116,6 +116,7 @@ class Matrix(Collection[_T]): [self[j, i] for j in range(sx)] for i in range(sy) ) -## TODO write tests! + +__all__ = ('Matrix', ) diff --git a/src/suou/obsolete/configparsev0_3.py b/src/suou/obsolete/configparsev0_3.py deleted file mode 100644 index 1563813..0000000 --- a/src/suou/obsolete/configparsev0_3.py +++ /dev/null @@ -1,239 +0,0 @@ -""" -Utilities for parsing config variables. - -BREAKING older, non-generalized version, kept for backwards compability -in case 0.4+ version happens to break. - -WILL BE removed in 0.5.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. -""" - -from __future__ import annotations -from ast import TypeVar -from collections.abc import Mapping -from configparser import ConfigParser as _ConfigParser -import os -from typing import Any, Callable, Iterator -from collections import OrderedDict -import warnings - -from ..functools import deprecated -from ..exceptions import MissingConfigError, MissingConfigWarning - -warnings.warn('This module will be removed in 0.5.0 and is kept only in case new implementation breaks!\n'\ - 'Do not use unless you know what you are doing.', DeprecationWarning) - -MISSING = object() -_T = TypeVar('T') - - -@deprecated('use configparse') -class ConfigSource(Mapping): - ''' - Abstract config source. - ''' - __slots__ = () - -@deprecated('use configparse') -class EnvConfigSource(ConfigSource): - ''' - Config source from os.environ aka .env - ''' - def __getitem__(self, key: str, /) -> str: - return os.environ[key] - def get(self, key: str, fallback = None, /): - return os.getenv(key, fallback) - def __contains__(self, key: str, /) -> bool: - return key in os.environ - def __iter__(self) -> Iterator[str]: - yield from os.environ - def __len__(self) -> int: - return len(os.environ) - -@deprecated('use configparse') -class ConfigParserConfigSource(ConfigSource): - ''' - Config source from ConfigParser - ''' - __slots__ = ('_cfp', ) - _cfp: _ConfigParser - - def __init__(self, cfp: _ConfigParser): - if not isinstance(cfp, _ConfigParser): - raise TypeError(f'a ConfigParser object is required (got {cfp.__class__.__name__!r})') - self._cfp = cfp - def __getitem__(self, key: str, /) -> str: - k1, _, k2 = key.partition('.') - return self._cfp.get(k1, k2) - def get(self, key: str, fallback = None, /): - k1, _, k2 = key.partition('.') - return self._cfp.get(k1, k2, fallback=fallback) - def __contains__(self, key: str, /) -> bool: - k1, _, k2 = key.partition('.') - return self._cfp.has_option(k1, k2) - def __iter__(self) -> Iterator[str]: - for k1, v1 in self._cfp.items(): - for k2 in v1: - yield f'{k1}.{k2}' - def __len__(self) -> int: - ## XXX might be incorrect but who cares - return sum(len(x) for x in self._cfp) - -@deprecated('use configparse') -class DictConfigSource(ConfigSource): - ''' - Config source from Python mappings. Useful with JSON/TOML config - ''' - __slots__ = ('_d',) - - _d: dict[str, Any] - - def __init__(self, mapping: dict[str, Any]): - self._d = mapping - def __getitem__(self, key: str, /) -> str: - return self._d[key] - def get(self, key: str, fallback: _T = None, /): - return self._d.get(key, fallback) - def __contains__(self, key: str, /) -> bool: - return key in self._d - def __iter__(self) -> Iterator[str]: - yield from self._d - def __len__(self) -> int: - return len(self._d) - -@deprecated('use configparse') -class ConfigValue: - """ - A single config property. - - By default, it is sourced from os.environ — i.e. environment variables, - and property name is upper cased. - - You can specify further sources, if the parent ConfigOptions class - supports them. - - Arguments: - - public: mark value as public, making it available across the app (e.g. in Jinja2 templates). - - prefix: src but for the lazy - - preserve_case: if True, src is not CAPITALIZED. Useful for parsing from Python dictionaries or ConfigParser's - - required: throw an error if empty or not supplied - """ - # XXX disabled per https://stackoverflow.com/questions/45864273/slots-conflicts-with-a-class-variable-in-a-generic-class - #__slots__ = ('_srcs', '_val', '_default', '_cast', '_required', '_preserve_case') - - _srcs: dict[str, str] | None - _preserve_case: bool = False - _val: Any | MISSING = MISSING - _default: Any | None - _cast: Callable | None - _required: bool - _pub_name: str | bool = False - def __init__(self, /, - src: str | None = None, *, default = None, cast: Callable | None = None, - required: bool = False, preserve_case: bool = False, prefix: str | None = None, - public: str | bool = False, **kwargs): - self._srcs = dict() - self._preserve_case = preserve_case - if src: - self._srcs['default'] = src if preserve_case else src.upper() - elif prefix: - self._srcs['default'] = f'{prefix if preserve_case else prefix.upper}?' - self._default = default - self._cast = cast - self._required = required - self._pub_name = public - for k, v in kwargs.items(): - if k.endswith('_src'): - self._srcs[k[:-4]] = v - else: - raise TypeError(f'unknown keyword argument {k!r}') - def __set_name__(self, owner, name: str): - if 'default' not in self._srcs: - self._srcs['default'] = name if self._preserve_case else name.upper() - elif self._srcs['default'].endswith('?'): - self._srcs['default'] = self._srcs['default'].rstrip('?') + (name if self._preserve_case else name.upper() ) - - if self._pub_name is True: - self._pub_name = name - if self._pub_name and isinstance(owner, ConfigOptions): - owner.expose(self._pub_name, name) - def __get__(self, obj: ConfigOptions, owner=False): - if self._val is MISSING: - v = MISSING - for srckey, src in obj._srcs.items(): - if srckey in self._srcs: - v = src.get(self._srcs[srckey], v) - if self._required and (not v or v is MISSING): - raise MissingConfigError(f'required config {self._srcs['default']} not set!') - if v is MISSING: - v = self._default - if callable(self._cast): - v = self._cast(v) if v is not None else self._cast() - self._val = v - return self._val - - @property - def source(self, /): - return self._srcs['default'] - -@deprecated('use configparse') -class ConfigOptions: - """ - Base class for loading config values. - - It is intended to get subclassed; config values must be defined as - ConfigValue() properties. - - Further config sources can be added with .add_source() - """ - - __slots__ = ('_srcs', '_pub') - - _srcs: OrderedDict[str, ConfigSource] - _pub: dict[str, str] - - def __init__(self, /): - self._srcs = OrderedDict( - default = EnvConfigSource() - ) - self._pub = dict() - - def add_source(self, key: str, csrc: ConfigSource, /, *, first: bool = False): - self._srcs[key] = csrc - if first: - self._srcs.move_to_end(key, False) - - add_config_source = deprecated_alias(add_source) - - def expose(self, public_name: str, attr_name: str | None = None) -> None: - ''' - Mark a config value as public. - - Called automatically by ConfigValue.__set_name__(). - ''' - attr_name = attr_name or public_name - self._pub[public_name] = attr_name - - def to_dict(self, /): - d = dict() - for k, v in self._pub.items(): - d[k] = getattr(self, v) - return d - - -__all__ = ( - 'MissingConfigError', 'MissingConfigWarning', 'ConfigOptions', 'EnvConfigSource', 'ConfigParserConfigSource', 'DictConfigSource', 'ConfigSource', 'ConfigValue' -) - - From e6ee355f2e9ec5c295bf1694d490fdb6a61b987e Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Thu, 18 Dec 2025 16:57:36 +0100 Subject: [PATCH 119/121] 0.12.0a3 add rgb <-> srgb --- CHANGELOG.md | 1 + src/suou/__init__.py | 2 +- src/suou/color.py | 36 ++++++++++++++++++++++++++++++++---- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21a7882..e28e673 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * All `AuthSrc()` derivatives, deprecated and never used, have been removed. * New module `mat` adds a shallow reimplementation of `Matrix()` in order to implement matrix multiplication * Removed obsolete `configparse` implementation that has been around since 0.3 and shelved since 0.4. +* `color`: added support for conversion from RGB to sRGB ## 0.11.2 diff --git a/src/suou/__init__.py b/src/suou/__init__.py index a0fbed1..06f5698 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -38,7 +38,7 @@ from .http import WantsContentType from .color import chalk, WebColor from .mat import Matrix -__version__ = "0.12.0a2" +__version__ = "0.12.0a3" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', diff --git a/src/suou/color.py b/src/suou/color.py index 5a8b899..dfd2a6d 100644 --- a/src/suou/color.py +++ b/src/suou/color.py @@ -93,9 +93,9 @@ chalk = Chalk() ## Utilities for web colors -class WebColor(namedtuple('_WebColor', 'red green blue')): +class RGBColor(namedtuple('_WebColor', 'red green blue')): """ - Representation of a color in the TrueColor space (aka rgb). + Representation of a color in the RGB TrueColor space. Useful for theming. """ @@ -126,21 +126,49 @@ class WebColor(namedtuple('_WebColor', 'red green blue')): """ return self.darken(factor=factor) + self.lighten(factor=factor) - def blend_with(self, other: WebColor): + def blend_with(self, other: RGBColor): """ Mix two colors, returning the average. """ - return WebColor ( + return RGBColor ( (self.red + other.red) // 2, (self.green + other.green) // 2, (self.blue + other.blue) // 2 ) + def to_srgb(self): + """ + Convert to sRGB space. + + *New in 0.12.0* + """ + return SRGBColor(*( + (i / 12.92 if abs(c) <= 0.04045 else + (-1 if i < 0 else 1) * (((abs(c) + 0.55)) / 1.055) ** 2.4) for i in self + )) + __add__ = blend_with def __str__(self): return f"rgb({self.red}, {self.green}, {self.blue})" +WebColor = RGBColor + +## The following have been adapted from +## https://gist.github.com/dkaraush/65d19d61396f5f3cd8ba7d1b4b3c9432 + +class SRGBColor(namedtuple('_SRGBColor', 'red green blue')): + """ + Represent a color in the sRGB-Linear space. + + *New in 0.12.0* + """ + def to_rgb(self): + return RGBColor(*( + ((-1 if i < 0 else 1) * (1.055 * (abs(i) ** (1/2.4)) - 0.055) + if abs(i) > 0.0031308 else 12.92 * i) for i in self)) + + __all__ = ('chalk', 'WebColor') From ef645bd4daa21d073a1269da141242f6b71e7f8e Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sat, 20 Dec 2025 07:50:43 +0100 Subject: [PATCH 120/121] 0.12.0a4 add XYZ color --- CHANGELOG.md | 2 +- docs/color.rst | 19 +++++++++ docs/generated/suou.codecs.rst | 3 +- docs/generated/suou.color.rst | 4 +- docs/generated/suou.flask_sqlalchemy.rst | 14 +------ docs/generated/suou.legal.rst | 8 +++- docs/generated/suou.peewee.rst | 23 +++++++++++ docs/generated/suou.strtools.rst | 3 +- docs/generated/suou.validators.rst | 3 +- docs/index.rst | 1 + src/suou/__init__.py | 3 +- src/suou/color.py | 51 +++++++++++++++++++++--- src/suou/functools.py | 2 +- src/suou/glue.py | 2 +- src/suou/legal.py | 2 +- src/suou/mat.py | 19 +++++++++ src/suou/migrate.py | 2 +- 17 files changed, 132 insertions(+), 29 deletions(-) create mode 100644 docs/color.rst create mode 100644 docs/generated/suou.peewee.rst diff --git a/CHANGELOG.md b/CHANGELOG.md index e28e673..952cd75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ * All `AuthSrc()` derivatives, deprecated and never used, have been removed. * New module `mat` adds a shallow reimplementation of `Matrix()` in order to implement matrix multiplication * Removed obsolete `configparse` implementation that has been around since 0.3 and shelved since 0.4. -* `color`: added support for conversion from RGB to sRGB +* `color`: added support for conversion from RGB to sRGB, XYZ ## 0.11.2 diff --git a/docs/color.rst b/docs/color.rst new file mode 100644 index 0000000..189a063 --- /dev/null +++ b/docs/color.rst @@ -0,0 +1,19 @@ + +Color +===== + +.. currentmodule:: suou.color + +... + +Web colors +---------- + +.. autoclass:: RGBColor + + +.. autoclass:: WebColor + + +.. autoclass:: XYZColor + diff --git a/docs/generated/suou.codecs.rst b/docs/generated/suou.codecs.rst index 0112a23..0e76eff 100644 --- a/docs/generated/suou.codecs.rst +++ b/docs/generated/suou.codecs.rst @@ -1,4 +1,4 @@ -suou.codecs +suou.codecs =========== .. automodule:: suou.codecs @@ -16,6 +16,7 @@ suou.codecs b64encode cb32decode cb32encode + cb32lencode jsonencode quote_css_string rb64decode diff --git a/docs/generated/suou.color.rst b/docs/generated/suou.color.rst index 03365c4..339e4ed 100644 --- a/docs/generated/suou.color.rst +++ b/docs/generated/suou.color.rst @@ -1,4 +1,4 @@ -suou.color +suou.color ========== .. automodule:: suou.color @@ -9,5 +9,7 @@ suou.color .. autosummary:: Chalk + RGBColor + SRGBColor WebColor \ No newline at end of file diff --git a/docs/generated/suou.flask_sqlalchemy.rst b/docs/generated/suou.flask_sqlalchemy.rst index 458fa6f..dd6a455 100644 --- a/docs/generated/suou.flask_sqlalchemy.rst +++ b/docs/generated/suou.flask_sqlalchemy.rst @@ -1,18 +1,6 @@ -suou.flask\_sqlalchemy +suou.flask\_sqlalchemy ====================== .. automodule:: suou.flask_sqlalchemy - - .. rubric:: Functions - - .. autosummary:: - - require_auth - - .. rubric:: Classes - - .. autosummary:: - - FlaskAuthSrc \ No newline at end of file diff --git a/docs/generated/suou.legal.rst b/docs/generated/suou.legal.rst index f19f6f0..2598387 100644 --- a/docs/generated/suou.legal.rst +++ b/docs/generated/suou.legal.rst @@ -1,6 +1,12 @@ -suou.legal +suou.legal ========== .. automodule:: suou.legal + + .. rubric:: Classes + + .. autosummary:: + + Lawyer \ No newline at end of file diff --git a/docs/generated/suou.peewee.rst b/docs/generated/suou.peewee.rst new file mode 100644 index 0000000..f2c339e --- /dev/null +++ b/docs/generated/suou.peewee.rst @@ -0,0 +1,23 @@ +suou.peewee +=========== + +.. automodule:: suou.peewee + + + .. rubric:: Functions + + .. autosummary:: + + connect_reconnect + + .. rubric:: Classes + + .. autosummary:: + + ConnectToDatabase + PeeweeConnectionState + ReconnectMysqlDatabase + RegexCharField + SiqField + SnowflakeField + \ No newline at end of file diff --git a/docs/generated/suou.strtools.rst b/docs/generated/suou.strtools.rst index 1bc81a1..5ae2742 100644 --- a/docs/generated/suou.strtools.rst +++ b/docs/generated/suou.strtools.rst @@ -1,4 +1,4 @@ -suou.strtools +suou.strtools ============= .. automodule:: suou.strtools @@ -9,4 +9,5 @@ suou.strtools .. autosummary:: PrefixIdentifier + SpitText \ No newline at end of file diff --git a/docs/generated/suou.validators.rst b/docs/generated/suou.validators.rst index b7974a0..4c10573 100644 --- a/docs/generated/suou.validators.rst +++ b/docs/generated/suou.validators.rst @@ -1,4 +1,4 @@ -suou.validators +suou.validators =============== .. automodule:: suou.validators @@ -12,4 +12,5 @@ suou.validators must_be not_greater_than not_less_than + yesno \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 9c3d855..69ab767 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -16,4 +16,5 @@ ease programmer's QoL and write shorter and cleaner code that works. sqlalchemy iding validators + color api \ No newline at end of file diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 06f5698..2753744 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -38,13 +38,14 @@ from .http import WantsContentType from .color import chalk, WebColor from .mat import Matrix -__version__ = "0.12.0a3" +__version__ = "0.12.0a4" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', 'DictConfigSource', 'EnvConfigSource', 'I18n', 'Incomplete', 'JsonI18n', 'Matrix', 'MissingConfigError', 'MissingConfigWarning', 'PrefixIdentifier', + 'RGBColor', 'SRGBColor', 'Siq', 'SiqCache', 'SiqGen', 'SiqType', 'Snowflake', 'SnowflakeGen', 'StringCase', 'TimedDict', 'TomlI18n', 'UserSigner', 'Wanted', 'WantsContentType', 'WebColor', diff --git a/src/suou/color.py b/src/suou/color.py index dfd2a6d..e8383fb 100644 --- a/src/suou/color.py +++ b/src/suou/color.py @@ -22,6 +22,8 @@ from __future__ import annotations from collections import namedtuple from functools import lru_cache +from suou.mat import Matrix + class Chalk: """ @@ -143,15 +145,26 @@ class RGBColor(namedtuple('_WebColor', 'red green blue')): *New in 0.12.0* """ return SRGBColor(*( - (i / 12.92 if abs(c) <= 0.04045 else - (-1 if i < 0 else 1) * (((abs(c) + 0.55)) / 1.055) ** 2.4) for i in self + (i / 12.92 if abs(i) <= 0.04045 else + (-1 if i < 0 else 1) * (((abs(i) + 0.55)) / 1.055) ** 2.4) for i in self )) + + __add__ = blend_with def __str__(self): return f"rgb({self.red}, {self.green}, {self.blue})" + RGB_TO_XYZ = Matrix([ + [0.41239079926595934, 0.357584339383878, 0.1804807884018343], + [0.21263900587151027, 0.715168678767756, 0.07219231536073371], + [0.01933081871559182, 0.11919477979462598, 0.9505321522496607] + ]) + + def to_xyz(self): + return XYZColor(*(self.RGB_TO_XYZ @ Matrix.as_column(self)).get_column()) + WebColor = RGBColor @@ -160,15 +173,43 @@ WebColor = RGBColor class SRGBColor(namedtuple('_SRGBColor', 'red green blue')): """ - Represent a color in the sRGB-Linear space. + Represent a color in the sRGB space. *New in 0.12.0* - """ + """ + red: float + green: float + blue: float + + def __str__(self): + return f"srgb({self.red}, {self.green}, {self.blue})" + def to_rgb(self): return RGBColor(*( ((-1 if i < 0 else 1) * (1.055 * (abs(i) ** (1/2.4)) - 0.055) if abs(i) > 0.0031308 else 12.92 * i) for i in self)) + def to_xyz(self): + return self.to_rgb().to_xyz() -__all__ = ('chalk', 'WebColor') + + +class XYZColor(namedtuple('_XYZColor', 'x y z')): + """ + Represent a color in the XYZ color space. + """ + + XYZ_TO_RGB = Matrix([ + [ 3.2409699419045226, -1.537383177570094, -0.4986107602930034], + [-0.9692436362808796, 1.8759675015077202, 0.04155505740717559], + [ 0.05563007969699366, -0.20397695888897652, 1.0569715142428786] + ]) + + def to_rgb(self): + return RGBColor(*(self.XYZ_TO_RGB @ Matrix.as_column(self)).get_column()) + + + + +__all__ = ('chalk', 'WebColor', "RGBColor", 'SRGBColor') diff --git a/src/suou/functools.py b/src/suou/functools.py index c68c6b6..e72b689 100644 --- a/src/suou/functools.py +++ b/src/suou/functools.py @@ -341,4 +341,4 @@ def none_pass(func: Callable[_T, _U], *args, **kwargs) -> Callable[_T, _U]: __all__ = ( 'deprecated', 'not_implemented', 'timed_cache', 'none_pass', 'alru_cache' -) \ No newline at end of file +) diff --git a/src/suou/glue.py b/src/suou/glue.py index 6368deb..2ead20b 100644 --- a/src/suou/glue.py +++ b/src/suou/glue.py @@ -66,4 +66,4 @@ def glue(*modules): return decorator # This module is experimental and therefore not re-exported into __init__ -__all__ = ('glue', 'FakeModule') \ No newline at end of file +__all__ = ('glue', 'FakeModule') diff --git a/src/suou/legal.py b/src/suou/legal.py index 8046435..91d9c8b 100644 --- a/src/suou/legal.py +++ b/src/suou/legal.py @@ -95,4 +95,4 @@ class Lawyer(SpitText): return self.format(COMPLETENESS, 'app_name') # This module is experimental and therefore not re-exported into __init__ -__all__ = ('Lawyer',) \ No newline at end of file +__all__ = ('Lawyer',) diff --git a/src/suou/mat.py b/src/suou/mat.py index 4f8f2b9..b0b201b 100644 --- a/src/suou/mat.py +++ b/src/suou/mat.py @@ -116,6 +116,25 @@ class Matrix(Collection[_T]): [self[j, i] for j in range(sx)] for i in range(sy) ) + @classmethod + def as_row(cls, iterable: Iterable): + return cls([[*iterable]]) + + @classmethod + def as_column(cls, iterable: Iterable): + return cls([[x] for x in iterable]) + + def get_column(self, idx = 0): + sx, _ = self.shape() + return [ + self[j, idx] for j in range(sx) + ] + + def get_row(self, idx = 0): + _, sy = self.shape() + return [ + self[idx, j] for j in range(sy) + ] __all__ = ('Matrix', ) diff --git a/src/suou/migrate.py b/src/suou/migrate.py index 357dc03..5cb199c 100644 --- a/src/suou/migrate.py +++ b/src/suou/migrate.py @@ -135,4 +135,4 @@ class UlidSiqMigrator(SiqMigrator): __all__ = ( 'SnowflakeSiqMigrator', 'UlidSiqMigrator' -) \ No newline at end of file +) From b1d0c62b448d490dc18f2c8a246e329b4993416c Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Thu, 25 Dec 2025 11:01:10 +0100 Subject: [PATCH 121/121] 0.12.0a5 add OKLab and oklch --- CHANGELOG.md | 4 +- src/suou/__init__.py | 11 +++-- src/suou/color.py | 98 ++++++++++++++++++++++++++++++++++++++++++-- src/suou/mat.py | 4 +- 4 files changed, 105 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 952cd75..2bb633f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,11 @@ # Changelog -## 0.12.0 +## 0.12.0 "The Color Update" * All `AuthSrc()` derivatives, deprecated and never used, have been removed. * New module `mat` adds a shallow reimplementation of `Matrix()` in order to implement matrix multiplication * Removed obsolete `configparse` implementation that has been around since 0.3 and shelved since 0.4. -* `color`: added support for conversion from RGB to sRGB, XYZ +* `color`: added support for conversion from RGB to sRGB, XYZ, OKLab and OKLCH. ## 0.11.2 diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 2753744..ce85766 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -35,20 +35,19 @@ from .strtools import PrefixIdentifier from .validators import matches, not_less_than, not_greater_than, yesno from .redact import redact_url_password from .http import WantsContentType -from .color import chalk, WebColor +from .color import OKLabColor, chalk, WebColor, RGBColor, SRGBColor, XYZColor, OKLabColor from .mat import Matrix -__version__ = "0.12.0a4" +__version__ = "0.12.0a5" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', 'DictConfigSource', 'EnvConfigSource', 'I18n', 'Incomplete', 'JsonI18n', - 'Matrix', - 'MissingConfigError', 'MissingConfigWarning', 'PrefixIdentifier', - 'RGBColor', 'SRGBColor', + 'Matrix', 'MissingConfigError', 'MissingConfigWarning', 'OKLabColor', + 'PrefixIdentifier', 'RGBColor', 'SRGBColor', 'Siq', 'SiqCache', 'SiqGen', 'SiqType', 'Snowflake', 'SnowflakeGen', 'StringCase', 'TimedDict', 'TomlI18n', 'UserSigner', 'Wanted', 'WantsContentType', - 'WebColor', + 'WebColor', 'XYZColor', 'addattr', 'additem', 'age_and_days', 'alru_cache', 'b2048decode', 'b2048encode', 'b32ldecode', 'b32lencode', 'b64encode', 'b64decode', 'cb32encode', 'cb32decode', 'chalk', 'count_ones', 'dei_args', 'deprecated', diff --git a/src/suou/color.py b/src/suou/color.py index e8383fb..058fcc7 100644 --- a/src/suou/color.py +++ b/src/suou/color.py @@ -21,6 +21,7 @@ from __future__ import annotations from collections import namedtuple from functools import lru_cache +import math from suou.mat import Matrix @@ -100,6 +101,9 @@ class RGBColor(namedtuple('_WebColor', 'red green blue')): Representation of a color in the RGB TrueColor space. Useful for theming. + + *Changed in 0.12.0*: name is now RGBColor, with WebColor being an alias. + Added conversions to and from OKLCH, OKLab, sRGB, and XYZ. """ def lighten(self, *, factor = .75): """ @@ -149,7 +153,8 @@ class RGBColor(namedtuple('_WebColor', 'red green blue')): (-1 if i < 0 else 1) * (((abs(i) + 0.55)) / 1.055) ** 2.4) for i in self )) - + def to_oklab(self): + return self.to_xyz().to_oklab() __add__ = blend_with @@ -165,6 +170,11 @@ class RGBColor(namedtuple('_WebColor', 'red green blue')): def to_xyz(self): return XYZColor(*(self.RGB_TO_XYZ @ Matrix.as_column(self)).get_column()) + def to_oklch(self): + return self.to_xyz().to_oklch() + + def to_oklab(self): + return self.to_xyz().to_oklab() WebColor = RGBColor @@ -192,12 +202,15 @@ class SRGBColor(namedtuple('_SRGBColor', 'red green blue')): def to_xyz(self): return self.to_rgb().to_xyz() - + def to_oklab(self): + return self.to_rgb().to_oklab() class XYZColor(namedtuple('_XYZColor', 'x y z')): """ Represent a color in the XYZ color space. + + *New in 0.12.0* """ XYZ_TO_RGB = Matrix([ @@ -206,10 +219,89 @@ class XYZColor(namedtuple('_XYZColor', 'x y z')): [ 0.05563007969699366, -0.20397695888897652, 1.0569715142428786] ]) + XYZ_TO_LMS = Matrix([ + [0.8190224379967030, 0.3619062600528904, -0.1288737815209879], + [0.0329836539323885, 0.9292868615863434, 0.0361446663506424], + [0.0481771893596242, 0.2642395317527308, 0.6335478284694309] + ]) + + LMSG_TO_OKLAB = Matrix([ + [0.2104542683093140, 0.7936177747023054, -0.0040720430116193], + [1.9779985324311684, -2.4285922420485799, 0.4505937096174110], + [0.0259040424655478, 0.7827717124575296, -0.8086757549230774] + ]) + def to_rgb(self): return RGBColor(*(self.XYZ_TO_RGB @ Matrix.as_column(self)).get_column()) + def to_oklab(self): + lms = (self.XYZ_TO_LMS @ Matrix.as_column(self)).get_column() + lmsg = [math.cbrt(i) for i in lms] + oklab = (self.LMSG_TO_OKLAB @ Matrix.as_column(self)).get_column() + return OKLabColor(*oklab) + + def to_oklch(self): + return self.to_oklab().to_oklch() +class OKLabColor(namedtuple('_OKLabColor', 'l a b')): + """ + Represent a color in the OKLab color space. -__all__ = ('chalk', 'WebColor', "RGBColor", 'SRGBColor') + *New in 0.12.0* + """ + + OKLAB_TO_LMSG = Matrix([ + [1., 0.3963377773761749, 0.2158037573099136], + [1., -0.1055613458156586, -0.0638541728258133], + [1., -0.0894841775298119, -1.2914855480194092] + ]) + + LMS_TO_XYZ = Matrix([ + [ 1.2268798758459243, -0.5578149944602171, 0.2813910456659647], + [-0.0405757452148008, 1.1122868032803170, -0.0717110580655164], + [-0.0763729366746601, -0.4214933324022432, 1.5869240198367816] + ]) + + def to_xyz(self): + lmsg = (self.OKLAB_TO_LMSG @ Matrix.as_column(self)).get_column() + lms = [i ** 3 for i in lmsg] + xyz = (self.LMS_TO_XYZ @ Matrix.as_column(lms)).get_column() + return XYZColor(*xyz) + + def to_oklch(self): + return OKLCHColor( + self.l, + math.sqrt(self.a ** 2 + self.b ** 2), + 0 if abs(self.a) < .0002 and abs(self.b) < .0002 else (((math.atan2(self.b, self.a) * 180) / math.pi % 360) + 360) % 360 + ) + + def to_rgb(self): + return self.to_xyz().to_rgb() + +class OKLCHColor(namedtuple('_OKLCHColor', 'l c h')): + """ + Represent a color in the OKLCH color space. + + *Warning*: conversion to RGB is not bound checked yet! + + *New in 0.12.0* + """ + + def __str__(self): + l, c, h = round(self.l, 4), round(self.c, 4), round(self.h, 4) + + return f'oklch({l}, {c}, {h})' + + + def to_oklab(self): + return OKLabColor( + self.l, + self.c * math.cos(self.h * math.pi / 180), + self.h * math.cos(self.h * math.pi / 180) + ) + + def to_rgb(self): + return self.to_oklab().to_rgb() + +__all__ = ('chalk', 'WebColor', "RGBColor", 'SRGBColor', 'XYZColor', 'OKLabColor') diff --git a/src/suou/mat.py b/src/suou/mat.py index b0b201b..8bcbb01 100644 --- a/src/suou/mat.py +++ b/src/suou/mat.py @@ -24,7 +24,9 @@ _T = TypeVar('_T') class Matrix(Collection[_T]): """ - Shallow reimplementation of numpy's matrices in pure Python. + Minimalist reimplementation of matrices in pure Python. + + This to avoid adding numpy as a dependency. *New in 0.12.0* """