gecko-web-runtime-client 155.0.0__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.
@@ -0,0 +1,15 @@
1
+ """Portable Python client for the Firefox-derived WPR runtime bundle."""
2
+
3
+ from .session import (
4
+ AsyncGeckoWorkerSession,
5
+ GeckoWorkerSession,
6
+ RuntimeConfig,
7
+ WprProtocolError,
8
+ )
9
+
10
+ __all__ = [
11
+ "AsyncGeckoWorkerSession",
12
+ "GeckoWorkerSession",
13
+ "RuntimeConfig",
14
+ "WprProtocolError",
15
+ ]
@@ -0,0 +1,52 @@
1
+ """Windows named-pipe transport for the public WPR JSON protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ import json
7
+ import msvcrt
8
+ import os
9
+ import time
10
+ from typing import Any
11
+
12
+
13
+ KERNEL32 = ctypes.WinDLL("kernel32", use_last_error=True)
14
+ GENERIC_READ = 0x80000000
15
+ GENERIC_WRITE = 0x40000000
16
+ OPEN_EXISTING = 3
17
+ INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
18
+
19
+
20
+ def connect_pipe(pipe_name: str, timeout_seconds: float = 60.0):
21
+ deadline = time.monotonic() + timeout_seconds
22
+ while time.monotonic() < deadline:
23
+ handle = KERNEL32.CreateFileW(
24
+ pipe_name,
25
+ GENERIC_READ | GENERIC_WRITE,
26
+ 0,
27
+ None,
28
+ OPEN_EXISTING,
29
+ 0,
30
+ None,
31
+ )
32
+ invalid = {-1, 0xFFFFFFFF, 0xFFFFFFFFFFFFFFFF, INVALID_HANDLE_VALUE}
33
+ if int(handle) not in invalid and int(handle) != 0:
34
+ return os.fdopen(msvcrt.open_osfhandle(handle, 0), "r+b", buffering=0)
35
+ KERNEL32.WaitNamedPipeW(pipe_name, 1000)
36
+ raise TimeoutError(f"named pipe did not become available: {pipe_name}")
37
+
38
+
39
+ def request(payload: dict[str, Any], pipe_name: str) -> dict[str, Any]:
40
+ """Send one JSON request. The Worker creates one pipe instance per RPC."""
41
+ pipe = connect_pipe(pipe_name)
42
+ try:
43
+ pipe.write((json.dumps(payload, separators=(",", ":")) + "\n").encode())
44
+ line = pipe.readline()
45
+ if not line:
46
+ raise RuntimeError("gecko worker closed the RPC pipe")
47
+ value = json.loads(line)
48
+ if not isinstance(value, dict):
49
+ raise RuntimeError("invalid WPR RPC envelope")
50
+ return value
51
+ finally:
52
+ pipe.close()
@@ -0,0 +1,276 @@
1
+ """Relocatable sync/async client for one real Gecko Worker session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import tempfile
10
+ import uuid
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from .pipe import request
16
+
17
+
18
+ PACKAGE_ROOT = Path(__file__).resolve().parents[2]
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class RuntimeConfig:
23
+ """Runtime paths resolved from the bundle, not from a developer checkout."""
24
+
25
+ package_root: Path
26
+ runtime_dir: Path
27
+ worker_exe: Path
28
+ profile_root: Path | None = None
29
+ log_root: Path | None = None
30
+
31
+ @classmethod
32
+ def bundled(cls) -> "RuntimeConfig":
33
+ root = Path(os.environ.get("WPR_RUNTIME_HOME", PACKAGE_ROOT)).resolve()
34
+ runtime = Path(os.environ.get("WPR_RUNTIME_DIR", root / "runtime")).resolve()
35
+ worker = Path(
36
+ os.environ.get("WPR_WORKER_EXE", runtime / "gecko-web-worker.exe")
37
+ ).resolve()
38
+ profile = os.environ.get("WPR_PROFILE_ROOT")
39
+ logs = os.environ.get("WPR_LOG_ROOT")
40
+ return cls(
41
+ package_root=root,
42
+ runtime_dir=runtime,
43
+ worker_exe=worker,
44
+ profile_root=Path(profile).resolve() if profile else None,
45
+ log_root=Path(logs).resolve() if logs else None,
46
+ )
47
+
48
+ def validate(self) -> None:
49
+ if os.name != "nt":
50
+ raise OSError("wpr-runtime-155-win64 requires Windows")
51
+ if not self.worker_exe.is_file():
52
+ raise FileNotFoundError(f"Worker executable not found: {self.worker_exe}")
53
+
54
+
55
+ class WprProtocolError(RuntimeError):
56
+ def __init__(self, method: str, response: dict[str, Any]):
57
+ error = response.get("error")
58
+ if isinstance(error, dict):
59
+ self.code = str(error.get("code", "UNKNOWN_ERROR"))
60
+ self.phase = str(error.get("phase", "unknown"))
61
+ message = str(error.get("message", self.code))
62
+ else:
63
+ self.code = "UNKNOWN_ERROR"
64
+ self.phase = "unknown"
65
+ message = str(error or "request failed")
66
+ self.method = method
67
+ self.response = response
68
+ super().__init__(f"{method} [{self.code}] {message}")
69
+
70
+
71
+ def require_ok(method: str, response: dict[str, Any]) -> Any:
72
+ if not response.get("ok"):
73
+ raise WprProtocolError(method, response)
74
+ return response.get("value") or {}
75
+
76
+
77
+ class GeckoWorkerSession:
78
+ """One Worker process and one BrowsingContext/CookieStore session."""
79
+
80
+ def __init__(self, config: RuntimeConfig | None = None):
81
+ self.config = config or RuntimeConfig.bundled()
82
+ self.config.validate()
83
+ self.session_id = uuid.uuid4().hex[:12]
84
+ self.pipe_name = rf"\\.\pipe\wpr-gecko-{self.session_id}"
85
+ profile_parent = self.config.profile_root
86
+ if profile_parent:
87
+ profile_parent.mkdir(parents=True, exist_ok=True)
88
+ else:
89
+ profile_parent = Path(tempfile.gettempdir())
90
+ self.profile_dir = Path(
91
+ tempfile.mkdtemp(prefix=f"wpr-profile-{self.session_id}-", dir=profile_parent)
92
+ )
93
+ (self.profile_dir / "local").mkdir(parents=True, exist_ok=True)
94
+ log_root = self.config.log_root or Path(tempfile.gettempdir()) / "wpr-runtime-logs"
95
+ log_root.mkdir(parents=True, exist_ok=True)
96
+ self.stdout = (log_root / f"wpr-{self.session_id}.stdout.log").open(
97
+ "w", encoding="utf-8", errors="replace"
98
+ )
99
+ self.stderr = (log_root / f"wpr-{self.session_id}.stderr.log").open(
100
+ "w", encoding="utf-8", errors="replace"
101
+ )
102
+ env = os.environ.copy()
103
+ env.update(
104
+ {
105
+ "WPR_RPC": "1",
106
+ "WPR_REMOTE_PAGE": "1",
107
+ "WPR_PIPE_NAME": self.pipe_name,
108
+ "WPR_PROFILE_DIR": str(self.profile_dir),
109
+ }
110
+ )
111
+ env["PATH"] = str(self.config.runtime_dir) + os.pathsep + env.get("PATH", "")
112
+ self.process = subprocess.Popen(
113
+ [str(self.config.worker_exe)],
114
+ cwd=str(self.config.runtime_dir),
115
+ env=env,
116
+ stdout=self.stdout,
117
+ stderr=self.stderr,
118
+ )
119
+ self.closed = False
120
+
121
+ def request(self, payload: dict[str, Any]) -> dict[str, Any]:
122
+ if self.closed:
123
+ raise RuntimeError("GeckoWorkerSession is closed")
124
+ return request(payload, self.pipe_name)
125
+
126
+ def page_create(self) -> dict[str, Any]:
127
+ return self.request({"method": "page.create"})
128
+
129
+ def page_navigate(self, url: str) -> dict[str, Any]:
130
+ return self.request({"method": "page.navigate", "url": url})
131
+
132
+ def page_eval(self, source: str, args: Any = None) -> Any:
133
+ payload: dict[str, Any] = {"method": "page.eval", "source": source}
134
+ if args is not None:
135
+ payload["args"] = args
136
+ return require_ok("page.eval", self.request(payload))
137
+
138
+ def controlled_navigation_begin(self, url: str) -> Any:
139
+ return require_ok(
140
+ "controlled_navigation_begin",
141
+ self.request({"method": "controlled_navigation_begin", "url": url}),
142
+ )
143
+
144
+ def navigation_allow_scripts(self, transaction_id: str, scripts: list[str]) -> Any:
145
+ return require_ok(
146
+ "navigation.allow_scripts",
147
+ self.request(
148
+ {
149
+ "method": "navigation.allow_scripts",
150
+ "transaction_id": transaction_id,
151
+ "scripts": scripts,
152
+ }
153
+ ),
154
+ )
155
+
156
+ def navigation_commit(self, transaction_id: str) -> Any:
157
+ return require_ok(
158
+ "navigation.commit",
159
+ self.request({"method": "navigation.commit", "transaction_id": transaction_id}),
160
+ )
161
+
162
+ def navigation_wait(self, transaction_id: str) -> Any:
163
+ return require_ok(
164
+ "navigation.wait",
165
+ self.request({"method": "navigation.wait", "transaction_id": transaction_id}),
166
+ )
167
+
168
+ def page_cookie_snapshot(self) -> Any:
169
+ return self.page_eval(
170
+ "(() => ({url: location.href, cookie: document.cookie, "
171
+ "readyState: document.readyState}))()"
172
+ )
173
+
174
+ def page_close(self) -> dict[str, Any]:
175
+ return self.request({"method": "page.close"})
176
+
177
+ def close(self) -> None:
178
+ if self.closed:
179
+ return
180
+ try:
181
+ if self.process.poll() is None:
182
+ try:
183
+ self.request({"method": "runtime.shutdown"})
184
+ except Exception:
185
+ pass
186
+ try:
187
+ self.process.wait(timeout=15)
188
+ except subprocess.TimeoutExpired:
189
+ self.process.kill()
190
+ self.process.wait(timeout=5)
191
+ finally:
192
+ self.closed = True
193
+ self.stdout.close()
194
+ self.stderr.close()
195
+ shutil.rmtree(self.profile_dir, ignore_errors=True)
196
+
197
+ def __enter__(self) -> "GeckoWorkerSession":
198
+ return self
199
+
200
+ def __exit__(self, exc_type, exc, tb) -> None:
201
+ self.close()
202
+
203
+
204
+ class AsyncGeckoWorkerSession:
205
+ """Async facade; DOM/JS still run only on Gecko's owner thread."""
206
+
207
+ def __init__(self, config: RuntimeConfig | None = None):
208
+ self.config = config
209
+ self._sync: GeckoWorkerSession | None = None
210
+ self._lock = asyncio.Lock()
211
+
212
+ async def __aenter__(self) -> "AsyncGeckoWorkerSession":
213
+ self._sync = await asyncio.to_thread(GeckoWorkerSession, self.config)
214
+ return self
215
+
216
+ async def request(self, payload: dict[str, Any]) -> dict[str, Any]:
217
+ if self._sync is None:
218
+ raise RuntimeError("AsyncGeckoWorkerSession is not open")
219
+ async with self._lock:
220
+ return await asyncio.to_thread(self._sync.request, payload)
221
+
222
+ async def page_create(self) -> dict[str, Any]:
223
+ return await self.request({"method": "page.create"})
224
+
225
+ async def page_navigate(self, url: str) -> dict[str, Any]:
226
+ return await self.request({"method": "page.navigate", "url": url})
227
+
228
+ async def page_eval(self, source: str, args: Any = None) -> Any:
229
+ payload: dict[str, Any] = {"method": "page.eval", "source": source}
230
+ if args is not None:
231
+ payload["args"] = args
232
+ return require_ok("page.eval", await self.request(payload))
233
+
234
+ async def controlled_navigation_begin(self, url: str) -> Any:
235
+ return require_ok(
236
+ "controlled_navigation_begin",
237
+ await self.request({"method": "controlled_navigation_begin", "url": url}),
238
+ )
239
+
240
+ async def navigation_allow_scripts(self, transaction_id: str, scripts: list[str]) -> Any:
241
+ return require_ok(
242
+ "navigation.allow_scripts",
243
+ await self.request(
244
+ {
245
+ "method": "navigation.allow_scripts",
246
+ "transaction_id": transaction_id,
247
+ "scripts": scripts,
248
+ }
249
+ ),
250
+ )
251
+
252
+ async def navigation_commit(self, transaction_id: str) -> Any:
253
+ return require_ok(
254
+ "navigation.commit",
255
+ await self.request({"method": "navigation.commit", "transaction_id": transaction_id}),
256
+ )
257
+
258
+ async def navigation_wait(self, transaction_id: str) -> Any:
259
+ return require_ok(
260
+ "navigation.wait",
261
+ await self.request({"method": "navigation.wait", "transaction_id": transaction_id}),
262
+ )
263
+
264
+ async def page_cookie_snapshot(self) -> Any:
265
+ return await self.page_eval(
266
+ "(() => ({url: location.href, cookie: document.cookie, "
267
+ "readyState: document.readyState}))()"
268
+ )
269
+
270
+ async def page_close(self) -> dict[str, Any]:
271
+ return await self.request({"method": "page.close"})
272
+
273
+ async def __aexit__(self, exc_type, exc, tb) -> None:
274
+ if self._sync is not None:
275
+ await asyncio.to_thread(self._sync.close)
276
+ self._sync = None
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.1
2
+ Name: gecko-web-runtime-client
3
+ Version: 155.0.0
4
+ Summary: Thin Python client for the Firefox-derived WPR Gecko Worker protocol
5
+ Author: WPR project
6
+ License: MPL-2.0-compatible client; see the runtime bundle notices
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+
10
+ # Python client
11
+
12
+ The client uses only Python's standard library. From this directory:
13
+
14
+ ```powershell
15
+ $env:PYTHONPATH = "$PWD"
16
+ python ..\examples\normal_navigation.py
17
+ ```
18
+
19
+ The package resolves `runtime/gecko-web-worker.exe` relative to its own
20
+ bundle. Override paths with `WPR_RUNTIME_HOME`, `WPR_RUNTIME_DIR`,
21
+ `WPR_WORKER_EXE`, `WPR_PROFILE_ROOT`, or `WPR_LOG_ROOT` when embedding it in a
22
+ larger application.
@@ -0,0 +1,7 @@
1
+ gecko_web_runtime/__init__.py,sha256=kVQLriTmJe1d3ngq0ECQS2HjgrIKgG7wSZb4u0DzF8U,310
2
+ gecko_web_runtime/pipe.py,sha256=DApeCdA0qa1iEvmPb_v9u5E-lXj4mJnXzIYbF_mWyew,1663
3
+ gecko_web_runtime/session.py,sha256=J6e23gi-yR_cYobCiu-hwquFGX0toK_w0FfgnAZ9ntk,9932
4
+ gecko_web_runtime_client-155.0.0.dist-info/METADATA,sha256=O6ijQSint0P-0SNtOWE0Kgg3eQ4GQL9ZXOwug3dszho,731
5
+ gecko_web_runtime_client-155.0.0.dist-info/WHEEL,sha256=2AKKnJ9mtikOHAUvaZxUB0niUxQWtKbUrC-HkIWuE1c,91
6
+ gecko_web_runtime_client-155.0.0.dist-info/top_level.txt,sha256=oSRLteQmBqmmSldH5FG_meNE87A7DortZyec99kekIs,18
7
+ gecko_web_runtime_client-155.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.3.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ gecko_web_runtime