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,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
|