Using Dataclasses for Configuration in Python
Tags: python, configuration, tutorial, architecture
Published: 2022-04-02 15:17:49

## Introduction

Defining configuration schemes for python apps can be tricky. In this article, I will showcase a simple and effective way to make handling configuration easy, with only the standard library.

## Format

For this method, any file format will work, as long as you can parse it into a python `dict`.

## A Simple Example

We'll make a simple example with this made-up config from [wikipedia](https://en.wikipedia.org/wiki/YAML#Example):

```yaml
---
receipt:     Oz-Ware Purchase Invoice
date:        2012-08-06
customer:
    first_name:   Dorothy
    family_name:  Gale

items:
    - part_no:   A4786
      descrip:   Water Bucket (Filled)
      price:     1.47
      quantity:  4

    - part_no:   E1628
      descrip:   High Heeled "Ruby" Slippers
      size:      8
      price:     133.7
      quantity:  1

bill-to:  &id001
    street: |
            123 Tornado Alley
            Suite 16
    city:   East Centerville
    state:  KS

ship-to:  *id001

specialDelivery:  >
    Follow the Yellow Brick
    Road to the Emerald City.
    Pay no attention to the
    man behind the curtain.

```

We can parse this into python to get a dict:

```py
config = {
    "reciept": "Oz-Ware Purchase Invoice",
    "date": "2012-08-06",
    "customer": {
        "first_name": "Dorothy",
        "family_name": "Gale",
    },
    "items": [
        {
            "part_no": "A4786",
            "descrip": "Water Bucket (Filled)",
            "price": 1.47,
            "quantity": 4,
        },
        {
            "part_no": "E1628",
            "descrip": "High Heeled \"Ruby\" Slippers",
            "size": 8,
            "price": 133.7,
            "quantity": 1,
        },
    ],
    "bill-to": {
        "street" : "123 Tornado Alley\nSuite 16",
        "city": "East Centerville",
        "state": "KS",
    },
    "ship-to": {
        "street" : "123 Tornado Alley\nSuite 16",
        "city": "East Centerville",
        "state": "KS",
    },
    "specialDelivery": "Follow the Yellow Brick Road to the Emerald City. Pay no attention to the man behind the curtain.",
}
```

However, using this in the code is cumbersome. `config["customer"]["first_name"]` is prone to error, and difficult to refactor.

## Dataclasses

Dataclasses will make our life much easier. We define the config properties, sub-properties, and types in a `config.py` file:

```py
import typing as t
from dataclasses import dataclass
from datetime import date

@dataclass
class Item:
    part_no: str
    description: str
    price: float
    quantity: int
    size: int = None

    def __post_init__(self):
        # Do some validation
        if self.quantity <= 0:
            raise ValueError("quantity must be greater than zero")

    @classmethod
    def from_dict(cls: t.Type["Item"], obj: dict):
        return cls(
            part_no=obj["part_no"],
            description=obj["descrip"],
            price=obj["price"],
            quantity=obj["quantity"],
            size=obj.get("size"),
        )

@dataclass
class Customer:
    first_name: str
    family_name: str

    @classmethod
    def from_dict(cls: t.Type["Customer"], obj: dict):
        return cls(
            first_name=obj["first_name"],
            last_name=obj["family_name"],
        )

@dataclass
class Address:
    street: str
    city: str
    state: str

    @classmethod
    def from_dict(cls: t.Type["Address"], obj: dict):
        return cls(
            street=obj["street"],
            city=obj["city"],
            state=obj["state"],
        )

@dataclass
class Order:
    reciept: str
    date: date
    customer: Customer
    items: t.Sequence[Item]
    bill_to: Address
    ship_to: Address
    special_delivery: str = None

    @classmethod
    def from_dict(cls: t.Type["Order"], obj: dict):
        return cls(
            receipt=obj["reciept"],
            date=date(obj["date"]),
            customer=Customer.from_dict(obj["customer"]),
            items=[Item.from_dict(item) for item in obj["items"]),
            bill_to=Address.from_dict(obj["bill-to"]),
            ship_to=Address.from_dict(obj["ship-to"]),
            special_delivery=obj.get("specialDelivery"),
        )
```

Now, when we want to use the config in our application, we can simply do:

```py
raw_config = {...}

config = Order.from_dict(raw_config)

config.customer.first_name
```

This method has a ton of benefits:
- We get code completion and type hints in the editor
- It's easier to maintain, since you only have to change a config property name in one place
- Can implement version reconciliation in the `from_dict` method
- Refactoring is a breeze, since editors can auto-refactor class property names
- Allows you to define configurations with python code, since you can instantiate the dataclasses directly in a `settings.py` file, for example
- It's testable:

```py
import unittest
from .config import Order

class TestOrderConfig(unittest.TestCase):
    def test_example_config(self):
        raw_config = {...}
        expected = Order(
            customer=Customer(...),
            ...
        )

        self.assertEqual(Order.from_dict(raw_config), expected)
```

Hopefully you found this useful, and can use this method to clean up some of your projects!