fastapi-rosetta 0.1.0__py3-none-any.whl

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.
Files changed (58) hide show
  1. fastapi_rosetta/__init__.py +20 -0
  2. fastapi_rosetta/access.py +104 -0
  3. fastapi_rosetta/conf/__init__.py +130 -0
  4. fastapi_rosetta/dependencies.py +34 -0
  5. fastapi_rosetta/jinja.py +256 -0
  6. fastapi_rosetta/locale/ar/LC_MESSAGES/messages.mo +0 -0
  7. fastapi_rosetta/locale/ar/LC_MESSAGES/messages.po +209 -0
  8. fastapi_rosetta/locale/az/LC_MESSAGES/messages.mo +0 -0
  9. fastapi_rosetta/locale/az/LC_MESSAGES/messages.po +210 -0
  10. fastapi_rosetta/locale/cs/LC_MESSAGES/messages.mo +0 -0
  11. fastapi_rosetta/locale/cs/LC_MESSAGES/messages.po +201 -0
  12. fastapi_rosetta/locale/de/LC_MESSAGES/messages.mo +0 -0
  13. fastapi_rosetta/locale/de/LC_MESSAGES/messages.po +206 -0
  14. fastapi_rosetta/locale/es/LC_MESSAGES/messages.mo +0 -0
  15. fastapi_rosetta/locale/es/LC_MESSAGES/messages.po +231 -0
  16. fastapi_rosetta/locale/fa/LC_MESSAGES/messages.mo +0 -0
  17. fastapi_rosetta/locale/fa/LC_MESSAGES/messages.po +203 -0
  18. fastapi_rosetta/locale/fr/LC_MESSAGES/messages.mo +0 -0
  19. fastapi_rosetta/locale/fr/LC_MESSAGES/messages.po +209 -0
  20. fastapi_rosetta/locale/hu/LC_MESSAGES/messages.mo +0 -0
  21. fastapi_rosetta/locale/hu/LC_MESSAGES/messages.po +197 -0
  22. fastapi_rosetta/locale/it/LC_MESSAGES/messages.mo +0 -0
  23. fastapi_rosetta/locale/it/LC_MESSAGES/messages.po +204 -0
  24. fastapi_rosetta/locale/ky/LC_MESSAGES/messages.mo +0 -0
  25. fastapi_rosetta/locale/ky/LC_MESSAGES/messages.po +197 -0
  26. fastapi_rosetta/locale/nl/LC_MESSAGES/messages.mo +0 -0
  27. fastapi_rosetta/locale/nl/LC_MESSAGES/messages.po +203 -0
  28. fastapi_rosetta/locale/pl/LC_MESSAGES/messages.mo +0 -0
  29. fastapi_rosetta/locale/pl/LC_MESSAGES/messages.po +213 -0
  30. fastapi_rosetta/locale/ru/LC_MESSAGES/messages.mo +0 -0
  31. fastapi_rosetta/locale/ru/LC_MESSAGES/messages.po +200 -0
  32. fastapi_rosetta/locale/tr/LC_MESSAGES/messages.mo +0 -0
  33. fastapi_rosetta/locale/tr/LC_MESSAGES/messages.po +230 -0
  34. fastapi_rosetta/locale/uk/LC_MESSAGES/messages.mo +0 -0
  35. fastapi_rosetta/locale/uk/LC_MESSAGES/messages.po +209 -0
  36. fastapi_rosetta/locale/xx/LC_MESSAGES/messages.po +33 -0
  37. fastapi_rosetta/locale/zh_Hans/LC_MESSAGES/messages.mo +0 -0
  38. fastapi_rosetta/locale/zh_Hans/LC_MESSAGES/messages.po +212 -0
  39. fastapi_rosetta/poutil.py +173 -0
  40. fastapi_rosetta/routers/__init__.py +3 -0
  41. fastapi_rosetta/routers/rosetta.py +130 -0
  42. fastapi_rosetta/routers/services.py +817 -0
  43. fastapi_rosetta/signals.py +32 -0
  44. fastapi_rosetta/static/rosetta/css/rosetta.css +600 -0
  45. fastapi_rosetta/static/rosetta/favicon.svg +4 -0
  46. fastapi_rosetta/static/rosetta/img/icon_searchbox_rosetta.png +0 -0
  47. fastapi_rosetta/static/rosetta/js/rosetta.js +255 -0
  48. fastapi_rosetta/storage.py +130 -0
  49. fastapi_rosetta/templates/rosetta/base.html +131 -0
  50. fastapi_rosetta/templates/rosetta/file-list.html +151 -0
  51. fastapi_rosetta/templates/rosetta/form.html +463 -0
  52. fastapi_rosetta/test_smoke.py +17 -0
  53. fastapi_rosetta/translate_utils.py +271 -0
  54. fastapi_rosetta/version.py +6 -0
  55. fastapi_rosetta-0.1.0.dist-info/METADATA +243 -0
  56. fastapi_rosetta-0.1.0.dist-info/RECORD +58 -0
  57. fastapi_rosetta-0.1.0.dist-info/WHEEL +4 -0
  58. fastapi_rosetta-0.1.0.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from .conf import (
4
+ RosettaConfig,
5
+ configure,
6
+ get_settings,
7
+ reset_settings,
8
+ update_settings,
9
+ )
10
+ from .routers import mount_static, router
11
+
12
+ __all__ = [
13
+ "RosettaConfig",
14
+ "configure",
15
+ "get_settings",
16
+ "mount_static",
17
+ "reset_settings",
18
+ "router",
19
+ "update_settings",
20
+ ]
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ from collections.abc import Callable
5
+ from typing import Any, cast
6
+
7
+ from .conf import get_settings
8
+
9
+ AccessControl = Callable[[object], bool]
10
+ LanguageAccessControl = Callable[[object, str], bool]
11
+
12
+
13
+ def can_translate(user: object) -> bool:
14
+ return get_access_control_function()(user)
15
+
16
+
17
+ def can_translate_language(
18
+ user: object,
19
+ language_code: str,
20
+ ) -> bool:
21
+ settings = get_settings()
22
+
23
+ if not settings.language_groups:
24
+ return can_translate(user)
25
+
26
+ return get_language_access_control_function()(
27
+ user,
28
+ language_code,
29
+ )
30
+
31
+
32
+ def get_access_control_function() -> AccessControl:
33
+ settings = get_settings()
34
+
35
+ access_function = settings.access_control_function
36
+
37
+ if access_function is None:
38
+ return allow_all
39
+
40
+ if callable(access_function):
41
+ return cast(
42
+ AccessControl,
43
+ access_function,
44
+ )
45
+
46
+ if isinstance(access_function, str):
47
+ module_name, func_name = access_function.rsplit(".", 1)
48
+
49
+ module = importlib.import_module(module_name)
50
+
51
+ return cast(
52
+ AccessControl,
53
+ getattr(module, func_name),
54
+ )
55
+
56
+ raise TypeError(
57
+ "access_control_function must be " "callable, import path string or None."
58
+ )
59
+
60
+
61
+ def get_language_access_control_function() -> LanguageAccessControl:
62
+ settings = get_settings()
63
+
64
+ access_function: Any = getattr(
65
+ settings,
66
+ "language_access_control_function",
67
+ None,
68
+ )
69
+
70
+ if access_function is None:
71
+ return default_language_access_control
72
+
73
+ if callable(access_function):
74
+ return cast(
75
+ LanguageAccessControl,
76
+ access_function,
77
+ )
78
+
79
+ if isinstance(access_function, str):
80
+ module_name, func_name = access_function.rsplit(".", 1)
81
+
82
+ module = importlib.import_module(module_name)
83
+
84
+ return cast(
85
+ LanguageAccessControl,
86
+ getattr(module, func_name),
87
+ )
88
+
89
+ raise TypeError(
90
+ "language_access_control_function "
91
+ "must be callable, import path string "
92
+ "or None."
93
+ )
94
+
95
+
96
+ def allow_all(user: object) -> bool:
97
+ return True
98
+
99
+
100
+ def default_language_access_control(
101
+ user: object,
102
+ language_code: str,
103
+ ) -> bool:
104
+ return can_translate(user)
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ from threading import Lock
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+
9
+ class RosettaConfig(BaseModel):
10
+ model_config = ConfigDict(
11
+ extra="forbid",
12
+ validate_assignment=True,
13
+ )
14
+
15
+ messages_per_page: int = 10
16
+
17
+ enable_translation_suggestions: bool = False
18
+
19
+ yandex_translate_key: str | None = None
20
+ azure_client_secret: str | None = None
21
+
22
+ google_application_credentials_path: str | None = None
23
+ google_project_id: str | None = None
24
+
25
+ deepl_auth_key: str | None = None
26
+
27
+ openai_api_key: str | None = None
28
+ openai_prompt_template: str | None = None
29
+
30
+ main_language: str | None = None
31
+
32
+ messages_source_language_code: str = "en"
33
+ messages_source_language_name: str = "English"
34
+
35
+ access_control_function: str | None = None
36
+
37
+ wsgi_auto_reload: bool = False
38
+ uwsgi_auto_reload: bool = False
39
+
40
+ excluded_applications: tuple[str, ...] = ()
41
+
42
+ pofile_wrap_width: int = 78
43
+
44
+ storage_class: str = "fastapi_rosetta.storage.MemoryRosettaStorage"
45
+
46
+ enable_reflang: bool = False
47
+
48
+ pofilenames: tuple[str, ...] = ("messages.po",)
49
+
50
+ cache_name: str = "default"
51
+
52
+ requires_auth: bool = True
53
+
54
+ locale_paths: tuple[str, ...] = ()
55
+ excluded_paths: tuple[str, ...] = ()
56
+
57
+ language_groups: bool = False
58
+
59
+ auto_compile: bool = True
60
+
61
+ show_occurrences: bool = True
62
+
63
+ deepl_languages: dict[str, str] = Field(
64
+ default_factory=dict,
65
+ )
66
+ openai_base_url: str | None = None
67
+
68
+ openai_model: str = "gpt-4.1-mini"
69
+
70
+ languages: tuple[tuple[str, str], ...] = ()
71
+
72
+ login_url: str = "/login/"
73
+
74
+ languages_bidi: frozenset[str] = frozenset(
75
+ {"he", "ar", "ar-dz", "ckb", "fa", "ur"},
76
+ )
77
+
78
+ static_url_prefix: str = "/rosetta/static"
79
+
80
+
81
+ _lock = Lock()
82
+
83
+ _settings = RosettaConfig()
84
+
85
+
86
+ def configure(config: RosettaConfig) -> None:
87
+ """
88
+ Configure fastapi-rosetta.
89
+
90
+ Example:
91
+ configure(
92
+ RosettaConfig(
93
+ messages_per_page=50,
94
+ auto_compile=False,
95
+ )
96
+ )
97
+ """
98
+ global _settings
99
+
100
+ with _lock:
101
+ _settings = config
102
+
103
+
104
+ def get_settings() -> RosettaConfig:
105
+ return _settings
106
+
107
+
108
+ def reset_settings() -> None:
109
+ """
110
+ Mainly useful for tests.
111
+ """
112
+ global _settings
113
+
114
+ with _lock:
115
+ _settings = RosettaConfig()
116
+
117
+
118
+ def update_settings(**kwargs: Any) -> None:
119
+ """
120
+ Partially update existing settings.
121
+
122
+ Example:
123
+ update_settings(auto_compile=False)
124
+ """
125
+ global _settings
126
+
127
+ with _lock:
128
+ _settings = _settings.model_copy(
129
+ update=kwargs,
130
+ )
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated
4
+ from urllib.parse import quote
5
+
6
+ from fastapi import Depends, HTTPException, Request
7
+
8
+ from .access import can_translate
9
+ from .conf import get_settings
10
+
11
+
12
+ class AnonymousUser:
13
+ first_name = "Anonymous"
14
+ last_name = "User"
15
+ email = "anonymous@user.tld"
16
+
17
+
18
+ def get_user(request: Request) -> object:
19
+ return getattr(request.state, "user", AnonymousUser())
20
+
21
+
22
+ def require_translate(
23
+ request: Request,
24
+ user: Annotated[object, Depends(get_user)],
25
+ ) -> object:
26
+ settings = get_settings()
27
+ if settings.requires_auth and not can_translate(user):
28
+ login_url = settings.login_url
29
+ next_url = quote(str(request.url), safe="")
30
+ raise HTTPException(
31
+ status_code=302,
32
+ headers={"Location": f"{login_url}?next={next_url}"},
33
+ )
34
+ return user
@@ -0,0 +1,256 @@
1
+ from __future__ import annotations
2
+
3
+ import gettext
4
+ import json
5
+ import re
6
+ from collections.abc import Callable
7
+ from functools import lru_cache
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
12
+ from jinja2.runtime import Undefined
13
+ from markupsafe import Markup, escape
14
+
15
+ from .access import can_translate
16
+ from .conf import RosettaConfig, get_settings
17
+
18
+ __all__ = [
19
+ "TabIndexCounter",
20
+ "get_jinja_environment",
21
+ "register_jinja_filters",
22
+ "render_template",
23
+ "RosettaSettingsView",
24
+ ]
25
+
26
+
27
+ RX = re.compile(r"(%(\([^\s\)]*\))?[sd]|\{[\w\d_]+?\})")
28
+
29
+
30
+ @lru_cache
31
+ def get_translation(
32
+ language: str,
33
+ ) -> gettext.GNUTranslations | gettext.NullTranslations:
34
+
35
+ settings = get_settings()
36
+
37
+ locale_paths = [
38
+ Path(__file__).parent / "locale",
39
+ ]
40
+
41
+ if settings.locale_paths:
42
+ locale_paths.extend(Path(path) for path in settings.locale_paths)
43
+
44
+ for locale_path in locale_paths:
45
+ try:
46
+ return gettext.translation(
47
+ "messages",
48
+ localedir=str(locale_path),
49
+ languages=[language],
50
+ )
51
+ except FileNotFoundError:
52
+ continue
53
+
54
+ return gettext.NullTranslations()
55
+
56
+
57
+ def gettext_lazy(
58
+ language: str,
59
+ ) -> Callable[[str], str]:
60
+ return get_translation(language).gettext
61
+
62
+
63
+ class RosettaSettingsView:
64
+ """Expose RosettaConfig fields with legacy uppercase names for templates."""
65
+
66
+ _ALIASES = {
67
+ "MESSAGES_PER_PAGE": "messages_per_page",
68
+ "ENABLE_TRANSLATION_SUGGESTIONS": "enable_translation_suggestions",
69
+ "YANDEX_TRANSLATE_KEY": "yandex_translate_key",
70
+ "AZURE_CLIENT_SECRET": "azure_client_secret",
71
+ "GOOGLE_APPLICATION_CREDENTIALS_PATH": ("google_application_credentials_path"),
72
+ "GOOGLE_PROJECT_ID": "google_project_id",
73
+ "DEEPL_AUTH_KEY": "deepl_auth_key",
74
+ "OPENAI_API_KEY": "openai_api_key",
75
+ "MAIN_LANGUAGE": "main_language",
76
+ "MESSAGES_SOURCE_LANGUAGE_CODE": "messages_source_language_code",
77
+ "MESSAGES_SOURCE_LANGUAGE_NAME": "messages_source_language_name",
78
+ "WSGI_AUTO_RELOAD": "wsgi_auto_reload",
79
+ "UWSGI_AUTO_RELOAD": "uwsgi_auto_reload",
80
+ "EXCLUDED_APPLICATIONS": "excluded_applications",
81
+ "POFILE_WRAP_WIDTH": "pofile_wrap_width",
82
+ "STORAGE_CLASS": "storage_class",
83
+ "ENABLE_REFLANG": "enable_reflang",
84
+ "POFILENAMES": "pofilenames",
85
+ "CACHE_NAME": "cache_name",
86
+ "REQUIRES_AUTH": "requires_auth",
87
+ "LOCALE_PATHS": "locale_paths",
88
+ "EXCLUDED_PATHS": "excluded_paths",
89
+ "LANGUAGE_GROUPS": "language_groups",
90
+ "AUTO_COMPILE": "auto_compile",
91
+ "SHOW_OCCURRENCES": "show_occurrences",
92
+ "DEEPL_LANGUAGES": "deepl_languages",
93
+ "OPENAI_BASE_URL": "openai_base_url",
94
+ "OPENAI_MODEL": "openai_model",
95
+ "ROSETTA_LANGUAGES": "languages",
96
+ "LOGIN_URL": "login_url",
97
+ }
98
+
99
+ def __init__(self, config: RosettaConfig | None = None) -> None:
100
+ self._config = config or get_settings()
101
+
102
+ def __getattr__(self, name: str) -> Any:
103
+ attr = self._ALIASES.get(name, name)
104
+ return getattr(self._config, attr)
105
+
106
+
107
+ class TabIndexCounter:
108
+
109
+ def __init__(self, start: int = 0) -> None:
110
+ self.value: int = start
111
+
112
+ def next(self) -> int:
113
+ self.value += 1
114
+ return self.value
115
+
116
+
117
+ def format_message(message: str) -> Markup:
118
+ if not message:
119
+ return Markup("")
120
+
121
+ escaped = escape(message).replace("\n", "<br>\n")
122
+
123
+ return Markup(
124
+ RX.sub(
125
+ r"<code>\1</code>",
126
+ str(escaped),
127
+ )
128
+ )
129
+
130
+
131
+ def lines_count(message: str) -> int:
132
+ if not message:
133
+ return 1
134
+
135
+ return int(1 + sum(len(line) / 50 for line in message.splitlines()))
136
+
137
+
138
+ def mult(a: int | str, b: int | str) -> int:
139
+ return int(a) * int(b)
140
+
141
+
142
+ def minus(a: int | str, b: int | str) -> int:
143
+ try:
144
+ return int(a) - int(b)
145
+ except (TypeError, ValueError):
146
+ return 0
147
+
148
+
149
+ def gt(a: int | str, b: int | str) -> bool:
150
+ try:
151
+ return int(a) > int(b)
152
+ except (TypeError, ValueError):
153
+ return False
154
+
155
+
156
+ def is_fuzzy(message: object) -> bool:
157
+ return bool(message and hasattr(message, "flags") and "fuzzy" in message.flags)
158
+
159
+
160
+ def linebreaksbr(value: object) -> Markup:
161
+ text = str(value)
162
+ return Markup(text.replace("\n", "<br>\n"))
163
+
164
+
165
+ def linebreaks(value: object) -> Markup:
166
+ text = str(value)
167
+
168
+ return Markup(
169
+ "".join(f"<p>{paragraph}</p>" for paragraph in text.splitlines() if paragraph)
170
+ )
171
+
172
+
173
+ def tojson(value: object) -> Markup:
174
+
175
+ def default(obj: object) -> object:
176
+
177
+ if isinstance(obj, Undefined):
178
+ return None
179
+
180
+ if isinstance(obj, RosettaSettingsView):
181
+ return {key: getattr(obj, key) for key in obj._ALIASES}
182
+
183
+ return str(obj)
184
+
185
+ return Markup(
186
+ json.dumps(
187
+ value,
188
+ ensure_ascii=False,
189
+ default=default,
190
+ )
191
+ )
192
+
193
+
194
+ def register_jinja_filters(
195
+ env: Environment,
196
+ ) -> Environment:
197
+ env.filters["can_translate"] = can_translate
198
+ env.filters["format_message"] = format_message
199
+ env.filters["lines_count"] = lines_count
200
+ env.filters["mult"] = mult
201
+ env.filters["minus"] = minus
202
+ env.filters["gt"] = gt
203
+ env.filters["is_fuzzy"] = is_fuzzy
204
+ env.filters["linebreaksbr"] = linebreaksbr
205
+ env.filters["linebreaks"] = linebreaks
206
+ env.filters["tojson"] = tojson
207
+ env.filters["increment"] = lambda counter: counter.next()
208
+ env.filters["add"] = lambda a, b: int(a) + int(b)
209
+ env.filters["length"] = len
210
+ env.filters["title"] = lambda value: str(value).title()
211
+ return env
212
+
213
+
214
+ @lru_cache
215
+ def get_jinja_environment() -> Environment:
216
+
217
+ templates_dir = Path(__file__).parent / "templates"
218
+
219
+ env = Environment(
220
+ loader=FileSystemLoader(str(templates_dir)),
221
+ autoescape=select_autoescape(["html", "xml"]),
222
+ )
223
+
224
+ register_jinja_filters(env)
225
+
226
+ return env
227
+
228
+
229
+ def render_template(
230
+ template_name: str,
231
+ context: dict[str, Any],
232
+ *,
233
+ url_for: Any,
234
+ static: Any,
235
+ request: Any,
236
+ ) -> str:
237
+
238
+ settings = get_settings()
239
+
240
+ languages: tuple[tuple[str, str], ...] = settings.languages or ()
241
+
242
+ default_language = languages[0][0] if languages else "en"
243
+
244
+ language = request.cookies.get("rosetta_language") or default_language
245
+ env = get_jinja_environment()
246
+ template = env.get_template(template_name)
247
+ render_context = {
248
+ **context,
249
+ "url": url_for,
250
+ "static": static,
251
+ "request": request,
252
+ "_": gettext_lazy(language),
253
+ "current_language": language,
254
+ "rosetta_languages": languages,
255
+ }
256
+ return template.render(render_context)