cadwyn 3.15.10__py3-none-any.whl → 4.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of cadwyn might be problematic. Click here for more details.
- cadwyn/__init__.py +25 -8
- cadwyn/__main__.py +46 -90
- cadwyn/_asts.py +9 -133
- cadwyn/_importer.py +31 -0
- cadwyn/_render.py +152 -0
- cadwyn/_utils.py +7 -107
- cadwyn/applications.py +5 -34
- cadwyn/exceptions.py +11 -3
- cadwyn/middleware.py +4 -4
- cadwyn/route_generation.py +22 -450
- cadwyn/routing.py +2 -5
- cadwyn/schema_generation.py +946 -0
- cadwyn/structure/__init__.py +0 -2
- cadwyn/structure/schemas.py +50 -49
- cadwyn/structure/versions.py +24 -137
- {cadwyn-3.15.10.dist-info → cadwyn-4.0.0.dist-info}/METADATA +4 -5
- cadwyn-4.0.0.dist-info/RECORD +27 -0
- {cadwyn-3.15.10.dist-info → cadwyn-4.0.0.dist-info}/WHEEL +1 -1
- cadwyn/_compat.py +0 -151
- cadwyn/_package_utils.py +0 -45
- cadwyn/codegen/README.md +0 -10
- cadwyn/codegen/__init__.py +0 -10
- cadwyn/codegen/_common.py +0 -168
- cadwyn/codegen/_main.py +0 -279
- cadwyn/codegen/_plugins/__init__.py +0 -0
- cadwyn/codegen/_plugins/class_migrations.py +0 -423
- cadwyn/codegen/_plugins/class_rebuilding.py +0 -109
- cadwyn/codegen/_plugins/class_renaming.py +0 -49
- cadwyn/codegen/_plugins/import_auto_adding.py +0 -64
- cadwyn/codegen/_plugins/module_migrations.py +0 -15
- cadwyn/main.py +0 -11
- cadwyn/structure/modules.py +0 -39
- cadwyn-3.15.10.dist-info/RECORD +0 -38
- {cadwyn-3.15.10.dist-info → cadwyn-4.0.0.dist-info}/LICENSE +0 -0
- {cadwyn-3.15.10.dist-info → cadwyn-4.0.0.dist-info}/entry_points.txt +0 -0
cadwyn/__init__.py
CHANGED
|
@@ -1,22 +1,39 @@
|
|
|
1
1
|
import importlib.metadata
|
|
2
2
|
|
|
3
3
|
from .applications import Cadwyn
|
|
4
|
-
from .
|
|
5
|
-
from .
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
from .route_generation import VersionedAPIRouter, generate_versioned_routers
|
|
5
|
+
from .schema_generation import 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,
|
|
9
19
|
)
|
|
10
|
-
from .structure import HeadVersion, Version, VersionBundle
|
|
11
20
|
|
|
12
21
|
__version__ = importlib.metadata.version("cadwyn")
|
|
13
22
|
__all__ = [
|
|
14
23
|
"Cadwyn",
|
|
15
24
|
"VersionedAPIRouter",
|
|
16
|
-
"generate_code_for_versioned_packages",
|
|
17
25
|
"VersionBundle",
|
|
18
26
|
"HeadVersion",
|
|
19
27
|
"Version",
|
|
28
|
+
"migrate_response_body",
|
|
20
29
|
"generate_versioned_routers",
|
|
21
|
-
"
|
|
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",
|
|
22
39
|
]
|
cadwyn/__main__.py
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import warnings
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
from typing import Any
|
|
1
|
+
from datetime import date
|
|
2
|
+
from typing import Annotated
|
|
6
3
|
|
|
7
4
|
import typer
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.syntax import Syntax
|
|
8
7
|
|
|
9
|
-
from cadwyn.
|
|
10
|
-
|
|
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")]
|
|
11
12
|
|
|
12
13
|
app = typer.Typer(
|
|
13
14
|
name="cadwyn",
|
|
@@ -15,6 +16,14 @@ app = typer.Typer(
|
|
|
15
16
|
help="Modern Stripe-like API versioning in FastAPI",
|
|
16
17
|
)
|
|
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
|
+
|
|
18
27
|
|
|
19
28
|
def version_callback(value: bool):
|
|
20
29
|
if value:
|
|
@@ -24,92 +33,39 @@ def version_callback(value: bool):
|
|
|
24
33
|
raise typer.Exit
|
|
25
34
|
|
|
26
35
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
),
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
help="Add a pragma: no cover comment to the star imports in the generated version of the latest module.",
|
|
45
|
-
),
|
|
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,
|
|
46
53
|
) -> None:
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
from .codegen._main import generate_code_for_versioned_packages
|
|
55
|
-
|
|
56
|
-
sys.path.append(str(Path.cwd()))
|
|
57
|
-
template_package = importlib.import_module(path_to_template_package)
|
|
58
|
-
path_to_version_bundle, version_bundle_variable_name = full_path_to_version_bundle.split(":")
|
|
59
|
-
version_bundle_module = importlib.import_module(path_to_version_bundle)
|
|
60
|
-
possibly_version_bundle = getattr(version_bundle_module, version_bundle_variable_name)
|
|
61
|
-
version_bundle = _get_version_bundle(possibly_version_bundle)
|
|
62
|
-
|
|
63
|
-
return generate_code_for_versioned_packages( # pyright: ignore[reportDeprecated]
|
|
64
|
-
template_package,
|
|
65
|
-
version_bundle,
|
|
66
|
-
ignore_coverage_for_latest_aliases=ignore_coverage_for_latest_aliases,
|
|
67
|
-
)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
@app.command(
|
|
71
|
-
name="codegen",
|
|
72
|
-
help=(
|
|
73
|
-
"For each version in the version bundle, generate a versioned package based on the "
|
|
74
|
-
"`head_schema_package` package"
|
|
75
|
-
),
|
|
76
|
-
short_help="Generate code for all versions of schemas",
|
|
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",
|
|
77
61
|
)
|
|
78
|
-
def
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
),
|
|
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,
|
|
84
67
|
) -> None:
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
sys.path.append(str(Path.cwd()))
|
|
88
|
-
path_to_version_bundle, version_bundle_variable_name = full_path_to_version_bundle.split(":")
|
|
89
|
-
version_bundle_module = importlib.import_module(path_to_version_bundle)
|
|
90
|
-
possibly_version_bundle = getattr(version_bundle_module, version_bundle_variable_name)
|
|
91
|
-
version_bundle = _get_version_bundle(possibly_version_bundle)
|
|
92
|
-
|
|
93
|
-
if version_bundle.head_schemas_package is None: # pragma: no cover
|
|
94
|
-
raise CadwynError("VersionBundle requires a 'head_schemas_package' argument to generate schemas.")
|
|
95
|
-
|
|
96
|
-
return generate_code_for_versioned_packages(version_bundle.head_schemas_package, version_bundle)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def _get_version_bundle(possibly_version_bundle: Any) -> VersionBundle:
|
|
100
|
-
if not isinstance(possibly_version_bundle, VersionBundle):
|
|
101
|
-
err = TypeError(
|
|
102
|
-
"The provided version bundle is not a version bundle and "
|
|
103
|
-
"is not a zero-argument callable that returns the version bundle. "
|
|
104
|
-
f"Instead received: {possibly_version_bundle}",
|
|
105
|
-
)
|
|
106
|
-
if callable(possibly_version_bundle):
|
|
107
|
-
try:
|
|
108
|
-
return _get_version_bundle(possibly_version_bundle())
|
|
109
|
-
except TypeError as e:
|
|
110
|
-
raise err from e
|
|
111
|
-
raise err
|
|
112
|
-
return possibly_version_bundle
|
|
68
|
+
output_code(render_module_by_path(module, app, version), raw)
|
|
113
69
|
|
|
114
70
|
|
|
115
71
|
@app.callback()
|
cadwyn/_asts.py
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
import ast
|
|
2
2
|
import inspect
|
|
3
|
-
import re
|
|
4
3
|
from collections.abc import Callable
|
|
5
|
-
from dataclasses import dataclass
|
|
6
4
|
from enum import Enum, auto
|
|
7
|
-
from
|
|
8
|
-
from types import GenericAlias, LambdaType, ModuleType, NoneType
|
|
5
|
+
from types import GenericAlias, LambdaType, NoneType
|
|
9
6
|
from typing import ( # noqa: UP035
|
|
10
|
-
TYPE_CHECKING,
|
|
11
7
|
Any,
|
|
12
8
|
List,
|
|
13
9
|
cast,
|
|
@@ -15,21 +11,12 @@ from typing import ( # noqa: UP035
|
|
|
15
11
|
get_origin,
|
|
16
12
|
)
|
|
17
13
|
|
|
18
|
-
|
|
19
|
-
PYDANTIC_V2,
|
|
20
|
-
is_pydantic_1_constrained_type,
|
|
21
|
-
)
|
|
22
|
-
from cadwyn._package_utils import (
|
|
23
|
-
get_absolute_python_path_of_import,
|
|
24
|
-
)
|
|
25
|
-
from cadwyn._utils import PlainRepr, UnionType
|
|
26
|
-
from cadwyn.exceptions import CodeGenerationError, InvalidGenerationInstructionError, ModuleIsNotAvailableAsTextError
|
|
14
|
+
import annotated_types
|
|
27
15
|
|
|
28
|
-
|
|
29
|
-
|
|
16
|
+
from cadwyn._utils import PlainRepr, UnionType
|
|
17
|
+
from cadwyn.exceptions import InvalidGenerationInstructionError
|
|
30
18
|
|
|
31
19
|
_LambdaFunctionName = (lambda: None).__name__ # pragma: no branch
|
|
32
|
-
_RE_CAMEL_TO_SNAKE = re.compile(r"(?<!^)(?=[A-Z])")
|
|
33
20
|
|
|
34
21
|
|
|
35
22
|
# A parent type of typing._GenericAlias
|
|
@@ -42,11 +29,8 @@ GenericAliasUnion = GenericAlias | _BaseGenericAlias
|
|
|
42
29
|
|
|
43
30
|
|
|
44
31
|
def get_fancy_repr(value: Any):
|
|
45
|
-
if
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if isinstance(value, annotated_types.GroupedMetadata) and hasattr(type(value), "__dataclass_fields__"):
|
|
49
|
-
return transform_grouped_metadata(value)
|
|
32
|
+
if isinstance(value, annotated_types.GroupedMetadata) and hasattr(type(value), "__dataclass_fields__"):
|
|
33
|
+
return transform_grouped_metadata(value)
|
|
50
34
|
if isinstance(value, list | tuple | set | frozenset):
|
|
51
35
|
return transform_collection(value)
|
|
52
36
|
if isinstance(value, dict):
|
|
@@ -59,7 +43,7 @@ def get_fancy_repr(value: Any):
|
|
|
59
43
|
return transform_type(value)
|
|
60
44
|
if isinstance(value, Enum):
|
|
61
45
|
return transform_enum(value)
|
|
62
|
-
if isinstance(value, auto):
|
|
46
|
+
if isinstance(value, auto): # pragma: no cover # it works but we no longer use auto
|
|
63
47
|
return transform_auto(value)
|
|
64
48
|
if isinstance(value, UnionType):
|
|
65
49
|
return transform_union(value)
|
|
@@ -101,29 +85,11 @@ def transform_generic_alias(value: GenericAliasUnion) -> Any:
|
|
|
101
85
|
return f"{get_fancy_repr(get_origin(value))}[{', '.join(get_fancy_repr(a) for a in get_args(value))}]"
|
|
102
86
|
|
|
103
87
|
|
|
104
|
-
def transform_none(_:
|
|
88
|
+
def transform_none(_: NoneType) -> Any:
|
|
105
89
|
return "None"
|
|
106
90
|
|
|
107
91
|
|
|
108
92
|
def transform_type(value: type) -> Any:
|
|
109
|
-
# This is a hack for pydantic's Constrained types
|
|
110
|
-
if is_pydantic_1_constrained_type(value):
|
|
111
|
-
parent = value.mro()[1]
|
|
112
|
-
snake_case = _RE_CAMEL_TO_SNAKE.sub("_", value.__name__)
|
|
113
|
-
cls_name = "con" + "".join(snake_case.split("_")[1:-1])
|
|
114
|
-
return (
|
|
115
|
-
cls_name.lower()
|
|
116
|
-
+ "("
|
|
117
|
-
+ ", ".join(
|
|
118
|
-
[
|
|
119
|
-
f"{key}={get_fancy_repr(val)}"
|
|
120
|
-
for key, val in value.__dict__.items()
|
|
121
|
-
if not key.startswith("_") and val is not None and val != parent.__dict__[key]
|
|
122
|
-
],
|
|
123
|
-
)
|
|
124
|
-
+ ")"
|
|
125
|
-
)
|
|
126
|
-
|
|
127
93
|
return value.__name__
|
|
128
94
|
|
|
129
95
|
|
|
@@ -131,7 +97,7 @@ def transform_enum(value: Enum) -> Any:
|
|
|
131
97
|
return PlainRepr(f"{value.__class__.__name__}.{value.name}")
|
|
132
98
|
|
|
133
99
|
|
|
134
|
-
def transform_auto(_: auto) -> Any:
|
|
100
|
+
def transform_auto(_: auto) -> Any: # pragma: no cover # it works but we no longer use auto
|
|
135
101
|
return PlainRepr("auto()")
|
|
136
102
|
|
|
137
103
|
|
|
@@ -172,68 +138,6 @@ def _get_lambda_source_from_default_factory(source: str) -> str:
|
|
|
172
138
|
)
|
|
173
139
|
|
|
174
140
|
|
|
175
|
-
def read_python_module(module: ModuleType) -> str:
|
|
176
|
-
# Can be cached in the future to gain some speedups
|
|
177
|
-
try:
|
|
178
|
-
return inspect.getsource(module)
|
|
179
|
-
except OSError as e:
|
|
180
|
-
if module.__file__ is None: # pragma: no cover
|
|
181
|
-
raise CodeGenerationError(f"Failed to get file path to the module {module}") from e
|
|
182
|
-
path = Path(module.__file__)
|
|
183
|
-
if path.is_file() and path.read_text() == "":
|
|
184
|
-
return ""
|
|
185
|
-
raise ModuleIsNotAvailableAsTextError( # pragma: no cover
|
|
186
|
-
f"Failed to get source code for module {module}. "
|
|
187
|
-
"This is likely because this module is not available as code "
|
|
188
|
-
"(it could be a compiled C extension or even a .pyc file). "
|
|
189
|
-
"Cadwyn does not support models from such code. "
|
|
190
|
-
"Please, open an issue on Cadwyn's issue tracker if you believe that your use case is valid "
|
|
191
|
-
"and if you believe that it is possible for Cadwyn to support it.",
|
|
192
|
-
) from e
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
def get_all_names_defined_at_toplevel_of_module(body: ast.Module, module_python_path: str) -> dict[str, str]:
|
|
196
|
-
"""Some day we will want to use this to auto-add imports for new symbols in versions. Some day..."""
|
|
197
|
-
defined_names = {}
|
|
198
|
-
for node in body.body:
|
|
199
|
-
if isinstance(node, ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef):
|
|
200
|
-
defined_names[node.name] = module_python_path
|
|
201
|
-
elif isinstance(node, ast.Assign):
|
|
202
|
-
for target in node.targets:
|
|
203
|
-
if isinstance(target, ast.Name):
|
|
204
|
-
defined_names[target.id] = module_python_path
|
|
205
|
-
elif isinstance(node, ast.ImportFrom):
|
|
206
|
-
for name in node.names:
|
|
207
|
-
defined_names[name.name] = get_absolute_python_path_of_import(node, module_python_path)
|
|
208
|
-
elif isinstance(node, ast.Import):
|
|
209
|
-
for name in node.names:
|
|
210
|
-
defined_names[name.name] = name.name
|
|
211
|
-
return defined_names
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
def add_keyword_to_call(attr_name: str, attr_value: Any, call: ast.Call):
|
|
215
|
-
new_keyword = get_ast_keyword_from_argument_name_and_value(attr_name, attr_value)
|
|
216
|
-
for i, keyword in enumerate(call.keywords):
|
|
217
|
-
if keyword.arg == attr_name:
|
|
218
|
-
call.keywords[i] = new_keyword
|
|
219
|
-
break
|
|
220
|
-
else:
|
|
221
|
-
call.keywords.append(new_keyword)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
def delete_keyword_from_call(attr_name: str, call: ast.Call):
|
|
225
|
-
for i, keyword in enumerate(call.keywords): # pragma: no branch
|
|
226
|
-
if keyword.arg == attr_name:
|
|
227
|
-
call.keywords.pop(i)
|
|
228
|
-
break
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
def get_ast_keyword_from_argument_name_and_value(name: str, value: Any):
|
|
232
|
-
if not isinstance(value, ast.AST):
|
|
233
|
-
value = ast.parse(get_fancy_repr(value), mode="eval").body
|
|
234
|
-
return ast.keyword(arg=name, value=value) # pyright: ignore[reportArgumentType, reportCallIssue]
|
|
235
|
-
|
|
236
|
-
|
|
237
141
|
def pop_docstring_from_cls_body(cls_body: list[ast.stmt]) -> list[ast.stmt]:
|
|
238
142
|
if (
|
|
239
143
|
len(cls_body) > 0
|
|
@@ -244,31 +148,3 @@ def pop_docstring_from_cls_body(cls_body: list[ast.stmt]) -> list[ast.stmt]:
|
|
|
244
148
|
return [cls_body.pop(0)]
|
|
245
149
|
else:
|
|
246
150
|
return []
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
@dataclass(slots=True)
|
|
250
|
-
class _ValidatorWrapper:
|
|
251
|
-
func_ast: ast.FunctionDef
|
|
252
|
-
index_of_validator_decorator: int
|
|
253
|
-
field_names: set[str | ast.expr] | None
|
|
254
|
-
is_deleted: bool = False
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
def get_validator_info_or_none(method: ast.FunctionDef) -> _ValidatorWrapper | None:
|
|
258
|
-
for index, decorator in enumerate(method.decorator_list):
|
|
259
|
-
# The cases we handle here:
|
|
260
|
-
# * `Name(id="root_validator")`
|
|
261
|
-
# * `Call(func=Name(id="validator"), args=[Constant(value="foo")])`
|
|
262
|
-
# * `Attribute(value=Name(id="pydantic"), attr="root_validator")`
|
|
263
|
-
# * `Call(func=Attribute(value=Name(id="pydantic"), attr="root_validator"), args=[])`
|
|
264
|
-
|
|
265
|
-
if isinstance(decorator, ast.Call) and ast.unparse(decorator.func).endswith("validator"):
|
|
266
|
-
if len(decorator.args) == 0:
|
|
267
|
-
return _ValidatorWrapper(method, index, None)
|
|
268
|
-
else:
|
|
269
|
-
return _ValidatorWrapper(
|
|
270
|
-
method, index, {arg.value if isinstance(arg, ast.Constant) else arg for arg in decorator.args}
|
|
271
|
-
)
|
|
272
|
-
elif isinstance(decorator, ast.Name | ast.Attribute) and ast.unparse(decorator).endswith("validator"):
|
|
273
|
-
return _ValidatorWrapper(method, index, None)
|
|
274
|
-
return None
|
cadwyn/_importer.py
ADDED
|
@@ -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
|
cadwyn/_render.py
ADDED
|
@@ -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
|
+
_generate_versioned_models,
|
|
17
|
+
_PydanticRuntimeModelWrapper,
|
|
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
|
+
)
|