Search code examples
pythondictionarydefaultdict

Nested dictionary that acts as defaultdict when setting items but not when getting items


I want to implement a dict-like data structure that has the following properties:

from collections import UserDict

class TestDict(UserDict):
    pass

test_dict = TestDict()

# Create empty dictionaries at 'level_1' and 'level_2' and insert 'Hello' at the 'level_3' key.
test_dict['level_1']['level_2']['level_3'] = 'Hello'

>>> test_dict
{
    'level_1': {
        'level_2': {
            'level_3': 'Hello'
        }
    }
}

# However, this should not return an empty dictionary but raise a KeyError.
>>> test_dict['unknown_key']
KeyError: 'unknown_key'

The problem, to my knowledge, is that python does not know whether __getitem__ is being called in the context of setting an item, i.e. the first example, or in the context of getting and item, the second example.

I have already seen Python `defaultdict`: Use default when setting, but not when getting, but I do not think that this question is a duplicate, or that it answers my question.

Please let me know if you have any ideas.

Thanks in advance.

EDIT:

It is possible to achieve something similar using:

def set_nested_item(dict_in: Union[dict, TestDict], value, keys):
    for i, key in enumerate(keys):
        is_last = i == (len(keys) - 1)
        if is_last:
            dict_in[key] = value
        else:
            if key not in dict_in:
                dict_in[key] = {}
            else:
                if not isinstance(dict_in[key], (dict, TestDict)):
                    dict_in[key] = {}

            dict_in[key] = set_nested_item(dict_in[key], value, keys[(i + 1):])
        return dict_in


class TestDict(UserDict):
    def __init__(self):
        super().__init__()

    def __setitem__(self, key, value):
        if isinstance(key, list):
            self.update(set_nested_item(self, value, key))
        else:
            super().__setitem__(key, value)

test_dict[['level_1', 'level_2', 'level_3']] = 'Hello'
>>> test_dict
{
    'level_1': {
        'level_2': {
            'level_3': 'Hello'
        }
    }
}




Solution

  • It's impossible.

    test_dict['level_1']['level_2']['level_3'] = 'Hello'
    

    is semantically equivalent to:

    temp1 = test_dict['level_1'] # Should this line fail?
    temp1['level_2']['level_3'] = 'Hello'
    

    But... if determined to implement it anyway, you could inspect the Python stack to grab/parse the calling line of code, and then vary the behaviour depending on whether the calling line of code contains an assignment! Unfortunately, sometimes the calling code isn't available in the stack trace (e.g. when called interactively), in which case you need to work with Python bytecode.

    import dis
    import inspect
    from collections import UserDict
    
    def get_opcodes(code_object, lineno):
        """Utility function to extract Python VM opcodes for line of code"""
        line_ops = []
        instructions = dis.get_instructions(code_object).__iter__()
        for instruction in instructions:
            if instruction.starts_line == lineno:
                # found start of our line
                line_ops.append(instruction.opcode)
                break
        for instruction in instructions:
            if not instruction.starts_line:
                line_ops.append(instruction.opcode)
            else:
                # start of next line
                break
        return line_ops
    
    class TestDict(UserDict):
        def __getitem__(self, key):
            try:
                return super().__getitem__(key)
            except KeyError:
                # inspect the stack to get calling line of code
                frame = inspect.stack()[1].frame
                opcodes = get_opcodes(frame.f_code, frame.f_lineno)
                # STORE_SUBSCR is Python opcode for TOS1[TOS] = TOS2
                if dis.opmap['STORE_SUBSCR'] in opcodes:
                    # calling line of code contains a dict/array assignment
                    default = TestDict()
                    super().__setitem__(key, default)
                    return default
                else:
                    raise
    
    test_dict = TestDict()
    test_dict['level_1']['level_2']['level_3'] = 'Hello'
    print(test_dict)
    # {'level_1': {'level_2': {'level_3': 'Hello'}}}
    
    test_dict['unknown_key']
    # KeyError: 'unknown_key'
    

    The above is just a partial solution. It can still be fooled if there are other dictionary/array assignments on the same line, e.g. other['key'] = test_dict['unknown_key']. A more complete solution would need to actually parse the line of code to figure out where the variable occurs in the assignment.