Skip to content

Commit af2fc6f

Browse files
Merge pull request #3218 from VWS-Python/fix/report-json-boundaries
Validate report response values
2 parents 5e48f8b + 184d89c commit af2fc6f

3 files changed

Lines changed: 109 additions & 26 deletions

File tree

‎newsfragments/3218.change.rst‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Validate query and Model Target report response values at construction time.

‎src/vws/reports.py‎

Lines changed: 73 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,42 @@
33
import csv
44
import datetime
55
import io
6-
from collections.abc import Sequence
6+
from collections.abc import Mapping, Sequence
77
from dataclasses import dataclass
88
from enum import Enum, unique
9-
from typing import Any, Self
9+
from typing import Any, Self, TypeIs
1010

1111
from beartype import BeartypeConf, beartype
12+
from beartype.door import TypeHint
13+
14+
15+
def _checked[T](value: object, hint: type[T], /) -> T:
16+
"""Return a value after checking its runtime type."""
17+
if not _is_type(value, hint):
18+
msg = f"Expected {hint!r}, got {value!r}."
19+
raise TypeError(msg)
20+
return value
21+
22+
23+
def _is_type[T](value: object, hint: type[T], /) -> TypeIs[T]:
24+
"""Return whether a value satisfies a runtime type."""
25+
return TypeHint(hint=hint).is_bearable(obj=value)
26+
27+
28+
def _number(value: object, /) -> int | float:
29+
"""Return a runtime-validated JSON number."""
30+
if isinstance(value, bool) or not isinstance(value, int | float):
31+
msg = f"Expected a number, got {value!r}."
32+
raise TypeError(msg)
33+
return value
34+
35+
36+
def _optional_string(value: object, /) -> str | None:
37+
"""Return a runtime-validated optional string."""
38+
if value is not None and not isinstance(value, str):
39+
msg = f"Expected an optional string, got {value!r}."
40+
raise TypeError(msg)
41+
return value
1242

1343

1444
@beartype
@@ -143,23 +173,29 @@ class QueryResult:
143173
target_data: TargetData | None
144174

145175
@classmethod
146-
def from_response_dict(cls, response_dict: dict[str, Any]) -> Self: # pyrefly: ignore [explicit-any]
176+
def from_response_dict(
177+
cls,
178+
response_dict: Mapping[str, object],
179+
) -> Self:
147180
"""Construct from a VWS API query result item dict."""
148181
target_data: TargetData | None = None
149182
if "target_data" in response_dict:
150-
target_data_dict = response_dict["target_data"]
183+
target_data_dict = _checked(
184+
response_dict["target_data"], dict[str, object]
185+
)
151186
target_timestamp = datetime.datetime.fromtimestamp(
152-
timestamp=target_data_dict["target_timestamp"], # pyrefly: ignore [unknown-argument-type]
187+
timestamp=_number(target_data_dict["target_timestamp"]),
153188
tz=datetime.UTC,
154189
)
155190
target_data = TargetData(
156-
name=target_data_dict["name"], # pyrefly: ignore [unknown-argument-type]
157-
# pyrefly: ignore [unknown-argument-type]
158-
application_metadata=target_data_dict["application_metadata"],
191+
name=_checked(target_data_dict["name"], str),
192+
application_metadata=_optional_string(
193+
target_data_dict["application_metadata"]
194+
),
159195
target_timestamp=target_timestamp,
160196
)
161197
return cls(
162-
target_id=response_dict["target_id"],
198+
target_id=_checked(response_dict["target_id"], str),
163199
target_data=target_data,
164200
)
165201

@@ -301,48 +337,59 @@ class ModelTargetDatasetStatusReport:
301337
"""
302338

303339
@classmethod
304-
def from_response_dict(cls, response_dict: dict[str, Any]) -> Self: # pyrefly: ignore [explicit-any]
340+
def from_response_dict(
341+
cls,
342+
response_dict: Mapping[str, object],
343+
) -> Self:
305344
"""Construct from a Model Target Web API response dict."""
306345
error: ModelTargetGenerationError | None = None
307346
if "error" in response_dict:
308-
error_dict = dict(response_dict["error"])
347+
error_dict = _checked(response_dict["error"], dict[str, object])
309348
error = ModelTargetGenerationError(
310-
code=error_dict["code"],
311-
message=error_dict["message"],
349+
code=_checked(error_dict["code"], str),
350+
message=_checked(error_dict["message"], str),
312351
)
313352

314353
warning: ModelTargetGenerationWarning | None = None
315354
if "warning" in response_dict:
316-
warning_dict = dict(response_dict["warning"])
355+
warning_dict = _checked(
356+
response_dict["warning"], dict[str, object]
357+
)
358+
details = _checked(
359+
warning_dict["details"], list[dict[str, object]]
360+
)
317361
warning = ModelTargetGenerationWarning(
318-
code=warning_dict["code"],
319-
message=warning_dict["message"],
320-
target=warning_dict["target"],
362+
code=_checked(warning_dict["code"], str),
363+
message=_checked(warning_dict["message"], str),
364+
target=_checked(warning_dict["target"], str),
321365
details=[
322366
ModelTargetGenerationDetail(
323-
code=detail["code"], # pyrefly: ignore [unknown-argument-type]
324-
# pyrefly: ignore [unknown-argument-type]
325-
message=detail["message"],
367+
code=_checked(detail["code"], str),
368+
message=_checked(detail["message"], str),
326369
)
327-
for detail in warning_dict["details"]
370+
for detail in details
328371
],
329372
)
330373

331374
eta: datetime.datetime | None = None
332375
if "eta" in response_dict:
333-
eta = datetime.datetime.fromisoformat(response_dict["eta"])
376+
eta = datetime.datetime.fromisoformat(
377+
_checked(response_dict["eta"], str),
378+
)
334379

335380
completed_at: datetime.datetime | None = None
336381
if "completedAt" in response_dict:
337382
completed_at = datetime.datetime.fromisoformat(
338-
response_dict["completedAt"],
383+
_checked(response_dict["completedAt"], str),
339384
)
340385

341386
return cls(
342-
status=ModelTargetDatasetStatuses(value=response_dict["status"]),
343-
dataset_uuid=response_dict["uuid"],
387+
status=ModelTargetDatasetStatuses(
388+
value=_checked(response_dict["status"], str),
389+
),
390+
dataset_uuid=_checked(response_dict["uuid"], str),
344391
created_at=datetime.datetime.fromisoformat(
345-
response_dict["createdAt"],
392+
_checked(response_dict["createdAt"], str),
346393
),
347394
eta=eta,
348395
completed_at=completed_at,

‎tests/test_reports.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Tests for public report constructors."""
2+
3+
import pytest
4+
5+
from vws.reports import QueryResult
6+
7+
8+
@pytest.mark.parametrize(
9+
argnames="response",
10+
argvalues=[
11+
{"target_id": 1},
12+
{
13+
"target_id": "target-id",
14+
"target_data": {
15+
"name": "target-name",
16+
"application_metadata": None,
17+
"target_timestamp": True,
18+
},
19+
},
20+
{
21+
"target_id": "target-id",
22+
"target_data": {
23+
"name": "target-name",
24+
"application_metadata": 1,
25+
"target_timestamp": 0,
26+
},
27+
},
28+
],
29+
)
30+
def test_query_result_rejects_invalid_response_values(
31+
*, response: dict[str, object]
32+
) -> None:
33+
"""Query reports reject values of the wrong type."""
34+
with pytest.raises(expected_exception=TypeError):
35+
_ = QueryResult.from_response_dict(response_dict=response)

0 commit comments

Comments
 (0)