From 3da1a89e7d05be9888ff32fa428b1433f7764dd6 Mon Sep 17 00:00:00 2001 From: Jochen Hoenle Date: Mon, 27 Jul 2026 16:49:05 +0200 Subject: [PATCH 1/7] [rules score] correct sphinx_html_merge link rewriting and drop .doctrees - add tests for sphinx html merge - fix: cross-module links were depth-blind - fix: single-dependency case took a different code path - fix: dont publish Sphinx's build cache --- bazel/rules/rules_score/BUILD | 7 + .../rules_score/src/sphinx_html_merge.py | 75 ++++---- bazel/rules/rules_score/test/BUILD | 8 + .../test/test_sphinx_html_merge.py | 164 ++++++++++++++++++ 4 files changed, 224 insertions(+), 30 deletions(-) create mode 100644 bazel/rules/rules_score/test/test_sphinx_html_merge.py diff --git a/bazel/rules/rules_score/BUILD b/bazel/rules/rules_score/BUILD index c365f3ac..03894e65 100644 --- a/bazel/rules/rules_score/BUILD +++ b/bazel/rules/rules_score/BUILD @@ -114,6 +114,13 @@ py_binary( ) # HTML merge tool +py_library( + name = "sphinx_html_merge_lib", + srcs = ["src/sphinx_html_merge.py"], + imports = ["src"], + visibility = ["//visibility:public"], +) + py_binary( name = "sphinx_html_merge", srcs = ["src/sphinx_html_merge.py"], diff --git a/bazel/rules/rules_score/src/sphinx_html_merge.py b/bazel/rules/rules_score/src/sphinx_html_merge.py index 7f597604..0b392ed6 100755 --- a/bazel/rules/rules_score/src/sphinx_html_merge.py +++ b/bazel/rules/rules_score/src/sphinx_html_merge.py @@ -25,7 +25,6 @@ import argparse import logging -import os import re import shutil import sys @@ -39,21 +38,29 @@ } -# Standard Sphinx directories that should be copied -# Note: _static and _sphinx_design_static are excluded for dependencies to avoid duplication -SPHINX_DIRS = {"_sources", ".doctrees"} +# Sphinx build-cache artifacts that must never reach the published site. +# .doctrees holds pickled BuildEnvironment state (absolute execroot paths, +# per-doc mtimes) — copying it is both wasted space and a hermeticity leak. +BUILD_ARTIFACT_DIRS = {".doctrees"} -def copy_html_files(src_dir, dst_dir, exclude_module_dirs=None, sibling_modules=None): +def copy_html_files(src_dir, dst_dir, is_dependency=False, sibling_modules=None): """Copy HTML and related files from src to dst, with optional link fixing. Args: src_dir: Source HTML directory dst_dir: Destination directory - exclude_module_dirs: Set of module directory names to skip (to avoid copying nested modules). - If None, copy everything. - sibling_modules: Set of sibling module names for fixing links in HTML files. - If None, no link fixing is performed. + is_dependency: Whether src_dir is a dependency module's HTML being placed + into a subdirectory of the merged site (as opposed to the + main module, which is copied as-is at the site root). + Dependencies have their own _static/_sphinx_design_static + dropped (the merged site uses one shared _static/ at the + root) and their internal links rewritten for the new + nesting depth. + sibling_modules: Set of sibling module directory names to skip (so nested + copies of other modules already merged elsewhere aren't + duplicated) and to rewrite intra-site links for. Only + meaningful when is_dependency is True. """ src_path = Path(src_dir) dst_path = Path(dst_dir) @@ -64,12 +71,10 @@ def copy_html_files(src_dir, dst_dir, exclude_module_dirs=None, sibling_modules= dst_path.mkdir(parents=True, exist_ok=True) - if exclude_module_dirs is None: - exclude_module_dirs = set() + sibling_modules = sibling_modules or set() - # Prepare regex patterns for link fixing if needed + # Prepare regex pattern for sibling-module link fixing, if needed. module_pattern = None - static_pattern = None if sibling_modules: module_pattern = re.compile( r'((?:href|src)=")(' @@ -77,28 +82,34 @@ def copy_html_files(src_dir, dst_dir, exclude_module_dirs=None, sibling_modules= + r")/", re.IGNORECASE, ) - static_pattern = re.compile( - r'((?:href|src)=")(\.\./)*(_static|_sphinx_design_static)/', re.IGNORECASE - ) + static_pattern = re.compile( + r'((?:href|src)=")(\.\./)*(_static|_sphinx_design_static)/', re.IGNORECASE + ) def process_file(src_file, dst_file, relative_path): """Read, optionally modify, and write a file.""" - if src_file.suffix == ".html" and sibling_modules: + if src_file.suffix == ".html" and is_dependency: # Read, modify, and write HTML files try: content = src_file.read_text(encoding="utf-8") - # Replace module_name/ with ../module_name/ - modified_content = module_pattern.sub(r"\1../\2/", content) - - # Calculate depth for static file references + # Both rewrites below must agree on how many directory levels + # this page now sits below the merged site root, so compute + # the prefix once and share it. depth = len(relative_path.parents) - 1 parent_prefix = "../" * (depth + 1) + if module_pattern is not None: + + def replace_module(match): + return f"{match.group(1)}{parent_prefix}{match.group(2)}/" + + content = module_pattern.sub(replace_module, content) + def replace_static(match): return f"{match.group(1)}{parent_prefix}{match.group(3)}/" - modified_content = static_pattern.sub(replace_static, modified_content) + modified_content = static_pattern.sub(replace_static, content) # Write modified content dst_file.parent.mkdir(parents=True, exist_ok=True) @@ -121,13 +132,17 @@ def copy_tree(src, dst, rel_path): if item.is_file(): process_file(item, dst_item, rel_item) elif item.is_dir(): - # Skip excluded directories - if item.name in exclude_module_dirs: + # Never publish Sphinx's own build cache. + if item.name in BUILD_ARTIFACT_DIRS: + continue + # Skip nested copies of sibling modules to avoid duplication. + if item.name in sibling_modules: continue - # Skip static dirs from dependencies - if ( - item.name in ("_static", "_sphinx_design_static") - and exclude_module_dirs + # Dependencies use the merged site's shared _static/ instead + # of their own. + if is_dependency and item.name in ( + "_static", + "_sphinx_design_static", ): continue @@ -153,7 +168,7 @@ def merge_html_dirs(output_dir, main_html_dir, dependencies, extra_static=None): # First, copy the main HTML directory logging.info("Copying main HTML from %s to %s", main_html_dir, output_dir) - copy_html_files(main_html_dir, output_dir) + copy_html_files(main_html_dir, output_dir, is_dependency=False) # Copy any extra static files into output/_static/ for src_file, dest_subpath in extra_static or []: @@ -177,7 +192,7 @@ def merge_html_dirs(output_dir, main_html_dir, dependencies, extra_static=None): copy_html_files( dep_html_dir, dep_output, - exclude_module_dirs=sibling_modules, + is_dependency=True, sibling_modules=sibling_modules, ) diff --git a/bazel/rules/rules_score/test/BUILD b/bazel/rules/rules_score/test/BUILD index 7684381b..c0ba0cbd 100644 --- a/bazel/rules/rules_score/test/BUILD +++ b/bazel/rules/rules_score/test/BUILD @@ -880,6 +880,13 @@ py_test( deps = ["@score_tooling//bazel/rules/rules_score:aou_forwarding_to_lobster"], ) +py_test( + name = "test_sphinx_html_merge", + size = "small", + srcs = ["test_sphinx_html_merge.py"], + deps = ["@score_tooling//bazel/rules/rules_score:sphinx_html_merge_lib"], +) + py_test( name = "test_rst_to_trlc", size = "small", @@ -910,6 +917,7 @@ test_suite( ":test_aou_forwarding_to_lobster", ":test_fmea_assembler", ":test_rst_to_trlc", + ":test_sphinx_html_merge", ":test_trlc_rst_image_rendering", ":unit_component_tests", "//fixtures/image_srcs:requirements_image_tests", diff --git a/bazel/rules/rules_score/test/test_sphinx_html_merge.py b/bazel/rules/rules_score/test/test_sphinx_html_merge.py new file mode 100644 index 00000000..4295570b --- /dev/null +++ b/bazel/rules/rules_score/test/test_sphinx_html_merge.py @@ -0,0 +1,164 @@ +# ******************************************************************************* +# 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 sphinx_html_merge.""" + +import tempfile +import unittest +from pathlib import Path + +from sphinx_html_merge import merge_html_dirs + + +def _write(path: Path, content: str = "") -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +class TestMergeHtmlDirs(unittest.TestCase): + """Tests for merge_html_dirs / copy_html_files.""" + + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) + self.output = self.root / "output" + + def tearDown(self) -> None: + self._tmp.cleanup() + + def test_nested_dep_page_rewrites_sibling_link_with_full_depth(self) -> None: + """Regression test: a dependency's link to a sibling module from a + page nested two directories deep must climb back out to the merged + site root before descending into the sibling, not just one level. + """ + main = self.root / "main" + _write(main / "index.html", "") + + dep_a = self.root / "dep_a" + _write( + dep_a / "sub" / "deep" / "page.html", + 'link', + ) + + dep_b = self.root / "dep_b" + _write(dep_b / "index.html", "") + + merge_html_dirs( + self.output, + main, + [("dep_a", dep_a), ("dep_b", dep_b)], + ) + + content = (self.output / "dep_a" / "sub" / "deep" / "page.html").read_text() + self.assertIn('href="../../../dep_b/index.html"', content) + + def test_static_and_module_rewrites_agree_on_depth(self) -> None: + """The _static rewrite and the sibling-module rewrite must use the + same depth computation; a page linking to both should get the same + number of '../' for each. + """ + main = self.root / "main" + _write(main / "index.html", "") + + dep_a = self.root / "dep_a" + _write( + dep_a / "sub" / "page.html", + 'link', + ) + + dep_b = self.root / "dep_b" + _write(dep_b / "index.html", "") + + merge_html_dirs( + self.output, + main, + [("dep_a", dep_a), ("dep_b", dep_b)], + ) + + content = (self.output / "dep_a" / "sub" / "page.html").read_text() + self.assertIn('href="../../_static/theme.css"', content) + self.assertIn('href="../../dep_b/index.html"', content) + + def test_single_dependency_still_drops_own_static(self) -> None: + """Regression test: with exactly one dependency there are no sibling + modules to rewrite links for, but the dependency's own _static/ must + still be dropped in favor of the merged site's shared _static/ — this + must not depend on whether the sibling set happens to be non-empty. + """ + main = self.root / "main" + _write(main / "index.html", "") + _write(main / "_static" / "theme.css", "/* main theme */") + + dep_a = self.root / "dep_a" + _write(dep_a / "index.html", "") + _write(dep_a / "_static" / "theme.css", "/* dep theme, must be dropped */") + + merge_html_dirs(self.output, main, [("dep_a", dep_a)]) + + self.assertFalse((self.output / "dep_a" / "_static").exists()) + self.assertTrue((self.output / "_static" / "theme.css").exists()) + + def test_doctrees_are_never_published(self) -> None: + """Regression test: Sphinx's build cache (.doctrees) must never be + copied into the merged, published site — for the main module or for + any dependency. + """ + main = self.root / "main" + _write(main / "index.html", "") + _write(main / ".doctrees" / "index.doctree", "pickled-state") + + dep_a = self.root / "dep_a" + _write(dep_a / "index.html", "") + _write(dep_a / ".doctrees" / "index.doctree", "pickled-state") + + merge_html_dirs(self.output, main, [("dep_a", dep_a)]) + + self.assertFalse((self.output / ".doctrees").exists()) + self.assertFalse((self.output / "dep_a" / ".doctrees").exists()) + + def test_sources_dir_is_still_published(self) -> None: + """_sources/ backs Sphinx's "view page source" links and is real HTML + output, unlike .doctrees — it must survive the merge. + """ + main = self.root / "main" + _write(main / "index.html", "") + _write(main / "_sources" / "index.rst.txt", "Index\n=====\n") + + merge_html_dirs(self.output, main, []) + + self.assertTrue((self.output / "_sources" / "index.rst.txt").exists()) + + def test_extra_static_copied_after_main(self) -> None: + """extra_static entries must land under output/_static/ and are + copied after the main module, so they can override theme defaults. + """ + main = self.root / "main" + _write(main / "index.html", "") + _write(main / "_static" / "logo.svg", "main-logo") + + extra = self.root / "custom_logo.svg" + _write(extra, "custom-logo") + + merge_html_dirs( + self.output, + main, + [], + extra_static=[(str(extra), "logo.svg")], + ) + + self.assertEqual( + (self.output / "_static" / "logo.svg").read_text(), "custom-logo" + ) + + +if __name__ == "__main__": + unittest.main() From d7e0779e595e7aac5fb204057c5b94df9c87981d Mon Sep 17 00:00:00 2001 From: Jochen Hoenle Date: Mon, 27 Jul 2026 16:49:17 +0200 Subject: [PATCH 2/7] add user .bazelrc to imports --- .bazelrc | 3 +++ .gitignore | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/.bazelrc b/.bazelrc index 915faecc..1ae0e2af 100644 --- a/.bazelrc +++ b/.bazelrc @@ -42,3 +42,6 @@ coverage:coverage --@rules_rust//rust/settings:extra_rustc_flag=-Ccodegen-units= # Import AI checker custom configuration try-import %workspace%/.bazelrc.ai_checker + +# Import Bazel Userrc +try-import %workspace%/user.bazelrc diff --git a/.gitignore b/.gitignore index 51a160c7..e5bb7211 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ external .vscode/ .clwb/ +# Per-developer Bazel overrides (e.g. a local disk cache path), imported via +# `try-import %workspace%/user.bazelrc` in .bazelrc. +user.bazelrc + __pycache__ .ruff_cache/ coverage-html/ From 339857c1e699662e553770f55a9b99f866ac7d89 Mon Sep 17 00:00:00 2001 From: Jochen Hoenle Date: Mon, 27 Jul 2026 16:49:30 +0200 Subject: [PATCH 3/7] [rules score] rule hygiene in sphinx_module.bzl - Separate output namespace for the needs rule - Introduce Action mnemonics - Remove unused modules list - simplify tool setup --- .../rules_score/private/sphinx_module.bzl | 99 ++++++++++--------- 1 file changed, 52 insertions(+), 47 deletions(-) diff --git a/bazel/rules/rules_score/private/sphinx_module.bzl b/bazel/rules/rules_score/private/sphinx_module.bzl index 345ac9ab..16729bb4 100644 --- a/bazel/rules/rules_score/private/sphinx_module.bzl +++ b/bazel/rules/rules_score/private/sphinx_module.bzl @@ -89,12 +89,43 @@ sphinx_rule_attrs = dict( **VERBOSITY_ATTR ) +def _hermetic_tool_env(ctx): + """Compute the env vars that give conf.py hermetic access to plantuml/graphviz. + + Returns both the execroot-relative path (for `os.path.abspath()` at process + start, while cwd is still the execroot) and an analysis-time-stable + rlocation key (no exec-config hash) for diagnostic logging. See + docs/tooling_architecture.rst §"Hermetic tool path resolution". + """ + gv_short = ctx.executable._graphviz.short_path + graphviz_rloc = gv_short[3:] if gv_short.startswith("../") else ctx.workspace_name + "/" + gv_short + pl_short = ctx.executable._plantuml.short_path + plantuml_rloc = pl_short[3:] if pl_short.startswith("../") else ctx.workspace_name + "/" + pl_short + fta_metamodel_files = ctx.files._fta_metamodel + fta_metamodel_dir = fta_metamodel_files[0].dirname if fta_metamodel_files else "" + return fta_metamodel_files, { + "PLANTUML_BIN": ctx.executable._plantuml.path, + "PLANTUML_BIN_RLOC": plantuml_rloc, + "GRAPHVIZ_DOT": ctx.executable._graphviz.path, + "GRAPHVIZ_DOT_RLOC": graphviz_rloc, + "FTA_METAMODEL_DIR": fta_metamodel_dir, + } + +def _needs_output_prefix(name): + """Derive the `_score_needs` output prefix from its target name. + + `sphinx_module` always names this target `_needs`; stripping the + suffix (rather than replacing all occurrences of "_needs") keeps a module + named e.g. "foo_needs_bar" from producing a mismatched output path. + """ + return name.removesuffix("_needs") + # ====================================================================================== # Rule implementations # ====================================================================================== def _score_needs_impl(ctx): sphinx_toolchain = ctx.toolchains["//bazel/rules/rules_score:toolchain_type"].sphinxinfo - output_path = ctx.label.name.replace("_needs", "") + "/needs.json" + output_path = ctx.label.name + "/needs.json" needs_output = ctx.actions.declare_file(output_path) # Get config file (generate or use provided) @@ -123,27 +154,13 @@ def _score_needs_impl(ctx): get_log_level(ctx), ] - # Compute analysis-time-stable rlocation keys from short_path (no exec-config - # hash, no parent-directory walking). These are passed to conf.py for - # diagnostic logging and as the canonical Bazel identity of each tool. - # See docs/tooling_architecture.rst §"Hermetic tool path resolution". - _gv_short = ctx.executable._graphviz.short_path - _graphviz_rloc = _gv_short[3:] if _gv_short.startswith("../") else ctx.workspace_name + "/" + _gv_short - _pl_short = ctx.executable._plantuml.short_path - _plantuml_rloc = _pl_short[3:] if _pl_short.startswith("../") else ctx.workspace_name + "/" + _pl_short - fta_metamodel_files = ctx.files._fta_metamodel - fta_metamodel_dir = fta_metamodel_files[0].dirname if fta_metamodel_files else "" + fta_metamodel_files, action_env = _hermetic_tool_env(ctx) ctx.actions.run( inputs = needs_inputs + fta_metamodel_files, outputs = [needs_output], arguments = needs_args, - env = { - "PLANTUML_BIN": ctx.executable._plantuml.path, - "PLANTUML_BIN_RLOC": _plantuml_rloc, - "GRAPHVIZ_DOT": ctx.executable._graphviz.path, - "GRAPHVIZ_DOT_RLOC": _graphviz_rloc, - "FTA_METAMODEL_DIR": fta_metamodel_dir, - }, + env = action_env, + mnemonic = "SphinxNeedsBuild", progress_message = "Generating needs.json for: %s" % ctx.label.name, executable = sphinx_toolchain.sphinx.files_to_run.executable, tools = [ @@ -191,13 +208,11 @@ def _score_html_impl(ctx): args.add(expanded_opt) run_args.append(expanded_opt) - # Collect all transitive dependencies with deduplication - modules = [] sphinx_toolchain = ctx.toolchains["//bazel/rules/rules_score:toolchain_type"].sphinxinfo needs_external_needs = {} for dep in ctx.attr.needs: if SphinxNeedsInfo in dep: - dep_name = dep.label.name.replace("_needs", "") + dep_name = _needs_output_prefix(dep.label.name) needs_external_needs[dep.label.name] = { "base_url": dep_name, # Relative path to the subdirectory where dep HTML is copied "json_path": dep[SphinxNeedsInfo].needs_json_file.path, # Use direct file @@ -205,9 +220,6 @@ def _score_html_impl(ctx): "css_class": "", "version": "1.0", } - for dep in ctx.attr.deps: - if SphinxModuleInfo in dep: - modules.extend([dep[SphinxModuleInfo].html_dir]) needs_external_needs_json = ctx.actions.declare_file(ctx.label.name + "/needs_external_needs.json") ctx.actions.write( output = needs_external_needs_json, @@ -232,9 +244,6 @@ def _score_html_impl(ctx): sphinx_source_files.append(dest_file) return dest_file - for dep in ctx.attr.deps: - if SphinxModuleInfo in dep: - modules.extend([dep[SphinxModuleInfo].html_dir]) for t in ctx.attr.docs_library_deps: info = t[SphinxDocsLibraryInfo] for entry in info.transitive.to_list(): @@ -276,28 +285,14 @@ def _score_html_impl(ctx): # Use the hermetic graphviz wrapper that executes `/usr/bin/dot` inside the # docs_runtime sysroot via exec_in_sysroot. - # Compute analysis-time-stable rlocation keys from short_path (no exec-config - # hash, no parent-directory walking). See docs/tooling_architecture.rst - # §"Hermetic tool path resolution". - _gv_short = ctx.executable._graphviz.short_path - _graphviz_rloc = _gv_short[3:] if _gv_short.startswith("../") else ctx.workspace_name + "/" + _gv_short - _pl_short = ctx.executable._plantuml.short_path - _plantuml_rloc = _pl_short[3:] if _pl_short.startswith("../") else ctx.workspace_name + "/" + _pl_short - fta_metamodel_files = ctx.files._fta_metamodel - fta_metamodel_dir = fta_metamodel_files[0].dirname if fta_metamodel_files else "" - action_env = { - "PLANTUML_BIN": ctx.executable._plantuml.path, - "PLANTUML_BIN_RLOC": _plantuml_rloc, - "GRAPHVIZ_DOT": ctx.executable._graphviz.path, - "GRAPHVIZ_DOT_RLOC": _graphviz_rloc, - "FTA_METAMODEL_DIR": fta_metamodel_dir, - } + fta_metamodel_files, action_env = _hermetic_tool_env(ctx) ctx.actions.run( inputs = html_inputs + fta_metamodel_files, outputs = [sphinx_html_output], arguments = html_args + [args], env = action_env, + mnemonic = "SphinxHtmlBuild", progress_message = "Building HTML: %s" % ctx.label.name, executable = sphinx_toolchain.sphinx.files_to_run.executable, tools = [ @@ -346,6 +341,7 @@ def _score_html_impl(ctx): inputs = merge_inputs, outputs = [html_output], arguments = merge_args, + mnemonic = "SphinxHtmlMerge", progress_message = "Merging HTML with dependencies for %s" % ctx.label.name, executable = sphinx_toolchain.html_merge_tool.files_to_run.executable, tools = [sphinx_toolchain.html_merge_tool.files_to_run], @@ -355,6 +351,9 @@ def _score_html_impl(ctx): SphinxModuleInfo( html_dir = html_output, ), + OutputGroupInfo( + sphinx_sources = depset([config_file] + sphinx_source_files), + ), ] # ====================================================================================== @@ -413,16 +412,22 @@ def sphinx_module( **kwargs): """Build a Sphinx module with transitive HTML dependencies. This rule builds documentation modules into complete HTML sites with - transitive dependency collection. All dependencies are automatically - included in a modules/ subdirectory for intersphinx cross-referencing. + transitive dependency collection. Each dependency's HTML is copied into a + / subdirectory of the merged site for intersphinx/sphinx-needs + cross-referencing. Args: name: Name of the target srcs: List of source files (.rst, .md) with index file first index: Label to index.rst file - config: Label to conf.py configuration file (optional, will be auto-generated if not provided) deps: List of other sphinx_module targets this module depends on docs_library_deps: {type}`list[label]` of {obj}`sphinx_docs_library` targets. - sphinx: Label to sphinx build binary (default: :sphinx_build) + renamed_srcs: {type}`dict[label, str]` Doc source files that are renamed + on their way into the Sphinx source tree. + sphinx: Currently unused. The Sphinx binary is resolved from the + registered `//bazel/rules/rules_score:toolchain_type` toolchain, + not from this parameter. Kept for source compatibility with + existing callers; to use a different Sphinx binary, register a + different `sphinx_toolchain` instead. strip_prefix: {type}`str` A prefix to remove from the file paths of the source files. e.g., given `//sphinxdocs/docs:foo.md`, stripping `docs/` makes Sphinx see `foo.md` in its generated source directory. If not From e8869dd1438c42c2ed844da1bbded4f607788232 Mon Sep 17 00:00:00 2001 From: Jochen Hoenle Date: Mon, 27 Jul 2026 16:49:47 +0200 Subject: [PATCH 4/7] [rules score] rework sphinx toolchain - html_merge_tool as private attribute - sphinx parameter removed from public API - toolchain macro for easier integration --- bazel/rules/rules_score/BUILD | 30 +++-- .../rules_score/docs/integration_guide.rst | 111 ++++++++++++++---- .../rules/rules_score/docs/rule_reference.rst | 4 - .../rules_score/docs/tooling_architecture.rst | 9 +- .../some_other_library/bazel/toolchains/BUILD | 4 - .../private/dependable_element.bzl | 3 - .../rules_score/private/sphinx_module.bzl | 15 ++- bazel/rules/rules_score/sphinx_toolchain.bzl | 83 ++++++++++++- bazel/rules/rules_score/test/BUILD | 36 ++---- .../test/score_module_providers_test.bzl | 41 +++++++ 10 files changed, 254 insertions(+), 82 deletions(-) diff --git a/bazel/rules/rules_score/BUILD b/bazel/rules/rules_score/BUILD index 03894e65..5ec5eb08 100644 --- a/bazel/rules/rules_score/BUILD +++ b/bazel/rules/rules_score/BUILD @@ -215,17 +215,12 @@ sphinx_module( visibility = ["//visibility:public"], ) -# HTML merge tool - -py_binary( - name = "raw_build", - srcs = ["src/sphinx_wrapper.py"], - env = { - "SOURCE_DIRECTORY": "", - "DATA": "", - "ACTION": "check", - }, - main = "src/sphinx_wrapper.py", +# Dependency set for the default Sphinx build binary. Extracted into its own +# py_library so score_sphinx_toolchain() (sphinx_toolchain.bzl) can pull it in +# as a base for consumers who only want to add extensions, without +# copy-pasting this list. +py_library( + name = "sphinx_base_deps", visibility = ["//visibility:public"], deps = [ ":sphinx_module_ext", @@ -243,6 +238,19 @@ py_binary( ], ) +py_binary( + name = "raw_build", + srcs = ["src/sphinx_wrapper.py"], + env = { + "SOURCE_DIRECTORY": "", + "DATA": "", + "ACTION": "check", + }, + main = "src/sphinx_wrapper.py", + visibility = ["//visibility:public"], + deps = [":sphinx_base_deps"], +) + sphinx_toolchain( name = "default_toolchain", sphinx = ":raw_build", diff --git a/bazel/rules/rules_score/docs/integration_guide.rst b/bazel/rules/rules_score/docs/integration_guide.rst index 34cb1123..49b4a9f4 100644 --- a/bazel/rules/rules_score/docs/integration_guide.rst +++ b/bazel/rules/rules_score/docs/integration_guide.rst @@ -31,25 +31,103 @@ produce the final outputs. Toolchain Setup --------------- -The ``sphinx_toolchain`` rule configures the Sphinx build environment with -custom extensions. External modules must define and register their own toolchain -to use ``rules_score``. +``rules_score`` ships a default Sphinx toolchain, registered by ``score_tooling``'s +own ``MODULE.bazel``. **You need nothing to get a working Sphinx build** — plain +sphinx-needs, TRLC and PlantUML documentation builds out of the box for any +module that depends on ``score_tooling``, with no toolchain setup at all. + +Registering your own toolchain is only needed when you want **additional** +Sphinx extensions (e.g. Breathe for Doxygen, a custom theme) that the default +doesn't carry. Because Bazel resolves toolchains from the root module first, +a toolchain registered by your own ``MODULE.bazel`` always wins over +``score_tooling``'s default — no special opt-out required. + +Adding extensions +~~~~~~~~~~~~~~~~~ + +Use the ``score_sphinx_toolchain`` macro to extend the default dependency set +instead of reproducing it: **MODULE.bazel:** .. code-block:: python - # Add rules_score dependency bazel_dep(name = "score_tooling", version = "1.3.2") - # Add dependencies for custom Sphinx extensions (if needed) - bazel_dep(name = "score_docs_as_code", version = "3.0.1") + # Dependency providing your custom Sphinx extension + bazel_dep(name = "score_docs_as_code", version = "3.0.1", dev_dependency = True) - # Register your custom toolchain register_toolchains("//:my_toolchain") **BUILD:** +.. code-block:: python + + load("@score_tooling//bazel/rules/rules_score:sphinx_toolchain.bzl", "score_sphinx_toolchain") + + score_sphinx_toolchain( + name = "my_toolchain", + extra_deps = [ + "@score_docs_as_code//src/extensions/score_sphinx_bundle", + ], + ) + +This emits ``my_toolchain_binary`` (the Sphinx build binary: the shared +defaults plus ``extra_deps``), ``my_toolchain_info`` (the ``sphinx_toolchain`` +target) and ``my_toolchain`` (the ``toolchain()`` itself) — register the last +one. + +Diagnosing which toolchain won +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: shell + + bazel cquery --toolchain_resolution_debug='.*rules_score.*' //:my_target + +Look for the ``Selected ... toolchain`` line under +``@score_tooling//bazel/rules/rules_score:toolchain_type``. + +Replacing the dependency set entirely +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If your extensions conflict with the shared defaults — e.g. a different pip +hub pinning an incompatible version of a shared package — pass ``deps`` +instead of ``extra_deps`` to bypass ``sphinx_base_deps`` entirely and supply +the full list yourself: + +.. code-block:: python + + score_sphinx_toolchain( + name = "my_toolchain", + conf_template = "//:my_conf.template.py", + deps = [ + "@score_tooling//bazel/rules/rules_score:sphinx_module_ext", + "@my_pip_hub//sphinx:pkg", + "@my_pip_hub//my_custom_extension:pkg", + # ... full list; sphinx_base_deps is not included in this mode + ], + ) + +``extra_deps`` and ``deps`` are mutually exclusive — passing both fails at +load time. + +**score_sphinx_toolchain parameters:** + +- ``name`` — name of the emitted ``toolchain()`` target (mandatory) +- ``extra_deps`` — extend mode: extra deps added on top of the shared default set (optional; default: ``[]``) +- ``deps`` — replace mode: exact dep list, bypassing the shared defaults (optional; default: not set) +- ``extra_data`` — extra data files/targets for the Sphinx build binary (optional; default: ``[]``) +- ``conf_template`` — Label to ``conf.py`` template (optional; default: ``@score_tooling//bazel/rules/rules_score:templates/conf.template.py``) +- ``package_collisions`` — forwarded to the generated ``py_binary`` (optional; default: ``"warning"``) +- any other keyword argument (e.g. ``visibility``, ``exec_compatible_with``, ``target_compatible_with``) is forwarded to the ``toolchain()`` target + +Assembling a toolchain by hand +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For cases the macro doesn't fit, use the underlying ``sphinx_toolchain`` rule +directly — it only bundles a Sphinx binary and a ``conf.py`` template into the +provider the rules consume; you own the ``py_binary`` and ``toolchain()``: + .. code-block:: python load("@aspect_rules_py//py:defs.bzl", "py_binary") @@ -61,10 +139,8 @@ to use ``rules_score``. main = "@score_tooling//bazel/rules/rules_score:src/sphinx_wrapper.py", visibility = ["//visibility:public"], deps = [ - "@score_tooling//bazel/rules/rules_score:sphinx_module_ext", - "@score_docs_as_code//src:plantuml_for_python", + "@score_tooling//bazel/rules/rules_score:sphinx_base_deps", "@score_docs_as_code//src/extensions/score_sphinx_bundle", - # Add your custom Sphinx extensions here ], ) @@ -75,14 +151,6 @@ to use ``rules_score``. toolchain( name = "my_toolchain", - exec_compatible_with = [ - "@platforms//os:linux", - "@platforms//cpu:x86_64", - ], - target_compatible_with = [ - "@platforms//os:linux", - "@platforms//cpu:x86_64", - ], toolchain = ":score_sphinx_toolchain", toolchain_type = "@score_tooling//bazel/rules/rules_score:toolchain_type", visibility = ["//visibility:public"], @@ -90,9 +158,12 @@ to use ``rules_score``. **sphinx_toolchain parameters:** -- ``sphinx`` — Label to the Sphinx build binary (mandatory) +- ``sphinx`` — Label to the Sphinx build binary (optional; default: ``@score_tooling//bazel/rules/rules_score:raw_build``) - ``conf_template`` — Label to ``conf.py`` template (optional; default: ``@score_tooling//bazel/rules/rules_score:templates/conf.template.py``) -- ``html_merge_tool`` — Label to HTML merge tool (optional; default: ``@score_tooling//bazel/rules/rules_score:sphinx_html_merge``) + +The HTML-merge tool used to combine dependency HTML trees is a fixed, +private implementation detail of ``sphinx_module`` — it is not part of +``SphinxInfo`` and cannot be overridden. Cross-module dependencies diff --git a/bazel/rules/rules_score/docs/rule_reference.rst b/bazel/rules/rules_score/docs/rule_reference.rst index 9a4ca2fd..c388c85e 100644 --- a/bazel/rules/rules_score/docs/rule_reference.rst +++ b/bazel/rules/rules_score/docs/rule_reference.rst @@ -59,10 +59,6 @@ cross-module dependencies and automatic HTML merging. - label list - no - Other ``sphinx_module`` or ``dependable_element`` targets for cross-referencing and HTML merging (default ``[]``) - * - ``sphinx`` - - label - - no - - Override the Sphinx binary (default: toolchain-provided binary) * - ``testonly`` - bool - no diff --git a/bazel/rules/rules_score/docs/tooling_architecture.rst b/bazel/rules/rules_score/docs/tooling_architecture.rst index 39e5bf7f..5e02e069 100644 --- a/bazel/rules/rules_score/docs/tooling_architecture.rst +++ b/bazel/rules/rules_score/docs/tooling_architecture.rst @@ -140,10 +140,11 @@ are rendered under :doc:`tool_reference/index`. against the *expected* architecture (static/dynamic ``.fbs.bin`` from ``architectural_design``). Fails the build on a mismatch. * - **Sphinx (Docs)** - - ``score_build`` (``src/sphinx_wrapper.py``), - ``html_merge_tool`` (``src/sphinx_html_merge.py``), - ``sphinx_module_ext``, - ``trlc`` Sphinx extension (``@trlc``) + - Sphinx build binary (``src/sphinx_wrapper.py``, resolved via the + registered ``sphinx_toolchain``), HTML merge tool + (``src/sphinx_html_merge.py``, a private rule detail — not toolchain- + configurable), ``sphinx_module_ext``, ``trlc`` Sphinx extension + (``@trlc``) - ``sphinx_module``, ``dependable_element`` - Two-phase documentation build: **phase 1** (``_needs`` target) runs Sphinx with ``--builder needs`` to emit ``needs.json`` containing diff --git a/bazel/rules/rules_score/examples/some_other_library/bazel/toolchains/BUILD b/bazel/rules/rules_score/examples/some_other_library/bazel/toolchains/BUILD index e60f56fb..a82370e1 100644 --- a/bazel/rules/rules_score/examples/some_other_library/bazel/toolchains/BUILD +++ b/bazel/rules/rules_score/examples/some_other_library/bazel/toolchains/BUILD @@ -11,10 +11,6 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -load( - "@score_tooling//bazel/rules/rules_score:sphinx_toolchain.bzl", - "sphinx_toolchain", -) load( "@score_tooling//cpp/libclang:libclang_toolchain.bzl", "libclang_toolchain", diff --git a/bazel/rules/rules_score/private/dependable_element.bzl b/bazel/rules/rules_score/private/dependable_element.bzl index 3605a32b..152ac82d 100644 --- a/bazel/rules/rules_score/private/dependable_element.bzl +++ b/bazel/rules/rules_score/private/dependable_element.bzl @@ -1527,7 +1527,6 @@ def dependable_element( deps = [], aou_forwarding = None, maturity = "release", - sphinx = Label("//bazel/rules/rules_score:score_build"), testonly = True, docs_prefix = "docs/sphinx/", **kwargs): @@ -1568,7 +1567,6 @@ def dependable_element( aou_forwarding: Optional label to a YAML file listing received AoU IDs to further-forward to this element's own dependees. Only needed for chain-forwarding received AoUs that this element cannot handle. - sphinx: Label to sphinx build binary. Default: //bazel/rules/rules_score:score_build testonly: If True, only testonly targets can depend on this target. docs_prefix: Prefix under which the generated `_rst` sphinx_docs_library exposes its RST sources, so that an external Sphinx build can embed it via @@ -1619,7 +1617,6 @@ def dependable_element( srcs = [":" + name + "_index"], index = ":" + name + "_index", deps = [d + _DOC_TARGET_SUFFIX for d in deps], - sphinx = sphinx, testonly = testonly, **kwargs ) diff --git a/bazel/rules/rules_score/private/sphinx_module.bzl b/bazel/rules/rules_score/private/sphinx_module.bzl index 16729bb4..80359d83 100644 --- a/bazel/rules/rules_score/private/sphinx_module.bzl +++ b/bazel/rules/rules_score/private/sphinx_module.bzl @@ -343,8 +343,8 @@ def _score_html_impl(ctx): arguments = merge_args, mnemonic = "SphinxHtmlMerge", progress_message = "Merging HTML with dependencies for %s" % ctx.label.name, - executable = sphinx_toolchain.html_merge_tool.files_to_run.executable, - tools = [sphinx_toolchain.html_merge_tool.files_to_run], + executable = ctx.executable._html_merge_tool, + tools = [ctx.attr._html_merge_tool.files_to_run], ) return [ DefaultInfo(files = depset([html_output])), @@ -368,6 +368,11 @@ _score_html = rule( implementation = _score_html_impl, attrs = dict( sphinx_rule_attrs, + _html_merge_tool = attr.label( + default = Label("//bazel/rules/rules_score:sphinx_html_merge"), + executable = True, + cfg = "exec", + ), strip_prefix = attr.string(doc = "Prefix to remove from input file paths."), docs_library_deps = attr.label_list( doc = "List of sphinx_docs_library targets to include as source files with prefix/strip_prefix handling.", @@ -404,7 +409,6 @@ def sphinx_module( deps = [], docs_library_deps = [], renamed_srcs = {}, - sphinx = Label("//bazel/rules/rules_score:score_build"), strip_prefix = "", extra_opts = [], extra_opts_targets = [], @@ -423,11 +427,6 @@ def sphinx_module( docs_library_deps: {type}`list[label]` of {obj}`sphinx_docs_library` targets. renamed_srcs: {type}`dict[label, str]` Doc source files that are renamed on their way into the Sphinx source tree. - sphinx: Currently unused. The Sphinx binary is resolved from the - registered `//bazel/rules/rules_score:toolchain_type` toolchain, - not from this parameter. Kept for source compatibility with - existing callers; to use a different Sphinx binary, register a - different `sphinx_toolchain` instead. strip_prefix: {type}`str` A prefix to remove from the file paths of the source files. e.g., given `//sphinxdocs/docs:foo.md`, stripping `docs/` makes Sphinx see `foo.md` in its generated source directory. If not diff --git a/bazel/rules/rules_score/sphinx_toolchain.bzl b/bazel/rules/rules_score/sphinx_toolchain.bzl index 1b679066..4fdd6cce 100644 --- a/bazel/rules/rules_score/sphinx_toolchain.bzl +++ b/bazel/rules/rules_score/sphinx_toolchain.bzl @@ -10,13 +10,16 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* +"""Sphinx toolchain: the SphinxInfo provider, the sphinx_toolchain rule, and +the score_sphinx_toolchain() convenience macro for consumers.""" + +load("@aspect_rules_py//py:defs.bzl", "py_binary") SphinxInfo = provider( doc = "Provider for Sphinx Toolchain", fields = { "sphinx": "sphinx executable", "conf_template": "template for conf.py", - "html_merge_tool": "tool to merge html files", }, ) @@ -25,7 +28,6 @@ def _sphinx_toolchain_impl(ctx): sphinxinfo = SphinxInfo( sphinx = ctx.attr.sphinx, conf_template = ctx.attr.conf_template, - html_merge_tool = ctx.attr.html_merge_tool, ), ) return [toolchain_info] @@ -40,8 +42,79 @@ sphinx_toolchain = rule( allow_single_file = True, default = Label("//bazel/rules/rules_score:templates/conf.template.py"), ), - "html_merge_tool": attr.label( - default = Label("//bazel/rules/rules_score:sphinx_html_merge"), - ), }, ) + +def score_sphinx_toolchain( + name, + extra_deps = [], + deps = None, + extra_data = [], + conf_template = None, + package_collisions = "warning", + **kwargs): + """Declares a sphinx_toolchain, reusing score_tooling's default build binary. + + Emits `_binary` (the Sphinx build py_binary), `_info` (the + `sphinx_toolchain` target), and `` (the `toolchain()` itself). + Register the result yourself so the root module's registration takes + precedence over score_tooling's own default: + + register_toolchains("//:") + + Args: + name: Name of the emitted `toolchain()` target. + extra_deps: Extend mode. Extra Python deps added on top of + `@score_tooling//bazel/rules/rules_score:sphinx_base_deps` — the + same deps used by score_tooling's own default toolchain. Mutually + exclusive with `deps`. + deps: Replace mode. Exact list of Python deps for the Sphinx build + binary, bypassing `sphinx_base_deps` entirely. Use this when a + package version conflicts with the shared deps (e.g. a separate + pip hub). Mutually exclusive with `extra_deps`. + extra_data: Extra data files/targets for the Sphinx build binary. + conf_template: Label of a conf.py template. Defaults to score_tooling's + generic template if not given. + package_collisions: Forwarded to the generated py_binary. + **kwargs: Forwarded to the `toolchain()` target (e.g. `visibility`, + `exec_compatible_with`, `target_compatible_with`). + """ + if deps != None and extra_deps: + fail("score_sphinx_toolchain: pass either `deps` (replace mode) or " + + "`extra_deps` (extend mode) for target '%s', not both" % name) + + binary_deps = deps if deps != None else ( + ["@score_tooling//bazel/rules/rules_score:sphinx_base_deps"] + extra_deps + ) + + py_binary( + name = name + "_binary", + srcs = ["@score_tooling//bazel/rules/rules_score:src/sphinx_wrapper.py"], + main = "@score_tooling//bazel/rules/rules_score:src/sphinx_wrapper.py", + data = extra_data, + env = { + "SOURCE_DIRECTORY": "", + "DATA": "", + "ACTION": "check", + }, + package_collisions = package_collisions, + visibility = ["//visibility:private"], + deps = binary_deps, + ) + + toolchain_kwargs = {} + if conf_template != None: + toolchain_kwargs["conf_template"] = conf_template + + sphinx_toolchain( + name = name + "_info", + sphinx = ":" + name + "_binary", + **toolchain_kwargs + ) + + native.toolchain( + name = name, + toolchain = ":" + name + "_info", + toolchain_type = "@score_tooling//bazel/rules/rules_score:toolchain_type", + **kwargs + ) diff --git a/bazel/rules/rules_score/test/BUILD b/bazel/rules/rules_score/test/BUILD index c0ba0cbd..b599d900 100644 --- a/bazel/rules/rules_score/test/BUILD +++ b/bazel/rules/rules_score/test/BUILD @@ -11,7 +11,6 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -load("@aspect_rules_py//py:defs.bzl", "py_binary") load("@pip_tooling_test//:requirements.bzl", "requirement") load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_library", "cc_test") load( @@ -29,7 +28,7 @@ load( "unit", "unit_design", ) -load("@score_tooling//bazel/rules/rules_score:sphinx_toolchain.bzl", "sphinx_toolchain") +load("@score_tooling//bazel/rules/rules_score:sphinx_toolchain.bzl", "score_sphinx_toolchain") load("@score_tooling//cpp/libclang:libclang_toolchain.bzl", "libclang_toolchain") load("@trlc//:trlc.bzl", "trlc_requirements", "trlc_requirements_test") load( @@ -71,6 +70,7 @@ load( "score_needs_info_fields_test", "score_needs_transitive_collection_test", "score_needs_with_deps_test", + "score_toolchain_override_test", "sphinx_module_info_fields_test", "sphinx_module_providers_test_suite", "two_phase_html_second_test", @@ -107,12 +107,10 @@ package(default_visibility = ["//visibility:public"]) exports_files(["template/conf.template.py"]) -py_binary( - name = "score_build", - srcs = ["@score_tooling//bazel/rules/rules_score:src/sphinx_wrapper.py"], - main = "@score_tooling//bazel/rules/rules_score:src/sphinx_wrapper.py", +score_sphinx_toolchain( + name = "score_toolchain", + conf_template = "//:template/conf.template.py", package_collisions = "warning", - visibility = ["//visibility:public"], deps = [ "@score_docs_as_code//src:plantuml_for_python", "@score_docs_as_code//src/extensions/score_sphinx_bundle", @@ -122,19 +120,6 @@ py_binary( ], ) -sphinx_toolchain( - name = "score_sphinx_toolchain", - conf_template = "//:template/conf.template.py", - html_merge_tool = "@score_tooling//bazel/rules/rules_score:sphinx_html_merge", - sphinx = ":score_build", -) - -toolchain( - name = "score_toolchain", - toolchain = ":score_sphinx_toolchain", - toolchain_type = "@score_tooling//bazel/rules/rules_score:toolchain_type", -) - # ============================================================================ # libclang Toolchain — this integrating repo supplies its own libclang/C++ # toolchain for the score libclang-based C/C++ parser. @@ -163,14 +148,12 @@ sphinx_module( name = "module_c_lib", srcs = glob(["fixtures/module_c/*.rst"]), index = "fixtures/module_c/index.rst", - sphinx = "@score_tooling//bazel/rules/rules_score:score_build", ) sphinx_module( name = "module_b_lib", srcs = glob(["fixtures/module_b/*.rst"]), index = "fixtures/module_b/index.rst", - sphinx = "@score_tooling//bazel/rules/rules_score:score_build", deps = [":module_c_lib"], ) @@ -178,7 +161,6 @@ sphinx_module( name = "module_a_lib", srcs = glob(["fixtures/module_a/*.rst"]), index = "fixtures/module_a/index.rst", - sphinx = "@score_tooling//bazel/rules/rules_score:score_build", deps = [ ":module_b_lib", ":module_c_lib", @@ -623,6 +605,14 @@ score_needs_with_deps_test( target_under_test = ":module_a_lib_needs", ) +# Toolchain resolution: this module's own //:score_toolchain must win over +# score_tooling's sphinx_default_toolchain (see MODULE.bazel's +# register_toolchains("//:score_toolchain")). +score_toolchain_override_test( + name = "score_toolchain_override_test", + target_under_test = ":module_c_lib", +) + deps_html_merging_test( name = "deps_html_merging_test", target_under_test = ":module_a_lib", diff --git a/bazel/rules/rules_score/test/score_module_providers_test.bzl b/bazel/rules/rules_score/test/score_module_providers_test.bzl index 5cad7bc3..8b117d89 100644 --- a/bazel/rules/rules_score/test/score_module_providers_test.bzl +++ b/bazel/rules/rules_score/test/score_module_providers_test.bzl @@ -295,6 +295,44 @@ def _deps_needs_collection_test_impl(ctx): deps_needs_collection_test = analysistest.make(_deps_needs_collection_test_impl) +# ============================================================================ +# Toolchain Resolution Tests +# ============================================================================ + +def _score_toolchain_override_test_impl(ctx): + """Test that this module's own registered toolchain wins over the default. + + This module (rules_score/test/MODULE.bazel) registers `//:score_toolchain` + as a root-module toolchain, which must take precedence over score_tooling's + default registration. Verified by inspecting the executable of the + SphinxHtmlBuild action rather than a provider field, since which toolchain + won is not itself exposed as a provider. + """ + env = analysistest.begin(ctx) + + actions = analysistest.target_actions(env) + html_build_actions = [a for a in actions if a.mnemonic == "SphinxHtmlBuild"] + asserts.equals(env, 1, len(html_build_actions), "Expected exactly one SphinxHtmlBuild action") + + executable_path = html_build_actions[0].argv[0] + asserts.true( + env, + "score_toolchain_binary" in executable_path, + "Expected the locally-registered //:score_toolchain to be selected " + + "(executable path should contain 'score_toolchain_binary'), got: %s" % executable_path, + ) + asserts.false( + env, + "raw_build" in executable_path, + "score_tooling's default toolchain binary ('raw_build') should not " + + "have been selected when this module registers its own " + + "//:score_toolchain, got: %s" % executable_path, + ) + + return analysistest.end(env) + +score_toolchain_override_test = analysistest.make(_score_toolchain_override_test_impl) + # ============================================================================ # Test Suite # ============================================================================ @@ -332,5 +370,8 @@ def sphinx_module_providers_test_suite(name): # Dependency tests ":deps_html_merging_test", ":deps_needs_collection_test", + + # Toolchain resolution tests + ":score_toolchain_override_test", ], ) From fdf0f2d9930ce0541f521f05cfe23b39642692bd Mon Sep 17 00:00:00 2001 From: Jochen Hoenle Date: Mon, 27 Jul 2026 16:59:35 +0200 Subject: [PATCH 5/7] [rules score] add more unit tests --- .../test/test_sphinx_html_merge.py | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/bazel/rules/rules_score/test/test_sphinx_html_merge.py b/bazel/rules/rules_score/test/test_sphinx_html_merge.py index 4295570b..9592818f 100644 --- a/bazel/rules/rules_score/test/test_sphinx_html_merge.py +++ b/bazel/rules/rules_score/test/test_sphinx_html_merge.py @@ -159,6 +159,62 @@ def test_extra_static_copied_after_main(self) -> None: (self.output / "_static" / "logo.svg").read_text(), "custom-logo" ) + def test_nested_sibling_module_copy_is_skipped(self) -> None: + """Regression test: if a dependency's own HTML tree happens to contain + a subdirectory named after a sibling module (e.g. because that + dependency was itself built with sphinx_html_merge and already + embeds the sibling), that nested copy must be skipped rather than + duplicated into the merged output - the sibling is placed once, at + the site root, by its own top-level merge entry. + """ + main = self.root / "main" + _write(main / "index.html", "") + + dep_a = self.root / "dep_a" + _write(dep_a / "index.html", "") + # dep_a already contains its own nested (stale) copy of dep_b. + _write(dep_a / "dep_b" / "index.html", "stale nested copy") + + dep_b = self.root / "dep_b" + _write(dep_b / "index.html", "canonical dep_b") + + merge_html_dirs( + self.output, + main, + [("dep_a", dep_a), ("dep_b", dep_b)], + ) + + # The nested copy under dep_a/dep_b/ must not have been copied. + self.assertFalse((self.output / "dep_a" / "dep_b").exists()) + # The canonical dep_b, copied from its own top-level entry, is intact. + self.assertEqual( + (self.output / "dep_b" / "index.html").read_text(), + "canonical dep_b", + ) + + def test_main_page_links_to_dependency_are_not_rewritten(self) -> None: + """Documents a current limitation: only dependency HTML + (is_dependency=True) gets its links rewritten. A main-module page + that links directly to a dependency keeps an unqualified href + regardless of how deep the main page is nested, so such links must + already be authored relative to the merged site root (e.g. via + sphinx-needs external_needs base_url) rather than as a plain relative + path - the merge step will not fix them up. + """ + main = self.root / "main" + _write( + main / "guide" / "page.html", + 'link', + ) + + dep_a = self.root / "dep_a" + _write(dep_a / "index.html", "") + + merge_html_dirs(self.output, main, [("dep_a", dep_a)]) + + content = (self.output / "guide" / "page.html").read_text() + self.assertIn('href="dep_a/index.html">link', content) + if __name__ == "__main__": unittest.main() From ab4b1d68cc5a7a3f0cd2f89b52bdea5abc3944a8 Mon Sep 17 00:00:00 2001 From: Jochen Hoenle Date: Mon, 27 Jul 2026 17:31:19 +0200 Subject: [PATCH 6/7] [docs toolchain] provide reusable building blocks --- bazel/rules/rules_score/BUILD | 15 ++ .../rules_score/src/sphinx_conf_helpers.py | 252 ++++++++++++++++++ .../rules_score/templates/conf.template.py | 157 +++-------- 3 files changed, 298 insertions(+), 126 deletions(-) create mode 100644 bazel/rules/rules_score/src/sphinx_conf_helpers.py diff --git a/bazel/rules/rules_score/BUILD b/bazel/rules/rules_score/BUILD index 5ec5eb08..4a5e2c17 100644 --- a/bazel/rules/rules_score/BUILD +++ b/bazel/rules/rules_score/BUILD @@ -148,6 +148,20 @@ py_library( ], ) +# Shared conf.py building blocks (hermetic plantuml/graphviz/FTA resolution, +# metamodel needs schema loading). Used by the default conf.template.py and +# available to any consumer who supplies their own conf_template. +py_library( + name = "sphinx_conf_helpers", + srcs = ["src/sphinx_conf_helpers.py"], + imports = ["src"], + visibility = ["//visibility:public"], + deps = [ + ":bazel_sphinx_needs", + requirement("sphinx"), + ], +) + # --------------------------------------------------------------------------- # Tool reference pages: each README.md is renamed to .md via # renamed_srcs on sphinx_module. No sphinx_docs_library needed for renames — @@ -223,6 +237,7 @@ py_library( name = "sphinx_base_deps", visibility = ["//visibility:public"], deps = [ + ":sphinx_conf_helpers", ":sphinx_module_ext", "@lobster//sphinx_lobster:sphinx_lobster_builder", "@score_tooling//plantuml/sphinx/clickable_plantuml", diff --git a/bazel/rules/rules_score/src/sphinx_conf_helpers.py b/bazel/rules/rules_score/src/sphinx_conf_helpers.py new file mode 100644 index 00000000..f7641391 --- /dev/null +++ b/bazel/rules/rules_score/src/sphinx_conf_helpers.py @@ -0,0 +1,252 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Shared conf.py building blocks for score_tooling Sphinx consumers. + +Every module/repo that supplies its own `conf_template` to +`score_sphinx_toolchain()` (rather than using score_tooling's default one) +currently has to re-derive several non-obvious pieces of Bazel/Sphinx +plumbing from scratch. This module consolidates them so a custom conf.py can +import it and only set what it actually wants to customize (extensions, +theme, etc.): + +- Hermetic PlantUML / Graphviz / FTA-metamodel resolution. The env vars this + reads (PLANTUML_BIN, GRAPHVIZ_DOT, FTA_METAMODEL_DIR) are set + unconditionally by `_hermetic_tool_env()` in sphinx_module.bzl for every + SphinxNeedsBuild/SphinxHtmlBuild action, regardless of which toolchain or + conf_template is in effect - so this works for any consumer without extra + Bazel wiring. See docs/tooling_architecture.rst + §"Hermetic tool path resolution". +- sphinx-needs external-needs loading, re-exported from bazel_sphinx_needs + rather than re-derived (see that module's docstring for the JSON format). +- The sphinx-needs type/option/link schema loaded from the upstream S-CORE + metamodel, so custom conf.py files can't silently drift from it. +- A few small shared constants (exclude patterns, suppressed warnings, MyST + extensions) that encode otherwise-easy-to-miss Bazel-sandbox behavior. +""" + +import logging +import os +from typing import Any, Dict, List, Optional + +from bazel_sphinx_needs import ( + load_external_needs, + log_config_info, + setup_sphinx_extension, +) + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Shared constants +# --------------------------------------------------------------------------- + +# Bazel-sandbox artifacts that must never be treated as doc sources. +DEFAULT_EXCLUDE_PATTERNS: List[str] = [ + "bazel-*", + ".venv*", + # Design-fragment subdirectories (e.g. units/unit_1_design/) are included + # via '.. include::' directives and must not be treated as standalone + # pages. + "**/*_design", +] + +# The needs builder phase runs against only the static docs/ checkout; +# generated files (trlc_rst outputs, renamed_srcs, docs_library_deps) live in +# bazel-out/ and are invisible to it, producing cosmetic toc.not_readable +# warnings. Safe to suppress: the needs builder (sphinx-needs NeedsBuilder) +# only captures `.. need::` directives, so needs.json content is unaffected +# by missing toctree targets. The HTML phase never hits this warning, since +# it relocates every file into one staging directory first. +DEFAULT_SUPPRESS_WARNINGS: List[str] = ["toc.not_readable"] + +DEFAULT_MYST_ENABLE_EXTENSIONS: List[str] = ["colon_fence"] + + +# --------------------------------------------------------------------------- +# Hermetic tool resolution: PlantUML, Graphviz, FTA metamodel include path +# --------------------------------------------------------------------------- + + +def resolve_graphviz_dot(required: bool = True) -> Optional[str]: + """Resolve the hermetic Graphviz `dot` wrapper path from GRAPHVIZ_DOT. + + Args: + required: If True (default), raise ValueError when GRAPHVIZ_DOT is + unset - matches the default template's fail-loud contract, so a + missing hermetic tool is a build error, not a silently degraded + diagram. Pass False only if the caller has a real fallback + rendering path and genuinely wants to continue without it. + """ + path = os.environ.get("GRAPHVIZ_DOT") + if not path: + msg = ( + "GRAPHVIZ_DOT environment variable is not set. It must point at " + "the //third_party/docs_runtime:dot hermetic wrapper and is " + "normally provided by the sphinx_module Bazel rule. If you are " + "invoking Sphinx outside that rule, set GRAPHVIZ_DOT to the " + "hermetic dot wrapper path." + ) + if required: + raise ValueError(msg) + logger.warning(msg) + return None + resolved = os.path.abspath(path) + logger.debug( + "graphviz dot resolved: %s (rloc: %s)", + resolved, + os.environ.get("GRAPHVIZ_DOT_RLOC", "n/a"), + ) + return resolved + + +def resolve_fta_metamodel_dir() -> str: + """Resolve the directory containing fta_metamodel.puml from + FTA_METAMODEL_DIR, or "" (with a warning) if it isn't set. + """ + raw = os.environ.get("FTA_METAMODEL_DIR", "") + if not raw: + logger.warning( + "FTA_METAMODEL_DIR is not set; FTA diagrams using " + "!include fta_metamodel.puml will fail to render." + ) + return "" + resolved = os.path.abspath(raw) + logger.debug("fta_metamodel include path: %s", resolved) + return resolved + + +def resolve_plantuml_command( + required: bool = True, graphviz_dot_path: Optional[str] = None +) -> Optional[str]: + """Build the full `plantuml` conf.py setting. + + Combines PLANTUML_BIN with the FTA metamodel include path and the + hermetic Graphviz dot, exactly matching the default template's + configuration. Pair this with `plantuml_output_format = "svg_obj"` in + conf.py (a fixed literal, not tool-path dependent, so it isn't derived + here). + + Args: + required: See `resolve_graphviz_dot`. Also governs whether a missing + PLANTUML_BIN raises or warns-and-returns-None. + graphviz_dot_path: If the caller already resolved GRAPHVIZ_DOT (e.g. + to also set conf.py's own `graphviz_dot` for sphinx.ext.graphviz), + pass it here to reuse that value instead of re-resolving (and + re-logging) it. If None (default), resolves it internally. + """ + plantuml_bin = os.environ.get("PLANTUML_BIN") + if not plantuml_bin: + msg = ( + "PLANTUML_BIN environment variable is not set. It must point at " + "the //third_party/plantuml:plantuml launcher and is normally " + "provided by the sphinx_module Bazel rule. If you are invoking " + "Sphinx outside that rule, set PLANTUML_BIN to the plantuml " + "binary path." + ) + if required: + raise ValueError(msg) + logger.warning(msg) + return None + + plantuml_path = os.path.abspath(plantuml_bin) + logger.debug( + "plantuml resolved: %s (rloc: %s)", + plantuml_path, + os.environ.get("PLANTUML_BIN_RLOC", "n/a"), + ) + + fta_dir = resolve_fta_metamodel_dir() + include_flag = ( + " --jvm_flag=-Dplantuml.include.path=%s" % fta_dir if fta_dir else "" + ) + + dot_path = ( + graphviz_dot_path + if graphviz_dot_path is not None + else resolve_graphviz_dot(required=required) + ) + layout_flag = " -graphvizdot %s" % dot_path if dot_path else "" + + return "%s%s%s" % (plantuml_path, include_flag, layout_flag) + + +# --------------------------------------------------------------------------- +# sphinx-needs schema, loaded from the upstream S-CORE metamodel +# --------------------------------------------------------------------------- + + +def load_metamodel_needs_schema() -> Dict[str, Any]: + """Load the needs_types/needs_extra_options/needs_extra_links/ + needs_id_regex schema from score_metamodel, so a custom conf.py can't + silently drift from the upstream S-CORE process description. + + score_metamodel is intentionally NOT loaded as a Sphinx extension here + (i.e. via `extensions = [..., "score_metamodel"]`) - doing so registers + validation hooks (mandatory options, prohibited words, link pattern + checks) that do bare `from score_metamodel import ...` imports requiring + src/extensions/ on sys.path, which is only set up by aspect_rules_py's + venv mechanism and not guaranteed here. Calling + load_metamodel_data() directly instead gets only the type/option/regex + data, without activating those hooks. + + Returns a dict with keys `needs_types`, `needs_extra_options`, + `needs_extra_links`, `needs_id_regex` - assign each directly in conf.py: + + _schema = load_metamodel_needs_schema() + needs_types = _schema["needs_types"] + needs_extra_options = _schema["needs_extra_options"] + needs_extra_links = _schema["needs_extra_links"] + needs_id_regex = _schema["needs_id_regex"] + + Falls back to an empty schema (with a warning) if score_metamodel isn't + a dependency of the calling conf.py's toolchain binary. + """ + fallback_id_regex = "^[A-Za-z0-9_-]{6,}" + try: + from src.extensions.score_metamodel.yaml_parser import ( + load_metamodel_data as _load_metamodel_data, + ) + + metamodel = _load_metamodel_data() + return { + "needs_types": metamodel.needs_types, + "needs_extra_options": metamodel.needs_extra_options, + "needs_extra_links": metamodel.needs_extra_links, + "needs_id_regex": fallback_id_regex, + } + except ImportError: + logger.warning( + "score_metamodel not available; using minimal needs_types fallback" + ) + return { + "needs_types": [], + "needs_extra_options": [], + "needs_extra_links": [], + "needs_id_regex": fallback_id_regex, + } + + +__all__ = [ + "DEFAULT_EXCLUDE_PATTERNS", + "DEFAULT_SUPPRESS_WARNINGS", + "DEFAULT_MYST_ENABLE_EXTENSIONS", + "resolve_graphviz_dot", + "resolve_fta_metamodel_dir", + "resolve_plantuml_command", + "load_metamodel_needs_schema", + # Re-exported so consumers only need one import for both needs-loading + # and hermetic-tool concerns. + "load_external_needs", + "log_config_info", + "setup_sphinx_extension", +] diff --git a/bazel/rules/rules_score/templates/conf.template.py b/bazel/rules/rules_score/templates/conf.template.py index 998e5509..2820d453 100644 --- a/bazel/rules/rules_score/templates/conf.template.py +++ b/bazel/rules/rules_score/templates/conf.template.py @@ -24,6 +24,8 @@ from sphinx.util import logging +import sphinx_conf_helpers + # Create a logger with the Sphinx namespace logger = logging.getLogger(__name__) @@ -57,16 +59,12 @@ ] # MyST parser extensions -myst_enable_extensions = ["colon_fence"] - -# Exclude patterns for Bazel builds -exclude_patterns = [ - "bazel-*", - ".venv*", - # Design-fragment subdirectories (e.g. units/unit_1_design/) are included - # via '.. include::' directives and must not be treated as standalone pages. - "**/*_design", -] +myst_enable_extensions = sphinx_conf_helpers.DEFAULT_MYST_ENABLE_EXTENSIONS + +# Exclude patterns for Bazel builds. Design-fragment subdirectories (e.g. +# units/unit_1_design/) are included via '.. include::' directives and must +# not be treated as standalone pages -- see DEFAULT_EXCLUDE_PATTERNS. +exclude_patterns = sphinx_conf_helpers.DEFAULT_EXCLUDE_PATTERNS # Suppress toctree warnings for documents absent from the needs builder's source # tree. The needs builder runs against only the static docs/ checkout; generated @@ -77,7 +75,7 @@ # directives, so needs.json content is unaffected by missing files. # This suppression is safe for the HTML phase because that phase relocates every # file into a unified staging directory, so it never encounters toc.not_readable. -suppress_warnings = ["toc.not_readable"] +suppress_warnings = sphinx_conf_helpers.DEFAULT_SUPPRESS_WARNINGS # Enable markdown rendering source_suffix = { @@ -101,127 +99,34 @@ # bare "from score_metamodel import ..." imports, which require src/extensions/ # to be on sys.path. That path is only set up by aspect_rules_py's venv # mechanism, not by the rules_python setup used here. -# Instead, we call load_metamodel_data() directly from yaml_parser — the -# score_docs_as_code+ repo root IS on sys.path, so the import resolves — and -# we get only the type/option/regex data without activating the validation hooks. -try: - from src.extensions.score_metamodel.yaml_parser import ( - load_metamodel_data as _load_metamodel_data, - ) - - _metamodel = _load_metamodel_data() - needs_types = _metamodel.needs_types - needs_extra_options = _metamodel.needs_extra_options - needs_extra_links = _metamodel.needs_extra_links - needs_id_regex = "^[A-Za-z0-9_-]{6,}" -except ImportError: - logger.warning("score_metamodel not available; using minimal needs_types fallback") - needs_types = [] - needs_extra_options = [] - needs_extra_links = [] - needs_id_regex = "^[A-Za-z0-9_-]{6,}" +# Instead, sphinx_conf_helpers calls load_metamodel_data() directly from +# yaml_parser — the score_docs_as_code+ repo root IS on sys.path, so the +# import resolves — and we get only the type/option/regex data without +# activating the validation hooks. +_needs_schema = sphinx_conf_helpers.load_metamodel_needs_schema() +needs_types = _needs_schema["needs_types"] +needs_extra_options = _needs_schema["needs_extra_options"] +needs_extra_links = _needs_schema["needs_extra_links"] +needs_id_regex = _needs_schema["needs_id_regex"] # --------------------------------------------------------------------------- -# PlantUML binary discovery -# --------------------------------------------------------------------------- -# PLANTUML_BIN — execroot-relative path of //third_party/plantuml:plantuml -# (a rules_java java_binary launcher script), injected by the -# sphinx_module Bazel rule via the action env. -# PLANTUML_BIN_RLOC — analysis-time-stable Bazel rlocation key derived from the -# target's short_path (no exec-config hash); used only for -# diagnostic logging here. -# -# Path resolution rationale (applies to all hermetic tool paths in this file): -# os.path.abspath() converts the execroot-relative path to an absolute path -# using the process's current working directory. Bazel guarantees that the -# action's cwd equals the execroot at process start. conf.py is loaded during -# Sphinx initialisation — before Sphinx can perform any os.chdir() — so the -# abspath() call is stable for the entire action lifetime. This replaces the -# previous _resolve_execroot_path() which walked parent directories as a -# fallback, a pattern that is fragile and wrong when nested under bazel-out/. -# See docs/tooling_architecture.rst §"Hermetic tool path resolution". -_plantuml_bin = os.environ.get("PLANTUML_BIN") -if not _plantuml_bin: - raise ValueError( - "PLANTUML_BIN environment variable is not set. It must point at the " - "//third_party/plantuml:plantuml launcher and is normally provided by the " - "sphinx_module Bazel rule. If you are invoking Sphinx outside that rule, " - "set PLANTUML_BIN to the plantuml binary path." - ) -plantuml_path = os.path.abspath(_plantuml_bin) -logger.debug( - f"plantuml resolved: {plantuml_path} " - f"(rloc: {os.environ.get('PLANTUML_BIN_RLOC', 'n/a')})" -) - -plantuml_output_format = "svg_obj" -# `plantuml` command is assembled below, after graphviz_dot is resolved, so -# PlantUML can use the same hermetic Graphviz dot (see Graphviz section). - +# Hermetic PlantUML / Graphviz / FTA metamodel tool resolution # --------------------------------------------------------------------------- -# Graphviz (sphinx.ext.graphviz) -# --------------------------------------------------------------------------- -# GRAPHVIZ_DOT — execroot-relative path of //third_party/docs_runtime:dot -# (the exec_in_sysroot POSIX-sh wrapper), injected by the -# sphinx_module Bazel rule. -# GRAPHVIZ_DOT_RLOC — analysis-time-stable rlocation key; logged only. -# -# Path resolution: same os.path.abspath() rationale as PLANTUML_BIN above. -# -# The exec_in_sysroot wrapper is a runfiles-aware POSIX-sh script that -# bootstraps its own runfiles via the standard $0.runfiles/ Bazel convention. -# Passing the ABSOLUTE path ensures $0 is absolute, so $0.runfiles/ resolves to -# the correct companion directory even when the wrapper is called as a -# subprocess from inside the Sphinx Python process (which carries its own -# RUNFILES_DIR pointing at the sphinx tool's runfiles, not the dot wrapper's -# runfiles). -# -# GRAPHVIZ_DOT is mandatory: the sphinx_module rule always provides the hermetic -# wrapper, so if it is missing conf.py fails loudly rather than silently using a -# host-installed dot binary. -_graphviz_dot_path = os.environ.get("GRAPHVIZ_DOT") -if not _graphviz_dot_path: - raise ValueError( - "GRAPHVIZ_DOT environment variable is not set. It must point at the " - "//third_party/docs_runtime:dot hermetic wrapper and is normally provided " - "by the sphinx_module Bazel rule. If you are invoking Sphinx outside that " - "rule, set GRAPHVIZ_DOT to the hermetic dot wrapper path." - ) -_graphviz_dot_rloc = os.environ.get("GRAPHVIZ_DOT_RLOC", "") -graphviz_dot = os.path.abspath(_graphviz_dot_path) +# PLANTUML_BIN, GRAPHVIZ_DOT and FTA_METAMODEL_DIR are injected by the +# sphinx_module Bazel rule via the action env (see _hermetic_tool_env() in +# sphinx_module.bzl). Resolution (path rationale, hermeticity requirements, +# the FTA include-path JVM flag, etc.) is centralised in sphinx_conf_helpers +# so every conf.py -- this default template and any custom conf_template -- +# shares one implementation. See docs/tooling_architecture.rst +# §"Hermetic tool path resolution". +graphviz_dot = sphinx_conf_helpers.resolve_graphviz_dot() graphviz_output_format = "svg" -logger.debug( - f"graphviz dot resolved: {graphviz_dot} (rloc: {_graphviz_dot_rloc or 'n/a'})" -) -# --------------------------------------------------------------------------- -# PlantUML layout engine: hermetic dot + FTA metamodel include path -# --------------------------------------------------------------------------- -# FTA_METAMODEL_DIR — directory containing fta_metamodel.puml, set by the -# sphinx_module rule from //plantuml:fta_metamodel. -# FTA diagrams keep ``!include fta_metamodel.puml``; -# sphinxcontrib-plantuml renders via -pipe (no source-file -# dir on the include search path) so the file must be -# listed on plantuml.include.path. The JVM flag must -# precede the program args or the java_binary launcher -# passes it to PlantUML instead of the JVM. -_fta_metamodel_dir = os.environ.get("FTA_METAMODEL_DIR", "") -if _fta_metamodel_dir: - _fta_metamodel_dir = os.path.abspath(_fta_metamodel_dir) - logger.debug(f"fta_metamodel include path: {_fta_metamodel_dir}") - _include_flag = f" --jvm_flag=-Dplantuml.include.path={_fta_metamodel_dir}" -else: - logger.warning( - "FTA_METAMODEL_DIR is not set; FTA diagrams using " - "!include fta_metamodel.puml will fail to render." - ) - _include_flag = "" - -# PlantUML uses the same hermetic Graphviz dot as sphinx.ext.graphviz for its -# internal layout calls, via the -graphvizdot flag. There is no fallback: the -# hermetic dot is the single reference rendering path for both. -plantuml = f"{plantuml_path}{_include_flag} -graphvizdot {graphviz_dot}" +plantuml_output_format = "svg_obj" +# Reuses the graphviz_dot already resolved above instead of re-resolving +# GRAPHVIZ_DOT a second time. +plantuml = sphinx_conf_helpers.resolve_plantuml_command(graphviz_dot_path=graphviz_dot) # HTML theme html_theme = "sphinx_rtd_theme" From bcb75b32720cfa40988753137a7cb8c56574c4e5 Mon Sep 17 00:00:00 2001 From: Jochen Hoenle Date: Mon, 27 Jul 2026 17:46:25 +0200 Subject: [PATCH 7/7] [docs toolchain] dedupe needs-loading between sphinx_module_ext and bazel_sphinx_needs --- bazel/rules/rules_score/BUILD | 1 + .../docs/_assets/tooling_chain.puml | 2 +- .../rules_score/src/sphinx_module_ext.py | 99 ++----------------- 3 files changed, 12 insertions(+), 90 deletions(-) diff --git a/bazel/rules/rules_score/BUILD b/bazel/rules/rules_score/BUILD index 4a5e2c17..1a357ce0 100644 --- a/bazel/rules/rules_score/BUILD +++ b/bazel/rules/rules_score/BUILD @@ -134,6 +134,7 @@ py_library( imports = ["src"], visibility = ["//visibility:public"], deps = [ + ":bazel_sphinx_needs", requirement("sphinx"), ], ) diff --git a/bazel/rules/rules_score/docs/_assets/tooling_chain.puml b/bazel/rules/rules_score/docs/_assets/tooling_chain.puml index 0db400a7..4cfa4888 100644 --- a/bazel/rules/rules_score/docs/_assets/tooling_chain.puml +++ b/bazel/rules/rules_score/docs/_assets/tooling_chain.puml @@ -47,7 +47,7 @@ rectangle "**puml_cli** (FTA mode)\ninline metamodel + extract\n(.puml -> inline rectangle "**fmea_assembler**\nTRLCRST page build\n(.trlc + chains -> fmea.rst)" <> as asm rectangle "**Lobster**\nlobster-trlc / -report /\n-ci-report / gtest_report" <> as lob rectangle "**Architecture Verifier**\nvalidation_cli\n(arch.json + .fbs.bin)" <> as verifier -rectangle "**Sphinx**\nscore_build + html_merge\n(.rst -> needs.json + HTML)" <> as docs +rectangle "**Sphinx**\ntoolchain-resolved build binary + private\nhtml_merge tool\n(.rst -> needs.json + HTML)" <> as docs ' ── Tool-qualification branch (traces tool requirements to source) ──────────── rectangle "rules_score_impl\n(tool-qualification target)" <> as impl diff --git a/bazel/rules/rules_score/src/sphinx_module_ext.py b/bazel/rules/rules_score/src/sphinx_module_ext.py index a0301b25..90a049c6 100644 --- a/bazel/rules/rules_score/src/sphinx_module_ext.py +++ b/bazel/rules/rules_score/src/sphinx_module_ext.py @@ -11,98 +11,19 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* # -import json -import logging -import os -import sys -from pathlib import Path -from typing import Any, Dict, List +"""Sphinx extension entry point for loading external (cross-module) needs. -# Create a logger with the Sphinx namespace -logger = logging.getLogger(__name__) +Registered by listing "sphinx_module_ext" in conf.py's `extensions = [...]`; +Sphinx then auto-invokes `setup(app)` below, no manual wiring required. This +is the counterpart to bazel_sphinx_needs.py, which offers the same +find_workspace_root()/load_external_needs() logic for conf.py authors who +prefer to import and wire it up explicitly instead of registering an +extension. See bazel_sphinx_needs.py's module docstring for that alternative. +""" -# Configuration constants defined by bazel -NEEDS_EXTERNAL_FILE = "needs_external_needs.json" -BAZEL_OUT_DIR = "bazel-out" +from typing import Any, Dict - -def find_workspace_root() -> Path: - """ - Find the Bazel workspace root by looking for the bazel-out directory. - - Returns: - Path to the workspace root directory - """ - current = Path.cwd() - - # Traverse up the directory tree looking for bazel-out - while current != current.parent: - if (current / BAZEL_OUT_DIR).exists(): - return current - current = current.parent - - # If we reach the root without finding it, return current directory - return Path.cwd() - - -def load_external_needs() -> List[Dict[str, Any]]: - """ - Load external needs configuration from JSON file. - - This function reads the needs_external_needs.json file if it exists and - resolves relative paths to absolute paths based on the workspace root. - - Returns: - List of external needs configurations with resolved paths - """ - needs_file = Path(NEEDS_EXTERNAL_FILE) - - if not needs_file.exists(): - logger.debug(f"{NEEDS_EXTERNAL_FILE} not found - no external dependencies") - return [] - - logger.debug(f"Loading external needs from {NEEDS_EXTERNAL_FILE}") - - try: - with needs_file.open("r", encoding="utf-8") as file: - needs_dict = json.load(file) - except json.JSONDecodeError as e: - logger.error(f"Failed to parse {NEEDS_EXTERNAL_FILE}: {e}") - return [] - except Exception as e: - logger.error(f"Failed to read {NEEDS_EXTERNAL_FILE}: {e}") - return [] - - workspace_root = find_workspace_root() - logger.debug(f"Workspace root: {workspace_root}") - - external_needs = [] - for key, config in needs_dict.items(): - if "json_path" not in config: - logger.warning( - f"External needs config for '{key}' missing 'json_path', skipping" - ) - continue - - if "version" not in config: - logger.warning( - f"External needs config for '{key}' missing 'version', skipping" - ) - continue - # Resolve relative path to absolute path - # Bazel provides relative paths like: bazel-out/k8-fastbuild/bin/.../needs.json - # We need absolute paths: .../execroot/_main/bazel-out/... - json_path = workspace_root / config["json_path"] - config["json_path"] = str(json_path) - - logger.debug(f"Added external needs config for '{key}':") - logger.debug(f" json_path: {config['json_path']}") - logger.debug(f" id_prefix: {config.get('id_prefix', 'none')}") - logger.debug(f" version: {config['version']}") - - external_needs.append(config) - - return external_needs +from bazel_sphinx_needs import load_external_needs def init_external_needs(app: Any, config: Any) -> None: