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.
Files changed (39) hide show
  1. fastapi_router_versioning-0.1.2/.github/workflows/prepare-release.yml +81 -0
  2. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/.github/workflows/test-redistribute.yml +27 -4
  3. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/.github/workflows/test.yml +29 -0
  4. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/PKG-INFO +15 -16
  5. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/README.md +13 -14
  6. fastapi_router_versioning-0.1.2/RELEASE_NOTES.md +47 -0
  7. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/fastapi_router_versioning/__init__.py +1 -1
  8. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/fastapi_router_versioning/versioner.py +43 -23
  9. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/pyproject.toml +2 -1
  10. fastapi_router_versioning-0.1.2/scripts/prepare_release.py +189 -0
  11. fastapi_router_versioning-0.1.2/tests/test_prepare_release.py +211 -0
  12. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/tests/test_versioner.py +81 -0
  13. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/uv.lock +26 -0
  14. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/.github/dependabot.yml +0 -0
  15. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/.github/workflows/detect-conflicts.yml +0 -0
  16. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/.github/workflows/pre-commit.yml +0 -0
  17. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/.github/workflows/publish.yml +0 -0
  18. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/.gitignore +0 -0
  19. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/.pre-commit-config.yaml +0 -0
  20. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/.python-version +0 -0
  21. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/LICENSE +0 -0
  22. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/examples/calver_app.py +0 -0
  23. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/examples/download_static_assets.py +0 -0
  24. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/examples/multi_router_app.py +0 -0
  25. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/examples/openapi_hook_app.py +0 -0
  26. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/examples/self_hosted_docs_app.py +0 -0
  27. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/examples/semver_app.py +0 -0
  28. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/examples/semver_major_only_app.py +0 -0
  29. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/examples/webhook_versioning_app.py +0 -0
  30. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/fastapi_router_versioning/py.typed +0 -0
  31. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/scripts/format.sh +0 -0
  32. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/scripts/lint.sh +0 -0
  33. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/scripts/test-cov-html.sh +0 -0
  34. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/scripts/test-cov.sh +0 -0
  35. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/scripts/test.sh +0 -0
  36. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/tests/__init__.py +0 -0
  37. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/tests/test_calver.py +0 -0
  38. {fastapi_router_versioning-0.1.0 → fastapi_router_versioning-0.1.2}/tests/test_fastapi_integration.py +0 -0
  39. {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
@@ -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.0
4
- Summary: Native, router-based API versioning for FastAPI, featuring per-version docs and a declarative route lifecycle.
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 (Native API Versioning)
29
+ # FastAPI Router Versioning
30
30
 
31
31
  [![PyPI](https://img.shields.io/pypi/v/fastapi-router-versioning)](https://pypi.org/project/fastapi-router-versioning/)
32
32
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
33
33
  [![Python](https://img.shields.io/pypi/pyversions/fastapi-router-versioning)](https://pypi.org/project/fastapi-router-versioning/)
34
34
 
35
- The native, router-based solution for **API versioning in FastAPI**.
35
+ Router-based API versioning for FastAPI.
36
36
 
37
- If you want to implement **FastAPI API versioning**, you can simply annotate your routes with `@api_version`, call `.versionize()`, and get isolated URL prefixes, per-version Swagger UI, and a full route lifecycle — all without touching your existing application structure.
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 also gets its own Swagger UI at `/v1_0/docs`, `/v2_0/docs`, and so on.
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
- Any string is a valid CalVer token: `"2025-01-01"`, `"v3"`, `"stable"`, etc.
120
+ Valid CalVer tokens: `"2025-01-01"`, `"v3"`, `"stable"`, etc.
121
121
 
122
- > **CalVer sorting:** versions are sorted lexicographically, so strings must be comparable in the intended order. ISO dates (`"2025-01-01"`) and zero-padded numbers (`"v01"`, `"v02"`) work correctly. Strings like `"v1"`, `"v10"`, `"v2"` will **not** sort correctly and will cause routes to appear in the wrong versions.
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 | Version when this route is introduced |
185
- | `deprecate_in` | same \| `None` | no | Version when this route is marked deprecated in the docs |
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 (modified) dict.
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 introduce / `remove_in` lifecycle applies. Webhook versions follow route versions:
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 that are split across modules:
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 assets you host yourself:
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 ready-made 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.
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 (Native API Versioning)
1
+ # FastAPI Router Versioning
2
2
 
3
3
  [![PyPI](https://img.shields.io/pypi/v/fastapi-router-versioning)](https://pypi.org/project/fastapi-router-versioning/)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
5
  [![Python](https://img.shields.io/pypi/pyversions/fastapi-router-versioning)](https://pypi.org/project/fastapi-router-versioning/)
6
6
 
7
- The native, router-based solution for **API versioning in FastAPI**.
7
+ Router-based API versioning for FastAPI.
8
8
 
9
- If you want to implement **FastAPI API versioning**, you can simply annotate your routes with `@api_version`, call `.versionize()`, and get isolated URL prefixes, per-version Swagger UI, and a full route lifecycle — all without touching your existing application structure.
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 also gets its own Swagger UI at `/v1_0/docs`, `/v2_0/docs`, and so on.
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
- Any string is a valid CalVer token: `"2025-01-01"`, `"v3"`, `"stable"`, etc.
92
+ Valid CalVer tokens: `"2025-01-01"`, `"v3"`, `"stable"`, etc.
93
93
 
94
- > **CalVer sorting:** versions are sorted lexicographically, so strings must be comparable in the intended order. ISO dates (`"2025-01-01"`) and zero-padded numbers (`"v01"`, `"v02"`) work correctly. Strings like `"v1"`, `"v10"`, `"v2"` will **not** sort correctly and will cause routes to appear in the wrong versions.
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 | Version when this route is introduced |
157
- | `deprecate_in` | same \| `None` | no | Version when this route is marked deprecated in the docs |
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 (modified) dict.
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 introduce / `remove_in` lifecycle applies. Webhook versions follow route versions:
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 that are split across modules:
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 assets you host yourself:
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 ready-made 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.
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.
@@ -1,5 +1,5 @@
1
1
  from .versioner import RouterVersioner, VersionFormat, VersionT, api_version
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.2"
4
4
 
5
5
  __all__ = ["RouterVersioner", "api_version", "VersionFormat", "VersionT"]
@@ -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 = fastapi.openapi.utils.get_openapi(
428
- title=title,
429
- version=doc_version_str,
430
- openapi_version=self._app.openapi_version,
431
- summary=self._app.summary,
432
- description=self._app.description,
433
- routes=router.routes,
434
- webhooks=webhooks,
435
- tags=versioned_tags,
436
- servers=self._app.servers,
437
- terms_of_service=self._app.terms_of_service,
438
- contact=self._app.contact,
439
- license_info=self._app.license_info,
440
- separate_input_output_schemas=self._app.separate_input_output_schemas,
441
- external_docs=self._app.openapi_external_docs,
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 = "Native, router-based API versioning for FastAPI, featuring per-version docs and a declarative route lifecycle."
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"