Skip to content
Merged
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
147 changes: 133 additions & 14 deletions bzl/bundle_rules.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,10 @@ 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 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.",
"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.",
},
)

Expand Down Expand Up @@ -137,13 +135,68 @@ def _bundle_runtime_path(ctx):
runfiles. Keep that spelling here; ``_bundle_execroot_path`` converts it to
the corresponding ``external/<repo>/...`` form for build actions.
"""
source_file = ctx.files.srcs[0].short_path
Comment thread
AlexanderLanin marked this conversation as resolved.
# All files were globbed from this bundle's one source_dir, so the first
# file is representative for detecting an external-repository prefix.
source_file = ctx.files.source_dir_globbed[0].short_path
external_prefix = ""
if source_file.startswith("../"):
path_parts = source_file.split("/")
external_prefix = path_parts[0] + "/" + path_parts[1] + "/"
return external_prefix + ctx.attr.strip_prefix.rstrip("/")

def _source_target_path(source_file):
"""Return the path spelling used by the source-target staging action."""
# Bazel marks workspace files with ``is_source``; generated outputs use
# the execroot path while workspace files use their runfiles short path.
return source_file.path if not source_file.is_source else source_file.short_path
Comment thread
AlexanderLanin marked this conversation as resolved.

def _source_targets_runtime_path(files):
"""Return the runtime directory shared by one set of source targets.

Source targets are either workspace files or generated outputs. Their
paths use different Bazel spellings, while one manifest entry can carry
only one runtime root and one generated/source classification. The first
file therefore establishes the canonical representation and every later
file is validated against it.
"""
first_file = files[0]
first_path = _source_target_path(first_file)
first_is_source = first_file.is_source
separator = first_path.rfind("/")
# A root-package source has no parent component; ``.`` represents the
# workspace/execroot root so it can still be used as the shared directory.
runtime_path = first_path[:separator] if separator >= 0 else "."
Comment thread
AlexanderLanin marked this conversation as resolved.
for source_file in files[1:]:
Comment thread
AlexanderLanin marked this conversation as resolved.
# A single bundle entry cannot combine source roots from the workspace
# and bazel-out because they have different runtime resolution rules.
source_path = _source_target_path(source_file)
if source_file.is_source != first_is_source:
fail(("explicit bundle sources cannot mix workspace and generated files; " +
"found %r and %r") % (first_path, source_path))
source_separator = source_path.rfind("/")
# Use the same ``.`` spelling for another root-package source.
source_root = source_path[:source_separator] if source_separator >= 0 else "."
if source_root != runtime_path:
fail(("explicit bundle sources must share one parent directory; " +
"found %r and %r") % (runtime_path, source_root))
return runtime_path

def _source_targets_relative_paths(files, runtime_path):
"""Return each source target's path relative to the shared source root."""
relative_paths = []
for source_file in files:
source_path = _source_target_path(source_file)
# A root-package source has ``.`` as its shared parent, so its complete
# path is already relative to the staging tree.
if runtime_path == ".":
relative_paths.append(source_path)
continue
prefix = runtime_path + "/"
if not source_path.startswith(prefix):
fail("explicit bundle source %r is outside %r" % (source_path, runtime_path))
relative_paths.append(source_path[len(prefix):])
return relative_paths

def _bundle_execroot_path(runtime_path):
"""Return the execroot-relative spelling of an external runtime path."""
if runtime_path.startswith("../"):
Expand Down Expand Up @@ -197,6 +250,11 @@ def _rebase_bundle_entry(entry, mount_at, attach_to):
entry_doc = entry.entry_doc,
external = entry.external,
repository = entry.repository,
# Preserve whether the entry's source root comes from bazel-out when
# the entry is moved below a parent bundle's mount point.
generated = entry.generated,
# Preserve the explicit file allowlist when the entry is rebased.
files = entry.files,
data = entry.data,
)

Expand Down Expand Up @@ -245,7 +303,13 @@ def _docs_bundle_impl(ctx):
own_external_runfiles = []
own_data = depset(direct = ctx.files.data)

if ctx.files.srcs:
# The macro validates this combination before creating the rule; retain
# the rule-level check for callers of the internal helper as well.
if ctx.files.source_dir_globbed and ctx.files.source_targets:
fail(("bundle %s cannot combine source_dir sources with explicit source " +
"targets") % ctx.label)

if ctx.files.source_dir_globbed:
runtime_path = _bundle_runtime_path(ctx)
external = runtime_path.startswith("../")
entries.append(struct(
Expand All @@ -259,13 +323,48 @@ def _docs_bundle_impl(ctx):
entry_doc = ctx.attr.entry_doc,
external = external,
repository = ctx.label.workspace_name,
# Directory-discovered sources are resolved from the workspace.
generated = False,
# Directory mounts discover all supported files below this root.
files = [],
data = own_data,
))
own_source_files.extend(ctx.files.srcs)
own_source_files.extend(ctx.files.source_dir_globbed)
# Local sources are read directly from the workspace by ``bazel run``.
# Only sources from external repositories must be staged in runfiles.
if external:
own_external_runfiles.extend(ctx.files.srcs)
own_external_runfiles.extend(ctx.files.source_dir_globbed)
elif ctx.files.source_targets:
# Keep explicit sources at their original paths; the manifest carries
# 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)
source_files = _source_targets_relative_paths(
ctx.files.source_targets,
runtime_path,
)
external = runtime_path.startswith("../")
entries.append(struct(
runtime_path = runtime_path,
src_root = _bundle_execroot_path(runtime_path),
mount_at = "",
attach_to = "",
entry_doc = ctx.attr.entry_doc,
external = external,
repository = ctx.label.workspace_name,
# Generated inputs need bazel-out-to-bazel-bin translation; source
# inputs resolve from their original workspace or runfiles paths.
generated = not ctx.files.source_targets[0].is_source,
# Runtime file-list mounting uses these paths relative to the
# original source root and therefore visits only declared files.
files = source_files,
data = own_data,
))
own_source_files.extend(ctx.files.source_targets)
# Explicit artifacts outside the workspace source tree need to be
# staged for ``bazel run`` just like external source bundles.
if not ctx.files.source_targets[0].is_source or external:
own_external_runfiles.extend(ctx.files.source_targets)
elif own_data:
# Pure data bundle: create an entry so the data files appear in the manifest.
entries.append(struct(
Expand All @@ -276,6 +375,10 @@ def _docs_bundle_impl(ctx):
entry_doc = ctx.attr.entry_doc,
external = False,
repository = ctx.label.workspace_name,
# Pure-data entries do not resolve a generated source root.
generated = False,
# Pure-data entries have no documentation source allowlist.
files = [],
data = own_data,
))

Expand Down Expand Up @@ -327,7 +430,8 @@ def _docs_bundle_impl(ctx):
_docs_bundle = rule(
implementation = _docs_bundle_impl,
attrs = {
"srcs": attr.label_list(allow_files = True),
"source_dir_globbed": attr.label_list(allow_files = True),
"source_targets": attr.label_list(allow_files = True),
"sourcelinks": attr.label_list(allow_files = True),
"strip_prefix": attr.string(default = ""),
"entry_doc": attr.string(default = "index"),
Expand All @@ -339,12 +443,27 @@ _docs_bundle = rule(
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,
source_dir_globbed = [],
source_targets = [],
sourcelinks = [],
strip_prefix = "",
entry_doc = "index",
data = [],
visibility = None,
**kwargs):
"""Create a bundle from directory-discovered files and source targets.

``source_dir_globbed`` and ``source_targets`` are separate internal inputs
because they use different runtime path and staging rules.
"""
parsed_bundles = [_parse_bundle_declaration(declaration) for declaration in bundles]
_docs_bundle(
name = name,
srcs = srcs,
source_dir_globbed = source_dir_globbed,
source_targets = source_targets,
sourcelinks = sourcelinks,
strip_prefix = strip_prefix,
entry_doc = entry_doc,
Expand Down
12 changes: 10 additions & 2 deletions bzl/mount_rules.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,24 @@ def _mounts_manifest_impl(ctx):

json_mounts = []
for entry in entries:
json_mounts.append({
mount = {
"src_root": entry.src_root,
"runtime_path": entry.runtime_path,
"mount_at": entry.mount_at,
"attach_to": entry.attach_to,
"entry_doc": entry.entry_doc,
"external": entry.external,
"repository": entry.repository,
# Tell the runtime resolver whether src_root is a generated output
# tree rather than a workspace or external-repository directory.
"generated": entry.generated,
"data": [f.path for f in entry.data.to_list()],
})
}
# Explicit source targets are mounted as a file allowlist. Directory
# bundles omit this key and retain the existing recursive behavior.
if entry.files:
mount["files"] = entry.files
json_mounts.append(mount)

out = ctx.actions.declare_file(ctx.label.name + ".json")
ctx.actions.write(out, json.encode({"mounts": json_mounts}))
Expand Down
43 changes: 36 additions & 7 deletions docs.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -101,18 +101,34 @@ _generated_conf = rule(
},
)

def docs_bundle(name, source_dir = None, data = [], entry_doc = "index", bundles = [], scan_code = [], code_targets = [], visibility = None, **kwargs):
def docs_bundle(
name,
source_dir = None,
srcs = [],
Comment thread
AlexanderLanin marked this conversation as resolved.
data = [],
entry_doc = "index",
bundles = [],
scan_code = [],
code_targets = [],
visibility = None,
**kwargs):
"""A docs bundle, optionally composed of others.

Args:
name: target name.
source_dir: optional directory holding this bundle's own doc sources. It is
globbed like `docs()` (same file kinds) and the contents are stored after
stripping the `source_dir` prefix. Leave it unset for a pure aggregator.
srcs: Explicit documentation source files, including generated files.
Use this for a source-less bundle whose documentation is produced by a
build action. All files must share one parent directory so they can be
mounted as one bundle entry.
data: Files owned by this bundle that are not discovered as documentation
sources. This includes generated RST, literalinclude inputs, and assets
that belong at the bundle's eventual mount location. Omitting
``source_dir`` creates a bundle containing only these files.
sources. Use this for runtime/support files that belong at the bundle's
eventual mount location. ``docs(data = [...])`` is the corresponding
shorthand for supporting files in the project's root bundle. Both
forms make their files available to a build; only bundle data travels
with a mounted bundle.
entry_doc: bundle-relative docname attached when this bundle is mounted.
Defaults to `index`.
bundles: nested bundles to compose, each a dict
Expand All @@ -130,7 +146,15 @@ def docs_bundle(name, source_dir = None, data = [], entry_doc = "index", bundles
**kwargs: Additional attributes forwarded to the underlying rule.
"""

srcs = glob_doc_sources(source_dir) if source_dir != None else []
if source_dir != None and srcs:
fail(
("docs_bundle(%s): srcs cannot be combined with source_dir; " +
"put generated sources in a dedicated bundle") % name,
)
Comment thread
AlexanderLanin marked this conversation as resolved.

# Keep directory-discovered sources separate from explicit Bazel targets so
# each kind can retain its own runtime path and staging behavior.
source_dir_globbed = glob_doc_sources(source_dir) if source_dir != None else []
sourcelinks = []
if scan_code:
print("WARNING: docs_bundle(%s) uses deprecated scan_code; use code_targets instead." % name)
Expand All @@ -152,7 +176,8 @@ def docs_bundle(name, source_dir = None, data = [], entry_doc = "index", bundles
# The helper validates child declarations and creates the internal target.
create_bundle(
name = name,
srcs = srcs,
source_dir_globbed = source_dir_globbed,
source_targets = srcs,
sourcelinks = sourcelinks,
strip_prefix = strip_prefix,
entry_doc = entry_doc,
Expand Down Expand Up @@ -221,6 +246,9 @@ def docs(
data: Additional files owned by this project's root ``:docs_bundle``.
This is shorthand for declaring the files in that root bundle; mounted
child content belongs in the child ``docs_bundle(data = [...])``.
For generated documentation in a mounted child, use that bundle's
explicit ``srcs`` instead; ``data`` remains for supporting/runtime
files.
deps: Additional dependencies for the documentation build.
external_needs: List of external needs targets to include in the documentation build.
scan_code: Deprecated. Explicit source files or filegroups to scan for source
Expand All @@ -245,7 +273,8 @@ def docs(

``docs(data = [...])`` owns files in the root ``:docs_bundle``. A child
bundle uses the same ``data`` attribute and may omit ``source_dir`` when it
contains only generated or supporting files.
contains only supporting files. Use explicit ``srcs`` in a source-less
child bundle when its documentation is generated by a build action.
"""
# HINT: keep documentation sync docs/reference/bazel_macros.rst

Expand Down
19 changes: 10 additions & 9 deletions docs/how-to/bundles/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,13 @@ If the parent is mounted at ``guides/example``, its page is rendered at
``guides/example/child/landing.html``. The consuming project can change
``guides/example`` without changing either bundle.

Mount generated documentation
-----------------------------
Mount generated bundle content
------------------------------

When a build action produces documentation rather than a source-tree ``.rst``
file, create a bundle without ``source_dir``. Its generated files are the
bundle's complete payload. You can generate and mount a page like this:
Use explicit ``srcs`` when a build action produces the documentation rather
than a source-tree ``.rst`` file. The source-less bundle has no ``source_dir``;
its generated files are the bundle's complete source payload. You can generate
and mount a page like this:
Comment thread
AlexanderLanin marked this conversation as resolved.

.. code-block:: starlark

Expand All @@ -68,10 +69,10 @@ bundle's complete payload. You can generate and mount a page like this:
===================' > $@""",
)

# Pure-data bundle: the genrule output lives in ``bazel-out/``, not the tree.
# Source bundle: the genrule output lives in ``bazel-out/``, not the tree.
docs_bundle(
name = "data_bundle",
data = [":generated_page"],
srcs = [":generated_page"],
entry_doc = "index",
visibility = ["//visibility:public"],
)
Expand All @@ -89,8 +90,8 @@ See the `complete generated-data fixture on GitHub
<https://github.com/eclipse-score/docs-as-code/tree/main/src/tests/docs_bzl/scenarios/data_files_runfiles>`_.

The generated page is rendered at ``data_test/index.html`` and is added to the
consuming project's index page's toctree. It is mounted and navigated in
exactly the same way as a bundle with source files.
consuming project's index page's toctree. A generated source bundle is mounted
and navigated in exactly the same way as a bundle with workspace sources.

Mount documentation from another module
----------------------------------------
Expand Down
Loading
Loading