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.
@@ -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,7 @@
1
+ from http_config.httpx.client import create_async_client as async_client
2
+ from http_config.httpx.client import create_sync_client as sync_client
3
+
4
+ __all__ = [
5
+ "async_client",
6
+ "sync_client",
7
+ ]
@@ -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