From df5acb07b63264202a23cc6d8dc7636fb331c3ee Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Sun, 20 Sep 2026 02:12:14 +0200 Subject: [PATCH 1/3] feat: expose bundle target metadata --- bzl/bundle_rules.bzl | 50 +++++- bzl/mount_rules.bzl | 44 +++++- deprecations.md | 36 +++++ docs.bzl | 25 ++- src/extensions/docs/mounts_internals.rst | 14 +- src/extensions/score_mounts/__init__.py | 14 +- src/extensions/score_mounts/_resolver.py | 145 ++++++++++++------ .../score_mounts/tests/test_data_mounts.py | 68 ++++++++ .../score_mounts/tests/test_resolver.py | 97 ++++++++++-- .../docs_bzl/scenarios/nested_bundles/BUILD | 11 +- .../_expected/mounts_manifest.json | 47 ++++++ .../_expected/ordered_aggregate_manifest.json | 25 +++ 12 files changed, 473 insertions(+), 103 deletions(-) create mode 100644 deprecations.md diff --git a/bzl/bundle_rules.bzl b/bzl/bundle_rules.bzl index 2e3db9f01..d9191d506 100644 --- a/bzl/bundle_rules.bzl +++ b/bzl/bundle_rules.bzl @@ -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.", @@ -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.", }, ) @@ -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( @@ -216,8 +224,11 @@ 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: + # This is the child bundle's own root before it is placed in the parent. + # It is distinct from ``root_bundle``, which describes ownership by the + # root bundle of the complete composition. + is_unplaced_bundle_root = not entry.mount_at + if is_unplaced_bundle_root: rebased_attach_to = attach_to or _parent_index_docname(mount_at) else: rebased_attach_to = join_path(mount_at, entry.attach_to) @@ -236,6 +247,13 @@ 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, + root_bundle = False, ) def _entries_visible_through(ctx, child): @@ -283,6 +301,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. @@ -310,6 +337,10 @@ 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, + root_bundle = True, + 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``. @@ -342,6 +373,10 @@ 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, + root_bundle = True, + code_targets = own_code_targets, )) own_source_files.extend(ctx.files.source_targets) # Explicit artifacts outside the workspace source tree need to be @@ -363,6 +398,10 @@ 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, + root_bundle = True, + code_targets = own_code_targets, )) child_source_files = [] @@ -427,6 +466,9 @@ _docs_bundle = rule( "bundle_mount_ats": attr.string_list(), "bundle_attach_tos": attr.string_list(), "data": attr.label_list(allow_files = True), + # 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.", ) @@ -440,6 +482,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. @@ -459,6 +502,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 ) diff --git a/bzl/mount_rules.bzl b/bzl/mount_rules.bzl index 4ec4e26d6..1ae929ac7 100644 --- a/bzl/mount_rules.bzl +++ b/bzl/mount_rules.bzl @@ -16,8 +16,27 @@ 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 _sorted_code_targets(targets): + """Return target metadata in deterministic label/type order. + + Bundle declarations are ordered for source composition, but metadata + consumers should not observe incidental declaration ordering. Encoding the + pair before sorting keeps each label associated with its rule type. + """ + encoded = sorted([ + target.label + "\n" + target.type + for target in targets + ]) + return [ + { + "label": value.split("\n")[0], + "type": value.split("\n", 1)[1], + } + for value in encoded + ] + +def _composition_manifest_impl(ctx): + """Generate the bundle composition manifest.""" bundle_info = ctx.attr.bundle[DocsBundleInfo] entries = bundle_info.entries @@ -35,6 +54,14 @@ 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, + "code_targets": _sorted_code_targets(entry.code_targets), + }, } # Explicit source targets are mounted as a file allowlist. Directory # bundles omit this key and retain the existing recursive behavior. @@ -46,18 +73,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 diff --git a/deprecations.md b/deprecations.md new file mode 100644 index 000000000..cd67e8d20 --- /dev/null +++ b/deprecations.md @@ -0,0 +1,36 @@ + + +# Deprecated behavior to remove + +This file tracks compatibility paths that should be removed after callers have +migrated to the current bundle model. + +## Generated documentation declared through `data` + +`docs_bundle(data = [...])` is still accepted for generated documentation +files, and `score_mounts` turns those files into runtime mounts. Generated +documentation should be declared with `docs_bundle(srcs = [...])` instead. + +Once callers have migrated, remove the data-to-mount path from +`src/extensions/score_mounts/__init__.py` and the corresponding regression +fixture for `legacy_data_bundle`. + +## External Needs passed through `docs(data = [...])` + +Passing `needs_json` targets through `docs(data = [...])` is the old external +Needs interface. Callers should use `docs(external_needs = [...])` instead. + +Once module repositories have migrated, remove the `needs_json` detection and +filtering in `docs.bzl`, together with the compatibility coverage for the old +`data` form. diff --git a/docs.bzl b/docs.bzl index e5c8773be..b28fede38 100644 --- a/docs.bzl +++ b/docs.bzl @@ -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. @@ -306,6 +306,7 @@ def _declare_docs_bundle( entry_doc = entry_doc, bundles = bundles, data = bundle_data, + code_targets = code_targets, visibility = visibility, **kwargs ) @@ -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"), @@ -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, @@ -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 diff --git a/src/extensions/docs/mounts_internals.rst b/src/extensions/docs/mounts_internals.rst index 1c73c0b80..5695f74b9 100644 --- a/src/extensions/docs/mounts_internals.rst +++ b/src/extensions/docs/mounts_internals.rst @@ -24,10 +24,10 @@ semantics are documented in :ref:`docs_concept_mounts`; BUILD usage is in Architecture and manifest contract ---------------------------------- -``docs.bzl`` owns bundle graph traversal. Its ``_mounts_manifest`` rule turns +``docs.bzl`` owns bundle graph traversal. Its composition-manifest rule turns the ``DocsBundleInfo`` provider graph and each consumer placement into one JSON -manifest. Python deliberately receives paths rather than Bazel labels, so it -does not reconstruct Bazel repository names at Sphinx runtime. +manifest. Python 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: @@ -37,7 +37,13 @@ Each manifest entry contains: walked; * ``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. +* ``external`` — whether the directory belongs to another Bazel module; +* ``bundle`` — the declaring bundle's Bazel label, name, and direct targets; +* ``root_bundle`` — whether this physical entry belongs to the composition's + root bundle. + +Bundle metadata is mandatory for every entry. The Bazel producer and Python +consumer are kept in sync as one repository-owned contract. At ``config-inited``, ``score_mounts`` resolves all directory source mounts before constructing ``config.mounts``. A mount below Sphinx's primary source directory diff --git a/src/extensions/score_mounts/__init__.py b/src/extensions/score_mounts/__init__.py index c8b192b05..abf49c19d 100644 --- a/src/extensions/score_mounts/__init__.py +++ b/src/extensions/score_mounts/__init__.py @@ -77,6 +77,14 @@ def _resolve_data_mounts( """ data_mounts: dict[str, MountSpec] = {} for spec in manifest.mounts: + # TODO: Remove this data-mount path, including the root-bundle + # distinction, once callers have migrated generated documentation from + # ``docs_bundle(data = [...])`` to ``docs_bundle(srcs = [...])``. See + # ``deprecations.md`` in the repository root. + # Data belonging to the primary bundle is already part of the Sphinx + # action inputs. Only rebased child data has a documentation-tree mount. + if spec.root_bundle: + continue for data_file in spec.data: if ws_root is not None and runfiles_dir is not None: runfiles_str = str(runfiles_dir) @@ -291,7 +299,7 @@ def _resolve_source_mounts( """ source_mounts: list[tuple[MountSpec, Path]] = [] for spec in manifest.mounts: - if not spec.src_root or spec.files: + if not spec.src_root or spec.files or spec.root_bundle: continue walk_dir = resolve_walk_dir(manifest, spec, ws_root, runfiles_dir) if not walk_dir.is_dir(): @@ -355,7 +363,9 @@ 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]] = [] for spec in manifest.mounts: - if not spec.src_root: + # 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: # Explicit source bundles use sphinx-mounts' file-list mode so the diff --git a/src/extensions/score_mounts/_resolver.py b/src/extensions/score_mounts/_resolver.py index a769578b9..692c7af55 100644 --- a/src/extensions/score_mounts/_resolver.py +++ b/src/extensions/score_mounts/_resolver.py @@ -11,7 +11,7 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -"""Load the mounts manifest JSON emitted by the ``_mounts_manifest`` Bazel rule. +"""Load the composition manifest emitted by Bazel. All mount paths are authored by Bazel (where ``File`` objects have real paths) and shipped in a small JSON manifest. This module only *reads* that manifest — it @@ -27,9 +27,57 @@ from typing import cast +@dataclass(frozen=True) +class BazelTarget: + """One direct code target associated with a bundle. + + This is deliberately not just a Bazel label: the label identifies the + target, while ``type`` carries ``ctx.rule.kind`` (for example + ``cc_library`` or ``filegroup``). Both values are needed by consumers of + the composition manifest. + """ + + # Canonical Bazel label, for example + # ``@@//score/components/memory:implementation``. + label: str + # Bazel rule kind, for example ``cc_library`` or ``filegroup``. The rule + # kind is not encoded in the label itself. + type: str + + +@dataclass(frozen=True) +class BundleMetadata: + """Identity and direct targets of one bundle in a composition. + + ``MountSpec`` describes one physical source entry and its placement. + ``BundleMetadata`` describes the logical bundle that declared that entry. + A bundle can produce several mount entries after nesting and rebasing, so + each of those ``MountSpec`` objects carries the same bundle metadata while + retaining its own source and placement fields. + """ + + # Canonical Bazel label of the declaring bundle, for example + # ``@@//score/components/memory:docs``. + label: str = "" + # The name passed to ``docs_bundle(name = ...)``, for example ``docs``. + name: str = "" + # Targets declared directly by this bundle, for example + # ``(BazelTarget("@@//score/components/memory:implementation", "cc_library"),)``. + # Targets inherited from dependencies or nested bundles do not belong here. + code_targets: tuple[BazelTarget, ...] = () + + @dataclass(frozen=True) class MountSpec: - """Describe one documentation mount and how its source root is resolved.""" + """Describe one physical mount and its logical bundle owner. + + The source, path, and placement fields describe where this particular + entry is read and mounted. ``bundle`` links that physical entry back to + the logical bundle that declared it. For example, the bundle + ``@@//score/components/memory:docs`` may be rebased to + ``components/memory``: ``mount_at`` changes, while ``bundle.name`` remains + ``docs`` and ``bundle.label`` remains the bundle's Bazel label. + """ src_root: str runtime_path: str @@ -45,6 +93,12 @@ class MountSpec: # the mount can use the original files without recursively walking peers. files: list[str] = field(default_factory=list) data: list[str] = field(default_factory=list) + # Whether this physical entry belongs to the composition's root bundle. + # 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. + bundle: BundleMetadata = field(default_factory=BundleMetadata) @dataclass(frozen=True) @@ -52,6 +106,39 @@ class MountsManifest: mounts: list[MountSpec] +def _read_bundle_metadata(entry: dict[str, object]) -> BundleMetadata: + """Map the producer-owned bundle fields to their runtime dataclass.""" + bundle = cast("dict[str, object]", entry["bundle"]) + targets = cast("list[dict[str, str]]", bundle["code_targets"]) + return BundleMetadata( + label=cast("str", bundle["label"]), + name=cast("str", bundle["name"]), + code_targets=tuple( + BazelTarget(label=target["label"], type=target["type"]) + for target in targets + ), + ) + + +def _read_mount_spec(entry: dict[str, object]) -> MountSpec: + """Map one producer-owned manifest entry to a runtime mount spec.""" + attach_to = cast("str", entry["attach_to"]) + return MountSpec( + src_root=cast("str", entry["src_root"]), + runtime_path=cast("str", entry["runtime_path"]), + mount_at=cast("str", entry["mount_at"]), + attach_to=attach_to or None, + entry_doc=cast("str", entry["entry_doc"]), + external=cast("bool", entry["external"]), + repository=cast("str", entry["repository"]), + generated=cast("bool", entry["generated"]), + files=cast("list[str]", entry.get("files", [])), + data=cast("list[str]", entry["data"]), + root_bundle=cast("bool", entry["root_bundle"]), + bundle=_read_bundle_metadata(entry), + ) + + def load_mounts_manifest(manifest_path: str | Path) -> MountsManifest: """Read the manifest JSON at ``manifest_path`` (an already-resolved path). @@ -59,54 +146,12 @@ def load_mounts_manifest(manifest_path: str | Path) -> MountsManifest: exec root in a sandbox) is the caller's responsibility. """ manifest_path = Path(manifest_path) - raw_data: object = json.loads(manifest_path.read_text(encoding="utf-8")) - if not isinstance(raw_data, dict): - raise ValueError( - f"mounts manifest must be a JSON object, got {type(raw_data).__name__}: {raw_data!r}" - ) - data = cast("dict[str, object]", raw_data) - mounts: list[MountSpec] = [] - mounts_data = data.get("mounts", []) - if not isinstance(mounts_data, list): - raise ValueError("mounts manifest field 'mounts' must be a list") - typed_mounts_data = cast("list[object]", mounts_data) - for raw_entry in typed_mounts_data: - if not isinstance(raw_entry, dict): - raise ValueError(f"mounts manifest entry must be an object: {raw_entry!r}") - entry = cast("dict[str, object]", raw_entry) - if "src_root" not in entry or "mount_at" not in entry: - raise ValueError( - f"mounts manifest entry missing 'src_root'/'mount_at': {entry!r}" - ) - raw_data = entry.get("data", []) - if not isinstance(raw_data, list): - raise ValueError( - f"mounts manifest entry field 'data' must be a list: {raw_data!r}" - ) - raw_files = entry.get("files", []) - if not isinstance(raw_files, list): - raise ValueError( - f"mounts manifest entry field 'files' must be a list: {raw_files!r}" - ) - mounts.append( - MountSpec( - src_root=str(entry["src_root"]), - runtime_path=str(entry.get("runtime_path", "")), - mount_at=str(entry["mount_at"]), - attach_to=str(entry["attach_to"]) if entry.get("attach_to") else None, - entry_doc=str(entry["entry_doc"]) - if entry.get("entry_doc") - else "index", - external=bool(entry.get("external", False)), - repository=str(entry.get("repository", "")), - # Older manifests do not have this field and represent regular - # workspace or external-repository source roots. - generated=bool(entry.get("generated", False)), - files=[str(f) for f in cast("list[object]", raw_files)], - data=[str(f) for f in cast("list[object]", raw_data)], - ) - ) - return MountsManifest(mounts=mounts) + data = cast( + "dict[str, object]", + json.loads(manifest_path.read_text(encoding="utf-8")), + ) + entries = cast("list[dict[str, object]]", data["mounts"]) + return MountsManifest(mounts=[_read_mount_spec(entry) for entry in entries]) def resolve_walk_dir( diff --git a/src/extensions/score_mounts/tests/test_data_mounts.py b/src/extensions/score_mounts/tests/test_data_mounts.py index b5a16a9b0..4adce6423 100644 --- a/src/extensions/score_mounts/tests/test_data_mounts.py +++ b/src/extensions/score_mounts/tests/test_data_mounts.py @@ -19,6 +19,7 @@ from src.extensions.score_mounts import ( _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 ) from src.extensions.score_mounts._resolver import MountsManifest, MountSpec @@ -62,6 +63,73 @@ def test_existing_data_file_resolved(tmp_path: Path) -> None: assert str(tmp_path / "bazel-bin") in mounts +def test_root_bundle_data_is_not_mounted_but_child_data_is( + tmp_path: Path, +) -> None: + """Only rebased child data creates a runtime data mount.""" + root_data = tmp_path / "bazel-bin" / "root.txt" + child_data = tmp_path / "bazel-bin" / "child" / "child.txt" + root_data.parent.mkdir(parents=True) + child_data.parent.mkdir() + root_data.write_text("root", encoding="utf-8") + child_data.write_text("child", encoding="utf-8") + manifest = MountsManifest( + mounts=[ + MountSpec( + src_root="", + runtime_path="", + mount_at="", + data=["bazel-out/k8-fastbuild/bin/root.txt"], + root_bundle=True, + ), + MountSpec( + src_root="", + runtime_path="", + mount_at="child", + data=["bazel-out/k8-fastbuild/bin/child/child.txt"], + root_bundle=False, + ), + ] + ) + + mounts = _resolve_data_mounts(manifest, tmp_path, tmp_path / "runfiles") + + assert str(root_data.parent) not in mounts + assert mounts[str(child_data.parent)] is manifest.mounts[1] + + +def test_root_bundle_source_is_not_a_runtime_mount_but_child_source_is( + tmp_path: Path, +) -> None: + """Only rebased child source roots enter the runtime mount set.""" + root_dir = tmp_path / "root" + child_dir = tmp_path / "child" + root_dir.mkdir() + child_dir.mkdir() + (root_dir / "index.rst").write_text("Root", encoding="utf-8") + (child_dir / "index.rst").write_text("Child", encoding="utf-8") + manifest = MountsManifest( + mounts=[ + MountSpec( + src_root="root", + runtime_path="root", + mount_at="", + root_bundle=True, + ), + MountSpec( + src_root="child", + runtime_path="child", + mount_at="child", + root_bundle=False, + ), + ] + ) + + mounts = _resolve_source_mounts(manifest, tmp_path, None) + + assert mounts == [(manifest.mounts[1], child_dir.resolve())] + + def test_mount_entry_uses_canonical_directory_for_symlinked_bundle( tmp_path: Path, ) -> None: diff --git a/src/extensions/score_mounts/tests/test_resolver.py b/src/extensions/score_mounts/tests/test_resolver.py index 326942fc3..95d9e4909 100644 --- a/src/extensions/score_mounts/tests/test_resolver.py +++ b/src/extensions/score_mounts/tests/test_resolver.py @@ -12,16 +12,19 @@ # ******************************************************************************* """Unit tests for the mounts manifest loader (``_resolver``). -These cover the pure parsing layer only: reading the JSON manifest into -``MountSpec`` objects, applying defaults, rejecting malformed input, and -resolving source roots in runfiles versus an exec root.""" +These cover the pure parsing layer only: reading the synchronized producer +format into ``MountSpec`` objects and resolving source roots in runfiles versus +an exec root.""" import json from pathlib import Path +from typing import cast import pytest from src.extensions.score_mounts._resolver import ( + BazelTarget, + BundleMetadata, MountSpec, load_mounts_manifest, resolve_source_files, @@ -29,7 +32,39 @@ ) -def _write_manifest(tmp_path: Path, payload: dict[str, object]) -> Path: +def _write_manifest( + tmp_path: Path, + payload: dict[str, object], +) -> Path: + """Write one complete producer-shaped manifest fixture.""" + if isinstance(payload.get("mounts"), list): + mounts: list[object] = [] + for raw_entry in cast("list[object]", payload["mounts"]): + if isinstance(raw_entry, dict): + entry = cast("dict[str, object]", raw_entry) + mounts.append( + { + "src_root": "", + "runtime_path": "", + "mount_at": "", + "attach_to": "", + "entry_doc": "index", + "external": False, + "repository": "", + "generated": False, + "data": [], + "root_bundle": False, + "bundle": { + "label": "@@//:test_bundle", + "name": "test_bundle", + "code_targets": [], + }, + **entry, + } + ) + else: + mounts.append(raw_entry) + payload = {**payload, "mounts": mounts} tmp_path.mkdir(parents=True, exist_ok=True) manifest = tmp_path / "_mounts_manifest.json" manifest.write_text(json.dumps(payload), encoding="utf-8") @@ -56,6 +91,10 @@ def test_load_single_entry(tmp_path: Path) -> None: src_root="src/docs", runtime_path="src/docs_dir", mount_at="internals/code_docs", + bundle=BundleMetadata( + label="@@//:test_bundle", + name="test_bundle", + ), ) ] @@ -80,6 +119,43 @@ def test_load_entry_with_attach_to_and_entry_doc(tmp_path: Path) -> None: assert spec.entry_doc == "start" +def test_load_bundle_metadata_and_direct_targets(tmp_path: Path) -> None: + """Decode bundle identity and direct target pairs from the manifest.""" + manifest = _write_manifest( + tmp_path, + { + "mounts": [ + { + "src_root": "docs", + "runtime_path": "docs", + "mount_at": "component", + "root_bundle": True, + "bundle": { + "label": "@@//pkg:memory", + "name": "memory", + "code_targets": [ + {"label": "@@//pkg:memory_core", "type": "cc_library"}, + {"label": "@@//pkg:memory_api", "type": "cc_library"}, + ], + }, + } + ], + }, + ) + + result = load_mounts_manifest(manifest) + + assert result.mounts[0].root_bundle is True + assert result.mounts[0].bundle == BundleMetadata( + label="@@//pkg:memory", + name="memory", + code_targets=( + BazelTarget(label="@@//pkg:memory_core", type="cc_library"), + BazelTarget(label="@@//pkg:memory_api", type="cc_library"), + ), + ) + + def test_external_mount_keeps_execroot_and_runfiles_locations(tmp_path: Path) -> None: manifest = _write_manifest( tmp_path, @@ -107,19 +183,6 @@ def test_external_mount_keeps_execroot_and_runfiles_locations(tmp_path: Path) -> assert specs[1].repository == "score_process_description+" -def test_load_missing_required_key_raises(tmp_path: Path) -> None: - manifest = _write_manifest(tmp_path, {"mounts": [{"runtime_path": "src/docs_dir"}]}) - with pytest.raises(ValueError, match="missing 'src_root'/'mount_at'"): - load_mounts_manifest(str(manifest)) - - -def test_load_non_object_raises(tmp_path: Path) -> None: - manifest = tmp_path / "_mounts_manifest.json" - manifest.write_text('["not", "an", "object"]', encoding="utf-8") - with pytest.raises(ValueError, match="must be a JSON object"): - load_mounts_manifest(str(manifest)) - - def test_external_mount_uses_execroot_path_in_sandbox( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/src/tests/docs_bzl/scenarios/nested_bundles/BUILD b/src/tests/docs_bzl/scenarios/nested_bundles/BUILD index af393c3ec..8b3b4873f 100644 --- a/src/tests/docs_bzl/scenarios/nested_bundles/BUILD +++ b/src/tests/docs_bzl/scenarios/nested_bundles/BUILD @@ -11,7 +11,7 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -load("//:bzl/mount_rules.bzl", "create_mounts_manifest") +load("//:bzl/mount_rules.bzl", "create_composition_manifest") load("//:docs.bzl", "docs", "docs_bundle") load("@aspect_rules_py//py:defs.bzl", "py_binary") @@ -19,10 +19,12 @@ docs_bundle( name = "child", source_dir = "child", entry_doc = "landing", + # Keep declarations deliberately unsorted; the manifest contract requires + # deterministic label/type ordering independent of BUILD-file order. code_targets = [ - ":example_binary", - ":example_executable", ":nested_filegroup_sources", + ":example_executable", + ":example_binary", ], ) @@ -92,7 +94,7 @@ docs_bundle( source_dir = "other", ) -create_mounts_manifest( +create_composition_manifest( name = "ordered_aggregate_manifest", bundle = ":ordered_aggregate", ) @@ -101,6 +103,7 @@ create_mounts_manifest( # pytest integration test in //src/tests/docs_bzl. docs( source_dir = "host_docs", + code_targets = [":example_binary"], test_sources = ["src/tests/docs_bzl/scenarios/nested_bundles"], bundles = [{ "bundle": ":parent", diff --git a/src/tests/docs_bzl/scenarios/nested_bundles/_expected/mounts_manifest.json b/src/tests/docs_bzl/scenarios/nested_bundles/_expected/mounts_manifest.json index eac0111a3..ff816fd86 100644 --- a/src/tests/docs_bzl/scenarios/nested_bundles/_expected/mounts_manifest.json +++ b/src/tests/docs_bzl/scenarios/nested_bundles/_expected/mounts_manifest.json @@ -1,7 +1,35 @@ { "mounts": [ + { + "attach_to": "", + "root_bundle": true, + "bundle": { + "label": "@@//src/tests/docs_bzl/scenarios/nested_bundles:docs_bundle", + "name": "docs_bundle", + "code_targets": [ + { + "label": "@@//src/tests/docs_bzl/scenarios/nested_bundles:example_binary", + "type": "py_binary" + } + ] + }, + "data": [], + "entry_doc": "index", + "external": false, + "generated": false, + "mount_at": "", + "repository": "", + "runtime_path": "src/tests/docs_bzl/scenarios/nested_bundles/host_docs", + "src_root": "src/tests/docs_bzl/scenarios/nested_bundles/host_docs" + }, { "attach_to": "concepts/index", + "root_bundle": false, + "bundle": { + "label": "@@//src/tests/docs_bzl/scenarios/nested_bundles:parent", + "name": "parent", + "code_targets": [] + }, "data": [ "src/tests/docs_bzl/scenarios/nested_bundles/generated/generated_output.txt" ], @@ -15,6 +43,25 @@ }, { "attach_to": "concepts/example_bundle/index", + "root_bundle": false, + "bundle": { + "label": "@@//src/tests/docs_bzl/scenarios/nested_bundles:child", + "name": "child", + "code_targets": [ + { + "label": "@@//src/tests/docs_bzl/scenarios/nested_bundles:example_binary", + "type": "py_binary" + }, + { + "label": "@@//src/tests/docs_bzl/scenarios/nested_bundles:example_executable", + "type": "cc_binary" + }, + { + "label": "@@//src/tests/docs_bzl/scenarios/nested_bundles:nested_filegroup_sources", + "type": "filegroup" + } + ] + }, "data": [], "entry_doc": "landing", "external": false, diff --git a/src/tests/docs_bzl/scenarios/nested_bundles/_expected/ordered_aggregate_manifest.json b/src/tests/docs_bzl/scenarios/nested_bundles/_expected/ordered_aggregate_manifest.json index 16f46399c..4c4afecf3 100644 --- a/src/tests/docs_bzl/scenarios/nested_bundles/_expected/ordered_aggregate_manifest.json +++ b/src/tests/docs_bzl/scenarios/nested_bundles/_expected/ordered_aggregate_manifest.json @@ -2,6 +2,12 @@ "mounts": [ { "attach_to": "index", + "root_bundle": false, + "bundle": { + "label": "@@//src/tests/docs_bzl/scenarios/nested_bundles:other", + "name": "other", + "code_targets": [] + }, "data": [], "entry_doc": "index", "external": false, @@ -13,6 +19,25 @@ }, { "attach_to": "index", + "root_bundle": false, + "bundle": { + "label": "@@//src/tests/docs_bzl/scenarios/nested_bundles:child", + "name": "child", + "code_targets": [ + { + "label": "@@//src/tests/docs_bzl/scenarios/nested_bundles:example_binary", + "type": "py_binary" + }, + { + "label": "@@//src/tests/docs_bzl/scenarios/nested_bundles:example_executable", + "type": "cc_binary" + }, + { + "label": "@@//src/tests/docs_bzl/scenarios/nested_bundles:nested_filegroup_sources", + "type": "filegroup" + } + ] + }, "data": [], "entry_doc": "landing", "external": false, From 87b2480b32a81c6793635d43e9f23fd806e451c9 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Sun, 20 Sep 2026 02:54:28 +0200 Subject: [PATCH 2/3] fix: address bundle metadata review feedback --- src/docs_cli/cli.py | 7 +++ src/docs_cli/dirty_build_test.py | 51 +++++++++++++++++++ src/docs_cli/main_test.py | 25 ++++++++- .../docs_bzl/scenarios/nested_bundles/BUILD | 6 +-- 4 files changed, 84 insertions(+), 5 deletions(-) diff --git a/src/docs_cli/cli.py b/src/docs_cli/cli.py index e33d051c0..57dfc5d6c 100644 --- a/src/docs_cli/cli.py +++ b/src/docs_cli/cli.py @@ -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 diff --git a/src/docs_cli/dirty_build_test.py b/src/docs_cli/dirty_build_test.py index f8e82281c..932568cda 100644 --- a/src/docs_cli/dirty_build_test.py +++ b/src/docs_cli/dirty_build_test.py @@ -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": [], + }, }, ] } @@ -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": [], + }, } ] } diff --git a/src/docs_cli/main_test.py b/src/docs_cli/main_test.py index 11311dea6..83ab50a43 100644 --- a/src/docs_cli/main_test.py +++ b/src/docs_cli/main_test.py @@ -11,6 +11,7 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* +import json from pathlib import Path from unittest.mock import Mock @@ -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() diff --git a/src/tests/docs_bzl/scenarios/nested_bundles/BUILD b/src/tests/docs_bzl/scenarios/nested_bundles/BUILD index 8b3b4873f..5ab657032 100644 --- a/src/tests/docs_bzl/scenarios/nested_bundles/BUILD +++ b/src/tests/docs_bzl/scenarios/nested_bundles/BUILD @@ -19,12 +19,10 @@ docs_bundle( name = "child", source_dir = "child", entry_doc = "landing", - # Keep declarations deliberately unsorted; the manifest contract requires - # deterministic label/type ordering independent of BUILD-file order. code_targets = [ - ":nested_filegroup_sources", - ":example_executable", ":example_binary", + ":example_executable", + ":nested_filegroup_sources", ], ) From 6fdb8055ac36454b2beefe3a48e85ec753d5e87b Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Mon, 21 Sep 2026 14:47:40 +0200 Subject: [PATCH 3/3] refactor: clarify bundle metadata contract --- bzl/bundle_rules.bzl | 24 ++++++-- bzl/mount_rules.bzl | 29 +++------- deprecations.md | 36 ------------ src/extensions/docs/mounts_internals.rst | 5 +- src/extensions/score_mounts/__init__.py | 5 +- src/extensions/score_mounts/_resolver.py | 74 +++++++++++++----------- 6 files changed, 73 insertions(+), 100 deletions(-) delete mode 100644 deprecations.md diff --git a/bzl/bundle_rules.bzl b/bzl/bundle_rules.bzl index d9191d506..b7e2c8deb 100644 --- a/bzl/bundle_rules.bzl +++ b/bzl/bundle_rules.bzl @@ -224,13 +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. """ - # This is the child bundle's own root before it is placed in the parent. - # It is distinct from ``root_bundle``, which describes ownership by the - # root bundle of the complete composition. - is_unplaced_bundle_root = not entry.mount_at - if is_unplaced_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( @@ -253,6 +255,9 @@ def _rebase_bundle_entry(entry, mount_at, attach_to): 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, ) @@ -339,6 +344,9 @@ def _docs_bundle_impl(ctx): 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, code_targets = own_code_targets, )) @@ -375,6 +383,9 @@ def _docs_bundle_impl(ctx): 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, code_targets = own_code_targets, )) @@ -400,6 +411,9 @@ def _docs_bundle_impl(ctx): 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, code_targets = own_code_targets, )) diff --git a/bzl/mount_rules.bzl b/bzl/mount_rules.bzl index 1ae929ac7..ea0c01e03 100644 --- a/bzl/mount_rules.bzl +++ b/bzl/mount_rules.bzl @@ -16,25 +16,6 @@ Conversion of documentation bundles from Bazel into mount metadata. load("@score_docs_as_code//:bzl/bundle_rules.bzl", "DocsBundleInfo") -def _sorted_code_targets(targets): - """Return target metadata in deterministic label/type order. - - Bundle declarations are ordered for source composition, but metadata - consumers should not observe incidental declaration ordering. Encoding the - pair before sorting keeps each label associated with its rule type. - """ - encoded = sorted([ - target.label + "\n" + target.type - for target in targets - ]) - return [ - { - "label": value.split("\n")[0], - "type": value.split("\n", 1)[1], - } - for value in encoded - ] - def _composition_manifest_impl(ctx): """Generate the bundle composition manifest.""" bundle_info = ctx.attr.bundle[DocsBundleInfo] @@ -60,7 +41,15 @@ def _composition_manifest_impl(ctx): "bundle": { "label": entry.bundle_label, "name": entry.bundle_name, - "code_targets": _sorted_code_targets(entry.code_targets), + # 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 diff --git a/deprecations.md b/deprecations.md deleted file mode 100644 index cd67e8d20..000000000 --- a/deprecations.md +++ /dev/null @@ -1,36 +0,0 @@ - - -# Deprecated behavior to remove - -This file tracks compatibility paths that should be removed after callers have -migrated to the current bundle model. - -## Generated documentation declared through `data` - -`docs_bundle(data = [...])` is still accepted for generated documentation -files, and `score_mounts` turns those files into runtime mounts. Generated -documentation should be declared with `docs_bundle(srcs = [...])` instead. - -Once callers have migrated, remove the data-to-mount path from -`src/extensions/score_mounts/__init__.py` and the corresponding regression -fixture for `legacy_data_bundle`. - -## External Needs passed through `docs(data = [...])` - -Passing `needs_json` targets through `docs(data = [...])` is the old external -Needs interface. Callers should use `docs(external_needs = [...])` instead. - -Once module repositories have migrated, remove the `needs_json` detection and -filtering in `docs.bzl`, together with the compatibility coverage for the old -`data` form. diff --git a/src/extensions/docs/mounts_internals.rst b/src/extensions/docs/mounts_internals.rst index 5695f74b9..72daa9638 100644 --- a/src/extensions/docs/mounts_internals.rst +++ b/src/extensions/docs/mounts_internals.rst @@ -39,8 +39,9 @@ Each manifest entry contains: * ``entry_doc`` — the canonical entry document declared by the source bundle. * ``external`` — whether the directory belongs to another Bazel module; * ``bundle`` — the declaring bundle's Bazel label, name, and direct targets; -* ``root_bundle`` — whether this physical entry belongs to the composition's - root bundle. +* ``root_bundle`` — whether this physical entry belongs to the root bundle of + 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. diff --git a/src/extensions/score_mounts/__init__.py b/src/extensions/score_mounts/__init__.py index abf49c19d..1f3a14c77 100644 --- a/src/extensions/score_mounts/__init__.py +++ b/src/extensions/score_mounts/__init__.py @@ -78,9 +78,8 @@ def _resolve_data_mounts( data_mounts: dict[str, MountSpec] = {} for spec in manifest.mounts: # TODO: Remove this data-mount path, including the root-bundle - # distinction, once callers have migrated generated documentation from - # ``docs_bundle(data = [...])`` to ``docs_bundle(srcs = [...])``. See - # ``deprecations.md`` in the repository root. + # distinction, once callers migrate generated documentation from + # ``docs_bundle(data = [...])`` to ``docs_bundle(srcs = [...])``. # Data belonging to the primary bundle is already part of the Sphinx # action inputs. Only rebased child data has a documentation-tree mount. if spec.root_bundle: diff --git a/src/extensions/score_mounts/_resolver.py b/src/extensions/score_mounts/_resolver.py index 692c7af55..1b1e130bb 100644 --- a/src/extensions/score_mounts/_resolver.py +++ b/src/extensions/score_mounts/_resolver.py @@ -44,6 +44,11 @@ class BazelTarget: # kind is not encoded in the label itself. type: str + @classmethod + def from_manifest_entry(cls, entry: dict[str, str]) -> BazelTarget: + """Create a target from the producer-owned manifest representation.""" + return cls(label=entry["label"], type=entry["type"]) + @dataclass(frozen=True) class BundleMetadata: @@ -66,6 +71,19 @@ class BundleMetadata: # Targets inherited from dependencies or nested bundles do not belong here. code_targets: tuple[BazelTarget, ...] = () + @classmethod + def from_manifest_entry(cls, entry: dict[str, object]) -> BundleMetadata: + """Create bundle metadata from one producer-owned manifest entry.""" + bundle = cast("dict[str, object]", entry["bundle"]) + targets = cast("list[dict[str, str]]", bundle["code_targets"]) + return cls( + label=cast("str", bundle["label"]), + name=cast("str", bundle["name"]), + code_targets=tuple( + BazelTarget.from_manifest_entry(target) for target in targets + ), + ) + @dataclass(frozen=True) class MountSpec: @@ -100,45 +118,31 @@ class MountSpec: # Logical owner and direct-target metadata for this physical mount entry. bundle: BundleMetadata = field(default_factory=BundleMetadata) + @classmethod + def from_manifest_entry(cls, entry: dict[str, object]) -> MountSpec: + """Create one mount spec from the producer-owned manifest entry.""" + attach_to = cast("str", entry["attach_to"]) + return cls( + src_root=cast("str", entry["src_root"]), + runtime_path=cast("str", entry["runtime_path"]), + mount_at=cast("str", entry["mount_at"]), + attach_to=attach_to or None, + entry_doc=cast("str", entry["entry_doc"]), + external=cast("bool", entry["external"]), + repository=cast("str", entry["repository"]), + generated=cast("bool", entry["generated"]), + files=cast("list[str]", entry.get("files", [])), + data=cast("list[str]", entry["data"]), + root_bundle=cast("bool", entry["root_bundle"]), + bundle=BundleMetadata.from_manifest_entry(entry), + ) + @dataclass(frozen=True) class MountsManifest: mounts: list[MountSpec] -def _read_bundle_metadata(entry: dict[str, object]) -> BundleMetadata: - """Map the producer-owned bundle fields to their runtime dataclass.""" - bundle = cast("dict[str, object]", entry["bundle"]) - targets = cast("list[dict[str, str]]", bundle["code_targets"]) - return BundleMetadata( - label=cast("str", bundle["label"]), - name=cast("str", bundle["name"]), - code_targets=tuple( - BazelTarget(label=target["label"], type=target["type"]) - for target in targets - ), - ) - - -def _read_mount_spec(entry: dict[str, object]) -> MountSpec: - """Map one producer-owned manifest entry to a runtime mount spec.""" - attach_to = cast("str", entry["attach_to"]) - return MountSpec( - src_root=cast("str", entry["src_root"]), - runtime_path=cast("str", entry["runtime_path"]), - mount_at=cast("str", entry["mount_at"]), - attach_to=attach_to or None, - entry_doc=cast("str", entry["entry_doc"]), - external=cast("bool", entry["external"]), - repository=cast("str", entry["repository"]), - generated=cast("bool", entry["generated"]), - files=cast("list[str]", entry.get("files", [])), - data=cast("list[str]", entry["data"]), - root_bundle=cast("bool", entry["root_bundle"]), - bundle=_read_bundle_metadata(entry), - ) - - def load_mounts_manifest(manifest_path: str | Path) -> MountsManifest: """Read the manifest JSON at ``manifest_path`` (an already-resolved path). @@ -151,7 +155,9 @@ def load_mounts_manifest(manifest_path: str | Path) -> MountsManifest: json.loads(manifest_path.read_text(encoding="utf-8")), ) entries = cast("list[dict[str, object]]", data["mounts"]) - return MountsManifest(mounts=[_read_mount_spec(entry) for entry in entries]) + return MountsManifest( + mounts=[MountSpec.from_manifest_entry(entry) for entry in entries] + ) def resolve_walk_dir(