mirror of
https://github.com/python/cpython
synced 2024-09-16 00:17:02 +00:00
GH-96145: Add AttrDict to JSON module for use with object_hook (#96146)
This commit is contained in:
parent
054328f0dd
commit
1f0eafa844
|
@ -9,6 +9,11 @@
|
||||||
|
|
||||||
**Source code:** :source:`Lib/json/__init__.py`
|
**Source code:** :source:`Lib/json/__init__.py`
|
||||||
|
|
||||||
|
.. testsetup:: *
|
||||||
|
|
||||||
|
import json
|
||||||
|
from json import AttrDict
|
||||||
|
|
||||||
--------------
|
--------------
|
||||||
|
|
||||||
`JSON (JavaScript Object Notation) <https://json.org>`_, specified by
|
`JSON (JavaScript Object Notation) <https://json.org>`_, specified by
|
||||||
|
@ -532,6 +537,44 @@ Exceptions
|
||||||
|
|
||||||
.. versionadded:: 3.5
|
.. 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
|
Standard Compliance and Interoperability
|
||||||
----------------------------------------
|
----------------------------------------
|
||||||
|
|
|
@ -97,7 +97,7 @@
|
||||||
"""
|
"""
|
||||||
__version__ = '2.0.9'
|
__version__ = '2.0.9'
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'dump', 'dumps', 'load', 'loads',
|
'dump', 'dumps', 'load', 'loads', 'AttrDict',
|
||||||
'JSONDecoder', 'JSONDecodeError', 'JSONEncoder',
|
'JSONDecoder', 'JSONDecodeError', 'JSONEncoder',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -357,3 +357,53 @@ def loads(s, *, cls=None, object_hook=None, parse_float=None,
|
||||||
if parse_constant is not None:
|
if parse_constant is not None:
|
||||||
kw['parse_constant'] = parse_constant
|
kw['parse_constant'] = parse_constant
|
||||||
return cls(**kw).decode(s)
|
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))
|
||||||
|
|
|
@ -18,6 +18,7 @@ class PyTest(unittest.TestCase):
|
||||||
json = pyjson
|
json = pyjson
|
||||||
loads = staticmethod(pyjson.loads)
|
loads = staticmethod(pyjson.loads)
|
||||||
dumps = staticmethod(pyjson.dumps)
|
dumps = staticmethod(pyjson.dumps)
|
||||||
|
AttrDict = pyjson.AttrDict
|
||||||
JSONDecodeError = staticmethod(pyjson.JSONDecodeError)
|
JSONDecodeError = staticmethod(pyjson.JSONDecodeError)
|
||||||
|
|
||||||
@unittest.skipUnless(cjson, 'requires _json')
|
@unittest.skipUnless(cjson, 'requires _json')
|
||||||
|
|
145
Lib/test/test_json/test_attrdict.py
Normal file
145
Lib/test/test_json/test_attrdict.py
Normal file
|
@ -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()
|
|
@ -0,0 +1 @@
|
||||||
|
Add AttrDict to JSON module for use with object_hook.
|
Loading…
Reference in a new issue