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