finalsa-common-http-client 0.0.1__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,117 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ *.egg-info/
24
+ .installed.cfg
25
+ *.egg
26
+ MANIFEST
27
+
28
+ # PyInstaller
29
+ # Usually these files are written by a python script from a template
30
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
31
+ *.manifest
32
+ *.spec
33
+
34
+ # Installer logs
35
+ pip-log.txt
36
+ pip-delete-this-directory.txt
37
+
38
+ # Unit test / coverage reports
39
+ htmlcov/
40
+ .tox/
41
+ .nox/
42
+ .coverage
43
+ .coverage.*
44
+ .cache
45
+ nosetests.xml
46
+ coverage.xml
47
+ *.cover
48
+ .hypothesis/
49
+ .pytest_cache/
50
+
51
+ # Translations
52
+ *.mo
53
+ *.pot
54
+
55
+ # Django stuff:
56
+ *.log
57
+ local_settings.py
58
+ db.sqlite3
59
+
60
+ # Flask stuff:
61
+ instance/
62
+ .webassets-cache
63
+
64
+ # Scrapy stuff:
65
+ .scrapy
66
+
67
+ # Sphinx documentation
68
+ docs/_build/
69
+
70
+ # PyBuilder
71
+ target/
72
+
73
+ # Jupyter Notebook
74
+ .ipynb_checkpoints
75
+
76
+ # IPython
77
+ profile_default/
78
+ ipython_config.py
79
+
80
+ # pyenv
81
+ .python-version
82
+
83
+ # celery beat schedule file
84
+ celerybeat-schedule
85
+
86
+ # SageMath parsed files
87
+ *.sage.py
88
+
89
+ # Environments
90
+ .env
91
+ .venv
92
+ env/
93
+ venv/
94
+ ENV/
95
+ env.bak/
96
+ venv.bak/
97
+
98
+ # Spyder project settings
99
+ .spyderproject
100
+ .spyproject
101
+
102
+ # Rope project settings
103
+ .ropeproject
104
+
105
+ # mkdocs documentation
106
+ /site
107
+
108
+ # mypy
109
+ .mypy_cache/
110
+ .dmypy.json
111
+ dmypy.json
112
+
113
+ # Pyre type checker
114
+ .pyre/
115
+ test.db
116
+
117
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 Luis Diego Jiménez Delgado
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,235 @@
1
+ Metadata-Version: 2.4
2
+ Name: finalsa-common-http-client
3
+ Version: 0.0.1
4
+ Summary: HTTP client library for common data types used in business applications
5
+ Project-URL: Homepage, https://github.com/finalsa/finalsa-http-client
6
+ Project-URL: Documentation, https://github.com/finalsa/finalsa-http-client#readme
7
+ Project-URL: Repository, https://github.com/finalsa/finalsa-http-client.git
8
+ Project-URL: Issues, https://github.com/finalsa/finalsa-http-client/issues
9
+ Project-URL: Changelog, https://github.com/finalsa/finalsa-http-client/blob/main/CHANGELOG.md
10
+ Author-email: Luis Jimenez <luis@finalsa.com>
11
+ License: MIT License
12
+
13
+ Copyright (c) 2021 Luis Diego Jiménez Delgado
14
+
15
+ Permission is hereby granted, free of charge, to any person obtaining a copy
16
+ of this software and associated documentation files (the "Software"), to deal
17
+ in the Software without restriction, including without limitation the rights
18
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
19
+ copies of the Software, and to permit persons to whom the Software is
20
+ furnished to do so, subject to the following conditions:
21
+
22
+ The above copyright notice and this permission notice shall be included in all
23
+ copies or substantial portions of the Software.
24
+
25
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
26
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
27
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
28
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
29
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
30
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
31
+ SOFTWARE.
32
+ License-File: LICENSE.md
33
+ Keywords: client,finalsa,http
34
+ Classifier: Development Status :: 4 - Beta
35
+ Classifier: Intended Audience :: Developers
36
+ Classifier: License :: OSI Approved :: MIT License
37
+ Classifier: Operating System :: OS Independent
38
+ Classifier: Programming Language :: Python :: 3
39
+ Classifier: Programming Language :: Python :: 3.9
40
+ Classifier: Programming Language :: Python :: 3.10
41
+ Classifier: Programming Language :: Python :: 3.11
42
+ Classifier: Programming Language :: Python :: 3.12
43
+ Classifier: Topic :: Software Development :: Internationalization
44
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
45
+ Classifier: Topic :: Software Development :: Localization
46
+ Classifier: Typing :: Typed
47
+ Requires-Python: >=3.9
48
+ Requires-Dist: aiohttp>=3.10.5
49
+ Requires-Dist: fastapi>=0.116.1
50
+ Requires-Dist: finalsa-traceability>=1.0.1
51
+ Requires-Dist: requests>=2.32.4
52
+ Description-Content-Type: text/markdown
53
+
54
+ # Finalsa HTTP Client
55
+
56
+ `finalsa-http-client` provides small, well-tested base classes for building HTTP
57
+ clients that share a consistent set of defaults across Finalsa services. One
58
+ implementation wraps `requests` for synchronous workloads and the other wraps
59
+ `aiohttp` for asyncio-aware code.
60
+
61
+ - Normalizes `base_url` values and fills in schemes automatically.
62
+ - Builds a predictable `User-Agent` header (e.g. `finalsa-http-client/0.0.1`).
63
+ - Merges per-request headers with service defaults.
64
+ - Manages the underlying session lifecycle (context manager friendly).
65
+ - Lets you opt into strict `raise_for_status` behavior on every request.
66
+
67
+ ## Installation
68
+
69
+ ```bash
70
+ python -m pip install finalsa-http-client
71
+ ```
72
+
73
+ ## Synchronous usage
74
+
75
+ ```python
76
+ from finalsa.http.sync import BaseSyncHttpClient
77
+
78
+ with BaseSyncHttpClient(
79
+ base_url="https://api.example.test",
80
+ service_name="billing-api",
81
+ default_headers={"X-App": "billing"},
82
+ timeout=5, # seconds
83
+ trust_env=False,
84
+ ) as client:
85
+ response = client.post(
86
+ "/v1/invoices",
87
+ json={"customer_id": "cust_123", "total": 1999},
88
+ params={"dry_run": False},
89
+ )
90
+ print(response.json())
91
+ ```
92
+
93
+ Key synchronous notes:
94
+
95
+ - `base_url` can be provided without a scheme (`api.example.test`), the client
96
+ will inject the configured `default_scheme` (defaults to `http`).
97
+ - Use `.build_url()` when you need the normalized URL without sending a request.
98
+ - `service_name` is used to stamp the `User-Agent`. You can still call
99
+ `.update_default_headers()` later if you need to augment the defaults.
100
+ - Set `trust_env=False` (default is `True`) to keep the underlying `requests`
101
+ session from honoring proxy-related environment variables.
102
+ - Pass `url="https://status.example.test/health"` to `.request()` to bypass the
103
+ configured `base_url`.
104
+
105
+ ## Asynchronous usage
106
+
107
+ ```python
108
+ import asyncio
109
+
110
+ from finalsa.http.async import BaseAsyncHttpClient
111
+
112
+
113
+ async def main() -> None:
114
+ async with BaseAsyncHttpClient(
115
+ base_url="https://api.example.test",
116
+ service_name="billing-api",
117
+ trust_env=True, # honor proxy-related env vars
118
+ timeout=10,
119
+ ) as client:
120
+ response = await client.get("/v1/health", params={"extended": True})
121
+ payload = await response.json()
122
+ print(payload)
123
+
124
+
125
+ asyncio.run(main())
126
+ ```
127
+
128
+ Asynchronous specifics:
129
+
130
+ - Timeouts accept either a float (seconds) or an explicit `aiohttp.ClientTimeout`.
131
+ - `service_name` mirrors the sync client parameter and controls the leading
132
+ portion of the `User-Agent` header.
133
+ - `trust_env=True` allows `aiohttp` to reuse proxy / SSL options from the host
134
+ environment.
135
+ - You can supply an existing `aiohttp.ClientSession` via the `session` keyword
136
+ when you want to keep full control over connection pooling. The client will
137
+ detect that it does not own the session and skip closing it.
138
+
139
+ ## URL handling & headers
140
+
141
+ - Both clients share helpers in `finalsa.http._shared` for URL resolution,
142
+ header merging, and scheme enforcement.
143
+ - Per-request headers passed to `.request()` (or `.get()`, `.post()`, etc.) are
144
+ merged on top of the client's defaults without mutating the stored defaults.
145
+ - When neither `path` nor `url` is provided, `.request()` raises `ValueError` to
146
+ surface misconfigurations early.
147
+
148
+ ## Sessions, timeouts & errors
149
+
150
+ - Entering the client as a context manager ensures the underlying session is
151
+ created once and cleaned up automatically. You can also call `.close()` when
152
+ managing the lifecycle manually.
153
+ - `BaseSyncHttpClient` defaults to `raise_for_status=True` and will immediately
154
+ raise any non-2xx response. The async variant passes the same flag through to
155
+ `aiohttp.ClientSession`.
156
+ - Supplying your own session (`requests.Session` or `aiohttp.ClientSession`)
157
+ allows you to plug in custom retry adapters, authentication, or tracing
158
+ middleware while still benefiting from URL building and header management.
159
+
160
+ ## Migrating from `@responses.activate`
161
+
162
+ Many of our legacy tests used the `responses` package to stub outgoing HTTP
163
+ traffic. You can keep the same ergonomics with `BaseSyncHttpClient` and adopt a
164
+ similar pattern for asyncio-based code.
165
+
166
+ ### Sync tests (requests + responses)
167
+
168
+ ```python
169
+ import responses
170
+ from finalsa.http.sync import BaseSyncHttpClient
171
+
172
+
173
+ @responses.activate
174
+ def test_creates_invoice() -> None:
175
+ responses.add(
176
+ method=responses.POST,
177
+ url="https://api.example.test/v1/invoices",
178
+ json={"id": "inv_123", "status": "draft"},
179
+ status=201,
180
+ )
181
+
182
+ client = BaseSyncHttpClient(base_url="https://api.example.test")
183
+
184
+ response = client.post("/v1/invoices", json={"total": 5000})
185
+
186
+ assert response.json()["status"] == "draft"
187
+ assert len(responses.calls) == 1
188
+ ```
189
+
190
+ Nothing special is required: because the client reuses `requests.Session`, any
191
+ `responses.activate` or `responses.RequestsMock` context automatically captures
192
+ the outgoing call.
193
+
194
+ ### Async tests (aiohttp + aioresponses)
195
+
196
+ Use [`aioresponses`](https://github.com/pnuckowski/aioresponses) (or your async
197
+ HTTP mock of choice) to intercept `aiohttp` traffic:
198
+
199
+ ```python
200
+ import pytest
201
+ from aioresponses import aioresponses
202
+ from finalsa.http.async import BaseAsyncHttpClient
203
+
204
+
205
+ @pytest.mark.asyncio
206
+ async def test_health_check() -> None:
207
+ client = BaseAsyncHttpClient(base_url="https://api.example.test")
208
+
209
+ with aioresponses() as mocked:
210
+ mocked.get(
211
+ "https://api.example.test/v1/health",
212
+ payload={"status": "ok"},
213
+ status=200,
214
+ )
215
+
216
+ response = await client.get("/v1/health")
217
+ assert await response.json() == {"status": "ok"}
218
+ assert ("GET", "https://api.example.test/v1/health") in mocked.requests
219
+ ```
220
+
221
+ Behind the scenes the async client creates an `aiohttp.ClientSession`, so tools
222
+ like `aioresponses`, `aresponses`, or `pytest-aiohttp` slot in naturally. For
223
+ more brittle tests you can also inject your own `ClientSession` (e.g. one backed
224
+ by a stub transport) through the `session` constructor argument.
225
+
226
+ ## Development
227
+
228
+ ```bash
229
+ python -m pip install -e ".[test]"
230
+ pytest
231
+ ```
232
+
233
+ The test suite demonstrates additional patterns (custom timeouts, header
234
+ merging, session injection) if you need more examples. Feel free to open an
235
+ issue or pull request on GitHub with improvements or questions.
@@ -0,0 +1,182 @@
1
+ # Finalsa HTTP Client
2
+
3
+ `finalsa-http-client` provides small, well-tested base classes for building HTTP
4
+ clients that share a consistent set of defaults across Finalsa services. One
5
+ implementation wraps `requests` for synchronous workloads and the other wraps
6
+ `aiohttp` for asyncio-aware code.
7
+
8
+ - Normalizes `base_url` values and fills in schemes automatically.
9
+ - Builds a predictable `User-Agent` header (e.g. `finalsa-http-client/0.0.1`).
10
+ - Merges per-request headers with service defaults.
11
+ - Manages the underlying session lifecycle (context manager friendly).
12
+ - Lets you opt into strict `raise_for_status` behavior on every request.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ python -m pip install finalsa-http-client
18
+ ```
19
+
20
+ ## Synchronous usage
21
+
22
+ ```python
23
+ from finalsa.http.sync import BaseSyncHttpClient
24
+
25
+ with BaseSyncHttpClient(
26
+ base_url="https://api.example.test",
27
+ service_name="billing-api",
28
+ default_headers={"X-App": "billing"},
29
+ timeout=5, # seconds
30
+ trust_env=False,
31
+ ) as client:
32
+ response = client.post(
33
+ "/v1/invoices",
34
+ json={"customer_id": "cust_123", "total": 1999},
35
+ params={"dry_run": False},
36
+ )
37
+ print(response.json())
38
+ ```
39
+
40
+ Key synchronous notes:
41
+
42
+ - `base_url` can be provided without a scheme (`api.example.test`), the client
43
+ will inject the configured `default_scheme` (defaults to `http`).
44
+ - Use `.build_url()` when you need the normalized URL without sending a request.
45
+ - `service_name` is used to stamp the `User-Agent`. You can still call
46
+ `.update_default_headers()` later if you need to augment the defaults.
47
+ - Set `trust_env=False` (default is `True`) to keep the underlying `requests`
48
+ session from honoring proxy-related environment variables.
49
+ - Pass `url="https://status.example.test/health"` to `.request()` to bypass the
50
+ configured `base_url`.
51
+
52
+ ## Asynchronous usage
53
+
54
+ ```python
55
+ import asyncio
56
+
57
+ from finalsa.http.async import BaseAsyncHttpClient
58
+
59
+
60
+ async def main() -> None:
61
+ async with BaseAsyncHttpClient(
62
+ base_url="https://api.example.test",
63
+ service_name="billing-api",
64
+ trust_env=True, # honor proxy-related env vars
65
+ timeout=10,
66
+ ) as client:
67
+ response = await client.get("/v1/health", params={"extended": True})
68
+ payload = await response.json()
69
+ print(payload)
70
+
71
+
72
+ asyncio.run(main())
73
+ ```
74
+
75
+ Asynchronous specifics:
76
+
77
+ - Timeouts accept either a float (seconds) or an explicit `aiohttp.ClientTimeout`.
78
+ - `service_name` mirrors the sync client parameter and controls the leading
79
+ portion of the `User-Agent` header.
80
+ - `trust_env=True` allows `aiohttp` to reuse proxy / SSL options from the host
81
+ environment.
82
+ - You can supply an existing `aiohttp.ClientSession` via the `session` keyword
83
+ when you want to keep full control over connection pooling. The client will
84
+ detect that it does not own the session and skip closing it.
85
+
86
+ ## URL handling & headers
87
+
88
+ - Both clients share helpers in `finalsa.http._shared` for URL resolution,
89
+ header merging, and scheme enforcement.
90
+ - Per-request headers passed to `.request()` (or `.get()`, `.post()`, etc.) are
91
+ merged on top of the client's defaults without mutating the stored defaults.
92
+ - When neither `path` nor `url` is provided, `.request()` raises `ValueError` to
93
+ surface misconfigurations early.
94
+
95
+ ## Sessions, timeouts & errors
96
+
97
+ - Entering the client as a context manager ensures the underlying session is
98
+ created once and cleaned up automatically. You can also call `.close()` when
99
+ managing the lifecycle manually.
100
+ - `BaseSyncHttpClient` defaults to `raise_for_status=True` and will immediately
101
+ raise any non-2xx response. The async variant passes the same flag through to
102
+ `aiohttp.ClientSession`.
103
+ - Supplying your own session (`requests.Session` or `aiohttp.ClientSession`)
104
+ allows you to plug in custom retry adapters, authentication, or tracing
105
+ middleware while still benefiting from URL building and header management.
106
+
107
+ ## Migrating from `@responses.activate`
108
+
109
+ Many of our legacy tests used the `responses` package to stub outgoing HTTP
110
+ traffic. You can keep the same ergonomics with `BaseSyncHttpClient` and adopt a
111
+ similar pattern for asyncio-based code.
112
+
113
+ ### Sync tests (requests + responses)
114
+
115
+ ```python
116
+ import responses
117
+ from finalsa.http.sync import BaseSyncHttpClient
118
+
119
+
120
+ @responses.activate
121
+ def test_creates_invoice() -> None:
122
+ responses.add(
123
+ method=responses.POST,
124
+ url="https://api.example.test/v1/invoices",
125
+ json={"id": "inv_123", "status": "draft"},
126
+ status=201,
127
+ )
128
+
129
+ client = BaseSyncHttpClient(base_url="https://api.example.test")
130
+
131
+ response = client.post("/v1/invoices", json={"total": 5000})
132
+
133
+ assert response.json()["status"] == "draft"
134
+ assert len(responses.calls) == 1
135
+ ```
136
+
137
+ Nothing special is required: because the client reuses `requests.Session`, any
138
+ `responses.activate` or `responses.RequestsMock` context automatically captures
139
+ the outgoing call.
140
+
141
+ ### Async tests (aiohttp + aioresponses)
142
+
143
+ Use [`aioresponses`](https://github.com/pnuckowski/aioresponses) (or your async
144
+ HTTP mock of choice) to intercept `aiohttp` traffic:
145
+
146
+ ```python
147
+ import pytest
148
+ from aioresponses import aioresponses
149
+ from finalsa.http.async import BaseAsyncHttpClient
150
+
151
+
152
+ @pytest.mark.asyncio
153
+ async def test_health_check() -> None:
154
+ client = BaseAsyncHttpClient(base_url="https://api.example.test")
155
+
156
+ with aioresponses() as mocked:
157
+ mocked.get(
158
+ "https://api.example.test/v1/health",
159
+ payload={"status": "ok"},
160
+ status=200,
161
+ )
162
+
163
+ response = await client.get("/v1/health")
164
+ assert await response.json() == {"status": "ok"}
165
+ assert ("GET", "https://api.example.test/v1/health") in mocked.requests
166
+ ```
167
+
168
+ Behind the scenes the async client creates an `aiohttp.ClientSession`, so tools
169
+ like `aioresponses`, `aresponses`, or `pytest-aiohttp` slot in naturally. For
170
+ more brittle tests you can also inject your own `ClientSession` (e.g. one backed
171
+ by a stub transport) through the `session` constructor argument.
172
+
173
+ ## Development
174
+
175
+ ```bash
176
+ python -m pip install -e ".[test]"
177
+ pytest
178
+ ```
179
+
180
+ The test suite demonstrates additional patterns (custom timeouts, header
181
+ merging, session injection) if you need more examples. Feel free to open an
182
+ issue or pull request on GitHub with improvements or questions.
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from importlib import metadata
5
+ from urllib.parse import urljoin
6
+
7
+ from finalsa.traceability import get_w3c_traceparent, get_w3c_tracestate
8
+ from finalsa.traceability.functions import (
9
+ HTTP_HEADER_TRACEPARENT,
10
+ HTTP_HEADER_TRACESTATE,
11
+ )
12
+
13
+ Headers = Mapping[str, str]
14
+
15
+
16
+ def normalize_base_url(
17
+ base_url: str | None,
18
+ default_scheme: str,
19
+ ) -> str | None:
20
+ """Return a normalized base URL or None when no usable value is provided."""
21
+ if not base_url:
22
+ return None
23
+ value = base_url.strip()
24
+ if not value:
25
+ return None
26
+ if "://" not in value:
27
+ value = f"{default_scheme}://{value}"
28
+ return value.rstrip("/")
29
+
30
+
31
+ def ensure_scheme(candidate: str, default_scheme: str) -> str:
32
+ """Ensure that the provided candidate has an explicit scheme."""
33
+ trimmed = candidate.strip()
34
+ if "://" in trimmed:
35
+ return trimmed
36
+ return f"{default_scheme}://{trimmed}"
37
+
38
+
39
+ def build_url(
40
+ *,
41
+ path: str | None,
42
+ url: str | None,
43
+ base_url: str | None,
44
+ default_scheme: str,
45
+ ) -> str:
46
+ """Resolve the final request URL from the supplied inputs."""
47
+ if url:
48
+ return ensure_scheme(url, default_scheme)
49
+
50
+ if path is None:
51
+ raise ValueError("Either 'path' or 'url' must be provided.")
52
+
53
+ candidate = path.strip()
54
+ if candidate.startswith(("http://", "https://")):
55
+ return candidate
56
+ if base_url:
57
+ base = base_url if base_url.endswith("/") else f"{base_url}/"
58
+ return urljoin(base, candidate.lstrip("/"))
59
+ return ensure_scheme(candidate, default_scheme)
60
+
61
+
62
+ def merge_headers(
63
+ default_headers: Mapping[str, str],
64
+ headers: Mapping[str, str] | None,
65
+ ) -> dict[str, str]:
66
+ """Merge default headers with any per-request overrides."""
67
+ merged = dict(default_headers)
68
+ if headers:
69
+ merged.update(headers)
70
+ merged[HTTP_HEADER_TRACEPARENT] = get_w3c_traceparent()
71
+ merged[HTTP_HEADER_TRACESTATE] = get_w3c_tracestate()
72
+ return merged
73
+
74
+
75
+ def build_default_headers(
76
+ default_headers: Mapping[str, str],
77
+ user_headers: Mapping[str, str] | None,
78
+ service_name: str,
79
+ ) -> dict[str, str]:
80
+ """Construct the default header set for an HTTP client."""
81
+ headers = dict(default_headers)
82
+ headers.setdefault("User-Agent", f"{service_name}/{get_package_version()}")
83
+ if user_headers:
84
+ headers.update(user_headers)
85
+ return headers
86
+
87
+
88
+ def get_package_version(package_name: str = "finalsa-http-client") -> str:
89
+ """Return the installed package version or a sensible fallback in dev mode."""
90
+ try:
91
+ return metadata.version(package_name)
92
+ except metadata.PackageNotFoundError:
93
+ return "0.0.0"
94
+
@@ -0,0 +1,3 @@
1
+ from .base import BaseAsyncHttpClient
2
+
3
+ __all__ = ["BaseAsyncHttpClient"]
@@ -0,0 +1,154 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any
5
+
6
+ import aiohttp
7
+ from aiohttp import ClientSession, ClientTimeout
8
+
9
+ from finalsa.http import _shared as shared
10
+
11
+
12
+ class BaseAsyncHttpClient:
13
+ """Reusable aiohttp-based HTTP client with sane defaults."""
14
+
15
+ DEFAULT_HEADERS: Mapping[str, str] = {
16
+ "Accept": "application/json",
17
+ "Content-Type": "application/json",
18
+ }
19
+
20
+ def __init__(
21
+ self,
22
+ *,
23
+ base_url: str | None = None,
24
+ default_headers: Mapping[str, str] | None = None,
25
+ timeout: float | ClientTimeout | None = None,
26
+ default_scheme: str = "http",
27
+ service_name: str = "finalsa-http-client",
28
+ trust_env: bool = False,
29
+ raise_for_status: bool = True,
30
+ session: ClientSession | None = None,
31
+ ) -> None:
32
+ self._default_scheme = default_scheme
33
+ self.base_url = shared.normalize_base_url(base_url, default_scheme)
34
+ self._timeout = self._normalize_timeout(timeout)
35
+ self._trust_env = trust_env
36
+ self._raise_for_status = raise_for_status
37
+ self._session = session
38
+ self._owns_session = session is None
39
+ self._default_headers = shared.build_default_headers(
40
+ self.DEFAULT_HEADERS,
41
+ default_headers,
42
+ service_name,
43
+ )
44
+
45
+ async def __aenter__(self) -> BaseAsyncHttpClient:
46
+ await self._ensure_session()
47
+ return self
48
+
49
+ async def __aexit__(self, exc_type, exc, tb) -> None: # type: ignore[override]
50
+ await self.close()
51
+
52
+ @property
53
+ def session(self) -> ClientSession | None:
54
+ """Return the underlying aiohttp session, if it has been created."""
55
+ return self._session
56
+
57
+ @property
58
+ def default_headers(self) -> Mapping[str, str]:
59
+ """Return a copy of the default headers."""
60
+ return dict(self._default_headers)
61
+
62
+ def update_default_headers(self, headers: Mapping[str, str]) -> None:
63
+ """Update the headers that will be sent with every request."""
64
+ if not headers:
65
+ return
66
+ self._default_headers.update(headers)
67
+
68
+ def build_url(self, path_or_url: str) -> str:
69
+ """Resolve the final URL for a request."""
70
+ return shared.build_url(
71
+ path=path_or_url,
72
+ url=None,
73
+ base_url=self.base_url,
74
+ default_scheme=self._default_scheme,
75
+ )
76
+
77
+ async def request(
78
+ self,
79
+ method: str,
80
+ path: str | None = None,
81
+ *,
82
+ url: str | None = None,
83
+ headers: Mapping[str, str] | None = None,
84
+ params: Mapping[str, str | int | float | None] | None = None,
85
+ json: Any | None = None,
86
+ data: Any | None = None,
87
+ timeout: float | ClientTimeout | None = None,
88
+ **kwargs: Any,
89
+ ) -> aiohttp.ClientResponse:
90
+ """Send an HTTP request using aiohttp.ClientSession.request."""
91
+ if path is None and url is None:
92
+ raise ValueError("Either 'path' or 'url' must be provided.")
93
+
94
+ session = await self._ensure_session()
95
+ resolved_url = shared.build_url(
96
+ path=path,
97
+ url=url,
98
+ base_url=self.base_url,
99
+ default_scheme=self._default_scheme,
100
+ )
101
+ merged_headers = shared.merge_headers(self._default_headers, headers)
102
+ request_timeout = self._normalize_timeout(timeout) or self._timeout
103
+
104
+ return await session.request(
105
+ method.upper(),
106
+ resolved_url,
107
+ headers=merged_headers,
108
+ params=params,
109
+ json=json,
110
+ data=data,
111
+ timeout=request_timeout,
112
+ **kwargs,
113
+ )
114
+
115
+ async def get(self, path: str, **kwargs: Any) -> aiohttp.ClientResponse:
116
+ return await self.request("GET", path, **kwargs)
117
+
118
+ async def post(self, path: str, **kwargs: Any) -> aiohttp.ClientResponse:
119
+ return await self.request("POST", path, **kwargs)
120
+
121
+ async def put(self, path: str, **kwargs: Any) -> aiohttp.ClientResponse:
122
+ return await self.request("PUT", path, **kwargs)
123
+
124
+ async def patch(self, path: str, **kwargs: Any) -> aiohttp.ClientResponse:
125
+ return await self.request("PATCH", path, **kwargs)
126
+
127
+ async def delete(self, path: str, **kwargs: Any) -> aiohttp.ClientResponse:
128
+ return await self.request("DELETE", path, **kwargs)
129
+
130
+ async def close(self) -> None:
131
+ """Close the underlying aiohttp session if we created it."""
132
+ if self._session and self._owns_session and not self._session.closed:
133
+ await self._session.close()
134
+ if self._owns_session:
135
+ self._session = None
136
+
137
+ async def _ensure_session(self) -> ClientSession:
138
+ if self._session is None or self._session.closed:
139
+ self._session = aiohttp.ClientSession(
140
+ timeout=self._timeout,
141
+ trust_env=self._trust_env,
142
+ raise_for_status=self._raise_for_status,
143
+ )
144
+ return self._session
145
+
146
+ def _normalize_timeout(
147
+ self,
148
+ timeout: float | ClientTimeout | None,
149
+ ) -> ClientTimeout | None:
150
+ if timeout is None:
151
+ return None
152
+ if isinstance(timeout, ClientTimeout):
153
+ return timeout
154
+ return ClientTimeout(total=timeout)
@@ -0,0 +1 @@
1
+ from .base import BaseSyncHttpClient as BaseSyncHttpClient
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any
5
+
6
+ import requests
7
+
8
+ from finalsa.http import _shared as shared
9
+
10
+
11
+ class BaseSyncHttpClient:
12
+ """Reusable requests-based HTTP client with sane defaults."""
13
+
14
+ DEFAULT_HEADERS: Mapping[str, str] = {
15
+ "Accept": "application/json",
16
+ "Content-Type": "application/json",
17
+ }
18
+
19
+ def __init__(
20
+ self,
21
+ *,
22
+ base_url: str | None = None,
23
+ default_headers: Mapping[str, str] | None = None,
24
+ timeout: float | tuple[float, float] | None = None,
25
+ default_scheme: str = "http",
26
+ service_name: str = "finalsa-http-client",
27
+ trust_env: bool = True,
28
+ raise_for_status: bool = True,
29
+ session: requests.Session | None = None,
30
+ ) -> None:
31
+ self._default_scheme = default_scheme
32
+ self.base_url = shared.normalize_base_url(base_url, default_scheme)
33
+ self._timeout = timeout
34
+ self._trust_env = trust_env
35
+ self._raise_for_status = raise_for_status
36
+ self._session = session
37
+ self._owns_session = session is None
38
+ self._default_headers = shared.build_default_headers(
39
+ self.DEFAULT_HEADERS,
40
+ default_headers,
41
+ service_name,
42
+ )
43
+
44
+ def __enter__(self) -> BaseSyncHttpClient:
45
+ self._ensure_session()
46
+ return self
47
+
48
+ def __exit__(self, exc_type, exc, tb) -> None: # type: ignore[override]
49
+ self.close()
50
+
51
+ @property
52
+ def session(self) -> requests.Session | None:
53
+ """Return the underlying requests session, if it has been created."""
54
+ return self._session
55
+
56
+ @property
57
+ def default_headers(self) -> Mapping[str, str]:
58
+ """Return a copy of the default headers."""
59
+ return dict(self._default_headers)
60
+
61
+ def update_default_headers(self, headers: Mapping[str, str]) -> None:
62
+ """Update the headers that will be sent with every request."""
63
+ if not headers:
64
+ return
65
+ self._default_headers.update(headers)
66
+
67
+ def build_url(self, path_or_url: str) -> str:
68
+ """Resolve the final URL for a request."""
69
+ return shared.build_url(
70
+ path=path_or_url,
71
+ url=None,
72
+ base_url=self.base_url,
73
+ default_scheme=self._default_scheme,
74
+ )
75
+
76
+ def request(
77
+ self,
78
+ method: str,
79
+ path: str | None = None,
80
+ *,
81
+ url: str | None = None,
82
+ headers: Mapping[str, str] | None = None,
83
+ params: Mapping[str, str | int | float | None] | None = None,
84
+ json: Any | None = None,
85
+ data: Any | None = None,
86
+ timeout: float | tuple[float, float] | None = None,
87
+ **kwargs: Any,
88
+ ) -> requests.Response:
89
+ """Send an HTTP request using requests.Session.request."""
90
+ if path is None and url is None:
91
+ raise ValueError("Either 'path' or 'url' must be provided.")
92
+
93
+ session = self._ensure_session()
94
+ resolved_url = shared.build_url(
95
+ path=path,
96
+ url=url,
97
+ base_url=self.base_url,
98
+ default_scheme=self._default_scheme,
99
+ )
100
+ merged_headers = shared.merge_headers(self._default_headers, headers)
101
+ request_timeout = timeout if timeout is not None else self._timeout
102
+
103
+ response = session.request(
104
+ method.upper(),
105
+ resolved_url,
106
+ headers=merged_headers,
107
+ params=params,
108
+ json=json,
109
+ data=data,
110
+ timeout=request_timeout,
111
+ **kwargs,
112
+ )
113
+ if self._raise_for_status:
114
+ response.raise_for_status()
115
+ return response
116
+
117
+ def get(self, path: str, **kwargs: Any) -> requests.Response:
118
+ return self.request("GET", path, **kwargs)
119
+
120
+ def post(self, path: str, **kwargs: Any) -> requests.Response:
121
+ return self.request("POST", path, **kwargs)
122
+
123
+ def put(self, path: str, **kwargs: Any) -> requests.Response:
124
+ return self.request("PUT", path, **kwargs)
125
+
126
+ def patch(self, path: str, **kwargs: Any) -> requests.Response:
127
+ return self.request("PATCH", path, **kwargs)
128
+
129
+ def delete(self, path: str, **kwargs: Any) -> requests.Response:
130
+ return self.request("DELETE", path, **kwargs)
131
+
132
+ def close(self) -> None:
133
+ """Close the underlying requests session if we created it."""
134
+ if self._session and self._owns_session:
135
+ self._session.close()
136
+ self._session = None
137
+
138
+ def _ensure_session(self) -> requests.Session:
139
+ if self._session is None:
140
+ session = requests.Session()
141
+ session.trust_env = self._trust_env
142
+ self._session = session
143
+ return self._session
@@ -0,0 +1,121 @@
1
+ [project]
2
+ name = "finalsa-common-http-client"
3
+ version = "0.0.1"
4
+ description = "HTTP client library for common data types used in business applications"
5
+ readme = "README.md"
6
+ requires-python = ">=3.9"
7
+ dependencies = [
8
+ "aiohttp>=3.10.5",
9
+ "fastapi>=0.116.1",
10
+ "finalsa-traceability>=1.0.1",
11
+ "requests>=2.32.4",
12
+ ]
13
+ authors = [
14
+ {name = "Luis Jimenez", email = "luis@finalsa.com"},
15
+ ]
16
+ keywords = [
17
+ "http",
18
+ "client",
19
+ "finalsa"
20
+ ]
21
+ classifiers = [
22
+ "Development Status :: 4 - Beta",
23
+ "Intended Audience :: Developers",
24
+ "License :: OSI Approved :: MIT License",
25
+ "Operating System :: OS Independent",
26
+ "Programming Language :: Python :: 3",
27
+ "Programming Language :: Python :: 3.9",
28
+ "Programming Language :: Python :: 3.10",
29
+ "Programming Language :: Python :: 3.11",
30
+ "Programming Language :: Python :: 3.12",
31
+ "Topic :: Software Development :: Libraries :: Python Modules",
32
+ "Topic :: Software Development :: Internationalization",
33
+ "Topic :: Software Development :: Localization",
34
+ "Typing :: Typed",
35
+ ]
36
+
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/finalsa/finalsa-http-client"
40
+ Documentation = "https://github.com/finalsa/finalsa-http-client#readme"
41
+ Repository = "https://github.com/finalsa/finalsa-http-client.git"
42
+ Issues = "https://github.com/finalsa/finalsa-http-client/issues"
43
+ Changelog = "https://github.com/finalsa/finalsa-http-client/blob/main/CHANGELOG.md"
44
+
45
+ [project.license]
46
+ name = "MIT"
47
+ file = "LICENSE.md"
48
+
49
+ [build-system]
50
+ requires = ["hatchling"]
51
+ build-backend = "hatchling.build"
52
+
53
+
54
+ [tool.hatch.build.targets.sdist]
55
+ include = [
56
+ "finalsa/http/async/*.py",
57
+ "finalsa/http/sync/*.py",
58
+ "finalsa/http/_shared.py",
59
+ ]
60
+
61
+ [tool.hatch.build.targets.wheel]
62
+ include = [
63
+ "finalsa/http/async/*.py",
64
+ "finalsa/http/sync/*.py",
65
+ "finalsa/http/_shared.py",
66
+ ]
67
+
68
+ [dependency-groups]
69
+ test = [
70
+ "coverage>=7.9.2",
71
+ "pytest>=8.3.4",
72
+ "pytest-asyncio>=0.24.0",
73
+ ]
74
+
75
+ [tool.pytest.ini_options]
76
+ addopts = "-ra"
77
+ testpaths = ["tests"]
78
+ markers = [
79
+ "asyncio: mark tests that require an event loop",
80
+ ]
81
+ asyncio_mode = "auto"
82
+
83
+ [tool.ruff]
84
+ # Exclude examples directory from ruff checks since they have optional dependencies
85
+ exclude = [
86
+ "examples/**",
87
+ "htmlcov/**",
88
+ "*.egg-info/**",
89
+ ".venv/**",
90
+ "build/**",
91
+ "dist/**",
92
+ ]
93
+
94
+ # Configure line length and target Python version
95
+ line-length = 88
96
+ target-version = "py39"
97
+
98
+ [tool.ruff.lint]
99
+ # Enable common lint rules
100
+ select = [
101
+ "E", # pycodestyle errors
102
+ "W", # pycodestyle warnings
103
+ "F", # pyflakes
104
+ "I", # isort
105
+ "B", # flake8-bugbear
106
+ "C4", # flake8-comprehensions
107
+ "UP", # pyupgrade
108
+ ]
109
+
110
+ # Ignore specific rules that might be too strict for this project
111
+ ignore = [
112
+ "E501", # line too long (handled by line-length setting)
113
+ "UP006", # pyupgrade: use f-string
114
+ "UP035", # pyupgrade: use f-string for single-line format
115
+ ]
116
+
117
+ [tool.ruff.format]
118
+ # Use double quotes and trailing commas
119
+ quote-style = "double"
120
+ indent-style = "space"
121
+ skip-magic-trailing-comma = false