From 1f0eafa844bf5a380603d55e8d4b42d8c2a3439d Mon Sep 17 00:00:00 2001
From: Raymond Hettinger <rhettinger@users.noreply.github.com>
Date: Tue, 23 Aug 2022 16:22:00 -0500
Subject: GH-96145: Add AttrDict to JSON module for use with object_hook
 (#96146)

---
 Doc/library/json.rst                               |  43 ++++++
 Lib/json/__init__.py                               |  52 +++++++-
 Lib/test/test_json/__init__.py                     |   1 +
 Lib/test/test_json/test_attrdict.py                | 145 +++++++++++++++++++++
 .../2022-08-20-12-56-15.gh-issue-96145.8ah3pE.rst  |   1 +
 5 files changed, 241 insertions(+), 1 deletion(-)
 create mode 100644 Lib/test/test_json/test_attrdict.py
 create mode 100644 Misc/NEWS.d/next/Library/2022-08-20-12-56-15.gh-issue-96145.8ah3pE.rst

diff --git a/Doc/library/json.rst b/Doc/library/json.rst
index f65be85..467d5d9 100644
--- a/Doc/library/json.rst
+++ b/Doc/library/json.rst
@@ -9,6 +9,11 @@
 
 **Source code:** :source:`Lib/json/__init__.py`
 
+.. testsetup:: *
+
+   import json
+   from json import AttrDict
+
 --------------
 
 `JSON (JavaScript Object Notation) <https://json.org>`_, specified by
@@ -532,6 +537,44 @@ Exceptions
 
    .. versionadded:: 3.5
 
+.. class:: AttrDict(**kwargs)
+           AttrDict(mapping, **kwargs)
+           AttrDict(iterable, **kwargs)
+
+   Subclass of :class:`dict` object that also supports attribute style dotted access.
+
+   This class is intended for use with the :attr:`object_hook` in
+   :func:`json.load` and :func:`json.loads`::
+
+   .. doctest::
+
+        >>> json_string = '{"mercury": 88, "venus": 225, "earth": 365, "mars": 687}'
+        >>> orbital_period = json.loads(json_string, object_hook=AttrDict)
+        >>> orbital_period['earth']     # Dict style lookup
+        365
+        >>> orbital_period.earth        # Attribute style lookup
+        365
+        >>> orbital_period.keys()       # All dict methods are present
+        dict_keys(['mercury', 'venus', 'earth', 'mars'])
+
+   Attribute style access only works for keys that are valid attribute
+   names.  In contrast, dictionary style access works for all keys.  For
+   example, ``d.two words`` contains a space and is not syntactically
+   valid Python, so ``d["two words"]`` should be used instead.
+
+   If a key has the same name as a dictionary method, then a dictionary
+   lookup finds the key and an attribute lookup finds the method:
+
+   .. doctest::
+
+        >>> d = AttrDict(items=50)
+        >>> d['items']                  # Lookup the key
+        50
+        >>> d.items()                   # Call the method
+        dict_items([('items', 50)])
+
+   .. versionadded:: 3.12
+
 
 Standard Compliance and Interoperability
 ----------------------------------------
diff --git a/Lib/json/__init__.py b/Lib/json/__init__.py
index e4c21da..d775fb1 100644
--- a/Lib/json/__init__.py
+++ b/Lib/json/__init__.py
@@ -97,7 +97,7 @@ Using json.tool from the shell to validate and pretty-print::
 """
 __version__ = '2.0.9'
 __all__ = [
-    'dump', 'dumps', 'load', 'loads',
+    'dump', 'dumps', 'load', 'loads', 'AttrDict',
     'JSONDecoder', 'JSONDecodeError', 'JSONEncoder',
 ]
 
@@ -357,3 +357,53 @@ def loads(s, *, cls=None, object_hook=None, parse_float=None,
     if parse_constant is not None:
         kw['parse_constant'] = parse_constant
     return cls(**kw).decode(s)
+
+class AttrDict(dict):
+    """Dict like object that supports attribute style dotted access.
+
+    This class is intended for use with the *object_hook* in json.loads():
+
+        >>> from json import loads, AttrDict
+        >>> json_string = '{"mercury": 88, "venus": 225, "earth": 365, "mars": 687}'
+        >>> orbital_period = loads(json_string, object_hook=AttrDict)
+        >>> orbital_period['earth']     # Dict style lookup
+        365
+        >>> orbital_period.earth        # Attribute style lookup
+        365
+        >>> orbital_period.keys()       # All dict methods are present
+        dict_keys(['mercury', 'venus', 'earth', 'mars'])
+
+    Attribute style access only works for keys that are valid attribute names.
+    In contrast, dictionary style access works for all keys.
+    For example, ``d.two words`` contains a space and is not syntactically
+    valid Python, so ``d["two words"]`` should be used instead.
+
+    If a key has the same name as dictionary method, then a dictionary
+    lookup finds the key and an attribute lookup finds the method:
+
+        >>> d = AttrDict(items=50)
+        >>> d['items']                  # Lookup the key
+        50
+        >>> d.items()                   # Call the method
+        dict_items([('items', 50)])
+
+    """
+    __slots__ = ()
+
+    def __getattr__(self, attr):
+        try:
+            return self[attr]
+        except KeyError:
+            raise AttributeError(attr) from None
+
+    def __setattr__(self, attr, value):
+        self[attr] = value
+
+    def __delattr__(self, attr):
+        try:
+            del self[attr]
+        except KeyError:
+            raise AttributeError(attr) from None
+
+    def __dir__(self):
+        return list(self) + dir(type(self))
diff --git a/Lib/test/test_json/__init__.py b/Lib/test/test_json/__init__.py
index 74b64ed..37b2e0d 100644
--- a/Lib/test/test_json/__init__.py
+++ b/Lib/test/test_json/__init__.py
@@ -18,6 +18,7 @@ class PyTest(unittest.TestCase):
     json = pyjson
     loads = staticmethod(pyjson.loads)
     dumps = staticmethod(pyjson.dumps)
+    AttrDict = pyjson.AttrDict
     JSONDecodeError = staticmethod(pyjson.JSONDecodeError)
 
 @unittest.skipUnless(cjson, 'requires _json')
diff --git a/Lib/test/test_json/test_attrdict.py b/Lib/test/test_json/test_attrdict.py
new file mode 100644
index 0000000..48d14f4
--- /dev/null
+++ b/Lib/test/test_json/test_attrdict.py
@@ -0,0 +1,145 @@
+from test.test_json import PyTest
+import pickle
+import sys
+import unittest
+
+kepler_dict = {
+    "orbital_period": {
+        "mercury": 88,
+        "venus": 225,
+        "earth": 365,
+        "mars": 687,
+        "jupiter": 4331,
+        "saturn": 10_756,
+        "uranus": 30_687,
+        "neptune": 60_190,
+    },
+    "dist_from_sun": {
+        "mercury": 58,
+        "venus": 108,
+        "earth": 150,
+        "mars": 228,
+        "jupiter": 778,
+        "saturn": 1_400,
+        "uranus": 2_900,
+        "neptune": 4_500,
+    }
+}
+
+class TestAttrDict(PyTest):
+
+    def test_dict_subclass(self):
+        self.assertTrue(issubclass(self.AttrDict, dict))
+
+    def test_slots(self):
+        d = self.AttrDict(x=1, y=2)
+        with self.assertRaises(TypeError):
+            vars(d)
+
+    def test_constructor_signatures(self):
+        AttrDict = self.AttrDict
+        target = dict(x=1, y=2)
+        self.assertEqual(AttrDict(x=1, y=2), target)                   # kwargs
+        self.assertEqual(AttrDict(dict(x=1, y=2)), target)             # mapping
+        self.assertEqual(AttrDict(dict(x=1, y=0), y=2), target)        # mapping, kwargs
+        self.assertEqual(AttrDict([('x', 1), ('y', 2)]), target)       # iterable
+        self.assertEqual(AttrDict([('x', 1), ('y', 0)], y=2), target)  # iterable, kwargs
+
+    def test_getattr(self):
+        d = self.AttrDict(x=1, y=2)
+        self.assertEqual(d.x, 1)
+        with self.assertRaises(AttributeError):
+            d.z
+
+    def test_setattr(self):
+        d = self.AttrDict(x=1, y=2)
+        d.x = 3
+        d.z = 5
+        self.assertEqual(d, dict(x=3, y=2, z=5))
+
+    def test_delattr(self):
+        d = self.AttrDict(x=1, y=2)
+        del d.x
+        self.assertEqual(d, dict(y=2))
+        with self.assertRaises(AttributeError):
+            del d.z
+
+    def test_dir(self):
+        d = self.AttrDict(x=1, y=2)
+        self.assertTrue(set(dir(d)), set(dir(dict)).union({'x', 'y'}))
+
+    def test_repr(self):
+        # This repr is doesn't round-trip.  It matches a regular dict.
+        # That seems to be the norm for AttrDict recipes being used
+        # in the wild.  Also it supports the design concept that an
+        # AttrDict is just like a regular dict but has optional
+        # attribute style lookup.
+        self.assertEqual(repr(self.AttrDict(x=1, y=2)),
+                         repr(dict(x=1, y=2)))
+
+    def test_overlapping_keys_and_methods(self):
+        d = self.AttrDict(items=50)
+        self.assertEqual(d['items'], 50)
+        self.assertEqual(d.items(), dict(d).items())
+
+    def test_invalid_attribute_names(self):
+        d = self.AttrDict({
+            'control': 'normal case',
+            'class': 'keyword',
+            'two words': 'contains space',
+            'hypen-ate': 'contains a hyphen'
+        })
+        self.assertEqual(d.control, dict(d)['control'])
+        self.assertEqual(d['class'], dict(d)['class'])
+        self.assertEqual(d['two words'], dict(d)['two words'])
+        self.assertEqual(d['hypen-ate'], dict(d)['hypen-ate'])
+
+    def test_object_hook_use_case(self):
+        AttrDict = self.AttrDict
+        json_string = self.dumps(kepler_dict)
+        kepler_ad = self.loads(json_string, object_hook=AttrDict)
+
+        self.assertEqual(kepler_ad, kepler_dict)     # Match regular dict
+        self.assertIsInstance(kepler_ad, AttrDict)   # Verify conversion
+        self.assertIsInstance(kepler_ad.orbital_period, AttrDict)  # Nested
+
+        # Exercise dotted lookups
+        self.assertEqual(kepler_ad.orbital_period, kepler_dict['orbital_period'])
+        self.assertEqual(kepler_ad.orbital_period.earth,
+                         kepler_dict['orbital_period']['earth'])
+        self.assertEqual(kepler_ad['orbital_period'].earth,
+                         kepler_dict['orbital_period']['earth'])
+
+        # Dict style error handling and Attribute style error handling
+        with self.assertRaises(KeyError):
+            kepler_ad.orbital_period['pluto']
+        with self.assertRaises(AttributeError):
+            kepler_ad.orbital_period.Pluto
+
+        # Order preservation
+        self.assertEqual(list(kepler_ad.items()), list(kepler_dict.items()))
+        self.assertEqual(list(kepler_ad.orbital_period.items()),
+                             list(kepler_dict['orbital_period'].items()))
+
+        # Round trip
+        self.assertEqual(self.dumps(kepler_ad), json_string)
+
+    def test_pickle(self):
+        AttrDict = self.AttrDict
+        json_string = self.dumps(kepler_dict)
+        kepler_ad = self.loads(json_string, object_hook=AttrDict)
+
+        # Pickling requires the cached module to be the real module
+        cached_module = sys.modules.get('json')
+        sys.modules['json'] = self.json
+        try:
+            for protocol in range(6):
+                kepler_ad2 = pickle.loads(pickle.dumps(kepler_ad, protocol))
+                self.assertEqual(kepler_ad2, kepler_ad)
+                self.assertEqual(type(kepler_ad2), AttrDict)
+        finally:
+            sys.modules['json'] = cached_module
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/Misc/NEWS.d/next/Library/2022-08-20-12-56-15.gh-issue-96145.8ah3pE.rst b/Misc/NEWS.d/next/Library/2022-08-20-12-56-15.gh-issue-96145.8ah3pE.rst
new file mode 100644
index 0000000..540ec8b
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2022-08-20-12-56-15.gh-issue-96145.8ah3pE.rst
@@ -0,0 +1 @@
+Add AttrDict to JSON module for use with object_hook.
-- 
cgit v0.12