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.
- sphinx_mounts/__init__.py +10 -0
- sphinx_mounts/config.py +513 -0
- sphinx_mounts/extension.py +249 -0
- sphinx_mounts/mounter.py +297 -0
- sphinx_mounts-0.1.0.dist-info/METADATA +133 -0
- sphinx_mounts-0.1.0.dist-info/RECORD +8 -0
- sphinx_mounts-0.1.0.dist-info/WHEEL +4 -0
- sphinx_mounts-0.1.0.dist-info/licenses/LICENSE +21 -0
sphinx_mounts/config.py
ADDED
|
@@ -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
|
+
}
|
sphinx_mounts/mounter.py
ADDED
|
@@ -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,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.
|