From 73d3088d86797e92a05a3369f587d1fe3df0dd42 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Wed, 30 Jul 2025 10:54:09 +0200 Subject: [PATCH] add modules redact, sqlalchemy_async, functions none_pass() --- CHANGELOG.md | 4 +- pyproject.toml | 2 +- src/suou/__init__.py | 10 +-- src/suou/collections.py | 2 + src/suou/functools.py | 18 +++++- src/suou/redact.py | 21 ++++++ src/suou/sqlalchemy.py | 6 ++ src/suou/sqlalchemy_async.py | 121 +++++++++++++++++++++++++++++++++++ 8 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 src/suou/redact.py create mode 100644 src/suou/sqlalchemy_async.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4df9892..8510622 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,11 @@ ## 0.5.0 + `sqlalchemy`: add `unbound_fk()`, `bound_fk()` -+ Add `timed_cache()`, `TimedDict()` ++ Add `sqlalchemy_async` module with `SQLAlchemy()` ++ Add `timed_cache()`, `TimedDict()`, `none_pass` + Add module `calendar` with `want_*` date type conversion utilities and `age_and_days()` + Move obsolete stuff to `obsolete` package (includes configparse 0.3 as of now) ++ Add `redact` module with `redact_url_password()` + Add more exceptions: `NotFoundError()` ## 0.4.0 diff --git a/pyproject.toml b/pyproject.toml index e73689a..b1132f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ Repository = "https://github.com/sakuragasaki46/suou" [project.optional-dependencies] # the below are all dev dependencies (and probably already installed) sqlalchemy = [ - "SQLAlchemy>=2.0.0" + "SQLAlchemy>=2.0.0[asyncio]" ] flask = [ "Flask>=2.0.0", diff --git a/src/suou/__init__.py b/src/suou/__init__.py index d00606e..b769b40 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -23,7 +23,7 @@ from .bits import count_ones, mask_shift, split_bits, join_bits, mod_ceil, mod_f from .calendar import want_datetime, want_isodate, want_timestamp, age_and_days from .configparse import MissingConfigError, MissingConfigWarning, ConfigOptions, ConfigParserConfigSource, ConfigSource, DictConfigSource, ConfigValue, EnvConfigSource from .collections import TimedDict -from .functools import deprecated, not_implemented, timed_cache +from .functools import deprecated, not_implemented, timed_cache, none_pass from .classtools import Wanted, Incomplete from .itertools import makelist, kwargs_prefix, ltuple, rtuple, additem, addattr from .i18n import I18n, JsonI18n, TomlI18n @@ -31,6 +31,7 @@ from .snowflake import Snowflake, SnowflakeGen from .lex import symbol_table, lex, ilex from .strtools import PrefixIdentifier from .validators import matches +from .redact import redact_url_password __version__ = "0.5.0-dev30" @@ -44,7 +45,8 @@ __all__ = ( 'b32ldecode', 'b32lencode', 'b64encode', 'b64decode', 'cb32encode', 'cb32decode', 'count_ones', 'deprecated', 'ilex', 'join_bits', 'jsonencode', 'kwargs_prefix', 'lex', 'ltuple', 'makelist', 'mask_shift', - 'matches', 'mod_ceil', 'mod_floor', 'not_implemented', 'rtuple', 'split_bits', - 'ssv_list', 'symbol_table', 'timed_cache', 'want_bytes', 'want_datetime', - 'want_isodate', 'want_str', 'want_timestamp', 'want_urlsafe', 'want_urlsafe_bytes' + 'matches', 'mod_ceil', 'mod_floor', 'none_pass', 'not_implemented', + 'redact_url_password', 'rtuple', 'split_bits', 'ssv_list', 'symbol_table', + 'timed_cache', 'want_bytes', 'want_datetime', 'want_isodate', 'want_str', + 'want_timestamp', 'want_urlsafe', 'want_urlsafe_bytes' ) diff --git a/src/suou/collections.py b/src/suou/collections.py index 37097d2..090659d 100644 --- a/src/suou/collections.py +++ b/src/suou/collections.py @@ -27,6 +27,8 @@ _VT = TypeVar('_VT') class TimedDict(dict[_KT, _VT]): """ Dictionary where keys expire after the defined time to live, expressed in seconds. + + NEW 0.5.0 """ _expires: dict[_KT, int] _ttl: int diff --git a/src/suou/functools.py b/src/suou/functools.py index a34f023..128a1ec 100644 --- a/src/suou/functools.py +++ b/src/suou/functools.py @@ -70,6 +70,8 @@ def not_implemented(msg: Callable | str | None = None): def timed_cache(ttl: int, maxsize: int = 128, typed: bool = False) -> Callable[[Callable], Callable]: """ LRU cache which expires after the TTL in seconds passed as argument. + + NEW 0.5.0 """ def decorator(func): start_time = None @@ -87,7 +89,21 @@ def timed_cache(ttl: int, maxsize: int = 128, typed: bool = False) -> Callable[[ return wrapper return decorator +def none_pass(func: Callable, *args, **kwargs): + """ + Wrap callable so that gets called only on not None values. + + Shorthand for func(x) if x is not None else None + + NEW 0.5.0 + """ + @wraps(func) + def wrapper(x): + if x is None: + return x + return func(x, *args, **kwargs) + return wrapper __all__ = ( - 'deprecated', 'not_implemented', 'timed_cache' + 'deprecated', 'not_implemented', 'timed_cache', 'none_pass' ) \ No newline at end of file diff --git a/src/suou/redact.py b/src/suou/redact.py new file mode 100644 index 0000000..e8ee104 --- /dev/null +++ b/src/suou/redact.py @@ -0,0 +1,21 @@ +""" +"Security through obscurity" helpers for less sensitive logging +""" + +import re + + +def redact_url_password(u: str) -> str: + """ + Remove password from URIs. + + The password part in URIs is: + scheme://username:password@hostname/path?query + ^------^ + + NEW 0.5.0 + """ + return re.sub(r':[^@:/ ]+@', ':***@', u) + + +__all__ = ('redact_url_password', ) \ No newline at end of file diff --git a/src/suou/sqlalchemy.py b/src/suou/sqlalchemy.py index 282fa78..b297352 100644 --- a/src/suou/sqlalchemy.py +++ b/src/suou/sqlalchemy.py @@ -232,6 +232,8 @@ def unbound_fk(target: str | Column | InstrumentedAttribute, typ: TypeEngine | N "Unbound" foreign keys are nullable and set to null when referenced object is deleted. If target is a string, make sure to pass the column type at typ= (default: IdType aka varbinary(16))! + + NEW 0.5.0 """ if isinstance(target, (Column, InstrumentedAttribute)): target_name = f'{target.table.name}.{target.name}' @@ -251,6 +253,8 @@ def bound_fk(target: str | Column | InstrumentedAttribute, typ: TypeEngine | Non parent deleted -> all children deleted. If target is a string, make sure to pass the column type at typ= (default: IdType aka varbinary(16))! + + NEW 0.5.0 """ if isinstance(target, (Column, InstrumentedAttribute)): target_name = f'{target.table.name}.{target.name}' @@ -284,6 +288,8 @@ class AuthSrc(metaclass=ABCMeta): AuthSrc object required for require_auth_base(). This is an abstract class and is NOT usable directly. + + This is not part of the public API ''' def required_exc(self) -> Never: raise ValueError('required field missing') diff --git a/src/suou/sqlalchemy_async.py b/src/suou/sqlalchemy_async.py new file mode 100644 index 0000000..9ddd24f --- /dev/null +++ b/src/suou/sqlalchemy_async.py @@ -0,0 +1,121 @@ +""" +Helpers for asynchronous user of SQLAlchemy + +--- + +Copyright (c) 2025 Sakuragasaki46. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +See LICENSE for the specific language governing permissions and +limitations under the License. + +This software is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +""" + +from sqlalchemy import Engine, Select, func, select +from sqlalchemy.orm import DeclarativeBase, Session, lazyload +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from flask_sqlalchemy.pagination import Pagination + +from suou.exceptions import NotFoundError + +class SQLAlchemy: + """ + Drop-in (?) replacement for flask_sqlalchemy.SQLAlchemy() + eligible for async environments + + NEW 0.5.0 + """ + base: DeclarativeBase + engine: Engine + NotFound = NotFoundError + + def __init__(self, base: DeclarativeBase): + self.base = base + self.engine = None + def bind(self, url: str): + self.engine = create_async_engine(url) + async def begin(self): + if self.engine is None: + raise RuntimeError('database is not connected') + return await self.engine.begin() + __aenter__ = begin + async def __aexit__(self, e1, e2, e3): + return await self.engine.__aexit__(e1, e2, e3) + async def paginate(self, select: Select, *, + page: int | None = None, per_page: int | None = None, + max_per_page: int | None = None, error_out: bool = True, + count: bool = True): + """ + """ + async with self as session: + return AsyncSelectPagination( + select = select, + session = session, + page = page, + per_page=per_page, max_per_page=max_per_page, + error_out=self.NotFound if error_out else None, count=count + ) + + + +class AsyncSelectPagination(Pagination): + """ + flask_sqlalchemy.SelectPagination but asynchronous + """ + + async def _query_items(self) -> list: + select_q: Select = self._query_args["select"] + select = select_q.limit(self.per_page).offset(self._query_offset) + session: AsyncSession = self._query_args["session"] + out = (await session.execute(select)).scalars() + + async def _query_count(self) -> int: + select_q: Select = self._query_args["select"] + sub = select_q.options(lazyload("*")).order_by(None).subquery() + session: AsyncSession = self._query_args["session"] + out = await session.execute(select(func.count()).select_from(sub)) + return out + + def __init__(self, + page: int | None = None, + per_page: int | None = None, + max_per_page: int | None = 100, + error_out: Exception | None = NotFoundError, + count: bool = True, + **kwargs): + ## XXX flask-sqlalchemy says Pagination() is not public API. + ## Things may break; beware. + self._query_args = kwargs + page, per_page = self._prepare_page_args( + page=page, + per_page=per_page, + max_per_page=max_per_page, + error_out=error_out, + ) + + self.page: int = page + """The current page.""" + + self.per_page: int = per_page + """The maximum number of items on a page.""" + + self.max_per_page: int | None = max_per_page + """The maximum allowed value for ``per_page``.""" + + self.items = None + self.total = None + self.error_out = error_out + self.has_count = count + + async def __await__(self): + self.items = await self._query_items() + if not self.items and self.page != 1 and self.error_out: + raise self.error_out + if self.has_count: + self.total = await self._query_count() + return self + +__all__ = ('SQLAlchemy', ) \ No newline at end of file