mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-07-31 01:36:15 -04:00
python: add lightpanda package (sidecar v1)
pip-installable Python API driving the bundled binary over MCP HTTP: Browser/Session with one generated method per browser tool (lightpanda/_methods.py, regenerated from tools/list), run_script() replay, a CLI trampoline entry point, pytest suite against local fixtures, and pdoc docs generation.
This commit is contained in:
8
bindings/python/.gitignore
vendored
Normal file
8
bindings/python/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
docs/
|
||||
.venv/
|
||||
lightpanda/lightpanda
|
||||
lightpanda/lightpanda.exe
|
||||
43
bindings/python/README.md
Normal file
43
bindings/python/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# lightpanda for Python
|
||||
|
||||
Scrape and automate the web from Python with the [Lightpanda](https://lightpanda.io)
|
||||
headless browser — an order of magnitude lighter than Chrome. The wheel bundles
|
||||
the browser binary; there is no separate install step.
|
||||
|
||||
```python
|
||||
from lightpanda import Browser
|
||||
|
||||
with Browser() as b:
|
||||
page = b.new_session()
|
||||
page.goto(url="https://example.com")
|
||||
data = page.extract(schema={"title": "h1"})
|
||||
```
|
||||
|
||||
Every browser tool is a `Session` method (both `waitForSelector` and
|
||||
`wait_for_selector` work), with kwargs matching the tool's documented
|
||||
arguments. Replay a saved lightpanda agent script without any LLM:
|
||||
|
||||
```python
|
||||
from lightpanda import run_script
|
||||
|
||||
run_script("hn.lp.js", env={"LP_HN_USERNAME": "me"})
|
||||
```
|
||||
|
||||
The package also puts the full `lightpanda` CLI on PATH (agent REPL, fetch,
|
||||
serve).
|
||||
|
||||
## Development
|
||||
|
||||
The runtime binary is resolved from `LIGHTPANDA_BIN`, the package directory,
|
||||
then PATH. For a repo checkout: build with `zig build` and run tests with
|
||||
|
||||
```bash
|
||||
uv run --group dev pytest tests
|
||||
```
|
||||
|
||||
Regenerate the tool methods (`lightpanda/_methods.py`) and the API docs:
|
||||
|
||||
```bash
|
||||
uv run --no-project python scripts/generate_methods.py
|
||||
uv run --no-project --with pdoc python scripts/build_docs.py # writes docs/
|
||||
```
|
||||
24
bindings/python/lightpanda/__init__.py
Normal file
24
bindings/python/lightpanda/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Lightpanda for Python: a lightweight headless browser.
|
||||
|
||||
```python
|
||||
from lightpanda import Browser
|
||||
|
||||
with Browser() as b:
|
||||
page = b.new_session()
|
||||
page.goto(url="https://example.com")
|
||||
data = page.extract(schema={"title": "h1"})
|
||||
```
|
||||
"""
|
||||
|
||||
from .browser import Browser, Session, run_script
|
||||
from .errors import LightpandaError, ProtocolError, ScriptError, ToolError
|
||||
|
||||
__all__ = [
|
||||
"Browser",
|
||||
"Session",
|
||||
"run_script",
|
||||
"LightpandaError",
|
||||
"ProtocolError",
|
||||
"ScriptError",
|
||||
"ToolError",
|
||||
]
|
||||
139
bindings/python/lightpanda/_methods.py
Normal file
139
bindings/python/lightpanda/_methods.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Generated by scripts/generate_methods.py — do not edit.
|
||||
|
||||
One method per lightpanda browser tool, signatures derived from the tool
|
||||
JSON schemas. camelCase aliases mirror the underlying tool names.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class SessionMethods:
|
||||
def call(self, tool: str, **kwargs: Any) -> Any:
|
||||
raise NotImplementedError
|
||||
|
||||
def click(self, *, selector: str | None = None, backendNodeId: int | None = None) -> Any:
|
||||
"""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."""
|
||||
return self.call("click", selector=selector, backendNodeId=backendNodeId)
|
||||
def console_logs(self) -> Any:
|
||||
"""Get buffered console.log/warn/error messages from the current page. Returns all messages since last call and clears the buffer."""
|
||||
return self.call("consoleLogs")
|
||||
|
||||
consoleLogs = console_logs
|
||||
def detect_forms(self, *, url: str | None = None, timeout: int | None = None) -> Any:
|
||||
"""Detect all forms on the page and return their structure including fields, types, and required status. If a url is provided, it navigates to that url first."""
|
||||
return self.call("detectForms", url=url, timeout=timeout)
|
||||
|
||||
detectForms = detect_forms
|
||||
def evaluate(self, *, script: str, url: str | None = None, timeout: int | None = None, save: str | None = None) -> Any:
|
||||
"""Evaluate JavaScript in the current page context — an escape hatch for page-side logic the dedicated tools can't express; prefer `extract` for data and click/fill/etc. for actions. It runs in the page, so it cannot see the agent script's variables or builtins — interpolate any value into the `script` string. A bare trailing expression yields its value; top-level `await` and `return` are supported (the body then runs as an async function, so use `return` to produce a value). Objects and arrays return as JSON, so no `JSON.stringify` is needed. If a url is provided, it navigates there first. The `globalThis.lp` object exposes a Session-scoped bridge store: values written via `lp.foo = ...` auto-sync at end of evaluate, surviving navigation; values previously set via `/extract save=` or `/evaluate save=` appear as `lp.<name>`."""
|
||||
return self.call("evaluate", script=script, url=url, timeout=timeout, save=save)
|
||||
def extract(self, *, schema: str | dict | list, save: str | None = None) -> Any:
|
||||
"""Extract structured data from the current page (navigate first). `schema` is a JSON object (passed as a string) mapping output field names to CSS-selector specs. It is NOT a JSON Schema — no "type"/"properties" wrappers; the keys ARE your output fields. Value shapes:
|
||||
"<sel>" → first match's text (trimmed; null if no match)
|
||||
["<sel>"] → every match's text (string[])
|
||||
{"selector":"<sel>","attr":"<name>"} → first match's attribute value (href/src resolved to absolute URLs)
|
||||
[{"selector":"<sel>","attr":"<name>"}] → every match's attribute (string[])
|
||||
[{"selector":"<sel>","fields":{…}}] → one object per match; field selectors resolve relative to that match and accept any shape above ("" = the match's own text; nest arrays for per-item sub-lists)
|
||||
Add "limit": N inside any array's object spec to cap matches.
|
||||
Every extracted value is a string or null — parse numbers downstream. An empty array is a valid result, but if ALL top-level keys miss, the call errors: inspect the page (tree/markdown) and retry with corrected selectors.
|
||||
Finish data tasks with extract — it is the only read recorded as a replayable `extract(...)` script call; answers lifted from `markdown` text in chat are not.
|
||||
|
||||
Examples (schema → result):
|
||||
{"karma": "#karma"} → {"karma":"42"}
|
||||
{"items": [".story .title"]} → {"items":["Title 1","Title 2"]}
|
||||
{"top3": [{"selector":".story .title","limit":3}]} → {"top3":["A","B","C"]}
|
||||
{"links": [{"selector":"a.title","attr":"href"}]} → {"links":["https://site/a","https://site/b"]}
|
||||
{"stories": [{"selector":".athing","fields":{"title":".titleline","rank":".rank"}}]} → {"stories":[{"title":"Foo","rank":"1"}]}"""
|
||||
return self.call("extract", schema=schema, save=save)
|
||||
def fill(self, *, value: str, selector: str | None = None, backendNodeId: int | None = None) -> Any:
|
||||
"""Fill text into an input element. Provide either a CSS selector (preferred for reproducibility) or a backendNodeId."""
|
||||
return self.call("fill", value=value, selector=selector, backendNodeId=backendNodeId)
|
||||
def find_element(self, *, role: str | None = None, name: str | None = None) -> Any:
|
||||
"""Find interactive elements by role and/or accessible name. Returns matching elements with their backend node IDs. Useful for locating specific elements without parsing the full semantic tree."""
|
||||
return self.call("findElement", role=role, name=name)
|
||||
|
||||
findElement = find_element
|
||||
def get_cookies(self, *, url: str | None = None, all: bool | None = None) -> Any:
|
||||
"""Cookies stored in the browser. Defaults to cookies whose domain matches the current page's host. Pass `url=<URL>` to filter for another host, or `all=true` to dump every cookie regardless of host. Useful for debugging authentication and session state."""
|
||||
return self.call("getCookies", url=url, all=all)
|
||||
|
||||
getCookies = get_cookies
|
||||
def get_env(self, *, name: str | None = None) -> Any:
|
||||
"""With `name`: read an LP_* env var (other namespaces report as not set) — for non-secret config only (base URLs, flags). Without `name`: list LP_* names that are set (no values) — safe credential discovery. For secrets, pass `$LP_*` placeholders in tool args; never request a credential by name (the value would land in your context)."""
|
||||
return self.call("getEnv", name=name)
|
||||
|
||||
getEnv = get_env
|
||||
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")
|
||||
|
||||
getUrl = get_url
|
||||
def goto(self, *, url: str, timeout: int | None = None) -> Any:
|
||||
"""Navigate to a specified URL and load the page in memory so it can be reused later for info extraction."""
|
||||
return self.call("goto", url=url, timeout=timeout)
|
||||
def hover(self, *, selector: str | None = None, backendNodeId: int | None = None) -> Any:
|
||||
"""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."""
|
||||
return self.call("hover", selector=selector, backendNodeId=backendNodeId)
|
||||
def html(self, *, selector: str | None = None, backendNodeId: int | None = None, url: str | None = None, timeout: int | None = None) -> Any:
|
||||
"""Raw HTML for the document or, with `selector`/`backendNodeId`, a single node's outerHTML. Verbose; use only when you need attributes that markdown discards."""
|
||||
return self.call("html", selector=selector, backendNodeId=backendNodeId, url=url, timeout=timeout)
|
||||
def interactive_elements(self, *, url: str | None = None, timeout: int | None = None) -> Any:
|
||||
"""Extract interactive elements from the opened page. If a url is provided, it navigates to that url first."""
|
||||
return self.call("interactiveElements", url=url, timeout=timeout)
|
||||
|
||||
interactiveElements = interactive_elements
|
||||
def links(self, *, url: str | None = None, timeout: int | None = None) -> Any:
|
||||
"""Extract all links in the opened page as JSON objects with `text` (visible anchor text), `href` (resolved URL), and `backendNodeId` (pass to click/nodeDetails). If a url is provided, it navigates to that url first."""
|
||||
return self.call("links", url=url, timeout=timeout)
|
||||
def markdown(self, *, selector: str | None = None, backendNodeId: int | None = None, maxBytes: int | None = None, url: str | None = None, timeout: int | None = None) -> Any:
|
||||
"""Render the page (or a subtree) as markdown. Scope with `selector` or `backendNodeId` to read just the relevant region — full-page markdown is the last resort. Use `maxBytes` to cap long pages."""
|
||||
return self.call("markdown", selector=selector, backendNodeId=backendNodeId, maxBytes=maxBytes, url=url, timeout=timeout)
|
||||
def node_details(self, *, backendNodeId: int) -> Any:
|
||||
"""Details for a node by backendNodeId: a ready-to-use CSS `selector` that resolves to the node (the first match, as click/fill resolve it), plus tag, role, name, interactivity, disabled, value, input type, placeholder, href, id, class, checked, select options. The canonical way to turn a tree backendNodeId into a CSS selector."""
|
||||
return self.call("nodeDetails", backendNodeId=backendNodeId)
|
||||
|
||||
nodeDetails = node_details
|
||||
def press(self, *, key: str, selector: str | None = None, backendNodeId: int | None = None) -> Any:
|
||||
"""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."""
|
||||
return self.call("press", key=key, selector=selector, backendNodeId=backendNodeId)
|
||||
def scroll(self, *, backendNodeId: int | None = None, x: int | None = None, y: int | None = None) -> Any:
|
||||
"""Scroll the page or a specific element. Returns the scroll position and current page URL and title."""
|
||||
return self.call("scroll", backendNodeId=backendNodeId, x=x, y=y)
|
||||
def search(self, *, query: str, timeout: int | None = None) -> Any:
|
||||
"""Run a web search and return results as markdown. When TAVILY_API_KEY is set, queries the Tavily Search API and returns a numbered list of {title, url, snippet}. Otherwise (or on Tavily failure) falls back to scraping the DuckDuckGo HTML endpoint — degraded results, may rate-limit on bursty traffic. Prefer this over goto-ing google.com/search directly (Google blocks the browser on User-Agent/TLS). Browser state after this call is unspecified — to interact with a result, use `goto` with its URL; do not assume the browser DOM matches the results page."""
|
||||
return self.call("search", query=query, timeout=timeout)
|
||||
def select_option(self, *, value: str, selector: str | None = None, backendNodeId: int | None = None) -> Any:
|
||||
"""Select an option in a <select> dropdown element by its value. Provide either a CSS selector (preferred for reproducibility) or a backendNodeId. Dispatches input and change events."""
|
||||
return self.call("selectOption", value=value, selector=selector, backendNodeId=backendNodeId)
|
||||
|
||||
selectOption = select_option
|
||||
def set_checked(self, *, checked: bool, selector: str | None = None, backendNodeId: int | None = None) -> Any:
|
||||
"""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."""
|
||||
return self.call("setChecked", checked=checked, selector=selector, backendNodeId=backendNodeId)
|
||||
|
||||
setChecked = set_checked
|
||||
def structured_data(self, *, url: str | None = None, timeout: int | None = None) -> Any:
|
||||
"""Extract structured data (like JSON-LD, OpenGraph, etc) from the opened page. If a url is provided, it navigates to that url first."""
|
||||
return self.call("structuredData", url=url, timeout=timeout)
|
||||
|
||||
structuredData = structured_data
|
||||
def tree(self, *, url: str | None = None, timeout: int | None = None, backendNodeId: int | None = None, maxDepth: int | None = None) -> Any:
|
||||
"""Simplified semantic DOM tree (role, name, value, backendNodeId per node). Pass `backendNodeId` to scope, `maxDepth` to limit depth."""
|
||||
return self.call("tree", url=url, timeout=timeout, backendNodeId=backendNodeId, maxDepth=maxDepth)
|
||||
def wait_for_script(self, *, script: str, timeout: int | None = None) -> Any:
|
||||
"""Wait until a JS expression returns truthy. Re-evaluates on each tick of the event loop. Use for synchronization beyond what CSS selectors can express — e.g. `window.dataLoaded === true`, `document.readyState === 'complete'`, `document.querySelectorAll('.row').length >= 5`."""
|
||||
return self.call("waitForScript", script=script, timeout=timeout)
|
||||
|
||||
waitForScript = wait_for_script
|
||||
def wait_for_selector(self, *, selector: str, timeout: int | None = None) -> Any:
|
||||
"""Wait for an element matching a CSS selector to appear in the page. Returns the backend node ID of the matched element."""
|
||||
return self.call("waitForSelector", selector=selector, timeout=timeout)
|
||||
|
||||
waitForSelector = wait_for_selector
|
||||
def wait_for_state(self, *, state: str, timeout: int | None = None) -> Any:
|
||||
"""Wait for the CURRENT page to reach a load state (no navigation). After a `goto`, the page is returned at the fast `load` snapshot, so content rendered by post-load JS (XHR-loaded lists, feeds, search results) may still be missing. When a read looks incomplete — empty lists, spinners, skeletons — call this with 'networkidle' and re-read. Prefer 'networkidle'; 'done' can be slow on sites with constant background activity (ads, polling)."""
|
||||
return self.call("waitForState", state=state, timeout=timeout)
|
||||
|
||||
waitForState = wait_for_state
|
||||
229
bindings/python/lightpanda/browser.py
Normal file
229
bindings/python/lightpanda/browser.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""Public API: Browser, Session, run_script.
|
||||
|
||||
Session tool methods are generated from the server's ``tools/list`` schemas —
|
||||
one method per browser tool, kwargs exactly the tool's schema properties.
|
||||
Both the original tool name (``waitForSelector``) and its snake_case form
|
||||
(``wait_for_selector``) resolve to the same method.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from .client import Client, find_binary
|
||||
from .errors import ProtocolError, ScriptError, ToolError
|
||||
|
||||
try:
|
||||
from ._methods import SessionMethods
|
||||
except ImportError:
|
||||
# Bootstrap: scripts/generate_methods.py itself imports this module
|
||||
# before _methods.py exists; tool calls then rely on __getattr__.
|
||||
class SessionMethods:
|
||||
pass
|
||||
|
||||
|
||||
_SESSION_TOOLS = {"save", "session_new", "session_list", "session_close"}
|
||||
|
||||
|
||||
def _snake(name: str) -> str:
|
||||
return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
|
||||
|
||||
|
||||
def _parse_result_text(text: str):
|
||||
"""Tool results are text; JSON payloads (extract, links, tree, ...) are
|
||||
returned parsed, anything else as the raw string."""
|
||||
try:
|
||||
return json.loads(text)
|
||||
except ValueError:
|
||||
return text
|
||||
|
||||
|
||||
class Session(SessionMethods):
|
||||
"""One isolated browsing context (own page, cookies, memory).
|
||||
|
||||
Do not construct directly — use :meth:`Browser.new_session`.
|
||||
"""
|
||||
|
||||
def __init__(self, client: Client, session_id: str, tools: dict[str, dict]):
|
||||
self._client = client
|
||||
self._id = session_id
|
||||
self._tools = tools
|
||||
self._snake_map = {_snake(name): name for name in tools}
|
||||
self._closed = False
|
||||
|
||||
self._client.request(
|
||||
"initialize",
|
||||
{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "lightpanda-python", "version": _version()},
|
||||
},
|
||||
session_id=self._id,
|
||||
)
|
||||
self._client.notify("notifications/initialized", session_id=self._id)
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return self._id
|
||||
|
||||
def call(self, tool: str, **kwargs):
|
||||
"""Invoke a browser tool by name. The generated methods route here."""
|
||||
if self._closed:
|
||||
raise ToolError(f"session {self._id} is closed")
|
||||
name = self._snake_map.get(tool, tool)
|
||||
if name not in self._tools:
|
||||
raise ToolError(f"unknown tool {tool!r}")
|
||||
kwargs = {k: v for k, v in kwargs.items() if v is not None}
|
||||
if name == "extract" and isinstance(kwargs.get("schema"), (dict, list)):
|
||||
kwargs["schema"] = json.dumps(kwargs["schema"])
|
||||
|
||||
result = self._client.request(
|
||||
"tools/call", {"name": name, "arguments": kwargs}, session_id=self._id
|
||||
)
|
||||
content = result.get("content") or []
|
||||
text = "\n".join(part.get("text", "") for part in content if part.get("type") == "text")
|
||||
if result.get("isError"):
|
||||
raise ToolError(text or f"{name} failed")
|
||||
return _parse_result_text(text)
|
||||
|
||||
def __getattr__(self, attr: str):
|
||||
tools = self.__dict__.get("_tools") or {}
|
||||
name = self.__dict__.get("_snake_map", {}).get(attr, attr)
|
||||
if name in tools and name not in _SESSION_TOOLS:
|
||||
def method(**kwargs):
|
||||
return self.call(name, **kwargs)
|
||||
|
||||
method.__name__ = attr
|
||||
method.__qualname__ = f"Session.{attr}"
|
||||
method.__doc__ = tools[name].get("description")
|
||||
return method
|
||||
raise AttributeError(f"{type(self).__name__!r} object has no attribute {attr!r}")
|
||||
|
||||
def __dir__(self):
|
||||
names = set(super().__dir__())
|
||||
for name in self._tools:
|
||||
if name not in _SESSION_TOOLS:
|
||||
names.add(name)
|
||||
names.add(_snake(name))
|
||||
return sorted(names)
|
||||
|
||||
def close(self) -> None:
|
||||
if not self._closed:
|
||||
self._closed = True
|
||||
self._client.delete_session(self._id)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.close()
|
||||
|
||||
|
||||
# pdoc hides members inherited from a private module; re-attach the generated
|
||||
# tool methods as Session's own so they document (and introspect) directly.
|
||||
for _name, _member in vars(SessionMethods).items():
|
||||
if not _name.startswith("_") and _name != "call":
|
||||
setattr(Session, _name, _member)
|
||||
|
||||
|
||||
# pdoc hides members inherited from a private module; re-attach the generated
|
||||
# tool methods as Session's own so they document (and introspect) directly.
|
||||
for _name, _member in vars(SessionMethods).items():
|
||||
if not _name.startswith("_") and _name != "call":
|
||||
setattr(Session, _name, _member)
|
||||
|
||||
|
||||
class Browser:
|
||||
"""A lightpanda browser process. Spawns the bundled binary on first use.
|
||||
|
||||
Not fork-inheritable: after ``os.fork()``/``multiprocessing``, create a
|
||||
fresh Browser in the child.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
binary: str | os.PathLike | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout: float = 300.0,
|
||||
verbose: bool = False,
|
||||
):
|
||||
self._client = Client(binary=binary, env=env, timeout=timeout, verbose=verbose)
|
||||
self._seq = 0
|
||||
listed = self._client.request("tools/list")
|
||||
self._tools = {
|
||||
tool["name"]: {
|
||||
"description": tool.get("description", ""),
|
||||
"schema": tool.get("inputSchema") or {},
|
||||
}
|
||||
for tool in listed.get("tools", [])
|
||||
}
|
||||
|
||||
@property
|
||||
def tools(self) -> dict[str, dict]:
|
||||
"""Tool name → {description, schema}, as reported by the browser."""
|
||||
return self._tools
|
||||
|
||||
def new_session(self) -> Session:
|
||||
self._seq += 1
|
||||
return Session(self._client, f"py{self._seq}", self._tools)
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.close()
|
||||
|
||||
|
||||
def run_script(
|
||||
script: str | os.PathLike,
|
||||
env: dict[str, str] | None = None,
|
||||
binary: str | os.PathLike | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> str:
|
||||
"""Replay a saved lightpanda script (no LLM) and return its stdout.
|
||||
|
||||
``env`` entries (e.g. ``LP_*`` placeholder values) are added to the
|
||||
child's environment. Raises :class:`ScriptError` on a non-zero exit.
|
||||
"""
|
||||
path = Path(script)
|
||||
if not path.is_file():
|
||||
raise ScriptError(f"script not found: {path}", returncode=-1)
|
||||
child_env = dict(os.environ)
|
||||
if env:
|
||||
child_env.update(env)
|
||||
|
||||
proc = subprocess.run(
|
||||
[str(find_binary(binary)), "run", str(path)],
|
||||
env=child_env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
detail = proc.stderr.strip() or proc.stdout.strip() or "no output"
|
||||
raise ScriptError(
|
||||
f"{path.name} failed (exit {proc.returncode}): {detail}",
|
||||
returncode=proc.returncode,
|
||||
stdout=proc.stdout,
|
||||
stderr=proc.stderr,
|
||||
)
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def _version() -> str:
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
|
||||
return version("lightpanda")
|
||||
except Exception:
|
||||
return "0.0.0.dev0"
|
||||
|
||||
|
||||
__all__ = ["Browser", "Session", "run_script", "ProtocolError", "ScriptError", "ToolError"]
|
||||
32
bindings/python/lightpanda/cli.py
Normal file
32
bindings/python/lightpanda/cli.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Console entry point: trampoline into the bundled lightpanda binary."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from .client import find_binary
|
||||
from .errors import LightpandaError
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
binary = find_binary()
|
||||
except LightpandaError as err:
|
||||
print(f"error: {err}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
args = [str(binary)] + sys.argv[1:]
|
||||
try:
|
||||
if sys.platform != "win32":
|
||||
os.execv(binary, args)
|
||||
else:
|
||||
sys.exit(subprocess.run(args).returncode)
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(130)
|
||||
except OSError as err:
|
||||
print(f"error executing {binary}: {err}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
185
bindings/python/lightpanda/client.py
Normal file
185
bindings/python/lightpanda/client.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Subprocess management and MCP-over-HTTP JSON-RPC transport.
|
||||
|
||||
The bundled ``lightpanda`` binary is spawned in ``mcp --port <n>`` mode
|
||||
(MCP Streamable HTTP on 127.0.0.1). Each JSON-RPC message is one POST;
|
||||
session routing uses the ``Mcp-Session-Id`` header — an unknown id creates
|
||||
the session on first use, so the client mints its own ids. DELETE with the
|
||||
header tears a session down.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from .errors import LightpandaError, ProtocolError
|
||||
|
||||
BINARY_NAME = "lightpanda.exe" if sys.platform == "win32" else "lightpanda"
|
||||
|
||||
_SPAWN_ATTEMPTS = 3
|
||||
_READY_TIMEOUT = 15.0
|
||||
|
||||
|
||||
def find_binary(explicit: str | os.PathLike | None = None) -> Path:
|
||||
"""Locate the lightpanda binary: explicit arg, $LIGHTPANDA_BIN, the
|
||||
bundled package copy, then PATH."""
|
||||
candidates: list[Path] = []
|
||||
if explicit:
|
||||
candidates.append(Path(explicit))
|
||||
env = os.environ.get("LIGHTPANDA_BIN")
|
||||
if env:
|
||||
candidates.append(Path(env))
|
||||
candidates.append(Path(__file__).parent / BINARY_NAME)
|
||||
which = shutil.which("lightpanda")
|
||||
if which:
|
||||
candidates.append(Path(which))
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
raise LightpandaError(
|
||||
"could not find the lightpanda binary; reinstall the package, set "
|
||||
"LIGHTPANDA_BIN, or put `lightpanda` on PATH"
|
||||
)
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
class Client:
|
||||
"""Owns one lightpanda subprocess and speaks JSON-RPC to it."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
binary: str | os.PathLike | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout: float = 300.0,
|
||||
verbose: bool = False,
|
||||
):
|
||||
self._binary = find_binary(binary)
|
||||
self._timeout = timeout
|
||||
self._lock = threading.Lock()
|
||||
self._id = 0
|
||||
self._proc: subprocess.Popen | None = None
|
||||
self._port = 0
|
||||
|
||||
child_env = dict(os.environ)
|
||||
if env:
|
||||
child_env.update(env)
|
||||
|
||||
stderr = None if verbose else subprocess.DEVNULL
|
||||
last_error: Exception | None = None
|
||||
for _ in range(_SPAWN_ATTEMPTS):
|
||||
port = _free_port()
|
||||
proc = subprocess.Popen(
|
||||
[str(self._binary), "mcp", "--port", str(port)],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=stderr,
|
||||
env=child_env,
|
||||
)
|
||||
try:
|
||||
self._wait_ready(proc, port)
|
||||
except LightpandaError as err:
|
||||
last_error = err
|
||||
self._terminate(proc)
|
||||
continue
|
||||
self._proc = proc
|
||||
self._port = port
|
||||
return
|
||||
raise LightpandaError(f"failed to start lightpanda: {last_error}")
|
||||
|
||||
@staticmethod
|
||||
def _wait_ready(proc: subprocess.Popen, port: int) -> None:
|
||||
deadline = time.monotonic() + _READY_TIMEOUT
|
||||
while time.monotonic() < deadline:
|
||||
if proc.poll() is not None:
|
||||
raise LightpandaError(
|
||||
f"lightpanda exited during startup (code {proc.returncode})"
|
||||
)
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.25):
|
||||
return
|
||||
except OSError:
|
||||
time.sleep(0.05)
|
||||
raise LightpandaError("timed out waiting for lightpanda to listen")
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return f"http://127.0.0.1:{self._port}/"
|
||||
|
||||
def _http(self, method: str, body: bytes | None, session_id: str | None) -> tuple[int, bytes]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if session_id:
|
||||
headers["Mcp-Session-Id"] = session_id
|
||||
req = urllib.request.Request(self.base_url, data=body, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
||||
return resp.status, resp.read()
|
||||
except urllib.error.HTTPError as err:
|
||||
return err.code, err.read()
|
||||
except OSError as err:
|
||||
alive = self._proc is not None and self._proc.poll() is None
|
||||
state = "running" if alive else f"exited (code {self._proc.returncode})" if self._proc else "not started"
|
||||
raise LightpandaError(f"lost connection to lightpanda ({state}): {err}") from err
|
||||
|
||||
def request(self, method: str, params: dict | None = None, session_id: str | None = None):
|
||||
"""Send one JSON-RPC request and return its ``result``."""
|
||||
with self._lock:
|
||||
self._id += 1
|
||||
rpc_id = self._id
|
||||
message: dict = {"jsonrpc": "2.0", "id": rpc_id, "method": method}
|
||||
if params is not None:
|
||||
message["params"] = params
|
||||
|
||||
status, body = self._http("POST", json.dumps(message).encode(), session_id)
|
||||
if not body:
|
||||
raise ProtocolError(f"empty response (HTTP {status}) for {method}")
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except ValueError as err:
|
||||
raise ProtocolError(f"invalid JSON-RPC response for {method}: {body[:200]!r}") from err
|
||||
if error := payload.get("error"):
|
||||
raise ProtocolError(f"{error.get('message', 'error')} (code {error.get('code')})", code=error.get("code"))
|
||||
return payload.get("result")
|
||||
|
||||
def notify(self, method: str, session_id: str | None = None) -> None:
|
||||
message = {"jsonrpc": "2.0", "method": method}
|
||||
self._http("POST", json.dumps(message).encode(), session_id)
|
||||
|
||||
def delete_session(self, session_id: str) -> None:
|
||||
self._http("DELETE", None, session_id)
|
||||
|
||||
@staticmethod
|
||||
def _terminate(proc: subprocess.Popen) -> None:
|
||||
if proc.poll() is not None:
|
||||
return
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
|
||||
def close(self) -> None:
|
||||
if self._proc is not None:
|
||||
self._terminate(self._proc)
|
||||
self._proc = None
|
||||
|
||||
def __del__(self):
|
||||
try:
|
||||
self.close()
|
||||
except Exception:
|
||||
pass
|
||||
24
bindings/python/lightpanda/errors.py
Normal file
24
bindings/python/lightpanda/errors.py
Normal file
@@ -0,0 +1,24 @@
|
||||
class LightpandaError(Exception):
|
||||
"""Base error for the lightpanda package."""
|
||||
|
||||
|
||||
class ProtocolError(LightpandaError):
|
||||
"""JSON-RPC level failure (invalid request, timeout, internal error)."""
|
||||
|
||||
def __init__(self, message: str, code: int | None = None):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
class ToolError(LightpandaError):
|
||||
"""A browser tool reported failure (bad selector, JS exception, ...)."""
|
||||
|
||||
|
||||
class ScriptError(LightpandaError):
|
||||
"""A script replay (`run_script`) exited with a failure."""
|
||||
|
||||
def __init__(self, message: str, returncode: int, stdout: str = "", stderr: str = ""):
|
||||
super().__init__(message)
|
||||
self.returncode = returncode
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
0
bindings/python/lightpanda/py.typed
Normal file
0
bindings/python/lightpanda/py.typed
Normal file
36
bindings/python/pyproject.toml
Normal file
36
bindings/python/pyproject.toml
Normal file
@@ -0,0 +1,36 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "lightpanda"
|
||||
version = "0.1.0.dev0"
|
||||
description = "Lightpanda headless browser: scrape and automate the web from Python"
|
||||
readme = "README.md"
|
||||
license = { text = "AGPL-3.0-only" }
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "Lightpanda" }]
|
||||
keywords = ["browser", "headless", "scraping", "automation"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Topic :: Internet :: WWW/HTTP :: Browsers",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://lightpanda.io"
|
||||
Repository = "https://github.com/lightpanda-io/browser"
|
||||
|
||||
[project.scripts]
|
||||
lightpanda = "lightpanda.cli:main"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["lightpanda"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
lightpanda = ["lightpanda", "lightpanda.exe", "py.typed"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pytest>=8"]
|
||||
docs = ["pdoc"]
|
||||
32
bindings/python/scripts/build_docs.py
Normal file
32
bindings/python/scripts/build_docs.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Generate API documentation with pdoc (zignal's flow, adapted).
|
||||
|
||||
Usage, from bindings/python (needs a lightpanda binary for regeneration):
|
||||
uv run --group docs python scripts/build_docs.py
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).parent.parent
|
||||
|
||||
|
||||
def main():
|
||||
print("Regenerating tool methods from the binary...")
|
||||
if subprocess.call([sys.executable, str(HERE / "scripts" / "generate_methods.py")]) != 0:
|
||||
sys.exit("error: failed to regenerate lightpanda/_methods.py")
|
||||
|
||||
docs_dir = HERE / "docs"
|
||||
if docs_dir.exists():
|
||||
shutil.rmtree(docs_dir)
|
||||
|
||||
print("Generating documentation...")
|
||||
cmd = [sys.executable, "-m", "pdoc", "lightpanda", "-o", str(docs_dir), "--no-show-source"]
|
||||
if subprocess.call(cmd, cwd=HERE) != 0:
|
||||
sys.exit("error generating documentation")
|
||||
print(f"documentation generated in {docs_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
98
bindings/python/scripts/generate_methods.py
Normal file
98
bindings/python/scripts/generate_methods.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Generate lightpanda/_methods.py from the binary's tools/list schemas.
|
||||
|
||||
Emits one concrete, annotated, documented method per browser tool (snake_case,
|
||||
with the original tool name aliased), all forwarding to ``Session.call``.
|
||||
Being real code, the methods are visible to IDEs, type checkers, and pdoc
|
||||
alike. Run from bindings/python with a binary available:
|
||||
|
||||
uv run --no-project python scripts/generate_methods.py
|
||||
|
||||
CI regenerates and diffs the file to catch tool-schema drift.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import keyword
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from lightpanda import Browser # noqa: E402
|
||||
from lightpanda.browser import _SESSION_TOOLS, _snake # noqa: E402
|
||||
|
||||
PY_TYPES = {"string": "str", "integer": "int", "number": "float", "boolean": "bool", "object": "dict", "array": "list"}
|
||||
|
||||
HEADER = '''\
|
||||
"""Generated by scripts/generate_methods.py — do not edit.
|
||||
|
||||
One method per lightpanda browser tool, signatures derived from the tool
|
||||
JSON schemas. camelCase aliases mirror the underlying tool names.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class SessionMethods:
|
||||
def call(self, tool: str, **kwargs: Any) -> Any:
|
||||
raise NotImplementedError
|
||||
'''
|
||||
|
||||
|
||||
def docstring(text: str) -> str:
|
||||
body = text.strip().replace("\\", "\\\\").replace('"""', '\\"\\"\\"')
|
||||
return f' """{body}"""'
|
||||
|
||||
|
||||
def method_source(name: str, spec: dict) -> str:
|
||||
schema = spec["schema"]
|
||||
properties = schema.get("properties", {})
|
||||
required = set(schema.get("required", []))
|
||||
for prop in properties:
|
||||
if keyword.iskeyword(prop) or not prop.isidentifier():
|
||||
raise SystemExit(f"tool {name}: property {prop!r} is not a valid Python parameter")
|
||||
|
||||
params = ["self"]
|
||||
if properties:
|
||||
params.append("*")
|
||||
forwards = []
|
||||
for prop in sorted(properties, key=lambda p: p not in required):
|
||||
py_type = PY_TYPES.get(properties[prop].get("type", ""), "Any")
|
||||
if name == "extract" and prop == "schema":
|
||||
py_type = "str | dict | list"
|
||||
if prop in required:
|
||||
params.append(f"{prop}: {py_type}")
|
||||
else:
|
||||
params.append(f"{prop}: {py_type} | None = None")
|
||||
forwards.append(f"{prop}={prop}")
|
||||
|
||||
snake = _snake(name)
|
||||
call_args = ", ".join([f'"{name}"'] + forwards)
|
||||
lines = [f" def {snake}({', '.join(params)}) -> Any:"]
|
||||
lines.append(docstring(spec["description"]))
|
||||
lines.append(f" return self.call({call_args})")
|
||||
if snake != name:
|
||||
lines.append("")
|
||||
lines.append(f" {name} = {snake}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
out = Path(__file__).parent.parent / "lightpanda" / "_methods.py"
|
||||
with Browser() as browser:
|
||||
tools = {k: v for k, v in browser.tools.items() if k not in _SESSION_TOOLS}
|
||||
|
||||
blocks = [HEADER]
|
||||
for name in sorted(tools):
|
||||
blocks.append(method_source(name, tools[name]))
|
||||
|
||||
source = "\n".join(blocks) + "\n"
|
||||
compile(source, str(out), "exec")
|
||||
out.write_text(source)
|
||||
print(f"wrote {out} ({len(tools)} tools)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
60
bindings/python/setup.py
Normal file
60
bindings/python/setup.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Build glue: bundle the lightpanda binary into the wheel.
|
||||
|
||||
The binary is taken from, in order: the LIGHTPANDA_BIN environment variable,
|
||||
or the repository's zig-out/bin/lightpanda (built with
|
||||
`zig build -Doptimize=ReleaseFast`). Wheel CI downloads a release artifact and
|
||||
points LIGHTPANDA_BIN at it. A source install without a binary still works —
|
||||
the runtime falls back to LIGHTPANDA_BIN or PATH.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from setuptools import setup
|
||||
from setuptools.command.build_py import build_py
|
||||
from setuptools.dist import Distribution
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
REPO_ROOT = HERE.parent.parent
|
||||
BINARY_NAME = "lightpanda.exe" if sys.platform == "win32" else "lightpanda"
|
||||
|
||||
|
||||
def find_binary():
|
||||
env = os.environ.get("LIGHTPANDA_BIN")
|
||||
if env and Path(env).is_file():
|
||||
return Path(env)
|
||||
built = REPO_ROOT / "zig-out" / "bin" / BINARY_NAME
|
||||
if built.is_file():
|
||||
return built
|
||||
return None
|
||||
|
||||
|
||||
class BuildPyWithBinary(build_py):
|
||||
def run(self):
|
||||
super().run()
|
||||
binary = find_binary()
|
||||
dest = Path(self.build_lib) / "lightpanda" / BINARY_NAME
|
||||
if binary is None:
|
||||
print(
|
||||
"warning: no lightpanda binary found (set LIGHTPANDA_BIN or run "
|
||||
"`zig build -Doptimize=ReleaseFast`); the package will rely on "
|
||||
"LIGHTPANDA_BIN or PATH at runtime",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(binary, dest)
|
||||
dest.chmod(0o755)
|
||||
|
||||
|
||||
class BinaryDistribution(Distribution):
|
||||
def has_ext_modules(self):
|
||||
return True
|
||||
|
||||
|
||||
setup(
|
||||
cmdclass={"build_py": BuildPyWithBinary},
|
||||
distclass=BinaryDistribution,
|
||||
)
|
||||
45
bindings/python/tests/conftest.py
Normal file
45
bindings/python/tests/conftest.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import http.server
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent.parent.parent
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
def _binary() -> str | None:
|
||||
env = os.environ.get("LIGHTPANDA_BIN")
|
||||
if env and Path(env).is_file():
|
||||
return env
|
||||
built = REPO_ROOT / "zig-out" / "bin" / "lightpanda"
|
||||
if built.is_file():
|
||||
return str(built)
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def binary() -> str:
|
||||
path = _binary()
|
||||
if path is None:
|
||||
pytest.skip("no lightpanda binary (set LIGHTPANDA_BIN or build zig-out/bin/lightpanda)")
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def fixture_url():
|
||||
handler = lambda *args: http.server.SimpleHTTPRequestHandler(*args, directory=str(FIXTURES))
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
yield f"http://127.0.0.1:{server.server_address[1]}"
|
||||
server.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def browser(binary):
|
||||
from lightpanda import Browser
|
||||
|
||||
with Browser(binary=binary) as b:
|
||||
yield b
|
||||
12
bindings/python/tests/fixtures/index.html
vendored
Normal file
12
bindings/python/tests/fixtures/index.html
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Fixture Home</title></head>
|
||||
<body>
|
||||
<h1 id="headline">Hello from the fixture</h1>
|
||||
<ul class="items">
|
||||
<li class="item"><a href="/other.html">First item</a></li>
|
||||
<li class="item"><a href="/other.html">Second item</a></li>
|
||||
<li class="item"><a href="/other.html">Third item</a></li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
5
bindings/python/tests/fixtures/other.html
vendored
Normal file
5
bindings/python/tests/fixtures/other.html
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Fixture Other</title></head>
|
||||
<body><p id="marker">the other page</p></body>
|
||||
</html>
|
||||
59
bindings/python/tests/test_browser.py
Normal file
59
bindings/python/tests/test_browser.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import pytest
|
||||
|
||||
from lightpanda import ToolError, run_script
|
||||
|
||||
|
||||
def test_goto_and_markdown(browser, fixture_url):
|
||||
page = browser.new_session()
|
||||
page.goto(url=f"{fixture_url}/index.html")
|
||||
text = page.markdown()
|
||||
assert "Hello from the fixture" in text
|
||||
page.close()
|
||||
|
||||
|
||||
def test_extract_dict_schema(browser, fixture_url):
|
||||
with browser.new_session() as page:
|
||||
page.goto(url=f"{fixture_url}/index.html")
|
||||
data = page.extract(schema={"headline": "#headline", "items": [".item a"]})
|
||||
assert data["headline"] == "Hello from the fixture"
|
||||
assert data["items"] == ["First item", "Second item", "Third item"]
|
||||
|
||||
|
||||
def test_evaluate(browser, fixture_url):
|
||||
with browser.new_session() as page:
|
||||
page.goto(url=f"{fixture_url}/index.html")
|
||||
assert page.evaluate(script="1 + 2") == 3
|
||||
assert page.evaluate(script="document.title") == "Fixture Home"
|
||||
|
||||
|
||||
def test_snake_case_alias(browser, fixture_url):
|
||||
with browser.new_session() as page:
|
||||
page.goto(url=f"{fixture_url}/index.html")
|
||||
assert page.get_url() == page.getUrl()
|
||||
|
||||
|
||||
def test_sessions_are_isolated(browser, fixture_url):
|
||||
with browser.new_session() as a, browser.new_session() as b:
|
||||
a.goto(url=f"{fixture_url}/index.html")
|
||||
b.goto(url=f"{fixture_url}/other.html")
|
||||
assert a.get_url().endswith("/index.html")
|
||||
assert b.get_url().endswith("/other.html")
|
||||
|
||||
|
||||
def test_tool_error_raises(browser, fixture_url):
|
||||
with browser.new_session() as page:
|
||||
page.goto(url=f"{fixture_url}/index.html")
|
||||
with pytest.raises(ToolError):
|
||||
page.extract(schema={"nope": "#does-not-exist"})
|
||||
|
||||
|
||||
def test_unknown_tool_raises(browser):
|
||||
with browser.new_session() as page:
|
||||
with pytest.raises(ToolError, match="unknown tool"):
|
||||
page.call("teleport", where="moon")
|
||||
|
||||
|
||||
def test_run_script(binary, fixture_url, tmp_path):
|
||||
script = tmp_path / "visit.js"
|
||||
script.write_text('const page = new Page();\nawait page.goto("$LP_TEST_URL");\n')
|
||||
run_script(script, env={"LP_TEST_URL": f"{fixture_url}/index.html"}, binary=binary)
|
||||
Reference in New Issue
Block a user