xml-cmpr
Compare des documents XML produits par deux chaînes de production différentes et produit un rapport des différences, fichier par fichier.
L'outil parcourt la source A, cherche dans la source B le fichier de même chemin relatif (les fichiers sans correspondance sont ignorés), compare les deux documents sur leur forme canonique, et écrit un rapport Markdown et/ou JSON dans le répertoire de sortie.
Chaque source est soit un répertoire, soit une archive ZIP — les deux peuvent se mélanger.
Installation
Le paquet est publié sur PyPI sous le nom xml-cmpr, qui est aussi celui de la
commande installée :
uv tool install xml-cmpr # ou : pipx install xml-cmpr
xml-cmpr --help
Python 3.13 ou 3.14 est requis (le socle de saxonche, qui apporte le moteur XSLT).
Le paquet lui-même est en Python pur, mais saxonche est publié sans source.
L'installation n'aboutit donc que sur les plateformes pour lesquelles il existe la librairie a été publiée :
| Système | Architectures |
|---|---|
| Linux (glibc 2.24 ou plus, « manylinux ») | x86_64, aarch64 |
| macOS (11 ou plus) | x86_64, arm64 |
| Windows | x86_64 |
Utilisation
uv run xml-cmpr SOURCE_A SOURCE_B REPERTOIRE_SORTIE
où SOURCE_A et SOURCE_B sont chacun un répertoire ou une archive ZIP :
uv run xml-cmpr chaine_a/ chaine_b/ rapport/
uv run xml-cmpr chaine_a.zip chaine_b.zip rapport/
uv run xml-cmpr chaine_a.zip chaine_b/ rapport/
Produit REPERTOIRE_SORTIE/rapport.md et REPERTOIRE_SORTIE/rapport.json
(--format choisit les formats écrits) :
# Rapport de comparaison XML
- **Source A** : `tests/fixtures/a`
- **Source B** : `tests/fixtures/b`
- **Date** : 2026-08-18 11:45
- Fichiers analysés : **5** · identiques : **1** · différents : **3** · en erreur : **1**
- Ignorés (absents de la source B) : **1**
## Fichiers différents
### `factures/facture_001.xml` — 1 différence
| Chemin | Type | A | B |
|---|---|---|---|
| `/Facture/Lignes/Ligne[2]` | élément ajouté en B | — | `<Ligne id="2">deux` |
Options
| Option | Effet |
|---|---|
--pattern GLOB |
motif des fichiers de A (défaut : *.xml) |
--no-recursive |
ne pas descendre dans les sous-répertoires |
--keep-whitespace |
ne pas normaliser les espaces des nœuds texte |
--exclude FICHIER |
fichier contenant des expressions XPath à exclure de la comparaison |
--max-diffs-per-file N |
différences détaillées par fichier (défaut : 50, 0 = illimité) |
--no-size-limit |
extraire une archive ZIP quels que soient sa taille et son nombre d'entrées. Par défaut les limites sont fixées à 1 Go ou de 100 000 fichiers. |
--format FORMATS |
formats du rapport : md, json (défaut : md,json) |
--fail-on-diff |
code de sortie 1 si au moins une différence |
--fail-on-error |
code de sortie 3 si au moins un fichier est illisible |
-v, --verbose |
progression sur stderr |
Codes de sortie : 0 succès · 1 différences détectées (avec --fail-on-diff) ·
2 erreur d'usage (source introuvable ou illisible, sortie inutilisable, XPath
d'exclusion invalide, format inconnu) · 3 fichiers en erreur (avec
--fail-on-error).
Un fichier XML malformé n'interrompt pas la campagne : il est reporté comme fichier en
erreur, et sans --fail-on-error la comparaison se termine, avec un code retour 0.
Le rapport JSON
rapport.json contient le même bilan que le Markdown, sous une forme directement
consommable par un autre traitement :
{
"summary": {
"source_a": "chaine_a",
"source_b": "chaine_b",
"generated_at": "2026-08-18T11:45:12+02:00",
"analyzed": 5,
"identical": 1,
"different": 3,
"errors": 1,
"skipped": 1
},
"files": [
{
"path": "factures/facture_001.xml",
"status": "different",
"differences": [
{
"path": "/Facture/Lignes/Ligne[2]",
"kind": "element_added",
"label": "élément ajouté en B",
"a": null,
"b": "<Ligne id=\"2\">deux"
}
]
},
{ "path": "identique.xml", "status": "identique" },
{ "path": "malforme.xml", "status": "erreur", "error": "SXXP0003 …" }
],
"skipped": ["absent_de_b.xml"]
}
generated_atdate la campagne, en ISO 8601 à la seconde, heure locale avec son décalage ; c'est la même date que celle du rapport Markdown.filesliste tous les fichiers analysés, un par chemin relatif ;statusvautidentique,differentouerreur.differencesn'apparaît que pour un fichierdifferent;errorque pour un fichiererreur;truncated(nombre de différences non détaillées) que si--max-diffs-per-filea coupé la liste.kindest la nature de la différence, stable et utilisable en code —element_added,element_removed,element_renamed,attribute_added,attribute_removed,attribute_changed,text_changed,text_added,text_removed— etlabelen est le libellé français affiché au rapport Markdown.aetbsont les valeurs de part et d'autre,nullquand la valeur n'existe pas du côté correspondant. Pourelement_renamed, ce sont les deux noms : le nom local quand l'élément n'a pas d'espace de noms (Date),{uri}localsinon ({urn:facture}Date).skippedliste les fichiers de A sans homologue dans B.
Sources en archive ZIP
Une source ZIP est extraite dans un répertoire temporaire, supprimé à la fin de la campagne ; la comparaison, les options et le rapport sont ensuite exactement les mêmes que pour un répertoire (le rapport nomme l'archive, pas le répertoire temporaire).
Une archive dont le contenu décompressé dépasse 1 Gio, ou qui compte plus de
100 000 entrées (fichiers et répertoires), est refusée (code 2), avant toute
extraction : les bornes valent pour chaque archive séparément, et ne concernent pas une
source répertoire, qui n'est pas copiée. --no-size-limit lève les deux limites.
Utiliser la comparaison depuis Python
Le pipeline se décompose en trois étapes indépendantes : analyse, formatage, écriture. Le formatage retourne une chaîne qui n'est pas directement écrite sur le disque, elle peut donc être utilisée dans une réponse HTTP ou vers un autre traitement.
from pathlib import Path
from xml_cmpr import ReportFormat, render, run_comparison, write_report
summary = run_comparison(Path("chaine_a"), Path("chaine_b")) # analyse -> RunSummary
corps = render(summary, ReportFormat.JSON) # formatage -> str
# ... ou render(summary, "md") pour le Markdown
write_report(summary, Path("rapport"), "json") # écriture -> Path (facultatif)
| Étape | Fonction | Rend |
|---|---|---|
| analyse | run_comparison(source_a, source_b, **options) |
RunSummary |
| formatage | render(summary, "md"|"json") |
str |
summary.as_dict() |
dict (JSON sans passer par la chaîne) |
|
| écriture | write_report(summary, dossier, format) |
Path du fichier écrit |
write_reports(summary, dossier, formats) |
list[Path] |
run_comparison accepte les mêmes options que le CLI (pattern, recursive,
keep_whitespace, exclude_xpaths, max_diffs_per_file), et
label_a / label_b pour choisir les chemins affichés au rapport. Pour une source ZIP,
as_directory(source) est le context manager qui extrait l'archive. Il applique la même
bornes que le CLI, MAX_EXTRACTED_SIZE (1 Gio) et MAX_ENTRIES (100 000) ; size_limit=
(en octets) et entry_limit= les changent une à une, et seul un None explicite lève la
borne concernée :
from pathlib import Path
from xml_cmpr import as_directory, run_comparison
with as_directory(Path("chaine_a.zip")) as dir_a:
summary = run_comparison(dir_a, Path("chaine_b"), label_a=Path("chaine_a.zip"))
Les exceptions
Les exceptions de xml_cmpr sont dérivées d'Exception :
| Exception | Levée par | Cause |
|---|---|---|
ExcludeError |
run_comparison, Canonicalizer |
un XPath d'exclusion est invalide |
ArchiveError |
as_directory |
source introuvable, archive illisible, chemin d'entrée suspect |
ArchiveSizeError |
as_directory |
sous-classe d'ArchiveError : archive au-delà de size_limit ou d'entry_limit (attributs size, limit, et measure : "bytes" ou "entries") |
CanonicalError |
Canonicalizer.load |
un document est illisible ou malformé |
Exclure des éléments ou attributs de la comparaison
--exclude FICHIER prend un fichier texte listant un XPath par ligne. Une ligne dont le
premier caractère non blanc est # est un commentaire ; ailleurs dans la ligne, le #
fait partie du XPath — il n'y a pas de commentaire de fin de ligne, justement pour que
les URI de namespace terminées par # restent écrivables.
# ignorer l'horodatage de génération et l'identifiant technique
//DateGeneration
//Facture/@idTechnique
//Q{http://www.w3.org/1999/02/22-rdf-syntax-ns#}Description
Chaque élément ou attribut sélectionné par l'un de ces XPath, dans A comme dans B, est retiré de la forme canonique avant le calcul de l'empreinte : il ne compte ni pour le test d'identité, ni dans le diff. Un élément trouvé dans 1 XPath exclut tout son sous-arbre.
Le XPath est évalué par Saxon directement sur le document source, sans les préfixes de
namespace déclarés dans le document. Pour un nom
sans espace de noms, écrire //DateGeneration ; pour un nom avec espace de noms,
utiliser la notation Q{uri}local, par ex. //Q{urn:facture}DateGeneration.
Ce que « identique » veut dire
La comparaison porte sur une forme canonique produite par Saxon
(normalize.xsl). Ne sont pas des
différences :
- l'indentation et les retours à la ligne (texte normalisé) ;
- l'ordre des attributs (triés par nom) ;
- les préfixes de namespace (les noms sont comparés par URI, en notation de Clark) ;
- les commentaires et instructions de traitement, où qu'ils soient ;
- la déclaration XML, l'encodage, les sections CDATA.
Commentaires et instructions de traitement
Ils ne sont jamais comparés, et aucune option ne les réintègre. Ni ceux placés dans
un élément, ni ceux du prologue — la bannière de génération qu'une chaîne écrit avant
l'élément racine, la PI <?xml-stylesheet?> qui désigne une feuille de style.
Le contenu mixte
Quand un élément contient à la fois du texte et des éléments, le texte n'est pas
agrégé : chaque nœud texte garde sa place parmi les enfants, et se désigne au rapport
par le segment #text (numéroté quand il y en a plusieurs).
A : <p>Voir la <ref>note</ref> ci-dessous.</p>
B : <p>Voir la ci-dessous.<ref>note</ref></p>
| Chemin | Type | A | B |
|---|---|---|---|
/p/#text[1] |
texte modifié | Voir la |
Voir la ci-dessous. |
/p/#text[2] |
texte absent de B | ci-dessous. |
— |
Les deux documents sont considérés comme différents.
Licence
Distribué sous licence MIT — © 2026 Frédéric Laurent.
Metadata
Release files for xml-cmpr 0.2.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| xml_cmpr-0.2.1.tar.gz | 40.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| xml_cmpr-0.2.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 69.0 kB
Release files / xml_cmpr-0.2.1.tar.gz
| Download URL | xml_cmpr-0.2.1.tar.gz |
|---|---|
| Size | 40.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9d66546e5607bda267b4e92c62000fdb165edc65ec380cd5bedb8667423f985a
|
|
BLAKE2b-256 checksum How to use checksums |
5c9eb979f879ee77d693098cd7a84e85750e34fdaf5d9773616a863f91e2ee21
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"13","id":"trixie","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / xml_cmpr-0.2.1-py3-none-any.whl
| Download URL | xml_cmpr-0.2.1-py3-none-any.whl |
|---|---|
| Size | 28.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d53f80a001d16e2e6b80f7c3f54e38287cfbd184c038a62aab74f3d0f26c178e
|
|
BLAKE2b-256 checksum How to use checksums |
780a53c11bb03b8aa4ec11cf4dd6fee682f533c579c912eb65be2418891393a9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"13","id":"trixie","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|