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
68 changes: 35 additions & 33 deletions bazel/rules/rules_score/private/sphinx_module.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -238,29 +238,15 @@ def _score_html_impl(ctx):
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.
# Wire in the hermetic graphviz deb if provided. GRAPHVIZ_DOT points at
# dot_builtins; conf.template.py resolves it to an absolute path and derives
# LD_LIBRARY_PATH / LTDL_LIBRARY_PATH from it using stdlib Path.parent so
# the rule itself needs no knowledge of the deb's directory layout.
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
dot_binary = ctx.file.graphviz_dot
if dot_binary:
graphviz_env = {"GRAPHVIZ_DOT": dot_binary.path}
html_inputs = html_inputs + [dot_binary] + ctx.files.graphviz_all

ctx.actions.run(
inputs = html_inputs,
Expand Down Expand Up @@ -358,12 +344,20 @@ _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(
graphviz_dot = attr.label(
default = None,
allow_single_file = True,
doc = "Hermetic 'dot_builtins' binary from @graphviz_deb (e.g. @graphviz_deb//:dot_binary). " +
"When set, PlantUML uses it via -graphvizdot for native Graphviz layout quality. " +
"Only available on Linux x86_64; other platforms fall back to Smetana.",
),
graphviz_all = 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.",
doc = "All Graphviz files from @graphviz_deb (e.g. @graphviz_deb//:all): the dot_builtins " +
"binary, core shared libraries, and plugin shared libraries. These are staged into " +
"the sandbox as action inputs; library directories are derived from graphviz_dot's " +
"path using the standard FHS layout (usr/lib, usr/lib/graphviz).",
),
),
toolchains = ["//bazel/rules/rules_score:toolchain_type"],
Expand Down Expand Up @@ -408,14 +402,21 @@ 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).
graphviz: Controls hermetic dot support for PlantUML layout. `None` (default)
auto-enables it on linux_x86_64 using @graphviz_deb (dot_builtins + bundled libs)
so PlantUML uses native Graphviz layout quality. On other platforms PlantUML
falls back to Smetana. Pass `False` to disable hermetic dot wiring entirely.
visibility: Bazel visibility
"""
if graphviz == None:
graphviz = select({
if graphviz == False:
graphviz_dot = None
graphviz_all = None
else:
graphviz_dot = select({
"//bazel/rules/rules_score:linux_x86_64": "@graphviz_deb//:dot_binary",
"//conditions:default": None,
})
graphviz_all = select({
"//bazel/rules/rules_score:linux_x86_64": "@graphviz_deb//:all",
"//conditions:default": None,
})
Expand All @@ -438,7 +439,8 @@ def sphinx_module(
needs = [d + "_needs" for d in deps],
extra_opts = extra_opts,
extra_opts_targets = extra_opts_targets,
graphviz = graphviz,
graphviz_dot = graphviz_dot,
graphviz_all = graphviz_all,
testonly = testonly,
**kwargs
)
6 changes: 6 additions & 0 deletions bazel/rules/rules_score/src/sphinx_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,12 @@ def main() -> int:
Exit code (0 for success, non-zero for failure)
"""
try:
# Capture the Bazel execroot (the action's cwd) before Sphinx changes
# into the generated source tree. conf.template.py reads
# SPHINX_BAZEL_EXECROOT to resolve execroot-relative tool paths (e.g.
# the hermetic graphviz dot binary) to absolute paths that stay valid
# after Sphinx chdirs.
os.environ["SPHINX_BAZEL_EXECROOT"] = os.getcwd()
args, extra_args = parse_arguments()
logging.basicConfig(
level=_LEVEL_MAP[args.log_level], format="%(levelname)s: %(message)s"
Expand Down
87 changes: 36 additions & 51 deletions bazel/rules/rules_score/templates/conf.template.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,42 +35,30 @@
# ---------------------------------------------------------------------------


# 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()
# Resolve execroot-relative paths against the Bazel execroot. sphinx_wrapper.py
# exports SPHINX_BAZEL_EXECROOT (the action cwd captured before Sphinx changes
# into the generated source tree). Fall back to the current cwd for non-Bazel
# / IDE runs where the variable is absent.
_EXECROOT = Path(os.environ.get("SPHINX_BAZEL_EXECROOT", "") or 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.
``external/+_repo_rules+graphviz_deb/usr/bin/dot_builtins``). Sphinx changes
into the generated source tree before importing conf.py, so the process cwd
is no longer the execroot. We resolve against ``_EXECROOT`` (captured by the
wrapper before that chdir) so the paths stay valid for the ``dot``
subprocess.

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

Expand Down Expand Up @@ -100,7 +88,6 @@ def _resolve_execroot_path(path_value: str) -> str:
"sphinxcontrib.plantuml",
"trlc",
"clickable_plantuml",
"sphinx.ext.graphviz",
]

# MyST parser extensions
Expand Down Expand Up @@ -205,38 +192,36 @@ def _resolve_execroot_path(path_value: str) -> str:
f"Could not find plantuml binary via runfiles lookup. Searched: {searched}."
)

# Use PlantUML's built-in Smetana layout engine (Java port of Graphviz).
# This avoids requiring an external dot binary in the Bazel sandbox.
plantuml = f"{plantuml_path} -Playout=smetana"
plantuml_output_format = "svg_obj"

# ---------------------------------------------------------------------------
# Graphviz (sphinx.ext.graphviz)
# PlantUML + hermetic dot
# ---------------------------------------------------------------------------
# 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.
# GRAPHVIZ_DOT is set by sphinx_module on linux_x86_64 to the hermetic
# dot_builtins binary from @graphviz_deb. When present, PlantUML is told to
# use it directly via -graphvizdot, giving native Graphviz layout quality for
# all UML diagram types. LD_LIBRARY_PATH / LTDL_LIBRARY_PATH are resolved to
# absolute paths here so they remain valid in the dot_builtins subprocess that
# PlantUML spawns (Sphinx may have chdir'd before then).
# On other platforms (e.g. arm64, macOS) GRAPHVIZ_DOT is absent and PlantUML
# falls back to its built-in Smetana layout engine (pure-Java, no dot needed).
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(":")
)
_dot_path = Path(_resolve_execroot_path(os.environ["GRAPHVIZ_DOT"]))
# Derive library search paths from the binary location so the rule passes
# only GRAPHVIZ_DOT and conf.py stays self-contained.
# The graphviz cmake deb installs:
# usr/bin/dot_builtins ← GRAPHVIZ_DOT points here
# usr/lib/*.so* ← LD_LIBRARY_PATH (core shared libs)
# usr/lib/graphviz/*.so* ← LTDL_LIBRARY_PATH (layout/render plugins)
_usr_dir = _dot_path.parent.parent # usr/bin → parent → usr
os.environ["LD_LIBRARY_PATH"] = str(_usr_dir / "lib")
os.environ["LTDL_LIBRARY_PATH"] = str(_usr_dir / "lib" / "graphviz")
plantuml = f"{plantuml_path} -graphvizdot {_dot_path}"
Comment on lines +207 to +217

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really nasty...
Better go into this direction:
eclipse-score/communication#548

else:
graphviz_dot = "/__hermetic_graphviz_not_configured__/dot"
logger.warning(
"GRAPHVIZ_DOT not set; PlantUML falling back to Smetana layout engine. "
"Hermetic dot (@graphviz_deb) is only available on linux_x86_64."
)
plantuml = f"{plantuml_path} -Playout=smetana"
plantuml_output_format = "svg_obj"

# HTML theme
html_theme = "sphinx_rtd_theme"
Expand Down
20 changes: 11 additions & 9 deletions bazel/rules/rules_score/test/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,13 @@ sphinx_module(
],
)

# Test 2: Graphviz Rendering
# Tests hermetic graphviz support via sphinx.ext.graphviz directive
# Test 2: PlantUML dot rendering
# Tests that the hermetic dot binary is wired to PlantUML via -graphvizdot
# and that @startdot content in a .. uml:: directive renders to SVG.
sphinx_module(
name = "graphviz_test_lib",
srcs = glob(["fixtures/graphviz_test/*.rst"]),
index = "fixtures/graphviz_test/index.rst",
name = "plantuml_dot_test_lib",
srcs = glob(["fixtures/plantuml_dot_test/*.rst"]),
index = "fixtures/plantuml_dot_test/index.rst",
sphinx = "@score_tooling//bazel/rules/rules_score:score_build",
)

Expand Down Expand Up @@ -788,11 +789,11 @@ py_test(
)

py_test(
name = "test_graphviz_rendering",
name = "test_plantuml_dot_rendering",
size = "small",
srcs = ["graphviz_render_test.py"],
data = [":graphviz_test_lib"],
main = "graphviz_render_test.py",
srcs = ["plantuml_dot_render_test.py"],
data = [":plantuml_dot_test_lib"],
main = "plantuml_dot_render_test.py",
)

# Combined test suite for all tests
Expand All @@ -805,6 +806,7 @@ test_suite(
":sphinx_module_tests",
":test_aou_forwarding_to_lobster",
":test_graphviz_rendering",
":test_plantuml_dot_rendering",
":test_rst_to_trlc",
":test_safety_analysis_tools",
":test_trlc_rst_image_rendering",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
..
# *******************************************************************************
# Copyright (c) 2026 Contributors to the Eclipse Foundation
# Copyright (c) 2025 Contributors to the Eclipse Foundation
#
# See the NOTICE file(s) distributed with this work for additional
# information regarding copyright ownership.
Expand All @@ -12,24 +12,26 @@
# SPDX-License-Identifier: Apache-2.0
# *******************************************************************************

Graphviz Rendering Test
=======================
PlantUML dot Rendering Test
===========================

This document tests the ``sphinx.ext.graphviz`` directive to ensure hermetic graphviz
integration is working correctly.
This document verifies that the hermetic ``dot_builtins`` binary is correctly
wired to PlantUML via ``-graphvizdot`` and that ``@startdot`` content inside a
``.. uml::`` directive is rendered as an SVG image.

Simple DAG
----------
Simple DAG via @startdot
------------------------

.. graphviz::
:align: center
.. uml::

@startdot
digraph {
A -> B;
A -> C;
B -> D;
C -> D;
label = "Simple DAG";
}
@enddot

This graphviz diagram should render as SVG in the produced HTML output.
The diagram above should appear as an SVG in the produced HTML output.
Loading
Loading