Compare commits

...
Sign in to create a new pull request.

12 commits

10 changed files with 95 additions and 31 deletions

1
.gitignore vendored
View file

@ -24,3 +24,4 @@ dist/
.err .err
.vscode .vscode
/run.sh /run.sh
ROADMAP.md

View file

@ -1,5 +1,31 @@
# Changelog # Changelog
## 0.4.0
👀
## 0.3.8
- Fixed return types for `.sqlalchemy` module.
- `sqlalchemy.parent_children()` now takes a `lazy` parameter. Backported from 0.5.0.
## 0.3.7
- Fixed a bug in `b64decode()` padding handling which made the function inconsistent and non injective. Now, leading `'A'` is NEVER stripped.
## 0.3.6
- Fixed `ConfigValue` behavior with multiple sources. It used to iterate through all the sources, possibly overwriting; now, iteration stops at first non-missing value.
## 0.3.5
- Fixed cb32 handling. Now leading zeros in SIQ's are stripped, and `.from_cb32()` was implemented.
## 0.3.4
- Bug fixes in `.flask_restx` regarding error handling
- Fixed a bug in `.configparse` dealing with unset values from multiple sources
## 0.3.3 ## 0.3.3
- Fixed leftovers in `snowflake` module from unchecked code copying — i.e. `SnowflakeGen.generate_one()` used to require an unused typ= parameter - Fixed leftovers in `snowflake` module from unchecked code copying — i.e. `SnowflakeGen.generate_one()` used to require an unused typ= parameter

View file

@ -2,7 +2,7 @@
Good morning, my brother! Welcome the SUOU (SIS Unified Object Underarmor), a library for the management of the storage of objects into a database. Good morning, my brother! Welcome the SUOU (SIS Unified Object Underarmor), a library for the management of the storage of objects into a database.
It provides utilities such as [SIQ](https://sakux.moe/protocols/siq.html), signing and generation of access tokens (on top of [ItsDangerous](https://github.com/pallets/itsdangerous)) and various utilities, including helpers for use in Flask and SQLAlchemy. It provides utilities such as [SIQ](https://yusur.moe/protocols/siq.html), signing and generation of access tokens (on top of [ItsDangerous](https://github.com/pallets/itsdangerous)) and various utilities, including helpers for use in Flask and SQLAlchemy.
**It is not an ORM** nor a replacement of it; it works along existing ORMs (currently only SQLAlchemy is supported lol). **It is not an ORM** nor a replacement of it; it works along existing ORMs (currently only SQLAlchemy is supported lol).

View file

@ -27,7 +27,7 @@ from .itertools import makelist, kwargs_prefix, ltuple, rtuple, additem
from .i18n import I18n, JsonI18n, TomlI18n from .i18n import I18n, JsonI18n, TomlI18n
from .snowflake import Snowflake, SnowflakeGen from .snowflake import Snowflake, SnowflakeGen
__version__ = "0.3.3" __version__ = "0.3.8"
__all__ = ( __all__ = (
'Siq', 'SiqCache', 'SiqType', 'SiqGen', 'StringCase', 'Siq', 'SiqCache', 'SiqType', 'SiqGen', 'StringCase',

View file

@ -178,10 +178,10 @@ def b32ldecode(val: bytes | str) -> bytes:
def b64encode(val: bytes, *, strip: bool = True) -> str: def b64encode(val: bytes, *, strip: bool = True) -> str:
''' '''
Wrapper around base64.urlsafe_b64encode() which also strips trailing '=' and leading 'A'. Wrapper around base64.urlsafe_b64encode() which also strips trailing '='.
''' '''
b = want_str(base64.urlsafe_b64encode(val)) b = want_str(base64.urlsafe_b64encode(val))
return b.lstrip('A').rstrip('=') if strip else b return b.rstrip('=') if strip else b
def b64decode(val: bytes | str) -> bytes: def b64decode(val: bytes | str) -> bytes:
''' '''
@ -227,7 +227,7 @@ def jsonencode(obj: dict, *, skipkeys: bool = True, separators: tuple[str, str]
''' '''
return json.dumps(obj, skipkeys=skipkeys, separators=separators, default=_json_default(default), **kwargs) return json.dumps(obj, skipkeys=skipkeys, separators=separators, default=_json_default(default), **kwargs)
jsondecode = deprecated('just use json.loads()')(json.loads) jsondecode: Callable[Any, dict] = deprecated('just use json.loads()')(json.loads)
def ssv_list(s: str, *, sep_chars = ',;') -> list[str]: def ssv_list(s: str, *, sep_chars = ',;') -> list[str]:
""" """

View file

@ -20,15 +20,18 @@ from ast import TypeVar
from collections.abc import Mapping from collections.abc import Mapping
from configparser import ConfigParser as _ConfigParser from configparser import ConfigParser as _ConfigParser
import os import os
from typing import Any, Callable, Iterable, Iterator from typing import Any, Callable, Iterator
from collections import OrderedDict from collections import OrderedDict
from .functools import deprecated_alias from .functools import deprecated
MISSING = object() MISSING = object()
_T = TypeVar('T') _T = TypeVar('T')
def _not_missing(v) -> bool:
return v and v is not MISSING
class MissingConfigError(LookupError): class MissingConfigError(LookupError):
""" """
@ -78,6 +81,8 @@ class ConfigParserConfigSource(ConfigSource):
_cfp: _ConfigParser _cfp: _ConfigParser
def __init__(self, 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 self._cfp = cfp
def __getitem__(self, key: str, /) -> str: def __getitem__(self, key: str, /) -> str:
k1, _, k2 = key.partition('.') k1, _, k2 = key.partition('.')
@ -174,12 +179,17 @@ class ConfigValue:
owner.expose(self._pub_name, name) owner.expose(self._pub_name, name)
def __get__(self, obj: ConfigOptions, owner=False): def __get__(self, obj: ConfigOptions, owner=False):
if self._val is MISSING: if self._val is MISSING:
v = MISSING
for srckey, src in obj._srcs.items(): for srckey, src in obj._srcs.items():
v = src.get(self._srcs[srckey], MISSING) if srckey in self._srcs:
if self._required and not v: v = src.get(self._srcs[srckey], v)
raise MissingConfigError(f'required config {self._src} not set!') if _not_missing(v):
if v is MISSING: break
v = self._default if not _not_missing(v):
if self._required:
raise MissingConfigError(f'required config {self._srcs['default']} not set!')
else:
v = self._default
if callable(self._cast): if callable(self._cast):
v = self._cast(v) if v is not None else self._cast() v = self._cast(v) if v is not None else self._cast()
self._val = v self._val = v
@ -216,7 +226,7 @@ class ConfigOptions:
if first: if first:
self._srcs.move_to_end(key, False) self._srcs.move_to_end(key, False)
add_config_source = deprecated_alias(add_source) add_config_source = deprecated('use add_source() instead')(add_source)
def expose(self, public_name: str, attr_name: str | None = None) -> None: def expose(self, public_name: str, attr_name: str | None = None) -> None:
''' '''

View file

@ -16,10 +16,10 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
from typing import Any, Mapping from typing import Any, Mapping
import warnings import warnings
from flask import current_app, make_response from flask import current_app, Response, make_response
from flask_restx import Api as _Api from flask_restx import Api as _Api
from .codecs import jsonencode from .codecs import jsondecode, jsonencode, want_bytes, want_str
def output_json(data, code, headers=None): def output_json(data, code, headers=None):
@ -54,10 +54,21 @@ class Api(_Api):
Notably, all JSON is whitespace-free and .message is remapped to .error Notably, all JSON is whitespace-free and .message is remapped to .error
""" """
def handle_error(self, e): def handle_error(self, e):
### XXX in order for errors to get handled the correct way, import
### suou.flask_restx.Api() NOT flask_restx.Api() !!!!
res = super().handle_error(e) res = super().handle_error(e)
if isinstance(res, Mapping) and 'message' in res: if isinstance(res, Mapping) and 'message' in res:
res['error'] = res['message'] res['error'] = res['message']
del res['message'] del res['message']
elif isinstance(res, Response):
try:
body = want_str(res.response[0])
bodj = jsondecode(body)
if 'message' in bodj:
bodj['error'] = bodj.pop('message')
res.response = [want_bytes(jsonencode(bodj))]
except (IndexError, KeyError):
pass
return res return res
def __init__(self, *a, **ka): def __init__(self, *a, **ka):
super().__init__(*a, **ka) super().__init__(*a, **ka)

View file

@ -22,7 +22,7 @@ from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import DeclarativeBase, Session from sqlalchemy.orm import DeclarativeBase, Session
from .codecs import want_bytes from .codecs import want_bytes
from .sqlalchemy import require_auth_base from .sqlalchemy import AuthSrc, require_auth_base
class FlaskAuthSrc(AuthSrc): class FlaskAuthSrc(AuthSrc):
''' '''
@ -35,14 +35,15 @@ class FlaskAuthSrc(AuthSrc):
def get_session(self) -> Session: def get_session(self) -> Session:
return self.db.session return self.db.session
def get_token(self): def get_token(self):
return request.authorization.token if request.authorization:
return request.authorization.token
def get_signature(self) -> bytes: def get_signature(self) -> bytes:
sig = request.headers.get('authorization-signature', None) sig = request.headers.get('authorization-signature', None)
return want_bytes(sig) if sig else None return want_bytes(sig) if sig else None
def invalid_exc(self, msg: str = 'validation failed') -> Never: def invalid_exc(self, msg: str = 'validation failed') -> Never:
abort(400, msg) abort(400, msg)
def required_exc(self): def required_exc(self):
abort(401) abort(401, 'Login required')
def require_auth(cls: type[DeclarativeBase], db: SQLAlchemy) -> Callable[Any, Callable]: def require_auth(cls: type[DeclarativeBase], db: SQLAlchemy) -> Callable[Any, Callable]:
""" """
@ -51,6 +52,9 @@ def require_auth(cls: type[DeclarativeBase], db: SQLAlchemy) -> Callable[Any, Ca
This looks for a token in the Authorization header, validates it, loads the This looks for a token in the Authorization header, validates it, loads the
appropriate object, and injects it as the user= parameter. appropriate object, and injects it as the user= parameter.
NOTE: the actual decorator to be used on routes is **auth_required()**,
NOT require_auth() which is the **constructor** for it.
cls is a SQLAlchemy table. cls is a SQLAlchemy table.
db is a flask_sqlalchemy.SQLAlchemy() binding. db is a flask_sqlalchemy.SQLAlchemy() binding.
@ -62,8 +66,15 @@ def require_auth(cls: type[DeclarativeBase], db: SQLAlchemy) -> Callable[Any, Ca
@auth_required(validators=[lambda x: x.is_administrator]) @auth_required(validators=[lambda x: x.is_administrator])
def super_secret_stuff(user): def super_secret_stuff(user):
pass pass
NOTE: require_auth() DOES NOT work with flask_restx.
""" """
return partial(require_auth_base, cls=cls, src=FlaskAuthSrc(db)) def auth_required(**kwargs):
return require_auth_base(cls=cls, src=FlaskAuthSrc(db), **kwargs)
auth_required.__doc__ = require_auth_base.__doc__
return auth_required
__all__ = ('require_auth', ) __all__ = ('require_auth', )

View file

@ -41,7 +41,7 @@ from typing import Iterable, override
import warnings import warnings
from .functools import deprecated from .functools import deprecated
from .codecs import b32lencode, b64encode, cb32encode from .codecs import b32lencode, b64encode, cb32decode, cb32encode, want_str
class SiqType(enum.Enum): class SiqType(enum.Enum):
@ -235,9 +235,14 @@ class Siq(int):
def to_base64(self, length: int = 15, *, strip: bool = True) -> str: def to_base64(self, length: int = 15, *, strip: bool = True) -> str:
return b64encode(self.to_bytes(length), strip=strip) return b64encode(self.to_bytes(length), strip=strip)
def to_cb32(self) -> str: def to_cb32(self) -> str:
return cb32encode(self.to_bytes(15, 'big')) return cb32encode(self.to_bytes(15, 'big')).lstrip('0')
to_crockford = to_cb32 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: def to_hex(self) -> str:
return f'{self:x}' return f'{self:x}'
def to_oct(self) -> str: def to_oct(self) -> str:

View file

@ -21,7 +21,7 @@ from functools import wraps
from typing import Callable, Iterable, Never, TypeVar from typing import Callable, Iterable, Never, TypeVar
import warnings import warnings
from sqlalchemy import BigInteger, CheckConstraint, Date, Dialect, ForeignKey, LargeBinary, Column, MetaData, SmallInteger, String, create_engine, select, text from sqlalchemy import BigInteger, CheckConstraint, Date, Dialect, ForeignKey, LargeBinary, Column, MetaData, SmallInteger, String, create_engine, select, text
from sqlalchemy.orm import DeclarativeBase, Session, declarative_base as _declarative_base, relationship from sqlalchemy.orm import DeclarativeBase, Relationship, Session, declarative_base as _declarative_base, relationship
from .snowflake import SnowflakeGen from .snowflake import SnowflakeGen
from .itertools import kwargs_prefix, makelist from .itertools import kwargs_prefix, makelist
@ -120,7 +120,8 @@ def match_column(length: int, regex: str, /, case: StringCase = StringCase.AS_IS
constraint_name=constraint_name or f'{x.__tablename__}_{n}_valid')), *args, **kwargs) constraint_name=constraint_name or f'{x.__tablename__}_{n}_valid')), *args, **kwargs)
def declarative_base(domain_name: str, master_secret: bytes, metadata: dict | None = None, **kwargs):
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()
taking in account requirements for SIQ generation (i.e. domain name). taking in account requirements for SIQ generation (i.e. domain name).
@ -193,7 +194,7 @@ def age_pair(*, nullable: bool = False, **ka) -> tuple[Column, Column]:
return (date_col, acc_col) return (date_col, acc_col)
def parent_children(keyword: str, /, **kwargs): def parent_children(keyword: str, /, *, lazy: str = 'selectin', **kwargs) -> tuple[Incomplete[Relationship], Incomplete[Relationship]]:
""" """
Self-referential one-to-many relationship pair. Self-referential one-to-many relationship pair.
Parent comes first, children come later. Parent comes first, children come later.
@ -208,8 +209,8 @@ def parent_children(keyword: str, /, **kwargs):
parent_kwargs = kwargs_prefix(kwargs, 'parent_') parent_kwargs = kwargs_prefix(kwargs, 'parent_')
child_kwargs = kwargs_prefix(kwargs, 'child_') child_kwargs = kwargs_prefix(kwargs, 'child_')
parent = Incomplete(relationship, Wanted(lambda o, n: o.__name__), back_populates=f'child_{keyword}s', **parent_kwargs) parent = Incomplete(relationship, Wanted(lambda o, n: o.__name__), back_populates=f'child_{keyword}s', lazy=lazy, **parent_kwargs)
child = Incomplete(relationship, Wanted(lambda o, n: o.__name__), back_populates=f'parent_{keyword}', **child_kwargs) child = Incomplete(relationship, Wanted(lambda o, n: o.__name__), back_populates=f'parent_{keyword}', lazy=lazy, **child_kwargs)
return parent, child return parent, child
@ -253,8 +254,7 @@ class AuthSrc(metaclass=ABCMeta):
def require_auth_base(cls: type[DeclarativeBase], *, src: AuthSrc, column: str | Column[_T] = 'id', dest: str = 'user', def require_auth_base(cls: type[DeclarativeBase], *, src: AuthSrc, column: str | Column[_T] = 'id', dest: str = 'user',
required: bool = False, signed: bool = False, sig_dest: str = 'signature', validators: Callable | Iterable[Callable] | None = None, required: bool = False, signed: bool = False, sig_dest: str = 'signature', validators: Callable | Iterable[Callable] | None = None):
invalid_exc: Callable | None = None, required_exc: Callable | None = None):
''' '''
Inject the current user into a view, given the Authorization: Bearer header. Inject the current user into a view, given the Authorization: Bearer header.
@ -275,11 +275,11 @@ def require_auth_base(cls: type[DeclarativeBase], *, src: AuthSrc, column: str |
except Exception: except Exception:
return None return None
def _default_invalid(msg: str): def _default_invalid(msg: str = 'validation failed'):
raise ValueError(msg) raise ValueError(msg)
invalid_exc = invalid_exc or _default_invalid invalid_exc = src.invalid_exc or _default_invalid
required_exc = required_exc or (lambda: _default_invalid()) required_exc = src.required_exc or (lambda: _default_invalid('Login required'))
def decorator(func: Callable): def decorator(func: Callable):
@wraps(func) @wraps(func)