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, en429: trop de requêtes rapprochées pour la clé. L'en-têteRetry-Afterdonne les secondes à attendre ;reponse.headersse lit sans égard à la casse. -
quota-exceeded, en429aussi, sansRetry-After: les validations de la période sont consommées, attendre n'y change rien. -
Un
429en HTML : la limite de débit du serveur frontal. Sans le test duContent-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 defiles=. Le contenu part comme un champ de formulaire ordinaire, sans fichier, et l'API répond par un400. -
Un
Content-Typeposé à la main dansheaders: il remplace celui querequestsconstruit, la frontière disparaît, et le service refuse l'envoi d'emblée. La réponse est un400en 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 OSErrorplacé en premier.requests.exceptions.RequestExceptionhérite d'OSError: écrit avant,except OSErrorattrape aussi les délais dépassés et les coupures réseau, et les présente comme un fichier illisible. Le script attrapeRequestExceptiond'abord. -
Un appel hors du bloc
with. Le fichier est déjà fermé, etrequestslève uneValueErroren 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