add i18n
This commit is contained in:
parent
bc15db8153
commit
91c6e9c1f9
10 changed files with 280 additions and 10 deletions
11
CHANGELOG.md
Normal file
11
CHANGELOG.md
Normal 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
|
||||
|
||||
|
|
@ -5,7 +5,8 @@ authors = [
|
|||
]
|
||||
dynamic = [ "version" ]
|
||||
dependencies = [
|
||||
"itsdangerous"
|
||||
"itsdangerous",
|
||||
"toml"
|
||||
]
|
||||
requires-python = ">=3.10"
|
||||
license = "Apache-2.0"
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
122
src/suou/i18n.py
Normal 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')
|
||||
|
|
@ -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
41
src/suou/itertools.py
Normal 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')
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue