httpr 0.7.1__tar.gz → 0.7.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {httpr-0.7.1 → httpr-0.7.4}/.github/workflows/CI.yml +75 -13
- {httpr-0.7.1 → httpr-0.7.4}/CLAUDE.md +11 -4
- {httpr-0.7.1 → httpr-0.7.4}/Cargo.lock +1 -1
- {httpr-0.7.1 → httpr-0.7.4}/Cargo.toml +1 -1
- {httpr-0.7.1 → httpr-0.7.4}/PKG-INFO +7 -3
- {httpr-0.7.1 → httpr-0.7.4}/README.md +6 -2
- {httpr-0.7.1 → httpr-0.7.4}/docs/advanced/proxy.md +1 -1
- {httpr-0.7.1 → httpr-0.7.4}/docs/advanced/ssl-tls.md +16 -7
- {httpr-0.7.1 → httpr-0.7.4}/docs/api/index.md +1 -0
- {httpr-0.7.1 → httpr-0.7.4}/docs/api/response.md +19 -1
- {httpr-0.7.1 → httpr-0.7.4}/docs/tutorial/making-requests.md +15 -5
- {httpr-0.7.1 → httpr-0.7.4}/httpr/__init__.py +47 -32
- {httpr-0.7.1 → httpr-0.7.4}/httpr/httpr.pyi +30 -4
- {httpr-0.7.1 → httpr-0.7.4}/pyproject.toml +1 -1
- {httpr-0.7.1 → httpr-0.7.4}/src/exceptions.rs +12 -2
- {httpr-0.7.1 → httpr-0.7.4}/src/lib.rs +90 -72
- {httpr-0.7.1 → httpr-0.7.4}/src/lifecycle.rs +38 -7
- {httpr-0.7.1 → httpr-0.7.4}/src/response.rs +72 -8
- httpr-0.7.4/tests/e2e/test_http_version.py +48 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_asyncclient.py +13 -14
- {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_close.py +92 -33
- httpr-0.7.4/tests/unit/test_http_version.py +48 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_proxy.py +6 -4
- {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_streaming.py +5 -5
- {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_timeout.py +13 -11
- {httpr-0.7.1 → httpr-0.7.4}/.github/actions/set-version/action.yml +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/.github/copilot-instructions.md +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/.github/workflows/benchmark.yml +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/.github/workflows/codspeed.yml +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/.github/workflows/compare.yml +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/.github/workflows/copilot-setup-steps.yml +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/.github/workflows/mkdocs.yml +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/.github/workflows/set_version.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/.gitignore +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/.pre-commit-config.yaml +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/LICENSE +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/Taskfile.yaml +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/benchmark/README.md +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/benchmark/__init__.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/benchmark/benchmark.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/benchmark/benchmark_cbor.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/benchmark/render_comparison.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/benchmark/server.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/docs/advanced/cookies.md +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/docs/advanced/index.md +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/docs/api/async-client.md +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/docs/api/client.md +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/docs/api/functions.md +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/docs/benchmark.md +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/docs/index.md +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/docs/quickstart.md +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/docs/tutorial/async.md +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/docs/tutorial/authentication.md +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/docs/tutorial/index.md +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/docs/tutorial/response-handling.md +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/docs/writings/index.md +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/docs/writings/posts/2025-02-24-python-http-clients-suck.md +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/httpr/py.typed +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/mkdocs.yml +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/scripts/generate_certs.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/src/params.rs +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/src/timeout.rs +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/src/traits.rs +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/src/utils.rs +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/benchmark/__init__.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/benchmark/codspeed/README.md +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/benchmark/codspeed/__init__.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/benchmark/codspeed/bench_server.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/benchmark/codspeed/conftest.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/benchmark/codspeed/test_decoding.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/benchmark/codspeed/test_transport.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/benchmark/test_performance.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/conftest.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/e2e/__init__.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/e2e/test_async.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/e2e/test_auth.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/e2e/test_redirects.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/e2e/test_ssl.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/e2e/test_streaming.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/e2e/test_uploads.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/unit/__init__.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/unit/cbor_test_server.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/unit/httpx_conns.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_ca_bundle.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_cbor.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_client.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_defs.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_docs.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_exceptions.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_params.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_response.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_ssl.py +0 -0
- {httpr-0.7.1 → httpr-0.7.4}/uv.lock +0 -0
|
@@ -62,6 +62,53 @@ jobs:
|
|
|
62
62
|
uv run task dev
|
|
63
63
|
uv run task test:unit
|
|
64
64
|
|
|
65
|
+
# Downstream compatibility: pyvespa's unit suite against the wheel built from
|
|
66
|
+
# this commit. pyvespa is the main consumer of httpr and consumed lazy
|
|
67
|
+
# generators after `close()` for a year without anyone noticing, until 0.7.0
|
|
68
|
+
# made close() real. Runs on PRs, main branch, and tag pushes.
|
|
69
|
+
downstream-pyvespa:
|
|
70
|
+
runs-on: ubuntu-22.04
|
|
71
|
+
if: github.event_name == 'pull_request' || (github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')))
|
|
72
|
+
steps:
|
|
73
|
+
- uses: actions/checkout@v5
|
|
74
|
+
- uses: astral-sh/setup-uv@v7
|
|
75
|
+
with:
|
|
76
|
+
python-version: "3.12"
|
|
77
|
+
- name: Cache Rust build
|
|
78
|
+
uses: actions/cache@v5
|
|
79
|
+
with:
|
|
80
|
+
path: |
|
|
81
|
+
~/.cargo/registry
|
|
82
|
+
~/.cargo/git
|
|
83
|
+
target
|
|
84
|
+
key: rust-v2-${{ runner.os }}-downstream-${{ hashFiles('**/Cargo.lock') }}
|
|
85
|
+
restore-keys: |
|
|
86
|
+
rust-v2-${{ runner.os }}-downstream-
|
|
87
|
+
- name: Build wheel
|
|
88
|
+
run: uv run --with maturin maturin build --release --out dist
|
|
89
|
+
- name: Check out pyvespa master
|
|
90
|
+
uses: actions/checkout@v5
|
|
91
|
+
with:
|
|
92
|
+
repository: vespa-engine/pyvespa
|
|
93
|
+
ref: master
|
|
94
|
+
path: pyvespa
|
|
95
|
+
- name: Install pyvespa, then swap in this wheel
|
|
96
|
+
# pyvespa pins httpr>=0.4.0 and the local wheel is 0.0.0.dev0, so it
|
|
97
|
+
# cannot be part of the resolve; install it afterwards with --no-deps.
|
|
98
|
+
run: |
|
|
99
|
+
uv venv .venv-pyvespa
|
|
100
|
+
uv pip install --python .venv-pyvespa/bin/python "./pyvespa[unittest]"
|
|
101
|
+
uv pip install --python .venv-pyvespa/bin/python --no-deps --reinstall dist/httpr-*.whl
|
|
102
|
+
.venv-pyvespa/bin/python -c "import importlib.metadata as m; v = m.version('httpr'); assert v.startswith('0.0.0'), v; print('httpr', v)"
|
|
103
|
+
- name: pyvespa unit tests
|
|
104
|
+
# The venv's bin goes first on PATH: the deployment tests create a
|
|
105
|
+
# throwaway Vespa Cloud cert/key pair with the `vespa` CLI (from the
|
|
106
|
+
# `vespacli` package in pyvespa's unittest extra) and skip it otherwise.
|
|
107
|
+
working-directory: pyvespa
|
|
108
|
+
run: |
|
|
109
|
+
export PATH="$GITHUB_WORKSPACE/.venv-pyvespa/bin:$PATH"
|
|
110
|
+
python -m pytest tests/unit -q -p no:cacheprovider
|
|
111
|
+
|
|
65
112
|
# E2E tests with httpbun Docker container - runs on PRs, main branch, and tag pushes
|
|
66
113
|
e2e:
|
|
67
114
|
runs-on: ubuntu-22.04
|
|
@@ -143,13 +190,25 @@ jobs:
|
|
|
143
190
|
matrix:
|
|
144
191
|
platform:
|
|
145
192
|
- runner: ubuntu-22.04
|
|
146
|
-
target: x86_64
|
|
193
|
+
target: x86_64-unknown-linux-gnu
|
|
194
|
+
arch: x86_64
|
|
195
|
+
manylinux: auto
|
|
147
196
|
- runner: ubuntu-22.04
|
|
148
|
-
target:
|
|
197
|
+
target: i686-unknown-linux-gnu
|
|
198
|
+
arch: x86
|
|
199
|
+
manylinux: auto
|
|
149
200
|
- runner: ubuntu-24.04-arm
|
|
150
|
-
target: aarch64
|
|
201
|
+
target: aarch64-unknown-linux-gnu
|
|
202
|
+
arch: aarch64
|
|
203
|
+
manylinux: auto
|
|
151
204
|
- runner: ubuntu-22.04
|
|
152
|
-
target: armv7
|
|
205
|
+
target: armv7-unknown-linux-gnueabihf
|
|
206
|
+
arch: armv7
|
|
207
|
+
manylinux: auto
|
|
208
|
+
- runner: ubuntu-24.04-riscv
|
|
209
|
+
target: riscv64gc-unknown-linux-gnu
|
|
210
|
+
arch: riscv64
|
|
211
|
+
manylinux: 2_39
|
|
153
212
|
# - runner: ubuntu-22.04
|
|
154
213
|
# target: s390x
|
|
155
214
|
# - runner: ubuntu-22.04
|
|
@@ -169,21 +228,21 @@ jobs:
|
|
|
169
228
|
target: ${{ matrix.platform.target }}
|
|
170
229
|
args: --release --out dist
|
|
171
230
|
sccache: true
|
|
172
|
-
manylinux:
|
|
231
|
+
manylinux: ${{ matrix.platform.manylinux }}
|
|
173
232
|
- name: Build free-threaded wheels
|
|
174
233
|
uses: PyO3/maturin-action@v1
|
|
175
234
|
with:
|
|
176
235
|
target: ${{ matrix.platform.target }}
|
|
177
236
|
args: --release --out dist -i python3.14t
|
|
178
237
|
sccache: true
|
|
179
|
-
manylinux:
|
|
238
|
+
manylinux: ${{ matrix.platform.manylinux }}
|
|
180
239
|
- name: Upload wheels
|
|
181
240
|
uses: actions/upload-artifact@v6
|
|
182
241
|
with:
|
|
183
|
-
name: wheels-linux-${{ matrix.platform.
|
|
242
|
+
name: wheels-linux-${{ matrix.platform.arch }}
|
|
184
243
|
path: dist
|
|
185
244
|
- name: pytest
|
|
186
|
-
if: ${{ matrix.platform.
|
|
245
|
+
if: ${{ matrix.platform.arch == 'x86_64' || matrix.platform.arch == 'aarch64' }}
|
|
187
246
|
shell: bash
|
|
188
247
|
env:
|
|
189
248
|
RUSTC_WRAPPER: ""
|
|
@@ -202,11 +261,14 @@ jobs:
|
|
|
202
261
|
matrix:
|
|
203
262
|
platform:
|
|
204
263
|
- runner: ubuntu-22.04
|
|
205
|
-
target: x86_64
|
|
264
|
+
target: x86_64-unknown-linux-musl
|
|
265
|
+
arch: x86_64
|
|
206
266
|
- runner: ubuntu-22.04
|
|
207
|
-
target:
|
|
267
|
+
target: i686-unknown-linux-musl
|
|
268
|
+
arch: x86
|
|
208
269
|
- runner: ubuntu-24.04-arm
|
|
209
|
-
target: aarch64
|
|
270
|
+
target: aarch64-unknown-linux-musl
|
|
271
|
+
arch: aarch64
|
|
210
272
|
# - runner: ubuntu-22.04
|
|
211
273
|
# target: armv7
|
|
212
274
|
steps:
|
|
@@ -235,10 +297,10 @@ jobs:
|
|
|
235
297
|
- name: Upload wheels
|
|
236
298
|
uses: actions/upload-artifact@v6
|
|
237
299
|
with:
|
|
238
|
-
name: wheels-musllinux-${{ matrix.platform.
|
|
300
|
+
name: wheels-musllinux-${{ matrix.platform.arch }}
|
|
239
301
|
path: dist
|
|
240
302
|
- name: pytest
|
|
241
|
-
if: ${{ matrix.platform.
|
|
303
|
+
if: ${{ matrix.platform.arch == 'x86_64' || matrix.platform.arch == 'aarch64' }}
|
|
242
304
|
uses: addnab/docker-run-action@v3
|
|
243
305
|
with:
|
|
244
306
|
image: alpine:latest
|
|
@@ -186,7 +186,13 @@ builds in debug mode and the numbers are meaningless.
|
|
|
186
186
|
|
|
187
187
|
### Proxy
|
|
188
188
|
- Set via `proxy` param or `HTTPR_PROXY` env var; the env var is read once in `new()`, never in the setter
|
|
189
|
-
- Changing `client.proxy` rebuilds the entire reqwest client (expensive). `RClient.config: ClientConfig` keeps the construction settings (loaded root certs, mTLS `Identity`, redirects, verify, https_only, http2_only, cookie_store, referer) and `ClientConfig::build()` is the single place a `reqwest::Client` is built, used by `new()` and `set_proxy()`; add any new builder setting there, not inline (issue #84). The setter takes `Option<String>`; `None` removes the proxy. The rebuilt client starts with an empty cookie store
|
|
189
|
+
- Changing `client.proxy` rebuilds the entire reqwest client (expensive). `RClient.config: ClientConfig` keeps the construction settings (loaded root certs, mTLS `Identity`, redirects, verify, https_only, http2_only, http1_only, cookie_store, referer) and `ClientConfig::build()` is the single place a `reqwest::Client` is built, used by `new()` and `set_proxy()`; add any new builder setting there, not inline (issue #84). The setter takes `Option<String>`; `None` removes the proxy. The rebuilt client starts with an empty cookie store
|
|
190
|
+
|
|
191
|
+
### HTTP Version (issue #113)
|
|
192
|
+
- Default (`http2_only=False`, `http1_only=False`) is reqwest's ALPN negotiation: HTTP/2 over TLS when the server offers it, else HTTP/1.1; plain `http://` is HTTP/1.1. It is not "HTTP/1 only", whatever older docs said
|
|
193
|
+
- `http2_only=True` → `http2_prior_knowledge()` (h2c on `http://`); `http1_only=True` → `http1_only()`; both at once raises `ValueError` in `new()`. Both live in `ClientConfig`
|
|
194
|
+
- pyvespa relies on both current modes (sync client: negotiation, gets h2 on Vespa Cloud; `VespaAsync`: `http2_only=True`, h2c locally). Do not switch the default to httpx's HTTP/1.1-only or deprecate `http2_only` without Thomas's approval (issue #111)
|
|
195
|
+
- `Response.http_version` / `StreamingResponse.http_version` use httpx spelling (`"HTTP/1.1"`, `"HTTP/2"`), set from `ResponseMeta::from_response` in `src/response.rs`
|
|
190
196
|
|
|
191
197
|
### Client Lifecycle (issue #88)
|
|
192
198
|
- All lifecycle state lives in `src/lifecycle.rs`: `ClientState` (`Arc`-shared between an `RClient` and its in-flight requests) holds `client: Mutex<Option<reqwest::Client>>`, an `in_flight` counter and a `CancellationToken` for pending connects. `RClient::close()` takes the client (`None`); dropping the `reqwest::Client` drops the client's handle to its connection pool
|
|
@@ -194,9 +200,10 @@ builds in debug mode and the numbers are meaningless.
|
|
|
194
200
|
- Overlapping requests make hyper race a fresh connect against the idle-pool checkout; a losing connect is finished in the background and holds the pool alive until it resolves, which needs real I/O and against a remote host can take longer than `close()` should wait. So every `reqwest::Client` is built with `CancelConnectsLayer` (a tower layer over reqwest's connector, via `ClientBuilder::connector_layer`) and `close()` cancels the token when nothing is in flight; cancelled connects resolve on the next scheduler turn and drop their pool handle. Do not replace this with waiting or with `num_alive_tasks` heuristics: the count is runtime-global and a pending connect keeps it constant
|
|
195
201
|
- `begin_request()` increments `in_flight` before cloning the `reqwest::Client`, so a request already in flight keeps the pool alive and finishes normally when the client is closed underneath it. While anything is in flight `close()` only drops its own handle and yields; the `InFlight` guard of the last request to finish (in `request()` after the body is buffered, or `StreamingResponse::close()`/drop for streams) cancels pending connects and settles. `StreamingResponse` owns its `InFlight` for its whole lifetime, since its connection stays busy until it is closed
|
|
196
202
|
- `set_proxy` swaps the client via `ClientState::replace()` and settles the old pool; it raises `ClientClosed` on a closed client. `RClient` implements `Drop`, so a client garbage-collected without `close()` releases its pool as well (safe: every `block_on` in the crate runs with the GIL released, so dealloc never happens inside one)
|
|
197
|
-
- Use after close
|
|
198
|
-
- Python: `Client.close()`/`__exit__` call the Rust `close()`; `AsyncClient.aclose()`/`__aexit__` additionally `shutdown(wait=False)` the client's own `ThreadPoolExecutor
|
|
199
|
-
- Leaving a `with`/`async with` block closes the client; re-entering it afterwards
|
|
203
|
+
- Use after close reopens the client (since 0.7.2; `requests.Session` semantics): `RClient::begin_request` finds the slot empty, rebuilds the `reqwest::Client` via `ClientConfig::build()` with a fresh `CancellationToken`, installs it with `ClientState::reopen()` (the old token was cancelled by `close()` and must not be reused) and emits `ClientReopenedWarning`, a `ResourceWarning` subclass, so `warnings.simplefilter("error", httpr.ClientReopenedWarning)` gives httpx's strict behaviour. The rebuilt client starts with an empty cookie store; headers, params, auth, proxy and timeout live on `RClient` and carry over. `is_closed` is true from `close()` until the next request. `ClientClosed` (a `RuntimeError` subclass) stays exported but is only raised if a `close()` races the reopen. Both messages live once in Rust (`CLIENT_CLOSED_MSG`/`CLIENT_REOPENED_MSG`, exported as `_CLIENT_CLOSED_MSG`/`_CLIENT_REOPENED_MSG`). Do not go back to raising on use after close: every released pyvespa (`httpr>=0.4.0`) consumes `visit`/streaming-query generators after the owning `with VespaSync` block has closed the client, and 0.7.0/0.7.1 broke them all (pyvespa PR #1347 fixes the pattern going forward)
|
|
204
|
+
- Python: `Client.close()`/`__exit__` call the Rust `close()`; `AsyncClient.aclose()`/`__aexit__` additionally `shutdown(wait=False)` the client's own `ThreadPoolExecutor` and drop it, and run on the event-loop thread on purpose (sub-millisecond, no I/O wait). The executor is created on demand by `_dispatch_executor()`, so a reopened client gets a new one; `_run_sync_asyncio` retries once on the executor's "cannot schedule new futures after shutdown" `RuntimeError`, which covers a `close()` racing in from another OS thread
|
|
205
|
+
- Leaving a `with`/`async with` block closes the client; re-entering it afterwards works and warns once per reopen
|
|
206
|
+
- CI runs pyvespa's unit suite (`origin/master`) against the wheel built from every PR (`downstream-pyvespa` job in `CI.yml`); it is the guard against this class of regression
|
|
200
207
|
|
|
201
208
|
### Timeouts (issue #81)
|
|
202
209
|
- Default `timeout` is 30 s and lives in the PyO3 `#[new]` signature in `src/lib.rs`; `Client.__init__` in Python only documents the parameters and forwards nothing, so its defaults must match Rust's (`test_python_signature_matches_rust_defaults` enforces this). `None` disables the timeout
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name = "httpr"
|
|
3
3
|
# Version is set dynamically by CI from git tags (e.g., v1.2.3 -> 1.2.3).
|
|
4
4
|
# Do not edit manually. See .github/actions/set-version/ for details.
|
|
5
|
-
version = "0.7.
|
|
5
|
+
version = "0.7.4"
|
|
6
6
|
edition = "2021"
|
|
7
7
|
description = "Fast HTTP client for python"
|
|
8
8
|
authors = ["thomasht86"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: httpr
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.4
|
|
4
4
|
Classifier: Programming Language :: Rust
|
|
5
5
|
Classifier: Programming Language :: Python :: 3
|
|
6
6
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
@@ -149,7 +149,11 @@ class Client:
|
|
|
149
149
|
verify (bool | None): Verify SSL certificates. Default is True.
|
|
150
150
|
ca_cert_file (str | None): Path to CA certificate store. Default is None.
|
|
151
151
|
https_only` (bool | None): Restrict the Client to be used with HTTPS only requests. Default is `false`.
|
|
152
|
-
http2_only` (bool | None):
|
|
152
|
+
http2_only` (bool | None): Speak HTTP/2 from the first byte (prior knowledge), including cleartext h2c on
|
|
153
|
+
`http://` URLs. Default is `false`, which negotiates: over TLS, HTTP/2 when the server offers it,
|
|
154
|
+
otherwise HTTP/1.1; plain `http://` uses HTTP/1.1.
|
|
155
|
+
http1_only` (bool | None): Only use HTTP/1.1, never HTTP/2. Cannot be combined with `http2_only`.
|
|
156
|
+
Default is `false`.
|
|
153
157
|
|
|
154
158
|
"""
|
|
155
159
|
```
|
|
@@ -170,7 +174,7 @@ finally:
|
|
|
170
174
|
client.close() # idempotent
|
|
171
175
|
```
|
|
172
176
|
|
|
173
|
-
Requests that are still in flight when `close()` is called (including open `stream()` responses) finish normally; the pool is released as soon as the last of them completes.
|
|
177
|
+
Requests that are still in flight when `close()` is called (including open `stream()` responses) finish normally; the pool is released as soon as the last of them completes. A request made afterwards reopens the client with a fresh pool, the way a `requests.Session` does, and emits `httpr.ClientReopenedWarning` (a `ResourceWarning`, silent by default). Run with `-W error::httpr.ClientReopenedWarning` or `warnings.simplefilter("error", httpr.ClientReopenedWarning)` to get httpx's strict use-after-close behaviour instead. `client.is_closed` tells you which state a client is in. `AsyncClient` offers the same via `aclose()` / `async with`, and additionally shuts down its own thread pool. A client that is garbage-collected without being closed releases its pool too, but only once the interpreter gets to it, so prefer closing explicitly.
|
|
174
178
|
|
|
175
179
|
#### Client methods
|
|
176
180
|
|
|
@@ -105,7 +105,11 @@ class Client:
|
|
|
105
105
|
verify (bool | None): Verify SSL certificates. Default is True.
|
|
106
106
|
ca_cert_file (str | None): Path to CA certificate store. Default is None.
|
|
107
107
|
https_only` (bool | None): Restrict the Client to be used with HTTPS only requests. Default is `false`.
|
|
108
|
-
http2_only` (bool | None):
|
|
108
|
+
http2_only` (bool | None): Speak HTTP/2 from the first byte (prior knowledge), including cleartext h2c on
|
|
109
|
+
`http://` URLs. Default is `false`, which negotiates: over TLS, HTTP/2 when the server offers it,
|
|
110
|
+
otherwise HTTP/1.1; plain `http://` uses HTTP/1.1.
|
|
111
|
+
http1_only` (bool | None): Only use HTTP/1.1, never HTTP/2. Cannot be combined with `http2_only`.
|
|
112
|
+
Default is `false`.
|
|
109
113
|
|
|
110
114
|
"""
|
|
111
115
|
```
|
|
@@ -126,7 +130,7 @@ finally:
|
|
|
126
130
|
client.close() # idempotent
|
|
127
131
|
```
|
|
128
132
|
|
|
129
|
-
Requests that are still in flight when `close()` is called (including open `stream()` responses) finish normally; the pool is released as soon as the last of them completes.
|
|
133
|
+
Requests that are still in flight when `close()` is called (including open `stream()` responses) finish normally; the pool is released as soon as the last of them completes. A request made afterwards reopens the client with a fresh pool, the way a `requests.Session` does, and emits `httpr.ClientReopenedWarning` (a `ResourceWarning`, silent by default). Run with `-W error::httpr.ClientReopenedWarning` or `warnings.simplefilter("error", httpr.ClientReopenedWarning)` to get httpx's strict use-after-close behaviour instead. `client.is_closed` tells you which state a client is in. `AsyncClient` offers the same via `aclose()` / `async with`, and additionally shuts down its own thread pool. A client that is garbage-collected without being closed releases its pool too, but only once the interpreter gets to it, so prefer closing explicitly.
|
|
130
134
|
|
|
131
135
|
#### Client methods
|
|
132
136
|
|
|
@@ -101,7 +101,7 @@ client.proxy = None
|
|
|
101
101
|
|
|
102
102
|
Every other setting from construction is carried over: `verify`, `ca_cert_file`,
|
|
103
103
|
the mTLS identity, `follow_redirects`/`max_redirects`, `https_only`, `http2_only`,
|
|
104
|
-
the current headers and the timeout. Two things to know:
|
|
104
|
+
`http1_only`, the current headers and the timeout. Two things to know:
|
|
105
105
|
|
|
106
106
|
- Assigning `None` removes the proxy; the `HTTPR_PROXY` environment variable is
|
|
107
107
|
only consulted when the client is constructed.
|
|
@@ -173,21 +173,30 @@ with client:
|
|
|
173
173
|
print(response.json())
|
|
174
174
|
```
|
|
175
175
|
|
|
176
|
-
## HTTP
|
|
176
|
+
## HTTP version
|
|
177
177
|
|
|
178
|
-
httpr
|
|
178
|
+
By default httpr negotiates the protocol: over TLS it offers both HTTP/2 and
|
|
179
|
+
HTTP/1.1 (ALPN) and uses HTTP/2 whenever the server supports it; plain `http://`
|
|
180
|
+
URLs use HTTP/1.1. `response.http_version` tells you which one was used.
|
|
179
181
|
|
|
180
182
|
```python
|
|
181
183
|
import httpr
|
|
182
184
|
|
|
183
|
-
|
|
184
|
-
client = httpr.Client(http2_only=True)
|
|
185
|
+
client = httpr.Client()
|
|
185
186
|
response = client.get("https://http2.example.com")
|
|
187
|
+
print(response.http_version) # "HTTP/2" if the server supports it, else "HTTP/1.1"
|
|
188
|
+
|
|
189
|
+
# Never use HTTP/2
|
|
190
|
+
client = httpr.Client(http1_only=True)
|
|
191
|
+
|
|
192
|
+
# Speak HTTP/2 from the first byte, also over plain http:// (h2c)
|
|
193
|
+
client = httpr.Client(http2_only=True)
|
|
186
194
|
```
|
|
187
195
|
|
|
188
196
|
!!! note
|
|
189
|
-
|
|
190
|
-
|
|
197
|
+
`http2_only=True` skips negotiation (HTTP/2 "prior knowledge"), so the server
|
|
198
|
+
must support HTTP/2; over `http://` it must accept cleartext h2c. `http1_only`
|
|
199
|
+
and `http2_only` cannot both be set.
|
|
191
200
|
|
|
192
201
|
## HTTPS Only Mode
|
|
193
202
|
|
|
@@ -280,7 +289,7 @@ The CA certificate chain is incomplete:
|
|
|
280
289
|
Protocol mismatch:
|
|
281
290
|
|
|
282
291
|
1. Server may not support modern TLS versions
|
|
283
|
-
2. Try `http2_only=
|
|
292
|
+
2. Try `http1_only=True` to rule out HTTP/2, or drop `http2_only=True` if the server does not support HTTP/2
|
|
284
293
|
|
|
285
294
|
### mTLS Errors
|
|
286
295
|
|
|
@@ -208,6 +208,24 @@ print(response.url) # https://httpbin.org/get
|
|
|
208
208
|
|
|
209
209
|
---
|
|
210
210
|
|
|
211
|
+
### http_version
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
@property
|
|
215
|
+
def http_version(self) -> str
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
The protocol the response arrived over: `"HTTP/1.1"` or `"HTTP/2"` (also
|
|
219
|
+
`"HTTP/1.0"`, `"HTTP/0.9"` or `"HTTP/3"`). Also available on streaming responses.
|
|
220
|
+
|
|
221
|
+
**Example:**
|
|
222
|
+
```python
|
|
223
|
+
response = httpr.get("https://httpbin.org/get")
|
|
224
|
+
print(response.http_version) # HTTP/2
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
211
229
|
### encoding
|
|
212
230
|
|
|
213
231
|
```python
|
|
@@ -635,7 +653,7 @@ async with httpr.AsyncClient() as client:
|
|
|
635
653
|
handle(line)
|
|
636
654
|
```
|
|
637
655
|
|
|
638
|
-
The synchronous `iter_bytes()`, `iter_text()`, `iter_lines()` and `read()` remain available on the async response but block the event loop until the next chunk arrives; prefer the `a`-prefixed methods in async code.
|
|
656
|
+
The synchronous `iter_bytes()`, `iter_text()`, `iter_lines()` and `read()` remain available on the async response but block the event loop until the next chunk arrives; prefer the `a`-prefixed methods in async code. A stream that is open when `client.aclose()` is called keeps its connection and can still be read to the end.
|
|
639
657
|
|
|
640
658
|
---
|
|
641
659
|
|
|
@@ -326,20 +326,30 @@ response = client.get("https://example.com")
|
|
|
326
326
|
# response = client.get("http://example.com")
|
|
327
327
|
```
|
|
328
328
|
|
|
329
|
-
### HTTP
|
|
329
|
+
### HTTP version
|
|
330
330
|
|
|
331
|
-
|
|
331
|
+
By default httpr negotiates the protocol: over TLS it offers both HTTP/2 and
|
|
332
|
+
HTTP/1.1 (ALPN) and uses HTTP/2 whenever the server supports it; plain `http://`
|
|
333
|
+
URLs use HTTP/1.1. `response.http_version` tells you which one was used.
|
|
332
334
|
|
|
333
335
|
```python
|
|
334
336
|
import httpr
|
|
335
337
|
|
|
336
|
-
|
|
338
|
+
client = httpr.Client()
|
|
339
|
+
response = client.get("https://http2.example.com")
|
|
340
|
+
print(response.http_version) # "HTTP/2" if the server supports it, else "HTTP/1.1"
|
|
341
|
+
|
|
342
|
+
# Never use HTTP/2
|
|
343
|
+
client = httpr.Client(http1_only=True)
|
|
344
|
+
|
|
345
|
+
# Speak HTTP/2 from the first byte, also over plain http:// (h2c)
|
|
337
346
|
client = httpr.Client(http2_only=True)
|
|
338
|
-
response = client.get("https://example.com")
|
|
339
347
|
```
|
|
340
348
|
|
|
341
349
|
!!! note
|
|
342
|
-
|
|
350
|
+
`http2_only=True` skips negotiation (HTTP/2 "prior knowledge"), so the server
|
|
351
|
+
must support HTTP/2; over `http://` it must accept cleartext h2c. `http1_only`
|
|
352
|
+
and `http2_only` cannot both be set.
|
|
343
353
|
|
|
344
354
|
## Complete Example
|
|
345
355
|
|
|
@@ -42,9 +42,9 @@ else:
|
|
|
42
42
|
|
|
43
43
|
|
|
44
44
|
from .httpr import (
|
|
45
|
-
_CLIENT_CLOSED_MSG,
|
|
46
45
|
CaseInsensitiveHeaderMap,
|
|
47
46
|
ClientClosed,
|
|
47
|
+
ClientReopenedWarning,
|
|
48
48
|
RClient,
|
|
49
49
|
Response,
|
|
50
50
|
StreamingResponse,
|
|
@@ -223,6 +223,7 @@ class Client(RClient):
|
|
|
223
223
|
client_pem_data: bytes | None = None,
|
|
224
224
|
https_only: bool | None = False,
|
|
225
225
|
http2_only: bool | None = False,
|
|
226
|
+
http1_only: bool | None = False,
|
|
226
227
|
):
|
|
227
228
|
"""
|
|
228
229
|
Initialize an HTTP client.
|
|
@@ -253,7 +254,12 @@ class Client(RClient):
|
|
|
253
254
|
client_pem_data: Client certificate and key as bytes for mTLS (PEM format).
|
|
254
255
|
Use this instead of client_pem when you have the certificate in memory.
|
|
255
256
|
https_only: Only allow HTTPS requests. Default is False.
|
|
256
|
-
http2_only:
|
|
257
|
+
http2_only: Speak HTTP/2 from the first byte (prior knowledge), including
|
|
258
|
+
cleartext h2c on `http://` URLs. Default is False, which negotiates:
|
|
259
|
+
over TLS, HTTP/2 when the server offers it (ALPN), otherwise HTTP/1.1;
|
|
260
|
+
plain `http://` uses HTTP/1.1.
|
|
261
|
+
http1_only: Only use HTTP/1.1, never HTTP/2. Default is False. Cannot be
|
|
262
|
+
combined with `http2_only`.
|
|
257
263
|
|
|
258
264
|
Example:
|
|
259
265
|
```python
|
|
@@ -308,9 +314,11 @@ class Client(RClient):
|
|
|
308
314
|
Idle pooled connections are shut down before this returns. Requests
|
|
309
315
|
that are already in flight (including open `stream()` responses) finish
|
|
310
316
|
normally and keep the pool alive until the last of them completes, at
|
|
311
|
-
which point it is released.
|
|
312
|
-
|
|
313
|
-
|
|
317
|
+
which point it is released. A request made after `close()` reopens the
|
|
318
|
+
client with a fresh connection pool and emits
|
|
319
|
+
`httpr.ClientReopenedWarning` (a `ResourceWarning`, silent by default);
|
|
320
|
+
turn it into an error with the `warnings` module to get httpx's strict
|
|
321
|
+
behaviour instead. Calling `close()` more than once is a no-op.
|
|
314
322
|
|
|
315
323
|
Example:
|
|
316
324
|
```python
|
|
@@ -684,6 +692,11 @@ class AsyncStreamingResponse:
|
|
|
684
692
|
"""Final URL after any redirects."""
|
|
685
693
|
return self._response.url
|
|
686
694
|
|
|
695
|
+
@property
|
|
696
|
+
def http_version(self) -> str:
|
|
697
|
+
"""Protocol the response arrived over, e.g. "HTTP/1.1" or "HTTP/2"."""
|
|
698
|
+
return self._response.http_version
|
|
699
|
+
|
|
687
700
|
@property
|
|
688
701
|
def is_informational(self) -> bool:
|
|
689
702
|
"""True for 1xx status codes."""
|
|
@@ -738,8 +751,8 @@ class AsyncStreamingResponse:
|
|
|
738
751
|
|
|
739
752
|
async def _aiter(self, it: Iterator[_T]) -> AsyncIterator[_T]:
|
|
740
753
|
# Each `next()` does a blocking read on the Rust side, so it goes through
|
|
741
|
-
# the client's executor like a request does.
|
|
742
|
-
#
|
|
754
|
+
# the client's executor like a request does. The stream holds its own
|
|
755
|
+
# handle to the pool, so it keeps reading after the client is closed.
|
|
743
756
|
sentinel: object = object()
|
|
744
757
|
while True:
|
|
745
758
|
item = await self._client._run_sync_asyncio(next, it, sentinel)
|
|
@@ -896,12 +909,10 @@ class AsyncClient(Client):
|
|
|
896
909
|
"""
|
|
897
910
|
super().__init__(*args, **kwargs)
|
|
898
911
|
self.max_concurrency = max_concurrency
|
|
899
|
-
#
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
else ThreadPoolExecutor(max_workers=max_concurrency, thread_name_prefix="httpr")
|
|
904
|
-
)
|
|
912
|
+
# Created on first use by `_dispatch_executor()`; `close()`/`aclose()`
|
|
913
|
+
# shut it down and drop it, and the next request creates a new one, so
|
|
914
|
+
# a client reopened after close gets its threads back as well.
|
|
915
|
+
self._executor: ThreadPoolExecutor | None = None
|
|
905
916
|
|
|
906
917
|
async def __aenter__(self) -> AsyncClient:
|
|
907
918
|
"""Enter async context manager."""
|
|
@@ -920,19 +931,21 @@ class AsyncClient(Client):
|
|
|
920
931
|
the `Client` contract too.
|
|
921
932
|
"""
|
|
922
933
|
super().close()
|
|
923
|
-
|
|
934
|
+
executor, self._executor = self._executor, None
|
|
935
|
+
if executor is not None:
|
|
924
936
|
# Requests still running on the pool keep their handle to the reqwest
|
|
925
|
-
# client and finish normally
|
|
926
|
-
#
|
|
927
|
-
|
|
937
|
+
# client and finish normally. Not waiting keeps this safe to call
|
|
938
|
+
# from the event-loop thread.
|
|
939
|
+
executor.shutdown(wait=False)
|
|
928
940
|
|
|
929
941
|
async def aclose(self) -> None:
|
|
930
942
|
"""
|
|
931
943
|
Close the async client.
|
|
932
944
|
|
|
933
945
|
Releases the connection pool and shuts down this client's thread pool.
|
|
934
|
-
|
|
935
|
-
|
|
946
|
+
A request made after `aclose()` reopens both, with a
|
|
947
|
+
`httpr.ClientReopenedWarning` (see `Client.close()`). Calling it more
|
|
948
|
+
than once is a no-op.
|
|
936
949
|
|
|
937
950
|
Example:
|
|
938
951
|
```python
|
|
@@ -948,24 +961,25 @@ class AsyncClient(Client):
|
|
|
948
961
|
# millisecond, less than a hop through the executor would cost.
|
|
949
962
|
self.close()
|
|
950
963
|
|
|
964
|
+
def _dispatch_executor(self) -> ThreadPoolExecutor | None:
|
|
965
|
+
"""This client's thread pool, created on demand; `None` means asyncio's default."""
|
|
966
|
+
if self.max_concurrency is None:
|
|
967
|
+
return None
|
|
968
|
+
if self._executor is None:
|
|
969
|
+
self._executor = ThreadPoolExecutor(max_workers=self.max_concurrency, thread_name_prefix="httpr")
|
|
970
|
+
return self._executor
|
|
971
|
+
|
|
951
972
|
async def _run_sync_asyncio(self, fn, *args, **kwargs):
|
|
952
973
|
"""Run a synchronous function on this client's executor."""
|
|
953
|
-
if self.is_closed:
|
|
954
|
-
# Checked here rather than left to the Rust side so a closed client
|
|
955
|
-
# raises ClientClosed instead of the executor's own "cannot schedule
|
|
956
|
-
# new futures after shutdown" RuntimeError.
|
|
957
|
-
raise ClientClosed(_CLIENT_CLOSED_MSG)
|
|
958
974
|
loop = asyncio.get_running_loop()
|
|
975
|
+
call = partial(fn, *args, **kwargs)
|
|
959
976
|
try:
|
|
960
|
-
future = loop.run_in_executor(self.
|
|
977
|
+
future = loop.run_in_executor(self._dispatch_executor(), call)
|
|
961
978
|
except RuntimeError:
|
|
962
|
-
#
|
|
963
|
-
#
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
if self.is_closed:
|
|
967
|
-
raise ClientClosed(_CLIENT_CLOSED_MSG) from None
|
|
968
|
-
raise
|
|
979
|
+
# A close() from another thread shut the pool down between the lookup
|
|
980
|
+
# and the submit. The client reopens on use, so does its executor.
|
|
981
|
+
self._executor = None
|
|
982
|
+
future = loop.run_in_executor(self._dispatch_executor(), call)
|
|
969
983
|
return await future
|
|
970
984
|
|
|
971
985
|
async def request( # type: ignore[override]
|
|
@@ -1463,6 +1477,7 @@ __all__ = [
|
|
|
1463
1477
|
"StreamClosed",
|
|
1464
1478
|
# Client lifecycle exceptions
|
|
1465
1479
|
"ClientClosed",
|
|
1480
|
+
"ClientReopenedWarning",
|
|
1466
1481
|
"InvalidURL",
|
|
1467
1482
|
"CookieConflict",
|
|
1468
1483
|
]
|