hayate-fetch 0.1.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yusuke Hayashi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,30 @@
1
+ Metadata-Version: 2.4
2
+ Name: hayate-fetch
3
+ Version: 0.1.0
4
+ Summary: Client-side WHATWG fetch for hayate: the same Request/Response types, pointed outward
5
+ Keywords: hayate,fetch,http-client,workers
6
+ Author: Yusuke Hayashi
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Requires-Dist: hayate>=0.8.0
15
+ Requires-Python: >=3.12
16
+ Project-URL: Repository, https://github.com/hayatepy/hayate-fetch
17
+ Description-Content-Type: text/markdown
18
+
19
+ # hayate-fetch
20
+
21
+ Client-side WHATWG fetch for [hayate](https://github.com/hayatepy/hayate):
22
+ the same Request/Response types your server handles, pointed outward.
23
+
24
+ > **Status: design phase.** Implementation is demand-driven by hayate-auth
25
+ > v0.2 (OAuth token exchange). The design memo (Japanese, per project
26
+ > convention) lives in [DESIGN.md](DESIGN.md).
27
+
28
+ ## License
29
+
30
+ MIT
@@ -0,0 +1,12 @@
1
+ # hayate-fetch
2
+
3
+ Client-side WHATWG fetch for [hayate](https://github.com/hayatepy/hayate):
4
+ the same Request/Response types your server handles, pointed outward.
5
+
6
+ > **Status: design phase.** Implementation is demand-driven by hayate-auth
7
+ > v0.2 (OAuth token exchange). The design memo (Japanese, per project
8
+ > convention) lives in [DESIGN.md](DESIGN.md).
9
+
10
+ ## License
11
+
12
+ MIT
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.9,<1"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "hayate-fetch"
7
+ version = "0.1.0"
8
+ description = "Client-side WHATWG fetch for hayate: the same Request/Response types, pointed outward"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Yusuke Hayashi" }]
14
+ keywords = ["hayate", "fetch", "http-client", "workers"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ ]
22
+ dependencies = ["hayate>=0.8.0"]
23
+
24
+ [project.urls]
25
+ Repository = "https://github.com/hayatepy/hayate-fetch"
26
+
27
+ [dependency-groups]
28
+ dev = [
29
+ "pytest>=8.3",
30
+ "pytest-asyncio>=0.25",
31
+ "ruff>=0.9",
32
+ ]
33
+
34
+ [tool.pytest.ini_options]
35
+ asyncio_mode = "auto"
36
+ testpaths = ["tests"]
37
+
38
+ [tool.ruff]
39
+ line-length = 100
40
+ target-version = "py312"
41
+ src = ["src", "tests"]
42
+
43
+ [tool.ruff.lint]
44
+ select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
@@ -0,0 +1,49 @@
1
+ """hayate-fetch: client-side WHATWG fetch on hayate's Request/Response."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from hayate import Request, Response
8
+
9
+ from .backends import FetchBackend, UrllibBackend, WorkersBackend, default_backend
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ __all__ = [
14
+ "FetchBackend",
15
+ "UrllibBackend",
16
+ "WorkersBackend",
17
+ "__version__",
18
+ "default_backend",
19
+ "fetch",
20
+ ]
21
+
22
+ _default: FetchBackend | None = None
23
+
24
+
25
+ async def fetch(
26
+ input: str | Request,
27
+ *,
28
+ method: str = "GET",
29
+ headers: Any = None,
30
+ body: Any = None,
31
+ backend: FetchBackend | None = None,
32
+ ) -> Response:
33
+ """WHATWG-shaped fetch: pass a URL (plus init) or a prebuilt Request.
34
+
35
+ HTTP error statuses resolve to a Response; network failures raise
36
+ OSError. The documented subset excludes browser-only init fields
37
+ (mode / credentials / cache — DESIGN §2).
38
+ """
39
+ request = (
40
+ input
41
+ if isinstance(input, Request)
42
+ else Request(input, method=method, headers=headers, body=body)
43
+ )
44
+ global _default
45
+ if backend is None:
46
+ if _default is None:
47
+ _default = default_backend()
48
+ backend = _default
49
+ return await backend.send(request)
@@ -0,0 +1,96 @@
1
+ """The two bundled FetchBackends (DESIGN §3).
2
+
3
+ CPython: stdlib urllib pushed off-loop. Workers: passthrough to the JS
4
+ global fetch (a subrequest — the platform's one true client). Both consume
5
+ and produce hayate's WHATWG Request/Response, so callers never see the seam.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import sys
12
+ import urllib.error
13
+ import urllib.request
14
+ from typing import Any, Protocol
15
+
16
+ from hayate import Headers, Request, Response
17
+
18
+
19
+ class FetchBackend(Protocol):
20
+ async def send(self, request: Request) -> Response: ...
21
+
22
+
23
+ class UrllibBackend:
24
+ """stdlib-only backend: ``urllib.request`` + ``asyncio.to_thread``.
25
+
26
+ Fetch semantics: HTTP error statuses come back as Responses, not
27
+ exceptions; only network/protocol failures raise (OSError). Responses
28
+ are buffered (DESIGN §3, the v0.1 contract).
29
+ """
30
+
31
+ def __init__(self, *, timeout: float = 30.0, redirect: str = "follow") -> None:
32
+ if redirect not in ("follow", "manual"):
33
+ raise ValueError("redirect must be 'follow' or 'manual'")
34
+ self.timeout = timeout
35
+ self.redirect = redirect
36
+
37
+ async def send(self, request: Request) -> Response:
38
+ body = await request.bytes() if request.method not in ("GET", "HEAD") else None
39
+ headers = list(request.headers)
40
+ url = request.url.href
41
+ method = request.method
42
+
43
+ def run() -> tuple[int, list[tuple[str, str]], bytes]:
44
+ raw = urllib.request.Request(url, data=body or None, method=method)
45
+ for name, value in headers:
46
+ raw.add_header(name, value)
47
+ opener = self._opener()
48
+ try:
49
+ with opener.open(raw, timeout=self.timeout) as res:
50
+ return res.status, list(res.headers.items()), res.read()
51
+ except urllib.error.HTTPError as error:
52
+ # An HTTP response, just not a 2xx: still a Response.
53
+ return error.code, list(error.headers.items()), error.read()
54
+
55
+ status, header_items, data = await asyncio.to_thread(run)
56
+ return Response(data, status=status, headers=Headers(header_items))
57
+
58
+ def _opener(self) -> urllib.request.OpenerDirector:
59
+ if self.redirect == "manual":
60
+ return urllib.request.build_opener(_NoRedirect)
61
+ return urllib.request.build_opener()
62
+
63
+
64
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
65
+ def redirect_request(self, *args: Any, **kwargs: Any) -> None:
66
+ return None
67
+
68
+
69
+ class WorkersBackend:
70
+ """Cloudflare Workers backend: the JS global ``fetch`` (a subrequest)."""
71
+
72
+ async def send(self, request: Request) -> Response:
73
+ import js
74
+ from pyodide.ffi import to_js
75
+
76
+ options: dict[str, Any] = {
77
+ "method": request.method,
78
+ "headers": list(request.headers),
79
+ }
80
+ if request.method not in ("GET", "HEAD"):
81
+ body = await request.bytes()
82
+ if body:
83
+ options["body"] = to_js(body)
84
+ js_response = await js.fetch(
85
+ request.url.href, to_js(options, dict_converter=js.Object.fromEntries)
86
+ )
87
+ buffer = await js_response.arrayBuffer()
88
+ data = bytes(js.Uint8Array.new(buffer).to_py())
89
+ headers = Headers([(k, v) for k, v in js_response.headers.entries()])
90
+ return Response(data, status=int(js_response.status), headers=headers)
91
+
92
+
93
+ def default_backend() -> FetchBackend:
94
+ if sys.platform == "emscripten":
95
+ return WorkersBackend()
96
+ return UrllibBackend()