Skip to content

Commit 6dec52a

Browse files
Extract shared mapping payload reader utility
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent dd0993d commit 6dec52a

File tree

4 files changed

+193
-66
lines changed

4 files changed

+193
-66
lines changed

hyperbrowser/client/managers/response_utils.py

Lines changed: 14 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
from collections.abc import Mapping
21
from typing import Any, Type, TypeVar
32

43
from hyperbrowser.exceptions import HyperbrowserError
4+
from hyperbrowser.mapping_utils import read_string_key_mapping
55

66
T = TypeVar("T")
77
_MAX_OPERATION_NAME_DISPLAY_LENGTH = 120
@@ -75,38 +75,21 @@ def parse_response_model(
7575
if is_empty_operation_name:
7676
raise HyperbrowserError("operation_name must be a non-empty string")
7777
normalized_operation_name = _normalize_operation_name_for_error(operation_name)
78-
if not isinstance(response_data, Mapping):
79-
raise HyperbrowserError(
78+
response_payload = read_string_key_mapping(
79+
response_data,
80+
expected_mapping_error=(
8081
f"Expected {normalized_operation_name} response to be an object"
81-
)
82-
try:
83-
response_keys = list(response_data.keys())
84-
except HyperbrowserError:
85-
raise
86-
except Exception as exc:
87-
raise HyperbrowserError(
88-
f"Failed to read {normalized_operation_name} response keys",
89-
original_error=exc,
90-
) from exc
91-
for key in response_keys:
92-
if type(key) is str:
93-
continue
94-
raise HyperbrowserError(
82+
),
83+
read_keys_error=f"Failed to read {normalized_operation_name} response keys",
84+
non_string_key_error_builder=lambda _key: (
9585
f"Expected {normalized_operation_name} response object keys to be strings"
96-
)
97-
response_payload: dict[str, object] = {}
98-
for key in response_keys:
99-
try:
100-
response_payload[key] = response_data[key]
101-
except HyperbrowserError:
102-
raise
103-
except Exception as exc:
104-
key_display = _normalize_response_key_for_error(key)
105-
raise HyperbrowserError(
106-
f"Failed to read {normalized_operation_name} response value for key "
107-
f"'{key_display}'",
108-
original_error=exc,
109-
) from exc
86+
),
87+
read_value_error_builder=lambda key_display: (
88+
f"Failed to read {normalized_operation_name} response value for key "
89+
f"'{key_display}'"
90+
),
91+
key_display=_normalize_response_key_for_error,
92+
)
11093
try:
11194
return model(**response_payload)
11295
except HyperbrowserError:

hyperbrowser/mapping_utils.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from collections.abc import Mapping as MappingABC
2+
from typing import Any, Callable, Dict
3+
4+
from hyperbrowser.exceptions import HyperbrowserError
5+
6+
7+
def read_string_key_mapping(
8+
mapping_value: Any,
9+
*,
10+
expected_mapping_error: str,
11+
read_keys_error: str,
12+
non_string_key_error_builder: Callable[[object], str],
13+
read_value_error_builder: Callable[[str], str],
14+
key_display: Callable[[str], str],
15+
) -> Dict[str, object]:
16+
if not isinstance(mapping_value, MappingABC):
17+
raise HyperbrowserError(expected_mapping_error)
18+
try:
19+
mapping_keys = list(mapping_value.keys())
20+
except HyperbrowserError:
21+
raise
22+
except Exception as exc:
23+
raise HyperbrowserError(
24+
read_keys_error,
25+
original_error=exc,
26+
) from exc
27+
for key in mapping_keys:
28+
if type(key) is str:
29+
continue
30+
raise HyperbrowserError(non_string_key_error_builder(key))
31+
normalized_mapping: Dict[str, object] = {}
32+
for key in mapping_keys:
33+
try:
34+
normalized_mapping[key] = mapping_value[key]
35+
except HyperbrowserError:
36+
raise
37+
except Exception as exc:
38+
try:
39+
key_text = key_display(key)
40+
if type(key_text) is not str:
41+
raise TypeError("mapping key display must be a string")
42+
except Exception:
43+
key_text = "<unreadable key>"
44+
raise HyperbrowserError(
45+
read_value_error_builder(key_text),
46+
original_error=exc,
47+
) from exc
48+
return normalized_mapping

hyperbrowser/transport/base.py

Lines changed: 18 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
from collections.abc import Mapping as MappingABC
21
from abc import ABC, abstractmethod
32
from typing import Generic, Mapping, Optional, Type, TypeVar, Union
43

54
from hyperbrowser.exceptions import HyperbrowserError
5+
from hyperbrowser.mapping_utils import read_string_key_mapping
66

77
T = TypeVar("T")
88
_TRUNCATED_DISPLAY_SUFFIX = "... (truncated)"
@@ -129,43 +129,26 @@ def from_json(
129129
) -> "APIResponse[T]":
130130
"""Create an APIResponse from JSON data with a specific model."""
131131
model_name = _safe_model_name(model)
132-
if not isinstance(json_data, MappingABC):
133-
actual_type_name = type(json_data).__name__
134-
raise HyperbrowserError(
132+
normalized_payload = read_string_key_mapping(
133+
json_data,
134+
expected_mapping_error=(
135+
f"Failed to parse response data for {model_name}: expected a mapping "
136+
f"but received {type(json_data).__name__}"
137+
),
138+
read_keys_error=(
135139
f"Failed to parse response data for {model_name}: "
136-
f"expected a mapping but received {actual_type_name}"
137-
)
138-
try:
139-
response_keys = list(json_data.keys())
140-
except HyperbrowserError:
141-
raise
142-
except Exception as exc:
143-
raise HyperbrowserError(
140+
"unable to read mapping keys"
141+
),
142+
non_string_key_error_builder=lambda key: (
144143
f"Failed to parse response data for {model_name}: "
145-
"unable to read mapping keys",
146-
original_error=exc,
147-
) from exc
148-
for key in response_keys:
149-
if type(key) is str:
150-
continue
151-
key_type_name = type(key).__name__
152-
raise HyperbrowserError(
144+
f"expected string keys but received {type(key).__name__}"
145+
),
146+
read_value_error_builder=lambda key_display: (
153147
f"Failed to parse response data for {model_name}: "
154-
f"expected string keys but received {key_type_name}"
155-
)
156-
normalized_payload: dict[str, object] = {}
157-
for key in response_keys:
158-
try:
159-
normalized_payload[key] = json_data[key]
160-
except HyperbrowserError:
161-
raise
162-
except Exception as exc:
163-
key_display = _format_mapping_key_for_error(key)
164-
raise HyperbrowserError(
165-
f"Failed to parse response data for {model_name}: "
166-
f"unable to read value for key '{key_display}'",
167-
original_error=exc,
168-
) from exc
148+
f"unable to read value for key '{key_display}'"
149+
),
150+
key_display=_format_mapping_key_for_error,
151+
)
169152
try:
170153
return cls(data=model(**normalized_payload))
171154
except HyperbrowserError:

tests/test_mapping_utils.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
from collections.abc import Iterator, Mapping
2+
3+
import pytest
4+
5+
from hyperbrowser.exceptions import HyperbrowserError
6+
from hyperbrowser.mapping_utils import read_string_key_mapping
7+
8+
9+
class _BrokenKeysMapping(Mapping[object, object]):
10+
def __getitem__(self, key: object) -> object:
11+
_ = key
12+
return "value"
13+
14+
def __iter__(self) -> Iterator[object]:
15+
return iter(())
16+
17+
def __len__(self) -> int:
18+
return 0
19+
20+
def keys(self): # type: ignore[override]
21+
raise RuntimeError("broken keys")
22+
23+
24+
class _BrokenValueMapping(Mapping[object, object]):
25+
def __getitem__(self, key: object) -> object:
26+
_ = key
27+
raise RuntimeError("broken value read")
28+
29+
def __iter__(self) -> Iterator[object]:
30+
return iter(("field",))
31+
32+
def __len__(self) -> int:
33+
return 1
34+
35+
36+
class _HyperbrowserValueFailureMapping(Mapping[object, object]):
37+
def __getitem__(self, key: object) -> object:
38+
_ = key
39+
raise HyperbrowserError("custom value read failure")
40+
41+
def __iter__(self) -> Iterator[object]:
42+
return iter(("field",))
43+
44+
def __len__(self) -> int:
45+
return 1
46+
47+
48+
def _read_mapping(mapping_value):
49+
return read_string_key_mapping(
50+
mapping_value,
51+
expected_mapping_error="expected mapping",
52+
read_keys_error="failed keys",
53+
non_string_key_error_builder=lambda key: f"non-string key: {type(key).__name__}",
54+
read_value_error_builder=lambda key_display: (
55+
f"failed value for '{key_display}'"
56+
),
57+
key_display=lambda key: key,
58+
)
59+
60+
61+
def test_read_string_key_mapping_returns_dict():
62+
assert _read_mapping({"field": "value"}) == {"field": "value"}
63+
64+
65+
def test_read_string_key_mapping_rejects_non_mappings():
66+
with pytest.raises(HyperbrowserError, match="expected mapping"):
67+
_read_mapping(["value"])
68+
69+
70+
def test_read_string_key_mapping_wraps_key_iteration_failures():
71+
with pytest.raises(HyperbrowserError, match="failed keys") as exc_info:
72+
_read_mapping(_BrokenKeysMapping())
73+
74+
assert isinstance(exc_info.value.original_error, RuntimeError)
75+
76+
77+
def test_read_string_key_mapping_rejects_non_string_keys():
78+
with pytest.raises(HyperbrowserError, match="non-string key: int"):
79+
_read_mapping({1: "value"})
80+
81+
82+
def test_read_string_key_mapping_wraps_value_read_failures():
83+
with pytest.raises(
84+
HyperbrowserError, match="failed value for 'field'"
85+
) as exc_info:
86+
_read_mapping(_BrokenValueMapping())
87+
88+
assert isinstance(exc_info.value.original_error, RuntimeError)
89+
90+
91+
def test_read_string_key_mapping_preserves_hyperbrowser_value_failures():
92+
with pytest.raises(HyperbrowserError, match="custom value read failure") as exc_info:
93+
_read_mapping(_HyperbrowserValueFailureMapping())
94+
95+
assert exc_info.value.original_error is None
96+
97+
98+
def test_read_string_key_mapping_falls_back_for_unreadable_key_display():
99+
with pytest.raises(
100+
HyperbrowserError, match="failed value for '<unreadable key>'"
101+
):
102+
read_string_key_mapping(
103+
_BrokenValueMapping(),
104+
expected_mapping_error="expected mapping",
105+
read_keys_error="failed keys",
106+
non_string_key_error_builder=lambda key: (
107+
f"non-string key: {type(key).__name__}"
108+
),
109+
read_value_error_builder=lambda key_display: (
110+
f"failed value for '{key_display}'"
111+
),
112+
key_display=lambda key: key.encode("utf-8"),
113+
)

0 commit comments

Comments
 (0)