apiroute 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.
Files changed (120) hide show
  1. apiroute-1.0.0/.github/workflows/ci.yml +53 -0
  2. apiroute-1.0.0/.github/workflows/release.yml +107 -0
  3. apiroute-1.0.0/.gitignore +8 -0
  4. apiroute-1.0.0/.release-please-manifest.json +3 -0
  5. apiroute-1.0.0/CHANGELOG.md +57 -0
  6. apiroute-1.0.0/LICENSE +21 -0
  7. apiroute-1.0.0/PKG-INFO +379 -0
  8. apiroute-1.0.0/README.md +347 -0
  9. apiroute-1.0.0/pyproject.toml +61 -0
  10. apiroute-1.0.0/release-please-config.json +10 -0
  11. apiroute-1.0.0/src/apiroute/__init__.py +57 -0
  12. apiroute-1.0.0/src/apiroute/api_route_error.py +2 -0
  13. apiroute-1.0.0/src/apiroute/argument_targets.py +33 -0
  14. apiroute-1.0.0/src/apiroute/async_chunk_iterator.py +35 -0
  15. apiroute-1.0.0/src/apiroute/async_retry_runner.py +26 -0
  16. apiroute-1.0.0/src/apiroute/base_url_validator.py +15 -0
  17. apiroute-1.0.0/src/apiroute/body_encoder.py +25 -0
  18. apiroute-1.0.0/src/apiroute/chunk_iterator.py +36 -0
  19. apiroute-1.0.0/src/apiroute/client.py +85 -0
  20. apiroute-1.0.0/src/apiroute/client_config.py +95 -0
  21. apiroute-1.0.0/src/apiroute/dataclass_converter.py +56 -0
  22. apiroute-1.0.0/src/apiroute/deferred_converter.py +22 -0
  23. apiroute-1.0.0/src/apiroute/from_client.py +9 -0
  24. apiroute-1.0.0/src/apiroute/generated_signature.py +93 -0
  25. apiroute-1.0.0/src/apiroute/http_method.py +44 -0
  26. apiroute-1.0.0/src/apiroute/model_types.py +43 -0
  27. apiroute-1.0.0/src/apiroute/namespace.py +34 -0
  28. apiroute-1.0.0/src/apiroute/path_template.py +25 -0
  29. apiroute-1.0.0/src/apiroute/placeholder_resolver.py +20 -0
  30. apiroute-1.0.0/src/apiroute/py.typed +0 -0
  31. apiroute-1.0.0/src/apiroute/pydantic_converter.py +15 -0
  32. apiroute-1.0.0/src/apiroute/query_encoder.py +50 -0
  33. apiroute-1.0.0/src/apiroute/request_builder.py +139 -0
  34. apiroute-1.0.0/src/apiroute/request_options.py +56 -0
  35. apiroute-1.0.0/src/apiroute/request_spec.py +13 -0
  36. apiroute-1.0.0/src/apiroute/response_converter.py +22 -0
  37. apiroute-1.0.0/src/apiroute/response_reader.py +58 -0
  38. apiroute-1.0.0/src/apiroute/response_validation_error.py +13 -0
  39. apiroute-1.0.0/src/apiroute/result.py +9 -0
  40. apiroute-1.0.0/src/apiroute/retries.py +50 -0
  41. apiroute-1.0.0/src/apiroute/retry_policy.py +66 -0
  42. apiroute-1.0.0/src/apiroute/retry_runner.py +26 -0
  43. apiroute-1.0.0/src/apiroute/route.py +42 -0
  44. apiroute-1.0.0/src/apiroute/route_compiler.py +214 -0
  45. apiroute-1.0.0/src/apiroute/route_decorator.py +23 -0
  46. apiroute-1.0.0/src/apiroute/route_definition.py +48 -0
  47. apiroute-1.0.0/src/apiroute/route_definition_error.py +5 -0
  48. apiroute-1.0.0/src/apiroute/route_executor.py +113 -0
  49. apiroute-1.0.0/src/apiroute/route_method.py +75 -0
  50. apiroute-1.0.0/src/apiroute/route_spec.py +15 -0
  51. apiroute-1.0.0/src/apiroute/router.py +52 -0
  52. apiroute-1.0.0/src/apiroute/router_view.py +35 -0
  53. apiroute-1.0.0/src/apiroute/section.py +27 -0
  54. apiroute-1.0.0/src/apiroute/section_accessor.py +25 -0
  55. apiroute-1.0.0/src/apiroute/stream_path_validator.py +16 -0
  56. apiroute-1.0.0/src/apiroute/stream_reader.py +82 -0
  57. apiroute-1.0.0/src/apiroute/transport_configuration.py +37 -0
  58. apiroute-1.0.0/src/apiroute/transports/__init__.py +25 -0
  59. apiroute-1.0.0/src/apiroute/transports/async_httpx_client_transport.py +25 -0
  60. apiroute-1.0.0/src/apiroute/transports/async_one_shot_transport.py +33 -0
  61. apiroute-1.0.0/src/apiroute/transports/async_pooled_transport.py +84 -0
  62. apiroute-1.0.0/src/apiroute/transports/async_transport.py +18 -0
  63. apiroute-1.0.0/src/apiroute/transports/httpx_client_factory.py +32 -0
  64. apiroute-1.0.0/src/apiroute/transports/httpx_client_transport.py +33 -0
  65. apiroute-1.0.0/src/apiroute/transports/one_shot_transport.py +34 -0
  66. apiroute-1.0.0/src/apiroute/transports/pooled_transport.py +44 -0
  67. apiroute-1.0.0/src/apiroute/transports/thread_local_transport.py +60 -0
  68. apiroute-1.0.0/src/apiroute/transports/transport.py +22 -0
  69. apiroute-1.0.0/src/apiroute/transports/transport_registry.py +52 -0
  70. apiroute-1.0.0/src/apiroute/unexpected_response.py +18 -0
  71. apiroute-1.0.0/src/apiroute/value_template.py +59 -0
  72. apiroute-1.0.0/tests/__init__.py +0 -0
  73. apiroute-1.0.0/tests/conftest.py +60 -0
  74. apiroute-1.0.0/tests/test_argument_targets.py +15 -0
  75. apiroute-1.0.0/tests/test_async_retry_runner.py +39 -0
  76. apiroute-1.0.0/tests/test_base_url_validator.py +17 -0
  77. apiroute-1.0.0/tests/test_body_encoder.py +21 -0
  78. apiroute-1.0.0/tests/test_client.py +97 -0
  79. apiroute-1.0.0/tests/test_client_async.py +93 -0
  80. apiroute-1.0.0/tests/test_client_config.py +60 -0
  81. apiroute-1.0.0/tests/test_dataclass_converter.py +39 -0
  82. apiroute-1.0.0/tests/test_generated_signature.py +73 -0
  83. apiroute-1.0.0/tests/test_http_method.py +36 -0
  84. apiroute-1.0.0/tests/test_model_types.py +44 -0
  85. apiroute-1.0.0/tests/test_namespace.py +45 -0
  86. apiroute-1.0.0/tests/test_path_template.py +24 -0
  87. apiroute-1.0.0/tests/test_placeholder_resolver.py +24 -0
  88. apiroute-1.0.0/tests/test_pydantic_converter.py +20 -0
  89. apiroute-1.0.0/tests/test_query_encoder.py +25 -0
  90. apiroute-1.0.0/tests/test_request_builder.py +100 -0
  91. apiroute-1.0.0/tests/test_request_hooks.py +74 -0
  92. apiroute-1.0.0/tests/test_request_options.py +26 -0
  93. apiroute-1.0.0/tests/test_response_reader.py +47 -0
  94. apiroute-1.0.0/tests/test_result.py +75 -0
  95. apiroute-1.0.0/tests/test_retries.py +38 -0
  96. apiroute-1.0.0/tests/test_retry_policy.py +41 -0
  97. apiroute-1.0.0/tests/test_retry_runner.py +34 -0
  98. apiroute-1.0.0/tests/test_route_compiler.py +82 -0
  99. apiroute-1.0.0/tests/test_route_decorator.py +79 -0
  100. apiroute-1.0.0/tests/test_router_view.py +54 -0
  101. apiroute-1.0.0/tests/test_section.py +58 -0
  102. apiroute-1.0.0/tests/test_stream_path_validator.py +19 -0
  103. apiroute-1.0.0/tests/test_streaming.py +108 -0
  104. apiroute-1.0.0/tests/test_streaming_async.py +57 -0
  105. apiroute-1.0.0/tests/test_transport_configuration.py +79 -0
  106. apiroute-1.0.0/tests/test_typed_returns.py +107 -0
  107. apiroute-1.0.0/tests/test_value_template.py +38 -0
  108. apiroute-1.0.0/tests/transports/__init__.py +0 -0
  109. apiroute-1.0.0/tests/transports/conftest.py +44 -0
  110. apiroute-1.0.0/tests/transports/test_async_httpx_client_transport.py +19 -0
  111. apiroute-1.0.0/tests/transports/test_async_one_shot_transport.py +15 -0
  112. apiroute-1.0.0/tests/transports/test_async_pooled_transport.py +40 -0
  113. apiroute-1.0.0/tests/transports/test_httpx_client_factory.py +22 -0
  114. apiroute-1.0.0/tests/transports/test_httpx_client_transport.py +19 -0
  115. apiroute-1.0.0/tests/transports/test_one_shot_transport.py +12 -0
  116. apiroute-1.0.0/tests/transports/test_pooled_transport.py +32 -0
  117. apiroute-1.0.0/tests/transports/test_streaming_transports.py +34 -0
  118. apiroute-1.0.0/tests/transports/test_thread_local_transport.py +35 -0
  119. apiroute-1.0.0/tests/transports/test_transport_registry.py +48 -0
  120. apiroute-1.0.0/uv.lock +470 -0
@@ -0,0 +1,53 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_call: # run by release.yml before publishing
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ test:
14
+ runs-on: ubuntu-latest
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - uses: astral-sh/setup-uv@v6
22
+ with:
23
+ python-version: ${{ matrix.python-version }}
24
+ - run: uv sync
25
+ - run: uv run ruff check .
26
+ - run: uv run ruff format --check .
27
+ - run: uv run pytest
28
+
29
+ lowest-dependencies:
30
+ runs-on: ubuntu-latest
31
+ steps:
32
+ - uses: actions/checkout@v4
33
+ - uses: astral-sh/setup-uv@v6
34
+ with:
35
+ python-version: "3.10"
36
+ - run: uv sync --resolution lowest-direct
37
+ - run: uv run --no-sync python -c "import httpx, pydantic; print(httpx.__version__, pydantic.VERSION)"
38
+ - run: uv run --no-sync pytest
39
+
40
+ without-pydantic:
41
+ runs-on: ubuntu-latest
42
+ steps:
43
+ - uses: actions/checkout@v4
44
+ - uses: astral-sh/setup-uv@v6
45
+ with:
46
+ python-version: "3.14"
47
+ - run: uv sync
48
+ - run: uv pip uninstall pydantic
49
+ - run: >-
50
+ uv run --no-sync pytest
51
+ --ignore=tests/test_pydantic_converter.py
52
+ --ignore=tests/test_typed_returns.py
53
+ --ignore=tests/test_model_types.py
@@ -0,0 +1,107 @@
1
+ name: Release
2
+
3
+ # release-please keeps a release PR open that bumps the version in pyproject.toml and
4
+ # CHANGELOG.md from conventional commits. Merging it tags the release, creates the GitHub
5
+ # release and publishes the package to PyPI.
6
+ # Run it manually (workflow_dispatch) to retry publishing the current version.
7
+ on:
8
+ push:
9
+ branches: [main]
10
+ workflow_dispatch:
11
+
12
+ permissions:
13
+ contents: read
14
+
15
+ concurrency:
16
+ group: release
17
+ cancel-in-progress: false
18
+
19
+ jobs:
20
+ release-please:
21
+ runs-on: ubuntu-latest
22
+ permissions:
23
+ contents: write # release commits, tags and GitHub releases
24
+ issues: write # release labels
25
+ pull-requests: write # the release PR
26
+ outputs:
27
+ release_created: ${{ steps.release.outputs.release_created }}
28
+ tag_name: ${{ steps.release.outputs.tag_name }}
29
+ version: ${{ steps.release.outputs.version }}
30
+ pr_branch: ${{ steps.release.outputs.pr && fromJSON(steps.release.outputs.pr).headBranchName || '' }}
31
+ steps:
32
+ - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
33
+ id: release
34
+ with:
35
+ config-file: release-please-config.json
36
+ manifest-file: .release-please-manifest.json
37
+
38
+ # uv.lock records apiroute's own version, which release-please doesn't update,
39
+ # so regenerate the lockfile on the release PR branch.
40
+ sync-lockfile:
41
+ needs: release-please
42
+ if: needs.release-please.outputs.pr_branch != ''
43
+ runs-on: ubuntu-latest
44
+ permissions:
45
+ contents: write # push to the release PR branch
46
+ steps:
47
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
48
+ with:
49
+ ref: ${{ needs.release-please.outputs.pr_branch }}
50
+ - uses: astral-sh/setup-uv@v6
51
+ - run: uv lock
52
+ - name: Push updated uv.lock
53
+ run: |
54
+ git diff --quiet uv.lock && exit 0
55
+ git config user.name 'github-actions[bot]'
56
+ git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
57
+ git commit -m 'build(deps): sync uv.lock version' uv.lock
58
+ git push
59
+
60
+ # The release PR is opened with GITHUB_TOKEN, which doesn't trigger CI on it,
61
+ # so run the suite here before anything is published.
62
+ ci:
63
+ needs: release-please
64
+ if: needs.release-please.outputs.release_created == 'true' || github.event_name == 'workflow_dispatch'
65
+ uses: ./.github/workflows/ci.yml
66
+
67
+ publish:
68
+ needs: [release-please, ci]
69
+ runs-on: ubuntu-latest
70
+ environment:
71
+ name: pypi
72
+ url: https://pypi.org/project/apiroute/
73
+ permissions:
74
+ contents: write # attach the distributions to the GitHub release
75
+ id-token: write # PyPI trusted publishing (OIDC)
76
+ steps:
77
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
78
+ with:
79
+ # the new tag on release; the dispatched commit on a manual retry
80
+ ref: ${{ needs.release-please.outputs.tag_name || github.sha }}
81
+ persist-credentials: false
82
+ - uses: astral-sh/setup-uv@v6
83
+
84
+ - name: Read version
85
+ run: |
86
+ version=$(uv version --short)
87
+ echo "VERSION=$version" >> "$GITHUB_ENV"
88
+ echo "TAG=v$version" >> "$GITHUB_ENV"
89
+
90
+ - name: Build
91
+ run: uv build
92
+
93
+ # A version that's already on PyPI is skipped, so a manual retry can finish a
94
+ # partially published release.
95
+ - name: Publish to PyPI
96
+ run: |
97
+ if curl -sf "https://pypi.org/pypi/apiroute/$VERSION/json" > /dev/null; then
98
+ echo "apiroute $VERSION is already published"
99
+ else
100
+ uv publish --trusted-publishing always
101
+ fi
102
+
103
+ - name: Attach distributions to GitHub release
104
+ env:
105
+ GH_TOKEN: ${{ github.token }}
106
+ run: |
107
+ gh release upload "$TAG" dist/* --repo "$GITHUB_REPOSITORY" --clobber
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ build/
8
+ *.egg-info/
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "1.0.0"
3
+ }
@@ -0,0 +1,57 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [1.0.0] - 2026-10-23
8
+
9
+ The first release: a Python port of the Ruby gem
10
+ [client-api-builder](https://github.com/dougyouch/client-api-builder), with
11
+ connection handling separated from the API definition.
12
+
13
+ ### Routes
14
+
15
+ - `@route` on a stub method, with the HTTP method inferred from its name;
16
+ `{name}` and `:name` placeholders in paths, query and body templates; a
17
+ whole-string `"{name}"` keeps the value's type
18
+ - Placeholders fall back to the client's attribute or method; `FROM_CLIENT`
19
+ makes that explicit for a parameter
20
+ - Parameters no template uses must be annotated `Query`, `Header` or `Body`,
21
+ or the class fails to build with `RouteDefinitionError`
22
+ - Routes without a `def` (`get_user = route("/users/:username")`) get a
23
+ generated keyword-only signature
24
+ - `namespace()` blocks prefix the paths of the routes inside them
25
+ - Sections: nested `Section` classes reached as `client.<name>`, inheriting
26
+ only what `inherit` lists
27
+ - `with_options()` for per-call headers, query, body (shallow-merged over a
28
+ dict body), timeout, retries and return mode
29
+
30
+ ### Responses
31
+
32
+ - Parsed JSON by default, or `returns="text" | "bytes" | "response"`
33
+ - `UnexpectedResponse` for unexpected statuses and invalid JSON
34
+ - Typed returns: a dataclass or pydantic model in the return annotation
35
+ (or `model=`) converts the JSON; `ResponseValidationError` when it doesn't fit
36
+ - A keyword-only `Result` parameter turns the method body into a response hook
37
+ - Streaming to a file, a writable object or a chunk iterator, with the status
38
+ checked before any of the body is read
39
+
40
+ ### Sending requests
41
+
42
+ - Transports chosen per environment, not per class: one-shot (default),
43
+ pooled, thread-local, or a caller-owned `httpx.Client`; fork-safe
44
+ - Async through `client.aio` on the same class, with async transports
45
+ (one-shot, pooled per event loop, caller-owned) in a registry of their own
46
+ - Retries: attempts, backoff, cap, jitter and `Retry-After`; only network
47
+ errors by default, `should_retry` to change that; every failure logged
48
+ - `on_request` (may change the request, e.g. to sign it) and `on_response`
49
+ (with elapsed time) for every attempt
50
+
51
+ ### Security
52
+
53
+ - `http`/`https`-only base URLs with a host; path values percent-encoded per
54
+ segment; URLs joined rather than resolved; TLS verified by default
55
+ - Streamed file paths with a null byte or `..` segment rejected
56
+
57
+ [1.0.0]: https://github.com/dougyouch/apiroute/releases/tag/v1.0.0
apiroute-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dougyouch
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,379 @@
1
+ Metadata-Version: 2.5
2
+ Name: apiroute
3
+ Version: 1.0.0
4
+ Summary: Build API clients declaratively: one @route per endpoint, safe defaults, httpx underneath.
5
+ Project-URL: Homepage, https://github.com/dougyouch/apiroute
6
+ Project-URL: Repository, https://github.com/dougyouch/apiroute
7
+ Project-URL: Issues, https://github.com/dougyouch/apiroute/issues
8
+ Project-URL: Changelog, https://github.com/dougyouch/apiroute/blob/main/CHANGELOG.md
9
+ Author-email: Doug Youch <dougyouch@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: api,async,client,declarative,http,httpx,rest
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Framework :: AsyncIO
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Internet :: WWW/HTTP
25
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
+ Classifier: Typing :: Typed
27
+ Requires-Python: >=3.10
28
+ Requires-Dist: httpx>=0.27
29
+ Provides-Extra: pydantic
30
+ Requires-Dist: pydantic>=2; extra == 'pydantic'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # apiroute
34
+
35
+ Build API clients declaratively: one `@route` per endpoint, safe defaults, [httpx](https://www.python-httpx.org/) underneath. A Python port of the Ruby gem [client-api-builder](https://github.com/dougyouch/client-api-builder).
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ pip install apiroute # or: uv add apiroute
41
+ pip install apiroute[pydantic] # to return pydantic models
42
+ ```
43
+
44
+ apiroute needs Python 3.10 or newer, and httpx.
45
+
46
+ ## Quick start
47
+
48
+ ```python
49
+ from apiroute import Client, Query, Retries, Section, route
50
+
51
+
52
+ class GitHubApi(Client):
53
+ base_url = "https://api.github.com"
54
+ headers = {
55
+ "Accept": "application/vnd.github.v3+json",
56
+ "Authorization": lambda self: f"Bearer {self.access_token}",
57
+ }
58
+ retries = Retries.exponential(attempts=5, initial=0.1, max=10, jitter=True)
59
+
60
+ def __init__(self, access_token: str, org: str, **kwargs):
61
+ super().__init__(**kwargs) # base_url=..., transport=...
62
+ self.access_token = access_token
63
+ self.org = org
64
+
65
+ @route("/users/{username}") # GET, inferred from the name
66
+ def get_user(self, username: str) -> dict: ...
67
+
68
+ @route("/users/{username}/repos", query={"per_page": "{per_page}"})
69
+ def get_repos(self, username: str, per_page: int = 30) -> list: ...
70
+
71
+ # POST, inferred; {org} comes from self.org
72
+ @route("/orgs/{org}/repos", body={"name": "{name}", "private": "{private}"})
73
+ def create_org_repo(self, name: str, private: bool = False) -> dict: ...
74
+
75
+ @route("POST", "/uploads")
76
+ def create_upload(self, body: dict) -> dict: ...
77
+
78
+ class issues(Section):
79
+ inherit = ("headers",)
80
+
81
+ @route("/repos/{owner}/{repo}/issues")
82
+ def list(self, owner: str, repo: str, state: Query = None) -> list: ...
83
+
84
+
85
+ gh = GitHubApi(access_token="ghp_...", org="acme")
86
+ gh.get_user("octocat")
87
+ gh.issues.list("acme", "api", state="open")
88
+ gh.with_options(timeout=5, retries=1).get_repos("octocat")
89
+ ```
90
+
91
+ ## Routes
92
+
93
+ `@route([method,] path, query=..., body=..., expected=..., returns=..., timeout=..., stream=...)`
94
+
95
+ - **Method inference.** A name that is, or starts with, `post`, `create`, `add` or `insert` followed by `_` is POST. `put`, `update`, `modify` and `change` are PUT, `patch` is PATCH, and `delete`, `remove` and `destroy` are DELETE. Anything else is GET.
96
+ - **Placeholders.** Write `{name}` in paths, query values and body values (paths also take `:name`). The value comes from the argument with that name, or else from the client's attribute or method. A string that is exactly `"{name}"` keeps the value's type.
97
+ - **Other arguments** must be annotated with `Query`, `Query("name")`, `Header("X-Name")` or `Body`. The exception is a parameter named `body` on a route without a body template. Otherwise the class fails to build with `RouteDefinitionError`.
98
+ - **Returns.** Parsed JSON by default (`None` for an empty body), or `returns="text" | "bytes" | "response"`.
99
+ - **Errors.** Any status outside `expected` (any 2xx when it's unset) raises `UnexpectedResponse`, which carries the `.response`.
100
+ - **Falling back to the client.** A parameter whose default is `FROM_CLIENT` takes the client's attribute of the same name when the caller leaves it out: `def list_members(self, org: str = FROM_CLIENT)`.
101
+
102
+ ### Response hook
103
+
104
+ Give the method a keyword-only parameter annotated `Result` and its body runs after the request. The parameter receives the processed result (parsed JSON, or whatever `returns` asks for), and a value the body returns replaces it. Returning `None` keeps the result. Callers never see the parameter.
105
+
106
+ ```python
107
+ @route("/users/{username}")
108
+ def get_login(self, username: str, *, data: Result) -> str:
109
+ return data["login"]
110
+
111
+
112
+ gh.get_login("octocat") # "octocat"
113
+ ```
114
+
115
+ ### Streaming
116
+
117
+ | `stream=` | Destination | Returns |
118
+ | --- | --- | --- |
119
+ | `"file"` | the path in a `file` parameter, written `wb` | the `httpx.Response` |
120
+ | `"io"` | the writable object in an `io` parameter, left open | the `httpx.Response` |
121
+ | `"iter"` | none | a `ChunkIterator` of `bytes` (an `AsyncChunkIterator` through `.aio`) |
122
+
123
+ ```python
124
+ @route("/files/{id}", stream="file")
125
+ def download(self, id: int, file: str): ...
126
+
127
+
128
+ with gh.get_events() as events: # stream="iter"; the with block closes the connection
129
+ for chunk in events:
130
+ ...
131
+ ```
132
+
133
+ - The status is checked before any of the body is read. An error response raises `UnexpectedResponse` with its body readable, and nothing is written: an existing file is left untouched.
134
+ - File paths with a null byte or a `..` segment are rejected before the request is sent, as in the Ruby gem. Absolute paths are allowed.
135
+ - Opening the stream is retried like any request; a failure while the body is being read is raised, not retried.
136
+ - In async calls, an `io` object whose `write` returns an awaitable (such as an aiofiles file) is awaited.
137
+
138
+ ### Typed returns
139
+
140
+ A return annotation that contains a dataclass or a pydantic model converts the JSON response. That includes `-> User`, `-> list[User]`, `-> dict[str, User]` and `-> User | None`. Routes without a `def` take `model=` instead. Plain annotations (`dict`, `list`, `Any`) leave the JSON as parsed.
141
+
142
+ ```python
143
+ @route("/users/{username}")
144
+ def get_user(self, username: str) -> User: ...
145
+
146
+
147
+ list_users = route("/users", model=list[User])
148
+ ```
149
+
150
+ - **Pydantic models** are validated with pydantic's `TypeAdapter`, with its usual type coercion. Install it with `pip install apiroute[pydantic]`.
151
+ - **Dataclasses** are built by apiroute itself, so they don't need pydantic. Keys the dataclass doesn't declare are ignored, and a missing required field fails.
152
+ - **Errors.** A body that doesn't fit raises `ResponseValidationError`, which carries the `.response` and the underlying `.error`.
153
+ - **With a response hook,** the `Result` parameter's type is used (`user: Annotated[User, Result()]`). The return annotation then describes what the hook returns.
154
+ - **Late-defined types.** A model defined below the client class (`-> "Team"`) is looked up on the first call.
155
+ - **Other return modes.** Only JSON is converted; `returns="text"`, `"bytes"`, `"response"` and streams are left alone.
156
+
157
+ ### Routes without a `def`
158
+
159
+ For simple endpoints, assign the route and let the class generate the method, as the Ruby gem does:
160
+
161
+ ```python
162
+ class GitHubApi(Client):
163
+ org: str # declared, so {org} falls back to self.org
164
+
165
+ get_user = route("/users/:username") # (*, username)
166
+ list_repos = route("/orgs/{org}/repos", query={"page": "{page}"}) # (*, org=FROM_CLIENT, page)
167
+ create_gist = route("/gists") # (*, body=None), POST
168
+
169
+
170
+ gh.get_user(username="octocat")
171
+ gh.list_repos(page=2)
172
+ ```
173
+
174
+ - Every placeholder becomes a keyword-only parameter, in path, then query, then body order.
175
+ - A name the class declares (a class attribute, method, property or annotation such as `org: str`) is optional and falls back to the client. In a section, declare it on the section; the value still comes from the root client.
176
+ - POST, PUT and PATCH routes without a body template take an optional `body`.
177
+ - `help()`, `inspect.signature` and wrong-argument `TypeError`s work. Editors and type checkers can't see a signature made at runtime, though, so prefer a `def` where autocomplete and type checking matter.
178
+
179
+ ### Namespaces
180
+
181
+ Routes declared inside a `namespace` block get its path prefix, as in the gem. Blocks nest.
182
+
183
+ ```python
184
+ class Api(Client):
185
+ with namespace("/admin"):
186
+ list_users = route("/users") # GET /admin/users
187
+
188
+ @route("/users/{id}")
189
+ def delete_user(self, id: int): ... # DELETE /admin/users/{id}
190
+ ```
191
+
192
+ ### Per-request options
193
+
194
+ `client.with_options(headers=..., query=..., body=..., timeout=..., retries=..., returns=...)` returns a view of the client whose route calls also send these options. Calls can be chained.
195
+
196
+ - `headers` and `query` merge over the class and route values. A `None` value leaves that header or param out.
197
+ - `body` is shallow-merged over the route's body when both are dicts, and a `None` value removes that key. Any other body (a list or a string, or when the route has no dict body) replaces the route's body.
198
+
199
+ ```python
200
+ @route("/users", body={"user": {"name": "{name}"}, "notify": True})
201
+ def create_user(self, name: str) -> dict: ...
202
+
203
+
204
+ client.with_options(body={"notify": None, "source": "import"}).create_user("Ann")
205
+ # sends {"user": {"name": "Ann"}, "source": "import"}
206
+ ```
207
+
208
+ ## Sections
209
+
210
+ A nested class that subclasses `Section` groups routes under the client, as `client.<class name>`:
211
+
212
+ ```python
213
+ class Api(Client):
214
+ base_url = "https://api.example.com"
215
+ headers = {"Authorization": lambda self: f"Bearer {self.token}"}
216
+
217
+ class users(Section):
218
+ inherit = ("headers",) # use the client's headers too
219
+
220
+ list = route("/users")
221
+
222
+ class uploads(Section):
223
+ base_url = "https://uploads.example.com" # its own host
224
+ headers = {"Authorization": lambda self: f"Upload {self.upload_token}"}
225
+
226
+ create = route("/files")
227
+
228
+
229
+ api.users.list()
230
+ api.uploads.create(body=data)
231
+ ```
232
+
233
+ - A section uses its parent's `base_url` unless it sets one. It inherits nothing else unless listed in `inherit` (`"headers"`, `"query_params"`), as in the gem.
234
+ - `{name}` placeholders, callable headers, the overridable methods and the transport all belong to the root client, so a section can use the client's state.
235
+ - Sections nest, are created once per client, and remain reachable as classes (`Api.users`).
236
+
237
+ ## Retries
238
+
239
+ ```python
240
+ retries = Retries(attempts=3, sleep=0.5) # 3 attempts in total, 0.5 s apart
241
+ retries = Retries.exponential(attempts=5, initial=0.1, max=10) # 0.1, 0.2, 0.4, 0.8 s
242
+ retries = Retries(attempts=5, sleep=0.1, backoff=2, max_sleep=10, jitter=True)
243
+ ```
244
+
245
+ - `attempts` is the total number of attempts. The default is one, so nothing is retried until you configure it.
246
+ - The sleep before retry n is `sleep * backoff ** (n - 1)`, capped at `max_sleep`. `jitter=True` sleeps a random time up to that; a number from 0 to 1 shortens it by up to that fraction.
247
+ - Only timeouts, network errors and protocol errors are retried, unless `should_retry` says otherwise. A retried `UnexpectedResponse` with a `Retry-After` header waits that long, capped at `max_sleep`.
248
+ - `with_options(retries=3)` changes the attempts for one call; `with_options(retries=Retries(...))` replaces the policy.
249
+
250
+ ## Errors
251
+
252
+ | Exception | Raised when | Carries |
253
+ | --- | --- | --- |
254
+ | `RouteDefinitionError` | a route is wrong (an unused parameter, a bad template value or option); raised when the class is created | the class, route and reason in its message |
255
+ | `UnexpectedResponse` | the status isn't expected (any 2xx when `expected` is unset), or a JSON route's body isn't JSON | `.response`, `.status_code`, `.request`, `.route_name` |
256
+ | `ResponseValidationError` | the JSON doesn't fit the route's model | `.response`, `.error`, `.route_name` |
257
+ | `httpx.TransportError` subclasses | the network fails, after retries run out | httpx's details |
258
+ | `ValueError` | a class setting is invalid (a `base_url` scheme, `body_format`, `inherit`), or a streamed file path is unsafe | the reason in its message |
259
+
260
+ The first three derive from `ApiRouteError`. `client.last_response` holds the most recent response for debugging; it is per client, so don't rely on it when one client is shared between threads.
261
+
262
+ ## Class settings
263
+
264
+ | Attribute | Default |
265
+ | --- | --- |
266
+ | `base_url` | none; http or https only |
267
+ | `headers`, `query_params` | `{}`. Values may be callables taking the client. Both merge down subclasses, and `None` removes a key |
268
+ | `timeout` | `30` seconds |
269
+ | `retries` | `Retries()` (one attempt) |
270
+ | `body_format` | `"json"`, `"form"` or `callable(client, data)` |
271
+ | `query_format` | `"flat"`, `"nested"` (`a[b]=1`) or `callable(client, data)` returning pairs |
272
+
273
+ ### Methods to override
274
+
275
+ | Method | Runs | Default |
276
+ | --- | --- | --- |
277
+ | `on_request(self, request)` | before every attempt, sync or async; may return a changed `RequestSpec` | sends the request as is |
278
+ | `on_response(self, request, response, elapsed)` | after every response, whatever its status; for streams, before the body is read | nothing |
279
+ | `should_retry(self, error, attempt)` | when an attempt fails | retries httpx timeouts, network and protocol errors |
280
+ | `escape_path(self, value)` | for every path value | percent-encodes everything but `A-Z a-z 0-9 - . _ ~` |
281
+
282
+ ```python
283
+ class SignedApi(Client):
284
+ def on_request(self, request):
285
+ signature = hmac.new(self.secret, request.url.encode(), "sha256").hexdigest()
286
+ return dataclasses.replace(request, headers={**request.headers, "X-Signature": signature})
287
+
288
+ def on_response(self, request, response, elapsed):
289
+ metrics.timing("api.request", elapsed, tags=[f"status:{response.status_code}"])
290
+ ```
291
+
292
+ Network errors don't reach `on_response`; every failed attempt is logged on the `apiroute` logger, with the route, the attempt number and the error.
293
+
294
+ ## Transports: how requests are sent
295
+
296
+ An API class only describes the API. How its requests reach the network is a **transport**, chosen for the environment at startup rather than baked into the class. The same `GitHubApi` can open a fresh connection per request in a web server and keep a pool per thread in a background worker.
297
+
298
+ | Transport | Connections | Suits |
299
+ | --- | --- | --- |
300
+ | `OneShotTransport` (default) | a new one per request, closed once the response is read | web servers, forking process managers, low request volume |
301
+ | `PooledTransport` | one `httpx.Client` (one pool) shared by all threads | many threads or short-lived threads, HTTP/2 |
302
+ | `ThreadLocalTransport` | one `httpx.Client` per thread, closed when the thread ends | a fixed set of worker threads |
303
+ | `HttpxClientTransport` | an `httpx.Client` you own | tests (with `httpx.MockTransport`), custom setups |
304
+
305
+ Each takes `httpx.Client` keyword arguments, such as `limits`, `http2=True`, `proxy` and `verify`. The SSL context is built once per transport, so a one-shot request costs a new connection but not a reload of the CA bundle. Pooled and thread-local transports open new clients in a forked child.
306
+
307
+ ```python
308
+ import httpx
309
+ from apiroute import (
310
+ HttpxClientTransport,
311
+ PooledTransport,
312
+ ThreadLocalTransport,
313
+ close_transports,
314
+ use_transport,
315
+ )
316
+
317
+ # worker.py: each worker thread keeps its own connections
318
+ use_transport(ThreadLocalTransport())
319
+
320
+ # or one shared pool for one API, with HTTP/2
321
+ use_transport(PooledTransport(http2=True, limits=httpx.Limits(max_connections=20)), GitHubApi)
322
+
323
+ # one instance, e.g. in a test
324
+ gh = GitHubApi("token", "acme", transport=HttpxClientTransport(httpx.Client(transport=mock)))
325
+
326
+ close_transports() # at shutdown
327
+ ```
328
+
329
+ A client uses its own `transport=` argument if it was given one. Otherwise it uses the transport registered for its class or the nearest base class, and otherwise the process default. Sections use their root client's transport. A custom transport is any object with `send(spec) -> httpx.Response`, `stream(spec)` (a context manager yielding an unread `httpx.Response`, needed only for streaming routes) and `close()`.
330
+
331
+ ## Async
332
+
333
+ The same client class works with `await`: `client.aio` is a view whose route calls return coroutines.
334
+
335
+ ```python
336
+ user = gh.get_user("octocat") # sync
337
+ user = await gh.aio.get_user("octocat") # async, same class and routes
338
+ issues = await gh.aio.issues.list("acme", "api") # sections too
339
+ await gh.aio.with_options(timeout=5).get_user("a") # with_options, in either order
340
+ ```
341
+
342
+ Async calls have transports of their own, registered beside the sync ones. `use_transport` sends an async transport (one whose `send` is a coroutine) to the async side, so configuring one side never changes the other.
343
+
344
+ | Transport | Connections |
345
+ | --- | --- |
346
+ | `AsyncOneShotTransport` (default) | a new one per request |
347
+ | `AsyncPooledTransport` | one `httpx.AsyncClient` per event loop (an `AsyncClient` can't be shared across loops) |
348
+ | `AsyncHttpxClientTransport` | an `httpx.AsyncClient` you own |
349
+
350
+ ```python
351
+ use_transport(AsyncPooledTransport(http2=True)) # async default for every client
352
+ gh = GitHubApi("token", "acme", async_transport=AsyncHttpxClientTransport(async_client))
353
+ await aclose_transports() # at shutdown, inside the event loop
354
+ ```
355
+
356
+ Retries sleep with `asyncio.sleep`. `should_retry`, `escape_path`, `on_request`, `on_response` and callable headers stay plain sync methods, shared by both sides.
357
+
358
+ ## Security
359
+
360
+ - **Base URLs** must be `http` or `https` and have a host. This is checked when the class is created and when a `base_url` is passed to a client, which keeps a misconfiguration from reaching `file:` and similar schemes.
361
+ - **Path values** are percent-encoded so each stays one path segment: `get_file(name="a/b c")` requests `/files/a%2Fb%20c`, so a value can never add `..` or a segment. Override `escape_path` to allow `/`.
362
+ - **URLs are joined, not resolved:** the request URL is `base_url` plus the path, so a route can't replace the host.
363
+ - **TLS** is verified by default (httpx). Turning it off takes an explicit `verify=False` on a transport.
364
+ - **Streamed files** are only opened once the status has passed, and paths with a null byte or a `..` segment are rejected.
365
+
366
+ ## Thread safety
367
+
368
+ - Route definitions are compiled once, when the class is created, and never change afterwards.
369
+ - `PooledTransport` is safe to share between threads, and `ThreadLocalTransport` gives each thread its own connections. `OneShotTransport` shares nothing.
370
+ - A client instance holds no connections, so one instance can be shared between threads. The exception is `last_response`, which is per instance and changes with every request.
371
+ - `namespace` blocks are tracked per thread.
372
+
373
+ ## Development
374
+
375
+ ```bash
376
+ uv sync
377
+ uv run pytest
378
+ uv run ruff check .
379
+ ```