8

I'm utilising a package which returns a nested dictionary. It feels awkward to access this return object in my class methods with the dictionary syntax, when everything else is in object syntax. Searching has brought me to the bunch / neobunch packages, which seems to achieve what I'm after. I've also seen namedtuples suggested but these do not easily support nested attributes and most solutions rely on using dictionaries within the namedtuple for nesting.

What would be a more natural way of achieving this?

data = {'a': 'aval', 'b': {'b1':{'b2a':{'b3a':'b3aval','b3b':'b3bval'},'b2b':'b2bval'}} }

print(data['b']['b1']['b2a']['b3b'])  # dictionary access
# print(data.b.b1.b2a.b3b)  # desired access

import neobunch
data1 = neobunch.bunchify(data)
print(data1.b.b1.b2a.b3b)
Tim B
  • 123
  • 2
  • 4

8 Answers8

18

The following class would let you do what you want (works in Python 2 & 3):

class AttrDict(dict):
    """ Dictionary subclass whose entries can be accessed by attributes (as well
        as normally).

    >>> obj = AttrDict()
    >>> obj['test'] = 'hi'
    >>> print obj.test
    hi
    >>> del obj.test
    >>> obj.test = 'bye'
    >>> print obj['test']
    bye
    >>> print len(obj)
    1
    >>> obj.clear()
    >>> print len(obj)
    0
    """
    def __init__(self, *args, **kwargs):
        super(AttrDict, self).__init__(*args, **kwargs)
        self.__dict__ = self

    @classmethod
    def from_nested_dicts(cls, data):
        """ Construct nested AttrDicts from nested dictionaries. """
        if not isinstance(data, dict):
            return data
        else:
            return cls({key: cls.from_nested_dicts(data[key]) for key in data})


if __name__ == '__main__':

    data = {
        "a": "aval",
        "b": {
            "b1": {
                "b2b": "b2bval",
                "b2a": {
                    "b3a": "b3aval",
                    "b3b": "b3bval"
                }
            }
        }
    }

    attrdict = AttrDict.from_nested_dicts(data)
    print(attrdict.b.b1.b2a.b3b)  # -> b3bval

martineau
  • 119,623
  • 25
  • 170
  • 301
  • Could you please elaborate on why/how the code works? Thanks! –  Jan 08 '17 at 19:08
  • @BartKleijngeld: What part(s) don't you understand? – martineau Jan 08 '17 at 19:43
  • I don't understand how the dictionary keys become properties of the `data1` object somehow. I feel like I'm missing out on something very simple but would appreciate if you could explain that part to me :). –  Jan 08 '17 at 19:48
  • 2
    @BartKleijngeld: Sure...it is a rather strange beast. An `AttrDict` is a dictionary subclass that uses _itself_ as the class' `__dict__`. The latter is where instance attributes are normally stored, which are normally referenced by using `.` (dot) notation, like `obj.attr`, but since it's a dictionary you can still also access them with regular `[]` notation, like `obj['attr']`. It's an idea borrowed from Javascript. There's a different recipe for it on [ActiveState](https://code.activestate.com/recipes/576972-attrdict), and I've seen in several other places, too. – martineau Jan 08 '17 at 20:17
3

Building on @martineau's excellent answer, you can make the AttrDict class to work on nested dictionaries without explicitly calling the from_nested_dict() function:

class AttrDict(dict):
""" Dictionary subclass whose entries can be accessed by attributes
    (as well as normally).
"""
def __init__(self, *args, **kwargs):
    def from_nested_dict(data):
        """ Construct nested AttrDicts from nested dictionaries. """
        if not isinstance(data, dict):
            return data
        else:
            return AttrDict({key: from_nested_dict(data[key])
                                for key in data})

    super(AttrDict, self).__init__(*args, **kwargs)
    self.__dict__ = self

    for key in self.keys():
        self[key] = from_nested_dict(self[key])
Saravana Kumar
  • 482
  • 3
  • 10
  • 1
    a brilliant addition! – naturesenshi Mar 26 '21 at 13:11
  • @saravana-kumar this is great. what modifications would be necessary to return None if the key does not exist? Overriding the __getitem__ method only applies to d[key] notation and not d.key. Thank you – onlinespending Jan 24 '22 at 23:52
  • @saravana-kumar looks like simply overriding __getattr__ to return None works well, as that method is only called when an attribute isn't found in "the usual places" – onlinespending Jan 25 '22 at 00:32
  • @saravana-kumar unfortunately that alone won't work with nested dicts. ```__getattribute__``` is first called, and if attribute is not found calls ```__getattr__```. But for a nested dict, there's a chain of ```__getattr__``` calls. So returning None from ```__getattr__``` tries to ```__getattr__``` on a NoneType object. Not sure if you know of a good solution. – onlinespending Jan 25 '22 at 01:04
2

json.loads has an interesting parameter called object_hook that can be used if all dictionary values are JSON Serializable i.e.,

import json
from types import SimpleNamespace

data = {'a': 'aval', 'b': {'b1':{'b2a':{'b3a':'b3aval','b3b':'b3bval'},'b2b':'b2bval'}}}
data1= json.loads(
    json.dumps(data), object_hook=lambda d: SimpleNamespace(**d)
)
print(data1.b.b1.b2a.b3b)  # -> b3bval

If Guido is listening, I think SimpleNamespace should take a recursive parameter so you can just do data1 = SimpleNamespace(recursive=True, **data).

pandichef
  • 706
  • 9
  • 11
  • 1
    This is probably a bit inefficient but as of now it is the easiest solution I could find, that does not depend on third-party packages. – luator Jul 13 '21 at 12:16
2

Try Dotsi or EasyDict. They both support dot-notation for nested dicts.

>>> import dotsi
>>> data = dotsi.fy({'a': 'aval', 'b': {'b1':{'b2a':{'b3a':'b3aval','b3b':'b3bval'},'b2b':'b2bval'}} })
>>> print(data.b.b1.b2a.b3b)
b3bval
>>> 

In addition to dicts-within-dicts, Dotsi also supports dicts-within-lists-within-dicts.
Note: I'm Dotsi's author.

Sumukh Barve
  • 1,414
  • 15
  • 12
1

What about using __setattr__ method ?

>>> class AttrDict(dict):
...     def __getattr__(self, name):
...         if name in self:
...             return self[name]
... 
...     def __setattr__(self, name, value):
...         self[name] = self.from_nested_dict(value)
... 
...     def __delattr__(self, name):
...         if name in self:
...             del self[name]
... 
...     @staticmethod
...     def from_nested_dict(data):
...         """ Construct nested AttrDicts from nested dictionaries. """
...         if not isinstance(data, dict):
...             return data
...         else:
...             return AttrDict({key: AttrDict.from_nested_dict(data[key])
...                                 for key in data})
...         

>>> ad = AttrDict()
>>> ad
{}

>>> data = {'a': 'aval', 'b': {'b1':{'b2a':{'b3a':'b3aval','b3b':'b3bval'},'b2b':'b2bval'}} }

>>> ad.data = data
>>> ad.data
{'a': 'aval', 'b': {'b1': {'b2a': {'b3a': 'b3aval', 'b3b': 'b3bval'}, 'b2b': 'b2bval'}}}

>>> print(ad.data.b.b1.b2a.b3b)
    b3bval
LuckyDams
  • 109
  • 5
0

A simple class, built on the basic object can be used:

class afoo1(object):
    def __init__(self, kwargs):
        for name in kwargs:
            val = kwargs[name]
            if isinstance(val, dict):
                val = afoo1(val)
            setattr(self,name,val)

I am borrowing the argparse.Namespace definition, tweaked to allow for nesting.

It would be used as

In [172]: dd={'a':'aval','b':{'b1':'bval'}}

In [173]: f=afoo1(dd)

In [174]: f
Out[174]: <__main__.afoo1 at 0xb3808ccc>

In [175]: f.a
Out[175]: 'aval'

In [176]: f.b
Out[176]: <__main__.afoo1 at 0xb380802c>

In [177]: f.b.b1
Out[177]: 'bval'

It could also have been defined with **kwargs (along with *args). A __repr__ definition might be nice as well.

As with other simple objects, attributes can be added, e.g. f.c = f (a recursive definition). vars(f) returns a dictionary, though it does not do any recursive conversion).

hpaulj
  • 221,503
  • 14
  • 230
  • 353
  • 1
    While there's nothing wrong with your class from a functionality standpoint, it could have better style. I'd not use `kwargs` for a regular dictionary argument, since that parameter name is usually for keyword arguments, which doesn't apply at all in this context. I'd also use `for name, value in kwargs.items()` (or `.iteritems()` in Python 2), rather than iterating over the keys and looking up the value on the next line. – Blckknght Jun 26 '16 at 02:56
  • Yes, though faults are the result of incomplete editing from the `Namespace` original. – hpaulj Jun 26 '16 at 03:44
0

Acknowledgement: Inspired by top answer by @martineau

Also adding support for list/tuple

class AttrDict(dict):
    """ support any nested structure """
    def __init__(self, *args, **kwargs):
        super(AttrDict, self).__init__(*args, **kwargs)

        for k, v in self.items():
            if isinstance(v, dict):
                self[k] = AttrDict(v)
            elif isinstance(v, (list, tuple)):
                self[k] = [AttrDict(_v) for _v in  v]
            elif isinstance(v, (int, float, bytes, bytearray, str)):
                self[k] = v
            else:
                raise NotImplementedError()

        self.__dict__ = self
0

I happen to have the same problem last year. In the end, I wrote CHANfiG.

Here is the core functions (note that DefaultDict works similar to collections.defaultdict), some implementation details of attribute-style access may be in FlatDict which is a subclass of dict. __getattr__ and __getitem__ both calls get internally but raises different Exception, so does set and delete.

class NestedDict(DefaultDict):
    r"""
    `NestedDict` further extends `DefaultDict` object by introducing a nested structure with `delimiter`.
    By default, `delimiter` is `.`, but it could be modified in subclass or by calling `dict.setattr('delimiter', D)`.

    `d = NestedDict({"a.b.c": 1})` is equivalent to `d = NestedDict({"a": {"b": {"c": 1}}})`,
    and you can access members either by `d["a.b.c"]` or more simply by `d.a.b.c`.

    This behavior allows you to pass keyword arguments to other function as easy as `func1(**d.func1)`.

    Since `NestedDict` inherits from `DefaultDict`, it also supports `default_factory`.
    With `default_factory`, you can assign `d.a.b.c = 1` without assign `d.a = NestedDict()` in the first place.
    Note that the constructor of `NestedDict` is different from `DefaultDict`, `default_factory` is not a positional
    argument, and must be set in a keyword argument.

    `NestedDict` also introduce `all_keys`, `all_values`, `all_items` methods to get all keys, values, items
    respectively in the nested structure.

    Attributes:
        convert_mapping: bool = False
            If `True`, all new values with a type of `Mapping` will be converted to `default_factory`.
                If `default_factory` is `Null`, will create an empty instance via `self.empty` as `default_factory`.
        delimiter: str = "."
            Delimiter for nested structure.

    Notes:
        When `convert_mapping` specified, all new values with type of `Mapping` will be converted to `default_factory`.
            If `default_factory` is `Null`, will create an empty instance via `self.empty` as `default_factory`.

        `convert_mapping` is automatically applied to arguments during initialisation.

    Examples:
        >>> NestedDict({"f.n": "chang"})
        NestedDict(
          ('f'): NestedDict(
            ('n'): 'chang'
          )
        )
        >>> d = NestedDict({"f.n": "chang"}, default_factory=NestedDict)
        >>> d.i.d = 1013
        >>> d['i.d']
        1013
        >>> d.i.d
        1013
        >>> d.dict()
        {'f': {'n': 'chang'}, 'i': {'d': 1013}}
    """

    convert_mapping: bool = False
    delimiter: str = "."

    def __init__(self, *args, default_factory: Optional[Callable] = None, **kwargs) -> None:
        super().__init__(default_factory, *args, **kwargs)

    def _init(self, *args, **kwargs) -> None:
        if len(args) == 1:
            args = args[0]
            if isinstance(args, Mapping):
                for key, value in args.items():
                    self.set(key, value, convert_mapping=True)
            elif isinstance(args, Iterable):
                for key, value in args:
                    self.set(key, value, convert_mapping=True)
        else:
            for key, value in args:
                self.set(key, value, convert_mapping=True)
        for key, value in kwargs.items():
            self.set(key, value, convert_mapping=True)

    def all_keys(self) -> Iterator:
        r"""
        Get all keys of `NestedDict`.

        Returns:
            (Iterator):

        Examples:
            >>> d = NestedDict({'a': 1, 'b': {'c': 2, 'd': 3}})
            >>> list(d.all_keys())
            ['a', 'b.c', 'b.d']
        """

        delimiter = self.getattr("delimiter", ".")

        @wraps(self.all_keys)
        def all_keys(self, prefix=""):
            for key, value in self.items():
                if prefix:
                    key = str(prefix) + str(delimiter) + str(key)
                if isinstance(value, NestedDict):
                    yield from all_keys(value, key)
                else:
                    yield key

        return all_keys(self)

    def all_values(self) -> Iterator:
        r"""
        Get all values of `NestedDict`.

        Returns:
            (Iterator):

        Examples:
            >>> d = NestedDict({'a': 1, 'b': {'c': 2, 'd': 3}})
            >>> list(d.all_values())
            [1, 2, 3]
        """

        for value in self.values():
            if isinstance(value, NestedDict):
                yield from value.all_values()
            else:
                yield value

    def all_items(self) -> Iterator[Tuple]:
        r"""
        Get all items of `NestedDict`.

        Returns:
            (Iterator):

        Examples:
            >>> d = NestedDict({'a': 1, 'b': {'c': 2, 'd': 3}})
            >>> list(d.all_items())
            [('a', 1), ('b.c', 2), ('b.d', 3)]
        """

        delimiter = self.getattr("delimiter", ".")

        @wraps(self.all_items)
        def all_items(self, prefix=""):
            for key, value in self.items():
                if prefix:
                    key = str(prefix) + str(delimiter) + str(key)
                if isinstance(value, NestedDict):
                    yield from all_items(value, key)
                else:
                    yield key, value

        return all_items(self)

    def get(self, name: Any, default: Any = Null) -> Any:
        r"""
        Get value from `NestedDict`.

        Note that `default` has higher priority than `default_factory`.

        Args:
            name:
            default:

        Returns:
            value:
                If `NestedDict` does not contain `name`, return `default`.
                If `default` is not specified, return `default_factory()`.

        Raises:
            KeyError: If `NestedDict` does not contain `name` and `default`/`default_factory` is not specified.

        Examples:
            >>> d = NestedDict({"i.d": 1013}, default_factory=NestedDict)
            >>> d.get('i.d')
            1013
            >>> d['i.d']
            1013
            >>> d.i.d
            1013
            >>> d.get('i.d', None)
            1013
            >>> d.get('f', 2)
            2
            >>> d.f
            NestedDict(<class 'chanfig.nested_dict.NestedDict'>, )
            >>> del d.f
            >>> d = NestedDict()
            >>> d.e
            Traceback (most recent call last):
            AttributeError: 'NestedDict' object has no attribute 'e'
            >>> d.e.f
            Traceback (most recent call last):
            AttributeError: 'NestedDict' object has no attribute 'e'
        """

        delimiter = self.getattr("delimiter", ".")
        try:
            while isinstance(name, str) and delimiter in name:
                name, rest = name.split(delimiter, 1)
                self, name = self[name], rest  # pylint: disable=W0642
        except (AttributeError, TypeError):
            raise KeyError(name) from None
        # if value is a python dict
        if not isinstance(self, NestedDict):
            if name not in self and default is not Null:
                return default
            return dict.get(self, name)
        return super().get(name, default)

    def set(  # pylint: disable=W0221
        self,
        name: Any,
        value: Any,
        convert_mapping: Optional[bool] = None,
    ) -> None:
        r"""
        Set value of `NestedDict`.

        Args:
            name:
            value:
            convert_mapping: Whether convert mapping to NestedDict.
                Defaults to self.convert_mapping.

        Examples:
            >>> d = NestedDict(default_factory=NestedDict)
            >>> d.set('i.d', 1013)
            >>> d.get('i.d')
            1013
            >>> d.dict()
            {'i': {'d': 1013}}
            >>> d['f.n'] = 'chang'
            >>> d.f.n
            'chang'
            >>> d.n.l = 'liu'
            >>> d['n.l']
            'liu'
            >>> d['f.n.e'] = "error"
            Traceback (most recent call last):
            ValueError: Cannot set `f.n.e` to `error`, as `f.n=chang`.
            >>> d['f.n.e.a'] = "error"
            Traceback (most recent call last):
            KeyError: 'e'
            >>> d.f.n.e.a = "error"
            Traceback (most recent call last):
            AttributeError: 'str' object has no attribute 'e'
            >>> d.setattr('convert_mapping', True)
            >>> d.a.b = {'c': {'d': 1}, 'e.f' : 2}
            >>> d.a.b.c.d
            1
            >>> d['c.d'] = {'c': {'d': 1}, 'e.f' : 2}
            >>> d.c.d['e.f']
            2
            >>> d.setattr('convert_mapping', False)
            >>> d.set('e.f', {'c': {'d': 1}, 'e.f' : 2}, convert_mapping=True)
            >>> d['e.f']['c.d']
            1
        """
        # pylint: disable=W0642

        full_name = name
        if convert_mapping is None:
            convert_mapping = self.convert_mapping
        delimiter = self.getattr("delimiter", ".")
        default_factory = self.getattr("default_factory", self.empty)
        try:
            while isinstance(name, str) and delimiter in name:
                name, rest = name.split(delimiter, 1)
                default_factory = self.getattr("default_factory", self.empty)
                if name in dir(self) and isinstance(getattr(self.__class__, name), property):
                    self, name = getattr(self, name), rest
                elif name not in self:
                    self, name = self.__missing__(name, default_factory()), rest
                else:
                    self, name = self[name], rest
        except (AttributeError, TypeError):
            raise KeyError(name) from None
        if convert_mapping and isinstance(value, Mapping):
            value = default_factory(value)
        if isinstance(self, Mapping):
            if not isinstance(self, NestedDict):
                dict.__setitem__(self, name, value)
            else:
                super().set(name, value)
        else:
            raise ValueError(
                f"Cannot set `{full_name}` to `{value}`, as `{delimiter.join(full_name.split(delimiter)[:-1])}={self}`."
            )

    def delete(self, name: Any) -> None:
        r"""
        Delete value from `NestedDict`.

        Args:
            name:

        Examples:
            >>> d = NestedDict({"i.d": 1013, "f.n": "chang"}, default_factory=NestedDict)
            >>> d.i.d
            1013
            >>> d.f.n
            'chang'
            >>> d.delete('i.d')
            >>> "i.d" in d
            False
            >>> d.i.d
            Traceback (most recent call last):
            AttributeError: 'NestedDict' object has no attribute 'd'
            >>> del d.f.n
            >>> d.f.n
            Traceback (most recent call last):
            AttributeError: 'NestedDict' object has no attribute 'n'
            >>> del d.e
            Traceback (most recent call last):
            AttributeError: 'NestedDict' object has no attribute 'e'
            >>> del d['e.f']
            Traceback (most recent call last):
            KeyError: 'f'
        """

        delimiter = self.getattr("delimiter", ".")
        try:
            while isinstance(name, str) and delimiter in name:
                name, rest = name.split(delimiter, 1)
                self, name = self[name], rest  # pylint: disable=W0642
        except (AttributeError, TypeError):
            raise KeyError(name) from None
        super().delete(name)

    def pop(self, name: Any, default: Any = Null) -> Any:
        r"""
        Pop value from `NestedDict`.

        Args:
            name:
            default:

        Returns:
            value: If `NestedDict` does not contain `name`, return `default`.

        Examples:
            >>> d = NestedDict({"i.d": 1013, "f.n": "chang", "n.a.b.c": 1}, default_factory=NestedDict)
            >>> d.pop('i.d')
            1013
            >>> d.pop('i.d', True)
            True
            >>> d.pop('i.d')
            Traceback (most recent call last):
            KeyError: 'd'
            >>> d.pop('e')
            Traceback (most recent call last):
            KeyError: 'e'
            >>> d.pop('e.f')
            Traceback (most recent call last):
            KeyError: 'f'
        """

        delimiter = self.getattr("delimiter", ".")
        try:
            while isinstance(name, str) and delimiter in name:
                name, rest = name.split(delimiter, 1)
                self, name = self[name], rest  # pylint: disable=W0642
        except (AttributeError, TypeError):
            raise KeyError(name) from None
        if not isinstance(self, dict) or name not in self:
            if default is not Null:
                return default
            raise KeyError(name)
        return super().pop(name)

    def __contains__(self, name: Any) -> bool:  # type: ignore
        delimiter = self.getattr("delimiter", ".")
        try:
            while isinstance(name, str) and delimiter in name:
                name, rest = name.split(delimiter, 1)
                self, name = self[name], rest  # pylint: disable=W0642
            return super().__contains__(name)
        except (TypeError, KeyError):  # TypeError when name is not in self
            return False