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.
Files changed (36) hide show
  1. mcp_server_python_docs/__init__.py +17 -0
  2. mcp_server_python_docs/__main__.py +574 -0
  3. mcp_server_python_docs/app_context.py +33 -0
  4. mcp_server_python_docs/data/synonyms.yaml +194 -0
  5. mcp_server_python_docs/detection.py +82 -0
  6. mcp_server_python_docs/diagnostics.py +74 -0
  7. mcp_server_python_docs/errors.py +33 -0
  8. mcp_server_python_docs/ingestion/__init__.py +1 -0
  9. mcp_server_python_docs/ingestion/cpython_versions.py +32 -0
  10. mcp_server_python_docs/ingestion/inventory.py +178 -0
  11. mcp_server_python_docs/ingestion/publish.py +504 -0
  12. mcp_server_python_docs/ingestion/sphinx_json.py +617 -0
  13. mcp_server_python_docs/models.py +216 -0
  14. mcp_server_python_docs/py.typed +0 -0
  15. mcp_server_python_docs/retrieval/__init__.py +31 -0
  16. mcp_server_python_docs/retrieval/budget.py +63 -0
  17. mcp_server_python_docs/retrieval/query.py +183 -0
  18. mcp_server_python_docs/retrieval/ranker.py +369 -0
  19. mcp_server_python_docs/server.py +329 -0
  20. mcp_server_python_docs/services/__init__.py +7 -0
  21. mcp_server_python_docs/services/cache.py +111 -0
  22. mcp_server_python_docs/services/content.py +155 -0
  23. mcp_server_python_docs/services/observability.py +130 -0
  24. mcp_server_python_docs/services/package_docs.py +161 -0
  25. mcp_server_python_docs/services/persistent_cache.py +147 -0
  26. mcp_server_python_docs/services/search.py +111 -0
  27. mcp_server_python_docs/services/version.py +47 -0
  28. mcp_server_python_docs/services/version_resolution.py +79 -0
  29. mcp_server_python_docs/storage/__init__.py +1 -0
  30. mcp_server_python_docs/storage/db.py +146 -0
  31. mcp_server_python_docs/storage/schema.sql +136 -0
  32. python_docs_mcp_server-0.1.2.dist-info/METADATA +528 -0
  33. python_docs_mcp_server-0.1.2.dist-info/RECORD +36 -0
  34. python_docs_mcp_server-0.1.2.dist-info/WHEEL +4 -0
  35. python_docs_mcp_server-0.1.2.dist-info/entry_points.txt +3 -0
  36. 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")