http-config 0.1.4__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.
- http_config-0.1.4/PKG-INFO +216 -0
- http_config-0.1.4/README.md +196 -0
- http_config-0.1.4/pyproject.toml +92 -0
- http_config-0.1.4/pyproject.toml.orig +87 -0
- http_config-0.1.4/src/http_config/__init__.py +16 -0
- http_config-0.1.4/src/http_config/_ssl.py +36 -0
- http_config-0.1.4/src/http_config/config.py +90 -0
- http_config-0.1.4/src/http_config/httpx/__init__.py +7 -0
- http_config-0.1.4/src/http_config/httpx/client.py +178 -0
- http_config-0.1.4/src/http_config/httpx/logger.py +131 -0
- http_config-0.1.4/src/http_config/py.typed +0 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: http-config
|
|
3
|
+
Version: 0.1.4
|
|
4
|
+
Summary: A shared config to control HTTP clients
|
|
5
|
+
Author: Kalle M. Krog Aagaard
|
|
6
|
+
Author-email: Kalle M. Krog Aagaard <git@k-moeller.dk>
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
9
|
+
Requires-Dist: pydantic>=2.13.4
|
|
10
|
+
Requires-Dist: pydantic-merge>=0.1.3
|
|
11
|
+
Requires-Dist: httpx>=0.28.1 ; extra == 'httpx'
|
|
12
|
+
Maintainer: Kalle M. Krog Aagaard
|
|
13
|
+
Maintainer-email: Kalle M. Krog Aagaard <git@k-moeller.dk>
|
|
14
|
+
Requires-Python: >=3.13
|
|
15
|
+
Project-URL: Homepage, https://github.com/KalleDK/py-http-config
|
|
16
|
+
Project-URL: Documentation, https://github.com/KalleDK/py-http-config
|
|
17
|
+
Project-URL: Repository, https://github.com/KalleDK/py-http-config.git
|
|
18
|
+
Provides-Extra: httpx
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# HTTP Config
|
|
22
|
+
|
|
23
|
+
Shared, typed configuration for Python HTTP clients. Keep proxy, timeout, connection-limit, TLS, and request
|
|
24
|
+
logging settings in one Pydantic model and reuse them across client integrations.
|
|
25
|
+
|
|
26
|
+
The core configuration models are client-library independent. The project currently provides an integration
|
|
27
|
+
for [HTTPX](https://www.python-httpx.org/), with support for additional HTTP libraries planned for the future.
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
Using `uv`:
|
|
32
|
+
|
|
33
|
+
```console
|
|
34
|
+
uv add "http-config[httpx]"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Using `pip`:
|
|
38
|
+
|
|
39
|
+
```console
|
|
40
|
+
python -m pip install "http-config[httpx]"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The `httpx` extra installs the current HTTPX integration. The configuration models are available without any
|
|
44
|
+
client-library extra, allowing future integrations to be added independently.
|
|
45
|
+
|
|
46
|
+
## Quick Start
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from datetime import timedelta
|
|
50
|
+
|
|
51
|
+
from http_config import HTTPConfig, LimitConfig, TimeoutConfig
|
|
52
|
+
from http_config.httpx import sync_client
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
config = HTTPConfig(
|
|
56
|
+
timeout=TimeoutConfig(
|
|
57
|
+
timeout=timedelta(seconds=10),
|
|
58
|
+
connect_timeout=timedelta(seconds=3),
|
|
59
|
+
),
|
|
60
|
+
limits=LimitConfig(
|
|
61
|
+
max_connections=50,
|
|
62
|
+
max_keepalive_connections=10,
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
with sync_client(config) as client:
|
|
67
|
+
response = client.get("https://httpbin.org/get")
|
|
68
|
+
response.raise_for_status()
|
|
69
|
+
print(response.json())
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
For asynchronous code, use `async_client`:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from http_config.httpx import async_client
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def fetch() -> dict:
|
|
79
|
+
async with async_client() as client:
|
|
80
|
+
response = await client.get("https://httpbin.org/get")
|
|
81
|
+
response.raise_for_status()
|
|
82
|
+
return response.json()
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Configuration
|
|
86
|
+
|
|
87
|
+
`HTTPConfig` supports these settings:
|
|
88
|
+
|
|
89
|
+
| Setting | Type | Description |
|
|
90
|
+
| --- | --- | --- |
|
|
91
|
+
| `proxy` | `str \| None` | Proxy URL passed to the active client integration. |
|
|
92
|
+
| `timeout` | `timedelta \| False \| TimeoutConfig \| None` | Overall or per-operation timeout. `False` disables the timeout. |
|
|
93
|
+
| `limits` | `LimitConfig \| None` | Maximum open and keep-alive connections. |
|
|
94
|
+
| `ssl` | `bool \| SSLConfig \| None` | TLS verification mode and custom certificate sources. |
|
|
95
|
+
| `log_path` | `Path \| None` | Directory where request and response files are recorded by supported integrations. |
|
|
96
|
+
|
|
97
|
+
### Merging Configuration
|
|
98
|
+
|
|
99
|
+
The configuration models use [`pydantic-merge`](https://github.com/tylerjamesyoung/pydantic-merge). Use
|
|
100
|
+
`model_merge()` to create a validated copy with updates applied recursively. Nested configuration is merged
|
|
101
|
+
instead of replaced, so an update to one timeout value preserves the other timeout values:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from datetime import timedelta
|
|
105
|
+
|
|
106
|
+
from http_config import HTTPConfig, TimeoutConfig
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
base = HTTPConfig(
|
|
110
|
+
timeout=TimeoutConfig(
|
|
111
|
+
timeout=timedelta(seconds=10),
|
|
112
|
+
read_timeout=timedelta(seconds=5),
|
|
113
|
+
),
|
|
114
|
+
)
|
|
115
|
+
updated = base.model_merge(
|
|
116
|
+
HTTPConfig(
|
|
117
|
+
timeout=TimeoutConfig(connect_timeout=timedelta(seconds=3)),
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
assert updated.timeout.timeout == timedelta(seconds=10)
|
|
122
|
+
assert updated.timeout.read_timeout == timedelta(seconds=5)
|
|
123
|
+
assert updated.timeout.connect_timeout == timedelta(seconds=3)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### TLS
|
|
127
|
+
|
|
128
|
+
The default is normal certificate verification. Set `ssl=False` to disable verification, or provide a
|
|
129
|
+
custom CA file, directory, or certificate data with `SSLConfig`:
|
|
130
|
+
|
|
131
|
+
If [certifi](https://github.com/certifi/python-certifi) is installed, its CA bundle is used automatically
|
|
132
|
+
when no explicit `cafile` is configured. Install it separately with `uv add certifi` or
|
|
133
|
+
`python -m pip install certifi`. An explicit `cafile` takes precedence; set `ignore_certifi=True` to opt out
|
|
134
|
+
of the automatic certifi fallback.
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from pathlib import Path
|
|
138
|
+
|
|
139
|
+
from http_config import HTTPConfig, SSLConfig
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
config = HTTPConfig(
|
|
143
|
+
ssl=SSLConfig(
|
|
144
|
+
cafile=Path("certificates/ca.pem"),
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
To disable the automatic certifi fallback:
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
from http_config import HTTPConfig, SSLConfig
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
config = HTTPConfig(ssl=SSLConfig(ignore_certifi=True))
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`SSLConfig.create()` is useful when settings come from optional application configuration:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
ssl_setting = SSLConfig.create(insecure=False, cafile=Path("ca.pem"))
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Request Logging
|
|
165
|
+
|
|
166
|
+
Set `log_path` to enable file-based transport logging. The directory is created automatically. Each request
|
|
167
|
+
gets JSON header files and body files when a body is present, paired by a timestamp-based prefix and request
|
|
168
|
+
index.
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from pathlib import Path
|
|
172
|
+
|
|
173
|
+
from http_config import HTTPConfig
|
|
174
|
+
from http_config.httpx import sync_client
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
config = HTTPConfig(log_path=Path("http-logs"))
|
|
178
|
+
with sync_client(config) as client:
|
|
179
|
+
client.get("https://httpbin.org/get")
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## HTTPX Middleware and Authentication
|
|
183
|
+
|
|
184
|
+
The current HTTPX client factories accept an optional transport middleware function and either an HTTPX auth
|
|
185
|
+
object or a factory that receives the client:
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
import httpx
|
|
189
|
+
|
|
190
|
+
from http_config.httpx import sync_client
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def middleware(transport: httpx.BaseTransport) -> httpx.BaseTransport:
|
|
194
|
+
return transport
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
with sync_client(
|
|
198
|
+
middleware=middleware,
|
|
199
|
+
auth=httpx.BasicAuth("user", "password"),
|
|
200
|
+
) as client:
|
|
201
|
+
response = client.get("https://example.com")
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Development
|
|
205
|
+
|
|
206
|
+
Install the test dependencies and run the suite with:
|
|
207
|
+
|
|
208
|
+
```console
|
|
209
|
+
uv sync --group test
|
|
210
|
+
uv run pytest
|
|
211
|
+
uv run ruff check
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## License
|
|
215
|
+
|
|
216
|
+
This project is available under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# HTTP Config
|
|
2
|
+
|
|
3
|
+
Shared, typed configuration for Python HTTP clients. Keep proxy, timeout, connection-limit, TLS, and request
|
|
4
|
+
logging settings in one Pydantic model and reuse them across client integrations.
|
|
5
|
+
|
|
6
|
+
The core configuration models are client-library independent. The project currently provides an integration
|
|
7
|
+
for [HTTPX](https://www.python-httpx.org/), with support for additional HTTP libraries planned for the future.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Using `uv`:
|
|
12
|
+
|
|
13
|
+
```console
|
|
14
|
+
uv add "http-config[httpx]"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Using `pip`:
|
|
18
|
+
|
|
19
|
+
```console
|
|
20
|
+
python -m pip install "http-config[httpx]"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The `httpx` extra installs the current HTTPX integration. The configuration models are available without any
|
|
24
|
+
client-library extra, allowing future integrations to be added independently.
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from datetime import timedelta
|
|
30
|
+
|
|
31
|
+
from http_config import HTTPConfig, LimitConfig, TimeoutConfig
|
|
32
|
+
from http_config.httpx import sync_client
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
config = HTTPConfig(
|
|
36
|
+
timeout=TimeoutConfig(
|
|
37
|
+
timeout=timedelta(seconds=10),
|
|
38
|
+
connect_timeout=timedelta(seconds=3),
|
|
39
|
+
),
|
|
40
|
+
limits=LimitConfig(
|
|
41
|
+
max_connections=50,
|
|
42
|
+
max_keepalive_connections=10,
|
|
43
|
+
),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
with sync_client(config) as client:
|
|
47
|
+
response = client.get("https://httpbin.org/get")
|
|
48
|
+
response.raise_for_status()
|
|
49
|
+
print(response.json())
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
For asynchronous code, use `async_client`:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from http_config.httpx import async_client
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def fetch() -> dict:
|
|
59
|
+
async with async_client() as client:
|
|
60
|
+
response = await client.get("https://httpbin.org/get")
|
|
61
|
+
response.raise_for_status()
|
|
62
|
+
return response.json()
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Configuration
|
|
66
|
+
|
|
67
|
+
`HTTPConfig` supports these settings:
|
|
68
|
+
|
|
69
|
+
| Setting | Type | Description |
|
|
70
|
+
| --- | --- | --- |
|
|
71
|
+
| `proxy` | `str \| None` | Proxy URL passed to the active client integration. |
|
|
72
|
+
| `timeout` | `timedelta \| False \| TimeoutConfig \| None` | Overall or per-operation timeout. `False` disables the timeout. |
|
|
73
|
+
| `limits` | `LimitConfig \| None` | Maximum open and keep-alive connections. |
|
|
74
|
+
| `ssl` | `bool \| SSLConfig \| None` | TLS verification mode and custom certificate sources. |
|
|
75
|
+
| `log_path` | `Path \| None` | Directory where request and response files are recorded by supported integrations. |
|
|
76
|
+
|
|
77
|
+
### Merging Configuration
|
|
78
|
+
|
|
79
|
+
The configuration models use [`pydantic-merge`](https://github.com/tylerjamesyoung/pydantic-merge). Use
|
|
80
|
+
`model_merge()` to create a validated copy with updates applied recursively. Nested configuration is merged
|
|
81
|
+
instead of replaced, so an update to one timeout value preserves the other timeout values:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from datetime import timedelta
|
|
85
|
+
|
|
86
|
+
from http_config import HTTPConfig, TimeoutConfig
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
base = HTTPConfig(
|
|
90
|
+
timeout=TimeoutConfig(
|
|
91
|
+
timeout=timedelta(seconds=10),
|
|
92
|
+
read_timeout=timedelta(seconds=5),
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
updated = base.model_merge(
|
|
96
|
+
HTTPConfig(
|
|
97
|
+
timeout=TimeoutConfig(connect_timeout=timedelta(seconds=3)),
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
assert updated.timeout.timeout == timedelta(seconds=10)
|
|
102
|
+
assert updated.timeout.read_timeout == timedelta(seconds=5)
|
|
103
|
+
assert updated.timeout.connect_timeout == timedelta(seconds=3)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### TLS
|
|
107
|
+
|
|
108
|
+
The default is normal certificate verification. Set `ssl=False` to disable verification, or provide a
|
|
109
|
+
custom CA file, directory, or certificate data with `SSLConfig`:
|
|
110
|
+
|
|
111
|
+
If [certifi](https://github.com/certifi/python-certifi) is installed, its CA bundle is used automatically
|
|
112
|
+
when no explicit `cafile` is configured. Install it separately with `uv add certifi` or
|
|
113
|
+
`python -m pip install certifi`. An explicit `cafile` takes precedence; set `ignore_certifi=True` to opt out
|
|
114
|
+
of the automatic certifi fallback.
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from pathlib import Path
|
|
118
|
+
|
|
119
|
+
from http_config import HTTPConfig, SSLConfig
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
config = HTTPConfig(
|
|
123
|
+
ssl=SSLConfig(
|
|
124
|
+
cafile=Path("certificates/ca.pem"),
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
To disable the automatic certifi fallback:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from http_config import HTTPConfig, SSLConfig
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
config = HTTPConfig(ssl=SSLConfig(ignore_certifi=True))
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`SSLConfig.create()` is useful when settings come from optional application configuration:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
ssl_setting = SSLConfig.create(insecure=False, cafile=Path("ca.pem"))
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Request Logging
|
|
145
|
+
|
|
146
|
+
Set `log_path` to enable file-based transport logging. The directory is created automatically. Each request
|
|
147
|
+
gets JSON header files and body files when a body is present, paired by a timestamp-based prefix and request
|
|
148
|
+
index.
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from pathlib import Path
|
|
152
|
+
|
|
153
|
+
from http_config import HTTPConfig
|
|
154
|
+
from http_config.httpx import sync_client
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
config = HTTPConfig(log_path=Path("http-logs"))
|
|
158
|
+
with sync_client(config) as client:
|
|
159
|
+
client.get("https://httpbin.org/get")
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## HTTPX Middleware and Authentication
|
|
163
|
+
|
|
164
|
+
The current HTTPX client factories accept an optional transport middleware function and either an HTTPX auth
|
|
165
|
+
object or a factory that receives the client:
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
import httpx
|
|
169
|
+
|
|
170
|
+
from http_config.httpx import sync_client
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def middleware(transport: httpx.BaseTransport) -> httpx.BaseTransport:
|
|
174
|
+
return transport
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
with sync_client(
|
|
178
|
+
middleware=middleware,
|
|
179
|
+
auth=httpx.BasicAuth("user", "password"),
|
|
180
|
+
) as client:
|
|
181
|
+
response = client.get("https://example.com")
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Development
|
|
185
|
+
|
|
186
|
+
Install the test dependencies and run the suite with:
|
|
187
|
+
|
|
188
|
+
```console
|
|
189
|
+
uv sync --group test
|
|
190
|
+
uv run pytest
|
|
191
|
+
uv run ruff check
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## License
|
|
195
|
+
|
|
196
|
+
This project is available under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "http-config"
|
|
3
|
+
version = "0.1.4"
|
|
4
|
+
description = "A shared config to control HTTP clients"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
classifiers = [
|
|
8
|
+
"License :: OSI Approved :: MIT License",
|
|
9
|
+
"Programming Language :: Python :: 3.13",
|
|
10
|
+
]
|
|
11
|
+
dependencies = [
|
|
12
|
+
"pydantic>=2.13.4",
|
|
13
|
+
"pydantic-merge>=0.1.3",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[[project.authors]]
|
|
17
|
+
name = "Kalle M. Krog Aagaard"
|
|
18
|
+
email = "git@k-moeller.dk"
|
|
19
|
+
|
|
20
|
+
[[project.maintainers]]
|
|
21
|
+
name = "Kalle M. Krog Aagaard"
|
|
22
|
+
email = "git@k-moeller.dk"
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/KalleDK/py-http-config"
|
|
26
|
+
Documentation = "https://github.com/KalleDK/py-http-config"
|
|
27
|
+
Repository = "https://github.com/KalleDK/py-http-config.git"
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
httpx = ["httpx>=0.28.1"]
|
|
31
|
+
|
|
32
|
+
[dependency-groups]
|
|
33
|
+
test = [
|
|
34
|
+
"pytest>=9.1.1",
|
|
35
|
+
"pytest-cov>=7.1.0",
|
|
36
|
+
]
|
|
37
|
+
stubs = []
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["uv_build>=0.12.4,<0.13.0"]
|
|
41
|
+
build-backend = "uv_build"
|
|
42
|
+
|
|
43
|
+
[tool.uv.build-backend]
|
|
44
|
+
module-name = "http_config"
|
|
45
|
+
|
|
46
|
+
[tool.pyright]
|
|
47
|
+
include = [
|
|
48
|
+
"src",
|
|
49
|
+
"tests",
|
|
50
|
+
"examples",
|
|
51
|
+
]
|
|
52
|
+
typeCheckingMode = "strict"
|
|
53
|
+
pythonVersion = "3.13"
|
|
54
|
+
|
|
55
|
+
[tool.ruff]
|
|
56
|
+
line-length = 120
|
|
57
|
+
target-version = "py313"
|
|
58
|
+
|
|
59
|
+
[tool.ruff.lint]
|
|
60
|
+
extend-select = [
|
|
61
|
+
"E4",
|
|
62
|
+
"E7",
|
|
63
|
+
"E9",
|
|
64
|
+
"F",
|
|
65
|
+
"I",
|
|
66
|
+
"UP",
|
|
67
|
+
"B",
|
|
68
|
+
"SIM",
|
|
69
|
+
"RUF",
|
|
70
|
+
"T20",
|
|
71
|
+
"RET",
|
|
72
|
+
"S",
|
|
73
|
+
"ANN",
|
|
74
|
+
"TC",
|
|
75
|
+
"FA",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
[tool.ruff.lint.per-file-ignores]
|
|
79
|
+
"tests/**/*.py" = ["S101"]
|
|
80
|
+
"examples/**/*.py" = [
|
|
81
|
+
"S101",
|
|
82
|
+
"T20",
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
[tool.ruff.format]
|
|
86
|
+
quote-style = "double"
|
|
87
|
+
indent-style = "space"
|
|
88
|
+
line-ending = "lf"
|
|
89
|
+
|
|
90
|
+
[tool.pytest.ini_options]
|
|
91
|
+
addopts = "-v --cov=http_config --cov-report=term-missing --cov-report=xml"
|
|
92
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "http-config"
|
|
3
|
+
version = "0.1.4"
|
|
4
|
+
description = "A shared config to control HTTP clients"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
authors = [
|
|
8
|
+
{name = "Kalle M. Krog Aagaard", email = "git@k-moeller.dk"},
|
|
9
|
+
]
|
|
10
|
+
maintainers = [
|
|
11
|
+
{name = "Kalle M. Krog Aagaard", email = "git@k-moeller.dk"},
|
|
12
|
+
]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3.13",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"pydantic>=2.13.4",
|
|
19
|
+
"pydantic-merge>=0.1.3",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/KalleDK/py-http-config"
|
|
24
|
+
Documentation = "https://github.com/KalleDK/py-http-config"
|
|
25
|
+
Repository = "https://github.com/KalleDK/py-http-config.git"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
httpx = [
|
|
29
|
+
"httpx>=0.28.1",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[dependency-groups]
|
|
33
|
+
test = [
|
|
34
|
+
"pytest>=9.1.1",
|
|
35
|
+
"pytest-cov>=7.1.0",
|
|
36
|
+
]
|
|
37
|
+
stubs = []
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["uv_build>=0.12.4,<0.13.0"]
|
|
41
|
+
build-backend = "uv_build"
|
|
42
|
+
|
|
43
|
+
[tool.uv.build-backend]
|
|
44
|
+
module-name = "http_config"
|
|
45
|
+
|
|
46
|
+
[tool.pyright]
|
|
47
|
+
include = ["src", "tests", "examples"]
|
|
48
|
+
typeCheckingMode = "strict"
|
|
49
|
+
pythonVersion = "3.13"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
[tool.ruff]
|
|
53
|
+
line-length = 120
|
|
54
|
+
target-version = "py313"
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint]
|
|
57
|
+
extend-select = [
|
|
58
|
+
"E4", # Import and indentation errors
|
|
59
|
+
"E7", # Statement errors
|
|
60
|
+
"E9", # Runtime and syntax errors
|
|
61
|
+
"F", # Pyflakes correctness checks
|
|
62
|
+
"I", # Import sorting
|
|
63
|
+
"UP", # Python syntax and API upgrades
|
|
64
|
+
"B", # Common bug patterns
|
|
65
|
+
"SIM", # Simplifiable code
|
|
66
|
+
"RUF", # Ruff-specific improvements
|
|
67
|
+
"T20", # See if print or pprint is used in code
|
|
68
|
+
"RET", # Return statements
|
|
69
|
+
"S", # Security issues
|
|
70
|
+
"ANN", # Type annotation issues
|
|
71
|
+
"TC", # Type checking issues
|
|
72
|
+
"FA", # Future annotations issues
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
[tool.ruff.lint.per-file-ignores]
|
|
76
|
+
"tests/**/*.py" = ["S101"]
|
|
77
|
+
"examples/**/*.py" = ["S101", "T20"]
|
|
78
|
+
|
|
79
|
+
[tool.ruff.format]
|
|
80
|
+
quote-style = "double"
|
|
81
|
+
indent-style = "space"
|
|
82
|
+
line-ending = "lf"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
[tool.pytest.ini_options]
|
|
86
|
+
addopts = "-v --cov=http_config --cov-report=term-missing --cov-report=xml"
|
|
87
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from http_config.config import HTTPConfig as HTTPConfig
|
|
4
|
+
from http_config.config import LimitConfig as LimitConfig
|
|
5
|
+
from http_config.config import SSLConfig as SSLConfig
|
|
6
|
+
from http_config.config import TimeoutConfig as TimeoutConfig
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.4"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"HTTPConfig",
|
|
13
|
+
"LimitConfig",
|
|
14
|
+
"SSLConfig",
|
|
15
|
+
"TimeoutConfig",
|
|
16
|
+
]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ssl as _ssl
|
|
4
|
+
|
|
5
|
+
from http_config.config import CERTIFI_PATH, SSLConfig
|
|
6
|
+
|
|
7
|
+
# region SSL
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_insecure_ssl_context() -> _ssl.SSLContext:
|
|
11
|
+
ctx = _ssl.SSLContext(_ssl.PROTOCOL_TLS_CLIENT)
|
|
12
|
+
ctx.check_hostname = False
|
|
13
|
+
ctx.verify_mode = _ssl.CERT_NONE
|
|
14
|
+
return ctx
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def create_ssl_context(ssl_config: SSLConfig | bool | None) -> _ssl.SSLContext:
|
|
18
|
+
match ssl_config:
|
|
19
|
+
case None:
|
|
20
|
+
return _ssl.create_default_context()
|
|
21
|
+
case SSLConfig() as _ssl_config:
|
|
22
|
+
return _ssl.create_default_context(
|
|
23
|
+
cafile=_ssl_config.cafile_normalized,
|
|
24
|
+
capath=_ssl_config.capath,
|
|
25
|
+
cadata=_ssl_config.cadata,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
case bool():
|
|
29
|
+
if ssl_config is False:
|
|
30
|
+
return create_insecure_ssl_context()
|
|
31
|
+
return _ssl.create_default_context(
|
|
32
|
+
cafile=CERTIFI_PATH,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# endregion
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
from datetime import timedelta # noqa: TC003
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic_merge import BaseModel
|
|
8
|
+
|
|
9
|
+
# region SSL
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_certifi() -> pathlib.Path | None:
|
|
13
|
+
try:
|
|
14
|
+
import certifi
|
|
15
|
+
|
|
16
|
+
return pathlib.Path(certifi.where())
|
|
17
|
+
except ImportError:
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
CERTIFI_PATH: pathlib.Path | None = load_certifi()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SSLConfig(BaseModel):
|
|
25
|
+
cafile: pathlib.Path | None = None
|
|
26
|
+
capath: pathlib.Path | None = None
|
|
27
|
+
cadata: str | bytes | None = None
|
|
28
|
+
ignore_certifi: bool | None = None
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def cafile_normalized(self) -> pathlib.Path | None:
|
|
32
|
+
if self.cafile is not None:
|
|
33
|
+
return self.cafile
|
|
34
|
+
|
|
35
|
+
if self.ignore_certifi is True:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
return CERTIFI_PATH
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def create(
|
|
42
|
+
cls,
|
|
43
|
+
insecure: bool | None = None,
|
|
44
|
+
cafile: pathlib.Path | None = None,
|
|
45
|
+
capath: pathlib.Path | None = None,
|
|
46
|
+
cadata: str | bytes | None = None,
|
|
47
|
+
) -> SSLConfig | bool | None:
|
|
48
|
+
if insecure is True:
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
if cafile is None and capath is None and cadata is None:
|
|
52
|
+
if insecure is None:
|
|
53
|
+
return None
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
return cls(cafile=cafile, capath=capath, cadata=cadata)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# endregion
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# region Timeout
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TimeoutConfig(BaseModel):
|
|
66
|
+
timeout: timedelta | Literal[False] | None = None
|
|
67
|
+
read_timeout: timedelta | Literal[False] | None = None
|
|
68
|
+
write_timeout: timedelta | Literal[False] | None = None
|
|
69
|
+
connect_timeout: timedelta | Literal[False] | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# endregion
|
|
73
|
+
|
|
74
|
+
# region Limits
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class LimitConfig(BaseModel):
|
|
78
|
+
max_connections: int | None = None
|
|
79
|
+
max_keepalive_connections: int | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# endregion
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class HTTPConfig(BaseModel):
|
|
86
|
+
proxy: str | None = None
|
|
87
|
+
timeout: timedelta | Literal[False] | TimeoutConfig | None = None
|
|
88
|
+
limits: LimitConfig | None = None
|
|
89
|
+
ssl: bool | SSLConfig | None = None
|
|
90
|
+
log_path: pathlib.Path | None = None
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
from typing import TYPE_CHECKING, Literal, NotRequired, TypedDict
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from http_config._ssl import create_ssl_context
|
|
9
|
+
from http_config.config import HTTPConfig, LimitConfig, TimeoutConfig
|
|
10
|
+
from http_config.httpx.logger import AsyncTransportLogger, SyncTransportLogger
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
import ssl
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# region Timeout
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TimeoutDict(TypedDict):
|
|
23
|
+
timeout: NotRequired[float | None]
|
|
24
|
+
read: NotRequired[float | None]
|
|
25
|
+
write: NotRequired[float | None]
|
|
26
|
+
connect: NotRequired[float | None]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def create_timeout(value: timedelta | Literal[False] | TimeoutConfig | None) -> httpx.Timeout | None:
|
|
30
|
+
match value:
|
|
31
|
+
case None:
|
|
32
|
+
return None
|
|
33
|
+
case TimeoutConfig() as v:
|
|
34
|
+
if v.timeout is None and v.read_timeout is None and v.write_timeout is None and v.connect_timeout is None:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
timeout_dct: TimeoutDict = {}
|
|
38
|
+
if v.timeout is not None:
|
|
39
|
+
timeout_dct["timeout"] = None if v.timeout is False else v.timeout.total_seconds()
|
|
40
|
+
if v.read_timeout is not None:
|
|
41
|
+
timeout_dct["read"] = None if v.read_timeout is False else v.read_timeout.total_seconds()
|
|
42
|
+
if v.write_timeout is not None:
|
|
43
|
+
timeout_dct["write"] = None if v.write_timeout is False else v.write_timeout.total_seconds()
|
|
44
|
+
if v.connect_timeout is not None:
|
|
45
|
+
timeout_dct["connect"] = None if v.connect_timeout is False else v.connect_timeout.total_seconds()
|
|
46
|
+
return httpx.Timeout(**timeout_dct)
|
|
47
|
+
case timedelta():
|
|
48
|
+
return httpx.Timeout(timeout=value.total_seconds())
|
|
49
|
+
case False:
|
|
50
|
+
return httpx.Timeout(timeout=None)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# endregion
|
|
54
|
+
|
|
55
|
+
# region Limits
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def create_limits(value: LimitConfig | None) -> httpx.Limits | None:
|
|
59
|
+
if value is None:
|
|
60
|
+
return None
|
|
61
|
+
return httpx.Limits(
|
|
62
|
+
max_connections=value.max_connections,
|
|
63
|
+
max_keepalive_connections=value.max_keepalive_connections,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# endregion
|
|
68
|
+
|
|
69
|
+
# region Transport
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class TransportDict(TypedDict):
|
|
73
|
+
verify: ssl.SSLContext
|
|
74
|
+
proxy: NotRequired[str]
|
|
75
|
+
limits: NotRequired[httpx.Limits]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def create_transport_dct(http_config: HTTPConfig | None) -> TransportDict:
|
|
79
|
+
if http_config is None:
|
|
80
|
+
http_config = HTTPConfig()
|
|
81
|
+
|
|
82
|
+
transport_dct: TransportDict = {
|
|
83
|
+
"verify": create_ssl_context(http_config.ssl),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (proxy := http_config.proxy) is not None:
|
|
87
|
+
transport_dct["proxy"] = proxy
|
|
88
|
+
|
|
89
|
+
if (limits := create_limits(http_config.limits)) is not None:
|
|
90
|
+
transport_dct["limits"] = limits
|
|
91
|
+
|
|
92
|
+
return transport_dct
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def create_async_transport(
|
|
96
|
+
http_config: HTTPConfig | None = None,
|
|
97
|
+
middleware: Callable[[httpx.AsyncBaseTransport], httpx.AsyncBaseTransport] | None = None,
|
|
98
|
+
) -> httpx.AsyncBaseTransport:
|
|
99
|
+
|
|
100
|
+
transport = httpx.AsyncHTTPTransport(**create_transport_dct(http_config))
|
|
101
|
+
if http_config is not None and http_config.log_path is not None:
|
|
102
|
+
transport = AsyncTransportLogger(transport, http_config.log_path)
|
|
103
|
+
if middleware is not None:
|
|
104
|
+
transport = middleware(transport)
|
|
105
|
+
return transport
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def create_sync_transport(
|
|
109
|
+
http_config: HTTPConfig | None = None,
|
|
110
|
+
middleware: Callable[[httpx.BaseTransport], httpx.BaseTransport] | None = None,
|
|
111
|
+
) -> httpx.BaseTransport:
|
|
112
|
+
|
|
113
|
+
transport = httpx.HTTPTransport(**create_transport_dct(http_config))
|
|
114
|
+
if http_config is not None and http_config.log_path is not None:
|
|
115
|
+
transport = SyncTransportLogger(transport, http_config.log_path)
|
|
116
|
+
if middleware is not None:
|
|
117
|
+
transport = middleware(transport)
|
|
118
|
+
|
|
119
|
+
return transport
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# endregion
|
|
123
|
+
|
|
124
|
+
# region Client
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ClientDict(TypedDict):
|
|
128
|
+
timeout: NotRequired[httpx.Timeout]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _create_client_dict(config: HTTPConfig) -> ClientDict:
|
|
132
|
+
dct: ClientDict = {}
|
|
133
|
+
if (timeout := create_timeout(config.timeout)) is not None:
|
|
134
|
+
dct["timeout"] = timeout
|
|
135
|
+
return dct
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def create_async_client(
|
|
139
|
+
http_config: HTTPConfig | None = None,
|
|
140
|
+
middleware: Callable[[httpx.AsyncBaseTransport], httpx.AsyncBaseTransport] | None = None,
|
|
141
|
+
auth: Callable[[httpx.AsyncClient], httpx.Auth] | httpx.Auth | None = None,
|
|
142
|
+
) -> httpx.AsyncClient:
|
|
143
|
+
|
|
144
|
+
if http_config is None:
|
|
145
|
+
http_config = HTTPConfig()
|
|
146
|
+
|
|
147
|
+
client_dct = _create_client_dict(http_config)
|
|
148
|
+
|
|
149
|
+
client = httpx.AsyncClient(**client_dct, transport=create_async_transport(http_config, middleware=middleware))
|
|
150
|
+
|
|
151
|
+
if isinstance(auth, httpx.Auth):
|
|
152
|
+
client.auth = auth
|
|
153
|
+
elif auth is not None:
|
|
154
|
+
client.auth = auth(client)
|
|
155
|
+
return client
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def create_sync_client(
|
|
159
|
+
http_config: HTTPConfig | None = None,
|
|
160
|
+
middleware: Callable[[httpx.BaseTransport], httpx.BaseTransport] | None = None,
|
|
161
|
+
auth: Callable[[httpx.Client], httpx.Auth] | httpx.Auth | None = None,
|
|
162
|
+
) -> httpx.Client:
|
|
163
|
+
|
|
164
|
+
if http_config is None:
|
|
165
|
+
http_config = HTTPConfig()
|
|
166
|
+
|
|
167
|
+
client_dct = _create_client_dict(http_config)
|
|
168
|
+
|
|
169
|
+
client = httpx.Client(**client_dct, transport=create_sync_transport(http_config, middleware=middleware))
|
|
170
|
+
|
|
171
|
+
if isinstance(auth, httpx.Auth):
|
|
172
|
+
client.auth = auth
|
|
173
|
+
elif auth is not None:
|
|
174
|
+
client.auth = auth(client)
|
|
175
|
+
return client
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# endregion
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import dataclasses
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
import pathlib
|
|
13
|
+
from collections.abc import Generator
|
|
14
|
+
from zoneinfo import ZoneInfo
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# region Logger
|
|
18
|
+
|
|
19
|
+
TZ: ZoneInfo | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def now() -> datetime:
|
|
23
|
+
return datetime.now(TZ)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclasses.dataclass
|
|
27
|
+
class FileSession:
|
|
28
|
+
log_dir: pathlib.Path
|
|
29
|
+
prefix: str
|
|
30
|
+
suffix: str
|
|
31
|
+
idx: int
|
|
32
|
+
|
|
33
|
+
def _write_req_headers(self, request: httpx.Request) -> None:
|
|
34
|
+
data = {
|
|
35
|
+
"method": request.method,
|
|
36
|
+
"url": str(request.url),
|
|
37
|
+
"headers": dict(request.headers),
|
|
38
|
+
}
|
|
39
|
+
self.log_dir.joinpath(f"{self.prefix}_{self.idx:04d}_REQ_HEADERS.json").write_text(json.dumps(data, indent=2))
|
|
40
|
+
|
|
41
|
+
def _write_req_body(self, data: bytes) -> None:
|
|
42
|
+
if not data:
|
|
43
|
+
return
|
|
44
|
+
self.log_dir.joinpath(f"{self.prefix}_{self.idx:04d}_REQ_BODY{self.suffix}").write_bytes(data)
|
|
45
|
+
|
|
46
|
+
def _write_res_headers(self, response: httpx.Response) -> None:
|
|
47
|
+
data = {
|
|
48
|
+
"status_code": response.status_code,
|
|
49
|
+
"headers": dict(response.headers),
|
|
50
|
+
}
|
|
51
|
+
self.log_dir.joinpath(f"{self.prefix}_{self.idx:04d}_RES_HEADERS.json").write_text(json.dumps(data, indent=2))
|
|
52
|
+
|
|
53
|
+
def _write_res_body(self, data: bytes) -> None:
|
|
54
|
+
if not data:
|
|
55
|
+
return
|
|
56
|
+
self.log_dir.joinpath(f"{self.prefix}_{self.idx:04d}_RES_BODY{self.suffix}").write_bytes(data)
|
|
57
|
+
|
|
58
|
+
async def awrite_request(self, request: httpx.Request) -> None:
|
|
59
|
+
self._write_req_headers(request)
|
|
60
|
+
self._write_req_body(await request.aread())
|
|
61
|
+
|
|
62
|
+
async def awrite_response(self, response: httpx.Response) -> None:
|
|
63
|
+
self._write_res_headers(response)
|
|
64
|
+
self._write_res_body(await response.aread())
|
|
65
|
+
|
|
66
|
+
def write_request(self, request: httpx.Request) -> None:
|
|
67
|
+
self._write_req_headers(request)
|
|
68
|
+
self._write_req_body(request.read())
|
|
69
|
+
|
|
70
|
+
def write_response(self, response: httpx.Response) -> None:
|
|
71
|
+
self._write_res_headers(response)
|
|
72
|
+
self._write_res_body(response.read())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class FileLogger:
|
|
76
|
+
def __init__(self, log_dir: pathlib.Path, suffix: str = ".txt", create_dir: bool = True) -> None:
|
|
77
|
+
if not log_dir.is_dir():
|
|
78
|
+
if log_dir.exists():
|
|
79
|
+
raise RuntimeError("log_dir is not a directory")
|
|
80
|
+
if not create_dir:
|
|
81
|
+
raise RuntimeError("log_dir does not exists")
|
|
82
|
+
log_dir.mkdir(parents=True)
|
|
83
|
+
|
|
84
|
+
self.log_dir = log_dir
|
|
85
|
+
self.prefix = now().strftime("%Y%m%d_%H%M%S")
|
|
86
|
+
self.suffix = suffix
|
|
87
|
+
self._idx = 0
|
|
88
|
+
|
|
89
|
+
def get_idx(self) -> int:
|
|
90
|
+
idx = self._idx
|
|
91
|
+
self._idx += 1
|
|
92
|
+
return idx
|
|
93
|
+
|
|
94
|
+
@contextlib.contextmanager
|
|
95
|
+
def session(self) -> Generator[FileSession, Any]:
|
|
96
|
+
yield FileSession(self.log_dir, self.prefix, self.suffix, self.get_idx())
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class AsyncTransportLogger(httpx.AsyncBaseTransport):
|
|
100
|
+
def __init__(self, transport: httpx.AsyncBaseTransport, log_dir: pathlib.Path, suffix: str = ".txt") -> None:
|
|
101
|
+
self._logger = FileLogger(log_dir, suffix=suffix)
|
|
102
|
+
self.transport = transport
|
|
103
|
+
|
|
104
|
+
async def handle_async_request(
|
|
105
|
+
self,
|
|
106
|
+
request: httpx.Request,
|
|
107
|
+
) -> httpx.Response:
|
|
108
|
+
with self._logger.session() as session:
|
|
109
|
+
await session.awrite_request(request)
|
|
110
|
+
response = await self.transport.handle_async_request(request)
|
|
111
|
+
await session.awrite_response(response)
|
|
112
|
+
return response
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class SyncTransportLogger(httpx.BaseTransport):
|
|
116
|
+
def __init__(self, transport: httpx.BaseTransport, log_dir: pathlib.Path, suffix: str = ".txt") -> None:
|
|
117
|
+
self._logger = FileLogger(log_dir, suffix=suffix)
|
|
118
|
+
self.transport = transport
|
|
119
|
+
|
|
120
|
+
def handle_request(
|
|
121
|
+
self,
|
|
122
|
+
request: httpx.Request,
|
|
123
|
+
) -> httpx.Response:
|
|
124
|
+
with self._logger.session() as session:
|
|
125
|
+
session.write_request(request)
|
|
126
|
+
response = self.transport.handle_request(request)
|
|
127
|
+
session.write_response(response)
|
|
128
|
+
return response
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# endregion
|
|
File without changes
|