Compare commits

..

3 commits

14 changed files with 218 additions and 18 deletions

View file

@ -3,10 +3,12 @@
## 0.5.0
+ `sqlalchemy`: add `unbound_fk()`, `bound_fk()`
+ Add `timed_cache()`, `TimedDict()`, `age_and_days()`
+ Add date conversion utilities
+ 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 more exceptions: `NotFoundError()`
+ Add `redact` module with `redact_url_password()`
+ Add more exceptions: `NotFoundError()`, `BabelTowerError()`
## 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,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 .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
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-dev29"
__version__ = "0.5.0-dev30"
__all__ = (
'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue',
'DictConfigSource', 'EnvConfigSource', 'I18n', 'Incomplete', 'JsonI18n',
'MissingConfigError', 'MissingConfigWarning', 'PrefixIdentifier', 'Siq', 'SiqCache', 'SiqGen',
'SiqType', 'Snowflake', 'SnowflakeGen', 'StringCase', 'TimedDict', 'TomlI18n', 'Wanted',
'MissingConfigError', 'MissingConfigWarning', 'PrefixIdentifier',
'Siq', 'SiqCache', 'SiqGen', 'SiqType', 'Snowflake', 'SnowflakeGen',
'StringCase', 'TimedDict', 'TomlI18n', 'Wanted',
'addattr', 'additem', 'age_and_days', 'b2048decode', 'b2048encode',
'b32ldecode', 'b32lencode', 'b64encode', 'b64decode', 'cb32encode',
'cb32decode', 'count_ones', 'deprecated', 'ilex', 'join_bits',
'jsonencode', 'kwargs_prefix', 'lex', 'ltuple', 'makelist', 'mask_shift',
'mod_ceil', 'mod_floor', 'not_implemented', 'rtuple', 'split_bits',
'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

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

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

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

View file

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

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

View file

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

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.
"""
# TODO more snippets
INDEMNIFY = """
You agree to indemnify and hold harmless {0} from any and all claims, damages, liabilities, costs and expenses, including reasonable and unreasonable counsel and 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.
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', )