lingva-sdk 0.1.0__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.
- lingva_sdk-0.1.0/.gitignore +57 -0
- lingva_sdk-0.1.0/PKG-INFO +139 -0
- lingva_sdk-0.1.0/README.md +125 -0
- lingva_sdk-0.1.0/pyproject.toml +23 -0
- lingva_sdk-0.1.0/src/lingva/__init__.py +14 -0
- lingva_sdk-0.1.0/src/lingva/_client.py +153 -0
- lingva_sdk-0.1.0/src/lingva/_delivery.py +54 -0
- lingva_sdk-0.1.0/tests/test_client.py +78 -0
- lingva_sdk-0.1.0/tests/test_delivery.py +35 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
2
|
+
|
|
3
|
+
# Dependencies
|
|
4
|
+
node_modules
|
|
5
|
+
.pnp
|
|
6
|
+
.pnp.js
|
|
7
|
+
|
|
8
|
+
# Local env files
|
|
9
|
+
.env*
|
|
10
|
+
!.env*.example
|
|
11
|
+
.npmrc.private
|
|
12
|
+
|
|
13
|
+
# Testing
|
|
14
|
+
coverage
|
|
15
|
+
.lingva/local-remote/
|
|
16
|
+
|
|
17
|
+
# Turbo
|
|
18
|
+
.turbo
|
|
19
|
+
|
|
20
|
+
# Vercel
|
|
21
|
+
.vercel
|
|
22
|
+
|
|
23
|
+
# Build Outputs
|
|
24
|
+
.angular/
|
|
25
|
+
.next/
|
|
26
|
+
.nuxt/
|
|
27
|
+
.output/
|
|
28
|
+
.svelte-kit/
|
|
29
|
+
.build/
|
|
30
|
+
.dart_tool/
|
|
31
|
+
.gradle/
|
|
32
|
+
out/
|
|
33
|
+
build
|
|
34
|
+
dist
|
|
35
|
+
|
|
36
|
+
# Python
|
|
37
|
+
__pycache__/
|
|
38
|
+
.pytest_cache/
|
|
39
|
+
*.pyc
|
|
40
|
+
.venv/
|
|
41
|
+
venv/
|
|
42
|
+
|
|
43
|
+
# Native SDK package-manager state
|
|
44
|
+
.flutter-plugins
|
|
45
|
+
.flutter-plugins-dependencies
|
|
46
|
+
sdks/flutter/pubspec.lock
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Debug
|
|
50
|
+
npm-debug.log*
|
|
51
|
+
|
|
52
|
+
# Misc
|
|
53
|
+
.DS_Store
|
|
54
|
+
*.pem
|
|
55
|
+
.env*.local
|
|
56
|
+
|
|
57
|
+
.pnpm-store/
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: lingva-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the Lingva localization platform
|
|
5
|
+
License: SEE LICENSE IN ../../LICENSE.md
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
9
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
10
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
11
|
+
Provides-Extra: http
|
|
12
|
+
Requires-Dist: httpx>=0.27; extra == 'http'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# lingva — Python SDK
|
|
16
|
+
|
|
17
|
+
Python client for the [Lingva](https://lingva.dev) localization platform.
|
|
18
|
+
|
|
19
|
+
**Requires Python 3.11+.**
|
|
20
|
+
|
|
21
|
+
> This is a preview package. It follows the same conformance contract as the Swift, Kotlin, and Flutter SDKs but is not yet published to PyPI.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# From the monorepo root (editable install)
|
|
29
|
+
pip install -e sdks/python
|
|
30
|
+
|
|
31
|
+
# With async HTTP support (httpx)
|
|
32
|
+
pip install -e "sdks/python[http]"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Once published:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install lingva-sdk
|
|
39
|
+
pip install "lingva-sdk[http]" # adds httpx for async delivery
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Quickstart
|
|
45
|
+
|
|
46
|
+
### Load from a local bundle file
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from lingva import LingvaClient
|
|
50
|
+
|
|
51
|
+
client = LingvaClient.from_file(".lingva/translations/en.bundle.json")
|
|
52
|
+
|
|
53
|
+
print(client.t("actions.continue")) # Continue
|
|
54
|
+
print(client.t("welcome.title", name="Alice")) # Welcome, Alice.
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Load from CDN (async)
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import asyncio
|
|
61
|
+
from lingva import LingvaClient, LingvaDeliveryTarget
|
|
62
|
+
|
|
63
|
+
target = LingvaDeliveryTarget(
|
|
64
|
+
base_url="https://api.lingva.dev",
|
|
65
|
+
project_id="my-project",
|
|
66
|
+
environment="production",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
async def main():
|
|
70
|
+
client = await LingvaClient.from_url(target, locale="fr", fallback_locale="en")
|
|
71
|
+
print(client.t("welcome.title", name="Alice"))
|
|
72
|
+
|
|
73
|
+
asyncio.run(main())
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Load from CDN (synchronous)
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from lingva import LingvaClient, LingvaDeliveryTarget
|
|
80
|
+
|
|
81
|
+
target = LingvaDeliveryTarget(
|
|
82
|
+
base_url="https://api.lingva.dev",
|
|
83
|
+
project_id="my-project",
|
|
84
|
+
environment="production",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
client = LingvaClient.from_url_sync(target, locale="fr", fallback_locale="en")
|
|
88
|
+
print(client.t("welcome.title", name="Alice"))
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## API
|
|
94
|
+
|
|
95
|
+
### `LingvaClient`
|
|
96
|
+
|
|
97
|
+
| Method / Property | Description |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `LingvaClient.from_bundle(bundle)` | Create from a parsed dict |
|
|
100
|
+
| `LingvaClient.from_file(path, *, fallback_path=None)` | Load from filesystem |
|
|
101
|
+
| `LingvaClient.from_url_sync(target, locale, ...)` | Synchronous HTTP fetch |
|
|
102
|
+
| `await LingvaClient.from_url(target, locale, ...)` | Async HTTP fetch (requires `lingva[http]`) |
|
|
103
|
+
| `.t(key, **variables)` | Translate a key with optional `{varname}` substitution |
|
|
104
|
+
| `.has(key)` | Check if a key exists |
|
|
105
|
+
| `.locale` | Active locale string |
|
|
106
|
+
| `.fallback_locale` | Fallback locale string or `None` |
|
|
107
|
+
| `.bundle()` | Raw bundle dict (for last-valid caching) |
|
|
108
|
+
|
|
109
|
+
### `LingvaDeliveryTarget`
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
LingvaDeliveryTarget(
|
|
113
|
+
base_url="https://api.lingva.dev",
|
|
114
|
+
project_id="my-project",
|
|
115
|
+
environment="production", # default
|
|
116
|
+
uri_template="...", # optional custom path template
|
|
117
|
+
)
|
|
118
|
+
target.resolve("fr")
|
|
119
|
+
# -> "https://api.lingva.dev/bundles/my-project/production/latest/fr.bundle.json"
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Bundle schema
|
|
125
|
+
|
|
126
|
+
The SDK reads schemaVersion 1 bundles. Variables use `{varname}` syntax. Unknown variables pass through unchanged.
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"schemaVersion": 1,
|
|
131
|
+
"locale": "en",
|
|
132
|
+
"fallbackLocale": "en",
|
|
133
|
+
"fallbackKeys": [],
|
|
134
|
+
"messages": {
|
|
135
|
+
"actions.continue": "Continue",
|
|
136
|
+
"welcome.title": "Welcome, {name}."
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
```
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# lingva — Python SDK
|
|
2
|
+
|
|
3
|
+
Python client for the [Lingva](https://lingva.dev) localization platform.
|
|
4
|
+
|
|
5
|
+
**Requires Python 3.11+.**
|
|
6
|
+
|
|
7
|
+
> This is a preview package. It follows the same conformance contract as the Swift, Kotlin, and Flutter SDKs but is not yet published to PyPI.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# From the monorepo root (editable install)
|
|
15
|
+
pip install -e sdks/python
|
|
16
|
+
|
|
17
|
+
# With async HTTP support (httpx)
|
|
18
|
+
pip install -e "sdks/python[http]"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Once published:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install lingva-sdk
|
|
25
|
+
pip install "lingva-sdk[http]" # adds httpx for async delivery
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Quickstart
|
|
31
|
+
|
|
32
|
+
### Load from a local bundle file
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from lingva import LingvaClient
|
|
36
|
+
|
|
37
|
+
client = LingvaClient.from_file(".lingva/translations/en.bundle.json")
|
|
38
|
+
|
|
39
|
+
print(client.t("actions.continue")) # Continue
|
|
40
|
+
print(client.t("welcome.title", name="Alice")) # Welcome, Alice.
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Load from CDN (async)
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import asyncio
|
|
47
|
+
from lingva import LingvaClient, LingvaDeliveryTarget
|
|
48
|
+
|
|
49
|
+
target = LingvaDeliveryTarget(
|
|
50
|
+
base_url="https://api.lingva.dev",
|
|
51
|
+
project_id="my-project",
|
|
52
|
+
environment="production",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
async def main():
|
|
56
|
+
client = await LingvaClient.from_url(target, locale="fr", fallback_locale="en")
|
|
57
|
+
print(client.t("welcome.title", name="Alice"))
|
|
58
|
+
|
|
59
|
+
asyncio.run(main())
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Load from CDN (synchronous)
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from lingva import LingvaClient, LingvaDeliveryTarget
|
|
66
|
+
|
|
67
|
+
target = LingvaDeliveryTarget(
|
|
68
|
+
base_url="https://api.lingva.dev",
|
|
69
|
+
project_id="my-project",
|
|
70
|
+
environment="production",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
client = LingvaClient.from_url_sync(target, locale="fr", fallback_locale="en")
|
|
74
|
+
print(client.t("welcome.title", name="Alice"))
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## API
|
|
80
|
+
|
|
81
|
+
### `LingvaClient`
|
|
82
|
+
|
|
83
|
+
| Method / Property | Description |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `LingvaClient.from_bundle(bundle)` | Create from a parsed dict |
|
|
86
|
+
| `LingvaClient.from_file(path, *, fallback_path=None)` | Load from filesystem |
|
|
87
|
+
| `LingvaClient.from_url_sync(target, locale, ...)` | Synchronous HTTP fetch |
|
|
88
|
+
| `await LingvaClient.from_url(target, locale, ...)` | Async HTTP fetch (requires `lingva[http]`) |
|
|
89
|
+
| `.t(key, **variables)` | Translate a key with optional `{varname}` substitution |
|
|
90
|
+
| `.has(key)` | Check if a key exists |
|
|
91
|
+
| `.locale` | Active locale string |
|
|
92
|
+
| `.fallback_locale` | Fallback locale string or `None` |
|
|
93
|
+
| `.bundle()` | Raw bundle dict (for last-valid caching) |
|
|
94
|
+
|
|
95
|
+
### `LingvaDeliveryTarget`
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
LingvaDeliveryTarget(
|
|
99
|
+
base_url="https://api.lingva.dev",
|
|
100
|
+
project_id="my-project",
|
|
101
|
+
environment="production", # default
|
|
102
|
+
uri_template="...", # optional custom path template
|
|
103
|
+
)
|
|
104
|
+
target.resolve("fr")
|
|
105
|
+
# -> "https://api.lingva.dev/bundles/my-project/production/latest/fr.bundle.json"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Bundle schema
|
|
111
|
+
|
|
112
|
+
The SDK reads schemaVersion 1 bundles. Variables use `{varname}` syntax. Unknown variables pass through unchanged.
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"schemaVersion": 1,
|
|
117
|
+
"locale": "en",
|
|
118
|
+
"fallbackLocale": "en",
|
|
119
|
+
"fallbackKeys": [],
|
|
120
|
+
"messages": {
|
|
121
|
+
"actions.continue": "Continue",
|
|
122
|
+
"welcome.title": "Welcome, {name}."
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "lingva-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for the Lingva localization platform"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "SEE LICENSE IN ../../LICENSE.md" }
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
dependencies = []
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
http = ["httpx>=0.27"]
|
|
16
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.24", "httpx>=0.27"]
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.wheel]
|
|
19
|
+
packages = ["src/lingva"]
|
|
20
|
+
|
|
21
|
+
[tool.pytest.ini_options]
|
|
22
|
+
asyncio_mode = "auto"
|
|
23
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from lingva._client import LingvaClient, LingvaSchemaError
|
|
2
|
+
from lingva._delivery import (
|
|
3
|
+
DEFAULT_REMOTE_BUNDLE_PATH_TEMPLATE,
|
|
4
|
+
LingvaDeliveryTarget,
|
|
5
|
+
load_bundle_from_file,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"DEFAULT_REMOTE_BUNDLE_PATH_TEMPLATE",
|
|
10
|
+
"LingvaClient",
|
|
11
|
+
"LingvaDeliveryTarget",
|
|
12
|
+
"LingvaSchemaError",
|
|
13
|
+
"load_bundle_from_file",
|
|
14
|
+
]
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from lingva._delivery import (
|
|
8
|
+
LingvaDeliveryTarget,
|
|
9
|
+
load_bundle_from_file,
|
|
10
|
+
load_bundle_from_url_async,
|
|
11
|
+
load_bundle_from_url_sync,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
_SCHEMA_VERSION = 1
|
|
15
|
+
_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _interpolate(template: str, variables: dict[str, str]) -> str:
|
|
19
|
+
"""Replace {varname} tokens; unknown tokens pass through unchanged."""
|
|
20
|
+
def replace(m: re.Match[str]) -> str:
|
|
21
|
+
return variables.get(m.group(1), m.group(0))
|
|
22
|
+
return _PLACEHOLDER_RE.sub(replace, template)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LingvaSchemaError(ValueError):
|
|
26
|
+
"""Raised when a bundle fails schema validation."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LingvaClient:
|
|
30
|
+
"""
|
|
31
|
+
Reads a Lingva locale bundle and provides key lookup with fallback and
|
|
32
|
+
variable interpolation.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
bundle: dict[str, Any],
|
|
38
|
+
fallback_bundle: dict[str, Any] | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
if bundle.get("schemaVersion") != _SCHEMA_VERSION:
|
|
41
|
+
raise LingvaSchemaError(
|
|
42
|
+
f"Unsupported bundle schemaVersion: {bundle.get('schemaVersion')!r}"
|
|
43
|
+
)
|
|
44
|
+
self._bundle = bundle
|
|
45
|
+
self._messages: dict[str, str] = bundle.get("messages") or {}
|
|
46
|
+
self._fallback_messages: dict[str, str] = (
|
|
47
|
+
fallback_bundle.get("messages") or {} if fallback_bundle else {}
|
|
48
|
+
)
|
|
49
|
+
self._fallback_keys: set[str] = set(bundle.get("fallbackKeys") or [])
|
|
50
|
+
self._locale: str = bundle.get("locale") or bundle.get("defaultLocale") or "en"
|
|
51
|
+
self._fallback_locale: str | None = bundle.get("fallbackLocale")
|
|
52
|
+
self._last_valid: dict[str, Any] = bundle
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------------
|
|
55
|
+
# Factory methods
|
|
56
|
+
# ------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_bundle(cls, bundle: dict[str, Any]) -> "LingvaClient":
|
|
60
|
+
"""Create a client from an already-parsed bundle dict."""
|
|
61
|
+
return cls(bundle)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_file(cls, path: str | Path, *, fallback_path: str | Path | None = None) -> "LingvaClient":
|
|
65
|
+
"""Load a bundle from the local filesystem."""
|
|
66
|
+
bundle = load_bundle_from_file(path)
|
|
67
|
+
fallback = load_bundle_from_file(fallback_path) if fallback_path else None
|
|
68
|
+
return cls(bundle, fallback)
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_url_sync(
|
|
72
|
+
cls,
|
|
73
|
+
target: LingvaDeliveryTarget,
|
|
74
|
+
locale: str,
|
|
75
|
+
*,
|
|
76
|
+
fallback_locale: str | None = None,
|
|
77
|
+
last_valid: dict[str, Any] | None = None,
|
|
78
|
+
) -> "LingvaClient":
|
|
79
|
+
"""Fetch a bundle synchronously; return a client over `last_valid` on error."""
|
|
80
|
+
try:
|
|
81
|
+
bundle = load_bundle_from_url_sync(target.resolve(locale))
|
|
82
|
+
except Exception:
|
|
83
|
+
if last_valid is not None:
|
|
84
|
+
return cls(last_valid)
|
|
85
|
+
raise
|
|
86
|
+
fallback = None
|
|
87
|
+
if fallback_locale and fallback_locale != locale:
|
|
88
|
+
try:
|
|
89
|
+
fallback = load_bundle_from_url_sync(target.resolve(fallback_locale))
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
return cls(bundle, fallback)
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
async def from_url(
|
|
96
|
+
cls,
|
|
97
|
+
target: LingvaDeliveryTarget,
|
|
98
|
+
locale: str,
|
|
99
|
+
*,
|
|
100
|
+
fallback_locale: str | None = None,
|
|
101
|
+
last_valid: dict[str, Any] | None = None,
|
|
102
|
+
) -> "LingvaClient":
|
|
103
|
+
"""Fetch a bundle asynchronously; return a client over `last_valid` on error."""
|
|
104
|
+
try:
|
|
105
|
+
bundle = await load_bundle_from_url_async(target.resolve(locale))
|
|
106
|
+
except Exception:
|
|
107
|
+
if last_valid is not None:
|
|
108
|
+
return cls(last_valid)
|
|
109
|
+
raise
|
|
110
|
+
fallback = None
|
|
111
|
+
if fallback_locale and fallback_locale != locale:
|
|
112
|
+
try:
|
|
113
|
+
fallback = await load_bundle_from_url_async(target.resolve(fallback_locale))
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
return cls(bundle, fallback)
|
|
117
|
+
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
# Public API
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def locale(self) -> str:
|
|
124
|
+
return self._locale
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def fallback_locale(self) -> str | None:
|
|
128
|
+
return self._fallback_locale
|
|
129
|
+
|
|
130
|
+
def has(self, key: str) -> bool:
|
|
131
|
+
"""Return True if the key exists in the primary or fallback messages."""
|
|
132
|
+
return key in self._messages or key in self._fallback_messages
|
|
133
|
+
|
|
134
|
+
def t(self, key: str, **variables: str) -> str:
|
|
135
|
+
"""
|
|
136
|
+
Translate `key` with optional variable substitution.
|
|
137
|
+
|
|
138
|
+
Falls back to `fallback_bundle` when the key is missing from the
|
|
139
|
+
primary bundle or is listed in `fallbackKeys`.
|
|
140
|
+
"""
|
|
141
|
+
if key in self._fallback_keys or key not in self._messages:
|
|
142
|
+
template = self._fallback_messages.get(key) or self._messages.get(key)
|
|
143
|
+
else:
|
|
144
|
+
template = self._messages.get(key)
|
|
145
|
+
|
|
146
|
+
if template is None:
|
|
147
|
+
return key
|
|
148
|
+
|
|
149
|
+
return _interpolate(template, variables) if variables else template
|
|
150
|
+
|
|
151
|
+
def bundle(self) -> dict[str, Any]:
|
|
152
|
+
"""Return the raw bundle dict (useful for last-valid caching)."""
|
|
153
|
+
return self._bundle
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.request
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
DEFAULT_REMOTE_BUNDLE_PATH_TEMPLATE = "/bundles/{projectId}/{environment}/latest/{locale}.bundle.json"
|
|
10
|
+
|
|
11
|
+
_PLACEHOLDERS = {"projectId", "environment", "locale"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class LingvaDeliveryTarget:
|
|
16
|
+
"""Resolves a Lingva CDN delivery URL for a given locale."""
|
|
17
|
+
base_url: str
|
|
18
|
+
uri_template: str = DEFAULT_REMOTE_BUNDLE_PATH_TEMPLATE
|
|
19
|
+
project_id: str = ""
|
|
20
|
+
environment: str = "production"
|
|
21
|
+
|
|
22
|
+
def resolve(self, locale: str) -> str:
|
|
23
|
+
path = (
|
|
24
|
+
self.uri_template
|
|
25
|
+
.replace("{projectId}", self.project_id)
|
|
26
|
+
.replace("{environment}", self.environment)
|
|
27
|
+
.replace("{locale}", locale)
|
|
28
|
+
)
|
|
29
|
+
return self.base_url.rstrip("/") + path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_bundle_from_file(path: str | Path) -> dict[str, Any]:
|
|
33
|
+
"""Read and parse a locale bundle JSON file."""
|
|
34
|
+
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_bundle_from_url_sync(url: str) -> dict[str, Any]:
|
|
38
|
+
"""Fetch a locale bundle over HTTP (synchronous, no extra deps)."""
|
|
39
|
+
with urllib.request.urlopen(url) as resp: # noqa: S310
|
|
40
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def load_bundle_from_url_async(url: str) -> dict[str, Any]:
|
|
44
|
+
"""Fetch a locale bundle over HTTP using httpx (async)."""
|
|
45
|
+
try:
|
|
46
|
+
import httpx
|
|
47
|
+
except ImportError as exc:
|
|
48
|
+
raise ImportError(
|
|
49
|
+
"Install 'lingva[http]' (httpx) for async HTTP delivery."
|
|
50
|
+
) from exc
|
|
51
|
+
async with httpx.AsyncClient() as client:
|
|
52
|
+
response = await client.get(url)
|
|
53
|
+
response.raise_for_status()
|
|
54
|
+
return response.json()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from lingva import LingvaClient, LingvaSchemaError
|
|
7
|
+
|
|
8
|
+
FIXTURE_PATH = Path(__file__).parent.parent.parent / "fixtures" / "en.bundle.json"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture()
|
|
12
|
+
def fixture_bundle() -> dict:
|
|
13
|
+
return json.loads(FIXTURE_PATH.read_text())
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_loads_fixture_bundle(fixture_bundle):
|
|
17
|
+
client = LingvaClient.from_bundle(fixture_bundle)
|
|
18
|
+
assert client.locale == "en"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_translates_simple_key(fixture_bundle):
|
|
22
|
+
client = LingvaClient.from_bundle(fixture_bundle)
|
|
23
|
+
assert client.t("actions.continue") == "Continue"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_interpolates_variable(fixture_bundle):
|
|
27
|
+
client = LingvaClient.from_bundle(fixture_bundle)
|
|
28
|
+
assert client.t("welcome.title", name="Alice") == "Welcome, Alice."
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_has_returns_true_for_known_key(fixture_bundle):
|
|
32
|
+
client = LingvaClient.from_bundle(fixture_bundle)
|
|
33
|
+
assert client.has("actions.continue") is True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_has_returns_false_for_unknown_key(fixture_bundle):
|
|
37
|
+
client = LingvaClient.from_bundle(fixture_bundle)
|
|
38
|
+
assert client.has("nonexistent.key") is False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_returns_key_for_missing_translation(fixture_bundle):
|
|
42
|
+
client = LingvaClient.from_bundle(fixture_bundle)
|
|
43
|
+
assert client.t("nonexistent.key") == "nonexistent.key"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_rejects_wrong_schema_version():
|
|
47
|
+
with pytest.raises(LingvaSchemaError):
|
|
48
|
+
LingvaClient.from_bundle({"schemaVersion": 99, "messages": {}})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_fallback_bundle_used_for_missing_key():
|
|
52
|
+
primary = {"schemaVersion": 1, "locale": "fr", "fallbackLocale": "en", "messages": {}, "fallbackKeys": []}
|
|
53
|
+
fallback = {"schemaVersion": 1, "locale": "en", "messages": {"greeting": "Hello"}, "fallbackKeys": []}
|
|
54
|
+
client = LingvaClient(primary, fallback)
|
|
55
|
+
assert client.t("greeting") == "Hello"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_fallback_keys_list_forces_fallback():
|
|
59
|
+
primary = {
|
|
60
|
+
"schemaVersion": 1, "locale": "fr", "fallbackLocale": "en",
|
|
61
|
+
"messages": {"greeting": "Bonjour"},
|
|
62
|
+
"fallbackKeys": ["greeting"],
|
|
63
|
+
}
|
|
64
|
+
fallback = {"schemaVersion": 1, "locale": "en", "messages": {"greeting": "Hello"}, "fallbackKeys": []}
|
|
65
|
+
client = LingvaClient(primary, fallback)
|
|
66
|
+
assert client.t("greeting") == "Hello"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_from_file_loads_fixture():
|
|
70
|
+
client = LingvaClient.from_file(FIXTURE_PATH)
|
|
71
|
+
assert client.t("actions.continue") == "Continue"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_unknown_variable_passes_through(fixture_bundle):
|
|
75
|
+
client = LingvaClient.from_bundle(fixture_bundle)
|
|
76
|
+
# {name} is known but {extra} is not — {extra} should remain
|
|
77
|
+
result = client.t("welcome.title", name="Bob")
|
|
78
|
+
assert result == "Welcome, Bob."
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from lingva._delivery import DEFAULT_REMOTE_BUNDLE_PATH_TEMPLATE, LingvaDeliveryTarget
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_default_template_constant():
|
|
5
|
+
assert DEFAULT_REMOTE_BUNDLE_PATH_TEMPLATE == "/bundles/{projectId}/{environment}/latest/{locale}.bundle.json"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_resolve_builds_correct_url():
|
|
9
|
+
target = LingvaDeliveryTarget(
|
|
10
|
+
base_url="https://api.lingva.dev",
|
|
11
|
+
project_id="my-project",
|
|
12
|
+
environment="production",
|
|
13
|
+
)
|
|
14
|
+
url = target.resolve("fr")
|
|
15
|
+
assert url == "https://api.lingva.dev/bundles/my-project/production/latest/fr.bundle.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_resolve_strips_trailing_slash():
|
|
19
|
+
target = LingvaDeliveryTarget(
|
|
20
|
+
base_url="https://api.lingva.dev/",
|
|
21
|
+
project_id="p1",
|
|
22
|
+
environment="dev",
|
|
23
|
+
)
|
|
24
|
+
url = target.resolve("en")
|
|
25
|
+
assert url == "https://api.lingva.dev/bundles/p1/dev/latest/en.bundle.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_custom_uri_template():
|
|
29
|
+
target = LingvaDeliveryTarget(
|
|
30
|
+
base_url="https://cdn.example.com",
|
|
31
|
+
uri_template="/v2/{locale}/{projectId}.json",
|
|
32
|
+
project_id="abc",
|
|
33
|
+
)
|
|
34
|
+
url = target.resolve("de")
|
|
35
|
+
assert url == "https://cdn.example.com/v2/de/abc.json"
|