diff --git a/.gitignore b/.gitignore index 96fd286..481d9da 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ docs/_build docs/_static docs/templates .coverage +.pytest_cache/ # changes during CD/CI aliases/*/pyproject.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index ef2d55a..7cccb6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ # Changelog -## 0.13.1 +## 0.14.0 + +* Added `ast` module +* Deprecate `dei_args()` for problems with the typing system. The function is not going away tho +* Module `sqlalchemy`: added `email_column()` + +## 0.13.1 and 0.12.7 + Typing fixes diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 44f07a3..d7d1720 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -41,7 +41,7 @@ from .color import OKLabColor, chalk, WebColor, RGBColor, LinearRGBColor, \ from .mat import Matrix from .argparse import LetterSubparsers -__version__ = "0.13.1" +__version__ = "0.14.0a1" __all__ = ( 'ColorFormatter', diff --git a/src/suou/ast.py b/src/suou/ast.py new file mode 100644 index 0000000..c6b744d --- /dev/null +++ b/src/suou/ast.py @@ -0,0 +1,116 @@ +""" +Experimental AST module + +--- + +Copyright (c) 2026 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 typing import Any, Collection + +_T = TypeVar('_T') + +class Node: + __slots__ = ('_children', ) + + _count: int + _children: list[Node] + _types: Collection[type] + + def __repr__(self): + return f"{self.__class__.__name__}({', '.join(repr(x) for x in self._children)})" + + def __init__(self, *args): + if self._count != len(args): + raise TypeError(f'{self.__class__.__name__} must be instanced with {self._count} arguments, got {len(args)}') + + for i, (a, t) in enumerate(zip(args, self._types)): + if t != Any and not isinstance(a, t): + raise TypeError(f"argument {i} must be of type {t.__name__!r}, got {a.__class__.__name__!r}") + + self._children = list(args) + + def eval(self, ctx: dict): + raise TypeError(f'{self.__class__.__name__} cannot be eval()ed') + + def __getitem__(self, key: int): + if key >= self._count: + raise IndexError('child node index out of range') + return self._children[key] + + def __len__(self): + return self._count + + +class ZeroOp(Node): + _count = 0 + _types = () + + +class UnaryOp(Node): + _count = 1 + _types = (Any,) + + +class BinaryOp(Node): + _count = 2 + _types = (Any, Any) + + +class TernaryOp(Node): + _count = 3 + _types = (Any, Any, Any) + + +class MultiOp(Node): + _count = 1 + _types = (list,) + + def __getitem__(self, key: int): + return self._children[0][key] + + def __iter__(self): + return iter(self._children[0]) + + def __len__(self): + return len(self._children[0]) + + + +class Literal(UnaryOp): + """A literal evals to the enclosed value.""" + def eval(self, ctx: dict): + return self[0] + + +## SUOU provides some ready-made literals, for the sake of ease-of-use. + +class IntLiteral(Literal): + _types = (int,) + +class FloatLiteral(Literal): + """ + WARNING: may be subject to loss of precision. + """ + _types = (float,) + +class StringLiteral(Literal): + _types = (str,) + + +# This module is experimental and therefore not re-exported into __init__ +__all__ = ( + 'Node', 'ZeroOp', 'UnaryOp', 'BinaryOp', 'TernaryOp', + 'MultiOp', 'Literal', 'IntLiteral', 'FloatLiteral', + 'StringLiteral' +) \ No newline at end of file diff --git a/src/suou/dei.py b/src/suou/dei.py index 8d0f326..a49a398 100644 --- a/src/suou/dei.py +++ b/src/suou/dei.py @@ -21,6 +21,8 @@ from __future__ import annotations from functools import wraps from typing import Callable, Collection, TypeVar, Any +from . import deprecated + _T = TypeVar('_T') _U = TypeVar('_U') @@ -115,6 +117,7 @@ class Pronoun(int): +@deprecated('breaks the typing system somewhat; won\'t be removed tho') def dei_args(**renames: dict[str, str]): """ Allow for aliases in the keyword argument names, in form alias='real_name'. diff --git a/src/suou/signing.py b/src/suou/signing.py index c2011ce..55a950d 100644 --- a/src/suou/signing.py +++ b/src/suou/signing.py @@ -22,11 +22,8 @@ from itsdangerous import TimestampSigner from itsdangerous import Signer as _Signer from itsdangerous.encoding import int_to_bytes as _int_to_bytes -from suou.dei import dei_args -from suou.itertools import rtuple - -from .functools import not_implemented -from .codecs import jsondecode, jsonencode, rb64decode, want_bytes, want_str, b64decode, b64encode +from .itertools import rtuple +from .codecs import jsondecode, jsonencode, want_bytes, want_str, b64decode, b64encode from .iding import Siq from .classtools import MISSING @@ -35,7 +32,6 @@ class UserSigner(TimestampSigner): itsdangerous.TimestampSigner() instanced from a user ID, with token generation and validation capabilities. """ user_id: int - @dei_args(primary_secret='master_secret') def __init__(self, master_secret: bytes, user_id: int, user_secret: bytes, **kwargs): super().__init__(master_secret + user_secret, salt=Siq(user_id).to_bytes(), **kwargs) self.user_id = user_id diff --git a/src/suou/sqlalchemy/asyncio.py b/src/suou/sqlalchemy/asyncio.py index b28f305..4061c34 100644 --- a/src/suou/sqlalchemy/asyncio.py +++ b/src/suou/sqlalchemy/asyncio.py @@ -22,8 +22,8 @@ from functools import wraps from contextvars import ContextVar, Token from typing import Callable, TypeVar -from sqlalchemy import Select, Table, func, select -from sqlalchemy.orm import DeclarativeBase, lazyload +from sqlalchemy import Select, Table, select +from sqlalchemy.orm import DeclarativeBase from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine try: @@ -31,8 +31,7 @@ try: except ImportError: AsyncSelectPagination = None -from suou.exceptions import NotFoundError -from suou.glue import glue +from ..exceptions import NotFoundError _T = TypeVar('_T') _U = TypeVar('_U') @@ -126,9 +125,11 @@ class SQLAlchemy: self.engine, checkfirst=checkfirst ) -# XXX NOT public API! DO NOT USE -current_session: ContextVar[AsyncSession] = ContextVar('current_session') +current_session: ContextVar[AsyncSession] = ContextVar('current_session') +""" +XXX NOT public API! DO NOT USE +""" def async_query(db: SQLAlchemy, multi: False): """ diff --git a/src/suou/sqlalchemy/orm.py b/src/suou/sqlalchemy/orm.py index ba022e7..bfd6be1 100644 --- a/src/suou/sqlalchemy/orm.py +++ b/src/suou/sqlalchemy/orm.py @@ -18,7 +18,6 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -from binascii import Incomplete import os import re from typing import Any, Callable, TypeVar @@ -27,10 +26,8 @@ from sqlalchemy import BigInteger, Boolean, CheckConstraint, Column, Date, Forei from sqlalchemy.orm import DeclarativeBase, InstrumentedAttribute, Relationship, declarative_base as _declarative_base, relationship from sqlalchemy.types import TypeEngine from sqlalchemy.ext.hybrid import Comparator -from suou.functools import future from suou.classtools import Wanted, Incomplete from suou.codecs import StringCase -from suou.dei import dei_args from suou.iding import Siq, SiqCache, SiqGen, SiqType from suou.itertools import kwargs_prefix from suou.snowflake import SnowflakeGen @@ -131,6 +128,23 @@ def username_column( return match_column(length, regex, case=case, nullable=nullable, unique=True, *args, **kwargs) +EMAIL_RE_USERNAME = r"[a-z0-9-]+(\.[a-z0-9-]+)*" +EMAIL_RE_DOMAIN = r"([a-z0-9-]+\.)+[a-z0-9-]{2,15}" +EMAIL_RE_YESALIASES = EMAIL_RE_USERNAME + r"(\+" + EMAIL_RE_USERNAME + ")?@" + EMAIL_RE_DOMAIN +EMAIL_RE_NOALIASES = EMAIL_RE_USERNAME + r"@" + EMAIL_RE_DOMAIN + +def email_column( + length: int = 256, *args, allow_aliases: bool = True, nullable: bool = False, unique: bool = True, **kwargs +): + """ + Construct a column containing a email address. + + *New in 0.14.0* + """ + return match_column(length, EMAIL_RE_YESALIASES if allow_aliases else EMAIL_RE_NOALIASES, case = StringCase.FORCE_LOWER, + unique = unique, nullable = nullable, *args, **kwargs) + + def bool_column(value: bool = False, nullable: bool = False, **kwargs) -> Column[bool]: """ Column for a single boolean value. @@ -141,7 +155,6 @@ def bool_column(value: bool = False, nullable: bool = False, **kwargs) -> Column return Column(Boolean, server_default=def_val, nullable=nullable, **kwargs) -@dei_args(primary_secret='master_secret') def declarative_base(domain_name: str, master_secret: bytes, metadata: dict | None = None, **kwargs) -> type[DeclarativeBase]: """ Drop-in replacement for sqlalchemy.orm.declarative_base() diff --git a/src/suou/validators.py b/src/suou/validators.py index 349172a..72daf57 100644 --- a/src/suou/validators.py +++ b/src/suou/validators.py @@ -74,7 +74,7 @@ def yesno(x: str | int | bool | None) -> bool: if isinstance(x, str): return x.lower() not in ('', '0', 'off', 'n', 'no', 'false', 'f') return True - + __all__ = ('matches', 'must_be', 'not_greater_than', 'not_less_than', 'yesno')