xml-cmpr 0.2.0__tar.gz → 0.2.1__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 → xml_cmpr-0.2.1}/PKG-INFO +49 -138
  2. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/README.md +48 -137
  3. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/pyproject.toml +1 -1
  4. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/uv.lock +23 -23
  5. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/.gitignore +0 -0
  6. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/LICENSE +0 -0
  7. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/docs/README.md +0 -0
  8. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/docs/analyses/README.md +0 -0
  9. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/src/xml_cmpr/__init__.py +0 -0
  10. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/src/xml_cmpr/archive.py +0 -0
  11. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/src/xml_cmpr/campaign.py +0 -0
  12. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/src/xml_cmpr/canonical.py +0 -0
  13. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/src/xml_cmpr/cli.py +0 -0
  14. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/src/xml_cmpr/diff.py +0 -0
  15. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/src/xml_cmpr/discovery.py +0 -0
  16. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/src/xml_cmpr/model.py +0 -0
  17. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/src/xml_cmpr/py.typed +0 -0
  18. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/src/xml_cmpr/report.py +0 -0
  19. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/src/xml_cmpr/resources/__init__.py +0 -0
  20. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/src/xml_cmpr/resources/normalize.xsl +0 -0
  21. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/tests/conftest.py +0 -0
  22. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/tests/fixtures/a/absent_de_b.xml +0 -0
  23. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/tests/fixtures/a/attributs.xml +0 -0
  24. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/tests/fixtures/a/factures/facture_001.xml +0 -0
  25. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/tests/fixtures/a/identique.xml +0 -0
  26. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/tests/fixtures/a/malforme.xml +0 -0
  27. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/tests/fixtures/a/mixte.xml +0 -0
  28. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/tests/fixtures/a/notes.txt +0 -0
  29. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/tests/fixtures/a/texte.xml +0 -0
  30. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/tests/fixtures/b/attributs.xml +0 -0
  31. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/tests/fixtures/b/factures/facture_001.xml +0 -0
  32. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/tests/fixtures/b/identique.xml +0 -0
  33. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/tests/fixtures/b/malforme.xml +0 -0
  34. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/tests/fixtures/b/mixte.xml +0 -0
  35. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/tests/fixtures/b/notes.txt +0 -0
  36. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/tests/fixtures/b/seulement_en_b.xml +0 -0
  37. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/tests/fixtures/b/texte.xml +0 -0
  38. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/tests/test_archive.py +0 -0
  39. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/tests/test_campaign.py +0 -0
  40. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/tests/test_canonical.py +0 -0
  41. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/tests/test_cli.py +0 -0
  42. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/tests/test_diff.py +0 -0
  43. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/tests/test_discovery.py +0 -0
  44. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/tests/test_docs.py +0 -0
  45. {xml_cmpr-0.2.0 → xml_cmpr-0.2.1}/tests/test_report.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: xml-cmpr
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Compare des documents XML issus de deux sources et produit un rapport des différences
5
5
  Project-URL: Homepage, https://framagit.org/opikanoba/xml-compare
6
6
  Project-URL: Repository, https://framagit.org/opikanoba/xml-compare.git
@@ -30,7 +30,7 @@ Compare des documents XML produits par **deux chaînes de production différente
30
30
  produit un rapport des différences, fichier par fichier.
31
31
 
32
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
33
+ relatif (les fichiers sans correspondance sont ignorés), compare les deux documents sur leur
34
34
  **forme canonique**, et écrit un rapport Markdown et/ou JSON dans le répertoire de
35
35
  sortie.
36
36
 
@@ -49,25 +49,15 @@ xml-cmpr --help
49
49
 
50
50
  Python 3.13 ou 3.14 est requis (le socle de `saxonche`, qui apporte le moteur XSLT).
51
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 :
52
+ Le paquet lui-même est en Python pur, mais `saxonche` est publié sans source.
55
53
 
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 :
54
+ L'installation n'aboutit donc que sur les plateformes pour lesquelles il existe la librairie a été publiée :
67
55
 
68
- ```sh
69
- uv sync
70
- ```
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 |
71
61
 
72
62
  ## Utilisation
73
63
 
@@ -106,18 +96,18 @@ Produit `REPERTOIRE_SORTIE/rapport.md` et `REPERTOIRE_SORTIE/rapport.json`
106
96
 
107
97
  ### Options
108
98
 
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 |
99
+ | Option | Effet |
100
+ | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
101
+ | `--pattern GLOB` | motif des fichiers de A (défaut : `*.xml`) |
102
+ | `--no-recursive` | ne pas descendre dans les sous-répertoires |
103
+ | `--keep-whitespace` | ne pas normaliser les espaces des nœuds texte |
104
+ | `--exclude FICHIER` | fichier contenant des expressions XPath à exclure de la comparaison |
105
+ | `--max-diffs-per-file N` | différences détaillées par fichier (défaut : 50, `0` = illimité) |
106
+ | `--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. |
107
+ | `--format FORMATS` | formats du rapport : `md`, `json` (défaut : `md,json`) |
108
+ | `--fail-on-diff` | code de sortie 1 si au moins une différence |
109
+ | `--fail-on-error` | code de sortie 3 si au moins un fichier est illisible |
110
+ | `-v`, `--verbose` | progression sur stderr |
121
111
 
122
112
  Codes de sortie : `0` succès · `1` différences détectées (avec `--fail-on-diff`) ·
123
113
  `2` erreur d'usage (source introuvable ou illisible, sortie inutilisable, XPath
@@ -125,10 +115,7 @@ d'exclusion invalide, format inconnu) · `3` fichiers en erreur (avec
125
115
  `--fail-on-error`).
126
116
 
127
117
  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é.
118
+ erreur, et **sans `--fail-on-error` la comparaison se termine, avec un code retour `0`**.
132
119
 
133
120
  ### Le rapport JSON
134
121
 
@@ -189,24 +176,17 @@ consommable par un autre traitement :
189
176
 
190
177
  Une source ZIP est extraite dans un répertoire temporaire, supprimé à la fin de la
191
178
  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.
179
+ que pour un répertoire (le rapport nomme l'archive, pas le répertoire temporaire).
197
180
 
198
181
  Une archive dont le contenu **décompressé** dépasse **1 Gio**, ou qui compte plus de
199
182
  **100 000 entrées** (fichiers et répertoires), est refusée (code `2`), avant toute
200
183
  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.
184
+ source répertoire, qui n'est pas copiée. `--no-size-limit` lève les deux limites.
204
185
 
205
186
  ## Utiliser la comparaison depuis Python
206
187
 
207
188
  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.
189
+ **é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.
210
190
 
211
191
  ```python
212
192
  from pathlib import Path
@@ -220,13 +200,13 @@ corps = render(summary, ReportFormat.JSON) # formatage -> str
220
200
  write_report(summary, Path("rapport"), "json") # écriture -> Path (facultatif)
221
201
  ```
222
202
 
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]` |
203
+ | Étape | Fonction | Rend |
204
+ | --------- | ----------------------------------------------- | --------------------------------------- |
205
+ | analyse | `run_comparison(source_a, source_b, **options)` | `RunSummary` |
206
+ | formatage | `render(summary, "md"\|"json")` | `str` |
207
+ | | `summary.as_dict()` | `dict` (JSON sans passer par la chaîne) |
208
+ | écriture | `write_report(summary, dossier, format)` | `Path` du fichier écrit |
209
+ | | `write_reports(summary, dossier, formats)` | `list[Path]` |
230
210
 
231
211
  `run_comparison` accepte les mêmes options que le CLI (`pattern`, `recursive`,
232
212
  `keep_whitespace`, `exclude_xpaths`, `max_diffs_per_file`), et
@@ -247,21 +227,14 @@ with as_directory(Path("chaine_a.zip")) as dir_a:
247
227
 
248
228
  ### Les exceptions
249
229
 
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é |
230
+ Les exceptions de `xml_cmpr` sont dérivées d'`Exception` :
258
231
 
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.
232
+ | Exception | Levée par | Cause |
233
+ | ------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
234
+ | `ExcludeError` | `run_comparison`, `Canonicalizer` | un XPath d'exclusion est invalide |
235
+ | `ArchiveError` | `as_directory` | source introuvable, archive illisible, chemin d'entrée suspect |
236
+ | `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"`) |
237
+ | `CanonicalError` | `Canonicalizer.load` | un document est illisible ou malformé |
265
238
 
266
239
  ### Exclure des éléments ou attributs de la comparaison
267
240
 
@@ -279,10 +252,10 @@ les URI de namespace terminées par `#` restent écrivables.
279
252
 
280
253
  Chaque élément ou attribut sélectionné par l'un de ces XPath, dans A comme dans B, est
281
254
  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.
255
+ le test d'identité, ni dans le diff. Un élément trouvé dans 1 XPath exclut tout son sous-arbre.
283
256
 
284
257
  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
258
+ namespace déclarés dans le document. Pour un nom
286
259
  sans espace de noms, écrire `//DateGeneration` ; pour un nom avec espace de noms,
287
260
  utiliser la notation `Q{uri}local`, par ex. `//Q{urn:facture}DateGeneration`.
288
261
 
@@ -302,25 +275,7 @@ différences :
302
275
 
303
276
  Ils ne sont **jamais** comparés, et aucune option ne les réintègre. Ni ceux placés dans
304
277
  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`.
278
+ l'élément racine, la PI `<?xml-stylesheet?>` qui désigne une feuille de style.
324
279
 
325
280
  ### Le contenu mixte
326
281
 
@@ -333,57 +288,13 @@ A : <p>Voir la <ref>note</ref> ci-dessous.</p>
333
288
  B : <p>Voir la ci-dessous.<ref>note</ref></p>
334
289
  ```
335
290
 
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.` | — |
291
+ | Chemin | Type | A | B |
292
+ | ------------- | ----------------- | ------------- | --------------------- |
293
+ | `/p/#text[1]` | texte modifié | `Voir la` | `Voir la ci-dessous.` |
294
+ | `/p/#text[2]` | texte absent de B | `ci-dessous.` | — |
340
295
 
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).
296
+ Les deux documents sont considérés comme **différents**.
344
297
 
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
- ```
298
+ ## Licence
365
299
 
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`.
300
+ Distribué sous licence [MIT](LICENSE) — © 2026 Frédéric Laurent.
@@ -4,7 +4,7 @@ Compare des documents XML produits par **deux chaînes de production différente
4
4
  produit un rapport des différences, fichier par fichier.
5
5
 
6
6
  L'outil parcourt la source A, cherche dans la source B le fichier de même chemin
7
- relatif (les fichiers sans homologue sont ignorés), compare les deux documents sur leur
7
+ relatif (les fichiers sans correspondance sont ignorés), compare les deux documents sur leur
8
8
  **forme canonique**, et écrit un rapport Markdown et/ou JSON dans le répertoire de
9
9
  sortie.
10
10
 
@@ -23,25 +23,15 @@ xml-cmpr --help
23
23
 
24
24
  Python 3.13 ou 3.14 est requis (le socle de `saxonche`, qui apporte le moteur XSLT).
25
25
 
26
- Le paquet lui-même est en Python pur, mais `saxonche` n'est publié qu'en roues natives,
27
- sans source à compiler. L'installation n'aboutit donc que sur les plateformes pour
28
- lesquelles il existe une roue :
26
+ Le paquet lui-même est en Python pur, mais `saxonche` est publié sans source.
29
27
 
30
- | Système | Architectures |
31
- |---|---|
32
- | Linux (glibc 2.24 ou plus, « manylinux ») | x86_64, aarch64 |
33
- | macOS (11 ou plus) | x86_64, arm64 |
34
- | Windows | x86_64 |
35
-
36
- Ailleurs — Alpine et autres Linux musl, processeurs 32 bits, PyPy, Python 3.15 —,
37
- l'installation échoue en résolvant `saxonche` (pip : « No matching distribution found
38
- for saxonche » ; uv : aucune roue pour la plateforme courante) : ce message signale une
39
- plateforme non prise en charge, pas un défaut du paquet.
40
- Pour travailler sur le dépôt lui-même :
28
+ L'installation n'aboutit donc que sur les plateformes pour lesquelles il existe la librairie a été publiée :
41
29
 
42
- ```sh
43
- uv sync
44
- ```
30
+ | Système | Architectures |
31
+ | ----------------------------------------- | --------------- |
32
+ | Linux (glibc 2.24 ou plus, « manylinux ») | x86_64, aarch64 |
33
+ | macOS (11 ou plus) | x86_64, arm64 |
34
+ | Windows | x86_64 |
45
35
 
46
36
  ## Utilisation
47
37
 
@@ -80,18 +70,18 @@ Produit `REPERTOIRE_SORTIE/rapport.md` et `REPERTOIRE_SORTIE/rapport.json`
80
70
 
81
71
  ### Options
82
72
 
83
- | Option | Effet |
84
- |---|---|
85
- | `--pattern GLOB` | motif des fichiers de A (défaut : `*.xml`) |
86
- | `--no-recursive` | ne pas descendre dans les sous-répertoires |
87
- | `--keep-whitespace` | ne pas normaliser les espaces des nœuds texte |
88
- | `--exclude FICHIER` | fichier de XPath à exclure de la comparaison |
89
- | `--max-diffs-per-file N` | différences détaillées par fichier (défaut : 50, `0` = illimité) |
90
- | `--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) |
91
- | `--format FORMATS` | formats du rapport : `md`, `json` (défaut : `md,json`) |
92
- | `--fail-on-diff` | code de sortie 1 si au moins une différence |
93
- | `--fail-on-error` | code de sortie 3 si au moins un fichier est illisible |
94
- | `-v`, `--verbose` | progression sur stderr |
73
+ | Option | Effet |
74
+ | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
75
+ | `--pattern GLOB` | motif des fichiers de A (défaut : `*.xml`) |
76
+ | `--no-recursive` | ne pas descendre dans les sous-répertoires |
77
+ | `--keep-whitespace` | ne pas normaliser les espaces des nœuds texte |
78
+ | `--exclude FICHIER` | fichier contenant des expressions XPath à exclure de la comparaison |
79
+ | `--max-diffs-per-file N` | différences détaillées par fichier (défaut : 50, `0` = illimité) |
80
+ | `--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. |
81
+ | `--format FORMATS` | formats du rapport : `md`, `json` (défaut : `md,json`) |
82
+ | `--fail-on-diff` | code de sortie 1 si au moins une différence |
83
+ | `--fail-on-error` | code de sortie 3 si au moins un fichier est illisible |
84
+ | `-v`, `--verbose` | progression sur stderr |
95
85
 
96
86
  Codes de sortie : `0` succès · `1` différences détectées (avec `--fail-on-diff`) ·
97
87
  `2` erreur d'usage (source introuvable ou illisible, sortie inutilisable, XPath
@@ -99,10 +89,7 @@ d'exclusion invalide, format inconnu) · `3` fichiers en erreur (avec
99
89
  `--fail-on-error`).
100
90
 
101
91
  Un fichier XML malformé n'interrompt pas la campagne : il est reporté comme fichier en
102
- erreur, et **sans `--fail-on-error` la campagne se termine quand même en `0`**. En
103
- intégration continue, poser les deux drapeaux : `--fail-on-diff` seul laisse passer un
104
- corpus entièrement illisible. Quand les deux conditions sont réunies, `3` l'emporte —
105
- une campagne incomplète est un résultat moins sûr qu'une campagne qui a tout comparé.
92
+ erreur, et **sans `--fail-on-error` la comparaison se termine, avec un code retour `0`**.
106
93
 
107
94
  ### Le rapport JSON
108
95
 
@@ -163,24 +150,17 @@ consommable par un autre traitement :
163
150
 
164
151
  Une source ZIP est extraite dans un répertoire temporaire, supprimé à la fin de la
165
152
  campagne ; la comparaison, les options et le rapport sont ensuite exactement les mêmes
166
- que pour un répertoire (le rapport nomme l'archive, pas le temporaire).
167
-
168
- Si l'archive enveloppe tout son contenu dans un unique répertoire de tête, celui-ci est
169
- traversé : deux archives dont les répertoires de tête s'appellent `chaine_a/` et
170
- `chaine_b/` s'apparient quand même sur le chemin relatif interne.
153
+ que pour un répertoire (le rapport nomme l'archive, pas le répertoire temporaire).
171
154
 
172
155
  Une archive dont le contenu **décompressé** dépasse **1 Gio**, ou qui compte plus de
173
156
  **100 000 entrées** (fichiers et répertoires), est refusée (code `2`), avant toute
174
157
  extraction : les bornes valent pour chaque archive séparément, et ne concernent pas une
175
- source répertoire, qui n'est pas copiée. `--no-size-limit` les lève toutes les deux, et
176
- explicitement — en connaissance de cause, le répertoire temporaire devant alors pouvoir
177
- accueillir l'archive entière.
158
+ source répertoire, qui n'est pas copiée. `--no-size-limit` lève les deux limites.
178
159
 
179
160
  ## Utiliser la comparaison depuis Python
180
161
 
181
162
  Le pipeline se décompose en trois étapes indépendantes : **analyse**, **formatage**,
182
- **écriture**. Le formatage retourne une chaîne — rien n'oblige à l'écrire sur disque,
183
- elle peut partir directement dans une réponse HTTP ou vers le traitement suivant.
163
+ **é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.
184
164
 
185
165
  ```python
186
166
  from pathlib import Path
@@ -194,13 +174,13 @@ corps = render(summary, ReportFormat.JSON) # formatage -> str
194
174
  write_report(summary, Path("rapport"), "json") # écriture -> Path (facultatif)
195
175
  ```
196
176
 
197
- | Étape | Fonction | Rend |
198
- |---|---|---|
199
- | analyse | `run_comparison(source_a, source_b, **options)` | `RunSummary` |
200
- | formatage | `render(summary, "md"\|"json")` | `str` |
201
- | | `summary.as_dict()` | `dict` (JSON sans passer par la chaîne) |
202
- | écriture | `write_report(summary, dossier, format)` | `Path` du fichier écrit |
203
- | | `write_reports(summary, dossier, formats)` | `list[Path]` |
177
+ | Étape | Fonction | Rend |
178
+ | --------- | ----------------------------------------------- | --------------------------------------- |
179
+ | analyse | `run_comparison(source_a, source_b, **options)` | `RunSummary` |
180
+ | formatage | `render(summary, "md"\|"json")` | `str` |
181
+ | | `summary.as_dict()` | `dict` (JSON sans passer par la chaîne) |
182
+ | écriture | `write_report(summary, dossier, format)` | `Path` du fichier écrit |
183
+ | | `write_reports(summary, dossier, formats)` | `list[Path]` |
204
184
 
205
185
  `run_comparison` accepte les mêmes options que le CLI (`pattern`, `recursive`,
206
186
  `keep_whitespace`, `exclude_xpaths`, `max_diffs_per_file`), et
@@ -221,21 +201,14 @@ with as_directory(Path("chaine_a.zip")) as dir_a:
221
201
 
222
202
  ### Les exceptions
223
203
 
224
- Toutes exportées par `xml_cmpr`, toutes dérivées d'`Exception` :
225
-
226
- | Exception | Levée par | Cause |
227
- |---|---|---|
228
- | `ExcludeError` | `run_comparison`, `Canonicalizer` | un XPath d'exclusion est invalide |
229
- | `ArchiveError` | `as_directory` | source introuvable, archive illisible, chemin d'entrée suspect |
230
- | `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"`) |
231
- | `CanonicalError` | `Canonicalizer.load` | un document est illisible ou malformé |
204
+ Les exceptions de `xml_cmpr` sont dérivées d'`Exception` :
232
205
 
233
- La distinction compte pour un appelant qui expose la comparaison : `ExcludeError` et
234
- `ArchiveError` viennent de ce que l'appelant a fourni — c'est un `400`, pas un `500`
235
- (`ArchiveSizeError`, attrapée avant, se prête plutôt à un `413`).
236
- `CanonicalError`, lui, **ne traverse pas `run_comparison`** : la campagne l'attrape
237
- fichier par fichier et le reporte comme fichier en erreur, sans s'interrompre. On ne le
238
- voit qu'en appelant `Canonicalizer.load` soi-même.
206
+ | Exception | Levée par | Cause |
207
+ | ------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
208
+ | `ExcludeError` | `run_comparison`, `Canonicalizer` | un XPath d'exclusion est invalide |
209
+ | `ArchiveError` | `as_directory` | source introuvable, archive illisible, chemin d'entrée suspect |
210
+ | `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"`) |
211
+ | `CanonicalError` | `Canonicalizer.load` | un document est illisible ou malformé |
239
212
 
240
213
  ### Exclure des éléments ou attributs de la comparaison
241
214
 
@@ -253,10 +226,10 @@ les URI de namespace terminées par `#` restent écrivables.
253
226
 
254
227
  Chaque élément ou attribut sélectionné par l'un de ces XPath, dans A comme dans B, est
255
228
  retiré de la forme canonique **avant** le calcul de l'empreinte : il ne compte ni pour
256
- le test d'identité, ni dans le diff. Un élément exclu emporte tout son sous-arbre.
229
+ le test d'identité, ni dans le diff. Un élément trouvé dans 1 XPath exclut tout son sous-arbre.
257
230
 
258
231
  Le XPath est évalué par Saxon directement sur le document source, sans les préfixes de
259
- namespace déclarés dans le document (un XPath autonome n'en hérite pas) : pour un nom
232
+ namespace déclarés dans le document. Pour un nom
260
233
  sans espace de noms, écrire `//DateGeneration` ; pour un nom avec espace de noms,
261
234
  utiliser la notation `Q{uri}local`, par ex. `//Q{urn:facture}DateGeneration`.
262
235
 
@@ -276,25 +249,7 @@ différences :
276
249
 
277
250
  Ils ne sont **jamais** comparés, et aucune option ne les réintègre. Ni ceux placés dans
278
251
  un élément, ni ceux du prologue — la bannière de génération qu'une chaîne écrit avant
279
- l'élément racine, la PI `<?xml-stylesheet?>` qui désigne une feuille de style. Deux
280
- documents qui ne diffèrent que par là sont `identique`.
281
-
282
- C'est un choix de périmètre, pas une limite d'implémentation : l'outil compare ce que
283
- les documents *disent* — éléments, attributs, texte — et un commentaire ne dit rien du
284
- contenu. Deux chaînes de production qui datent et signent différemment leurs sorties ne
285
- doivent pas produire un rapport plein de différences pour autant. Pour comparer une
286
- bannière de génération, comparer les fichiers en texte, pas en XML.
287
-
288
- Sont des différences : l'ordre des éléments frères, la présence ou la valeur d'un
289
- attribut, le texte d'un élément, la présence d'un élément, et **la position du texte en
290
- contenu mixte**.
291
-
292
- Les éléments frères sont appariés d'abord par empreinte de sous-arbre, puis par nom :
293
- insérer un `<Ligne>` au milieu d'une liste coûte **une** différence, pas un décalage en
294
- cascade sur tous les suivants. Enfin, deux éléments qui ne diffèrent **que** par leur nom
295
- (ou leur espace de noms) sont appariés et rapportés en `element_renamed`, à n'importe
296
- quelle profondeur ; un élément à la fois renommé et modifié reste un `element_removed`
297
- suivi d'un `element_added`.
252
+ l'élément racine, la PI `<?xml-stylesheet?>` qui désigne une feuille de style.
298
253
 
299
254
  ### Le contenu mixte
300
255
 
@@ -307,57 +262,13 @@ A : <p>Voir la <ref>note</ref> ci-dessous.</p>
307
262
  B : <p>Voir la ci-dessous.<ref>note</ref></p>
308
263
  ```
309
264
 
310
- | Chemin | Type | A | B |
311
- |---|---|---|---|
312
- | `/p/#text[1]` | texte modifié | `Voir la` | `Voir la ci-dessous.` |
313
- | `/p/#text[2]` | texte absent de B | `ci-dessous.` | — |
265
+ | Chemin | Type | A | B |
266
+ | ------------- | ----------------- | ------------- | --------------------- |
267
+ | `/p/#text[1]` | texte modifié | `Voir la` | `Voir la ci-dessous.` |
268
+ | `/p/#text[2]` | texte absent de B | `ci-dessous.` | — |
314
269
 
315
- Les deux documents ne disent pas la même chose : ils sont **différents**. Un élément
316
- qui ne contient que du texte, lui, n'est pas concerné — c'est le cas courant, et son
317
- texte reste comparé d'un bloc (`text_changed` sur le chemin de l'élément).
270
+ Les deux documents sont considérés comme **différents**.
318
271
 
319
- ## Développement
320
-
321
- ```sh
322
- uv run pytest # tests
323
- uv run ruff check . && uv run ruff format .
324
- ```
325
-
326
- ## Publication
327
-
328
- `publish.sh` enchaîne les étapes d'une livraison sur PyPI : contrôle du verrou, `ruff`,
329
- `pytest`, construction des distributions, contrôle des métadonnées (`twine check`),
330
- vérification que la roue embarque bien `normalize.xsl`, puis téléversement. Il refuse un
331
- arbre de travail modifié ou une version déjà publiée, et ne pose aucun tag lui-même — il
332
- rappelle la commande à la fin.
333
-
334
- ```sh
335
- uv version --bump patch # 0.2.0 -> 0.2.1 (ou minor / major)
336
- ./publish.sh --test # répétition générale sur TestPyPI
337
- ./publish.sh # publication réelle sur PyPI
338
- ```
272
+ ## Licence
339
273
 
340
- | Option | Effet |
341
- |---|---|
342
- | `--test` | publier sur TestPyPI (index `testpypi` déclaré dans `pyproject.toml`) |
343
- | `--dry-run` | tout exécuter sauf le téléversement |
344
- | `--skip-checks` | ne pas rejouer `ruff` et `pytest` |
345
- | `--allow-dirty` | autoriser un arbre de travail modifié |
346
-
347
- Le jeton d'API s'obtient, par ordre de préférence :
348
-
349
- 1. par une commande, `PUBLISH_TOKEN_CMD='pass show pypi/xml-cmpr' ./publish.sh` — le
350
- jeton ne transite ni par l'historique du shell ni par l'environnement ;
351
- 2. par saisie au clavier, sans écho, si rien n'est fourni et que le script tourne dans
352
- un terminal ;
353
- 3. par `UV_PUBLISH_TOKEN`, toléré pour la compatibilité mais déconseillé : exporté dans
354
- le profil, il est lisible par tous les processus de la session.
355
-
356
- Quelle que soit sa source, le script retire le jeton de l'environnement dès son
357
- démarrage et ne le remet qu'à `uv publish` : `pytest`, `uv build` et `twine` — donc les
358
- dépendances de dev et les hooks de build — ne le voient jamais. Après la première
359
- publication, remplacer le jeton global du compte par un jeton limité au projet.
360
-
361
- Le jeton est celui du dépôt visé — un jeton PyPI ne vaut pas sur TestPyPI, et
362
- réciproquement. Après un `--test`, l'installation se vérifie par
363
- `uv tool install --index https://test.pypi.org/simple xml-cmpr`.
274
+ Distribué sous licence [MIT](LICENSE) — © 2026 Frédéric Laurent.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xml-cmpr"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Compare des documents XML issus de deux sources et produit un rapport des différences"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -13,11 +13,11 @@ wheels = [
13
13
 
14
14
  [[package]]
15
15
  name = "iniconfig"
16
- version = "2.3.0"
16
+ version = "2.3.1"
17
17
  source = { registry = "https://pypi.org/simple" }
18
- sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
18
+ sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
19
19
  wheels = [
20
- { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
20
+ { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
21
21
  ]
22
22
 
23
23
  [[package]]
@@ -65,27 +65,27 @@ wheels = [
65
65
 
66
66
  [[package]]
67
67
  name = "ruff"
68
- version = "0.16.3"
68
+ version = "0.17.0"
69
69
  source = { registry = "https://pypi.org/simple" }
70
- sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" }
70
+ sdist = { url = "https://files.pythonhosted.org/packages/e9/a7/70debb024dfacda67b8e560cc7511f52b34b9348a8cc8c1ec23036dcd51d/ruff-0.17.0.tar.gz", hash = "sha256:5cd03240d8208a557c2a9655a5cb07ebe36aa6bb35065f97d48c1f6adef5a322", size = 5320554, upload-time = "2026-10-09T19:47:29.248Z" }
71
71
  wheels = [
72
- { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" },
73
- { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" },
74
- { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" },
75
- { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" },
76
- { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" },
77
- { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" },
78
- { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" },
79
- { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" },
80
- { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" },
81
- { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" },
82
- { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" },
83
- { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" },
84
- { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" },
85
- { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" },
86
- { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" },
87
- { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" },
88
- { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" },
72
+ { url = "https://files.pythonhosted.org/packages/bc/f8/ee5ab9da6089eae2a33e6008b01deb1eda19992c1c8e10661e98cee1640f/ruff-0.17.0-py3-none-linux_armv6l.whl", hash = "sha256:0e271826af9a20d18c6cfae8c51e82959167c24859686ddd3eb9a7f0842ce81e", size = 10148907, upload-time = "2026-10-09T19:46:38.695Z" },
73
+ { url = "https://files.pythonhosted.org/packages/9f/d9/2f81fb5a9d580afbb11b1c8ff915233a11f2a1b27405d7991f183c5e1976/ruff-0.17.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5f0ca4a40f81403689c04f12966e22f44e329ae362072d8f1587b7bda87f603b", size = 10224208, upload-time = "2026-10-09T19:46:41.711Z" },
74
+ { url = "https://files.pythonhosted.org/packages/a7/20/643f3c8f75594f937b2bf74801241c56a2e2b8e139d24dff8b66b28cdd7f/ruff-0.17.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cbf7149e0927dc3295d5d64679a4765576eef71b00782b2ae969ef82274d6bb9", size = 9273324, upload-time = "2026-10-09T19:46:44.323Z" },
75
+ { url = "https://files.pythonhosted.org/packages/ec/91/627700b233d367736cb274f1bd0b47d1f2b12f68878192812bd875adadc3/ruff-0.17.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13ee90156522998c3037059d8f66885c8adeeaf7643bdce2caceee196ecd23e0", size = 9953190, upload-time = "2026-10-09T19:46:47.155Z" },
76
+ { url = "https://files.pythonhosted.org/packages/cd/92/91f7b5ed39490f89d6cbf56e1f543c383667a725efa8e2c2dee0f01f5591/ruff-0.17.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d8e4a002a94cd9d0dc48b51dc69d807a172b5b9bf2b668e656424dc5b55ead1", size = 9961746, upload-time = "2026-10-09T19:46:50.098Z" },
77
+ { url = "https://files.pythonhosted.org/packages/87/c5/7310f9fc63ce11ff6394edbd5e85433dfb0e14c9fbf6ccc97f1538491bc7/ruff-0.17.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0b8a60c06a218c337e1161638d34757f83449243e2db161483ddf948e53ad14", size = 10625483, upload-time = "2026-10-09T19:46:53.379Z" },
78
+ { url = "https://files.pythonhosted.org/packages/a5/8d/97443f0dca4a03a0bc7629fd396fd494a1cb6666121e38c5075acb217d8f/ruff-0.17.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a330178bdffc4205dbf3bda11d93e059e388fd6546f8cdd304501a9160363c0d", size = 11350129, upload-time = "2026-10-09T19:46:56.486Z" },
79
+ { url = "https://files.pythonhosted.org/packages/9c/0a/c525efd9777be4b6b012e6969a3012648468e7e6c4b3e5b46af69f46e8eb/ruff-0.17.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7bb08489e234876fa2da67ae3ea938e9a2156da80293e0e4365abd6973d98329", size = 10906223, upload-time = "2026-10-09T19:47:00.263Z" },
80
+ { url = "https://files.pythonhosted.org/packages/2c/3c/4a01195d93420cad1175bedad13a515dc8a56f95a6e39789b92e582682f5/ruff-0.17.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc73e7c133e82d55b5f15897b2a442d72c0cb4a0c886c46801ce3c247150b60c", size = 10386314, upload-time = "2026-10-09T19:47:03.057Z" },
81
+ { url = "https://files.pythonhosted.org/packages/c7/72/1a3951665485a921f6375f91e754a1854d5a645d41acc3642668064ff64d/ruff-0.17.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:db4f74c533403ab70fe4007873f6ae0c9f94a8b03158cf48d78788e47cdbe399", size = 10565464, upload-time = "2026-10-09T19:47:05.831Z" },
82
+ { url = "https://files.pythonhosted.org/packages/90/8c/539b4d8c082f57e18db8ae2be85a460d77861c79dcd5798e32b536a6a06f/ruff-0.17.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3d8cc360e666d1914e47b0777c6906d70cf18891a55532bd0a16844195d70859", size = 10079468, upload-time = "2026-10-09T19:47:08.617Z" },
83
+ { url = "https://files.pythonhosted.org/packages/e0/b8/84286966db79434e8c26b585b0a0f6897cb3ab1c51a4aa4df10c28488b62/ruff-0.17.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d66de796b726c4801e05fa99a2a8d7a780e107be222486c304ab61765561e866", size = 9958556, upload-time = "2026-10-09T19:47:11.324Z" },
84
+ { url = "https://files.pythonhosted.org/packages/69/50/27b6eed27b83fcdd5bfa0d52b83231e29094754374698da404d094487ae3/ruff-0.17.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c3f268baf004aea944f040623327119527ea231af15f7fb7890e82cea0679589", size = 10352173, upload-time = "2026-10-09T19:47:14.188Z" },
85
+ { url = "https://files.pythonhosted.org/packages/2a/fa/955399fd13044cd827862044117d784a59e3196f6cce7424908ac9a7f914/ruff-0.17.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:864b6c1acb6b0bccf94b5a3938a1531fd09aaca5e5659a2e7bf0f3cf2a685540", size = 10791761, upload-time = "2026-10-09T19:47:16.931Z" },
86
+ { url = "https://files.pythonhosted.org/packages/ae/bf/024e01e1f6aec87768696725e648ed5b438941341eea8f8100beb681961f/ruff-0.17.0-py3-none-win32.whl", hash = "sha256:5e50aa5b84decd9fe5b0bb0e6f71c3b592f1767ed09faa4b7207d933961e35cd", size = 9904112, upload-time = "2026-10-09T19:47:19.75Z" },
87
+ { url = "https://files.pythonhosted.org/packages/cc/77/1ee73df41dcc8d1cdb686ee4bc46ea29704ea175feb6b95c78420f631ab8/ruff-0.17.0-py3-none-win_amd64.whl", hash = "sha256:8ab76bcda86dfd28e13776cb5de3c7bcdcf1ae3d37ed761113d1a5a415dc134c", size = 10115585, upload-time = "2026-10-09T19:47:22.698Z" },
88
+ { url = "https://files.pythonhosted.org/packages/fd/71/eb4f0ccc844aece56963e8578df9c95d4c00f580547d52329d4035d3af18/ruff-0.17.0-py3-none-win_arm64.whl", hash = "sha256:c154c73ff43f9854395e24cac507af13078962e53d2b511605058d22af1fdb88", size = 9872035, upload-time = "2026-10-09T19:47:26.306Z" },
89
89
  ]
90
90
 
91
91
  [[package]]
@@ -112,7 +112,7 @@ wheels = [
112
112
 
113
113
  [[package]]
114
114
  name = "xml-cmpr"
115
- version = "0.2.0"
115
+ version = "0.2.1"
116
116
  source = { editable = "." }
117
117
  dependencies = [
118
118
  { name = "saxonche" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes