sqlmodel-translation 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.3
2
+ Name: sqlmodel-translation
3
+ Version: 0.1.1
4
+ Summary: Translation library for SQLModel and FastAPI
5
+ Requires-Dist: fastapi>=0.119.1
6
+ Requires-Dist: sqlalchemy>=2.0.44
7
+ Requires-Dist: sqlmodel>=0.0.27
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+
11
+ # SQLModel-translation
12
+
13
+ SQLModel-translation is a translation library for [SQLModel](https://sqlmodel.tiangolo.com) and [FastAPI](https://fastapi.tiangolo.com).
14
+
15
+ Documentation: [https://dnafivuq.github.io/sqlmodel-translation](https://dnafivuq.github.io/sqlmodel-translation)
16
+
17
+ This project uses [uv](https://docs.astral.sh/uv/) for package managment.
18
+
19
+ To generate the documentation run `make docs` and visit http://127.0.0.1:8000/.
20
+
21
+ For more actions see the Makefile in this directory. Running `make` will print out all the targets with descriptions.
22
+
@@ -0,0 +1,12 @@
1
+ # SQLModel-translation
2
+
3
+ SQLModel-translation is a translation library for [SQLModel](https://sqlmodel.tiangolo.com) and [FastAPI](https://fastapi.tiangolo.com).
4
+
5
+ Documentation: [https://dnafivuq.github.io/sqlmodel-translation](https://dnafivuq.github.io/sqlmodel-translation)
6
+
7
+ This project uses [uv](https://docs.astral.sh/uv/) for package managment.
8
+
9
+ To generate the documentation run `make docs` and visit http://127.0.0.1:8000/.
10
+
11
+ For more actions see the Makefile in this directory. Running `make` will print out all the targets with descriptions.
12
+
@@ -0,0 +1,99 @@
1
+ [project]
2
+ name = "sqlmodel-translation"
3
+ version = "0.1.1"
4
+ description = "Translation library for SQLModel and FastAPI"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+
8
+ dependencies = [
9
+ "fastapi>=0.119.1",
10
+ "sqlalchemy>=2.0.44",
11
+ "sqlmodel>=0.0.27",
12
+ ]
13
+
14
+ [dependency-groups]
15
+ dev = [
16
+ "mkdocs>=1.6.1",
17
+ "mkdocstrings[python]>=0.30.1",
18
+ "mkdocs-material>=9.6.22",
19
+ "pytest>=8.4.2",
20
+ "tox>=4.31.0",
21
+ "tox-uv>=1.29.0",
22
+ "fastapi[standard]>=0.119.1",
23
+ "mypy>=1.19.1",
24
+ "pytest-cov>=7.0.0",
25
+ "ruff>=0.14.11",
26
+ ]
27
+
28
+ [build-system]
29
+ requires = ["uv_build>=0.9.22,<0.10.0"]
30
+ build-backend = "uv_build"
31
+
32
+ [tool.uv.build-backend]
33
+ module-name = "modeltranslation"
34
+
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["./tests/*"]
38
+
39
+
40
+ [tool.tox]
41
+ requires = ["tox>=4"]
42
+ env_list = ["3.12", "3.13", "3.14"]
43
+
44
+ [tool.tox.env_run_base]
45
+ description = "run tests"
46
+ deps = [
47
+ "pytest>=8"
48
+ ]
49
+
50
+ commands = [["pytest", "-q", "tests", { replace = "posargs", extend = true}]]
51
+
52
+ # Doctests are currently turned off. This is because SQLModel saves state
53
+ # between doctests and we haven't found a way to fix that.
54
+ # ["pytest", "-q", "--doctest-modules", "src"]
55
+
56
+
57
+ [tool.tox.env-3.12]
58
+ description = "run unit tests and doctests in python-3.12"
59
+
60
+ [tool.tox.env-3.13]
61
+ description = "run unit tests and doctests in python-3.13"
62
+
63
+ [tool.tox.env-3.14]
64
+ description = "run unit tests and doctests in python-3.14"
65
+
66
+
67
+ [tool.ruff]
68
+ line-length = 109
69
+
70
+
71
+ [tool.ruff.lint]
72
+ select = ["ALL"]
73
+
74
+ ignore = [
75
+ # Rules conflicting with the formatter
76
+ "W191",
77
+ "E111",
78
+ "E114",
79
+ "E117",
80
+ "D206",
81
+ "D300",
82
+ "Q000",
83
+ "Q001",
84
+ "Q002",
85
+ "Q003",
86
+ "COM812",
87
+ "COM819",
88
+
89
+ # Conflicting rules
90
+ "D203",
91
+ "D213",
92
+
93
+ # Annoying rules
94
+ "D1", # Required docstrings
95
+ "T20", # No print()
96
+ "S101", # No assert
97
+ "TD" # Too strict TODO rules
98
+
99
+ ]
@@ -0,0 +1,5 @@
1
+ from .exceptions import ImproperlyConfiguredError
2
+ from .fastapi_middleware import apply_translation
3
+ from .translator import TranslationOptions, Translator
4
+
5
+ __all__ = ["ImproperlyConfiguredError", "TranslationOptions", "Translator", "apply_translation"]
@@ -0,0 +1,5 @@
1
+ class ImproperlyConfiguredError(Exception):
2
+ """Raised when the configuration is internally inconsistent."""
3
+
4
+ def __init__(self, message: str) -> None:
5
+ super().__init__(message)
@@ -0,0 +1,43 @@
1
+ from fastapi import FastAPI, Request
2
+
3
+ from .translator import Translator
4
+
5
+
6
+ def apply_translation(app: FastAPI, translator: Translator) -> None:
7
+ """Configure the app set the current language as a context variable.
8
+
9
+ Applies middleware to FastAPI app which sets language based on the accept-language HTTP header.
10
+ The resolved language is stored in the translator per execution context.
11
+
12
+ Args:
13
+ app (FastAPI): FastAPI application.
14
+ translator (Translator): The translator used to register translations in this app.
15
+
16
+ Examples:
17
+ >>> from fastapi import FastAPI
18
+ >>> from modeltranslation import Translator, apply_translation
19
+ ...
20
+ >>> translator = Translator(
21
+ ... default_language="en",
22
+ ... languages=("en", "pl"),
23
+ ... )
24
+ >>> app = FastAPI()
25
+ >>> apply_translation(app, translator)
26
+
27
+ Note:
28
+ In a typical use case, you would register translations with
29
+ the translator before calling this function.
30
+
31
+ """
32
+
33
+ @app.middleware("http")
34
+ async def set_locale_context(request: Request, call_next):
35
+ header = request.headers.get("accept-language")
36
+ locale = header.split(",") if header else None
37
+ if locale:
38
+ for entry in locale:
39
+ lang = entry.split(";")
40
+ if lang[0] in translator.get_languages():
41
+ translator.set_active_language(str(lang[0]))
42
+ break
43
+ return await call_next(request)
@@ -0,0 +1,432 @@
1
+ from collections.abc import Callable, Iterator
2
+ from contextvars import ContextVar
3
+ from copy import deepcopy
4
+ from functools import wraps
5
+ from types import UnionType
6
+ from typing import Any, get_args, get_origin
7
+
8
+ from pydantic import field_serializer
9
+ from sqlalchemy import Column
10
+ from sqlalchemy.orm import column_property
11
+ from sqlmodel import SQLModel
12
+
13
+ from .exceptions import ImproperlyConfiguredError
14
+
15
+
16
+ class TranslationOptions:
17
+ """Base class for configuring the translation of SQLModel classes.
18
+
19
+ This class defines which fields are translated,
20
+ which translations are required and how to handle missing values.
21
+
22
+ Examples:
23
+ >>> from modeltranslation import TranslationOptions
24
+ >>> class BookTranslationOptions(TranslationOptions):
25
+ ... fields = ("title",)
26
+ ... required_languages = ("en",)
27
+
28
+ """
29
+
30
+ fields: tuple[str, ...] = ()
31
+ """Names of fields to translate.
32
+
33
+ Example:
34
+ `(`title`, `description`)`
35
+ """
36
+
37
+ fallback_languages: dict[str, tuple[str, ...]] | None = None
38
+ """Languages to use when the current language is missing.
39
+
40
+ Example:
41
+ `('en', 'pl', 'de')`
42
+
43
+ The fallbacks can be also specified with a dictionary. The default key is required.
44
+
45
+ Example:
46
+ ```
47
+ {
48
+ 'default': ('en', 'pl', 'de'),
49
+ 'fr': 'es'
50
+ }
51
+ ```
52
+ """
53
+ fallback_values: dict[str, Any] | Any = None
54
+ """The values to use if all fallback languages yielded no value.
55
+
56
+ Example:
57
+ `('No translation provided')`
58
+
59
+ It's also possible to specify a fallback value for each field.
60
+
61
+ Example:
62
+ ```
63
+ {
64
+ 'title': ('No translation'),
65
+ 'author': ('No translation provided')
66
+ }
67
+ ```
68
+
69
+ """
70
+
71
+ fallback_undefined: dict[str, Any] | None = None
72
+
73
+ required_languages: dict[str, tuple[str, ...]] | tuple[str, ...] | None = None
74
+ """The required translations for this class.
75
+
76
+ This also affects the pydantic model and typehints.
77
+
78
+ Example:
79
+ `('en',)`
80
+
81
+ The fallbacks can be also specified with a dictionary.
82
+ This makes it possible to set the requirements per field.
83
+
84
+ Example:
85
+ ```
86
+ {
87
+ 'en': ('title', 'author'),
88
+ 'default': ('title',)
89
+ }
90
+ ```
91
+ The `default` key is required.
92
+ For english, title and author are required. For all other languages only title is required.
93
+
94
+ """
95
+
96
+
97
+ class Translator:
98
+ """A translator object that manages translations for registered SQLModel classes."""
99
+
100
+ def __init__(
101
+ self,
102
+ default_language: str,
103
+ languages: tuple[str, ...],
104
+ fallback_languages: dict[str, tuple[str, ...]] | None = None,
105
+ ) -> None:
106
+ """Construct a translator object.
107
+
108
+ Args:
109
+ default_language (str): The language to use if no language was set externally.
110
+
111
+ languages (tuple[str, ...]): All supported languages i.e the translations you want to store.
112
+
113
+ fallback_languages (dict[str, tuple[str, ...]] | None): Fallbacks for each language
114
+ used when the active language is not in `languages`. An example:
115
+ `{
116
+ 'default': ('en', 'pl', 'de'),
117
+ 'fr': 'es'
118
+ }`.
119
+ The default key is required.
120
+
121
+ Raises:
122
+ ImproperlyConfiguredError: If the configuration is internally inconsistent.
123
+
124
+ """
125
+ self._active_language: ContextVar[str] = ContextVar("current_locale", default=default_language)
126
+
127
+ self._default_language: str = default_language
128
+
129
+ self._languages: tuple[str, ...] = languages
130
+
131
+ # fallbacks for untranslated languages
132
+ self._fallback_languages: dict[str, tuple[str, ...]] = {"default": (self._default_language,)}
133
+ if fallback_languages:
134
+ self._fallback_languages = fallback_languages
135
+
136
+ self._validate_translator_object()
137
+
138
+ def get_languages(self) -> tuple[str, ...]:
139
+ return self._languages
140
+
141
+ def get_active_language(self) -> str:
142
+ return self._active_language.get()
143
+
144
+ def set_active_language(self, locale: str) -> None:
145
+ self._active_language.set(locale)
146
+
147
+ def get_default_language(self) -> str:
148
+ return self._default_language
149
+
150
+ def register(self, model: type[SQLModel]) -> Callable:
151
+ """Register a SQLModel class for translations.
152
+
153
+ This function returns a decorator that applies `TranslationOptions`
154
+ to the given SQLModel class. After applying, the model
155
+ will have translation accessors and metadata set up automatically.
156
+
157
+ Args:
158
+ model (SQLModel): the class to apply translations on.
159
+
160
+ Raises:
161
+ ImproperlyConfiguredError: If the translation options are inconsistent with the Translator.
162
+
163
+ Examples:
164
+ >>> from sqlmodel import SQLModel
165
+ >>> from modeltranslation import Translator
166
+ ...
167
+ >>> class Book(SQLModel, table=True):
168
+ ... title: str
169
+ ...
170
+ >>> translator = Translator(
171
+ ... default_language="en",
172
+ ... languages=("en", "pl"))
173
+ ...
174
+ >>> @translator.register(Book)
175
+ ... class BookTranslationOptions(TranslationOptions):
176
+ ... fields=('title',)
177
+
178
+ """
179
+
180
+ def decorator(options: TranslationOptions) -> None:
181
+ self._replace_accessors(model, options)
182
+ self._rebuild_model(model, options)
183
+
184
+ return decorator
185
+
186
+ def _replace_accessors( # noqa: C901
187
+ self, model: type[SQLModel], options: TranslationOptions
188
+ ) -> type[SQLModel]:
189
+ # check if TranslationOptions are valid before modifing model
190
+ self._validate_translation_options(options)
191
+
192
+ def locale_get_decorator(original_get_function: Callable) -> Callable:
193
+ @wraps(original_get_function)
194
+ def locale_function(
195
+ model_self: type[SQLModel] | SQLModel, name: str, *args: tuple[Any, ...]
196
+ ) -> Callable:
197
+ # ignore private and not translated functions
198
+ if name.startswith("_") or name not in options.fields:
199
+ return original_get_function(model_self, name, *args)
200
+
201
+ active_language = self.get_active_language()
202
+
203
+ if active_language in self._languages:
204
+ value = original_get_function(model_self, f"{name}_{active_language}")
205
+ if not self._is_null_value(name, value, options):
206
+ return value
207
+
208
+ for fallback_language in self._fallbacks_generator(active_language, options):
209
+ value = original_get_function(model_self, f"{name}_{fallback_language}")
210
+ if not self._is_null_value(name, value, options):
211
+ return value
212
+
213
+ # no fallback language yielded a value, try fallback values
214
+ return self._fallback_value(name, options)
215
+
216
+ return locale_function
217
+
218
+ def locale_set_decorator(original_set_function: Callable) -> Callable:
219
+ @wraps(original_set_function)
220
+ def locale_function(model_self: type[SQLModel], name: str, value: Any) -> Callable: # noqa: ANN401
221
+ if name.startswith("_") or name not in options.fields:
222
+ return original_set_function(model_self, name, value)
223
+
224
+ active_language = self.get_active_language()
225
+ # if language is in translation use it, else use the default translator language
226
+ if active_language in self._languages:
227
+ return original_set_function(model_self, f"{name}_{active_language}", value)
228
+ return original_set_function(model_self, f"{name}_{self._default_language}", value)
229
+
230
+ return locale_function
231
+
232
+ def locale_class_get_decorator(original_get_function: Callable) -> Callable:
233
+ @wraps(original_get_function)
234
+ def locale_function(
235
+ model_self: type[SQLModel] | SQLModel, name: str, *args: tuple[Any, ...]
236
+ ) -> Callable:
237
+ if name.startswith("_") or name not in options.fields:
238
+ return original_get_function(model_self, name, *args)
239
+
240
+ active_language = self.get_active_language()
241
+
242
+ if active_language in self._languages:
243
+ return original_get_function(model_self, f"{name}_{active_language}")
244
+
245
+ for fallback_language in self._fallbacks_generator(active_language, options):
246
+ return original_get_function(model_self, f"{name}_{fallback_language}")
247
+
248
+ return original_get_function(model_self, f"{name}_{self._default_language}")
249
+
250
+ return locale_function
251
+
252
+ model.__class__.__getattribute__ = locale_class_get_decorator(model.__class__.__getattribute__)
253
+ model.__getattribute__ = locale_get_decorator(model.__getattribute__)
254
+ model.__setattr__ = locale_set_decorator(model.__setattr__)
255
+ return model
256
+
257
+ def _rebuild_model(self, model: type[SQLModel], options: TranslationOptions) -> None:
258
+ def make_serializer(field_name: str) -> Callable:
259
+ @field_serializer(field_name, when_used="json")
260
+ def serial(self: type[SQLModel], _: Any) -> Any: # noqa: ANN401
261
+ return getattr(self, field_name)
262
+
263
+ return serial
264
+
265
+ for field in options.fields:
266
+ orig_type = model.__table__.columns[field].type # pyright: ignore[reportAttributeAccessIssue]
267
+ orig_annotation = model.__annotations__[field]
268
+
269
+ # change field to be Nullable
270
+ model.__table__.columns[field].nullable = True # pyright: ignore[reportAttributeAccessIssue]
271
+ model.__annotations__[field] = self._make_optional(orig_annotation)
272
+ model.model_fields[field].annotation = model.__annotations__[field]
273
+
274
+ # add custom json serialization
275
+ setattr(model, f"_serialize_{field}", make_serializer(field))
276
+
277
+ for lang in self._languages:
278
+ translation_field = f"{field}_{lang}"
279
+
280
+ translation_annotation = (
281
+ orig_annotation
282
+ if self._is_required(lang, field, options)
283
+ else self._make_optional(orig_annotation)
284
+ )
285
+
286
+ # change model SQL Alchemy table
287
+ column = Column(
288
+ translation_field, orig_type, nullable=(not self._is_required(lang, field, options))
289
+ )
290
+
291
+ model.__table__.append_column(column) # pyright: ignore[reportAttributeAccessIssue]
292
+
293
+ # change model Pydantic field
294
+ pydantic_field = deepcopy(model.model_fields[field])
295
+ pydantic_field.exclude = True
296
+ pydantic_field.alias = translation_field
297
+ pydantic_field.annotation = translation_annotation
298
+
299
+ model.model_fields[translation_field] = pydantic_field
300
+ model.__annotations__[translation_field] = translation_annotation
301
+
302
+ setattr(model, translation_field, column_property(column))
303
+
304
+ model.__pydantic_decorators__.build(model)
305
+ model.model_rebuild(force=True)
306
+
307
+ def _make_optional(self, typehint: Any) -> Any: # noqa: ANN401
308
+ """Wrap a type in Optional[] unless it's already optional."""
309
+ origin = get_origin(typehint)
310
+ # if origin is Union and type(None) in get_args(typehint):
311
+ if origin is UnionType and type(None) in get_args(typehint):
312
+ return typehint
313
+ return typehint | None
314
+
315
+ def _is_required(self, language: str, field: str, options: TranslationOptions) -> bool:
316
+ if type(options.required_languages) is tuple:
317
+ return language in options.required_languages
318
+
319
+ if type(options.required_languages) is dict:
320
+ if language in options.required_languages:
321
+ return field in options.required_languages[language]
322
+ if "default" in options.required_languages:
323
+ return field in options.required_languages["default"]
324
+ # required_languages in TranslationOptions is None
325
+ return False
326
+
327
+ def _is_null_value(self, field: str, value: Any, options: TranslationOptions) -> bool: # noqa: ANN401
328
+ # if translation defines custom fallback undefined value then check if value is eq to it
329
+ if options.fallback_undefined is not None and field in options.fallback_undefined:
330
+ return value is None or value == options.fallback_undefined[field]
331
+ # else check if value is eq None
332
+ return value is None
333
+
334
+ def _fallbacks_generator(self, language: str, options: TranslationOptions) -> Iterator[str]:
335
+ if options.fallback_languages is not None:
336
+ yield from self._yield_fallbacks(language, options.fallback_languages)
337
+ elif self._fallback_languages is not None:
338
+ yield from self._yield_fallbacks(language, self._fallback_languages)
339
+
340
+ def _yield_fallbacks(self, language: str, fallbacks: dict[str, tuple[str, ...]]) -> Iterator[str]:
341
+ seen: set[str] = set()
342
+
343
+ for fallback in fallbacks.get(language, ()):
344
+ if fallback not in seen:
345
+ seen.add(fallback)
346
+ yield fallback
347
+
348
+ for fallback in fallbacks.get("default", ()):
349
+ if fallback != language and fallback not in seen:
350
+ seen.add(fallback)
351
+ yield fallback
352
+
353
+ def _fallback_value(self, field: str, options: TranslationOptions) -> Any: # noqa: ANN401
354
+ if options.fallback_values is None:
355
+ return None
356
+ if type(options.fallback_values) is not dict:
357
+ return options.fallback_values
358
+ if field in options.fallback_values:
359
+ return options.fallback_values[field]
360
+ return None
361
+
362
+ def _validate_translator_object(self) -> None:
363
+ if self._languages is None:
364
+ msg = "'languages' can not be None"
365
+ raise ImproperlyConfiguredError(msg)
366
+ if type(self._languages) is not tuple:
367
+ msg = f"'languages' type is invalid {type(self._languages)}"
368
+ raise ImproperlyConfiguredError(msg)
369
+
370
+ if self._default_language is None:
371
+ msg = "'default_language' can not be None"
372
+ raise ImproperlyConfiguredError(msg)
373
+
374
+ if self._default_language not in self._languages:
375
+ msg = f"'{self._default_language}' used in 'defult_language' not in defined languages {self._languages}" # noqa: E501
376
+ raise ImproperlyConfiguredError(msg)
377
+
378
+ self._validate_fallback_languages(self._fallback_languages)
379
+
380
+ def _validate_translation_options(self, options: TranslationOptions) -> None:
381
+ self._validate_fallback_languages(options.fallback_languages)
382
+
383
+ if options.required_languages is None:
384
+ return
385
+
386
+ if type(options.required_languages) is tuple:
387
+ for lang in options.required_languages:
388
+ if lang not in self._languages:
389
+ msg = f"'{lang}' used in 'required_languages' not in defined languages {self._languages}"
390
+ raise ImproperlyConfiguredError(msg)
391
+ elif type(options.required_languages) is dict:
392
+ for lang in options.required_languages:
393
+ if lang != "default" and lang not in self._languages:
394
+ msg = f"'{lang}' used in 'required_languages' not in defined languages {self._languages}"
395
+ raise ImproperlyConfiguredError(msg)
396
+ else:
397
+ msg = f"'required_languages' type is invalid {type(options.required_languages)}"
398
+ raise ImproperlyConfiguredError(msg)
399
+
400
+ def _validate_fallback_languages(self, fallback_languages: dict[str, tuple[str, ...]] | None) -> None:
401
+ if fallback_languages is None:
402
+ return
403
+
404
+ if type(fallback_languages) is not dict:
405
+ msg = f"'fallback_languages' type is invalid {type(fallback_languages)}"
406
+ raise ImproperlyConfiguredError(msg)
407
+
408
+ if "default" not in fallback_languages:
409
+ msg = "missing 'default' key in 'fallback_languages'"
410
+ raise ImproperlyConfiguredError(msg)
411
+
412
+ for key, value in fallback_languages.items():
413
+ # check if languages used as keys are defined inside Translator languages
414
+ if key != "default" and key not in self._languages:
415
+ msg = f"'{key}' used in 'fallback_languages' not in defined languages {self._languages}"
416
+ raise ImproperlyConfiguredError(msg)
417
+
418
+ # check if fallbacks are defined as tuples
419
+ if type(value) is not tuple:
420
+ msg = f"'{key}' fallback type is invalid {type(value)}"
421
+ raise ImproperlyConfiguredError(msg)
422
+
423
+ # check if language is not used as fallback to itself
424
+ if key != "default" and key in value:
425
+ msg = f"'{key}' used in 'fallback_languages' used as fallback to itself"
426
+ raise ImproperlyConfiguredError(msg)
427
+
428
+ # check if languages used as fallbacks are defined inside Translator languages
429
+ for lang in value:
430
+ if lang not in self._languages:
431
+ msg = f"'{lang}' used in 'fallback_languages' not in defined languages {self._languages}"
432
+ raise ImproperlyConfiguredError(msg)