cadwyn 3.12.0__py3-none-any.whl → 3.15.10__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.

Potentially problematic release.


This version of cadwyn might be problematic. Click here for more details.

@@ -12,10 +12,10 @@ from enum import Enum
12
12
  from pathlib import Path
13
13
  from types import GenericAlias, MappingProxyType, ModuleType
14
14
  from typing import (
15
+ TYPE_CHECKING,
15
16
  Annotated,
16
17
  Any,
17
18
  Generic,
18
- TypeAlias,
19
19
  TypeVar,
20
20
  _BaseGenericAlias, # pyright: ignore[reportAttributeAccessIssue]
21
21
  cast,
@@ -29,29 +29,25 @@ import fastapi.params
29
29
  import fastapi.routing
30
30
  import fastapi.security.base
31
31
  import fastapi.utils
32
+ from fastapi import APIRouter
32
33
  from fastapi._compat import ModelField as FastAPIModelField
33
34
  from fastapi._compat import create_body_model
34
- from fastapi.dependencies.models import Dependant
35
- from fastapi.dependencies.utils import (
36
- get_body_field,
37
- get_dependant,
38
- get_parameterless_sub_dependant,
39
- )
40
35
  from fastapi.params import Depends
41
36
  from fastapi.routing import APIRoute
37
+ from issubclass import issubclass as lenient_issubclass
42
38
  from pydantic import BaseModel
43
- from starlette.routing import (
44
- BaseRoute,
45
- request_response,
46
- )
39
+ from starlette.routing import BaseRoute
47
40
  from typing_extensions import Self, assert_never, deprecated
48
41
 
49
- from cadwyn._compat import model_fields, rebuild_fastapi_body_param
42
+ from cadwyn._compat import get_annotation_from_model_field, model_fields, rebuild_fastapi_body_param
50
43
  from cadwyn._package_utils import get_version_dir_path
51
44
  from cadwyn._utils import Sentinel, UnionType, get_another_version_of_cls
52
45
  from cadwyn.exceptions import (
53
46
  CadwynError,
54
47
  RouteAlreadyExistsError,
48
+ RouteByPathConverterDoesNotApplyToAnythingError,
49
+ RouteRequestBySchemaConverterDoesNotApplyToAnythingError,
50
+ RouteResponseBySchemaConverterDoesNotApplyToAnythingError,
55
51
  RouterGenerationError,
56
52
  RouterPathParamsModifiedError,
57
53
  )
@@ -64,12 +60,13 @@ from cadwyn.structure.endpoints import (
64
60
  )
65
61
  from cadwyn.structure.versions import _CADWYN_REQUEST_PARAM_NAME, _CADWYN_RESPONSE_PARAM_NAME, VersionChange
66
62
 
67
- _T = TypeVar("_T", bound=Callable[..., Any])
63
+ if TYPE_CHECKING:
64
+ from fastapi.dependencies.models import Dependant
65
+
66
+ _Call = TypeVar("_Call", bound=Callable[..., Any])
68
67
  _R = TypeVar("_R", bound=fastapi.routing.APIRouter)
69
68
  # This is a hack we do because we can't guarantee how the user will use the router.
70
69
  _DELETED_ROUTE_TAG = "_CADWYN_DELETED_ROUTE"
71
- _EndpointPath: TypeAlias = str
72
- _EndpointMethod: TypeAlias = str
73
70
 
74
71
 
75
72
  @dataclass(slots=True, frozen=True, eq=True)
@@ -78,13 +75,6 @@ class _EndpointInfo:
78
75
  endpoint_methods: frozenset[str]
79
76
 
80
77
 
81
- @dataclass(slots=True)
82
- class _RouterInfo(Generic[_R]):
83
- router: _R
84
- routes_with_migrated_requests: dict[_EndpointPath, set[_EndpointMethod]]
85
- route_bodies_with_migrated_requests: set[type[BaseModel]]
86
-
87
-
88
78
  @deprecated("It will soon be deleted. Use HeadVersion version changes instead.")
89
79
  class InternalRepresentationOf:
90
80
  def __class_getitem__(cls, original_schema: type, /) -> type[Self]:
@@ -95,8 +85,7 @@ class InternalRepresentationOf:
95
85
  def generate_versioned_routers(
96
86
  router: _R,
97
87
  versions: VersionBundle,
98
- ) -> dict[VersionDate, _R]:
99
- ...
88
+ ) -> dict[VersionDate, _R]: ...
100
89
 
101
90
 
102
91
  @overload
@@ -105,8 +94,7 @@ def generate_versioned_routers(
105
94
  router: _R,
106
95
  versions: VersionBundle,
107
96
  latest_schemas_package: ModuleType | None,
108
- ) -> dict[VersionDate, _R]:
109
- ...
97
+ ) -> dict[VersionDate, _R]: ...
110
98
 
111
99
 
112
100
  def generate_versioned_routers(
@@ -129,7 +117,7 @@ def generate_versioned_routers(
129
117
 
130
118
 
131
119
  class VersionedAPIRouter(fastapi.routing.APIRouter):
132
- def only_exists_in_older_versions(self, endpoint: _T) -> _T:
120
+ def only_exists_in_older_versions(self, endpoint: _Call) -> _Call:
133
121
  route = _get_route_from_func(self.routes, endpoint)
134
122
  if route is None:
135
123
  raise LookupError(
@@ -163,22 +151,15 @@ class _EndpointTransformer(Generic[_R]):
163
151
  self.parent_router
164
152
  )
165
153
  router = deepcopy(self.parent_router)
166
- router_infos: dict[VersionDate, _RouterInfo] = {}
167
- routes_with_migrated_requests = {}
168
- route_bodies_with_migrated_requests: set[type[BaseModel]] = set()
154
+ routers: dict[VersionDate, _R] = {}
155
+
169
156
  for version in self.versions:
170
157
  self.annotation_transformer.migrate_router_to_version(router, version)
171
158
 
172
- router_infos[version.value] = _RouterInfo(
173
- router,
174
- routes_with_migrated_requests,
175
- route_bodies_with_migrated_requests,
176
- )
159
+ self._validate_all_data_converters_are_applied(router, version)
160
+
161
+ routers[version.value] = router
177
162
  # Applying changes for the next version
178
- routes_with_migrated_requests = _get_migrated_routes_by_path(version)
179
- route_bodies_with_migrated_requests = {
180
- schema for change in version.version_changes for schema in change.alter_request_by_schema_instructions
181
- }
182
163
  router = deepcopy(router)
183
164
  self._apply_endpoint_changes_to_router(router, version)
184
165
 
@@ -196,21 +177,21 @@ class _EndpointTransformer(Generic[_R]):
196
177
  continue
197
178
  _add_request_and_response_params(head_route)
198
179
  copy_of_dependant = deepcopy(head_route.dependant)
199
- # Remember this: if len(body_params) == 1, then route.body_schema == route.dependant.body_params[0]
200
- if len(copy_of_dependant.body_params) == 1:
180
+
181
+ if _route_has_a_simple_body_schema(head_route):
201
182
  self._replace_internal_representation_with_the_versioned_schema(
202
183
  copy_of_dependant,
203
184
  schema_to_internal_request_body_representation,
204
185
  )
205
186
 
206
- for older_router_info in list(router_infos.values()):
207
- older_route = older_router_info.router.routes[route_index]
187
+ for older_router in list(routers.values()):
188
+ older_route = older_router.routes[route_index]
208
189
 
209
190
  # We know they are APIRoutes because of the check at the very beginning of the top loop.
210
191
  # I.e. Because head_route is an APIRoute, both routes are APIRoutes too
211
192
  older_route = cast(APIRoute, older_route)
212
193
  # Wait.. Why do we need this code again?
213
- if older_route.body_field is not None and len(older_route.dependant.body_params) == 1:
194
+ if older_route.body_field is not None and _route_has_a_simple_body_schema(older_route):
214
195
  template_older_body_model = self.annotation_transformer._change_version_of_annotations(
215
196
  older_route.body_field.type_,
216
197
  self.annotation_transformer.head_version_dir,
@@ -226,17 +207,103 @@ class _EndpointTransformer(Generic[_R]):
226
207
  copy_of_dependant,
227
208
  self.versions,
228
209
  )
229
- for _, router_info in router_infos.items():
230
- router_info.router.routes = [
210
+ for _, router in routers.items():
211
+ router.routes = [
231
212
  route
232
- for route in router_info.router.routes
213
+ for route in router.routes
233
214
  if not (isinstance(route, fastapi.routing.APIRoute) and _DELETED_ROUTE_TAG in route.tags)
234
215
  ]
235
- return {version: router_info.router for version, router_info in router_infos.items()}
216
+ return routers
217
+
218
+ def _validate_all_data_converters_are_applied(self, router: APIRouter, version: Version):
219
+ path_to_route_methods_mapping, head_response_models, head_request_bodies = self._extract_all_routes_identifiers(
220
+ router
221
+ )
222
+
223
+ for version_change in version.version_changes:
224
+ for by_path_converters in [
225
+ *version_change.alter_response_by_path_instructions.values(),
226
+ *version_change.alter_request_by_path_instructions.values(),
227
+ ]:
228
+ for by_path_converter in by_path_converters:
229
+ missing_methods = by_path_converter.methods.difference(
230
+ path_to_route_methods_mapping[by_path_converter.path]
231
+ )
232
+
233
+ if missing_methods:
234
+ raise RouteByPathConverterDoesNotApplyToAnythingError(
235
+ f"{by_path_converter.repr_name} "
236
+ f'"{version_change.__name__}.{by_path_converter.transformer.__name__}" '
237
+ f"failed to find routes with the following methods: {list(missing_methods)}. "
238
+ f"This means that you are trying to apply this converter to non-existing endpoint(s). "
239
+ "Please, check whether the path and methods are correct. (hint: path must include "
240
+ "all path variables and have a name that was used in the version that this "
241
+ "VersionChange resides in)"
242
+ )
243
+
244
+ for by_schema_converters in version_change.alter_request_by_schema_instructions.values():
245
+ for by_schema_converter in by_schema_converters:
246
+ missing_models = set(by_schema_converter.schemas) - head_request_bodies
247
+ if missing_models:
248
+ raise RouteRequestBySchemaConverterDoesNotApplyToAnythingError(
249
+ f"Request by body schema converter "
250
+ f'"{version_change.__name__}.{by_schema_converter.transformer.__name__}" '
251
+ f"failed to find routes with the following body schemas: "
252
+ f"{[m.__name__ for m in missing_models]}. "
253
+ f"This means that you are trying to apply this converter to non-existing endpoint(s). "
254
+ )
255
+ for by_schema_converters in version_change.alter_response_by_schema_instructions.values():
256
+ for by_schema_converter in by_schema_converters:
257
+ missing_models = set(by_schema_converter.schemas) - head_response_models
258
+ if missing_models:
259
+ raise RouteResponseBySchemaConverterDoesNotApplyToAnythingError(
260
+ f"Response by response model converter "
261
+ f'"{version_change.__name__}.{by_schema_converter.transformer.__name__}" '
262
+ f"failed to find routes with the following response models: "
263
+ f"{[m.__name__ for m in missing_models]}. "
264
+ f"This means that you are trying to apply this converter to non-existing endpoint(s). "
265
+ )
266
+
267
+ def _extract_all_routes_identifiers(
268
+ self, router: APIRouter
269
+ ) -> tuple[defaultdict[str, set[str]], set[Any], set[Any]]:
270
+ response_models: set[Any] = set()
271
+ request_bodies: set[Any] = set()
272
+ path_to_route_methods_mapping: dict[str, set[str]] = defaultdict(set)
273
+
274
+ for route in router.routes:
275
+ if isinstance(route, APIRoute):
276
+ if route.response_model is not None and lenient_issubclass(route.response_model, BaseModel):
277
+ # FIXME: This is going to fail on Pydantic 1
278
+ response_models.add(route.response_model)
279
+ # Not sure if it can ever be None when it's a simple schema. Eh, I would rather be safe than sorry
280
+ if _route_has_a_simple_body_schema(route) and route.body_field is not None:
281
+ annotation = get_annotation_from_model_field(route.body_field)
282
+ if lenient_issubclass(annotation, BaseModel):
283
+ # FIXME: This is going to fail on Pydantic 1
284
+ request_bodies.add(annotation)
285
+ path_to_route_methods_mapping[route.path] |= route.methods
286
+
287
+ head_response_models = {
288
+ self.annotation_transformer._change_version_of_annotations(
289
+ model,
290
+ self.versions.versioned_directories_with_head[0],
291
+ )
292
+ for model in response_models
293
+ }
294
+ head_request_bodies = {
295
+ self.annotation_transformer._change_version_of_annotations(
296
+ body,
297
+ self.versions.versioned_directories_with_head[0],
298
+ )
299
+ for body in request_bodies
300
+ }
301
+
302
+ return path_to_route_methods_mapping, head_response_models, head_request_bodies
236
303
 
237
304
  def _replace_internal_representation_with_the_versioned_schema(
238
305
  self,
239
- copy_of_dependant: Dependant,
306
+ copy_of_dependant: "Dependant",
240
307
  schema_to_internal_request_body_representation: dict[type[BaseModel], type[BaseModel]],
241
308
  ):
242
309
  body_param: FastAPIModelField = copy_of_dependant.body_params[0]
@@ -378,9 +445,12 @@ def _extract_internal_request_schemas_from_router(
378
445
 
379
446
  def _extract_internal_request_schemas_from_annotations(annotations: dict[str, Any]):
380
447
  for key, annotation in annotations.items():
381
- if isinstance(annotation, type(Annotated[int, int])):
448
+ if isinstance(annotation, type(Annotated[int, int])): # pyright: ignore[reportArgumentType]
382
449
  args = get_args(annotation)
383
- if isinstance(args[1], type) and issubclass(args[1], InternalRepresentationOf): # pragma: no branch
450
+ if isinstance(args[1], type) and issubclass( # pragma: no branch
451
+ args[1],
452
+ InternalRepresentationOf, # pyright: ignore[reportDeprecated]
453
+ ):
384
454
  internal_schema = args[0]
385
455
  original_schema = args[1].mro()[2]
386
456
  schema_to_internal_request_body_representation[original_schema] = internal_schema
@@ -392,9 +462,10 @@ def _extract_internal_request_schemas_from_router(
392
462
 
393
463
  for route in router.routes:
394
464
  if isinstance(route, APIRoute): # pragma: no branch
395
- route.endpoint = _modify_callable(
465
+ route.endpoint = _modify_callable_annotations(
396
466
  route.endpoint,
397
467
  modify_annotations=_extract_internal_request_schemas_from_annotations,
468
+ annotation_modifying_wrapper_factory=_copy_endpoint,
398
469
  )
399
470
  _remake_endpoint_dependencies(route)
400
471
  return schema_to_internal_request_body_representation
@@ -424,8 +495,8 @@ class _AnnotationTransformer:
424
495
  self.versions = versions
425
496
  self.versions.head_schemas_package = head_schemas_package
426
497
  self.head_schemas_package = head_schemas_package
427
- self.head_version_dir = min(versions.versioned_directories) # "head" < "v0000_00_00"
428
- self.latest_version_dir = max(versions.versioned_directories) # "v2005_11_11" > "v2000_11_11"
498
+ self.head_version_dir = min(versions.versioned_directories_with_head) # "head" < "v0000_00_00"
499
+ self.latest_version_dir = max(versions.versioned_directories_with_head) # "v2005_11_11" > "v2000_11_11"
429
500
 
430
501
  # This cache is not here for speeding things up. It's for preventing the creation of copies of the same object
431
502
  # because such copies could produce weird behaviors at runtime, especially if you/fastapi do any comparisons.
@@ -454,7 +525,7 @@ class _AnnotationTransformer:
454
525
  ):
455
526
  if route.response_model is not None and not ignore_response_model:
456
527
  route.response_model = self._change_version_of_annotations(route.response_model, version_dir)
457
- route.response_field = fastapi.utils.create_response_field(
528
+ route.response_field = fastapi.utils.create_model_field(
458
529
  name="Response_" + route.unique_id,
459
530
  type_=route.response_model,
460
531
  mode="serialization",
@@ -501,7 +572,12 @@ class _AnnotationTransformer:
501
572
  def modifier(annotation: Any):
502
573
  return self._change_version_of_annotations(annotation, version_dir)
503
574
 
504
- return _modify_callable(annotation, modifier, modifier)
575
+ return _modify_callable_annotations(
576
+ annotation,
577
+ modifier,
578
+ modifier,
579
+ annotation_modifying_wrapper_factory=_copy_function_through_class_based_wrapper,
580
+ )
505
581
  else:
506
582
  return annotation
507
583
 
@@ -539,7 +615,7 @@ class _AnnotationTransformer:
539
615
  )
540
616
  else:
541
617
  self._validate_source_file_is_located_in_template_dir(annotation, source_file)
542
- return get_another_version_of_cls(annotation, version_dir, self.versions.versioned_directories)
618
+ return get_another_version_of_cls(annotation, version_dir, self.versions.versioned_directories_with_head)
543
619
  else:
544
620
  return annotation
545
621
 
@@ -552,7 +628,7 @@ class _AnnotationTransformer:
552
628
  if (
553
629
  source_file.startswith(dir_with_versions)
554
630
  and not source_file.startswith(template_dir)
555
- and any(source_file.startswith(str(d)) for d in self.versions.versioned_directories)
631
+ and any(source_file.startswith(str(d)) for d in self.versions.versioned_directories_with_head)
556
632
  ):
557
633
  raise RouterGenerationError(
558
634
  f'"{annotation}" is not defined in "{self.head_version_dir}" even though it must be. '
@@ -562,12 +638,14 @@ class _AnnotationTransformer:
562
638
  )
563
639
 
564
640
 
565
- def _modify_callable(
566
- call: Callable,
641
+ def _modify_callable_annotations(
642
+ call: _Call,
567
643
  modify_annotations: Callable[[dict[str, Any]], dict[str, Any]] = lambda a: a,
568
644
  modify_defaults: Callable[[tuple[Any, ...]], tuple[Any, ...]] = lambda a: a,
569
- ):
570
- annotation_modifying_wrapper = _copy_function(call)
645
+ *,
646
+ annotation_modifying_wrapper_factory: Callable[[_Call], _Call],
647
+ ) -> _Call:
648
+ annotation_modifying_wrapper = annotation_modifying_wrapper_factory(call)
571
649
  old_params = inspect.signature(call).parameters
572
650
  callable_annotations = annotation_modifying_wrapper.__annotations__
573
651
  annotation_modifying_wrapper.__annotations__ = modify_annotations(callable_annotations)
@@ -583,15 +661,12 @@ def _modify_callable(
583
661
 
584
662
 
585
663
  def _remake_endpoint_dependencies(route: fastapi.routing.APIRoute):
586
- route.dependant = get_dependant(path=route.path_format, call=route.endpoint)
664
+ # Unlike get_dependant, APIRoute is the public API of FastAPI and it's (almost) guaranteed to be stable.
665
+
666
+ route_copy = fastapi.routing.APIRoute(route.path, route.endpoint, dependencies=route.dependencies)
667
+ route.dependant = route_copy.dependant
668
+ route.body_field = route_copy.body_field
587
669
  _add_request_and_response_params(route)
588
- route.body_field = get_body_field(dependant=route.dependant, name=route.unique_id)
589
- for depends in route.dependencies[::-1]:
590
- route.dependant.dependencies.insert(
591
- 0,
592
- get_parameterless_sub_dependant(depends=depends, path=route.path_format),
593
- )
594
- route.app = request_response(route.get_route_handler())
595
670
 
596
671
 
597
672
  def _add_request_and_response_params(route: APIRoute):
@@ -606,7 +681,7 @@ def _add_data_migrations_to_route(
606
681
  head_route: Any,
607
682
  template_body_field: type[BaseModel] | None,
608
683
  template_body_field_name: str | None,
609
- dependant_for_request_migrations: Dependant,
684
+ dependant_for_request_migrations: "Dependant",
610
685
  versions: VersionBundle,
611
686
  ):
612
687
  if not (route.dependant.request_param_name and route.dependant.response_param_name): # pragma: no cover
@@ -727,46 +802,80 @@ def _get_route_from_func(
727
802
  return None
728
803
 
729
804
 
730
- def _get_migrated_routes_by_path(version: Version) -> dict[_EndpointPath, set[_EndpointMethod]]:
731
- request_by_path_migration_instructions = [
732
- version_change.alter_request_by_path_instructions for version_change in version.version_changes
733
- ]
734
- migrated_routes = defaultdict(set)
735
- for instruction_dict in request_by_path_migration_instructions:
736
- for path, instruction_list in instruction_dict.items():
737
- for instruction in instruction_list:
738
- migrated_routes[path] |= instruction.methods
739
- return migrated_routes
805
+ def _copy_endpoint(function: Any) -> Any:
806
+ function = _unwrap_callable(function)
807
+ function_copy: Any = types.FunctionType(
808
+ function.__code__,
809
+ function.__globals__,
810
+ name=function.__name__,
811
+ argdefs=function.__defaults__,
812
+ closure=function.__closure__,
813
+ )
814
+ function_copy = functools.update_wrapper(function_copy, function)
815
+ # Otherwise it will have the same signature as __wrapped__ due to how inspect module works
816
+ del function_copy.__wrapped__
740
817
 
818
+ function_copy._original_callable = function
819
+ function.__kwdefaults__ = function.__kwdefaults__.copy() if function.__kwdefaults__ is not None else {}
741
820
 
742
- def _copy_function(function: _T) -> _T:
743
- while hasattr(function, "__alt_wrapped__"):
744
- function = function.__alt_wrapped__
745
- if not isinstance(function, types.FunctionType | types.MethodType):
746
- # This means that the callable is actually an instance of a regular class
747
- function = function.__call__
748
- if inspect.iscoroutinefunction(function):
821
+ return function_copy
822
+
823
+
824
+ class _CallableWrapper:
825
+ """__eq__ and __hash__ are needed to make sure that dependency overrides work correctly.
826
+ They are based on putting dependencies (functions) as keys for the dictionary so if we want to be able to
827
+ override the wrapper, we need to make sure that it is equivalent to the original in __hash__ and __eq__
828
+ """
829
+
830
+ def __init__(self, original_callable: Callable) -> None:
831
+ super().__init__()
832
+ self._original_callable = original_callable
833
+ functools.update_wrapper(self, original_callable)
834
+
835
+ @property
836
+ def __globals__(self):
837
+ """FastAPI uses __globals__ to resolve forward references in type hints
838
+ It's supposed to be an attribute on the function but we use it as property to prevent python
839
+ from trying to pickle globals when we deepcopy this wrapper
840
+ """
841
+ #
842
+ return self._original_callable.__globals__
843
+
844
+ def __call__(self, *args: Any, **kwargs: Any):
845
+ return self._original_callable(*args, **kwargs)
846
+
847
+ def __hash__(self):
848
+ return hash(self._original_callable)
849
+
850
+ def __eq__(self, value: object) -> bool:
851
+ return self._original_callable == value # pyright: ignore[reportUnnecessaryComparison]
852
+
853
+
854
+ class _AsyncCallableWrapper(_CallableWrapper):
855
+ async def __call__(self, *args: Any, **kwargs: Any):
856
+ return await self._original_callable(*args, **kwargs)
749
857
 
750
- @functools.wraps(function)
751
- async def annotation_modifying_wrapper( # pyright: ignore[reportRedeclaration]
752
- *args: Any,
753
- **kwargs: Any,
754
- ) -> Any:
755
- return await function(*args, **kwargs)
756
858
 
859
+ def _copy_function_through_class_based_wrapper(call: Any):
860
+ """Separate from copy_endpoint because endpoints MUST be functions in FastAPI, they cannot be cls instances"""
861
+ call = _unwrap_callable(call)
862
+
863
+ if inspect.iscoroutinefunction(call):
864
+ return _AsyncCallableWrapper(call)
757
865
  else:
866
+ return _CallableWrapper(call)
758
867
 
759
- @functools.wraps(function)
760
- def annotation_modifying_wrapper(
761
- *args: Any,
762
- **kwargs: Any,
763
- ) -> Any:
764
- return function(*args, **kwargs)
765
868
 
766
- # Otherwise it will have the same signature as __wrapped__ due to how inspect module works
767
- annotation_modifying_wrapper.__alt_wrapped__ = ( # pyright: ignore[reportAttributeAccessIssue]
768
- annotation_modifying_wrapper.__wrapped__
769
- )
770
- del annotation_modifying_wrapper.__wrapped__
869
+ def _unwrap_callable(call: Any) -> Any:
870
+ while hasattr(call, "_original_callable"):
871
+ call = call._original_callable
872
+ if not isinstance(call, types.FunctionType | types.MethodType):
873
+ # This means that the callable is actually an instance of a regular class
874
+ call = call.__call__
875
+
876
+ return call
877
+
771
878
 
772
- return cast(_T, annotation_modifying_wrapper)
879
+ def _route_has_a_simple_body_schema(route: APIRoute) -> bool:
880
+ # Remember this: if len(body_params) == 1, then route.body_schema == route.dependant.body_params[0]
881
+ return len(route.dependant.body_params) == 1
cadwyn/routing.py CHANGED
@@ -12,7 +12,12 @@ from starlette.responses import RedirectResponse
12
12
  from starlette.routing import BaseRoute, Match
13
13
  from starlette.types import Receive, Scope, Send
14
14
 
15
- from .route_generation import InternalRepresentationOf, generate_versioned_routers
15
+ from cadwyn._utils import same_definition_as_in
16
+
17
+ from .route_generation import (
18
+ InternalRepresentationOf, # pyright: ignore[reportDeprecated]
19
+ generate_versioned_routers,
20
+ )
16
21
 
17
22
  # TODO: Remove this in a major version. This is only here for backwards compatibility
18
23
  __all__ = ["InternalRepresentationOf", "generate_versioned_routers"]
@@ -46,6 +51,7 @@ class _RootHeaderAPIRouter(APIRouter):
46
51
  self.versioned_routers: dict[date, APIRouter] = {}
47
52
  self.api_version_header_name = api_version_header_name.lower()
48
53
  self.api_version_var = api_version_var
54
+ self.unversioned_routes: list[BaseRoute] = []
49
55
 
50
56
  @cached_property
51
57
  def sorted_versions(self):
@@ -67,15 +73,20 @@ class _RootHeaderAPIRouter(APIRouter):
67
73
  if self.min_routes_version > request_header_value:
68
74
  # then the request version is older that the oldest route we have
69
75
  _logger.info(
70
- f"Request version {request_version} "
71
- f"is older than the oldest "
72
- f"version {self.min_routes_version.isoformat()} ",
76
+ "Request version is older than the oldest version. No route can match this version",
77
+ extra={
78
+ "oldest_version": self.min_routes_version.isoformat(),
79
+ "request_version": request_version,
80
+ },
73
81
  )
74
82
  return []
75
83
  version_chosen = self.find_closest_date_but_not_new(request_header_value)
76
84
  _logger.info(
77
- f"Partial match. The endpoint with {version_chosen} "
78
- f"version was selected for API call version {request_version}",
85
+ "Partial match. The endpoint with a lower version was selected for the API call",
86
+ extra={
87
+ "version_chosen": version_chosen,
88
+ "request_version": request_version,
89
+ },
79
90
  )
80
91
  return self.versioned_routers[version_chosen].routes
81
92
 
@@ -95,13 +106,33 @@ class _RootHeaderAPIRouter(APIRouter):
95
106
  # if header_value is None, then it's an unversioned request and we need to use the unversioned routes
96
107
  # if there will be a value, we search for the most suitable version
97
108
  if not header_value:
98
- routes = self.routes
109
+ routes = self.unversioned_routes
99
110
  elif header_value in self.versioned_routers:
100
111
  routes = self.versioned_routers[header_value].routes
101
112
  else:
102
113
  routes = self.pick_version(request_header_value=header_value)
103
114
  await self.process_request(scope=scope, receive=receive, send=send, routes=routes)
104
115
 
116
+ @same_definition_as_in(APIRouter.add_api_route)
117
+ def add_api_route(self, *args: Any, **kwargs: Any):
118
+ super().add_api_route(*args, **kwargs)
119
+ self.unversioned_routes.append(self.routes[-1])
120
+
121
+ @same_definition_as_in(APIRouter.add_route)
122
+ def add_route(self, *args: Any, **kwargs: Any):
123
+ super().add_route(*args, **kwargs)
124
+ self.unversioned_routes.append(self.routes[-1])
125
+
126
+ @same_definition_as_in(APIRouter.add_api_websocket_route)
127
+ def add_api_websocket_route(self, *args: Any, **kwargs: Any): # pragma: no cover
128
+ super().add_api_websocket_route(*args, **kwargs)
129
+ self.unversioned_routes.append(self.routes[-1])
130
+
131
+ @same_definition_as_in(APIRouter.add_websocket_route)
132
+ def add_websocket_route(self, *args: Any, **kwargs: Any): # pragma: no cover
133
+ super().add_websocket_route(*args, **kwargs)
134
+ self.unversioned_routes.append(self.routes[-1])
135
+
105
136
  async def process_request(self, scope: Scope, receive: Receive, send: Send, routes: Sequence[BaseRoute]) -> None:
106
137
  """
107
138
  its a copy-paste from starlette.routing.Router