Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/INTEGRATION-TEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <retained-index> --output regression-results.json`.
The new separate 3.15 fixtures do not replace that gate.

## Evidence log

| Test | Pass/Fail | Tester | Date | Notes |
Expand Down
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions src/mcp_server_python_docs/ingestion/cpython_versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand Down
65 changes: 65 additions & 0 deletions tests/fixtures/python315.json
Original file line number Diff line number Diff line change
@@ -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."
]
]
}
5 changes: 3 additions & 2 deletions tests/test_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
27 changes: 23 additions & 4 deletions tests/test_publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -363,14 +377,17 @@ 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
content checks (P2 fix for publish/validate consistency).
"""
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)

Expand All @@ -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 "
Expand Down
131 changes: 131 additions & 0 deletions tests/test_python315.py
Original file line number Diff line number Diff line change
@@ -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'<section id="{case["anchor"]}"><h2>{case["name"]}</h2>'
f'<p>{html.escape(body)}</p></section>')
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'<section id="{anchor}"><h2>{title}</h2><p>{body}</p></section>')
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)
Loading