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,504 @@
1
+ """Atomic-swap publishing with smoke tests and rollback (PUBL-01 through PUBL-06).
2
+
3
+ Handles the full publish pipeline: timestamped build artifacts, SHA256 hashing,
4
+ smoke test validation, atomic rename with .previous backup, and the restart
5
+ message to stderr.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import logging
11
+ import os
12
+ import sqlite3
13
+ import sys
14
+ from collections.abc import Iterable
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+ from typing import Final
18
+
19
+ from mcp_server_python_docs.storage.db import (
20
+ get_cache_dir,
21
+ get_index_path,
22
+ get_readonly_connection,
23
+ )
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ SMOKE_SENTINEL_SYMBOL: Final[str] = "asyncio.run"
28
+
29
+
30
+ def _version_sort_key(version: str) -> tuple[int, ...]:
31
+ """Sort dotted Python versions numerically."""
32
+ return tuple(int(part) for part in version.split("."))
33
+
34
+
35
+ def parse_expected_versions(version: str) -> list[str]:
36
+ """Parse a comma-separated build version string."""
37
+ return [v.strip() for v in version.split(",") if v.strip()]
38
+
39
+
40
+ def generate_build_path() -> Path:
41
+ """Generate a timestamped build artifact path (PUBL-01).
42
+
43
+ Returns a path like ``~/.cache/mcp-python-docs/build-20260416-143022-123456.db``.
44
+ Creates the cache directory if it does not exist.
45
+
46
+ Returns:
47
+ Path to the new build artifact.
48
+ """
49
+ cache_dir = get_cache_dir()
50
+ cache_dir.mkdir(parents=True, exist_ok=True)
51
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
52
+ return cache_dir / f"build-{timestamp}.db"
53
+
54
+
55
+ def compute_sha256(db_path: Path) -> str:
56
+ """Compute the SHA256 hex digest of a file (PUBL-02).
57
+
58
+ Reads the file in 8KB chunks for memory efficiency.
59
+
60
+ Args:
61
+ db_path: Path to the database file.
62
+
63
+ Returns:
64
+ SHA256 hex digest string (64 characters).
65
+ """
66
+ h = hashlib.sha256()
67
+ with open(db_path, "rb") as f:
68
+ while True:
69
+ chunk = f.read(8192)
70
+ if not chunk:
71
+ break
72
+ h.update(chunk)
73
+ return h.hexdigest()
74
+
75
+
76
+ def record_ingestion_run(
77
+ conn: sqlite3.Connection,
78
+ source: str,
79
+ version: str,
80
+ status: str,
81
+ artifact_hash: str | None,
82
+ notes: str | None = None,
83
+ ) -> int:
84
+ """Record an ingestion run in the ingestion_runs table (PUBL-02).
85
+
86
+ Args:
87
+ conn: Read-write SQLite connection.
88
+ source: Source identifier (e.g., 'python-docs').
89
+ version: Version string (e.g., '3.13' or '3.10,3.11,3.12,3.13,3.14').
90
+ status: Run status ('building', 'smoke_testing', 'published', 'failed').
91
+ artifact_hash: SHA256 hash of the build artifact.
92
+ notes: Optional notes about the run.
93
+
94
+ Returns:
95
+ Row ID of the inserted record.
96
+ """
97
+ cursor = conn.execute(
98
+ "INSERT INTO ingestion_runs "
99
+ "(source, version, status, started_at, finished_at, artifact_hash, notes) "
100
+ "VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?, ?)",
101
+ (source, version, status, artifact_hash, notes),
102
+ )
103
+ conn.commit()
104
+ return cursor.lastrowid # type: ignore[return-value]
105
+
106
+
107
+ def run_smoke_tests(
108
+ db_path: Path,
109
+ *,
110
+ require_content: bool = True,
111
+ expected_versions: Iterable[str] | None = None,
112
+ ) -> tuple[bool, list[str]]:
113
+ """Run smoke tests against a newly built database (PUBL-03).
114
+
115
+ Validates that the index has sufficient data to be useful:
116
+ - doc_sets table has at least 1 row
117
+ - symbols table has at least 1000 rows
118
+ - For content builds: documents/sections are populated and sections_fts is searchable
119
+ - For symbol-only builds: symbols_fts is searchable
120
+
121
+ Args:
122
+ db_path: Path to the database to test.
123
+ require_content: When True, enforce document/section checks suitable for
124
+ full-content builds. When False, validate a symbol-only build.
125
+ expected_versions: Optional versions requested by the build command. When
126
+ present, each version must have its own expected corpus rows and the
127
+ highest requested version must be the default.
128
+
129
+ Returns:
130
+ Tuple of (passed, messages). ``passed`` is True only if ALL
131
+ checks succeed.
132
+ """
133
+ messages: list[str] = []
134
+ passed = True
135
+
136
+ try:
137
+ conn = get_readonly_connection(db_path)
138
+ except Exception as e:
139
+ return False, [f"FAIL: Cannot open database: {e}"]
140
+
141
+ try:
142
+ expected_version_list = list(dict.fromkeys(expected_versions or []))
143
+
144
+ # Check doc_sets
145
+ doc_set_rows = conn.execute(
146
+ "SELECT id, version, is_default FROM doc_sets"
147
+ ).fetchall()
148
+ count = len(doc_set_rows)
149
+ present_versions = [row["version"] for row in doc_set_rows]
150
+ if count >= 1:
151
+ messages.append(f"OK: doc_sets: {count} rows")
152
+ else:
153
+ messages.append(f"FAIL: doc_sets: {count} rows (need >= 1)")
154
+ passed = False
155
+
156
+ if expected_version_list:
157
+ missing_versions = [
158
+ version
159
+ for version in expected_version_list
160
+ if version not in present_versions
161
+ ]
162
+ if missing_versions:
163
+ messages.append(
164
+ "FAIL: doc_sets: missing expected versions: "
165
+ + ", ".join(missing_versions)
166
+ )
167
+ passed = False
168
+ else:
169
+ messages.append(
170
+ "OK: doc_sets: expected versions present: "
171
+ + ", ".join(expected_version_list)
172
+ )
173
+
174
+ versions_for_default_check = expected_version_list or present_versions
175
+ if versions_for_default_check:
176
+ expected_default = max(versions_for_default_check, key=_version_sort_key)
177
+ default_versions = [
178
+ row["version"] for row in doc_set_rows if row["is_default"]
179
+ ]
180
+ if not default_versions:
181
+ messages.append(
182
+ f"FAIL: doc_sets: no default version (expected {expected_default})"
183
+ )
184
+ passed = False
185
+ elif len(default_versions) > 1:
186
+ messages.append(
187
+ "FAIL: doc_sets: multiple default versions: "
188
+ + ", ".join(default_versions)
189
+ )
190
+ passed = False
191
+ elif default_versions[0] != expected_default:
192
+ messages.append(
193
+ "FAIL: doc_sets: default version is "
194
+ f"{default_versions[0]} (expected {expected_default})"
195
+ )
196
+ passed = False
197
+ else:
198
+ messages.append(f"OK: doc_sets: default version is {expected_default}")
199
+
200
+ # Check symbols
201
+ count = conn.execute("SELECT COUNT(*) FROM symbols").fetchone()[0]
202
+ if count >= 1000:
203
+ messages.append(f"OK: symbols: {count} rows")
204
+ else:
205
+ messages.append(f"FAIL: symbols: {count} rows (need >= 1000)")
206
+ passed = False
207
+
208
+ for version in expected_version_list:
209
+ count = conn.execute(
210
+ "SELECT COUNT(*) FROM symbols "
211
+ "JOIN doc_sets ON doc_sets.id = symbols.doc_set_id "
212
+ "WHERE doc_sets.version = ?",
213
+ (version,),
214
+ ).fetchone()[0]
215
+ if count >= 1000:
216
+ messages.append(f"OK: symbols: version {version} has {count} rows")
217
+ else:
218
+ messages.append(
219
+ f"FAIL: symbols: version {version} has {count} rows (need >= 1000)"
220
+ )
221
+ passed = False
222
+
223
+ row = conn.execute(
224
+ "SELECT 1 FROM symbols "
225
+ "JOIN doc_sets ON doc_sets.id = symbols.doc_set_id "
226
+ "WHERE doc_sets.version = ? "
227
+ "AND symbols.qualified_name = ? LIMIT 1",
228
+ (version, SMOKE_SENTINEL_SYMBOL),
229
+ ).fetchone()
230
+ if row:
231
+ messages.append(
232
+ f"OK: sentinel: {SMOKE_SENTINEL_SYMBOL} symbol found "
233
+ f"for version {version}"
234
+ )
235
+ else:
236
+ messages.append(
237
+ f"FAIL: sentinel: {SMOKE_SENTINEL_SYMBOL} symbol missing "
238
+ f"for version {version}"
239
+ )
240
+ passed = False
241
+
242
+ if require_content:
243
+ # Check documents
244
+ count = conn.execute("SELECT COUNT(*) FROM documents").fetchone()[0]
245
+ if count >= 10:
246
+ messages.append(f"OK: documents: {count} rows")
247
+ else:
248
+ messages.append(f"FAIL: documents: {count} rows (need >= 10)")
249
+ passed = False
250
+
251
+ # Check sections
252
+ count = conn.execute("SELECT COUNT(*) FROM sections").fetchone()[0]
253
+ if count >= 50:
254
+ messages.append(f"OK: sections: {count} rows")
255
+ else:
256
+ messages.append(f"FAIL: sections: {count} rows (need >= 50)")
257
+ passed = False
258
+
259
+ for version in expected_version_list:
260
+ count = conn.execute(
261
+ "SELECT COUNT(*) FROM documents "
262
+ "JOIN doc_sets ON doc_sets.id = documents.doc_set_id "
263
+ "WHERE doc_sets.version = ?",
264
+ (version,),
265
+ ).fetchone()[0]
266
+ if count >= 10:
267
+ messages.append(
268
+ f"OK: documents: version {version} has {count} rows"
269
+ )
270
+ else:
271
+ messages.append(
272
+ f"FAIL: documents: version {version} has {count} rows (need >= 10)"
273
+ )
274
+ passed = False
275
+
276
+ count = conn.execute(
277
+ "SELECT COUNT(*) FROM sections "
278
+ "JOIN documents ON documents.id = sections.document_id "
279
+ "JOIN doc_sets ON doc_sets.id = documents.doc_set_id "
280
+ "WHERE doc_sets.version = ?",
281
+ (version,),
282
+ ).fetchone()[0]
283
+ if count >= 50:
284
+ messages.append(
285
+ f"OK: sections: version {version} has {count} rows"
286
+ )
287
+ else:
288
+ messages.append(
289
+ f"FAIL: sections: version {version} has {count} rows (need >= 50)"
290
+ )
291
+ passed = False
292
+
293
+ # Spot-check: asyncio document exists
294
+ row = conn.execute(
295
+ "SELECT 1 FROM documents WHERE slug LIKE '%asyncio%' LIMIT 1"
296
+ ).fetchone()
297
+ if row:
298
+ messages.append("OK: spot-check: asyncio document found")
299
+ else:
300
+ messages.append("FAIL: spot-check: no asyncio document found")
301
+ passed = False
302
+
303
+ # FTS5 check: sections_fts is searchable
304
+ try:
305
+ row = conn.execute(
306
+ 'SELECT 1 FROM sections_fts WHERE sections_fts MATCH \'"asyncio"\' LIMIT 1'
307
+ ).fetchone()
308
+ if row:
309
+ messages.append("OK: fts5: sections_fts searchable")
310
+ else:
311
+ messages.append(
312
+ "WARN: fts5: sections_fts has no asyncio matches"
313
+ " (may be OK for partial builds)"
314
+ )
315
+ except sqlite3.OperationalError as e:
316
+ messages.append(f"FAIL: fts5: sections_fts query failed: {e}")
317
+ passed = False
318
+ else:
319
+ messages.append("OK: content checks skipped for symbol-only build")
320
+ try:
321
+ row = conn.execute(
322
+ 'SELECT 1 FROM symbols_fts WHERE symbols_fts MATCH \'"asyncio"\' LIMIT 1'
323
+ ).fetchone()
324
+ if row:
325
+ messages.append("OK: fts5: symbols_fts searchable")
326
+ else:
327
+ messages.append(
328
+ "WARN: fts5: symbols_fts has no asyncio matches"
329
+ " (unexpected for stdlib builds)"
330
+ )
331
+ except sqlite3.OperationalError as e:
332
+ messages.append(f"FAIL: fts5: symbols_fts query failed: {e}")
333
+ passed = False
334
+
335
+ except Exception as e:
336
+ messages.append(f"FAIL: Unexpected error during smoke tests: {e}")
337
+ passed = False
338
+ finally:
339
+ conn.close()
340
+
341
+ return passed, messages
342
+
343
+
344
+ def atomic_swap(
345
+ new_db_path: Path,
346
+ target_path: Path | None = None,
347
+ ) -> Path | None:
348
+ """Atomically swap a new database into place (PUBL-04).
349
+
350
+ If a previous ``index.db`` exists, it is renamed to ``index.db.previous``
351
+ for rollback. The new database is then renamed to ``index.db``.
352
+
353
+ Both renames must be on the same filesystem for POSIX atomicity.
354
+
355
+ Args:
356
+ new_db_path: Path to the new build artifact.
357
+ target_path: Target path for index.db. Defaults to the standard
358
+ cache location.
359
+
360
+ Returns:
361
+ Path to the .previous backup if one was created, or None.
362
+ """
363
+ if target_path is None:
364
+ target_path = get_index_path()
365
+
366
+ previous_path: Path | None = None
367
+
368
+ if target_path.exists():
369
+ previous = target_path.parent / (target_path.name + ".previous")
370
+ # Remove old .previous if it exists
371
+ if previous.exists():
372
+ previous.unlink()
373
+ os.rename(target_path, previous)
374
+ logger.info("Previous index backed up to %s", previous)
375
+ previous_path = previous
376
+
377
+ os.replace(new_db_path, target_path)
378
+ logger.info("New index published at %s", target_path)
379
+
380
+ return previous_path
381
+
382
+
383
+ def rollback(target_path: Path | None = None) -> bool:
384
+ """Restore the previous index from .previous backup.
385
+
386
+ Args:
387
+ target_path: Path to index.db. Defaults to the standard
388
+ cache location.
389
+
390
+ Returns:
391
+ True if rollback succeeded, False if no .previous exists.
392
+ """
393
+ if target_path is None:
394
+ target_path = get_index_path()
395
+
396
+ previous = target_path.parent / (target_path.name + ".previous")
397
+
398
+ if previous.exists():
399
+ os.replace(previous, target_path)
400
+ logger.info("Rolled back to previous index")
401
+ return True
402
+
403
+ logger.warning("No .previous backup found at %s", previous)
404
+ return False
405
+
406
+
407
+ def print_restart_message() -> None:
408
+ """Print the restart message to stderr (PUBL-05).
409
+
410
+ Stdout is reserved for MCP protocol messages — this message
411
+ must go to stderr only.
412
+ """
413
+ print(
414
+ "Index rebuilt. Restart your MCP client to pick up the new index.",
415
+ file=sys.stderr,
416
+ )
417
+
418
+
419
+ def publish_index(
420
+ build_db_path: Path,
421
+ version: str,
422
+ *,
423
+ require_content: bool = True,
424
+ ) -> bool:
425
+ """Orchestrate the full publish pipeline.
426
+
427
+ 1. Compute SHA256 of the build artifact
428
+ 2. Record ingestion run
429
+ 3. Run smoke tests
430
+ 4. If passed: atomic swap + restart message
431
+ 5. If failed: update run status, return False
432
+
433
+ Args:
434
+ build_db_path: Path to the build artifact database.
435
+ version: Version string for the ingestion run record.
436
+ require_content: Whether publish validation should require content tables.
437
+
438
+ Returns:
439
+ True if publishing succeeded, False if smoke tests failed.
440
+ """
441
+ # Compute SHA256 (PUBL-02)
442
+ artifact_hash = compute_sha256(build_db_path)
443
+ logger.info("Build artifact SHA256: %s", artifact_hash)
444
+
445
+ # Record ingestion run
446
+ from mcp_server_python_docs.storage.db import get_readwrite_connection
447
+
448
+ build_notes = "build_mode=symbol_only" if not require_content else None
449
+ conn = get_readwrite_connection(build_db_path)
450
+ try:
451
+ run_id = record_ingestion_run(
452
+ conn,
453
+ source="python-docs",
454
+ version=version,
455
+ status="smoke_testing",
456
+ artifact_hash=artifact_hash,
457
+ notes=build_notes,
458
+ )
459
+ finally:
460
+ conn.close()
461
+
462
+ # Run smoke tests (PUBL-03)
463
+ passed, messages = run_smoke_tests(
464
+ build_db_path,
465
+ require_content=require_content,
466
+ expected_versions=parse_expected_versions(version),
467
+ )
468
+ for msg in messages:
469
+ logger.info("Smoke test: %s", msg)
470
+
471
+ if not passed:
472
+ # Update run status to failed
473
+ conn = get_readwrite_connection(build_db_path)
474
+ try:
475
+ conn.execute(
476
+ "UPDATE ingestion_runs SET status = ?, notes = ?, "
477
+ "finished_at = CURRENT_TIMESTAMP WHERE id = ?",
478
+ ("failed", "\n".join(messages), run_id),
479
+ )
480
+ conn.commit()
481
+ finally:
482
+ conn.close()
483
+ logger.error("Smoke tests failed — not publishing")
484
+ return False
485
+
486
+ # Update run status to published (preserve build_mode note)
487
+ conn = get_readwrite_connection(build_db_path)
488
+ try:
489
+ conn.execute(
490
+ "UPDATE ingestion_runs SET status = ?, notes = ?, "
491
+ "finished_at = CURRENT_TIMESTAMP WHERE id = ?",
492
+ ("published", build_notes, run_id),
493
+ )
494
+ conn.commit()
495
+ finally:
496
+ conn.close()
497
+
498
+ # Atomic swap (PUBL-04)
499
+ atomic_swap(build_db_path)
500
+
501
+ # Restart message (PUBL-05)
502
+ print_restart_message()
503
+
504
+ return True