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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .bazelignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@
# As it contains multiple copies of the entire repository, this totally tripps bazel,
# which then tries to build all the files in there.
.claude

# tools/module_verification_reports.py keeps its shallow downstream checkouts
# here, including a self-referencing overlay of this repo, which Bazel's
# package discovery cannot traverse.
.cache
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ user.bazelrc
_build/
ubproject.toml

# tools/module_verification_reports.py downstream repository checkouts
/.cache/

# Vale - editorial style guide
.vale.ini
styles/
Expand Down
7 changes: 7 additions & 0 deletions BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ docs(
],
code_targets = [
"//scripts_bazel:sources",
"//tools:module_verification_reports",
"//src:all_sources",
],
source_dir = "docs",
Expand All @@ -72,3 +73,9 @@ alias(
name = "actionlint",
actual = "@score_devcontainer//tools:actionlint",
)

# gallery for the standalone module verification report.
alias(
name = "module_verification_reports",
actual = "//tools:module_verification_reports",
)
1 change: 1 addition & 0 deletions src/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ filegroup(
"//src/extensions/score_mounts:all_sources",
"//src/extensions/score_source_code_linker:all_sources",
"//src/extensions/score_sphinx_bundle:all_sources",
"//src/extensions/score_sphinx_needs_templates:all_sources",
"//src/extensions/score_sync_toml:all_sources",
"//src/extensions/score_metrics:all_sources",
"//src/helper_lib:all_sources",
Expand Down
1 change: 0 additions & 1 deletion src/extensions/score_metamodel/metamodel.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -998,7 +998,6 @@ needs_types:
- verification_report
parts: 3


# https://eclipse-score.github.io/process_description/main/permalink.html?id=gd_temp__change_decision_record
dec_rec:
title: Decision Record
Expand Down
5 changes: 1 addition & 4 deletions src/extensions/score_sphinx_bundle/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,11 @@ filegroup(
py_library(
name = "score_sphinx_bundle",
srcs = [":all_sources"],
# Keep the shared Sphinx-Needs templates beside the extension in the
# Bazel runfiles tree. The Python extension discovers their directory from
# its own __file__ instead of receiving a path from docs.bzl.
data = ["@score_docs_as_code//src/needs_templates:files"],
visibility = ["//visibility:public"],
deps = all_requirements + [
"@score_docs_as_code//src/extensions:score_plantuml",
"@score_docs_as_code//src/extensions:broken_link_fix",
"@score_docs_as_code//src/extensions/score_sphinx_needs_templates",
"@score_docs_as_code//src/extensions/score_draw_uml_funcs",
"@score_docs_as_code//src/extensions/score_cross_module_compatibility",
"@score_docs_as_code//src/extensions/score_layout",
Expand Down
29 changes: 1 addition & 28 deletions src/extensions/score_sphinx_bundle/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@
#
# SPDX-License-Identifier: Apache-2.0
# *******************************************************************************
from pathlib import Path

import matplotlib
from sphinx.application import Sphinx

Expand All @@ -24,6 +22,7 @@
"sphinxcontrib.plantuml",
"score_plantuml",
"sphinx_needs",
"score_sphinx_needs_templates",
"score_cross_module_compatibility",
"score_metamodel",
"sphinx_design",
Expand All @@ -45,38 +44,12 @@
]


def _needs_template_folder() -> Path:
"""Return the shared Sphinx-Needs template directory.

The extension and the templates are both part of the main ``src`` tree.
Deriving the path from ``__file__`` works for the workspace, Bazel
runfiles, and the sandbox because the extension's data files preserve that
source-tree layout.
"""
# Keep the runfiles/sandbox prefix intact; only walk from the extension's
# package directory to the sibling ``needs_templates`` directory.
# Basically: src/extensions/score_sphinx_bundle/../../needs_templates.
template_folder = Path(__file__).parents[2] / "needs_templates"
if not template_folder.is_dir():
raise FileNotFoundError(
f"Sphinx-Needs template folder does not exist: {template_folder}"
)
return template_folder


def setup(app: Sphinx) -> dict[str, object]:
matplotlib.rcParamsDefault["savefig.bbox"] = "tight"

config_setdefault(app.config, "html_copy_source", False)
config_setdefault(app.config, "html_show_sourcelink", False)

# The templates are a data dependency of this extension. Locate the
# shared directory from the extension itself instead of passing a Bazel
# label or a list of generated paths through every docs target.
config_setdefault(
app.config, "needs_template_folder", str(_needs_template_folder())
)

# Global settings
# Note: the "sub-extensions" also set their own config values

Expand Down
30 changes: 30 additions & 0 deletions src/extensions/score_sphinx_needs_templates/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# *******************************************************************************
# 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 2.0 which is available at
# https://www.apache.org/licenses/LICENSE-2.0
#
# SPDX-License-Identifier: Apache-2.0
# *******************************************************************************
load("@aspect_rules_py//py:defs.bzl", "py_library")
load("@docs_as_code_hub_env//:requirements.bzl", "all_requirements")

filegroup(
name = "all_sources",
srcs = ["__init__.py"],
visibility = ["//visibility:public"],
)

py_library(
name = "score_sphinx_needs_templates",
srcs = [":all_sources"],
data = ["@score_docs_as_code//src/needs_templates:files"],
visibility = ["//visibility:public"],
deps = all_requirements + [
"@score_docs_as_code//src/helper_lib",
],
)
61 changes: 61 additions & 0 deletions src/extensions/score_sphinx_needs_templates/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<!-- ----------------------------------------------------------------------------
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
----------------------------------------------------------------------------- -->

# `score_sphinx_needs_templates`

This extension contains the runtime support for the repository's Sphinx-Needs
`.need` templates. It is loaded by `score_sphinx_bundle` immediately after
`sphinx_needs`.

## Features

The extension provides:

* the shared `src/needs_templates` directory as the Sphinx-Needs template
directory;
* the `linked_needs(need_id, link_name)` helper for traversing Need links;
* support for graph-driven `post_template`s that are rendered after parallel
Need collection has been merged;
* ordinary Sphinx page navigation for sections generated by those
`post_template`s.

## Using `linked_needs`

The helper returns the linked `NeedItem` objects in the order declared by the
source Need. This allows a template to derive its sections from the Need graph
instead of embedding Need IDs.

For example:

```jinja
{# score: render-after-needs-collection #}
{% set components = linked_needs(module_id, "includes") %}
{% for component in components %}
{{ component["title"] }}
{% endfor %}
```

Templates that follow links across the Need model should include the
`score: render-after-needs-collection` marker in a Jinja comment and be selected
with Sphinx-Needs' `:post_template:` option. The extension then purges and
rereads the affected report page once after parallel Need collection has been
merged, so `linked_needs` can see the complete model.

The generated content should use normal reStructuredText sections instead of
rubrics. Sphinx-Needs parses `post_template` output after the Need and with
section matching enabled, so section IDs and the local page ToC are collected
by Sphinx itself. Ordinary `:template:` use keeps its normal Sphinx-Needs
behavior and is not part of this second-read path.

Need fields and filters continue to expose the corresponding backlink fields
with the `_back` suffix.
173 changes: 173 additions & 0 deletions src/extensions/score_sphinx_needs_templates/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# *******************************************************************************
# 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 2.0 which is available at
# https://www.apache.org/licenses/LICENSE-2.0
#
# SPDX-License-Identifier: Apache-2.0
# *******************************************************************************
from pathlib import Path

from sphinx.application import Sphinx
from sphinx.environment import BuildEnvironment
from sphinx_needs.data import SphinxNeedsData
from sphinx_needs.need_item import NeedItem

from src.helper_lib import config_setdefault

_template_environment: BuildEnvironment | None = None
# Post-templates containing this marker need a second read after parallel Need
# collection has been merged.
_RENDER_AFTER_NEEDS_COLLECTION_MARKER = "score: render-after-needs-collection"


def _base_need_id(need_id: str) -> str:
"""Strip link conditions from an ID used to look up a merged Need."""
return need_id.split("[", 1)[0]


def _find_need(needs: dict[str, NeedItem], need_id: str) -> NeedItem | None:
"""Find a Need by its address, tolerating version-qualified collection keys."""
base_id = _base_need_id(need_id)
for candidate_id in (need_id, base_id):
candidate = needs.get(candidate_id)
if candidate is not None:
return candidate

# Some imported collections use a qualified dictionary key even though the
# NeedItem itself keeps the canonical, unqualified ID.
for candidate_id, candidate in needs.items():
if _base_need_id(candidate_id) == base_id or candidate["id"] == base_id:
return candidate
return None


def _needs_template_folder() -> Path:
"""Locate the shared ``.need`` template directory for Sphinx-Needs."""
template_folder = Path(__file__).parents[2] / "needs_templates"
if not template_folder.is_dir():
raise FileNotFoundError(
f"Sphinx-Needs template folder does not exist: {template_folder}"
)
return template_folder


class _LinkedNeeds:
"""Provide link traversal to Need templates as a pickleable callable.

Calling the object with a Need ID and a link field returns the target
``NeedItem`` objects in the order declared by the source Need. This lets a
template derive sections from the Need graph instead of embedding IDs.

The object is deliberately a top-level class instance because Sphinx puts
the render context into its parallel-reader configuration. A plain
function would make that configuration unpickleable. The build environment
is kept process-local and captured once Sphinx has created ``app.env``.
"""

def __call__(self, need_id: str, link_name: str) -> list[NeedItem]:
if _template_environment is None:
return []

needs = SphinxNeedsData(_template_environment).get_needs_mutable()
source = _find_need(needs, need_id)
if source is None:
return []

linked: list[NeedItem] = []
for link in source.get_links(link_name, as_str=False):
target = _find_need(needs, link.to_link_string())
if target is not None:
linked.append(target)
return linked


_linked_needs_callable = _LinkedNeeds()


def _complex_post_template_names(app: Sphinx) -> set[str]:
"""Return post-template names opting into the post-merge rendering pass."""
template_folder = _needs_template_folder()
return {
template.stem
for template in template_folder.glob("*.need")
if (
_RENDER_AFTER_NEEDS_COLLECTION_MARKER
in template.read_text(encoding="utf-8")
)
}


def _rerender_pages_with_complex_post_templates(
app: Sphinx, env: BuildEnvironment
) -> list[str]:
"""Re-read marked post-template pages after Need environments are merged.

Post-templates are expanded while source documents are read. A parallel
worker cannot see Needs collected by other workers at that point. Marked
pages are therefore purged and read once more from the main environment
before Sphinx-Needs post-processing begins.
"""
if app.builder.name != "html":
return []

complex_post_templates = _complex_post_template_names(app)
if not complex_post_templates:
return []

needs_data = SphinxNeedsData(env)
if needs_data.needs_is_post_processed:
return []

complex_post_template_docs: set[str] = set()
for need in needs_data.get_needs_mutable().values():
post_template = need.get("post_template")
if (
not isinstance(post_template, str)
or post_template not in complex_post_templates
):
continue
docname = need["docname"]
if isinstance(docname, str) and docname:
complex_post_template_docs.add(docname)

pages_to_rerender = sorted(complex_post_template_docs)
for docname in pages_to_rerender:
app.emit("env-purge-doc", env, docname)
env.clear_doc(docname)
app.builder.read_doc(docname)

return pages_to_rerender


def _capture_template_environment(app: Sphinx) -> None:
"""Give the link helper the environment in which it should resolve Needs.

The helper is registered during ``setup()``, but Sphinx creates ``app.env``
only after extension setup has completed. ``builder-inited`` is the first
lifecycle event at which the final build environment is available.
"""
global _template_environment
_template_environment = app.env


def setup(app: Sphinx) -> dict[str, object]:
"""Install Sphinx-Needs template helpers and the marked-page second pass."""
app.setup_extension("sphinx_needs")

config_setdefault(
app.config, "needs_template_folder", str(_needs_template_folder())
)
app.config.needs_render_context.setdefault("linked_needs", _linked_needs_callable)
app.connect("builder-inited", _capture_template_environment)
app.connect("env-updated", _rerender_pages_with_complex_post_templates)

return {
"version": "1.0.0",
"parallel_read_safe": True,
"parallel_write_safe": True,
}
Loading
Loading