crashbytes-apiclient 1.0.0__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 @@
1
+ * @CrashBytes
@@ -0,0 +1,23 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: ${{ matrix.python-version }}
20
+ - run: pip install -e ".[dev]"
21
+ - run: ruff check src/ tests/
22
+ - run: mypy --strict src/
23
+ - run: pytest --cov=crashbytes_apiclient --cov-branch --cov-fail-under=90
@@ -0,0 +1,19 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags: ["*"]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ id-token: write
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: actions/setup-python@v5
15
+ with:
16
+ python-version: "3.12"
17
+ - run: pip install build
18
+ - run: python -m build
19
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .coverage
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CrashBytes
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,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: crashbytes-apiclient
3
+ Version: 1.0.0
4
+ Summary: httpx API client toolkit — pagination, retry, rate limiting, auth refresh, middleware.
5
+ Project-URL: Homepage, https://github.com/CrashBytes/crashbytes-apiclient
6
+ Project-URL: Repository, https://github.com/CrashBytes/crashbytes-apiclient
7
+ Project-URL: Issues, https://github.com/CrashBytes/crashbytes-apiclient/issues
8
+ Author-email: CrashBytes <crashbytes@users.noreply.github.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: api-client,httpx,middleware,pagination,retry
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: httpx>=0.27
23
+ Provides-Extra: dev
24
+ Requires-Dist: mypy; extra == 'dev'
25
+ Requires-Dist: pytest; extra == 'dev'
26
+ Requires-Dist: pytest-asyncio; extra == 'dev'
27
+ Requires-Dist: pytest-cov; extra == 'dev'
28
+ Requires-Dist: pytest-httpx; extra == 'dev'
29
+ Requires-Dist: ruff; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # crashbytes-apiclient
33
+
34
+ httpx API client toolkit — pagination, retry, rate limiting, auth refresh, middleware.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install crashbytes-apiclient
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ ```python
45
+ from crashbytes_apiclient import (
46
+ ApiClient, BearerAuth, RetryMiddleware, CursorPaginator,
47
+ )
48
+
49
+ client = ApiClient(
50
+ base_url="https://api.example.com",
51
+ auth=BearerAuth("your-token"),
52
+ middlewares=[RetryMiddleware(max_retries=3)],
53
+ )
54
+
55
+ # Simple requests
56
+ response = client.get("/users")
57
+ response = client.post("/users", json={"name": "Alice"})
58
+
59
+ # Paginated iteration
60
+ for batch in client.paginate("/items", CursorPaginator()):
61
+ for item in batch:
62
+ process(item)
63
+
64
+ client.close()
65
+ ```
66
+
67
+ ## Features
68
+
69
+ - **Auth:** `BearerAuth`, `RefreshableAuth` (auto-refresh on 401)
70
+ - **Middleware:** `RetryMiddleware`, `RateLimitMiddleware`, custom middleware protocol
71
+ - **Pagination:** `CursorPaginator`, `PageNumberPaginator`
72
+ - **HTTP Methods:** `.get()`, `.post()`, `.put()`, `.patch()`, `.delete()`
73
+ - **Context Manager:** `with ApiClient(...) as client:`
74
+
75
+ ## License
76
+
77
+ MIT
@@ -0,0 +1,46 @@
1
+ # crashbytes-apiclient
2
+
3
+ httpx API client toolkit — pagination, retry, rate limiting, auth refresh, middleware.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install crashbytes-apiclient
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ from crashbytes_apiclient import (
15
+ ApiClient, BearerAuth, RetryMiddleware, CursorPaginator,
16
+ )
17
+
18
+ client = ApiClient(
19
+ base_url="https://api.example.com",
20
+ auth=BearerAuth("your-token"),
21
+ middlewares=[RetryMiddleware(max_retries=3)],
22
+ )
23
+
24
+ # Simple requests
25
+ response = client.get("/users")
26
+ response = client.post("/users", json={"name": "Alice"})
27
+
28
+ # Paginated iteration
29
+ for batch in client.paginate("/items", CursorPaginator()):
30
+ for item in batch:
31
+ process(item)
32
+
33
+ client.close()
34
+ ```
35
+
36
+ ## Features
37
+
38
+ - **Auth:** `BearerAuth`, `RefreshableAuth` (auto-refresh on 401)
39
+ - **Middleware:** `RetryMiddleware`, `RateLimitMiddleware`, custom middleware protocol
40
+ - **Pagination:** `CursorPaginator`, `PageNumberPaginator`
41
+ - **HTTP Methods:** `.get()`, `.post()`, `.put()`, `.patch()`, `.delete()`
42
+ - **Context Manager:** `with ApiClient(...) as client:`
43
+
44
+ ## License
45
+
46
+ MIT
@@ -0,0 +1,55 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "crashbytes-apiclient"
7
+ version = "1.0.0"
8
+ description = "httpx API client toolkit — pagination, retry, rate limiting, auth refresh, middleware."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ dependencies = ["httpx>=0.27"]
13
+ authors = [{ name = "CrashBytes", email = "crashbytes@users.noreply.github.com" }]
14
+ keywords = ["httpx", "api-client", "pagination", "retry", "middleware"]
15
+ classifiers = [
16
+ "Development Status :: 5 - Production/Stable",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Typing :: Typed",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ dev = ["pytest", "pytest-cov", "pytest-asyncio", "pytest-httpx", "mypy", "ruff"]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/CrashBytes/crashbytes-apiclient"
32
+ Repository = "https://github.com/CrashBytes/crashbytes-apiclient"
33
+ Issues = "https://github.com/CrashBytes/crashbytes-apiclient/issues"
34
+
35
+ [tool.ruff]
36
+ target-version = "py310"
37
+ line-length = 99
38
+
39
+ [tool.ruff.lint]
40
+ select = ["E", "F", "I", "N", "UP", "B", "SIM", "TCH"]
41
+
42
+ [tool.mypy]
43
+ strict = true
44
+ python_version = "3.10"
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
48
+ asyncio_mode = "auto"
49
+
50
+ [tool.coverage.run]
51
+ branch = true
52
+ source = ["crashbytes_apiclient"]
53
+
54
+ [tool.coverage.report]
55
+ fail_under = 90
@@ -0,0 +1,21 @@
1
+ """crashbytes-apiclient — httpx API client toolkit."""
2
+
3
+ from crashbytes_apiclient._core import (
4
+ ApiClient,
5
+ BearerAuth,
6
+ CursorPaginator,
7
+ PageNumberPaginator,
8
+ RateLimitMiddleware,
9
+ RefreshableAuth,
10
+ RetryMiddleware,
11
+ )
12
+
13
+ __all__ = [
14
+ "ApiClient",
15
+ "BearerAuth",
16
+ "CursorPaginator",
17
+ "PageNumberPaginator",
18
+ "RateLimitMiddleware",
19
+ "RefreshableAuth",
20
+ "RetryMiddleware",
21
+ ]
@@ -0,0 +1,280 @@
1
+ """httpx API client toolkit — pagination, retry, rate limiting, auth, middleware."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import TYPE_CHECKING, Any, Protocol
7
+
8
+ import httpx
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Generator, Iterator
12
+
13
+
14
+ # ── Auth ────────────────────────────────────────────────────────────
15
+
16
+
17
+ class BearerAuth(httpx.Auth):
18
+ """Static bearer token authentication."""
19
+
20
+ def __init__(self, token: str) -> None:
21
+ self._token = token
22
+
23
+ def auth_flow(
24
+ self, request: httpx.Request
25
+ ) -> Generator[httpx.Request, httpx.Response, None]:
26
+ request.headers["Authorization"] = f"Bearer {self._token}"
27
+ yield request
28
+
29
+
30
+ class RefreshableAuth(httpx.Auth):
31
+ """Bearer auth that refreshes the token when it expires."""
32
+
33
+ def __init__(
34
+ self,
35
+ token: str,
36
+ refresh_fn: Any,
37
+ refresh_status: int = 401,
38
+ ) -> None:
39
+ self._token = token
40
+ self._refresh_fn = refresh_fn
41
+ self._refresh_status = refresh_status
42
+
43
+ def auth_flow(
44
+ self, request: httpx.Request
45
+ ) -> Generator[httpx.Request, httpx.Response, None]:
46
+ request.headers["Authorization"] = f"Bearer {self._token}"
47
+ response = yield request
48
+ if response.status_code == self._refresh_status:
49
+ self._token = self._refresh_fn()
50
+ request.headers["Authorization"] = f"Bearer {self._token}"
51
+ yield request
52
+
53
+
54
+ # ── Middleware Protocol ─────────────────────────────────────────────
55
+
56
+
57
+ class Middleware(Protocol):
58
+ """Protocol for request/response middleware."""
59
+
60
+ def process_request(self, request: httpx.Request) -> httpx.Request:
61
+ """Process the request before sending."""
62
+ ...
63
+
64
+ def process_response(self, response: httpx.Response) -> httpx.Response:
65
+ """Process the response after receiving."""
66
+ ...
67
+
68
+
69
+ class RetryMiddleware:
70
+ """Retry failed requests with exponential backoff."""
71
+
72
+ def __init__(
73
+ self,
74
+ max_retries: int = 3,
75
+ retry_statuses: tuple[int, ...] = (429, 500, 502, 503, 504),
76
+ delay: float = 0.5,
77
+ backoff: float = 2.0,
78
+ ) -> None:
79
+ self.max_retries = max_retries
80
+ self.retry_statuses = retry_statuses
81
+ self.delay = delay
82
+ self.backoff = backoff
83
+
84
+ def process_request(self, request: httpx.Request) -> httpx.Request:
85
+ return request
86
+
87
+ def process_response(self, response: httpx.Response) -> httpx.Response:
88
+ return response
89
+
90
+ def should_retry(self, response: httpx.Response) -> bool:
91
+ """Check if the response should trigger a retry."""
92
+ return response.status_code in self.retry_statuses
93
+
94
+
95
+ class RateLimitMiddleware:
96
+ """Simple rate limiting between requests."""
97
+
98
+ def __init__(self, min_interval: float = 0.1) -> None:
99
+ self._min_interval = min_interval
100
+ self._last_request_time = 0.0
101
+
102
+ def process_request(self, request: httpx.Request) -> httpx.Request:
103
+ elapsed = time.monotonic() - self._last_request_time
104
+ if elapsed < self._min_interval:
105
+ time.sleep(self._min_interval - elapsed)
106
+ self._last_request_time = time.monotonic()
107
+ return request
108
+
109
+ def process_response(self, response: httpx.Response) -> httpx.Response:
110
+ return response
111
+
112
+
113
+ # ── Paginator Protocols ─────────────────────────────────────────────
114
+
115
+
116
+ class CursorPaginator:
117
+ """Extract next-page cursor from response JSON."""
118
+
119
+ def __init__(
120
+ self,
121
+ cursor_param: str = "cursor",
122
+ cursor_path: str = "next_cursor",
123
+ results_path: str = "results",
124
+ ) -> None:
125
+ self._cursor_param = cursor_param
126
+ self._cursor_path = cursor_path
127
+ self._results_path = results_path
128
+
129
+ def get_results(self, data: dict[str, Any]) -> list[Any]:
130
+ """Extract results from the response data."""
131
+ return data.get(self._results_path, []) # type: ignore[no-any-return]
132
+
133
+ def get_next_params(self, data: dict[str, Any]) -> dict[str, str] | None:
134
+ """Get params for the next page, or None if done."""
135
+ cursor = data.get(self._cursor_path)
136
+ if cursor:
137
+ return {self._cursor_param: str(cursor)}
138
+ return None
139
+
140
+
141
+ class PageNumberPaginator:
142
+ """Page-number-based pagination."""
143
+
144
+ def __init__(
145
+ self,
146
+ page_param: str = "page",
147
+ results_path: str = "results",
148
+ total_path: str = "total_pages",
149
+ ) -> None:
150
+ self._page_param = page_param
151
+ self._results_path = results_path
152
+ self._total_path = total_path
153
+ self._current_page = 1
154
+
155
+ def get_results(self, data: dict[str, Any]) -> list[Any]:
156
+ """Extract results from the response data."""
157
+ return data.get(self._results_path, []) # type: ignore[no-any-return]
158
+
159
+ def get_next_params(self, data: dict[str, Any]) -> dict[str, str] | None:
160
+ """Get params for the next page, or None if done."""
161
+ total = data.get(self._total_path, 0)
162
+ if self._current_page < int(total):
163
+ self._current_page += 1
164
+ return {self._page_param: str(self._current_page)}
165
+ return None
166
+
167
+
168
+ # ── API Client ──────────────────────────────────────────────────────
169
+
170
+
171
+ class ApiClient:
172
+ """High-level httpx API client with middleware and pagination."""
173
+
174
+ def __init__(
175
+ self,
176
+ base_url: str,
177
+ auth: httpx.Auth | None = None,
178
+ middlewares: list[Any] | None = None,
179
+ headers: dict[str, str] | None = None,
180
+ timeout: float = 30.0,
181
+ ) -> None:
182
+ self._base_url = base_url.rstrip("/")
183
+ self._auth = auth
184
+ self._middlewares = middlewares or []
185
+ self._client = httpx.Client(
186
+ base_url=self._base_url,
187
+ auth=auth,
188
+ headers=headers or {},
189
+ timeout=timeout,
190
+ )
191
+
192
+ def _apply_request_middleware(self, request: httpx.Request) -> httpx.Request:
193
+ for mw in self._middlewares:
194
+ request = mw.process_request(request)
195
+ return request
196
+
197
+ def _apply_response_middleware(self, response: httpx.Response) -> httpx.Response:
198
+ for mw in self._middlewares:
199
+ response = mw.process_response(response)
200
+ return response
201
+
202
+ def _send_with_retry(self, request: httpx.Request) -> httpx.Response:
203
+ retry_mw = None
204
+ for mw in self._middlewares:
205
+ if isinstance(mw, RetryMiddleware):
206
+ retry_mw = mw
207
+ break
208
+
209
+ if retry_mw is None:
210
+ return self._client.send(request)
211
+
212
+ delay = retry_mw.delay
213
+ last_response: httpx.Response | None = None
214
+ for attempt in range(retry_mw.max_retries + 1):
215
+ response = self._client.send(request)
216
+ last_response = response
217
+ if not retry_mw.should_retry(response):
218
+ return response
219
+ if attempt < retry_mw.max_retries:
220
+ time.sleep(delay)
221
+ delay *= retry_mw.backoff
222
+ assert last_response is not None
223
+ return last_response
224
+
225
+ def request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
226
+ """Send an HTTP request."""
227
+ request = self._client.build_request(method, path, **kwargs)
228
+ request = self._apply_request_middleware(request)
229
+ response = self._send_with_retry(request)
230
+ return self._apply_response_middleware(response)
231
+
232
+ def get(self, path: str, **kwargs: Any) -> httpx.Response:
233
+ """Send a GET request."""
234
+ return self.request("GET", path, **kwargs)
235
+
236
+ def post(self, path: str, **kwargs: Any) -> httpx.Response:
237
+ """Send a POST request."""
238
+ return self.request("POST", path, **kwargs)
239
+
240
+ def put(self, path: str, **kwargs: Any) -> httpx.Response:
241
+ """Send a PUT request."""
242
+ return self.request("PUT", path, **kwargs)
243
+
244
+ def patch(self, path: str, **kwargs: Any) -> httpx.Response:
245
+ """Send a PATCH request."""
246
+ return self.request("PATCH", path, **kwargs)
247
+
248
+ def delete(self, path: str, **kwargs: Any) -> httpx.Response:
249
+ """Send a DELETE request."""
250
+ return self.request("DELETE", path, **kwargs)
251
+
252
+ def paginate(
253
+ self,
254
+ path: str,
255
+ paginator: CursorPaginator | PageNumberPaginator,
256
+ **kwargs: Any,
257
+ ) -> Iterator[list[Any]]:
258
+ """Iterate through paginated results."""
259
+ params: dict[str, str] = dict(kwargs.pop("params", {}))
260
+ while True:
261
+ response = self.get(path, params=params, **kwargs)
262
+ response.raise_for_status()
263
+ data = response.json()
264
+ results = paginator.get_results(data)
265
+ if results:
266
+ yield results
267
+ next_params = paginator.get_next_params(data)
268
+ if next_params is None:
269
+ break
270
+ params.update(next_params)
271
+
272
+ def close(self) -> None:
273
+ """Close the underlying httpx client."""
274
+ self._client.close()
275
+
276
+ def __enter__(self) -> ApiClient:
277
+ return self
278
+
279
+ def __exit__(self, *args: Any) -> None:
280
+ self.close()
@@ -0,0 +1,315 @@
1
+ """Tests for crashbytes-apiclient."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from unittest.mock import MagicMock
7
+
8
+ import httpx
9
+
10
+ from crashbytes_apiclient import (
11
+ ApiClient,
12
+ BearerAuth,
13
+ CursorPaginator,
14
+ PageNumberPaginator,
15
+ RateLimitMiddleware,
16
+ RefreshableAuth,
17
+ RetryMiddleware,
18
+ )
19
+
20
+ # ── Auth Tests ──────────────────────────────────────────────────────
21
+
22
+
23
+ class TestBearerAuth:
24
+ def test_adds_header(self) -> None:
25
+ auth = BearerAuth("mytoken")
26
+ request = httpx.Request("GET", "https://example.com")
27
+ flow = auth.auth_flow(request)
28
+ modified = next(flow)
29
+ assert modified.headers["Authorization"] == "Bearer mytoken"
30
+
31
+
32
+ class TestRefreshableAuth:
33
+ def test_no_refresh_on_success(self) -> None:
34
+ auth = RefreshableAuth("token1", lambda: "token2")
35
+ request = httpx.Request("GET", "https://example.com")
36
+ flow = auth.auth_flow(request)
37
+ modified = next(flow)
38
+ assert modified.headers["Authorization"] == "Bearer token1"
39
+
40
+ def test_refresh_on_401(self) -> None:
41
+ refresh_fn = MagicMock(return_value="token2")
42
+ auth = RefreshableAuth("token1", refresh_fn)
43
+ request = httpx.Request("GET", "https://example.com")
44
+ flow = auth.auth_flow(request)
45
+ modified = next(flow)
46
+ assert modified.headers["Authorization"] == "Bearer token1"
47
+
48
+ # Simulate 401 response
49
+ response = httpx.Response(401, request=request)
50
+ modified = flow.send(response)
51
+ assert modified.headers["Authorization"] == "Bearer token2"
52
+ refresh_fn.assert_called_once()
53
+
54
+
55
+ # ── Middleware Tests ────────────────────────────────────────────────
56
+
57
+
58
+ class TestRetryMiddleware:
59
+ def test_should_retry_on_503(self) -> None:
60
+ mw = RetryMiddleware()
61
+ response = httpx.Response(503)
62
+ assert mw.should_retry(response) is True
63
+
64
+ def test_should_not_retry_on_200(self) -> None:
65
+ mw = RetryMiddleware()
66
+ response = httpx.Response(200)
67
+ assert mw.should_retry(response) is False
68
+
69
+ def test_process_request_passthrough(self) -> None:
70
+ mw = RetryMiddleware()
71
+ request = httpx.Request("GET", "https://example.com")
72
+ assert mw.process_request(request) is request
73
+
74
+ def test_process_response_passthrough(self) -> None:
75
+ mw = RetryMiddleware()
76
+ response = httpx.Response(200)
77
+ assert mw.process_response(response) is response
78
+
79
+
80
+ class TestRateLimitMiddleware:
81
+ def test_process_request(self) -> None:
82
+ mw = RateLimitMiddleware(min_interval=0.0)
83
+ request = httpx.Request("GET", "https://example.com")
84
+ result = mw.process_request(request)
85
+ assert result is request
86
+
87
+ def test_process_response_passthrough(self) -> None:
88
+ mw = RateLimitMiddleware()
89
+ response = httpx.Response(200)
90
+ assert mw.process_response(response) is response
91
+
92
+
93
+ # ── Paginator Tests ─────────────────────────────────────────────────
94
+
95
+
96
+ class TestCursorPaginator:
97
+ def test_get_results(self) -> None:
98
+ pag = CursorPaginator()
99
+ data: dict[str, Any] = {"results": [1, 2, 3], "next_cursor": "abc"}
100
+ assert pag.get_results(data) == [1, 2, 3]
101
+
102
+ def test_get_next_params(self) -> None:
103
+ pag = CursorPaginator()
104
+ data: dict[str, Any] = {"results": [], "next_cursor": "abc"}
105
+ assert pag.get_next_params(data) == {"cursor": "abc"}
106
+
107
+ def test_get_next_params_none(self) -> None:
108
+ pag = CursorPaginator()
109
+ data: dict[str, Any] = {"results": [], "next_cursor": None}
110
+ assert pag.get_next_params(data) is None
111
+
112
+ def test_get_next_params_missing(self) -> None:
113
+ pag = CursorPaginator()
114
+ data: dict[str, Any] = {"results": []}
115
+ assert pag.get_next_params(data) is None
116
+
117
+ def test_custom_keys(self) -> None:
118
+ pag = CursorPaginator(
119
+ cursor_param="after", cursor_path="paging.next", results_path="data"
120
+ )
121
+ data: dict[str, Any] = {"data": [1], "paging.next": "xyz"}
122
+ assert pag.get_results(data) == [1]
123
+ assert pag.get_next_params(data) == {"after": "xyz"}
124
+
125
+
126
+ class TestPageNumberPaginator:
127
+ def test_get_results(self) -> None:
128
+ pag = PageNumberPaginator()
129
+ data: dict[str, Any] = {"results": [1, 2], "total_pages": 3}
130
+ assert pag.get_results(data) == [1, 2]
131
+
132
+ def test_get_next_params(self) -> None:
133
+ pag = PageNumberPaginator()
134
+ data: dict[str, Any] = {"results": [], "total_pages": 3}
135
+ params = pag.get_next_params(data)
136
+ assert params == {"page": "2"}
137
+
138
+ def test_get_next_params_last_page(self) -> None:
139
+ pag = PageNumberPaginator()
140
+ pag._current_page = 3
141
+ data: dict[str, Any] = {"results": [], "total_pages": 3}
142
+ assert pag.get_next_params(data) is None
143
+
144
+
145
+ # ── ApiClient Tests ─────────────────────────────────────────────────
146
+
147
+
148
+ class TestApiClient:
149
+ def test_context_manager(self) -> None:
150
+ transport = httpx.MockTransport(
151
+ lambda req: httpx.Response(200, json={"ok": True})
152
+ )
153
+ with ApiClient("https://api.example.com") as client:
154
+ client._client = httpx.Client(
155
+ base_url="https://api.example.com", transport=transport
156
+ )
157
+ resp = client.get("/test")
158
+ assert resp.status_code == 200
159
+ assert resp.json() == {"ok": True}
160
+
161
+ def test_post(self) -> None:
162
+ transport = httpx.MockTransport(
163
+ lambda req: httpx.Response(201, json={"id": 1})
164
+ )
165
+ with ApiClient("https://api.example.com") as client:
166
+ client._client = httpx.Client(
167
+ base_url="https://api.example.com", transport=transport
168
+ )
169
+ resp = client.post("/items", json={"name": "test"})
170
+ assert resp.status_code == 201
171
+
172
+ def test_put(self) -> None:
173
+ transport = httpx.MockTransport(
174
+ lambda req: httpx.Response(200, json={"updated": True})
175
+ )
176
+ with ApiClient("https://api.example.com") as client:
177
+ client._client = httpx.Client(
178
+ base_url="https://api.example.com", transport=transport
179
+ )
180
+ resp = client.put("/items/1", json={"name": "updated"})
181
+ assert resp.status_code == 200
182
+
183
+ def test_patch(self) -> None:
184
+ transport = httpx.MockTransport(
185
+ lambda req: httpx.Response(200)
186
+ )
187
+ with ApiClient("https://api.example.com") as client:
188
+ client._client = httpx.Client(
189
+ base_url="https://api.example.com", transport=transport
190
+ )
191
+ resp = client.patch("/items/1", json={"name": "patched"})
192
+ assert resp.status_code == 200
193
+
194
+ def test_delete(self) -> None:
195
+ transport = httpx.MockTransport(
196
+ lambda req: httpx.Response(204)
197
+ )
198
+ with ApiClient("https://api.example.com") as client:
199
+ client._client = httpx.Client(
200
+ base_url="https://api.example.com", transport=transport
201
+ )
202
+ resp = client.delete("/items/1")
203
+ assert resp.status_code == 204
204
+
205
+ def test_with_bearer_auth(self) -> None:
206
+ def handler(request: httpx.Request) -> httpx.Response:
207
+ auth = request.headers.get("Authorization", "")
208
+ return httpx.Response(200, json={"auth": auth})
209
+
210
+ transport = httpx.MockTransport(handler)
211
+ with ApiClient(
212
+ "https://api.example.com", auth=BearerAuth("test-token")
213
+ ) as client:
214
+ client._client = httpx.Client(
215
+ base_url="https://api.example.com",
216
+ transport=transport,
217
+ auth=BearerAuth("test-token"),
218
+ )
219
+ resp = client.get("/me")
220
+ assert resp.json()["auth"] == "Bearer test-token"
221
+
222
+ def test_with_middleware(self) -> None:
223
+ call_log: list[str] = []
224
+
225
+ class LogMiddleware:
226
+ def process_request(self, request: httpx.Request) -> httpx.Request:
227
+ call_log.append("request")
228
+ return request
229
+
230
+ def process_response(self, response: httpx.Response) -> httpx.Response:
231
+ call_log.append("response")
232
+ return response
233
+
234
+ transport = httpx.MockTransport(
235
+ lambda req: httpx.Response(200)
236
+ )
237
+ with ApiClient(
238
+ "https://api.example.com", middlewares=[LogMiddleware()]
239
+ ) as client:
240
+ client._client = httpx.Client(
241
+ base_url="https://api.example.com", transport=transport
242
+ )
243
+ client.get("/test")
244
+ assert call_log == ["request", "response"]
245
+
246
+ def test_retry_middleware(self) -> None:
247
+ attempt = 0
248
+
249
+ def handler(request: httpx.Request) -> httpx.Response:
250
+ nonlocal attempt
251
+ attempt += 1
252
+ if attempt < 3:
253
+ return httpx.Response(503)
254
+ return httpx.Response(200, json={"ok": True})
255
+
256
+ transport = httpx.MockTransport(handler)
257
+ retry_mw = RetryMiddleware(max_retries=3, delay=0.0)
258
+ with ApiClient(
259
+ "https://api.example.com", middlewares=[retry_mw]
260
+ ) as client:
261
+ client._client = httpx.Client(
262
+ base_url="https://api.example.com", transport=transport
263
+ )
264
+ resp = client.get("/test")
265
+ assert resp.status_code == 200
266
+ assert attempt == 3
267
+
268
+ def test_retry_exhausted(self) -> None:
269
+ transport = httpx.MockTransport(
270
+ lambda req: httpx.Response(503)
271
+ )
272
+ retry_mw = RetryMiddleware(max_retries=2, delay=0.0)
273
+ with ApiClient(
274
+ "https://api.example.com", middlewares=[retry_mw]
275
+ ) as client:
276
+ client._client = httpx.Client(
277
+ base_url="https://api.example.com", transport=transport
278
+ )
279
+ resp = client.get("/test")
280
+ assert resp.status_code == 503
281
+
282
+ def test_paginate_cursor(self) -> None:
283
+ page = 0
284
+
285
+ def handler(request: httpx.Request) -> httpx.Response:
286
+ nonlocal page
287
+ page += 1
288
+ if page == 1:
289
+ return httpx.Response(
290
+ 200, json={"results": [1, 2], "next_cursor": "abc"}
291
+ )
292
+ return httpx.Response(200, json={"results": [3], "next_cursor": None})
293
+
294
+ transport = httpx.MockTransport(handler)
295
+ with ApiClient("https://api.example.com") as client:
296
+ client._client = httpx.Client(
297
+ base_url="https://api.example.com", transport=transport
298
+ )
299
+ all_results: list[Any] = []
300
+ for batch in client.paginate("/items", CursorPaginator()):
301
+ all_results.extend(batch)
302
+ assert all_results == [1, 2, 3]
303
+
304
+ def test_paginate_empty(self) -> None:
305
+ transport = httpx.MockTransport(
306
+ lambda req: httpx.Response(
307
+ 200, json={"results": [], "next_cursor": None}
308
+ )
309
+ )
310
+ with ApiClient("https://api.example.com") as client:
311
+ client._client = httpx.Client(
312
+ base_url="https://api.example.com", transport=transport
313
+ )
314
+ pages = list(client.paginate("/items", CursorPaginator()))
315
+ assert pages == []