Integration with Sphinx¶
This page goes a layer below Usage and documents how sphinx-mounts plugs into Sphinx’s build pipeline, how docnames are derived from mount configuration, and the discipline mounted source trees should follow to stay reusable.
Event handlers¶
The extension registers five event handlers in setup(). Sphinx
event priority is “lower number runs earlier”; the default is 500.
Event |
Priority |
Purpose |
|---|---|---|
|
400 |
|
|
500 |
|
|
default |
|
|
400 |
|
|
default |
|
Why doctree-read runs at priority 400¶
Sphinx ships an environment collector — TocTreeCollector — that
also subscribes to doctree-read. Its
process_doc(app, doctree) populates app.env.included from the
toctrees it finds in the doctree, and app.env.included is what
Sphinx’s later check_consistency step uses to emit the
toc.not_included warning (“document isn’t included in any
toctree”). The collector runs at the default priority of 500.
If _on_doctree_read also ran at 500, listener order would be
decided by registration order: TocTreeCollector is connected
during Sphinx app initialisation (before extensions load), and our
extension’s setup() runs later — so the collector would handle
each doctree first and our toctree mutation would land after
env.included was already populated from the unmodified node. A
build run with sphinx-build -W would then flag every entry doc
attach_to injected as not-included, even though the mutated
toctree node visibly contains it.
Registering at priority 400 places our handler ahead of every
default-priority listener. The collector sees the mutated toctree,
env.included reflects every injected entry, and the consistency
check passes cleanly — even with warnings-as-errors. The fix is the
small reason attach_to works under -W and the
tests/example/ end-to-end test runs green.
The mount-aware project¶
sphinx.project.Project is the in-memory model of “which
docnames exist and where do they live on disk”. sphinx-mounts
subclasses it:
class _MountAwareProject(Project):
def discover(self, exclude_paths=(), include_paths=("**",)):
docs = super().discover(exclude_paths, include_paths)
for mount in self._mounts:
docs |= _attach_mount(self, mount)
return docs
After super().discover() populates docnames from the host
srcdir, every configured mount is walked and its files are
registered with absolute filesystem paths in the project’s
_docname_to_path dictionary.
The absolute-path trick¶
Sphinx resolves a docname to a path with
project.doc2path(docname, absolute=True), which internally
computes:
srcdir / self._docname_to_path[docname]
The relevant detail is pathlib.Path’s __truediv__
behaviour:
>>> from pathlib import Path
>>> Path("/some/srcdir") / Path("/abs/external/file.rst")
PosixPath('/abs/external/file.rst')
When the right operand is absolute, the left operand is discarded.
So storing an absolute external path in _docname_to_path causes
Sphinx to read from that external location transparently, without any
copy, symlink, or staging step. This single observation is the entire
mechanism that lets sphinx-mounts work without touching any other part
of Sphinx.
Note
The extension intentionally writes to private (single-underscore)
attributes on sphinx.project.Project —
_docname_to_path and _path_to_docname. The use is gated to
one module (src/sphinx_mounts/mounter.py) and is documented in
code with a pointer at the upstream class. If Sphinx ever changes
this contract, the breakage will be local.
Diagnostic locations are absolute¶
The absolute path in _docname_to_path has a payoff beyond reading
the file: every warning or error Sphinx emits for a mounted document
is located at that absolute path, with a line number — never at a
host-relative path or a bare docname. An editor’s problem matcher, a
terminal’s Ctrl+click, or a CI annotation can therefore jump straight
to the offending line in the real source file. A path relative to
the host srcdir would be useless to a tool that does not already
know srcdir — for instance an editor that has the bundle open on
its own.
The mechanism is entirely Sphinx’s; sphinx-mounts adds nothing:
For docutils / RST messages (a short title underline, a directive that cannot read its
:file:), the location is taken from the parsed node’ssource, which Sphinx sets todoc2path(docname)— the stored absolute path.sphinx.util.logging.get_node_locationformats it withos.path.abspath.For reference messages (a
:doc:to a missing document), the location is a(docname, line)pair that Sphinx resolves throughenv.doc2path(docname)— again the stored absolute path.
Because the path comes from Sphinx core and not from any individual
directive, the behaviour is uniform across docutils-native directives
(include, csv-table, raw), Sphinx core (image,
figure, literalinclude, cross-references), a Sphinx-bundled
extension (sphinx.ext.graphviz), and third-party extensions
(sphinxcontrib.plantuml, sphinxcontrib.mermaid). Every one of
these is exercised in tests/test_warning_locations.py.
Note
One nuance: the location prefix of every message is absolute, but
the asset path printed inside the body of an “image file not
readable” / “download file not readable” message is rendered
relative to srcdir (e.g. ../bundle/missing.png). That is a
stock display choice in Sphinx’s asset collector, not something the
mount controls — the location prefix that tooling jumps to is still
the absolute path of the referencing document.
Docname mapping¶
A docname is the canonical identifier Sphinx uses for a document.
It is a forward-slash-separated path without the source suffix.
mount_at is the prefix the mount contributes; the docname tail
depends on the mount mode.
Directory mode¶
For each file under dir whose extension matches the project’s
source_suffix:
docname_tail = relative_path_under_dir (POSIX-style)
with the matched suffix stripped
docname = f"{mount_at}/{docname_tail}"
Worked examples, assuming mount_at = "_generated/api-foo" and a
project whose source_suffix is {".rst": ..., ".md": ...}:
Source file (under |
Resulting docname |
|---|---|
|
|
|
|
|
|
|
|
|
(skipped silently) |
Subdirectories under dir are preserved in the docname, so the
on-disk layout and the docname tree mirror each other.
File-list mode¶
For each path in the files list:
docname_tail = basename(file) with the matched suffix stripped
docname = f"{mount_at}/{docname_tail}"
The file’s parent directories are deliberately discarded — file-list
mode is for cherry-picking individual documents, and a flat namespace
under mount_at is what the user is asking for. Two files with the
same basename would collide on docname and raise.
Worked examples, again with mount_at = "_generated/notes":
Listed file |
Resulting docname |
|---|---|
|
|
|
|
|
|
Unlike directory mode, a listed file with an extension that does not
match source_suffix is an error, not a silent skip — the user
asked for that file by name, so silently ignoring it would be wrong.
Suffix handling¶
Suffix matching iterates whatever Sphinx has registered in
source_suffix:
.rstis the default..mdis registered whenmyst_parseris loaded; mounting Markdown bundles “just works” once the host enables the parser.Multi-dot suffixes (e.g. a parser registering
.rst.txt) are matched by full-string suffix comparison, so the docname tail strips the entire matched suffix.Any parser extension a project plugs in is honoured the same way. See Source formats: RST, Markdown, and anything Sphinx knows about.
Cross-document references¶
Inside the mount¶
Once a file is mounted, its docname is indistinguishable from any
other docname in the project. :doc: and :ref: work as usual.
Within a bundle, the recommended form is relative docname
references:
See the :doc:`details` page for the calling convention.
When this directive lives in intro.rst inside the bundle, the
unqualified details resolves to the sibling details docname
in the same directory — independently of the mount_at prefix
the host project happens to use.
From the host into the mount¶
The host project references mounted docs by their full docname
(mount_at prefix + tail). This is what a toctree entry, an
explicit :doc: link, or an attach_to injection produces:
.. toctree::
_generated/api-foo/index
The :doc:`_generated/api-foo/details` page shows the parameters.
Anti-pattern: mounted sources linking back to the host¶
A mounted document can reference host project docnames or labels. Sphinx will resolve those references the same way it resolves any others, because by the time resolution runs there is only one docname space. You should not do this.
A bundle that references back into the host project becomes coupled to that host:
Circular dependency. The host depends on the bundle for content; the bundle now depends on the host’s structure for its own cross-references to resolve. Any time either side moves a doc or renames a label, the other side breaks.
Non-portable bundles. A bundle that mentions
:doc:`/guides/installation`works in one host project and 404s in every other. The bundle has effectively become host-specific documentation that happens to live in another tree.Maintenance noise. The bundle author can no longer review their own RST in isolation; they need to know what every host that consumes the bundle calls its own docs.
IDE confusion. Tools that resolve cross-references against the
ubproject.tomlof whichever project is open will succeed in one workspace and fail in another, even though the source on disk is identical.
The correct mental model is strictly one-way linking:
┌──────────────────────────┐
│ host project │
│ (index.rst, guides/, │
│ toctrees, refs) │
└────────────┬─────────────┘
│ links DOWN
▼
┌──────────────────────────┐
│ mounted source tree │
│ (self-contained, only │
│ uses relative :doc:/ │
│ :ref: within itself) │
└──────────────────────────┘
Treat the bundle as a library of documentation:
A library publishes its API docs as a stand-alone artefact.
Consumers (host projects) link to that library’s docs.
The library never references the consumer back.
Concretely, when authoring a mounted bundle:
Use only relative
:doc:references that stay inside the bundle.Use only
:ref:labels defined inside the bundle.Do not include
..segments or anchored docnames that walk out of the bundle’s directory.Do not rely on substitutions,
:rst-prolog:,:rst-epilog:, or other implicit context provided by the host’sconf.py.
The payoff is that the bundle can be:
Developed and tested in isolation (e.g. with its own minimal
conf.py).Reused unchanged across multiple host projects.
Versioned independently of any single host.
Rendered by the host’s IDE / language server consistently with the full build, because nothing in the bundle depends on host-specific context.
sphinx-mounts does not yet enforce these constraints with a linter; following them is part of the same bundle discipline the extension expects.