Compare commits

..

3 commits

14 changed files with 218 additions and 18 deletions

View file

@ -3,10 +3,12 @@
## 0.5.0 ## 0.5.0
+ `sqlalchemy`: add `unbound_fk()`, `bound_fk()` + `sqlalchemy`: add `unbound_fk()`, `bound_fk()`
+ Add `timed_cache()`, `TimedDict()`, `age_and_days()` + Add `sqlalchemy_async` module with `SQLAlchemy()`
+ Add date conversion utilities + 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) + Move obsolete stuff to `obsolete` package (includes configparse 0.3 as of now)
+ Add more exceptions: `NotFoundError()` + Add `redact` module with `redact_url_password()`
+ Add more exceptions: `NotFoundError()`, `BabelTowerError()`
## 0.4.0 ## 0.4.0

View file

@ -34,7 +34,7 @@ Repository = "https://github.com/sakuragasaki46/suou"
[project.optional-dependencies] [project.optional-dependencies]
# the below are all dev dependencies (and probably already installed) # the below are all dev dependencies (and probably already installed)
sqlalchemy = [ sqlalchemy = [
"SQLAlchemy>=2.0.0" "SQLAlchemy>=2.0.0[asyncio]"
] ]
flask = [ flask = [
"Flask>=2.0.0", "Flask>=2.0.0",

View file

@ -23,26 +23,30 @@ 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 .calendar import want_datetime, want_isodate, want_timestamp, age_and_days
from .configparse import MissingConfigError, MissingConfigWarning, ConfigOptions, ConfigParserConfigSource, ConfigSource, DictConfigSource, ConfigValue, EnvConfigSource from .configparse import MissingConfigError, MissingConfigWarning, ConfigOptions, ConfigParserConfigSource, ConfigSource, DictConfigSource, ConfigValue, EnvConfigSource
from .collections import TimedDict 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 .classtools import Wanted, Incomplete
from .itertools import makelist, kwargs_prefix, ltuple, rtuple, additem, addattr from .itertools import makelist, kwargs_prefix, ltuple, rtuple, additem, addattr
from .i18n import I18n, JsonI18n, TomlI18n from .i18n import I18n, JsonI18n, TomlI18n
from .snowflake import Snowflake, SnowflakeGen from .snowflake import Snowflake, SnowflakeGen
from .lex import symbol_table, lex, ilex from .lex import symbol_table, lex, ilex
from .strtools import PrefixIdentifier from .strtools import PrefixIdentifier
from .validators import matches
from .redact import redact_url_password
__version__ = "0.5.0-dev29" __version__ = "0.5.0-dev30"
__all__ = ( __all__ = (
'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue',
'DictConfigSource', 'EnvConfigSource', 'I18n', 'Incomplete', 'JsonI18n', 'DictConfigSource', 'EnvConfigSource', 'I18n', 'Incomplete', 'JsonI18n',
'MissingConfigError', 'MissingConfigWarning', 'PrefixIdentifier', 'Siq', 'SiqCache', 'SiqGen', 'MissingConfigError', 'MissingConfigWarning', 'PrefixIdentifier',
'SiqType', 'Snowflake', 'SnowflakeGen', 'StringCase', 'TimedDict', 'TomlI18n', 'Wanted', 'Siq', 'SiqCache', 'SiqGen', 'SiqType', 'Snowflake', 'SnowflakeGen',
'StringCase', 'TimedDict', 'TomlI18n', 'Wanted',
'addattr', 'additem', 'age_and_days', 'b2048decode', 'b2048encode', 'addattr', 'additem', 'age_and_days', 'b2048decode', 'b2048encode',
'b32ldecode', 'b32lencode', 'b64encode', 'b64decode', 'cb32encode', 'b32ldecode', 'b32lencode', 'b64encode', 'b64decode', 'cb32encode',
'cb32decode', 'count_ones', 'deprecated', 'ilex', 'join_bits', 'cb32decode', 'count_ones', 'deprecated', 'ilex', 'join_bits',
'jsonencode', 'kwargs_prefix', 'lex', 'ltuple', 'makelist', 'mask_shift', 'jsonencode', 'kwargs_prefix', 'lex', 'ltuple', 'makelist', 'mask_shift',
'mod_ceil', 'mod_floor', 'not_implemented', 'rtuple', 'split_bits', 'matches', 'mod_ceil', 'mod_floor', 'none_pass', 'not_implemented',
'ssv_list', 'symbol_table', 'timed_cache', 'want_bytes', 'want_datetime', 'redact_url_password', 'rtuple', 'split_bits', 'ssv_list', 'symbol_table',
'want_isodate', 'want_str', 'want_timestamp', 'want_urlsafe', 'want_urlsafe_bytes' 'timed_cache', 'want_bytes', 'want_datetime', 'want_isodate', 'want_str',
'want_timestamp', 'want_urlsafe', 'want_urlsafe_bytes'
) )

View file

@ -16,12 +16,10 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
from __future__ import annotations from __future__ import annotations
from abc import ABCMeta, abstractmethod from abc import abstractmethod
from typing import Any, Callable, Generic, Iterable, Mapping, TypeVar from typing import Any, Callable, Generic, Iterable, Mapping, TypeVar
import logging import logging
from suou.codecs import StringCase
_T = TypeVar('_T') _T = TypeVar('_T')
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -69,8 +67,6 @@ class Incomplete(Generic[_T]):
Missing arguments must be passed in the appropriate positions Missing arguments must be passed in the appropriate positions
(positional or keyword) as a Wanted() object. (positional or keyword) as a Wanted() object.
""" """
# XXX disabled for https://stackoverflow.com/questions/45864273/slots-conflicts-with-a-class-variable-in-a-generic-class
#__slots__ = ('_obj', '_args', '_kwargs')
_obj = Callable[Any, _T] _obj = Callable[Any, _T]
_args: Iterable _args: Iterable
_kwargs: dict _kwargs: dict
@ -193,3 +189,5 @@ class ValueProperty(Generic[_T]):
return self._srcs['default'] return self._srcs['default']
__all__ = ('Wanted', 'Incomplete', 'ValueSource', 'ValueProperty')

View file

@ -27,6 +27,8 @@ _VT = TypeVar('_VT')
class TimedDict(dict[_KT, _VT]): class TimedDict(dict[_KT, _VT]):
""" """
Dictionary where keys expire after the defined time to live, expressed in seconds. Dictionary where keys expire after the defined time to live, expressed in seconds.
NEW 0.5.0
""" """
_expires: dict[_KT, int] _expires: dict[_KT, int]
_ttl: int _ttl: int

View file

@ -38,5 +38,6 @@ SENSITIVE_ENDPOINTS = """
/.backup /.backup
/db.sql /db.sql
/database.sql /database.sql
/.vite
""".split() """.split()

View file

@ -45,6 +45,13 @@ class NotFoundError(LookupError):
""" """
The requested item was not found. The requested item was not found.
""" """
# Werkzeug et al.
code = 404
class BabelTowerError(NotFoundError):
"""
The user requested a language that cannot be understood.
"""
__all__ = ( __all__ = (
'MissingConfigError', 'MissingConfigWarning', 'LexError', 'InconsistencyError', 'NotFoundError' 'MissingConfigError', 'MissingConfigWarning', 'LexError', 'InconsistencyError', 'NotFoundError'

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]: 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. LRU cache which expires after the TTL in seconds passed as argument.
NEW 0.5.0
""" """
def decorator(func): def decorator(func):
start_time = None start_time = None
@ -87,7 +89,21 @@ def timed_cache(ttl: int, maxsize: int = 128, typed: bool = False) -> Callable[[
return wrapper return wrapper
return decorator 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__ = ( __all__ = (
'deprecated', 'not_implemented', 'timed_cache' 'deprecated', 'not_implemented', 'timed_cache', 'none_pass'
) )

View file

@ -23,6 +23,7 @@ import os
import toml import toml
from typing import Mapping from typing import Mapping
from .exceptions import BabelTowerError
class IdentityLang: class IdentityLang:
''' '''
@ -81,7 +82,10 @@ class I18n(metaclass=ABCMeta):
def load_lang(self, name: str, filename: str | None = None) -> I18nLang: def load_lang(self, name: str, filename: str | None = None) -> I18nLang:
if not filename: if not filename:
filename = self.filename_tmpl.format(lang=name, ext=self.EXT) filename = self.filename_tmpl.format(lang=name, ext=self.EXT)
data = self.load_file(filename) try:
data = self.load_file(filename)
except OSError as e:
raise BabelTowerError(f'unknown language: {name}') from e
l = self.langs.setdefault(name, I18nLang()) l = self.langs.setdefault(name, I18nLang())
l.update(data[name] if name in data else data) l.update(data[name] if name in data else data)
if name != self.default_lang: if name != self.default_lang:

View file

@ -16,6 +16,7 @@ This software is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
""" """
# TODO more snippets
INDEMNIFY = """ 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 attorneys fees, arising out of any breach of this agreement. You agree to indemnify and hold harmless {0} from any and all claims, damages, liabilities, costs and expenses, including reasonable and unreasonable counsel and attorneys fees, arising out of any breach of this agreement.

17
src/suou/quart.py Normal file
View file

@ -0,0 +1,17 @@
"""
Utilities for Quart, asynchronous successor of Flask
---
Copyright (c) 2025 Sakuragasaki46.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
See LICENSE for the specific language governing permissions and
limitations under the License.
This software is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
# TODO everything

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. "Unbound" foreign keys are nullable and set to null when referenced object is deleted.
If target is a string, make sure to pass the column type at typ= (default: IdType aka varbinary(16))! If 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)): if isinstance(target, (Column, InstrumentedAttribute)):
target_name = f'{target.table.name}.{target.name}' 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. parent deleted -> all children deleted.
If target is a string, make sure to pass the column type at typ= (default: IdType aka varbinary(16))! If 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)): if isinstance(target, (Column, InstrumentedAttribute)):
target_name = f'{target.table.name}.{target.name}' target_name = f'{target.table.name}.{target.name}'
@ -284,6 +288,8 @@ class AuthSrc(metaclass=ABCMeta):
AuthSrc object required for require_auth_base(). AuthSrc object required for require_auth_base().
This is an abstract class and is NOT usable directly. This is an abstract class and is NOT usable directly.
This is not part of the public API
''' '''
def required_exc(self) -> Never: def required_exc(self) -> Never:
raise ValueError('required field missing') 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', )