Skip to content

Commit 30a41a4

Browse files
committed
fix(docs): resolve F-01 Python 3.10-3.14 support
Closes #5
1 parent 5c8666f commit 30a41a4

14 files changed

Lines changed: 221 additions & 53 deletions

File tree

.github/INTEGRATION-TEST.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Release-specific sign-off still lives in [`.github/RELEASE.md`](RELEASE.md).
1313
- `uv run pyright src/`
1414
- `uv run pytest --tb=short -q`
1515
- Local index build completed:
16-
- `uv run mcp-server-python-docs build-index --versions 3.12,3.13`
16+
- `uv run mcp-server-python-docs build-index --versions 3.10,3.11,3.12,3.13,3.14`
1717
- Doctor passes:
1818
- `uv run mcp-server-python-docs doctor`
1919
- Slow E2E workflow passes when preparing a release:
@@ -113,7 +113,7 @@ locked.
113113

114114
- [ ] `uvx mcp-server-python-docs --version`
115115
- Expected: prints the current package version
116-
- [ ] `uvx mcp-server-python-docs build-index --versions 3.12,3.13`
116+
- [ ] `uvx mcp-server-python-docs build-index --versions 3.10,3.11,3.12,3.13,3.14`
117117
- Expected: index build completes successfully
118118
- [ ] `uvx mcp-server-python-docs doctor`
119119
- Expected: all required checks pass
@@ -131,8 +131,8 @@ or supported Python versions.
131131
- Expected: both Python 3.13 and Python 3.14 jobs start
132132
- [ ] Confirm each job installs the built wheel into a clean virtual environment
133133
- Expected: the command path is the installed `mcp-server-python-docs`, not editable source
134-
- [ ] Confirm `build-index --versions 3.12,3.13` passes
135-
- Expected: both versions produce content, not symbol-only fallback
134+
- [ ] Confirm `build-index --versions 3.10,3.11,3.12,3.13,3.14` passes
135+
- Expected: all five versions produce content, not symbol-only fallback
136136
- [ ] Confirm `doctor` and `validate-corpus` pass
137137
- Expected: corpus smoke checks include requested versions and the default version
138138
- [ ] Inspect uploaded logs if a job fails

.github/RELEASE.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,15 @@ Before the first release, configure PyPI Trusted Publishing:
1919

2020
## Notes
2121

22-
**Python version coverage:** The release workflow builds and tests against Python 3.13 only.
23-
Python 3.12 is covered by the CI workflow (`ci.yml`) which runs a 2x2 matrix (3.12/3.13 x
24-
ubuntu/macos) on every push to `main`. Since tags are created from commits that have already
25-
passed CI, 3.12 compatibility is verified before the release workflow runs. This is an accepted
26-
trade-off to keep the release artifact pipeline simple (single Python version produces the wheel).
22+
**Runtime coverage:** The release workflow builds and tests against Python 3.13 only.
23+
Python 3.12 is covered by the CI workflow (`ci.yml`) which runs a 2x2 matrix
24+
(3.12/3.13 x ubuntu/macos) on every push to `main`. Since tags are created
25+
from commits that have already passed CI, 3.12 compatibility is verified before
26+
the release workflow runs. This is an accepted trade-off to keep the release
27+
artifact pipeline simple (single Python version produces the wheel).
28+
29+
**Documentation coverage:** The full docs index target is Python documentation
30+
versions 3.10 through 3.14.
2731

2832
## Creating a Release
2933

@@ -106,7 +110,7 @@ Complete these steps in order. Each step has a checkbox -- do not skip ahead.
106110
First public release of mcp-server-python-docs.
107111
108112
A read-only, version-aware MCP retrieval server over Python
109-
standard library documentation (3.12 + 3.13).
113+
standard library documentation (3.10 through 3.14).
110114
111115
Installable via: uvx mcp-server-python-docs"
112116
```
@@ -137,7 +141,7 @@ Complete these steps in order. Each step has a checkbox -- do not skip ahead.
137141
# Should print 0.1.0
138142

139143
# Step 2: Build index
140-
uvx mcp-server-python-docs build-index --versions 3.12,3.13
144+
uvx mcp-server-python-docs build-index --versions 3.10,3.11,3.12,3.13,3.14
141145
# Should complete successfully
142146

143147
# Step 3: Doctor check
@@ -148,7 +152,8 @@ Complete these steps in order. Each step has a checkbox -- do not skip ahead.
148152
- Run GitHub Actions workflow `Slow E2E`
149153
- Confirm Python 3.13 and Python 3.14 jobs both pass
150154
- Confirm each job installs the built wheel, runs
151-
`build-index --versions 3.12,3.13`, `doctor`, and `validate-corpus`
155+
`build-index --versions 3.10,3.11,3.12,3.13,3.14`, `doctor`, and
156+
`validate-corpus`
152157
- [ ] Claude Desktop test with published package:
153158
Configure `mcpServers` with `uvx mcp-server-python-docs` and verify
154159
"what is asyncio.TaskGroup" returns a correct hit

.github/workflows/e2e.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ jobs:
99
installed-build-index:
1010
name: Installed build-index (Python ${{ matrix.python-version }})
1111
runs-on: ubuntu-latest
12-
timeout-minutes: 60
12+
timeout-minutes: 120
1313

1414
strategy:
1515
fail-fast: false
@@ -42,7 +42,7 @@ jobs:
4242
- name: Build and validate full docs index
4343
run: |
4444
set -o pipefail
45-
.e2e-venv/bin/mcp-server-python-docs build-index --versions 3.12,3.13 \
45+
.e2e-venv/bin/mcp-server-python-docs build-index --versions 3.10,3.11,3.12,3.13,3.14 \
4646
2>&1 | tee "${RUNNER_TEMP}/build-index-${{ matrix.python-version }}.log"
4747
.e2e-venv/bin/mcp-server-python-docs doctor
4848
.e2e-venv/bin/mcp-server-python-docs validate-corpus

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ uv run pytest tests/test_retrieval_regression.py -q
4242
The server needs a local SQLite index before runtime validation:
4343

4444
```bash
45-
uv run mcp-server-python-docs build-index --versions 3.12,3.13
45+
uv run mcp-server-python-docs build-index --versions 3.10,3.11,3.12,3.13,3.14
4646
uv run mcp-server-python-docs doctor
4747
uv run mcp-server-python-docs validate-corpus
4848
```

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,13 @@ shell or use `python -m uv ...` as a fallback for local contributor commands.
8080
Build the local documentation index:
8181

8282
```bash
83-
uvx mcp-server-python-docs build-index --versions 3.12,3.13
83+
uvx mcp-server-python-docs build-index --versions 3.10,3.11,3.12,3.13,3.14
8484
```
8585

8686
If you installed the package persistently, you can drop the `uvx` prefix:
8787

8888
```bash
89-
mcp-server-python-docs build-index --versions 3.12,3.13
89+
mcp-server-python-docs build-index --versions 3.10,3.11,3.12,3.13,3.14
9090
```
9191

9292
This downloads Python's `objects.inv` files, clones CPython docs sources, runs
@@ -178,7 +178,7 @@ The server currently exposes four MCP tools:
178178
Use this server when you need:
179179

180180
- exact Python stdlib symbol resolution
181-
- consistent version-aware answers across Python 3.12 and 3.13
181+
- consistent version-aware answers across Python 3.10 through 3.14
182182
- token-efficient section retrieval from official docs
183183
- a local, read-only MCP server with a simple operational story
184184

@@ -292,7 +292,7 @@ For contributor setup and verification:
292292
Tested on macOS and Linux. Windows should work, but it is not verified on
293293
every release.
294294

295-
Python 3.12 and 3.13 are currently supported.
295+
Python documentation versions 3.10 through 3.14 are currently supported.
296296

297297
## License
298298

src/mcp_server_python_docs/__main__.py

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ def serve() -> None:
105105
@click.option(
106106
"--versions",
107107
required=True,
108-
help="Comma-separated Python versions (e.g., 3.12,3.13)",
108+
help="Comma-separated Python versions (e.g., 3.10,3.11,3.12,3.13,3.14)",
109109
)
110110
@click.option(
111111
"--skip-content",
@@ -120,6 +120,9 @@ def build_index(versions: str, skip_content: bool) -> None:
120120
import venv
121121
from pathlib import Path
122122

123+
from mcp_server_python_docs.ingestion.cpython_versions import (
124+
CPYTHON_DOCS_BUILD_CONFIG,
125+
)
123126
from mcp_server_python_docs.ingestion.inventory import ingest_inventory
124127
from mcp_server_python_docs.ingestion.publish import (
125128
_version_sort_key,
@@ -128,6 +131,7 @@ def build_index(versions: str, skip_content: bool) -> None:
128131
publish_index,
129132
)
130133
from mcp_server_python_docs.ingestion.sphinx_json import (
134+
build_sphinx_bootstrap_requirements,
131135
build_sphinx_json_command,
132136
ingest_sphinx_json_dir,
133137
make_sphinx_json_env,
@@ -142,15 +146,11 @@ def build_index(versions: str, skip_content: bool) -> None:
142146
get_readwrite_connection,
143147
)
144148

145-
# Version tag mapping: CPython git tag and Sphinx constraints (INGR-C-02)
146-
VERSION_CONFIG: dict[str, dict[str, str]] = {
147-
"3.12": {"tag": "v3.12.13", "sphinx_pin": "sphinx~=8.2.0"},
148-
"3.13": {"tag": "v3.13.12", "sphinx_pin": "sphinx<9.0.0"},
149-
}
150-
151149
version_list = parse_expected_versions(versions)
152150
if not version_list:
153-
logger.error("No valid versions specified. Example: --versions 3.13")
151+
logger.error(
152+
"No valid versions specified. Example: --versions 3.10,3.11,3.12,3.13,3.14"
153+
)
154154
raise SystemExit(1)
155155

156156
# Validate version format before sorting (CR-03, WR-04)
@@ -188,7 +188,7 @@ def build_index(versions: str, skip_content: bool) -> None:
188188
continue
189189

190190
# === Content ingestion (INGR-C-01 through INGR-C-03) ===
191-
config = VERSION_CONFIG.get(version)
191+
config = CPYTHON_DOCS_BUILD_CONFIG.get(version)
192192
if not config:
193193
logger.warning(
194194
"No CPython build config for %s, skipping content ingestion",
@@ -226,9 +226,17 @@ def build_index(versions: str, skip_content: bool) -> None:
226226
)
227227
pip_path = os.path.join(scripts_dir, "pip")
228228

229-
# Install Sphinx with the version pin for this CPython branch
229+
# Install Sphinx with the version pin for this CPython branch.
230+
# Older Sphinx releases still import pkg_resources, which
231+
# modern venvs do not always seed by default.
230232
subprocess.run(
231-
[pip_path, "install", config["sphinx_pin"]],
233+
[
234+
pip_path,
235+
"install",
236+
*build_sphinx_bootstrap_requirements(
237+
config["sphinx_pin"]
238+
),
239+
],
232240
check=True,
233241
capture_output=True,
234242
text=True,
@@ -381,7 +389,10 @@ def validate_corpus(db_path: str | None) -> None:
381389

382390
if not target.exists():
383391
logger.error("Index not found at %s", target)
384-
logger.error("Run: mcp-server-python-docs build-index --versions 3.13")
392+
logger.error(
393+
"Run: mcp-server-python-docs build-index --versions "
394+
"3.10,3.11,3.12,3.13,3.14"
395+
)
385396
raise SystemExit(1)
386397

387398
logger.info("Validating corpus at %s", target)
@@ -506,7 +517,8 @@ def doctor() -> None:
506517
index_detail = str(index_path)
507518
if not index_exists:
508519
index_detail += (
509-
" (not found -- run: mcp-server-python-docs build-index --versions 3.13)"
520+
" (not found -- run: mcp-server-python-docs build-index --versions "
521+
"3.10,3.11,3.12,3.13,3.14)"
510522
)
511523
else:
512524
size_mb = index_path.stat().st_size / (1024 * 1024)

src/mcp_server_python_docs/detection.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ def match_to_indexed(
7474
"""Match a detected version to the closest indexed version.
7575
7676
Returns the detected version if it's in the index, otherwise None.
77-
We don't guess if 3.11 is detected but only 3.12/3.13 are indexed,
78-
return None and let the normal default resolution handle it.
77+
We don't guess -- if a detected version is not indexed, return None and
78+
let the normal default resolution handle it.
7979
"""
8080
if detected in indexed_versions:
8181
return detected
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Pinned CPython documentation build targets."""
2+
from __future__ import annotations
3+
4+
from typing import Final, TypedDict
5+
6+
7+
class CPythonDocsBuildConfig(TypedDict):
8+
"""Build settings for one CPython documentation release."""
9+
10+
tag: str
11+
sphinx_pin: str
12+
13+
14+
SUPPORTED_DOC_VERSIONS: Final[tuple[str, ...]] = (
15+
"3.10",
16+
"3.11",
17+
"3.12",
18+
"3.13",
19+
"3.14",
20+
)
21+
22+
SUPPORTED_DOC_VERSIONS_CSV: Final[str] = ",".join(SUPPORTED_DOC_VERSIONS)
23+
24+
# CPython git tags are pinned so content builds are reproducible and do not
25+
# drift when a maintenance branch receives new commits.
26+
CPYTHON_DOCS_BUILD_CONFIG: Final[dict[str, CPythonDocsBuildConfig]] = {
27+
"3.10": {"tag": "v3.10.20", "sphinx_pin": "sphinx==3.4.3"},
28+
"3.11": {"tag": "v3.11.15", "sphinx_pin": "sphinx~=7.2.0"},
29+
"3.12": {"tag": "v3.12.13", "sphinx_pin": "sphinx~=8.2.0"},
30+
"3.13": {"tag": "v3.13.13", "sphinx_pin": "sphinx<9.0.0"},
31+
"3.14": {"tag": "v3.14.4", "sphinx_pin": "sphinx<9.0.0"},
32+
}

src/mcp_server_python_docs/ingestion/publish.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323

2424
logger = logging.getLogger(__name__)
2525

26+
SMOKE_SENTINEL_SYMBOL = "asyncio.run"
27+
2628

2729
def _version_sort_key(version: str) -> tuple[int, ...]:
2830
"""Sort dotted Python versions numerically."""
@@ -83,7 +85,7 @@ def record_ingestion_run(
8385
Args:
8486
conn: Read-write SQLite connection.
8587
source: Source identifier (e.g., 'python-docs').
86-
version: Version string (e.g., '3.13' or '3.12,3.13').
88+
version: Version string (e.g., '3.13' or '3.10,3.11,3.12,3.13,3.14').
8789
status: Run status ('building', 'smoke_testing', 'published', 'failed').
8890
artifact_hash: SHA256 hash of the build artifact.
8991
notes: Optional notes about the run.
@@ -221,16 +223,18 @@ def run_smoke_tests(
221223
"SELECT 1 FROM symbols "
222224
"JOIN doc_sets ON doc_sets.id = symbols.doc_set_id "
223225
"WHERE doc_sets.version = ? "
224-
"AND symbols.qualified_name = 'asyncio.TaskGroup' LIMIT 1",
225-
(version,),
226+
"AND symbols.qualified_name = ? LIMIT 1",
227+
(version, SMOKE_SENTINEL_SYMBOL),
226228
).fetchone()
227229
if row:
228230
messages.append(
229-
f"OK: sentinel: asyncio.TaskGroup symbol found for version {version}"
231+
f"OK: sentinel: {SMOKE_SENTINEL_SYMBOL} symbol found "
232+
f"for version {version}"
230233
)
231234
else:
232235
messages.append(
233-
f"FAIL: sentinel: asyncio.TaskGroup symbol missing for version {version}"
236+
f"FAIL: sentinel: {SMOKE_SENTINEL_SYMBOL} symbol missing "
237+
f"for version {version}"
234238
)
235239
passed = False
236240

src/mcp_server_python_docs/ingestion/sphinx_json.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,41 @@ def _mcp_json_default(self, obj):
6363
jsonimpl.SphinxJSONEncoder.default = _mcp_json_default
6464
'''
6565

66+
_IMGHDR_COMPAT_MODULE = '''"""Compatibility shim for old Sphinx on Python 3.13+."""
67+
68+
from __future__ import annotations
69+
70+
import os
71+
72+
73+
tests = []
74+
75+
76+
def what(file, h=None):
77+
"""Return an image type for the header formats old Sphinx may ask about."""
78+
if h is None:
79+
if isinstance(file, (str, bytes, os.PathLike)):
80+
with open(file, "rb") as image_file:
81+
h = image_file.read(32)
82+
else:
83+
position = file.tell()
84+
h = file.read(32)
85+
file.seek(position)
86+
87+
for test in tests:
88+
result = test(h, file)
89+
if result:
90+
return result
91+
92+
if h.startswith(b"\\xff\\xd8"):
93+
return "jpeg"
94+
if h.startswith(b"\\x89PNG\\r\\n\\x1a\\n"):
95+
return "png"
96+
if h[:6] in (b"GIF87a", b"GIF89a"):
97+
return "gif"
98+
return None
99+
'''
100+
66101

67102
def _canonical_requirement_name(line: str) -> str | None:
68103
stripped = line.strip()
@@ -109,6 +144,8 @@ def write_sphinx_json_sitecustomize(output_dir: Path) -> Path:
109144
output_dir.mkdir(parents=True, exist_ok=True)
110145
sitecustomize_path = output_dir / "sitecustomize.py"
111146
sitecustomize_path.write_text(_SPHINX_JSON_SITECUSTOMIZE, encoding="utf-8")
147+
imghdr_path = output_dir / "imghdr.py"
148+
imghdr_path.write_text(_IMGHDR_COMPAT_MODULE, encoding="utf-8")
112149
return sitecustomize_path
113150

114151

@@ -145,6 +182,14 @@ def build_sphinx_json_command(
145182
]
146183

147184

185+
def build_sphinx_bootstrap_requirements(sphinx_pin: str) -> list[str]:
186+
"""Return packages needed before installing CPython Doc requirements."""
187+
return [
188+
"setuptools<70",
189+
sphinx_pin,
190+
]
191+
192+
148193
def parse_fjson(filepath: Path) -> dict:
149194
"""Load and parse a .fjson file.
150195

0 commit comments

Comments
 (0)