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:
Adrià Arrufat
2026-07-23 18:09:21 +02:00
parent 40894a6bfd
commit f4ecf92f3b
17 changed files with 1031 additions and 0 deletions

8
bindings/python/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
__pycache__/
*.egg-info/
build/
dist/
docs/
.venv/
lightpanda/lightpanda
lightpanda/lightpanda.exe

43
bindings/python/README.md Normal file
View 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/
```

View 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",
]

View 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

View 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"]

View 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()

View 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

View 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

View File

View 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"]

View 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()

View 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
View 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,
)

View 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

View 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>

View File

@@ -0,0 +1,5 @@
<!DOCTYPE html>
<html>
<head><title>Fixture Other</title></head>
<body><p id="marker">the other page</p></body>
</html>

View 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)