httpr 0.3.1__tar.gz → 0.3.2__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.
- {httpr-0.3.1 → httpr-0.3.2}/.github/workflows/CI.yml +14 -6
- httpr-0.3.2/.github/workflows/mkdocs.yml +88 -0
- {httpr-0.3.1 → httpr-0.3.2}/CLAUDE.md +2 -1
- {httpr-0.3.1 → httpr-0.3.2}/Cargo.lock +1 -1
- {httpr-0.3.1 → httpr-0.3.2}/Cargo.toml +1 -1
- {httpr-0.3.1 → httpr-0.3.2}/PKG-INFO +2 -2
- {httpr-0.3.1 → httpr-0.3.2}/README.md +1 -1
- {httpr-0.3.1 → httpr-0.3.2}/docs/advanced/ssl-tls.md +27 -0
- {httpr-0.3.1 → httpr-0.3.2}/httpr/__init__.py +14 -1
- {httpr-0.3.1 → httpr-0.3.2}/httpr/httpr.pyi +5 -0
- {httpr-0.3.1 → httpr-0.3.2}/pyproject.toml +1 -1
- {httpr-0.3.1 → httpr-0.3.2}/src/lib.rs +19 -6
- {httpr-0.3.1 → httpr-0.3.2}/tests/unit/test_ssl.py +77 -7
- httpr-0.3.1/.github/workflows/mkdocs.yml +0 -30
- {httpr-0.3.1 → httpr-0.3.2}/.github/actions/set-version/action.yml +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/.github/copilot-instructions.md +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/.github/workflows/CI.yml.backup +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/.github/workflows/benchmark.yml +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/.github/workflows/claude-code-review.yml +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/.github/workflows/claude.yml +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/.github/workflows/copilot-setup-steps.yml +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/.github/workflows/set_version.py +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/.gitignore +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/.pre-commit-config.yaml +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/LICENSE +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/Taskfile.yaml +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/benchmark/README.md +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/benchmark/__init__.py +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/benchmark/benchmark.py +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/benchmark/benchmark_cbor.py +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/benchmark/generate_image.py +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/benchmark/server.py +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/benchmark.jpg +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/docs/advanced/cookies.md +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/docs/advanced/index.md +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/docs/advanced/proxy.md +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/docs/api/async-client.md +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/docs/api/client.md +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/docs/api/functions.md +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/docs/api/index.md +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/docs/api/response.md +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/docs/index.md +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/docs/quickstart.md +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/docs/tutorial/async.md +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/docs/tutorial/authentication.md +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/docs/tutorial/index.md +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/docs/tutorial/making-requests.md +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/docs/tutorial/response-handling.md +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/docs/writings/index.md +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/docs/writings/posts/2025-02-24-python-http-clients-suck.md +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/httpr/py.typed +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/httpr.code-workspace +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/mkdocs.yml +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/scripts/generate_certs.py +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/src/exceptions.rs +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/src/response.rs +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/src/traits.rs +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/src/utils.rs +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/tests/benchmark/__init__.py +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/tests/benchmark/test_performance.py +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/tests/conftest.py +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/tests/e2e/__init__.py +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/tests/e2e/test_async.py +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/tests/e2e/test_auth.py +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/tests/e2e/test_redirects.py +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/tests/e2e/test_ssl.py +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/tests/e2e/test_streaming.py +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/tests/e2e/test_uploads.py +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/tests/unit/__init__.py +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/tests/unit/cbor_test_server.py +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/tests/unit/httpx_conns.py +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/tests/unit/test_asyncclient.py +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/tests/unit/test_cbor.py +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/tests/unit/test_client.py +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/tests/unit/test_defs.py +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/tests/unit/test_docs.py +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/tests/unit/test_exceptions.py +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/tests/unit/test_streaming.py +0 -0
- {httpr-0.3.1 → httpr-0.3.2}/uv.lock +0 -0
|
@@ -434,19 +434,27 @@ jobs:
|
|
|
434
434
|
HTTPR_VERSION="dev"
|
|
435
435
|
fi
|
|
436
436
|
uv run python benchmark/benchmark.py --httpr-version "$HTTPR_VERSION"
|
|
437
|
-
- name: Generate and
|
|
437
|
+
- name: Generate and deploy benchmark image to gh-pages
|
|
438
438
|
if: startsWith(github.ref, 'refs/tags/')
|
|
439
439
|
run: |
|
|
440
440
|
uv run python benchmark/generate_image.py
|
|
441
441
|
git config --global user.name 'github-actions[bot]'
|
|
442
442
|
git config --global user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
443
|
+
|
|
444
|
+
# Save the generated image
|
|
445
|
+
cp benchmark.jpg /tmp/benchmark.jpg
|
|
446
|
+
|
|
447
|
+
# Fetch and checkout gh-pages
|
|
448
|
+
git fetch origin gh-pages:gh-pages
|
|
449
|
+
git checkout gh-pages
|
|
450
|
+
|
|
451
|
+
# Copy the benchmark image
|
|
452
|
+
cp /tmp/benchmark.jpg benchmark.jpg
|
|
453
|
+
|
|
454
|
+
git add benchmark.jpg
|
|
447
455
|
if ! git diff --quiet --cached; then
|
|
448
456
|
git commit -m "Update benchmark image for ${{ github.ref_name }}"
|
|
449
|
-
git push origin
|
|
457
|
+
git push origin gh-pages
|
|
450
458
|
else
|
|
451
459
|
echo "No changes to commit"
|
|
452
460
|
fi
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
name: mkdocs
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
branches:
|
|
5
|
+
- master
|
|
6
|
+
- main
|
|
7
|
+
permissions:
|
|
8
|
+
contents: write
|
|
9
|
+
jobs:
|
|
10
|
+
deploy:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
with:
|
|
15
|
+
fetch-depth: 0
|
|
16
|
+
- name: Configure Git Credentials
|
|
17
|
+
run: |
|
|
18
|
+
git config user.name github-actions[bot]
|
|
19
|
+
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
|
|
20
|
+
- uses: astral-sh/setup-uv@v7
|
|
21
|
+
with:
|
|
22
|
+
python-version: "3.12"
|
|
23
|
+
enable-cache: false
|
|
24
|
+
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
|
|
25
|
+
- uses: actions/cache@v4
|
|
26
|
+
with:
|
|
27
|
+
key: mkdocs-material-${{ env.cache_id }}
|
|
28
|
+
path: .cache
|
|
29
|
+
restore-keys: |
|
|
30
|
+
mkdocs-material-
|
|
31
|
+
- run: uv sync --extra docs
|
|
32
|
+
- name: Build docs
|
|
33
|
+
run: uv run mkdocs build
|
|
34
|
+
- name: Deploy to gh-pages (preserving benchmark data)
|
|
35
|
+
run: |
|
|
36
|
+
# Capture source commit BEFORE switching branches
|
|
37
|
+
SOURCE_COMMIT=$(git rev-parse --short HEAD)
|
|
38
|
+
|
|
39
|
+
# Fetch gh-pages branch
|
|
40
|
+
git fetch origin gh-pages:gh-pages || true
|
|
41
|
+
|
|
42
|
+
# Save benchmark data if it exists
|
|
43
|
+
mkdir -p /tmp/bench-backup
|
|
44
|
+
if git show gh-pages:dev/bench/data.js > /tmp/bench-backup/data.js 2>/dev/null; then
|
|
45
|
+
echo "Found existing benchmark data.js, will preserve it"
|
|
46
|
+
fi
|
|
47
|
+
if git show gh-pages:dev/bench/index.html > /tmp/bench-backup/index.html 2>/dev/null; then
|
|
48
|
+
echo "Found existing benchmark index.html, will preserve it"
|
|
49
|
+
fi
|
|
50
|
+
# Save benchmark image if it exists
|
|
51
|
+
if git show gh-pages:benchmark.jpg > /tmp/bench-backup/benchmark.jpg 2>/dev/null; then
|
|
52
|
+
echo "Found existing benchmark.jpg, will preserve it"
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
# Switch to gh-pages branch
|
|
56
|
+
git checkout gh-pages || git checkout --orphan gh-pages
|
|
57
|
+
|
|
58
|
+
# Remove all tracked files
|
|
59
|
+
git rm -rf . || true
|
|
60
|
+
|
|
61
|
+
# Copy new docs from site/ directory
|
|
62
|
+
if [ ! -d "site" ] || [ -z "$(ls -A site 2>/dev/null)" ]; then
|
|
63
|
+
echo "Error: site/ directory is empty or missing"
|
|
64
|
+
exit 1
|
|
65
|
+
fi
|
|
66
|
+
cp -r site/* .
|
|
67
|
+
|
|
68
|
+
# Restore benchmark data if it existed
|
|
69
|
+
if [ -f /tmp/bench-backup/data.js ] || [ -f /tmp/bench-backup/index.html ]; then
|
|
70
|
+
mkdir -p dev/bench
|
|
71
|
+
[ -f /tmp/bench-backup/data.js ] && cp /tmp/bench-backup/data.js dev/bench/
|
|
72
|
+
[ -f /tmp/bench-backup/index.html ] && cp /tmp/bench-backup/index.html dev/bench/
|
|
73
|
+
echo "Restored benchmark chart files"
|
|
74
|
+
fi
|
|
75
|
+
# Restore benchmark image if it existed
|
|
76
|
+
if [ -f /tmp/bench-backup/benchmark.jpg ]; then
|
|
77
|
+
cp /tmp/bench-backup/benchmark.jpg benchmark.jpg
|
|
78
|
+
echo "Restored benchmark.jpg"
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
# Add all files and commit
|
|
82
|
+
git add -A
|
|
83
|
+
if git diff --cached --quiet; then
|
|
84
|
+
echo "No changes to deploy"
|
|
85
|
+
else
|
|
86
|
+
git commit -m "Deployed ${SOURCE_COMMIT} with MkDocs"
|
|
87
|
+
git push origin gh-pages --force
|
|
88
|
+
fi
|
|
@@ -145,7 +145,8 @@ uv run python benchmark.py # Terminal 2: Run benchmarks
|
|
|
145
145
|
### SSL/TLS
|
|
146
146
|
- CA certs loaded via `HTTPR_CA_BUNDLE` env var
|
|
147
147
|
- `ca_cert_file` param sets `HTTPR_CA_BUNDLE` internally
|
|
148
|
-
- mTLS via `client_pem` parameter (PEM format)
|
|
148
|
+
- mTLS via `client_pem` parameter (file path, PEM format) or `client_pem_data` (bytes, PEM format)
|
|
149
|
+
- `client_pem_data` allows passing certificates without filesystem access (useful for containers/secrets managers)
|
|
149
150
|
- `verify=False` enables `danger_accept_invalid_certs()`
|
|
150
151
|
|
|
151
152
|
### Headers Behavior
|
|
@@ -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.
|
|
5
|
+
version = "0.3.2"
|
|
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
|
+
Version: 0.3.2
|
|
4
4
|
Classifier: Programming Language :: Rust
|
|
5
5
|
Classifier: Programming Language :: Python :: 3
|
|
6
6
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
@@ -123,7 +123,7 @@ pip install -U httpr
|
|
|
123
123
|
|
|
124
124
|
## Benchmark
|
|
125
125
|
|
|
126
|
-

|
|
127
127
|
|
|
128
128
|
## Usage
|
|
129
129
|
|
|
@@ -117,6 +117,33 @@ MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwgg...
|
|
|
117
117
|
-----END PRIVATE KEY-----
|
|
118
118
|
```
|
|
119
119
|
|
|
120
|
+
### Using In-Memory Certificates
|
|
121
|
+
|
|
122
|
+
For containerized environments or when certificates are stored in secrets managers, you can pass the certificate data directly without writing to the filesystem:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
import httpr
|
|
126
|
+
import os
|
|
127
|
+
|
|
128
|
+
# Load certificate from environment variable or secrets manager
|
|
129
|
+
cert_data = os.environ.get("CLIENT_CERT_PEM").encode() # or from secrets manager
|
|
130
|
+
|
|
131
|
+
client = httpr.Client(
|
|
132
|
+
client_pem_data=cert_data, # Pass bytes directly
|
|
133
|
+
ca_cert_file="/path/to/ca-bundle.pem"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
response = client.get("https://mtls.example.com/api")
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
**Benefits:**
|
|
140
|
+
- No temporary files needed
|
|
141
|
+
- Better security in containerized environments
|
|
142
|
+
- Works with secrets managers (AWS Secrets Manager, HashiCorp Vault, etc.)
|
|
143
|
+
- Reduces filesystem I/O
|
|
144
|
+
|
|
145
|
+
**Note:** Either `client_pem` (file path) or `client_pem_data` (bytes) can be used, but not both.
|
|
146
|
+
|
|
120
147
|
### Complete mTLS Example
|
|
121
148
|
|
|
122
149
|
```python
|
|
@@ -117,6 +117,7 @@ class Client(RClient):
|
|
|
117
117
|
verify: bool | None = True,
|
|
118
118
|
ca_cert_file: str | None = None,
|
|
119
119
|
client_pem: str | None = None,
|
|
120
|
+
client_pem_data: bytes | None = None,
|
|
120
121
|
https_only: bool | None = False,
|
|
121
122
|
http2_only: bool | None = False,
|
|
122
123
|
):
|
|
@@ -140,6 +141,8 @@ class Client(RClient):
|
|
|
140
141
|
verify: Verify SSL certificates. Default is True.
|
|
141
142
|
ca_cert_file: Path to CA certificate bundle (PEM format).
|
|
142
143
|
client_pem: Path to client certificate for mTLS (PEM format).
|
|
144
|
+
client_pem_data: Client certificate and key as bytes for mTLS (PEM format).
|
|
145
|
+
Use this instead of client_pem when you have the certificate in memory.
|
|
143
146
|
https_only: Only allow HTTPS requests. Default is False.
|
|
144
147
|
http2_only: Use HTTP/2 only (False uses HTTP/1.1). Default is False.
|
|
145
148
|
|
|
@@ -165,11 +168,18 @@ class Client(RClient):
|
|
|
165
168
|
# Client with proxy
|
|
166
169
|
client = httpr.Client(proxy="http://proxy.example.com:8080")
|
|
167
170
|
|
|
168
|
-
# Client with mTLS
|
|
171
|
+
# Client with mTLS using file path
|
|
169
172
|
client = httpr.Client(
|
|
170
173
|
client_pem="/path/to/client.pem",
|
|
171
174
|
ca_cert_file="/path/to/ca.pem",
|
|
172
175
|
)
|
|
176
|
+
|
|
177
|
+
# Client with mTLS using direct certificate data
|
|
178
|
+
cert_data = b"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
|
|
179
|
+
client = httpr.Client(
|
|
180
|
+
client_pem_data=cert_data,
|
|
181
|
+
ca_cert_file="/path/to/ca.pem",
|
|
182
|
+
)
|
|
173
183
|
```
|
|
174
184
|
"""
|
|
175
185
|
super().__init__()
|
|
@@ -787,6 +797,7 @@ def request(
|
|
|
787
797
|
verify: bool | None = True,
|
|
788
798
|
ca_cert_file: str | None = None,
|
|
789
799
|
client_pem: str | None = None,
|
|
800
|
+
client_pem_data: bytes | None = None,
|
|
790
801
|
**kwargs: Unpack[RequestParams],
|
|
791
802
|
) -> Response:
|
|
792
803
|
"""
|
|
@@ -801,6 +812,7 @@ def request(
|
|
|
801
812
|
verify: Verify SSL certificates. Default is True.
|
|
802
813
|
ca_cert_file: Path to CA certificate bundle.
|
|
803
814
|
client_pem: Path to client certificate for mTLS.
|
|
815
|
+
client_pem_data: Client certificate and key as bytes for mTLS.
|
|
804
816
|
**kwargs: Additional request parameters.
|
|
805
817
|
|
|
806
818
|
Returns:
|
|
@@ -818,6 +830,7 @@ def request(
|
|
|
818
830
|
verify=verify,
|
|
819
831
|
ca_cert_file=ca_cert_file,
|
|
820
832
|
client_pem=client_pem,
|
|
833
|
+
client_pem_data=client_pem_data,
|
|
821
834
|
) as client:
|
|
822
835
|
return client.request(method, url, **kwargs)
|
|
823
836
|
|
|
@@ -28,6 +28,7 @@ class ClientRequestParams(RequestParams):
|
|
|
28
28
|
verify: bool | None
|
|
29
29
|
ca_cert_file: str | None
|
|
30
30
|
client_pem: str | None
|
|
31
|
+
client_pem_data: bytes | None
|
|
31
32
|
|
|
32
33
|
class CaseInsensitiveHeaderMap:
|
|
33
34
|
"""
|
|
@@ -286,6 +287,7 @@ class RClient:
|
|
|
286
287
|
verify: bool | None = True,
|
|
287
288
|
ca_cert_file: str | None = None,
|
|
288
289
|
client_pem: str | None = None,
|
|
290
|
+
client_pem_data: bytes | None = None,
|
|
289
291
|
https_only: bool | None = False,
|
|
290
292
|
http2_only: bool | None = False,
|
|
291
293
|
): ...
|
|
@@ -374,6 +376,7 @@ class Client(RClient):
|
|
|
374
376
|
verify: bool | None = True,
|
|
375
377
|
ca_cert_file: str | None = None,
|
|
376
378
|
client_pem: str | None = None,
|
|
379
|
+
client_pem_data: bytes | None = None,
|
|
377
380
|
https_only: bool | None = False,
|
|
378
381
|
http2_only: bool | None = False,
|
|
379
382
|
) -> None:
|
|
@@ -395,6 +398,7 @@ class Client(RClient):
|
|
|
395
398
|
verify: Verify SSL certificates. Default is True.
|
|
396
399
|
ca_cert_file: Path to CA certificate bundle (PEM format).
|
|
397
400
|
client_pem: Path to client certificate for mTLS (PEM format).
|
|
401
|
+
client_pem_data: Client certificate and key as bytes for mTLS (PEM format).
|
|
398
402
|
https_only: Only allow HTTPS requests. Default is False.
|
|
399
403
|
http2_only: Use HTTP/2 only. Default is False.
|
|
400
404
|
"""
|
|
@@ -472,6 +476,7 @@ class AsyncClient(Client):
|
|
|
472
476
|
verify: bool | None = True,
|
|
473
477
|
ca_cert_file: str | None = None,
|
|
474
478
|
client_pem: str | None = None,
|
|
479
|
+
client_pem_data: bytes | None = None,
|
|
475
480
|
https_only: bool | None = False,
|
|
476
481
|
http2_only: bool | None = False,
|
|
477
482
|
) -> None:
|
|
@@ -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.
|
|
14
|
+
version = "0.3.2"
|
|
15
15
|
dependencies = []
|
|
16
16
|
[[project.authors]]
|
|
17
17
|
name = "thomasht86"
|
|
@@ -7,6 +7,7 @@ use anyhow::anyhow;
|
|
|
7
7
|
use bytes::Bytes;
|
|
8
8
|
use foldhash::fast::RandomState;
|
|
9
9
|
use indexmap::IndexMap;
|
|
10
|
+
use pyo3::exceptions::PyValueError;
|
|
10
11
|
use pyo3::prelude::*;
|
|
11
12
|
use pyo3::types::PyBytes;
|
|
12
13
|
use pythonize::depythonize;
|
|
@@ -115,7 +116,7 @@ impl RClient {
|
|
|
115
116
|
#[new]
|
|
116
117
|
#[pyo3(signature = (auth=None, auth_bearer=None, params=None, headers=None, cookies=None,
|
|
117
118
|
cookie_store=true, referer=true, proxy=None, timeout=None, follow_redirects=true,
|
|
118
|
-
max_redirects=20, verify=true, ca_cert_file=None, client_pem=None, https_only=false, http2_only=false))]
|
|
119
|
+
max_redirects=20, verify=true, ca_cert_file=None, client_pem=None, client_pem_data=None, https_only=false, http2_only=false))]
|
|
119
120
|
fn new(
|
|
120
121
|
auth: Option<(String, Option<String>)>,
|
|
121
122
|
auth_bearer: Option<String>,
|
|
@@ -131,9 +132,15 @@ impl RClient {
|
|
|
131
132
|
verify: Option<bool>,
|
|
132
133
|
ca_cert_file: Option<String>,
|
|
133
134
|
client_pem: Option<String>,
|
|
135
|
+
client_pem_data: Option<Vec<u8>>,
|
|
134
136
|
https_only: Option<bool>,
|
|
135
137
|
http2_only: Option<bool>,
|
|
136
138
|
) -> PyResult<Self> {
|
|
139
|
+
if client_pem.is_some() && client_pem_data.is_some() {
|
|
140
|
+
return Err(PyValueError::new_err(
|
|
141
|
+
"Only one of client_pem or client_pem_data may be set.",
|
|
142
|
+
));
|
|
143
|
+
}
|
|
137
144
|
// Client builder
|
|
138
145
|
let mut client_builder = reqwest::Client::builder();
|
|
139
146
|
|
|
@@ -195,11 +202,17 @@ impl RClient {
|
|
|
195
202
|
}
|
|
196
203
|
}
|
|
197
204
|
// Load client pem identity if provided
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
205
|
+
// Either from file path (client_pem) or direct data (client_pem_data)
|
|
206
|
+
let client_identity_pem = if let Some(pem_data) = &client_pem_data {
|
|
207
|
+
Some(pem_data.clone())
|
|
208
|
+
} else if let Some(pem_path) = &client_pem {
|
|
209
|
+
Some(fs::read(pem_path).map_err(|e| map_anyhow_error(anyhow::Error::new(e)))?)
|
|
210
|
+
} else {
|
|
211
|
+
None
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
if let Some(pem_bytes) = client_identity_pem {
|
|
215
|
+
let identity = Identity::from_pem(&pem_bytes).map_err(map_reqwest_error)?;
|
|
203
216
|
client_builder = client_builder.identity(identity);
|
|
204
217
|
}
|
|
205
218
|
} else {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
1
3
|
import http.server
|
|
2
4
|
import os
|
|
3
5
|
import ssl
|
|
@@ -7,8 +9,8 @@ import unittest
|
|
|
7
9
|
|
|
8
10
|
import trustme
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
from httpr import Client
|
|
12
|
+
import httpr
|
|
13
|
+
from httpr import AsyncClient, Client
|
|
12
14
|
|
|
13
15
|
|
|
14
16
|
class SSLTestHandler(http.server.BaseHTTPRequestHandler):
|
|
@@ -21,8 +23,6 @@ class SSLTestHandler(http.server.BaseHTTPRequestHandler):
|
|
|
21
23
|
self.wfile.write(b"OK")
|
|
22
24
|
|
|
23
25
|
def finish(self):
|
|
24
|
-
import contextlib
|
|
25
|
-
|
|
26
26
|
try:
|
|
27
27
|
self.wfile.flush()
|
|
28
28
|
self.request.settimeout(1.0)
|
|
@@ -94,6 +94,30 @@ class TestClientSSL(unittest.TestCase):
|
|
|
94
94
|
self.assertEqual(response.status_code, 200)
|
|
95
95
|
self.assertEqual(response.text, "OK")
|
|
96
96
|
|
|
97
|
+
def test_valid_ssl_connection_with_pem_data(self):
|
|
98
|
+
"""A connection with client_pem_data (bytes) should work the same as client_pem (path)."""
|
|
99
|
+
# Read the certificate file into memory
|
|
100
|
+
with open(self.client_cert_path, "rb") as f:
|
|
101
|
+
cert_data = f.read()
|
|
102
|
+
|
|
103
|
+
# Use client_pem_data instead of client_pem
|
|
104
|
+
with Client(client_pem_data=cert_data, ca_cert_file=self.client_ca_path) as client:
|
|
105
|
+
response = client.get(f"https://localhost:{self.server_port}")
|
|
106
|
+
self.assertEqual(response.status_code, 200)
|
|
107
|
+
self.assertEqual(response.text, "OK")
|
|
108
|
+
|
|
109
|
+
def test_client_pem_and_data_are_mutually_exclusive(self):
|
|
110
|
+
"""Passing both client_pem and client_pem_data should raise a ValueError."""
|
|
111
|
+
with open(self.client_cert_path, "rb") as f:
|
|
112
|
+
cert_data = f.read()
|
|
113
|
+
|
|
114
|
+
with self.assertRaises(ValueError):
|
|
115
|
+
Client(
|
|
116
|
+
client_pem=self.client_cert_path,
|
|
117
|
+
client_pem_data=cert_data,
|
|
118
|
+
ca_cert_file=self.client_ca_path,
|
|
119
|
+
)
|
|
120
|
+
|
|
97
121
|
def test_missing_client_cert(self):
|
|
98
122
|
"""Omitting the client certificate should fail the handshake (server requires it)."""
|
|
99
123
|
with Client(ca_cert_file=self.client_ca_path) as client:
|
|
@@ -102,9 +126,6 @@ class TestClientSSL(unittest.TestCase):
|
|
|
102
126
|
|
|
103
127
|
def test_invalid_client_cert_path(self):
|
|
104
128
|
"""Providing a non-existent client certificate file should raise an error."""
|
|
105
|
-
# Import httpr to use the correct exception type
|
|
106
|
-
import httpr
|
|
107
|
-
|
|
108
129
|
with self.assertRaises(httpr.RequestError):
|
|
109
130
|
# Assuming your Client loads the file on initialization.
|
|
110
131
|
with Client(client_pem="nonexistent.pem", ca_cert_file=self.client_ca_path) as client:
|
|
@@ -126,6 +147,55 @@ class TestClientSSL(unittest.TestCase):
|
|
|
126
147
|
with self.assertRaises(Exception):
|
|
127
148
|
client.get(f"https://localhost:{self.server_port}")
|
|
128
149
|
|
|
150
|
+
def test_async_client_with_pem_data(self):
|
|
151
|
+
"""AsyncClient with client_pem_data should work the same as sync Client."""
|
|
152
|
+
with open(self.client_cert_path, "rb") as f:
|
|
153
|
+
cert_data = f.read()
|
|
154
|
+
|
|
155
|
+
async def make_request():
|
|
156
|
+
async with AsyncClient(
|
|
157
|
+
client_pem_data=cert_data, ca_cert_file=self.client_ca_path
|
|
158
|
+
) as client:
|
|
159
|
+
return await client.get(f"https://localhost:{self.server_port}")
|
|
160
|
+
|
|
161
|
+
response = asyncio.run(make_request())
|
|
162
|
+
self.assertEqual(response.status_code, 200)
|
|
163
|
+
self.assertEqual(response.text, "OK")
|
|
164
|
+
|
|
165
|
+
def test_request_function_with_pem_data(self):
|
|
166
|
+
"""The request() convenience function should accept client_pem_data."""
|
|
167
|
+
with open(self.client_cert_path, "rb") as f:
|
|
168
|
+
cert_data = f.read()
|
|
169
|
+
|
|
170
|
+
response = httpr.request(
|
|
171
|
+
"GET",
|
|
172
|
+
f"https://localhost:{self.server_port}",
|
|
173
|
+
client_pem_data=cert_data,
|
|
174
|
+
ca_cert_file=self.client_ca_path,
|
|
175
|
+
)
|
|
176
|
+
self.assertEqual(response.status_code, 200)
|
|
177
|
+
self.assertEqual(response.text, "OK")
|
|
178
|
+
|
|
179
|
+
def test_invalid_pem_data_raises_error(self):
|
|
180
|
+
"""Malformed PEM data should raise an appropriate error."""
|
|
181
|
+
with self.assertRaises(httpr.RequestError):
|
|
182
|
+
Client(
|
|
183
|
+
client_pem_data=b"not valid pem data",
|
|
184
|
+
ca_cert_file=self.client_ca_path,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def test_async_client_pem_and_data_are_mutually_exclusive(self):
|
|
188
|
+
"""AsyncClient should also enforce mutual exclusivity."""
|
|
189
|
+
with open(self.client_cert_path, "rb") as f:
|
|
190
|
+
cert_data = f.read()
|
|
191
|
+
|
|
192
|
+
with self.assertRaises(ValueError):
|
|
193
|
+
AsyncClient(
|
|
194
|
+
client_pem=self.client_cert_path,
|
|
195
|
+
client_pem_data=cert_data,
|
|
196
|
+
ca_cert_file=self.client_ca_path,
|
|
197
|
+
)
|
|
198
|
+
|
|
129
199
|
|
|
130
200
|
if __name__ == "__main__":
|
|
131
201
|
unittest.main()
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
name: mkdocs
|
|
2
|
-
on:
|
|
3
|
-
push:
|
|
4
|
-
branches:
|
|
5
|
-
- master
|
|
6
|
-
- main
|
|
7
|
-
permissions:
|
|
8
|
-
contents: write
|
|
9
|
-
jobs:
|
|
10
|
-
deploy:
|
|
11
|
-
runs-on: ubuntu-latest
|
|
12
|
-
steps:
|
|
13
|
-
- uses: actions/checkout@v4
|
|
14
|
-
- name: Configure Git Credentials
|
|
15
|
-
run: |
|
|
16
|
-
git config user.name github-actions[bot]
|
|
17
|
-
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
|
|
18
|
-
- uses: astral-sh/setup-uv@v7
|
|
19
|
-
with:
|
|
20
|
-
python-version: "3.12"
|
|
21
|
-
enable-cache: false
|
|
22
|
-
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
|
|
23
|
-
- uses: actions/cache@v4
|
|
24
|
-
with:
|
|
25
|
-
key: mkdocs-material-${{ env.cache_id }}
|
|
26
|
-
path: .cache
|
|
27
|
-
restore-keys: |
|
|
28
|
-
mkdocs-material-
|
|
29
|
-
- run: uv sync --extra docs
|
|
30
|
-
- run: uv run mkdocs gh-deploy --force
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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
|