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.
Files changed (76) hide show
  1. {httpr-0.4.6 → httpr-0.4.8}/Cargo.lock +1 -1
  2. {httpr-0.4.6 → httpr-0.4.8}/Cargo.toml +1 -1
  3. {httpr-0.4.6 → httpr-0.4.8}/PKG-INFO +1 -1
  4. {httpr-0.4.6 → httpr-0.4.8}/pyproject.toml +1 -1
  5. {httpr-0.4.6 → httpr-0.4.8}/src/lib.rs +17 -18
  6. {httpr-0.4.6 → httpr-0.4.8}/src/utils.rs +57 -4
  7. {httpr-0.4.6 → httpr-0.4.8}/tests/unit/test_ssl.py +71 -4
  8. {httpr-0.4.6 → httpr-0.4.8}/.github/actions/set-version/action.yml +0 -0
  9. {httpr-0.4.6 → httpr-0.4.8}/.github/copilot-instructions.md +0 -0
  10. {httpr-0.4.6 → httpr-0.4.8}/.github/workflows/CI.yml +0 -0
  11. {httpr-0.4.6 → httpr-0.4.8}/.github/workflows/CI.yml.backup +0 -0
  12. {httpr-0.4.6 → httpr-0.4.8}/.github/workflows/benchmark.yml +0 -0
  13. {httpr-0.4.6 → httpr-0.4.8}/.github/workflows/copilot-setup-steps.yml +0 -0
  14. {httpr-0.4.6 → httpr-0.4.8}/.github/workflows/mkdocs.yml +0 -0
  15. {httpr-0.4.6 → httpr-0.4.8}/.github/workflows/set_version.py +0 -0
  16. {httpr-0.4.6 → httpr-0.4.8}/.gitignore +0 -0
  17. {httpr-0.4.6 → httpr-0.4.8}/.pre-commit-config.yaml +0 -0
  18. {httpr-0.4.6 → httpr-0.4.8}/CLAUDE.md +0 -0
  19. {httpr-0.4.6 → httpr-0.4.8}/LICENSE +0 -0
  20. {httpr-0.4.6 → httpr-0.4.8}/README.md +0 -0
  21. {httpr-0.4.6 → httpr-0.4.8}/Taskfile.yaml +0 -0
  22. {httpr-0.4.6 → httpr-0.4.8}/benchmark/README.md +0 -0
  23. {httpr-0.4.6 → httpr-0.4.8}/benchmark/__init__.py +0 -0
  24. {httpr-0.4.6 → httpr-0.4.8}/benchmark/benchmark.py +0 -0
  25. {httpr-0.4.6 → httpr-0.4.8}/benchmark/benchmark_cbor.py +0 -0
  26. {httpr-0.4.6 → httpr-0.4.8}/benchmark/generate_image.py +0 -0
  27. {httpr-0.4.6 → httpr-0.4.8}/benchmark/server.py +0 -0
  28. {httpr-0.4.6 → httpr-0.4.8}/benchmark.jpg +0 -0
  29. {httpr-0.4.6 → httpr-0.4.8}/docs/advanced/cookies.md +0 -0
  30. {httpr-0.4.6 → httpr-0.4.8}/docs/advanced/index.md +0 -0
  31. {httpr-0.4.6 → httpr-0.4.8}/docs/advanced/proxy.md +0 -0
  32. {httpr-0.4.6 → httpr-0.4.8}/docs/advanced/ssl-tls.md +0 -0
  33. {httpr-0.4.6 → httpr-0.4.8}/docs/api/async-client.md +0 -0
  34. {httpr-0.4.6 → httpr-0.4.8}/docs/api/client.md +0 -0
  35. {httpr-0.4.6 → httpr-0.4.8}/docs/api/functions.md +0 -0
  36. {httpr-0.4.6 → httpr-0.4.8}/docs/api/index.md +0 -0
  37. {httpr-0.4.6 → httpr-0.4.8}/docs/api/response.md +0 -0
  38. {httpr-0.4.6 → httpr-0.4.8}/docs/index.md +0 -0
  39. {httpr-0.4.6 → httpr-0.4.8}/docs/quickstart.md +0 -0
  40. {httpr-0.4.6 → httpr-0.4.8}/docs/tutorial/async.md +0 -0
  41. {httpr-0.4.6 → httpr-0.4.8}/docs/tutorial/authentication.md +0 -0
  42. {httpr-0.4.6 → httpr-0.4.8}/docs/tutorial/index.md +0 -0
  43. {httpr-0.4.6 → httpr-0.4.8}/docs/tutorial/making-requests.md +0 -0
  44. {httpr-0.4.6 → httpr-0.4.8}/docs/tutorial/response-handling.md +0 -0
  45. {httpr-0.4.6 → httpr-0.4.8}/docs/writings/index.md +0 -0
  46. {httpr-0.4.6 → httpr-0.4.8}/docs/writings/posts/2025-02-24-python-http-clients-suck.md +0 -0
  47. {httpr-0.4.6 → httpr-0.4.8}/httpr/__init__.py +0 -0
  48. {httpr-0.4.6 → httpr-0.4.8}/httpr/httpr.pyi +0 -0
  49. {httpr-0.4.6 → httpr-0.4.8}/httpr/py.typed +0 -0
  50. {httpr-0.4.6 → httpr-0.4.8}/httpr.code-workspace +0 -0
  51. {httpr-0.4.6 → httpr-0.4.8}/mkdocs.yml +0 -0
  52. {httpr-0.4.6 → httpr-0.4.8}/scripts/generate_certs.py +0 -0
  53. {httpr-0.4.6 → httpr-0.4.8}/src/exceptions.rs +0 -0
  54. {httpr-0.4.6 → httpr-0.4.8}/src/response.rs +0 -0
  55. {httpr-0.4.6 → httpr-0.4.8}/src/traits.rs +0 -0
  56. {httpr-0.4.6 → httpr-0.4.8}/tests/benchmark/__init__.py +0 -0
  57. {httpr-0.4.6 → httpr-0.4.8}/tests/benchmark/test_performance.py +0 -0
  58. {httpr-0.4.6 → httpr-0.4.8}/tests/conftest.py +0 -0
  59. {httpr-0.4.6 → httpr-0.4.8}/tests/e2e/__init__.py +0 -0
  60. {httpr-0.4.6 → httpr-0.4.8}/tests/e2e/test_async.py +0 -0
  61. {httpr-0.4.6 → httpr-0.4.8}/tests/e2e/test_auth.py +0 -0
  62. {httpr-0.4.6 → httpr-0.4.8}/tests/e2e/test_redirects.py +0 -0
  63. {httpr-0.4.6 → httpr-0.4.8}/tests/e2e/test_ssl.py +0 -0
  64. {httpr-0.4.6 → httpr-0.4.8}/tests/e2e/test_streaming.py +0 -0
  65. {httpr-0.4.6 → httpr-0.4.8}/tests/e2e/test_uploads.py +0 -0
  66. {httpr-0.4.6 → httpr-0.4.8}/tests/unit/__init__.py +0 -0
  67. {httpr-0.4.6 → httpr-0.4.8}/tests/unit/cbor_test_server.py +0 -0
  68. {httpr-0.4.6 → httpr-0.4.8}/tests/unit/httpx_conns.py +0 -0
  69. {httpr-0.4.6 → httpr-0.4.8}/tests/unit/test_asyncclient.py +0 -0
  70. {httpr-0.4.6 → httpr-0.4.8}/tests/unit/test_cbor.py +0 -0
  71. {httpr-0.4.6 → httpr-0.4.8}/tests/unit/test_client.py +0 -0
  72. {httpr-0.4.6 → httpr-0.4.8}/tests/unit/test_defs.py +0 -0
  73. {httpr-0.4.6 → httpr-0.4.8}/tests/unit/test_docs.py +0 -0
  74. {httpr-0.4.6 → httpr-0.4.8}/tests/unit/test_exceptions.py +0 -0
  75. {httpr-0.4.6 → httpr-0.4.8}/tests/unit/test_streaming.py +0 -0
  76. {httpr-0.4.6 → httpr-0.4.8}/uv.lock +0 -0
@@ -492,7 +492,7 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
492
492
 
493
493
  [[package]]
494
494
  name = "httpr"
495
- version = "0.4.6"
495
+ version = "0.4.8"
496
496
  dependencies = [
497
497
  "anyhow",
498
498
  "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.4.6"
5
+ version = "0.4.8"
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.4.6
3
+ Version: 0.4.8
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: 3 :: Only
@@ -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.6"
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
- if let Ok(certs) = load_ca_certs() {
203
- for cert in certs {
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
- while let Ok(Some(cert)) = rustls_pemfile::read_one(&mut cursor) {
33
- match cert {
34
- rustls_pemfile::Item::X509Certificate(cert) => {
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
- """Using an invalid CA certificate file should cause a handshake failure."""
136
- with Client(client_pem=self.client_cert_path, ca_cert_file="nonexistent_ca.pem") as client:
137
- with self.assertRaises(Exception):
138
- client.get(f"https://localhost:{self.server_port}")
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