Compare commits

...

5 commits

8 changed files with 49 additions and 19 deletions

View file

@ -1,5 +1,10 @@
# Changelog
## 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
- 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.
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).

View file

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

View file

@ -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)
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]:
"""

View file

@ -20,7 +20,7 @@ from ast import TypeVar
from collections.abc import Mapping
from configparser import ConfigParser as _ConfigParser
import os
from typing import Any, Callable, Iterable, Iterator
from typing import Any, Callable, Iterator
from collections import OrderedDict
from .functools import deprecated_alias
@ -78,6 +78,8 @@ class ConfigParserConfigSource(ConfigSource):
_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('.')
@ -174,10 +176,12 @@ class ConfigValue:
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():
v = src.get(self._srcs[srckey], MISSING)
if self._required and not v:
raise MissingConfigError(f'required config {self._src} not set!')
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):

View file

@ -16,10 +16,10 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
from typing import Any, Mapping
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 .codecs import jsonencode
from .codecs import jsondecode, jsonencode, want_bytes, want_str
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
"""
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)
if isinstance(res, Mapping) and 'message' in res:
res['error'] = 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
def __init__(self, *a, **ka):
super().__init__(*a, **ka)

View file

@ -22,7 +22,7 @@ from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import DeclarativeBase, Session
from .codecs import want_bytes
from .sqlalchemy import require_auth_base
from .sqlalchemy import AuthSrc, require_auth_base
class FlaskAuthSrc(AuthSrc):
'''
@ -35,22 +35,26 @@ class FlaskAuthSrc(AuthSrc):
def get_session(self) -> Session:
return self.db.session
def get_token(self):
return request.authorization.token
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)
abort(401, 'Login required')
def require_auth(cls: type[DeclarativeBase], db: SQLAlchemy) -> Callable[Any, Callable]:
def require_auth(cls: type[DeclarativeBase], db: SQLAlchemy) -> Callable:
"""
Make an auth_required() decorator for Flask views.
This looks for a token in the Authorization header, validates it, loads the
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.
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])
def super_secret_stuff(user):
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', )

View file

@ -253,8 +253,7 @@ class AuthSrc(metaclass=ABCMeta):
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,
invalid_exc: Callable | None = None, required_exc: Callable | None = None):
required: bool = False, signed: bool = False, sig_dest: str = 'signature', validators: Callable | Iterable[Callable] | None = None):
'''
Inject the current user into a view, given the Authorization: Bearer header.
@ -275,11 +274,11 @@ def require_auth_base(cls: type[DeclarativeBase], *, src: AuthSrc, column: str |
except Exception:
return None
def _default_invalid(msg: str):
def _default_invalid(msg: str = 'validation failed'):
raise ValueError(msg)
invalid_exc = invalid_exc or _default_invalid
required_exc = required_exc or (lambda: _default_invalid())
invalid_exc = src.invalid_exc or _default_invalid
required_exc = src.required_exc or (lambda: _default_invalid())
def decorator(func: Callable):
@wraps(func)