browserwright 0.7.4__tar.gz → 0.8.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {browserwright-0.7.4 → browserwright-0.8.1}/PKG-INFO +1 -1
- browserwright-0.8.1/pyproject.toml +112 -0
- browserwright-0.7.4/src/browserwright/api.py → browserwright-0.8.1/src/browserwright/__init__.py +30 -16
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/_executor/__init__.py +4 -14
- browserwright-0.8.1/src/browserwright/_executor/client.py +301 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/_executor/process.py +310 -157
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/_executor/protocol.py +139 -3
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/cdp.py +27 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/cli.py +167 -46
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/_ipc.py +147 -6
- browserwright-0.8.1/src/browserwright/daemon/_rpc.py +85 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/_stale.py +16 -37
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/backends/extension.py +2 -12
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/cli.py +336 -398
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/config.py +8 -4
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/launch_chrome.py +109 -9
- browserwright-0.8.1/src/browserwright/daemon/launchagent.py +224 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/observability.py +56 -0
- browserwright-0.8.1/src/browserwright/daemon/probe.py +194 -0
- browserwright-0.8.1/src/browserwright/daemon/relay_status.py +51 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/server/daemon.py +199 -5
- browserwright-0.8.1/src/browserwright/daemon/server/executor_registry.py +703 -0
- browserwright-0.8.1/src/browserwright/daemon/server/extension_upstream.py +1314 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/server/facade.py +44 -14
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/server/facade_extension.py +102 -40
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/server/listener.py +256 -107
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/server/proxy.py +182 -68
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/server/relay.py +214 -46
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/server/state.py +19 -0
- browserwright-0.8.1/src/browserwright/daemon/server/status.py +282 -0
- browserwright-0.8.1/src/browserwright/daemon/server/upstream.py +887 -0
- browserwright-0.8.1/src/browserwright/daemon/server/verbs.py +867 -0
- browserwright-0.8.1/src/browserwright/daemon/supervise.py +162 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/errors.py +31 -3
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/health.py +2 -2
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/install.py +2 -2
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/memory/_md.py +0 -1
- browserwright-0.8.1/src/browserwright/primitives/__init__.py +18 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/primitives/site.py +0 -1
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/repl/inline.py +13 -7
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/repl/playwright_handle.py +167 -40
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/session.py +30 -7
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/session_create.py +142 -58
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/session_registry.py +46 -2
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/skill_runtime.md +50 -8
- browserwright-0.8.1/src/browserwright/task_runner.py +347 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright.egg-info/PKG-INFO +1 -1
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright.egg-info/SOURCES.txt +6 -1
- browserwright-0.7.4/pyproject.toml +0 -71
- browserwright-0.7.4/src/browserwright/__init__.py +0 -33
- browserwright-0.7.4/src/browserwright/_executor/client.py +0 -135
- browserwright-0.7.4/src/browserwright/daemon/server/executor_registry.py +0 -408
- browserwright-0.7.4/src/browserwright/daemon/server/extension_upstream.py +0 -673
- browserwright-0.7.4/src/browserwright/daemon/server/upstream.py +0 -252
- browserwright-0.7.4/src/browserwright/daemon/server/verbs.py +0 -1155
- browserwright-0.7.4/src/browserwright/primitives/__init__.py +0 -35
- browserwright-0.7.4/src/browserwright/task_runner.py +0 -134
- {browserwright-0.7.4 → browserwright-0.8.1}/README.md +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/setup.cfg +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/__main__.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/_executor/__main__.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/__init__.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/backends/__init__.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/backends/base.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/backends/cdp.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/doctor.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/errors.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/platforms.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/resolver.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/server/__init__.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/userscripts.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/discovery.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/memory/__init__.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/memory/_lock.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/memory/_yaml.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/memory/global_mem.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/memory/site_mem.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/mode_b_client.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/output_schema.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/primitives/discovery_api.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/primitives/http.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/repl/__init__.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/repl/_namespace.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/repl/_smart_goto.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/repl/snapshot.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/session_ctx.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/session_runtime.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/github.com/SKILL.md +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/github.com/memory.md +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/github.com/tasks/list_issues.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/google.com/SKILL.md +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/google.com/memory.md +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/google.com/tasks/search.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/producthunt.com/SKILL.md +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/producthunt.com/memory.md +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/producthunt.com/tasks/today.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/wikipedia.org/SKILL.md +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/wikipedia.org/memory.md +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/ycombinator.com/SKILL.md +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/ycombinator.com/memory.md +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/skill_doc.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/version.py +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright.egg-info/dependency_links.txt +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright.egg-info/entry_points.txt +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright.egg-info/requires.txt +0 -0
- {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: browserwright
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.1
|
|
4
4
|
Summary: Browserwright — let AI/code agents drive a real or isolated browser and author userscripts. Single package: the agent-facing REPL/site-skills/memory layer plus the bundled browser-resolving daemon (CDP proxy + extension/cloud backends).
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Requires-Dist: websockets==15.0.1
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "browserwright"
|
|
7
|
+
# Placeholder, never bumped per release — the git tag is the single source of
|
|
8
|
+
# truth. `.github/workflows/release.yml` rewrites this line (and
|
|
9
|
+
# `chrome-extension/manifest.json`) from `GITHUB_REF_NAME` at build time, so a
|
|
10
|
+
# release commit must NOT edit it. The sentinel is `0.0.0` on purpose: it is
|
|
11
|
+
# valid semver, is below every published version, and can never be mistaken for
|
|
12
|
+
# a real release — which a stale-but-plausible number (this used to sit at
|
|
13
|
+
# "0.6.2" while tags had reached v0.8.0) very much could. Keep it in lockstep
|
|
14
|
+
# with the manifest, keep the line on its own with no trailing comment: the
|
|
15
|
+
# stamping regex is `^(version\s*=\s*)["'][^"']+["']\s*$` and CI aborts unless
|
|
16
|
+
# it matches exactly once. `tests/skill/test_release_versioning.py` enforces all
|
|
17
|
+
# of that before a tag is ever pushed. See RELEASING.md.
|
|
18
|
+
version = "0.8.1"
|
|
19
|
+
description = "Browserwright — let AI/code agents drive a real or isolated browser and author userscripts. Single package: the agent-facing REPL/site-skills/memory layer plus the bundled browser-resolving daemon (CDP proxy + extension/cloud backends)."
|
|
20
|
+
requires-python = ">=3.11"
|
|
21
|
+
dependencies = [
|
|
22
|
+
"websockets==15.0.1",
|
|
23
|
+
"pillow==12.2.0",
|
|
24
|
+
"httpx>=0.27",
|
|
25
|
+
# Phase C: the skill heredoc injects a real Playwright `page`/`context`
|
|
26
|
+
# bound to the daemon facade, so playwright is a runtime dependency now (it
|
|
27
|
+
# was previously dev-only). `playwright install chromium` is still needed
|
|
28
|
+
# for tests that launch a browser; the daemon-facade path drives the
|
|
29
|
+
# daemon-resolved Chrome and does not need Playwright's bundled browser.
|
|
30
|
+
"playwright>=1.60.0",
|
|
31
|
+
"pyyaml>=6.0.3",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
ux = ["rich>=13"]
|
|
36
|
+
|
|
37
|
+
[dependency-groups]
|
|
38
|
+
dev = [
|
|
39
|
+
"coverage>=7",
|
|
40
|
+
"pytest>=8",
|
|
41
|
+
"pytest-asyncio>=0.23",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[project.scripts]
|
|
45
|
+
browserwright = "browserwright.cli:main"
|
|
46
|
+
browserwright-daemon = "browserwright.daemon.cli:main"
|
|
47
|
+
|
|
48
|
+
[tool.setuptools]
|
|
49
|
+
package-dir = {"" = "src"}
|
|
50
|
+
include-package-data = true
|
|
51
|
+
|
|
52
|
+
[tool.setuptools.packages.find]
|
|
53
|
+
where = ["src"]
|
|
54
|
+
|
|
55
|
+
[tool.setuptools.package-data]
|
|
56
|
+
browserwright = [
|
|
57
|
+
"site_skills_starter/**/*.md",
|
|
58
|
+
"site_skills_starter/**/*.py",
|
|
59
|
+
"skill_runtime.md",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
[tool.setuptools.exclude-package-data]
|
|
63
|
+
browserwright = ["**/__pycache__/*", "**/*.pyc", "**/*.pyo"]
|
|
64
|
+
|
|
65
|
+
[tool.pytest.ini_options]
|
|
66
|
+
pythonpath = ["src"]
|
|
67
|
+
asyncio_mode = "auto"
|
|
68
|
+
testpaths = ["tests"]
|
|
69
|
+
markers = [
|
|
70
|
+
"real_chrome: end-to-end test that launches a real Chrome + extension (skipped unless explicitly selected)",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
# Lint config. `select` is explicit on purpose: ruff's *default* rule set grew
|
|
74
|
+
# substantially by 0.16.x (ASYNC/S/BLE/B/UP/RUF/... are now on by default), which
|
|
75
|
+
# silently took this repo from clean to 646 findings without a line of code
|
|
76
|
+
# changing. Pinning the ruff version (mise `[tools]`) *and* the rule set here is
|
|
77
|
+
# what makes `mise run lint` mean the same thing on every machine and across ruff
|
|
78
|
+
# upgrades. Widening the set is a deliberate, separate decision — do it in one
|
|
79
|
+
# focused pass, not as a side effect of an unrelated ruff release.
|
|
80
|
+
[tool.ruff]
|
|
81
|
+
target-version = "py311"
|
|
82
|
+
|
|
83
|
+
[tool.ruff.lint]
|
|
84
|
+
select = ["E4", "E7", "E9", "F"]
|
|
85
|
+
|
|
86
|
+
[tool.ruff.lint.per-file-ignores]
|
|
87
|
+
# api.py exists purely to re-export the agent-facing surface; the names it
|
|
88
|
+
# imports are listed in `EXPORTS` as *strings*, which ruff can't see as usage.
|
|
89
|
+
# Without this, `--fix` strips the whole re-export block and every
|
|
90
|
+
# `api.<name>` lookup dies at runtime (verified: it breaks 22 test modules).
|
|
91
|
+
"src/browserwright/api.py" = ["F401"]
|
|
92
|
+
# Site-skill task snippets are executed with a Playwright `page` injected into
|
|
93
|
+
# globals by the executor — `page` is undefined at import time by design.
|
|
94
|
+
"src/browserwright/site_skills_starter/**" = ["F821"]
|
|
95
|
+
# pytest fixtures are shadowed by same-named test parameters (the standard
|
|
96
|
+
# fixture-injection pattern), and the e2e helpers extend sys.path before
|
|
97
|
+
# importing, so imports legitimately follow statements.
|
|
98
|
+
"tests/daemon/e2e/**" = ["E402", "F811"]
|
|
99
|
+
# Agent hook scripts bootstrap sys.path before importing their own modules.
|
|
100
|
+
".claude/hooks/**" = ["E402"]
|
|
101
|
+
".cursor/hooks/**" = ["E402"]
|
|
102
|
+
|
|
103
|
+
[tool.coverage.run]
|
|
104
|
+
source = ["src/browserwright"]
|
|
105
|
+
omit = [
|
|
106
|
+
"src/browserwright/__main__.py",
|
|
107
|
+
"src/browserwright/site_skills_starter/*",
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
[tool.coverage.report]
|
|
111
|
+
skip_covered = true
|
|
112
|
+
sort = "Cover"
|
browserwright-0.7.4/src/browserwright/api.py → browserwright-0.8.1/src/browserwright/__init__.py
RENAMED
|
@@ -1,26 +1,38 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""browserwright — Layer 2 of the browser stack.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
Public surface: ``browserwright.cli:main`` is the CLI entry point.
|
|
4
|
+
|
|
5
|
+
For programmatic use inside REPL/task scripts, import names from the top-level
|
|
6
|
+
``browserwright`` namespace::
|
|
7
|
+
|
|
8
|
+
from browserwright import http_get, remember, run_task
|
|
9
|
+
|
|
10
|
+
``EXPORTS`` below is the canonical primitive surface. The inline / repl / task
|
|
11
|
+
entry points all assemble their exec globals from it
|
|
12
|
+
(``repl/_namespace.build_globals``), and ``skill_doc`` renders the generated
|
|
13
|
+
skill documentation from it — so the list an agent reads always matches the
|
|
14
|
+
list the binary actually binds. Keeping it in one place means an agent who
|
|
15
|
+
imports ``browserwright`` directly from a saved task gets the same names the
|
|
16
|
+
REPL gave them.
|
|
7
17
|
|
|
8
18
|
The legacy CDP browser-driving primitives (``open``/``goto_url``/
|
|
9
19
|
``click_at_xy``/``js``/``cdp``/``capture_screenshot``/``snapshot``/… — the
|
|
10
20
|
whole page/tab interaction surface) are DELETED. The agent drives the browser
|
|
11
21
|
with **real Playwright** via the injected ``page`` / ``context`` (bound to the
|
|
12
|
-
session's current tab, reused across heredocs) and observes with
|
|
13
|
-
|
|
14
|
-
``page.locator("aria-ref=eN")``). Those three names are injected per-heredoc
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
``browserwright.session_runtime``.
|
|
22
|
+
session's current tab, reused across heredocs) and observes with ``snapshot()``
|
|
23
|
+
(a first-party AI aria snapshot whose ``[ref=eN]`` refs feed
|
|
24
|
+
``page.locator("aria-ref=eN")``). Those three names are injected per-heredoc by
|
|
25
|
+
``repl/_namespace.build_globals``, NOT exported here; they are bound per call
|
|
26
|
+
to the session's current tab. The internal tab lifecycle the Playwright binding
|
|
27
|
+
glue relies on lives in ``browserwright.session_runtime``.
|
|
18
28
|
|
|
19
29
|
What remains in EXPORTS is the set of NON-browser-driving helpers that do not
|
|
20
30
|
overlap Playwright: ``http_get`` (no-browser escape hatch), the memory verbs,
|
|
21
31
|
and the site-skill / task layer.
|
|
22
32
|
"""
|
|
23
|
-
from .
|
|
33
|
+
from .version import __version__ # noqa: F401
|
|
34
|
+
|
|
35
|
+
from .errors import ( # noqa: F401
|
|
24
36
|
AuthWall,
|
|
25
37
|
BrowserwrightError,
|
|
26
38
|
Captcha,
|
|
@@ -31,16 +43,18 @@ from .errors import (
|
|
|
31
43
|
NetworkError,
|
|
32
44
|
PageLoadFailed,
|
|
33
45
|
)
|
|
34
|
-
from .primitives import (
|
|
35
|
-
bootstrap_site,
|
|
36
|
-
http_get,
|
|
46
|
+
from .primitives.discovery_api import ( # noqa: F401
|
|
37
47
|
list_site_skills,
|
|
38
48
|
load_site_skill,
|
|
49
|
+
run_task,
|
|
50
|
+
)
|
|
51
|
+
from .primitives.http import http_get # noqa: F401
|
|
52
|
+
from .primitives.site import ( # noqa: F401
|
|
53
|
+
bootstrap_site,
|
|
39
54
|
memory_read,
|
|
40
55
|
remember,
|
|
41
56
|
remember_global,
|
|
42
57
|
remember_preference,
|
|
43
|
-
run_task,
|
|
44
58
|
)
|
|
45
59
|
|
|
46
60
|
EXPORTS = [
|
|
@@ -29,19 +29,9 @@ Status: PR1 (process skeleton + data plane), PR2 (daemon-side supervision —
|
|
|
29
29
|
idle reap / endSession kill / crash reap / orphan sweep), and PR3 (``reset()`` +
|
|
30
30
|
full output protocol: warnings / screenshots / truncation / traceback-bearing
|
|
31
31
|
errors + per-call timeout enforcement) are all in place.
|
|
32
|
+
|
|
33
|
+
This package deliberately re-exports nothing: import ``.protocol`` (wire
|
|
34
|
+
types) or ``.client`` (thin client) directly, which is what every call site
|
|
35
|
+
already does.
|
|
32
36
|
"""
|
|
33
37
|
from __future__ import annotations
|
|
34
|
-
|
|
35
|
-
from .protocol import (
|
|
36
|
-
ExecuteRequest,
|
|
37
|
-
ExecuteResponse,
|
|
38
|
-
recv_message,
|
|
39
|
-
send_message,
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
__all__ = [
|
|
43
|
-
"ExecuteRequest",
|
|
44
|
-
"ExecuteResponse",
|
|
45
|
-
"recv_message",
|
|
46
|
-
"send_message",
|
|
47
|
-
]
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Thin-client side of the executor data plane.
|
|
2
|
+
|
|
3
|
+
Used by ``repl/inline.py`` when inline code touches ``page`` / ``context`` /
|
|
4
|
+
``snapshot`` / ``state`` / ``reset``: the whole code body is shipped to the
|
|
5
|
+
session's resident executor and the response is replayed locally.
|
|
6
|
+
|
|
7
|
+
Control plane (spawn + discover) goes through the daemon's
|
|
8
|
+
``BrowserwrightDaemon.ensureExecutor`` verb over the EXISTING mode_b socket
|
|
9
|
+
(tiny payload). The daemon spawns the executor if absent, waits for it to bind +
|
|
10
|
+
write its ``_ipc`` discovery file, and returns the socket path. The data plane
|
|
11
|
+
(this module) then connects DIRECTLY to that socket — keeping arbitrary code +
|
|
12
|
+
large output off the daemon's event loop (Fork 2).
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import signal
|
|
17
|
+
import socket
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from collections.abc import Iterator
|
|
21
|
+
from contextlib import contextmanager
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
from .. import session_registry as reg
|
|
25
|
+
from ..errors import BrowserwrightError
|
|
26
|
+
from .protocol import (
|
|
27
|
+
DEFAULT_TIMEOUT_MS,
|
|
28
|
+
ExecuteRequest,
|
|
29
|
+
ExecuteResponse,
|
|
30
|
+
TaskEnvelope,
|
|
31
|
+
recv_message,
|
|
32
|
+
send_message,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Allow the executor's deadline response a small framing/delivery margin. The
|
|
36
|
+
# browser cold-start is part of the request deadline, not hidden extra time.
|
|
37
|
+
_RESPONSE_DELIVERY_SLACK_S = 2.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ExecutorUnavailable(BrowserwrightError):
|
|
41
|
+
"""The session's executor could not be ensured/reached.
|
|
42
|
+
|
|
43
|
+
Surfaced when ``ensureExecutor`` fails or the executor socket can't be
|
|
44
|
+
connected — actionable: the daemon must be running (it spawns the
|
|
45
|
+
executor)."""
|
|
46
|
+
|
|
47
|
+
default_fix = ("ensure the daemon is running (`browserwright-daemon status "
|
|
48
|
+
"--json` should show `alive`); if the executor is stale, "
|
|
49
|
+
"call `reset()` as a standalone/final inline statement, "
|
|
50
|
+
"then retry in a new command; or run `browserwright "
|
|
51
|
+
"session reset <id>` before retrying.")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class ExecutorLease:
|
|
56
|
+
sock_path: str
|
|
57
|
+
executor_id: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _ensure_executor_lease(sess) -> ExecutorLease:
|
|
61
|
+
"""Ask the daemon to ensure the session's executor and return its socket
|
|
62
|
+
path. Uses the session's mode_b CDP client (``sess.cdp``) to send the
|
|
63
|
+
control-plane verb."""
|
|
64
|
+
sid = _session_id(sess)
|
|
65
|
+
try:
|
|
66
|
+
# The browserwright session is already bound on the websocket query
|
|
67
|
+
# (`?session=<id>`). Do not pass it as CDP's top-level `sessionId`;
|
|
68
|
+
# that field means "attached target session" inside the proxy mux.
|
|
69
|
+
res = sess.cdp.send("BrowserwrightDaemon.ensureExecutor", bsSession=sid)
|
|
70
|
+
except Exception as e:
|
|
71
|
+
raise ExecutorUnavailable(
|
|
72
|
+
f"ensureExecutor failed for session {sid!r}: {e}"
|
|
73
|
+
) from e
|
|
74
|
+
sock_path = res.get("exec_sock") if isinstance(res, dict) else None
|
|
75
|
+
executor_id = res.get("executor_id") if isinstance(res, dict) else None
|
|
76
|
+
if not isinstance(sock_path, str) or not sock_path:
|
|
77
|
+
raise ExecutorUnavailable(
|
|
78
|
+
f"ensureExecutor returned no socket for session {sid!r}: {res!r}"
|
|
79
|
+
)
|
|
80
|
+
if not isinstance(executor_id, str) or not executor_id:
|
|
81
|
+
raise ExecutorUnavailable(
|
|
82
|
+
"ensureExecutor returned no executor instance identity; restart "
|
|
83
|
+
"the daemon so timeout cleanup cannot target a newer process"
|
|
84
|
+
)
|
|
85
|
+
return ExecutorLease(sock_path=sock_path, executor_id=executor_id)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def ensure_executor(sess) -> str:
|
|
89
|
+
"""Compatibility wrapper returning only the executor socket path."""
|
|
90
|
+
return _ensure_executor_lease(sess).sock_path
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def run_on_executor(
|
|
94
|
+
sess,
|
|
95
|
+
code: str,
|
|
96
|
+
*,
|
|
97
|
+
timeout_ms: int = DEFAULT_TIMEOUT_MS,
|
|
98
|
+
env: dict[str, str] | None = None,
|
|
99
|
+
) -> ExecuteResponse:
|
|
100
|
+
"""Ship ``code`` to the session's executor and return its response.
|
|
101
|
+
|
|
102
|
+
Ensures the executor (control plane), connects its socket (data plane),
|
|
103
|
+
sends one :class:`ExecuteRequest`, reads one :class:`ExecuteResponse`.
|
|
104
|
+
|
|
105
|
+
The executor enforces ``timeout_ms`` across cold-start and user code. A
|
|
106
|
+
terminal deadline/reset response is not returned to the caller until the
|
|
107
|
+
daemon confirms that exact executor instance has exited."""
|
|
108
|
+
return _run_request_on_executor(
|
|
109
|
+
sess,
|
|
110
|
+
ExecuteRequest(
|
|
111
|
+
code=code,
|
|
112
|
+
timeout_ms=timeout_ms,
|
|
113
|
+
env=dict(env or {}),
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def run_task_on_executor(
|
|
119
|
+
sess,
|
|
120
|
+
site: str,
|
|
121
|
+
name: str,
|
|
122
|
+
*,
|
|
123
|
+
args: dict | None = None,
|
|
124
|
+
isolated: bool = False,
|
|
125
|
+
timeout_ms: int = DEFAULT_TIMEOUT_MS,
|
|
126
|
+
env: dict[str, str] | None = None,
|
|
127
|
+
) -> ExecuteResponse:
|
|
128
|
+
"""Run one validated site-skill task on the resident executor surface."""
|
|
129
|
+
return _run_request_on_executor(
|
|
130
|
+
sess,
|
|
131
|
+
ExecuteRequest(
|
|
132
|
+
code="",
|
|
133
|
+
timeout_ms=timeout_ms,
|
|
134
|
+
env=dict(env or {}),
|
|
135
|
+
task=TaskEnvelope(
|
|
136
|
+
site=site,
|
|
137
|
+
name=name,
|
|
138
|
+
args=dict(args or {}),
|
|
139
|
+
isolated=isolated,
|
|
140
|
+
),
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _run_request_on_executor(
|
|
146
|
+
sess,
|
|
147
|
+
request: ExecuteRequest,
|
|
148
|
+
) -> ExecuteResponse:
|
|
149
|
+
"""Send one request through the shared lease/reap data-plane lifecycle."""
|
|
150
|
+
# Validate/copy before touching the daemon or opening a socket. The
|
|
151
|
+
# executor validates again at the trust boundary.
|
|
152
|
+
request = ExecuteRequest.from_dict(request.to_dict())
|
|
153
|
+
sid = _session_id(sess)
|
|
154
|
+
# Session idle is "time since the last user/agent instruction", not
|
|
155
|
+
# executor process liveness. Touch before contacting the executor so a
|
|
156
|
+
# wedged or long-running executor cannot prevent the durable idle clock
|
|
157
|
+
# from reflecting that a new instruction arrived.
|
|
158
|
+
reg.touch(sid)
|
|
159
|
+
lease = _ensure_executor_lease(sess)
|
|
160
|
+
# The executor owns the exact outer deadline, including first-call
|
|
161
|
+
# cold-start. Give only a small framing/delivery margin so its terminal
|
|
162
|
+
# response wins the race against the local socket timeout.
|
|
163
|
+
recv_timeout = (
|
|
164
|
+
max(request.timeout_ms, 1) / 1000.0 + _RESPONSE_DELIVERY_SLACK_S
|
|
165
|
+
)
|
|
166
|
+
conn = _connect(lease.sock_path, timeout=recv_timeout)
|
|
167
|
+
sent = False
|
|
168
|
+
interrupted: BaseException | None = None
|
|
169
|
+
transport_error: ConnectionError | OSError | ValueError | None = None
|
|
170
|
+
msg: dict | None = None
|
|
171
|
+
try:
|
|
172
|
+
with _sigterm_as_system_exit():
|
|
173
|
+
request.executor_id = lease.executor_id
|
|
174
|
+
send_message(conn, request.to_dict())
|
|
175
|
+
sent = True
|
|
176
|
+
msg = recv_message(conn)
|
|
177
|
+
except (KeyboardInterrupt, SystemExit) as e:
|
|
178
|
+
interrupted = e
|
|
179
|
+
except (ConnectionError, OSError, ValueError) as e:
|
|
180
|
+
transport_error = e
|
|
181
|
+
finally:
|
|
182
|
+
try:
|
|
183
|
+
conn.close()
|
|
184
|
+
except OSError:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
# Close the data plane before asking the daemon to wait for process death.
|
|
188
|
+
if interrupted is not None:
|
|
189
|
+
if sent:
|
|
190
|
+
_best_effort_recycle(sess, lease.executor_id)
|
|
191
|
+
raise interrupted
|
|
192
|
+
if transport_error is not None:
|
|
193
|
+
recycle_error: Exception | None = None
|
|
194
|
+
if sent:
|
|
195
|
+
try:
|
|
196
|
+
_confirm_recycled(sess, lease.executor_id)
|
|
197
|
+
except Exception as cleanup_error: # noqa: BLE001
|
|
198
|
+
recycle_error = cleanup_error
|
|
199
|
+
suffix = (
|
|
200
|
+
f"; executor reap could not be confirmed: {recycle_error}"
|
|
201
|
+
if recycle_error is not None
|
|
202
|
+
else ""
|
|
203
|
+
)
|
|
204
|
+
raise ExecutorUnavailable(
|
|
205
|
+
f"executor data-plane error on {lease.sock_path!r}: "
|
|
206
|
+
f"{transport_error}{suffix}"
|
|
207
|
+
) from transport_error
|
|
208
|
+
if msg is None:
|
|
209
|
+
raise ExecutorUnavailable(
|
|
210
|
+
f"executor returned no response on {lease.sock_path!r}"
|
|
211
|
+
)
|
|
212
|
+
try:
|
|
213
|
+
response = ExecuteResponse.from_dict(msg)
|
|
214
|
+
except (TypeError, ValueError) as e:
|
|
215
|
+
try:
|
|
216
|
+
_confirm_recycled(sess, lease.executor_id)
|
|
217
|
+
except Exception as cleanup_error: # noqa: BLE001
|
|
218
|
+
raise ExecutorUnavailable(
|
|
219
|
+
"executor returned a malformed response and its reap could "
|
|
220
|
+
f"not be confirmed: {cleanup_error}"
|
|
221
|
+
) from e
|
|
222
|
+
raise ExecutorUnavailable(
|
|
223
|
+
f"executor returned a malformed response: {e}"
|
|
224
|
+
) from e
|
|
225
|
+
if response.terminal_reason is not None:
|
|
226
|
+
_confirm_recycled(sess, lease.executor_id)
|
|
227
|
+
return response
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _confirm_recycled(sess, executor_id: str) -> dict:
|
|
231
|
+
"""Ask the daemon to reap one exact executor and wait for process death."""
|
|
232
|
+
sid = _session_id(sess)
|
|
233
|
+
result = sess.cdp.send(
|
|
234
|
+
"BrowserwrightDaemon.killExecutor",
|
|
235
|
+
bsSession=sid,
|
|
236
|
+
executorId=executor_id,
|
|
237
|
+
wait=True,
|
|
238
|
+
)
|
|
239
|
+
if not isinstance(result, dict) or result.get("reaped") is not True:
|
|
240
|
+
raise ExecutorUnavailable(
|
|
241
|
+
f"daemon did not confirm executor {executor_id!r} was reaped: {result!r}"
|
|
242
|
+
)
|
|
243
|
+
return result
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _best_effort_recycle(sess, executor_id: str) -> None:
|
|
247
|
+
try:
|
|
248
|
+
_confirm_recycled(sess, executor_id)
|
|
249
|
+
except BaseException: # noqa: BLE001 - cannot mask the original interrupt
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@contextmanager
|
|
254
|
+
def _sigterm_as_system_exit() -> Iterator[None]:
|
|
255
|
+
"""Make SIGTERM unwind the blocking recv so exact-instance reap is tried."""
|
|
256
|
+
if (
|
|
257
|
+
not hasattr(signal, "SIGTERM")
|
|
258
|
+
or threading.current_thread() is not threading.main_thread()
|
|
259
|
+
):
|
|
260
|
+
yield
|
|
261
|
+
return
|
|
262
|
+
previous = signal.getsignal(signal.SIGTERM)
|
|
263
|
+
|
|
264
|
+
def _handle(signum, _frame):
|
|
265
|
+
raise SystemExit(128 + int(signum))
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
signal.signal(signal.SIGTERM, _handle)
|
|
269
|
+
yield
|
|
270
|
+
finally:
|
|
271
|
+
signal.signal(signal.SIGTERM, previous)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _connect(sock_path: str, *, timeout: float = 30.0,
|
|
275
|
+
retry_until: float = 5.0) -> socket.socket:
|
|
276
|
+
"""Connect the executor's unix socket, briefly retrying a not-yet-bound
|
|
277
|
+
socket (the daemon returns the path the moment it spawns; the bind may race
|
|
278
|
+
by a few ms)."""
|
|
279
|
+
deadline = time.monotonic() + retry_until
|
|
280
|
+
last: OSError | None = None
|
|
281
|
+
while True:
|
|
282
|
+
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
283
|
+
s.settimeout(timeout)
|
|
284
|
+
try:
|
|
285
|
+
s.connect(sock_path)
|
|
286
|
+
return s
|
|
287
|
+
except OSError as e:
|
|
288
|
+
last = e
|
|
289
|
+
s.close()
|
|
290
|
+
if time.monotonic() >= deadline:
|
|
291
|
+
raise ExecutorUnavailable(
|
|
292
|
+
f"could not connect executor socket {sock_path!r}: {e}"
|
|
293
|
+
) from last
|
|
294
|
+
time.sleep(0.05)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _session_id(sess) -> str:
|
|
298
|
+
rec = getattr(sess, "session_record", None)
|
|
299
|
+
if isinstance(rec, dict) and rec.get("id"):
|
|
300
|
+
return str(rec["id"])
|
|
301
|
+
raise ExecutorUnavailable("no session id bound; cannot reach an executor")
|