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,143 @@
|
|
|
1
|
+
"""The bbf and ecma collections' PDF model: stored extractions, section lookup and sentence search.
|
|
2
|
+
|
|
3
|
+
Reading a PDF moved to the core: `extract` is `primarysource.readers.pdf.extract` (`cl.reader_pdf_pypdf`) and the
|
|
4
|
+
cutting into sections is `primarysource.readers.bookmarks` (`cl.segment_bookmarks`). They are re-exported here so
|
|
5
|
+
the importers, the libraries and their tests are unchanged. The import runs once and its result is stored as
|
|
6
|
+
JSON beside the PDF, so serving reads `PdfDocument.from_json` and never needs `pypdf`. A proposal for the core
|
|
7
|
+
version of the rest is in `docs/ecma.md`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import shutil # noqa: F401 - tests patch `_pdf.shutil.which` to keep pdftotext out
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
|
|
16
|
+
from primarysource.errors import LibError
|
|
17
|
+
from primarysource.readers.bookmarks import cut as _cut
|
|
18
|
+
from primarysource.readers.bookmarks import heading_id as _heading_id
|
|
19
|
+
from primarysource.readers.bookmarks import slug, split_sentences
|
|
20
|
+
from primarysource.readers.pdf import INSTALL_HINT, MAX_BYTES, MAX_OUTLINE, MAX_PAGES, extract
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"INSTALL_HINT",
|
|
24
|
+
"MAX_BYTES",
|
|
25
|
+
"MAX_OUTLINE",
|
|
26
|
+
"MAX_PAGES",
|
|
27
|
+
"MAX_QUERY_TERMS",
|
|
28
|
+
"PdfDocument",
|
|
29
|
+
"PdfSection",
|
|
30
|
+
"_cut",
|
|
31
|
+
"_heading_id",
|
|
32
|
+
"extract",
|
|
33
|
+
"query_terms",
|
|
34
|
+
"score_units",
|
|
35
|
+
"slug",
|
|
36
|
+
"snippet",
|
|
37
|
+
"split_sentences",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PdfSection:
|
|
42
|
+
"""One bookmarked section of an extracted PDF: `sentences` are its own text, subsections excluded."""
|
|
43
|
+
|
|
44
|
+
__slots__ = ("depth", "id", "page", "sentences", "title")
|
|
45
|
+
|
|
46
|
+
def __init__(self, ident: str, title: str, depth: int, page: int, sentences: tuple[str, ...]) -> None:
|
|
47
|
+
self.id, self.title, self.depth, self.page, self.sentences = ident, title, depth, page, sentences
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PdfDocument:
|
|
51
|
+
"""An extracted PDF as stored on disk: see `extract` for the fields."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, title: str, pages: int, extractor: str, sections: tuple[PdfSection, ...]) -> None:
|
|
54
|
+
self.title, self.pages, self.extractor, self.sections = title, pages, extractor, sections
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_json(cls, raw: object) -> PdfDocument:
|
|
58
|
+
"""Validate a stored extraction. A malformed one is a `LibError`: the install is damaged or hostile."""
|
|
59
|
+
try:
|
|
60
|
+
if not isinstance(raw, dict):
|
|
61
|
+
raise TypeError("not an object")
|
|
62
|
+
sections = tuple(
|
|
63
|
+
PdfSection(
|
|
64
|
+
str(entry["id"]),
|
|
65
|
+
str(entry["title"]),
|
|
66
|
+
int(entry["depth"]),
|
|
67
|
+
int(entry["page"]),
|
|
68
|
+
tuple(line for line in str(entry["text"]).split("\n") if line),
|
|
69
|
+
)
|
|
70
|
+
for entry in raw["sections"]
|
|
71
|
+
)
|
|
72
|
+
return cls(str(raw.get("title", "")), int(raw["pages"]), str(raw.get("extractor", "")), sections)
|
|
73
|
+
except (AssertionError, KeyError, TypeError, ValueError) as exc:
|
|
74
|
+
raise LibError("the stored extraction is malformed", "", "re-import the document") from exc
|
|
75
|
+
|
|
76
|
+
def find(self, wanted: str) -> tuple[int, str]:
|
|
77
|
+
"""Index of the section `wanted` names (its id, else its title's slug, else its title ignoring case),
|
|
78
|
+
and a warning ("" if none). Raises `LibError` with close matches when nothing fits."""
|
|
79
|
+
text = wanted.strip()
|
|
80
|
+
for match in (
|
|
81
|
+
[i for i, s in enumerate(self.sections) if s.id == text],
|
|
82
|
+
[i for i, s in enumerate(self.sections) if s.id.lower() == text.lower()],
|
|
83
|
+
[i for i, s in enumerate(self.sections) if slug(s.title) == slug(text)],
|
|
84
|
+
):
|
|
85
|
+
if match:
|
|
86
|
+
warning = ""
|
|
87
|
+
if len(match) > 1:
|
|
88
|
+
warning = f"{len(match)} sections match {text!r}; this is the first"
|
|
89
|
+
return match[0], warning
|
|
90
|
+
import difflib
|
|
91
|
+
|
|
92
|
+
close = difflib.get_close_matches(text, [s.id for s in self.sections], n=3, cutoff=0.6)
|
|
93
|
+
raise LibError(f"no section {text!r}", f"did you mean: {', '.join(close)}" if close else "see the outline")
|
|
94
|
+
|
|
95
|
+
def span(self, index: int) -> tuple[str, ...]:
|
|
96
|
+
"""The section's sentences, then each subsection as a heading unit (`§5.3.1 Title`) followed by its own
|
|
97
|
+
sentences, down to the next section at the same or a shallower depth."""
|
|
98
|
+
root = self.sections[index]
|
|
99
|
+
units = list(root.sentences)
|
|
100
|
+
for sub in self.sections[index + 1 :]:
|
|
101
|
+
if sub.depth <= root.depth:
|
|
102
|
+
break
|
|
103
|
+
units.append(f"§{sub.id} {sub.title}")
|
|
104
|
+
units.extend(sub.sentences)
|
|
105
|
+
return tuple(units)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_TERM = re.compile(r"\w+", re.UNICODE)
|
|
109
|
+
MAX_QUERY_TERMS = 16
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def query_terms(query: str) -> list[str]:
|
|
113
|
+
"""The words of a search query, case-folded, at most `MAX_QUERY_TERMS` of at most 64 characters each."""
|
|
114
|
+
terms = [t for t in _TERM.findall(query.casefold()) if len(t) <= 64]
|
|
115
|
+
if not terms:
|
|
116
|
+
raise LibError("the query has no searchable words", "use words or identifiers, e.g. 'Inform retry'")
|
|
117
|
+
return terms[:MAX_QUERY_TERMS]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def snippet(text: str, terms: list[str], width: int = 160) -> str:
|
|
121
|
+
"""About `width` characters of `text` around the first query word."""
|
|
122
|
+
flat = " ".join(text.split())
|
|
123
|
+
low = flat.casefold()
|
|
124
|
+
at = min((i for i in (low.find(t) for t in terms) if i >= 0), default=0)
|
|
125
|
+
begin = max(0, at - 60)
|
|
126
|
+
return ("..." if begin else "") + flat[begin : begin + width] + ("..." if begin + width < len(flat) else "")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def score_units(units: Sequence[str], terms: list[str], title: str, need_all: bool) -> list[tuple[float, int]]:
|
|
130
|
+
"""(score, 1-based unit number) for each unit containing every term (or, if not `need_all`, at least half).
|
|
131
|
+
Score: term frequency, plus 3 per distinct term present, plus 2 per term in the section title."""
|
|
132
|
+
found: list[tuple[float, int]] = []
|
|
133
|
+
low_title = title.casefold()
|
|
134
|
+
for number, unit in enumerate(units, start=1):
|
|
135
|
+
if unit.startswith("§"):
|
|
136
|
+
continue # a subsection heading unit is reached through its own section
|
|
137
|
+
words = _TERM.findall(unit.casefold())
|
|
138
|
+
counts = [words.count(t) for t in terms]
|
|
139
|
+
present = sum(1 for c in counts if c)
|
|
140
|
+
if present == 0 or present * 2 < len(terms) or (need_all and present < len(terms)):
|
|
141
|
+
continue
|
|
142
|
+
found.append((sum(counts) + 3 * present + sum(2 for t in terms if t in low_title), number))
|
|
143
|
+
return found
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"terms": {
|
|
3
|
+
"licence": "Ecma International text copyright policy (default notice)",
|
|
4
|
+
"placement": "volume",
|
|
5
|
+
"terms_url": "https://ecma-international.org/policies/by-ipr/ecma-text-copyright-policy/",
|
|
6
|
+
"terms_read": "2026-11-12",
|
|
7
|
+
"summary": "The notice printed in ECMA-427 allows copying and distribution of the whole or parts with the copyright notice and licence on every copy, and derivative works only as commentary, accessibility, translation or format change, or implementation; the content may not otherwise be modified. Ecma prefers that the standard is downloaded from its own site. This collection therefore stays on a user-fetched volume: the importer downloads the PDF from ecma-international.org and writes only to the user's own home. Software in the standard is BSD-licensed (the Software License section of the PDF)."
|
|
8
|
+
},
|
|
9
|
+
"editions": [
|
|
10
|
+
{
|
|
11
|
+
"number": "427",
|
|
12
|
+
"edition": 1,
|
|
13
|
+
"designation": "ECMA-427",
|
|
14
|
+
"title": "Package-URL (PURL) specification",
|
|
15
|
+
"edition_name": "1st edition, December 2025",
|
|
16
|
+
"page_url": "https://ecma-international.org/publications-and-standards/standards/ecma-427/",
|
|
17
|
+
"source_url": "https://ecma-international.org/wp-content/uploads/ECMA-427_1st_edition_december_2025.pdf",
|
|
18
|
+
"size": 2320437,
|
|
19
|
+
"sha256": "037180df99e7d7c26d910dc3adb3fa07c4fb787cc4bd29e43634de3802313825"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""The Ecma catalogue: which standards and editions, where to fetch them, and the sha256 of each. Metadata only.
|
|
2
|
+
|
|
3
|
+
`catalogue.json` is package data. Starting with ECMA-427 (Package-URL, 1st edition, December 2025). An edition is
|
|
4
|
+
identified by the standard's number and its edition number; a ref names the number (`ecma:427`) and optionally the
|
|
5
|
+
edition (`ecma:427@1`); with no edition the highest imported one is read.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from importlib import resources
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from primarysource.errors import LibError
|
|
17
|
+
|
|
18
|
+
NUMBER = re.compile(r"[0-9]{1,4}")
|
|
19
|
+
SHA256 = re.compile(r"[0-9a-f]{64}")
|
|
20
|
+
HOSTS = ("ecma-international.org", "www.ecma-international.org")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class Terms:
|
|
25
|
+
licence: str
|
|
26
|
+
placement: str
|
|
27
|
+
terms_url: str
|
|
28
|
+
terms_read: str
|
|
29
|
+
summary: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class Edition:
|
|
34
|
+
"""One edition of one Ecma standard, with its pinned PDF."""
|
|
35
|
+
|
|
36
|
+
number: str
|
|
37
|
+
edition: int
|
|
38
|
+
designation: str
|
|
39
|
+
title: str
|
|
40
|
+
edition_name: str
|
|
41
|
+
page_url: str
|
|
42
|
+
source_url: str
|
|
43
|
+
size: int
|
|
44
|
+
sha256: str
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def key(self) -> str:
|
|
48
|
+
"""The file stem under the install directory: `427-1`."""
|
|
49
|
+
return f"{self.number}-{self.edition}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class Catalogue:
|
|
54
|
+
terms: Terms
|
|
55
|
+
editions: tuple[Edition, ...]
|
|
56
|
+
|
|
57
|
+
def edition(self, number: str, edition: int | None = None) -> Edition:
|
|
58
|
+
"""The catalogued edition of `number` (the highest if `edition` is None)."""
|
|
59
|
+
have = [e for e in self.editions if e.number == number and (edition is None or e.edition == edition)]
|
|
60
|
+
if not have:
|
|
61
|
+
known = ", ".join(f"{e.number}@{e.edition}" for e in self.editions)
|
|
62
|
+
raise LibError(
|
|
63
|
+
f"no Ecma standard {number}{'@' + str(edition) if edition else ''} in the catalogue",
|
|
64
|
+
f"known: {known}",
|
|
65
|
+
)
|
|
66
|
+
return max(have, key=lambda e: e.edition)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_catalogue(raw: dict[str, Any]) -> Catalogue:
|
|
70
|
+
"""Validate a catalogue document. Raises `ValueError`: a malformed catalogue is a packaging bug."""
|
|
71
|
+
terms = raw["terms"]
|
|
72
|
+
if terms["placement"] not in ("layer", "volume"):
|
|
73
|
+
raise ValueError("terms.placement must be 'layer' or 'volume'")
|
|
74
|
+
editions: list[Edition] = []
|
|
75
|
+
for entry in raw["editions"]:
|
|
76
|
+
found = Edition(
|
|
77
|
+
str(entry["number"]),
|
|
78
|
+
int(entry["edition"]),
|
|
79
|
+
entry["designation"],
|
|
80
|
+
entry["title"],
|
|
81
|
+
entry["edition_name"],
|
|
82
|
+
entry["page_url"],
|
|
83
|
+
entry["source_url"],
|
|
84
|
+
int(entry["size"]),
|
|
85
|
+
entry["sha256"],
|
|
86
|
+
)
|
|
87
|
+
if not NUMBER.fullmatch(found.number) or not SHA256.fullmatch(found.sha256) or found.edition < 1:
|
|
88
|
+
raise ValueError(f"catalogue entry {found.designation!r} is malformed")
|
|
89
|
+
editions.append(found)
|
|
90
|
+
if len({e.key for e in editions}) != len(editions):
|
|
91
|
+
raise ValueError("duplicate editions in the catalogue")
|
|
92
|
+
return Catalogue(
|
|
93
|
+
Terms(terms["licence"], terms["placement"], terms["terms_url"], terms["terms_read"], terms["summary"]),
|
|
94
|
+
tuple(editions),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def catalogue() -> Catalogue:
|
|
99
|
+
text = resources.files("primarysource.ecma").joinpath("catalogue.json").read_text(encoding="utf-8")
|
|
100
|
+
return parse_catalogue(json.loads(text))
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""`primarysource ecma ...`: the collection's own commands.
|
|
2
|
+
|
|
3
|
+
primarysource ecma import [NUMBER] [--edition N] [--file PDF]
|
|
4
|
+
primarysource ecma docs
|
|
5
|
+
primarysource ecma outline <doc>
|
|
6
|
+
primarysource ecma read <ref> [--max-tokens N]
|
|
7
|
+
primarysource ecma search <query> [--doc NUMBER] [--limit N] [--start N]
|
|
8
|
+
primarysource ecma cite <ref>
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from primarysource.ecma import importer, library
|
|
19
|
+
from primarysource.errors import LibError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parser() -> argparse.ArgumentParser:
|
|
23
|
+
parser = argparse.ArgumentParser(prog="primarysource ecma", description="Ecma International standards")
|
|
24
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
25
|
+
imp = commands.add_parser("import", help="fetch (or read a file), verify and index one standard")
|
|
26
|
+
imp.add_argument("number", nargs="?", default="427", help="the standard's number (default 427)")
|
|
27
|
+
imp.add_argument("--edition", type=int, help="edition number (default: the newest catalogued)")
|
|
28
|
+
imp.add_argument("--file", type=Path, help="the PDF you downloaded yourself, instead of fetching it")
|
|
29
|
+
commands.add_parser("docs", help="the catalogue and what is imported")
|
|
30
|
+
commands.add_parser("outline", help="a standard's clauses with refs").add_argument("doc")
|
|
31
|
+
read = commands.add_parser("read", help="read by ref")
|
|
32
|
+
read.add_argument("ref")
|
|
33
|
+
read.add_argument("--max-tokens", type=int, default=2000)
|
|
34
|
+
search = commands.add_parser("search", help="find sentences containing words")
|
|
35
|
+
search.add_argument("query")
|
|
36
|
+
search.add_argument("--doc")
|
|
37
|
+
search.add_argument("--limit", type=int, default=10)
|
|
38
|
+
search.add_argument("--start", type=int, default=0)
|
|
39
|
+
commands.add_parser("cite", help="the citation for a ref").add_argument("ref")
|
|
40
|
+
return parser
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _out(text: str) -> int:
|
|
44
|
+
sys.stdout.write(text if text.endswith("\n") else text + "\n")
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main(argv: Sequence[str]) -> int:
|
|
49
|
+
args = _parser().parse_args(list(argv))
|
|
50
|
+
try:
|
|
51
|
+
if args.command == "import":
|
|
52
|
+
result = importer.import_edition(library.default_home(), args.number, args.edition, file=args.file)
|
|
53
|
+
return _out(
|
|
54
|
+
f"imported {result.key}: {result.sections} sections, {result.requests} request(s), {result.pdf}"
|
|
55
|
+
)
|
|
56
|
+
if args.command == "docs":
|
|
57
|
+
return _out(library.docs())
|
|
58
|
+
if args.command == "outline":
|
|
59
|
+
return _out(library.outline(args.doc))
|
|
60
|
+
if args.command == "read":
|
|
61
|
+
return _out(library.read(args.ref, max_tokens=args.max_tokens))
|
|
62
|
+
if args.command == "search":
|
|
63
|
+
return _out(library.search(args.query, doc=args.doc, limit=args.limit, start=args.start))
|
|
64
|
+
return _out(library.cite(args.ref))
|
|
65
|
+
except LibError as exc:
|
|
66
|
+
print(exc.as_text(), file=sys.stderr)
|
|
67
|
+
return 1
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""How the ecma collection recognises an Ecma standard in a supplied PDF (`registry.Collection.identify`).
|
|
2
|
+
|
|
3
|
+
Covers and running headers read `ECMA-427` (`Standard ECMA-427`); Ecma's download is `ECMA-427_1st_edition.pdf`.
|
|
4
|
+
The importer takes the file the user downloaded (`--file`) and checks it against the catalogue's pinned digest.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from primarysource.identify import Candidate, Probe, candidates
|
|
12
|
+
|
|
13
|
+
_ECMA = re.compile(r"\bECMA-(?P<num>\d{1,4})\b")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _label(match: re.Match[str]) -> str:
|
|
17
|
+
return f"ECMA-{match['num']}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def identify(probe: Probe) -> list[Candidate]:
|
|
21
|
+
def command(label: str) -> str:
|
|
22
|
+
where = probe.path if probe.path is not None else probe.name
|
|
23
|
+
return f'primarysource ecma import {label.removeprefix("ECMA-")} --file "{where}"'
|
|
24
|
+
|
|
25
|
+
return candidates(probe, "ecma", _ECMA, _label, command)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Import an Ecma standard into `$PRIMARYSOURCE_HOME/ecma/`.
|
|
2
|
+
|
|
3
|
+
The PDF is fetched from ecma-international.org (one request) or taken from a file the user downloaded, checked
|
|
4
|
+
against the catalogue's pinned sha256 before anything is parsed or written, stored as `<number>-<edition>.pdf`,
|
|
5
|
+
and read once with `_pdf.extract` into `<number>-<edition>.json`, which is what the readers use. `install.json`
|
|
6
|
+
records the pin, the extractor and the time. Placement is a volume: this is the user's own copy, downloaded under
|
|
7
|
+
Ecma's terms (see `catalogue.json`), and is not for an image layer.
|
|
8
|
+
|
|
9
|
+
Run as `primarysource ecma import [NUMBER] [--file PDF]`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from primarysource.c2sp._fetch import Fetcher, Transport, verify
|
|
22
|
+
from primarysource.ecma import _pdf
|
|
23
|
+
from primarysource.ecma.catalogue import HOSTS, Catalogue, Edition
|
|
24
|
+
from primarysource.ecma.catalogue import catalogue as packaged_catalogue
|
|
25
|
+
from primarysource.errors import LibError
|
|
26
|
+
from primarysource.home import atomic_write
|
|
27
|
+
|
|
28
|
+
INSTALL_RECORD = "install.json"
|
|
29
|
+
MAX_PDF = 64 * 1024 * 1024
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class ImportResult:
|
|
34
|
+
key: str
|
|
35
|
+
pdf: Path
|
|
36
|
+
extraction: Path
|
|
37
|
+
sections: int
|
|
38
|
+
requests: int
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def read_record(home: Path) -> dict[str, Any]:
|
|
42
|
+
path = home / INSTALL_RECORD
|
|
43
|
+
try:
|
|
44
|
+
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
45
|
+
except (OSError, ValueError):
|
|
46
|
+
return {"editions": {}}
|
|
47
|
+
return loaded if isinstance(loaded, dict) and isinstance(loaded.get("editions"), dict) else {"editions": {}}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def import_edition(
|
|
51
|
+
home: Path,
|
|
52
|
+
number: str,
|
|
53
|
+
edition: int | None = None,
|
|
54
|
+
*,
|
|
55
|
+
file: Path | None = None,
|
|
56
|
+
cat: Catalogue | None = None,
|
|
57
|
+
transport: Transport | None = None,
|
|
58
|
+
fetcher: Fetcher | None = None,
|
|
59
|
+
) -> ImportResult:
|
|
60
|
+
"""Fetch (or read `file`), verify, store and extract one edition. Nothing is written unless the sha256
|
|
61
|
+
matches the pin and the PDF reads."""
|
|
62
|
+
cat = cat or packaged_catalogue()
|
|
63
|
+
found: Edition = cat.edition(number, edition)
|
|
64
|
+
requests = 0
|
|
65
|
+
if file is not None:
|
|
66
|
+
try:
|
|
67
|
+
data = file.read_bytes()
|
|
68
|
+
except OSError as exc:
|
|
69
|
+
raise LibError(f"cannot read {file}: {exc}", "pass the PDF you downloaded from the publisher") from exc
|
|
70
|
+
if len(data) > MAX_PDF:
|
|
71
|
+
raise LibError(f"{file} is larger than {MAX_PDF} bytes", "")
|
|
72
|
+
else:
|
|
73
|
+
fetcher = fetcher or Fetcher(HOSTS, transport)
|
|
74
|
+
data = fetcher.get(found.source_url, MAX_PDF)
|
|
75
|
+
requests = fetcher.requests
|
|
76
|
+
verify(data, found.sha256, found.designation)
|
|
77
|
+
if not data.startswith(b"%PDF-"):
|
|
78
|
+
raise LibError(f"{found.designation}: not a PDF", "the pinned file is a PDF; nothing was written")
|
|
79
|
+
pdf_path = home / f"{found.key}.pdf"
|
|
80
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
part = home / f"{found.key}.pdf.part"
|
|
82
|
+
part.write_bytes(data)
|
|
83
|
+
try:
|
|
84
|
+
extraction = _pdf.extract(data, part)
|
|
85
|
+
except BaseException:
|
|
86
|
+
part.unlink(missing_ok=True)
|
|
87
|
+
raise
|
|
88
|
+
os.replace(part, pdf_path)
|
|
89
|
+
extraction.update(
|
|
90
|
+
{
|
|
91
|
+
"title": found.title,
|
|
92
|
+
"designation": found.designation,
|
|
93
|
+
"edition": found.edition_name,
|
|
94
|
+
"page_url": found.page_url,
|
|
95
|
+
"sha256": found.sha256,
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
json_path = home / f"{found.key}.json"
|
|
99
|
+
atomic_write(json_path, (json.dumps(extraction, ensure_ascii=False) + "\n").encode("utf-8"))
|
|
100
|
+
record = read_record(home)
|
|
101
|
+
record["collection"] = "ecma"
|
|
102
|
+
record["editions"][found.key] = {
|
|
103
|
+
"sha256": found.sha256,
|
|
104
|
+
"extractor": extraction["extractor"],
|
|
105
|
+
"imported": datetime.now(UTC).isoformat(timespec="seconds"),
|
|
106
|
+
}
|
|
107
|
+
atomic_write(home / INSTALL_RECORD, (json.dumps(record, indent=2) + "\n").encode("utf-8"))
|
|
108
|
+
return ImportResult(found.key, pdf_path, json_path, len(extraction["sections"]), requests)
|