From 5af715c99dc1bc106076d91db328867954121364 Mon Sep 17 00:00:00 2001 From: Ian Chechin Date: Wed, 13 May 2026 15:36:15 +0800 Subject: [PATCH] Obj: refuse to auto-vivify dunder and sunder attributes 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 #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 #176. --- mautrix/types/util/obj.py | 8 +++++ mautrix/types/util/obj_test.py | 60 ++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 mautrix/types/util/obj_test.py diff --git a/mautrix/types/util/obj.py b/mautrix/types/util/obj.py index b1359ebe..dcdbc667 100644 --- a/mautrix/types/util/obj.py +++ b/mautrix/types/util/obj.py @@ -16,6 +16,14 @@ def __init__(self, **kwargs): } def __getattr__(self, name): + # Refuse to auto-vivify dunder (__name__) and sunder (_name_) + # attributes. Python tooling that probes for them (e.g. + # ``inspect.unwrap`` looking for ``__wrapped__``, or IPython's + # ``_ipython_canary_method_should_not_exist``) would otherwise + # cause ``Obj`` to grow recursive children and serialize() to + # raise RecursionError. See issue #176. + if name.startswith("_") and name.endswith("_"): + raise AttributeError(name) name = name.rstrip("_") obj = self.__dict__.get(name) if obj is None: diff --git a/mautrix/types/util/obj_test.py b/mautrix/types/util/obj_test.py new file mode 100644 index 00000000..fc264b9a --- /dev/null +++ b/mautrix/types/util/obj_test.py @@ -0,0 +1,60 @@ +# Copyright (c) 2026 Tulir Asokan +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +import inspect + +import pytest + +from .obj import Obj + + +def test_obj_basic_attribute_access(): + o = Obj(name="hello", count=3) + assert o.name == "hello" + assert o.count == 3 + + +def test_obj_trailing_underscore_keyword_escape(): + # The single trailing underscore is the documented escape hatch for + # Python keywords (e.g. ``obj.from_``); rstrip("_") still maps it to + # the same backing key. + o = Obj(**{"from": "@user"}) + assert o.from_ == "@user" + + +def test_obj_dunder_attribute_raises(): + # Regression for issue #176: probing tools like ``inspect.unwrap`` + # would auto-vivify ``__wrapped__`` and put the object into a state + # where repr/serialize recurses until RecursionError. + o = Obj() + with pytest.raises(AttributeError): + o.__wrapped__ + with pytest.raises(AttributeError): + o.__custom_thing__ + # The attribute must NOT have been stored. + assert "__wrapped__" not in o.__dict__ + assert "__wrapped" not in o.__dict__ + + +def test_obj_sunder_attribute_raises(): + # IPython's ``_ipython_canary_method_should_not_exist_`` (a sunder + # name) used to leak into the object's dict via __getattr__. + o = Obj() + with pytest.raises(AttributeError): + o._ipython_canary_method_should_not_exist_ + assert "_ipython_canary_method_should_not_exist" not in o.__dict__ + + +def test_obj_inspect_unwrap_does_not_recurse(): + # End-to-end version of the original report. + o = Obj() + # inspect.unwrap probes ``__wrapped__``; with the dunder guard in + # place it gets an AttributeError on the first try and returns the + # original object instead of looping until RecursionError. + assert inspect.unwrap(o) is o + # And the object must still be safely serializable / printable + # afterwards. + assert o.serialize() == {} + assert repr(o) == "{}"