fastapi-router-versioning 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,5 @@
1
+ from .versioner import RouterVersioner, VersionFormat, VersionT, api_version
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["RouterVersioner", "api_version", "VersionFormat", "VersionT"]
File without changes
@@ -0,0 +1,564 @@
1
+ import inspect
2
+
3
+ from collections import defaultdict
4
+ from collections.abc import Callable, Iterator
5
+ from enum import Enum
6
+ from typing import Any, TypeAlias, TypeVar
7
+
8
+ import fastapi.openapi.utils
9
+ import fastapi.routing
10
+
11
+ from fastapi import APIRouter, FastAPI
12
+ from fastapi.openapi.docs import (
13
+ get_redoc_html,
14
+ get_swagger_ui_html,
15
+ get_swagger_ui_oauth2_redirect_html,
16
+ )
17
+ from fastapi.responses import HTMLResponse, JSONResponse
18
+ from fastapi.routing import APIRoute, APIWebSocketRoute
19
+ from starlette.requests import Request
20
+
21
+ # iter_route_contexts was introduced in FastAPI 0.137.2. On older versions the
22
+ # attribute does not exist and getattr returns None, activating the fallback path.
23
+ _route_contexts_fn: Callable[..., Any] | None = getattr(fastapi.routing, "iter_route_contexts", None)
24
+
25
+
26
+ def _iter_routes_flat(routes: list[Any]) -> Iterator[Any]:
27
+ """
28
+ Flattens the route tree using iter_route_contexts (FastAPI >= 0.137.2),
29
+ or yields the original flat list for older versions.
30
+ """
31
+ if _route_contexts_fn is None:
32
+ yield from routes
33
+ return
34
+
35
+ for route_ctx in _route_contexts_fn(routes):
36
+ original = route_ctx.original_route
37
+ if isinstance(original, APIRoute):
38
+ # RouteContext merges path/tags/deps via __getattr__; use the context directly.
39
+ yield route_ctx
40
+ else:
41
+ yield original
42
+
43
+
44
+ def _unwrap_route(route: Any) -> Any:
45
+ # RouteContext (FastAPI >= 0.137.2) wraps the original route.
46
+ # Attributes like response_model, status_code, operation_id live on the original.
47
+ return getattr(route, "original_route", route)
48
+
49
+
50
+ CallableT = TypeVar("CallableT", bound=Callable[..., Any])
51
+
52
+ VersionT: TypeAlias = tuple[int, int] | str
53
+
54
+ _ATTR_API_VERSION = "_api_version"
55
+ _ATTR_DEPRECATE_IN = "_deprecate_in_version"
56
+ _ATTR_REMOVE_IN = "_remove_in_version"
57
+
58
+
59
+ class VersionFormat(str, Enum):
60
+ """
61
+ Defines the allowed versioning strategy for the RouterVersioner.
62
+ """
63
+
64
+ SEMVER = "semver" # Accepts tuple[int, int] (e.g., (1, 0))
65
+ CALVER = "calver" # Accepts str (e.g., "2025-01-01", "v1")
66
+
67
+
68
+ def _validate_api_version_arg(value: Any, param_name: str) -> None:
69
+ if not isinstance(value, (tuple, str)):
70
+ raise TypeError(
71
+ f"api_version: '{param_name}' must be a tuple[int, int] (SemVer) or str (CalVer), "
72
+ f"got {type(value).__name__!r} instead. "
73
+ "Example: @api_version((1, 0)) or @api_version('2025-01-01')."
74
+ )
75
+
76
+
77
+ def api_version(
78
+ version: VersionT,
79
+ deprecate_in: VersionT | None = None,
80
+ remove_in: VersionT | None = None,
81
+ ) -> Callable[[CallableT], CallableT]:
82
+ """
83
+ Decorator to annotate API routes with their specific version.
84
+
85
+ Accepts both Semantic Versioning (e.g., tuple (1, 0)) and Calendar Versioning
86
+ or strings (e.g., "2025-01-01", "v1").
87
+ Metadata is injected directly into the wrapper function, allowing the
88
+ RouterVersioner to organize the routes dynamically.
89
+ """
90
+ _validate_api_version_arg(version, "version")
91
+ if deprecate_in is not None:
92
+ _validate_api_version_arg(deprecate_in, "deprecate_in")
93
+ if remove_in is not None:
94
+ _validate_api_version_arg(remove_in, "remove_in")
95
+
96
+ def decorator(func: CallableT) -> CallableT:
97
+ setattr(func, _ATTR_API_VERSION, version) # noqa: B010
98
+
99
+ if deprecate_in is not None:
100
+ setattr(func, _ATTR_DEPRECATE_IN, deprecate_in) # noqa: B010
101
+
102
+ if remove_in is not None:
103
+ setattr(func, _ATTR_REMOVE_IN, remove_in) # noqa: B010
104
+
105
+ return func
106
+
107
+ return decorator
108
+
109
+
110
+ class RouterVersioner:
111
+ def __init__(
112
+ self,
113
+ app: FastAPI,
114
+ routers: list[APIRouter] | APIRouter,
115
+ version_format: VersionFormat = VersionFormat.SEMVER,
116
+ prefix_format: str | None = None,
117
+ semantic_version_format: str | None = None,
118
+ default_version: VersionT | None = None,
119
+ latest_prefix: str | None = None,
120
+ include_version_docs: bool = True,
121
+ include_version_openapi_route: bool = True,
122
+ include_versions_route: bool = False,
123
+ sort_routes: bool = False,
124
+ callback: Callable[[APIRouter, VersionT, str], None] | None = None,
125
+ webhook_routers: list[APIRouter] | APIRouter | None = None,
126
+ openapi_hook: Callable[[dict[str, Any], VersionT], dict[str, Any]] | None = None,
127
+ swagger_js_url: str | None = None,
128
+ swagger_css_url: str | None = None,
129
+ swagger_favicon_url: str | None = None,
130
+ redoc_js_url: str | None = None,
131
+ redoc_favicon_url: str | None = None,
132
+ redoc_with_google_fonts: bool = True,
133
+ ):
134
+ """
135
+ Versionize your FastAPI application in-place, organizing routes based on their API version.
136
+
137
+ :param app: The main FastAPI application instance.
138
+ :param routers: A single APIRouter or a list of APIRouters containing the routes to version.
139
+ :param version_format: Enforces the versioning strategy (SEMVER or CALVER).
140
+ For CALVER, version strings must be lexicographically sortable in the intended
141
+ order (e.g. ISO dates "2025-01-01", zero-padded numbers "v01", "v02").
142
+ Strings like "v1", "v10", "v2" will NOT sort correctly and cause routes to
143
+ appear in the wrong versions.
144
+ :param prefix_format: Format used to build the route prefix.
145
+ :param semantic_version_format: Format used to build the version in Swagger/ReDoc.
146
+ :param default_version: Default version used if a route is not explicitly decorated.
147
+ :param latest_prefix: If specified, creates an alias prefix for the latest active version.
148
+ :param include_version_docs: If True, creates isolated Swagger/ReDoc pages for each version.
149
+ :param include_version_openapi_route: If True, creates an independent openapi.json route for each version.
150
+ :param include_versions_route: If True, adds a 'GET /versions' endpoint returning info on all active API versions.
151
+ :param sort_routes: If True, sorts all routes alphabetically by path.
152
+ :param callback: Optional hook invoked every time a versioned APIRouter is created.
153
+ :param webhook_routers: A single APIRouter or a list of APIRouters containing webhook definitions
154
+ annotated with @api_version. When provided, each version's OpenAPI schema shows only the
155
+ webhooks active in that version (using the same introduce/remove lifecycle as regular routes).
156
+ When None, every version inherits app.webhooks unchanged.
157
+ :param openapi_hook: Optional hook applied to the generated OpenAPI schema for each version.
158
+ Receives the schema dict and the current version; must return the (modified) schema dict.
159
+ Use this to add custom extensions, logos, or version-specific metadata that would
160
+ otherwise be bypassed by the per-version schema generation.
161
+ :param swagger_js_url: Custom URL for the Swagger UI JS bundle. Defaults to FastAPI's CDN URL.
162
+ :param swagger_css_url: Custom URL for the Swagger UI CSS. Defaults to FastAPI's CDN URL.
163
+ :param swagger_favicon_url: Custom URL for the Swagger UI favicon. Defaults to FastAPI's favicon.
164
+ :param redoc_js_url: Custom URL for the ReDoc JS bundle. Defaults to FastAPI's CDN URL.
165
+ :param redoc_favicon_url: Custom URL for the ReDoc favicon. Defaults to FastAPI's favicon.
166
+ :param redoc_with_google_fonts: If False, ReDoc will not load Google Fonts. Defaults to True.
167
+ """
168
+ self._app = app
169
+ self._routers = [routers] if isinstance(routers, APIRouter) else routers
170
+ self._version_format = version_format
171
+
172
+ if prefix_format is None:
173
+ self._prefix_format = "/v{major}_{minor}" if version_format == VersionFormat.SEMVER else "/{version}"
174
+ else:
175
+ self._prefix_format = prefix_format
176
+
177
+ if semantic_version_format is None:
178
+ self._semantic_version_format = "{major}.{minor}" if version_format == VersionFormat.SEMVER else "{version}"
179
+ else:
180
+ self._semantic_version_format = semantic_version_format
181
+
182
+ self._latest_prefix = latest_prefix
183
+ self._include_version_docs = include_version_docs
184
+ self._include_version_openapi_route = include_version_openapi_route
185
+ self._include_versions_route = include_versions_route
186
+ self._sort_routes = sort_routes
187
+ self._callback = callback
188
+ self._webhook_routers: list[APIRouter] | None = (
189
+ [webhook_routers] if isinstance(webhook_routers, APIRouter) else webhook_routers
190
+ )
191
+ self._openapi_hook = openapi_hook
192
+ self._swagger_js_url = swagger_js_url
193
+ self._swagger_css_url = swagger_css_url
194
+ self._swagger_favicon_url = swagger_favicon_url
195
+ self._redoc_js_url = redoc_js_url
196
+ self._redoc_favicon_url = redoc_favicon_url
197
+ self._redoc_with_google_fonts = redoc_with_google_fonts
198
+
199
+ if default_version is None:
200
+ self._default_version: VersionT = (1, 0) if version_format == VersionFormat.SEMVER else "1"
201
+ else:
202
+ self._validate_version_type(default_version, "default_version fallback")
203
+ self._default_version = default_version
204
+
205
+ self._docs_url = getattr(app, "docs_url", "/docs")
206
+ self._redoc_url = getattr(app, "redoc_url", "/redoc")
207
+
208
+ def _validate_version_type(self, version: Any, route_path: str) -> None:
209
+ if self._version_format == VersionFormat.SEMVER:
210
+ if not isinstance(version, tuple) or len(version) != 2 or not all(isinstance(i, int) for i in version):
211
+ error_msg = f"RouterVersioner expects SEMVER, but found an invalid version '{version}' on {route_path}. Use a tuple of exactly two integers: (major, minor). e.g., (1, 0)."
212
+ raise ValueError(error_msg)
213
+ elif self._version_format == VersionFormat.CALVER:
214
+ if not isinstance(version, str):
215
+ error_msg = f"RouterVersioner expects CALVER, but found a non-string version '{version}' on {route_path}. Use a string like '2025-01-01'."
216
+ raise ValueError(error_msg)
217
+
218
+ @staticmethod
219
+ def _format_string(format_str: str, version: VersionT) -> str:
220
+ if isinstance(version, tuple):
221
+ return format_str.format(major=version[0], minor=version[1], version=f"{version[0]}_{version[1]}")
222
+ return format_str.format(version=version, major=version, minor=version)
223
+
224
+ def _extract_version_attribute(self, endpoint: Any, attribute: str, route_path: str) -> VersionT | None:
225
+ val = getattr(endpoint, attribute, None)
226
+ if isinstance(val, (tuple, str)):
227
+ self._validate_version_type(val, route_path)
228
+ return val
229
+ return None
230
+
231
+ def versionize(self) -> list[VersionT]:
232
+ latest_version: VersionT | None = None
233
+ latest_routes: dict[tuple[str, str], Any] = {}
234
+ latest_webhooks: list[Any] = []
235
+
236
+ routes_by_version = self._get_routes_by_version()
237
+ versions = list(routes_by_version.keys())
238
+ webhooks_by_version = self._get_webhooks_by_version()
239
+
240
+ for version, routes_by_key in routes_by_version.items():
241
+ version_prefix = self._format_string(self._prefix_format, version)
242
+ active_webhooks = self._resolve_webhooks_for_version(version, webhooks_by_version)
243
+
244
+ version_router = self._build_version_router(
245
+ version=version, version_prefix=version_prefix, routes_by_key=routes_by_key, webhooks=active_webhooks
246
+ )
247
+
248
+ if self._callback:
249
+ self._callback(version_router, version, version_prefix)
250
+
251
+ self._app.include_router(router=version_router)
252
+
253
+ latest_version = version
254
+ latest_routes = routes_by_key
255
+ latest_webhooks = active_webhooks
256
+
257
+ if self._latest_prefix is not None and latest_version is not None:
258
+ latest_router = self._build_version_router(
259
+ version=latest_version,
260
+ version_prefix=self._latest_prefix,
261
+ routes_by_key=latest_routes,
262
+ webhooks=latest_webhooks,
263
+ )
264
+ if self._callback:
265
+ self._callback(latest_router, latest_version, self._latest_prefix)
266
+ self._app.include_router(router=latest_router)
267
+
268
+ if self._include_versions_route:
269
+ self._add_versions_route(versions=versions)
270
+
271
+ return versions
272
+
273
+ def _build_version_router(
274
+ self,
275
+ version: VersionT,
276
+ version_prefix: str,
277
+ routes_by_key: dict[tuple[str, str], Any],
278
+ webhooks: list[Any],
279
+ ) -> APIRouter:
280
+ router = APIRouter(prefix=version_prefix, responses=self._app.router.responses)
281
+
282
+ if self._sort_routes:
283
+ routes_by_key = dict(sorted(routes_by_key.items()))
284
+
285
+ for route in routes_by_key.values():
286
+ self._add_route_to_router(route=route, router=router, version=version)
287
+
288
+ self._add_version_docs(router=router, version=version, version_prefix=version_prefix, webhooks=webhooks)
289
+
290
+ return router
291
+
292
+ def _get_routes_by_version(self) -> dict[VersionT, dict[tuple[str, str], Any]]:
293
+ all_routes: list[Any] = []
294
+ for router in self._routers:
295
+ all_routes.extend(_iter_routes_flat(router.routes))
296
+
297
+ routes_introduced: dict[VersionT, list[Any]] = defaultdict(list)
298
+ for route in all_routes:
299
+ start_version = self._extract_version_attribute(route.endpoint, _ATTR_API_VERSION, route.path)
300
+ if start_version is None:
301
+ start_version = self._default_version
302
+ routes_introduced[start_version].append(route)
303
+
304
+ routes_removed: dict[VersionT, list[Any]] = defaultdict(list)
305
+ for route in all_routes:
306
+ end_version = self._extract_version_attribute(route.endpoint, _ATTR_REMOVE_IN, route.path)
307
+ if end_version is not None:
308
+ routes_removed[end_version].append(route)
309
+
310
+ all_version_keys = set(routes_introduced.keys()) | set(routes_removed.keys())
311
+ versions = sorted(all_version_keys)
312
+ routes_by_version: dict[VersionT, dict[tuple[str, str], Any]] = {}
313
+ active_routes: dict[tuple[str, str], Any] = {}
314
+
315
+ for version in versions:
316
+ for route in routes_introduced[version]:
317
+ active_routes.update(self._get_route_keys(route=route))
318
+
319
+ for route in routes_removed.get(version, []):
320
+ for route_key in self._get_route_keys(route=route):
321
+ active_routes.pop(route_key, None)
322
+
323
+ routes_by_version[version] = dict(active_routes)
324
+
325
+ return routes_by_version
326
+
327
+ def _get_webhooks_by_version(self) -> dict[VersionT, list[Any]]:
328
+ if not self._webhook_routers:
329
+ return {}
330
+
331
+ all_webhooks: list[Any] = []
332
+ for router in self._webhook_routers:
333
+ all_webhooks.extend(_iter_routes_flat(router.routes))
334
+
335
+ webhooks_introduced: dict[VersionT, list[Any]] = defaultdict(list)
336
+ for route in all_webhooks:
337
+ v = self._extract_version_attribute(route.endpoint, _ATTR_API_VERSION, route.path)
338
+ webhooks_introduced[v if v is not None else self._default_version].append(route)
339
+
340
+ webhooks_removed: dict[VersionT, list[Any]] = defaultdict(list)
341
+ for route in all_webhooks:
342
+ v = self._extract_version_attribute(route.endpoint, _ATTR_REMOVE_IN, route.path)
343
+ if v is not None:
344
+ webhooks_removed[v].append(route)
345
+
346
+ combined_keys = set(webhooks_introduced.keys()) | set(webhooks_removed.keys())
347
+ result: dict[VersionT, list[Any]] = {}
348
+ active: dict[tuple[str, str], Any] = {}
349
+
350
+ for version in sorted(combined_keys):
351
+ for route in webhooks_introduced[version]:
352
+ active.update(self._get_route_keys(route=route))
353
+ for route in webhooks_removed.get(version, []):
354
+ for key in self._get_route_keys(route=route):
355
+ active.pop(key, None)
356
+ result[version] = list(active.values())
357
+
358
+ return result
359
+
360
+ def _resolve_webhooks_for_version(
361
+ self, version: VersionT, webhooks_by_version: dict[VersionT, list[Any]]
362
+ ) -> list[Any]:
363
+ if not webhooks_by_version:
364
+ # webhook_routers not provided: fall back to global app.webhooks
365
+ return list(self._app.webhooks.routes)
366
+ candidates: list[VersionT] = []
367
+ if isinstance(version, tuple):
368
+ candidates = [v for v in webhooks_by_version if isinstance(v, tuple) and v <= version]
369
+ else:
370
+ candidates = [v for v in webhooks_by_version if isinstance(v, str) and v <= version]
371
+ if not candidates:
372
+ return []
373
+ return webhooks_by_version[max(candidates)]
374
+
375
+ @classmethod
376
+ def _get_route_keys(cls, route: Any) -> dict[tuple[str, str], Any]:
377
+ path = route.path
378
+ routes_by_key: dict[tuple[str, str], Any] = {}
379
+ route_type = _unwrap_route(route)
380
+
381
+ if isinstance(route_type, APIRoute):
382
+ for method in route.methods:
383
+ routes_by_key[(path, method)] = route
384
+ elif isinstance(route_type, APIWebSocketRoute):
385
+ routes_by_key[(path, "")] = route
386
+
387
+ return routes_by_key
388
+
389
+ def _add_version_docs(self, router: APIRouter, version: VersionT, version_prefix: str, webhooks: list[Any]) -> None:
390
+ doc_version_str = self._format_string(self._semantic_version_format, version)
391
+ title = f"{self._app.title} - v{doc_version_str}"
392
+ versioned_tags = self._collect_versioned_tags(router)
393
+ openapi_url = self._app.openapi_url
394
+
395
+ if self._include_version_openapi_route and openapi_url is not None:
396
+ self._add_openapi_route(router, title, doc_version_str, versioned_tags, openapi_url, version, webhooks)
397
+
398
+ if self._include_version_docs and self._docs_url is not None and openapi_url is not None:
399
+ self._add_swagger_ui_routes(router, title, version_prefix, self._docs_url, openapi_url)
400
+
401
+ if self._include_version_docs and self._redoc_url is not None and openapi_url is not None:
402
+ self._add_redoc_route(router, title, version_prefix, self._redoc_url, openapi_url)
403
+
404
+ def _collect_versioned_tags(self, router: APIRouter) -> list[dict[str, Any]]:
405
+ if self._app.openapi_tags is None:
406
+ return []
407
+ tags: set[str | Enum] = set()
408
+ for route in router.routes:
409
+ if isinstance(route, APIRoute) and isinstance(route.tags, list):
410
+ tags.update(route.tags)
411
+ if not tags:
412
+ return []
413
+ return [tag for tag in self._app.openapi_tags if tag["name"] in tags]
414
+
415
+ def _add_openapi_route(
416
+ self,
417
+ router: APIRouter,
418
+ title: str,
419
+ doc_version_str: str,
420
+ versioned_tags: list[dict[str, Any]],
421
+ openapi_url: str,
422
+ version: VersionT,
423
+ webhooks: list[Any],
424
+ ) -> None:
425
+ @router.get(openapi_url, include_in_schema=False)
426
+ 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
+
444
+ root_path = req.scope.get("root_path", "").rstrip("/")
445
+ if root_path and getattr(self._app, "root_path_in_servers", True):
446
+ server_urls = {s.get("url") for s in schema.get("servers", [])}
447
+ if root_path not in server_urls:
448
+ schema = dict(schema)
449
+ schema["servers"] = [{"url": root_path}] + schema.get("servers", [])
450
+
451
+ if self._openapi_hook is not None:
452
+ schema = self._openapi_hook(schema, version)
453
+
454
+ return schema
455
+
456
+ def _add_swagger_ui_routes(
457
+ self, router: APIRouter, title: str, version_prefix: str, docs_url: str, openapi_url: str
458
+ ) -> None:
459
+ swagger_asset_kwargs: dict[str, Any] = {}
460
+ if self._swagger_js_url is not None:
461
+ swagger_asset_kwargs["swagger_js_url"] = self._swagger_js_url
462
+ if self._swagger_css_url is not None:
463
+ swagger_asset_kwargs["swagger_css_url"] = self._swagger_css_url
464
+ if self._swagger_favicon_url is not None:
465
+ swagger_asset_kwargs["swagger_favicon_url"] = self._swagger_favicon_url
466
+
467
+ # root_path is resolved at request time (mirrors FastAPI's own /docs handler).
468
+ @router.get(docs_url, include_in_schema=False)
469
+ async def get_docs(request: Request) -> HTMLResponse:
470
+ root_path = request.scope.get("root_path", "").rstrip("/")
471
+ versioned_openapi_url = f"{root_path}{version_prefix}{openapi_url}"
472
+ oauth2_redirect_url = (
473
+ f"{root_path}{version_prefix}{self._app.swagger_ui_oauth2_redirect_url}"
474
+ if self._app.swagger_ui_oauth2_redirect_url
475
+ else None
476
+ )
477
+ return get_swagger_ui_html(
478
+ openapi_url=versioned_openapi_url,
479
+ title=title,
480
+ oauth2_redirect_url=oauth2_redirect_url,
481
+ init_oauth=self._app.swagger_ui_init_oauth,
482
+ swagger_ui_parameters=self._app.swagger_ui_parameters,
483
+ **swagger_asset_kwargs,
484
+ )
485
+
486
+ if self._app.swagger_ui_oauth2_redirect_url:
487
+
488
+ @router.get(self._app.swagger_ui_oauth2_redirect_url, include_in_schema=False)
489
+ async def get_oauth2_redirect(_request: Request) -> HTMLResponse:
490
+ return get_swagger_ui_oauth2_redirect_html()
491
+
492
+ def _add_redoc_route(
493
+ self, router: APIRouter, title: str, version_prefix: str, redoc_url: str, openapi_url: str
494
+ ) -> None:
495
+ redoc_asset_kwargs: dict[str, Any] = {"with_google_fonts": self._redoc_with_google_fonts}
496
+ if self._redoc_js_url is not None:
497
+ redoc_asset_kwargs["redoc_js_url"] = self._redoc_js_url
498
+ if self._redoc_favicon_url is not None:
499
+ redoc_asset_kwargs["redoc_favicon_url"] = self._redoc_favicon_url
500
+
501
+ @router.get(redoc_url, include_in_schema=False)
502
+ async def get_redoc(request: Request) -> HTMLResponse:
503
+ root_path = request.scope.get("root_path", "").rstrip("/")
504
+ versioned_openapi_url = f"{root_path}{version_prefix}{openapi_url}"
505
+ return get_redoc_html(openapi_url=versioned_openapi_url, title=title, **redoc_asset_kwargs)
506
+
507
+ def _add_versions_route(self, versions: list[VersionT]) -> None:
508
+ @self._app.get("/versions", tags=["Versions"], response_class=JSONResponse)
509
+ def get_versions(request: Request) -> dict[str, Any]:
510
+ root_path = request.scope.get("root_path", "").rstrip("/")
511
+ version_models: list[dict[str, Any]] = []
512
+ for version in versions:
513
+ version_prefix = self._format_string(self._prefix_format, version)
514
+ doc_version_str = self._format_string(self._semantic_version_format, version)
515
+
516
+ version_model = {"version": doc_version_str}
517
+
518
+ if self._include_version_openapi_route and self._app.openapi_url is not None:
519
+ version_model["openapi_url"] = f"{root_path}{version_prefix}{self._app.openapi_url}"
520
+ if self._include_version_docs and self._docs_url is not None:
521
+ version_model["swagger_url"] = f"{root_path}{version_prefix}{self._docs_url}"
522
+ if self._include_version_docs and self._redoc_url is not None:
523
+ version_model["redoc_url"] = f"{root_path}{version_prefix}{self._redoc_url}"
524
+
525
+ version_models.append(version_model)
526
+
527
+ return {"versions": version_models}
528
+
529
+ def _add_route_to_router(self, route: Any, router: APIRouter, version: VersionT) -> None:
530
+ # Read attributes from the original route, not the RouteContext proxy. The proxy
531
+ # (FastAPI >= 0.137.2) only merges path/tags/deps; other fields such as
532
+ # response_model, status_code, and operation_id would be silently lost.
533
+ source_route = _unwrap_route(route)
534
+ add_method: Callable[..., Any]
535
+
536
+ if isinstance(source_route, APIRoute):
537
+ add_method = router.add_api_route
538
+ elif isinstance(source_route, APIWebSocketRoute):
539
+ add_method = router.add_api_websocket_route
540
+ else:
541
+ raise TypeError(f"Unsupported route type: {type(source_route).__name__}")
542
+
543
+ valid_params = inspect.signature(add_method).parameters.keys()
544
+ filtered_kwargs = {k: getattr(source_route, k) for k in valid_params if hasattr(source_route, k)}
545
+ filtered_kwargs.setdefault("endpoint", source_route.endpoint)
546
+ # Override path/tags/deps with the merged values from RouteContext when present.
547
+ for merged_attr in ("path", "tags", "dependencies"):
548
+ if hasattr(route, merged_attr) and merged_attr in valid_params:
549
+ filtered_kwargs[merged_attr] = getattr(route, merged_attr)
550
+
551
+ deprecated_in_version = self._extract_version_attribute(route.endpoint, _ATTR_DEPRECATE_IN, route.path)
552
+ if deprecated_in_version is not None:
553
+ if isinstance(version, tuple) and isinstance(deprecated_in_version, tuple):
554
+ if version >= deprecated_in_version:
555
+ filtered_kwargs["deprecated"] = True
556
+ elif isinstance(version, str) and isinstance(deprecated_in_version, str):
557
+ if version >= deprecated_in_version:
558
+ filtered_kwargs["deprecated"] = True
559
+
560
+ # An empty string name causes an internal FastAPI error; drop it to use the default.
561
+ if "name" in filtered_kwargs and not filtered_kwargs["name"]:
562
+ filtered_kwargs.pop("name")
563
+
564
+ add_method(**filtered_kwargs)
@@ -0,0 +1,410 @@
1
+ Metadata-Version: 2.4
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.
5
+ Project-URL: Homepage, https://github.com/mat81black/fastapi-router-versioning
6
+ Project-URL: Repository, https://github.com/mat81black/fastapi-router-versioning
7
+ Project-URL: Issues, https://github.com/mat81black/fastapi-router-versioning/issues
8
+ Author-email: Matteo Nieddu <mat81black@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Environment :: Web Environment
13
+ Classifier: Framework :: FastAPI
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Internet :: WWW/HTTP
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.10
26
+ Requires-Dist: fastapi!=0.137.0,!=0.137.1,>=0.120.0
27
+ Description-Content-Type: text/markdown
28
+
29
+ # FastAPI Router Versioning (Native API Versioning)
30
+
31
+ [![PyPI](https://img.shields.io/pypi/v/fastapi-router-versioning)](https://pypi.org/project/fastapi-router-versioning/)
32
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
33
+ [![Python](https://img.shields.io/pypi/pyversions/fastapi-router-versioning)](https://pypi.org/project/fastapi-router-versioning/)
34
+
35
+ The native, router-based solution for **API versioning in FastAPI**.
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.
38
+
39
+ ---
40
+
41
+ ## Features
42
+
43
+ - **SemVer and CalVer** — version routes with `(major, minor)` tuples or arbitrary strings
44
+ - **Per-version docs** — isolated Swagger UI, ReDoc, and `openapi.json` for every version
45
+ - **Declarative lifecycle** — introduce, deprecate, and remove routes with a single decorator
46
+ - **Latest alias** — serve the newest version under a stable `/latest` prefix
47
+ - **Self-hosted docs** — point Swagger UI and ReDoc at your own assets for air-gapped environments
48
+ - **Reverse proxy aware** — doc URLs include the ASGI `root_path` at request time, so sub-app mounting works out of the box
49
+ - **Broad compatibility** — works with nested routers, WebSockets, `Depends`, and OpenAPI Callbacks
50
+
51
+ ---
52
+
53
+ ## Requirements
54
+
55
+ - Python ≥ 3.10
56
+ - FastAPI ≥ 0.120.0
57
+
58
+ ---
59
+
60
+ ## Installation
61
+
62
+ ```bash
63
+ pip install fastapi-router-versioning
64
+ # or
65
+ uv add fastapi-router-versioning
66
+ ```
67
+
68
+ ---
69
+
70
+ ## Quick start — SemVer
71
+
72
+ ```python
73
+ from fastapi import APIRouter, FastAPI
74
+ from fastapi_router_versioning import RouterVersioner, VersionFormat, api_version
75
+
76
+ app = FastAPI()
77
+ router = APIRouter()
78
+
79
+
80
+ @router.get("/items")
81
+ @api_version((1, 0))
82
+ def get_items_v1():
83
+ return {"version": "1.0", "items": ["a", "b"]}
84
+
85
+
86
+ @router.get("/items")
87
+ @api_version((2, 0))
88
+ def get_items_v2():
89
+ return {"version": "2.0", "items": ["a", "b", "c"]}
90
+
91
+
92
+ RouterVersioner(app=app, routers=router, version_format=VersionFormat.SEMVER).versionize()
93
+ # Mounts: GET /v1_0/items GET /v2_0/items
94
+ ```
95
+
96
+ Each version also gets its own Swagger UI at `/v1_0/docs`, `/v2_0/docs`, and so on.
97
+
98
+ ---
99
+
100
+ ## Quick start — CalVer
101
+
102
+ ```python
103
+ from fastapi import APIRouter, FastAPI
104
+ from fastapi_router_versioning import RouterVersioner, VersionFormat, api_version
105
+
106
+ app = FastAPI()
107
+ router = APIRouter()
108
+
109
+
110
+ @router.get("/items")
111
+ @api_version("2025-01-01")
112
+ def get_items():
113
+ return {"release": "2025-01-01"}
114
+
115
+
116
+ RouterVersioner(app=app, routers=router, version_format=VersionFormat.CALVER).versionize()
117
+ # Mounts: GET /2025-01-01/items
118
+ ```
119
+
120
+ Any string is a valid CalVer token: `"2025-01-01"`, `"v3"`, `"stable"`, etc.
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.
123
+
124
+ ---
125
+
126
+ ## Route lifecycle
127
+
128
+ Use `deprecate_in` and `remove_in` to manage the full lifecycle of a route across versions.
129
+
130
+ ```python
131
+ @router.get("/legacy")
132
+ @api_version((1, 0), deprecate_in=(2, 0), remove_in=(3, 0))
133
+ def legacy_route():
134
+ return {"msg": "I am stable in v1, deprecated in v2, gone in v3."}
135
+ ```
136
+
137
+ | Version | `/legacy` present? | Marked deprecated? |
138
+ |---------|-------------------|--------------------|
139
+ | v1.0 | yes | no |
140
+ | v2.0 | yes | **yes** |
141
+ | v3.0 | **no** | — |
142
+
143
+ Routes without `@api_version` fall back to `default_version` (default: `(1, 0)` for SemVer, `"1"` for CalVer).
144
+
145
+ ---
146
+
147
+ ## `RouterVersioner` reference
148
+
149
+ | Parameter | Type | Default | Description |
150
+ |---|---|---|---|
151
+ | `app` | `FastAPI` | required | The FastAPI application instance |
152
+ | `routers` | `APIRouter \| list[APIRouter]` | required | Router(s) whose routes will be versioned |
153
+ | `version_format` | `VersionFormat` | `SEMVER` | Versioning strategy (`SEMVER` or `CALVER`) |
154
+ | `prefix_format` | `str \| None` | `/v{major}_{minor}` / `/{version}` | URL prefix template; supports `{major}`, `{minor}`, `{version}` |
155
+ | `semantic_version_format` | `str \| None` | `{major}.{minor}` / `{version}` | Version label used in Swagger/ReDoc titles |
156
+ | `default_version` | `VersionT \| None` | `(1, 0)` / `"1"` | Fallback version for routes without `@api_version` |
157
+ | `latest_prefix` | `str \| None` | `None` | If set, adds an alias prefix (e.g. `"/latest"`) pointing to the newest version |
158
+ | `include_version_docs` | `bool` | `True` | Create per-version Swagger UI and ReDoc pages |
159
+ | `include_version_openapi_route` | `bool` | `True` | Create a per-version `openapi.json` route |
160
+ | `include_versions_route` | `bool` | `False` | Add a `GET /versions` endpoint listing all active versions |
161
+ | `sort_routes` | `bool` | `False` | Sort routes alphabetically by path within each version |
162
+ | `callback` | `Callable[[APIRouter, VersionT, str], None] \| None` | `None` | Hook called once per versioned router, before it is included in the app |
163
+ | `webhook_routers` | `APIRouter \| list[APIRouter] \| None` | `None` | Router(s) containing webhook definitions annotated with `@api_version`; each version's schema shows only the webhooks active in that version |
164
+ | `openapi_hook` | `Callable[[dict, VersionT], dict] \| None` | `None` | Hook applied to the generated OpenAPI schema for each version; receives `(schema, version)` and must return the modified schema |
165
+ | `swagger_js_url` | `str \| None` | FastAPI CDN | Custom URL for the Swagger UI JS bundle |
166
+ | `swagger_css_url` | `str \| None` | FastAPI CDN | Custom URL for the Swagger UI CSS |
167
+ | `swagger_favicon_url` | `str \| None` | FastAPI favicon | Custom URL for the Swagger UI favicon |
168
+ | `redoc_js_url` | `str \| None` | FastAPI CDN | Custom URL for the ReDoc JS bundle |
169
+ | `redoc_favicon_url` | `str \| None` | FastAPI favicon | Custom URL for the ReDoc favicon |
170
+ | `redoc_with_google_fonts` | `bool` | `True` | If `False`, ReDoc will not load Google Fonts |
171
+
172
+ Call `.versionize()` after constructing the object. It returns the list of active versions.
173
+
174
+ ---
175
+
176
+ ## `@api_version` reference
177
+
178
+ ```python
179
+ @api_version(version, *, deprecate_in=None, remove_in=None)
180
+ ```
181
+
182
+ | Parameter | Type | Required | Description |
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 |
186
+ | `remove_in` | same \| `None` | no | Version from which this route is removed entirely |
187
+
188
+ All three parameters must match the `version_format` configured on `RouterVersioner`
189
+ (`tuple[int, int]` for SemVer, `str` for CalVer).
190
+
191
+ ---
192
+
193
+ ## Advanced options
194
+
195
+ ### Latest alias
196
+
197
+ Serve the newest version under a stable prefix that clients can pin to:
198
+
199
+ ```python
200
+ RouterVersioner(
201
+ app=app,
202
+ routers=router,
203
+ version_format=VersionFormat.SEMVER,
204
+ latest_prefix="/latest",
205
+ ).versionize()
206
+ # Also mounts /latest/... pointing to the highest version
207
+ ```
208
+
209
+ ### Version discovery endpoint
210
+
211
+ ```python
212
+ RouterVersioner(
213
+ app=app,
214
+ routers=router,
215
+ version_format=VersionFormat.SEMVER,
216
+ include_versions_route=True,
217
+ ).versionize()
218
+ ```
219
+
220
+ ```json
221
+ GET /versions
222
+ {
223
+ "versions": [
224
+ {
225
+ "version": "1.0",
226
+ "openapi_url": "/v1_0/openapi.json",
227
+ "swagger_url": "/v1_0/docs",
228
+ "redoc_url": "/v1_0/redoc"
229
+ }
230
+ ]
231
+ }
232
+ ```
233
+
234
+ ### Custom URL format
235
+
236
+ Use `prefix_format` and `semantic_version_format` to control how versions appear in URLs and docs.
237
+
238
+ **Major-only versioning** (`/v1`, `/v2`, `/v3`):
239
+
240
+ ```python
241
+ RouterVersioner(
242
+ app=app,
243
+ routers=router,
244
+ version_format=VersionFormat.SEMVER,
245
+ prefix_format="/v{major}",
246
+ semantic_version_format="{major}",
247
+ latest_prefix="/latest",
248
+ ).versionize()
249
+ # Mounts: GET /v1/items GET /v2/items GET /latest/items
250
+ # Swagger at /v1/docs, /v2/docs — titles show "v1", "v2"
251
+ ```
252
+
253
+ The route decorator still uses `(major, minor)` tuples — only the URL and doc label change.
254
+
255
+ ### OpenAPI schema hook
256
+
257
+ `openapi_hook` lets you modify the generated OpenAPI JSON for each version — useful for
258
+ custom extensions, logos, version-specific metadata, or AWS API Gateway integration.
259
+ Unlike overriding `app.openapi`, this hook is called inside the per-version generation
260
+ pipeline, so it receives the correct filtered schema.
261
+
262
+ ```python
263
+ def my_openapi_hook(schema: dict, version: tuple[int, int]) -> dict:
264
+ # Applied to every version
265
+ schema["info"]["x-logo"] = {"url": "https://example.com/logo.png"}
266
+
267
+ # Applied only to v1
268
+ if version == (1, 0):
269
+ schema["info"]["description"] += "\n\n**DEPRECATED:** Use v2."
270
+
271
+ return schema
272
+
273
+ RouterVersioner(
274
+ app=app,
275
+ routers=router,
276
+ version_format=VersionFormat.SEMVER,
277
+ openapi_hook=my_openapi_hook,
278
+ ).versionize()
279
+ ```
280
+
281
+ The hook receives `(schema: dict, version: VersionT)` and must return the (modified) dict.
282
+
283
+ ### OpenAPI Callbacks and Webhooks
284
+
285
+ **Callbacks** (per-route) are propagated automatically — any `callbacks=[...]` parameter
286
+ on a route is copied to every versioned copy of that route:
287
+
288
+ ```python
289
+ callback_router = APIRouter()
290
+
291
+ @callback_router.post("{$url}")
292
+ def on_event(body: dict) -> None: ...
293
+
294
+ @router.post("/items", callbacks=callback_router.routes)
295
+ @api_version((1, 0))
296
+ def create_item() -> dict: ...
297
+ ```
298
+
299
+ **Webhooks** (`app.webhooks`) appear in the OpenAPI schema of every version by default.
300
+ To version webhooks independently, use `webhook_routers` with the same `@api_version`
301
+ decorator used on regular routes:
302
+
303
+ ```python
304
+ webhook_router = APIRouter()
305
+
306
+ @webhook_router.post("/order-created")
307
+ @api_version((1, 0))
308
+ def webhook_order_v1(body: OrderV1) -> None: ...
309
+
310
+ @webhook_router.post("/order-created")
311
+ @api_version((2, 0)) # replaces v1 definition (same path + method)
312
+ def webhook_order_v2(body: OrderV2) -> None: ...
313
+
314
+ @webhook_router.post("/payment-failed")
315
+ @api_version((1, 0), remove_in=(2, 0))
316
+ def webhook_payment_v1(body: dict) -> None: ...
317
+
318
+ RouterVersioner(
319
+ app=app,
320
+ routers=router,
321
+ webhook_routers=webhook_router,
322
+ version_format=VersionFormat.SEMVER,
323
+ ).versionize()
324
+ # /v1_0/openapi.json → webhooks: /order-created (V1), /payment-failed
325
+ # /v2_0/openapi.json → webhooks: /order-created (V2) ← /payment-failed removed
326
+ ```
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.
330
+
331
+ ### Multiple routers
332
+
333
+ Pass a list of routers to version routes that are split across modules:
334
+
335
+ ```python
336
+ RouterVersioner(
337
+ app=app,
338
+ routers=[users_router, products_router],
339
+ version_format=VersionFormat.SEMVER,
340
+ ).versionize()
341
+ ```
342
+
343
+ All routers are versioned together under the same prefix tree.
344
+
345
+ ### Self-hosted docs (air-gapped environments)
346
+
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:
348
+
349
+ ```python
350
+ RouterVersioner(
351
+ app=app,
352
+ routers=router,
353
+ version_format=VersionFormat.SEMVER,
354
+ swagger_js_url="/static/swagger-ui-bundle.js",
355
+ swagger_css_url="/static/swagger-ui.css",
356
+ swagger_favicon_url="/static/favicon.png",
357
+ redoc_js_url="/static/redoc.standalone.js",
358
+ redoc_favicon_url="/static/favicon.png",
359
+ redoc_with_google_fonts=False,
360
+ ).versionize()
361
+ ```
362
+
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.
364
+
365
+ ### Reverse proxy / sub-app mounting
366
+
367
+ When the app runs behind a reverse proxy or is mounted as a sub-application, the ASGI `root_path` is included in all per-version doc URLs automatically — no extra configuration needed:
368
+
369
+ ```python
370
+ parent = FastAPI()
371
+ parent.mount("/api", app) # root_path="/api" is injected at request time
372
+ # /api/v1_0/docs correctly references /api/v1_0/openapi.json
373
+ ```
374
+
375
+ ### Callback hook
376
+
377
+ Run custom logic each time a versioned router is created — useful for logging or metrics:
378
+
379
+ ```python
380
+ def on_version_created(router: APIRouter, version, prefix: str) -> None:
381
+ print(f"Registered version {version} at {prefix}")
382
+
383
+ RouterVersioner(
384
+ app=app,
385
+ routers=router,
386
+ version_format=VersionFormat.SEMVER,
387
+ callback=on_version_created,
388
+ ).versionize()
389
+ ```
390
+
391
+ ---
392
+
393
+ ## Examples
394
+
395
+ Runnable examples are available in the [`examples/`](examples/) directory:
396
+
397
+ | File | What it shows |
398
+ |---|---|
399
+ | [`semver_app.py`](examples/semver_app.py) | Full SemVer lifecycle (introduce, deprecate, remove) |
400
+ | [`calver_app.py`](examples/calver_app.py) | Same lifecycle with CalVer date strings |
401
+ | [`semver_major_only_app.py`](examples/semver_major_only_app.py) | Custom prefix `/v1`, `/v2` via `prefix_format` |
402
+ | [`webhook_versioning_app.py`](examples/webhook_versioning_app.py) | Per-version webhook definitions via `webhook_routers` |
403
+ | [`multi_router_app.py`](examples/multi_router_app.py) | Multiple routers versioned together |
404
+ | [`self_hosted_docs_app.py`](examples/self_hosted_docs_app.py) | Swagger UI and ReDoc from local static assets |
405
+
406
+ ---
407
+
408
+ ## License
409
+
410
+ [MIT](LICENSE)
@@ -0,0 +1,7 @@
1
+ fastapi_router_versioning/__init__.py,sha256=HEfhSE-ggX7QJX8vOQiUGNRhnKg2VcBUY5eds9ReD3c,175
2
+ fastapi_router_versioning/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ fastapi_router_versioning/versioner.py,sha256=9xBQCB_snF9pDXaCAh95IDKQ2Js_JAFVzU24zjZ7RO4,26437
4
+ fastapi_router_versioning-0.1.0.dist-info/METADATA,sha256=gBtOBWdNZrVaR_oDlg0w0-wMeRVoBynBnGRKD_Z7Rvo,14710
5
+ fastapi_router_versioning-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
6
+ fastapi_router_versioning-0.1.0.dist-info/licenses/LICENSE,sha256=Tsif_IFIW5f-xYSy1KlhAy7v_oNEU4lP2cEnSQbMdE4,1086
7
+ fastapi_router_versioning-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2018 Sebastián Ramírez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.