Compare commits

...

2 commits

Author SHA1 Message Date
4aefac0e99 0.14.0a1 add ast module + email_column() 2026-06-26 12:28:58 +02:00
82d4fc2ab2 0.13.1 typing fixes 2026-06-20 17:26:07 +02:00
14 changed files with 186 additions and 37 deletions

1
.gitignore vendored
View file

@ -30,6 +30,7 @@ docs/_build
docs/_static docs/_static
docs/templates docs/templates
.coverage .coverage
.pytest_cache/
# changes during CD/CI # changes during CD/CI
aliases/*/pyproject.toml aliases/*/pyproject.toml

View file

@ -1,5 +1,15 @@
# Changelog # Changelog
## 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
## 0.13.0 "Laconic Letters" ## 0.13.0 "Laconic Letters"
+ Added module `argparse` with class `LetterSubparsers()`, which allows pacman-style args by preprocessing them + Added module `argparse` with class `LetterSubparsers()`, which allows pacman-style args by preprocessing them

View file

@ -41,7 +41,7 @@ from .color import OKLabColor, chalk, WebColor, RGBColor, LinearRGBColor, \
from .mat import Matrix from .mat import Matrix
from .argparse import LetterSubparsers from .argparse import LetterSubparsers
__version__ = "0.13.0" __version__ = "0.14.0a1"
__all__ = ( __all__ = (
'ColorFormatter', 'ColorFormatter',

116
src/suou/ast.py Normal file
View file

@ -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'
)

View file

@ -21,6 +21,8 @@ from __future__ import annotations
from functools import wraps from functools import wraps
from typing import Callable, Collection, TypeVar, Any from typing import Callable, Collection, TypeVar, Any
from . import deprecated
_T = TypeVar('_T') _T = TypeVar('_T')
_U = TypeVar('_U') _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]): def dei_args(**renames: dict[str, str]):
""" """
Allow for aliases in the keyword argument names, in form alias='real_name'. Allow for aliases in the keyword argument names, in form alias='real_name'.

View file

@ -22,17 +22,13 @@ from suou.classtools import MISSING
_T = TypeVar('_T') _T = TypeVar('_T')
def makelist(l: Any, wrap: bool = True) -> list | Callable[Any, list]: def _makelist_callable(l: Callable) -> Callable[..., list]:
''' @wraps(l)
Make a list out of an iterable or a single value. def wrapper(*a, **k):
return _makelist_nowrap(l(*a, **k))
return wrapper
*Changed in 0.4.0* Now supports a callable: can be used to decorate generators and turn them into lists. def _makelist_nowrap(l: Any) -> list:
Pass wrap=False to return instead the unwrapped function in a list.
*Changed in 0.11.0*: ``wrap`` argument is now no more keyword only.
'''
if callable(l) and wrap:
return wraps(l)(lambda *a, **k: makelist(l(*a, **k), wrap=False))
if isinstance(l, (str, bytes, bytearray)): if isinstance(l, (str, bytes, bytearray)):
return [l] return [l]
elif isinstance(l, Iterable): elif isinstance(l, Iterable):
@ -42,7 +38,21 @@ def makelist(l: Any, wrap: bool = True) -> list | Callable[Any, list]:
else: else:
return [l] return [l]
def ltuple(seq: Iterable[_T], size: int, /, pad = None) -> tuple: def makelist(l: Any, wrap: bool = True) -> list | Callable[..., list]:
'''
Make a list out of an iterable or a single value.
*Changed in 0.4.0* Now supports a callable: can be used to decorate generators and turn them into lists.
Pass wrap=False to return instead the unwrapped function in a list.
*Changed in 0.11.0*: ``wrap`` argument is now no more keyword only.
'''
if callable(l) and wrap:
return _makelist_callable(l)
else:
return _makelist_nowrap(l)
def ltuple(seq: Iterable[_T], size: int, /, pad = None) -> tuple[_T, ...]:
""" """
Truncate an iterable into a fixed size tuple, if necessary padding it. Truncate an iterable into a fixed size tuple, if necessary padding it.
""" """
@ -51,7 +61,7 @@ def ltuple(seq: Iterable[_T], size: int, /, pad = None) -> tuple:
seq = seq + (pad,) * (size - len(seq)) seq = seq + (pad,) * (size - len(seq))
return seq return seq
def rtuple(seq: Iterable[_T], size: int, /, pad = None) -> tuple: def rtuple(seq: Iterable[_T], size: int, /, pad = None) -> tuple[_T, ...]:
""" """
Same as rtuple() but the padding and truncation is made right to left. Same as rtuple() but the padding and truncation is made right to left.
""" """
@ -81,7 +91,7 @@ def kwargs_prefix(it: dict[str, Any], prefix: str, *, remove = True, keep_prefix
it.pop(k) it.pop(k)
return ka return ka
def additem(obj: MutableMapping, /, name: str = None): def additem(obj: MutableMapping, /, name: str | None = None):
""" """
Syntax sugar for adding a function to a mapping, immediately. Syntax sugar for adding a function to a mapping, immediately.
""" """
@ -93,7 +103,7 @@ def additem(obj: MutableMapping, /, name: str = None):
return func return func
return decorator return decorator
def addattr(obj: Any, /, name: str = None): def addattr(obj: Any, /, name: str | None = None):
""" """
Same as additem() but setting as attribute instead. Same as additem() but setting as attribute instead.
""" """

View file

@ -62,8 +62,6 @@ def symbol_table(*args: Iterable[tuple | TokenSym], whitespace: str | None = Non
yield TokenSym('[' + re.escape(whitespace) + ']+', '', discard=True) yield TokenSym('[' + re.escape(whitespace) + ']+', '', discard=True)
symbol_table: Callable[..., list]
def ilex(text: str, table: Iterable[TokenSym], *, whitespace = False): def ilex(text: str, table: Iterable[TokenSym], *, whitespace = False):
""" """
Return a text as a list of tokens, given a token table (iterable of TokenSym). Return a text as a list of tokens, given a token table (iterable of TokenSym).

View file

@ -35,7 +35,7 @@ def lucky(validators: Iterable[Callable[[_U], bool]] = ()):
*New in 0.7.0* *New in 0.7.0*
""" """
def decorator(func: Callable[_T, _U]) -> Callable[_T, _U]: def decorator(func: Callable[..., _U]) -> Callable[..., _U]:
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs) -> _U: def wrapper(*args, **kwargs) -> _U:
try: try:

View file

@ -86,4 +86,5 @@ class PingExtension(markdown.extensions.Extension):
md.inlinePatterns.register(MentionPattern(re.escape(at) + r'(' + self.CHARACTERS + ')', url_prefix), 'ping_mention', 14) md.inlinePatterns.register(MentionPattern(re.escape(at) + r'(' + self.CHARACTERS + ')', url_prefix), 'ping_mention', 14)
# Optional dependency: do not import into __init__.py
__all__ = ('PingExtension', 'SpoilerExtension', 'StrikethroughExtension') __all__ = ('PingExtension', 'SpoilerExtension', 'StrikethroughExtension')

View file

@ -22,11 +22,8 @@ from itsdangerous import TimestampSigner
from itsdangerous import Signer as _Signer from itsdangerous import Signer as _Signer
from itsdangerous.encoding import int_to_bytes as _int_to_bytes from itsdangerous.encoding import int_to_bytes as _int_to_bytes
from suou.dei import dei_args from .itertools import rtuple
from suou.itertools import rtuple from .codecs import jsondecode, jsonencode, want_bytes, want_str, b64decode, b64encode
from .functools import not_implemented
from .codecs import jsondecode, jsonencode, rb64decode, want_bytes, want_str, b64decode, b64encode
from .iding import Siq from .iding import Siq
from .classtools import MISSING from .classtools import MISSING
@ -35,7 +32,6 @@ class UserSigner(TimestampSigner):
itsdangerous.TimestampSigner() instanced from a user ID, with token generation and validation capabilities. itsdangerous.TimestampSigner() instanced from a user ID, with token generation and validation capabilities.
""" """
user_id: int user_id: int
@dei_args(primary_secret='master_secret')
def __init__(self, master_secret: bytes, user_id: int, user_secret: bytes, **kwargs): 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) super().__init__(master_secret + user_secret, salt=Siq(user_id).to_bytes(), **kwargs)
self.user_id = user_id self.user_id = user_id

View file

@ -22,8 +22,8 @@ from functools import wraps
from contextvars import ContextVar, Token from contextvars import ContextVar, Token
from typing import Callable, TypeVar from typing import Callable, TypeVar
from sqlalchemy import Select, Table, func, select from sqlalchemy import Select, Table, select
from sqlalchemy.orm import DeclarativeBase, lazyload from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
try: try:
@ -31,8 +31,7 @@ try:
except ImportError: except ImportError:
AsyncSelectPagination = None AsyncSelectPagination = None
from suou.exceptions import NotFoundError from ..exceptions import NotFoundError
from suou.glue import glue
_T = TypeVar('_T') _T = TypeVar('_T')
_U = TypeVar('_U') _U = TypeVar('_U')
@ -126,9 +125,11 @@ class SQLAlchemy:
self.engine, checkfirst=checkfirst 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): def async_query(db: SQLAlchemy, multi: False):
""" """

View file

@ -18,7 +18,6 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
from binascii import Incomplete
import os import os
import re import re
from typing import Any, Callable, TypeVar 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.orm import DeclarativeBase, InstrumentedAttribute, Relationship, declarative_base as _declarative_base, relationship
from sqlalchemy.types import TypeEngine from sqlalchemy.types import TypeEngine
from sqlalchemy.ext.hybrid import Comparator from sqlalchemy.ext.hybrid import Comparator
from suou.functools import future
from suou.classtools import Wanted, Incomplete from suou.classtools import Wanted, Incomplete
from suou.codecs import StringCase from suou.codecs import StringCase
from suou.dei import dei_args
from suou.iding import Siq, SiqCache, SiqGen, SiqType from suou.iding import Siq, SiqCache, SiqGen, SiqType
from suou.itertools import kwargs_prefix from suou.itertools import kwargs_prefix
from suou.snowflake import SnowflakeGen 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) 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]: def bool_column(value: bool = False, nullable: bool = False, **kwargs) -> Column[bool]:
""" """
Column for a single boolean value. 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) 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]: def declarative_base(domain_name: str, master_secret: bytes, metadata: dict | None = None, **kwargs) -> type[DeclarativeBase]:
""" """
Drop-in replacement for sqlalchemy.orm.declarative_base() Drop-in replacement for sqlalchemy.orm.declarative_base()

View file

@ -28,7 +28,7 @@ from suou.functools import future
@future() @future()
class Waiter(): class Waiter():
_cached_app: Callable | None = None _cached_app: Starlette | None = None
def __init__(self): def __init__(self):
self.routes: list[Route] = [] self.routes: list[Route] = []
@ -60,7 +60,7 @@ class Waiter():
def patch(self, endpoint: str, *a, **k): def patch(self, endpoint: str, *a, **k):
return self._route('PATCH', endpoint, *a, **k) return self._route('PATCH', endpoint, *a, **k)
def _route(self, methods: list[str], endpoint: str, **kwargs): def _route(self, methods: str | list[str], endpoint: str, **kwargs):
def decorator(func): def decorator(func):
self.routes.append(Route(endpoint, func, methods=makelist(methods, False), **kwargs)) self.routes.append(Route(endpoint, func, methods=makelist(methods, False), **kwargs))
return func return func