i18n-fastapi 0.1.3__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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Johnatan Palacios Londoño
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: i18n-fastapi
3
+ Version: 0.1.3
4
+ Classifier: Development Status :: 4 - Beta
5
+ Classifier: Framework :: FastAPI
6
+ Classifier: Intended Audience :: Developers
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Rust
13
+ Classifier: Topic :: Software Development :: Internationalization
14
+ Classifier: Typing :: Typed
15
+ Requires-Dist: fastapi>=0.100.0
16
+ Requires-Dist: orjson>=3.9.0
17
+ Requires-Dist: pydantic>=2.0.0
18
+ Requires-Dist: pytest>=8.0.0 ; extra == 'dev'
19
+ Requires-Dist: pytest-asyncio>=0.23.0 ; extra == 'dev'
20
+ Requires-Dist: httpx>=0.27.0 ; extra == 'dev'
21
+ Requires-Dist: ruff>=0.4.0 ; extra == 'dev'
22
+ Requires-Dist: mypy>=1.10.0 ; extra == 'dev'
23
+ Requires-Dist: maturin>=1.5,<2.0 ; extra == 'dev'
24
+ Requires-Dist: pre-commit>=3.7.0 ; extra == 'dev'
25
+ Requires-Dist: watchfiles>=0.20.0 ; extra == 'reload'
26
+ Provides-Extra: dev
27
+ Provides-Extra: reload
28
+ License-File: LICENSE
29
+ Summary: High-performance i18n for FastAPI with Rust-powered translation engine
30
+ Keywords: fastapi,i18n,internationalization,rust,translations
31
+ Author-email: Johnatan Palacios Londoño <johnatan.palacios@utp.edu.co>
32
+ License: MIT
33
+ Requires-Python: >=3.11
34
+ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
35
+
36
+ # i18n-fastapi
37
+
38
+ [![PyPI version](https://img.shields.io/pypi/v/i18n-fastapi.svg)](https://pypi.org/project/i18n-fastapi/)
39
+ [![Python versions](https://img.shields.io/pypi/pyversions/i18n-fastapi.svg)](https://pypi.org/project/i18n-fastapi/)
40
+ [![License](https://img.shields.io/pypi/l/i18n-fastapi.svg)](https://github.com/johnatanpalacios/i18n-fastapi/blob/main/LICENSE)
41
+ [![CI](https://img.shields.io/github/actions/workflow/status/johnatanpalacios/i18n-fastapi/ci.yml?branch=main&label=CI)](https://github.com/johnatanpalacios/i18n-fastapi/actions)
42
+
43
+ High-performance internationalization for FastAPI, powered by a Rust translation engine with automatic Python fallback.
44
+
45
+ ## Features
46
+
47
+ - **Rust-powered engine** — blazing-fast translation lookups via PyO3, with pure Python fallback
48
+ - **ICU plural support** — zero / one / two / few / many / other
49
+ - **Configurable locale detection** — query params, cookies, headers, path prefix, in any priority order
50
+ - **Plugin pattern** — one-line setup with `I18n(app)`
51
+ - **Multiple locale directories** — shared + per-module translations with filename-based namespacing
52
+ - **Duplicate key detection** — catch conflicts at startup, not at runtime
53
+ - **Hot reload** — watch translation files during development
54
+ - **Built-in endpoint** — `GET /i18n/languages` returns available locales
55
+ - **FastAPI dependencies** — inject `Locale` and `TranslateFunc` into route handlers
56
+
57
+ ## Installation
58
+
59
+ ```bash
60
+ pip install i18n-fastapi
61
+ ```
62
+
63
+ For hot reload during development:
64
+
65
+ ```bash
66
+ pip install "i18n-fastapi[reload]"
67
+ ```
68
+
69
+ ## Quick Start
70
+
71
+ Create translation files:
72
+
73
+ ```
74
+ locales/
75
+ ├── en/
76
+ │ └── messages.json # {"greeting": "Hello {name}"}
77
+ └── es/
78
+ └── messages.json # {"greeting": "Hola {name}"}
79
+ ```
80
+
81
+ Set up your app:
82
+
83
+ ```python
84
+ from fastapi import FastAPI
85
+ from i18n_fastapi import I18n, t
86
+
87
+ app = FastAPI()
88
+ I18n(app)
89
+
90
+ @app.get("/greet")
91
+ async def greet():
92
+ return {"message": t("messages.greeting", name="World")}
93
+ ```
94
+
95
+ Request with `?lang=es` or `Accept-Language: es` and the response adapts automatically.
96
+
97
+ ## Documentation
98
+
99
+ - **[Usage Guide](docs/USAGE.md)** — configuration, locale detection, pluralization, and more
100
+ - **[Contributing](CONTRIBUTING.md)** — how to set up the dev environment, run tests, and submit PRs
101
+ - **[Publishing](docs/PUBLISHING.md)** — how to publish releases to PyPI
102
+ - **[Changelog](CHANGELOG.md)** — version history
103
+ - **[Code of Conduct](CODE_OF_CONDUCT.md)** — community standards
104
+
105
+ ## Author
106
+
107
+ Johnatan Palacios Londoño — [johnatan.palacios@utp.edu.co](mailto:johnatan.palacios@utp.edu.co)
108
+
109
+ ## License
110
+
111
+ [MIT](LICENSE)
112
+
@@ -0,0 +1,76 @@
1
+ # i18n-fastapi
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/i18n-fastapi.svg)](https://pypi.org/project/i18n-fastapi/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/i18n-fastapi.svg)](https://pypi.org/project/i18n-fastapi/)
5
+ [![License](https://img.shields.io/pypi/l/i18n-fastapi.svg)](https://github.com/johnatanpalacios/i18n-fastapi/blob/main/LICENSE)
6
+ [![CI](https://img.shields.io/github/actions/workflow/status/johnatanpalacios/i18n-fastapi/ci.yml?branch=main&label=CI)](https://github.com/johnatanpalacios/i18n-fastapi/actions)
7
+
8
+ High-performance internationalization for FastAPI, powered by a Rust translation engine with automatic Python fallback.
9
+
10
+ ## Features
11
+
12
+ - **Rust-powered engine** — blazing-fast translation lookups via PyO3, with pure Python fallback
13
+ - **ICU plural support** — zero / one / two / few / many / other
14
+ - **Configurable locale detection** — query params, cookies, headers, path prefix, in any priority order
15
+ - **Plugin pattern** — one-line setup with `I18n(app)`
16
+ - **Multiple locale directories** — shared + per-module translations with filename-based namespacing
17
+ - **Duplicate key detection** — catch conflicts at startup, not at runtime
18
+ - **Hot reload** — watch translation files during development
19
+ - **Built-in endpoint** — `GET /i18n/languages` returns available locales
20
+ - **FastAPI dependencies** — inject `Locale` and `TranslateFunc` into route handlers
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ pip install i18n-fastapi
26
+ ```
27
+
28
+ For hot reload during development:
29
+
30
+ ```bash
31
+ pip install "i18n-fastapi[reload]"
32
+ ```
33
+
34
+ ## Quick Start
35
+
36
+ Create translation files:
37
+
38
+ ```
39
+ locales/
40
+ ├── en/
41
+ │ └── messages.json # {"greeting": "Hello {name}"}
42
+ └── es/
43
+ └── messages.json # {"greeting": "Hola {name}"}
44
+ ```
45
+
46
+ Set up your app:
47
+
48
+ ```python
49
+ from fastapi import FastAPI
50
+ from i18n_fastapi import I18n, t
51
+
52
+ app = FastAPI()
53
+ I18n(app)
54
+
55
+ @app.get("/greet")
56
+ async def greet():
57
+ return {"message": t("messages.greeting", name="World")}
58
+ ```
59
+
60
+ Request with `?lang=es` or `Accept-Language: es` and the response adapts automatically.
61
+
62
+ ## Documentation
63
+
64
+ - **[Usage Guide](docs/USAGE.md)** — configuration, locale detection, pluralization, and more
65
+ - **[Contributing](CONTRIBUTING.md)** — how to set up the dev environment, run tests, and submit PRs
66
+ - **[Publishing](docs/PUBLISHING.md)** — how to publish releases to PyPI
67
+ - **[Changelog](CHANGELOG.md)** — version history
68
+ - **[Code of Conduct](CODE_OF_CONDUCT.md)** — community standards
69
+
70
+ ## Author
71
+
72
+ Johnatan Palacios Londoño — [johnatan.palacios@utp.edu.co](mailto:johnatan.palacios@utp.edu.co)
73
+
74
+ ## License
75
+
76
+ [MIT](LICENSE)
@@ -0,0 +1,71 @@
1
+ [project]
2
+ name = "i18n-fastapi"
3
+ version = "0.1.3"
4
+ description = "High-performance i18n for FastAPI with Rust-powered translation engine"
5
+ authors = [{ name = "Johnatan Palacios Londoño", email = "johnatan.palacios@utp.edu.co" }]
6
+ readme = "README.md"
7
+ license = { text = "MIT" }
8
+ requires-python = ">=3.11"
9
+ keywords = ["fastapi", "i18n", "internationalization", "rust", "translations"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Framework :: FastAPI",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Programming Language :: Rust",
20
+ "Topic :: Software Development :: Internationalization",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = [
24
+ "fastapi>=0.100.0",
25
+ "orjson>=3.9.0",
26
+ "pydantic>=2.0.0",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ reload = ["watchfiles>=0.20.0"]
31
+ dev = [
32
+ "pytest>=8.0.0",
33
+ "pytest-asyncio>=0.23.0",
34
+ "httpx>=0.27.0",
35
+ "ruff>=0.4.0",
36
+ "mypy>=1.10.0",
37
+ "maturin>=1.5,<2.0",
38
+ "pre-commit>=3.7.0",
39
+ ]
40
+
41
+ [build-system]
42
+ requires = ["maturin>=1.5,<2.0"]
43
+ build-backend = "maturin"
44
+
45
+ [tool.maturin]
46
+ manifest-path = "rust/Cargo.toml"
47
+ module-name = "i18n_fastapi._native"
48
+ python-source = "python"
49
+ features = ["pyo3/extension-module"]
50
+ include = [{ path = "LICENSE", format = "sdist" }]
51
+
52
+ [tool.ruff]
53
+ line-length = 88
54
+ target-version = "py311"
55
+
56
+ [tool.ruff.lint]
57
+ select = ["E", "F", "I", "B", "UP", "SIM", "RUF", "ANN"]
58
+ ignore = ["ANN401"]
59
+
60
+ [tool.ruff.format]
61
+ quote-style = "double"
62
+ indent-style = "space"
63
+
64
+ [tool.mypy]
65
+ python_version = "3.11"
66
+ disallow_untyped_defs = true
67
+ check_untyped_defs = true
68
+
69
+ [tool.pytest.ini_options]
70
+ asyncio_mode = "auto"
71
+ testpaths = ["tests"]
@@ -0,0 +1,15 @@
1
+ """i18n-fastapi: High-performance i18n for FastAPI with Rust-powered engine."""
2
+
3
+ from i18n_fastapi.config import I18nConfig
4
+ from i18n_fastapi.context import get_locale, t
5
+ from i18n_fastapi.dependencies import Locale, TranslateFunc
6
+ from i18n_fastapi.plugin import I18n
7
+
8
+ __all__ = [
9
+ "I18n",
10
+ "I18nConfig",
11
+ "Locale",
12
+ "TranslateFunc",
13
+ "get_locale",
14
+ "t",
15
+ ]
@@ -0,0 +1,29 @@
1
+ """Engine selector: imports Rust native engine or falls back to pure Python."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import warnings
7
+
8
+ logger = logging.getLogger("i18n_fastapi")
9
+
10
+ RUST_AVAILABLE: bool
11
+
12
+ try:
13
+ from i18n_fastapi._native import TranslationEngine # type: ignore[import-not-found]
14
+
15
+ RUST_AVAILABLE = True
16
+ except ImportError:
17
+ from i18n_fastapi._python_engine import (
18
+ TranslationEngine, # type: ignore[assignment]
19
+ )
20
+
21
+ RUST_AVAILABLE = False
22
+ warnings.warn(
23
+ "i18n-fastapi: Rust engine not available, using Python fallback. "
24
+ "Install from a pre-compiled wheel for best performance.",
25
+ RuntimeWarning,
26
+ stacklevel=2,
27
+ )
28
+
29
+ __all__ = ["RUST_AVAILABLE", "TranslationEngine"]
@@ -0,0 +1,262 @@
1
+ """Pure Python fallback translation engine.
2
+
3
+ Provides the same interface as the Rust-native TranslationEngine.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import re
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import orjson
14
+
15
+ logger = logging.getLogger("i18n_fastapi")
16
+
17
+ ICU_KEYS = frozenset({"zero", "one", "two", "few", "many", "other"})
18
+
19
+ _PLACEHOLDER_RE = re.compile(r"\{(\w+)\}")
20
+
21
+
22
+ def _interpolate(template: str, params: dict[str, str]) -> str:
23
+ if not params or "{" not in template:
24
+ return template
25
+
26
+ def _replace(match: re.Match[str]) -> str:
27
+ key = match.group(1)
28
+ return params.get(key, match.group(0))
29
+
30
+ return _PLACEHOLDER_RE.sub(_replace, template)
31
+
32
+
33
+ def _select_plural(forms: dict[str, str], count: int) -> str:
34
+ if count == 0 and "zero" in forms:
35
+ return forms["zero"]
36
+ if count == 1 and "one" in forms:
37
+ return forms["one"]
38
+ if count == 2 and "two" in forms:
39
+ return forms["two"]
40
+ if 3 <= count <= 10 and "few" in forms:
41
+ return forms["few"]
42
+ if 11 <= count <= 99 and "many" in forms:
43
+ return forms["many"]
44
+ return forms["other"]
45
+
46
+
47
+ def _is_plural_object(obj: dict[str, Any]) -> bool:
48
+ keys = set(obj.keys())
49
+ return "other" in keys and keys <= ICU_KEYS and len(keys) >= 2
50
+
51
+
52
+ def _find_project_root(start_path: Path) -> Path:
53
+ current = start_path if start_path.is_dir() else start_path.parent
54
+
55
+ while True:
56
+ if (
57
+ (current / ".git").exists()
58
+ or (current / "pyproject.toml").exists()
59
+ or (current / "setup.py").exists()
60
+ or (current / "setup.cfg").exists()
61
+ ):
62
+ return current
63
+
64
+ parent = current.parent
65
+ if parent == current:
66
+ return start_path
67
+ current = parent
68
+
69
+
70
+ _SKIP_DIRS = frozenset(
71
+ {
72
+ "__pycache__",
73
+ "venv",
74
+ ".venv",
75
+ "env",
76
+ "node_modules",
77
+ "dist",
78
+ "target",
79
+ }
80
+ )
81
+
82
+
83
+ def _scan_locale_dirs(root: Path, dir_name: str) -> list[Path]:
84
+ results: list[Path] = []
85
+ _scan_recursive(root, dir_name, results)
86
+ return results
87
+
88
+
89
+ def _scan_recursive(directory: Path, target_name: str, results: list[Path]) -> None:
90
+ try:
91
+ entries = list(directory.iterdir())
92
+ except PermissionError:
93
+ return
94
+
95
+ for entry in entries:
96
+ if not entry.is_dir():
97
+ continue
98
+
99
+ name = entry.name
100
+ if name.startswith(".") or name in _SKIP_DIRS:
101
+ continue
102
+
103
+ if name == target_name:
104
+ results.append(entry)
105
+ else:
106
+ _scan_recursive(entry, target_name, results)
107
+
108
+
109
+ class TranslationEngine:
110
+ """Pure Python translation engine (fallback when Rust is unavailable)."""
111
+
112
+ def __init__(
113
+ self,
114
+ default_locale: str,
115
+ raise_on_duplicate: bool = True,
116
+ locale_dir_name: str = "locales",
117
+ ) -> None:
118
+ self.default_locale = default_locale
119
+ self.raise_on_duplicate = raise_on_duplicate
120
+ self.locale_dir_name = locale_dir_name
121
+ self._translations: dict[str, dict[str, Any]] = {}
122
+ self._loaded_dirs: list[Path] = []
123
+ self._missing_keys: list[tuple[str, str]] = []
124
+
125
+ def auto_discover(self, root_path: str) -> None:
126
+ """Auto-discover locale directories starting from root_path."""
127
+ root = Path(root_path)
128
+ project_root = _find_project_root(root)
129
+ dirs = _scan_locale_dirs(project_root, self.locale_dir_name)
130
+ for d in dirs:
131
+ self._load_dir(d)
132
+
133
+ def load_locale_dir(self, path: str) -> None:
134
+ """Load a specific locale directory."""
135
+ self._load_dir(Path(path))
136
+
137
+ def translate(
138
+ self,
139
+ key: str,
140
+ locale: str,
141
+ params: dict[str, str] | None = None,
142
+ ) -> str:
143
+ """Translate a key with optional parameters."""
144
+ if params is None:
145
+ params = {}
146
+
147
+ val = self._lookup(key, locale)
148
+
149
+ if val is None:
150
+ self._missing_keys.append((key, locale))
151
+ return key
152
+
153
+ if isinstance(val, dict) and _is_plural_object(val):
154
+ count = int(params.get("count", "0"))
155
+ template = _select_plural(val, count)
156
+ return _interpolate(template, params)
157
+
158
+ if isinstance(val, str):
159
+ return _interpolate(val, params)
160
+
161
+ return str(val)
162
+
163
+ def available_locales(self) -> list[str]:
164
+ """Return all loaded locale codes."""
165
+ return sorted(self._translations.keys())
166
+
167
+ def reload(self) -> None:
168
+ """Reload all previously loaded directories."""
169
+ dirs = list(self._loaded_dirs)
170
+ self._translations.clear()
171
+ self._missing_keys.clear()
172
+ for d in dirs:
173
+ self._load_dir(d)
174
+
175
+ def get_missing_keys(self) -> list[tuple[str, str]]:
176
+ """Return accumulated missing key lookups."""
177
+ return list(self._missing_keys)
178
+
179
+ def clear_missing_keys(self) -> None:
180
+ """Clear the missing keys log."""
181
+ self._missing_keys.clear()
182
+
183
+ def has_key(self, key: str, locale: str) -> bool:
184
+ """Check if a translation key exists for a locale."""
185
+ return self._lookup(key, locale) is not None
186
+
187
+ def loaded_directories(self) -> list[str]:
188
+ """Return loaded directory paths."""
189
+ return [str(p) for p in self._loaded_dirs]
190
+
191
+ def _load_dir(self, locale_dir: Path) -> None:
192
+ if not locale_dir.exists() or not locale_dir.is_dir():
193
+ return
194
+
195
+ for lang_dir in sorted(locale_dir.iterdir()):
196
+ if not lang_dir.is_dir():
197
+ continue
198
+
199
+ lang = lang_dir.name
200
+ lang_map = self._translations.setdefault(lang, {})
201
+
202
+ for json_file in sorted(lang_dir.rglob("*.json")):
203
+ relative = json_file.relative_to(lang_dir)
204
+ namespace = ".".join(relative.with_suffix("").parts)
205
+
206
+ try:
207
+ raw = json_file.read_bytes()
208
+ data = orjson.loads(raw)
209
+ except Exception:
210
+ logger.exception("Failed to load %s", json_file)
211
+ continue
212
+
213
+ self._flatten(data, namespace, lang_map, lang, str(json_file))
214
+
215
+ if locale_dir not in self._loaded_dirs:
216
+ self._loaded_dirs.append(locale_dir)
217
+
218
+ def _flatten(
219
+ self,
220
+ value: Any,
221
+ prefix: str,
222
+ target: dict[str, Any],
223
+ lang: str,
224
+ source_file: str,
225
+ ) -> None:
226
+ if isinstance(value, dict):
227
+ if _is_plural_object(value):
228
+ if self.raise_on_duplicate and prefix in target:
229
+ msg = (
230
+ f'DuplicateKeyError: Key "{prefix}" for locale '
231
+ f'"{lang}" already loaded, '
232
+ f'conflicting file: "{source_file}"'
233
+ )
234
+ raise ValueError(msg)
235
+ target[prefix] = {k: str(v) for k, v in value.items()}
236
+ else:
237
+ for k, v in value.items():
238
+ full_key = f"{prefix}.{k}" if prefix else k
239
+ self._flatten(v, full_key, target, lang, source_file)
240
+ elif isinstance(value, str):
241
+ if self.raise_on_duplicate and prefix in target:
242
+ msg = (
243
+ f'DuplicateKeyError: Key "{prefix}" for locale '
244
+ f'"{lang}" already loaded, '
245
+ f'conflicting file: "{source_file}"'
246
+ )
247
+ raise ValueError(msg)
248
+ target[prefix] = value
249
+ else:
250
+ target[prefix] = str(value)
251
+
252
+ def _lookup(self, key: str, locale: str) -> Any | None:
253
+ lang_map = self._translations.get(locale)
254
+ if lang_map and key in lang_map:
255
+ return lang_map[key]
256
+
257
+ if locale != self.default_locale:
258
+ default_map = self._translations.get(self.default_locale)
259
+ if default_map and key in default_map:
260
+ return default_map[key]
261
+
262
+ return None
@@ -0,0 +1,64 @@
1
+ """File watcher for hot-reloading translation files during development."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ from collections.abc import Callable
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ if TYPE_CHECKING:
11
+ from i18n_fastapi._engine import TranslationEngine
12
+
13
+ logger = logging.getLogger("i18n_fastapi")
14
+
15
+
16
+ def start_watcher(engine: TranslationEngine) -> Callable[[], None]:
17
+ """Start a background thread that watches loaded locale dirs for changes.
18
+
19
+ Returns a stop function that terminates the watcher thread.
20
+ Requires the `watchfiles` package (install via `pip install i18n-fastapi[reload]`).
21
+ """
22
+ from watchfiles import watch
23
+
24
+ dirs = engine.loaded_directories()
25
+ if not dirs:
26
+ logger.warning("i18n-fastapi watcher: no directories to watch")
27
+ return lambda: None
28
+
29
+ stop_event = threading.Event()
30
+
31
+ def _watch_loop() -> None:
32
+ try:
33
+ for _changes in watch(
34
+ *dirs,
35
+ stop_event=stop_event,
36
+ watch_filter=_json_filter,
37
+ ):
38
+ logger.info("i18n-fastapi: translation files changed, reloading...")
39
+ try:
40
+ engine.reload()
41
+ logger.info("i18n-fastapi: translations reloaded")
42
+ except Exception:
43
+ logger.exception("i18n-fastapi: failed to reload translations")
44
+ except Exception:
45
+ if not stop_event.is_set():
46
+ logger.exception("i18n-fastapi: watcher crashed")
47
+
48
+ thread = threading.Thread(
49
+ target=_watch_loop,
50
+ daemon=True,
51
+ name="i18n-fastapi-watcher",
52
+ )
53
+ thread.start()
54
+
55
+ def stop() -> None:
56
+ stop_event.set()
57
+ thread.join(timeout=2)
58
+
59
+ return stop
60
+
61
+
62
+ def _json_filter(change: Any, path: str) -> bool:
63
+ """Only react to .json file changes."""
64
+ return path.endswith(".json")