python-docs-mcp-server 0.1.2__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.
- mcp_server_python_docs/__init__.py +17 -0
- mcp_server_python_docs/__main__.py +574 -0
- mcp_server_python_docs/app_context.py +33 -0
- mcp_server_python_docs/data/synonyms.yaml +194 -0
- mcp_server_python_docs/detection.py +82 -0
- mcp_server_python_docs/diagnostics.py +74 -0
- mcp_server_python_docs/errors.py +33 -0
- mcp_server_python_docs/ingestion/__init__.py +1 -0
- mcp_server_python_docs/ingestion/cpython_versions.py +32 -0
- mcp_server_python_docs/ingestion/inventory.py +178 -0
- mcp_server_python_docs/ingestion/publish.py +504 -0
- mcp_server_python_docs/ingestion/sphinx_json.py +617 -0
- mcp_server_python_docs/models.py +216 -0
- mcp_server_python_docs/py.typed +0 -0
- mcp_server_python_docs/retrieval/__init__.py +31 -0
- mcp_server_python_docs/retrieval/budget.py +63 -0
- mcp_server_python_docs/retrieval/query.py +183 -0
- mcp_server_python_docs/retrieval/ranker.py +369 -0
- mcp_server_python_docs/server.py +329 -0
- mcp_server_python_docs/services/__init__.py +7 -0
- mcp_server_python_docs/services/cache.py +111 -0
- mcp_server_python_docs/services/content.py +155 -0
- mcp_server_python_docs/services/observability.py +130 -0
- mcp_server_python_docs/services/package_docs.py +161 -0
- mcp_server_python_docs/services/persistent_cache.py +147 -0
- mcp_server_python_docs/services/search.py +111 -0
- mcp_server_python_docs/services/version.py +47 -0
- mcp_server_python_docs/services/version_resolution.py +79 -0
- mcp_server_python_docs/storage/__init__.py +1 -0
- mcp_server_python_docs/storage/db.py +146 -0
- mcp_server_python_docs/storage/schema.sql +136 -0
- python_docs_mcp_server-0.1.2.dist-info/METADATA +528 -0
- python_docs_mcp_server-0.1.2.dist-info/RECORD +36 -0
- python_docs_mcp_server-0.1.2.dist-info/WHEEL +4 -0
- python_docs_mcp_server-0.1.2.dist-info/entry_points.txt +3 -0
- python_docs_mcp_server-0.1.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
"""Sphinx JSON ingestion: parse .fjson files, extract sections/examples, convert to markdown.
|
|
2
|
+
|
|
3
|
+
Handles per-document failure isolation (INGR-C-06) so one broken file never
|
|
4
|
+
aborts the whole build. Converts HTML body fragments to markdown (INGR-C-05),
|
|
5
|
+
extracts code blocks with doctest classification (INGR-C-07), populates
|
|
6
|
+
FTS5 indexes (INGR-C-08), and seeds the synonyms table (INGR-C-09).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib.resources
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import sqlite3
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
from bs4 import BeautifulSoup, Tag
|
|
21
|
+
from markdownify import markdownify as md
|
|
22
|
+
|
|
23
|
+
from mcp_server_python_docs.errors import IngestionError
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# Files to skip during directory ingestion (not documentation pages)
|
|
28
|
+
_SKIP_FILES = {
|
|
29
|
+
"globalcontext.json",
|
|
30
|
+
"searchindex.json",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_SKIP_SLUGS = {
|
|
34
|
+
"genindex",
|
|
35
|
+
"py-modindex",
|
|
36
|
+
"search",
|
|
37
|
+
"contents",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_HTML_ONLY_SPHINX_REQUIREMENTS = frozenset({
|
|
41
|
+
"python-docs-theme",
|
|
42
|
+
"sphinx-notfound-page",
|
|
43
|
+
"sphinxext-opengraph",
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
_SPHINX_JSON_SITECUSTOMIZE = '''"""Compatibility patch for disposable Sphinx JSON builds."""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
from sphinxcontrib.serializinghtml import jsonimpl
|
|
52
|
+
except Exception:
|
|
53
|
+
jsonimpl = None
|
|
54
|
+
|
|
55
|
+
if jsonimpl is not None:
|
|
56
|
+
_original_default = jsonimpl.SphinxJSONEncoder.default
|
|
57
|
+
|
|
58
|
+
def _mcp_json_default(self, obj):
|
|
59
|
+
if obj.__class__.__name__ == "_TranslationProxy":
|
|
60
|
+
return str(obj)
|
|
61
|
+
return _original_default(self, obj)
|
|
62
|
+
|
|
63
|
+
jsonimpl.SphinxJSONEncoder.default = _mcp_json_default
|
|
64
|
+
'''
|
|
65
|
+
|
|
66
|
+
_IMGHDR_COMPAT_MODULE = '''"""Compatibility shim for Sphinx builds that import imghdr."""
|
|
67
|
+
|
|
68
|
+
from __future__ import annotations
|
|
69
|
+
|
|
70
|
+
import os
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# Preserve the stdlib imghdr extension hook for old Sphinx-related extensions.
|
|
74
|
+
tests = []
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def what(file, h=None):
|
|
78
|
+
"""Return an image type for the header formats old Sphinx may ask about."""
|
|
79
|
+
if h is None:
|
|
80
|
+
if isinstance(file, (str, bytes, os.PathLike)):
|
|
81
|
+
with open(file, "rb") as image_file:
|
|
82
|
+
h = image_file.read(32)
|
|
83
|
+
else:
|
|
84
|
+
position = file.tell()
|
|
85
|
+
h = file.read(32)
|
|
86
|
+
file.seek(position)
|
|
87
|
+
|
|
88
|
+
for test in tests:
|
|
89
|
+
result = test(h, file)
|
|
90
|
+
if result:
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
if h.startswith(b"\\xff\\xd8"):
|
|
94
|
+
return "jpeg"
|
|
95
|
+
if h.startswith(b"\\x89PNG\\r\\n\\x1a\\n"):
|
|
96
|
+
return "png"
|
|
97
|
+
if h[:6] in (b"GIF87a", b"GIF89a"):
|
|
98
|
+
return "gif"
|
|
99
|
+
return None
|
|
100
|
+
'''
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _canonical_requirement_name(line: str) -> str | None:
|
|
104
|
+
stripped = line.strip()
|
|
105
|
+
if not stripped or stripped.startswith("#") or stripped.startswith("-"):
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
match = re.match(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)", line)
|
|
109
|
+
if match is None:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
return re.sub(r"[-_.]+", "-", match.group(1)).lower()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def write_json_build_requirements(source_path: Path, output_path: Path) -> list[str]:
|
|
116
|
+
"""Write CPython Doc requirements filtered for Sphinx JSON builds.
|
|
117
|
+
|
|
118
|
+
CPython's documentation requirements include optional extensions and a
|
|
119
|
+
theme package that only support HTML output. If installed, Sphinx can load
|
|
120
|
+
them during the JSON build path and fail before any .fjson files are
|
|
121
|
+
written.
|
|
122
|
+
"""
|
|
123
|
+
filtered_lines: list[str] = []
|
|
124
|
+
omitted: list[str] = []
|
|
125
|
+
|
|
126
|
+
for line in source_path.read_text(encoding="utf-8").splitlines(keepends=True):
|
|
127
|
+
package_name = _canonical_requirement_name(line)
|
|
128
|
+
if package_name in _HTML_ONLY_SPHINX_REQUIREMENTS:
|
|
129
|
+
omitted.append(package_name)
|
|
130
|
+
continue
|
|
131
|
+
filtered_lines.append(line)
|
|
132
|
+
|
|
133
|
+
output_path.write_text("".join(filtered_lines), encoding="utf-8")
|
|
134
|
+
return omitted
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def write_sphinx_json_sitecustomize(output_dir: Path) -> Path:
|
|
138
|
+
"""Write a temporary sitecustomize shim for Sphinx JSON builds.
|
|
139
|
+
|
|
140
|
+
Sphinx 8.2's JSON encoder does not serialize ``_TranslationProxy`` objects,
|
|
141
|
+
even though CPython docs can place them in the page context. The Sphinx venv
|
|
142
|
+
is disposable, so keep this compatibility patch isolated to the JSON build
|
|
143
|
+
subprocess via PYTHONPATH instead of mutating installed packages.
|
|
144
|
+
"""
|
|
145
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
sitecustomize_path = output_dir / "sitecustomize.py"
|
|
147
|
+
sitecustomize_path.write_text(_SPHINX_JSON_SITECUSTOMIZE, encoding="utf-8")
|
|
148
|
+
# PYTHONPATH prepending makes this shim shadow stdlib imghdr on Python 3.12.
|
|
149
|
+
# The detected formats match the Sphinx usage we need for CPython docs builds.
|
|
150
|
+
imghdr_path = output_dir / "imghdr.py"
|
|
151
|
+
imghdr_path.write_text(_IMGHDR_COMPAT_MODULE, encoding="utf-8")
|
|
152
|
+
return sitecustomize_path
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def make_sphinx_json_env(
|
|
156
|
+
compat_dir: Path,
|
|
157
|
+
base_env: Mapping[str, str] | None = None,
|
|
158
|
+
) -> dict[str, str]:
|
|
159
|
+
"""Return an environment that loads the JSON-build compatibility shim first."""
|
|
160
|
+
env = dict(base_env) if base_env is not None else os.environ.copy()
|
|
161
|
+
existing_pythonpath = env.get("PYTHONPATH")
|
|
162
|
+
if existing_pythonpath:
|
|
163
|
+
env["PYTHONPATH"] = f"{compat_dir}{os.pathsep}{existing_pythonpath}"
|
|
164
|
+
else:
|
|
165
|
+
env["PYTHONPATH"] = str(compat_dir)
|
|
166
|
+
return env
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def build_sphinx_json_command(
|
|
170
|
+
sphinx_build: Path | str,
|
|
171
|
+
doc_dir: Path | str,
|
|
172
|
+
json_out: Path | str,
|
|
173
|
+
) -> list[str]:
|
|
174
|
+
"""Return the Sphinx command used for CPython JSON documentation builds."""
|
|
175
|
+
return [
|
|
176
|
+
str(sphinx_build),
|
|
177
|
+
"-b",
|
|
178
|
+
"json",
|
|
179
|
+
"-D",
|
|
180
|
+
"html_theme=classic",
|
|
181
|
+
"-j",
|
|
182
|
+
"auto",
|
|
183
|
+
str(doc_dir),
|
|
184
|
+
str(json_out),
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _sphinx_pin_needs_pkg_resources(sphinx_pin: str) -> bool:
|
|
189
|
+
normalized = sphinx_pin.strip().lower().replace(" ", "")
|
|
190
|
+
return normalized.startswith(
|
|
191
|
+
(
|
|
192
|
+
"sphinx==3.",
|
|
193
|
+
"sphinx==4.",
|
|
194
|
+
"sphinx~=3.",
|
|
195
|
+
"sphinx~=4.",
|
|
196
|
+
"sphinx<5",
|
|
197
|
+
"sphinx<=4",
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def build_sphinx_bootstrap_requirements(sphinx_pin: str) -> list[str]:
|
|
203
|
+
"""Return packages needed before installing CPython Doc requirements.
|
|
204
|
+
|
|
205
|
+
setuptools<70 keeps ``pkg_resources`` available when old Sphinx
|
|
206
|
+
releases (e.g. the 3.4.x line pinned for the Python 3.10 docs build)
|
|
207
|
+
still import at startup.
|
|
208
|
+
"""
|
|
209
|
+
if _sphinx_pin_needs_pkg_resources(sphinx_pin):
|
|
210
|
+
return ["setuptools<70", sphinx_pin]
|
|
211
|
+
return [sphinx_pin]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def parse_fjson(filepath: Path) -> dict:
|
|
215
|
+
"""Load and parse a .fjson file.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
filepath: Path to the .fjson file.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
Parsed JSON as a dict.
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
IngestionError: If the file cannot be parsed as JSON.
|
|
225
|
+
"""
|
|
226
|
+
try:
|
|
227
|
+
with open(filepath, encoding="utf-8") as f:
|
|
228
|
+
return json.load(f)
|
|
229
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
230
|
+
raise IngestionError(f"Failed to parse {filepath}: {e}") from e
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def html_to_markdown(html: str) -> str:
|
|
234
|
+
"""Convert an HTML body fragment to markdown (INGR-C-05).
|
|
235
|
+
|
|
236
|
+
Uses markdownify with ATX-style headings and strips non-content
|
|
237
|
+
elements (images, scripts, styles).
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
html: HTML string to convert.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Markdown string with leading/trailing whitespace stripped.
|
|
244
|
+
"""
|
|
245
|
+
if not html or not html.strip():
|
|
246
|
+
return ""
|
|
247
|
+
result = md(html, heading_style="ATX", strip=["img", "script", "style"])
|
|
248
|
+
return result.strip()
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def extract_sections(body_html: str, doc_uri: str) -> list[dict]:
|
|
252
|
+
"""Extract sections from HTML body based on heading hierarchy (INGR-C-04).
|
|
253
|
+
|
|
254
|
+
Walks heading tags (h1-h6) that have an ``id`` attribute, treating each
|
|
255
|
+
as a section boundary. Content between headings becomes the section's
|
|
256
|
+
``content_text`` after markdown conversion.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
body_html: HTML body fragment from an .fjson file.
|
|
260
|
+
doc_uri: Document URI (e.g., ``library/asyncio-task.html``).
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
List of section dicts with keys: anchor, heading, level, ordinal,
|
|
264
|
+
content_text, char_count, uri.
|
|
265
|
+
"""
|
|
266
|
+
if not body_html or not body_html.strip():
|
|
267
|
+
return []
|
|
268
|
+
|
|
269
|
+
soup = BeautifulSoup(body_html, "html.parser")
|
|
270
|
+
heading_tags = soup.find_all(re.compile(r"^h[1-6]$"), id=True)
|
|
271
|
+
|
|
272
|
+
if not heading_tags:
|
|
273
|
+
# No headings with id — create a single section covering the whole body
|
|
274
|
+
content = html_to_markdown(body_html)
|
|
275
|
+
if not content:
|
|
276
|
+
return []
|
|
277
|
+
return [
|
|
278
|
+
{
|
|
279
|
+
"anchor": "",
|
|
280
|
+
"heading": "Introduction",
|
|
281
|
+
"level": 1,
|
|
282
|
+
"ordinal": 0,
|
|
283
|
+
"content_text": content,
|
|
284
|
+
"char_count": len(content),
|
|
285
|
+
"uri": doc_uri,
|
|
286
|
+
}
|
|
287
|
+
]
|
|
288
|
+
|
|
289
|
+
sections: list[dict] = []
|
|
290
|
+
for i, tag in enumerate(heading_tags):
|
|
291
|
+
anchor = tag.get("id", "")
|
|
292
|
+
heading_text = tag.get_text(strip=True)
|
|
293
|
+
# Remove pilcrow/paragraph marks that Sphinx adds
|
|
294
|
+
heading_text = heading_text.rstrip("\u00b6").strip()
|
|
295
|
+
level = int(tag.name[1])
|
|
296
|
+
|
|
297
|
+
# Collect content: everything between this heading and the next
|
|
298
|
+
content_parts: list[str] = []
|
|
299
|
+
sibling = tag.next_sibling
|
|
300
|
+
next_heading = heading_tags[i + 1] if i + 1 < len(heading_tags) else None
|
|
301
|
+
|
|
302
|
+
while sibling is not None:
|
|
303
|
+
if next_heading is not None and sibling is next_heading:
|
|
304
|
+
break
|
|
305
|
+
if isinstance(sibling, Tag):
|
|
306
|
+
# Check if this tag contains the next heading
|
|
307
|
+
if next_heading is not None and sibling.find(
|
|
308
|
+
re.compile(r"^h[1-6]$"), id=str(next_heading.get("id", ""))
|
|
309
|
+
):
|
|
310
|
+
break
|
|
311
|
+
content_parts.append(str(sibling))
|
|
312
|
+
sibling = sibling.next_sibling
|
|
313
|
+
|
|
314
|
+
content_html = "".join(content_parts)
|
|
315
|
+
content_text = html_to_markdown(content_html)
|
|
316
|
+
|
|
317
|
+
sections.append(
|
|
318
|
+
{
|
|
319
|
+
"anchor": anchor,
|
|
320
|
+
"heading": heading_text,
|
|
321
|
+
"level": level,
|
|
322
|
+
"ordinal": i,
|
|
323
|
+
"content_text": content_text,
|
|
324
|
+
"char_count": len(content_text),
|
|
325
|
+
"uri": f"{doc_uri}#{anchor}",
|
|
326
|
+
}
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
return sections
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def extract_code_blocks(body_html: str) -> list[dict]:
|
|
333
|
+
"""Extract code blocks from HTML body, classifying doctests vs examples (INGR-C-07).
|
|
334
|
+
|
|
335
|
+
Finds ``<div class="highlight-pycon">`` (doctests) and
|
|
336
|
+
``<div class="highlight-python3">`` or ``<div class="highlight-default">``
|
|
337
|
+
(standalone examples).
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
body_html: HTML body fragment from an .fjson file.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
List of code block dicts with keys: code, is_doctest, language,
|
|
344
|
+
ordinal, section_anchor.
|
|
345
|
+
"""
|
|
346
|
+
if not body_html or not body_html.strip():
|
|
347
|
+
return []
|
|
348
|
+
|
|
349
|
+
soup = BeautifulSoup(body_html, "html.parser")
|
|
350
|
+
blocks: list[dict] = []
|
|
351
|
+
|
|
352
|
+
# Find all highlight divs
|
|
353
|
+
highlight_divs = soup.find_all(
|
|
354
|
+
"div",
|
|
355
|
+
class_=re.compile(
|
|
356
|
+
r"highlight-(pycon|python3|python|default|pycon3)"
|
|
357
|
+
),
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
for i, div in enumerate(highlight_divs):
|
|
361
|
+
classes = div.get("class") or []
|
|
362
|
+
class_str = " ".join(classes) if isinstance(classes, list) else str(classes)
|
|
363
|
+
|
|
364
|
+
is_doctest = 1 if "highlight-pycon" in class_str else 0
|
|
365
|
+
|
|
366
|
+
# Extract code text from the <pre> element
|
|
367
|
+
pre = div.find("pre")
|
|
368
|
+
if pre is None:
|
|
369
|
+
continue
|
|
370
|
+
code = pre.get_text()
|
|
371
|
+
if not code.strip():
|
|
372
|
+
continue
|
|
373
|
+
|
|
374
|
+
# Find nearest preceding heading to determine section
|
|
375
|
+
section_anchor = ""
|
|
376
|
+
element = div
|
|
377
|
+
while element:
|
|
378
|
+
element = element.find_previous(re.compile(r"^h[1-6]$"), id=True)
|
|
379
|
+
if element:
|
|
380
|
+
section_anchor = element.get("id", "")
|
|
381
|
+
break
|
|
382
|
+
|
|
383
|
+
blocks.append(
|
|
384
|
+
{
|
|
385
|
+
"code": code,
|
|
386
|
+
"is_doctest": is_doctest,
|
|
387
|
+
"language": "python",
|
|
388
|
+
"ordinal": i,
|
|
389
|
+
"section_anchor": section_anchor,
|
|
390
|
+
}
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
return blocks
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def ingest_fjson_file(
|
|
397
|
+
conn: sqlite3.Connection,
|
|
398
|
+
filepath: Path,
|
|
399
|
+
doc_set_id: int,
|
|
400
|
+
base_uri: str = "",
|
|
401
|
+
) -> bool:
|
|
402
|
+
"""Ingest a single .fjson file with per-document failure isolation (INGR-C-06).
|
|
403
|
+
|
|
404
|
+
On ANY exception, logs a warning and returns False without aborting
|
|
405
|
+
the overall build.
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
conn: Read-write SQLite connection.
|
|
409
|
+
filepath: Path to the .fjson file.
|
|
410
|
+
doc_set_id: Foreign key into doc_sets.
|
|
411
|
+
base_uri: Base URI prefix (usually empty for relative URIs).
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
True if ingestion succeeded, False if the file was skipped.
|
|
415
|
+
"""
|
|
416
|
+
try:
|
|
417
|
+
data = parse_fjson(filepath)
|
|
418
|
+
|
|
419
|
+
# Extract core fields
|
|
420
|
+
body_html = data.get("body", "")
|
|
421
|
+
title_html = data.get("title", "")
|
|
422
|
+
current_page_name = data.get("current_page_name", "")
|
|
423
|
+
|
|
424
|
+
if not current_page_name:
|
|
425
|
+
logger.warning("Skipping %s: no current_page_name", filepath)
|
|
426
|
+
return False
|
|
427
|
+
|
|
428
|
+
# Skip non-documentation pages
|
|
429
|
+
slug = current_page_name
|
|
430
|
+
if any(slug.endswith(skip) or slug == skip for skip in _SKIP_SLUGS):
|
|
431
|
+
return False
|
|
432
|
+
|
|
433
|
+
# Clean title (strip HTML tags if present)
|
|
434
|
+
title = BeautifulSoup(title_html, "html.parser").get_text(strip=True)
|
|
435
|
+
if not title:
|
|
436
|
+
title = slug.split("/")[-1]
|
|
437
|
+
|
|
438
|
+
# Build document URI
|
|
439
|
+
doc_uri = f"{current_page_name}.html"
|
|
440
|
+
|
|
441
|
+
# Convert full body to markdown for document content_text
|
|
442
|
+
content_text = html_to_markdown(body_html)
|
|
443
|
+
char_count = len(content_text)
|
|
444
|
+
|
|
445
|
+
# Insert document
|
|
446
|
+
cursor = conn.execute(
|
|
447
|
+
"INSERT OR REPLACE INTO documents "
|
|
448
|
+
"(doc_set_id, uri, slug, title, content_text, char_count) "
|
|
449
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
450
|
+
(doc_set_id, doc_uri, slug, title, content_text, char_count),
|
|
451
|
+
)
|
|
452
|
+
document_id = cursor.lastrowid
|
|
453
|
+
|
|
454
|
+
# Extract and insert sections
|
|
455
|
+
sections = extract_sections(body_html, doc_uri)
|
|
456
|
+
section_id_by_anchor: dict[str, int] = {}
|
|
457
|
+
|
|
458
|
+
for section in sections:
|
|
459
|
+
cursor = conn.execute(
|
|
460
|
+
"INSERT OR REPLACE INTO sections "
|
|
461
|
+
"(document_id, uri, anchor, heading, level, ordinal, "
|
|
462
|
+
"content_text, char_count) "
|
|
463
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
464
|
+
(
|
|
465
|
+
document_id,
|
|
466
|
+
section["uri"],
|
|
467
|
+
section["anchor"],
|
|
468
|
+
section["heading"],
|
|
469
|
+
section["level"],
|
|
470
|
+
section["ordinal"],
|
|
471
|
+
section["content_text"],
|
|
472
|
+
section["char_count"],
|
|
473
|
+
),
|
|
474
|
+
)
|
|
475
|
+
row_id = cursor.lastrowid
|
|
476
|
+
assert row_id is not None
|
|
477
|
+
section_id_by_anchor[section["anchor"]] = row_id
|
|
478
|
+
|
|
479
|
+
# Extract and insert code blocks
|
|
480
|
+
code_blocks = extract_code_blocks(body_html)
|
|
481
|
+
for block in code_blocks:
|
|
482
|
+
section_id = section_id_by_anchor.get(block["section_anchor"])
|
|
483
|
+
if section_id is None:
|
|
484
|
+
# Fall back to first section if anchor not found
|
|
485
|
+
if section_id_by_anchor:
|
|
486
|
+
section_id = next(iter(section_id_by_anchor.values()))
|
|
487
|
+
else:
|
|
488
|
+
continue # No sections to attach to
|
|
489
|
+
|
|
490
|
+
conn.execute(
|
|
491
|
+
"INSERT INTO examples "
|
|
492
|
+
"(section_id, code, language, is_doctest, ordinal) "
|
|
493
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
494
|
+
(
|
|
495
|
+
section_id,
|
|
496
|
+
block["code"],
|
|
497
|
+
block["language"],
|
|
498
|
+
block["is_doctest"],
|
|
499
|
+
block["ordinal"],
|
|
500
|
+
),
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
conn.commit()
|
|
504
|
+
return True
|
|
505
|
+
|
|
506
|
+
except Exception as e:
|
|
507
|
+
logger.warning("Skipping %s: %s", filepath, e)
|
|
508
|
+
try:
|
|
509
|
+
conn.rollback()
|
|
510
|
+
except Exception:
|
|
511
|
+
pass
|
|
512
|
+
return False
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def ingest_sphinx_json_dir(
|
|
516
|
+
conn: sqlite3.Connection,
|
|
517
|
+
json_dir: Path,
|
|
518
|
+
doc_set_id: int,
|
|
519
|
+
) -> tuple[int, int]:
|
|
520
|
+
"""Walk a Sphinx JSON output directory and ingest all .fjson files.
|
|
521
|
+
|
|
522
|
+
Skips non-documentation files (globalcontext.json, searchindex.json, etc.).
|
|
523
|
+
Reports progress every 100 files.
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
conn: Read-write SQLite connection.
|
|
527
|
+
json_dir: Path to the Sphinx JSON output directory.
|
|
528
|
+
doc_set_id: Foreign key into doc_sets.
|
|
529
|
+
|
|
530
|
+
Returns:
|
|
531
|
+
Tuple of (success_count, failure_count).
|
|
532
|
+
"""
|
|
533
|
+
success = 0
|
|
534
|
+
failures = 0
|
|
535
|
+
total = 0
|
|
536
|
+
|
|
537
|
+
fjson_files = sorted(json_dir.rglob("*.fjson"))
|
|
538
|
+
|
|
539
|
+
for filepath in fjson_files:
|
|
540
|
+
# Skip non-documentation files
|
|
541
|
+
if filepath.name in _SKIP_FILES:
|
|
542
|
+
continue
|
|
543
|
+
|
|
544
|
+
total += 1
|
|
545
|
+
if ingest_fjson_file(conn, filepath, doc_set_id):
|
|
546
|
+
success += 1
|
|
547
|
+
else:
|
|
548
|
+
failures += 1
|
|
549
|
+
|
|
550
|
+
if total % 100 == 0:
|
|
551
|
+
logger.info("Processed %d documents (%d ok, %d failed)...", total, success, failures)
|
|
552
|
+
|
|
553
|
+
logger.info(
|
|
554
|
+
"Ingestion complete: %d documents (%d ok, %d failed)",
|
|
555
|
+
total,
|
|
556
|
+
success,
|
|
557
|
+
failures,
|
|
558
|
+
)
|
|
559
|
+
return success, failures
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def populate_synonyms(conn: sqlite3.Connection) -> int:
|
|
563
|
+
"""Populate the synonyms table from the packaged synonyms.yaml (INGR-C-09).
|
|
564
|
+
|
|
565
|
+
Reads ``data/synonyms.yaml`` via ``importlib.resources`` and inserts
|
|
566
|
+
each concept-expansion pair into the ``synonyms`` table.
|
|
567
|
+
|
|
568
|
+
Args:
|
|
569
|
+
conn: Read-write SQLite connection.
|
|
570
|
+
|
|
571
|
+
Returns:
|
|
572
|
+
Number of synonym entries inserted.
|
|
573
|
+
"""
|
|
574
|
+
ref = importlib.resources.files("mcp_server_python_docs") / "data" / "synonyms.yaml"
|
|
575
|
+
with importlib.resources.as_file(ref) as path:
|
|
576
|
+
data = yaml.safe_load(path.read_text())
|
|
577
|
+
|
|
578
|
+
if not isinstance(data, dict):
|
|
579
|
+
from mcp_server_python_docs.errors import IngestionError
|
|
580
|
+
|
|
581
|
+
raise IngestionError(
|
|
582
|
+
f"synonyms.yaml must be a YAML mapping, got {type(data).__name__}"
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
count = 0
|
|
586
|
+
for concept, expansion in data.items():
|
|
587
|
+
if isinstance(expansion, list):
|
|
588
|
+
expansion_str = " ".join(str(e) for e in expansion)
|
|
589
|
+
else:
|
|
590
|
+
expansion_str = str(expansion)
|
|
591
|
+
|
|
592
|
+
conn.execute(
|
|
593
|
+
"INSERT OR REPLACE INTO synonyms (concept, expansion) VALUES (?, ?)",
|
|
594
|
+
(concept, expansion_str),
|
|
595
|
+
)
|
|
596
|
+
count += 1
|
|
597
|
+
|
|
598
|
+
conn.commit()
|
|
599
|
+
logger.info("Populated %d synonym entries", count)
|
|
600
|
+
return count
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def rebuild_fts_indexes(conn: sqlite3.Connection) -> None:
|
|
604
|
+
"""Rebuild sections_fts and examples_fts indexes after content ingestion (INGR-C-08).
|
|
605
|
+
|
|
606
|
+
Uses the FTS5 'rebuild' command to re-read all content from the
|
|
607
|
+
canonical tables and repopulate the FTS indexes atomically.
|
|
608
|
+
|
|
609
|
+
Note: symbols_fts is rebuilt by inventory.py, not here.
|
|
610
|
+
|
|
611
|
+
Args:
|
|
612
|
+
conn: Read-write SQLite connection.
|
|
613
|
+
"""
|
|
614
|
+
conn.execute("INSERT INTO sections_fts(sections_fts) VALUES('rebuild')")
|
|
615
|
+
conn.execute("INSERT INTO examples_fts(examples_fts) VALUES('rebuild')")
|
|
616
|
+
conn.commit()
|
|
617
|
+
logger.info("Rebuilt sections_fts and examples_fts indexes")
|