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.
- apiroute-1.0.0/.github/workflows/ci.yml +53 -0
- apiroute-1.0.0/.github/workflows/release.yml +107 -0
- apiroute-1.0.0/.gitignore +8 -0
- apiroute-1.0.0/.release-please-manifest.json +3 -0
- apiroute-1.0.0/CHANGELOG.md +57 -0
- apiroute-1.0.0/LICENSE +21 -0
- apiroute-1.0.0/PKG-INFO +379 -0
- apiroute-1.0.0/README.md +347 -0
- apiroute-1.0.0/pyproject.toml +61 -0
- apiroute-1.0.0/release-please-config.json +10 -0
- apiroute-1.0.0/src/apiroute/__init__.py +57 -0
- apiroute-1.0.0/src/apiroute/api_route_error.py +2 -0
- apiroute-1.0.0/src/apiroute/argument_targets.py +33 -0
- apiroute-1.0.0/src/apiroute/async_chunk_iterator.py +35 -0
- apiroute-1.0.0/src/apiroute/async_retry_runner.py +26 -0
- apiroute-1.0.0/src/apiroute/base_url_validator.py +15 -0
- apiroute-1.0.0/src/apiroute/body_encoder.py +25 -0
- apiroute-1.0.0/src/apiroute/chunk_iterator.py +36 -0
- apiroute-1.0.0/src/apiroute/client.py +85 -0
- apiroute-1.0.0/src/apiroute/client_config.py +95 -0
- apiroute-1.0.0/src/apiroute/dataclass_converter.py +56 -0
- apiroute-1.0.0/src/apiroute/deferred_converter.py +22 -0
- apiroute-1.0.0/src/apiroute/from_client.py +9 -0
- apiroute-1.0.0/src/apiroute/generated_signature.py +93 -0
- apiroute-1.0.0/src/apiroute/http_method.py +44 -0
- apiroute-1.0.0/src/apiroute/model_types.py +43 -0
- apiroute-1.0.0/src/apiroute/namespace.py +34 -0
- apiroute-1.0.0/src/apiroute/path_template.py +25 -0
- apiroute-1.0.0/src/apiroute/placeholder_resolver.py +20 -0
- apiroute-1.0.0/src/apiroute/py.typed +0 -0
- apiroute-1.0.0/src/apiroute/pydantic_converter.py +15 -0
- apiroute-1.0.0/src/apiroute/query_encoder.py +50 -0
- apiroute-1.0.0/src/apiroute/request_builder.py +139 -0
- apiroute-1.0.0/src/apiroute/request_options.py +56 -0
- apiroute-1.0.0/src/apiroute/request_spec.py +13 -0
- apiroute-1.0.0/src/apiroute/response_converter.py +22 -0
- apiroute-1.0.0/src/apiroute/response_reader.py +58 -0
- apiroute-1.0.0/src/apiroute/response_validation_error.py +13 -0
- apiroute-1.0.0/src/apiroute/result.py +9 -0
- apiroute-1.0.0/src/apiroute/retries.py +50 -0
- apiroute-1.0.0/src/apiroute/retry_policy.py +66 -0
- apiroute-1.0.0/src/apiroute/retry_runner.py +26 -0
- apiroute-1.0.0/src/apiroute/route.py +42 -0
- apiroute-1.0.0/src/apiroute/route_compiler.py +214 -0
- apiroute-1.0.0/src/apiroute/route_decorator.py +23 -0
- apiroute-1.0.0/src/apiroute/route_definition.py +48 -0
- apiroute-1.0.0/src/apiroute/route_definition_error.py +5 -0
- apiroute-1.0.0/src/apiroute/route_executor.py +113 -0
- apiroute-1.0.0/src/apiroute/route_method.py +75 -0
- apiroute-1.0.0/src/apiroute/route_spec.py +15 -0
- apiroute-1.0.0/src/apiroute/router.py +52 -0
- apiroute-1.0.0/src/apiroute/router_view.py +35 -0
- apiroute-1.0.0/src/apiroute/section.py +27 -0
- apiroute-1.0.0/src/apiroute/section_accessor.py +25 -0
- apiroute-1.0.0/src/apiroute/stream_path_validator.py +16 -0
- apiroute-1.0.0/src/apiroute/stream_reader.py +82 -0
- apiroute-1.0.0/src/apiroute/transport_configuration.py +37 -0
- apiroute-1.0.0/src/apiroute/transports/__init__.py +25 -0
- apiroute-1.0.0/src/apiroute/transports/async_httpx_client_transport.py +25 -0
- apiroute-1.0.0/src/apiroute/transports/async_one_shot_transport.py +33 -0
- apiroute-1.0.0/src/apiroute/transports/async_pooled_transport.py +84 -0
- apiroute-1.0.0/src/apiroute/transports/async_transport.py +18 -0
- apiroute-1.0.0/src/apiroute/transports/httpx_client_factory.py +32 -0
- apiroute-1.0.0/src/apiroute/transports/httpx_client_transport.py +33 -0
- apiroute-1.0.0/src/apiroute/transports/one_shot_transport.py +34 -0
- apiroute-1.0.0/src/apiroute/transports/pooled_transport.py +44 -0
- apiroute-1.0.0/src/apiroute/transports/thread_local_transport.py +60 -0
- apiroute-1.0.0/src/apiroute/transports/transport.py +22 -0
- apiroute-1.0.0/src/apiroute/transports/transport_registry.py +52 -0
- apiroute-1.0.0/src/apiroute/unexpected_response.py +18 -0
- apiroute-1.0.0/src/apiroute/value_template.py +59 -0
- apiroute-1.0.0/tests/__init__.py +0 -0
- apiroute-1.0.0/tests/conftest.py +60 -0
- apiroute-1.0.0/tests/test_argument_targets.py +15 -0
- apiroute-1.0.0/tests/test_async_retry_runner.py +39 -0
- apiroute-1.0.0/tests/test_base_url_validator.py +17 -0
- apiroute-1.0.0/tests/test_body_encoder.py +21 -0
- apiroute-1.0.0/tests/test_client.py +97 -0
- apiroute-1.0.0/tests/test_client_async.py +93 -0
- apiroute-1.0.0/tests/test_client_config.py +60 -0
- apiroute-1.0.0/tests/test_dataclass_converter.py +39 -0
- apiroute-1.0.0/tests/test_generated_signature.py +73 -0
- apiroute-1.0.0/tests/test_http_method.py +36 -0
- apiroute-1.0.0/tests/test_model_types.py +44 -0
- apiroute-1.0.0/tests/test_namespace.py +45 -0
- apiroute-1.0.0/tests/test_path_template.py +24 -0
- apiroute-1.0.0/tests/test_placeholder_resolver.py +24 -0
- apiroute-1.0.0/tests/test_pydantic_converter.py +20 -0
- apiroute-1.0.0/tests/test_query_encoder.py +25 -0
- apiroute-1.0.0/tests/test_request_builder.py +100 -0
- apiroute-1.0.0/tests/test_request_hooks.py +74 -0
- apiroute-1.0.0/tests/test_request_options.py +26 -0
- apiroute-1.0.0/tests/test_response_reader.py +47 -0
- apiroute-1.0.0/tests/test_result.py +75 -0
- apiroute-1.0.0/tests/test_retries.py +38 -0
- apiroute-1.0.0/tests/test_retry_policy.py +41 -0
- apiroute-1.0.0/tests/test_retry_runner.py +34 -0
- apiroute-1.0.0/tests/test_route_compiler.py +82 -0
- apiroute-1.0.0/tests/test_route_decorator.py +79 -0
- apiroute-1.0.0/tests/test_router_view.py +54 -0
- apiroute-1.0.0/tests/test_section.py +58 -0
- apiroute-1.0.0/tests/test_stream_path_validator.py +19 -0
- apiroute-1.0.0/tests/test_streaming.py +108 -0
- apiroute-1.0.0/tests/test_streaming_async.py +57 -0
- apiroute-1.0.0/tests/test_transport_configuration.py +79 -0
- apiroute-1.0.0/tests/test_typed_returns.py +107 -0
- apiroute-1.0.0/tests/test_value_template.py +38 -0
- apiroute-1.0.0/tests/transports/__init__.py +0 -0
- apiroute-1.0.0/tests/transports/conftest.py +44 -0
- apiroute-1.0.0/tests/transports/test_async_httpx_client_transport.py +19 -0
- apiroute-1.0.0/tests/transports/test_async_one_shot_transport.py +15 -0
- apiroute-1.0.0/tests/transports/test_async_pooled_transport.py +40 -0
- apiroute-1.0.0/tests/transports/test_httpx_client_factory.py +22 -0
- apiroute-1.0.0/tests/transports/test_httpx_client_transport.py +19 -0
- apiroute-1.0.0/tests/transports/test_one_shot_transport.py +12 -0
- apiroute-1.0.0/tests/transports/test_pooled_transport.py +32 -0
- apiroute-1.0.0/tests/transports/test_streaming_transports.py +34 -0
- apiroute-1.0.0/tests/transports/test_thread_local_transport.py +35 -0
- apiroute-1.0.0/tests/transports/test_transport_registry.py +48 -0
- 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,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.
|
apiroute-1.0.0/PKG-INFO
ADDED
|
@@ -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
|
+
```
|