diff --git a/bzl/bundle_rules.bzl b/bzl/bundle_rules.bzl index 737dc62f0..631e6df43 100644 --- a/bzl/bundle_rules.bzl +++ b/bzl/bundle_rules.bzl @@ -488,12 +488,13 @@ _bundle_source_files = rule( doc = "Exposes direct bundle sources without nested bundle sources.", ) -def bundle_source_files(name, bundle, visibility = None): +def bundle_source_files(name, bundle, visibility = None, tags = None): """Create a target containing only the direct sources of a bundle.""" _bundle_source_files( name = name, bundle = bundle, visibility = visibility, + tags = tags, ) return ":" + name diff --git a/default_conf.py.tpl b/default_conf.py.tpl index d7455cbb2..b82018d2d 100644 --- a/default_conf.py.tpl +++ b/default_conf.py.tpl @@ -10,17 +10,22 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -# Default Sphinx configuration emitted by the ``docs()`` macro. -# SCORE Docs-as-Code owns these baseline settings. Projects needing further -# Sphinx configuration can provide their own conf.py instead. +# Default Sphinx configuration emitted by the ``docs()`` and +# ``docs_bundle()`` macros. +# SCORE Docs-as-Code owns these baseline settings. The ``docs()`` macro may +# use a project-provided conf.py; bundle-local Needs exports always use this +# generated configuration. project = {PROJECT} project_url = {PROJECT_URL} version = "0.0.0" +# ``docs_bundle(entry_doc = ...)`` may use a non-index entry page. The regular +# project-level docs() build uses the default value, ``index``. +master_doc = {ENTRY_DOC} # Allow feature IDs that use the Bazel module name without its first # underscore-separated prefix (for example, ``score_docs_as_code`` becomes -# ``docs_as_code``). A user-provided conf.py remains authoritative. +# ``docs_as_code``). A user-provided conf.py remains authoritative for docs(). required_in_id = {REQUIRED_IN_ID} extensions = ["score_sphinx_bundle"] diff --git a/docs.bzl b/docs.bzl index a863d512b..dba8833b4 100644 --- a/docs.bzl +++ b/docs.bzl @@ -11,8 +11,15 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -""" -Easy streamlined way for S-CORE docs-as-code. +"""Public Bazel macros for building and composing S-CORE documentation. + +The ``docs_bundle`` macro describes which documentation files belong to a +reusable bundle and how nested bundles are composed. Source-bearing bundles +also create a ``.__internal__.needs_local`` export containing Needs from +their own sources. This first version only supports self-contained bundles; +references to Needs defined outside the bundle remain unresolved until +cross-bundle imports are added. The top-level ``docs`` macro continues to use +its existing project-wide ``needs_json`` export. """ # Multiple approaches are available to build the same documentation output: @@ -74,7 +81,16 @@ def _module_name_without_prefix(): return "" return module_name.split("_", 1)[-1] +def _bundle_internal_target(name, target): + """Return the conventional name for a target internal to a bundle.""" + return name + ".__internal__." + target + def _generated_conf_impl(ctx): + """Generate a Sphinx config at the source-root path expected by sphinxdocs. + + ``entry_doc`` determines which document Sphinx treats as the project root + when a bundle's entry page is not named ``index``. + """ output = ctx.actions.declare_file(ctx.attr.output_path) ctx.actions.expand_template( template = ctx.file.template, @@ -83,6 +99,7 @@ def _generated_conf_impl(ctx): "{PROJECT}": repr(ctx.attr.project), "{PROJECT_URL}": repr(ctx.attr.project_url), "{REQUIRED_IN_ID}": repr([ctx.attr.required_in_id]) if ctx.attr.required_in_id else "[]", + "{ENTRY_DOC}": repr(ctx.attr.entry_doc), }, ) return [DefaultInfo(files = depset([output]))] @@ -93,6 +110,7 @@ _generated_conf = rule( "project": attr.string(mandatory = True), "project_url": attr.string(mandatory = True), "required_in_id": attr.string(mandatory = True), + "entry_doc": attr.string(default = "index"), "output_path": attr.string(mandatory = True), "template": attr.label( allow_single_file = True, @@ -114,6 +132,21 @@ def _is_needs_json_target(label): """ return str(label).rsplit(":", 1)[-1] == "needs_json" +def _bundle_sphinx_strip_prefix(source_dir): + """Return the source prefix used by a Sphinx action for this repository.""" + source_prefix = join_path(native.package_name(), source_dir) + repository = native.repo_name() + if repository: + # External repository files use ``..//`` in short_path, + # while the main repository uses paths without that leading segment. + source_prefix = join_path( + "../" + repository, + source_prefix, + ) + if source_prefix: + source_prefix += "/" + return source_prefix + def _declare_docs_bundle( name, source_dir = None, @@ -131,7 +164,7 @@ def _declare_docs_bundle( points. It deliberately contains no targets for consuming a bundle on its own; those can be added to the public ``docs_bundle`` wrapper without making ``docs()`` create them for the project root. - + It returns the source and sourcelink inputs needed by local bundle exports. Args: name: target name. source_dir: optional directory holding this bundle's own doc sources. It is @@ -187,7 +220,9 @@ def _declare_docs_bundle( sourcelinks.append(code_targets_sourcelinks) # Store the source directory relative to the workspace so bundle consumers - # can locate the original files without copying them. + # can locate the original files without copying them. The internal rule + # keeps this path in its provider; the Needs build below uses the same + # source root so docnames and link targets remain stable. pkg = native.package_name() strip_prefix = join_path(pkg, source_dir) if source_dir != None else "" @@ -217,6 +252,11 @@ def _declare_docs_bundle( **kwargs ) + return struct( + source_dir_globbed = source_dir_globbed, + sourcelinks = sourcelinks, + ) + def docs_bundle( name, source_dir = None, @@ -235,7 +275,7 @@ def docs_bundle( distinct home while allowing ``docs()`` to use the shared declaration for the project root. """ - _declare_docs_bundle( + bundle = _declare_docs_bundle( name = name, source_dir = source_dir, srcs = srcs, @@ -247,6 +287,106 @@ def docs_bundle( visibility = visibility, **kwargs ) + source_dir_globbed = bundle.source_dir_globbed + sourcelinks = bundle.sourcelinks + + if source_dir_globbed or srcs: + # ``bundle_source_files`` is important here: using the complete bundle + # would also feed nested child sources into this Sphinx invocation and + # export their Needs under the parent's local target. Ownership stays + # one-way: every source-bearing bundle exports only its own sources. + own_sources = bundle_source_files( + name = _bundle_internal_target(name, "needs_sources"), + bundle = ":" + name, + visibility = visibility, + tags = ["manual"], + ) + + # Sphinx expects conf.py below the source root. Bundle-local Needs + # exports always use a generated config so the bundle stays + # self-contained and does not depend on a caller-provided conf.py. + config_file_path = join_path(source_dir, "conf.py") + needs_config = ":" + _bundle_internal_target(name, "needs_conf") + _generated_conf( + name = _bundle_internal_target(name, "needs_conf"), + project = name, + project_url = "", + required_in_id = "", + entry_doc = entry_doc, + output_path = config_file_path, + tags = ["manual"], + ) + + # A Needs export also carries source-code-link metadata. Reuse the + # bundle's existing link file when there is one; create an empty file + # for the common no-code-target case; and merge multiple files when + # both deprecated scan_code and code_targets contributed inputs. + if len(sourcelinks) == 0: + needs_sourcelinks = ":" + _bundle_internal_target(name, "needs_sourcelinks_json") + _sourcelinks_json( + name = _bundle_internal_target(name, "needs_sourcelinks_json"), + srcs = [], + ) + elif len(sourcelinks) == 1: + needs_sourcelinks = sourcelinks[0] + else: + needs_sourcelinks_name = _bundle_internal_target(name, "needs_sourcelinks_json") + merge_bundle_sourcelinks( + name = needs_sourcelinks_name, + bundle = ":" + name, + visibility = visibility, + ) + needs_sourcelinks = ":" + needs_sourcelinks_name + + # This is the Sphinx executable used by the action below. The extension + # and PlantUML helper are explicit because a bundle-local export is a + # standalone Sphinx invocation, not the host docs() invocation. + needs_deps = all_requirements + [ + Label("//src:plantuml_for_python"), + Label("//src/extensions/score_sphinx_bundle:score_sphinx_bundle"), + ] + needs_sphinx_build = _bundle_internal_target(name, "needs_sphinx_build") + sphinx_build_binary( + name = needs_sphinx_build, + deps = needs_deps, + visibility = visibility, + tags = ["manual"], + ) + + source_strip_prefix = _bundle_sphinx_strip_prefix(source_dir) + + # The source files are declared with their workspace-relative paths, + # while sphinxdocs expects the prefix to remove from those paths before + # placing them below the temporary Sphinx source root. + # + # Build the own export from this bundle's sources only. References to + # Needs owned by another bundle are intentionally unsupported until + # cross-bundle imports are added. + needs_local = _bundle_internal_target(name, "needs_local") + sphinx_docs( + name = needs_local, + srcs = [own_sources], + config = needs_config, + # ``sphinxdocs`` removes this string literally from short_path. + # Keep the separator so a source_dir/conf.py is relocated as + # conf.py rather than /conf.py. + strip_prefix = source_strip_prefix, + extra_opts = [ + "-W", + "--keep-going", + "-T", + # Prevent the non-Bazel fallback query from importing the + # host project's external Needs into this standalone export. + "--define=external_needs_source=[]", + "--define=score_sourcelinks_json=$(location " + str(needs_sourcelinks) + ")", + ], + formats = ["needs"], + sphinx = ":" + needs_sphinx_build, + tools = [needs_sourcelinks], + visibility = visibility, + allow_persistent_workers = False, + tags = ["manual"], + ) def _missing_requirements(deps): """Add Python hub dependencies if they are missing.""" diff --git a/docs/reference/bazel_macros.rst b/docs/reference/bazel_macros.rst index 9459c5c6f..d78a68379 100644 --- a/docs/reference/bazel_macros.rst +++ b/docs/reference/bazel_macros.rst @@ -176,6 +176,8 @@ Signature: ``docs_bundle(name, source_dir = None, srcs = [], data = [], entry_do ``docs()`` (RST, Markdown, images, and the other doc file kinds). The ``source_dir`` itself is the mount root, so the files mount relative to it (so ``concept/index.rst`` with ``source_dir = "concept"`` becomes ``index.rst``). + Bundle-local Needs exports always use a generated Sphinx configuration; + user-provided ``conf.py`` files are not supported for bundles. The bundle exposes those files as a Bazel depset (via the ``DocsBundleInfo`` provider) and records the ``source_dir`` path; sphinx-mounts walks that original directory directly — no copy is made. Leave it unset for a bundle whose @@ -216,6 +218,14 @@ Signature: ``docs_bundle(name, source_dir = None, srcs = [], data = [], entry_do error. See :ref:`howto_mount_external_sources` for a worked example and :ref:`docs_concept_mounts` for the composition and transitivity semantics. +- ``needs_local`` (internal target) + A source-bearing bundle creates ``.__internal__.needs_local`` with the + Needs declared by its own sources. The standalone build is intentionally + self-contained in this version: references to Needs defined outside the + bundle remain unresolved and fail strict builds. Cross-bundle imports and + merged exports are planned for a later change. Data-only bundles do not + create a Needs target. + .. note:: A bundle is **placement-free**: its ``mount_at`` and ``attach_to`` are assigned diff --git a/src/extensions/score_metamodel/external_needs.py b/src/extensions/score_metamodel/external_needs.py index 51f927a21..6a74f1cb6 100644 --- a/src/extensions/score_metamodel/external_needs.py +++ b/src/extensions/score_metamodel/external_needs.py @@ -141,12 +141,6 @@ def extend_needs_json_exporter(config: Config, params: list[str]) -> None: # This is wrong. But good enough. config.add(p, default="", rebuild="env", types=(), description="") - if not getattr(config, p): - logger.error( - f"Config value '{p}' is not set. " - + "Please set it in your Sphinx config." - ) - # Patch json exporter to include our custom fields # Note: yeah, NeedsList is the json exporter! orig_function = NeedsList._finalise # pyright: ignore[reportPrivateUsage] diff --git a/src/extensions/score_metamodel/tests/test_external_needs.py b/src/extensions/score_metamodel/tests/test_external_needs.py index 5c8929b41..824ee33de 100644 --- a/src/extensions/score_metamodel/tests/test_external_needs.py +++ b/src/extensions/score_metamodel/tests/test_external_needs.py @@ -19,6 +19,8 @@ import json from pathlib import Path +from types import SimpleNamespace +from typing import cast import pytest import score_metamodel.external_needs as ext_needs @@ -31,6 +33,24 @@ parse_external_needs_sources_from_DATA, ) from sphinx.config import Config +from sphinx_needs.needsfile import NeedsList + + +def test_extend_needs_json_exporter_uses_configured_value( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The exporter reads the current project URL from Sphinx configuration.""" + config = Config() + config.project_url = "https://example.test/before" + + monkeypatch.setattr(NeedsList, "_finalise", lambda _needs_list: None) + ext_needs.extend_needs_json_exporter(config, ["project_url"]) + config.project_url = "https://example.test/after" + + needs_list = cast(NeedsList, SimpleNamespace(needs_list={})) + NeedsList._finalise(needs_list) # pyright: ignore[reportPrivateUsage] - white-box test + + assert needs_list.needs_list["project_url"] == "https://example.test/after" def test_empty_list(): diff --git a/src/tests/docs_bzl/scenarios/reference_integration/score_platform/BUILD b/src/tests/docs_bzl/scenarios/reference_integration/score_platform/BUILD index 25c676abe..aaa7af205 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/score_platform/BUILD +++ b/src/tests/docs_bzl/scenarios/reference_integration/score_platform/BUILD @@ -11,7 +11,7 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -load("//:docs.bzl", "docs") +load("//:docs.bzl", "docs", "docs_bundle") # This fixture represents the S-CORE platform documentation. It publishes a # feature requirement that is consumed by a software module and by the @@ -21,3 +21,12 @@ docs( project_url = "https://example.invalid/score-platform", source_dir = "docs", ) + +# Keep the project-wide ``docs()`` target above as the Needs producer consumed +# by the integration. This second declaration reuses the same self-contained +# source tree to exercise the reusable ``docs_bundle`` local Needs export +# without introducing another fixture with duplicate documentation content. +docs_bundle( + name = "standalone_bundle", + source_dir = "docs", +) diff --git a/src/tests/docs_bzl/test_reference_integration.py b/src/tests/docs_bzl/test_reference_integration.py index 0b31075a4..c451b0f26 100644 --- a/src/tests/docs_bzl/test_reference_integration.py +++ b/src/tests/docs_bzl/test_reference_integration.py @@ -15,7 +15,7 @@ import pytest -from src.tests.docs_bzl.helpers import load_needs, run_scenario +from src.tests.docs_bzl.helpers import built_output, load_needs, run_scenario def test_score_platform_publishes_feature_requirement(): @@ -28,6 +28,27 @@ def test_score_platform_publishes_feature_requirement(): assert "feat_req__platform__feature" in needs, sorted(needs) +def test_score_platform_bundle_exports_its_own_needs(): + """The self-contained platform source tree supports a local bundle export.""" + run_scenario( + "build", + "reference_integration/score_platform", + ":standalone_bundle.__internal__.needs_local", + ) + + needs = load_needs( + built_output( + "scenarios/reference_integration/score_platform", + "standalone_bundle.__internal__.needs_local/_build/needs/needs.json", + ) + ) + + assert { + "feat__platform_feature", + "feat_req__platform__feature", + } <= needs.keys() + + @pytest.mark.parametrize( "scenario", [