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):