fastapi-router-versioning 0.1.0__tar.gz → 0.1.2__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.
- fastapi_router_versioning-0.1.2/.github/workflows/prepare-release.yml +81 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/.github/workflows/test-redistribute.yml +27 -4
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/.github/workflows/test.yml +29 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/PKG-INFO +15 -16
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/README.md +13 -14
- fastapi_router_versioning-0.1.2/RELEASE_NOTES.md +47 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/fastapi_router_versioning/__init__.py +1 -1
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/fastapi_router_versioning/versioner.py +43 -23
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/pyproject.toml +2 -1
- fastapi_router_versioning-0.1.2/scripts/prepare_release.py +189 -0
- fastapi_router_versioning-0.1.2/tests/test_prepare_release.py +211 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/tests/test_versioner.py +81 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/uv.lock +26 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/.github/dependabot.yml +0 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/.github/workflows/detect-conflicts.yml +0 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/.github/workflows/pre-commit.yml +0 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/.github/workflows/publish.yml +0 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/.gitignore +0 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/.pre-commit-config.yaml +0 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/.python-version +0 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/LICENSE +0 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/examples/calver_app.py +0 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/examples/download_static_assets.py +0 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/examples/multi_router_app.py +0 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/examples/openapi_hook_app.py +0 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/examples/self_hosted_docs_app.py +0 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/examples/semver_app.py +0 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/examples/semver_major_only_app.py +0 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/examples/webhook_versioning_app.py +0 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/fastapi_router_versioning/py.typed +0 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/scripts/format.sh +0 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/scripts/lint.sh +0 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/scripts/test-cov-html.sh +0 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/scripts/test-cov.sh +0 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/scripts/test.sh +0 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/tests/__init__.py +0 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/tests/test_calver.py +0 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/tests/test_fastapi_integration.py +0 -0
- {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/tests/test_semver.py +0 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
name: Prepare Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
inputs:
|
|
6
|
+
bump:
|
|
7
|
+
description: Release bump
|
|
8
|
+
required: true
|
|
9
|
+
type: choice
|
|
10
|
+
options:
|
|
11
|
+
- patch
|
|
12
|
+
- minor
|
|
13
|
+
- major
|
|
14
|
+
date:
|
|
15
|
+
description: Release date in YYYY-MM-DD format. Defaults to today.
|
|
16
|
+
required: false
|
|
17
|
+
type: string
|
|
18
|
+
|
|
19
|
+
permissions: {}
|
|
20
|
+
|
|
21
|
+
env:
|
|
22
|
+
PREPARE_RELEASE_VERSION_FILE: fastapi_router_versioning/__init__.py
|
|
23
|
+
PREPARE_RELEASE_RELEASE_NOTES_FILE: RELEASE_NOTES.md
|
|
24
|
+
|
|
25
|
+
jobs:
|
|
26
|
+
prepare-release:
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
timeout-minutes: 5
|
|
29
|
+
permissions:
|
|
30
|
+
contents: write
|
|
31
|
+
issues: write
|
|
32
|
+
pull-requests: write
|
|
33
|
+
steps:
|
|
34
|
+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
|
35
|
+
with:
|
|
36
|
+
persist-credentials: true
|
|
37
|
+
- name: Set up Python
|
|
38
|
+
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
|
39
|
+
with:
|
|
40
|
+
python-version-file: ".python-version"
|
|
41
|
+
- name: Install uv
|
|
42
|
+
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
|
43
|
+
with:
|
|
44
|
+
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
|
|
45
|
+
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
|
|
46
|
+
version: "0.11.18"
|
|
47
|
+
- name: Prepare release
|
|
48
|
+
env:
|
|
49
|
+
PREPARE_RELEASE_BUMP: ${{ inputs.bump }}
|
|
50
|
+
PREPARE_RELEASE_DATE: ${{ inputs.date }}
|
|
51
|
+
run: uv run python scripts/prepare_release.py prepare
|
|
52
|
+
- name: Get release version
|
|
53
|
+
id: release-version
|
|
54
|
+
run: |
|
|
55
|
+
version="$(uv run python scripts/prepare_release.py current-version)"
|
|
56
|
+
echo "$version"
|
|
57
|
+
echo "version=$version" >> "$GITHUB_OUTPUT"
|
|
58
|
+
- name: Ensure release label exists
|
|
59
|
+
env:
|
|
60
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
61
|
+
run: |
|
|
62
|
+
gh label create release --color 0075ca --description "Release PR" 2>/dev/null || true
|
|
63
|
+
- name: Create release pull request
|
|
64
|
+
env:
|
|
65
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
66
|
+
VERSION: ${{ steps.release-version.outputs.version }}
|
|
67
|
+
run: |
|
|
68
|
+
set -euo pipefail
|
|
69
|
+
branch="release-${VERSION}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
|
70
|
+
git config user.name "github-actions[bot]"
|
|
71
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
72
|
+
git switch -c "$branch"
|
|
73
|
+
git add $PREPARE_RELEASE_VERSION_FILE $PREPARE_RELEASE_RELEASE_NOTES_FILE
|
|
74
|
+
git commit -m "🔖 Release version ${VERSION}"
|
|
75
|
+
git push --set-upstream origin "$branch"
|
|
76
|
+
gh pr create \
|
|
77
|
+
--base main \
|
|
78
|
+
--head "$branch" \
|
|
79
|
+
--title "🔖 Release version ${VERSION}" \
|
|
80
|
+
--body "Prepare release ${VERSION}." \
|
|
81
|
+
--label release
|
|
@@ -12,7 +12,33 @@ on:
|
|
|
12
12
|
permissions: {}
|
|
13
13
|
|
|
14
14
|
jobs:
|
|
15
|
+
changes:
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
permissions:
|
|
18
|
+
pull-requests: read
|
|
19
|
+
timeout-minutes: 5
|
|
20
|
+
outputs:
|
|
21
|
+
src: ${{ steps.filter.outputs.src }}
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
|
24
|
+
with:
|
|
25
|
+
persist-credentials: false
|
|
26
|
+
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
|
27
|
+
id: filter
|
|
28
|
+
with:
|
|
29
|
+
filters: |
|
|
30
|
+
src:
|
|
31
|
+
- .github/workflows/test-redistribute.yml
|
|
32
|
+
- fastapi_router_versioning/**
|
|
33
|
+
- tests/**
|
|
34
|
+
- .python-version
|
|
35
|
+
- pyproject.toml
|
|
36
|
+
- uv.lock
|
|
37
|
+
|
|
15
38
|
test-redistribute:
|
|
39
|
+
needs:
|
|
40
|
+
- changes
|
|
41
|
+
if: needs.changes.outputs.src == 'true' || github.ref == 'refs/heads/main'
|
|
16
42
|
runs-on: ubuntu-latest
|
|
17
43
|
timeout-minutes: 5
|
|
18
44
|
permissions:
|
|
@@ -49,10 +75,6 @@ jobs:
|
|
|
49
75
|
run: |
|
|
50
76
|
cd dist
|
|
51
77
|
pip wheel --no-deps fastapi_router_versioning*.tar.gz
|
|
52
|
-
- name: Dump GitHub context
|
|
53
|
-
env:
|
|
54
|
-
GITHUB_CONTEXT: ${{ toJson(github) }}
|
|
55
|
-
run: echo "$GITHUB_CONTEXT"
|
|
56
78
|
|
|
57
79
|
# https://github.com/marketplace/actions/alls-green#why
|
|
58
80
|
test-redistribute-alls-green:
|
|
@@ -66,3 +88,4 @@ jobs:
|
|
|
66
88
|
uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2
|
|
67
89
|
with:
|
|
68
90
|
jobs: ${{ toJSON(needs) }}
|
|
91
|
+
allowed-skips: test-redistribute
|
{fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/.github/workflows/test.yml
RENAMED
|
@@ -17,7 +17,34 @@ env:
|
|
|
17
17
|
UV_NO_SYNC: true
|
|
18
18
|
|
|
19
19
|
jobs:
|
|
20
|
+
changes:
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
permissions:
|
|
23
|
+
pull-requests: read
|
|
24
|
+
timeout-minutes: 5
|
|
25
|
+
outputs:
|
|
26
|
+
src: ${{ steps.filter.outputs.src }}
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
|
29
|
+
with:
|
|
30
|
+
persist-credentials: false
|
|
31
|
+
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
|
32
|
+
id: filter
|
|
33
|
+
with:
|
|
34
|
+
filters: |
|
|
35
|
+
src:
|
|
36
|
+
- .github/workflows/test.yml
|
|
37
|
+
- fastapi_router_versioning/**
|
|
38
|
+
- scripts/**
|
|
39
|
+
- tests/**
|
|
40
|
+
- .python-version
|
|
41
|
+
- pyproject.toml
|
|
42
|
+
- uv.lock
|
|
43
|
+
|
|
20
44
|
test:
|
|
45
|
+
needs:
|
|
46
|
+
- changes
|
|
47
|
+
if: needs.changes.outputs.src == 'true' || github.ref == 'refs/heads/main'
|
|
21
48
|
timeout-minutes: 15
|
|
22
49
|
permissions:
|
|
23
50
|
contents: read
|
|
@@ -134,6 +161,7 @@ jobs:
|
|
|
134
161
|
check:
|
|
135
162
|
if: always()
|
|
136
163
|
needs:
|
|
164
|
+
- test
|
|
137
165
|
- coverage-combine
|
|
138
166
|
runs-on: ubuntu-latest
|
|
139
167
|
timeout-minutes: 5
|
|
@@ -143,3 +171,4 @@ jobs:
|
|
|
143
171
|
uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2
|
|
144
172
|
with:
|
|
145
173
|
jobs: ${{ toJSON(needs) }}
|
|
174
|
+
allowed-skips: test,coverage-combine
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-router-versioning
|
|
3
|
-
Version: 0.1.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Router-based API versioning for FastAPI, with per-version docs and a declarative route lifecycle.
|
|
5
5
|
Project-URL: Homepage, https://github.com/mat81black/fastapi-router-versioning
|
|
6
6
|
Project-URL: Repository, https://github.com/mat81black/fastapi-router-versioning
|
|
7
7
|
Project-URL: Issues, https://github.com/mat81black/fastapi-router-versioning/issues
|
|
@@ -26,15 +26,15 @@ Requires-Python: >=3.10
|
|
|
26
26
|
Requires-Dist: fastapi!=0.137.0,!=0.137.1,>=0.120.0
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
28
28
|
|
|
29
|
-
# FastAPI Router Versioning
|
|
29
|
+
# FastAPI Router Versioning
|
|
30
30
|
|
|
31
31
|
[](https://pypi.org/project/fastapi-router-versioning/)
|
|
32
32
|
[](LICENSE)
|
|
33
33
|
[](https://pypi.org/project/fastapi-router-versioning/)
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
Router-based API versioning for FastAPI.
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
FastAPI has no built-in versioning mechanism. The common workaround — duplicating routers or managing prefixes manually — breaks down quickly as the number of versions grows. This package adds declarative versioning directly on routes, with isolated URL prefixes, per-version Swagger UI, and a full route lifecycle, without touching the existing application structure.
|
|
38
38
|
|
|
39
39
|
---
|
|
40
40
|
|
|
@@ -93,7 +93,7 @@ RouterVersioner(app=app, routers=router, version_format=VersionFormat.SEMVER).ve
|
|
|
93
93
|
# Mounts: GET /v1_0/items GET /v2_0/items
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
-
Each version
|
|
96
|
+
Each version gets its own Swagger UI at `/v1_0/docs`, `/v2_0/docs`, and so on.
|
|
97
97
|
|
|
98
98
|
---
|
|
99
99
|
|
|
@@ -117,9 +117,9 @@ RouterVersioner(app=app, routers=router, version_format=VersionFormat.CALVER).ve
|
|
|
117
117
|
# Mounts: GET /2025-01-01/items
|
|
118
118
|
```
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
Valid CalVer tokens: `"2025-01-01"`, `"v3"`, `"stable"`, etc.
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
**CalVer sorting:** versions are sorted lexicographically, so tokens must be comparable in the intended order. ISO dates (`"2025-01-01"`) and zero-padded numbers (`"v01"`, `"v02"`) work correctly. Non-padded strings like `"v1"`, `"v10"`, `"v2"` will not sort correctly and will cause routes to appear in the wrong versions.
|
|
123
123
|
|
|
124
124
|
---
|
|
125
125
|
|
|
@@ -181,8 +181,8 @@ Call `.versionize()` after constructing the object. It returns the list of activ
|
|
|
181
181
|
|
|
182
182
|
| Parameter | Type | Required | Description |
|
|
183
183
|
|---|---|---|---|
|
|
184
|
-
| `version` | `tuple[int, int] \| str` | yes |
|
|
185
|
-
| `deprecate_in` | same \| `None` | no | Version
|
|
184
|
+
| `version` | `tuple[int, int] \| str` | yes | First version in which this route is active |
|
|
185
|
+
| `deprecate_in` | same \| `None` | no | Version in which this route is marked deprecated in the docs |
|
|
186
186
|
| `remove_in` | same \| `None` | no | Version from which this route is removed entirely |
|
|
187
187
|
|
|
188
188
|
All three parameters must match the `version_format` configured on `RouterVersioner`
|
|
@@ -278,7 +278,7 @@ RouterVersioner(
|
|
|
278
278
|
).versionize()
|
|
279
279
|
```
|
|
280
280
|
|
|
281
|
-
The hook receives `(schema: dict, version: VersionT)` and must return the
|
|
281
|
+
The hook receives `(schema: dict, version: VersionT)` and must return the modified dict.
|
|
282
282
|
|
|
283
283
|
### OpenAPI Callbacks and Webhooks
|
|
284
284
|
|
|
@@ -325,12 +325,11 @@ RouterVersioner(
|
|
|
325
325
|
# /v2_0/openapi.json → webhooks: /order-created (V2) ← /payment-failed removed
|
|
326
326
|
```
|
|
327
327
|
|
|
328
|
-
The same
|
|
329
|
-
a new webhook version only appears once a route version creates that API prefix.
|
|
328
|
+
The same `remove_in` lifecycle applies. A new webhook version only appears once a route version creates that API prefix.
|
|
330
329
|
|
|
331
330
|
### Multiple routers
|
|
332
331
|
|
|
333
|
-
Pass a list of routers to version routes
|
|
332
|
+
Pass a list of routers to version routes split across modules:
|
|
334
333
|
|
|
335
334
|
```python
|
|
336
335
|
RouterVersioner(
|
|
@@ -344,7 +343,7 @@ All routers are versioned together under the same prefix tree.
|
|
|
344
343
|
|
|
345
344
|
### Self-hosted docs (air-gapped environments)
|
|
346
345
|
|
|
347
|
-
By default, Swagger UI and ReDoc assets are loaded from the FastAPI CDN. In air-gapped or corporate environments, point them at
|
|
346
|
+
By default, Swagger UI and ReDoc assets are loaded from the FastAPI CDN. In air-gapped or corporate environments, point them at locally hosted assets:
|
|
348
347
|
|
|
349
348
|
```python
|
|
350
349
|
RouterVersioner(
|
|
@@ -360,7 +359,7 @@ RouterVersioner(
|
|
|
360
359
|
).versionize()
|
|
361
360
|
```
|
|
362
361
|
|
|
363
|
-
See [`examples/download_static_assets.py`](examples/download_static_assets.py) for a
|
|
362
|
+
See [`examples/download_static_assets.py`](examples/download_static_assets.py) for a script that downloads all required assets in one step, and [`examples/self_hosted_docs_app.py`](examples/self_hosted_docs_app.py) for a complete working example.
|
|
364
363
|
|
|
365
364
|
### Reverse proxy / sub-app mounting
|
|
366
365
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
# FastAPI Router Versioning
|
|
1
|
+
# FastAPI Router Versioning
|
|
2
2
|
|
|
3
3
|
[](https://pypi.org/project/fastapi-router-versioning/)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
[](https://pypi.org/project/fastapi-router-versioning/)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Router-based API versioning for FastAPI.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
FastAPI has no built-in versioning mechanism. The common workaround — duplicating routers or managing prefixes manually — breaks down quickly as the number of versions grows. This package adds declarative versioning directly on routes, with isolated URL prefixes, per-version Swagger UI, and a full route lifecycle, without touching the existing application structure.
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
@@ -65,7 +65,7 @@ RouterVersioner(app=app, routers=router, version_format=VersionFormat.SEMVER).ve
|
|
|
65
65
|
# Mounts: GET /v1_0/items GET /v2_0/items
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
-
Each version
|
|
68
|
+
Each version gets its own Swagger UI at `/v1_0/docs`, `/v2_0/docs`, and so on.
|
|
69
69
|
|
|
70
70
|
---
|
|
71
71
|
|
|
@@ -89,9 +89,9 @@ RouterVersioner(app=app, routers=router, version_format=VersionFormat.CALVER).ve
|
|
|
89
89
|
# Mounts: GET /2025-01-01/items
|
|
90
90
|
```
|
|
91
91
|
|
|
92
|
-
|
|
92
|
+
Valid CalVer tokens: `"2025-01-01"`, `"v3"`, `"stable"`, etc.
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
**CalVer sorting:** versions are sorted lexicographically, so tokens must be comparable in the intended order. ISO dates (`"2025-01-01"`) and zero-padded numbers (`"v01"`, `"v02"`) work correctly. Non-padded strings like `"v1"`, `"v10"`, `"v2"` will not sort correctly and will cause routes to appear in the wrong versions.
|
|
95
95
|
|
|
96
96
|
---
|
|
97
97
|
|
|
@@ -153,8 +153,8 @@ Call `.versionize()` after constructing the object. It returns the list of activ
|
|
|
153
153
|
|
|
154
154
|
| Parameter | Type | Required | Description |
|
|
155
155
|
|---|---|---|---|
|
|
156
|
-
| `version` | `tuple[int, int] \| str` | yes |
|
|
157
|
-
| `deprecate_in` | same \| `None` | no | Version
|
|
156
|
+
| `version` | `tuple[int, int] \| str` | yes | First version in which this route is active |
|
|
157
|
+
| `deprecate_in` | same \| `None` | no | Version in which this route is marked deprecated in the docs |
|
|
158
158
|
| `remove_in` | same \| `None` | no | Version from which this route is removed entirely |
|
|
159
159
|
|
|
160
160
|
All three parameters must match the `version_format` configured on `RouterVersioner`
|
|
@@ -250,7 +250,7 @@ RouterVersioner(
|
|
|
250
250
|
).versionize()
|
|
251
251
|
```
|
|
252
252
|
|
|
253
|
-
The hook receives `(schema: dict, version: VersionT)` and must return the
|
|
253
|
+
The hook receives `(schema: dict, version: VersionT)` and must return the modified dict.
|
|
254
254
|
|
|
255
255
|
### OpenAPI Callbacks and Webhooks
|
|
256
256
|
|
|
@@ -297,12 +297,11 @@ RouterVersioner(
|
|
|
297
297
|
# /v2_0/openapi.json → webhooks: /order-created (V2) ← /payment-failed removed
|
|
298
298
|
```
|
|
299
299
|
|
|
300
|
-
The same
|
|
301
|
-
a new webhook version only appears once a route version creates that API prefix.
|
|
300
|
+
The same `remove_in` lifecycle applies. A new webhook version only appears once a route version creates that API prefix.
|
|
302
301
|
|
|
303
302
|
### Multiple routers
|
|
304
303
|
|
|
305
|
-
Pass a list of routers to version routes
|
|
304
|
+
Pass a list of routers to version routes split across modules:
|
|
306
305
|
|
|
307
306
|
```python
|
|
308
307
|
RouterVersioner(
|
|
@@ -316,7 +315,7 @@ All routers are versioned together under the same prefix tree.
|
|
|
316
315
|
|
|
317
316
|
### Self-hosted docs (air-gapped environments)
|
|
318
317
|
|
|
319
|
-
By default, Swagger UI and ReDoc assets are loaded from the FastAPI CDN. In air-gapped or corporate environments, point them at
|
|
318
|
+
By default, Swagger UI and ReDoc assets are loaded from the FastAPI CDN. In air-gapped or corporate environments, point them at locally hosted assets:
|
|
320
319
|
|
|
321
320
|
```python
|
|
322
321
|
RouterVersioner(
|
|
@@ -332,7 +331,7 @@ RouterVersioner(
|
|
|
332
331
|
).versionize()
|
|
333
332
|
```
|
|
334
333
|
|
|
335
|
-
See [`examples/download_static_assets.py`](examples/download_static_assets.py) for a
|
|
334
|
+
See [`examples/download_static_assets.py`](examples/download_static_assets.py) for a script that downloads all required assets in one step, and [`examples/self_hosted_docs_app.py`](examples/self_hosted_docs_app.py) for a complete working example.
|
|
336
335
|
|
|
337
336
|
### Reverse proxy / sub-app mounting
|
|
338
337
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Release Notes
|
|
2
|
+
|
|
3
|
+
## Latest Changes
|
|
4
|
+
|
|
5
|
+
## 0.1.2 (2026-06-25)
|
|
6
|
+
|
|
7
|
+
### Internal
|
|
8
|
+
|
|
9
|
+
* 👷 Add Python 3.14 classifier to `pyproject.toml` to align with the versions already covered by CI.
|
|
10
|
+
|
|
11
|
+
### Docs
|
|
12
|
+
|
|
13
|
+
* 📝 Update README and `pyproject.toml` to clarify package description and improve documentation consistency. PR [#12](https://github.com/mat81black/fastapi-router-versioning/pull/12) by [@mat81black](https://github.com/mat81black).
|
|
14
|
+
|
|
15
|
+
## 0.1.1 (2026-06-24)
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
* ✨ Add per-version OpenAPI schema caching: schemas are generated once and cached, reducing overhead on repeated requests. PR [#5](https://github.com/mat81black/fastapi-router-versioning/pull/5) by [@mat81black](https://github.com/mat81black).
|
|
20
|
+
* ✨ Track which routes belong to each version to invalidate the cache automatically when the route set changes. PR [#5](https://github.com/mat81black/fastapi-router-versioning/pull/5) by [@mat81black](https://github.com/mat81black).
|
|
21
|
+
|
|
22
|
+
### Fixes
|
|
23
|
+
|
|
24
|
+
* 🐛 Fix WebSocket routes not being handled correctly in `versioned_routers` when the cache was active. PR [#5](https://github.com/mat81black/fastapi-router-versioning/pull/5) by [@mat81black](https://github.com/mat81black).
|
|
25
|
+
* 🐛 Improve OpenAPI schema cache resilience: cache is now safely bypassed on unexpected errors instead of raising. PR [#5](https://github.com/mat81black/fastapi-router-versioning/pull/5) by [@mat81black](https://github.com/mat81black).
|
|
26
|
+
|
|
27
|
+
### Internal
|
|
28
|
+
|
|
29
|
+
* 👷 Add `prepare-release.yml` workflow and `scripts/prepare_release.py` for automated version bumping and release PR creation. PR [#6](https://github.com/mat81black/fastapi-router-versioning/pull/6) by [@mat81black](https://github.com/mat81black).
|
|
30
|
+
* 👷 Add `changes` job to `test.yml` and `test-redistribute.yml` to skip CI when no relevant files are modified. PR [#10](https://github.com/mat81black/fastapi-router-versioning/pull/10) by [@mat81black](https://github.com/mat81black).
|
|
31
|
+
|
|
32
|
+
## 0.1.0 (2025-06-24)
|
|
33
|
+
|
|
34
|
+
🚀 First official public release of **fastapi-router-versioning**.
|
|
35
|
+
|
|
36
|
+
Router-based API versioning for FastAPI, with declarative route lifecycle, per-version OpenAPI schemas, and isolated documentation — without altering the core application structure.
|
|
37
|
+
|
|
38
|
+
### Features
|
|
39
|
+
|
|
40
|
+
* ✨ SemVer & CalVer support: route versioning using `(major, minor)` tuples or lexicographically sortable arbitrary strings.
|
|
41
|
+
* ✨ Declarative route lifecycle: introduce, deprecate (`deprecate_in`), and remove (`remove_in`) routes across versions.
|
|
42
|
+
* ✨ Per-version documentation: isolated Swagger UI, ReDoc, and `openapi.json` for each active version.
|
|
43
|
+
* ✨ OpenAPI schema hook: modify the filtered schema per version via `openapi_hook`.
|
|
44
|
+
* ✨ Independent webhook versioning via `webhook_routers`, with propagation of per-route OpenAPI Callbacks.
|
|
45
|
+
* ✨ Latest-version alias: expose the highest active version under a stable configurable prefix.
|
|
46
|
+
* ✨ Self-hosted docs: full control over Swagger/ReDoc static assets and option to disable Google Fonts.
|
|
47
|
+
* ✨ Reverse proxy aware: resolves and injects the ASGI `root_path` at request time.
|
|
@@ -37,8 +37,15 @@ def _iter_routes_flat(routes: list[Any]) -> Iterator[Any]:
|
|
|
37
37
|
if isinstance(original, APIRoute):
|
|
38
38
|
# RouteContext merges path/tags/deps via __getattr__; use the context directly.
|
|
39
39
|
yield route_ctx
|
|
40
|
+
elif isinstance(original, APIWebSocketRoute):
|
|
41
|
+
# For WebSockets, RouteContext.__getattr__ does NOT merge parent prefixes into path.
|
|
42
|
+
# _route_context._EffectiveRouteContext holds the fully resolved starlette_route
|
|
43
|
+
# (with all include_router prefixes applied). Fall back to original for direct routes.
|
|
44
|
+
rc = getattr(route_ctx, "_route_context", None)
|
|
45
|
+
starlette_route = getattr(rc, "starlette_route", None) if rc is not None else None
|
|
46
|
+
yield starlette_route if starlette_route is not None else original
|
|
40
47
|
else:
|
|
41
|
-
yield original
|
|
48
|
+
yield original # pragma: no cover
|
|
42
49
|
|
|
43
50
|
|
|
44
51
|
def _unwrap_route(route: Any) -> Any:
|
|
@@ -195,6 +202,8 @@ class RouterVersioner:
|
|
|
195
202
|
self._redoc_js_url = redoc_js_url
|
|
196
203
|
self._redoc_favicon_url = redoc_favicon_url
|
|
197
204
|
self._redoc_with_google_fonts = redoc_with_google_fonts
|
|
205
|
+
self._openapi_schemas_cache: dict[VersionT, dict[str, Any]] = {}
|
|
206
|
+
self._openapi_routes_versions: dict[VersionT, int | None] = {}
|
|
198
207
|
|
|
199
208
|
if default_version is None:
|
|
200
209
|
self._default_version: VersionT = (1, 0) if version_format == VersionFormat.SEMVER else "1"
|
|
@@ -363,9 +372,8 @@ class RouterVersioner:
|
|
|
363
372
|
if not webhooks_by_version:
|
|
364
373
|
# webhook_routers not provided: fall back to global app.webhooks
|
|
365
374
|
return list(self._app.webhooks.routes)
|
|
366
|
-
candidates: list[VersionT] = []
|
|
367
375
|
if isinstance(version, tuple):
|
|
368
|
-
candidates = [v for v in webhooks_by_version if isinstance(v, tuple) and v <= version]
|
|
376
|
+
candidates: list[VersionT] = [v for v in webhooks_by_version if isinstance(v, tuple) and v <= version]
|
|
369
377
|
else:
|
|
370
378
|
candidates = [v for v in webhooks_by_version if isinstance(v, str) and v <= version]
|
|
371
379
|
if not candidates:
|
|
@@ -424,23 +432,38 @@ class RouterVersioner:
|
|
|
424
432
|
) -> None:
|
|
425
433
|
@router.get(openapi_url, include_in_schema=False)
|
|
426
434
|
async def get_openapi(req: Request) -> Any:
|
|
427
|
-
schema
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
435
|
+
# _get_routes_version() is the same internal FastAPI uses for its own schema cache;
|
|
436
|
+
# if unavailable (private API removed), current_routes_version stays None and the
|
|
437
|
+
# cache persists indefinitely — degraded but correct.
|
|
438
|
+
_get_routes_version = getattr(router, "_get_routes_version", None)
|
|
439
|
+
current_routes_version = _get_routes_version() if _get_routes_version else None
|
|
440
|
+
|
|
441
|
+
cached = self._openapi_schemas_cache.get(version)
|
|
442
|
+
if cached is None or self._openapi_routes_versions.get(version) != current_routes_version:
|
|
443
|
+
schema = fastapi.openapi.utils.get_openapi(
|
|
444
|
+
title=title,
|
|
445
|
+
version=doc_version_str,
|
|
446
|
+
openapi_version=self._app.openapi_version,
|
|
447
|
+
summary=self._app.summary,
|
|
448
|
+
description=self._app.description,
|
|
449
|
+
routes=router.routes,
|
|
450
|
+
webhooks=webhooks,
|
|
451
|
+
tags=versioned_tags,
|
|
452
|
+
servers=self._app.servers,
|
|
453
|
+
terms_of_service=self._app.terms_of_service,
|
|
454
|
+
contact=self._app.contact,
|
|
455
|
+
license_info=self._app.license_info,
|
|
456
|
+
separate_input_output_schemas=self._app.separate_input_output_schemas,
|
|
457
|
+
external_docs=self._app.openapi_external_docs,
|
|
458
|
+
)
|
|
459
|
+
if self._openapi_hook is not None:
|
|
460
|
+
schema = self._openapi_hook(schema, version)
|
|
461
|
+
self._openapi_schemas_cache[version] = schema
|
|
462
|
+
self._openapi_routes_versions[version] = current_routes_version
|
|
463
|
+
else:
|
|
464
|
+
schema = self._openapi_schemas_cache[version]
|
|
465
|
+
|
|
466
|
+
# root_path is per-request: shallow copy to avoid polluting the cache
|
|
444
467
|
root_path = req.scope.get("root_path", "").rstrip("/")
|
|
445
468
|
if root_path and getattr(self._app, "root_path_in_servers", True):
|
|
446
469
|
server_urls = {s.get("url") for s in schema.get("servers", [])}
|
|
@@ -448,9 +471,6 @@ class RouterVersioner:
|
|
|
448
471
|
schema = dict(schema)
|
|
449
472
|
schema["servers"] = [{"url": root_path}] + schema.get("servers", [])
|
|
450
473
|
|
|
451
|
-
if self._openapi_hook is not None:
|
|
452
|
-
schema = self._openapi_hook(schema, version)
|
|
453
|
-
|
|
454
474
|
return schema
|
|
455
475
|
|
|
456
476
|
def _add_swagger_ui_routes(
|
|
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fastapi-router-versioning"
|
|
7
7
|
dynamic = ["version"]
|
|
8
|
-
description = "
|
|
8
|
+
description = "Router-based API versioning for FastAPI, with per-version docs and a declarative route lifecycle."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
11
11
|
license-files = ["LICENSE"]
|
|
@@ -52,6 +52,7 @@ dev = [
|
|
|
52
52
|
"prek >=0.2.22",
|
|
53
53
|
"ty>=0.0.28",
|
|
54
54
|
"pyrefly>=0.60.0",
|
|
55
|
+
"typer>=0.15.0",
|
|
55
56
|
"uvicorn>=0.49.0",
|
|
56
57
|
"zizmor>=1.0.0",
|
|
57
58
|
]
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Prepare a release by updating the package version and release notes."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from datetime import date
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated, Literal
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
VERSION_PATTERN = re.compile(r'(?m)^__version__ = "(\d+\.\d+\.\d+)"$')
|
|
12
|
+
VERSION_HEADING_PATTERN = re.compile(r"(?m)^## (\d+\.\d+\.\d+)(?: \([^)]+\))?$")
|
|
13
|
+
RELEASE_NOTES_HEADER = "# Release Notes\n\n"
|
|
14
|
+
LATEST_CHANGES_HEADER = "## Latest Changes"
|
|
15
|
+
BumpType = Literal["major", "minor", "patch"]
|
|
16
|
+
|
|
17
|
+
app = typer.Typer()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_version(version: str) -> tuple[int, int, int]:
|
|
21
|
+
match = re.fullmatch(r"\d+\.\d+\.\d+", version)
|
|
22
|
+
if not match:
|
|
23
|
+
raise ValueError(f"Invalid version: {version!r}. Expected format: X.Y.Z")
|
|
24
|
+
major, minor, patch = version.split(".")
|
|
25
|
+
return int(major), int(minor), int(patch)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_current_version(content: str, version_file: Path) -> str:
|
|
29
|
+
matches = list(VERSION_PATTERN.finditer(content))
|
|
30
|
+
if len(matches) != 1:
|
|
31
|
+
raise RuntimeError(f"Expected exactly one __version__ assignment in {version_file}, found {len(matches)}")
|
|
32
|
+
return matches[0].group(1)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def bump_version(version: str, bump: BumpType) -> str:
|
|
36
|
+
major, minor, patch = parse_version(version)
|
|
37
|
+
if bump == "major":
|
|
38
|
+
return f"{major + 1}.0.0"
|
|
39
|
+
if bump == "minor":
|
|
40
|
+
return f"{major}.{minor + 1}.0"
|
|
41
|
+
return f"{major}.{minor}.{patch + 1}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def update_version_file(content: str, version: str, version_file: Path) -> str:
|
|
45
|
+
current_version = get_current_version(content, version_file)
|
|
46
|
+
if parse_version(version) <= parse_version(current_version):
|
|
47
|
+
raise RuntimeError(f"New version {version} must be greater than current version {current_version}")
|
|
48
|
+
return VERSION_PATTERN.sub(f'__version__ = "{version}"', content, count=1)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def update_release_notes(content: str, version: str, release_date: date, release_notes_file: Path) -> str:
|
|
52
|
+
if not content.startswith(RELEASE_NOTES_HEADER):
|
|
53
|
+
raise RuntimeError(f"{release_notes_file} must start with {RELEASE_NOTES_HEADER!r}")
|
|
54
|
+
if re.search(rf"^## {re.escape(version)}(?: \([^)]+\))?$", content, re.M):
|
|
55
|
+
raise RuntimeError(f"Release notes already contain a section for {version}")
|
|
56
|
+
|
|
57
|
+
latest_header = f"{RELEASE_NOTES_HEADER}{LATEST_CHANGES_HEADER}\n"
|
|
58
|
+
if not content.startswith(latest_header):
|
|
59
|
+
raise RuntimeError(f"{release_notes_file} must start with {latest_header!r}")
|
|
60
|
+
|
|
61
|
+
release_header = f"## {version} ({release_date.isoformat()})"
|
|
62
|
+
return content.replace(
|
|
63
|
+
latest_header,
|
|
64
|
+
f"{RELEASE_NOTES_HEADER}{LATEST_CHANGES_HEADER}\n\n{release_header}\n",
|
|
65
|
+
1,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_release_notes_body(content: str, version: str, release_notes_file: Path) -> str:
|
|
70
|
+
version_heading = re.compile(rf"(?m)^## {re.escape(version)}(?: \([^)]+\))?$")
|
|
71
|
+
match = version_heading.search(content)
|
|
72
|
+
if not match:
|
|
73
|
+
raise RuntimeError(f"Could not find release notes section for {version} in {release_notes_file}")
|
|
74
|
+
|
|
75
|
+
next_match = VERSION_HEADING_PATTERN.search(content, match.end())
|
|
76
|
+
end = next_match.start() if next_match else len(content)
|
|
77
|
+
body = content[match.end() : end].strip()
|
|
78
|
+
if not body:
|
|
79
|
+
raise RuntimeError(f"Release notes section for {version} in {release_notes_file} is empty")
|
|
80
|
+
return f"{body}\n"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.command()
|
|
84
|
+
def prepare(
|
|
85
|
+
bump: Annotated[
|
|
86
|
+
BumpType,
|
|
87
|
+
typer.Argument(
|
|
88
|
+
envvar="PREPARE_RELEASE_BUMP",
|
|
89
|
+
help="The release bump to make: major, minor, or patch.",
|
|
90
|
+
),
|
|
91
|
+
],
|
|
92
|
+
version_file: Annotated[
|
|
93
|
+
Path,
|
|
94
|
+
typer.Option(
|
|
95
|
+
envvar="PREPARE_RELEASE_VERSION_FILE",
|
|
96
|
+
exists=True,
|
|
97
|
+
file_okay=True,
|
|
98
|
+
dir_okay=False,
|
|
99
|
+
readable=True,
|
|
100
|
+
writable=True,
|
|
101
|
+
help="Path to the Python file containing the __version__ assignment.",
|
|
102
|
+
),
|
|
103
|
+
],
|
|
104
|
+
release_notes_file: Annotated[
|
|
105
|
+
Path,
|
|
106
|
+
typer.Option(
|
|
107
|
+
envvar="PREPARE_RELEASE_RELEASE_NOTES_FILE",
|
|
108
|
+
exists=True,
|
|
109
|
+
file_okay=True,
|
|
110
|
+
dir_okay=False,
|
|
111
|
+
readable=True,
|
|
112
|
+
writable=True,
|
|
113
|
+
help="Path to the release notes Markdown file.",
|
|
114
|
+
),
|
|
115
|
+
],
|
|
116
|
+
release_date: Annotated[
|
|
117
|
+
str,
|
|
118
|
+
typer.Option(
|
|
119
|
+
"--date",
|
|
120
|
+
envvar="PREPARE_RELEASE_DATE",
|
|
121
|
+
help="Release date in YYYY-MM-DD format. Defaults to today.",
|
|
122
|
+
),
|
|
123
|
+
] = date.today().isoformat(),
|
|
124
|
+
) -> None:
|
|
125
|
+
parsed_release_date = date.fromisoformat(release_date or date.today().isoformat())
|
|
126
|
+
|
|
127
|
+
version_file_content = version_file.read_text()
|
|
128
|
+
release_notes_content = release_notes_file.read_text()
|
|
129
|
+
version = bump_version(get_current_version(version_file_content, version_file), bump)
|
|
130
|
+
|
|
131
|
+
version_file.write_text(update_version_file(version_file_content, version, version_file))
|
|
132
|
+
release_notes_file.write_text(
|
|
133
|
+
update_release_notes(release_notes_content, version, parsed_release_date, release_notes_file)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
typer.echo(f"Prepared release {version} ({parsed_release_date.isoformat()})")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@app.command()
|
|
140
|
+
def current_version(
|
|
141
|
+
version_file: Annotated[
|
|
142
|
+
Path,
|
|
143
|
+
typer.Option(
|
|
144
|
+
envvar="PREPARE_RELEASE_VERSION_FILE",
|
|
145
|
+
exists=True,
|
|
146
|
+
file_okay=True,
|
|
147
|
+
dir_okay=False,
|
|
148
|
+
readable=True,
|
|
149
|
+
help="Path to the Python file containing the __version__ assignment.",
|
|
150
|
+
),
|
|
151
|
+
],
|
|
152
|
+
) -> None:
|
|
153
|
+
typer.echo(get_current_version(version_file.read_text(), version_file))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@app.command()
|
|
157
|
+
def release_notes(
|
|
158
|
+
version_file: Annotated[
|
|
159
|
+
Path,
|
|
160
|
+
typer.Option(
|
|
161
|
+
envvar="PREPARE_RELEASE_VERSION_FILE",
|
|
162
|
+
exists=True,
|
|
163
|
+
file_okay=True,
|
|
164
|
+
dir_okay=False,
|
|
165
|
+
readable=True,
|
|
166
|
+
help="Path to the Python file containing the __version__ assignment.",
|
|
167
|
+
),
|
|
168
|
+
],
|
|
169
|
+
release_notes_file: Annotated[
|
|
170
|
+
Path,
|
|
171
|
+
typer.Option(
|
|
172
|
+
envvar="PREPARE_RELEASE_RELEASE_NOTES_FILE",
|
|
173
|
+
exists=True,
|
|
174
|
+
file_okay=True,
|
|
175
|
+
dir_okay=False,
|
|
176
|
+
readable=True,
|
|
177
|
+
help="Path to the release notes Markdown file.",
|
|
178
|
+
),
|
|
179
|
+
],
|
|
180
|
+
) -> None:
|
|
181
|
+
version = get_current_version(version_file.read_text(), version_file)
|
|
182
|
+
typer.echo(
|
|
183
|
+
get_release_notes_body(release_notes_file.read_text(), version, release_notes_file),
|
|
184
|
+
nl=False,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
if __name__ == "__main__":
|
|
189
|
+
app()
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Tests for scripts/prepare_release.py."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from typer.testing import CliRunner
|
|
8
|
+
|
|
9
|
+
from scripts.prepare_release import app
|
|
10
|
+
|
|
11
|
+
runner = CliRunner()
|
|
12
|
+
|
|
13
|
+
INIT_TEMPLATE = '__version__ = "{version}"\n'
|
|
14
|
+
NOTES_TEMPLATE = "# Release Notes\n\n## Latest Changes\n\n## 0.1.0 (2025-01-01)\n\nInitial release.\n"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture()
|
|
18
|
+
def version_file(tmp_path: Path) -> Path:
|
|
19
|
+
f = tmp_path / "__init__.py"
|
|
20
|
+
f.write_text(INIT_TEMPLATE.format(version="0.1.0"))
|
|
21
|
+
return f
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture()
|
|
25
|
+
def notes_file(tmp_path: Path) -> Path:
|
|
26
|
+
f = tmp_path / "RELEASE_NOTES.md"
|
|
27
|
+
f.write_text(NOTES_TEMPLATE)
|
|
28
|
+
return f
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# current-version
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_current_version(version_file: Path) -> None:
|
|
37
|
+
result = runner.invoke(app, ["current-version", "--version-file", str(version_file)])
|
|
38
|
+
assert result.exit_code == 0
|
|
39
|
+
assert result.output.strip() == "0.1.0"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# prepare
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.mark.parametrize(
|
|
48
|
+
("bump", "expected"),
|
|
49
|
+
[
|
|
50
|
+
("patch", "0.1.1"),
|
|
51
|
+
("minor", "0.2.0"),
|
|
52
|
+
("major", "1.0.0"),
|
|
53
|
+
],
|
|
54
|
+
)
|
|
55
|
+
def test_prepare_bump(
|
|
56
|
+
version_file: Path,
|
|
57
|
+
notes_file: Path,
|
|
58
|
+
bump: str,
|
|
59
|
+
expected: str,
|
|
60
|
+
) -> None:
|
|
61
|
+
result = runner.invoke(
|
|
62
|
+
app,
|
|
63
|
+
[
|
|
64
|
+
"prepare",
|
|
65
|
+
bump,
|
|
66
|
+
"--version-file",
|
|
67
|
+
str(version_file),
|
|
68
|
+
"--release-notes-file",
|
|
69
|
+
str(notes_file),
|
|
70
|
+
"--date",
|
|
71
|
+
"2026-01-01",
|
|
72
|
+
],
|
|
73
|
+
)
|
|
74
|
+
assert result.exit_code == 0, result.output
|
|
75
|
+
assert f'__version__ = "{expected}"' in version_file.read_text()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_prepare_updates_release_notes(version_file: Path, notes_file: Path) -> None:
|
|
79
|
+
runner.invoke(
|
|
80
|
+
app,
|
|
81
|
+
[
|
|
82
|
+
"prepare",
|
|
83
|
+
"minor",
|
|
84
|
+
"--version-file",
|
|
85
|
+
str(version_file),
|
|
86
|
+
"--release-notes-file",
|
|
87
|
+
str(notes_file),
|
|
88
|
+
"--date",
|
|
89
|
+
"2026-06-01",
|
|
90
|
+
],
|
|
91
|
+
)
|
|
92
|
+
notes = notes_file.read_text()
|
|
93
|
+
assert "## Latest Changes" in notes
|
|
94
|
+
assert "## 0.2.0 (2026-06-01)" in notes
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_prepare_version_must_increase(version_file: Path, notes_file: Path) -> None:
|
|
98
|
+
# bump to 0.2.0 first
|
|
99
|
+
runner.invoke(
|
|
100
|
+
app,
|
|
101
|
+
[
|
|
102
|
+
"prepare",
|
|
103
|
+
"minor",
|
|
104
|
+
"--version-file",
|
|
105
|
+
str(version_file),
|
|
106
|
+
"--release-notes-file",
|
|
107
|
+
str(notes_file),
|
|
108
|
+
"--date",
|
|
109
|
+
"2026-01-01",
|
|
110
|
+
],
|
|
111
|
+
)
|
|
112
|
+
# write a fresh notes file — otherwise "already contains section" fires first
|
|
113
|
+
notes_file.write_text(NOTES_TEMPLATE)
|
|
114
|
+
# try to bump patch on already-bumped file: 0.2.0 patch → 0.2.1, which is valid.
|
|
115
|
+
# To trigger the guard, manually set version higher than what bump produces.
|
|
116
|
+
version_file.write_text(INIT_TEMPLATE.format(version="0.2.0"))
|
|
117
|
+
# writing 0.2.0 again via a fake bump isn't easy via CLI, so test the helper directly
|
|
118
|
+
from scripts.prepare_release import update_version_file
|
|
119
|
+
|
|
120
|
+
content = version_file.read_text()
|
|
121
|
+
with pytest.raises(RuntimeError, match="must be greater than"):
|
|
122
|
+
update_version_file(content, "0.2.0", version_file)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_prepare_section_already_exists(version_file: Path, notes_file: Path) -> None:
|
|
126
|
+
# bump once
|
|
127
|
+
runner.invoke(
|
|
128
|
+
app,
|
|
129
|
+
[
|
|
130
|
+
"prepare",
|
|
131
|
+
"minor",
|
|
132
|
+
"--version-file",
|
|
133
|
+
str(version_file),
|
|
134
|
+
"--release-notes-file",
|
|
135
|
+
str(notes_file),
|
|
136
|
+
"--date",
|
|
137
|
+
"2026-01-01",
|
|
138
|
+
],
|
|
139
|
+
)
|
|
140
|
+
# restore version so bump produces the same version again
|
|
141
|
+
version_file.write_text(INIT_TEMPLATE.format(version="0.1.0"))
|
|
142
|
+
result = runner.invoke(
|
|
143
|
+
app,
|
|
144
|
+
[
|
|
145
|
+
"prepare",
|
|
146
|
+
"minor",
|
|
147
|
+
"--version-file",
|
|
148
|
+
str(version_file),
|
|
149
|
+
"--release-notes-file",
|
|
150
|
+
str(notes_file),
|
|
151
|
+
"--date",
|
|
152
|
+
"2026-01-01",
|
|
153
|
+
],
|
|
154
|
+
)
|
|
155
|
+
assert result.exit_code != 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_prepare_notes_wrong_header(version_file: Path, tmp_path: Path) -> None:
|
|
159
|
+
bad_notes = tmp_path / "RELEASE_NOTES.md"
|
|
160
|
+
bad_notes.write_text("## Latest Changes\n\n## 0.1.0\n\nInitial release.\n")
|
|
161
|
+
result = runner.invoke(
|
|
162
|
+
app,
|
|
163
|
+
["prepare", "minor", "--version-file", str(version_file), "--release-notes-file", str(bad_notes)],
|
|
164
|
+
)
|
|
165
|
+
assert result.exit_code != 0
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_prepare_notes_missing_latest_changes(version_file: Path, tmp_path: Path) -> None:
|
|
169
|
+
bad_notes = tmp_path / "RELEASE_NOTES.md"
|
|
170
|
+
bad_notes.write_text("# Release Notes\n\n## 0.1.0\n\nInitial release.\n")
|
|
171
|
+
result = runner.invoke(
|
|
172
|
+
app,
|
|
173
|
+
["prepare", "minor", "--version-file", str(version_file), "--release-notes-file", str(bad_notes)],
|
|
174
|
+
)
|
|
175
|
+
assert result.exit_code != 0
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# release-notes
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_release_notes_extracts_body(version_file: Path, tmp_path: Path) -> None:
|
|
184
|
+
notes = tmp_path / "RELEASE_NOTES.md"
|
|
185
|
+
notes.write_text("# Release Notes\n\n## Latest Changes\n\n## 0.1.0 (2025-01-01)\n\nInitial release.\n")
|
|
186
|
+
result = runner.invoke(
|
|
187
|
+
app,
|
|
188
|
+
["release-notes", "--version-file", str(version_file), "--release-notes-file", str(notes)],
|
|
189
|
+
)
|
|
190
|
+
assert result.exit_code == 0, result.output
|
|
191
|
+
assert result.output.strip() == "Initial release."
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_release_notes_missing_section(version_file: Path, tmp_path: Path) -> None:
|
|
195
|
+
notes = tmp_path / "RELEASE_NOTES.md"
|
|
196
|
+
notes.write_text("# Release Notes\n\n## Latest Changes\n\n## 0.2.0 (2025-01-01)\n\nSomething.\n")
|
|
197
|
+
result = runner.invoke(
|
|
198
|
+
app,
|
|
199
|
+
["release-notes", "--version-file", str(version_file), "--release-notes-file", str(notes)],
|
|
200
|
+
)
|
|
201
|
+
assert result.exit_code != 0
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_release_notes_empty_section(version_file: Path, tmp_path: Path) -> None:
|
|
205
|
+
notes = tmp_path / "RELEASE_NOTES.md"
|
|
206
|
+
notes.write_text("# Release Notes\n\n## Latest Changes\n\n## 0.1.0 (2025-01-01)\n")
|
|
207
|
+
result = runner.invoke(
|
|
208
|
+
app,
|
|
209
|
+
["release-notes", "--version-file", str(version_file), "--release-notes-file", str(notes)],
|
|
210
|
+
)
|
|
211
|
+
assert result.exit_code != 0
|
|
@@ -129,6 +129,28 @@ def test_websockets_versioning() -> None:
|
|
|
129
129
|
assert data == "Hello Versioned WS"
|
|
130
130
|
|
|
131
131
|
|
|
132
|
+
def test_websocket_nested_router_prefix_is_preserved() -> None:
|
|
133
|
+
"""WebSocket inside a sub-router with a prefix must carry the full merged path when versionized."""
|
|
134
|
+
app = FastAPI()
|
|
135
|
+
ws_router = APIRouter()
|
|
136
|
+
|
|
137
|
+
@ws_router.websocket("/ws")
|
|
138
|
+
@api_version((1, 0))
|
|
139
|
+
async def ws_endpoint(websocket: WebSocket) -> None:
|
|
140
|
+
await websocket.accept()
|
|
141
|
+
await websocket.send_text("ok")
|
|
142
|
+
await websocket.close()
|
|
143
|
+
|
|
144
|
+
parent_router = APIRouter(prefix="/chat")
|
|
145
|
+
parent_router.include_router(ws_router)
|
|
146
|
+
|
|
147
|
+
RouterVersioner(app=app, routers=parent_router, version_format=VersionFormat.SEMVER).versionize()
|
|
148
|
+
|
|
149
|
+
client = TestClient(app)
|
|
150
|
+
with client.websocket_connect("/v1_0/chat/ws") as ws:
|
|
151
|
+
assert ws.receive_text() == "ok"
|
|
152
|
+
|
|
153
|
+
|
|
132
154
|
def test_unsupported_route_type_raises_error() -> None:
|
|
133
155
|
"""A Starlette Route (not APIRoute/APIWebSocketRoute) should raise TypeError."""
|
|
134
156
|
app = FastAPI()
|
|
@@ -812,6 +834,65 @@ def test_webhook_routers_calver() -> None:
|
|
|
812
834
|
assert "/event" in schema.get("webhooks", {})
|
|
813
835
|
|
|
814
836
|
|
|
837
|
+
def test_openapi_schema_is_cached() -> None:
|
|
838
|
+
"""The schema is generated only once; subsequent requests use the cache."""
|
|
839
|
+
from unittest.mock import patch
|
|
840
|
+
|
|
841
|
+
import fastapi.openapi.utils as openapi_utils
|
|
842
|
+
|
|
843
|
+
app = FastAPI()
|
|
844
|
+
router = APIRouter()
|
|
845
|
+
|
|
846
|
+
@router.get("/data")
|
|
847
|
+
@api_version((1, 0))
|
|
848
|
+
def get_data() -> dict[str, str]: ...
|
|
849
|
+
|
|
850
|
+
RouterVersioner(app=app, routers=router, version_format=VersionFormat.SEMVER).versionize()
|
|
851
|
+
client = TestClient(app)
|
|
852
|
+
|
|
853
|
+
with patch.object(openapi_utils, "get_openapi", wraps=openapi_utils.get_openapi) as mock_fn:
|
|
854
|
+
client.get("/v1_0/openapi.json")
|
|
855
|
+
client.get("/v1_0/openapi.json")
|
|
856
|
+
assert mock_fn.call_count == 1
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def test_openapi_cache_invalidated_on_route_change() -> None:
|
|
860
|
+
"""The cache is invalidated when _get_routes_version() changes after a new route is added."""
|
|
861
|
+
from unittest.mock import patch
|
|
862
|
+
|
|
863
|
+
import fastapi.openapi.utils as openapi_utils
|
|
864
|
+
|
|
865
|
+
import fastapi_router_versioning.versioner as versioner_mod
|
|
866
|
+
|
|
867
|
+
if versioner_mod._route_contexts_fn is None:
|
|
868
|
+
pytest.skip("_get_routes_version not available (FastAPI < 0.137.2)") # pragma: no cover
|
|
869
|
+
|
|
870
|
+
app = FastAPI()
|
|
871
|
+
router = APIRouter()
|
|
872
|
+
|
|
873
|
+
@router.get("/data")
|
|
874
|
+
@api_version((1, 0))
|
|
875
|
+
def get_data() -> dict[str, str]: ...
|
|
876
|
+
|
|
877
|
+
captured_routers: dict[Any, APIRouter] = {}
|
|
878
|
+
|
|
879
|
+
def capture_callback(versioned_router: APIRouter, version: VersionT, prefix: str) -> None:
|
|
880
|
+
captured_routers[version] = versioned_router
|
|
881
|
+
|
|
882
|
+
versioner = RouterVersioner(app=app, routers=router, version_format=VersionFormat.SEMVER, callback=capture_callback)
|
|
883
|
+
versioner.versionize()
|
|
884
|
+
client = TestClient(app)
|
|
885
|
+
|
|
886
|
+
with patch.object(openapi_utils, "get_openapi", wraps=openapi_utils.get_openapi) as mock_fn:
|
|
887
|
+
client.get("/v1_0/openapi.json")
|
|
888
|
+
assert mock_fn.call_count == 1
|
|
889
|
+
|
|
890
|
+
captured_routers[(1, 0)].add_api_route("/dynamic", lambda: {}, methods=["GET"])
|
|
891
|
+
|
|
892
|
+
client.get("/v1_0/openapi.json")
|
|
893
|
+
assert mock_fn.call_count == 2
|
|
894
|
+
|
|
895
|
+
|
|
815
896
|
def test_iter_routes_flat_fallback_without_route_context_fn() -> None:
|
|
816
897
|
"""Covers the _route_contexts_fn=None fallback (legacy FastAPI < 0.137.2).
|
|
817
898
|
|
|
@@ -302,6 +302,7 @@ dev = [
|
|
|
302
302
|
{ name = "pytest-dotenv" },
|
|
303
303
|
{ name = "ruff" },
|
|
304
304
|
{ name = "ty" },
|
|
305
|
+
{ name = "typer" },
|
|
305
306
|
{ name = "uvicorn" },
|
|
306
307
|
{ name = "zizmor" },
|
|
307
308
|
]
|
|
@@ -323,6 +324,7 @@ dev = [
|
|
|
323
324
|
{ name = "pytest-dotenv", specifier = ">=0.5.2" },
|
|
324
325
|
{ name = "ruff", specifier = ">=0.14.0" },
|
|
325
326
|
{ name = "ty", specifier = ">=0.0.28" },
|
|
327
|
+
{ name = "typer", specifier = ">=0.15.0" },
|
|
326
328
|
{ name = "uvicorn", specifier = ">=0.49.0" },
|
|
327
329
|
{ name = "zizmor", specifier = ">=1.0.0" },
|
|
328
330
|
]
|
|
@@ -904,6 +906,15 @@ wheels = [
|
|
|
904
906
|
{ url = "https://files.pythonhosted.org/packages/29/4c/67bb45e41609eb4726f1bfeb59e083cf91d14c696d4bd14c234a980be93d/ruff-0.15.18-py3-none-win_arm64.whl", hash = "sha256:b2c9257fcbd4a3e5b977a1904e6facca016bafe2edc17df24db67cfaee03b4e4", size = 11329958, upload-time = "2026-06-18T18:25:43.686Z" },
|
|
905
907
|
]
|
|
906
908
|
|
|
909
|
+
[[package]]
|
|
910
|
+
name = "shellingham"
|
|
911
|
+
version = "1.5.4"
|
|
912
|
+
source = { registry = "https://pypi.org/simple" }
|
|
913
|
+
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
|
|
914
|
+
wheels = [
|
|
915
|
+
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
|
916
|
+
]
|
|
917
|
+
|
|
907
918
|
[[package]]
|
|
908
919
|
name = "starlette"
|
|
909
920
|
version = "1.3.1"
|
|
@@ -1005,6 +1016,21 @@ wheels = [
|
|
|
1005
1016
|
{ url = "https://files.pythonhosted.org/packages/50/b5/d49096cd5f3694becb86a5a6ccd0f229ead695fc7430d6bc4dd0a104c6fe/ty-0.0.51-py3-none-win_arm64.whl", hash = "sha256:62ced5e380284f12b2dc4802a3e4ed3dac39913fc6719afde7978814a4c7f169", size = 11657350, upload-time = "2026-06-19T01:48:55.904Z" },
|
|
1006
1017
|
]
|
|
1007
1018
|
|
|
1019
|
+
[[package]]
|
|
1020
|
+
name = "typer"
|
|
1021
|
+
version = "0.26.7"
|
|
1022
|
+
source = { registry = "https://pypi.org/simple" }
|
|
1023
|
+
dependencies = [
|
|
1024
|
+
{ name = "annotated-doc" },
|
|
1025
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
1026
|
+
{ name = "rich" },
|
|
1027
|
+
{ name = "shellingham" },
|
|
1028
|
+
]
|
|
1029
|
+
sdist = { url = "https://files.pythonhosted.org/packages/5e/ed/ef06584ccdd5c410df0837951ecd7e15d9a6144ea1bd4c73cecab1a89891/typer-0.26.7.tar.gz", hash = "sha256:e314a34c617e419c091b2830dda3ea1f257134ff593061a8f5b9717ab8dddb3a", size = 201709, upload-time = "2026-06-03T07:18:06.843Z" }
|
|
1030
|
+
wheels = [
|
|
1031
|
+
{ url = "https://files.pythonhosted.org/packages/24/25/2201973529af2c954de0bb725323c3aaed6d7f0ceee8f550dec9185df013/typer-0.26.7-py3-none-any.whl", hash = "sha256:5c87cfbc5d34491c5346ebf49c23e18d56ccb863268d3a8d592b26087c2f5e58", size = 122456, upload-time = "2026-06-03T07:18:05.732Z" },
|
|
1032
|
+
]
|
|
1033
|
+
|
|
1008
1034
|
[[package]]
|
|
1009
1035
|
name = "typing-extensions"
|
|
1010
1036
|
version = "4.15.0"
|
|
File without changes
|
|
File without changes
|
{fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/.github/workflows/pre-commit.yml
RENAMED
|
File without changes
|
{fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/.github/workflows/publish.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/examples/multi_router_app.py
RENAMED
|
File without changes
|
{fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/examples/openapi_hook_app.py
RENAMED
|
File without changes
|
{fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/examples/self_hosted_docs_app.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/scripts/test-cov-html.sh
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|