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.
Files changed (303) hide show
  1. primarysource/__init__.py +5 -0
  2. primarysource/bbf/__init__.py +56 -0
  3. primarysource/bbf/_xml.py +63 -0
  4. primarysource/bbf/catalogue.json +777 -0
  5. primarysource/bbf/catalogue.py +137 -0
  6. primarysource/bbf/cli.py +79 -0
  7. primarysource/bbf/datamodel.py +281 -0
  8. primarysource/bbf/importer.py +151 -0
  9. primarysource/bbf/library.py +380 -0
  10. primarysource/bbf/server.py +72 -0
  11. primarysource/budget.py +63 -0
  12. primarysource/c2sp/__init__.py +53 -0
  13. primarysource/c2sp/_fetch.py +5 -0
  14. primarysource/c2sp/catalogue.json +231 -0
  15. primarysource/c2sp/catalogue.py +95 -0
  16. primarysource/c2sp/cli.py +62 -0
  17. primarysource/c2sp/importer.py +90 -0
  18. primarysource/c2sp/library.py +309 -0
  19. primarysource/c2sp/markdown.py +31 -0
  20. primarysource/c2sp/server.py +68 -0
  21. primarysource/cavp/__init__.py +42 -0
  22. primarysource/cavp/cli.py +84 -0
  23. primarysource/cavp/data/vectors.toml +47 -0
  24. primarysource/cavp/importer.py +156 -0
  25. primarysource/cavp/model.py +279 -0
  26. primarysource/cavp/parse.py +269 -0
  27. primarysource/cavp/paths.py +17 -0
  28. primarysource/cavp/server.py +67 -0
  29. primarysource/cavp/tools.py +169 -0
  30. primarysource/cli.py +107 -0
  31. primarysource/cursor.py +82 -0
  32. primarysource/cve/__init__.py +5 -0
  33. primarysource/cve/__main__.py +6 -0
  34. primarysource/cve/cli.py +144 -0
  35. primarysource/cve/collection.py +20 -0
  36. primarysource/cve/config.py +58 -0
  37. primarysource/cve/errors.py +16 -0
  38. primarysource/cve/kernel.py +232 -0
  39. primarysource/cve/mcp.py +139 -0
  40. primarysource/cve/parse.py +318 -0
  41. primarysource/cve/py.typed +0 -0
  42. primarysource/cve/service.py +760 -0
  43. primarysource/cve/store.py +214 -0
  44. primarysource/cve/sync.py +438 -0
  45. primarysource/cve/views.py +304 -0
  46. primarysource/debpolicy/__init__.py +16 -0
  47. primarysource/debpolicy/catalogue.py +263 -0
  48. primarysource/debpolicy/cli.py +12 -0
  49. primarysource/debpolicy/server.py +11 -0
  50. primarysource/draft/__init__.py +11 -0
  51. primarysource/draft/catalogue.py +117 -0
  52. primarysource/draft/cli.py +149 -0
  53. primarysource/draft/collection.py +21 -0
  54. primarysource/draft/datatracker.py +158 -0
  55. primarysource/draft/importer.py +163 -0
  56. primarysource/draft/licence.py +89 -0
  57. primarysource/draft/names.py +64 -0
  58. primarysource/draft/net.py +133 -0
  59. primarysource/draft/segment.py +192 -0
  60. primarysource/draft/server.py +91 -0
  61. primarysource/draft/status.py +146 -0
  62. primarysource/draft/store.py +199 -0
  63. primarysource/draft/tracked.json +104 -0
  64. primarysource/draft/views.py +430 -0
  65. primarysource/draft/xmlmeta.py +74 -0
  66. primarysource/ecma/__init__.py +52 -0
  67. primarysource/ecma/_pdf.py +143 -0
  68. primarysource/ecma/catalogue.json +22 -0
  69. primarysource/ecma/catalogue.py +100 -0
  70. primarysource/ecma/cli.py +67 -0
  71. primarysource/ecma/identify.py +25 -0
  72. primarysource/ecma/importer.py +108 -0
  73. primarysource/ecma/library.py +240 -0
  74. primarysource/ecma/server.py +67 -0
  75. primarysource/errors.py +29 -0
  76. primarysource/fetch.py +360 -0
  77. primarysource/fsutil.py +150 -0
  78. primarysource/gitdocs/__init__.py +15 -0
  79. primarysource/gitdocs/catalogue.py +116 -0
  80. primarysource/gitdocs/cli.py +12 -0
  81. primarysource/gitdocs/server.py +8 -0
  82. primarysource/home.py +65 -0
  83. primarysource/iana/__init__.py +32 -0
  84. primarysource/iana/catalogue.json +244 -0
  85. primarysource/iana/catalogue.py +69 -0
  86. primarysource/iana/cli.py +69 -0
  87. primarysource/iana/importer.py +157 -0
  88. primarysource/iana/model.py +325 -0
  89. primarysource/iana/reader.py +315 -0
  90. primarysource/iana/server.py +67 -0
  91. primarysource/iana/store.py +83 -0
  92. primarysource/iana/xmlsafe.py +47 -0
  93. primarysource/identify.py +186 -0
  94. primarysource/ieee/__init__.py +58 -0
  95. primarysource/ieee/_html.py +49 -0
  96. primarysource/ieee/catalogue.json +74 -0
  97. primarysource/ieee/catalogue.py +123 -0
  98. primarysource/ieee/cli.py +79 -0
  99. primarysource/ieee/identify.py +24 -0
  100. primarysource/ieee/ieee802.py +47 -0
  101. primarysource/ieee/importer.py +305 -0
  102. primarysource/ieee/posix.py +233 -0
  103. primarysource/ieee/server.py +79 -0
  104. primarysource/index.py +446 -0
  105. primarysource/iso/__init__.py +51 -0
  106. primarysource/iso/cli.py +98 -0
  107. primarysource/iso/designation.py +95 -0
  108. primarysource/iso/identify.py +100 -0
  109. primarysource/iso/importer.py +224 -0
  110. primarysource/iso/library.py +249 -0
  111. primarysource/iso/server.py +69 -0
  112. primarysource/itu/__init__.py +41 -0
  113. primarysource/itu/__main__.py +8 -0
  114. primarysource/itu/asn1mod.py +478 -0
  115. primarysource/itu/catalog.py +472 -0
  116. primarysource/itu/cli.py +445 -0
  117. primarysource/itu/collection.py +20 -0
  118. primarysource/itu/corpus.py +118 -0
  119. primarysource/itu/errata.py +16 -0
  120. primarysource/itu/formal.py +565 -0
  121. primarysource/itu/identify.py +26 -0
  122. primarysource/itu/index.py +490 -0
  123. primarysource/itu/mcp.py +474 -0
  124. primarysource/itu/py.typed +0 -0
  125. primarysource/itu/sources.py +600 -0
  126. primarysource/itu/text.py +609 -0
  127. primarysource/kconfig/__init__.py +16 -0
  128. primarysource/kconfig/catalogue.py +98 -0
  129. primarysource/kconfig/cli.py +12 -0
  130. primarysource/kconfig/server.py +8 -0
  131. primarysource/model.py +63 -0
  132. primarysource/nist/__init__.py +5 -0
  133. primarysource/nist/collection.py +46 -0
  134. primarysource/nist/data/acquired-nist.json +1657 -0
  135. primarysource/nist/data/nist-catalog.json +12090 -0
  136. primarysource/nist/data/sources.toml +1996 -0
  137. primarysource/nist/identify.py +41 -0
  138. primarysource/nist/ingest/__init__.py +1 -0
  139. primarysource/nist/ingest/acquire_nist.py +128 -0
  140. primarysource/nist/ingest/controls.py +82 -0
  141. primarysource/nist/ingest/discover_nist.py +218 -0
  142. primarysource/nist/ingest/extract.py +48 -0
  143. primarysource/nist/ingest/fetch.py +211 -0
  144. primarysource/nist/ingest/index.py +14 -0
  145. primarysource/nist/ingest/oscal.py +480 -0
  146. primarysource/nist/ingest/prose_layouts.py +236 -0
  147. primarysource/nist/ingest/sources.py +94 -0
  148. primarysource/nist/ingest/status.py +255 -0
  149. primarysource/nist/models.py +7 -0
  150. primarysource/nist/paths.py +33 -0
  151. primarysource/nist/server/__init__.py +3 -0
  152. primarysource/nist/server/corpus.py +38 -0
  153. primarysource/nist/server/mcp.py +19 -0
  154. primarysource/nist/server/tools.py +35 -0
  155. primarysource/nist/spec.py +38 -0
  156. primarysource/pdfcorpus/__init__.py +16 -0
  157. primarysource/pdfcorpus/corpus.py +159 -0
  158. primarysource/pdfcorpus/fts.py +175 -0
  159. primarysource/pdfcorpus/index.py +70 -0
  160. primarysource/pdfcorpus/models.py +30 -0
  161. primarysource/pdfcorpus/server.py +96 -0
  162. primarysource/pdfcorpus/spec.py +37 -0
  163. primarysource/pdfcorpus/split.py +391 -0
  164. primarysource/pdfcorpus/tools.py +244 -0
  165. primarysource/pep/__init__.py +15 -0
  166. primarysource/pep/catalogue.py +191 -0
  167. primarysource/pep/cli.py +12 -0
  168. primarysource/pep/server.py +8 -0
  169. primarysource/pkgdocs/__init__.py +19 -0
  170. primarysource/pkgdocs/fetch.py +356 -0
  171. primarysource/pkgdocs/index.py +89 -0
  172. primarysource/pkgdocs/model.py +76 -0
  173. primarysource/pkgdocs/readers/__init__.py +1 -0
  174. primarysource/pkgdocs/readers/_common.py +134 -0
  175. primarysource/pkgdocs/readers/asciidoc.py +160 -0
  176. primarysource/pkgdocs/readers/latex.py +315 -0
  177. primarysource/pkgdocs/readers/markdown.py +173 -0
  178. primarysource/pkgdocs/readers/pod.py +132 -0
  179. primarysource/pkgdocs/readers/rst.py +239 -0
  180. primarysource/pkgdocs/readers/scdoc.py +108 -0
  181. primarysource/pkgdocs/runtime.py +552 -0
  182. primarysource/pkgdocs/serving.py +158 -0
  183. primarysource/pkgdocs/store.py +167 -0
  184. primarysource/pms/__init__.py +16 -0
  185. primarysource/pms/catalogue.py +88 -0
  186. primarysource/pms/cli.py +12 -0
  187. primarysource/pms/server.py +8 -0
  188. primarysource/py.typed +0 -0
  189. primarysource/readers/__init__.py +10 -0
  190. primarysource/readers/bookmarks.py +192 -0
  191. primarysource/readers/html.py +1312 -0
  192. primarysource/readers/markdown.py +189 -0
  193. primarysource/readers/pdf.py +187 -0
  194. primarysource/readers/xml.py +230 -0
  195. primarysource/refs.py +87 -0
  196. primarysource/registry.py +161 -0
  197. primarysource/render.py +49 -0
  198. primarysource/rfc/__init__.py +3 -0
  199. primarysource/rfc/__main__.py +6 -0
  200. primarysource/rfc/cite.py +204 -0
  201. primarysource/rfc/cli.py +903 -0
  202. primarysource/rfc/collection.py +19 -0
  203. primarysource/rfc/config.py +138 -0
  204. primarysource/rfc/errata.py +203 -0
  205. primarysource/rfc/fsutil.py +82 -0
  206. primarysource/rfc/graph.py +213 -0
  207. primarysource/rfc/index/__init__.py +1 -0
  208. primarysource/rfc/index/chunker.py +281 -0
  209. primarysource/rfc/index/embed.py +166 -0
  210. primarysource/rfc/index/hybrid.py +305 -0
  211. primarysource/rfc/index/lexical.py +223 -0
  212. primarysource/rfc/index/rfcxml.py +673 -0
  213. primarysource/rfc/index/search.py +508 -0
  214. primarysource/rfc/index/txtseg.py +585 -0
  215. primarysource/rfc/library.py +294 -0
  216. primarysource/rfc/mcp/__init__.py +1 -0
  217. primarysource/rfc/mcp/server.py +381 -0
  218. primarysource/rfc/packs/__init__.py +1 -0
  219. primarysource/rfc/packs/builder.py +228 -0
  220. primarysource/rfc/packs/collections/all-current-txt.json +10 -0
  221. primarysource/rfc/packs/collections/essentials.json +19 -0
  222. primarysource/rfc/packs/collections/fyi-current.json +10 -0
  223. primarysource/rfc/packs/collections/standards-current.json +10 -0
  224. primarysource/rfc/packs/collections/year-2025.json +10 -0
  225. primarysource/rfc/packs/collections/year-2026.json +10 -0
  226. primarysource/rfc/packs/collections.py +163 -0
  227. primarysource/rfc/packs/docsetexport.py +163 -0
  228. primarysource/rfc/packs/epub.py +159 -0
  229. primarysource/rfc/packs/export.py +306 -0
  230. primarysource/rfc/packs/htmlexport.py +82 -0
  231. primarysource/rfc/packs/serve.py +143 -0
  232. primarysource/rfc/packs/zimexport.py +171 -0
  233. primarysource/rfc/query.py +466 -0
  234. primarysource/rfc/subjects.py +457 -0
  235. primarysource/rfc/sync/__init__.py +1 -0
  236. primarysource/rfc/sync/downloader.py +132 -0
  237. primarysource/rfc/sync/manifest.py +162 -0
  238. primarysource/rfc/sync/scanner.py +49 -0
  239. primarysource/rfc/sync/state.py +464 -0
  240. primarysource/rfc/views.py +1067 -0
  241. primarysource/rpmdocs/__init__.py +15 -0
  242. primarysource/rpmdocs/catalogue.py +117 -0
  243. primarysource/rpmdocs/cli.py +12 -0
  244. primarysource/rpmdocs/server.py +8 -0
  245. primarysource/scaffold.py +223 -0
  246. primarysource/serve.py +75 -0
  247. primarysource/settings.py +152 -0
  248. primarysource/usb/__init__.py +5 -0
  249. primarysource/usb/collection.py +37 -0
  250. primarysource/usb/data/acquired.json +2025 -0
  251. primarysource/usb/data/sources-usb.toml +852 -0
  252. primarysource/usb/data/sources.toml +10 -0
  253. primarysource/usb/data/usb-catalog.json +1663 -0
  254. primarysource/usb/data/usb-classification.json +1986 -0
  255. primarysource/usb/data/usb-corpus-build.json +608 -0
  256. primarysource/usb/data/usb-pdf-choice.json +839 -0
  257. primarysource/usb/data/usb-split-measure.json +1803 -0
  258. primarysource/usb/ingest/__init__.py +1 -0
  259. primarysource/usb/ingest/acquire.py +130 -0
  260. primarysource/usb/ingest/discover_usb.py +88 -0
  261. primarysource/usb/ingest/extract.py +48 -0
  262. primarysource/usb/ingest/fetch.py +197 -0
  263. primarysource/usb/ingest/index.py +14 -0
  264. primarysource/usb/ingest/sources.py +85 -0
  265. primarysource/usb/models.py +7 -0
  266. primarysource/usb/paths.py +33 -0
  267. primarysource/usb/server/__init__.py +3 -0
  268. primarysource/usb/server/corpus.py +38 -0
  269. primarysource/usb/server/mcp.py +19 -0
  270. primarysource/usb/server/tools.py +35 -0
  271. primarysource/usb/spec.py +20 -0
  272. primarysource/usbids/__init__.py +56 -0
  273. primarysource/usbids/cli.py +65 -0
  274. primarysource/usbids/importer.py +272 -0
  275. primarysource/usbids/parse.py +283 -0
  276. primarysource/usbids/server.py +55 -0
  277. primarysource/usbids/store.py +80 -0
  278. primarysource/usbids/tools.py +174 -0
  279. primarysource/zim/__init__.py +5 -0
  280. primarysource/zim/__main__.py +8 -0
  281. primarysource/zim/_libzim.py +37 -0
  282. primarysource/zim/catalog.py +594 -0
  283. primarysource/zim/cli.py +555 -0
  284. primarysource/zim/collection.py +41 -0
  285. primarysource/zim/errors.py +100 -0
  286. primarysource/zim/groups.py +30 -0
  287. primarysource/zim/library.py +668 -0
  288. primarysource/zim/manage.py +636 -0
  289. primarysource/zim/markdown.py +1247 -0
  290. primarysource/zim/mcp.py +201 -0
  291. primarysource/zim/purl.py +130 -0
  292. primarysource/zim/py.typed +0 -0
  293. primarysource/zim/refs.py +90 -0
  294. primarysource/zim/service.py +1182 -0
  295. primarysource/zim/snapshot.py +197 -0
  296. primarysource/zim/sync.py +269 -0
  297. primarysource/zim/views.py +230 -0
  298. primarysource-0.1.0.dist-info/METADATA +118 -0
  299. primarysource-0.1.0.dist-info/RECORD +303 -0
  300. primarysource-0.1.0.dist-info/WHEEL +4 -0
  301. primarysource-0.1.0.dist-info/entry_points.txt +2 -0
  302. primarysource-0.1.0.dist-info/licenses/LICENSE +203 -0
  303. primarysource-0.1.0.dist-info/licenses/NOTICE +51 -0
@@ -0,0 +1,158 @@
1
+ """The MCP server and the command line for a pkgdocs collection, built from its `Spec`.
2
+
3
+ Tools carry the collection's prefix (`gitdocs_read`, `pep_search`), answer in text with the fixed footer, and turn a
4
+ `LibError` into an MCP tool error (`isError`) whose text names the remedy. Every tool reads installed files only;
5
+ none fetches or writes (`primarysource <collection> import` is the one command that does, and it is not a tool).
6
+ `mcp` is imported only when a server is built, so the readers and the importer need nothing but the standard
7
+ library.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import sys
14
+ from collections.abc import Sequence
15
+ from typing import Any
16
+
17
+ from primarysource import __version__
18
+ from primarysource.errors import LibError
19
+ from primarysource.pkgdocs.runtime import Library, Spec
20
+ from primarysource.scaffold import add_tools
21
+
22
+ DEFAULT_MAX_TOKENS = 2000
23
+
24
+
25
+ def build_server(spec: Spec, library: Library | None = None) -> Any:
26
+ """An `MCPServer` with read, outline, search, cite, docs and status tools for `spec`."""
27
+ from mcp.server.mcpserver import MCPServer
28
+ from mcp.types import ToolAnnotations
29
+
30
+ lib = library or Library(spec)
31
+ examples = "; ".join(spec.example_refs)
32
+
33
+ def read(ref: str, max_tokens: int = DEFAULT_MAX_TOKENS) -> str:
34
+ return lib.read(ref, max_tokens=max_tokens)
35
+
36
+ def outline(doc: str, start: int = 0, max_tokens: int = 4000) -> str:
37
+ return lib.outline(doc, start=start, max_tokens=max_tokens)
38
+
39
+ def search(query: str, doc_prefix: str = "", limit: int = 10, start: int = 0) -> str:
40
+ return lib.search(query, limit=limit, start=start, doc_prefix=doc_prefix)
41
+
42
+ def cite(ref: str) -> str:
43
+ return lib.cite(ref)
44
+
45
+ def docs(prefix: str = "", limit: int = 200, start: int = 0) -> str:
46
+ return lib.docs(prefix, limit=limit, start=start)
47
+
48
+ def status() -> str:
49
+ return lib.status()
50
+
51
+ read.__doc__ = (
52
+ f"Read {spec.title} text by ref, cut at block boundaries within max_tokens. A 'next: <ref>' footer line is "
53
+ "the ref that reads the next page: pass it back as ref. '¶n' reads from the nth block. The cite: line is the "
54
+ "citation.\n\n"
55
+ f"ref: '<doc>[@<commit>]§<section>[#<anchor>][¶<n>]'; the section is an id, an anchor, a heading or a unique "
56
+ f"part of one. Examples: {examples}"
57
+ )
58
+ outline.__doc__ = (
59
+ "List a document's sections, each with the ref that reads it. doc: a document id (see docs). A "
60
+ "'next: start=N' footer line means call again with start=N (a 0-based offset)."
61
+ )
62
+ search.__doc__ = (
63
+ f"Find sections of {spec.title} by words (all of them, else some, which the relaxed: line says). "
64
+ "One line per "
65
+ "hit: '<ref>: <document> / <section> -- <snippet>'. doc_prefix narrows to document ids starting with it. "
66
+ "A 'next: start=N' footer line means call again with start=N (a 0-based offset)."
67
+ )
68
+ cite.__doc__ = (
69
+ "The citation for a ref (document, section, edition, commit, source file, licence) and its span hash."
70
+ )
71
+ docs.__doc__ = (
72
+ "List document ids and titles, optionally those whose id starts with prefix. A 'next: start=N' footer line "
73
+ "means call again with start=N (a 0-based offset)."
74
+ )
75
+ status.__doc__ = "What this collection pins, what is imported, and its licence."
76
+
77
+ server = MCPServer(f"primarysource-{spec.name}", version=__version__, instructions=spec.instructions)
78
+ annotations = ToolAnnotations(
79
+ read_only_hint=True, idempotent_hint=True, destructive_hint=False, open_world_hint=False
80
+ )
81
+ add_tools(server, (read, outline, search, cite, docs, status), prefix=spec.prefix, annotations=annotations)
82
+ return server
83
+
84
+
85
+ def _parser(spec: Spec) -> argparse.ArgumentParser:
86
+ parser = argparse.ArgumentParser(prog=f"primarysource {spec.name}", description=spec.title)
87
+ commands = parser.add_subparsers(dest="command", required=True)
88
+ imp = commands.add_parser("import", help="fetch the pinned edition (one request per second) and install it")
89
+ imp.add_argument("--tree", help="only this tree")
90
+ imp.add_argument("--commit", help="a pinned edition other than the current one: a label or commit prefix")
91
+ imp.add_argument("--archive", help="read this downloaded archive instead of fetching (needs --tree)")
92
+ imp.add_argument("--allow-unpinned", action="store_true", help="trust the digest of this one fetch")
93
+ commands.add_parser("status", help="what is pinned and imported, and the licence")
94
+ docs = commands.add_parser("docs", help="list documents")
95
+ docs.add_argument("prefix", nargs="?", default="")
96
+ docs.add_argument("--limit", type=int, default=200)
97
+ docs.add_argument("--start", type=int, default=0)
98
+ outline = commands.add_parser("outline", help="a document's sections with their refs")
99
+ outline.add_argument("doc")
100
+ outline.add_argument("--start", type=int, default=0)
101
+ outline.add_argument("--max-tokens", type=int, default=4000)
102
+ read = commands.add_parser("read", help="read by ref")
103
+ read.add_argument("ref")
104
+ read.add_argument("--max-tokens", type=int, default=DEFAULT_MAX_TOKENS)
105
+ search = commands.add_parser("search", help="find sections by words")
106
+ search.add_argument("query")
107
+ search.add_argument("--doc-prefix", default="")
108
+ search.add_argument("--limit", type=int, default=10)
109
+ search.add_argument("--start", type=int, default=0)
110
+ commands.add_parser("cite", help="the citation for a ref").add_argument("ref")
111
+ return parser
112
+
113
+
114
+ def _write(text: str) -> None:
115
+ """UTF-8 to stdout whatever the console's code page is: refs contain `§` and `¶`."""
116
+ stream = getattr(sys.stdout, "buffer", None)
117
+ if stream is not None:
118
+ sys.stdout.flush()
119
+ stream.write(text.encode("utf-8"))
120
+ stream.flush()
121
+ else:
122
+ sys.stdout.write(text)
123
+
124
+
125
+ def run(spec: Spec, argv: Sequence[str], library: Library | None = None) -> int:
126
+ """`primarysource <collection> ...`: exit 0, or 1 with the error and its hint on stderr."""
127
+ args = _parser(spec).parse_args(list(argv))
128
+ lib = library or Library(spec)
129
+ try:
130
+ if args.command == "import":
131
+ for report in lib.import_edition(
132
+ args.tree, args.commit, args.archive, allow_unpinned=args.allow_unpinned
133
+ ):
134
+ skipped = ", ".join(f"{k}: {v}" for k, v in sorted(report.skipped.items())) or "none"
135
+ print(
136
+ f"{report.tree} {report.label} {report.commit[:12]}: {report.docs} documents, {report.sections} "
137
+ f"sections, {report.units} blocks, {report.warnings} reader warnings\n"
138
+ f" files sha256 {report.files_sha256} ({'matches the pin' if report.pinned else 'NOT pinned'})\n"
139
+ f" skipped: {skipped}\n installed at {report.path}"
140
+ )
141
+ return 0
142
+ if args.command == "status":
143
+ text = lib.status()
144
+ elif args.command == "docs":
145
+ text = lib.docs(args.prefix, limit=args.limit, start=args.start)
146
+ elif args.command == "outline":
147
+ text = lib.outline(args.doc, start=args.start, max_tokens=args.max_tokens)
148
+ elif args.command == "read":
149
+ text = lib.read(args.ref, max_tokens=args.max_tokens)
150
+ elif args.command == "search":
151
+ text = lib.search(args.query, limit=args.limit, start=args.start, doc_prefix=args.doc_prefix)
152
+ else:
153
+ text = lib.cite(args.ref)
154
+ _write(text if text.endswith("\n") else text + "\n")
155
+ return 0
156
+ except LibError as exc:
157
+ print(exc.as_text(), file=sys.stderr)
158
+ return 1
@@ -0,0 +1,167 @@
1
+ """An installed edition: parsed documents and a search index under `$PRIMARYSOURCE_HOME/<collection>/`.
2
+
3
+ <collection>/<tree>/<commit>/edition.json what was imported, from where, under which digest
4
+ index.json the documents (id, title, file, meta) and chapter aliases
5
+ docs/<n>.json one parsed document
6
+ index.sqlite the full-text index
7
+
8
+ An edition is written into a staging directory beside its destination and renamed into place, so a reader sees
9
+ the old edition or the new one. What is stored is the parsed sections, not the archive and not the repository;
10
+ nothing in this module or its caller writes under the source tree.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import secrets
18
+ import shutil
19
+ from collections.abc import Mapping, Sequence
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from primarysource.errors import LibError
25
+ from primarysource.pkgdocs import index
26
+ from primarysource.pkgdocs.model import Doc
27
+
28
+ RECORD = "edition.json"
29
+ CATALOGUE = "index.json"
30
+ SEARCH = "index.sqlite"
31
+ _COMMIT = frozenset("0123456789abcdef")
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class Entry:
36
+ """A document as listed in the edition's catalogue."""
37
+
38
+ id: str
39
+ title: str
40
+ file: str
41
+ sections: int
42
+ meta: Mapping[str, str]
43
+
44
+
45
+ class Edition:
46
+ """One installed edition, opened for reading."""
47
+
48
+ def __init__(self, path: Path) -> None:
49
+ self.path = path
50
+ try:
51
+ self.record: dict[str, Any] = json.loads((path / RECORD).read_text(encoding="utf-8"))
52
+ listing = json.loads((path / CATALOGUE).read_text(encoding="utf-8"))
53
+ self.entries: dict[str, Entry] = {
54
+ str(e["id"]): Entry(
55
+ str(e["id"]), str(e["title"]), str(e["file"]), int(e["sections"]), dict(e["meta"])
56
+ )
57
+ for e in listing["docs"]
58
+ }
59
+ self.aliases: dict[str, str] = {str(k): str(v) for k, v in listing["aliases"].items()}
60
+ except (OSError, ValueError, KeyError, TypeError) as exc:
61
+ raise LibError(f"the edition at {path} is damaged ({exc})", "", "import it again") from exc
62
+
63
+ @property
64
+ def commit(self) -> str:
65
+ return str(self.record["commit"])
66
+
67
+ @property
68
+ def label(self) -> str:
69
+ return str(self.record.get("label", ""))
70
+
71
+ def load(self, doc_id: str) -> Doc:
72
+ entry = self.entries.get(doc_id)
73
+ if entry is None:
74
+ raise LibError(f"no document {doc_id!r} in this edition", "list the documents first")
75
+ try:
76
+ return Doc.from_json(json.loads((self.path / "docs" / entry.file).read_text(encoding="utf-8")))
77
+ except (OSError, ValueError) as exc:
78
+ raise LibError(f"document {doc_id!r} is unreadable ({exc})", "", "import the edition again") from exc
79
+
80
+ @property
81
+ def index_path(self) -> Path:
82
+ return self.path / SEARCH
83
+
84
+
85
+ def write_edition(
86
+ base: Path,
87
+ commit: str,
88
+ record: Mapping[str, Any],
89
+ docs: Sequence[Doc],
90
+ aliases: Mapping[str, str] | None = None,
91
+ ) -> Path:
92
+ """Install `docs` as `<base>/<commit>`, replacing an earlier install of the same commit. Returns the path."""
93
+ if not commit or not set(commit) <= _COMMIT:
94
+ raise LibError(f"{commit!r} is not a commit id", "a pin names a full lowercase hex commit")
95
+ base.mkdir(parents=True, exist_ok=True)
96
+ staging = base / f".{commit}.part-{secrets.token_hex(4)}"
97
+ staging.mkdir()
98
+ try:
99
+ (staging / "docs").mkdir()
100
+ listing: list[dict[str, Any]] = []
101
+ for number, doc in enumerate(docs, start=1):
102
+ name = f"{number:05d}.json"
103
+ (staging / "docs" / name).write_text(json.dumps(doc.to_json(), ensure_ascii=False), encoding="utf-8")
104
+ listing.append(
105
+ {
106
+ "id": doc.id,
107
+ "title": doc.title,
108
+ "file": name,
109
+ "sections": len(doc.sections),
110
+ "meta": dict(doc.meta),
111
+ }
112
+ )
113
+ (staging / CATALOGUE).write_text(
114
+ json.dumps({"docs": listing, "aliases": dict(aliases or {})}, ensure_ascii=False, indent=0),
115
+ encoding="utf-8",
116
+ )
117
+ sections = index.build(staging / SEARCH, docs)
118
+ (staging / RECORD).write_text(
119
+ json.dumps({**record, "commit": commit, "docs": len(docs), "sections": sections}, indent=2) + "\n",
120
+ encoding="utf-8",
121
+ )
122
+ target = base / commit
123
+ old = base / f".{commit}.old-{secrets.token_hex(4)}"
124
+ if target.exists():
125
+ os.replace(target, old)
126
+ try:
127
+ os.replace(staging, target)
128
+ except BaseException:
129
+ if old.exists():
130
+ os.replace(old, target)
131
+ raise
132
+ shutil.rmtree(old, ignore_errors=True)
133
+ return target
134
+ finally:
135
+ shutil.rmtree(staging, ignore_errors=True)
136
+
137
+
138
+ def installed(base: Path) -> list[Edition]:
139
+ """Every readable edition under `base`, newest import first. A damaged one is skipped, not hidden by
140
+ raising: `find` reports the damage when it is the one asked for."""
141
+ found: list[Edition] = []
142
+ if not base.is_dir():
143
+ return found
144
+ for child in sorted(base.iterdir()):
145
+ if child.is_dir() and not child.name.startswith(".") and (child / RECORD).is_file():
146
+ try:
147
+ found.append(Edition(child))
148
+ except LibError:
149
+ continue
150
+ found.sort(key=lambda e: str(e.record.get("imported", "")), reverse=True)
151
+ return found
152
+
153
+
154
+ def find(base: Path, commit: str | None, what: str, hint: str) -> Edition:
155
+ """The edition whose commit starts with `commit`, or the newest import when `commit` is None."""
156
+ editions = installed(base)
157
+ if not editions:
158
+ raise LibError(f"{what} is not imported (nothing under {base})", hint)
159
+ if commit is None:
160
+ return editions[0]
161
+ matches = [e for e in editions if e.commit.startswith(commit.lower())]
162
+ if len(matches) == 1:
163
+ return matches[0]
164
+ have = ", ".join(e.commit[:12] for e in editions)
165
+ if not matches:
166
+ raise LibError(f"no imported edition of {what} has a commit starting {commit!r} (have: {have})", hint)
167
+ raise LibError(f"{commit!r} matches more than one edition of {what} ({have})", "give more of the commit")
@@ -0,0 +1,16 @@
1
+ """`pms`: the Gentoo Package Manager Specification, a pinned EAPI release, read from its LaTeX source.
2
+ Served under `pms_`.
3
+
4
+ Stdlib only. `mcp` is imported by `server.py` when a server is built; `primarysource pms ...` runs `cli.py`.
5
+ `docs/pms.md` is the description; `catalogue.py` holds the pin, the licence and the build.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from primarysource.pkgdocs.runtime import make_collection
11
+ from primarysource.pms.catalogue import SPEC
12
+
13
+ COLLECTION = make_collection(SPEC, "primarysource.pms")
14
+ """Refs: `pms:pms§Ebuild-defined variables`; see `pkgdocs.runtime`."""
15
+
16
+ __all__ = ["COLLECTION", "SPEC"]
@@ -0,0 +1,88 @@
1
+ """The `pms` collection: the Gentoo Package Manager Specification, read from its LaTeX source.
2
+
3
+ Source, placement and pin (read from the repository on 2026-11-13, see `docs/pms.md`):
4
+
5
+ - the tree is the root of gentoo/pms at the commit of the tag `eapi-9-approved-2026-06-14` (the approved EAPI 9
6
+ text), fetched as a GitHub tarball (77 KB). `pms.tex` includes the chapters in order; the build reads them from
7
+ the archive's own files, never from disk, into one document, `pms`, numbered as the typeset specification is
8
+ (chapters 1, 2, ..., then A, B, ... after `\\appendix`);
9
+ - the licence is **CC-BY-SA-4.0**, from the title page of `pms.tex`: "released under the Creative Commons
10
+ Attribution-ShareAlike 4.0 International licence". (Group F's notes said 3.0, read on the EAPI 3 page; the
11
+ current source says 4.0, and that is what is recorded.) Placement is **layer, with attribution**: every
12
+ citation carries the credit, and the licence line travels with the layer;
13
+ - GLEPs, the other half of the original brief, are not here: gitweb.gentoo.org answered HTTP 502 to the
14
+ snapshot request on 2026-11-13 and the project has no GitHub mirror (`gentoo/glep` and `projg2/glep` are not
15
+ found). See ROADMAP row `col.gentoo_glep`.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections import Counter
21
+ from collections.abc import Mapping
22
+
23
+ from primarysource.errors import LibError
24
+ from primarysource.pkgdocs.fetch import Source
25
+ from primarysource.pkgdocs.readers.latex import read_latex
26
+ from primarysource.pkgdocs.runtime import Build, Licence, Pin, Spec, TreeSpec, matching
27
+
28
+ COMMIT = "2cee9c6bd86851738e8ba1874ac622cfcec70969"
29
+
30
+ PIN = Pin(
31
+ commit=COMMIT,
32
+ label="eapi-9-approved-2026-06-14",
33
+ date="2026-06-14",
34
+ files_sha256="c302537c3d04c28a69b3e4fae19247f168913a29beff587e2e26d6af08d62502",
35
+ sources=(
36
+ Source(f"https://codeload.github.com/gentoo/pms/tar.gz/{COMMIT}", strip=1),
37
+ Source(f"https://github.com/gentoo/pms/archive/{COMMIT}.tar.gz", strip=1, hosts=("codeload.github.com",)),
38
+ ),
39
+ )
40
+
41
+ LICENCE = Licence(
42
+ spdx="CC-BY-SA-4.0",
43
+ redistribute="layer",
44
+ attribution="(c) 2007-2026 Stephen Bennett, Ciaran McCreesh and others; contributions are owned by their authors",
45
+ evidence="the title page of pms.tex in gentoo/pms " + COMMIT[:12],
46
+ credit="CC BY-SA 4.0, Stephen Bennett, Ciaran McCreesh and others",
47
+ )
48
+
49
+ TITLE = "Package Manager Specification"
50
+
51
+
52
+ def build(files: Mapping[str, bytes], pin: Pin) -> Build:
53
+ skipped: Counter[str] = Counter()
54
+ if "pms.tex" not in files:
55
+ raise LibError("pms.tex is not in the archive", "the repository layout changed; report the collection")
56
+ try:
57
+ doc = read_latex(files, "pms.tex", "pms", f"{TITLE} ({pin.label})")
58
+ except LibError:
59
+ skipped["not UTF-8 text"] += 1
60
+ raise
61
+ aliases = {s.id.split(".")[0]: "pms" for s in doc.sections if s.id != "0"}
62
+ aliases["pms-" + pin.label] = "pms"
63
+ return Build((doc.with_meta(path="pms.tex", licence="CC-BY-SA-4.0"),), aliases, skipped)
64
+
65
+
66
+ SPEC = Spec(
67
+ name="pms",
68
+ title="Gentoo Package Manager Specification",
69
+ licence=LICENCE,
70
+ trees=(
71
+ TreeSpec(
72
+ name="pms",
73
+ title="gentoo/pms (LaTeX)",
74
+ pins=(PIN,),
75
+ include=matching(r"[A-Za-z0-9._-]+\.tex"),
76
+ build=build,
77
+ ),
78
+ ),
79
+ instructions=(
80
+ "The Gentoo Package Manager Specification (PMS), one document, 'pms', numbered as the typeset text is: "
81
+ "chapters 1-14, then appendices A, B, ... Loop: pms_search('<words>') -> pms_outline('pms') -> "
82
+ "pms_read('<ref>'). Section numbers work as refs without a document: 'pms:8.2.4'. A section is an id, "
83
+ "an anchor (the LaTeX label, e.g. ch:ebuild-vars), a heading or a unique part of one. Text is reduced "
84
+ "from LaTeX: \\ref numbers are resolved, markup is dropped, and the warning: line lists the commands "
85
+ "that were dropped. Every citation carries the CC BY-SA 4.0 credit."
86
+ ),
87
+ example_refs=("pms:8.2.4", "pms:pms§Ebuild-defined variables", "pms:pms§ch:ebuild-vars¶2"),
88
+ )
@@ -0,0 +1,12 @@
1
+ """`primarysource pms import|status|docs|outline|read|search|cite`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ from primarysource.pkgdocs.serving import run
8
+ from primarysource.pms.catalogue import SPEC
9
+
10
+
11
+ def main(argv: Sequence[str]) -> int:
12
+ return run(SPEC, argv)
@@ -0,0 +1,8 @@
1
+ """The MCP server for `pms` (`primarysource mcp --collection pms`). Imports `mcp` when this module loads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from primarysource.pkgdocs.serving import build_server
6
+ from primarysource.pms.catalogue import SPEC
7
+
8
+ server = build_server(SPEC)
primarysource/py.typed ADDED
File without changes
@@ -0,0 +1,10 @@
1
+ """The core readers (PLAN.md §4). Each takes bytes or text and never unpacks an archive to disk.
2
+
3
+ - `html`: HTML to readable markdown blocks, zimlore's converter plus a `Profile` (`cl.reader_html`).
4
+ - `markdown`: a strict Markdown subset into headings and blocks (`cl.reader_markdown`).
5
+ - `pdf`: a bookmarked PDF into sections of sentences, `pypdf` imported only when a PDF is read
6
+ (`cl.reader_pdf_pypdf`); `bookmarks` cuts the page texts at the bookmarks (`cl.segment_bookmarks`).
7
+ - `xml`: the one hardened XML parser: no DTDs, no entities, no network (`cl.reader_xml_hardened`).
8
+
9
+ Standard library only, apart from `pypdf`, which is an ingest-time extra (PLAN.md §5.13).
10
+ """
@@ -0,0 +1,192 @@
1
+ """Cut a bookmarked document's page texts into numbered sections of sentences (`cl.segment_bookmarks`).
2
+
3
+ Backend-independent: it takes the page texts and the flattened bookmark outline that a reader produced
4
+ (`primarysource.readers.pdf`) and never opens a file. Lifted from the ecma collection's private reader
5
+ (`primarysource/ecma/_pdf.py`), whose tests moved with it.
6
+
7
+ - a bookmark whose title starts with a clause number (`5.3 Separator characters`, `A.2.1 Data Types`) or
8
+ `Annex A` gets that number as its id (`5.3`, `A.2.1`, `A`), an `Appendix I` bookmark gets `Appendix-I`, any
9
+ other (`Bibliography`) its slug;
10
+ - page furniture (a header or footer line that repeats on many pages, once digits and Roman numerals are
11
+ ignored) is dropped before anything is cut;
12
+ - a section's text runs from its heading to the next bookmark's heading, subsections excluded, and is split
13
+ into sentences, because a PDF gives sentences and not paragraphs (`Unit.SENTENCE`).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ import unicodedata
20
+ from collections import Counter
21
+ from typing import Any
22
+
23
+ _NUMBERED = re.compile(r"^(?P<id>\d+(?:\.\d+)*)\.?\s+(?P<title>\S.*)$")
24
+ _LETTERED = re.compile(r"^(?P<id>[A-Z](?:\.\d+)+)\.?\s+(?P<title>\S.*)$")
25
+ _ANNEX = re.compile(r"^(?:Annex|Appendix)\s+(?P<id>[A-Z0-9]+(?:\.\d+)*)\b[.:]?\s*(?P<title>.*)$", re.IGNORECASE)
26
+ _DIGITS = re.compile(r"\d+(?:\s+of\s+\d+)?")
27
+ _ROMAN = re.compile(r"^(?:[ivxlcdm]+\s+)|(?:\s+[ivxlcdm]+)$", re.IGNORECASE)
28
+ _SENTENCE_END = re.compile(r"(?<=[.!?])\s+(?=[A-Z0-9(\[\"'•])")
29
+ _ABBREV = ("e.g.", "i.e.", "etc.", "vs.", "cf.", "No.", "Fig.", "Sec.", "approx.")
30
+
31
+ Outline = list[tuple[str, int, int]]
32
+ """A bookmark tree flattened to (title, 0-based page, depth), in document order."""
33
+
34
+
35
+ def slug(text: str) -> str:
36
+ folded = unicodedata.normalize("NFKC", text).lower()
37
+ return re.sub(r"[^a-z0-9]+", "-", folded).strip("-") or "section"
38
+
39
+
40
+ def split_sentences(text: str) -> list[str]:
41
+ """Sentences of already-flowed text: split after . ! ? before a capital, digit or bracket, not after the
42
+ abbreviations in `_ABBREV`."""
43
+ flowed = " ".join(text.split())
44
+ if not flowed:
45
+ return []
46
+ parts = _SENTENCE_END.split(flowed)
47
+ out: list[str] = []
48
+ for part in parts:
49
+ if out and out[-1].endswith(_ABBREV):
50
+ out[-1] = f"{out[-1]} {part}"
51
+ else:
52
+ out.append(part)
53
+ return out
54
+
55
+
56
+ def furniture_key(line: str) -> str:
57
+ """A line with its digits and leading or trailing Roman numerals folded away, so a running header or footer
58
+ gives the same key on every page."""
59
+ key = _DIGITS.sub("#", line.strip())
60
+ key = _ROMAN.sub("", key).strip()
61
+ return re.sub(r"\s+", " ", key)
62
+
63
+
64
+ def heading_id(title: str) -> tuple[str, str]:
65
+ """The section id and the title text a bookmark title gives (see the module docstring)."""
66
+ for pattern in (_NUMBERED, _LETTERED, _ANNEX):
67
+ match = pattern.match(title)
68
+ if match:
69
+ ident = match.group("id").upper() if pattern is not _NUMBERED else match.group("id")
70
+ if pattern is _ANNEX and title.lower().startswith("appendix"):
71
+ ident = f"Appendix-{ident}"
72
+ return ident, match.group("title").strip() or title
73
+ return slug(title), title
74
+
75
+
76
+ _TRACKING_MARK = re.compile(r"^\s*[-`,]{8,}")
77
+ """A distributor's tracking string printed before the first line of a page (``--`,``,``,,,`---1 Scope``)."""
78
+
79
+
80
+ def clean_pages(pages: list[str], *, keep_spacing: bool = False) -> list[list[str]]:
81
+ """Each page's non-empty lines with page furniture dropped: a line whose `furniture_key` occurs on at least a
82
+ quarter of the pages (at least 3) is a running header or footer. Whitespace is collapsed unless `keep_spacing`
83
+ (layout text, whose column gaps the numbered-heading splitter reads); a distributor's tracking string at the
84
+ start of a line is removed either way."""
85
+
86
+ def tidy(raw: str) -> str:
87
+ line = _TRACKING_MARK.sub("", raw)
88
+ return line.rstrip() if keep_spacing else " ".join(line.split())
89
+
90
+ per_page = [[ln for ln in (tidy(raw) for raw in text.splitlines()) if ln.strip()] for text in pages]
91
+ seen: Counter[str] = Counter()
92
+ for lines in per_page:
93
+ seen.update({furniture_key(" ".join(ln.split())) for ln in lines})
94
+ limit = max(3, len(pages) // 4)
95
+ return [[ln for ln in lines if seen[furniture_key(" ".join(ln.split()))] < limit] for lines in per_page]
96
+
97
+
98
+ def cut_numbered(pages: list[str]) -> tuple[list[dict[str, Any]], int]:
99
+ """Cut page texts into sections at numbered headings, for a PDF without bookmarks (older ISO editions, for
100
+ one). Furniture is dropped as in `cut`, then the shared numbered-heading splitter
101
+ (`primarysource.pdfcorpus.split.split_sections`, `cl.segment_numbered`) finds the headings. Text before the
102
+ first heading becomes a `front-matter` section. Returns the sections in `cut`'s shape and how many headings
103
+ were found."""
104
+ from primarysource.pdfcorpus.split import split_sections
105
+
106
+ lines: list[str] = []
107
+ page_of: list[int] = []
108
+ for number, page_lines in enumerate(clean_pages(pages, keep_spacing=True)):
109
+ lines.extend(page_lines)
110
+ page_of.extend([number] * len(page_lines))
111
+ found = split_sections("\n".join(lines))
112
+ sections: list[dict[str, Any]] = []
113
+ first = found[0].start_line - 1 if found else len(lines)
114
+ if first > 0:
115
+ sections.append(
116
+ {
117
+ "id": "front-matter",
118
+ "title": "Front matter",
119
+ "depth": 0,
120
+ "page": 1,
121
+ "text": "\n".join(split_sentences(" ".join(lines[:first]))),
122
+ }
123
+ )
124
+ used: Counter[str] = Counter()
125
+ for section in found:
126
+ ident = section.id
127
+ used[ident] += 1
128
+ if used[ident] > 1:
129
+ ident = f"{ident}-{used[ident] - 1}"
130
+ body = lines[section.start_line : section.end_line]
131
+ sections.append(
132
+ {
133
+ "id": ident,
134
+ "title": " ".join(section.heading.split()),
135
+ "depth": section.id.count("."),
136
+ "page": page_of[section.start_line - 1] + 1,
137
+ "text": "\n".join(split_sentences(" ".join(body))),
138
+ }
139
+ )
140
+ return sections, len(found)
141
+
142
+
143
+ def cut(pages: list[str], outline: Outline) -> tuple[list[dict[str, Any]], int]:
144
+ """Cut page texts into sections at the bookmarked headings. Returns the sections
145
+ (`{"id", "title", "depth", "page", "text"}`, `page` 1-based, `text` the sentences joined by newlines) and how
146
+ many headings were found as a line of text (the rest fall back to the start of their page)."""
147
+ count = len(pages)
148
+ cleaned = ["\n".join(lines) for lines in clean_pages(pages)]
149
+ starts: list[int] = []
150
+ joined = ""
151
+ for text in cleaned:
152
+ starts.append(len(joined))
153
+ joined += text + "\n"
154
+
155
+ spots: list[tuple[int, int]] = [] # (start, end) of each heading's line in `joined`, never moving backwards
156
+ cursor = 0
157
+ located = 0
158
+ for heading, page, _ in outline:
159
+ ident, text_title = heading_id(heading)
160
+ numbered = _NUMBERED.match(heading) or _LETTERED.match(heading)
161
+ words = r"\s+".join(re.escape(w) for w in (text_title if numbered else heading).split())
162
+ number = re.escape(ident) if numbered else ""
163
+ pattern = re.compile(rf"(?m)^{number}\.?\s*{words}" if number else rf"(?m)^{words}")
164
+ first = min(page, count - 1)
165
+ base = max(starts[first], cursor)
166
+ found = pattern.search(joined, base, starts[first + 1] if first + 1 < count else len(joined))
167
+ if found is None:
168
+ found = pattern.search(joined, base)
169
+ if found is not None:
170
+ located += 1
171
+ spot = (found.start(), found.end()) if found else (base, base)
172
+ spots.append(spot)
173
+ cursor = max(cursor, spot[0])
174
+ sections: list[dict[str, Any]] = []
175
+ used: Counter[str] = Counter()
176
+ for n, (heading, page, depth) in enumerate(outline):
177
+ ident, text_title = heading_id(heading)
178
+ used[ident] += 1
179
+ if used[ident] > 1:
180
+ ident = f"{ident}-{used[ident] - 1}"
181
+ begin = spots[n][1]
182
+ stop = spots[n + 1][0] if n + 1 < len(outline) else len(joined)
183
+ sections.append(
184
+ {
185
+ "id": ident,
186
+ "title": text_title,
187
+ "depth": depth,
188
+ "page": page + 1,
189
+ "text": "\n".join(split_sentences(joined[begin : max(begin, stop)])),
190
+ }
191
+ )
192
+ return sections, located