Skip to content
Draft
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
4 changes: 2 additions & 2 deletions BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ package(default_visibility = ["//visibility:public"])
exports_files(["pyproject.toml"])

docs(
data = [
"@score_process//:needs_json",
external_needs = [
"@score_process//:needs_json_file",
],
scan_code = [
"//scripts_bazel:sources",
Expand Down
26 changes: 20 additions & 6 deletions docs.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ def _missing_requirements(deps):
fail(msg)
fail("This case should be unreachable?!")

def docs(source_dir = "docs", data = [], deps = [], scan_code = [], test_sources = [], known_good = None, metamodel = None):
def docs(source_dir = "docs", data = [], deps = [], external_needs = [], scan_code = [], test_sources = [], known_good = None, metamodel = None):
"""Creates all targets related to documentation.

By using this function, you'll get any and all updates for documentation targets in one place.
Expand All @@ -136,13 +136,15 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], test_sources
source_dir: The source directory containing documentation files. Defaults to "docs".
data: Additional data files to include in the documentation build.
deps: Additional dependencies for the documentation build.
external_needs: List of external needs targets to include in the documentation build.
scan_code: List of code targets to scan for source code links.
test_sources: Optional list of repo-relative directory paths which will be used to filter testcases for documentation generation.
When empty (default), all testcases found in `bazel-testlogs` will be used.
known_good: Optional label to a "known good" JSON file for source links.
metamodel: Optional label to a metamodel.yaml file. When set, the extension loads this
file instead of the default metamodel shipped with score_metamodel.
"""
# HINT: keep documentation sync docs/reference/bazel_macros.rst

metamodel_data = []
metamodel_env = {}
Expand All @@ -164,7 +166,7 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], test_sources
sphinx_build_binary(
name = "sphinx_build",
visibility = ["//visibility:private"],
data = data + metamodel_data,
data = data + external_needs + metamodel_data,
deps = deps,
)

Expand Down Expand Up @@ -199,20 +201,22 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], test_sources
data_with_docs_sources = _rewrite_needs_json_to_docs_sources(data)
additional_combo_sourcelinks = _rewrite_needs_json_to_sourcelinks(data)
_merge_sourcelinks(name = "merged_sourcelinks", sourcelinks = [":sourcelinks_json"] + additional_combo_sourcelinks, known_good = known_good)
docs_data = data + metamodel_data + [":sourcelinks_json"]
combo_data = data_with_docs_sources + metamodel_data + [":merged_sourcelinks"]
docs_data = data + external_needs + metamodel_data + [":sourcelinks_json"]
combo_data = data_with_docs_sources + external_needs + metamodel_data + [":merged_sourcelinks"]

docs_env = {
"SOURCE_DIRECTORY": source_dir,
"PACKAGE_DIR": native.package_name(),
"DATA": str(data),
"EXTERNAL_NEEDS_FILES": str(external_needs),
"TEST_SOURCES": str(test_sources),
"SCORE_SOURCELINKS": "$(location :sourcelinks_json)",
} | metamodel_env
docs_sources_env = {
"SOURCE_DIRECTORY": source_dir,
"PACKAGE_DIR": native.package_name(),
"DATA": str(data_with_docs_sources),
"EXTERNAL_NEEDS_FILES": str(external_needs),
"TEST_SOURCES": str(test_sources),
"SCORE_SOURCELINKS": "$(location :merged_sourcelinks)",
} | metamodel_env
Expand Down Expand Up @@ -309,13 +313,13 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], test_sources
"-T", # show more details in case of errors
"--jobs",
"auto",
"--define=external_needs_source=" + str(data),
"--define=external_needs_source=" + str(data + external_needs),
"--define=score_sourcelinks_json=$(location :sourcelinks_json)",
"--define=score_source_code_linker_plain_links=1",
],
formats = ["needs"],
sphinx = ":sphinx_build",
tools = data + [":sourcelinks_json"],
tools = data + external_needs + [":sourcelinks_json"],
visibility = ["//visibility:public"],
# Persistent workers cause stale symlinks after dependency version
# changes, corrupting the Bazel cache.
Expand All @@ -330,6 +334,16 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], test_sources
visibility = ["//visibility:public"],
)

native.genrule(
# In contrast to the "needs_json" target represents *only* the needs.json file,
# not the whole needs build output.
name = "needs_json_file",
srcs = [":needs_json"],
outs = ["needs.json"],
cmd = "cp $(location :needs_json)/needs.json $@",
visibility = ["//visibility:public"],
)

native.alias(
name = "traceability_gate",
actual = Label("//scripts_bazel:traceability_gate"),
Expand Down
25 changes: 22 additions & 3 deletions docs/how-to/other_modules.rst
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@ A minimal example (add or extend the existing `bazel_deps` stanza):
2) Extend your `docs` rule so Sphinx picks up the other module's inventory
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The documentation build in this project is exposed via a Bazel macro/rule that accepts a `data` parameter.
Add the external module's ``:needs_json`` target to that list
to have their needs elements available for cross-referencing.
The documentation build is exposed via a Bazel macro that accepts an ``external_needs`` parameter
for external ``:needs_json_file`` targets.
Use ``external_needs`` instead of ``data`` when the target produces needs JSON β€”
``data`` is meant for non-needs runfiles (e.g. custom tool outputs).

Example `BUILD` snippet (consumer module):

Expand Down Expand Up @@ -76,3 +77,21 @@ Which results in:

See the `Sphinx-Needs documentation <https://sphinx-needs.readthedocs.io/en/latest/>`_
for more details on cross-referencing needs.

Data vs. external_needs
~~~~~~~~~~~~~~~~~~~~~~~

It might seem like ``data`` and ``external_needs`` behave nearly the same.

.. code-block:: starlark

docs(
data = ["@score_process//:needs_json"],
external_needs = ["@score_process//:needs_json_file"], # same?
)

For a ``:docs`` they behave the same,
but for ``:docs_combo`` they do not.
In a combo build, the ``data`` are treated differently and will include the documentation
instead of just link the needs online.
However, ``external_needs`` will always be hyperlinks.
14 changes: 1 addition & 13 deletions docs/how-to/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,19 +73,7 @@ docs(
)
```


#### Configuration Options

The `docs()` macro accepts the following arguments:

| Parameter | Description | Required |
|-----------|-------------|----------|
| `source_dir` | Directory of documentation source files (RST, MD) | Yes |
| `data` | List of `needs_json` targets that should be included in the documentation | No |
| `deps` | Additional Bazel Python dependencies | No |
| `scan_code` | Source code targets to scan for traceability tags | No |
| `known_good` | Label to a "known good" JSON file for source links | No |
| `metamodel` | Label to a custom `metamodel.yaml` that replaces the default metamodel | No |
For configuration options see {ref}`docs_bazel-macros`.

### 4. Copy conf.py

Expand Down
6 changes: 6 additions & 0 deletions docs/reference/bazel_macros.rst
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ Minimal example (root ``BUILD``)
Source code targets to scan for traceability tags (``req-Id:`` annotations).
Used to generate the source-code-link JSON that maps tags back to source files.

- ``external_needs`` (list of bazel labels)
External ``:needs_json_file`` targets from other modules/repositories
for referencing their needs.
Users must specify ``:needs_json_file`` explicitly (not ``:needs_json``).
Works the same during ``:docs`` and ``:docs_combo``.

- ``metamodel`` (bazel label, optional)
Path to a custom ``metamodel.yaml`` file.
When set, the ``score_metamodel`` extension loads **this file instead of** the default metamodel.
Expand Down
67 changes: 60 additions & 7 deletions src/extensions/score_metamodel/external_needs.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
# *******************************************************************************

import json
import os
import subprocess
from dataclasses import dataclass
from pathlib import Path
Expand Down Expand Up @@ -54,7 +55,7 @@
repo, path_to_target = repo_and_path.split("//", 1)
repo = repo.lstrip("@") # empty for same-repo `//pkg:needs_json`

if target in ("needs_json", "docs_sources"):
if target in ("needs_json", "needs_json_file", "docs_sources"):
return ExternalNeedsSource(
bazel_module=repo,
path_to_target=path_to_target,
Expand Down Expand Up @@ -159,12 +160,11 @@
def get_external_needs_source(external_needs_source: str) -> list[ExternalNeedsSource]:
if external_needs_source:
# Path taken for all invocations via `bazel`
external_needs = parse_external_needs_sources_from_DATA(external_needs_source)
return parse_external_needs_sources_from_DATA(external_needs_source)
else:
# This is the path taken for anything that doesn't
# run via `bazel` e.g. esbonio or other direct executions
external_needs = parse_external_needs_sources_from_bazel_query() # pyright: ignore[reportAny]
return external_needs
return parse_external_needs_sources_from_bazel_query() # pyright: ignore[reportAny]


def add_external_needs_json(e: ExternalNeedsSource, config: Config):
Expand All @@ -182,9 +182,8 @@
needs_json_data = json.loads(Path(json_file).read_text(encoding="utf-8")) # pyright: ignore[reportAny]
except FileNotFoundError:
logger.error(
f"Could not find external needs JSON file at {json_file}. "
+ "Something went terribly wrong. "
+ "Try running `bazel clean --async && rm -rf _build`."
"Could not find external needs JSON file at %s from target %s.",
json_file, e.target
)
# Attempt to continue, exit code will be non-zero after a logged error anyway.
return
Expand All @@ -198,6 +197,9 @@
)





def add_external_docs_sources(e: ExternalNeedsSource, config: Config):
# Note that bazel does NOT write the files under e.target!
# The runfiles layout mirrors the original git layout: same-repo mounts live
Expand Down Expand Up @@ -227,6 +229,7 @@
def connect_external_needs(app: Sphinx, config: Config):
extend_needs_json_exporter(config, ["project_url"])

# Local external needs from DATA (e.g. :needs_json or :docs_sources)
external_needs = get_external_needs_source(app.config.external_needs_source)

# this sets the default value - required for the needs-config-writer
Expand All @@ -236,9 +239,59 @@
for e in external_needs:
if e.target == "needs_json":
add_external_needs_json(e, app.config)
elif e.target == "needs_json_file":
_add_needs_json_file(e, app.config)
elif e.target == "docs_sources":
add_external_docs_sources(e, app.config)
else:
raise ValueError(
f"Internal Error. Unknown external needs target: {e.target}"
)

# External needs from explicit :needs_json_file targets
_load_external_needs_from_bazel_labels(
os.environ.get("EXTERNAL_NEEDS_FILES", ""), config
)


def _add_needs_json_file(e: ExternalNeedsSource, config: Config) -> None:
"""Resolve a needs_json_file target from runfiles and register it."""
json_file_raw = Path(_runfiles_module_dir(e)) / e.path_to_target / "needs.json"
r = get_runfiles_dir()
json_file = r / json_file_raw
logger.debug(f"External needs_json_file: {json_file}")
try:
needs_json_data = json.loads(
Path(json_file).read_text(encoding="utf-8") # pyright: ignore[reportAny]
)
except FileNotFoundError:
logger.error(
"Could not find external needs JSON file at %s from target %s.",
json_file, e.target
)
return

Check failure on line 272 in src/extensions/score_metamodel/external_needs.py

View workflow job for this annotation

GitHub Actions / common / πŸ” Common PR checks

Type "JSONDecodeError" is not assignable to declared type "ExternalNeedsSource" Β Β "JSONDecodeError" is not assignable to "ExternalNeedsSource" (reportAssignmentType)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse external needs JSON file {json_file}: {e}")
return
config.needs_external_needs.append({ # pyright: ignore[reportUnknownMemberType]
"base_url": needs_json_data.get("project_url", "") + "/main",
"json_path": json_file,
})


def _load_external_needs_from_bazel_labels(
external_needs_labels: str, config: Config
) -> None:
"""Load external needs.json files from Bazel labels (``:needs_json_file`` targets).

Called both from the ``EXTERNAL_NEEDS_FILES`` env var and from
``connect_external_needs`` when the ``--define=external_needs_source`` path
carries a ``needs_json_file`` entry. Labels are parsed into
``ExternalNeedsSource`` objects and resolved to runfile paths
(``{module}+/needs.json``).
"""
if not external_needs_labels:
return
sources = parse_external_needs_sources_from_DATA(external_needs_labels)
for e in sources:
_add_needs_json_file(e, config)
30 changes: 30 additions & 0 deletions src/extensions/score_metamodel/tests/test_external_needs.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@
import score_metamodel.external_needs as ext_needs
from score_metamodel.external_needs import (
ExternalNeedsSource,
add_external_docs_sources,

Check warning on line 27 in src/extensions/score_metamodel/tests/test_external_needs.py

View workflow job for this annotation

GitHub Actions / common / πŸ” Common PR checks

"_load_external_needs_from_bazel_labels" is private and used outside of the module in which it is declared (reportPrivateUsage)
add_external_needs_json,
get_external_needs_source,
parse_external_needs_sources_from_DATA,
_load_external_needs_from_bazel_labels,
)
from sphinx.config import Config

Expand Down Expand Up @@ -202,6 +203,35 @@
assert Path(entry["json_path"]) == json_path


def test_load_external_needs_from_bazel_labels_appends_entry(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""_load_external_needs_from_bazel_labels should load from :needs_json_file targets."""
# Arrange: create the needs.json at the runfiles path
rel_json = Path("ext_mod+/needs.json")
json_path = tmp_path / rel_json
json_path.parent.mkdir(parents=True, exist_ok=True)
json_path.write_text(
json.dumps({"project_url": "https://example.test/json-file"}), encoding="utf-8"
)

runfiles_dir = tmp_path
config = Config()
config.needs_external_needs = []

monkeypatch.setattr(ext_needs, "get_runfiles_dir", lambda: runfiles_dir)

# Act: pass the Bazel label
_load_external_needs_from_bazel_labels('["@ext_mod//:needs_json_file"]', config)

# Assert
assert config.needs_external_needs is not None
assert len(config.needs_external_needs) == 1
entry = config.needs_external_needs[0]
assert entry["base_url"] == "https://example.test/json-file/main"
assert Path(entry["json_path"]) == json_path


def test_add_external_needs_json_missing_file_keeps_list_empty(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@ load("//:docs.bzl", "docs")
# avoiding the workspace-wide bazel-testlogs scan during the html build.
docs(
source_dir = "docs",
data = ["//src/tests/docs_bzl/fixtures/external_needs/producer:needs_json"],
external_needs = ["//src/tests/docs_bzl/fixtures/external_needs/producer:needs_json_file"],
test_sources = ["src/tests/docs_bzl/fixtures/external_needs/consumer"],
)
20 changes: 20 additions & 0 deletions src/tests/docs_bzl/fixtures/external_needs/consumer_combo/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# *******************************************************************************
# 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
# *******************************************************************************

load("//:docs.bzl", "docs")

docs(
source_dir = "docs",
external_needs = ["//src/tests/docs_bzl/fixtures/external_needs/producer:needs_json_file"],
test_sources = ["src/tests/docs_bzl/fixtures/external_needs/consumer_combo"],
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# *******************************************************************************
# 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
# *******************************************************************************

import os

project = "External Needs Consumer"
project_url = "https://example.invalid/external-needs-consumer"
extensions = ["score_sphinx_bundle"]

# FIXME:
# sphinx-needs refuses to add an external need whose type is not registered in
# this project's needs_types, so the consumer must declare the producer's
# `test_req` type. score_metamodel's own checks skip external needs, so the
# id/parts rules never run here. Loaded via the score_metamodel_yaml config
# value because docs(metamodel=...) does not reach the build target.
score_metamodel_yaml = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "metamodel.yaml"
)
Loading
Loading