Metadata-Version: 2.5
Name: xml-cmpr
Version: 0.2.0
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 homologue 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` n'est publié qu'en roues natives,
sans source à compiler. L'installation n'aboutit donc que sur les plateformes pour
lesquelles il existe une roue :

| Système | Architectures |
|---|---|
| Linux (glibc 2.24 ou plus, « manylinux ») | x86_64, aarch64 |
| macOS (11 ou plus) | x86_64, arm64 |
| Windows | x86_64 |

Ailleurs — Alpine et autres Linux musl, processeurs 32 bits, PyPy, Python 3.15 —,
l'installation échoue en résolvant `saxonche` (pip : « No matching distribution found
for saxonche » ; uv : aucune roue pour la plateforme courante) : ce message signale une
plateforme non prise en charge, pas un défaut du paquet.
Pour travailler sur le dépôt lui-même :

```sh
uv sync
```

## 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 de 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 (défaut : refus au-delà de 1 Gio ou de 100 000 entrées) |
| `--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 campagne se termine quand même en `0`**. En
intégration continue, poser les deux drapeaux : `--fail-on-diff` seul laisse passer un
corpus entièrement illisible. Quand les deux conditions sont réunies, `3` l'emporte —
une campagne incomplète est un résultat moins sûr qu'une campagne qui a tout comparé.

### 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 temporaire).

Si l'archive enveloppe tout son contenu dans un unique répertoire de tête, celui-ci est
traversé : deux archives dont les répertoires de tête s'appellent `chaine_a/` et
`chaine_b/` s'apparient quand même sur le chemin relatif interne.

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` les lève toutes les deux, et
explicitement — en connaissance de cause, le répertoire temporaire devant alors pouvoir
accueillir l'archive entière.

## Utiliser la comparaison depuis Python

Le pipeline se décompose en trois étapes indépendantes : **analyse**, **formatage**,
**écriture**. Le formatage retourne une chaîne — rien n'oblige à l'écrire sur disque,
elle peut partir directement dans une réponse HTTP ou vers le traitement suivant.

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

Toutes exportées par `xml_cmpr`, toutes 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é |

La distinction compte pour un appelant qui expose la comparaison : `ExcludeError` et
`ArchiveError` viennent de ce que l'appelant a fourni — c'est un `400`, pas un `500`
(`ArchiveSizeError`, attrapée avant, se prête plutôt à un `413`).
`CanonicalError`, lui, **ne traverse pas `run_comparison`** : la campagne l'attrape
fichier par fichier et le reporte comme fichier en erreur, sans s'interrompre. On ne le
voit qu'en appelant `Canonicalizer.load` soi-même.

### 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 exclu emporte 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 (un XPath autonome n'en hérite pas) : 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. Deux
documents qui ne diffèrent que par là sont `identique`.

C'est un choix de périmètre, pas une limite d'implémentation : l'outil compare ce que
les documents *disent* — éléments, attributs, texte — et un commentaire ne dit rien du
contenu. Deux chaînes de production qui datent et signent différemment leurs sorties ne
doivent pas produire un rapport plein de différences pour autant. Pour comparer une
bannière de génération, comparer les fichiers en texte, pas en XML.

Sont des différences : l'ordre des éléments frères, la présence ou la valeur d'un
attribut, le texte d'un élément, la présence d'un élément, et **la position du texte en
contenu mixte**.

Les éléments frères sont appariés d'abord par empreinte de sous-arbre, puis par nom :
insérer un `<Ligne>` au milieu d'une liste coûte **une** différence, pas un décalage en
cascade sur tous les suivants. Enfin, deux éléments qui ne diffèrent **que** par leur nom
(ou leur espace de noms) sont appariés et rapportés en `element_renamed`, à n'importe
quelle profondeur ; un élément à la fois renommé et modifié reste un `element_removed`
suivi d'un `element_added`.

### 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 ne disent pas la même chose : ils sont **différents**. Un élément
qui ne contient que du texte, lui, n'est pas concerné — c'est le cas courant, et son
texte reste comparé d'un bloc (`text_changed` sur le chemin de l'élément).

## Développement

```sh
uv run pytest                     # tests
uv run ruff check . && uv run ruff format .
```

## Publication

`publish.sh` enchaîne les étapes d'une livraison sur PyPI : contrôle du verrou, `ruff`,
`pytest`, construction des distributions, contrôle des métadonnées (`twine check`),
vérification que la roue embarque bien `normalize.xsl`, puis téléversement. Il refuse un
arbre de travail modifié ou une version déjà publiée, et ne pose aucun tag lui-même — il
rappelle la commande à la fin.

```sh
uv version --bump patch                 # 0.2.0 -> 0.2.1 (ou minor / major)
./publish.sh --test                     # répétition générale sur TestPyPI
./publish.sh                            # publication réelle sur PyPI
```

| Option | Effet |
|---|---|
| `--test` | publier sur TestPyPI (index `testpypi` déclaré dans `pyproject.toml`) |
| `--dry-run` | tout exécuter sauf le téléversement |
| `--skip-checks` | ne pas rejouer `ruff` et `pytest` |
| `--allow-dirty` | autoriser un arbre de travail modifié |

Le jeton d'API s'obtient, par ordre de préférence :

1. par une commande, `PUBLISH_TOKEN_CMD='pass show pypi/xml-cmpr' ./publish.sh` — le
   jeton ne transite ni par l'historique du shell ni par l'environnement ;
2. par saisie au clavier, sans écho, si rien n'est fourni et que le script tourne dans
   un terminal ;
3. par `UV_PUBLISH_TOKEN`, toléré pour la compatibilité mais déconseillé : exporté dans
   le profil, il est lisible par tous les processus de la session.

Quelle que soit sa source, le script retire le jeton de l'environnement dès son
démarrage et ne le remet qu'à `uv publish` : `pytest`, `uv build` et `twine` — donc les
dépendances de dev et les hooks de build — ne le voient jamais. Après la première
publication, remplacer le jeton global du compte par un jeton limité au projet.

Le jeton est celui du dépôt visé — un jeton PyPI ne vaut pas sur TestPyPI, et
réciproquement. Après un `--test`, l'installation se vérifie par
`uv tool install --index https://test.pypi.org/simple xml-cmpr`.
