diff --git a/BUILD b/BUILD index 38137587b..906aed427 100644 --- a/BUILD +++ b/BUILD @@ -33,6 +33,11 @@ docs( "bundle": "//src/extensions/score_mounts/docs:internals", "mount_at": "internals/extensions/mounts", }, + { + "bundle": "//src/extensions/score_metamodel/docs:metamodel", + "mount_at": "reference/metamodel", + "attach_to": "reference/index", + }, ], scan_code = [ "//scripts_bazel:sources", diff --git a/bzl/bundle_rules.bzl b/bzl/bundle_rules.bzl index f55cc138a..ace5d796d 100644 --- a/bzl/bundle_rules.bzl +++ b/bzl/bundle_rules.bzl @@ -64,6 +64,7 @@ DocsBundleInfo = provider( "entries": "Ordered entries, one per source directory, including its final documentation-tree location.", "sourcelinks": "Source-code-link JSON files together with their owning repository.", "external_runfiles": "Documentation source files from external repositories needed in runfiles.", + "data": "Non-source-tree files (e.g. genrule outputs) needed for Sphinx resolution.", }, ) @@ -144,7 +145,7 @@ def _bundle_execroot_path(runtime_path): return "external/" + runtime_path[3:] return runtime_path -def _rebase_bundle_entry(entry, mount_at, attach_to): +def _rebase_bundle_entry(entry, mount_at, attach_to, data): """Place a bundle entry below a requested documentation-tree location. A bundle's own root has no ``mount_at`` yet. For that root, an omitted @@ -165,6 +166,7 @@ def _rebase_bundle_entry(entry, mount_at, attach_to): entry_doc = entry.entry_doc, external = entry.external, repository = entry.repository, + data = data, ) def _entries_visible_through(ctx, child): @@ -210,6 +212,7 @@ def _docs_bundle_impl(ctx): entries = [] own_source_files = [] own_external_runfiles = [] + own_data = depset(direct = ctx.files.data) if ctx.files.srcs: runtime_path = _bundle_runtime_path(ctx) @@ -225,12 +228,25 @@ def _docs_bundle_impl(ctx): entry_doc = ctx.attr.entry_doc, external = external, repository = ctx.label.workspace_name, + data = own_data, )) 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. if external: own_external_runfiles.extend(ctx.files.srcs) + elif own_data: + # Pure data bundle: create an entry so the data files appear in the manifest. + entries.append(struct( + runtime_path = "", + src_root = "", + mount_at = "", + attach_to = "", + entry_doc = ctx.attr.entry_doc, + external = False, + repository = ctx.label.workspace_name, + data = own_data, + )) child_source_files = [] child_external_runfiles = [] @@ -239,11 +255,13 @@ def _docs_bundle_impl(ctx): for source_link in ctx.files.sourcelinks ] for index, child in enumerate(ctx.attr.bundles): + child_data = child[DocsBundleInfo].data entries.extend([ _rebase_bundle_entry( entry, ctx.attr.bundle_mount_ats[index], ctx.attr.bundle_attach_tos[index], + child_data, ) for entry in _entries_visible_through(ctx, child) ]) @@ -260,12 +278,19 @@ def _docs_bundle_impl(ctx): direct = own_external_runfiles, transitive = child_external_runfiles, ) + all_data = depset( + transitive = [own_data] + [ + child[DocsBundleInfo].data + for child in ctx.attr.bundles + ], + ) return [ - DefaultInfo(files = all_source_files), + DefaultInfo(files = depset(transitive = [all_source_files, all_data])), DocsBundleInfo( entries = entries, sourcelinks = sourcelinks, external_runfiles = external_runfiles, + data = all_data, ), ] @@ -279,11 +304,12 @@ _docs_bundle = rule( "bundles": attr.label_list(providers = [DocsBundleInfo]), "bundle_mount_ats": attr.string_list(), "bundle_attach_tos": attr.string_list(), + "data": attr.label_list(allow_files = True), }, doc = "Internal rule that carries bundle files and their documentation-tree locations.", ) -def create_bundle(name, bundles, srcs = [], sourcelinks = [], strip_prefix = "", entry_doc = "index", visibility = None, **kwargs): +def create_bundle(name, bundles, srcs = [], sourcelinks = [], strip_prefix = "", entry_doc = "index", data = [], visibility = None, **kwargs): """Create a reusable documentation bundle from files and child declarations.""" parsed_bundles = [_parse_bundle_declaration(declaration) for declaration in bundles] _docs_bundle( @@ -295,6 +321,7 @@ def create_bundle(name, bundles, srcs = [], sourcelinks = [], strip_prefix = "", bundles = [bundle.bundle for bundle in parsed_bundles], bundle_mount_ats = [bundle.mount_at for bundle in parsed_bundles], bundle_attach_tos = [bundle.attach_to for bundle in parsed_bundles], + data = data, visibility = visibility, **kwargs ) @@ -302,7 +329,10 @@ def create_bundle(name, bundles, srcs = [], sourcelinks = [], strip_prefix = "", def _external_docs_runfiles_impl(ctx): """Expose external documentation sources needed under ``bazel run``.""" - return [DefaultInfo(files = ctx.attr.bundle[DocsBundleInfo].external_runfiles)] + bundle = ctx.attr.bundle[DocsBundleInfo] + return [DefaultInfo(files = depset( + transitive = [bundle.external_runfiles, bundle.data], + ))] _external_docs_runfiles = rule( implementation = _external_docs_runfiles_impl, diff --git a/bzl/mount_rules.bzl b/bzl/mount_rules.bzl index aa710795d..562de5996 100644 --- a/bzl/mount_rules.bzl +++ b/bzl/mount_rules.bzl @@ -18,7 +18,8 @@ load("@score_docs_as_code//:bzl/bundle_rules.bzl", "DocsBundleInfo") def _mounts_manifest_impl(ctx): """Generate the canonical Sphinx mount manifest.""" - entries = ctx.attr.bundle[DocsBundleInfo].entries + bundle_info = ctx.attr.bundle[DocsBundleInfo] + entries = bundle_info.entries json_mounts = [] for entry in entries: @@ -29,6 +30,7 @@ def _mounts_manifest_impl(ctx): "attach_to": entry.attach_to, "entry_doc": entry.entry_doc, "external": entry.external, + "data": [f.path for f in entry.data.to_list()], }) out = ctx.actions.declare_file(ctx.label.name + ".json") diff --git a/docs.bzl b/docs.bzl index 0ac56327e..1f4e41a92 100644 --- a/docs.bzl +++ b/docs.bzl @@ -61,7 +61,7 @@ load( "create_mounts_manifest", ) -def docs_bundle(name, source_dir = None, entry_doc = "index", bundles = [], scan_code = [], code_targets = [], visibility = None, **kwargs): +def docs_bundle(name, source_dir = None, data = [], entry_doc = "index", bundles = [], scan_code = [], code_targets = [], visibility = None, **kwargs): """A docs bundle, optionally composed of others. Args: @@ -69,6 +69,9 @@ def docs_bundle(name, source_dir = None, entry_doc = "index", bundles = [], scan source_dir: optional directory holding this bundle's own doc sources. It is globbed like `docs()` (same file kinds) and the contents are stored after stripping the `source_dir` prefix. Leave it unset for a pure aggregator. + data: + Additional data dependencies for this target. + Useful for generated rst sources. entry_doc: bundle-relative docname attached when this bundle is mounted. Defaults to `index`. bundles: nested bundles to compose, each a dict @@ -113,6 +116,7 @@ def docs_bundle(name, source_dir = None, entry_doc = "index", bundles = [], scan strip_prefix = strip_prefix, entry_doc = entry_doc, bundles = bundles, + data = data, visibility = visibility, **kwargs ) diff --git a/src/extensions/score_metamodel/docs/BUILD b/src/extensions/score_metamodel/docs/BUILD new file mode 100644 index 000000000..30b89e9a6 --- /dev/null +++ b/src/extensions/score_metamodel/docs/BUILD @@ -0,0 +1,42 @@ +# ******************************************************************************* +# 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") +load("@aspect_rules_py//py:defs.bzl", "py_binary") +load("@docs_as_code_hub_env//:requirements.bzl", "all_requirements") + +genrule( + name = "generate_metamodel_rst", + srcs = [ + "//src/extensions/score_metamodel:metamodel_yaml", + ], + outs = ["generated/index.rst", "generated/metamodel_classes.mmd"], + cmd = "$(location :generate_metamodel_rst_bin) --rst-output $(location generated/index.rst) --mmd-output $(location generated/metamodel_classes.mmd) $(location //src/extensions/score_metamodel:metamodel_yaml)", + tools = [":generate_metamodel_rst_bin"], + visibility = ["//visibility:private"], +) + +py_binary( + name = "generate_metamodel_rst_bin", + srcs = ["generate_metamodel_rst.py"], + main = "generate_metamodel_rst.py", + deps = all_requirements, + visibility = ["//visibility:private"], +) + +docs_bundle( + name = "metamodel", + data = [":generate_metamodel_rst"], + entry_doc = "index", + visibility = ["//visibility:public"], +) diff --git a/src/extensions/score_metamodel/docs/generate_metamodel_rst.py b/src/extensions/score_metamodel/docs/generate_metamodel_rst.py new file mode 100644 index 000000000..423cc8947 --- /dev/null +++ b/src/extensions/score_metamodel/docs/generate_metamodel_rst.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Generate an RST file with a list-table and Mermaid class diagram from metamodel.yaml. + +Usage: + generate_metamodel_rst.py --rst-output FILE --mmd-output FILE [METAMODEL_YAML] +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import ruamel.yaml + + +def _parse_yaml(path: Path) -> dict: + yaml = ruamel.yaml.YAML() + yaml.preserve_quotes = True + with Path(path).open(encoding="utf-8") as fh: + return yaml.load(fh) # type: ignore[no-any-return] + + +def _build_table(types: dict) -> list[str]: + lines: list[str] = [] + lines.append(".. list-table:: Need Types") + lines.append(" :header-rows: 1") + lines.append("") + lines.append(" * - Type") + lines.append(" - Title") + lines.append(" - Mandatory Options") + lines.append(" - Links") + for name, ty in sorted(types.items()): + title = ty.get("title", name) + mandatory = ( + ", ".join(sorted(ty.get("mandatory_options", {}).keys())) or "\u2014" + ) + optional_links = ", ".join(sorted(ty.get("optional_links", {}).keys())) + mandatory_links = ", ".join(sorted(ty.get("mandatory_links", {}).keys())) + if mandatory_links: + links = f"{optional_links} | mandatory: {mandatory_links}" + else: + links = optional_links or "\u2014" + lines.append(" * - " + name) + lines.append(" - " + title) + lines.append(" - " + mandatory) + lines.append(" - " + links) + return lines + + +def _build_mermaid(types: dict) -> list[str]: + lines: list[str] = [] + seen: set[tuple[str, str, str]] = set() + for name, ty in sorted(types.items()): + for link_name, targets in sorted( + list(ty.get("mandatory_links", {}).items()) + + list(ty.get("optional_links", {}).items()) + ): + target_types = targets.split(", ") if targets != "ANY" else [] + for target in target_types: + target = target.strip() + if target and target in types: + key = (name, target, link_name) + if key not in seen: + seen.add(key) + lines.append(f"{name} --> {target} : {link_name}") + return lines + + +def main() -> int: + parser = argparse.ArgumentParser(description="Generate RST from metamodel.yaml") + parser.add_argument("--rst-output", type=Path, required=True) + parser.add_argument("--mmd-output", type=Path, required=True) + parser.add_argument("metamodel", nargs="?", default=None) + args = parser.parse_args() + + meta_path = ( + Path(args.metamodel) + if args.metamodel + else Path(__file__).parent / "metamodel.yaml" + ) + if not meta_path.is_file(): + print(f"Error: metamodel.yaml not found at {meta_path}", file=sys.stderr) + return 1 + + try: + data = _parse_yaml(meta_path) + except Exception as exc: + print(f"Error parsing YAML: {exc}", file=sys.stderr) + return 1 + + types = data.get("needs_types", {}) + if not types: + print( + "Error: 'needs_types' section not found in metamodel.yaml", file=sys.stderr + ) + return 1 + + table = _build_table(types) + mermaid_lines = ["classDiagram"] + _build_mermaid(types) + args.mmd_output.write_text("\n".join(mermaid_lines) + "\n", encoding="utf-8") + output = "\n".join( + [ + "..", + " # (generated \u2014 do not edit)", + " # SPDX-License-Identifier: Apache-2.0", + "", + ".. _metamodel-types-visualization:", + "", + "Metamodel Types Visualization", + "==============================", + "", + f".. mermaid:: {args.mmd_output.name}", + "", + "Need Types", + "----------", + "", + ] + + table + + [""] + ) + + args.rst_output.write_text(output, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/extensions/score_mounts/__init__.py b/src/extensions/score_mounts/__init__.py index 0aa79255b..9b6124fee 100644 --- a/src/extensions/score_mounts/__init__.py +++ b/src/extensions/score_mounts/__init__.py @@ -32,6 +32,8 @@ from sphinx.util import logging from src.extensions.score_mounts._resolver import ( + MountsManifest, + MountSpec, load_mounts_manifest, resolve_walk_dir, ) @@ -63,6 +65,54 @@ def _read_manifest(config: Config): return load_mounts_manifest(manifest_path) +def _resolve_data_mounts( + manifest: MountsManifest, + ws_root: Path | None, + runfiles_dir: Path | None, +) -> dict[str, MountSpec]: + """Resolve data file mounts from the manifest. + + Data paths are execroot-relative (e.g. bazel-out/.../bin/src/.../index.rst). + Returns resolved mount dicts keyed by directory. + """ + data_mounts: dict[str, MountSpec] = {} + for spec in manifest.mounts: + for data_file in spec.data: + if ws_root is not None and runfiles_dir is not None: + runfiles_str = str(runfiles_dir) + if "/bazel-out/" in runfiles_str: + # Execroot = runfiles path before the first /bazel-out/ occurrence + # e.g. runfiles=execroot/_main/bazel-out/... => execroot=execroot/_main + walk_file = Path(runfiles_str.split("/bazel-out/")[0]) / data_file + else: + walk_file = ( + ws_root + / "bazel-bin" + / data_file.removeprefix("bazel-out/k8-fastbuild/bin/") + ) + else: + walk_file = Path.cwd() / data_file + if not walk_file.is_file(): + raise ValueError( + "score_mounts: resolved data file does not exist: " + f"{walk_file} (mount_at={spec.mount_at})" + ) + walk_dir = walk_file.parent + if str(walk_dir) not in data_mounts: + data_mounts[str(walk_dir)] = spec + return data_mounts + + +def _make_mount_entry(walk_dir: Path, spec: MountSpec) -> dict[str, object]: + """Build a mount entry dict from a resolved directory and spec.""" + return { + "dir": str(walk_dir), + "mount_at": spec.mount_at, + "attach_to": spec.attach_to, + "entry_doc": spec.entry_doc, + } + + def _on_config_inited(app: Sphinx, config: Config) -> None: """Translate the Bazel manifest into ``sphinx_mounts`` runtime config. @@ -75,6 +125,9 @@ def _on_config_inited(app: Sphinx, config: Config) -> None: if manifest is None or not manifest.mounts: return + ws_root = find_ws_root() + runfiles_dir = get_runfiles_dir() if ws_root is not None else None + # In every context sphinx_mounts walks the bundle's original files (no copy is # made); only where those files are staged differs: # * external bundle: use its runfiles-relative location under ``bazel run`` @@ -85,28 +138,32 @@ def _on_config_inited(app: Sphinx, config: Config) -> None: # as inputs at their exec-root-relative path. The manifest lives under # bazel-out/ and is NOT colocated with them, so src_root is resolved # against the exec root (the sphinx action's cwd), not the manifest. - ws_root = find_ws_root() - runfiles_dir = get_runfiles_dir() if ws_root is not None else None + # Pure-data bundles have empty src_root; skip directory walk. runtime_mounts: list[dict[str, object]] = [] for spec in manifest.mounts: + if not spec.src_root: + continue walk_dir = resolve_walk_dir(manifest, spec, ws_root, runfiles_dir) if not walk_dir.is_dir(): raise ValueError( "score_mounts: resolved mount dir does not exist: " f"{walk_dir} (mount_at={spec.mount_at})" ) - - runtime_mounts.append( - { - "dir": str(walk_dir), - "mount_at": spec.mount_at, - "attach_to": spec.attach_to, - "entry_doc": spec.entry_doc, - } - ) + runtime_mounts.append(_make_mount_entry(walk_dir, spec)) config.mounts = runtime_mounts + + # Resolve data (e.g. genrule outputs in bazel-out). + # Data paths are execroot-relative (e.g. bazel-out/.../bin/src/.../index.rst). + # During bazel run: compute execroot from RUNFILES_DIR; during sandboxed build: + # cwd IS the execroot. + # Only the parent directories of resolved files are added to mounts. + data_mounts = _resolve_data_mounts(manifest, ws_root, runfiles_dir) + for walk_dir_str, spec in data_mounts.items(): + config.mounts.append(_make_mount_entry(Path(walk_dir_str), spec)) + logger.info("score_mounts: added %d data mount(s)", len(data_mounts)) + # Prevent sphinx_mounts._on_load_toml from overwriting our config with a # possibly-stale docs/ubproject.toml entry. config.mounts_from_toml = None diff --git a/src/extensions/score_mounts/_resolver.py b/src/extensions/score_mounts/_resolver.py index a26713992..96ecdfa3d 100644 --- a/src/extensions/score_mounts/_resolver.py +++ b/src/extensions/score_mounts/_resolver.py @@ -22,7 +22,7 @@ import json import os -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import cast @@ -35,6 +35,7 @@ class MountSpec: attach_to: str | None = None entry_doc: str = "index" external: bool = False + data: list[str] = field(default_factory=list) @dataclass(frozen=True) @@ -68,6 +69,11 @@ def load_mounts_manifest(manifest_path: str | Path) -> MountsManifest: raise ValueError( f"mounts manifest entry missing 'src_root'/'mount_at': {entry!r}" ) + raw_data = entry.get("data", []) + if not isinstance(raw_data, list): + raise ValueError( + f"mounts manifest entry field 'data' must be a list: {raw_data!r}" + ) mounts.append( MountSpec( src_root=str(entry["src_root"]), @@ -78,11 +84,10 @@ def load_mounts_manifest(manifest_path: str | Path) -> MountsManifest: if entry.get("entry_doc") else "index", external=bool(entry.get("external", False)), + data=[str(f) for f in cast("list[object]", raw_data)], ) ) - return MountsManifest( - mounts=mounts, - ) + return MountsManifest(mounts=mounts) def resolve_walk_dir( diff --git a/src/extensions/score_mounts/tests/test_data_mounts.py b/src/extensions/score_mounts/tests/test_data_mounts.py new file mode 100644 index 000000000..93dc2c4d6 --- /dev/null +++ b/src/extensions/score_mounts/tests/test_data_mounts.py @@ -0,0 +1,59 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Tests for ``_resolve_data_mounts`` in the ``score_mounts`` extension.""" + +from pathlib import Path + +import pytest + +from src.extensions.score_mounts import _resolve_data_mounts +from src.extensions.score_mounts._resolver import MountsManifest, MountSpec + + +def test_missing_data_file_raises(tmp_path: Path) -> None: + """A manifest with an unavailable data file must fail fast.""" + manifest = MountsManifest( + mounts=[ + MountSpec( + src_root="", + runtime_path="", + mount_at="missing", + data=["bazel-out/k8-fastbuild/bin/nonexistent.rst"], + ) + ] + ) + + with pytest.raises(ValueError, match="resolved data file does not exist"): + _resolve_data_mounts(manifest, tmp_path, tmp_path) + + +def test_existing_data_file_resolved(tmp_path: Path) -> None: + """An existing data file resolves to its parent directory.""" + data_file = tmp_path / "bazel-bin" / "file.rst" + data_file.parent.mkdir(parents=True) + data_file.write_text("..", encoding="utf-8") + + manifest = MountsManifest( + mounts=[ + MountSpec( + src_root="", + runtime_path="", + mount_at="exists", + data=["bazel-out/k8-fastbuild/bin/file.rst"], + ) + ] + ) + + mounts = _resolve_data_mounts(manifest, tmp_path, tmp_path / "runfiles") + + assert str(tmp_path / "bazel-bin") in mounts diff --git a/src/extensions/score_sphinx_bundle/__init__.py b/src/extensions/score_sphinx_bundle/__init__.py index b0d84da5a..975f8bfe8 100644 --- a/src/extensions/score_sphinx_bundle/__init__.py +++ b/src/extensions/score_sphinx_bundle/__init__.py @@ -50,6 +50,7 @@ def setup(app: Sphinx) -> dict[str, object]: # Same as current VS Code extension config_setdefault(app.config, "mermaid_version", "11.6.0") + config_setdefault(app.config, "mermaid_d3_zoom", True) # The following entries are not required when building the documentation via # 'bazel build //:docs', as that command runs in a sandboxed environment. diff --git a/src/tests/docs_bzl/scenarios/data_files_runfiles/BUILD b/src/tests/docs_bzl/scenarios/data_files_runfiles/BUILD new file mode 100644 index 000000000..2c370adc2 --- /dev/null +++ b/src/tests/docs_bzl/scenarios/data_files_runfiles/BUILD @@ -0,0 +1,46 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* + +# This fixture exercises the public docs() macro with a data bundle whose +# only content is a genrule output living in ``bazel-out/.../bin/``. That +# output must reach the runfiles of ``:docs`` (via _external_docs_runfiles) +# and be resolved by ``score_mounts`` at ``bazel run`` time. See PR #686. + +load("//:docs.bzl", "docs", "docs_bundle") + +# Emit into a ``generated/`` subdirectory (as the real metamodel bundle does) +# so the resolved mount directory contains only the genrule output and not the +# sibling ``docs.runfiles/`` tree. +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", +) + +docs( + source_dir = "docs", + bundles = [{ + "bundle": ":data_bundle", + "mount_at": "data_test", + "attach_to": "index", + }], +) diff --git a/src/tests/docs_bzl/scenarios/data_files_runfiles/docs/conf.py b/src/tests/docs_bzl/scenarios/data_files_runfiles/docs/conf.py new file mode 100644 index 000000000..3175b997d --- /dev/null +++ b/src/tests/docs_bzl/scenarios/data_files_runfiles/docs/conf.py @@ -0,0 +1,16 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* + +project = "Data Files Runfiles Test" +project_url = "https://github.com/eclipse-score/docs-as-code" +extensions = ["score_sphinx_bundle"] diff --git a/src/tests/docs_bzl/scenarios/data_files_runfiles/docs/index.rst b/src/tests/docs_bzl/scenarios/data_files_runfiles/docs/index.rst new file mode 100644 index 000000000..fbeefb8f2 --- /dev/null +++ b/src/tests/docs_bzl/scenarios/data_files_runfiles/docs/index.rst @@ -0,0 +1,20 @@ +.. + # ******************************************************************************* + # 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 + # ******************************************************************************* + +Data Files Runfiles Test +======================== + +This scenario verifies that ``data`` files (genrule outputs) from a +``docs_bundle`` reach the runfiles of ``:docs`` and are resolved by the +Sphinx preview at ``bazel run`` time. diff --git a/src/tests/docs_bzl/scenarios/nested_bundles/BUILD b/src/tests/docs_bzl/scenarios/nested_bundles/BUILD index 57a3a30e0..5aab2b04c 100644 --- a/src/tests/docs_bzl/scenarios/nested_bundles/BUILD +++ b/src/tests/docs_bzl/scenarios/nested_bundles/BUILD @@ -38,6 +38,13 @@ filegroup( srcs = [":filegroup_sources"], ) +# Simulated generated doc output consumed via the `data` attribute. +filegroup( + name = "generated_doc_output", + srcs = ["generated/generated_output.txt"], + visibility = ["//visibility:private"], +) + py_binary( name = "example_binary", srcs = ["child/example.py"], @@ -64,6 +71,7 @@ docs_bundle( "bundle": ":child", "mount_at": "child", }], + data = [":generated_doc_output"], ) # An aggregator deliberately has no source_dir. It only propagates its child diff --git a/src/tests/docs_bzl/scenarios/nested_bundles/generated/generated_output.txt b/src/tests/docs_bzl/scenarios/nested_bundles/generated/generated_output.txt new file mode 100644 index 000000000..e97f41e26 --- /dev/null +++ b/src/tests/docs_bzl/scenarios/nested_bundles/generated/generated_output.txt @@ -0,0 +1 @@ +Generated documentation output for testing data field propagation. diff --git a/src/tests/docs_bzl/test_data_files_runfiles.py b/src/tests/docs_bzl/test_data_files_runfiles.py new file mode 100644 index 000000000..872a0cdeb --- /dev/null +++ b/src/tests/docs_bzl/test_data_files_runfiles.py @@ -0,0 +1,31 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* + +"""Verify that genrule-generated RST files are reachable at ``bazel run`` time. + +The ``data`` attribute of ``docs_bundle`` carries genrule outputs that live +in ``bazel-out/.../bin/``. The fix in ``_external_docs_runfiles_impl`` stages +them into the runfiles of ``:docs``; ``score_mounts`` then resolves the +execroot-relative paths against ``/bazel-bin`` and mounts them. This +end-to-end test fails if either half of that chain regresses.""" + +from src.tests.docs_bzl.helpers import run_scenario + + +def test_data_files_reachable_at_runtime(): + """Genrule output in a docs_bundle data dep must be resolved by Sphinx.""" + result = run_scenario("run", "data_files_runfiles", ":docs") + + generated_html = result.build_dir / "data_test" / "index.html" + + assert "Generated Data Page" in generated_html.read_text(encoding="utf-8") diff --git a/src/tests/docs_bzl/test_nested_bundles.py b/src/tests/docs_bzl/test_nested_bundles.py index 98390c1a0..0bf1861dc 100644 --- a/src/tests/docs_bzl/test_nested_bundles.py +++ b/src/tests/docs_bzl/test_nested_bundles.py @@ -44,6 +44,13 @@ def test_nested_bundles_render_and_preserve_metadata(): "index", "landing", ] + # Verify that the parent bundle's data (generated doc output) appears in the manifest. + parent_mount = next( + m for m in manifest["mounts"] if m["mount_at"] == "concepts/example_bundle" + ) + assert parent_mount["data"] == [ + "src/tests/docs_bzl/scenarios/nested_bundles/generated/generated_output.txt", + ] sourcelinks = json.loads( built_output("scenarios/nested_bundles", "sourcelinks_json.json").read_text(