Skip to content

Commit 76443ee

Browse files
authored
Merge pull request #7 from lightpanda-io/self-documenting-methods
Document every argument and method: Args sections from the tool schemas
2 parents 830f8c5 + 9f8c908 commit 76443ee

8 files changed

Lines changed: 478 additions & 100 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ binary, with tool and parameter names in snake_case (`waitForSelector` →
128128
`wait_for_selector`, `backendNodeId``backend_node_id`). `Session.call` is
129129
the escape hatch that takes the raw tool and parameter names as the MCP
130130
server declares them. The full API reference is published at
131-
[lightpanda.io/docs/reference/python-api](https://lightpanda.io/docs/reference/python-api).
131+
[lightpanda.io/docs/reference/python](https://lightpanda.io/docs/reference/python).
132132

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

213213
The API reference at
214-
[lightpanda.io/docs/reference/python-api](https://lightpanda.io/docs/reference/python-api)
214+
[lightpanda.io/docs/reference/python](https://lightpanda.io/docs/reference/python)
215215
is regenerated daily from this repository's `main` branch by the
216216
[docs repo's `python-reference` workflow](https://github.com/lightpanda-io/docs/blob/main/.github/workflows/python-reference.yml),
217217
so a merged docstring or signature change shows up there with no step on this

lightpanda/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
BiDi the same way and hands you the ``command_executor`` URL.
2727
"""
2828

29+
__docformat__ = "google"
30+
2931
from .async_browser import AsyncBrowser, AsyncSession, run_script_async
3032
from .bidi import AsyncBiDiServer, BiDiServer
3133
from .browser import Browser, Session, run_script

lightpanda/_methods.py

Lines changed: 378 additions & 80 deletions
Large diffs are not rendered by default.

lightpanda/_serve.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,25 @@ def __init__(
3131
args: Sequence[str] = (),
3232
port: int | None = None,
3333
):
34-
"""``port`` pins the listening port (default: a free one). ``args``
35-
are extra ``lightpanda serve`` flags; pass ``port=`` rather than
36-
``--port``. ``verbose`` lets the browser log through to stderr."""
34+
"""Spawn the server process.
35+
36+
Args:
37+
binary: Path to a lightpanda binary. When omitted, resolved from
38+
the ``LIGHTPANDA_BIN`` environment variable, then the binary
39+
bundled in the package, then ``PATH``.
40+
env: Extra environment variables for the spawned process.
41+
verbose: Let the browser's own logging through to stderr.
42+
args: Extra ``lightpanda serve`` flags; pass ``port=`` rather
43+
than ``--port``.
44+
port: Pin the listening port. Defaults to a free one.
45+
"""
3746
self._proc, self._port = _spawn(
3847
find_binary(binary), "serve", [*self._protocol, *args], env, verbose, port=port
3948
)
4049

4150
@property
4251
def port(self) -> int:
52+
"""The port the server listens on."""
4353
return self._port
4454

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

6474
def close(self) -> None:
75+
"""Stop the server process. Idempotent."""
6576
if self._proc is not None:
6677
_terminate(self._proc)
6778
self._proc = None
@@ -97,7 +108,15 @@ def __init__(
97108
args: Sequence[str] = (),
98109
port: int | None = None,
99110
):
100-
"""Arguments are forwarded to the sync class."""
111+
"""Prepare the facade; the process is spawned by :meth:`start`.
112+
113+
Args:
114+
binary: Forwarded to the sync class.
115+
env: Forwarded to the sync class.
116+
verbose: Forwarded to the sync class.
117+
args: Forwarded to the sync class.
118+
port: Forwarded to the sync class.
119+
"""
101120
self._kwargs = dict(binary=binary, env=env, verbose=verbose, args=args, port=port)
102121
self._server: _S | None = None
103122
self._start_lock = asyncio.Lock()
@@ -116,6 +135,7 @@ def _started(self) -> _S:
116135

117136
@property
118137
def port(self) -> int:
138+
"""The port the server listens on."""
119139
return self._started().port
120140

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

126146
async def close(self) -> None:
147+
"""Stop the server process. Idempotent."""
127148
if self._server is not None:
128149
server, self._server = self._server, None
129150
await asyncio.to_thread(server.close)

lightpanda/async_browser.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,12 @@ def __init__(self, session: Session, executor: ThreadPoolExecutor):
4242

4343
@property
4444
def id(self) -> str:
45+
"""The session id, as the browser knows it."""
4546
return self._session.id
4647

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

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

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

6063
async def __aenter__(self):
@@ -82,10 +85,18 @@ def __init__(
8285
args: Sequence[str] = (),
8386
max_concurrency: int = 32,
8487
):
85-
"""``binary``/``env``/``timeout``/``verbose``/``args`` are forwarded
86-
to :class:`Browser`. ``max_concurrency`` caps concurrently executing
87-
tool calls across this browser's sessions (worker threads are
88-
created lazily)."""
88+
"""Prepare the facade; the process is spawned by :meth:`start`.
89+
90+
Args:
91+
binary: Forwarded to :class:`Browser`.
92+
env: Forwarded to :class:`Browser`.
93+
timeout: Forwarded to :class:`Browser`.
94+
verbose: Forwarded to :class:`Browser`.
95+
args: Forwarded to :class:`Browser`.
96+
max_concurrency: Caps the tool calls executing concurrently
97+
across this browser's sessions; worker threads are created
98+
lazily.
99+
"""
89100
self._kwargs = dict(binary=binary, env=env, timeout=timeout, verbose=verbose, args=args)
90101
self._browser: Browser | None = None
91102
self._owns = True
@@ -117,6 +128,8 @@ def tools(self) -> dict[str, dict]:
117128
return self._browser.tools
118129

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

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

134147
async def close(self) -> None:
148+
"""Stop the browser process and the worker threads. A browser adopted
149+
with :meth:`wrap` is left running."""
135150
if self._browser is not None:
136151
browser, self._browser = self._browser, None
137152
if self._owns:

lightpanda/browser.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,21 @@ def __init__(self, browser: Browser, session_id: str):
7171

7272
@property
7373
def id(self) -> str:
74+
"""The session id, as the browser knows it."""
7475
return self._id
7576

7677
def call(self, tool: str, **kwargs):
7778
"""Invoke a browser tool by name. The generated methods route here.
7879
79-
Returns parsed JSON for JSON-carrying tools, ``bytes`` for image
80-
results (``screenshot`` without ``path``), otherwise the result text.
80+
Accepts the tool and argument names as the browser declares them
81+
(``waitForSelector``, ``backendNodeId``) as well as their snake_case
82+
forms. Returns parsed JSON for JSON-carrying tools, ``bytes`` for
83+
image results (``screenshot`` without ``path``), otherwise the result
84+
text. Raises :class:`ToolError` when the tool reports a failure.
85+
86+
Args:
87+
tool: The tool name.
88+
**kwargs: The tool's arguments; ``None`` values are omitted.
8189
"""
8290
if self._closed:
8391
raise ToolError(f"session {self._id} is closed")
@@ -124,6 +132,8 @@ def __getattr__(self, attr: str):
124132
raise AttributeError(f"{type(self).__name__!r} object has no attribute {attr!r}")
125133

126134
def close(self) -> None:
135+
"""Release the session's page. Idempotent; calls made after this
136+
raise :class:`ToolError`. Closing the browser closes every session."""
127137
if not self._closed:
128138
self._closed = True
129139
self._client.delete_session(self._id)
@@ -151,8 +161,19 @@ def __init__(
151161
verbose: bool = False,
152162
args: Sequence[str] = (),
153163
):
154-
"""``args`` are extra CLI flags for the spawned browser process
155-
(e.g. ``["--http-cache-dir", path]`` or cookie flags)."""
164+
"""Spawn the browser process and fetch its tool list.
165+
166+
Args:
167+
binary: Path to a lightpanda binary. When omitted, resolved from
168+
the ``LIGHTPANDA_BIN`` environment variable, then the binary
169+
bundled in the package, then ``PATH``.
170+
env: Extra environment variables for the spawned process.
171+
timeout: Seconds to wait for a response to any request before
172+
raising :class:`ProtocolError`.
173+
verbose: Let the browser's own logging through to stderr.
174+
args: Extra CLI flags for the spawned browser process, e.g.
175+
``["--http-cache-dir", path]`` or cookie flags.
176+
"""
156177
self._client = Client(binary=binary, env=env, timeout=timeout, verbose=verbose, args=args)
157178
self._seq = itertools.count(1)
158179
listed = self._client.request("tools/list")
@@ -171,11 +192,14 @@ def tools(self) -> dict[str, dict]:
171192
return self._tools
172193

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

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

181205
def __enter__(self):

lightpanda/errors.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class ProtocolError(LightpandaError):
1212
def __init__(self, message: str, code: int | None = None):
1313
super().__init__(message)
1414
self.code = code
15+
"""The JSON-RPC error code, when the server sent one."""
1516

1617

1718
class ToolError(LightpandaError):
@@ -24,5 +25,8 @@ class ScriptError(LightpandaError):
2425
def __init__(self, message: str, returncode: int, stdout: str = "", stderr: str = ""):
2526
super().__init__(message)
2627
self.returncode = returncode
28+
"""The process exit status, or ``-1`` when the script file does not exist."""
2729
self.stdout = stdout
30+
"""What the script wrote to stdout before failing."""
2831
self.stderr = stderr
32+
"""What the script wrote to stderr."""

scripts/generate_methods.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
33
Emits one concrete, annotated, documented method per browser tool, with the
44
tool name and its parameters in snake_case, all forwarding to ``Session.call``
5-
(which maps them back to the schema's names). Being real code, the methods are
5+
(which maps them back to the schema's names). Each docstring carries the tool
6+
description and a Google-style ``Args:`` section from the schema's property
7+
descriptions, so IDEs and pdoc show what every argument means. Being real code, the methods are
68
visible to IDEs, type checkers, and pdoc alike. Run with a binary available:
79
810
uv run --no-project python scripts/generate_methods.py
@@ -45,9 +47,19 @@ class {cls}:
4547
'''
4648

4749

48-
def docstring(text: str) -> str:
49-
body = text.strip().replace("\\", "\\\\").replace('"""', '\\"\\"\\"')
50-
return f' """{body}"""'
50+
def docstring(description: str, args: list[tuple[str, str]]) -> str:
51+
"""The method docstring: the tool description, then a Google-style
52+
``Args:`` section built from the schema's property descriptions."""
53+
text = description.strip()
54+
documented = [(arg, desc.strip()) for arg, desc in args if desc.strip()]
55+
if documented:
56+
text += "\n\nArgs:\n" + "\n".join(f" {arg}: {desc}" for arg, desc in documented)
57+
body = text.replace("\\", "\\\\").replace('"""', '\\"\\"\\"')
58+
lines = body.split("\n")
59+
if len(lines) == 1:
60+
return f' """{body}"""'
61+
indented = "\n".join(f" {line}" if line else "" for line in lines)
62+
return f' """{indented.lstrip()}\n """'
5163

5264

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

7993
snake = _snake(name)
8094
call_args = ", ".join([f'"{name}"'] + forwards)
8195
prefix = "async def" if is_async else "def"
8296
await_ = "await " if is_async else ""
8397
lines = [f" {prefix} {snake}({', '.join(params)}) -> Any:"]
84-
lines.append(docstring(spec["description"]))
98+
lines.append(docstring(spec["description"], documented))
8599
lines.append(f" return {await_}self.call({call_args})")
86100
return "\n".join(lines)
87101

0 commit comments

Comments
 (0)