Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
)
Expand Down
8 changes: 8 additions & 0 deletions bazel/rules/rules_score/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
45 changes: 45 additions & 0 deletions bazel/rules/rules_score/private/sphinx_module.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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"],
)
Expand All @@ -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.
Expand All @@ -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,
Expand All @@ -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
)
78 changes: 74 additions & 4 deletions bazel/rules/rules_score/templates/conf.template.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,59 @@
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

# 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))
Expand All @@ -55,6 +100,7 @@
"sphinxcontrib.plantuml",
"trlc",
"clickable_plantuml",
"sphinx.ext.graphviz",
]

# MyST parser extensions
Expand Down Expand Up @@ -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"
Expand Down
20 changes: 19 additions & 1 deletion bazel/rules/rules_score/test/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
35 changes: 35 additions & 0 deletions bazel/rules/rules_score/test/fixtures/graphviz_test/index.rst
Original file line number Diff line number Diff line change
@@ -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.
82 changes: 82 additions & 0 deletions bazel/rules/rules_score/test/graphviz_render_test.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading