python-docx-oss 0.3.2__py3-none-any.whl → 0.4.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.
docx/__init__.py CHANGED
@@ -9,14 +9,16 @@ from __future__ import annotations
9
9
  from typing import TYPE_CHECKING, Type
10
10
 
11
11
  from docx.api import Document
12
+ from docx.inspection import FingerprintPolicy, fingerprint, inspect
13
+ from docx.transforms import normalize, repair
12
14
 
13
15
  if TYPE_CHECKING:
14
16
  from docx.opc.part import Part
15
17
 
16
- __version__ = "0.3.2"
18
+ __version__ = "0.4.0"
17
19
 
18
20
 
19
- __all__ = ["Document"]
21
+ __all__ = ["Document", "FingerprintPolicy", "fingerprint", "inspect", "normalize", "repair"]
20
22
 
21
23
 
22
24
  # -- register custom Part classes with opc package reader --
@@ -0,0 +1,24 @@
1
+ """Read-only package inspection and configurable content fingerprints."""
2
+
3
+ from docx.inspection.fingerprint import FingerprintPolicy, fingerprint
4
+ from docx.inspection.reader import inspect
5
+ from docx.inspection.result import (
6
+ Features,
7
+ Finding,
8
+ PackageInspection,
9
+ PackageInventory,
10
+ PartInventory,
11
+ RelationshipInventory,
12
+ )
13
+
14
+ __all__ = [
15
+ "Features",
16
+ "Finding",
17
+ "FingerprintPolicy",
18
+ "PackageInspection",
19
+ "PackageInventory",
20
+ "PartInventory",
21
+ "RelationshipInventory",
22
+ "fingerprint",
23
+ "inspect",
24
+ ]
@@ -0,0 +1,157 @@
1
+ """XML event targets keep no element tree, including for very large story parts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+
7
+ from docx.inspection.result import Features
8
+
9
+ W_NAMESPACES = (
10
+ "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
11
+ "http://purl.oclc.org/ooxml/wordprocessingml/main",
12
+ )
13
+
14
+
15
+ def word_tag(tag: str) -> str:
16
+ for namespace in W_NAMESPACES:
17
+ prefix = "{" + namespace + "}"
18
+ if tag.startswith(prefix):
19
+ return tag[len(prefix) :]
20
+ return ""
21
+
22
+
23
+ class _FieldCommand:
24
+ """Match only the first token, keeping constant state across parser callbacks."""
25
+
26
+ def __init__(self):
27
+ self._length = 0
28
+ self._done = False
29
+ self.matched = False
30
+
31
+ def feed(self, text: str) -> None:
32
+ for char in text:
33
+ if self._done:
34
+ break
35
+ if char.isspace():
36
+ if self._length:
37
+ self.finish()
38
+ elif self._length < len("HYPERLINK") and char.upper() == "HYPERLINK"[self._length]:
39
+ self._length += 1
40
+ else:
41
+ self._done = True
42
+
43
+ def finish(self) -> None:
44
+ if not self._done:
45
+ self.matched = self._length == len("HYPERLINK")
46
+ self._done = True
47
+
48
+
49
+ class XmlFacts:
50
+ """Observe field instructions across runs, without retaining instruction text."""
51
+
52
+ def __init__(self, scan_features: bool = False):
53
+ self.scan_features = scan_features
54
+ self.field_hyperlink = False
55
+ self.split_hyperlink_runs = False
56
+ self._stack: list[str] = []
57
+ self._hyperlinks: list[tuple[int, int]] = []
58
+ self._instruction = False
59
+ self._fields: list[_FieldCommand] = []
60
+ self._loose_command = _FieldCommand()
61
+
62
+ def _finish_command(self, command: _FieldCommand) -> None:
63
+ command.finish()
64
+ self.field_hyperlink |= command.matched
65
+
66
+ def start(self, tag: str, attrs: Mapping[str, str]) -> None:
67
+ name = word_tag(tag) if self.scan_features else ""
68
+ self._stack.append(name)
69
+ if name == "hyperlink":
70
+ self._hyperlinks.append((len(self._stack), 0))
71
+ elif name == "r" and self._hyperlinks:
72
+ depth, count = self._hyperlinks[-1]
73
+ if len(self._stack) == depth + 1:
74
+ self._hyperlinks[-1] = (depth, count + 1)
75
+ self.split_hyperlink_runs |= count >= 1
76
+ elif name == "fldChar":
77
+ namespace = tag[: tag.index("}") + 1]
78
+ kind = attrs.get(namespace + "fldCharType")
79
+ if kind == "begin":
80
+ self._fields.append(_FieldCommand())
81
+ elif kind in ("separate", "end"):
82
+ command = self._fields[-1] if self._fields else self._loose_command
83
+ self._finish_command(command)
84
+ if kind == "end":
85
+ if self._fields:
86
+ self._fields.pop()
87
+ self._loose_command = _FieldCommand()
88
+ elif name == "instrText":
89
+ self._instruction = True
90
+ elif name == "fldSimple":
91
+ namespace = tag[: tag.index("}") + 1]
92
+ command = _FieldCommand()
93
+ command.feed(attrs.get(namespace + "instr", ""))
94
+ self._finish_command(command)
95
+ elif name == "p":
96
+ self._loose_command = _FieldCommand()
97
+
98
+ def end(self, tag: str) -> None:
99
+ name = self._stack.pop()
100
+ if name == "hyperlink":
101
+ self._hyperlinks.pop()
102
+ elif name == "instrText":
103
+ self._instruction = False
104
+ elif name == "p":
105
+ self._finish_command(self._loose_command)
106
+ self._loose_command = _FieldCommand()
107
+
108
+ def data(self, text: str) -> None:
109
+ if self._instruction:
110
+ command = self._fields[-1] if self._fields else self._loose_command
111
+ command.feed(text)
112
+ self.field_hyperlink |= command.matched
113
+
114
+ def start_ns(self, prefix: str | None, uri: str) -> None:
115
+ pass
116
+
117
+ def end_ns(self, prefix: str | None) -> None:
118
+ pass
119
+
120
+ def comment(self, text: str) -> None:
121
+ pass
122
+
123
+ def pi(self, target: str, data: str) -> None:
124
+ pass
125
+
126
+ def doctype(self, name: str | None, public_id: str | None, system_id: str | None) -> None:
127
+ raise ValueError("DTDs are not supported in package XML")
128
+
129
+ def close(self) -> Features:
130
+ self._finish_command(self._loose_command)
131
+ for command in self._fields:
132
+ self._finish_command(command)
133
+ return Features(self.field_hyperlink, self.split_hyperlink_runs)
134
+
135
+
136
+ class MetadataFacts(XmlFacts):
137
+ """Keep OPC metadata entries, never arbitrary XML text or unrelated elements."""
138
+
139
+ def __init__(self):
140
+ super().__init__()
141
+ self.elements: list[tuple[str, dict[str, str]]] = []
142
+ self.root_tag: str | None = None
143
+ self._depth = 0
144
+
145
+ def start(self, tag: str, attrs: Mapping[str, str]) -> None:
146
+ if self._depth == 0:
147
+ self.root_tag = tag
148
+ self._depth += 1
149
+ if self._depth == 2 and tag in (
150
+ "{http://schemas.openxmlformats.org/package/2006/content-types}Default",
151
+ "{http://schemas.openxmlformats.org/package/2006/content-types}Override",
152
+ "{http://schemas.openxmlformats.org/package/2006/relationships}Relationship",
153
+ ):
154
+ self.elements.append((tag, dict(attrs)))
155
+
156
+ def end(self, tag: str) -> None:
157
+ self._depth -= 1
@@ -0,0 +1,150 @@
1
+ """Stream sorted package members into a versioned, unambiguously framed digest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from collections.abc import Callable, Mapping
7
+ from dataclasses import dataclass
8
+ from fnmatch import fnmatchcase
9
+ from urllib.parse import unquote
10
+ from zipfile import ZipFile
11
+
12
+ from lxml import etree
13
+
14
+ from docx.inspection.features import MetadataFacts
15
+ from docx.inspection.reader import CT_NS, REL_NS, is_xml, scan_xml
16
+ from docx.inspection.source import PackageSource, open_source
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class FingerprintPolicy:
21
+ """Explicit exclusions; the default includes every member and all XML content.
22
+
23
+ Part patterns match package-relative names using shell-style fnmatch (``*``
24
+ also matches ``/``). XML exclusions use expanded names, e.g. ``{namespace}tag``.
25
+ Relationship exclusions match complete Type URIs. Excluding a part does not
26
+ implicitly remove its relationships or Content-Type declaration.
27
+ """
28
+
29
+ exclude_parts: tuple[str, ...] = ()
30
+ exclude_relationship_types: tuple[str, ...] = ()
31
+ exclude_xml_tags: tuple[str, ...] = ()
32
+ exclude_xml_attributes: tuple[str, ...] = ()
33
+ canonicalize_xml: bool = True
34
+
35
+ def __post_init__(self) -> None:
36
+ if not self.canonicalize_xml and (
37
+ self.exclude_relationship_types or self.exclude_xml_tags or self.exclude_xml_attributes
38
+ ):
39
+ raise ValueError("XML exclusions require canonicalize_xml=True")
40
+
41
+
42
+ class _CanonicalXml:
43
+ def __init__(self, write: Callable[[str], None], policy: FingerprintPolicy):
44
+ self._writer = etree.C14NWriterTarget(
45
+ write,
46
+ with_comments=True,
47
+ exclude_tags=set(policy.exclude_xml_tags),
48
+ exclude_attrs=set(policy.exclude_xml_attributes),
49
+ )
50
+ self._excluded_relationships = policy.exclude_relationship_types
51
+ self._skip_depth = 0
52
+
53
+ def start_ns(self, prefix: str | None, uri: str) -> None:
54
+ self._writer.start_ns(prefix or "", uri)
55
+
56
+ def end_ns(self, prefix: str | None) -> None:
57
+ pass
58
+
59
+ def start(self, tag: str, attrs: Mapping[str, str]) -> None:
60
+ if self._skip_depth or (
61
+ tag == f"{{{REL_NS}}}Relationship" and attrs.get("Type") in self._excluded_relationships
62
+ ):
63
+ self._skip_depth += 1
64
+ else:
65
+ self._writer.start(tag, dict(attrs))
66
+
67
+ def end(self, tag: str) -> None:
68
+ if self._skip_depth:
69
+ self._skip_depth -= 1
70
+ else:
71
+ self._writer.end(tag)
72
+
73
+ def data(self, text: str) -> None:
74
+ if not self._skip_depth:
75
+ self._writer.data(text)
76
+
77
+ def comment(self, text: str) -> None:
78
+ if not self._skip_depth:
79
+ self._writer.comment(text)
80
+
81
+ def pi(self, target: str, data: str) -> None:
82
+ if not self._skip_depth:
83
+ self._writer.pi(target, data)
84
+
85
+ def doctype(self, name: str | None, public_id: str | None, system_id: str | None) -> None:
86
+ raise ValueError("DTDs are not supported in package XML")
87
+
88
+ def close(self) -> None:
89
+ self._writer.close()
90
+
91
+
92
+ def _xml_types(package: ZipFile) -> tuple[dict[str, str], dict[str, str]]:
93
+ if "[Content_Types].xml" not in package.namelist():
94
+ return {}, {}
95
+ target = MetadataFacts()
96
+ try:
97
+ scan_xml(package, package.getinfo("[Content_Types].xml"), target)
98
+ except (etree.XMLSyntaxError, ValueError) as exc:
99
+ raise ValueError(f"Cannot fingerprint XML part '[Content_Types].xml': {exc}") from exc
100
+ defaults: dict[str, str] = {}
101
+ overrides: dict[str, str] = {}
102
+ for tag, attrs in target.elements:
103
+ if tag == f"{{{CT_NS}}}Default":
104
+ defaults[attrs.get("Extension", "").lower()] = attrs.get("ContentType", "")
105
+ elif tag == f"{{{CT_NS}}}Override":
106
+ overrides[unquote(attrs.get("PartName", "")).lstrip("/")] = attrs.get("ContentType", "")
107
+ return defaults, overrides
108
+
109
+
110
+ def fingerprint(source: PackageSource, *, policy: FingerprintPolicy | None = None) -> str:
111
+ """Return a SHA-256 content fingerprint, ignoring ZIP order and ZIP metadata.
112
+
113
+ XML uses C14N 2.0 with comments, preserving text whitespace and namespace
114
+ prefixes. This is a package-content comparison, not a semantic Word diff.
115
+ Malformed XML and ambiguous duplicate names raise; no partial hash is returned.
116
+ Stream ownership and position guarantees match inspect().
117
+ """
118
+ policy = FingerprintPolicy() if policy is None else policy
119
+ with open_source(source) as stream, ZipFile(stream) as package:
120
+ members = [member for member in package.infolist() if not member.is_dir()]
121
+ if len({member.filename for member in members}) != len(members):
122
+ raise ValueError("Cannot fingerprint a package with duplicate member names")
123
+ defaults, overrides = _xml_types(package) if policy.canonicalize_xml else ({}, {})
124
+ digest = hashlib.sha256(b"python-docx-oss package fingerprint v1\0")
125
+ for member in sorted(members, key=lambda member: member.filename):
126
+ name = member.filename
127
+ if any(fnmatchcase(name, pattern) for pattern in policy.exclude_parts):
128
+ continue
129
+ part_digest = hashlib.sha256()
130
+ content_type = overrides.get(name) or defaults.get(name.rsplit(".", 1)[-1].lower())
131
+ with package.open(member) as content:
132
+ if policy.canonicalize_xml and is_xml(name, content_type):
133
+ target = _CanonicalXml(
134
+ lambda text: part_digest.update(text.encode("utf-8")), policy
135
+ )
136
+ parser = etree.XMLParser(target=target, resolve_entities=False, no_network=True)
137
+ try:
138
+ while chunk := content.read(64 * 1024):
139
+ parser.feed(chunk)
140
+ parser.close()
141
+ except (etree.XMLSyntaxError, ValueError) as exc:
142
+ raise ValueError(f"Cannot fingerprint XML part {name!r}: {exc}") from exc
143
+ else:
144
+ while chunk := content.read(64 * 1024):
145
+ part_digest.update(chunk)
146
+ encoded_name = name.encode("utf-8")
147
+ digest.update(len(encoded_name).to_bytes(8, "big"))
148
+ digest.update(encoded_name)
149
+ digest.update(part_digest.digest())
150
+ return digest.hexdigest()
@@ -0,0 +1,245 @@
1
+ """Tolerant OPC inventory, independent of Document and the eager package reader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import posixpath
6
+ import zlib
7
+ from collections import deque
8
+ from urllib.parse import unquote, urlsplit
9
+ from zipfile import BadZipFile, ZipFile, ZipInfo
10
+
11
+ from lxml import etree
12
+
13
+ from docx.inspection.features import MetadataFacts, XmlFacts
14
+ from docx.inspection.result import (
15
+ Features,
16
+ Finding,
17
+ PackageInspection,
18
+ PackageInventory,
19
+ PartInventory,
20
+ RelationshipInventory,
21
+ )
22
+ from docx.inspection.source import PackageSource, open_source
23
+
24
+ CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
25
+ REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
26
+ OFFICE_REL_BASES = (
27
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/",
28
+ "http://purl.oclc.org/ooxml/officeDocument/relationships/",
29
+ )
30
+ PACKAGE_ERRORS = (
31
+ BadZipFile,
32
+ EOFError,
33
+ OSError,
34
+ RuntimeError,
35
+ NotImplementedError,
36
+ UnicodeError,
37
+ zlib.error,
38
+ )
39
+ STORIES = {"officeDocument", "header", "footer", "footnotes", "endnotes", "comments"}
40
+
41
+
42
+ def relationship_kind(relationship_type: str) -> str:
43
+ for base in OFFICE_REL_BASES:
44
+ if relationship_type.startswith(base):
45
+ return relationship_type[len(base) :]
46
+ return ""
47
+
48
+
49
+ def relationship_source(name: str) -> str | None:
50
+ if name == "_rels/.rels":
51
+ return "/"
52
+ directory, filename = posixpath.split(name)
53
+ if posixpath.basename(directory) == "_rels" and filename.endswith(".rels"):
54
+ return posixpath.join(posixpath.dirname(directory), filename[:-5])
55
+ return None
56
+
57
+
58
+ def resolve_target(source: str, target: str) -> str:
59
+ path = unquote(urlsplit(target).path)
60
+ if path.startswith("/"):
61
+ return posixpath.normpath(path)[1:]
62
+ directory = "" if source == "/" else posixpath.dirname(source)
63
+ return posixpath.normpath(posixpath.join(directory, path))
64
+
65
+
66
+ def scan_xml(package: ZipFile, member: ZipInfo, target: XmlFacts) -> Features:
67
+ parser = etree.XMLParser(target=target, resolve_entities=False, no_network=True)
68
+ nonblank = False
69
+ with package.open(member) as stream:
70
+ while chunk := stream.read(64 * 1024):
71
+ nonblank |= bool(chunk.strip(b" \t\r\n"))
72
+ parser.feed(chunk)
73
+ if not nonblank:
74
+ raise EmptyXmlError("XML part is empty or contains only whitespace")
75
+ return parser.close()
76
+
77
+
78
+ class EmptyXmlError(ValueError):
79
+ """An XML member contains no document content."""
80
+
81
+
82
+ def is_xml(name: str, content_type: str | None) -> bool:
83
+ return name.endswith((".xml", ".rels")) or bool(
84
+ content_type
85
+ and (content_type.endswith("+xml") or content_type in ("text/xml", "application/xml"))
86
+ )
87
+
88
+
89
+ class _Inspection:
90
+ def __init__(self, package: ZipFile):
91
+ self.package = package
92
+ self.findings: list[Finding] = []
93
+ self.members: dict[str, ZipInfo] = {}
94
+ self.defaults: dict[str, str] = {}
95
+ self.overrides: dict[str, str] = {}
96
+ self.relationships: list[RelationshipInventory] = []
97
+ for member in package.infolist():
98
+ name = member.filename
99
+ if member.is_dir():
100
+ continue
101
+ if name in self.members:
102
+ self.findings.append(Finding("duplicate_member", name))
103
+ else:
104
+ self.members[name] = member
105
+ if name.startswith("/") or "\\" in name or ".." in name.split("/"):
106
+ self.findings.append(Finding("unsafe_member_name", name))
107
+
108
+ def _scan(self, name: str, target: XmlFacts) -> Features:
109
+ member = self.members[name]
110
+ if member.file_size == 0:
111
+ self.findings.append(Finding("empty_xml_part", name))
112
+ return Features()
113
+ try:
114
+ return scan_xml(self.package, member, target)
115
+ except EmptyXmlError:
116
+ self.findings.append(Finding("empty_xml_part", name))
117
+ except (etree.XMLSyntaxError, ValueError) as exc:
118
+ self.findings.append(Finding("unparseable_xml_part", name, str(exc)))
119
+ except PACKAGE_ERRORS as exc:
120
+ self.findings.append(Finding("unreadable_part", name, str(exc)))
121
+ return Features()
122
+
123
+ def _read_metadata(self) -> None:
124
+ if "[Content_Types].xml" not in self.members:
125
+ self.findings.append(Finding("missing_content_types"))
126
+ else:
127
+ target = MetadataFacts()
128
+ self._scan("[Content_Types].xml", target)
129
+ if target.root_tag != f"{{{CT_NS}}}Types":
130
+ self.findings.append(Finding("invalid_content_types", "[Content_Types].xml"))
131
+ for tag, attrs in target.elements:
132
+ if tag == f"{{{CT_NS}}}Default":
133
+ self.defaults[attrs.get("Extension", "").lower()] = attrs.get("ContentType", "")
134
+ elif tag == f"{{{CT_NS}}}Override":
135
+ self.overrides[unquote(attrs.get("PartName", "")).lstrip("/")] = attrs.get(
136
+ "ContentType", ""
137
+ )
138
+ for name in sorted(self.members):
139
+ source = relationship_source(name)
140
+ if source is None:
141
+ continue
142
+ target = MetadataFacts()
143
+ self._scan(name, target)
144
+ if target.root_tag != f"{{{REL_NS}}}Relationships":
145
+ self.findings.append(Finding("invalid_relationships", name))
146
+ continue
147
+ ids: set[str] = set()
148
+ for tag, attrs in target.elements:
149
+ if tag != f"{{{REL_NS}}}Relationship":
150
+ continue
151
+ raw_target = attrs.get("Target", "")
152
+ rel_id = attrs.get("Id", "")
153
+ if not raw_target or not rel_id or not attrs.get("Type") or rel_id in ids:
154
+ self.findings.append(Finding("invalid_relationships", name))
155
+ continue
156
+ ids.add(rel_id)
157
+ external = attrs.get("TargetMode") == "External"
158
+ try:
159
+ resolved = raw_target if external else resolve_target(source, raw_target)
160
+ except ValueError as exc:
161
+ self.findings.append(Finding("invalid_relationships", name, str(exc)))
162
+ continue
163
+ relationship = RelationshipInventory(
164
+ source, rel_id, attrs["Type"], resolved, external
165
+ )
166
+ self.relationships.append(relationship)
167
+ if not external and resolved not in self.members:
168
+ self.findings.append(
169
+ Finding("missing_relationship_target", name, target=resolved)
170
+ )
171
+
172
+ def _reachable(self) -> set[str]:
173
+ edges: dict[str, list[str]] = {}
174
+ for rel in self.relationships:
175
+ if not rel.external:
176
+ edges.setdefault(rel.source, []).append(rel.target)
177
+ reached = {"/"}
178
+ pending = deque(["/"])
179
+ while pending:
180
+ for target in edges.get(pending.popleft(), []):
181
+ if target not in reached and target in self.members:
182
+ reached.add(target)
183
+ pending.append(target)
184
+ reached.add("[Content_Types].xml")
185
+ reached.update(name for name in self.members if relationship_source(name) in reached)
186
+ return reached
187
+
188
+ def content_type(self, name: str) -> str | None:
189
+ return self.overrides.get(name) or self.defaults.get(name.rsplit(".", 1)[-1].lower())
190
+
191
+ def report(self) -> PackageInspection:
192
+ self._read_metadata()
193
+ reachable = self._reachable()
194
+ main_parts = {
195
+ rel.target
196
+ for rel in self.relationships
197
+ if rel.source == "/"
198
+ and relationship_kind(rel.relationship_type) == "officeDocument"
199
+ and not rel.external
200
+ and rel.target in self.members
201
+ }
202
+ if not main_parts:
203
+ self.findings.append(Finding("missing_main_document"))
204
+ story_parts = main_parts | {
205
+ rel.target
206
+ for rel in self.relationships
207
+ if rel.source in reachable
208
+ and relationship_kind(rel.relationship_type) in STORIES
209
+ and not rel.external
210
+ }
211
+ features = Features()
212
+ for name in sorted(self.members):
213
+ if name == "[Content_Types].xml" or relationship_source(name) is not None:
214
+ continue
215
+ if not is_xml(name, self.content_type(name)):
216
+ continue
217
+ observed = self._scan(name, XmlFacts(name in story_parts))
218
+ features = Features(
219
+ features.field_hyperlink or observed.field_hyperlink,
220
+ features.split_hyperlink_runs or observed.split_hyperlink_runs,
221
+ )
222
+ inventory = PackageInventory(
223
+ tuple(
224
+ PartInventory(name, member.file_size, self.content_type(name), name in reachable)
225
+ for name, member in sorted(self.members.items())
226
+ ),
227
+ tuple(self.relationships),
228
+ )
229
+ return PackageInspection(inventory, tuple(self.findings), features)
230
+
231
+
232
+ def inspect(source: PackageSource) -> PackageInspection:
233
+ """Observe package structure and Word story features without constructing a Document.
234
+
235
+ Format failures become findings. Source opening failures propagate, and invalid
236
+ argument types raise TypeError.
237
+ Seekable streams are read from the beginning and restored; borrowed streams stay open.
238
+ Binary members are inventoried without reading their payloads.
239
+ """
240
+ with open_source(source) as stream:
241
+ try:
242
+ with ZipFile(stream) as package:
243
+ return _Inspection(package).report()
244
+ except PACKAGE_ERRORS as exc:
245
+ return PackageInspection(findings=(Finding("not_a_package", message=str(exc)),))
@@ -0,0 +1,59 @@
1
+ """Immutable observations of a package; findings do not prescribe repair policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Finding:
10
+ code: str
11
+ partname: str | None = None
12
+ message: str = ""
13
+ target: str | None = None
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class PartInventory:
18
+ """ZIP member metadata. Names are package-relative, without a leading slash."""
19
+
20
+ partname: str
21
+ size: int
22
+ content_type: str | None
23
+ reachable: bool
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class RelationshipInventory:
28
+ """A relationship with its resolved internal target or original external URI."""
29
+
30
+ source: str
31
+ relationship_id: str
32
+ relationship_type: str
33
+ target: str
34
+ external: bool
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class PackageInventory:
39
+ parts: tuple[PartInventory, ...] = ()
40
+ relationships: tuple[RelationshipInventory, ...] = ()
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Features:
45
+ field_hyperlink: bool = False
46
+ split_hyperlink_runs: bool = False
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class PackageInspection:
51
+ inventory: PackageInventory = PackageInventory()
52
+ findings: tuple[Finding, ...] = ()
53
+ features: Features = Features()
54
+
55
+ def has_finding(self, code: str) -> bool:
56
+ return any(finding.code == code for finding in self.findings)
57
+
58
+ def findings_by_code(self, code: str) -> tuple[Finding, ...]:
59
+ return tuple(finding for finding in self.findings if finding.code == code)