diff --git a/.github/INTEGRATION-TEST.md b/.github/INTEGRATION-TEST.md index 75e7caa..8ff7277 100644 --- a/.github/INTEGRATION-TEST.md +++ b/.github/INTEGRATION-TEST.md @@ -142,6 +142,34 @@ or supported Python versions. - [ ] Inspect uploaded logs if a job fails - Expected: Sphinx/build logs are available as workflow artifacts +## Python 3.15 documentation checks + +This opt-in content target does not expand the runtime-support matrix or the +existing hosted workflow coverage. Record the interpreter used separately from +the documentation versions; do not infer named-client compatibility. + +1. Build a fresh index with + `uv run --locked python-docs-mcp-server build-index --versions 3.14,3.15`. + This replaces the index; include any other versions you need to retain. +2. Run `doctor`; confirm both versions contain documents and sections. +3. Disconnect network access for query checks. Verify `list_versions` lists + exactly the indexed versions, and that an explicit 3.15 request against a + 3.14-only index returns an error. +4. Search `asyncio.TaskGroup.cancel` with `version="3.15", kind="symbol"` + and retrieve the returned slug/anchor. Check the canonical location is + `https://docs.python.org/3.15/library/asyncio-task.html#asyncio.TaskGroup.cancel`. +5. Compare `asyncio.TaskGroup.cancel` (added), `base64.z85encode` (changed), + and `http.server.CGIHTTPRequestHandler` (removed) from 3.14 to 3.15. + Removed content belongs to the old version; consult the 3.15 What's New + removal section for migration guidance. +6. Paginate `whatsnew_for_version(version="3.15", max_sections=2)` using every + returned `next_start_index`. Check no gaps/duplicate anchors and bounded + response bodies. Retrieve individual sections via + `get_docs(slug="whatsnew/3.15", version="3.15", anchor=...)`. +7. Preserve the original 3.11–3.13 frozen regression index and run + `uv run --locked python -m benchmarks.regression --index --output regression-results.json`. + The new separate 3.15 fixtures do not replace that gate. + ## Evidence log | Test | Pass/Fail | Tester | Date | Notes | diff --git a/README.md b/README.md index 582ac79..bd7e937 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,34 @@ The first build downloads Python's `objects.inv` files, clones CPython docs sources, runs `sphinx-build -b json`, and writes an SQLite index to your local cache. It can take several minutes. +### Python 3.15 documentation (opt-in) + +The source-pinned documentation targets include Python 3.10–3.15. To index +3.15 alongside 3.14, explicitly run: + +```bash +uvx python-docs-mcp-server build-index --versions 3.14,3.15 +``` + +A build replaces the local index with the requested versions; include every +version you want to keep. The first-run examples above intentionally remain +unchanged. The builder still marks the highest **requested** version as the +index default; adding this target does not select 3.15 in an existing index. +`get_docs` without a version uses the configured/detected default, while +versionless search spans indexed versions. Pass `version="3.15"` for isolated +3.15 answers. `list_versions` reports the actual index, not all build targets; +an unindexed explicit version raises an error, never an older-docs fallback. + +This is documentation coverage, **not** a claim that the server runs on Python +3.15 or that a named MCP client was tested with it. Queries remain offline and +read-only after a successful build. Until it is indexed, consult the +[official 3.15 docs](https://docs.python.org/3.15/) directly. + +The 3.15 target is CPython tag `v3.15.0`, commit +`d220409a13b77de7a6c30abe16ea90db01528edc`, with Sphinx `8.2.3`. +See [integration checks](.github/INTEGRATION-TEST.md#python-315-documentation-checks) +for explicit-version and comparison examples. + ## Configure your MCP client ### Claude Code @@ -418,8 +446,10 @@ For contributor setup and verification: Tested on macOS and Linux. Windows should work, but it is not verified on every release. -The server requires Python 3.12+ to run. Its generated documentation corpus -covers Python documentation versions 3.10 through 3.14. +The server requires Python 3.12+ to run. Source-pinned documentation targets +cover Python 3.10 through 3.15; Python 3.15 is opt-in. The versions available +for queries depend on the documentation versions selected when building the +index. ## License diff --git a/src/mcp_server_python_docs/ingestion/cpython_versions.py b/src/mcp_server_python_docs/ingestion/cpython_versions.py index 2103d3d..be03921 100644 --- a/src/mcp_server_python_docs/ingestion/cpython_versions.py +++ b/src/mcp_server_python_docs/ingestion/cpython_versions.py @@ -18,6 +18,7 @@ class CPythonDocsBuildConfig(TypedDict): "3.12", "3.13", "3.14", + "3.15", ) SUPPORTED_DOC_VERSIONS_CSV: Final[str] = ",".join(SUPPORTED_DOC_VERSIONS) @@ -45,6 +46,13 @@ class CPythonDocsBuildConfig(TypedDict): "sha": "01104ce1beb3135c2e0c01ec835b994c1f55a1c0", "sphinx_pin": "sphinx<9.0.0", }, + # v3.15.0 annotated tag peeled via git ls-remote on 2026-10-15; + # Doc/requirements.txt at this commit requires Sphinx <9. + "3.15": { + "tag": "v3.15.0", + "sha": "d220409a13b77de7a6c30abe16ea90db01528edc", + "sphinx_pin": "sphinx==8.2.3", + }, "3.14": { "tag": "v3.14.4", "sha": "23116f998f6789d8c2fbe5ed5b8146854c8c2a4f", diff --git a/tests/fixtures/python315.json b/tests/fixtures/python315.json new file mode 100644 index 0000000..5ba746c --- /dev/null +++ b/tests/fixtures/python315.json @@ -0,0 +1,65 @@ +{ + "provenance": { + "source": "https://github.com/python/cpython", + "tag": "v3.15.0", + "commit": "d220409a13b77de7a6c30abe16ea90db01528edc", + "note": "Small authored offline ingestion fixtures using source-backed excerpts; not a full-index build substitute." + }, + "symbols": [ + { + "name": "asyncio.TaskGroup.cancel", + "slug": "library/asyncio-task", + "anchor": "asyncio.TaskGroup.cancel", + "change": "added", + "bodies": { + "3.15": "Request cancellation of all tasks in the group. New in version 3.15." + } + }, + { + "name": "base64.z85encode", + "slug": "library/base64", + "anchor": "base64.z85encode", + "change": "changed", + "bodies": { + "3.14": "Encode bytes-like object b in Z85 format.", + "3.15": "Encode bytes-like object b in Z85 format. Changed in version 3.15: Added the pad parameter." + } + }, + { + "name": "http.server.CGIHTTPRequestHandler", + "slug": "library/http.server", + "anchor": "http.server.CGIHTTPRequestHandler", + "change": "removed", + "bodies": { + "3.14": "CGIHTTPRequestHandler serves CGI scripts. Deprecated since version 3.13." + } + } + ], + "whatsnew": [ + [ + "new-features", + "New Features", + "" + ], + [ + "whatsnew315-frozendict", + "PEP 814: Add frozendict built-in type", + "A new immutable type, frozendict, is added to the builtins module." + ], + [ + "whatsnew315-sentinel", + "PEP 661: Add sentinel built-in type", + "A new sentinel type is added to the builtins module." + ], + [ + "removed", + "Removed", + "" + ], + [ + "http-server", + "http.server", + "Removed the CGIHTTPRequestHandler class and the --cgi flag." + ] + ] +} diff --git a/tests/test_ingestion.py b/tests/test_ingestion.py index b1f76f7..93cb852 100644 --- a/tests/test_ingestion.py +++ b/tests/test_ingestion.py @@ -44,8 +44,9 @@ class TestCPythonVersionConfig: def test_supports_python_3_10_through_3_14(self): - assert SUPPORTED_DOC_VERSIONS == ("3.10", "3.11", "3.12", "3.13", "3.14") - assert SUPPORTED_DOC_VERSIONS_CSV == "3.10,3.11,3.12,3.13,3.14" + assert SUPPORTED_DOC_VERSIONS[:5] == ("3.10", "3.11", "3.12", "3.13", "3.14") + assert SUPPORTED_DOC_VERSIONS == ("3.10", "3.11", "3.12", "3.13", "3.14", "3.15") + assert SUPPORTED_DOC_VERSIONS_CSV == "3.10,3.11,3.12,3.13,3.14,3.15" def test_supported_versions_have_pinned_docs_build_config(self): assert set(CPYTHON_DOCS_BUILD_CONFIG) == set(SUPPORTED_DOC_VERSIONS) diff --git a/tests/test_publish.py b/tests/test_publish.py index 1292089..3b7369e 100644 --- a/tests/test_publish.py +++ b/tests/test_publish.py @@ -257,12 +257,13 @@ def test_pass_on_populated_db(self, tmp_path): assert any("OK" in m for m in messages) def test_pass_on_supported_issue_5_version_range(self, tmp_path): - """Smoke tests accept the full supported docs version range.""" + """Preserve the original 3.10–3.14 build/default contract.""" + versions = ("3.10", "3.11", "3.12", "3.13", "3.14") db_path = tmp_path / "issue-5-range.db" - self._create_populated_db(db_path, versions=SUPPORTED_DOC_VERSIONS) + self._create_populated_db(db_path, versions=versions) passed, messages = run_smoke_tests( - db_path, expected_versions=SUPPORTED_DOC_VERSIONS + db_path, expected_versions=versions ) assert passed is True @@ -272,6 +273,19 @@ def test_pass_on_supported_issue_5_version_range(self, tmp_path): ) assert "OK: doc_sets: default version is 3.14" in messages + def test_pass_on_python315_version_range(self, tmp_path): + db_path = tmp_path / "issue-156-range.db" + self._create_populated_db(db_path, versions=SUPPORTED_DOC_VERSIONS) + passed, messages = run_smoke_tests( + db_path, expected_versions=SUPPORTED_DOC_VERSIONS + ) + assert passed is True + assert ( + "OK: doc_sets: expected versions present: 3.10, 3.11, 3.12, 3.13, 3.14, 3.15" + in messages + ) + assert "OK: doc_sets: default version is 3.15" in messages + def test_fails_when_expected_version_is_missing(self, tmp_path): """Smoke tests fail when a requested build version is absent.""" db_path = tmp_path / "missing-version.db" @@ -363,7 +377,7 @@ def test_symbols_only_fails_when_content_required(self, tmp_path): assert any("documents" in msg for msg in messages) - def test_symbol_only_mode_persisted_for_validation(self, tmp_path): + def test_symbol_only_mode_persisted_for_validation(self, tmp_path, monkeypatch): """Published symbol-only indexes record build_mode in ingestion_runs notes. This ensures validate-corpus can auto-detect the build mode and skip @@ -371,6 +385,9 @@ def test_symbol_only_mode_persisted_for_validation(self, tmp_path): """ from mcp_server_python_docs.ingestion.publish import publish_index + monkeypatch.setattr( + "mcp_server_python_docs.storage.db.get_cache_dir", lambda: tmp_path + ) db_path = tmp_path / "symbols-only-publish.db" self._create_symbols_only_db(db_path) @@ -382,6 +399,8 @@ def test_symbol_only_mode_persisted_for_validation(self, tmp_path): from mcp_server_python_docs.storage.db import get_index_path published = get_index_path() + assert published == tmp_path / "index.db" + assert not db_path.exists() conn = get_readonly_connection(published) row = conn.execute( "SELECT notes FROM ingestion_runs " diff --git a/tests/test_python315.py b/tests/test_python315.py new file mode 100644 index 0000000..eab5c7f --- /dev/null +++ b/tests/test_python315.py @@ -0,0 +1,131 @@ +"""Separate 3.15 coverage: production ingestion and unchanged service contracts.""" +from __future__ import annotations + +import html +import json +import socket +from pathlib import Path + +import pytest + +from mcp_server_python_docs.errors import VersionNotFoundError +from mcp_server_python_docs.ingestion.cpython_versions import CPYTHON_DOCS_BUILD_CONFIG +from mcp_server_python_docs.ingestion.sphinx_json import ingest_fjson_file, rebuild_fts_indexes +from mcp_server_python_docs.services.compare import CompareService +from mcp_server_python_docs.services.content import ContentService +from mcp_server_python_docs.services.search import SearchService +from mcp_server_python_docs.services.version import VersionService +from mcp_server_python_docs.services.whatsnew import WhatsNewService +from mcp_server_python_docs.storage.db import bootstrap_schema, get_readwrite_connection + +FIXTURE = json.loads((Path(__file__).parent / "fixtures/python315.json").read_text()) + + +@pytest.fixture +def docs315(tmp_path, monkeypatch): + def deny(*args, **kwargs): + raise AssertionError("Offline query attempted network access") + + monkeypatch.setattr(socket, "create_connection", deny) + monkeypatch.setattr(socket.socket, "connect", deny) + db = get_readwrite_connection(tmp_path / "index.db") + bootstrap_schema(db) + + def ingest(ds, slug, body): + path = tmp_path / "page.fjson" + path.write_text(json.dumps(dict(current_page_name=slug, title=slug, body=body))) + assert ingest_fjson_file(db, path, ds) + + for version in ("3.14", "3.15"): + ds = db.execute( + "INSERT INTO doc_sets (version, label, is_default, base_url) VALUES (?, ?, ?, ?)", + (version, f"Python {version}", int(version == "3.14"), + f"https://docs.python.org/{version}/"), + ).lastrowid + for case in FIXTURE["symbols"]: + if version not in case["bodies"]: + continue + body = case["bodies"][version] + ingest(ds, case["slug"], + f'

{case["name"]}

' + f'

{html.escape(body)}

') + db.execute( + "INSERT INTO symbols (doc_set_id, qualified_name, normalized_name, module, " + "symbol_type, uri, anchor) VALUES (?, ?, ?, ?, 'method', ?, ?)", + (ds, case["name"], case["name"].lower(), case["name"].split(".")[0], + f'{case["slug"]}.html#{case["anchor"]}', case["anchor"]), + ) + if version == "3.15": + parts = [] + for anchor, title, body in FIXTURE["whatsnew"]: + parts.append(f'

{title}

{body}

') + ingest(ds, "whatsnew/3.15", "".join(parts)) + rebuild_fts_indexes(db) + db.commit() + yield db + db.close() + + +def test_source_pin(): + pin = CPYTHON_DOCS_BUILD_CONFIG["3.15"] + assert pin["tag"] == FIXTURE["provenance"]["tag"] + assert pin["sha"] == FIXTURE["provenance"]["commit"] + assert pin["sphinx_pin"] == "sphinx==8.2.3" + + +def test_actual_index_and_preserved_default(docs315): + versions = VersionService(docs315).list_versions().versions + assert {v.version for v in versions} == {"3.14", "3.15"} + assert [v.version for v in versions if v.is_default] == ["3.14"] + result = ContentService(docs315).get_docs("library/base64", anchor="base64.z85encode") + assert result.version == "3.14" + + +@pytest.mark.parametrize("case", FIXTURE["symbols"], ids=lambda c: c["name"]) +def test_offline_search_content_comparison(docs315, case): + content = ContentService(docs315) + result = CompareService(docs315, content).compare(case["name"], "3.14", "3.15") + assert result.change == case["change"] + assert result.note is None + version = "3.14" if case["change"] == "removed" else "3.15" + hits = SearchService(docs315, {}).search(case["name"], version, kind="symbol").hits + assert hits + assert all(hit.version == version for hit in hits) + assert hits[0].anchor == case["anchor"] + assert hits[0].uri == f'{case["slug"]}.html#{case["anchor"]}' + page = content.get_docs(case["slug"], version, case["anchor"]) + assert page.content.strip() + assert page.version == version + + +def test_unindexed_explicit315_never_falls_back(docs315): + docs315.execute("DELETE FROM doc_sets WHERE version='3.15'") + assert [v.version for v in VersionService(docs315).list_versions().versions] == ["3.14"] + with pytest.raises(VersionNotFoundError, match="3.15.*not found"): + SearchService(docs315, {}).search("base64.z85encode", "3.15") + with pytest.raises(VersionNotFoundError, match="3.15.*not found"): + ContentService(docs315).get_docs("library/base64", "3.15") + with pytest.raises(VersionNotFoundError, match="3.15.*not found"): + CompareService(docs315, ContentService(docs315)).compare("base64.z85encode", "3.14", "3.15") + + +def test_whatsnew315_gap_free_bounded(docs315): + service = WhatsNewService(docs315) + expected = [anchor for anchor, _, body in FIXTURE["whatsnew"] if body] + anchors = [] + start = 0 + while True: + page = service.get("3.15", start_index=start, max_sections=2) + assert len(page.sections) <= 2 + assert len(page.model_dump_json()) <= 20_000 + anchors.extend(section.anchor for section in page.sections) + for section in page.sections: + assert ContentService(docs315).get_docs( + "whatsnew/3.15", "3.15", section.anchor + ).content.strip() + if page.next_start_index is None: + break + assert page.next_start_index > start + start = page.next_start_index + assert anchors == expected + assert len(set(anchors)) == len(anchors)