wpr-http 0.2.0__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.
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.4
2
+ Name: wpr-http
3
+ Version: 0.2.0
4
+ Summary: Standalone HTTP client using version-selected Firefox Necko/NSS Workers
5
+ License: MPL-2.0-compatible client; see runtime bundle notices
6
+ Project-URL: Repository, https://github.com/childclm/wpr-http
7
+ Project-URL: Documentation, https://github.com/childclm/wpr-http/blob/main/README.md
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: wpr-runtime-core<0.3,>=0.2.0
11
+
12
+ # WPR HTTP
13
+
14
+ Standalone HTTP requests using real, version-selected Firefox Necko/NSS network
15
+ Workers. Installing this package does not install the WPR browser client.
16
+ The native runtime is downloaded separately and remains centrally built.
17
+
18
+ ```python
19
+ import asyncio
20
+ from wpr_http import AsyncClient
21
+
22
+ async def main():
23
+ async with AsyncClient(firefox_version="155") as client:
24
+ response = await client.get("https://www.baidu.com/")
25
+ async with response:
26
+ response.raise_for_status()
27
+ print(response.status_code, await response.atext())
28
+
29
+ asyncio.run(main())
30
+ ```
31
+
32
+ `firefox_version` selects a genuine installed Gecko network engine, not a
33
+ User-Agent string or a simulated browser. Only Firefox 155 is currently built
34
+ and supported. Selecting another version raises an explicit compatibility error;
35
+ it never silently uses 155. Different installed engine versions use separate
36
+ runtime/profile locations and separate Worker processes.
37
+
38
+ Runtime installation and supported versions:
39
+
40
+ ```powershell
41
+ python -m wpr_http runtimes
42
+ python -m wpr_http install-runtime --version 155
43
+ ```
44
+
45
+ An explicit `runtime_home` can select another verified runtime installation.
46
+ Existing `AsyncWprHttpClient`, Request/Response/Timeout and exception names remain
47
+ available. `WprHttpClient` remains the historical asynchronous alias.
48
+
49
+ The native network runtime is a separate download from the private
50
+ `childclm/gecko-web-runtime` Release. Installation needs access to that repository. Set
51
+ `WPR_GITHUB_TOKEN` or `GH_TOKEN`, or use Git Credential Manager, then run
52
+ `python -m wpr_http install-runtime --version 155`.
@@ -0,0 +1,41 @@
1
+ # WPR HTTP
2
+
3
+ Standalone HTTP requests using real, version-selected Firefox Necko/NSS network
4
+ Workers. Installing this package does not install the WPR browser client.
5
+ The native runtime is downloaded separately and remains centrally built.
6
+
7
+ ```python
8
+ import asyncio
9
+ from wpr_http import AsyncClient
10
+
11
+ async def main():
12
+ async with AsyncClient(firefox_version="155") as client:
13
+ response = await client.get("https://www.baidu.com/")
14
+ async with response:
15
+ response.raise_for_status()
16
+ print(response.status_code, await response.atext())
17
+
18
+ asyncio.run(main())
19
+ ```
20
+
21
+ `firefox_version` selects a genuine installed Gecko network engine, not a
22
+ User-Agent string or a simulated browser. Only Firefox 155 is currently built
23
+ and supported. Selecting another version raises an explicit compatibility error;
24
+ it never silently uses 155. Different installed engine versions use separate
25
+ runtime/profile locations and separate Worker processes.
26
+
27
+ Runtime installation and supported versions:
28
+
29
+ ```powershell
30
+ python -m wpr_http runtimes
31
+ python -m wpr_http install-runtime --version 155
32
+ ```
33
+
34
+ An explicit `runtime_home` can select another verified runtime installation.
35
+ Existing `AsyncWprHttpClient`, Request/Response/Timeout and exception names remain
36
+ available. `WprHttpClient` remains the historical asynchronous alias.
37
+
38
+ The native network runtime is a separate download from the private
39
+ `childclm/gecko-web-runtime` Release. Installation needs access to that repository. Set
40
+ `WPR_GITHUB_TOKEN` or `GH_TOKEN`, or use Git Credential Manager, then run
41
+ `python -m wpr_http install-runtime --version 155`.
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "wpr-http"
7
+ version = "0.2.0"
8
+ description = "Standalone HTTP client using version-selected Firefox Necko/NSS Workers"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MPL-2.0-compatible client; see runtime bundle notices"}
12
+ dependencies = ["wpr-runtime-core>=0.2.0,<0.3"]
13
+
14
+ [project.urls]
15
+ Repository = "https://github.com/childclm/wpr-http"
16
+ Documentation = "https://github.com/childclm/wpr-http/blob/main/README.md"
17
+
18
+ [project.scripts]
19
+ wpr-http = "wpr_http.__main__:main"
20
+
21
+ [tool.setuptools.packages.find]
22
+ include = ["wpr_http*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,187 @@
1
+ import hashlib
2
+ import json
3
+ import subprocess
4
+ import sys
5
+
6
+ import pytest
7
+ from wpr_http import AsyncClient, Request, RuntimeCompatibilityError, Timeout
8
+ from wpr_runtime_core.registry import RuntimeManifest, resolve_runtime
9
+ from wpr_runtime_core.session import AsyncWorkerSession
10
+
11
+
12
+ def make_runtime(tmp_path, major="155", protocol="wpr-http-v1", abi=None):
13
+ home = tmp_path / major
14
+ worker = home / "runtime" / f"net-v{major}" / "gecko-net-worker.exe"
15
+ worker.parent.mkdir(parents=True)
16
+ worker.write_bytes(b"not-an-executable-fixture")
17
+ relative = worker.relative_to(home).as_posix()
18
+ manifest = {
19
+ "schema": "wpr.runtime-manifest.v1",
20
+ "version": major + ".0.6",
21
+ "platform": "windows-x86_64",
22
+ "protocols": [protocol],
23
+ "network_worker": relative,
24
+ "components": {
25
+ "network": {
26
+ "firefox_major": int(major),
27
+ "protocol": protocol,
28
+ "client_api": abi or {"min": 1, "max": 1},
29
+ "worker": relative,
30
+ }
31
+ },
32
+ "files": {
33
+ relative: {"sha256": hashlib.sha256(worker.read_bytes()).hexdigest()}
34
+ },
35
+ }
36
+ (home / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
37
+ return home, manifest
38
+
39
+
40
+ def test_import_does_not_load_browser():
41
+ subprocess.run(
42
+ [
43
+ sys.executable,
44
+ "-c",
45
+ "import sys,wpr_http; assert not any(n.startswith('gecko_web_runtime') for n in sys.modules)",
46
+ ],
47
+ check=True,
48
+ )
49
+ assert not hasattr(AsyncWorkerSession, "page_create")
50
+ assert not hasattr(AsyncWorkerSession, "page_eval")
51
+ assert isinstance(AsyncWorkerSession.is_open, property)
52
+
53
+
54
+ def test_requests_and_deadlines_are_standalone():
55
+ request = Request(
56
+ "get", "https://example.com", headers={"X-Test": "yes"}, timeout=0.2
57
+ )
58
+ assert request.method == "GET"
59
+ assert request.headers["x-test"] == "yes"
60
+ assert request.timeout == Timeout(total=0.2)
61
+
62
+
63
+ @pytest.mark.asyncio
64
+ async def test_response_accepts_a_body_lease_without_a_worker_session():
65
+ from types import SimpleNamespace
66
+
67
+ from wpr_http import Response
68
+
69
+ class MemoryLease:
70
+ descriptor = SimpleNamespace(encoding="utf-8")
71
+ mapping_open_ms = copy_ms = 0.0
72
+ released = False
73
+
74
+ def __init__(self):
75
+ self.release_timings = {}
76
+
77
+ def open(self):
78
+ return self
79
+
80
+ def read_bytes(self):
81
+ return b"standalone"
82
+
83
+ async def release(self):
84
+ self.released = True
85
+ return {"released": True}
86
+
87
+ lease = MemoryLease()
88
+ response = Response(
89
+ None, {"status": 200, "final_url": "http://fixture/"}, body=lease
90
+ )
91
+ assert await response.atext() == "standalone"
92
+ assert lease.released and response.is_closed
93
+
94
+
95
+ def test_selected_runtime_is_actual_manifest_worker(tmp_path):
96
+ home, _ = make_runtime(tmp_path)
97
+ client = AsyncClient(firefox_version=155, runtime_home=home)
98
+ assert client.runtime_manifest.firefox_major == "155"
99
+ assert client.config.runtime_dir == home / "runtime"
100
+ assert client.max_channels_per_worker == 20
101
+
102
+
103
+ def test_explicit_future_runtime_is_separate_not_a_published_claim(tmp_path):
104
+ # Fixture only: proves independent selection without claiming a 156 build.
105
+ a, _ = make_runtime(tmp_path, "155")
106
+ b, _ = make_runtime(tmp_path, "156")
107
+ assert (
108
+ resolve_runtime("155", runtime_home=a).network_worker
109
+ != resolve_runtime("156", runtime_home=b).network_worker
110
+ )
111
+ with pytest.raises(RuntimeCompatibilityError, match="requested Firefox"):
112
+ resolve_runtime("156", runtime_home=a)
113
+
114
+
115
+ def test_unpublished_version_never_silently_falls_back(monkeypatch):
116
+ monkeypatch.delenv("WPR_RUNTIME_HOME", raising=False)
117
+ with pytest.raises(RuntimeCompatibilityError, match="no published runtime"):
118
+ AsyncClient(firefox_version=999)
119
+
120
+
121
+ def test_registration_preserves_other_engines_and_exact_builds(tmp_path, monkeypatch):
122
+ from wpr_runtime_core.installer import _write_config, config_path
123
+
124
+ monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "appdata"))
125
+ monkeypatch.delenv("WPR_RUNTIME_HOME", raising=False)
126
+ a, _ = make_runtime(tmp_path, "155")
127
+ b, _ = make_runtime(tmp_path, "156")
128
+ _write_config(a, product="network")
129
+ _write_config(b, product="network")
130
+ settings = json.loads(config_path().read_text(encoding="utf-8"))
131
+ assert settings["network_runtimes"]["155"] == str(a)
132
+ assert settings["network_runtimes"]["156.0.6"] == str(b)
133
+ assert resolve_runtime("155").home == a
134
+ assert resolve_runtime("156").home == b
135
+
136
+
137
+ @pytest.mark.parametrize("value", [True, "firefox155", "../155", "", 0])
138
+ def test_invalid_engine_version(value):
139
+ with pytest.raises(RuntimeCompatibilityError):
140
+ resolve_runtime(value)
141
+
142
+
143
+ def test_exact_build_pin(tmp_path):
144
+ home, _ = make_runtime(tmp_path)
145
+ with pytest.raises(RuntimeCompatibilityError, match="installed runtime"):
146
+ resolve_runtime("155.0.5", runtime_home=home)
147
+ assert resolve_runtime("155.0.6", runtime_home=home).runtime_version == "155.0.6"
148
+
149
+
150
+ def test_catalog_selects_default_and_exact_build_without_fallback(monkeypatch):
151
+ from copy import deepcopy
152
+
153
+ from wpr_runtime_core import installer
154
+
155
+ catalog = deepcopy(installer.RUNTIME_CATALOG)
156
+ releases = catalog["engines"]["firefox155"]
157
+ older = deepcopy(releases["builds"]["155.0.6"])
158
+ older["version"] = "155.0.5"
159
+ releases["builds"]["155.0.5"] = older
160
+ monkeypatch.setattr(installer, "RUNTIME_CATALOG", catalog)
161
+ assert installer.select_release("155")["version"] == "155.0.6"
162
+ assert installer.select_release("155.0.5")["version"] == "155.0.5"
163
+ with pytest.raises(RuntimeCompatibilityError, match="no published runtime"):
164
+ installer.select_release("155.0.4")
165
+
166
+
167
+ @pytest.mark.parametrize("kind", ["protocol", "abi", "hash", "escape"])
168
+ def test_incompatible_runtime_is_rejected_before_launch(tmp_path, kind):
169
+ home, manifest = make_runtime(tmp_path)
170
+ if kind == "protocol":
171
+ manifest["components"]["network"]["protocol"] = "wpr-http-v2"
172
+ elif kind == "abi":
173
+ manifest["components"]["network"]["client_api"] = {"min": 2, "max": 2}
174
+ elif kind == "hash":
175
+ manifest["files"][manifest["network_worker"]]["sha256"] = "0" * 64
176
+ else:
177
+ manifest["components"]["network"]["worker"] = "../outside.exe"
178
+ (home / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
179
+ with pytest.raises(RuntimeCompatibilityError):
180
+ RuntimeManifest.load(home, "155")
181
+
182
+
183
+ def test_legacy_manifest_v1_is_supported(tmp_path):
184
+ home, manifest = make_runtime(tmp_path)
185
+ manifest.pop("components")
186
+ (home / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
187
+ assert resolve_runtime("155", runtime_home=home).protocol == "wpr-http-v1"
@@ -0,0 +1,124 @@
1
+ """Optional real-engine tests, independent of the browser client and public sites."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import threading
7
+ import time
8
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
+
10
+ import pytest
11
+ from wpr_http import AsyncClient, WprHTTPStatusError, WprTimeoutError
12
+
13
+ pytestmark = pytest.mark.skipif(
14
+ os.name != "nt" or not os.environ.get("WPR_TEST_RUNTIME_HOME"),
15
+ reason="requires an explicitly selected installed Windows network runtime",
16
+ )
17
+
18
+
19
+ class Api(ThreadingHTTPServer):
20
+ request_queue_size = 128
21
+ active = 0
22
+ peak = 0
23
+ lock = threading.Lock()
24
+
25
+ def handle_error(self, *_):
26
+ pass # cancelled/timed-out requests intentionally close their sockets
27
+
28
+
29
+ class Handler(BaseHTTPRequestHandler):
30
+ def log_message(self, *_):
31
+ pass
32
+
33
+ def do_GET(self):
34
+ with self.server.lock:
35
+ self.server.active += 1
36
+ self.server.peak = max(self.server.peak, self.server.active)
37
+ try:
38
+ if self.path.startswith("/slow"):
39
+ time.sleep(0.3)
40
+ if self.path.startswith("/parallel"):
41
+ time.sleep(0.1)
42
+ body = json.dumps(
43
+ {"path": self.path, "cookie": self.headers.get("Cookie")}
44
+ ).encode()
45
+ if self.path == "/large":
46
+ body = b"x" * (1024 * 1024)
47
+ status = (
48
+ 418
49
+ if self.path == "/error"
50
+ else 302
51
+ if self.path == "/redirect"
52
+ else 200
53
+ )
54
+ self.send_response(status)
55
+ self.send_header("Content-Type", "application/json")
56
+ self.send_header("Content-Length", str(len(body)))
57
+ if self.path == "/cookie":
58
+ self.send_header("Set-Cookie", "native=yes; Path=/")
59
+ if self.path == "/redirect":
60
+ self.send_header("Location", "/final")
61
+ self.end_headers()
62
+ self.wfile.write(body)
63
+ finally:
64
+ with self.server.lock:
65
+ self.server.active -= 1
66
+
67
+
68
+ def test_native_sequential_parallel_cookie_redirect_timeout_body():
69
+ api = Api(("127.0.0.1", 0), Handler)
70
+ thread = threading.Thread(target=api.serve_forever, daemon=True)
71
+ thread.start()
72
+
73
+ async def run():
74
+ base = f"http://127.0.0.1:{api.server_port}"
75
+ async with AsyncClient(
76
+ firefox_version="155",
77
+ runtime_home=os.environ["WPR_TEST_RUNTIME_HOME"],
78
+ proxy="",
79
+ base_url=base,
80
+ ) as client:
81
+ for i in range(3):
82
+ response = await client.get(f"/serial/{i}")
83
+ assert (await response.ajson())["path"] == f"/serial/{i}"
84
+
85
+ async def one(i):
86
+ response = await client.get(f"/parallel/{i}")
87
+ return await response.ajson()
88
+
89
+ results = await asyncio.gather(*(one(i) for i in range(8)))
90
+ assert len(results) == 8 and api.peak >= 3
91
+ response = await client.get("/cookie")
92
+ await response.aread()
93
+ response = await client.get("/check")
94
+ assert "native=yes" in (await response.ajson())["cookie"]
95
+ response = await client.get("/redirect")
96
+ assert response.url.endswith("/final")
97
+ await response.aread()
98
+ response = await client.get("/error")
99
+ with pytest.raises(WprHTTPStatusError):
100
+ response.raise_for_status()
101
+ await response.aclose()
102
+ response = await client.get("/large")
103
+ assert len(await response.aread()) == 1024 * 1024
104
+ with pytest.raises(WprTimeoutError):
105
+ await client.get("/slow", timeout=0.04)
106
+ response = await client.get("/after-timeout")
107
+ assert response.status_code == 200
108
+ await response.aread()
109
+ task = asyncio.create_task(client.get("/slow-cancel"))
110
+ await asyncio.sleep(0.05)
111
+ task.cancel()
112
+ with pytest.raises(asyncio.CancelledError):
113
+ await task
114
+ response = await client.get("/after-cancel")
115
+ await response.aread()
116
+ await client.flush_releases()
117
+ assert client.transport_stats["pending_blob_count"] == 0
118
+
119
+ try:
120
+ asyncio.run(run())
121
+ finally:
122
+ api.shutdown()
123
+ api.server_close()
124
+ thread.join()
@@ -0,0 +1,60 @@
1
+ """Public network client API backed by Firefox Necko."""
2
+
3
+ from wpr_runtime_core.registry import RuntimeCompatibilityError, RuntimeManifest
4
+
5
+ from .client import AsyncWprHttpClient, WprHttpClient
6
+ from .exceptions import (
7
+ WprConnectError,
8
+ WprHttpError,
9
+ WprHTTPStatusError,
10
+ WprPoolTimeoutError,
11
+ WprProxyError,
12
+ WprTimeoutError,
13
+ WprTransportError,
14
+ )
15
+ from .models import Headers, HttpRequest, HttpResponse, NetworkTiming, Request, Timeout
16
+
17
+ __version__ = "0.2.0"
18
+
19
+
20
+ class AsyncClient(AsyncWprHttpClient):
21
+ """Standalone client: select and validate an actual Firefox network runtime."""
22
+
23
+ def __init__(
24
+ self, *, firefox_version=None, runtime_home=None, config=None, **kwargs
25
+ ):
26
+ # Explicit config supports local development builds. Installed engines
27
+ # always go through manifest/version/hash validation.
28
+ if config is None and firefox_version is None:
29
+ firefox_version = "155"
30
+ super().__init__(
31
+ config=config,
32
+ firefox_version=firefox_version,
33
+ runtime_home=runtime_home,
34
+ **kwargs,
35
+ )
36
+
37
+
38
+ Response = HttpResponse
39
+
40
+ __all__ = [
41
+ "AsyncClient",
42
+ "AsyncWprHttpClient",
43
+ "Headers",
44
+ "HttpRequest",
45
+ "HttpResponse",
46
+ "NetworkTiming",
47
+ "Request",
48
+ "Response",
49
+ "RuntimeCompatibilityError",
50
+ "RuntimeManifest",
51
+ "Timeout",
52
+ "WprConnectError",
53
+ "WprHTTPStatusError",
54
+ "WprHttpClient",
55
+ "WprHttpError",
56
+ "WprPoolTimeoutError",
57
+ "WprProxyError",
58
+ "WprTimeoutError",
59
+ "WprTransportError",
60
+ ]
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+
6
+ from wpr_runtime_core.installer import RUNTIME_CATALOG, install_runtime
7
+
8
+
9
+ def main() -> int:
10
+ parser = argparse.ArgumentParser(prog="python -m wpr_http")
11
+ commands = parser.add_subparsers(dest="command", required=True)
12
+ commands.add_parser(
13
+ "runtimes", help="list published engine versions in this client's catalog"
14
+ )
15
+ install = commands.add_parser("install-runtime")
16
+ install.add_argument("--version", default="155")
17
+ install.add_argument("--runtime-home")
18
+ install.add_argument("--force", action="store_true")
19
+ args = parser.parse_args()
20
+ if args.command == "runtimes":
21
+ print(json.dumps(RUNTIME_CATALOG, indent=2))
22
+ else:
23
+ print(install_runtime(args.version, args.runtime_home, force=args.force, product="network"))
24
+ return 0
25
+
26
+
27
+ if __name__ == "__main__":
28
+ raise SystemExit(main())
@@ -0,0 +1,31 @@
1
+ """URL helpers used in diagnostics; credential and query values stay private."""
2
+
3
+ from urllib.parse import urlsplit, urlunsplit
4
+
5
+
6
+ def safe_url(url: str) -> str:
7
+ try:
8
+ parsed = urlsplit(url)
9
+ host = parsed.hostname or ""
10
+ if ":" in host and not host.startswith("["):
11
+ host = f"[{host}]"
12
+ if parsed.port:
13
+ host = f"{host}:{parsed.port}"
14
+ return urlunsplit(
15
+ (parsed.scheme, host, parsed.path, "..." if parsed.query else "", "")
16
+ )
17
+ except ValueError:
18
+ return "<invalid URL>"
19
+
20
+
21
+ def safe_proxy(proxy: str) -> str:
22
+ try:
23
+ parsed = urlsplit(proxy)
24
+ host = parsed.hostname or "<unknown>"
25
+ if ":" in host and not host.startswith("["):
26
+ host = f"[{host}]"
27
+ if parsed.port:
28
+ host = f"{host}:{parsed.port}"
29
+ return f"{parsed.scheme}://{host}"
30
+ except ValueError:
31
+ return "<configured proxy>"