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/__init__.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Comparaison de documents XML issus de deux sources différentes.
|
|
2
|
+
|
|
3
|
+
Le pipeline se lit en trois étapes indépendantes :
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from xml_cmpr import ReportFormat, render, run_comparison, write_report
|
|
7
|
+
|
|
8
|
+
summary = run_comparison(source_a, source_b) # analyse -> RunSummary
|
|
9
|
+
corps = render(summary, ReportFormat.JSON) # formatage -> str (JSON ou Markdown)
|
|
10
|
+
write_report(summary, sortie, "md") # écriture -> Path (facultatif)
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`render` retourne une chaîne : elle peut aussi bien partir dans une réponse HTTP
|
|
14
|
+
que vers le traitement suivant, sans jamais toucher au disque.
|
|
15
|
+
|
|
16
|
+
Les trois exceptions du paquet sont exportées ici : `ExcludeError` (XPath d'exclusion
|
|
17
|
+
invalide) et `ArchiveError` (source illisible, ou `ArchiveSizeError` pour une archive au-delà de
|
|
18
|
+
`MAX_EXTRACTED_SIZE` ou de `MAX_ENTRIES`) viennent de ce que l'appelant a fourni ;
|
|
19
|
+
`CanonicalError` ne traverse pas `run_comparison`, la campagne la reporte fichier par
|
|
20
|
+
fichier.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from .archive import (
|
|
24
|
+
MAX_ENTRIES,
|
|
25
|
+
MAX_EXTRACTED_SIZE,
|
|
26
|
+
ArchiveError,
|
|
27
|
+
ArchiveSizeError,
|
|
28
|
+
as_directory,
|
|
29
|
+
is_archive,
|
|
30
|
+
)
|
|
31
|
+
from .campaign import run_comparison
|
|
32
|
+
from .canonical import CanonicalError, Canonicalizer, ExcludeError
|
|
33
|
+
from .diff import diff_documents
|
|
34
|
+
from .discovery import discover
|
|
35
|
+
from .model import Difference, FileResult, Kind, Node, RunSummary, Status
|
|
36
|
+
from .report import (
|
|
37
|
+
DEFAULT_FORMATS,
|
|
38
|
+
FILENAMES,
|
|
39
|
+
ReportFormat,
|
|
40
|
+
parse_format,
|
|
41
|
+
render,
|
|
42
|
+
render_json,
|
|
43
|
+
render_markdown,
|
|
44
|
+
write_report,
|
|
45
|
+
write_reports,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"DEFAULT_FORMATS",
|
|
50
|
+
"FILENAMES",
|
|
51
|
+
"MAX_ENTRIES",
|
|
52
|
+
"MAX_EXTRACTED_SIZE",
|
|
53
|
+
"ArchiveError",
|
|
54
|
+
"ArchiveSizeError",
|
|
55
|
+
"CanonicalError",
|
|
56
|
+
"Canonicalizer",
|
|
57
|
+
"Difference",
|
|
58
|
+
"ExcludeError",
|
|
59
|
+
"FileResult",
|
|
60
|
+
"Kind",
|
|
61
|
+
"Node",
|
|
62
|
+
"ReportFormat",
|
|
63
|
+
"RunSummary",
|
|
64
|
+
"Status",
|
|
65
|
+
"as_directory",
|
|
66
|
+
"diff_documents",
|
|
67
|
+
"discover",
|
|
68
|
+
"is_archive",
|
|
69
|
+
"parse_format",
|
|
70
|
+
"render",
|
|
71
|
+
"render_json",
|
|
72
|
+
"render_markdown",
|
|
73
|
+
"run_comparison",
|
|
74
|
+
"write_report",
|
|
75
|
+
"write_reports",
|
|
76
|
+
]
|
xml_cmpr/archive.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Sources fournies sous forme d'archive ZIP.
|
|
2
|
+
|
|
3
|
+
Une source peut être un répertoire ou un fichier ZIP. Le ZIP est extrait dans un
|
|
4
|
+
répertoire temporaire, supprimé à la fin de la campagne, et le reste du pipeline
|
|
5
|
+
travaille sur ce répertoire comme sur n'importe quel autre : l'archive ne change
|
|
6
|
+
rien à la découverte, à la canonicalisation ni au diff.
|
|
7
|
+
|
|
8
|
+
L'extraction est bornée : une archive dont le contenu décompressé dépasse
|
|
9
|
+
`MAX_EXTRACTED_SIZE`, ou qui compte plus de `MAX_ENTRIES` entrées, est refusée avant
|
|
10
|
+
qu'un seul octet ne soit écrit. Chaque borne se lève explicitement (`size_limit=None`,
|
|
11
|
+
`entry_limit=None`), jamais par défaut.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import tempfile
|
|
17
|
+
import zipfile
|
|
18
|
+
from collections.abc import Iterator
|
|
19
|
+
from contextlib import contextmanager
|
|
20
|
+
from pathlib import Path, PurePosixPath
|
|
21
|
+
from typing import Literal
|
|
22
|
+
|
|
23
|
+
#: Taille décompressée maximale d'une archive, par source : 1 Gio.
|
|
24
|
+
MAX_EXTRACTED_SIZE = 1024**3
|
|
25
|
+
|
|
26
|
+
#: Nombre maximal d'entrées (fichiers et répertoires) d'une archive, par source.
|
|
27
|
+
MAX_ENTRIES = 100_000
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ArchiveError(Exception):
|
|
31
|
+
"""L'archive est illisible, corrompue, ou contient des chemins suspects."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ArchiveSizeError(ArchiveError):
|
|
35
|
+
"""L'archive dépasse une borne : volume décompressé ou nombre d'entrées.
|
|
36
|
+
|
|
37
|
+
`measure` dit laquelle (`"bytes"` ou `"entries"`) ; `size` et `limit` sont exprimés
|
|
38
|
+
dans cette unité.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self, message: str, size: int, limit: int, measure: Literal["bytes", "entries"]
|
|
43
|
+
) -> None:
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.size = size
|
|
46
|
+
self.limit = limit
|
|
47
|
+
self.measure = measure
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_archive(path: Path) -> bool:
|
|
51
|
+
"""Vrai si `path` est un fichier ZIP lisible."""
|
|
52
|
+
return path.is_file() and zipfile.is_zipfile(path)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@contextmanager
|
|
56
|
+
def as_directory(
|
|
57
|
+
source: Path,
|
|
58
|
+
*,
|
|
59
|
+
label: str = "source",
|
|
60
|
+
size_limit: int | None = MAX_EXTRACTED_SIZE,
|
|
61
|
+
entry_limit: int | None = MAX_ENTRIES,
|
|
62
|
+
) -> Iterator[Path]:
|
|
63
|
+
"""Donne un répertoire à comparer, que `source` soit un répertoire ou un ZIP.
|
|
64
|
+
|
|
65
|
+
Pour un ZIP, l'extraction a lieu dans un répertoire temporaire supprimé à la
|
|
66
|
+
sortie du contexte ; pour un répertoire, le chemin est rendu tel quel.
|
|
67
|
+
|
|
68
|
+
`size_limit` borne, en octets, le contenu décompressé d'un ZIP, et `entry_limit` son
|
|
69
|
+
nombre d'entrées (`ArchiveSizeError` au-delà) ; `None` lève la borne concernée. Un
|
|
70
|
+
répertoire n'est jamais concerné : rien n'est copié.
|
|
71
|
+
"""
|
|
72
|
+
if source.is_dir():
|
|
73
|
+
yield source
|
|
74
|
+
return
|
|
75
|
+
if is_archive(source):
|
|
76
|
+
with _extracted(
|
|
77
|
+
source, label=label, size_limit=size_limit, entry_limit=entry_limit
|
|
78
|
+
) as root:
|
|
79
|
+
yield root
|
|
80
|
+
return
|
|
81
|
+
if source.is_file():
|
|
82
|
+
raise ArchiveError(f"n'est ni un répertoire ni une archive ZIP : {source}")
|
|
83
|
+
raise ArchiveError(f"répertoire ou archive introuvable : {source}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@contextmanager
|
|
87
|
+
def _extracted(
|
|
88
|
+
archive_path: Path, *, label: str, size_limit: int | None, entry_limit: int | None
|
|
89
|
+
) -> Iterator[Path]:
|
|
90
|
+
with tempfile.TemporaryDirectory(prefix=f"xml-cmpr-{label}-") as tmp:
|
|
91
|
+
root = Path(tmp)
|
|
92
|
+
try:
|
|
93
|
+
with zipfile.ZipFile(archive_path) as archive:
|
|
94
|
+
_check_members(archive_path, archive)
|
|
95
|
+
_check_limits(archive_path, archive, size_limit, entry_limit)
|
|
96
|
+
archive.extractall(root)
|
|
97
|
+
except (zipfile.BadZipFile, OSError) as err:
|
|
98
|
+
raise ArchiveError(f"archive illisible : {archive_path} ({err})") from err
|
|
99
|
+
yield _strip_single_root(root)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _check_members(archive_path: Path, archive: zipfile.ZipFile) -> None:
|
|
103
|
+
"""Refuse les entrées qui sortiraient du répertoire d'extraction.
|
|
104
|
+
|
|
105
|
+
`extractall` neutralise déjà ces chemins, mais silencieusement : mieux vaut
|
|
106
|
+
une erreur explicite qu'une extraction dont il manque des fichiers.
|
|
107
|
+
"""
|
|
108
|
+
for name in archive.namelist():
|
|
109
|
+
pure = PurePosixPath(name)
|
|
110
|
+
if pure.is_absolute() or name.startswith("\\") or ".." in pure.parts:
|
|
111
|
+
raise ArchiveError(f"chemin d'entrée suspect dans {archive_path} : {name}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _check_limits(
|
|
115
|
+
archive_path: Path,
|
|
116
|
+
archive: zipfile.ZipFile,
|
|
117
|
+
size_limit: int | None,
|
|
118
|
+
entry_limit: int | None,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Refuse l'archive au-delà de `entry_limit` entrées ou de `size_limit` octets.
|
|
121
|
+
|
|
122
|
+
Les deux bornes se complètent : des millions de fichiers vides pèsent zéro octet et
|
|
123
|
+
épuisent pourtant les inodes du répertoire temporaire.
|
|
124
|
+
|
|
125
|
+
Les tailles viennent de l'annuaire central, donc sans rien décompresser. Une
|
|
126
|
+
archive qui sous-déclare ses tailles ne passe pas pour autant : `zipfile` ne
|
|
127
|
+
produit jamais plus d'octets que la taille déclarée d'une entrée, et le CRC de
|
|
128
|
+
l'entrée tronquée échoue (`BadZipFile`, donc `ArchiveError`).
|
|
129
|
+
"""
|
|
130
|
+
infos = archive.infolist()
|
|
131
|
+
if entry_limit is not None and len(infos) > entry_limit:
|
|
132
|
+
raise ArchiveSizeError(
|
|
133
|
+
f"archive aux entrées trop nombreuses : {archive_path} — {_count(len(infos))} "
|
|
134
|
+
f"entrées, pour une limite de {_count(entry_limit)}",
|
|
135
|
+
len(infos),
|
|
136
|
+
entry_limit,
|
|
137
|
+
"entries",
|
|
138
|
+
)
|
|
139
|
+
size = sum(info.file_size for info in infos)
|
|
140
|
+
if size_limit is not None and size > size_limit:
|
|
141
|
+
raise ArchiveSizeError(
|
|
142
|
+
f"archive trop volumineuse : {archive_path} — {_human(size)} une fois "
|
|
143
|
+
f"décompressée, pour une limite de {_human(size_limit)}",
|
|
144
|
+
size,
|
|
145
|
+
size_limit,
|
|
146
|
+
"bytes",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _count(n: int) -> str:
|
|
151
|
+
"""`100000` -> `"100 000"`, avec une espace fine insécable comme séparateur."""
|
|
152
|
+
return f"{n:_}".replace("_", "\u202f")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _human(size: int) -> str:
|
|
156
|
+
"""`1610612736` -> `"1,5 Gio"` : une taille lisible dans un message d'erreur."""
|
|
157
|
+
value = float(size)
|
|
158
|
+
for unit in ("o", "Kio", "Mio", "Gio"):
|
|
159
|
+
if value < 1024 or unit == "Gio":
|
|
160
|
+
break
|
|
161
|
+
value /= 1024
|
|
162
|
+
text = f"{value:.0f}" if value == int(value) else f"{value:.1f}".replace(".", ",")
|
|
163
|
+
return f"{text} {unit}"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _strip_single_root(root: Path) -> Path:
|
|
167
|
+
"""Descend dans l'unique répertoire de tête d'une archive, s'il y en a un.
|
|
168
|
+
|
|
169
|
+
Un ZIP enveloppe souvent son contenu dans un répertoire dont le nom diffère
|
|
170
|
+
d'une chaîne de production à l'autre ; le garder ferait échouer l'appariement,
|
|
171
|
+
qui se fait sur le chemin relatif à la racine de la source.
|
|
172
|
+
"""
|
|
173
|
+
entries = [entry for entry in root.iterdir() if entry.name != "__MACOSX"]
|
|
174
|
+
if len(entries) == 1 and entries[0].is_dir():
|
|
175
|
+
return entries[0]
|
|
176
|
+
return root
|
xml_cmpr/campaign.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""L'analyse : comparer deux répertoires et produire le bilan de la campagne.
|
|
2
|
+
|
|
3
|
+
Première étape du pipeline `analyse -> formatage -> écriture`. Rien ici ne connaît
|
|
4
|
+
le format du rapport ni le système de fichiers de sortie : `run_comparison` rend un
|
|
5
|
+
`RunSummary`, que l'appelant formate (`report.render`) et écrit s'il le souhaite
|
|
6
|
+
(`report.write_report`).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .canonical import CanonicalError, Canonicalizer
|
|
15
|
+
from .diff import diff_documents
|
|
16
|
+
from .discovery import discover
|
|
17
|
+
from .model import FileResult, RunSummary, Status
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_comparison(
|
|
21
|
+
source_a: Path,
|
|
22
|
+
source_b: Path,
|
|
23
|
+
*,
|
|
24
|
+
pattern: str = "*.xml",
|
|
25
|
+
recursive: bool = True,
|
|
26
|
+
keep_whitespace: bool = False,
|
|
27
|
+
exclude_xpaths: list[str] | None = None,
|
|
28
|
+
max_diffs_per_file: int = 50,
|
|
29
|
+
verbose: bool = False,
|
|
30
|
+
label_a: Path | None = None,
|
|
31
|
+
label_b: Path | None = None,
|
|
32
|
+
) -> RunSummary:
|
|
33
|
+
"""Compare les deux répertoires et retourne le bilan de la campagne.
|
|
34
|
+
|
|
35
|
+
`label_a` / `label_b` remplacent les chemins affichés au rapport, pour nommer
|
|
36
|
+
l'archive d'origine plutôt que le répertoire temporaire où elle a été extraite.
|
|
37
|
+
"""
|
|
38
|
+
pairing = discover(source_a, source_b, pattern=pattern, recursive=recursive)
|
|
39
|
+
summary = RunSummary(
|
|
40
|
+
source_a=label_a or source_a, source_b=label_b or source_b, skipped=pairing.skipped
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
with Canonicalizer(
|
|
44
|
+
keep_whitespace=keep_whitespace,
|
|
45
|
+
exclude_xpaths=exclude_xpaths,
|
|
46
|
+
) as canon:
|
|
47
|
+
for relative, path_a, path_b in pairing.matched:
|
|
48
|
+
if verbose:
|
|
49
|
+
print(f"… {relative}", file=sys.stderr)
|
|
50
|
+
summary.results.append(
|
|
51
|
+
_compare_one(canon, relative, path_a, path_b, max_diffs_per_file)
|
|
52
|
+
)
|
|
53
|
+
return summary
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _compare_one(
|
|
57
|
+
canon: Canonicalizer,
|
|
58
|
+
relative: str,
|
|
59
|
+
path_a: Path,
|
|
60
|
+
path_b: Path,
|
|
61
|
+
max_diffs: int,
|
|
62
|
+
) -> FileResult:
|
|
63
|
+
try:
|
|
64
|
+
tree_a, fingerprint_a = canon.load(path_a)
|
|
65
|
+
tree_b, fingerprint_b = canon.load(path_b)
|
|
66
|
+
except CanonicalError as err:
|
|
67
|
+
return FileResult(relative, Status.ERROR, error=str(err))
|
|
68
|
+
|
|
69
|
+
if fingerprint_a == fingerprint_b:
|
|
70
|
+
return FileResult(relative, Status.IDENTICAL)
|
|
71
|
+
|
|
72
|
+
differences = diff_documents(tree_a, tree_b)
|
|
73
|
+
truncated = 0
|
|
74
|
+
if 0 < max_diffs < len(differences):
|
|
75
|
+
truncated = len(differences) - max_diffs
|
|
76
|
+
differences = differences[:max_diffs]
|
|
77
|
+
return FileResult(relative, Status.DIFFERENT, differences=differences, truncated=truncated)
|
xml_cmpr/canonical.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Mise sous forme canonique des documents XML, via Saxon.
|
|
2
|
+
|
|
3
|
+
Un unique `PySaxonProcessor` et une unique compilation de `normalize.xsl` servent toute
|
|
4
|
+
la campagne : leur initialisation coûte bien plus cher qu'une transformation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
from contextlib import suppress
|
|
12
|
+
from importlib.resources import files
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from types import TracebackType
|
|
15
|
+
|
|
16
|
+
from saxonche import PySaxonApiError, PySaxonProcessor, PyXPathProcessor, PyXsltExecutable
|
|
17
|
+
|
|
18
|
+
from .model import Node
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CanonicalError(Exception):
|
|
22
|
+
"""Le document n'a pas pu être lu ou normalisé (XML malformé, entité absente…)."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ExcludeError(Exception):
|
|
26
|
+
"""L'expression XPath d'exclusion (`--exclude`) est invalide."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Canonicalizer:
|
|
30
|
+
"""Transforme un fichier XML en son arbre canonique et son empreinte."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
keep_whitespace: bool = False,
|
|
36
|
+
exclude_xpaths: list[str] | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._keep_whitespace = keep_whitespace
|
|
39
|
+
self._exclude_expr = "(" + ")|(".join(exclude_xpaths) + ")" if exclude_xpaths else None
|
|
40
|
+
self._processor: PySaxonProcessor | None = None
|
|
41
|
+
self._executable: PyXsltExecutable | None = None
|
|
42
|
+
self._xpath_processor: PyXPathProcessor | None = None
|
|
43
|
+
|
|
44
|
+
def __enter__(self) -> Canonicalizer:
|
|
45
|
+
self._processor = PySaxonProcessor(license=False)
|
|
46
|
+
self._processor.__enter__()
|
|
47
|
+
stylesheet = files("xml_cmpr.resources").joinpath("normalize.xsl").read_text("utf-8")
|
|
48
|
+
xslt = self._processor.new_xslt30_processor()
|
|
49
|
+
self._executable = xslt.compile_stylesheet(stylesheet_text=stylesheet)
|
|
50
|
+
self._executable.set_parameter(
|
|
51
|
+
"keep-whitespace", self._processor.make_boolean_value(self._keep_whitespace)
|
|
52
|
+
)
|
|
53
|
+
if self._exclude_expr is not None:
|
|
54
|
+
self._xpath_processor = self._processor.new_xpath_processor()
|
|
55
|
+
probe = self._processor.parse_xml(xml_text="<x/>")
|
|
56
|
+
self._xpath_processor.set_context(xdm_item=probe)
|
|
57
|
+
try:
|
|
58
|
+
self._xpath_processor.evaluate(self._exclude_expr)
|
|
59
|
+
except PySaxonApiError as err:
|
|
60
|
+
raise ExcludeError(_clean_message(err)) from err
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def __exit__(
|
|
64
|
+
self,
|
|
65
|
+
exc_type: type[BaseException] | None,
|
|
66
|
+
exc: BaseException | None,
|
|
67
|
+
tb: TracebackType | None,
|
|
68
|
+
) -> None:
|
|
69
|
+
if self._processor is not None:
|
|
70
|
+
self._processor.__exit__(exc_type, exc, tb)
|
|
71
|
+
self._processor = None
|
|
72
|
+
self._executable = None
|
|
73
|
+
self._xpath_processor = None
|
|
74
|
+
|
|
75
|
+
def canonical_json(self, path: Path) -> str:
|
|
76
|
+
"""Forme canonique du document, en JSON. Deux documents sémantiquement
|
|
77
|
+
identiques donnent exactement la même chaîne."""
|
|
78
|
+
if self._executable is None or self._processor is None:
|
|
79
|
+
raise RuntimeError("Canonicalizer doit être utilisé comme context manager")
|
|
80
|
+
try:
|
|
81
|
+
node = self._processor.parse_xml(xml_file_name=str(path))
|
|
82
|
+
if node is None:
|
|
83
|
+
raise CanonicalError(f"document illisible : {path}")
|
|
84
|
+
if self._exclude_expr is not None and self._xpath_processor is not None:
|
|
85
|
+
self._xpath_processor.set_context(xdm_item=node)
|
|
86
|
+
excluded = self._xpath_processor.evaluate(self._exclude_expr)
|
|
87
|
+
if excluded is None:
|
|
88
|
+
# `remove_parameter` lève KeyError si le paramètre n'a pas encore
|
|
89
|
+
# été posé — malgré sa docstring, qui annonce un retour booléen.
|
|
90
|
+
with suppress(KeyError):
|
|
91
|
+
self._executable.remove_parameter("excluded")
|
|
92
|
+
else:
|
|
93
|
+
self._executable.set_parameter("excluded", excluded)
|
|
94
|
+
result = self._executable.transform_to_string(xdm_node=node)
|
|
95
|
+
except PySaxonApiError as err:
|
|
96
|
+
raise CanonicalError(_clean_message(err)) from err
|
|
97
|
+
if result is None:
|
|
98
|
+
raise CanonicalError(f"transformation sans résultat : {path}")
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
def load(self, path: Path) -> tuple[Node, str]:
|
|
102
|
+
"""Retourne l'arbre canonique et l'empreinte du document."""
|
|
103
|
+
canonical = self.canonical_json(path)
|
|
104
|
+
fingerprint = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
105
|
+
return Node.from_json(json.loads(canonical)), fingerprint
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _clean_message(err: PySaxonApiError) -> str:
|
|
109
|
+
"""Les messages Saxon sont multilignes ; on garde la première ligne utile."""
|
|
110
|
+
text = " ".join(str(err).split())
|
|
111
|
+
return text or err.__class__.__name__
|