Faire manger du datetime à JSON en Python


La guerre de la sérialisation a plus ou moins été gagnée par JSON. Le XML est relégué aux documents très riches et aux systèmes legacy. Le YML est cantonné a des niches (et en plus souvent des niches en Ruby, c’est dire !). Et les formats binaires, sont gardés pour les besoins de perf. Le reste, c’est du JSON, du JSON et du JSON.

Seulement le JSON ne permet pas de sauvegarder des dates, seulement des chaînes, des entiers, des booléens et null. Heureusement on peut créer son propre dialecte au dessus de JSON pour y remédier, mais il faut avoir un un parseur qui le gère.

En Python on peut créer sa propre classe d’encodeur et décodeur de JSON et donc techniquement ajouter n’importe quel type.

Voici une recette pour en créer un qui gère le type datetime de manière transparente :

import re
import json
 
from datetime import datetime
 
# On hérite simplement de l'encodeur de base pour faire son propre encodeur
class JSONEncoder(json.JSONEncoder):
 
    # Cette méthode est appelée pour serialiser les objets en JSON
    def default(self, obj):
        # Si l'objet est de type datetime, on retourne une chaîne formatée
        # représentant l'instant de manière classique
        # ex: "2014-03-09 19:51:32.7689"
        if isinstance(obj, datetime):
            return obj.strftime('%Y-%m-%d %H:%M:%S.%f')
        return json.JSONEncoder.default(self, obj)
 
 
# On fait l'opération exactement inverse pour le décodeur
class JSONDecoder(json.JSONDecoder):
 
 
    # On écrase la méthode qui permet de décoder les paires clé / valeur
    # du format JSON afin que chaque valeur passe par notre moulinette
    def object_pairs_hook(self, obj):
        return dict((k, self.decode_on_match(v)) for k, v in obj)
 
 
    # notre moulinette
    def decode_on_match(self, obj):
 
        # une petite regex permet de savoir si la chaine est une date
        # sérialisée selon notre format précédent
        match = re.search(r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{6}', unicode(obj))
        # si oui, on parse et on retourne le datetime
        if match:
            return datetime.strptime(match.string, self.datetime_format)
 
        # sinon on retourne l'objet tel quel
        return obj
 
# On se fait des raccourcis pour loader et dumper le json
 
def json_dumps(data):
    return JSONEncoder().encode(data)
 
 
def json_loads(string):
    return JSONDecoder().decode(string)

Usage :

>>> res = json_dumps({'test': datetime(2000, 1, 1, 1, 1, 1), 'autre': [True, 1]})
>>> print(type(res), res)
(<type 'str'>, '{"test": "2000-01-01 01:01:01.000000", "autre": [true, 1]}')
>>> res = json_loads(res)
>>> print(type(res), res)
(<type 'dict'>, {u'test': u'2000-01-01 01:01:01.000000', u'autre': [True, 1]})

Minibelt contient une version un peu plus élaborée de ce code qui prend en compte les types date, time et timedelta ainsi que pas mal d’options de configuration.

6 thoughts on “Faire manger du datetime à JSON en Python

  • Badeu

    isinstance ? Erf…

    Pourquoi ne pas utiliser ça dans le sérialiseur plutôt ?

    if hasattr(obj, 'isoformat'):
        return obj.isoformat()

    Rapide, pas cher, standard … et ça se fait bien manger par toutes les libs de tous les langages. Et puis après, il suffit d’avoir le désérialiseur idoine.

  • floweb

    Comme Badu, je préfère la méthode .isoformat(), il vaut mieux garder les standards… ISO 8601 (UTC format)

  • Mathieu

    +1, mieux vaut s’en tenir à isoformat, surtout qu’avec la méthode actuelle on perdrait les timezone des datetime conscient, ce qui est grave.

  • Sam Post author

    Tous des points parfaitement valides. Effectivement, ce seraient de bonnes améliorations.

  • OlyGrim

    Dans l’exemple, la désérialisation de la date ne renvoie pas un datetime. C’est toujours une chaîne de caractère.

    J’ai pas trop compris pourquoi (peut-être en as-tu parlé dans le guide ultime et définitif sur la POO) mais apparemment la méthode decode n’utilise pas l’attribut object_pairs_hook de l’instance mais de la classe parente. Pour corriger le tir, j’ai fait ceci:

    import json
    
    class Decoder(json.JSONDecoder):
        def __init__(self, format, **kwargs):
            if not kwargs.get("object_pairs_hook", None):
                kwargs["object_pairs_hook"] = self.object_pairs_hook
            super().__init__(**kwargs)
            self.format = format
    
    
    #main
    donnees = {"date": datetime.now(), "valeur": 160}
    donnees_dumps = JSONEncoder().encode(donnees)
    donnees_loads = Decoder(format='%Y-%m-%d').decode(donnees_dumps)

    Avec ça, le print(donnees_loads) renvoie bien undatetime.

    Mais j’avoue ne pas comprendre pourquoi cet attribut n’est pas pris en compte, alors que si je redéfini l’attribut indent dans l’encodeur, lui est bien pris en compte.

    Juste 2 petites remarques: Dans la méthode decode_on_match de ton JSONDecoder, tu n’as pas défini self.datetime_format. Et object_pairs_hook n’est pas à la base une méthode mais un attribut (qui attend un callable).

    PS: Désolé pour le formatage de mon code. J’ai bien utilisé les balises pre mais dans le pré-visualiseur le code n’apparaît pas correctement.

  • Will

    Une autre solution que je trouve simple et lisible :

    <

    pre>from datetime import datetime

    def json_encoder(o):

    if isinstance(o, datetime):

    return {“isoformat”: o.isoformat(), “class“: str(o.class)}

    if isinstance(o, (CustomClass1, CustomClass2)):

    dic = o.dict

    dic[“class“] = str(o.class)

    return dic

    raise TypeError(“CustomClass or datetime expected.”)

       
    def json_decoder(dic):
        if "__class__" in dic:
            if dic["__class__"] == str(CustomClass1):
                return CustomClass1(**dic)
            if dic["__class__"] == str(datetime):
                return datetime.strptime(dic["isoformat"], "%Y-%m-%dT%H:%M:%S")  # ajoutez ".%f" pour les ms
            raise TypeError("CustomClass or datetime expected.")
    
    #main
    with open("test.json", "w") as f:
            json.dump(myobject, f, default=json_encoder, indent=4)
    with open("test.json", "r") as f:
        myobject = json.load(f, object_hook=json_decoder)
    

Comments are closed.

Des questions Python sans rapport avec l'article ? Posez-les sur IndexError.