diff --git a/README.md b/README.md index 95340e0..7fbf027 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,14 @@ m = DotMap() m.people.steve.age = 31 ``` +You can provide a default value for missing attributes when you do not want automatic hierarchy creation for absent keys + +```python +m = DotMap({'city': 'abc', 'CountryCode': 101}, _default='') +print(m.zipCode) +# '' +``` + And key initialization ```python diff --git a/dotmap/__init__.py b/dotmap/__init__.py index 25da0b0..3cd5c40 100755 --- a/dotmap/__init__.py +++ b/dotmap/__init__.py @@ -15,10 +15,17 @@ def here(item=None): __all__ = ['DotMap'] +_default_sentinel = object() + class DotMap(MutableMapping, OrderedDict): def __init__(self, *args, **kwargs): self._map = OrderedDict() self._dynamic = kwargs.pop('_dynamic', True) + default = kwargs.pop('_default', _default_sentinel) + self._default = None if default is _default_sentinel else default + self._default_provided = default is not _default_sentinel + if self._default_provided and not self._dynamic: + raise ValueError('cannot provide _default when _dynamic is False') self._prevent_method_masking = kwargs.pop('_prevent_method_masking', False) _key_convert_hook = kwargs.pop('_key_convert_hook', None) @@ -46,7 +53,15 @@ def __init__(self, *args, **kwargs): v = trackedIDs[idv] else: trackedIDs[idv] = v - v = self.__class__(v, _dynamic=self._dynamic, _prevent_method_masking = self._prevent_method_masking, _key_convert_hook =_key_convert_hook, _trackedIDs = trackedIDs) + child_kwargs = { + '_dynamic': self._dynamic, + '_prevent_method_masking': self._prevent_method_masking, + '_key_convert_hook': _key_convert_hook, + '_trackedIDs': trackedIDs + } + if self._default_provided: + child_kwargs['_default'] = self._default + v = self.__class__(v, **child_kwargs) if type(v) is list: l = [] for i in v: @@ -57,7 +72,14 @@ def __init__(self, *args, **kwargs): n = trackedIDs[idi] else: trackedIDs[idi] = i - n = self.__class__(i, _dynamic=self._dynamic, _key_convert_hook =_key_convert_hook, _prevent_method_masking = self._prevent_method_masking) + child_kwargs = { + '_dynamic': self._dynamic, + '_key_convert_hook': _key_convert_hook, + '_prevent_method_masking': self._prevent_method_masking + } + if self._default_provided: + child_kwargs['_default'] = self._default + n = self.__class__(i, **child_kwargs) l.append(n) v = l self._map[k] = v @@ -90,13 +112,20 @@ def next(self): def __setitem__(self, k, v): self._map[k] = v def __getitem__(self, k): - if k not in self._map and self._dynamic and k != '_ipython_canary_method_should_not_exist_': - # automatically extend to new DotMap - self[k] = self.__class__() + if k not in self._map: + if self._default_provided: + return self._default + if self._dynamic and k != '_ipython_canary_method_should_not_exist_': + # automatically extend to new DotMap + self[k] = self.__class__() return self._map[k] def __setattr__(self, k, v): - if k in {'_map','_dynamic', '_ipython_canary_method_should_not_exist_', '_prevent_method_masking'}: + if k in { + '_map', '_dynamic', '_default', '_default_provided', + '_ipython_canary_method_should_not_exist_', + '_prevent_method_masking' + }: super(DotMap, self).__setattr__(k,v) elif self._prevent_method_masking and k in reserved_keys: raise KeyError('"{}" is reserved'.format(k)) @@ -107,7 +136,10 @@ def __getattr__(self, k): if k.startswith('__') and k.endswith('__'): raise AttributeError(k) - if k in {'_map','_dynamic','_ipython_canary_method_should_not_exist_'}: + if k in { + '_map', '_dynamic', '_default', '_default_provided', + '_ipython_canary_method_should_not_exist_' + }: return super(DotMap, self).__getattr__(k) try: @@ -243,7 +275,10 @@ def __len__(self): def clear(self): self._map.clear() def copy(self): - return self.__class__(self) + kwargs = {} + if self._default_provided: + kwargs['_default'] = self._default + return self.__class__(self, **kwargs) def __copy__(self): return self.copy() def __deepcopy__(self, memo=None): @@ -280,7 +315,12 @@ def fromkeys(cls, seq, value=None): d._map = OrderedDict.fromkeys(seq, value) return d def __getstate__(self): return self.__dict__ - def __setstate__(self, d): self.__dict__.update(d) + def __setstate__(self, d): + self.__dict__.update(d) + if '_default' not in self.__dict__: + self._default = None + if '_default_provided' not in self.__dict__: + self._default_provided = False # bannerStr def _getListStr(self,items): out = '[' diff --git a/dotmap/test.py b/dotmap/test.py index 8b10f90..93f482a 100644 --- a/dotmap/test.py +++ b/dotmap/test.py @@ -192,6 +192,44 @@ def assignNonDynamicKeyWithInit(): self.assertRaises(KeyError, assignNonDynamicKeyWithInit) +class TestDefault(unittest.TestCase): + def test_missing_attribute_returns_default(self): + address = {'city': 'abc', 'country': 'XY', 'CountryCode': 101} + m = DotMap(address, _default='') + + self.assertEqual(m.city, 'abc') + self.assertEqual(m.CountryCode, 101) + self.assertNotIn('zipCode', m) + self.assertEqual(m.zipCode, '') + + def test_nested_maps_inherit_default(self): + m = DotMap({'address': {'city': 'abc'}}, _default='') + + self.assertEqual(m.address.city, 'abc') + self.assertEqual(m.address.zipCode, '') + + def test_default_with_dynamic_false_raises(self): + self.assertRaises(ValueError, lambda: DotMap(_default='', _dynamic=False)) + + def test_copy_preserves_default(self): + m = DotMap({'city': 'abc'}, _default='') + c = m.copy() + + self.assertEqual(c.city, 'abc') + self.assertNotIn('zipCode', c) + self.assertEqual(c.zipCode, '') + + def test_deepcopy_preserves_default(self): + import copy + m = DotMap({'address': {'city': 'abc'}}, _default='') + c = copy.deepcopy(m) + + self.assertEqual(c.address.city, 'abc') + self.assertNotIn('zipCode', c) + self.assertEqual(c.zipCode, '') + self.assertEqual(c.address.zipCode, '') + + class TestRecursive(unittest.TestCase): def test(self): m = DotMap()