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 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__