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.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ dist/
6
+ *.egg-info/
7
+ .venv/
8
+ venv/
@@ -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,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 usage(client: PolyLingo) -> dict[str, object]:
10
+ return client._request_json("GET", "/usage", 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"