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
64 changes: 61 additions & 3 deletions bzl/bundle_rules.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ load("@score_docs_as_code//:bzl/basics.bzl", "join_path")
DocsBundleInfo = provider(
doc = "A documentation bundle with its source and placement metadata.",
fields = {
"entries": "Ordered entries, one per source directory, including its final documentation-tree location.",
# Each entry carries the source and placement information needed by
# the runtime manifest, plus the identity and direct-target metadata
# of the bundle that declared it.
"entries": "Ordered entries with source, placement, and direct-target metadata.",
"own_source_files": "This bundle's direct source files, excluding nested bundles.",
"source_dir_execroot_path": "Execution-root-relative path of this bundle's direct source root.",
"sourcelinks": "Source-code-link JSON files together with their owning repository.",
Expand All @@ -53,6 +56,7 @@ CodeTargetSourcesInfo = provider(
doc = "Source files collected from an implementation target and its dependencies.",
fields = {
"sources": "Depset of direct and transitive source files.",
"kind": "Bazel rule kind of the selected code target.",
},
)

Expand Down Expand Up @@ -82,6 +86,10 @@ def _collect_code_target_sources_impl(target, ctx):
direct = _source_files_from_attributes(ctx),
transitive = dependency_sources,
),
# The aspect follows dependencies to collect source files, but the
# manifest needs the kind of the target explicitly selected by the
# bundle rather than the kinds of those transitive dependencies.
kind = ctx.rule.kind,
)]

_collect_code_target_sources = aspect(
Expand Down Expand Up @@ -216,10 +224,15 @@ def _rebase_bundle_entry(entry, mount_at, attach_to):
bundle's aggregate data would associate the same file with unrelated
mounts, so the mounts resolver could select the wrong destination.
"""
is_bundle_root = not entry.mount_at
if is_bundle_root:
if not entry.mount_at:
# The child bundle's own root has not been placed below the parent yet.
# Its default attachment is therefore the parent directory's index;
# an explicit attach_to still overrides that default.
rebased_attach_to = attach_to or _parent_index_docname(mount_at)
else:
# This entry is already below another location in the child bundle.
# Keep its attachment relative to that location and prefix the whole
# placement with the mount point chosen by the parent.
rebased_attach_to = join_path(mount_at, entry.attach_to)

return struct(
Expand All @@ -236,6 +249,16 @@ def _rebase_bundle_entry(entry, mount_at, attach_to):
# Preserve the explicit file allowlist when the entry is rebased.
files = entry.files,
data = entry.data,
# Rebasing changes only placement. Keep the declaring bundle identity
# and direct targets attached to the source entry as it moves through
# the composition graph.
bundle_label = entry.bundle_label,
bundle_name = entry.bundle_name,
code_targets = entry.code_targets,
# This entry is now part of a parent composition. It may have been the
# root of its own standalone bundle, but it is a child entry here and
# must be handled as a mounted source rather than as the parent's root.
root_bundle = False,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the difference between root_gundle and is_unplaced_bundle_root ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So e.g. a child mount has is_unplaced_bundle_root = True, until it is finally mounted somewhere.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated response: root_bundle is the persisted flag on the final manifest entry. It says whether the entry belongs to the root bundle of the current composition. The old is_unplaced_bundle_root expression was only a temporary condition while rebasing and has now been removed. A bundle can therefore be a root in a standalone composition and a child when embedded.

)

def _entries_visible_through(ctx, child):
Expand Down Expand Up @@ -283,6 +306,15 @@ def _docs_bundle_impl(ctx):
source_dir_execroot_path = ""
own_external_runfiles = []
own_data = depset(direct = ctx.files.data)
own_bundle_label = str(ctx.label)
own_bundle_name = ctx.label.name
own_code_targets = [
struct(
label = str(target.label),
type = target[CodeTargetSourcesInfo].kind,
)
for target in ctx.attr.code_targets
]

# The macro validates this combination before creating the rule; retain
# the rule-level check for callers of the internal helper as well.
Expand Down Expand Up @@ -310,6 +342,13 @@ def _docs_bundle_impl(ctx):
# Directory mounts discover all supported files below this root.
files = [],
data = own_data,
bundle_label = own_bundle_label,
bundle_name = own_bundle_name,
# This direct entry belongs to the current composition's root
# bundle. _rebase_bundle_entry changes this to false if a parent
# embeds the bundle as a child.
root_bundle = True,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do you know that this is a root_bundle at this point?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All bundles start with the assumption that they are roots, until they are placed somewhere. And _rebase_bundle_entry sets it to false.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated response: At this point _docs_bundle_impl is creating an entry directly from the current bundle's own source or data. Direct entries are root entries of the current composition and are therefore marked true. If a parent embeds the bundle, _rebase_bundle_entry creates the composed entry and sets root_bundle to false. Root status is composition-specific, not a permanent property of the bundle.

code_targets = own_code_targets,
))
own_source_files.extend(ctx.files.source_dir_globbed)
# Local sources are read directly from the workspace by ``bazel run``.
Expand Down Expand Up @@ -342,6 +381,13 @@ def _docs_bundle_impl(ctx):
# original source root and therefore visits only declared files.
files = source_files,
data = own_data,
bundle_label = own_bundle_label,
bundle_name = own_bundle_name,
# This direct entry belongs to the current composition's root
# bundle. _rebase_bundle_entry changes this to false if a parent
# embeds the bundle as a child.
root_bundle = True,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question as above

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated response: The same explanation applies here: this is the current bundle's direct explicit-source entry, so it starts as a root entry of the current composition. When a parent embeds it, _rebase_bundle_entry marks the resulting child entry as false.

code_targets = own_code_targets,
))
own_source_files.extend(ctx.files.source_targets)
# Explicit artifacts outside the workspace source tree need to be
Expand All @@ -363,6 +409,13 @@ def _docs_bundle_impl(ctx):
# Pure-data entries have no documentation source allowlist.
files = [],
data = own_data,
bundle_label = own_bundle_label,
bundle_name = own_bundle_name,
# This direct entry belongs to the current composition's root
# bundle. _rebase_bundle_entry changes this to false if a parent
# embeds the bundle as a child.
root_bundle = True,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm guessing data bundles are root_bundles by defintion?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as normal bundles, until they are wrapped somewhere they are root bundles

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated response: A pure-data bundle gets a root entry when considered as a standalone composition because that entry is needed to carry the bundle's data. Root here means root entry of the current composition, not a documentation source. When the bundle is nested, rebasing changes the entry to root_bundle = false so its data can be mounted as child data.

code_targets = own_code_targets,
))

child_source_files = []
Expand Down Expand Up @@ -427,6 +480,9 @@ _docs_bundle = rule(
"bundle_mount_ats": attr.string_list(),
"bundle_attach_tos": attr.string_list(),
"data": attr.label_list(allow_files = True),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this already has the other things? root_bundle label & name etc. ? Just Code targets was missing?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated response: These fields are introduced as one manifest contract. The previous manifest did not carry the bundle label, bundle name, root status, or direct code targets. code_targets is the new payload, while label, name, and root_bundle identify which bundle the targets belong to and how the entry participates in the current composition.

# The aspect preserves the selected target's rule kind while
# recursively collecting its source files for source-link generation.
"code_targets": attr.label_list(aspects = [_collect_code_target_sources]),
},
doc = "Internal rule that carries bundle files and their documentation-tree locations.",
)
Expand All @@ -440,6 +496,7 @@ def create_bundle(
source_dir = None,
entry_doc = "index",
data = [],
code_targets = [],
visibility = None,
**kwargs):
"""Create a bundle from directory-discovered files and source targets.
Expand All @@ -459,6 +516,7 @@ def create_bundle(
bundle_mount_ats = [bundle.mount_at for bundle in parsed_bundles],
bundle_attach_tos = [bundle.attach_to for bundle in parsed_bundles],
data = data,
code_targets = code_targets,
visibility = visibility,
**kwargs
)
Expand Down
33 changes: 25 additions & 8 deletions bzl/mount_rules.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ Conversion of documentation bundles from Bazel into mount metadata.

load("@score_docs_as_code//:bzl/bundle_rules.bzl", "DocsBundleInfo")

def _mounts_manifest_impl(ctx):
"""Generate the canonical Sphinx mount manifest."""
def _composition_manifest_impl(ctx):
"""Generate the bundle composition manifest."""
bundle_info = ctx.attr.bundle[DocsBundleInfo]
entries = bundle_info.entries

Expand All @@ -35,6 +35,22 @@ def _mounts_manifest_impl(ctx):
# tree rather than a workspace or external-repository directory.
"generated": entry.generated,
"data": [f.path for f in entry.data.to_list()],
# Keep identity and direct-target metadata in the same manifest as
# placement so all runtime consumers use one composition snapshot.
"root_bundle": entry.root_bundle,
"bundle": {
"label": entry.bundle_label,
"name": entry.bundle_name,
# Each direct target contributes its Bazel label and rule kind
# to the bundle metadata consumed by Python.
"code_targets": [
{
"label": target.label,
"type": target.type,
}
for target in entry.code_targets
],
},
}
# Explicit source targets are mounted as a file allowlist. Directory
# bundles omit this key and retain the existing recursive behavior.
Expand All @@ -46,18 +62,19 @@ def _mounts_manifest_impl(ctx):
ctx.actions.write(out, json.encode({"mounts": json_mounts}))
return [DefaultInfo(files = depset([out]))]

_create_mounts_manifest = rule(
implementation = _mounts_manifest_impl,
_composition_manifest = rule(
implementation = _composition_manifest_impl,
attrs = {
"bundle": attr.label(providers = [DocsBundleInfo]),
},
doc = "Writes a Sphinx mount manifest from reusable documentation bundles.",
doc = "Writes the composition consumed by runtime documentation tools.",
)

def create_mounts_manifest(name, bundle):
"""Create a Sphinx mount manifest from reusable documentation bundles."""
_create_mounts_manifest(
def create_composition_manifest(name, bundle, visibility = None):
"""Create a common mount and bundle-metadata manifest."""
_composition_manifest(
name = name,
bundle = bundle,
visibility = visibility,
)
return ":" + name
25 changes: 10 additions & 15 deletions docs.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ load(
)
load(
"@score_docs_as_code//:bzl/mount_rules.bzl",
"create_mounts_manifest",
"create_composition_manifest",
)
# Keep the low-level action behind this name so this macro owns the shared
# Sphinx policy while ``needs_rules.bzl`` owns Bazel's input/output plumbing.
Expand Down Expand Up @@ -306,6 +306,7 @@ def _declare_docs_bundle(
entry_doc = entry_doc,
bundles = bundles,
data = bundle_data,
code_targets = code_targets,
visibility = visibility,
**kwargs
)
Expand Down Expand Up @@ -554,19 +555,6 @@ def docs(
# list-valued attributes such as ``data`` and ``tools``.
metamodel_label = [metamodel] if metamodel else []

mounts_manifest = None
if bundles:
mounts_bundle = create_bundle(
name = "_docs_mounts",
bundles = bundles,
visibility = ["//visibility:private"],
)
mounts_manifest = create_mounts_manifest(
name = "_mounts_manifest",
bundle = mounts_bundle,
)
mounts_manifest_label = [mounts_manifest] if mounts_manifest else []

deps = _sphinx_deps(deps)
deps = deps + [
Label("//src:plantuml_for_python"),
Expand All @@ -587,6 +575,13 @@ def docs(
visibility = ["//visibility:public"],
tags = ["manual"]
)
# The runtime manifest must be generated from the actual root bundle. The
# root entry carries the project's direct code_targets and gives Python a
# complete composition snapshot alongside the nested mounts.
mounts_manifest = create_composition_manifest(
name = "_mounts_manifest",
bundle = ":docs_bundle",
)
_declare_bundle_local_needs(
name = "docs_bundle",
source_dir_globbed = root_bundle.source_dir_globbed,
Expand Down Expand Up @@ -616,7 +611,7 @@ def docs(
docs_data = (
data + external_needs + metamodel_label +
[":sourcelinks_json", ":_external_docs_runfiles"] +
mounts_manifest_label
[mounts_manifest]
)
if config_is_generated:
# A source configuration is read from the workspace; only the
Expand Down
7 changes: 7 additions & 0 deletions src/docs_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ def add_watch_dir(path: Path) -> None:
watch_dirs.append(path_string)

for spec in manifest.mounts:
# The root entry describes the primary source tree already passed to
# Sphinx. It is present so Python receives complete bundle metadata,
# but it is not an additional external mount for live preview. In
# particular, its data paths are action inputs and must not be
# reinterpreted as generated files below bazel-bin.
if spec.root_bundle:
continue
# A data-only bundle has no source directory. Passing its empty
# ``src_root`` to resolve_walk_dir would watch the workspace root,
# which makes sphinx-autobuild observe unrelated files (including its
Expand Down
51 changes: 51 additions & 0 deletions src/docs_cli/dirty_build_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,16 +177,56 @@ def test_mounted_watch_dirs_match_sphinx_mount_paths(tmp_path: Path) -> None:
json.dumps(
{
"mounts": [
{
"src_root": "primary/docs",
"runtime_path": "primary/docs",
"mount_at": "",
"attach_to": "",
"entry_doc": "index",
"external": False,
"repository": "",
"generated": False,
"data": ["bazel-out/k8-fastbuild/bin/primary/generated.rst"],
"root_bundle": True,
"bundle": {
"label": "@@//:root_bundle",
"name": "root_bundle",
"code_targets": [],
},
},
{
"src_root": "extensions/local/docs",
"runtime_path": "extensions/local/docs",
"mount_at": "local",
"attach_to": "",
"entry_doc": "index",
"external": False,
"repository": "",
"generated": False,
"data": [],
"root_bundle": False,
"bundle": {
"label": "@@//:local_bundle",
"name": "local_bundle",
"code_targets": [],
},
},
{
"src_root": "external/vendor+/docs",
"runtime_path": "../vendor+/docs",
"mount_at": "external",
"attach_to": "",
"entry_doc": "index",
"external": True,
"repository": "vendor+",
"generated": False,
"data": [],
"root_bundle": False,
"bundle": {
"label": "@@vendor+//:docs_bundle",
"name": "docs_bundle",
"code_targets": [],
},
},
]
}
Expand Down Expand Up @@ -214,7 +254,18 @@ def test_mounted_watch_dirs_use_data_directories_for_pure_data_bundles(
"src_root": "",
"runtime_path": "__data__/pkg/data_bundle",
"mount_at": "generated",
"attach_to": "",
"entry_doc": "index",
"external": False,
"repository": "",
"generated": False,
"data": ["bazel-out/k8-fastbuild/bin/pkg/generated/index.rst"],
"root_bundle": False,
"bundle": {
"label": "@@//:data_bundle",
"name": "data_bundle",
"code_targets": [],
},
}
]
}
Expand Down
25 changes: 24 additions & 1 deletion src/docs_cli/main_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
# SPDX-License-Identifier: Apache-2.0
# *******************************************************************************

import json
from pathlib import Path
from unittest.mock import Mock

Expand Down Expand Up @@ -163,7 +164,29 @@ def test_live_preview_uses_port_and_bundle_watches(
monkeypatch.setenv("ACTION", "live_preview")
manifest = workspace / "runfiles/mounts.json"
manifest.write_text(
'{"mounts": [{"src_root": "extra/docs", "runtime_path": "extra/docs", "mount_at": "extra"}]}'
json.dumps(
{
"mounts": [
{
"src_root": "extra/docs",
"runtime_path": "extra/docs",
"mount_at": "extra",
"attach_to": "",
"entry_doc": "index",
"external": False,
"repository": "",
"generated": False,
"data": [],
"root_bundle": False,
"bundle": {
"label": "@@//:extra_bundle",
"name": "extra_bundle",
"code_targets": [],
},
}
]
}
)
)
monkeypatch.setenv("MOUNTS_MANIFEST", "mounts.json")
autobuild = Mock()
Expand Down
Loading
Loading