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,430 @@
|
|
|
1
|
+
"""The text of every tool and command: read, outline, search, cite, info, list. Local files only, no network.
|
|
2
|
+
|
|
3
|
+
Every result about a draft says three things the other collections need not, because a draft is not a fixed
|
|
4
|
+
document: which revision it read (a ref without one resolves to the latest stored revision, and says so), whether
|
|
5
|
+
that draft is still alive (RFC 2026 §2.2: no formal status, subject to change or removal at any time), and when
|
|
6
|
+
that was last checked. A dead or superseded draft starts with a banner line as well as ending in `warning:`, so
|
|
7
|
+
nobody has to reach the footer to learn it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import difflib
|
|
13
|
+
import math
|
|
14
|
+
import re
|
|
15
|
+
from collections import Counter
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import date
|
|
18
|
+
|
|
19
|
+
from primarysource import budget, cursor
|
|
20
|
+
from primarysource.draft import catalogue, datatracker, licence, names
|
|
21
|
+
from primarysource.draft.segment import Block, DSection, Segmented, normalise
|
|
22
|
+
from primarysource.draft.status import STALE_DAYS, Lifecycle, Status, lifecycle, staleness
|
|
23
|
+
from primarysource.draft.store import RevMeta, Store
|
|
24
|
+
from primarysource.errors import LibError
|
|
25
|
+
from primarysource.model import span_hash
|
|
26
|
+
from primarysource.refs import Ref
|
|
27
|
+
from primarysource.render import render
|
|
28
|
+
|
|
29
|
+
_WORD = re.compile(r"[a-z0-9][a-z0-9_]*")
|
|
30
|
+
_MONTHS = ("January", "February", "March", "April", "May", "June", "July", "August", "September", "October",
|
|
31
|
+
"November", "December") # fmt: skip
|
|
32
|
+
MAX_QUERY = 300
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class Target:
|
|
37
|
+
"""A ref resolved against the library: the draft, the revision actually read, and whether it was chosen."""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
rev: str
|
|
41
|
+
chosen: bool
|
|
42
|
+
ref: Ref
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def doc(self) -> str:
|
|
46
|
+
return f"{self.name}-{self.rev}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def resolve(store: Store, text: str, need_rev: bool = True) -> Target:
|
|
50
|
+
"""Parse `text` as a ref and pick the revision. A ref with no document part is refused."""
|
|
51
|
+
if len(text) > 400:
|
|
52
|
+
raise LibError("not a ref: too long", "")
|
|
53
|
+
ref = names.GRAMMAR.parse(text)
|
|
54
|
+
if not ref.doc:
|
|
55
|
+
raise LibError(f"not a draft ref: {text[:60]!r}", "expected draft-<words>[-NN][§<section>[¶n]]")
|
|
56
|
+
name, rev = names.split_doc(ref.doc)
|
|
57
|
+
if rev is None and not need_rev:
|
|
58
|
+
return Target(name, "", False, ref)
|
|
59
|
+
chosen_rev, chosen = store.resolve(name, rev)
|
|
60
|
+
return Target(name, chosen_rev, chosen, ref)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _day(published: str) -> str:
|
|
64
|
+
try:
|
|
65
|
+
y, m, d = (int(x) for x in published[:10].split("-"))
|
|
66
|
+
date(y, m, d)
|
|
67
|
+
return f"{d} {_MONTHS[m - 1]} {y}"
|
|
68
|
+
except (ValueError, IndexError):
|
|
69
|
+
return ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _initials(author: str) -> str:
|
|
73
|
+
parts = author.split()
|
|
74
|
+
if len(parts) < 2:
|
|
75
|
+
return author
|
|
76
|
+
return " ".join(f"{p[0]}." for p in parts[:-1] if p) + " " + parts[-1]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _authors(meta: RevMeta) -> str:
|
|
80
|
+
listed = [_initials(a) for a in meta.authors]
|
|
81
|
+
if not listed:
|
|
82
|
+
return ""
|
|
83
|
+
if len(listed) > 3:
|
|
84
|
+
return f"{listed[0]} et al."
|
|
85
|
+
if len(listed) == 3:
|
|
86
|
+
return f"{listed[0]}, {listed[1]}, and {listed[2]}"
|
|
87
|
+
return " and ".join(listed)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def section_anchor(section: DSection) -> str:
|
|
91
|
+
"""The datatracker HTML's anchor for a numbered section (`section-5.1`, `section-appendix.a`, `section-a.1`),
|
|
92
|
+
or '' for an unnumbered one, which has no stable anchor."""
|
|
93
|
+
if not section.num:
|
|
94
|
+
return ""
|
|
95
|
+
if section.num.startswith("Appendix "):
|
|
96
|
+
return "section-appendix." + section.num.split(" ", 1)[1].lower()
|
|
97
|
+
return "section-" + section.num.lower()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def cite_string(meta: RevMeta, section: DSection | None = None, para: int | str | None = None) -> str:
|
|
101
|
+
"""`Authors, "Title", Work in Progress, Internet-Draft, name-NN, date, <url#anchor>, Section 5.1, paragraph 2.`
|
|
102
|
+
"Work in Progress" is the only way RFC 2026 §2.2 and the drafts' own status text allow one to be cited."""
|
|
103
|
+
anchor = section_anchor(section) if section else ""
|
|
104
|
+
url = datatracker.html_url(meta.name, meta.rev) + (f"#{anchor}" if anchor else "")
|
|
105
|
+
parts = [p for p in (_authors(meta), f'"{meta.title}"', "Work in Progress", "Internet-Draft",
|
|
106
|
+
f"{meta.name}-{meta.rev}", _day(meta.published), f"<{url}>") if p] # fmt: skip
|
|
107
|
+
text = ", ".join(parts)
|
|
108
|
+
if section is not None:
|
|
109
|
+
where = f"Section {section.num.removeprefix('Appendix ')}" if section.num else f'"{section.title}"'
|
|
110
|
+
if section.num.startswith("Appendix "):
|
|
111
|
+
where = f"Appendix {section.num.split(' ', 1)[1]}"
|
|
112
|
+
text += f", {where}" + (f", paragraph{'s' if isinstance(para, str) else ''} {para}" if para else "")
|
|
113
|
+
return text + "."
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def standing(store: Store, name: str, rev: str, chosen: bool = False) -> tuple[list[str], str]:
|
|
117
|
+
"""(warnings, banner) for reading `name`-`rev`: what a reader must know before relying on it. The banner is
|
|
118
|
+
'' when the draft is alive and `rev` is its latest revision."""
|
|
119
|
+
notes: list[str] = []
|
|
120
|
+
banner = ""
|
|
121
|
+
if chosen:
|
|
122
|
+
notes.append(f"no revision in the ref: read the latest stored revision -{rev}")
|
|
123
|
+
status = store.status(name)
|
|
124
|
+
if status is None:
|
|
125
|
+
notes.append(
|
|
126
|
+
f"no datatracker status stored, so expiry and replacement are unknown: primarysource draft refresh {name}"
|
|
127
|
+
)
|
|
128
|
+
return notes, banner
|
|
129
|
+
cycle = lifecycle(status)
|
|
130
|
+
if not cycle.alive:
|
|
131
|
+
banner = f"[{cycle.level.upper()}: {cycle.text}]"
|
|
132
|
+
notes.append(f"{cycle.level.upper()}: {cycle.text}")
|
|
133
|
+
if status.rev != rev:
|
|
134
|
+
newer = f"-{rev} is not the latest revision: the datatracker listed -{status.rev}"
|
|
135
|
+
banner = banner or f"[{newer}]"
|
|
136
|
+
notes.append(newer)
|
|
137
|
+
age = staleness(status)
|
|
138
|
+
if age > STALE_DAYS:
|
|
139
|
+
notes.append(f"status was checked {age} days ago: primarysource draft refresh {name}")
|
|
140
|
+
return notes, banner
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _footer(cite: str, notes: list[str], span: str = "", nxt: str = "") -> dict[str, str]:
|
|
144
|
+
return {"cite": cite, "unit": "paragraph", "span": span, "next": nxt, "warning": "; ".join(notes)}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _section_not_found(seg: Segmented, section: str, doc: str) -> LibError:
|
|
148
|
+
close = difflib.get_close_matches(section, [s.id for s in seg.sections], n=3)
|
|
149
|
+
hint = f"did you mean {', '.join(close)}?" if close else f"draft_outline lists the sections of {doc}"
|
|
150
|
+
return LibError(f"{doc} has no section {section!r}", hint)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _block_text(block: Block) -> str:
|
|
154
|
+
return block.text
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def read(store: Store, text: str, max_tokens: int = 2000) -> str:
|
|
158
|
+
"""Read by ref: `name[-rev]` (abstract and outline), `§section` (its paragraphs, paged), `§section¶n` (from
|
|
159
|
+
paragraph n, paged) or `:first-last` (those lines of the stored text). The `next:` footer is the ref that reads
|
|
160
|
+
the next page."""
|
|
161
|
+
target = resolve(store, text)
|
|
162
|
+
meta = store.meta(target.name, target.rev)
|
|
163
|
+
notes, banner = standing(store, target.name, target.rev, target.chosen)
|
|
164
|
+
ref = target.ref
|
|
165
|
+
if ref.lines is not None:
|
|
166
|
+
return _read_lines(store, target, meta, ref.lines, banner, notes)
|
|
167
|
+
seg = store.segmented(target.name, target.rev)
|
|
168
|
+
if ref.section is None:
|
|
169
|
+
return _overview(seg, target, meta, banner, notes)
|
|
170
|
+
section = seg.section(ref.section)
|
|
171
|
+
if section is None:
|
|
172
|
+
raise _section_not_found(seg, ref.section, target.doc)
|
|
173
|
+
head = f"{target.doc}§{section.id} {section.title}".rstrip()
|
|
174
|
+
if ref.para is not None and ref.para > len(section.blocks):
|
|
175
|
+
raise LibError(f"{head} has {len(section.blocks)} paragraphs, not {ref.para}", "")
|
|
176
|
+
units = tuple(_block_text(b) for b in section.blocks)
|
|
177
|
+
if not units:
|
|
178
|
+
body = "(no text under this heading; see its subsections in the outline)"
|
|
179
|
+
return render("\n".join(x for x in (banner, head, body) if x), _footer(cite_string(meta, section), notes))
|
|
180
|
+
if max_tokens < 1:
|
|
181
|
+
raise LibError("max_tokens must be positive", "")
|
|
182
|
+
page = budget.page(units, ref.para or 1, max_tokens)
|
|
183
|
+
body = "\n\n".join(page.units)
|
|
184
|
+
nxt = cursor.read_next(f"{target.doc}§{section.id}", page)
|
|
185
|
+
cite = cite_string(meta, section, cursor.para_label(ref.para, page))
|
|
186
|
+
if ref.para is not None: # a ¶ read is the paragraphs themselves, as a search hit's ref promises
|
|
187
|
+
return render("\n".join(x for x in (banner, body) if x), _footer(cite, notes, span_hash(body), nxt))
|
|
188
|
+
top = [banner, f"{head} (paragraphs {page.first}-{page.last} of {page.total})"]
|
|
189
|
+
return render("\n".join(x for x in top if x) + "\n\n" + body, _footer(cite, notes, span_hash(body), nxt))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _read_lines(
|
|
193
|
+
store: Store, target: Target, meta: RevMeta, span: tuple[int, int], banner: str, notes: list[str]
|
|
194
|
+
) -> str:
|
|
195
|
+
lines = normalise(store.raw_text(target.name, target.rev).decode("utf-8", errors="replace"))
|
|
196
|
+
first, last = span
|
|
197
|
+
if first > len(lines):
|
|
198
|
+
raise LibError(f"{target.doc} has {len(lines)} lines; {first}-{last} is past the end", "")
|
|
199
|
+
body = "\n".join(lines[first - 1 : last])
|
|
200
|
+
cite = cite_string(meta)[:-1] + f", lines {first}-{min(last, len(lines))}."
|
|
201
|
+
return render("\n".join(x for x in (banner, body) if x), _footer(cite, notes, span_hash(body)))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _overview(seg: Segmented, target: Target, meta: RevMeta, banner: str, notes: list[str]) -> str:
|
|
205
|
+
abstract = seg.section("abstract")
|
|
206
|
+
head = f"{target.doc} {meta.title}"
|
|
207
|
+
parts = [banner] if banner else []
|
|
208
|
+
parts += [head, f"{len(seg.sections)} sections; segmented from the text by {seg.method}"]
|
|
209
|
+
if abstract is not None and abstract.blocks:
|
|
210
|
+
parts += ["", "Abstract", *(b.text for b in abstract.blocks)]
|
|
211
|
+
parts += ["", "Sections (read one with draft_read('<ref>')):", *_outline_lines(seg, target.doc)]
|
|
212
|
+
return render("\n".join(p for p in parts if p is not None), _footer(cite_string(meta), notes))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _outline_lines(seg: Segmented, doc: str) -> list[str]:
|
|
216
|
+
out = []
|
|
217
|
+
for s in seg.sections:
|
|
218
|
+
label = f"{s.num} " if s.num else ""
|
|
219
|
+
out.append(f"{' ' * (s.depth - 1)}{doc}§{s.id} {label}{s.title} ({len(s.blocks)})")
|
|
220
|
+
return out
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def outline(store: Store, text: str) -> str:
|
|
224
|
+
"""The sections of a stored revision, each with the ref that reads it and its paragraph count."""
|
|
225
|
+
target = resolve(store, text)
|
|
226
|
+
meta = store.meta(target.name, target.rev)
|
|
227
|
+
notes, banner = standing(store, target.name, target.rev, target.chosen)
|
|
228
|
+
seg = store.segmented(target.name, target.rev)
|
|
229
|
+
body = "\n".join([x for x in (banner, f"{target.doc} {meta.title}") if x] + _outline_lines(seg, target.doc))
|
|
230
|
+
return render(body, _footer(cite_string(meta), notes))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def cite(store: Store, text: str) -> str:
|
|
234
|
+
"""The citation for a ref, with the span hash of the cited text when it is a paragraph or a section."""
|
|
235
|
+
target = resolve(store, text)
|
|
236
|
+
meta = store.meta(target.name, target.rev)
|
|
237
|
+
notes, banner = standing(store, target.name, target.rev, target.chosen)
|
|
238
|
+
ref = target.ref
|
|
239
|
+
section: DSection | None = None
|
|
240
|
+
span = ""
|
|
241
|
+
if ref.section is not None:
|
|
242
|
+
seg = store.segmented(target.name, target.rev)
|
|
243
|
+
section = seg.section(ref.section)
|
|
244
|
+
if section is None:
|
|
245
|
+
raise _section_not_found(seg, ref.section, target.doc)
|
|
246
|
+
if ref.para is not None:
|
|
247
|
+
if ref.para > len(section.blocks):
|
|
248
|
+
raise LibError(f"{target.doc}§{section.id} has {len(section.blocks)} paragraphs, not {ref.para}", "")
|
|
249
|
+
span = span_hash(section.blocks[ref.para - 1].text)
|
|
250
|
+
else:
|
|
251
|
+
span = span_hash("\n\n".join(b.text for b in section.blocks))
|
|
252
|
+
cite_text = cite_string(meta, section, ref.para)
|
|
253
|
+
resolved = Ref(doc=target.doc, section=ref.section, para=ref.para)
|
|
254
|
+
said = f"citation for {names.GRAMMAR.format(resolved)}; the cite: line is the string to use"
|
|
255
|
+
return render("\n".join(x for x in (banner, said) if x), _footer(cite_text, notes, span))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _lifecycle_line(status: Status) -> str:
|
|
259
|
+
cycle: Lifecycle = lifecycle(status)
|
|
260
|
+
return f"{cycle.level.upper()}: {cycle.text}"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def info(store: Store, text: str) -> str:
|
|
264
|
+
"""Everything known about a draft: standing, revisions stored and upstream, licence legend, pins."""
|
|
265
|
+
ref = names.GRAMMAR.parse(text)
|
|
266
|
+
if not ref.doc:
|
|
267
|
+
raise LibError(f"not a draft name: {text[:60]!r}", "")
|
|
268
|
+
name, _rev = names.split_doc(ref.doc)
|
|
269
|
+
stored = store.revisions(name)
|
|
270
|
+
status = store.status(name)
|
|
271
|
+
tracked = catalogue.TRACKED.get(name)
|
|
272
|
+
if not stored and status is None and tracked is None:
|
|
273
|
+
raise LibError(
|
|
274
|
+
f"{name} is neither stored nor tracked",
|
|
275
|
+
"draft_list lists what is tracked",
|
|
276
|
+
f"primarysource draft import {name}",
|
|
277
|
+
)
|
|
278
|
+
lines = [name]
|
|
279
|
+
if status is not None:
|
|
280
|
+
lines += [
|
|
281
|
+
f"title: {status.title}",
|
|
282
|
+
f"datatracker state: {status.state}",
|
|
283
|
+
f"standing: {_lifecycle_line(status)}",
|
|
284
|
+
]
|
|
285
|
+
lines.append(
|
|
286
|
+
f"latest revision on the datatracker: -{status.rev}"
|
|
287
|
+
+ (f" (posted {status.published(status.rev)})" if status.published(status.rev) else "")
|
|
288
|
+
)
|
|
289
|
+
lines.append(f"expires: {status.expires[:10] or 'unknown'}")
|
|
290
|
+
if status.replaced_by:
|
|
291
|
+
lines.append(f"replaced by: {', '.join(status.replaced_by)}")
|
|
292
|
+
if status.became_rfc:
|
|
293
|
+
lines.append(f"became: {status.became_rfc} (rfclib reads it)")
|
|
294
|
+
lines.append(f"revisions on the datatracker: {', '.join(r for r, _ in status.revisions) or 'none listed'}")
|
|
295
|
+
if status.group or status.stream:
|
|
296
|
+
lines.append(f"group: {status.group or '-'} stream: {status.stream or '-'}")
|
|
297
|
+
lines.append(f"status checked: {status.checked[:10]} ({staleness(status)} days ago)")
|
|
298
|
+
elif tracked is not None:
|
|
299
|
+
lines += [
|
|
300
|
+
"no datatracker status stored here; the catalogue's snapshot:",
|
|
301
|
+
f"catalogue snapshot, checked {tracked.checked or 'never'}: state {tracked.state or 'unknown'}, "
|
|
302
|
+
f"revision read -{tracked.rev_read or '?'}"
|
|
303
|
+
+ (f", replaced by {', '.join(tracked.replaced_by)}" if tracked.replaced_by else "")
|
|
304
|
+
+ (f", became {tracked.became_rfc}" if tracked.became_rfc else ""),
|
|
305
|
+
f"refresh it: primarysource draft refresh {name}",
|
|
306
|
+
]
|
|
307
|
+
lines.append(f"revisions stored: {', '.join('-' + r for r in stored) or 'none'}")
|
|
308
|
+
if stored:
|
|
309
|
+
meta = store.meta(name, stored[-1])
|
|
310
|
+
lines.append(
|
|
311
|
+
f"text of -{meta.rev}: {meta.txt_bytes:,} bytes, sha256 {meta.txt_sha256}, imported {meta.imported[:10]}"
|
|
312
|
+
)
|
|
313
|
+
lines.append(
|
|
314
|
+
f"author's xml of -{meta.rev}: {meta.xml_state}"
|
|
315
|
+
+ (f", sha256 {meta.xml_sha256}" if meta.xml_sha256 else "")
|
|
316
|
+
)
|
|
317
|
+
lines.append("licence: " + licence.Licence(meta.restriction, meta.copyright_year, meta.ipr).summary())
|
|
318
|
+
pin = catalogue.pin_for(name, meta.rev)
|
|
319
|
+
lines.append(f"pinned in the catalogue: {'yes' if pin else 'no (trusted on first use)'}")
|
|
320
|
+
if tracked is not None:
|
|
321
|
+
lines.append(f"tracked for: {', '.join(tracked.consumers)}. {tracked.why}")
|
|
322
|
+
lines.append(
|
|
323
|
+
"a draft has no formal status and may change or disappear at any time (RFC 2026 §2.2): "
|
|
324
|
+
"cite it only as work in progress"
|
|
325
|
+
)
|
|
326
|
+
notes = []
|
|
327
|
+
if status is not None and not lifecycle(status).alive:
|
|
328
|
+
notes.append(_lifecycle_line(status))
|
|
329
|
+
return render("\n".join(lines), {"warning": "; ".join(notes)})
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def list_tracked(store: Store) -> str:
|
|
333
|
+
"""One line per tracked or stored draft: stored revisions, the state last seen, who replaced it."""
|
|
334
|
+
rows = []
|
|
335
|
+
for name in sorted(set(catalogue.TRACKED) | set(store.names())):
|
|
336
|
+
stored = store.revisions(name)
|
|
337
|
+
status = store.status(name)
|
|
338
|
+
tracked = catalogue.TRACKED.get(name)
|
|
339
|
+
state = (
|
|
340
|
+
status.state if status else (f"{tracked.state} (catalogue)" if tracked and tracked.state else "unknown")
|
|
341
|
+
)
|
|
342
|
+
later = f"-{status.rev}" if status else (f"-{tracked.rev_read}" if tracked and tracked.rev_read else "-")
|
|
343
|
+
extra = ""
|
|
344
|
+
if status and status.replaced_by:
|
|
345
|
+
extra = f" replaced by {', '.join(status.replaced_by)}"
|
|
346
|
+
elif tracked and tracked.replaced_by:
|
|
347
|
+
extra = f" replaced by {', '.join(tracked.replaced_by)}"
|
|
348
|
+
rows.append(
|
|
349
|
+
f"{name} stored: {', '.join('-' + r for r in stored) or 'none'} latest: {later} state: {state}{extra}"
|
|
350
|
+
)
|
|
351
|
+
return render("\n".join(rows) or "no drafts stored or tracked", {})
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _tokens(text: str) -> list[str]:
|
|
355
|
+
return _WORD.findall(text.lower())
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _snippet(blocks: tuple[Block, ...], words: set[str], width: int = 160) -> str:
|
|
359
|
+
for b in blocks:
|
|
360
|
+
flat = " ".join(b.text.split())
|
|
361
|
+
low = flat.lower()
|
|
362
|
+
hits = [low.find(w) for w in words if low.find(w) >= 0]
|
|
363
|
+
if hits:
|
|
364
|
+
at = max(0, min(hits) - 40)
|
|
365
|
+
return ("..." if at else "") + flat[at : at + width]
|
|
366
|
+
return ""
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def search(store: Store, query: str, limit: int = 10, name: str | None = None) -> str:
|
|
370
|
+
"""BM25 over the sections of each draft's latest stored revision (or of one named draft or revision). All
|
|
371
|
+
words must match; if none of the sections has them all, any section with some does, and `relaxed:` says so."""
|
|
372
|
+
if not query.strip() or len(query) > MAX_QUERY:
|
|
373
|
+
raise LibError(f"a query is 1 to {MAX_QUERY} characters", "")
|
|
374
|
+
if not 1 <= limit <= 50:
|
|
375
|
+
raise LibError("limit is 1 to 50", "")
|
|
376
|
+
words = _tokens(query)
|
|
377
|
+
if not words:
|
|
378
|
+
raise LibError("the query has no searchable words", "use letters and digits")
|
|
379
|
+
chosen: list[tuple[str, str]] = []
|
|
380
|
+
if name:
|
|
381
|
+
n, rev = names.split_doc(name)
|
|
382
|
+
chosen.append((n, store.resolve(n, rev)[0]))
|
|
383
|
+
else:
|
|
384
|
+
chosen = [(n, store.revisions(n)[-1]) for n in store.names()]
|
|
385
|
+
if not chosen:
|
|
386
|
+
raise LibError(
|
|
387
|
+
"no draft is stored", "draft_list lists the drafts to import", "primarysource draft import <name>"
|
|
388
|
+
)
|
|
389
|
+
rows: list[tuple[str, str, DSection, Counter[str], int, Status | None]] = []
|
|
390
|
+
for n, rev in chosen:
|
|
391
|
+
seg = store.segmented(n, rev)
|
|
392
|
+
meta = store.meta(n, rev)
|
|
393
|
+
status = store.status(n)
|
|
394
|
+
for s in seg.sections:
|
|
395
|
+
tf = Counter(_tokens(s.title) * 3 + _tokens(" ".join(b.text for b in s.blocks)))
|
|
396
|
+
rows.append((n, rev, s, tf, sum(tf.values()), status))
|
|
397
|
+
_ = meta
|
|
398
|
+
avg = sum(r[4] for r in rows) / max(1, len(rows))
|
|
399
|
+
df = {w: sum(1 for r in rows if r[3][w]) for w in set(words)}
|
|
400
|
+
|
|
401
|
+
def score(row: tuple[str, str, DSection, Counter[str], int, Status | None]) -> float:
|
|
402
|
+
total = 0.0
|
|
403
|
+
for w in set(words):
|
|
404
|
+
f = row[3][w]
|
|
405
|
+
if f:
|
|
406
|
+
idf = math.log(1 + (len(rows) - df[w] + 0.5) / (df[w] + 0.5))
|
|
407
|
+
total += idf * f * 2.2 / (f + 1.2 * (0.25 + 0.75 * row[4] / max(avg, 1.0)))
|
|
408
|
+
return total
|
|
409
|
+
|
|
410
|
+
everything = [r for r in rows if all(r[3][w] for w in set(words))]
|
|
411
|
+
relaxed = ""
|
|
412
|
+
if not everything:
|
|
413
|
+
everything = [r for r in rows if any(r[3][w] for w in set(words))]
|
|
414
|
+
relaxed = "not every word matched in any one section; these match some of them" if everything else ""
|
|
415
|
+
everything.sort(key=lambda r: (-score(r), r[0], r[1], r[2].head_line))
|
|
416
|
+
lines = []
|
|
417
|
+
dead = 0
|
|
418
|
+
for n, rev, s, _tf, _len, status in everything[:limit]:
|
|
419
|
+
mark = ""
|
|
420
|
+
if status is not None and not lifecycle(status).alive:
|
|
421
|
+
mark = f" [{lifecycle(status).level}]"
|
|
422
|
+
dead += 1
|
|
423
|
+
label = f"{s.num} " if s.num else ""
|
|
424
|
+
lines.append(f"{n}-{rev}§{s.id} {label}{s.title}{mark}: {_snippet(s.blocks, set(words))}")
|
|
425
|
+
if not lines:
|
|
426
|
+
return render("no section matched", {})
|
|
427
|
+
notes = (
|
|
428
|
+
[f"{dead} hit(s) are in drafts that are not alive (marked); check draft_info before citing"] if dead else []
|
|
429
|
+
)
|
|
430
|
+
return render("\n".join(lines), {"relaxed": relaxed, "warning": "; ".join(notes)})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""What the root element of a draft's submitted `.xml` says, read without parsing the rest.
|
|
2
|
+
|
|
3
|
+
The archive's `.xml` is the author's source: xml2rfc v2 (`<!DOCTYPE rfc SYSTEM "rfc2629.dtd">`, no `version`
|
|
4
|
+
attribute) or v3, with no `pn` ids and no section numbers. It is stored as the author's source and pinned, but
|
|
5
|
+
it is not segmented (segment.py says why), so all this module reads is the root start tag: the format version,
|
|
6
|
+
`docName`, `category`, `submissionType` and `ipr` (the licence legend, RFC 7991 Appendix A.1.1).
|
|
7
|
+
|
|
8
|
+
Reading only the first start tag is also the hardening. The rest of a v2 source can use entities the DTD defines
|
|
9
|
+
(` `) which a parser without the DTD cannot expand, and a hostile file can declare its own. So:
|
|
10
|
+
|
|
11
|
+
- any `<!ENTITY` or internal DTD subset is refused before a parser sees the bytes (no billion-laughs, no
|
|
12
|
+
external entity, ever);
|
|
13
|
+
- the parser stops at the root start tag, and never fetches a DTD.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
import xml.etree.ElementTree as ET # noqa: S405 # internal subsets and entities are refused first; root tag only
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
V2 = "v2"
|
|
24
|
+
V3 = "v3"
|
|
25
|
+
REFUSED = "refused"
|
|
26
|
+
UNPARSEABLE = "unparseable"
|
|
27
|
+
|
|
28
|
+
_INTERNAL_SUBSET = re.compile(rb"<!DOCTYPE[^>\[]*\[")
|
|
29
|
+
_MAX_ATTR = 200
|
|
30
|
+
_HEAD = 65536
|
|
31
|
+
_ROOT_TAG = re.compile(rb"<rfc(?=[\s/>])(?:[^>\"']|\"[^\"]*\"|'[^']*')*>")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class XmlInfo:
|
|
36
|
+
"""`state` is V2, V3, REFUSED or UNPARSEABLE; `why` says what was refused; the rest are the root's
|
|
37
|
+
attributes (empty when absent)."""
|
|
38
|
+
|
|
39
|
+
state: str
|
|
40
|
+
ipr: str = ""
|
|
41
|
+
doc_name: str = ""
|
|
42
|
+
category: str = ""
|
|
43
|
+
submission_type: str = ""
|
|
44
|
+
why: str = ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def inspect(data: bytes) -> XmlInfo:
|
|
48
|
+
"""The root element's facts, or a refusal that says why. Never raises on bad input."""
|
|
49
|
+
if b"<!ENTITY" in data:
|
|
50
|
+
return XmlInfo(REFUSED, why="declares entities")
|
|
51
|
+
if _INTERNAL_SUBSET.search(data[:4096]):
|
|
52
|
+
return XmlInfo(REFUSED, why="has an internal DTD subset")
|
|
53
|
+
root = _ROOT_TAG.search(data[:_HEAD])
|
|
54
|
+
if root is None:
|
|
55
|
+
return XmlInfo(UNPARSEABLE, why=f"no <rfc> start tag in the first {_HEAD:,} bytes")
|
|
56
|
+
parser: ET.XMLPullParser[Any] = ET.XMLPullParser(events=("start",))
|
|
57
|
+
try:
|
|
58
|
+
# only the bytes up to the end of the root start tag are fed: whatever follows (entities a DTD would
|
|
59
|
+
# define, a hostile body) is never seen by the parser
|
|
60
|
+
parser.feed(data[: root.end()])
|
|
61
|
+
events: Any = parser.read_events()
|
|
62
|
+
for event in events:
|
|
63
|
+
element = event[1]
|
|
64
|
+
attrs = {key: value[:_MAX_ATTR] for key, value in element.attrib.items()}
|
|
65
|
+
return XmlInfo(
|
|
66
|
+
V3 if attrs.get("version") == "3" else V2,
|
|
67
|
+
ipr=attrs.get("ipr", ""),
|
|
68
|
+
doc_name=attrs.get("docName", ""),
|
|
69
|
+
category=attrs.get("category", ""),
|
|
70
|
+
submission_type=attrs.get("submissionType", ""),
|
|
71
|
+
)
|
|
72
|
+
except ET.ParseError as exc:
|
|
73
|
+
return XmlInfo(UNPARSEABLE, why=str(exc)[:100])
|
|
74
|
+
return XmlInfo(UNPARSEABLE, why="no root element")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Ecma International standards, starting with ECMA-427 (Package-URL, 1st edition, December 2025).
|
|
2
|
+
|
|
3
|
+
Source: the standard's PDF from ecma-international.org, pinned by sha256 in `catalogue.json`, imported into the
|
|
4
|
+
user's own `$PRIMARYSOURCE_HOME/ecma/` with `primarysource ecma import 427`. Placement is a volume (Ecma's text
|
|
5
|
+
copyright notice allows copies only with the notice and no modification, and Ecma prefers its own download).
|
|
6
|
+
Reading the PDF needs `pypdf` (the `[ecma]` extra) at import time only, and uses `pdftotext` for the text when it
|
|
7
|
+
is on PATH. `docs/ecma.md` is the user-facing description.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from primarysource.model import Unit
|
|
13
|
+
from primarysource.refs import Ref, RefGrammar
|
|
14
|
+
from primarysource.registry import Collection
|
|
15
|
+
|
|
16
|
+
ECMA_DOC = r"(?:ECMA-|ecma-)?[0-9]{1,4}(?:@[0-9]{1,2})?"
|
|
17
|
+
"""A standard by number, optionally with its edition number: `427`, `ECMA-427`, `427@1`."""
|
|
18
|
+
|
|
19
|
+
ECMA_SECTION = r"[0-9A-Za-z][0-9A-Za-z.\-]*"
|
|
20
|
+
"""A clause as the PDF bookmarks number it (`5.3`, `A`, `A.1`) or the slug of an unnumbered bookmark."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _Refs:
|
|
24
|
+
"""The collection's ref parser: `library.parse_ref` (which also takes the `ecma:` prefix and refuses what the
|
|
25
|
+
collection has no meaning for), imported on first use so the collection loads without its reader."""
|
|
26
|
+
|
|
27
|
+
_grammar = RefGrammar(ECMA_DOC, ECMA_SECTION)
|
|
28
|
+
|
|
29
|
+
def parse(self, text: str) -> Ref:
|
|
30
|
+
from primarysource.ecma.library import parse_ref
|
|
31
|
+
|
|
32
|
+
return parse_ref(text)
|
|
33
|
+
|
|
34
|
+
def format(self, ref: Ref) -> str:
|
|
35
|
+
return self._grammar.format(ref)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
ECMA = Collection(
|
|
39
|
+
name="ecma",
|
|
40
|
+
title="Ecma International standards (ECMA-427, Package-URL)",
|
|
41
|
+
prefix="ecma_",
|
|
42
|
+
unit=Unit.SENTENCE,
|
|
43
|
+
grammar=_Refs(),
|
|
44
|
+
server="primarysource.ecma.server:server",
|
|
45
|
+
cli="primarysource.ecma.cli:main",
|
|
46
|
+
identify="primarysource.ecma.identify:identify",
|
|
47
|
+
)
|
|
48
|
+
"""Refs: `ecma:427§5.3¶2`. `§` names a clause by its number; `¶n` is the nth sentence of the clause's span (its own
|
|
49
|
+
text, then each subsection as a `§5.3.1 Title` line and its text), because a PDF gives sentences, not paragraphs.
|
|
50
|
+
The `ecma:` prefix is optional and `ECMA-427` means `427`."""
|
|
51
|
+
|
|
52
|
+
__all__ = ["ECMA", "ECMA_DOC", "ECMA_SECTION"]
|