Skip to content

Commit d6919fe

Browse files
committed
Cache fields() in a weakly-keyed side table instead of mutating the class
1 parent db5c990 commit d6919fe

2 files changed

Lines changed: 37 additions & 4 deletions

File tree

‎src/msgspec/structs.py‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import weakref
34
from typing import Any
45

56
from . import NODEFAULT, Struct, field
@@ -61,6 +62,15 @@ def required(self) -> bool:
6162
return self.default is NODEFAULT and self.default_factory is NODEFAULT
6263

6364

65+
# Caches the `fields()` result for non-generic structs. Keyed weakly on the
66+
# struct type so cached entries are dropped when the class is collected. Kept in
67+
# a side table rather than on the class itself to avoid mutating user types
68+
# during inspection.
69+
_fields_cache: weakref.WeakKeyDictionary[type, tuple[FieldInfo, ...]] = (
70+
weakref.WeakKeyDictionary()
71+
)
72+
73+
6474
def fields(type_or_instance: Struct | type[Struct]) -> tuple[FieldInfo]:
6575
"""Get information about the fields in a Struct.
6676
@@ -91,7 +101,7 @@ def fields(type_or_instance: Struct | type[Struct]) -> tuple[FieldInfo]:
91101
# Return cached result for non-generic structs
92102
is_generic = annotated_cls is not cls
93103
if not is_generic:
94-
cached = cls.__dict__.get("__struct_field_info__")
104+
cached = _fields_cache.get(cls)
95105
if cached is not None:
96106
return cached
97107

@@ -122,6 +132,6 @@ def fields(type_or_instance: Struct | type[Struct]) -> tuple[FieldInfo]:
122132

123133
# Cache for non-generic structs
124134
if not is_generic:
125-
cls.__struct_field_info__ = result
135+
_fields_cache[cls] = result
126136

127137
return result

‎tests/unit/test_struct.py‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2554,8 +2554,8 @@ class Example(msgspec.Struct, Generic[T]):
25542554
assert result1 is not result2
25552555

25562556
def test_fields_cached_frozen_struct(self):
2557-
# frozen=True blocks setattr on instances, not on the class itself,
2558-
# so the cache slot is still writable via StructMeta's tp_setattro.
2557+
# The cache is a side table keyed on the class, so frozen structs
2558+
# (which block setattr on instances) are cached like any other.
25592559
class Example(msgspec.Struct, frozen=True):
25602560
x: int
25612561
y: str = "hello"
@@ -2564,6 +2564,29 @@ class Example(msgspec.Struct, frozen=True):
25642564
result2 = msgspec.structs.fields(Example)
25652565
assert result1 is result2
25662566

2567+
def test_fields_cache_does_not_mutate_class(self):
2568+
class Example(msgspec.Struct):
2569+
x: int
2570+
2571+
msgspec.structs.fields(Example)
2572+
# The cache lives in a side table, not on the class, so inspecting
2573+
# the struct must not add any attributes to it.
2574+
assert "__struct_field_info__" not in Example.__dict__
2575+
assert not any("field_info" in name for name in vars(Example))
2576+
2577+
def test_fields_cache_not_inherited_by_subclass(self):
2578+
class Base(msgspec.Struct):
2579+
x: int
2580+
2581+
class Child(Base):
2582+
y: int
2583+
2584+
base_fields = msgspec.structs.fields(Base)
2585+
child_fields = msgspec.structs.fields(Child)
2586+
assert [f.name for f in base_fields] == ["x"]
2587+
assert [f.name for f in child_fields] == ["x", "y"]
2588+
assert base_fields is not child_fields
2589+
25672590

25682591
class TestClassVar:
25692592
def case1(self):

0 commit comments

Comments
 (0)