httpr 0.4.6__tar.gz → 0.4.8__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.4.6 → httpr-0.4.8}/Cargo.lock +1 -1
- {httpr-0.4.6 → httpr-0.4.8}/Cargo.toml +1 -1
- {httpr-0.4.6 → httpr-0.4.8}/PKG-INFO +1 -1
- {httpr-0.4.6 → httpr-0.4.8}/pyproject.toml +1 -1
- {httpr-0.4.6 → httpr-0.4.8}/src/lib.rs +17 -18
- {httpr-0.4.6 → httpr-0.4.8}/src/utils.rs +57 -4
- {httpr-0.4.6 → httpr-0.4.8}/tests/unit/test_ssl.py +71 -4
- {httpr-0.4.6 → httpr-0.4.8}/.github/actions/set-version/action.yml +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/.github/copilot-instructions.md +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/.github/workflows/CI.yml +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/.github/workflows/CI.yml.backup +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/.github/workflows/benchmark.yml +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/.github/workflows/copilot-setup-steps.yml +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/.github/workflows/mkdocs.yml +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/.github/workflows/set_version.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/.gitignore +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/.pre-commit-config.yaml +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/CLAUDE.md +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/LICENSE +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/README.md +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/Taskfile.yaml +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/benchmark/README.md +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/benchmark/__init__.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/benchmark/benchmark.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/benchmark/benchmark_cbor.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/benchmark/generate_image.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/benchmark/server.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/benchmark.jpg +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/docs/advanced/cookies.md +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/docs/advanced/index.md +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/docs/advanced/proxy.md +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/docs/advanced/ssl-tls.md +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/docs/api/async-client.md +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/docs/api/client.md +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/docs/api/functions.md +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/docs/api/index.md +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/docs/api/response.md +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/docs/index.md +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/docs/quickstart.md +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/docs/tutorial/async.md +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/docs/tutorial/authentication.md +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/docs/tutorial/index.md +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/docs/tutorial/making-requests.md +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/docs/tutorial/response-handling.md +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/docs/writings/index.md +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/docs/writings/posts/2025-02-24-python-http-clients-suck.md +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/httpr/__init__.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/httpr/httpr.pyi +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/httpr/py.typed +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/httpr.code-workspace +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/mkdocs.yml +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/scripts/generate_certs.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/src/exceptions.rs +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/src/response.rs +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/src/traits.rs +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/tests/benchmark/__init__.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/tests/benchmark/test_performance.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/tests/conftest.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/tests/e2e/__init__.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/tests/e2e/test_async.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/tests/e2e/test_auth.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/tests/e2e/test_redirects.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/tests/e2e/test_ssl.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/tests/e2e/test_streaming.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/tests/e2e/test_uploads.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/tests/unit/__init__.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/tests/unit/cbor_test_server.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/tests/unit/httpx_conns.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/tests/unit/test_asyncclient.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/tests/unit/test_cbor.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/tests/unit/test_client.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/tests/unit/test_defs.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/tests/unit/test_docs.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/tests/unit/test_exceptions.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/tests/unit/test_streaming.py +0 -0
- {httpr-0.4.6 → httpr-0.4.8}/uv.lock +0 -0
|
@@ -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.4.
|
|
5
|
+
version = "0.4.8"
|
|
6
6
|
edition = "2021"
|
|
7
7
|
description = "Fast HTTP client for python"
|
|
8
8
|
authors = ["thomasht86"]
|
|
@@ -11,7 +11,7 @@ keywords = [ "python", "request",]
|
|
|
11
11
|
classifiers = [ "Programming Language :: Rust", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP", "Topic :: Software Development :: Libraries :: Python Modules",]
|
|
12
12
|
# Version is set dynamically by CI from git tags (e.g., v1.2.3 -> 1.2.3).
|
|
13
13
|
# Do not edit manually. See .github/actions/set-version/ for details.
|
|
14
|
-
version = "0.4.
|
|
14
|
+
version = "0.4.8"
|
|
15
15
|
dependencies = []
|
|
16
16
|
[[project.authors]]
|
|
17
17
|
name = "thomasht86"
|
|
@@ -199,29 +199,28 @@ impl RClient {
|
|
|
199
199
|
// Verify
|
|
200
200
|
if verify.unwrap_or(true) {
|
|
201
201
|
client_builder = client_builder.tls_built_in_root_certs(true);
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
client_builder = client_builder.add_root_certificate(cert);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
// Load client pem identity if provided
|
|
208
|
-
// Either from file path (client_pem) or direct data (client_pem_data)
|
|
209
|
-
let client_identity_pem = if let Some(pem_data) = &client_pem_data {
|
|
210
|
-
Some(pem_data.clone())
|
|
211
|
-
} else if let Some(pem_path) = &client_pem {
|
|
212
|
-
Some(fs::read(pem_path).map_err(|e| map_anyhow_error(anyhow::Error::new(e)))?)
|
|
213
|
-
} else {
|
|
214
|
-
None
|
|
215
|
-
};
|
|
216
|
-
|
|
217
|
-
if let Some(pem_bytes) = client_identity_pem {
|
|
218
|
-
let identity = Identity::from_pem(&pem_bytes).map_err(map_reqwest_error)?;
|
|
219
|
-
client_builder = client_builder.identity(identity);
|
|
202
|
+
for cert in load_ca_certs().map_err(map_anyhow_error)? {
|
|
203
|
+
client_builder = client_builder.add_root_certificate(cert);
|
|
220
204
|
}
|
|
221
205
|
} else {
|
|
222
206
|
client_builder = client_builder.danger_accept_invalid_certs(true);
|
|
223
207
|
}
|
|
224
208
|
|
|
209
|
+
// Client mTLS identity must be applied regardless of `verify`: disabling
|
|
210
|
+
// server verification doesn't imply disabling client authentication.
|
|
211
|
+
let client_identity_pem = if let Some(pem_data) = &client_pem_data {
|
|
212
|
+
Some(pem_data.clone())
|
|
213
|
+
} else if let Some(pem_path) = &client_pem {
|
|
214
|
+
Some(fs::read(pem_path).map_err(|e| map_anyhow_error(anyhow::Error::new(e)))?)
|
|
215
|
+
} else {
|
|
216
|
+
None
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
if let Some(pem_bytes) = client_identity_pem {
|
|
220
|
+
let identity = Identity::from_pem(&pem_bytes).map_err(map_reqwest_error)?;
|
|
221
|
+
client_builder = client_builder.identity(identity);
|
|
222
|
+
}
|
|
223
|
+
|
|
225
224
|
// Https_only
|
|
226
225
|
if let Some(true) = https_only {
|
|
227
226
|
client_builder = client_builder.https_only(true);
|
|
@@ -29,17 +29,26 @@ fn read_pem_certificates(path: &str) -> Result<Vec<Certificate>> {
|
|
|
29
29
|
let cert_bytes = fs::read(path).context("Failed to read certificate file")?;
|
|
30
30
|
let mut certificates = vec![];
|
|
31
31
|
let mut cursor = std::io::Cursor::new(cert_bytes);
|
|
32
|
-
|
|
33
|
-
match
|
|
34
|
-
|
|
32
|
+
loop {
|
|
33
|
+
match rustls_pemfile::read_one(&mut cursor)
|
|
34
|
+
.context("Failed to parse PEM data from certificate file")?
|
|
35
|
+
{
|
|
36
|
+
None => break,
|
|
37
|
+
Some(rustls_pemfile::Item::X509Certificate(cert)) => {
|
|
35
38
|
let certificate = Certificate::from_der(&cert)?;
|
|
36
39
|
certificates.push(certificate);
|
|
37
40
|
}
|
|
38
|
-
_ => {
|
|
41
|
+
Some(_) => {
|
|
39
42
|
tracing::warn!("Skipping non-certificate item");
|
|
40
43
|
}
|
|
41
44
|
}
|
|
42
45
|
}
|
|
46
|
+
if certificates.is_empty() {
|
|
47
|
+
anyhow::bail!(
|
|
48
|
+
"No X.509 certificates found in {}: refusing to silently fall back to built-in roots",
|
|
49
|
+
path
|
|
50
|
+
);
|
|
51
|
+
}
|
|
43
52
|
Ok(certificates)
|
|
44
53
|
}
|
|
45
54
|
|
|
@@ -129,16 +138,60 @@ Q29uc3VsdGF0aW9uczEiMCAGCSqGSIb3DQEJARYTcGVyc29uYWwtZW1haWwuY29t
|
|
|
129
138
|
|
|
130
139
|
// Clean up
|
|
131
140
|
fs::remove_file(ca_cert_path).unwrap();
|
|
141
|
+
env::remove_var("HTTPR_CA_BUNDLE");
|
|
132
142
|
}
|
|
133
143
|
|
|
134
144
|
#[test]
|
|
135
145
|
fn test_load_ca_certs_without_env_var() {
|
|
146
|
+
env::remove_var("HTTPR_CA_BUNDLE");
|
|
136
147
|
// Call the function
|
|
137
148
|
let result = load_ca_certs();
|
|
138
149
|
|
|
139
150
|
// Check the result
|
|
140
151
|
assert!(result.is_ok());
|
|
141
152
|
}
|
|
153
|
+
|
|
154
|
+
#[test]
|
|
155
|
+
fn test_load_ca_certs_malformed_bundle_errors() {
|
|
156
|
+
// A readable file whose contents are not a valid PEM bundle must error
|
|
157
|
+
// rather than silently returning an empty cert list (which would cause
|
|
158
|
+
// a silent fall-back to built-in roots).
|
|
159
|
+
let path = Path::new("test_ca_malformed.pem");
|
|
160
|
+
fs::write(path, b"this is not a PEM certificate at all").unwrap();
|
|
161
|
+
env::set_var("HTTPR_CA_BUNDLE", path);
|
|
162
|
+
|
|
163
|
+
let result = load_ca_certs();
|
|
164
|
+
|
|
165
|
+
fs::remove_file(path).unwrap();
|
|
166
|
+
env::remove_var("HTTPR_CA_BUNDLE");
|
|
167
|
+
|
|
168
|
+
assert!(
|
|
169
|
+
result.is_err(),
|
|
170
|
+
"expected error for malformed PEM, got {:?}",
|
|
171
|
+
result
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
#[test]
|
|
176
|
+
fn test_load_ca_certs_no_certs_errors() {
|
|
177
|
+
// A syntactically-valid PEM file containing only non-certificate items
|
|
178
|
+
// (e.g. an unrelated private key, or just comments) yields zero certs
|
|
179
|
+
// and must error rather than silently fall back to built-in roots.
|
|
180
|
+
let path = Path::new("test_ca_no_certs.pem");
|
|
181
|
+
fs::write(path, b"# only a comment, no certs here\n").unwrap();
|
|
182
|
+
env::set_var("HTTPR_CA_BUNDLE", path);
|
|
183
|
+
|
|
184
|
+
let result = load_ca_certs();
|
|
185
|
+
|
|
186
|
+
fs::remove_file(path).unwrap();
|
|
187
|
+
env::remove_var("HTTPR_CA_BUNDLE");
|
|
188
|
+
|
|
189
|
+
assert!(
|
|
190
|
+
result.is_err(),
|
|
191
|
+
"expected error for cert-less bundle, got {:?}",
|
|
192
|
+
result
|
|
193
|
+
);
|
|
194
|
+
}
|
|
142
195
|
}
|
|
143
196
|
|
|
144
197
|
#[cfg(test)]
|
|
@@ -132,10 +132,45 @@ class TestClientSSL(unittest.TestCase):
|
|
|
132
132
|
client.get(f"https://localhost:{self.server_port}")
|
|
133
133
|
|
|
134
134
|
def test_invalid_ca_cert_file(self):
|
|
135
|
-
"""
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
135
|
+
"""A non-existent ca_cert_file must fail loudly at client construction.
|
|
136
|
+
|
|
137
|
+
Previously load_ca_certs() errors were swallowed at the call site, so a
|
|
138
|
+
bad bundle silently fell back to the built-in Mozilla roots — the client
|
|
139
|
+
would then trust servers the user never intended to trust.
|
|
140
|
+
"""
|
|
141
|
+
with self.assertRaises(httpr.RequestError):
|
|
142
|
+
Client(client_pem=self.client_cert_path, ca_cert_file="nonexistent_ca.pem")
|
|
143
|
+
|
|
144
|
+
def test_malformed_ca_cert_file(self):
|
|
145
|
+
"""A readable but non-PEM ca_cert_file must also fail at construction.
|
|
146
|
+
|
|
147
|
+
The parse-error path was previously swallowed inside read_pem_certificates,
|
|
148
|
+
so a corrupt bundle silently produced zero certs and the client fell back
|
|
149
|
+
to built-in roots.
|
|
150
|
+
"""
|
|
151
|
+
with tempfile.NamedTemporaryFile(
|
|
152
|
+
suffix=".pem", delete=False, mode="wb"
|
|
153
|
+
) as f:
|
|
154
|
+
f.write(b"this is not a valid PEM bundle")
|
|
155
|
+
bad_path = f.name
|
|
156
|
+
try:
|
|
157
|
+
with self.assertRaises(httpr.RequestError):
|
|
158
|
+
Client(client_pem=self.client_cert_path, ca_cert_file=bad_path)
|
|
159
|
+
finally:
|
|
160
|
+
os.unlink(bad_path)
|
|
161
|
+
|
|
162
|
+
def test_empty_ca_cert_file(self):
|
|
163
|
+
"""A PEM file with no X.509 certs (e.g. only comments) must also fail."""
|
|
164
|
+
with tempfile.NamedTemporaryFile(
|
|
165
|
+
suffix=".pem", delete=False, mode="wb"
|
|
166
|
+
) as f:
|
|
167
|
+
f.write(b"# bundle with no certificates\n")
|
|
168
|
+
empty_path = f.name
|
|
169
|
+
try:
|
|
170
|
+
with self.assertRaises(httpr.RequestError):
|
|
171
|
+
Client(client_pem=self.client_cert_path, ca_cert_file=empty_path)
|
|
172
|
+
finally:
|
|
173
|
+
os.unlink(empty_path)
|
|
139
174
|
|
|
140
175
|
def test_verify_disabled_with_valid_client_cert(self):
|
|
141
176
|
"""
|
|
@@ -184,6 +219,38 @@ class TestClientSSL(unittest.TestCase):
|
|
|
184
219
|
ca_cert_file=self.client_ca_path,
|
|
185
220
|
)
|
|
186
221
|
|
|
222
|
+
def test_client_cert_sent_when_verify_disabled(self):
|
|
223
|
+
"""Regression for issue #65: client cert must still be presented when verify=False.
|
|
224
|
+
|
|
225
|
+
Disabling server verification should not disable client authentication.
|
|
226
|
+
"""
|
|
227
|
+
# client_pem (file path) + verify=False
|
|
228
|
+
with Client(client_pem=self.client_cert_path, verify=False) as client:
|
|
229
|
+
response = client.get(f"https://localhost:{self.server_port}")
|
|
230
|
+
self.assertEqual(response.status_code, 200)
|
|
231
|
+
self.assertEqual(response.text, "OK")
|
|
232
|
+
|
|
233
|
+
# client_pem_data (bytes) + verify=False
|
|
234
|
+
with open(self.client_cert_path, "rb") as f:
|
|
235
|
+
cert_data = f.read()
|
|
236
|
+
with Client(client_pem_data=cert_data, verify=False) as client:
|
|
237
|
+
response = client.get(f"https://localhost:{self.server_port}")
|
|
238
|
+
self.assertEqual(response.status_code, 200)
|
|
239
|
+
self.assertEqual(response.text, "OK")
|
|
240
|
+
|
|
241
|
+
def test_async_client_cert_sent_when_verify_disabled(self):
|
|
242
|
+
"""Regression for issue #65 on the AsyncClient Python wrapper."""
|
|
243
|
+
with open(self.client_cert_path, "rb") as f:
|
|
244
|
+
cert_data = f.read()
|
|
245
|
+
|
|
246
|
+
async def make_request():
|
|
247
|
+
async with AsyncClient(client_pem_data=cert_data, verify=False) as client:
|
|
248
|
+
return await client.get(f"https://localhost:{self.server_port}")
|
|
249
|
+
|
|
250
|
+
response = asyncio.run(make_request())
|
|
251
|
+
self.assertEqual(response.status_code, 200)
|
|
252
|
+
self.assertEqual(response.text, "OK")
|
|
253
|
+
|
|
187
254
|
def test_async_client_pem_and_data_are_mutually_exclusive(self):
|
|
188
255
|
"""AsyncClient should also enforce mutual exclusivity."""
|
|
189
256
|
with open(self.client_cert_path, "rb") as f:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|