Cas d'usages concrets des dataclasses Python

Article publié le 26/04/2025 par Jules SAGOT

Parser une réponse API avec une dataclass

On commence par définir notre schéma de données qu’on va obtenir via API avec le module dataclass :

from dataclasses import dataclass

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

@dataclass
class User:
    id: int
    name: str
    email: str
    address: Address

Une réponse API inclue souvent plus de champs que ce dont on a besoin.

Le problème étant qu’il n’est pas possible de passer plus d’arguments que prévu à une dataclass :

Address(street='Kulas Light', city='Gwenborough', zipcode='92998-3874', unexpected_field=42)

⬇️💣

TypeError: Address.__init__() got an unexpected keyword argument 'unexpected_field'

Afin d’éviter cette erreur, on crée un utilitaire parse_api qui permet de passer uniquement les champs déclarés aux dataclasses :

from dataclasses import dataclass, fields
from typing import Any, Dict

def parse_api(cls, data: Dict[str, Any]):
    init_kwargs = {}

    for field in fields(cls):
        field_name = field.name
        field_type = field.type

        if field_name in data:
            value = data[field_name]

            # If the field is a dataclass itself
            if hasattr(field_type, '__dataclass_fields__'):
                value = parse_api(field_type, value)

            init_kwargs[field_name] = value

    return cls(**init_kwargs)

Ce qui donne sur un exemple complet :

api_response = {
  "id": 1,
  "name": "Leanne Graham",
  "email": "Sincere@april.biz",
  "address": {
    "id": 1,
    "street": "Kulas Light",
    "city": "Gwenborough",
    "zipcode": "92998-3874"
  },
  "extra_field": "is ignored"
}
print(parse_api(User, api_response))

⬇️

User(id=1, name='Leanne Graham', email='Sincere@april.biz', address=Address(street='Kulas Light', city='Gwenborough', zipcode='92998-3874'))

Le module dataclass permet, sans dépendance externe, de rapidement créer des objects qui vont contenir la donnée qui vient de notre API.

Voici les avantages par rapport à un dictionnaire :

  • On a défini les champs retournés par l’API dans notre dataclass
  • Notre éditeur de texte va permettre d’auto-compléter les champs disponibles dans la suite du code
  • Solution performante car sans typage fort et basée sur la librairie standard Python

Cette solution est idéale si votre projet dépends peu d’API externes.

Si vous avez besoin d’un typage fort et garanti sur les champs, vous pouvez utiliser un module tiers comme pandera.

Configurer son application avec des dataclasses

Les dataclasses peuvent être utilisées pour sauvegarder la configuration de l’application web à son démarrage.

On va rendre la configuration non modifiable avec frozen=True.

La configuration sera stockée en YAML dans config.yaml. Python dispose aussi d’un module natif Python qui permet de lire le YAML.

debug: true
secret_key: iowkdsvomkfegrjfmdslqkplezfkogejtb
database:
  host: localhost
  port: 5432
  username: postgres
  password: postgres
  database_name: postgres

On déclare des dataclasses qui vont contenir notre configuration en lecture seule.

Ensuite, on déclare un utilitaire pour transformer le fichier de configuration en instances de dataclass.

Enfin, on utilise la configuration obtenue.

from dataclasses import dataclass
import yaml

@dataclass(frozen=True)
class DatabaseConfig:
    host: str
    port: int
    username: str
    password: str
    database_name: str

@dataclass(frozen=True)
class AppConfig:
    debug: bool
    secret_key: str
    database: DatabaseConfig

def load_config_from_yaml(path: str) -> AppConfig:
    with open(path, 'r') as file:
        data = yaml.safe_load(file)
    
    database_data = data['database']

    return AppConfig(
        debug=data['debug'],
        secret_key=data['secret_key'],
        database=DatabaseConfig(
            host=database_data['host'],
            port=database_data['port'],
            username=database_data['username'],
            password=database_data['password'],
            database_name=database_data['database_name']
        )
    )

config = load_config_from_yaml("config.yaml")
print(f"Debug Mode: {config.debug}")
print(f"Database Host: {config.database.host}")

⬇️

Debug Mode: True
Database Host: localhost

Gérer les dépendances de tâches avec un DAG et des dataclass

On peut aussi utiliser les dataclasses dans des algorithmes, pour éviter d’avoir à écrire la fonction __init__.

Pour l’exemple, on va créer un ensemble de tâches qui vont dépendre les unes des autres.

Une tâche a des dépendances et doit exécuter un code pour être terminée.

Ensuite, on crée un graphe orienté acyclique (DAG en anglais) qui va permettre d’éxecuter les tâches de manière synchrone, et dans le bon ordre en fonction de leurs dépendances.

from dataclasses import dataclass, field
from typing import List, Dict, Callable, Set


@dataclass
class Node:
    name: str
    func: Callable
    dependencies: List[str] = field(default_factory=list)

    def run(self, context: Dict[str, any]) -> any:
        # Run the function with inputs from context
        inputs = [context[dep] for dep in self.dependencies]
        output = self.func(*inputs)
        print(f"Node {self.name}: {output}")
        return output


@dataclass
class DAG:
    nodes: Dict[str, Node] = field(default_factory=dict)

    def add_node(self, node: Node):
        if node.name in self.nodes:
            raise ValueError(f"Node {node.name} already exists in DAG.")
        self.nodes[node.name] = node

    def _topological_sort(self) -> List[str]:
        visited = set()
        temp_marks = set()
        result = []

        def visit(node_name: str):
            if node_name in temp_marks:
                raise Exception(f"Cycle detected at {node_name}")
            if node_name not in visited:
                temp_marks.add(node_name)
                for dep in self.nodes[node_name].dependencies:
                    visit(dep)
                temp_marks.remove(node_name)
                visited.add(node_name)
                result.append(node_name)

        for node_name in self.nodes:
            visit(node_name)

        return result

    def execute(self) -> Dict[str, any]:
        order = self._topological_sort()
        context = {}
        for node_name in order:
            node = self.nodes[node_name]
            context[node_name] = node.run(context)
        return context


# Example usage:

# Define some simple functions
def start():
    return "start"

def step1(x):
    return f"{x} -> step1"

def step2(x):
    return f"{x} -> step2"

def final(x, y):
    return f"final({x}, {y})"

# Build the DAG
dag = DAG()
dag.add_node(Node(name="start", func=start))
dag.add_node(Node(name="step1", func=step1, dependencies=["start"]))
dag.add_node(Node(name="step2", func=step2, dependencies=["start"]))
dag.add_node(Node(name="final", func=final, dependencies=["step1", "step2"]))

# Execute the DAG
dag.execute()

⬇️

Node start: start
Node step1: start -> step1
Node step2: start -> step2
Node final: final(start -> step1, start -> step2)