rfclib 0.2.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.
Files changed (48) hide show
  1. rfclib/__init__.py +3 -0
  2. rfclib/__main__.py +6 -0
  3. rfclib/cite.py +204 -0
  4. rfclib/cli.py +848 -0
  5. rfclib/config.py +119 -0
  6. rfclib/errata.py +203 -0
  7. rfclib/fsutil.py +82 -0
  8. rfclib/graph.py +213 -0
  9. rfclib/index/__init__.py +1 -0
  10. rfclib/index/chunker.py +268 -0
  11. rfclib/index/embed.py +166 -0
  12. rfclib/index/hybrid.py +305 -0
  13. rfclib/index/lexical.py +223 -0
  14. rfclib/index/rfcxml.py +673 -0
  15. rfclib/index/search.py +496 -0
  16. rfclib/index/txtseg.py +516 -0
  17. rfclib/library.py +285 -0
  18. rfclib/mcp/__init__.py +1 -0
  19. rfclib/mcp/server.py +327 -0
  20. rfclib/packs/__init__.py +1 -0
  21. rfclib/packs/builder.py +228 -0
  22. rfclib/packs/collections/all-current-txt.json +10 -0
  23. rfclib/packs/collections/essentials.json +19 -0
  24. rfclib/packs/collections/fyi-current.json +10 -0
  25. rfclib/packs/collections/standards-current.json +10 -0
  26. rfclib/packs/collections/year-2025.json +10 -0
  27. rfclib/packs/collections/year-2026.json +10 -0
  28. rfclib/packs/collections.py +163 -0
  29. rfclib/packs/docsetexport.py +163 -0
  30. rfclib/packs/epub.py +159 -0
  31. rfclib/packs/export.py +306 -0
  32. rfclib/packs/htmlexport.py +82 -0
  33. rfclib/packs/serve.py +143 -0
  34. rfclib/packs/zimexport.py +171 -0
  35. rfclib/query.py +466 -0
  36. rfclib/subjects.py +448 -0
  37. rfclib/sync/__init__.py +1 -0
  38. rfclib/sync/downloader.py +129 -0
  39. rfclib/sync/manifest.py +163 -0
  40. rfclib/sync/scanner.py +49 -0
  41. rfclib/sync/state.py +445 -0
  42. rfclib/views.py +1050 -0
  43. rfclib-0.2.0.dist-info/METADATA +511 -0
  44. rfclib-0.2.0.dist-info/RECORD +48 -0
  45. rfclib-0.2.0.dist-info/WHEEL +4 -0
  46. rfclib-0.2.0.dist-info/entry_points.txt +2 -0
  47. rfclib-0.2.0.dist-info/licenses/LICENSE +21 -0
  48. rfclib-0.2.0.dist-info/licenses/NOTICE +24 -0
rfclib/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """rfclib: local RFC library with official RFC Editor sync and MCP interface."""
2
+
3
+ __version__ = "0.2.0"
rfclib/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow `python -m rfclib ...` as an alias for the `rfclib` console script."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
rfclib/cite.py ADDED
@@ -0,0 +1,204 @@
1
+ """Citation primitives: reference the spec, do not restate it.
2
+
3
+ Why this module exists: prose written *about* an RFC inside a library is a
4
+ second, uncheckable copy of the spec. It drifts when the RFC is updated, it
5
+ drifts when someone misremembers it, and nothing ever notices. A citation —
6
+ document, section, canonical URL, hash of the exact span — stays checkable
7
+ forever, and check() re-verifies it in a lookup.
8
+
9
+ So the policy this pushes toward:
10
+ * cite the section for a rule (`RFC 2658 section 3.2`), never a summary of it;
11
+ * quote verbatim (quote()) only when the exact wording is load-bearing, and
12
+ carry the span hash so an upstream change becomes detectable;
13
+ * do not paraphrase normative text (MUST/SHOULD sentences, ABNF, wire
14
+ formats) into comments, docstrings or identifiers. That is the copy that
15
+ rots silently, so lint() flags near-verbatim prose and uncited normative
16
+ claims instead of pretending they are documentation.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ import sqlite3
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+
26
+ from . import query as q
27
+ from .index import search as _s
28
+
29
+ NORM_KEYWORDS = ("MUST NOT", "MUST", "SHALL NOT", "SHALL", "SHOULD NOT",
30
+ "SHOULD NOT", "SHOULD", "MAY", "REQUIRED")
31
+ _WORDS = re.compile(r"[A-Za-z0-9][A-Za-z0-9.'\-_]*")
32
+ _MONTHS = ["jan", "feb", "mar", "apr", "may", "jun",
33
+ "jul", "aug", "sep", "oct", "nov", "dec"]
34
+
35
+
36
+ def _now() -> str:
37
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
38
+
39
+
40
+ def label(doc_id: str) -> str:
41
+ """'RFC2167' -> 'RFC 2167' (how a human writes it); 'BCP14' -> 'BCP 14'."""
42
+ m = re.fullmatch(r"(RFC|BCP|STD|FYI)(\d+)", doc_id)
43
+ return f"{m.group(1)} {int(m.group(2))}" if m else doc_id
44
+
45
+
46
+ def errata_note(conn: sqlite3.Connection, doc_id: str, sec: str = "") -> str:
47
+ """Errata status for a doc or one section. Distinguishes 'none' from
48
+ 'nobody looked' — asserting zero verified errata when the service was
49
+ never queried would be a false assurance."""
50
+ rows = conn.execute(
51
+ "SELECT status, section FROM errata WHERE doc_id=?",
52
+ (doc_id,)).fetchall()
53
+ if not rows:
54
+ from .errata import bulk_synced_at
55
+ at = bulk_synced_at(conn)
56
+ return (f"errata: none (as of {at[:10]})" if at
57
+ else f"errata: not cached (errata(ref='{doc_id}'))")
58
+ verified = [r for r in rows if r["status"] == "Verified"]
59
+ if not verified:
60
+ return f"errata: 0 verified ({len(rows)} on file)"
61
+ if sec:
62
+ want = _s.norm_sec(sec)
63
+ hit = [r for r in verified
64
+ if want and want in (r["section"] or "").replace("\n", " ")]
65
+ if hit:
66
+ # the exact call, so an agent that has not loaded the errata
67
+ # tool's schema copies the right argument name
68
+ return (f"errata: {len(hit)} VERIFIED on this section — read "
69
+ f"them before implementing: errata(ref='{doc_id}§{want}')")
70
+ return (f"errata: {len(verified)} verified elsewhere, "
71
+ f"none on this section")
72
+ return f"errata: {len(verified)} verified"
73
+
74
+
75
+ def _span(conn: sqlite3.Connection, doc_id: str, section: str | None,
76
+ lines: str | None) -> dict:
77
+ """Line span + anchor for a section reference, or an explicit 'a-b' line
78
+ range when the caller has no section number to offer."""
79
+ if section:
80
+ if not conn.execute("SELECT 1 FROM chunks WHERE doc_id=? LIMIT 1",
81
+ (doc_id,)).fetchone():
82
+ return {"ok": False, "reason": f"{doc_id} is not section-indexed "
83
+ "(run `rfclib index build`)"}
84
+ span = q._resolve_section_arg(conn, doc_id, section)
85
+ if not span.get("ok"):
86
+ return {"ok": False, "reason": span.get("reason"),
87
+ "suggestions": span.get("suggestions", []),
88
+ "outline": span.get("outline", [])}
89
+ return {"ok": True, "sec_num": span["sec_num"],
90
+ "sec_title": span.get("sec_title", ""),
91
+ "start_line": span["start_line"], "end_line": span["end_line"],
92
+ "frag": span.get("url_frag", "")}
93
+ if lines:
94
+ m = re.fullmatch(r"(\d+)\s*[-:]\s*(\d+)", lines.strip())
95
+ if not m:
96
+ return {"ok": False,
97
+ "reason": f"bad line range {lines!r} (want '1240-1288')"}
98
+ return {"ok": True, "sec_num": "", "sec_title": "",
99
+ "start_line": int(m.group(1)), "end_line": int(m.group(2)),
100
+ "frag": ""}
101
+ return {"ok": True, "sec_num": "", "sec_title": "", "start_line": 1,
102
+ "end_line": 0, "frag": ""}
103
+
104
+
105
+ def span_text(conn: sqlite3.Connection, doc_id: str, *, mirror_root: Path,
106
+ section: str | None = None, lines: str | None = None,
107
+ mode: str = "clean") -> tuple[str, dict]:
108
+ """The exact text of a span plus its resolved anchor metadata.
109
+
110
+ mode='clean' (default) is the canonical form span_hash is defined over;
111
+ mode='raw' is byte-exact against the RFC Editor file.
112
+ """
113
+ from .index.chunker import strip_page_furniture
114
+ text, served, _p = q._served_text(conn, doc_id, "txt", mirror_root)
115
+ sp = _span(conn, doc_id, section, lines)
116
+ if text is None:
117
+ return "", dict(sp, ok=False, reason="no local text copy")
118
+ if not sp.get("ok"):
119
+ return "", sp
120
+ ls = text.splitlines()
121
+ end = sp["end_line"] or len(ls)
122
+ body = "\n".join(ls[sp["start_line"] - 1:end])
123
+ if mode == "clean":
124
+ body = strip_page_furniture(body)
125
+ return body, dict(sp, ok=True, served_via=served, total_lines=len(ls))
126
+
127
+
128
+ def _authors_meta(conn, meta) -> str:
129
+ return " and ".join(meta.get("authors") or [])
130
+
131
+
132
+ def cite(conn: sqlite3.Connection, doc_id: str, section: str | None = None,
133
+ lines: str | None = None, style: str = "verbose", *,
134
+ mirror_root: Path | None = None) -> str:
135
+ """A canonical, checkable reference — the cheap substitute for pasting
136
+ spec text into code.
137
+
138
+ style: verbose (default) | short | markdown | comment | bibtex.
139
+ 'verbose' and 'comment' carry the span hash when mirror_root is given, so
140
+ the citation can later be re-verified with verify().
141
+ """
142
+ row = q.get_doc_row(conn, doc_id)
143
+ meta = q._row_to_meta(conn, row)
144
+ sp = _span(conn, doc_id, section, lines)
145
+ if not sp.get("ok") and style in ("verbose", "short", "comment",
146
+ "markdown"):
147
+ cands = ", ".join(sp.get("suggestions", [])) or "see get_toc"
148
+ return (f"CANNOT CITE {label(doc_id)} \u00a7{section}: {sp['reason']}\n"
149
+ f"candidates: {cands}")
150
+ sec = sp.get("sec_num", "")
151
+ title = meta.get("title") or ""
152
+ date = (meta.get("pub_date") or "")[:7]
153
+ url = q.anchor_url(meta, sp.get("frag") or "", q.resolve(conn, doc_id))
154
+ secpart = f" \u00a7{sec}" + (f' "{sp["sec_title"]}"'
155
+ if sp.get("sec_title") else "")
156
+ linepart = (f" lines {sp['start_line']}-{sp['end_line']}" if lines else "")
157
+ status = meta.get("current_status") or meta.get("status") or ""
158
+ successor = ", ".join(meta["relations"].get("obsoleted_by", []))
159
+ state = "current" if meta["is_current"] else \
160
+ f"OBSOLETE by {successor or 'unknown'}"
161
+
162
+ if style == "short":
163
+ return f"{label(doc_id)}{secpart}{linepart}"
164
+ if style == "markdown":
165
+ return f"[{label(doc_id)}{secpart}{linepart}]({url})"
166
+ if style == "bibtex":
167
+ num = re.sub(r"\D", "", doc_id)
168
+ series = ("Request for Comments" if doc_id.startswith("RFC")
169
+ else {"BCP": "Best Current Practice",
170
+ "STD": "Internet Standards",
171
+ "FYI": "FYI"}.get(doc_id[:3], "Request for Comments"))
172
+ month = (_MONTHS[int(date[5:7]) - 1]
173
+ if len(date) >= 7 and date[5:7].isdigit() else "")
174
+ rows = [f"@misc{{rfc{num},",
175
+ f" series = {{{series}}},",
176
+ f" number = {num},",
177
+ f" howpublished = {{{label(doc_id)}}},",
178
+ " publisher = {RFC Editor},",
179
+ f" doi = {{{meta.get('doi') or ''}}},",
180
+ f" url = {{{meta.get('canonical_url') or ''}}},",
181
+ f" author = {{{_authors_meta(conn, meta) or ''}}},",
182
+ f" title = {{{{{title}}}}},"]
183
+ if year := date[:4]:
184
+ rows.append(f" year = {year},")
185
+ if month:
186
+ rows.append(f" month = {month},")
187
+ rows.append("}")
188
+ return "\n".join(rows)
189
+
190
+ sha = ""
191
+ if mirror_root is not None and (sec or lines):
192
+ body, _ = span_text(conn, doc_id, mirror_root=mirror_root,
193
+ section=section, lines=lines)
194
+ sha = f" | span sha256:{q.span_hash(body)}"
195
+ tail = (f"{label(doc_id)}{secpart}{linepart} — {title} ({date}, "
196
+ f"{status}, {state})\n{url} | "
197
+ f"{errata_note(conn, doc_id, sec)}{sha}")
198
+ if style == "comment":
199
+ return "\n".join(("# " + ln) if ln else "#" for ln in tail.split("\n"))
200
+ if not meta["is_current"]:
201
+ tail += (f"\nWARNING: {label(doc_id)} is obsolete"
202
+ + (f"; cite {successor} instead" if successor else ""))
203
+ return tail
204
+