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.
- sqlmodel_translation-0.1.1/PKG-INFO +22 -0
- sqlmodel_translation-0.1.1/README.md +12 -0
- sqlmodel_translation-0.1.1/pyproject.toml +99 -0
- sqlmodel_translation-0.1.1/src/modeltranslation/__init__.py +5 -0
- sqlmodel_translation-0.1.1/src/modeltranslation/exceptions.py +5 -0
- sqlmodel_translation-0.1.1/src/modeltranslation/fastapi_middleware.py +43 -0
- sqlmodel_translation-0.1.1/src/modeltranslation/translator.py +432 -0
|
@@ -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,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)
|