cadwyn 5.4.6__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.
cadwyn/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ import importlib.metadata
2
+
3
+ from .applications import Cadwyn
4
+ from .changelogs import hidden
5
+ from .dependencies import current_dependency_solver
6
+ from .route_generation import VersionedAPIRouter, generate_versioned_routers
7
+ from .schema_generation import generate_versioned_models, migrate_response_body
8
+ from .structure import (
9
+ HeadVersion,
10
+ RequestInfo,
11
+ ResponseInfo,
12
+ Version,
13
+ VersionBundle,
14
+ VersionChange,
15
+ VersionChangeWithSideEffects,
16
+ convert_request_to_next_version_for,
17
+ convert_response_to_previous_version_for,
18
+ endpoint,
19
+ enum,
20
+ schema,
21
+ )
22
+
23
+ __version__ = importlib.metadata.version("cadwyn")
24
+ __all__ = [
25
+ "Cadwyn",
26
+ "HeadVersion",
27
+ "RequestInfo",
28
+ "ResponseInfo",
29
+ "Version",
30
+ "VersionBundle",
31
+ "VersionChange",
32
+ "VersionChangeWithSideEffects",
33
+ "VersionedAPIRouter",
34
+ "convert_request_to_next_version_for",
35
+ "convert_response_to_previous_version_for",
36
+ "current_dependency_solver",
37
+ "endpoint",
38
+ "enum",
39
+ "generate_versioned_models",
40
+ "generate_versioned_routers",
41
+ "hidden",
42
+ "migrate_response_body",
43
+ "schema",
44
+ ]
cadwyn/__main__.py ADDED
@@ -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: # pragma: no cover
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()
cadwyn/_asts.py ADDED
@@ -0,0 +1,155 @@
1
+ import ast
2
+ import inspect
3
+ from collections.abc import Callable
4
+ from enum import Enum, auto
5
+ from types import GenericAlias, LambdaType
6
+ from typing import ( # noqa: UP035
7
+ Any,
8
+ List,
9
+ Union,
10
+ cast,
11
+ get_args,
12
+ get_origin,
13
+ )
14
+
15
+ import annotated_types
16
+
17
+ from cadwyn._utils import PlainRepr, UnionType
18
+ from cadwyn.exceptions import InvalidGenerationInstructionError
19
+
20
+ _LambdaFunctionName = (lambda: None).__name__ # pragma: no branch
21
+ NoneType = type(None)
22
+
23
+
24
+ # A parent type of typing._GenericAlias
25
+ _BaseGenericAlias = cast("type", type(List[int])).mro()[1] # noqa: UP006
26
+
27
+ # type(list[int]) and type(List[int]) are different which is why we have to do this.
28
+ # Please note that this problem is much wider than just lists which is why we use typing._BaseGenericAlias
29
+ # instead of typing._GenericAlias.
30
+ GenericAliasUnion = Union[GenericAlias, _BaseGenericAlias]
31
+ GenericAliasUnionArgs = get_args(GenericAliasUnion)
32
+
33
+
34
+ def get_fancy_repr(value: Any) -> Any:
35
+ if isinstance(value, annotated_types.GroupedMetadata) and hasattr(type(value), "__dataclass_fields__"):
36
+ return transform_grouped_metadata(value)
37
+ if isinstance(value, (list, tuple, set, frozenset)):
38
+ return transform_collection(value)
39
+ if isinstance(value, dict):
40
+ return transform_dict(value)
41
+ if isinstance(value, GenericAliasUnionArgs):
42
+ return transform_generic_alias(value)
43
+ if value is None or value is NoneType:
44
+ return transform_none(value)
45
+ if isinstance(value, type):
46
+ return transform_type(value)
47
+ if isinstance(value, Enum):
48
+ return transform_enum(value)
49
+ if isinstance(value, auto): # pragma: no cover # it works but we no longer use auto
50
+ return transform_auto(value)
51
+ if isinstance(value, UnionType):
52
+ return transform_union(value) # pragma: no cover
53
+ if isinstance(value, LambdaType) and _LambdaFunctionName == value.__name__:
54
+ return transform_lambda(value)
55
+ if inspect.isfunction(value):
56
+ return transform_function(value)
57
+ else:
58
+ return transform_other(value)
59
+
60
+
61
+ def transform_grouped_metadata(value: "annotated_types.GroupedMetadata"):
62
+ empty_obj = type(value)
63
+
64
+ modified_fields = [
65
+ (key, getattr(value, key))
66
+ for key in value.__dataclass_fields__ # pyright: ignore[reportAttributeAccessIssue]
67
+ if getattr(value, key) != getattr(empty_obj, key)
68
+ ]
69
+
70
+ return PlainRepr(
71
+ value.__class__.__name__
72
+ + "("
73
+ + ", ".join(f"{PlainRepr(key)}={get_fancy_repr(v)}" for key, v in modified_fields)
74
+ + ")",
75
+ )
76
+
77
+
78
+ def transform_collection(value: Union[list, tuple, set, frozenset]) -> Any:
79
+ return PlainRepr(value.__class__(map(get_fancy_repr, value)))
80
+
81
+
82
+ def transform_dict(value: dict) -> Any:
83
+ return PlainRepr(
84
+ value.__class__((get_fancy_repr(k), get_fancy_repr(v)) for k, v in value.items()),
85
+ )
86
+
87
+
88
+ def transform_generic_alias(value: GenericAliasUnion) -> Any:
89
+ return f"{get_fancy_repr(get_origin(value))}[{', '.join(get_fancy_repr(a) for a in get_args(value))}]"
90
+
91
+
92
+ def transform_none(_: type[None]) -> Any:
93
+ return "None"
94
+
95
+
96
+ def transform_type(value: type) -> Any:
97
+ return value.__name__
98
+
99
+
100
+ def transform_enum(value: Enum) -> Any:
101
+ return PlainRepr(f"{value.__class__.__name__}.{value.name}")
102
+
103
+
104
+ def transform_auto(_: auto) -> Any: # pragma: no cover # it works but we no longer use auto
105
+ return PlainRepr("auto()")
106
+
107
+
108
+ def transform_union(value: UnionType) -> Any: # pragma: no cover
109
+ return "typing.Union[" + (", ".join(get_fancy_repr(a) for a in get_args(value))) + "]"
110
+
111
+
112
+ def transform_lambda(value: LambdaType) -> Any:
113
+ # We clean source because getsource() can return only a part of the expression which
114
+ # on its own is not a valid expression such as: "\n .had(default_factory=lambda: 91)"
115
+ return _get_lambda_source_from_default_factory(inspect.getsource(value).strip(" \n\t."))
116
+
117
+
118
+ def transform_function(value: Callable) -> Any:
119
+ return PlainRepr(value.__name__)
120
+
121
+
122
+ def transform_other(value: Any) -> Any:
123
+ return PlainRepr(repr(value))
124
+
125
+
126
+ def _get_lambda_source_from_default_factory(source: str) -> str:
127
+ found_lambdas: list[ast.Lambda] = [
128
+ node.value
129
+ for node in ast.walk(ast.parse(source))
130
+ if isinstance(node, ast.keyword) and node.arg == "default_factory" and isinstance(node.value, ast.Lambda)
131
+ ]
132
+
133
+ if len(found_lambdas) == 1:
134
+ return ast.unparse(found_lambdas[0])
135
+ # These two errors are really hard to cover. Not sure if even possible, honestly :)
136
+ elif len(found_lambdas) == 0: # pragma: no cover
137
+ raise InvalidGenerationInstructionError(
138
+ f"No lambda found in default_factory even though one was passed: {source}",
139
+ )
140
+ else: # pragma: no cover
141
+ raise InvalidGenerationInstructionError(
142
+ "More than one lambda found in default_factory. This is not supported.",
143
+ )
144
+
145
+
146
+ def pop_docstring_from_cls_body(cls_body: list[ast.stmt]) -> list[ast.stmt]:
147
+ if (
148
+ len(cls_body) > 0
149
+ and isinstance(cls_body[0], ast.Expr)
150
+ and isinstance(cls_body[0].value, ast.Constant)
151
+ and isinstance(cls_body[0].value.value, str)
152
+ ):
153
+ return [cls_body.pop(0)]
154
+ else:
155
+ return []
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 = f'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
File without changes
@@ -0,0 +1,9 @@
1
+ from contextvars import ContextVar
2
+
3
+ from typing_extensions import Literal
4
+
5
+ DEFAULT_API_VERSION_VAR: "ContextVar[str | None]" = ContextVar("cadwyn_default_api_version")
6
+ CURRENT_DEPENDENCY_SOLVER_OPTIONS = Literal["cadwyn", "fastapi"]
7
+ CURRENT_DEPENDENCY_SOLVER_VAR: ContextVar[CURRENT_DEPENDENCY_SOLVER_OPTIONS] = ContextVar(
8
+ "cadwyn_dependencies_current_dependency_solver"
9
+ )
cadwyn/_render.py ADDED
@@ -0,0 +1,155 @@
1
+ import ast
2
+ import inspect
3
+ import textwrap
4
+ from enum import Enum
5
+ from typing import TYPE_CHECKING, Union
6
+
7
+ import typer
8
+ from pydantic import BaseModel
9
+
10
+ from cadwyn._asts import get_fancy_repr, pop_docstring_from_cls_body
11
+ from cadwyn._utils import lenient_issubclass
12
+ from cadwyn.exceptions import CadwynRenderError
13
+ from cadwyn.schema_generation import (
14
+ PydanticFieldWrapper,
15
+ _EnumWrapper,
16
+ _PydanticModelWrapper,
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[Union[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[Union[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[Union[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: _PydanticModelWrapper, 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(
150
+ arg=attr,
151
+ value=ast.parse(get_fancy_repr(attr_value), mode="eval").body,
152
+ )
153
+ for attr, attr_value in field.passed_field_attributes.items()
154
+ ],
155
+ )
cadwyn/_utils.py ADDED
@@ -0,0 +1,79 @@
1
+ import sys
2
+ from collections.abc import Callable
3
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar, Union
4
+
5
+ from pydantic._internal._decorators import unwrap_wrapped_function
6
+
7
+ Sentinel: Any = object()
8
+
9
+ _T = TypeVar("_T", bound=Callable)
10
+
11
+
12
+ _P_T = TypeVar("_P_T")
13
+ _P_R = TypeVar("_P_R")
14
+
15
+
16
+ if sys.version_info >= (3, 10):
17
+ UnionType = type(int | str) | type(Union[int, str])
18
+ DATACLASS_SLOTS: dict[str, Any] = {"slots": True}
19
+ ZIP_STRICT_TRUE: dict[str, Any] = {"strict": True}
20
+ ZIP_STRICT_FALSE: dict[str, Any] = {"strict": False}
21
+ DATACLASS_KW_ONLY: dict[str, Any] = {"kw_only": True}
22
+ else:
23
+ UnionType = type(Union[int, str])
24
+ DATACLASS_SLOTS: dict[str, Any] = {}
25
+ DATACLASS_KW_ONLY: dict[str, Any] = {}
26
+ ZIP_STRICT_TRUE: dict[str, Any] = {}
27
+ ZIP_STRICT_FALSE: dict[str, Any] = {}
28
+
29
+
30
+ def get_name_of_function_wrapped_in_pydantic_validator(func: Any) -> str:
31
+ if hasattr(func, "wrapped"):
32
+ return get_name_of_function_wrapped_in_pydantic_validator(func.wrapped)
33
+ if hasattr(func, "__func__"):
34
+ return get_name_of_function_wrapped_in_pydantic_validator(func.__func__)
35
+ return func.__name__
36
+
37
+
38
+ class classproperty(Generic[_P_T, _P_R]): # noqa: N801
39
+ def __init__(self, func: Callable[[_P_T], _P_R]) -> None:
40
+ super().__init__()
41
+ self.func = func
42
+
43
+ def __get__(self, obj: Any, cls: _P_T) -> _P_R:
44
+ return self.func(cls)
45
+
46
+
47
+ class PlainRepr(str):
48
+ """String class where repr doesn't include quotes"""
49
+
50
+ def __repr__(self) -> str:
51
+ return str(self)
52
+
53
+
54
+ def same_definition_as_in(t: _T) -> Callable[[Callable], _T]:
55
+ def decorator(f: Callable) -> _T:
56
+ return f # pyright: ignore[reportReturnType]
57
+
58
+ return decorator
59
+
60
+
61
+ def fully_unwrap_decorator(func: Callable, is_pydantic_v1_style_validator: Any):
62
+ func = unwrap_wrapped_function(func)
63
+ if is_pydantic_v1_style_validator and func.__closure__:
64
+ func = func.__closure__[0].cell_contents
65
+ return unwrap_wrapped_function(func)
66
+
67
+
68
+ T = TypeVar("T", bound=type[object])
69
+
70
+ if TYPE_CHECKING:
71
+ lenient_issubclass = issubclass
72
+
73
+ else:
74
+
75
+ def lenient_issubclass(cls: type, other: Union[T, tuple[T, ...]]) -> bool:
76
+ try:
77
+ return issubclass(cls, other)
78
+ except TypeError: # pragma: no cover
79
+ return False