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.
Files changed (79) hide show
  1. {httpr-0.3.1 → httpr-0.3.2}/.github/workflows/CI.yml +14 -6
  2. httpr-0.3.2/.github/workflows/mkdocs.yml +88 -0
  3. {httpr-0.3.1 → httpr-0.3.2}/CLAUDE.md +2 -1
  4. {httpr-0.3.1 → httpr-0.3.2}/Cargo.lock +1 -1
  5. {httpr-0.3.1 → httpr-0.3.2}/Cargo.toml +1 -1
  6. {httpr-0.3.1 → httpr-0.3.2}/PKG-INFO +2 -2
  7. {httpr-0.3.1 → httpr-0.3.2}/README.md +1 -1
  8. {httpr-0.3.1 → httpr-0.3.2}/docs/advanced/ssl-tls.md +27 -0
  9. {httpr-0.3.1 → httpr-0.3.2}/httpr/__init__.py +14 -1
  10. {httpr-0.3.1 → httpr-0.3.2}/httpr/httpr.pyi +5 -0
  11. {httpr-0.3.1 → httpr-0.3.2}/pyproject.toml +1 -1
  12. {httpr-0.3.1 → httpr-0.3.2}/src/lib.rs +19 -6
  13. {httpr-0.3.1 → httpr-0.3.2}/tests/unit/test_ssl.py +77 -7
  14. httpr-0.3.1/.github/workflows/mkdocs.yml +0 -30
  15. {httpr-0.3.1 → httpr-0.3.2}/.github/actions/set-version/action.yml +0 -0
  16. {httpr-0.3.1 → httpr-0.3.2}/.github/copilot-instructions.md +0 -0
  17. {httpr-0.3.1 → httpr-0.3.2}/.github/workflows/CI.yml.backup +0 -0
  18. {httpr-0.3.1 → httpr-0.3.2}/.github/workflows/benchmark.yml +0 -0
  19. {httpr-0.3.1 → httpr-0.3.2}/.github/workflows/claude-code-review.yml +0 -0
  20. {httpr-0.3.1 → httpr-0.3.2}/.github/workflows/claude.yml +0 -0
  21. {httpr-0.3.1 → httpr-0.3.2}/.github/workflows/copilot-setup-steps.yml +0 -0
  22. {httpr-0.3.1 → httpr-0.3.2}/.github/workflows/set_version.py +0 -0
  23. {httpr-0.3.1 → httpr-0.3.2}/.gitignore +0 -0
  24. {httpr-0.3.1 → httpr-0.3.2}/.pre-commit-config.yaml +0 -0
  25. {httpr-0.3.1 → httpr-0.3.2}/LICENSE +0 -0
  26. {httpr-0.3.1 → httpr-0.3.2}/Taskfile.yaml +0 -0
  27. {httpr-0.3.1 → httpr-0.3.2}/benchmark/README.md +0 -0
  28. {httpr-0.3.1 → httpr-0.3.2}/benchmark/__init__.py +0 -0
  29. {httpr-0.3.1 → httpr-0.3.2}/benchmark/benchmark.py +0 -0
  30. {httpr-0.3.1 → httpr-0.3.2}/benchmark/benchmark_cbor.py +0 -0
  31. {httpr-0.3.1 → httpr-0.3.2}/benchmark/generate_image.py +0 -0
  32. {httpr-0.3.1 → httpr-0.3.2}/benchmark/server.py +0 -0
  33. {httpr-0.3.1 → httpr-0.3.2}/benchmark.jpg +0 -0
  34. {httpr-0.3.1 → httpr-0.3.2}/docs/advanced/cookies.md +0 -0
  35. {httpr-0.3.1 → httpr-0.3.2}/docs/advanced/index.md +0 -0
  36. {httpr-0.3.1 → httpr-0.3.2}/docs/advanced/proxy.md +0 -0
  37. {httpr-0.3.1 → httpr-0.3.2}/docs/api/async-client.md +0 -0
  38. {httpr-0.3.1 → httpr-0.3.2}/docs/api/client.md +0 -0
  39. {httpr-0.3.1 → httpr-0.3.2}/docs/api/functions.md +0 -0
  40. {httpr-0.3.1 → httpr-0.3.2}/docs/api/index.md +0 -0
  41. {httpr-0.3.1 → httpr-0.3.2}/docs/api/response.md +0 -0
  42. {httpr-0.3.1 → httpr-0.3.2}/docs/index.md +0 -0
  43. {httpr-0.3.1 → httpr-0.3.2}/docs/quickstart.md +0 -0
  44. {httpr-0.3.1 → httpr-0.3.2}/docs/tutorial/async.md +0 -0
  45. {httpr-0.3.1 → httpr-0.3.2}/docs/tutorial/authentication.md +0 -0
  46. {httpr-0.3.1 → httpr-0.3.2}/docs/tutorial/index.md +0 -0
  47. {httpr-0.3.1 → httpr-0.3.2}/docs/tutorial/making-requests.md +0 -0
  48. {httpr-0.3.1 → httpr-0.3.2}/docs/tutorial/response-handling.md +0 -0
  49. {httpr-0.3.1 → httpr-0.3.2}/docs/writings/index.md +0 -0
  50. {httpr-0.3.1 → httpr-0.3.2}/docs/writings/posts/2025-02-24-python-http-clients-suck.md +0 -0
  51. {httpr-0.3.1 → httpr-0.3.2}/httpr/py.typed +0 -0
  52. {httpr-0.3.1 → httpr-0.3.2}/httpr.code-workspace +0 -0
  53. {httpr-0.3.1 → httpr-0.3.2}/mkdocs.yml +0 -0
  54. {httpr-0.3.1 → httpr-0.3.2}/scripts/generate_certs.py +0 -0
  55. {httpr-0.3.1 → httpr-0.3.2}/src/exceptions.rs +0 -0
  56. {httpr-0.3.1 → httpr-0.3.2}/src/response.rs +0 -0
  57. {httpr-0.3.1 → httpr-0.3.2}/src/traits.rs +0 -0
  58. {httpr-0.3.1 → httpr-0.3.2}/src/utils.rs +0 -0
  59. {httpr-0.3.1 → httpr-0.3.2}/tests/benchmark/__init__.py +0 -0
  60. {httpr-0.3.1 → httpr-0.3.2}/tests/benchmark/test_performance.py +0 -0
  61. {httpr-0.3.1 → httpr-0.3.2}/tests/conftest.py +0 -0
  62. {httpr-0.3.1 → httpr-0.3.2}/tests/e2e/__init__.py +0 -0
  63. {httpr-0.3.1 → httpr-0.3.2}/tests/e2e/test_async.py +0 -0
  64. {httpr-0.3.1 → httpr-0.3.2}/tests/e2e/test_auth.py +0 -0
  65. {httpr-0.3.1 → httpr-0.3.2}/tests/e2e/test_redirects.py +0 -0
  66. {httpr-0.3.1 → httpr-0.3.2}/tests/e2e/test_ssl.py +0 -0
  67. {httpr-0.3.1 → httpr-0.3.2}/tests/e2e/test_streaming.py +0 -0
  68. {httpr-0.3.1 → httpr-0.3.2}/tests/e2e/test_uploads.py +0 -0
  69. {httpr-0.3.1 → httpr-0.3.2}/tests/unit/__init__.py +0 -0
  70. {httpr-0.3.1 → httpr-0.3.2}/tests/unit/cbor_test_server.py +0 -0
  71. {httpr-0.3.1 → httpr-0.3.2}/tests/unit/httpx_conns.py +0 -0
  72. {httpr-0.3.1 → httpr-0.3.2}/tests/unit/test_asyncclient.py +0 -0
  73. {httpr-0.3.1 → httpr-0.3.2}/tests/unit/test_cbor.py +0 -0
  74. {httpr-0.3.1 → httpr-0.3.2}/tests/unit/test_client.py +0 -0
  75. {httpr-0.3.1 → httpr-0.3.2}/tests/unit/test_defs.py +0 -0
  76. {httpr-0.3.1 → httpr-0.3.2}/tests/unit/test_docs.py +0 -0
  77. {httpr-0.3.1 → httpr-0.3.2}/tests/unit/test_exceptions.py +0 -0
  78. {httpr-0.3.1 → httpr-0.3.2}/tests/unit/test_streaming.py +0 -0
  79. {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 commit benchmark image
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
- # Checkout main branch to avoid detached HEAD issues
444
- git fetch origin main
445
- git checkout main
446
- git add *.jpg
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 main
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
@@ -481,7 +481,7 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
481
481
 
482
482
  [[package]]
483
483
  name = "httpr"
484
- version = "0.3.1"
484
+ version = "0.3.2"
485
485
  dependencies = [
486
486
  "anyhow",
487
487
  "bytes",
@@ -2,7 +2,7 @@
2
2
  name = "httpr"
3
3
  # Version is set dynamically by CI from git tags (e.g., v1.2.3 -> 1.2.3).
4
4
  # Do not edit manually. See .github/actions/set-version/ for details.
5
- version = "0.3.1"
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.1
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
- ![](https://github.com/thomasht86/httpr/blob/main/benchmark.jpg?raw=true)
126
+ ![](https://thomasht86.github.io/httpr/benchmark.jpg)
127
127
 
128
128
  ## Usage
129
129
 
@@ -63,7 +63,7 @@ pip install -U httpr
63
63
 
64
64
  ## Benchmark
65
65
 
66
- ![](https://github.com/thomasht86/httpr/blob/main/benchmark.jpg?raw=true)
66
+ ![](https://thomasht86.github.io/httpr/benchmark.jpg)
67
67
 
68
68
  ## Usage
69
69
 
@@ -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.1"
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
- if let Some(client_pem) = &client_pem {
199
- let client_identity_pem =
200
- fs::read(client_pem).map_err(|e| map_anyhow_error(anyhow::Error::new(e)))?;
201
- let identity =
202
- Identity::from_pem(&client_identity_pem).map_err(map_reqwest_error)?;
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
- # Import your Client class
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