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
|
+
"""What an Internet-Draft's own legends say about reuse, read from its text and, when it has one, its XML.
|
|
2
|
+
|
|
3
|
+
This is information for `info`, not legal advice. The sources are:
|
|
4
|
+
|
|
5
|
+
- BCP 78 (RFC 5378) §3.6: contributors keep the copyright in their Internet-Drafts and grant the IETF Trust
|
|
6
|
+
non-exclusive rights; §5.4: the Trust sublicenses them for the IETF Standards Process and may sublicense more;
|
|
7
|
+
- the IETF Trust's Trust Legal Provisions 5.0, §3.c: anyone may copy, publish, display and distribute a
|
|
8
|
+
Contribution "in full and without modification", or unmodified portions with attribution, and §3.d: no licence
|
|
9
|
+
to modify outside the Standards Process, and none to distribute without the required legends. §1.b and §1.c:
|
|
10
|
+
that applies to Contributions submitted after 2015-03-25; older ones stay under the policy in force then;
|
|
11
|
+
- RFC 7991 Appendix A.1.1: the `ipr` values, three of which add a legend that withdraws the right to modify.
|
|
12
|
+
|
|
13
|
+
So a draft is not covered by one blanket licence: the answer depends on its submission date and its legends.
|
|
14
|
+
That is why this collection stores drafts on the reader's own volume and never in an image (docs/draft.md).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
NONE = "none"
|
|
23
|
+
NO_MODIFICATION = "no-modification"
|
|
24
|
+
NO_DERIVATIVES = "no-derivatives"
|
|
25
|
+
PRE_5378 = "pre-5378"
|
|
26
|
+
|
|
27
|
+
TLP5_EFFECTIVE = (2015, 3, 25)
|
|
28
|
+
|
|
29
|
+
_IPR = {
|
|
30
|
+
"trust200902": NONE,
|
|
31
|
+
"nomodificationtrust200902": NO_MODIFICATION,
|
|
32
|
+
"noderivativestrust200902": NO_DERIVATIVES,
|
|
33
|
+
"pre5378trust200902": PRE_5378,
|
|
34
|
+
}
|
|
35
|
+
_YEAR = re.compile(r"Copyright \(c\) (\d{4}) IETF Trust")
|
|
36
|
+
_NO_DERIVATIVES = re.compile(r"may not be published except as an Internet-Draft", re.I)
|
|
37
|
+
_NO_MODIFICATION = re.compile(
|
|
38
|
+
r"This document may not be modified, and derivative works of it may not be created", re.I
|
|
39
|
+
)
|
|
40
|
+
_PRE_5378 = re.compile(
|
|
41
|
+
r"may contain material from IETF Documents or IETF Contributions published or made publicly", re.I
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class Licence:
|
|
47
|
+
"""`restriction` is one of NONE, NO_MODIFICATION, NO_DERIVATIVES, PRE_5378; `year` is the copyright year of
|
|
48
|
+
the IETF Trust notice, 0 if there is none; `ipr` is the xml2rfc `ipr` attribute, or ''."""
|
|
49
|
+
|
|
50
|
+
restriction: str
|
|
51
|
+
year: int
|
|
52
|
+
ipr: str
|
|
53
|
+
|
|
54
|
+
def summary(self) -> str:
|
|
55
|
+
"""One line for `info`."""
|
|
56
|
+
parts = []
|
|
57
|
+
if self.year and self.year < TLP5_EFFECTIVE[0]:
|
|
58
|
+
parts.append(
|
|
59
|
+
f"copyright {self.year}: submitted before the Trust Legal Provisions 5.0 (2015-03-25), so the "
|
|
60
|
+
"policy in force at its submission applies, not TLP 5.0 (TLP 5.0 §1.c)"
|
|
61
|
+
)
|
|
62
|
+
elif self.year:
|
|
63
|
+
parts.append(f"copyright {self.year} IETF Trust and the authors; Trust Legal Provisions 5.0 §3.c applies")
|
|
64
|
+
else:
|
|
65
|
+
parts.append("no IETF Trust copyright notice found: check the draft's own legends")
|
|
66
|
+
if self.restriction == NO_MODIFICATION:
|
|
67
|
+
parts.append("its legend forbids modification (TLP 6.c.i)")
|
|
68
|
+
elif self.restriction == NO_DERIVATIVES:
|
|
69
|
+
parts.append("its legend forbids modification and publication except as an Internet-Draft (TLP 6.c.ii)")
|
|
70
|
+
elif self.restriction == PRE_5378:
|
|
71
|
+
parts.append("its pre-5378 legend limits modification outside the IETF Standards Process (TLP 6.c.iii)")
|
|
72
|
+
return "; ".join(parts)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def detect(text: str, xml_ipr: str = "") -> Licence:
|
|
76
|
+
"""Read the legend from the text (whitespace collapsed, since the legend wraps) and the XML `ipr`. A legend in
|
|
77
|
+
the text wins over the attribute, because the text is what a reader sees."""
|
|
78
|
+
flat = " ".join(text.split())
|
|
79
|
+
restriction = NONE
|
|
80
|
+
if _NO_DERIVATIVES.search(flat):
|
|
81
|
+
restriction = NO_DERIVATIVES
|
|
82
|
+
elif _PRE_5378.search(flat):
|
|
83
|
+
restriction = PRE_5378
|
|
84
|
+
elif _NO_MODIFICATION.search(flat):
|
|
85
|
+
restriction = NO_MODIFICATION
|
|
86
|
+
if restriction == NONE:
|
|
87
|
+
restriction = _IPR.get(xml_ipr.lower(), NONE)
|
|
88
|
+
match = _YEAR.search(flat)
|
|
89
|
+
return Licence(restriction, int(match.group(1)) if match else 0, xml_ipr)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""What a draft's name and revision look like, and how a ref's document part is split into the two.
|
|
2
|
+
|
|
3
|
+
The datatracker names a draft `draft-<words>` in lowercase letters, digits and hyphens, and a revision is two
|
|
4
|
+
digits, `00` to `99`, appended with a hyphen: `draft-ietf-sidrops-8210bis-17`. A trailing `-NN` of exactly two
|
|
5
|
+
digits is read as the revision, which is how the datatracker itself reads it; a draft whose own name ends in two
|
|
6
|
+
digits therefore has to be written with its revision (`draft-foo-bar-12-00`) to be unambiguous.
|
|
7
|
+
|
|
8
|
+
Every name that reaches a URL or a path passes `parse_name` or `split_doc` first, so none of `/`, `..`, `%`, a
|
|
9
|
+
newline or an uppercase letter gets any further.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
from primarysource.errors import LibError
|
|
17
|
+
from primarysource.refs import RefGrammar
|
|
18
|
+
|
|
19
|
+
MAX_NAME = 120
|
|
20
|
+
_WORDS = r"draft-[a-z0-9]+(?:-[a-z0-9]+)*"
|
|
21
|
+
_NAME = re.compile(_WORDS)
|
|
22
|
+
_REVISION = re.compile(r"[0-9]{2}")
|
|
23
|
+
_DOC = re.compile(r"(?P<name>draft-[a-z0-9]+(?:-[a-z0-9]+)*?)(?:-(?P<rev>[0-9]{2}))?")
|
|
24
|
+
|
|
25
|
+
DOC_PATTERN = r"draft-[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*"
|
|
26
|
+
"""For the ref grammar: the name, with or without its revision. Case is folded by `split_doc`."""
|
|
27
|
+
|
|
28
|
+
SECTION_PATTERN = r"[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*"
|
|
29
|
+
"""A section id: `5.1`, `A.2` (an appendix), or a title slug for an unnumbered section (`abstract`)."""
|
|
30
|
+
|
|
31
|
+
GRAMMAR = RefGrammar(DOC_PATTERN, SECTION_PATTERN)
|
|
32
|
+
|
|
33
|
+
_HINT = "a draft is named draft-<words>, optionally followed by -NN, e.g. draft-ietf-sidrops-8210bis-17"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_name(text: str) -> str:
|
|
37
|
+
"""A draft name without a revision, or a LibError. Case is folded; nothing else is repaired."""
|
|
38
|
+
name = text.strip().lower()
|
|
39
|
+
if len(name) > MAX_NAME or not _NAME.fullmatch(name):
|
|
40
|
+
raise LibError(f"not a draft name: {text[:60]!r}", _HINT)
|
|
41
|
+
return name
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def valid_revision(text: str) -> str:
|
|
45
|
+
"""A two-digit revision, or a LibError."""
|
|
46
|
+
if not _REVISION.fullmatch(text):
|
|
47
|
+
raise LibError(f"not a revision: {text[:20]!r}", "a revision is two digits, 00 to 99")
|
|
48
|
+
return text
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def split_doc(text: str) -> tuple[str, str | None]:
|
|
52
|
+
"""`draft-x-y-17` -> (`draft-x-y`, `17`); `draft-x-y` -> (`draft-x-y`, None)."""
|
|
53
|
+
doc = text.strip().lower()
|
|
54
|
+
if len(doc) > MAX_NAME + 3:
|
|
55
|
+
raise LibError(f"not a draft name: {text[:60]!r}", _HINT)
|
|
56
|
+
match = _DOC.fullmatch(doc)
|
|
57
|
+
if match is None:
|
|
58
|
+
raise LibError(f"not a draft name: {text[:60]!r}", _HINT)
|
|
59
|
+
return parse_name(match["name"]), match["rev"]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def with_revision(name: str, rev: str) -> str:
|
|
63
|
+
"""`draft-x-y` and `17` -> `draft-x-y-17`."""
|
|
64
|
+
return f"{parse_name(name)}-{valid_revision(rev)}"
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""The only code in this collection that touches the network, behind an injectable transport.
|
|
2
|
+
|
|
3
|
+
A `Transport` is a function from a URL to a `Response`; tests pass their own and never open a socket. The real
|
|
4
|
+
one (`urllib_transport`) is held to three rules that a draft's bytes being public does not relax:
|
|
5
|
+
|
|
6
|
+
- only `https` and only the two hosts the IETF serves drafts and metadata from, redirects included, so a
|
|
7
|
+
redirect cannot send the importer somewhere the person did not name;
|
|
8
|
+
- a hard ceiling on the body, read in chunks, so a hostile or broken server cannot fill memory;
|
|
9
|
+
- a fixed User-Agent that names this package and carries nothing about the person.
|
|
10
|
+
|
|
11
|
+
`Fetcher` adds the courtesy the IETF asks of automated clients: one request per second, spaced from the end of
|
|
12
|
+
the previous request, a bounded retry on 429 and 5xx that honours `Retry-After`, and nothing else.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import time
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.request
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from urllib.parse import urlsplit
|
|
23
|
+
|
|
24
|
+
from primarysource import __version__
|
|
25
|
+
from primarysource.errors import LibError
|
|
26
|
+
|
|
27
|
+
DATATRACKER = "datatracker.ietf.org"
|
|
28
|
+
ARCHIVE = "www.ietf.org"
|
|
29
|
+
ALLOWED_HOSTS = (DATATRACKER, ARCHIVE)
|
|
30
|
+
USER_AGENT = f"primarysource-draft/{__version__}"
|
|
31
|
+
MAX_BODY = 8_000_000
|
|
32
|
+
INTERVAL = 1.0
|
|
33
|
+
RETRIES = 2
|
|
34
|
+
MAX_RETRY_WAIT = 30.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class Response:
|
|
39
|
+
"""What a transport returns: the HTTP status, the body (at most `MAX_BODY` bytes), the content type and the
|
|
40
|
+
`Retry-After` header if the server sent one. A transport returns 4xx and 5xx; it raises only when no HTTP
|
|
41
|
+
answer came back at all."""
|
|
42
|
+
|
|
43
|
+
status: int
|
|
44
|
+
body: bytes
|
|
45
|
+
content_type: str = ""
|
|
46
|
+
retry_after: float | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
Transport = Callable[[str], Response]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def check_url(url: str) -> None:
|
|
53
|
+
"""Refuse a URL that is not https on an allowed host. Used before a request and again on every redirect."""
|
|
54
|
+
parts = urlsplit(url)
|
|
55
|
+
if parts.scheme != "https" or parts.hostname not in ALLOWED_HOSTS or parts.username or parts.password:
|
|
56
|
+
raise LibError(f"refusing to fetch {url[:100]!r}: only https://{DATATRACKER} and https://{ARCHIVE}", "")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _Redirects(urllib.request.HTTPRedirectHandler):
|
|
60
|
+
def redirect_request(
|
|
61
|
+
self, req: urllib.request.Request, fp: object, code: int, msg: str, headers: object, newurl: str
|
|
62
|
+
) -> urllib.request.Request | None:
|
|
63
|
+
check_url(newurl)
|
|
64
|
+
return super().redirect_request(req, fp, code, msg, headers, newurl) # type: ignore[arg-type]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _retry_after(value: str | None) -> float | None:
|
|
68
|
+
try:
|
|
69
|
+
return float(value) if value is not None else None
|
|
70
|
+
except ValueError:
|
|
71
|
+
return None # an HTTP-date form is ignored; the fixed back-off applies
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def urllib_transport(url: str, timeout: float = 30.0) -> Response:
|
|
75
|
+
"""GET `url` with the standard library, under the rules in the module docstring."""
|
|
76
|
+
check_url(url)
|
|
77
|
+
opener = urllib.request.build_opener(_Redirects)
|
|
78
|
+
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept-Encoding": "identity"}) # noqa: S310
|
|
79
|
+
try:
|
|
80
|
+
with opener.open(request, timeout=timeout) as reply:
|
|
81
|
+
body = reply.read(MAX_BODY + 1)
|
|
82
|
+
if len(body) > MAX_BODY:
|
|
83
|
+
raise LibError(f"{url} is larger than {MAX_BODY:,} bytes; refusing it", "")
|
|
84
|
+
return Response(reply.status, body, reply.headers.get("Content-Type", ""))
|
|
85
|
+
except urllib.error.HTTPError as exc:
|
|
86
|
+
return Response(
|
|
87
|
+
exc.code, b"", exc.headers.get("Content-Type", ""), _retry_after(exc.headers.get("Retry-After"))
|
|
88
|
+
)
|
|
89
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
90
|
+
raise LibError(f"could not reach {urlsplit(url).hostname}: {exc}", "check the network and try again") from exc
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Fetcher:
|
|
94
|
+
"""Rate-limited, retrying GET over a transport. `clock` and `sleep` are injectable so tests take no time."""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
transport: Transport = urllib_transport,
|
|
99
|
+
interval: float = INTERVAL,
|
|
100
|
+
clock: Callable[[], float] = time.monotonic,
|
|
101
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
102
|
+
) -> None:
|
|
103
|
+
self._transport = transport
|
|
104
|
+
self._interval = interval
|
|
105
|
+
self._clock = clock
|
|
106
|
+
self._sleep = sleep
|
|
107
|
+
self._last: float | None = None
|
|
108
|
+
self.requests = 0
|
|
109
|
+
|
|
110
|
+
def _wait(self) -> None:
|
|
111
|
+
if self._last is not None:
|
|
112
|
+
remaining = self._interval - (self._clock() - self._last)
|
|
113
|
+
if remaining > 0:
|
|
114
|
+
self._sleep(remaining)
|
|
115
|
+
|
|
116
|
+
def get(self, url: str) -> Response:
|
|
117
|
+
"""The response for `url`, which may be a 404 (the caller decides what that means). A 429 or 5xx is
|
|
118
|
+
retried `RETRIES` times, then raised as a LibError that says to try later."""
|
|
119
|
+
check_url(url)
|
|
120
|
+
attempt = 0
|
|
121
|
+
while True:
|
|
122
|
+
self._wait()
|
|
123
|
+
try:
|
|
124
|
+
reply = self._transport(url)
|
|
125
|
+
finally:
|
|
126
|
+
self._last = self._clock()
|
|
127
|
+
self.requests += 1
|
|
128
|
+
if reply.status != 429 and reply.status < 500:
|
|
129
|
+
return reply
|
|
130
|
+
if attempt >= RETRIES:
|
|
131
|
+
raise LibError(f"{urlsplit(url).hostname} answered {reply.status} for {url[:100]}", "try again later")
|
|
132
|
+
attempt += 1
|
|
133
|
+
self._sleep(min(reply.retry_after if reply.retry_after is not None else 5.0 * attempt, MAX_RETRY_WAIT))
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Draft text -> sections and paragraph blocks, by the `rfc` collection's `.txt` segmenter.
|
|
2
|
+
|
|
3
|
+
A draft's `.txt` is the same xml2rfc rendering as an RFC's, so `primarysource.rfc.index.txtseg` (table of
|
|
4
|
+
contents as the answer key, headings found in order, paragraphs re-joined across page breaks) reads it
|
|
5
|
+
unchanged; the one thing a draft adds is its own page furniture, which that segmenter already skips by its
|
|
6
|
+
`[Page N]` footer. The author's `.xml` is not segmented: it is the *submitted* source, not the prepared one, so
|
|
7
|
+
it has no `pn` ids and no section numbers, which is what `rfc.index.rfcxml` needs. See `docs/draft.md`.
|
|
8
|
+
|
|
9
|
+
What is added here:
|
|
10
|
+
|
|
11
|
+
- the text is normalised first (CRLF and lone CR to LF, form feeds and the other `str.splitlines` separators
|
|
12
|
+
removed) so line N of the segmentation is line N of the file, and refs like `draft-x-y-03:100-120` mean what
|
|
13
|
+
they say;
|
|
14
|
+
- hostile text is refused before the segmenter sees it (NUL, over-long lines, over-size files), because its
|
|
15
|
+
regular expressions are written for well-formed RFC text;
|
|
16
|
+
- section ids are the ones a ref can carry: `5.1`, `A.2` for an appendix, a title slug for an unnumbered
|
|
17
|
+
section (`abstract`, `security-considerations`), made unique with `-2`, `-3`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from primarysource.errors import LibError
|
|
28
|
+
from primarysource.rfc.index import txtseg
|
|
29
|
+
|
|
30
|
+
PARSER_VERSION = "2"
|
|
31
|
+
MAX_TEXT_BYTES = 8_000_000
|
|
32
|
+
MAX_LINE = 2_000
|
|
33
|
+
MAX_LINES = 60_000
|
|
34
|
+
|
|
35
|
+
_SEPARATORS = str.maketrans({c: " " for c in "\x0b\x1c\x1d\x1e\x85
"} | {"\f": None})
|
|
36
|
+
_SLUG = re.compile(r"[^a-z0-9]+")
|
|
37
|
+
# The running header of a draft's pages: "Internet-Draft Short Title November 2026". The rfc segmenter skips
|
|
38
|
+
# the same line in an RFC ("RFC 9000 QUIC May 2021") and re-joins the paragraph the page break cut, so the
|
|
39
|
+
# header is rewritten to that spelling, column for column, rather than blanked (which would split the paragraph).
|
|
40
|
+
_PAGE_HEADER = re.compile(r"^Internet-Draft( {2,}.*\S {2,}\S+ \d{4}\s*)$")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class Block:
|
|
45
|
+
"""One paragraph, list, figure or table of a section, with its 1-based line range in the normalised text."""
|
|
46
|
+
|
|
47
|
+
text: str
|
|
48
|
+
kind: str
|
|
49
|
+
start: int
|
|
50
|
+
end: int
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class DSection:
|
|
55
|
+
"""`id` is what a ref's `§` carries; `num` is the number the draft prints (`5.1`, `Appendix A`, or '')."""
|
|
56
|
+
|
|
57
|
+
id: str
|
|
58
|
+
num: str
|
|
59
|
+
title: str
|
|
60
|
+
depth: int
|
|
61
|
+
head_line: int
|
|
62
|
+
end_line: int
|
|
63
|
+
blocks: tuple[Block, ...]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class Segmented:
|
|
68
|
+
sections: tuple[DSection, ...]
|
|
69
|
+
lines: int
|
|
70
|
+
method: str
|
|
71
|
+
notes: tuple[str, ...]
|
|
72
|
+
|
|
73
|
+
def section(self, section_id: str) -> DSection | None:
|
|
74
|
+
for candidate in self.sections:
|
|
75
|
+
if candidate.id == section_id:
|
|
76
|
+
return candidate
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
def to_json(self) -> str:
|
|
80
|
+
return json.dumps(
|
|
81
|
+
{
|
|
82
|
+
"parser": PARSER_VERSION,
|
|
83
|
+
"lines": self.lines,
|
|
84
|
+
"method": self.method,
|
|
85
|
+
"notes": list(self.notes),
|
|
86
|
+
"sections": [
|
|
87
|
+
{
|
|
88
|
+
"id": s.id,
|
|
89
|
+
"num": s.num,
|
|
90
|
+
"title": s.title,
|
|
91
|
+
"depth": s.depth,
|
|
92
|
+
"head": s.head_line,
|
|
93
|
+
"end": s.end_line,
|
|
94
|
+
"blocks": [[b.text, b.kind, b.start, b.end] for b in s.blocks],
|
|
95
|
+
}
|
|
96
|
+
for s in self.sections
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
ensure_ascii=False,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def from_json(text: str) -> Segmented | None:
|
|
104
|
+
"""The cached segmentation, or None when it is damaged or was made by another parser version (the caller
|
|
105
|
+
then segments the stored text again)."""
|
|
106
|
+
try:
|
|
107
|
+
raw: dict[str, Any] = json.loads(text)
|
|
108
|
+
if raw["parser"] != PARSER_VERSION:
|
|
109
|
+
return None
|
|
110
|
+
sections = tuple(
|
|
111
|
+
DSection(
|
|
112
|
+
str(s["id"]),
|
|
113
|
+
str(s["num"]),
|
|
114
|
+
str(s["title"]),
|
|
115
|
+
int(s["depth"]),
|
|
116
|
+
int(s["head"]),
|
|
117
|
+
int(s["end"]),
|
|
118
|
+
tuple(Block(str(b[0]), str(b[1]), int(b[2]), int(b[3])) for b in s["blocks"]),
|
|
119
|
+
)
|
|
120
|
+
for s in raw["sections"]
|
|
121
|
+
)
|
|
122
|
+
return Segmented(sections, int(raw["lines"]), str(raw["method"]), tuple(str(n) for n in raw["notes"]))
|
|
123
|
+
except (ValueError, KeyError, TypeError, IndexError):
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def decode(data: bytes) -> tuple[str, str]:
|
|
128
|
+
"""Bytes of a draft's `.txt` -> (text, encoding). UTF-8 if it is, else Latin-1, which the oldest drafts are;
|
|
129
|
+
a NUL byte means this is not a text draft at all."""
|
|
130
|
+
if len(data) > MAX_TEXT_BYTES:
|
|
131
|
+
raise LibError(f"a draft text of {len(data):,} bytes is over the {MAX_TEXT_BYTES:,} byte limit", "")
|
|
132
|
+
if b"\x00" in data:
|
|
133
|
+
raise LibError("the file holds NUL bytes: this is not a text draft", "")
|
|
134
|
+
try:
|
|
135
|
+
return data.decode("utf-8"), "utf-8"
|
|
136
|
+
except UnicodeDecodeError:
|
|
137
|
+
return data.decode("latin-1"), "latin-1"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def normalise(text: str) -> list[str]:
|
|
141
|
+
"""Lines of `text` such that line N is the Nth newline-separated line of the file, with the BOM, form feeds and
|
|
142
|
+
odd separators gone. Raises a LibError for a line or a file the segmenter should not be handed."""
|
|
143
|
+
flat = text.lstrip("").replace("\r\n", "\n").replace("\r", "\n").translate(_SEPARATORS)
|
|
144
|
+
lines = flat.split("\n")
|
|
145
|
+
if len(lines) > MAX_LINES:
|
|
146
|
+
raise LibError(f"{len(lines):,} lines is over the {MAX_LINES:,} line limit for a draft", "")
|
|
147
|
+
longest = max((len(line) for line in lines), default=0)
|
|
148
|
+
if longest > MAX_LINE:
|
|
149
|
+
raise LibError(
|
|
150
|
+
f"a line of {longest:,} characters is over the {MAX_LINE:,} limit: this is not a text draft", ""
|
|
151
|
+
)
|
|
152
|
+
return [_PAGE_HEADER.sub(lambda m: "RFC 0" + " " * 9 + m.group(1), line) for line in lines]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def section_id(num: str, title: str, used: set[str]) -> str:
|
|
156
|
+
"""A ref-safe, unique id for a section."""
|
|
157
|
+
if num.startswith("Appendix "):
|
|
158
|
+
base = num.split(" ", 1)[1]
|
|
159
|
+
elif num:
|
|
160
|
+
base = num
|
|
161
|
+
else:
|
|
162
|
+
base = _SLUG.sub("-", title.lower()).strip("-")[:60] or "section"
|
|
163
|
+
found, n = base, 2
|
|
164
|
+
while found in used:
|
|
165
|
+
found, n = f"{base}-{n}", n + 1
|
|
166
|
+
used.add(found)
|
|
167
|
+
return found
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def segment(text: str) -> Segmented:
|
|
171
|
+
"""Sections and blocks of a draft's text. Never returns nothing: a draft with no headings is one section."""
|
|
172
|
+
lines = normalise(text)
|
|
173
|
+
parsed, quality = txtseg.segment("\n".join(lines), "draft")
|
|
174
|
+
shift = 0
|
|
175
|
+
if any("no headings" in note for note in quality.notes):
|
|
176
|
+
# the rfc segmenter treats line 1 as the heading of its single "Entire document" section and drops it;
|
|
177
|
+
# one blank line in front gives that role to nothing, and every line number is then one too high
|
|
178
|
+
parsed, quality = txtseg.segment("\n" + "\n".join(lines), "draft")
|
|
179
|
+
shift = 1
|
|
180
|
+
used: set[str] = set()
|
|
181
|
+
sections: list[DSection] = []
|
|
182
|
+
for s in parsed.sections:
|
|
183
|
+
blocks = tuple(
|
|
184
|
+
Block(b.text, b.kind, b.start_line - shift, b.end_line - shift) for b in s.blocks if b.text.strip()
|
|
185
|
+
)
|
|
186
|
+
sections.append(
|
|
187
|
+
DSection(
|
|
188
|
+
section_id(s.sec_num, s.title, used), s.sec_num, s.title, s.depth,
|
|
189
|
+
max(1, s.head_line - shift), max(1, s.end_line - shift), blocks,
|
|
190
|
+
)
|
|
191
|
+
) # fmt: skip
|
|
192
|
+
return Segmented(tuple(sections), len(lines), quality.method, tuple(quality.notes))
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""The MCP server for the draft collection: `primarysource mcp --collection draft`.
|
|
2
|
+
|
|
3
|
+
Imported only when served, so `mcp` (the `[mcp]` extra) is needed only then. Tools carry the `draft_` prefix,
|
|
4
|
+
answer in text with the fixed footer, and turn a `LibError` into an MCP tool error (`isError`) whose text names
|
|
5
|
+
the remedy. Every tool reads local files only: none writes, none fetches. Drafts arrive by
|
|
6
|
+
`primarysource draft import`, which is a command a person runs, not a tool an agent calls.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from mcp.server.mcpserver import MCPServer
|
|
15
|
+
from mcp.types import ToolAnnotations
|
|
16
|
+
|
|
17
|
+
from primarysource import __version__
|
|
18
|
+
from primarysource.draft import views
|
|
19
|
+
from primarysource.draft.store import Store
|
|
20
|
+
from primarysource.scaffold import add_tools
|
|
21
|
+
|
|
22
|
+
READ_ONLY = ToolAnnotations(read_only_hint=True, idempotent_hint=True, destructive_hint=False, open_world_hint=False)
|
|
23
|
+
|
|
24
|
+
INSTRUCTIONS = (
|
|
25
|
+
"IETF Internet-Drafts from the user's own imported copies: documents that exist only as drafts (RTR version 2 "
|
|
26
|
+
"and the ASPA PDU, ASPA verification, LibrePGP, the TURN REST API). A draft is not a standard: it has "
|
|
27
|
+
"revisions, expires six months after its last one, and is replaced or becomes an RFC. Every result names the "
|
|
28
|
+
"revision it read and, when the draft is dead (expired, replaced, withdrawn, published as an RFC) or the "
|
|
29
|
+
"revision is not the latest, says so in a banner line and in the warning: footer. Never quote or cite a "
|
|
30
|
+
"draft without reading that. Cite only as 'Work in Progress' with the cite: line. Loop: draft_list -> "
|
|
31
|
+
"draft_info('<name>') -> draft_search('<words>') or draft_outline('<name>') -> draft_read('<ref>') -> "
|
|
32
|
+
"draft_cite('<ref>'). Refs: 'draft-ietf-sidrops-8210bis-17§5.1¶2'; without the -17 the latest stored revision "
|
|
33
|
+
"is read and the result says so. If a draft is not stored, the error says which command imports it."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _library() -> Store:
|
|
38
|
+
return Store()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def draft_read(ref: str, max_tokens: int = 2000) -> str:
|
|
42
|
+
"""Read a stored Internet-Draft by ref, cut at paragraph boundaries within max_tokens. A 'next: <ref>' footer
|
|
43
|
+
line is the ref that reads the next page: pass it back as ref. Read the banner and warning: lines first: they
|
|
44
|
+
say if the draft is expired, replaced, withdrawn or an RFC now, and if the revision is not the latest.
|
|
45
|
+
|
|
46
|
+
ref: 'draft-ietf-sidrops-8210bis-17' (abstract and outline) | '...-17§5.1' (a section) | '...-17§5.1¶2' (from
|
|
47
|
+
its 2nd paragraph) | '...-17:100-120' (lines of the text) | without '-17' the latest stored revision, said so.
|
|
48
|
+
Sections are numbered as the draft prints them; 'A.1' is Appendix A.1; unnumbered ones are 'abstract',
|
|
49
|
+
'security-considerations'."""
|
|
50
|
+
return views.read(_library(), ref, max_tokens=max_tokens)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def draft_outline(name: str) -> str:
|
|
54
|
+
"""List a stored draft's sections, each as the ref that reads it with its paragraph count. name:
|
|
55
|
+
'draft-ietf-sidrops-8210bis' or '...-17'."""
|
|
56
|
+
return views.outline(_library(), name)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def draft_search(query: str, name: str | None = None, limit: int = 10) -> str:
|
|
60
|
+
"""Search the sections of the stored drafts (each draft's latest stored revision) by words, best first. One
|
|
61
|
+
line per hit: '<ref> <section>: <snippet>', with [expired], [replaced] and so on after hits in a draft that
|
|
62
|
+
is not alive. name: restrict to one draft or one revision. limit: 1 to 50."""
|
|
63
|
+
return views.search(_library(), query, limit=limit, name=name)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def draft_cite(ref: str) -> str:
|
|
67
|
+
"""The citation for a draft ref: authors, title, 'Work in Progress, Internet-Draft', name-revision, date, URL
|
|
68
|
+
and section, with a span: hash of the cited text. Drafts may be cited only as work in progress (RFC 2026
|
|
69
|
+
§2.2); the warning: line says if this one is dead."""
|
|
70
|
+
return views.cite(_library(), ref)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def draft_info(name: str) -> str:
|
|
74
|
+
"""Standing of a draft: datatracker state, latest revision, expiry, what replaced it, whether it became an
|
|
75
|
+
RFC, revisions stored, licence legend and pins. Call this before relying on a draft."""
|
|
76
|
+
return views.info(_library(), name)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def draft_list() -> str:
|
|
80
|
+
"""List the tracked and stored drafts with stored revisions, latest known revision, state and replacement."""
|
|
81
|
+
return views.list_tracked(_library())
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _build() -> Any:
|
|
85
|
+
tools: list[Callable[..., str]] = [draft_read, draft_outline, draft_search, draft_cite, draft_info, draft_list]
|
|
86
|
+
built = MCPServer("primarysource-draft", version=__version__, instructions=INSTRUCTIONS)
|
|
87
|
+
add_tools(built, tools, annotations=READ_ONLY)
|
|
88
|
+
return built
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
server = _build()
|