sphinx-mounts 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ """sphinx-mounts: mount external RST source trees into a Sphinx build."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from sphinx_mounts.extension import setup
6
+
7
+ __all__ = [
8
+ "__version__",
9
+ "setup",
10
+ ]
@@ -0,0 +1,513 @@
1
+ """Configuration models for sphinx-mounts.
2
+
3
+ Validation is hand-rolled: a frozen :class:`dataclasses.dataclass` plus a
4
+ ``from_dict`` classmethod is enough for the small surface area of this
5
+ extension, and avoids the runtime weight of a full schema library.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+ from dataclasses import dataclass, fields
12
+ from pathlib import Path
13
+ import tomllib
14
+ from typing import Any
15
+
16
+
17
+ class TomlConfigError(Exception):
18
+ """Raised when the TOML config file cannot be parsed or is malformed."""
19
+
20
+
21
+ class MountConfigError(ValueError):
22
+ """Raised when a mount configuration entry is invalid."""
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class MountConfig:
27
+ """One mounted external source tree, in one of two modes.
28
+
29
+ A mount is either a *directory* (``dir``) or an *explicit list of
30
+ files* (``files``). The two are mutually exclusive: exactly one
31
+ must be set. Both modes share the same ``mount_at`` semantics and
32
+ the same toctree-integration knobs.
33
+
34
+ Fields:
35
+ mount_at: The docname prefix at which the mount appears in the
36
+ host project. For example, ``_generated/api-foo`` makes a
37
+ mounted file ``index.rst`` available as docname
38
+ ``_generated/api-foo/index``. Stored without surrounding
39
+ slashes. ``None`` (the default) mounts the bundle at the
40
+ host project's root — a bundle file ``tutorial.rst``
41
+ becomes docname ``tutorial``.
42
+ dir: **Directory mode.** Absolute path (or path relative to
43
+ confdir) of an external directory holding source files.
44
+ Every file under it whose extension matches the project's
45
+ ``source_suffix`` is mounted; the relative-to-``dir`` path
46
+ (minus the suffix) becomes the docname tail. Mutually
47
+ exclusive with ``files``.
48
+ files: **File-list mode.** Tuple of absolute paths (or paths
49
+ relative to confdir) to individual source files. Each file's
50
+ *basename* (minus the matched ``source_suffix``) becomes the
51
+ docname tail under ``mount_at``. Subdirectories in the file
52
+ paths are ignored — the result is a flat namespace. May
53
+ contain a single file. Mutually exclusive with ``dir``.
54
+ include: Tuple of gitignore-style glob patterns evaluated
55
+ relative to ``dir``. If non-empty, *only* files matching
56
+ at least one pattern are walked; everything else is
57
+ filtered out. If empty (the default), no allowlist is
58
+ applied. Only meaningful in directory mode. Aligns with
59
+ sphinx-codelinks' ``source_discover.include``.
60
+ exclude: Tuple of gitignore-style glob patterns evaluated
61
+ relative to ``dir``. Matching files are skipped after
62
+ ``include`` allowlisting. Only meaningful in directory
63
+ mode (in file-list mode the list itself is the filter).
64
+ Aligns with sphinx-codelinks' ``source_discover.exclude``.
65
+ gitignore: Whether to honour ``.gitignore`` / ``.ignore``
66
+ files found *inside* the mounted tree during discovery.
67
+ Defaults to ``True``. Set to ``False`` when mounting a
68
+ sibling repository whose ``.gitignore`` excludes content
69
+ you nevertheless want to publish, or when the bundle is
70
+ served from a generated cache where ``.gitignore`` is not
71
+ meaningful. Aligns with sphinx-codelinks'
72
+ ``source_discover.gitignore``. Parent ``.gitignore``
73
+ files are never consulted regardless of this setting —
74
+ otherwise mounts inside host-gitignored directories like
75
+ ``bazel-bin/`` would silently produce zero files.
76
+ attach_to: Optional host docname whose toctree should receive the
77
+ mount entry. If ``None`` (default), no automatic toctree
78
+ wiring is performed and the host project is responsible for
79
+ referencing the mount.
80
+ toctree_index: 0-based index selecting *which* toctree inside the
81
+ ``attach_to`` document to extend, in document order. Defaults
82
+ to ``0`` (the first toctree). Ignored when ``attach_to`` is
83
+ ``None``. If the document contains no toctree, a new one is
84
+ created and the entry is appended; if the index exceeds the
85
+ number of toctrees present, the build fails loudly.
86
+ entry_doc: Mount-relative docname of the entry document to wire
87
+ into the host toctree. Defaults to ``"index"``. The resulting
88
+ docname is ``f"{mount_at}/{entry_doc}"`` — or just
89
+ ``entry_doc`` when ``mount_at`` is ``None``. This is the
90
+ *only* doc auto-attached; any other docs in the mount must
91
+ be reachable from the entry doc via its own toctree / refs.
92
+ strict_mount_at: When ``True``, fail the build if the host
93
+ project already has a directory at ``<srcdir>/<mount_at>/``.
94
+ Defaults to ``False`` — the existing per-docname collision
95
+ check is the only gate, which lets a mount slot under a
96
+ host-owned staging directory that holds non-source siblings
97
+ (assets, READMEs). Set to ``True`` to treat any host
98
+ directory at ``mount_at`` as a misconfiguration, catching
99
+ the mistake earlier than per-docname collisions would.
100
+ Incompatible with a root mount (``mount_at = None``), since
101
+ the host srcdir always exists; that combination is rejected
102
+ at config validation.
103
+ """
104
+
105
+ mount_at: str | None = None
106
+ dir: Path | None = None
107
+ files: tuple[Path, ...] | None = None
108
+ include: tuple[str, ...] = ()
109
+ exclude: tuple[str, ...] = ()
110
+ gitignore: bool = True
111
+ attach_to: str | None = None
112
+ toctree_index: int = 0
113
+ entry_doc: str = "index"
114
+ strict_mount_at: bool = False
115
+
116
+ def __post_init__(self) -> None:
117
+ if self.mount_at is not None:
118
+ _validate_relative_docname("mount_at", self.mount_at)
119
+ normalized = self.mount_at.strip("/")
120
+ if normalized != self.mount_at:
121
+ object.__setattr__(self, "mount_at", normalized)
122
+
123
+ _validate_dir_or_files(self.dir, self.files)
124
+
125
+ # ``bool`` is a subclass of ``int``; here we accept only a true
126
+ # bool, not 1/0, to keep the config schema honest.
127
+ if not isinstance(self.gitignore, bool):
128
+ msg = f"gitignore must be a boolean; got {type(self.gitignore).__name__}."
129
+ raise MountConfigError(msg)
130
+
131
+ if self.attach_to is not None:
132
+ _validate_relative_docname("attach_to", self.attach_to)
133
+ normalized_attach = self.attach_to.strip("/")
134
+ if normalized_attach != self.attach_to:
135
+ object.__setattr__(self, "attach_to", normalized_attach)
136
+
137
+ # ``bool`` is a subclass of ``int`` — reject explicitly.
138
+ if isinstance(self.toctree_index, bool) or not isinstance(
139
+ self.toctree_index, int
140
+ ):
141
+ msg = (
142
+ f"toctree_index must be a non-negative integer; "
143
+ f"got {type(self.toctree_index).__name__}."
144
+ )
145
+ raise MountConfigError(msg)
146
+ if self.toctree_index < 0:
147
+ msg = f"toctree_index must be non-negative; got {self.toctree_index}."
148
+ raise MountConfigError(msg)
149
+
150
+ _validate_relative_docname("entry_doc", self.entry_doc)
151
+
152
+ if not isinstance(self.strict_mount_at, bool):
153
+ msg = (
154
+ f"strict_mount_at must be a boolean; "
155
+ f"got {type(self.strict_mount_at).__name__}."
156
+ )
157
+ raise MountConfigError(msg)
158
+ if self.strict_mount_at and self.mount_at is None:
159
+ msg = (
160
+ "strict_mount_at = true requires an explicit mount_at — "
161
+ "a root mount has no host-side directory whose existence "
162
+ "could meaningfully fail the check."
163
+ )
164
+ raise MountConfigError(msg)
165
+
166
+ @classmethod
167
+ def from_dict(cls, entry: Mapping[str, Any]) -> MountConfig:
168
+ """Construct a :class:`MountConfig` from a mapping (e.g. TOML table).
169
+
170
+ Unknown keys are rejected. Exactly one of ``dir`` / ``files``
171
+ must be present. ``mount_at`` is optional — when omitted, the
172
+ bundle mounts at the host project root. String paths are
173
+ coerced to :class:`pathlib.Path`; lists of patterns are
174
+ coerced to tuples.
175
+
176
+ :raises MountConfigError: If the mapping is malformed.
177
+ """
178
+ allowed = {f.name for f in fields(cls)}
179
+ unknown = set(entry) - allowed
180
+ if unknown:
181
+ msg = (
182
+ f"Unknown mount keys: {sorted(unknown)}. "
183
+ f"Allowed keys are {sorted(allowed)}."
184
+ )
185
+ raise MountConfigError(msg)
186
+ if "dir" not in entry and "files" not in entry:
187
+ msg = (
188
+ "Mount entry must declare either 'dir' (directory mode) "
189
+ "or 'files' (file-list mode)."
190
+ )
191
+ raise MountConfigError(msg)
192
+ if "dir" in entry and "files" in entry:
193
+ msg = "Mount entry must declare either 'dir' or 'files', not both."
194
+ raise MountConfigError(msg)
195
+
196
+ mount_dir = _coerce_path(entry["dir"]) if "dir" in entry else None
197
+ files = _coerce_files(entry["files"]) if "files" in entry else None
198
+ include = _coerce_pattern_list("include", entry.get("include", ()))
199
+ exclude = _coerce_pattern_list("exclude", entry.get("exclude", ()))
200
+ gitignore = entry.get("gitignore", True)
201
+
202
+ return cls(
203
+ mount_at=entry.get("mount_at"),
204
+ dir=mount_dir,
205
+ files=files,
206
+ include=include,
207
+ exclude=exclude,
208
+ gitignore=gitignore,
209
+ attach_to=entry.get("attach_to"),
210
+ toctree_index=entry.get("toctree_index", 0),
211
+ entry_doc=entry.get("entry_doc", "index"),
212
+ strict_mount_at=entry.get("strict_mount_at", False),
213
+ )
214
+
215
+
216
+ def _coerce_path(raw: Any) -> Path:
217
+ """Accept Path or str; reject anything else."""
218
+ if isinstance(raw, Path):
219
+ return raw
220
+ if isinstance(raw, str):
221
+ return Path(raw)
222
+ msg = f"dir must be a string or Path; got {type(raw).__name__}."
223
+ raise MountConfigError(msg)
224
+
225
+
226
+ def _coerce_files(raw: Any) -> tuple[Path, ...]:
227
+ """Accept a non-empty list/tuple of strings/Paths and return a tuple of Paths."""
228
+ if isinstance(raw, str) or not isinstance(raw, list | tuple):
229
+ msg = (
230
+ f"files must be a list or tuple of strings/Paths; got {type(raw).__name__}."
231
+ )
232
+ raise MountConfigError(msg)
233
+ if not raw:
234
+ msg = "files must contain at least one entry."
235
+ raise MountConfigError(msg)
236
+ collected: list[Path] = []
237
+ for item in raw:
238
+ if isinstance(item, Path):
239
+ collected.append(item)
240
+ elif isinstance(item, str):
241
+ collected.append(Path(item))
242
+ else:
243
+ msg = (
244
+ f"each entry in files must be a string or Path; "
245
+ f"got {type(item).__name__} for {item!r}."
246
+ )
247
+ raise MountConfigError(msg)
248
+ return tuple(collected)
249
+
250
+
251
+ def _coerce_pattern_list(field_name: str, raw: Any) -> tuple[str, ...]:
252
+ """Accept a list/tuple of strings (possibly empty) and return a
253
+ tuple. Used for both ``include`` and ``exclude`` glob lists; the
254
+ ``field_name`` argument feeds the error messages."""
255
+ # ``str`` is technically a sequence but never what we want here.
256
+ if isinstance(raw, str) or not isinstance(raw, list | tuple):
257
+ msg = (
258
+ f"{field_name} must be a list or tuple of strings; "
259
+ f"got {type(raw).__name__}."
260
+ )
261
+ raise MountConfigError(msg)
262
+ for item in raw:
263
+ if not isinstance(item, str):
264
+ msg = (
265
+ f"{field_name} entries must be strings; "
266
+ f"got {type(item).__name__} for {item!r}."
267
+ )
268
+ raise MountConfigError(msg)
269
+ return tuple(raw)
270
+
271
+
272
+ def _validate_dir_or_files(dir_: Path | None, files: tuple[Path, ...] | None) -> None:
273
+ """Enforce ``dir`` / ``files`` mutual exclusion and the shape of a
274
+ ``files`` list, raising :class:`MountConfigError` on violation.
275
+ Extracted from ``MountConfig.__post_init__`` to keep that method's
276
+ cyclomatic complexity inside ruff's threshold."""
277
+ if dir_ is None and files is None:
278
+ msg = (
279
+ "Mount must declare either `dir` (directory mode) or "
280
+ "`files` (file-list mode); got neither."
281
+ )
282
+ raise MountConfigError(msg)
283
+ if dir_ is not None and files is not None:
284
+ msg = (
285
+ "Mount must declare either `dir` (directory mode) or "
286
+ "`files` (file-list mode), not both."
287
+ )
288
+ raise MountConfigError(msg)
289
+ if files is None:
290
+ return
291
+ if not isinstance(files, tuple):
292
+ msg = f"files must be a tuple of paths; got {type(files).__name__}."
293
+ raise MountConfigError(msg)
294
+ if not files:
295
+ msg = "files must contain at least one entry."
296
+ raise MountConfigError(msg)
297
+ for f in files:
298
+ if not isinstance(f, Path):
299
+ msg = (
300
+ f"each entry in files must be a Path; got {type(f).__name__} for {f!r}."
301
+ )
302
+ raise MountConfigError(msg)
303
+
304
+
305
+ def _validate_relative_docname(field_name: str, value: object) -> None:
306
+ """Validate a relative docname-shaped string field.
307
+
308
+ Used by :class:`MountConfig` for ``mount_at``, ``attach_to``, and
309
+ ``entry_doc``. Rejects non-strings, empty strings, leading slashes,
310
+ and ``..`` components. Does *not* normalize — that is the caller's
311
+ responsibility, since each field has a slightly different normalization
312
+ rule (mount_at strips slashes; entry_doc keeps its shape).
313
+ """
314
+ if not isinstance(value, str):
315
+ msg = f"{field_name} must be a string; got {type(value).__name__}."
316
+ raise MountConfigError(msg)
317
+ if not value:
318
+ msg = f"{field_name} must be a non-empty string."
319
+ raise MountConfigError(msg)
320
+ if value.startswith("/"):
321
+ msg = (
322
+ f"{field_name} must not start with '/'; got {value!r}. "
323
+ "Use a relative docname such as 'index' or '_generated/api-foo'."
324
+ )
325
+ raise MountConfigError(msg)
326
+ if ".." in Path(value).parts:
327
+ msg = f"{field_name} must not contain '..' components; got {value!r}."
328
+ raise MountConfigError(msg)
329
+
330
+
331
+ def parse_mounts(raw: Any, confdir: Path) -> tuple[MountConfig, ...]:
332
+ """Validate and normalize the user-provided ``mounts`` config.
333
+
334
+ :param raw: Whatever the user set ``mounts`` to in ``conf.py``, or the
335
+ list extracted from the TOML config file. Expected to be a sequence
336
+ of mappings or :class:`MountConfig` instances.
337
+ :param confdir: Confdir path, used to resolve relative ``dir`` paths.
338
+ :return: Tuple of validated mount configs with absolute ``dir`` paths.
339
+ :raises TypeError: If ``raw`` is not a sequence of mappings.
340
+ :raises MountConfigError: If a mapping fails validation.
341
+ :raises FileNotFoundError: If a ``dir`` directory does not exist.
342
+ """
343
+ if raw is None:
344
+ return ()
345
+ if not isinstance(raw, list | tuple):
346
+ msg = f"`mounts` must be a list of mappings; got {type(raw).__name__}."
347
+ raise TypeError(msg)
348
+
349
+ resolved: list[MountConfig] = []
350
+ for index, entry in enumerate(raw):
351
+ mount = _entry_to_mount_config(entry, index)
352
+ resolved_dir = (
353
+ _resolve_dir(mount.dir, confdir, index) if mount.dir is not None else None
354
+ )
355
+ resolved_files = (
356
+ _resolve_files(mount.files, confdir, index)
357
+ if mount.files is not None
358
+ else None
359
+ )
360
+ resolved.append(
361
+ MountConfig(
362
+ mount_at=mount.mount_at,
363
+ dir=resolved_dir,
364
+ files=resolved_files,
365
+ include=mount.include,
366
+ exclude=mount.exclude,
367
+ gitignore=mount.gitignore,
368
+ attach_to=mount.attach_to,
369
+ toctree_index=mount.toctree_index,
370
+ entry_doc=mount.entry_doc,
371
+ strict_mount_at=mount.strict_mount_at,
372
+ )
373
+ )
374
+ return tuple(resolved)
375
+
376
+
377
+ def _entry_to_mount_config(entry: Any, index: int) -> MountConfig:
378
+ if isinstance(entry, MountConfig):
379
+ return entry
380
+ if isinstance(entry, Mapping):
381
+ return MountConfig.from_dict(entry)
382
+ msg = (
383
+ f"`mounts[{index}]` must be a mapping or MountConfig; "
384
+ f"got {type(entry).__name__}."
385
+ )
386
+ raise TypeError(msg)
387
+
388
+
389
+ def _resolve_dir(mount_dir: Path, confdir: Path, index: int) -> Path:
390
+ resolved = (
391
+ (confdir / mount_dir).resolve()
392
+ if not mount_dir.is_absolute()
393
+ else mount_dir.resolve()
394
+ )
395
+ if not resolved.is_dir():
396
+ msg = f"`mounts[{index}].dir` does not exist or is not a directory: {resolved}"
397
+ raise FileNotFoundError(msg)
398
+ return resolved
399
+
400
+
401
+ def _resolve_files(
402
+ files: tuple[Path, ...], confdir: Path, index: int
403
+ ) -> tuple[Path, ...]:
404
+ file_list: list[Path] = []
405
+ for file_index, raw_file in enumerate(files):
406
+ f = (
407
+ (confdir / raw_file).resolve()
408
+ if not raw_file.is_absolute()
409
+ else raw_file.resolve()
410
+ )
411
+ if not f.is_file():
412
+ msg = (
413
+ f"`mounts[{index}].files[{file_index}]` does not exist or is "
414
+ f"not a file: {f}"
415
+ )
416
+ raise FileNotFoundError(msg)
417
+ file_list.append(f)
418
+ return tuple(file_list)
419
+
420
+
421
+ def load_mounts_from_toml(toml_path: Path) -> list[dict[str, Any]] | None:
422
+ """Load the raw ``mounts`` list from a TOML configuration file.
423
+
424
+ The TOML file is expected to declare a top-level ``[[mounts]]`` array of
425
+ tables, one block per mount, with the same keys accepted by
426
+ :class:`MountConfig`:
427
+
428
+ .. code-block:: toml
429
+
430
+ [[mounts]]
431
+ dir = "/abs/path/to/bundle"
432
+ mount_at = "_generated/api-foo"
433
+
434
+ [[mounts]]
435
+ dir = "../other/docs"
436
+ mount_at = "guides/other"
437
+ include = ["**/*.rst"] # optional allowlist
438
+ exclude = ["**/internal/**", "*.tmp"]
439
+ gitignore = false # don't honour the sibling repo's .gitignore
440
+ attach_to = "index" # extend a toctree in index.rst
441
+ toctree_index = 0 # which toctree (0-based)
442
+ entry_doc = "index" # which file inside the mount
443
+
444
+ The TOML file is the *primary* config target so that non-Python tooling
445
+ (IDE extensions, language servers, build-system integrations) can read
446
+ the same configuration without evaluating ``conf.py``.
447
+
448
+ **Path anchoring.** Relative paths in ``dir`` and ``files`` are
449
+ resolved to absolute paths against the **directory containing
450
+ ``toml_path``**, not against the Sphinx ``confdir``. The TOML file
451
+ is self-describing: moving it as a unit keeps its relative paths
452
+ meaningful, and a TOML placed in a subdirectory of ``confdir`` does
453
+ not silently re-anchor. Absolute paths are left untouched.
454
+
455
+ :param toml_path: Absolute path to a TOML file. May or may not exist.
456
+ :return: The raw list of mount tables (each a ``dict``), or ``None`` if
457
+ ``toml_path`` does not exist or contains no top-level ``mounts``
458
+ array. Returning ``None`` is not an error — callers fall back to
459
+ the ``mounts`` value from ``conf.py``.
460
+ :raises TomlConfigError: If the file exists but is not valid TOML, or
461
+ if the top-level ``mounts`` key has the wrong shape.
462
+ """
463
+ if not toml_path.is_file():
464
+ return None
465
+ try:
466
+ with toml_path.open("rb") as f:
467
+ data = tomllib.load(f)
468
+ except tomllib.TOMLDecodeError as e:
469
+ msg = f"sphinx-mounts: failed to parse TOML config {toml_path}: {e}"
470
+ raise TomlConfigError(msg) from e
471
+
472
+ raw_mounts = data.get("mounts")
473
+ if raw_mounts is None:
474
+ return None
475
+ if not isinstance(raw_mounts, list):
476
+ msg = (
477
+ f"sphinx-mounts: top-level `mounts` in {toml_path} must be an "
478
+ f"array of tables; got {type(raw_mounts).__name__}."
479
+ )
480
+ raise TomlConfigError(msg)
481
+ for index, entry in enumerate(raw_mounts):
482
+ if not isinstance(entry, dict):
483
+ msg = (
484
+ f"sphinx-mounts: `mounts[{index}]` in {toml_path} must be a "
485
+ f"table; got {type(entry).__name__}."
486
+ )
487
+ raise TomlConfigError(msg)
488
+
489
+ _anchor_toml_paths(raw_mounts, toml_path.parent)
490
+ return raw_mounts
491
+
492
+
493
+ def _anchor_toml_paths(raw_mounts: list[dict[str, Any]], base_dir: Path) -> None:
494
+ """Make every relative ``dir`` / ``files`` path absolute against
495
+ ``base_dir``. Mutates ``raw_mounts`` in place. Non-string values and
496
+ already-absolute paths are left untouched; semantic validation of
497
+ types and shapes happens later in :meth:`MountConfig.from_dict`."""
498
+ for entry in raw_mounts:
499
+ if "dir" in entry:
500
+ entry["dir"] = _anchor_one_path(entry["dir"], base_dir)
501
+ if "files" in entry and isinstance(entry["files"], list):
502
+ entry["files"] = [_anchor_one_path(f, base_dir) for f in entry["files"]]
503
+
504
+
505
+ def _anchor_one_path(value: Any, base_dir: Path) -> Any:
506
+ """Return ``value`` unchanged unless it is a relative path string,
507
+ in which case return its absolute form anchored to ``base_dir``."""
508
+ if not isinstance(value, str):
509
+ return value
510
+ p = Path(value)
511
+ if p.is_absolute():
512
+ return value
513
+ return str((base_dir / p).resolve())
@@ -0,0 +1,249 @@
1
+ """Sphinx extension entry point for sphinx-mounts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from docutils import nodes
9
+ from sphinx import addnodes
10
+ from sphinx.application import Sphinx
11
+ from sphinx.config import Config
12
+ from sphinx.errors import ExtensionError
13
+ from sphinx.util import logging
14
+
15
+ from sphinx_mounts import __version__
16
+ from sphinx_mounts.config import MountConfig, load_mounts_from_toml, parse_mounts
17
+ from sphinx_mounts.mounter import _MountAwareProject, install_mount_aware_project
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ _CACHED_KEY = "_sphinx_mounts_parsed"
22
+
23
+ #: Default file name for the declarative TOML configuration. The file is
24
+ #: resolved relative to Sphinx's ``confdir``. ``ubproject.toml`` is the
25
+ #: convention shared with other useblocks tooling (sphinx-needs,
26
+ #: sphinx-codelinks) so that a single declarative file can describe a
27
+ #: project's documentation setup to *every* downstream consumer — Sphinx,
28
+ #: IDE extensions, language servers, build-system integrations — without
29
+ #: any of them having to execute ``conf.py``.
30
+ DEFAULT_TOML_FILENAME = "ubproject.toml"
31
+
32
+
33
+ def _on_load_toml(app: Sphinx, config: Config) -> None:
34
+ """Load mount entries from the TOML config file, if present.
35
+
36
+ Runs on ``config-inited`` *before* :func:`_on_config_inited`. If
37
+ ``mounts_from_toml`` resolves to an existing file, its top-level
38
+ ``[[mounts]]`` array replaces any value of ``mounts`` set in
39
+ ``conf.py``. If the file does not exist, ``config.mounts`` is left
40
+ untouched and any legacy conf.py-style value is used instead.
41
+ """
42
+ toml_setting = getattr(config, "mounts_from_toml", None)
43
+ if not toml_setting:
44
+ return
45
+ toml_path = (Path(app.confdir) / toml_setting).resolve()
46
+ raw = load_mounts_from_toml(toml_path)
47
+ if raw is None:
48
+ logger.debug(
49
+ "sphinx-mounts: no mounts loaded from TOML (path=%s, exists=%s).",
50
+ toml_path,
51
+ toml_path.is_file(),
52
+ )
53
+ return
54
+ config["mounts"] = raw
55
+ logger.info(
56
+ "sphinx-mounts: loaded %d mount(s) from %s",
57
+ len(raw),
58
+ toml_path,
59
+ )
60
+
61
+
62
+ def _on_config_inited(app: Sphinx, config: Config) -> None:
63
+ """Validate the ``mounts`` config and cache the parsed result.
64
+
65
+ ``config-inited`` fires *before* ``app.project`` is constructed
66
+ (see :mod:`sphinx.application`), so the actual project replacement is
67
+ deferred to ``builder-inited``. We still parse here to surface
68
+ configuration errors as early as possible.
69
+ """
70
+ parsed = parse_mounts(getattr(config, "mounts", None), Path(app.confdir))
71
+ setattr(app, _CACHED_KEY, parsed)
72
+
73
+
74
+ def _on_builder_inited(app: Sphinx) -> None:
75
+ """Replace ``app.project`` with a mount-aware project.
76
+
77
+ By the time ``builder-inited`` fires, ``app.project`` exists and the
78
+ build environment has been bound to it. We swap both so that the
79
+ subsequent ``env.find_files`` -> ``project.discover()`` call goes
80
+ through our subclass.
81
+ """
82
+ parsed: tuple[MountConfig, ...] = getattr(app, _CACHED_KEY, ())
83
+ if not parsed:
84
+ return
85
+
86
+ if not isinstance(app.project, _MountAwareProject):
87
+ app.project = install_mount_aware_project(app.project, parsed)
88
+ logger.info(
89
+ "sphinx-mounts: installed mount-aware project with %d mount(s)",
90
+ len(parsed),
91
+ )
92
+
93
+ if app.env is not None:
94
+ app.env.project = app.project
95
+
96
+
97
+ def _on_doctree_read(app: Sphinx, doctree: nodes.document) -> None:
98
+ """Extend (or create) toctrees in host docs to reference mount entries.
99
+
100
+ For every mount whose ``attach_to`` equals the doc currently being
101
+ read, locate the configured toctree (by ``toctree_index``, 0-based)
102
+ and append ``{mount_at}/{entry_doc}`` to it. If the host doc contains
103
+ no toctree at all, a new one is added beneath the first section. If
104
+ ``toctree_index`` exceeds the number of toctrees in the doc, raise
105
+ :class:`ExtensionError` — silent misconfiguration would leave the
106
+ mount unreferenced.
107
+ """
108
+ parsed: tuple[MountConfig, ...] = getattr(app, _CACHED_KEY, ())
109
+ if not parsed:
110
+ return
111
+ docname = app.env.docname
112
+ targets = [m for m in parsed if m.attach_to == docname]
113
+ if not targets:
114
+ return
115
+
116
+ toctrees: list[addnodes.toctree] = list(doctree.findall(addnodes.toctree))
117
+
118
+ for mount in targets:
119
+ entry_docname = (
120
+ mount.entry_doc
121
+ if mount.mount_at is None
122
+ else f"{mount.mount_at}/{mount.entry_doc}"
123
+ )
124
+
125
+ if not toctrees:
126
+ new_node = _build_toctree_node(docname, entry_docname)
127
+ _attach_to_first_section(doctree, new_node)
128
+ toctrees.append(new_node)
129
+ logger.info(
130
+ "sphinx-mounts: added toctree to %r referencing %r",
131
+ docname,
132
+ entry_docname,
133
+ )
134
+ continue
135
+
136
+ if mount.toctree_index >= len(toctrees):
137
+ mount_label = "<root>" if mount.mount_at is None else repr(mount.mount_at)
138
+ msg = (
139
+ f"sphinx-mounts: mount {mount_label} requested "
140
+ f"toctree_index={mount.toctree_index} in host doc "
141
+ f"{docname!r}, but only {len(toctrees)} toctree(s) exist."
142
+ )
143
+ raise ExtensionError(msg)
144
+
145
+ target = toctrees[mount.toctree_index]
146
+ if entry_docname in target["includefiles"]:
147
+ # Already referenced (e.g. the host author wrote the entry by
148
+ # hand). Skip silently so attach_to is idempotent next to a
149
+ # static toctree.
150
+ continue
151
+ target["entries"].append((None, entry_docname))
152
+ target["includefiles"].append(entry_docname)
153
+ logger.info(
154
+ "sphinx-mounts: extended toctree #%d in %r with %r",
155
+ mount.toctree_index,
156
+ docname,
157
+ entry_docname,
158
+ )
159
+
160
+
161
+ def _on_check_consistency(app: Sphinx, env: Any) -> None:
162
+ """Warn when ``attach_to`` targets a docname that does not exist."""
163
+ parsed: tuple[MountConfig, ...] = getattr(app, _CACHED_KEY, ())
164
+ if not parsed:
165
+ return
166
+ found = set(env.found_docs)
167
+ for mount in parsed:
168
+ if mount.attach_to is None:
169
+ continue
170
+ if mount.attach_to not in found:
171
+ mount_label = "<root>" if mount.mount_at is None else repr(mount.mount_at)
172
+ logger.warning(
173
+ "sphinx-mounts: mount %s has attach_to=%r, but that "
174
+ "docname does not exist in the project — nothing was "
175
+ "extended.",
176
+ mount_label,
177
+ mount.attach_to,
178
+ )
179
+
180
+
181
+ def _build_toctree_node(parent: str, entry: str) -> addnodes.toctree:
182
+ """Construct a fresh ``toctree`` node with sane defaults."""
183
+ node = addnodes.toctree()
184
+ node["parent"] = parent
185
+ node["entries"] = [(None, entry)]
186
+ node["includefiles"] = [entry]
187
+ node["maxdepth"] = -1
188
+ node["caption"] = None
189
+ node["glob"] = False
190
+ node["hidden"] = False
191
+ node["includehidden"] = False
192
+ node["numbered"] = 0
193
+ node["titlesonly"] = False
194
+ return node
195
+
196
+
197
+ def _attach_to_first_section(
198
+ doctree: nodes.document, toctree_node: addnodes.toctree
199
+ ) -> None:
200
+ """Append ``toctree_node`` at the **end** of the first top-level section.
201
+
202
+ Position matters: the host author owns the document's content and
203
+ ordering. Any prose, directives, or subsections they wrote come
204
+ first; the auto-injected mount entry is placed strictly below them
205
+ so the host doc remains self-contained and the injected references
206
+ are always at the bottom.
207
+
208
+ The append happens after *all* existing children of the section,
209
+ including nested subsections. Falls back to appending directly to
210
+ the document if it has no top-level section (e.g. a doc with only a
211
+ paragraph at the document root).
212
+ """
213
+ for child in doctree.children:
214
+ if isinstance(child, nodes.section):
215
+ # ``Element.append`` is ``self.children.append`` — i.e. end
216
+ # of the list, so the toctree ends up after every existing
217
+ # child of the section.
218
+ child.append(toctree_node)
219
+ return
220
+ doctree.append(toctree_node)
221
+
222
+
223
+ def setup(app: Sphinx) -> dict[str, Any]:
224
+ """Register the extension with Sphinx."""
225
+ app.add_config_value("mounts", default=[], rebuild="env", types=(list,))
226
+ app.add_config_value(
227
+ "mounts_from_toml",
228
+ default=DEFAULT_TOML_FILENAME,
229
+ rebuild="env",
230
+ types=(str, type(None)),
231
+ )
232
+ # Priority is "lower = earlier"; the TOML loader must run before the
233
+ # validator so that the TOML-derived list is what gets validated.
234
+ app.connect("config-inited", _on_load_toml, priority=400)
235
+ app.connect("config-inited", _on_config_inited, priority=500)
236
+ app.connect("builder-inited", _on_builder_inited)
237
+ # ``doctree-read`` priority 400 (< 500) places our toctree
238
+ # mutation *before* Sphinx's TocTreeCollector.process_doc, so the
239
+ # collector's pass sees the injected entries and includes them in
240
+ # ``env.included`` — without that, Sphinx's ``toc.not_included``
241
+ # consistency check would flag every mounted entry doc.
242
+ app.connect("doctree-read", _on_doctree_read, priority=400)
243
+ app.connect("env-check-consistency", _on_check_consistency)
244
+
245
+ return {
246
+ "version": __version__,
247
+ "parallel_read_safe": True,
248
+ "parallel_write_safe": True,
249
+ }
@@ -0,0 +1,297 @@
1
+ """Core mounting logic.
2
+
3
+ This module intentionally writes to ``sphinx.project.Project._docname_to_path``
4
+ and ``_path_to_docname``. Those attributes are private (single-underscore) in
5
+ Sphinx; we depend on the following observable contract from
6
+ ``sphinx/project.py``:
7
+
8
+ * ``Project.doc2path(docname, absolute=True)`` returns
9
+ ``self.srcdir / self._docname_to_path[docname]``.
10
+ * ``pathlib.Path("/srcdir") / Path("/abs/external")`` returns
11
+ ``Path("/abs/external")`` because the right operand is absolute.
12
+
13
+ So storing an absolute external path in ``_docname_to_path`` causes Sphinx
14
+ to read from that external location transparently. This is the entire trick.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+ from typing import TYPE_CHECKING
21
+
22
+ from ignore import Walk, WalkBuilder
23
+ from ignore.overrides import OverrideBuilder
24
+ from sphinx.project import Project
25
+ from sphinx.util import logging
26
+
27
+ from sphinx_mounts.config import MountConfig
28
+
29
+ if TYPE_CHECKING:
30
+ from collections.abc import Iterable
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ def _join_mount(mount_at: str | None, tail: str) -> str:
36
+ """Compose a docname from a mount prefix and a relative tail.
37
+
38
+ When ``mount_at`` is ``None`` the bundle is mounted at the host
39
+ project root, so the tail is returned unchanged. Otherwise the
40
+ prefix is joined with a single slash.
41
+ """
42
+ return tail if mount_at is None else f"{mount_at}/{tail}"
43
+
44
+
45
+ class _MountAwareProject(Project):
46
+ """A :class:`sphinx.project.Project` that also discovers mounted trees.
47
+
48
+ After ``super().discover()`` populates docnames from the host ``srcdir``,
49
+ each configured mount is walked and its files are injected with
50
+ **absolute** filesystem paths in ``_docname_to_path``.
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ srcdir: str | Path,
56
+ source_suffix: Iterable[str],
57
+ mounts: tuple[MountConfig, ...],
58
+ ) -> None:
59
+ super().__init__(srcdir, source_suffix)
60
+ self._mounts = mounts
61
+
62
+ def discover(
63
+ self,
64
+ exclude_paths: Iterable[str] = (),
65
+ include_paths: Iterable[str] = ("**",),
66
+ ) -> set[str]:
67
+ """Discover host srcdir docs plus all mounted external trees."""
68
+ docs = super().discover(exclude_paths, include_paths)
69
+ for mount in self._mounts:
70
+ _enforce_strict_mount_at(Path(self.srcdir), mount)
71
+ docs |= _attach_mount(self, mount)
72
+ return docs
73
+
74
+
75
+ def _enforce_strict_mount_at(srcdir: Path, mount: MountConfig) -> None:
76
+ """Raise if ``mount.strict_mount_at`` is set and the host srcdir
77
+ already contains a directory at ``mount.mount_at``.
78
+
79
+ The check is intentionally a no-op when ``strict_mount_at`` is
80
+ false (the default per-docname collision detector remains the only
81
+ gate) or when ``mount_at`` is ``None`` (rejected at config time so
82
+ this branch is defensive). It does not fire for stray files at the
83
+ path — files are left to the per-docname check, which catches the
84
+ only case Sphinx actually cares about.
85
+ """
86
+ if not mount.strict_mount_at or mount.mount_at is None:
87
+ return
88
+ candidate = srcdir / mount.mount_at
89
+ if candidate.is_dir():
90
+ msg = (
91
+ f"sphinx-mounts: strict_mount_at violation: host project "
92
+ f"already has a directory at {candidate}, but mount "
93
+ f"{mount.mount_at!r} requires the path to be free. "
94
+ f"Rename or remove the host directory, or set "
95
+ f"strict_mount_at = false to fall back to per-docname "
96
+ f"collision checking."
97
+ )
98
+ raise ValueError(msg)
99
+
100
+
101
+ def _attach_mount(project: Project, mount: MountConfig) -> set[str]:
102
+ """Inject ``mount`` into ``project`` — either a directory or a file list.
103
+
104
+ Directory mode (``mount.dir`` set): walk the directory and pick up
105
+ every file whose suffix matches one of the project's configured
106
+ source suffixes. The docname tail is the relative path under
107
+ ``dir``, minus the matched suffix.
108
+
109
+ File-list mode (``mount.files`` set): each file's *basename* (minus
110
+ the matched suffix) becomes the docname tail under ``mount.mount_at``.
111
+ Subdirectories in the file paths are ignored; the result is a flat
112
+ namespace under ``mount_at``.
113
+
114
+ :param project: The Sphinx :class:`Project` to inject into.
115
+ :param mount: Validated mount configuration.
116
+ :return: The set of docnames added by this mount.
117
+ :raises ValueError: If a constructed docname collides with an existing
118
+ docname already in the project (host shadowing or two mounts
119
+ producing the same docname), or — in file-list mode — if a file's
120
+ suffix is not registered with Sphinx.
121
+ """
122
+ if mount.dir is not None:
123
+ return _attach_mount_dir(project, mount, mount.dir)
124
+ if mount.files is not None:
125
+ return _attach_mount_files(project, mount, mount.files)
126
+ # MountConfig.__post_init__ guarantees exactly one of dir/files is
127
+ # set, so this branch is unreachable in practice.
128
+ mount_label = "<root>" if mount.mount_at is None else repr(mount.mount_at)
129
+ msg = f"sphinx-mounts: mount {mount_label} has neither dir nor files."
130
+ raise ValueError(msg)
131
+
132
+
133
+ def _attach_mount_dir(
134
+ project: Project, mount: MountConfig, mount_dir: Path
135
+ ) -> set[str]:
136
+ """Walk ``mount_dir`` with the ``ignore-python`` walker (a Rust
137
+ binding to the same crate used by ``sphinx-codelinks`` and ubCode).
138
+
139
+ The walker honours ``.gitignore`` and ``.ignore`` files *inside*
140
+ the mounted tree (when ``mount.gitignore`` is true), and exposes
141
+ the mount's ``include`` / ``exclude`` lists as gitignore-style
142
+ allowlist / denylist overrides. Parent ``.gitignore`` files are
143
+ NOT consulted regardless of the setting — otherwise mounting a
144
+ directory whose parent gitignores it (the canonical
145
+ ``bazel-bin/...`` case) would silently produce zero files.
146
+ """
147
+ added: set[str] = set()
148
+ suffixes = tuple(project.source_suffix)
149
+
150
+ walker = _build_walker(
151
+ mount_dir,
152
+ include=mount.include,
153
+ exclude=mount.exclude,
154
+ gitignore=mount.gitignore,
155
+ )
156
+
157
+ # Collect first, then process sorted, so docname order is
158
+ # deterministic regardless of filesystem walk order.
159
+ matched: list[tuple[Path, str]] = []
160
+ for entry in walker:
161
+ p = entry.path()
162
+ if not p.is_file():
163
+ continue
164
+ suffix = _match_suffix(p.name, suffixes)
165
+ if suffix is None:
166
+ continue
167
+ matched.append((p, suffix))
168
+ matched.sort(key=lambda pair: pair[0].as_posix())
169
+
170
+ for abs_path, suffix in matched:
171
+ rel_path = abs_path.relative_to(mount_dir)
172
+ # Strip the matched suffix (which may be multi-dot like ".rst.txt").
173
+ docname_tail = rel_path.as_posix()[: -len(suffix)]
174
+ docname = _join_mount(mount.mount_at, docname_tail)
175
+ _register(project, docname, abs_path, mount.mount_at)
176
+ added.add(docname)
177
+ return added
178
+
179
+
180
+ def _build_walker(
181
+ mount_dir: Path,
182
+ *,
183
+ include: tuple[str, ...],
184
+ exclude: tuple[str, ...],
185
+ gitignore: bool,
186
+ ) -> Walk:
187
+ """Configure a ``WalkBuilder`` for a single mount and return its
188
+ iterator.
189
+
190
+ Behaviour:
191
+
192
+ - When ``gitignore`` is ``True``: ``.gitignore`` and ``.ignore``
193
+ files *inside* the walked tree are honoured (the Rust ``ignore``
194
+ crate only activates ``.gitignore`` inside a git repository).
195
+ When ``False``: those files are treated as data, not filters —
196
+ useful for sibling repositories whose ``.gitignore`` excludes
197
+ content you still want to publish.
198
+ - Parent directories are NOT scanned for ignore files, regardless
199
+ of ``gitignore``. Mounts often live under paths that the host
200
+ workspace gitignores (e.g. ``bazel-bin/``); we do not want
201
+ those rules to silently strip the mount.
202
+ - The user's global git config and ``.git/info/exclude`` are NOT
203
+ consulted — keeps builds reproducible across machines.
204
+ - Hidden entries (dotfiles, ``.git/``) are skipped.
205
+ - ``include`` entries are added as positive overrides
206
+ (allowlist); ``exclude`` entries are added as negated overrides
207
+ (``!pattern``). Aligned with sphinx-codelinks'
208
+ ``source_discover`` semantics.
209
+ """
210
+ builder = WalkBuilder(mount_dir)
211
+ builder.ignore(gitignore)
212
+ builder.git_ignore(gitignore)
213
+ builder.parents(False)
214
+ builder.git_global(False)
215
+ builder.git_exclude(False)
216
+ builder.hidden(True)
217
+
218
+ if include or exclude:
219
+ ob = OverrideBuilder(mount_dir)
220
+ for pattern in include:
221
+ ob.add(pattern)
222
+ for pattern in exclude:
223
+ ob.add(f"!{pattern}")
224
+ builder.overrides(ob.build())
225
+
226
+ return builder.build()
227
+
228
+
229
+ def _attach_mount_files(
230
+ project: Project, mount: MountConfig, files: Iterable[Path]
231
+ ) -> set[str]:
232
+ added: set[str] = set()
233
+ suffixes = tuple(project.source_suffix)
234
+
235
+ for abs_path in files:
236
+ suffix = _match_suffix(abs_path.name, suffixes)
237
+ if suffix is None:
238
+ msg = (
239
+ f"sphinx-mounts: file {abs_path} has no extension matching "
240
+ f"the project's source_suffix {list(suffixes)!r}. Add a "
241
+ f"parser extension (e.g. myst_parser for .md) or remove "
242
+ f"the file from the mount's `files` list."
243
+ )
244
+ raise ValueError(msg)
245
+ docname_tail = abs_path.name[: -len(suffix)]
246
+ docname = _join_mount(mount.mount_at, docname_tail)
247
+ _register(project, docname, abs_path, mount.mount_at)
248
+ added.add(docname)
249
+ return added
250
+
251
+
252
+ def _register(
253
+ project: Project, docname: str, abs_path: Path, mount_at: str | None
254
+ ) -> None:
255
+ """Add a single (docname, abs_path) entry to the project, checking for
256
+ collisions with already-registered docnames."""
257
+ if docname in project.docnames:
258
+ existing = project._docname_to_path.get(docname)
259
+ mount_label = "<root>" if mount_at is None else repr(mount_at)
260
+ msg = (
261
+ f"sphinx-mounts: docname conflict for {docname!r}: "
262
+ f"mount {mount_label} would supply {abs_path}, "
263
+ f"but {existing} already provides it."
264
+ )
265
+ raise ValueError(msg)
266
+ project.docnames.add(docname)
267
+ project._docname_to_path[docname] = abs_path
268
+ project._path_to_docname[abs_path] = docname
269
+ logger.debug("sphinx-mounts: mounted %s -> %s", docname, abs_path)
270
+
271
+
272
+ def _match_suffix(filename: str, suffixes: Iterable[str]) -> str | None:
273
+ """Return the matching source suffix for ``filename``, or None."""
274
+ for suffix in suffixes:
275
+ if filename.endswith(suffix):
276
+ return suffix
277
+ return None
278
+
279
+
280
+ def install_mount_aware_project(
281
+ app_project: Project,
282
+ mounts: tuple[MountConfig, ...],
283
+ ) -> _MountAwareProject:
284
+ """Build a :class:`_MountAwareProject` carrying state from ``app_project``.
285
+
286
+ The original docname/path dictionaries are copied across so that any
287
+ state populated by Sphinx between construction and now is preserved.
288
+ """
289
+ new = _MountAwareProject(
290
+ app_project.srcdir,
291
+ app_project.source_suffix,
292
+ mounts,
293
+ )
294
+ new.docnames = set(app_project.docnames)
295
+ new._docname_to_path = dict(app_project._docname_to_path)
296
+ new._path_to_docname = dict(app_project._path_to_docname)
297
+ return new
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: sphinx-mounts
3
+ Version: 0.1.0
4
+ Summary: Mount external sources into a Sphinx build without copying or symlinking
5
+ Keywords: sphinx,documentation,mount,bazel,monorepo
6
+ Author-email: team useblocks <info@useblocks.com>
7
+ Maintainer-email: Marco Heinemann <marco.heinemann@useblocks.com>
8
+ Requires-Python: >=3.12,<4
9
+ Description-Content-Type: text/markdown
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Framework :: Sphinx :: Extension
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Documentation
22
+ Classifier: Topic :: Documentation :: Sphinx
23
+ Classifier: Topic :: Software Development :: Documentation
24
+ License-File: LICENSE
25
+ Requires-Dist: sphinx>=7.4,<10
26
+ Requires-Dist: ignore-python>=0.3.3
27
+ Project-URL: Changelog, https://github.com/useblocks/sphinx-mounts/blob/main/docs/source/changelog.rst
28
+ Project-URL: Documentation, https://sphinx-mounts.useblocks.com
29
+ Project-URL: Homepage, https://sphinx-mounts.useblocks.com
30
+ Project-URL: Issues, https://github.com/useblocks/sphinx-mounts/issues
31
+ Project-URL: Repository, https://github.com/useblocks/sphinx-mounts
32
+
33
+ # Sphinx Mounts
34
+
35
+ Mount external source trees into a Sphinx build without copying or
36
+ symlinking. Sources stay where they live — for example, a Bazel `bazel-bin/`
37
+ output tree, a sibling repository, or a generated cache directory — and are
38
+ made visible to Sphinx at a configured docname prefix. RST works out of the
39
+ box; Markdown works as soon as `myst-parser` is loaded in the host project;
40
+ any other format a Sphinx parser extension registers is picked up the same
41
+ way.
42
+
43
+ ## Features
44
+
45
+ - **No materialization**: sources are read directly from their original
46
+ location. No copy, no symlink, no staging step.
47
+ - **Declarative TOML config**: the mount mapping lives in `ubproject.toml`
48
+ (or any TOML file you name via `mounts_from_toml`). `conf.py` only
49
+ references it.
50
+ - **Language-agnostic & toolable**: because the config is static TOML, IDE
51
+ plugins, language servers, indexers, and build-system integrations
52
+ written in any language can read the same mount mapping that
53
+ `sphinx-build` reads — without having to evaluate `conf.py`.
54
+ - **Toctree auto-wiring**: an optional `attach_to` per mount injects the
55
+ bundle's entry doc into a host toctree at build time, so the host stays
56
+ buildable when a mount is absent (a developer hasn't run the upstream
57
+ build, CI hasn't fetched the bundle).
58
+ - **Self-contained bundles**: each mount is intended to be a
59
+ self-contained tree of `.rst` files with relative links only, so it can
60
+ be reused across host projects. A linter is on the roadmap; the
61
+ convention is not currently enforced.
62
+
63
+ ## Quick Start
64
+
65
+ ```bash
66
+ pip install sphinx-mounts
67
+ ```
68
+
69
+ Add to your `conf.py`:
70
+
71
+ ```python
72
+ extensions = ["sphinx_mounts"]
73
+ ```
74
+
75
+ Describe your mounts in `ubproject.toml` next to `conf.py`:
76
+
77
+ ```toml
78
+ [[mounts]]
79
+ dir = "/abs/path/to/bazel-bin/docs/api-foo"
80
+ mount_at = "_generated/api-foo"
81
+ ```
82
+
83
+ Reference mounted documents from your host project just like any other doc:
84
+
85
+ ```rst
86
+ .. toctree::
87
+
88
+ _generated/api-foo/index
89
+ ```
90
+
91
+ ## Why TOML?
92
+
93
+ A `conf.py` is executable Python — only a Python interpreter can read it
94
+ correctly. A TOML file is static data, parseable by every common language.
95
+ Putting the mount mapping in `ubproject.toml` means that any external tool
96
+ (an IDE extension, a documentation indexer, a CI gate, a non-Python build
97
+ system) can resolve cross-references without running Sphinx. The same file
98
+ can also carry sections owned by sibling tools such as [Sphinx-Needs]
99
+ (`[needs]`) and [sphinx-codelinks] (`[codelinks]`), keeping the project's
100
+ documentation configuration in one place.
101
+
102
+ If TOML isn't an option for your setup, the legacy `mounts = [...]` list in
103
+ `conf.py` is still honored as a fallback — see the docs for details.
104
+
105
+ ## Related projects
106
+
107
+ - [bazel-drives-sphinx] — a heavier take on Bazel-driven Sphinx
108
+ documentation: Bazel rules declare every RST file (and `needs.json`
109
+ artifact) as a label, and generated per-project targets invoke
110
+ `sphinx-build` on the assembled tree. `sphinx-mounts` is the lightweight
111
+ alternative — Bazel drops generated files under `bazel-bin/`, the
112
+ extension mounts that directory, and Sphinx reads in place. Pick the
113
+ former for fine-grained multi-project rule collection and tag-driven
114
+ variants; pick the latter when "Bazel build, then Sphinx" is enough.
115
+ See `docs/source/bazel.rst` for a side-by-side comparison.
116
+
117
+ ## Documentation
118
+
119
+ See `docs/source/` for the full configuration reference and the Bazel
120
+ integration walkthrough. The "Related projects" section in
121
+ `docs/source/index.rst` lists sibling tools ([Sphinx-Needs],
122
+ [sphinx-codelinks], [ubCode], [bazel-drives-sphinx]) that share the
123
+ `ubproject.toml` convention.
124
+
125
+ ## License
126
+
127
+ MIT — see [LICENSE](LICENSE).
128
+
129
+ [Sphinx-Needs]: https://sphinx-needs.readthedocs.io/
130
+ [sphinx-codelinks]: https://github.com/useblocks/sphinx-codelinks
131
+ [ubCode]: https://ubcode.useblocks.com/
132
+ [bazel-drives-sphinx]: https://github.com/useblocks/bazel-drives-sphinx
133
+
@@ -0,0 +1,8 @@
1
+ sphinx_mounts/__init__.py,sha256=rF9MDLsXakhe1fiW7bTeeMkGAToTi7ViAG5JGLjU_3o,187
2
+ sphinx_mounts/config.py,sha256=tjTUKMEn21RIY37eFSFcvKzcBvrHuvocRNSQEZ0kBik,21743
3
+ sphinx_mounts/extension.py,sha256=SUt65SBkt6wdUU5N2Q7p81N68gSSJTi6gvxO7Kv2qmI,9491
4
+ sphinx_mounts/mounter.py,sha256=oncPo5eQ6k5Md5jPrENOurmjbvPeyq703C2Xb0dQrIE,11309
5
+ sphinx_mounts-0.1.0.dist-info/licenses/LICENSE,sha256=72nRnAqdEM2NFi7I1q4xUAeEx-3bs7q7kdd_RnZI9L0,1066
6
+ sphinx_mounts-0.1.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
7
+ sphinx_mounts-0.1.0.dist-info/METADATA,sha256=HSGlWQtjBtsn8CLqRFlBeSmVMmxrPS3qJfekq_k62fc,5406
8
+ sphinx_mounts-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: flit 3.12.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 useblocks
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.