Metadata-Version: 2.5
Name: xml-cmpr
Version: 0.2.1
Summary: Compare des documents XML issus de deux sources et produit un rapport des différences
Project-URL: Homepage, https://framagit.org/opikanoba/xml-compare
Project-URL: Repository, https://framagit.org/opikanoba/xml-compare.git
Project-URL: Issues, https://framagit.org/opikanoba/xml-compare/-/issues
Author-email: Frédéric Laurent <flt@opikanoba.org>
License-Expression: MIT
License-File: LICENSE
Keywords: canonicalisation,comparaison,diff,saxon,xml,xslt
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Natural Language :: French
Classifier: Operating System :: MacOS
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Topic :: Text Processing :: Markup :: XML
Requires-Python: >=3.13
Requires-Dist: saxonche>=13.0.0
Description-Content-Type: text/markdown

# 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 :

```sh
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

```sh
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 :

```sh
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) :

```markdown
# 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 :

```json
{
  "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_at` date la campagne, en ISO 8601 à la seconde, heure locale avec son
  décalage ; c'est la même date que celle du rapport Markdown.
- `files` liste **tous** les fichiers analysés, un par chemin relatif ;
  `status` vaut `identique`, `different` ou `erreur`.
- `differences` n'apparaît que pour un fichier `different` ; `error` que pour un fichier
  `erreur` ; `truncated` (nombre de différences non détaillées) que si
  `--max-diffs-per-file` a coupé la liste.
- `kind` est 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`
  — et `label` en est le libellé français affiché au rapport Markdown.
- `a` et `b` sont les valeurs de part et d'autre, `null` quand la valeur n'existe pas
  du côté correspondant. Pour `element_renamed`, ce sont les deux noms : le nom local
  quand l'élément n'a pas d'espace de noms (`Date`), `{uri}local` sinon
  (`{urn:facture}Date`).
- `skipped` liste 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.

```python
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 :

```python
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`](https://framagit.org/opikanoba/xml-compare/-/blob/main/src/xml_cmpr/resources/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](LICENSE) — © 2026 Frédéric Laurent.
