diff --git a/CHANGELOG.md b/CHANGELOG.md index b4ba99a..e28e673 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 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 c3c8724..06f5698 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -36,12 +36,14 @@ 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.11.2" +__version__ = "0.12.0a3" __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 5a8b899..dfd2a6d 100644 --- a/src/suou/color.py +++ b/src/suou/color.py @@ -93,9 +93,9 @@ chalk = Chalk() ## Utilities for web colors -class WebColor(namedtuple('_WebColor', 'red green blue')): +class RGBColor(namedtuple('_WebColor', 'red green blue')): """ - Representation of a color in the TrueColor space (aka rgb). + Representation of a color in the RGB TrueColor space. Useful for theming. """ @@ -126,21 +126,49 @@ class WebColor(namedtuple('_WebColor', 'red green blue')): """ return self.darken(factor=factor) + self.lighten(factor=factor) - def blend_with(self, other: WebColor): + def blend_with(self, other: RGBColor): """ Mix two colors, returning the average. """ - return WebColor ( + return RGBColor ( (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 88122d2..3f1caf0 100644 --- a/src/suou/flask_sqlalchemy.py +++ b/src/suou/flask_sqlalchemy.py @@ -1,7 +1,7 @@ """ Utilities for Flask-SQLAlchemy binding. -This module is deprecated and will be REMOVED in 0.14.0. +This module has been emptied in 0.12.0 following deprecation removals. --- @@ -16,50 +16,6 @@ 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 a2e0c37..7997da3 100644 --- a/src/suou/iding.py +++ b/src/suou/iding.py @@ -249,13 +249,20 @@ 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! @@ -305,12 +312,10 @@ 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 new file mode 100644 index 0000000..4f8f2b9 --- /dev/null +++ b/src/suou/mat.py @@ -0,0 +1,122 @@ +""" +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 deleted file mode 100644 index 1563813..0000000 --- a/src/suou/obsolete/configparsev0_3.py +++ /dev/null @@ -1,239 +0,0 @@ -""" -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 new file mode 100644 index 0000000..ac1e00c --- /dev/null +++ b/tests/test_mat.py @@ -0,0 +1,47 @@ + + +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