Développeur

Valider une facture en Python

Votre facturation est écrite en Python ? Contrôlez chaque facture avant de l'envoyer, avec la bibliothèque requests. Voici un script complet : ouverture du fichier, envoi, délai d'attente, lecture du verdict et traitement de chaque refus, dans l'ordre où il faut les lire.

Avant de commencer

  • Python 3.10 ou plus récent, et requests, la seule dépendance du script. La bibliothèque standard sait faire un appel HTTP, mais pas construire un envoi de fichier en plusieurs parties sans une vingtaine de lignes écrites à la main.
  • Une clé API, créée sur votre page de clés API, copiée dès sa création. Le script la lit dans la variable d'environnement VALIDE_FACTURE_API_KEY : elle ne doit figurer ni dans le code, ni dans un fichier versionné.
  • Une facture : un PDF Factur-X, ou un XML CII ou UBL.

Installez requests dans l'environnement virtuel de votre projet :

python -m pip install requests

Le script complet

Enregistrez-le sous le nom valider.py :

"""Valide une facture : python valider.py facture.pdf

La clé API est lue dans la variable d'environnement VALIDE_FACTURE_API_KEY.
Code de sortie : 0 conforme, 1 autre verdict, 3 refus ou erreur technique.
"""
import os
import sys

import requests


def valider(chemin, cle):
    try:
        with open(chemin, "rb") as fichier:
            reponse = requests.post(
                "https://valide-facture.fr/api/validate",
                headers={"Authorization": f"Bearer {cle}"},
                files={"file": (os.path.basename(chemin), fichier)},
                timeout=35,
            )
    except requests.exceptions.RequestException as erreur:
        print(f"Pas de réponse du service ({type(erreur).__name__}) : réessayez plus tard.", file=sys.stderr)
        return 3
    except OSError as erreur:
        print(f"Fichier illisible : {erreur}", file=sys.stderr)
        return 3

    if reponse.status_code == 200:
        rapport = reponse.json()
        print(f"Verdict : {rapport['verdict']}")
        for constat in rapport["findings"]:
            print(f"- [{constat['severity']}] {constat['code']} : {constat['messageFr']}")
        return 0 if rapport["verdict"] == "CONFORME" else 1

    # Un refus du service est en application/problem+json. Un refus en amont (proxy,
    # limite de débit du frontal) peut arriver en HTML : on ne le décode pas.
    if not reponse.headers.get("Content-Type", "").startswith("application/problem+json"):
        print(f"Refus HTTP {reponse.status_code}, sans détail lisible : réessayez dans quelques instants.", file=sys.stderr)
        return 3

    probleme = reponse.json()
    print(f"Refus HTTP {reponse.status_code} : {probleme['detail']}", file=sys.stderr)
    if probleme["type"] == "https://valide-facture.fr/problems/rate-limit-exceeded":
        print(f"Trop de requêtes : réessayez dans {reponse.headers['Retry-After']} seconde(s).", file=sys.stderr)
    elif probleme["type"] == "https://valide-facture.fr/problems/quota-exceeded":
        print("Quota de la période atteint : réessayer ne servira à rien avant la période suivante.", file=sys.stderr)
    else:
        print(f"Type : {probleme['type']}", file=sys.stderr)
    return 3


if __name__ == "__main__":
    cle = os.environ.get("VALIDE_FACTURE_API_KEY")
    if len(sys.argv) != 2 or not cle:
        print("Usage : VALIDE_FACTURE_API_KEY=... python valider.py <facture>", file=sys.stderr)
        sys.exit(3)
    sys.exit(valider(sys.argv[1], cle))

Lancez-le sur une facture :

python valider.py facture.pdf

Dans votre application, importez valider() et remplacez les print par votre propre traitement : le rapport est le dictionnaire rapport, le corps d'erreur le dictionnaire probleme.

L'envoi du fichier avec files=

L'API attend un envoi multipart/form-data, avec le fichier dans le champ file. Avec requests, c'est le paramètre files : la clé du dictionnaire est le nom du champ, la valeur un couple (nom de fichier, flux). La bibliothèque construit l'envoi et pose elle-même l'en-tête Content-Type, frontière comprise.

Le fichier est ouvert en mode binaire ("rb") dans un bloc with, qui le referme même si l'appel lève une exception. Le flux est lu pendant l'envoi : l'appel doit donc rester dans le bloc with.

Le délai d'attente : ce que timeout=35 borne vraiment

Sans timeout, requests attend indéfiniment. Avec timeout=35, elle lève une exception si le service reste 35 secondes sans rien envoyer : c'est la durée que recommande la documentation de l'API. Le service ne répond rien avant d'avoir fini de juger, cette borne couvre donc l'attente du verdict.

Attention : ce délai vaut pour chaque attente, pas pour l'appel entier. L'envoi d'un gros fichier sur une connexion lente peut durer plus longtemps sans déclencher l'exception. Si votre traitement a besoin d'une durée totale garantie, exécutez l'appel dans un fil ou un processus que vous interrompez vous-même.

Un délai dépassé lève requests.exceptions.Timeout, un hôte injoignable requests.exceptions.ConnectionError. Les deux héritent de RequestException, que le script attrape : la facture n'a pas été jugée, renvoyez-la plus tard.

Lire la réponse avant raise_for_status()

Une facture non conforme est une facture jugée : elle arrive en 200, avec le rapport complet. Le verdict se lit dans rapport["verdict"].

N'appelez pas raise_for_status() avant d'avoir lu le corps d'erreur. Elle lève une HTTPError dont le message ne garde que le code et la raison HTTP : le type et le detail, qui disent quoi faire, ne sont plus lus. Le script vérifie l'en-tête Content-Type, puis branche sur probleme["type"] :

  • rate-limit-exceeded, en 429 : trop de requêtes rapprochées pour la clé. L'en-tête Retry-After donne les secondes à attendre ; reponse.headers se lit sans égard à la casse.
  • quota-exceeded, en 429 aussi, sans Retry-After : les validations de la période sont consommées, attendre n'y change rien.
  • Un 429 en HTML : la limite de débit du serveur frontal. Sans le test du Content-Type, reponse.json() lèverait une erreur de décodage sur ce corps.

Le script ne relance rien. Si votre traitement relance après un rate-limit-exceeded, attendez au moins Retry-After et bornez le nombre d'essais.

Les erreurs fréquentes en Python

  • Un fichier ouvert en texte. open(chemin) sans "b" décode les octets du PDF et échoue, ou les altère : le service reçoit un document qui n'est plus le vôtre.
  • data= au lieu de files=. Le contenu part comme un champ de formulaire ordinaire, sans fichier, et l'API répond par un 400.
  • Un Content-Type posé à la main dans headers : il remplace celui que requests construit, la frontière disparaît, et le service refuse l'envoi d'emblée. La réponse est un 400 en HTML : le programme affiche « Refus HTTP 400, sans détail lisible ».
  • Aucun timeout. Le programme peut rester bloqué sans fin sur une connexion qui ne répond plus.
  • Un except OSError placé en premier. requests.exceptions.RequestException hérite d'OSError : écrit avant, except OSError attrape aussi les délais dépassés et les coupures réseau, et les présente comme un fichier illisible. Le script attrape RequestException d'abord.
  • Un appel hors du bloc with. Le fichier est déjà fermé, et requests lève une ValueError en tentant de le lire.

Aller plus loin

La liste complète des refus et ce qu'il faut faire pour chacun est sur la page des guides du développeur. Chaque champ du rapport est décrit dans la documentation de l'API, et chaque code de règle a sa page parmi les codes d'erreur du validateur. Pour voir un rapport sans écrire de code, déposez une facture dans le validateur.

Portée de cette page

Ce script est un exemple à intégrer, pas une bibliothèque : aucun paquet n'est publié sur PyPI pour l'API. Le verdict porte sur la conformité technique du document au format et aux règles appliquées par le validateur ; il ne présume pas de l'acceptation de la facture par la plateforme de votre destinataire.

Développeur