cadwyn 3.11.1__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.
- cadwyn/__init__.py +8 -2
- cadwyn/__main__.py +2 -3
- cadwyn/_asts.py +5 -5
- cadwyn/_compat.py +9 -2
- cadwyn/applications.py +152 -83
- cadwyn/codegen/_common.py +2 -1
- cadwyn/codegen/_main.py +9 -11
- cadwyn/codegen/_plugins/class_rebuilding.py +2 -2
- cadwyn/exceptions.py +12 -0
- cadwyn/middleware.py +4 -4
- cadwyn/route_generation.py +215 -106
- cadwyn/routing.py +49 -26
- cadwyn/structure/data.py +17 -17
- cadwyn/structure/endpoints.py +7 -3
- cadwyn/structure/modules.py +2 -1
- cadwyn/structure/versions.py +64 -43
- {cadwyn-3.11.1.dist-info → cadwyn-3.15.10.dist-info}/METADATA +5 -4
- cadwyn-3.15.10.dist-info/RECORD +38 -0
- {cadwyn-3.11.1.dist-info → cadwyn-3.15.10.dist-info}/WHEEL +1 -1
- cadwyn-3.11.1.dist-info/RECORD +0 -38
- {cadwyn-3.11.1.dist-info → cadwyn-3.15.10.dist-info}/LICENSE +0 -0
- {cadwyn-3.11.1.dist-info → cadwyn-3.15.10.dist-info}/entry_points.txt +0 -0
cadwyn/route_generation.py
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
200
|
-
if
|
|
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
|
|
207
|
-
older_route =
|
|
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
|
|
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 _,
|
|
230
|
-
|
|
210
|
+
for _, router in routers.items():
|
|
211
|
+
router.routes = [
|
|
231
212
|
route
|
|
232
|
-
for route in
|
|
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
|
|
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(
|
|
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 =
|
|
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.
|
|
428
|
-
self.latest_version_dir = max(versions.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
566
|
-
call:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
)
|
|
770
|
-
|
|
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
|
-
|
|
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
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import bisect
|
|
2
|
-
from collections import OrderedDict
|
|
3
2
|
from collections.abc import Sequence
|
|
4
3
|
from contextvars import ContextVar
|
|
5
4
|
from datetime import date
|
|
@@ -13,7 +12,12 @@ from starlette.responses import RedirectResponse
|
|
|
13
12
|
from starlette.routing import BaseRoute, Match
|
|
14
13
|
from starlette.types import Receive, Scope, Send
|
|
15
14
|
|
|
16
|
-
from .
|
|
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
|
+
)
|
|
17
21
|
|
|
18
22
|
# TODO: Remove this in a major version. This is only here for backwards compatibility
|
|
19
23
|
__all__ = ["InternalRepresentationOf", "generate_versioned_routers"]
|
|
@@ -44,48 +48,47 @@ class _RootHeaderAPIRouter(APIRouter):
|
|
|
44
48
|
**kwargs: Any,
|
|
45
49
|
):
|
|
46
50
|
super().__init__(*args, **kwargs)
|
|
47
|
-
self.
|
|
48
|
-
self.unversioned_routes: list[BaseRoute] = []
|
|
51
|
+
self.versioned_routers: dict[date, APIRouter] = {}
|
|
49
52
|
self.api_version_header_name = api_version_header_name.lower()
|
|
50
53
|
self.api_version_var = api_version_var
|
|
54
|
+
self.unversioned_routes: list[BaseRoute] = []
|
|
51
55
|
|
|
52
56
|
@cached_property
|
|
53
|
-
def
|
|
54
|
-
|
|
55
|
-
return OrderedDict(sorted_routes)
|
|
57
|
+
def sorted_versions(self):
|
|
58
|
+
return sorted(self.versioned_routers.keys())
|
|
56
59
|
|
|
57
60
|
@cached_property
|
|
58
61
|
def min_routes_version(self):
|
|
59
|
-
return min(self.
|
|
62
|
+
return min(self.sorted_versions)
|
|
60
63
|
|
|
61
|
-
def find_closest_date_but_not_new(self, request_version: date):
|
|
62
|
-
|
|
63
|
-
index = bisect.bisect_left(routes, request_version)
|
|
64
|
+
def find_closest_date_but_not_new(self, request_version: date) -> date:
|
|
65
|
+
index = bisect.bisect_left(self.sorted_versions, request_version)
|
|
64
66
|
# as bisect_left returns the index where to insert item x in list a, assuming a is sorted
|
|
65
67
|
# we need to get the previous item and that will be a match
|
|
66
|
-
return
|
|
68
|
+
return self.sorted_versions[index - 1]
|
|
67
69
|
|
|
68
|
-
def pick_version(
|
|
69
|
-
self,
|
|
70
|
-
request_header_value: date,
|
|
71
|
-
) -> list[BaseRoute]:
|
|
72
|
-
routes = []
|
|
70
|
+
def pick_version(self, request_header_value: date) -> list[BaseRoute]:
|
|
73
71
|
request_version = request_header_value.isoformat()
|
|
74
72
|
|
|
75
73
|
if self.min_routes_version > request_header_value:
|
|
76
74
|
# then the request version is older that the oldest route we have
|
|
77
75
|
_logger.info(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
+
},
|
|
81
81
|
)
|
|
82
|
-
return
|
|
82
|
+
return []
|
|
83
83
|
version_chosen = self.find_closest_date_but_not_new(request_header_value)
|
|
84
84
|
_logger.info(
|
|
85
|
-
|
|
86
|
-
|
|
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
|
+
},
|
|
87
90
|
)
|
|
88
|
-
return self.
|
|
91
|
+
return self.versioned_routers[version_chosen].routes
|
|
89
92
|
|
|
90
93
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
91
94
|
"""
|
|
@@ -104,12 +107,32 @@ class _RootHeaderAPIRouter(APIRouter):
|
|
|
104
107
|
# if there will be a value, we search for the most suitable version
|
|
105
108
|
if not header_value:
|
|
106
109
|
routes = self.unversioned_routes
|
|
107
|
-
elif header_value in self.
|
|
108
|
-
routes = self.
|
|
110
|
+
elif header_value in self.versioned_routers:
|
|
111
|
+
routes = self.versioned_routers[header_value].routes
|
|
109
112
|
else:
|
|
110
113
|
routes = self.pick_version(request_header_value=header_value)
|
|
111
114
|
await self.process_request(scope=scope, receive=receive, send=send, routes=routes)
|
|
112
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
|
+
|
|
113
136
|
async def process_request(self, scope: Scope, receive: Receive, send: Send, routes: Sequence[BaseRoute]) -> None:
|
|
114
137
|
"""
|
|
115
138
|
its a copy-paste from starlette.routing.Router
|