lingva-sdk 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.
- lingva/__init__.py +14 -0
- lingva/_client.py +153 -0
- lingva/_delivery.py +54 -0
- lingva_sdk-0.1.0.dist-info/METADATA +139 -0
- lingva_sdk-0.1.0.dist-info/RECORD +6 -0
- lingva_sdk-0.1.0.dist-info/WHEEL +4 -0
lingva/__init__.py
ADDED
|
@@ -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
|
+
]
|
lingva/_client.py
ADDED
|
@@ -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
|
lingva/_delivery.py
ADDED
|
@@ -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,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,6 @@
|
|
|
1
|
+
lingva/__init__.py,sha256=IofiScxexQBorrgU3gdHVzPH7ab4jhXRuaiBAAOo9XA,346
|
|
2
|
+
lingva/_client.py,sha256=baxKPjEu3_5_TuyzuReVmSFgrwCve8A36j0Mn9xKC3Q,5292
|
|
3
|
+
lingva/_delivery.py,sha256=IR2Q4xs0H7qMKsQ9VMhUy6HdX9yN-HHnD0Tf3kzsisc,1765
|
|
4
|
+
lingva_sdk-0.1.0.dist-info/METADATA,sha256=wafaa5HCPgbwRaMshgliz7ER404RbKO35bMKWDJGM3s,3460
|
|
5
|
+
lingva_sdk-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
+
lingva_sdk-0.1.0.dist-info/RECORD,,
|