cadwyn 5.4.6__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,756 @@
1
+ import email.message
2
+ import functools
3
+ import http
4
+ import inspect
5
+ import json
6
+ from collections import defaultdict
7
+ from collections.abc import Callable, Iterator, Sequence
8
+ from contextlib import AsyncExitStack
9
+ from contextvars import ContextVar
10
+ from datetime import date
11
+ from enum import Enum
12
+ from typing import TYPE_CHECKING, ClassVar, Union, cast
13
+
14
+ from fastapi import BackgroundTasks, HTTPException, params
15
+ from fastapi import Request as FastapiRequest
16
+ from fastapi import Response as FastapiResponse
17
+ from fastapi._compat import ModelField, _normalize_errors
18
+ from fastapi.concurrency import run_in_threadpool
19
+ from fastapi.dependencies.models import Dependant
20
+ from fastapi.dependencies.utils import solve_dependencies
21
+ from fastapi.exceptions import RequestValidationError
22
+ from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
23
+ from fastapi.routing import APIRoute, _prepare_response_content
24
+ from pydantic import BaseModel
25
+ from pydantic_core import PydanticUndefined
26
+ from starlette._utils import is_async_callable
27
+ from typing_extensions import Any, ParamSpec, TypeAlias, TypeVar, assert_never, deprecated, get_args
28
+
29
+ from cadwyn._internal.context_vars import CURRENT_DEPENDENCY_SOLVER_VAR
30
+ from cadwyn._utils import classproperty
31
+ from cadwyn.exceptions import (
32
+ CadwynError,
33
+ CadwynHeadRequestValidationError,
34
+ CadwynStructureError,
35
+ )
36
+
37
+ from .._utils import Sentinel
38
+ from .common import Endpoint, VersionedModel, VersionType
39
+ from .data import (
40
+ RequestInfo,
41
+ ResponseInfo,
42
+ _AlterRequestByPathInstruction,
43
+ _AlterRequestBySchemaInstruction,
44
+ _AlterResponseByPathInstruction,
45
+ _AlterResponseBySchemaInstruction,
46
+ _BaseAlterResponseInstruction,
47
+ )
48
+ from .endpoints import AlterEndpointSubInstruction
49
+ from .enums import AlterEnumSubInstruction
50
+ from .schemas import AlterSchemaSubInstruction, SchemaHadInstruction
51
+
52
+ _CADWYN_REQUEST_PARAM_NAME = "cadwyn_request_param"
53
+ _CADWYN_RESPONSE_PARAM_NAME = "cadwyn_response_param"
54
+ _P = ParamSpec("_P")
55
+ _R = TypeVar("_R")
56
+ _RouteId = int
57
+
58
+ PossibleInstructions: TypeAlias = Union[
59
+ AlterSchemaSubInstruction, AlterEndpointSubInstruction, AlterEnumSubInstruction, SchemaHadInstruction, staticmethod
60
+ ]
61
+ APIVersionVarType: TypeAlias = Union[ContextVar[Union[VersionType, None]], ContextVar[VersionType]]
62
+ IdentifierPythonPath = str
63
+
64
+
65
+ if TYPE_CHECKING:
66
+ _AlterSchemaSubInstructionArgs = AlterSchemaSubInstruction
67
+ _AlterEnumSubInstructionArgs = AlterEnumSubInstruction
68
+ _AlterEndpointSubInstructionArgs = AlterEndpointSubInstruction
69
+ else:
70
+ _AlterSchemaSubInstructionArgs = get_args(AlterSchemaSubInstruction)
71
+ _AlterEnumSubInstructionArgs = get_args(AlterEnumSubInstruction)
72
+ _AlterEndpointSubInstructionArgs = get_args(AlterEndpointSubInstruction)
73
+
74
+
75
+ class VersionChange:
76
+ description: ClassVar[str] = Sentinel
77
+ is_hidden_from_changelog: bool = False
78
+ instructions_to_migrate_to_previous_version: ClassVar[Sequence[PossibleInstructions]] = Sentinel
79
+ alter_schema_instructions: ClassVar[list[Union[AlterSchemaSubInstruction, SchemaHadInstruction]]] = Sentinel
80
+ alter_enum_instructions: ClassVar[list[AlterEnumSubInstruction]] = Sentinel
81
+ alter_endpoint_instructions: ClassVar[list[AlterEndpointSubInstruction]] = Sentinel
82
+ alter_request_by_schema_instructions: ClassVar[dict[type[BaseModel], list[_AlterRequestBySchemaInstruction]]] = (
83
+ Sentinel
84
+ )
85
+ alter_request_by_path_instructions: ClassVar[dict[str, list[_AlterRequestByPathInstruction]]] = Sentinel
86
+ alter_response_by_schema_instructions: ClassVar[dict[type, list[_AlterResponseBySchemaInstruction]]] = Sentinel
87
+ alter_response_by_path_instructions: ClassVar[dict[str, list[_AlterResponseByPathInstruction]]] = Sentinel
88
+ _bound_version_bundle: "Union[VersionBundle, None]"
89
+ _route_to_request_migration_mapping: ClassVar[dict[_RouteId, list[_AlterRequestByPathInstruction]]] = Sentinel
90
+ _route_to_response_migration_mapping: ClassVar[dict[_RouteId, list[_AlterResponseByPathInstruction]]] = Sentinel
91
+
92
+ def __init_subclass__(cls, _abstract: bool = False) -> None:
93
+ super().__init_subclass__()
94
+
95
+ if _abstract:
96
+ return
97
+ cls._validate_subclass()
98
+ cls._extract_list_instructions_into_correct_containers()
99
+ cls._extract_body_instructions_into_correct_containers()
100
+ cls._check_no_subclassing()
101
+ cls._bound_version_bundle = None
102
+ cls._route_to_request_migration_mapping = defaultdict(list)
103
+ cls._route_to_response_migration_mapping = defaultdict(list)
104
+
105
+ @classmethod
106
+ def _extract_body_instructions_into_correct_containers(cls):
107
+ for instruction in cls.__dict__.values():
108
+ if isinstance(instruction, _AlterRequestBySchemaInstruction):
109
+ for schema in instruction.schemas:
110
+ cls.alter_request_by_schema_instructions[schema].append(instruction)
111
+ elif isinstance(instruction, _AlterRequestByPathInstruction):
112
+ cls.alter_request_by_path_instructions[instruction.path].append(instruction)
113
+ elif isinstance(instruction, _AlterResponseBySchemaInstruction):
114
+ for schema in instruction.schemas:
115
+ cls.alter_response_by_schema_instructions[schema].append(instruction)
116
+ elif isinstance(instruction, _AlterResponseByPathInstruction):
117
+ cls.alter_response_by_path_instructions[instruction.path].append(instruction)
118
+
119
+ @classmethod
120
+ def _extract_list_instructions_into_correct_containers(cls):
121
+ cls.alter_schema_instructions = []
122
+ cls.alter_enum_instructions = []
123
+ cls.alter_endpoint_instructions = []
124
+ cls.alter_request_by_schema_instructions = defaultdict(list)
125
+ cls.alter_request_by_path_instructions = defaultdict(list)
126
+ cls.alter_response_by_schema_instructions = defaultdict(list)
127
+ cls.alter_response_by_path_instructions = defaultdict(list)
128
+ for alter_instruction in cls.instructions_to_migrate_to_previous_version:
129
+ if isinstance(alter_instruction, SchemaHadInstruction) or isinstance( # noqa: SIM101
130
+ alter_instruction, _AlterSchemaSubInstructionArgs
131
+ ):
132
+ cls.alter_schema_instructions.append(alter_instruction)
133
+ elif isinstance(alter_instruction, _AlterEnumSubInstructionArgs):
134
+ cls.alter_enum_instructions.append(alter_instruction)
135
+ elif isinstance(alter_instruction, _AlterEndpointSubInstructionArgs):
136
+ cls.alter_endpoint_instructions.append(alter_instruction)
137
+ elif isinstance(alter_instruction, staticmethod): # pragma: no cover
138
+ raise NotImplementedError(f'"{alter_instruction}" is an unacceptable version change instruction')
139
+ else:
140
+ assert_never(alter_instruction)
141
+
142
+ @classmethod
143
+ def _validate_subclass(cls):
144
+ if cls.description is Sentinel:
145
+ raise CadwynStructureError(
146
+ f"Version change description is not set on '{cls.__name__}' but is required.",
147
+ )
148
+ if cls.instructions_to_migrate_to_previous_version is Sentinel:
149
+ raise CadwynStructureError(
150
+ f"Attribute 'instructions_to_migrate_to_previous_version' is not set on '{cls.__name__}'"
151
+ " but is required.",
152
+ )
153
+ if not isinstance(cls.instructions_to_migrate_to_previous_version, Sequence):
154
+ raise CadwynStructureError(
155
+ f"Attribute 'instructions_to_migrate_to_previous_version' must be a sequence in '{cls.__name__}'.",
156
+ )
157
+ for instruction in cls.instructions_to_migrate_to_previous_version:
158
+ if not isinstance(instruction, get_args(PossibleInstructions)):
159
+ raise CadwynStructureError(
160
+ f"Instruction '{instruction}' is not allowed. Please, use the correct instruction types",
161
+ )
162
+ for attr_name, attr_value in cls.__dict__.items():
163
+ if not isinstance(
164
+ attr_value,
165
+ (
166
+ _AlterRequestBySchemaInstruction,
167
+ _AlterRequestByPathInstruction,
168
+ _AlterResponseBySchemaInstruction,
169
+ _AlterResponseByPathInstruction,
170
+ ),
171
+ ) and attr_name not in {
172
+ "description",
173
+ "side_effects",
174
+ "instructions_to_migrate_to_previous_version",
175
+ "__module__",
176
+ "__doc__",
177
+ "__firstlineno__",
178
+ "__static_attributes__",
179
+ }:
180
+ raise CadwynStructureError(
181
+ f"Found: '{attr_name}' attribute of type '{type(attr_value)}' in '{cls.__name__}'."
182
+ " Only migration instructions and schema properties are allowed in version change class body.",
183
+ )
184
+
185
+ @classmethod
186
+ def _check_no_subclassing(cls):
187
+ if cls.mro() != [cls, VersionChange, object]:
188
+ raise TypeError(
189
+ f"Can't subclass {cls.__name__} as it was never meant to be subclassed.",
190
+ )
191
+
192
+ def __init__(self) -> None: # pyright: ignore[reportMissingSuperCall]
193
+ raise TypeError(
194
+ f"Can't instantiate {self.__class__.__name__} as it was never meant to be instantiated.",
195
+ )
196
+
197
+
198
+ class VersionChangeWithSideEffects(VersionChange, _abstract=True):
199
+ @classmethod
200
+ def _check_no_subclassing(cls):
201
+ if cls.mro() != [cls, VersionChangeWithSideEffects, VersionChange, object]:
202
+ raise TypeError(
203
+ f"Can't subclass {cls.__name__} as it was never meant to be subclassed.",
204
+ )
205
+
206
+ @classproperty
207
+ def is_applied(cls: type["VersionChangeWithSideEffects"]) -> bool: # pyright: ignore[reportGeneralTypeIssues]
208
+ if (
209
+ cls._bound_version_bundle is None
210
+ or cls not in cls._bound_version_bundle._version_changes_to_version_mapping
211
+ ):
212
+ raise CadwynError(
213
+ f"You tried to check whether '{cls.__name__}' is active but it was never bound to any version.",
214
+ )
215
+ api_version = cls._bound_version_bundle.api_version_var.get()
216
+ if api_version is None:
217
+ return True
218
+ return cls._bound_version_bundle._version_changes_to_version_mapping[cls] <= api_version
219
+
220
+
221
+ class Version:
222
+ def __init__(self, value: Union[str, date], *changes: type[VersionChange]) -> None:
223
+ super().__init__()
224
+
225
+ if isinstance(value, date):
226
+ value = value.isoformat()
227
+ self.value = value
228
+ self.changes = changes
229
+
230
+ @property
231
+ @deprecated("'version_changes' attribute is deprecated and will be removed in Cadwyn 5.x.x. Use 'changes' instead.")
232
+ def version_changes(self): # pragma: no cover
233
+ return self.changes
234
+
235
+ def __repr__(self) -> str:
236
+ return f"Version('{self.value}')"
237
+
238
+
239
+ class HeadVersion:
240
+ def __init__(self, *changes: type[VersionChange]) -> None:
241
+ super().__init__()
242
+ self.changes = changes
243
+
244
+ for version_change in changes:
245
+ if any(
246
+ [
247
+ version_change.alter_request_by_path_instructions,
248
+ version_change.alter_request_by_schema_instructions,
249
+ version_change.alter_response_by_path_instructions,
250
+ version_change.alter_response_by_schema_instructions,
251
+ ]
252
+ ):
253
+ raise NotImplementedError(
254
+ f"HeadVersion does not support request or response migrations but {version_change} contained one."
255
+ )
256
+
257
+ @property
258
+ @deprecated("'version_changes' attribute is deprecated and will be removed in Cadwyn 5.x.x. Use 'changes' instead.")
259
+ def version_changes(self): # pragma: no cover
260
+ return self.changes
261
+
262
+
263
+ def get_cls_pythonpath(cls: type) -> IdentifierPythonPath:
264
+ return f"{cls.__module__}.{cls.__name__}"
265
+
266
+
267
+ class VersionBundle:
268
+ def __init__(
269
+ self,
270
+ latest_version_or_head_version: Union[Version, HeadVersion],
271
+ /,
272
+ *other_versions: Version,
273
+ api_version_var: Union[ContextVar[Union[VersionType, None]], None] = None,
274
+ ) -> None:
275
+ super().__init__()
276
+
277
+ if isinstance(latest_version_or_head_version, HeadVersion):
278
+ self.head_version = latest_version_or_head_version
279
+ self.versions = other_versions
280
+ else:
281
+ self.head_version = HeadVersion()
282
+ self.versions = (latest_version_or_head_version, *other_versions)
283
+ self.reversed_versions = tuple(reversed(self.versions))
284
+
285
+ if api_version_var is None:
286
+ api_version_var = ContextVar("cadwyn_api_version")
287
+
288
+ self.version_values = tuple(version.value for version in self.versions)
289
+ self.reversed_version_values = tuple(reversed(self.version_values))
290
+ self.api_version_var = api_version_var
291
+ self._all_versions = (self.head_version, *self.versions)
292
+ self._version_changes_to_version_mapping = {
293
+ version_change: version.value for version in self.versions for version_change in version.changes
294
+ }
295
+ self._version_values_set: set[str] = set()
296
+ for version in self.versions:
297
+ if version.value not in self._version_values_set:
298
+ self._version_values_set.add(version.value)
299
+ else:
300
+ raise CadwynStructureError(
301
+ f"You tried to define two versions with the same value in the same "
302
+ f"{VersionBundle.__name__}: '{version.value}'.",
303
+ )
304
+ for version_change in version.changes:
305
+ if version_change._bound_version_bundle is not None:
306
+ raise CadwynStructureError(
307
+ f"You tried to bind version change '{version_change.__name__}' to two different versions. "
308
+ "It is prohibited.",
309
+ )
310
+ version_change._bound_version_bundle = self
311
+ if not self.versions:
312
+ raise CadwynStructureError("You must define at least one non-head version in a VersionBundle.")
313
+
314
+ if self.versions[-1].changes:
315
+ raise CadwynStructureError(
316
+ f'The first version "{self.versions[-1].value}" cannot have any version changes. '
317
+ "Version changes are defined to migrate to/from a previous version so you "
318
+ "cannot define one for the very first version.",
319
+ )
320
+
321
+ def __iter__(self) -> Iterator[Version]:
322
+ yield from self.versions
323
+
324
+ @property
325
+ @deprecated("Use 'version_values' instead.")
326
+ def version_dates(self): # pragma: no cover
327
+ return self.version_values
328
+
329
+ @functools.cached_property
330
+ def versioned_schemas(self) -> dict[IdentifierPythonPath, type[VersionedModel]]:
331
+ altered_schemas = {
332
+ get_cls_pythonpath(instruction.schema): instruction.schema
333
+ for version in self._all_versions
334
+ for version_change in version.changes
335
+ for instruction in list(version_change.alter_schema_instructions)
336
+ }
337
+
338
+ migrated_schemas = {
339
+ get_cls_pythonpath(schema): schema
340
+ for version in self._all_versions
341
+ for version_change in version.changes
342
+ for schema in list(version_change.alter_request_by_schema_instructions.keys())
343
+ }
344
+
345
+ return altered_schemas | migrated_schemas
346
+
347
+ @functools.cached_property
348
+ def versioned_enums(self) -> dict[IdentifierPythonPath, type[Enum]]:
349
+ return {
350
+ get_cls_pythonpath(instruction.enum): instruction.enum
351
+ for version in self._all_versions
352
+ for version_change in version.changes
353
+ for instruction in version_change.alter_enum_instructions
354
+ }
355
+
356
+ def _get_closest_lesser_version(self, version: VersionType):
357
+ for defined_version in self.version_values:
358
+ if defined_version <= version:
359
+ return defined_version
360
+ raise CadwynError("You tried to migrate to version that is earlier than the first version which is prohibited.")
361
+
362
+ async def _migrate_request(
363
+ self,
364
+ body_type: Union[type[BaseModel], None],
365
+ head_dependant: Dependant,
366
+ request: FastapiRequest,
367
+ response: FastapiResponse,
368
+ request_info: RequestInfo,
369
+ current_version: VersionType,
370
+ head_route: APIRoute,
371
+ *,
372
+ exit_stack: AsyncExitStack,
373
+ embed_body_fields: bool,
374
+ background_tasks: Union[BackgroundTasks, None],
375
+ ) -> dict[str, Any]:
376
+ start = self.reversed_version_values.index(current_version)
377
+ head_route_id = id(head_route)
378
+ for v in self.reversed_versions[start + 1 :]:
379
+ for version_change in v.changes:
380
+ if body_type is not None and body_type in version_change.alter_request_by_schema_instructions:
381
+ for instruction in version_change.alter_request_by_schema_instructions[body_type]:
382
+ instruction(request_info)
383
+ if head_route_id in version_change._route_to_request_migration_mapping:
384
+ for instruction in version_change._route_to_request_migration_mapping[head_route_id]:
385
+ instruction(request_info)
386
+
387
+ request.scope["headers"] = tuple((key.encode(), value.encode()) for key, value in request_info.headers.items())
388
+ del request._headers
389
+ # This gives us the ability to tell the user whether cadwyn is running its dependencies or FastAPI
390
+ CURRENT_DEPENDENCY_SOLVER_VAR.set("cadwyn")
391
+ # Remember this: if len(body_params) == 1, then route.body_schema == route.dependant.body_params[0]
392
+ result = await solve_dependencies(
393
+ request=request,
394
+ response=response,
395
+ dependant=head_dependant,
396
+ body=request_info.body,
397
+ dependency_overrides_provider=head_route.dependency_overrides_provider,
398
+ async_exit_stack=exit_stack,
399
+ embed_body_fields=embed_body_fields,
400
+ background_tasks=background_tasks,
401
+ )
402
+ if result.errors:
403
+ raise CadwynHeadRequestValidationError(
404
+ _normalize_errors(result.errors), body=request_info.body, version=current_version
405
+ )
406
+ return result.values
407
+
408
+ def _migrate_response(
409
+ self,
410
+ response_info: ResponseInfo,
411
+ current_version: VersionType,
412
+ head_response_model: Union[type[BaseModel], None],
413
+ head_route: Union[APIRoute, None],
414
+ ) -> ResponseInfo:
415
+ head_route_id = id(head_route)
416
+ end = self.version_values.index(current_version)
417
+ for v in self.versions[:end]:
418
+ for version_change in v.changes:
419
+ migrations_to_apply: list[_BaseAlterResponseInstruction] = []
420
+
421
+ if head_response_model and head_response_model in version_change.alter_response_by_schema_instructions:
422
+ migrations_to_apply.extend(
423
+ version_change.alter_response_by_schema_instructions[head_response_model]
424
+ )
425
+
426
+ if head_route_id in version_change._route_to_response_migration_mapping:
427
+ migrations_to_apply.extend(version_change._route_to_response_migration_mapping[head_route_id])
428
+
429
+ for migration in migrations_to_apply:
430
+ if response_info.status_code < 300 or migration.migrate_http_errors:
431
+ migration(response_info)
432
+ return response_info
433
+
434
+ # TODO (https://github.com/zmievsa/cadwyn/issues/113): Refactor this function and all functions it calls.
435
+ def _versioned(
436
+ self,
437
+ head_body_field: Union[type[BaseModel], None],
438
+ module_body_field_name: Union[str, None],
439
+ route: APIRoute,
440
+ head_route: APIRoute,
441
+ dependant_for_request_migrations: Dependant,
442
+ *,
443
+ request_param_name: str,
444
+ background_tasks_param_name: Union[str, None],
445
+ response_param_name: str,
446
+ ) -> "Callable[[Endpoint[_P, _R]], Endpoint[_P, _R]]":
447
+ def wrapper(endpoint: "Endpoint[_P, _R]") -> "Endpoint[_P, _R]":
448
+ @functools.wraps(endpoint)
449
+ async def decorator(*args: Any, **kwargs: Any) -> _R:
450
+ request_param: FastapiRequest = kwargs[request_param_name]
451
+ response_param: FastapiResponse = kwargs[response_param_name]
452
+ background_tasks: Union[BackgroundTasks, None] = kwargs.get(
453
+ background_tasks_param_name, # pyright: ignore[reportArgumentType]
454
+ )
455
+ method = request_param.method
456
+ response = Sentinel
457
+ async with AsyncExitStack() as exit_stack:
458
+ kwargs = await self._convert_endpoint_kwargs_to_version(
459
+ head_body_field,
460
+ module_body_field_name,
461
+ # Dependant must be from the version of the finally migrated request,
462
+ # not the version of endpoint
463
+ dependant_for_request_migrations,
464
+ request_param_name,
465
+ kwargs,
466
+ response_param,
467
+ route,
468
+ head_route,
469
+ exit_stack=exit_stack,
470
+ embed_body_fields=route._embed_body_fields,
471
+ background_tasks=background_tasks,
472
+ )
473
+
474
+ response = await self._convert_endpoint_response_to_version(
475
+ endpoint,
476
+ head_route,
477
+ route,
478
+ method,
479
+ response_param_name,
480
+ kwargs,
481
+ response_param,
482
+ )
483
+ if response is Sentinel: # pragma: no cover
484
+ raise CadwynError(
485
+ "No response object was returned. There's a high chance that the "
486
+ "application code is raising an exception and a dependency with yield "
487
+ "has a block with a bare except, or a block with except Exception, "
488
+ "and is not raising the exception again. Read more about it in the "
489
+ "docs: https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-with-yield/#dependencies-with-yield-and-except"
490
+ )
491
+ return response
492
+
493
+ if request_param_name == _CADWYN_REQUEST_PARAM_NAME:
494
+ _add_keyword_only_parameter(decorator, _CADWYN_REQUEST_PARAM_NAME, FastapiRequest)
495
+ if response_param_name == _CADWYN_RESPONSE_PARAM_NAME:
496
+ _add_keyword_only_parameter(decorator, _CADWYN_RESPONSE_PARAM_NAME, FastapiResponse)
497
+
498
+ return decorator # pyright: ignore[reportReturnType]
499
+
500
+ return wrapper
501
+
502
+ # TODO: Simplify it
503
+ async def _convert_endpoint_response_to_version( # noqa: C901
504
+ self,
505
+ func_to_get_response_from: Endpoint,
506
+ head_route: APIRoute,
507
+ route: APIRoute,
508
+ method: str,
509
+ response_param_name: str,
510
+ kwargs: dict[str, Any],
511
+ fastapi_response_dependency: FastapiResponse,
512
+ ) -> Any:
513
+ raised_exception = None
514
+ if response_param_name == _CADWYN_RESPONSE_PARAM_NAME:
515
+ kwargs.pop(response_param_name)
516
+ try:
517
+ if is_async_callable(func_to_get_response_from):
518
+ response_or_response_body: Union[FastapiResponse, object] = await func_to_get_response_from(**kwargs)
519
+ else:
520
+ response_or_response_body: Union[FastapiResponse, object] = await run_in_threadpool(
521
+ func_to_get_response_from,
522
+ **kwargs,
523
+ )
524
+ except HTTPException as exc:
525
+ raised_exception = exc
526
+ response_or_response_body = FastapiResponse(
527
+ content=json.dumps({"detail": raised_exception.detail}),
528
+ status_code=raised_exception.status_code,
529
+ headers=raised_exception.headers,
530
+ )
531
+ if (raised_exception.headers or {}).get("content-length") is None: # pragma: no branch
532
+ del response_or_response_body.headers["content-length"]
533
+ api_version = self.api_version_var.get()
534
+ if api_version is None:
535
+ return response_or_response_body
536
+
537
+ if isinstance(response_or_response_body, FastapiResponse):
538
+ # TODO (https://github.com/zmievsa/cadwyn/issues/125): Add support for migrating `StreamingResponse`
539
+ # TODO (https://github.com/zmievsa/cadwyn/issues/126): Add support for migrating `FileResponse`
540
+ # Starlette breaks Liskov Substitution principle and
541
+ # doesn't define `body` for `StreamingResponse` and `FileResponse`
542
+ if isinstance(response_or_response_body, (StreamingResponse, FileResponse)):
543
+ body = None
544
+ elif response_or_response_body.body:
545
+ if (isinstance(response_or_response_body, JSONResponse) or raised_exception is not None) and isinstance(
546
+ response_or_response_body.body, (str, bytes)
547
+ ):
548
+ body = json.loads(response_or_response_body.body)
549
+ elif isinstance(response_or_response_body.body, bytes):
550
+ body = response_or_response_body.body.decode(response_or_response_body.charset)
551
+ else: # pragma: no cover # I don't see a good use case here yet
552
+ body = response_or_response_body.body
553
+ else:
554
+ body = None
555
+ # TODO (https://github.com/zmievsa/cadwyn/issues/51): Only do this if there are migrations
556
+
557
+ response_info = ResponseInfo(response_or_response_body, body)
558
+ else:
559
+ if fastapi_response_dependency.status_code is not None: # pyright: ignore[reportUnnecessaryComparison]
560
+ status_code = fastapi_response_dependency.status_code
561
+ elif route.status_code is not None:
562
+ status_code = route.status_code
563
+ elif raised_exception is not None:
564
+ raise NotImplementedError
565
+ else:
566
+ status_code = 200
567
+ fastapi_response_dependency.status_code = status_code
568
+ response_info = ResponseInfo(
569
+ fastapi_response_dependency,
570
+ _prepare_response_content(
571
+ response_or_response_body,
572
+ exclude_unset=head_route.response_model_exclude_unset,
573
+ exclude_defaults=head_route.response_model_exclude_defaults,
574
+ exclude_none=head_route.response_model_exclude_none,
575
+ ),
576
+ )
577
+
578
+ response_info = self._migrate_response(
579
+ response_info,
580
+ api_version,
581
+ head_route.response_model,
582
+ head_route,
583
+ )
584
+
585
+ if isinstance(response_or_response_body, FastapiResponse):
586
+ # a webserver (uvicorn for instance) calculates the body at the endpoint level.
587
+ # if an endpoint returns no "body", its content-length will be set to 0
588
+ # json.dumps(None) results into "null", and content-length should be 4,
589
+ # but it was already calculated to 0 which causes
590
+ # `RuntimeError: Response content longer than Content-Length` or
591
+ # `Too much data for declared Content-Length`, based on the protocol
592
+ # which is why we skip the None case.
593
+
594
+ # We skip cases without "body" attribute because of StreamingResponse and FileResponse
595
+ # that do not have it. We don't support it too.
596
+ if response_info.body is not None and hasattr(response_info._response, "body"):
597
+ # TODO (https://github.com/zmievsa/cadwyn/issues/51): Only do this if there are migrations
598
+ if (
599
+ isinstance(response_info.body, str)
600
+ and response_info._response.headers.get("content-type") != "application/json"
601
+ ):
602
+ response_info._response.body = response_info.body.encode(response_info._response.charset)
603
+ else:
604
+ response_info._response.body = json.dumps(
605
+ response_info.body,
606
+ ensure_ascii=False,
607
+ allow_nan=False,
608
+ indent=None,
609
+ separators=(",", ":"),
610
+ ).encode("utf-8")
611
+ if response_info.headers.get("content-length") is not None:
612
+ # It makes sense to re-calculate content length because the previously calculated one
613
+ # might slightly differ. If it differs -- uvicorn will break.
614
+ response_info.headers["content-length"] = str(len(response_info._response.body))
615
+
616
+ if raised_exception is not None and response_info.status_code >= 400:
617
+ if isinstance(response_info.body, dict) and "detail" in response_info.body:
618
+ detail = response_info.body["detail"]
619
+ else:
620
+ detail = response_info.body
621
+ if detail is None:
622
+ detail = http.HTTPStatus(response_info.status_code).phrase
623
+ raised_exception.detail = cast("str", detail)
624
+ raised_exception.headers = dict(response_info.headers)
625
+ raised_exception.status_code = response_info.status_code
626
+
627
+ raise raised_exception
628
+ return response_info._response
629
+ return response_info.body
630
+
631
+ async def _convert_endpoint_kwargs_to_version(
632
+ self,
633
+ head_body_field: Union[type[BaseModel], None],
634
+ body_field_alias: Union[str, None],
635
+ head_dependant: Dependant,
636
+ request_param_name: str,
637
+ kwargs: dict[str, Any],
638
+ response: FastapiResponse,
639
+ route: APIRoute,
640
+ head_route: APIRoute,
641
+ *,
642
+ exit_stack: AsyncExitStack,
643
+ embed_body_fields: bool,
644
+ background_tasks: Union[BackgroundTasks, None],
645
+ ) -> dict[str, Any]:
646
+ request: FastapiRequest = kwargs[request_param_name]
647
+ if request_param_name == _CADWYN_REQUEST_PARAM_NAME:
648
+ kwargs.pop(request_param_name)
649
+
650
+ api_version = self.api_version_var.get()
651
+ if api_version is None:
652
+ return kwargs
653
+
654
+ # This is a kind of body param you get when you define a single pydantic schema in your route's body
655
+ if (
656
+ len(route.dependant.body_params) == 1
657
+ and head_body_field is not None
658
+ and body_field_alias is not None
659
+ and body_field_alias in kwargs
660
+ ):
661
+ raw_body: Union[BaseModel, None] = kwargs.get(body_field_alias)
662
+ if raw_body is None: # pragma: no cover # This is likely an impossible case but we would like to be safe
663
+ body = None
664
+ # It means we have a dict or a list instead of a full model.
665
+ # This covers the following use case in the endpoint definition: "payload: dict = Body(None)"
666
+ elif not isinstance(raw_body, BaseModel):
667
+ body = raw_body
668
+ else:
669
+ body = raw_body.model_dump(by_alias=True, exclude_unset=True)
670
+ else:
671
+ # This is for requests without body or with complex body such as form or file
672
+ body = await _get_body(request, route.body_field, exit_stack)
673
+
674
+ request_info = RequestInfo(request, body)
675
+ new_kwargs = await self._migrate_request(
676
+ head_body_field,
677
+ head_dependant,
678
+ request,
679
+ response,
680
+ request_info,
681
+ api_version,
682
+ head_route,
683
+ exit_stack=exit_stack,
684
+ embed_body_fields=embed_body_fields,
685
+ background_tasks=background_tasks,
686
+ )
687
+ # Because we re-added it into our kwargs when we did solve_dependencies
688
+ if _CADWYN_REQUEST_PARAM_NAME in new_kwargs:
689
+ new_kwargs.pop(_CADWYN_REQUEST_PARAM_NAME)
690
+
691
+ return new_kwargs
692
+
693
+
694
+ # We use this instead of `.body()` to automatically guess body type and load the correct body, even if it's a form
695
+ async def _get_body(
696
+ request: FastapiRequest, body_field: Union[ModelField, None], exit_stack: AsyncExitStack
697
+ ): # pragma: no cover # This is from FastAPI
698
+ is_body_form = body_field and isinstance(body_field.field_info, params.Form)
699
+ try:
700
+ body: Any = None
701
+ if body_field:
702
+ if is_body_form:
703
+ body = await request.form()
704
+ exit_stack.push_async_callback(body.close)
705
+ else:
706
+ body_bytes = await request.body()
707
+ if body_bytes:
708
+ json_body: Any = PydanticUndefined
709
+ content_type_value = request.headers.get("content-type")
710
+ if not content_type_value:
711
+ json_body = await request.json()
712
+ else:
713
+ message = email.message.Message()
714
+ message["content-type"] = content_type_value
715
+ if message.get_content_maintype() == "application":
716
+ subtype = message.get_content_subtype()
717
+ if subtype == "json" or subtype.endswith("+json"):
718
+ json_body = await request.json()
719
+ if json_body != PydanticUndefined:
720
+ body = json_body
721
+ else:
722
+ body = body_bytes
723
+ except json.JSONDecodeError as e:
724
+ raise RequestValidationError(
725
+ [
726
+ {
727
+ "type": "json_invalid",
728
+ "loc": ("body", e.pos),
729
+ "msg": "JSON decode error",
730
+ "input": {},
731
+ "ctx": {"error": e.msg},
732
+ },
733
+ ],
734
+ body=e.doc,
735
+ ) from e
736
+ except HTTPException:
737
+ raise
738
+ except Exception as e:
739
+ raise HTTPException(status_code=400, detail="There was an error parsing the body") from e
740
+ return body
741
+
742
+
743
+ def _add_keyword_only_parameter(
744
+ func: Callable,
745
+ param_name: str,
746
+ param_annotation: type,
747
+ ):
748
+ signature = inspect.signature(func)
749
+ func.__signature__ = signature.replace(
750
+ parameters=(
751
+ [
752
+ *list(signature.parameters.values()),
753
+ inspect.Parameter(param_name, kind=inspect._ParameterKind.KEYWORD_ONLY, annotation=param_annotation),
754
+ ]
755
+ ),
756
+ )