From f2c18d0cf384b8b16aab4390dd5af37fcb56283e Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 9 Sep 2026 14:43:31 +0100 Subject: [PATCH 1/3] Validate report response values --- src/vws/reports.py | 99 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 73 insertions(+), 26 deletions(-) diff --git a/src/vws/reports.py b/src/vws/reports.py index ee6a0288f..431d6a644 100644 --- a/src/vws/reports.py +++ b/src/vws/reports.py @@ -3,12 +3,42 @@ import csv import datetime import io -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from dataclasses import dataclass from enum import Enum, unique -from typing import Any, Self +from typing import Any, Self, TypeIs from beartype import BeartypeConf, beartype +from beartype.door import TypeHint + + +def _checked[T](value: object, hint: type[T], /) -> T: + """Return a value after checking its runtime type.""" + if not _is_type(value, hint): + msg = f"Expected {hint!r}, got {value!r}." + raise TypeError(msg) + return value + + +def _is_type[T](value: object, hint: type[T], /) -> TypeIs[T]: + """Return whether a value satisfies a runtime type.""" + return TypeHint(hint=hint).is_bearable(obj=value) + + +def _number(value: object, /) -> int | float: + """Return a runtime-validated JSON number.""" + if isinstance(value, bool) or not isinstance(value, int | float): + msg = f"Expected a number, got {value!r}." + raise TypeError(msg) + return value + + +def _optional_string(value: object, /) -> str | None: + """Return a runtime-validated optional string.""" + if value is not None and not isinstance(value, str): + msg = f"Expected an optional string, got {value!r}." + raise TypeError(msg) + return value @beartype @@ -143,23 +173,29 @@ class QueryResult: target_data: TargetData | None @classmethod - def from_response_dict(cls, response_dict: dict[str, Any]) -> Self: # pyrefly: ignore [explicit-any] + def from_response_dict( + cls, + response_dict: Mapping[str, object], + ) -> Self: """Construct from a VWS API query result item dict.""" target_data: TargetData | None = None if "target_data" in response_dict: - target_data_dict = response_dict["target_data"] + target_data_dict = _checked( + response_dict["target_data"], dict[str, object] + ) target_timestamp = datetime.datetime.fromtimestamp( - timestamp=target_data_dict["target_timestamp"], # pyrefly: ignore [unknown-argument-type] + timestamp=_number(target_data_dict["target_timestamp"]), tz=datetime.UTC, ) target_data = TargetData( - name=target_data_dict["name"], # pyrefly: ignore [unknown-argument-type] - # pyrefly: ignore [unknown-argument-type] - application_metadata=target_data_dict["application_metadata"], + name=_checked(target_data_dict["name"], str), + application_metadata=_optional_string( + target_data_dict["application_metadata"] + ), target_timestamp=target_timestamp, ) return cls( - target_id=response_dict["target_id"], + target_id=_checked(response_dict["target_id"], str), target_data=target_data, ) @@ -301,48 +337,59 @@ class ModelTargetDatasetStatusReport: """ @classmethod - def from_response_dict(cls, response_dict: dict[str, Any]) -> Self: # pyrefly: ignore [explicit-any] + def from_response_dict( + cls, + response_dict: Mapping[str, object], + ) -> Self: """Construct from a Model Target Web API response dict.""" error: ModelTargetGenerationError | None = None if "error" in response_dict: - error_dict = dict(response_dict["error"]) + error_dict = _checked(response_dict["error"], dict[str, object]) error = ModelTargetGenerationError( - code=error_dict["code"], - message=error_dict["message"], + code=_checked(error_dict["code"], str), + message=_checked(error_dict["message"], str), ) warning: ModelTargetGenerationWarning | None = None if "warning" in response_dict: - warning_dict = dict(response_dict["warning"]) + warning_dict = _checked( + response_dict["warning"], dict[str, object] + ) + details = _checked( + warning_dict["details"], list[dict[str, object]] + ) warning = ModelTargetGenerationWarning( - code=warning_dict["code"], - message=warning_dict["message"], - target=warning_dict["target"], + code=_checked(warning_dict["code"], str), + message=_checked(warning_dict["message"], str), + target=_checked(warning_dict["target"], str), details=[ ModelTargetGenerationDetail( - code=detail["code"], # pyrefly: ignore [unknown-argument-type] - # pyrefly: ignore [unknown-argument-type] - message=detail["message"], + code=_checked(detail["code"], str), + message=_checked(detail["message"], str), ) - for detail in warning_dict["details"] + for detail in details ], ) eta: datetime.datetime | None = None if "eta" in response_dict: - eta = datetime.datetime.fromisoformat(response_dict["eta"]) + eta = datetime.datetime.fromisoformat( + _checked(response_dict["eta"], str), + ) completed_at: datetime.datetime | None = None if "completedAt" in response_dict: completed_at = datetime.datetime.fromisoformat( - response_dict["completedAt"], + _checked(response_dict["completedAt"], str), ) return cls( - status=ModelTargetDatasetStatuses(value=response_dict["status"]), - dataset_uuid=response_dict["uuid"], + status=ModelTargetDatasetStatuses( + value=_checked(response_dict["status"], str), + ), + dataset_uuid=_checked(response_dict["uuid"], str), created_at=datetime.datetime.fromisoformat( - response_dict["createdAt"], + _checked(response_dict["createdAt"], str), ), eta=eta, completed_at=completed_at, From 8e4063f4677cf2681f6f95b1d9a6e836e39b9753 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 9 Sep 2026 14:47:31 +0100 Subject: [PATCH 2/3] Add changelog entry --- newsfragments/3218.change.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 newsfragments/3218.change.rst diff --git a/newsfragments/3218.change.rst b/newsfragments/3218.change.rst new file mode 100644 index 000000000..f3328e399 --- /dev/null +++ b/newsfragments/3218.change.rst @@ -0,0 +1 @@ +Validate query and Model Target report response values at construction time. From 184d89c828b2524bcc30f5a82655ae9c5db91630 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 9 Sep 2026 15:08:44 +0100 Subject: [PATCH 3/3] Cover invalid report response values --- tests/test_reports.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/test_reports.py diff --git a/tests/test_reports.py b/tests/test_reports.py new file mode 100644 index 000000000..ce8ed27a3 --- /dev/null +++ b/tests/test_reports.py @@ -0,0 +1,35 @@ +"""Tests for public report constructors.""" + +import pytest + +from vws.reports import QueryResult + + +@pytest.mark.parametrize( + argnames="response", + argvalues=[ + {"target_id": 1}, + { + "target_id": "target-id", + "target_data": { + "name": "target-name", + "application_metadata": None, + "target_timestamp": True, + }, + }, + { + "target_id": "target-id", + "target_data": { + "name": "target-name", + "application_metadata": 1, + "target_timestamp": 0, + }, + }, + ], +) +def test_query_result_rejects_invalid_response_values( + *, response: dict[str, object] +) -> None: + """Query reports reject values of the wrong type.""" + with pytest.raises(expected_exception=TypeError): + _ = QueryResult.from_response_dict(response_dict=response)