browserwright 0.6.2__py3-none-any.whl
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/__init__.py +33 -0
- browserwright/__main__.py +6 -0
- browserwright/_executor/__init__.py +47 -0
- browserwright/_executor/__main__.py +9 -0
- browserwright/_executor/client.py +127 -0
- browserwright/_executor/process.py +652 -0
- browserwright/_executor/protocol.py +152 -0
- browserwright/api.py +66 -0
- browserwright/cdp.py +285 -0
- browserwright/cli.py +741 -0
- browserwright/daemon/__init__.py +8 -0
- browserwright/daemon/_ipc.py +444 -0
- browserwright/daemon/active_tab.py +183 -0
- browserwright/daemon/auth.py +395 -0
- browserwright/daemon/backends/__init__.py +59 -0
- browserwright/daemon/backends/base.py +120 -0
- browserwright/daemon/backends/cloud.py +222 -0
- browserwright/daemon/backends/env.py +119 -0
- browserwright/daemon/backends/extension.py +185 -0
- browserwright/daemon/backends/rdp.py +214 -0
- browserwright/daemon/cli.py +1437 -0
- browserwright/daemon/config.py +380 -0
- browserwright/daemon/doctor.py +179 -0
- browserwright/daemon/errors.py +34 -0
- browserwright/daemon/launch_chrome.py +353 -0
- browserwright/daemon/observability.py +181 -0
- browserwright/daemon/platforms.py +234 -0
- browserwright/daemon/resolver.py +72 -0
- browserwright/daemon/server/__init__.py +6 -0
- browserwright/daemon/server/daemon.py +229 -0
- browserwright/daemon/server/executor_registry.py +434 -0
- browserwright/daemon/server/extension_upstream.py +677 -0
- browserwright/daemon/server/facade.py +375 -0
- browserwright/daemon/server/facade_extension.py +969 -0
- browserwright/daemon/server/listener.py +1058 -0
- browserwright/daemon/server/proxy.py +1991 -0
- browserwright/daemon/server/relay.py +783 -0
- browserwright/daemon/server/state.py +432 -0
- browserwright/daemon/server/upstream.py +266 -0
- browserwright/daemon/userscripts.py +150 -0
- browserwright/discovery.py +213 -0
- browserwright/errors.py +177 -0
- browserwright/health.py +169 -0
- browserwright/install.py +628 -0
- browserwright/memory/__init__.py +15 -0
- browserwright/memory/_md.py +120 -0
- browserwright/memory/_yaml.py +217 -0
- browserwright/memory/global_mem.py +201 -0
- browserwright/memory/repl_mem.py +28 -0
- browserwright/memory/session_decisions.py +53 -0
- browserwright/memory/site_mem.py +381 -0
- browserwright/mode_b_client.py +590 -0
- browserwright/multitask.py +131 -0
- browserwright/output_schema.py +99 -0
- browserwright/primitives/__init__.py +67 -0
- browserwright/primitives/discovery_api.py +79 -0
- browserwright/primitives/http.py +42 -0
- browserwright/primitives/inspect.py +876 -0
- browserwright/primitives/interact.py +518 -0
- browserwright/primitives/page.py +556 -0
- browserwright/primitives/site.py +143 -0
- browserwright/release_install.py +466 -0
- browserwright/repl/__init__.py +6 -0
- browserwright/repl/_namespace.py +106 -0
- browserwright/repl/_smart_goto.py +236 -0
- browserwright/repl/inline.py +180 -0
- browserwright/repl/playwright_handle.py +449 -0
- browserwright/repl/snapshot.py +150 -0
- browserwright/session.py +229 -0
- browserwright/session_create.py +252 -0
- browserwright/session_ctx.py +24 -0
- browserwright/session_registry.py +133 -0
- browserwright/session_runtime.py +133 -0
- browserwright/site_skills_starter/github.com/SKILL.md +14 -0
- browserwright/site_skills_starter/github.com/memory.md +29 -0
- browserwright/site_skills_starter/github.com/tasks/list_issues.py +55 -0
- browserwright/site_skills_starter/google.com/SKILL.md +16 -0
- browserwright/site_skills_starter/google.com/memory.md +27 -0
- browserwright/site_skills_starter/google.com/tasks/search.py +53 -0
- browserwright/site_skills_starter/producthunt.com/SKILL.md +7 -0
- browserwright/site_skills_starter/producthunt.com/memory.md +26 -0
- browserwright/site_skills_starter/producthunt.com/tasks/today.py +64 -0
- browserwright/site_skills_starter/wikipedia.org/SKILL.md +7 -0
- browserwright/site_skills_starter/wikipedia.org/memory.md +22 -0
- browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +55 -0
- browserwright/site_skills_starter/ycombinator.com/SKILL.md +8 -0
- browserwright/site_skills_starter/ycombinator.com/memory.md +25 -0
- browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +63 -0
- browserwright/skill_doc.py +140 -0
- browserwright/skill_runtime.md +194 -0
- browserwright/subscriptions.py +213 -0
- browserwright/task_runner.py +125 -0
- browserwright/version.py +117 -0
- browserwright-0.6.2.dist-info/METADATA +12 -0
- browserwright-0.6.2.dist-info/RECORD +98 -0
- browserwright-0.6.2.dist-info/WHEEL +5 -0
- browserwright-0.6.2.dist-info/entry_points.txt +3 -0
- browserwright-0.6.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Site + memory primitives (spec §A.2 second half).
|
|
2
|
+
|
|
3
|
+
These are the calls agents reach for to record per-site knowledge. The heavy
|
|
4
|
+
lifting lives in ``memory/`` — this module is mostly a thin shim that picks the
|
|
5
|
+
right host.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
from ..errors import NeedsUserConfirm
|
|
14
|
+
from ..memory import global_memory, site_memory
|
|
15
|
+
from ..memory.site_mem import RedactionRejected, bootstrap_site as _bootstrap, host_stem
|
|
16
|
+
from ..session import current_session
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _resolve_host(host_or_url: Optional[str]) -> str:
|
|
20
|
+
"""Pick the host stem to use.
|
|
21
|
+
|
|
22
|
+
Priority:
|
|
23
|
+
1. Explicit ``host_or_url`` argument.
|
|
24
|
+
2. ``current_session().current_target_id`` → URL → host.
|
|
25
|
+
3. Raise — we never silently write to "default" memory.
|
|
26
|
+
"""
|
|
27
|
+
if host_or_url:
|
|
28
|
+
return host_stem(host_or_url)
|
|
29
|
+
sess = current_session()
|
|
30
|
+
if sess.current_target_id:
|
|
31
|
+
# Look it up via the tab list (cheap, single CDP call).
|
|
32
|
+
from .page import list_tabs
|
|
33
|
+
for t in list_tabs():
|
|
34
|
+
if t["targetId"] == sess.current_target_id and t.get("url"):
|
|
35
|
+
return host_stem(t["url"])
|
|
36
|
+
raise ValueError("remember(): no host given and no current tab to infer from")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def bootstrap_site(host: str, aliases: Optional[list[str]] = None) -> str:
|
|
40
|
+
"""Lazy-create ``site-skills/<stem>/`` with the canonical layout. US2."""
|
|
41
|
+
d = _bootstrap(host, aliases=aliases)
|
|
42
|
+
return str(d)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def remember(host: Optional[str], text: str, *, section: str = "Notes") -> str:
|
|
46
|
+
"""Append a line to site memory (auto-lazy-creates the site dir).
|
|
47
|
+
|
|
48
|
+
``host`` accepts a URL, a hostname, or ``None`` to mean "the current tab".
|
|
49
|
+
|
|
50
|
+
Refuses to write if redaction tripwires fire (high entropy, Bearer tokens,
|
|
51
|
+
absolute user paths, etc.) — surfaces the reasons on stderr.
|
|
52
|
+
"""
|
|
53
|
+
stem = _resolve_host(host)
|
|
54
|
+
mem = site_memory(stem)
|
|
55
|
+
try:
|
|
56
|
+
path = mem.append(text, section=section)
|
|
57
|
+
except RedactionRejected as e:
|
|
58
|
+
print(
|
|
59
|
+
f"[browserwright] remember() refused — redaction tripwires: {e.reasons}",
|
|
60
|
+
file=sys.stderr,
|
|
61
|
+
)
|
|
62
|
+
return ""
|
|
63
|
+
return str(path)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def remember_global(text: str, *, section: str = "Notes") -> str:
|
|
67
|
+
"""Append a free-form line to ``~/.browserwright/global.md``."""
|
|
68
|
+
global_memory().append(text, section=section)
|
|
69
|
+
return str(global_memory().path)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def remember_preference(key: str, value: Any, *, confirm: bool = True) -> dict:
|
|
73
|
+
"""Structured global preference write (spec §C.3 type D, US4).
|
|
74
|
+
|
|
75
|
+
First call (``confirm=True``) raises ``NeedsUserConfirm``: the agent must
|
|
76
|
+
surface a dialog to the user. After assent the agent re-calls with
|
|
77
|
+
``confirm=False`` and the new value lands in ``global.md`` frontmatter.
|
|
78
|
+
|
|
79
|
+
**Dotted-key semantics** (v0.3.1 — Bug 4 from the AI E2E run):
|
|
80
|
+
``key`` is interpreted as a YAML frontmatter *path*, not a literal flat
|
|
81
|
+
key. ``"daemon.preferred_backend"`` writes to ``frontmatter.daemon
|
|
82
|
+
.preferred_backend`` — i.e. a nested mapping under ``daemon:``. This
|
|
83
|
+
matches the install-wizard layout (``global.md`` keeps a ``daemon:``
|
|
84
|
+
block with ``preferred_backend`` / ``notes`` siblings) and lets the
|
|
85
|
+
agent group related preferences together.
|
|
86
|
+
|
|
87
|
+
To write a flat top-level key, simply omit the dots
|
|
88
|
+
(``remember_preference("dark_mode", True)`` → ``frontmatter.dark_mode``).
|
|
89
|
+
|
|
90
|
+
**Caveat — silent overwrite on type mismatch** (REVIEW.md F-9):
|
|
91
|
+
writing a dotted key whose root segment already holds a scalar
|
|
92
|
+
silently replaces the scalar with a dict. e.g. if
|
|
93
|
+
``frontmatter.daemon`` was previously a string and the agent calls
|
|
94
|
+
``remember_preference("daemon.preferred_backend", "rdp")``, the
|
|
95
|
+
string is destroyed and replaced with ``{preferred_backend: "rdp"}``.
|
|
96
|
+
No diagnostic is emitted. Avoid this by never mixing scalar and
|
|
97
|
+
dotted writes under the same root key; v0.6 will surface a
|
|
98
|
+
``NeedsUserConfirm`` warning when the type would change.
|
|
99
|
+
|
|
100
|
+
The companion reader ``memory_read()`` / ``browserwright memory show
|
|
101
|
+
--global`` returns the full frontmatter tree, so you can verify the
|
|
102
|
+
write took the expected shape.
|
|
103
|
+
|
|
104
|
+
Example::
|
|
105
|
+
|
|
106
|
+
# First call asks the user.
|
|
107
|
+
remember_preference("daemon.preferred_backend", "extension")
|
|
108
|
+
# → NeedsUserConfirm raised; agent dialogs the user
|
|
109
|
+
|
|
110
|
+
# After the user agrees:
|
|
111
|
+
remember_preference("daemon.preferred_backend", "extension",
|
|
112
|
+
confirm=False)
|
|
113
|
+
# → global.md frontmatter gains:
|
|
114
|
+
# daemon:
|
|
115
|
+
# preferred_backend: extension
|
|
116
|
+
# set_by_user_at: <ts>
|
|
117
|
+
"""
|
|
118
|
+
if confirm:
|
|
119
|
+
raise NeedsUserConfirm(
|
|
120
|
+
what=f"set {key} = {value!r}",
|
|
121
|
+
proposal={"key": key, "value": value},
|
|
122
|
+
)
|
|
123
|
+
return global_memory().set_preference(key, value, confirm=False)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def memory_read(site: Optional[str] = None) -> dict:
|
|
127
|
+
"""Bundle of all memory the agent might want to read.
|
|
128
|
+
|
|
129
|
+
``site=None`` → returns ``{"global": ..., "current_site": ...}`` with
|
|
130
|
+
the current tab's site memory if attached.
|
|
131
|
+
"""
|
|
132
|
+
out: dict[str, Any] = {"global": global_memory().read()}
|
|
133
|
+
if site is None:
|
|
134
|
+
sess = current_session()
|
|
135
|
+
if sess.current_target_id:
|
|
136
|
+
try:
|
|
137
|
+
stem = _resolve_host(None)
|
|
138
|
+
site = stem
|
|
139
|
+
except ValueError:
|
|
140
|
+
site = None
|
|
141
|
+
if site:
|
|
142
|
+
out["current_site"] = {"site": host_stem(site), "data": site_memory(site).read()}
|
|
143
|
+
return out
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"""Install browserwright as immutable local releases.
|
|
2
|
+
|
|
3
|
+
The development checkout may be broken at any time. Global agent entry points
|
|
4
|
+
therefore point at a copied release directory, never at the checkout.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from .version import package_version
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
ROOT_ENV = "BROWSERWRIGHT_RELEASE_ROOT"
|
|
23
|
+
LOCAL_BIN_ENV = "BROWSERWRIGHT_LOCAL_BIN"
|
|
24
|
+
SKILL_TARGETS_ENV = "BROWSERWRIGHT_SKILL_TARGETS"
|
|
25
|
+
CHROME_EXTENSION_TARGET_ENV = "BROWSERWRIGHT_CHROME_EXTENSION_TARGET"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ReleaseError(RuntimeError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def data_root() -> Path:
|
|
33
|
+
override = os.environ.get(ROOT_ENV)
|
|
34
|
+
if override:
|
|
35
|
+
return Path(override).expanduser()
|
|
36
|
+
return Path.home() / ".local" / "share" / "browserwright"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def releases_dir() -> Path:
|
|
40
|
+
return data_root() / "releases"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def release_dir(version: str) -> Path:
|
|
44
|
+
return releases_dir() / version
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def local_bin_dir() -> Path:
|
|
48
|
+
override = os.environ.get(LOCAL_BIN_ENV)
|
|
49
|
+
if override:
|
|
50
|
+
return Path(override).expanduser()
|
|
51
|
+
return Path.home() / ".local" / "bin"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def skill_targets() -> list[Path]:
|
|
55
|
+
override = os.environ.get(SKILL_TARGETS_ENV)
|
|
56
|
+
if override:
|
|
57
|
+
return [Path(p).expanduser() for p in override.split(os.pathsep) if p]
|
|
58
|
+
return [
|
|
59
|
+
Path.home() / ".claude" / "skills" / "browserwright",
|
|
60
|
+
Path.home() / ".agents" / "skills" / "browserwright",
|
|
61
|
+
Path.home() / ".pi" / "agent" / "skills" / "browserwright",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def chrome_extension_target() -> Path | None:
|
|
66
|
+
override = os.environ.get(CHROME_EXTENSION_TARGET_ENV)
|
|
67
|
+
if override:
|
|
68
|
+
return Path(override).expanduser()
|
|
69
|
+
if sys.platform == "darwin":
|
|
70
|
+
return (
|
|
71
|
+
Path.home()
|
|
72
|
+
/ "Library"
|
|
73
|
+
/ "Mobile Documents"
|
|
74
|
+
/ "com~apple~CloudDocs"
|
|
75
|
+
/ "etc"
|
|
76
|
+
/ "chrome-extension"
|
|
77
|
+
/ "browserwright"
|
|
78
|
+
)
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def repo_root() -> Path:
|
|
83
|
+
here = Path(__file__).resolve()
|
|
84
|
+
for parent in here.parents:
|
|
85
|
+
if (parent / "pyproject.toml").is_file():
|
|
86
|
+
return parent
|
|
87
|
+
raise ReleaseError("cannot locate repo root from installed package")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _file_hash(path: Path) -> bytes:
|
|
91
|
+
h = hashlib.sha256()
|
|
92
|
+
h.update(path.name.encode("utf-8"))
|
|
93
|
+
h.update(b"\0")
|
|
94
|
+
h.update(path.read_bytes())
|
|
95
|
+
return h.digest()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def hash_paths(paths: list[Path]) -> str:
|
|
99
|
+
h = hashlib.sha256()
|
|
100
|
+
for root in sorted(paths, key=lambda p: p.as_posix()):
|
|
101
|
+
if not root.exists():
|
|
102
|
+
continue
|
|
103
|
+
if root.is_file():
|
|
104
|
+
h.update(root.as_posix().encode("utf-8"))
|
|
105
|
+
h.update(b"\0")
|
|
106
|
+
h.update(_file_hash(root))
|
|
107
|
+
continue
|
|
108
|
+
for path in sorted(p for p in root.rglob("*") if p.is_file()):
|
|
109
|
+
if "__pycache__" in path.parts or path.name.endswith((".pyc", ".pyo")):
|
|
110
|
+
continue
|
|
111
|
+
rel = path.relative_to(root).as_posix()
|
|
112
|
+
h.update(root.name.encode("utf-8"))
|
|
113
|
+
h.update(b"/")
|
|
114
|
+
h.update(rel.encode("utf-8"))
|
|
115
|
+
h.update(b"\0")
|
|
116
|
+
h.update(_file_hash(path))
|
|
117
|
+
return h.hexdigest()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def hash_directory_contents(root: Path) -> str:
|
|
121
|
+
h = hashlib.sha256()
|
|
122
|
+
if not root.exists():
|
|
123
|
+
return h.hexdigest()
|
|
124
|
+
for path in sorted(p for p in root.rglob("*") if p.is_file()):
|
|
125
|
+
if "__pycache__" in path.parts or path.name.endswith((".pyc", ".pyo")):
|
|
126
|
+
continue
|
|
127
|
+
rel = path.relative_to(root).as_posix()
|
|
128
|
+
h.update(rel.encode("utf-8"))
|
|
129
|
+
h.update(b"\0")
|
|
130
|
+
h.update(_file_hash(path))
|
|
131
|
+
return h.hexdigest()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def component_hashes(root: Path | None = None) -> dict[str, str]:
|
|
135
|
+
root = repo_root() if root is None else root
|
|
136
|
+
return {
|
|
137
|
+
"python": hash_paths([
|
|
138
|
+
root / "pyproject.toml",
|
|
139
|
+
root / "uv.lock",
|
|
140
|
+
root / "src" / "browserwright",
|
|
141
|
+
]),
|
|
142
|
+
"skill": hash_paths([root / "skill"]),
|
|
143
|
+
"chrome_extension": hash_paths([root / "chrome-extension"]),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def git_info(root: Path | None = None) -> dict[str, Any]:
|
|
148
|
+
root = repo_root() if root is None else root
|
|
149
|
+
|
|
150
|
+
def run(args: list[str]) -> str | None:
|
|
151
|
+
try:
|
|
152
|
+
proc = subprocess.run(
|
|
153
|
+
["git", *args],
|
|
154
|
+
cwd=root,
|
|
155
|
+
capture_output=True,
|
|
156
|
+
text=True,
|
|
157
|
+
timeout=5,
|
|
158
|
+
)
|
|
159
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
160
|
+
return None
|
|
161
|
+
if proc.returncode != 0:
|
|
162
|
+
return None
|
|
163
|
+
return proc.stdout.strip()
|
|
164
|
+
|
|
165
|
+
status = run(["status", "--porcelain"])
|
|
166
|
+
return {
|
|
167
|
+
"commit": run(["rev-parse", "HEAD"]),
|
|
168
|
+
"dirty": bool(status),
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def read_release_metadata(version: str) -> dict[str, Any] | None:
|
|
173
|
+
path = release_dir(version) / "release.json"
|
|
174
|
+
try:
|
|
175
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
176
|
+
except (FileNotFoundError, ValueError, OSError):
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def list_releases() -> list[dict[str, Any]]:
|
|
181
|
+
out: list[dict[str, Any]] = []
|
|
182
|
+
base = releases_dir()
|
|
183
|
+
if not base.exists():
|
|
184
|
+
return out
|
|
185
|
+
for entry in sorted(base.iterdir(), key=lambda p: p.name):
|
|
186
|
+
if not entry.is_dir():
|
|
187
|
+
continue
|
|
188
|
+
meta = read_release_metadata(entry.name) or {"version": entry.name}
|
|
189
|
+
meta["path"] = str(entry)
|
|
190
|
+
meta["active"] = entry.resolve() == active_release_dir()
|
|
191
|
+
out.append(meta)
|
|
192
|
+
return out
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _run(cmd: list[str], *, cwd: Path | None = None) -> None:
|
|
196
|
+
try:
|
|
197
|
+
proc = subprocess.run(cmd, cwd=cwd, text=True, capture_output=True)
|
|
198
|
+
except FileNotFoundError as e:
|
|
199
|
+
raise ReleaseError(f"command not found: {cmd[0]}") from e
|
|
200
|
+
if proc.returncode != 0:
|
|
201
|
+
detail = (proc.stderr or proc.stdout or "").strip()
|
|
202
|
+
suffix = f": {detail}" if detail else ""
|
|
203
|
+
raise ReleaseError(f"command failed ({proc.returncode}): {' '.join(cmd)}{suffix}")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _copytree(src: Path, dst: Path) -> None:
|
|
207
|
+
if not src.is_dir():
|
|
208
|
+
raise ReleaseError(f"required directory missing: {src}")
|
|
209
|
+
shutil.copytree(
|
|
210
|
+
src,
|
|
211
|
+
dst,
|
|
212
|
+
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store"),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _copytree_replace(src: Path, dst: Path) -> None:
|
|
217
|
+
if not src.is_dir():
|
|
218
|
+
raise ReleaseError(f"required directory missing: {src}")
|
|
219
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
220
|
+
tmp = dst.with_name(f".{dst.name}.tmp-{os.getpid()}")
|
|
221
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
222
|
+
if tmp.exists() or tmp.is_symlink():
|
|
223
|
+
tmp.unlink()
|
|
224
|
+
_copytree(src, tmp)
|
|
225
|
+
if dst.exists() and not dst.is_symlink():
|
|
226
|
+
shutil.rmtree(dst)
|
|
227
|
+
elif dst.exists() or dst.is_symlink():
|
|
228
|
+
dst.unlink()
|
|
229
|
+
os.replace(tmp, dst)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def sync_chrome_extension(version: str) -> dict[str, Any] | None:
|
|
233
|
+
"""Copy the active release extension into the stable Chrome load path."""
|
|
234
|
+
target = chrome_extension_target()
|
|
235
|
+
if target is None:
|
|
236
|
+
return None
|
|
237
|
+
src = release_dir(version) / "chrome-extension"
|
|
238
|
+
if not src.is_dir():
|
|
239
|
+
raise ReleaseError(f"release chrome-extension missing: {src}")
|
|
240
|
+
if target.exists() and target.is_symlink():
|
|
241
|
+
target.unlink()
|
|
242
|
+
elif target.exists() and not target.is_dir():
|
|
243
|
+
raise ReleaseError(f"chrome extension target is not a directory: {target}")
|
|
244
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
245
|
+
tmp = target.with_name(f".{target.name}.tmp-{os.getpid()}")
|
|
246
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
247
|
+
shutil.copytree(
|
|
248
|
+
src,
|
|
249
|
+
tmp,
|
|
250
|
+
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store"),
|
|
251
|
+
)
|
|
252
|
+
if target.exists():
|
|
253
|
+
shutil.rmtree(target)
|
|
254
|
+
os.replace(tmp, target)
|
|
255
|
+
return {"path": str(target), "source": str(src)}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _venv_python(venv: Path) -> Path:
|
|
259
|
+
if sys.platform == "win32":
|
|
260
|
+
return venv / "Scripts" / "python.exe"
|
|
261
|
+
return venv / "bin" / "python"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _build_wheel(root: Path, target: Path) -> Path:
|
|
265
|
+
dist = target / "dist"
|
|
266
|
+
dist.mkdir(parents=True, exist_ok=True)
|
|
267
|
+
_run(["uv", "build", "--wheel", "--out-dir", str(dist)], cwd=root)
|
|
268
|
+
wheels = sorted(dist.glob("browserwright-*.whl"))
|
|
269
|
+
if not wheels:
|
|
270
|
+
raise ReleaseError("uv build did not produce a browserwright wheel")
|
|
271
|
+
return wheels[-1]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _install_wheel(root: Path, target: Path) -> None:
|
|
275
|
+
wheels = sorted((target / "dist").glob("browserwright-*.whl"))
|
|
276
|
+
if not wheels:
|
|
277
|
+
raise ReleaseError("release dist does not contain a browserwright wheel")
|
|
278
|
+
venv = target / ".venv"
|
|
279
|
+
_run(["uv", "venv", str(venv)], cwd=root)
|
|
280
|
+
py = _venv_python(venv)
|
|
281
|
+
_run(["uv", "pip", "install", "--python", str(py), str(wheels[-1])], cwd=root)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _atomic_symlink(target: Path, link: Path) -> None:
|
|
285
|
+
link.parent.mkdir(parents=True, exist_ok=True)
|
|
286
|
+
tmp = link.with_name(f".{link.name}.tmp-{os.getpid()}")
|
|
287
|
+
try:
|
|
288
|
+
tmp.unlink()
|
|
289
|
+
except FileNotFoundError:
|
|
290
|
+
pass
|
|
291
|
+
os.symlink(str(target), str(tmp))
|
|
292
|
+
os.replace(tmp, link)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def active_release_dir() -> Path | None:
|
|
296
|
+
link = local_bin_dir() / "browserwright"
|
|
297
|
+
if not link.is_symlink():
|
|
298
|
+
return None
|
|
299
|
+
try:
|
|
300
|
+
target = link.resolve()
|
|
301
|
+
except OSError:
|
|
302
|
+
return None
|
|
303
|
+
for parent in target.parents:
|
|
304
|
+
if parent.parent == releases_dir():
|
|
305
|
+
return parent
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def active_version() -> str | None:
|
|
310
|
+
active = active_release_dir()
|
|
311
|
+
return active.name if active else None
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def activate(version: str) -> dict[str, Any]:
|
|
315
|
+
target = release_dir(version)
|
|
316
|
+
if not target.is_dir():
|
|
317
|
+
raise ReleaseError(f"release not installed: {version}")
|
|
318
|
+
bin_dir = local_bin_dir()
|
|
319
|
+
_atomic_symlink(target / ".venv" / "bin" / "browserwright", bin_dir / "browserwright")
|
|
320
|
+
_atomic_symlink(
|
|
321
|
+
target / ".venv" / "bin" / "browserwright-daemon",
|
|
322
|
+
bin_dir / "browserwright-daemon",
|
|
323
|
+
)
|
|
324
|
+
for skill in skill_targets():
|
|
325
|
+
_copytree_replace(target / "skill", skill)
|
|
326
|
+
return {"version": version, "path": str(target)}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def install_local(*, force: bool = False, activate_release: bool = True) -> dict[str, Any]:
|
|
330
|
+
root = repo_root()
|
|
331
|
+
version = package_version()
|
|
332
|
+
final = release_dir(version)
|
|
333
|
+
previous_version = active_version()
|
|
334
|
+
previous_meta = read_release_metadata(previous_version) if previous_version else None
|
|
335
|
+
if final.exists():
|
|
336
|
+
if not force:
|
|
337
|
+
raise ReleaseError(f"release {version} already exists; use --force to replace it")
|
|
338
|
+
releases_dir().mkdir(parents=True, exist_ok=True)
|
|
339
|
+
tmp = Path(tempfile.mkdtemp(prefix=f".{version}.", dir=str(releases_dir())))
|
|
340
|
+
backup: Path | None = None
|
|
341
|
+
try:
|
|
342
|
+
_copytree(root / "skill", tmp / "skill")
|
|
343
|
+
_copytree(root / "chrome-extension", tmp / "chrome-extension")
|
|
344
|
+
_build_wheel(root, tmp)
|
|
345
|
+
hashes = component_hashes(root)
|
|
346
|
+
meta = {
|
|
347
|
+
"schema_version": 1,
|
|
348
|
+
"version": version,
|
|
349
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
350
|
+
"source": {
|
|
351
|
+
"repo": str(root),
|
|
352
|
+
**git_info(root),
|
|
353
|
+
},
|
|
354
|
+
"components": hashes,
|
|
355
|
+
}
|
|
356
|
+
(tmp / "release.json").write_text(
|
|
357
|
+
json.dumps(meta, indent=2, sort_keys=True) + "\n",
|
|
358
|
+
encoding="utf-8",
|
|
359
|
+
)
|
|
360
|
+
if final.exists():
|
|
361
|
+
backup = final.with_name(f".{final.name}.backup-{os.getpid()}")
|
|
362
|
+
shutil.rmtree(backup, ignore_errors=True)
|
|
363
|
+
os.replace(final, backup)
|
|
364
|
+
os.replace(tmp, final)
|
|
365
|
+
_install_wheel(root, final)
|
|
366
|
+
if backup is not None:
|
|
367
|
+
shutil.rmtree(backup, ignore_errors=True)
|
|
368
|
+
except Exception:
|
|
369
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
370
|
+
if backup is not None:
|
|
371
|
+
shutil.rmtree(final, ignore_errors=True)
|
|
372
|
+
if backup.exists():
|
|
373
|
+
os.replace(backup, final)
|
|
374
|
+
elif not final.exists() or not (final / ".venv").exists():
|
|
375
|
+
shutil.rmtree(final, ignore_errors=True)
|
|
376
|
+
raise
|
|
377
|
+
|
|
378
|
+
activated = activate(version) if activate_release else None
|
|
379
|
+
extension_sync = sync_chrome_extension(version)
|
|
380
|
+
changes = compare_components(previous_meta, meta)
|
|
381
|
+
return {
|
|
382
|
+
"ok": True,
|
|
383
|
+
"version": version,
|
|
384
|
+
"path": str(final),
|
|
385
|
+
"previous_version": previous_version,
|
|
386
|
+
"activated": activated is not None,
|
|
387
|
+
"chrome_extension_sync": extension_sync,
|
|
388
|
+
"changes": changes,
|
|
389
|
+
"actions": required_actions(changes),
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def compare_components(
|
|
394
|
+
previous_meta: dict[str, Any] | None,
|
|
395
|
+
current_meta: dict[str, Any],
|
|
396
|
+
) -> dict[str, bool]:
|
|
397
|
+
prev = (previous_meta or {}).get("components") or {}
|
|
398
|
+
cur = current_meta.get("components") or {}
|
|
399
|
+
return {name: prev.get(name) != digest for name, digest in cur.items()}
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def required_actions(changes: dict[str, bool]) -> dict[str, bool]:
|
|
403
|
+
return {
|
|
404
|
+
"restart_daemon": bool(changes.get("python")),
|
|
405
|
+
"reload_chrome_extension": bool(changes.get("chrome_extension")),
|
|
406
|
+
"skill_relinked": bool(changes.get("skill")),
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _daemon_status() -> dict[str, Any]:
|
|
411
|
+
try:
|
|
412
|
+
proc = subprocess.run(
|
|
413
|
+
["browserwright-daemon", "status", "--json"],
|
|
414
|
+
capture_output=True,
|
|
415
|
+
text=True,
|
|
416
|
+
timeout=5,
|
|
417
|
+
)
|
|
418
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
419
|
+
return {"alive": False, "version": None, "error": "status unavailable"}
|
|
420
|
+
try:
|
|
421
|
+
data = json.loads(proc.stdout or "{}")
|
|
422
|
+
except ValueError:
|
|
423
|
+
data = {}
|
|
424
|
+
data.setdefault("alive", proc.returncode == 0)
|
|
425
|
+
data.setdefault("version", None)
|
|
426
|
+
return data
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def status() -> dict[str, Any]:
|
|
430
|
+
active = active_release_dir()
|
|
431
|
+
version = active.name if active else None
|
|
432
|
+
daemon = _daemon_status()
|
|
433
|
+
active_skill = active / "skill" if active else None
|
|
434
|
+
expected_skill_hash = (
|
|
435
|
+
hash_directory_contents(active_skill) if active_skill is not None else None
|
|
436
|
+
)
|
|
437
|
+
skill = [
|
|
438
|
+
{
|
|
439
|
+
"path": str(path),
|
|
440
|
+
"target": str(path.resolve()) if path.is_symlink() else None,
|
|
441
|
+
"ok": (
|
|
442
|
+
path.is_dir()
|
|
443
|
+
and not path.is_symlink()
|
|
444
|
+
and expected_skill_hash is not None
|
|
445
|
+
and hash_directory_contents(path) == expected_skill_hash
|
|
446
|
+
),
|
|
447
|
+
}
|
|
448
|
+
for path in skill_targets()
|
|
449
|
+
]
|
|
450
|
+
return {
|
|
451
|
+
"schema_version": 1,
|
|
452
|
+
"installed_version": version,
|
|
453
|
+
"installed_path": str(active) if active else None,
|
|
454
|
+
"chrome_extension_target": str(chrome_extension_target())
|
|
455
|
+
if chrome_extension_target()
|
|
456
|
+
else None,
|
|
457
|
+
"daemon": {
|
|
458
|
+
"alive": bool(daemon.get("alive")),
|
|
459
|
+
"version": daemon.get("version"),
|
|
460
|
+
"restart_required": bool(
|
|
461
|
+
daemon.get("alive") and version and daemon.get("version") != version
|
|
462
|
+
),
|
|
463
|
+
},
|
|
464
|
+
"skill": skill,
|
|
465
|
+
"releases": list_releases(),
|
|
466
|
+
}
|