xml-cmpr 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. xml_cmpr-0.2.0/.gitignore +15 -0
  2. xml_cmpr-0.2.0/LICENSE +21 -0
  3. xml_cmpr-0.2.0/PKG-INFO +389 -0
  4. xml_cmpr-0.2.0/README.md +363 -0
  5. xml_cmpr-0.2.0/docs/README.md +8 -0
  6. xml_cmpr-0.2.0/docs/analyses/README.md +18 -0
  7. xml_cmpr-0.2.0/pyproject.toml +66 -0
  8. xml_cmpr-0.2.0/src/xml_cmpr/__init__.py +76 -0
  9. xml_cmpr-0.2.0/src/xml_cmpr/archive.py +176 -0
  10. xml_cmpr-0.2.0/src/xml_cmpr/campaign.py +77 -0
  11. xml_cmpr-0.2.0/src/xml_cmpr/canonical.py +111 -0
  12. xml_cmpr-0.2.0/src/xml_cmpr/cli.py +261 -0
  13. xml_cmpr-0.2.0/src/xml_cmpr/diff.py +181 -0
  14. xml_cmpr-0.2.0/src/xml_cmpr/discovery.py +40 -0
  15. xml_cmpr-0.2.0/src/xml_cmpr/model.py +207 -0
  16. xml_cmpr-0.2.0/src/xml_cmpr/py.typed +0 -0
  17. xml_cmpr-0.2.0/src/xml_cmpr/report.py +184 -0
  18. xml_cmpr-0.2.0/src/xml_cmpr/resources/__init__.py +0 -0
  19. xml_cmpr-0.2.0/src/xml_cmpr/resources/normalize.xsl +94 -0
  20. xml_cmpr-0.2.0/tests/conftest.py +28 -0
  21. xml_cmpr-0.2.0/tests/fixtures/a/absent_de_b.xml +2 -0
  22. xml_cmpr-0.2.0/tests/fixtures/a/attributs.xml +4 -0
  23. xml_cmpr-0.2.0/tests/fixtures/a/factures/facture_001.xml +9 -0
  24. xml_cmpr-0.2.0/tests/fixtures/a/identique.xml +9 -0
  25. xml_cmpr-0.2.0/tests/fixtures/a/malforme.xml +2 -0
  26. xml_cmpr-0.2.0/tests/fixtures/a/mixte.xml +2 -0
  27. xml_cmpr-0.2.0/tests/fixtures/a/notes.txt +1 -0
  28. xml_cmpr-0.2.0/tests/fixtures/a/texte.xml +2 -0
  29. xml_cmpr-0.2.0/tests/fixtures/b/attributs.xml +4 -0
  30. xml_cmpr-0.2.0/tests/fixtures/b/factures/facture_001.xml +10 -0
  31. xml_cmpr-0.2.0/tests/fixtures/b/identique.xml +4 -0
  32. xml_cmpr-0.2.0/tests/fixtures/b/malforme.xml +2 -0
  33. xml_cmpr-0.2.0/tests/fixtures/b/mixte.xml +2 -0
  34. xml_cmpr-0.2.0/tests/fixtures/b/notes.txt +1 -0
  35. xml_cmpr-0.2.0/tests/fixtures/b/seulement_en_b.xml +2 -0
  36. xml_cmpr-0.2.0/tests/fixtures/b/texte.xml +2 -0
  37. xml_cmpr-0.2.0/tests/test_archive.py +253 -0
  38. xml_cmpr-0.2.0/tests/test_campaign.py +24 -0
  39. xml_cmpr-0.2.0/tests/test_canonical.py +132 -0
  40. xml_cmpr-0.2.0/tests/test_cli.py +234 -0
  41. xml_cmpr-0.2.0/tests/test_diff.py +178 -0
  42. xml_cmpr-0.2.0/tests/test_discovery.py +34 -0
  43. xml_cmpr-0.2.0/tests/test_docs.py +97 -0
  44. xml_cmpr-0.2.0/tests/test_report.py +223 -0
  45. xml_cmpr-0.2.0/uv.lock +134 -0
@@ -0,0 +1,15 @@
1
+ output/
2
+ /db/
3
+ .venv/
4
+ **/__pycache__/
5
+ *.log
6
+ data/
7
+ .env
8
+ temp/
9
+ build/
10
+ dist/
11
+ .idea/
12
+ **/.local
13
+ **/.claude
14
+ **/.obsidian/*
15
+ .coverage
xml_cmpr-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Frédéric Laurent
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,389 @@
1
+ Metadata-Version: 2.5
2
+ Name: xml-cmpr
3
+ Version: 0.2.0
4
+ Summary: Compare des documents XML issus de deux sources et produit un rapport des différences
5
+ Project-URL: Homepage, https://framagit.org/opikanoba/xml-compare
6
+ Project-URL: Repository, https://framagit.org/opikanoba/xml-compare.git
7
+ Project-URL: Issues, https://framagit.org/opikanoba/xml-compare/-/issues
8
+ Author-email: Frédéric Laurent <flt@opikanoba.org>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: canonicalisation,comparaison,diff,saxon,xml,xslt
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Natural Language :: French
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: Microsoft :: Windows
18
+ Classifier: Operating System :: POSIX :: Linux
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Software Development :: Quality Assurance
22
+ Classifier: Topic :: Text Processing :: Markup :: XML
23
+ Requires-Python: >=3.13
24
+ Requires-Dist: saxonche>=13.0.0
25
+ Description-Content-Type: text/markdown
26
+
27
+ # xml-cmpr
28
+
29
+ Compare des documents XML produits par **deux chaînes de production différentes** et
30
+ produit un rapport des différences, fichier par fichier.
31
+
32
+ L'outil parcourt la source A, cherche dans la source B le fichier de même chemin
33
+ relatif (les fichiers sans homologue sont ignorés), compare les deux documents sur leur
34
+ **forme canonique**, et écrit un rapport Markdown et/ou JSON dans le répertoire de
35
+ sortie.
36
+
37
+ Chaque source est soit un répertoire, soit une **archive ZIP** — les deux peuvent se
38
+ mélanger.
39
+
40
+ ## Installation
41
+
42
+ Le paquet est publié sur PyPI sous le nom **`xml-cmpr`**, qui est aussi celui de la
43
+ commande installée :
44
+
45
+ ```sh
46
+ uv tool install xml-cmpr # ou : pipx install xml-cmpr
47
+ xml-cmpr --help
48
+ ```
49
+
50
+ Python 3.13 ou 3.14 est requis (le socle de `saxonche`, qui apporte le moteur XSLT).
51
+
52
+ Le paquet lui-même est en Python pur, mais `saxonche` n'est publié qu'en roues natives,
53
+ sans source à compiler. L'installation n'aboutit donc que sur les plateformes pour
54
+ lesquelles il existe une roue :
55
+
56
+ | Système | Architectures |
57
+ |---|---|
58
+ | Linux (glibc 2.24 ou plus, « manylinux ») | x86_64, aarch64 |
59
+ | macOS (11 ou plus) | x86_64, arm64 |
60
+ | Windows | x86_64 |
61
+
62
+ Ailleurs — Alpine et autres Linux musl, processeurs 32 bits, PyPy, Python 3.15 —,
63
+ l'installation échoue en résolvant `saxonche` (pip : « No matching distribution found
64
+ for saxonche » ; uv : aucune roue pour la plateforme courante) : ce message signale une
65
+ plateforme non prise en charge, pas un défaut du paquet.
66
+ Pour travailler sur le dépôt lui-même :
67
+
68
+ ```sh
69
+ uv sync
70
+ ```
71
+
72
+ ## Utilisation
73
+
74
+ ```sh
75
+ uv run xml-cmpr SOURCE_A SOURCE_B REPERTOIRE_SORTIE
76
+ ```
77
+
78
+ où `SOURCE_A` et `SOURCE_B` sont chacun un répertoire ou une archive ZIP :
79
+
80
+ ```sh
81
+ uv run xml-cmpr chaine_a/ chaine_b/ rapport/
82
+ uv run xml-cmpr chaine_a.zip chaine_b.zip rapport/
83
+ uv run xml-cmpr chaine_a.zip chaine_b/ rapport/
84
+ ```
85
+
86
+ Produit `REPERTOIRE_SORTIE/rapport.md` et `REPERTOIRE_SORTIE/rapport.json`
87
+ (`--format` choisit les formats écrits) :
88
+
89
+ ```markdown
90
+ # Rapport de comparaison XML
91
+
92
+ - **Source A** : `tests/fixtures/a`
93
+ - **Source B** : `tests/fixtures/b`
94
+ - **Date** : 2026-08-18 11:45
95
+ - Fichiers analysés : **5** · identiques : **1** · différents : **3** · en erreur : **1**
96
+ - Ignorés (absents de la source B) : **1**
97
+
98
+ ## Fichiers différents
99
+
100
+ ### `factures/facture_001.xml` — 1 différence
101
+
102
+ | Chemin | Type | A | B |
103
+ |---|---|---|---|
104
+ | `/Facture/Lignes/Ligne[2]` | élément ajouté en B | — | `<Ligne id="2">deux` |
105
+ ```
106
+
107
+ ### Options
108
+
109
+ | Option | Effet |
110
+ |---|---|
111
+ | `--pattern GLOB` | motif des fichiers de A (défaut : `*.xml`) |
112
+ | `--no-recursive` | ne pas descendre dans les sous-répertoires |
113
+ | `--keep-whitespace` | ne pas normaliser les espaces des nœuds texte |
114
+ | `--exclude FICHIER` | fichier de XPath à exclure de la comparaison |
115
+ | `--max-diffs-per-file N` | différences détaillées par fichier (défaut : 50, `0` = illimité) |
116
+ | `--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) |
117
+ | `--format FORMATS` | formats du rapport : `md`, `json` (défaut : `md,json`) |
118
+ | `--fail-on-diff` | code de sortie 1 si au moins une différence |
119
+ | `--fail-on-error` | code de sortie 3 si au moins un fichier est illisible |
120
+ | `-v`, `--verbose` | progression sur stderr |
121
+
122
+ Codes de sortie : `0` succès · `1` différences détectées (avec `--fail-on-diff`) ·
123
+ `2` erreur d'usage (source introuvable ou illisible, sortie inutilisable, XPath
124
+ d'exclusion invalide, format inconnu) · `3` fichiers en erreur (avec
125
+ `--fail-on-error`).
126
+
127
+ Un fichier XML malformé n'interrompt pas la campagne : il est reporté comme fichier en
128
+ erreur, et **sans `--fail-on-error` la campagne se termine quand même en `0`**. En
129
+ intégration continue, poser les deux drapeaux : `--fail-on-diff` seul laisse passer un
130
+ corpus entièrement illisible. Quand les deux conditions sont réunies, `3` l'emporte —
131
+ une campagne incomplète est un résultat moins sûr qu'une campagne qui a tout comparé.
132
+
133
+ ### Le rapport JSON
134
+
135
+ `rapport.json` contient le même bilan que le Markdown, sous une forme directement
136
+ consommable par un autre traitement :
137
+
138
+ ```json
139
+ {
140
+ "summary": {
141
+ "source_a": "chaine_a",
142
+ "source_b": "chaine_b",
143
+ "generated_at": "2026-08-18T11:45:12+02:00",
144
+ "analyzed": 5,
145
+ "identical": 1,
146
+ "different": 3,
147
+ "errors": 1,
148
+ "skipped": 1
149
+ },
150
+ "files": [
151
+ {
152
+ "path": "factures/facture_001.xml",
153
+ "status": "different",
154
+ "differences": [
155
+ {
156
+ "path": "/Facture/Lignes/Ligne[2]",
157
+ "kind": "element_added",
158
+ "label": "élément ajouté en B",
159
+ "a": null,
160
+ "b": "<Ligne id=\"2\">deux"
161
+ }
162
+ ]
163
+ },
164
+ { "path": "identique.xml", "status": "identique" },
165
+ { "path": "malforme.xml", "status": "erreur", "error": "SXXP0003 …" }
166
+ ],
167
+ "skipped": ["absent_de_b.xml"]
168
+ }
169
+ ```
170
+
171
+ - `generated_at` date la campagne, en ISO 8601 à la seconde, heure locale avec son
172
+ décalage ; c'est la même date que celle du rapport Markdown.
173
+ - `files` liste **tous** les fichiers analysés, un par chemin relatif ;
174
+ `status` vaut `identique`, `different` ou `erreur`.
175
+ - `differences` n'apparaît que pour un fichier `different` ; `error` que pour un fichier
176
+ `erreur` ; `truncated` (nombre de différences non détaillées) que si
177
+ `--max-diffs-per-file` a coupé la liste.
178
+ - `kind` est la nature de la différence, stable et utilisable en code —
179
+ `element_added`, `element_removed`, `element_renamed`, `attribute_added`,
180
+ `attribute_removed`, `attribute_changed`, `text_changed`, `text_added`, `text_removed`
181
+ — et `label` en est le libellé français affiché au rapport Markdown.
182
+ - `a` et `b` sont les valeurs de part et d'autre, `null` quand la valeur n'existe pas
183
+ du côté correspondant. Pour `element_renamed`, ce sont les deux noms : le nom local
184
+ quand l'élément n'a pas d'espace de noms (`Date`), `{uri}local` sinon
185
+ (`{urn:facture}Date`).
186
+ - `skipped` liste les fichiers de A sans homologue dans B.
187
+
188
+ ### Sources en archive ZIP
189
+
190
+ Une source ZIP est extraite dans un répertoire temporaire, supprimé à la fin de la
191
+ campagne ; la comparaison, les options et le rapport sont ensuite exactement les mêmes
192
+ que pour un répertoire (le rapport nomme l'archive, pas le temporaire).
193
+
194
+ Si l'archive enveloppe tout son contenu dans un unique répertoire de tête, celui-ci est
195
+ traversé : deux archives dont les répertoires de tête s'appellent `chaine_a/` et
196
+ `chaine_b/` s'apparient quand même sur le chemin relatif interne.
197
+
198
+ Une archive dont le contenu **décompressé** dépasse **1 Gio**, ou qui compte plus de
199
+ **100 000 entrées** (fichiers et répertoires), est refusée (code `2`), avant toute
200
+ extraction : les bornes valent pour chaque archive séparément, et ne concernent pas une
201
+ source répertoire, qui n'est pas copiée. `--no-size-limit` les lève toutes les deux, et
202
+ explicitement — en connaissance de cause, le répertoire temporaire devant alors pouvoir
203
+ accueillir l'archive entière.
204
+
205
+ ## Utiliser la comparaison depuis Python
206
+
207
+ Le pipeline se décompose en trois étapes indépendantes : **analyse**, **formatage**,
208
+ **écriture**. Le formatage retourne une chaîne — rien n'oblige à l'écrire sur disque,
209
+ elle peut partir directement dans une réponse HTTP ou vers le traitement suivant.
210
+
211
+ ```python
212
+ from pathlib import Path
213
+
214
+ from xml_cmpr import ReportFormat, render, run_comparison, write_report
215
+
216
+ summary = run_comparison(Path("chaine_a"), Path("chaine_b")) # analyse -> RunSummary
217
+ corps = render(summary, ReportFormat.JSON) # formatage -> str
218
+ # ... ou render(summary, "md") pour le Markdown
219
+
220
+ write_report(summary, Path("rapport"), "json") # écriture -> Path (facultatif)
221
+ ```
222
+
223
+ | Étape | Fonction | Rend |
224
+ |---|---|---|
225
+ | analyse | `run_comparison(source_a, source_b, **options)` | `RunSummary` |
226
+ | formatage | `render(summary, "md"\|"json")` | `str` |
227
+ | | `summary.as_dict()` | `dict` (JSON sans passer par la chaîne) |
228
+ | écriture | `write_report(summary, dossier, format)` | `Path` du fichier écrit |
229
+ | | `write_reports(summary, dossier, formats)` | `list[Path]` |
230
+
231
+ `run_comparison` accepte les mêmes options que le CLI (`pattern`, `recursive`,
232
+ `keep_whitespace`, `exclude_xpaths`, `max_diffs_per_file`), et
233
+ `label_a` / `label_b` pour choisir les chemins affichés au rapport. Pour une source ZIP,
234
+ `as_directory(source)` est le context manager qui extrait l'archive. Il applique la même
235
+ bornes que le CLI, `MAX_EXTRACTED_SIZE` (1 Gio) et `MAX_ENTRIES` (100 000) ; `size_limit=`
236
+ (en octets) et `entry_limit=` les changent une à une, et seul un `None` explicite lève la
237
+ borne concernée :
238
+
239
+ ```python
240
+ from pathlib import Path
241
+
242
+ from xml_cmpr import as_directory, run_comparison
243
+
244
+ with as_directory(Path("chaine_a.zip")) as dir_a:
245
+ summary = run_comparison(dir_a, Path("chaine_b"), label_a=Path("chaine_a.zip"))
246
+ ```
247
+
248
+ ### Les exceptions
249
+
250
+ Toutes exportées par `xml_cmpr`, toutes dérivées d'`Exception` :
251
+
252
+ | Exception | Levée par | Cause |
253
+ |---|---|---|
254
+ | `ExcludeError` | `run_comparison`, `Canonicalizer` | un XPath d'exclusion est invalide |
255
+ | `ArchiveError` | `as_directory` | source introuvable, archive illisible, chemin d'entrée suspect |
256
+ | `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"`) |
257
+ | `CanonicalError` | `Canonicalizer.load` | un document est illisible ou malformé |
258
+
259
+ La distinction compte pour un appelant qui expose la comparaison : `ExcludeError` et
260
+ `ArchiveError` viennent de ce que l'appelant a fourni — c'est un `400`, pas un `500`
261
+ (`ArchiveSizeError`, attrapée avant, se prête plutôt à un `413`).
262
+ `CanonicalError`, lui, **ne traverse pas `run_comparison`** : la campagne l'attrape
263
+ fichier par fichier et le reporte comme fichier en erreur, sans s'interrompre. On ne le
264
+ voit qu'en appelant `Canonicalizer.load` soi-même.
265
+
266
+ ### Exclure des éléments ou attributs de la comparaison
267
+
268
+ `--exclude FICHIER` prend un fichier texte listant un XPath par ligne. Une ligne dont le
269
+ premier caractère non blanc est `#` est un commentaire ; ailleurs dans la ligne, le `#`
270
+ fait partie du XPath — il n'y a pas de commentaire de fin de ligne, justement pour que
271
+ les URI de namespace terminées par `#` restent écrivables.
272
+
273
+ ```
274
+ # ignorer l'horodatage de génération et l'identifiant technique
275
+ //DateGeneration
276
+ //Facture/@idTechnique
277
+ //Q{http://www.w3.org/1999/02/22-rdf-syntax-ns#}Description
278
+ ```
279
+
280
+ Chaque élément ou attribut sélectionné par l'un de ces XPath, dans A comme dans B, est
281
+ retiré de la forme canonique **avant** le calcul de l'empreinte : il ne compte ni pour
282
+ le test d'identité, ni dans le diff. Un élément exclu emporte tout son sous-arbre.
283
+
284
+ Le XPath est évalué par Saxon directement sur le document source, sans les préfixes de
285
+ namespace déclarés dans le document (un XPath autonome n'en hérite pas) : pour un nom
286
+ sans espace de noms, écrire `//DateGeneration` ; pour un nom avec espace de noms,
287
+ utiliser la notation `Q{uri}local`, par ex. `//Q{urn:facture}DateGeneration`.
288
+
289
+ ## Ce que « identique » veut dire
290
+
291
+ La comparaison porte sur une forme canonique produite par Saxon
292
+ ([`normalize.xsl`](https://framagit.org/opikanoba/xml-compare/-/blob/main/src/xml_cmpr/resources/normalize.xsl)). Ne sont **pas** des
293
+ différences :
294
+
295
+ - l'indentation et les retours à la ligne (texte normalisé) ;
296
+ - l'ordre des attributs (triés par nom) ;
297
+ - les préfixes de namespace (les noms sont comparés par URI, en notation de Clark) ;
298
+ - les commentaires et instructions de traitement, **où qu'ils soient** ;
299
+ - la déclaration XML, l'encodage, les sections CDATA.
300
+
301
+ ### Commentaires et instructions de traitement
302
+
303
+ Ils ne sont **jamais** comparés, et aucune option ne les réintègre. Ni ceux placés dans
304
+ un élément, ni ceux du prologue — la bannière de génération qu'une chaîne écrit avant
305
+ l'élément racine, la PI `<?xml-stylesheet?>` qui désigne une feuille de style. Deux
306
+ documents qui ne diffèrent que par là sont `identique`.
307
+
308
+ C'est un choix de périmètre, pas une limite d'implémentation : l'outil compare ce que
309
+ les documents *disent* — éléments, attributs, texte — et un commentaire ne dit rien du
310
+ contenu. Deux chaînes de production qui datent et signent différemment leurs sorties ne
311
+ doivent pas produire un rapport plein de différences pour autant. Pour comparer une
312
+ bannière de génération, comparer les fichiers en texte, pas en XML.
313
+
314
+ Sont des différences : l'ordre des éléments frères, la présence ou la valeur d'un
315
+ attribut, le texte d'un élément, la présence d'un élément, et **la position du texte en
316
+ contenu mixte**.
317
+
318
+ Les éléments frères sont appariés d'abord par empreinte de sous-arbre, puis par nom :
319
+ insérer un `<Ligne>` au milieu d'une liste coûte **une** différence, pas un décalage en
320
+ cascade sur tous les suivants. Enfin, deux éléments qui ne diffèrent **que** par leur nom
321
+ (ou leur espace de noms) sont appariés et rapportés en `element_renamed`, à n'importe
322
+ quelle profondeur ; un élément à la fois renommé et modifié reste un `element_removed`
323
+ suivi d'un `element_added`.
324
+
325
+ ### Le contenu mixte
326
+
327
+ Quand un élément contient à la fois du texte et des éléments, le texte n'est pas
328
+ agrégé : chaque nœud texte garde sa place parmi les enfants, et se désigne au rapport
329
+ par le segment `#text` (numéroté quand il y en a plusieurs).
330
+
331
+ ```
332
+ A : <p>Voir la <ref>note</ref> ci-dessous.</p>
333
+ B : <p>Voir la ci-dessous.<ref>note</ref></p>
334
+ ```
335
+
336
+ | Chemin | Type | A | B |
337
+ |---|---|---|---|
338
+ | `/p/#text[1]` | texte modifié | `Voir la` | `Voir la ci-dessous.` |
339
+ | `/p/#text[2]` | texte absent de B | `ci-dessous.` | — |
340
+
341
+ Les deux documents ne disent pas la même chose : ils sont **différents**. Un élément
342
+ qui ne contient que du texte, lui, n'est pas concerné — c'est le cas courant, et son
343
+ texte reste comparé d'un bloc (`text_changed` sur le chemin de l'élément).
344
+
345
+ ## Développement
346
+
347
+ ```sh
348
+ uv run pytest # tests
349
+ uv run ruff check . && uv run ruff format .
350
+ ```
351
+
352
+ ## Publication
353
+
354
+ `publish.sh` enchaîne les étapes d'une livraison sur PyPI : contrôle du verrou, `ruff`,
355
+ `pytest`, construction des distributions, contrôle des métadonnées (`twine check`),
356
+ vérification que la roue embarque bien `normalize.xsl`, puis téléversement. Il refuse un
357
+ arbre de travail modifié ou une version déjà publiée, et ne pose aucun tag lui-même — il
358
+ rappelle la commande à la fin.
359
+
360
+ ```sh
361
+ uv version --bump patch # 0.2.0 -> 0.2.1 (ou minor / major)
362
+ ./publish.sh --test # répétition générale sur TestPyPI
363
+ ./publish.sh # publication réelle sur PyPI
364
+ ```
365
+
366
+ | Option | Effet |
367
+ |---|---|
368
+ | `--test` | publier sur TestPyPI (index `testpypi` déclaré dans `pyproject.toml`) |
369
+ | `--dry-run` | tout exécuter sauf le téléversement |
370
+ | `--skip-checks` | ne pas rejouer `ruff` et `pytest` |
371
+ | `--allow-dirty` | autoriser un arbre de travail modifié |
372
+
373
+ Le jeton d'API s'obtient, par ordre de préférence :
374
+
375
+ 1. par une commande, `PUBLISH_TOKEN_CMD='pass show pypi/xml-cmpr' ./publish.sh` — le
376
+ jeton ne transite ni par l'historique du shell ni par l'environnement ;
377
+ 2. par saisie au clavier, sans écho, si rien n'est fourni et que le script tourne dans
378
+ un terminal ;
379
+ 3. par `UV_PUBLISH_TOKEN`, toléré pour la compatibilité mais déconseillé : exporté dans
380
+ le profil, il est lisible par tous les processus de la session.
381
+
382
+ Quelle que soit sa source, le script retire le jeton de l'environnement dès son
383
+ démarrage et ne le remet qu'à `uv publish` : `pytest`, `uv build` et `twine` — donc les
384
+ dépendances de dev et les hooks de build — ne le voient jamais. Après la première
385
+ publication, remplacer le jeton global du compte par un jeton limité au projet.
386
+
387
+ Le jeton est celui du dépôt visé — un jeton PyPI ne vaut pas sur TestPyPI, et
388
+ réciproquement. Après un `--test`, l'installation se vérifie par
389
+ `uv tool install --index https://test.pypi.org/simple xml-cmpr`.