Skip to content

Commit 99932b7

Browse files
authored
Add support for classvars (#292)
1 parent b424ecd commit 99932b7

File tree

5 files changed

+115
-10
lines changed

5 files changed

+115
-10
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ Please follow [the Keep a Changelog standard](https://keepachangelog.com/en/1.0.
55

66
## [Unreleased]
77

8+
## [5.4.4]
9+
10+
### Fixed
11+
12+
* Fixed KeyError when versioning Pydantic models containing ClassVar annotations
13+
814
## [5.4.3]
915

1016
### Fixed

cadwyn/schema_generation.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from typing import (
1515
TYPE_CHECKING,
1616
Annotated,
17+
ClassVar,
1718
Generic,
1819
Union,
1920
_BaseGenericAlias, # pyright: ignore[reportAttributeAccessIssue]
@@ -41,6 +42,7 @@
4142
from pydantic._internal._known_annotated_metadata import collect_known_metadata
4243
from pydantic._internal._typing_extra import try_eval_type as pydantic_try_eval_type
4344
from pydantic.fields import ComputedFieldInfo, FieldInfo
45+
from pydantic_core import PydanticUndefined
4446
from typing_extensions import (
4547
Any,
4648
Doc,
@@ -316,7 +318,7 @@ def _rebuild_annotated(name: str):
316318
field_name,
317319
)
318320
for field_name in model.__annotations__
319-
if field_name in defined_fields
321+
if field_name in defined_fields and field_name in model.model_fields
320322
}
321323

322324
main_attributes = fields | validators
@@ -592,7 +594,12 @@ def _change_version_of_a_non_container_annotation(self, annotation: Any) -> Any:
592594
from typing_inspection.typing_objects import is_any, is_newtype, is_typealiastype
593595

594596
if isinstance(annotation, (types.GenericAlias, _BaseGenericAlias)):
595-
return get_origin(annotation)[tuple(self.change_version_of_annotation(arg) for arg in get_args(annotation))]
597+
origin = get_origin(annotation)
598+
args = get_args(annotation)
599+
# Classvar does not support generic tuple arguments
600+
if origin is ClassVar:
601+
return ClassVar[self.change_version_of_annotation(args[0])]
602+
return origin[tuple(self.change_version_of_annotation(arg) for arg in get_args(annotation))]
596603
elif is_typealiastype(annotation):
597604
if (
598605
annotation.__module__ is not None and (annotation.__module__.startswith("pydantic."))
@@ -948,11 +955,20 @@ def _add_field_to_model(
948955
f'in "{version_change_name}" but there is already a field with that name.',
949956
)
950957

951-
field = PydanticFieldWrapper(
952-
alter_schema_instruction.field, alter_schema_instruction.field.annotation, alter_schema_instruction.name
953-
)
954-
model.fields[alter_schema_instruction.name] = field
955-
model.annotations[alter_schema_instruction.name] = alter_schema_instruction.field.annotation
958+
# Special handling for ClassVar fields
959+
if get_origin(alter_schema_instruction.field.annotation) is ClassVar:
960+
# ClassVar fields should not be in model.fields, only in annotations and other_attributes
961+
model.annotations[alter_schema_instruction.name] = alter_schema_instruction.field.annotation
962+
# Set the actual ClassVar value in other_attributes
963+
if alter_schema_instruction.field.default is not PydanticUndefined:
964+
model.other_attributes[alter_schema_instruction.name] = alter_schema_instruction.field.default
965+
else:
966+
# Regular field handling
967+
field = PydanticFieldWrapper(
968+
alter_schema_instruction.field, alter_schema_instruction.field.annotation, alter_schema_instruction.name
969+
)
970+
model.fields[alter_schema_instruction.name] = field
971+
model.annotations[alter_schema_instruction.name] = alter_schema_instruction.field.annotation
956972

957973

958974
def _change_field_in_model(
@@ -1085,6 +1101,11 @@ def _delete_field_from_model(model: _PydanticModelWrapper, field_name: str, vers
10851101
validator = model.validators[field_name]
10861102
model.validators[field_name].is_deleted = True
10871103
model.annotations.pop(field_name, None)
1104+
elif field_name in model.annotations and get_origin(model.annotations[field_name]) is ClassVar:
1105+
# Handle ClassVar fields - they exist in annotations but not in model.fields
1106+
model.annotations.pop(field_name)
1107+
# Also remove the attribute from other_attributes if it exists there
1108+
model.other_attributes.pop(field_name, None)
10881109
else:
10891110
raise InvalidGenerationInstructionError(
10901111
f'You tried to delete a field "{field_name}" from "{model.name}" '

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "cadwyn"
3-
version = "5.4.3"
3+
version = "5.4.4"
44
description = "Production-ready community-driven modern Stripe-like API versioning in FastAPI"
55
authors = [{ name = "Stanislav Zmiev", email = "zmievsa@gmail.com" }]
66
license = "MIT"

tests/test_schema_generation/test_schema_field.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import re
22
from enum import Enum, auto
3-
from typing import Annotated, Any, Literal, Union
3+
from typing import Annotated, Any, ClassVar, Literal, Union, get_origin
44

55
import pytest
66
from pydantic import BaseModel, Field, StringConstraints, ValidationError, computed_field, conint, constr
@@ -807,3 +807,81 @@ def image_url(self) -> str:
807807

808808
assert not hasattr(old_instance, "image_url")
809809
assert "image_url" not in old_instance.model_dump()
810+
811+
812+
def test__schema_with_classvar__should_be_recreated_in_older_version(create_runtime_schemas: CreateRuntimeSchemas):
813+
class SchemaWithClassVar(BaseModel):
814+
regular_field: str
815+
class_level_config: ClassVar[str] = "default_config"
816+
817+
schemas = create_runtime_schemas(version_change())
818+
819+
latest_model = schemas["2001-01-01"][SchemaWithClassVar]
820+
old_model = schemas["2000-01-01"][SchemaWithClassVar]
821+
822+
latest_instance = latest_model(regular_field="test")
823+
old_instance = old_model(regular_field="test")
824+
825+
assert latest_model.class_level_config == "default_config"
826+
assert old_model.class_level_config == "default_config"
827+
828+
assert "class_level_config" not in latest_instance.model_dump()
829+
assert "class_level_config" not in old_instance.model_dump()
830+
831+
assert latest_instance.regular_field == "test"
832+
assert old_instance.regular_field == "test"
833+
834+
assert "class_level_config" in latest_model.__annotations__
835+
assert "class_level_config" in old_model.__annotations__
836+
837+
assert get_origin(latest_model.__annotations__["class_level_config"]) is ClassVar
838+
assert get_origin(old_model.__annotations__["class_level_config"]) is ClassVar
839+
840+
841+
def test__schema_with_classvar__remove_classvar(create_runtime_schemas: CreateRuntimeSchemas):
842+
class SchemaWithClassVar(BaseModel):
843+
regular_field: str
844+
class_level_config: ClassVar[str] = "default_config"
845+
846+
schemas = create_runtime_schemas(version_change(schema(SchemaWithClassVar).field("class_level_config").didnt_exist))
847+
848+
latest_model = schemas["2001-01-01"][SchemaWithClassVar]
849+
latest_instance = latest_model(regular_field="test")
850+
851+
assert latest_model.class_level_config == "default_config"
852+
assert "class_level_config" not in latest_instance.model_dump()
853+
854+
old_model = schemas["2000-01-01"][SchemaWithClassVar]
855+
old_instance = old_model(regular_field="test")
856+
857+
assert not hasattr(old_model, "class_level_config")
858+
assert "class_level_config" not in old_instance.model_dump()
859+
860+
assert latest_instance.regular_field == "test"
861+
assert old_instance.regular_field == "test"
862+
863+
864+
def test__schema_with_classvar__add_classvar_field(create_runtime_schemas: CreateRuntimeSchemas):
865+
class SchemaWithoutClassVar(BaseModel):
866+
regular_field: str
867+
868+
schemas = create_runtime_schemas(
869+
version_change(),
870+
version_change(
871+
schema(SchemaWithoutClassVar)
872+
.field("new_config")
873+
.existed_as(type=ClassVar[str], info=Field(default="added_config")),
874+
schema(SchemaWithoutClassVar).field("new_config_without_value").existed_as(type=ClassVar[str]),
875+
),
876+
)
877+
latest_model = schemas["2002-01-01"][SchemaWithoutClassVar]
878+
mid_model = schemas["2001-01-01"][SchemaWithoutClassVar]
879+
old_model = schemas["2000-01-01"][SchemaWithoutClassVar]
880+
881+
assert not hasattr(latest_model, "new_config")
882+
assert mid_model.new_config == "added_config"
883+
assert old_model.new_config == "added_config"
884+
885+
assert not hasattr(latest_model, "new_config_without_value")
886+
assert not hasattr(mid_model, "new_config_without_value")
887+
assert not hasattr(old_model, "new_config_without_value")

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)