diff --git a/BUILD b/BUILD index d28698186..c2483286c 100644 --- a/BUILD +++ b/BUILD @@ -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", diff --git a/bzl/bundle_rules.bzl b/bzl/bundle_rules.bzl index ab59f354c..de0951eab 100644 --- a/bzl/bundle_rules.bzl +++ b/bzl/bundle_rules.bzl @@ -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 @@ -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 @@ -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 @@ -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 @@ -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. @@ -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 = [] @@ -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) @@ -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( @@ -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( @@ -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] diff --git a/bzl/mount_rules.bzl b/bzl/mount_rules.bzl index dd42571eb..2b5fcc28f 100644 --- a/bzl/mount_rules.bzl +++ b/bzl/mount_rules.bzl @@ -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") diff --git a/docs.bzl b/docs.bzl index 56eaa297a..30c78c4f2 100644 --- a/docs.bzl +++ b/docs.bzl @@ -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 @@ -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. @@ -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( @@ -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", @@ -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", diff --git a/docs/reference/bazel_macros.rst b/docs/reference/bazel_macros.rst index d6f2ffc43..bcf26a878 100644 --- a/docs/reference/bazel_macros.rst +++ b/docs/reference/bazel_macros.rst @@ -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``) -------------------------------- @@ -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:: @@ -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 @@ -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 diff --git a/src/extensions/docs/mounts_internals.rst b/src/extensions/docs/mounts_internals.rst index 2fbf7c3d5..638d8cae3 100644 --- a/src/extensions/docs/mounts_internals.rst +++ b/src/extensions/docs/mounts_internals.rst @@ -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 -------------------- diff --git a/src/extensions/score_mounts/__init__.py b/src/extensions/score_mounts/__init__.py index b03ca6d38..2a85a6d1f 100644 --- a/src/extensions/score_mounts/__init__.py +++ b/src/extensions/score_mounts/__init__.py @@ -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: @@ -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, } diff --git a/src/extensions/score_mounts/_resolver.py b/src/extensions/score_mounts/_resolver.py index e820d8bac..f925cc05b 100644 --- a/src/extensions/score_mounts/_resolver.py +++ b/src/extensions/score_mounts/_resolver.py @@ -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) @@ -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) diff --git a/src/extensions/score_mounts/tests/test_data_mounts.py b/src/extensions/score_mounts/tests/test_data_mounts.py index a8144e343..0d99c9d07 100644 --- a/src/extensions/score_mounts/tests/test_data_mounts.py +++ b/src/extensions/score_mounts/tests/test_data_mounts.py @@ -59,6 +59,53 @@ def test_existing_data_file_resolved(tmp_path: Path) -> None: assert str(tmp_path / "bazel-bin") in mounts +def test_source_supporting_file_is_not_mounted_as_a_second_source_tree( + tmp_path: Path, +) -> None: + """Supporting files on a source bundle do not create a duplicate mount.""" + source_dir = tmp_path / "docs" + source_dir.mkdir() + (source_dir / "index.rst").write_text("Index", encoding="utf-8") + fixture = tmp_path / "src" / "tests" / "scenario" / "BUILD" + fixture.parent.mkdir(parents=True) + fixture.write_text("filegroup(name = 'fixture')", encoding="utf-8") + + spec = MountSpec( + src_root="docs", + runtime_path="docs", + mount_at="docs", + data=["src/tests/scenario/BUILD"], + path_check="off", + ) + manifest = MountsManifest(mounts=[spec]) + + assert _resolve_data_mounts(manifest, tmp_path, tmp_path / "runfiles") == {} + assert _make_mount_entry(source_dir, spec)["path_check"] == "off" + + +def test_non_document_payload_does_not_mount_its_parent_directory( + tmp_path: Path, +) -> None: + """A data-only BUILD payload must not turn neighboring docs into mounts.""" + data_file = tmp_path / "bazel-bin" / "scenario" / "BUILD" + data_file.parent.mkdir(parents=True) + data_file.write_text("filegroup(name = 'fixture')", encoding="utf-8") + (data_file.parent / "index.rst").write_text("not a bundle page", encoding="utf-8") + + manifest = MountsManifest( + mounts=[ + MountSpec( + src_root="", + runtime_path="", + mount_at="data", + data=["bazel-out/k8-fastbuild/bin/scenario/BUILD"], + ) + ] + ) + + assert _resolve_data_mounts(manifest, tmp_path, tmp_path / "runfiles") == {} + + def test_mount_entry_uses_canonical_directory_for_symlinked_bundle( tmp_path: Path, ) -> None: diff --git a/src/extensions/score_mounts/tests/test_resolver.py b/src/extensions/score_mounts/tests/test_resolver.py index b9fe60e5b..64e40ab63 100644 --- a/src/extensions/score_mounts/tests/test_resolver.py +++ b/src/extensions/score_mounts/tests/test_resolver.py @@ -71,6 +71,7 @@ def test_load_entry_with_attach_to_and_entry_doc(tmp_path: Path) -> None: "mount_at": "x", "attach_to": "internals/index", "entry_doc": "start", + "path_check": "off", } ], }, @@ -78,6 +79,7 @@ def test_load_entry_with_attach_to_and_entry_doc(tmp_path: Path) -> None: spec = load_mounts_manifest(str(manifest)).mounts[0] assert spec.attach_to == "internals/index" assert spec.entry_doc == "start" + assert spec.path_check == "off" def test_external_mount_keeps_execroot_and_runfiles_locations(tmp_path: Path) -> None: diff --git a/src/extensions/score_sync_toml/_mounts.py b/src/extensions/score_sync_toml/_mounts.py index d84cf3412..65a809be5 100644 --- a/src/extensions/score_sync_toml/_mounts.py +++ b/src/extensions/score_sync_toml/_mounts.py @@ -56,6 +56,8 @@ def materialize_mounts(entries: list[dict[str, Any]]) -> Path | None: lines.append(f"attach_to = {_toml_string(entry['attach_to'])}") if entry.get("entry_doc", "index") != "index": lines.append(f"entry_doc = {_toml_string(entry['entry_doc'])}") + if entry.get("path_check", "error") != "error": + lines.append(f"path_check = {_toml_string(entry['path_check'])}") lines.append("") outdir = Path(tempfile.mkdtemp(prefix="score_sync_toml_")) fragment = outdir / "score_mounts.toml" diff --git a/src/extensions/score_sync_toml/test_mounts.py b/src/extensions/score_sync_toml/test_mounts.py index 52a299af6..4b2b65fd1 100644 --- a/src/extensions/score_sync_toml/test_mounts.py +++ b/src/extensions/score_sync_toml/test_mounts.py @@ -48,6 +48,17 @@ def test_materialize_mounts_omits_default_fields(): ) +def test_materialize_mounts_preserves_non_default_path_check(): + fragment = materialize_mounts( + [{"dir": "docs", "mount_at": "guide", "path_check": "off"}] + ) + + assert fragment is not None + assert fragment.read_text(encoding="utf-8") == ( + '[[mounts]]\ndir = "docs"\nmount_at = "guide"\npath_check = "off"\n' + ) + + def test_materialize_mounts_maps_external_runfiles_path_to_bazel_bin( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/src/tests/docs_bzl/scenarios/BUILD b/src/tests/docs_bzl/scenarios/BUILD new file mode 100644 index 000000000..74645a286 --- /dev/null +++ b/src/tests/docs_bzl/scenarios/BUILD @@ -0,0 +1,26 @@ +# ******************************************************************************* +# 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") + +# These scenario BUILD files are used by literalinclude examples in the root +# documentation. Keep the payload with the scenarios that own the fixtures. +docs_bundle( + name = "docs_literalinclude_data", + data = [ + "//src/tests/docs_bzl/scenarios/data_files_runfiles:generated_data_build", + "//src/tests/docs_bzl/scenarios/external_bundle:external_bundle_build", + "//src/tests/docs_bzl/scenarios/nested_bundles:nested_bundle_build", + ], + visibility = ["//visibility:public"], +) diff --git a/src/tests/docs_bzl/scenarios/bundle_data_provider/BUILD b/src/tests/docs_bzl/scenarios/bundle_data_provider/BUILD new file mode 100644 index 000000000..8ffb6f652 --- /dev/null +++ b/src/tests/docs_bzl/scenarios/bundle_data_provider/BUILD @@ -0,0 +1,36 @@ +# ******************************************************************************* +# 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", "docs_bundle") + +filegroup( + name = "fixture_build", + srcs = ["BUILD"], + visibility = ["//visibility:public"], +) + +docs_bundle( + name = "literalinclude_data", + data = [":fixture_build"], + visibility = ["//visibility:public"], +) + +docs( + source_dir = "docs", + project = "Bundle Data Provider", + project_url = "https://example.invalid/bundle-data-provider", + bundles = [{ + "bundle": ":literalinclude_data", + "mount_at": "_literalinclude_data", + }], +) diff --git a/src/tests/docs_bzl/scenarios/bundle_data_provider/docs/index.rst b/src/tests/docs_bzl/scenarios/bundle_data_provider/docs/index.rst new file mode 100644 index 000000000..db961a335 --- /dev/null +++ b/src/tests/docs_bzl/scenarios/bundle_data_provider/docs/index.rst @@ -0,0 +1,21 @@ +.. + # ******************************************************************************* + # 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 + # ******************************************************************************* + +Bundle Data Provider +==================== + +The provider deliberately documents its BUILD file as-is: + +.. literalinclude:: ../BUILD + :language: starlark diff --git a/src/tests/docs_bzl/test_external_consumer_bundle_data.py b/src/tests/docs_bzl/test_external_consumer_bundle_data.py new file mode 100644 index 000000000..cd889512d --- /dev/null +++ b/src/tests/docs_bzl/test_external_consumer_bundle_data.py @@ -0,0 +1,97 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Black-box coverage for an external consumer mounting a data-backed bundle.""" + +import subprocess +from pathlib import Path + +from src.tests.docs_bzl.helpers import repo_root, run_scenario + + +def _write_consumer(workspace: Path, source_root: Path) -> None: + """Create a minimal Bzlmod consumer for the public provider bundle.""" + subprocess.run(["git", "init", "--quiet", str(workspace)], check=True) + (workspace / ".bazelversion").write_text( + (source_root / ".bazelversion").read_text(encoding="utf-8"), + encoding="utf-8", + ) + (workspace / "MODULE.bazel").write_text( + f'''module(name = "literalinclude_consumer") +bazel_dep(name = "rules_python", version = "1.8.5") +python = use_extension("@rules_python//python/extensions:python.bzl", "python") +python.toolchain(is_default = True, python_version = "3.12") +bazel_dep(name = "score_docs_as_code", version = "8.1.0") +local_path_override(module_name = "score_docs_as_code", path = "{source_root}") +''', + encoding="utf-8", + ) + (workspace / "BUILD").write_text( + """load("@score_docs_as_code//:docs.bzl", "docs") + +docs( + source_dir = "docs", + project = "Literalinclude Consumer", + project_url = "https://example.invalid/literalinclude-consumer", + bundles = [{ + "bundle": "@score_docs_as_code//src/tests/docs_bzl/scenarios/bundle_data_provider:docs_bundle", + "mount_at": "provider", + }], +) +""", + encoding="utf-8", + ) + docs = workspace / "docs" + docs.mkdir() + (docs / "index.rst").write_text( + """Literalinclude Consumer +======================== + +The provider bundle is mounted below this page. +""", + encoding="utf-8", + ) + + +def test_external_consumer_can_render_data_backed_literalincludes( + tmp_path: Path, +) -> None: + """A Bzlmod consumer can render a provider's original literalinclude path.""" + source_root = repo_root() + _write_consumer(tmp_path, source_root) + + result = subprocess.run( + [ + "bazel", + "--batch", + f"--output_user_root={tmp_path / '.bazel_output'}", + f"--bazelrc={source_root / '.bazelrc'}", + "run", + # local_path_override cannot run the source-link helper in a + # sandbox in this consumer setup; keep the Sphinx action sandboxed. + "--strategy=MergeBundleSourcelinks=local", + "//:docs", + ], + cwd=tmp_path, + text=True, + capture_output=True, + ) + + assert result.returncode == 0, result.stdout + result.stderr + provider_page = tmp_path / "_build" / "provider" / "index.html" + assert provider_page.is_file() + assert "filegroup" in provider_page.read_text(encoding="utf-8") + + +def test_sandboxed_needs_build_stages_nested_bundle_non_document_data() -> None: + """A nested data bundle supplies the original BUILD path to Sphinx.""" + run_scenario("build", "bundle_data_provider", ":needs_json")