cadwyn 3.3.4__py3-none-any.whl → 3.4.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/_compat.py CHANGED
@@ -3,16 +3,19 @@ import dataclasses
3
3
  import inspect
4
4
  from typing import Any, TypeAlias
5
5
 
6
+ import pydantic
6
7
  from fastapi._compat import ModelField as FastAPIModelField
7
8
  from pydantic import BaseModel, Field
8
9
 
9
10
  ModelField: TypeAlias = Any # pyright: ignore[reportGeneralTypeIssues]
10
- Undefined: TypeAlias = Any
11
+ PydanticUndefined: TypeAlias = Any
12
+ VALIDATOR_CONFIG_KEY = "__validators__"
11
13
 
12
14
  try:
13
15
  PYDANTIC_V2 = False
14
16
 
15
- from pydantic.fields import FieldInfo, ModelField, Undefined # pyright: ignore # noqa: PGH003
17
+ from pydantic.fields import FieldInfo, ModelField # pyright: ignore # noqa: PGH003
18
+ from pydantic.fields import Undefined as PydanticUndefined # pyright: ignore # noqa: PGH003
16
19
 
17
20
  _all_field_arg_names = []
18
21
  EXTRA_FIELD_NAME = "extra"
@@ -20,10 +23,9 @@ except ImportError:
20
23
  PYDANTIC_V2 = True
21
24
 
22
25
  from pydantic.fields import FieldInfo
23
- from pydantic_core import PydanticUndefined
26
+ from pydantic_core import PydanticUndefined # pyright: ignore # noqa: PGH003
24
27
 
25
28
  ModelField: TypeAlias = FieldInfo # pyright: ignore # noqa: PGH003
26
- Undefined = PydanticUndefined # pyright: ignore # noqa: PGH003
27
29
  _all_field_arg_names = sorted(
28
30
  [
29
31
  name
@@ -38,38 +40,34 @@ _empty_field_info = Field()
38
40
  dict_of_empty_field_info = {k: getattr(_empty_field_info, k) for k in FieldInfo.__slots__}
39
41
 
40
42
 
41
- def is_pydantic_constrained_type(value: object):
43
+ def is_pydantic_1_constrained_type(value: object):
44
+ """This method only works for pydanticV1. It is always False in PydanticV2"""
42
45
  return isinstance(value, type) and value.__name__.startswith("Constrained") and value.__name__.endswith("Value")
43
46
 
44
47
 
45
- def get_attrs_that_are_not_from_field_and_that_are_from_field(value: type):
46
- parent_public_attrs = {k: v for k, v in value.mro()[1].__dict__.items() if not k.startswith("_")}
47
- value_private_attrs = {k: v for k, v in value.__dict__.items() if not k.startswith("_")}
48
- attrs_in_value_different_from_parent = {
49
- k: v for k, v in value_private_attrs.items() if k in parent_public_attrs and parent_public_attrs[k] != v
50
- }
51
- attrs_in_value_different_from_parent_that_are_not_in_field_def = {
52
- k: v for k, v in attrs_in_value_different_from_parent.items() if k not in dict_of_empty_field_info
53
- }
54
- attrs_in_value_different_from_parent_that_are_in_field_def = {
55
- k: v for k, v in attrs_in_value_different_from_parent.items() if k in dict_of_empty_field_info
56
- }
57
-
58
- return (
59
- attrs_in_value_different_from_parent_that_are_not_in_field_def,
60
- attrs_in_value_different_from_parent_that_are_in_field_def,
61
- )
48
+ def is_constrained_type(value: object):
49
+ if PYDANTIC_V2:
50
+ import annotated_types
51
+
52
+ return isinstance(value, annotated_types.Len | annotated_types.Interval | pydantic.StringConstraints)
53
+
54
+ else:
55
+ return is_pydantic_1_constrained_type(value)
62
56
 
63
57
 
64
58
  @dataclasses.dataclass(slots=True)
65
59
  class PydanticFieldWrapper:
60
+ """We DO NOT maintain field.metadata at all"""
61
+
66
62
  annotation: Any
67
63
 
68
64
  init_model_field: dataclasses.InitVar[ModelField] # pyright: ignore[reportGeneralTypeIssues]
69
65
  field_info: FieldInfo = dataclasses.field(init=False)
70
66
 
71
67
  annotation_ast: ast.expr | None = None
72
- field_ast: ast.expr | None = None
68
+ # In the expressions "foo: str | None = None" and "foo: str | None = Field(default=None)"
69
+ # the value_ast is "None" and "Field(default=None)" respectively
70
+ value_ast: ast.expr | None = None
73
71
 
74
72
  def __post_init__(self, init_model_field: ModelField): # pyright: ignore[reportGeneralTypeIssues]
75
73
  if isinstance(init_model_field, FieldInfo):
@@ -77,20 +75,18 @@ class PydanticFieldWrapper:
77
75
  else:
78
76
  self.field_info = init_model_field.field_info
79
77
 
80
- def get_annotation_for_rendering(self):
81
- if self.annotation_ast:
82
- return self.annotation_ast
83
- else:
84
- return self.annotation
85
-
86
78
  def update_attribute(self, *, name: str, value: Any):
87
79
  if PYDANTIC_V2:
88
- if name in FieldInfo.metadata_lookup:
89
- self.field_info.metadata.extend(FieldInfo._collect_metadata({name: value}))
90
80
  self.field_info._attributes_set[name] = value
91
81
  else:
92
82
  setattr(self.field_info, name, value)
93
83
 
84
+ def delete_attribute(self, *, name: str) -> None:
85
+ if PYDANTIC_V2:
86
+ self.field_info._attributes_set.pop(name)
87
+ else:
88
+ setattr(self.field_info, name, PydanticUndefined)
89
+
94
90
  @property
95
91
  def passed_field_attributes(self):
96
92
  if PYDANTIC_V2:
cadwyn/codegen/_asts.py CHANGED
@@ -2,6 +2,7 @@ import ast
2
2
  import inspect
3
3
  import re
4
4
  from collections.abc import Callable
5
+ from dataclasses import dataclass
5
6
  from enum import Enum, auto
6
7
  from pathlib import Path
7
8
  from types import GenericAlias, LambdaType, ModuleType, NoneType
@@ -15,8 +16,7 @@ from typing import ( # noqa: UP035
15
16
 
16
17
  from cadwyn._compat import (
17
18
  PYDANTIC_V2,
18
- get_attrs_that_are_not_from_field_and_that_are_from_field,
19
- is_pydantic_constrained_type,
19
+ is_pydantic_1_constrained_type,
20
20
  )
21
21
  from cadwyn._package_utils import (
22
22
  get_absolute_python_path_of_import,
@@ -108,29 +108,22 @@ def transform_none(_: NoneType) -> Any:
108
108
 
109
109
  def transform_type(value: type) -> Any:
110
110
  # This is a hack for pydantic's Constrained types
111
- if is_pydantic_constrained_type(value):
112
- if get_attrs_that_are_not_from_field_and_that_are_from_field(value)[0]:
113
- parent = value.mro()[1]
114
- snake_case = _RE_CAMEL_TO_SNAKE.sub("_", value.__name__)
115
- cls_name = "con" + "".join(snake_case.split("_")[1:-1])
116
- return (
117
- cls_name.lower()
118
- + "("
119
- + ", ".join(
120
- [
121
- f"{key}={get_fancy_repr(val)}"
122
- for key, val in value.__dict__.items()
123
- if not key.startswith("_") and val is not None and val != parent.__dict__[key]
124
- ],
125
- )
126
- + ")"
111
+ if is_pydantic_1_constrained_type(value):
112
+ parent = value.mro()[1]
113
+ snake_case = _RE_CAMEL_TO_SNAKE.sub("_", value.__name__)
114
+ cls_name = "con" + "".join(snake_case.split("_")[1:-1])
115
+ return (
116
+ cls_name.lower()
117
+ + "("
118
+ + ", ".join(
119
+ [
120
+ f"{key}={get_fancy_repr(val)}"
121
+ for key, val in value.__dict__.items()
122
+ if not key.startswith("_") and val is not None and val != parent.__dict__[key]
123
+ ],
127
124
  )
128
- else:
129
- # In pydantic V1:
130
- # MRO of constr looks like: [ConstrainedStrValue, pydantic.types.ConstrainedStr, str, object]
131
- # -2 -1
132
- # ^^^
133
- value = value.mro()[-2]
125
+ + ")"
126
+ )
134
127
 
135
128
  return value.__name__
136
129
 
@@ -230,11 +223,17 @@ def add_keyword_to_call(attr_name: str, attr_value: Any, call: ast.Call):
230
223
  call.keywords.append(new_keyword)
231
224
 
232
225
 
226
+ def delete_keyword_from_call(attr_name: str, call: ast.Call):
227
+ for i, keyword in enumerate(call.keywords): # pragma: no branch
228
+ if keyword.arg == attr_name:
229
+ call.keywords.pop(i)
230
+ break
231
+
232
+
233
233
  def get_ast_keyword_from_argument_name_and_value(name: str, value: Any):
234
- return ast.keyword(
235
- arg=name,
236
- value=ast.parse(get_fancy_repr(value), mode="eval").body,
237
- )
234
+ if not isinstance(value, ast.AST):
235
+ value = ast.parse(get_fancy_repr(value), mode="eval").body
236
+ return ast.keyword(arg=name, value=value)
238
237
 
239
238
 
240
239
  def pop_docstring_from_cls_body(cls_body: list[ast.stmt]) -> list[ast.stmt]:
@@ -247,3 +246,31 @@ def pop_docstring_from_cls_body(cls_body: list[ast.stmt]) -> list[ast.stmt]:
247
246
  return [cls_body.pop(0)]
248
247
  else:
249
248
  return []
249
+
250
+
251
+ @dataclass(slots=True)
252
+ class _ValidatorWrapper:
253
+ func_ast: ast.FunctionDef
254
+ index_of_validator_decorator: int
255
+ field_names: set[str | ast.expr] | None
256
+ is_deleted: bool = False
257
+
258
+
259
+ def get_validator_info_or_none(method: ast.FunctionDef) -> _ValidatorWrapper | None:
260
+ for index, decorator in enumerate(method.decorator_list):
261
+ # The cases we handle here:
262
+ # * `Name(id="root_validator")`
263
+ # * `Call(func=Name(id="validator"), args=[Constant(value="foo")])`
264
+ # * `Attribute(value=Name(id="pydantic"), attr="root_validator")`
265
+ # * `Call(func=Attribute(value=Name(id="pydantic"), attr="root_validator"), args=[])`
266
+
267
+ if isinstance(decorator, ast.Call) and ast.unparse(decorator.func).endswith("validator"):
268
+ if len(decorator.args) == 0:
269
+ return _ValidatorWrapper(method, index, None)
270
+ else:
271
+ return _ValidatorWrapper(
272
+ method, index, {arg.value if isinstance(arg, ast.Constant) else arg for arg in decorator.args}
273
+ )
274
+ elif isinstance(decorator, ast.Name | ast.Attribute) and ast.unparse(decorator).endswith("validator"):
275
+ return _ValidatorWrapper(method, index, None)
276
+ return None
cadwyn/codegen/_common.py CHANGED
@@ -16,6 +16,8 @@ from cadwyn._package_utils import IdentifierPythonPath
16
16
  from cadwyn.exceptions import CodeGenerationError
17
17
  from cadwyn.structure.versions import Version
18
18
 
19
+ from ._asts import _ValidatorWrapper, get_validator_info_or_none
20
+
19
21
  _FieldName: TypeAlias = str
20
22
  _CodegenPluginASTType = TypeVar("_CodegenPluginASTType", bound=ast.AST)
21
23
 
@@ -25,6 +27,7 @@ class PydanticModelWrapper:
25
27
  cls: type[BaseModel]
26
28
  name: str
27
29
  fields: dict[_FieldName, PydanticFieldWrapper]
30
+ validators: dict[_FieldName, _ValidatorWrapper]
28
31
  _parents: list[Self] | None = dataclasses.field(init=False, default=None)
29
32
 
30
33
  def _get_parents(self, schemas: "dict[IdentifierPythonPath, Self]"):
@@ -37,11 +40,14 @@ class PydanticModelWrapper:
37
40
  if schema_path in schemas:
38
41
  parents.append(schemas[schema_path])
39
42
  elif issubclass(base, BaseModel):
40
- parents.append(type(self)(base, base.__name__, get_fields_from_model(base)))
43
+ fields, validators = get_fields_and_validators_from_model(base)
44
+ parents.append(type(self)(base, base.__name__, fields, validators))
41
45
  self._parents = parents
42
46
  return parents
43
47
 
44
- def _get_defined_fields(self, schemas: "dict[IdentifierPythonPath, Self]") -> dict[str, PydanticFieldWrapper]:
48
+ def _get_defined_fields_through_mro(
49
+ self, schemas: "dict[IdentifierPythonPath, Self]"
50
+ ) -> dict[str, PydanticFieldWrapper]:
45
51
  fields = {}
46
52
 
47
53
  for parent in reversed(self._get_parents(schemas)):
@@ -49,9 +55,19 @@ class PydanticModelWrapper:
49
55
 
50
56
  return fields | self.fields
51
57
 
58
+ def _get_defined_annotations_through_mro(self, schemas: "dict[IdentifierPythonPath, Self]") -> dict[str, Any]:
59
+ annotations = {}
60
+
61
+ for parent in reversed(self._get_parents(schemas)):
62
+ annotations |= parent.cls.__annotations__
63
+
64
+ return annotations | self.cls.__annotations__
65
+
52
66
 
53
67
  @cache
54
- def get_fields_from_model(cls: type) -> dict[str, PydanticFieldWrapper]:
68
+ def get_fields_and_validators_from_model(
69
+ cls: type,
70
+ ) -> tuple[dict[_FieldName, PydanticFieldWrapper], dict[_FieldName, _ValidatorWrapper]]:
55
71
  if not isinstance(cls, type) or not issubclass(cls, BaseModel):
56
72
  raise CodeGenerationError(f"Model {cls} is not a subclass of BaseModel")
57
73
 
@@ -59,25 +75,37 @@ def get_fields_from_model(cls: type) -> dict[str, PydanticFieldWrapper]:
59
75
  try:
60
76
  source = inspect.getsource(cls)
61
77
  except OSError:
62
- return {
63
- field_name: PydanticFieldWrapper(
64
- annotation=field.annotation,
65
- init_model_field=field,
66
- )
67
- for field_name, field in fields.items()
68
- }
78
+ return (
79
+ {
80
+ field_name: PydanticFieldWrapper(annotation=field.annotation, init_model_field=field)
81
+ for field_name, field in fields.items()
82
+ },
83
+ {},
84
+ )
69
85
  else:
70
86
  cls_ast = cast(ast.ClassDef, ast.parse(source).body[0])
71
- return {
72
- node.target.id: PydanticFieldWrapper(
73
- annotation=fields[node.target.id].annotation,
74
- init_model_field=fields[node.target.id],
75
- annotation_ast=node.annotation,
76
- field_ast=node.value,
77
- )
87
+ validators: dict[str, _ValidatorWrapper] = {}
88
+
89
+ validators_and_nones = (
90
+ get_validator_info_or_none(node)
78
91
  for node in cls_ast.body
79
- if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.target.id in fields
80
- }
92
+ if isinstance(node, ast.FunctionDef) and node.decorator_list
93
+ )
94
+ validators = {validator.func_ast.name: validator for validator in validators_and_nones if validator is not None}
95
+
96
+ return (
97
+ {
98
+ node.target.id: PydanticFieldWrapper(
99
+ annotation=fields[node.target.id].annotation,
100
+ init_model_field=fields[node.target.id],
101
+ annotation_ast=node.annotation,
102
+ value_ast=node.value,
103
+ )
104
+ for node in cls_ast.body
105
+ if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.target.id in fields
106
+ },
107
+ validators,
108
+ )
81
109
 
82
110
 
83
111
  @dataclass(slots=True)
cadwyn/codegen/_main.py CHANGED
@@ -21,7 +21,7 @@ from cadwyn.codegen._common import (
21
21
  PydanticModelWrapper,
22
22
  _EnumWrapper,
23
23
  _ModuleWrapper,
24
- get_fields_from_model,
24
+ get_fields_and_validators_from_model,
25
25
  )
26
26
  from cadwyn.codegen._plugins.class_migrations import class_migration_plugin
27
27
  from cadwyn.codegen._plugins.class_rebuilding import ClassRebuildingPlugin
@@ -63,14 +63,15 @@ def generate_code_for_versioned_packages(
63
63
  version of the latest module.
64
64
  """
65
65
  extra_context = extra_context or {}
66
+ schemas = {}
67
+ for k, v in deepcopy(versions.versioned_schemas).items():
68
+ fields, validators = get_fields_and_validators_from_model(v)
69
+ schemas[k] = PydanticModelWrapper(v, v.__name__, fields, validators)
66
70
 
67
71
  _generate_versioned_directories(
68
72
  template_module,
69
73
  versions=list(versions),
70
- schemas={
71
- k: PydanticModelWrapper(v, v.__name__, get_fields_from_model(v))
72
- for k, v in deepcopy(versions.versioned_schemas).items()
73
- },
74
+ schemas=schemas,
74
75
  enums={
75
76
  k: _EnumWrapper(v, {member.name: member.value for member in v})
76
77
  for k, v in deepcopy(versions.versioned_enums).items()
@@ -1,21 +1,31 @@
1
1
  import ast
2
2
  from collections.abc import Sequence
3
+ from typing import Annotated, Any, cast, get_args, get_origin
3
4
 
4
5
  from typing_extensions import assert_never
5
6
 
6
- from cadwyn._compat import FieldInfo, PydanticFieldWrapper, dict_of_empty_field_info, is_pydantic_constrained_type
7
+ from cadwyn._compat import (
8
+ PYDANTIC_V2,
9
+ FieldInfo,
10
+ PydanticFieldWrapper,
11
+ dict_of_empty_field_info,
12
+ is_constrained_type,
13
+ )
7
14
  from cadwyn._package_utils import IdentifierPythonPath, get_cls_pythonpath
8
15
  from cadwyn._utils import Sentinel
9
- from cadwyn.codegen._asts import add_keyword_to_call
16
+ from cadwyn.codegen._asts import add_keyword_to_call, delete_keyword_from_call, get_fancy_repr
10
17
  from cadwyn.codegen._common import GlobalCodegenContext, PydanticModelWrapper, _EnumWrapper
11
18
  from cadwyn.exceptions import InvalidGenerationInstructionError
12
19
  from cadwyn.structure.enums import AlterEnumSubInstruction, EnumDidntHaveMembersInstruction, EnumHadMembersInstruction
13
20
  from cadwyn.structure.schemas import (
14
- AlterSchemaInstruction,
15
21
  AlterSchemaSubInstruction,
16
- OldSchemaFieldDidntExist,
17
- OldSchemaFieldExistedWith,
18
- OldSchemaFieldHad,
22
+ FieldDidntExistInstruction,
23
+ FieldDidntHaveInstruction,
24
+ FieldExistedAsInstruction,
25
+ FieldHadInstruction,
26
+ SchemaHadInstruction,
27
+ ValidatorDidntExistInstruction,
28
+ ValidatorExistedInstruction,
19
29
  )
20
30
 
21
31
 
@@ -35,26 +45,41 @@ def class_migration_plugin(context: GlobalCodegenContext):
35
45
 
36
46
  def _apply_alter_schema_instructions(
37
47
  modified_schemas: dict[IdentifierPythonPath, PydanticModelWrapper],
38
- alter_schema_instructions: Sequence[AlterSchemaSubInstruction | AlterSchemaInstruction],
48
+ alter_schema_instructions: Sequence[AlterSchemaSubInstruction | SchemaHadInstruction],
39
49
  version_change_name: str,
40
50
  ):
41
51
  for alter_schema_instruction in alter_schema_instructions:
42
52
  schema = alter_schema_instruction.schema
43
53
  schema_path = get_cls_pythonpath(schema)
44
- mutable_schema_info = modified_schemas[schema_path]
45
- if isinstance(alter_schema_instruction, OldSchemaFieldDidntExist):
46
- _delete_field_from_model(mutable_schema_info, alter_schema_instruction.field_name, version_change_name)
47
- elif isinstance(alter_schema_instruction, OldSchemaFieldHad):
54
+ schema_info = modified_schemas[schema_path]
55
+ if isinstance(alter_schema_instruction, FieldExistedAsInstruction):
56
+ _add_field_to_model(schema_info, modified_schemas, alter_schema_instruction, version_change_name)
57
+ elif isinstance(alter_schema_instruction, FieldHadInstruction | FieldDidntHaveInstruction):
48
58
  _change_field_in_model(
49
- mutable_schema_info,
59
+ schema_info,
50
60
  modified_schemas,
51
61
  alter_schema_instruction,
52
62
  version_change_name,
53
63
  )
54
- elif isinstance(alter_schema_instruction, OldSchemaFieldExistedWith):
55
- _add_field_to_model(mutable_schema_info, modified_schemas, alter_schema_instruction, version_change_name)
56
- elif isinstance(alter_schema_instruction, AlterSchemaInstruction):
57
- _change_model(mutable_schema_info, alter_schema_instruction, version_change_name)
64
+ elif isinstance(alter_schema_instruction, FieldDidntExistInstruction):
65
+ _delete_field_from_model(schema_info, alter_schema_instruction.name, version_change_name)
66
+ elif isinstance(alter_schema_instruction, ValidatorExistedInstruction):
67
+ validator_name = alter_schema_instruction.validator.__name__
68
+ schema_info.validators[validator_name] = alter_schema_instruction.validator_info
69
+ elif isinstance(alter_schema_instruction, ValidatorDidntExistInstruction):
70
+ if alter_schema_instruction.name not in schema_info.validators:
71
+ raise InvalidGenerationInstructionError(
72
+ f'You tried to delete a validator "{alter_schema_instruction.name}" from "{schema_info.name}" '
73
+ f'in "{version_change_name}" but it doesn\'t have such a validator.',
74
+ )
75
+ if schema_info.validators[alter_schema_instruction.name].is_deleted:
76
+ raise InvalidGenerationInstructionError(
77
+ f'You tried to delete a validator "{alter_schema_instruction.name}" from "{schema_info.name}" '
78
+ f'in "{version_change_name}" but it is already deleted.',
79
+ )
80
+ schema_info.validators[alter_schema_instruction.name].is_deleted = True
81
+ elif isinstance(alter_schema_instruction, SchemaHadInstruction):
82
+ _change_model(schema_info, alter_schema_instruction, version_change_name)
58
83
  else:
59
84
  assert_never(alter_schema_instruction)
60
85
 
@@ -90,7 +115,7 @@ def _apply_alter_enum_instructions(
90
115
 
91
116
  def _change_model(
92
117
  model: PydanticModelWrapper,
93
- alter_schema_instruction: AlterSchemaInstruction,
118
+ alter_schema_instruction: SchemaHadInstruction,
94
119
  version_change_name: str,
95
120
  ):
96
121
  # We only handle names right now so we just go ahead and check
@@ -99,69 +124,144 @@ def _change_model(
99
124
  f'You tried to change the name of "{model.name}" in "{version_change_name}" '
100
125
  "but it already has the name you tried to assign.",
101
126
  )
127
+
102
128
  model.name = alter_schema_instruction.name
103
129
 
104
130
 
105
131
  def _add_field_to_model(
106
132
  model: PydanticModelWrapper,
107
133
  schemas: "dict[IdentifierPythonPath, PydanticModelWrapper]",
108
- alter_schema_instruction: OldSchemaFieldExistedWith,
134
+ alter_schema_instruction: FieldExistedAsInstruction,
109
135
  version_change_name: str,
110
136
  ):
111
- defined_fields = model._get_defined_fields(schemas)
112
- if alter_schema_instruction.field_name in defined_fields:
137
+ defined_fields = model._get_defined_fields_through_mro(schemas)
138
+ if alter_schema_instruction.name in defined_fields:
113
139
  raise InvalidGenerationInstructionError(
114
- f'You tried to add a field "{alter_schema_instruction.field_name}" to "{model.name}" '
140
+ f'You tried to add a field "{alter_schema_instruction.name}" to "{model.name}" '
115
141
  f'in "{version_change_name}" but there is already a field with that name.',
116
142
  )
117
143
 
118
- model.fields[alter_schema_instruction.field_name] = PydanticFieldWrapper(
119
- annotation_ast=None,
144
+ fancy_type_repr = get_fancy_repr(alter_schema_instruction.type)
145
+ field = PydanticFieldWrapper(
146
+ annotation_ast=ast.parse(fancy_type_repr, mode="eval").body,
120
147
  annotation=alter_schema_instruction.type,
121
148
  init_model_field=alter_schema_instruction.field,
122
- field_ast=None,
149
+ value_ast=None,
123
150
  )
151
+ model.fields[alter_schema_instruction.name] = field
152
+
153
+ passed_field_attributes = field.passed_field_attributes
154
+ if passed_field_attributes:
155
+ field_call_ast = cast(ast.Call, ast.parse("Field()", mode="eval").body)
156
+ for attr_name, attr_value in passed_field_attributes.items():
157
+ add_keyword_to_call(attr_name, attr_value, field_call_ast)
158
+ field.value_ast = field_call_ast
159
+ model.cls.__annotations__[alter_schema_instruction.name] = alter_schema_instruction.type
124
160
 
125
161
 
126
162
  def _change_field_in_model(
127
163
  model: PydanticModelWrapper,
128
164
  schemas: "dict[IdentifierPythonPath, PydanticModelWrapper]",
129
- alter_schema_instruction: OldSchemaFieldHad,
165
+ alter_schema_instruction: FieldHadInstruction | FieldDidntHaveInstruction,
130
166
  version_change_name: str,
131
167
  ):
132
- defined_fields = model._get_defined_fields(schemas)
133
- if alter_schema_instruction.field_name not in defined_fields:
168
+ defined_annotations = model._get_defined_annotations_through_mro(schemas)
169
+ defined_fields = model._get_defined_fields_through_mro(schemas)
170
+ if alter_schema_instruction.name not in defined_fields:
134
171
  raise InvalidGenerationInstructionError(
135
- f'You tried to change the type of field "{alter_schema_instruction.field_name}" from '
172
+ f'You tried to change the field "{alter_schema_instruction.name}" from '
136
173
  f'"{model.name}" in "{version_change_name}" but it doesn\'t have such a field.',
137
174
  )
138
175
 
139
- field = defined_fields[alter_schema_instruction.field_name]
140
- model.fields[alter_schema_instruction.field_name] = field
176
+ field = defined_fields[alter_schema_instruction.name]
177
+ model.fields[alter_schema_instruction.name] = field
178
+ model.cls.__annotations__[alter_schema_instruction.name] = defined_annotations[alter_schema_instruction.name]
179
+
180
+ annotation_ast, field_call_ast, contype_is_definitely_used = _get_constraint_asts_and_field_call_ast(
181
+ schemas, model, alter_schema_instruction.name, field
182
+ )
183
+
184
+ constrained_type_annotation = None
185
+
186
+ if annotation_ast is not None:
187
+ field_type_is_annotated = True # I.e. typing.Annotated
188
+ # PydanticV2 changed field annotation handling so field.annotation lies to us
189
+ real_annotation = model._get_defined_annotations_through_mro(schemas)[alter_schema_instruction.name]
190
+ type_annotation = get_args(real_annotation)[0]
191
+ if is_constrained_type(type_annotation):
192
+ constrained_type_annotation = type_annotation
193
+ else:
194
+ field_type_is_annotated = False
195
+ type_annotation = field.annotation
196
+ if is_constrained_type(type_annotation):
197
+ constrained_type_annotation = type_annotation
198
+ annotation_ast = field.annotation_ast
199
+ if field_call_ast is None:
200
+ field_call_ast = field.value_ast
141
201
 
142
- current_field_is_constrained_type = is_pydantic_constrained_type(field.annotation)
202
+ if isinstance(alter_schema_instruction, FieldHadInstruction):
203
+ # TODO: This naming sucks
204
+ _change_field(
205
+ model,
206
+ alter_schema_instruction,
207
+ version_change_name,
208
+ defined_annotations,
209
+ field,
210
+ annotation_ast,
211
+ field_call_ast,
212
+ type_annotation,
213
+ field_type_is_annotated,
214
+ constrained_type_annotation,
215
+ )
216
+ else:
217
+ _delete_field_attributes(
218
+ model,
219
+ alter_schema_instruction,
220
+ version_change_name,
221
+ field,
222
+ annotation_ast,
223
+ field_call_ast,
224
+ type_annotation,
225
+ constrained_type_annotation,
226
+ contype_is_definitely_used,
227
+ )
228
+
229
+
230
+ def _change_field( # noqa: C901
231
+ model: PydanticModelWrapper,
232
+ alter_schema_instruction: FieldHadInstruction,
233
+ version_change_name: str,
234
+ defined_annotations: dict[str, Any],
235
+ field: PydanticFieldWrapper,
236
+ annotation_ast: ast.expr | None,
237
+ field_call_ast: ast.expr | None,
238
+ type_annotation: Any,
239
+ field_type_is_annotated: bool,
240
+ constrained_type_annotation: Any | None,
241
+ ):
143
242
  if alter_schema_instruction.type is not Sentinel:
144
243
  if field.annotation == alter_schema_instruction.type:
145
244
  raise InvalidGenerationInstructionError(
146
- f'You tried to change the type of field "{alter_schema_instruction.field_name}" to '
245
+ f'You tried to change the type of field "{alter_schema_instruction.name}" to '
147
246
  f'"{alter_schema_instruction.type}" from "{model.name}" in "{version_change_name}" '
148
247
  f'but it already has type "{field.annotation}"',
149
248
  )
150
249
  field.annotation = alter_schema_instruction.type
151
-
152
- field.annotation_ast = None
153
- if current_field_is_constrained_type:
154
- field.field_ast = None
250
+ model.cls.__annotations__[alter_schema_instruction.name] = alter_schema_instruction.type
251
+ fancy_type_repr = get_fancy_repr(alter_schema_instruction.type)
252
+ field.annotation_ast = ast.parse(fancy_type_repr, mode="eval").body
155
253
 
156
254
  if alter_schema_instruction.new_name is not Sentinel:
157
- if alter_schema_instruction.new_name == alter_schema_instruction.field_name:
255
+ if alter_schema_instruction.new_name == alter_schema_instruction.name:
158
256
  raise InvalidGenerationInstructionError(
159
- f'You tried to change the name of field "{alter_schema_instruction.field_name}" '
257
+ f'You tried to change the name of field "{alter_schema_instruction.name}" '
160
258
  f'from "{model.name}" in "{version_change_name}" '
161
259
  "but it already has that name.",
162
260
  )
163
- model.fields[alter_schema_instruction.new_name] = model.fields.pop(
164
- alter_schema_instruction.field_name,
261
+ model.fields[alter_schema_instruction.new_name] = model.fields.pop(alter_schema_instruction.name)
262
+ model.cls.__annotations__[alter_schema_instruction.new_name] = model.cls.__annotations__.pop(
263
+ alter_schema_instruction.name,
264
+ defined_annotations[alter_schema_instruction.name],
165
265
  )
166
266
 
167
267
  field_info = field.field_info
@@ -176,26 +276,129 @@ def _change_field_in_model(
176
276
  if field.passed_field_attributes.get(attr_name, Sentinel) == attr_value:
177
277
  raise InvalidGenerationInstructionError(
178
278
  f'You tried to change the attribute "{attr_name}" of field '
179
- f'"{alter_schema_instruction.field_name}" '
279
+ f'"{alter_schema_instruction.name}" '
180
280
  f'from "{model.name}" to {attr_value!r} in "{version_change_name}" '
181
281
  "but it already has that value.",
182
282
  )
283
+ if constrained_type_annotation is not None and hasattr(constrained_type_annotation, attr_name):
284
+ _setattr_on_constrained_type(constrained_type_annotation, attr_name, attr_value)
285
+ if isinstance(annotation_ast, ast.Call):
286
+ add_keyword_to_call(attr_name, attr_value, annotation_ast)
287
+ elif not PYDANTIC_V2: # pragma: no branch
288
+ field.update_attribute(name=attr_name, value=attr_value)
289
+ if isinstance(field_call_ast, ast.Call): # pragma: no branch
290
+ add_keyword_to_call(attr_name, attr_value, field_call_ast)
183
291
 
184
- if hasattr(field.annotation, attr_name) and current_field_is_constrained_type:
185
- setattr(field.annotation, attr_name, attr_value)
186
- ann_ast = field.annotation_ast
187
- if ann_ast is not None and isinstance(ann_ast, ast.Call):
188
- add_keyword_to_call(attr_name, attr_value, ann_ast)
189
- else:
190
- field.field_ast = None
191
- field.annotation_ast = None
192
292
  else:
193
293
  field.update_attribute(name=attr_name, value=attr_value)
194
- field_ast = field.field_ast
195
- if isinstance(field_ast, ast.Call):
196
- add_keyword_to_call(attr_name, attr_value, field_ast)
294
+ if field_type_is_annotated and attr_name == "default":
295
+ field.value_ast = ast.parse(get_fancy_repr(attr_value), mode="eval").body
296
+ elif isinstance(field_call_ast, ast.Call):
297
+ add_keyword_to_call(attr_name, attr_value, field_call_ast)
298
+ elif field.value_ast is not None:
299
+ field_call_ast = cast(ast.Call, ast.parse("Field()", mode="eval").body)
300
+ add_keyword_to_call("default", field.value_ast, field_call_ast)
301
+ add_keyword_to_call(attr_name, attr_value, field_call_ast)
302
+ field.value_ast = field_call_ast
197
303
  else:
198
- field.field_ast = None
304
+ field.value_ast = cast(ast.Call, ast.parse("Field()", mode="eval").body)
305
+ field_call_ast = field.value_ast
306
+ add_keyword_to_call(attr_name, attr_value, field_call_ast)
307
+
308
+
309
+ def _setattr_on_constrained_type(constrained_type_annotation: Any, attr_name: str, attr_value: Any) -> None:
310
+ setattr(constrained_type_annotation, attr_name, attr_value)
311
+
312
+
313
+ def _delete_field_attributes(
314
+ model: PydanticModelWrapper,
315
+ alter_schema_instruction: FieldDidntHaveInstruction,
316
+ version_change_name: str,
317
+ field: PydanticFieldWrapper,
318
+ type_annotation_ast: ast.expr | None,
319
+ field_call_ast: ast.expr | None,
320
+ type_annotation: Any,
321
+ constrained_type_annotation: Any,
322
+ contype_is_definitely_used: bool,
323
+ ) -> None:
324
+ for attr_name in alter_schema_instruction.attributes:
325
+ if attr_name in field.passed_field_attributes:
326
+ field.delete_attribute(name=attr_name)
327
+ if isinstance(field_call_ast, ast.Call):
328
+ delete_keyword_from_call(attr_name, field_call_ast)
329
+ elif attr_name == "default": # pragma: no branch
330
+ field.value_ast = None
331
+ # In case annotation_ast is a conint/constr/etc. Notice how we do not support
332
+ # the same operation for **adding** constraints for simplicity.
333
+ elif (hasattr(constrained_type_annotation, attr_name)) or contype_is_definitely_used:
334
+ if hasattr(constrained_type_annotation, attr_name):
335
+ _setattr_on_constrained_type(constrained_type_annotation, attr_name, None)
336
+ if isinstance(type_annotation_ast, ast.Call): # pragma: no branch
337
+ delete_keyword_from_call(attr_name, type_annotation_ast)
338
+ else:
339
+ raise InvalidGenerationInstructionError(
340
+ f'You tried to delete the attribute "{attr_name}" of field "{alter_schema_instruction.name}" '
341
+ f'from "{model.name}" in "{version_change_name}" '
342
+ "but it already doesn't have that attribute.",
343
+ )
344
+
345
+
346
+ ContypeIsDefinitelyUsed = bool
347
+ CONTYPES = (
348
+ "conbytes",
349
+ "condate",
350
+ "condecimal",
351
+ "confloat",
352
+ "conint",
353
+ "conlist",
354
+ "conset",
355
+ "constr",
356
+ )
357
+
358
+
359
+ def _get_constraint_asts_and_field_call_ast(
360
+ schemas: dict[IdentifierPythonPath, PydanticModelWrapper],
361
+ model: PydanticModelWrapper,
362
+ field_name: str,
363
+ field: PydanticFieldWrapper,
364
+ ) -> tuple[ast.expr | None, ast.Call | None, ContypeIsDefinitelyUsed]:
365
+ """If the field type is Annotated and contains "Field" """
366
+ # We return both annotation ast and field call ast because annotation might be a constrained type such as conint
367
+ # and therefore contain constraints that we might want to remove.
368
+
369
+ # ContypeIsDefinitely used is used to determine whether constr/conint/etc is used in Pydantic 2
370
+ # because pydantic 2 changes original type hints to make sure that conint/constr/etc do not appear in annotations.
371
+
372
+ real_annotation = model._get_defined_annotations_through_mro(schemas)[field_name]
373
+ # typing.Annotated scenario
374
+ if get_origin(real_annotation) == Annotated:
375
+ index_of_field_info = _find_index_of_field_info_in_annotated(real_annotation)
376
+ if not (isinstance(field.annotation_ast, ast.Subscript) and isinstance(field.annotation_ast.slice, ast.Tuple)):
377
+ return (field.annotation_ast, None, True)
378
+
379
+ unparsed_annotation = ast.unparse(field.annotation_ast.slice.elts[0])
380
+ contype_is_definitely_used = any(contype in unparsed_annotation for contype in CONTYPES)
381
+
382
+ # In pydantic 2, this means that in fact there is conint/constr/etc instead of an actual Annotated.
383
+ # Yes, pydantic 2 changes original type hints to make sure that conint/constr/etc do not appear in types.
384
+
385
+ if index_of_field_info is not None:
386
+ field_call_ast = field.annotation_ast.slice.elts[index_of_field_info]
387
+
388
+ return (field.annotation_ast.slice.elts[0], cast(ast.Call, field_call_ast), contype_is_definitely_used)
389
+ return (field.annotation_ast.slice.elts[0], None, contype_is_definitely_used)
390
+ return (None, None, False)
391
+
392
+
393
+ def _find_index_of_field_info_in_annotated(real_annotation: Any):
394
+ # Pydantic turns `Annotated[conint(lt=2 + 5), Field(default=11), annotated_types.Gt(0)]` into:
395
+ # `Annotated[int, None, Interval(lt=7), None, FieldInfo(default=11), annotated_types.Gt(0)]`
396
+ # Why? No idea. Probably due to its weird handling of constrained types. So we gotta go from the last element
397
+ # because constrained types can only appear and mess up indexing in the first element.
398
+ for i, arg in enumerate(reversed(get_args(real_annotation)), start=1):
399
+ if isinstance(arg, FieldInfo):
400
+ return -i
401
+ return None
199
402
 
200
403
 
201
404
  def _delete_field_from_model(model: PydanticModelWrapper, field_name: str, version_change_name: str):
@@ -205,3 +408,16 @@ def _delete_field_from_model(model: PydanticModelWrapper, field_name: str, versi
205
408
  f'in "{version_change_name}" but it doesn\'t have such a field.',
206
409
  )
207
410
  model.fields.pop(field_name)
411
+ for validator_name, validator in model.validators.copy().items():
412
+ if validator.field_names is not None and field_name in validator.field_names:
413
+ validator.field_names.remove(field_name)
414
+
415
+ validator_decorator = cast(
416
+ ast.Call, validator.func_ast.decorator_list[validator.index_of_validator_decorator]
417
+ )
418
+ for arg in validator_decorator.args.copy():
419
+ if isinstance(arg, ast.Constant) and arg.value == field_name:
420
+ validator_decorator.args.remove(arg)
421
+ validator.func_ast.decorator_list[0]
422
+ if not validator.field_names:
423
+ model.validators[validator_name].is_deleted = True
@@ -2,14 +2,8 @@ import ast
2
2
  import copy
3
3
  from typing import Any
4
4
 
5
- from cadwyn._compat import (
6
- PydanticFieldWrapper,
7
- get_attrs_that_are_not_from_field_and_that_are_from_field,
8
- is_pydantic_constrained_type,
9
- )
10
5
  from cadwyn._package_utils import IdentifierPythonPath, get_absolute_python_path_of_import
11
6
  from cadwyn.codegen._asts import (
12
- get_ast_keyword_from_argument_name_and_value,
13
7
  get_fancy_repr,
14
8
  pop_docstring_from_cls_body,
15
9
  )
@@ -92,48 +86,28 @@ def _modify_schema_cls(
92
86
  field_definitions = [
93
87
  ast.AnnAssign(
94
88
  target=ast.Name(name, ctx=ast.Store()),
95
- annotation=_render_annotation(field.get_annotation_for_rendering()),
96
- value=_generate_field_ast(field),
89
+ annotation=copy.deepcopy(field.annotation_ast),
90
+ # We do this because next plugins **might** use a transformer which will edit the ast within the field
91
+ # and break rendering
92
+ value=copy.deepcopy(field.value_ast),
97
93
  simple=1,
98
94
  )
99
95
  for name, field in model_info.fields.items()
100
96
  ]
97
+ validator_definitions = [
98
+ validator.func_ast for validator in model_info.validators.values() if not validator.is_deleted
99
+ ]
101
100
 
102
- old_body = [n for n in cls_node.body if not isinstance(n, ast.AnnAssign | ast.Assign | ast.Pass | ast.Constant)]
101
+ old_body = [
102
+ n
103
+ for n in cls_node.body
104
+ if not (
105
+ isinstance(n, ast.AnnAssign | ast.Assign | ast.Pass | ast.Constant)
106
+ or (isinstance(n, ast.FunctionDef) and n.name in model_info.validators)
107
+ )
108
+ ]
103
109
  docstring = pop_docstring_from_cls_body(old_body)
104
- cls_node.body = docstring + field_definitions + old_body
110
+ cls_node.body = docstring + field_definitions + validator_definitions + old_body
105
111
  if not cls_node.body:
106
112
  cls_node.body = [ast.Pass()]
107
113
  return cls_node
108
-
109
-
110
- def _render_annotation(annotation: Any):
111
- if isinstance(annotation, ast.AST):
112
- return copy.deepcopy(annotation)
113
- return ast.parse(get_fancy_repr(annotation), mode="eval").body
114
-
115
-
116
- def _generate_field_ast(field: PydanticFieldWrapper):
117
- if field.field_ast is not None:
118
- # We do this because next plugins **might** use a transformer which will edit the ast within the field
119
- # and break rendering
120
- return copy.deepcopy(field.field_ast)
121
- passed_attrs = field.passed_field_attributes
122
- if is_pydantic_constrained_type(field.annotation) and field.annotation_ast is None:
123
- (
124
- attrs_that_are_only_in_contype,
125
- attrs_that_are_only_in_field,
126
- ) = get_attrs_that_are_not_from_field_and_that_are_from_field(field.annotation)
127
- if not attrs_that_are_only_in_contype:
128
- passed_attrs |= attrs_that_are_only_in_field
129
-
130
- if passed_attrs:
131
- return ast.Call(
132
- func=ast.Name("Field"),
133
- args=[],
134
- keywords=[
135
- get_ast_keyword_from_argument_name_and_value(attr, attr_value)
136
- for attr, attr_value in passed_attrs.items()
137
- ],
138
- )
139
- return None
@@ -5,6 +5,7 @@ from cadwyn.codegen._common import CodegenContext
5
5
 
6
6
  _extra_imports: list[tuple[str, str]] = [
7
7
  ("typing", "import typing"),
8
+ ("pydantic", "import pydantic"),
8
9
  ("Any", "from typing import Any"),
9
10
  ("Annotated", "from typing import Annotated"),
10
11
  ("Field", "from pydantic import Field"),
@@ -16,6 +17,8 @@ _extra_imports: list[tuple[str, str]] = [
16
17
  ("confloat", "from pydantic import confloat"),
17
18
  ("condecimal", "from pydantic import condecimal"),
18
19
  ("condate", "from pydantic import condate"),
20
+ ("validator", "from pydantic import validator"),
21
+ ("root_validator", "from pydantic import root_validator"),
19
22
  ]
20
23
 
21
24
 
@@ -29,6 +32,8 @@ if PYDANTIC_V2:
29
32
  ("StrictFloat", "from pydantic import StrictFloat"),
30
33
  ("StrictInt", "from pydantic import StrictInt"),
31
34
  ("StrictStr", "from pydantic import StrictStr"),
35
+ ("model_validator", "from pydantic import model_validator"),
36
+ ("field_validator", "from pydantic import field_validator"),
32
37
  ],
33
38
  )
34
39
 
cadwyn/structure/data.py CHANGED
@@ -73,7 +73,7 @@ class _AlterDataInstruction:
73
73
  signature = inspect.signature(self.transformer)
74
74
  if list(signature.parameters) != [self._payload_arg_name]:
75
75
  raise ValueError(
76
- f"Method '{self.transformer.__name__}' must have 2 parameters: cls and {self._payload_arg_name}",
76
+ f"Method '{self.transformer.__name__}' must have only 1 parameter: {self._payload_arg_name}",
77
77
  )
78
78
 
79
79
  functools.update_wrapper(self, self.transformer)
@@ -1,19 +1,52 @@
1
+ import ast
2
+ import inspect
3
+ import textwrap
1
4
  from collections.abc import Callable
2
- from dataclasses import dataclass
3
- from typing import TYPE_CHECKING, Any
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING, Any, Literal
4
7
 
5
8
  from pydantic import BaseModel, Field
6
9
  from pydantic.fields import FieldInfo
7
10
 
11
+ from cadwyn._compat import PYDANTIC_V2
12
+ from cadwyn._utils import Sentinel
13
+ from cadwyn.codegen._asts import _ValidatorWrapper, get_validator_info_or_none
8
14
  from cadwyn.exceptions import CadwynStructureError
9
15
 
10
- from .._compat import PYDANTIC_V2
11
- from .._utils import Sentinel
12
-
13
16
  if TYPE_CHECKING:
14
17
  from pydantic.typing import AbstractSetIntStr, MappingIntStrAny
15
18
 
16
19
 
20
+ PossibleFieldAttributes = Literal[
21
+ "default",
22
+ "default_factory",
23
+ "alias",
24
+ "title",
25
+ "description",
26
+ "exclude",
27
+ "include",
28
+ "const",
29
+ "gt",
30
+ "ge",
31
+ "lt",
32
+ "le",
33
+ "multiple_of",
34
+ "allow_inf_nan",
35
+ "max_digits",
36
+ "decimal_places",
37
+ "min_items",
38
+ "max_items",
39
+ "unique_items",
40
+ "min_length",
41
+ "max_length",
42
+ "allow_mutation",
43
+ "regex",
44
+ "pattern",
45
+ "discriminator",
46
+ "repr",
47
+ ]
48
+
49
+
17
50
  @dataclass(slots=True)
18
51
  class FieldChanges:
19
52
  default: Any
@@ -45,24 +78,31 @@ class FieldChanges:
45
78
 
46
79
 
47
80
  @dataclass(slots=True)
48
- class OldSchemaFieldHad:
81
+ class FieldHadInstruction:
49
82
  schema: type[BaseModel]
50
- field_name: str
83
+ name: str
51
84
  type: type
52
85
  field_changes: FieldChanges
53
86
  new_name: str
54
87
 
55
88
 
56
89
  @dataclass(slots=True)
57
- class OldSchemaFieldDidntExist:
90
+ class FieldDidntHaveInstruction:
58
91
  schema: type[BaseModel]
59
- field_name: str
92
+ name: str
93
+ attributes: tuple[str, ...]
60
94
 
61
95
 
62
96
  @dataclass(slots=True)
63
- class OldSchemaFieldExistedWith:
97
+ class FieldDidntExistInstruction:
64
98
  schema: type[BaseModel]
65
- field_name: str
99
+ name: str
100
+
101
+
102
+ @dataclass(slots=True)
103
+ class FieldExistedAsInstruction:
104
+ schema: type[BaseModel]
105
+ name: str
66
106
  type: type
67
107
  field: FieldInfo
68
108
 
@@ -104,7 +144,7 @@ class AlterFieldInstructionFactory:
104
144
  pattern: str = Sentinel,
105
145
  discriminator: str = Sentinel,
106
146
  repr: bool = Sentinel,
107
- ) -> OldSchemaFieldHad:
147
+ ) -> FieldHadInstruction:
108
148
  if PYDANTIC_V2:
109
149
  if regex is not Sentinel:
110
150
  raise CadwynStructureError("`regex` was removed in Pydantic 2. Use `pattern` instead")
@@ -122,9 +162,9 @@ class AlterFieldInstructionFactory:
122
162
  else:
123
163
  if pattern is not Sentinel:
124
164
  raise CadwynStructureError("`pattern` is only available in Pydantic 2. use `regex` instead")
125
- return OldSchemaFieldHad(
165
+ return FieldHadInstruction(
126
166
  schema=self.schema,
127
- field_name=self.name,
167
+ name=self.name,
128
168
  type=type,
129
169
  new_name=name,
130
170
  field_changes=FieldChanges(
@@ -157,29 +197,82 @@ class AlterFieldInstructionFactory:
157
197
  ),
158
198
  )
159
199
 
200
+ def didnt_have(self, *attributes: PossibleFieldAttributes) -> FieldDidntHaveInstruction:
201
+ for attribute in attributes:
202
+ if attribute not in FieldChanges.__dataclass_fields__:
203
+ raise CadwynStructureError(
204
+ f"Unknown attribute {attribute!r}. Are you sure it's a valid field attribute?"
205
+ )
206
+ return FieldDidntHaveInstruction(self.schema, self.name, attributes)
207
+
160
208
  @property
161
- def didnt_exist(self) -> OldSchemaFieldDidntExist:
162
- return OldSchemaFieldDidntExist(self.schema, field_name=self.name)
209
+ def didnt_exist(self) -> FieldDidntExistInstruction:
210
+ return FieldDidntExistInstruction(self.schema, name=self.name)
163
211
 
164
212
  def existed_as(
165
213
  self,
166
214
  *,
167
215
  type: Any,
168
216
  info: FieldInfo | None = None,
169
- ) -> OldSchemaFieldExistedWith:
170
- return OldSchemaFieldExistedWith(
217
+ ) -> FieldExistedAsInstruction:
218
+ return FieldExistedAsInstruction(
171
219
  self.schema,
172
- field_name=self.name,
220
+ name=self.name,
173
221
  type=type,
174
222
  field=info or Field(),
175
223
  )
176
224
 
177
225
 
178
- AlterSchemaSubInstruction = OldSchemaFieldHad | OldSchemaFieldDidntExist | OldSchemaFieldExistedWith
226
+ @dataclass(slots=True)
227
+ class ValidatorExistedInstruction:
228
+ schema: type[BaseModel]
229
+ validator: Callable[..., Any]
230
+ validator_info: _ValidatorWrapper = field(init=False)
231
+
232
+ def __post_init__(self):
233
+ source = textwrap.dedent(inspect.getsource(self.validator))
234
+ validator_ast = ast.parse(source).body[0]
235
+ if not isinstance(validator_ast, ast.FunctionDef):
236
+ raise CadwynStructureError("The passed validator must be a function")
237
+
238
+ validator_info = get_validator_info_or_none(validator_ast)
239
+ if validator_info is None:
240
+ raise CadwynStructureError("The passed function must be a pydantic validator")
241
+ self.validator_info = validator_info
179
242
 
180
243
 
181
244
  @dataclass(slots=True)
182
- class AlterSchemaInstruction:
245
+ class ValidatorDidntExistInstruction:
246
+ schema: type[BaseModel]
247
+ name: str
248
+
249
+
250
+ @dataclass(slots=True)
251
+ class AlterValidatorInstructionFactory:
252
+ schema: type[BaseModel]
253
+ func: Callable[..., Any]
254
+
255
+ @property
256
+ def existed(self) -> ValidatorExistedInstruction:
257
+ return ValidatorExistedInstruction(self.schema, self.func)
258
+
259
+ @property
260
+ def didnt_exist(self) -> ValidatorDidntExistInstruction:
261
+ return ValidatorDidntExistInstruction(self.schema, self.func.__name__)
262
+
263
+
264
+ AlterSchemaSubInstruction = (
265
+ FieldHadInstruction
266
+ | FieldDidntHaveInstruction
267
+ | FieldDidntExistInstruction
268
+ | FieldExistedAsInstruction
269
+ | ValidatorExistedInstruction
270
+ | ValidatorDidntExistInstruction
271
+ )
272
+
273
+
274
+ @dataclass(slots=True)
275
+ class SchemaHadInstruction:
183
276
  schema: type[BaseModel]
184
277
  name: str
185
278
 
@@ -191,8 +284,13 @@ class AlterSchemaInstructionFactory:
191
284
  def field(self, name: str, /) -> AlterFieldInstructionFactory:
192
285
  return AlterFieldInstructionFactory(self.schema, name)
193
286
 
194
- def had(self, *, name: str) -> AlterSchemaInstruction:
195
- return AlterSchemaInstruction(self.schema, name)
287
+ def validator(self, func: "Callable[..., Any] | classmethod[Any, Any, Any]", /) -> AlterValidatorInstructionFactory:
288
+ if isinstance(func, classmethod):
289
+ func = func.__wrapped__
290
+ return AlterValidatorInstructionFactory(self.schema, func)
291
+
292
+ def had(self, *, name: str) -> SchemaHadInstruction:
293
+ return SchemaHadInstruction(self.schema, name)
196
294
 
197
295
 
198
296
  def schema(model: type[BaseModel], /) -> AlterSchemaInstructionFactory:
@@ -25,7 +25,7 @@ from pydantic import BaseModel
25
25
  from starlette._utils import is_async_callable
26
26
  from typing_extensions import assert_never
27
27
 
28
- from cadwyn._compat import PYDANTIC_V2, ModelField, Undefined, model_dump
28
+ from cadwyn._compat import PYDANTIC_V2, ModelField, PydanticUndefined, model_dump
29
29
  from cadwyn._package_utils import IdentifierPythonPath, get_cls_pythonpath
30
30
  from cadwyn.exceptions import CadwynError, CadwynStructureError
31
31
 
@@ -42,7 +42,7 @@ from .data import (
42
42
  from .endpoints import AlterEndpointSubInstruction
43
43
  from .enums import AlterEnumSubInstruction
44
44
  from .modules import AlterModuleInstruction
45
- from .schemas import AlterSchemaInstruction, AlterSchemaSubInstruction
45
+ from .schemas import AlterSchemaSubInstruction, SchemaHadInstruction
46
46
 
47
47
  _CADWYN_REQUEST_PARAM_NAME = "cadwyn_request_param"
48
48
  _CADWYN_RESPONSE_PARAM_NAME = "cadwyn_response_param"
@@ -52,7 +52,7 @@ PossibleInstructions: TypeAlias = (
52
52
  AlterSchemaSubInstruction
53
53
  | AlterEndpointSubInstruction
54
54
  | AlterEnumSubInstruction
55
- | AlterSchemaInstruction
55
+ | SchemaHadInstruction
56
56
  | AlterModuleInstruction
57
57
  | staticmethod
58
58
  )
@@ -62,7 +62,7 @@ APIVersionVarType: TypeAlias = ContextVar[VersionDate | None] | ContextVar[Versi
62
62
  class VersionChange:
63
63
  description: ClassVar[str] = Sentinel
64
64
  instructions_to_migrate_to_previous_version: ClassVar[Sequence[PossibleInstructions]] = Sentinel
65
- alter_schema_instructions: ClassVar[list[AlterSchemaSubInstruction | AlterSchemaInstruction]] = Sentinel
65
+ alter_schema_instructions: ClassVar[list[AlterSchemaSubInstruction | SchemaHadInstruction]] = Sentinel
66
66
  alter_enum_instructions: ClassVar[list[AlterEnumSubInstruction]] = Sentinel
67
67
  alter_module_instructions: ClassVar[list[AlterModuleInstruction]] = Sentinel
68
68
  alter_endpoint_instructions: ClassVar[list[AlterEndpointSubInstruction]] = Sentinel
@@ -116,7 +116,7 @@ class VersionChange:
116
116
  cls.alter_response_by_schema_instructions = {}
117
117
  cls.alter_response_by_path_instructions = defaultdict(list)
118
118
  for alter_instruction in cls.instructions_to_migrate_to_previous_version:
119
- if isinstance(alter_instruction, AlterSchemaInstruction | AlterSchemaSubInstruction):
119
+ if isinstance(alter_instruction, SchemaHadInstruction | AlterSchemaSubInstruction):
120
120
  cls.alter_schema_instructions.append(alter_instruction)
121
121
  elif isinstance(alter_instruction, AlterEnumSubInstruction):
122
122
  cls.alter_enum_instructions.append(alter_instruction)
@@ -567,7 +567,7 @@ async def _get_body(request: FastapiRequest, body_field: ModelField | None): #
567
567
  else:
568
568
  body_bytes = await request.body()
569
569
  if body_bytes:
570
- json_body: Any = Undefined
570
+ json_body: Any = PydanticUndefined
571
571
  content_type_value = request.headers.get("content-type")
572
572
  if not content_type_value:
573
573
  json_body = await request.json()
@@ -578,7 +578,7 @@ async def _get_body(request: FastapiRequest, body_field: ModelField | None): #
578
578
  subtype = message.get_content_subtype()
579
579
  if subtype == "json" or subtype.endswith("+json"):
580
580
  json_body = await request.json()
581
- if json_body != Undefined:
581
+ if json_body != PydanticUndefined:
582
582
  body = json_body
583
583
  else:
584
584
  body = body_bytes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cadwyn
3
- Version: 3.3.4
3
+ Version: 3.4.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
@@ -1,18 +1,18 @@
1
1
  cadwyn/__init__.py,sha256=gVLVH3SSBGH0IQYGL5tbro4s0vk--9sAym0UvoG3s1w,478
2
2
  cadwyn/__main__.py,sha256=JUNmAhwn7tG1EeXI82QmFZE-fpjfAOv2kxFNDfxWbhQ,2851
3
- cadwyn/_compat.py,sha256=yUXMFK_PS7jOzKktHDcnwwCt4iT3O3eqrgBRliOSKrY,5782
3
+ cadwyn/_compat.py,sha256=B0K-cj9bN7ytOIehOMjN9O9s0CE3lq9WAkV97pnvll8,5410
4
4
  cadwyn/_package_utils.py,sha256=trxTYLmppv-10SKhScfyDQJh21rsQGFoLaOtHycKKR0,1443
5
5
  cadwyn/_utils.py,sha256=gEv_1Qqp7uzvdY0u_l77d6lppAsNsOCjD8FEFBKi3Js,3758
6
6
  cadwyn/codegen/README.md,sha256=V2Kz2IOz1cTxrC-RnQ7YbWEVCIGYr3tR4IPCvepeq0M,1047
7
7
  cadwyn/codegen/__init__.py,sha256=JgddDjxMTjSfVrMXHwNu1ODgdn2QfPWpccrRKquBV6k,355
8
- cadwyn/codegen/_asts.py,sha256=vWQRIpVYA63ku2Su8a3i_uTF0l81LHZmuWltnLoZYLk,9213
9
- cadwyn/codegen/_common.py,sha256=iy7CoM6wRaFcouE1L_opHMgz0CDQybRjTxrJGj5QZtw,4586
10
- cadwyn/codegen/_main.py,sha256=IW-J_9fRfOIkfxiPbkal2QjDzpn4Y2fW-4YeTtB_sgs,9032
8
+ cadwyn/codegen/_asts.py,sha256=rwg3FMC9c_20rawub98UTWzL8hhkBgJ0RdJsqnW9bVE,10161
9
+ cadwyn/codegen/_common.py,sha256=6vU9RtDPkXtuseRDtHeBbWYSTFwGtONv4OCA7BQrr3I,5651
10
+ cadwyn/codegen/_main.py,sha256=wiadc3OYn1MlLwirfWuhkanvr2El-GjeQJpmpxHc4jA,9122
11
11
  cadwyn/codegen/_plugins/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- cadwyn/codegen/_plugins/class_migrations.py,sha256=m22lfgEvMw2DFXVdaFjbHAQ4JDM7io7QFNNS6EK7pM0,9549
13
- cadwyn/codegen/_plugins/class_rebuilding.py,sha256=JrALq6B8YfPclGG4zG03fXxtoOsP38Jt2yCSkWhqEzw,4677
12
+ cadwyn/codegen/_plugins/class_migrations.py,sha256=dbmXMdfUGA2X4phsNdtRQQde4SuoOYtNB86XL05U1jY,20159
13
+ cadwyn/codegen/_plugins/class_rebuilding.py,sha256=mJR297bqsLUfP4HW5_1GuHlpiYSQd851yHpG_ajjilg,3703
14
14
  cadwyn/codegen/_plugins/class_renaming.py,sha256=5ka2W1c18i4maNbEkEpELvGLEFbd8tthvQX3YA3Bu0A,1843
15
- cadwyn/codegen/_plugins/import_auto_adding.py,sha256=cYH7Fj_za-5_1h6yOfSiwxymg0X_vVJGyfQ5iJxb4FQ,2314
15
+ cadwyn/codegen/_plugins/import_auto_adding.py,sha256=00zGK99cT-bq2eXKDlYBR5-Z3uHLOGU7dbhB0YFFrt0,2613
16
16
  cadwyn/codegen/_plugins/latest_version_aliasing.py,sha256=s8TgpBL9FofKyJaF4AB7UxJYkq-f9LiMVL33LJxFVp4,4010
17
17
  cadwyn/codegen/_plugins/module_migrations.py,sha256=TeWJk4Iu4SRQ9K2iI3v3sCs1110jrltKlPdfU9mXIsQ,722
18
18
  cadwyn/exceptions.py,sha256=XOLsT4EH1uGNirmKlkgEk03PjUMtD7tgaCDadt_eBbE,695
@@ -21,14 +21,14 @@ cadwyn/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
21
  cadwyn/routing.py,sha256=3H8GKT10wAYroAOCtGCNBMuA63SGAQSESze3EhBLwo0,34433
22
22
  cadwyn/structure/__init__.py,sha256=BjFPlQYCw8ds_4zxdCi2LimarUGqSzyTNmOdT-FkGms,661
23
23
  cadwyn/structure/common.py,sha256=6Z4nI97XPWTCinn6np73m-rLPyYNrz2fWXKJlqjsiaQ,269
24
- cadwyn/structure/data.py,sha256=g9UmCkcdLJA8SlFMmYJIrBEuuIO8DNkhbfUHiOrYnds,5785
24
+ cadwyn/structure/data.py,sha256=cCaclB67mKlgRiydPFijyfLdng4qyqnY_hP8ApS5pT4,5781
25
25
  cadwyn/structure/endpoints.py,sha256=VngfAydGBwekhV2tBOtNDPVgl3X1IgYxUCw--VZ5cQY,5627
26
26
  cadwyn/structure/enums.py,sha256=iMokxA2QYJ61SzyB-Pmuq3y7KL7-e6TsnjLVUaVZQnw,954
27
27
  cadwyn/structure/modules.py,sha256=1FK-lLm-zOTXEvn-QtyBH38aDRht5PDQiZrOPCsBlM4,1268
28
- cadwyn/structure/schemas.py,sha256=JmatYHXTaA8lZZcgctGtRyR_HDc-lUqyz8c8NhQgprU,6050
29
- cadwyn/structure/versions.py,sha256=37pp1ywy6TCSXQuf37IkULK70Gs03n1IWJTNy8agcBQ,27964
30
- cadwyn-3.3.4.dist-info/entry_points.txt,sha256=eO05hLn9GoRzzpwT9GONPmXKsonjuMNssM2D2WHWKGk,46
31
- cadwyn-3.3.4.dist-info/LICENSE,sha256=KeCWewiDQYpmSnzF-p_0YpoWiyDcUPaCuG8OWQs4ig4,1072
32
- cadwyn-3.3.4.dist-info/WHEEL,sha256=vxFmldFsRN_Hx10GDvsdv1wroKq8r5Lzvjp6GZ4OO8c,88
33
- cadwyn-3.3.4.dist-info/METADATA,sha256=O0H0iQkNJiEoBNMWecsr5K9TuAG1srWWgZt4M_m5D7c,4264
34
- cadwyn-3.3.4.dist-info/RECORD,,
28
+ cadwyn/structure/schemas.py,sha256=LIKwDuzorVC9AHg4EN-UYdI133lCk_2MkBTdiyAr-EQ,8808
29
+ cadwyn/structure/versions.py,sha256=UcjsvSzYNgRtTmsCgnCXdv0LtNYTZqCs5yEpU68gnq4,27980
30
+ cadwyn-3.4.0.dist-info/entry_points.txt,sha256=eO05hLn9GoRzzpwT9GONPmXKsonjuMNssM2D2WHWKGk,46
31
+ cadwyn-3.4.0.dist-info/LICENSE,sha256=KeCWewiDQYpmSnzF-p_0YpoWiyDcUPaCuG8OWQs4ig4,1072
32
+ cadwyn-3.4.0.dist-info/WHEEL,sha256=vxFmldFsRN_Hx10GDvsdv1wroKq8r5Lzvjp6GZ4OO8c,88
33
+ cadwyn-3.4.0.dist-info/METADATA,sha256=vrs8AOFwEjJZ6L_3fshUEFgRTmfWj7gh_pqGtn5axvg,4264
34
+ cadwyn-3.4.0.dist-info/RECORD,,
File without changes