Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 99 additions & 1 deletion hyperbrowser/client/_request.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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],
Expand All @@ -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, "
Expand Down
109 changes: 109 additions & 0 deletions tests/test_unknown_request_keys.py
Original file line number Diff line number Diff line change
@@ -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)