Skip to content

aset for private attributes#11

Merged
ymahlau merged 9 commits into
mainfrom
develop
Apr 24, 2026
Merged

aset for private attributes#11
ymahlau merged 9 commits into
mainfrom
develop

Conversation

@ymahlau

@ymahlau ymahlau commented Apr 23, 2026

Copy link
Copy Markdown
Owner

resolves #9

Summary by CodeRabbit

  • New Features

    • Added allow_private and bypass_callbacks controls for attribute updates.
    • Added create_new_ok to permit creating new attributes/keys during in-place updates.
  • Bug Fixes

    • Clearer errors and stricter validation for missing or non-init fields; improved behavior for private-field updates.
  • Refactor

    • Simplified visualization string formatting.
  • Documentation

    • Documented that slot-based dataclasses are not supported.
  • Tests

    • Expanded coverage for private-field updates, callback control, and create_new_ok.

@coderabbitai

coderabbitai Bot commented Apr 23, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds granular controls for dataclass field updates: DataClass.aset, aset_inplace, and _AtIndexer.set gain allow_private and bypass_callbacks (and create_new_ok for inplace). Replaces dataclasses.replace with _copy_and_set, tightens field validation and errors, and makes the dataclass transform ignore slots/weakref_slot.

Changes

Cohort / File(s) Summary
Core dataclass mutation
src/drinx/base.py
Replaced dataclasses.replace with new _copy_and_set. DataClass.aset signature adds allow_private and bypass_callbacks; aset_inplace adds create_new_ok and bypass_callbacks; _AtIndexer.set forwards flags. Enforces that targets are declared dataclass fields (controlled by create_new_ok), rejects init=False updates unless allow_private=True, optionally invokes DRINX_ON_SETATTR callbacks unless bypass_callbacks=True, and performs in-place vs functional update paths accordingly. __init_subclass__/documentation now treats slots/weakref_slot as unsupported and forces weakref_slot=False.
Pytree flatten validation
src/drinx/transform.py
flatten_with_keys rebuilt to use explicit loops for dynamic leaves and static aux accumulation, catching missing-attribute AttributeError and re-raising clearer messages that name the missing field and explain non-init requirements. The dataclass decorator no longer honors slots/weakref_slot (always calls dataclasses.dataclass(..., slots=False, weakref_slot=False)).
Visualization small refactor
src/drinx/visualize.py
Minor refactor in visualize_leaf building prefix with a single-line f-string using a generator expression; no behavior change.
Tests: base behavior and private fields
tests/test_base.py
Extensive new tests for allow_private behavior (functional and inplace), create_new_ok creation semantics for attrs and nested dict keys, callback execution control via bypass_callbacks (including ordering and nested vs leaf callbacks), and assertions that aset is functional while aset_inplace mutates. Clarified comments about unknown-field rejection and default allow_private=False.
Tests: dataclass slots expectations
tests/test_dataclass.py
Removed assertions that @dataclass(slots=True) works; retained checks that default (no slots) does not add __slots__ and that instantiation/access still function.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant API as Client API (aset / at[].set / aset_inplace)
    participant Validator as Field Validator
    participant Copier as _copy_and_set / Inplace Mutator
    participant Instance as DataClass Instance
    participant Callbacks as DRINX_ON_SETATTR

    User->>API: request update(field, value, allow_private?, bypass_callbacks?, create_new_ok?)
    API->>Validator: verify field exists / check create_new_ok and init flag
    alt Field missing & create_new_ok=False
        Validator-->>API: raise TypeError
        API-->>User: error
    else Field present or creation allowed
        Validator->>Validator: determine if init=False and whether allow_private allows it
        alt init=False and allow_private=False
            Validator-->>API: raise TypeError
            API-->>User: error
        else
            API->>Callbacks: if not bypass -> run pre/field callbacks (may be nested)
            API->>Copier: perform update (functional via _copy_and_set or inplace mutator)
            Copier->>Instance: clone+set or mutate, apply callbacks if not already run
            Instance-->>Copier: updated instance/state
            Copier-->>API: return result
            API-->>User: return updated instance or None (inplace)
        end
    end
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I hop through fields both hidden and bright,

allow_private nudges secrets to light.
I copy, I set, callbacks may sing—
a gentle clone and a careful spring. 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.03% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'aset for private attributes' directly and clearly summarizes the main change—adding support for private attributes in the aset method.
Linked Issues check ✅ Passed The PR implements the requirement from issue #9: aset now supports private (leading-underscore) attributes via a new _copy_and_set helper and allow_private parameter instead of dataclasses.replace.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing private attribute support and related functional enhancements (callbacks, field creation control); no unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov-commenter

codecov-commenter commented Apr 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.00000% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.96%. Comparing base (fa3bc32) to head (ccf6bf0).

Files with missing lines Patch % Lines
src/drinx/base.py 81.25% 5 Missing and 4 partials ⚠️
src/drinx/transform.py 75.00% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #11      +/-   ##
==========================================
- Coverage   86.47%   85.96%   -0.51%     
==========================================
  Files           6        6              
  Lines         451      506      +55     
  Branches       96      114      +18     
==========================================
+ Hits          390      435      +45     
- Misses         39       46       +7     
- Partials       22       25       +3     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/drinx/base.py`:
- Around line 377-385: The _copy_and_set helper bypasses on_setattr callbacks
(and any __post_init__ logic) because it creates a shallow copy via
object.__new__ and object.__setattr__, so updates performed through aset no
longer trigger callbacks as dataclasses.replace did; fix by either restoring
behavior: call the same callback invocation used by on_setattr (or reuse
dataclasses.replace) when setting field_name in _copy_and_set (ensure
__post_init__/on_setattr handlers are invoked for the new value), or explicitly
document that _copy_and_set intentionally skips callbacks; reference
functions/classes: _copy_and_set, aset, on_setattr, dataclasses.replace, and
__post_init__ when applying the chosen change.
- Around line 377-385: The _copy_and_set function fails for slots-based
dataclass instances because it assumes obj.__dict__ exists; update _copy_and_set
to detect if the instance has a __dict__ and copy attributes from it, and
otherwise iterate over the class's __slots__ (and any inherited slots) to read
and copy slot values, using object.__getattribute__ to fetch each attribute and
object.__setattr__ to set them on the newly created instance; preserve the
existing behavior of setting the target field_name after copying and ensure
private/ mangled slot names are handled (e.g., look up slot names on type(obj)
and skip slot names not present on the instance).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 493bd431-f0bb-4e0a-8598-7b31f3b74317

📥 Commits

Reviewing files that changed from the base of the PR and between fa3bc32 and ea3fe01.

📒 Files selected for processing (4)
  • src/drinx/base.py
  • src/drinx/transform.py
  • src/drinx/visualize.py
  • tests/test_base.py

Comment thread src/drinx/base.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/drinx/base.py (1)

431-436: ⚠️ Potential issue | 🔴 Critical

allow_private=True still rejects declared private fields that have not been initialized yet.

This check uses hasattr(parent, str(final_op)) before you look at the dataclass field definition. For private_field() / init=False fields without a default or default_factory, the attribute is absent from the instance, so aset("_field", ..., allow_private=True) still raises here instead of reaching the new private-field path.

Suggested fix
         if final_op_type == "attribute":
-            if not hasattr(parent, str(final_op)):
-                if not create_new_ok:
-                    raise Exception(
-                        f"Attribute: {final_op} does not exist for {parent.__class__}"
-                    )
+            if dataclasses.is_dataclass(parent):
+                dc_field_names = {f.name for f in dataclasses.fields(parent)}
+                if str(final_op) not in dc_field_names and not create_new_ok:
+                    raise Exception(
+                        f"Attribute: {final_op} does not exist for {parent.__class__}"
+                    )
+            elif not hasattr(parent, str(final_op)) and not create_new_ok:
+                raise Exception(
+                    f"Attribute: {final_op} does not exist for {parent.__class__}"
+                )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/drinx/base.py` around lines 431 - 436, The current attribute-existence
check in the final_op_type == "attribute" branch uses hasattr(parent,
str(final_op)) which incorrectly raises for declared dataclass private fields
(private_field/init=False without default) that are not yet set; update the
logic to, when hasattr returns False and create_new_ok is False, inspect the
dataclass definition on type(parent) (e.g., type(parent).__dataclass_fields__ or
dataclasses.fields(type(parent))) to see if a field named str(final_op) is
declared and represents a private/init=False field and allow_private is True —
if so, do not raise and let the existing private-field handling path proceed;
otherwise keep raising the exception referring to final_op and parent.__class__.
Ensure you reference final_op_type, parent, final_op, create_new_ok and the
allow_private/private_field semantics when implementing the check.
♻️ Duplicate comments (1)
src/drinx/base.py (1)

381-389: ⚠️ Potential issue | 🟠 Major

aset now bypasses __post_init__ invariants for functional updates.

_copy_and_set clones __dict__ and writes the field directly, so any derived/cache fields recomputed in __post_init__ stay stale after aset. That is a behavior regression from dataclasses.replace(...) and can leave the returned object internally inconsistent.

One way to preserve the old semantics for normal init-fields
-                cur_attr = DataClass._copy_and_set(current_parent, str(op), cur_attr)
+                if target_field.init and not bypass_callbacks:
+                    cur_attr = dataclasses.replace(
+                        current_parent, **{str(op): cur_attr}
+                    )
+                else:
+                    cur_attr = DataClass._copy_and_set(
+                        current_parent, str(op), cur_attr
+                    )

Also applies to: 470-475

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/drinx/base.py` around lines 381 - 389, _copy_and_set currently clones
__dict__ and writes the field directly which bypasses dataclass/attrs post-init
invariants; fix by preserving semantics: if the object is a dataclass use
dataclasses.replace(obj, **{field_name: value}) inside _copy_and_set (or in
aset), otherwise after copying and setting the new attribute call the
class/object post-init hook if present (e.g., post = getattr(new_obj,
"__post_init__", None); if callable(post): post()) so derived/cache fields are
recomputed; update the same logic for the other occurrence around lines 470-475
(reference functions: _copy_and_set and aset).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/drinx/base.py`:
- Around line 83-90: The docstring says weakref_slot is ignored but the code
still forwards weakref_slot to the stdlib dataclass decorator, which raises if
weakref_slot=True while slots isn't passed; update the implementation so
weakref_slot is discarded like slots instead of being forwarded to dataclass:
locate the argument handling where slots and weakref_slot are received and the
decorator application around the dataclass(...) call (references: variables
slots and weakref_slot and the dataclass decorator invocation that wraps the
class), remove or omit weakref_slot from the keyword args passed into
dataclass(...) and ensure the decorator call no longer forwards weakref_slot to
avoid runtime validation errors.

---

Outside diff comments:
In `@src/drinx/base.py`:
- Around line 431-436: The current attribute-existence check in the
final_op_type == "attribute" branch uses hasattr(parent, str(final_op)) which
incorrectly raises for declared dataclass private fields
(private_field/init=False without default) that are not yet set; update the
logic to, when hasattr returns False and create_new_ok is False, inspect the
dataclass definition on type(parent) (e.g., type(parent).__dataclass_fields__ or
dataclasses.fields(type(parent))) to see if a field named str(final_op) is
declared and represents a private/init=False field and allow_private is True —
if so, do not raise and let the existing private-field handling path proceed;
otherwise keep raising the exception referring to final_op and parent.__class__.
Ensure you reference final_op_type, parent, final_op, create_new_ok and the
allow_private/private_field semantics when implementing the check.

---

Duplicate comments:
In `@src/drinx/base.py`:
- Around line 381-389: _copy_and_set currently clones __dict__ and writes the
field directly which bypasses dataclass/attrs post-init invariants; fix by
preserving semantics: if the object is a dataclass use dataclasses.replace(obj,
**{field_name: value}) inside _copy_and_set (or in aset), otherwise after
copying and setting the new attribute call the class/object post-init hook if
present (e.g., post = getattr(new_obj, "__post_init__", None); if
callable(post): post()) so derived/cache fields are recomputed; update the same
logic for the other occurrence around lines 470-475 (reference functions:
_copy_and_set and aset).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3f122fa3-a111-48c6-8238-43a13401dcc0

📥 Commits

Reviewing files that changed from the base of the PR and between ea3fe01 and 0619b3c.

📒 Files selected for processing (3)
  • src/drinx/base.py
  • src/drinx/transform.py
  • tests/test_base.py

Comment thread src/drinx/base.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/drinx/transform.py`:
- Around line 145-149: The docstring for the dataclass options mentions a
`weakref_slot` that "Add a ``__weakref__`` slot", but at runtime the
`weakref_slot` parameter is ignored and forced to False (so it does not add a
__weakref__ slot); update the docstring text around the `slots` / `weakref_slot`
entries to state that `weakref_slot` is not supported/ignored (or deprecated)
and has no effect (defaults to False) instead of claiming it adds a __weakref__
slot; reference the `weakref_slot` parameter name in your change and remove or
replace the misleading sentence so the docs match the code behavior in
transform.py.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3a5f6b47-e96a-4d2c-adee-e44c1ccc6e4d

📥 Commits

Reviewing files that changed from the base of the PR and between 0619b3c and a93e206.

📒 Files selected for processing (3)
  • src/drinx/base.py
  • src/drinx/transform.py
  • tests/test_dataclass.py
💤 Files with no reviewable changes (1)
  • tests/test_dataclass.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/drinx/base.py

Comment thread src/drinx/transform.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
tests/test_base.py (1)

2175-2181: Minor: Clarify comment wording.

The comment "no callback: value unchanged" at line 2181 is slightly misleading—the value IS changed (from 1.0 to 9.0), it's just not transformed by any callback. Consider: "no callback registered: value set as-is".

📝 Suggested comment clarification
-        assert foo.x == 9.0  # no callback: value unchanged
+        assert foo.x == 9.0  # no callback registered: value set as-is
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_base.py` around lines 2175 - 2181, Update the inline test comment
in test_bypass_false_no_callback_registered to clearly state that the field
value was changed but not transformed by any callback; replace "no callback:
value unchanged" with something like "no callback registered: value set as-is"
(refer to the test function test_bypass_false_no_callback_registered, the Foo
DataClass, and the aset_inplace call) so the comment accurately reflects that
the value was set to 9.0 without callback processing.
src/drinx/base.py (1)

618-634: Docstring missing allow_private parameter documentation.

The set method signature includes allow_private: bool = False at line 619, but the docstring Args section (lines 628-630) only documents bypass_callbacks. The allow_private parameter should be documented for API completeness.

📝 Add missing parameter documentation
         Args:
             value: The new value.  For mask updates this may be a scalar or a
                 DataClass tree of the same type as the mask/object.
+            allow_private: If True, allow updating non-init (private) fields.
+                Default False rejects such updates.
             bypass_callbacks: If True, skip ``on_setattr`` callbacks. Default False runs them.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/drinx/base.py` around lines 618 - 634, The docstring for DataClass.set is
missing documentation for the allow_private parameter; update the Args section
of the set method's docstring to include allow_private: bool = False and a short
description (e.g., whether private attributes/keys are allowed to be set or
filtered out), mirroring the style used for bypass_callbacks and mentioning
interaction with DataClass.aset or mask updates where relevant so callers
understand its effect.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/drinx/base.py`:
- Around line 618-634: The docstring for DataClass.set is missing documentation
for the allow_private parameter; update the Args section of the set method's
docstring to include allow_private: bool = False and a short description (e.g.,
whether private attributes/keys are allowed to be set or filtered out),
mirroring the style used for bypass_callbacks and mentioning interaction with
DataClass.aset or mask updates where relevant so callers understand its effect.

In `@tests/test_base.py`:
- Around line 2175-2181: Update the inline test comment in
test_bypass_false_no_callback_registered to clearly state that the field value
was changed but not transformed by any callback; replace "no callback: value
unchanged" with something like "no callback registered: value set as-is" (refer
to the test function test_bypass_false_no_callback_registered, the Foo
DataClass, and the aset_inplace call) so the comment accurately reflects that
the value was set to 9.0 without callback processing.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c0e89ece-b743-4c73-bfaa-f5a2b1dc0243

📥 Commits

Reviewing files that changed from the base of the PR and between a93e206 and ccf6bf0.

📒 Files selected for processing (2)
  • src/drinx/base.py
  • tests/test_base.py

@ymahlau ymahlau merged commit bc8cd92 into main Apr 24, 2026
3 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request Apr 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Method for private attributes

2 participants