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.
- gecko_web_runtime/__init__.py +15 -0
- gecko_web_runtime/pipe.py +52 -0
- gecko_web_runtime/session.py +276 -0
- gecko_web_runtime_client-155.0.0.dist-info/METADATA +22 -0
- gecko_web_runtime_client-155.0.0.dist-info/RECORD +7 -0
- gecko_web_runtime_client-155.0.0.dist-info/WHEEL +5 -0
- gecko_web_runtime_client-155.0.0.dist-info/top_level.txt +1 -0
|
@@ -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 @@
|
|
|
1
|
+
gecko_web_runtime
|