A project with a module whose last name component is html cannot build its docs: sphinx-build -b html aborts in the sphinx_llm.txt extension.
sphinx.errors.ExtensionError: Documents '_autosummary/pkg.renderers.html' (replace)
and '_autosummary/pkg.renderers' (append) resolve to the same published
Markdown path '_autosummary/pkg.renderers.html.md'
Seen on a thorwhalen/* package (epythet 0.2.12, sphinx-llm 1.1.0, Sphinx 9.1, Python 3.12) as soon as a renderers/html.py submodule got an API page. Reproduced locally with epythet quickstart ..
Cause
epythet.agent_outputs.sphinx_settings turns the agent outputs on but leaves llms_txt_suffix_mode at sphinx-llm's default, "auto". In "auto" mode sphinx_llm.txt.resolve_markdown_targets publishes two spellings of every page's Markdown twin:
MarkdownLayout.APPEND — <html_target>.md, i.e. <page>.html.md
MarkdownLayout.REPLACE — html_target.with_suffix(".md"), i.e. <page>.md
For a document whose docname already ends in .html the two spellings collide with those of its parent:
| document |
append twin |
replace twin |
_autosummary/pkg.renderers |
pkg.renderers.html.md |
pkg.renderers.md |
_autosummary/pkg.renderers.html |
pkg.renderers.html.html.md |
pkg.renderers.html.md |
copy_markdown_files detects the clash and raises rather than silently overwriting one twin with the other, which is the right call on its side.
Note that llms_txt_exclude is not a workaround: the collision check runs in the first pass over plans, before _is_excluded is consulted, so an excluded document still claims its target paths.
Suggested fix
Set "llms_txt_suffix_mode": "append" in epythet.agent_outputs.sphinx_settings. epythet already assumes the append spelling everywhere it references a twin:
agent_outputs.link_relation_tags emits href="{basename}.html.md"
agent_outputs.inject_link_relations_into_site looks for page.with_name(page.name + ".md")
So the REPLACE copies are unreferenced by epythet's own discovery contract, and dropping them costs nothing while removing a whole class of build failure. (If they are wanted for URL-suffix-style serving, the alternative is to keep "auto" and make the mode configurable from [tool.epythet].)
Second, smaller finding: a committed conf.py override is silently clobbered
epythet.templates.conf_py_shim's own docstring says:
Put project-specific overrides below the import; anything defined here wins over the generated value.
That is not what happens. epythet.scaffold.write_generated_file overwrites the file whenever the marker is present and the content differs from the template:
if not any(marker in existing for marker in markers):
say(f"Keeping hand-written {path.name} (no epythet marker found)")
return
if existing == content:
return
path.write_text(content, encoding="utf-8")
Since the marker is the from epythet.sphinx_conf import * line, a shim with an override appended matches the marker, differs from the template, and gets regenerated — the override is lost on the next build, with Wrote .../conf.py as the only trace.
A project that needs one raw Sphinx setting therefore has to drop the marker and import the generated settings by hand, which also opts it out of future shim updates. Either the docstring should be corrected, or write_generated_file should treat "template + extra lines below it" as user content (e.g. overwrite only when the existing text is exactly a known generated version, the way refresh_docsrc_gitignore already handles appended lines).
A [tool.epythet] passthrough for raw Sphinx settings — the same shape as theme_options — would remove the need for a committed conf.py at all, and would have made the first issue above a one-line project-side fix.
Workaround in use
The affected repo commits a hand-written docsrc/conf.py with no marker line:
from epythet import sphinx_conf as _epythet_generated
globals().update(
{_k: _v for _k, _v in vars(_epythet_generated).items() if not _k.startswith("_")}
)
llms_txt_suffix_mode = "append"
With that, the build succeeds and both module pages plus both twins are published at distinct paths.
Filed from an unattended session; not starting the epythet-side change.
A project with a module whose last name component is
htmlcannot build its docs:sphinx-build -b htmlaborts in thesphinx_llm.txtextension.Seen on a
thorwhalen/*package (epythet 0.2.12, sphinx-llm 1.1.0, Sphinx 9.1, Python 3.12) as soon as arenderers/html.pysubmodule got an API page. Reproduced locally withepythet quickstart ..Cause
epythet.agent_outputs.sphinx_settingsturns the agent outputs on but leavesllms_txt_suffix_modeat sphinx-llm's default,"auto". In"auto"modesphinx_llm.txt.resolve_markdown_targetspublishes two spellings of every page's Markdown twin:MarkdownLayout.APPEND—<html_target>.md, i.e.<page>.html.mdMarkdownLayout.REPLACE—html_target.with_suffix(".md"), i.e.<page>.mdFor a document whose docname already ends in
.htmlthe two spellings collide with those of its parent:_autosummary/pkg.rendererspkg.renderers.html.mdpkg.renderers.md_autosummary/pkg.renderers.htmlpkg.renderers.html.html.mdpkg.renderers.html.mdcopy_markdown_filesdetects the clash and raises rather than silently overwriting one twin with the other, which is the right call on its side.Note that
llms_txt_excludeis not a workaround: the collision check runs in the first pass overplans, before_is_excludedis consulted, so an excluded document still claims its target paths.Suggested fix
Set
"llms_txt_suffix_mode": "append"inepythet.agent_outputs.sphinx_settings. epythet already assumes the append spelling everywhere it references a twin:agent_outputs.link_relation_tagsemitshref="{basename}.html.md"agent_outputs.inject_link_relations_into_sitelooks forpage.with_name(page.name + ".md")So the REPLACE copies are unreferenced by epythet's own discovery contract, and dropping them costs nothing while removing a whole class of build failure. (If they are wanted for URL-suffix-style serving, the alternative is to keep
"auto"and make the mode configurable from[tool.epythet].)Second, smaller finding: a committed
conf.pyoverride is silently clobberedepythet.templates.conf_py_shim's own docstring says:That is not what happens.
epythet.scaffold.write_generated_fileoverwrites the file whenever the marker is present and the content differs from the template:Since the marker is the
from epythet.sphinx_conf import *line, a shim with an override appended matches the marker, differs from the template, and gets regenerated — the override is lost on the next build, withWrote .../conf.pyas the only trace.A project that needs one raw Sphinx setting therefore has to drop the marker and import the generated settings by hand, which also opts it out of future shim updates. Either the docstring should be corrected, or
write_generated_fileshould treat "template + extra lines below it" as user content (e.g. overwrite only when the existing text is exactly a known generated version, the wayrefresh_docsrc_gitignorealready handles appended lines).A
[tool.epythet]passthrough for raw Sphinx settings — the same shape astheme_options— would remove the need for a committedconf.pyat all, and would have made the first issue above a one-line project-side fix.Workaround in use
The affected repo commits a hand-written
docsrc/conf.pywith no marker line:With that, the build succeeds and both module pages plus both twins are published at distinct paths.
Filed from an unattended session; not starting the epythet-side change.