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.
Files changed (93) hide show
  1. {httpr-0.7.1 → httpr-0.7.4}/.github/workflows/CI.yml +75 -13
  2. {httpr-0.7.1 → httpr-0.7.4}/CLAUDE.md +11 -4
  3. {httpr-0.7.1 → httpr-0.7.4}/Cargo.lock +1 -1
  4. {httpr-0.7.1 → httpr-0.7.4}/Cargo.toml +1 -1
  5. {httpr-0.7.1 → httpr-0.7.4}/PKG-INFO +7 -3
  6. {httpr-0.7.1 → httpr-0.7.4}/README.md +6 -2
  7. {httpr-0.7.1 → httpr-0.7.4}/docs/advanced/proxy.md +1 -1
  8. {httpr-0.7.1 → httpr-0.7.4}/docs/advanced/ssl-tls.md +16 -7
  9. {httpr-0.7.1 → httpr-0.7.4}/docs/api/index.md +1 -0
  10. {httpr-0.7.1 → httpr-0.7.4}/docs/api/response.md +19 -1
  11. {httpr-0.7.1 → httpr-0.7.4}/docs/tutorial/making-requests.md +15 -5
  12. {httpr-0.7.1 → httpr-0.7.4}/httpr/__init__.py +47 -32
  13. {httpr-0.7.1 → httpr-0.7.4}/httpr/httpr.pyi +30 -4
  14. {httpr-0.7.1 → httpr-0.7.4}/pyproject.toml +1 -1
  15. {httpr-0.7.1 → httpr-0.7.4}/src/exceptions.rs +12 -2
  16. {httpr-0.7.1 → httpr-0.7.4}/src/lib.rs +90 -72
  17. {httpr-0.7.1 → httpr-0.7.4}/src/lifecycle.rs +38 -7
  18. {httpr-0.7.1 → httpr-0.7.4}/src/response.rs +72 -8
  19. httpr-0.7.4/tests/e2e/test_http_version.py +48 -0
  20. {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_asyncclient.py +13 -14
  21. {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_close.py +92 -33
  22. httpr-0.7.4/tests/unit/test_http_version.py +48 -0
  23. {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_proxy.py +6 -4
  24. {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_streaming.py +5 -5
  25. {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_timeout.py +13 -11
  26. {httpr-0.7.1 → httpr-0.7.4}/.github/actions/set-version/action.yml +0 -0
  27. {httpr-0.7.1 → httpr-0.7.4}/.github/copilot-instructions.md +0 -0
  28. {httpr-0.7.1 → httpr-0.7.4}/.github/workflows/benchmark.yml +0 -0
  29. {httpr-0.7.1 → httpr-0.7.4}/.github/workflows/codspeed.yml +0 -0
  30. {httpr-0.7.1 → httpr-0.7.4}/.github/workflows/compare.yml +0 -0
  31. {httpr-0.7.1 → httpr-0.7.4}/.github/workflows/copilot-setup-steps.yml +0 -0
  32. {httpr-0.7.1 → httpr-0.7.4}/.github/workflows/mkdocs.yml +0 -0
  33. {httpr-0.7.1 → httpr-0.7.4}/.github/workflows/set_version.py +0 -0
  34. {httpr-0.7.1 → httpr-0.7.4}/.gitignore +0 -0
  35. {httpr-0.7.1 → httpr-0.7.4}/.pre-commit-config.yaml +0 -0
  36. {httpr-0.7.1 → httpr-0.7.4}/LICENSE +0 -0
  37. {httpr-0.7.1 → httpr-0.7.4}/Taskfile.yaml +0 -0
  38. {httpr-0.7.1 → httpr-0.7.4}/benchmark/README.md +0 -0
  39. {httpr-0.7.1 → httpr-0.7.4}/benchmark/__init__.py +0 -0
  40. {httpr-0.7.1 → httpr-0.7.4}/benchmark/benchmark.py +0 -0
  41. {httpr-0.7.1 → httpr-0.7.4}/benchmark/benchmark_cbor.py +0 -0
  42. {httpr-0.7.1 → httpr-0.7.4}/benchmark/render_comparison.py +0 -0
  43. {httpr-0.7.1 → httpr-0.7.4}/benchmark/server.py +0 -0
  44. {httpr-0.7.1 → httpr-0.7.4}/docs/advanced/cookies.md +0 -0
  45. {httpr-0.7.1 → httpr-0.7.4}/docs/advanced/index.md +0 -0
  46. {httpr-0.7.1 → httpr-0.7.4}/docs/api/async-client.md +0 -0
  47. {httpr-0.7.1 → httpr-0.7.4}/docs/api/client.md +0 -0
  48. {httpr-0.7.1 → httpr-0.7.4}/docs/api/functions.md +0 -0
  49. {httpr-0.7.1 → httpr-0.7.4}/docs/benchmark.md +0 -0
  50. {httpr-0.7.1 → httpr-0.7.4}/docs/index.md +0 -0
  51. {httpr-0.7.1 → httpr-0.7.4}/docs/quickstart.md +0 -0
  52. {httpr-0.7.1 → httpr-0.7.4}/docs/tutorial/async.md +0 -0
  53. {httpr-0.7.1 → httpr-0.7.4}/docs/tutorial/authentication.md +0 -0
  54. {httpr-0.7.1 → httpr-0.7.4}/docs/tutorial/index.md +0 -0
  55. {httpr-0.7.1 → httpr-0.7.4}/docs/tutorial/response-handling.md +0 -0
  56. {httpr-0.7.1 → httpr-0.7.4}/docs/writings/index.md +0 -0
  57. {httpr-0.7.1 → httpr-0.7.4}/docs/writings/posts/2025-02-24-python-http-clients-suck.md +0 -0
  58. {httpr-0.7.1 → httpr-0.7.4}/httpr/py.typed +0 -0
  59. {httpr-0.7.1 → httpr-0.7.4}/mkdocs.yml +0 -0
  60. {httpr-0.7.1 → httpr-0.7.4}/scripts/generate_certs.py +0 -0
  61. {httpr-0.7.1 → httpr-0.7.4}/src/params.rs +0 -0
  62. {httpr-0.7.1 → httpr-0.7.4}/src/timeout.rs +0 -0
  63. {httpr-0.7.1 → httpr-0.7.4}/src/traits.rs +0 -0
  64. {httpr-0.7.1 → httpr-0.7.4}/src/utils.rs +0 -0
  65. {httpr-0.7.1 → httpr-0.7.4}/tests/benchmark/__init__.py +0 -0
  66. {httpr-0.7.1 → httpr-0.7.4}/tests/benchmark/codspeed/README.md +0 -0
  67. {httpr-0.7.1 → httpr-0.7.4}/tests/benchmark/codspeed/__init__.py +0 -0
  68. {httpr-0.7.1 → httpr-0.7.4}/tests/benchmark/codspeed/bench_server.py +0 -0
  69. {httpr-0.7.1 → httpr-0.7.4}/tests/benchmark/codspeed/conftest.py +0 -0
  70. {httpr-0.7.1 → httpr-0.7.4}/tests/benchmark/codspeed/test_decoding.py +0 -0
  71. {httpr-0.7.1 → httpr-0.7.4}/tests/benchmark/codspeed/test_transport.py +0 -0
  72. {httpr-0.7.1 → httpr-0.7.4}/tests/benchmark/test_performance.py +0 -0
  73. {httpr-0.7.1 → httpr-0.7.4}/tests/conftest.py +0 -0
  74. {httpr-0.7.1 → httpr-0.7.4}/tests/e2e/__init__.py +0 -0
  75. {httpr-0.7.1 → httpr-0.7.4}/tests/e2e/test_async.py +0 -0
  76. {httpr-0.7.1 → httpr-0.7.4}/tests/e2e/test_auth.py +0 -0
  77. {httpr-0.7.1 → httpr-0.7.4}/tests/e2e/test_redirects.py +0 -0
  78. {httpr-0.7.1 → httpr-0.7.4}/tests/e2e/test_ssl.py +0 -0
  79. {httpr-0.7.1 → httpr-0.7.4}/tests/e2e/test_streaming.py +0 -0
  80. {httpr-0.7.1 → httpr-0.7.4}/tests/e2e/test_uploads.py +0 -0
  81. {httpr-0.7.1 → httpr-0.7.4}/tests/unit/__init__.py +0 -0
  82. {httpr-0.7.1 → httpr-0.7.4}/tests/unit/cbor_test_server.py +0 -0
  83. {httpr-0.7.1 → httpr-0.7.4}/tests/unit/httpx_conns.py +0 -0
  84. {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_ca_bundle.py +0 -0
  85. {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_cbor.py +0 -0
  86. {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_client.py +0 -0
  87. {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_defs.py +0 -0
  88. {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_docs.py +0 -0
  89. {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_exceptions.py +0 -0
  90. {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_params.py +0 -0
  91. {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_response.py +0 -0
  92. {httpr-0.7.1 → httpr-0.7.4}/tests/unit/test_ssl.py +0 -0
  93. {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: x86
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: auto
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: auto
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.target }}
242
+ name: wheels-linux-${{ matrix.platform.arch }}
184
243
  path: dist
185
244
  - name: pytest
186
- if: ${{ matrix.platform.target == 'x86_64' || matrix.platform.target == 'aarch64' }}
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: x86
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.target }}
300
+ name: wheels-musllinux-${{ matrix.platform.arch }}
239
301
  path: dist
240
302
  - name: pytest
241
- if: ${{ matrix.platform.target == 'x86_64' || matrix.platform.target == 'aarch64' }}
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 raises `ClientClosed`, a `RuntimeError` subclass (httpx raises plain `RuntimeError` here); header/cookie getters keep working. `is_closed` getter mirrors httpx. The message lives once in Rust (`CLIENT_CLOSED_MSG`, exported as `_CLIENT_CLOSED_MSG`)
198
- - Python: `Client.close()`/`__exit__` call the Rust `close()`; `AsyncClient.aclose()`/`__aexit__` additionally `shutdown(wait=False)` the client's own `ThreadPoolExecutor`, and run on the event-loop thread on purpose (sub-millisecond, no I/O wait). `_run_sync_asyncio` checks `is_closed` first and also maps the executor's "cannot schedule new futures after shutdown" `RuntimeError` to `ClientClosed`, which covers a `close()` racing in from another OS thread
199
- - Leaving a `with`/`async with` block closes the client; re-entering it afterwards raises `ClientClosed` on the next request. pyvespa's `VespaSync`/`VespaAsync` given an external session never close it and are unaffected; with an owned client they close but never null it, so re-entering the same wrapper object raises `ClientClosed` (see memory note; needs a pyvespa-side fix)
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
@@ -516,7 +516,7 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
516
516
 
517
517
  [[package]]
518
518
  name = "httpr"
519
- version = "0.7.1"
519
+ version = "0.7.4"
520
520
  dependencies = [
521
521
  "anyhow",
522
522
  "bytes",
@@ -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.1"
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.1
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): If true - use only HTTP/2; if false - use only HTTP/1. Default is `false`.
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. Any request made afterwards, or assigning `client.proxy`, raises `httpr.ClientClosed` (a `RuntimeError`, as in httpx); `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.
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): If true - use only HTTP/2; if false - use only HTTP/1. Default is `false`.
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. Any request made afterwards, or assigning `client.proxy`, raises `httpr.ClientClosed` (a `RuntimeError`, as in httpx); `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.
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/2
176
+ ## HTTP version
177
177
 
178
- httpr supports HTTP/2 over TLS:
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
- # HTTP/2 only mode
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
- HTTP/2 requires TLS. The `http2_only` option forces HTTP/2 protocol.
190
- When `http2_only=False` (default), httpr uses HTTP/1.1.
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=False` if using HTTP/2
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
 
@@ -48,6 +48,7 @@ client = httpr.Client(
48
48
  # Protocol
49
49
  https_only=False,
50
50
  http2_only=False,
51
+ http1_only=False,
51
52
  )
52
53
  ```
53
54
 
@@ -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. After `client.aclose()`, the next async step raises `httpr.ClientClosed`.
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/2
329
+ ### HTTP version
330
330
 
331
- Enable HTTP/2 only mode:
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
- # Use only HTTP/2
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
- When `http2_only=False` (default), httpr uses HTTP/1.1. Set to `True` for HTTP/2.
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: Use HTTP/2 only (False uses HTTP/1.1). Default is False.
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. Any request made after `close()` raises
312
- `httpr.ClientClosed` (a `RuntimeError`, as in httpx). Calling `close()`
313
- more than once is a no-op.
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. `_run_sync_asyncio` maps a
742
- # closed client to ClientClosed.
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
- # Threads are created on demand; `close()`/`aclose()` shut the pool down.
900
- self._executor = (
901
- None
902
- if max_concurrency is None
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
- if self._executor is not None:
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; queued ones raise ClientClosed when they
926
- # run. Not waiting keeps this safe to call from the event-loop thread.
927
- self._executor.shutdown(wait=False)
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
- Any request made after `aclose()` raises `httpr.ClientClosed`. Calling it
935
- more than once is a no-op.
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._executor, partial(fn, *args, **kwargs))
977
+ future = loop.run_in_executor(self._dispatch_executor(), call)
961
978
  except RuntimeError:
962
- # The executor is only ever shut down by close()/aclose(), so if one
963
- # landed between the check above and submit (from another thread),
964
- # report it as the client being closed rather than leaking the
965
- # executor's own error.
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
  ]