Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/reflex-base/news/6726.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +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.
15 changes: 15 additions & 0 deletions packages/reflex-base/src/reflex_base/vars/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3386,6 +3386,11 @@ def dispatch(

FIELD_TYPE = TypeVar("FIELD_TYPE")

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


class Field(Generic[FIELD_TYPE]):
"""A field for a state."""
Expand All @@ -3402,6 +3407,7 @@ def __init__(
is_var: bool = True,
annotated_type: GenericType # pyright: ignore [reportRedeclaration]
| _MISSING_TYPE = MISSING,
source_field: Field | None = None,
) -> None:
"""Initialize the field.

Expand All @@ -3410,6 +3416,8 @@ def __init__(
default_factory: The default factory for the field.
is_var: Whether the field is a Var.
annotated_type: The annotated type for the field.
source_field: If given, deep-copy custom (non-reserved) attributes
from this field that the new field did not compute itself.
"""
self.default = default
self.default_factory = default_factory
Expand Down Expand Up @@ -3440,6 +3448,10 @@ def __init__(
self.type_ = self.type_origin = type_origin
else:
self.outer_type_ = self.annotated_type = self.type_ = self.type_origin = Any
if source_field is not None:
for key, value in source_field.__dict__.items():
if key not in self.__dict__ and key not in _RESERVED_FIELD_ATTRS:
self.__dict__[key] = copy.deepcopy(value)

def default_value(self) -> FIELD_TYPE:
"""Get the default value for the field.
Expand Down Expand Up @@ -3686,12 +3698,14 @@ def __new__(
default=value.default,
is_var=value.is_var,
annotated_type=figure_out_type(value.default),
source_field=value,
)
else:
new_value = Field(
default_factory=value.default_factory,
is_var=value.is_var,
annotated_type=Any,
source_field=value,
)
elif (
not key.startswith("__")
Expand Down Expand Up @@ -3741,6 +3755,7 @@ def __new__(
default_factory=value.default_factory,
is_var=value.is_var,
annotated_type=annotation,
source_field=value,
)

own_fields[key] = value
Expand Down
Empty file.
80 changes: 80 additions & 0 deletions tests/units/reflex_base/vars/test_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Tests for reflex_base.vars.base state metaclass field handling."""

from typing import Any

from reflex_base.utils.types import get_field_type
from reflex_base.vars.base import EvenMoreBasicBaseState, field

_MARKER_ATTR = "_marker"


def test_custom_field_attr_survives_annotated_rebuild():
"""A custom attribute on an annotated Field survives a rebuild."""
f = field("x")
setattr(f, _MARKER_ATTR, "tag")

class MyState(EvenMoreBasicBaseState):
name: str = f # pyright: ignore[reportAssignmentType]

rebuilt = MyState.get_fields()["name"]
assert getattr(rebuilt, _MARKER_ATTR, None) == "tag"
assert rebuilt.annotated_type is str


def test_custom_field_attr_survives_unannotated_rebuild():
"""A custom attribute survives an inferred-type Field rebuild."""
f = field(0)
setattr(f, _MARKER_ATTR, "tag")

class MyState(EvenMoreBasicBaseState):
count = f

rebuilt = MyState.get_fields()["count"]
assert getattr(rebuilt, _MARKER_ATTR, None) == "tag"
assert rebuilt.annotated_type is int


def test_custom_field_attr_survives_unannotated_factory_rebuild():
"""A custom attribute survives a default-factory Field rebuild."""
f = field(default_factory=list)
setattr(f, _MARKER_ATTR, "tag")

class MyState(EvenMoreBasicBaseState):
items = f

rebuilt = MyState.get_fields()["items"]
assert getattr(rebuilt, _MARKER_ATTR, None) == "tag"
Comment thread
greptile-apps[bot] marked this conversation as resolved.
assert rebuilt.annotated_type is Any


def test_reserved_annotation_attr_not_copied():
"""A custom `annotation` attr must not make the rebuilt Field look pydantic.

get_field_type duck-types __fields__ entries on `.annotation`, so copying
it would shadow the real class annotation.
"""
f = field("x")
f.annotation = int # pyright: ignore[reportAttributeAccessIssue]

class MyState(EvenMoreBasicBaseState):
name: str = f # pyright: ignore[reportAssignmentType]

rebuilt = MyState.get_fields()["name"]
assert "annotation" not in rebuilt.__dict__
assert get_field_type(MyState, "name") is str


def test_custom_mutable_attr_is_deepcopied():
"""Mutable custom attrs are deep-copied, not shared by reference."""
f = field("x")
opts = {"a": [1]}
f._opts = opts # pyright: ignore[reportAttributeAccessIssue]

class MyState(EvenMoreBasicBaseState):
name: str = f # pyright: ignore[reportAssignmentType]

rebuilt = MyState.get_fields()["name"]
assert rebuilt._opts == {"a": [1]} # pyright: ignore[reportAttributeAccessIssue]
assert rebuilt._opts is not opts # pyright: ignore[reportAttributeAccessIssue]
opts["a"].append(2)
assert rebuilt._opts == {"a": [1]} # pyright: ignore[reportAttributeAccessIssue]
Loading