Using Dataclasses for Configuration in Python

eblocha

eblocha

Posted on April 2, 2022

Using Dataclasses for Configuration in Python

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:

---
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.

Enter fullscreen mode Exit fullscreen mode

We can parse this into python to get a dict:

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.",
}
Enter fullscreen mode Exit fullscreen mode

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:

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"),
        )
Enter fullscreen mode Exit fullscreen mode

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

raw_config = {...}

config = Order.from_dict(raw_config)

config.customer.first_name
Enter fullscreen mode Exit fullscreen mode

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:
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)
Enter fullscreen mode Exit fullscreen mode

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

💖 💪 🙅 🚩
eblocha
eblocha

Posted on April 2, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related