diff --git a/README.md b/README.md index 95340e0..d9a810a 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ print(m.name) # Joe Smith Jr ``` -It also has fast, automatic hierarchy (which can be deactivated by initializing with `DotMap(_dynamic=False)`) +It also has fast, automatic hierarchy (which can be deactivated by initializing with `DotMap(_dynamic=False)` or by using `StaticDotMap`) ```python m = DotMap() diff --git a/dotmap/__init__.py b/dotmap/__init__.py index 25da0b0..efdaafe 100755 --- a/dotmap/__init__.py +++ b/dotmap/__init__.py @@ -355,6 +355,18 @@ def bannerStr(self): s = '\n'.join(lines) return s + + +class StaticDotMap(DotMap): + def __init__(self, *args, **kwargs): + # DotMap uses multiple inheritance so we can not just pass the parameter + # to `super(...).__init__()` + if kwargs.get('_dynamic', False): + raise ValueError('StaticDotMap does not support _dynamic=True') + kwargs['_dynamic'] = False + super().__init__(*args, **kwargs) + + reserved_keys = {i for i in dir(DotMap) if not i.startswith('__') and not i.endswith('__')} if __name__ == '__main__': diff --git a/dotmap/test.py b/dotmap/test.py index 8b10f90..6b71449 100644 --- a/dotmap/test.py +++ b/dotmap/test.py @@ -1,5 +1,9 @@ +import copy +import pickle import unittest -from dotmap import DotMap +from collections import OrderedDict + +from dotmap import DotMap, StaticDotMap class TestReadme(unittest.TestCase): @@ -142,7 +146,6 @@ def setUp(self): } def test(self): - import pickle pm = DotMap(self.d) s = pickle.dumps(pm) m = pickle.loads(s) @@ -231,7 +234,6 @@ def capture(**kwargs): class TestDeepCopy(unittest.TestCase): def test(self): - import copy original = DotMap() original.a = 1 original.b = 3 @@ -246,7 +248,6 @@ def test(self): self.assertNotEqual(original, deepCopy) def test_order_preserved(self): - import copy original = DotMap() original.a = 1 original.b = 2 @@ -269,7 +270,6 @@ def test(self): class TestOrderedDictInit(unittest.TestCase): def test(self): - from collections import OrderedDict o = OrderedDict([('a', 1), ('b', 2), ('c', [OrderedDict([('d', 3)])])]) m = DotMap(o) self.assertIsInstance(m, DotMap) @@ -384,6 +384,160 @@ def test_subclass_with_property(self): self.assertIsInstance(p.my_prop.second, PropertyDotMap) self.assertEqual(p.my_prop.second.third, 456) + +class TestStaticDotMap(unittest.TestCase): + def test_is_dotmap_subclass(self): + m = StaticDotMap() + self.assertIsInstance(m, StaticDotMap) + self.assertIsInstance(m, DotMap) + + def test_empty_init(self): + m = StaticDotMap() + self.assertEqual(len(m), 0) + self.assertTrue(m.empty()) + with self.assertRaises(AttributeError): + m.missing + + def test_kwargs_init(self): + m = StaticDotMap(a=1, b=2) + self.assertEqual(m.a, 1) + self.assertEqual(m.b, 2) + with self.assertRaises(AttributeError): + m.missing + + def test_dict_init(self): + d = {'a': 1, 'b': 2, 'sub': {'c': 3, 'd': 4}} + m = StaticDotMap(d) + self.assertEqual(m.a, 1) + self.assertEqual(m.b, 2) + self.assertEqual(m.sub.c, 3) + self.assertEqual(m.sub.d, 4) + + def test_missing_attribute_raises(self): + m = StaticDotMap(a=1) + with self.assertRaises(AttributeError): + m.missing + + def test_missing_item_raises(self): + m = StaticDotMap(a=1) + with self.assertRaises(KeyError): + m['missing'] + + def test_nested_missing_attribute_raises(self): + m = StaticDotMap({'sub': {'a': 1}}) + with self.assertRaises(AttributeError): + m.sub.missing + + def test_dynamic_true_kwarg_raises(self): + # A caller cannot opt back into dynamic behaviour through the kwarg. + with self.assertRaises(ValueError): + StaticDotMap({'a': 1}, _dynamic=True) + + def test_nested_dicts_become_static(self): + m = StaticDotMap({'a': 1, 'sub': {'b': 2, 'deep': {'c': 3}}}) + self.assertIsInstance(m.sub, StaticDotMap) + self.assertIsInstance(m.sub.deep, StaticDotMap) + with self.assertRaises(AttributeError): + m.sub.missing + with self.assertRaises(AttributeError): + m.sub.deep.missing + + def test_list_of_dicts_become_static(self): + m = StaticDotMap({ + 'children': [{'name': 'a'}, {'name': 'b'}], + }) + self.assertEqual(len(m.children), 2) + for child in m.children: + self.assertIsInstance(child, StaticDotMap) + with self.assertRaises(AttributeError): + child.missing + + def test_assignment_to_existing_or_new_key_works(self): + # Static only disables auto-creation on read; assignment is still allowed. + m = StaticDotMap({'a': 1}) + m.a = 10 + self.assertEqual(m.a, 10) + m.b = 2 + self.assertEqual(m.b, 2) + m['c'] = 3 + self.assertEqual(m['c'], 3) + + def test_dict_protocol(self): + m = StaticDotMap({'a': 1, 'b': 2}) + self.assertEqual(set(m.keys()), {'a', 'b'}) + self.assertEqual(set(m.values()), {1, 2}) + self.assertEqual(dict(m.items()), {'a': 1, 'b': 2}) + self.assertEqual(len(m), 2) + self.assertIn('a', m) + self.assertNotIn('missing', m) + self.assertEqual(m.get('a'), 1) + self.assertEqual(m.get('missing', 'default'), 'default') + + def test_toDict_returns_plain_dict(self): + m = StaticDotMap({'a': 1, 'sub': {'b': 2}}) + d = m.toDict() + self.assertIsInstance(d, dict) + self.assertNotIsInstance(d, DotMap) + self.assertIsInstance(d['sub'], dict) + self.assertNotIsInstance(d['sub'], DotMap) + self.assertEqual(d, {'a': 1, 'sub': {'b': 2}}) + + def test_copy_preserves_type_and_static(self): + m = StaticDotMap({'a': 1, 'sub': {'b': 2}}) + c = m.copy() + self.assertIsInstance(c, StaticDotMap) + self.assertIsInstance(c.sub, StaticDotMap) + self.assertEqual(c.a, 1) + self.assertEqual(c.sub.b, 2) + with self.assertRaises(AttributeError): + c.missing + with self.assertRaises(AttributeError): + c.sub.missing + + def test_deepcopy_preserves_type_and_static(self): + m = StaticDotMap({'a': 1, 'sub': {'b': 2}}) + c = copy.deepcopy(m) + self.assertIsInstance(c, StaticDotMap) + self.assertIsInstance(c.sub, StaticDotMap) + with self.assertRaises(AttributeError): + c.missing + with self.assertRaises(AttributeError): + c.sub.missing + + def test_pickle_preserves_type_and_static(self): + m = StaticDotMap({'a': 1, 'sub': {'b': 2}}) + restored = pickle.loads(pickle.dumps(m)) + self.assertIsInstance(restored, StaticDotMap) + self.assertIsInstance(restored.sub, StaticDotMap) + self.assertEqual(restored.a, 1) + self.assertEqual(restored.sub.b, 2) + with self.assertRaises(AttributeError): + restored.missing + with self.assertRaises(AttributeError): + restored.sub.missing + + def test_init_from_dotmap(self): + source = DotMap({'a': 1, 'sub': {'b': 2}}) + m = StaticDotMap(source) + self.assertEqual(m.a, 1) + self.assertEqual(m.sub.b, 2) + with self.assertRaises(AttributeError): + m.missing + + def test_equality_with_dict_and_dotmap(self): + m = StaticDotMap({'a': 1, 'b': 2}) + self.assertEqual(m, {'a': 1, 'b': 2}) + self.assertEqual(m, DotMap({'a': 1, 'b': 2})) + self.assertEqual(m, StaticDotMap({'a': 1, 'b': 2})) + + def test_prevent_method_masking_still_works(self): + # The flag introduced for DotMap should pass through to StaticDotMap. + with self.assertRaises(KeyError): + StaticDotMap(a=1, get='mango', _prevent_method_masking=True) + with self.assertRaises(KeyError): + StaticDotMap({'a': 1, 'get': 'mango'}, _prevent_method_masking=True) + + class TestKeyConvertHook(unittest.TestCase): def fix_illegal_key(self, key): return key.replace(".", "_").replace("-","_")