httpr 0.1.0__tar.gz → 0.1.12__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 (41) hide show
  1. {httpr-0.1.0 → httpr-0.1.12}/.github/workflows/CI.yml +63 -2
  2. {httpr-0.1.0 → httpr-0.1.12}/.github/workflows/mkdocs.yml +1 -1
  3. httpr-0.1.12/.github/workflows/set_version.py +56 -0
  4. {httpr-0.1.0 → httpr-0.1.12}/.gitignore +3 -1
  5. {httpr-0.1.0 → httpr-0.1.12}/PKG-INFO +17 -11
  6. {httpr-0.1.0 → httpr-0.1.12}/README.md +15 -9
  7. httpr-0.1.12/benchmark.jpg +0 -0
  8. httpr-0.1.12/docs/writings/index.md +1 -0
  9. httpr-0.1.12/docs/writings/posts/2025-02-24-python-http-clients-suck.md +9 -0
  10. {httpr-0.1.0 → httpr-0.1.12}/httpr/__init__.py +14 -0
  11. httpr-0.1.12/mkdocs.yml +41 -0
  12. httpr-0.1.12/pyproject.toml +49 -0
  13. {httpr-0.1.0 → httpr-0.1.12}/src/lib.rs +3 -2
  14. httpr-0.1.12/src/response.rs +199 -0
  15. {httpr-0.1.0 → httpr-0.1.12}/src/traits.rs +3 -1
  16. {httpr-0.1.0 → httpr-0.1.12}/src/utils.rs +32 -2
  17. {httpr-0.1.0 → httpr-0.1.12}/tests/test_client.py +38 -2
  18. {httpr-0.1.0 → httpr-0.1.12}/uv.lock +104 -65
  19. httpr-0.1.0/benchmark.jpg +0 -0
  20. httpr-0.1.0/mkdocs.yml +0 -4
  21. httpr-0.1.0/pyproject.toml +0 -87
  22. httpr-0.1.0/src/response.rs +0 -100
  23. {httpr-0.1.0 → httpr-0.1.12}/.pre-commit-config.yaml +0 -0
  24. {httpr-0.1.0 → httpr-0.1.12}/Cargo.lock +0 -0
  25. {httpr-0.1.0 → httpr-0.1.12}/Cargo.toml +0 -0
  26. {httpr-0.1.0 → httpr-0.1.12}/LICENSE +0 -0
  27. {httpr-0.1.0 → httpr-0.1.12}/benchmark/README.md +0 -0
  28. {httpr-0.1.0 → httpr-0.1.12}/benchmark/benchmark.py +0 -0
  29. {httpr-0.1.0 → httpr-0.1.12}/benchmark/generate_image.py +0 -0
  30. {httpr-0.1.0 → httpr-0.1.12}/benchmark/pyproject.toml +0 -0
  31. {httpr-0.1.0 → httpr-0.1.12}/benchmark/requirements.txt +0 -0
  32. {httpr-0.1.0 → httpr-0.1.12}/benchmark/server.py +0 -0
  33. {httpr-0.1.0 → httpr-0.1.12}/docs/index.md +0 -0
  34. {httpr-0.1.0 → httpr-0.1.12}/httpr/httpr.pyi +0 -0
  35. {httpr-0.1.0 → httpr-0.1.12}/httpr/py.typed +0 -0
  36. {httpr-0.1.0 → httpr-0.1.12}/httpr.code-workspace +0 -0
  37. {httpr-0.1.0 → httpr-0.1.12}/scratch.ipynb +0 -0
  38. {httpr-0.1.0 → httpr-0.1.12}/tests/httpx_conns.py +0 -0
  39. {httpr-0.1.0 → httpr-0.1.12}/tests/test_asyncclient.py +0 -0
  40. {httpr-0.1.0 → httpr-0.1.12}/tests/test_defs.py +0 -0
  41. {httpr-0.1.0 → httpr-0.1.12}/tests/test_ssl.py +0 -0
@@ -19,6 +19,20 @@ permissions:
19
19
  contents: read
20
20
 
21
21
  jobs:
22
+ extract-version:
23
+ runs-on: ubuntu-latest
24
+ if: ${{ startsWith(github.ref, 'refs/tags/') }}
25
+ outputs:
26
+ version: ${{ steps.get_version.outputs.version }}
27
+ steps:
28
+ - name: Extract version from tag
29
+ id: get_version
30
+ run: |
31
+ # Extract version from tag (e.g., v0.1.0 -> 0.1.0)
32
+ TAG_VERSION=${GITHUB_REF#refs/tags/v}
33
+ echo "version=$TAG_VERSION" >> $GITHUB_OUTPUT
34
+ echo "Version extracted: $TAG_VERSION"
35
+
22
36
  linux:
23
37
  runs-on: ${{ matrix.platform.runner }}
24
38
  strategy:
@@ -47,6 +61,13 @@ jobs:
47
61
  - uses: actions/setup-python@v5
48
62
  with:
49
63
  python-version: 3.x
64
+ # Only modify pyproject.toml temporarily for this job if running from a tag
65
+ - name: Update version in pyproject.toml
66
+ if: ${{ startsWith(github.ref, 'refs/tags/') }}
67
+ run: |
68
+ TAG_VERSION=${GITHUB_REF#refs/tags/v}
69
+ pip install toml
70
+ python .github/workflows/set_version.py $TAG_VERSION
50
71
  - name: Build wheels
51
72
  uses: PyO3/maturin-action@v1
52
73
  with:
@@ -103,13 +124,20 @@ jobs:
103
124
  target: x86
104
125
  - runner: ubuntu-22.04
105
126
  target: aarch64
106
- - runner: ubuntu-22.04
107
- target: armv7
127
+ # - runner: ubuntu-22.04
128
+ # target: armv7
108
129
  steps:
109
130
  - uses: actions/checkout@v4
110
131
  - uses: actions/setup-python@v5
111
132
  with:
112
133
  python-version: 3.x
134
+ # Only modify pyproject.toml temporarily for this job if running from a tag
135
+ - name: Update version in pyproject.toml
136
+ if: ${{ startsWith(github.ref, 'refs/tags/') }}
137
+ run: |
138
+ TAG_VERSION=${GITHUB_REF#refs/tags/v}
139
+ pip install toml
140
+ python .github/workflows/set_version.py $TAG_VERSION
113
141
  - name: Build wheels
114
142
  uses: PyO3/maturin-action@v1
115
143
  with:
@@ -175,6 +203,14 @@ jobs:
175
203
  with:
176
204
  python-version: 3.x
177
205
  architecture: ${{ matrix.platform.target }}
206
+ # Only modify pyproject.toml temporarily for this job if running from a tag
207
+ - name: Update version in pyproject.toml
208
+ if: ${{ startsWith(github.ref, 'refs/tags/') }}
209
+ shell: pwsh
210
+ run: |
211
+ $TAG_VERSION = $env:GITHUB_REF -replace 'refs/tags/v', ''
212
+ pip install toml
213
+ python .github/workflows/set_version.py $TAG_VERSION
178
214
  - name: Build wheels
179
215
  uses: PyO3/maturin-action@v1
180
216
  with:
@@ -216,6 +252,13 @@ jobs:
216
252
  - uses: actions/setup-python@v5
217
253
  with:
218
254
  python-version: 3.x
255
+ # Only modify pyproject.toml temporarily for this job if running from a tag
256
+ - name: Update version in pyproject.toml
257
+ if: ${{ startsWith(github.ref, 'refs/tags/') }}
258
+ run: |
259
+ TAG_VERSION=${GITHUB_REF#refs/tags/v}
260
+ pip install toml
261
+ python .github/workflows/set_version.py $TAG_VERSION
219
262
  - name: Build wheels
220
263
  uses: PyO3/maturin-action@v1
221
264
  with:
@@ -246,6 +289,16 @@ jobs:
246
289
  runs-on: ubuntu-latest
247
290
  steps:
248
291
  - uses: actions/checkout@v4
292
+ - uses: actions/setup-python@v5
293
+ with:
294
+ python-version: 3.x
295
+ # Only modify pyproject.toml temporarily for this job if running from a tag
296
+ - name: Update version in pyproject.toml
297
+ if: ${{ startsWith(github.ref, 'refs/tags/') }}
298
+ run: |
299
+ TAG_VERSION=${GITHUB_REF#refs/tags/v}
300
+ pip install toml
301
+ python .github/workflows/set_version.py $TAG_VERSION
249
302
  - name: Build sdist
250
303
  uses: PyO3/maturin-action@v1
251
304
  with:
@@ -292,6 +345,8 @@ jobs:
292
345
  needs: [linux]
293
346
  steps:
294
347
  - uses: actions/checkout@v4
348
+ with:
349
+ fetch-depth: 0
295
350
  - name: Set up Python
296
351
  uses: actions/setup-python@v5
297
352
  with:
@@ -312,8 +367,14 @@ jobs:
312
367
  run: python benchmark/benchmark.py
313
368
  - name: Generate image
314
369
  run: python benchmark/generate_image.py
370
+ - name: Checkout main branch
371
+ if: ${{ startsWith(github.ref, 'refs/tags/') }}
372
+ run: |
373
+ git checkout main
315
374
  - name: Commit generated image if changed
316
375
  uses: EndBug/add-and-commit@v9
317
376
  with:
318
377
  message: "Update generated image"
319
378
  add: "*.jpg"
379
+ default_author: github_actions
380
+ push: true
@@ -25,5 +25,5 @@ jobs:
25
25
  path: .cache
26
26
  restore-keys: |
27
27
  mkdocs-material-
28
- - run: pip install mkdocs-material
28
+ - run: pip install mkdocs-material[imaging]
29
29
  - run: mkdocs gh-deploy --force
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+
7
+ # Replace tomllib with toml (needs to be installed with pip)
8
+ import toml
9
+
10
+
11
+ def update_version(version):
12
+ """Update project version in pyproject.toml file"""
13
+ # Get the project root directory (assuming script is in .github/workflows)
14
+ project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
15
+ pyproject_path = os.path.join(project_root, "pyproject.toml")
16
+
17
+ # Check if pyproject.toml exists
18
+ if not os.path.isfile(pyproject_path):
19
+ print(f"Error: pyproject.toml not found at {pyproject_path}")
20
+ sys.exit(1)
21
+
22
+ try:
23
+ # Load the pyproject.toml file
24
+ data = toml.load(pyproject_path)
25
+
26
+ # Show the current version
27
+ current_version = data.get("project", {}).get("version", "unknown")
28
+ print(f"Current version: {current_version}")
29
+
30
+ # Update the version
31
+ if "project" not in data:
32
+ print("Error: 'project' section not found in pyproject.toml")
33
+ sys.exit(1)
34
+
35
+ data["project"]["version"] = version
36
+
37
+ # Write the updated content back to the file
38
+ with open(pyproject_path, "w") as f:
39
+ toml.dump(data, f)
40
+
41
+ print(f"Version updated to: {version}")
42
+
43
+ except Exception as e:
44
+ print(f"Error updating version: {e}")
45
+ sys.exit(1)
46
+
47
+
48
+ if __name__ == "__main__":
49
+ # Parse command line arguments
50
+ parser = argparse.ArgumentParser(description="Update project version in pyproject.toml")
51
+ parser.add_argument("version", help="New version to set (e.g., '0.1.0')")
52
+
53
+ args = parser.parse_args()
54
+
55
+ # Update the version
56
+ update_version(args.version)
@@ -79,4 +79,6 @@ benchmark/*.jpg
79
79
  .env
80
80
 
81
81
  *.pem
82
- *.key
82
+ *.key
83
+
84
+ *.dylib
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: httpr
3
- Version: 0.1.0
3
+ Version: 0.1.12
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: 3 :: Only
@@ -22,7 +22,7 @@ Requires-Dist: mypy>=1.14.1 ; extra == 'dev'
22
22
  Requires-Dist: ruff>=0.9.2 ; extra == 'dev'
23
23
  Requires-Dist: maturin ; extra == 'dev'
24
24
  Requires-Dist: trustme ; extra == 'dev'
25
- Requires-Dist: mkdocs-material ; extra == 'docs'
25
+ Requires-Dist: mkdocs-material[imaging] ; extra == 'docs'
26
26
  Requires-Dist: httpr[dev] ; extra == 'scratch'
27
27
  Requires-Dist: matplotlib ; extra == 'scratch'
28
28
  Requires-Dist: pandas ; extra == 'scratch'
@@ -47,19 +47,24 @@ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
47
47
 
48
48
  # httpr
49
49
 
50
- **Blazing fast http-client** for Python in Rust 🦀 that can be used as drop-in replacement for `httpx` in most cases.
50
+ **Blazing fast http-client** for Python in Rust 🦀 that can be used as drop-in replacement for `httpx` and `requests` in most cases.
51
51
 
52
- - **Fast**: httpr is built on top of `reqwests` which is a blazing fast http client in Rust. Check out the [benchmark](#benchmark).
53
- - **Both async and sync**: httpr provides both a sync and async client.
54
- - **Lightweight**: httpr is a lightweight http client with 0 python-dependencies.
55
- - **Async**: first-class support for async/await.
56
- - **http2**: httpr supports http2.
57
- - **mTLS**: httpr supports mTLS.
58
- - **Zero python dependencies**: httpr is a pure rust library with no python dependencies.
52
+ - **Fast**: `httpr` is built on top of `reqwests`, which is a blazing fast http client in Rust. Check out the [benchmark](#benchmark).
53
+ - **Both async and sync**: `httpr` provides both a sync and async client.
54
+ - **Lightweight**: `httpr` is a lightweight http client with zero python-dependencies.
55
+ - **Async**: first-class async support.
56
+ - **http2**: `httpr` supports HTTP/2.
57
+ - **mTLS**: `httpr` supports mTLS.
58
+
59
+ ## Not implemented yet
60
+
61
+ - **Streaming**: Streaming is not implemented yet.
62
+ - **Fine-grained error handling**: Fine-grained error handling is not implemented yet.
59
63
 
60
64
  ## Table of Contents
61
65
 
62
66
  - [httpr](#httpr)
67
+ - [Not implemented yet](#not-implemented-yet)
63
68
  - [Table of Contents](#table-of-contents)
64
69
  - [Installation](#installation)
65
70
  - [Install with uv](#install-with-uv)
@@ -310,7 +315,8 @@ Provides precompiled wheels for the following platforms:
310
315
 
311
316
  ## Acknowledgements
312
317
 
313
- - [PRIMP](https://github.com/deedy5/primp): *A lot* of code is borrowed from PRIMP, that wraps rust library `rquest` for python in a similar way.
318
+ - [uv](https://docs.astral.sh/uv/): The package manager used, and for leading the way in the "Rust for python tools"-sphere.
319
+ - [primp](https://github.com/deedy5/primp): *A lot* of code is borrowed from primp, that wraps rust library `rquest` for python in a similar way. If primp supported mTLS, I would have used it instead.
314
320
  - [reqwests](https://github.com/seanmonstar/reqwest): The rust library that powers httpr.
315
321
  - [pyo3](https://github.com/PyO3/pyo3)
316
322
  - [maturin](https://github.com/PyO3/maturin)
@@ -1,18 +1,23 @@
1
1
  # httpr
2
2
 
3
- **Blazing fast http-client** for Python in Rust 🦀 that can be used as drop-in replacement for `httpx` in most cases.
3
+ **Blazing fast http-client** for Python in Rust 🦀 that can be used as drop-in replacement for `httpx` and `requests` in most cases.
4
4
 
5
- - **Fast**: httpr is built on top of `reqwests` which is a blazing fast http client in Rust. Check out the [benchmark](#benchmark).
6
- - **Both async and sync**: httpr provides both a sync and async client.
7
- - **Lightweight**: httpr is a lightweight http client with 0 python-dependencies.
8
- - **Async**: first-class support for async/await.
9
- - **http2**: httpr supports http2.
10
- - **mTLS**: httpr supports mTLS.
11
- - **Zero python dependencies**: httpr is a pure rust library with no python dependencies.
5
+ - **Fast**: `httpr` is built on top of `reqwests`, which is a blazing fast http client in Rust. Check out the [benchmark](#benchmark).
6
+ - **Both async and sync**: `httpr` provides both a sync and async client.
7
+ - **Lightweight**: `httpr` is a lightweight http client with zero python-dependencies.
8
+ - **Async**: first-class async support.
9
+ - **http2**: `httpr` supports HTTP/2.
10
+ - **mTLS**: `httpr` supports mTLS.
11
+
12
+ ## Not implemented yet
13
+
14
+ - **Streaming**: Streaming is not implemented yet.
15
+ - **Fine-grained error handling**: Fine-grained error handling is not implemented yet.
12
16
 
13
17
  ## Table of Contents
14
18
 
15
19
  - [httpr](#httpr)
20
+ - [Not implemented yet](#not-implemented-yet)
16
21
  - [Table of Contents](#table-of-contents)
17
22
  - [Installation](#installation)
18
23
  - [Install with uv](#install-with-uv)
@@ -263,7 +268,8 @@ Provides precompiled wheels for the following platforms:
263
268
 
264
269
  ## Acknowledgements
265
270
 
266
- - [PRIMP](https://github.com/deedy5/primp): *A lot* of code is borrowed from PRIMP, that wraps rust library `rquest` for python in a similar way.
271
+ - [uv](https://docs.astral.sh/uv/): The package manager used, and for leading the way in the "Rust for python tools"-sphere.
272
+ - [primp](https://github.com/deedy5/primp): *A lot* of code is borrowed from primp, that wraps rust library `rquest` for python in a similar way. If primp supported mTLS, I would have used it instead.
267
273
  - [reqwests](https://github.com/seanmonstar/reqwest): The rust library that powers httpr.
268
274
  - [pyo3](https://github.com/PyO3/pyo3)
269
275
  - [maturin](https://github.com/PyO3/maturin)
Binary file
@@ -0,0 +1 @@
1
+ # Blog
@@ -0,0 +1,9 @@
1
+ ---
2
+ date: 2025-02-25T00:00:00-07:00
3
+ social:
4
+ cards: true
5
+ ---
6
+
7
+ # Lorem ipsum dolor sit amet
8
+
9
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsum mauris. Maecenas congue ligula ac quam viverra nec consectetur ante hendrerit. Donec et mollis dolor. Praesent et diam eget libero egestas mattis sit amet vitae augue. Nam tincidunt est et ultrices eleifend, nunc risus varius orci, in dignissim purus enim quis turpis.
@@ -80,6 +80,10 @@ class Client(RClient):
80
80
  # Validate the HTTP method. Raise an exception if it's not supported.
81
81
  if method not in ["GET", "HEAD", "OPTIONS", "DELETE", "POST", "PUT", "PATCH"]:
82
82
  raise ValueError(f"Unsupported HTTP method: {method}")
83
+ # Convert all params values to strings if params is present
84
+ if "params" in kwargs and kwargs["params"] is not None:
85
+ kwargs["params"] = {k: str(v) for k, v in kwargs["params"].items()}
86
+
83
87
  return super().request(method=method, url=url, **kwargs)
84
88
 
85
89
  def get(self, url: str, **kwargs: Unpack[RequestParams]) -> Response:
@@ -114,11 +118,21 @@ class AsyncClient(Client):
114
118
  async def __aexit__(self, *args):
115
119
  del self
116
120
 
121
+ async def aclose(self):
122
+ del self
123
+ return
124
+
117
125
  async def _run_sync_asyncio(self, fn, *args, **kwargs):
118
126
  loop = asyncio.get_running_loop()
119
127
  return await loop.run_in_executor(None, partial(fn, *args, **kwargs))
120
128
 
121
129
  async def request(self, method: HttpMethod, url: str, **kwargs: Unpack[RequestParams]): # type: ignore
130
+ if method not in ["GET", "HEAD", "OPTIONS", "DELETE", "POST", "PUT", "PATCH"]:
131
+ raise ValueError(f"Unsupported HTTP method: {method}")
132
+ # Convert all params values to strings if params is present
133
+ if "params" in kwargs and kwargs["params"] is not None:
134
+ kwargs["params"] = {k: str(v) for k, v in kwargs["params"].items()}
135
+
122
136
  return await self._run_sync_asyncio(super().request, method=method, url=url, **kwargs)
123
137
 
124
138
  async def get(self, url: str, **kwargs: Unpack[RequestParams]): # type: ignore
@@ -0,0 +1,41 @@
1
+ site_name: httpr
2
+ site_url: https://thomasht86.github.io/httpr
3
+ plugins:
4
+ - search
5
+ - blog:
6
+ blog_dir: writings
7
+ - social:
8
+ enabled: true
9
+
10
+ nav:
11
+ - Home: index.md
12
+ - Docs: docs.md
13
+ - Blog: writings/index.md
14
+
15
+ theme:
16
+ name: material
17
+ font:
18
+ text: Lato
19
+ code: Fira Code
20
+ features:
21
+ - content.code.copy
22
+ - search.suggest
23
+
24
+ markdown_extensions:
25
+ - attr_list
26
+ - admonition
27
+ - pymdownx.betterem
28
+ - toc:
29
+ permalink: true
30
+ - pymdownx.highlight:
31
+ anchor_linenums: true
32
+ line_spans: __span
33
+ pygments_lang_class: true
34
+ - pymdownx.inlinehilite
35
+ - pymdownx.snippets
36
+ - pymdownx.superfences
37
+ - pymdownx.tabbed:
38
+ alternate_style: true
39
+ - pymdownx.emoji
40
+ - pymdownx.keys
41
+ - footnotes
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = [ "maturin>=1.5,<2.0",]
3
+ build-backend = "maturin"
4
+
5
+ [project]
6
+ name = "httpr"
7
+ description = "Fast HTTP client for Python"
8
+ requires-python = ">=3.9"
9
+ keywords = [ "python", "request",]
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
+ dynamic = [ "version",]
12
+ dependencies = []
13
+ version = "0.1.12"
14
+ [[project.authors]]
15
+ name = "thomasht86"
16
+
17
+ [project.license]
18
+ text = "MIT License"
19
+
20
+ [project.optional-dependencies]
21
+ dev = [ "certifi", "pytest>=8.1.1", "pytest-asyncio>=0.25.3", "typing_extensions; python_version <= '3.11'", "mypy>=1.14.1", "ruff>=0.9.2", "maturin", "trustme",]
22
+ docs = [ "mkdocs-material[imaging]",]
23
+ scratch = [ "httpr[dev]", "matplotlib", "pandas", "jupyter", "ipykernel", "httpx", "gunicorn", "uvicorn", "trustme", "starlette", "fastapi",]
24
+
25
+ [tool.maturin]
26
+ features = [ "pyo3/extension-module",]
27
+
28
+ [tool.ruff]
29
+ line-length = 120
30
+ exclude = [ "tests",]
31
+
32
+ [tool.mypy]
33
+ python_version = "0.1.5"
34
+
35
+ [tool.uv]
36
+ [[tool.uv.cache-keys]]
37
+ file = "pyproject.toml"
38
+
39
+ [[tool.uv.cache-keys]]
40
+ file = "rust/Cargo.toml"
41
+
42
+ [[tool.uv.cache-keys]]
43
+ file = "**/*.rs"
44
+
45
+ [tool.ruff.lint]
46
+ select = [ "E", "F", "UP", "B", "SIM", "I",]
47
+
48
+ [tool.uv.workspace]
49
+ members = [ "benchmark",]
@@ -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
  })
@@ -440,5 +440,6 @@ fn httpr(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
440
440
  pyo3_log::init();
441
441
 
442
442
  m.add_class::<RClient>()?;
443
+ m.add_class::<Response>()?;
443
444
  Ok(())
444
445
  }
@@ -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:?}"))