From 77b4d793d01ae787981086ff1f84ae71d0384154 Mon Sep 17 00:00:00 2001 From: Aryan Panwar Date: Sun, 11 Oct 2026 09:55:54 +0530 Subject: [PATCH] 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 --- hyperbrowser/client/_request.py | 100 +++++++++++++++++++++++++- tests/test_unknown_request_keys.py | 109 +++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 tests/test_unknown_request_keys.py diff --git a/hyperbrowser/client/_request.py b/hyperbrowser/client/_request.py index c874bed7..cae6d213 100644 --- a/hyperbrowser/client/_request.py +++ b/hyperbrowser/client/_request.py @@ -1,6 +1,8 @@ +import difflib +import warnings from collections.abc import Mapping from copy import deepcopy -from typing import Any, Dict, Type, TypeVar +from typing import Any, Dict, List, Optional, Type, TypeVar, get_args, get_origin import jsonref from pydantic import BaseModel @@ -9,6 +11,93 @@ RequestModelT = TypeVar("RequestModelT", bound=BaseModel) +def _is_open(annotation: Any) -> bool: + """True for annotations whose mapping values are user-owned data.""" + return ( + annotation is Any + or annotation is dict + or get_origin(annotation) in (dict, Mapping, type) + ) + + +def _nested_model(annotation: Any) -> Optional[Type[BaseModel]]: + """Return the single request model wrapped by an annotation, if any. + + Unwraps Optional, List and similar containers. Returns None for open + mappings and for unions of several models, which are left unchecked. + """ + if isinstance(annotation, type) and issubclass(annotation, BaseModel): + return annotation + if _is_open(annotation): + return None + + found = set() + for arg in get_args(annotation): + if _is_open(arg): + # The field also accepts an open mapping or a class, so a mapping + # value may be user-owned data (e.g. a JSON Schema). Skip it. + return None + nested = _nested_model(arg) + if nested is not None: + found.add(nested) + return found.pop() if len(found) == 1 else None + + +def _unknown_keys(model: Type[BaseModel], data: Mapping, path: str) -> List[str]: + """List keys in a request mapping that the model would silently drop.""" + if model.model_config.get("extra") in ("allow", "forbid"): + # "allow" keeps extra keys and "forbid" already raises on them. + return [] + + config = model.model_config + by_name = config.get("populate_by_name") or config.get("validate_by_name") + by_alias = config.get("validate_by_alias", True) + + # Keys pydantic accepts on input, and the key to suggest for each field. + accepted = {} + preferred = {} + for field_name, field in model.model_fields.items(): + inputs = [] + if field.alias is None or by_name: + inputs.append(field_name) + if by_alias: + inputs += [a for a in (field.alias, field.validation_alias) if a] + inputs = [a for a in inputs if isinstance(a, str)] + for input_key in inputs: + accepted[input_key] = field + if inputs: + for wire_name in (field_name, field.serialization_alias): + if isinstance(wire_name, str) and wire_name not in accepted: + preferred[wire_name] = inputs[0] + + problems = [] + for key, item in data.items(): + if not isinstance(key, str): + continue + field = accepted.get(key) + if field is None: + if key in preferred: + hint = f"use '{preferred[key]}'" + else: + close = difflib.get_close_matches(key, list(accepted), n=1) + hint = f"did you mean '{close[0]}'?" if close else "unknown option" + problems.append(f"'{path}{key}' ({hint})") + continue + + nested = _nested_model(field.annotation) + if nested is None: + continue + if isinstance(item, Mapping): + problems.extend(_unknown_keys(nested, item, f"{path}{key}.")) + elif isinstance(item, (list, tuple)): + for index, element in enumerate(item): + if isinstance(element, Mapping): + problems.extend( + _unknown_keys(nested, element, f"{path}{key}[{index}].") + ) + return problems + + def coerce_request( value: Any, model: Type[RequestModelT], @@ -19,6 +108,15 @@ def coerce_request( if isinstance(value, model): return value if isinstance(value, Mapping): + problems = _unknown_keys(model, value, "") + if problems: + warnings.warn( + f"{model.__name__} ignored unrecognized {name} keys: " + + ", ".join(problems) + + ". These options were not sent to the API.", + UserWarning, + stacklevel=4, + ) return model.model_validate(dict(value)) raise TypeError( f"{name} must be a {model.__name__} instance or mapping, " diff --git a/tests/test_unknown_request_keys.py b/tests/test_unknown_request_keys.py new file mode 100644 index 00000000..d1c561df --- /dev/null +++ b/tests/test_unknown_request_keys.py @@ -0,0 +1,109 @@ +import warnings + +import pytest + +from hyperbrowser.client._request import dump_request +from hyperbrowser.models import ( + CreateSandboxParams, + CreateSessionParams, + StartBrowserUseTaskParams, + StartExtractJobParams, + StartScrapeJobParams, +) + + +def _dump_without_warnings(mapping, model): + with warnings.catch_warnings(): + warnings.simplefilter("error") + return dump_request(mapping, model) + + +def test_misspelled_key_warns_with_suggestion(): + with pytest.warns(UserWarning) as record: + payload = dump_request( + {"use_stealth": True, "solve_captcha": True}, CreateSessionParams + ) + + message = str(record[0].message) + assert "'solve_captcha' (did you mean 'solve_captchas'?)" in message + assert "not sent to the API" in message + # Behaviour is unchanged apart from the warning. + assert payload["useStealth"] is True + assert payload["solveCaptchas"] is False + + +def test_wire_name_key_warns_with_python_name(): + with pytest.warns(UserWarning) as record: + payload = dump_request({"useStealth": True}, CreateSessionParams) + + assert "'useStealth' (use 'use_stealth')" in str(record[0].message) + assert payload["useStealth"] is False + + +def test_nested_request_model_keys_are_checked(): + with pytest.warns(UserWarning) as record: + dump_request( + { + "url": "https://example.com", + "scrape_options": {"formats": ["markdown"], "only_main": True}, + }, + StartScrapeJobParams, + ) + + message = str(record[0].message) + assert "'scrape_options.only_main' (did you mean 'only_main_content'?)" in message + + +def test_valid_mappings_do_not_warn(): + _dump_without_warnings( + { + "use_stealth": True, + "solve_captchas": True, + "screen": {"width": 1920, "height": 1080}, + }, + CreateSessionParams, + ) + _dump_without_warnings( + { + "url": "https://example.com", + "scrape_options": {"formats": ["markdown"], "only_main_content": True}, + }, + StartScrapeJobParams, + ) + + +def test_aliased_input_and_user_owned_mappings_do_not_warn(): + _dump_without_warnings( + { + "urls": ["https://example.com"], + "schema": {"type": "object", "properties": {"anyKey": {}}}, + }, + StartExtractJobParams, + ) + _dump_without_warnings( + { + "task": "log in", + "initial_actions": [{"custom_action": {"snake_case_key": None}}], + }, + StartBrowserUseTaskParams, + ) + + +def test_open_mapping_fields_do_not_warn(): + # Keys of a dict-typed field are user data, not option names. + _dump_without_warnings( + {"image_name": "node", "mounts": {"/workspace/cache": {"id": "vol_123"}}}, + CreateSandboxParams, + ) + # A field that accepts either a JSON Schema dict or a model class. + _dump_without_warnings( + { + "task": "extract", + "output_model_schema": {"type": "object", "$defs": {}, "x-custom": 1}, + }, + StartBrowserUseTaskParams, + ) + + +def test_legacy_model_instances_do_not_warn(): + _dump_without_warnings(CreateSessionParams(use_stealth=True), CreateSessionParams)