Compare commits
7 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5030ef25a | |||
| cb99c3911c | |||
| f0a109983b | |||
| 96d25e0e85 | |||
| 04ce86a43e | |||
| 1c2bd11212 | |||
| bc4ea9b101 |
9 changed files with 77 additions and 24 deletions
17
CHANGELOG.md
17
CHANGELOG.md
|
|
@ -1,5 +1,22 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.4.0
|
||||||
|
|
||||||
|
👀
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.6"
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'Siq', 'SiqCache', 'SiqType', 'SiqGen', 'StringCase',
|
'Siq', 'SiqCache', 'SiqType', 'SiqGen', 'StringCase',
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ 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_alias
|
||||||
|
|
@ -29,6 +29,9 @@ from .functools import deprecated_alias
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,22 +35,26 @@ 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:
|
||||||
"""
|
"""
|
||||||
Make an auth_required() decorator for Flask views.
|
Make an auth_required() decorator for Flask views.
|
||||||
|
|
||||||
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', )
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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',
|
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 +274,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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue