lingolix 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
+ *.egg-info/
3
+ .mypy_cache/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .venv/
7
+ dist/
8
+ node_modules/
lingolix-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 lingolix
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.5
2
+ Name: lingolix
3
+ Version: 0.1.0
4
+ Summary: Python client for the Lingolix pronunciation scoring API
5
+ Project-URL: Homepage, https://lingolix.com
6
+ Project-URL: Documentation, https://lingolix.com/api-portal/docs
7
+ Project-URL: Source, https://github.com/lingolix/lingolix-sdk
8
+ Project-URL: Issues, https://github.com/lingolix/lingolix-sdk/issues
9
+ Author-email: Lingolix <info@lingolix.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: ipa,language-learning,phonetics,pronunciation,speech
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: httpx>=0.27
24
+ Description-Content-Type: text/markdown
25
+
26
+ # lingolix
27
+
28
+ Python client for the [Lingolix](https://lingolix.com) pronunciation scoring API. Send a
29
+ recording, get back per-syllable accuracy with expected vs. detected IPA, pitch and
30
+ timing — in 17 languages.
31
+
32
+ Get an API key at [lingolix.com/api-portal](https://lingolix.com/api-portal), then set
33
+ `LINGOLIX_API_KEY` in your environment. Keep it server-side — never ship it to a browser.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install lingolix # or: uv add lingolix
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ ```python
44
+ from lingolix import Lingolix
45
+
46
+ with Lingolix() as client: # key from $LINGOLIX_API_KEY
47
+ r = client.check("audio.wav", sentence="Guten Morgen", language="de")
48
+
49
+ print(r.accuracy, r.completeness, r.speaking_rate)
50
+ for word in r.words:
51
+ for s in word.syllables:
52
+ print(s.text, s.expected_ipa, "→", s.detected_ipa, s.pitch, round(s.accuracy, 2))
53
+ ```
54
+
55
+ Async is the same class with an `Async` prefix:
56
+
57
+ ```python
58
+ from lingolix import AsyncLingolix
59
+
60
+ async with AsyncLingolix() as client:
61
+ r = await client.check(audio_bytes, sentence="こんにちは", language="ja")
62
+ ```
63
+
64
+ `check()` takes a path, `bytes`, or any file-like object. Drop `sentence` and the audio is
65
+ transcribed first, then scored against its own transcription — useful for free-form speech.
66
+
67
+ ## Result shape
68
+
69
+ | Field | Meaning |
70
+ | --- | --- |
71
+ | `text` | What the audio was scored against — your `sentence`, or the transcription |
72
+ | `accuracy` | Overall pronunciation accuracy, 0–1 |
73
+ | `completeness` | How much of the expected utterance was actually said, 0–1 |
74
+ | `speaking_rate` | Syllables per second |
75
+ | `words[]` | `text`, `accuracy`, `completeness`, `start_ms`, `end_ms`, `char_start`, `char_end` |
76
+ | `words[].syllables[]` | `text`, `expected_ipa`, `detected_ipa`, `accuracy`, `completeness`, `pitch`, `duration_ms`, `start_ms`, `end_ms`, `is_missing`, `is_extra` |
77
+
78
+ `pitch` is one of `"high"`, `"flat"`, `"low"`, `"unknown"`.
79
+
80
+ ## Errors
81
+
82
+ Every failure is a subclass of `LingolixError` carrying `.status` and `.detail`.
83
+
84
+ | Class | HTTP | Cause |
85
+ | --- | --- | --- |
86
+ | `InvalidAudioError` | 400, 413 | Undecodable audio, or over the 10 MiB limit |
87
+ | `AuthenticationError` | 401 | Missing, malformed or unknown API key |
88
+ | `SubscriptionError` | 403 | No active API subscription |
89
+ | `QuotaExceededError` | 429 | Monthly quota exhausted |
90
+ | `ServiceUnavailableError` | 503 | Scoring service temporarily down |
91
+
92
+ `QuotaExceededError` also carries `plan`, `remaining_minutes`, `quota_percentage` and
93
+ `upgrade_url`:
94
+
95
+ ```python
96
+ from lingolix import Lingolix, QuotaExceededError
97
+
98
+ try:
99
+ r = Lingolix().check("audio.wav", language="en")
100
+ except QuotaExceededError as e:
101
+ print(f"{e.plan} plan is out of minutes — upgrade at {e.upgrade_url}")
102
+ ```
103
+
104
+ ## Languages
105
+
106
+ `bg cs de el en es fi fr hu it ja ko nl pl pt sv uk` — the default is `ja`.
107
+
108
+ ## Audio
109
+
110
+ Anything ffmpeg can read: WAV, MP3, M4A, OGG/Opus, FLAC, WebM. Uploads are capped at
111
+ 10 MiB, checked client-side before sending. Prefer a compressed format — a single utterance
112
+ is well under 1 MiB as MP3 or Opus.
113
+
114
+ ## Links
115
+
116
+ - [TypeScript client](https://www.npmjs.com/package/@lingolix/sdk)
117
+ - [API documentation](https://lingolix.com/api-portal/docs)
118
+ - [Pricing](https://lingolix.com/api-portal/pricing)
119
+ - [Source and issues](https://github.com/lingolix/lingolix-sdk)
120
+
121
+ MIT licensed.
@@ -0,0 +1,96 @@
1
+ # lingolix
2
+
3
+ Python client for the [Lingolix](https://lingolix.com) pronunciation scoring API. Send a
4
+ recording, get back per-syllable accuracy with expected vs. detected IPA, pitch and
5
+ timing — in 17 languages.
6
+
7
+ Get an API key at [lingolix.com/api-portal](https://lingolix.com/api-portal), then set
8
+ `LINGOLIX_API_KEY` in your environment. Keep it server-side — never ship it to a browser.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install lingolix # or: uv add lingolix
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ```python
19
+ from lingolix import Lingolix
20
+
21
+ with Lingolix() as client: # key from $LINGOLIX_API_KEY
22
+ r = client.check("audio.wav", sentence="Guten Morgen", language="de")
23
+
24
+ print(r.accuracy, r.completeness, r.speaking_rate)
25
+ for word in r.words:
26
+ for s in word.syllables:
27
+ print(s.text, s.expected_ipa, "→", s.detected_ipa, s.pitch, round(s.accuracy, 2))
28
+ ```
29
+
30
+ Async is the same class with an `Async` prefix:
31
+
32
+ ```python
33
+ from lingolix import AsyncLingolix
34
+
35
+ async with AsyncLingolix() as client:
36
+ r = await client.check(audio_bytes, sentence="こんにちは", language="ja")
37
+ ```
38
+
39
+ `check()` takes a path, `bytes`, or any file-like object. Drop `sentence` and the audio is
40
+ transcribed first, then scored against its own transcription — useful for free-form speech.
41
+
42
+ ## Result shape
43
+
44
+ | Field | Meaning |
45
+ | --- | --- |
46
+ | `text` | What the audio was scored against — your `sentence`, or the transcription |
47
+ | `accuracy` | Overall pronunciation accuracy, 0–1 |
48
+ | `completeness` | How much of the expected utterance was actually said, 0–1 |
49
+ | `speaking_rate` | Syllables per second |
50
+ | `words[]` | `text`, `accuracy`, `completeness`, `start_ms`, `end_ms`, `char_start`, `char_end` |
51
+ | `words[].syllables[]` | `text`, `expected_ipa`, `detected_ipa`, `accuracy`, `completeness`, `pitch`, `duration_ms`, `start_ms`, `end_ms`, `is_missing`, `is_extra` |
52
+
53
+ `pitch` is one of `"high"`, `"flat"`, `"low"`, `"unknown"`.
54
+
55
+ ## Errors
56
+
57
+ Every failure is a subclass of `LingolixError` carrying `.status` and `.detail`.
58
+
59
+ | Class | HTTP | Cause |
60
+ | --- | --- | --- |
61
+ | `InvalidAudioError` | 400, 413 | Undecodable audio, or over the 10 MiB limit |
62
+ | `AuthenticationError` | 401 | Missing, malformed or unknown API key |
63
+ | `SubscriptionError` | 403 | No active API subscription |
64
+ | `QuotaExceededError` | 429 | Monthly quota exhausted |
65
+ | `ServiceUnavailableError` | 503 | Scoring service temporarily down |
66
+
67
+ `QuotaExceededError` also carries `plan`, `remaining_minutes`, `quota_percentage` and
68
+ `upgrade_url`:
69
+
70
+ ```python
71
+ from lingolix import Lingolix, QuotaExceededError
72
+
73
+ try:
74
+ r = Lingolix().check("audio.wav", language="en")
75
+ except QuotaExceededError as e:
76
+ print(f"{e.plan} plan is out of minutes — upgrade at {e.upgrade_url}")
77
+ ```
78
+
79
+ ## Languages
80
+
81
+ `bg cs de el en es fi fr hu it ja ko nl pl pt sv uk` — the default is `ja`.
82
+
83
+ ## Audio
84
+
85
+ Anything ffmpeg can read: WAV, MP3, M4A, OGG/Opus, FLAC, WebM. Uploads are capped at
86
+ 10 MiB, checked client-side before sending. Prefer a compressed format — a single utterance
87
+ is well under 1 MiB as MP3 or Opus.
88
+
89
+ ## Links
90
+
91
+ - [TypeScript client](https://www.npmjs.com/package/@lingolix/sdk)
92
+ - [API documentation](https://lingolix.com/api-portal/docs)
93
+ - [Pricing](https://lingolix.com/api-portal/pricing)
94
+ - [Source and issues](https://github.com/lingolix/lingolix-sdk)
95
+
96
+ MIT licensed.
@@ -0,0 +1,56 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "lingolix"
7
+ dynamic = ["version"]
8
+ description = "Python client for the Lingolix pronunciation scoring API"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ authors = [{ name = "Lingolix", email = "info@lingolix.com" }]
14
+ keywords = ["pronunciation", "speech", "language-learning", "ipa", "phonetics"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Multimedia :: Sound/Audio :: Speech",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = ["httpx>=0.27"]
27
+
28
+ [project.urls]
29
+ Homepage = "https://lingolix.com"
30
+ Documentation = "https://lingolix.com/api-portal/docs"
31
+ Source = "https://github.com/lingolix/lingolix-sdk"
32
+ Issues = "https://github.com/lingolix/lingolix-sdk/issues"
33
+
34
+ [dependency-groups]
35
+ dev = ["pytest>=8", "mypy>=1.11", "ruff>=0.6"]
36
+
37
+ [tool.hatch.version]
38
+ path = "src/lingolix/__init__.py"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/lingolix"]
42
+
43
+ [tool.hatch.build.targets.sdist]
44
+ include = ["src", "tests", "README.md", "LICENSE"]
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
48
+
49
+ [tool.mypy]
50
+ strict = true
51
+
52
+ [tool.ruff]
53
+ line-length = 100
54
+
55
+ [tool.ruff.lint]
56
+ select = ["E", "F", "I", "UP", "B"]
@@ -0,0 +1,326 @@
1
+ """Lingolix pronunciation API client.
2
+
3
+ from lingolix import Lingolix
4
+
5
+ with Lingolix() as client: # key from $LINGOLIX_API_KEY
6
+ r = client.check("audio.wav", sentence="Guten Morgen", language="de")
7
+ print(r.accuracy)
8
+
9
+ Async is the same with ``AsyncLingolix`` and ``await``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import dataclasses
15
+ import mimetypes
16
+ import os
17
+ from dataclasses import dataclass, field
18
+ from typing import Any, BinaryIO, Literal, TypeVar, Union
19
+
20
+ import httpx
21
+
22
+ __version__ = "0.1.0"
23
+
24
+ __all__ = [
25
+ "Lingolix",
26
+ "AsyncLingolix",
27
+ "PronunciationResult",
28
+ "Word",
29
+ "Syllable",
30
+ "LingolixError",
31
+ "AuthenticationError",
32
+ "SubscriptionError",
33
+ "InvalidAudioError",
34
+ "QuotaExceededError",
35
+ "ServiceUnavailableError",
36
+ "LANGUAGES",
37
+ "Pitch",
38
+ "MAX_UPLOAD_BYTES",
39
+ "__version__",
40
+ ]
41
+
42
+ DEFAULT_BASE_URL = "https://api.lingolix.com"
43
+ CHECK_PATH = "/api/pronunciation/v3/check"
44
+
45
+ #: Mirrors the server limit in the backend's pronunciation API.
46
+ MAX_UPLOAD_BYTES = 10 * 1024 * 1024
47
+
48
+ #: Languages accepted by the v3 endpoint.
49
+ LANGUAGES = (
50
+ "bg", "cs", "de", "el", "en", "es", "fi", "fr",
51
+ "hu", "it", "ja", "ko", "nl", "pl", "pt", "sv", "uk",
52
+ )
53
+
54
+ AudioInput = Union[str, "os.PathLike[str]", bytes, bytearray, BinaryIO]
55
+
56
+ #: Relative pitch of a syllable.
57
+ Pitch = Literal["high", "flat", "low", "unknown"]
58
+
59
+ T = TypeVar("T")
60
+
61
+
62
+ # --------------------------------------------------------------------------- errors
63
+
64
+
65
+ class LingolixError(Exception):
66
+ """Base class for every error this client raises."""
67
+
68
+ def __init__(
69
+ self,
70
+ message: str,
71
+ *,
72
+ status: int | None = None,
73
+ detail: Any = None,
74
+ ) -> None:
75
+ super().__init__(message)
76
+ self.message = message
77
+ self.status = status
78
+ self.detail = detail
79
+
80
+
81
+ class AuthenticationError(LingolixError):
82
+ """The API key is missing, malformed or unknown (HTTP 401)."""
83
+
84
+
85
+ class SubscriptionError(LingolixError):
86
+ """No active API subscription for this key (HTTP 403)."""
87
+
88
+
89
+ class InvalidAudioError(LingolixError):
90
+ """The audio could not be decoded, or was too large (HTTP 400 / 413)."""
91
+
92
+
93
+ class QuotaExceededError(LingolixError):
94
+ """The monthly quota is exhausted (HTTP 429)."""
95
+
96
+ def __init__(self, message: str, *, status: int | None = None, detail: Any = None) -> None:
97
+ super().__init__(message, status=status, detail=detail)
98
+ d = detail if isinstance(detail, dict) else {}
99
+ self.error: str | None = d.get("error")
100
+ self.plan: str | None = d.get("plan")
101
+ self.upgrade_url: str | None = d.get("upgrade_url")
102
+ self.remaining_minutes: float | None = d.get("remaining_minutes")
103
+ self.quota_percentage: float | None = d.get("quota_percentage")
104
+
105
+
106
+ class ServiceUnavailableError(LingolixError):
107
+ """The scoring service is temporarily unreachable (HTTP 503)."""
108
+
109
+
110
+ _ERRORS: dict[int, type[LingolixError]] = {
111
+ 400: InvalidAudioError,
112
+ 401: AuthenticationError,
113
+ 403: SubscriptionError,
114
+ 413: InvalidAudioError,
115
+ 429: QuotaExceededError,
116
+ 503: ServiceUnavailableError,
117
+ }
118
+
119
+
120
+ # --------------------------------------------------------------------------- results
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class Syllable:
125
+ """One syllable of a word, with expected vs. detected pronunciation."""
126
+
127
+ text: str
128
+ expected_ipa: str
129
+ detected_ipa: str
130
+ accuracy: float
131
+ completeness: float
132
+ pitch: Pitch
133
+ duration_ms: float
134
+ start_ms: float
135
+ end_ms: float
136
+ is_missing: bool
137
+ is_extra: bool
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class Word:
142
+ """One word, broken into syllables."""
143
+
144
+ text: str
145
+ syllables: list[Syllable]
146
+ accuracy: float
147
+ completeness: float
148
+ start_ms: float
149
+ end_ms: float
150
+ char_start: int = -1
151
+ char_end: int = -1
152
+
153
+
154
+ @dataclass(frozen=True)
155
+ class PronunciationResult:
156
+ """The scored utterance.
157
+
158
+ ``text`` is the reference the audio was scored against: the ``sentence`` you
159
+ passed, or the transcription when you passed none.
160
+ """
161
+
162
+ text: str
163
+ speaking_rate: float
164
+ accuracy: float
165
+ completeness: float
166
+ words: list[Word] = field(default_factory=list)
167
+
168
+
169
+ def _mk(cls: type[T], data: dict[str, Any]) -> T:
170
+ # ponytail: drop unknown keys so a new backend field doesn't break installed clients
171
+ names = {f.name for f in dataclasses.fields(cls)} # type: ignore[arg-type]
172
+ return cls(**{k: v for k, v in data.items() if k in names})
173
+
174
+
175
+ def _to_result(data: dict[str, Any]) -> PronunciationResult:
176
+ words = [
177
+ _mk(Word, {**w, "syllables": [_mk(Syllable, s) for s in w.get("syllables", [])]})
178
+ for w in data.get("words", [])
179
+ ]
180
+ return _mk(PronunciationResult, {**data, "words": words})
181
+
182
+
183
+ # --------------------------------------------------------------------------- clients
184
+
185
+
186
+ class _BaseClient:
187
+ def __init__(
188
+ self,
189
+ api_key: str | None = None,
190
+ *,
191
+ base_url: str | None = None,
192
+ timeout: float | httpx.Timeout | None = None,
193
+ ) -> None:
194
+ key = api_key or os.environ.get("LINGOLIX_API_KEY")
195
+ if not key:
196
+ raise LingolixError(
197
+ "No API key. Pass Lingolix(api_key=...) or set the LINGOLIX_API_KEY "
198
+ "environment variable. Create a key at https://lingolix.com/api-portal"
199
+ )
200
+ self.api_key = key
201
+ self.base_url = (
202
+ base_url or os.environ.get("LINGOLIX_BASE_URL") or DEFAULT_BASE_URL
203
+ ).rstrip("/")
204
+ # Scoring is slow; a short default timeout would just cause spurious failures.
205
+ self.timeout = timeout if timeout is not None else httpx.Timeout(120.0, connect=10.0)
206
+
207
+ @property
208
+ def _headers(self) -> dict[str, str]:
209
+ return {
210
+ "Authorization": f"Bearer {self.api_key}",
211
+ "User-Agent": f"lingolix-python/{__version__}",
212
+ }
213
+
214
+ @staticmethod
215
+ def _multipart(
216
+ audio: AudioInput, sentence: str | None, language: str
217
+ ) -> tuple[dict[str, Any], dict[str, str]]:
218
+ if isinstance(audio, (bytes, bytearray)):
219
+ content: Any = bytes(audio)
220
+ name, mime = "audio.wav", "application/octet-stream"
221
+ elif isinstance(audio, (str, os.PathLike)):
222
+ path = os.fspath(audio)
223
+ with open(path, "rb") as fh:
224
+ content = fh.read()
225
+ name = os.path.basename(path) or "audio.wav"
226
+ mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
227
+ else: # file-like
228
+ content = audio.read()
229
+ name = os.path.basename(getattr(audio, "name", "") or "") or "audio.wav"
230
+ mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
231
+
232
+ if len(content) > MAX_UPLOAD_BYTES:
233
+ raise InvalidAudioError(
234
+ f"Audio is {len(content) / 1048576:.1f} MiB, over the "
235
+ f"{MAX_UPLOAD_BYTES // 1048576} MiB limit. Send a compressed format "
236
+ "(MP3, Opus/WebM, AAC) instead of WAV — a single utterance is well "
237
+ "under 1 MiB when encoded."
238
+ )
239
+
240
+ data = {"language_code": language}
241
+ if sentence is not None:
242
+ data["sentence"] = sentence
243
+ return {"speechdata": (name, content, mime)}, data
244
+
245
+ @staticmethod
246
+ def _parse(response: httpx.Response) -> PronunciationResult:
247
+ if response.status_code == 200:
248
+ return _to_result(response.json())
249
+
250
+ try:
251
+ # FastAPI puts a string here normally, and a dict for the 429 quota payload.
252
+ detail = response.json().get("detail")
253
+ except ValueError:
254
+ detail = response.text or None
255
+
256
+ if isinstance(detail, dict):
257
+ message = detail.get("message") or detail.get("error") or str(detail)
258
+ else:
259
+ message = detail or f"HTTP {response.status_code}"
260
+
261
+ cls = _ERRORS.get(response.status_code, LingolixError)
262
+ raise cls(str(message), status=response.status_code, detail=detail)
263
+
264
+
265
+ class Lingolix(_BaseClient):
266
+ """Synchronous client. Usable as a context manager."""
267
+
268
+ def __init__(self, api_key: str | None = None, **kwargs: Any) -> None:
269
+ super().__init__(api_key, **kwargs)
270
+ self._client = httpx.Client(
271
+ base_url=self.base_url, headers=self._headers, timeout=self.timeout
272
+ )
273
+
274
+ def check(
275
+ self,
276
+ audio: AudioInput,
277
+ *,
278
+ sentence: str | None = None,
279
+ language: str = "ja",
280
+ ) -> PronunciationResult:
281
+ """Score ``audio``.
282
+
283
+ Pass ``sentence`` to score against known text; omit it to have the audio
284
+ transcribed first and scored against its own transcription.
285
+ """
286
+ files, data = self._multipart(audio, sentence, language)
287
+ return self._parse(self._client.post(CHECK_PATH, files=files, data=data))
288
+
289
+ def close(self) -> None:
290
+ self._client.close()
291
+
292
+ def __enter__(self) -> Lingolix:
293
+ return self
294
+
295
+ def __exit__(self, *exc: Any) -> None:
296
+ self.close()
297
+
298
+
299
+ class AsyncLingolix(_BaseClient):
300
+ """Asynchronous client. Usable as an async context manager."""
301
+
302
+ def __init__(self, api_key: str | None = None, **kwargs: Any) -> None:
303
+ super().__init__(api_key, **kwargs)
304
+ self._client = httpx.AsyncClient(
305
+ base_url=self.base_url, headers=self._headers, timeout=self.timeout
306
+ )
307
+
308
+ async def check(
309
+ self,
310
+ audio: AudioInput,
311
+ *,
312
+ sentence: str | None = None,
313
+ language: str = "ja",
314
+ ) -> PronunciationResult:
315
+ """Score ``audio``. See :meth:`Lingolix.check`."""
316
+ files, data = self._multipart(audio, sentence, language)
317
+ return self._parse(await self._client.post(CHECK_PATH, files=files, data=data))
318
+
319
+ async def aclose(self) -> None:
320
+ await self._client.aclose()
321
+
322
+ async def __aenter__(self) -> AsyncLingolix:
323
+ return self
324
+
325
+ async def __aexit__(self, *exc: Any) -> None:
326
+ await self.aclose()
File without changes
@@ -0,0 +1,205 @@
1
+ """Client tests against a mocked transport — no network, no extra dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ import httpx
8
+ import pytest
9
+
10
+ from lingolix import (
11
+ AsyncLingolix,
12
+ AuthenticationError,
13
+ InvalidAudioError,
14
+ Lingolix,
15
+ QuotaExceededError,
16
+ ServiceUnavailableError,
17
+ )
18
+
19
+ KEY = "lx_test_key"
20
+
21
+ # A real-shaped v3 response, plus an unknown field the backend might add later.
22
+ OK_BODY = {
23
+ "text": "Guten Morgen",
24
+ "speaking_rate": 3.5,
25
+ "accuracy": 0.87,
26
+ "completeness": 1.0,
27
+ "future_field": "ignored",
28
+ "words": [
29
+ {
30
+ "text": "Guten",
31
+ "accuracy": 0.9,
32
+ "completeness": 1.0,
33
+ "start_ms": 0.0,
34
+ "end_ms": 400.0,
35
+ "char_start": 0,
36
+ "char_end": 5,
37
+ "syllables": [
38
+ {
39
+ "text": "Gu",
40
+ "expected_ipa": "ɡuː",
41
+ "detected_ipa": "ɡu",
42
+ "accuracy": 0.92,
43
+ "completeness": 1.0,
44
+ "pitch": "high",
45
+ "duration_ms": 200.0,
46
+ "start_ms": 0.0,
47
+ "end_ms": 200.0,
48
+ "is_missing": False,
49
+ "is_extra": False,
50
+ "future_field": "ignored",
51
+ }
52
+ ],
53
+ }
54
+ ],
55
+ }
56
+
57
+ QUOTA_BODY = {
58
+ "detail": {
59
+ "error": "quota_exceeded",
60
+ "message": "Monthly quota exhausted.",
61
+ "quota_percentage": 100.0,
62
+ "remaining_minutes": 0.0,
63
+ "plan": "free",
64
+ "upgrade_url": "https://lingolix.com/api-portal/pricing",
65
+ }
66
+ }
67
+
68
+
69
+ def make_client(handler, *, cls=Lingolix):
70
+ """Build a client whose HTTP layer is `handler` instead of the network."""
71
+ client = cls(KEY)
72
+ transport = httpx.MockTransport(handler)
73
+ inner = client._client
74
+ client._client = type(inner)(
75
+ base_url=inner.base_url, headers=inner.headers, transport=transport
76
+ )
77
+ return client
78
+
79
+
80
+ def test_request_shape():
81
+ seen = {}
82
+
83
+ def handler(request: httpx.Request) -> httpx.Response:
84
+ seen["url"] = str(request.url)
85
+ seen["auth"] = request.headers["authorization"]
86
+ seen["body"] = request.content.decode("utf-8", "replace")
87
+ return httpx.Response(200, json=OK_BODY)
88
+
89
+ with make_client(handler) as client:
90
+ client.check(b"RIFFfake", sentence="Guten Morgen", language="de")
91
+
92
+ assert seen["url"] == "https://api.lingolix.com/api/pronunciation/v3/check"
93
+ assert seen["auth"] == f"Bearer {KEY}"
94
+ assert 'name="speechdata"' in seen["body"]
95
+ assert 'name="sentence"' in seen["body"] and "Guten Morgen" in seen["body"]
96
+ assert 'name="language_code"' in seen["body"] and "de" in seen["body"]
97
+
98
+
99
+ def test_sentence_omitted_when_none():
100
+ seen = {}
101
+
102
+ def handler(request: httpx.Request) -> httpx.Response:
103
+ seen["body"] = request.content.decode("utf-8", "replace")
104
+ return httpx.Response(200, json=OK_BODY)
105
+
106
+ with make_client(handler) as client:
107
+ client.check(b"RIFFfake")
108
+
109
+ assert 'name="sentence"' not in seen["body"]
110
+ assert 'name="language_code"' in seen["body"]
111
+
112
+
113
+ def test_parses_nested_result_and_ignores_unknown_fields():
114
+ with make_client(lambda r: httpx.Response(200, json=OK_BODY)) as client:
115
+ result = client.check(b"RIFFfake", sentence="Guten Morgen", language="de")
116
+
117
+ assert result.accuracy == 0.87
118
+ assert result.text == "Guten Morgen"
119
+ assert result.words[0].text == "Guten"
120
+ syllable = result.words[0].syllables[0]
121
+ assert syllable.expected_ipa == "ɡuː"
122
+ assert syllable.pitch == "high"
123
+ assert syllable.is_missing is False
124
+
125
+
126
+ def test_quota_error_exposes_upgrade_url():
127
+ handler = lambda r: httpx.Response(429, json=QUOTA_BODY) # noqa: E731
128
+ with make_client(handler) as client:
129
+ with pytest.raises(QuotaExceededError) as excinfo:
130
+ client.check(b"RIFFfake")
131
+
132
+ err = excinfo.value
133
+ assert err.status == 429
134
+ assert err.plan == "free"
135
+ assert err.upgrade_url == "https://lingolix.com/api-portal/pricing"
136
+ assert "quota" in str(err).lower()
137
+
138
+
139
+ @pytest.mark.parametrize(
140
+ ("status", "expected"),
141
+ [
142
+ (401, AuthenticationError),
143
+ (400, InvalidAudioError),
144
+ (503, ServiceUnavailableError),
145
+ ],
146
+ )
147
+ def test_string_detail_errors(status, expected):
148
+ handler = lambda r: httpx.Response(status, json={"detail": "nope"}) # noqa: E731
149
+ with make_client(handler) as client:
150
+ with pytest.raises(expected, match="nope"):
151
+ client.check(b"RIFFfake")
152
+
153
+
154
+ def test_oversized_audio_rejected_before_request():
155
+ def handler(request: httpx.Request) -> httpx.Response: # pragma: no cover
156
+ raise AssertionError("request should not have been sent")
157
+
158
+ with make_client(handler) as client:
159
+ with pytest.raises(InvalidAudioError, match="10 MiB limit"):
160
+ client.check(b"x" * (10 * 1024 * 1024 + 1))
161
+
162
+
163
+ def test_missing_api_key(monkeypatch):
164
+ monkeypatch.delenv("LINGOLIX_API_KEY", raising=False)
165
+ with pytest.raises(Exception, match="LINGOLIX_API_KEY"):
166
+ Lingolix()
167
+
168
+
169
+ def test_file_path_upload(tmp_path):
170
+ path = tmp_path / "sample.wav"
171
+ path.write_bytes(b"RIFFfake")
172
+ seen = {}
173
+
174
+ def handler(request: httpx.Request) -> httpx.Response:
175
+ seen["body"] = request.content.decode("utf-8", "replace")
176
+ return httpx.Response(200, json=OK_BODY)
177
+
178
+ with make_client(handler) as client:
179
+ client.check(path, language="ja")
180
+
181
+ assert "sample.wav" in seen["body"]
182
+ assert "RIFFfake" in seen["body"]
183
+
184
+
185
+ def test_async_client():
186
+ async def run():
187
+ client = make_client(lambda r: httpx.Response(200, json=OK_BODY), cls=AsyncLingolix)
188
+ async with client:
189
+ return await client.check(b"RIFFfake", sentence="Guten Morgen", language="de")
190
+
191
+ result = asyncio.run(run())
192
+ assert result.accuracy == 0.87
193
+ assert result.words[0].syllables[0].text == "Gu"
194
+
195
+
196
+ def test_async_quota_error():
197
+ async def run():
198
+ client = make_client(
199
+ lambda r: httpx.Response(429, json=QUOTA_BODY), cls=AsyncLingolix
200
+ )
201
+ async with client:
202
+ await client.check(b"RIFFfake")
203
+
204
+ with pytest.raises(QuotaExceededError):
205
+ asyncio.run(run())