Skip to content
Closed
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
16 changes: 13 additions & 3 deletions bzl/bundle_rules.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ 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.",
"entries": "Ordered entries, one per source directory, including its final documentation-tree location and direct source files.",
"own_source_entry": "This bundle's own source directory entry, or None for an aggregator.",
"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.",
Expand Down Expand Up @@ -182,6 +183,9 @@ def _rebase_bundle_entry(entry, mount_at, attach_to):
generated files at a different mount. Giving every rebased entry the
bundle's aggregate data would associate the same file with unrelated
mounts, so the mounts resolver could select the wrong destination.

The direct ``source_files`` list is also preserved through rebasing so the
manifest can reproduce the source package's allowlist at its final mount.
"""
is_bundle_root = not entry.mount_at
if is_bundle_root:
Expand All @@ -197,6 +201,7 @@ def _rebase_bundle_entry(entry, mount_at, attach_to):
entry_doc = entry.entry_doc,
external = entry.external,
repository = entry.repository,
source_files = entry.source_files,
data = entry.data,
)

Expand Down Expand Up @@ -244,11 +249,12 @@ def _docs_bundle_impl(ctx):
own_source_files = []
own_external_runfiles = []
own_data = depset(direct = ctx.files.data)
own_source_entry = None

if ctx.files.srcs:
runtime_path = _bundle_runtime_path(ctx)
external = runtime_path.startswith("../")
entries.append(struct(
own_source_entry = struct(
runtime_path = runtime_path,
# The execution root and runfiles tree spell external repositories
# differently. Keep both locations so every public docs() target can
Expand All @@ -259,8 +265,10 @@ def _docs_bundle_impl(ctx):
entry_doc = ctx.attr.entry_doc,
external = external,
repository = ctx.label.workspace_name,
source_files = ctx.files.srcs,
data = own_data,
))
)
entries.append(own_source_entry)
own_source_files.extend(ctx.files.srcs)
# Local sources are read directly from the workspace by ``bazel run``.
# Only sources from external repositories must be staged in runfiles.
Expand All @@ -276,6 +284,7 @@ def _docs_bundle_impl(ctx):
entry_doc = ctx.attr.entry_doc,
external = False,
repository = ctx.label.workspace_name,
source_files = [],
data = own_data,
))

Expand Down Expand Up @@ -317,6 +326,7 @@ def _docs_bundle_impl(ctx):
DefaultInfo(files = depset(transitive = [all_source_files, all_data])),
DocsBundleInfo(
entries = entries,
own_source_entry = own_source_entry,
own_source_files = depset(direct = own_source_files),
sourcelinks = sourcelinks,
external_runfiles = external_runfiles,
Expand Down
84 changes: 71 additions & 13 deletions bzl/mount_rules.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -12,44 +12,102 @@
# *******************************************************************************
"""
Conversion of documentation bundles from Bazel into mount metadata.

The manifest records both the directories to mount and the exact documentation
files owned by each source bundle. The latter is needed because a source bundle
can live below another Bazel package, which is visible on disk but deliberately
excluded from the parent package's ``native.glob``.
"""

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

def _source_includes(entry):
"""Return direct bundle files as Sphinx include patterns.

``sphinx-mounts`` applies these patterns relative to the mounted directory.
Include every file selected by the Bazel source glob: Sphinx will still
parse only files matching the consumer's ``source_suffix`` while assets
remain available to directives such as ``image`` and ``literalinclude``.
"""
source_root = entry.runtime_path.rstrip("/")
if source_root.endswith("/."):
source_root = source_root[:-2]
if source_root == ".":
source_root = ""
source_prefix = source_root + "/" if source_root else ""
includes = []
for source_file in entry.source_files:
# ``source_files`` is populated by the ``source_dir``-scoped Bazel glob
# and preserved unchanged when an entry is rebased. This conversion
# therefore does not need to validate the source root again.
includes.append("/" + source_file.short_path[len(source_prefix):])
if includes:
return sorted(includes)

# An empty allowlist disables filtering in sphinx-mounts. Use an impossible
# path so an empty source entry cannot accidentally discover nearby docs.
return ["/__score_docs_as_code_no_direct_sources__"]

def _entry_json(entry):
"""Serialize one source or data entry for the mounts manifest."""
return {
"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,
"include": _source_includes(entry) if entry.src_root else [],
"data": [f.path for f in entry.data.to_list()],
}

def _mounts_manifest_impl(ctx):
"""Generate the canonical Sphinx mount manifest."""
bundle_info = ctx.attr.bundle[DocsBundleInfo]
entries = bundle_info.entries
primary_entry = None
if ctx.attr.primary_bundle:
primary_entry = ctx.attr.primary_bundle[DocsBundleInfo].own_source_entry

json_mounts = []
for entry in entries:
json_mounts.append({
"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,
"data": [f.path for f in entry.data.to_list()],
})
json_mounts.append(_entry_json(entry))

out = ctx.actions.declare_file(ctx.label.name + ".json")
ctx.actions.write(out, json.encode({"mounts": json_mounts}))
manifest = {"mounts": json_mounts}
if primary_entry != None:
manifest["primary_source"] = _entry_json(primary_entry)
ctx.actions.write(out, json.encode(manifest))
return [DefaultInfo(files = depset([out]))]

_create_mounts_manifest = rule(
implementation = _mounts_manifest_impl,
attrs = {
"bundle": attr.label(providers = [DocsBundleInfo]),
"primary_bundle": attr.label(
default = None,
providers = [DocsBundleInfo],
doc = "Optional host bundle whose direct sources define the primary-source allowlist.",
),
},
doc = "Writes a Sphinx mount manifest from reusable documentation bundles.",
)

def create_mounts_manifest(name, bundle):
"""Create a Sphinx mount manifest from reusable documentation bundles."""
def create_mounts_manifest(name, bundle, primary_bundle = None):
"""Create a Sphinx mount manifest from reusable documentation bundles.

Args:
name: Manifest target name.
bundle: Bundle whose composed entries become ``mounts`` entries.
primary_bundle: Optional host bundle. Its own source entry is emitted as
``primary_source`` so the runtime can exclude source files that are
physically below the host directory but outside the host package's
Bazel glob.
"""
_create_mounts_manifest(
name = name,
bundle = bundle,
primary_bundle = primary_bundle,
)
return ":" + name
3 changes: 3 additions & 0 deletions docs.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -316,10 +316,13 @@ def docs(
visibility = ["//visibility:private"],
)

# The host bundle supplies the package-aware allowlist for its primary
# source tree; nested bundle sources remain mounted entries.
mounts_manifest_label = [
create_mounts_manifest(
name = "_mounts_manifest",
bundle = mounts_bundle,
primary_bundle = ":docs_bundle",
),
]

Expand Down
6 changes: 6 additions & 0 deletions docs/concepts/mounts/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ Bundles are read from their original source directories. Consequently, an
in-repository bundle remains editable and IDE navigation reaches its real
source files rather than generated copies.

Bazel package boundaries still determine ownership. A host ``docs()`` source
glob does not include documentation below a nested ``BUILD`` package. Such
content should be declared by a bundle in that package and composed through
``bundles``; the generated source allowlist prevents the live host directory
from rediscovering the same files as primary documentation.

Composition
-----------

Expand Down
5 changes: 5 additions & 0 deletions docs/how-to/bundles/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ files are collected like those of ``docs()``; for example,
``entry_doc`` names the bundle-relative page used for navigation when the
bundle is mounted.

Bazel package boundaries are part of source ownership. If a ``BUILD`` file
appears below ``source_dir``, the parent bundle does not glob that nested
package's files. Define those files in a nested ``docs_bundle`` and compose it
with ``bundles`` so the mounted tree contains them exactly once.

Mount a bundle in a project
---------------------------

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 @@ -72,6 +72,11 @@ Minimal example (root ``BUILD``)
- ``source_dir`` (string, default: ``"docs"``)
Path (relative to repository root) to your Sphinx source directory. This is the folder
that contains the top-level ReST/markdown sources. A ``conf.py`` is optional.
Bazel package boundaries apply to this glob: a nested directory containing a
``BUILD`` file is not included in the parent package's source set. Declare
documentation in such a nested package as a ``docs_bundle`` and mount it
through ``bundles``; the generated manifest keeps it from being discovered a
second time from the host directory.

- ``project`` and ``project_url`` (strings, optional)
Project name and canonical project URL. They are required when ``source_dir``
Expand Down Expand Up @@ -175,6 +180,9 @@ Signature: ``docs_bundle(name, source_dir = None, data = [], entry_doc = "index"
``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``).
Bazel package boundaries are respected. Sources below a nested ``BUILD``
package are owned by that package rather than this bundle and must be exposed
through a nested bundle if they are part of the composed documentation.
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 data-only bundle
Expand Down
19 changes: 19 additions & 0 deletions src/extensions/docs/mounts_internals.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@ Each manifest entry contains:
* ``mount_at`` and ``attach_to`` — the already-composed Sphinx placement; and
* ``entry_doc`` — the canonical entry document declared by the source bundle.
* ``external`` — whether the directory belongs to another Bazel module.
* ``repository`` — the Bazel repository that owns the entry;
* ``include`` — the exact files selected by the entry's direct Bazel source
glob, expressed as patterns relative to the mounted directory; and
* ``data`` — execroot-relative generated/supporting files resolved at the
entry's mount.

The manifest may also contain a top-level ``primary_source`` entry. ``docs()``
uses it for the host bundle's own source directory. At runtime,
``score_mounts`` adds every file matching Sphinx's configured source suffixes
that is present below that directory but absent from
``primary_source.include`` to Sphinx's ``exclude_patterns``. This mirrors
Bazel's package-aware source glob when a nested bundle package is physically
located below the host's source directory and prevents duplicate document
discovery.

The rule rejects conflicting final placements before Sphinx starts. A mount
without ``attach_to`` is attached to the ``index`` document beside its
Expand Down Expand Up @@ -93,6 +107,11 @@ A ``source_dir`` bundle already has the mount-relative layout on disk.
Materializing a second directory would duplicate its files, point navigation at
a generated copy, and add a build action without changing the Sphinx input.

The direct-source allowlist in the manifest handles the one filtering case
needed by Bazel package boundaries without copying files: it limits discovery
to the files owned by the source bundle while leaving the original directory
available for mounted assets.

Reconsider materialization only if a future bundle cannot be represented by one
existing directory, for example for a filtered file set, a custom
``strip_prefix``, or generated provider content. Prefer native filtering support
Expand Down
Loading
Loading