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,100 @@
|
|
|
1
|
+
"""How the iso collection recognises an ISO publication in a supplied PDF (`registry.Collection.identify`).
|
|
2
|
+
|
|
3
|
+
An ISO cover prints `Reference number` and then the designation (`ISO/IEC 7816-3:2006(E)`), and every later page
|
|
4
|
+
repeats the designation as a running header, so the cover and the header outweigh whatever the file is called.
|
|
5
|
+
The PDF's title metadata is often useless (`Microsoft Word - C038770e.doc`) and is only one piece of evidence.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from primarysource.identify import Candidate, Probe, candidates, points
|
|
13
|
+
from primarysource.iso.designation import _PATTERN, Designation, _from_match
|
|
14
|
+
|
|
15
|
+
_REFERENCE = re.compile(r"Reference\s+number\s+(?P<designation>ISO\S*(?:\s+\S+){0,3})", re.IGNORECASE)
|
|
16
|
+
REFERENCE_POINTS = 3
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _label(match: re.Match[str]) -> str | None:
|
|
20
|
+
return _from_match(match).label
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def designation_of(label: str) -> Designation:
|
|
24
|
+
match = _PATTERN.fullmatch(label)
|
|
25
|
+
if match is None: # every label this module produces parses; a foreign one is a bug
|
|
26
|
+
raise ValueError(label)
|
|
27
|
+
return _from_match(match)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_EDITION = re.compile(r"\bedition\b", re.IGNORECASE)
|
|
31
|
+
_DATE = re.compile(r"\d{4}-\d{2}-\d{2}")
|
|
32
|
+
_STOP = re.compile(r"Reference\s+number|\bPartie\b|[àâçéèêëîïôûùüÿœ]")
|
|
33
|
+
_JUNK_TITLE = re.compile(r"^\s*$|^(?:Microsoft Word|untitled)|\.(?:docx?|pdf|indd|fm)\b", re.IGNORECASE)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def cover_title(probe: Probe) -> str:
|
|
37
|
+
"""The English title printed on an ISO cover: the lines after the edition line, up to the French title or
|
|
38
|
+
the `Reference number` line. Empty when the cover does not have that shape."""
|
|
39
|
+
if not probe.pages:
|
|
40
|
+
return ""
|
|
41
|
+
lines = [" ".join(line.split()) for line in probe.pages[0].splitlines() if line.strip()]
|
|
42
|
+
start = next((i + 1 for i, line in enumerate(lines) if _EDITION.search(line)), None)
|
|
43
|
+
if start is None:
|
|
44
|
+
return ""
|
|
45
|
+
title: list[str] = []
|
|
46
|
+
for line in lines[start:]:
|
|
47
|
+
if _STOP.search(line):
|
|
48
|
+
break
|
|
49
|
+
if not _DATE.fullmatch(line):
|
|
50
|
+
title.append(line)
|
|
51
|
+
return " ".join(title)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def useful_title(title: str, designation: str) -> bool:
|
|
55
|
+
"""False for title metadata that names a file or a word processor (`Microsoft Word - C038770e.doc`) or that is
|
|
56
|
+
only the designation again."""
|
|
57
|
+
return not _JUNK_TITLE.search(title) and title.strip() != designation
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _merge(found: list[Candidate]) -> list[Candidate]:
|
|
61
|
+
"""One candidate per document: `ISO 7816-3:2006` from a file name and `ISO/IEC 7816-3:2006` from the cover
|
|
62
|
+
are the same document (`7816-3@2006`), spelled by the surest evidence, with the evidence pooled."""
|
|
63
|
+
by_doc: dict[str, list[Candidate]] = {}
|
|
64
|
+
for candidate in found:
|
|
65
|
+
by_doc.setdefault(designation_of(candidate.designation).doc, []).append(candidate)
|
|
66
|
+
merged: list[Candidate] = []
|
|
67
|
+
for group in by_doc.values():
|
|
68
|
+
best = max(group, key=lambda c: c.score)
|
|
69
|
+
evidence = tuple(dict.fromkeys(e for c in group for e in c.evidence))
|
|
70
|
+
merged.append(Candidate(best.collection, best.designation, points(evidence), evidence, best.command))
|
|
71
|
+
return merged
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def identify(probe: Probe) -> list[Candidate]:
|
|
75
|
+
"""ISO designations in the probe, the cover's `Reference number` line counting 3 more."""
|
|
76
|
+
|
|
77
|
+
def command(label: str) -> str:
|
|
78
|
+
where = f'"{probe.path}"' if probe.path is not None else f'"{probe.name}"'
|
|
79
|
+
return f'primarysource iso import {where} --designation "{label}"'
|
|
80
|
+
|
|
81
|
+
found = _merge(candidates(probe, "iso", _PATTERN, _label, command))
|
|
82
|
+
if not probe.pages:
|
|
83
|
+
return found
|
|
84
|
+
referenced = set()
|
|
85
|
+
for match in _REFERENCE.finditer(probe.pages[0]):
|
|
86
|
+
inner = _PATTERN.search(match["designation"])
|
|
87
|
+
if inner is not None:
|
|
88
|
+
referenced.add(_from_match(inner).label)
|
|
89
|
+
out: list[Candidate] = []
|
|
90
|
+
for candidate in found:
|
|
91
|
+
if candidate.designation in referenced:
|
|
92
|
+
candidate = Candidate(
|
|
93
|
+
candidate.collection,
|
|
94
|
+
candidate.designation,
|
|
95
|
+
candidate.score + REFERENCE_POINTS,
|
|
96
|
+
("cover reference number", *candidate.evidence),
|
|
97
|
+
candidate.command,
|
|
98
|
+
)
|
|
99
|
+
out.append(candidate)
|
|
100
|
+
return out
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Import the owner's own purchased ISO PDFs into `$PRIMARYSOURCE_HOME/iso/`.
|
|
2
|
+
|
|
3
|
+
`import_folder` reads every new or changed PDF in the home; `import_file` takes one file and, optionally, the
|
|
4
|
+
designation printed on its cover. Which standard a PDF is comes from its content first (`iso.identify`: the cover's
|
|
5
|
+
`Reference number` line and the running header), then its file name, so a renamed download still imports and a
|
|
6
|
+
file named for the wrong standard is caught. A PDF another collection knows (an ITU-T Recommendation, a NIST
|
|
7
|
+
publication) is refused with that collection's command. Each PDF is read once with `readers.pdf.extract` (cut at
|
|
8
|
+
its bookmarks, or at its numbered headings when it has none, as older ISO editions do) into
|
|
9
|
+
`extracted/<key>.json`, which is what serving reads, so the PDF is never copied and serving never needs `pypdf`.
|
|
10
|
+
`install.json` records the designation, the sha256, size and mtime of the source, the extractor and the
|
|
11
|
+
segmenter. When the PDF's title metadata is useless (`Microsoft Word - C038770e.doc`), the English title on the
|
|
12
|
+
cover is used. After an import the core FTS5 index (`index.sqlite`) is rebuilt atomically. Nothing here fetches,
|
|
13
|
+
and nothing records the buyer's watermark or a distributor's tracking string.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from collections.abc import Iterator
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from primarysource import index
|
|
26
|
+
from primarysource.errors import LibError
|
|
27
|
+
from primarysource.fsutil import atomic_write, sha256
|
|
28
|
+
from primarysource.iso.designation import Designation, from_filename, parse
|
|
29
|
+
from primarysource.iso.identify import cover_title, useful_title
|
|
30
|
+
from primarysource.model import Document, Section, Unit
|
|
31
|
+
|
|
32
|
+
INSTALL_RECORD = "install.json"
|
|
33
|
+
INDEX_FILE = "index.sqlite"
|
|
34
|
+
EXTRACTED = "extracted"
|
|
35
|
+
REBUILD_COMMAND = "primarysource iso index"
|
|
36
|
+
IMPORT_COMMAND = "primarysource iso import"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class ImportResult:
|
|
41
|
+
doc: str
|
|
42
|
+
designation: str
|
|
43
|
+
sections: int
|
|
44
|
+
headings_found: int
|
|
45
|
+
segmenter: str = ""
|
|
46
|
+
note: str = ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def read_record(home: Path) -> dict[str, Any]:
|
|
50
|
+
try:
|
|
51
|
+
loaded = json.loads((home / INSTALL_RECORD).read_text(encoding="utf-8"))
|
|
52
|
+
except (OSError, ValueError):
|
|
53
|
+
return {"documents": {}}
|
|
54
|
+
if isinstance(loaded, dict) and isinstance(loaded.get("documents"), dict):
|
|
55
|
+
return loaded
|
|
56
|
+
return {"documents": {}}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _write_record(home: Path, record: dict[str, Any]) -> None:
|
|
60
|
+
record["collection"] = "iso"
|
|
61
|
+
atomic_write(home / INSTALL_RECORD, (json.dumps(record, indent=2, sort_keys=True) + "\n").encode("utf-8"))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_pdf(path: Path) -> bool:
|
|
65
|
+
return path.is_file() and path.suffix.lower() == ".pdf"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def designate(path: Path, explicit: str | None = None) -> tuple[Designation, str]:
|
|
69
|
+
"""Which ISO standard `path` is, and a note when the file name disagrees with the cover. An explicit
|
|
70
|
+
designation wins; then the PDF's own content (a score of 3 or more: the cover, or a running header and the
|
|
71
|
+
file name); then the file name. A PDF another collection recognises is refused with that collection's
|
|
72
|
+
command, and one nobody recognises asks for `--designation`."""
|
|
73
|
+
if explicit:
|
|
74
|
+
return parse(explicit), ""
|
|
75
|
+
from primarysource import identify
|
|
76
|
+
from primarysource.iso.identify import designation_of
|
|
77
|
+
from primarysource.iso.identify import identify as iso_identify
|
|
78
|
+
|
|
79
|
+
probe = identify.probe_pdf(path)
|
|
80
|
+
named = from_filename(path.name)
|
|
81
|
+
ours = sorted(iso_identify(probe), key=lambda c: -c.score)
|
|
82
|
+
if ours and ours[0].score >= 3:
|
|
83
|
+
found = designation_of(ours[0].designation)
|
|
84
|
+
note = ""
|
|
85
|
+
if named is not None and named.doc != found.doc:
|
|
86
|
+
note = f"the file name says {named.label}, the PDF says {found.label}; using the PDF"
|
|
87
|
+
return found, note
|
|
88
|
+
if named is not None:
|
|
89
|
+
return named, ""
|
|
90
|
+
others = [c for c in identify.identify(probe, identify.registered_hooks()) if c.collection != "iso"]
|
|
91
|
+
if others:
|
|
92
|
+
best = others[0]
|
|
93
|
+
raise LibError(
|
|
94
|
+
f"{path.name} is {best.designation}, a {best.collection} document, not an ISO standard", "", best.command
|
|
95
|
+
)
|
|
96
|
+
raise LibError(
|
|
97
|
+
f"{path.name}: no ISO designation found on its cover, in its running headers or in its file name",
|
|
98
|
+
"",
|
|
99
|
+
f'{IMPORT_COMMAND} "{path}" --designation "ISO/IEC 9899:2024" (as printed on the cover)',
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def import_file(home: Path, path: Path, designation: str | None = None, *, reindex: bool = True) -> ImportResult:
|
|
104
|
+
"""Identify, extract and record one PDF. Nothing is recorded unless the PDF reads and can be cut into
|
|
105
|
+
sections (at its bookmarks, or at three or more numbered headings)."""
|
|
106
|
+
from primarysource.readers import pdf
|
|
107
|
+
|
|
108
|
+
if not path.is_file():
|
|
109
|
+
raise LibError(f"{path} is not a file", "", f"check the path, then {IMPORT_COMMAND}")
|
|
110
|
+
try:
|
|
111
|
+
data = path.read_bytes()
|
|
112
|
+
stat = path.stat()
|
|
113
|
+
except OSError as exc:
|
|
114
|
+
raise LibError(f"cannot read {path}: {exc}", "", f"check the file, then {IMPORT_COMMAND}") from exc
|
|
115
|
+
if len(data) > pdf.MAX_BYTES:
|
|
116
|
+
raise LibError(f"{path.name} is {len(data)} bytes, over the {pdf.MAX_BYTES} byte bound", "")
|
|
117
|
+
if not data.startswith(b"%PDF-"):
|
|
118
|
+
raise LibError(f"{path.name} is not a PDF", "", "use the PDF as delivered by the ISO store")
|
|
119
|
+
found, note = designate(path, designation)
|
|
120
|
+
extraction = pdf.extract(data, path, numbered=True)
|
|
121
|
+
digest = sha256(data)
|
|
122
|
+
extraction.update({"designation": found.label, "doc": found.doc, "sha256": digest})
|
|
123
|
+
if not useful_title(str(extraction["title"]), found.label):
|
|
124
|
+
from primarysource import identify
|
|
125
|
+
|
|
126
|
+
extraction["title"] = cover_title(identify.probe_pdf(path))
|
|
127
|
+
atomic_write(
|
|
128
|
+
home / EXTRACTED / f"{found.key}.json",
|
|
129
|
+
(json.dumps(extraction, ensure_ascii=False) + "\n").encode("utf-8"),
|
|
130
|
+
)
|
|
131
|
+
record = read_record(home)
|
|
132
|
+
record["documents"][found.doc] = {
|
|
133
|
+
"designation": found.label,
|
|
134
|
+
"key": found.key,
|
|
135
|
+
"title": extraction["title"],
|
|
136
|
+
"source": str(path),
|
|
137
|
+
"sha256": digest,
|
|
138
|
+
"size": stat.st_size,
|
|
139
|
+
"mtime_ns": stat.st_mtime_ns,
|
|
140
|
+
"extractor": extraction["extractor"],
|
|
141
|
+
"segmenter": extraction["segmenter"],
|
|
142
|
+
"imported": datetime.now(UTC).isoformat(timespec="seconds"),
|
|
143
|
+
}
|
|
144
|
+
_write_record(home, record)
|
|
145
|
+
if reindex:
|
|
146
|
+
rebuild_index(home)
|
|
147
|
+
return ImportResult(
|
|
148
|
+
found.doc,
|
|
149
|
+
found.label,
|
|
150
|
+
len(extraction["sections"]),
|
|
151
|
+
int(extraction["headings_found"]),
|
|
152
|
+
str(extraction["segmenter"]),
|
|
153
|
+
note,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _unchanged(entry: dict[str, Any] | None, path: Path) -> bool:
|
|
158
|
+
if not entry or entry.get("source") != str(path):
|
|
159
|
+
return False
|
|
160
|
+
try:
|
|
161
|
+
stat = path.stat()
|
|
162
|
+
except OSError:
|
|
163
|
+
return False
|
|
164
|
+
return entry.get("size") == stat.st_size and entry.get("mtime_ns") == stat.st_mtime_ns
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def import_folder(home: Path, folder: Path | None = None) -> tuple[list[ImportResult], list[tuple[str, str]]]:
|
|
168
|
+
"""Import every PDF directly in `folder` (default: the home itself) that is new or changed since its last
|
|
169
|
+
import, identifying each from its content. Returns what was imported and what was skipped, with the reason.
|
|
170
|
+
The index is rebuilt once at the end when anything was imported."""
|
|
171
|
+
folder = folder or home
|
|
172
|
+
if not folder.is_dir():
|
|
173
|
+
raise LibError(
|
|
174
|
+
f"{folder} does not exist",
|
|
175
|
+
"",
|
|
176
|
+
f"create it (or set PRIMARYSOURCE_ISO_HOME to where your ISO PDFs are), put the PDFs in it, then "
|
|
177
|
+
f"{IMPORT_COMMAND}",
|
|
178
|
+
)
|
|
179
|
+
record = read_record(home)
|
|
180
|
+
by_source = {entry.get("source"): entry for entry in record["documents"].values()}
|
|
181
|
+
done: list[ImportResult] = []
|
|
182
|
+
skipped: list[tuple[str, str]] = []
|
|
183
|
+
for path in sorted(p for p in folder.iterdir() if _is_pdf(p)):
|
|
184
|
+
if _unchanged(by_source.get(str(path)), path):
|
|
185
|
+
skipped.append((path.name, "already imported, unchanged"))
|
|
186
|
+
continue
|
|
187
|
+
try:
|
|
188
|
+
done.append(import_file(home, path, reindex=False))
|
|
189
|
+
except LibError as exc:
|
|
190
|
+
skipped.append((path.name, exc.message + (f"; {exc.action}" if exc.action else "")))
|
|
191
|
+
if done:
|
|
192
|
+
rebuild_index(home)
|
|
193
|
+
return done, skipped
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def documents(home: Path) -> Iterator[Document]:
|
|
197
|
+
"""Every imported standard as a core `Document`, one section per bookmarked clause, sentences as units."""
|
|
198
|
+
record = read_record(home)
|
|
199
|
+
for doc, entry in sorted(record["documents"].items()):
|
|
200
|
+
stored = home / EXTRACTED / f"{entry['key']}.json"
|
|
201
|
+
try:
|
|
202
|
+
raw = json.loads(stored.read_text(encoding="utf-8"))
|
|
203
|
+
sections = tuple(
|
|
204
|
+
Section(
|
|
205
|
+
str(s["id"]),
|
|
206
|
+
str(s["title"]),
|
|
207
|
+
tuple(line for line in str(s["text"]).split("\n") if line),
|
|
208
|
+
int(s["depth"]) + 1,
|
|
209
|
+
)
|
|
210
|
+
for s in raw["sections"]
|
|
211
|
+
)
|
|
212
|
+
except (OSError, ValueError, KeyError, TypeError) as exc:
|
|
213
|
+
raise LibError(
|
|
214
|
+
f"the extraction of {entry.get('designation', doc)} is unreadable: {exc}",
|
|
215
|
+
"",
|
|
216
|
+
f"re-import it: {IMPORT_COMMAND}",
|
|
217
|
+
) from exc
|
|
218
|
+
title = f"{entry['designation']} {entry.get('title') or ''}".strip()
|
|
219
|
+
yield Document(doc, title, Unit.SENTENCE, sections)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def rebuild_index(home: Path) -> int:
|
|
223
|
+
"""Rebuild the search index from every stored extraction; returns the number of sections indexed."""
|
|
224
|
+
return index.rebuild(home / INDEX_FILE, documents(home), rebuild_command=REBUILD_COMMAND)
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Read, outline, search and cite the owner's imported ISO standards. Local files only; nothing here fetches.
|
|
2
|
+
|
|
3
|
+
A ref is `iso:9899@2024§6.5.2¶3`: the standard's number, `@` and its year (the newest imported year when omitted),
|
|
4
|
+
`§` and a clause as the PDF's bookmarks number it (`6.5.2`, `A` for Annex A, `A.1`) or the slug of an unnumbered
|
|
5
|
+
bookmark (`bibliography`), and `¶n`, the nth sentence of that clause's span. The span is the clause's own text,
|
|
6
|
+
then each subsection as a `§6.5.2.1 Title` line followed by its text. A printed designation works too:
|
|
7
|
+
`ISO/IEC 9899:2024§6.5.2`. A ref with no section is the whole standard.
|
|
8
|
+
|
|
9
|
+
Search goes through the core FTS5 index (`index.sqlite`, BM25, heading matches weighted 4); read, outline and cite
|
|
10
|
+
read the stored extraction.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from primarysource import budget, cursor, index
|
|
22
|
+
from primarysource.ecma._pdf import PdfDocument, PdfSection
|
|
23
|
+
from primarysource.errors import LibError
|
|
24
|
+
from primarysource.home import collection_home
|
|
25
|
+
from primarysource.iso.designation import parse, split_doc
|
|
26
|
+
from primarysource.iso.importer import (
|
|
27
|
+
EXTRACTED,
|
|
28
|
+
IMPORT_COMMAND,
|
|
29
|
+
INDEX_FILE,
|
|
30
|
+
REBUILD_COMMAND,
|
|
31
|
+
read_record,
|
|
32
|
+
)
|
|
33
|
+
from primarysource.model import span_hash
|
|
34
|
+
from primarysource.refs import Ref, RefGrammar
|
|
35
|
+
from primarysource.render import render
|
|
36
|
+
|
|
37
|
+
PREFIX = re.compile(r"^iso:", re.IGNORECASE)
|
|
38
|
+
_PRINTED = re.compile(r"^(?P<head>ISO[^§¶]*?)\s*(?P<rest>(?:§|¶).*)?$", re.IGNORECASE)
|
|
39
|
+
RANKING = index.Ranking(doc_title=1.0, title=4.0, body=1.0)
|
|
40
|
+
SEARCH_WINDOW = 500
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def grammar() -> RefGrammar:
|
|
44
|
+
from primarysource.iso import ISO_DOC, ISO_SECTION
|
|
45
|
+
|
|
46
|
+
return RefGrammar(ISO_DOC, ISO_SECTION)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class Loaded:
|
|
51
|
+
doc: str
|
|
52
|
+
designation: str
|
|
53
|
+
title: str
|
|
54
|
+
document: PdfDocument
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def default_home() -> Path:
|
|
58
|
+
return collection_home("iso")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _not_imported(what: str, home: Path) -> LibError:
|
|
62
|
+
have = ", ".join(sorted(read_record(home)["documents"])) or "nothing"
|
|
63
|
+
return LibError(
|
|
64
|
+
f"{what} is not imported (have: {have})",
|
|
65
|
+
"iso_docs lists what is imported",
|
|
66
|
+
f"put the PDF in {home}, then run: {IMPORT_COMMAND}",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_ref(text: str) -> Ref:
|
|
71
|
+
"""Parse an iso ref, accepting the `iso:` prefix and a printed designation for the document."""
|
|
72
|
+
body = PREFIX.sub("", text.strip(), count=1)
|
|
73
|
+
printed = _PRINTED.match(body)
|
|
74
|
+
if printed:
|
|
75
|
+
body = parse(printed["head"]).doc + (printed["rest"] or "")
|
|
76
|
+
body, doc_para = cursor.split_doc_para(body)
|
|
77
|
+
ref = grammar().parse(body)
|
|
78
|
+
if doc_para is not None:
|
|
79
|
+
ref = Ref(ref.doc, None, doc_para, None, None)
|
|
80
|
+
if ref.lines is not None or ref.anchor is not None:
|
|
81
|
+
raise LibError(f"not an iso ref: {text!r}", "iso refs are <number>[@year][§clause][¶n], e.g. 9899@2024§6.5.2")
|
|
82
|
+
if ref.doc is None:
|
|
83
|
+
raise LibError(f"not an iso ref: {text!r}", "start with the standard's number, e.g. 9899@2024")
|
|
84
|
+
return ref
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def resolve(token: str, home: Path) -> str:
|
|
88
|
+
"""The imported document a token names: exact when it carries `@year`, else the newest imported year of that
|
|
89
|
+
number (the standard itself before an amendment)."""
|
|
90
|
+
number, rest = split_doc(token)
|
|
91
|
+
documents = read_record(home)["documents"]
|
|
92
|
+
if rest is not None:
|
|
93
|
+
doc = f"{number}@{rest}"
|
|
94
|
+
if doc not in documents:
|
|
95
|
+
raise _not_imported(f"ISO {doc}", home)
|
|
96
|
+
return doc
|
|
97
|
+
candidates: list[str] = [str(d) for d in documents if str(d).split("@", 1)[0] == number]
|
|
98
|
+
if not candidates:
|
|
99
|
+
raise _not_imported(f"ISO {number}", home)
|
|
100
|
+
return max(candidates, key=lambda d: ("+" not in d, d.split("@", 1)[1]))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load(token: str, home: Path | None = None) -> Loaded:
|
|
104
|
+
home = home or default_home()
|
|
105
|
+
doc = resolve(token, home)
|
|
106
|
+
entry = read_record(home)["documents"][doc]
|
|
107
|
+
try:
|
|
108
|
+
raw: Any = json.loads((home / EXTRACTED / f"{entry['key']}.json").read_text(encoding="utf-8"))
|
|
109
|
+
except (OSError, ValueError, KeyError) as exc:
|
|
110
|
+
raise LibError(
|
|
111
|
+
f"cannot read the import of {entry.get('designation', doc)}: {exc}", "", f"re-import it: {IMPORT_COMMAND}"
|
|
112
|
+
) from exc
|
|
113
|
+
document = PdfDocument.from_json(raw)
|
|
114
|
+
return Loaded(doc, str(entry["designation"]), document.title or str(entry["designation"]), document)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _title(doc: Loaded) -> str:
|
|
118
|
+
"""The PDF title without the designation it often starts with."""
|
|
119
|
+
title = doc.title
|
|
120
|
+
if title.upper().startswith(doc.designation.upper()):
|
|
121
|
+
title = title[len(doc.designation) :].lstrip(" -—:")
|
|
122
|
+
return title
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _cite_text(doc: Loaded, section: PdfSection | None, para: int | str | None) -> str:
|
|
126
|
+
where = f", §{section.id} {section.title}" if section else ""
|
|
127
|
+
if para:
|
|
128
|
+
where += f" ¶{para}"
|
|
129
|
+
title = _title(doc)
|
|
130
|
+
return f"{doc.designation}{', ' + title if title else ''}{where}"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _doc(ref: Ref) -> str:
|
|
134
|
+
if ref.doc is None:
|
|
135
|
+
raise LibError("the ref names no document", "start with the standard's number")
|
|
136
|
+
return ref.doc
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _select(ref: Ref, home: Path | None) -> tuple[Loaded, PdfSection | None, tuple[str, ...], str]:
|
|
140
|
+
doc = load(_doc(ref), home)
|
|
141
|
+
if ref.section is None:
|
|
142
|
+
units: list[str] = []
|
|
143
|
+
for section in doc.document.sections:
|
|
144
|
+
units.append(f"§{section.id} {section.title}")
|
|
145
|
+
units.extend(section.sentences)
|
|
146
|
+
return doc, None, tuple(units), ""
|
|
147
|
+
position, warning = doc.document.find(ref.section)
|
|
148
|
+
return doc, doc.document.sections[position], doc.document.span(position), warning
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def read(ref_text: str, max_tokens: int = 2000, home: Path | None = None) -> str:
|
|
152
|
+
"""The text of a ref, cut at sentence boundaries within `max_tokens`. A `¶n` ref starts at sentence n and runs to
|
|
153
|
+
the end of its scope; the `next:` footer is the ref that reads the next page."""
|
|
154
|
+
ref = parse_ref(ref_text)
|
|
155
|
+
doc, section, units, warning = _select(ref, home)
|
|
156
|
+
if not units:
|
|
157
|
+
raise LibError(f"{ref_text}: that clause has no text of its own", "read its subsections, see iso_outline")
|
|
158
|
+
first = cursor.first_unit(ref.para, len(units), "sentences", ref_text)
|
|
159
|
+
chosen = budget.page(units, start=first, max_tokens=max_tokens)
|
|
160
|
+
body = "\n".join(chosen.units)
|
|
161
|
+
scope = f"iso:{doc.doc}" + (f"§{section.id}" if section else "")
|
|
162
|
+
footer = {
|
|
163
|
+
"cite": _cite_text(doc, section, cursor.para_label(ref.para, chosen)),
|
|
164
|
+
"unit": "sentence",
|
|
165
|
+
"span": span_hash(body),
|
|
166
|
+
"next": cursor.read_next(scope, chosen),
|
|
167
|
+
"warning": warning,
|
|
168
|
+
}
|
|
169
|
+
return render(body, footer)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def outline(token: str, start: int = 0, limit: int = 200, home: Path | None = None) -> str:
|
|
173
|
+
"""A standard's bookmarked clauses, indented by depth, each with the ref that reads it and its PDF page."""
|
|
174
|
+
doc = load(token, home)
|
|
175
|
+
rows = [
|
|
176
|
+
f"{' ' * section.depth}{section.id} {section.title} iso:{doc.doc}§{section.id} (p. {section.page})"
|
|
177
|
+
for section in doc.document.sections
|
|
178
|
+
]
|
|
179
|
+
shown, nxt = cursor.window(rows, start, limit)
|
|
180
|
+
head = f"{doc.designation} {_title(doc)} [iso:{doc.doc}, {doc.document.pages} pages]"
|
|
181
|
+
return render("\n".join([head, *shown]), {"next": nxt})
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def search(query: str, doc: str | None = None, limit: int = 10, start: int = 0, home: Path | None = None) -> str:
|
|
185
|
+
"""Clauses containing every query word (else any, flagged `relaxed`), best first by BM25: one line per hit,
|
|
186
|
+
`<ref> <clause title>: <snippet>`."""
|
|
187
|
+
home = home or default_home()
|
|
188
|
+
if not read_record(home)["documents"]:
|
|
189
|
+
raise LibError(
|
|
190
|
+
"no ISO standards are imported",
|
|
191
|
+
"iso_docs lists what is imported",
|
|
192
|
+
f"put your PDFs in {home}, then run: {IMPORT_COMMAND}",
|
|
193
|
+
)
|
|
194
|
+
wanted = resolve(doc, home) if doc else None
|
|
195
|
+
try:
|
|
196
|
+
conn = index.open_read(home / INDEX_FILE, rebuild_command=REBUILD_COMMAND)
|
|
197
|
+
except LibError as exc:
|
|
198
|
+
raise LibError(exc.message, "", REBUILD_COMMAND) from exc
|
|
199
|
+
try:
|
|
200
|
+
found = index.search(conn, query, ranking=RANKING, limit=SEARCH_WINDOW)
|
|
201
|
+
finally:
|
|
202
|
+
conn.close()
|
|
203
|
+
hits = [hit for hit in found.hits if wanted is None or hit.doc_id == wanted]
|
|
204
|
+
if not hits:
|
|
205
|
+
return render("no matches", {})
|
|
206
|
+
shown, nxt = cursor.window(hits, start, limit)
|
|
207
|
+
lines = [f"iso:{hit.doc_id}§{hit.section_id} {hit.title}: {' '.join(hit.snippet.split())}" for hit in shown]
|
|
208
|
+
footer = {"next": nxt, "relaxed": "only some query words matched" if found.relaxed else ""}
|
|
209
|
+
return render("\n".join(lines), footer)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def cite(ref_text: str, home: Path | None = None) -> str:
|
|
213
|
+
"""The citation string for a ref, with the span hash of the exact text cited."""
|
|
214
|
+
ref = parse_ref(ref_text)
|
|
215
|
+
doc, section, units, warning = _select(ref, home)
|
|
216
|
+
if ref.para is not None:
|
|
217
|
+
if ref.para > len(units):
|
|
218
|
+
raise LibError(f"{ref_text}: the span has {len(units)} sentences", f"use ¶1 to ¶{len(units)}")
|
|
219
|
+
units = (units[ref.para - 1],)
|
|
220
|
+
footer = {
|
|
221
|
+
"cite": _cite_text(doc, section, ref.para),
|
|
222
|
+
"unit": "sentence",
|
|
223
|
+
"span": span_hash("\n".join(units)),
|
|
224
|
+
"warning": warning,
|
|
225
|
+
}
|
|
226
|
+
label = f"iso:{doc.doc}" + (f"§{section.id}" if section else "") + (f"¶{ref.para}" if ref.para else "")
|
|
227
|
+
return render(label, footer)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def docs(home: Path | None = None) -> str:
|
|
231
|
+
"""What is imported, with each source file's state: present, changed since import, or missing."""
|
|
232
|
+
home = home or default_home()
|
|
233
|
+
record = read_record(home)["documents"]
|
|
234
|
+
if not record:
|
|
235
|
+
return render(f"nothing imported; put your purchased ISO PDFs in {home} and run: {IMPORT_COMMAND}", {})
|
|
236
|
+
lines = []
|
|
237
|
+
for doc, entry in sorted(record.items()):
|
|
238
|
+
source = Path(str(entry.get("source", "")))
|
|
239
|
+
try:
|
|
240
|
+
stat = source.stat()
|
|
241
|
+
state = (
|
|
242
|
+
"source unchanged"
|
|
243
|
+
if stat.st_size == entry.get("size") and stat.st_mtime_ns == entry.get("mtime_ns")
|
|
244
|
+
else "source changed since import"
|
|
245
|
+
)
|
|
246
|
+
except OSError:
|
|
247
|
+
state = "source file missing"
|
|
248
|
+
lines.append(f"iso:{doc} {entry['designation']} {entry.get('title', '')} ({state})")
|
|
249
|
+
return render("\n".join(lines), {})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""MCP server for the iso collection: `primarysource mcp --collection iso`.
|
|
2
|
+
|
|
3
|
+
Imported only when served, so `mcp` (the `[mcp]` extra) is needed only then. Tools carry the `iso_` prefix, answer
|
|
4
|
+
in text with the fixed footer, and turn a `LibError` into an MCP tool error with `error:`/`hint:`/`action:` lines.
|
|
5
|
+
Every tool reads local files only; none writes or fetches. Serve it on the buyer's own machine (stdio, or a
|
|
6
|
+
container with the iso home mounted read-only): the PDFs behind it are single-user copies.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from mcp.server.mcpserver import MCPServer
|
|
14
|
+
from mcp.types import ToolAnnotations
|
|
15
|
+
|
|
16
|
+
from primarysource import __version__
|
|
17
|
+
from primarysource.iso import library
|
|
18
|
+
from primarysource.scaffold import add_tools
|
|
19
|
+
|
|
20
|
+
READ_ONLY = ToolAnnotations(read_only_hint=True, idempotent_hint=True, destructive_hint=False, open_world_hint=False)
|
|
21
|
+
|
|
22
|
+
INSTRUCTIONS = (
|
|
23
|
+
"ISO standards from the user's own purchased PDFs. Loop: iso_search('<words>') -> iso_read('<ref>'). "
|
|
24
|
+
"Refs: 'iso:9899@2024§6.5.2' (a clause as numbered in the PDF), '...¶3' (from its 3rd sentence), '9899' means "
|
|
25
|
+
"the newest imported year, 'A' is Annex A; 'ISO/IEC 9899:2024§6.5.2' works too. iso_outline('9899') lists "
|
|
26
|
+
"clauses as refs; iso_cite('<ref>') gives the citation and the hash of the cited text. iso_docs lists what is "
|
|
27
|
+
"imported. If a standard is not imported, the error says what the user must do."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def iso_read(ref: str, max_tokens: int = 2000) -> str:
|
|
32
|
+
"""Read ISO text by ref, cut at sentence boundaries within max_tokens. A 'next: <ref>' footer line is the ref
|
|
33
|
+
that reads the next page: pass it back as ref.
|
|
34
|
+
|
|
35
|
+
ref: 'iso:9899@2024' (whole standard) | 'iso:9899@2024§6.5.2' (a clause and its subsections) |
|
|
36
|
+
'iso:9899@2024§6.5.2¶3' (from its 3rd sentence) | 'ISO/IEC 9899:2024§A'. iso_cite gives exactly one sentence."""
|
|
37
|
+
return library.read(ref, max_tokens=max_tokens)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def iso_search(query: str, doc: str | None = None, limit: int = 10, start: int = 0) -> str:
|
|
41
|
+
"""Find clauses containing the query words (all of them, else any, flagged 'relaxed'), best first. One line per
|
|
42
|
+
hit: '<ref> <clause title>: <snippet>'. doc restricts to one standard, e.g. '9899' or '9899@2024'. A
|
|
43
|
+
'next: start=N' footer line means call again with start=N (a 0-based offset)."""
|
|
44
|
+
return library.search(query, doc=doc, limit=limit, start=start)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def iso_outline(doc: str, start: int = 0, limit: int = 200) -> str:
|
|
48
|
+
"""List a standard's clauses, indented by depth, each with the ref that reads it and its PDF page. A
|
|
49
|
+
'next: start=N' footer line means call again with start=N."""
|
|
50
|
+
return library.outline(doc, start=start, limit=limit)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def iso_cite(ref: str) -> str:
|
|
54
|
+
"""The citation string for a ref, with the sha256 prefix of the exact text cited (cite: and span: lines)."""
|
|
55
|
+
return library.cite(ref)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def iso_docs() -> str:
|
|
59
|
+
"""List the imported ISO standards and whether each source PDF is unchanged, changed or missing."""
|
|
60
|
+
return library.docs()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _build() -> Any:
|
|
64
|
+
built = MCPServer("primarysource-iso", version=__version__, instructions=INSTRUCTIONS)
|
|
65
|
+
add_tools(built, (iso_read, iso_search, iso_outline, iso_cite, iso_docs), annotations=READ_ONLY)
|
|
66
|
+
return built
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
server = _build()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""itulib: ITU-T recommendations as a local, citation-precise library and MCP server.
|
|
2
|
+
|
|
3
|
+
X.680 and X.690 are the normative source for BER and DER and are not in the RFC series, so `rfclib` cannot
|
|
4
|
+
answer a question about tag-number minimality or DER canonicalisation. This is the same shape of tool pointed
|
|
5
|
+
at the other corpus: clause-precise search, paging, citation, and an errata surface.
|
|
6
|
+
|
|
7
|
+
The corpus is not in this package and cannot be. ITU's own notice, page iii of X.690 (02/2021), forbids
|
|
8
|
+
reproduction without written permission, so `fetch` downloads it on the user's machine into `ITULIB_HOME`.
|
|
9
|
+
Every entry point raises `CorpusMissing` naming that command rather than returning an empty answer, because a
|
|
10
|
+
missing file and a document with nothing to say must never look the same.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
from .corpus import CorpusMissing, fetch, has, home, require
|
|
18
|
+
from .sources import AMENDMENTS, BY_REC, IN_FORCE, SUPERSEDED, Amendment, Edition, current, resolve
|
|
19
|
+
from .text import Clause, clauses, find, pages, tidy
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"__version__",
|
|
23
|
+
"AMENDMENTS",
|
|
24
|
+
"BY_REC",
|
|
25
|
+
"IN_FORCE",
|
|
26
|
+
"SUPERSEDED",
|
|
27
|
+
"Amendment",
|
|
28
|
+
"Clause",
|
|
29
|
+
"CorpusMissing",
|
|
30
|
+
"Edition",
|
|
31
|
+
"clauses",
|
|
32
|
+
"current",
|
|
33
|
+
"fetch",
|
|
34
|
+
"find",
|
|
35
|
+
"has",
|
|
36
|
+
"home",
|
|
37
|
+
"pages",
|
|
38
|
+
"require",
|
|
39
|
+
"resolve",
|
|
40
|
+
"tidy",
|
|
41
|
+
]
|