diff --git a/bzl/bundle_rules.bzl b/bzl/bundle_rules.bzl index 5fff287b3..44c548838 100644 --- a/bzl/bundle_rules.bzl +++ b/bzl/bundle_rules.bzl @@ -54,6 +54,10 @@ load("@score_docs_as_code//:bzl/basics.bzl", "join_path") +load( + "@sphinxdocs//sphinxdocs/private:sphinx_docs_library_info.bzl", + "SphinxDocsLibraryInfo", +) # Internal data passed between bundle targets and eventually consumed by an # adapter such as the Sphinx mounts manifest. Users configure bundles through @@ -63,6 +67,8 @@ DocsBundleInfo = provider( fields = { "entries": "Ordered entries, one per source directory, including its final documentation-tree location.", "own_source_files": "This bundle's direct source files, excluding nested bundles.", + "own_source_root": "Runtime path of this bundle's direct source root.", + "own_source_is_explicit": "Whether the direct sources came from explicit source targets.", "sourcelinks": "Source-code-link JSON files together with their owning repository.", "external_runfiles": "Documentation source files not read from the workspace at runtime.", # Bundle-owned supporting/runtime files. Unlike host-owned docs data, @@ -300,6 +306,8 @@ def _docs_bundle_impl(ctx): """Compose source files and nested bundles into a reusable bundle.""" entries = [] own_source_files = [] + own_source_root = "" + own_source_is_explicit = False own_external_runfiles = [] own_data = depset(direct = ctx.files.data) @@ -311,6 +319,7 @@ def _docs_bundle_impl(ctx): if ctx.files.source_dir_globbed: runtime_path = _bundle_runtime_path(ctx) + own_source_root = runtime_path external = runtime_path.startswith("../") entries.append(struct( runtime_path = runtime_path, @@ -339,6 +348,8 @@ def _docs_bundle_impl(ctx): # the declared relative file list so runtime discovery cannot include # undeclared siblings from the shared parent directory. runtime_path = _source_targets_runtime_path(ctx.files.source_targets) + own_source_root = runtime_path + own_source_is_explicit = True source_files = _source_targets_relative_paths( ctx.files.source_targets, runtime_path, @@ -423,6 +434,8 @@ def _docs_bundle_impl(ctx): DocsBundleInfo( entries = entries, own_source_files = depset(direct = own_source_files), + own_source_root = own_source_root, + own_source_is_explicit = own_source_is_explicit, sourcelinks = sourcelinks, external_runfiles = external_runfiles, data = all_data, @@ -490,12 +503,68 @@ _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 + +def _bundle_sphinx_source_files_impl(ctx): + """Expose direct bundle sources with a Sphinx-specific path mapping.""" + bundle = ctx.attr.bundle[DocsBundleInfo] + source_files = tuple(bundle.own_source_files.to_list()) + if not source_files: + fail("bundle %s has no direct documentation sources" % ctx.attr.bundle) + + # Directory-discovered sources already carry a stable bundle-relative root + # in the provider. Explicit source targets instead use the output path + # Bazel gives to Sphinx. Deriving that parent from ``short_path`` handles + # both workspace files and generated outputs (whose paths include + # ``bazel-out``) without making the macro guess a configuration-dependent + # output directory. + if bundle.own_source_is_explicit: + source_path = source_files[0].short_path + separator = source_path.rfind("/") + strip_prefix = source_path[:separator + 1] if separator >= 0 else "" + else: + strip_prefix = bundle.own_source_root + if strip_prefix and not strip_prefix.endswith("/"): + strip_prefix += "/" + + entry = struct( + strip_prefix = strip_prefix, + prefix = "", + files = source_files, + ) + return [ + DefaultInfo(files = depset(source_files)), + SphinxDocsLibraryInfo( + strip_prefix = strip_prefix, + prefix = "", + files = source_files, + transitive = depset(direct = [entry]), + ), + ] + +_bundle_sphinx_source_files = rule( + implementation = _bundle_sphinx_source_files_impl, + attrs = { + "bundle": attr.label(providers = [DocsBundleInfo]), + }, + doc = "Exposes direct bundle sources with paths rooted for a Sphinx build.", +) + +def bundle_sphinx_source_files(name, bundle, visibility = None, tags = None): + """Create a Sphinx library containing only a bundle's direct sources.""" + _bundle_sphinx_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..96c5a6935 100644 --- a/default_conf.py.tpl +++ b/default_conf.py.tpl @@ -10,9 +10,10 @@ # # 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()`` macro and by +# standalone bundle-local Needs exports. SCORE Docs-as-Code owns these +# baseline settings. Project builds and the root bundle may provide their own +# conf.py; standalone bundle-local exports use this baseline. project = {PROJECT} project_url = {PROJECT_URL} @@ -20,7 +21,8 @@ version = "0.0.0" # 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 the +# normal project build. required_in_id = {REQUIRED_IN_ID} extensions = ["score_sphinx_bundle"] diff --git a/docs.bzl b/docs.bzl index 5c1d34d5c..10bda2280 100644 --- a/docs.bzl +++ b/docs.bzl @@ -11,8 +11,16 @@ # 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. The root bundle created by ``docs()`` follows the same rule; +its existing project-wide ``needs_json`` export remains available as well. +This first version only supports self-contained bundles; references to Needs +defined outside the bundle remain unresolved until cross-bundle imports are +added. """ # Multiple approaches are available to build the same documentation output: @@ -51,6 +59,7 @@ load( load( "@score_docs_as_code//:bzl/bundle_rules.bzl", "bundle_source_files", + "bundle_sphinx_source_files", "create_bundle", "external_docs_runfiles", "generate_code_target_sourcelinks", @@ -67,6 +76,78 @@ load( ) load("@sphinxdocs//sphinxdocs:sphinx_docs_library.bzl", "sphinx_docs_library") +def _sphinx_define(name, value): + """Return a Sphinx ``--define`` option when ``value`` is configured.""" + if value == None: + return [] + return ["--define=" + name + "=" + value] + +def _needs_sphinx_docs( + name, + config, + sphinx_build_deps, + srcs = [], + deps = [], + strip_prefix = "", + master_doc = None, + external_needs_source = None, + score_bundle_needs_export = None, + score_sourcelinks_json = None, + score_source_code_linker_plain_links = None, + mounts_manifest = None, + score_metamodel_yaml = None, + tools = [], + sphinx_build_data = [], + visibility = None): + """Declare a bundle Needs export with the repository-wide Sphinx policy.""" + sphinx_build_name = _bundle_internal_target(name, "sphinx_build") + sphinx_build_binary( + name = sphinx_build_name, + data = sphinx_build_data, + deps = sphinx_build_deps, + # The Sphinx executable is an implementation detail of the Needs + # target; only the generated Needs target itself needs the requested + # visibility. + visibility = ["//visibility:private"], + tags = ["manual"], + ) + sphinx_docs( + name = name, + srcs = srcs, + deps = deps, + config = config, + formats = ["needs"], + strip_prefix = strip_prefix, + extra_opts = ( + # Keep the baseline diagnostic policy in the Needs wrapper. The + # underlying sphinxdocs rule already supplies common Bazel-safe + # options such as ``--jobs auto``, ``--fresh-env``, and + # ``--write-all``. + [ + "-W", + "--keep-going", + "-T", + ] + + _sphinx_define("master_doc", master_doc) + + _sphinx_define("external_needs_source", external_needs_source) + + _sphinx_define("score_bundle_needs_export", score_bundle_needs_export) + + _sphinx_define("score_sourcelinks_json", score_sourcelinks_json) + + _sphinx_define( + "score_source_code_linker_plain_links", + score_source_code_linker_plain_links, + ) + + _sphinx_define("mounts_manifest", mounts_manifest) + + _sphinx_define("score_metamodel_yaml", score_metamodel_yaml) + ), + sphinx = ":" + sphinx_build_name, + tools = tools, + visibility = visibility, + # Persistent workers can retain stale symlinks after dependency + # version changes, corrupting the Bazel cache for Needs exports. + allow_persistent_workers = False, + tags = ["manual"], + ) + def _module_name_without_prefix(): """Return the current Bazel module name without its first prefix.""" module_name = native.module_name() @@ -79,6 +160,7 @@ def _bundle_internal_target(name, target): return name + ".__internal__." + target def _generated_conf_impl(ctx): + """Generate a Sphinx config at the source-root path expected by sphinxdocs.""" output = ctx.actions.declare_file(ctx.attr.output_path) ctx.actions.expand_template( template = ctx.file.template, @@ -118,6 +200,19 @@ def _is_needs_json_target(label): """ return str(label).rsplit(":", 1)[-1] == "needs_json" +def _bundle_short_path_prefix(path): + """Return the short-path prefix for a file below ``path``.""" + if path == ".": + path = "" + prefix = join_path(native.package_name(), path) + repository = native.repo_name() + if repository: + # External repository files use ``..//`` in short_path. + prefix = join_path("../" + repository, prefix) + if prefix: + prefix += "/" + return prefix + def _declare_docs_bundle( name, source_dir = None, @@ -131,10 +226,8 @@ def _declare_docs_bundle( """Declare the shared bundle target implementation. This helper performs the bundle declaration used by both public entry - 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. - + points. It returns the source and sourcelink inputs used by the standalone + Needs export created for source-bearing bundles. Args: name: target name. source_dir: optional directory holding this bundle's own doc sources. It is @@ -181,9 +274,16 @@ def _declare_docs_bundle( ) # 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 "" + # ``source_dir = "."`` denotes the package root. Keep its provider path + # normalized so Sphinx can remove the same prefix from ordinary short_paths + # (which never contain the literal ``/.`` segment). + strip_prefix = ( + pkg if source_dir == "." else join_path(pkg, source_dir) + ) if source_dir != None else "" # ``needs_json`` is an inventory consumed by score_metamodel, not content # owned by this bundle. It must remain in the caller's build/runfile inputs @@ -211,6 +311,93 @@ def _declare_docs_bundle( **kwargs ) + return struct( + source_dir_globbed = source_dir_globbed, + sourcelinks_json = sourcelinks_json, + ) + +def _declare_bundle_local_needs( + name, + source_dir_globbed, + srcs, + entry_doc, + sourcelinks_json, + visibility = None, + config = None, + config_strip_prefix = "", + deps = []): + """Create a standalone Needs export for a bundle's direct sources. + + Standalone ``docs_bundle`` exports use a generated baseline configuration. + The root bundle created by ``docs()`` may provide the project's own + configuration because it is also the project's normal documentation root. + """ + if not source_dir_globbed and not srcs: + return + + # ``bundle_sphinx_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_sphinx_source_files( + name = _bundle_internal_target(name, "needs_sources"), + bundle = ":" + name, + visibility = visibility, + tags = ["manual"], + ) + + if config == None: + # Sphinx expects conf.py below the source root. Generate a private + # config for each standalone export so a source-only bundle remains + # independent of the project that composes it. + needs_conf = _bundle_internal_target(name, "needs_conf") + config_output_path = join_path(needs_conf, "conf.py") + _generated_conf( + name = needs_conf, + project = name, + project_url = "", + required_in_id = "", + output_path = config_output_path, + tags = ["manual"], + ) + needs_config = ":" + needs_conf + config_strip_prefix = _bundle_short_path_prefix(needs_conf) + else: + # The root bundle belongs to docs(), so its local export must retain + # the same project configuration as the normal project-wide export. + needs_config = config + + # 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. + sphinx_build_deps = deps + _missing_requirements(deps) + for fixed_dep in [ + Label("//src:plantuml_for_python"), + Label("//src/extensions/score_sphinx_bundle:score_sphinx_bundle"), + ]: + if fixed_dep not in sphinx_build_deps: + sphinx_build_deps.append(fixed_dep) + + needs_local = _bundle_internal_target(name, "needs_local") + _needs_sphinx_docs( + name = needs_local, + deps = [own_sources], + config = needs_config, + sphinx_build_deps = sphinx_build_deps, + master_doc = entry_doc, + external_needs_source = "[]", + score_bundle_needs_export = "1", + score_sourcelinks_json = "$(location " + str(sourcelinks_json) + ")" if sourcelinks_json else None, + score_source_code_linker_plain_links = "1", + strip_prefix = config_strip_prefix, + tools = [sourcelinks_json] if sourcelinks_json else [], + visibility = visibility, + ) + def docs_bundle( name, source_dir = None, @@ -228,7 +415,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, @@ -239,6 +426,14 @@ def docs_bundle( visibility = visibility, **kwargs ) + _declare_bundle_local_needs( + name = name, + source_dir_globbed = bundle.source_dir_globbed, + srcs = srcs, + entry_doc = entry_doc, + sourcelinks_json = bundle.sourcelinks_json, + visibility = visibility, + ) def _missing_requirements(deps): """Add Python hub dependencies if they are missing.""" @@ -394,19 +589,11 @@ def docs( incremental_src = Label("//src:incremental.py") - sphinx_build_binary( - name = "sphinx_build", - visibility = ["//visibility:private"], - data = data + external_needs + metamodel_label + [":docs_bundle"], - deps = deps, - tags = ["manual"] - ) - known_good_label = [known_good] if known_good else [] # The public bundle carries both the complete source tree and the # transitive source-code links of every nested bundle. - _declare_docs_bundle( + root_bundle = _declare_docs_bundle( name = "docs_bundle", source_dir = source_dir, data = data, @@ -416,6 +603,17 @@ def docs( visibility = ["//visibility:public"], tags = ["manual"] ) + _declare_bundle_local_needs( + name = "docs_bundle", + source_dir_globbed = root_bundle.source_dir_globbed, + srcs = [], + entry_doc = "index", + sourcelinks_json = root_bundle.sourcelinks_json, + visibility = ["//visibility:public"], + config = sphinx_config, + config_strip_prefix = _bundle_short_path_prefix(source_dir), + deps = deps, + ) sphinx_sources = bundle_source_files( name = "_docs_sphinx_sources", bundle = ":docs_bundle", @@ -529,7 +727,7 @@ def docs( package_collisions = "warning", ) - sphinx_docs( + _needs_sphinx_docs( name = "needs_json", # Nested bundle sources are mounted by score_mounts. Passing the # complete bundle as srcs would also expose those files as raw Sphinx @@ -537,28 +735,17 @@ def docs( srcs = [sphinx_sources], deps = root_bundle_data_for_sphinx, config = sphinx_config, - extra_opts = [ - "-W", - "--keep-going", - "-T", # show more details in case of errors - "--jobs", - "auto", - "--define=external_needs_source=" + str(data + external_needs), - "--define=score_sourcelinks_json=$(location :sourcelinks_json)", - "--define=score_source_code_linker_plain_links=1", - ] + ( - # ``sphinx_docs`` is a sandboxed build action, so it needs the - # action-input path rather than the runfiles-relative spelling. - ["--define=mounts_manifest=$(location :_mounts_manifest)"] if bundles else [] - ) + (["--define=score_metamodel_yaml=$(location " + str(metamodel) + ")"] if metamodel else []), - formats = ["needs"], - sphinx = ":sphinx_build", + sphinx_build_deps = deps, + sphinx_build_data = data + external_needs + metamodel_label + [":docs_bundle"], + external_needs_source = str(data + external_needs), + score_sourcelinks_json = "$(location :sourcelinks_json)", + score_source_code_linker_plain_links = "1", + # ``sphinx_docs`` is a sandboxed build action, so it needs the + # action-input path rather than the runfiles-relative spelling. + mounts_manifest = "$(location :_mounts_manifest)" if bundles else None, + score_metamodel_yaml = "$(location " + str(metamodel) + ")" if metamodel else None, tools = external_needs + metamodel_label + [":sourcelinks_json", ":docs_bundle"] + mounts_manifest_label, visibility = ["//visibility:public"], - # Persistent workers cause stale symlinks after dependency version - # changes, corrupting the Bazel cache. - allow_persistent_workers = False, - tags = ["manual"], ) native.genrule( diff --git a/docs/reference/bazel_macros.rst b/docs/reference/bazel_macros.rst index 0ae1c0e18..013db9a76 100644 --- a/docs/reference/bazel_macros.rst +++ b/docs/reference/bazel_macros.rst @@ -172,6 +172,9 @@ 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``). + Standalone bundle-local Needs exports always use a self-contained, generated + Sphinx configuration. A root bundle created by ``docs()`` instead uses the + project's ``conf.py`` so its local export matches the normal project build. 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 @@ -212,6 +215,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 @@ -225,9 +236,3 @@ Signature: ``docs_bundle(name, source_dir = None, srcs = [], data = [], entry_do recursively from their ``deps``; filegroups expand to their files. The bundle owns one cached scan result; Bazel only regenerates it when its collected source inputs change. - -Edge cases ----------- - -- If your Sphinx ``conf.py`` expects files generated by other Bazel targets, make sure those - targets are included in the ``data`` list so they are available to the build driver. diff --git a/src/extensions/score_metamodel/__init__.py b/src/extensions/score_metamodel/__init__.py index 01c788293..98c8d19fd 100644 --- a/src/extensions/score_metamodel/__init__.py +++ b/src/extensions/score_metamodel/__init__.py @@ -245,6 +245,7 @@ def setup(app: Sphinx) -> dict[str, str | bool]: app.add_config_value("external_needs_source", "", rebuild="env") app.add_config_value("score_metamodel_yaml", "", rebuild="env") app.add_config_value("required_in_id", [], rebuild="env") + app.add_config_value("score_bundle_needs_export", False, rebuild="env") config_setdefault(app.config, "needs_id_required", True) config_setdefault(app.config, "needs_id_regex", "^[A-Za-z0-9_-]{6,}") diff --git a/src/extensions/score_metamodel/external_needs.py b/src/extensions/score_metamodel/external_needs.py index 51f927a21..cd89a6566 100644 --- a/src/extensions/score_metamodel/external_needs.py +++ b/src/extensions/score_metamodel/external_needs.py @@ -130,7 +130,13 @@ def parse_external_needs_sources_from_bazel_query() -> list[ExternalNeedsSource] return res -def extend_needs_json_exporter(config: Config, params: list[str]) -> None: +def extend_needs_json_exporter( + config: Config, + params: list[str], + *, + log_missing: bool = True, + export_values: dict[str, str] | None = None, +) -> 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. @@ -141,7 +147,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." @@ -153,7 +159,10 @@ def extend_needs_json_exporter(config: Config, params: list[str]) -> None: def temp(self: NeedsList): for p in params: - self.needs_list[p] = getattr(config, p) # pyright: ignore[reportUnknownMemberType] + if export_values is not None and p in export_values: + self.needs_list[p] = export_values[p] + else: + self.needs_list[p] = getattr(config, p) # pyright: ignore[reportUnknownMemberType] orig_function(self) @@ -228,7 +237,17 @@ 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 bundle exports intentionally omit the host URL from their JSON so + # the inventory remains reusable by whichever documentation site consumes + # it. Keep the configuration value available to Sphinx itself, and retain + # the existing missing-value diagnostic for normal host builds. + bundle_export = bool(config.score_bundle_needs_export) + extend_needs_json_exporter( + config, + ["project_url"], + log_missing=not bundle_export, + export_values={"project_url": ""} if bundle_export else None, + ) # Local external needs from DATA (e.g. :needs_json or :docs_sources) external_needs = get_external_needs_source(app.config.external_needs_source) diff --git a/src/extensions/score_metamodel/tests/test_external_needs.py b/src/extensions/score_metamodel/tests/test_external_needs.py index 5c8929b41..4e301e160 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,46 @@ 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_extend_needs_json_exporter_can_override_bundle_export_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Bundle exports omit the host URL while Sphinx keeps the config value.""" + config = Config() + config.project_url = "https://example.test/host" + + monkeypatch.setattr(NeedsList, "_finalise", lambda _needs_list: None) + ext_needs.extend_needs_json_exporter( + config, + ["project_url"], + log_missing=False, + export_values={"project_url": ""}, + ) + + needs_list = cast(NeedsList, SimpleNamespace(needs_list={})) + NeedsList._finalise(needs_list) # pyright: ignore[reportPrivateUsage] - white-box test + + assert config.project_url == "https://example.test/host" + assert needs_list.needs_list["project_url"] == "" def test_empty_list(): diff --git a/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/BUILD b/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/BUILD index 860a114ad..e26a1dfdf 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/BUILD +++ b/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/BUILD @@ -11,25 +11,20 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -load("//:docs.bzl", "docs") +load("//:docs.bzl", "docs_bundle") filegroup( name = "component_sources", srcs = ["implementation.py"], ) -# Keep the component as a complete documentation producer while placing its -# Bazel package below the module's source directory. This mirrors the feature -# package layout used by the S-CORE integration and exercises package-aware -# source ownership at runtime. The implementation file is deliberately -# supplied through ``code_targets`` so its traceability annotation exercises -# the source-link generation path used by real component targets. -docs( - project = "S-CORE Legacy Component", - project_url = "https://example.invalid/score-legacy-component", +# This component is a nested documentation package below the module's source +# directory. The implementation file is deliberately supplied through +# ``code_targets`` so its traceability annotation exercises the source-link +# generation path used by real component bundles. +docs_bundle( + name = "docs_bundle", source_dir = ".", - data = [ - "//src/tests/docs_bzl/scenarios/reference_integration/score_platform:needs_json", - ], code_targets = [":component_sources"], + visibility = ["//visibility:public"], ) diff --git a/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/index.rst b/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/index.rst index 6b1e7f37e..2b08d8c3b 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/index.rst +++ b/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/index.rst @@ -15,8 +15,8 @@ S-CORE Legacy Component ======================= -This component consumes platform feature requirements through the legacy -``data`` API and is mounted by the legacy module. +This component is mounted by the legacy module, which consumes the platform +feature requirements through the legacy ``data`` API. .. tool_req:: Legacy component implementation is traceable :id: tool_req__legacy_component @@ -24,4 +24,4 @@ This component consumes platform feature requirements through the legacy The legacy component implementation is covered by the component source code-link scan. The integration test checks that this link is preserved - when the component is built alone, by its module, and by the full site. + when the component is built through its module and by the full site. diff --git a/src/tests/docs_bzl/scenarios/reference_integration/modern_module/BUILD b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/BUILD index 630119989..43da4e4b9 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/modern_module/BUILD +++ b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/BUILD @@ -14,9 +14,11 @@ load("//:docs.bzl", "docs") # This module uses the current ``external_needs`` API to import platform -# feature requirements and mounts its component documentation. The component -# package is nested below this module's ``docs`` source tree, so both the -# module's primary walk and its parent bundle mount must exclude that child. +# feature requirements and mounts two component bundles. One component has a +# requirement linked to the imported platform feature; the other is +# self-contained. Keeping both under the module's ``docs`` source tree +# exercises the distinction between a component that needs its parent bundle's +# external Needs and one that can be built independently. docs( project = "S-CORE Modern Module", project_url = "https://example.invalid/score-modern-module", @@ -30,5 +32,9 @@ docs( "bundle": "//src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component:docs_bundle", "mount_at": "components/component", "attach_to": "components", + }, { + "bundle": "//src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/unlinked_component:docs_bundle", + "mount_at": "components/unlinked_component", + "attach_to": "components", }], ) diff --git a/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component/BUILD b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component/BUILD index 6d61dade9..e3d2d92cd 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component/BUILD +++ b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component/BUILD @@ -11,7 +11,7 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -load("//:docs.bzl", "docs") +load("//:docs.bzl", "docs_bundle") filegroup( name = "component_sources", @@ -23,12 +23,13 @@ filegroup( # tree and mounts this bundle at the component placement. The implementation # file is supplied through ``code_targets`` to exercise source-link generation # for a component that is propagated through the module bundle. -docs( - project = "S-CORE Modern Component", - project_url = "https://example.invalid/score-modern-component", +# +# The component intentionally does not import the platform Needs itself. Its +# requirement links to the platform feature, so the component Needs target is +# valid only when the parent module supplies that external Needs. +docs_bundle( + name = "docs_bundle", source_dir = ".", - external_needs = [ - "//src/tests/docs_bzl/scenarios/reference_integration/score_platform:needs_json", - ], code_targets = [":component_sources"], + visibility = ["//visibility:public"], ) diff --git a/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component/index.rst b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component/index.rst index eea58d5ba..51c0e500e 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component/index.rst +++ b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component/index.rst @@ -15,13 +15,44 @@ S-CORE Modern Component ======================= -This component consumes platform feature requirements through the current -``external_needs`` API and is mounted by the modern module. +This component is mounted by the modern module and derives one of its +component requirements from the platform feature requirement. The platform +requirement is imported by the module, not by this standalone component. + +.. feat:: Modern component feature + :id: feat__modern_component + :security: NO + :safety: QM + :status: valid + :version: 1 + +.. comp:: Modern linked component + :id: comp__modern_component + :security: NO + :safety: QM + :status: valid + :version: 1 + :belongs_to: feat__modern_component + +.. comp_req:: Modern component platform requirement + :id: comp_req__modern_component__platform_feature + :reqtype: Functional + :security: NO + :safety: QM + :status: valid + :version: 1 + :derived_from: feat_req__platform__feature + :satisfied_by: comp__modern_component + + The modern component requires the platform feature made available by its + parent module. .. tool_req:: Modern component implementation is traceable :id: tool_req__modern_component :version: 1 The modern component implementation is covered by the component source - code-link scan. The integration test checks that this link is preserved - when the component is built alone, by its module, and by the full site. + code-link scan. The linked component requirement above depends on the + platform feature requirement. The integration test checks that the + component can be built through its module, where that requirement is + available, but not as a standalone bundle. diff --git a/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/unlinked_component/BUILD b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/unlinked_component/BUILD new file mode 100644 index 000000000..3e3cbf317 --- /dev/null +++ b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/unlinked_component/BUILD @@ -0,0 +1,24 @@ +# ******************************************************************************* +# 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") + +# This component deliberately contains no links to Needs outside its own +# source tree. It is the control case for the linked component next to it: the +# same module can mount both bundles, while this one can also produce a local +# Needs inventory without the module's external Needs. +docs_bundle( + name = "docs_bundle", + source_dir = ".", + visibility = ["//visibility:public"], +) diff --git a/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/unlinked_component/index.rst b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/unlinked_component/index.rst new file mode 100644 index 000000000..6936d3b4f --- /dev/null +++ b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/unlinked_component/index.rst @@ -0,0 +1,27 @@ +.. + # ******************************************************************************* + # 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 + # ******************************************************************************* + +S-CORE Modern Unlinked Component +================================ + +This component has no dependency on a requirement owned by another bundle. +It should therefore be buildable both independently and through the modern +module. + +.. tool_req:: Modern unlinked component is self-contained + :id: tool_req__modern_unlinked_component + :version: 1 + + This requirement is intentionally not linked to the platform feature + requirement. It verifies the independent-component control case. diff --git a/src/tests/docs_bzl/test_data_files_runfiles.py b/src/tests/docs_bzl/test_data_files_runfiles.py index e770535aa..2240248b8 100644 --- a/src/tests/docs_bzl/test_data_files_runfiles.py +++ b/src/tests/docs_bzl/test_data_files_runfiles.py @@ -19,7 +19,7 @@ ``/bazel-bin`` and mounts it. This end-to-end test fails if either half of that chain regresses.""" -from src.tests.docs_bzl.helpers import run_scenario +from src.tests.docs_bzl.helpers import built_output, run_bazel, run_scenario def test_generated_source_files_reachable_at_runtime(): @@ -50,3 +50,21 @@ def test_explicit_source_bundle_excludes_undeclared_siblings(): assert "Isolated Declared Page" in declared_html.read_text(encoding="utf-8") assert not undeclared_html.exists() + + +def test_explicit_source_bundles_export_local_needs_from_their_source_root(): + """Explicit source targets are staged with their bundle entry as the root.""" + run_bazel( + [ + "build", + "//src/tests/docs_bzl/scenarios/data_files_runfiles:data_bundle.__internal__.needs_local", + "//src/tests/docs_bzl/scenarios/data_files_runfiles:isolated_source_bundle.__internal__.needs_local", + ] + ) + + for target in ("data_bundle", "isolated_source_bundle"): + needs_output = built_output( + "scenarios/data_files_runfiles", + f"{target}.__internal__.needs_local/_build/needs/needs.json", + ) + assert needs_output.is_file() diff --git a/src/tests/docs_bzl/test_reference_integration.py b/src/tests/docs_bzl/test_reference_integration.py index dcdea4fc1..0df5eb22e 100644 --- a/src/tests/docs_bzl/test_reference_integration.py +++ b/src/tests/docs_bzl/test_reference_integration.py @@ -15,10 +15,13 @@ from typing import cast +import pytest + from src.tests.docs_bzl.helpers import built_output, load_needs, run_bazel, run_scenario def test_score_platform_publishes_feature_requirement(): + """The platform fixture provides the external requirement used by modules.""" result = run_scenario( "build", "reference_integration/score_platform", ":needs_json" ) @@ -29,12 +32,12 @@ def test_score_platform_publishes_feature_requirement(): def test_module_and_component_needs_targets_build_with_source_links(): - """Preserve component source links in standalone and composed needs builds. + """Preserve component source links in composed Needs builds. - The component targets prove that ``code_targets`` creates the expected - links directly. The module targets prove that the links propagate through - a nested module bundle. All four targets are built in one Bazel invocation - because this suite deliberately drives coarse-grained integration cases. + The module targets prove that ``code_targets`` creates links for nested + component bundles and propagates them through the module bundle. Both + targets are built in one Bazel invocation because this suite deliberately + drives coarse-grained integration cases. """ expected_links = [ ( @@ -47,17 +50,9 @@ def test_module_and_component_needs_targets_build_with_source_links(): "tool_req__modern_component", "modern_module/docs/components/component/implementation.py#L17", ), - ( - "reference_integration/legacy_module/docs/components/component", - "tool_req__legacy_component", - "legacy_module/docs/components/component/implementation.py#L14", - ), - ( - "reference_integration/modern_module/docs/components/component", - "tool_req__modern_component", - "modern_module/docs/components/component/implementation.py#L17", - ), ] + # Build the module and nested component bundles together so the assertions + # below verify source-link propagation at each relevant bundle boundary. run_bazel( [ "build", @@ -65,10 +60,13 @@ def test_module_and_component_needs_targets_build_with_source_links(): f"//src/tests/docs_bzl/scenarios/{scenario}:needs_json" for scenario, _, _ in expected_links ], + "//src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component:docs_bundle.__internal__.needs_local", ] ) for scenario, need_id, source_link_fragment in expected_links: + # Each Needs export must retain the source path belonging to the + # package where the annotated implementation is defined. needs = load_needs( built_output( f"scenarios/{scenario}", @@ -82,18 +80,102 @@ def test_module_and_component_needs_targets_build_with_source_links(): assert isinstance(source_code_link, str) assert source_link_fragment in source_code_link + # The component's own export uses the same source-link input as the + # composed module build. Keep this assertion separate from the module + # outputs so the local target is verified as an actual standalone export. + local_needs = load_needs( + built_output( + "scenarios/reference_integration/legacy_module/docs/components/component", + "docs_bundle.__internal__.needs_local/_build/needs/needs.json", + ) + ) + local_need = local_needs.get("tool_req__legacy_component") + assert isinstance(local_need, dict), sorted(local_needs) + typed_local_need = cast(dict[str, object], local_need) + local_source_code_link = typed_local_need.get("source_code_link") + assert isinstance(local_source_code_link, str) + assert "legacy_module/docs/components/component/implementation.py#L14" in ( + local_source_code_link + ) + def test_nested_component_package_is_mounted_by_its_module(): - """A module run excludes its nested component from primary discovery.""" - result = run_scenario("run", "reference_integration/legacy_module", ":docs") + """Nested component bundles are rendered at both module mount points.""" + result = run_scenario("run", "reference_integration/modern_module", ":docs") + # The parent module owns the surrounding ``components`` tree, while each + # child bundle supplies its own page below the corresponding mount point. assert (result.build_dir / "components" / "component" / "index.html").is_file() + assert ( + result.build_dir / "components" / "unlinked_component" / "index.html" + ).is_file() + + +def test_modern_module_linked_and_unlinked_components(): + """Parent context is required only for the component with an external link. + + The modern module imports the platform Needs. Its linked component relies + on that imported feature requirement, whereas the unlinked component has + no dependency outside its own bundle and is the independent-build control. + """ + module_result = run_scenario( + "build", "reference_integration/modern_module", ":needs_json" + ) + assert module_result.artifacts + module_needs = load_needs(module_result.artifacts["needs.json"]) + # Building through the module provides the platform requirement and must + # export both the linked and unlinked component requirements. + assert { + "comp_req__modern_component__platform_feature", + "tool_req__modern_component", + "tool_req__modern_unlinked_component", + } <= module_needs.keys() + + # The unlinked component has no external references, so its local Needs + # export is valid without the modern module's external_needs declaration. + run_scenario( + "build", + "reference_integration/modern_module/docs/components/unlinked_component", + ":docs_bundle.__internal__.needs_local", + ) + unlinked_needs = load_needs( + built_output( + "scenarios/reference_integration/modern_module/docs/components/unlinked_component", + "docs_bundle.__internal__.needs_local/_build/needs/needs.json", + ) + ) + assert "tool_req__modern_unlinked_component" in unlinked_needs + + # The linked component intentionally omits that external Needs input. The + # standalone build must therefore fail while resolving its derived_from + # link to the platform feature requirement. + with pytest.raises(RuntimeError) as exc_info: + run_scenario( + "build", + "reference_integration/modern_module/docs/components/component", + ":docs_bundle.__internal__.needs_local", + ) + assert "feat_req__platform__feature" in str(exc_info.value) + + +def test_reference_integration_root_bundle_exports_its_own_needs(): + """The root docs() bundle exposes a local Needs export for its own sources.""" + run_scenario( + "build", "reference_integration", ":docs_bundle.__internal__.needs_local" + ) + + assert built_output( + "scenarios/reference_integration", + "docs_bundle.__internal__.needs_local/_build/needs/needs.json", + ).is_file() def test_reference_integration_builds_with_platform_requirements(): - """Mount modules and render their nested component source-code links.""" + """Mount modules and render nested component source-code links.""" result = run_scenario("run", "reference_integration", ":docs") + # The top-level site imports the platform bundle, so its feature link is + # rendered on the main page before the module mounts are traversed. html = (result.build_dir / "index.html").read_text(encoding="utf-8") assert ( "score-platform/main/platform/feature.html#feat_req__platform__feature" in html @@ -122,6 +204,8 @@ def test_reference_integration_builds_with_platform_requirements(): assert "modern_module/docs/components/component/implementation.py#L17" in ( modern_component_html ) + # Both module pages and their nested component pages must be present after + # composition; source links are checked above on the component pages. assert (result.build_dir / "modules" / "legacy_module" / "index.html").is_file() assert ( result.build_dir