From 91c6e9c1f95fd9a2f1c4f2513137a0640fd56420 Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Mon, 26 May 2025 17:44:34 +0200 Subject: [PATCH] add i18n --- CHANGELOG.md | 11 ++++ pyproject.toml | 3 +- src/suou/__init__.py | 7 ++- src/suou/configparse.py | 22 +++++++- src/suou/flask.py | 33 ++++++++++- src/suou/i18n.py | 122 ++++++++++++++++++++++++++++++++++++++++ src/suou/iding.py | 10 ++++ src/suou/itertools.py | 41 ++++++++++++++ src/suou/peewee.py | 16 +++++- src/suou/sqlalchemy.py | 25 +++++++- 10 files changed, 280 insertions(+), 10 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/suou/i18n.py create mode 100644 src/suou/itertools.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1da90aa --- /dev/null +++ b/CHANGELOG.md @@ -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 + diff --git a/pyproject.toml b/pyproject.toml index e4ca2af..4b8aa8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,8 @@ authors = [ ] dynamic = [ "version" ] dependencies = [ - "itsdangerous" + "itsdangerous", + "toml" ] requires-python = ">=3.10" license = "Apache-2.0" diff --git a/src/suou/__init__.py b/src/suou/__init__.py index 5fd46d9..4e06041 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -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' ) diff --git a/src/suou/configparse.py b/src/suou/configparse.py index bb57570..a217f91 100644 --- a/src/suou/configparse.py +++ b/src/suou/configparse.py @@ -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: """ diff --git a/src/suou/flask.py b/src/suou/flask.py index 91a9693..e4f8f65 100644 --- a/src/suou/flask.py +++ b/src/suou/flask.py @@ -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') diff --git a/src/suou/i18n.py b/src/suou/i18n.py new file mode 100644 index 0000000..7080019 --- /dev/null +++ b/src/suou/i18n.py @@ -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') \ No newline at end of file diff --git a/src/suou/iding.py b/src/suou/iding.py index 07c95f3..dba591c 100644 --- a/src/suou/iding.py +++ b/src/suou/iding.py @@ -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' diff --git a/src/suou/itertools.py b/src/suou/itertools.py new file mode 100644 index 0000000..99a5477 --- /dev/null +++ b/src/suou/itertools.py @@ -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') diff --git a/src/suou/peewee.py b/src/suou/peewee.py index b4858a3..395cd69 100644 --- a/src/suou/peewee.py +++ b/src/suou/peewee.py @@ -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') diff --git a/src/suou/sqlalchemy.py b/src/suou/sqlalchemy.py index 1d7fcbe..af66b71 100644 --- a/src/suou/sqlalchemy.py +++ b/src/suou/sqlalchemy.py @@ -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',