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,89 @@
|
|
|
1
|
+
"""A per-edition full-text index: SQLite FTS5, built once at import, opened read-only.
|
|
2
|
+
|
|
3
|
+
One row per section (its title and its blocks), ranked by BM25 with the title weighted over the body. This is the
|
|
4
|
+
interim form of the core's `cl.index_fts5`; it keeps the rules the roadmap already names for that row (a sanitiser
|
|
5
|
+
that quotes user text term by term so FTS5 operators cannot raise, strict AND then a relaxed OR that says so, a
|
|
6
|
+
file that is built beside its destination and renamed into place, a read-only open).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import re
|
|
13
|
+
import sqlite3
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from primarysource.errors import LibError
|
|
19
|
+
from primarysource.pkgdocs.model import Doc
|
|
20
|
+
|
|
21
|
+
SCHEMA_VERSION = 1
|
|
22
|
+
MAX_TERMS = 12
|
|
23
|
+
WEIGHTS = "0.0, 0.0, 8.0, 1.0" # doc and sec are not searched; a title match outweighs a body match
|
|
24
|
+
_TERM = re.compile(r"\w+", re.UNICODE)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class Hit:
|
|
29
|
+
doc: str
|
|
30
|
+
section: str
|
|
31
|
+
title: str
|
|
32
|
+
snippet: str
|
|
33
|
+
score: float
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build(path: Path, docs: Sequence[Doc]) -> int:
|
|
37
|
+
"""Write the index for `docs` at `path` (which must not exist). Returns the number of sections indexed."""
|
|
38
|
+
rows = 0
|
|
39
|
+
with contextlib.closing(sqlite3.connect(path)) as db:
|
|
40
|
+
db.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
|
41
|
+
db.execute(
|
|
42
|
+
"CREATE VIRTUAL TABLE sections USING fts5("
|
|
43
|
+
"doc UNINDEXED, sec UNINDEXED, title, body, tokenize='porter unicode61')"
|
|
44
|
+
)
|
|
45
|
+
for doc in docs:
|
|
46
|
+
for section in doc.sections:
|
|
47
|
+
db.execute(
|
|
48
|
+
"INSERT INTO sections(doc, sec, title, body) VALUES (?, ?, ?, ?)",
|
|
49
|
+
(doc.id, section.id, section.title, "\n".join(section.units)),
|
|
50
|
+
)
|
|
51
|
+
rows += 1
|
|
52
|
+
db.commit()
|
|
53
|
+
return rows
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _match(terms: Sequence[str], joiner: str) -> str:
|
|
57
|
+
return f" {joiner} ".join('"' + t.replace('"', '""') + '"' for t in terms)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def search(path: Path, query: str, limit: int = 10, offset: int = 0, doc_prefix: str = "") -> tuple[list[Hit], bool]:
|
|
61
|
+
"""Sections matching `query`, best first, and whether the match was relaxed (OR instead of AND). An empty
|
|
62
|
+
list means nothing matched; a missing or unreadable index is a `LibError` that names the remedy."""
|
|
63
|
+
terms = _TERM.findall(query)[:MAX_TERMS]
|
|
64
|
+
if not terms:
|
|
65
|
+
raise LibError(f"nothing to search for in {query!r}", "give at least one word")
|
|
66
|
+
if not path.is_file():
|
|
67
|
+
raise LibError(f"there is no search index at {path}", "", "import the edition again")
|
|
68
|
+
try:
|
|
69
|
+
with contextlib.closing(sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True)) as db:
|
|
70
|
+
if db.execute("PRAGMA user_version").fetchone()[0] != SCHEMA_VERSION:
|
|
71
|
+
raise LibError("the search index has a different schema version", "", "import the edition again")
|
|
72
|
+
for relaxed, joiner in ((False, "AND"), (True, "OR")):
|
|
73
|
+
if relaxed and len(terms) == 1:
|
|
74
|
+
break
|
|
75
|
+
like = doc_prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
|
76
|
+
found = db.execute(
|
|
77
|
+
"SELECT doc, sec, title, snippet(sections, 3, '[', ']', ' ... ', 24), " # noqa: S608
|
|
78
|
+
f"bm25(sections, {WEIGHTS}) "
|
|
79
|
+
"FROM sections WHERE sections MATCH ? AND doc LIKE ? ESCAPE '\\' "
|
|
80
|
+
f"ORDER BY bm25(sections, {WEIGHTS}) LIMIT ? OFFSET ?",
|
|
81
|
+
(_match(terms, joiner), like, limit, offset),
|
|
82
|
+
).fetchall()
|
|
83
|
+
if found:
|
|
84
|
+
return [
|
|
85
|
+
Hit(str(r[0]), str(r[1]), str(r[2]), " ".join(str(r[3]).split()), float(r[4])) for r in found
|
|
86
|
+
], relaxed
|
|
87
|
+
except sqlite3.DatabaseError as exc:
|
|
88
|
+
raise LibError(f"the search index is unreadable ({exc})", "", "import the edition again") from exc
|
|
89
|
+
return [], False
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""The document a reader returns and the store keeps.
|
|
2
|
+
|
|
3
|
+
Like `primarysource.model.Document` but each section carries an `anchor`, because Markdown, reStructuredText,
|
|
4
|
+
AsciiDoc and LaTeX address sections by a label or a slug as well as by position. When the core's `Section` gains
|
|
5
|
+
`anchor` (ROADMAP `cl.model_anchor`) this module reduces to a conversion.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from primarysource.errors import LibError
|
|
15
|
+
from primarysource.model import Unit
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class Sec:
|
|
20
|
+
"""One addressable part of a document. `id` is an ordinal path (`2.1`); `units` are in reading order and
|
|
21
|
+
`¶1` is `units[0]`. `depth` is the nesting depth, 1 for a top-level section."""
|
|
22
|
+
|
|
23
|
+
id: str
|
|
24
|
+
title: str
|
|
25
|
+
depth: int
|
|
26
|
+
anchor: str
|
|
27
|
+
units: tuple[str, ...] = ()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class Doc:
|
|
32
|
+
"""A document: an id, a title, the unit kind, its sections in order, free-form `meta` (source path, status,
|
|
33
|
+
licence note) and the `warnings` a reader raised about what it could not represent."""
|
|
34
|
+
|
|
35
|
+
id: str
|
|
36
|
+
title: str
|
|
37
|
+
unit: Unit
|
|
38
|
+
sections: tuple[Sec, ...] = ()
|
|
39
|
+
meta: Mapping[str, str] = field(default_factory=dict)
|
|
40
|
+
warnings: tuple[str, ...] = ()
|
|
41
|
+
|
|
42
|
+
def section(self, section_id: str) -> Sec | None:
|
|
43
|
+
for candidate in self.sections:
|
|
44
|
+
if candidate.id == section_id:
|
|
45
|
+
return candidate
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
def with_meta(self, **more: str) -> Doc:
|
|
49
|
+
return Doc(self.id, self.title, self.unit, self.sections, {**self.meta, **more}, self.warnings)
|
|
50
|
+
|
|
51
|
+
def to_json(self) -> dict[str, Any]:
|
|
52
|
+
return {
|
|
53
|
+
"id": self.id,
|
|
54
|
+
"title": self.title,
|
|
55
|
+
"unit": self.unit.value,
|
|
56
|
+
"meta": dict(self.meta),
|
|
57
|
+
"warnings": list(self.warnings),
|
|
58
|
+
"sections": [[s.id, s.title, s.depth, s.anchor, list(s.units)] for s in self.sections],
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def from_json(data: Mapping[str, Any]) -> Doc:
|
|
63
|
+
try:
|
|
64
|
+
sections = tuple(
|
|
65
|
+
Sec(str(r[0]), str(r[1]), int(r[2]), str(r[3]), tuple(str(u) for u in r[4])) for r in data["sections"]
|
|
66
|
+
)
|
|
67
|
+
return Doc(
|
|
68
|
+
str(data["id"]),
|
|
69
|
+
str(data["title"]),
|
|
70
|
+
Unit(data["unit"]),
|
|
71
|
+
sections,
|
|
72
|
+
{str(k): str(v) for k, v in dict(data["meta"]).items()},
|
|
73
|
+
tuple(str(w) for w in data["warnings"]),
|
|
74
|
+
)
|
|
75
|
+
except (KeyError, IndexError, TypeError, ValueError) as exc:
|
|
76
|
+
raise LibError(f"a stored document is damaged ({exc!r})", "", "import the edition again") from exc
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Minimal, bounded readers: bytes in, a `Doc` out. See `primarysource.pkgdocs` for what "bounded" promises."""
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""What every reader needs: strict decoding, slugs and the section builder."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import unicodedata
|
|
7
|
+
|
|
8
|
+
from primarysource.errors import LibError
|
|
9
|
+
from primarysource.model import Unit
|
|
10
|
+
from primarysource.pkgdocs.model import Doc, Sec
|
|
11
|
+
|
|
12
|
+
_SLUG_DROP = re.compile(r"[^\w\- ]", re.UNICODE)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def decode(data: bytes, what: str) -> str:
|
|
16
|
+
"""UTF-8 text with line endings normalised to `\\n`. A NUL byte or invalid UTF-8 is an error, never a guess:
|
|
17
|
+
a reader that substituted characters would quietly change what a citation says."""
|
|
18
|
+
if b"\x00" in data:
|
|
19
|
+
raise LibError(f"{what} contains a NUL byte; it is not text", "check that the source is the expected file")
|
|
20
|
+
try:
|
|
21
|
+
text = data.decode("utf-8-sig")
|
|
22
|
+
except UnicodeDecodeError as exc:
|
|
23
|
+
raise LibError(
|
|
24
|
+
f"{what} is not valid UTF-8 ({exc.reason} at byte {exc.start})",
|
|
25
|
+
"the collection reads UTF-8 sources only; report the file rather than converting it",
|
|
26
|
+
) from exc
|
|
27
|
+
if "\r" in text:
|
|
28
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
29
|
+
return text
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_ROLE = re.compile(r":[A-Za-z][\w:+.-]*:`([^`]*)`")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def clean_title(title: str) -> str:
|
|
36
|
+
"""A heading as plain text: RST roles and inline-literal backticks removed, whitespace collapsed."""
|
|
37
|
+
return " ".join(_ROLE.sub(chr(92) + "1", title).replace("`", "").split())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def slugify(title: str) -> str:
|
|
41
|
+
"""GitHub's heading slug: lowercase, punctuation dropped, spaces to hyphens. This is the rule used here, not
|
|
42
|
+
a claim that any specification defines it."""
|
|
43
|
+
text = unicodedata.normalize("NFKC", title).strip().lower()
|
|
44
|
+
return _SLUG_DROP.sub("", text).replace(" ", "-")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Builder:
|
|
48
|
+
"""Collects headings and units into sections with ordinal-path ids and unique anchors.
|
|
49
|
+
|
|
50
|
+
`heading(level, ...)` takes the format's own level; levels may skip (h1 to h3) and ids follow the nesting
|
|
51
|
+
actually seen, so a skipped level does not leave a gap in the path. Text before the first heading goes in
|
|
52
|
+
section `0` titled with the document title. With `base="5"` the document is one numbered unit of a larger
|
|
53
|
+
work (a chapter): `root()` opens section `5` and every later heading is `5.<n>`.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, base: str = "") -> None:
|
|
57
|
+
self._base = base
|
|
58
|
+
self._ids: list[str] = []
|
|
59
|
+
self._titles: list[str] = []
|
|
60
|
+
self._depths: list[int] = []
|
|
61
|
+
self._anchors_of: list[str] = []
|
|
62
|
+
self._units: list[list[str]] = []
|
|
63
|
+
self._stack: list[int] = [] # levels of the open sections
|
|
64
|
+
self._counts: list[int] = []
|
|
65
|
+
self._used: set[str] = set()
|
|
66
|
+
self._current: list[str] | None = None
|
|
67
|
+
self._preamble_title = ""
|
|
68
|
+
self._rooted = False
|
|
69
|
+
|
|
70
|
+
def set_preamble_title(self, title: str) -> None:
|
|
71
|
+
self._preamble_title = title
|
|
72
|
+
|
|
73
|
+
def _unique(self, anchor: str) -> str:
|
|
74
|
+
if not anchor:
|
|
75
|
+
anchor = "section"
|
|
76
|
+
candidate, n = anchor, 0
|
|
77
|
+
while candidate in self._used:
|
|
78
|
+
n += 1
|
|
79
|
+
candidate = f"{anchor}-{n}"
|
|
80
|
+
self._used.add(candidate)
|
|
81
|
+
return candidate
|
|
82
|
+
|
|
83
|
+
def _open(self, section_id: str, title: str, depth: int, anchor: str) -> None:
|
|
84
|
+
units: list[str] = []
|
|
85
|
+
self._ids.append(section_id)
|
|
86
|
+
self._titles.append(title)
|
|
87
|
+
self._depths.append(depth)
|
|
88
|
+
self._anchors_of.append(self._unique(anchor))
|
|
89
|
+
self._units.append(units)
|
|
90
|
+
self._current = units
|
|
91
|
+
|
|
92
|
+
def root(self, title: str, anchor: str = "") -> None:
|
|
93
|
+
"""Open the numbered root section (`base`, or `0`) with the document's own title."""
|
|
94
|
+
self._rooted = True
|
|
95
|
+
self._stack = []
|
|
96
|
+
title = clean_title(title)
|
|
97
|
+
self._open(self._base or "0", title, 1, anchor or slugify(title))
|
|
98
|
+
|
|
99
|
+
def heading(self, level: int, title: str, anchor: str = "") -> None:
|
|
100
|
+
title = clean_title(title)
|
|
101
|
+
while self._stack and self._stack[-1] >= level:
|
|
102
|
+
self._stack.pop()
|
|
103
|
+
depth = len(self._stack) + 1
|
|
104
|
+
del self._counts[depth:]
|
|
105
|
+
while len(self._counts) < depth:
|
|
106
|
+
self._counts.append(0)
|
|
107
|
+
self._counts[depth - 1] += 1
|
|
108
|
+
path = ".".join(str(n) for n in self._counts[:depth])
|
|
109
|
+
section_id = f"{self._base}.{path}" if self._base else path
|
|
110
|
+
self._stack.append(level)
|
|
111
|
+
self._open(section_id, title, depth + (1 if self._rooted else 0), anchor or slugify(title))
|
|
112
|
+
|
|
113
|
+
def unit(self, text: str) -> None:
|
|
114
|
+
if not text.strip():
|
|
115
|
+
return
|
|
116
|
+
if self._current is None:
|
|
117
|
+
self._open(self._base or "0", self._preamble_title, 1, slugify(self._preamble_title) or "preamble")
|
|
118
|
+
if self._current is not None:
|
|
119
|
+
self._current.append(text)
|
|
120
|
+
|
|
121
|
+
def sections(self) -> tuple[Sec, ...]:
|
|
122
|
+
return tuple(
|
|
123
|
+
Sec(i, t, d, a, tuple(u))
|
|
124
|
+
for i, t, d, a, u in zip(
|
|
125
|
+
self._ids, self._titles, self._depths, self._anchors_of, self._units, strict=True
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def doc(self, doc_id: str, title: str, *, unit: Unit = Unit.BLOCK, warnings: tuple[str, ...] = ()) -> Doc:
|
|
130
|
+
sections = self.sections()
|
|
131
|
+
if sections and not sections[0].title:
|
|
132
|
+
first = sections[0]
|
|
133
|
+
sections = (Sec(first.id, title or doc_id, first.depth, first.anchor, first.units), *sections[1:])
|
|
134
|
+
return Doc(doc_id, title or doc_id, unit, sections, {}, warnings)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""A minimal AsciiDoc reader for git's Documentation: sections, blocks, anchors; nothing is executed or opened.
|
|
2
|
+
|
|
3
|
+
What it reads:
|
|
4
|
+
|
|
5
|
+
- the document title (`= Title`, or a line underlined with `=`) is the document's title, not a section;
|
|
6
|
+
- sections by `==` ... `======` or by an underline of `-` (level 1), `~` (2), `^` (3), `+` (4) whose length is
|
|
7
|
+
within one of the title's, as asciidoc requires;
|
|
8
|
+
- explicit anchors `[[id]]`, `[[id,text]]`, `[#id]` and `[id=...]` before a heading, else the GitHub-style slug;
|
|
9
|
+
- blocks (the unit, so `¶n` counts blocks): a run of non-blank lines; a delimited block (`----`, `....`, `====`,
|
|
10
|
+
`****`, `____`, `++++`, a `|===` table) as one block with its delimiters dropped; and every labelled-list term
|
|
11
|
+
(`-a::`) or list marker (`* `, `. `, `1. `) starts a new block even without a blank line before it;
|
|
12
|
+
- a block title (`.Title`) leads the block it titles.
|
|
13
|
+
|
|
14
|
+
What it does not do, by design:
|
|
15
|
+
|
|
16
|
+
- `include::path[]` is **never read**. It becomes the block `[include] path`, so the text says where the rest is;
|
|
17
|
+
the included file is its own document in the same collection;
|
|
18
|
+
- `ifdef::`/`ifndef::`/`ifeval::`/`endif::` lines are dropped and their content kept unconditionally, and the
|
|
19
|
+
document carries a warning with the count, because the condition's result is the build's, not the text's;
|
|
20
|
+
- attribute entries (`:name: value`), comments (`//`, `////` blocks), block attribute lines (`[verse]`) and the
|
|
21
|
+
`+` list continuation marker are dropped;
|
|
22
|
+
- inline markup (`linkgit:`, backticks, `'emphasis'`) is left as written.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import re
|
|
28
|
+
|
|
29
|
+
from primarysource.model import Unit
|
|
30
|
+
from primarysource.pkgdocs.model import Doc
|
|
31
|
+
from primarysource.pkgdocs.readers._common import Builder, decode
|
|
32
|
+
|
|
33
|
+
_UNDERLINE_LEVEL = {"=": 0, "-": 1, "~": 2, "^": 3, "+": 4}
|
|
34
|
+
_ATX = re.compile(r"(={1,6}) +(\S.*)")
|
|
35
|
+
_DELIM = re.compile(r"(-{4,}|\.{4,}|={4,}|\*{4,}|_{4,}|\+{4,}|/{4,}|[|!,:]={3,})")
|
|
36
|
+
_LIST = re.compile(r"\s*(?:\*{1,5}|-|\.{1,5}|\d{1,3}\.|[a-zA-Z]\.)\s+\S")
|
|
37
|
+
_TERM = re.compile(r"\S.*?(?:::|;;|:::|::::)(?:\s|$)")
|
|
38
|
+
_ATTR_ENTRY = re.compile(r":!?[\w][\w-]*!?:(?:\s.*)?")
|
|
39
|
+
_COND_PREFIXES = ("ifdef::", "ifndef::", "ifeval::", "endif::")
|
|
40
|
+
_ANCHOR = re.compile(r"\[\[([A-Za-z_][\w.:-]*)(?:,[^\]]*)?\]\]\s*")
|
|
41
|
+
_ATTRLINE = re.compile(r"\[(?!\[)[^\]]*\]\s*")
|
|
42
|
+
_BLOCKTITLE = re.compile(r"\.[^\s.].*")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_underline(line: str, title: str) -> int | None:
|
|
46
|
+
"""The level an underline gives `title`, or None when `line` is not its underline."""
|
|
47
|
+
if len(line) < 2 or line[0] not in _UNDERLINE_LEVEL or line.count(line[0]) != len(line):
|
|
48
|
+
return None
|
|
49
|
+
if abs(len(line) - len(title)) > 1 or not title.strip() or title[0] in " \t[.*-+":
|
|
50
|
+
return None
|
|
51
|
+
return _UNDERLINE_LEVEL[line[0]]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def read_asciidoc(data: bytes, doc_id: str, title: str | None = None) -> Doc:
|
|
55
|
+
"""Read one AsciiDoc file. Raises `LibError` for NUL bytes or invalid UTF-8; everything else reads."""
|
|
56
|
+
text = decode(data, f"AsciiDoc file {doc_id!r}")
|
|
57
|
+
lines = text.expandtabs(8).split("\n")
|
|
58
|
+
builder = Builder()
|
|
59
|
+
doc_title = title or ""
|
|
60
|
+
title_set = bool(title)
|
|
61
|
+
pending_anchor = ""
|
|
62
|
+
pending_title = ""
|
|
63
|
+
conditionals = 0
|
|
64
|
+
para: list[str] = []
|
|
65
|
+
|
|
66
|
+
def flush() -> None:
|
|
67
|
+
nonlocal pending_title
|
|
68
|
+
if para:
|
|
69
|
+
block = "\n".join(para)
|
|
70
|
+
if pending_title:
|
|
71
|
+
block = pending_title + "\n" + block
|
|
72
|
+
pending_title = ""
|
|
73
|
+
builder.unit(block)
|
|
74
|
+
para.clear()
|
|
75
|
+
|
|
76
|
+
def heading(level: int, name: str) -> None:
|
|
77
|
+
nonlocal doc_title, title_set, pending_anchor
|
|
78
|
+
name = name.strip()
|
|
79
|
+
if level == 0 and not title_set and not doc_title:
|
|
80
|
+
doc_title = name
|
|
81
|
+
title_set = True
|
|
82
|
+
builder.set_preamble_title(name)
|
|
83
|
+
pending_anchor = ""
|
|
84
|
+
return
|
|
85
|
+
builder.heading(max(level, 1), name, pending_anchor)
|
|
86
|
+
pending_anchor = ""
|
|
87
|
+
|
|
88
|
+
i, count = 0, len(lines)
|
|
89
|
+
while i < count:
|
|
90
|
+
line = lines[i].rstrip()
|
|
91
|
+
i += 1
|
|
92
|
+
stripped = line.strip()
|
|
93
|
+
if not stripped:
|
|
94
|
+
flush()
|
|
95
|
+
continue
|
|
96
|
+
# an underlined title: exactly one paragraph line above, this line is its underline
|
|
97
|
+
if len(para) == 1:
|
|
98
|
+
level = _is_underline(line, para[0])
|
|
99
|
+
if level is not None and not _LIST.match(para[0]):
|
|
100
|
+
name = para.pop()
|
|
101
|
+
heading(level, name)
|
|
102
|
+
continue
|
|
103
|
+
if line.startswith(_COND_PREFIXES) and line.endswith("]") and "[" in line:
|
|
104
|
+
conditionals += 1
|
|
105
|
+
continue
|
|
106
|
+
if line.startswith("//") and not line.startswith("///"):
|
|
107
|
+
continue
|
|
108
|
+
if _ATTR_ENTRY.fullmatch(stripped) and not para:
|
|
109
|
+
continue
|
|
110
|
+
match = _DELIM.fullmatch(line)
|
|
111
|
+
if match and not (len(para) == 1 and _is_underline(line, para[0]) is not None):
|
|
112
|
+
flush()
|
|
113
|
+
body: list[str] = []
|
|
114
|
+
while i < count and lines[i].rstrip() != line:
|
|
115
|
+
body.append(lines[i].rstrip())
|
|
116
|
+
i += 1
|
|
117
|
+
i += 1 # the closing delimiter, or the end of the file for an unclosed block
|
|
118
|
+
if line.startswith("/"):
|
|
119
|
+
continue
|
|
120
|
+
block = "\n".join(body).strip("\n")
|
|
121
|
+
if pending_title:
|
|
122
|
+
block = pending_title + "\n" + block
|
|
123
|
+
pending_title = ""
|
|
124
|
+
builder.unit(block)
|
|
125
|
+
continue
|
|
126
|
+
if line == "--":
|
|
127
|
+
flush()
|
|
128
|
+
continue
|
|
129
|
+
if line == "+":
|
|
130
|
+
continue
|
|
131
|
+
atx = _ATX.fullmatch(line)
|
|
132
|
+
if atx and not para:
|
|
133
|
+
heading(len(atx.group(1)) - 1, atx.group(2))
|
|
134
|
+
continue
|
|
135
|
+
if line.startswith("include::") and line.endswith("]") and "[" in line:
|
|
136
|
+
flush()
|
|
137
|
+
builder.unit("[include] " + line[len("include::") : line.index("[")])
|
|
138
|
+
continue
|
|
139
|
+
anchor = _ANCHOR.fullmatch(line)
|
|
140
|
+
if anchor and not para:
|
|
141
|
+
pending_anchor = anchor.group(1)
|
|
142
|
+
continue
|
|
143
|
+
if _ATTRLINE.fullmatch(line) and not para:
|
|
144
|
+
found = re.search(r"#([A-Za-z_][\w.:-]*)|\bid=([A-Za-z_][\w.:-]*)", line)
|
|
145
|
+
if found:
|
|
146
|
+
pending_anchor = found.group(1) or found.group(2)
|
|
147
|
+
continue
|
|
148
|
+
if not para and _BLOCKTITLE.fullmatch(line):
|
|
149
|
+
pending_title = line
|
|
150
|
+
continue
|
|
151
|
+
if para and (_LIST.match(line) or _TERM.match(line) and not line.startswith(" ")):
|
|
152
|
+
flush()
|
|
153
|
+
para.append(line)
|
|
154
|
+
flush()
|
|
155
|
+
warnings: tuple[str, ...] = ()
|
|
156
|
+
if conditionals:
|
|
157
|
+
warnings = (f"{conditionals} ifdef/ifndef/ifeval/endif lines dropped; their content is kept unconditionally",)
|
|
158
|
+
if not doc_title:
|
|
159
|
+
doc_title = doc_id
|
|
160
|
+
return builder.doc(doc_id, doc_title, unit=Unit.BLOCK, warnings=warnings)
|