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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion bzl/bundle_rules.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 9 additions & 4 deletions default_conf.py.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
150 changes: 145 additions & 5 deletions docs.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<name>.__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:
Expand Down Expand Up @@ -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,
Expand All @@ -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]))]
Expand All @@ -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,
Expand All @@ -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 ``../<canonical-repo>/`` 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,
Expand All @@ -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
Expand Down Expand Up @@ -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 ""

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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."""
Expand Down
10 changes: 10 additions & 0 deletions docs/reference/bazel_macros.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ``<name>.__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
Expand Down
6 changes: 0 additions & 6 deletions src/extensions/score_metamodel/external_needs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
20 changes: 20 additions & 0 deletions src/extensions/score_metamodel/tests/test_external_needs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
)
23 changes: 22 additions & 1 deletion src/tests/docs_bzl/test_reference_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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",
[
Expand Down
Loading