From b4d15d2b62be60da716f591f24c4e2e7449a3b36 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Mon, 31 Aug 2026 16:03:34 +0200 Subject: [PATCH 01/10] docs: keep bundle examples self-contained --- BUILD | 6 ----- docs/how-to/bundles/examples.rst | 6 ++--- .../bundles/examples/external_bundle.inc | 10 +++++++ .../bundles/examples/generated_data.inc | 26 +++++++++++++++++++ .../bundles/examples/nested_bundles.inc | 12 +++++++++ src/tests/docs_bzl/test_external_bundle.py | 17 +++++++++++- 6 files changed, 67 insertions(+), 10 deletions(-) create mode 100644 docs/how-to/bundles/examples/external_bundle.inc create mode 100644 docs/how-to/bundles/examples/generated_data.inc create mode 100644 docs/how-to/bundles/examples/nested_bundles.inc diff --git a/BUILD b/BUILD index d28698186..8a002a5ab 100644 --- a/BUILD +++ b/BUILD @@ -25,12 +25,6 @@ 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/extensions/docs:extensions", diff --git a/docs/how-to/bundles/examples.rst b/docs/how-to/bundles/examples.rst index 5dcbe2ff9..ff3c94e2b 100644 --- a/docs/how-to/bundles/examples.rst +++ b/docs/how-to/bundles/examples.rst @@ -30,7 +30,7 @@ consuming project chooses where the assembled bundle appears. The tested parent bundle composes the child like this: -.. literalinclude:: ../../../src/tests/docs_bzl/scenarios/nested_bundles/BUILD +.. literalinclude:: examples/nested_bundles.inc :language: starlark :start-after: BEGIN docs-bundle-howto: parent-composition :end-before: END docs-bundle-howto: parent-composition @@ -50,7 +50,7 @@ Use a data-only bundle when a build action produces the documentation rather than a source-tree ``.rst`` file. It has no ``source_dir``; its generated files are the bundle's complete payload. You can generate and mount a page like this: -.. literalinclude:: ../../../src/tests/docs_bzl/scenarios/data_files_runfiles/BUILD +.. literalinclude:: examples/generated_data.inc :language: starlark :start-after: BEGIN docs-bundle-howto: generated-data :end-before: END docs-bundle-howto: generated-data @@ -70,7 +70,7 @@ Every project using ``docs()`` exposes its own documentation as a ``:docs_bundle`` target. The external-bundle test fixture mounts that target like this: -.. literalinclude:: ../../../src/tests/docs_bzl/scenarios/external_bundle/BUILD +.. literalinclude:: examples/external_bundle.inc :language: starlark :start-after: BEGIN docs-bundle-howto: external-bundle :end-before: END docs-bundle-howto: external-bundle diff --git a/docs/how-to/bundles/examples/external_bundle.inc b/docs/how-to/bundles/examples/external_bundle.inc new file mode 100644 index 000000000..7da59286e --- /dev/null +++ b/docs/how-to/bundles/examples/external_bundle.inc @@ -0,0 +1,10 @@ +# BEGIN docs-bundle-howto: external-bundle +docs( + source_dir = "host_docs", + test_sources = ["src/tests/docs_bzl/scenarios/external_bundle"], + bundles = [{ + "bundle": "@score_process_description//:docs_bundle", + "mount_at": "process", + }], +) +# END docs-bundle-howto: external-bundle diff --git a/docs/how-to/bundles/examples/generated_data.inc b/docs/how-to/bundles/examples/generated_data.inc new file mode 100644 index 000000000..fb1afd4c9 --- /dev/null +++ b/docs/how-to/bundles/examples/generated_data.inc @@ -0,0 +1,26 @@ +# BEGIN docs-bundle-howto: generated-data +genrule( + name = "generated_page", + srcs = [], + outs = ["generated/index.rst"], + cmd = """echo 'Generated Data Page +===================' > $@""", +) + +# Pure-data bundle: the genrule output lives in ``bazel-out/``, not the tree. +docs_bundle( + name = "data_bundle", + data = [":generated_page"], + entry_doc = "index", + visibility = ["//visibility:public"], +) + +docs( + source_dir = "docs", + bundles = [{ + "bundle": ":data_bundle", + "mount_at": "data_test", + "attach_to": "index", + }], +) +# END docs-bundle-howto: generated-data diff --git a/docs/how-to/bundles/examples/nested_bundles.inc b/docs/how-to/bundles/examples/nested_bundles.inc new file mode 100644 index 000000000..4ab3fdf06 --- /dev/null +++ b/docs/how-to/bundles/examples/nested_bundles.inc @@ -0,0 +1,12 @@ +# BEGIN docs-bundle-howto: parent-composition +docs_bundle( + name = "parent", + source_dir = "parent", + bundles = [{ + "bundle": ":child", + "mount_at": "child", + }], + data = [":generated_doc_output"], + visibility = ["//visibility:public"], +) +# END docs-bundle-howto: parent-composition diff --git a/src/tests/docs_bzl/test_external_bundle.py b/src/tests/docs_bzl/test_external_bundle.py index 39f230147..cced5bc6c 100644 --- a/src/tests/docs_bzl/test_external_bundle.py +++ b/src/tests/docs_bzl/test_external_bundle.py @@ -25,7 +25,7 @@ import json -from src.tests.docs_bzl.helpers import run_scenario +from src.tests.docs_bzl.helpers import repo_root, run_scenario def test_external_bundle_builds_in_sandbox_and_at_runtime(): @@ -37,3 +37,18 @@ def test_external_bundle_builds_in_sandbox_and_at_runtime(): (result.build_dir / "compatibility-findings.json").read_text(encoding="utf-8") ) assert report["summary"]["count"] >= 0 + + +def test_public_bundle_examples_use_only_bundle_sources(): + page = repo_root() / "docs/how-to/bundles/examples.rst" + content = page.read_text(encoding="utf-8") + + for relative_path in ( + "examples/nested_bundles.inc", + "examples/generated_data.inc", + "examples/external_bundle.inc", + ): + assert f"literalinclude:: {relative_path}" in content + assert (page.parent / relative_path).is_file() + + assert "../../../src/tests/docs_bzl" not in content From cf2edb70a8507191f377c8779230db078dd85d7f Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Mon, 31 Aug 2026 16:28:46 +0200 Subject: [PATCH 02/10] docs: preserve literalinclude sources in public bundle --- BUILD | 6 ++++ docs.bzl | 30 +++++++++++++------ docs/how-to/bundles/examples.rst | 6 ++-- .../bundles/examples/external_bundle.inc | 10 ------- .../bundles/examples/generated_data.inc | 26 ---------------- .../bundles/examples/nested_bundles.inc | 12 -------- docs/reference/bazel_macros.rst | 27 +++++++++++++---- src/extensions/docs/mounts_internals.rst | 13 ++++++-- src/extensions/score_mounts/__init__.py | 13 ++++++++ .../score_mounts/tests/test_data_mounts.py | 23 ++++++++++++++ src/tests/docs_bzl/test_external_bundle.py | 17 +---------- 11 files changed, 98 insertions(+), 85 deletions(-) delete mode 100644 docs/how-to/bundles/examples/external_bundle.inc delete mode 100644 docs/how-to/bundles/examples/generated_data.inc delete mode 100644 docs/how-to/bundles/examples/nested_bundles.inc diff --git a/BUILD b/BUILD index 8a002a5ab..880d3ba86 100644 --- a/BUILD +++ b/BUILD @@ -25,6 +25,12 @@ docs( external_needs = [ "@score_process_description//:needs_json_file", ], + bundle_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/extensions/docs:extensions", diff --git a/docs.bzl b/docs.bzl index 56eaa297a..fd9a2f7a9 100644 --- a/docs.bzl +++ b/docs.bzl @@ -212,7 +212,8 @@ def docs( test_sources = [], known_good = None, metamodel = None, - bundles = []): + bundles = [], + bundle_data = []): """Creates all targets related to documentation. By using this function, you'll get any and all updates for documentation targets in one place. @@ -227,6 +228,12 @@ def docs( it in a `docs_bundle` and place that bundle through `bundles` instead. Generated documentation and assets are not staged into the workspace source tree for `bazel run`; use a data-only `docs_bundle` for those. + bundle_data: Additional files owned by this project's public `docs_bundle`. + Use this for source files referenced from the project's documentation, + such as test fixtures shown with `literalinclude`. The files remain + the original Bazel sources and are carried with the public bundle; they + are not copied into the documentation tree. Mounts containing such + explicitly declared files relax path confinement for those references. 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 @@ -253,7 +260,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, and ``bundle_data`` for + supporting files referenced by this project's own public bundle. """ # HINT: keep documentation sync docs/reference/bazel_macros.rst @@ -283,14 +291,17 @@ def docs( metamodel_label = [metamodel] if metamodel else [] data_library_label_for_sphinx_docs = [] - if data: + if data or bundle_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 + # mount. ``bundle_data`` is staged here as well because the ``needs`` + # action builds the public source tree in a sandbox before + # ``score_mounts`` can resolve the mounted bundle. + # + # These ``docs(data = [...])`` inputs are intentionally project-level + # instead: they support the project build and are not assigned to a + # bundle mount. Both kinds of data are build inputs; 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. @@ -303,7 +314,7 @@ def docs( # belong in a data-only docs_bundle instead. sphinx_docs_library( name = "_docs_data", - srcs = data, + srcs = data + bundle_data, strip_prefix = "", ) data_library_label_for_sphinx_docs = [":_docs_data"] @@ -348,6 +359,7 @@ def docs( source_dir = source_dir, entry_doc = "index", bundles = bundles, + data = bundle_data, scan_code = scan_code, code_targets = code_targets, visibility = ["//visibility:public"], diff --git a/docs/how-to/bundles/examples.rst b/docs/how-to/bundles/examples.rst index ff3c94e2b..5dcbe2ff9 100644 --- a/docs/how-to/bundles/examples.rst +++ b/docs/how-to/bundles/examples.rst @@ -30,7 +30,7 @@ consuming project chooses where the assembled bundle appears. The tested parent bundle composes the child like this: -.. literalinclude:: examples/nested_bundles.inc +.. literalinclude:: ../../../src/tests/docs_bzl/scenarios/nested_bundles/BUILD :language: starlark :start-after: BEGIN docs-bundle-howto: parent-composition :end-before: END docs-bundle-howto: parent-composition @@ -50,7 +50,7 @@ Use a data-only bundle when a build action produces the documentation rather than a source-tree ``.rst`` file. It has no ``source_dir``; its generated files are the bundle's complete payload. You can generate and mount a page like this: -.. literalinclude:: examples/generated_data.inc +.. literalinclude:: ../../../src/tests/docs_bzl/scenarios/data_files_runfiles/BUILD :language: starlark :start-after: BEGIN docs-bundle-howto: generated-data :end-before: END docs-bundle-howto: generated-data @@ -70,7 +70,7 @@ Every project using ``docs()`` exposes its own documentation as a ``:docs_bundle`` target. The external-bundle test fixture mounts that target like this: -.. literalinclude:: examples/external_bundle.inc +.. literalinclude:: ../../../src/tests/docs_bzl/scenarios/external_bundle/BUILD :language: starlark :start-after: BEGIN docs-bundle-howto: external-bundle :end-before: END docs-bundle-howto: external-bundle diff --git a/docs/how-to/bundles/examples/external_bundle.inc b/docs/how-to/bundles/examples/external_bundle.inc deleted file mode 100644 index 7da59286e..000000000 --- a/docs/how-to/bundles/examples/external_bundle.inc +++ /dev/null @@ -1,10 +0,0 @@ -# BEGIN docs-bundle-howto: external-bundle -docs( - source_dir = "host_docs", - test_sources = ["src/tests/docs_bzl/scenarios/external_bundle"], - bundles = [{ - "bundle": "@score_process_description//:docs_bundle", - "mount_at": "process", - }], -) -# END docs-bundle-howto: external-bundle diff --git a/docs/how-to/bundles/examples/generated_data.inc b/docs/how-to/bundles/examples/generated_data.inc deleted file mode 100644 index fb1afd4c9..000000000 --- a/docs/how-to/bundles/examples/generated_data.inc +++ /dev/null @@ -1,26 +0,0 @@ -# BEGIN docs-bundle-howto: generated-data -genrule( - name = "generated_page", - srcs = [], - outs = ["generated/index.rst"], - cmd = """echo 'Generated Data Page -===================' > $@""", -) - -# Pure-data bundle: the genrule output lives in ``bazel-out/``, not the tree. -docs_bundle( - name = "data_bundle", - data = [":generated_page"], - entry_doc = "index", - visibility = ["//visibility:public"], -) - -docs( - source_dir = "docs", - bundles = [{ - "bundle": ":data_bundle", - "mount_at": "data_test", - "attach_to": "index", - }], -) -# END docs-bundle-howto: generated-data diff --git a/docs/how-to/bundles/examples/nested_bundles.inc b/docs/how-to/bundles/examples/nested_bundles.inc deleted file mode 100644 index 4ab3fdf06..000000000 --- a/docs/how-to/bundles/examples/nested_bundles.inc +++ /dev/null @@ -1,12 +0,0 @@ -# BEGIN docs-bundle-howto: parent-composition -docs_bundle( - name = "parent", - source_dir = "parent", - bundles = [{ - "bundle": ":child", - "mount_at": "child", - }], - data = [":generated_doc_output"], - visibility = ["//visibility:public"], -) -# END docs-bundle-howto: parent-composition diff --git a/docs/reference/bazel_macros.rst b/docs/reference/bazel_macros.rst index d6f2ffc43..a50bf2863 100644 --- a/docs/reference/bazel_macros.rst +++ b/docs/reference/bazel_macros.rst @@ -28,8 +28,7 @@ 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 @@ -40,10 +39,17 @@ trees: 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. +* ``docs(bundle_data = [...])`` puts files in the public ``docs_bundle`` owned + by the project. Use this for source files that the project's own pages + reference, for example test fixtures shown with ``literalinclude``. The + original files are carried as declared bundle inputs; no snapshot is copied + into ``docs/``. Such references are intentionally allowed when the bundle is + mounted. + +If a file belongs to a nested or generated bundle, use +``docs_bundle(data = [...])``. If it belongs to the project's own public +documentation bundle, use ``docs(bundle_data = [...])``. Both make files +available to the sandboxed and runfiles builds. Minimal example (root ``BUILD``) -------------------------------- @@ -94,6 +100,15 @@ Minimal example (root ``BUILD``) To pull in another module's needs for cross-referencing, add its ``:needs_json`` target here. +- ``bundle_data`` (list of bazel labels) + Files owned by the public ``docs_bundle`` generated by this macro. Declare + source files here when a page in ``source_dir`` references them directly, + such as a test's ``BUILD`` file included with ``literalinclude``. They stay + at their original workspace-relative paths and are staged with the bundle; + they are not copied into ``source_dir``. The mount bridge treats these + explicit bundle inputs as intentional external references while retaining + strict path checking for bundles without ``bundle_data``. + - ``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. diff --git a/src/extensions/docs/mounts_internals.rst b/src/extensions/docs/mounts_internals.rst index 2fbf7c3d5..ea7ff996a 100644 --- a/src/extensions/docs/mounts_internals.rst +++ b/src/extensions/docs/mounts_internals.rst @@ -38,12 +38,19 @@ 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 allowed references (for example a test + fixture shown with ``literalinclude``); for a data-only entry they identify + the generated files that form the mount. 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. Source-bearing entries with +explicit ``data`` receive ``path_check = "off"`` because sphinx-mounts 0.1.x +has no per-file allowlist; ordinary entries retain the strict ``"error"`` +setting. Directory resolution -------------------- diff --git a/src/extensions/score_mounts/__init__.py b/src/extensions/score_mounts/__init__.py index b03ca6d38..f6f541ca5 100644 --- a/src/extensions/score_mounts/__init__.py +++ b/src/extensions/score_mounts/__init__.py @@ -77,6 +77,12 @@ 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: if ws_root is not None and runfiles_dir is not None: runfiles_str = str(runfiles_dir) @@ -168,6 +174,13 @@ 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, + # A source bundle may explicitly carry supporting files outside its + # source_dir (for example test BUILD files used by literalinclude). + # Those files are part of the declared bundle payload, so the + # reference is intentional. sphinx-mounts 0.1.x has no per-file + # allowlist; keep the strict default for ordinary bundles and relax it + # only for an entry carrying explicit bundle data. + "path_check": "off" if spec.src_root and spec.data else "error", } diff --git a/src/extensions/score_mounts/tests/test_data_mounts.py b/src/extensions/score_mounts/tests/test_data_mounts.py index a8144e343..9b81bd9b6 100644 --- a/src/extensions/score_mounts/tests/test_data_mounts.py +++ b/src/extensions/score_mounts/tests/test_data_mounts.py @@ -59,6 +59,29 @@ def test_existing_data_file_resolved(tmp_path: Path) -> None: assert str(tmp_path / "bazel-bin") in mounts +def test_source_bundle_data_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"], + ) + 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_mount_entry_uses_canonical_directory_for_symlinked_bundle( tmp_path: Path, ) -> None: diff --git a/src/tests/docs_bzl/test_external_bundle.py b/src/tests/docs_bzl/test_external_bundle.py index cced5bc6c..39f230147 100644 --- a/src/tests/docs_bzl/test_external_bundle.py +++ b/src/tests/docs_bzl/test_external_bundle.py @@ -25,7 +25,7 @@ import json -from src.tests.docs_bzl.helpers import repo_root, run_scenario +from src.tests.docs_bzl.helpers import run_scenario def test_external_bundle_builds_in_sandbox_and_at_runtime(): @@ -37,18 +37,3 @@ def test_external_bundle_builds_in_sandbox_and_at_runtime(): (result.build_dir / "compatibility-findings.json").read_text(encoding="utf-8") ) assert report["summary"]["count"] >= 0 - - -def test_public_bundle_examples_use_only_bundle_sources(): - page = repo_root() / "docs/how-to/bundles/examples.rst" - content = page.read_text(encoding="utf-8") - - for relative_path in ( - "examples/nested_bundles.inc", - "examples/generated_data.inc", - "examples/external_bundle.inc", - ): - assert f"literalinclude:: {relative_path}" in content - assert (page.parent / relative_path).is_file() - - assert "../../../src/tests/docs_bzl" not in content From 8bed3c8df0b199b35e593365a7f219e7f7ab0586 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Mon, 31 Aug 2026 16:36:22 +0200 Subject: [PATCH 03/10] docs: preserve mount path check in generated config --- src/extensions/score_sync_toml/_mounts.py | 2 ++ src/extensions/score_sync_toml/test_mounts.py | 13 +++++++++++++ 2 files changed, 15 insertions(+) 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..4dc0feae8 100644 --- a/src/extensions/score_sync_toml/test_mounts.py +++ b/src/extensions/score_sync_toml/test_mounts.py @@ -48,6 +48,19 @@ 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"\n' + 'mount_at = "guide"\n' + 'path_check = "off"\n' + ) + + def test_materialize_mounts_maps_external_runfiles_path_to_bazel_bin( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 50e0fd7a9bf048884f37b297c73e9e84a0bc66ef Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Mon, 31 Aug 2026 16:40:52 +0200 Subject: [PATCH 04/10] style: format updated tests --- src/extensions/score_sync_toml/test_mounts.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/extensions/score_sync_toml/test_mounts.py b/src/extensions/score_sync_toml/test_mounts.py index 4dc0feae8..4b2b65fd1 100644 --- a/src/extensions/score_sync_toml/test_mounts.py +++ b/src/extensions/score_sync_toml/test_mounts.py @@ -55,9 +55,7 @@ def test_materialize_mounts_preserves_non_default_path_check(): assert fragment is not None assert fragment.read_text(encoding="utf-8") == ( - '[[mounts]]\ndir = "docs"\n' - 'mount_at = "guide"\n' - 'path_check = "off"\n' + '[[mounts]]\ndir = "docs"\nmount_at = "guide"\npath_check = "off"\n' ) From 0d7d245d10c0d3063a12c9dada192848f116224b Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Mon, 31 Aug 2026 17:46:11 +0200 Subject: [PATCH 05/10] fix: carry literalinclude data in child bundles --- BUILD | 26 ++++-- bzl/bundle_rules.bzl | 39 +++++++- bzl/mount_rules.bzl | 1 + docs.bzl | 36 +++----- docs/reference/bazel_macros.rst | 41 ++++----- src/extensions/docs/mounts_internals.rst | 13 +-- src/extensions/score_mounts/__init__.py | 13 ++- src/extensions/score_mounts/_resolver.py | 2 + .../score_mounts/tests/test_data_mounts.py | 26 +++++- .../score_mounts/tests/test_resolver.py | 2 + .../scenarios/bundle_data_provider/BUILD | 37 ++++++++ .../bundle_data_provider/docs/index.rst | 21 +++++ .../test_external_consumer_bundle_data.py | 90 +++++++++++++++++++ 13 files changed, 280 insertions(+), 67 deletions(-) create mode 100644 src/tests/docs_bzl/scenarios/bundle_data_provider/BUILD create mode 100644 src/tests/docs_bzl/scenarios/bundle_data_provider/docs/index.rst create mode 100644 src/tests/docs_bzl/test_external_consumer_bundle_data.py diff --git a/BUILD b/BUILD index 880d3ba86..47ed9d551 100644 --- a/BUILD +++ b/BUILD @@ -13,25 +13,39 @@ load("//:docs.bzl", "docs") +load("//:docs.bzl", "docs_bundle") + package(default_visibility = ["//visibility:public"]) exports_files([ "default_conf.py.tpl", "pyproject.toml", ]) +_literalinclude_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", +] + +docs_bundle( + name = "docs_literalinclude_data", + data = _literalinclude_data, + visibility = ["//visibility:public"], +) + docs( project = "S-CORE Docs-as-Code", project_url = "https://eclipse-score.github.io/docs-as-code", external_needs = [ "@score_process_description//:needs_json_file", ], - bundle_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", - ], + data = _literalinclude_data, bundles = [ + { + "bundle": ":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..938ff7f3d 100644 --- a/bzl/bundle_rules.bzl +++ b/bzl/bundle_rules.bzl @@ -198,8 +198,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 +266,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 +283,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 +302,7 @@ def _docs_bundle_impl(ctx): external = False, repository = ctx.label.workspace_name, data = own_data, + path_check = "error", )) child_source_files = [] @@ -286,13 +312,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) 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 fd9a2f7a9..e7ad1fd9d 100644 --- a/docs.bzl +++ b/docs.bzl @@ -212,8 +212,7 @@ def docs( test_sources = [], known_good = None, metamodel = None, - bundles = [], - bundle_data = []): + bundles = []): """Creates all targets related to documentation. By using this function, you'll get any and all updates for documentation targets in one place. @@ -228,12 +227,6 @@ def docs( it in a `docs_bundle` and place that bundle through `bundles` instead. Generated documentation and assets are not staged into the workspace source tree for `bazel run`; use a data-only `docs_bundle` for those. - bundle_data: Additional files owned by this project's public `docs_bundle`. - Use this for source files referenced from the project's documentation, - such as test fixtures shown with `literalinclude`. The files remain - the original Bazel sources and are carried with the public bundle; they - are not copied into the documentation tree. Mounts containing such - explicitly declared files relax path confinement for those references. 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 @@ -260,8 +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, and ``bundle_data`` for - supporting files referenced by this project's own public bundle. + 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 @@ -291,17 +284,17 @@ def docs( metamodel_label = [metamodel] if metamodel else [] data_library_label_for_sphinx_docs = [] - if data or bundle_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. ``bundle_data`` is staged here as well because the ``needs`` - # action builds the public source tree in a sandbox before - # ``score_mounts`` can resolve the mounted bundle. + if data: + # ``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. # - # These ``docs(data = [...])`` inputs are intentionally project-level - # instead: they support the project build and are not assigned to a - # bundle mount. Both kinds of data are build inputs; without the staging - # below, project-level inputs would + # Files that travel with a public bundle are declared on a data-only + # ``docs_bundle`` and composed through ``bundles``. The bundle keeps + # its data associated with that child entry. + # + # 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. @@ -314,7 +307,7 @@ def docs( # belong in a data-only docs_bundle instead. sphinx_docs_library( name = "_docs_data", - srcs = data + bundle_data, + srcs = data, strip_prefix = "", ) data_library_label_for_sphinx_docs = [":_docs_data"] @@ -359,7 +352,6 @@ def docs( source_dir = source_dir, entry_doc = "index", bundles = bundles, - data = bundle_data, scan_code = scan_code, code_targets = code_targets, visibility = ["//visibility:public"], diff --git a/docs/reference/bazel_macros.rst b/docs/reference/bazel_macros.rst index a50bf2863..de7d954c0 100644 --- a/docs/reference/bazel_macros.rst +++ b/docs/reference/bazel_macros.rst @@ -35,21 +35,19 @@ The macros expose project inputs and bundle payloads separately: ``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 = [...])``. -* ``docs(bundle_data = [...])`` puts files in the public ``docs_bundle`` owned - by the project. Use this for source files that the project's own pages - reference, for example test fixtures shown with ``literalinclude``. The - original files are carried as declared bundle inputs; no snapshot is copied - into ``docs/``. Such references are intentionally allowed when the bundle is - mounted. + outside any bundle. These files have no bundle mount path. Keep a label here + when the project's own sandboxed build needs it, for example to resolve a + ``literalinclude``. +* 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. 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 = [...])``. If it belongs to the project's own public -documentation bundle, use ``docs(bundle_data = [...])``. Both make files -available to the sandboxed and runfiles builds. +``docs_bundle(data = [...])`` and compose that bundle through ``bundles``. +If the project's own pages reference the files directly, also list the labels +in ``docs(data = [...])`` so the sandboxed ``needs_json`` build can resolve the +original paths. Minimal example (root ``BUILD``) -------------------------------- @@ -100,18 +98,12 @@ Minimal example (root ``BUILD``) To pull in another module's needs for cross-referencing, add its ``:needs_json`` target here. -- ``bundle_data`` (list of bazel labels) - Files owned by the public ``docs_bundle`` generated by this macro. Declare - source files here when a page in ``source_dir`` references them directly, - such as a test's ``BUILD`` file included with ``literalinclude``. They stay - at their original workspace-relative paths and are staged with the bundle; - they are not copied into ``source_dir``. The mount bridge treats these - explicit bundle inputs as intentional external references while retaining - strict path checking for bundles without ``bundle_data``. - - ``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 @@ -202,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``; keep the same label in + ``docs(data = [...])`` when the host's own sandboxed build needs it. - ``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 ea7ff996a..638d8cae3 100644 --- a/src/extensions/docs/mounts_internals.rst +++ b/src/extensions/docs/mounts_internals.rst @@ -39,18 +39,21 @@ Each manifest entry contains: * ``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 allowed references (for example a test + 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. Source-bearing entries with -explicit ``data`` receive ``path_check = "off"`` because sphinx-mounts 0.1.x -has no per-file allowlist; ordinary entries retain the strict ``"error"`` -setting. +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 f6f541ca5..2a85a6d1f 100644 --- a/src/extensions/score_mounts/__init__.py +++ b/src/extensions/score_mounts/__init__.py @@ -84,6 +84,11 @@ def _resolve_data_mounts( 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: @@ -174,13 +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, - # A source bundle may explicitly carry supporting files outside its - # source_dir (for example test BUILD files used by literalinclude). - # Those files are part of the declared bundle payload, so the - # reference is intentional. sphinx-mounts 0.1.x has no per-file - # allowlist; keep the strict default for ordinary bundles and relax it - # only for an entry carrying explicit bundle data. - "path_check": "off" if spec.src_root and spec.data else "error", + "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 9b81bd9b6..0d99c9d07 100644 --- a/src/extensions/score_mounts/tests/test_data_mounts.py +++ b/src/extensions/score_mounts/tests/test_data_mounts.py @@ -59,7 +59,7 @@ def test_existing_data_file_resolved(tmp_path: Path) -> None: assert str(tmp_path / "bazel-bin") in mounts -def test_source_bundle_data_is_not_mounted_as_a_second_source_tree( +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.""" @@ -75,6 +75,7 @@ def test_source_bundle_data_is_not_mounted_as_a_second_source_tree( runtime_path="docs", mount_at="docs", data=["src/tests/scenario/BUILD"], + path_check="off", ) manifest = MountsManifest(mounts=[spec]) @@ -82,6 +83,29 @@ def test_source_bundle_data_is_not_mounted_as_a_second_source_tree( 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/tests/docs_bzl/scenarios/bundle_data_provider/BUILD b/src/tests/docs_bzl/scenarios/bundle_data_provider/BUILD new file mode 100644 index 000000000..99f70228b --- /dev/null +++ b/src/tests/docs_bzl/scenarios/bundle_data_provider/BUILD @@ -0,0 +1,37 @@ +# ******************************************************************************* +# 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", + data = [":fixture_build"], + 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..d6a550464 --- /dev/null +++ b/src/tests/docs_bzl/test_external_consumer_bundle_data.py @@ -0,0 +1,90 @@ +# ******************************************************************************* +# 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 + + +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") From d76c212e417636d387fdb5b27ae190c3a9823f60 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Mon, 31 Aug 2026 17:49:23 +0200 Subject: [PATCH 06/10] style: format external consumer test --- src/tests/docs_bzl/test_external_consumer_bundle_data.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/tests/docs_bzl/test_external_consumer_bundle_data.py b/src/tests/docs_bzl/test_external_consumer_bundle_data.py index d6a550464..c7ca81f6e 100644 --- a/src/tests/docs_bzl/test_external_consumer_bundle_data.py +++ b/src/tests/docs_bzl/test_external_consumer_bundle_data.py @@ -62,7 +62,9 @@ def _write_consumer(workspace: Path, source_root: Path) -> None: ) -def test_external_consumer_can_render_data_backed_literalincludes(tmp_path: Path) -> None: +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) From d3bc648c2795d57cb9cd8d2d376edaf5c835a89e Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Mon, 31 Aug 2026 19:58:42 +0200 Subject: [PATCH 07/10] fix: stage nested bundle payloads once --- BUILD | 5 +-- bzl/bundle_rules.bzl | 30 ++++++++++++++++++ docs.bzl | 31 +++++++++++++++---- docs/reference/bazel_macros.rst | 18 +++++------ .../scenarios/bundle_data_provider/BUILD | 1 - .../test_external_consumer_bundle_data.py | 7 ++++- 6 files changed, 71 insertions(+), 21 deletions(-) diff --git a/BUILD b/BUILD index 47ed9d551..abce68a51 100644 --- a/BUILD +++ b/BUILD @@ -11,9 +11,7 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -load("//:docs.bzl", "docs") - -load("//:docs.bzl", "docs_bundle") +load("//:docs.bzl", "docs", "docs_bundle") package(default_visibility = ["//visibility:public"]) exports_files([ @@ -40,7 +38,6 @@ docs( external_needs = [ "@score_process_description//:needs_json_file", ], - data = _literalinclude_data, bundles = [ { "bundle": ":docs_literalinclude_data", diff --git a/bzl/bundle_rules.bzl b/bzl/bundle_rules.bzl index 938ff7f3d..90ef06b12 100644 --- a/bzl/bundle_rules.bzl +++ b/bzl/bundle_rules.bzl @@ -411,6 +411,36 @@ 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 _bundle_supporting_files_impl(ctx): + """Expose non-document bundle data for a sandboxed Sphinx source tree.""" + bundle = ctx.attr.bundle[DocsBundleInfo] + return [DefaultInfo(files = depset(direct = [ + file + for file in bundle.data.to_list() + if not _is_sphinx_source_file(file) + ]))] + +_bundle_supporting_files = rule( + implementation = _bundle_supporting_files_impl, + attrs = { + "bundle": attr.label(providers = [DocsBundleInfo]), + }, + doc = "Exposes non-document data from a complete docs bundle.", +) + +def bundle_supporting_files(name, bundle, visibility = None): + """Create a target that stages bundle payloads without duplicate documents.""" + _bundle_supporting_files( + name = name, + bundle = bundle, + visibility = visibility, + ) + return ":" + name + def _external_docs_runfiles_impl(ctx): """Expose external documentation sources needed under ``bazel run``.""" bundle = ctx.attr.bundle[DocsBundleInfo] diff --git a/docs.bzl b/docs.bzl index e7ad1fd9d..c3d76e72b 100644 --- a/docs.bzl +++ b/docs.bzl @@ -51,6 +51,7 @@ load( load( "@score_docs_as_code//:bzl/bundle_rules.bzl", "bundle_source_files", + "bundle_supporting_files", "create_bundle", "external_docs_runfiles", "generate_code_target_sourcelinks", @@ -290,10 +291,6 @@ def docs( # Sphinx source root for the sandboxed ``needs`` action; without this, # standard literalinclude resolution cannot find them. # - # Files that travel with a public bundle are declared on a data-only - # ``docs_bundle`` and composed through ``bundles``. The bundle keeps - # its data associated with that child entry. - # # 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`` @@ -308,10 +305,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_supporting_data_library_label_for_sphinx_docs = [] mounts_manifest_label = [] if bundles: mounts_bundle = create_bundle( @@ -320,6 +320,22 @@ def docs( visibility = ["//visibility:private"], ) + bundle_supporting_data = bundle_supporting_files( + name = "_docs_bundle_supporting_data", + bundle = mounts_bundle, + visibility = ["//visibility:private"], + ) + sphinx_docs_library( + name = "_docs_bundle_supporting_data_sources", + srcs = [bundle_supporting_data], + # Keep the original workspace-relative locations so a source page + # can use its literalinclude path without a second data declaration. + strip_prefix = "/", + ) + bundle_supporting_data_library_label_for_sphinx_docs = [ + ":_docs_bundle_supporting_data_sources", + ] + mounts_manifest_label = [ create_mounts_manifest( name = "_mounts_manifest", @@ -476,7 +492,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_supporting_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 de7d954c0..d77ad76f5 100644 --- a/docs/reference/bazel_macros.rst +++ b/docs/reference/bazel_macros.rst @@ -35,19 +35,18 @@ The macros expose project inputs and bundle payloads separately: ``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. Keep a label here - when the project's own sandboxed build needs it, for example to resolve a - ``literalinclude``. + 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. This keeps the original files in the - bundle payload; no snapshot is copied into ``docs/``. + 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``. -If the project's own pages reference the files directly, also list the labels -in ``docs(data = [...])`` so the sandboxed ``needs_json`` build can resolve the -original paths. Minimal example (root ``BUILD``) -------------------------------- @@ -91,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:: diff --git a/src/tests/docs_bzl/scenarios/bundle_data_provider/BUILD b/src/tests/docs_bzl/scenarios/bundle_data_provider/BUILD index 99f70228b..8ffb6f652 100644 --- a/src/tests/docs_bzl/scenarios/bundle_data_provider/BUILD +++ b/src/tests/docs_bzl/scenarios/bundle_data_provider/BUILD @@ -29,7 +29,6 @@ docs( source_dir = "docs", project = "Bundle Data Provider", project_url = "https://example.invalid/bundle-data-provider", - data = [":fixture_build"], bundles = [{ "bundle": ":literalinclude_data", "mount_at": "_literalinclude_data", diff --git a/src/tests/docs_bzl/test_external_consumer_bundle_data.py b/src/tests/docs_bzl/test_external_consumer_bundle_data.py index c7ca81f6e..b8045b08d 100644 --- a/src/tests/docs_bzl/test_external_consumer_bundle_data.py +++ b/src/tests/docs_bzl/test_external_consumer_bundle_data.py @@ -15,7 +15,7 @@ import subprocess from pathlib import Path -from src.tests.docs_bzl.helpers import repo_root +from src.tests.docs_bzl.helpers import repo_root, run_scenario def _write_consumer(workspace: Path, source_root: Path) -> None: @@ -90,3 +90,8 @@ def test_external_consumer_can_render_data_backed_literalincludes( 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_supporting_files() -> None: + """A nested data bundle supplies the original BUILD path to Sphinx.""" + run_scenario("build", "bundle_data_provider", ":needs_json") From f9abe40d0eb6f452cacdce154750b6f4f8e138e8 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Mon, 31 Aug 2026 20:21:49 +0200 Subject: [PATCH 08/10] refactor: keep literalinclude fixtures with scenarios --- BUILD | 17 ++--------------- src/tests/docs_bzl/scenarios/BUILD | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 15 deletions(-) create mode 100644 src/tests/docs_bzl/scenarios/BUILD diff --git a/BUILD b/BUILD index abce68a51..c2483286c 100644 --- a/BUILD +++ b/BUILD @@ -11,7 +11,7 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -load("//:docs.bzl", "docs", "docs_bundle") +load("//:docs.bzl", "docs") package(default_visibility = ["//visibility:public"]) exports_files([ @@ -19,19 +19,6 @@ exports_files([ "pyproject.toml", ]) -_literalinclude_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", -] - -docs_bundle( - name = "docs_literalinclude_data", - data = _literalinclude_data, - visibility = ["//visibility:public"], -) - docs( project = "S-CORE Docs-as-Code", project_url = "https://eclipse-score.github.io/docs-as-code", @@ -40,7 +27,7 @@ docs( ], bundles = [ { - "bundle": ":docs_literalinclude_data", + "bundle": "//src/tests/docs_bzl/scenarios:docs_literalinclude_data", "mount_at": "_literalinclude_data", }, { 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"], +) From a354c5dca7edcc127d77925a5b10cb06a1cf2e2a Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Mon, 31 Aug 2026 20:36:19 +0200 Subject: [PATCH 09/10] refactor: expose bundle data as sphinx library --- bzl/bundle_rules.bzl | 50 ++++++++----------- docs.bzl | 21 ++------ .../test_external_consumer_bundle_data.py | 2 +- 3 files changed, 25 insertions(+), 48 deletions(-) diff --git a/bzl/bundle_rules.bzl b/bzl/bundle_rules.bzl index 90ef06b12..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 @@ -346,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( @@ -355,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( @@ -415,32 +433,6 @@ 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 _bundle_supporting_files_impl(ctx): - """Expose non-document bundle data for a sandboxed Sphinx source tree.""" - bundle = ctx.attr.bundle[DocsBundleInfo] - return [DefaultInfo(files = depset(direct = [ - file - for file in bundle.data.to_list() - if not _is_sphinx_source_file(file) - ]))] - -_bundle_supporting_files = rule( - implementation = _bundle_supporting_files_impl, - attrs = { - "bundle": attr.label(providers = [DocsBundleInfo]), - }, - doc = "Exposes non-document data from a complete docs bundle.", -) - -def bundle_supporting_files(name, bundle, visibility = None): - """Create a target that stages bundle payloads without duplicate documents.""" - _bundle_supporting_files( - name = name, - bundle = bundle, - visibility = visibility, - ) - return ":" + name - def _external_docs_runfiles_impl(ctx): """Expose external documentation sources needed under ``bazel run``.""" bundle = ctx.attr.bundle[DocsBundleInfo] diff --git a/docs.bzl b/docs.bzl index c3d76e72b..30c78c4f2 100644 --- a/docs.bzl +++ b/docs.bzl @@ -51,7 +51,6 @@ load( load( "@score_docs_as_code//:bzl/bundle_rules.bzl", "bundle_source_files", - "bundle_supporting_files", "create_bundle", "external_docs_runfiles", "generate_code_target_sourcelinks", @@ -311,7 +310,7 @@ def docs( ) data_library_label_for_sphinx_docs = [":_docs_data"] - bundle_supporting_data_library_label_for_sphinx_docs = [] + bundle_data_library_label_for_sphinx_docs = [] mounts_manifest_label = [] if bundles: mounts_bundle = create_bundle( @@ -320,21 +319,7 @@ def docs( visibility = ["//visibility:private"], ) - bundle_supporting_data = bundle_supporting_files( - name = "_docs_bundle_supporting_data", - bundle = mounts_bundle, - visibility = ["//visibility:private"], - ) - sphinx_docs_library( - name = "_docs_bundle_supporting_data_sources", - srcs = [bundle_supporting_data], - # Keep the original workspace-relative locations so a source page - # can use its literalinclude path without a second data declaration. - strip_prefix = "/", - ) - bundle_supporting_data_library_label_for_sphinx_docs = [ - ":_docs_bundle_supporting_data_sources", - ] + bundle_data_library_label_for_sphinx_docs = [mounts_bundle] mounts_manifest_label = [ create_mounts_manifest( @@ -494,7 +479,7 @@ def docs( srcs = [sphinx_sources], deps = ( data_library_label_for_sphinx_docs + - bundle_supporting_data_library_label_for_sphinx_docs + bundle_data_library_label_for_sphinx_docs ), config = sphinx_config, extra_opts = [ diff --git a/src/tests/docs_bzl/test_external_consumer_bundle_data.py b/src/tests/docs_bzl/test_external_consumer_bundle_data.py index b8045b08d..cd889512d 100644 --- a/src/tests/docs_bzl/test_external_consumer_bundle_data.py +++ b/src/tests/docs_bzl/test_external_consumer_bundle_data.py @@ -92,6 +92,6 @@ def test_external_consumer_can_render_data_backed_literalincludes( assert "filegroup" in provider_page.read_text(encoding="utf-8") -def test_sandboxed_needs_build_stages_nested_bundle_supporting_files() -> None: +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") From 102ef459f1340ac6ab8399fa50dc8e8cfaf0868d Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Mon, 31 Aug 2026 20:44:24 +0200 Subject: [PATCH 10/10] docs: clarify data-only bundle staging --- docs/reference/bazel_macros.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/bazel_macros.rst b/docs/reference/bazel_macros.rst index d77ad76f5..bcf26a878 100644 --- a/docs/reference/bazel_macros.rst +++ b/docs/reference/bazel_macros.rst @@ -196,8 +196,8 @@ Signature: ``docs_bundle(name, source_dir = None, data = [], entry_doc = "index" for any file that belongs with the mounted bundle. Use ``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``; keep the same label in - ``docs(data = [...])`` when the host's own sandboxed build needs it. + 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