Compare commits
3 commits
38ff59c76a
...
d30e1086f3
| Author | SHA1 | Date | |
|---|---|---|---|
| d30e1086f3 | |||
| 73d3088d86 | |||
| a3330d4340 |
14 changed files with 218 additions and 18 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -38,5 +38,6 @@ SENSITIVE_ENDPOINTS = """
|
|||
/.backup
|
||||
/db.sql
|
||||
/database.sql
|
||||
/.vite
|
||||
""".split()
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 attorney’s fees, arising out of any breach of this agreement.
|
||||
|
|
|
|||
17
src/suou/quart.py
Normal file
17
src/suou/quart.py
Normal 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
21
src/suou/redact.py
Normal 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', )
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
121
src/suou/sqlalchemy_async.py
Normal file
121
src/suou/sqlalchemy_async.py
Normal 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', )
|
||||
Loading…
Add table
Add a link
Reference in a new issue