cadwyn 3.12.1__tar.gz → 3.13.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (37) hide show
  1. {cadwyn-3.12.1 → cadwyn-3.13.0}/PKG-INFO +2 -1
  2. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/_asts.py +3 -2
  3. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/_compat.py +9 -2
  4. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/exceptions.py +12 -0
  5. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/route_generation.py +115 -48
  6. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/structure/data.py +13 -9
  7. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/structure/endpoints.py +7 -3
  8. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/structure/versions.py +26 -23
  9. {cadwyn-3.12.1 → cadwyn-3.13.0}/pyproject.toml +3 -5
  10. {cadwyn-3.12.1 → cadwyn-3.13.0}/LICENSE +0 -0
  11. {cadwyn-3.12.1 → cadwyn-3.13.0}/README.md +0 -0
  12. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/__init__.py +0 -0
  13. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/__main__.py +0 -0
  14. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/_package_utils.py +0 -0
  15. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/_utils.py +0 -0
  16. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/applications.py +0 -0
  17. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/codegen/README.md +0 -0
  18. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/codegen/__init__.py +0 -0
  19. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/codegen/_common.py +0 -0
  20. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/codegen/_main.py +0 -0
  21. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/codegen/_plugins/__init__.py +0 -0
  22. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/codegen/_plugins/class_migrations.py +0 -0
  23. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/codegen/_plugins/class_rebuilding.py +0 -0
  24. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/codegen/_plugins/class_renaming.py +0 -0
  25. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/codegen/_plugins/import_auto_adding.py +0 -0
  26. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/codegen/_plugins/module_migrations.py +0 -0
  27. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/main.py +0 -0
  28. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/middleware.py +0 -0
  29. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/py.typed +0 -0
  30. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/routing.py +0 -0
  31. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/static/__init__.py +0 -0
  32. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/static/docs.html +0 -0
  33. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/structure/__init__.py +0 -0
  34. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/structure/common.py +0 -0
  35. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/structure/enums.py +0 -0
  36. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/structure/modules.py +0 -0
  37. {cadwyn-3.12.1 → cadwyn-3.13.0}/cadwyn/structure/schemas.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cadwyn
3
- Version: 3.12.1
3
+ Version: 3.13.0
4
4
  Summary: Production-ready community-driven modern Stripe-like API versioning in FastAPI
5
5
  Home-page: https://github.com/zmievsa/cadwyn
6
6
  License: MIT
@@ -34,6 +34,7 @@ Classifier: Typing :: Typed
34
34
  Provides-Extra: cli
35
35
  Requires-Dist: better-ast-comments (>=1.2.1,<1.3.0)
36
36
  Requires-Dist: fastapi (>=0.110.0)
37
+ Requires-Dist: issubclass (>=0.1.2,<0.2.0)
37
38
  Requires-Dist: jinja2 (>=3.1.2)
38
39
  Requires-Dist: pydantic (>=1.0.0)
39
40
  Requires-Dist: starlette (>=0.36.3)
@@ -10,6 +10,7 @@ from typing import ( # noqa: UP035
10
10
  TYPE_CHECKING,
11
11
  Any,
12
12
  List,
13
+ cast,
13
14
  get_args,
14
15
  get_origin,
15
16
  )
@@ -32,7 +33,7 @@ _RE_CAMEL_TO_SNAKE = re.compile(r"(?<!^)(?=[A-Z])")
32
33
 
33
34
 
34
35
  # A parent type of typing._GenericAlias
35
- _BaseGenericAlias = type(List[int]).mro()[1] # noqa: UP006
36
+ _BaseGenericAlias = cast(type, type(List[int])).mro()[1] # noqa: UP006
36
37
 
37
38
  # type(list[int]) and type(List[int]) are different which is why we have to do this.
38
39
  # Please note that this problem is much wider than just lists which is why we use typing._BaseGenericAlias
@@ -134,7 +135,7 @@ def transform_auto(_: auto) -> Any:
134
135
  return PlainRepr("auto()")
135
136
 
136
137
 
137
- def transform_union(value: UnionType) -> Any: # pyright: ignore[reportInvalidTypeForm]
138
+ def transform_union(value: UnionType) -> Any:
138
139
  return "typing.Union[" + (", ".join(get_fancy_repr(a) for a in get_args(value))) + "]"
139
140
 
140
141
 
@@ -61,7 +61,7 @@ class PydanticFieldWrapper:
61
61
 
62
62
  annotation: Any
63
63
 
64
- init_model_field: dataclasses.InitVar[ModelField] # pyright: ignore[reportInvalidTypeForm]
64
+ init_model_field: dataclasses.InitVar[ModelField]
65
65
  field_info: FieldInfo = dataclasses.field(init=False)
66
66
 
67
67
  annotation_ast: ast.expr | None = None
@@ -69,7 +69,7 @@ class PydanticFieldWrapper:
69
69
  # the value_ast is "None" and "Field(default=None)" respectively
70
70
  value_ast: ast.expr | None = None
71
71
 
72
- def __post_init__(self, init_model_field: ModelField): # pyright: ignore[reportInvalidTypeForm]
72
+ def __post_init__(self, init_model_field: ModelField):
73
73
  if isinstance(init_model_field, FieldInfo):
74
74
  self.field_info = init_model_field
75
75
  else:
@@ -111,6 +111,13 @@ class PydanticFieldWrapper:
111
111
  return attributes | extras
112
112
 
113
113
 
114
+ def get_annotation_from_model_field(model: ModelField) -> Any:
115
+ if PYDANTIC_V2:
116
+ return model.field_info.annotation
117
+ else:
118
+ return model.annotation
119
+
120
+
114
121
  def model_fields(model: type[BaseModel]) -> dict[str, FieldInfo]:
115
122
  if PYDANTIC_V2:
116
123
  return model.model_fields
@@ -46,6 +46,18 @@ class RouterPathParamsModifiedError(RouterGenerationError):
46
46
  pass
47
47
 
48
48
 
49
+ class RouteResponseBySchemaConverterDoesNotApplyToAnythingError(RouterGenerationError):
50
+ pass
51
+
52
+
53
+ class RouteRequestBySchemaConverterDoesNotApplyToAnythingError(RouterGenerationError):
54
+ pass
55
+
56
+
57
+ class RouteByPathConverterDoesNotApplyToAnythingError(RouterGenerationError):
58
+ pass
59
+
60
+
49
61
  class RouteAlreadyExistsError(RouterGenerationError):
50
62
  def __init__(self, *routes: APIRoute):
51
63
  self.routes = routes
@@ -15,7 +15,6 @@ from typing import (
15
15
  Annotated,
16
16
  Any,
17
17
  Generic,
18
- TypeAlias,
19
18
  TypeVar,
20
19
  _BaseGenericAlias, # pyright: ignore[reportAttributeAccessIssue]
21
20
  cast,
@@ -29,6 +28,7 @@ import fastapi.params
29
28
  import fastapi.routing
30
29
  import fastapi.security.base
31
30
  import fastapi.utils
31
+ from fastapi import APIRouter
32
32
  from fastapi._compat import ModelField as FastAPIModelField
33
33
  from fastapi._compat import create_body_model
34
34
  from fastapi.dependencies.models import Dependant
@@ -39,6 +39,7 @@ from fastapi.dependencies.utils import (
39
39
  )
40
40
  from fastapi.params import Depends
41
41
  from fastapi.routing import APIRoute
42
+ from issubclass import issubclass as lenient_issubclass
42
43
  from pydantic import BaseModel
43
44
  from starlette.routing import (
44
45
  BaseRoute,
@@ -46,12 +47,15 @@ from starlette.routing import (
46
47
  )
47
48
  from typing_extensions import Self, assert_never, deprecated
48
49
 
49
- from cadwyn._compat import model_fields, rebuild_fastapi_body_param
50
+ from cadwyn._compat import get_annotation_from_model_field, model_fields, rebuild_fastapi_body_param
50
51
  from cadwyn._package_utils import get_version_dir_path
51
52
  from cadwyn._utils import Sentinel, UnionType, get_another_version_of_cls
52
53
  from cadwyn.exceptions import (
53
54
  CadwynError,
54
55
  RouteAlreadyExistsError,
56
+ RouteByPathConverterDoesNotApplyToAnythingError,
57
+ RouteRequestBySchemaConverterDoesNotApplyToAnythingError,
58
+ RouteResponseBySchemaConverterDoesNotApplyToAnythingError,
55
59
  RouterGenerationError,
56
60
  RouterPathParamsModifiedError,
57
61
  )
@@ -68,8 +72,6 @@ _T = TypeVar("_T", bound=Callable[..., Any])
68
72
  _R = TypeVar("_R", bound=fastapi.routing.APIRouter)
69
73
  # This is a hack we do because we can't guarantee how the user will use the router.
70
74
  _DELETED_ROUTE_TAG = "_CADWYN_DELETED_ROUTE"
71
- _EndpointPath: TypeAlias = str
72
- _EndpointMethod: TypeAlias = str
73
75
 
74
76
 
75
77
  @dataclass(slots=True, frozen=True, eq=True)
@@ -78,13 +80,6 @@ class _EndpointInfo:
78
80
  endpoint_methods: frozenset[str]
79
81
 
80
82
 
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
83
  @deprecated("It will soon be deleted. Use HeadVersion version changes instead.")
89
84
  class InternalRepresentationOf:
90
85
  def __class_getitem__(cls, original_schema: type, /) -> type[Self]:
@@ -161,22 +156,15 @@ class _EndpointTransformer(Generic[_R]):
161
156
  self.parent_router
162
157
  )
163
158
  router = deepcopy(self.parent_router)
164
- router_infos: dict[VersionDate, _RouterInfo] = {}
165
- routes_with_migrated_requests = {}
166
- route_bodies_with_migrated_requests: set[type[BaseModel]] = set()
159
+ routers: dict[VersionDate, _R] = {}
160
+
167
161
  for version in self.versions:
168
162
  self.annotation_transformer.migrate_router_to_version(router, version)
169
163
 
170
- router_infos[version.value] = _RouterInfo(
171
- router,
172
- routes_with_migrated_requests,
173
- route_bodies_with_migrated_requests,
174
- )
164
+ self._validate_all_data_converters_are_applied(router, version)
165
+
166
+ routers[version.value] = router
175
167
  # Applying changes for the next version
176
- routes_with_migrated_requests = _get_migrated_routes_by_path(version)
177
- route_bodies_with_migrated_requests = {
178
- schema for change in version.version_changes for schema in change.alter_request_by_schema_instructions
179
- }
180
168
  router = deepcopy(router)
181
169
  self._apply_endpoint_changes_to_router(router, version)
182
170
 
@@ -194,21 +182,21 @@ class _EndpointTransformer(Generic[_R]):
194
182
  continue
195
183
  _add_request_and_response_params(head_route)
196
184
  copy_of_dependant = deepcopy(head_route.dependant)
197
- # Remember this: if len(body_params) == 1, then route.body_schema == route.dependant.body_params[0]
198
- if len(copy_of_dependant.body_params) == 1:
185
+
186
+ if _route_has_a_simple_body_schema(head_route):
199
187
  self._replace_internal_representation_with_the_versioned_schema(
200
188
  copy_of_dependant,
201
189
  schema_to_internal_request_body_representation,
202
190
  )
203
191
 
204
- for older_router_info in list(router_infos.values()):
205
- older_route = older_router_info.router.routes[route_index]
192
+ for older_router in list(routers.values()):
193
+ older_route = older_router.routes[route_index]
206
194
 
207
195
  # We know they are APIRoutes because of the check at the very beginning of the top loop.
208
196
  # I.e. Because head_route is an APIRoute, both routes are APIRoutes too
209
197
  older_route = cast(APIRoute, older_route)
210
198
  # Wait.. Why do we need this code again?
211
- if older_route.body_field is not None and len(older_route.dependant.body_params) == 1:
199
+ if older_route.body_field is not None and _route_has_a_simple_body_schema(older_route):
212
200
  template_older_body_model = self.annotation_transformer._change_version_of_annotations(
213
201
  older_route.body_field.type_,
214
202
  self.annotation_transformer.head_version_dir,
@@ -224,13 +212,99 @@ class _EndpointTransformer(Generic[_R]):
224
212
  copy_of_dependant,
225
213
  self.versions,
226
214
  )
227
- for _, router_info in router_infos.items():
228
- router_info.router.routes = [
215
+ for _, router in routers.items():
216
+ router.routes = [
229
217
  route
230
- for route in router_info.router.routes
218
+ for route in router.routes
231
219
  if not (isinstance(route, fastapi.routing.APIRoute) and _DELETED_ROUTE_TAG in route.tags)
232
220
  ]
233
- return {version: router_info.router for version, router_info in router_infos.items()}
221
+ return routers
222
+
223
+ def _validate_all_data_converters_are_applied(self, router: APIRouter, version: Version):
224
+ path_to_route_methods_mapping, head_response_models, head_request_bodies = self._extract_all_routes_identifiers(
225
+ router
226
+ )
227
+
228
+ for version_change in version.version_changes:
229
+ for by_path_converters in [
230
+ *version_change.alter_response_by_path_instructions.values(),
231
+ *version_change.alter_request_by_path_instructions.values(),
232
+ ]:
233
+ for by_path_converter in by_path_converters:
234
+ missing_methods = by_path_converter.methods.difference(
235
+ path_to_route_methods_mapping[by_path_converter.path]
236
+ )
237
+
238
+ if missing_methods:
239
+ raise RouteByPathConverterDoesNotApplyToAnythingError(
240
+ f"{by_path_converter.repr_name} "
241
+ f'"{version_change.__name__}.{by_path_converter.transformer.__name__}" '
242
+ f"failed to find routes with the following methods: {list(missing_methods)}. "
243
+ f"This means that you are trying to apply this converter to non-existing endpoint(s). "
244
+ "Please, check whether the path and methods are correct. (hint: path must include "
245
+ "all path variables and have a name that was used in the version that this "
246
+ "VersionChange resides in)"
247
+ )
248
+
249
+ for by_schema_converters in version_change.alter_request_by_schema_instructions.values():
250
+ for by_schema_converter in by_schema_converters:
251
+ missing_models = set(by_schema_converter.schemas) - head_request_bodies
252
+ if missing_models:
253
+ raise RouteRequestBySchemaConverterDoesNotApplyToAnythingError(
254
+ f"Request by body schema converter "
255
+ f'"{version_change.__name__}.{by_schema_converter.transformer.__name__}" '
256
+ f"failed to find routes with the following body schemas: "
257
+ f"{[m.__name__ for m in missing_models]}. "
258
+ f"This means that you are trying to apply this converter to non-existing endpoint(s). "
259
+ )
260
+ for by_schema_converters in version_change.alter_response_by_schema_instructions.values():
261
+ for by_schema_converter in by_schema_converters:
262
+ missing_models = set(by_schema_converter.schemas) - head_response_models
263
+ if missing_models:
264
+ raise RouteResponseBySchemaConverterDoesNotApplyToAnythingError(
265
+ f"Response by response model converter "
266
+ f'"{version_change.__name__}.{by_schema_converter.transformer.__name__}" '
267
+ f"failed to find routes with the following response models: "
268
+ f"{[m.__name__ for m in missing_models]}. "
269
+ f"This means that you are trying to apply this converter to non-existing endpoint(s). "
270
+ )
271
+
272
+ def _extract_all_routes_identifiers(
273
+ self, router: APIRouter
274
+ ) -> tuple[defaultdict[str, set[str]], set[Any], set[Any]]:
275
+ response_models: set[Any] = set()
276
+ request_bodies: set[Any] = set()
277
+ path_to_route_methods_mapping: dict[str, set[str]] = defaultdict(set)
278
+
279
+ for route in router.routes:
280
+ if isinstance(route, APIRoute):
281
+ if route.response_model is not None and lenient_issubclass(route.response_model, BaseModel):
282
+ # FIXME: This is going to fail on Pydantic 1
283
+ response_models.add(route.response_model)
284
+ # Not sure if it can ever be None when it's a simple schema. Eh, I would rather be safe than sorry
285
+ if _route_has_a_simple_body_schema(route) and route.body_field is not None:
286
+ annotation = get_annotation_from_model_field(route.body_field)
287
+ if lenient_issubclass(annotation, BaseModel):
288
+ # FIXME: This is going to fail on Pydantic 1
289
+ request_bodies.add(annotation)
290
+ path_to_route_methods_mapping[route.path] |= route.methods
291
+
292
+ head_response_models = {
293
+ self.annotation_transformer._change_version_of_annotations(
294
+ model,
295
+ self.versions.versioned_directories_with_head[0],
296
+ )
297
+ for model in response_models
298
+ }
299
+ head_request_bodies = {
300
+ self.annotation_transformer._change_version_of_annotations(
301
+ body,
302
+ self.versions.versioned_directories_with_head[0],
303
+ )
304
+ for body in request_bodies
305
+ }
306
+
307
+ return path_to_route_methods_mapping, head_response_models, head_request_bodies
234
308
 
235
309
  def _replace_internal_representation_with_the_versioned_schema(
236
310
  self,
@@ -422,8 +496,8 @@ class _AnnotationTransformer:
422
496
  self.versions = versions
423
497
  self.versions.head_schemas_package = head_schemas_package
424
498
  self.head_schemas_package = head_schemas_package
425
- self.head_version_dir = min(versions.versioned_directories) # "head" < "v0000_00_00"
426
- self.latest_version_dir = max(versions.versioned_directories) # "v2005_11_11" > "v2000_11_11"
499
+ self.head_version_dir = min(versions.versioned_directories_with_head) # "head" < "v0000_00_00"
500
+ self.latest_version_dir = max(versions.versioned_directories_with_head) # "v2005_11_11" > "v2000_11_11"
427
501
 
428
502
  # This cache is not here for speeding things up. It's for preventing the creation of copies of the same object
429
503
  # because such copies could produce weird behaviors at runtime, especially if you/fastapi do any comparisons.
@@ -537,7 +611,7 @@ class _AnnotationTransformer:
537
611
  )
538
612
  else:
539
613
  self._validate_source_file_is_located_in_template_dir(annotation, source_file)
540
- return get_another_version_of_cls(annotation, version_dir, self.versions.versioned_directories)
614
+ return get_another_version_of_cls(annotation, version_dir, self.versions.versioned_directories_with_head)
541
615
  else:
542
616
  return annotation
543
617
 
@@ -550,7 +624,7 @@ class _AnnotationTransformer:
550
624
  if (
551
625
  source_file.startswith(dir_with_versions)
552
626
  and not source_file.startswith(template_dir)
553
- and any(source_file.startswith(str(d)) for d in self.versions.versioned_directories)
627
+ and any(source_file.startswith(str(d)) for d in self.versions.versioned_directories_with_head)
554
628
  ):
555
629
  raise RouterGenerationError(
556
630
  f'"{annotation}" is not defined in "{self.head_version_dir}" even though it must be. '
@@ -725,18 +799,6 @@ def _get_route_from_func(
725
799
  return None
726
800
 
727
801
 
728
- def _get_migrated_routes_by_path(version: Version) -> dict[_EndpointPath, set[_EndpointMethod]]:
729
- request_by_path_migration_instructions = [
730
- version_change.alter_request_by_path_instructions for version_change in version.version_changes
731
- ]
732
- migrated_routes = defaultdict(set)
733
- for instruction_dict in request_by_path_migration_instructions:
734
- for path, instruction_list in instruction_dict.items():
735
- for instruction in instruction_list:
736
- migrated_routes[path] |= instruction.methods
737
- return migrated_routes
738
-
739
-
740
802
  def _copy_function(function: _T) -> _T:
741
803
  while hasattr(function, "__alt_wrapped__"):
742
804
  function = function.__alt_wrapped__
@@ -768,3 +830,8 @@ def _copy_function(function: _T) -> _T:
768
830
  del annotation_modifying_wrapper.__wrapped__
769
831
 
770
832
  return cast(_T, annotation_modifying_wrapper)
833
+
834
+
835
+ def _route_has_a_simple_body_schema(route: APIRoute) -> bool:
836
+ # Remember this: if len(body_params) == 1, then route.body_schema == route.dependant.body_params[0]
837
+ return len(route.dependant.body_params) == 1
@@ -8,6 +8,7 @@ from fastapi import Request, Response
8
8
  from starlette.datastructures import MutableHeaders
9
9
 
10
10
  from cadwyn._utils import same_definition_as_in
11
+ from cadwyn.structure.endpoints import _validate_that_strings_are_valid_http_methods
11
12
 
12
13
  _P = ParamSpec("_P")
13
14
 
@@ -96,14 +97,15 @@ class _BaseAlterRequestInstruction(_AlterDataInstruction):
96
97
 
97
98
 
98
99
  @dataclass
99
- class AlterRequestBySchemaInstruction(_BaseAlterRequestInstruction):
100
+ class _AlterRequestBySchemaInstruction(_BaseAlterRequestInstruction):
100
101
  schemas: tuple[Any, ...]
101
102
 
102
103
 
103
104
  @dataclass
104
- class AlterRequestByPathInstruction(_BaseAlterRequestInstruction):
105
+ class _AlterRequestByPathInstruction(_BaseAlterRequestInstruction):
105
106
  path: str
106
107
  methods: set[str]
108
+ repr_name = "Request by path converter"
107
109
 
108
110
 
109
111
  @overload
@@ -126,7 +128,7 @@ def convert_request_to_next_version_for(
126
128
 
127
129
  def decorator(transformer: Callable[[RequestInfo], None]) -> Any:
128
130
  if isinstance(schema_or_path, str):
129
- return AlterRequestByPathInstruction(
131
+ return _AlterRequestByPathInstruction(
130
132
  path=schema_or_path,
131
133
  methods=set(cast(list, methods_or_second_schema)),
132
134
  transformer=transformer,
@@ -136,7 +138,7 @@ def convert_request_to_next_version_for(
136
138
  schemas = (schema_or_path,)
137
139
  else:
138
140
  schemas = (schema_or_path, methods_or_second_schema, *additional_schemas)
139
- return AlterRequestBySchemaInstruction(
141
+ return _AlterRequestBySchemaInstruction(
140
142
  schemas=schemas,
141
143
  transformer=transformer,
142
144
  )
@@ -156,14 +158,15 @@ class _BaseAlterResponseInstruction(_AlterDataInstruction):
156
158
 
157
159
 
158
160
  @dataclass
159
- class AlterResponseBySchemaInstruction(_BaseAlterResponseInstruction):
161
+ class _AlterResponseBySchemaInstruction(_BaseAlterResponseInstruction):
160
162
  schemas: tuple[Any, ...]
161
163
 
162
164
 
163
165
  @dataclass
164
- class AlterResponseByPathInstruction(_BaseAlterResponseInstruction):
166
+ class _AlterResponseByPathInstruction(_BaseAlterResponseInstruction):
165
167
  path: str
166
168
  methods: set[str]
169
+ repr_name = "Response by path converter"
167
170
 
168
171
 
169
172
  @overload
@@ -197,7 +200,7 @@ def convert_response_to_previous_version_for(
197
200
  def decorator(transformer: Callable[[ResponseInfo], None]) -> Any:
198
201
  if isinstance(schema_or_path, str):
199
202
  # The validation above checks that methods is not None
200
- return AlterResponseByPathInstruction(
203
+ return _AlterResponseByPathInstruction(
201
204
  path=schema_or_path,
202
205
  methods=set(cast(list, methods_or_second_schema)),
203
206
  transformer=transformer,
@@ -208,7 +211,7 @@ def convert_response_to_previous_version_for(
208
211
  schemas = (schema_or_path,)
209
212
  else:
210
213
  schemas = (schema_or_path, methods_or_second_schema, *additional_schemas)
211
- return AlterResponseBySchemaInstruction(
214
+ return _AlterResponseBySchemaInstruction(
212
215
  schemas=schemas,
213
216
  transformer=transformer,
214
217
  migrate_http_errors=migrate_http_errors,
@@ -219,10 +222,11 @@ def convert_response_to_previous_version_for(
219
222
 
220
223
  def _validate_decorator_args(
221
224
  schema_or_path: type | str, methods_or_second_schema: list[str] | type | None, additional_schemas: tuple[type, ...]
222
- ):
225
+ ) -> None:
223
226
  if isinstance(schema_or_path, str):
224
227
  if not isinstance(methods_or_second_schema, list):
225
228
  raise TypeError("If path was provided as a first argument, methods must be provided as a second argument")
229
+ _validate_that_strings_are_valid_http_methods(methods_or_second_schema)
226
230
  if additional_schemas:
227
231
  raise TypeError("If path was provided as a first argument, then additional schemas cannot be added")
228
232
 
@@ -1,4 +1,4 @@
1
- from collections.abc import Callable, Sequence
1
+ from collections.abc import Callable, Collection, Sequence
2
2
  from dataclasses import dataclass
3
3
  from enum import Enum
4
4
  from typing import Any
@@ -148,6 +148,12 @@ class EndpointInstructionFactory:
148
148
 
149
149
 
150
150
  def endpoint(path: str, methods: list[str], /, *, func_name: str | None = None) -> EndpointInstructionFactory:
151
+ _validate_that_strings_are_valid_http_methods(methods)
152
+
153
+ return EndpointInstructionFactory(path, set(methods), func_name)
154
+
155
+
156
+ def _validate_that_strings_are_valid_http_methods(methods: Collection[str]):
151
157
  invalid_methods = set(methods) - HTTP_METHODS
152
158
  if invalid_methods:
153
159
  invalid_methods = ", ".join(sorted(invalid_methods))
@@ -156,7 +162,5 @@ def endpoint(path: str, methods: list[str], /, *, func_name: str | None = None)
156
162
  "Please use valid HTTP methods such as GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD.",
157
163
  )
158
164
 
159
- return EndpointInstructionFactory(path, set(methods), func_name)
160
-
161
165
 
162
166
  AlterEndpointSubInstruction = EndpointDidntExistInstruction | EndpointExistedInstruction | EndpointHadInstruction
@@ -39,12 +39,12 @@ from cadwyn.exceptions import CadwynError, CadwynHeadRequestValidationError, Cad
39
39
  from .._utils import Sentinel
40
40
  from .common import Endpoint, VersionDate, VersionedModel
41
41
  from .data import (
42
- AlterRequestByPathInstruction,
43
- AlterRequestBySchemaInstruction,
44
- AlterResponseByPathInstruction,
45
- AlterResponseBySchemaInstruction,
46
42
  RequestInfo,
47
43
  ResponseInfo,
44
+ _AlterRequestByPathInstruction,
45
+ _AlterRequestBySchemaInstruction,
46
+ _AlterResponseByPathInstruction,
47
+ _AlterResponseBySchemaInstruction,
48
48
  _BaseAlterResponseInstruction,
49
49
  )
50
50
  from .endpoints import AlterEndpointSubInstruction
@@ -74,12 +74,12 @@ class VersionChange:
74
74
  alter_enum_instructions: ClassVar[list[AlterEnumSubInstruction]] = Sentinel
75
75
  alter_module_instructions: ClassVar[list[AlterModuleInstruction]] = Sentinel
76
76
  alter_endpoint_instructions: ClassVar[list[AlterEndpointSubInstruction]] = Sentinel
77
- alter_request_by_schema_instructions: ClassVar[dict[type[BaseModel], list[AlterRequestBySchemaInstruction]]] = (
77
+ alter_request_by_schema_instructions: ClassVar[dict[type[BaseModel], list[_AlterRequestBySchemaInstruction]]] = (
78
78
  Sentinel
79
79
  )
80
- alter_request_by_path_instructions: ClassVar[dict[str, list[AlterRequestByPathInstruction]]] = Sentinel
81
- alter_response_by_schema_instructions: ClassVar[dict[type, list[AlterResponseBySchemaInstruction]]] = Sentinel
82
- alter_response_by_path_instructions: ClassVar[dict[str, list[AlterResponseByPathInstruction]]] = Sentinel
80
+ alter_request_by_path_instructions: ClassVar[dict[str, list[_AlterRequestByPathInstruction]]] = Sentinel
81
+ alter_response_by_schema_instructions: ClassVar[dict[type, list[_AlterResponseBySchemaInstruction]]] = Sentinel
82
+ alter_response_by_path_instructions: ClassVar[dict[str, list[_AlterResponseByPathInstruction]]] = Sentinel
83
83
  _bound_version_bundle: "VersionBundle | None"
84
84
 
85
85
  def __init_subclass__(cls, _abstract: bool = False) -> None:
@@ -96,15 +96,15 @@ class VersionChange:
96
96
  @classmethod
97
97
  def _extract_body_instructions_into_correct_containers(cls):
98
98
  for instruction in cls.__dict__.values():
99
- if isinstance(instruction, AlterRequestBySchemaInstruction):
99
+ if isinstance(instruction, _AlterRequestBySchemaInstruction):
100
100
  for schema in instruction.schemas:
101
101
  cls.alter_request_by_schema_instructions[schema].append(instruction)
102
- elif isinstance(instruction, AlterRequestByPathInstruction):
102
+ elif isinstance(instruction, _AlterRequestByPathInstruction):
103
103
  cls.alter_request_by_path_instructions[instruction.path].append(instruction)
104
- elif isinstance(instruction, AlterResponseBySchemaInstruction):
104
+ elif isinstance(instruction, _AlterResponseBySchemaInstruction):
105
105
  for schema in instruction.schemas:
106
106
  cls.alter_response_by_schema_instructions[schema].append(instruction)
107
- elif isinstance(instruction, AlterResponseByPathInstruction):
107
+ elif isinstance(instruction, _AlterResponseByPathInstruction):
108
108
  cls.alter_response_by_path_instructions[instruction.path].append(instruction)
109
109
 
110
110
  @classmethod
@@ -154,10 +154,10 @@ class VersionChange:
154
154
  for attr_name, attr_value in cls.__dict__.items():
155
155
  if not isinstance(
156
156
  attr_value,
157
- AlterRequestBySchemaInstruction
158
- | AlterRequestByPathInstruction
159
- | AlterResponseBySchemaInstruction
160
- | AlterResponseByPathInstruction,
157
+ _AlterRequestBySchemaInstruction
158
+ | _AlterRequestByPathInstruction
159
+ | _AlterResponseBySchemaInstruction
160
+ | _AlterResponseByPathInstruction,
161
161
  ) and attr_name not in {
162
162
  "description",
163
163
  "side_effects",
@@ -385,7 +385,7 @@ class VersionBundle:
385
385
  }
386
386
 
387
387
  @functools.cached_property
388
- def versioned_directories(self) -> tuple[Path, ...]:
388
+ def versioned_directories_with_head(self) -> tuple[Path, ...]:
389
389
  if self.head_schemas_package is None:
390
390
  raise CadwynError(
391
391
  f"You cannot call 'VersionBundle.{self.migrate_response_body.__name__}' because it has no access to "
@@ -397,6 +397,10 @@ class VersionBundle:
397
397
  + [get_version_dir_path(self.head_schemas_package, version.value) for version in self]
398
398
  )
399
399
 
400
+ @functools.cached_property
401
+ def versioned_directories_without_head(self) -> tuple[Path, ...]:
402
+ return self.versioned_directories_with_head[1:]
403
+
400
404
  def migrate_response_body(self, latest_response_model: type[BaseModel], *, latest_body: Any, version: VersionDate):
401
405
  """Convert the data to a specific version by applying all version changes from latest until that version
402
406
  in reverse order and wrapping the result in the correct version of latest_response_model.
@@ -411,11 +415,10 @@ class VersionBundle:
411
415
  )
412
416
 
413
417
  version = self._get_closest_lesser_version(version)
414
- # + 1 comes from latest also being in the versioned_directories list
415
- version_dir = self.versioned_directories[self.version_dates.index(version) + 1]
418
+ version_dir = self.versioned_directories_without_head[self.version_dates.index(version)]
416
419
 
417
420
  versioned_response_model: type[BaseModel] = get_another_version_of_cls(
418
- latest_response_model, version_dir, self.versioned_directories
421
+ latest_response_model, version_dir, self.versioned_directories_with_head
419
422
  )
420
423
  return versioned_response_model.parse_obj(migrated_response.body)
421
424
 
@@ -455,7 +458,7 @@ class VersionBundle:
455
458
  instruction(request_info)
456
459
  if path in version_change.alter_request_by_path_instructions:
457
460
  for instruction in version_change.alter_request_by_path_instructions[path]:
458
- if method in instruction.methods:
461
+ if method in instruction.methods: # pragma: no branch # safe branch to skip
459
462
  instruction(request_info)
460
463
  request.scope["headers"] = tuple((key.encode(), value.encode()) for key, value in request_info.headers.items())
461
464
  del request._headers
@@ -507,7 +510,7 @@ class VersionBundle:
507
510
 
508
511
  if path in version_change.alter_response_by_path_instructions:
509
512
  for instruction in version_change.alter_response_by_path_instructions[path]:
510
- if method in instruction.methods:
513
+ if method in instruction.methods: # pragma: no branch # Safe branch to skip
511
514
  migrations_to_apply.append(instruction)
512
515
 
513
516
  for migration in migrations_to_apply:
@@ -723,7 +726,7 @@ class VersionBundle:
723
726
  and body_field_alias in kwargs
724
727
  ):
725
728
  raw_body: BaseModel | None = kwargs.get(body_field_alias)
726
- if raw_body is None:
729
+ if raw_body is None: # pragma: no cover # This is likely an impossible case but we would like to be safe
727
730
  body = None
728
731
  # It means we have a dict or a list instead of a full model.
729
732
  # This covers the following use case in the endpoint definition: "payload: dict = Body(None)"
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "cadwyn"
3
- version = "3.12.1"
3
+ version = "3.13.0"
4
4
  description = "Production-ready community-driven modern Stripe-like API versioning in FastAPI"
5
5
  authors = ["Stanislav Zmiev <zmievsa@gmail.com>"]
6
6
  license = "MIT"
@@ -58,6 +58,7 @@ pydantic = ">=1.0.0"
58
58
  typer = {version = ">=0.7.0", optional = true}
59
59
  better-ast-comments = "~1.2.1"
60
60
  jinja2 = ">=3.1.2"
61
+ issubclass = "^0.1.2"
61
62
 
62
63
  [tool.poetry.extras]
63
64
  cli = ["typer"]
@@ -81,10 +82,6 @@ pytest-sugar = "^1.0.0"
81
82
  [tool.poetry.scripts]
82
83
  cadwyn = "cadwyn.__main__:app"
83
84
 
84
-
85
- [tool.pytest.ini_options]
86
- asyncio_mode = "auto"
87
-
88
85
  [tool.coverage.report]
89
86
  skip_covered = true
90
87
  skip_empty = true
@@ -130,6 +127,7 @@ reportUnnecessaryTypeIgnoreComment = true
130
127
  reportMissingSuperCall = true
131
128
  reportFunctionMemberAccess = false
132
129
  reportCircularImports = true
130
+ reportInvalidTypeForm = false
133
131
 
134
132
 
135
133
  [build-system]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes