cadwyn 3.15.10__tar.gz → 4.1.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.
- {cadwyn-3.15.10 → cadwyn-4.1.0}/PKG-INFO +4 -5
- cadwyn-4.1.0/cadwyn/__init__.py +40 -0
- cadwyn-4.1.0/cadwyn/__main__.py +78 -0
- cadwyn-4.1.0/cadwyn/_asts.py +150 -0
- cadwyn-4.1.0/cadwyn/_importer.py +31 -0
- cadwyn-4.1.0/cadwyn/_render.py +152 -0
- cadwyn-4.1.0/cadwyn/_utils.py +42 -0
- {cadwyn-3.15.10 → cadwyn-4.1.0}/cadwyn/applications.py +5 -34
- {cadwyn-3.15.10 → cadwyn-4.1.0}/cadwyn/exceptions.py +11 -3
- {cadwyn-3.15.10 → cadwyn-4.1.0}/cadwyn/middleware.py +4 -4
- {cadwyn-3.15.10 → cadwyn-4.1.0}/cadwyn/route_generation.py +22 -450
- {cadwyn-3.15.10 → cadwyn-4.1.0}/cadwyn/routing.py +2 -5
- cadwyn-4.1.0/cadwyn/schema_generation.py +946 -0
- {cadwyn-3.15.10 → cadwyn-4.1.0}/cadwyn/structure/__init__.py +0 -2
- {cadwyn-3.15.10 → cadwyn-4.1.0}/cadwyn/structure/schemas.py +50 -49
- {cadwyn-3.15.10 → cadwyn-4.1.0}/cadwyn/structure/versions.py +24 -137
- {cadwyn-3.15.10 → cadwyn-4.1.0}/pyproject.toml +5 -5
- cadwyn-3.15.10/cadwyn/__init__.py +0 -22
- cadwyn-3.15.10/cadwyn/__main__.py +0 -122
- cadwyn-3.15.10/cadwyn/_asts.py +0 -274
- cadwyn-3.15.10/cadwyn/_compat.py +0 -151
- cadwyn-3.15.10/cadwyn/_package_utils.py +0 -45
- cadwyn-3.15.10/cadwyn/_utils.py +0 -142
- cadwyn-3.15.10/cadwyn/codegen/README.md +0 -10
- cadwyn-3.15.10/cadwyn/codegen/__init__.py +0 -10
- cadwyn-3.15.10/cadwyn/codegen/_common.py +0 -168
- cadwyn-3.15.10/cadwyn/codegen/_main.py +0 -279
- cadwyn-3.15.10/cadwyn/codegen/_plugins/class_migrations.py +0 -423
- cadwyn-3.15.10/cadwyn/codegen/_plugins/class_rebuilding.py +0 -109
- cadwyn-3.15.10/cadwyn/codegen/_plugins/class_renaming.py +0 -49
- cadwyn-3.15.10/cadwyn/codegen/_plugins/import_auto_adding.py +0 -64
- cadwyn-3.15.10/cadwyn/codegen/_plugins/module_migrations.py +0 -15
- cadwyn-3.15.10/cadwyn/main.py +0 -11
- cadwyn-3.15.10/cadwyn/static/__init__.py +0 -0
- cadwyn-3.15.10/cadwyn/structure/modules.py +0 -39
- {cadwyn-3.15.10 → cadwyn-4.1.0}/LICENSE +0 -0
- {cadwyn-3.15.10 → cadwyn-4.1.0}/README.md +0 -0
- {cadwyn-3.15.10 → cadwyn-4.1.0}/cadwyn/py.typed +0 -0
- {cadwyn-3.15.10/cadwyn/codegen/_plugins → cadwyn-4.1.0/cadwyn/static}/__init__.py +0 -0
- {cadwyn-3.15.10 → cadwyn-4.1.0}/cadwyn/static/docs.html +0 -0
- {cadwyn-3.15.10 → cadwyn-4.1.0}/cadwyn/structure/common.py +0 -0
- {cadwyn-3.15.10 → cadwyn-4.1.0}/cadwyn/structure/data.py +0 -0
- {cadwyn-3.15.10 → cadwyn-4.1.0}/cadwyn/structure/endpoints.py +0 -0
- {cadwyn-3.15.10 → cadwyn-4.1.0}/cadwyn/structure/enums.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: cadwyn
|
|
3
|
-
Version:
|
|
3
|
+
Version: 4.1.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
|
|
@@ -32,12 +32,11 @@ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
|
32
32
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
33
33
|
Classifier: Typing :: Typed
|
|
34
34
|
Provides-Extra: cli
|
|
35
|
-
Requires-Dist:
|
|
36
|
-
Requires-Dist: fastapi (>=0.115.2)
|
|
35
|
+
Requires-Dist: fastapi (>=0.110.0)
|
|
37
36
|
Requires-Dist: issubclass (>=0.1.2,<0.2.0)
|
|
38
37
|
Requires-Dist: jinja2 (>=3.1.2)
|
|
39
|
-
Requires-Dist: pydantic (>=
|
|
40
|
-
Requires-Dist: starlette (>=0.
|
|
38
|
+
Requires-Dist: pydantic (>=2.0.0)
|
|
39
|
+
Requires-Dist: starlette (>=0.30.0)
|
|
41
40
|
Requires-Dist: typer (>=0.7.0) ; extra == "cli"
|
|
42
41
|
Requires-Dist: typing-extensions
|
|
43
42
|
Project-URL: Documentation, https://docs.cadwyn.dev
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import importlib.metadata
|
|
2
|
+
|
|
3
|
+
from .applications import Cadwyn
|
|
4
|
+
from .route_generation import VersionedAPIRouter, generate_versioned_routers
|
|
5
|
+
from .schema_generation import generate_versioned_models, migrate_response_body
|
|
6
|
+
from .structure import (
|
|
7
|
+
HeadVersion,
|
|
8
|
+
RequestInfo,
|
|
9
|
+
ResponseInfo,
|
|
10
|
+
Version,
|
|
11
|
+
VersionBundle,
|
|
12
|
+
VersionChange,
|
|
13
|
+
VersionChangeWithSideEffects,
|
|
14
|
+
convert_request_to_next_version_for,
|
|
15
|
+
convert_response_to_previous_version_for,
|
|
16
|
+
endpoint,
|
|
17
|
+
enum,
|
|
18
|
+
schema,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__version__ = importlib.metadata.version("cadwyn")
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Cadwyn",
|
|
24
|
+
"VersionedAPIRouter",
|
|
25
|
+
"VersionBundle",
|
|
26
|
+
"HeadVersion",
|
|
27
|
+
"Version",
|
|
28
|
+
"migrate_response_body",
|
|
29
|
+
"generate_versioned_routers",
|
|
30
|
+
"VersionChange",
|
|
31
|
+
"VersionChangeWithSideEffects",
|
|
32
|
+
"endpoint",
|
|
33
|
+
"schema",
|
|
34
|
+
"enum",
|
|
35
|
+
"convert_response_to_previous_version_for",
|
|
36
|
+
"convert_request_to_next_version_for",
|
|
37
|
+
"RequestInfo",
|
|
38
|
+
"ResponseInfo",
|
|
39
|
+
"generate_versioned_models",
|
|
40
|
+
]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from datetime import date
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.syntax import Syntax
|
|
7
|
+
|
|
8
|
+
from cadwyn._render import render_model_by_path, render_module_by_path
|
|
9
|
+
|
|
10
|
+
_CONSOLE = Console()
|
|
11
|
+
_RAW_ARG = Annotated[bool, typer.Option(help="Output code without color")]
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(
|
|
14
|
+
name="cadwyn",
|
|
15
|
+
add_completion=False,
|
|
16
|
+
help="Modern Stripe-like API versioning in FastAPI",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
render_subapp = typer.Typer(
|
|
20
|
+
name="render",
|
|
21
|
+
add_completion=False,
|
|
22
|
+
help="Render pydantic models and enums from a certainn version and output them to stdout",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
app.add_typer(render_subapp)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def version_callback(value: bool):
|
|
29
|
+
if value:
|
|
30
|
+
from . import __version__
|
|
31
|
+
|
|
32
|
+
typer.echo(f"Cadwyn {__version__}")
|
|
33
|
+
raise typer.Exit
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def output_code(code: str, raw: bool):
|
|
37
|
+
if raw:
|
|
38
|
+
typer.echo(code)
|
|
39
|
+
else:
|
|
40
|
+
_CONSOLE.print(Syntax(code, "python", line_numbers=True))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@render_subapp.command(
|
|
44
|
+
name="model",
|
|
45
|
+
help="Render a concrete pydantic model or enum from a certain version and output it to stdout",
|
|
46
|
+
short_help="Render a single model or enum",
|
|
47
|
+
)
|
|
48
|
+
def render(
|
|
49
|
+
model: Annotated[str, typer.Argument(metavar="<module>:<attribute>", help="Python path to the model to render")],
|
|
50
|
+
app: Annotated[str, typer.Option(metavar="<module>:<attribute>", help="Python path to the main Cadwyn app")],
|
|
51
|
+
version: Annotated[str, typer.Option(parser=lambda s: str(date.fromisoformat(s)), metavar="ISO-VERSION")],
|
|
52
|
+
raw: _RAW_ARG = False,
|
|
53
|
+
) -> None:
|
|
54
|
+
output_code(render_model_by_path(model, app, version), raw)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@render_subapp.command(
|
|
58
|
+
name="module",
|
|
59
|
+
help="Render all versioned models and enums within a module from a certain version and output them to stdout",
|
|
60
|
+
short_help="Render all models and enums from an entire module",
|
|
61
|
+
)
|
|
62
|
+
def render_module(
|
|
63
|
+
module: Annotated[str, typer.Argument(metavar="<module>", help="Python path to the module to render")],
|
|
64
|
+
app: Annotated[str, typer.Option(metavar="<module>:<attribute>", help="Python path to the main Cadwyn app")],
|
|
65
|
+
version: Annotated[str, typer.Option(parser=lambda s: str(date.fromisoformat(s)), metavar="ISO-VERSION")],
|
|
66
|
+
raw: _RAW_ARG = False,
|
|
67
|
+
) -> None:
|
|
68
|
+
output_code(render_module_by_path(module, app, version), raw)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.callback()
|
|
72
|
+
def main(
|
|
73
|
+
version: bool = typer.Option(None, "-V", "--version", callback=version_callback, is_eager=True),
|
|
74
|
+
): ...
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
app()
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import inspect
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from enum import Enum, auto
|
|
5
|
+
from types import GenericAlias, LambdaType, NoneType
|
|
6
|
+
from typing import ( # noqa: UP035
|
|
7
|
+
Any,
|
|
8
|
+
List,
|
|
9
|
+
cast,
|
|
10
|
+
get_args,
|
|
11
|
+
get_origin,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
import annotated_types
|
|
15
|
+
|
|
16
|
+
from cadwyn._utils import PlainRepr, UnionType
|
|
17
|
+
from cadwyn.exceptions import InvalidGenerationInstructionError
|
|
18
|
+
|
|
19
|
+
_LambdaFunctionName = (lambda: None).__name__ # pragma: no branch
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# A parent type of typing._GenericAlias
|
|
23
|
+
_BaseGenericAlias = cast(type, type(List[int])).mro()[1] # noqa: UP006
|
|
24
|
+
|
|
25
|
+
# type(list[int]) and type(List[int]) are different which is why we have to do this.
|
|
26
|
+
# Please note that this problem is much wider than just lists which is why we use typing._BaseGenericAlias
|
|
27
|
+
# instead of typing._GenericAlias.
|
|
28
|
+
GenericAliasUnion = GenericAlias | _BaseGenericAlias
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_fancy_repr(value: Any):
|
|
32
|
+
if isinstance(value, annotated_types.GroupedMetadata) and hasattr(type(value), "__dataclass_fields__"):
|
|
33
|
+
return transform_grouped_metadata(value)
|
|
34
|
+
if isinstance(value, list | tuple | set | frozenset):
|
|
35
|
+
return transform_collection(value)
|
|
36
|
+
if isinstance(value, dict):
|
|
37
|
+
return transform_dict(value)
|
|
38
|
+
if isinstance(value, GenericAliasUnion):
|
|
39
|
+
return transform_generic_alias(value)
|
|
40
|
+
if value is None or value is NoneType:
|
|
41
|
+
return transform_none(value)
|
|
42
|
+
if isinstance(value, type):
|
|
43
|
+
return transform_type(value)
|
|
44
|
+
if isinstance(value, Enum):
|
|
45
|
+
return transform_enum(value)
|
|
46
|
+
if isinstance(value, auto): # pragma: no cover # it works but we no longer use auto
|
|
47
|
+
return transform_auto(value)
|
|
48
|
+
if isinstance(value, UnionType):
|
|
49
|
+
return transform_union(value)
|
|
50
|
+
if isinstance(value, LambdaType) and _LambdaFunctionName == value.__name__:
|
|
51
|
+
return transform_lambda(value)
|
|
52
|
+
if inspect.isfunction(value):
|
|
53
|
+
return transform_function(value)
|
|
54
|
+
else:
|
|
55
|
+
return transform_other(value)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def transform_grouped_metadata(value: "annotated_types.GroupedMetadata"):
|
|
59
|
+
modified_fields = []
|
|
60
|
+
empty_obj = type(value)
|
|
61
|
+
|
|
62
|
+
for key in empty_obj.__dataclass_fields__: # pyright: ignore[reportAttributeAccessIssue]
|
|
63
|
+
if getattr(value, key) != getattr(empty_obj, key):
|
|
64
|
+
modified_fields.append((key, getattr(value, key)))
|
|
65
|
+
|
|
66
|
+
return PlainRepr(
|
|
67
|
+
value.__class__.__name__
|
|
68
|
+
+ "("
|
|
69
|
+
+ ", ".join(f"{PlainRepr(key)}={get_fancy_repr(v)}" for key, v in modified_fields)
|
|
70
|
+
+ ")",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def transform_collection(value: list | tuple | set | frozenset) -> Any:
|
|
75
|
+
return PlainRepr(value.__class__(map(get_fancy_repr, value)))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def transform_dict(value: dict) -> Any:
|
|
79
|
+
return PlainRepr(
|
|
80
|
+
value.__class__((get_fancy_repr(k), get_fancy_repr(v)) for k, v in value.items()),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def transform_generic_alias(value: GenericAliasUnion) -> Any:
|
|
85
|
+
return f"{get_fancy_repr(get_origin(value))}[{', '.join(get_fancy_repr(a) for a in get_args(value))}]"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def transform_none(_: NoneType) -> Any:
|
|
89
|
+
return "None"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def transform_type(value: type) -> Any:
|
|
93
|
+
return value.__name__
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def transform_enum(value: Enum) -> Any:
|
|
97
|
+
return PlainRepr(f"{value.__class__.__name__}.{value.name}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def transform_auto(_: auto) -> Any: # pragma: no cover # it works but we no longer use auto
|
|
101
|
+
return PlainRepr("auto()")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def transform_union(value: UnionType) -> Any:
|
|
105
|
+
return "typing.Union[" + (", ".join(get_fancy_repr(a) for a in get_args(value))) + "]"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def transform_lambda(value: LambdaType) -> Any:
|
|
109
|
+
# We clean source because getsource() can return only a part of the expression which
|
|
110
|
+
# on its own is not a valid expression such as: "\n .had(default_factory=lambda: 91)"
|
|
111
|
+
return _get_lambda_source_from_default_factory(inspect.getsource(value).strip(" \n\t."))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def transform_function(value: Callable) -> Any:
|
|
115
|
+
return PlainRepr(value.__name__)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def transform_other(value: Any) -> Any:
|
|
119
|
+
return PlainRepr(repr(value))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _get_lambda_source_from_default_factory(source: str) -> str:
|
|
123
|
+
found_lambdas: list[ast.Lambda] = []
|
|
124
|
+
|
|
125
|
+
for node in ast.walk(ast.parse(source)):
|
|
126
|
+
if isinstance(node, ast.keyword) and node.arg == "default_factory" and isinstance(node.value, ast.Lambda):
|
|
127
|
+
found_lambdas.append(node.value)
|
|
128
|
+
if len(found_lambdas) == 1:
|
|
129
|
+
return ast.unparse(found_lambdas[0])
|
|
130
|
+
# These two errors are really hard to cover. Not sure if even possible, honestly :)
|
|
131
|
+
elif len(found_lambdas) == 0: # pragma: no cover
|
|
132
|
+
raise InvalidGenerationInstructionError(
|
|
133
|
+
f"No lambda found in default_factory even though one was passed: {source}",
|
|
134
|
+
)
|
|
135
|
+
else: # pragma: no cover
|
|
136
|
+
raise InvalidGenerationInstructionError(
|
|
137
|
+
"More than one lambda found in default_factory. This is not supported.",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def pop_docstring_from_cls_body(cls_body: list[ast.stmt]) -> list[ast.stmt]:
|
|
142
|
+
if (
|
|
143
|
+
len(cls_body) > 0
|
|
144
|
+
and isinstance(cls_body[0], ast.Expr)
|
|
145
|
+
and isinstance(cls_body[0].value, ast.Constant)
|
|
146
|
+
and isinstance(cls_body[0].value.value, str)
|
|
147
|
+
):
|
|
148
|
+
return [cls_body.pop(0)]
|
|
149
|
+
else:
|
|
150
|
+
return []
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from cadwyn.exceptions import ImportFromStringError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def import_attribute_from_string(import_str: str) -> Any:
|
|
8
|
+
module_str, _, attrs_str = import_str.partition(":")
|
|
9
|
+
if not module_str or not attrs_str:
|
|
10
|
+
message = 'Import string "{import_str}" must be in format "<module>:<attribute>".'
|
|
11
|
+
raise ImportFromStringError(message.format(import_str=import_str))
|
|
12
|
+
|
|
13
|
+
module = import_module_from_string(module_str)
|
|
14
|
+
|
|
15
|
+
instance = module
|
|
16
|
+
try:
|
|
17
|
+
for attr_str in attrs_str.split("."):
|
|
18
|
+
instance = getattr(instance, attr_str)
|
|
19
|
+
except AttributeError as e:
|
|
20
|
+
raise ImportFromStringError(f'Attribute "{attrs_str}" not found in module "{module_str}".') from e
|
|
21
|
+
|
|
22
|
+
return instance
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def import_module_from_string(module_str: str):
|
|
26
|
+
try:
|
|
27
|
+
return importlib.import_module(module_str)
|
|
28
|
+
except ModuleNotFoundError as e:
|
|
29
|
+
if e.name != module_str: # pragma: no cover
|
|
30
|
+
raise e from None
|
|
31
|
+
raise ImportFromStringError(f'Could not import module "{module_str}".') from e
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import inspect
|
|
3
|
+
import textwrap
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from issubclass import issubclass as lenient_issubclass
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from cadwyn._asts import get_fancy_repr, pop_docstring_from_cls_body
|
|
12
|
+
from cadwyn.exceptions import CadwynRenderError
|
|
13
|
+
from cadwyn.schema_generation import (
|
|
14
|
+
PydanticFieldWrapper,
|
|
15
|
+
_EnumWrapper,
|
|
16
|
+
_PydanticRuntimeModelWrapper,
|
|
17
|
+
generate_versioned_models,
|
|
18
|
+
)
|
|
19
|
+
from cadwyn.structure.versions import VersionBundle, get_cls_pythonpath
|
|
20
|
+
|
|
21
|
+
from ._importer import import_attribute_from_string, import_module_from_string
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from cadwyn.applications import Cadwyn
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def render_module_by_path(module_path: str, app_path: str, version: str):
|
|
28
|
+
module = import_module_from_string(module_path)
|
|
29
|
+
app: Cadwyn = import_attribute_from_string(app_path)
|
|
30
|
+
attributes_to_alter = [
|
|
31
|
+
name
|
|
32
|
+
for name, value in module.__dict__.items()
|
|
33
|
+
if lenient_issubclass(value, Enum | BaseModel) and value.__module__ == module.__name__
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
module_ast = ast.parse(inspect.getsource(module))
|
|
38
|
+
except (OSError, SyntaxError, ValueError) as e: # pragma: no cover
|
|
39
|
+
raise CadwynRenderError(f"Failed to find the source for module {module.__name__}") from e
|
|
40
|
+
|
|
41
|
+
return ast.unparse(
|
|
42
|
+
ast.Module(
|
|
43
|
+
body=[
|
|
44
|
+
_render_model_from_ast(node, getattr(module, node.name), app.versions, version)
|
|
45
|
+
if isinstance(node, ast.ClassDef) and node.name in attributes_to_alter
|
|
46
|
+
else node
|
|
47
|
+
for node in module_ast.body
|
|
48
|
+
],
|
|
49
|
+
type_ignores=module_ast.type_ignores,
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def render_model_by_path(model_path: str, app_path: str, version: str) -> str:
|
|
55
|
+
# cadwyn render model schemas:MySchema --app=run:app --version=2000-01-01
|
|
56
|
+
model: type[BaseModel | Enum] = import_attribute_from_string(model_path)
|
|
57
|
+
app: Cadwyn = import_attribute_from_string(app_path)
|
|
58
|
+
return render_model(model, app.versions, version)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def render_model(model: type[BaseModel | Enum], versions: VersionBundle, version: str) -> str:
|
|
62
|
+
try:
|
|
63
|
+
original_cls_node = ast.parse(textwrap.dedent(inspect.getsource(model))).body[0]
|
|
64
|
+
except (OSError, SyntaxError, ValueError): # pragma: no cover
|
|
65
|
+
typer.echo(f"Failed to find the source for model {get_cls_pythonpath(model)}")
|
|
66
|
+
return f"class {model.__name__}: 'failed to find the original class source'"
|
|
67
|
+
if not isinstance(original_cls_node, ast.ClassDef):
|
|
68
|
+
raise TypeError(f"{get_cls_pythonpath(model)} is not a class")
|
|
69
|
+
|
|
70
|
+
return ast.unparse(_render_model_from_ast(original_cls_node, model, versions, version))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _render_model_from_ast(
|
|
74
|
+
model_ast: ast.ClassDef, model: type[BaseModel | Enum], versions: VersionBundle, version: str
|
|
75
|
+
):
|
|
76
|
+
versioned_models = generate_versioned_models(versions)
|
|
77
|
+
generator = versioned_models[version]
|
|
78
|
+
wrapper = generator._get_wrapper_for_model(model)
|
|
79
|
+
|
|
80
|
+
if isinstance(wrapper, _EnumWrapper):
|
|
81
|
+
return _render_enum_model(wrapper, model_ast)
|
|
82
|
+
else:
|
|
83
|
+
return _render_pydantic_model(wrapper, model_ast)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _render_enum_model(wrapper: _EnumWrapper, original_cls_node: ast.ClassDef):
|
|
87
|
+
# This is for possible schema renaming
|
|
88
|
+
original_cls_node.name = wrapper.cls.__name__
|
|
89
|
+
|
|
90
|
+
new_body = [
|
|
91
|
+
ast.Assign(
|
|
92
|
+
targets=[ast.Name(member, ctx=ast.Store())],
|
|
93
|
+
value=ast.Name(get_fancy_repr(member_value)),
|
|
94
|
+
lineno=0,
|
|
95
|
+
)
|
|
96
|
+
for member, member_value in wrapper.members.items()
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
old_body = [
|
|
100
|
+
n for n in original_cls_node.body if not isinstance(n, ast.AnnAssign | ast.Assign | ast.Pass | ast.Constant)
|
|
101
|
+
]
|
|
102
|
+
docstring = pop_docstring_from_cls_body(old_body)
|
|
103
|
+
|
|
104
|
+
original_cls_node.body = docstring + new_body + old_body
|
|
105
|
+
if not original_cls_node.body:
|
|
106
|
+
original_cls_node.body = [ast.Pass()]
|
|
107
|
+
return original_cls_node
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _render_pydantic_model(wrapper: _PydanticRuntimeModelWrapper, original_cls_node: ast.ClassDef):
|
|
111
|
+
# This is for possible schema renaming
|
|
112
|
+
original_cls_node.name = wrapper.name
|
|
113
|
+
|
|
114
|
+
field_definitions = [
|
|
115
|
+
ast.AnnAssign(
|
|
116
|
+
target=ast.Name(name),
|
|
117
|
+
annotation=ast.Name(get_fancy_repr(field.annotation)),
|
|
118
|
+
value=_generate_field_ast(field),
|
|
119
|
+
simple=1,
|
|
120
|
+
)
|
|
121
|
+
for name, field in wrapper.fields.items()
|
|
122
|
+
]
|
|
123
|
+
validator_definitions = [
|
|
124
|
+
ast.parse(textwrap.dedent(inspect.getsource(validator.func))).body[0]
|
|
125
|
+
for validator in wrapper.validators.values()
|
|
126
|
+
if not validator.is_deleted
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
old_body = [
|
|
130
|
+
n
|
|
131
|
+
for n in original_cls_node.body
|
|
132
|
+
if not (
|
|
133
|
+
isinstance(n, ast.AnnAssign | ast.Assign | ast.Pass | ast.Constant)
|
|
134
|
+
or (isinstance(n, ast.FunctionDef) and n.name in wrapper.validators)
|
|
135
|
+
)
|
|
136
|
+
]
|
|
137
|
+
docstring = pop_docstring_from_cls_body(old_body)
|
|
138
|
+
original_cls_node.body = docstring + field_definitions + validator_definitions + old_body
|
|
139
|
+
if not original_cls_node.body:
|
|
140
|
+
original_cls_node.body = [ast.Pass()]
|
|
141
|
+
return original_cls_node
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _generate_field_ast(field: PydanticFieldWrapper) -> ast.Call:
|
|
145
|
+
return ast.Call(
|
|
146
|
+
func=ast.Name("Field"),
|
|
147
|
+
args=[],
|
|
148
|
+
keywords=[
|
|
149
|
+
ast.keyword(arg=attr, value=ast.parse(get_fancy_repr(attr_value), mode="eval").body)
|
|
150
|
+
for attr, attr_value in field.passed_field_attributes.items()
|
|
151
|
+
],
|
|
152
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Any, Generic, TypeVar, Union
|
|
3
|
+
|
|
4
|
+
from pydantic._internal._decorators import unwrap_wrapped_function
|
|
5
|
+
|
|
6
|
+
Sentinel: Any = object()
|
|
7
|
+
UnionType = type(int | str) | type(Union[int, str])
|
|
8
|
+
_T = TypeVar("_T", bound=Callable)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_P_T = TypeVar("_P_T")
|
|
12
|
+
_P_R = TypeVar("_P_R")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class classproperty(Generic[_P_T, _P_R]): # noqa: N801
|
|
16
|
+
def __init__(self, func: Callable[[_P_T], _P_R]) -> None:
|
|
17
|
+
super().__init__()
|
|
18
|
+
self.func = func
|
|
19
|
+
|
|
20
|
+
def __get__(self, obj: Any, cls: _P_T) -> _P_R:
|
|
21
|
+
return self.func(cls)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PlainRepr(str):
|
|
25
|
+
"""String class where repr doesn't include quotes"""
|
|
26
|
+
|
|
27
|
+
def __repr__(self) -> str:
|
|
28
|
+
return str(self)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def same_definition_as_in(t: _T) -> Callable[[Callable], _T]:
|
|
32
|
+
def decorator(f: Callable) -> _T:
|
|
33
|
+
return f # pyright: ignore[reportReturnType]
|
|
34
|
+
|
|
35
|
+
return decorator
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def fully_unwrap_decorator(func: Callable, is_pydantic_v1_style_validator: Any):
|
|
39
|
+
func = unwrap_wrapped_function(func)
|
|
40
|
+
if is_pydantic_v1_style_validator and func.__closure__:
|
|
41
|
+
func = func.__closure__[0].cell_contents
|
|
42
|
+
return unwrap_wrapped_function(func)
|
|
@@ -4,7 +4,6 @@ from collections.abc import Callable, Coroutine, Sequence
|
|
|
4
4
|
from datetime import date
|
|
5
5
|
from logging import getLogger
|
|
6
6
|
from pathlib import Path
|
|
7
|
-
from types import ModuleType
|
|
8
7
|
from typing import Any, cast
|
|
9
8
|
|
|
10
9
|
from fastapi import APIRouter, FastAPI, HTTPException, routing
|
|
@@ -24,7 +23,7 @@ from starlette.requests import Request
|
|
|
24
23
|
from starlette.responses import JSONResponse, Response
|
|
25
24
|
from starlette.routing import BaseRoute, Route
|
|
26
25
|
from starlette.types import Lifespan
|
|
27
|
-
from typing_extensions import Self
|
|
26
|
+
from typing_extensions import Self
|
|
28
27
|
|
|
29
28
|
from cadwyn.middleware import HeaderVersioningMiddleware, _get_api_version_dependency
|
|
30
29
|
from cadwyn.route_generation import generate_versioned_routers
|
|
@@ -95,9 +94,6 @@ class Cadwyn(FastAPI):
|
|
|
95
94
|
) -> None:
|
|
96
95
|
self.versions = versions
|
|
97
96
|
# TODO: Remove argument entirely in any major version.
|
|
98
|
-
latest_schemas_package = extra.pop("latest_schemas_package", None) or self.versions.head_schemas_package
|
|
99
|
-
self.versions.head_schemas_package = latest_schemas_package
|
|
100
|
-
self._latest_schemas_package = cast(ModuleType, latest_schemas_package)
|
|
101
97
|
self._dependency_overrides_provider = FakeDependencyOverridesProvider({})
|
|
102
98
|
|
|
103
99
|
super().__init__(
|
|
@@ -175,9 +171,10 @@ class Cadwyn(FastAPI):
|
|
|
175
171
|
|
|
176
172
|
@property
|
|
177
173
|
def dependency_overrides(self) -> dict[Callable[..., Any], Callable[..., Any]]:
|
|
174
|
+
# TODO: Remove this approach as it is no longer necessary
|
|
178
175
|
# This is only necessary because we cannot send self to versioned router generator
|
|
179
|
-
# because it takes a deepcopy of the router and self.versions.head_schemas_package
|
|
180
|
-
# which
|
|
176
|
+
# because it takes a deepcopy of the router and self.versions.head_schemas_package was a module
|
|
177
|
+
# which couldn't be copied.
|
|
181
178
|
return self._dependency_overrides_provider.dependency_overrides
|
|
182
179
|
|
|
183
180
|
@dependency_overrides.setter
|
|
@@ -187,16 +184,6 @@ class Cadwyn(FastAPI):
|
|
|
187
184
|
) -> None:
|
|
188
185
|
self._dependency_overrides_provider.dependency_overrides = value
|
|
189
186
|
|
|
190
|
-
@property # pragma: no cover
|
|
191
|
-
@deprecated("It is going to be deleted in the future. Use VersionBundle.head_schemas_package instead")
|
|
192
|
-
def latest_schemas_package(self):
|
|
193
|
-
return self._latest_schemas_package
|
|
194
|
-
|
|
195
|
-
@latest_schemas_package.setter # pragma: no cover
|
|
196
|
-
@deprecated("It is going to be deleted in the future. Use VersionBundle.head_schemas_package instead")
|
|
197
|
-
def latest_schemas_package(self, value: ModuleType | None):
|
|
198
|
-
self._latest_schemas_package = value
|
|
199
|
-
|
|
200
187
|
def _add_openapi_endpoints(self, unversioned_router: APIRouter):
|
|
201
188
|
if self.openapi_url is not None:
|
|
202
189
|
unversioned_router.add_route(
|
|
@@ -233,10 +220,7 @@ class Cadwyn(FastAPI):
|
|
|
233
220
|
root_router = APIRouter(dependency_overrides_provider=self._dependency_overrides_provider)
|
|
234
221
|
for router in routers:
|
|
235
222
|
root_router.include_router(router)
|
|
236
|
-
router_versions = generate_versioned_routers(
|
|
237
|
-
root_router,
|
|
238
|
-
versions=self.versions,
|
|
239
|
-
)
|
|
223
|
+
router_versions = generate_versioned_routers(root_router, versions=self.versions)
|
|
240
224
|
for version, router in router_versions.items():
|
|
241
225
|
self.add_header_versioned_routers(router, header_value=version.isoformat())
|
|
242
226
|
|
|
@@ -358,16 +342,3 @@ class Cadwyn(FastAPI):
|
|
|
358
342
|
self.router.routes.extend(added_routes)
|
|
359
343
|
|
|
360
344
|
return added_routes
|
|
361
|
-
|
|
362
|
-
@deprecated("Use builtin FastAPI methods such as include_router instead")
|
|
363
|
-
def add_unversioned_routers(self, *routers: APIRouter):
|
|
364
|
-
for router in routers:
|
|
365
|
-
self.include_router(router)
|
|
366
|
-
|
|
367
|
-
@deprecated("Use builtin FastAPI methods such as add_api_route instead")
|
|
368
|
-
def add_unversioned_routes(self, *routes: Route):
|
|
369
|
-
router = APIRouter(routes=list(routes))
|
|
370
|
-
self.include_router(router)
|
|
371
|
-
|
|
372
|
-
@deprecated("It no longer does anything")
|
|
373
|
-
def enrich_swagger(self): ...
|
|
@@ -5,6 +5,10 @@ from typing import Any
|
|
|
5
5
|
from fastapi.routing import APIRoute
|
|
6
6
|
|
|
7
7
|
|
|
8
|
+
class CadwynRenderError(Exception):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
8
12
|
class CadwynError(Exception):
|
|
9
13
|
pass
|
|
10
14
|
|
|
@@ -26,15 +30,15 @@ class LintingError(CadwynError):
|
|
|
26
30
|
pass
|
|
27
31
|
|
|
28
32
|
|
|
29
|
-
class
|
|
33
|
+
class SchemaGenerationError(CadwynError):
|
|
30
34
|
pass
|
|
31
35
|
|
|
32
36
|
|
|
33
|
-
class ModuleIsNotAvailableAsTextError(
|
|
37
|
+
class ModuleIsNotAvailableAsTextError(SchemaGenerationError):
|
|
34
38
|
pass
|
|
35
39
|
|
|
36
40
|
|
|
37
|
-
class InvalidGenerationInstructionError(
|
|
41
|
+
class InvalidGenerationInstructionError(SchemaGenerationError):
|
|
38
42
|
pass
|
|
39
43
|
|
|
40
44
|
|
|
@@ -70,3 +74,7 @@ class CadwynStructureError(CadwynError):
|
|
|
70
74
|
|
|
71
75
|
class ModuleIsNotVersionedError(ValueError):
|
|
72
76
|
pass
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ImportFromStringError(CadwynError):
|
|
80
|
+
pass
|
|
@@ -64,11 +64,11 @@ class HeaderVersioningMiddleware(BaseHTTPMiddleware):
|
|
|
64
64
|
request=request,
|
|
65
65
|
dependant=self.version_header_validation_dependant,
|
|
66
66
|
async_exit_stack=async_exit_stack,
|
|
67
|
-
embed_body_fields=False,
|
|
68
67
|
)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
values, errors, *_ = solved_result
|
|
69
|
+
if errors:
|
|
70
|
+
return self.default_response_class(status_code=422, content=_normalize_errors(errors))
|
|
71
|
+
api_version = cast(date, values[self.api_version_header_name.replace("-", "_")])
|
|
72
72
|
self.api_version_var.set(api_version)
|
|
73
73
|
|
|
74
74
|
response = await call_next(request)
|