primarysource 0.1.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.
- primarysource/__init__.py +5 -0
- primarysource/bbf/__init__.py +56 -0
- primarysource/bbf/_xml.py +63 -0
- primarysource/bbf/catalogue.json +777 -0
- primarysource/bbf/catalogue.py +137 -0
- primarysource/bbf/cli.py +79 -0
- primarysource/bbf/datamodel.py +281 -0
- primarysource/bbf/importer.py +151 -0
- primarysource/bbf/library.py +380 -0
- primarysource/bbf/server.py +72 -0
- primarysource/budget.py +63 -0
- primarysource/c2sp/__init__.py +53 -0
- primarysource/c2sp/_fetch.py +5 -0
- primarysource/c2sp/catalogue.json +231 -0
- primarysource/c2sp/catalogue.py +95 -0
- primarysource/c2sp/cli.py +62 -0
- primarysource/c2sp/importer.py +90 -0
- primarysource/c2sp/library.py +309 -0
- primarysource/c2sp/markdown.py +31 -0
- primarysource/c2sp/server.py +68 -0
- primarysource/cavp/__init__.py +42 -0
- primarysource/cavp/cli.py +84 -0
- primarysource/cavp/data/vectors.toml +47 -0
- primarysource/cavp/importer.py +156 -0
- primarysource/cavp/model.py +279 -0
- primarysource/cavp/parse.py +269 -0
- primarysource/cavp/paths.py +17 -0
- primarysource/cavp/server.py +67 -0
- primarysource/cavp/tools.py +169 -0
- primarysource/cli.py +107 -0
- primarysource/cursor.py +82 -0
- primarysource/cve/__init__.py +5 -0
- primarysource/cve/__main__.py +6 -0
- primarysource/cve/cli.py +144 -0
- primarysource/cve/collection.py +20 -0
- primarysource/cve/config.py +58 -0
- primarysource/cve/errors.py +16 -0
- primarysource/cve/kernel.py +232 -0
- primarysource/cve/mcp.py +139 -0
- primarysource/cve/parse.py +318 -0
- primarysource/cve/py.typed +0 -0
- primarysource/cve/service.py +760 -0
- primarysource/cve/store.py +214 -0
- primarysource/cve/sync.py +438 -0
- primarysource/cve/views.py +304 -0
- primarysource/debpolicy/__init__.py +16 -0
- primarysource/debpolicy/catalogue.py +263 -0
- primarysource/debpolicy/cli.py +12 -0
- primarysource/debpolicy/server.py +11 -0
- primarysource/draft/__init__.py +11 -0
- primarysource/draft/catalogue.py +117 -0
- primarysource/draft/cli.py +149 -0
- primarysource/draft/collection.py +21 -0
- primarysource/draft/datatracker.py +158 -0
- primarysource/draft/importer.py +163 -0
- primarysource/draft/licence.py +89 -0
- primarysource/draft/names.py +64 -0
- primarysource/draft/net.py +133 -0
- primarysource/draft/segment.py +192 -0
- primarysource/draft/server.py +91 -0
- primarysource/draft/status.py +146 -0
- primarysource/draft/store.py +199 -0
- primarysource/draft/tracked.json +104 -0
- primarysource/draft/views.py +430 -0
- primarysource/draft/xmlmeta.py +74 -0
- primarysource/ecma/__init__.py +52 -0
- primarysource/ecma/_pdf.py +143 -0
- primarysource/ecma/catalogue.json +22 -0
- primarysource/ecma/catalogue.py +100 -0
- primarysource/ecma/cli.py +67 -0
- primarysource/ecma/identify.py +25 -0
- primarysource/ecma/importer.py +108 -0
- primarysource/ecma/library.py +240 -0
- primarysource/ecma/server.py +67 -0
- primarysource/errors.py +29 -0
- primarysource/fetch.py +360 -0
- primarysource/fsutil.py +150 -0
- primarysource/gitdocs/__init__.py +15 -0
- primarysource/gitdocs/catalogue.py +116 -0
- primarysource/gitdocs/cli.py +12 -0
- primarysource/gitdocs/server.py +8 -0
- primarysource/home.py +65 -0
- primarysource/iana/__init__.py +32 -0
- primarysource/iana/catalogue.json +244 -0
- primarysource/iana/catalogue.py +69 -0
- primarysource/iana/cli.py +69 -0
- primarysource/iana/importer.py +157 -0
- primarysource/iana/model.py +325 -0
- primarysource/iana/reader.py +315 -0
- primarysource/iana/server.py +67 -0
- primarysource/iana/store.py +83 -0
- primarysource/iana/xmlsafe.py +47 -0
- primarysource/identify.py +186 -0
- primarysource/ieee/__init__.py +58 -0
- primarysource/ieee/_html.py +49 -0
- primarysource/ieee/catalogue.json +74 -0
- primarysource/ieee/catalogue.py +123 -0
- primarysource/ieee/cli.py +79 -0
- primarysource/ieee/identify.py +24 -0
- primarysource/ieee/ieee802.py +47 -0
- primarysource/ieee/importer.py +305 -0
- primarysource/ieee/posix.py +233 -0
- primarysource/ieee/server.py +79 -0
- primarysource/index.py +446 -0
- primarysource/iso/__init__.py +51 -0
- primarysource/iso/cli.py +98 -0
- primarysource/iso/designation.py +95 -0
- primarysource/iso/identify.py +100 -0
- primarysource/iso/importer.py +224 -0
- primarysource/iso/library.py +249 -0
- primarysource/iso/server.py +69 -0
- primarysource/itu/__init__.py +41 -0
- primarysource/itu/__main__.py +8 -0
- primarysource/itu/asn1mod.py +478 -0
- primarysource/itu/catalog.py +472 -0
- primarysource/itu/cli.py +445 -0
- primarysource/itu/collection.py +20 -0
- primarysource/itu/corpus.py +118 -0
- primarysource/itu/errata.py +16 -0
- primarysource/itu/formal.py +565 -0
- primarysource/itu/identify.py +26 -0
- primarysource/itu/index.py +490 -0
- primarysource/itu/mcp.py +474 -0
- primarysource/itu/py.typed +0 -0
- primarysource/itu/sources.py +600 -0
- primarysource/itu/text.py +609 -0
- primarysource/kconfig/__init__.py +16 -0
- primarysource/kconfig/catalogue.py +98 -0
- primarysource/kconfig/cli.py +12 -0
- primarysource/kconfig/server.py +8 -0
- primarysource/model.py +63 -0
- primarysource/nist/__init__.py +5 -0
- primarysource/nist/collection.py +46 -0
- primarysource/nist/data/acquired-nist.json +1657 -0
- primarysource/nist/data/nist-catalog.json +12090 -0
- primarysource/nist/data/sources.toml +1996 -0
- primarysource/nist/identify.py +41 -0
- primarysource/nist/ingest/__init__.py +1 -0
- primarysource/nist/ingest/acquire_nist.py +128 -0
- primarysource/nist/ingest/controls.py +82 -0
- primarysource/nist/ingest/discover_nist.py +218 -0
- primarysource/nist/ingest/extract.py +48 -0
- primarysource/nist/ingest/fetch.py +211 -0
- primarysource/nist/ingest/index.py +14 -0
- primarysource/nist/ingest/oscal.py +480 -0
- primarysource/nist/ingest/prose_layouts.py +236 -0
- primarysource/nist/ingest/sources.py +94 -0
- primarysource/nist/ingest/status.py +255 -0
- primarysource/nist/models.py +7 -0
- primarysource/nist/paths.py +33 -0
- primarysource/nist/server/__init__.py +3 -0
- primarysource/nist/server/corpus.py +38 -0
- primarysource/nist/server/mcp.py +19 -0
- primarysource/nist/server/tools.py +35 -0
- primarysource/nist/spec.py +38 -0
- primarysource/pdfcorpus/__init__.py +16 -0
- primarysource/pdfcorpus/corpus.py +159 -0
- primarysource/pdfcorpus/fts.py +175 -0
- primarysource/pdfcorpus/index.py +70 -0
- primarysource/pdfcorpus/models.py +30 -0
- primarysource/pdfcorpus/server.py +96 -0
- primarysource/pdfcorpus/spec.py +37 -0
- primarysource/pdfcorpus/split.py +391 -0
- primarysource/pdfcorpus/tools.py +244 -0
- primarysource/pep/__init__.py +15 -0
- primarysource/pep/catalogue.py +191 -0
- primarysource/pep/cli.py +12 -0
- primarysource/pep/server.py +8 -0
- primarysource/pkgdocs/__init__.py +19 -0
- primarysource/pkgdocs/fetch.py +356 -0
- primarysource/pkgdocs/index.py +89 -0
- primarysource/pkgdocs/model.py +76 -0
- primarysource/pkgdocs/readers/__init__.py +1 -0
- primarysource/pkgdocs/readers/_common.py +134 -0
- primarysource/pkgdocs/readers/asciidoc.py +160 -0
- primarysource/pkgdocs/readers/latex.py +315 -0
- primarysource/pkgdocs/readers/markdown.py +173 -0
- primarysource/pkgdocs/readers/pod.py +132 -0
- primarysource/pkgdocs/readers/rst.py +239 -0
- primarysource/pkgdocs/readers/scdoc.py +108 -0
- primarysource/pkgdocs/runtime.py +552 -0
- primarysource/pkgdocs/serving.py +158 -0
- primarysource/pkgdocs/store.py +167 -0
- primarysource/pms/__init__.py +16 -0
- primarysource/pms/catalogue.py +88 -0
- primarysource/pms/cli.py +12 -0
- primarysource/pms/server.py +8 -0
- primarysource/py.typed +0 -0
- primarysource/readers/__init__.py +10 -0
- primarysource/readers/bookmarks.py +192 -0
- primarysource/readers/html.py +1312 -0
- primarysource/readers/markdown.py +189 -0
- primarysource/readers/pdf.py +187 -0
- primarysource/readers/xml.py +230 -0
- primarysource/refs.py +87 -0
- primarysource/registry.py +161 -0
- primarysource/render.py +49 -0
- primarysource/rfc/__init__.py +3 -0
- primarysource/rfc/__main__.py +6 -0
- primarysource/rfc/cite.py +204 -0
- primarysource/rfc/cli.py +903 -0
- primarysource/rfc/collection.py +19 -0
- primarysource/rfc/config.py +138 -0
- primarysource/rfc/errata.py +203 -0
- primarysource/rfc/fsutil.py +82 -0
- primarysource/rfc/graph.py +213 -0
- primarysource/rfc/index/__init__.py +1 -0
- primarysource/rfc/index/chunker.py +281 -0
- primarysource/rfc/index/embed.py +166 -0
- primarysource/rfc/index/hybrid.py +305 -0
- primarysource/rfc/index/lexical.py +223 -0
- primarysource/rfc/index/rfcxml.py +673 -0
- primarysource/rfc/index/search.py +508 -0
- primarysource/rfc/index/txtseg.py +585 -0
- primarysource/rfc/library.py +294 -0
- primarysource/rfc/mcp/__init__.py +1 -0
- primarysource/rfc/mcp/server.py +381 -0
- primarysource/rfc/packs/__init__.py +1 -0
- primarysource/rfc/packs/builder.py +228 -0
- primarysource/rfc/packs/collections/all-current-txt.json +10 -0
- primarysource/rfc/packs/collections/essentials.json +19 -0
- primarysource/rfc/packs/collections/fyi-current.json +10 -0
- primarysource/rfc/packs/collections/standards-current.json +10 -0
- primarysource/rfc/packs/collections/year-2025.json +10 -0
- primarysource/rfc/packs/collections/year-2026.json +10 -0
- primarysource/rfc/packs/collections.py +163 -0
- primarysource/rfc/packs/docsetexport.py +163 -0
- primarysource/rfc/packs/epub.py +159 -0
- primarysource/rfc/packs/export.py +306 -0
- primarysource/rfc/packs/htmlexport.py +82 -0
- primarysource/rfc/packs/serve.py +143 -0
- primarysource/rfc/packs/zimexport.py +171 -0
- primarysource/rfc/query.py +466 -0
- primarysource/rfc/subjects.py +457 -0
- primarysource/rfc/sync/__init__.py +1 -0
- primarysource/rfc/sync/downloader.py +132 -0
- primarysource/rfc/sync/manifest.py +162 -0
- primarysource/rfc/sync/scanner.py +49 -0
- primarysource/rfc/sync/state.py +464 -0
- primarysource/rfc/views.py +1067 -0
- primarysource/rpmdocs/__init__.py +15 -0
- primarysource/rpmdocs/catalogue.py +117 -0
- primarysource/rpmdocs/cli.py +12 -0
- primarysource/rpmdocs/server.py +8 -0
- primarysource/scaffold.py +223 -0
- primarysource/serve.py +75 -0
- primarysource/settings.py +152 -0
- primarysource/usb/__init__.py +5 -0
- primarysource/usb/collection.py +37 -0
- primarysource/usb/data/acquired.json +2025 -0
- primarysource/usb/data/sources-usb.toml +852 -0
- primarysource/usb/data/sources.toml +10 -0
- primarysource/usb/data/usb-catalog.json +1663 -0
- primarysource/usb/data/usb-classification.json +1986 -0
- primarysource/usb/data/usb-corpus-build.json +608 -0
- primarysource/usb/data/usb-pdf-choice.json +839 -0
- primarysource/usb/data/usb-split-measure.json +1803 -0
- primarysource/usb/ingest/__init__.py +1 -0
- primarysource/usb/ingest/acquire.py +130 -0
- primarysource/usb/ingest/discover_usb.py +88 -0
- primarysource/usb/ingest/extract.py +48 -0
- primarysource/usb/ingest/fetch.py +197 -0
- primarysource/usb/ingest/index.py +14 -0
- primarysource/usb/ingest/sources.py +85 -0
- primarysource/usb/models.py +7 -0
- primarysource/usb/paths.py +33 -0
- primarysource/usb/server/__init__.py +3 -0
- primarysource/usb/server/corpus.py +38 -0
- primarysource/usb/server/mcp.py +19 -0
- primarysource/usb/server/tools.py +35 -0
- primarysource/usb/spec.py +20 -0
- primarysource/usbids/__init__.py +56 -0
- primarysource/usbids/cli.py +65 -0
- primarysource/usbids/importer.py +272 -0
- primarysource/usbids/parse.py +283 -0
- primarysource/usbids/server.py +55 -0
- primarysource/usbids/store.py +80 -0
- primarysource/usbids/tools.py +174 -0
- primarysource/zim/__init__.py +5 -0
- primarysource/zim/__main__.py +8 -0
- primarysource/zim/_libzim.py +37 -0
- primarysource/zim/catalog.py +594 -0
- primarysource/zim/cli.py +555 -0
- primarysource/zim/collection.py +41 -0
- primarysource/zim/errors.py +100 -0
- primarysource/zim/groups.py +30 -0
- primarysource/zim/library.py +668 -0
- primarysource/zim/manage.py +636 -0
- primarysource/zim/markdown.py +1247 -0
- primarysource/zim/mcp.py +201 -0
- primarysource/zim/purl.py +130 -0
- primarysource/zim/py.typed +0 -0
- primarysource/zim/refs.py +90 -0
- primarysource/zim/service.py +1182 -0
- primarysource/zim/snapshot.py +197 -0
- primarysource/zim/sync.py +269 -0
- primarysource/zim/views.py +230 -0
- primarysource-0.1.0.dist-info/METADATA +118 -0
- primarysource-0.1.0.dist-info/RECORD +303 -0
- primarysource-0.1.0.dist-info/WHEEL +4 -0
- primarysource-0.1.0.dist-info/entry_points.txt +2 -0
- primarysource-0.1.0.dist-info/licenses/LICENSE +203 -0
- primarysource-0.1.0.dist-info/licenses/NOTICE +51 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""A small, strict Markdown reader: headings, blocks, front matter. The core's `cl.reader_markdown`.
|
|
2
|
+
|
|
3
|
+
Written for the C2SP specifications (`primarysource/c2sp/markdown.py`, now a re-export of this module). It reads
|
|
4
|
+
the subset those specifications use and nothing else is interpreted: ATX headings (`#` to `######`),
|
|
5
|
+
setext headings over one paragraph line, fenced code (backticks or tildes), blank-line-separated blocks, and a
|
|
6
|
+
leading `---` front-matter block of `key: value` lines. HTML, links and images are never followed or rendered;
|
|
7
|
+
a heading's title is its text with link targets, emphasis marks and code ticks removed.
|
|
8
|
+
|
|
9
|
+
Input is bounded (`MAX_BYTES`, `MAX_LINE`, `MAX_SECTIONS`) because the text of a specification is untrusted data:
|
|
10
|
+
a document over a bound is refused with a `LibError`, never truncated.
|
|
11
|
+
|
|
12
|
+
Units: `¶n` counts blocks. A block is a blank-line-separated chunk of lines, or one whole fenced code block
|
|
13
|
+
(blank lines inside a fence do not split it). A section's units are every block from its heading to the next
|
|
14
|
+
heading of the same or a higher level, subsection headings included as blocks, its own heading excluded.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
import unicodedata
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
|
|
23
|
+
from primarysource.errors import LibError
|
|
24
|
+
|
|
25
|
+
MAX_BYTES = 4 * 1024 * 1024
|
|
26
|
+
MAX_LINE = 20_000
|
|
27
|
+
MAX_SECTIONS = 20_000
|
|
28
|
+
|
|
29
|
+
_ATX = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$")
|
|
30
|
+
_FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})")
|
|
31
|
+
_SETEXT = re.compile(r"^ {0,3}(=+|-+)[ \t]*$")
|
|
32
|
+
_LINK = re.compile(r"!?\[([^\]]*)\]\([^)]*\)")
|
|
33
|
+
_REFLINK = re.compile(r"\[([^\]]+)\]\[[^\]]*\]")
|
|
34
|
+
_TAG = re.compile(r"<[^>\n]*>")
|
|
35
|
+
_MARKS = re.compile(r"[`*_~]+")
|
|
36
|
+
_SLUG_DROP = re.compile(r"[^\w\- ]", re.UNICODE)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class Heading:
|
|
41
|
+
"""One section of a document, in document order. `units` are its blocks (see the module docstring)."""
|
|
42
|
+
|
|
43
|
+
title: str
|
|
44
|
+
slug: str
|
|
45
|
+
level: int
|
|
46
|
+
units: tuple[str, ...]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class MarkdownDocument:
|
|
51
|
+
"""What the reader found. `preamble` is the blocks before the first heading; `title` is the first level-1
|
|
52
|
+
heading, else the front matter's `title`, else empty."""
|
|
53
|
+
|
|
54
|
+
front: dict[str, str]
|
|
55
|
+
title: str
|
|
56
|
+
preamble: tuple[str, ...]
|
|
57
|
+
headings: tuple[Heading, ...]
|
|
58
|
+
|
|
59
|
+
def units(self) -> tuple[str, ...]:
|
|
60
|
+
"""The whole document as units: the preamble, then every block of every top-level section, with each
|
|
61
|
+
heading as a block of its own."""
|
|
62
|
+
if not self.headings:
|
|
63
|
+
return self.preamble
|
|
64
|
+
top = min(h.level for h in self.headings)
|
|
65
|
+
out = list(self.preamble)
|
|
66
|
+
for heading in self.headings:
|
|
67
|
+
if heading.level == top:
|
|
68
|
+
out.append(f"{'#' * heading.level} {heading.title}")
|
|
69
|
+
out.extend(heading.units)
|
|
70
|
+
return tuple(out)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def plain(text: str) -> str:
|
|
74
|
+
"""A heading's text without Markdown: links reduced to their text, tags, ticks and emphasis marks removed."""
|
|
75
|
+
text = _LINK.sub(r"\1", text)
|
|
76
|
+
text = _REFLINK.sub(r"\1", text)
|
|
77
|
+
text = _TAG.sub("", text)
|
|
78
|
+
text = _MARKS.sub("", text)
|
|
79
|
+
return " ".join(text.split())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def slugify(title: str) -> str:
|
|
83
|
+
"""The GitHub-style anchor of a title: lower case, punctuation dropped, spaces to hyphens."""
|
|
84
|
+
folded = unicodedata.normalize("NFKC", title).lower()
|
|
85
|
+
return _SLUG_DROP.sub("", folded).strip().replace(" ", "-")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _front_matter(lines: list[str]) -> tuple[dict[str, str], int]:
|
|
89
|
+
"""Parse a leading `---` block of `key: value` lines; return it and the index of the first body line."""
|
|
90
|
+
if not lines or lines[0].strip() != "---":
|
|
91
|
+
return {}, 0
|
|
92
|
+
for end in range(1, min(len(lines), 60)):
|
|
93
|
+
if lines[end].strip() == "---":
|
|
94
|
+
front: dict[str, str] = {}
|
|
95
|
+
for line in lines[1:end]:
|
|
96
|
+
key, sep, value = line.partition(":")
|
|
97
|
+
if sep and re.fullmatch(r"[A-Za-z][\w-]*", key.strip()):
|
|
98
|
+
front[key.strip()] = value.strip().strip("\"'")
|
|
99
|
+
return front, end + 1
|
|
100
|
+
return {}, 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def decode(data: bytes) -> str:
|
|
104
|
+
"""UTF-8 text from bytes, or a `LibError`: over the bound, not UTF-8, or containing a NUL."""
|
|
105
|
+
if len(data) > MAX_BYTES:
|
|
106
|
+
raise LibError(f"document is {len(data)} bytes, over the {MAX_BYTES} byte bound", "")
|
|
107
|
+
try:
|
|
108
|
+
text = data.decode("utf-8-sig")
|
|
109
|
+
except UnicodeDecodeError as exc:
|
|
110
|
+
raise LibError("document is not valid UTF-8", "re-import it; the pinned file is UTF-8") from exc
|
|
111
|
+
if "\x00" in text:
|
|
112
|
+
raise LibError("document contains a NUL byte", "re-import it; the pinned file is plain text")
|
|
113
|
+
return text
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def parse(data: bytes | str) -> MarkdownDocument:
|
|
117
|
+
"""Parse one document. Raises `LibError` for a document that is not UTF-8 text or exceeds a bound."""
|
|
118
|
+
text = decode(data) if isinstance(data, bytes) else data
|
|
119
|
+
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
120
|
+
if any(len(line) > MAX_LINE for line in lines):
|
|
121
|
+
raise LibError(f"document has a line over {MAX_LINE} characters", "")
|
|
122
|
+
front, first = _front_matter(lines)
|
|
123
|
+
|
|
124
|
+
# pass 1: split into (kind, level, text) items: headings and blocks, fences kept whole
|
|
125
|
+
items: list[tuple[str, int, str]] = []
|
|
126
|
+
paragraph: list[str] = []
|
|
127
|
+
fence: tuple[str, int] | None = None
|
|
128
|
+
fenced: list[str] = []
|
|
129
|
+
|
|
130
|
+
def flush() -> None:
|
|
131
|
+
if paragraph:
|
|
132
|
+
items.append(("block", 0, "\n".join(paragraph)))
|
|
133
|
+
paragraph.clear()
|
|
134
|
+
|
|
135
|
+
for line in lines[first:]:
|
|
136
|
+
if fence is not None:
|
|
137
|
+
fenced.append(line)
|
|
138
|
+
close = _FENCE.match(line)
|
|
139
|
+
if close and close.group(1)[0] == fence[0] and len(close.group(1)) >= fence[1] and not line.strip(" `~"):
|
|
140
|
+
items.append(("block", 0, "\n".join(fenced)))
|
|
141
|
+
fence, fenced = None, []
|
|
142
|
+
continue
|
|
143
|
+
opening = _FENCE.match(line)
|
|
144
|
+
if opening:
|
|
145
|
+
flush()
|
|
146
|
+
fence, fenced = (opening.group(1)[0], len(opening.group(1))), [line]
|
|
147
|
+
continue
|
|
148
|
+
if not line.strip():
|
|
149
|
+
flush()
|
|
150
|
+
continue
|
|
151
|
+
atx = _ATX.match(line)
|
|
152
|
+
if atx:
|
|
153
|
+
flush()
|
|
154
|
+
items.append(("heading", len(atx.group(1)), plain(atx.group(2) or "")))
|
|
155
|
+
continue
|
|
156
|
+
setext = _SETEXT.match(line)
|
|
157
|
+
if setext and len(paragraph) == 1 and not paragraph[0].lstrip().startswith(("-", "*", ">", "|")):
|
|
158
|
+
items.append(("heading", 1 if setext.group(1)[0] == "=" else 2, plain(paragraph[0])))
|
|
159
|
+
paragraph.clear()
|
|
160
|
+
continue
|
|
161
|
+
paragraph.append(line)
|
|
162
|
+
if fence is not None: # an unclosed fence runs to the end of the document, as in CommonMark
|
|
163
|
+
items.append(("block", 0, "\n".join(fenced)))
|
|
164
|
+
flush()
|
|
165
|
+
|
|
166
|
+
# pass 2: assign blocks to sections
|
|
167
|
+
spans = [(body, level, index) for index, (kind, level, body) in enumerate(items) if kind == "heading"]
|
|
168
|
+
if len(spans) > MAX_SECTIONS:
|
|
169
|
+
raise LibError(f"document has {len(spans)} headings, over the {MAX_SECTIONS} bound", "")
|
|
170
|
+
first_heading = spans[0][2] if spans else len(items)
|
|
171
|
+
preamble = tuple(body for kind, _, body in items[:first_heading] if kind == "block")
|
|
172
|
+
headings: list[Heading] = []
|
|
173
|
+
seen: dict[str, int] = {}
|
|
174
|
+
for n, (title, level, start) in enumerate(spans):
|
|
175
|
+
end = len(items)
|
|
176
|
+
for _, later_level, later in spans[n + 1 :]:
|
|
177
|
+
if later_level <= level:
|
|
178
|
+
end = later
|
|
179
|
+
break
|
|
180
|
+
units = tuple(
|
|
181
|
+
f"{'#' * item_level} {body}".rstrip() if kind == "heading" else body
|
|
182
|
+
for kind, item_level, body in items[start + 1 : end]
|
|
183
|
+
)
|
|
184
|
+
slug = slugify(title)
|
|
185
|
+
count = seen.get(slug, 0)
|
|
186
|
+
seen[slug] = count + 1
|
|
187
|
+
headings.append(Heading(title, slug if count == 0 else f"{slug}-{count}", level, units))
|
|
188
|
+
top = next((h.title for h in headings if h.level == 1), "")
|
|
189
|
+
return MarkdownDocument(front, top or front.get("title", ""), preamble, tuple(headings))
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Read a publisher's PDF into bookmarked sections of sentences (`cl.reader_pdf_pypdf`).
|
|
2
|
+
|
|
3
|
+
`pypdf` is an ingest-time extra (PLAN.md §5.13): it is imported inside the functions that need it, never at
|
|
4
|
+
module level, so a server that answers from stored extractions imports this module without it. Asking for a PDF
|
|
5
|
+
read without it is a `LibError` naming the extra, not an `ImportError`.
|
|
6
|
+
|
|
7
|
+
Lifted from the ecma collection's private reader (`primarysource/ecma/_pdf.py`, now a re-export), with its tests.
|
|
8
|
+
The bookmarks always come from `pypdf`; the page text comes from `pdftotext` (poppler or Xpdf) when it is on PATH
|
|
9
|
+
and a path to the PDF is given, else from `pypdf`. Backends give different text, so the backend's name and
|
|
10
|
+
version are in every extraction (`extractor`) and are what an index puts into its source hash (PLAN.md §4).
|
|
11
|
+
Figures, tables and equations are whatever the backend extracts: expect broken columns.
|
|
12
|
+
|
|
13
|
+
Cutting the page texts into sections is `primarysource.readers.bookmarks` (`cl.segment_bookmarks`).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import io
|
|
19
|
+
import re
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess # noqa: S404 - runs the user's own pdftotext on a PDF whose sha256 the caller pinned
|
|
22
|
+
from collections.abc import Sequence
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from types import ModuleType
|
|
25
|
+
from typing import Any, NamedTuple
|
|
26
|
+
|
|
27
|
+
from primarysource.errors import LibError
|
|
28
|
+
from primarysource.readers.bookmarks import Outline, cut, cut_numbered
|
|
29
|
+
|
|
30
|
+
MAX_BYTES = 64 * 1024 * 1024
|
|
31
|
+
MAX_PAGES = 2000
|
|
32
|
+
MAX_OUTLINE = 6000
|
|
33
|
+
INSTALL_HINT = 'pip install "primarysource[ecma]" (or [bbf], [ieee], [itu], [iso]); each brings pypdf'
|
|
34
|
+
|
|
35
|
+
_PDFTOTEXT_VERSION = re.compile(rb"pdftotext\s+version\s+(\S+)", re.IGNORECASE)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Backend(NamedTuple):
|
|
39
|
+
"""The program that produced a text: what an index hashes so a change of backend rebuilds it."""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
version: str
|
|
43
|
+
|
|
44
|
+
def label(self) -> str:
|
|
45
|
+
"""`name version`, the form stored as an extraction's `extractor`."""
|
|
46
|
+
return f"{self.name} {self.version}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _pypdf() -> ModuleType:
|
|
50
|
+
try:
|
|
51
|
+
import pypdf
|
|
52
|
+
except ImportError as exc: # ModuleNotFoundError, or None in sys.modules
|
|
53
|
+
raise LibError("reading a PDF needs pypdf, which is not installed", INSTALL_HINT) from exc
|
|
54
|
+
return pypdf
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def pypdf_backend() -> Backend:
|
|
58
|
+
"""`pypdf` and its version, or a `LibError` naming the extra when it is not installed."""
|
|
59
|
+
return Backend("pypdf", str(_pypdf().__version__))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def pdftotext_backend() -> Backend | None:
|
|
63
|
+
"""`pdftotext` and the version it reports, or None when it is not on PATH. A version it does not report is
|
|
64
|
+
`unknown`."""
|
|
65
|
+
exe = shutil.which("pdftotext")
|
|
66
|
+
if exe is None:
|
|
67
|
+
return None
|
|
68
|
+
try:
|
|
69
|
+
done = subprocess.run([exe, "-v"], capture_output=True, timeout=30, check=False) # noqa: S603 - fixed argv
|
|
70
|
+
except (OSError, subprocess.SubprocessError):
|
|
71
|
+
return None
|
|
72
|
+
found = _PDFTOTEXT_VERSION.search(done.stderr + b"\n" + done.stdout)
|
|
73
|
+
return Backend("pdftotext", found.group(1).decode("ascii", errors="replace") if found else "unknown")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _page_of(reader: Any, item: Any) -> int:
|
|
77
|
+
"""The 0-based page a bookmark points at, or -1 if its destination is unusable."""
|
|
78
|
+
try:
|
|
79
|
+
return int(reader.get_destination_page_number(item))
|
|
80
|
+
except Exception: # noqa: BLE001 - pypdf raises many types for a broken destination
|
|
81
|
+
return -1
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def outline(reader: Any) -> Outline:
|
|
85
|
+
"""A `pypdf.PdfReader`'s bookmark tree flattened to (title, 0-based page, depth), in document order, bounded
|
|
86
|
+
by `MAX_OUTLINE` entries and 12 levels."""
|
|
87
|
+
flat: Outline = []
|
|
88
|
+
|
|
89
|
+
def walk(items: Sequence[Any], depth: int) -> None:
|
|
90
|
+
for item in items:
|
|
91
|
+
if len(flat) >= MAX_OUTLINE or depth > 12:
|
|
92
|
+
return
|
|
93
|
+
if isinstance(item, list):
|
|
94
|
+
walk(item, depth + 1)
|
|
95
|
+
continue
|
|
96
|
+
page = _page_of(reader, item)
|
|
97
|
+
title = " ".join(str(item.title).split())
|
|
98
|
+
if title and page >= 0:
|
|
99
|
+
flat.append((title, page, depth))
|
|
100
|
+
|
|
101
|
+
walk(reader.outline, 0)
|
|
102
|
+
return flat
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def pdftotext_pages(path: Path, count: int, *, layout: bool = False) -> list[str] | None:
|
|
106
|
+
"""The page texts from `pdftotext` if it is on PATH and returns `count` pages, else None. Its text keeps word
|
|
107
|
+
spacing that `pypdf` loses on some fonts, and puts headings where they are read. `layout` keeps the page's
|
|
108
|
+
columns (`-layout`), which the numbered-heading splitter reads contents rows and table cells by."""
|
|
109
|
+
exe = shutil.which("pdftotext")
|
|
110
|
+
if exe is None:
|
|
111
|
+
return None
|
|
112
|
+
argv = [exe, *(["-layout"] if layout else []), "-enc", "UTF-8", "-q", str(path), "-"]
|
|
113
|
+
try:
|
|
114
|
+
done = subprocess.run(argv, capture_output=True, timeout=300, check=False) # noqa: S603 - no shell
|
|
115
|
+
except (OSError, subprocess.SubprocessError):
|
|
116
|
+
return None
|
|
117
|
+
if done.returncode != 0:
|
|
118
|
+
return None
|
|
119
|
+
pages = done.stdout.decode("utf-8", errors="replace").split("\f")
|
|
120
|
+
if pages and not pages[-1].strip():
|
|
121
|
+
pages.pop()
|
|
122
|
+
return pages if len(pages) == count else None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def extract(data: bytes, path: Path | None = None, *, numbered: bool = False) -> dict[str, Any]:
|
|
126
|
+
"""Read a PDF into `{"title", "pages", "extractor", "pypdf", "headings_found", "sections": [{"id", "title",
|
|
127
|
+
"depth", "page", "text"}]}`.
|
|
128
|
+
|
|
129
|
+
`text` is the section's sentences joined by newlines. `extractor` is the backend that gave the page text, as
|
|
130
|
+
`name version` (`Backend.label`); `pypdf` is the version that gave the bookmarks. If `path` names the PDF on
|
|
131
|
+
disk and `pdftotext` is on PATH, its page text is used; otherwise `pypdf`'s, in content-stream order first,
|
|
132
|
+
and if fewer than 70% of the bookmarked headings are found in it as lines (some typesetters emit headings out
|
|
133
|
+
of order) the pages are re-read in `layout` mode and the better result is kept. With `numbered`, a PDF without
|
|
134
|
+
bookmarks is cut at numbered headings instead (`bookmarks.cut_numbered`), and `segmenter` says which was used.
|
|
135
|
+
Raises `LibError` for a PDF that is too large, cannot be read, has no bookmarks (and, with `numbered`, fewer
|
|
136
|
+
than 3 numbered headings), or when `pypdf` is not installed."""
|
|
137
|
+
if len(data) > MAX_BYTES:
|
|
138
|
+
raise LibError(f"PDF is {len(data)} bytes, over the {MAX_BYTES} byte bound", "")
|
|
139
|
+
pypdf = _pypdf()
|
|
140
|
+
try:
|
|
141
|
+
reader = pypdf.PdfReader(io.BytesIO(data), strict=False)
|
|
142
|
+
count = len(reader.pages)
|
|
143
|
+
if count > MAX_PAGES:
|
|
144
|
+
raise LibError(f"PDF has {count} pages, over the {MAX_PAGES} bound", "")
|
|
145
|
+
bookmarks = outline(reader)
|
|
146
|
+
if not bookmarks and not numbered:
|
|
147
|
+
raise LibError("the PDF has no bookmarks to split it into sections", "this reader needs a bookmarked PDF")
|
|
148
|
+
meta = reader.metadata
|
|
149
|
+
title = str(meta.title) if meta is not None and meta.title else ""
|
|
150
|
+
external = pdftotext_pages(path, count, layout=not bookmarks) if path is not None else None
|
|
151
|
+
backend = Backend("pypdf", str(pypdf.__version__))
|
|
152
|
+
if not bookmarks:
|
|
153
|
+
if external is not None:
|
|
154
|
+
backend = pdftotext_backend() or Backend("pdftotext", "unknown")
|
|
155
|
+
texts = external
|
|
156
|
+
if texts is None:
|
|
157
|
+
texts = [str(p.extract_text(extraction_mode="layout") or "") for p in reader.pages]
|
|
158
|
+
sections, located = cut_numbered(texts)
|
|
159
|
+
if located < 3:
|
|
160
|
+
raise LibError(
|
|
161
|
+
"the PDF has no bookmarks, and fewer than 3 numbered headings were found in its text",
|
|
162
|
+
"this reader needs a bookmarked PDF or numbered clause headings",
|
|
163
|
+
)
|
|
164
|
+
elif external is not None:
|
|
165
|
+
backend = pdftotext_backend() or Backend("pdftotext", "unknown")
|
|
166
|
+
sections, located = cut(external, bookmarks)
|
|
167
|
+
else:
|
|
168
|
+
sections, located = cut([str(reader.pages[i].extract_text() or "") for i in range(count)], bookmarks)
|
|
169
|
+
if bookmarks and external is None and located * 10 < len(bookmarks) * 7:
|
|
170
|
+
layout = [str(reader.pages[i].extract_text(extraction_mode="layout") or "") for i in range(count)]
|
|
171
|
+
better, better_located = cut(layout, bookmarks)
|
|
172
|
+
if better_located > located:
|
|
173
|
+
sections, located = better, better_located
|
|
174
|
+
except LibError:
|
|
175
|
+
raise
|
|
176
|
+
except Exception as exc: # noqa: BLE001 - pypdf raises many types on hostile input
|
|
177
|
+
message = f"could not read the PDF: {type(exc).__name__}: {exc}"
|
|
178
|
+
raise LibError(message, "check the file is the publisher's PDF") from exc
|
|
179
|
+
return {
|
|
180
|
+
"title": title,
|
|
181
|
+
"pages": count,
|
|
182
|
+
"extractor": backend.label(),
|
|
183
|
+
"pypdf": pypdf.__version__,
|
|
184
|
+
"headings_found": located,
|
|
185
|
+
"segmenter": "bookmarks" if bookmarks else "numbered headings",
|
|
186
|
+
"sections": sections,
|
|
187
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""The one hardened XML reader (`cl.reader_xml_hardened`), built on `xml.parsers.expat` and nothing else.
|
|
2
|
+
|
|
3
|
+
Three copies were compared: `primarysource/iana/xmlsafe.py`, `primarysource/bbf/_xml.py` and devicespy's
|
|
4
|
+
`devicespy/xmlsafe.py` (read, not imported; devicespy commit e26c820). This keeps the strictest behaviour of each:
|
|
5
|
+
devicespy's design and typed refusals, iana's text bound, bbf's scan for declarations before parsing. The iana and
|
|
6
|
+
bbf modules are now thin wrappers over this one that keep their own bounds and messages.
|
|
7
|
+
|
|
8
|
+
It refuses rather than defends:
|
|
9
|
+
|
|
10
|
+
- any document type declaration, entity or parameter-entity declaration, notation or external reference, so no
|
|
11
|
+
entity can be declared, expansion attacks are impossible and nothing is ever fetched. The raw bytes are scanned
|
|
12
|
+
for `<!DOCTYPE`, `<!ENTITY`, `<!ELEMENT`, `<!ATTLIST` and `<!NOTATION` before expat sees them (also in a
|
|
13
|
+
UTF-16 or UTF-32 decoding, so a declaration hidden by the encoding is named as one), and every expat handler
|
|
14
|
+
that a declaration would reach refuses too;
|
|
15
|
+
- any processing instruction other than the XML declaration, unless `ignore_processing_instructions` is set, in
|
|
16
|
+
which case they are dropped and never acted on (IANA registry files open with two);
|
|
17
|
+
- any declared encoding other than UTF-8, and input that looks like UTF-16 or UTF-32;
|
|
18
|
+
- input over `limit` bytes (checked before a parser exists), nesting over `max_depth`, more than `max_elements`
|
|
19
|
+
elements, more than `max_attributes` attributes on one element, an attribute value over
|
|
20
|
+
`max_attribute_length` characters, and text or a tail in one element over `max_text` characters;
|
|
21
|
+
- empty, truncated and malformed input, an undefined entity and a second root element.
|
|
22
|
+
|
|
23
|
+
XInclude and XSLT are never processed: ElementTree has no such step and only `Element` objects are built here.
|
|
24
|
+
Every refusal is an `XmlRefused`, which is a `LibError` with a hint, in one of four kinds: `XmlTooLarge`,
|
|
25
|
+
`XmlTooDeep`, `XmlForbidden`, `XmlMalformed`.
|
|
26
|
+
|
|
27
|
+
Names come out in ElementTree's `{namespace}local` form, unless `namespaces=False`, when they are the raw
|
|
28
|
+
qualified names (`dm:document`) and an undeclared prefix is not an error.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import re
|
|
34
|
+
import xml.etree.ElementTree as ET # noqa: S405 - only used to build elements; parsing is expat with DTDs refused
|
|
35
|
+
from typing import NoReturn
|
|
36
|
+
from xml.parsers import expat # noqa: S406 - hardened below: declarations refused, no entities, bounded
|
|
37
|
+
|
|
38
|
+
from primarysource.errors import LibError
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"DEFAULT_HINT",
|
|
42
|
+
"XmlForbidden",
|
|
43
|
+
"XmlMalformed",
|
|
44
|
+
"XmlRefused",
|
|
45
|
+
"XmlTooDeep",
|
|
46
|
+
"XmlTooLarge",
|
|
47
|
+
"localname",
|
|
48
|
+
"parse",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
DEFAULT_HINT = "the file is damaged or not the expected document; fetch or import it again"
|
|
52
|
+
_SEP = "}"
|
|
53
|
+
_DECLARATION = re.compile(rb"<!\s*(?:DOCTYPE|ENTITY|ELEMENT|ATTLIST|NOTATION)", re.IGNORECASE)
|
|
54
|
+
_DECLARATION_TEXT = re.compile(r"<!\s*(?:DOCTYPE|ENTITY|ELEMENT|ATTLIST|NOTATION)", re.IGNORECASE)
|
|
55
|
+
_WIDE_CODECS = ("utf-16", "utf-16-le", "utf-16-be", "utf-32", "utf-32-le", "utf-32-be")
|
|
56
|
+
_FORBIDDEN_DECLARATIONS = "document type declarations, entities and notations are not accepted"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class XmlRefused(LibError): # noqa: N818 - the name is the contract, as in devicespy
|
|
60
|
+
"""The document was refused. `reason` says why in one short phrase; the message is `XML refused: <reason>`."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, reason: str, hint: str = DEFAULT_HINT) -> None:
|
|
63
|
+
super().__init__(f"XML refused: {reason}", hint)
|
|
64
|
+
self.reason = reason
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class XmlTooLarge(XmlRefused):
|
|
68
|
+
"""A size bound was exceeded: bytes, elements, attributes, attribute length or text length."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class XmlTooDeep(XmlRefused):
|
|
72
|
+
"""Element nesting went past `max_depth`."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class XmlForbidden(XmlRefused):
|
|
76
|
+
"""A construct this reader never accepts: a declaration, an entity, a processing instruction, an encoding."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class XmlMalformed(XmlRefused):
|
|
80
|
+
"""Not well-formed XML, including empty and truncated input."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _qname(name: str) -> str:
|
|
84
|
+
# expat with a namespace separator yields "uri}local" (the local part cannot contain it).
|
|
85
|
+
if _SEP in name:
|
|
86
|
+
uri, local = name.rsplit(_SEP, 1)
|
|
87
|
+
return "{" + uri + "}" + local
|
|
88
|
+
return name
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def localname(tag: str) -> tuple[str, str]:
|
|
92
|
+
"""Split an ElementTree name `{namespace}local` into `(namespace, local)`; no namespace gives `("", name)`."""
|
|
93
|
+
if tag.startswith("{"):
|
|
94
|
+
uri, sep, local = tag[1:].partition("}")
|
|
95
|
+
if sep:
|
|
96
|
+
return uri, local
|
|
97
|
+
return "", tag
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class _Builder:
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
hint: str,
|
|
104
|
+
max_depth: int,
|
|
105
|
+
max_elements: int,
|
|
106
|
+
max_attributes: int,
|
|
107
|
+
max_attribute_length: int,
|
|
108
|
+
max_text: int,
|
|
109
|
+
) -> None:
|
|
110
|
+
self.hint = hint
|
|
111
|
+
self.max_depth = max_depth
|
|
112
|
+
self.max_elements = max_elements
|
|
113
|
+
self.max_attributes = max_attributes
|
|
114
|
+
self.max_attribute_length = max_attribute_length
|
|
115
|
+
self.max_text = max_text
|
|
116
|
+
self.stack: list[ET.Element] = []
|
|
117
|
+
self.root: ET.Element | None = None
|
|
118
|
+
self.count = 0
|
|
119
|
+
self.tail_of: ET.Element | None = None
|
|
120
|
+
|
|
121
|
+
def refuse(self, *_args: object) -> NoReturn:
|
|
122
|
+
raise XmlForbidden(_FORBIDDEN_DECLARATIONS, self.hint)
|
|
123
|
+
|
|
124
|
+
def refuse_pi(self, *_args: object) -> NoReturn:
|
|
125
|
+
raise XmlForbidden("processing instructions are not accepted", self.hint)
|
|
126
|
+
|
|
127
|
+
def xml_decl(self, _version: str, encoding: str | None, _standalone: int) -> None:
|
|
128
|
+
if encoding is not None and encoding.lower() not in ("utf-8", "utf8"):
|
|
129
|
+
raise XmlForbidden("only UTF-8 is accepted, the document declares " + encoding, self.hint)
|
|
130
|
+
|
|
131
|
+
def start(self, name: str, attrs: dict[str, str]) -> None:
|
|
132
|
+
if len(self.stack) >= self.max_depth:
|
|
133
|
+
raise XmlTooDeep(f"nesting deeper than {self.max_depth}", self.hint)
|
|
134
|
+
self.count += 1
|
|
135
|
+
if self.count > self.max_elements:
|
|
136
|
+
raise XmlTooLarge(f"more than {self.max_elements} elements", self.hint)
|
|
137
|
+
if len(attrs) > self.max_attributes:
|
|
138
|
+
raise XmlTooLarge(f"more than {self.max_attributes} attributes on one element", self.hint)
|
|
139
|
+
for value in attrs.values():
|
|
140
|
+
if len(value) > self.max_attribute_length:
|
|
141
|
+
raise XmlTooLarge(f"an attribute value is longer than {self.max_attribute_length}", self.hint)
|
|
142
|
+
element = ET.Element(_qname(name), {_qname(k): v for k, v in attrs.items()})
|
|
143
|
+
if self.stack:
|
|
144
|
+
self.stack[-1].append(element)
|
|
145
|
+
else:
|
|
146
|
+
self.root = element
|
|
147
|
+
self.stack.append(element)
|
|
148
|
+
self.tail_of = None
|
|
149
|
+
|
|
150
|
+
def end(self, _name: str) -> None:
|
|
151
|
+
self.tail_of = self.stack.pop()
|
|
152
|
+
|
|
153
|
+
def data(self, text: str) -> None:
|
|
154
|
+
if self.tail_of is not None:
|
|
155
|
+
self.tail_of.tail = (self.tail_of.tail or "") + text
|
|
156
|
+
if len(self.tail_of.tail) > self.max_text:
|
|
157
|
+
raise XmlTooLarge(f"text longer than {self.max_text}", self.hint)
|
|
158
|
+
elif self.stack:
|
|
159
|
+
top = self.stack[-1]
|
|
160
|
+
top.text = (top.text or "") + text
|
|
161
|
+
if len(top.text) > self.max_text:
|
|
162
|
+
raise XmlTooLarge(f"text longer than {self.max_text}", self.hint)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _looks_wide(head: bytes) -> bool:
|
|
166
|
+
return head[:2] in (b"\xfe\xff", b"\xff\xfe") or b"\x00" in head
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _declares_in_wide_text(raw: bytes) -> bool:
|
|
170
|
+
return any(_DECLARATION_TEXT.search(raw.decode(codec, errors="ignore")) for codec in _WIDE_CODECS)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def parse(
|
|
174
|
+
raw: bytes,
|
|
175
|
+
limit: int,
|
|
176
|
+
*,
|
|
177
|
+
max_depth: int = 32,
|
|
178
|
+
max_elements: int = 50_000,
|
|
179
|
+
max_attributes: int = 16,
|
|
180
|
+
max_attribute_length: int = 4096,
|
|
181
|
+
max_text: int = 1_000_000,
|
|
182
|
+
ignore_processing_instructions: bool = False,
|
|
183
|
+
namespaces: bool = True,
|
|
184
|
+
hint: str = DEFAULT_HINT,
|
|
185
|
+
) -> ET.Element:
|
|
186
|
+
"""Parse `raw` into its root Element, or raise `XmlRefused` (a `LibError` carrying `hint`).
|
|
187
|
+
|
|
188
|
+
`limit` is the largest acceptable `len(raw)` and is checked before any parsing. The whole document is passed
|
|
189
|
+
at once. The defaults are the strictest any of the three source copies used; a caller with larger documents
|
|
190
|
+
passes its own bounds. The root returned is never a placeholder: every refusal raises."""
|
|
191
|
+
if not isinstance(raw, bytes | bytearray | memoryview):
|
|
192
|
+
raise TypeError("raw must be bytes")
|
|
193
|
+
if limit < 0:
|
|
194
|
+
raise ValueError("limit must not be negative")
|
|
195
|
+
if len(raw) > limit:
|
|
196
|
+
raise XmlTooLarge(f"{len(raw):,} bytes exceeds the limit of {limit:,}", hint)
|
|
197
|
+
data = bytes(raw)
|
|
198
|
+
if not data:
|
|
199
|
+
raise XmlMalformed("empty document", hint)
|
|
200
|
+
if _DECLARATION.search(data):
|
|
201
|
+
raise XmlForbidden(_FORBIDDEN_DECLARATIONS, hint)
|
|
202
|
+
if _looks_wide(data[:4]):
|
|
203
|
+
if _declares_in_wide_text(data):
|
|
204
|
+
raise XmlForbidden(_FORBIDDEN_DECLARATIONS, hint)
|
|
205
|
+
raise XmlForbidden("only UTF-8 is accepted, the input looks like UTF-16 or UTF-32", hint)
|
|
206
|
+
builder = _Builder(hint, max_depth, max_elements, max_attributes, max_attribute_length, max_text)
|
|
207
|
+
parser = expat.ParserCreate(None, _SEP) if namespaces else expat.ParserCreate()
|
|
208
|
+
parser.buffer_text = True
|
|
209
|
+
parser.SetParamEntityParsing(expat.XML_PARAM_ENTITY_PARSING_NEVER)
|
|
210
|
+
parser.StartDoctypeDeclHandler = builder.refuse
|
|
211
|
+
parser.EntityDeclHandler = builder.refuse
|
|
212
|
+
parser.ExternalEntityRefHandler = builder.refuse
|
|
213
|
+
parser.NotationDeclHandler = builder.refuse
|
|
214
|
+
if not ignore_processing_instructions:
|
|
215
|
+
parser.ProcessingInstructionHandler = builder.refuse_pi
|
|
216
|
+
parser.XmlDeclHandler = builder.xml_decl
|
|
217
|
+
parser.StartElementHandler = builder.start
|
|
218
|
+
parser.EndElementHandler = builder.end
|
|
219
|
+
parser.CharacterDataHandler = builder.data
|
|
220
|
+
try:
|
|
221
|
+
parser.Parse(data, True)
|
|
222
|
+
except XmlRefused:
|
|
223
|
+
raise
|
|
224
|
+
except expat.ExpatError as error:
|
|
225
|
+
raise XmlMalformed("not well-formed XML: " + expat.ErrorString(error.code), hint) from None
|
|
226
|
+
except (ValueError, RecursionError) as error:
|
|
227
|
+
raise XmlMalformed(f"not well-formed XML: {type(error).__name__}", hint) from None
|
|
228
|
+
if builder.root is None:
|
|
229
|
+
raise XmlMalformed("no root element", hint)
|
|
230
|
+
return builder.root
|