add modules redact, sqlalchemy_async, functions none_pass()

This commit is contained in:
Yusur 2025-07-30 10:54:09 +02:00
parent a3330d4340
commit 73d3088d86
8 changed files with 177 additions and 7 deletions

View file

@ -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

View file

@ -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",

View file

@ -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'
)

View file

@ -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

View file

@ -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'
)

21
src/suou/redact.py Normal file
View file

@ -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', )

View file

@ -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')

View file

@ -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', )