From fe8aef53765e4cfc314058ed8ffe3312eb60a8ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Thu, 1 Oct 2026 09:53:07 +0200 Subject: [PATCH] Annotate the actions that declare an outputSchema as PageResult The eight tools that answer with structuredContent (goto and the actions) now say so on tools/list with an outputSchema, and `Session.call` already returns a `PageResult` for exactly those answers. The generator reads that schema, so their methods are annotated `-> PageResult` instead of `Any` without a hand-maintained list. `Browser.tools` carries it as `output_schema`. `PageResult` is imported under TYPE_CHECKING, since browser.py imports the generated module; pdoc still resolves and links it. Also picks up nightly's new `goto` description. Part of #6. --- lightpanda/_methods.py | 41 ++++++++++++++++++++----------------- lightpanda/async_browser.py | 3 ++- lightpanda/browser.py | 4 +++- scripts/generate_methods.py | 13 +++++++++--- 4 files changed, 37 insertions(+), 24 deletions(-) diff --git a/lightpanda/_methods.py b/lightpanda/_methods.py index 950d2bf..1ecc3e5 100644 --- a/lightpanda/_methods.py +++ b/lightpanda/_methods.py @@ -8,14 +8,17 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .browser import PageResult class SessionMethods: def call(self, tool: str, **kwargs: Any) -> Any: raise NotImplementedError - def click(self, *, selector: str | None = None, backend_node_id: int | None = None) -> Any: + def click(self, *, selector: str | None = None, backend_node_id: int | None = None) -> PageResult: """Click on an interactive element. Provide either a CSS selector (preferred for reproducibility) or a backendNodeId. Returns the current page URL and title after the click. Args: @@ -67,7 +70,7 @@ def extract(self, *, schema: str | dict | list, save: str | None = None) -> Any: save: Optional bridge-store key. The extracted JSON is stored under this name and exposed as `lp.` in subsequent /evaluate calls. """ return self.call("extract", schema=schema, save=save) - def fill(self, *, value: str, selector: str | None = None, backend_node_id: int | None = None) -> Any: + def fill(self, *, value: str, selector: str | None = None, backend_node_id: int | None = None) -> PageResult: """Fill text into an input element. Provide either a CSS selector (preferred for reproducibility) or a backendNodeId. Args: @@ -102,8 +105,8 @@ def get_env(self, *, name: str | None = None) -> Any: def get_url(self) -> Any: """Current page URL. The browser may already have a page loaded (command, replayed script) not visible in this conversation — call this before assuming nothing is loaded when the user references the current page/site. Also useful to verify a navigation or detect a redirect.""" return self.call("getUrl") - def goto(self, *, url: str, timeout: int | None = None, wait_until: str | None = None) -> Any: - """Navigate the current page to a URL. Returns a short status once `waitUntil` fires (default `load`), or a timeout notice; content rendered by post-load JavaScript may not be there yet (see `waitForState`). The page stays loaded for later reads and actions. To navigate and read in one call, pass `url` to `markdown`, `tree` or `html` instead; use `goto` when the next step is an action or `extract`. + def goto(self, *, url: str, timeout: int | None = None, wait_until: str | None = None) -> PageResult: + """Navigate the current page to a URL. Returns the HTTP status once `waitUntil` fires (default `load`), or a timeout notice; a 4xx or 5xx means the page you got is an error page, not the content — check it before reading on; content rendered by post-load JavaScript may not be there yet (see `waitForState`). The page stays loaded for later reads and actions. To navigate and read in one call, pass `url` to `markdown`, `tree` or `html` instead; use `goto` when the next step is an action or `extract`. Args: url: The URL to navigate to, must be a valid URL. @@ -111,7 +114,7 @@ def goto(self, *, url: str, timeout: int | None = None, wait_until: str | None = wait_until: Event that completes the navigation. Defaults to 'load'. Prefer 'domcontentloaded' followed by waitForSelector on pages whose late scripts (ads) hold 'load' back. Avoid 'done' (full quiescence): on pages with constant background activity it is the slowest choice and can run to the timeout. """ return self.call("goto", url=url, timeout=timeout, wait_until=wait_until) - def hover(self, *, selector: str | None = None, backend_node_id: int | None = None) -> Any: + def hover(self, *, selector: str | None = None, backend_node_id: int | None = None) -> PageResult: """Hover over an element, triggering mouseover and mouseenter events. Provide either a CSS selector (preferred for reproducibility) or a backendNodeId. Useful for menus, tooltips, and hover states. Args: @@ -167,7 +170,7 @@ def node_details(self, *, backend_node_id: int) -> Any: backend_node_id: The backend node ID of the element to inspect. """ return self.call("nodeDetails", backend_node_id=backend_node_id) - def press(self, *, key: str, selector: str | None = None, backend_node_id: int | None = None) -> Any: + def press(self, *, key: str, selector: str | None = None, backend_node_id: int | None = None) -> PageResult: """Press a keyboard key, dispatching keydown and keyup events. Use key names like 'Enter', 'Tab', 'Escape', 'ArrowDown', 'Backspace', or single characters like 'a', '1'. Common shorthand is normalized: 'enter'/'return' → 'Enter', 'esc' → 'Escape', 'up'/'down'/'left'/'right' → 'Arrow*', 'space' → ' '. Pressing 'Enter' on a form input or submit button triggers implicit form submission. Args: @@ -189,7 +192,7 @@ def screenshot(self, *, path: str | None = None, selector: str | None = None, ba timeout: Optional timeout in milliseconds. Defaults to 10000. """ return self.call("screenshot", path=path, selector=selector, backend_node_id=backend_node_id, full_page=full_page, strip=strip, url=url, timeout=timeout) - def scroll(self, *, selector: str | None = None, backend_node_id: int | None = None, x: int | None = None, y: int | None = None) -> Any: + def scroll(self, *, selector: str | None = None, backend_node_id: int | None = None, x: int | None = None, y: int | None = None) -> PageResult: """Scroll the window, or an element's scroll container, to an absolute position; an omitted axis keeps its current offset. Target an element with a CSS selector (preferred for reproducibility) or a backendNodeId; omit both to scroll the window. Page scripts receive a `scroll` event, so content that loads on scroll (infinite feeds, lazy lists) may appear: read the page again afterwards, with `waitForState` if it is still loading. Returns the final scroll position and the current page URL and title. Args: @@ -207,7 +210,7 @@ def search(self, *, query: str, timeout: int | None = None) -> Any: timeout: Optional timeout in milliseconds. Defaults to 10000. """ return self.call("search", query=query, timeout=timeout) - def select_option(self, *, value: str, selector: str | None = None, backend_node_id: int | None = None) -> Any: + def select_option(self, *, value: str, selector: str | None = None, backend_node_id: int | None = None) -> PageResult: """Select an option in a element. """ return self.call("selectOption", value=value, selector=selector, backend_node_id=backend_node_id) - def set_checked(self, *, checked: bool, selector: str | None = None, backend_node_id: int | None = None) -> Any: + def set_checked(self, *, checked: bool, selector: str | None = None, backend_node_id: int | None = None) -> PageResult: """Check or uncheck a checkbox or radio button. Provide either a CSS selector (preferred for reproducibility) or a backendNodeId. Dispatches input, change, and click events. Args: @@ -272,7 +275,7 @@ class AsyncSessionMethods: async def call(self, tool: str, **kwargs: Any) -> Any: raise NotImplementedError - async def click(self, *, selector: str | None = None, backend_node_id: int | None = None) -> Any: + async def click(self, *, selector: str | None = None, backend_node_id: int | None = None) -> PageResult: """Click on an interactive element. Provide either a CSS selector (preferred for reproducibility) or a backendNodeId. Returns the current page URL and title after the click. Args: @@ -324,7 +327,7 @@ async def extract(self, *, schema: str | dict | list, save: str | None = None) - save: Optional bridge-store key. The extracted JSON is stored under this name and exposed as `lp.` in subsequent /evaluate calls. """ return await self.call("extract", schema=schema, save=save) - async def fill(self, *, value: str, selector: str | None = None, backend_node_id: int | None = None) -> Any: + async def fill(self, *, value: str, selector: str | None = None, backend_node_id: int | None = None) -> PageResult: """Fill text into an input element. Provide either a CSS selector (preferred for reproducibility) or a backendNodeId. Args: @@ -359,8 +362,8 @@ async def get_env(self, *, name: str | None = None) -> Any: async def get_url(self) -> Any: """Current page URL. The browser may already have a page loaded (command, replayed script) not visible in this conversation — call this before assuming nothing is loaded when the user references the current page/site. Also useful to verify a navigation or detect a redirect.""" return await self.call("getUrl") - async def goto(self, *, url: str, timeout: int | None = None, wait_until: str | None = None) -> Any: - """Navigate the current page to a URL. Returns a short status once `waitUntil` fires (default `load`), or a timeout notice; content rendered by post-load JavaScript may not be there yet (see `waitForState`). The page stays loaded for later reads and actions. To navigate and read in one call, pass `url` to `markdown`, `tree` or `html` instead; use `goto` when the next step is an action or `extract`. + async def goto(self, *, url: str, timeout: int | None = None, wait_until: str | None = None) -> PageResult: + """Navigate the current page to a URL. Returns the HTTP status once `waitUntil` fires (default `load`), or a timeout notice; a 4xx or 5xx means the page you got is an error page, not the content — check it before reading on; content rendered by post-load JavaScript may not be there yet (see `waitForState`). The page stays loaded for later reads and actions. To navigate and read in one call, pass `url` to `markdown`, `tree` or `html` instead; use `goto` when the next step is an action or `extract`. Args: url: The URL to navigate to, must be a valid URL. @@ -368,7 +371,7 @@ async def goto(self, *, url: str, timeout: int | None = None, wait_until: str | wait_until: Event that completes the navigation. Defaults to 'load'. Prefer 'domcontentloaded' followed by waitForSelector on pages whose late scripts (ads) hold 'load' back. Avoid 'done' (full quiescence): on pages with constant background activity it is the slowest choice and can run to the timeout. """ return await self.call("goto", url=url, timeout=timeout, wait_until=wait_until) - async def hover(self, *, selector: str | None = None, backend_node_id: int | None = None) -> Any: + async def hover(self, *, selector: str | None = None, backend_node_id: int | None = None) -> PageResult: """Hover over an element, triggering mouseover and mouseenter events. Provide either a CSS selector (preferred for reproducibility) or a backendNodeId. Useful for menus, tooltips, and hover states. Args: @@ -424,7 +427,7 @@ async def node_details(self, *, backend_node_id: int) -> Any: backend_node_id: The backend node ID of the element to inspect. """ return await self.call("nodeDetails", backend_node_id=backend_node_id) - async def press(self, *, key: str, selector: str | None = None, backend_node_id: int | None = None) -> Any: + async def press(self, *, key: str, selector: str | None = None, backend_node_id: int | None = None) -> PageResult: """Press a keyboard key, dispatching keydown and keyup events. Use key names like 'Enter', 'Tab', 'Escape', 'ArrowDown', 'Backspace', or single characters like 'a', '1'. Common shorthand is normalized: 'enter'/'return' → 'Enter', 'esc' → 'Escape', 'up'/'down'/'left'/'right' → 'Arrow*', 'space' → ' '. Pressing 'Enter' on a form input or submit button triggers implicit form submission. Args: @@ -446,7 +449,7 @@ async def screenshot(self, *, path: str | None = None, selector: str | None = No timeout: Optional timeout in milliseconds. Defaults to 10000. """ return await self.call("screenshot", path=path, selector=selector, backend_node_id=backend_node_id, full_page=full_page, strip=strip, url=url, timeout=timeout) - async def scroll(self, *, selector: str | None = None, backend_node_id: int | None = None, x: int | None = None, y: int | None = None) -> Any: + async def scroll(self, *, selector: str | None = None, backend_node_id: int | None = None, x: int | None = None, y: int | None = None) -> PageResult: """Scroll the window, or an element's scroll container, to an absolute position; an omitted axis keeps its current offset. Target an element with a CSS selector (preferred for reproducibility) or a backendNodeId; omit both to scroll the window. Page scripts receive a `scroll` event, so content that loads on scroll (infinite feeds, lazy lists) may appear: read the page again afterwards, with `waitForState` if it is still loading. Returns the final scroll position and the current page URL and title. Args: @@ -464,7 +467,7 @@ async def search(self, *, query: str, timeout: int | None = None) -> Any: timeout: Optional timeout in milliseconds. Defaults to 10000. """ return await self.call("search", query=query, timeout=timeout) - async def select_option(self, *, value: str, selector: str | None = None, backend_node_id: int | None = None) -> Any: + async def select_option(self, *, value: str, selector: str | None = None, backend_node_id: int | None = None) -> PageResult: """Select an option in a element. """ return await self.call("selectOption", value=value, selector=selector, backend_node_id=backend_node_id) - async def set_checked(self, *, checked: bool, selector: str | None = None, backend_node_id: int | None = None) -> Any: + async def set_checked(self, *, checked: bool, selector: str | None = None, backend_node_id: int | None = None) -> PageResult: """Check or uncheck a checkbox or radio button. Provide either a CSS selector (preferred for reproducibility) or a backendNodeId. Dispatches input, change, and click events. Args: diff --git a/lightpanda/async_browser.py b/lightpanda/async_browser.py index 34a5b36..e3de672 100644 --- a/lightpanda/async_browser.py +++ b/lightpanda/async_browser.py @@ -124,7 +124,8 @@ async def start(self) -> AsyncBrowser: @property def tools(self) -> dict[str, dict]: - """Tool name → {description, schema}, as reported by the browser.""" + """Tool name → {description, schema, output_schema}, as reported by + the browser. ``output_schema`` is None for tools that declare none.""" if self._browser is None: raise LightpandaError("browser not started; use `async with` or `await start()`") return self._browser.tools diff --git a/lightpanda/browser.py b/lightpanda/browser.py index 0866ded..3b71012 100644 --- a/lightpanda/browser.py +++ b/lightpanda/browser.py @@ -232,6 +232,7 @@ def __init__( tool["name"]: { "description": tool.get("description", ""), "schema": tool.get("inputSchema") or {}, + "output_schema": tool.get("outputSchema"), } for tool in listed.get("tools", []) } @@ -239,7 +240,8 @@ def __init__( @property def tools(self) -> dict[str, dict]: - """Tool name → {description, schema}, as reported by the browser.""" + """Tool name → {description, schema, output_schema}, as reported by + the browser. ``output_schema`` is None for tools that declare none.""" return self._tools def new_session(self) -> Session: diff --git a/scripts/generate_methods.py b/scripts/generate_methods.py index 43b0e31..a53951c 100644 --- a/scripts/generate_methods.py +++ b/scripts/generate_methods.py @@ -4,7 +4,10 @@ tool name and its parameters in snake_case, all forwarding to ``Session.call`` (which maps them back to the schema's names). Each docstring carries the tool description and a Google-style ``Args:`` section from the schema's property -descriptions, so IDEs and pdoc show what every argument means. Being real code, the methods are +descriptions, so IDEs and pdoc show what every argument means. Tools that +declare an ``outputSchema`` answer with ``structuredContent``, which +``Session.call`` returns as a ``PageResult``, so those methods are annotated +with it; the rest stay ``Any``. Being real code, the methods are visible to IDEs, type checkers, and pdoc alike. Run with a binary available: uv run --no-project python scripts/generate_methods.py @@ -36,7 +39,10 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .browser import PageResult ''' CLASS_HEADER = '''\ @@ -94,7 +100,8 @@ def method_source(name: str, spec: dict, is_async: bool = False) -> str: call_args = ", ".join([f'"{name}"'] + forwards) prefix = "async def" if is_async else "def" await_ = "await " if is_async else "" - lines = [f" {prefix} {snake}({', '.join(params)}) -> Any:"] + returns = "PageResult" if spec.get("output_schema") else "Any" + lines = [f" {prefix} {snake}({', '.join(params)}) -> {returns}:"] lines.append(docstring(spec["description"], documented)) lines.append(f" return {await_}self.call({call_args})") return "\n".join(lines)