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/mount_rules.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ def _composition_manifest_impl(ctx):

json_mounts = []
for entry in entries:
# A standalone bundle's Needs action must see only the entries it owns.
# Composed documentation still receives every entry through the normal
# manifest, so this filter changes only the manifest view requested by
# the caller.
if ctx.attr.own_only and not entry.root_bundle:
continue
mount = {
"src_root": entry.src_root,
"runtime_path": entry.runtime_path,
Expand Down Expand Up @@ -67,15 +73,19 @@ _composition_manifest = rule(
implementation = _composition_manifest_impl,
attrs = {
"bundle": attr.label(providers = [DocsBundleInfo]),
# ``own_only`` creates the local view used by a bundle's standalone
# Needs export; the default keeps the complete composition unchanged.
"own_only": attr.bool(default = False),
},
doc = "Writes the composition consumed by runtime documentation tools.",
doc = "Writes the bundle composition consumed by runtime documentation tools.",
)

def create_composition_manifest(name, bundle, visibility = None):
"""Create a common mount and bundle-metadata manifest."""
def create_composition_manifest(name, bundle, own_only = False, visibility = None):
"""Create a mount manifest for composed builds or local Needs document mapping."""
_composition_manifest(
name = name,
bundle = bundle,
own_only = own_only,
visibility = visibility,
)
return ":" + name
17 changes: 17 additions & 0 deletions docs.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -311,9 +311,20 @@ def _declare_docs_bundle(
**kwargs
)

# Standalone Needs actions must resolve document-to-bundle mappings against
# this bundle's own root entries. Nested entries belong to the eventual
# composing build and are therefore omitted from this local manifest view.
local_manifest = create_composition_manifest(
name = _bundle_internal_target(name, "local_manifest"),
bundle = ":" + name,
own_only = True,
visibility = visibility,
)

return struct(
source_dir_globbed = source_dir_globbed,
sourcelinks_json = sourcelinks_json,
local_manifest = local_manifest,
)

def _declare_bundle_local_needs(
Expand All @@ -322,6 +333,7 @@ def _declare_bundle_local_needs(
srcs,
entry_doc,
sourcelinks_json,
mounts_manifest,
data = [],
visibility = None,
config = None,
Expand Down Expand Up @@ -363,6 +375,8 @@ def _declare_bundle_local_needs(
# The generated source-links target stays typed as a label here; the
# private Needs rule owns translating it to an action environment path
# and declaring it as an input.
# Pass the local manifest to the Needs action so its Python Sphinx process
# sees the same bundle boundary as this standalone export.
_needs_sphinx_docs(
name = needs_local,
bundle = ":" + name,
Expand All @@ -374,6 +388,7 @@ def _declare_bundle_local_needs(
score_bundle_needs_export = "1",
score_sourcelinks_json = sourcelinks_json,
score_source_code_linker_plain_links = "1",
mounts_manifest = mounts_manifest,
visibility = visibility,
)

Expand Down Expand Up @@ -411,6 +426,7 @@ def docs_bundle(
srcs = srcs,
entry_doc = entry_doc,
sourcelinks_json = bundle.sourcelinks_json,
mounts_manifest = bundle.local_manifest,
data = data,
visibility = visibility,
)
Expand Down Expand Up @@ -588,6 +604,7 @@ def docs(
srcs = [],
entry_doc = "index",
sourcelinks_json = root_bundle.sourcelinks_json,
mounts_manifest = root_bundle.local_manifest,
data = data,
visibility = ["//visibility:public"],
config = sphinx_config,
Expand Down
25 changes: 19 additions & 6 deletions src/extensions/docs/mounts_internals.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@ semantics are documented in :ref:`docs_concept_mounts`; BUILD usage is in
Architecture and manifest contract
----------------------------------

``docs.bzl`` owns bundle graph traversal. Its composition-manifest rule turns
the ``DocsBundleInfo`` provider graph and each consumer placement into one JSON
manifest. Python receives paths and stable bundle metadata rather than Bazel
providers, so it does not reconstruct Bazel repository names at Sphinx runtime.
``docs.bzl`` owns bundle graph traversal. Its composition-manifest rule turns the
``DocsBundleInfo`` provider graph and each consumer placement into one JSON composition
manifest. The same manifest supplies both mount placement and document-to-bundle
metadata. Python deliberately receives paths and stable bundle metadata rather than
Bazel providers, so it does not reconstruct Bazel repository names at Sphinx runtime.

Each manifest entry contains:

Expand All @@ -43,8 +44,9 @@ Each manifest entry contains:
the current composition. This is composition-specific: the same bundle can
be a root in a standalone manifest and a child in another composition.

Bundle metadata is mandatory for every entry. The Bazel producer and Python
consumer are kept in sync as one repository-owned contract.
Standalone bundle Needs actions receive a local-only view containing the
bundle's own root entries; composed builds receive the complete active
composition. Both views use the same schema and runtime resolver.

At ``config-inited``, ``score_mounts`` resolves all directory source mounts before
constructing ``config.mounts``. A mount below Sphinx's primary source directory
Expand All @@ -60,6 +62,17 @@ not included in these directory exclusions. They are intended for generated
sources outside the primary source tree; an explicitly mounted workspace file
below a walked root is a known limitation and would need exact-file exclusions.

Document-to-bundle mapping
--------------------------

After Sphinx has updated its environment, ``score_mounts`` exposes
``get_document_bundles(app)``. The result maps each actually discovered
docname to one bundle instance. Mounted docnames come from the mount
integration's recorded output, while primary docnames come from Sphinx's
discovery set. This avoids assigning a bundle to skipped or conflicting
mounts. The matcher in ``score_metamodel`` will consume this mapping without
inspecting paths or mount configuration.

The rule rejects conflicting final placements before Sphinx starts. A mount
without ``attach_to`` is attached to the ``index`` document beside its
``mount_at``; ``attach_to`` overrides that target. The Python
Expand Down
110 changes: 108 additions & 2 deletions src/extensions/score_mounts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,16 @@

from __future__ import annotations

from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import cast

from sphinx.application import Sphinx
from sphinx.config import Config
from sphinx.util import logging

from src.extensions.score_mounts._resolver import (
BundleMetadata,
MountsManifest,
MountSpec,
load_mounts_manifest,
Expand Down Expand Up @@ -310,6 +313,96 @@
return source_mounts


def _docnames_by_mount_index(
project: object,
runtime_specs: Sequence[MountSpec | None],
) -> dict[int, tuple[str, ...]]:
"""Return the docnames produced by each configured runtime mount.

The returned dictionary uses the mount's index in ``config.mounts`` as its
key and contains the docnames that ``sphinx-mounts`` actually discovered
for that mount. ``_set_document_bundles`` combines those indexes with the
corresponding ``MountSpec`` objects to associate each mounted document
with a bundle.

``score_mounts`` and ``sphinx_mounts`` are version-bound together, so the
adapter's shape is part of their shared contract. Casting documents that
contract for the type checker without re-validating every value keeps this
bridge focused on translating bundle associations, rather than duplicating
manifest validation in Python.
"""
if not runtime_specs:
return {}

# The mapping is produced by the matching sphinx-mounts version and uses
# the same indexes as the runtime mount list assembled below.
raw_docnames = cast(
"Mapping[int, Sequence[str]]",
project._mount_entry_docnames,

Check failure on line 341 in src/extensions/score_mounts/__init__.py

View workflow job for this annotation

GitHub Actions / common / 🔁 Common PR checks

Cannot access attribute "_mount_entry_docnames" for class "object"   Attribute "_mount_entry_docnames" is unknown (reportAttributeAccessIssue)
)
return {index: tuple(docnames) for index, docnames in raw_docnames.items()}


def _set_document_bundles(app: Sphinx, env: object) -> None:
"""Record the bundle associated with each document in the current build."""
# ``config-inited`` stores the manifest on the application because the
# later ``env-updated`` event receives the environment, not the config.
manifest: MountsManifest | None = getattr(app, "_score_mounts_manifest", None)
if manifest is None:
env._score_document_bundles = {}

Check failure on line 352 in src/extensions/score_mounts/__init__.py

View workflow job for this annotation

GitHub Actions / common / 🔁 Common PR checks

Cannot assign to attribute "_score_document_bundles" for class "object"   Attribute "_score_document_bundles" is unknown (reportAttributeAccessIssue)
return

# Keep the manifest order next to the mount indexes reported by
# sphinx-mounts. Data mounts have no associated bundle and are represented
# by ``None`` in this parallel list.
runtime_specs: tuple[MountSpec | None, ...] = getattr(
app, "_score_mount_runtime_specs", ()
)
project = getattr(env, "project", None)
docnames_by_mount = _docnames_by_mount_index(project, runtime_specs)
document_bundles: dict[str, BundleMetadata] = {}
mounted_docnames: set[str] = set()
for index, docnames in docnames_by_mount.items():
spec = runtime_specs[index]
mounted_docnames.update(docnames)
if spec is None or not spec.bundle.label:
continue
# A mounted document is associated with the bundle that supplied its
# mount, not with the primary source tree where its file is staged.
for docname in docnames:
document_bundles[docname] = spec.bundle

# Bazel emits one root source entry for a composition. Taking that entry
# directly keeps this consumer aligned with the producer-owned contract.
primary_bundle = next(
(
spec.bundle
for spec in manifest.mounts
if spec.root_bundle and spec.bundle.label and spec.src_root
),
None,
)
if primary_bundle is not None:
found_docs = cast("set[str]", getattr(env, "found_docs", set()))
for docname in found_docs:
# Sphinx's discovery set is the authoritative list for the primary
# tree. Do not overwrite a bundle association already assigned to a
# mounted bundle, including entries skipped during its walk.
if docname not in mounted_docnames:
document_bundles.setdefault(docname, primary_bundle)

# Store only the final docname-to-bundle mapping on the environment so the
# later matcher can consume it without re-reading paths or mounts.
env._score_document_bundles = document_bundles

Check failure on line 396 in src/extensions/score_mounts/__init__.py

View workflow job for this annotation

GitHub Actions / common / 🔁 Common PR checks

Cannot assign to attribute "_score_document_bundles" for class "object"   Attribute "_score_document_bundles" is unknown (reportAttributeAccessIssue)


def get_document_bundles(app: Sphinx) -> dict[str, BundleMetadata]:
"""Return the bundle associated with each document in the active build."""
# Return a copy because consumers should not be able to mutate Sphinx's
# environment state while they inspect the document-to-bundle mapping.
return dict(getattr(app.env, "_score_document_bundles", {}))


def _on_config_inited(app: Sphinx, config: Config) -> None:
"""Translate the Bazel manifest into ``sphinx_mounts`` runtime config.

Expand All @@ -320,6 +413,11 @@
empty manifest is a no-op.
"""
manifest = _read_manifest(config)
# Keep the input and the runtime index mapping on the app for
# ``env-updated``, which is where Sphinx exposes the documents it actually
# discovered.
app._score_mounts_manifest = manifest

Check failure on line 419 in src/extensions/score_mounts/__init__.py

View workflow job for this annotation

GitHub Actions / common / 🔁 Common PR checks

Cannot assign to attribute "_score_mounts_manifest" for class "Sphinx"   Attribute "_score_mounts_manifest" is unknown (reportAttributeAccessIssue)
app._score_mount_runtime_specs = ()

Check failure on line 420 in src/extensions/score_mounts/__init__.py

View workflow job for this annotation

GitHub Actions / common / 🔁 Common PR checks

Cannot assign to attribute "_score_mount_runtime_specs" for class "Sphinx"   Attribute "_score_mount_runtime_specs" is unknown (reportAttributeAccessIssue)
if manifest is None or not manifest.mounts:
return

Expand Down Expand Up @@ -361,9 +459,10 @@

# Pure-data bundles have empty src_root; skip directory walk.
runtime_mounts: list[dict[str, object]] = []
# This list mirrors ``config.mounts`` so an index from sphinx-mounts can be
# translated back to the bundle that owns the resulting docnames.
runtime_specs: list[MountSpec | None] = []
for spec in manifest.mounts:
# The primary source tree is discovered by Sphinx itself. Its manifest
# entry is metadata for Python consumers, not an additional mount.
if not spec.src_root or spec.root_bundle:
continue
if spec.files:
Expand All @@ -384,6 +483,7 @@
# Companion assets stay in the original source directory and are
# resolved relative to the explicitly mounted document.
runtime_mounts.append(_make_file_mount_entry(document_files, spec))
runtime_specs.append(spec)
continue

# This directory was validated during the ownership pass above. Reuse its
Expand All @@ -397,6 +497,7 @@
nested_exclusions[index],
)
)
runtime_specs.append(spec)

config.mounts = runtime_mounts

Expand All @@ -408,13 +509,15 @@
data_mounts = _resolve_data_mounts(manifest, ws_root, runfiles_dir)
for walk_dir_str, spec in data_mounts.items():
config.mounts.append(_make_mount_entry(Path(walk_dir_str), spec))
runtime_specs.append(None)
logger.info("score_mounts: added %d data mount(s)", len(data_mounts))

# Prevent sphinx_mounts._on_load_toml from overwriting our config with a
# possibly-stale docs/ubproject.toml entry.
config.mounts_from_toml = None

logger.info("score_mounts: registered %d mount(s)", len(runtime_mounts))
app._score_mount_runtime_specs = tuple(runtime_specs)

Check failure on line 520 in src/extensions/score_mounts/__init__.py

View workflow job for this annotation

GitHub Actions / common / 🔁 Common PR checks

Cannot assign to attribute "_score_mount_runtime_specs" for class "Sphinx"   Attribute "_score_mount_runtime_specs" is unknown (reportAttributeAccessIssue)


def setup(app: Sphinx) -> dict[str, object]:
Expand All @@ -427,6 +530,9 @@
"""
app.add_config_value("mounts_manifest", default="", rebuild="env", types=(str,))
app.connect("config-inited", _on_config_inited, priority=300)
# The document-to-bundle mapping is calculated after Sphinx has completed
# discovery so it contains only documents that really entered the environment.
app.connect("env-updated", _set_document_bundles, priority=500)
return {
"version": "0.1",
"parallel_read_safe": True,
Expand Down
5 changes: 3 additions & 2 deletions src/extensions/score_mounts/_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ def from_manifest_entry(cls, entry: dict[str, object]) -> BundleMetadata:

@dataclass(frozen=True)
class MountSpec:
"""Describe one physical mount and its logical bundle owner.
"""Describe one physical mount and its associated logical bundle.

The source, path, and placement fields describe where this particular
entry is read and mounted. ``bundle`` links that physical entry back to
Expand Down Expand Up @@ -115,7 +115,8 @@ class MountSpec:
# Rebasing a nested bundle changes this to false while preserving the
# logical bundle metadata below.
root_bundle: bool = False
# Logical owner and direct-target metadata for this physical mount entry.
# Logical bundle association and direct-target metadata for this physical
# mount entry.
bundle: BundleMetadata = field(default_factory=BundleMetadata)

@classmethod
Expand Down
Loading
Loading