From 8a412af8a0280d45d5940ce824627f46574f9439 Mon Sep 17 00:00:00 2001 From: Sachin Sachdeva <7625278+sachinsachdeva@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:13:18 +1000 Subject: [PATCH 1/2] Add default fallback for missing keys --- README.md | 8 ++++++++ dotmap/__init__.py | 44 +++++++++++++++++++++++++++++++++++++++----- dotmap/test.py | 17 +++++++++++++++++ 3 files changed, 64 insertions(+), 5 deletions(-) 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..6b439bb 100755 --- a/dotmap/__init__.py +++ b/dotmap/__init__.py @@ -15,10 +15,15 @@ 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 self._prevent_method_masking = kwargs.pop('_prevent_method_masking', False) _key_convert_hook = kwargs.pop('_key_convert_hook', None) @@ -46,7 +51,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 +70,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 +110,19 @@ def next(self): def __setitem__(self, k, v): self._map[k] = v def __getitem__(self, k): + if k not in self._map and self._default_provided: + return self._default 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__() 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 +133,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: @@ -280,7 +309,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..d96d3eb 100644 --- a/dotmap/test.py +++ b/dotmap/test.py @@ -192,6 +192,23 @@ 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.assertEqual(m.zipCode, '') + self.assertNotIn('zipCode', m) + + def test_nested_maps_inherit_default(self): + m = DotMap({'address': {'city': 'abc'}}, _default='') + + self.assertEqual(m.address.city, 'abc') + self.assertEqual(m.address.zipCode, '') + + class TestRecursive(unittest.TestCase): def test(self): m = DotMap() From 2c7676759c29467556a94150394cd0a7140b484e Mon Sep 17 00:00:00 2001 From: Sachin Sachdeva <7625278+sachinsachdeva@users.noreply.github.com> Date: Thu, 18 Jun 2026 20:50:35 +1000 Subject: [PATCH 2/2] Address review feedback for _default - Raise ValueError when _default is provided with _dynamic=False instead of silently overriding _dynamic - Nest the _default check inside the existing "k not in self._map" branch in __getitem__ - Preserve _default through copy()/deepcopy() - Add tests for the _dynamic=False error, copy, and deepcopy; reorder the missing-key assertions to check setup before behavior Co-Authored-By: Claude Opus 4.8 --- dotmap/__init__.py | 18 ++++++++++++------ dotmap/test.py | 23 ++++++++++++++++++++++- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/dotmap/__init__.py b/dotmap/__init__.py index 6b439bb..3cd5c40 100755 --- a/dotmap/__init__.py +++ b/dotmap/__init__.py @@ -24,6 +24,8 @@ def __init__(self, *args, **kwargs): 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) @@ -110,11 +112,12 @@ def next(self): def __setitem__(self, k, v): self._map[k] = v def __getitem__(self, k): - if k not in self._map and self._default_provided: - return self._default - 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): @@ -272,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): diff --git a/dotmap/test.py b/dotmap/test.py index d96d3eb..93f482a 100644 --- a/dotmap/test.py +++ b/dotmap/test.py @@ -199,8 +199,8 @@ def test_missing_attribute_returns_default(self): self.assertEqual(m.city, 'abc') self.assertEqual(m.CountryCode, 101) - self.assertEqual(m.zipCode, '') self.assertNotIn('zipCode', m) + self.assertEqual(m.zipCode, '') def test_nested_maps_inherit_default(self): m = DotMap({'address': {'city': 'abc'}}, _default='') @@ -208,6 +208,27 @@ def test_nested_maps_inherit_default(self): 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):