cadwyn 3.12.0__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 (38) hide show
  1. {cadwyn-3.12.0 → cadwyn-3.13.0}/PKG-INFO +2 -1
  2. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/__main__.py +1 -2
  3. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/_asts.py +3 -2
  4. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/_compat.py +9 -2
  5. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/codegen/_main.py +2 -4
  6. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/exceptions.py +12 -0
  7. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/route_generation.py +117 -52
  8. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/routing.py +4 -5
  9. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/structure/data.py +17 -17
  10. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/structure/endpoints.py +7 -3
  11. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/structure/versions.py +48 -35
  12. cadwyn-3.13.0/pyproject.toml +135 -0
  13. cadwyn-3.12.0/pyproject.toml +0 -247
  14. {cadwyn-3.12.0 → cadwyn-3.13.0}/LICENSE +0 -0
  15. {cadwyn-3.12.0 → cadwyn-3.13.0}/README.md +0 -0
  16. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/__init__.py +0 -0
  17. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/_package_utils.py +0 -0
  18. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/_utils.py +0 -0
  19. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/applications.py +0 -0
  20. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/codegen/README.md +0 -0
  21. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/codegen/__init__.py +0 -0
  22. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/codegen/_common.py +0 -0
  23. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/codegen/_plugins/__init__.py +0 -0
  24. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/codegen/_plugins/class_migrations.py +0 -0
  25. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/codegen/_plugins/class_rebuilding.py +0 -0
  26. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/codegen/_plugins/class_renaming.py +0 -0
  27. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/codegen/_plugins/import_auto_adding.py +0 -0
  28. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/codegen/_plugins/module_migrations.py +0 -0
  29. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/main.py +0 -0
  30. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/middleware.py +0 -0
  31. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/py.typed +0 -0
  32. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/static/__init__.py +0 -0
  33. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/static/docs.html +0 -0
  34. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/structure/__init__.py +0 -0
  35. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/structure/common.py +0 -0
  36. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/structure/enums.py +0 -0
  37. {cadwyn-3.12.0 → cadwyn-3.13.0}/cadwyn/structure/modules.py +0 -0
  38. {cadwyn-3.12.0 → 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.0
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)
@@ -115,8 +115,7 @@ def _get_version_bundle(possibly_version_bundle: Any) -> VersionBundle:
115
115
  @app.callback()
116
116
  def main(
117
117
  version: bool = typer.Option(None, "-V", "--version", callback=version_callback, is_eager=True),
118
- ):
119
- ...
118
+ ): ...
120
119
 
121
120
 
122
121
  if __name__ == "__main__":
@@ -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
@@ -53,8 +53,7 @@ def generate_code_for_versioned_packages(
53
53
  codegen_plugins: Sequence[CodegenPlugin] = DEFAULT_CODEGEN_PLUGINS,
54
54
  migration_plugins: Sequence[MigrationPlugin] = DEFAULT_CODEGEN_MIGRATION_PLUGINS,
55
55
  extra_context: dict[str, Any] | None = None,
56
- ):
57
- ...
56
+ ): ...
58
57
 
59
58
 
60
59
  @overload
@@ -70,8 +69,7 @@ def generate_code_for_versioned_packages(
70
69
  codegen_plugins: Sequence[CodegenPlugin] = DEFAULT_CODEGEN_PLUGINS,
71
70
  migration_plugins: Sequence[MigrationPlugin] = DEFAULT_CODEGEN_MIGRATION_PLUGINS,
72
71
  extra_context: dict[str, Any] | None = None,
73
- ):
74
- ...
72
+ ): ...
75
73
 
76
74
 
77
75
  def generate_code_for_versioned_packages(
@@ -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]:
@@ -95,8 +90,7 @@ class InternalRepresentationOf:
95
90
  def generate_versioned_routers(
96
91
  router: _R,
97
92
  versions: VersionBundle,
98
- ) -> dict[VersionDate, _R]:
99
- ...
93
+ ) -> dict[VersionDate, _R]: ...
100
94
 
101
95
 
102
96
  @overload
@@ -105,8 +99,7 @@ def generate_versioned_routers(
105
99
  router: _R,
106
100
  versions: VersionBundle,
107
101
  latest_schemas_package: ModuleType | None,
108
- ) -> dict[VersionDate, _R]:
109
- ...
102
+ ) -> dict[VersionDate, _R]: ...
110
103
 
111
104
 
112
105
  def generate_versioned_routers(
@@ -163,22 +156,15 @@ class _EndpointTransformer(Generic[_R]):
163
156
  self.parent_router
164
157
  )
165
158
  router = deepcopy(self.parent_router)
166
- router_infos: dict[VersionDate, _RouterInfo] = {}
167
- routes_with_migrated_requests = {}
168
- route_bodies_with_migrated_requests: set[type[BaseModel]] = set()
159
+ routers: dict[VersionDate, _R] = {}
160
+
169
161
  for version in self.versions:
170
162
  self.annotation_transformer.migrate_router_to_version(router, version)
171
163
 
172
- router_infos[version.value] = _RouterInfo(
173
- router,
174
- routes_with_migrated_requests,
175
- route_bodies_with_migrated_requests,
176
- )
164
+ self._validate_all_data_converters_are_applied(router, version)
165
+
166
+ routers[version.value] = router
177
167
  # 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
168
  router = deepcopy(router)
183
169
  self._apply_endpoint_changes_to_router(router, version)
184
170
 
@@ -196,21 +182,21 @@ class _EndpointTransformer(Generic[_R]):
196
182
  continue
197
183
  _add_request_and_response_params(head_route)
198
184
  copy_of_dependant = deepcopy(head_route.dependant)
199
- # Remember this: if len(body_params) == 1, then route.body_schema == route.dependant.body_params[0]
200
- if len(copy_of_dependant.body_params) == 1:
185
+
186
+ if _route_has_a_simple_body_schema(head_route):
201
187
  self._replace_internal_representation_with_the_versioned_schema(
202
188
  copy_of_dependant,
203
189
  schema_to_internal_request_body_representation,
204
190
  )
205
191
 
206
- for older_router_info in list(router_infos.values()):
207
- 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]
208
194
 
209
195
  # We know they are APIRoutes because of the check at the very beginning of the top loop.
210
196
  # I.e. Because head_route is an APIRoute, both routes are APIRoutes too
211
197
  older_route = cast(APIRoute, older_route)
212
198
  # Wait.. Why do we need this code again?
213
- if older_route.body_field is not None and len(older_route.dependant.body_params) == 1:
199
+ if older_route.body_field is not None and _route_has_a_simple_body_schema(older_route):
214
200
  template_older_body_model = self.annotation_transformer._change_version_of_annotations(
215
201
  older_route.body_field.type_,
216
202
  self.annotation_transformer.head_version_dir,
@@ -226,13 +212,99 @@ class _EndpointTransformer(Generic[_R]):
226
212
  copy_of_dependant,
227
213
  self.versions,
228
214
  )
229
- for _, router_info in router_infos.items():
230
- router_info.router.routes = [
215
+ for _, router in routers.items():
216
+ router.routes = [
231
217
  route
232
- for route in router_info.router.routes
218
+ for route in router.routes
233
219
  if not (isinstance(route, fastapi.routing.APIRoute) and _DELETED_ROUTE_TAG in route.tags)
234
220
  ]
235
- 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
236
308
 
237
309
  def _replace_internal_representation_with_the_versioned_schema(
238
310
  self,
@@ -424,8 +496,8 @@ class _AnnotationTransformer:
424
496
  self.versions = versions
425
497
  self.versions.head_schemas_package = head_schemas_package
426
498
  self.head_schemas_package = head_schemas_package
427
- self.head_version_dir = min(versions.versioned_directories) # "head" < "v0000_00_00"
428
- self.latest_version_dir = max(versions.versioned_directories) # "v2005_11_11" > "v2000_11_11"
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"
429
501
 
430
502
  # This cache is not here for speeding things up. It's for preventing the creation of copies of the same object
431
503
  # because such copies could produce weird behaviors at runtime, especially if you/fastapi do any comparisons.
@@ -539,7 +611,7 @@ class _AnnotationTransformer:
539
611
  )
540
612
  else:
541
613
  self._validate_source_file_is_located_in_template_dir(annotation, source_file)
542
- return get_another_version_of_cls(annotation, version_dir, self.versions.versioned_directories)
614
+ return get_another_version_of_cls(annotation, version_dir, self.versions.versioned_directories_with_head)
543
615
  else:
544
616
  return annotation
545
617
 
@@ -552,7 +624,7 @@ class _AnnotationTransformer:
552
624
  if (
553
625
  source_file.startswith(dir_with_versions)
554
626
  and not source_file.startswith(template_dir)
555
- 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)
556
628
  ):
557
629
  raise RouterGenerationError(
558
630
  f'"{annotation}" is not defined in "{self.head_version_dir}" even though it must be. '
@@ -727,18 +799,6 @@ def _get_route_from_func(
727
799
  return None
728
800
 
729
801
 
730
- def _get_migrated_routes_by_path(version: Version) -> dict[_EndpointPath, set[_EndpointMethod]]:
731
- request_by_path_migration_instructions = [
732
- version_change.alter_request_by_path_instructions for version_change in version.version_changes
733
- ]
734
- migrated_routes = defaultdict(set)
735
- for instruction_dict in request_by_path_migration_instructions:
736
- for path, instruction_list in instruction_dict.items():
737
- for instruction in instruction_list:
738
- migrated_routes[path] |= instruction.methods
739
- return migrated_routes
740
-
741
-
742
802
  def _copy_function(function: _T) -> _T:
743
803
  while hasattr(function, "__alt_wrapped__"):
744
804
  function = function.__alt_wrapped__
@@ -770,3 +830,8 @@ def _copy_function(function: _T) -> _T:
770
830
  del annotation_modifying_wrapper.__wrapped__
771
831
 
772
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
@@ -67,15 +67,14 @@ class _RootHeaderAPIRouter(APIRouter):
67
67
  if self.min_routes_version > request_header_value:
68
68
  # then the request version is older that the oldest route we have
69
69
  _logger.info(
70
- f"Request version {request_version} "
71
- f"is older than the oldest "
72
- f"version {self.min_routes_version.isoformat()} ",
70
+ "Request version is older than the oldest version. No route can match this version",
71
+ extra={"oldest_version": self.min_routes_version.isoformat(), "request_version": request_version},
73
72
  )
74
73
  return []
75
74
  version_chosen = self.find_closest_date_but_not_new(request_header_value)
76
75
  _logger.info(
77
- f"Partial match. The endpoint with {version_chosen} "
78
- f"version was selected for API call version {request_version}",
76
+ "Partial match. The endpoint with a lower version was selected for the API call",
77
+ extra={"version_chosen": version_chosen, "request_version": request_version},
79
78
  )
80
79
  return self.versioned_routers[version_chosen].routes
81
80
 
@@ -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,26 +97,25 @@ 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
110
112
  def convert_request_to_next_version_for(
111
113
  first_schema: type, /, *additional_schemas: type
112
- ) -> "type[staticmethod[_P, None]]":
113
- ...
114
+ ) -> "type[staticmethod[_P, None]]": ...
114
115
 
115
116
 
116
117
  @overload
117
- def convert_request_to_next_version_for(path: str, methods: list[str], /) -> "type[staticmethod[_P, None]]":
118
- ...
118
+ def convert_request_to_next_version_for(path: str, methods: list[str], /) -> "type[staticmethod[_P, None]]": ...
119
119
 
120
120
 
121
121
  def convert_request_to_next_version_for(
@@ -128,7 +128,7 @@ def convert_request_to_next_version_for(
128
128
 
129
129
  def decorator(transformer: Callable[[RequestInfo], None]) -> Any:
130
130
  if isinstance(schema_or_path, str):
131
- return AlterRequestByPathInstruction(
131
+ return _AlterRequestByPathInstruction(
132
132
  path=schema_or_path,
133
133
  methods=set(cast(list, methods_or_second_schema)),
134
134
  transformer=transformer,
@@ -138,7 +138,7 @@ def convert_request_to_next_version_for(
138
138
  schemas = (schema_or_path,)
139
139
  else:
140
140
  schemas = (schema_or_path, methods_or_second_schema, *additional_schemas)
141
- return AlterRequestBySchemaInstruction(
141
+ return _AlterRequestBySchemaInstruction(
142
142
  schemas=schemas,
143
143
  transformer=transformer,
144
144
  )
@@ -158,14 +158,15 @@ class _BaseAlterResponseInstruction(_AlterDataInstruction):
158
158
 
159
159
 
160
160
  @dataclass
161
- class AlterResponseBySchemaInstruction(_BaseAlterResponseInstruction):
161
+ class _AlterResponseBySchemaInstruction(_BaseAlterResponseInstruction):
162
162
  schemas: tuple[Any, ...]
163
163
 
164
164
 
165
165
  @dataclass
166
- class AlterResponseByPathInstruction(_BaseAlterResponseInstruction):
166
+ class _AlterResponseByPathInstruction(_BaseAlterResponseInstruction):
167
167
  path: str
168
168
  methods: set[str]
169
+ repr_name = "Response by path converter"
169
170
 
170
171
 
171
172
  @overload
@@ -174,8 +175,7 @@ def convert_response_to_previous_version_for(
174
175
  /,
175
176
  *schemas: type,
176
177
  migrate_http_errors: bool = False,
177
- ) -> "type[staticmethod[_P, None]]":
178
- ...
178
+ ) -> "type[staticmethod[_P, None]]": ...
179
179
 
180
180
 
181
181
  @overload
@@ -185,8 +185,7 @@ def convert_response_to_previous_version_for(
185
185
  /,
186
186
  *,
187
187
  migrate_http_errors: bool = False,
188
- ) -> "type[staticmethod[_P, None]]":
189
- ...
188
+ ) -> "type[staticmethod[_P, None]]": ...
190
189
 
191
190
 
192
191
  def convert_response_to_previous_version_for(
@@ -201,7 +200,7 @@ def convert_response_to_previous_version_for(
201
200
  def decorator(transformer: Callable[[ResponseInfo], None]) -> Any:
202
201
  if isinstance(schema_or_path, str):
203
202
  # The validation above checks that methods is not None
204
- return AlterResponseByPathInstruction(
203
+ return _AlterResponseByPathInstruction(
205
204
  path=schema_or_path,
206
205
  methods=set(cast(list, methods_or_second_schema)),
207
206
  transformer=transformer,
@@ -212,7 +211,7 @@ def convert_response_to_previous_version_for(
212
211
  schemas = (schema_or_path,)
213
212
  else:
214
213
  schemas = (schema_or_path, methods_or_second_schema, *additional_schemas)
215
- return AlterResponseBySchemaInstruction(
214
+ return _AlterResponseBySchemaInstruction(
216
215
  schemas=schemas,
217
216
  transformer=transformer,
218
217
  migrate_http_errors=migrate_http_errors,
@@ -223,10 +222,11 @@ def convert_response_to_previous_version_for(
223
222
 
224
223
  def _validate_decorator_args(
225
224
  schema_or_path: type | str, methods_or_second_schema: list[str] | type | None, additional_schemas: tuple[type, ...]
226
- ):
225
+ ) -> None:
227
226
  if isinstance(schema_or_path, str):
228
227
  if not isinstance(methods_or_second_schema, list):
229
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)
230
230
  if additional_schemas:
231
231
  raise TypeError("If path was provided as a first argument, then additional schemas cannot be added")
232
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
@@ -20,7 +20,7 @@ from fastapi.concurrency import run_in_threadpool
20
20
  from fastapi.dependencies.models import Dependant
21
21
  from fastapi.dependencies.utils import solve_dependencies
22
22
  from fastapi.exceptions import RequestValidationError
23
- from fastapi.responses import FileResponse, StreamingResponse
23
+ from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
24
24
  from fastapi.routing import APIRoute, _prepare_response_content
25
25
  from pydantic import BaseModel
26
26
  from starlette._utils import is_async_callable
@@ -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[
78
- dict[type[BaseModel], list[AlterRequestBySchemaInstruction]]
79
- ] = 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
77
+ alter_request_by_schema_instructions: ClassVar[dict[type[BaseModel], list[_AlterRequestBySchemaInstruction]]] = (
78
+ Sentinel
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
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",
@@ -245,8 +245,7 @@ class VersionBundle:
245
245
  *other_versions: Version,
246
246
  api_version_var: APIVersionVarType | None = None,
247
247
  head_schemas_package: ModuleType | None = None,
248
- ) -> None:
249
- ...
248
+ ) -> None: ...
250
249
 
251
250
  @overload
252
251
  @deprecated("Pass head_version_package instead of latest_schemas_package.")
@@ -257,8 +256,7 @@ class VersionBundle:
257
256
  *other_versions: Version,
258
257
  api_version_var: APIVersionVarType | None = None,
259
258
  latest_schemas_package: ModuleType | None = None,
260
- ) -> None:
261
- ...
259
+ ) -> None: ...
262
260
 
263
261
  def __init__(
264
262
  self,
@@ -387,7 +385,7 @@ class VersionBundle:
387
385
  }
388
386
 
389
387
  @functools.cached_property
390
- def versioned_directories(self) -> tuple[Path, ...]:
388
+ def versioned_directories_with_head(self) -> tuple[Path, ...]:
391
389
  if self.head_schemas_package is None:
392
390
  raise CadwynError(
393
391
  f"You cannot call 'VersionBundle.{self.migrate_response_body.__name__}' because it has no access to "
@@ -399,6 +397,10 @@ class VersionBundle:
399
397
  + [get_version_dir_path(self.head_schemas_package, version.value) for version in self]
400
398
  )
401
399
 
400
+ @functools.cached_property
401
+ def versioned_directories_without_head(self) -> tuple[Path, ...]:
402
+ return self.versioned_directories_with_head[1:]
403
+
402
404
  def migrate_response_body(self, latest_response_model: type[BaseModel], *, latest_body: Any, version: VersionDate):
403
405
  """Convert the data to a specific version by applying all version changes from latest until that version
404
406
  in reverse order and wrapping the result in the correct version of latest_response_model.
@@ -413,11 +415,10 @@ class VersionBundle:
413
415
  )
414
416
 
415
417
  version = self._get_closest_lesser_version(version)
416
- # + 1 comes from latest also being in the versioned_directories list
417
- version_dir = self.versioned_directories[self.version_dates.index(version) + 1]
418
+ version_dir = self.versioned_directories_without_head[self.version_dates.index(version)]
418
419
 
419
420
  versioned_response_model: type[BaseModel] = get_another_version_of_cls(
420
- latest_response_model, version_dir, self.versioned_directories
421
+ latest_response_model, version_dir, self.versioned_directories_with_head
421
422
  )
422
423
  return versioned_response_model.parse_obj(migrated_response.body)
423
424
 
@@ -457,7 +458,7 @@ class VersionBundle:
457
458
  instruction(request_info)
458
459
  if path in version_change.alter_request_by_path_instructions:
459
460
  for instruction in version_change.alter_request_by_path_instructions[path]:
460
- if method in instruction.methods:
461
+ if method in instruction.methods: # pragma: no branch # safe branch to skip
461
462
  instruction(request_info)
462
463
  request.scope["headers"] = tuple((key.encode(), value.encode()) for key, value in request_info.headers.items())
463
464
  del request._headers
@@ -509,7 +510,7 @@ class VersionBundle:
509
510
 
510
511
  if path in version_change.alter_response_by_path_instructions:
511
512
  for instruction in version_change.alter_response_by_path_instructions[path]:
512
- if method in instruction.methods:
513
+ if method in instruction.methods: # pragma: no branch # Safe branch to skip
513
514
  migrations_to_apply.append(instruction)
514
515
 
515
516
  for migration in migrations_to_apply:
@@ -620,7 +621,10 @@ class VersionBundle:
620
621
  if isinstance(response_or_response_body, StreamingResponse | FileResponse):
621
622
  body = None
622
623
  elif response_or_response_body.body:
623
- body = json.loads(response_or_response_body.body)
624
+ if isinstance(response_or_response_body, JSONResponse) or raised_exception is not None:
625
+ body = json.loads(response_or_response_body.body)
626
+ else:
627
+ body = response_or_response_body.body.decode(response_or_response_body.charset)
624
628
  else:
625
629
  body = None
626
630
  # TODO (https://github.com/zmievsa/cadwyn/issues/51): Only do this if there are migrations
@@ -666,16 +670,25 @@ class VersionBundle:
666
670
  # that do not have it. We don't support it too.
667
671
  if response_info.body is not None and hasattr(response_info._response, "body"):
668
672
  # TODO (https://github.com/zmievsa/cadwyn/issues/51): Only do this if there are migrations
669
- response_info._response.body = json.dumps(response_info.body).encode()
673
+ if isinstance(response_info.body, str):
674
+ response_info._response.body = response_info.body.encode(response_info._response.charset)
675
+ else:
676
+ response_info._response.body = json.dumps(
677
+ response_info.body,
678
+ ensure_ascii=False,
679
+ allow_nan=False,
680
+ indent=None,
681
+ separators=(",", ":"),
682
+ ).encode("utf-8")
683
+ # It makes sense to re-calculate content length because the previously calculated one
684
+ # might slightly differ. If it differs -- uvicorn will break.
685
+ response_info.headers["content-length"] = str(len(response_info._response.body))
670
686
 
671
687
  if raised_exception is not None and response_info.status_code >= 400:
672
688
  if isinstance(response_info.body, dict) and "detail" in response_info.body:
673
689
  detail = response_info.body["detail"]
674
690
  else:
675
691
  detail = response_info.body
676
- # It makes more sense to re-calculate content length because the previously calculated one
677
- # might slightly differ.
678
- del response_info.headers["content-length"]
679
692
 
680
693
  raise HTTPException(
681
694
  status_code=response_info.status_code,
@@ -713,7 +726,7 @@ class VersionBundle:
713
726
  and body_field_alias in kwargs
714
727
  ):
715
728
  raw_body: BaseModel | None = kwargs.get(body_field_alias)
716
- 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
717
730
  body = None
718
731
  # It means we have a dict or a list instead of a full model.
719
732
  # This covers the following use case in the endpoint definition: "payload: dict = Body(None)"
@@ -0,0 +1,135 @@
1
+ [tool.poetry]
2
+ name = "cadwyn"
3
+ version = "3.13.0"
4
+ description = "Production-ready community-driven modern Stripe-like API versioning in FastAPI"
5
+ authors = ["Stanislav Zmiev <zmievsa@gmail.com>"]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+ repository = "https://github.com/zmievsa/cadwyn"
9
+ documentation = "https://docs.cadwyn.dev"
10
+ keywords = [
11
+ "python",
12
+ "api",
13
+ "json-schema",
14
+ "stripe",
15
+ "versioning",
16
+ "code-generation",
17
+ "hints",
18
+ "api-versioning",
19
+ "pydantic",
20
+ "fastapi",
21
+ "python310",
22
+ "python311",
23
+ "python312",
24
+ ]
25
+ classifiers = [
26
+ "Intended Audience :: Information Technology",
27
+ "Intended Audience :: System Administrators",
28
+ "Operating System :: OS Independent",
29
+ "Programming Language :: Python",
30
+ "Programming Language :: Python :: 3",
31
+ "Programming Language :: Python :: 3.10",
32
+ "Programming Language :: Python :: 3.11",
33
+ "Programming Language :: Python :: 3.12",
34
+ "Topic :: Internet",
35
+ "Topic :: Software Development :: Libraries :: Application Frameworks",
36
+ "Topic :: Software Development :: Libraries :: Python Modules",
37
+ "Topic :: Software Development :: Libraries",
38
+ "Topic :: Software Development",
39
+ "Typing :: Typed",
40
+ "Development Status :: 5 - Production/Stable",
41
+ "Environment :: Web Environment",
42
+ "Framework :: AsyncIO",
43
+ "Framework :: FastAPI",
44
+ "Framework :: Pydantic",
45
+ "Intended Audience :: Developers",
46
+ "License :: OSI Approved :: MIT License",
47
+ "Topic :: Internet :: WWW/HTTP :: HTTP Servers",
48
+ "Topic :: Internet :: WWW/HTTP",
49
+ ]
50
+
51
+
52
+ [tool.poetry.dependencies]
53
+ python = "^3.10"
54
+ typing-extensions = "*"
55
+ fastapi = ">=0.110.0"
56
+ starlette = ">=0.36.3"
57
+ pydantic = ">=1.0.0"
58
+ typer = {version = ">=0.7.0", optional = true}
59
+ better-ast-comments = "~1.2.1"
60
+ jinja2 = ">=3.1.2"
61
+ issubclass = "^0.1.2"
62
+
63
+ [tool.poetry.extras]
64
+ cli = ["typer"]
65
+
66
+
67
+ [tool.poetry.group.dev.dependencies]
68
+ pytest = ">=7.2.1"
69
+ pytest-cov = ">=4.0.0"
70
+ uvicorn = "*"
71
+ devtools = "*"
72
+ pdbpp = "^0.10.3"
73
+ httpx = "*"
74
+ pytest-fixture-classes = ">=1.0.3"
75
+ dirty-equals = ">=0.6.0"
76
+ mkdocs = ">=1.5.2"
77
+ mkdocs-material = ">=9.3.1"
78
+ python-multipart = ">=0.0.6"
79
+ mkdocs-simple-hooks = ">=0.1.5"
80
+ pytest-sugar = "^1.0.0"
81
+
82
+ [tool.poetry.scripts]
83
+ cadwyn = "cadwyn.__main__:app"
84
+
85
+ [tool.coverage.report]
86
+ skip_covered = true
87
+ skip_empty = true
88
+ # Taken from https://coverage.readthedocs.io/en/7.1.0/excluding.html#advanced-exclusion
89
+ exclude_lines = [
90
+ "pragma: no cover",
91
+ "assert_never\\(",
92
+ "if self.debug:",
93
+ "if settings.DEBUG",
94
+ "raise AssertionError",
95
+ "raise NotImplementedError",
96
+ "if False:",
97
+ "assert_never",
98
+ "if 0:",
99
+ "class .*\\bProtocol\\):",
100
+ "if __name__ == .__main__.:",
101
+ "if TYPE_CHECKING:",
102
+ "@(abc\\.)?abstractmethod",
103
+ "@(typing\\.)?overload",
104
+ "__rich_repr__",
105
+ "__repr__",
106
+ ]
107
+ omit = ["./docs/plugin.py", "./site/plugin.py", "./tests/_data/_temp/**/*", "tests/tutorial/data/**/*", "scripts/*.py"]
108
+
109
+ [tool.pyright]
110
+ reportMissingImports = true
111
+ strictListInference = true
112
+ strictDictionaryInference = true
113
+ strictSetInference = true
114
+ reportPropertyTypeMismatch = true
115
+ reportImportCycles = true
116
+ reportUntypedFunctionDecorator = "warning"
117
+ reportUntypedClassDecorator = "warning"
118
+ reportUntypedBaseClass = "warning"
119
+ reportDeprecated = "warning"
120
+ reportInvalidTypeVarUse = true
121
+ reportUnnecessaryCast = true
122
+ reportUnnecessaryComparison = true
123
+ reportUnnecessaryContains = true
124
+ reportAssertAlwaysTrue = true
125
+ reportUnsupportedDunderAll = true
126
+ reportUnnecessaryTypeIgnoreComment = true
127
+ reportMissingSuperCall = true
128
+ reportFunctionMemberAccess = false
129
+ reportCircularImports = true
130
+ reportInvalidTypeForm = false
131
+
132
+
133
+ [build-system]
134
+ requires = ["poetry-core>=1.0.0"]
135
+ build-backend = "poetry.core.masonry.api"
@@ -1,247 +0,0 @@
1
- [tool.poetry]
2
- name = "cadwyn"
3
- version = "3.12.0"
4
- description = "Production-ready community-driven modern Stripe-like API versioning in FastAPI"
5
- authors = ["Stanislav Zmiev <zmievsa@gmail.com>"]
6
- license = "MIT"
7
- readme = "README.md"
8
- repository = "https://github.com/zmievsa/cadwyn"
9
- documentation = "https://docs.cadwyn.dev"
10
- keywords = [
11
- "python",
12
- "api",
13
- "json-schema",
14
- "stripe",
15
- "versioning",
16
- "code-generation",
17
- "hints",
18
- "api-versioning",
19
- "pydantic",
20
- "fastapi",
21
- "python310",
22
- "python311",
23
- "python312",
24
- ]
25
- classifiers = [
26
- "Intended Audience :: Information Technology",
27
- "Intended Audience :: System Administrators",
28
- "Operating System :: OS Independent",
29
- "Programming Language :: Python",
30
- "Programming Language :: Python :: 3",
31
- "Programming Language :: Python :: 3.10",
32
- "Programming Language :: Python :: 3.11",
33
- "Programming Language :: Python :: 3.12",
34
- "Topic :: Internet",
35
- "Topic :: Software Development :: Libraries :: Application Frameworks",
36
- "Topic :: Software Development :: Libraries :: Python Modules",
37
- "Topic :: Software Development :: Libraries",
38
- "Topic :: Software Development",
39
- "Typing :: Typed",
40
- "Development Status :: 5 - Production/Stable",
41
- "Environment :: Web Environment",
42
- "Framework :: AsyncIO",
43
- "Framework :: FastAPI",
44
- "Framework :: Pydantic",
45
- "Intended Audience :: Developers",
46
- "License :: OSI Approved :: MIT License",
47
- "Topic :: Internet :: WWW/HTTP :: HTTP Servers",
48
- "Topic :: Internet :: WWW/HTTP",
49
- ]
50
-
51
-
52
- [tool.poetry.dependencies]
53
- python = "^3.10"
54
- typing-extensions = "*"
55
- fastapi = ">=0.110.0"
56
- starlette = ">=0.36.3"
57
- pydantic = ">=1.0.0"
58
- typer = {version = ">=0.7.0", optional = true}
59
- better-ast-comments = "~1.2.1"
60
- jinja2 = ">=3.1.2"
61
-
62
- [tool.poetry.extras]
63
- cli = ["typer"]
64
-
65
-
66
- [tool.poetry.group.dev.dependencies]
67
- ruff = "*"
68
- pytest = ">=7.2.1"
69
- pytest-cov = ">=4.0.0"
70
- uvicorn = "*"
71
- devtools = "*"
72
- pdbpp = "^0.10.3"
73
- httpx = "*"
74
- pytest-fixture-classes = ">=1.0.3"
75
- pre-commit = ">=3.4.0"
76
- dirty-equals = ">=0.6.0"
77
- mkdocs = ">=1.5.2"
78
- mkdocs-material = ">=9.3.1"
79
- python-multipart = ">=0.0.6"
80
- mkdocs-simple-hooks = ">=0.1.5"
81
- pytest-sugar = "^1.0.0"
82
-
83
- [tool.poetry.scripts]
84
- cadwyn = "cadwyn.__main__:app"
85
-
86
-
87
- [tool.pytest.ini_options]
88
- asyncio_mode = "auto"
89
-
90
- [tool.coverage.report]
91
- skip_covered = true
92
- skip_empty = true
93
- # Taken from https://coverage.readthedocs.io/en/7.1.0/excluding.html#advanced-exclusion
94
- exclude_lines = [
95
- "pragma: no cover",
96
- "assert_never\\(",
97
- "if self.debug:",
98
- "if settings.DEBUG",
99
- "raise AssertionError",
100
- "raise NotImplementedError",
101
- "if False:",
102
- "assert_never",
103
- "if 0:",
104
- "class .*\\bProtocol\\):",
105
- "if __name__ == .__main__.:",
106
- "if TYPE_CHECKING:",
107
- "@(abc\\.)?abstractmethod",
108
- "@(typing\\.)?overload",
109
- "__rich_repr__",
110
- "__repr__",
111
- ]
112
- omit = ["./docs/plugin.py", "./site/plugin.py", "./tests/_data/_temp/**/*", "tests/tutorial/data/**/*", "scripts/*.py"]
113
-
114
- [tool.pyright]
115
- reportMissingImports = true
116
- strictListInference = true
117
- strictDictionaryInference = true
118
- strictSetInference = true
119
- reportPropertyTypeMismatch = true
120
- reportImportCycles = true
121
- reportUntypedFunctionDecorator = "warning"
122
- reportUntypedClassDecorator = "warning"
123
- reportUntypedBaseClass = "warning"
124
- reportDeprecated = "warning"
125
- reportInvalidTypeVarUse = true
126
- reportUnnecessaryCast = true
127
- reportUnnecessaryComparison = true
128
- reportUnnecessaryContains = true
129
- reportAssertAlwaysTrue = true
130
- reportUnsupportedDunderAll = true
131
- reportUnnecessaryTypeIgnoreComment = true
132
- reportMissingSuperCall = true
133
- reportFunctionMemberAccess = false
134
- reportCircularImports = true
135
-
136
-
137
- [tool.ruff]
138
- target-version = "py310"
139
- line-length = 120
140
- extend-exclude = ["scripts/*.py"]
141
-
142
- [tool.ruff.lint]
143
- select = [
144
- "F", # pyflakes
145
- "E", # pycodestyle errors
146
- "W", # pycodestyle warnings
147
- "C90", # mccabe
148
- "I", # isort
149
- "N", # pep8-naming
150
- "UP", # pyupgrade
151
- "YTT", # flake8-2020
152
- "S", # flake8-bandit
153
- "BLE", # flake8-blind-except
154
- "FBT003", # flake8-boolean-trap
155
- "B", # flake8-bugbear
156
- "COM", # flake8-commas
157
- "C4", # flake8-comprehensions
158
- "T10", # flake8-debugger
159
- "ISC", # flake8-implicit-str-concat
160
- "G010", # Logging statement uses warn instead of warning
161
- "G201", # Logging .exception(...) should be used instead of .error(..., exc_info=True)
162
- "G202", # Logging statement has redundant exc_info
163
- "INP", # flake8-no-pep420
164
- "PIE", # flake8-pie
165
- "T20", # flake8-print
166
- "PYI", # flake8-pyi
167
- "PT", # flake8-pytest-style
168
- "Q", # flake8-quotes
169
- "RSE", # flake8-raise
170
- "RET", # flake8-return
171
- "SIM", # flake8-simplify
172
- "TCH", # flake8-type-checking
173
- "ARG", # flake8-unused-arguments
174
- "PTH", # flake8-use-pathlib
175
- "ERA", # flake8-eradicate
176
- "PGH", # pygrep-hooks
177
- "PLC0414", # Import alias does not rename original package
178
- "PLE", # Error
179
- "PLW", # Warning
180
- "TRY", # tryceratops
181
- "FLY", # flynt
182
- "RUF", # ruff-specific rules
183
- "ANN001", # missing type annotation for arguments
184
- "ANN002", # missing type annotation for *args
185
- "ANN003", # missing type annotation for **kwargs
186
- ]
187
- unfixable = [
188
- "ERA001", # eradicate: found commented out code (can be dangerous if fixed automatically)
189
- ]
190
- ignore = [
191
- "D203", # 1 blank line required before class docstring
192
- "ARG001", # Unused first argument
193
- "ARG002", # Unused method argument
194
- "TRY003", # Avoid specifying long messages outside the exception class
195
- "TRY300", # Consider moving statement into the else clause
196
- "PT019", # Fixture without value is injected as parameter, use @pytest.mark.usefixtures instead
197
- # (usefixtures doesn't play well with IDE features such as auto-renaming)
198
- "SIM108", # Use ternary operator instead of if-else block (ternaries lie to coverage)
199
- "RET505", # Unnecessary `else` after `return` statement
200
- "N805", # First argument of a method should be named `self` (pydantic validators don't play well with this)
201
- "UP007", # Use `X | Y` for type annotations (we need this for testing and our runtime logic)
202
-
203
- # The following rules are recommended to be ignored by ruff when using ruff format
204
- "ISC001", # Checks for implicitly concatenated strings on a single line
205
- "ISC002", # Checks for implicitly concatenated strings that span multiple lines
206
- "W191", # Checks for indentation that uses tabs
207
- "E111", # Checks for indentation with a non-multiple of 4 spaces
208
- "E114", # Checks for indentation of comments with a non-multiple of 4 spaces
209
- "E117", # Checks for over-indented code
210
- "D206", # Checks for docstrings that are indented with tabs
211
- "D300", # Checks for docstrings that use '''single quotes''' instead of """double quotes"""
212
- "Q000", # Checks for inline strings that use single quotes or double quotes
213
- "Q001", # Checks for multiline strings that use single quotes or double quotes
214
- "Q002", # Checks for docstrings that use single quotes or double quotes
215
- "Q003", # Checks for strings that include escaped quotes
216
- "COM812", # Checks for the absence of trailing commas
217
- "COM819", # Checks for the presence of prohibited trailing commas
218
- "RET506", # Unnecessary `elif` after `raise` statement
219
- ]
220
-
221
-
222
- [tool.ruff.lint.per-file-ignores]
223
- "tests/*" = [
224
- "S", # ignore bandit security issues in tests
225
- "B018", # ignore useless expressions in tests
226
- "PT012", # ignore complex with pytest.raises clauses
227
- "RUF012", # ignore mutable class attributes ClassVar typehint requirement
228
- "ANN001", # Missing type annotation for function argument
229
- "ANN002", # Missing type annotation for *args
230
- "ANN003", # Missing type annotation for **kwargs
231
- "PGH003", # Use specific rule codes when ignoring type issues
232
- "B008", # Do not perform function call in argument defaults
233
- ]
234
- "cadwyn/_utils.py" = [
235
- "ERA001", # Found commented-out code (it's not actually commented out. It's just comments)
236
- ]
237
-
238
- [tool.ruff.lint.mccabe]
239
- max-complexity = 14
240
-
241
- [tool.ruff.format]
242
- quote-style = "double"
243
- indent-style = "space"
244
-
245
- [build-system]
246
- requires = ["poetry-core>=1.0.0"]
247
- build-backend = "poetry.core.masonry.api"
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