Skip to content

py/objtype: Ensures native attr handlers process subclass attribute stores#18594

Open
cnadler86 wants to merge 1 commit into
micropython:masterfrom
cnadler86:fix/18592
Open

py/objtype: Ensures native attr handlers process subclass attribute stores#18594
cnadler86 wants to merge 1 commit into
micropython:masterfrom
cnadler86:fix/18592

Conversation

@cnadler86

@cnadler86 cnadler86 commented Dec 19, 2025

Copy link
Copy Markdown

Summary

Problem Description

When a Python class inherits from a native type that uses an attr handler to
implement properties, setting attributes on instances fails to call the native
property setters. Instead, the values are stored in the instance's members
dictionary, shadowing the native properties.

Root Cause

In objtype.c:

  • mp_obj_instance_store_attr() did not check whether the native base type has
    an attr handler before storing attributes in the instance's member
    dictionary.
  • For LOAD operations, mp_obj_class_lookup() already delegates to the native
    subobject's attr handler, but there was no equivalent for STORE operations.

Solution

Modified mp_obj_instance_store_attr() to give the native base's attr handler
a chance to handle the store/delete before the value is written to the
instance dict:

  • Check for a native base early: Before any other processing, check whether
    the instance's type has a native base class with an attr handler.
  • Delegate to the native handler: Call the handler following the standard
    attr protocol (the same convention used by mp_store_attr): dest[0] is
    passed as MP_OBJ_SENTINEL and dest[1] holds the value.
  • Respect the handler's signal: If the handler owns the attribute it
    performs the store and marks it handled (leaves dest[0] != MP_OBJ_SENTINEL),
    and we return success. If it leaves dest[0] == MP_OBJ_SENTINEL, it doesn't
    own the attribute, so we fall through to the normal property/descriptor checks
    and dict storage.
  • Let exceptions propagate: Any exception the handler raises (e.g. a setter
    rejecting an invalid value) propagates to the caller, exactly as in CPython
    for attribute assignment. No special-casing or catching of exceptions is
    needed.

This keeps the implementation small and consistent with how native attr
handlers already signal "not handled" elsewhere in the codebase (e.g. the
exception type's handler in objexcept.c).

Fixes #18592

Testing

tests/misc/cexample_subclass.py (extended)

Uses cexample.AdvancedTimer — a native type whose attr handler implements
.seconds as a read/write property and follows the sentinel protocol for every
other attribute. This is a dedicated, well-behaved C-level test object (built
into the unix coverage variant), independent of any module-specific quirks.

It verifies, discriminatingly (these assertions fail on the pre-fix code):

  • Store delegation (the bug fix): after self.seconds = 123, the store was
    delegated to the native setter and is not shadowed in the instance dict
    ("seconds" not in self.__dict__).
  • Dict fall-through (Python behaviour preserved): assigning an attribute the
    handler doesn't own (self.extra = "hello") falls through to normal instance
    __dict__ storage and reads back correctly.

Coverage of the added objtype.c block was confirmed with gcov: both outcomes
of the delegation branch are exercised by this single test (the handler-handled
path and the fall-through path).

tests/basics/subclass_native_attrs.py

Exception-based regression test that runs on all ports: verifies that native
attribute access (.args) keeps working on a subclass while arbitrary Python
attributes still fall back to the instance dict.

Notes

  • Catching AttributeError/KeyError from the handler (original approach):
    An earlier version wrapped the handler call in nlr_push/nlr_pop and, on
    AttributeError/KeyError, fell back to dict storage. This was dropped per
    review: it's more complex, swallows genuine errors, and is not CPython-like.
    The sentinel protocol already provides a clean "not handled" signal, and real
    errors (e.g. a setter's TypeError) should propagate.
  • uctypes as the test vehicle (original test): The first test was based on
    uctypes. It was replaced because the uctypes attr handler raises
    KeyError for unknown fields instead of leaving the sentinel, which coupled
    the test to a uctypes-specific quirk. cexample.AdvancedTimer keeps the test
    independent and discriminating.
  • Behavioural note: A native type whose attr handler raises (rather than
    leaving the sentinel) for unknown attributes will, by design, propagate that
    exception in subclasses too instead of falling back to the dict. This matches
    the handler's own contract; well-behaved handlers that follow the sentinel
    protocol get dict fall-through for free.

@codecov

codecov Bot commented Dec 19, 2025

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.51%. Comparing base (d901e98) to head (d027332).

Additional details and impacted files
@@           Coverage Diff           @@
##           master   #18594   +/-   ##
=======================================
  Coverage   98.51%   98.51%           
=======================================
  Files         176      176           
  Lines       22903    22910    +7     
=======================================
+ Hits        22562    22569    +7     
  Misses        341      341           

☔ View full report in Codecov by Harness.
📢 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.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Dec 19, 2025

Copy link
Copy Markdown

Code size report:

Reference:  unix/README: Update the supported targets list. [d901e98]
Comparison: py/objtype: Fix attr delegation in subclasses of native types. [merge of d027332]
  mpy-cross:   +96 +0.025% 
   bare-arm:   +64 +0.114% 
minimal x86:  +115 +0.061% 
   unix x64:   +96 +0.011% standard
      stm32:   +56 +0.014% PYBV10
      esp32:   +64 +0.004% ESP32_GENERIC
     mimxrt:   +56 +0.014% TEENSY40
        rp2:   +64 +0.007% RPI_PICO_W
       samd:   +56 +0.020% ADAFRUIT_ITSYBITSY_M4_EXPRESS
  qemu rv32:   +64 +0.014% VIRT_RV32

@cnadler86 cnadler86 force-pushed the fix/18592 branch 5 times, most recently from 2ac14e6 to f402456 Compare December 21, 2025 17:50
@cnadler86 cnadler86 marked this pull request as ready for review December 21, 2025 18:55
@dlech

dlech commented Dec 22, 2025

Copy link
Copy Markdown
Contributor

It would be nice to to have additional tests without a .exp file to show that this also works like CPython.

@cnadler86

Copy link
Copy Markdown
Author

It would be nice to to have additional tests without a .exp file to show that this also works like CPython.

Yes. but the problem is that i need a cpython built in with an attr handler for that. I could extend a test with something like this:

class Base:
    def __init__(self):
        self._value = 0
    
    @property
    def value(self):
        return self._value
    
    @value.setter
    def value(self, val):
        # This setter should be called even in subclasses
        if not isinstance(val, int):
            raise TypeError("value must be int")
        self._value = val

class Sub(Base):
    pass

Will then use this and more or less test hat the properties of the subclass work as expected. What do you think?

@cnadler86 cnadler86 changed the title Ensures native attr handlers process subclass attribute stores py/objtype: Ensures native attr handlers process subclass attribute stores Dec 23, 2025
@dlech

dlech commented Dec 23, 2025

Copy link
Copy Markdown
Contributor

Hmm... this is a bit tricky. That isn't a valid test since the base class is not implement in C in MicroPython.

I looked at all of the builtin types in MicroPython that have an attrs slot that allows setting an attribute that also exist in CPython. The only one I came up with was Exception that has a special case to allows setting __traceback__ to None. Maybe there would be some way to make a test out of that?

@cnadler86 cnadler86 force-pushed the fix/18592 branch 2 times, most recently from 2042cb8 to 22e6986 Compare December 26, 2025 21:06
@cnadler86

cnadler86 commented Dec 26, 2025

Copy link
Copy Markdown
Author

Hmm... this is a bit tricky. That isn't a valid test since the base class is not implement in C in MicroPython.

I looked at all of the builtin types in MicroPython that have an attrs slot that allows setting an attribute that also exist in CPython. The only one I came up with was Exception that has a special case to allows setting __traceback__ to None. Maybe there would be some way to make a test out of that?

Yes, I thought custom classes will not be the right approach.
I tried to follow your suggestion, but sadly the __traceback__ path is solely a store path and I cannot read the attribute back or its consequences in a test (or at least I didn't found a way).

A possibility would be to extend the objexcept.c so that it raises a TypeError if we try to set the __traceback__ not to None (would be more c-python compatible). In this case I could test this path. I did this locally and the test passed and this PR and c-python produced the same output.

At the moment I just added some additional tests. They don't bring more coverage, and IMO could be deleted, but I left them here for discussion at the moment.

@dpgeorge dpgeorge added the py-core Relates to py/ directory in source label Dec 31, 2025
@cnadler86

Copy link
Copy Markdown
Author

Hi everyone,

I wanted to politely follow up on this PR. It's been a few weeks since I submitted this fix for the native attribute handler issue in subclasses.

I'd be happy to address any feedback or make adjustments if needed. Could someone let me know if there are any concerns with the approach, or if additional tests/documentation would be helpful?

Thanks for taking the time to review this, and I appreciate your work on MicroPython!

Best regards,
Chris

@dpgeorge

dpgeorge commented May 9, 2026

Copy link
Copy Markdown
Member

Thanks @cnadler86 for the PR.

I think MicroPython already implements the correct behaviour here, namely that stores to an instance go to the top-level instance, and are not delegated to base classes.

Loads are similar in that the top-level instance is searched first. Only if that fails does the search continue in the base classes.

Since a store will always succeed at the top-level, there's no need to go to the base class for a store.

Your original bug report was about inheriting from uctypes.struct not working as expected. But, I don't think there's a good expectation of what should happen there. Probably the existing behaviour is correct (per CPython semantics) albeit unexpected.

So I'd be inclined to close this PR.

@cnadler86

Copy link
Copy Markdown
Author

Hi @dpgeorge,
I probably should test this with the newest master, but here is an explanation of the problem:

I have on c-level the following property definition (only as example

// Property handler
static void camera_obj_property(mp_obj_t self_in, qstr attr, mp_obj_t *dest) {
    mp_camera_obj_t *self = MP_OBJ_TO_PTR(self_in);
    if (dest[0] == MP_OBJ_NULL) {
        // Load (reading)
        switch (attr) {
            case MP_QSTR_frame_size:
                dest[0] = MP_OBJ_NEW_SMALL_INT(mp_camera_hal_get_frame_size(self));
                break;
            default:
                // Delegate to locals_dict
                dest[1] = MP_OBJ_SENTINEL;
                return;
        }
    } else if (dest[0] == MP_OBJ_SENTINEL && dest[1] != MP_OBJ_NULL) {
        // Store (writing)
        switch (attr) {
            case MP_QSTR_frame_size:
                mp_camera_hal_set_frame_size(self, mp_obj_get_int(dest[1]));
                break;
            default:
                // Delegate to locals_dict for methods not handled here
                return;
        }
        // Success - indicate attribute was stored
        dest[0] = MP_OBJ_NULL;
    }

...

MP_DEFINE_CONST_OBJ_TYPE(
    camera_type,
    MP_QSTR_Camera,
    MP_TYPE_FLAG_NONE,
    make_new, mp_camera_make_new,
    print, mp_camera_hal_print,
    attr, camera_obj_property,
    locals_dict, &camera_camera_locals_dict
);

Then, in python I subclass from my C-Level class (e.g. in modul acamera.py):

from camera import Camera as _Camera
class Camera(_Camera):

Then, if I instantiate a Camera object from the acamera module, the properties inherited from _Camera are not settable. I can get get them, but not use the c-level setter. Instead, they are stored in the __dict__ of the upper level instance.

Of course, there is always a work around for this, but I found the behaviour as you mention unexpected. Not sure if c-python would handle such a situation the same way. I just used uctypes as an example since this built-in object had the needed low level setter.

@dpgeorge

dpgeorge commented Jun 2, 2026

Copy link
Copy Markdown
Member

Thanks for the code snippet. That definitely helps to explain the issue.

After some testing I did find a way to exhibit this behaviour in CPython. Here's the code:

import socket
class MySocket(socket.socket):
    pass
s = MySocket()
print(s.__dict__)
s.foo = 123
print(s.__dict__)
print(s.proto)
s.proto = 456

That gives AttributeError: readonly attribute (for storing to proto).

This CPython tests shows that CPython does indeed delegate stores first to the underlying native object. Only if there's no such attribute does it then create a new entry in the locals __dict__ of the Python subclass. If the attribute exists (eg proto) then it defers the store to it, even if it's read-only.

Using the same test but storing s.type = 456 leads to AttributeError: property 'type' of 'MySocket' object has no setter. So indeed it defers fully to the attribute.

Still... adding this to MicroPython will add code size, and more importantly give a performance hit to stores for all user classes. Need to consider if it's worth it.

Comment thread py/objtype.c Outdated
@cnadler86 cnadler86 force-pushed the fix/18592 branch 4 times, most recently from 756d218 to 451c3f0 Compare June 16, 2026 03:55
@cnadler86

cnadler86 commented Jun 16, 2026

Copy link
Copy Markdown
Author

This PR now needs #19345 to land in the master branch in order to have green tests.

Fixes incorrect delegation of attribute assignment in subclasses
of native types with custom attribute handlers by ensuring the
handler is invoked before storing attributes in the instance
dictionary. Adds tests to verify correct behavior for both
native and subclassed types.

Signed-off-by: Christopher Nadler <christopher.nadler@gmail.com>
@cnadler86

Copy link
Copy Markdown
Author

I changed the test path to have no PR dependencies.

# Test 1: Basic subclass functionality
e1 = MyException("hello", "world")
assert e1.args == ("hello", "world"), "Subclass args failed"
print("Subclass creation: OK")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If this test runs the same under CPython (and I think it does) then it's simpler (and consistent with other tests) to just do print(e1.args) here, instead of the assert and the print of "Subclass creation: OK" (eg if it fails in the future it'll at least print out the incorrect value of e1.args instead of just failing the assert).

e2 = MyException("Test PASS")
e2.custom_attr = "test" # type: ignore
assert e2.custom_attr == "test", "Custom attribute failed" # type: ignore
print("Dict fallback: OK")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

As above, these two lines could simply be print(e2.custom_attr).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

py-core Relates to py/ directory in source

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python subclass of native type with attr handler cannot properly store attributes

3 participants