Skip to content

Ensure semimutable vars show public names#15

Draft
Glinte wants to merge 1 commit into
mainfrom
codex/update-frozenfield-implementation-and-add-tests
Draft

Ensure semimutable vars show public names#15
Glinte wants to merge 1 commit into
mainfrom
codex/update-frozenfield-implementation-and-add-tests

Conversation

@Glinte

@Glinte Glinte commented Aug 8, 2025

Copy link
Copy Markdown
Owner

Summary

  • Mirror frozen field values under both public and private keys so vars() exposes public names
  • Rework class freezing to reserve slots for private fields while retaining a __dict__
  • Add regression test confirming vars() outputs only public field names

Testing

  • pytest -q

https://chatgpt.com/codex/tasks/task_e_68958e5b410c83229e0e5acf44cfed7c

@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 82.60870% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
semimutable/__init__.py 82.60% 3 Missing and 1 partial ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Files with missing lines Coverage Δ
semimutable/__init__.py 85.03% <82.60%> (-0.91%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 FrozenField descriptor 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__ for slots=False classes
  • Added regression test confirming vars() outputs only public field names for slots=False case

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.

Comment thread semimutable/__init__.py

# Clear existing `__weakref__` descriptor, it belongs to a previous type:
cls_dict.pop("__weakref__", None) # gh-102069
else:

Copilot AI Jan 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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.

Copilot uses AI. Check for mistakes.
Comment thread semimutable/__init__.py
Comment on lines +362 to +367
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)

Copilot AI Jan 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
Comment thread semimutable/__init__.py
)

# 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)

Copilot AI Jan 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread semimutable/__init__.py
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__",)

Copilot AI Jan 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread semimutable/__init__.py
Comment on lines +91 to +92
except Exception:
dct = None

Copilot AI Jan 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread semimutable/__init__.py
Comment on lines +103 to +104
except Exception:
dct = None

Copilot AI Jan 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
import pytest

from semimutable import FrozenFieldError, dataclass, field
from semimutable import FROZEN_PREFIX

Copilot AI Jan 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Adding FROZEN_PREFIX to __all__ in semimutable/init.py if it's part of the public API
  2. Using a private import approach or accessing it through the internal implementation if it's only needed for testing

Copilot uses AI. Check for mistakes.
Comment on lines +67 to +76
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

Copilot AI Jan 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants