Skip to content

Commit 04b2d35

Browse files
hive-botclaude
andcommitted
fix: raise DeepLException for malformed style profile responses
StyleProfile.from_json raised a raw KeyError for a missing style_profile_id or name and a raw TypeError for a non-object body, and returned None for a missing or malformed creation_time or updated_time. All of these now raise DeepLException; creation_time and updated_time are typed as datetime. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 4ac7df7 commit 04b2d35

6 files changed

Lines changed: 121 additions & 29 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7070
`create_style_profile()` and `update_style_profile()` accept IDs or
7171
`GlossaryInfo`, `MultilingualGlossaryInfo`, `StyleRuleInfo` and
7272
`TranslationMemoryInfo` objects, and both functions accept
73-
`extra_body_parameters`.
73+
`extra_body_parameters`. A style profile response that is not a JSON object,
74+
or lacks a valid `style_profile_id`, `name`, `creation_time` or
75+
`updated_time`, raises `DeepLException`.
7476
- Added the `TagHandlingMode` (`HTML`, `XML`) and `TagHandlingVersion` (`V1`,
7577
`V2`) enums. The `tag_handling` and `tag_handling_version` options of
7678
`translate_text()` accept them as well as strings.

‎deepl/api_data.py‎

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1466,10 +1466,8 @@ class StyleProfile:
14661466
:param translation_memory_ids: IDs of the linked translation memories.
14671467
:param glossary_priority_order_enabled: Whether the order of glossary_ids
14681468
decides which glossary wins when glossaries overlap.
1469-
:param creation_time: Timestamp when the style profile was created, or
1470-
None if the response has none or it cannot be parsed.
1471-
:param updated_time: Timestamp of the last update, or None if the
1472-
response has none or it cannot be parsed.
1469+
:param creation_time: Timestamp when the style profile was created.
1470+
:param updated_time: Timestamp of the last update.
14731471
"""
14741472

14751473
def __init__(
@@ -1482,8 +1480,8 @@ def __init__(
14821480
style_rule_ids: List[str],
14831481
translation_memory_ids: List[str],
14841482
glossary_priority_order_enabled: bool,
1485-
creation_time: Optional[datetime.datetime],
1486-
updated_time: Optional[datetime.datetime],
1483+
creation_time: datetime.datetime,
1484+
updated_time: datetime.datetime,
14871485
):
14881486
self._style_profile_id = style_profile_id
14891487
self._name = name
@@ -1501,7 +1499,31 @@ def __str__(self) -> str:
15011499

15021500
@staticmethod
15031501
def from_json(json) -> "StyleProfile":
1504-
"""Create StyleProfile from the given API JSON object."""
1502+
"""Create StyleProfile from the given API JSON object.
1503+
1504+
:raises DeepLException: If the response is not a JSON object, or a
1505+
required field is missing or malformed.
1506+
"""
1507+
# Imported here: deepl.exceptions imports this module.
1508+
from deepl.exceptions import DeepLException
1509+
1510+
if not isinstance(json, dict):
1511+
raise DeepLException("Style profile response is not a JSON object")
1512+
for field in ("style_profile_id", "name"):
1513+
if not isinstance(json.get(field), str):
1514+
raise DeepLException(
1515+
f"Style profile response is missing {field}"
1516+
)
1517+
1518+
def required_timestamp(field: str) -> datetime.datetime:
1519+
timestamp = parse_optional_timestamp(json.get(field))
1520+
if timestamp is None:
1521+
raise DeepLException(
1522+
f"Style profile response has a missing or malformed "
1523+
f"{field}"
1524+
)
1525+
return timestamp
1526+
15051527
return StyleProfile(
15061528
json["style_profile_id"],
15071529
json["name"],
@@ -1511,8 +1533,8 @@ def from_json(json) -> "StyleProfile":
15111533
_string_list(json.get("style_rule_ids")),
15121534
_string_list(json.get("translation_memory_ids")),
15131535
json.get("glossary_priority_order_enabled") is True,
1514-
parse_optional_timestamp(json.get("creation_time")),
1515-
parse_optional_timestamp(json.get("updated_time")),
1536+
required_timestamp("creation_time"),
1537+
required_timestamp("updated_time"),
15161538
)
15171539

15181540
@property
@@ -1548,11 +1570,11 @@ def glossary_priority_order_enabled(self) -> bool:
15481570
return self._glossary_priority_order_enabled
15491571

15501572
@property
1551-
def creation_time(self) -> Optional[datetime.datetime]:
1573+
def creation_time(self) -> datetime.datetime:
15521574
return self._creation_time
15531575

15541576
@property
1555-
def updated_time(self) -> Optional[datetime.datetime]:
1577+
def updated_time(self) -> datetime.datetime:
15561578
return self._updated_time
15571579

15581580

‎deepl/deepl_client.py‎

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1302,16 +1302,17 @@ def list_style_profiles(
13021302
status, content, json = self._api_call(endpoint, method="GET")
13031303
self._raise_for_status(status, content, json)
13041304

1305-
style_profiles = (
1306-
json.get("style_profiles") or []
1307-
if (json and isinstance(json, dict))
1308-
else []
1309-
)
1310-
return [
1311-
StyleProfile.from_json(style_profile)
1312-
for style_profile in style_profiles
1313-
if isinstance(style_profile, dict)
1314-
]
1305+
if not isinstance(json, dict):
1306+
raise DeepLException(
1307+
"Style profile list response is not a JSON object"
1308+
)
1309+
style_profiles = json.get("style_profiles") or []
1310+
if not isinstance(style_profiles, list):
1311+
raise DeepLException(
1312+
"Style profile list response has a malformed style_profiles"
1313+
)
1314+
# from_json raises DeepLException for a malformed entry.
1315+
return [StyleProfile.from_json(p) for p in style_profiles]
13151316

13161317
def create_style_profile(
13171318
self,

‎tests/offline/test_style_profiles.py‎

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -216,11 +216,9 @@ def test_parses_a_profile_without_optional_fields():
216216
assert not hasattr(profile, "spoken_terms_ids")
217217

218218

219-
@pytest.mark.parametrize("creation_time", ["not a timestamp", 123])
220-
def test_tolerates_malformed_values(mocked, creation_time):
219+
def test_tolerates_malformed_optional_values(mocked):
221220
malformed = dict(
222221
STYLE_PROFILE_JSON,
223-
creation_time=creation_time,
224222
glossary_ids=["g1", 7, None, "g2"],
225223
translation_memory_ids=None,
226224
)
@@ -229,12 +227,10 @@ def test_tolerates_malformed_values(mocked, creation_time):
229227

230228
profile = make_deepl_client().get_style_profile(PROFILE_ID)
231229

232-
assert profile.creation_time is None
233230
assert profile.glossary_ids == ["g1", "g2"]
234231
assert profile.translation_memory_ids == []
235232
assert profile.glossary_priority_order_enabled is False
236233
assert profile.name == "Legal, German"
237-
assert profile.updated_time == utc(2026, 8, 6, 9, 12, 44, 108000)
238234

239235

240236
def test_maps_404_to_not_found(mocked):
@@ -322,3 +318,64 @@ def test_update_merges_extra_body_parameters_last(mocked):
322318
glossary_ids=[GLOSSARY],
323319
extra_body_parameters={"glossary_ids": ["x"], "extra": True},
324320
)
321+
322+
323+
def _without(field):
324+
return {k: v for k, v in STYLE_PROFILE_JSON.items() if k != field}
325+
326+
327+
@pytest.mark.parametrize(
328+
"body",
329+
[
330+
_without("style_profile_id"),
331+
_without("name"),
332+
_without("creation_time"),
333+
_without("updated_time"),
334+
dict(STYLE_PROFILE_JSON, creation_time="not a timestamp"),
335+
dict(STYLE_PROFILE_JSON, updated_time=123),
336+
dict(STYLE_PROFILE_JSON, name=None),
337+
],
338+
)
339+
def test_raises_for_a_missing_or_malformed_required_field(mocked, body):
340+
mocked.get(url(f"v3/style_profiles/{PROFILE_ID}"), json=body)
341+
342+
with pytest.raises(deepl.DeepLException):
343+
make_deepl_client().get_style_profile(PROFILE_ID)
344+
345+
346+
@pytest.mark.parametrize(
347+
"entry", [_without("style_profile_id"), "not an object", None]
348+
)
349+
def test_list_raises_for_a_malformed_entry(mocked, entry):
350+
mocked.get(
351+
url("v3/style_profiles"),
352+
json={"style_profiles": [STYLE_PROFILE_JSON, entry]},
353+
)
354+
355+
with pytest.raises(deepl.DeepLException):
356+
make_deepl_client().list_style_profiles()
357+
358+
359+
@pytest.mark.parametrize(
360+
"method, path, call",
361+
[
362+
("GET", "v3/style_profiles", lambda c: c.list_style_profiles()),
363+
("POST", "v3/style_profiles", lambda c: c.create_style_profile("n")),
364+
(
365+
"GET",
366+
f"v3/style_profiles/{PROFILE_ID}",
367+
lambda c: c.get_style_profile(PROFILE_ID),
368+
),
369+
(
370+
"PATCH",
371+
f"v3/style_profiles/{PROFILE_ID}",
372+
lambda c: c.update_style_profile(PROFILE_ID, name="n"),
373+
),
374+
],
375+
)
376+
@pytest.mark.parametrize("body", ["not json", "[]", "null"])
377+
def test_raises_for_a_non_object_body(mocked, method, path, call, body):
378+
mocked.add(method, url(path), body=body)
379+
380+
with pytest.raises(deepl.DeepLException):
381+
call(make_deepl_client())

‎tests/offline/test_translate_document.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,12 @@ def test_sends_all_options_as_form_fields(mocked):
207207
[
208208
"sp-1",
209209
deepl.StyleProfile.from_json(
210-
{"style_profile_id": "sp-1", "name": "profile"}
210+
{
211+
"style_profile_id": "sp-1",
212+
"name": "profile",
213+
"creation_time": "2026-10-26T16:27:10Z",
214+
"updated_time": "2026-10-26T16:27:10Z",
215+
}
211216
),
212217
],
213218
)

‎tests/offline/test_translate_text.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,12 @@ def style_rule_info(language="en"):
7676

7777
def style_profile():
7878
return deepl.StyleProfile.from_json(
79-
{"style_profile_id": "sp-1", "name": "profile"}
79+
{
80+
"style_profile_id": "sp-1",
81+
"name": "profile",
82+
"creation_time": "2026-10-26T16:27:10Z",
83+
"updated_time": "2026-10-26T16:27:10Z",
84+
}
8085
)
8186

8287

0 commit comments

Comments
 (0)