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
13 changes: 8 additions & 5 deletions bzl/bundle_rules.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,12 @@ DocsBundleInfo = provider(
"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.",
"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,
# these are resolved at this bundle's mount.
"data": "Bundle-owned supporting/runtime files resolved at the bundle's mount.",
"external_runfiles": "Documentation source files from external repositories needed in runfiles.",
# Bundle-owned generated/supporting files. Both bundle data and
# docs(data = [...]) are build inputs; unlike host-owned docs data,
# 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.",
},
)

Expand Down Expand Up @@ -488,12 +490,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
6 changes: 5 additions & 1 deletion default_conf.py.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
180 changes: 174 additions & 6 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,7 +132,22 @@ def _is_needs_json_target(label):
"""
return str(label).rsplit(":", 1)[-1] == "needs_json"

def docs_bundle(
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,
srcs = [],
Expand All @@ -125,7 +158,7 @@ def docs_bundle(
code_targets = [],
visibility = None,
**kwargs):
"""A docs bundle, optionally composed of others.
"""Declare the reusable bundle and return inputs needed by local exports.

Args:
name: target name.
Expand Down Expand Up @@ -182,7 +215,9 @@ def 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 @@ -212,6 +247,139 @@ def docs_bundle(
**kwargs
)

return struct(
source_dir_globbed = source_dir_globbed,
sourcelinks = sourcelinks,
)

def docs_bundle(
name,
source_dir = None,
srcs = [],
data = [],
entry_doc = "index",
bundles = [],
scan_code = [],
code_targets = [],
visibility = None,
**kwargs):
"""Declare a reusable documentation bundle."""
bundle = _declare_docs_bundle(
name = name,
source_dir = source_dir,
srcs = srcs,
data = data,
entry_doc = entry_doc,
bundles = bundles,
scan_code = scan_code,
code_targets = code_targets,
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. Prefer a caller-provided
# config, and otherwise generate the same default config used by docs().
config_file_path = join_path(source_dir, "conf.py")
if native.glob([config_file_path], allow_empty = True):
needs_config = ":" + config_file_path
else:
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",
"--define=score_bundle_needs_export=1",
# 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."""
found = []
Expand Down Expand Up @@ -381,7 +549,7 @@ def docs(

# The public bundle carries both the complete source tree and the
# transitive source-code links of every nested bundle.
docs_bundle(
_declare_docs_bundle(
name = "docs_bundle",
source_dir = source_dir,
data = data,
Expand Down
8 changes: 8 additions & 0 deletions docs/reference/bazel_macros.rst
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,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
1 change: 1 addition & 0 deletions src/extensions/score_metamodel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ def _clear_needs_defaults(app: Sphinx):

def setup(app: Sphinx) -> dict[str, str | bool]:
app.add_config_value("external_needs_source", "", rebuild="env")
app.add_config_value("score_bundle_needs_export", False, rebuild="env")
app.add_config_value("score_metamodel_yaml", "", rebuild="env")
app.add_config_value("required_in_id", [], rebuild="env")
config_setdefault(app.config, "needs_id_required", True)
Expand Down
52 changes: 33 additions & 19 deletions src/extensions/score_metamodel/external_needs.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,30 +130,39 @@ def parse_external_needs_sources_from_bazel_query() -> list[ExternalNeedsSource]
return res


def extend_needs_json_exporter(config: Config, params: list[str]) -> None:
def register_project_url_export(
config: Config,
*,
exported_project_url: str | None = None,
) -> None:
"""Add ``project_url`` to the Needs JSON export.

By default, the value comes from the Sphinx configuration and an empty
value is reported as a configuration error. ``exported_project_url`` is an
explicit replacement for the JSON value; supplying it also makes an empty
replacement valid without changing the active Sphinx configuration.
"""
This will add each param to app.config as a config value.
Then it will overwrite the needs.json exporter to include these values.
"""

for p in params:
# Note: we are currently addinig these values to config after config-inited.
# This is wrong. But good enough.
config.add(p, default="", rebuild="env", types=(), description="")
# ``project_url`` is a SCORE-specific configuration value, so register it
# before accessing it. This currently happens during ``config-inited``;
# moving the declaration into extension setup is a separate cleanup.
config.add("project_url", default="", rebuild="env", types=(), description="")
configured_project_url = config.project_url
if exported_project_url is None and not configured_project_url:
logger.error(
"Config value 'project_url' is not set. "
+ "Please set it in your Sphinx config."
)

if not getattr(config, p):
logger.error(
f"Config value '{p}' is not set. "
+ "Please set it in your Sphinx config."
)
project_url = (
configured_project_url if exported_project_url is None else exported_project_url
)

# Patch json exporter to include our custom fields
# Note: yeah, NeedsList is the json exporter!
# Patch json exporter to include our custom field.
# Note: ``NeedsList`` is the sphinx-needs JSON exporter.
orig_function = NeedsList._finalise # pyright: ignore[reportPrivateUsage]

def temp(self: NeedsList):
for p in params:
self.needs_list[p] = getattr(config, p) # pyright: ignore[reportUnknownMemberType]
self.needs_list["project_url"] = project_url

orig_function(self)

Expand Down Expand Up @@ -228,7 +237,12 @@ def add_external_docs_sources(e: ExternalNeedsSource, config: Config):


def connect_external_needs(app: Sphinx, config: Config):
extend_needs_json_exporter(config, ["project_url"])
# Bundle-local exports intentionally leave project_url empty, while host
# builds retain the existing missing-value diagnostic.
register_project_url_export(
config,
exported_project_url="" if config.score_bundle_needs_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)
Expand Down
Loading
Loading