Skip to content

Commit 65a2889

Browse files
ENG-9860 fix(vars): preserve custom Field attributes in state metaclass (#6726)
* fix(vars): preserve custom Field attributes in state metaclass BaseStateMeta rebuilt Field instances for state annotations, discarding any custom attributes callers had set. Copy over attrs the rebuilt field did not recompute via Field._copy_custom_attrs_from so subclassed or customized fields keep their extra state. * fix(vars): deep-copy custom Field attrs and skip reserved annotation Fold custom-attribute copying into Field.__init__ via a source_field parameter instead of the post-hoc _copy_custom_attrs_from helper. Copied attrs are now deep-copied so mutable values aren't shared between the original and rebuilt fields, and the reserved `annotation` attribute is never carried over — get_field_type duck-types pydantic fields on `.annotation`, so copying it would shadow the real class annotation.
1 parent 32f41e2 commit 65a2889

4 files changed

Lines changed: 96 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Custom attributes set on a `Field` are now preserved (deep-copied) when the state metaclass rebuilds fields, instead of being silently discarded. The reserved `annotation` attribute is never carried over so rebuilt fields are not misidentified as pydantic fields.

packages/reflex-base/src/reflex_base/vars/base.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3470,6 +3470,11 @@ def dispatch(
34703470

34713471
FIELD_TYPE = TypeVar("FIELD_TYPE")
34723472

3473+
# Custom attrs never copied from a source field: get_field_type duck-types
3474+
# pydantic fields on `.annotation`, so carrying it over would shadow the
3475+
# real class annotation.
3476+
_RESERVED_FIELD_ATTRS = frozenset({"annotation"})
3477+
34733478

34743479
class Field(Generic[FIELD_TYPE]):
34753480
"""A field for a state."""
@@ -3486,6 +3491,7 @@ def __init__(
34863491
is_var: bool = True,
34873492
annotated_type: GenericType # pyright: ignore [reportRedeclaration]
34883493
| _MISSING_TYPE = MISSING,
3494+
source_field: Field | None = None,
34893495
) -> None:
34903496
"""Initialize the field.
34913497
@@ -3494,6 +3500,8 @@ def __init__(
34943500
default_factory: The default factory for the field.
34953501
is_var: Whether the field is a Var.
34963502
annotated_type: The annotated type for the field.
3503+
source_field: If given, deep-copy custom (non-reserved) attributes
3504+
from this field that the new field did not compute itself.
34973505
"""
34983506
self.default = default
34993507
self.default_factory = default_factory
@@ -3524,6 +3532,10 @@ def __init__(
35243532
self.type_ = self.type_origin = type_origin
35253533
else:
35263534
self.outer_type_ = self.annotated_type = self.type_ = self.type_origin = Any
3535+
if source_field is not None:
3536+
for key, value in source_field.__dict__.items():
3537+
if key not in self.__dict__ and key not in _RESERVED_FIELD_ATTRS:
3538+
self.__dict__[key] = copy.deepcopy(value)
35273539

35283540
def default_value(self) -> FIELD_TYPE:
35293541
"""Get the default value for the field.
@@ -3770,12 +3782,14 @@ def __new__(
37703782
default=value.default,
37713783
is_var=value.is_var,
37723784
annotated_type=figure_out_type(value.default),
3785+
source_field=value,
37733786
)
37743787
else:
37753788
new_value = Field(
37763789
default_factory=value.default_factory,
37773790
is_var=value.is_var,
37783791
annotated_type=Any,
3792+
source_field=value,
37793793
)
37803794
elif (
37813795
not key.startswith("__")
@@ -3825,6 +3839,7 @@ def __new__(
38253839
default_factory=value.default_factory,
38263840
is_var=value.is_var,
38273841
annotated_type=annotation,
3842+
source_field=value,
38283843
)
38293844

38303845
own_fields[key] = value

tests/units/reflex_base/vars/__init__.py

Whitespace-only changes.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Tests for reflex_base.vars.base state metaclass field handling."""
2+
3+
from typing import Any
4+
5+
from reflex_base.utils.types import get_field_type
6+
from reflex_base.vars.base import EvenMoreBasicBaseState, field
7+
8+
_MARKER_ATTR = "_marker"
9+
10+
11+
def test_custom_field_attr_survives_annotated_rebuild():
12+
"""A custom attribute on an annotated Field survives a rebuild."""
13+
f = field("x")
14+
setattr(f, _MARKER_ATTR, "tag")
15+
16+
class MyState(EvenMoreBasicBaseState):
17+
name: str = f # pyright: ignore[reportAssignmentType]
18+
19+
rebuilt = MyState.get_fields()["name"]
20+
assert getattr(rebuilt, _MARKER_ATTR, None) == "tag"
21+
assert rebuilt.annotated_type is str
22+
23+
24+
def test_custom_field_attr_survives_unannotated_rebuild():
25+
"""A custom attribute survives an inferred-type Field rebuild."""
26+
f = field(0)
27+
setattr(f, _MARKER_ATTR, "tag")
28+
29+
class MyState(EvenMoreBasicBaseState):
30+
count = f
31+
32+
rebuilt = MyState.get_fields()["count"]
33+
assert getattr(rebuilt, _MARKER_ATTR, None) == "tag"
34+
assert rebuilt.annotated_type is int
35+
36+
37+
def test_custom_field_attr_survives_unannotated_factory_rebuild():
38+
"""A custom attribute survives a default-factory Field rebuild."""
39+
f = field(default_factory=list)
40+
setattr(f, _MARKER_ATTR, "tag")
41+
42+
class MyState(EvenMoreBasicBaseState):
43+
items = f
44+
45+
rebuilt = MyState.get_fields()["items"]
46+
assert getattr(rebuilt, _MARKER_ATTR, None) == "tag"
47+
assert rebuilt.annotated_type is Any
48+
49+
50+
def test_reserved_annotation_attr_not_copied():
51+
"""A custom `annotation` attr must not make the rebuilt Field look pydantic.
52+
53+
get_field_type duck-types __fields__ entries on `.annotation`, so copying
54+
it would shadow the real class annotation.
55+
"""
56+
f = field("x")
57+
f.annotation = int # pyright: ignore[reportAttributeAccessIssue]
58+
59+
class MyState(EvenMoreBasicBaseState):
60+
name: str = f # pyright: ignore[reportAssignmentType]
61+
62+
rebuilt = MyState.get_fields()["name"]
63+
assert "annotation" not in rebuilt.__dict__
64+
assert get_field_type(MyState, "name") is str
65+
66+
67+
def test_custom_mutable_attr_is_deepcopied():
68+
"""Mutable custom attrs are deep-copied, not shared by reference."""
69+
f = field("x")
70+
opts = {"a": [1]}
71+
f._opts = opts # pyright: ignore[reportAttributeAccessIssue]
72+
73+
class MyState(EvenMoreBasicBaseState):
74+
name: str = f # pyright: ignore[reportAssignmentType]
75+
76+
rebuilt = MyState.get_fields()["name"]
77+
assert rebuilt._opts == {"a": [1]} # pyright: ignore[reportAttributeAccessIssue]
78+
assert rebuilt._opts is not opts # pyright: ignore[reportAttributeAccessIssue]
79+
opts["a"].append(2)
80+
assert rebuilt._opts == {"a": [1]} # pyright: ignore[reportAttributeAccessIssue]

0 commit comments

Comments
 (0)