xml-cmpr 0.2.0__py3-none-any.whl

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.
xml_cmpr/cli.py ADDED
@@ -0,0 +1,261 @@
1
+ """Point d'entrée en ligne de commande."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from collections.abc import Iterator
8
+ from contextlib import ExitStack, contextmanager
9
+ from pathlib import Path
10
+
11
+ from .archive import (
12
+ MAX_ENTRIES,
13
+ MAX_EXTRACTED_SIZE,
14
+ ArchiveError,
15
+ ArchiveSizeError,
16
+ as_directory,
17
+ is_archive,
18
+ )
19
+ from .campaign import run_comparison
20
+ from .canonical import ExcludeError
21
+ from .report import DEFAULT_FORMATS, ReportFormat, parse_format, write_reports
22
+
23
+ EXIT_OK = 0
24
+ EXIT_DIFFERENCES = 1
25
+ EXIT_ERROR = 2
26
+ #: Au moins un fichier n'a pas pu être lu. Distinct de EXIT_DIFFERENCES : la campagne
27
+ #: a bien tourné dans le premier cas, elle est incomplète dans le second.
28
+ EXIT_FILE_ERRORS = 3
29
+
30
+
31
+ def build_parser() -> argparse.ArgumentParser:
32
+ parser = argparse.ArgumentParser(
33
+ prog="xml-cmpr",
34
+ description=(
35
+ "Compare les fichiers XML de deux sources — répertoire ou archive ZIP — et "
36
+ "produit un rapport des différences dans le répertoire de sortie."
37
+ ),
38
+ )
39
+ parser.add_argument(
40
+ "source_a", type=Path, metavar="SOURCE_A", help="source A : répertoire ou archive ZIP"
41
+ )
42
+ parser.add_argument(
43
+ "source_b", type=Path, metavar="SOURCE_B", help="source B : répertoire ou archive ZIP"
44
+ )
45
+ parser.add_argument("output", type=Path, metavar="OUTPUT", help="répertoire de sortie")
46
+ parser.add_argument(
47
+ "--pattern", default="*.xml", help="motif des fichiers de A (défaut: %(default)s)"
48
+ )
49
+ parser.add_argument(
50
+ "--no-recursive",
51
+ dest="recursive",
52
+ action="store_false",
53
+ help="ne pas descendre dans les sous-répertoires",
54
+ )
55
+ parser.add_argument(
56
+ "--keep-whitespace",
57
+ action="store_true",
58
+ help="ne pas normaliser les espaces des nœuds texte",
59
+ )
60
+ parser.add_argument(
61
+ "--exclude",
62
+ type=Path,
63
+ metavar="FICHIER",
64
+ help=(
65
+ "fichier listant des XPath à exclure de la comparaison (un par ligne, "
66
+ "'#' pour un commentaire) ; les éléments et attributs sélectionnés n'entrent "
67
+ "ni dans l'empreinte d'identité ni dans le diff. Sans préfixe déclaré, "
68
+ "utiliser Q{uri}local pour un nom avec espace de noms"
69
+ ),
70
+ )
71
+ parser.add_argument(
72
+ "--max-diffs-per-file",
73
+ type=_max_diffs,
74
+ default=50,
75
+ help="nombre de différences détaillées par fichier (défaut: %(default)s, 0 = illimité)",
76
+ )
77
+ parser.add_argument(
78
+ "--format",
79
+ dest="formats",
80
+ type=_parse_formats,
81
+ action="extend",
82
+ metavar="FORMATS",
83
+ help=(
84
+ "formats du rapport, séparés par des virgules ou répétés : md, json (défaut : md,json)"
85
+ ),
86
+ )
87
+ parser.add_argument(
88
+ "--no-size-limit",
89
+ dest="archive_limits",
90
+ action="store_false",
91
+ help=(
92
+ "extraire une archive ZIP quels que soient sa taille décompressée et son "
93
+ "nombre d'entrées (par défaut, au-delà de 1 Gio ou de 100 000 entrées par "
94
+ "archive, la source est refusée)"
95
+ ),
96
+ )
97
+ parser.add_argument(
98
+ "--fail-on-diff",
99
+ action="store_true",
100
+ help="code de sortie 1 si au moins une différence est détectée",
101
+ )
102
+ parser.add_argument(
103
+ "--fail-on-error",
104
+ action="store_true",
105
+ help="code de sortie 3 si au moins un fichier n'a pas pu être lu",
106
+ )
107
+ parser.add_argument(
108
+ "-v", "--verbose", action="store_true", help="afficher la progression sur stderr"
109
+ )
110
+ return parser
111
+
112
+
113
+ def _parse_formats(value: str) -> list[ReportFormat]:
114
+ """`"md,json"` -> `[MARKDOWN, JSON]`, pour `--format`."""
115
+ formats = []
116
+ for chunk in value.split(","):
117
+ if not chunk.strip():
118
+ continue
119
+ try:
120
+ fmt = parse_format(chunk)
121
+ except ValueError:
122
+ expected = ", ".join(f.value for f in ReportFormat)
123
+ raise argparse.ArgumentTypeError(
124
+ f"format inconnu : {chunk.strip()} (attendus : {expected})"
125
+ ) from None
126
+ if fmt not in formats:
127
+ formats.append(fmt)
128
+ if not formats:
129
+ raise argparse.ArgumentTypeError("aucun format demandé")
130
+ return formats
131
+
132
+
133
+ def _chosen_formats(args: argparse.Namespace) -> list[ReportFormat]:
134
+ """Formats demandés : ceux de `--format`, dédoublonnés, sinon le défaut."""
135
+ if args.formats:
136
+ return list(dict.fromkeys(args.formats))
137
+ return list(DEFAULT_FORMATS)
138
+
139
+
140
+ def _max_diffs(value: str) -> int:
141
+ """Borne de différences détaillées : un entier positif, `0` valant « illimité ».
142
+
143
+ Une valeur négative passerait pour « illimité » sans le dire — la campagne compare
144
+ `0 < max_diffs < len(differences)` — alors que l'aide n'annonce que `0`.
145
+ """
146
+ try:
147
+ bound = int(value)
148
+ except ValueError:
149
+ raise argparse.ArgumentTypeError(f"nombre entier attendu : {value!r}") from None
150
+ if bound < 0:
151
+ raise argparse.ArgumentTypeError(f"borne négative : {bound} (utiliser 0 pour « illimité »)")
152
+ return bound
153
+
154
+
155
+ def _read_exclude_file(path: Path) -> list[str]:
156
+ """Lit un fichier de XPath à exclure : un par ligne.
157
+
158
+ Une ligne dont le premier caractère non blanc est `#` est un commentaire. Partout
159
+ ailleurs le `#` appartient au XPath : les URI de namespace se terminent souvent par
160
+ `#` (`Q{http://www.w3.org/1999/02/22-rdf-syntax-ns#}Description`), et couper là
161
+ rendait la notation `Q{uri}local` inutilisable sur tout un pan des vocabulaires.
162
+ """
163
+ xpaths = []
164
+ for line in path.read_text(encoding="utf-8").splitlines():
165
+ xpath = line.strip()
166
+ if xpath and not xpath.startswith("#"):
167
+ xpaths.append(xpath)
168
+ return xpaths
169
+
170
+
171
+ @contextmanager
172
+ def _open_source(source: Path, label: str, verbose: bool, limits: bool) -> Iterator[Path]:
173
+ """Répertoire de travail pour une source, en extrayant l'archive le cas échéant.
174
+
175
+ `limits` faux lève d'un coup les deux bornes d'extraction : `--no-size-limit` vaut
176
+ pour le volume comme pour le nombre d'entrées.
177
+ """
178
+ if verbose and is_archive(source):
179
+ print(f"… extraction de {source}", file=sys.stderr)
180
+ with as_directory(
181
+ source,
182
+ label=label,
183
+ size_limit=MAX_EXTRACTED_SIZE if limits else None,
184
+ entry_limit=MAX_ENTRIES if limits else None,
185
+ ) as directory:
186
+ yield directory
187
+
188
+
189
+ def main(argv: list[str] | None = None) -> int:
190
+ args = build_parser().parse_args(argv)
191
+
192
+ exclude_xpaths: list[str] | None = None
193
+ if args.exclude is not None:
194
+ if not args.exclude.is_file():
195
+ print(f"erreur : fichier d'exclusion introuvable : {args.exclude}", file=sys.stderr)
196
+ return EXIT_ERROR
197
+ try:
198
+ exclude_xpaths = _read_exclude_file(args.exclude)
199
+ except (OSError, UnicodeDecodeError) as err:
200
+ print(f"erreur : fichier d'exclusion illisible : {err}", file=sys.stderr)
201
+ return EXIT_ERROR
202
+
203
+ try:
204
+ args.output.mkdir(parents=True, exist_ok=True)
205
+ except OSError as err:
206
+ print(f"erreur : répertoire de sortie inutilisable : {err}", file=sys.stderr)
207
+ return EXIT_ERROR
208
+
209
+ try:
210
+ with ExitStack() as stack:
211
+ dir_a = stack.enter_context(
212
+ _open_source(args.source_a, "a", args.verbose, args.archive_limits)
213
+ )
214
+ dir_b = stack.enter_context(
215
+ _open_source(args.source_b, "b", args.verbose, args.archive_limits)
216
+ )
217
+ summary = run_comparison(
218
+ dir_a,
219
+ dir_b,
220
+ pattern=args.pattern,
221
+ recursive=args.recursive,
222
+ keep_whitespace=args.keep_whitespace,
223
+ exclude_xpaths=exclude_xpaths,
224
+ max_diffs_per_file=args.max_diffs_per_file,
225
+ verbose=args.verbose,
226
+ label_a=args.source_a,
227
+ label_b=args.source_b,
228
+ )
229
+ except ArchiveSizeError as err:
230
+ print(f"erreur : {err} (--no-size-limit pour l'extraire quand même)", file=sys.stderr)
231
+ return EXIT_ERROR
232
+ except ArchiveError as err:
233
+ print(f"erreur : {err}", file=sys.stderr)
234
+ return EXIT_ERROR
235
+ except ExcludeError as err:
236
+ print(f"erreur : XPath d'exclusion invalide : {err}", file=sys.stderr)
237
+ return EXIT_ERROR
238
+
239
+ written = write_reports(summary, args.output, _chosen_formats(args))
240
+
241
+ print(
242
+ f"{len(summary.results)} fichier(s) analysé(s) · "
243
+ f"{len(summary.identical)} identique(s) · "
244
+ f"{len(summary.different)} différent(s) · "
245
+ f"{len(summary.errored)} en erreur · "
246
+ f"{len(summary.skipped)} ignoré(s)"
247
+ )
248
+ for path in written:
249
+ print(f"rapport : {path}")
250
+
251
+ # Les erreurs passent avant les différences : une campagne incomplète est un
252
+ # résultat moins sûr qu'une campagne qui a tout comparé.
253
+ if args.fail_on_error and summary.errored:
254
+ return EXIT_FILE_ERRORS
255
+ if args.fail_on_diff and summary.different:
256
+ return EXIT_DIFFERENCES
257
+ return EXIT_OK
258
+
259
+
260
+ if __name__ == "__main__":
261
+ raise SystemExit(main())
xml_cmpr/diff.py ADDED
@@ -0,0 +1,181 @@
1
+ """Comparaison structurelle de deux arbres canoniques.
2
+
3
+ L'appariement des éléments frères se fait en trois passes : d'abord sur l'empreinte des
4
+ sous-arbres (les blocs strictement identiques s'alignent), puis, à l'intérieur des trous,
5
+ sur le nom des éléments (deux éléments de même nom sont alors comparés en détail plutôt
6
+ que rapportés comme une suppression suivie d'un ajout), enfin, dans ce qui reste, sur le
7
+ contenu hors nom (deux éléments qui ne diffèrent que par leur nom sont un renommage).
8
+ C'est ce qui fait qu'une insertion au milieu d'une liste coûte une différence et non un
9
+ décalage en cascade.
10
+
11
+ Un renommage n'est reconnu que si le contenu est strictement le même : un élément à la
12
+ fois renommé et modifié reste une suppression suivie d'un ajout, faute de quoi deux
13
+ éléments sans rapport finiraient appariés pour la seule raison qu'ils occupent la même
14
+ place.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ from collections import Counter
21
+ from collections.abc import Callable, Iterator
22
+ from difflib import SequenceMatcher
23
+
24
+ from .model import Difference, Kind, Node, clark_local, display_name
25
+
26
+ #: Une paire de frères appariés, désignés par leur position — `None` d'un côté quand
27
+ #: l'élément n'existe que de l'autre.
28
+ Pair = tuple[int | None, int | None]
29
+
30
+
31
+ def diff_documents(a: Node, b: Node) -> list[Difference]:
32
+ """Liste des différences entre deux documents canoniques, en ordre de lecture de A."""
33
+ differences: list[Difference] = []
34
+ _diff_nodes(a, b, "/" + a.local_name, differences)
35
+ return differences
36
+
37
+
38
+ def _diff_nodes(a: Node, b: Node, path: str, out: list[Difference]) -> None:
39
+ if a.fingerprint == b.fingerprint:
40
+ return
41
+
42
+ if a.name != b.name:
43
+ out.append(
44
+ Difference(path, Kind.ELEMENT_RENAMED, display_name(a.name), display_name(b.name))
45
+ )
46
+
47
+ _diff_attributes(a, b, path, out)
48
+
49
+ if a.text != b.text:
50
+ out.append(Difference(path, Kind.TEXT_CHANGED, a.text, b.text))
51
+
52
+ _diff_children(a, b, path, out)
53
+
54
+
55
+ def _diff_attributes(a: Node, b: Node, path: str, out: list[Difference]) -> None:
56
+ for name in sorted(a.attrs.keys() | b.attrs.keys()):
57
+ value_a = a.attrs.get(name)
58
+ value_b = b.attrs.get(name)
59
+ if value_a == value_b:
60
+ continue
61
+ attr_path = f"{path}/@{clark_local(name)}"
62
+ if value_a is None:
63
+ out.append(Difference(attr_path, Kind.ATTRIBUTE_ADDED, None, value_b))
64
+ elif value_b is None:
65
+ out.append(Difference(attr_path, Kind.ATTRIBUTE_REMOVED, value_a, None))
66
+ else:
67
+ out.append(Difference(attr_path, Kind.ATTRIBUTE_CHANGED, value_a, value_b))
68
+
69
+
70
+ def _diff_children(a: Node, b: Node, path: str, out: list[Difference]) -> None:
71
+ segments_a = _segments(a.children)
72
+ segments_b = _segments(b.children)
73
+
74
+ for i, j in _align(a.children, b.children):
75
+ if i is not None and j is not None:
76
+ _diff_nodes(a.children[i], b.children[j], f"{path}/{segments_a[i]}", out)
77
+ elif i is not None:
78
+ child_a = a.children[i]
79
+ kind = Kind.TEXT_REMOVED if child_a.is_text else Kind.ELEMENT_REMOVED
80
+ out.append(Difference(f"{path}/{segments_a[i]}", kind, child_a.summary(), None))
81
+ elif j is not None:
82
+ child_b = b.children[j]
83
+ kind = Kind.TEXT_ADDED if child_b.is_text else Kind.ELEMENT_ADDED
84
+ out.append(Difference(f"{path}/{segments_b[j]}", kind, None, child_b.summary()))
85
+
86
+
87
+ def _segments(nodes: list[Node]) -> list[str]:
88
+ """Segment de chemin de chaque enfant, avec prédicat quand le nom est répété."""
89
+ totals = Counter(node.local_name for node in nodes)
90
+ seen: Counter[str] = Counter()
91
+ segments = []
92
+ for node in nodes:
93
+ local = node.local_name
94
+ seen[local] += 1
95
+ segments.append(f"{local}[{seen[local]}]" if totals[local] > 1 else local)
96
+ return segments
97
+
98
+
99
+ def _align(a_nodes: list[Node], b_nodes: list[Node]) -> Iterator[Pair]:
100
+ """Apparie deux listes de frères : par sous-arbre identique, par nom, puis par contenu.
101
+
102
+ L'appariement rend des **positions**, jamais les nœuds eux-mêmes : deux frères
103
+ peuvent être égaux, voire être le même objet `Node` réutilisé à deux places par un
104
+ appelant de l'API, et doivent malgré tout garder chacun leur chemin au rapport.
105
+ """
106
+ yield from _cascade(
107
+ a_nodes, b_nodes, list(range(len(a_nodes))), list(range(len(b_nodes))), _KEYS
108
+ )
109
+
110
+
111
+ def _fingerprint(node: Node) -> str:
112
+ return node.fingerprint
113
+
114
+
115
+ def _name(node: Node) -> str:
116
+ return node.name
117
+
118
+
119
+ def _content(node: Node) -> str:
120
+ """Clé de la passe « renommage » : le contenu hors nom, pour un élément seulement.
121
+
122
+ Calculée à la demande, sur les seuls frères restés sans partenaire : la payer à la
123
+ construction de chaque `Node` coûterait un second hachage par élément de chaque
124
+ document, pour une passe qui ne voit d'ordinaire que quelques nœuds.
125
+
126
+ Un nœud texte garde son empreinte complète : `{}#text` contenant « x » et `<a>x</a>`
127
+ ont le même contenu, mais un texte remplacé par un élément n'est pas un renommage.
128
+ """
129
+ if node.is_text:
130
+ return node.fingerprint
131
+ h = hashlib.sha256()
132
+ for key, value in node.attrs.items():
133
+ h.update(f"{key}\x01{value}\x02".encode())
134
+ h.update(f"{node.text}\x03".encode())
135
+ for child in node.children:
136
+ h.update(child.fingerprint.encode())
137
+ return h.hexdigest()
138
+
139
+
140
+ #: Les passes d'appariement, de la plus stricte à la plus lâche.
141
+ _KEYS: tuple[Callable[[Node], str], ...] = (_fingerprint, _name, _content)
142
+
143
+
144
+ def _cascade(
145
+ a_nodes: list[Node],
146
+ b_nodes: list[Node],
147
+ positions_a: list[int],
148
+ positions_b: list[int],
149
+ keys: tuple[Callable[[Node], str], ...],
150
+ ) -> Iterator[Pair]:
151
+ """Aligne sur `keys[0]`, puis rejoue les passes suivantes dans chaque trou.
152
+
153
+ Ce qui reste sans partenaire après la dernière passe est rendu non apparié.
154
+ """
155
+ if not keys or not positions_a or not positions_b:
156
+ yield from ((i, None) for i in positions_a)
157
+ yield from ((None, j) for j in positions_b)
158
+ return
159
+ for tag, chunk_a, chunk_b in _blocks(a_nodes, b_nodes, positions_a, positions_b, keys[0]):
160
+ if tag == "equal":
161
+ yield from zip(chunk_a, chunk_b, strict=True)
162
+ else:
163
+ yield from _cascade(a_nodes, b_nodes, chunk_a, chunk_b, keys[1:])
164
+
165
+
166
+ def _blocks(
167
+ a_nodes: list[Node],
168
+ b_nodes: list[Node],
169
+ positions_a: list[int],
170
+ positions_b: list[int],
171
+ key: Callable[[Node], str],
172
+ ) -> Iterator[tuple[str, list[int], list[int]]]:
173
+ """Découpe deux suites de positions en blocs alignés (`equal`) et en trous, selon `key`."""
174
+ matcher = SequenceMatcher(
175
+ None,
176
+ [key(a_nodes[i]) for i in positions_a],
177
+ [key(b_nodes[j]) for j in positions_b],
178
+ autojunk=False,
179
+ )
180
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
181
+ yield tag, positions_a[i1:i2], positions_b[j1:j2]
xml_cmpr/discovery.py ADDED
@@ -0,0 +1,40 @@
1
+ """Parcours du répertoire source A et appariement avec le répertoire source B."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass(slots=True)
10
+ class Pairing:
11
+ """Fichiers de A appariés avec leur homologue de B, et ceux qui n'en ont pas."""
12
+
13
+ matched: list[tuple[str, Path, Path]] = field(default_factory=list)
14
+ skipped: list[str] = field(default_factory=list)
15
+
16
+
17
+ def discover(
18
+ source_a: Path,
19
+ source_b: Path,
20
+ *,
21
+ pattern: str = "*.xml",
22
+ recursive: bool = True,
23
+ ) -> Pairing:
24
+ """Apparie les fichiers de `source_a` avec ceux de `source_b`.
25
+
26
+ La clé d'appariement est le chemin relatif à la racine de chaque source. Un fichier
27
+ de A sans homologue dans B est ignoré (et signalé dans `skipped`).
28
+ """
29
+ pairing = Pairing()
30
+ candidates = source_a.rglob(pattern) if recursive else source_a.glob(pattern)
31
+ for path_a in sorted(candidates):
32
+ if not path_a.is_file():
33
+ continue
34
+ relative = path_a.relative_to(source_a).as_posix()
35
+ path_b = source_b / relative
36
+ if path_b.is_file():
37
+ pairing.matched.append((relative, path_a, path_b))
38
+ else:
39
+ pairing.skipped.append(relative)
40
+ return pairing
xml_cmpr/model.py ADDED
@@ -0,0 +1,207 @@
1
+ """Structures de données partagées par tout le pipeline de comparaison."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from dataclasses import dataclass, field
7
+ from datetime import UTC, datetime
8
+ from enum import StrEnum
9
+ from pathlib import Path
10
+
11
+ #: Pseudo-nom d'un nœud texte de contenu mixte, tel que l'émet `normalize.xsl`.
12
+ TEXT_NODE = "{}#text"
13
+
14
+
15
+ def clark_local(name: str) -> str:
16
+ """`{urn:f}Ligne` -> `Ligne`. Un nom sans namespace est rendu tel quel."""
17
+ if name.startswith("{"):
18
+ return name[name.index("}") + 1 :]
19
+ return name
20
+
21
+
22
+ def display_name(name: str) -> str:
23
+ """Nom d'élément tel qu'affiché au rapport : local sans namespace, Clark sinon.
24
+
25
+ `{}Ligne` -> `Ligne`, `{urn:f}Ligne` -> `{urn:f}Ligne` : l'URI ne s'affiche que
26
+ lorsqu'elle existe, puisque c'est alors elle qui distingue deux noms.
27
+ """
28
+ return name if clark_uri(name) else clark_local(name)
29
+
30
+
31
+ def clark_uri(name: str) -> str:
32
+ """`{urn:f}Ligne` -> `urn:f`. Chaîne vide si le nom n'est pas dans un namespace."""
33
+ if name.startswith("{"):
34
+ return name[1 : name.index("}")]
35
+ return ""
36
+
37
+
38
+ @dataclass(slots=True)
39
+ class Node:
40
+ """Un élément de la forme canonique.
41
+
42
+ `name` est en notation de Clark (`{uri}local`), `attrs` est trié par nom, et `text`
43
+ est la concaténation normalisée des nœuds texte enfants directs. Les commentaires et
44
+ instructions de traitement conservés apparaissent dans `children` sous les pseudo-noms
45
+ `{}#comment` et `{}#pi`.
46
+
47
+ En contenu mixte — du texte et des éléments sous le même parent — `text` est vide et
48
+ chaque nœud texte est un enfant `{}#text` à sa place dans `children` : c'est ce qui
49
+ fait qu'un texte déplacé autour d'un élément compte comme une différence.
50
+ """
51
+
52
+ name: str
53
+ attrs: dict[str, str] = field(default_factory=dict)
54
+ text: str = ""
55
+ children: list[Node] = field(default_factory=list)
56
+ fingerprint: str = field(init=False, default="")
57
+
58
+ def __post_init__(self) -> None:
59
+ h = hashlib.sha256()
60
+ h.update(self.name.encode())
61
+ h.update(b"\x00")
62
+ for key, value in self.attrs.items():
63
+ h.update(key.encode())
64
+ h.update(b"\x01")
65
+ h.update(value.encode())
66
+ h.update(b"\x02")
67
+ h.update(self.text.encode())
68
+ h.update(b"\x03")
69
+ for child in self.children:
70
+ h.update(child.fingerprint.encode())
71
+ self.fingerprint = h.hexdigest()
72
+
73
+ @property
74
+ def local_name(self) -> str:
75
+ return clark_local(self.name)
76
+
77
+ @property
78
+ def namespace(self) -> str:
79
+ return clark_uri(self.name)
80
+
81
+ @property
82
+ def is_text(self) -> bool:
83
+ """Vrai pour un nœud texte de contenu mixte, faux pour un élément."""
84
+ return self.name == TEXT_NODE
85
+
86
+ def summary(self, max_len: int = 60) -> str:
87
+ """Représentation courte d'un sous-arbre, pour la colonne d'un rapport."""
88
+ if self.is_text:
89
+ rendered = self.text
90
+ else:
91
+ attrs = "".join(f' {clark_local(k)}="{v}"' for k, v in self.attrs.items())
92
+ inner = self.text or ("…" if self.children else "")
93
+ rendered = f"<{self.local_name}{attrs}>{inner}"
94
+ if len(rendered) > max_len:
95
+ rendered = rendered[: max_len - 1] + "…"
96
+ return rendered
97
+
98
+ @classmethod
99
+ def from_json(cls, data: dict) -> Node:
100
+ """Reconstruit un arbre depuis la sortie JSON de `normalize.xsl`."""
101
+ return cls(
102
+ name=data["n"],
103
+ attrs=dict(data["a"]),
104
+ text=data["t"],
105
+ children=[cls.from_json(child) for child in data["c"]],
106
+ )
107
+
108
+
109
+ class Kind(StrEnum):
110
+ """Nature d'une différence. La valeur sert de libellé dans le rapport."""
111
+
112
+ ELEMENT_ADDED = "élément ajouté en B"
113
+ ELEMENT_REMOVED = "élément absent de B"
114
+ ELEMENT_RENAMED = "élément renommé"
115
+ ATTRIBUTE_ADDED = "attribut ajouté en B"
116
+ ATTRIBUTE_REMOVED = "attribut absent de B"
117
+ ATTRIBUTE_CHANGED = "attribut modifié"
118
+ TEXT_CHANGED = "texte modifié"
119
+ TEXT_ADDED = "texte ajouté en B"
120
+ TEXT_REMOVED = "texte absent de B"
121
+
122
+
123
+ @dataclass(slots=True, frozen=True)
124
+ class Difference:
125
+ path: str
126
+ kind: Kind
127
+ value_a: str | None = None
128
+ value_b: str | None = None
129
+
130
+ def as_dict(self) -> dict:
131
+ return {
132
+ "path": self.path,
133
+ "kind": self.kind.name.lower(),
134
+ "label": str(self.kind),
135
+ "a": self.value_a,
136
+ "b": self.value_b,
137
+ }
138
+
139
+
140
+ class Status(StrEnum):
141
+ IDENTICAL = "identique"
142
+ DIFFERENT = "different"
143
+ ERROR = "erreur"
144
+
145
+
146
+ @dataclass(slots=True)
147
+ class FileResult:
148
+ """Résultat de la comparaison d'un fichier, désigné par son chemin relatif."""
149
+
150
+ path: str
151
+ status: Status
152
+ differences: list[Difference] = field(default_factory=list)
153
+ error: str | None = None
154
+ truncated: int = 0
155
+
156
+ def as_dict(self) -> dict:
157
+ data: dict = {"path": self.path, "status": self.status.value}
158
+ if self.status is Status.DIFFERENT:
159
+ data["differences"] = [d.as_dict() for d in self.differences]
160
+ if self.truncated:
161
+ data["truncated"] = self.truncated
162
+ if self.error:
163
+ data["error"] = self.error
164
+ return data
165
+
166
+
167
+ @dataclass(slots=True)
168
+ class RunSummary:
169
+ """Bilan d'une campagne de comparaison.
170
+
171
+ `generated_at` date la campagne (heure locale, avec son décalage) : c'est la même
172
+ date que portent le rapport Markdown et le rapport JSON.
173
+ """
174
+
175
+ source_a: Path
176
+ source_b: Path
177
+ results: list[FileResult] = field(default_factory=list)
178
+ skipped: list[str] = field(default_factory=list)
179
+ generated_at: datetime = field(default_factory=lambda: datetime.now(tz=UTC).astimezone())
180
+
181
+ @property
182
+ def identical(self) -> list[FileResult]:
183
+ return [r for r in self.results if r.status is Status.IDENTICAL]
184
+
185
+ @property
186
+ def different(self) -> list[FileResult]:
187
+ return [r for r in self.results if r.status is Status.DIFFERENT]
188
+
189
+ @property
190
+ def errored(self) -> list[FileResult]:
191
+ return [r for r in self.results if r.status is Status.ERROR]
192
+
193
+ def as_dict(self) -> dict:
194
+ return {
195
+ "summary": {
196
+ "source_a": str(self.source_a),
197
+ "source_b": str(self.source_b),
198
+ "generated_at": self.generated_at.isoformat(timespec="seconds"),
199
+ "analyzed": len(self.results),
200
+ "identical": len(self.identical),
201
+ "different": len(self.different),
202
+ "errors": len(self.errored),
203
+ "skipped": len(self.skipped),
204
+ },
205
+ "files": [r.as_dict() for r in self.results],
206
+ "skipped": self.skipped,
207
+ }
xml_cmpr/py.typed ADDED
File without changes