[Tips] Enhancing Dictionary Usability with DotDict in Python
By JoeVu, at: May 1, 2024, 2:24 p.m.
Enhancing Dictionary Usability with DotDict in Python
When working with dictionaries in Python, accessing keys can sometimes feel cumbersome with the standard bracket notation. Wouldn't it be nice to access dictionary keys as attributes? In this post, we'll show you how to create a DotDict
class that allows you to do just that.
Here is a blog post about DotDict class in Python: https://glinteco.com/en/post/tips-python-dotdict-class/
Implementing the DotDict Class
The DotDict
class is a subclass of Python's built-in dict
class. By overriding the __getattr__
, __setattr__
, and __delattr__
methods, we enable attribute-style access to dictionary keys.
class DotDict(dict):
"""DotDict class allows accessing dictionary keys as attributes."""
def __getattr__(self, attr):
if attr in self:
return self[attr]
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{attr}'")
def __setattr__(self, key, value):
self[key] = value
def __delattr__(self, item):
try:
del self[item]
except KeyError:
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{item}'")
# Example usage:
my_dict = {'name': 'Joe', 'age': 30, 'city': 'Hanoi'}
dot_dict = DotDict(my_dict)
# Access dictionary keys as attributes
print(dot_dict.name) # Output: Joe
print(dot_dict.age) # Output: 30
print(dot_dict.city) # Output: Hanoi
# Modify values using attributes
dot_dict.age = 31
print(dot_dict.age) # Output: 31
# Delete an attribute (key-value pair)
del dot_dict.city
# Accessing a deleted attribute raises an AttributeError
# print(dot_dict.city) # Uncommenting this line would raise an AttributeError
How It Works
__getattr__
: Allows you to access dictionary keys as attributes. If the key does not exist, it raises anAttributeError
.
__setattr__
: Lets you set dictionary keys using attribute-style assignment.
__delattr__
: Enables deletion of dictionary keys using thedel
keyword.
Enhancements
1. Handling Missing Keys Gracefully:
Accessing a missing key using dot notation will raise a KeyError
. You could add an __getattr__
method that handles missing keys gracefully, potentially returning None
or raising an AttributeError
to keep it consistent with normal attribute access.
def __getattr__(self, name):
try:
return self[name]
except KeyError:
raise AttributeError(f"'DotDict' object has no attribute '{name}'")
2. Initialization Default Handling:
The __init__
assumes that a dictionary is always passed. To make it more flexible, consider allowing an empty dictionary by default:
def __init__(self, dct=None):
dct = dct or {}
for key, value in dct.items():
self[key] = DotDict(value) if isinstance(value, dict) else value
3. Recursive Handling:
You already handle nested dictionaries well. However, ensure it doesn't inadvertently recurse infinitely if it encounters self-references.
my_dict = {'name': 'Joe', 'age': 30, 'address': {'city': 'hanoi'}}
dot_dict = DotDict(my_dict)
dot_dict.address.city
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
Cell In[6], line 1
----> 1 dot_dict.address.city
AttributeError: 'dict' object has no attribute 'city'
A better version
class DotDict(dict):
"""DotDict class allows accessing dictionary keys as attributes."""
def __getattr__(self, name):
try:
return self[name]
except KeyError:
raise AttributeError(f"'DotDict' object has no attribute '{name}'")
def __setattr__(self, name, value):
self[name] = value
def __delattr__(self, name):
try:
del self[name]
except KeyError:
raise AttributeError(f"'DotDict' object has no attribute '{name}'")
def __init__(self, dct=None):
dct = dct or {}
for key, value in dct.items():
if not isinstance(key, str) or not key.isidentifier():
raise ValueError(f"Invalid key for dot notation: {key}")
self[key] = DotDict(value) if isinstance(value, dict) else value
def __repr__(self):
return f"DotDict({super().__repr__()})"
# Example Usage
my_dict = my_dict = {'name': 'Joe', 'age': 30, 'address': {'city': 'hanoi'}}
dot_dict = DotDict(my_dict)
print(dot_dict.address.city) # Output: hanoi
Testing the DotDict Class
To ensure our DotDict
class works correctly, we can write some tests using pytest
. Here's a test suite that covers various use cases:
import pytest
from dotdict import DotDict # Assuming the DotDict class is in a file named dotdict.py
@pytest.fixture
def sample_data():
"""Fixture for sample data."""
return {
"company": "Glinteco",
"founder": "Joe",
"slogan": "Best Python Team",
"address": {
"street": "302 Cau Giay",
"city": "Hanoi",
"country": "Vietnam",
},
"teams": ["Hanoi Python Team", "Vietnam Python Team"],
"established_year": 2024,
"next_goals_year": 2025,
}
def test_dotdict_initialization(sample_data):
"""Test initialization of DotDict."""
data = DotDict(sample_data)
assert data.company == "Glinteco"
assert data.founder == "Joe"
assert data.slogan == "Best Python Team"
assert data.address.street == "302 Cau Giay"
assert data.address.city == "Hanoi"
assert data.address.country == "Vietnam"
assert data.teams == ["Hanoi Python Team", "Vietnam Python Team"]
assert data.established_year == 2024
assert data.next_goals_year == 2025
def test_dotdict_attribute_assignment(sample_data):
"""Test setting attributes in DotDict."""
data = DotDict(sample_data)
data.ceo = "Joe Nguyen"
assert data.ceo == "Joe Nguyen"
data.address.city = "Ho Chi Minh"
assert data.address.city == "Ho Chi Minh"
def test_dotdict_nested_update(sample_data):
"""Test nested dictionary update."""
data = DotDict(sample_data)
data.address["street"] = "123 Tran Phu"
assert data.address.street == "123 Tran Phu"
def test_dotdict_key_error(sample_data):
"""Test access to missing attributes raises AttributeError."""
data = DotDict(sample_data)
with pytest.raises(AttributeError):
_ = data.non_existent_key
def test_dotdict_delattr(sample_data):
"""Test deleting attributes."""
data = DotDict(sample_data)
del data.address
with pytest.raises(AttributeError):
_ = data.address
def test_dotdict_list_access(sample_data):
"""Test list access in DotDict."""
data = DotDict(sample_data)
assert data.teams[0] == "Hanoi Python Team"
assert data.teams[1] == "Vietnam Python Team"
def test_dotdict_recursive_initialization(sample_data):
"""Test recursive initialization of nested DotDicts."""
data = DotDict(sample_data)
assert isinstance(data.address, DotDict)
assert isinstance(data.address.city, str)
assert data.address.city == "Hanoi"
def test_dotdict_invalid_key():
"""Test DotDict raises ValueError for invalid keys."""
with pytest.raises(ValueError):
DotDict({"1invalid-key": "test"})
if __name__ == "__main__":
pytest.main()
You can run these tests by navigating to the directory containing test_dotdict.py
and executing:
pytest test_dotdict.py
Another tests are implemented by unittest
:
import unittest
from dotdict import DotDict # Assuming the DotDict class is in a file named dotdict.py
class TestDotDict(unittest.TestCase):
def setUp(self):
"""Set up sample data for tests."""
self.sample_data = {
"company": "Glinteco",
"founder": "Joe",
"slogan": "Best Python Team",
"address": {
"street": "302 Cau Giay",
"city": "Hanoi",
"country": "Vietnam",
},
"teams": ["Hanoi Python Team", "Vietnam Python Team"],
"established_year": 2024,
"next_goals_year": 2025,
}
self.data = DotDict(self.sample_data)
def test_initialization(self):
"""Test initialization of DotDict."""
self.assertEqual(self.data.company, "Glinteco")
self.assertEqual(self.data.founder, "Joe")
self.assertEqual(self.data.slogan, "Best Python Team")
self.assertEqual(self.data.address.street, "302 Cau Giay")
self.assertEqual(self.data.address.city, "Hanoi")
self.assertEqual(self.data.address.country, "Vietnam")
self.assertEqual(self.data.teams, ["Hanoi Python Team", "Vietnam Python Team"])
self.assertEqual(self.data.established_year, 2024)
self.assertEqual(self.data.next_goals_year, 2025)
def test_attribute_assignment(self):
"""Test setting attributes in DotDict."""
self.data.ceo = "Joe Nguyen"
self.assertEqual(self.data.ceo, "Joe Nguyen")
self.data.address.city = "Ho Chi Minh"
self.assertEqual(self.data.address.city, "Ho Chi Minh")
def test_nested_update(self):
"""Test nested dictionary update."""
self.data.address["street"] = "123 Tran Phu"
self.assertEqual(self.data.address.street, "123 Tran Phu")
def test_key_error(self):
"""Test access to missing attributes raises AttributeError."""
with self.assertRaises(AttributeError):
_ = self.data.non_existent_key
def test_delattr(self):
"""Test deleting attributes."""
del self.data.address
with self.assertRaises(AttributeError):
_ = self.data.address
def test_list_access(self):
"""Test list access in DotDict."""
self.assertEqual(self.data.teams[0], "Hanoi Python Team")
self.assertEqual(self.data.teams[1], "Vietnam Python Team")
def test_recursive_initialization(self):
"""Test recursive initialization of nested DotDicts."""
self.assertIsInstance(self.data.address, DotDict)
self.assertIsInstance(self.data.address.city, str)
self.assertEqual(self.data.address.city, "Hanoi")
def test_invalid_key(self):
"""Test DotDict raises ValueError for invalid keys."""
with self.assertRaises(ValueError):
DotDict({"1invalid-key": "test"})
if __name__ == "__main__":
unittest.main()
To execute the test
python -m unittest test_dotdict.py
These will run the unittest
/pytest
frameworks, executing the tests and providing feedback on their success or failure.
Conclusion
The DotDict
class is a simple and elegant solution for accessing dictionary keys as attributes. It can make your code cleaner and more intuitive. However, be mindful of its limitations and consider if it fits your use case. If you encounter keys that are not valid attribute names, you may need to stick with the traditional dictionary access methods or use a different approach.