Ensure semimutable vars show public names#15
Conversation
|
Codecov Report❌ Patch coverage is
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR ensures that vars() on semimutable dataclass instances exposes only public field names, not the internal private field names used for frozen field storage. The implementation mirrors frozen field values under both public (in __dict__) and private (in slots) keys.
Changes:
- Modified
FrozenFielddescriptor to store values in both__dict__(under public names) and slots (under private names) for classes with__dict__ - Updated descriptor's
__get__method to prefer public name from__dict__when available - Reworked class freezing logic to create slots for private fields while retaining
__dict__forslots=Falseclasses - Added regression test confirming
vars()outputs only public field names forslots=Falsecase
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 8 comments.
| File | Description |
|---|---|
| tests/test_semimutable_dataclass.py | Adds test verifying that vars() returns only public field names, not private prefixed names |
| semimutable/init.py | Updates FrozenField descriptor to mirror values in both public (__dict__) and private (slots) storage; modifies slots creation logic to support __dict__ for slots=False while providing private field slots |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| # Clear existing `__weakref__` descriptor, it belongs to a previous type: | ||
| cls_dict.pop("__weakref__", None) # gh-102069 | ||
| else: |
There was a problem hiding this comment.
Setting needs_new_class = True unconditionally for the slots=False case means a new class will always be created even if not strictly necessary for slots. While this simplifies the logic and ensures consistent behavior, consider adding a comment explaining why a new class is required for the slots=False case (presumably to add the private field slots while maintaining dict).
| else: | |
| else: | |
| # Even when the original class does not define __slots__, we still need to synthesize | |
| # a new class so that we can introduce our own private frozen-field slots while | |
| # preserving an instance __dict__. This mirrors dataclasses._process_class behavior | |
| # and avoids mutating the original class definition in-place. |
| needs_new_class = True | ||
| cls_dict["__slots__"] = tuple(FROZEN_PREFIX + field_name for field_name in field_names) + ("__dict__",) | ||
| for field_name in field_names: | ||
| cls_dict.pop(field_name, None) | ||
| cls_dict.pop("__dict__", None) | ||
| cls_dict.pop("__weakref__", None) |
There was a problem hiding this comment.
This logic creates __slots__ even when slots=False is specified. The new slots include private frozen field names and __dict__. While this achieves the goal of having both private slots and a __dict__, it may be surprising behavior since users explicitly requested slots=False. Consider documenting this behavior or verifying that this doesn't break expected semantics of slots=False for non-frozen fields.
| needs_new_class = True | |
| cls_dict["__slots__"] = tuple(FROZEN_PREFIX + field_name for field_name in field_names) + ("__dict__",) | |
| for field_name in field_names: | |
| cls_dict.pop(field_name, None) | |
| cls_dict.pop("__dict__", None) | |
| cls_dict.pop("__weakref__", None) | |
| # Only synthesize __slots__ for classes without existing slots when slots are enabled. | |
| # When params.slots is False, respect the standard dataclasses semantics by not creating __slots__. | |
| if params.slots: | |
| needs_new_class = True | |
| cls_dict["__slots__"] = tuple( | |
| FROZEN_PREFIX + field_name for field_name in field_names | |
| ) + ("__dict__",) | |
| for field_name in field_names: | |
| cls_dict.pop(field_name, None) | |
| cls_dict.pop("__dict__", None) | |
| cls_dict.pop("__weakref__", None) |
| ) | ||
|
|
||
| # Add our frozen fields to the slots, so they can be used by descriptors. | ||
| cls_dict["__slots__"] += tuple(FROZEN_PREFIX + field_name for field_name in field_names) |
There was a problem hiding this comment.
Private slots are being created for ALL field names (FROZEN_PREFIX + field_name for field_name in field_names), but only frozen fields actually need them. Non-frozen fields don't use these private slots, making them wasteful. Consider filtering to create private slots only for fields that are actually frozen (those with "frozen" in their metadata), which can be determined by checking f.metadata when iterating over fields(cls).
| cls_dict.pop("__weakref__", None) # gh-102069 | ||
| else: | ||
| needs_new_class = True | ||
| cls_dict["__slots__"] = tuple(FROZEN_PREFIX + field_name for field_name in field_names) + ("__dict__",) |
There was a problem hiding this comment.
Private slots are being created for ALL field names (FROZEN_PREFIX + field_name for field_name in field_names), but only frozen fields actually need them. Non-frozen fields don't use these private slots, making them wasteful. Consider filtering to create private slots only for fields that are actually frozen (those with "frozen" in their metadata), which can be determined by checking f.metadata when iterating over fields(cls).
| except Exception: | ||
| dct = None |
There was a problem hiding this comment.
The broad except Exception: clause may suppress legitimate errors. Consider catching AttributeError specifically, which is the expected exception when an object doesn't have a __dict__ attribute (e.g., slotted classes). This makes the error handling more precise and avoids masking unexpected exceptions.
| except Exception: | ||
| dct = None |
There was a problem hiding this comment.
The broad except Exception: clause may suppress legitimate errors. Consider catching AttributeError specifically, which is the expected exception when an object doesn't have a __dict__ attribute (e.g., slotted classes). This makes the error handling more precise and avoids masking unexpected exceptions.
| import pytest | ||
|
|
||
| from semimutable import FrozenFieldError, dataclass, field | ||
| from semimutable import FROZEN_PREFIX |
There was a problem hiding this comment.
FROZEN_PREFIX is imported from semimutable but is not included in the module's __all__ export list. This suggests it's intended as a private implementation detail. Consider either:
- Adding FROZEN_PREFIX to
__all__in semimutable/init.py if it's part of the public API - Using a private import approach or accessing it through the internal implementation if it's only needed for testing
| def test_vars_lists_public_field_names_only(): | ||
| @dataclass(slots=False) | ||
| class Sm: | ||
| x: int = field(frozen=True) | ||
| y: int = 0 | ||
|
|
||
| sm = Sm(x=1, y=2) | ||
| d = vars(sm) | ||
| assert d == {"x": 1, "y": 2} | ||
| assert FROZEN_PREFIX + "x" not in d |
There was a problem hiding this comment.
The test only validates the behavior for slots=False. Consider adding a test case for slots=True to ensure that the descriptor logic works correctly for slotted classes, even though slotted classes typically don't have __dict__. This would verify that the fallback to getattr(instance, self._private_name) works as expected.
Summary
vars()exposes public names__dict__vars()outputs only public field namesTesting
pytest -qhttps://chatgpt.com/codex/tasks/task_e_68958e5b410c83229e0e5acf44cfed7c