diff --git a/CHANGELOG.md b/CHANGELOG.md index b89014a..cce9c06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,10 @@ ## 0.5.0 + `sqlalchemy`: add `unbound_fk()`, `bound_fk()` -+ 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()` ++ Add `timed_cache()`, `TimedDict()`, `age_and_days()` ++ Add date conversion utilities + Move obsolete stuff to `obsolete` package (includes configparse 0.3 as of now) -+ Add `redact` module with `redact_url_password()` -+ Add more exceptions: `NotFoundError()`, `BabelTowerError()` ++ Add more exceptions: `NotFoundError()` ## 0.4.0 diff --git a/pyproject.toml b/pyproject.toml index b1132f7..e73689a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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[asyncio]" + "SQLAlchemy>=2.0.0" ] flask = [ "Flask>=2.0.0", diff --git a/src/suou/__init__.py b/src/suou/__init__.py index b769b40..81955cf 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -23,30 +23,26 @@ 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, none_pass +from .functools import deprecated, not_implemented, timed_cache 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-dev30" +__version__ = "0.5.0-dev29" __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', - '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' + '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' ) diff --git a/src/suou/classtools.py b/src/suou/classtools.py index c27fa61..eefdba3 100644 --- a/src/suou/classtools.py +++ b/src/suou/classtools.py @@ -16,10 +16,12 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. from __future__ import annotations -from abc import abstractmethod +from abc import ABCMeta, abstractmethod from typing import Any, Callable, Generic, Iterable, Mapping, TypeVar import logging +from suou.codecs import StringCase + _T = TypeVar('_T') logger = logging.getLogger(__name__) @@ -67,6 +69,8 @@ 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 @@ -189,5 +193,3 @@ class ValueProperty(Generic[_T]): return self._srcs['default'] -__all__ = ('Wanted', 'Incomplete', 'ValueSource', 'ValueProperty') - diff --git a/src/suou/collections.py b/src/suou/collections.py index 090659d..37097d2 100644 --- a/src/suou/collections.py +++ b/src/suou/collections.py @@ -27,8 +27,6 @@ _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 diff --git a/src/suou/dorks.py b/src/suou/dorks.py index 81f860a..1d56c6e 100644 --- a/src/suou/dorks.py +++ b/src/suou/dorks.py @@ -38,6 +38,5 @@ SENSITIVE_ENDPOINTS = """ /.backup /db.sql /database.sql -/.vite """.split() diff --git a/src/suou/exceptions.py b/src/suou/exceptions.py index 7952bc7..c1fe496 100644 --- a/src/suou/exceptions.py +++ b/src/suou/exceptions.py @@ -45,13 +45,6 @@ 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' diff --git a/src/suou/functools.py b/src/suou/functools.py index 128a1ec..a34f023 100644 --- a/src/suou/functools.py +++ b/src/suou/functools.py @@ -70,8 +70,6 @@ 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 @@ -89,21 +87,7 @@ 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', 'none_pass' + 'deprecated', 'not_implemented', 'timed_cache' ) \ No newline at end of file diff --git a/src/suou/i18n.py b/src/suou/i18n.py index 254c104..7080019 100644 --- a/src/suou/i18n.py +++ b/src/suou/i18n.py @@ -23,7 +23,6 @@ import os import toml from typing import Mapping -from .exceptions import BabelTowerError class IdentityLang: ''' @@ -82,10 +81,7 @@ 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 + data = self.load_file(filename) l = self.langs.setdefault(name, I18nLang()) l.update(data[name] if name in data else data) if name != self.default_lang: diff --git a/src/suou/legal.py b/src/suou/legal.py index 422486d..f1da0ea 100644 --- a/src/suou/legal.py +++ b/src/suou/legal.py @@ -16,7 +16,6 @@ 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. diff --git a/src/suou/quart.py b/src/suou/quart.py deleted file mode 100644 index 9caaee1..0000000 --- a/src/suou/quart.py +++ /dev/null @@ -1,17 +0,0 @@ -""" -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 \ No newline at end of file diff --git a/src/suou/redact.py b/src/suou/redact.py deleted file mode 100644 index e8ee104..0000000 --- a/src/suou/redact.py +++ /dev/null @@ -1,21 +0,0 @@ -""" -"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', ) \ No newline at end of file diff --git a/src/suou/sqlalchemy.py b/src/suou/sqlalchemy.py index b297352..282fa78 100644 --- a/src/suou/sqlalchemy.py +++ b/src/suou/sqlalchemy.py @@ -232,8 +232,6 @@ 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}' @@ -253,8 +251,6 @@ 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}' @@ -288,8 +284,6 @@ 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') diff --git a/src/suou/sqlalchemy_async.py b/src/suou/sqlalchemy_async.py deleted file mode 100644 index 9ddd24f..0000000 --- a/src/suou/sqlalchemy_async.py +++ /dev/null @@ -1,121 +0,0 @@ -""" -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', ) \ No newline at end of file