diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 023d1233..06b91b3b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ repos: - id: python-check-blanket-noqa - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.3.2 + rev: v0.3.3 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/CHANGELOG.md b/CHANGELOG.md index d83ef1ca..94ad31a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ Please follow [the Keep a Changelog standard](https://keepachangelog.com/en/1.0. ## [Unreleased] +## [3.13.0] + +### Added + +* Validation for path converters to make sure that impossible HTTP methods cannot be used +* Validation for both path and schema converters to make sure that they are used at some point. Otherwise, router generation will raise an error + ## [3.12.1] ### Fixed diff --git a/cadwyn/_asts.py b/cadwyn/_asts.py index fd3682bf..149971e2 100644 --- a/cadwyn/_asts.py +++ b/cadwyn/_asts.py @@ -10,6 +10,7 @@ TYPE_CHECKING, Any, List, + cast, get_args, get_origin, ) @@ -32,7 +33,7 @@ # A parent type of typing._GenericAlias -_BaseGenericAlias = type(List[int]).mro()[1] # noqa: UP006 +_BaseGenericAlias = cast(type, type(List[int])).mro()[1] # noqa: UP006 # type(list[int]) and type(List[int]) are different which is why we have to do this. # 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: return PlainRepr("auto()") -def transform_union(value: UnionType) -> Any: # pyright: ignore[reportInvalidTypeForm] +def transform_union(value: UnionType) -> Any: return "typing.Union[" + (", ".join(get_fancy_repr(a) for a in get_args(value))) + "]" diff --git a/cadwyn/_compat.py b/cadwyn/_compat.py index 504c9cd1..8f249d96 100644 --- a/cadwyn/_compat.py +++ b/cadwyn/_compat.py @@ -61,7 +61,7 @@ class PydanticFieldWrapper: annotation: Any - init_model_field: dataclasses.InitVar[ModelField] # pyright: ignore[reportInvalidTypeForm] + init_model_field: dataclasses.InitVar[ModelField] field_info: FieldInfo = dataclasses.field(init=False) annotation_ast: ast.expr | None = None @@ -69,7 +69,7 @@ class PydanticFieldWrapper: # the value_ast is "None" and "Field(default=None)" respectively value_ast: ast.expr | None = None - def __post_init__(self, init_model_field: ModelField): # pyright: ignore[reportInvalidTypeForm] + def __post_init__(self, init_model_field: ModelField): if isinstance(init_model_field, FieldInfo): self.field_info = init_model_field else: @@ -111,6 +111,13 @@ def passed_field_attributes(self): return attributes | extras +def get_annotation_from_model_field(model: ModelField) -> Any: + if PYDANTIC_V2: + return model.field_info.annotation + else: + return model.annotation + + def model_fields(model: type[BaseModel]) -> dict[str, FieldInfo]: if PYDANTIC_V2: return model.model_fields diff --git a/cadwyn/exceptions.py b/cadwyn/exceptions.py index f071d682..f2e3beb0 100644 --- a/cadwyn/exceptions.py +++ b/cadwyn/exceptions.py @@ -46,6 +46,18 @@ class RouterPathParamsModifiedError(RouterGenerationError): pass +class RouteResponseBySchemaConverterDoesNotApplyToAnythingError(RouterGenerationError): + pass + + +class RouteRequestBySchemaConverterDoesNotApplyToAnythingError(RouterGenerationError): + pass + + +class RouteByPathConverterDoesNotApplyToAnythingError(RouterGenerationError): + pass + + class RouteAlreadyExistsError(RouterGenerationError): def __init__(self, *routes: APIRoute): self.routes = routes diff --git a/cadwyn/route_generation.py b/cadwyn/route_generation.py index 4db345a4..c01172c0 100644 --- a/cadwyn/route_generation.py +++ b/cadwyn/route_generation.py @@ -15,7 +15,6 @@ Annotated, Any, Generic, - TypeAlias, TypeVar, _BaseGenericAlias, # pyright: ignore[reportAttributeAccessIssue] cast, @@ -29,6 +28,7 @@ import fastapi.routing import fastapi.security.base import fastapi.utils +from fastapi import APIRouter from fastapi._compat import ModelField as FastAPIModelField from fastapi._compat import create_body_model from fastapi.dependencies.models import Dependant @@ -39,6 +39,7 @@ ) from fastapi.params import Depends from fastapi.routing import APIRoute +from issubclass import issubclass as lenient_issubclass from pydantic import BaseModel from starlette.routing import ( BaseRoute, @@ -46,12 +47,15 @@ ) from typing_extensions import Self, assert_never, deprecated -from cadwyn._compat import model_fields, rebuild_fastapi_body_param +from cadwyn._compat import get_annotation_from_model_field, model_fields, rebuild_fastapi_body_param from cadwyn._package_utils import get_version_dir_path from cadwyn._utils import Sentinel, UnionType, get_another_version_of_cls from cadwyn.exceptions import ( CadwynError, RouteAlreadyExistsError, + RouteByPathConverterDoesNotApplyToAnythingError, + RouteRequestBySchemaConverterDoesNotApplyToAnythingError, + RouteResponseBySchemaConverterDoesNotApplyToAnythingError, RouterGenerationError, RouterPathParamsModifiedError, ) @@ -68,8 +72,6 @@ _R = TypeVar("_R", bound=fastapi.routing.APIRouter) # This is a hack we do because we can't guarantee how the user will use the router. _DELETED_ROUTE_TAG = "_CADWYN_DELETED_ROUTE" -_EndpointPath: TypeAlias = str -_EndpointMethod: TypeAlias = str @dataclass(slots=True, frozen=True, eq=True) @@ -78,13 +80,6 @@ class _EndpointInfo: endpoint_methods: frozenset[str] -@dataclass(slots=True) -class _RouterInfo(Generic[_R]): - router: _R - routes_with_migrated_requests: dict[_EndpointPath, set[_EndpointMethod]] - route_bodies_with_migrated_requests: set[type[BaseModel]] - - @deprecated("It will soon be deleted. Use HeadVersion version changes instead.") class InternalRepresentationOf: def __class_getitem__(cls, original_schema: type, /) -> type[Self]: @@ -161,22 +156,15 @@ def transform(self) -> dict[VersionDate, _R]: self.parent_router ) router = deepcopy(self.parent_router) - router_infos: dict[VersionDate, _RouterInfo] = {} - routes_with_migrated_requests = {} - route_bodies_with_migrated_requests: set[type[BaseModel]] = set() + routers: dict[VersionDate, _R] = {} + for version in self.versions: self.annotation_transformer.migrate_router_to_version(router, version) - router_infos[version.value] = _RouterInfo( - router, - routes_with_migrated_requests, - route_bodies_with_migrated_requests, - ) + self._validate_all_data_converters_are_applied(router, version) + + routers[version.value] = router # Applying changes for the next version - routes_with_migrated_requests = _get_migrated_routes_by_path(version) - route_bodies_with_migrated_requests = { - schema for change in version.version_changes for schema in change.alter_request_by_schema_instructions - } router = deepcopy(router) self._apply_endpoint_changes_to_router(router, version) @@ -194,21 +182,21 @@ def transform(self) -> dict[VersionDate, _R]: continue _add_request_and_response_params(head_route) copy_of_dependant = deepcopy(head_route.dependant) - # Remember this: if len(body_params) == 1, then route.body_schema == route.dependant.body_params[0] - if len(copy_of_dependant.body_params) == 1: + + if _route_has_a_simple_body_schema(head_route): self._replace_internal_representation_with_the_versioned_schema( copy_of_dependant, schema_to_internal_request_body_representation, ) - for older_router_info in list(router_infos.values()): - older_route = older_router_info.router.routes[route_index] + for older_router in list(routers.values()): + older_route = older_router.routes[route_index] # We know they are APIRoutes because of the check at the very beginning of the top loop. # I.e. Because head_route is an APIRoute, both routes are APIRoutes too older_route = cast(APIRoute, older_route) # Wait.. Why do we need this code again? - if older_route.body_field is not None and len(older_route.dependant.body_params) == 1: + if older_route.body_field is not None and _route_has_a_simple_body_schema(older_route): template_older_body_model = self.annotation_transformer._change_version_of_annotations( older_route.body_field.type_, self.annotation_transformer.head_version_dir, @@ -224,13 +212,99 @@ def transform(self) -> dict[VersionDate, _R]: copy_of_dependant, self.versions, ) - for _, router_info in router_infos.items(): - router_info.router.routes = [ + for _, router in routers.items(): + router.routes = [ route - for route in router_info.router.routes + for route in router.routes if not (isinstance(route, fastapi.routing.APIRoute) and _DELETED_ROUTE_TAG in route.tags) ] - return {version: router_info.router for version, router_info in router_infos.items()} + return routers + + def _validate_all_data_converters_are_applied(self, router: APIRouter, version: Version): + path_to_route_methods_mapping, head_response_models, head_request_bodies = self._extract_all_routes_identifiers( + router + ) + + for version_change in version.version_changes: + for by_path_converters in [ + *version_change.alter_response_by_path_instructions.values(), + *version_change.alter_request_by_path_instructions.values(), + ]: + for by_path_converter in by_path_converters: + missing_methods = by_path_converter.methods.difference( + path_to_route_methods_mapping[by_path_converter.path] + ) + + if missing_methods: + raise RouteByPathConverterDoesNotApplyToAnythingError( + f"{by_path_converter.repr_name} " + f'"{version_change.__name__}.{by_path_converter.transformer.__name__}" ' + f"failed to find routes with the following methods: {list(missing_methods)}. " + f"This means that you are trying to apply this converter to non-existing endpoint(s). " + "Please, check whether the path and methods are correct. (hint: path must include " + "all path variables and have a name that was used in the version that this " + "VersionChange resides in)" + ) + + for by_schema_converters in version_change.alter_request_by_schema_instructions.values(): + for by_schema_converter in by_schema_converters: + missing_models = set(by_schema_converter.schemas) - head_request_bodies + if missing_models: + raise RouteRequestBySchemaConverterDoesNotApplyToAnythingError( + f"Request by body schema converter " + f'"{version_change.__name__}.{by_schema_converter.transformer.__name__}" ' + f"failed to find routes with the following body schemas: " + f"{[m.__name__ for m in missing_models]}. " + f"This means that you are trying to apply this converter to non-existing endpoint(s). " + ) + for by_schema_converters in version_change.alter_response_by_schema_instructions.values(): + for by_schema_converter in by_schema_converters: + missing_models = set(by_schema_converter.schemas) - head_response_models + if missing_models: + raise RouteResponseBySchemaConverterDoesNotApplyToAnythingError( + f"Response by response model converter " + f'"{version_change.__name__}.{by_schema_converter.transformer.__name__}" ' + f"failed to find routes with the following response models: " + f"{[m.__name__ for m in missing_models]}. " + f"This means that you are trying to apply this converter to non-existing endpoint(s). " + ) + + def _extract_all_routes_identifiers( + self, router: APIRouter + ) -> tuple[defaultdict[str, set[str]], set[Any], set[Any]]: + response_models: set[Any] = set() + request_bodies: set[Any] = set() + path_to_route_methods_mapping: dict[str, set[str]] = defaultdict(set) + + for route in router.routes: + if isinstance(route, APIRoute): + if route.response_model is not None and lenient_issubclass(route.response_model, BaseModel): + # FIXME: This is going to fail on Pydantic 1 + response_models.add(route.response_model) + # Not sure if it can ever be None when it's a simple schema. Eh, I would rather be safe than sorry + if _route_has_a_simple_body_schema(route) and route.body_field is not None: + annotation = get_annotation_from_model_field(route.body_field) + if lenient_issubclass(annotation, BaseModel): + # FIXME: This is going to fail on Pydantic 1 + request_bodies.add(annotation) + path_to_route_methods_mapping[route.path] |= route.methods + + head_response_models = { + self.annotation_transformer._change_version_of_annotations( + model, + self.versions.versioned_directories_with_head[0], + ) + for model in response_models + } + head_request_bodies = { + self.annotation_transformer._change_version_of_annotations( + body, + self.versions.versioned_directories_with_head[0], + ) + for body in request_bodies + } + + return path_to_route_methods_mapping, head_response_models, head_request_bodies def _replace_internal_representation_with_the_versioned_schema( self, @@ -422,8 +496,8 @@ def __init__(self, head_schemas_package: ModuleType, versions: VersionBundle) -> self.versions = versions self.versions.head_schemas_package = head_schemas_package self.head_schemas_package = head_schemas_package - self.head_version_dir = min(versions.versioned_directories) # "head" < "v0000_00_00" - self.latest_version_dir = max(versions.versioned_directories) # "v2005_11_11" > "v2000_11_11" + self.head_version_dir = min(versions.versioned_directories_with_head) # "head" < "v0000_00_00" + self.latest_version_dir = max(versions.versioned_directories_with_head) # "v2005_11_11" > "v2000_11_11" # This cache is not here for speeding things up. It's for preventing the creation of copies of the same object # because such copies could produce weird behaviors at runtime, especially if you/fastapi do any comparisons. @@ -537,7 +611,7 @@ def _change_version_of_type(self, annotation: type, version_dir: Path): ) else: self._validate_source_file_is_located_in_template_dir(annotation, source_file) - return get_another_version_of_cls(annotation, version_dir, self.versions.versioned_directories) + return get_another_version_of_cls(annotation, version_dir, self.versions.versioned_directories_with_head) else: return annotation @@ -550,7 +624,7 @@ def _validate_source_file_is_located_in_template_dir(self, annotation: type, sou if ( source_file.startswith(dir_with_versions) and not source_file.startswith(template_dir) - and any(source_file.startswith(str(d)) for d in self.versions.versioned_directories) + and any(source_file.startswith(str(d)) for d in self.versions.versioned_directories_with_head) ): raise RouterGenerationError( f'"{annotation}" is not defined in "{self.head_version_dir}" even though it must be. ' @@ -725,18 +799,6 @@ def _get_route_from_func( return None -def _get_migrated_routes_by_path(version: Version) -> dict[_EndpointPath, set[_EndpointMethod]]: - request_by_path_migration_instructions = [ - version_change.alter_request_by_path_instructions for version_change in version.version_changes - ] - migrated_routes = defaultdict(set) - for instruction_dict in request_by_path_migration_instructions: - for path, instruction_list in instruction_dict.items(): - for instruction in instruction_list: - migrated_routes[path] |= instruction.methods - return migrated_routes - - def _copy_function(function: _T) -> _T: while hasattr(function, "__alt_wrapped__"): function = function.__alt_wrapped__ @@ -768,3 +830,8 @@ def annotation_modifying_wrapper( del annotation_modifying_wrapper.__wrapped__ return cast(_T, annotation_modifying_wrapper) + + +def _route_has_a_simple_body_schema(route: APIRoute) -> bool: + # Remember this: if len(body_params) == 1, then route.body_schema == route.dependant.body_params[0] + return len(route.dependant.body_params) == 1 diff --git a/cadwyn/structure/data.py b/cadwyn/structure/data.py index 5b4db9d2..79b66782 100644 --- a/cadwyn/structure/data.py +++ b/cadwyn/structure/data.py @@ -8,6 +8,7 @@ from starlette.datastructures import MutableHeaders from cadwyn._utils import same_definition_as_in +from cadwyn.structure.endpoints import _validate_that_strings_are_valid_http_methods _P = ParamSpec("_P") @@ -96,14 +97,15 @@ class _BaseAlterRequestInstruction(_AlterDataInstruction): @dataclass -class AlterRequestBySchemaInstruction(_BaseAlterRequestInstruction): +class _AlterRequestBySchemaInstruction(_BaseAlterRequestInstruction): schemas: tuple[Any, ...] @dataclass -class AlterRequestByPathInstruction(_BaseAlterRequestInstruction): +class _AlterRequestByPathInstruction(_BaseAlterRequestInstruction): path: str methods: set[str] + repr_name = "Request by path converter" @overload @@ -126,7 +128,7 @@ def convert_request_to_next_version_for( def decorator(transformer: Callable[[RequestInfo], None]) -> Any: if isinstance(schema_or_path, str): - return AlterRequestByPathInstruction( + return _AlterRequestByPathInstruction( path=schema_or_path, methods=set(cast(list, methods_or_second_schema)), transformer=transformer, @@ -136,7 +138,7 @@ def decorator(transformer: Callable[[RequestInfo], None]) -> Any: schemas = (schema_or_path,) else: schemas = (schema_or_path, methods_or_second_schema, *additional_schemas) - return AlterRequestBySchemaInstruction( + return _AlterRequestBySchemaInstruction( schemas=schemas, transformer=transformer, ) @@ -156,14 +158,15 @@ class _BaseAlterResponseInstruction(_AlterDataInstruction): @dataclass -class AlterResponseBySchemaInstruction(_BaseAlterResponseInstruction): +class _AlterResponseBySchemaInstruction(_BaseAlterResponseInstruction): schemas: tuple[Any, ...] @dataclass -class AlterResponseByPathInstruction(_BaseAlterResponseInstruction): +class _AlterResponseByPathInstruction(_BaseAlterResponseInstruction): path: str methods: set[str] + repr_name = "Response by path converter" @overload @@ -197,7 +200,7 @@ def convert_response_to_previous_version_for( def decorator(transformer: Callable[[ResponseInfo], None]) -> Any: if isinstance(schema_or_path, str): # The validation above checks that methods is not None - return AlterResponseByPathInstruction( + return _AlterResponseByPathInstruction( path=schema_or_path, methods=set(cast(list, methods_or_second_schema)), transformer=transformer, @@ -208,7 +211,7 @@ def decorator(transformer: Callable[[ResponseInfo], None]) -> Any: schemas = (schema_or_path,) else: schemas = (schema_or_path, methods_or_second_schema, *additional_schemas) - return AlterResponseBySchemaInstruction( + return _AlterResponseBySchemaInstruction( schemas=schemas, transformer=transformer, migrate_http_errors=migrate_http_errors, @@ -219,10 +222,11 @@ def decorator(transformer: Callable[[ResponseInfo], None]) -> Any: def _validate_decorator_args( schema_or_path: type | str, methods_or_second_schema: list[str] | type | None, additional_schemas: tuple[type, ...] -): +) -> None: if isinstance(schema_or_path, str): if not isinstance(methods_or_second_schema, list): raise TypeError("If path was provided as a first argument, methods must be provided as a second argument") + _validate_that_strings_are_valid_http_methods(methods_or_second_schema) if additional_schemas: raise TypeError("If path was provided as a first argument, then additional schemas cannot be added") diff --git a/cadwyn/structure/endpoints.py b/cadwyn/structure/endpoints.py index 38d61317..03fc5145 100644 --- a/cadwyn/structure/endpoints.py +++ b/cadwyn/structure/endpoints.py @@ -1,4 +1,4 @@ -from collections.abc import Callable, Sequence +from collections.abc import Callable, Collection, Sequence from dataclasses import dataclass from enum import Enum from typing import Any @@ -148,6 +148,12 @@ def had( def endpoint(path: str, methods: list[str], /, *, func_name: str | None = None) -> EndpointInstructionFactory: + _validate_that_strings_are_valid_http_methods(methods) + + return EndpointInstructionFactory(path, set(methods), func_name) + + +def _validate_that_strings_are_valid_http_methods(methods: Collection[str]): invalid_methods = set(methods) - HTTP_METHODS if invalid_methods: invalid_methods = ", ".join(sorted(invalid_methods)) @@ -156,7 +162,5 @@ def endpoint(path: str, methods: list[str], /, *, func_name: str | None = None) "Please use valid HTTP methods such as GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD.", ) - return EndpointInstructionFactory(path, set(methods), func_name) - AlterEndpointSubInstruction = EndpointDidntExistInstruction | EndpointExistedInstruction | EndpointHadInstruction diff --git a/cadwyn/structure/versions.py b/cadwyn/structure/versions.py index 64a53ea0..78013aef 100644 --- a/cadwyn/structure/versions.py +++ b/cadwyn/structure/versions.py @@ -39,12 +39,12 @@ from .._utils import Sentinel from .common import Endpoint, VersionDate, VersionedModel from .data import ( - AlterRequestByPathInstruction, - AlterRequestBySchemaInstruction, - AlterResponseByPathInstruction, - AlterResponseBySchemaInstruction, RequestInfo, ResponseInfo, + _AlterRequestByPathInstruction, + _AlterRequestBySchemaInstruction, + _AlterResponseByPathInstruction, + _AlterResponseBySchemaInstruction, _BaseAlterResponseInstruction, ) from .endpoints import AlterEndpointSubInstruction @@ -74,12 +74,12 @@ class VersionChange: alter_enum_instructions: ClassVar[list[AlterEnumSubInstruction]] = Sentinel alter_module_instructions: ClassVar[list[AlterModuleInstruction]] = Sentinel alter_endpoint_instructions: ClassVar[list[AlterEndpointSubInstruction]] = Sentinel - alter_request_by_schema_instructions: ClassVar[dict[type[BaseModel], list[AlterRequestBySchemaInstruction]]] = ( + alter_request_by_schema_instructions: ClassVar[dict[type[BaseModel], list[_AlterRequestBySchemaInstruction]]] = ( Sentinel ) - alter_request_by_path_instructions: ClassVar[dict[str, list[AlterRequestByPathInstruction]]] = Sentinel - alter_response_by_schema_instructions: ClassVar[dict[type, list[AlterResponseBySchemaInstruction]]] = Sentinel - alter_response_by_path_instructions: ClassVar[dict[str, list[AlterResponseByPathInstruction]]] = Sentinel + alter_request_by_path_instructions: ClassVar[dict[str, list[_AlterRequestByPathInstruction]]] = Sentinel + alter_response_by_schema_instructions: ClassVar[dict[type, list[_AlterResponseBySchemaInstruction]]] = Sentinel + alter_response_by_path_instructions: ClassVar[dict[str, list[_AlterResponseByPathInstruction]]] = Sentinel _bound_version_bundle: "VersionBundle | None" def __init_subclass__(cls, _abstract: bool = False) -> None: @@ -96,15 +96,15 @@ def __init_subclass__(cls, _abstract: bool = False) -> None: @classmethod def _extract_body_instructions_into_correct_containers(cls): for instruction in cls.__dict__.values(): - if isinstance(instruction, AlterRequestBySchemaInstruction): + if isinstance(instruction, _AlterRequestBySchemaInstruction): for schema in instruction.schemas: cls.alter_request_by_schema_instructions[schema].append(instruction) - elif isinstance(instruction, AlterRequestByPathInstruction): + elif isinstance(instruction, _AlterRequestByPathInstruction): cls.alter_request_by_path_instructions[instruction.path].append(instruction) - elif isinstance(instruction, AlterResponseBySchemaInstruction): + elif isinstance(instruction, _AlterResponseBySchemaInstruction): for schema in instruction.schemas: cls.alter_response_by_schema_instructions[schema].append(instruction) - elif isinstance(instruction, AlterResponseByPathInstruction): + elif isinstance(instruction, _AlterResponseByPathInstruction): cls.alter_response_by_path_instructions[instruction.path].append(instruction) @classmethod @@ -154,10 +154,10 @@ def _validate_subclass(cls): for attr_name, attr_value in cls.__dict__.items(): if not isinstance( attr_value, - AlterRequestBySchemaInstruction - | AlterRequestByPathInstruction - | AlterResponseBySchemaInstruction - | AlterResponseByPathInstruction, + _AlterRequestBySchemaInstruction + | _AlterRequestByPathInstruction + | _AlterResponseBySchemaInstruction + | _AlterResponseByPathInstruction, ) and attr_name not in { "description", "side_effects", @@ -385,7 +385,7 @@ def versioned_modules(self) -> dict[IdentifierPythonPath, ModuleType]: } @functools.cached_property - def versioned_directories(self) -> tuple[Path, ...]: + def versioned_directories_with_head(self) -> tuple[Path, ...]: if self.head_schemas_package is None: raise CadwynError( f"You cannot call 'VersionBundle.{self.migrate_response_body.__name__}' because it has no access to " @@ -397,6 +397,10 @@ def versioned_directories(self) -> tuple[Path, ...]: + [get_version_dir_path(self.head_schemas_package, version.value) for version in self] ) + @functools.cached_property + def versioned_directories_without_head(self) -> tuple[Path, ...]: + return self.versioned_directories_with_head[1:] + def migrate_response_body(self, latest_response_model: type[BaseModel], *, latest_body: Any, version: VersionDate): """Convert the data to a specific version by applying all version changes from latest until that version in reverse order and wrapping the result in the correct version of latest_response_model. @@ -411,11 +415,10 @@ def migrate_response_body(self, latest_response_model: type[BaseModel], *, lates ) version = self._get_closest_lesser_version(version) - # + 1 comes from latest also being in the versioned_directories list - version_dir = self.versioned_directories[self.version_dates.index(version) + 1] + version_dir = self.versioned_directories_without_head[self.version_dates.index(version)] versioned_response_model: type[BaseModel] = get_another_version_of_cls( - latest_response_model, version_dir, self.versioned_directories + latest_response_model, version_dir, self.versioned_directories_with_head ) return versioned_response_model.parse_obj(migrated_response.body) @@ -455,7 +458,7 @@ async def _migrate_request( instruction(request_info) if path in version_change.alter_request_by_path_instructions: for instruction in version_change.alter_request_by_path_instructions[path]: - if method in instruction.methods: + if method in instruction.methods: # pragma: no branch # safe branch to skip instruction(request_info) request.scope["headers"] = tuple((key.encode(), value.encode()) for key, value in request_info.headers.items()) del request._headers @@ -507,7 +510,7 @@ def _migrate_response( if path in version_change.alter_response_by_path_instructions: for instruction in version_change.alter_response_by_path_instructions[path]: - if method in instruction.methods: + if method in instruction.methods: # pragma: no branch # Safe branch to skip migrations_to_apply.append(instruction) for migration in migrations_to_apply: @@ -723,7 +726,7 @@ async def _convert_endpoint_kwargs_to_version( and body_field_alias in kwargs ): raw_body: BaseModel | None = kwargs.get(body_field_alias) - if raw_body is None: + if raw_body is None: # pragma: no cover # This is likely an impossible case but we would like to be safe body = None # It means we have a dict or a list instead of a full model. # This covers the following use case in the endpoint definition: "payload: dict = Body(None)" diff --git a/poetry.lock b/poetry.lock index b6307043..29c2a2e5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -106,17 +106,6 @@ files = [ {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, ] -[[package]] -name = "cfgv" -version = "3.4.0" -description = "Validate configuration and produce human readable error messages." -optional = false -python-versions = ">=3.8" -files = [ - {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, - {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, -] - [[package]] name = "charset-normalizer" version = "3.3.2" @@ -341,17 +330,6 @@ pytz = ">=2021.3" [package.extras] pydantic = ["pydantic (>=2.4.2)"] -[[package]] -name = "distlib" -version = "0.3.8" -description = "Distribution utilities" -optional = false -python-versions = "*" -files = [ - {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, - {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, -] - [[package]] name = "exceptiongroup" version = "1.2.0" @@ -414,22 +392,6 @@ typing-extensions = ">=4.8.0" [package.extras] all = ["email-validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] -[[package]] -name = "filelock" -version = "3.13.1" -description = "A platform independent file lock." -optional = false -python-versions = ">=3.8" -files = [ - {file = "filelock-3.13.1-py3-none-any.whl", hash = "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c"}, - {file = "filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e"}, -] - -[package.extras] -docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.24)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] -typing = ["typing-extensions (>=4.8)"] - [[package]] name = "ghp-import" version = "2.1.0" @@ -503,20 +465,6 @@ cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] -[[package]] -name = "identify" -version = "2.5.35" -description = "File identification library for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "identify-2.5.35-py2.py3-none-any.whl", hash = "sha256:c4de0081837b211594f8e877a6b4fad7ca32bbfc1a9307fdd61c28bfe923f13e"}, - {file = "identify-2.5.35.tar.gz", hash = "sha256:10a7ca245cfcd756a554a7288159f72ff105ad233c7c4b9c6f0f4d108f5f6791"}, -] - -[package.extras] -license = ["ukkonen"] - [[package]] name = "idna" version = "3.6" @@ -539,6 +487,17 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "issubclass" +version = "0.1.2" +description = "issubclass() builtin that doesn't raise TypeError when arguments are not classes" +optional = false +python-versions = ">=3.8" +files = [ + {file = "issubclass-0.1.2-py3-none-any.whl", hash = "sha256:ea54b6b27526cf1be49a4bc15d713bef0b480e0230482626a37cf21529da3864"}, + {file = "issubclass-0.1.2.tar.gz", hash = "sha256:740dabca95adbd25442d1dd616ed455046ab9551c38514758dc291f431fded35"}, +] + [[package]] name = "jinja2" version = "3.1.3" @@ -738,20 +697,6 @@ mkdocs = ">=1.2" [package.extras] test = ["pytest (>=4.0)", "pytest-cov"] -[[package]] -name = "nodeenv" -version = "1.8.0" -description = "Node.js virtual environment builder" -optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" -files = [ - {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, - {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, -] - -[package.dependencies] -setuptools = "*" - [[package]] name = "packaging" version = "23.2" @@ -834,24 +779,6 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] -[[package]] -name = "pre-commit" -version = "3.6.2" -description = "A framework for managing and maintaining multi-language pre-commit hooks." -optional = false -python-versions = ">=3.9" -files = [ - {file = "pre_commit-3.6.2-py2.py3-none-any.whl", hash = "sha256:ba637c2d7a670c10daedc059f5c49b5bd0aadbccfcd7ec15592cf9665117532c"}, - {file = "pre_commit-3.6.2.tar.gz", hash = "sha256:c3ef34f463045c88658c5b99f38c1e297abdcc0ff13f98d3370055fbbfabc67e"}, -] - -[package.dependencies] -cfgv = ">=2.0.0" -identify = ">=1.0.0" -nodeenv = ">=0.11.1" -pyyaml = ">=5.1" -virtualenv = ">=20.10.0" - [[package]] name = "pydantic" version = "2.6.3" @@ -1325,48 +1252,6 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] -[[package]] -name = "ruff" -version = "0.2.2" -description = "An extremely fast Python linter and code formatter, written in Rust." -optional = false -python-versions = ">=3.7" -files = [ - {file = "ruff-0.2.2-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:0a9efb032855ffb3c21f6405751d5e147b0c6b631e3ca3f6b20f917572b97eb6"}, - {file = "ruff-0.2.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d450b7fbff85913f866a5384d8912710936e2b96da74541c82c1b458472ddb39"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecd46e3106850a5c26aee114e562c329f9a1fbe9e4821b008c4404f64ff9ce73"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e22676a5b875bd72acd3d11d5fa9075d3a5f53b877fe7b4793e4673499318ba"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1695700d1e25a99d28f7a1636d85bafcc5030bba9d0578c0781ba1790dbcf51c"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b0c232af3d0bd8f521806223723456ffebf8e323bd1e4e82b0befb20ba18388e"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f63d96494eeec2fc70d909393bcd76c69f35334cdbd9e20d089fb3f0640216ca"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a61ea0ff048e06de273b2e45bd72629f470f5da8f71daf09fe481278b175001"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e1439c8f407e4f356470e54cdecdca1bd5439a0673792dbe34a2b0a551a2fe3"}, - {file = "ruff-0.2.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:940de32dc8853eba0f67f7198b3e79bc6ba95c2edbfdfac2144c8235114d6726"}, - {file = "ruff-0.2.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0c126da55c38dd917621552ab430213bdb3273bb10ddb67bc4b761989210eb6e"}, - {file = "ruff-0.2.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3b65494f7e4bed2e74110dac1f0d17dc8e1f42faaa784e7c58a98e335ec83d7e"}, - {file = "ruff-0.2.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1ec49be4fe6ddac0503833f3ed8930528e26d1e60ad35c2446da372d16651ce9"}, - {file = "ruff-0.2.2-py3-none-win32.whl", hash = "sha256:d920499b576f6c68295bc04e7b17b6544d9d05f196bb3aac4358792ef6f34325"}, - {file = "ruff-0.2.2-py3-none-win_amd64.whl", hash = "sha256:cc9a91ae137d687f43a44c900e5d95e9617cb37d4c989e462980ba27039d239d"}, - {file = "ruff-0.2.2-py3-none-win_arm64.whl", hash = "sha256:c9d15fc41e6054bfc7200478720570078f0b41c9ae4f010bcc16bd6f4d1aacdd"}, - {file = "ruff-0.2.2.tar.gz", hash = "sha256:e62ed7f36b3068a30ba39193a14274cd706bc486fad521276458022f7bccb31d"}, -] - -[[package]] -name = "setuptools" -version = "69.1.1" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "setuptools-69.1.1-py3-none-any.whl", hash = "sha256:02fa291a0471b3a18b2b2481ed902af520c69e8ae0919c13da936542754b4c56"}, - {file = "setuptools-69.1.1.tar.gz", hash = "sha256:5c0806c7d9af348e6dd3777b4f4dbb42c7ad85b190104837488eab9a7c945cf8"}, -] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] - [[package]] name = "six" version = "1.16.0" @@ -1499,26 +1384,6 @@ typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} [package.extras] standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] -[[package]] -name = "virtualenv" -version = "20.25.1" -description = "Virtual Python Environment builder" -optional = false -python-versions = ">=3.7" -files = [ - {file = "virtualenv-20.25.1-py3-none-any.whl", hash = "sha256:961c026ac520bac5f69acb8ea063e8a4f071bcc9457b9c1f28f6b085c511583a"}, - {file = "virtualenv-20.25.1.tar.gz", hash = "sha256:e08e13ecdca7a0bd53798f356d5831434afa5b07b93f0abdf0797b7a06ffe197"}, -] - -[package.dependencies] -distlib = ">=0.3.7,<1" -filelock = ">=3.12.2,<4" -platformdirs = ">=3.9.1,<5" - -[package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] - [[package]] name = "watchdog" version = "4.0.0" @@ -1583,4 +1448,4 @@ cli = ["typer"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "5957fc73b9847b81c0859d723c9e8ab199df9607455e4938cf39a5274b88e7f4" +content-hash = "684d4adb9baa9f0af53670bc266e8dd80b35c3c1727c61632da13a1aba7708b7" diff --git a/pyproject.toml b/pyproject.toml index 3df5afc1..2110b3e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "cadwyn" -version = "3.12.1" +version = "3.13.0" description = "Production-ready community-driven modern Stripe-like API versioning in FastAPI" authors = ["Stanislav Zmiev "] license = "MIT" @@ -58,6 +58,7 @@ pydantic = ">=1.0.0" typer = {version = ">=0.7.0", optional = true} better-ast-comments = "~1.2.1" jinja2 = ">=3.1.2" +issubclass = "^0.1.2" [tool.poetry.extras] cli = ["typer"] @@ -81,10 +82,6 @@ pytest-sugar = "^1.0.0" [tool.poetry.scripts] cadwyn = "cadwyn.__main__:app" - -[tool.pytest.ini_options] -asyncio_mode = "auto" - [tool.coverage.report] skip_covered = true skip_empty = true @@ -130,6 +127,7 @@ reportUnnecessaryTypeIgnoreComment = true reportMissingSuperCall = true reportFunctionMemberAccess = false reportCircularImports = true +reportInvalidTypeForm = false [build-system] diff --git a/tests/conftest.py b/tests/conftest.py index f960786e..cbe9c838 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -85,7 +85,7 @@ class CreateVersionedPackages: def __call__( self, - *version_changes: type[VersionChange] | list[type[VersionChange]], + *version_changes: type[VersionChange], ) -> tuple[ModuleType, ...]: created_versions = versions(version_changes) latest = importlib.import_module(self.temp_data_package_path + ".head") @@ -211,7 +211,7 @@ class CreateLocalVersionedPackages: def __call__( self, - *version_changes: type[VersionChange] | list[type[VersionChange]], + *version_changes: type[VersionChange], codegen_plugins: Sequence[CodegenPlugin] = DEFAULT_CODEGEN_PLUGINS, migration_plugins: Sequence[MigrationPlugin] = DEFAULT_CODEGEN_MIGRATION_PLUGINS, extra_context: dict[str, Any] | None = None, @@ -316,7 +316,7 @@ class CreateVersionedApp: def __call__( self, - *version_changes: type[VersionChange] | list[type[VersionChange]], + *version_changes: type[VersionChange], head_version_changes: Sequence[type[VersionChange]] = (), ) -> Cadwyn: bundle = VersionBundle( @@ -334,10 +334,7 @@ def __call__( def versions(version_changes): versions = [Version(date(2000, 1, 1))] for i, change in enumerate(version_changes): - if isinstance(change, list): - versions.append(Version(date(2001 + i, 1, 1), *change)) - else: - versions.append(Version(date(2001 + i, 1, 1), change)) + versions.append(Version(date(2001 + i, 1, 1), change)) return list(reversed(versions)) @@ -348,7 +345,7 @@ class CreateVersionedClients: def __call__( self, - *version_changes: type[VersionChange] | list[type[VersionChange]], + *version_changes: type[VersionChange], head_version_changes: Sequence[type[VersionChange]] = (), ) -> dict[date, CadwynTestClient]: app = self.create_versioned_app(*version_changes, head_version_changes=head_version_changes) diff --git a/tests/test_data_migrations.py b/tests/test_data_migrations.py index 4407b7ac..679eb06d 100644 --- a/tests/test_data_migrations.py +++ b/tests/test_data_migrations.py @@ -19,7 +19,13 @@ from cadwyn import VersionedAPIRouter, generate_code_for_versioned_packages from cadwyn._compat import PYDANTIC_V2, model_dump -from cadwyn.exceptions import CadwynError, CadwynHeadRequestValidationError +from cadwyn.exceptions import ( + CadwynError, + CadwynHeadRequestValidationError, + RouteByPathConverterDoesNotApplyToAnythingError, + RouteRequestBySchemaConverterDoesNotApplyToAnythingError, + RouteResponseBySchemaConverterDoesNotApplyToAnythingError, +) from cadwyn.route_generation import InternalRepresentationOf from cadwyn.structure import ( VersionChange, @@ -191,7 +197,7 @@ def change_addresses_to_list(response: ResponseInfo) -> None: ) -@pytest.fixture(params=["no request", "with request"]) +@pytest.fixture(params=["without_request", "with request"]) def _post_endpoint_with_extra_depends( # noqa: PT005 request: pytest.FixtureRequest, router: VersionedAPIRouter, @@ -199,7 +205,7 @@ def _post_endpoint_with_extra_depends( # noqa: PT005 head_module: ModuleType, _post_endpoint: Callable[..., Coroutine[Any, Any, dict[str, Any]]], # pyright: ignore[reportRedeclaration] ): - if request.param == "no request": + if request.param == "without_request": router.routes = [] @router.post(test_path) @@ -320,26 +326,6 @@ def migrator(request: RequestInfo): with pytest.raises(CadwynHeadRequestValidationError): clients[date(2000, 1, 1)].get(test_path, headers={"my-header": "wow"}).json() - def test__optional_body_field( - self, - create_versioned_clients: CreateVersionedClients, - head_module: ModuleType, - test_path: Literal["/test"], - router: VersionedAPIRouter, - ): - @router.post(test_path) - async def route(payload: head_module.AnyRequestSchema | None = Body(None)): - return payload or {"hello": "world"} - - @convert_request_to_next_version_for(head_module.AnyRequestSchema) - def migrator(request: RequestInfo): - assert request.body is None - - clients = create_versioned_clients(version_change(migrator=migrator)) - - assert clients[date(2000, 1, 1)].post(test_path).json() == {"hello": "world"} - assert clients[date(2001, 1, 1)].post(test_path).json() == {"hello": "world"} - def test__internal_schema_specified__with_no_migrations__body_gets_parsed_to_internal_request_schema( self, create_versioned_clients: CreateVersionedClients, @@ -964,42 +950,6 @@ def test__try_migrating_to_version_above_latest__no_migrations_get_applied( == [] ) - def test__migrate_one_version_down__with_inapplicable_migrations__result_is_only_affected_by_applicable_migrations( - self, - version_change_1: type[VersionChange], - create_versioned_clients: CreateVersionedClients, - test_path: Literal["/test"], - _post_endpoint, - head_module, - ): - def bad_req(request: RequestInfo): - raise NotImplementedError("I was not supposed to be ever called! This is very bad!") - - def bad_resp(response: ResponseInfo): - raise NotImplementedError("I was not supposed to be ever called! This is very bad!") - - clients = create_versioned_clients( - [ - version_change_1, - version_change( - wrong_body_schema=convert_request_to_next_version_for(head_module.AnyResponseSchema)(bad_req), - wrong_resp_schema=convert_response_to_previous_version_for(head_module.AnyRequestSchema)( - bad_resp, - ), - wrong_req_path=convert_request_to_next_version_for("/wrong_path", ["POST"])(bad_req), - wrong_req_method=convert_request_to_next_version_for(test_path, ["GET"])(bad_req), - wrong_resp_path=convert_response_to_previous_version_for("/wrong_path", ["POST"])(bad_resp), - wrong_resp_method=convert_response_to_previous_version_for(test_path, ["GET"])(bad_resp), - ), - ], - ) - assert len(clients) == 2 - assert clients[date(2000, 1, 1)].post(test_path, json=[]).json()["body"] == [ - "request change 1", - "response change 1", - ] - assert clients[date(2001, 1, 1)].post(test_path, json=[]).json()["body"] == [] - def test__cookies_can_be_deleted_during_migrations( self, create_versioned_clients: CreateVersionedClients, @@ -1274,6 +1224,82 @@ def response_converter(response: ResponseInfo): assert resp_2001.json() == 83 +@pytest.mark.parametrize(("path", "method"), [("/NOT_test", "POST"), ("/test", "PUT")]) +def test__request_by_path_migration__for_nonexistent_endpoint_path__should_raise_error( + create_versioned_clients: CreateVersionedClients, + head_module, + router: VersionedAPIRouter, + path: str, + method: str, +): + @router.post("/test") + async def endpoint(): + raise NotImplementedError + + @convert_request_to_next_version_for(path, [method]) + def request_converter(request: RequestInfo): + raise NotImplementedError + + with pytest.raises(RouteByPathConverterDoesNotApplyToAnythingError): + create_versioned_clients(version_change(converter=request_converter)) + + +@pytest.mark.parametrize(("path", "method"), [("/NOT_test", "POST"), ("/test", "PUT")]) +def test__response_by_path_migration__for_nonexistent_endpoint_path__should_raise_error( + create_versioned_clients: CreateVersionedClients, + head_module, + router: VersionedAPIRouter, + path: str, + method: str, +): + @router.post("/test") + async def endpoint(): + raise NotImplementedError + + @convert_response_to_previous_version_for(path, [method]) + def response_converter(response: ResponseInfo): + raise NotImplementedError + + with pytest.raises(RouteByPathConverterDoesNotApplyToAnythingError): + create_versioned_clients(version_change(converter=response_converter)) + + +def test__request_by_schema_migration__for_nonexistent_schema__should_raise_error( + create_versioned_clients: CreateVersionedClients, + head_module, + router: VersionedAPIRouter, +): + @router.post("/test", response_model=head_module.AnyResponseSchema) + async def endpoint(body: head_module.AnyRequestSchema): + raise NotImplementedError + + # Using response model for requests to cause an error + @convert_request_to_next_version_for(head_module.AnyResponseSchema) + def request_converter(request: RequestInfo): + raise NotImplementedError + + with pytest.raises(RouteRequestBySchemaConverterDoesNotApplyToAnythingError): + create_versioned_clients(version_change(converter=request_converter)) + + +def test__response_by_schema_migration__for_nonexistent_schema__should_raise_error( + create_versioned_clients: CreateVersionedClients, + head_module, + router: VersionedAPIRouter, +): + @router.post("/test", response_model=head_module.AnyResponseSchema) + async def endpoint(body: head_module.AnyRequestSchema): + raise NotImplementedError + + # Using request model for responses to cause an error + @convert_response_to_previous_version_for(head_module.AnyRequestSchema) + def response_converter(response: ResponseInfo): + raise NotImplementedError + + with pytest.raises(RouteResponseBySchemaConverterDoesNotApplyToAnythingError): + create_versioned_clients(version_change(converter=response_converter)) + + def test__manual_response_migrations( head_with_empty_classes: _FakeModuleWithEmptyClasses, head_package_path: str, @@ -1352,17 +1378,17 @@ class Response_3(BaseModel): ) @router.post("/test_1") - async def endpoint_1(body: latest.Request_1) -> latest.Response_1: # pyright: ignore[reportInvalidTypeForm] + async def endpoint_1(body: latest.Request_1) -> latest.Response_1: body.i.append("test_1") return body @router.post("/test_2") - async def endpoint_2(body: latest.Request_2) -> latest.Response_2: # pyright: ignore[reportInvalidTypeForm] + async def endpoint_2(body: latest.Request_2) -> latest.Response_2: body.i.append("test_2") return body @router.post("/test_3") - async def endpoint_3(body: latest.Request_3) -> latest.Response_3: # pyright: ignore[reportInvalidTypeForm] + async def endpoint_3(body: latest.Request_3) -> latest.Response_3: body.i.append("test_3") return body diff --git a/tests/test_router_generation.py b/tests/test_router_generation.py index c0ab75b3..b8200986 100644 --- a/tests/test_router_generation.py +++ b/tests/test_router_generation.py @@ -734,7 +734,7 @@ def test__router_generation__using_non_latest_version_of_schema__should_raise_er schemas_2000, _ = create_simple_versioned_packages() @router.post("/testik") - async def testik(body: schemas_2000.SchemaWithOnePydanticField): # pyright: ignore + async def testik(body: schemas_2000.SchemaWithOnePydanticField): raise NotImplementedError with pytest.raises( @@ -763,7 +763,7 @@ def test__router_generation__using_unversioned_schema_from_versioned_base_dir__s module = importlib.import_module("tests._data.unversioned_schema_dir") @router.post("/testik") - async def testik(body: module.UnversionedSchema2): # pyright: ignore + async def testik(body: module.UnversionedSchema2): raise NotImplementedError create_versioned_app() @@ -1016,7 +1016,7 @@ class MySchema(BaseModel): other_module = importlib.import_module(temp_data_package_path + ".other_module") @router.post("/test") - async def test_with_dep1(dep: other_module.MySchema): # pyright: ignore[reportInvalidTypeForm] + async def test_with_dep1(dep: other_module.MySchema): return dep app = create_versioned_app(version_change()) diff --git a/tests/test_tutorial_with_deprecated_features/__init__.py b/tests/tutorial_with_deprecated_features/__init__.py similarity index 100% rename from tests/test_tutorial_with_deprecated_features/__init__.py rename to tests/tutorial_with_deprecated_features/__init__.py diff --git a/tests/test_tutorial_with_deprecated_features/data/__init__.py b/tests/tutorial_with_deprecated_features/data/__init__.py similarity index 100% rename from tests/test_tutorial_with_deprecated_features/data/__init__.py rename to tests/tutorial_with_deprecated_features/data/__init__.py diff --git a/tests/test_tutorial_with_deprecated_features/data/latest/__init__.py b/tests/tutorial_with_deprecated_features/data/latest/__init__.py similarity index 100% rename from tests/test_tutorial_with_deprecated_features/data/latest/__init__.py rename to tests/tutorial_with_deprecated_features/data/latest/__init__.py diff --git a/tests/test_tutorial_with_deprecated_features/data/latest/users.py b/tests/tutorial_with_deprecated_features/data/latest/users.py similarity index 100% rename from tests/test_tutorial_with_deprecated_features/data/latest/users.py rename to tests/tutorial_with_deprecated_features/data/latest/users.py diff --git a/tests/test_tutorial_with_deprecated_features/data/unversioned.py b/tests/tutorial_with_deprecated_features/data/unversioned.py similarity index 100% rename from tests/test_tutorial_with_deprecated_features/data/unversioned.py rename to tests/tutorial_with_deprecated_features/data/unversioned.py diff --git a/tests/test_tutorial_with_deprecated_features/generate_schemas.py b/tests/tutorial_with_deprecated_features/generate_schemas.py similarity index 51% rename from tests/test_tutorial_with_deprecated_features/generate_schemas.py rename to tests/tutorial_with_deprecated_features/generate_schemas.py index e1eec3f4..15cb51a8 100644 --- a/tests/test_tutorial_with_deprecated_features/generate_schemas.py +++ b/tests/tutorial_with_deprecated_features/generate_schemas.py @@ -1,6 +1,6 @@ if __name__ == "__main__": from cadwyn.codegen._main import generate_code_for_versioned_packages - from tests.test_tutorial_with_deprecated_features.data import latest - from tests.test_tutorial_with_deprecated_features.versions import version_bundle + from tests.tutorial_with_deprecated_features.data import latest + from tests.tutorial_with_deprecated_features.versions import version_bundle generate_code_for_versioned_packages(latest, version_bundle) diff --git a/tests/test_tutorial_with_deprecated_features/routes.py b/tests/tutorial_with_deprecated_features/routes.py similarity index 100% rename from tests/test_tutorial_with_deprecated_features/routes.py rename to tests/tutorial_with_deprecated_features/routes.py diff --git a/tests/test_tutorial_with_deprecated_features/run.py b/tests/tutorial_with_deprecated_features/run.py similarity index 66% rename from tests/test_tutorial_with_deprecated_features/run.py rename to tests/tutorial_with_deprecated_features/run.py index f1c0e83f..ca39927c 100644 --- a/tests/test_tutorial_with_deprecated_features/run.py +++ b/tests/tutorial_with_deprecated_features/run.py @@ -3,8 +3,8 @@ import uvicorn - from tests.test_tutorial_with_deprecated_features.routes import app, router - from tests.test_tutorial_with_deprecated_features.utils import clean_versions + from tests.tutorial_with_deprecated_features.routes import app, router + from tests.tutorial_with_deprecated_features.utils import clean_versions try: app.generate_and_include_versioned_routers(router) diff --git a/tests/test_tutorial_with_deprecated_features/test_example.py b/tests/tutorial_with_deprecated_features/test_example.py similarity index 100% rename from tests/test_tutorial_with_deprecated_features/test_example.py rename to tests/tutorial_with_deprecated_features/test_example.py diff --git a/tests/test_tutorial_with_deprecated_features/utils.py b/tests/tutorial_with_deprecated_features/utils.py similarity index 100% rename from tests/test_tutorial_with_deprecated_features/utils.py rename to tests/tutorial_with_deprecated_features/utils.py diff --git a/tests/test_tutorial_with_deprecated_features/versions/__init__.py b/tests/tutorial_with_deprecated_features/versions/__init__.py similarity index 100% rename from tests/test_tutorial_with_deprecated_features/versions/__init__.py rename to tests/tutorial_with_deprecated_features/versions/__init__.py diff --git a/tests/test_tutorial_with_deprecated_features/versions/v2001_1_1.py b/tests/tutorial_with_deprecated_features/versions/v2001_1_1.py similarity index 100% rename from tests/test_tutorial_with_deprecated_features/versions/v2001_1_1.py rename to tests/tutorial_with_deprecated_features/versions/v2001_1_1.py diff --git a/tests/test_tutorial_with_deprecated_features/versions/v2002_1_1.py b/tests/tutorial_with_deprecated_features/versions/v2002_1_1.py similarity index 100% rename from tests/test_tutorial_with_deprecated_features/versions/v2002_1_1.py rename to tests/tutorial_with_deprecated_features/versions/v2002_1_1.py diff --git a/we.py b/we.py new file mode 100644 index 00000000..e69de29b