add token loader, .flask_sqlalchemy, jsonencode(), base2048

This commit is contained in:
Yusur 2025-06-01 10:51:32 +02:00
parent dfa4309216
commit 9f75d983ba
11 changed files with 462 additions and 42 deletions

View file

@ -1,5 +1,12 @@
# Changelog # Changelog
## 0.3.0
- Add auth loaders i.e. `sqlalchemy.require_auth_base()`, `flask_sqlalchemy`
- Improve JSON handling in `flask_restx`
- Add base2048 (i.e. BIP-39) codec
- Add `split_bits()` and `join_bits()`
## 0.2.3 ## 0.2.3
- Bug fixes in `classtools` and `sqlalchemy` - Bug fixes in `classtools` and `sqlalchemy`

View file

@ -35,6 +35,9 @@ flask = [
"Flask>=2.0.0", "Flask>=2.0.0",
"Flask-RestX" "Flask-RestX"
] ]
flask_sqlalchemy = [
"Flask-SqlAlchemy"
]
peewee = [ peewee = [
"peewee>=3.0.0, <4.0" "peewee>=3.0.0, <4.0"
] ]

View file

@ -17,19 +17,20 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
""" """
from .iding import Siq, SiqCache, SiqType, SiqGen from .iding import Siq, SiqCache, SiqType, SiqGen
from .codecs import StringCase, cb32encode, cb32decode, jsonencode from .codecs import StringCase, cb32encode, cb32decode, jsonencode, want_bytes, want_str, b2048encode, b2048decode
from .bits import count_ones, mask_shift from .bits import count_ones, mask_shift, split_bits, join_bits
from .configparse import MissingConfigError, MissingConfigWarning, ConfigOptions, ConfigParserConfigSource, ConfigSource, DictConfigSource, ConfigValue, EnvConfigSource from .configparse import MissingConfigError, MissingConfigWarning, ConfigOptions, ConfigParserConfigSource, ConfigSource, DictConfigSource, ConfigValue, EnvConfigSource
from .functools import deprecated, not_implemented from .functools import deprecated, not_implemented
from .classtools import Wanted, Incomplete from .classtools import Wanted, Incomplete
from .itertools import makelist, kwargs_prefix from .itertools import makelist, kwargs_prefix
from .i18n import I18n, JsonI18n, TomlI18n from .i18n import I18n, JsonI18n, TomlI18n
__version__ = "0.2.3" __version__ = "0.3.0-dev21"
__all__ = ( __all__ = (
'Siq', 'SiqCache', 'SiqType', 'SiqGen', 'StringCase', 'Siq', 'SiqCache', 'SiqType', 'SiqGen', 'StringCase',
'MissingConfigError', 'MissingConfigWarning', 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', 'EnvConfigSource', 'DictConfigSource', 'MissingConfigError', 'MissingConfigWarning', 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', 'EnvConfigSource', 'DictConfigSource',
'deprecated', 'not_implemented', 'Wanted', 'Incomplete', 'jsonencode', 'deprecated', 'not_implemented', 'Wanted', 'Incomplete', 'jsonencode',
'makelist', 'kwargs_prefix', 'I18n', 'JsonI18n', 'TomlI18n', 'cb32encode', 'cb32decode', 'count_ones', 'mask_shift' 'makelist', 'kwargs_prefix', 'I18n', 'JsonI18n', 'TomlI18n', 'cb32encode', 'cb32decode', 'count_ones', 'mask_shift',
'want_bytes', 'want_str', 'version', 'b2048encode', 'split_bits', 'join_bits', 'b2048decode'
) )

View file

@ -14,6 +14,8 @@ This software is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
''' '''
import math
def mask_shift(n: int, mask: int) -> int: def mask_shift(n: int, mask: int) -> int:
''' '''
Select the bits from n chosen by mask, least significant first. Select the bits from n chosen by mask, least significant first.
@ -45,4 +47,53 @@ def count_ones(n: int) -> int:
n >>= 1 n >>= 1
return ones return ones
__all__ = ('count_ones', 'mask_shift') def split_bits(buf: bytes, nbits: int) -> list[int]:
'''
Split a bytestring into chunks of equal size, and interpret each chunk as an unsigned integer.
XXX DOES NOT WORK DO NOT USE!!!!!!!!
'''
mem = memoryview(buf)
chunk_size = nbits // math.gcd(nbits, 8)
est_len = math.ceil(len(buf) * 8 / nbits)
mask_n = chunk_size * 8 // nbits
numbers = []
off = 0
while off < len(buf):
chunk = mem[off:off+chunk_size].tobytes()
if len(chunk) < chunk_size:
chunk = chunk + b'\0' * (chunk_size - len(chunk))
num = int.from_bytes(chunk, 'big')
for j in range(mask_n):
numbers.append(mask_shift(num, ((1 << nbits) - 1) << ((mask_n - 1 - j) * nbits) ))
off += chunk_size
assert sum(numbers[est_len:]) == 0, str(f'{chunk_size=} {len(numbers)=} {est_len=} {numbers[est_len:]=}')
return numbers[:est_len]
def join_bits(l: list[int], nbits: int) -> bytes:
"""
Concatenate a list of integers into a bytestring.
"""
chunk_size = nbits // math.gcd(nbits, 8)
chunk = 0
mask_n = chunk_size * 8 // nbits
ou = b''
chunk, j = 0, mask_n - 1
for num in l:
chunk |= num << nbits * j
if j <= 0:
ou += chunk.to_bytes(chunk_size, 'big')
chunk, j = 0, mask_n - 1
else:
j -= 1
else:
if chunk != 0:
ou += chunk.to_bytes(chunk_size, 'big')
return ou
__all__ = ('count_ones', 'mask_shift', 'split_bits', 'join_bits')

View file

@ -18,10 +18,36 @@ import base64
import datetime import datetime
import enum import enum
import json import json
import math
import re import re
from typing import Any, Callable from typing import Any, Callable
from suou.functools import not_implemented from .bits import split_bits, join_bits
from .functools import deprecated
# yes, I know ItsDangerous implements that as well, but remember
# what happened with werkzeug.safe_str_cmp()?
# see also: https://gitlab.com/wcorrales/quart-csrf/-/issues/1
def want_bytes(s: str | bytes, encoding: str = "utf-8", errors: str = "strict") -> bytes:
"""
Force a string into its bytes representation.
By default, UTF-8 encoding is assumed.
"""
if isinstance(s, str):
s = s.encode(encoding, errors)
return s
def want_str(s: str | bytes, encoding: str = "utf-8", errors: str = "strict") -> str:
"""
Convert a bytestring into a text string.
By default, UTF-8 encoding is assumed.
"""
if isinstance(s, bytes):
s = s.decode(encoding, errors)
return s
B32_TO_CROCKFORD = str.maketrans( B32_TO_CROCKFORD = str.maketrans(
'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
@ -33,49 +59,157 @@ CROCKFORD_TO_B32 = str.maketrans(
'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
'=') '=')
BIP39_WORD_LIST = """
abandon ability able about above absent absorb abstract absurd abuse access accident account accuse achieve acid acoustic acquire across act action
actor actress actual adapt add addict address adjust admit adult advance advice aerobic affair afford afraid again age agent agree ahead aim air airport
aisle alarm album alcohol alert alien all alley allow almost alone alpha already also alter always amateur amazing among amount amused analyst anchor
ancient anger angle angry animal ankle announce annual another answer antenna antique anxiety any apart apology appear apple approve april arch
arctic area arena argue arm armed armor army around arrange arrest arrive arrow art artefact artist artwork ask aspect assault asset assist assume
asthma athlete atom attack attend attitude attract auction audit august aunt author auto autumn average avocado avoid awake aware away awesome
awful awkward axis baby bachelor bacon badge bag balance balcony ball bamboo banana banner bar barely bargain barrel base basic basket battle
beach bean beauty because become beef before begin behave behind believe below belt bench benefit best betray better between beyond bicycle bid
bike bind biology bird birth bitter black blade blame blanket blast bleak bless blind blood blossom blouse blue blur blush board boat body boil bomb
bone bonus book boost border boring borrow boss bottom bounce box boy bracket brain brand brass brave bread breeze brick bridge brief bright bring
brisk broccoli broken bronze broom brother brown brush bubble buddy budget buffalo build bulb bulk bullet bundle bunker burden burger burst bus
business busy butter buyer buzz cabbage cabin cable cactus cage cake call calm camera camp can canal cancel candy cannon canoe canvas canyon
capable capital captain car carbon card cargo carpet carry cart case cash casino castle casual cat catalog catch category cattle caught cause caution
cave ceiling celery cement census century cereal certain chair chalk champion change chaos chapter charge chase chat cheap check cheese chef cherry
chest chicken chief child chimney choice choose chronic chuckle chunk churn cigar cinnamon circle citizen city civil claim clap clarify claw clay clean clerk
clever click client cliff climb clinic clip clock clog close cloth cloud clown club clump cluster clutch coach coast coconut code coffee coil coin collect color
column combine come comfort comic common company concert conduct confirm congress connect consider control convince cook cool copper copy coral
core corn correct cost cotton couch country couple course cousin cover coyote crack cradle craft cram crane crash crater crawl crazy cream credit creek
crew cricket crime crisp critic crop cross crouch crowd crucial cruel cruise crumble crunch crush cry crystal cube culture cup cupboard curious current
curtain curve cushion custom cute cycle dad damage damp dance danger daring dash daughter dawn day deal debate debris decade december decide
decline decorate decrease deer defense define defy degree delay deliver demand demise denial dentist deny depart depend deposit depth deputy derive
describe desert design desk despair destroy detail detect develop device devote diagram dial diamond diary dice diesel diet differ digital dignity dilemma
dinner dinosaur direct dirt disagree discover disease dish dismiss disorder display distance divert divide divorce dizzy doctor document dog doll dolphin
domain donate donkey donor door dose double dove draft dragon drama drastic draw dream dress drift drill drink drip drive drop drum dry duck dumb
dune during dust dutch duty dwarf dynamic eager eagle early earn earth easily east easy echo ecology economy edge edit educate effort egg eight
either elbow elder electric elegant element elephant elevator elite else embark embody embrace emerge emotion employ empower empty enable enact
end endless endorse enemy energy enforce engage engine enhance enjoy enlist enough enrich enroll ensure enter entire entry envelope episode equal
equip era erase erode erosion error erupt escape essay essence estate eternal ethics evidence evil evoke evolve exact example excess exchange excite
exclude excuse execute exercise exhaust exhibit exile exist exit exotic expand expect expire explain expose express extend extra eye eyebrow fabric face
faculty fade faint faith fall false fame family famous fan fancy fantasy farm fashion fat fatal father fatigue fault favorite feature february federal fee
feed feel female fence festival fetch fever few fiber fiction field figure file film filter final find fine finger finish fire firm first fiscal fish fit fitness fix flag
flame flash flat flavor flee flight flip float flock floor flower fluid flush fly foam focus fog foil fold follow food foot force forest forget fork fortune forum
forward fossil foster found fox fragile frame frequent fresh friend fringe frog front frost frown frozen fruit fuel fun funny furnace fury future gadget gain
galaxy gallery game gap garage garbage garden garlic garment gas gasp gate gather gauge gaze general genius genre gentle genuine gesture ghost
giant gift giggle ginger giraffe girl give glad glance glare glass glide glimpse globe gloom glory glove glow glue goat goddess gold good goose gorilla
gospel gossip govern gown grab grace grain grant grape grass gravity great green grid grief grit grocery group grow grunt guard guess guide guilt
guitar gun gym habit hair half hammer hamster hand happy harbor hard harsh harvest hat have hawk hazard head health heart heavy hedgehog height
hello helmet help hen hero hidden high hill hint hip hire history hobby hockey hold hole holiday hollow home honey hood hope horn horror horse hospital
host hotel hour hover hub huge human humble humor hundred hungry hunt hurdle hurry hurt husband hybrid ice icon idea identify idle ignore ill illegal
illness image imitate immense immune impact impose improve impulse inch include income increase index indicate indoor industry infant inflict inform
inhale inherit initial inject injury inmate inner innocent input inquiry insane insect inside inspire install intact interest into invest invite involve iron island
isolate issue item ivory jacket jaguar jar jazz jealous jeans jelly jewel job join joke journey joy judge juice jump jungle junior junk just kangaroo keen
keep ketchup key kick kid kidney kind kingdom kiss kit kitchen kite kitten kiwi knee knife knock know lab label labor ladder lady lake lamp language
laptop large later latin laugh laundry lava law lawn lawsuit layer lazy leader leaf learn leave lecture left leg legal legend leisure lemon lend length lens
leopard lesson letter level liar liberty library license life lift light like limb limit link lion liquid list little live lizard load loan lobster local lock logic lonely
long loop lottery loud lounge love loyal lucky luggage lumber lunar lunch luxury lyrics machine mad magic magnet maid mail main major make mammal
man manage mandate mango mansion manual maple marble march margin marine market marriage mask mass master match material math matrix
matter maximum maze meadow mean measure meat mechanic medal media melody melt member memory mention menu mercy merge merit merry mesh
message metal method middle midnight milk million mimic mind minimum minor minute miracle mirror misery miss mistake mix mixed mixture mobile
model modify mom moment monitor monkey monster month moon moral more morning mosquito mother motion motor mountain mouse move movie
much muffin mule multiply muscle museum mushroom music must mutual myself mystery myth naive name napkin narrow nasty nation nature near neck
need negative neglect neither nephew nerve nest net network neutral never news next nice night noble noise nominee noodle normal north nose notable
note nothing notice novel now nuclear number nurse nut oak obey object oblige obscure observe obtain obvious occur ocean october odor off offer office
often oil okay old olive olympic omit once one onion online only open opera opinion oppose option orange orbit orchard order ordinary organ orient
original orphan ostrich other outdoor outer output outside oval oven over own owner oxygen oyster ozone pact paddle page pair palace palm panda
panel panic panther paper parade parent park parrot party pass patch path patient patrol pattern pause pave payment peace peanut pear peasant pelican
pen penalty pencil people pepper perfect permit person pet phone photo phrase physical piano picnic picture piece pig pigeon pill pilot pink pioneer pipe
pistol pitch pizza place planet plastic plate play please pledge pluck plug plunge poem poet point polar pole police pond pony pool popular portion
position possible post potato pottery poverty powder power practice praise predict prefer prepare present pretty prevent price pride primary print
priority prison private prize problem process produce profit program project promote proof property prosper protect proud provide public pudding pull
pulp pulse pumpkin punch pupil puppy purchase purity purpose purse push put puzzle pyramid quality quantum quarter question quick quit quiz quote
rabbit raccoon race rack radar radio rail rain raise rally ramp ranch random range rapid rare rate rather raven raw razor ready real reason rebel
rebuild recall receive recipe record recycle reduce reflect reform refuse region regret regular reject relax release relief rely remain remember remind
remove render renew rent reopen repair repeat replace report require rescue resemble resist resource response result retire retreat return reunion
reveal review reward rhythm rib ribbon rice rich ride ridge rifle right rigid ring riot ripple risk ritual rival river road roast robot robust rocket romance
roof rookie room rose rotate rough round route royal rubber rude rug rule run runway rural sad saddle sadness safe sail salad salmon salon salt salute
same sample sand satisfy satoshi sauce sausage save say scale scan scare scatter scene scheme school science scissors scorpion scout scrap screen
script scrub sea search season seat second secret section security seed seek segment select sell seminar senior sense sentence series service session
settle setup seven shadow shaft shallow share shed shell sheriff shield shift shine ship shiver shock shoe shoot shop short shoulder shove shrimp shrug
shuffle shy sibling sick side siege sight sign silent silk silly silver similar simple since sing siren sister situate six size skate sketch ski skill skin skirt
skull slab slam sleep slender slice slide slight slim slogan slot slow slush small smart smile smoke smooth snack snake snap sniff snow soap soccer
social sock soda soft solar soldier solid solution solve someone song soon sorry sort soul sound soup source south space spare spatial spawn speak
special speed spell spend sphere spice spider spike spin spirit split spoil sponsor spoon sport spot spray spread spring spy square squeeze squirrel
stable stadium staff stage stairs stamp stand start state stay steak steel stem step stereo stick still sting stock stomach stone stool story stove
strategy street strike strong struggle student stuff stumble style subject submit subway success such sudden suffer sugar suggest suit summer sun
sunny sunset super supply supreme sure surface surge surprise surround survey suspect sustain swallow swamp swap swarm swear sweet swift swim
swing switch sword symbol symptom syrup system table tackle tag tail talent talk tank tape target task taste tattoo taxi teach team tell ten tenant
tennis tent term test text thank that theme then theory there they thing this thought three thrive throw thumb thunder ticket tide tiger tilt timber time
tiny tip tired tissue title toast tobacco today toddler toe together toilet token tomato tomorrow tone tongue tonight tool tooth top topic topple torch
tornado tortoise toss total tourist toward tower town toy track trade traffic tragic train transfer trap trash travel tray treat tree trend trial tribe trick
trigger trim trip trophy trouble truck true truly trumpet trust truth try tube tuition tumble tuna tunnel turkey turn turtle twelve twenty twice twin twist
two type typical ugly umbrella unable unaware uncle uncover under undo unfair unfold unhappy uniform unique unit universe unknown unlock until
unusual unveil update upgrade uphold upon upper upset urban urge usage use used useful useless usual utility vacant vacuum vague valid valley valve
van vanish vapor various vast vault vehicle velvet vendor venture venue verb verify version very vessel veteran viable vibrant vicious victory video view
village vintage violin virtual virus visa visit visual vital vivid vocal voice void volcano volume vote voyage wage wagon wait walk wall walnut want
warfare warm warrior wash wasp waste water wave way wealth weapon wear weasel weather web wedding weekend weird welcome west wet whale
what wheat wheel when where whip whisper wide width wife wild will win window wine wing wink winner winter wire wisdom wise wish witness wolf
woman wonder wood wool word work world worry worth wrap wreck wrestle wrist write wrong yard year yellow you young youth zebra zero zone zoo
""".split()
BIP39_DECODE_MATRIX = {v[:4]: i for i, v in enumerate(BIP39_WORD_LIST)}
def cb32encode(val: bytes) -> str: def cb32encode(val: bytes) -> str:
''' '''
Encode bytes in Crockford Base32. Encode bytes in Crockford Base32.
''' '''
return base64.b32encode(val).decode('ascii').translate(B32_TO_CROCKFORD) return want_str(base64.b32encode(val)).translate(B32_TO_CROCKFORD)
def cb32decode(val: bytes | str) -> str: def cb32decode(val: bytes | str) -> str:
''' '''
Decode bytes from Crockford Base32. Decode bytes from Crockford Base32.
''' '''
if isinstance(val, str): return base64.b32decode(want_bytes(val).upper().translate(CROCKFORD_TO_B32) + b'=' * ((5 - len(val) % 5) % 5))
val = val.encode('ascii')
return base64.b32decode(val.upper().translate(CROCKFORD_TO_B32) + b'=' * ((5 - len(val) % 5) % 5))
def b32lencode(val: bytes) -> str: def b32lencode(val: bytes) -> str:
''' '''
Encode bytes as a lowercase base32 string, with trailing '=' stripped. Encode bytes as a lowercase base32 string, with trailing '=' stripped.
''' '''
return base64.b32encode(val).decode('ascii').rstrip('=').lower() return want_str(base64.b32encode(val)).rstrip('=').lower()
def b32ldecode(val: bytes | str) -> bytes: def b32ldecode(val: bytes | str) -> bytes:
''' '''
Decode a lowercase base32 encoded byte sequence. Padding is managed automatically. Decode a lowercase base32 encoded byte sequence. Padding is managed automatically.
''' '''
if isinstance(val, str): return base64.b32decode(want_bytes(val).upper() + b'=' * ((5 - len(val) % 5) % 5))
val = val.encode('ascii')
return base64.b32decode(val.upper() + b'=' * ((5 - len(val) % 5) % 5))
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 '=' and leading 'A'.
''' '''
b = base64.urlsafe_b64encode(val).decode('ascii') b = want_str(base64.urlsafe_b64encode(val))
return b.lstrip('A').rstrip('=') if strip else b return b.lstrip('A').rstrip('=') if strip else b
def b64decode(val: bytes | str) -> bytes: def b64decode(val: bytes | str) -> bytes:
''' '''
Wrapper around base64.urlsafe_b64decode() which deals with padding. Wrapper around base64.urlsafe_b64decode() which deals with padding.
''' '''
if isinstance(val, str): return base64.urlsafe_b64decode(want_bytes(val).replace(b'/', b'_').replace(b'+', b'-') + b'=' * ((4 - len(val) % 4) % 4))
val = val.encode('ascii')
return base64.urlsafe_b64decode(val.replace(b'/', b'_').replace(b'+', b'-') + b'=' * ((4 - len(val) % 4) % 4)) def b2048encode(val: bytes) -> str:
'''
Encode a bytestring using the BIP-39 wordlist.
'''
return ' '.join(BIP39_WORD_LIST[x] for x in split_bits(val, 11))
def b2048decode(val: bytes | str, *, strip = True) -> bytes:
"""
Decode a BIP-39 encoded string into bytes.
"""
try:
words = [BIP39_DECODE_MATRIX[x[:4]] for x in re.sub(r'[^a-z]+', ' ', want_str(val).lower()).split()]
except KeyError:
raise ValueError('illegal character')
b = join_bits(words, 11)
if strip:
assert b[math.ceil(len(words) * 11 / 8):].rstrip(b'\0') == b''
b = b[:math.ceil(len(words) * 11 / 8)]
return b
def _json_default(func = None) -> Callable[Any, str | list | dict]: def _json_default(func = None) -> Callable[Any, str | list | dict]:
def default_converter(obj: Any) -> str | list | dict: def default_converter(obj: Any) -> str | list | dict:
@ -89,10 +223,12 @@ def _json_default(func = None) -> Callable[Any, str | list | dict]:
def jsonencode(obj: dict, *, skipkeys: bool = True, separators: tuple[str, str] = (',', ':'), default: Callable | None = None, **kwargs) -> str: def jsonencode(obj: dict, *, skipkeys: bool = True, separators: tuple[str, str] = (',', ':'), default: Callable | None = None, **kwargs) -> str:
''' '''
JSON encoder with stricter and smarter defaults. json.dumps() but with stricter and smarter defaults, i.e. no whitespace in separators, and encoding dates as ISO strings.
''' '''
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)
class StringCase(enum.Enum): class StringCase(enum.Enum):
""" """
Enum values used by regex validators and storage converters. Enum values used by regex validators and storage converters.
@ -106,7 +242,7 @@ class StringCase(enum.Enum):
AS_IS = 0 AS_IS = 0
LOWER = FORCE_LOWER = 1 LOWER = FORCE_LOWER = 1
UPPER = FORCE_UPPER = 2 UPPER = FORCE_UPPER = 2
## difference between above and below is in storage and representation. ## difference between above and below is in storage and representation
IGNORE_LOWER = IGNORE = 3 IGNORE_LOWER = IGNORE = 3
IGNORE_UPPER = 4 IGNORE_UPPER = 4
@ -128,5 +264,5 @@ class StringCase(enum.Enum):
__all__ = ( __all__ = (
'cb32encode', 'cb32decode', 'b32lencode', 'b32ldecode', 'b64encode', 'b64decode', 'jsonencode' 'cb32encode', 'cb32decode', 'b32lencode', 'b32ldecode', 'b64encode', 'b64decode', 'jsonencode'
'StringCase' 'StringCase', 'want_bytes', 'want_str', 'jsondecode'
) )

View file

@ -14,7 +14,8 @@ This software is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
""" """
from flask import Flask, g, request from typing import Any
from flask import Flask, current_app, g, request
from .i18n import I18n from .i18n import I18n
from .configparse import ConfigOptions from .configparse import ConfigOptions
@ -57,6 +58,14 @@ def add_i18n(app: Flask, i18n: I18n, var_name: str = 'T', *,
return app return app
__all__ = ('add_context_from_config', 'add_i18n') def get_flask_conf(key: str, default = None, *, app: Flask | None = None) -> Any:
'''
Get a Flask configuration value
'''
if not app:
app = current_app
return app.config.get(key, default)
__all__ = ('add_context_from_config', 'add_i18n', 'get_flask_conf')

View file

@ -14,12 +14,44 @@ This software is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
""" """
from typing import Mapping from typing import Any, Mapping
import warnings
from flask import current_app, make_response
from flask_restx import Api as _Api from flask_restx import Api as _Api
from .codecs import jsonencode
def output_json(data, code, headers=None):
"""Makes a Flask response with a JSON encoded body.
The difference with flask_restx.representations handler of the
same name is suou.codecs.jsonencode() being used in place of plain json.dumps().
Opinionated: some RESTX_JSON settings are ignored.
"""
try:
settings: dict = current_app.config.get("RESTX_JSON", {}).copy()
settings.pop('indent', 0)
settings.pop('separators', 0)
except TypeError:
warnings.warn('illegal value for RESTX_JSON', UserWarning)
settings = {}
# always end the json dumps with a new line
# see https://github.com/mitsuhiko/flask/pull/1262
dumped = jsonencode(data, **settings) + "\n"
resp = make_response(dumped, code)
resp.headers.extend(headers or {})
return resp
class Api(_Api): class Api(_Api):
""" """
Fix Api() class by remapping .message to .error Improved flask_restx.Api() with better defaults.
Notably, all JSON is whitespace-free and .message is remapped to .error
""" """
def handle_error(self, e): def handle_error(self, e):
res = super().handle_error(e) res = super().handle_error(e)
@ -27,5 +59,9 @@ class Api(_Api):
res['error'] = res['message'] res['error'] = res['message']
del res['message'] del res['message']
return res return res
def __init__(self, *a, **ka):
super().__init__(*a, **ka)
self.representations['application/json'] = output_json
__all__ = ('Api',) __all__ = ('Api',)

View file

@ -0,0 +1,66 @@
"""
Utilities for Flask-SQLAlchemy binding.
---
Copyright (c) 2025 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 functools import partial
from typing import Any, Callable, Never
from flask import abort, request
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import DeclarativeBase, Session
from .sqlalchemy import require_auth_base
class FlaskAuthSrc(AuthSrc):
'''
'''
db: SQLAlchemy
def __init__(self, db: SQLAlchemy):
super().__init__()
self.db = db
def get_session(self) -> Session:
return self.db.session
def get_token(self):
return request.authorization.token
def invalid_exc(self, msg: str = 'validation failed') -> Never:
abort(400, msg)
def required_exc(self):
abort(401)
def require_auth(cls: type[DeclarativeBase], db: SQLAlchemy) -> Callable[Any, 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.
cls is a SQLAlchemy table.
db is a flask_sqlalchemy.SQLAlchemy() binding.
Usage:
auth_required = require_auth(User, db)
@route('/admin')
@auth_required(validators=[lambda x: x.is_administrator])
def super_secret_stuff(user):
pass
"""
return partial(require_auth_base, cls=cls, src=FlaskAuthSrc(db))
__all__ = ('require_auth', )

View file

@ -73,7 +73,7 @@ def connect_reconnect(db):
return db return db
## END async helperss ## END async helpers for Peewee
class RegexCharField(CharField): class RegexCharField(CharField):
''' '''

View file

@ -14,15 +14,17 @@ This software is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
""" """
from abc import ABC
from base64 import b64decode
from typing import Any, Callable
from itsdangerous import TimestampSigner from itsdangerous import TimestampSigner
from suou.iding import Siq from .codecs import want_str
from .iding import Siq
class UserSigner(TimestampSigner): class UserSigner(TimestampSigner):
""" """
Instance itsdangerous.TimestampSigner() from a user ID. itsdangerous.TimestampSigner() instanced from a user ID, with token generation and validation capabilities.
XXX UNTESTED!!!
""" """
user_id: int user_id: int
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):
@ -30,4 +32,21 @@ class UserSigner(TimestampSigner):
self.user_id = user_id self.user_id = user_id
def token(self) -> str: def token(self) -> str:
return self.sign(Siq(self.user_id).to_base64()).decode('ascii') return self.sign(Siq(self.user_id).to_base64()).decode('ascii')
@classmethod
def split_token(cls, /, token: str | bytes) :
a, b, c = want_str(token).rsplit('.', 2)
return b64decode(a), b, b64decode(c)
class HasSigner(ABC):
'''
Abstract base class for INTERNAL USE.
'''
signer: Callable[Any, UserSigner]
@classmethod
def __instancehook__(cls, obj) -> bool:
return callable(getattr(obj, 'signer', None))
__all__ = ('UserSigner', )

View file

@ -16,33 +16,39 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
from __future__ import annotations from __future__ import annotations
from typing import Callable from abc import ABCMeta, abstractmethod
from functools import wraps
from typing import Any, Callable, Iterable, Never, TypeVar
import warnings import warnings
from sqlalchemy import CheckConstraint, Date, ForeignKey, LargeBinary, Column, MetaData, SmallInteger, String, text from sqlalchemy import CheckConstraint, Date, Dialect, ForeignKey, LargeBinary, Column, MetaData, SmallInteger, String, select, text
from sqlalchemy.orm import DeclarativeBase, declarative_base as _declarative_base from sqlalchemy.orm import DeclarativeBase, Session, declarative_base as _declarative_base
from suou.itertools import kwargs_prefix from .itertools import kwargs_prefix, makelist
from .signing import HasSigner, UserSigner
from .signing import UserSigner
from .codecs import StringCase from .codecs import StringCase
from .functools import deprecated from .functools import deprecated
from .iding import SiqType, SiqCache from .iding import SiqType, SiqCache
from .classtools import Incomplete, Wanted from .classtools import Incomplete, Wanted
_T = TypeVar('_T')
# SIQs are 14 bytes long. Storage is padded for alignment # SIQs are 14 bytes long. Storage is padded for alignment
# Not to be confused with SiqType. # Not to be confused with SiqType.
IdType = LargeBinary(16) IdType = LargeBinary(16)
def sql_escape(s: str, /, dialect: str) -> str: def sql_escape(s: str, /, dialect: Dialect) -> str:
""" """
Escape a value for SQL embedding, using SQLAlchemy's literal processors. Escape a value for SQL embedding, using SQLAlchemy's literal processors.
Requires a dialect argument. Requires a dialect argument.
XXX this function is not mature yet, do not use
""" """
if isinstance(s, str): if isinstance(s, str):
return String().literal_processor(dialect=dialect)(s) return String().literal_processor(dialect=dialect)(s)
raise TypeError('invalid data type') raise TypeError('invalid data type')
def id_column(typ: SiqType, *, primary_key: bool = True): def id_column(typ: SiqType, *, primary_key: bool = True):
""" """
Marks a column which contains a SIQ. Marks a column which contains a SIQ.
@ -109,9 +115,7 @@ def token_signer(id_attr: Column | str, secret_attr: Column | str) -> Incomplete
Requires a master secret (taken from Base.metadata), a user id (visible in the token) Requires a master secret (taken from Base.metadata), a user id (visible in the token)
and a user secret. and a user secret.
""" """
if isinstance(id_attr, Incomplete): if isinstance(id_attr, Column):
raise TypeError('attempt to pass an uninstanced column. Pass the column name as a string instead.')
elif isinstance(id_attr, Column):
id_val = id_attr id_val = id_attr
elif isinstance(id_attr, str): elif isinstance(id_attr, str):
id_val = Wanted(id_attr) id_val = Wanted(id_attr)
@ -126,6 +130,7 @@ def token_signer(id_attr: Column | str, secret_attr: Column | str) -> Incomplete
return my_signer return my_signer
return Incomplete(Wanted(token_signer_factory)) return Incomplete(Wanted(token_signer_factory))
def author_pair(fk_name: str, *, id_type: type = IdType, sig_type: type | None = None, nullable: bool = False, sig_length: int | None = 2048, **ka) -> tuple[Column, Column]: def author_pair(fk_name: str, *, id_type: type = IdType, sig_type: type | None = None, nullable: bool = False, sig_length: int | None = 2048, **ka) -> tuple[Column, Column]:
""" """
Return an owner ID/signature column pair, for authenticated values. Return an owner ID/signature column pair, for authenticated values.
@ -136,6 +141,7 @@ def author_pair(fk_name: str, *, id_type: type = IdType, sig_type: type | None =
sig_col = Column(sig_type or LargeBinary(sig_length), nullable = nullable, **sig_ka) sig_col = Column(sig_type or LargeBinary(sig_length), nullable = nullable, **sig_ka)
return (id_col, sig_col) return (id_col, sig_col)
def age_pair(*, nullable: bool = False, **ka) -> tuple[Column, Column]: def age_pair(*, nullable: bool = False, **ka) -> tuple[Column, Column]:
""" """
Return a SIS-compliant age representation, i.e. a date and accuracy pair. Return a SIS-compliant age representation, i.e. a date and accuracy pair.
@ -154,7 +160,93 @@ def age_pair(*, nullable: bool = False, **ka) -> tuple[Column, Column]:
return (date_col, acc_col) return (date_col, acc_col)
def want_column(cls: type[DeclarativeBase], col: Column[_T] | str) -> Column[_T]:
"""
Return a table's column given its name.
XXX does it belong outside any scopes?
"""
if isinstance(col, Incomplete):
raise TypeError('attempt to pass an uninstanced column. Pass the column name as a string instead.')
elif isinstance(col, Column):
return col
elif isinstance(col, str):
return getattr(cls, col)
else:
raise TypeError
class AuthSrc(metaclass=ABCMeta):
'''
AuthSrc object required for require_auth_base().
This is an abstract class and is NOT usable directly.
'''
def required_exc(self) -> Never:
raise ValueError('required field missing')
def invalid_exc(self, msg: str = 'validation failed') -> Never:
raise ValueError(msg)
@abstractmethod
def get_session(self) -> Session:
pass
def get_user(self, getter: Callable):
return getter(self.get_token())
@abstractmethod
def get_token(self):
pass
def require_auth_base(cls: type[DeclarativeBase], *, src: AuthSrc, value_src: Callable[[Callable[[], _T]], Any], session_src: Callable[[], Session],
column: str | Column[_T] = 'id', dest: str = 'user', required: bool = False, 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.
For portability reasons, this is a partial, two-component function.
The value_src() callable takes a callable as its only and required argument, and the supplied
callable, when called, returns the value of the column.
The session_src() callable takes no argument, and returns a Session object.
XXX maybe a class is better than a thousand callables?
'''
col = want_column(cls, column)
validators = makelist(validators)
def get_user(token) -> DeclarativeBase:
if token is None:
return None
tok_parts = UserSigner.split_token(token)
user: HasSigner = session_src().execute(select(cls).where(col == tok_parts[0])).scalar()
try:
signer: UserSigner = user.signer()
signer.unsign(token)
return user
except Exception:
return None
def _default_invalid(msg: str):
raise ValueError(msg)
invalid_exc = invalid_exc or _default_invalid
required_exc = required_exc or (lambda: _default_invalid())
def decorator(func: Callable):
@wraps(func)
def wrapper(*a, **ka):
ka[dest] = value_src(get_user)
if not ka[dest] and required:
required_exc()
for valid in validators:
if not valid(ka[dest]):
invalid_exc(getattr(valid, 'message', 'validation failed').format(user=ka[dest]))
return func(*a, **ka)
return wrapper
return decorator
__all__ = ( __all__ = (
'IdType', 'id_column', 'entity_base', 'declarative_base', 'token_signer', 'match_column', 'match_constraint', 'IdType', 'id_column', 'entity_base', 'declarative_base', 'token_signer', 'match_column', 'match_constraint',
'author_pair', 'age_pair' 'author_pair', 'age_pair', 'require_auth_base', 'want_column'
) )