From 4a31fbc14f7cbe89351c83d189032655421302dd Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Wed, 5 Nov 2025 10:47:08 +0100 Subject: [PATCH 01/19] 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 02/19] 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 db49a47ce58ada598547efe1d1fbbe8e8efa6beb Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Wed, 5 Nov 2025 18:08:49 +0100 Subject: [PATCH 03/19] 0.7.8 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 91b035a..d61a99a 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.8", "itsdangerous", "toml", "pydantic", diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 7411deb..e1bc39b 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.7.8" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', diff --git a/src/suou/sqlalchemy/orm.py b/src/suou/sqlalchemy/orm.py index 37b4def..bac68d3 100644 --- a/src/suou/sqlalchemy/orm.py +++ b/src/suou/sqlalchemy/orm.py @@ -256,6 +256,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) @@ -277,6 +279,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 04/19] 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 d454eaea2c24ddfb08301399189437b7621eec70 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Mon, 10 Nov 2025 17:18:13 +0100 Subject: [PATCH 05/19] 0.7.9 fix chalk behavior --- CHANGELOG.md | 17 ++++++++++++++ aliases/sakuragasaki46_suou/pyproject.toml | 2 +- src/suou/__init__.py | 7 ++++-- src/suou/color.py | 14 +++++++++++ tests/test_color.py | 27 ++++++++++++++++++++++ 5 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 tests/test_color.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c3b718..58ef000 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # 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` ++ 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 d61a99a..967dbec 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.8", + "suou==0.7.9", "itsdangerous", "toml", "pydantic", diff --git a/src/suou/__init__.py b/src/suou/__init__.py index e1bc39b..387cd8b 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -35,9 +35,11 @@ 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.7.9" -__version__ = "0.7.8" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', @@ -45,6 +47,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 06/19] 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 07/19] 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 08/19] 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 09/19] 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 10/19] 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 11/19] 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 12/19] 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 13/19] 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 14/19] 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 15/19] 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 16/19] 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 17/19] 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 18/19] 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 19/19] 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* """