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.
- pathapi_httpx/AGENTS.md +149 -0
- pathapi_httpx/README.md +135 -0
- pathapi_httpx/__init__.py +18 -0
- pathapi_httpx/_async.py +156 -0
- pathapi_httpx/_common.py +185 -0
- pathapi_httpx/_sync.py +134 -0
- pathapi_httpx/_version.py +11 -0
- pathapi_httpx/py.typed +0 -0
- pathapi_httpx-0.1.0.dist-info/METADATA +172 -0
- pathapi_httpx-0.1.0.dist-info/RECORD +13 -0
- pathapi_httpx-0.1.0.dist-info/WHEEL +4 -0
- pathapi_httpx-0.1.0.dist-info/entry_points.txt +5 -0
- pathapi_httpx-0.1.0.dist-info/licenses/LICENSE +21 -0
pathapi_httpx/AGENTS.md
ADDED
|
@@ -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.
|
pathapi_httpx/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# pathapi-httpx
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/pathapi-httpx/)
|
|
4
|
+
[](https://pypi.org/project/pathapi-httpx/)
|
|
5
|
+
[](https://github.com/jose-pr/pathapi-httpx/blob/main/LICENSE)
|
|
6
|
+
[](https://jose-pr.github.io/pathapi-httpx/)
|
|
7
|
+
[](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__"]
|
pathapi_httpx/_async.py
ADDED
|
@@ -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()
|
pathapi_httpx/_common.py
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/pathapi-httpx/)
|
|
41
|
+
[](https://pypi.org/project/pathapi-httpx/)
|
|
42
|
+
[](https://github.com/jose-pr/pathapi-httpx/blob/main/LICENSE)
|
|
43
|
+
[](https://jose-pr.github.io/pathapi-httpx/)
|
|
44
|
+
[](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,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.
|