TATECHATLAS
◎ Français
Programmation / Guide

Sérialisation d'objets Python complexes en JSON avec des encodeurs personnalisés

Le module json de Python ne gère qu'un ensemble fixe de types. Pour sérialiser des nombres complexes, des dates, des chemins ou des classes définies par l'utilisateur, vous devez fournir une fonction default ou sous-classer JSONEncoder, puis associer la sortie à object_hook du côté décodage.

Dans ce guide

Le module json de Python convertit uniquement dict, list, tuple, str, int, float, bool et None par défaut. Tout autre type provoque une TypeError sauf interception. L'interception se fait via le paramètre default de json.dumps ou la méthode default d'une sous-classe JSONEncoder. Les deux approches reçoivent l'objet non supporté et doivent retourner une valeur compatible JSON ou lever TypeError pour signaler l'échec. Pour la fidélité aller-retour, vous devez également fournir un object_hook correspondant au côté décoder qui reconstruit le type original à partir du marqueur.

Pourquoi JSON ne sérialise pas les objets Python arbitraires

Le module json définit une table de conversion fixe. Le dictionnaire Python mappe vers un objet JSON, list et tuple vers un tableau, str vers une chaîne, int et float vers un nombre, True et False vers leurs littéraux JSON, et None vers null. Les nombres complexes, les instances datetime, les objets pathlib.Path et les instances de classes définies par l'utilisateur n'ont aucune entrée dans cette table. Lorsque l'encodeur rencontre une valeur hors table il appelle le crochet default. Si aucun crochet n'est fourni le crochet par défaut lève TypeError. La documentation précise explicitement : l'encodeur ne prend en charge que les types listés et pour l'étendre vous devez sous-classer JSONEncoder et implémenter une méthode default.

import json
# This raises TypeError because complex has no default mapping
try:
    json.dumps(1 + 2j)
except TypeError as e:
    print(e)

Lors de la sérialisation d'une classe personnalisée, intégrez toujours une clé de discriminateur de type comme __type__ afin que le décodeur puisse distinguer votre dictionnaire marqueur d'un dictionnaire simple partageant les mêmes noms de champs.

Sérialisation via le paramètre default dans json.dumps

Le paramètre default accepte un appelable qui reçoit l'objet non supporté et retourne une valeur sérialisable JSON. La valeur de retour peut être un dictionnaire, une liste, une chaîne, un nombre ou un booléen. Si l'appelable ne peut pas gérer l'objet il doit lever TypeError afin que d'autres objets dans le même document puissent encore être encodés selon leurs propres règles ou par un appel super chaîné. Cette approche est idéale lorsque vous avez besoin d'une transformation ponctuelle dans un seul site d'appel et ne souhaitez pas définir une classe réutilisable.

import json

def custom_json(obj):
    if isinstance(obj, complex):
        return {"__complex__": True, "real": obj.real, "imag": obj.imag}
    raise TypeError(f"Cannot serialize object of {type(obj)}")

print(json.dumps(1 + 2j, default=custom_json))

Extension du comportement via une sous-classe JSONEncoder

Lorsque la même logique de sérialisation doit être réutilisée sur plusieurs sites d'appel ou lorsque vous devez configurer des paramètres de niveau encodeur tels que indent ou ensure_ascii conjointement avec la gestion de types personnalisés, la sous-classation de JSONEncoder est l'option plus propre. Vous écrasez la méthode default et appelez super().default(o) pour les types non gérés afin que la TypeError standard soit levée avec le message propre à la bibliothèque. La sous-classe est passée à json.dumps ou json.dump via le paramètre cls.

import json

class SimpleEncoder(json.JSONEncoder):
    def default(self, o):
        if isinstance(o, complex):
            return {"__complex__": True, "real": o.real, "imag": o.imag}
        return super().default(o)

print(json.dumps({"z": 3 + 4j}, cls=SimpleEncoder))

Choix entre une fonction default et une sous-classe JSONEncoder

Utilisez une fonction default simple lorsque la transformation est spécifique à un appel, que la logique est courte et que vous n'avez pas besoin de partager la configuration de l'encodeur. La fonction est passée en ligne et disparaît après l'appel. Utilisez une sous-classe JSONEncoder lorsque la logique doit être réutilisée, lorsque vous voulez combiner la gestion de types personnalisés avec des paramètres d'encodeur non par défaut tels que sort_keys ou un tuple separators personnalisé, ou lorsque la chaîne de transformation dépasse deux ou trois vérifications isinstance et qu'une fonction plate devient difficile à lire.

import json
from datetime import datetime, timezone

class ProjectEncoder(json.JSONEncoder):
    def default(self, o):
        if isinstance(o, datetime):
            return o.isoformat()
        if isinstance(o, complex):
            return {"__complex__": True, "real": o.real, "imag": o.imag}
        return super().default(o)

payload = {"ts": datetime(2024, 1, 15, tzinfo=timezone.utc), "z": 1+1j}
print(json.dumps(payload, cls=ProjectEncoder, sort_keys=True))

Encodage de dates, chemins et classes définies par l'utilisateur

Les objets datetime n'ont pas de mappage JSON. La représentation conventionnelle est la chaîne ISO 8601 produite par la méthode isoformat. Le côté décodeur utilise datetime.fromisoformat pour reconstruire la valeur. Les objets pathlib.Path sont sérialisés sous forme de chaîne via str(path) ; le décodeur enveloppe la chaîne avec Path(). Pour les classes définies par l'utilisateur le modèle recommandé est un dictionnaire marqueur qui inclut une clé de discriminateur de type, par exemple __type__ défini sur le nom de la classe, plus les champs nécessaires à la reconstruction.

import json
from datetime import datetime, timezone
from pathlib import Path

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

def encode(obj):
    if isinstance(obj, datetime):
        return obj.isoformat()
    if isinstance(obj, Path):
        return str(obj)
    if isinstance(obj, Point):
        return {"__type__": "Point", "x": obj.x, "y": obj.y}
    raise TypeError(f"Unsupported type {type(obj)}")

def decode(dct):
    if dct.get("__type__") == "Point":
        return Point(dct["x"], dct["y"])
    return dct

p = Point(3, 4)
s = json.dumps(p, default=encode)
print(s)
restored = json.loads(s, object_hook=decode)
print(type(restored).__name__, restored.x, restored.y)

Paramètres d'encodeur qui affectent la sortie

ensure_ascii contrôle si les caractères non-ASCII sont échappés en séquences \uXXXX ou émis tels quels. Lorsque votre default personnalisé retourne des chaînes contenant du texte non-ASCII ce paramètre change la représentation binaire mais pas le contenu logique. indent ajoute des espaces blancs pour la lisibilité et modifie les séparateurs par défaut de (',', ':') à (', ', ': '). sort_keys trie les clés de dictionnaire alphabétiquement avant la conversion en chaîne, ce qui est utile pour une sortie déterministe dans les tests. separators permet de produire une sortie compacte en passant (',',':'). allow_nan régit si NaN, Infinity et -Infinity sont encodés comme littéraux JavaScript ou lèvent ValueError. check_circular détecte les cycles de référence dans les conteneurs et lève ValueError lorsqu'il est activé ; s'il est désactivé un cycle cause RecursionError. skipkeys supprime silencieusement les clés de dictionnaire qui ne sont pas str, int, float, bool ou None au lieu de lever TypeError.

import json
from datetime import datetime

class Enc(json.JSONEncoder):
    def default(self, o):
        if isinstance(o, datetime):
            return o.isoformat()
        return super().default(o)

print(json.dumps({"b": 1, "a": datetime(2024,1,1)}, cls=Enc, sort_keys=True, indent=2))

Gestion des erreurs et limitations de portabilité

Lorsque default ou la méthode default de l'encodeur lève TypeError l'appel dumps entier échoue ; il n'y a pas de sortie partielle. Cela signifie qu'un objet non supporté quelque part dans l'arbre annule toute la sérialisation. Concevez votre fonction default pour lever TypeError avec un message clair nommant le type fautif afin que le débogage soit rapide. JSON n'est pas un protocole encadré. Des appels répétés à json.dump avec le même objet fichier produisent des documents concaténés qui ne sont pas valides JSON. Les clés qui ne sont pas des chaînes deviennent des chaînes après aller-retour, donc loads(dumps(x)) peut ne pas être égal à x si x avait des clés entières ou tuple. Les très grands entiers et les valeurs decimal.Decimal peuvent dépasser la précision des consommateurs IEEE 754 double précision. La documentation met en garde contre les JSON malveillants qui peuvent consommer beaucoup de CPU et mémoire, donc limitez la taille d'entrée lors de l'analyse de données non fiables.

import json, io
buf = io.StringIO()
json.dump({"a": 1}, buf)
json.dump({"b": 2}, buf)  # second call makes the file invalid
print(buf.getvalue())  # {"a": 1}{"b": 2} - not valid JSON

Exemple pratique combinant nombres complexes et une classe personnalisée

L'exemple complet suivant montre les deux approches côte à côte. Une fonction default autonome gère les nombres complexes sur un site d'appel. Une sous-classe JSONEncoder gère les nombres complexes et une classe Event personnalisée sur un autre site d'appel, combinée avec sort_keys pour une sortie déterministe. Le côté décodeur utilise object_hook pour reconstruire les deux types à partir de leurs dictionnaires marqueurs. Cet exemple fonctionne sur Python 3.6 et ultérieur. Il ne nécessite aucun package tiers. La seule prérequisite est la familiarité avec isinstance, les dictionnaires et l'API de base du module json.

import json

class Event:
    def __init__(self, name, ts, payload):
        self.name = name
        self.ts = ts
        self.payload = payload

# Approach 1: standalone default function
def complex_default(obj):
    if isinstance(obj, complex):
        return {"__complex__": True, "real": obj.real, "imag": obj.imag}
    raise TypeError(f"Cannot serialize {type(obj)}")

s1 = json.dumps(1 + 2j, default=complex_default)
print(s1)

# Approach 2: reusable JSONEncoder subclass
class AppEncoder(json.JSONEncoder):
    def default(self, o):
        if isinstance(o, complex):
            return {"__complex__": True, "real": o.real, "imag": o.imag}
        if isinstance(o, Event):
            return {"__type__": "Event", "name": o.name,
                    "ts": o.ts, "payload": o.payload}
        return super().default(o)

s2 = json.dumps({"z": 3+4j, "ev": Event("click", 100, {"x": 1})},
                cls=AppEncoder, sort_keys=True)
print(s2)

# Round-trip decode
def app_hook(dct):
    if dct.get("__complex__"):
        return complex(dct["real"], dct["imag"])
    if dct.get("__type__") == "Event":
        return Event(dct["name"], dct["ts"], dct["payload"])
    return dct

restored = json.loads(s2, object_hook=app_hook)
print(type(restored["z"]).__name__, restored["ev"].name)

Points à vérifier

  • json.dumps sans default ou cls lève TypeError pour complex, datetime, Path ou instances de classes arbitraires
  • Une fonction default doit lever TypeError pour les types non gérés ; retourner None encode silencieusement null
  • Une sous-classe JSONEncoder doit appeler super().default(o) pour les types non gérés afin de préserver le message d'erreur standard
  • La fidélité aller-retour nécessite un object_hook correspondant au côté décodeur qui vérifie la clé marqueur
  • Des appels répétés à json.dump vers le même objet fichier produisent une sortie concaténée invalide
  • ensure_ascii, indent, sort_keys et separators affectent la représentation mais pas le contenu logique produit par default
  • allow_nan=False transforme l'encodage de NaN et Infinity en ValueError
  • check_circular=True détecte les cycles dans les objets encodés personnalisés et lève ValueError

Cet article couvre uniquement le module json de la bibliothèque standard sur CPython 3.6 et ultérieur. Il n'aborde pas les sérialisateurs tiers tels que orjson, ujson ou rapidjson qui ont leurs propres mécanismes d'extension. Les garanties aller-retour discutées ici dépendent entièrement de la cohérence entre vos marqueurs d'encodage et les crochets de décodage ; la bibliothèque elle-même ne fournit aucun registre de type ni validation de schéma. Les très grands entiers et les valeurs decimal.Decimal peuvent perdre de la précision dans les consommateurs JSON qui analysent les nombres comme des doubles IEEE 754. Les limites de taille d'entrée pour le JSON non fiable relèvent de la responsabilité de l'application, pas du module.

Sources

  1. Python: json ↗
  2. Python: pathlib ↗
  3. Python: os and working directories ↗
Retour en haut ↑