diff --git a/MODULE.bazel b/MODULE.bazel index 0c20a38e..8aaee15a 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -34,6 +34,11 @@ bazel_dep(name = "bazel_skylib", version = "1.7.1") bazel_dep(name = "buildifier_prebuilt", version = "8.2.0.2") bazel_dep(name = "flatbuffers", version = "25.9.23") bazel_dep(name = "download_utils", version = "1.2.2") +git_override( + module_name = "download_utils", + commit = "3b96912fb6622dda83f25efd1f8ae596fc4a63a6", + remote = "https://gitlab.arm.com/bazel/download_utils.git", +) # flatbuffers depends on this transitively, but older grpc-java version # The main problem is that there the command `bazel mod deps` is broken, which @@ -254,6 +259,19 @@ deb( urls = ["https://archive.ubuntu.com/ubuntu/pool/universe/l/lcov/lcov_2.0-4ubuntu2_all.deb"], ) +############################################################################### +# Graphviz deb package (cmake release; bundles all graphviz .so files so +# dot_builtins runs without system graphviz installation) +# Uses download_deb from @download_utils at a commit that includes +# data.tar.gz support in download/deb/repository.bzl. +############################################################################### +deb( + name = "graphviz_deb", + build = "//third_party/graphviz:graphviz.BUILD", + integrity = "sha256-Jk5gSqo8l0INoY+kr1ZAsi2WhZY8LlAFlEag54H3Q2Q=", + urls = ["https://gitlab.com/api/v4/projects/4207231/packages/generic/graphviz-releases/12.2.1/ubuntu_24.04_graphviz-12.2.1-cmake.deb"], +) + register_toolchains( "//bazel/rules/rules_score:sphinx_default_toolchain", ) diff --git a/bazel/rules/rules_score/BUILD b/bazel/rules/rules_score/BUILD index 08ff2788..ba0d1c26 100644 --- a/bazel/rules/rules_score/BUILD +++ b/bazel/rules/rules_score/BUILD @@ -22,6 +22,14 @@ load( load("//bazel/rules/rules_score:sphinx_toolchain.bzl", "sphinx_toolchain") load("//lobster_bazel:lobster_bazel.bzl", "lobster_linker") +config_setting( + name = "linux_x86_64", + constraint_values = [ + "@platforms//os:linux", + "@platforms//cpu:x86_64", + ], +) + exports_files([ "src/bazel_sphinx_needs.py", "src/sphinx_wrapper.py", diff --git a/bazel/rules/rules_score/private/sphinx_module.bzl b/bazel/rules/rules_score/private/sphinx_module.bzl index 6fae4449..01074c2e 100644 --- a/bazel/rules/rules_score/private/sphinx_module.bzl +++ b/bazel/rules/rules_score/private/sphinx_module.bzl @@ -236,10 +236,36 @@ def _score_html_impl(ctx): "--log-level", get_log_level(ctx), ] + + # Wire in the hermetic graphviz deb (dot_builtins + bundled shared libs) if provided. + # conf.template.py resolves all three env vars (GRAPHVIZ_DOT, + # LD_LIBRARY_PATH, LTDL_LIBRARY_PATH) from execroot-relative to absolute + # paths so dot_builtins can load its plugins without a system installation. + graphviz_env = {} + graphviz_files = ctx.files.graphviz + if graphviz_files: + _dot_suffix = "/usr/bin/dot_builtins" + dot_binary = None + for f in graphviz_files: + if f.path.endswith(_dot_suffix): + dot_binary = f + break + if not dot_binary: + fail("graphviz target {} must provide usr/bin/dot_builtins".format(ctx.attr.graphviz)) + + graphviz_prefix = dot_binary.path[:-len(_dot_suffix)] + graphviz_env = { + "GRAPHVIZ_DOT": dot_binary.path, + "LD_LIBRARY_PATH": graphviz_prefix + "/usr/lib", + "LTDL_LIBRARY_PATH": graphviz_prefix + "/usr/lib/graphviz", + } + html_inputs = html_inputs + graphviz_files + ctx.actions.run( inputs = html_inputs, outputs = [sphinx_html_output], arguments = html_args + [args], + env = graphviz_env, progress_message = "Building HTML: %s" % ctx.label.name, executable = sphinx_toolchain.sphinx.files_to_run.executable, tools = [ @@ -331,6 +357,13 @@ _score_html = rule( "destination paths relative to the Sphinx source root. Exactly one " + "file per label. Mirrors sphinx_docs.renamed_srcs from rules_python.", ), + graphviz = attr.label( + default = None, + allow_files = True, + doc = "Graphviz cmake-release deb files (dot_builtins binary + bundled libs). " + + "Only available on Linux x86_64; provides a hermetic 'dot' binary without requiring a system graphviz installation. " + + "Defaults to @graphviz_deb//:all on Linux x86_64.", + ), ), toolchains = ["//bazel/rules/rules_score:toolchain_type"], ) @@ -349,6 +382,7 @@ def sphinx_module( strip_prefix = "", extra_opts = [], extra_opts_targets = [], + graphviz = None, testonly = False, **kwargs): """Build a Sphinx module with transitive HTML dependencies. @@ -373,8 +407,18 @@ def sphinx_module( extra_opts_targets: {type}`list[label]` Label targets that resolve to extra Sphinx arguments at analysis time. Each target must provide FilteredExecpathInfo (e.g. filter_execpath targets). + graphviz: Graphviz cmake-release deb files (dot_builtins + bundled libs). On Linux x86_64, + defaults to @graphviz_deb//:all for hermetic graphviz support. On other platforms + or if explicitly set to None, no graphviz support is provided (the sphinx.ext.graphviz + extension will not be available). visibility: Bazel visibility """ + if graphviz == None: + graphviz = select({ + "//bazel/rules/rules_score:linux_x86_64": "@graphviz_deb//:all", + "//conditions:default": None, + }) + _score_needs( name = name + "_needs", srcs = srcs, @@ -393,6 +437,7 @@ def sphinx_module( needs = [d + "_needs" for d in deps], extra_opts = extra_opts, extra_opts_targets = extra_opts_targets, + graphviz = graphviz, testonly = testonly, **kwargs ) diff --git a/bazel/rules/rules_score/templates/conf.template.py b/bazel/rules/rules_score/templates/conf.template.py index a158bd4d..df5964d1 100644 --- a/bazel/rules/rules_score/templates/conf.template.py +++ b/bazel/rules/rules_score/templates/conf.template.py @@ -22,7 +22,7 @@ import os import sys from pathlib import Path -from typing import Any, Dict, List +from typing import List from python.runfiles import Runfiles from sphinx.util import logging @@ -30,6 +30,51 @@ # Create a logger with the Sphinx namespace logger = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# Helpers: Bazel execroot path resolution +# --------------------------------------------------------------------------- + + +# Capture the current working directory at module import time. +# In Bazel action context, cwd == execroot. In IDE/non-Bazel runs, cwd is +# the current directory. This is captured once to avoid repeated resolution. +_EXECROOT = Path.cwd() + + +def _resolve_execroot_path(path_value: str) -> str: + """Resolve an execroot-relative path to an absolute filesystem path. + + Bazel passes action inputs as paths relative to the execroot (e.g. + ``external/+_repo_rules2+graphviz_deb/usr/bin/dot_builtins``). Those + paths are only valid when the process' cwd is the execroot — which is + not guaranteed once Sphinx changes directories during the build. + + This function makes them absolute so they work regardless of cwd. + Absolute paths and plain command names (e.g. ``dot``) are returned + unchanged. + """ + p = Path(path_value) + if p.is_absolute(): + return str(p) + if path_value.startswith("external/") or path_value.startswith("bazel-out/"): + # First try cwd-as-execroot (fast path). + candidate = (_EXECROOT / p).resolve() + if candidate.exists(): + return str(candidate) + + # If cwd is nested under bazel-out, walk upward and locate the first + # parent that contains the requested relative path. + for parent in [_EXECROOT, *_EXECROOT.parents]: + candidate = (parent / p).resolve() + if candidate.exists(): + return str(candidate) + + # Fallback: preserve previous behavior even if the file does not exist + # yet (keeps logging/debug output deterministic). + return str((_EXECROOT / p).resolve()) + return path_value + + logger.debug("#" * 80) logger.debug("# READING CONF.PY") logger.debug("SYSPATH:" + str(sys.path)) @@ -55,6 +100,7 @@ "sphinxcontrib.plantuml", "trlc", "clickable_plantuml", + "sphinx.ext.graphviz", ] # MyST parser extensions @@ -164,9 +210,33 @@ plantuml = f"{plantuml_path} -Playout=smetana" plantuml_output_format = "svg_obj" -import shutil as _shutil - -graphviz_dot = os.environ.get("GRAPHVIZ_DOT") or _shutil.which("dot") or "dot" +# --------------------------------------------------------------------------- +# Graphviz (sphinx.ext.graphviz) +# --------------------------------------------------------------------------- +# GRAPHVIZ_DOT is set by the Bazel sphinx_module rule to point at the hermetic +# dot_builtins binary from @graphviz_deb. The path is execroot-relative, so +# we resolve it to an absolute path here so it remains valid after any cwd +# change that Sphinx may perform during the build. +# If GRAPHVIZ_DOT is absent, force a known-invalid dot path so Sphinx fails +# clearly on graphviz directives instead of silently using host-installed dot. +if "GRAPHVIZ_DOT" in os.environ: + graphviz_dot = _resolve_execroot_path(os.environ["GRAPHVIZ_DOT"]) + graphviz_output_format = "svg" + + # LD_LIBRARY_PATH and LTDL_LIBRARY_PATH are set by the Bazel rule as + # execroot-relative paths. We mutate os.environ (not just a local) because + # sphinx.ext.graphviz spawns `dot` as a child process that inherits these + # variables to locate the bundled shared libraries and plugins. Each + # component is resolved to absolute so it stays valid if Sphinx changes cwd + # before spawning the dot subprocess. + for _env_var in ("LD_LIBRARY_PATH", "LTDL_LIBRARY_PATH"): + _env_val = os.environ.get(_env_var, "") + if _env_val: + os.environ[_env_var] = ":".join( + _resolve_execroot_path(p) for p in _env_val.split(":") + ) +else: + graphviz_dot = "/__hermetic_graphviz_not_configured__/dot" # HTML theme html_theme = "sphinx_rtd_theme" diff --git a/bazel/rules/rules_score/test/BUILD b/bazel/rules/rules_score/test/BUILD index 3b3402b8..1c2f2e6b 100644 --- a/bazel/rules/rules_score/test/BUILD +++ b/bazel/rules/rules_score/test/BUILD @@ -126,7 +126,16 @@ sphinx_module( ], ) -# Test 2: SEooC (Safety Element out of Context) Module +# Test 2: Graphviz Rendering +# Tests hermetic graphviz support via sphinx.ext.graphviz directive +sphinx_module( + name = "graphviz_test_lib", + srcs = glob(["fixtures/graphviz_test/*.rst"]), + index = "fixtures/graphviz_test/index.rst", + sphinx = "@score_tooling//bazel/rules/rules_score:score_build", +) + +# Test 3: SEooC (Safety Element out of Context) Module # Tests the score_component macro with S-CORE process artifacts # - Feature Requirements: wp__requirements_feat @@ -771,6 +780,14 @@ py_test( deps = ["@trlc//tools/trlc_rst:trlc_rst_lib"], ) +py_test( + name = "test_graphviz_rendering", + size = "small", + srcs = ["graphviz_render_test.py"], + data = [":graphviz_test_lib"], + main = "graphviz_render_test.py", +) + # Combined test suite for all tests test_suite( name = "all_tests", @@ -779,6 +796,7 @@ test_suite( ":requirements_rst_tests", ":seooc_tests", ":sphinx_module_tests", + ":test_graphviz_rendering", ":test_rst_to_trlc", ":test_safety_analysis_tools", ":test_trlc_rst_image_rendering", diff --git a/bazel/rules/rules_score/test/fixtures/graphviz_test/index.rst b/bazel/rules/rules_score/test/fixtures/graphviz_test/index.rst new file mode 100644 index 00000000..cb44cddb --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/graphviz_test/index.rst @@ -0,0 +1,35 @@ +.. + # ******************************************************************************* + # 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 + # ******************************************************************************* + +Graphviz Rendering Test +======================= + +This document tests the ``sphinx.ext.graphviz`` directive to ensure hermetic graphviz +integration is working correctly. + +Simple DAG +---------- + +.. graphviz:: + :align: center + + digraph { + A -> B; + A -> C; + B -> D; + C -> D; + label = "Simple DAG"; + } + +This graphviz diagram should render as SVG in the produced HTML output. diff --git a/bazel/rules/rules_score/test/graphviz_render_test.py b/bazel/rules/rules_score/test/graphviz_render_test.py new file mode 100644 index 00000000..8a92641c --- /dev/null +++ b/bazel/rules/rules_score/test/graphviz_render_test.py @@ -0,0 +1,82 @@ +# ******************************************************************************* +# Copyright (c) 2025 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 +# ******************************************************************************* +"""Integration test for hermetic graphviz rendering in sphinx_module. + +Verifies that the sphinx.ext.graphviz extension successfully renders diagrams +to SVG when using hermetic graphviz bundled via download_deb. +""" + +import os +import unittest + + +def _runfile(*parts: str) -> str: + """Locate a file in the Bazel runfiles tree.""" + srcdir = os.environ["TEST_SRCDIR"] + workspace = os.environ.get("TEST_WORKSPACE", "_main") + for candidate in [ + os.path.join(srcdir, workspace, *parts), + os.path.join(srcdir, *parts), + ]: + if os.path.exists(candidate): + return candidate + raise FileNotFoundError( + f"Runfile not found: {os.path.join(*parts)}\n" + f" Searched under TEST_SRCDIR={srcdir}" + ) + + +def _find_generated_html_root() -> str: + """Locate the generated HTML directory for graphviz_test_lib in runfiles. + + Depending on Bazel/runfiles layout, the directory artifact may appear either + at ``.../graphviz_test_lib/html`` or at ``.../graphviz_test_lib``. + """ + for parts in [ + ("bazel/rules/rules_score/test/graphviz_test_lib/html",), + ("bazel/rules/rules_score/test/graphviz_test_lib",), + ]: + try: + root = _runfile(*parts) + except FileNotFoundError: + continue + if os.path.exists(os.path.join(root, "index.html")): + return root + + raise FileNotFoundError( + "Unable to locate generated graphviz_test_lib HTML output in runfiles" + ) + + +class TestGraphvizRendering(unittest.TestCase): + """Verify that sphinx.ext.graphviz renders an SVG artifact.""" + + def test_graphviz_renders_svg(self): + """Test that graphviz output is rendered as SVG in generated HTML.""" + html_dir = _find_generated_html_root() + + svg_files = [ + file_name + for _, _, files in os.walk(html_dir) + for file_name in files + if file_name.endswith(".svg") + ] + + self.assertTrue( + svg_files, + "Generated HTML output should include at least one rendered graphviz SVG artifact", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/third_party/graphviz/BUILD b/third_party/graphviz/BUILD new file mode 100644 index 00000000..e9f4f198 --- /dev/null +++ b/third_party/graphviz/BUILD @@ -0,0 +1,16 @@ +# ******************************************************************************* +# Copyright (c) 2025 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 package hosts the BUILD file used by the @graphviz_deb external repository. +# The download_deb rule from @download_utils extracts the Graphviz cmake +# release .deb and uses graphviz.BUILD as its top-level BUILD file. diff --git a/third_party/graphviz/graphviz.BUILD b/third_party/graphviz/graphviz.BUILD new file mode 100644 index 00000000..bbc54ccb --- /dev/null +++ b/third_party/graphviz/graphviz.BUILD @@ -0,0 +1,49 @@ +# ******************************************************************************* +# Copyright (c) 2025 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 BUILD file is injected into the @graphviz_deb external repository by the +# graphviz_deb rule. It exposes dot_builtins and required bundled shared +# libraries for Sphinx graphviz rendering in a hermetic way. + +package(default_visibility = ["//visibility:public"]) + +# The actual graphviz rendering binary (not the dot wrapper/launcher). +# Uses RUNPATH $ORIGIN/../lib to find bundled shared libraries. +filegroup( + name = "dot_binary", + srcs = ["usr/bin/dot_builtins"], +) + +# Bundled graphviz shared libraries (libgvc, libcgraph, libcdt, libpathplan, libxdot). +# These are found automatically by dot_builtins via RUNPATH $ORIGIN/../lib. +filegroup( + name = "core_libs", + srcs = glob(["usr/lib/*.so*"]), +) + +# Graphviz plugin shared libraries (libgvplugin_core, libgvplugin_dot_layout, etc.). +# Loaded at runtime via libltdl; requires LTDL_LIBRARY_PATH=usr/lib/graphviz. +filegroup( + name = "plugin_libs", + srcs = glob(["usr/lib/graphviz/*.so*"]), +) + +# All graphviz files needed to run dot_builtins. +filegroup( + name = "all", + srcs = [ + ":core_libs", + ":dot_binary", + ":plugin_libs", + ], +) diff --git a/tools/sphinx/BUILD b/tools/sphinx/BUILD index e0168a64..d1a64d03 100644 --- a/tools/sphinx/BUILD +++ b/tools/sphinx/BUILD @@ -12,7 +12,6 @@ # ******************************************************************************* load("@pip_rules_score//:requirements.bzl", "requirement") -load("@pip_tooling//:requirements.bzl", "requirement") load("@rules_java//java:defs.bzl", "java_binary") load("@rules_python//sphinxdocs:sphinx.bzl", "sphinx_build_binary")