Skip to content

Commit 77b4d79

Browse files
aryannpanwarrclaude
andcommitted
Warn when request mappings contain unrecognized keys
Request dictionaries are validated against pydantic models that ignore unknown keys, so a typo such as "solve_captcha" or a wire-format name such as "useStealth" was dropped silently and the default (False) was sent instead. The session was created with the feature off and no error. coerce_request now emits a UserWarning listing each unrecognized key, including nested request models, with a suggestion ("did you mean 'solve_captchas'?" / "use 'use_stealth'"). Open mappings such as JSON schemas, mounts and custom actions are not checked. Payloads are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 4427393 commit 77b4d79

2 files changed

Lines changed: 208 additions & 1 deletion

File tree

‎hyperbrowser/client/_request.py‎

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import difflib
2+
import warnings
13
from collections.abc import Mapping
24
from copy import deepcopy
3-
from typing import Any, Dict, Type, TypeVar
5+
from typing import Any, Dict, List, Optional, Type, TypeVar, get_args, get_origin
46

57
import jsonref
68
from pydantic import BaseModel
@@ -9,6 +11,93 @@
911
RequestModelT = TypeVar("RequestModelT", bound=BaseModel)
1012

1113

14+
def _is_open(annotation: Any) -> bool:
15+
"""True for annotations whose mapping values are user-owned data."""
16+
return (
17+
annotation is Any
18+
or annotation is dict
19+
or get_origin(annotation) in (dict, Mapping, type)
20+
)
21+
22+
23+
def _nested_model(annotation: Any) -> Optional[Type[BaseModel]]:
24+
"""Return the single request model wrapped by an annotation, if any.
25+
26+
Unwraps Optional, List and similar containers. Returns None for open
27+
mappings and for unions of several models, which are left unchecked.
28+
"""
29+
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
30+
return annotation
31+
if _is_open(annotation):
32+
return None
33+
34+
found = set()
35+
for arg in get_args(annotation):
36+
if _is_open(arg):
37+
# The field also accepts an open mapping or a class, so a mapping
38+
# value may be user-owned data (e.g. a JSON Schema). Skip it.
39+
return None
40+
nested = _nested_model(arg)
41+
if nested is not None:
42+
found.add(nested)
43+
return found.pop() if len(found) == 1 else None
44+
45+
46+
def _unknown_keys(model: Type[BaseModel], data: Mapping, path: str) -> List[str]:
47+
"""List keys in a request mapping that the model would silently drop."""
48+
if model.model_config.get("extra") in ("allow", "forbid"):
49+
# "allow" keeps extra keys and "forbid" already raises on them.
50+
return []
51+
52+
config = model.model_config
53+
by_name = config.get("populate_by_name") or config.get("validate_by_name")
54+
by_alias = config.get("validate_by_alias", True)
55+
56+
# Keys pydantic accepts on input, and the key to suggest for each field.
57+
accepted = {}
58+
preferred = {}
59+
for field_name, field in model.model_fields.items():
60+
inputs = []
61+
if field.alias is None or by_name:
62+
inputs.append(field_name)
63+
if by_alias:
64+
inputs += [a for a in (field.alias, field.validation_alias) if a]
65+
inputs = [a for a in inputs if isinstance(a, str)]
66+
for input_key in inputs:
67+
accepted[input_key] = field
68+
if inputs:
69+
for wire_name in (field_name, field.serialization_alias):
70+
if isinstance(wire_name, str) and wire_name not in accepted:
71+
preferred[wire_name] = inputs[0]
72+
73+
problems = []
74+
for key, item in data.items():
75+
if not isinstance(key, str):
76+
continue
77+
field = accepted.get(key)
78+
if field is None:
79+
if key in preferred:
80+
hint = f"use '{preferred[key]}'"
81+
else:
82+
close = difflib.get_close_matches(key, list(accepted), n=1)
83+
hint = f"did you mean '{close[0]}'?" if close else "unknown option"
84+
problems.append(f"'{path}{key}' ({hint})")
85+
continue
86+
87+
nested = _nested_model(field.annotation)
88+
if nested is None:
89+
continue
90+
if isinstance(item, Mapping):
91+
problems.extend(_unknown_keys(nested, item, f"{path}{key}."))
92+
elif isinstance(item, (list, tuple)):
93+
for index, element in enumerate(item):
94+
if isinstance(element, Mapping):
95+
problems.extend(
96+
_unknown_keys(nested, element, f"{path}{key}[{index}].")
97+
)
98+
return problems
99+
100+
12101
def coerce_request(
13102
value: Any,
14103
model: Type[RequestModelT],
@@ -19,6 +108,15 @@ def coerce_request(
19108
if isinstance(value, model):
20109
return value
21110
if isinstance(value, Mapping):
111+
problems = _unknown_keys(model, value, "")
112+
if problems:
113+
warnings.warn(
114+
f"{model.__name__} ignored unrecognized {name} keys: "
115+
+ ", ".join(problems)
116+
+ ". These options were not sent to the API.",
117+
UserWarning,
118+
stacklevel=4,
119+
)
22120
return model.model_validate(dict(value))
23121
raise TypeError(
24122
f"{name} must be a {model.__name__} instance or mapping, "

‎tests/test_unknown_request_keys.py‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import warnings
2+
3+
import pytest
4+
5+
from hyperbrowser.client._request import dump_request
6+
from hyperbrowser.models import (
7+
CreateSandboxParams,
8+
CreateSessionParams,
9+
StartBrowserUseTaskParams,
10+
StartExtractJobParams,
11+
StartScrapeJobParams,
12+
)
13+
14+
15+
def _dump_without_warnings(mapping, model):
16+
with warnings.catch_warnings():
17+
warnings.simplefilter("error")
18+
return dump_request(mapping, model)
19+
20+
21+
def test_misspelled_key_warns_with_suggestion():
22+
with pytest.warns(UserWarning) as record:
23+
payload = dump_request(
24+
{"use_stealth": True, "solve_captcha": True}, CreateSessionParams
25+
)
26+
27+
message = str(record[0].message)
28+
assert "'solve_captcha' (did you mean 'solve_captchas'?)" in message
29+
assert "not sent to the API" in message
30+
# Behaviour is unchanged apart from the warning.
31+
assert payload["useStealth"] is True
32+
assert payload["solveCaptchas"] is False
33+
34+
35+
def test_wire_name_key_warns_with_python_name():
36+
with pytest.warns(UserWarning) as record:
37+
payload = dump_request({"useStealth": True}, CreateSessionParams)
38+
39+
assert "'useStealth' (use 'use_stealth')" in str(record[0].message)
40+
assert payload["useStealth"] is False
41+
42+
43+
def test_nested_request_model_keys_are_checked():
44+
with pytest.warns(UserWarning) as record:
45+
dump_request(
46+
{
47+
"url": "https://example.com",
48+
"scrape_options": {"formats": ["markdown"], "only_main": True},
49+
},
50+
StartScrapeJobParams,
51+
)
52+
53+
message = str(record[0].message)
54+
assert "'scrape_options.only_main' (did you mean 'only_main_content'?)" in message
55+
56+
57+
def test_valid_mappings_do_not_warn():
58+
_dump_without_warnings(
59+
{
60+
"use_stealth": True,
61+
"solve_captchas": True,
62+
"screen": {"width": 1920, "height": 1080},
63+
},
64+
CreateSessionParams,
65+
)
66+
_dump_without_warnings(
67+
{
68+
"url": "https://example.com",
69+
"scrape_options": {"formats": ["markdown"], "only_main_content": True},
70+
},
71+
StartScrapeJobParams,
72+
)
73+
74+
75+
def test_aliased_input_and_user_owned_mappings_do_not_warn():
76+
_dump_without_warnings(
77+
{
78+
"urls": ["https://example.com"],
79+
"schema": {"type": "object", "properties": {"anyKey": {}}},
80+
},
81+
StartExtractJobParams,
82+
)
83+
_dump_without_warnings(
84+
{
85+
"task": "log in",
86+
"initial_actions": [{"custom_action": {"snake_case_key": None}}],
87+
},
88+
StartBrowserUseTaskParams,
89+
)
90+
91+
92+
def test_open_mapping_fields_do_not_warn():
93+
# Keys of a dict-typed field are user data, not option names.
94+
_dump_without_warnings(
95+
{"image_name": "node", "mounts": {"/workspace/cache": {"id": "vol_123"}}},
96+
CreateSandboxParams,
97+
)
98+
# A field that accepts either a JSON Schema dict or a model class.
99+
_dump_without_warnings(
100+
{
101+
"task": "extract",
102+
"output_model_schema": {"type": "object", "$defs": {}, "x-custom": 1},
103+
},
104+
StartBrowserUseTaskParams,
105+
)
106+
107+
108+
def test_legacy_model_instances_do_not_warn():
109+
_dump_without_warnings(CreateSessionParams(use_stealth=True), CreateSessionParams)

0 commit comments

Comments
 (0)