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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ binary, with tool and parameter names in snake_case (`waitForSelector` →
`wait_for_selector`, `backendNodeId` → `backend_node_id`). `Session.call` is
the escape hatch that takes the raw tool and parameter names as the MCP
server declares them. The full API reference is published at
[lightpanda.io/docs/reference/python-api](https://lightpanda.io/docs/reference/python-api).
[lightpanda.io/docs/reference/python](https://lightpanda.io/docs/reference/python).

The bindings follow Lightpanda's development and the package version tracks
browser releases — there is no backwards-compatibility guarantee: when the
Expand Down Expand Up @@ -211,7 +211,7 @@ an exact `==0.4.0` pin deliberately does not, so pin with `~=0.4.0` to
receive them. The `workflow_dispatch` path has a matching `post` input.

The API reference at
[lightpanda.io/docs/reference/python-api](https://lightpanda.io/docs/reference/python-api)
[lightpanda.io/docs/reference/python](https://lightpanda.io/docs/reference/python)
is regenerated daily from this repository's `main` branch by the
[docs repo's `python-reference` workflow](https://github.com/lightpanda-io/docs/blob/main/.github/workflows/python-reference.yml),
so a merged docstring or signature change shows up there with no step on this
Expand Down
2 changes: 2 additions & 0 deletions lightpanda/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
BiDi the same way and hands you the ``command_executor`` URL.
"""

__docformat__ = "google"

from .async_browser import AsyncBrowser, AsyncSession, run_script_async
from .bidi import AsyncBiDiServer, BiDiServer
from .browser import Browser, Session, run_script
Expand Down
458 changes: 378 additions & 80 deletions lightpanda/_methods.py

Large diffs are not rendered by default.

29 changes: 25 additions & 4 deletions lightpanda/_serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,25 @@ def __init__(
args: Sequence[str] = (),
port: int | None = None,
):
"""``port`` pins the listening port (default: a free one). ``args``
are extra ``lightpanda serve`` flags; pass ``port=`` rather than
``--port``. ``verbose`` lets the browser log through to stderr."""
"""Spawn the server process.

Args:
binary: Path to a lightpanda binary. When omitted, resolved from
the ``LIGHTPANDA_BIN`` environment variable, then the binary
bundled in the package, then ``PATH``.
env: Extra environment variables for the spawned process.
verbose: Let the browser's own logging through to stderr.
args: Extra ``lightpanda serve`` flags; pass ``port=`` rather
than ``--port``.
port: Pin the listening port. Defaults to a free one.
"""
self._proc, self._port = _spawn(
find_binary(binary), "serve", [*self._protocol, *args], env, verbose, port=port
)

@property
def port(self) -> int:
"""The port the server listens on."""
return self._port

@property
Expand All @@ -62,6 +72,7 @@ def _get_json(self, path: str) -> dict:
raise LightpandaError(f"GET {url} failed: {err}") from err

def close(self) -> None:
"""Stop the server process. Idempotent."""
if self._proc is not None:
_terminate(self._proc)
self._proc = None
Expand Down Expand Up @@ -97,7 +108,15 @@ def __init__(
args: Sequence[str] = (),
port: int | None = None,
):
"""Arguments are forwarded to the sync class."""
"""Prepare the facade; the process is spawned by :meth:`start`.

Args:
binary: Forwarded to the sync class.
env: Forwarded to the sync class.
verbose: Forwarded to the sync class.
args: Forwarded to the sync class.
port: Forwarded to the sync class.
"""
self._kwargs = dict(binary=binary, env=env, verbose=verbose, args=args, port=port)
self._server: _S | None = None
self._start_lock = asyncio.Lock()
Expand All @@ -116,6 +135,7 @@ def _started(self) -> _S:

@property
def port(self) -> int:
"""The port the server listens on."""
return self._started().port

@property
Expand All @@ -124,6 +144,7 @@ def http_endpoint(self) -> str:
return self._started().http_endpoint

async def close(self) -> None:
"""Stop the server process. Idempotent."""
if self._server is not None:
server, self._server = self._server, None
await asyncio.to_thread(server.close)
Expand Down
25 changes: 20 additions & 5 deletions lightpanda/async_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,12 @@ def __init__(self, session: Session, executor: ThreadPoolExecutor):

@property
def id(self) -> str:
"""The session id, as the browser knows it."""
return self._session.id

async def call(self, tool: str, **kwargs):
"""Invoke a browser tool by name. The generated methods route here."""
"""Invoke a browser tool by name. The generated methods route here.
Same contract as :meth:`Session.call`, awaitable."""
return await _run(self._executor, self._session.call, tool, **kwargs)

def __getattr__(self, attr: str):
Expand All @@ -55,6 +57,7 @@ def __getattr__(self, attr: str):
raise AttributeError(f"{type(self).__name__!r} object has no attribute {attr!r}")

async def close(self) -> None:
"""Release the session's page; see :meth:`Session.close`."""
await _run(self._executor, self._session.close)

async def __aenter__(self):
Expand Down Expand Up @@ -82,10 +85,18 @@ def __init__(
args: Sequence[str] = (),
max_concurrency: int = 32,
):
"""``binary``/``env``/``timeout``/``verbose``/``args`` are forwarded
to :class:`Browser`. ``max_concurrency`` caps concurrently executing
tool calls across this browser's sessions (worker threads are
created lazily)."""
"""Prepare the facade; the process is spawned by :meth:`start`.

Args:
binary: Forwarded to :class:`Browser`.
env: Forwarded to :class:`Browser`.
timeout: Forwarded to :class:`Browser`.
verbose: Forwarded to :class:`Browser`.
args: Forwarded to :class:`Browser`.
max_concurrency: Caps the tool calls executing concurrently
across this browser's sessions; worker threads are created
lazily.
"""
self._kwargs = dict(binary=binary, env=env, timeout=timeout, verbose=verbose, args=args)
self._browser: Browser | None = None
self._owns = True
Expand Down Expand Up @@ -117,6 +128,8 @@ def tools(self) -> dict[str, dict]:
return self._browser.tools

async def new_session(self) -> AsyncSession:
"""Start the browser if needed, then open a new isolated browsing
context: its own page, cookies and memory."""
await self.start()
return AsyncSession(await _run(self._executor, self._browser.new_session), self._executor)

Expand All @@ -132,6 +145,8 @@ async def session(self):
await page.close()

async def close(self) -> None:
"""Stop the browser process and the worker threads. A browser adopted
with :meth:`wrap` is left running."""
if self._browser is not None:
browser, self._browser = self._browser, None
if self._owns:
Expand Down
32 changes: 28 additions & 4 deletions lightpanda/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,21 @@ def __init__(self, browser: Browser, session_id: str):

@property
def id(self) -> str:
"""The session id, as the browser knows it."""
return self._id

def call(self, tool: str, **kwargs):
"""Invoke a browser tool by name. The generated methods route here.

Returns parsed JSON for JSON-carrying tools, ``bytes`` for image
results (``screenshot`` without ``path``), otherwise the result text.
Accepts the tool and argument names as the browser declares them
(``waitForSelector``, ``backendNodeId``) as well as their snake_case
forms. Returns parsed JSON for JSON-carrying tools, ``bytes`` for
image results (``screenshot`` without ``path``), otherwise the result
text. Raises :class:`ToolError` when the tool reports a failure.

Args:
tool: The tool name.
**kwargs: The tool's arguments; ``None`` values are omitted.
"""
if self._closed:
raise ToolError(f"session {self._id} is closed")
Expand Down Expand Up @@ -124,6 +132,8 @@ def __getattr__(self, attr: str):
raise AttributeError(f"{type(self).__name__!r} object has no attribute {attr!r}")

def close(self) -> None:
"""Release the session's page. Idempotent; calls made after this
raise :class:`ToolError`. Closing the browser closes every session."""
if not self._closed:
self._closed = True
self._client.delete_session(self._id)
Expand Down Expand Up @@ -151,8 +161,19 @@ def __init__(
verbose: bool = False,
args: Sequence[str] = (),
):
"""``args`` are extra CLI flags for the spawned browser process
(e.g. ``["--http-cache-dir", path]`` or cookie flags)."""
"""Spawn the browser process and fetch its tool list.

Args:
binary: Path to a lightpanda binary. When omitted, resolved from
the ``LIGHTPANDA_BIN`` environment variable, then the binary
bundled in the package, then ``PATH``.
env: Extra environment variables for the spawned process.
timeout: Seconds to wait for a response to any request before
raising :class:`ProtocolError`.
verbose: Let the browser's own logging through to stderr.
args: Extra CLI flags for the spawned browser process, e.g.
``["--http-cache-dir", path]`` or cookie flags.
"""
self._client = Client(binary=binary, env=env, timeout=timeout, verbose=verbose, args=args)
self._seq = itertools.count(1)
listed = self._client.request("tools/list")
Expand All @@ -171,11 +192,14 @@ def tools(self) -> dict[str, dict]:
return self._tools

def new_session(self) -> Session:
"""Open a new isolated browsing context: its own page, cookies and
memory. Close it with :meth:`Session.close` or a ``with`` block."""
# itertools.count is atomic, so concurrent callers (the async facade's
# worker threads) can't mint duplicate session ids.
return Session(self, f"py{next(self._seq)}")

def close(self) -> None:
"""Stop the browser process, closing every session with it."""
self._client.close()

def __enter__(self):
Expand Down
4 changes: 4 additions & 0 deletions lightpanda/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class ProtocolError(LightpandaError):
def __init__(self, message: str, code: int | None = None):
super().__init__(message)
self.code = code
"""The JSON-RPC error code, when the server sent one."""


class ToolError(LightpandaError):
Expand All @@ -24,5 +25,8 @@ class ScriptError(LightpandaError):
def __init__(self, message: str, returncode: int, stdout: str = "", stderr: str = ""):
super().__init__(message)
self.returncode = returncode
"""The process exit status, or ``-1`` when the script file does not exist."""
self.stdout = stdout
"""What the script wrote to stdout before failing."""
self.stderr = stderr
"""What the script wrote to stderr."""
24 changes: 19 additions & 5 deletions scripts/generate_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

Emits one concrete, annotated, documented method per browser tool, with the
tool name and its parameters in snake_case, all forwarding to ``Session.call``
(which maps them back to the schema's names). Being real code, the methods are
(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
visible to IDEs, type checkers, and pdoc alike. Run with a binary available:

uv run --no-project python scripts/generate_methods.py
Expand Down Expand Up @@ -45,9 +47,19 @@ class {cls}:
'''


def docstring(text: str) -> str:
body = text.strip().replace("\\", "\\\\").replace('"""', '\\"\\"\\"')
return f' """{body}"""'
def docstring(description: str, args: list[tuple[str, str]]) -> str:
"""The method docstring: the tool description, then a Google-style
``Args:`` section built from the schema's property descriptions."""
text = description.strip()
documented = [(arg, desc.strip()) for arg, desc in args if desc.strip()]
if documented:
text += "\n\nArgs:\n" + "\n".join(f" {arg}: {desc}" for arg, desc in documented)
body = text.replace("\\", "\\\\").replace('"""', '\\"\\"\\"')
lines = body.split("\n")
if len(lines) == 1:
return f' """{body}"""'
indented = "\n".join(f" {line}" if line else "" for line in lines)
return f' """{indented.lstrip()}\n """'


def method_source(name: str, spec: dict, is_async: bool = False) -> str:
Expand All @@ -65,6 +77,7 @@ def method_source(name: str, spec: dict, is_async: bool = False) -> str:
if properties:
params.append("*")
forwards = []
documented = []
for prop in sorted(properties, key=lambda p: p not in required):
arg = args[prop]
py_type = PY_TYPES.get(properties[prop].get("type", ""), "Any")
Expand All @@ -75,13 +88,14 @@ def method_source(name: str, spec: dict, is_async: bool = False) -> str:
else:
params.append(f"{arg}: {py_type} | None = None")
forwards.append(f"{arg}={arg}")
documented.append((arg, properties[prop].get("description", "")))

snake = _snake(name)
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:"]
lines.append(docstring(spec["description"]))
lines.append(docstring(spec["description"], documented))
lines.append(f" return {await_}self.call({call_args})")
return "\n".join(lines)

Expand Down
Loading