fastapi-router-versioning 0.2.3__tar.gz → 0.2.5__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 (48) hide show
  1. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/.github/workflows/pre-commit.yml +4 -2
  2. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/.github/workflows/test-redistribute.yml +2 -1
  3. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/.github/workflows/test.yml +32 -8
  4. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/PKG-INFO +6 -3
  5. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/README.md +5 -2
  6. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/RELEASE_NOTES.md +12 -0
  7. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/fastapi_router_versioning/__init__.py +1 -1
  8. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/fastapi_router_versioning/versioner.py +39 -4
  9. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/tests/test_versioner.py +108 -0
  10. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/.github/dependabot.yml +0 -0
  11. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/.github/labeler.yml +0 -0
  12. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/.github/workflows/create-draft-release.yml +0 -0
  13. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/.github/workflows/detect-conflicts.yml +0 -0
  14. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/.github/workflows/guard-dependencies.yml +0 -0
  15. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/.github/workflows/labeler.yml +0 -0
  16. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/.github/workflows/latest-changes.yml +0 -0
  17. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/.github/workflows/prepare-release.yml +0 -0
  18. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/.github/workflows/publish.yml +0 -0
  19. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/.github/workflows/zizmor.yml +0 -0
  20. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/.gitignore +0 -0
  21. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/.pre-commit-config.yaml +0 -0
  22. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/.python-version +0 -0
  23. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/LICENSE +0 -0
  24. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/examples/calver_app.py +0 -0
  25. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/examples/download_static_assets.py +0 -0
  26. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/examples/mounted_subapps_app.py +0 -0
  27. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/examples/multi_router_app.py +0 -0
  28. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/examples/openapi_hook_app.py +0 -0
  29. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/examples/self_hosted_docs_app.py +0 -0
  30. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/examples/semver_app.py +0 -0
  31. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/examples/semver_major_only_app.py +0 -0
  32. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/examples/validation_error_code_app.py +0 -0
  33. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/examples/validation_error_code_custom_handler_app.py +0 -0
  34. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/examples/webhook_versioning_app.py +0 -0
  35. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/fastapi_router_versioning/py.typed +0 -0
  36. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/pyproject.toml +0 -0
  37. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/scripts/format.sh +0 -0
  38. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/scripts/lint.sh +0 -0
  39. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/scripts/prepare_release.py +0 -0
  40. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/scripts/test-cov-html.sh +0 -0
  41. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/scripts/test-cov.sh +0 -0
  42. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/scripts/test.sh +0 -0
  43. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/tests/__init__.py +0 -0
  44. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/tests/test_calver.py +0 -0
  45. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/tests/test_fastapi_integration.py +0 -0
  46. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/tests/test_prepare_release.py +0 -0
  47. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/tests/test_semver.py +0 -0
  48. {fastapi_router_versioning-0.2.3 → fastapi_router_versioning-0.2.5}/uv.lock +0 -0
@@ -32,6 +32,8 @@ jobs:
32
32
  - name: Setup uv
33
33
  uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
34
34
  with:
35
+ # Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
36
+ # See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
35
37
  version: "0.11.18"
36
38
  cache-dependency-glob: |
37
39
  pyproject.toml
@@ -44,8 +46,8 @@ jobs:
44
46
  # Only lint files changed in this PR, not the entire repo
45
47
  run: uv run prek run --from-ref origin/${GITHUB_BASE_REF} --to-ref HEAD --show-diff-on-failure
46
48
 
47
- # Required by GitHub branch protection rules
48
- pre-commit-alls-green:
49
+ # https://github.com/marketplace/actions/alls-green#why
50
+ pre-commit-alls-green: # This job does nothing and is only used for the branch protection
49
51
  if: always()
50
52
  needs:
51
53
  - pre-commit
@@ -12,6 +12,7 @@ jobs:
12
12
  changes:
13
13
  runs-on: ubuntu-latest
14
14
  permissions:
15
+ contents: read
15
16
  pull-requests: read
16
17
  timeout-minutes: 5
17
18
  outputs:
@@ -74,7 +75,7 @@ jobs:
74
75
  pip wheel --no-deps fastapi_router_versioning*.tar.gz
75
76
 
76
77
  # https://github.com/marketplace/actions/alls-green#why
77
- test-redistribute-alls-green:
78
+ test-redistribute-alls-green: # This job does nothing and is only used for the branch protection
78
79
  if: always()
79
80
  needs:
80
81
  - test-redistribute
@@ -6,6 +6,7 @@ on:
6
6
  - main
7
7
  pull_request:
8
8
  schedule:
9
+ # cron every week on monday
9
10
  - cron: "0 0 * * 1"
10
11
 
11
12
  permissions: {}
@@ -16,15 +17,19 @@ env:
16
17
  jobs:
17
18
  changes:
18
19
  runs-on: ubuntu-latest
20
+ # Required permissions
19
21
  permissions:
22
+ contents: read
20
23
  pull-requests: read
21
24
  timeout-minutes: 5
25
+ # Set job outputs to values from filter step
22
26
  outputs:
23
27
  src: ${{ steps.filter.outputs.src }}
24
28
  steps:
25
29
  - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
26
30
  with:
27
31
  persist-credentials: false
32
+ # For pull requests it's not necessary to checkout the code but for the main branch it is
28
33
  - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
29
34
  id: filter
30
35
  with:
@@ -79,14 +84,16 @@ jobs:
79
84
  - name: Setup uv
80
85
  uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
81
86
  with:
87
+ # Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
88
+ # See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
82
89
  version: "0.11.18"
83
90
  enable-cache: true
84
- cache-suffix: ${{ matrix.os }}-${{ matrix.python-version }}-${{ matrix.uv-resolution }}-fa${{ matrix.fastapi-version }}
91
+ cache-suffix: ${{ matrix.python-version }}-fa${{ matrix.fastapi-version }}
85
92
  cache-dependency-glob: |
86
93
  pyproject.toml
87
94
  uv.lock
88
95
 
89
- - name: Install dependencies
96
+ - name: Install Dependencies
90
97
  run: uv sync
91
98
 
92
99
  - name: Override FastAPI version
@@ -97,17 +104,16 @@ jobs:
97
104
  run: mkdir coverage
98
105
 
99
106
  - name: Test
100
- # GitHub Windows runners ship bash natively
101
107
  run: uv run --no-sync bash scripts/test-cov.sh
102
108
  env:
103
- COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }}-fa${{ matrix.fastapi-version }}
104
- CONTEXT: ${{ runner.os }}-py${{ matrix.python-version }}-fa${{ matrix.fastapi-version }}
109
+ COVERAGE_FILE: coverage/.coverage.py${{ matrix.python-version }}-fa${{ matrix.fastapi-version }}
110
+ CONTEXT: py${{ matrix.python-version }}-fa${{ matrix.fastapi-version }}
105
111
 
106
112
  - name: Store coverage files
107
113
  if: matrix.coverage == 'coverage'
108
114
  uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
109
115
  with:
110
- name: coverage-${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.fastapi-version }}
116
+ name: coverage-${{ matrix.python-version }}-${{ matrix.fastapi-version }}
111
117
  path: coverage
112
118
  include-hidden-files: true
113
119
 
@@ -119,6 +125,11 @@ jobs:
119
125
  permissions:
120
126
  contents: read
121
127
  steps:
128
+ - name: Dump GitHub context
129
+ env:
130
+ GITHUB_CONTEXT: ${{ toJson(github) }}
131
+ run: echo "$GITHUB_CONTEXT"
132
+
122
133
  - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
123
134
  with:
124
135
  persist-credentials: false
@@ -126,6 +137,8 @@ jobs:
126
137
  - name: Setup uv
127
138
  uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
128
139
  with:
140
+ # Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
141
+ # See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
129
142
  version: "0.11.18"
130
143
  enable-cache: true
131
144
  cache-dependency-glob: |
@@ -145,6 +158,7 @@ jobs:
145
158
  - run: ls -la coverage
146
159
  - run: uv run coverage combine coverage
147
160
  - run: uv run coverage html --title "Coverage for ${{ github.sha }}"
161
+ - run: uv run coverage xml
148
162
 
149
163
  - name: Store coverage HTML
150
164
  uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -153,18 +167,28 @@ jobs:
153
167
  path: htmlcov
154
168
  include-hidden-files: true
155
169
 
170
+ - name: Upload coverage to Codecov
171
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
172
+ with:
173
+ token: ${{ secrets.CODECOV_TOKEN }}
174
+ files: coverage.xml
175
+ fail_ci_if_error: true
176
+
156
177
  - run: uv run coverage report --fail-under=100
157
178
 
158
179
  # https://github.com/marketplace/actions/alls-green#why
159
- test-alls-green: # This job does nothing and is only used for the branch protection
180
+ test-alls-green: # This job does nothing and is only used for the branch protection
160
181
  if: always()
161
182
  needs:
162
183
  - test
163
184
  - coverage-combine
164
185
  runs-on: ubuntu-latest
165
186
  timeout-minutes: 5
166
- permissions: {}
167
187
  steps:
188
+ - name: Dump GitHub context
189
+ env:
190
+ GITHUB_CONTEXT: ${{ toJson(github) }}
191
+ run: echo "$GITHUB_CONTEXT"
168
192
  - name: Decide whether the needed jobs succeeded or failed
169
193
  uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2
170
194
  with:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-router-versioning
3
- Version: 0.2.3
3
+ Version: 0.2.5
4
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
@@ -32,7 +32,8 @@ Description-Content-Type: text/markdown
32
32
  # FastAPI Router Versioning
33
33
 
34
34
  [![Build Status](https://github.com/mat81black/fastapi-router-versioning/workflows/Test/badge.svg)](https://github.com/mat81black/fastapi-router-versioning/actions)
35
- [![Package version](https://badge.fury.io/py/fastapi-router-versioning.svg)](https://pypi.org/project/fastapi-router-versioning/)
35
+ [![codecov](https://codecov.io/github/mat81black/fastapi-router-versioning/graph/badge.svg?token=4WQ63Q7ESY)](https://codecov.io/github/mat81black/fastapi-router-versioning)
36
+ [![pypi package](https://img.shields.io/pypi/v/fastapi-router-versioning?color=%2334D058&label=pypi%20package)](https://pypi.org/project/fastapi-router-versioning/)
36
37
  [![Supported Python versions](https://img.shields.io/pypi/pyversions/fastapi-router-versioning.svg?color=%2334D058)](https://pypi.org/project/fastapi-router-versioning/)
37
38
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
38
39
 
@@ -182,7 +183,9 @@ A route without `@api_version` isn't excluded, it falls back to `default_version
182
183
  | `validation_error_code` | `int` | `422` | Status code returned for request validation errors; also replaces the `422` entry everywhere it appears in the OpenAPI schema, root schema included |
183
184
  | `handle_validation_exceptions` | `bool` | `True` | Set `False` to only patch the schema and register your own `RequestValidationError` handler |
184
185
 
185
- `.versionize()` returns the list of versions it activated.
186
+ `.versionize()` returns the list of versions it activated. It can only be called once per
187
+ instance; a second call raises `RuntimeError`, since it mutates the live FastAPI app in a way
188
+ that can't be undone.
186
189
 
187
190
  ---
188
191
 
@@ -1,7 +1,8 @@
1
1
  # FastAPI Router Versioning
2
2
 
3
3
  [![Build Status](https://github.com/mat81black/fastapi-router-versioning/workflows/Test/badge.svg)](https://github.com/mat81black/fastapi-router-versioning/actions)
4
- [![Package version](https://badge.fury.io/py/fastapi-router-versioning.svg)](https://pypi.org/project/fastapi-router-versioning/)
4
+ [![codecov](https://codecov.io/github/mat81black/fastapi-router-versioning/graph/badge.svg?token=4WQ63Q7ESY)](https://codecov.io/github/mat81black/fastapi-router-versioning)
5
+ [![pypi package](https://img.shields.io/pypi/v/fastapi-router-versioning?color=%2334D058&label=pypi%20package)](https://pypi.org/project/fastapi-router-versioning/)
5
6
  [![Supported Python versions](https://img.shields.io/pypi/pyversions/fastapi-router-versioning.svg?color=%2334D058)](https://pypi.org/project/fastapi-router-versioning/)
6
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
7
8
 
@@ -151,7 +152,9 @@ A route without `@api_version` isn't excluded, it falls back to `default_version
151
152
  | `validation_error_code` | `int` | `422` | Status code returned for request validation errors; also replaces the `422` entry everywhere it appears in the OpenAPI schema, root schema included |
152
153
  | `handle_validation_exceptions` | `bool` | `True` | Set `False` to only patch the schema and register your own `RequestValidationError` handler |
153
154
 
154
- `.versionize()` returns the list of versions it activated.
155
+ `.versionize()` returns the list of versions it activated. It can only be called once per
156
+ instance; a second call raises `RuntimeError`, since it mutates the live FastAPI app in a way
157
+ that can't be undone.
155
158
 
156
159
  ---
157
160
 
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## Latest Changes
4
4
 
5
+ ## 0.2.5 (2026-07-10)
6
+
7
+ ### Fixes
8
+
9
+ * 🐛 Fix misleading self-collision error in RouterVersioner prefix claiming. PR [#36](https://github.com/mat81black/fastapi-router-versioning/pull/36) by [@mat81black](https://github.com/mat81black).
10
+
11
+ ## 0.2.4 (2026-07-09)
12
+
13
+ ### Internal
14
+
15
+ * 🔧 Integrate Codecov for coverage tracking and update README. PR [#34](https://github.com/mat81black/fastapi-router-versioning/pull/34) by [@mat81black](https://github.com/mat81black).
16
+
5
17
  ## 0.2.3 (2026-07-09)
6
18
 
7
19
  ### Docs
@@ -1,5 +1,5 @@
1
1
  from .versioner import RouterVersioner, VersionFormat, VersionT, api_version
2
2
 
3
- __version__ = "0.2.3"
3
+ __version__ = "0.2.5"
4
4
 
5
5
  __all__ = ["RouterVersioner", "api_version", "VersionFormat", "VersionT"]
@@ -1,4 +1,5 @@
1
1
  import inspect
2
+ import itertools
2
3
 
3
4
  from collections import defaultdict
4
5
  from collections.abc import Callable, Iterator
@@ -33,6 +34,12 @@ _ATTR_API_VERSION = "_api_version"
33
34
  _ATTR_DEPRECATE_IN = "_deprecate_in_version"
34
35
  _ATTR_REMOVE_IN = "_remove_in_version"
35
36
 
37
+ # Unique per-instance token for _claim_prefix, so it can tell apart a prefix collision
38
+ # caused by this same RouterVersioner instance (e.g. a degenerate prefix_format) from one
39
+ # caused by a different instance sharing the app. Not id(self): that can be reused after
40
+ # garbage collection for the common `RouterVersioner(...).versionize()` one-liner pattern.
41
+ _instance_counter = itertools.count()
42
+
36
43
 
37
44
  class VersionFormat(str, Enum):
38
45
  """
@@ -194,6 +201,8 @@ class RouterVersioner:
194
201
 
195
202
  self._validation_error_code = validation_error_code
196
203
  self._handle_validation_exceptions = handle_validation_exceptions
204
+ self._versionized = False
205
+ self._instance_token = next(_instance_counter)
197
206
 
198
207
  if self._validation_error_code != 422 and self._handle_validation_exceptions:
199
208
  self._register_validation_exception_handler()
@@ -345,6 +354,24 @@ class RouterVersioner:
345
354
  return None
346
355
 
347
356
  def versionize(self) -> list[VersionT]:
357
+ """
358
+ Reads the configured routers, groups their routes by version, and mounts one
359
+ versioned router per active version on the app.
360
+
361
+ May only be called once per instance: it mutates the live FastAPI app (including
362
+ routers, registering routes) in a way that cannot be undone, so a second call
363
+ would either duplicate routes or, if a version prefix was already claimed, raise.
364
+ Create a new RouterVersioner if you need to versionize again.
365
+
366
+ :return: The list of versions that were actually mounted.
367
+ """
368
+ if self._versionized:
369
+ raise RuntimeError(
370
+ "versionize() was already called on this RouterVersioner instance. "
371
+ "Create a new RouterVersioner if you need to versionize a router again."
372
+ )
373
+ self._versionized = True
374
+
348
375
  latest_version: VersionT | None = None
349
376
  latest_routes: dict[tuple[str, str], Any] = {}
350
377
  latest_webhooks: list[Any] = []
@@ -450,19 +477,27 @@ class RouterVersioner:
450
477
  return {version: list(routes.values()) for version, routes in self._resolve_lifecycle(all_webhooks).items()}
451
478
 
452
479
  def _claim_prefix(self, prefix: str) -> None:
453
- claimed: set[str] | None = getattr(self._app.state, "_router_versioner_claimed_prefixes", None)
480
+ claimed: dict[str, int] | None = getattr(self._app.state, "_router_versioner_claimed_prefixes", None)
454
481
  if claimed is None:
455
- claimed = set()
482
+ claimed = {}
456
483
  self._app.state._router_versioner_claimed_prefixes = claimed
457
484
 
458
- if prefix in claimed:
485
+ owner = claimed.get(prefix)
486
+ if owner is not None:
487
+ if owner == self._instance_token:
488
+ raise RuntimeError(
489
+ f"Prefix '{prefix}' was already claimed by this same RouterVersioner instance. "
490
+ "This usually means prefix_format doesn't use {major}/{minor}/{version} and "
491
+ "produces the same prefix for multiple versions, or latest_prefix collides "
492
+ "with an already-active version prefix."
493
+ )
459
494
  raise RuntimeError(
460
495
  f"Prefix '{prefix}' is already used by another RouterVersioner attached to this app. "
461
496
  "Two RouterVersioner instances sharing the same app must use distinct "
462
497
  "prefix_format/latest_prefix values, otherwise their docs/openapi routes silently "
463
498
  "shadow each other."
464
499
  )
465
- claimed.add(prefix)
500
+ claimed[prefix] = self._instance_token
466
501
 
467
502
  def _resolve_webhooks_for_version(
468
503
  self, version: VersionT, webhooks_by_version: dict[VersionT, list[Any]]
@@ -687,6 +687,29 @@ def test_default_version_mixed_with_explicitly_decorated_routes() -> None:
687
687
  assert client.get("/v3_0/explicit").status_code == 200
688
688
 
689
689
 
690
+ def test_routers_as_list() -> None:
691
+ """routers accepts a list of APIRouter, not just a single one (multi-router support)."""
692
+ app = FastAPI()
693
+ router1 = APIRouter()
694
+ router2 = APIRouter()
695
+
696
+ @router1.get("/a")
697
+ @api_version((1, 0))
698
+ def route_a() -> dict[str, str]:
699
+ return {"msg": "a"}
700
+
701
+ @router2.get("/b")
702
+ @api_version((1, 0))
703
+ def route_b() -> dict[str, str]:
704
+ return {"msg": "b"}
705
+
706
+ RouterVersioner(app=app, routers=[router1, router2], version_format=VersionFormat.SEMVER).versionize()
707
+
708
+ client = TestClient(app)
709
+ assert client.get("/v1_0/a").status_code == 200
710
+ assert client.get("/v1_0/b").status_code == 200
711
+
712
+
690
713
  def test_openapi_callbacks_are_propagated_to_versioned_routes() -> None:
691
714
  """OpenAPI callbacks defined on a route are propagated to every versioned copy of that route."""
692
715
  app = FastAPI()
@@ -815,6 +838,38 @@ def test_webhook_routers_none_falls_back_to_app_webhooks() -> None:
815
838
  assert "/global-event" in schema_v1.get("webhooks", {})
816
839
 
817
840
 
841
+ def test_webhook_routers_as_list() -> None:
842
+ """webhook_routers accepts a list of APIRouter, not just a single one."""
843
+ app = FastAPI()
844
+ router = APIRouter()
845
+ webhook_router1 = APIRouter()
846
+ webhook_router2 = APIRouter()
847
+
848
+ @router.get("/items")
849
+ @api_version((1, 0))
850
+ def get_items() -> dict[str, str]: ...
851
+
852
+ @webhook_router1.post("/hook-a")
853
+ @api_version((1, 0))
854
+ def hook_a(body: dict[str, str]) -> None: ...
855
+
856
+ @webhook_router2.post("/hook-b")
857
+ @api_version((1, 0))
858
+ def hook_b(body: dict[str, str]) -> None: ...
859
+
860
+ RouterVersioner(
861
+ app=app,
862
+ routers=router,
863
+ webhook_routers=[webhook_router1, webhook_router2],
864
+ version_format=VersionFormat.SEMVER,
865
+ ).versionize()
866
+
867
+ client = TestClient(app)
868
+ schema = client.get("/v1_0/openapi.json").json()
869
+ assert "/hook-a" in schema.get("webhooks", {})
870
+ assert "/hook-b" in schema.get("webhooks", {})
871
+
872
+
818
873
  def test_webhook_routers_no_webhook_before_first_version() -> None:
819
874
  """When all webhook versions are higher than the current route version, returns no webhooks."""
820
875
  app = FastAPI()
@@ -1281,6 +1336,59 @@ def test_duplicate_version_prefix_across_versioners_raises() -> None:
1281
1336
  RouterVersioner(app=app, routers=router2, version_format=VersionFormat.SEMVER).versionize()
1282
1337
 
1283
1338
 
1339
+ def test_degenerate_prefix_format_self_collision_raises() -> None:
1340
+ """A prefix_format without {major}/{minor}/{version} placeholders makes every version
1341
+ resolve to the same prefix. This is a self-collision on a single instance, not a clash
1342
+ with another RouterVersioner, so the error message must say so explicitly."""
1343
+ app = FastAPI()
1344
+ router = APIRouter()
1345
+
1346
+ @router.get("/items")
1347
+ @api_version((1, 0))
1348
+ def get_items_v1() -> dict[str, str]: ...
1349
+
1350
+ @router.get("/items")
1351
+ @api_version((2, 0))
1352
+ def get_items_v2() -> dict[str, str]: ...
1353
+
1354
+ with pytest.raises(RuntimeError, match="already claimed by this same RouterVersioner instance"):
1355
+ RouterVersioner(app=app, routers=router, version_format=VersionFormat.SEMVER, prefix_format="/api").versionize()
1356
+
1357
+
1358
+ def test_latest_prefix_collides_with_own_version_prefix_raises() -> None:
1359
+ """latest_prefix accidentally set to the same value as an actual version's own prefix is
1360
+ a self-collision on a single instance, not a clash with another RouterVersioner."""
1361
+ app = FastAPI()
1362
+ router = APIRouter()
1363
+
1364
+ @router.get("/items")
1365
+ @api_version((1, 0))
1366
+ def get_items() -> dict[str, str]: ...
1367
+
1368
+ with pytest.raises(RuntimeError, match="already claimed by this same RouterVersioner instance"):
1369
+ RouterVersioner(
1370
+ app=app, routers=router, version_format=VersionFormat.SEMVER, latest_prefix="/v1_0"
1371
+ ).versionize()
1372
+
1373
+
1374
+ def test_calling_versionize_twice_on_same_instance_raises() -> None:
1375
+ """Calling .versionize() a second time on the same instance must raise a clear,
1376
+ self-explanatory error instead of the misleading '...used by another RouterVersioner'
1377
+ message that _claim_prefix would otherwise produce (it's the same instance, not another)."""
1378
+ app = FastAPI()
1379
+ router = APIRouter()
1380
+
1381
+ @router.get("/items")
1382
+ @api_version((1, 0))
1383
+ def get_items() -> dict[str, str]: ...
1384
+
1385
+ versioner = RouterVersioner(app=app, routers=router, version_format=VersionFormat.SEMVER)
1386
+ versioner.versionize()
1387
+
1388
+ with pytest.raises(RuntimeError, match="versionize\\(\\) was already called on this RouterVersioner instance"):
1389
+ versioner.versionize()
1390
+
1391
+
1284
1392
  def test_duplicate_latest_prefix_across_versioners_raises() -> None:
1285
1393
  """Two RouterVersioners sharing the same latest_prefix on the same app must fail fast."""
1286
1394
  app = FastAPI()