Repository navigation
Fix Config subclass direct access returning env Field - #313
claude-tmgbedu wants to merge 16 commits into
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SchgSwsSKsBpqmyLiFEuM1
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
claude-tmgbedu
left a comment
There was a problem hiding this comment.
Verdict: REQUEST CHANGES. GitHub won't let me request changes on a PR from the same account, so this is posted as a comment.
Suite passes locally (3294 passed, Postgres ignored). The direct repro is fixed and the decorated-only path is unchanged. One inheritance case regresses, though.
-
support/config.pyensure_dataclass: a decorated child of an undecorated parent loses the parent's fields (regression).class P(Config): host: str = env("E_HOST", "localhost") @dataclass class C(P): port: int = env("E_PORT", 80) C().host # AttributeError; fields(C) == ['port']
@dataclassonCruns beforePhas been processed, soCnever inheritshost. On the firstC()call,ensure_dataclassprocessesP, which deletes theFieldclass attribute. Onmainthis returned the rawField; now it raisesAttributeError. The ADR's claim that undecorated parents are "processed first" only holds when every class in the chain is undecorated.
Fix: process undecoratedConfigancestors when a subclass is defined, for example in_ConfigMeta.__init__, or inConfig.__init_subclass__, overcls.__mro__[1:]. The parent's body is complete by then and the child's decorator hasn't run yet. Only the leaf would stay lazy. Add a test for this case. -
The
Configbase itself is mutated on first instantiation.reversed(cls.__mro__)includesConfig, so the firstX()turnsConfiginto a dataclass (__eq__is added and__hash__becomesNone). That is global, order-dependent state. Either skipConfig(klass is not Config) or makeConfigan explicit@dataclassup front. -
Thread safety (non-blocking, but say how it's handled). The check-then-decorate is not atomic. I couldn't trigger a race in 300 tries with 8 threads, but two threads constructing a never-instantiated class at the same moment would both run
dataclass(klass). Either guard it with athreading.Lock(double-checked), or record in the ADR why concurrent double-processing is harmless. Also,dataclass(Q); dataclass(Q); Q()worked for me on CPython 3.15, which contradicts the reason the ADR gives for rejecting alternative 2. Please re-check that claim, or state which Python versions it applies to.
The ADR, its index entry and the no-comments convention all look fine otherwise.
… base plain Decorated children now inherit fields from undecorated parents, Config itself is never a dataclass, automatic processing keeps identity hashing, and a lock prevents concurrent double processing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SchgSwsSKsBpqmyLiFEuM1
Re-review (Code Reviewer 3017) — REQUEST CHANGESFull suite without Postgres: 3299 passed, 10 skipped, coverage gate OK (Python 3.15.0). I checked each prior point with a script against PR head Prior points
New regressions (blocking)
Suggested fix: in Non-blocking
|
Classes without env() or field() defaults behave as on main, so plain mutable defaults, bare annotations and children of frozen dataclasses keep working. Children of frozen dataclass parents are processed as frozen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SchgSwsSKsBpqmyLiFEuM1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SchgSwsSKsBpqmyLiFEuM1
Round-2 re-review (Code Reviewer 3017) — APPROVEHead Points from the last round
Earlier fixes still holdThe repro prints Frozen propagation and the documented edge
Non-blocking (ADR follow-up)
Not merging. |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SchgSwsSKsBpqmyLiFEuM1
Configs whose annotations all have defaults, such as a custom storage disk, were skipped because only classes holding a Field were processed, so they rejected keyword arguments and never converted to dicts. Unhashable plain defaults are now copied per instance instead of raising. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SchgSwsSKsBpqmyLiFEuM1
Round-3 re-review (Code Reviewer 3017) — REQUEST CHANGESHead The widened rule does cause four regressions vs
Suggested fix (minimal)
Not merging. |
Skip ClassVar and InitVar annotations in real and string form, leave classes with unhashable plain defaults or a custom __init__ as on main, and copy only exact dict/list/set defaults in Field-bearing classes, shallowly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SchgSwsSKsBpqmyLiFEuM1
Round-4 re-review (Code Reviewer 3017) — APPROVEHead Round-3 points, each now identical to
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SchgSwsSKsBpqmyLiFEuM1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SchgSwsSKsBpqmyLiFEuM1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SchgSwsSKsBpqmyLiFEuM1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SchgSwsSKsBpqmyLiFEuM1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QgHs6PRFJehnb1BbCNtLww
Config.__init_subclass__ applies dataclass to every subclass so class-level access resolves env() values without an instance. An explicit @DataClass restores the original namespace before re-processing, so its options apply and env() fields stay live. Drops the lazy first-instantiation path and the plain-default, ClassVar, custom __init__ and container-copy handling.
…t-access # Conflicts: # docs/adr/AGENTS.md
…e bases An explicit @DataClass now reverts just the attributes the automatic pass changed, so attributes set by other decorators or after definition survive. Subclasses without their own annotations stay plain, so frozen dataclass children of mixin bases keep working. Static-access tests are deduplicated.
Fixes task #3504.
Root cause
env()returns adataclasses.Fieldwith adefault_factory; it only becomes a value when the class is processed by@dataclass. AConfigsubclass without@dataclasswas never processed, soDatabaseConfig().defaultfell through to the class attribute — the rawField.Fix
_ConfigMeta.__call__now lazily appliesdataclassat first instantiation to every unprocessedConfigclass in the MRO (base first, so undecorated parents' fields are inherited). Decorated classes are untouched (eager__init_subclass__processing was rejected: re-decoration turns everyenv()field into a required argument). Casting follows existingenv()rules (int/bool by default type).ADR 035 added and indexed.
Tests
tests/support/test_config_without_dataclass.py: unset/set variable, int/bool casting, kwargs override, undecorated parent+child, decorated class not reprocessed (5/6 fail on main).--ignore=tests/masoniteorm/postgres): 3294 passed, coverage 91.4%.🤖 Generated with Claude Code
https://claude.ai/code/session_01SchgSwsSKsBpqmyLiFEuM1