Skip to content

Commit 31113ed

Browse files
hive-botclaude
andcommitted
fix: raise DeepLException for malformed /v3/languages responses
get_languages_for_resource() and get_language_resources() returned an empty list for a response that was not a list, let raw KeyError or TypeError escape when an entry lacked a required field, and turned a null usable_as_source/usable_as_target into False. ResourceFeature.needs_source_support/needs_target_support were None when the API omitted them. Malformed responses now raise DeepLException and both flags default to False. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 2ef5d75 commit 31113ed

5 files changed

Lines changed: 158 additions & 27 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4040
- Added `get_languages_for_resource()` method to the `DeepLClient` class to
4141
retrieve the languages supported by a resource, with per-feature support
4242
information, via the `/v3/languages` endpoint. Resources are given as
43-
`LanguageResourceName` values or strings.
43+
`LanguageResourceName` values or strings; results are `LanguageSupport`
44+
objects. Malformed responses raise `DeepLException`.
4445
- Added `get_language_resources()` method to the `DeepLClient` class to
4546
retrieve the API resources and their features via the
4647
`/v3/languages/resources` endpoint. Results are returned as
47-
`LanguageResource` and `ResourceFeature` objects.
48+
`LanguageResource` and `ResourceFeature` objects; `needs_source_support`
49+
and `needs_target_support` are `False` when the API omits them.
4850
- Added `Language` constants for the target variants `de-CH`, `de-DE`,
4951
`es-ES`, `fr-CA`, and `fr-FR`.
5052
- Added the `enable_watermark` option to `translate_document()`,

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -821,8 +821,8 @@ a list of `LanguageResource` objects. Each has `name` (the resource name),
821821
`features` (a map from feature name to `ResourceFeature`) and
822822
`supports(feature)`. A `ResourceFeature` has `name`, `needs_source_support` and
823823
`needs_target_support`: `True` means the source (or target) language must
824-
support the feature for it to be available, `None` means the API did not
825-
specify it.
824+
support the feature for it to be available; they are `False` when the API
825+
omits them.
826826

827827
```python
828828
for resource in deepl_client.get_language_resources():

‎deepl/api_data.py‎

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -569,22 +569,30 @@ def target_lang(self) -> str:
569569
return self._target_lang
570570

571571

572+
def _required(json, key: str):
573+
"""Returns json[key]; raises KeyError if the field is missing or null."""
574+
value = json[key]
575+
if value is None:
576+
raise KeyError(key)
577+
return value
578+
579+
572580
class ResourceFeature:
573581
"""A feature supported by a DeepL API resource, as returned by
574582
DeepLClient.get_language_resources().
575583
576584
:param name: The feature name, for example "glossary" or "formality".
577585
:param needs_source_support: True if the source language must support
578-
the feature for it to be available, None if not specified.
586+
the feature for it to be available; False if the API omits it.
579587
:param needs_target_support: True if the target language must support
580-
the feature for it to be available, None if not specified.
588+
the feature for it to be available; False if the API omits it.
581589
"""
582590

583591
def __init__(
584592
self,
585593
name: str,
586-
needs_source_support: Optional[bool] = None,
587-
needs_target_support: Optional[bool] = None,
594+
needs_source_support: bool = False,
595+
needs_target_support: bool = False,
588596
):
589597
self._name = name
590598
self._needs_source_support = needs_source_support
@@ -594,9 +602,9 @@ def __init__(
594602
def from_json(json) -> "ResourceFeature":
595603
"""Create ResourceFeature from the given API JSON object."""
596604
return ResourceFeature(
597-
name=json["name"],
598-
needs_source_support=json.get("needs_source_support"),
599-
needs_target_support=json.get("needs_target_support"),
605+
name=_required(json, "name"),
606+
needs_source_support=bool(json.get("needs_source_support")),
607+
needs_target_support=bool(json.get("needs_target_support")),
600608
)
601609

602610
def __str__(self) -> str:
@@ -608,15 +616,15 @@ def name(self) -> str:
608616
return self._name
609617

610618
@property
611-
def needs_source_support(self) -> Optional[bool]:
619+
def needs_source_support(self) -> bool:
612620
"""Returns True if the source language must support this feature,
613-
or None if the API did not specify it."""
621+
False otherwise (also when the API did not specify it)."""
614622
return self._needs_source_support
615623

616624
@property
617-
def needs_target_support(self) -> Optional[bool]:
625+
def needs_target_support(self) -> bool:
618626
"""Returns True if the target language must support this feature,
619-
or None if the API did not specify it."""
627+
False otherwise (also when the API did not specify it)."""
620628
return self._needs_target_support
621629

622630

@@ -641,7 +649,7 @@ def from_json(json) -> "LanguageResource":
641649
for feature in json.get("features") or []
642650
]
643651
return LanguageResource(
644-
name=json["name"],
652+
name=_required(json, "name"),
645653
features={feature.name: feature for feature in features},
646654
)
647655

@@ -2271,7 +2279,7 @@ def __init__(self, status: str):
22712279
@staticmethod
22722280
def from_json(json) -> "LanguageFeature":
22732281
"""Create LanguageFeature from the given API JSON object."""
2274-
return LanguageFeature(status=json["status"])
2282+
return LanguageFeature(status=_required(json, "status"))
22752283

22762284
@property
22772285
def status(self) -> str:
@@ -2319,11 +2327,11 @@ def from_json(json) -> "LanguageSupport":
23192327
"""Create LanguageSupport from the given API JSON object."""
23202328
features = json.get("features") or {}
23212329
return LanguageSupport(
2322-
code=json["lang"],
2323-
name=json["name"],
2324-
status=json["status"],
2325-
usable_as_source=bool(json["usable_as_source"]),
2326-
usable_as_target=bool(json["usable_as_target"]),
2330+
code=_required(json, "lang"),
2331+
name=_required(json, "name"),
2332+
status=_required(json, "status"),
2333+
usable_as_source=bool(_required(json, "usable_as_source")),
2334+
usable_as_target=bool(_required(json, "usable_as_target")),
23272335
features={
23282336
name: LanguageFeature.from_json(feature)
23292337
for name, feature in features.items()

‎deepl/deepl_client.py‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,8 +1001,15 @@ def get_languages_for_resource(
10011001
status, content, json = self._api_call(endpoint, method="GET")
10021002
self._raise_for_status(status, content, json)
10031003

1004-
languages = json if (json and isinstance(json, list)) else []
1005-
return [LanguageSupport.from_json(lang) for lang in languages]
1004+
if not isinstance(json, list):
1005+
raise DeepLException("Languages response is not a list")
1006+
languages = json
1007+
try:
1008+
return [LanguageSupport.from_json(lang) for lang in languages]
1009+
except (AttributeError, KeyError, TypeError, ValueError) as e:
1010+
raise DeepLException(
1011+
f"Malformed language in response: {e!r}"
1012+
) from e
10061013

10071014
def get_language_resources(self) -> List[LanguageResource]:
10081015
"""Retrieves the resources supported by the DeepL API, along with
@@ -1015,8 +1022,15 @@ def get_language_resources(self) -> List[LanguageResource]:
10151022
"v3/languages/resources", method="GET"
10161023
)
10171024
self._raise_for_status(status, content, json)
1018-
resources = json if (json and isinstance(json, list)) else []
1019-
return [LanguageResource.from_json(r) for r in resources]
1025+
if not isinstance(json, list):
1026+
raise DeepLException("Language resources response is not a list")
1027+
resources = json
1028+
try:
1029+
return [LanguageResource.from_json(r) for r in resources]
1030+
except (AttributeError, KeyError, TypeError, ValueError) as e:
1031+
raise DeepLException(
1032+
f"Malformed language resource in response: {e!r}"
1033+
) from e
10201034

10211035
def get_all_style_rules(
10221036
self,

‎tests/offline/test_languages.py‎

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,10 @@ def test_parses_language_resources(mocked):
212212
assert translate_text.supports("glossary")
213213
assert not translate_text.supports("nonexistent")
214214
glossary = translate_text.features["glossary"]
215-
assert translate_text.features["formality"].needs_source_support is None
215+
assert glossary.needs_source_support is True
216+
formality = translate_text.features["formality"]
217+
assert formality.needs_source_support is False
218+
assert formality.needs_target_support is False
216219
assert str(glossary) == "glossary"
217220
assert voice.features == {}
218221

@@ -268,3 +271,107 @@ def test_raises_deepl_exception_for_malformed_languages(
268271

269272
with pytest.raises(deepl.DeepLException, match="Malformed language"):
270273
getattr(make_deepl_client(), method)()
274+
275+
276+
@pytest.mark.parametrize(
277+
"language",
278+
[
279+
{
280+
"name": "German",
281+
"status": "stable",
282+
"usable_as_source": True,
283+
"usable_as_target": True,
284+
},
285+
{
286+
"lang": "de",
287+
"name": None,
288+
"status": "stable",
289+
"usable_as_source": True,
290+
"usable_as_target": True,
291+
},
292+
{
293+
"lang": "de",
294+
"name": "German",
295+
"status": "stable",
296+
"usable_as_target": True,
297+
},
298+
{
299+
"lang": "de",
300+
"name": "German",
301+
"status": "stable",
302+
"usable_as_source": None,
303+
"usable_as_target": True,
304+
},
305+
{
306+
"lang": "de",
307+
"name": "German",
308+
"status": "stable",
309+
"usable_as_source": True,
310+
"usable_as_target": None,
311+
},
312+
{
313+
"lang": "de",
314+
"name": "German",
315+
"status": "stable",
316+
"usable_as_source": True,
317+
"usable_as_target": True,
318+
"features": {"formality": {}},
319+
},
320+
{
321+
"lang": "de",
322+
"name": "German",
323+
"status": "stable",
324+
"usable_as_source": True,
325+
"usable_as_target": True,
326+
"features": ["formality"],
327+
},
328+
"de",
329+
],
330+
)
331+
def test_raises_deepl_exception_for_malformed_languages_for_resource(
332+
mocked, language
333+
):
334+
mocked.get(url("v3/languages"), json=[language])
335+
336+
with pytest.raises(deepl.DeepLException, match="Malformed language"):
337+
make_deepl_client().get_languages_for_resource("translate_text")
338+
339+
340+
@pytest.mark.parametrize(
341+
"resource",
342+
[
343+
{"features": []},
344+
{"name": None},
345+
{"name": "translate_text", "features": [{"needs_source_support": 1}]},
346+
{"name": "translate_text", "features": ["glossary"]},
347+
"translate_text",
348+
],
349+
)
350+
def test_raises_deepl_exception_for_malformed_language_resources(
351+
mocked, resource
352+
):
353+
mocked.get(url("v3/languages/resources"), json=[resource])
354+
355+
with pytest.raises(
356+
deepl.DeepLException, match="Malformed language resource"
357+
):
358+
make_deepl_client().get_language_resources()
359+
360+
361+
@pytest.mark.parametrize("body", [{"message": "unexpected"}, "text"])
362+
@pytest.mark.parametrize(
363+
"path, method",
364+
[
365+
("v3/languages", "get_languages_for_resource"),
366+
("v3/languages/resources", "get_language_resources"),
367+
],
368+
)
369+
def test_raises_deepl_exception_for_non_list_language_reply(
370+
mocked, path, method, body
371+
):
372+
mocked.get(url(path), json=body)
373+
client = make_deepl_client()
374+
args = ["translate_text"] if method == "get_languages_for_resource" else []
375+
376+
with pytest.raises(deepl.DeepLException, match="not a list"):
377+
getattr(client, method)(*args)

0 commit comments

Comments
 (0)