Skip to content
Draft
10 changes: 4 additions & 6 deletions BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,11 @@ docs(
external_needs = [
"@score_process_description//:needs_json_file",
],
data = [
# These scenario BUILD files are used by literalinclude examples.
"//src/tests/docs_bzl/scenarios/nested_bundles:nested_bundle_build",
"//src/tests/docs_bzl/scenarios/data_files_runfiles:generated_data_build",
"//src/tests/docs_bzl/scenarios/external_bundle:external_bundle_build",
],
bundles = [
{
"bundle": "//src/tests/docs_bzl/scenarios:docs_literalinclude_data",
"mount_at": "_literalinclude_data",
},
{
"bundle": "//src/extensions/docs:extensions",
"mount_at": "internals/extensions",
Expand Down
67 changes: 61 additions & 6 deletions bzl/bundle_rules.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,12 @@
# Extending `sphinx_docs_library` is also not a good fit. Its provider represents
# individual file mappings, while our provider represents complete mounted bundles. Adding
# the required metadata would therefore not be a small extension of the existing
# abstraction; it would change its propagated unit and its semantics. It would also couple
# SCORE-specific composition rules to the generic `rules_sphinxdocs` implementation.
# abstraction; it would change its propagated unit and its semantics.

# We therefore reimplement the relatively small overlapping part—transitive source
# collection—while keeping the richer bundle model explicit and independent.
# collection—while keeping the richer bundle model explicit. The rule exposes a
# narrow ``SphinxDocsLibraryInfo`` view only for non-document bundle data so a
# sandboxed Sphinx action can stage those files without duplicating mounted docs.

# The name `docs_bundle` reflects that relationship: it fills the same general role
# as `sphinx_docs_library`, but uses a SCORE-specific data model for composing structured
Expand All @@ -54,6 +55,7 @@


load("@score_docs_as_code//:bzl/basics.bzl", "join_path")
load("@sphinxdocs//sphinxdocs/private:sphinx_docs_library_info.bzl", "SphinxDocsLibraryInfo")

# Internal data passed between bundle targets and eventually consumed by an
# adapter such as the Sphinx mounts manifest. Users configure bundles through
Expand Down Expand Up @@ -198,8 +200,30 @@ def _rebase_bundle_entry(entry, mount_at, attach_to):
external = entry.external,
repository = entry.repository,
data = entry.data,
path_check = entry.path_check,
)

def _entry_with_path_check(entry, path_check):
"""Return an entry with the requested sphinx-mounts confinement mode."""
return struct(
runtime_path = entry.runtime_path,
src_root = entry.src_root,
mount_at = entry.mount_at,
attach_to = entry.attach_to,
entry_doc = entry.entry_doc,
external = entry.external,
repository = entry.repository,
data = entry.data,
path_check = path_check,
)

def _has_data_only_entry(entries):
"""Return whether the entries contain a declared pure-data bundle."""
for entry in entries:
if not entry.src_root and entry.data.to_list():
return True
return False

def _entries_visible_through(ctx, child):
"""Keep an external module's own docs, but not its foreign mounts."""
entries = child[DocsBundleInfo].entries
Expand Down Expand Up @@ -244,11 +268,12 @@ def _docs_bundle_impl(ctx):
own_source_files = []
own_external_runfiles = []
own_data = depset(direct = ctx.files.data)
own_source_entry = None

if ctx.files.srcs:
runtime_path = _bundle_runtime_path(ctx)
external = runtime_path.startswith("../")
entries.append(struct(
own_source_entry = struct(
runtime_path = runtime_path,
# The execution root and runfiles tree spell external repositories
# differently. Keep both locations so every public docs() target can
Expand All @@ -260,7 +285,9 @@ def _docs_bundle_impl(ctx):
external = external,
repository = ctx.label.workspace_name,
data = own_data,
))
path_check = "off" if own_data.to_list() else "error",
)
entries.append(own_source_entry)
own_source_files.extend(ctx.files.srcs)
# Local sources are read directly from the workspace by ``bazel run``.
# Only sources from external repositories must be staged in runfiles.
Expand All @@ -277,6 +304,7 @@ def _docs_bundle_impl(ctx):
external = False,
repository = ctx.label.workspace_name,
data = own_data,
path_check = "error",
))

child_source_files = []
Expand All @@ -286,13 +314,20 @@ def _docs_bundle_impl(ctx):
for source_link in ctx.files.sourcelinks
]
for index, child in enumerate(ctx.attr.bundles):
child_entries = _entries_visible_through(ctx, child)
if own_source_entry != None and _has_data_only_entry(child_entries):
# Supporting files composed through a data-only child are available
# to this source tree. sphinx-mounts 0.1.x has no per-file allowlist,
# so the source entry uses its documented non-confining mode.
own_source_entry = _entry_with_path_check(own_source_entry, "off")
entries[0] = own_source_entry
entries.extend([
_rebase_bundle_entry(
entry,
ctx.attr.bundle_mount_ats[index],
ctx.attr.bundle_attach_tos[index],
)
for entry in _entries_visible_through(ctx, child)
for entry in child_entries
])
child_source_files.append(child[DefaultInfo].files)
child_external_runfiles.append(child[DocsBundleInfo].external_runfiles)
Expand All @@ -313,6 +348,16 @@ def _docs_bundle_impl(ctx):
for child in ctx.attr.bundles
],
)
non_document_data = tuple([
file
for file in all_data.to_list()
if not _is_sphinx_source_file(file)
])
sphinx_data_entry = struct(
strip_prefix = "/",
prefix = "",
files = non_document_data,
)
return [
DefaultInfo(files = depset(transitive = [all_source_files, all_data])),
DocsBundleInfo(
Expand All @@ -322,6 +367,12 @@ def _docs_bundle_impl(ctx):
external_runfiles = external_runfiles,
data = all_data,
),
SphinxDocsLibraryInfo(
files = depset(direct = non_document_data),
strip_prefix = "/",
prefix = "",
transitive = depset(direct = [sphinx_data_entry]) if non_document_data else depset(),
),
]

_docs_bundle = rule(
Expand Down Expand Up @@ -378,6 +429,10 @@ def bundle_source_files(name, bundle, visibility = None):
)
return ":" + name

def _is_sphinx_source_file(file):
"""Return whether ``file`` is discovered as a normal Sphinx document."""
return file.basename.endswith(".rst") or file.basename.endswith(".md")

def _external_docs_runfiles_impl(ctx):
"""Expose external documentation sources needed under ``bazel run``."""
bundle = ctx.attr.bundle[DocsBundleInfo]
Expand Down
1 change: 1 addition & 0 deletions bzl/mount_rules.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def _mounts_manifest_impl(ctx):
"external": entry.external,
"repository": entry.repository,
"data": [f.path for f in entry.data.to_list()],
"path_check": entry.path_check,
})

out = ctx.actions.declare_file(ctx.label.name + ".json")
Expand Down
28 changes: 18 additions & 10 deletions docs.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,8 @@ def docs(
use a bundle for anything that should travel as one portable mount. A
bundle may be data-only when its deliverable is generated/supporting data
rather than source RST. Use ``docs(data = [...])`` only for project-level
inputs that do not belong to a bundle mount.
inputs that do not belong to a bundle mount. Compose a data-only
``docs_bundle`` when those files must travel with the public bundle as well.
"""
# HINT: keep documentation sync docs/reference/bazel_macros.rst

Expand Down Expand Up @@ -284,13 +285,12 @@ def docs(

data_library_label_for_sphinx_docs = []
if data:
# ``docs_bundle`` can carry data, including as a pure-data bundle. That
# data belongs to the bundle payload and is resolved at its eventual
# mount. These ``docs(data = [...])`` inputs are intentionally
# project-level instead: they support the project build or its
# literalinclude examples and are not assigned to a bundle mount. Both
# kinds of data are build inputs; without the staging below, project-
# level inputs would
# ``docs(data = [...])`` inputs support the project build and are not
# assigned to a bundle mount. They must also be staged below the
# Sphinx source root for the sandboxed ``needs`` action; without this,
# standard literalinclude resolution cannot find them.
#
# Without the staging below, project-level inputs would
# remain only execution inputs for Sphinx's tools rather than files
# below Sphinx's source directory, where standard ``literalinclude``
# looks for them.
Expand All @@ -304,10 +304,13 @@ def docs(
sphinx_docs_library(
name = "_docs_data",
srcs = data,
strip_prefix = "",
# ``sphinx_docs_library`` otherwise defaults to this package and
# would change the path seen by a relative literalinclude.
strip_prefix = "/",
)
data_library_label_for_sphinx_docs = [":_docs_data"]

bundle_data_library_label_for_sphinx_docs = []
mounts_manifest_label = []
if bundles:
mounts_bundle = create_bundle(
Expand All @@ -316,6 +319,8 @@ def docs(
visibility = ["//visibility:private"],
)

bundle_data_library_label_for_sphinx_docs = [mounts_bundle]

mounts_manifest_label = [
create_mounts_manifest(
name = "_mounts_manifest",
Expand Down Expand Up @@ -472,7 +477,10 @@ def docs(
# complete bundle as srcs would also expose those files as raw Sphinx
# sources and make every nested need appear twice.
srcs = [sphinx_sources],
deps = data_library_label_for_sphinx_docs,
deps = (
data_library_label_for_sphinx_docs +
bundle_data_library_label_for_sphinx_docs
),
config = sphinx_config,
extra_opts = [
"-W",
Expand Down
34 changes: 22 additions & 12 deletions docs/reference/bazel_macros.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,22 +28,25 @@ The macro must be called from the repository root package.
Supporting files: project inputs and bundle payloads
----------------------------------------------------

There are two ``data`` attributes, and they belong to different documentation
trees:
The macros expose project inputs and bundle payloads separately:

* ``docs_bundle(data = [...])`` puts files in a bundle payload. The files
travel with that bundle and are resolved below the bundle's eventual
``mount_at`` path. Use this for generated documentation, images, and other
assets needed by a mounted bundle.
* ``docs(data = [...])`` puts files in the project-level ``docs()`` build,
outside any bundle. These files have no bundle mount path. Use this only for
inputs needed by the project-level build itself. ``bazel run`` does not copy
generated data into the workspace source tree; generated documentation or
assets must use ``docs_bundle(data = [...])``.

If a file belongs to a mounted bundle, use ``docs_bundle(data = [...])``.
Both attributes make files available to a build; they differ in which
documentation tree carries the files and where they are resolved.
outside any bundle. These files have no bundle mount path and do not travel
with the public bundle.
* A data-only ``docs_bundle(data = [...])`` is a separate child bundle. Compose
it through ``bundles`` when the files must travel with the project's public
bundle or with another mounted bundle. ``docs()`` stages non-document payloads
from such children below the sandboxed Sphinx source root, so a project's
original ``literalinclude`` path works without repeating the label in
``docs(data = [...])``. This keeps the original files in the bundle payload;
no snapshot is copied into ``docs/``.

If a file belongs to a nested or generated bundle, use
``docs_bundle(data = [...])`` and compose that bundle through ``bundles``.

Minimal example (root ``BUILD``)
--------------------------------
Expand Down Expand Up @@ -87,7 +90,8 @@ Minimal example (root ``BUILD``)
The items in ``data`` are added to the py_binaries and to the Sphinx tooling so they are
available at build time. These are project-level inputs; they are not part of
a bundle and do not receive a bundle mount path. Use ``docs_bundle(data = [...])``
for files that belong to mounted documentation.
for files that belong to mounted documentation or must travel with the public
bundle.

.. note::

Expand All @@ -97,6 +101,9 @@ Minimal example (root ``BUILD``)
- ``bundles`` (list of placement dicts)
Documentation bundles to overlay into this project's documentation tree, each with its
placement (``mount_at``). See :ref:`howto_mount_external_sources` for the full reference.
A data-only child contributes supporting files without adding a documentation
page; use a private-looking mount path when it exists only to carry data for
the public bundle.

- ``deps`` (list of bazel labels)
Additional Bazel dependencies to add to the Python binaries and the virtual environment
Expand Down Expand Up @@ -187,7 +194,10 @@ Signature: ``docs_bundle(name, source_dir = None, data = [], entry_doc = "index"
``index.rst``. If ``source_dir`` is omitted and ``data`` contains the
bundle's deliverable, the result is a data-only bundle. Use this attribute
for any file that belongs with the mounted bundle. Use
``docs(data = [...])`` only for project-level inputs outside a bundle.
``docs(data = [...])`` only for project-level inputs outside a bundle. If a
source page references a file outside its ``source_dir``, use a data-only
child bundle and compose it through ``bundles``. Its non-document payload is
staged for the host's sandboxed build automatically.

- ``entry_doc`` (string, optional)
Bundle-relative docname used as the canonical navigation entry. It defaults to
Expand Down
16 changes: 13 additions & 3 deletions src/extensions/docs/mounts_internals.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,22 @@ Each manifest entry contains:
* ``mount_at`` and ``attach_to`` — the already-composed Sphinx placement; and
* ``entry_doc`` — the canonical entry document declared by the source bundle.
* ``external`` — whether the directory belongs to another Bazel module.
* ``data`` — explicitly declared supporting files carried by that entry. For
a source-bearing entry these are bundle-owned references (for example a test
fixture shown with ``literalinclude``); for a data-only entry they identify
the generated files that form the mount.
* ``path_check`` — the ``sphinx-mounts`` confinement mode selected by the
bundle graph. It is normally ``error``; a source entry composed with a
data-only child uses ``off`` because sphinx-mounts 0.1.x has no per-file
allowlist for references outside the source directory.

The rule rejects conflicting final placements before Sphinx starts. A mount
without ``attach_to`` is attached to the ``index`` document beside its
``mount_at``; ``attach_to`` overrides that target. The Python
extension may therefore preserve declaration order and only translates each
manifest entry into the ``sphinx_mounts`` configuration format.
``mount_at``; ``attach_to`` overrides that target. The Python extension may
therefore preserve declaration order and only translates each manifest entry
into the ``sphinx_mounts`` configuration format. Data-only entries containing
non-document payloads do not create a directory mount; otherwise their parent
directory could expose unrelated documentation files to Sphinx.

Directory resolution
--------------------
Expand Down
12 changes: 12 additions & 0 deletions src/extensions/score_mounts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,18 @@ def _resolve_data_mounts(
"""
data_mounts: dict[str, MountSpec] = {}
for spec in manifest.mounts:
# Data on a source-bearing entry is supporting input for that source
# tree (for example a BUILD file shown by literalinclude), not a
# separate documentation tree. Only pure-data bundle entries need a
# directory mount for their generated source files.
if spec.src_root:
continue
for data_file in spec.data:
# Non-document payloads remain available as bundle inputs; mounting
# their parent would make sphinx-mounts walk unrelated neighboring
# documentation files as part of a data-only bundle.
if Path(data_file).suffix not in {".md", ".rst"}:
continue
if ws_root is not None and runfiles_dir is not None:
runfiles_str = str(runfiles_dir)
if "/bazel-out/" in runfiles_str:
Expand Down Expand Up @@ -168,6 +179,7 @@ def _make_mount_entry(walk_dir: Path, spec: MountSpec) -> dict[str, object]:
"mount_at": spec.mount_at,
"attach_to": spec.attach_to,
"entry_doc": spec.entry_doc,
"path_check": spec.path_check,
}


Expand Down
2 changes: 2 additions & 0 deletions src/extensions/score_mounts/_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class MountSpec:
external: bool = False
repository: str = ""
data: list[str] = field(default_factory=list)
path_check: str = "error"


@dataclass(frozen=True)
Expand Down Expand Up @@ -87,6 +88,7 @@ def load_mounts_manifest(manifest_path: str | Path) -> MountsManifest:
external=bool(entry.get("external", False)),
repository=str(entry.get("repository", "")),
data=[str(f) for f in cast("list[object]", raw_data)],
path_check=str(entry.get("path_check", "error")),
)
)
return MountsManifest(mounts=mounts)
Expand Down
Loading
Loading