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.
- {httpr-0.1.0 → httpr-0.1.12}/.github/workflows/CI.yml +63 -2
- {httpr-0.1.0 → httpr-0.1.12}/.github/workflows/mkdocs.yml +1 -1
- httpr-0.1.12/.github/workflows/set_version.py +56 -0
- {httpr-0.1.0 → httpr-0.1.12}/.gitignore +3 -1
- {httpr-0.1.0 → httpr-0.1.12}/PKG-INFO +17 -11
- {httpr-0.1.0 → httpr-0.1.12}/README.md +15 -9
- httpr-0.1.12/benchmark.jpg +0 -0
- httpr-0.1.12/docs/writings/index.md +1 -0
- httpr-0.1.12/docs/writings/posts/2025-02-24-python-http-clients-suck.md +9 -0
- {httpr-0.1.0 → httpr-0.1.12}/httpr/__init__.py +14 -0
- httpr-0.1.12/mkdocs.yml +41 -0
- httpr-0.1.12/pyproject.toml +49 -0
- {httpr-0.1.0 → httpr-0.1.12}/src/lib.rs +3 -2
- httpr-0.1.12/src/response.rs +199 -0
- {httpr-0.1.0 → httpr-0.1.12}/src/traits.rs +3 -1
- {httpr-0.1.0 → httpr-0.1.12}/src/utils.rs +32 -2
- {httpr-0.1.0 → httpr-0.1.12}/tests/test_client.py +38 -2
- {httpr-0.1.0 → httpr-0.1.12}/uv.lock +104 -65
- httpr-0.1.0/benchmark.jpg +0 -0
- httpr-0.1.0/mkdocs.yml +0 -4
- httpr-0.1.0/pyproject.toml +0 -87
- httpr-0.1.0/src/response.rs +0 -100
- {httpr-0.1.0 → httpr-0.1.12}/.pre-commit-config.yaml +0 -0
- {httpr-0.1.0 → httpr-0.1.12}/Cargo.lock +0 -0
- {httpr-0.1.0 → httpr-0.1.12}/Cargo.toml +0 -0
- {httpr-0.1.0 → httpr-0.1.12}/LICENSE +0 -0
- {httpr-0.1.0 → httpr-0.1.12}/benchmark/README.md +0 -0
- {httpr-0.1.0 → httpr-0.1.12}/benchmark/benchmark.py +0 -0
- {httpr-0.1.0 → httpr-0.1.12}/benchmark/generate_image.py +0 -0
- {httpr-0.1.0 → httpr-0.1.12}/benchmark/pyproject.toml +0 -0
- {httpr-0.1.0 → httpr-0.1.12}/benchmark/requirements.txt +0 -0
- {httpr-0.1.0 → httpr-0.1.12}/benchmark/server.py +0 -0
- {httpr-0.1.0 → httpr-0.1.12}/docs/index.md +0 -0
- {httpr-0.1.0 → httpr-0.1.12}/httpr/httpr.pyi +0 -0
- {httpr-0.1.0 → httpr-0.1.12}/httpr/py.typed +0 -0
- {httpr-0.1.0 → httpr-0.1.12}/httpr.code-workspace +0 -0
- {httpr-0.1.0 → httpr-0.1.12}/scratch.ipynb +0 -0
- {httpr-0.1.0 → httpr-0.1.12}/tests/httpx_conns.py +0 -0
- {httpr-0.1.0 → httpr-0.1.12}/tests/test_asyncclient.py +0 -0
- {httpr-0.1.0 → httpr-0.1.12}/tests/test_defs.py +0 -0
- {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
|
-
|
|
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
|
|
@@ -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)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: httpr
|
|
3
|
-
Version: 0.1.
|
|
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
|
|
53
|
-
- **Both async and sync**: httpr provides both a sync and async client.
|
|
54
|
-
- **Lightweight**: httpr is a lightweight http client with
|
|
55
|
-
- **Async**: first-class support
|
|
56
|
-
- **http2**: httpr supports
|
|
57
|
-
- **mTLS**: httpr supports mTLS.
|
|
58
|
-
|
|
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
|
-
- [
|
|
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
|
|
6
|
-
- **Both async and sync**: httpr provides both a sync and async client.
|
|
7
|
-
- **Lightweight**: httpr is a lightweight http client with
|
|
8
|
-
- **Async**: first-class support
|
|
9
|
-
- **http2**: httpr supports
|
|
10
|
-
- **mTLS**: httpr supports mTLS.
|
|
11
|
-
|
|
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
|
-
- [
|
|
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
|
httpr-0.1.12/mkdocs.yml
ADDED
|
@@ -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
|
-
|
|
46
|
+
header_name,
|
|
45
47
|
value
|
|
46
48
|
.to_str()
|
|
47
49
|
.unwrap_or_else(|v| panic!("Invalid header value: {v:?}"))
|