diff --git a/CHANGELOG.md b/CHANGELOG.md index e28e673..b4ba99a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,5 @@ # Changelog -## 0.12.0 - -* All `AuthSrc()` derivatives, deprecated and never used, have been removed. -* New module `mat` adds a shallow reimplementation of `Matrix()` in order to implement matrix multiplication -* Removed obsolete `configparse` implementation that has been around since 0.3 and shelved since 0.4. -* `color`: added support for conversion from RGB to sRGB - ## 0.11.2 + increase test coverage of `validators` diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 06f5698..c3c8724 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -36,14 +36,12 @@ from .validators import matches, not_less_than, not_greater_than, yesno from .redact import redact_url_password from .http import WantsContentType from .color import chalk, WebColor -from .mat import Matrix -__version__ = "0.12.0a3" +__version__ = "0.11.2" __all__ = ( 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', 'DictConfigSource', 'EnvConfigSource', 'I18n', 'Incomplete', 'JsonI18n', - 'Matrix', 'MissingConfigError', 'MissingConfigWarning', 'PrefixIdentifier', 'Siq', 'SiqCache', 'SiqGen', 'SiqType', 'Snowflake', 'SnowflakeGen', 'StringCase', 'TimedDict', 'TomlI18n', 'UserSigner', 'Wanted', 'WantsContentType', diff --git a/src/suou/color.py b/src/suou/color.py index dfd2a6d..5a8b899 100644 --- a/src/suou/color.py +++ b/src/suou/color.py @@ -93,9 +93,9 @@ chalk = Chalk() ## Utilities for web colors -class RGBColor(namedtuple('_WebColor', 'red green blue')): +class WebColor(namedtuple('_WebColor', 'red green blue')): """ - Representation of a color in the RGB TrueColor space. + Representation of a color in the TrueColor space (aka rgb). Useful for theming. """ @@ -126,49 +126,21 @@ class RGBColor(namedtuple('_WebColor', 'red green blue')): """ return self.darken(factor=factor) + self.lighten(factor=factor) - def blend_with(self, other: RGBColor): + def blend_with(self, other: WebColor): """ Mix two colors, returning the average. """ - return RGBColor ( + return WebColor ( (self.red + other.red) // 2, (self.green + other.green) // 2, (self.blue + other.blue) // 2 ) - def to_srgb(self): - """ - Convert to sRGB space. - - *New in 0.12.0* - """ - return SRGBColor(*( - (i / 12.92 if abs(c) <= 0.04045 else - (-1 if i < 0 else 1) * (((abs(c) + 0.55)) / 1.055) ** 2.4) for i in self - )) - __add__ = blend_with def __str__(self): return f"rgb({self.red}, {self.green}, {self.blue})" -WebColor = RGBColor - -## The following have been adapted from -## https://gist.github.com/dkaraush/65d19d61396f5f3cd8ba7d1b4b3c9432 - -class SRGBColor(namedtuple('_SRGBColor', 'red green blue')): - """ - Represent a color in the sRGB-Linear space. - - *New in 0.12.0* - """ - def to_rgb(self): - return RGBColor(*( - ((-1 if i < 0 else 1) * (1.055 * (abs(i) ** (1/2.4)) - 0.055) - if abs(i) > 0.0031308 else 12.92 * i) for i in self)) - - __all__ = ('chalk', 'WebColor') diff --git a/src/suou/flask_sqlalchemy.py b/src/suou/flask_sqlalchemy.py index 3f1caf0..88122d2 100644 --- a/src/suou/flask_sqlalchemy.py +++ b/src/suou/flask_sqlalchemy.py @@ -1,7 +1,7 @@ """ Utilities for Flask-SQLAlchemy binding. -This module has been emptied in 0.12.0 following deprecation removals. +This module is deprecated and will be REMOVED in 0.14.0. --- @@ -16,6 +16,50 @@ This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. """ +from functools import partial +from typing import Any, Callable, Never + +from flask import abort, request +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy.orm import DeclarativeBase, Session +from .functools import deprecated + +from .codecs import want_bytes +from .sqlalchemy import AuthSrc, require_auth_base + +@deprecated('inherits from deprecated and unused class') +class FlaskAuthSrc(AuthSrc): + ''' + + ''' + db: SQLAlchemy + def __init__(self, db: SQLAlchemy): + super().__init__() + self.db = db + def get_session(self) -> Session: + return self.db.session + def get_token(self): + if request.authorization: + return request.authorization.token + def get_signature(self) -> bytes: + sig = request.headers.get('authorization-signature', None) + return want_bytes(sig) if sig else None + def invalid_exc(self, msg: str = 'validation failed') -> Never: + abort(400, msg) + def required_exc(self): + abort(401, 'Login required') + +@deprecated('not intuitive to use') +def require_auth(cls: type[DeclarativeBase], db: SQLAlchemy) -> Callable[Any, Callable]: + """ + + """ + def auth_required(**kwargs): + return require_auth_base(cls=cls, src=FlaskAuthSrc(db), **kwargs) + + auth_required.__doc__ = require_auth_base.__doc__ + + return auth_required # Optional dependency: do not import into __init__.py __all__ = () diff --git a/src/suou/iding.py b/src/suou/iding.py index 7997da3..a2e0c37 100644 --- a/src/suou/iding.py +++ b/src/suou/iding.py @@ -249,20 +249,13 @@ class Siq(int): def to_base64(self, length: int = 15, *, strip: bool = True) -> str: return b64encode(self.to_bytes(length), strip=strip) - def to_cb32(self) -> str: return cb32encode(self.to_bytes(15, 'big')).lstrip('0') to_crockford = to_cb32 - @classmethod - def from_cb32(cls, val: str | bytes): - return cls.from_bytes(cb32decode(want_str(val).zfill(24))) - def to_hex(self) -> str: return f'{self:x}' - def to_oct(self) -> str: return f'{self:o}' - def to_b32l(self) -> str: """ This is NOT the URI serializer! @@ -312,10 +305,12 @@ class Siq(int): raise ValueError('checksum mismatch') return cls(int.from_bytes(b, 'big')) + @classmethod + def from_cb32(cls, val: str | bytes): + return cls.from_bytes(cb32decode(want_str(val).zfill(24))) def to_mastodon(self, /, domain: str | None = None): return f'@{self:u}{"@" if domain else ""}{domain}' - def to_matrix(self, /, domain: str): return f'@{self:u}:{domain}' diff --git a/src/suou/mat.py b/src/suou/mat.py deleted file mode 100644 index 4f8f2b9..0000000 --- a/src/suou/mat.py +++ /dev/null @@ -1,122 +0,0 @@ -""" -Matrix (not the movie...) - -*New in 0.12.0* - ---- - -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 __future__ import annotations -from typing import Collection, Iterable, TypeVar -from .functools import deprecated - -_T = TypeVar('_T') - -class Matrix(Collection[_T]): - """ - Shallow reimplementation of numpy's matrices in pure Python. - - *New in 0.12.0* - """ - _shape: tuple[int, int] - _elements: list[_T] - - def shape(self): - return self._shape - - def __init__(self, iterable: Iterable[_T] | Iterable[Collection[_T]], shape: tuple[int, int] | None = None): - elements = [] - boundary_x = boundary_y = 0 - for row in iterable: - if isinstance(row, Collection): - if not boundary_y: - boundary_y = len(row) - elements.extend(row) - boundary_x += 1 - elif boundary_y != len(row): - raise ValueError('row length mismatch') - else: - elements.extend(row) - boundary_x += 1 - elif shape: - if not boundary_x: - boundary_x, boundary_y = shape - elements.append(row) - self._shape = boundary_x, boundary_y - self._elements = elements - assert len(self._elements) == boundary_x * boundary_y - - def __getitem__(self, key: tuple[int, int]) -> _T: - (x, y), (_, sy) = key, self.shape() - - return self._elements[x * sy + y] - - @property - def T(self): - sx, sy = self.shape() - return Matrix( - [ - [ - self[j, i] for j in range(sx) - ] for i in range(sy) - ] - ) - - def __matmul__(self, other: Matrix) -> Matrix: - (ax, ay), (bx, by) = self.shape(), other.shape() - - if ay != bx: - raise ValueError('cannot multiply matrices with incompatible shape') - - return Matrix([ - [ - sum(self[i, k] * other[k, j] for k in range(ay)) for j in range(by) - ] for i in range(ax) - ]) - - def __eq__(self, other: Matrix): - try: - return self._elements == other._elements and self._shape == other._shape - except Exception: - return False - - def __len__(self): - ax, ay = self.shape() - return ax * ay - - @deprecated('please use .rows() or .columns() instead') - def __iter__(self): - return iter(self._elements) - - def __contains__(self, x: object, /) -> bool: - return x in self._elements - - def __repr__(self): - return f'{self.__class__.__name__}({list(self.rows())})' - - def rows(self): - sx, sy = self.shape() - return ( - [self[j, i] for j in range(sy)] for i in range(sx) - ) - - def columns(self): - sx, sy = self.shape() - return ( - [self[j, i] for j in range(sx)] for i in range(sy) - ) - - -__all__ = ('Matrix', ) - - diff --git a/src/suou/obsolete/configparsev0_3.py b/src/suou/obsolete/configparsev0_3.py new file mode 100644 index 0000000..1563813 --- /dev/null +++ b/src/suou/obsolete/configparsev0_3.py @@ -0,0 +1,239 @@ +""" +Utilities for parsing config variables. + +BREAKING older, non-generalized version, kept for backwards compability +in case 0.4+ version happens to break. + +WILL BE removed in 0.5.0. + +--- + +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 __future__ import annotations +from ast import TypeVar +from collections.abc import Mapping +from configparser import ConfigParser as _ConfigParser +import os +from typing import Any, Callable, Iterator +from collections import OrderedDict +import warnings + +from ..functools import deprecated +from ..exceptions import MissingConfigError, MissingConfigWarning + +warnings.warn('This module will be removed in 0.5.0 and is kept only in case new implementation breaks!\n'\ + 'Do not use unless you know what you are doing.', DeprecationWarning) + +MISSING = object() +_T = TypeVar('T') + + +@deprecated('use configparse') +class ConfigSource(Mapping): + ''' + Abstract config source. + ''' + __slots__ = () + +@deprecated('use configparse') +class EnvConfigSource(ConfigSource): + ''' + Config source from os.environ aka .env + ''' + def __getitem__(self, key: str, /) -> str: + return os.environ[key] + def get(self, key: str, fallback = None, /): + return os.getenv(key, fallback) + def __contains__(self, key: str, /) -> bool: + return key in os.environ + def __iter__(self) -> Iterator[str]: + yield from os.environ + def __len__(self) -> int: + return len(os.environ) + +@deprecated('use configparse') +class ConfigParserConfigSource(ConfigSource): + ''' + Config source from ConfigParser + ''' + __slots__ = ('_cfp', ) + _cfp: _ConfigParser + + def __init__(self, cfp: _ConfigParser): + if not isinstance(cfp, _ConfigParser): + raise TypeError(f'a ConfigParser object is required (got {cfp.__class__.__name__!r})') + self._cfp = cfp + def __getitem__(self, key: str, /) -> str: + k1, _, k2 = key.partition('.') + return self._cfp.get(k1, k2) + def get(self, key: str, fallback = None, /): + k1, _, k2 = key.partition('.') + return self._cfp.get(k1, k2, fallback=fallback) + def __contains__(self, key: str, /) -> bool: + k1, _, k2 = key.partition('.') + return self._cfp.has_option(k1, k2) + def __iter__(self) -> Iterator[str]: + for k1, v1 in self._cfp.items(): + for k2 in v1: + yield f'{k1}.{k2}' + def __len__(self) -> int: + ## XXX might be incorrect but who cares + return sum(len(x) for x in self._cfp) + +@deprecated('use configparse') +class DictConfigSource(ConfigSource): + ''' + Config source from Python mappings. Useful with JSON/TOML config + ''' + __slots__ = ('_d',) + + _d: dict[str, Any] + + def __init__(self, mapping: dict[str, Any]): + self._d = mapping + def __getitem__(self, key: str, /) -> str: + return self._d[key] + def get(self, key: str, fallback: _T = None, /): + return self._d.get(key, fallback) + def __contains__(self, key: str, /) -> bool: + return key in self._d + def __iter__(self) -> Iterator[str]: + yield from self._d + def __len__(self) -> int: + return len(self._d) + +@deprecated('use configparse') +class ConfigValue: + """ + A single config property. + + By default, it is sourced from os.environ — i.e. environment variables, + and property name is upper cased. + + You can specify further sources, if the parent ConfigOptions class + supports them. + + Arguments: + - public: mark value as public, making it available across the app (e.g. in Jinja2 templates). + - prefix: src but for the lazy + - preserve_case: if True, src is not CAPITALIZED. Useful for parsing from Python dictionaries or ConfigParser's + - required: throw an error if empty or not supplied + """ + # XXX disabled per https://stackoverflow.com/questions/45864273/slots-conflicts-with-a-class-variable-in-a-generic-class + #__slots__ = ('_srcs', '_val', '_default', '_cast', '_required', '_preserve_case') + + _srcs: dict[str, str] | None + _preserve_case: bool = False + _val: Any | MISSING = MISSING + _default: Any | None + _cast: Callable | None + _required: bool + _pub_name: str | bool = False + def __init__(self, /, + src: str | None = None, *, default = None, cast: Callable | None = None, + required: bool = False, preserve_case: bool = False, prefix: str | None = None, + public: str | bool = False, **kwargs): + self._srcs = dict() + self._preserve_case = preserve_case + if src: + self._srcs['default'] = src if preserve_case else src.upper() + elif prefix: + self._srcs['default'] = f'{prefix if preserve_case else prefix.upper}?' + self._default = default + self._cast = cast + self._required = required + self._pub_name = public + for k, v in kwargs.items(): + if k.endswith('_src'): + self._srcs[k[:-4]] = v + else: + raise TypeError(f'unknown keyword argument {k!r}') + def __set_name__(self, owner, name: str): + if 'default' not in self._srcs: + self._srcs['default'] = name if self._preserve_case else name.upper() + elif self._srcs['default'].endswith('?'): + self._srcs['default'] = self._srcs['default'].rstrip('?') + (name if self._preserve_case else name.upper() ) + + if self._pub_name is True: + self._pub_name = name + if self._pub_name and isinstance(owner, ConfigOptions): + owner.expose(self._pub_name, name) + def __get__(self, obj: ConfigOptions, owner=False): + if self._val is MISSING: + v = MISSING + for srckey, src in obj._srcs.items(): + if srckey in self._srcs: + v = src.get(self._srcs[srckey], v) + if self._required and (not v or v is MISSING): + raise MissingConfigError(f'required config {self._srcs['default']} not set!') + if v is MISSING: + v = self._default + if callable(self._cast): + v = self._cast(v) if v is not None else self._cast() + self._val = v + return self._val + + @property + def source(self, /): + return self._srcs['default'] + +@deprecated('use configparse') +class ConfigOptions: + """ + Base class for loading config values. + + It is intended to get subclassed; config values must be defined as + ConfigValue() properties. + + Further config sources can be added with .add_source() + """ + + __slots__ = ('_srcs', '_pub') + + _srcs: OrderedDict[str, ConfigSource] + _pub: dict[str, str] + + def __init__(self, /): + self._srcs = OrderedDict( + default = EnvConfigSource() + ) + self._pub = dict() + + def add_source(self, key: str, csrc: ConfigSource, /, *, first: bool = False): + self._srcs[key] = csrc + if first: + self._srcs.move_to_end(key, False) + + add_config_source = deprecated_alias(add_source) + + def expose(self, public_name: str, attr_name: str | None = None) -> None: + ''' + Mark a config value as public. + + Called automatically by ConfigValue.__set_name__(). + ''' + attr_name = attr_name or public_name + self._pub[public_name] = attr_name + + def to_dict(self, /): + d = dict() + for k, v in self._pub.items(): + d[k] = getattr(self, v) + return d + + +__all__ = ( + 'MissingConfigError', 'MissingConfigWarning', 'ConfigOptions', 'EnvConfigSource', 'ConfigParserConfigSource', 'DictConfigSource', 'ConfigSource', 'ConfigValue' +) + + diff --git a/tests/test_mat.py b/tests/test_mat.py deleted file mode 100644 index ac1e00c..0000000 --- a/tests/test_mat.py +++ /dev/null @@ -1,47 +0,0 @@ - - -import unittest - -from suou.mat import Matrix - - -class TestMat(unittest.TestCase): - def setUp(self): - self.m_a = Matrix([ - [2, 2], - [1, 3] - ]) - self.m_b = Matrix([ - [1], [-4] - ]) - def tearDown(self) -> None: - ... - def test_transpose(self): - self.assertEqual( - self.m_a.T, - Matrix([ - [2, 1], - [2, 3] - ]) - ) - self.assertEqual( - self.m_b.T, - Matrix([[1, -4]]) - ) - def test_mul(self): - self.assertEqual( - self.m_b.T @ self.m_a, - Matrix([ - [-2, -10] - ]) - ) - self.assertEqual( - self.m_a @ self.m_b, - Matrix([ - [-6], [-11] - ]) - ) - def test_shape(self): - self.assertEqual(self.m_a.shape(), (2, 2)) - self.assertEqual(self.m_b.shape(), (2, 1)) - self.assertEqual(self.m_b.T.shape(), (1, 2)) \ No newline at end of file