Skip to content

gh-96145: Add AttrDict to JSON module for use with object_hook #96146

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Aug 23, 2022
43 changes: 43 additions & 0 deletions Doc/library/json.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
----------------------------------------
Expand Down
52 changes: 51 additions & 1 deletion Lib/json/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@
"""
__version__ = '2.0.9'
__all__ = [
'dump', 'dumps', 'load', 'loads',
'dump', 'dumps', 'load', 'loads', 'AttrDict',
'JSONDecoder', 'JSONDecodeError', 'JSONEncoder',
]

Expand Down Expand Up @@ -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))
1 change: 1 addition & 0 deletions Lib/test/test_json/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
145 changes: 145 additions & 0 deletions Lib/test/test_json/test_attrdict.py
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add AttrDict to JSON module for use with object_hook.