Skip to content

Obj: refuse to auto-vivify dunder and sunder attributes#185

Open
Deln0r wants to merge 1 commit into
mautrix:masterfrom
Deln0r:fix/issue-176-obj-getattr-dunder
Open

Obj: refuse to auto-vivify dunder and sunder attributes#185
Deln0r wants to merge 1 commit into
mautrix:masterfrom
Deln0r:fix/issue-176-obj-getattr-dunder

Conversation

@Deln0r
Copy link
Copy Markdown

@Deln0r Deln0r commented May 13, 2026

Closes #176.

Problem

Obj.__getattr__ auto-vivifies missing attributes into empty child Obj instances. That is intentional for the documented keyword-escape pattern (obj.from_ maps to the from key), but it interacts badly with Python introspection tooling:

  • inspect.unwrap(obj) probes __wrapped__, gets a fresh Obj back, follows it, gets another fresh Obj, and the underlying Obj now holds a recursive __wrapped entry. Any subsequent serialize() or __repr__() call recurses until RecursionError — exactly the traceback in the issue.
  • IPython probes _ipython_canary_method_should_not_exist_ (a sunder name) on every object it displays, leaving the same kind of stray entry.

Fix

Refuse to create attributes whose name starts AND ends with an underscore. The keyword-escape case (single trailing underscore, e.g. from_) is unaffected because the leading underscore is absent. Defined dunder methods (__init__, __repr__, …) are unaffected because __getattr__ is only called when normal attribute lookup has already failed.

def __getattr__(self, name):
    if name.startswith("_") and name.endswith("_"):
        raise AttributeError(name)
    name = name.rstrip("_")
    ...

This is the exact shape proposed by the issue author.

Test plan

A new mautrix/types/util/obj_test.py covers:

Test Behaviour
test_obj_basic_attribute_access Normal obj.name = ... access still works
test_obj_trailing_underscore_keyword_escape obj.from_ still maps to the from key (no regression)
test_obj_dunder_attribute_raises obj.__wrapped__ raises and does NOT pollute __dict__
test_obj_sunder_attribute_raises obj._ipython_canary_method_should_not_exist_ raises
test_obj_inspect_unwrap_does_not_recurse End-to-end: inspect.unwrap(o) is o, o.serialize() == {}
$ pytest mautrix/types/util/obj_test.py -v
5 passed

Obj.__getattr__ currently creates an empty child Obj for any
attribute that is missing from the backing dict. This is convenient
for keyword-escape access like obj.from_ but it interacts badly with
Python introspection tooling:

  - inspect.unwrap probes ``.__wrapped__``, gets a fresh Obj back,
    follows it, gets another fresh Obj... and the unwrap loop in
    CPython 3.13 terminates with a wrapper-loop ValueError, but the
    Obj has now been mutated to hold a recursive ``__wrapped``
    entry, and any subsequent serialize()/__repr__() call recurses
    until RecursionError. See mautrix#176 for the original traceback.

  - IPython's display path probes
    ``_ipython_canary_method_should_not_exist_`` (a sunder name) on
    every object it displays, leaving the same kind of stray entry.

Refuse to create attributes whose name starts AND ends with an
underscore so __getattr__ stays useful for the documented
keyword-escape (single trailing underscore, e.g. ``from_``) but
returns AttributeError for dunder / sunder probes. Defined dunder
methods (__init__, __repr__, ...) are unaffected because __getattr__
is only called when normal attribute lookup has already failed.

Adds obj_test.py with five regression cases covering normal access,
the keyword-escape, dunder probe, sunder probe, and the original
inspect.unwrap end-to-end scenario.

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

Labels

None yet

Development

Successfully merging this pull request may close these issues.

using inspect.unwrap on maurix.types.Obj places the object into a state where serializing raises a RecursionError

1 participant