pathapi-httpx 0.1.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,149 @@
1
+ # `pathapi_httpx` — public API header
2
+
3
+ Header-file-style reference for the `pathapi_httpx` package: every public
4
+ export with its signature, arguments, contract and gotchas, so the package can
5
+ be used without reading its source. It ships inside the package and is
6
+ self-contained. Development documentation lives with the source at
7
+ <https://github.com/jose-pr/pathapi-httpx>.
8
+
9
+ Names are imported from `pathapi_httpx`, which exports two classes,
10
+ `HttpxTransport` and `AsyncHttpxTransport`, and `__version__`. A module or name
11
+ that starts with an underscore is private and nothing there is contract.
12
+
13
+ `pip install pathapi-httpx`. Required dependencies: `pathapi>=0.1.2,<0.2` and
14
+ `httpx>=0.28.0,<0.29`. `httpx` is imported when a transport is built, not when
15
+ this package is. HTTP/2 needs `h2`: `pip install "httpx[http2]"`.
16
+
17
+ `pathapi_httpx.__version__` is the version of the installed
18
+ distribution, read from its metadata. The rendered documentation is at
19
+ <https://jose-pr.github.io/pathapi-httpx/> and the project overview is the
20
+ `README.md` shipped next to this file (`pathapi_httpx/README.md`).
21
+
22
+ ## The `httpx` transports (`pathapi_httpx`)
23
+
24
+ ```python
25
+ class HttpxTransport:
26
+ client: httpx.Client
27
+ def __init__(self, client=None, **client_args): ...
28
+ def send(
29
+ self, method, url, *, headers, body=None, timeout, stream=False,
30
+ ) -> HTTPResponse: ...
31
+ def close(self) -> None: ...
32
+
33
+ class AsyncHttpxTransport:
34
+ client: httpx.AsyncClient
35
+ def __init__(self, client=None, **client_args): ...
36
+ async def send(
37
+ self, method, url, *, headers, body=None, timeout, stream=False,
38
+ ) -> AsyncHTTPResponse: ...
39
+ async def aclose(self) -> None: ...
40
+ ```
41
+
42
+ `HttpxTransport` is an HTTP transport for `pathapi.http` and
43
+ `AsyncHttpxTransport` an async one (the contracts are "The transport contract"
44
+ and "The async transport contract" in the `pathapi` header): `send()` makes one
45
+ request with an `httpx` client and returns a `pathapi.http.HTTPResponse`, or
46
+ for the async one an `AsyncHTTPResponse` read with coroutines. `pathapi.http`
47
+ finds them through the `httpx` entries of its `pathapi.http_transports` and
48
+ `pathapi.http_async_transports` entry-point groups, which this distribution
49
+ declares, so installing the distribution is all that is needed:
50
+
51
+ - `HTTPClient(HttpxTransport(client))` and
52
+ `AsyncHTTPClient(AsyncHttpxTransport(client))` send every request of the
53
+ client through the `httpx` client. `HTTPClient` and `AsyncHTTPClient` follow
54
+ the redirects and never close a transport they were given.
55
+ - `PATHAPI_HTTP_TRANSPORT=httpx` makes every `HTTPClient` that was given no
56
+ transport build `HttpxTransport()`, and `PATHAPI_HTTP_ASYNC_TRANSPORT=httpx`
57
+ every `AsyncHTTPClient` build `AsyncHttpxTransport()`; so do
58
+ `pathapi.http.default_transport()`, `default_async_transport()` and
59
+ `get_transport("httpx")()`, `get_async_transport("httpx")()`.
60
+
61
+ Use `HttpxTransport` for what the built-in transport does not do: HTTP/2, a
62
+ client of your own with its `mounts`, `limits`, proxies and `auth`,
63
+ `SSL_CERT_FILE` and the certifi bundle. Use `AsyncHttpxTransport` to send from
64
+ an event loop on its own sockets: `ThreadedTransport` (the default async
65
+ transport) needs a worker thread for every request that is waiting.
66
+
67
+ - **`client`** — the `httpx.Client` (`httpx.AsyncClient` for the async
68
+ transport) to send with, an object with a `send()` method (an async one for
69
+ the async transport; `TypeError` otherwise). `None` makes a new one from
70
+ `client_args`. The attribute `client` is the one in use. `close()` (`await
71
+ aclose()`) closes a client this transport made and leaves a caller's open, so
72
+ a client passed in is still usable afterwards; both transports are context
73
+ managers that close (`async with` for the async one). A client the
74
+ synchronous transport made is also closed when the transport is collected;
75
+ the async one must be closed with `aclose()`. A response that is still open
76
+ keeps its transport alive.
77
+ - **`client_args`** — keyword arguments of `httpx.Client(...)` /
78
+ `httpx.AsyncClient(...)` for the client the transport makes: `verify` (a
79
+ `bool` or an `ssl.SSLContext`), `cert`, `http2`, `proxy`, `mounts`,
80
+ `transport`, `limits`, `trust_env`, `auth`, ... `client_args` together with a
81
+ `client` is `TypeError`. `follow_redirects` is always `False`, whatever is
82
+ given.
83
+ - **`send(method, url, *, headers, body=None, timeout, stream=False)`** is
84
+ `HTTPTransport.send()` (`await send()` of `ThreadedTransport.send()`):
85
+ `ValueError` before anything is sent for what `pathapi.http.prepare_request()`
86
+ refuses, one request and no redirect followed (a client told to
87
+ `follow_redirects` still returns the redirect), the response body read whole
88
+ with `stream=False`. A `timeout` of `(connect, read)` is `httpx.Timeout`'s
89
+ `connect` and `pool`, and `read` and `write`. A body that is a binary file or
90
+ an iterable of `bytes` is read by the async transport on a worker thread.
91
+ - **Exactly the headers it is given.** `httpx` adds no `User-Agent`, `Accept`
92
+ or `Connection` and no cookie of the client's jar, and the default headers of
93
+ the client are not sent: `HTTPClient` and `AsyncHTTPClient` decide the headers
94
+ of a request.
95
+ - **Credentials belong to one server.** The `auth` of the client is used for
96
+ the server of the first request: its scheme, host and port. A request to any
97
+ other server carries the `Authorization` it was given and nothing the client
98
+ holds. An `auth` of the client wins over an `Authorization` header on that
99
+ first server. Redirects are followed by `HTTPClient`, which drops
100
+ `Authorization` when one leaves the server.
101
+ - **Failures** are `TimeoutError`, `ConnectionError`, `ssl.SSLError`,
102
+ `ValueError` (the request was not valid) or `OSError(EIO)` (a reply that is
103
+ not valid HTTP, is cut short or has more than 100 header fields, or a
104
+ `Location` that a client given to the transport cannot read). The text of an
105
+ `httpx` exception, which can quote a URL, is never passed on, and the
106
+ exception is not chained. An exception raised by the request body passes
107
+ through. `repr()` shows nothing of the client or the arguments.
108
+
109
+ `pathapi_httpx.__version__` is a `str`.
110
+
111
+ ## Checking the transports
112
+
113
+ `pathapi` ships the tests that hold a transport to the promises of the
114
+ built-in ones. With this distribution and `pytest` installed:
115
+
116
+ ```
117
+ python -m pathapi.http.testing --http-transports httpx --http-async-transports httpx -q
118
+ ```
119
+
120
+ ## Environment variables
121
+
122
+ ### `PATHAPI_HTTP_TRANSPORT` and `PATHAPI_HTTP_ASYNC_TRANSPORT`
123
+
124
+ Read by `pathapi.http`. `httpx` makes an `HTTPClient` (an `AsyncHTTPClient`)
125
+ that was given no transport build `HttpxTransport()` (`AsyncHttpxTransport()`).
126
+ The name is case-sensitive, and one that no installed distribution declares is
127
+ `ValueError` naming the choices.
128
+
129
+ ### `httpx` variables
130
+
131
+ A client the transport makes has `trust_env=True`, so `httpx` reads
132
+ `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY` and `NO_PROXY`, and `SSL_CERT_FILE`
133
+ and `SSL_CERT_DIR` (unless `verify=` is given). Pass `trust_env=False` to
134
+ ignore them.
135
+
136
+ ## Gotchas
137
+
138
+ `HttpxTransport()` builds an `httpx.Client` the moment it is constructed; a
139
+ client it made is closed by `close()`, by leaving a `with` block, and when the
140
+ transport is collected, so a transport you build is yours to close. An
141
+ `HTTPClient` closes only a transport it built itself, which is why
142
+ `HTTPClient(HttpxTransport(client))` does not close one.
143
+
144
+ An `httpx.AsyncClient` keeps its connections on the event loop that first sent
145
+ with it: an `AsyncHttpxTransport` is used from one loop, and a transport for
146
+ another `asyncio.run()` is a new one.
147
+
148
+ A client given to the transport that is mounted on `httpx.MockTransport` works:
149
+ its replies are read whole already, and `send()` returns them as they are.
@@ -0,0 +1,135 @@
1
+ # pathapi-httpx
2
+
3
+ [![Version](https://img.shields.io/pypi/v/pathapi-httpx.svg)](https://pypi.org/project/pathapi-httpx/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/pathapi-httpx.svg)](https://pypi.org/project/pathapi-httpx/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/jose-pr/pathapi-httpx/blob/main/LICENSE)
6
+ [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://jose-pr.github.io/pathapi-httpx/)
7
+ [![CI](https://img.shields.io/github/actions/workflow/status/jose-pr/pathapi-httpx/test.yml)](https://github.com/jose-pr/pathapi-httpx/actions/workflows/test.yml)
8
+
9
+ **The `httpx` HTTP transports for [`pathapi`](https://github.com/jose-pr/pathapi).**
10
+ `pathapi.http` sends requests with a transport built on the standard library,
11
+ and from an event loop with that transport on worker threads. Install this
12
+ distribution and `HTTPClient` sends through an `httpx.Client`, and
13
+ `AsyncHTTPClient` through an `httpx.AsyncClient` on the caller's loop, with
14
+ nothing to configure. It is documented at
15
+ <https://jose-pr.github.io/pathapi-httpx/>.
16
+
17
+ ## Features
18
+
19
+ | You want | Built-in transports | These transports |
20
+ | --- | --- | --- |
21
+ | Requests from an event loop without a worker thread per request | No: `ThreadedTransport` runs the standard-library transport on threads | Yes: `AsyncHttpxTransport` |
22
+ | HTTP/2 | No | Yes: `HttpxTransport(http2=True)` with `httpx[http2]` |
23
+ | Your own `httpx.Client` or `httpx.AsyncClient`: mounted transports, limits, proxies, `auth` | No | Yes: `HttpxTransport(client)` |
24
+ | `SSL_CERT_FILE`, `SSL_CERT_DIR` and the certifi bundle | The system trust store, or an `ssl_context=` | Yes |
25
+ | Proxies from the environment, `proxy=` and `mounts=` | `http://` proxies through a provider | Yes, as `httpx` does them |
26
+
27
+ - **Credentials belong to one server.** The `auth` of a client is sent to the
28
+ server of the first request and to no other, and the clients of `pathapi.http`
29
+ never take an `Authorization` header to another server on a redirect.
30
+ - **Exactly the headers it is given.** A client's default headers and cookies
31
+ are not added: `HTTPClient` and `AsyncHTTPClient` own the headers of a request.
32
+ - **No third-party text in an error.** A failure of `httpx` is a
33
+ `TimeoutError`, `ConnectionError`, `ssl.SSLError`, `ValueError` or
34
+ `OSError(EIO)`; what the library says, which can quote a URL, is never passed on.
35
+ - **The same promises as the built-in transports.** The tests `pathapi` runs
36
+ over its own transports run over these
37
+ (`python -m pathapi.http.testing --http-transports httpx --http-async-transports httpx`).
38
+
39
+ ## Installation
40
+
41
+ ```bash
42
+ pip install pathapi-httpx
43
+ ```
44
+
45
+ Requires `pathapi` and `httpx`, which pip installs with it. HTTP/2 needs
46
+ `pip install "httpx[http2]"`.
47
+
48
+ ## Quick start
49
+
50
+ **Send through an `httpx.Client` from threads or a script:**
51
+
52
+ ```python
53
+ import httpx
54
+ from pathapi.http import HTTPClient
55
+ from pathapi_httpx import HttpxTransport
56
+
57
+ with httpx.Client() as session:
58
+ with HTTPClient(HttpxTransport(session), timeout=(3, 30)) as client:
59
+ reply = client.request("GET", "http://files.example.org/reports/today.txt")
60
+ print(reply.read().decode())
61
+ ```
62
+
63
+ The `httpx.Client` stays yours: neither the client nor the transport closes it.
64
+
65
+ **Send from an event loop** with `httpx.AsyncClient` on that loop:
66
+
67
+ ```python
68
+ import asyncio
69
+
70
+ from pathapi.http import AsyncHTTPClient
71
+ from pathapi_httpx import AsyncHttpxTransport
72
+
73
+
74
+ async def main():
75
+ async with AsyncHttpxTransport() as transport:
76
+ async with AsyncHTTPClient(transport) as client:
77
+ replies = await asyncio.gather(
78
+ *(
79
+ client.request("GET", f"http://files.example.org/reports/{day}.txt")
80
+ for day in ("mon", "tue", "wed")
81
+ )
82
+ )
83
+ print([len(await reply.read()) for reply in replies])
84
+
85
+
86
+ asyncio.run(main())
87
+ ```
88
+
89
+ **Choose them for the whole process.** `PATHAPI_HTTP_TRANSPORT=httpx` and
90
+ `PATHAPI_HTTP_ASYNC_TRANSPORT=httpx` make every `HTTPClient` and
91
+ `AsyncHTTPClient` that was given no transport send through a new `httpx` client:
92
+
93
+ ```bash
94
+ PATHAPI_HTTP_ASYNC_TRANSPORT=httpx python -c "from pathapi.http import AsyncHTTPClient; print(type(AsyncHTTPClient().transport))"
95
+ ```
96
+
97
+ ## API overview
98
+
99
+ | Name | Purpose |
100
+ | --- | --- |
101
+ | `pathapi_httpx.HttpxTransport` | The transport: `send()` and `close()` over an `httpx.Client` |
102
+ | `pathapi_httpx.AsyncHttpxTransport` | The async transport: `await send()` and `await aclose()` over an `httpx.AsyncClient` |
103
+ | `pathapi_httpx.__version__` | The installed version |
104
+
105
+ `pathapi.http` finds the classes through the `httpx` entries of its
106
+ `pathapi.http_transports` and `pathapi.http_async_transports` entry-point
107
+ groups, which this distribution declares. The reference is in the
108
+ [API header](https://github.com/jose-pr/pathapi-httpx/blob/main/src/pathapi_httpx/AGENTS.md)
109
+ that ships with the package.
110
+
111
+ ## Development
112
+
113
+ ```bash
114
+ python -m venv .venv/3.14-posix-x86_64
115
+ .venv/3.14-posix-x86_64/bin/python -m pip install -e ".[dev]"
116
+ .venv/3.14-posix-x86_64/bin/python -m pytest -q -rs
117
+ ```
118
+
119
+ `pathapi` 0.1.2 is a dependency; while it is not on PyPI, install it
120
+ editable from its checkout first (`pip install -e ../pathapi`). The test run
121
+ includes `python -m pathapi.http.testing --http-transports httpx
122
+ --http-async-transports httpx`, the tests `pathapi` runs over its own transports.
123
+
124
+ ### Releasing
125
+
126
+ This project follows [Semantic Versioning](https://semver.org/) and keeps a
127
+ [`CHANGELOG.md`](CHANGELOG.md). Pushing a tag matching `v*` triggers the release
128
+ workflow: test gate → build (checking the tag names the version built) → a strict
129
+ docs build as a gate → GitHub release → publish. The release workflow never deploys
130
+ the docs site itself: for a final release its last job dispatches the docs workflow
131
+ at the tag, which owns every Pages deploy.
132
+
133
+ ## License
134
+
135
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,18 @@
1
+ """The `httpx` HTTP transports for `pathapi.http`.
2
+
3
+ Installing this distribution is enough: `pathapi.http` finds `HttpxTransport`
4
+ through the `httpx` entry of its `pathapi.http_transports` group and
5
+ `AsyncHttpxTransport` through the `httpx` entry of `pathapi.http_async_transports`,
6
+ so `HTTPClient(HttpxTransport())`, `AsyncHTTPClient(AsyncHttpxTransport())`,
7
+ `PATHAPI_HTTP_TRANSPORT=httpx` and `PATHAPI_HTTP_ASYNC_TRANSPORT=httpx` send
8
+ through them. `httpx` is imported when a transport is built, not when this
9
+ package is.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from ._async import AsyncHttpxTransport
15
+ from ._sync import HttpxTransport
16
+ from ._version import __version__
17
+
18
+ __all__ = ["AsyncHttpxTransport", "HttpxTransport", "__version__"]
@@ -0,0 +1,156 @@
1
+ """`httpx.AsyncClient` as an async HTTP transport for `pathapi.http`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio as _asyncio
6
+ import inspect as _inspect
7
+ import typing as _ty
8
+
9
+ from pathapi.http import AsyncHTTPResponse, prepare_request
10
+
11
+ from ._common import (
12
+ CHUNK,
13
+ Pin,
14
+ build_request,
15
+ failure,
16
+ header_pairs,
17
+ import_httpx,
18
+ owned_client,
19
+ read_already,
20
+ requested_url,
21
+ )
22
+
23
+ _END = object()
24
+
25
+
26
+ async def _pieces(body) -> "_ty.AsyncIterator[bytes]":
27
+ """A file or an iterable of bytes as an async iterator, each read made on a
28
+ worker thread so that it cannot block the loop."""
29
+ if hasattr(body, "read"):
30
+ while True:
31
+ chunk = await _asyncio.to_thread(body.read, CHUNK)
32
+ if not chunk:
33
+ return
34
+ yield chunk
35
+ else:
36
+ pieces = iter(body)
37
+ while True:
38
+ chunk = await _asyncio.to_thread(next, pieces, _END)
39
+ if chunk is _END:
40
+ return
41
+ yield chunk
42
+
43
+
44
+ class _Body:
45
+ """The raw (still encoded) body of an `httpx` response."""
46
+
47
+ def __init__(self, owner, response):
48
+ self._owner = owner
49
+ self._response = response
50
+ self._chunks = response.aiter_raw()
51
+
52
+ async def read(self, amount: int) -> bytes:
53
+ try:
54
+ return await self._chunks.__anext__()
55
+ except StopAsyncIteration:
56
+ return b""
57
+ except Exception as error:
58
+ raise failure(error) from None
59
+
60
+ async def aclose(self) -> None:
61
+ await self._chunks.aclose()
62
+ await self._response.aclose()
63
+
64
+
65
+ class AsyncHttpxTransport:
66
+ """Sends requests with an `httpx.AsyncClient`, on the event loop that
67
+ awaits them.
68
+
69
+ `client` is the client to use (the caller's: `aclose()` leaves it open), or
70
+ `None` for a new one built from `client_args` (`verify`, `cert`, `http2`,
71
+ `proxy`, `mounts`, `limits`, `trust_env`, `auth`, ...) that `aclose()`
72
+ closes. `client_args` with a `client` is `TypeError`. Like any
73
+ `httpx.AsyncClient`, it keeps connections of the loop it first sent on.
74
+
75
+ The transport sends one request and follows no redirect, whatever the
76
+ client says. It sends exactly the headers it is given: the default headers
77
+ and the cookies of a client are not added. The client's `auth` is used for
78
+ the server of the first request only; a request to any other server
79
+ carries just the `Authorization` it was given. A request body that is a
80
+ file or an iterable is read on a worker thread.
81
+
82
+ Failures are `TimeoutError`, `ConnectionError`, `ssl.SSLError`,
83
+ `ValueError` or `OSError(EIO)`; what `httpx` says is never passed on."""
84
+
85
+ def __init__(self, client: "_ty.Any" = None, **client_args):
86
+ httpx = self._httpx = import_httpx()
87
+ if client is not None:
88
+ if client_args:
89
+ raise TypeError(
90
+ "client arguments apply to a client the transport makes"
91
+ )
92
+ if not _inspect.iscoroutinefunction(getattr(client, "send", None)):
93
+ raise TypeError("a client is an object with an async send() method")
94
+ self._owns_client = client is None
95
+ if client is None:
96
+ client = owned_client(httpx.AsyncClient)(
97
+ **{**client_args, "follow_redirects": False}
98
+ )
99
+ self.client = client
100
+ self._pin = Pin()
101
+
102
+ def __repr__(self) -> str:
103
+ return "AsyncHttpxTransport()"
104
+
105
+ async def __aenter__(self) -> "AsyncHttpxTransport":
106
+ return self
107
+
108
+ async def __aexit__(self, *exc_info) -> None:
109
+ await self.aclose()
110
+
111
+ async def aclose(self) -> None:
112
+ """Closes the client if this transport made it. Harmless when called
113
+ again."""
114
+ if self._owns_client:
115
+ await self.client.aclose()
116
+
117
+ async def send(
118
+ self,
119
+ method: str,
120
+ url: str,
121
+ *,
122
+ headers: "_ty.Mapping[str, str]",
123
+ body: "bytes | _ty.BinaryIO | _ty.Iterable[bytes] | None" = None,
124
+ timeout: "float | tuple | None",
125
+ stream: bool = False,
126
+ ) -> AsyncHTTPResponse:
127
+ """One request, one response, as `ThreadedTransport.send()`."""
128
+ prepared = prepare_request(
129
+ method, url, headers=headers, body=body, timeout=timeout
130
+ )
131
+ content = prepared.body
132
+ if content is not None and not isinstance(content, (bytes, bytearray)):
133
+ content = _pieces(content)
134
+ request = build_request(self._httpx, prepared, content)
135
+ auth = self._pin.auth(prepared, self._httpx.USE_CLIENT_DEFAULT)
136
+ try:
137
+ response = await self.client.send(
138
+ request, stream=True, follow_redirects=False, auth=auth
139
+ )
140
+ except Exception as error:
141
+ raise failure(error) from None
142
+ try:
143
+ pairs = header_pairs(response)
144
+ except BaseException:
145
+ await response.aclose()
146
+ raise
147
+ body = read_already(response)
148
+ result = AsyncHTTPResponse(
149
+ response.status_code,
150
+ response.reason_phrase,
151
+ pairs,
152
+ requested_url(request),
153
+ body if body is not None else _Body(self, response),
154
+ decode=prepared.decode and body is None,
155
+ )
156
+ return result if stream else await result.buffered()
@@ -0,0 +1,185 @@
1
+ """What the synchronous and the asynchronous transport share: the request as
2
+ `httpx` is given it, the reply as `pathapi.http` is given it, and the errors."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import errno as _errno
7
+ import ssl as _ssl
8
+ import urllib.parse as _urlparse
9
+
10
+ from pathapi.http import PreparedRequest
11
+
12
+ CHUNK = 64 * 1024
13
+ """Most bytes of a request body read in one call."""
14
+
15
+ MAX_HEADERS = 100
16
+ """Most header fields of a reply a transport accepts, as the built-in one."""
17
+
18
+ _DEFAULT_PORTS = {"http": 80, "https": 443}
19
+
20
+
21
+ def import_httpx():
22
+ try:
23
+ import httpx
24
+ except ImportError as error:
25
+ raise ImportError(
26
+ "httpx could not be imported; reinstall pathapi-httpx", name="httpx"
27
+ ) from error
28
+ return httpx
29
+
30
+
31
+ def origin(url: str) -> "tuple[str, str, int | None]":
32
+ """The scheme, host and port a URL names."""
33
+ parts = _urlparse.urlsplit(url)
34
+ scheme = parts.scheme.lower()
35
+ try:
36
+ port = parts.port
37
+ except ValueError:
38
+ port = -1
39
+ return scheme, (parts.hostname or "").lower(), port or _DEFAULT_PORTS.get(scheme)
40
+
41
+
42
+ class OnlyThis:
43
+ """Authentication that adds nothing of its own. A client's `auth` belongs to
44
+ the server of the first request; a request to any other carries the
45
+ `Authorization` it was given or none."""
46
+
47
+ def __init__(self, authorization: "str | None"):
48
+ self._authorization = authorization
49
+
50
+ def __call__(self, request):
51
+ request.headers.pop("Authorization", None)
52
+ if self._authorization is not None:
53
+ request.headers["Authorization"] = self._authorization
54
+ return request
55
+
56
+
57
+ class Pin:
58
+ """The origin of the first request a transport sends."""
59
+
60
+ def __init__(self) -> None:
61
+ self._origin: "tuple | None" = None
62
+
63
+ def auth(self, prepared: PreparedRequest, default):
64
+ """`default` (the client's own `auth`) for the first server; for any
65
+ other, an object that sends only the `Authorization` of the request."""
66
+ here = origin(prepared.url)
67
+ if self._origin is None:
68
+ self._origin = here
69
+ if self._origin == here:
70
+ return default
71
+ given = next(
72
+ (v for k, v in prepared.headers.items() if k.lower() == "authorization"),
73
+ None,
74
+ )
75
+ return OnlyThis(given)
76
+
77
+
78
+ def build_request(httpx, prepared: PreparedRequest, content):
79
+ """The `httpx.Request` for `prepared`: exactly its headers, none of a
80
+ client's defaults, with its `(connect, read)` timeout."""
81
+ connect, read = prepared.timeout
82
+ timeout = httpx.Timeout(connect=connect, read=read, write=read, pool=connect)
83
+ try:
84
+ return httpx.Request(
85
+ prepared.method,
86
+ prepared.url,
87
+ headers=prepared.headers,
88
+ content=content,
89
+ extensions={"timeout": timeout.as_dict()},
90
+ )
91
+ except httpx.InvalidURL:
92
+ raise ValueError("the request URL is not valid") from None
93
+
94
+
95
+ _OWNED: "dict[type, type]" = {}
96
+
97
+
98
+ def owned_client(base: type) -> type:
99
+ """`base` (`httpx.Client` or `httpx.AsyncClient`) as a client that does not
100
+ read the `Location` of a redirect it will not follow: `httpx` otherwise
101
+ fails a reply whose `Location` it cannot parse, where the caller of a
102
+ transport is to be given that reply."""
103
+ made = _OWNED.get(base)
104
+ if made is None:
105
+
106
+ class Client(base): # type: ignore[valid-type, misc]
107
+ def _build_redirect_request(self, request, response):
108
+ return request
109
+
110
+ made = _OWNED[base] = Client
111
+ return made
112
+
113
+
114
+ def requested_url(request) -> str:
115
+ """The URL a request went to, as `HTTPResponse.url` states it: no fragment,
116
+ `/` for an empty path."""
117
+ url = request.url
118
+ return f"{url.scheme}://{url.netloc.decode('ascii')}{url.raw_path.decode('ascii')}"
119
+
120
+
121
+ def read_already(response) -> "bytes | None":
122
+ """The body of a response that `httpx` has read whole and decoded (one a
123
+ mounted `httpx.MockTransport` made), else `None`."""
124
+ return response.content if response.is_stream_consumed else None
125
+
126
+
127
+ def header_pairs(response) -> "list[tuple[str, str]]":
128
+ """The headers of `response` as received, a name sent twice as two pairs;
129
+ more than `MAX_HEADERS` of them are `OSError(EIO)`."""
130
+ if len(response.headers.raw) > MAX_HEADERS:
131
+ raise OSError(_errno.EIO, f"the response has more than {MAX_HEADERS} headers")
132
+ return [
133
+ (name.decode("latin-1"), value.decode("latin-1"))
134
+ for name, value in response.headers.raw
135
+ ]
136
+
137
+
138
+ def _find(error: BaseException, kind: type) -> "BaseException | None":
139
+ """An exception of `kind` among the causes of `error`."""
140
+ pending, seen = [error], set()
141
+ while pending:
142
+ current = pending.pop()
143
+ if id(current) in seen:
144
+ continue
145
+ seen.add(id(current))
146
+ if isinstance(current, kind):
147
+ return current
148
+ pending.extend(
149
+ nxt
150
+ for nxt in (current.__cause__, current.__context__, *current.args)
151
+ if isinstance(nxt, BaseException)
152
+ )
153
+ return None
154
+
155
+
156
+ def failure(error: BaseException) -> BaseException:
157
+ """`error`, raised by `httpx` or by what it reads, as the exception a
158
+ transport raises: `TimeoutError`, `ConnectionError`, `ssl.SSLError`,
159
+ `ValueError` or `OSError(EIO)`. What `httpx` says, which can quote a URL,
160
+ is not passed on."""
161
+ httpx = import_httpx()
162
+ if isinstance(error, httpx.TimeoutException):
163
+ return TimeoutError(_errno.ETIMEDOUT, "the request timed out")
164
+ sslerror = _find(error, _ssl.SSLError)
165
+ if sslerror is not None:
166
+ return sslerror
167
+ if isinstance(error, httpx.RemoteProtocolError) and str(error).startswith(
168
+ "Server disconnected"
169
+ ):
170
+ return ConnectionError("the server closed the connection without a reply")
171
+ if isinstance(error, httpx.ProtocolError):
172
+ return OSError(_errno.EIO, "the response is not valid HTTP")
173
+ if isinstance(error, (httpx.NetworkError, httpx.ProxyError)):
174
+ return ConnectionError(f"connection failed ({type(error).__name__})")
175
+ if isinstance(error, httpx.InvalidURL):
176
+ # The URLs of a request were checked, so this is the `Location` of a
177
+ # reply, read by a client that was given to the transport.
178
+ return OSError(_errno.EIO, "the redirect of the reply names no usable URL")
179
+ if isinstance(error, httpx.UnsupportedProtocol):
180
+ return ValueError(f"the request is not valid ({type(error).__name__})")
181
+ if isinstance(error, (OSError, ValueError, TypeError)) and (
182
+ type(error).__module__ == "builtins"
183
+ ):
184
+ return error
185
+ return OSError(_errno.EIO, f"request failed ({type(error).__name__})")
pathapi_httpx/_sync.py ADDED
@@ -0,0 +1,134 @@
1
+ """`httpx.Client` as an HTTP transport for `pathapi.http`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typing as _ty
6
+ import weakref as _weakref
7
+
8
+ from pathapi.http import HTTPResponse, prepare_request
9
+
10
+ from ._common import (
11
+ CHUNK,
12
+ Pin,
13
+ build_request,
14
+ failure,
15
+ header_pairs,
16
+ import_httpx,
17
+ owned_client,
18
+ read_already,
19
+ requested_url,
20
+ )
21
+
22
+
23
+ class _Body:
24
+ """The raw (still encoded) body of an `httpx` response."""
25
+
26
+ def __init__(self, owner, response):
27
+ # The transport stays alive while its reply is open: collecting it
28
+ # would close the client the reply is reading from.
29
+ self._owner = owner
30
+ self._response = response
31
+ self._chunks = response.iter_raw()
32
+
33
+ def read(self, amount: int) -> bytes:
34
+ try:
35
+ return next(self._chunks, b"")
36
+ except Exception as error:
37
+ raise failure(error) from None
38
+
39
+ def close(self) -> None:
40
+ self._chunks.close()
41
+ self._response.close()
42
+
43
+
44
+ class HttpxTransport:
45
+ """Sends requests with an `httpx.Client`.
46
+
47
+ `client` is the client to use (the caller's: `close()` leaves it open), or
48
+ `None` for a new one built from `client_args` (`verify`, `cert`, `http2`,
49
+ `proxy`, `mounts`, `limits`, `trust_env`, `auth`, ...) that `close()`
50
+ closes. `client_args` with a `client` is `TypeError`.
51
+
52
+ The transport sends one request and follows no redirect, whatever the
53
+ client says. It sends exactly the headers it is given: the default headers
54
+ and the cookies of a client are not added. The client's `auth` is used for
55
+ the server of the first request only; a request to any other server
56
+ carries just the `Authorization` it was given.
57
+
58
+ Failures are `TimeoutError`, `ConnectionError`, `ssl.SSLError`,
59
+ `ValueError` or `OSError(EIO)`; what `httpx` says is never passed on."""
60
+
61
+ def __init__(self, client: "_ty.Any" = None, **client_args):
62
+ httpx = self._httpx = import_httpx()
63
+ if client is not None:
64
+ if client_args:
65
+ raise TypeError(
66
+ "client arguments apply to a client the transport makes"
67
+ )
68
+ if not callable(getattr(client, "send", None)):
69
+ raise TypeError("a client is an object with a send() method")
70
+ self._owns_client = client is None
71
+ if client is None:
72
+ client = owned_client(httpx.Client)(
73
+ **{**client_args, "follow_redirects": False}
74
+ )
75
+ # Without a finalizer a client that is dropped leaves its sockets
76
+ # to the garbage collector.
77
+ self._finalizer = _weakref.finalize(self, client.close)
78
+ self.client = client
79
+ self._pin = Pin()
80
+
81
+ def __repr__(self) -> str:
82
+ return "HttpxTransport()"
83
+
84
+ def __enter__(self) -> "HttpxTransport":
85
+ return self
86
+
87
+ def __exit__(self, *exc_info) -> None:
88
+ self.close()
89
+
90
+ def close(self) -> None:
91
+ """Closes the client if this transport made it, and does so when the
92
+ transport is collected without having been closed. Harmless when
93
+ called again."""
94
+ if self._owns_client:
95
+ self._finalizer.detach()
96
+ self.client.close()
97
+
98
+ def send(
99
+ self,
100
+ method: str,
101
+ url: str,
102
+ *,
103
+ headers: "_ty.Mapping[str, str]",
104
+ body: "bytes | _ty.BinaryIO | _ty.Iterable[bytes] | None" = None,
105
+ timeout: "float | tuple | None",
106
+ stream: bool = False,
107
+ ) -> HTTPResponse:
108
+ """One request, one response, as `HTTPTransport.send()`."""
109
+ prepared = prepare_request(
110
+ method, url, headers=headers, body=body, timeout=timeout
111
+ )
112
+ request = build_request(self._httpx, prepared, prepared.body)
113
+ auth = self._pin.auth(prepared, self._httpx.USE_CLIENT_DEFAULT)
114
+ try:
115
+ response = self.client.send(
116
+ request, stream=True, follow_redirects=False, auth=auth
117
+ )
118
+ except Exception as error:
119
+ raise failure(error) from None
120
+ try:
121
+ pairs = header_pairs(response)
122
+ except BaseException:
123
+ response.close()
124
+ raise
125
+ body = read_already(response)
126
+ result = HTTPResponse(
127
+ response.status_code,
128
+ response.reason_phrase,
129
+ pairs,
130
+ requested_url(request),
131
+ body if body is not None else _Body(self, response),
132
+ decode=prepared.decode and body is None,
133
+ )
134
+ return result if stream else result.buffered()
@@ -0,0 +1,11 @@
1
+ """The version of the installed distribution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib import metadata as _metadata
6
+
7
+ try:
8
+ __version__ = _metadata.version("pathapi-httpx")
9
+ except _metadata.PackageNotFoundError:
10
+ # Run from a source tree that was never installed.
11
+ __version__ = "0+unknown"
pathapi_httpx/py.typed ADDED
File without changes
@@ -0,0 +1,172 @@
1
+ Metadata-Version: 2.5
2
+ Name: pathapi-httpx
3
+ Version: 0.1.0
4
+ Summary: The httpx HTTP transports for pathapi: a synchronous one and a native async one, with HTTP/2, your own httpx client and its mounts
5
+ Project-URL: Homepage, https://github.com/jose-pr/pathapi-httpx/
6
+ Project-URL: Documentation, https://jose-pr.github.io/pathapi-httpx/
7
+ Project-URL: Issues, https://github.com/jose-pr/pathapi-httpx/issues
8
+ Author: Jose A
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: httpx<0.29,>=0.28.0
23
+ Requires-Dist: pathapi<0.2,>=0.1.2
24
+ Provides-Extra: dev
25
+ Requires-Dist: black; extra == 'dev'
26
+ Requires-Dist: build; extra == 'dev'
27
+ Requires-Dist: cryptography; extra == 'dev'
28
+ Requires-Dist: hatchling; extra == 'dev'
29
+ Requires-Dist: pytest; extra == 'dev'
30
+ Requires-Dist: pytest-cov; extra == 'dev'
31
+ Requires-Dist: twine; extra == 'dev'
32
+ Provides-Extra: docs
33
+ Requires-Dist: mkdocs; extra == 'docs'
34
+ Requires-Dist: mkdocs-material; extra == 'docs'
35
+ Requires-Dist: mkdocstrings[python]; extra == 'docs'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # pathapi-httpx
39
+
40
+ [![Version](https://img.shields.io/pypi/v/pathapi-httpx.svg)](https://pypi.org/project/pathapi-httpx/)
41
+ [![Python versions](https://img.shields.io/pypi/pyversions/pathapi-httpx.svg)](https://pypi.org/project/pathapi-httpx/)
42
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/jose-pr/pathapi-httpx/blob/main/LICENSE)
43
+ [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://jose-pr.github.io/pathapi-httpx/)
44
+ [![CI](https://img.shields.io/github/actions/workflow/status/jose-pr/pathapi-httpx/test.yml)](https://github.com/jose-pr/pathapi-httpx/actions/workflows/test.yml)
45
+
46
+ **The `httpx` HTTP transports for [`pathapi`](https://github.com/jose-pr/pathapi).**
47
+ `pathapi.http` sends requests with a transport built on the standard library,
48
+ and from an event loop with that transport on worker threads. Install this
49
+ distribution and `HTTPClient` sends through an `httpx.Client`, and
50
+ `AsyncHTTPClient` through an `httpx.AsyncClient` on the caller's loop, with
51
+ nothing to configure. It is documented at
52
+ <https://jose-pr.github.io/pathapi-httpx/>.
53
+
54
+ ## Features
55
+
56
+ | You want | Built-in transports | These transports |
57
+ | --- | --- | --- |
58
+ | Requests from an event loop without a worker thread per request | No: `ThreadedTransport` runs the standard-library transport on threads | Yes: `AsyncHttpxTransport` |
59
+ | HTTP/2 | No | Yes: `HttpxTransport(http2=True)` with `httpx[http2]` |
60
+ | Your own `httpx.Client` or `httpx.AsyncClient`: mounted transports, limits, proxies, `auth` | No | Yes: `HttpxTransport(client)` |
61
+ | `SSL_CERT_FILE`, `SSL_CERT_DIR` and the certifi bundle | The system trust store, or an `ssl_context=` | Yes |
62
+ | Proxies from the environment, `proxy=` and `mounts=` | `http://` proxies through a provider | Yes, as `httpx` does them |
63
+
64
+ - **Credentials belong to one server.** The `auth` of a client is sent to the
65
+ server of the first request and to no other, and the clients of `pathapi.http`
66
+ never take an `Authorization` header to another server on a redirect.
67
+ - **Exactly the headers it is given.** A client's default headers and cookies
68
+ are not added: `HTTPClient` and `AsyncHTTPClient` own the headers of a request.
69
+ - **No third-party text in an error.** A failure of `httpx` is a
70
+ `TimeoutError`, `ConnectionError`, `ssl.SSLError`, `ValueError` or
71
+ `OSError(EIO)`; what the library says, which can quote a URL, is never passed on.
72
+ - **The same promises as the built-in transports.** The tests `pathapi` runs
73
+ over its own transports run over these
74
+ (`python -m pathapi.http.testing --http-transports httpx --http-async-transports httpx`).
75
+
76
+ ## Installation
77
+
78
+ ```bash
79
+ pip install pathapi-httpx
80
+ ```
81
+
82
+ Requires `pathapi` and `httpx`, which pip installs with it. HTTP/2 needs
83
+ `pip install "httpx[http2]"`.
84
+
85
+ ## Quick start
86
+
87
+ **Send through an `httpx.Client` from threads or a script:**
88
+
89
+ ```python
90
+ import httpx
91
+ from pathapi.http import HTTPClient
92
+ from pathapi_httpx import HttpxTransport
93
+
94
+ with httpx.Client() as session:
95
+ with HTTPClient(HttpxTransport(session), timeout=(3, 30)) as client:
96
+ reply = client.request("GET", "http://files.example.org/reports/today.txt")
97
+ print(reply.read().decode())
98
+ ```
99
+
100
+ The `httpx.Client` stays yours: neither the client nor the transport closes it.
101
+
102
+ **Send from an event loop** with `httpx.AsyncClient` on that loop:
103
+
104
+ ```python
105
+ import asyncio
106
+
107
+ from pathapi.http import AsyncHTTPClient
108
+ from pathapi_httpx import AsyncHttpxTransport
109
+
110
+
111
+ async def main():
112
+ async with AsyncHttpxTransport() as transport:
113
+ async with AsyncHTTPClient(transport) as client:
114
+ replies = await asyncio.gather(
115
+ *(
116
+ client.request("GET", f"http://files.example.org/reports/{day}.txt")
117
+ for day in ("mon", "tue", "wed")
118
+ )
119
+ )
120
+ print([len(await reply.read()) for reply in replies])
121
+
122
+
123
+ asyncio.run(main())
124
+ ```
125
+
126
+ **Choose them for the whole process.** `PATHAPI_HTTP_TRANSPORT=httpx` and
127
+ `PATHAPI_HTTP_ASYNC_TRANSPORT=httpx` make every `HTTPClient` and
128
+ `AsyncHTTPClient` that was given no transport send through a new `httpx` client:
129
+
130
+ ```bash
131
+ PATHAPI_HTTP_ASYNC_TRANSPORT=httpx python -c "from pathapi.http import AsyncHTTPClient; print(type(AsyncHTTPClient().transport))"
132
+ ```
133
+
134
+ ## API overview
135
+
136
+ | Name | Purpose |
137
+ | --- | --- |
138
+ | `pathapi_httpx.HttpxTransport` | The transport: `send()` and `close()` over an `httpx.Client` |
139
+ | `pathapi_httpx.AsyncHttpxTransport` | The async transport: `await send()` and `await aclose()` over an `httpx.AsyncClient` |
140
+ | `pathapi_httpx.__version__` | The installed version |
141
+
142
+ `pathapi.http` finds the classes through the `httpx` entries of its
143
+ `pathapi.http_transports` and `pathapi.http_async_transports` entry-point
144
+ groups, which this distribution declares. The reference is in the
145
+ [API header](https://github.com/jose-pr/pathapi-httpx/blob/main/src/pathapi_httpx/AGENTS.md)
146
+ that ships with the package.
147
+
148
+ ## Development
149
+
150
+ ```bash
151
+ python -m venv .venv/3.14-posix-x86_64
152
+ .venv/3.14-posix-x86_64/bin/python -m pip install -e ".[dev]"
153
+ .venv/3.14-posix-x86_64/bin/python -m pytest -q -rs
154
+ ```
155
+
156
+ `pathapi` 0.1.2 is a dependency; while it is not on PyPI, install it
157
+ editable from its checkout first (`pip install -e ../pathapi`). The test run
158
+ includes `python -m pathapi.http.testing --http-transports httpx
159
+ --http-async-transports httpx`, the tests `pathapi` runs over its own transports.
160
+
161
+ ### Releasing
162
+
163
+ This project follows [Semantic Versioning](https://semver.org/) and keeps a
164
+ [`CHANGELOG.md`](CHANGELOG.md). Pushing a tag matching `v*` triggers the release
165
+ workflow: test gate → build (checking the tag names the version built) → a strict
166
+ docs build as a gate → GitHub release → publish. The release workflow never deploys
167
+ the docs site itself: for a final release its last job dispatches the docs workflow
168
+ at the tag, which owns every Pages deploy.
169
+
170
+ ## License
171
+
172
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,13 @@
1
+ pathapi_httpx/AGENTS.md,sha256=HmST-TIoLPJUOPYzyzC9h9XnTwHzDjkIy-KNw1P_HNg,7776
2
+ pathapi_httpx/__init__.py,sha256=GF0RZ2y2elC7KH0nvTUHZ5mfrIMjABr46Fc1jjNyHxI,741
3
+ pathapi_httpx/_async.py,sha256=qYi2XEX4WTd9qR05qt6fZM8es_9vQ8A5qdh3Xw8X3Xc,5343
4
+ pathapi_httpx/_common.py,sha256=vv8d4ODpJxPF1DcNwM-vfdneJTHdwnJnjEdCTg6ZtkQ,6720
5
+ pathapi_httpx/_sync.py,sha256=U2M00gKZ-JfifhEHalEjApyG5aiMy17DMg81zsLSnzY,4575
6
+ pathapi_httpx/_version.py,sha256=aWKSxVVOyk2ibF6fkEoqxrlXknS5XsPECa6LfsKQqDs,313
7
+ pathapi_httpx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ pathapi_httpx/README.md,sha256=3BtWXywk7tfjHBcJ_n1h6gta4NrMtwCme4kSGVoR0ok,5934
9
+ pathapi_httpx-0.1.0.dist-info/METADATA,sha256=QZSQK6R7KK3GW4fVHpyR9_7shyj-osihgbbiB4pE4mc,7457
10
+ pathapi_httpx-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
11
+ pathapi_httpx-0.1.0.dist-info/entry_points.txt,sha256=cNbQg2CRSj4UxSazq5nrISnut9ji4qfWCE3Ys_B0C9Y,138
12
+ pathapi_httpx-0.1.0.dist-info/licenses/LICENSE,sha256=bBY0SeXglEP0yGEOZbyqRTbNfXD0DVpkr-3mBq7-BPQ,1063
13
+ pathapi_httpx-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [pathapi.http_async_transports]
2
+ httpx = pathapi_httpx:AsyncHttpxTransport
3
+
4
+ [pathapi.http_transports]
5
+ httpx = pathapi_httpx:HttpxTransport
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Jose A
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.