From 60fbb3874c32520020318861848cf535e26b8a50 Mon Sep 17 00:00:00 2001 From: Mahmoud Hashemi Date: Sat, 8 Aug 2026 17:37:26 -0700 Subject: [PATCH] exporter: scrape dicts via items(), not keys() + __getitem__ A dict key mutated after insertion (hash no longer matches its bucket) iterates fine but raises KeyError on lookup by key, killing the whole dump. Seen in the wild with openpyxl's StyleArray (an array subclass with a content-based hash that openpyxl mutates in place after using it as a dict key): dump_graph on any heap containing a loaded-and- modified workbook died with KeyError(StyleArray(...)). dict.items() reads key and value straight off the bucket, so no re-lookup happens; it's also one C call instead of one per key. --- objex/exporter.py | 8 +++++--- tests/clean_dump_fixture.py | 19 +++++++++++++++++++ tests/test_objex.py | 26 ++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/objex/exporter.py b/objex/exporter.py index 69e8d30..79543ff 100644 --- a/objex/exporter.py +++ b/objex/exporter.py @@ -324,9 +324,11 @@ def add_obj(self, obj, refs=0): extra_relationship = frozenset # STEP 2 - GET KEYS if extra_relationship is dict: - keys = obj.keys() - key_dst += [('@{}'.format(self._ensure_db_id(key, refs=2)), dict.__getitem__(obj, key)) - for key in keys] + # dict.items, NOT keys() + __getitem__: a key mutated after insertion + # (e.g. openpyxl StyleArray, an array subclass with a content-based + # hash) iterates fine but raises KeyError on lookup by key + key_dst += [('@{}'.format(self._ensure_db_id(key, refs=2)), value) + for key, value in dict.items(obj)] elif extra_relationship in (list, tuple, collections.deque): key_dst += enumerate(obj) elif extra_relationship in (set, frozenset): diff --git a/tests/clean_dump_fixture.py b/tests/clean_dump_fixture.py index fa997ca..dba7e3d 100644 --- a/tests/clean_dump_fixture.py +++ b/tests/clean_dump_fixture.py @@ -63,6 +63,19 @@ def __getattribute__(self, name): return object.__getattribute__(self, name) +class MutableHashKey: + """Key whose hash changes when mutated (like openpyxl's StyleArray).""" + + def __init__(self, value=0): + self.value = value + + def __hash__(self): + return hash(self.value) + + def __eq__(self, other): + return isinstance(other, MutableHashKey) and self.value == other.value + + def closing(a, b=1): c = 2 @@ -104,6 +117,11 @@ def make_sample_objects(): weak_set = weakref.WeakSet() weak_set.add(slots_c) + mutated_key = MutableHashKey() + mutated_key_dict = {mutated_key: 'stale-bucket'} + # hash changed after insertion: iteration works, lookup by key raises KeyError + mutated_key.value = 99 + global GO_NESTED GO_NESTED = types.SimpleNamespace( level1=types.SimpleNamespace( @@ -125,6 +143,7 @@ def make_sample_objects(): 'bound_method': bound_method, 'generator': generator, 'weak_set': weak_set, + 'mutated_key_dict': mutated_key_dict, 'go_nested': GO_NESTED, } diff --git a/tests/test_objex.py b/tests/test_objex.py index 397365c..95fe211 100644 --- a/tests/test_objex.py +++ b/tests/test_objex.py @@ -77,6 +77,19 @@ def __getattribute__(self, name): return object.__getattribute__(self, name) +class MutableHashKey: + """Key whose hash changes when mutated (like openpyxl's StyleArray).""" + + def __init__(self, value=0): + self.value = value + + def __hash__(self): + return hash(self.value) + + def __eq__(self, other): + return isinstance(other, MutableHashKey) and self.value == other.value + + def closing(a, b=1): c = 2 @@ -157,6 +170,11 @@ def _make_sample_objects(): weak_set = weakref.WeakSet() weak_set.add(slots_c) + mutated_key = MutableHashKey() + mutated_key_dict = {mutated_key: 'stale-bucket'} + # hash changed after insertion: iteration works, lookup by key raises KeyError + mutated_key.value = 99 + global GO_NESTED GO_NESTED = types.SimpleNamespace( level1=types.SimpleNamespace( @@ -178,6 +196,7 @@ def _make_sample_objects(): 'bound_method': bound_method, 'generator': generator, 'weak_set': weak_set, + 'mutated_key_dict': mutated_key_dict, 'go_nested': GO_NESTED, } @@ -320,6 +339,13 @@ def test_dump_graph_uses_resolved_dunder_dict_without_reaccess(self): with Reader(str(self.shared_analysis_path)) as reader: assert reader.find_type_by_name('MisdirectingGetattribute') + def test_dump_graph_survives_mutated_dict_keys(self): + # dict scrape must use items(), not keys() + __getitem__: a key whose + # hash changed after insertion (openpyxl StyleArray in the wild) + # iterates fine but raises KeyError on lookup + with Reader(str(self.shared_analysis_path)) as reader: + assert reader.find_type_by_name('MutableHashKey') + @pytest.mark.slow @unittest.skipUnless(hasattr(os, 'fork'), 'requires os.fork') def test_spawn_dump_and_wait_dump(self):