Skip to content

Commit 1f0eafa

Browse files
authored
GH-96145: Add AttrDict to JSON module for use with object_hook (#96146)
1 parent 054328f commit 1f0eafa

File tree

5 files changed

+241
-1
lines changed

5 files changed

+241
-1
lines changed

Doc/library/json.rst

+43
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99

1010
**Source code:** :source:`Lib/json/__init__.py`
1111

12+
.. testsetup:: *
13+
14+
import json
15+
from json import AttrDict
16+
1217
--------------
1318

1419
`JSON (JavaScript Object Notation) <https://json.org>`_, specified by
@@ -532,6 +537,44 @@ Exceptions
532537

533538
.. versionadded:: 3.5
534539

540+
.. class:: AttrDict(**kwargs)
541+
AttrDict(mapping, **kwargs)
542+
AttrDict(iterable, **kwargs)
543+
544+
Subclass of :class:`dict` object that also supports attribute style dotted access.
545+
546+
This class is intended for use with the :attr:`object_hook` in
547+
:func:`json.load` and :func:`json.loads`::
548+
549+
.. doctest::
550+
551+
>>> json_string = '{"mercury": 88, "venus": 225, "earth": 365, "mars": 687}'
552+
>>> orbital_period = json.loads(json_string, object_hook=AttrDict)
553+
>>> orbital_period['earth'] # Dict style lookup
554+
365
555+
>>> orbital_period.earth # Attribute style lookup
556+
365
557+
>>> orbital_period.keys() # All dict methods are present
558+
dict_keys(['mercury', 'venus', 'earth', 'mars'])
559+
560+
Attribute style access only works for keys that are valid attribute
561+
names. In contrast, dictionary style access works for all keys. For
562+
example, ``d.two words`` contains a space and is not syntactically
563+
valid Python, so ``d["two words"]`` should be used instead.
564+
565+
If a key has the same name as a dictionary method, then a dictionary
566+
lookup finds the key and an attribute lookup finds the method:
567+
568+
.. doctest::
569+
570+
>>> d = AttrDict(items=50)
571+
>>> d['items'] # Lookup the key
572+
50
573+
>>> d.items() # Call the method
574+
dict_items([('items', 50)])
575+
576+
.. versionadded:: 3.12
577+
535578

536579
Standard Compliance and Interoperability
537580
----------------------------------------

Lib/json/__init__.py

+51-1
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@
9797
"""
9898
__version__ = '2.0.9'
9999
__all__ = [
100-
'dump', 'dumps', 'load', 'loads',
100+
'dump', 'dumps', 'load', 'loads', 'AttrDict',
101101
'JSONDecoder', 'JSONDecodeError', 'JSONEncoder',
102102
]
103103

@@ -357,3 +357,53 @@ def loads(s, *, cls=None, object_hook=None, parse_float=None,
357357
if parse_constant is not None:
358358
kw['parse_constant'] = parse_constant
359359
return cls(**kw).decode(s)
360+
361+
class AttrDict(dict):
362+
"""Dict like object that supports attribute style dotted access.
363+
364+
This class is intended for use with the *object_hook* in json.loads():
365+
366+
>>> from json import loads, AttrDict
367+
>>> json_string = '{"mercury": 88, "venus": 225, "earth": 365, "mars": 687}'
368+
>>> orbital_period = loads(json_string, object_hook=AttrDict)
369+
>>> orbital_period['earth'] # Dict style lookup
370+
365
371+
>>> orbital_period.earth # Attribute style lookup
372+
365
373+
>>> orbital_period.keys() # All dict methods are present
374+
dict_keys(['mercury', 'venus', 'earth', 'mars'])
375+
376+
Attribute style access only works for keys that are valid attribute names.
377+
In contrast, dictionary style access works for all keys.
378+
For example, ``d.two words`` contains a space and is not syntactically
379+
valid Python, so ``d["two words"]`` should be used instead.
380+
381+
If a key has the same name as dictionary method, then a dictionary
382+
lookup finds the key and an attribute lookup finds the method:
383+
384+
>>> d = AttrDict(items=50)
385+
>>> d['items'] # Lookup the key
386+
50
387+
>>> d.items() # Call the method
388+
dict_items([('items', 50)])
389+
390+
"""
391+
__slots__ = ()
392+
393+
def __getattr__(self, attr):
394+
try:
395+
return self[attr]
396+
except KeyError:
397+
raise AttributeError(attr) from None
398+
399+
def __setattr__(self, attr, value):
400+
self[attr] = value
401+
402+
def __delattr__(self, attr):
403+
try:
404+
del self[attr]
405+
except KeyError:
406+
raise AttributeError(attr) from None
407+
408+
def __dir__(self):
409+
return list(self) + dir(type(self))

Lib/test/test_json/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class PyTest(unittest.TestCase):
1818
json = pyjson
1919
loads = staticmethod(pyjson.loads)
2020
dumps = staticmethod(pyjson.dumps)
21+
AttrDict = pyjson.AttrDict
2122
JSONDecodeError = staticmethod(pyjson.JSONDecodeError)
2223

2324
@unittest.skipUnless(cjson, 'requires _json')

Lib/test/test_json/test_attrdict.py

+145
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
from test.test_json import PyTest
2+
import pickle
3+
import sys
4+
import unittest
5+
6+
kepler_dict = {
7+
"orbital_period": {
8+
"mercury": 88,
9+
"venus": 225,
10+
"earth": 365,
11+
"mars": 687,
12+
"jupiter": 4331,
13+
"saturn": 10_756,
14+
"uranus": 30_687,
15+
"neptune": 60_190,
16+
},
17+
"dist_from_sun": {
18+
"mercury": 58,
19+
"venus": 108,
20+
"earth": 150,
21+
"mars": 228,
22+
"jupiter": 778,
23+
"saturn": 1_400,
24+
"uranus": 2_900,
25+
"neptune": 4_500,
26+
}
27+
}
28+
29+
class TestAttrDict(PyTest):
30+
31+
def test_dict_subclass(self):
32+
self.assertTrue(issubclass(self.AttrDict, dict))
33+
34+
def test_slots(self):
35+
d = self.AttrDict(x=1, y=2)
36+
with self.assertRaises(TypeError):
37+
vars(d)
38+
39+
def test_constructor_signatures(self):
40+
AttrDict = self.AttrDict
41+
target = dict(x=1, y=2)
42+
self.assertEqual(AttrDict(x=1, y=2), target) # kwargs
43+
self.assertEqual(AttrDict(dict(x=1, y=2)), target) # mapping
44+
self.assertEqual(AttrDict(dict(x=1, y=0), y=2), target) # mapping, kwargs
45+
self.assertEqual(AttrDict([('x', 1), ('y', 2)]), target) # iterable
46+
self.assertEqual(AttrDict([('x', 1), ('y', 0)], y=2), target) # iterable, kwargs
47+
48+
def test_getattr(self):
49+
d = self.AttrDict(x=1, y=2)
50+
self.assertEqual(d.x, 1)
51+
with self.assertRaises(AttributeError):
52+
d.z
53+
54+
def test_setattr(self):
55+
d = self.AttrDict(x=1, y=2)
56+
d.x = 3
57+
d.z = 5
58+
self.assertEqual(d, dict(x=3, y=2, z=5))
59+
60+
def test_delattr(self):
61+
d = self.AttrDict(x=1, y=2)
62+
del d.x
63+
self.assertEqual(d, dict(y=2))
64+
with self.assertRaises(AttributeError):
65+
del d.z
66+
67+
def test_dir(self):
68+
d = self.AttrDict(x=1, y=2)
69+
self.assertTrue(set(dir(d)), set(dir(dict)).union({'x', 'y'}))
70+
71+
def test_repr(self):
72+
# This repr is doesn't round-trip. It matches a regular dict.
73+
# That seems to be the norm for AttrDict recipes being used
74+
# in the wild. Also it supports the design concept that an
75+
# AttrDict is just like a regular dict but has optional
76+
# attribute style lookup.
77+
self.assertEqual(repr(self.AttrDict(x=1, y=2)),
78+
repr(dict(x=1, y=2)))
79+
80+
def test_overlapping_keys_and_methods(self):
81+
d = self.AttrDict(items=50)
82+
self.assertEqual(d['items'], 50)
83+
self.assertEqual(d.items(), dict(d).items())
84+
85+
def test_invalid_attribute_names(self):
86+
d = self.AttrDict({
87+
'control': 'normal case',
88+
'class': 'keyword',
89+
'two words': 'contains space',
90+
'hypen-ate': 'contains a hyphen'
91+
})
92+
self.assertEqual(d.control, dict(d)['control'])
93+
self.assertEqual(d['class'], dict(d)['class'])
94+
self.assertEqual(d['two words'], dict(d)['two words'])
95+
self.assertEqual(d['hypen-ate'], dict(d)['hypen-ate'])
96+
97+
def test_object_hook_use_case(self):
98+
AttrDict = self.AttrDict
99+
json_string = self.dumps(kepler_dict)
100+
kepler_ad = self.loads(json_string, object_hook=AttrDict)
101+
102+
self.assertEqual(kepler_ad, kepler_dict) # Match regular dict
103+
self.assertIsInstance(kepler_ad, AttrDict) # Verify conversion
104+
self.assertIsInstance(kepler_ad.orbital_period, AttrDict) # Nested
105+
106+
# Exercise dotted lookups
107+
self.assertEqual(kepler_ad.orbital_period, kepler_dict['orbital_period'])
108+
self.assertEqual(kepler_ad.orbital_period.earth,
109+
kepler_dict['orbital_period']['earth'])
110+
self.assertEqual(kepler_ad['orbital_period'].earth,
111+
kepler_dict['orbital_period']['earth'])
112+
113+
# Dict style error handling and Attribute style error handling
114+
with self.assertRaises(KeyError):
115+
kepler_ad.orbital_period['pluto']
116+
with self.assertRaises(AttributeError):
117+
kepler_ad.orbital_period.Pluto
118+
119+
# Order preservation
120+
self.assertEqual(list(kepler_ad.items()), list(kepler_dict.items()))
121+
self.assertEqual(list(kepler_ad.orbital_period.items()),
122+
list(kepler_dict['orbital_period'].items()))
123+
124+
# Round trip
125+
self.assertEqual(self.dumps(kepler_ad), json_string)
126+
127+
def test_pickle(self):
128+
AttrDict = self.AttrDict
129+
json_string = self.dumps(kepler_dict)
130+
kepler_ad = self.loads(json_string, object_hook=AttrDict)
131+
132+
# Pickling requires the cached module to be the real module
133+
cached_module = sys.modules.get('json')
134+
sys.modules['json'] = self.json
135+
try:
136+
for protocol in range(6):
137+
kepler_ad2 = pickle.loads(pickle.dumps(kepler_ad, protocol))
138+
self.assertEqual(kepler_ad2, kepler_ad)
139+
self.assertEqual(type(kepler_ad2), AttrDict)
140+
finally:
141+
sys.modules['json'] = cached_module
142+
143+
144+
if __name__ == "__main__":
145+
unittest.main()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add AttrDict to JSON module for use with object_hook.

0 commit comments

Comments
 (0)