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/3] 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/3] 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): From d6e6fca5c9fc145c07882c9d20b82d49cf2150de Mon Sep 17 00:00:00 2001 From: Sachin Sachdeva <7625278+sachinsachdeva@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:48:40 +1000 Subject: [PATCH 3/3] Replace _default with _default_factory Per review feedback: a mutable _default value was shared across all missing keys, so mutating one leaked into every other miss. Following the collections.defaultdict pattern, _default_factory takes a callable invoked on each miss; the result is stored under the key and returned, giving every key its own independent value. Non-callables raise TypeError, and _default_provided is gone since None now simply means "not set". Co-Authored-By: Claude Opus 4.8 --- README.md | 4 ++-- dotmap/__init__.py | 42 +++++++++++++++++------------------------- dotmap/test.py | 39 +++++++++++++++++++++++++++++++++------ 3 files changed, 52 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 7fbf027..90249d4 100644 --- a/README.md +++ b/README.md @@ -54,10 +54,10 @@ 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 +You can provide a factory for default values of missing keys when you do not want automatic hierarchy creation for absent keys. Like `collections.defaultdict`, the factory is called on each miss and the result is stored under the key ```python -m = DotMap({'city': 'abc', 'CountryCode': 101}, _default='') +m = DotMap({'city': 'abc', 'CountryCode': 101}, _default_factory=str) print(m.zipCode) # '' ``` diff --git a/dotmap/__init__.py b/dotmap/__init__.py index 3cd5c40..ddcb84d 100755 --- a/dotmap/__init__.py +++ b/dotmap/__init__.py @@ -15,17 +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 - if self._default_provided and not self._dynamic: - raise ValueError('cannot provide _default when _dynamic is False') + self._default_factory = kwargs.pop('_default_factory', None) + if self._default_factory is not None and not callable(self._default_factory): + raise TypeError('_default_factory must be callable') + if self._default_factory is not None and not self._dynamic: + raise ValueError('cannot provide _default_factory when _dynamic is False') self._prevent_method_masking = kwargs.pop('_prevent_method_masking', False) _key_convert_hook = kwargs.pop('_key_convert_hook', None) @@ -55,12 +53,11 @@ def __init__(self, *args, **kwargs): trackedIDs[idv] = v child_kwargs = { '_dynamic': self._dynamic, + '_default_factory': self._default_factory, '_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 = [] @@ -74,11 +71,10 @@ def __init__(self, *args, **kwargs): trackedIDs[idi] = i child_kwargs = { '_dynamic': self._dynamic, + '_default_factory': self._default_factory, '_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 @@ -113,16 +109,17 @@ def __setitem__(self, k, v): self._map[k] = v def __getitem__(self, k): 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__() + if self._default_factory is not None: + self[k] = self._default_factory() + else: + # automatically extend to new DotMap + self[k] = self.__class__() return self._map[k] def __setattr__(self, k, v): if k in { - '_map', '_dynamic', '_default', '_default_provided', + '_map', '_dynamic', '_default_factory', '_ipython_canary_method_should_not_exist_', '_prevent_method_masking' }: @@ -137,7 +134,7 @@ def __getattr__(self, k): raise AttributeError(k) if k in { - '_map', '_dynamic', '_default', '_default_provided', + '_map', '_dynamic', '_default_factory', '_ipython_canary_method_should_not_exist_' }: return super(DotMap, self).__getattr__(k) @@ -275,10 +272,7 @@ def __len__(self): def clear(self): self._map.clear() def copy(self): - kwargs = {} - if self._default_provided: - kwargs['_default'] = self._default - return self.__class__(self, **kwargs) + return self.__class__(self, _default_factory=self._default_factory) def __copy__(self): return self.copy() def __deepcopy__(self, memo=None): @@ -317,10 +311,8 @@ def fromkeys(cls, seq, value=None): def __getstate__(self): return self.__dict__ 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 + if '_default_factory' not in self.__dict__: + self._default_factory = None # bannerStr def _getListStr(self,items): out = '[' diff --git a/dotmap/test.py b/dotmap/test.py index 93f482a..b1bf8d8 100644 --- a/dotmap/test.py +++ b/dotmap/test.py @@ -1,4 +1,5 @@ import unittest +import copy from dotmap import DotMap @@ -195,7 +196,7 @@ def assignNonDynamicKeyWithInit(): class TestDefault(unittest.TestCase): def test_missing_attribute_returns_default(self): address = {'city': 'abc', 'country': 'XY', 'CountryCode': 101} - m = DotMap(address, _default='') + m = DotMap(address, _default_factory=str) self.assertEqual(m.city, 'abc') self.assertEqual(m.CountryCode, 101) @@ -203,16 +204,21 @@ def test_missing_attribute_returns_default(self): self.assertEqual(m.zipCode, '') def test_nested_maps_inherit_default(self): - m = DotMap({'address': {'city': 'abc'}}, _default='') + m = DotMap({'address': {'city': 'abc'}}, _default_factory=str) 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)) + with self.assertRaises(ValueError): + DotMap(_default_factory=str, _dynamic=False) + + def test_default_factory_must_be_callable(self): + with self.assertRaises(TypeError): + DotMap(_default_factory='') def test_copy_preserves_default(self): - m = DotMap({'city': 'abc'}, _default='') + m = DotMap({'city': 'abc'}, _default_factory=str) c = m.copy() self.assertEqual(c.city, 'abc') @@ -220,8 +226,7 @@ def test_copy_preserves_default(self): self.assertEqual(c.zipCode, '') def test_deepcopy_preserves_default(self): - import copy - m = DotMap({'address': {'city': 'abc'}}, _default='') + m = DotMap({'address': {'city': 'abc'}}, _default_factory=str) c = copy.deepcopy(m) self.assertEqual(c.address.city, 'abc') @@ -229,6 +234,28 @@ def test_deepcopy_preserves_default(self): self.assertEqual(c.zipCode, '') self.assertEqual(c.address.zipCode, '') + def test_default_list(self): + m = DotMap({}, _default_factory=list) + self.assertEqual(m.x, []) + self.assertEqual(m.y, []) + + m.x.append(42) + self.assertEqual(m.x, [42]) + + self.assertEqual(m.y, []) + + def test_default_factory_returning_cached_list_shares_it(self): + # if the factory itself returns the same cached object every call, + # keys share it -- freshness is the factory's responsibility + cached = [] + m = DotMap({}, _default_factory=lambda: cached) + + self.assertIs(m.x, cached) + m.x.append(42) + + self.assertIs(m.y, cached) + self.assertEqual(m.y, [42]) + class TestRecursive(unittest.TestCase): def test(self):