Skip to content
Merged
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
77 changes: 70 additions & 7 deletions bazel/rules/rules_score/private/sphinx_module.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -33,30 +33,66 @@ def _get_index_file(ctx):
fail("'index' target must provide SphinxIndexFileInfo or produce exactly one file, got %d files" % len(files))
return files[0]

def _create_config_py(ctx, builder):
"""Get or generate the conf.py configuration file.
def _create_config_py(ctx, builder, project_name, output_prefix):
"""Generate the conf.py configuration file for one Sphinx builder pass.

Args:
ctx: Rule context
builder: The Sphinx builder this conf.py is generated for (e.g.
"needs" or "html"). Substituted as {BUILDER} so the template can
scope builder-specific behavior (see suppress_warnings_for_builder
in sphinx_conf_helpers.py).
project_name: Value substituted as {PROJECT_NAME} (Sphinx's `project`
config value).
output_prefix: Directory prefix the generated conf.py is declared
under. Must match the consuming _score_needs/_score_html target's
own output prefix so conf.py lands alongside that target's other
generated files (e.g. needs_external_needs.json, whose
confdir-relative lookup depends on this — see
bazel_sphinx_needs.py's base_dir doc).
"""
sphinx_toolchain = ctx.toolchains["//bazel/rules/rules_score:toolchain_type"].sphinxinfo
config_file = ctx.actions.declare_file(ctx.label.name + "/conf.py")
config_file = ctx.actions.declare_file(output_prefix + "/conf.py")
template = sphinx_toolchain.conf_template.files.to_list()[0]

# Read template and substitute PROJECT_NAME / BUILDER
ctx.actions.expand_template(
template = template,
output = config_file,
substitutions = {
"{PROJECT_NAME}": ctx.label.name.replace("_", " ").title(),
"{PROJECT_NAME}": project_name,
"{BUILDER}": builder,
},
)
return config_file

def _score_conf_impl(ctx):
config_file = _create_config_py(ctx, ctx.attr.builder, ctx.attr.project_name, ctx.attr.output_prefix)
return [DefaultInfo(files = depset([config_file]))]

_score_conf = rule(
implementation = _score_conf_impl,
doc = "Generates the conf.py for one Sphinx builder pass of a sphinx_module. " +
"Its own target so consumers can inspect/override conf.py generation " +
"independently of the needs/html build steps that consume it.",
attrs = {
"builder": attr.string(
mandatory = True,
values = ["needs", "html"],
doc = "The Sphinx builder this conf.py is generated for.",
),
"project_name": attr.string(
mandatory = True,
doc = "Value substituted as {PROJECT_NAME} (Sphinx's `project` config value).",
),
"output_prefix": attr.string(
mandatory = True,
doc = "Directory prefix the generated conf.py is declared under.",
),
},
toolchains = ["//bazel/rules/rules_score:toolchain_type"],
)

# ======================================================================================
# Common attributes for Sphinx rules
# ======================================================================================
Expand All @@ -74,6 +110,11 @@ sphinx_rule_attrs = dict(
"deps": attr.label_list(
doc = "List of other sphinx_module targets this module depends on for intersphinx.",
),
"conf": attr.label(
allow_single_file = ["conf.py"],
mandatory = True,
doc = "The _score_conf target providing this pass's generated conf.py.",
),
"_plantuml": attr.label(
default = Label("//third_party/plantuml:plantuml"),
executable = True,
Expand Down Expand Up @@ -133,8 +174,9 @@ def _score_needs_impl(ctx):
output_path = ctx.label.name + "/needs.json"
needs_output = ctx.actions.declare_file(output_path)

# Get config file (generate or use provided)
config_file = _create_config_py(ctx, "needs")
# Config file is generated by a standalone _score_conf target (see
# sphinx_module() macro), not inline here.
config_file = ctx.file.conf

# Phase 1: Build needs.json (without external needs).
# The needs builder (sphinx-needs NeedsBuilder) only collects `.. need::`
Expand Down Expand Up @@ -260,7 +302,10 @@ def _score_html_impl(ctx):
if len(src_files) != 1:
fail("renamed_srcs entry must be exactly 1 file, got %d files: %s" % (len(src_files), src_files))
_relocate(src_files[0], dest)
config_file = _create_config_py(ctx, "html")

# Config file is generated by a standalone _score_conf target (see
# sphinx_module() macro), not inline here.
config_file = ctx.file.conf

# Sphinx only accepts a single directory to read its doc sources from.
# Because plain files and generated files are in different directories,
Expand Down Expand Up @@ -446,11 +491,28 @@ def sphinx_module(
"""
package = native.package_name()
resolved_strip_prefix = strip_prefix if strip_prefix else (package + "/" if package else "")
_score_conf(
name = name + "_needs_conf",
builder = "needs",
project_name = (name + "_needs").replace("_", " ").title(),
output_prefix = name + "_needs",
testonly = testonly,
**kwargs
)
_score_conf(
name = name + "_conf",
builder = "html",
project_name = name.replace("_", " ").title(),
output_prefix = name,
testonly = testonly,
**kwargs
)
_score_needs(
name = name + "_needs",
srcs = srcs,
index = index,
deps = [d + "_needs" for d in deps],
conf = name + "_needs_conf",
testonly = testonly,
**kwargs
)
Expand All @@ -463,6 +525,7 @@ def sphinx_module(
renamed_srcs = renamed_srcs,
strip_prefix = resolved_strip_prefix,
needs = [d + "_needs" for d in deps],
conf = name + "_conf",
extra_opts = extra_opts,
extra_opts_targets = extra_opts_targets,
testonly = testonly,
Expand Down
16 changes: 13 additions & 3 deletions bazel/rules/rules_score/src/bazel_sphinx_needs.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
import os
import sys
from pathlib import Path
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional

# Create a logger with the Sphinx namespace
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -45,17 +45,27 @@ def find_workspace_root() -> Path:
return Path.cwd()


def load_external_needs() -> List[Dict[str, Any]]:
def load_external_needs(base_dir: Optional[Path] = None) -> 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.

Args:
base_dir: Directory to look for needs_external_needs.json in. Defaults
to Path.cwd(). A conf.py author calling this at module level runs
inside Sphinx's temporary chdir(confdir) (see
sphinx.config.eval_config_file) and can rely on that default, but
a "config-inited" event listener (e.g. sphinx_module_ext.py) fires
after that chdir has already been undone, with cwd back to
whatever it was before -- it must pass app.confdir explicitly.

Returns:
List of external needs configurations with resolved paths
"""
needs_file = Path(NEEDS_EXTERNAL_FILE)
base = Path(base_dir) if base_dir is not None else Path.cwd()
needs_file = base / NEEDS_EXTERNAL_FILE

if not needs_file.exists():
logger.info(f"{NEEDS_EXTERNAL_FILE} not found - no external dependencies")
Expand Down
8 changes: 7 additions & 1 deletion bazel/rules/rules_score/src/sphinx_module_ext.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
extension. See bazel_sphinx_needs.py's module docstring for that alternative.
"""

from pathlib import Path
from typing import Any, Dict

from bazel_sphinx_needs import load_external_needs
Expand All @@ -30,12 +31,17 @@ def init_external_needs(app: Any, config: Any) -> None:
"""
Initialize external needs configuration.

"config-inited" fires with cwd == execroot, not confdir -- Sphinx's
chdir(confdir) only wraps evaluating conf.py itself, and that context
has already exited by the time this listener runs. needs_external_needs.json
lives beside conf.py in confdir, so it must be looked up explicitly.

Args:
app: Sphinx application object
config: Sphinx configuration object
"""

config.needs_external_needs = load_external_needs()
config.needs_external_needs = load_external_needs(Path(app.confdir))


def setup(app: Any) -> Dict[str, Any]:
Expand Down
16 changes: 16 additions & 0 deletions bazel/rules/rules_score/test/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -1347,6 +1347,20 @@ py_test(
deps = ["@score_tooling//bazel/rules/rules_score:sphinx_html_merge_lib"],
)

py_test(
name = "test_bazel_sphinx_needs",
size = "small",
srcs = ["test_bazel_sphinx_needs.py"],
deps = ["@score_tooling//bazel/rules/rules_score:bazel_sphinx_needs"],
)

py_test(
name = "test_sphinx_module_ext",
size = "small",
srcs = ["test_sphinx_module_ext.py"],
deps = ["@score_tooling//bazel/rules/rules_score:sphinx_module_ext"],
)

py_test(
name = "test_rst_to_trlc",
size = "small",
Expand Down Expand Up @@ -1376,9 +1390,11 @@ test_suite(
":seooc_tests",
":sphinx_module_tests",
":test_aou_forwarding_to_lobster",
":test_bazel_sphinx_needs",
":test_fmea_assembler",
":test_rst_to_trlc",
":test_sphinx_html_merge",
":test_sphinx_module_ext",
":test_trlc_rst_image_rendering",
":unit_component_tests",
"//fixtures/image_srcs:requirements_image_tests",
Expand Down
76 changes: 76 additions & 0 deletions bazel/rules/rules_score/test/test_bazel_sphinx_needs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# *******************************************************************************
# 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
# *******************************************************************************
"""Unit tests for bazel_sphinx_needs.load_external_needs()'s base_dir handling."""

import json
import os
import tempfile
import unittest
from pathlib import Path

from bazel_sphinx_needs import load_external_needs


def _write_needs_file(directory: Path) -> None:
directory.mkdir(parents=True, exist_ok=True)
(directory / "needs_external_needs.json").write_text(
json.dumps({"dep": {"json_path": "bazel-out/dep/needs.json", "version": "1.0"}}),
encoding="utf-8",
)


class TestLoadExternalNeeds(unittest.TestCase):
"""Tests for load_external_needs's base_dir handling."""

def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.root = Path(self._tmp.name)
self._old_cwd = Path.cwd()

def tearDown(self) -> None:
os.chdir(self._old_cwd)
self._tmp.cleanup()

def test_explicit_base_dir_finds_file_regardless_of_cwd(self) -> None:
"""Regression test for the confdir-vs-cwd bug: a "config-inited"-style
caller running with cwd != confdir must still find the file when it
passes confdir explicitly instead of relying on the default cwd."""
confdir = self.root / "confdir"
_write_needs_file(confdir)
elsewhere = self.root / "elsewhere"
elsewhere.mkdir()
os.chdir(elsewhere)

result = load_external_needs(confdir)

self.assertEqual(len(result), 1)
self.assertEqual(result[0]["version"], "1.0")

def test_default_base_dir_falls_back_to_cwd(self) -> None:
"""A conf.py module-level caller (no base_dir) relies on cwd == confdir,
true while Sphinx's eval_config_file() chdir is active."""
_write_needs_file(self.root)
os.chdir(self.root)

result = load_external_needs()

self.assertEqual(len(result), 1)

def test_missing_file_returns_empty_list(self) -> None:
result = load_external_needs(self.root)

self.assertEqual(result, [])


if __name__ == "__main__":
unittest.main()
56 changes: 56 additions & 0 deletions bazel/rules/rules_score/test/test_sphinx_module_ext.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# *******************************************************************************
# 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
# *******************************************************************************
"""Unit tests for sphinx_module_ext's confdir-aware needs loading."""

import json
import os
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace

from sphinx_module_ext import init_external_needs


class TestInitExternalNeeds(unittest.TestCase):
"""Tests for init_external_needs's "config-inited" listener."""

def test_uses_app_confdir_not_cwd(self) -> None:
"""Regression test: init_external_needs is invoked by Sphinx after its
own chdir(confdir) (scoped to evaluating conf.py) has already been
undone, so it must resolve needs_external_needs.json via app.confdir
rather than the process's current working directory."""
with tempfile.TemporaryDirectory() as tmp:
confdir = Path(tmp) / "confdir"
confdir.mkdir()
(confdir / "needs_external_needs.json").write_text(
json.dumps({"dep": {"json_path": "x", "version": "1.0"}}),
encoding="utf-8",
)
elsewhere = Path(tmp) / "elsewhere"
elsewhere.mkdir()
old_cwd = Path.cwd()
os.chdir(elsewhere)
try:
app = SimpleNamespace(confdir=str(confdir))
config = SimpleNamespace()

init_external_needs(app, config)
finally:
os.chdir(old_cwd)

self.assertEqual(len(config.needs_external_needs), 1)


if __name__ == "__main__":
unittest.main()
Loading