0.14.0a1 add ast module + email_column()

This commit is contained in:
Yusur 2026-06-26 12:28:58 +02:00
parent 82d4fc2ab2
commit 4aefac0e99
9 changed files with 155 additions and 19 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,6 +1,12 @@
# Changelog # 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 + Typing fixes

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.1" __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,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()