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()
|