From 6632d4a969f629724f16e78ac71e232e284da113 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Tue, 1 Sep 2026 17:12:46 +0200 Subject: [PATCH 1/8] feat: add docs_bundle(srcs) param --- bzl/bundle_rules.bzl | 111 +++++++++++++++--- bzl/mount_rules.bzl | 3 + docs.bzl | 43 +++++-- docs/how-to/bundles/examples.rst | 15 +-- docs/how-to/generated_docs.rst | 36 +++--- docs/reference/bazel_macros.rst | 56 +++++---- src/extensions/score_metamodel/docs/BUILD | 3 +- src/extensions/score_mounts/__init__.py | 6 +- src/extensions/score_mounts/_resolver.py | 28 ++++- .../score_mounts/tests/test_resolver.py | 58 ++++++++- .../scenarios/data_files_runfiles/BUILD | 6 +- .../docs_bzl/test_data_files_runfiles.py | 16 +-- 12 files changed, 298 insertions(+), 83 deletions(-) diff --git a/bzl/bundle_rules.bzl b/bzl/bundle_rules.bzl index ab59f354c..08909dcc1 100644 --- a/bzl/bundle_rules.bzl +++ b/bzl/bundle_rules.bzl @@ -64,12 +64,10 @@ DocsBundleInfo = provider( "entries": "Ordered entries, one per source directory, including its final documentation-tree location.", "own_source_files": "This bundle's direct source files, excluding nested bundles.", "sourcelinks": "Source-code-link JSON files together with their owning repository.", - "external_runfiles": "Documentation source files from external repositories needed in runfiles.", - # Bundle-owned generated/supporting files. Both bundle data and - # docs(data = [...]) are build inputs; unlike host-owned docs data, - # these are resolved at this bundle's mount (for example, a generated - # index.rst). - "data": "Bundle-owned generated/supporting files resolved at the bundle's mount.", + "external_runfiles": "Documentation source files not read from the workspace at runtime.", + # Bundle-owned supporting/runtime files. Unlike host-owned docs data, + # these are resolved at this bundle's mount. + "data": "Bundle-owned supporting/runtime files resolved at the bundle's mount.", }, ) @@ -137,13 +135,46 @@ def _bundle_runtime_path(ctx): runfiles. Keep that spelling here; ``_bundle_execroot_path`` converts it to the corresponding ``external//...`` form for build actions. """ - source_file = ctx.files.srcs[0].short_path + source_file = ctx.files.source_dir_globbed[0].short_path external_prefix = "" if source_file.startswith("../"): path_parts = source_file.split("/") external_prefix = path_parts[0] + "/" + path_parts[1] + "/" return external_prefix + ctx.attr.strip_prefix.rstrip("/") +def _source_targets_runtime_path(files): + """Return the runtime directory shared by one set of source targets. + + Source targets are either workspace files or generated outputs. Their + paths use different Bazel spellings, so the kind of the first file is used + consistently for the complete source set. + """ + first_file = files[0] + # Generated outputs need their execroot path; workspace files resolve from + # their runfiles short path. + first_path = first_file.path if not first_file.is_source else first_file.short_path + first_is_source = first_file.is_source + separator = first_path.rfind("/") + if separator < 0: + fail("explicit bundle source %r has no parent directory" % first_path) + runtime_path = first_path[:separator] + for source_file in files[1:]: + # A single bundle entry cannot combine source roots from the workspace + # and bazel-out because they have different runtime resolution rules. + if source_file.is_source != first_is_source: + fail("explicit bundle sources cannot mix workspace and generated files") + source_path = ( + source_file.path + if not source_file.is_source + else source_file.short_path + ) + source_separator = source_path.rfind("/") + source_root = source_path[:source_separator] if source_separator >= 0 else "" + if source_root != runtime_path: + fail(("explicit bundle sources must share one parent directory; " + + "found %r and %r") % (runtime_path, source_root)) + return runtime_path + def _bundle_execroot_path(runtime_path): """Return the execroot-relative spelling of an external runtime path.""" if runtime_path.startswith("../"): @@ -197,6 +228,9 @@ def _rebase_bundle_entry(entry, mount_at, attach_to): entry_doc = entry.entry_doc, external = entry.external, repository = entry.repository, + # Preserve whether the entry's source root comes from bazel-out when + # the entry is moved below a parent bundle's mount point. + generated = entry.generated, data = entry.data, ) @@ -245,7 +279,12 @@ def _docs_bundle_impl(ctx): own_external_runfiles = [] own_data = depset(direct = ctx.files.data) - if ctx.files.srcs: + # The macro validates this combination before creating the rule; retain + # the rule-level check for callers of the internal helper as well. + if ctx.files.source_dir_globbed and ctx.files.source_targets: + fail("a bundle cannot combine source_dir sources with explicit source targets") + + if ctx.files.source_dir_globbed: runtime_path = _bundle_runtime_path(ctx) external = runtime_path.startswith("../") entries.append(struct( @@ -259,13 +298,39 @@ def _docs_bundle_impl(ctx): entry_doc = ctx.attr.entry_doc, external = external, repository = ctx.label.workspace_name, + # Directory-discovered sources are resolved from the workspace. + generated = False, data = own_data, )) - own_source_files.extend(ctx.files.srcs) + own_source_files.extend(ctx.files.source_dir_globbed) # Local sources are read directly from the workspace by ``bazel run``. # Only sources from external repositories must be staged in runfiles. if external: - own_external_runfiles.extend(ctx.files.srcs) + own_external_runfiles.extend(ctx.files.source_dir_globbed) + elif ctx.files.source_targets: + # Explicit source targets retain their Bazel artifact location so the + # resolver can find generated files in bazel-bin under ``bazel run``. + runtime_path = _source_targets_runtime_path(ctx.files.source_targets) + # File.is_source distinguishes a workspace source target from a + # generated target; the runtime resolver uses that distinction later. + generated = not ctx.files.source_targets[0].is_source + external = runtime_path.startswith("../") + entries.append(struct( + runtime_path = runtime_path, + src_root = _bundle_execroot_path(runtime_path), + mount_at = "", + attach_to = "", + entry_doc = ctx.attr.entry_doc, + external = external, + repository = ctx.label.workspace_name, + generated = generated, + data = own_data, + )) + own_source_files.extend(ctx.files.source_targets) + # Explicit artifacts outside the workspace source tree need to be + # staged for ``bazel run`` just like external source bundles. + if generated or external: + own_external_runfiles.extend(ctx.files.source_targets) elif own_data: # Pure data bundle: create an entry so the data files appear in the manifest. entries.append(struct( @@ -276,6 +341,8 @@ def _docs_bundle_impl(ctx): entry_doc = ctx.attr.entry_doc, external = False, repository = ctx.label.workspace_name, + # Pure-data entries do not resolve a generated source root. + generated = False, data = own_data, )) @@ -327,7 +394,8 @@ def _docs_bundle_impl(ctx): _docs_bundle = rule( implementation = _docs_bundle_impl, attrs = { - "srcs": attr.label_list(allow_files = True), + "source_dir_globbed": attr.label_list(allow_files = True), + "source_targets": attr.label_list(allow_files = True), "sourcelinks": attr.label_list(allow_files = True), "strip_prefix": attr.string(default = ""), "entry_doc": attr.string(default = "index"), @@ -339,12 +407,27 @@ _docs_bundle = rule( doc = "Internal rule that carries bundle files and their documentation-tree locations.", ) -def create_bundle(name, bundles, srcs = [], sourcelinks = [], strip_prefix = "", entry_doc = "index", data = [], visibility = None, **kwargs): - """Create a reusable documentation bundle from files and child declarations.""" +def create_bundle( + name, + bundles, + source_dir_globbed = [], + source_targets = [], + sourcelinks = [], + strip_prefix = "", + entry_doc = "index", + data = [], + visibility = None, + **kwargs): + """Create a bundle from directory-discovered files and source targets. + + ``source_dir_globbed`` and ``source_targets`` are separate internal inputs + because they use different runtime path and staging rules. + """ parsed_bundles = [_parse_bundle_declaration(declaration) for declaration in bundles] _docs_bundle( name = name, - srcs = srcs, + source_dir_globbed = source_dir_globbed, + source_targets = source_targets, sourcelinks = sourcelinks, strip_prefix = strip_prefix, entry_doc = entry_doc, diff --git a/bzl/mount_rules.bzl b/bzl/mount_rules.bzl index dd42571eb..e2ad3580c 100644 --- a/bzl/mount_rules.bzl +++ b/bzl/mount_rules.bzl @@ -31,6 +31,9 @@ def _mounts_manifest_impl(ctx): "entry_doc": entry.entry_doc, "external": entry.external, "repository": entry.repository, + # Tell the runtime resolver whether src_root is a generated output + # tree rather than a workspace or external-repository directory. + "generated": entry.generated, "data": [f.path for f in entry.data.to_list()], }) diff --git a/docs.bzl b/docs.bzl index 5e2d66fbc..3dd2e3858 100644 --- a/docs.bzl +++ b/docs.bzl @@ -101,7 +101,17 @@ _generated_conf = rule( }, ) -def docs_bundle(name, source_dir = None, data = [], entry_doc = "index", bundles = [], scan_code = [], code_targets = [], visibility = None, **kwargs): +def docs_bundle( + name, + source_dir = None, + srcs = [], + data = [], + entry_doc = "index", + bundles = [], + scan_code = [], + code_targets = [], + visibility = None, + **kwargs): """A docs bundle, optionally composed of others. Args: @@ -109,10 +119,16 @@ def docs_bundle(name, source_dir = None, data = [], entry_doc = "index", bundles source_dir: optional directory holding this bundle's own doc sources. It is globbed like `docs()` (same file kinds) and the contents are stored after stripping the `source_dir` prefix. Leave it unset for a pure aggregator. + srcs: Explicit documentation source files, including generated files. + Use this for a source-less bundle whose documentation is produced by a + build action. All files must share one parent directory so they can be + mounted as one bundle entry. data: Files owned by this bundle that are not discovered as documentation - sources. This includes generated RST, literalinclude inputs, and assets - that belong at the bundle's eventual mount location. Omitting - ``source_dir`` creates a bundle containing only these files. + sources. Use this for runtime/support files that belong at the bundle's + eventual mount location. ``docs(data = [...])`` is the corresponding + shorthand for supporting files in the project's root bundle. Both + forms make their files available to a build; only bundle data travels + with a mounted bundle. entry_doc: bundle-relative docname attached when this bundle is mounted. Defaults to `index`. bundles: nested bundles to compose, each a dict @@ -130,7 +146,15 @@ def docs_bundle(name, source_dir = None, data = [], entry_doc = "index", bundles **kwargs: Additional attributes forwarded to the underlying rule. """ - srcs = glob_doc_sources(source_dir) if source_dir != None else [] + if source_dir != None and srcs: + fail( + ("docs_bundle(%s): srcs cannot be combined with source_dir; " + + "put generated sources in a dedicated bundle") % name, + ) + + # Keep directory-discovered sources separate from explicit Bazel targets so + # each kind can retain its own runtime path and staging behavior. + source_dir_globbed = glob_doc_sources(source_dir) if source_dir != None else [] sourcelinks = [] if scan_code: print("WARNING: docs_bundle(%s) uses deprecated scan_code; use code_targets instead." % name) @@ -152,7 +176,8 @@ def docs_bundle(name, source_dir = None, data = [], entry_doc = "index", bundles # The helper validates child declarations and creates the internal target. create_bundle( name = name, - srcs = srcs, + source_dir_globbed = source_dir_globbed, + source_targets = srcs, sourcelinks = sourcelinks, strip_prefix = strip_prefix, entry_doc = entry_doc, @@ -221,6 +246,9 @@ def docs( data: Additional files owned by this project's root ``:docs_bundle``. This is shorthand for declaring the files in that root bundle; mounted child content belongs in the child ``docs_bundle(data = [...])``. + For generated documentation in a mounted child, use that bundle's + explicit ``srcs`` instead; ``data`` remains for supporting/runtime + files. deps: Additional dependencies for the documentation build. external_needs: List of external needs targets to include in the documentation build. scan_code: Deprecated. Explicit source files or filegroups to scan for source @@ -245,7 +273,8 @@ def docs( ``docs(data = [...])`` owns files in the root ``:docs_bundle``. A child bundle uses the same ``data`` attribute and may omit ``source_dir`` when it - contains only generated or supporting files. + contains only supporting files. Use explicit ``srcs`` in a source-less + child bundle when its documentation is generated by a build action. """ # HINT: keep documentation sync docs/reference/bazel_macros.rst diff --git a/docs/how-to/bundles/examples.rst b/docs/how-to/bundles/examples.rst index 78d59e8d1..58d2273f7 100644 --- a/docs/how-to/bundles/examples.rst +++ b/docs/how-to/bundles/examples.rst @@ -51,12 +51,13 @@ If the parent is mounted at ``guides/example``, its page is rendered at ``guides/example/child/landing.html``. The consuming project can change ``guides/example`` without changing either bundle. -Mount generated documentation ------------------------------ +Mount generated bundle content +------------------------------ -When a build action produces documentation rather than a source-tree ``.rst`` -file, create a bundle without ``source_dir``. Its generated files are the -bundle's complete payload. You can generate and mount a page like this: +Use explicit ``srcs`` when a build action produces the documentation rather +than a source-tree ``.rst`` file. The source-less bundle has no ``source_dir``; +its generated files are the bundle's complete source payload. You can generate +and mount a page like this: .. code-block:: starlark @@ -89,8 +90,8 @@ See the `complete generated-data fixture on GitHub `_. The generated page is rendered at ``data_test/index.html`` and is added to the -consuming project's index page's toctree. It is mounted and navigated in -exactly the same way as a bundle with source files. +consuming project's index page's toctree. A generated source bundle is mounted +and navigated in exactly the same way as a bundle with workspace sources. Mount documentation from another module ---------------------------------------- diff --git a/docs/how-to/generated_docs.rst b/docs/how-to/generated_docs.rst index cfd2960a8..e5414cf25 100644 --- a/docs/how-to/generated_docs.rst +++ b/docs/how-to/generated_docs.rst @@ -23,16 +23,21 @@ with a ``docs_bundle`` and mount the bundle into your documentation tree. You find a `complete working example `_ in the :ref:`metamodel-reference`. -Supporting files belong to bundles ----------------------------------- +Which bundle attribute? +------------------------ + +For generated documentation, put the generated files in +``docs_bundle(srcs = [...])``. This follows Bazel's normal ``srcs`` semantics: +the files are inputs that Sphinx processes, even when they are generated by +another build action. -``docs(data = [...])`` adds files to the root ``:docs_bundle`` exposed by the -macro. A mounted documentation bundle declares its own files with -``docs_bundle(data = [...])``. In both cases the files are bundle payload and, -when mounted, are resolved below the bundle's ``mount_at`` path. +Use ``docs_bundle(data = [...])`` for runtime or supporting files that belong at +the bundle's mount but are not themselves documentation sources. The +``docs(data = [...])`` argument is shorthand for supporting files in the root +``:docs_bundle``; it is not a substitute for ``docs_bundle(srcs = [...])``. -For this how-to, generated documentation belongs in -``docs_bundle(data = [...])`` because it is mounted as a child bundle. +For this how-to, the rule is simple: generated documentation belongs in +``docs_bundle(srcs = [...])``. Step 1: Generate the RST Files ------------------------------ @@ -65,21 +70,22 @@ Verify: Step 2: Declare the Bundle -------------------------- -Wrap the generated files in a ``docs_bundle`` with the ``data`` attribute. -Do not use ``srcs`` — that is for handwritten sources in the source tree. +Wrap the generated files in a ``docs_bundle`` with the ``srcs`` attribute. +Explicit ``srcs`` may point to generated files; they are not limited to +handwritten sources. .. code-block:: starlark :caption: In your BUILD file docs_bundle( name = "design_bundle", - data = [":generate_design_rst"], + srcs = [":generate_design_rst"], ) -This bundle has no ``source_dir`` because all of its documentation is -generated. The generated ``index.rst`` becomes the bundle's entry page and -travels with the bundle when it is mounted. A bundle with both handwritten -sources and generated files can use ``source_dir`` and ``data`` together. +This is a source bundle without a ``source_dir``: all of its documentation is +generated. It is still a normal mountable bundle. The generated ``index.rst`` +becomes the bundle's entry page and travels with the bundle when it is mounted. +All explicit source files must share one parent directory. Verify: ``bazel build :design_bundle`` must succeed. diff --git a/docs/reference/bazel_macros.rst b/docs/reference/bazel_macros.rst index 275dcbbfc..9459c5c6f 100644 --- a/docs/reference/bazel_macros.rst +++ b/docs/reference/bazel_macros.rst @@ -25,18 +25,27 @@ See :doc:`commands ` for the targets/commands it creates. The macro must be called from the repository root package. -Supporting files ----------------- - -Documentation supporting files always belong to a ``docs_bundle``. The -``data`` argument of ``docs()`` is shorthand for adding files to the root -``:docs_bundle`` that the macro exposes. A mounted module puts its files in -its own ``docs_bundle(data = [...])``; those files travel with that bundle and -are resolved below its eventual ``mount_at`` path. - -A bundle with ``data`` but no ``source_dir`` is simply a bundle whose content -is generated or supporting files. It uses the same ownership and placement -mechanism as every other bundle. +Bundle content and supporting files +----------------------------------- + +There are two ways to add files to a bundle. The ``data`` argument of ``docs()`` +is shorthand for supporting files in the root ``:docs_bundle``: + +* ``docs_bundle(srcs = [...])`` puts documentation source files in a bundle. + The files may be generated outputs from another build action; they are + processed as documentation sources and resolved below the bundle's eventual + ``mount_at`` path. +* ``docs_bundle(data = [...])`` puts supporting or runtime files in a bundle + payload. Use this for files that belong at the mount but are not themselves + documentation sources. +* ``docs(data = [...])`` puts supporting files in the root ``:docs_bundle`` + exposed by ``docs()``. A mounted module puts its files in its own bundle; + these files travel with that bundle and are resolved below its eventual + ``mount_at`` path. + +If a file is mounted documentation, use ``docs_bundle(srcs = [...])``. Both +bundle attributes make files available to a build; they differ in whether the +files are processed as documentation sources or carried as supporting data. Minimal example (root ``BUILD``) -------------------------------- @@ -160,7 +169,7 @@ site). visibility = ["//visibility:public"], ) -Signature: ``docs_bundle(name, source_dir = None, data = [], entry_doc = "index", bundles = [], scan_code = [], code_targets = [], visibility = None)``. +Signature: ``docs_bundle(name, source_dir = None, srcs = [], data = [], entry_doc = "index", bundles = [], scan_code = [], code_targets = [], visibility = None)``. - ``source_dir`` (string, optional) Directory holding the bundle's own doc sources. It is globbed the same way as @@ -169,16 +178,21 @@ Signature: ``docs_bundle(name, source_dir = None, data = [], entry_doc = "index" ``concept/index.rst`` with ``source_dir = "concept"`` becomes ``index.rst``). The bundle exposes those files as a Bazel depset (via the ``DocsBundleInfo`` provider) and records the ``source_dir`` path; sphinx-mounts walks that original - directory directly — no copy is made. Leave it unset for a data-only bundle - or for an aggregator that only composes other ``bundles``. + directory directly — no copy is made. Leave it unset for a bundle whose + sources are supplied explicitly or for an aggregator that only composes + other ``bundles``. + +- ``srcs`` (list of bazel labels, optional) + Explicit documentation source files, including generated outputs from a + build action. Use this for a source-less bundle whose documentation is + generated. All files must share one parent directory. It cannot be combined + with ``source_dir``. - ``data`` (list of bazel labels, optional) - Supporting or generated files owned by this bundle. These files are part of - the bundle payload and are available at the bundle's eventual mount path; - they are useful for generated documentation sources such as a generated - ``index.rst``. If ``source_dir`` is omitted and ``data`` contains the - bundle's deliverable, the result is a bundle containing only supporting - files. Use this attribute for every file that belongs with a mounted bundle. + Supporting or runtime files owned by this bundle. These files are part of the + bundle payload and are available at the bundle's eventual mount path, but are + not processed as the bundle's documentation sources. Use ``docs(data = [...])`` + for supporting files in the root bundle and this attribute for child bundles. - ``entry_doc`` (string, optional) Bundle-relative docname used as the canonical navigation entry. It defaults to diff --git a/src/extensions/score_metamodel/docs/BUILD b/src/extensions/score_metamodel/docs/BUILD index bb7621a9f..f1e75b7af 100644 --- a/src/extensions/score_metamodel/docs/BUILD +++ b/src/extensions/score_metamodel/docs/BUILD @@ -39,7 +39,8 @@ py_binary( docs_bundle( name = "metamodel", - data = [":generate_metamodel_rst"], + # The generated RST and Mermaid outputs form one explicit source tree. + srcs = [":generate_metamodel_rst"], entry_doc = "index", visibility = ["//visibility:public"], ) diff --git a/src/extensions/score_mounts/__init__.py b/src/extensions/score_mounts/__init__.py index b03ca6d38..fe384da8d 100644 --- a/src/extensions/score_mounts/__init__.py +++ b/src/extensions/score_mounts/__init__.py @@ -117,7 +117,7 @@ def _canonical_mount_dir(walk_dir: Path, spec: MountSpec) -> Path: files inside that directory can be symlinks to Bazel's repository cache, for example ``~/.cache/bazel/.../external/score_process_description+/process/index.rst``. - - for generated data bundles, the mount root may be the sandbox copy of a + - for generated bundle sources or data, the mount root may be the sandbox copy of a ``bazel-out`` directory, for example ``.../sandbox/.../execroot/_main/bazel-out/.../docs/generated``; generated files below it can resolve to the action execroot spelling, @@ -129,8 +129,8 @@ def _canonical_mount_dir(walk_dir: Path, spec: MountSpec) -> Path: ``.../sandbox/.../execroot/_main/score/socom/docs/index.rst`` ``→ /home/user/workspace/score/socom/docs/index.rst``. - This applies to all bundle types: external repositories, generated data - bundles, and in-tree (same-workspace) source bundles. Resolve one mounted + This applies to all bundle types: external repositories, generated bundle + sources or data, and in-tree (same-workspace) source bundles. Resolve one mounted source file first and walk back by its bundle-relative suffix to get the canonical root. diff --git a/src/extensions/score_mounts/_resolver.py b/src/extensions/score_mounts/_resolver.py index e820d8bac..320ea1d29 100644 --- a/src/extensions/score_mounts/_resolver.py +++ b/src/extensions/score_mounts/_resolver.py @@ -29,6 +29,8 @@ @dataclass(frozen=True) class MountSpec: + """Describe one documentation mount and how its source root is resolved.""" + src_root: str runtime_path: str mount_at: str @@ -36,6 +38,9 @@ class MountSpec: entry_doc: str = "index" external: bool = False repository: str = "" + # Generated roots use bazel-bin under ``bazel run`` and bazel-out in a + # sandbox; source and external roots follow their normal path rules. + generated: bool = False data: list[str] = field(default_factory=list) @@ -86,6 +91,9 @@ def load_mounts_manifest(manifest_path: str | Path) -> MountsManifest: else "index", external=bool(entry.get("external", False)), repository=str(entry.get("repository", "")), + # Older manifests do not have this field and represent regular + # workspace or external-repository source roots. + generated=bool(entry.get("generated", False)), data=[str(f) for f in cast("list[object]", raw_data)], ) ) @@ -98,7 +106,25 @@ def resolve_walk_dir( ws_root: Path | None, runfiles_dir: Path | None = None, ) -> Path: - """Resolve a mount directory for either ``bazel run`` or a sandbox build.""" + """Resolve a mount directory for either ``bazel run`` or a sandbox build. + + Generated source roots are recorded with their execroot-relative bazel-out + path, while ``bazel run`` exposes the same artifacts below ``bazel-bin`` in + the workspace. The ``generated`` flag selects that translation. + """ + if spec.generated: + if ws_root is not None: + # Generated source files are exposed through bazel-bin at runtime, + # while their manifest paths are execroot-relative bazel-out paths. + output_parts = spec.src_root.split("/") + if ( + len(output_parts) >= 4 + and output_parts[0] == "bazel-out" + and output_parts[2] == "bin" + ): + return ws_root / "bazel-bin" / "/".join(output_parts[3:]) + return ws_root / spec.src_root + return Path.cwd() / spec.src_root if spec.external and ws_root is not None: if runfiles_dir is None: raise ValueError("external mounts under bazel run require RUNFILES_DIR") diff --git a/src/extensions/score_mounts/tests/test_resolver.py b/src/extensions/score_mounts/tests/test_resolver.py index b9fe60e5b..6c2d7d762 100644 --- a/src/extensions/score_mounts/tests/test_resolver.py +++ b/src/extensions/score_mounts/tests/test_resolver.py @@ -13,9 +13,8 @@ """Unit tests for the mounts manifest loader (``_resolver``). These cover the pure parsing layer only: reading the JSON manifest into -``MountSpec`` objects, applying defaults, and rejecting malformed -input. Context-dependent path resolution (runfiles vs. exec root) lives in the -extension's ``__init__`` and is exercised via the consumer tests instead.""" +``MountSpec`` objects, applying defaults, rejecting malformed input, and +resolving source roots in runfiles versus an exec root.""" import json from pathlib import Path @@ -164,3 +163,56 @@ def test_external_mount_uses_runfiles_root_under_bazel_run(tmp_path: Path) -> No tmp_path / "workspace", tmp_path, ) == (tmp_path / "score_process_description+" / "docs_as_mount") + + +def test_generated_source_mount_uses_bazel_bin_under_bazel_run(tmp_path: Path) -> None: + """Translate an execroot-relative generated source to workspace bazel-bin.""" + manifest = _write_manifest( + tmp_path, + { + "mounts": [ + { + "src_root": "bazel-out/k8-fastbuild/bin/pkg/generated", + "runtime_path": "bazel-out/k8-fastbuild/bin/pkg/generated", + "mount_at": "generated", + "generated": True, + } + ] + }, + ) + spec = load_mounts_manifest(manifest).mounts[0] + + assert ( + resolve_walk_dir( + load_mounts_manifest(manifest), + spec, + tmp_path / "workspace", + tmp_path / "workspace" / "docs.runfiles", + ) + == tmp_path / "workspace" / "bazel-bin" / "pkg" / "generated" + ) + + +def test_generated_source_mount_uses_execroot_in_sandbox( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Keep the generated source's execroot path inside a sandbox.""" + monkeypatch.chdir(tmp_path) + manifest = _write_manifest( + tmp_path, + { + "mounts": [ + { + "src_root": "bazel-out/k8-fastbuild/bin/pkg/generated", + "runtime_path": "bazel-out/k8-fastbuild/bin/pkg/generated", + "mount_at": "generated", + "generated": True, + } + ] + }, + ) + spec = load_mounts_manifest(manifest).mounts[0] + + assert resolve_walk_dir(load_mounts_manifest(manifest), spec, None) == ( + tmp_path / "bazel-out" / "k8-fastbuild" / "bin" / "pkg" / "generated" + ) diff --git a/src/tests/docs_bzl/scenarios/data_files_runfiles/BUILD b/src/tests/docs_bzl/scenarios/data_files_runfiles/BUILD index c03222455..bd4d7d02b 100644 --- a/src/tests/docs_bzl/scenarios/data_files_runfiles/BUILD +++ b/src/tests/docs_bzl/scenarios/data_files_runfiles/BUILD @@ -11,7 +11,7 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -# This fixture exercises the public docs() macro with a data bundle whose +# This fixture exercises the public docs() macro with a source bundle whose # only content is a genrule output living in ``bazel-out/.../bin/``. That # output must reach the runfiles of ``:docs`` (via _external_docs_runfiles) # and be resolved by ``score_mounts`` at ``bazel run`` time. See PR #686. @@ -30,10 +30,10 @@ genrule( ===================' > $@""", ) -# Pure-data bundle: the genrule output lives in ``bazel-out/``, not the tree. +# Source bundle: the genrule output lives in ``bazel-out/``, not the tree. docs_bundle( name = "data_bundle", - data = [":generated_page"], + srcs = [":generated_page"], entry_doc = "index", visibility = ["//visibility:public"], ) diff --git a/src/tests/docs_bzl/test_data_files_runfiles.py b/src/tests/docs_bzl/test_data_files_runfiles.py index 872a0cdeb..27d3c7148 100644 --- a/src/tests/docs_bzl/test_data_files_runfiles.py +++ b/src/tests/docs_bzl/test_data_files_runfiles.py @@ -11,19 +11,19 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -"""Verify that genrule-generated RST files are reachable at ``bazel run`` time. +"""Verify that genrule-generated RST sources are reachable at ``bazel run`` time. -The ``data`` attribute of ``docs_bundle`` carries genrule outputs that live -in ``bazel-out/.../bin/``. The fix in ``_external_docs_runfiles_impl`` stages -them into the runfiles of ``:docs``; ``score_mounts`` then resolves the -execroot-relative paths against ``/bazel-bin`` and mounts them. This -end-to-end test fails if either half of that chain regresses.""" +The ``srcs`` attribute of ``docs_bundle`` can carry genrule outputs that live +in ``bazel-out/.../bin/``. The bundle stages them into the runfiles of +``:docs``; ``score_mounts`` then resolves the generated source root through +``/bazel-bin`` and mounts it. This end-to-end test fails if either half +of that chain regresses.""" from src.tests.docs_bzl.helpers import run_scenario -def test_data_files_reachable_at_runtime(): - """Genrule output in a docs_bundle data dep must be resolved by Sphinx.""" +def test_generated_source_files_reachable_at_runtime(): + """Genrule output in a docs_bundle src dep must be resolved by Sphinx.""" result = run_scenario("run", "data_files_runfiles", ":docs") generated_html = result.build_dir / "data_test" / "index.html" From 83900dc05128f085c378376d742bdbbdd61c9f7f Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Tue, 1 Sep 2026 17:21:04 +0200 Subject: [PATCH 2/8] feat: add legacy data bundle and test for backward compatibility --- .../scenarios/data_files_runfiles/BUILD | 22 +++++++++++++++++++ .../docs_bzl/test_data_files_runfiles.py | 9 ++++++++ 2 files changed, 31 insertions(+) diff --git a/src/tests/docs_bzl/scenarios/data_files_runfiles/BUILD b/src/tests/docs_bzl/scenarios/data_files_runfiles/BUILD index bd4d7d02b..ecde23022 100644 --- a/src/tests/docs_bzl/scenarios/data_files_runfiles/BUILD +++ b/src/tests/docs_bzl/scenarios/data_files_runfiles/BUILD @@ -44,7 +44,29 @@ docs( "bundle": ":data_bundle", "mount_at": "data_test", "attach_to": "index", + }, { + "bundle": ":legacy_data_bundle", + "mount_at": "legacy_data_test", + "attach_to": "index", }], ) # END docs-bundle-howto: generated-data + +# Legacy compatibility: generated RST files declared through ``data`` must +# remain mountable while callers migrate to the explicit ``srcs`` attribute. +genrule( + name = "legacy_generated_page", + srcs = [], + outs = ["legacy_generated/index.rst"], + cmd = """echo 'Legacy Data Page +==================' > $@""", +) + +# Legacy compatibility bundle: ``data`` still reaches the Sphinx mount walker. +docs_bundle( + name = "legacy_data_bundle", + data = [":legacy_generated_page"], + entry_doc = "index", + visibility = ["//visibility:public"], +) diff --git a/src/tests/docs_bzl/test_data_files_runfiles.py b/src/tests/docs_bzl/test_data_files_runfiles.py index 27d3c7148..1ce2c7b3b 100644 --- a/src/tests/docs_bzl/test_data_files_runfiles.py +++ b/src/tests/docs_bzl/test_data_files_runfiles.py @@ -29,3 +29,12 @@ def test_generated_source_files_reachable_at_runtime(): generated_html = result.build_dir / "data_test" / "index.html" assert "Generated Data Page" in generated_html.read_text(encoding="utf-8") + + +def test_legacy_generated_data_files_remain_reachable_at_runtime(): + """Legacy generated RST files declared through ``data`` still work.""" + result = run_scenario("run", "data_files_runfiles", ":docs") + + legacy_html = result.build_dir / "legacy_data_test" / "index.html" + + assert "Legacy Data Page" in legacy_html.read_text(encoding="utf-8") From 37fc389d350a2a51c1163557999390ce6ccc9ed8 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Tue, 1 Sep 2026 17:38:25 +0200 Subject: [PATCH 3/8] fix: address review feedback for generated docs --- bzl/bundle_rules.bzl | 8 ++++---- docs/how-to/bundles/examples.rst | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bzl/bundle_rules.bzl b/bzl/bundle_rules.bzl index 08909dcc1..c8c5ea8ca 100644 --- a/bzl/bundle_rules.bzl +++ b/bzl/bundle_rules.bzl @@ -155,9 +155,8 @@ def _source_targets_runtime_path(files): first_path = first_file.path if not first_file.is_source else first_file.short_path first_is_source = first_file.is_source separator = first_path.rfind("/") - if separator < 0: - fail("explicit bundle source %r has no parent directory" % first_path) - runtime_path = first_path[:separator] + # A source in the root package has the workspace root as its parent. + runtime_path = first_path[:separator] if separator >= 0 else "." for source_file in files[1:]: # A single bundle entry cannot combine source roots from the workspace # and bazel-out because they have different runtime resolution rules. @@ -169,7 +168,8 @@ def _source_targets_runtime_path(files): else source_file.short_path ) source_separator = source_path.rfind("/") - source_root = source_path[:source_separator] if source_separator >= 0 else "" + # Use the same ``.`` spelling for another root-package source. + source_root = source_path[:source_separator] if source_separator >= 0 else "." if source_root != runtime_path: fail(("explicit bundle sources must share one parent directory; " + "found %r and %r") % (runtime_path, source_root)) diff --git a/docs/how-to/bundles/examples.rst b/docs/how-to/bundles/examples.rst index 58d2273f7..50020054c 100644 --- a/docs/how-to/bundles/examples.rst +++ b/docs/how-to/bundles/examples.rst @@ -69,10 +69,10 @@ and mount a page like this: ===================' > $@""", ) - # Pure-data bundle: the genrule output lives in ``bazel-out/``, not the tree. + # Source bundle: the genrule output lives in ``bazel-out/``, not the tree. docs_bundle( name = "data_bundle", - data = [":generated_page"], + srcs = [":generated_page"], entry_doc = "index", visibility = ["//visibility:public"], ) From a74be126726a903b4c08eb8a15fa51ec78cca9cc Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Tue, 1 Sep 2026 17:49:23 +0200 Subject: [PATCH 4/8] fix: resolve root generated source path --- src/extensions/score_mounts/_resolver.py | 4 ++- .../score_mounts/tests/test_resolver.py | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/extensions/score_mounts/_resolver.py b/src/extensions/score_mounts/_resolver.py index 320ea1d29..b456c351f 100644 --- a/src/extensions/score_mounts/_resolver.py +++ b/src/extensions/score_mounts/_resolver.py @@ -118,7 +118,9 @@ def resolve_walk_dir( # while their manifest paths are execroot-relative bazel-out paths. output_parts = spec.src_root.split("/") if ( - len(output_parts) >= 4 + # A generated file may be directly below the configuration's + # ``bin`` directory, so the source root itself can end there. + len(output_parts) >= 3 and output_parts[0] == "bazel-out" and output_parts[2] == "bin" ): diff --git a/src/extensions/score_mounts/tests/test_resolver.py b/src/extensions/score_mounts/tests/test_resolver.py index 6c2d7d762..ab55d1f95 100644 --- a/src/extensions/score_mounts/tests/test_resolver.py +++ b/src/extensions/score_mounts/tests/test_resolver.py @@ -193,6 +193,31 @@ def test_generated_source_mount_uses_bazel_bin_under_bazel_run(tmp_path: Path) - ) +def test_generated_root_source_mount_uses_bazel_bin_under_bazel_run( + tmp_path: Path, +) -> None: + """Translate a generated root-level source to the bazel-bin directory.""" + manifest = _write_manifest( + tmp_path, + { + "mounts": [ + { + "src_root": "bazel-out/k8-fastbuild/bin", + "runtime_path": "bazel-out/k8-fastbuild/bin", + "mount_at": "generated", + "generated": True, + } + ] + }, + ) + spec = load_mounts_manifest(manifest).mounts[0] + + assert ( + resolve_walk_dir(load_mounts_manifest(manifest), spec, tmp_path / "workspace") + == tmp_path / "workspace" / "bazel-bin" + ) + + def test_generated_source_mount_uses_execroot_in_sandbox( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 4ef3b50f4b19c73aca7f51cd6bc309248025d90f Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Tue, 1 Sep 2026 20:00:43 +0200 Subject: [PATCH 5/8] fix: isolate explicit docs bundle sources --- bzl/bundle_rules.bzl | 59 ++++++++++++++----- bzl/mount_rules.bzl | 9 ++- src/extensions/score_mounts/__init__.py | 24 +++++++- src/extensions/score_mounts/_resolver.py | 34 +++++++++++ .../score_mounts/tests/test_resolver.py | 29 +++++++++ src/extensions/score_sync_toml/_mounts.py | 31 +++++++--- src/extensions/score_sync_toml/test_mounts.py | 28 +++++++++ .../scenarios/data_files_runfiles/BUILD | 14 +++++ .../data_files_runfiles/docs/index.rst | 2 + .../data_files_runfiles/isolated/index.rst | 18 ++++++ .../isolated/undeclared.rst | 18 ++++++ .../docs_bzl/test_data_files_runfiles.py | 11 ++++ 12 files changed, 250 insertions(+), 27 deletions(-) create mode 100644 src/tests/docs_bzl/scenarios/data_files_runfiles/isolated/index.rst create mode 100644 src/tests/docs_bzl/scenarios/data_files_runfiles/isolated/undeclared.rst diff --git a/bzl/bundle_rules.bzl b/bzl/bundle_rules.bzl index c8c5ea8ca..0628e23e0 100644 --- a/bzl/bundle_rules.bzl +++ b/bzl/bundle_rules.bzl @@ -142,6 +142,12 @@ def _bundle_runtime_path(ctx): external_prefix = path_parts[0] + "/" + path_parts[1] + "/" return external_prefix + ctx.attr.strip_prefix.rstrip("/") +def _source_target_path(source_file): + """Return the path spelling used by the source-target staging action.""" + # Generated files need their execroot path; workspace files resolve from + # their runfiles short path. + return source_file.path if not source_file.is_source else source_file.short_path + def _source_targets_runtime_path(files): """Return the runtime directory shared by one set of source targets. @@ -150,9 +156,7 @@ def _source_targets_runtime_path(files): consistently for the complete source set. """ first_file = files[0] - # Generated outputs need their execroot path; workspace files resolve from - # their runfiles short path. - first_path = first_file.path if not first_file.is_source else first_file.short_path + first_path = _source_target_path(first_file) first_is_source = first_file.is_source separator = first_path.rfind("/") # A source in the root package has the workspace root as its parent. @@ -162,11 +166,7 @@ def _source_targets_runtime_path(files): # and bazel-out because they have different runtime resolution rules. if source_file.is_source != first_is_source: fail("explicit bundle sources cannot mix workspace and generated files") - source_path = ( - source_file.path - if not source_file.is_source - else source_file.short_path - ) + source_path = _source_target_path(source_file) source_separator = source_path.rfind("/") # Use the same ``.`` spelling for another root-package source. source_root = source_path[:source_separator] if source_separator >= 0 else "." @@ -175,6 +175,22 @@ def _source_targets_runtime_path(files): "found %r and %r") % (runtime_path, source_root)) return runtime_path +def _source_targets_relative_paths(files, runtime_path): + """Return each source target's path relative to the shared source root.""" + relative_paths = [] + for source_file in files: + source_path = _source_target_path(source_file) + # A root-package source has ``.`` as its shared parent, so its complete + # path is already relative to the staging tree. + if runtime_path == ".": + relative_paths.append(source_path) + continue + prefix = runtime_path + "/" + if not source_path.startswith(prefix): + fail("explicit bundle source %r is outside %r" % (source_path, runtime_path)) + relative_paths.append(source_path[len(prefix):]) + return relative_paths + def _bundle_execroot_path(runtime_path): """Return the execroot-relative spelling of an external runtime path.""" if runtime_path.startswith("../"): @@ -231,6 +247,8 @@ def _rebase_bundle_entry(entry, mount_at, attach_to): # Preserve whether the entry's source root comes from bazel-out when # the entry is moved below a parent bundle's mount point. generated = entry.generated, + # Preserve the explicit file allowlist when the entry is rebased. + files = entry.files, data = entry.data, ) @@ -300,6 +318,8 @@ def _docs_bundle_impl(ctx): repository = ctx.label.workspace_name, # Directory-discovered sources are resolved from the workspace. generated = False, + # Directory mounts discover all supported files below this root. + files = [], data = own_data, )) own_source_files.extend(ctx.files.source_dir_globbed) @@ -308,12 +328,14 @@ def _docs_bundle_impl(ctx): if external: own_external_runfiles.extend(ctx.files.source_dir_globbed) elif ctx.files.source_targets: - # Explicit source targets retain their Bazel artifact location so the - # resolver can find generated files in bazel-bin under ``bazel run``. + # Keep explicit sources at their original paths; the manifest carries + # the declared relative file list so runtime discovery cannot include + # undeclared siblings from the shared parent directory. runtime_path = _source_targets_runtime_path(ctx.files.source_targets) - # File.is_source distinguishes a workspace source target from a - # generated target; the runtime resolver uses that distinction later. - generated = not ctx.files.source_targets[0].is_source + source_files = _source_targets_relative_paths( + ctx.files.source_targets, + runtime_path, + ) external = runtime_path.startswith("../") entries.append(struct( runtime_path = runtime_path, @@ -323,13 +345,18 @@ def _docs_bundle_impl(ctx): entry_doc = ctx.attr.entry_doc, external = external, repository = ctx.label.workspace_name, - generated = generated, + # Generated inputs need bazel-out-to-bazel-bin translation; source + # inputs resolve from their original workspace or runfiles paths. + generated = not ctx.files.source_targets[0].is_source, + # Runtime file-list mounting uses these paths relative to the + # original source root and therefore visits only declared files. + files = source_files, data = own_data, )) own_source_files.extend(ctx.files.source_targets) # Explicit artifacts outside the workspace source tree need to be # staged for ``bazel run`` just like external source bundles. - if generated or external: + if not ctx.files.source_targets[0].is_source or external: own_external_runfiles.extend(ctx.files.source_targets) elif own_data: # Pure data bundle: create an entry so the data files appear in the manifest. @@ -343,6 +370,8 @@ def _docs_bundle_impl(ctx): repository = ctx.label.workspace_name, # Pure-data entries do not resolve a generated source root. generated = False, + # Pure-data entries have no documentation source allowlist. + files = [], data = own_data, )) diff --git a/bzl/mount_rules.bzl b/bzl/mount_rules.bzl index e2ad3580c..4ec4e26d6 100644 --- a/bzl/mount_rules.bzl +++ b/bzl/mount_rules.bzl @@ -23,7 +23,7 @@ def _mounts_manifest_impl(ctx): json_mounts = [] for entry in entries: - json_mounts.append({ + mount = { "src_root": entry.src_root, "runtime_path": entry.runtime_path, "mount_at": entry.mount_at, @@ -35,7 +35,12 @@ def _mounts_manifest_impl(ctx): # tree rather than a workspace or external-repository directory. "generated": entry.generated, "data": [f.path for f in entry.data.to_list()], - }) + } + # Explicit source targets are mounted as a file allowlist. Directory + # bundles omit this key and retain the existing recursive behavior. + if entry.files: + mount["files"] = entry.files + json_mounts.append(mount) out = ctx.actions.declare_file(ctx.label.name + ".json") ctx.actions.write(out, json.encode({"mounts": json_mounts})) diff --git a/src/extensions/score_mounts/__init__.py b/src/extensions/score_mounts/__init__.py index fe384da8d..d79c96582 100644 --- a/src/extensions/score_mounts/__init__.py +++ b/src/extensions/score_mounts/__init__.py @@ -35,6 +35,7 @@ MountsManifest, MountSpec, load_mounts_manifest, + resolve_source_files, resolve_walk_dir, ) from src.helper_lib import find_ws_root, get_runfiles_dir @@ -171,6 +172,18 @@ def _make_mount_entry(walk_dir: Path, spec: MountSpec) -> dict[str, object]: } +def _make_file_mount_entry( + source_files: list[Path], spec: MountSpec +) -> dict[str, object]: + """Build a file-list mount entry from the original source files.""" + return { + "files": [str(source_file) for source_file in source_files], + "mount_at": spec.mount_at, + "attach_to": spec.attach_to, + "entry_doc": spec.entry_doc, + } + + def _on_config_inited(app: Sphinx, config: Config) -> None: """Translate the Bazel manifest into ``sphinx_mounts`` runtime config. @@ -186,8 +199,9 @@ def _on_config_inited(app: Sphinx, config: Config) -> None: ws_root = find_ws_root() runfiles_dir = get_runfiles_dir() if ws_root is not None else None - # In every context sphinx_mounts walks the bundle's original files (no copy is - # made); only where those files are staged differs: + # In every context sphinx_mounts reads the bundle's original files (no copy + # is made); directory mounts are walked while explicit source mounts use + # their declared file list. Only where those files are staged differs: # * external bundle: use its runfiles-relative location under ``bazel run`` # and its execroot-relative location in a sandboxed Bazel build. # * in-tree bundle under `bazel run`: use the live workspace source @@ -202,6 +216,12 @@ def _on_config_inited(app: Sphinx, config: Config) -> None: for spec in manifest.mounts: if not spec.src_root: continue + if spec.files: + # Explicit source bundles use sphinx-mounts' file-list mode so the + # original files are read directly without discovering siblings. + source_files = resolve_source_files(manifest, spec, ws_root, runfiles_dir) + runtime_mounts.append(_make_file_mount_entry(source_files, spec)) + continue walk_dir = resolve_walk_dir(manifest, spec, ws_root, runfiles_dir) if not walk_dir.is_dir(): raise ValueError( diff --git a/src/extensions/score_mounts/_resolver.py b/src/extensions/score_mounts/_resolver.py index b456c351f..7b01074e2 100644 --- a/src/extensions/score_mounts/_resolver.py +++ b/src/extensions/score_mounts/_resolver.py @@ -41,6 +41,9 @@ class MountSpec: # Generated roots use bazel-bin under ``bazel run`` and bazel-out in a # sandbox; source and external roots follow their normal path rules. generated: bool = False + # Explicit source bundles provide paths relative to ``runtime_path`` so + # the mount can use the original files without recursively walking peers. + files: list[str] = field(default_factory=list) data: list[str] = field(default_factory=list) @@ -80,6 +83,11 @@ def load_mounts_manifest(manifest_path: str | Path) -> MountsManifest: raise ValueError( f"mounts manifest entry field 'data' must be a list: {raw_data!r}" ) + raw_files = entry.get("files", []) + if not isinstance(raw_files, list): + raise ValueError( + f"mounts manifest entry field 'files' must be a list: {raw_files!r}" + ) mounts.append( MountSpec( src_root=str(entry["src_root"]), @@ -94,6 +102,7 @@ def load_mounts_manifest(manifest_path: str | Path) -> MountsManifest: # Older manifests do not have this field and represent regular # workspace or external-repository source roots. generated=bool(entry.get("generated", False)), + files=[str(f) for f in cast("list[object]", raw_files)], data=[str(f) for f in cast("list[object]", raw_data)], ) ) @@ -137,3 +146,28 @@ def resolve_walk_dir( if ws_root is not None: return ws_root / spec.src_root return Path.cwd() / spec.src_root + + +def resolve_source_files( + manifest: MountsManifest, + spec: MountSpec, + ws_root: Path | None, + runfiles_dir: Path | None = None, +) -> list[Path]: + """Resolve an explicit source allowlist below its original parent. + + ``src_root`` uses the same context-dependent resolution as directory + mounts. The manifest's relative file names then identify only the Bazel + artifacts declared by ``docs_bundle(srcs = [...])``. + """ + walk_dir = resolve_walk_dir(manifest, spec, ws_root, runfiles_dir) + resolved_files = [] + for relative_path in spec.files: + source_file = walk_dir / relative_path + if not source_file.is_file(): + raise ValueError( + "score_mounts: resolved source file does not exist: " + f"{source_file} (mount_at={spec.mount_at})" + ) + resolved_files.append(source_file) + return resolved_files diff --git a/src/extensions/score_mounts/tests/test_resolver.py b/src/extensions/score_mounts/tests/test_resolver.py index ab55d1f95..326942fc3 100644 --- a/src/extensions/score_mounts/tests/test_resolver.py +++ b/src/extensions/score_mounts/tests/test_resolver.py @@ -24,6 +24,7 @@ from src.extensions.score_mounts._resolver import ( MountSpec, load_mounts_manifest, + resolve_source_files, resolve_walk_dir, ) @@ -218,6 +219,34 @@ def test_generated_root_source_mount_uses_bazel_bin_under_bazel_run( ) +def test_explicit_source_files_resolve_below_original_root(tmp_path: Path) -> None: + """Resolve an explicit file allowlist without copying its source files.""" + source_root = tmp_path / "workspace" / "docs" + source_root.mkdir(parents=True) + (source_root / "index.rst").write_text("Index", encoding="utf-8") + (source_root / "guide.rst").write_text("Guide", encoding="utf-8") + manifest = _write_manifest( + tmp_path, + { + "mounts": [ + { + "src_root": "docs", + "runtime_path": "docs", + "mount_at": "generated", + "files": ["index.rst", "guide.rst"], + } + ] + }, + ) + spec = load_mounts_manifest(manifest).mounts[0] + + assert resolve_source_files( + load_mounts_manifest(manifest), + spec, + tmp_path / "workspace", + ) == [source_root / "index.rst", source_root / "guide.rst"] + + def test_generated_source_mount_uses_execroot_in_sandbox( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/src/extensions/score_sync_toml/_mounts.py b/src/extensions/score_sync_toml/_mounts.py index d84cf3412..ab6ae9253 100644 --- a/src/extensions/score_sync_toml/_mounts.py +++ b/src/extensions/score_sync_toml/_mounts.py @@ -20,38 +20,53 @@ def _toml_string(value: str) -> str: return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' -def _toml_dir(entry: dict[str, Any]) -> str: - """Derive a stable TOML path from a resolved ``config.mounts`` entry.""" - walk_dir = Path(entry["dir"]).resolve() +def _toml_path(path: Path) -> str: + """Derive a stable TOML path from a resolved runtime path.""" + resolved_path = path.resolve() git_root = find_git_root() if git_root is not None: try: - return str(walk_dir.relative_to(git_root)) + return str(resolved_path.relative_to(git_root)) except ValueError: pass try: - external_path = walk_dir.relative_to(get_runfiles_dir()) + external_path = resolved_path.relative_to(get_runfiles_dir()) except ValueError: - return str(walk_dir) + return str(resolved_path) if external_path.parts[0] == "_main": return str(Path(*external_path.parts[1:])) return "bazel-bin/external/" + str(external_path) +def _toml_dir(entry: dict[str, Any]) -> str: + """Derive a stable TOML directory from a resolved mount entry.""" + return _toml_path(Path(entry["dir"])) + + def materialize_mounts(entries: list[dict[str, Any]]) -> Path | None: """Write resolved mounts as a temporary, Git-root-relative TOML merge file.""" if not entries: return None lines: list[str] = [] for entry in entries: + source_files = entry.get("files", []) lines.extend( [ "[[mounts]]", - f"dir = {_toml_string(_toml_dir(entry))}", - f"mount_at = {_toml_string(entry['mount_at'])}", ] ) + if source_files: + # Preserve explicit source mounts as a file allowlist in the + # generated TOML instead of widening them back to a directory. + files = ", ".join( + _toml_string(_toml_path(Path(source_file))) + for source_file in source_files + ) + lines.append(f"files = [{files}]") + else: + lines.append(f"dir = {_toml_string(_toml_dir(entry))}") + lines.append(f"mount_at = {_toml_string(entry['mount_at'])}") if entry.get("attach_to"): lines.append(f"attach_to = {_toml_string(entry['attach_to'])}") if entry.get("entry_doc", "index") != "index": diff --git a/src/extensions/score_sync_toml/test_mounts.py b/src/extensions/score_sync_toml/test_mounts.py index 52a299af6..d718b0b8a 100644 --- a/src/extensions/score_sync_toml/test_mounts.py +++ b/src/extensions/score_sync_toml/test_mounts.py @@ -72,6 +72,34 @@ def test_materialize_mounts_maps_external_runfiles_path_to_bazel_bin( ) +def test_materialize_mounts_preserves_explicit_source_files( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Serialize explicit mounts as files instead of widening them to dirs.""" + git_root = tmp_path / "workspace" + git_root.mkdir() + monkeypatch.setattr(_mounts, "find_git_root", lambda: git_root) + + fragment = materialize_mounts( + [ + { + "files": [ + str(git_root / "generated" / "index.rst"), + str(git_root / "generated" / "guide.rst"), + ], + "mount_at": "generated", + } + ] + ) + + assert fragment is not None + assert fragment.read_text(encoding="utf-8") == ( + "[[mounts]]\n" + 'files = ["generated/index.rst", "generated/guide.rst"]\n' + 'mount_at = "generated"\n' + ) + + def test_setup_skips_toml_sync_without_git_worktree( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/src/tests/docs_bzl/scenarios/data_files_runfiles/BUILD b/src/tests/docs_bzl/scenarios/data_files_runfiles/BUILD index ecde23022..78f3837e5 100644 --- a/src/tests/docs_bzl/scenarios/data_files_runfiles/BUILD +++ b/src/tests/docs_bzl/scenarios/data_files_runfiles/BUILD @@ -38,6 +38,16 @@ docs_bundle( visibility = ["//visibility:public"], ) +# This source bundle deliberately declares only one file from ``isolated/``. +# The sibling below must not become part of the mounted documentation merely +# because it shares the source file's original parent directory. +docs_bundle( + name = "isolated_source_bundle", + srcs = ["isolated/index.rst"], + entry_doc = "index", + visibility = ["//visibility:public"], +) + docs( source_dir = "docs", bundles = [{ @@ -48,6 +58,10 @@ docs( "bundle": ":legacy_data_bundle", "mount_at": "legacy_data_test", "attach_to": "index", + }, { + "bundle": ":isolated_source_bundle", + "mount_at": "isolated_test", + "attach_to": "index", }], ) diff --git a/src/tests/docs_bzl/scenarios/data_files_runfiles/docs/index.rst b/src/tests/docs_bzl/scenarios/data_files_runfiles/docs/index.rst index 3259facbe..d97f68a41 100644 --- a/src/tests/docs_bzl/scenarios/data_files_runfiles/docs/index.rst +++ b/src/tests/docs_bzl/scenarios/data_files_runfiles/docs/index.rst @@ -17,3 +17,5 @@ Host page with generated bundle data The host project can mount a pure-data bundle and include its generated page in the documentation navigation. + +The explicitly declared source bundle also mounts only its declared page. diff --git a/src/tests/docs_bzl/scenarios/data_files_runfiles/isolated/index.rst b/src/tests/docs_bzl/scenarios/data_files_runfiles/isolated/index.rst new file mode 100644 index 000000000..6af1abff7 --- /dev/null +++ b/src/tests/docs_bzl/scenarios/data_files_runfiles/isolated/index.rst @@ -0,0 +1,18 @@ +.. + # ******************************************************************************* + # Copyright (c) 2026 Contributors to the Eclipse Foundation + # + # See the NOTICE file(s) distributed with this work for additional + # information regarding copyright ownership. + # + # This program and the accompanying materials are made available under the + # terms of the Apache License Version 2.0 which is available at + # https://www.apache.org/licenses/LICENSE-2.0 + # + # SPDX-License-Identifier: Apache-2.0 + # ******************************************************************************* + +Isolated Declared Page +====================== + +This page is explicitly declared in the bundle. diff --git a/src/tests/docs_bzl/scenarios/data_files_runfiles/isolated/undeclared.rst b/src/tests/docs_bzl/scenarios/data_files_runfiles/isolated/undeclared.rst new file mode 100644 index 000000000..76fe55768 --- /dev/null +++ b/src/tests/docs_bzl/scenarios/data_files_runfiles/isolated/undeclared.rst @@ -0,0 +1,18 @@ +.. + # ******************************************************************************* + # Copyright (c) 2026 Contributors to the Eclipse Foundation + # + # See the NOTICE file(s) distributed with this work for additional + # information regarding copyright ownership. + # + # This program and the accompanying materials are made available under the + # terms of the Apache License Version 2.0 which is available at + # https://www.apache.org/licenses/LICENSE-2.0 + # + # SPDX-License-Identifier: Apache-2.0 + # ******************************************************************************* + +Undeclared Sibling Page +======================= + +This page must not be mounted by the explicit source bundle. diff --git a/src/tests/docs_bzl/test_data_files_runfiles.py b/src/tests/docs_bzl/test_data_files_runfiles.py index 1ce2c7b3b..5f3ffb7f4 100644 --- a/src/tests/docs_bzl/test_data_files_runfiles.py +++ b/src/tests/docs_bzl/test_data_files_runfiles.py @@ -38,3 +38,14 @@ def test_legacy_generated_data_files_remain_reachable_at_runtime(): legacy_html = result.build_dir / "legacy_data_test" / "index.html" assert "Legacy Data Page" in legacy_html.read_text(encoding="utf-8") + + +def test_explicit_source_bundle_excludes_undeclared_siblings(): + """Explicit source bundles must not recursively mount undeclared files.""" + result = run_scenario("run", "data_files_runfiles", ":docs") + + declared_html = result.build_dir / "isolated_test" / "index.html" + undeclared_html = result.build_dir / "isolated_test" / "undeclared.html" + + assert "Isolated Declared Page" in declared_html.read_text(encoding="utf-8") + assert not undeclared_html.exists() From 1094b368e0799d59aba70d72e36613ba63f98e74 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Wed, 2 Sep 2026 07:57:19 +0200 Subject: [PATCH 6/8] fix: preserve companion assets in source bundles --- src/extensions/score_mounts/__init__.py | 25 ++++++++++++++++++- .../scenarios/data_files_runfiles/BUILD | 21 +++++++++++++--- .../scenarios/invalid_bundle_placements/BUILD | 9 +++++++ .../docs_bzl/test_data_files_runfiles.py | 1 + .../test_invalid_bundle_placements.py | 4 +++ 5 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/extensions/score_mounts/__init__.py b/src/extensions/score_mounts/__init__.py index d79c96582..a20653e71 100644 --- a/src/extensions/score_mounts/__init__.py +++ b/src/extensions/score_mounts/__init__.py @@ -184,6 +184,16 @@ def _make_file_mount_entry( } +def _configured_source_suffixes(config: Config) -> tuple[str, ...]: + """Return the source suffixes configured for the current Sphinx build.""" + configured = config.source_suffix + # Sphinx accepts either a sequence of suffixes or a mapping from suffixes + # to parser names; both forms expose the suffixes during iteration. + if isinstance(configured, str): + return (configured,) + return tuple(configured) + + def _on_config_inited(app: Sphinx, config: Config) -> None: """Translate the Bazel manifest into ``sphinx_mounts`` runtime config. @@ -220,7 +230,20 @@ def _on_config_inited(app: Sphinx, config: Config) -> None: # Explicit source bundles use sphinx-mounts' file-list mode so the # original files are read directly without discovering siblings. source_files = resolve_source_files(manifest, spec, ws_root, runfiles_dir) - runtime_mounts.append(_make_file_mount_entry(source_files, spec)) + source_suffixes = _configured_source_suffixes(config) + document_files = [ + source_file + for source_file in source_files + if any(source_file.name.endswith(suffix) for suffix in source_suffixes) + ] + if not document_files: + # An explicit bundle may contain only companion assets. Such + # assets remain available at their original paths, but there + # is no Sphinx document to register for this mount. + continue + # Companion assets stay in the original source directory and are + # resolved relative to the explicitly mounted document. + runtime_mounts.append(_make_file_mount_entry(document_files, spec)) continue walk_dir = resolve_walk_dir(manifest, spec, ws_root, runfiles_dir) if not walk_dir.is_dir(): diff --git a/src/tests/docs_bzl/scenarios/data_files_runfiles/BUILD b/src/tests/docs_bzl/scenarios/data_files_runfiles/BUILD index 78f3837e5..f796ba5e1 100644 --- a/src/tests/docs_bzl/scenarios/data_files_runfiles/BUILD +++ b/src/tests/docs_bzl/scenarios/data_files_runfiles/BUILD @@ -25,9 +25,24 @@ load("//:docs.bzl", "docs", "docs_bundle") genrule( name = "generated_page", srcs = [], - outs = ["generated/index.rst"], - cmd = """echo 'Generated Data Page -===================' > $@""", + outs = [ + "generated/index.rst", + "generated/generated_data.mmd", + ], + # The RST and Mermaid file are both declared sources, but only the RST + # should be registered as a Sphinx document. The Mermaid file is a + # companion asset that must remain readable next to the original RST. + cmd = """cat > $(location generated/index.rst) <<'EOF' +Generated Data Page +=================== + +.. mermaid:: generated_data.mmd + :name: generated-data-diagram +EOF +cat > $(location generated/generated_data.mmd) <<'EOF' +classDiagram + class GeneratedData +EOF""", ) # Source bundle: the genrule output lives in ``bazel-out/``, not the tree. diff --git a/src/tests/docs_bzl/scenarios/invalid_bundle_placements/BUILD b/src/tests/docs_bzl/scenarios/invalid_bundle_placements/BUILD index 82dc36edf..3ba02e927 100644 --- a/src/tests/docs_bzl/scenarios/invalid_bundle_placements/BUILD +++ b/src/tests/docs_bzl/scenarios/invalid_bundle_placements/BUILD @@ -75,3 +75,12 @@ docs_bundle( ], tags = ["manual"], ) + +# The public macro must reject mixing directory-discovered and explicit source +# inputs before creating an ambiguous bundle definition. +docs_bundle( + name = "bad_source_and_srcs", + source_dir = "child", + srcs = ["child/a.rst"], + tags = ["manual"], +) diff --git a/src/tests/docs_bzl/test_data_files_runfiles.py b/src/tests/docs_bzl/test_data_files_runfiles.py index 5f3ffb7f4..e770535aa 100644 --- a/src/tests/docs_bzl/test_data_files_runfiles.py +++ b/src/tests/docs_bzl/test_data_files_runfiles.py @@ -29,6 +29,7 @@ def test_generated_source_files_reachable_at_runtime(): generated_html = result.build_dir / "data_test" / "index.html" assert "Generated Data Page" in generated_html.read_text(encoding="utf-8") + assert "generated-data-diagram" in generated_html.read_text(encoding="utf-8") def test_legacy_generated_data_files_remain_reachable_at_runtime(): diff --git a/src/tests/docs_bzl/test_invalid_bundle_placements.py b/src/tests/docs_bzl/test_invalid_bundle_placements.py index 2c59249b9..8d492bc8a 100644 --- a/src/tests/docs_bzl/test_invalid_bundle_placements.py +++ b/src/tests/docs_bzl/test_invalid_bundle_placements.py @@ -31,3 +31,7 @@ def test_invalid_bundle_placements_are_rejected_during_analysis(): run_scenario( "build", "invalid_bundle_placements", ":bad_attach_to", expect_error=True ) + result = run_scenario( + "build", "invalid_bundle_placements", ":bad_source_and_srcs", expect_error=True + ) + assert "srcs cannot be combined with source_dir" in result.stderr From 048187a0f55eed161e6e2eee3b4e57b24ffd71c4 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Wed, 2 Sep 2026 08:09:52 +0200 Subject: [PATCH 7/8] test: isolate load-time bundle validation --- .../scenarios/invalid_bundle_placements/BUILD | 9 ------ .../invalid_source_combination/BUILD.negative | 22 +++++++++++++++ .../invalid_source_combination/child/a.rst | 15 ++++++++++ .../test_invalid_bundle_placements.py | 28 +++++++++++++++++-- 4 files changed, 62 insertions(+), 12 deletions(-) create mode 100644 src/tests/docs_bzl/scenarios/invalid_source_combination/BUILD.negative create mode 100644 src/tests/docs_bzl/scenarios/invalid_source_combination/child/a.rst diff --git a/src/tests/docs_bzl/scenarios/invalid_bundle_placements/BUILD b/src/tests/docs_bzl/scenarios/invalid_bundle_placements/BUILD index 3ba02e927..82dc36edf 100644 --- a/src/tests/docs_bzl/scenarios/invalid_bundle_placements/BUILD +++ b/src/tests/docs_bzl/scenarios/invalid_bundle_placements/BUILD @@ -75,12 +75,3 @@ docs_bundle( ], tags = ["manual"], ) - -# The public macro must reject mixing directory-discovered and explicit source -# inputs before creating an ambiguous bundle definition. -docs_bundle( - name = "bad_source_and_srcs", - source_dir = "child", - srcs = ["child/a.rst"], - tags = ["manual"], -) diff --git a/src/tests/docs_bzl/scenarios/invalid_source_combination/BUILD.negative b/src/tests/docs_bzl/scenarios/invalid_source_combination/BUILD.negative new file mode 100644 index 000000000..f55926aee --- /dev/null +++ b/src/tests/docs_bzl/scenarios/invalid_source_combination/BUILD.negative @@ -0,0 +1,22 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +load("//:docs.bzl", "docs_bundle") + +# This BUILD fixture intentionally fails while the docs_bundle() macro is +# evaluated; the test copies it to BUILD only for the expected-error check. +docs_bundle( + name = "bad_source_and_srcs", + source_dir = "child", + srcs = ["child/a.rst"], +) diff --git a/src/tests/docs_bzl/scenarios/invalid_source_combination/child/a.rst b/src/tests/docs_bzl/scenarios/invalid_source_combination/child/a.rst new file mode 100644 index 000000000..63153274e --- /dev/null +++ b/src/tests/docs_bzl/scenarios/invalid_source_combination/child/a.rst @@ -0,0 +1,15 @@ +.. ******************************************************************************* + Copyright (c) 2026 Contributors to the Eclipse Foundation + + See the NOTICE file(s) distributed with this work for additional + information regarding copyright ownership. + + This program and the accompanying materials are made available under the + terms of the Apache License Version 2.0 which is available at + https://www.apache.org/licenses/LICENSE-2.0 + + SPDX-License-Identifier: Apache-2.0 + ******************************************************************************* + +Invalid source combination fixture +=================================== diff --git a/src/tests/docs_bzl/test_invalid_bundle_placements.py b/src/tests/docs_bzl/test_invalid_bundle_placements.py index 8d492bc8a..4af200f89 100644 --- a/src/tests/docs_bzl/test_invalid_bundle_placements.py +++ b/src/tests/docs_bzl/test_invalid_bundle_placements.py @@ -23,7 +23,7 @@ # ******************************************************************************* """Invalid docs_bundle() placement scenario.""" -from src.tests.docs_bzl.helpers import run_scenario +from src.tests.docs_bzl.helpers import repo_root, run_scenario def test_invalid_bundle_placements_are_rejected_during_analysis(): @@ -31,7 +31,29 @@ def test_invalid_bundle_placements_are_rejected_during_analysis(): run_scenario( "build", "invalid_bundle_placements", ":bad_attach_to", expect_error=True ) - result = run_scenario( - "build", "invalid_bundle_placements", ":bad_source_and_srcs", expect_error=True + + +def test_source_dir_and_srcs_are_rejected_during_loading(): + scenario_dir = ( + repo_root() / "src/tests/docs_bzl/scenarios/invalid_source_combination" + ) + build_file = scenario_dir / "BUILD" + fixture = scenario_dir / "BUILD.negative" + # Keep this load-time failure fixture hidden from recursive Bazel targets. + assert not build_file.exists(), ( + "negative test package must not be discovered by //..." ) + + # Install the invalid BUILD file only while the subprocess test exercises it. + build_file.write_text(fixture.read_text(encoding="utf-8"), encoding="utf-8") + try: + result = run_scenario( + "build", + "invalid_source_combination", + ":bad_source_and_srcs", + expect_error=True, + ) + finally: + build_file.unlink() + assert "srcs cannot be combined with source_dir" in result.stderr From 6a16e836ef41555abff3bdf317a7f9606fc27dbc Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Wed, 2 Sep 2026 11:26:05 +0200 Subject: [PATCH 8/8] docs: clarify bundle source path handling --- bzl/bundle_rules.bzl | 23 +++++++++++++++-------- src/extensions/score_mounts/_resolver.py | 6 ++++++ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/bzl/bundle_rules.bzl b/bzl/bundle_rules.bzl index 0628e23e0..737dc62f0 100644 --- a/bzl/bundle_rules.bzl +++ b/bzl/bundle_rules.bzl @@ -135,6 +135,8 @@ def _bundle_runtime_path(ctx): runfiles. Keep that spelling here; ``_bundle_execroot_path`` converts it to the corresponding ``external//...`` form for build actions. """ + # All files were globbed from this bundle's one source_dir, so the first + # file is representative for detecting an external-repository prefix. source_file = ctx.files.source_dir_globbed[0].short_path external_prefix = "" if source_file.startswith("../"): @@ -144,29 +146,33 @@ def _bundle_runtime_path(ctx): def _source_target_path(source_file): """Return the path spelling used by the source-target staging action.""" - # Generated files need their execroot path; workspace files resolve from - # their runfiles short path. + # Bazel marks workspace files with ``is_source``; generated outputs use + # the execroot path while workspace files use their runfiles short path. return source_file.path if not source_file.is_source else source_file.short_path def _source_targets_runtime_path(files): """Return the runtime directory shared by one set of source targets. Source targets are either workspace files or generated outputs. Their - paths use different Bazel spellings, so the kind of the first file is used - consistently for the complete source set. + paths use different Bazel spellings, while one manifest entry can carry + only one runtime root and one generated/source classification. The first + file therefore establishes the canonical representation and every later + file is validated against it. """ first_file = files[0] first_path = _source_target_path(first_file) first_is_source = first_file.is_source separator = first_path.rfind("/") - # A source in the root package has the workspace root as its parent. + # A root-package source has no parent component; ``.`` represents the + # workspace/execroot root so it can still be used as the shared directory. runtime_path = first_path[:separator] if separator >= 0 else "." for source_file in files[1:]: # A single bundle entry cannot combine source roots from the workspace # and bazel-out because they have different runtime resolution rules. - if source_file.is_source != first_is_source: - fail("explicit bundle sources cannot mix workspace and generated files") source_path = _source_target_path(source_file) + if source_file.is_source != first_is_source: + fail(("explicit bundle sources cannot mix workspace and generated files; " + + "found %r and %r") % (first_path, source_path)) source_separator = source_path.rfind("/") # Use the same ``.`` spelling for another root-package source. source_root = source_path[:source_separator] if source_separator >= 0 else "." @@ -300,7 +306,8 @@ def _docs_bundle_impl(ctx): # The macro validates this combination before creating the rule; retain # the rule-level check for callers of the internal helper as well. if ctx.files.source_dir_globbed and ctx.files.source_targets: - fail("a bundle cannot combine source_dir sources with explicit source targets") + fail(("bundle %s cannot combine source_dir sources with explicit source " + + "targets") % ctx.label) if ctx.files.source_dir_globbed: runtime_path = _bundle_runtime_path(ctx) diff --git a/src/extensions/score_mounts/_resolver.py b/src/extensions/score_mounts/_resolver.py index 7b01074e2..7705ddd27 100644 --- a/src/extensions/score_mounts/_resolver.py +++ b/src/extensions/score_mounts/_resolver.py @@ -120,6 +120,12 @@ def resolve_walk_dir( Generated source roots are recorded with their execroot-relative bazel-out path, while ``bazel run`` exposes the same artifacts below ``bazel-bin`` in the workspace. The ``generated`` flag selects that translation. + + For example, a generated ``bazel-out/k8-fastbuild/bin/pkg/docs`` root + resolves to ``/bazel-bin/pkg/docs`` under ``bazel run`` and to + ``/bazel-out/k8-fastbuild/bin/pkg/docs`` in a sandbox. A regular + workspace source resolves to ``/`` under ``bazel run`` + and ``/`` in a sandbox. """ if spec.generated: if ws_root is not None: