cadwyn 2.0.4__py3-none-any.whl → 2.1.0rc0__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/codegen.py CHANGED
@@ -9,12 +9,14 @@ from copy import deepcopy
9
9
  from dataclasses import dataclass
10
10
  from datetime import date
11
11
  from enum import Enum, auto
12
+ from functools import cache
12
13
  from pathlib import Path
13
14
  from types import GenericAlias, LambdaType, ModuleType, NoneType
14
15
  from typing import (
15
16
  Any,
16
17
  TypeAlias,
17
- _BaseGenericAlias, # pyright: ignore[reportGeneralTypeIssues]
18
+ _BaseGenericAlias,
19
+ cast, # pyright: ignore[reportGeneralTypeIssues]
18
20
  final,
19
21
  get_args,
20
22
  get_origin,
@@ -109,10 +111,23 @@ class ModelPrivateAttrInfo:
109
111
  annotation: Any
110
112
 
111
113
 
114
+ @dataclass
115
+ class ModelFieldInfo:
116
+ cls: type[BaseModel]
117
+ _annotation: ast.expr | None
118
+ field: ModelField | ModelFieldLike
119
+ field_ast: ast.expr | None
120
+
121
+ def get_annotation(self): # intentionally weird to not clash with ModelField
122
+ if self._annotation:
123
+ return PlainRepr(ast.unparse(self._annotation))
124
+ return self.field.annotation
125
+
126
+
112
127
  @dataclass(slots=True)
113
128
  class ModelInfo:
114
129
  name: str
115
- fields: dict[_FieldName, tuple[type[BaseModel], ModelField | ModelFieldLike]]
130
+ fields: dict[_FieldName, ModelFieldInfo]
116
131
 
117
132
 
118
133
  @dataclass(slots=True)
@@ -121,6 +136,33 @@ class _SchemaBundle:
121
136
  schemas: dict[str, ModelInfo]
122
137
 
123
138
 
139
+ @cache
140
+ def _get_fields_from_model(cls: type):
141
+ mro_fields = [_get_fields_from_model(parent) for parent in cls.mro()[1:] if issubclass(parent, BaseModel)]
142
+ if not isinstance(cls, type) or not issubclass(cls, BaseModel):
143
+ raise CodeGenerationError(f"Model {cls} is not a subclass of BaseModel")
144
+ try:
145
+ source = inspect.getsource(cls)
146
+ except OSError:
147
+ current_field_defs = {
148
+ field_name: ModelFieldInfo(cls, None, field, None) for field_name, field in cls.__fields__.items()
149
+ }
150
+ else:
151
+ cls_ast = cast(ast.ClassDef, ast.parse(source).body[0])
152
+ current_field_defs = {
153
+ node.target.id: ModelFieldInfo(cls, node.annotation, cls.__fields__[node.target.id], node.value)
154
+ for node in cls_ast.body
155
+ if isinstance(node, ast.AnnAssign)
156
+ and isinstance(node.target, ast.Name)
157
+ and node.target.id in cls.__fields__
158
+ }
159
+
160
+ final_field_defs: dict[str, ModelFieldInfo] = {}
161
+ for attr in reversed(mro_fields):
162
+ final_field_defs |= attr
163
+ return final_field_defs | current_field_defs
164
+
165
+
124
166
  def generate_code_for_versioned_packages(
125
167
  template_module: ModuleType,
126
168
  versions: VersionBundle,
@@ -136,7 +178,7 @@ def generate_code_for_versioned_packages(
136
178
  version of the latest module.
137
179
  """
138
180
  schemas = {
139
- k: ModelInfo(v.__name__, _get_fields_for_model(v)) for k, v in deepcopy(versions.versioned_schemas).items()
181
+ k: ModelInfo(v.__name__, _get_fields_from_model(v)) for k, v in deepcopy(versions.versioned_schemas).items()
140
182
  }
141
183
  enums = {k: (v, {member.name: member.value for member in v}) for k, v in deepcopy(versions.versioned_enums).items()}
142
184
  schemas_per_version: list[_SchemaBundle] = []
@@ -276,7 +318,7 @@ def _apply_alter_schema_instructions( # noqa: C901
276
318
  f'You tried to change the type of field "{alter_schema_instruction.field_name}" from '
277
319
  f'"{schema.__name__}" in "{version_change_name}" but it doesn\'t have such a field.',
278
320
  )
279
- model_field = field_name_to_field_model[alter_schema_instruction.field_name][1]
321
+ model_field = field_name_to_field_model[alter_schema_instruction.field_name].field
280
322
  if alter_schema_instruction.type is not Sentinel:
281
323
  if model_field.annotation == alter_schema_instruction.type:
282
324
  raise InvalidGenerationInstructionError(
@@ -285,6 +327,8 @@ def _apply_alter_schema_instructions( # noqa: C901
285
327
  f'but it already has type "{model_field.annotation}"',
286
328
  )
287
329
  model_field.annotation = alter_schema_instruction.type
330
+ # TODO: Extend ast instead of removing it
331
+ field_name_to_field_model[alter_schema_instruction.field_name]._annotation = None
288
332
  if alter_schema_instruction.new_name is not Sentinel:
289
333
  if alter_schema_instruction.new_name == alter_schema_instruction.field_name:
290
334
  raise InvalidGenerationInstructionError(
@@ -311,7 +355,17 @@ def _apply_alter_schema_instructions( # noqa: C901
311
355
  f'from "{schema.__name__}" to {attr_value!r} in "{version_change_name}" '
312
356
  "but it already has that value.",
313
357
  )
314
- setattr(field_info, attr_name, attr_value)
358
+
359
+ if attr_name in model_field.annotation.__dict__ and _is_pydantic_constrained_type(
360
+ model_field.annotation,
361
+ ):
362
+ setattr(model_field.annotation, attr_name, attr_value)
363
+ else:
364
+ setattr(field_info, attr_name, attr_value)
365
+ # TODO: Extend ast instead of removing it
366
+ field_name_to_field_model[alter_schema_instruction.field_name]._annotation = None
367
+ field_name_to_field_model[alter_schema_instruction.field_name].field_ast = None
368
+
315
369
  elif isinstance(alter_schema_instruction, OldSchemaFieldExistedWith):
316
370
  if alter_schema_instruction.field_name in field_name_to_field_model:
317
371
  raise InvalidGenerationInstructionError(
@@ -322,9 +376,10 @@ def _apply_alter_schema_instructions( # noqa: C901
322
376
  annotation = alter_schema_instruction.import_as
323
377
  else:
324
378
  annotation = alter_schema_instruction.type
325
- field_name_to_field_model[alter_schema_instruction.field_name] = (
379
+ field_name_to_field_model[alter_schema_instruction.field_name] = ModelFieldInfo(
326
380
  schema,
327
- ModelFieldLike(
381
+ _annotation=None, # TODO: Get this from migration
382
+ field=ModelFieldLike(
328
383
  name=alter_schema_instruction.field_name,
329
384
  original_type=alter_schema_instruction.type,
330
385
  annotation=annotation,
@@ -332,6 +387,7 @@ def _apply_alter_schema_instructions( # noqa: C901
332
387
  import_from=alter_schema_instruction.import_from,
333
388
  import_as=alter_schema_instruction.import_as,
334
389
  ),
390
+ field_ast=None, # TODO: Get this from migration
335
391
  )
336
392
  elif isinstance(alter_schema_instruction, AlterSchemaInstruction):
337
393
  # We only handle names right now so we just go ahead and check
@@ -459,21 +515,6 @@ def _generate_parallel_directory(
459
515
  shutil.copyfile(original_file, parallel_file)
460
516
 
461
517
 
462
- def _get_fields_for_model(
463
- model: type[BaseModel],
464
- ) -> dict[_FieldName, tuple[type[BaseModel], ModelField | ModelFieldLike]]:
465
- actual_fields: dict[_FieldName, tuple[type[BaseModel], ModelField | ModelFieldLike]] = {}
466
- for cls in model.__mro__:
467
- if cls is BaseModel:
468
- return actual_fields
469
- if not issubclass(cls, BaseModel):
470
- continue
471
- for field_name, field in cls.__fields__.items():
472
- if field_name not in actual_fields and field_name in cls.__annotations__:
473
- actual_fields[field_name] = (cls, field)
474
- raise CodeGenerationError(f"Model {model} is not a subclass of BaseModel")
475
-
476
-
477
518
  def _parse_python_module(module: ModuleType) -> ast.Module:
478
519
  try:
479
520
  return ast.parse(inspect.getsource(module))
@@ -502,22 +543,37 @@ def _migrate_module_to_another_version(
502
543
  else:
503
544
  module_name = module.__name__
504
545
  all_names_in_file = _get_all_names_defined_in_module(parsed_file, module_name)
505
-
506
546
  # TODO: Does this play well with renaming?
507
547
  extra_field_imports = [
508
548
  ast.ImportFrom(
509
- module=field.import_from,
510
- names=[ast.alias(name=transformer.visit(field.original_type).strip("'"), asname=field.import_as)],
549
+ module=field.field.import_from,
550
+ names=[
551
+ ast.alias(name=transformer.visit(field.field.original_type).strip("'"), asname=field.field.import_as),
552
+ ],
511
553
  level=0,
512
554
  )
513
555
  for val in modified_schemas.values()
514
- for _, field in val.fields.values()
515
- if isinstance(field, ModelFieldLike) and field.import_from is not None
556
+ for field in val.fields.values()
557
+ if isinstance(field.field, ModelFieldLike) and field.field.import_from is not None
516
558
  ]
517
559
 
518
560
  body = ast.Module(
519
561
  [
520
- ast.ImportFrom(module="pydantic", names=[ast.alias(name="Field")], level=0),
562
+ ast.ImportFrom(
563
+ module="pydantic",
564
+ names=[
565
+ ast.alias(name="Field"),
566
+ ast.alias(name="conbytes"),
567
+ ast.alias(name="conlist"),
568
+ ast.alias(name="conset"),
569
+ ast.alias(name="constr"),
570
+ ast.alias(name="conint"),
571
+ ast.alias(name="confloat"),
572
+ ast.alias(name="condecimal"),
573
+ ast.alias(name="condate"),
574
+ ],
575
+ level=0,
576
+ ),
521
577
  ast.Import(names=[ast.alias(name="typing")], level=0),
522
578
  ast.ImportFrom(module="typing", names=[ast.alias(name="Any")], level=0),
523
579
  ]
@@ -606,49 +662,64 @@ def _modify_schema_cls(
606
662
  module_python_path: str,
607
663
  cls_python_path: str,
608
664
  ) -> ast.ClassDef:
609
- annotation_transformer = _AnnotationTransformerWithSchemaRenaming(
665
+ object_renamer = _AnnotationTransformer()
666
+ ast_renamer = _AnnotationASTNodeTransformerWithSchemaRenaming(
610
667
  modified_schemas,
611
- module_python_path,
612
668
  all_names_in_module,
669
+ module_python_path,
613
670
  )
614
- ast_transformer = _AnnotationASTNodeTransformer(modified_schemas, all_names_in_module, module_python_path)
615
671
  if cls_python_path in modified_schemas:
616
672
  model_info = modified_schemas[cls_python_path]
673
+ # This is for possible schema renaming
617
674
  cls_node.name = model_info.name
675
+
618
676
  field_definitions = [
619
677
  ast.AnnAssign(
620
678
  target=ast.Name(name, ctx=ast.Store()),
621
- annotation=ast.Name(annotation_transformer.visit(field.annotation)),
622
- value=_generate_field_ast(field, annotation_transformer),
679
+ annotation=ast.Name(object_renamer.visit(field.get_annotation())),
680
+ value=_generate_field_ast(field, object_renamer),
623
681
  simple=1,
624
682
  )
625
- for name, (_, field) in model_info.fields.items()
683
+ for name, field in model_info.fields.items()
626
684
  ]
627
685
  else:
628
686
  field_definitions = [field for field in cls_node.body if isinstance(field, ast.AnnAssign)]
629
-
630
687
  old_body = [n for n in cls_node.body if not isinstance(n, ast.AnnAssign | ast.Pass | ast.Ellipsis)]
631
688
  docstring = _pop_docstring_from_cls_body(old_body)
632
689
  cls_node.body = docstring + field_definitions + old_body
633
- return ast_transformer.visit(cls_node)
690
+ if not cls_node.body:
691
+ cls_node.body = [ast.Pass()]
692
+
693
+ return ast_renamer.visit(ast.parse(ast.unparse(cls_node)).body[0])
634
694
 
635
695
 
636
696
  def _generate_field_ast(
637
- field: ModelField | ModelFieldLike,
638
- annotation_transformer: "_AnnotationTransformerWithSchemaRenaming",
697
+ field: ModelFieldInfo,
698
+ annotation_transformer: "_AnnotationTransformer",
639
699
  ):
700
+ if field.field_ast is not None:
701
+ return field.field_ast
702
+ passed_attrs = {
703
+ attr: _get_attribute_from_field_info(field.field, attr)
704
+ for attr in _get_passed_attributes_to_field(field.field.field_info)
705
+ }
706
+ if _is_pydantic_constrained_type(field.field.annotation):
707
+ (
708
+ attrs_that_are_only_in_contype,
709
+ attrs_that_are_only_in_field,
710
+ ) = _get_attrs_that_are_not_from_field_and_that_are_from_field(field.field.annotation)
711
+ if not attrs_that_are_only_in_contype:
712
+ passed_attrs |= attrs_that_are_only_in_field
713
+
640
714
  return ast.Call(
641
715
  func=ast.Name("Field"),
642
716
  args=[],
643
717
  keywords=[
644
718
  ast.keyword(
645
719
  arg=attr,
646
- value=ast.parse(
647
- annotation_transformer.visit(_get_attribute_from_field_info(field, attr)),
648
- mode="eval",
649
- ).body,
720
+ value=ast.parse(annotation_transformer.visit(attr_value), mode="eval").body,
650
721
  )
651
- for attr in _get_passed_attributes_to_field(field.field_info)
722
+ for attr, attr_value in passed_attrs.items()
652
723
  ],
653
724
  )
654
725
 
@@ -701,7 +772,7 @@ def _get_passed_attributes_to_field(field_info: FieldInfo):
701
772
  yield from field_info.extra
702
773
 
703
774
 
704
- class _AnnotationASTNodeTransformer(ast.NodeTransformer):
775
+ class _AnnotationASTNodeTransformerWithSchemaRenaming(ast.NodeTransformer):
705
776
  def __init__(
706
777
  self,
707
778
  modified_schemas: dict[str, ModelInfo],
@@ -724,6 +795,8 @@ class _AnnotationASTNodeTransformer(ast.NodeTransformer):
724
795
 
725
796
 
726
797
  class _AnnotationTransformer:
798
+ """Returns fancy and correct reprs of annotations"""
799
+
727
800
  def visit(self, value: Any):
728
801
  if isinstance(value, list | tuple | set | frozenset):
729
802
  return self.transform_collection(value)
@@ -765,22 +838,27 @@ class _AnnotationTransformer:
765
838
  def transform_type(self, value: type) -> Any:
766
839
  # NOTE: Be wary of this hack when migrating to pydantic v2
767
840
  # This is a hack for pydantic's Constrained types
768
- if value.__name__.startswith("Constrained") and value.__name__.endswith("Value"):
769
- # No, get_origin and get_args don't work here. No idea why
770
- snake_case = _RE_CAMEL_TO_SNAKE.sub("_", value.__name__)
771
- cls_name = "con" + "".join(snake_case.split("_")[1:-1])
772
- return (
773
- cls_name.lower()
774
- + "("
775
- + ", ".join(
776
- [
777
- f"{key}={self.visit(val)}"
778
- for key, val in value.__dict__.items()
779
- if not key.startswith("__") and val is not None
780
- ],
841
+ if _is_pydantic_constrained_type(value):
842
+ if _get_attrs_that_are_not_from_field_and_that_are_from_field(value)[0]:
843
+ # No, get_origin and get_args don't work here. No idea why
844
+ parent = value.mro()[1]
845
+ snake_case = _RE_CAMEL_TO_SNAKE.sub("_", value.__name__)
846
+ cls_name = "con" + "".join(snake_case.split("_")[1:-1])
847
+ return (
848
+ cls_name.lower()
849
+ + "("
850
+ + ", ".join(
851
+ [
852
+ f"{key}={self.visit(val)}"
853
+ for key, val in value.__dict__.items()
854
+ if not key.startswith("_") and val is not None and val != parent.__dict__[key]
855
+ ],
856
+ )
857
+ + ")"
781
858
  )
782
- + ")"
783
- )
859
+ else:
860
+ value = value.mro()[-2]
861
+
784
862
  return value.__name__
785
863
 
786
864
  def transform_enum(self, value: Enum) -> Any:
@@ -804,36 +882,34 @@ class _AnnotationTransformer:
804
882
  return PlainRepr(repr(value))
805
883
 
806
884
 
807
- class PlainRepr(str):
808
- """
809
- String class where repr doesn't include quotes.
810
- """
885
+ def _is_pydantic_constrained_type(value: object):
886
+ return isinstance(value, type) and value.__name__.startswith("Constrained") and value.__name__.endswith("Value")
811
887
 
812
- def __repr__(self) -> str:
813
- return str(self)
814
888
 
889
+ def _get_attrs_that_are_not_from_field_and_that_are_from_field(value: type):
890
+ parent_public_attrs = {k: v for k, v in value.mro()[1].__dict__.items() if not k.startswith("_")}
891
+ value_private_attrs = {k: v for k, v in value.__dict__.items() if not k.startswith("_")}
892
+ attrs_in_value_different_from_parent = {
893
+ k: v for k, v in value_private_attrs.items() if k in parent_public_attrs and parent_public_attrs[k] != v
894
+ }
895
+ attrs_in_value_different_from_parent_that_are_not_in_field_def = {
896
+ k: v for k, v in attrs_in_value_different_from_parent.items() if k not in _dict_of_empty_field_info
897
+ }
898
+ attrs_in_value_different_from_parent_that_are_in_field_def = {
899
+ k: v for k, v in attrs_in_value_different_from_parent.items() if k in _dict_of_empty_field_info
900
+ }
815
901
 
816
- class _AnnotationTransformerWithSchemaRenaming(_AnnotationTransformer):
817
- def __init__(
818
- self,
819
- modified_schemas: dict[str, ModelInfo],
820
- module_python_path: str,
821
- all_names_in_module: dict[str, str],
822
- ):
823
- super().__init__()
902
+ return (
903
+ attrs_in_value_different_from_parent_that_are_not_in_field_def,
904
+ attrs_in_value_different_from_parent_that_are_in_field_def,
905
+ )
824
906
 
825
- self.modified_schemas = modified_schemas
826
- self.module_python_path = module_python_path
827
- self.all_names_in_module = all_names_in_module
828
907
 
829
- def transform_type(self, value: type) -> Any:
830
- model_info = self.modified_schemas.get(
831
- f"{self.all_names_in_module.get(value.__name__, self.module_python_path)}.{value.__name__}",
832
- )
833
- if model_info is not None:
834
- return model_info.name
835
- else:
836
- return super().transform_type(value)
908
+ class PlainRepr(str):
909
+ """String class where repr doesn't include quotes"""
910
+
911
+ def __repr__(self) -> str:
912
+ return str(self)
837
913
 
838
914
 
839
915
  def _find_a_lambda(source: str) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cadwyn
3
- Version: 2.0.4
3
+ Version: 2.1.0rc0
4
4
  Summary: Modern Stripe-like API versioning in FastAPI
5
5
  Home-page: https://github.com/zmievsa/cadwyn
6
6
  License: MIT
@@ -1,7 +1,7 @@
1
1
  cadwyn/__init__.py,sha256=IGJOStZuQ1cw7f8W40nymA7eYGDVKrWDXz5trMxsy3U,524
2
2
  cadwyn/__main__.py,sha256=aKAwxVnhqi3ATd1UsifoLA1t3udTzz56t0BRTlktX1A,2845
3
3
  cadwyn/_utils.py,sha256=Uh6No0FNY1AR2Z2E19fMPIU9_J4lbuG8XOQU2AlDIZw,3600
4
- cadwyn/codegen.py,sha256=Ny8gQPhPpTAbdBOajupwp2jEdPXWu-NhtaXKxkT78XU,34825
4
+ cadwyn/codegen.py,sha256=wIQgzUrMiYsyoMu9iDRbzUy3L2RHre5FSbbDmjwrpnw,38212
5
5
  cadwyn/exceptions.py,sha256=Utb6anOzrh97nOUgqCMmZHkQg8SFafLKSKO0EUPQ0yU,624
6
6
  cadwyn/header.py,sha256=2xw5wtxMQaGe2P7heSAvWu5GDHUEvWYpydZaQcKSc3s,901
7
7
  cadwyn/main.py,sha256=pUFGHmiHk8dpS2kxUiyll0T3XTti3MPVySdTecUd38s,5282
@@ -14,8 +14,8 @@ cadwyn/structure/endpoints.py,sha256=ozpD3z8BFTThiCbj5pidjW2-r46GzZg0NipxgBmggsw
14
14
  cadwyn/structure/enums.py,sha256=iMokxA2QYJ61SzyB-Pmuq3y7KL7-e6TsnjLVUaVZQnw,954
15
15
  cadwyn/structure/schemas.py,sha256=wpGFXzoq6qrOulEk7I_69UslapG5iD5crFLQp-kXEFQ,5241
16
16
  cadwyn/structure/versions.py,sha256=TjRsTHkNTmxbP9WrpgbhN5mJYvkEZTl6zkp7ThjgSF0,22178
17
- cadwyn-2.0.4.dist-info/entry_points.txt,sha256=eO05hLn9GoRzzpwT9GONPmXKsonjuMNssM2D2WHWKGk,46
18
- cadwyn-2.0.4.dist-info/LICENSE,sha256=KeCWewiDQYpmSnzF-p_0YpoWiyDcUPaCuG8OWQs4ig4,1072
19
- cadwyn-2.0.4.dist-info/WHEEL,sha256=vxFmldFsRN_Hx10GDvsdv1wroKq8r5Lzvjp6GZ4OO8c,88
20
- cadwyn-2.0.4.dist-info/METADATA,sha256=I8HTVnD3bSL8LLyvMSNL8OzmRUk3-rthItMkaq8xEY0,3804
21
- cadwyn-2.0.4.dist-info/RECORD,,
17
+ cadwyn-2.1.0rc0.dist-info/entry_points.txt,sha256=eO05hLn9GoRzzpwT9GONPmXKsonjuMNssM2D2WHWKGk,46
18
+ cadwyn-2.1.0rc0.dist-info/LICENSE,sha256=KeCWewiDQYpmSnzF-p_0YpoWiyDcUPaCuG8OWQs4ig4,1072
19
+ cadwyn-2.1.0rc0.dist-info/WHEEL,sha256=vxFmldFsRN_Hx10GDvsdv1wroKq8r5Lzvjp6GZ4OO8c,88
20
+ cadwyn-2.1.0rc0.dist-info/METADATA,sha256=OQS1v_1qTRw-oJczL2OlbzCn5H0iDEOdzwV5WAluyu4,3807
21
+ cadwyn-2.1.0rc0.dist-info/RECORD,,