add modules redact, sqlalchemy_async, functions none_pass()
This commit is contained in:
parent
a3330d4340
commit
73d3088d86
8 changed files with 177 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,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'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
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