This commit is contained in:
Yusur 2025-05-26 17:44:34 +02:00
parent bc15db8153
commit 91c6e9c1f9
10 changed files with 280 additions and 10 deletions

11
CHANGELOG.md Normal file
View file

@ -0,0 +1,11 @@
## 0.2.0
- Add `i18n`, `itertools`
- Add `toml` as a hard dependency
- Add support for Python dicts as `ConfigSource`
- First release on pip under name `sakuragasaki46-suou`
- Improve sqlalchemy support

View file

@ -5,7 +5,8 @@ authors = [
]
dynamic = [ "version" ]
dependencies = [
"itsdangerous"
"itsdangerous",
"toml"
]
requires-python = ">=3.10"
license = "Apache-2.0"

View file

@ -21,11 +21,14 @@ from .codecs import StringCase
from .configparse import MissingConfigError, MissingConfigWarning, ConfigOptions, ConfigParserConfigSource, ConfigSource, ConfigValue, EnvConfigSource
from .functools import deprecated, not_implemented
from .classtools import Wanted, Incomplete
from .itertools import makelist, kwargs_prefix
from .i18n import I18n, JsonI18n, TomlI18n
__version__ = "0.1.0"
__version__ = "0.2.0-dev21"
__all__ = (
'Siq', 'SiqCache', 'SiqType', 'SiqGen', 'StringCase',
'MissingConfigError', 'MissingConfigWarning', 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', 'EnvConfigSource',
'deprecated', 'not_implemented', 'Wanted', 'Incomplete'
'deprecated', 'not_implemented', 'Wanted', 'Incomplete',
'makelist', 'kwargs_prefix', 'I18n', 'JsonI18n', 'TomlI18n'
)

View file

@ -94,8 +94,28 @@ class ConfigParserConfigSource(ConfigSource):
yield f'{k1}.{k2}'
def __len__(self) -> int:
## XXX might be incorrect but who cares
return len(self._cfp)
return sum(len(x) for x in self._cfp)
class DictConfigSource(ConfigSource):
'''
Config source from Python mappings
'''
__slots__ = ('_d',)
_d: dict[str, Any]
def __init__(self, mapping: dict[str, Any]):
self._d = mapping
def __getitem__(self, key: str, /) -> str:
return self._d[key]
def get(self, key: str, fallback: _T = None, /):
return self._d.get(key, fallback)
def __contains__(self, key: str, /) -> bool:
return key in self._d
def __iter__(self) -> Iterator[str]:
yield from self._d
def __len__(self) -> int:
return len(self._d)
class ConfigValue:
"""

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.
"""
from flask import Flask
from flask import Flask, g, request
from .i18n import I18n
from .configparse import ConfigOptions
@ -28,6 +29,34 @@ def add_context_from_config(app: Flask, config: ConfigOptions) -> Flask:
return config.to_dict()
return app
__all__ = ('add_context_from_config', )
def add_i18n(app: Flask, i18n: I18n, var_name: str = 'T', *,
query_arg: str = 'lang', default_lang = 'en'):
'''
Integrate a I18n() object with a Flask application:
- set g.lang
- add T() to Jinja templates
'''
def _get_lang():
lang = request.args.get(query_arg)
if not lang:
for lp in request.headers.get('accept-language', 'en').split(','):
l = lp.split(';')[0]
lang = l
break
else:
lang = default_lang
return lang
@app.context_processor
def _add_i18n():
return {var_name: i18n.lang(_get_lang()).t}
@app.before_request
def _add_language_code():
g.lang = _get_lang()
return app
__all__ = ('add_context_from_config', 'add_i18n')

122
src/suou/i18n.py Normal file
View file

@ -0,0 +1,122 @@
'''
Internationalization (i18n) utilities.
---
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 __future__ import annotations
from abc import ABCMeta, abstractmethod
import json
import os
import toml
from typing import Mapping
class IdentityLang:
'''
Bogus language, translating strings to themselves.
'''
def t(self, key: str, /, *args, **kwargs) -> str:
return key.format(*args, **kwargs) if args or kwargs else key
class I18nLang:
'''
Single I18n language.
'''
_strings: dict[str, str]
_fallback: I18nLang | IdentityLang
def __init__(self, mapping: Mapping | None = None, /):
self._strings = dict(mapping) if mapping else dict()
self._fallback = IdentityLang()
def t(self, key: str, /, *args, **kwargs) -> str:
s = self._strings.get(key) or self._fallback.t(key)
if args or kwargs:
s = s.format(*args, **kwargs)
return s
def update(self, keys: dict[str, str], /):
self._strings.update(keys)
def add_fallback(self, fb: I18nLang):
self._fallback = fb
class I18n(metaclass=ABCMeta):
'''
Better, object-oriented version of python-i18n.
This is an __abstract class__! Use the appropriate subclasses in production
(i.e. JSON, TOML) according to the file format.
'''
root: str
langs: dict[str, I18nLang]
filename_tmpl: str
default_lang: str
autoload: bool
EXT: str
@classmethod
@abstractmethod
def loads(cls, s: str) -> dict:
pass
def load_file(self, filename: str, *, root: str | None = None) -> dict:
with open(os.path.join(root or self.root, filename)) as f:
return self.loads(f.read())
def load_lang(self, name: str, filename: str | None = None) -> I18nLang:
if not filename:
filename = self.filename_tmpl.format(lang=name, ext=self.EXT)
data = self.load_file(filename)
l = self.langs.setdefault(name, I18nLang())
l.update(data[name] if name in data else data)
if name != self.default_lang:
l.add_fallback(self.lang(self.default_lang))
return l
def __init__(self, root: str, filename_tmpl: str = 'strings.{lang}.{ext}', *, default_lang: str = 'en', autoload: bool = True):
self.root = root
# XXX f before string is MISSING on PURPOSE!
self.filename_tmpl = filename_tmpl if '{lang}' in filename_tmpl else filename_tmpl + '.{lang}.{ext}'
self.langs = dict()
self.default_lang = default_lang
self.autoload = autoload
def lang(self, name: str | None) -> I18nLang:
if not name:
name = self.default_lang
if name in self.langs:
l = self.langs[name]
else:
l = self.load_lang(name)
return l
class JsonI18n(I18n):
EXT = 'json'
@classmethod
def loads(cls, s: str) -> dict:
return json.loads(s)
class TomlI18n(I18n):
EXT = 'toml'
@classmethod
def loads(cls, s: str) -> dict:
return toml.loads(s)
__all__ = ('I18n', 'JsonI18n', 'TomlI18n')

View file

@ -38,6 +38,7 @@ from threading import Lock
import time
import os
from typing import Iterable, override
import warnings
from .functools import not_implemented, deprecated
from .codecs import b32lencode, b64encode, cb32encode
@ -221,6 +222,12 @@ class SiqCache:
class Siq(int):
def to_bytes(self, length: int = 14, byteorder = 'big', *, signed: bool = False) -> bytes:
return super().to_bytes(length, byteorder, signed=signed)
@classmethod
def from_bytes(cls, b: bytes, byteorder = 'big', *, signed: bool = False) -> Siq:
if len(b) < 14:
warnings.warn('trying to deserialize a bytestring shorter than 14 bytes', BytesWarning)
return super().from_bytes(b, byteorder, signed=signed)
def to_base64(self, length: int = 15, *, strip: bool = True) -> str:
return b64encode(self.to_bytes(length), strip=strip)
def to_cb32(self)-> str:
@ -277,6 +284,9 @@ class Siq(int):
def to_matrix(self, /, domain: str):
return f'@{self:u}:{domain}'
def __repr__(self):
return f'{self.__class__.__name__}({super().__repr__()})'
__all__ = (
'Siq', 'SiqCache', 'SiqType', 'SiqGen'

41
src/suou/itertools.py Normal file
View file

@ -0,0 +1,41 @@
'''
Iteration utilities.
---
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 typing import Any, Iterable
def makelist(l: Any) -> list:
'''
Make a list out of an iterable or a single value.
'''
if isinstance(l, (str, bytes, bytearray)):
return [l]
elif isinstance(l, Iterable):
return list(l)
elif l in (None, NotImplemented, Ellipsis):
return []
else:
return [l]
def kwargs_prefix(it: dict[str, Any], prefix: str) -> dict[str, Any]:
'''
Subset of keyword arguments. Useful for callable wrapping.
'''
return {k.removeprefix(prefix): v for k, v in it.items() if k.startswith(prefix)}
__all__ = ('makelist', 'kwargs_prefix')

View file

@ -21,6 +21,8 @@ from playhouse.shortcuts import ReconnectMixin
from peewee import CharField, Database, MySQLDatabase, _ConnectionState
import re
from suou.iding import Siq
from .codecs import StringCase
@ -94,7 +96,19 @@ class RegexCharField(CharField):
return CharField.db_value(self, value)
## TODO SiqField
class SiqField(Field):
field_type = 'varbinary(16)'
def db_value(self, value: int | Siq | bytes) -> bytes:
if isinstance(value, int):
value = Siq(value)
if isinstance(value, Siq):
value = value.to_bytes()
if not isinstance(value, bytes):
raise TypeError
return value
def python_value(self, value: bytes) -> Siq:
return Siq.from_bytes(value)
__all__ = ('connect_reconnect', 'RegexCharField')

View file

@ -18,9 +18,11 @@ from __future__ import annotations
from typing import Callable
import warnings
from sqlalchemy import CheckConstraint, ForeignKey, LargeBinary, Column, MetaData, String, text
from sqlalchemy import CheckConstraint, Date, ForeignKey, LargeBinary, Column, MetaData, SmallInteger, String, text
from sqlalchemy.orm import DeclarativeBase, declarative_base as _declarative_base
from suou.itertools import kwargs_prefix
from .signing import UserSigner
from .codecs import StringCase
from .functools import deprecated
@ -121,12 +123,29 @@ def author_pair(fk_name: str, *, id_type: type = IdType, sig_type: type | None =
"""
Return an owner ID/signature column pair, for authenticated values.
"""
id_ka = {k[3:]: v for k, v in ka.items() if k.startswith('id_')}
sig_ka = {k[4:]: v for k, v in ka.items() if k.startswith('sig_')}
id_ka = kwargs_prefix(ka, 'id_')
sig_ka = kwargs_prefix(ka, 'sig_')
id_col = Column(id_type, ForeignKey(fk_name), nullable = nullable, **id_ka)
sig_col = Column(sig_type or LargeBinary(sig_length), nullable = nullable, **sig_ka)
return (id_col, sig_col)
def age_pair(*, nullable: bool = False, **ka) -> tuple[Column, Column]:
"""
Return a SIS-compliant age representation, i.e. a date and accuracy pair.
Accuracy is represented by a small integer:
0 = exact
1 = month and day
2 = year and month
3 = year
4 = estimated year
"""
date_ka = kwargs_prefix(ka, 'date_')
acc_ka = kwargs_prefix(ka, 'acc_')
date_col = Column(Date, nullable = nullable, **date_ka)
acc_col = Column(SmallInteger, nullable = nullable, **acc_ka)
return (date_col, acc_col)
__all__ = (
'IdType', 'id_column', 'entity_base', 'declarative_base', 'token_signer', 'match_column', 'match_constraint',