httpr 0.1.11__tar.gz → 0.1.13__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.

Potentially problematic release.


This version of httpr might be problematic. Click here for more details.

Files changed (39) hide show
  1. {httpr-0.1.11 → httpr-0.1.13}/PKG-INFO +1 -1
  2. httpr-0.1.13/benchmark.jpg +0 -0
  3. {httpr-0.1.11 → httpr-0.1.13}/httpr/__init__.py +1 -1
  4. {httpr-0.1.11 → httpr-0.1.13}/pyproject.toml +1 -1
  5. {httpr-0.1.11 → httpr-0.1.13}/src/lib.rs +2 -2
  6. httpr-0.1.13/src/response.rs +199 -0
  7. {httpr-0.1.11 → httpr-0.1.13}/src/traits.rs +3 -1
  8. {httpr-0.1.11 → httpr-0.1.13}/src/utils.rs +32 -2
  9. {httpr-0.1.11 → httpr-0.1.13}/tests/test_client.py +36 -0
  10. httpr-0.1.11/benchmark.jpg +0 -0
  11. httpr-0.1.11/src/response.rs +0 -100
  12. {httpr-0.1.11 → httpr-0.1.13}/.github/workflows/CI.yml +4 -4
  13. {httpr-0.1.11 → httpr-0.1.13}/.github/workflows/mkdocs.yml +0 -0
  14. {httpr-0.1.11 → httpr-0.1.13}/.github/workflows/set_version.py +0 -0
  15. {httpr-0.1.11 → httpr-0.1.13}/.gitignore +0 -0
  16. {httpr-0.1.11 → httpr-0.1.13}/.pre-commit-config.yaml +0 -0
  17. {httpr-0.1.11 → httpr-0.1.13}/Cargo.lock +0 -0
  18. {httpr-0.1.11 → httpr-0.1.13}/Cargo.toml +0 -0
  19. {httpr-0.1.11 → httpr-0.1.13}/LICENSE +0 -0
  20. {httpr-0.1.11 → httpr-0.1.13}/README.md +0 -0
  21. {httpr-0.1.11 → httpr-0.1.13}/benchmark/README.md +0 -0
  22. {httpr-0.1.11 → httpr-0.1.13}/benchmark/benchmark.py +0 -0
  23. {httpr-0.1.11 → httpr-0.1.13}/benchmark/generate_image.py +0 -0
  24. {httpr-0.1.11 → httpr-0.1.13}/benchmark/pyproject.toml +0 -0
  25. {httpr-0.1.11 → httpr-0.1.13}/benchmark/requirements.txt +0 -0
  26. {httpr-0.1.11 → httpr-0.1.13}/benchmark/server.py +0 -0
  27. {httpr-0.1.11 → httpr-0.1.13}/docs/index.md +0 -0
  28. {httpr-0.1.11 → httpr-0.1.13}/docs/writings/index.md +0 -0
  29. {httpr-0.1.11 → httpr-0.1.13}/docs/writings/posts/2025-02-24-python-http-clients-suck.md +0 -0
  30. {httpr-0.1.11 → httpr-0.1.13}/httpr/httpr.pyi +0 -0
  31. {httpr-0.1.11 → httpr-0.1.13}/httpr/py.typed +0 -0
  32. {httpr-0.1.11 → httpr-0.1.13}/httpr.code-workspace +0 -0
  33. {httpr-0.1.11 → httpr-0.1.13}/mkdocs.yml +0 -0
  34. {httpr-0.1.11 → httpr-0.1.13}/scratch.ipynb +0 -0
  35. {httpr-0.1.11 → httpr-0.1.13}/tests/httpx_conns.py +0 -0
  36. {httpr-0.1.11 → httpr-0.1.13}/tests/test_asyncclient.py +0 -0
  37. {httpr-0.1.11 → httpr-0.1.13}/tests/test_defs.py +0 -0
  38. {httpr-0.1.11 → httpr-0.1.13}/tests/test_ssl.py +0 -0
  39. {httpr-0.1.11 → httpr-0.1.13}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: httpr
3
- Version: 0.1.11
3
+ Version: 0.1.13
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: 3 :: Only
Binary file
@@ -46,7 +46,7 @@ class Client(RClient):
46
46
  ca_cert_file: str | None = None,
47
47
  client_pem: str | None = None,
48
48
  https_only: bool | None = False,
49
- # http2_only: bool | None = False,
49
+ http2_only: bool | None = False,
50
50
  ):
51
51
  """
52
52
  Args:
@@ -10,7 +10,7 @@ keywords = [ "python", "request",]
10
10
  classifiers = [ "Programming Language :: Rust", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.8", "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",]
11
11
  dynamic = [ "version",]
12
12
  dependencies = []
13
- version = "0.1.11"
13
+ version = "0.1.13"
14
14
  [[project.authors]]
15
15
  name = "thomasht86"
16
16
 
@@ -26,7 +26,7 @@ use tokio_util::codec::{BytesCodec, FramedRead};
26
26
  use tracing;
27
27
 
28
28
  mod response;
29
- use response::Response;
29
+ use response::{CaseInsensitiveHeaderMap, Response};
30
30
 
31
31
  mod traits;
32
32
  use traits::{CookiesTraits, HeadersTraits};
@@ -428,7 +428,7 @@ impl RClient {
428
428
  content: PyBytes::new(py, &f_buf).unbind(),
429
429
  cookies: f_cookies,
430
430
  encoding: String::new(),
431
- headers: f_headers,
431
+ headers: CaseInsensitiveHeaderMap::from_indexmap(f_headers),
432
432
  status_code: f_status_code,
433
433
  url: f_url,
434
434
  })
@@ -0,0 +1,199 @@
1
+ use crate::utils::{get_encoding_from_content, get_encoding_from_case_insensitive_headers};
2
+ use anyhow::{anyhow, Result};
3
+ use encoding_rs::Encoding;
4
+ use foldhash::fast::RandomState;
5
+ use html2text::{
6
+ from_read, from_read_with_decorator,
7
+ render::{RichDecorator, TrivialDecorator},
8
+ };
9
+ use indexmap::IndexMap;
10
+ use pyo3::{prelude::*, types::PyBytes, IntoPyObject};
11
+ use pythonize::pythonize;
12
+ use serde_json::from_slice;
13
+
14
+ /// A struct representing an HTTP response.
15
+ ///
16
+ /// This struct provides methods to access various parts of an HTTP response, such as headers, cookies, status code, and the response body.
17
+ /// It also supports decoding the response body as text or JSON, with the ability to specify the character encoding.
18
+ #[pyclass]
19
+ #[derive(Clone)]
20
+ pub struct CaseInsensitiveHeaderMap {
21
+ headers: IndexMap<String, String, RandomState>,
22
+ lowercase_map: IndexMap<String, String, RandomState>,
23
+ }
24
+
25
+ #[pymethods]
26
+ impl CaseInsensitiveHeaderMap {
27
+ #[new]
28
+ fn new() -> Self {
29
+ CaseInsensitiveHeaderMap {
30
+ headers: IndexMap::with_hasher(RandomState::default()),
31
+ lowercase_map: IndexMap::with_hasher(RandomState::default()),
32
+ }
33
+ }
34
+
35
+ fn __getitem__(&self, key: String) -> PyResult<String> {
36
+ let lower_key = key.to_lowercase();
37
+ if let Some(original_key) = self.lowercase_map.get(&lower_key) {
38
+ if let Some(value) = self.headers.get(original_key) {
39
+ return Ok(value.clone());
40
+ }
41
+ }
42
+ Err(pyo3::exceptions::PyKeyError::new_err(format!("Header key '{}' not found", key)))
43
+ }
44
+
45
+ fn __contains__(&self, key: String) -> bool {
46
+ self.lowercase_map.contains_key(&key.to_lowercase())
47
+ }
48
+
49
+ fn __iter__(slf: PyRef<'_, Self>) -> PyResult<Py<PyAny>> {
50
+ let iter = slf.headers.keys().cloned().collect::<Vec<_>>();
51
+ Python::with_gil(|py| {
52
+ let iter_obj = iter.into_pyobject(py)?;
53
+ let iter_method = iter_obj.getattr("__iter__")?;
54
+ let py_iter = iter_method.call0()?;
55
+ Ok(py_iter.into())
56
+ })
57
+ }
58
+
59
+ fn items(&self) -> Vec<(String, String)> {
60
+ self.headers.clone().into_iter().collect()
61
+ }
62
+
63
+ fn keys(&self) -> Vec<String> {
64
+ self.headers.keys().cloned().collect()
65
+ }
66
+
67
+ fn values(&self) -> Vec<String> {
68
+ self.headers.values().cloned().collect()
69
+ }
70
+
71
+ #[pyo3(signature = (key, default=None))]
72
+ fn get(&self, key: String, default: Option<String>) -> String {
73
+ let lower_key = key.to_lowercase();
74
+ if let Some(original_key) = self.lowercase_map.get(&lower_key) {
75
+ if let Some(value) = self.headers.get(original_key) {
76
+ return value.clone();
77
+ }
78
+ }
79
+ default.unwrap_or_default()
80
+ }
81
+ }
82
+
83
+ impl CaseInsensitiveHeaderMap {
84
+ // Helper method to insert a header
85
+ pub fn insert(&mut self, key: String, value: String) {
86
+ let lower_key = key.to_lowercase();
87
+ self.lowercase_map.insert(lower_key, key.clone());
88
+ self.headers.insert(key, value);
89
+ }
90
+
91
+ // Helper method to build from an IndexMap
92
+ pub fn from_indexmap(map: IndexMap<String, String, RandomState>) -> Self {
93
+ let mut headers_map = CaseInsensitiveHeaderMap::new();
94
+ for (key, value) in map {
95
+ headers_map.insert(key, value);
96
+ }
97
+ headers_map
98
+ }
99
+
100
+ // Public method to check if a header exists
101
+ pub fn contains_key(&self, key: &str) -> bool {
102
+ self.lowercase_map.contains_key(&key.to_lowercase())
103
+ }
104
+
105
+ // Public method to get a header value
106
+ pub fn get_value(&self, key: &str) -> Option<String> {
107
+ let lower_key = key.to_lowercase();
108
+ if let Some(original_key) = self.lowercase_map.get(&lower_key) {
109
+ if let Some(value) = self.headers.get(original_key) {
110
+ return Some(value.clone());
111
+ }
112
+ }
113
+ None
114
+ }
115
+ }
116
+
117
+ #[pyclass]
118
+ pub struct Response {
119
+ #[pyo3(get)]
120
+ pub content: Py<PyBytes>,
121
+ #[pyo3(get)]
122
+ pub cookies: IndexMap<String, String, RandomState>,
123
+ #[pyo3(get, set)]
124
+ pub encoding: String,
125
+ #[pyo3(get)]
126
+ pub headers: CaseInsensitiveHeaderMap,
127
+ #[pyo3(get)]
128
+ pub status_code: u16,
129
+ #[pyo3(get)]
130
+ pub url: String,
131
+ }
132
+
133
+ #[pymethods]
134
+ impl Response {
135
+ #[getter]
136
+ fn get_encoding(&mut self, py: Python) -> Result<&String> {
137
+ if !self.encoding.is_empty() {
138
+ return Ok(&self.encoding);
139
+ }
140
+ self.encoding = get_encoding_from_case_insensitive_headers(&self.headers)
141
+ .or_else(|| get_encoding_from_content(self.content.as_bytes(py)))
142
+ .unwrap_or_else(|| "utf-8".to_string());
143
+ Ok(&self.encoding)
144
+ }
145
+
146
+ #[getter]
147
+ fn text(&mut self, py: Python) -> Result<String> {
148
+ // If self.encoding is empty, call get_encoding to populate self.encoding
149
+ if self.encoding.is_empty() {
150
+ self.get_encoding(py)?;
151
+ }
152
+
153
+ // Convert Py<PyBytes> to &[u8]
154
+ let raw_bytes = self.content.as_bytes(py);
155
+
156
+ // Release the GIL here because decoding can be CPU-intensive
157
+ py.allow_threads(|| {
158
+ let encoding = Encoding::for_label(self.encoding.as_bytes())
159
+ .ok_or_else(|| anyhow!("Unsupported charset: {}", self.encoding))?;
160
+ let (decoded_str, detected_encoding, _) = encoding.decode(raw_bytes);
161
+
162
+ // Update self.encoding based on the detected encoding
163
+ if self.encoding != detected_encoding.name() {
164
+ self.encoding = detected_encoding.name().to_string();
165
+ }
166
+
167
+ Ok(decoded_str.to_string())
168
+ })
169
+ }
170
+
171
+ fn json(&mut self, py: Python) -> Result<PyObject> {
172
+ let json_value: serde_json::Value = from_slice(self.content.as_bytes(py))?;
173
+ let result = pythonize(py, &json_value).unwrap().unbind();
174
+ Ok(result)
175
+ }
176
+
177
+ #[getter]
178
+ fn text_markdown(&mut self, py: Python) -> Result<String> {
179
+ let raw_bytes = self.content.bind(py).as_bytes();
180
+ let text = py.allow_threads(|| from_read(raw_bytes, 100))?;
181
+ Ok(text)
182
+ }
183
+
184
+ #[getter]
185
+ fn text_plain(&mut self, py: Python) -> Result<String> {
186
+ let raw_bytes = self.content.bind(py).as_bytes();
187
+ let text =
188
+ py.allow_threads(|| from_read_with_decorator(raw_bytes, 100, TrivialDecorator::new()))?;
189
+ Ok(text)
190
+ }
191
+
192
+ #[getter]
193
+ fn text_rich(&mut self, py: Python) -> Result<String> {
194
+ let raw_bytes = self.content.bind(py).as_bytes();
195
+ let text =
196
+ py.allow_threads(|| from_read_with_decorator(raw_bytes, 100, RichDecorator::new()))?;
197
+ Ok(text)
198
+ }
199
+ }
@@ -40,8 +40,10 @@ impl HeadersTraits for HeaderMap {
40
40
  let mut index_map =
41
41
  IndexMapSSR::with_capacity_and_hasher(self.len(), RandomState::default());
42
42
  for (key, value) in self {
43
+ // Store the original header name (preserving case)
44
+ let header_name = key.as_str().to_string();
43
45
  index_map.insert(
44
- key.as_str().to_string(),
46
+ header_name,
45
47
  value
46
48
  .to_str()
47
49
  .unwrap_or_else(|v| panic!("Invalid header value: {v:?}"))
@@ -46,8 +46,13 @@ fn read_pem_certificates(path: &str) -> Result<Vec<Certificate>> {
46
46
  }
47
47
 
48
48
  /// Get encoding from the "Content-Type" header
49
-
50
- /// Get encoding from the "Content-Type" header
49
+ ///
50
+ /// This function is deprecated; use get_encoding_from_case_insensitive_headers instead.
51
+ #[deprecated(
52
+ since = "0.1.0",
53
+ note = "use get_encoding_from_case_insensitive_headers instead"
54
+ )]
55
+ #[allow(dead_code)]
51
56
  pub fn get_encoding_from_headers(
52
57
  headers: &IndexMap<String, String, RandomState>,
53
58
  ) -> Option<String> {
@@ -72,6 +77,31 @@ pub fn get_encoding_from_headers(
72
77
  })
73
78
  }
74
79
 
80
+ /// Get encoding from the "Content-Type" header using CaseInsensitiveHeaderMap
81
+ pub fn get_encoding_from_case_insensitive_headers(
82
+ headers: &crate::response::CaseInsensitiveHeaderMap
83
+ ) -> Option<String> {
84
+ if headers.contains_key("content-type") {
85
+ let content_type = headers.get_value("content-type")?;
86
+
87
+ // Parse the Content-Type header to separate the media type and parameters
88
+ let mut parts = content_type.split(';');
89
+ let media_type = parts.next().unwrap_or("").trim();
90
+ let params = parts.next().unwrap_or("").trim();
91
+
92
+ // Check for specific conditions and return the appropriate encoding
93
+ if let Some(param) = params.to_ascii_lowercase().strip_prefix("charset=") {
94
+ Some(param.trim_matches('"').to_ascii_lowercase())
95
+ } else if media_type == "application/json" {
96
+ Some("utf-8".to_string())
97
+ } else {
98
+ None
99
+ }
100
+ } else {
101
+ None
102
+ }
103
+ }
104
+
75
105
  /// Get encoding from the `<meta charset="...">` tag within the first 2048 bytes of HTML content.
76
106
  pub fn get_encoding_from_content(raw_bytes: &[u8]) -> Option<String> {
77
107
  let start_sequence: &[u8] = b"charset=";
@@ -238,6 +238,42 @@ def test_client_post_json():
238
238
  assert json_data["args"] == {"x": "aaa", "y": "bbb", "z": "3"}
239
239
  assert json_data["json"] == data
240
240
 
241
+ @retry()
242
+ def test_client_number_params():
243
+ client = httpr.Client()
244
+ params = {
245
+ "int": 42,
246
+ "float": 3.14159,
247
+ "sci_notation": 1.23e-4,
248
+ "large_float": 1.7976931348623157e+308,
249
+ "small_float": 0.026305610314011577,
250
+ }
251
+ response = client.get("https://httpbin.org/anything", params=params)
252
+ assert response.status_code == 200
253
+ json_data = response.json()
254
+
255
+ # Verify all numeric values are converted to strings
256
+ assert json_data["args"]["int"] == "42"
257
+ assert json_data["args"]["float"] == "3.14159"
258
+ assert json_data["args"]["sci_notation"] == "0.000123" or json_data["args"]["sci_notation"] == "1.23e-4"
259
+ # Large values might have different string representations
260
+ assert float(json_data["args"]["large_float"]) == 1.7976931348623157e+308
261
+ assert float(json_data["args"]["small_float"]) == 0.026305610314011577
262
+
263
+ @retry()
264
+ def test_header_case_preservation():
265
+ client = httpr.Client()
266
+
267
+ # Send a request to a server that will return case-sensitive headers
268
+ response = client.get("https://httpbin.org/response-headers?X-Custom-Header=TestValue")
269
+
270
+ # Verify the header case is preserved
271
+ assert "X-Custom-Header" in response.headers
272
+ assert response.headers["X-Custom-Header"] == "TestValue"
273
+
274
+ # Also verify headers can be accessed with different cases
275
+ assert "x-custom-header" in response.headers
276
+ assert response.headers["x-custom-header"] == "TestValue"
241
277
 
242
278
  @pytest.fixture(scope="session")
243
279
  def test_files(tmp_path_factory):
Binary file
@@ -1,100 +0,0 @@
1
- use crate::utils::{get_encoding_from_content, get_encoding_from_headers};
2
- use anyhow::{anyhow, Result};
3
- use encoding_rs::Encoding;
4
- use foldhash::fast::RandomState;
5
- use html2text::{
6
- from_read, from_read_with_decorator,
7
- render::{RichDecorator, TrivialDecorator},
8
- };
9
- use indexmap::IndexMap;
10
- use pyo3::{prelude::*, types::PyBytes};
11
- use pythonize::pythonize;
12
- use serde_json::from_slice;
13
-
14
- /// A struct representing an HTTP response.
15
- ///
16
- /// This struct provides methods to access various parts of an HTTP response, such as headers, cookies, status code, and the response body.
17
- /// It also supports decoding the response body as text or JSON, with the ability to specify the character encoding.
18
- #[pyclass]
19
- pub struct Response {
20
- #[pyo3(get)]
21
- pub content: Py<PyBytes>,
22
- #[pyo3(get)]
23
- pub cookies: IndexMap<String, String, RandomState>,
24
- #[pyo3(get, set)]
25
- pub encoding: String,
26
- #[pyo3(get)]
27
- pub headers: IndexMap<String, String, RandomState>,
28
- #[pyo3(get)]
29
- pub status_code: u16,
30
- #[pyo3(get)]
31
- pub url: String,
32
- }
33
-
34
- #[pymethods]
35
- impl Response {
36
- #[getter]
37
- fn get_encoding(&mut self, py: Python) -> Result<&String> {
38
- if !self.encoding.is_empty() {
39
- return Ok(&self.encoding);
40
- }
41
- self.encoding = get_encoding_from_headers(&self.headers)
42
- .or_else(|| get_encoding_from_content(self.content.as_bytes(py)))
43
- .unwrap_or_else(|| "utf-8".to_string());
44
- Ok(&self.encoding)
45
- }
46
-
47
- #[getter]
48
- fn text(&mut self, py: Python) -> Result<String> {
49
- // If self.encoding is empty, call get_encoding to populate self.encoding
50
- if self.encoding.is_empty() {
51
- self.get_encoding(py)?;
52
- }
53
-
54
- // Convert Py<PyBytes> to &[u8]
55
- let raw_bytes = self.content.as_bytes(py);
56
-
57
- // Release the GIL here because decoding can be CPU-intensive
58
- py.allow_threads(|| {
59
- let encoding = Encoding::for_label(self.encoding.as_bytes())
60
- .ok_or_else(|| anyhow!("Unsupported charset: {}", self.encoding))?;
61
- let (decoded_str, detected_encoding, _) = encoding.decode(raw_bytes);
62
-
63
- // Update self.encoding based on the detected encoding
64
- if self.encoding != detected_encoding.name() {
65
- self.encoding = detected_encoding.name().to_string();
66
- }
67
-
68
- Ok(decoded_str.to_string())
69
- })
70
- }
71
-
72
- fn json(&mut self, py: Python) -> Result<PyObject> {
73
- let json_value: serde_json::Value = from_slice(self.content.as_bytes(py))?;
74
- let result = pythonize(py, &json_value).unwrap().unbind();
75
- Ok(result)
76
- }
77
-
78
- #[getter]
79
- fn text_markdown(&mut self, py: Python) -> Result<String> {
80
- let raw_bytes = self.content.bind(py).as_bytes();
81
- let text = py.allow_threads(|| from_read(raw_bytes, 100))?;
82
- Ok(text)
83
- }
84
-
85
- #[getter]
86
- fn text_plain(&mut self, py: Python) -> Result<String> {
87
- let raw_bytes = self.content.bind(py).as_bytes();
88
- let text =
89
- py.allow_threads(|| from_read_with_decorator(raw_bytes, 100, TrivialDecorator::new()))?;
90
- Ok(text)
91
- }
92
-
93
- #[getter]
94
- fn text_rich(&mut self, py: Python) -> Result<String> {
95
- let raw_bytes = self.content.bind(py).as_bytes();
96
- let text =
97
- py.allow_threads(|| from_read_with_decorator(raw_bytes, 100, RichDecorator::new()))?;
98
- Ok(text)
99
- }
100
- }
@@ -344,6 +344,10 @@ jobs:
344
344
  runs-on: ubuntu-latest
345
345
  needs: [linux]
346
346
  steps:
347
+ - name: Checkout main branch
348
+ if: ${{ startsWith(github.ref, 'refs/tags/') }}
349
+ run: |
350
+ git checkout main
347
351
  - uses: actions/checkout@v4
348
352
  with:
349
353
  fetch-depth: 0
@@ -367,10 +371,6 @@ jobs:
367
371
  run: python benchmark/benchmark.py
368
372
  - name: Generate image
369
373
  run: python benchmark/generate_image.py
370
- - name: Checkout main branch
371
- if: ${{ startsWith(github.ref, 'refs/tags/') }}
372
- run: |
373
- git checkout main
374
374
  - name: Commit generated image if changed
375
375
  uses: EndBug/add-and-commit@v9
376
376
  with:
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