fastapi-router-versioning 0.2.4__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.4 → fastapi_router_versioning-0.2.5}/PKG-INFO +6 -4
  2. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/README.md +5 -3
  3. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/RELEASE_NOTES.md +6 -0
  4. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/fastapi_router_versioning/__init__.py +1 -1
  5. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/fastapi_router_versioning/versioner.py +39 -4
  6. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/tests/test_versioner.py +108 -0
  7. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/.github/dependabot.yml +0 -0
  8. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/.github/labeler.yml +0 -0
  9. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/.github/workflows/create-draft-release.yml +0 -0
  10. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/.github/workflows/detect-conflicts.yml +0 -0
  11. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/.github/workflows/guard-dependencies.yml +0 -0
  12. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/.github/workflows/labeler.yml +0 -0
  13. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/.github/workflows/latest-changes.yml +0 -0
  14. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/.github/workflows/pre-commit.yml +0 -0
  15. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/.github/workflows/prepare-release.yml +0 -0
  16. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/.github/workflows/publish.yml +0 -0
  17. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/.github/workflows/test-redistribute.yml +0 -0
  18. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/.github/workflows/test.yml +0 -0
  19. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/.github/workflows/zizmor.yml +0 -0
  20. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/.gitignore +0 -0
  21. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/.pre-commit-config.yaml +0 -0
  22. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/.python-version +0 -0
  23. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/LICENSE +0 -0
  24. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/examples/calver_app.py +0 -0
  25. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/examples/download_static_assets.py +0 -0
  26. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/examples/mounted_subapps_app.py +0 -0
  27. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/examples/multi_router_app.py +0 -0
  28. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/examples/openapi_hook_app.py +0 -0
  29. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/examples/self_hosted_docs_app.py +0 -0
  30. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/examples/semver_app.py +0 -0
  31. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/examples/semver_major_only_app.py +0 -0
  32. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/examples/validation_error_code_app.py +0 -0
  33. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/examples/validation_error_code_custom_handler_app.py +0 -0
  34. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/examples/webhook_versioning_app.py +0 -0
  35. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/fastapi_router_versioning/py.typed +0 -0
  36. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/pyproject.toml +0 -0
  37. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/scripts/format.sh +0 -0
  38. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/scripts/lint.sh +0 -0
  39. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/scripts/prepare_release.py +0 -0
  40. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/scripts/test-cov-html.sh +0 -0
  41. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/scripts/test-cov.sh +0 -0
  42. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/scripts/test.sh +0 -0
  43. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/tests/__init__.py +0 -0
  44. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/tests/test_calver.py +0 -0
  45. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/tests/test_fastapi_integration.py +0 -0
  46. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/tests/test_prepare_release.py +0 -0
  47. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/tests/test_semver.py +0 -0
  48. {fastapi_router_versioning-0.2.4 → fastapi_router_versioning-0.2.5}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-router-versioning
3
- Version: 0.2.4
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,8 +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
- [![codecov](https://codecov.io/github/mat81black/fastapi-validation-override/graph/badge.svg?token=SL4JPWAB0O)](https://codecov.io/github/mat81black/fastapi-validation-override)
36
- [![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/)
37
37
  [![Supported Python versions](https://img.shields.io/pypi/pyversions/fastapi-router-versioning.svg?color=%2334D058)](https://pypi.org/project/fastapi-router-versioning/)
38
38
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
39
39
 
@@ -183,7 +183,9 @@ A route without `@api_version` isn't excluded, it falls back to `default_version
183
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 |
184
184
  | `handle_validation_exceptions` | `bool` | `True` | Set `False` to only patch the schema and register your own `RequestValidationError` handler |
185
185
 
186
- `.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.
187
189
 
188
190
  ---
189
191
 
@@ -1,8 +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
- [![codecov](https://codecov.io/github/mat81black/fastapi-validation-override/graph/badge.svg?token=SL4JPWAB0O)](https://codecov.io/github/mat81black/fastapi-validation-override)
5
- [![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/)
6
6
  [![Supported Python versions](https://img.shields.io/pypi/pyversions/fastapi-router-versioning.svg?color=%2334D058)](https://pypi.org/project/fastapi-router-versioning/)
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
8
8
 
@@ -152,7 +152,9 @@ A route without `@api_version` isn't excluded, it falls back to `default_version
152
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 |
153
153
  | `handle_validation_exceptions` | `bool` | `True` | Set `False` to only patch the schema and register your own `RequestValidationError` handler |
154
154
 
155
- `.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.
156
158
 
157
159
  ---
158
160
 
@@ -2,6 +2,12 @@
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
+
5
11
  ## 0.2.4 (2026-07-09)
6
12
 
7
13
  ### Internal
@@ -1,5 +1,5 @@
1
1
  from .versioner import RouterVersioner, VersionFormat, VersionT, api_version
2
2
 
3
- __version__ = "0.2.4"
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()