Skip to content

Commit 6ac8142

Browse files
committed
fix(build): resolve F-01 JSON docs build failure
1 parent 93cb25e commit 6ac8142

3 files changed

Lines changed: 162 additions & 3 deletions

File tree

src/mcp_server_python_docs/__main__.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@ def build_index(versions: str, skip_content: bool) -> None:
129129
ingest_sphinx_json_dir,
130130
populate_synonyms,
131131
rebuild_fts_indexes,
132+
write_json_build_requirements,
133+
write_sphinx_json_sitecustomize,
132134
)
133135
from mcp_server_python_docs.storage.db import (
134136
assert_fts5_available,
@@ -230,10 +232,21 @@ def build_index(versions: str, skip_content: bool) -> None:
230232
)
231233

232234
# Install remaining Doc/requirements.txt deps
233-
doc_reqs = os.path.join(clone_dir, "Doc", "requirements.txt")
234-
if os.path.exists(doc_reqs):
235+
doc_reqs = Path(clone_dir) / "Doc" / "requirements.txt"
236+
if doc_reqs.exists():
237+
json_doc_reqs = doc_reqs.with_name(
238+
"_json-build-requirements.txt"
239+
)
240+
omitted_reqs = write_json_build_requirements(
241+
doc_reqs, json_doc_reqs
242+
)
243+
if omitted_reqs:
244+
logger.info(
245+
"Omitted HTML-only Sphinx extensions for JSON build: %s",
246+
", ".join(omitted_reqs),
247+
)
235248
subprocess.run(
236-
[pip_path, "install", "-r", doc_reqs],
249+
[pip_path, "install", "-r", str(json_doc_reqs)],
237250
check=True,
238251
capture_output=True,
239252
text=True,
@@ -244,6 +257,14 @@ def build_index(versions: str, skip_content: bool) -> None:
244257
sphinx_build = os.path.join(scripts_dir, "sphinx-build")
245258
doc_dir = os.path.join(clone_dir, "Doc")
246259
json_out = os.path.join(doc_dir, "build", "json")
260+
sphinx_compat_dir = Path(clone_dir) / "_sphinx_json_compat"
261+
write_sphinx_json_sitecustomize(sphinx_compat_dir)
262+
sphinx_env = os.environ.copy()
263+
sphinx_env["PYTHONPATH"] = (
264+
str(sphinx_compat_dir)
265+
if not sphinx_env.get("PYTHONPATH")
266+
else f"{sphinx_compat_dir}{os.pathsep}{sphinx_env['PYTHONPATH']}"
267+
)
247268

248269
logger.info(
249270
"Running sphinx-build -b json for Python %s "
@@ -253,12 +274,14 @@ def build_index(versions: str, skip_content: bool) -> None:
253274
result = subprocess.run(
254275
[
255276
sphinx_build, "-b", "json",
277+
"-D", "html_theme=classic",
256278
"-j", "auto",
257279
doc_dir, json_out,
258280
],
259281
capture_output=True,
260282
text=True,
261283
cwd=doc_dir,
284+
env=sphinx_env,
262285
)
263286
if result.returncode != 0:
264287
logger.error(

src/mcp_server_python_docs/ingestion/sphinx_json.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,80 @@
3535
"contents",
3636
}
3737

38+
_HTML_ONLY_SPHINX_REQUIREMENTS = frozenset({
39+
"python-docs-theme",
40+
"sphinx-notfound-page",
41+
"sphinxext-opengraph",
42+
})
43+
44+
_SPHINX_JSON_SITECUSTOMIZE = '''"""Compatibility patch for disposable Sphinx JSON builds."""
45+
46+
from __future__ import annotations
47+
48+
try:
49+
from sphinxcontrib.serializinghtml import jsonimpl
50+
except Exception:
51+
jsonimpl = None
52+
53+
if jsonimpl is not None:
54+
_original_default = jsonimpl.SphinxJSONEncoder.default
55+
56+
def _mcp_json_default(self, obj):
57+
if obj.__class__.__name__ == "_TranslationProxy":
58+
return str(obj)
59+
return _original_default(self, obj)
60+
61+
jsonimpl.SphinxJSONEncoder.default = _mcp_json_default
62+
'''
63+
64+
65+
def _canonical_requirement_name(line: str) -> str | None:
66+
stripped = line.strip()
67+
if not stripped or stripped.startswith("#") or stripped.startswith("-"):
68+
return None
69+
70+
match = re.match(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)", line)
71+
if match is None:
72+
return None
73+
74+
return re.sub(r"[-_.]+", "-", match.group(1)).lower()
75+
76+
77+
def write_json_build_requirements(source_path: Path, output_path: Path) -> list[str]:
78+
"""Write CPython Doc requirements filtered for Sphinx JSON builds.
79+
80+
CPython's documentation requirements include optional extensions and a
81+
theme package that only support HTML output. If installed, Sphinx can load
82+
them during the JSON build path and fail before any .fjson files are
83+
written.
84+
"""
85+
filtered_lines: list[str] = []
86+
omitted: list[str] = []
87+
88+
for line in source_path.read_text(encoding="utf-8").splitlines(keepends=True):
89+
package_name = _canonical_requirement_name(line)
90+
if package_name in _HTML_ONLY_SPHINX_REQUIREMENTS:
91+
omitted.append(package_name)
92+
continue
93+
filtered_lines.append(line)
94+
95+
output_path.write_text("".join(filtered_lines), encoding="utf-8")
96+
return omitted
97+
98+
99+
def write_sphinx_json_sitecustomize(output_dir: Path) -> Path:
100+
"""Write a temporary sitecustomize shim for Sphinx JSON builds.
101+
102+
Sphinx 8.2's JSON encoder does not serialize ``_TranslationProxy`` objects,
103+
even though CPython docs can place them in the page context. The Sphinx venv
104+
is disposable, so keep this compatibility patch isolated to the JSON build
105+
subprocess via PYTHONPATH instead of mutating installed packages.
106+
"""
107+
output_dir.mkdir(parents=True, exist_ok=True)
108+
sitecustomize_path = output_dir / "sitecustomize.py"
109+
sitecustomize_path.write_text(_SPHINX_JSON_SITECUSTOMIZE, encoding="utf-8")
110+
return sitecustomize_path
111+
38112

39113
def parse_fjson(filepath: Path) -> dict:
40114
"""Load and parse a .fjson file.

tests/test_ingestion.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,70 @@
2121
parse_fjson,
2222
populate_synonyms,
2323
rebuild_fts_indexes,
24+
write_json_build_requirements,
25+
write_sphinx_json_sitecustomize,
2426
)
2527

28+
29+
class TestJsonBuildRequirements:
30+
def test_omits_html_only_sphinx_extensions(self, tmp_path):
31+
source = tmp_path / "requirements.txt"
32+
output = tmp_path / "json-requirements.txt"
33+
source.write_text(
34+
"\n".join([
35+
"# CPython docs requirements",
36+
"sphinx==8.2.3",
37+
"sphinxext-opengraph>=0.9.1",
38+
"python-docs-theme>=2025.8",
39+
"sphinx-notfound-page==1.0.0",
40+
"-c constraints.txt",
41+
"blurb",
42+
])
43+
+ "\n",
44+
encoding="utf-8",
45+
)
46+
47+
omitted = write_json_build_requirements(source, output)
48+
49+
assert omitted == [
50+
"sphinxext-opengraph",
51+
"python-docs-theme",
52+
"sphinx-notfound-page",
53+
]
54+
assert output.read_text(encoding="utf-8") == (
55+
"# CPython docs requirements\n"
56+
"sphinx==8.2.3\n"
57+
"-c constraints.txt\n"
58+
"blurb\n"
59+
)
60+
61+
def test_matches_requirement_names_case_and_separator_insensitively(self, tmp_path):
62+
source = tmp_path / "requirements.txt"
63+
output = tmp_path / "json-requirements.txt"
64+
source.write_text(
65+
"SphinxExt.OpenGraph>=0.9; python_version >= '3.12'\n"
66+
"SPHINX_NOTFOUND_PAGE==1.0\n",
67+
encoding="utf-8",
68+
)
69+
70+
omitted = write_json_build_requirements(source, output)
71+
72+
assert omitted == ["sphinxext-opengraph", "sphinx-notfound-page"]
73+
assert output.read_text(encoding="utf-8") == ""
74+
75+
76+
class TestSphinxJsonSitecustomize:
77+
def test_writes_translation_proxy_json_patch(self, tmp_path):
78+
output_dir = tmp_path / "compat"
79+
80+
sitecustomize = write_sphinx_json_sitecustomize(output_dir)
81+
82+
assert sitecustomize == output_dir / "sitecustomize.py"
83+
content = sitecustomize.read_text(encoding="utf-8")
84+
assert "_TranslationProxy" in content
85+
assert "SphinxJSONEncoder.default" in content
86+
87+
2688
# ── fjson parsing tests (INGR-C-04) ──
2789

2890

0 commit comments

Comments
 (0)