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/__init__.py +76 -0
- xml_cmpr/archive.py +176 -0
- xml_cmpr/campaign.py +77 -0
- xml_cmpr/canonical.py +111 -0
- xml_cmpr/cli.py +261 -0
- xml_cmpr/diff.py +181 -0
- xml_cmpr/discovery.py +40 -0
- xml_cmpr/model.py +207 -0
- xml_cmpr/py.typed +0 -0
- xml_cmpr/report.py +184 -0
- xml_cmpr/resources/__init__.py +0 -0
- xml_cmpr/resources/normalize.xsl +94 -0
- xml_cmpr-0.2.0.dist-info/METADATA +389 -0
- xml_cmpr-0.2.0.dist-info/RECORD +17 -0
- xml_cmpr-0.2.0.dist-info/WHEEL +4 -0
- xml_cmpr-0.2.0.dist-info/entry_points.txt +2 -0
- xml_cmpr-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|