From 6a292b5909298fdf7079b32d1a4a2ffdfdef1af0 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Sat, 29 Aug 2026 00:35:29 +0200 Subject: [PATCH] Add source bundle upward Needs exports --- bzl/bundle_rules.bzl | 52 +++- default_conf.py.tpl | 6 +- docs.bzl | 235 +++++++++++++++++- docs/reference/bazel_macros.rst | 59 ++++- .../score_metamodel/external_needs.py | 87 +++++-- .../tests/test_external_needs.py | 42 ++++ src/tests/docs_bzl/README.md | 8 +- .../docs_bzl/scenarios/upward_bundles/BUILD | 25 ++ .../upward_bundles/component/index.rst | 36 +++ .../upward_bundles/platform/index.rst | 35 +++ src/tests/docs_bzl/test_upward_bundles.py | 56 +++++ 11 files changed, 613 insertions(+), 28 deletions(-) create mode 100644 src/tests/docs_bzl/scenarios/upward_bundles/BUILD create mode 100644 src/tests/docs_bzl/scenarios/upward_bundles/component/index.rst create mode 100644 src/tests/docs_bzl/scenarios/upward_bundles/platform/index.rst create mode 100644 src/tests/docs_bzl/test_upward_bundles.py diff --git a/bzl/bundle_rules.bzl b/bzl/bundle_rules.bzl index 7145741f4..08568d55d 100644 --- a/bzl/bundle_rules.bzl +++ b/bzl/bundle_rules.bzl @@ -70,6 +70,11 @@ DocsBundleInfo = provider( # these are resolved at this bundle's mount (for example, a generated # index.rst). "data": "Bundle-owned generated/supporting files resolved at the bundle's mount.", + # Keep the direct declarations separate from the closure. The macro + # needs the direct labels to create the immediate Sphinx inputs, while + # consumers of the provider need the complete ancestor set. + "direct_upward_bundles": "The bundle targets declared directly in upward_bundles.", + "upward_bundles": "Depset containing the direct upward dependencies and their transitive closure.", }, ) @@ -233,12 +238,37 @@ def _parse_bundle_declaration(bundle): ) def _docs_bundle_impl(ctx): - """Compose source files and nested bundles into a reusable bundle.""" + """Compose a bundle and propagate both content and hierarchy metadata. + + ``bundles`` and ``upward_bundles`` describe two different graphs: + + * ``bundles`` is the content graph. Its entries are mounted into this + bundle and therefore contribute source files and data. + * ``upward_bundles`` is the Needs dependency graph. It does not mount any + files; it only makes the ancestors' merged Needs exports available to + the bundle's own Needs build. + + Keeping these graphs separate is intentional. A bundle may contain a + nested documentation subtree without depending on that subtree's Needs, + and a bundle may depend on an ancestor's Needs without mounting the + ancestor's sources. + """ entries = [] own_source_files = [] own_external_runfiles = [] own_data = depset(direct = ctx.files.data) + # Propagate the complete ancestor closure. ``docs.bzl`` uses the direct + # labels for the current Sphinx invocation, while the closure makes the + # hierarchy available transitively to future bundle consumers. + upward_bundles = depset( + direct = ctx.attr.upward_bundles, + transitive = [ + upward_bundle[DocsBundleInfo].upward_bundles + for upward_bundle in ctx.attr.upward_bundles + ], + ) + if ctx.files.srcs: runtime_path = _bundle_runtime_path(ctx) external = runtime_path.startswith("../") @@ -317,6 +347,8 @@ def _docs_bundle_impl(ctx): sourcelinks = sourcelinks, external_runfiles = external_runfiles, data = all_data, + direct_upward_bundles = ctx.attr.upward_bundles, + upward_bundles = upward_bundles, ), ] @@ -330,13 +362,26 @@ _docs_bundle = rule( "bundles": attr.label_list(providers = [DocsBundleInfo]), "bundle_mount_ats": attr.string_list(), "bundle_attach_tos": attr.string_list(), + "upward_bundles": attr.label_list( + providers = [DocsBundleInfo], + doc = "Ancestor bundles whose merged Needs exports are available to this bundle.", + ), "data": attr.label_list(allow_files = True), }, doc = "Internal rule that carries bundle files and their documentation-tree locations.", ) -def create_bundle(name, bundles, srcs = [], sourcelinks = [], strip_prefix = "", entry_doc = "index", data = [], visibility = None, **kwargs): - """Create a reusable documentation bundle from files and child declarations.""" +def create_bundle(name, bundles, srcs = [], sourcelinks = [], strip_prefix = "", entry_doc = "index", data = [], visibility = None, upward_bundles = [], **kwargs): + """Create a reusable bundle from files, child bundles, and Needs ancestors. + + ``bundles`` are content composition: their sources and data become part of + this bundle and are rebased below their declared mount points. + ``upward_bundles`` are deliberately not content composition. They describe + which already-built Needs exports are available when this bundle's own + sources are processed. The distinction prevents an ancestor's source tree + from being mounted or exported a second time just because its Needs are + needed for link resolution. + """ parsed_bundles = [_parse_bundle_declaration(declaration) for declaration in bundles] _docs_bundle( name = name, @@ -347,6 +392,7 @@ def create_bundle(name, bundles, srcs = [], sourcelinks = [], strip_prefix = "", bundles = [bundle.bundle for bundle in parsed_bundles], bundle_mount_ats = [bundle.mount_at for bundle in parsed_bundles], bundle_attach_tos = [bundle.attach_to for bundle in parsed_bundles], + upward_bundles = upward_bundles, data = data, visibility = visibility, **kwargs diff --git a/default_conf.py.tpl b/default_conf.py.tpl index d7455cbb2..026d84720 100644 --- a/default_conf.py.tpl +++ b/default_conf.py.tpl @@ -10,13 +10,17 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -# Default Sphinx configuration emitted by the ``docs()`` macro. +# Default Sphinx configuration emitted by the ``docs()`` and +# ``docs_bundle()`` macros. # SCORE Docs-as-Code owns these baseline settings. Projects needing further # Sphinx configuration can provide their own conf.py instead. 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 diff --git a/docs.bzl b/docs.bzl index 56eaa297a..ef6ffea03 100644 --- a/docs.bzl +++ b/docs.bzl @@ -11,8 +11,20 @@ # 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 has two independent responsibilities: + +* it describes which documentation files belong to a reusable bundle and how + nested bundles are composed; and +* for source-bearing bundles, it creates optional Needs exports that can be + consumed by another bundle higher in the documentation hierarchy. + +The Needs targets are deliberately split into an own export (``*_needs_local``) +and a merged upward export (``*_needs_upward``). The own Sphinx build reads +ancestor exports as external context so links can be checked without emitting +those ancestor Needs a second time. The merge then creates the artifact that a +child bundle can use as its complete ancestor context. """ # Multiple approaches are available to build the same documentation output: @@ -75,6 +87,13 @@ def _module_name_without_prefix(): return module_name.split("_", 1)[-1] def _generated_conf_impl(ctx): + """Generate a Sphinx config at the source-root path expected by sphinxdocs. + + ``docs()`` and a source-bearing ``docs_bundle()`` may both need a config, + but they use the same template and differ only in the values substituted + into it. In particular, ``entry_doc`` matters for a bundle whose canonical + 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 +102,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,15 +113,53 @@ _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, default = Label("@score_docs_as_code//:default_conf.py.tpl"), ), }, + doc = "Generate the default conf.py used by a docs() or docs_bundle() Sphinx build.", ) -def docs_bundle(name, source_dir = None, data = [], entry_doc = "index", bundles = [], scan_code = [], code_targets = [], visibility = None, **kwargs): +def _bundle_upward_needs_label(bundle): + """Return the merged Needs target generated for a ``docs_bundle`` label. + + A child must import the parent's merged export rather than the parent's + own-only export. That makes a chain such as ``child -> parent -> platform`` + work without requiring every child to list all ancestors explicitly. The + label normalization is needed because a macro may receive a shorthand + label (``:parent`` or ``parent``) as well as a fully qualified or external + label. + """ + bundle_string = str(bundle) + if bundle_string.startswith(":"): + bundle_string = "//" + native.package_name() + bundle_string + elif not bundle_string.startswith("//") and not bundle_string.startswith("@"): + bundle_string = "//" + native.package_name() + ":" + bundle_string + return Label(bundle_string + "_needs_upward") + +def _external_needs_label(label): + """Convert a Starlark label to the spelling accepted by the Sphinx extension. + + Bazel may expose repository labels in canonical ``@@repo+//...`` form, + while ``external_needs_source`` is parsed as a normal ``@repo//...`` or + ``//...`` label. The extension needs the latter spelling to find the same + target in the action's runfiles tree. + """ + label_string = str(label) + if label_string.startswith("@@//"): + return label_string[2:] + if label_string.startswith("@@"): + canonical = label_string[2:] + repository, separator, package_and_target = canonical.partition("//") + if repository.endswith("+"): + repository = repository[:-1] + return "@" + repository + separator + package_and_target + return label_string + +def docs_bundle(name, source_dir = None, data = [], entry_doc = "index", bundles = [], scan_code = [], code_targets = [], visibility = None, upward_bundles = [], bundle_conf = None, **kwargs): """A docs bundle, optionally composed of others. Args: @@ -118,6 +176,14 @@ def docs_bundle(name, source_dir = None, data = [], entry_doc = "index", bundles only bundle data travels with a mounted bundle. entry_doc: bundle-relative docname attached when this bundle is mounted. Defaults to `index`. + upward_bundles: docs_bundle targets in the documentation hierarchy above + this bundle. Their merged Needs exports are available while this + bundle's own sources are processed. This is a dependency declaration, + not a request to mount the ancestors' documentation sources. + bundle_conf: Optional Sphinx conf.py label to reuse for the local Needs + export. This is used internally by `docs()` because its project-level + config is already generated at the bundle source root. Reusing it + avoids creating a second config at the same path. bundles: nested bundles to compose, each a dict { "bundle": , @@ -148,7 +214,9 @@ def docs_bundle(name, source_dir = None, data = [], entry_doc = "index", bundles 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 "" @@ -160,11 +228,169 @@ def docs_bundle(name, source_dir = None, data = [], entry_doc = "index", bundles strip_prefix = strip_prefix, entry_doc = entry_doc, bundles = bundles, + upward_bundles = upward_bundles, data = data, visibility = visibility, **kwargs ) + # These are the *direct* ancestors declared by this bundle. Each label + # points to the ancestor's merged export, which already contains that + # ancestor's own ancestors. Passing only these direct exports avoids + # duplicate external imports while preserving the hierarchy's transitive + # closure. + upward_needs = [_bundle_upward_needs_label(bundle) for bundle in upward_bundles] + + # The local export contains only this bundle's own Needs. The Sphinx action + # nevertheless receives the declared upward exports as external context, + # so links in the own sources to Needs above this bundle are resolved. The + # upward export is merged from the local output and those same inputs below. + # In particular, a pure-data bundle must not acquire a Sphinx build merely + # because it is mounted next to a hierarchy-aware source bundle. + if 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 = name + "_needs_sources", + bundle = ":" + name, + visibility = visibility, + ) + + # Sphinx expects conf.py below the source root. Prefer a caller-provided + # config, and otherwise generate the same default config used by docs(). + # ``bundle_conf`` is what lets docs() reuse its already-generated + # project config instead of declaring a second file at source_dir/conf.py. + config_file_path = join_path(source_dir, "conf.py") + if bundle_conf: + needs_config = bundle_conf + elif native.glob([config_file_path], allow_empty = True): + needs_config = ":" + config_file_path + else: + needs_config = ":" + name + "_needs_conf" + _generated_conf( + name = name + "_needs_conf", + project = name, + project_url = "", + required_in_id = "", + entry_doc = entry_doc, + output_path = config_file_path, + ) + + # 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 = ":" + name + "_needs_sourcelinks_json" + _sourcelinks_json( + name = name + "_needs_sourcelinks_json", + srcs = [], + ) + elif len(sourcelinks) == 1: + needs_sourcelinks = sourcelinks[0] + else: + needs_sourcelinks_name = 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 = _missing_requirements([]) + [ + Label("//src:plantuml_for_python"), + Label("//src/extensions/score_sphinx_bundle:score_sphinx_bundle"), + ] + # ``data`` puts the ancestor labels into the Sphinx binary's runfiles; + # ``tools`` on sphinx_docs below makes the corresponding generated JSON + # files available inside the sandboxed action itself. + sphinx_build_binary( + name = name + "_needs_sphinx_build", + data = upward_needs, + deps = needs_deps, + visibility = visibility, + tags = ["manual"], + ) + + source_strip_prefix = join_path(native.package_name(), source_dir) + if source_strip_prefix: + source_strip_prefix += "/" + + # 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 with the ancestors available as external Needs. + # Sphinx-needs uses those files for link resolution, but the exporter + # still writes only the non-external Needs defined by this source tree. + sphinx_docs( + name = 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 = [ + "--keep-going", + "-T", + "--define=external_needs_source=" + str([ + _external_needs_label(label) + for label in upward_needs + ]), + "--define=score_sourcelinks_json=$(location " + str(needs_sourcelinks) + ")", + ], + formats = ["needs"], + sphinx = ":" + name + "_needs_sphinx_build", + tools = upward_needs + [needs_sourcelinks], + visibility = visibility, + allow_persistent_workers = False, + tags = ["manual"], + ) + + # Do not ask Sphinx to re-export the external ancestors. A small merge + # action is sufficient and preserves the exact JSON produced by each + # bundle. This is also what makes the result reusable by the next + # bundle in the hierarchy. + needs_upward = name + "_needs_upward" + merge_inputs = [":" + name + "_needs_local"] + upward_needs + merge_command = "$(location //scripts_bazel:merge_needs_json) --output $@ $(location :" + name + "_needs_local)/needs.json" + for input_label in upward_needs: + merge_command += " $(location " + str(input_label) + ")" + native.genrule( + name = needs_upward, + srcs = merge_inputs, + outs = [needs_upward + "/needs.json"], + cmd = merge_command, + tools = [Label("//scripts_bazel:merge_needs_json")], + visibility = visibility, + tags = ["manual"], + ) + elif upward_needs and not data: + # A source-less hierarchy group has no own Needs to build, so it can + # forward the already-merged exports unchanged. Data-only bundles are + # intentionally excluded: generated files may contain RST/Needs, but + # this PR has no source-root Sphinx step that could process them. Do + # not silently invent a partial export for such a bundle. + needs_upward = name + "_needs_upward" + merge_command = "$(location //scripts_bazel:merge_needs_json) --output $@ $(location " + str(upward_needs[0]) + ")" + for input_label in upward_needs[1:]: + merge_command += " $(location " + str(input_label) + ")" + native.genrule( + name = needs_upward, + srcs = upward_needs, + outs = [needs_upward + "/needs.json"], + cmd = merge_command, + tools = [Label("//scripts_bazel:merge_needs_json")], + visibility = visibility, + tags = ["manual"], + ) + def _missing_requirements(deps): """Add Python hub dependencies if they are missing.""" found = [] @@ -347,6 +573,7 @@ def docs( name = "docs_bundle", source_dir = source_dir, entry_doc = "index", + bundle_conf = sphinx_config, bundles = bundles, scan_code = scan_code, code_targets = code_targets, diff --git a/docs/reference/bazel_macros.rst b/docs/reference/bazel_macros.rst index d6f2ffc43..6caccee63 100644 --- a/docs/reference/bazel_macros.rst +++ b/docs/reference/bazel_macros.rst @@ -168,7 +168,7 @@ site). visibility = ["//visibility:public"], ) -Signature: ``docs_bundle(name, source_dir = None, data = [], entry_doc = "index", bundles = [], scan_code = [], code_targets = [], visibility = None)``. +Signature: ``docs_bundle(name, source_dir = None, data = [], entry_doc = "index", bundles = [], scan_code = [], code_targets = [], visibility = None, upward_bundles = [])``. - ``source_dir`` (string, optional) Directory holding the bundle's own doc sources. It is globbed the same way as @@ -206,10 +206,59 @@ Signature: ``docs_bundle(name, source_dir = None, data = [], entry_doc = "index" ``index`` document receives it. A child's ``mount_at``/``attach_to`` **prefix-stack** with the placement this - bundle later receives, so composition is fully transitive. The same underlying - bundle resolving to two different final ``mount_at`` values is a hard build - error. See :ref:`howto_mount_external_sources` for a worked example and - :ref:`docs_concept_mounts` for the composition and transitivity semantics. + bundle later receives, so composition is fully transitive. The same underlying + bundle resolving to two different final ``mount_at`` values is a hard build + error. See :ref:`howto_mount_external_sources` for a worked example and + :ref:`docs_concept_mounts` for the composition and transitivity semantics. + +- ``upward_bundles`` (list of ``docs_bundle`` labels, optional) + Documentation bundles whose Needs are above this bundle in the documentation + hierarchy. A source-bearing bundle creates ``_needs_local`` containing + its direct sources and ``_needs_upward`` for the merged export of those + sources and its declared ancestors. While producing the local export, the + ancestor exports are available as external context so links in the local + sources are resolved; the ancestor Needs are not repeated in the local output. + Here, ``local`` describes the Needs emitted by the target, not the complete + set of Needs available while it is built. + Data-only bundles do not create Needs targets. Support for Needs declared in + generated bundle data is intentionally left for a later change. + +Needs exports between bundles +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``upward_bundles`` describes a Needs dependency only. The listed bundles are +not mounted as documentation sources. Each source-bearing bundle creates two +internal artifacts: + +.. code-block:: text + + platform/index.rst + │ Sphinx, own sources only + ▼ + platform_needs_local + │ merge_needs_json + ▼ + platform_needs_upward + + component/index.rst + platform_needs_upward as external context + │ Sphinx, own sources only; links to platform are resolved + ▼ + component_needs_local + │ merge_needs_json(platform_needs_upward) + ▼ + component_needs_upward + +``*_needs_local`` contains only the Needs from the bundle's own sources. The +upward exports are loaded only as external resolution context during this Sphinx +run. ``*_needs_upward`` is then the reusable merge of the own export and the +already-merged ancestors. A child bundle can therefore import only its direct +ancestor and still receive the complete transitive hierarchy. + +This separation prevents duplicate Needs and keeps ownership clear: every +bundle exports its own sources, while the hierarchy is propagated through the +JSON dependency only. The link direction is intentionally upward: own Needs +may reference own or ancestor Needs; references to sibling or descendant +bundles are outside this local export model. .. note:: diff --git a/src/extensions/score_metamodel/external_needs.py b/src/extensions/score_metamodel/external_needs.py index 51f927a21..f8dc085d8 100644 --- a/src/extensions/score_metamodel/external_needs.py +++ b/src/extensions/score_metamodel/external_needs.py @@ -28,6 +28,15 @@ @dataclass class ExternalNeedsSource: + """A Bazel label whose generated JSON is imported as external Needs. + + ``path_to_target`` and ``target`` are kept separately because Bazel stores + ordinary ``needs_json_file`` outputs directly below the package, while a + generated bundle target stores its output below + ``//needs.json``. ``is_local`` selects the corresponding + runfiles root (``_main`` versus the external module directory). + """ + bazel_module: str path_to_target: str target: str @@ -38,6 +47,14 @@ class ExternalNeedsSource: def _parse_bazel_external_need(s: str) -> ExternalNeedsSource | None: + """Parse a supported external-needs label from Bazel configuration. + + The extension receives a mixture of ordinary local targets and external + data targets. Only labels that can provide Needs JSON (including the + generated ``*_needs_upward`` bundle targets) become + ``ExternalNeedsSource`` objects; unrelated labels are ignored so callers + can pass the complete ``data`` list through this parser. + """ is_cross_module = s.startswith("@") is_local = s.startswith("//") if not is_cross_module and not is_local: @@ -54,7 +71,11 @@ def _parse_bazel_external_need(s: str) -> ExternalNeedsSource | None: repo, path_to_target = repo_and_path.split("//", 1) repo = repo.lstrip("@") # empty for same-repo `//pkg:needs_json` - if target in ("needs_json", "needs_json_file", "docs_sources"): + # A bundle's own-only export is not a valid dependency for another bundle: + # only the merged upward target carries the complete ancestor chain. + if target in ("needs_json", "needs_json_file", "docs_sources") or target.endswith( + "_needs_upward" + ): return ExternalNeedsSource( bazel_module=repo, path_to_target=path_to_target, @@ -75,6 +96,7 @@ def _runfiles_module_dir(e: ExternalNeedsSource) -> str: def parse_external_needs_sources_from_DATA(v: str) -> list[ExternalNeedsSource]: + """Decode the JSON list passed by a Bazel ``--define`` option.""" if v in ["[]", ""]: return [] @@ -130,10 +152,17 @@ def parse_external_needs_sources_from_bazel_query() -> list[ExternalNeedsSource] return res -def extend_needs_json_exporter(config: Config, params: list[str]) -> None: - """ - This will add each param to app.config as a config value. - Then it will overwrite the needs.json exporter to include these values. +def extend_needs_json_exporter( + config: Config, params: list[str], *, log_missing: bool = True +) -> None: + """Register config fields and add them to every exported Needs JSON. + + ``sphinx-needs`` does not export arbitrary Sphinx config values. This + helper therefore wraps its private finalisation hook and copies the + requested fields into the JSON document. ``log_missing`` is optional + because host projects are expected to configure these fields, whereas a + bundle-local export may intentionally leave ``project_url`` empty until a + host consumes the merged artifact. """ for p in params: @@ -141,7 +170,7 @@ 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): + if log_missing and not getattr(config, p): logger.error( f"Config value '{p}' is not set. " + "Please set it in your Sphinx config." @@ -161,6 +190,7 @@ def temp(self: NeedsList): def get_external_needs_source(external_needs_source: str) -> list[ExternalNeedsSource]: + """Get external-needs declarations from Bazel or the local fallback query.""" if external_needs_source: # Path taken for all invocations via `bazel` return parse_external_needs_sources_from_DATA(external_needs_source) @@ -228,9 +258,26 @@ def add_external_docs_sources(e: ExternalNeedsSource, config: Config): 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) + """Connect Bazel-generated Needs files to the current Sphinx application. + + The connection happens during Sphinx initialisation. Imported files are + registered as ``needs_external_needs`` inputs, which lets local source + documents link to ancestor Needs without copying those ancestors into the + local export. A bundle's ``*_needs_upward`` file is therefore used as an + input here; it is merged into the next bundle's export only after this + bundle's own Sphinx run has completed. + """ + # Host documentation configs normally provide project_url. Bundle-local + # exports deliberately do not: their JSON can be consumed by different + # documentation hosts, which provide the canonical URL when importing it. + # Config values from conf.py are not exposed on Sphinx's Config object until + # they are registered. Register the field unconditionally so a host's + # project_url is preserved; bundle-local configs may legitimately omit it. + extend_needs_json_exporter(config, ["project_url"], log_missing=False) + + # Local external needs from DATA (e.g. :needs_json or :docs_sources). + # ``external_needs_source`` is a serialized list because the Sphinx action + # runs in a Bazel sandbox and cannot discover Starlark dependencies itself. external_needs = get_external_needs_source(app.config.external_needs_source) # this sets the default value - required for the needs-config-writer @@ -240,7 +287,9 @@ def connect_external_needs(app: Sphinx, config: Config): for e in external_needs: if e.target == "needs_json": add_external_needs_json(e, app.config) - elif e.target == "needs_json_file": + # Bundle consumers intentionally import only the merged upward export; + # importing ``*_needs_local`` would silently drop the ancestor chain. + elif e.target == "needs_json_file" or e.target.endswith("_needs_upward"): _add_needs_json_file(e, app.config) elif e.target == "docs_sources": add_external_docs_sources(e, app.config) @@ -251,10 +300,20 @@ def connect_external_needs(app: Sphinx, config: Config): def _add_needs_json_file(ext_needs: ExternalNeedsSource, config: Config) -> None: - """Resolve a needs_json_file target from runfiles and register it.""" - json_file_raw = ( - Path(_runfiles_module_dir(ext_needs)) / ext_needs.path_to_target / "needs.json" - ) + """Resolve a Needs JSON target from runfiles and register it with Sphinx. + + The legacy ``needs_json_file`` target is a file directly below its package. + A bundle's ``*_needs_upward`` target is a directory output containing + ``needs.json``. Both are registered through the same sphinx-needs mechanism; + only their Bazel output paths differ. + """ + json_file_raw = Path(_runfiles_module_dir(ext_needs)) / ext_needs.path_to_target + if ext_needs.target.endswith("_needs_upward"): + # Bundle output groups are directories so they can also carry the + # metrics and other Sphinx artifacts. The Needs file is one level below + # the generated target directory. + json_file_raw /= ext_needs.target + json_file_raw /= "needs.json" r = get_runfiles_dir() json_file = r / json_file_raw logger.debug(f"External needs_json_file: {json_file}") diff --git a/src/extensions/score_metamodel/tests/test_external_needs.py b/src/extensions/score_metamodel/tests/test_external_needs.py index 4c834590c..235a46593 100644 --- a/src/extensions/score_metamodel/tests/test_external_needs.py +++ b/src/extensions/score_metamodel/tests/test_external_needs.py @@ -101,6 +101,18 @@ def test_single_entry_json_no_path(): ] +def test_named_bundle_needs_upward_entry(): + result = parse_external_needs_sources_from_DATA('["//docs:component_needs_upward"]') + assert result == [ + ExternalNeedsSource( + bazel_module="", + path_to_target="docs", + target="component_needs_upward", + is_local=True, + ) + ] + + def test_multiple_entries(): result = parse_external_needs_sources_from_DATA( '["@repo1//:needs_json", "@repo2//:needs_json"]' @@ -238,6 +250,36 @@ def test_add_needs_json_file_appends_entry( assert Path(entry["json_path"]) == json_path +def test_add_named_bundle_needs_upward_appends_entry( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Named bundle exports resolve from their target-specific output directory.""" + rel_json = Path("_main/docs/component_needs_upward/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/bundle"}), + encoding="utf-8", + ) + + config = Config() + config.needs_external_needs = [] + monkeypatch.setattr(ext_needs, "get_runfiles_dir", lambda: tmp_path) + + _add_needs_json_file( + ExternalNeedsSource( + bazel_module="", + target="component_needs_upward", + path_to_target="docs", + is_local=True, + ), + config, + ) + + assert config.needs_external_needs is not None + assert Path(config.needs_external_needs[0]["json_path"]) == json_path + + def test_add_external_needs_json_missing_file_keeps_list_empty( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: diff --git a/src/tests/docs_bzl/README.md b/src/tests/docs_bzl/README.md index b270555a9..59e007caa 100644 --- a/src/tests/docs_bzl/README.md +++ b/src/tests/docs_bzl/README.md @@ -23,7 +23,8 @@ docs_bzl/ │ ├── subdirectory_bundle/ │ ├── external_bundle/ │ ├── local_version_mismatch/ -│ └── invalid_bundle_placements/ +│ ├── invalid_bundle_placements/ +│ └── upward_bundles/ └── test_.py ``` @@ -36,6 +37,11 @@ The cross-module compatibility test creates its consumer in a temporary workspace, so this repository's production ``MODULE.bazel`` stays free of test dependencies while the test still traverses real Bzlmod module boundaries. +The ``upward_bundles`` scenario documents the Needs-specific split: a component +build receives the platform bundle as external resolution context, exports only +the component's own Needs through ``component_needs_local``, and exposes the +complete platform-plus-component set through ``component_needs_upward``. + Note that these tests run `bazel` commands, so they are slow. They need to be executed sequentially. Use sparingly. They do not call `bazel clean`, so the persistent Bazel server and its action, repository, and disk caches are reused between diff --git a/src/tests/docs_bzl/scenarios/upward_bundles/BUILD b/src/tests/docs_bzl/scenarios/upward_bundles/BUILD new file mode 100644 index 000000000..62d61f478 --- /dev/null +++ b/src/tests/docs_bzl/scenarios/upward_bundles/BUILD @@ -0,0 +1,25 @@ +# ******************************************************************************* +# 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("//:docs.bzl", "docs_bundle") + +docs_bundle( + name = "platform", + source_dir = "platform", +) + +docs_bundle( + name = "component", + source_dir = "component", + upward_bundles = [":platform"], +) diff --git a/src/tests/docs_bzl/scenarios/upward_bundles/component/index.rst b/src/tests/docs_bzl/scenarios/upward_bundles/component/index.rst new file mode 100644 index 000000000..3d16934d8 --- /dev/null +++ b/src/tests/docs_bzl/scenarios/upward_bundles/component/index.rst @@ -0,0 +1,36 @@ +.. + # ******************************************************************************* + # 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 + # ******************************************************************************* + +Component +========= + +.. comp:: Seat heating controller + :id: comp__seat_heating_controller + :version: 1 + :security: NO + :safety: QM + :status: valid + :belongs_to: feat__platform__seat_heating + +.. comp_req:: Controller temperature control + :id: comp_req__component__temperature_control + :version: 1 + :reqtype: Functional + :security: NO + :safety: QM + :status: valid + :derived_from: feat_req__platform__seat_heating + :satisfied_by: comp__seat_heating_controller + + The controller regulates the requested heating level. diff --git a/src/tests/docs_bzl/scenarios/upward_bundles/platform/index.rst b/src/tests/docs_bzl/scenarios/upward_bundles/platform/index.rst new file mode 100644 index 000000000..0f89340cf --- /dev/null +++ b/src/tests/docs_bzl/scenarios/upward_bundles/platform/index.rst @@ -0,0 +1,35 @@ +.. + # ******************************************************************************* + # 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 + # ******************************************************************************* + +Platform +======== + +.. feat:: Platform seat heating + :id: feat__platform__seat_heating + :version: 1 + :security: NO + :safety: QM + :status: valid + +.. feat_req:: Platform seat heating availability + :id: feat_req__platform__seat_heating + :version: 1 + :reqtype: Functional + :security: NO + :safety: QM + :status: valid + :valid_from: v1.0 + :satisfied_by: feat__platform__seat_heating + + The platform makes the seat heating capability available. diff --git a/src/tests/docs_bzl/test_upward_bundles.py b/src/tests/docs_bzl/test_upward_bundles.py new file mode 100644 index 000000000..c0154cd67 --- /dev/null +++ b/src/tests/docs_bzl/test_upward_bundles.py @@ -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 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Focused tests for source-only docs_bundle Needs exports.""" + +from src.tests.docs_bzl.helpers import built_output, load_needs, run_bazel, run_scenario + + +def test_source_bundle_exports_own_needs_and_declared_parent_needs(): + run_scenario("build", "upward_bundles", ":component_needs_local") + run_scenario("build", "upward_bundles", ":component_needs_upward") + + local_needs = load_needs( + built_output( + "scenarios/upward_bundles", + "component_needs_local/_build/needs/needs.json", + ) + ) + needs = load_needs( + built_output( + "scenarios/upward_bundles", + "component_needs_upward/needs.json", + ) + ) + + assert { + "comp__seat_heating_controller", + "comp_req__component__temperature_control", + } <= local_needs.keys() + assert "feat_req__platform__seat_heating" not in local_needs + + assert { + "feat__platform__seat_heating", + "feat_req__platform__seat_heating", + "comp__seat_heating_controller", + "comp_req__component__temperature_control", + } <= needs.keys() + + +def test_data_only_bundle_does_not_get_needs_targets(): + run_bazel( + [ + "query", + "//src/tests/docs_bzl/scenarios/data_files_runfiles:data_bundle_needs_local", + ], + expect_error=True, + )