polylingo 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.
- polylingo-0.1.0/.gitignore +8 -0
- polylingo-0.1.0/PKG-INFO +104 -0
- polylingo-0.1.0/README.md +74 -0
- polylingo-0.1.0/polylingo/__init__.py +14 -0
- polylingo-0.1.0/polylingo/_async_client.py +126 -0
- polylingo-0.1.0/polylingo/_client.py +126 -0
- polylingo-0.1.0/polylingo/_errors.py +36 -0
- polylingo-0.1.0/polylingo/_http_utils.py +35 -0
- polylingo-0.1.0/polylingo/_types.py +117 -0
- polylingo-0.1.0/polylingo/py.typed +0 -0
- polylingo-0.1.0/polylingo/resources/_async_batch.py +18 -0
- polylingo-0.1.0/polylingo/resources/_async_health.py +10 -0
- polylingo-0.1.0/polylingo/resources/_async_jobs.py +65 -0
- polylingo-0.1.0/polylingo/resources/_async_languages.py +10 -0
- polylingo-0.1.0/polylingo/resources/_async_translate.py +20 -0
- polylingo-0.1.0/polylingo/resources/_async_usage.py +10 -0
- polylingo-0.1.0/polylingo/resources/_batch.py +18 -0
- polylingo-0.1.0/polylingo/resources/_health.py +10 -0
- polylingo-0.1.0/polylingo/resources/_jobs.py +91 -0
- polylingo-0.1.0/polylingo/resources/_languages.py +10 -0
- polylingo-0.1.0/polylingo/resources/_translate.py +20 -0
- polylingo-0.1.0/polylingo/resources/_usage.py +10 -0
- polylingo-0.1.0/pyproject.toml +56 -0
- polylingo-0.1.0/tests/test_errors.py +42 -0
- polylingo-0.1.0/tests/test_jobs.py +45 -0
- polylingo-0.1.0/tests/test_translate.py +39 -0
polylingo-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: polylingo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the PolyLingo translation API
|
|
5
|
+
Project-URL: Homepage, https://usepolylingo.com
|
|
6
|
+
Project-URL: Documentation, https://usepolylingo.com/en/docs/sdk/python
|
|
7
|
+
Project-URL: Repository, https://github.com/UsePolyLingo/polylingo-python
|
|
8
|
+
Project-URL: Issues, https://github.com/UsePolyLingo/polylingo-python/issues
|
|
9
|
+
Author: PolyLingo
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
Keywords: api,i18n,markdown,polylingo,translation
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Requires-Dist: httpx>=0.24
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: httpx>=0.24; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# polylingo (Python)
|
|
32
|
+
|
|
33
|
+
Official Python SDK for the [PolyLingo](https://usepolylingo.com) translation API.
|
|
34
|
+
|
|
35
|
+
**Requirements:** Python 3.9+
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install polylingo
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Sync usage
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import os
|
|
47
|
+
import polylingo
|
|
48
|
+
|
|
49
|
+
client = polylingo.PolyLingo(
|
|
50
|
+
api_key=os.environ["POLYLINGO_API_KEY"],
|
|
51
|
+
# base_url="https://api.polylingo.io/v1",
|
|
52
|
+
# timeout=120.0,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
result = client.translate(content="# Hello", targets=["es", "fr"], format="markdown")
|
|
56
|
+
print(result["translations"]["es"])
|
|
57
|
+
client.close()
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Context manager:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
with polylingo.PolyLingo(api_key="...") as client:
|
|
64
|
+
print(client.languages())
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Async usage
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
import polylingo
|
|
71
|
+
|
|
72
|
+
async with polylingo.AsyncPolyLingo(api_key="...") as client:
|
|
73
|
+
r = await client.translate(content="Hi", targets=["de"])
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## API
|
|
77
|
+
|
|
78
|
+
- `health()` / `await health()`
|
|
79
|
+
- `languages()`
|
|
80
|
+
- `translate(content=..., targets=..., format=..., source=..., model=...)`
|
|
81
|
+
- `batch(items=..., targets=..., source=..., model=...)`
|
|
82
|
+
- `usage()`
|
|
83
|
+
- `jobs.create(...)` — returns 202 payload
|
|
84
|
+
- `jobs.get(job_id)`
|
|
85
|
+
- `jobs.translate(..., poll_interval=5.0, timeout=1200.0, on_progress=...)`
|
|
86
|
+
|
|
87
|
+
## Exceptions
|
|
88
|
+
|
|
89
|
+
- `PolyLingoError` — base (`status`, `error`, `args[0]` message)
|
|
90
|
+
- `AuthError` — 401
|
|
91
|
+
- `RateLimitError` — 429 (`retry_after`)
|
|
92
|
+
- `JobFailedError` — failed job (`job_id`)
|
|
93
|
+
|
|
94
|
+
## Docs
|
|
95
|
+
|
|
96
|
+
[Python SDK reference](https://usepolylingo.com/en/docs/sdk/python) (when deployed).
|
|
97
|
+
|
|
98
|
+
## Repository
|
|
99
|
+
|
|
100
|
+
[UsePolyLingo/polylingo-python](https://github.com/UsePolyLingo/polylingo-python)
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# polylingo (Python)
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the [PolyLingo](https://usepolylingo.com) translation API.
|
|
4
|
+
|
|
5
|
+
**Requirements:** Python 3.9+
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install polylingo
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Sync usage
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import os
|
|
17
|
+
import polylingo
|
|
18
|
+
|
|
19
|
+
client = polylingo.PolyLingo(
|
|
20
|
+
api_key=os.environ["POLYLINGO_API_KEY"],
|
|
21
|
+
# base_url="https://api.polylingo.io/v1",
|
|
22
|
+
# timeout=120.0,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
result = client.translate(content="# Hello", targets=["es", "fr"], format="markdown")
|
|
26
|
+
print(result["translations"]["es"])
|
|
27
|
+
client.close()
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Context manager:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
with polylingo.PolyLingo(api_key="...") as client:
|
|
34
|
+
print(client.languages())
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Async usage
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import polylingo
|
|
41
|
+
|
|
42
|
+
async with polylingo.AsyncPolyLingo(api_key="...") as client:
|
|
43
|
+
r = await client.translate(content="Hi", targets=["de"])
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## API
|
|
47
|
+
|
|
48
|
+
- `health()` / `await health()`
|
|
49
|
+
- `languages()`
|
|
50
|
+
- `translate(content=..., targets=..., format=..., source=..., model=...)`
|
|
51
|
+
- `batch(items=..., targets=..., source=..., model=...)`
|
|
52
|
+
- `usage()`
|
|
53
|
+
- `jobs.create(...)` — returns 202 payload
|
|
54
|
+
- `jobs.get(job_id)`
|
|
55
|
+
- `jobs.translate(..., poll_interval=5.0, timeout=1200.0, on_progress=...)`
|
|
56
|
+
|
|
57
|
+
## Exceptions
|
|
58
|
+
|
|
59
|
+
- `PolyLingoError` — base (`status`, `error`, `args[0]` message)
|
|
60
|
+
- `AuthError` — 401
|
|
61
|
+
- `RateLimitError` — 429 (`retry_after`)
|
|
62
|
+
- `JobFailedError` — failed job (`job_id`)
|
|
63
|
+
|
|
64
|
+
## Docs
|
|
65
|
+
|
|
66
|
+
[Python SDK reference](https://usepolylingo.com/en/docs/sdk/python) (when deployed).
|
|
67
|
+
|
|
68
|
+
## Repository
|
|
69
|
+
|
|
70
|
+
[UsePolyLingo/polylingo-python](https://github.com/UsePolyLingo/polylingo-python)
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from polylingo._async_client import AsyncPolyLingo
|
|
2
|
+
from polylingo._client import PolyLingo
|
|
3
|
+
from polylingo._errors import AuthError, JobFailedError, PolyLingoError, RateLimitError
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"PolyLingo",
|
|
7
|
+
"AsyncPolyLingo",
|
|
8
|
+
"PolyLingoError",
|
|
9
|
+
"AuthError",
|
|
10
|
+
"RateLimitError",
|
|
11
|
+
"JobFailedError",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal, Optional, Union
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from polylingo._errors import PolyLingoError
|
|
8
|
+
from polylingo._http_utils import error_from_response
|
|
9
|
+
from polylingo.resources._async_batch import batch_async
|
|
10
|
+
from polylingo.resources._async_health import health_async
|
|
11
|
+
from polylingo.resources._async_jobs import AsyncJobsResource
|
|
12
|
+
from polylingo.resources._async_languages import languages_async
|
|
13
|
+
from polylingo.resources._async_translate import translate_async
|
|
14
|
+
from polylingo.resources._async_usage import usage_async
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = "https://api.polylingo.io/v1"
|
|
17
|
+
|
|
18
|
+
ExpectStatus = Union[int, tuple[int, ...]]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AsyncPolyLingo:
|
|
22
|
+
"""Asynchronous PolyLingo API client."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
api_key: str,
|
|
27
|
+
*,
|
|
28
|
+
base_url: Optional[str] = None,
|
|
29
|
+
timeout: float = 120.0,
|
|
30
|
+
) -> None:
|
|
31
|
+
if not api_key or not isinstance(api_key, str):
|
|
32
|
+
raise TypeError("AsyncPolyLingo: api_key is required")
|
|
33
|
+
self._base = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
34
|
+
self._timeout = timeout
|
|
35
|
+
self._client = httpx.AsyncClient(
|
|
36
|
+
base_url=self._base,
|
|
37
|
+
headers={
|
|
38
|
+
"Authorization": f"Bearer {api_key}",
|
|
39
|
+
"Accept": "application/json",
|
|
40
|
+
},
|
|
41
|
+
timeout=httpx.Timeout(timeout),
|
|
42
|
+
)
|
|
43
|
+
self.jobs = AsyncJobsResource(self)
|
|
44
|
+
|
|
45
|
+
async def aclose(self) -> None:
|
|
46
|
+
await self._client.aclose()
|
|
47
|
+
|
|
48
|
+
async def __aenter__(self) -> AsyncPolyLingo:
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
async def __aexit__(self, *args: object) -> None:
|
|
52
|
+
await self.aclose()
|
|
53
|
+
|
|
54
|
+
async def _request_json(
|
|
55
|
+
self,
|
|
56
|
+
method: str,
|
|
57
|
+
path: str,
|
|
58
|
+
*,
|
|
59
|
+
json: Optional[dict[str, Any]] = None,
|
|
60
|
+
expect_status: Optional[ExpectStatus] = None,
|
|
61
|
+
) -> Any:
|
|
62
|
+
try:
|
|
63
|
+
response = await self._client.request(method, path, json=json)
|
|
64
|
+
except httpx.TimeoutException:
|
|
65
|
+
raise PolyLingoError(
|
|
66
|
+
408,
|
|
67
|
+
"timeout",
|
|
68
|
+
f"Request timed out after {self._timeout}s",
|
|
69
|
+
) from None
|
|
70
|
+
|
|
71
|
+
exp = expect_status
|
|
72
|
+
if exp is None:
|
|
73
|
+
ok = response.is_success
|
|
74
|
+
elif isinstance(exp, tuple):
|
|
75
|
+
ok = response.status_code in exp
|
|
76
|
+
else:
|
|
77
|
+
ok = response.status_code == exp
|
|
78
|
+
|
|
79
|
+
if not ok:
|
|
80
|
+
raise error_from_response(response)
|
|
81
|
+
|
|
82
|
+
if response.status_code == 204 or not response.content:
|
|
83
|
+
return {}
|
|
84
|
+
return response.json()
|
|
85
|
+
|
|
86
|
+
async def health(self) -> dict[str, Any]:
|
|
87
|
+
return await health_async(self)
|
|
88
|
+
|
|
89
|
+
async def languages(self) -> dict[str, Any]:
|
|
90
|
+
return await languages_async(self)
|
|
91
|
+
|
|
92
|
+
async def translate(
|
|
93
|
+
self,
|
|
94
|
+
*,
|
|
95
|
+
content: str,
|
|
96
|
+
targets: list[str],
|
|
97
|
+
format: Optional[Literal["plain", "markdown", "json", "html"]] = None,
|
|
98
|
+
source: Optional[str] = None,
|
|
99
|
+
model: Optional[Literal["standard", "advanced"]] = None,
|
|
100
|
+
) -> dict[str, Any]:
|
|
101
|
+
params: dict[str, Any] = {"content": content, "targets": targets}
|
|
102
|
+
if format is not None:
|
|
103
|
+
params["format"] = format
|
|
104
|
+
if source is not None:
|
|
105
|
+
params["source"] = source
|
|
106
|
+
if model is not None:
|
|
107
|
+
params["model"] = model
|
|
108
|
+
return await translate_async(self, params)
|
|
109
|
+
|
|
110
|
+
async def batch(
|
|
111
|
+
self,
|
|
112
|
+
*,
|
|
113
|
+
items: list[dict[str, Any]],
|
|
114
|
+
targets: list[str],
|
|
115
|
+
source: Optional[str] = None,
|
|
116
|
+
model: Optional[Literal["standard", "advanced"]] = None,
|
|
117
|
+
) -> dict[str, Any]:
|
|
118
|
+
params: dict[str, Any] = {"items": items, "targets": targets}
|
|
119
|
+
if source is not None:
|
|
120
|
+
params["source"] = source
|
|
121
|
+
if model is not None:
|
|
122
|
+
params["model"] = model
|
|
123
|
+
return await batch_async(self, params)
|
|
124
|
+
|
|
125
|
+
async def usage(self) -> dict[str, Any]:
|
|
126
|
+
return await usage_async(self)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal, Optional, Union
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from polylingo._errors import PolyLingoError
|
|
8
|
+
from polylingo._http_utils import error_from_response
|
|
9
|
+
from polylingo.resources._batch import batch
|
|
10
|
+
from polylingo.resources._health import health
|
|
11
|
+
from polylingo.resources._jobs import JobsResource
|
|
12
|
+
from polylingo.resources._languages import languages
|
|
13
|
+
from polylingo.resources._translate import translate
|
|
14
|
+
from polylingo.resources._usage import usage
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = "https://api.polylingo.io/v1"
|
|
17
|
+
|
|
18
|
+
ExpectStatus = Union[int, tuple[int, ...]]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PolyLingo:
|
|
22
|
+
"""Synchronous PolyLingo API client."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
api_key: str,
|
|
27
|
+
*,
|
|
28
|
+
base_url: Optional[str] = None,
|
|
29
|
+
timeout: float = 120.0,
|
|
30
|
+
) -> None:
|
|
31
|
+
if not api_key or not isinstance(api_key, str):
|
|
32
|
+
raise TypeError("PolyLingo: api_key is required")
|
|
33
|
+
self._base = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
34
|
+
self._timeout = timeout
|
|
35
|
+
self._client = httpx.Client(
|
|
36
|
+
base_url=self._base,
|
|
37
|
+
headers={
|
|
38
|
+
"Authorization": f"Bearer {api_key}",
|
|
39
|
+
"Accept": "application/json",
|
|
40
|
+
},
|
|
41
|
+
timeout=httpx.Timeout(timeout),
|
|
42
|
+
)
|
|
43
|
+
self.jobs = JobsResource(self)
|
|
44
|
+
|
|
45
|
+
def close(self) -> None:
|
|
46
|
+
self._client.close()
|
|
47
|
+
|
|
48
|
+
def __enter__(self) -> PolyLingo:
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
def __exit__(self, *args: object) -> None:
|
|
52
|
+
self.close()
|
|
53
|
+
|
|
54
|
+
def _request_json(
|
|
55
|
+
self,
|
|
56
|
+
method: str,
|
|
57
|
+
path: str,
|
|
58
|
+
*,
|
|
59
|
+
json: Optional[dict[str, Any]] = None,
|
|
60
|
+
expect_status: Optional[ExpectStatus] = None,
|
|
61
|
+
) -> Any:
|
|
62
|
+
try:
|
|
63
|
+
response = self._client.request(method, path, json=json)
|
|
64
|
+
except httpx.TimeoutException:
|
|
65
|
+
raise PolyLingoError(
|
|
66
|
+
408,
|
|
67
|
+
"timeout",
|
|
68
|
+
f"Request timed out after {self._timeout}s",
|
|
69
|
+
) from None
|
|
70
|
+
|
|
71
|
+
exp = expect_status
|
|
72
|
+
if exp is None:
|
|
73
|
+
ok = response.is_success
|
|
74
|
+
elif isinstance(exp, tuple):
|
|
75
|
+
ok = response.status_code in exp
|
|
76
|
+
else:
|
|
77
|
+
ok = response.status_code == exp
|
|
78
|
+
|
|
79
|
+
if not ok:
|
|
80
|
+
raise error_from_response(response)
|
|
81
|
+
|
|
82
|
+
if response.status_code == 204 or not response.content:
|
|
83
|
+
return {}
|
|
84
|
+
return response.json()
|
|
85
|
+
|
|
86
|
+
def health(self) -> dict[str, Any]:
|
|
87
|
+
return health(self)
|
|
88
|
+
|
|
89
|
+
def languages(self) -> dict[str, Any]:
|
|
90
|
+
return languages(self)
|
|
91
|
+
|
|
92
|
+
def translate(
|
|
93
|
+
self,
|
|
94
|
+
*,
|
|
95
|
+
content: str,
|
|
96
|
+
targets: list[str],
|
|
97
|
+
format: Optional[Literal["plain", "markdown", "json", "html"]] = None,
|
|
98
|
+
source: Optional[str] = None,
|
|
99
|
+
model: Optional[Literal["standard", "advanced"]] = None,
|
|
100
|
+
) -> dict[str, Any]:
|
|
101
|
+
params: dict[str, Any] = {"content": content, "targets": targets}
|
|
102
|
+
if format is not None:
|
|
103
|
+
params["format"] = format
|
|
104
|
+
if source is not None:
|
|
105
|
+
params["source"] = source
|
|
106
|
+
if model is not None:
|
|
107
|
+
params["model"] = model
|
|
108
|
+
return translate(self, params)
|
|
109
|
+
|
|
110
|
+
def batch(
|
|
111
|
+
self,
|
|
112
|
+
*,
|
|
113
|
+
items: list[dict[str, Any]],
|
|
114
|
+
targets: list[str],
|
|
115
|
+
source: Optional[str] = None,
|
|
116
|
+
model: Optional[Literal["standard", "advanced"]] = None,
|
|
117
|
+
) -> dict[str, Any]:
|
|
118
|
+
params: dict[str, Any] = {"items": items, "targets": targets}
|
|
119
|
+
if source is not None:
|
|
120
|
+
params["source"] = source
|
|
121
|
+
if model is not None:
|
|
122
|
+
params["model"] = model
|
|
123
|
+
return batch(self, params)
|
|
124
|
+
|
|
125
|
+
def usage(self) -> dict[str, Any]:
|
|
126
|
+
return usage(self)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PolyLingoError(Exception):
|
|
5
|
+
"""Base error for PolyLingo API failures."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, status: int, error: str, message: str) -> None:
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.status = status
|
|
10
|
+
self.error = error
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthError(PolyLingoError):
|
|
14
|
+
"""Invalid or missing API key (HTTP 401)."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RateLimitError(PolyLingoError):
|
|
18
|
+
"""Rate limited (HTTP 429)."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
status: int,
|
|
23
|
+
error: str,
|
|
24
|
+
message: str,
|
|
25
|
+
retry_after: int | None = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
super().__init__(status, error, message)
|
|
28
|
+
self.retry_after = retry_after
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class JobFailedError(PolyLingoError):
|
|
32
|
+
"""Async job finished with status ``failed``."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, job_id: str, status: int, error: str, message: str) -> None:
|
|
35
|
+
super().__init__(status, error, message)
|
|
36
|
+
self.job_id = job_id
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from ._errors import AuthError, PolyLingoError, RateLimitError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def error_from_response(response: httpx.Response) -> PolyLingoError:
|
|
9
|
+
try:
|
|
10
|
+
data = response.json()
|
|
11
|
+
except Exception:
|
|
12
|
+
data = {}
|
|
13
|
+
if not isinstance(data, dict):
|
|
14
|
+
data = {}
|
|
15
|
+
code = str(data.get("error", "unknown_error"))
|
|
16
|
+
message = str(data.get("message", f"Request failed with status {response.status_code}"))
|
|
17
|
+
status = response.status_code
|
|
18
|
+
|
|
19
|
+
if status == 401:
|
|
20
|
+
return AuthError(status, code, message)
|
|
21
|
+
if status == 429:
|
|
22
|
+
retry_raw = data.get("retry_after")
|
|
23
|
+
retry_after: int | None
|
|
24
|
+
if isinstance(retry_raw, int):
|
|
25
|
+
retry_after = retry_raw
|
|
26
|
+
elif isinstance(retry_raw, str) and retry_raw.isdigit():
|
|
27
|
+
retry_after = int(retry_raw)
|
|
28
|
+
else:
|
|
29
|
+
retry_after = None
|
|
30
|
+
if retry_after is None:
|
|
31
|
+
h = response.headers.get("retry-after")
|
|
32
|
+
if h and h.isdigit():
|
|
33
|
+
retry_after = int(h)
|
|
34
|
+
return RateLimitError(status, code, message, retry_after=retry_after)
|
|
35
|
+
return PolyLingoError(status, code, message)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal, TypedDict
|
|
4
|
+
|
|
5
|
+
ContentFormat = Literal["plain", "markdown", "json", "html"]
|
|
6
|
+
ModelTier = Literal["standard", "advanced"]
|
|
7
|
+
JobStatus = Literal["pending", "processing", "completed", "failed"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TranslateUsage(TypedDict, total=False):
|
|
11
|
+
total_tokens: int
|
|
12
|
+
input_tokens: int
|
|
13
|
+
output_tokens: int
|
|
14
|
+
model: str
|
|
15
|
+
detected_format: str
|
|
16
|
+
detection_confidence: float
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TranslateResult(TypedDict):
|
|
20
|
+
translations: dict[str, str]
|
|
21
|
+
usage: TranslateUsage
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BatchItem(TypedDict, total=False):
|
|
25
|
+
id: str
|
|
26
|
+
content: str
|
|
27
|
+
format: ContentFormat
|
|
28
|
+
source: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BatchItemResult(TypedDict):
|
|
32
|
+
id: str
|
|
33
|
+
translations: dict[str, str]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BatchUsage(TypedDict, total=False):
|
|
37
|
+
total_tokens: int
|
|
38
|
+
input_tokens: int
|
|
39
|
+
output_tokens: int
|
|
40
|
+
model: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BatchResult(TypedDict):
|
|
44
|
+
results: list[BatchItemResult]
|
|
45
|
+
usage: BatchUsage
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Job(TypedDict, total=False):
|
|
49
|
+
job_id: str
|
|
50
|
+
status: JobStatus
|
|
51
|
+
queue_position: int
|
|
52
|
+
translations: dict[str, str]
|
|
53
|
+
usage: TranslateUsage
|
|
54
|
+
error: str
|
|
55
|
+
message: str
|
|
56
|
+
created_at: str
|
|
57
|
+
updated_at: str
|
|
58
|
+
completed_at: str | None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class HealthResponse(TypedDict):
|
|
62
|
+
status: str
|
|
63
|
+
timestamp: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class LanguageEntry(TypedDict):
|
|
67
|
+
code: str
|
|
68
|
+
name: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class LanguagesResponse(TypedDict):
|
|
72
|
+
languages: list[LanguageEntry]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class UsagePayload(TypedDict, total=False):
|
|
76
|
+
period_start: str
|
|
77
|
+
period_end: str
|
|
78
|
+
translations_used: int
|
|
79
|
+
translations_limit: int | None
|
|
80
|
+
tokens_used: int
|
|
81
|
+
tokens_limit: int | None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class UsageResponse(TypedDict):
|
|
85
|
+
usage: UsagePayload
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --- Params (plain dicts are fine; these document expected keys) ---
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TranslateParams(TypedDict, total=False):
|
|
92
|
+
content: str
|
|
93
|
+
targets: list[str]
|
|
94
|
+
format: ContentFormat
|
|
95
|
+
source: str
|
|
96
|
+
model: ModelTier
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class BatchParams(TypedDict, total=False):
|
|
100
|
+
items: list[BatchItem]
|
|
101
|
+
targets: list[str]
|
|
102
|
+
source: str
|
|
103
|
+
model: ModelTier
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class CreateJobParams(TypedDict, total=False):
|
|
107
|
+
content: str
|
|
108
|
+
targets: list[str]
|
|
109
|
+
format: ContentFormat
|
|
110
|
+
source: str
|
|
111
|
+
model: ModelTier
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class JobsTranslateParams(CreateJobParams, total=False):
|
|
115
|
+
poll_interval: float
|
|
116
|
+
timeout: float
|
|
117
|
+
on_progress: Any # Callable[[int | None], None] — avoid circular import
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Mapping
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from polylingo._async_client import AsyncPolyLingo
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def batch_async(client: AsyncPolyLingo, params: Mapping[str, Any]) -> dict[str, Any]:
|
|
10
|
+
body: dict[str, Any] = {
|
|
11
|
+
"items": params["items"],
|
|
12
|
+
"targets": params["targets"],
|
|
13
|
+
}
|
|
14
|
+
if params.get("source") is not None:
|
|
15
|
+
body["source"] = params["source"]
|
|
16
|
+
if params.get("model") is not None:
|
|
17
|
+
body["model"] = params["model"]
|
|
18
|
+
return await client._request_json("POST", "/translate/batch", json=body, expect_status=200)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from polylingo._async_client import AsyncPolyLingo
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def health_async(client: AsyncPolyLingo) -> dict[str, object]:
|
|
10
|
+
return await client._request_json("GET", "/health", expect_status=200)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Callable, Mapping, Optional
|
|
6
|
+
|
|
7
|
+
from polylingo._errors import JobFailedError
|
|
8
|
+
from polylingo.resources._jobs import DEFAULT_JOB_TIMEOUT_S, DEFAULT_POLL_S, _job_body, _merge_params
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from polylingo._async_client import AsyncPolyLingo
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AsyncJobsResource:
|
|
15
|
+
def __init__(self, client: AsyncPolyLingo) -> None:
|
|
16
|
+
self._client = client
|
|
17
|
+
|
|
18
|
+
async def create(self, params: Mapping[str, Any] | None = None, **kwargs: Any) -> dict[str, Any]:
|
|
19
|
+
p = _merge_params(params, kwargs)
|
|
20
|
+
body = _job_body(p)
|
|
21
|
+
return await self._client._request_json("POST", "/jobs", json=body, expect_status=202)
|
|
22
|
+
|
|
23
|
+
async def get(self, job_id: str) -> dict[str, Any]:
|
|
24
|
+
return await self._client._request_json("GET", f"/jobs/{job_id}", expect_status=200)
|
|
25
|
+
|
|
26
|
+
async def translate(
|
|
27
|
+
self,
|
|
28
|
+
params: Mapping[str, Any] | None = None,
|
|
29
|
+
*,
|
|
30
|
+
poll_interval: float = DEFAULT_POLL_S,
|
|
31
|
+
timeout: float = DEFAULT_JOB_TIMEOUT_S,
|
|
32
|
+
on_progress: Optional[Callable[[Optional[int]], None]] = None,
|
|
33
|
+
**kwargs: Any,
|
|
34
|
+
) -> dict[str, Any]:
|
|
35
|
+
p = _merge_params(params, kwargs)
|
|
36
|
+
job = await self.create(p)
|
|
37
|
+
jid = str(job["job_id"])
|
|
38
|
+
deadline = time.monotonic() + timeout
|
|
39
|
+
|
|
40
|
+
while time.monotonic() < deadline:
|
|
41
|
+
status = await self.get(jid)
|
|
42
|
+
st = status.get("status")
|
|
43
|
+
if st in ("pending", "processing") and on_progress is not None:
|
|
44
|
+
on_progress(status.get("queue_position")) # type: ignore[arg-type]
|
|
45
|
+
if st == "completed":
|
|
46
|
+
translations = status.get("translations")
|
|
47
|
+
usage = status.get("usage")
|
|
48
|
+
if not translations or not usage:
|
|
49
|
+
raise JobFailedError(
|
|
50
|
+
jid,
|
|
51
|
+
500,
|
|
52
|
+
"invalid_response",
|
|
53
|
+
"Job completed but translations or usage was missing",
|
|
54
|
+
)
|
|
55
|
+
return {"translations": translations, "usage": usage}
|
|
56
|
+
if st == "failed":
|
|
57
|
+
raise JobFailedError(
|
|
58
|
+
jid,
|
|
59
|
+
200,
|
|
60
|
+
str(status.get("error") or "job_failed"),
|
|
61
|
+
str(status.get("message") or status.get("error") or "Translation job failed"),
|
|
62
|
+
)
|
|
63
|
+
await asyncio.sleep(poll_interval)
|
|
64
|
+
|
|
65
|
+
raise JobFailedError(jid, 408, "timeout", "Job polling exceeded the configured timeout")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from polylingo._async_client import AsyncPolyLingo
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def languages_async(client: AsyncPolyLingo) -> dict[str, object]:
|
|
10
|
+
return await client._request_json("GET", "/languages", expect_status=200)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Mapping
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from polylingo._async_client import AsyncPolyLingo
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def translate_async(client: AsyncPolyLingo, params: Mapping[str, Any]) -> dict[str, Any]:
|
|
10
|
+
body: dict[str, Any] = {
|
|
11
|
+
"content": params["content"],
|
|
12
|
+
"targets": params["targets"],
|
|
13
|
+
}
|
|
14
|
+
if params.get("format") is not None:
|
|
15
|
+
body["format"] = params["format"]
|
|
16
|
+
if params.get("source") is not None:
|
|
17
|
+
body["source"] = params["source"]
|
|
18
|
+
if params.get("model") is not None:
|
|
19
|
+
body["model"] = params["model"]
|
|
20
|
+
return await client._request_json("POST", "/translate", json=body, expect_status=200)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from polylingo._async_client import AsyncPolyLingo
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def usage_async(client: AsyncPolyLingo) -> dict[str, object]:
|
|
10
|
+
return await client._request_json("GET", "/usage", expect_status=200)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Mapping
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from polylingo._client import PolyLingo
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def batch(client: PolyLingo, params: Mapping[str, Any]) -> dict[str, Any]:
|
|
10
|
+
body: dict[str, Any] = {
|
|
11
|
+
"items": params["items"],
|
|
12
|
+
"targets": params["targets"],
|
|
13
|
+
}
|
|
14
|
+
if params.get("source") is not None:
|
|
15
|
+
body["source"] = params["source"]
|
|
16
|
+
if params.get("model") is not None:
|
|
17
|
+
body["model"] = params["model"]
|
|
18
|
+
return client._request_json("POST", "/translate/batch", json=body, expect_status=200)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from polylingo._client import PolyLingo
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def health(client: PolyLingo) -> dict[str, object]:
|
|
10
|
+
return client._request_json("GET", "/health", expect_status=200)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Callable, Mapping, Optional
|
|
5
|
+
|
|
6
|
+
from polylingo._errors import JobFailedError
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from polylingo._client import PolyLingo
|
|
10
|
+
|
|
11
|
+
DEFAULT_POLL_S = 5.0
|
|
12
|
+
DEFAULT_JOB_TIMEOUT_S = 1200.0 # 20 minutes (matches Node default)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class JobsResource:
|
|
16
|
+
def __init__(self, client: PolyLingo) -> None:
|
|
17
|
+
self._client = client
|
|
18
|
+
|
|
19
|
+
def create(self, params: Mapping[str, Any] | None = None, **kwargs: Any) -> dict[str, Any]:
|
|
20
|
+
p = _merge_params(params, kwargs)
|
|
21
|
+
body = _job_body(p)
|
|
22
|
+
return self._client._request_json("POST", "/jobs", json=body, expect_status=202)
|
|
23
|
+
|
|
24
|
+
def get(self, job_id: str) -> dict[str, Any]:
|
|
25
|
+
return self._client._request_json("GET", f"/jobs/{job_id}", expect_status=200)
|
|
26
|
+
|
|
27
|
+
def translate(
|
|
28
|
+
self,
|
|
29
|
+
params: Mapping[str, Any] | None = None,
|
|
30
|
+
*,
|
|
31
|
+
poll_interval: float = DEFAULT_POLL_S,
|
|
32
|
+
timeout: float = DEFAULT_JOB_TIMEOUT_S,
|
|
33
|
+
on_progress: Optional[Callable[[Optional[int]], None]] = None,
|
|
34
|
+
**kwargs: Any,
|
|
35
|
+
) -> dict[str, Any]:
|
|
36
|
+
p = _merge_params(params, kwargs)
|
|
37
|
+
job = self.create(p)
|
|
38
|
+
jid = str(job["job_id"])
|
|
39
|
+
deadline = time.monotonic() + timeout
|
|
40
|
+
|
|
41
|
+
while time.monotonic() < deadline:
|
|
42
|
+
status = self.get(jid)
|
|
43
|
+
st = status.get("status")
|
|
44
|
+
if st in ("pending", "processing") and on_progress is not None:
|
|
45
|
+
on_progress(status.get("queue_position")) # type: ignore[arg-type]
|
|
46
|
+
if st == "completed":
|
|
47
|
+
translations = status.get("translations")
|
|
48
|
+
usage = status.get("usage")
|
|
49
|
+
if not translations or not usage:
|
|
50
|
+
raise JobFailedError(
|
|
51
|
+
jid,
|
|
52
|
+
500,
|
|
53
|
+
"invalid_response",
|
|
54
|
+
"Job completed but translations or usage was missing",
|
|
55
|
+
)
|
|
56
|
+
return {"translations": translations, "usage": usage}
|
|
57
|
+
if st == "failed":
|
|
58
|
+
raise JobFailedError(
|
|
59
|
+
jid,
|
|
60
|
+
200,
|
|
61
|
+
str(status.get("error") or "job_failed"),
|
|
62
|
+
str(status.get("message") or status.get("error") or "Translation job failed"),
|
|
63
|
+
)
|
|
64
|
+
time.sleep(poll_interval)
|
|
65
|
+
|
|
66
|
+
raise JobFailedError(jid, 408, "timeout", "Job polling exceeded the configured timeout")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _merge_params(
|
|
70
|
+
params: Optional[Mapping[str, Any]],
|
|
71
|
+
kwargs: dict[str, Any],
|
|
72
|
+
) -> dict[str, Any]:
|
|
73
|
+
out: dict[str, Any] = {}
|
|
74
|
+
if params:
|
|
75
|
+
out.update(dict(params))
|
|
76
|
+
out.update(kwargs)
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _job_body(p: Mapping[str, Any]) -> dict[str, Any]:
|
|
81
|
+
body: dict[str, Any] = {
|
|
82
|
+
"content": p["content"],
|
|
83
|
+
"targets": p["targets"],
|
|
84
|
+
}
|
|
85
|
+
if p.get("format") is not None:
|
|
86
|
+
body["format"] = p["format"]
|
|
87
|
+
if p.get("source") is not None:
|
|
88
|
+
body["source"] = p["source"]
|
|
89
|
+
if p.get("model") is not None:
|
|
90
|
+
body["model"] = p["model"]
|
|
91
|
+
return body
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from polylingo._client import PolyLingo
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def languages(client: PolyLingo) -> dict[str, object]:
|
|
10
|
+
return client._request_json("GET", "/languages", expect_status=200)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Mapping
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from polylingo._client import PolyLingo
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def translate(client: PolyLingo, params: Mapping[str, Any]) -> dict[str, Any]:
|
|
10
|
+
body: dict[str, Any] = {
|
|
11
|
+
"content": params["content"],
|
|
12
|
+
"targets": params["targets"],
|
|
13
|
+
}
|
|
14
|
+
if params.get("format") is not None:
|
|
15
|
+
body["format"] = params["format"]
|
|
16
|
+
if params.get("source") is not None:
|
|
17
|
+
body["source"] = params["source"]
|
|
18
|
+
if params.get("model") is not None:
|
|
19
|
+
body["model"] = params["model"]
|
|
20
|
+
return client._request_json("POST", "/translate", json=body, expect_status=200)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "polylingo"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for the PolyLingo translation API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "PolyLingo" }]
|
|
13
|
+
keywords = ["translation", "i18n", "polylingo", "markdown", "api"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = ["httpx>=0.24"]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://usepolylingo.com"
|
|
30
|
+
Documentation = "https://usepolylingo.com/en/docs/sdk/python"
|
|
31
|
+
Repository = "https://github.com/UsePolyLingo/polylingo-python"
|
|
32
|
+
Issues = "https://github.com/UsePolyLingo/polylingo-python/issues"
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=8.0",
|
|
37
|
+
"pytest-asyncio>=0.24",
|
|
38
|
+
"pytest-httpx>=0.30",
|
|
39
|
+
"httpx>=0.24",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["polylingo"]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.sdist]
|
|
46
|
+
include = ["/polylingo", "/README.md", "/tests"]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
testpaths = ["tests"]
|
|
50
|
+
pythonpath = ["."]
|
|
51
|
+
asyncio_mode = "auto"
|
|
52
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
53
|
+
|
|
54
|
+
[tool.ruff]
|
|
55
|
+
line-length = 100
|
|
56
|
+
target-version = "py39"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from polylingo import AsyncPolyLingo, AuthError, PolyLingo, RateLimitError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_auth_error_on_401(httpx_mock):
|
|
7
|
+
httpx_mock.add_response(
|
|
8
|
+
method="POST",
|
|
9
|
+
url="https://api.example.com/v1/translate",
|
|
10
|
+
status_code=401,
|
|
11
|
+
json={"error": "invalid_api_key", "message": "Bad"},
|
|
12
|
+
)
|
|
13
|
+
client = PolyLingo("k", base_url="https://api.example.com/v1")
|
|
14
|
+
with pytest.raises(AuthError):
|
|
15
|
+
client.translate(content="hi", targets=["es"])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_rate_limit_retry_after_header(httpx_mock):
|
|
19
|
+
httpx_mock.add_response(
|
|
20
|
+
method="POST",
|
|
21
|
+
url="https://api.example.com/v1/translate",
|
|
22
|
+
status_code=429,
|
|
23
|
+
json={"error": "rate_limited", "message": "Slow"},
|
|
24
|
+
headers={"Retry-After": "60"},
|
|
25
|
+
)
|
|
26
|
+
client = PolyLingo("k", base_url="https://api.example.com/v1")
|
|
27
|
+
with pytest.raises(RateLimitError) as ei:
|
|
28
|
+
client.translate(content="hi", targets=["es"])
|
|
29
|
+
assert ei.value.retry_after == 60
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.mark.asyncio
|
|
33
|
+
async def test_async_auth_error(httpx_mock):
|
|
34
|
+
httpx_mock.add_response(
|
|
35
|
+
method="POST",
|
|
36
|
+
url="https://api.example.com/v1/translate",
|
|
37
|
+
status_code=401,
|
|
38
|
+
json={"error": "invalid_api_key", "message": "Bad"},
|
|
39
|
+
)
|
|
40
|
+
async with AsyncPolyLingo("k", base_url="https://api.example.com/v1") as c:
|
|
41
|
+
with pytest.raises(AuthError):
|
|
42
|
+
await c.translate(content="hi", targets=["es"])
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from polylingo import JobFailedError, PolyLingo
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_jobs_translate_completed(httpx_mock):
|
|
7
|
+
httpx_mock.add_response(
|
|
8
|
+
method="POST",
|
|
9
|
+
url="https://api.example.com/v1/jobs",
|
|
10
|
+
status_code=202,
|
|
11
|
+
json={"job_id": "j1", "status": "pending"},
|
|
12
|
+
)
|
|
13
|
+
httpx_mock.add_response(
|
|
14
|
+
method="GET",
|
|
15
|
+
url="https://api.example.com/v1/jobs/j1",
|
|
16
|
+
status_code=200,
|
|
17
|
+
json={
|
|
18
|
+
"job_id": "j1",
|
|
19
|
+
"status": "completed",
|
|
20
|
+
"translations": {"fr": "Salut"},
|
|
21
|
+
"usage": {"total_tokens": 2, "input_tokens": 1, "output_tokens": 1},
|
|
22
|
+
},
|
|
23
|
+
)
|
|
24
|
+
client = PolyLingo("k", base_url="https://api.example.com/v1")
|
|
25
|
+
done = client.jobs.translate(content="Hi", targets=["fr"], poll_interval=0.01, timeout=5.0)
|
|
26
|
+
assert done["translations"]["fr"] == "Salut"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_jobs_translate_failed(httpx_mock):
|
|
30
|
+
httpx_mock.add_response(
|
|
31
|
+
method="POST",
|
|
32
|
+
url="https://api.example.com/v1/jobs",
|
|
33
|
+
status_code=202,
|
|
34
|
+
json={"job_id": "j1", "status": "pending"},
|
|
35
|
+
)
|
|
36
|
+
httpx_mock.add_response(
|
|
37
|
+
method="GET",
|
|
38
|
+
url="https://api.example.com/v1/jobs/j1",
|
|
39
|
+
status_code=200,
|
|
40
|
+
json={"job_id": "j1", "status": "failed", "error": "boom"},
|
|
41
|
+
)
|
|
42
|
+
client = PolyLingo("k", base_url="https://api.example.com/v1")
|
|
43
|
+
with pytest.raises(JobFailedError) as ei:
|
|
44
|
+
client.jobs.translate(content="x", targets=["de"], poll_interval=0.01, timeout=5.0)
|
|
45
|
+
assert ei.value.job_id == "j1"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_translate_posts_json(httpx_mock):
|
|
5
|
+
httpx_mock.add_response(
|
|
6
|
+
method="POST",
|
|
7
|
+
url="https://api.example.com/v1/translate",
|
|
8
|
+
status_code=200,
|
|
9
|
+
json={
|
|
10
|
+
"translations": {"es": "Hola"},
|
|
11
|
+
"usage": {"total_tokens": 3, "input_tokens": 1, "output_tokens": 2},
|
|
12
|
+
},
|
|
13
|
+
)
|
|
14
|
+
from polylingo import PolyLingo
|
|
15
|
+
|
|
16
|
+
client = PolyLingo("secret", base_url="https://api.example.com/v1")
|
|
17
|
+
r = client.translate(content="Hi", targets=["es"], format="plain")
|
|
18
|
+
assert r["translations"]["es"] == "Hola"
|
|
19
|
+
req = httpx_mock.get_request()
|
|
20
|
+
assert req.headers["Authorization"] == "Bearer secret"
|
|
21
|
+
import json
|
|
22
|
+
|
|
23
|
+
body = json.loads(req.content.decode())
|
|
24
|
+
assert body == {"content": "Hi", "targets": ["es"], "format": "plain"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.mark.asyncio
|
|
28
|
+
async def test_async_translate(httpx_mock):
|
|
29
|
+
httpx_mock.add_response(
|
|
30
|
+
method="POST",
|
|
31
|
+
url="https://api.example.com/v1/translate",
|
|
32
|
+
status_code=200,
|
|
33
|
+
json={"translations": {"de": "Ja"}, "usage": {"total_tokens": 1, "input_tokens": 0, "output_tokens": 1}},
|
|
34
|
+
)
|
|
35
|
+
from polylingo import AsyncPolyLingo
|
|
36
|
+
|
|
37
|
+
async with AsyncPolyLingo("x", base_url="https://api.example.com/v1") as c:
|
|
38
|
+
r = await c.translate(content="Yes", targets=["de"])
|
|
39
|
+
assert r["translations"]["de"] == "Ja"
|