diff --git a/bzl/mount_rules.bzl b/bzl/mount_rules.bzl index 44da7d227..aecb8c6b5 100644 --- a/bzl/mount_rules.bzl +++ b/bzl/mount_rules.bzl @@ -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, @@ -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 diff --git a/docs.bzl b/docs.bzl index b28fede38..afec80921 100644 --- a/docs.bzl +++ b/docs.bzl @@ -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( @@ -322,6 +333,7 @@ def _declare_bundle_local_needs( srcs, entry_doc, sourcelinks_json, + mounts_manifest, data = [], visibility = None, config = None, @@ -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, @@ -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, ) @@ -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, ) @@ -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, diff --git a/src/extensions/docs/mounts_internals.rst b/src/extensions/docs/mounts_internals.rst index 72daa9638..b5ed44f77 100644 --- a/src/extensions/docs/mounts_internals.rst +++ b/src/extensions/docs/mounts_internals.rst @@ -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: @@ -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 @@ -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 diff --git a/src/extensions/score_mounts/__init__.py b/src/extensions/score_mounts/__init__.py index 1f3a14c77..8d2f528fe 100644 --- a/src/extensions/score_mounts/__init__.py +++ b/src/extensions/score_mounts/__init__.py @@ -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, @@ -310,6 +313,96 @@ def _resolve_source_mounts( 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, + ) + 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 = {} + 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 + + +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. @@ -320,6 +413,11 @@ def _on_config_inited(app: Sphinx, config: Config) -> None: 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 + app._score_mount_runtime_specs = () if manifest is None or not manifest.mounts: return @@ -361,9 +459,10 @@ def _on_config_inited(app: Sphinx, config: Config) -> None: # 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: @@ -384,6 +483,7 @@ def _on_config_inited(app: Sphinx, config: Config) -> None: # 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 @@ -397,6 +497,7 @@ def _on_config_inited(app: Sphinx, config: Config) -> None: nested_exclusions[index], ) ) + runtime_specs.append(spec) config.mounts = runtime_mounts @@ -408,6 +509,7 @@ def _on_config_inited(app: Sphinx, config: Config) -> None: 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 @@ -415,6 +517,7 @@ def _on_config_inited(app: Sphinx, config: Config) -> None: config.mounts_from_toml = None logger.info("score_mounts: registered %d mount(s)", len(runtime_mounts)) + app._score_mount_runtime_specs = tuple(runtime_specs) def setup(app: Sphinx) -> dict[str, object]: @@ -427,6 +530,9 @@ def setup(app: Sphinx) -> dict[str, object]: """ 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, diff --git a/src/extensions/score_mounts/_resolver.py b/src/extensions/score_mounts/_resolver.py index 1b1e130bb..fbc394716 100644 --- a/src/extensions/score_mounts/_resolver.py +++ b/src/extensions/score_mounts/_resolver.py @@ -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 @@ -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 diff --git a/src/extensions/score_mounts/tests/test_data_mounts.py b/src/extensions/score_mounts/tests/test_data_mounts.py index 4adce6423..4ef7750e3 100644 --- a/src/extensions/score_mounts/tests/test_data_mounts.py +++ b/src/extensions/score_mounts/tests/test_data_mounts.py @@ -13,6 +13,7 @@ """Tests for ``_resolve_data_mounts`` in the ``score_mounts`` extension.""" from pathlib import Path +from types import SimpleNamespace import pytest @@ -20,8 +21,13 @@ _make_mount_entry, # pyright: ignore[reportPrivateUsage] - white-box unit test _resolve_data_mounts, # pyright: ignore[reportPrivateUsage] - white-box unit test _resolve_source_mounts, # pyright: ignore[reportPrivateUsage] - white-box unit test + _set_document_bundles, # pyright: ignore[reportPrivateUsage] +) +from src.extensions.score_mounts._resolver import ( + BundleMetadata, + MountsManifest, + MountSpec, ) -from src.extensions.score_mounts._resolver import MountsManifest, MountSpec def test_missing_data_file_raises(tmp_path: Path) -> None: @@ -98,6 +104,89 @@ def test_root_bundle_data_is_not_mounted_but_child_data_is( assert mounts[str(child_data.parent)] is manifest.mounts[1] +def test_data_mount_documents_do_not_get_a_bundle() -> None: + """Mounted data documents stay unassociated instead of inheriting the root bundle.""" + primary = BundleMetadata( + label="//:root", + name="root", + ) + manifest = MountsManifest( + mounts=[ + MountSpec( + src_root="", + runtime_path="", + mount_at="generated", + root_bundle=False, + bundle=BundleMetadata( + label="//:data", + name="data", + ), + ), + MountSpec( + src_root="docs", + runtime_path="docs", + mount_at="", + root_bundle=True, + bundle=primary, + ), + ] + ) + env = SimpleNamespace( + project=SimpleNamespace(_mount_entry_docnames={0: ["generated/page"]}), + found_docs={"generated/page", "index"}, + ) + app = SimpleNamespace( + env=env, + _score_mounts_manifest=manifest, + _score_mount_runtime_specs=(None,), + ) + + _set_document_bundles(app, env) + + document_bundles = env._score_document_bundles + assert "generated/page" not in document_bundles + assert document_bundles["index"] == primary + + +def test_mounted_documents_get_their_declaring_bundle() -> None: + """Mounted docnames use the bundle metadata attached to their mount.""" + primary = BundleMetadata(label="//:root", name="root") + child = BundleMetadata(label="//:child", name="child") + manifest = MountsManifest( + mounts=[ + MountSpec( + src_root="docs", + runtime_path="docs", + mount_at="", + root_bundle=True, + bundle=primary, + ), + MountSpec( + src_root="child/docs", + runtime_path="child/docs", + mount_at="child", + root_bundle=False, + bundle=child, + ), + ] + ) + env = SimpleNamespace( + project=SimpleNamespace(_mount_entry_docnames={0: ["child/page"]}), + found_docs={"child/page", "index"}, + ) + app = SimpleNamespace( + env=env, + _score_mounts_manifest=manifest, + _score_mount_runtime_specs=(manifest.mounts[1],), + ) + + _set_document_bundles(app, env) + + document_bundles = env._score_document_bundles + assert document_bundles["child/page"] == child + assert document_bundles["index"] == primary + + def test_root_bundle_source_is_not_a_runtime_mount_but_child_source_is( tmp_path: Path, ) -> None: