httpr 0.3.3__tar.gz → 0.4.1__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 (80) hide show
  1. {httpr-0.3.3 → httpr-0.4.1}/Cargo.lock +1 -1
  2. {httpr-0.3.3 → httpr-0.4.1}/Cargo.toml +1 -1
  3. {httpr-0.3.3 → httpr-0.4.1}/PKG-INFO +1 -1
  4. {httpr-0.3.3 → httpr-0.4.1}/benchmark/benchmark_cbor.py +1 -1
  5. {httpr-0.3.3 → httpr-0.4.1}/docs/tutorial/making-requests.md +0 -39
  6. {httpr-0.3.3 → httpr-0.4.1}/docs/tutorial/response-handling.md +1 -5
  7. {httpr-0.3.3 → httpr-0.4.1}/httpr/__init__.py +43 -0
  8. {httpr-0.3.3 → httpr-0.4.1}/pyproject.toml +1 -1
  9. {httpr-0.3.3 → httpr-0.4.1}/src/lib.rs +5 -41
  10. httpr-0.4.1/tests/unit/test_cbor.py +104 -0
  11. {httpr-0.3.3 → httpr-0.4.1}/tests/unit/test_client.py +38 -0
  12. httpr-0.4.1/uv.lock +7201 -0
  13. httpr-0.3.3/.github/workflows/claude-code-review.yml +0 -57
  14. httpr-0.3.3/.github/workflows/claude.yml +0 -50
  15. httpr-0.3.3/tests/unit/test_cbor.py +0 -94
  16. httpr-0.3.3/uv.lock +0 -4605
  17. {httpr-0.3.3 → httpr-0.4.1}/.github/actions/set-version/action.yml +0 -0
  18. {httpr-0.3.3 → httpr-0.4.1}/.github/copilot-instructions.md +0 -0
  19. {httpr-0.3.3 → httpr-0.4.1}/.github/workflows/CI.yml +0 -0
  20. {httpr-0.3.3 → httpr-0.4.1}/.github/workflows/CI.yml.backup +0 -0
  21. {httpr-0.3.3 → httpr-0.4.1}/.github/workflows/benchmark.yml +0 -0
  22. {httpr-0.3.3 → httpr-0.4.1}/.github/workflows/copilot-setup-steps.yml +0 -0
  23. {httpr-0.3.3 → httpr-0.4.1}/.github/workflows/mkdocs.yml +0 -0
  24. {httpr-0.3.3 → httpr-0.4.1}/.github/workflows/set_version.py +0 -0
  25. {httpr-0.3.3 → httpr-0.4.1}/.gitignore +0 -0
  26. {httpr-0.3.3 → httpr-0.4.1}/.pre-commit-config.yaml +0 -0
  27. {httpr-0.3.3 → httpr-0.4.1}/CLAUDE.md +0 -0
  28. {httpr-0.3.3 → httpr-0.4.1}/LICENSE +0 -0
  29. {httpr-0.3.3 → httpr-0.4.1}/README.md +0 -0
  30. {httpr-0.3.3 → httpr-0.4.1}/Taskfile.yaml +0 -0
  31. {httpr-0.3.3 → httpr-0.4.1}/benchmark/README.md +0 -0
  32. {httpr-0.3.3 → httpr-0.4.1}/benchmark/__init__.py +0 -0
  33. {httpr-0.3.3 → httpr-0.4.1}/benchmark/benchmark.py +0 -0
  34. {httpr-0.3.3 → httpr-0.4.1}/benchmark/generate_image.py +0 -0
  35. {httpr-0.3.3 → httpr-0.4.1}/benchmark/server.py +0 -0
  36. {httpr-0.3.3 → httpr-0.4.1}/benchmark.jpg +0 -0
  37. {httpr-0.3.3 → httpr-0.4.1}/docs/advanced/cookies.md +0 -0
  38. {httpr-0.3.3 → httpr-0.4.1}/docs/advanced/index.md +0 -0
  39. {httpr-0.3.3 → httpr-0.4.1}/docs/advanced/proxy.md +0 -0
  40. {httpr-0.3.3 → httpr-0.4.1}/docs/advanced/ssl-tls.md +0 -0
  41. {httpr-0.3.3 → httpr-0.4.1}/docs/api/async-client.md +0 -0
  42. {httpr-0.3.3 → httpr-0.4.1}/docs/api/client.md +0 -0
  43. {httpr-0.3.3 → httpr-0.4.1}/docs/api/functions.md +0 -0
  44. {httpr-0.3.3 → httpr-0.4.1}/docs/api/index.md +0 -0
  45. {httpr-0.3.3 → httpr-0.4.1}/docs/api/response.md +0 -0
  46. {httpr-0.3.3 → httpr-0.4.1}/docs/index.md +0 -0
  47. {httpr-0.3.3 → httpr-0.4.1}/docs/quickstart.md +0 -0
  48. {httpr-0.3.3 → httpr-0.4.1}/docs/tutorial/async.md +0 -0
  49. {httpr-0.3.3 → httpr-0.4.1}/docs/tutorial/authentication.md +0 -0
  50. {httpr-0.3.3 → httpr-0.4.1}/docs/tutorial/index.md +0 -0
  51. {httpr-0.3.3 → httpr-0.4.1}/docs/writings/index.md +0 -0
  52. {httpr-0.3.3 → httpr-0.4.1}/docs/writings/posts/2025-02-24-python-http-clients-suck.md +0 -0
  53. {httpr-0.3.3 → httpr-0.4.1}/httpr/httpr.pyi +0 -0
  54. {httpr-0.3.3 → httpr-0.4.1}/httpr/py.typed +0 -0
  55. {httpr-0.3.3 → httpr-0.4.1}/httpr.code-workspace +0 -0
  56. {httpr-0.3.3 → httpr-0.4.1}/mkdocs.yml +0 -0
  57. {httpr-0.3.3 → httpr-0.4.1}/scripts/generate_certs.py +0 -0
  58. {httpr-0.3.3 → httpr-0.4.1}/src/exceptions.rs +0 -0
  59. {httpr-0.3.3 → httpr-0.4.1}/src/response.rs +0 -0
  60. {httpr-0.3.3 → httpr-0.4.1}/src/traits.rs +0 -0
  61. {httpr-0.3.3 → httpr-0.4.1}/src/utils.rs +0 -0
  62. {httpr-0.3.3 → httpr-0.4.1}/tests/benchmark/__init__.py +0 -0
  63. {httpr-0.3.3 → httpr-0.4.1}/tests/benchmark/test_performance.py +0 -0
  64. {httpr-0.3.3 → httpr-0.4.1}/tests/conftest.py +0 -0
  65. {httpr-0.3.3 → httpr-0.4.1}/tests/e2e/__init__.py +0 -0
  66. {httpr-0.3.3 → httpr-0.4.1}/tests/e2e/test_async.py +0 -0
  67. {httpr-0.3.3 → httpr-0.4.1}/tests/e2e/test_auth.py +0 -0
  68. {httpr-0.3.3 → httpr-0.4.1}/tests/e2e/test_redirects.py +0 -0
  69. {httpr-0.3.3 → httpr-0.4.1}/tests/e2e/test_ssl.py +0 -0
  70. {httpr-0.3.3 → httpr-0.4.1}/tests/e2e/test_streaming.py +0 -0
  71. {httpr-0.3.3 → httpr-0.4.1}/tests/e2e/test_uploads.py +0 -0
  72. {httpr-0.3.3 → httpr-0.4.1}/tests/unit/__init__.py +0 -0
  73. {httpr-0.3.3 → httpr-0.4.1}/tests/unit/cbor_test_server.py +0 -0
  74. {httpr-0.3.3 → httpr-0.4.1}/tests/unit/httpx_conns.py +0 -0
  75. {httpr-0.3.3 → httpr-0.4.1}/tests/unit/test_asyncclient.py +0 -0
  76. {httpr-0.3.3 → httpr-0.4.1}/tests/unit/test_defs.py +0 -0
  77. {httpr-0.3.3 → httpr-0.4.1}/tests/unit/test_docs.py +0 -0
  78. {httpr-0.3.3 → httpr-0.4.1}/tests/unit/test_exceptions.py +0 -0
  79. {httpr-0.3.3 → httpr-0.4.1}/tests/unit/test_ssl.py +0 -0
  80. {httpr-0.3.3 → httpr-0.4.1}/tests/unit/test_streaming.py +0 -0
@@ -481,7 +481,7 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
481
481
 
482
482
  [[package]]
483
483
  name = "httpr"
484
- version = "0.3.3"
484
+ version = "0.4.1"
485
485
  dependencies = [
486
486
  "anyhow",
487
487
  "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.3.3"
5
+ version = "0.4.1"
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.3.3
3
+ Version: 0.4.1
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: 3 :: Only
@@ -1,6 +1,6 @@
1
1
  """CBOR vs JSON benchmark for httpr.
2
2
 
3
- This benchmark compares JSON and CBOR serialization/deserialization
3
+ This benchmark compares JSON and CBOR response deserialization
4
4
  performance for large vector data using httpr only.
5
5
  """
6
6
 
@@ -162,45 +162,6 @@ response = httpr.post(
162
162
  print(response.json()["json"])
163
163
  ```
164
164
 
165
- ### CBOR Data (Transparent)
166
-
167
- CBOR (Concise Binary Object Representation) encoding is automatically used when you set the `Accept: application/cbor` header:
168
-
169
- ```python
170
- import httpr
171
-
172
- # Simply add Accept header - serialization happens transparently
173
- response = httpr.post(
174
- "https://api.example.com/data",
175
- json={ # Use json parameter as normal
176
- "name": "John Doe",
177
- "values": [1, 2, 3, 4, 5],
178
- "metadata": {"version": 1}
179
- },
180
- headers={"Accept": "application/cbor"} # Triggers CBOR serialization
181
- )
182
-
183
- # Content-Type is automatically set to application/cbor
184
- # Server receives CBOR-encoded data
185
- ```
186
-
187
- CBOR offers several advantages over JSON:
188
-
189
- - **Smaller size**: Binary encoding is more compact than text
190
- - **Faster processing**: No text parsing overhead
191
- - **Better for binary data**: Native support for byte arrays
192
- - **Type preservation**: Maintains exact numeric types
193
-
194
- Use CBOR when:
195
-
196
- - Working with large arrays or datasets
197
- - Building high-performance APIs
198
- - Developing IoT or embedded applications
199
- - Reducing bandwidth usage is critical
200
-
201
- !!! note
202
- The server must support CBOR requests. Check the API documentation to confirm CBOR is accepted.
203
-
204
165
  ### Form Data
205
166
 
206
167
  Send URL-encoded form data:
@@ -112,11 +112,7 @@ When the server returns `Content-Type: application/cbor`, the `json()` method au
112
112
  ```python
113
113
  import httpr
114
114
 
115
- # Request CBOR data by setting Accept header
116
- response = httpr.get(
117
- "https://api.example.com/data",
118
- headers={"Accept": "application/cbor"}
119
- )
115
+ response = httpr.get("https://api.example.com/data")
120
116
 
121
117
  # json() automatically detects and deserializes CBOR based on Content-Type
122
118
  data = response.json() # Works transparently with CBOR!
@@ -42,6 +42,40 @@ else:
42
42
 
43
43
  from .httpr import CaseInsensitiveHeaderMap, RClient, Response, StreamingResponse
44
44
 
45
+
46
+ class CaseInsensitiveDict(dict[str, str]):
47
+ """A dict subclass that provides case-insensitive key access."""
48
+
49
+ def __getitem__(self, key: str) -> str:
50
+ return super().__getitem__(key.lower())
51
+
52
+ def __setitem__(self, key: str, value: str) -> None:
53
+ super().__setitem__(key.lower(), value)
54
+
55
+ def __delitem__(self, key: str) -> None:
56
+ super().__delitem__(key.lower())
57
+
58
+ def __contains__(self, key: object) -> bool:
59
+ if not isinstance(key, str):
60
+ return False
61
+ return super().__contains__(key.lower())
62
+
63
+ def get(self, key: str, default: str | None = None) -> str | None: # type: ignore[override]
64
+ return super().get(key.lower(), default)
65
+
66
+ def pop(self, key: str, *args: str) -> str: # type: ignore[override]
67
+ return super().pop(key.lower(), *args)
68
+
69
+ def setdefault(self, key: str, default: str | None = None) -> str | None: # type: ignore[override]
70
+ return super().setdefault(key.lower(), default) # type: ignore[arg-type]
71
+
72
+ def update(self, other: dict[str, str] | None = None, **kwargs: str) -> None: # type: ignore[override]
73
+ if other is not None:
74
+ super().update({k.lower(): v for k, v in other.items()})
75
+ if kwargs:
76
+ super().update({k.lower(): v for k, v in kwargs.items()})
77
+
78
+
45
79
  if TYPE_CHECKING:
46
80
  from .httpr import ClientRequestParams, HttpMethod, RequestParams
47
81
  else:
@@ -207,6 +241,15 @@ class Client(RClient):
207
241
  """
208
242
  del self
209
243
 
244
+ @property
245
+ def headers(self) -> dict[str, str]:
246
+ """Headers configured for this client (case-insensitive access)."""
247
+ return CaseInsensitiveDict(super().headers)
248
+
249
+ @headers.setter
250
+ def headers(self, value: dict[str, str] | None) -> None:
251
+ RClient.headers.__set__(self, value) # type: ignore[attr-defined]
252
+
210
253
  def request(
211
254
  self,
212
255
  method: HttpMethod,
@@ -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.3.3"
14
+ version = "0.4.1"
15
15
  dependencies = []
16
16
  [[project.authors]]
17
17
  name = "thomasht86"
@@ -12,7 +12,7 @@ use pyo3::prelude::*;
12
12
  use pyo3::types::PyBytes;
13
13
  use pythonize::depythonize;
14
14
  use reqwest::{
15
- header::{HeaderValue, ACCEPT, CONTENT_TYPE, COOKIE},
15
+ header::{HeaderValue, COOKIE},
16
16
  multipart,
17
17
  redirect::Policy,
18
18
  Body, Identity, Method,
@@ -452,27 +452,9 @@ impl RClient {
452
452
  if let Some(form_data) = data_value {
453
453
  request_builder = request_builder.form(&form_data);
454
454
  }
455
- // Json - check if we should use CBOR based on Accept header
455
+ // Json - always serialize as JSON regardless of Accept header
456
456
  if let Some(json_data) = json_value {
457
- // Check if Accept header is set to application/cbor
458
- let use_cbor = combined_headers
459
- .get(&ACCEPT)
460
- .and_then(|v| v.to_str().ok())
461
- .map(|s| s.contains("application/cbor"))
462
- .unwrap_or(false);
463
-
464
- if use_cbor {
465
- // Serialize as CBOR
466
- let mut cbor_bytes = Vec::new();
467
- serde_cbor_2::to_writer(&mut cbor_bytes, &json_data)
468
- .map_err(|e| anyhow!("Failed to serialize CBOR: {}", e))?;
469
- request_builder = request_builder
470
- .header(CONTENT_TYPE, "application/cbor")
471
- .body(cbor_bytes);
472
- } else {
473
- // Serialize as JSON (default)
474
- request_builder = request_builder.json(&json_data);
475
- }
457
+ request_builder = request_builder.json(&json_data);
476
458
  }
477
459
  // Files
478
460
  if let Some(files) = files {
@@ -643,27 +625,9 @@ impl RClient {
643
625
  if let Some(form_data) = data_value {
644
626
  request_builder = request_builder.form(&form_data);
645
627
  }
646
- // Json - check if we should use CBOR based on Accept header
628
+ // Json - always serialize as JSON regardless of Accept header
647
629
  if let Some(json_data) = json_value {
648
- // Check if Accept header is set to application/cbor
649
- let use_cbor = combined_headers
650
- .get(&ACCEPT)
651
- .and_then(|v| v.to_str().ok())
652
- .map(|s| s.contains("application/cbor"))
653
- .unwrap_or(false);
654
-
655
- if use_cbor {
656
- // Serialize as CBOR
657
- let mut cbor_bytes = Vec::new();
658
- serde_cbor_2::to_writer(&mut cbor_bytes, &json_data)
659
- .map_err(|e| anyhow!("Failed to serialize CBOR: {}", e))?;
660
- request_builder = request_builder
661
- .header(CONTENT_TYPE, "application/cbor")
662
- .body(cbor_bytes);
663
- } else {
664
- // Serialize as JSON (default)
665
- request_builder = request_builder.json(&json_data);
666
- }
630
+ request_builder = request_builder.json(&json_data);
667
631
  }
668
632
  // Files
669
633
  if let Some(files) = files {
@@ -0,0 +1,104 @@
1
+ """Tests for CBOR response deserialization support."""
2
+
3
+ import threading
4
+ from http.server import BaseHTTPRequestHandler, HTTPServer
5
+
6
+ import cbor2
7
+ import pytest
8
+
9
+ import httpr
10
+
11
+
12
+ class CborHandler(BaseHTTPRequestHandler):
13
+ """Serves CBOR responses for testing."""
14
+
15
+ def do_GET(self):
16
+ if self.path == "/cbor/echo":
17
+ data = {"message": "CBOR response", "count": 42, "items": [1, 2, 3, 4, 5]}
18
+ elif self.path == "/cbor/large":
19
+ data = [[i + j * 0.1 for j in range(1024)] for i in range(10)]
20
+ else:
21
+ self.send_error(404)
22
+ return
23
+ body = cbor2.dumps(data)
24
+ self.send_response(200)
25
+ self.send_header("Content-Type", "application/cbor")
26
+ self.send_header("Content-Length", str(len(body)))
27
+ self.end_headers()
28
+ self.wfile.write(body)
29
+
30
+ def log_message(self, format, *args):
31
+ pass # Suppress logs during tests
32
+
33
+
34
+ @pytest.fixture(scope="module")
35
+ def cbor_server():
36
+ """Start a simple CBOR test server on a random free port."""
37
+ server = HTTPServer(("127.0.0.1", 0), CborHandler)
38
+ port = server.server_address[1]
39
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
40
+ thread.start()
41
+ yield f"http://127.0.0.1:{port}"
42
+ server.shutdown()
43
+
44
+
45
+ def test_json_serialization_default(base_url_ssl, ca_bundle):
46
+ """Test that JSON is used by default when Accept header is not set."""
47
+ client = httpr.Client(ca_cert_file=ca_bundle)
48
+
49
+ test_data = {"test": "data"}
50
+
51
+ # Send without Accept header - should use JSON (default)
52
+ response = client.post(
53
+ f"{base_url_ssl}/anything",
54
+ json=test_data,
55
+ )
56
+
57
+ assert response.status_code == 200
58
+ json_data = response.json()
59
+
60
+ # Should use JSON by default
61
+ assert json_data["headers"]["Content-Type"] == "application/json"
62
+ assert json_data["json"] == test_data
63
+
64
+
65
+ def test_cbor_response_json_auto_detect(cbor_server):
66
+ """Test that response.json() auto-detects CBOR from Content-Type."""
67
+ client = httpr.Client()
68
+ response = client.get(f"{cbor_server}/cbor/echo")
69
+
70
+ assert response.status_code == 200
71
+ assert "application/cbor" in response.headers["content-type"]
72
+
73
+ # json() should transparently deserialize CBOR
74
+ data = response.json()
75
+ assert data["message"] == "CBOR response"
76
+ assert data["count"] == 42
77
+ assert data["items"] == [1, 2, 3, 4, 5]
78
+
79
+
80
+ def test_cbor_response_explicit(cbor_server):
81
+ """Test that response.cbor() explicitly deserializes CBOR."""
82
+ client = httpr.Client()
83
+ response = client.get(f"{cbor_server}/cbor/echo")
84
+
85
+ assert response.status_code == 200
86
+
87
+ # cbor() should explicitly deserialize
88
+ data = response.cbor()
89
+ assert data["message"] == "CBOR response"
90
+ assert data["count"] == 42
91
+ assert data["items"] == [1, 2, 3, 4, 5]
92
+
93
+
94
+ def test_cbor_response_large_data(cbor_server):
95
+ """Test CBOR deserialization with large dataset."""
96
+ client = httpr.Client()
97
+ response = client.get(f"{cbor_server}/cbor/large")
98
+
99
+ assert response.status_code == 200
100
+
101
+ data = response.json()
102
+ assert isinstance(data, list)
103
+ assert len(data) == 10
104
+ assert len(data[0]) == 1024
@@ -76,6 +76,7 @@ def test_client_setters(base_url_ssl, ca_bundle):
76
76
  assert response.status_code == 200
77
77
  assert client.auth == ("user", "password")
78
78
  assert client.headers == {"x-test": "TesT"} # Headers are lowercased (necessary for HTTP/2)
79
+ assert client.headers["X-Test"] == "TesT" # but still accessible case-insensitively
79
80
  assert client.cookies == {"ccc": "ddd", "cccc": "dddd"}
80
81
  assert client.params == {"x": "aaa", "y": "bbb"}
81
82
  assert client.timeout == 20.0
@@ -320,6 +321,43 @@ def test_setter_overwrites_constructor_headers():
320
321
  client.close()
321
322
 
322
323
 
324
+ def test_client_headers_case_insensitive():
325
+ """Test that client.headers supports case-insensitive access."""
326
+ client = httpr.Client(headers={"X-Custom": "value", "Content-Type": "application/json"})
327
+
328
+ # Case-insensitive access should work
329
+ assert client.headers["x-custom"] == "value"
330
+ assert client.headers["X-Custom"] == "value"
331
+ assert client.headers["X-CUSTOM"] == "value"
332
+
333
+ # Contains check should be case-insensitive
334
+ assert "x-custom" in client.headers
335
+ assert "X-Custom" in client.headers
336
+
337
+ # get() should be case-insensitive
338
+ assert client.headers.get("X-Custom") == "value"
339
+ assert client.headers.get("x-custom") == "value"
340
+
341
+ # Dict equality should still work (keys are lowercased)
342
+ assert client.headers == {"x-custom": "value", "content-type": "application/json"}
343
+
344
+ # Test mutating methods on the returned dict (doesn't affect client, but should work)
345
+ headers = client.headers
346
+ headers["X-New"] = "new-value"
347
+ assert headers["x-new"] == "new-value"
348
+
349
+ del headers["X-New"]
350
+ assert "x-new" not in headers
351
+
352
+ headers["X-Pop"] = "pop-value"
353
+ assert headers.pop("X-POP") == "pop-value"
354
+
355
+ assert headers.setdefault("X-Default", "default") == "default"
356
+ assert headers["x-default"] == "default"
357
+
358
+ client.close()
359
+
360
+
323
361
  def test_graceful_invalid_header_handling(base_url_ssl, ca_bundle):
324
362
  """Test that invalid header values are handled gracefully without crashing."""
325
363
  client = httpr.Client(ca_cert_file=ca_bundle)