[Tips] Enhancing Dictionary Usability with DotDict in Python

By JoeVu, at: May 1, 2024, 2:24 p.m.

Estimated Reading Time: 9 min read

[Tips] Enhancing Dictionary Usability with DotDict in Python
[Tips] Enhancing Dictionary Usability with DotDict in Python

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 an AttributeError.
     
  • __setattr__: Lets you set dictionary keys using attribute-style assignment.
     
  • __delattr__: Enables deletion of dictionary keys using the del 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.


Related

Python Learning

[Tips] Python DotDict Class

Read more
Python Automation

[TIPS] Python - QR Code Exploration

Read more
Subscribe

Subscribe to our newsletter and never miss out lastest news.