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.
- i18n_fastapi-0.1.3/LICENSE +21 -0
- i18n_fastapi-0.1.3/PKG-INFO +112 -0
- i18n_fastapi-0.1.3/README.md +76 -0
- i18n_fastapi-0.1.3/pyproject.toml +71 -0
- i18n_fastapi-0.1.3/python/i18n_fastapi/__init__.py +15 -0
- i18n_fastapi-0.1.3/python/i18n_fastapi/_engine.py +29 -0
- i18n_fastapi-0.1.3/python/i18n_fastapi/_python_engine.py +262 -0
- i18n_fastapi-0.1.3/python/i18n_fastapi/_watcher.py +64 -0
- i18n_fastapi-0.1.3/python/i18n_fastapi/config.py +113 -0
- i18n_fastapi-0.1.3/python/i18n_fastapi/context.py +53 -0
- i18n_fastapi-0.1.3/python/i18n_fastapi/dependencies.py +24 -0
- i18n_fastapi-0.1.3/python/i18n_fastapi/middleware.py +121 -0
- i18n_fastapi-0.1.3/python/i18n_fastapi/plugin.py +131 -0
- i18n_fastapi-0.1.3/python/i18n_fastapi/py.typed +0 -0
- i18n_fastapi-0.1.3/python/i18n_fastapi/router.py +28 -0
- i18n_fastapi-0.1.3/rust/Cargo.lock +243 -0
- i18n_fastapi-0.1.3/rust/Cargo.toml +13 -0
- i18n_fastapi-0.1.3/rust/src/engine.rs +191 -0
- i18n_fastapi-0.1.3/rust/src/interpolation.rs +91 -0
- i18n_fastapi-0.1.3/rust/src/lib.rs +12 -0
- i18n_fastapi-0.1.3/rust/src/loader.rs +269 -0
- i18n_fastapi-0.1.3/rust/src/pluralization.rs +117 -0
|
@@ -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
|
+
[](https://pypi.org/project/i18n-fastapi/)
|
|
39
|
+
[](https://pypi.org/project/i18n-fastapi/)
|
|
40
|
+
[](https://github.com/johnatanpalacios/i18n-fastapi/blob/main/LICENSE)
|
|
41
|
+
[](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
|
+
[](https://pypi.org/project/i18n-fastapi/)
|
|
4
|
+
[](https://pypi.org/project/i18n-fastapi/)
|
|
5
|
+
[](https://github.com/johnatanpalacios/i18n-fastapi/blob/main/LICENSE)
|
|
6
|
+
[](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")
|