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,158 @@
|
|
|
1
|
+
"""The MCP server and the command line for a pkgdocs collection, built from its `Spec`.
|
|
2
|
+
|
|
3
|
+
Tools carry the collection's prefix (`gitdocs_read`, `pep_search`), answer in text with the fixed footer, and turn a
|
|
4
|
+
`LibError` into an MCP tool error (`isError`) whose text names the remedy. Every tool reads installed files only;
|
|
5
|
+
none fetches or writes (`primarysource <collection> import` is the one command that does, and it is not a tool).
|
|
6
|
+
`mcp` is imported only when a server is built, so the readers and the importer need nothing but the standard
|
|
7
|
+
library.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from primarysource import __version__
|
|
18
|
+
from primarysource.errors import LibError
|
|
19
|
+
from primarysource.pkgdocs.runtime import Library, Spec
|
|
20
|
+
from primarysource.scaffold import add_tools
|
|
21
|
+
|
|
22
|
+
DEFAULT_MAX_TOKENS = 2000
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_server(spec: Spec, library: Library | None = None) -> Any:
|
|
26
|
+
"""An `MCPServer` with read, outline, search, cite, docs and status tools for `spec`."""
|
|
27
|
+
from mcp.server.mcpserver import MCPServer
|
|
28
|
+
from mcp.types import ToolAnnotations
|
|
29
|
+
|
|
30
|
+
lib = library or Library(spec)
|
|
31
|
+
examples = "; ".join(spec.example_refs)
|
|
32
|
+
|
|
33
|
+
def read(ref: str, max_tokens: int = DEFAULT_MAX_TOKENS) -> str:
|
|
34
|
+
return lib.read(ref, max_tokens=max_tokens)
|
|
35
|
+
|
|
36
|
+
def outline(doc: str, start: int = 0, max_tokens: int = 4000) -> str:
|
|
37
|
+
return lib.outline(doc, start=start, max_tokens=max_tokens)
|
|
38
|
+
|
|
39
|
+
def search(query: str, doc_prefix: str = "", limit: int = 10, start: int = 0) -> str:
|
|
40
|
+
return lib.search(query, limit=limit, start=start, doc_prefix=doc_prefix)
|
|
41
|
+
|
|
42
|
+
def cite(ref: str) -> str:
|
|
43
|
+
return lib.cite(ref)
|
|
44
|
+
|
|
45
|
+
def docs(prefix: str = "", limit: int = 200, start: int = 0) -> str:
|
|
46
|
+
return lib.docs(prefix, limit=limit, start=start)
|
|
47
|
+
|
|
48
|
+
def status() -> str:
|
|
49
|
+
return lib.status()
|
|
50
|
+
|
|
51
|
+
read.__doc__ = (
|
|
52
|
+
f"Read {spec.title} text by ref, cut at block boundaries within max_tokens. A 'next: <ref>' footer line is "
|
|
53
|
+
"the ref that reads the next page: pass it back as ref. '¶n' reads from the nth block. The cite: line is the "
|
|
54
|
+
"citation.\n\n"
|
|
55
|
+
f"ref: '<doc>[@<commit>]§<section>[#<anchor>][¶<n>]'; the section is an id, an anchor, a heading or a unique "
|
|
56
|
+
f"part of one. Examples: {examples}"
|
|
57
|
+
)
|
|
58
|
+
outline.__doc__ = (
|
|
59
|
+
"List a document's sections, each with the ref that reads it. doc: a document id (see docs). A "
|
|
60
|
+
"'next: start=N' footer line means call again with start=N (a 0-based offset)."
|
|
61
|
+
)
|
|
62
|
+
search.__doc__ = (
|
|
63
|
+
f"Find sections of {spec.title} by words (all of them, else some, which the relaxed: line says). "
|
|
64
|
+
"One line per "
|
|
65
|
+
"hit: '<ref>: <document> / <section> -- <snippet>'. doc_prefix narrows to document ids starting with it. "
|
|
66
|
+
"A 'next: start=N' footer line means call again with start=N (a 0-based offset)."
|
|
67
|
+
)
|
|
68
|
+
cite.__doc__ = (
|
|
69
|
+
"The citation for a ref (document, section, edition, commit, source file, licence) and its span hash."
|
|
70
|
+
)
|
|
71
|
+
docs.__doc__ = (
|
|
72
|
+
"List document ids and titles, optionally those whose id starts with prefix. A 'next: start=N' footer line "
|
|
73
|
+
"means call again with start=N (a 0-based offset)."
|
|
74
|
+
)
|
|
75
|
+
status.__doc__ = "What this collection pins, what is imported, and its licence."
|
|
76
|
+
|
|
77
|
+
server = MCPServer(f"primarysource-{spec.name}", version=__version__, instructions=spec.instructions)
|
|
78
|
+
annotations = ToolAnnotations(
|
|
79
|
+
read_only_hint=True, idempotent_hint=True, destructive_hint=False, open_world_hint=False
|
|
80
|
+
)
|
|
81
|
+
add_tools(server, (read, outline, search, cite, docs, status), prefix=spec.prefix, annotations=annotations)
|
|
82
|
+
return server
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _parser(spec: Spec) -> argparse.ArgumentParser:
|
|
86
|
+
parser = argparse.ArgumentParser(prog=f"primarysource {spec.name}", description=spec.title)
|
|
87
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
88
|
+
imp = commands.add_parser("import", help="fetch the pinned edition (one request per second) and install it")
|
|
89
|
+
imp.add_argument("--tree", help="only this tree")
|
|
90
|
+
imp.add_argument("--commit", help="a pinned edition other than the current one: a label or commit prefix")
|
|
91
|
+
imp.add_argument("--archive", help="read this downloaded archive instead of fetching (needs --tree)")
|
|
92
|
+
imp.add_argument("--allow-unpinned", action="store_true", help="trust the digest of this one fetch")
|
|
93
|
+
commands.add_parser("status", help="what is pinned and imported, and the licence")
|
|
94
|
+
docs = commands.add_parser("docs", help="list documents")
|
|
95
|
+
docs.add_argument("prefix", nargs="?", default="")
|
|
96
|
+
docs.add_argument("--limit", type=int, default=200)
|
|
97
|
+
docs.add_argument("--start", type=int, default=0)
|
|
98
|
+
outline = commands.add_parser("outline", help="a document's sections with their refs")
|
|
99
|
+
outline.add_argument("doc")
|
|
100
|
+
outline.add_argument("--start", type=int, default=0)
|
|
101
|
+
outline.add_argument("--max-tokens", type=int, default=4000)
|
|
102
|
+
read = commands.add_parser("read", help="read by ref")
|
|
103
|
+
read.add_argument("ref")
|
|
104
|
+
read.add_argument("--max-tokens", type=int, default=DEFAULT_MAX_TOKENS)
|
|
105
|
+
search = commands.add_parser("search", help="find sections by words")
|
|
106
|
+
search.add_argument("query")
|
|
107
|
+
search.add_argument("--doc-prefix", default="")
|
|
108
|
+
search.add_argument("--limit", type=int, default=10)
|
|
109
|
+
search.add_argument("--start", type=int, default=0)
|
|
110
|
+
commands.add_parser("cite", help="the citation for a ref").add_argument("ref")
|
|
111
|
+
return parser
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _write(text: str) -> None:
|
|
115
|
+
"""UTF-8 to stdout whatever the console's code page is: refs contain `§` and `¶`."""
|
|
116
|
+
stream = getattr(sys.stdout, "buffer", None)
|
|
117
|
+
if stream is not None:
|
|
118
|
+
sys.stdout.flush()
|
|
119
|
+
stream.write(text.encode("utf-8"))
|
|
120
|
+
stream.flush()
|
|
121
|
+
else:
|
|
122
|
+
sys.stdout.write(text)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def run(spec: Spec, argv: Sequence[str], library: Library | None = None) -> int:
|
|
126
|
+
"""`primarysource <collection> ...`: exit 0, or 1 with the error and its hint on stderr."""
|
|
127
|
+
args = _parser(spec).parse_args(list(argv))
|
|
128
|
+
lib = library or Library(spec)
|
|
129
|
+
try:
|
|
130
|
+
if args.command == "import":
|
|
131
|
+
for report in lib.import_edition(
|
|
132
|
+
args.tree, args.commit, args.archive, allow_unpinned=args.allow_unpinned
|
|
133
|
+
):
|
|
134
|
+
skipped = ", ".join(f"{k}: {v}" for k, v in sorted(report.skipped.items())) or "none"
|
|
135
|
+
print(
|
|
136
|
+
f"{report.tree} {report.label} {report.commit[:12]}: {report.docs} documents, {report.sections} "
|
|
137
|
+
f"sections, {report.units} blocks, {report.warnings} reader warnings\n"
|
|
138
|
+
f" files sha256 {report.files_sha256} ({'matches the pin' if report.pinned else 'NOT pinned'})\n"
|
|
139
|
+
f" skipped: {skipped}\n installed at {report.path}"
|
|
140
|
+
)
|
|
141
|
+
return 0
|
|
142
|
+
if args.command == "status":
|
|
143
|
+
text = lib.status()
|
|
144
|
+
elif args.command == "docs":
|
|
145
|
+
text = lib.docs(args.prefix, limit=args.limit, start=args.start)
|
|
146
|
+
elif args.command == "outline":
|
|
147
|
+
text = lib.outline(args.doc, start=args.start, max_tokens=args.max_tokens)
|
|
148
|
+
elif args.command == "read":
|
|
149
|
+
text = lib.read(args.ref, max_tokens=args.max_tokens)
|
|
150
|
+
elif args.command == "search":
|
|
151
|
+
text = lib.search(args.query, limit=args.limit, start=args.start, doc_prefix=args.doc_prefix)
|
|
152
|
+
else:
|
|
153
|
+
text = lib.cite(args.ref)
|
|
154
|
+
_write(text if text.endswith("\n") else text + "\n")
|
|
155
|
+
return 0
|
|
156
|
+
except LibError as exc:
|
|
157
|
+
print(exc.as_text(), file=sys.stderr)
|
|
158
|
+
return 1
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""An installed edition: parsed documents and a search index under `$PRIMARYSOURCE_HOME/<collection>/`.
|
|
2
|
+
|
|
3
|
+
<collection>/<tree>/<commit>/edition.json what was imported, from where, under which digest
|
|
4
|
+
index.json the documents (id, title, file, meta) and chapter aliases
|
|
5
|
+
docs/<n>.json one parsed document
|
|
6
|
+
index.sqlite the full-text index
|
|
7
|
+
|
|
8
|
+
An edition is written into a staging directory beside its destination and renamed into place, so a reader sees
|
|
9
|
+
the old edition or the new one. What is stored is the parsed sections, not the archive and not the repository;
|
|
10
|
+
nothing in this module or its caller writes under the source tree.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import secrets
|
|
18
|
+
import shutil
|
|
19
|
+
from collections.abc import Mapping, Sequence
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from primarysource.errors import LibError
|
|
25
|
+
from primarysource.pkgdocs import index
|
|
26
|
+
from primarysource.pkgdocs.model import Doc
|
|
27
|
+
|
|
28
|
+
RECORD = "edition.json"
|
|
29
|
+
CATALOGUE = "index.json"
|
|
30
|
+
SEARCH = "index.sqlite"
|
|
31
|
+
_COMMIT = frozenset("0123456789abcdef")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class Entry:
|
|
36
|
+
"""A document as listed in the edition's catalogue."""
|
|
37
|
+
|
|
38
|
+
id: str
|
|
39
|
+
title: str
|
|
40
|
+
file: str
|
|
41
|
+
sections: int
|
|
42
|
+
meta: Mapping[str, str]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Edition:
|
|
46
|
+
"""One installed edition, opened for reading."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, path: Path) -> None:
|
|
49
|
+
self.path = path
|
|
50
|
+
try:
|
|
51
|
+
self.record: dict[str, Any] = json.loads((path / RECORD).read_text(encoding="utf-8"))
|
|
52
|
+
listing = json.loads((path / CATALOGUE).read_text(encoding="utf-8"))
|
|
53
|
+
self.entries: dict[str, Entry] = {
|
|
54
|
+
str(e["id"]): Entry(
|
|
55
|
+
str(e["id"]), str(e["title"]), str(e["file"]), int(e["sections"]), dict(e["meta"])
|
|
56
|
+
)
|
|
57
|
+
for e in listing["docs"]
|
|
58
|
+
}
|
|
59
|
+
self.aliases: dict[str, str] = {str(k): str(v) for k, v in listing["aliases"].items()}
|
|
60
|
+
except (OSError, ValueError, KeyError, TypeError) as exc:
|
|
61
|
+
raise LibError(f"the edition at {path} is damaged ({exc})", "", "import it again") from exc
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def commit(self) -> str:
|
|
65
|
+
return str(self.record["commit"])
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def label(self) -> str:
|
|
69
|
+
return str(self.record.get("label", ""))
|
|
70
|
+
|
|
71
|
+
def load(self, doc_id: str) -> Doc:
|
|
72
|
+
entry = self.entries.get(doc_id)
|
|
73
|
+
if entry is None:
|
|
74
|
+
raise LibError(f"no document {doc_id!r} in this edition", "list the documents first")
|
|
75
|
+
try:
|
|
76
|
+
return Doc.from_json(json.loads((self.path / "docs" / entry.file).read_text(encoding="utf-8")))
|
|
77
|
+
except (OSError, ValueError) as exc:
|
|
78
|
+
raise LibError(f"document {doc_id!r} is unreadable ({exc})", "", "import the edition again") from exc
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def index_path(self) -> Path:
|
|
82
|
+
return self.path / SEARCH
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def write_edition(
|
|
86
|
+
base: Path,
|
|
87
|
+
commit: str,
|
|
88
|
+
record: Mapping[str, Any],
|
|
89
|
+
docs: Sequence[Doc],
|
|
90
|
+
aliases: Mapping[str, str] | None = None,
|
|
91
|
+
) -> Path:
|
|
92
|
+
"""Install `docs` as `<base>/<commit>`, replacing an earlier install of the same commit. Returns the path."""
|
|
93
|
+
if not commit or not set(commit) <= _COMMIT:
|
|
94
|
+
raise LibError(f"{commit!r} is not a commit id", "a pin names a full lowercase hex commit")
|
|
95
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
staging = base / f".{commit}.part-{secrets.token_hex(4)}"
|
|
97
|
+
staging.mkdir()
|
|
98
|
+
try:
|
|
99
|
+
(staging / "docs").mkdir()
|
|
100
|
+
listing: list[dict[str, Any]] = []
|
|
101
|
+
for number, doc in enumerate(docs, start=1):
|
|
102
|
+
name = f"{number:05d}.json"
|
|
103
|
+
(staging / "docs" / name).write_text(json.dumps(doc.to_json(), ensure_ascii=False), encoding="utf-8")
|
|
104
|
+
listing.append(
|
|
105
|
+
{
|
|
106
|
+
"id": doc.id,
|
|
107
|
+
"title": doc.title,
|
|
108
|
+
"file": name,
|
|
109
|
+
"sections": len(doc.sections),
|
|
110
|
+
"meta": dict(doc.meta),
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
(staging / CATALOGUE).write_text(
|
|
114
|
+
json.dumps({"docs": listing, "aliases": dict(aliases or {})}, ensure_ascii=False, indent=0),
|
|
115
|
+
encoding="utf-8",
|
|
116
|
+
)
|
|
117
|
+
sections = index.build(staging / SEARCH, docs)
|
|
118
|
+
(staging / RECORD).write_text(
|
|
119
|
+
json.dumps({**record, "commit": commit, "docs": len(docs), "sections": sections}, indent=2) + "\n",
|
|
120
|
+
encoding="utf-8",
|
|
121
|
+
)
|
|
122
|
+
target = base / commit
|
|
123
|
+
old = base / f".{commit}.old-{secrets.token_hex(4)}"
|
|
124
|
+
if target.exists():
|
|
125
|
+
os.replace(target, old)
|
|
126
|
+
try:
|
|
127
|
+
os.replace(staging, target)
|
|
128
|
+
except BaseException:
|
|
129
|
+
if old.exists():
|
|
130
|
+
os.replace(old, target)
|
|
131
|
+
raise
|
|
132
|
+
shutil.rmtree(old, ignore_errors=True)
|
|
133
|
+
return target
|
|
134
|
+
finally:
|
|
135
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def installed(base: Path) -> list[Edition]:
|
|
139
|
+
"""Every readable edition under `base`, newest import first. A damaged one is skipped, not hidden by
|
|
140
|
+
raising: `find` reports the damage when it is the one asked for."""
|
|
141
|
+
found: list[Edition] = []
|
|
142
|
+
if not base.is_dir():
|
|
143
|
+
return found
|
|
144
|
+
for child in sorted(base.iterdir()):
|
|
145
|
+
if child.is_dir() and not child.name.startswith(".") and (child / RECORD).is_file():
|
|
146
|
+
try:
|
|
147
|
+
found.append(Edition(child))
|
|
148
|
+
except LibError:
|
|
149
|
+
continue
|
|
150
|
+
found.sort(key=lambda e: str(e.record.get("imported", "")), reverse=True)
|
|
151
|
+
return found
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def find(base: Path, commit: str | None, what: str, hint: str) -> Edition:
|
|
155
|
+
"""The edition whose commit starts with `commit`, or the newest import when `commit` is None."""
|
|
156
|
+
editions = installed(base)
|
|
157
|
+
if not editions:
|
|
158
|
+
raise LibError(f"{what} is not imported (nothing under {base})", hint)
|
|
159
|
+
if commit is None:
|
|
160
|
+
return editions[0]
|
|
161
|
+
matches = [e for e in editions if e.commit.startswith(commit.lower())]
|
|
162
|
+
if len(matches) == 1:
|
|
163
|
+
return matches[0]
|
|
164
|
+
have = ", ".join(e.commit[:12] for e in editions)
|
|
165
|
+
if not matches:
|
|
166
|
+
raise LibError(f"no imported edition of {what} has a commit starting {commit!r} (have: {have})", hint)
|
|
167
|
+
raise LibError(f"{commit!r} matches more than one edition of {what} ({have})", "give more of the commit")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""`pms`: the Gentoo Package Manager Specification, a pinned EAPI release, read from its LaTeX source.
|
|
2
|
+
Served under `pms_`.
|
|
3
|
+
|
|
4
|
+
Stdlib only. `mcp` is imported by `server.py` when a server is built; `primarysource pms ...` runs `cli.py`.
|
|
5
|
+
`docs/pms.md` is the description; `catalogue.py` holds the pin, the licence and the build.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from primarysource.pkgdocs.runtime import make_collection
|
|
11
|
+
from primarysource.pms.catalogue import SPEC
|
|
12
|
+
|
|
13
|
+
COLLECTION = make_collection(SPEC, "primarysource.pms")
|
|
14
|
+
"""Refs: `pms:pms§Ebuild-defined variables`; see `pkgdocs.runtime`."""
|
|
15
|
+
|
|
16
|
+
__all__ = ["COLLECTION", "SPEC"]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""The `pms` collection: the Gentoo Package Manager Specification, read from its LaTeX source.
|
|
2
|
+
|
|
3
|
+
Source, placement and pin (read from the repository on 2026-11-13, see `docs/pms.md`):
|
|
4
|
+
|
|
5
|
+
- the tree is the root of gentoo/pms at the commit of the tag `eapi-9-approved-2026-06-14` (the approved EAPI 9
|
|
6
|
+
text), fetched as a GitHub tarball (77 KB). `pms.tex` includes the chapters in order; the build reads them from
|
|
7
|
+
the archive's own files, never from disk, into one document, `pms`, numbered as the typeset specification is
|
|
8
|
+
(chapters 1, 2, ..., then A, B, ... after `\\appendix`);
|
|
9
|
+
- the licence is **CC-BY-SA-4.0**, from the title page of `pms.tex`: "released under the Creative Commons
|
|
10
|
+
Attribution-ShareAlike 4.0 International licence". (Group F's notes said 3.0, read on the EAPI 3 page; the
|
|
11
|
+
current source says 4.0, and that is what is recorded.) Placement is **layer, with attribution**: every
|
|
12
|
+
citation carries the credit, and the licence line travels with the layer;
|
|
13
|
+
- GLEPs, the other half of the original brief, are not here: gitweb.gentoo.org answered HTTP 502 to the
|
|
14
|
+
snapshot request on 2026-11-13 and the project has no GitHub mirror (`gentoo/glep` and `projg2/glep` are not
|
|
15
|
+
found). See ROADMAP row `col.gentoo_glep`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections import Counter
|
|
21
|
+
from collections.abc import Mapping
|
|
22
|
+
|
|
23
|
+
from primarysource.errors import LibError
|
|
24
|
+
from primarysource.pkgdocs.fetch import Source
|
|
25
|
+
from primarysource.pkgdocs.readers.latex import read_latex
|
|
26
|
+
from primarysource.pkgdocs.runtime import Build, Licence, Pin, Spec, TreeSpec, matching
|
|
27
|
+
|
|
28
|
+
COMMIT = "2cee9c6bd86851738e8ba1874ac622cfcec70969"
|
|
29
|
+
|
|
30
|
+
PIN = Pin(
|
|
31
|
+
commit=COMMIT,
|
|
32
|
+
label="eapi-9-approved-2026-06-14",
|
|
33
|
+
date="2026-06-14",
|
|
34
|
+
files_sha256="c302537c3d04c28a69b3e4fae19247f168913a29beff587e2e26d6af08d62502",
|
|
35
|
+
sources=(
|
|
36
|
+
Source(f"https://codeload.github.com/gentoo/pms/tar.gz/{COMMIT}", strip=1),
|
|
37
|
+
Source(f"https://github.com/gentoo/pms/archive/{COMMIT}.tar.gz", strip=1, hosts=("codeload.github.com",)),
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
LICENCE = Licence(
|
|
42
|
+
spdx="CC-BY-SA-4.0",
|
|
43
|
+
redistribute="layer",
|
|
44
|
+
attribution="(c) 2007-2026 Stephen Bennett, Ciaran McCreesh and others; contributions are owned by their authors",
|
|
45
|
+
evidence="the title page of pms.tex in gentoo/pms " + COMMIT[:12],
|
|
46
|
+
credit="CC BY-SA 4.0, Stephen Bennett, Ciaran McCreesh and others",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
TITLE = "Package Manager Specification"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build(files: Mapping[str, bytes], pin: Pin) -> Build:
|
|
53
|
+
skipped: Counter[str] = Counter()
|
|
54
|
+
if "pms.tex" not in files:
|
|
55
|
+
raise LibError("pms.tex is not in the archive", "the repository layout changed; report the collection")
|
|
56
|
+
try:
|
|
57
|
+
doc = read_latex(files, "pms.tex", "pms", f"{TITLE} ({pin.label})")
|
|
58
|
+
except LibError:
|
|
59
|
+
skipped["not UTF-8 text"] += 1
|
|
60
|
+
raise
|
|
61
|
+
aliases = {s.id.split(".")[0]: "pms" for s in doc.sections if s.id != "0"}
|
|
62
|
+
aliases["pms-" + pin.label] = "pms"
|
|
63
|
+
return Build((doc.with_meta(path="pms.tex", licence="CC-BY-SA-4.0"),), aliases, skipped)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
SPEC = Spec(
|
|
67
|
+
name="pms",
|
|
68
|
+
title="Gentoo Package Manager Specification",
|
|
69
|
+
licence=LICENCE,
|
|
70
|
+
trees=(
|
|
71
|
+
TreeSpec(
|
|
72
|
+
name="pms",
|
|
73
|
+
title="gentoo/pms (LaTeX)",
|
|
74
|
+
pins=(PIN,),
|
|
75
|
+
include=matching(r"[A-Za-z0-9._-]+\.tex"),
|
|
76
|
+
build=build,
|
|
77
|
+
),
|
|
78
|
+
),
|
|
79
|
+
instructions=(
|
|
80
|
+
"The Gentoo Package Manager Specification (PMS), one document, 'pms', numbered as the typeset text is: "
|
|
81
|
+
"chapters 1-14, then appendices A, B, ... Loop: pms_search('<words>') -> pms_outline('pms') -> "
|
|
82
|
+
"pms_read('<ref>'). Section numbers work as refs without a document: 'pms:8.2.4'. A section is an id, "
|
|
83
|
+
"an anchor (the LaTeX label, e.g. ch:ebuild-vars), a heading or a unique part of one. Text is reduced "
|
|
84
|
+
"from LaTeX: \\ref numbers are resolved, markup is dropped, and the warning: line lists the commands "
|
|
85
|
+
"that were dropped. Every citation carries the CC BY-SA 4.0 credit."
|
|
86
|
+
),
|
|
87
|
+
example_refs=("pms:8.2.4", "pms:pms§Ebuild-defined variables", "pms:pms§ch:ebuild-vars¶2"),
|
|
88
|
+
)
|
primarysource/pms/cli.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""`primarysource pms import|status|docs|outline|read|search|cite`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from primarysource.pkgdocs.serving import run
|
|
8
|
+
from primarysource.pms.catalogue import SPEC
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main(argv: Sequence[str]) -> int:
|
|
12
|
+
return run(SPEC, argv)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""The MCP server for `pms` (`primarysource mcp --collection pms`). Imports `mcp` when this module loads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from primarysource.pkgdocs.serving import build_server
|
|
6
|
+
from primarysource.pms.catalogue import SPEC
|
|
7
|
+
|
|
8
|
+
server = build_server(SPEC)
|
primarysource/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""The core readers (PLAN.md §4). Each takes bytes or text and never unpacks an archive to disk.
|
|
2
|
+
|
|
3
|
+
- `html`: HTML to readable markdown blocks, zimlore's converter plus a `Profile` (`cl.reader_html`).
|
|
4
|
+
- `markdown`: a strict Markdown subset into headings and blocks (`cl.reader_markdown`).
|
|
5
|
+
- `pdf`: a bookmarked PDF into sections of sentences, `pypdf` imported only when a PDF is read
|
|
6
|
+
(`cl.reader_pdf_pypdf`); `bookmarks` cuts the page texts at the bookmarks (`cl.segment_bookmarks`).
|
|
7
|
+
- `xml`: the one hardened XML parser: no DTDs, no entities, no network (`cl.reader_xml_hardened`).
|
|
8
|
+
|
|
9
|
+
Standard library only, apart from `pypdf`, which is an ingest-time extra (PLAN.md §5.13).
|
|
10
|
+
"""
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Cut a bookmarked document's page texts into numbered sections of sentences (`cl.segment_bookmarks`).
|
|
2
|
+
|
|
3
|
+
Backend-independent: it takes the page texts and the flattened bookmark outline that a reader produced
|
|
4
|
+
(`primarysource.readers.pdf`) and never opens a file. Lifted from the ecma collection's private reader
|
|
5
|
+
(`primarysource/ecma/_pdf.py`), whose tests moved with it.
|
|
6
|
+
|
|
7
|
+
- a bookmark whose title starts with a clause number (`5.3 Separator characters`, `A.2.1 Data Types`) or
|
|
8
|
+
`Annex A` gets that number as its id (`5.3`, `A.2.1`, `A`), an `Appendix I` bookmark gets `Appendix-I`, any
|
|
9
|
+
other (`Bibliography`) its slug;
|
|
10
|
+
- page furniture (a header or footer line that repeats on many pages, once digits and Roman numerals are
|
|
11
|
+
ignored) is dropped before anything is cut;
|
|
12
|
+
- a section's text runs from its heading to the next bookmark's heading, subsections excluded, and is split
|
|
13
|
+
into sentences, because a PDF gives sentences and not paragraphs (`Unit.SENTENCE`).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
import unicodedata
|
|
20
|
+
from collections import Counter
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
_NUMBERED = re.compile(r"^(?P<id>\d+(?:\.\d+)*)\.?\s+(?P<title>\S.*)$")
|
|
24
|
+
_LETTERED = re.compile(r"^(?P<id>[A-Z](?:\.\d+)+)\.?\s+(?P<title>\S.*)$")
|
|
25
|
+
_ANNEX = re.compile(r"^(?:Annex|Appendix)\s+(?P<id>[A-Z0-9]+(?:\.\d+)*)\b[.:]?\s*(?P<title>.*)$", re.IGNORECASE)
|
|
26
|
+
_DIGITS = re.compile(r"\d+(?:\s+of\s+\d+)?")
|
|
27
|
+
_ROMAN = re.compile(r"^(?:[ivxlcdm]+\s+)|(?:\s+[ivxlcdm]+)$", re.IGNORECASE)
|
|
28
|
+
_SENTENCE_END = re.compile(r"(?<=[.!?])\s+(?=[A-Z0-9(\[\"'•])")
|
|
29
|
+
_ABBREV = ("e.g.", "i.e.", "etc.", "vs.", "cf.", "No.", "Fig.", "Sec.", "approx.")
|
|
30
|
+
|
|
31
|
+
Outline = list[tuple[str, int, int]]
|
|
32
|
+
"""A bookmark tree flattened to (title, 0-based page, depth), in document order."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def slug(text: str) -> str:
|
|
36
|
+
folded = unicodedata.normalize("NFKC", text).lower()
|
|
37
|
+
return re.sub(r"[^a-z0-9]+", "-", folded).strip("-") or "section"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def split_sentences(text: str) -> list[str]:
|
|
41
|
+
"""Sentences of already-flowed text: split after . ! ? before a capital, digit or bracket, not after the
|
|
42
|
+
abbreviations in `_ABBREV`."""
|
|
43
|
+
flowed = " ".join(text.split())
|
|
44
|
+
if not flowed:
|
|
45
|
+
return []
|
|
46
|
+
parts = _SENTENCE_END.split(flowed)
|
|
47
|
+
out: list[str] = []
|
|
48
|
+
for part in parts:
|
|
49
|
+
if out and out[-1].endswith(_ABBREV):
|
|
50
|
+
out[-1] = f"{out[-1]} {part}"
|
|
51
|
+
else:
|
|
52
|
+
out.append(part)
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def furniture_key(line: str) -> str:
|
|
57
|
+
"""A line with its digits and leading or trailing Roman numerals folded away, so a running header or footer
|
|
58
|
+
gives the same key on every page."""
|
|
59
|
+
key = _DIGITS.sub("#", line.strip())
|
|
60
|
+
key = _ROMAN.sub("", key).strip()
|
|
61
|
+
return re.sub(r"\s+", " ", key)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def heading_id(title: str) -> tuple[str, str]:
|
|
65
|
+
"""The section id and the title text a bookmark title gives (see the module docstring)."""
|
|
66
|
+
for pattern in (_NUMBERED, _LETTERED, _ANNEX):
|
|
67
|
+
match = pattern.match(title)
|
|
68
|
+
if match:
|
|
69
|
+
ident = match.group("id").upper() if pattern is not _NUMBERED else match.group("id")
|
|
70
|
+
if pattern is _ANNEX and title.lower().startswith("appendix"):
|
|
71
|
+
ident = f"Appendix-{ident}"
|
|
72
|
+
return ident, match.group("title").strip() or title
|
|
73
|
+
return slug(title), title
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_TRACKING_MARK = re.compile(r"^\s*[-`,]{8,}")
|
|
77
|
+
"""A distributor's tracking string printed before the first line of a page (``--`,``,``,,,`---1 Scope``)."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def clean_pages(pages: list[str], *, keep_spacing: bool = False) -> list[list[str]]:
|
|
81
|
+
"""Each page's non-empty lines with page furniture dropped: a line whose `furniture_key` occurs on at least a
|
|
82
|
+
quarter of the pages (at least 3) is a running header or footer. Whitespace is collapsed unless `keep_spacing`
|
|
83
|
+
(layout text, whose column gaps the numbered-heading splitter reads); a distributor's tracking string at the
|
|
84
|
+
start of a line is removed either way."""
|
|
85
|
+
|
|
86
|
+
def tidy(raw: str) -> str:
|
|
87
|
+
line = _TRACKING_MARK.sub("", raw)
|
|
88
|
+
return line.rstrip() if keep_spacing else " ".join(line.split())
|
|
89
|
+
|
|
90
|
+
per_page = [[ln for ln in (tidy(raw) for raw in text.splitlines()) if ln.strip()] for text in pages]
|
|
91
|
+
seen: Counter[str] = Counter()
|
|
92
|
+
for lines in per_page:
|
|
93
|
+
seen.update({furniture_key(" ".join(ln.split())) for ln in lines})
|
|
94
|
+
limit = max(3, len(pages) // 4)
|
|
95
|
+
return [[ln for ln in lines if seen[furniture_key(" ".join(ln.split()))] < limit] for lines in per_page]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def cut_numbered(pages: list[str]) -> tuple[list[dict[str, Any]], int]:
|
|
99
|
+
"""Cut page texts into sections at numbered headings, for a PDF without bookmarks (older ISO editions, for
|
|
100
|
+
one). Furniture is dropped as in `cut`, then the shared numbered-heading splitter
|
|
101
|
+
(`primarysource.pdfcorpus.split.split_sections`, `cl.segment_numbered`) finds the headings. Text before the
|
|
102
|
+
first heading becomes a `front-matter` section. Returns the sections in `cut`'s shape and how many headings
|
|
103
|
+
were found."""
|
|
104
|
+
from primarysource.pdfcorpus.split import split_sections
|
|
105
|
+
|
|
106
|
+
lines: list[str] = []
|
|
107
|
+
page_of: list[int] = []
|
|
108
|
+
for number, page_lines in enumerate(clean_pages(pages, keep_spacing=True)):
|
|
109
|
+
lines.extend(page_lines)
|
|
110
|
+
page_of.extend([number] * len(page_lines))
|
|
111
|
+
found = split_sections("\n".join(lines))
|
|
112
|
+
sections: list[dict[str, Any]] = []
|
|
113
|
+
first = found[0].start_line - 1 if found else len(lines)
|
|
114
|
+
if first > 0:
|
|
115
|
+
sections.append(
|
|
116
|
+
{
|
|
117
|
+
"id": "front-matter",
|
|
118
|
+
"title": "Front matter",
|
|
119
|
+
"depth": 0,
|
|
120
|
+
"page": 1,
|
|
121
|
+
"text": "\n".join(split_sentences(" ".join(lines[:first]))),
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
used: Counter[str] = Counter()
|
|
125
|
+
for section in found:
|
|
126
|
+
ident = section.id
|
|
127
|
+
used[ident] += 1
|
|
128
|
+
if used[ident] > 1:
|
|
129
|
+
ident = f"{ident}-{used[ident] - 1}"
|
|
130
|
+
body = lines[section.start_line : section.end_line]
|
|
131
|
+
sections.append(
|
|
132
|
+
{
|
|
133
|
+
"id": ident,
|
|
134
|
+
"title": " ".join(section.heading.split()),
|
|
135
|
+
"depth": section.id.count("."),
|
|
136
|
+
"page": page_of[section.start_line - 1] + 1,
|
|
137
|
+
"text": "\n".join(split_sentences(" ".join(body))),
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
return sections, len(found)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def cut(pages: list[str], outline: Outline) -> tuple[list[dict[str, Any]], int]:
|
|
144
|
+
"""Cut page texts into sections at the bookmarked headings. Returns the sections
|
|
145
|
+
(`{"id", "title", "depth", "page", "text"}`, `page` 1-based, `text` the sentences joined by newlines) and how
|
|
146
|
+
many headings were found as a line of text (the rest fall back to the start of their page)."""
|
|
147
|
+
count = len(pages)
|
|
148
|
+
cleaned = ["\n".join(lines) for lines in clean_pages(pages)]
|
|
149
|
+
starts: list[int] = []
|
|
150
|
+
joined = ""
|
|
151
|
+
for text in cleaned:
|
|
152
|
+
starts.append(len(joined))
|
|
153
|
+
joined += text + "\n"
|
|
154
|
+
|
|
155
|
+
spots: list[tuple[int, int]] = [] # (start, end) of each heading's line in `joined`, never moving backwards
|
|
156
|
+
cursor = 0
|
|
157
|
+
located = 0
|
|
158
|
+
for heading, page, _ in outline:
|
|
159
|
+
ident, text_title = heading_id(heading)
|
|
160
|
+
numbered = _NUMBERED.match(heading) or _LETTERED.match(heading)
|
|
161
|
+
words = r"\s+".join(re.escape(w) for w in (text_title if numbered else heading).split())
|
|
162
|
+
number = re.escape(ident) if numbered else ""
|
|
163
|
+
pattern = re.compile(rf"(?m)^{number}\.?\s*{words}" if number else rf"(?m)^{words}")
|
|
164
|
+
first = min(page, count - 1)
|
|
165
|
+
base = max(starts[first], cursor)
|
|
166
|
+
found = pattern.search(joined, base, starts[first + 1] if first + 1 < count else len(joined))
|
|
167
|
+
if found is None:
|
|
168
|
+
found = pattern.search(joined, base)
|
|
169
|
+
if found is not None:
|
|
170
|
+
located += 1
|
|
171
|
+
spot = (found.start(), found.end()) if found else (base, base)
|
|
172
|
+
spots.append(spot)
|
|
173
|
+
cursor = max(cursor, spot[0])
|
|
174
|
+
sections: list[dict[str, Any]] = []
|
|
175
|
+
used: Counter[str] = Counter()
|
|
176
|
+
for n, (heading, page, depth) in enumerate(outline):
|
|
177
|
+
ident, text_title = heading_id(heading)
|
|
178
|
+
used[ident] += 1
|
|
179
|
+
if used[ident] > 1:
|
|
180
|
+
ident = f"{ident}-{used[ident] - 1}"
|
|
181
|
+
begin = spots[n][1]
|
|
182
|
+
stop = spots[n + 1][0] if n + 1 < len(outline) else len(joined)
|
|
183
|
+
sections.append(
|
|
184
|
+
{
|
|
185
|
+
"id": ident,
|
|
186
|
+
"title": text_title,
|
|
187
|
+
"depth": depth,
|
|
188
|
+
"page": page + 1,
|
|
189
|
+
"text": "\n".join(split_sentences(joined[begin : max(begin, stop)])),
|
|
190
|
+
}
|
|
191
|
+
)
|
|
192
|
+
return sections, located
|