diff --git a/MODULE.bazel b/MODULE.bazel index ea12ea58e..f1d4a8646 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -20,7 +20,6 @@ module(name = "score_docs_as_code") # ############################################################################### bazel_dep(name = "rules_python", version = "1.8.5") -bazel_dep(name = "sphinxdocs", version = "2.2.0") PYTHON_VERSION = "3.12" diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index f3645293f..64b916f9d 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -149,8 +149,6 @@ "https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c", "https://bcr.bazel.build/modules/rules_shell/0.4.1/MODULE.bazel": "00e501db01bbf4e3e1dd1595959092c2fadf2087b2852d3f553b5370f5633592", "https://bcr.bazel.build/modules/rules_shell/0.4.1/source.json": "4757bd277fe1567763991c4425b483477bb82e35e777a56fd846eb5cceda324a", - "https://bcr.bazel.build/modules/sphinxdocs/2.2.0/MODULE.bazel": "e046c573919d72605d62c352a08d9223a10aafef3a7cb70d0fe253ebdd97019e", - "https://bcr.bazel.build/modules/sphinxdocs/2.2.0/source.json": "b1da19a3d14a1dd8aa6a9ccaedc42bbe0313c8160a77ba5cca336cca1315298d", "https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8", "https://bcr.bazel.build/modules/stardoc/0.5.3/MODULE.bazel": "c7f6948dae6999bf0db32c1858ae345f112cacf98f174c7a8bb707e41b974f1c", "https://bcr.bazel.build/modules/stardoc/0.5.6/MODULE.bazel": "c43dabc564990eeab55e25ed61c07a1aadafe9ece96a4efabb3f8bf9063b71ef", @@ -290,7 +288,6 @@ "https://raw.githubusercontent.com/eclipse-score/bazel_registry/main/modules/score_devcontainer/1.11.0/source.json": "b379a2499cf06f16de65c2981665c3723afba9e858ed16b453f044882067f56f", "https://raw.githubusercontent.com/eclipse-score/bazel_registry/main/modules/score_process_description/2.1.2/MODULE.bazel": "1076b36d2d05ab1a18df0746f6a545869eec6927019d651af99d99ef056e2023", "https://raw.githubusercontent.com/eclipse-score/bazel_registry/main/modules/score_process_description/2.1.2/source.json": "f76099f076243e2641803971e2a95ed27f24756c9bdda5d10a808c664996f0fc", - "https://raw.githubusercontent.com/eclipse-score/bazel_registry/main/modules/sphinxdocs/2.2.0/MODULE.bazel": "not found", "https://raw.githubusercontent.com/eclipse-score/bazel_registry/main/modules/stardoc/0.5.1/MODULE.bazel": "not found", "https://raw.githubusercontent.com/eclipse-score/bazel_registry/main/modules/stardoc/0.5.3/MODULE.bazel": "not found", "https://raw.githubusercontent.com/eclipse-score/bazel_registry/main/modules/stardoc/0.5.6/MODULE.bazel": "not found", diff --git a/bzl/bundle_rules.bzl b/bzl/bundle_rules.bzl index 44c548838..00dd0c192 100644 --- a/bzl/bundle_rules.bzl +++ b/bzl/bundle_rules.bzl @@ -12,18 +12,8 @@ # ******************************************************************************* """Internal Bazel support for composing reusable documentation bundles.""" -# `docs_bundle` and `sphinx_docs_library` operate at a similar architectural level: -# both describe reusable, transitively composable collections of documentation sources -# that are later assembled into a Sphinx source tree. - -# However, their data models and responsibilities differ significantly. - -# `sphinx_docs_library` primarily models file placement. Each library contributes files -# together with a `strip_prefix` and a `prefix`, allowing the final Sphinx rule to map -# every source file to a new location in the generated source tree. - -# `docs_bundle` instead models documentation structure at the bundle level. In -# addition to the source files, it propagates information such as: +# `docs_bundle` models documentation structure at the bundle level. In addition to +# source files, it propagates information such as: # * where a bundle is mounted, * which document it is attached to, * which document acts # as its entry point, * which repository owns its sources, * whether it is an internal @@ -34,30 +24,13 @@ # is therefore not just a set of files with path transformations, but a structured # documentation component with composition semantics. -# Using `sphinx_docs_library` directly would not preserve the metadata required by this -# model. We would need a second provider alongside it and would still have to implement -# most of the bundle traversal, rebasing, validation, and composition logic ourselves. - -# Extending `sphinx_docs_library` is also not a good fit. Its provider represents -# individual file mappings, while our provider represents complete mounted bundles. Adding -# the required metadata would therefore not be a small extension of the existing -# abstraction; it would change its propagated unit and its semantics. It would also couple -# SCORE-specific composition rules to the generic `rules_sphinxdocs` implementation. - -# We therefore reimplement the relatively small overlapping part—transitive source -# collection—while keeping the richer bundle model explicit and independent. - -# The name `docs_bundle` reflects that relationship: it fills the same general role -# as `sphinx_docs_library`, but uses a SCORE-specific data model for composing structured -# documentation bundles. +# The provider is consumed directly by the repository-owned Needs action, while +# its entries are consumed by the mounts manifest action. Keeping both consumers +# on this provider ensures that source ownership and runtime placement agree. load("@score_docs_as_code//:bzl/basics.bzl", "join_path") -load( - "@sphinxdocs//sphinxdocs/private:sphinx_docs_library_info.bzl", - "SphinxDocsLibraryInfo", -) # Internal data passed between bundle targets and eventually consumed by an # adapter such as the Sphinx mounts manifest. Users configure bundles through @@ -513,61 +486,6 @@ def bundle_source_files(name, bundle, visibility = None, tags = None): ) return ":" + name -def _bundle_sphinx_source_files_impl(ctx): - """Expose direct bundle sources with a Sphinx-specific path mapping.""" - bundle = ctx.attr.bundle[DocsBundleInfo] - source_files = tuple(bundle.own_source_files.to_list()) - if not source_files: - fail("bundle %s has no direct documentation sources" % ctx.attr.bundle) - - # Directory-discovered sources already carry a stable bundle-relative root - # in the provider. Explicit source targets instead use the output path - # Bazel gives to Sphinx. Deriving that parent from ``short_path`` handles - # both workspace files and generated outputs (whose paths include - # ``bazel-out``) without making the macro guess a configuration-dependent - # output directory. - if bundle.own_source_is_explicit: - source_path = source_files[0].short_path - separator = source_path.rfind("/") - strip_prefix = source_path[:separator + 1] if separator >= 0 else "" - else: - strip_prefix = bundle.own_source_root - if strip_prefix and not strip_prefix.endswith("/"): - strip_prefix += "/" - - entry = struct( - strip_prefix = strip_prefix, - prefix = "", - files = source_files, - ) - return [ - DefaultInfo(files = depset(source_files)), - SphinxDocsLibraryInfo( - strip_prefix = strip_prefix, - prefix = "", - files = source_files, - transitive = depset(direct = [entry]), - ), - ] - -_bundle_sphinx_source_files = rule( - implementation = _bundle_sphinx_source_files_impl, - attrs = { - "bundle": attr.label(providers = [DocsBundleInfo]), - }, - doc = "Exposes direct bundle sources with paths rooted for a Sphinx build.", -) - -def bundle_sphinx_source_files(name, bundle, visibility = None, tags = None): - """Create a Sphinx library containing only a bundle's direct sources.""" - _bundle_sphinx_source_files( - name = name, - bundle = bundle, - visibility = visibility, - tags = tags, - ) - return ":" + name - def _external_docs_runfiles_impl(ctx): """Expose external documentation sources needed under ``bazel run``.""" bundle = ctx.attr.bundle[DocsBundleInfo] diff --git a/bzl/needs_rules.bzl b/bzl/needs_rules.bzl new file mode 100644 index 000000000..758bb1549 --- /dev/null +++ b/bzl/needs_rules.bzl @@ -0,0 +1,85 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Private Bazel action for building the Needs output in place. + +The SCORE mount extension exposes bundle inputs directly from the Bazel +execution root. This action therefore provides Sphinx with the project's +primary source directory and the mount manifest. Bundle targets remain +explicit action inputs; a manifest by itself does not make the files named by +that manifest available inside a sandbox. +""" + +load("@score_docs_as_code//:bzl/bundle_rules.bzl", "DocsBundleInfo") + +def _sphinx_docs_impl(ctx): + """Run Sphinx against the Bazel execution-root source tree.""" + output = ctx.actions.declare_directory(ctx.label.name + "/_build/needs") + + bundle = ctx.attr.bundle[DocsBundleInfo] + # The bundle owns both the direct inputs and their root. Nested sources + # are provided separately for score_mounts, so local exports retain their + # bundle ownership. Generated roots already use execution-root paths; + # external source roots use runfiles spelling and need this translation. + if not bundle.own_source_files.to_list(): + fail("Sphinx requires a bundle with direct documentation sources") + source_dir = bundle.own_source_root + if source_dir.startswith("../"): + source_dir = "external/" + source_dir[3:] + + # Expand file labels at analysis time, then encode the argument list as + # JSON so spaces, quotes and '=' in Sphinx options survive the environment + # transport unchanged. The launcher adds these after its default options. + # ``config`` is transported separately because the launcher derives + # Sphinx's ``-c`` directory from its path; it is not just another data file. + env = { + "ACTION": "build_needs_json", + "SOURCE_DIRECTORY": source_dir or ".", + "OUTPUT_DIRECTORY": output.path, + "SPHINX_CONFIG_FILE": ctx.file.config.path, + "DATA": "[]", + "SPHINX_EXTRA_OPTS": json.encode([ + ctx.expand_location(option, targets = ctx.attr.tools) + for option in ctx.attr.extra_opts + ]), + } + + # Data and mounted sources must be present at their execution-root paths. + # The executable separately carries these labels in its Python runfiles + # for extensions that locate external inventories through Bazel labels. + ctx.actions.run( + executable = ctx.executable.sphinx, + env = env, + inputs = depset( + [ctx.file.config] + ctx.files.data + ctx.files.tools, + transitive = [bundle.own_source_files], + ), + outputs = [output], + mnemonic = "ScoreNeedsBuild", + progress_message = "Building Needs inventory for %s" % ctx.label, + ) + + return [DefaultInfo(files = depset([output]))] + +sphinx_docs = rule( + implementation = _sphinx_docs_impl, + attrs = { + "config": attr.label(allow_single_file = True, mandatory = True), + "bundle": attr.label(providers = [DocsBundleInfo], mandatory = True), + "data": attr.label_list(allow_files = True), + "tools": attr.label_list(allow_files = True), + "extra_opts": attr.string_list(), + # The launcher runs on the build host and carries extension runfiles. + "sphinx": attr.label(cfg = "exec", executable = True, mandatory = True), + }, + doc = "Private action that builds Needs from declared execution-root inputs.", +) diff --git a/docs.bzl b/docs.bzl index e2a8d2d1f..c4e16e459 100644 --- a/docs.bzl +++ b/docs.bzl @@ -58,8 +58,6 @@ load( ) load( "@score_docs_as_code//:bzl/bundle_rules.bzl", - "bundle_source_files", - "bundle_sphinx_source_files", "create_bundle", "external_docs_runfiles", "generate_code_target_sourcelinks", @@ -69,12 +67,9 @@ load( "@score_docs_as_code//:bzl/mount_rules.bzl", "create_mounts_manifest", ) -load( - "@sphinxdocs//sphinxdocs:sphinx.bzl", - "sphinx_build_binary", - "sphinx_docs", -) -load("@sphinxdocs//sphinxdocs:sphinx_docs_library.bzl", "sphinx_docs_library") +# 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. +load("@score_docs_as_code//:bzl/needs_rules.bzl", "sphinx_docs") def _sphinx_define(name, value): """Return a Sphinx ``--define`` option when ``value`` is configured.""" @@ -90,12 +85,11 @@ def _needs_sphinx_extra_opts( score_source_code_linker_plain_links, mounts_manifest, score_metamodel_yaml): - """Return the common diagnostics and configuration for a Needs build.""" + """Return per-target Sphinx configuration defines for a Needs build.""" + # The launcher supplies diagnostics shared by every builder. Keep only + # target-specific defines here so the action does not receive duplicate + # ``-W``, ``--keep-going``, and ``-T`` options after JSON transport. return [ - "-W", - "--keep-going", - "-T", - ] + [ option for name, value in [ ("master_doc", master_doc), @@ -112,8 +106,9 @@ def _needs_sphinx_extra_opts( def _declare_sphinx_build_binary(name, data, deps): """Declare the private Sphinx executable used by one Needs target.""" sphinx_build_name = _bundle_internal_target(name, "sphinx_build") - sphinx_build_binary( + py_binary( name = sphinx_build_name, + srcs = [Label("//src/docs_cli:cli.py")], data = data, deps = deps, # The Sphinx executable is an implementation detail of the Needs @@ -128,9 +123,7 @@ def _needs_sphinx_docs( name, config, sphinx_build_deps, - srcs = [], - deps = [], - strip_prefix = "", + bundle, master_doc = None, external_needs_source = None, score_bundle_needs_export = None, @@ -144,16 +137,17 @@ def _needs_sphinx_docs( """Declare a bundle Needs export with the repository-wide Sphinx policy.""" sphinx_build = _declare_sphinx_build_binary( name, - sphinx_build_data, + sphinx_build_data + [tool for tool in tools if tool not in sphinx_build_data], sphinx_build_deps, ) sphinx_docs( name = name, - srcs = srcs, - deps = deps, + bundle = bundle, + # Keep conf.py separate from supporting data: the action derives + # Sphinx's ``-c`` directory from this file's path, while ``data`` is + # made available as ordinary runtime input. config = config, - formats = ["needs"], - strip_prefix = strip_prefix, + data = sphinx_build_data, extra_opts = _needs_sphinx_extra_opts( master_doc, external_needs_source, @@ -166,9 +160,6 @@ def _needs_sphinx_docs( sphinx = sphinx_build, tools = tools, visibility = visibility, - # Persistent workers can retain stale symlinks after dependency - # version changes, corrupting the Bazel cache for Needs exports. - allow_persistent_workers = False, tags = ["manual"], ) @@ -224,19 +215,6 @@ def _is_needs_json_target(label): """ return str(label).rsplit(":", 1)[-1] == "needs_json" -def _bundle_short_path_prefix(path): - """Return the short-path prefix for a file below ``path``.""" - if path == ".": - path = "" - prefix = join_path(native.package_name(), path) - repository = native.repo_name() - if repository: - # External repository files use ``..//`` in short_path. - prefix = join_path("../" + repository, prefix) - if prefix: - prefix += "/" - return prefix - def _declare_docs_bundle( name, source_dir = None, @@ -346,9 +324,9 @@ def _declare_bundle_local_needs( srcs, entry_doc, sourcelinks_json, + data = [], visibility = None, config = None, - config_strip_prefix = "", deps = []): """Create a standalone Needs export for a bundle's direct sources. @@ -359,21 +337,9 @@ def _declare_bundle_local_needs( if not source_dir_globbed and not srcs: return - # ``bundle_sphinx_source_files`` is important here: using the complete bundle - # would also feed nested child sources into this Sphinx invocation and - # export their Needs under the parent's local target. Ownership stays - # one-way: every source-bearing bundle exports only its own sources. - own_sources = bundle_sphinx_source_files( - name = _bundle_internal_target(name, "needs_sources"), - bundle = ":" + name, - visibility = visibility, - tags = ["manual"], - ) - if config == None: - # Sphinx expects conf.py below the source root. Generate a private - # config for each standalone export so a source-only bundle remains - # independent of the project that composes it. + # Sphinx receives this private conf.py through its -c option, so the + # standalone export stays independent of the composing project. needs_conf = _bundle_internal_target(name, "needs_conf") config_output_path = join_path(needs_conf, "conf.py") _generated_conf( @@ -385,16 +351,11 @@ def _declare_bundle_local_needs( tags = ["manual"], ) needs_config = ":" + needs_conf - config_strip_prefix = _bundle_short_path_prefix(needs_conf) else: # The root bundle belongs to docs(), so its local export must retain # the same project configuration as the normal project-wide export. needs_config = config - # The source files are declared with their workspace-relative paths, - # while sphinxdocs expects the prefix to remove from those paths before - # placing them below the temporary Sphinx source root. - # # Build the own export from this bundle's sources only. References to # Needs owned by another bundle are intentionally unsupported until # cross-bundle imports are added. @@ -403,15 +364,15 @@ def _declare_bundle_local_needs( needs_local = _bundle_internal_target(name, "needs_local") _needs_sphinx_docs( name = needs_local, - deps = [own_sources], + bundle = ":" + name, config = needs_config, sphinx_build_deps = sphinx_build_deps, + sphinx_build_data = data, master_doc = entry_doc, external_needs_source = "[]", score_bundle_needs_export = "1", score_sourcelinks_json = "$(location " + str(sourcelinks_json) + ")" if sourcelinks_json else None, score_source_code_linker_plain_links = "1", - strip_prefix = config_strip_prefix, tools = [sourcelinks_json] if sourcelinks_json else [], visibility = visibility, ) @@ -450,6 +411,7 @@ def docs_bundle( srcs = srcs, entry_doc = entry_doc, sourcelinks_json = bundle.sourcelinks_json, + data = data, visibility = visibility, ) @@ -577,9 +539,8 @@ def docs( if not project or not project_url: fail("docs(): no " + config_file_path + " found; provide both project and project_url to docs().") - # Generate the config at the source-root location expected by - # sphinx_docs: that rule treats the config file's directory as the - # Sphinx source directory. + # Keep the generated config at the same package-relative location + # as a checked-in conf.py so the interactive launcher can find it. _generated_conf( name = "_docs_generated_config", project = project, @@ -594,24 +555,6 @@ def docs( # list-valued attributes such as ``data`` and ``tools``. metamodel_label = [metamodel] if metamodel else [] - root_bundle_data_for_sphinx = [] - if data: - # TODO: Replace this adapter once the mounts manifest can preserve a - # data file's destination path below the root documentation tree. - # The bundle provider records ownership and propagation. Sphinx uses - # its standard library provider to map the same files into the - # sandboxed source tree while preserving workspace-relative paths for - # literalinclude. - sphinx_docs_library( - name = "_root_bundle_data_for_sphinx", - srcs = data, - # rules_sphinxdocs treats an empty strip_prefix as the package - # path. An unmatched prefix preserves the workspace-relative paths - # used by this macro's direct documentation sources. - strip_prefix = "__root_bundle_data__", - ) - root_bundle_data_for_sphinx = [":_root_bundle_data_for_sphinx"] - mounts_manifest_label = [] if bundles: mounts_bundle = create_bundle( @@ -633,7 +576,6 @@ def docs( Label("//src/extensions/score_sphinx_bundle:score_sphinx_bundle"), ] - known_good_label = [known_good] if known_good else [] # The public bundle carries both the complete source tree and the @@ -654,16 +596,11 @@ def docs( srcs = [], entry_doc = "index", sourcelinks_json = root_bundle.sourcelinks_json, + data = data, visibility = ["//visibility:public"], config = sphinx_config, - config_strip_prefix = _bundle_short_path_prefix(source_dir), deps = deps, ) - sphinx_sources = bundle_source_files( - name = "_docs_sphinx_sources", - bundle = ":docs_bundle", - visibility = ["//visibility:private"], - ) merge_bundle_sourcelinks( name = "sourcelinks_json", bundle = ":docs_bundle", @@ -763,19 +700,15 @@ def docs( _needs_sphinx_docs( name = "needs_json", - # Nested bundle sources are mounted by score_mounts. Passing the - # complete bundle as srcs would also expose those files as raw Sphinx - # sources and make every nested need appear twice. - srcs = [sphinx_sources], - deps = root_bundle_data_for_sphinx, + bundle = ":docs_bundle", config = sphinx_config, sphinx_build_deps = deps, sphinx_build_data = data + external_needs + metamodel_label + [":docs_bundle"], external_needs_source = str(data + external_needs), score_sourcelinks_json = "$(location :sourcelinks_json)", score_source_code_linker_plain_links = "1", - # ``sphinx_docs`` is a sandboxed build action, so it needs the - # action-input path rather than the runfiles-relative spelling. + # The build action runs in a sandbox, so it needs the action-input path + # rather than the runfiles-relative spelling. mounts_manifest = "$(location :_mounts_manifest)" if bundles else None, score_metamodel_yaml = "$(location " + str(metamodel) + ")" if metamodel else None, tools = external_needs + metamodel_label + [":sourcelinks_json", ":docs_bundle"] + mounts_manifest_label, diff --git a/src/docs_cli/cli.py b/src/docs_cli/cli.py index 49d895d88..1e9fb18d1 100644 --- a/src/docs_cli/cli.py +++ b/src/docs_cli/cli.py @@ -146,12 +146,11 @@ def add_watch_dir(path: Path) -> None: def sphinx_arguments(ws_root: Path, package_dir: Path, build_dir: Path) -> list[str]: """Resolve package sources and Bazel-provided configuration for every builder.""" + is_bazel_build = os.environ.get("ACTION") == "build_needs_json" source_directory = get_env("SOURCE_DIRECTORY") base_arguments = [ str(package_dir / source_directory), str(build_dir), - "--warning-file", - str(build_dir / "warnings.txt"), "-W", # treat warning as errors "--keep-going", # do not abort after one error "-T", # show details in case of errors in extensions @@ -166,20 +165,46 @@ def sphinx_arguments(ws_root: Path, package_dir: Path, build_dir: Path) -> list[ f"--define=mounts_manifest={os.environ.get('MOUNTS_MANIFEST', '')}", ] + if is_bazel_build: + # The Bazel action declares ``build_dir`` as its output tree, and that + # tree must contain only the Needs inventory consumed by downstream + # actions. Keep Sphinx's internal doctree cache beside it instead of + # mixing action state into the declared output. + base_arguments.extend(["-d", str(build_dir) + "_doctrees"]) + + # The sandboxed Needs rule transports options as JSON so spaces, quotes and + # equals signs survive the environment boundary. Append them last so an + # action-specific value can override one of the shared defaults above. + base_arguments.extend(json.loads(os.environ.get("SPHINX_EXTRA_OPTS", "[]"))) + else: + # Interactive builds keep warnings in the workspace so developers can + # inspect them after a failed build. A Bazel action reports failure + # through its exit code and must leave its declared output tree free of + # this diagnostic side file. + base_arguments.extend(["--warning-file", str(build_dir / "warnings.txt")]) + generated_config = os.environ.get("SPHINX_CONFIG_FILE", "") if generated_config: - # Under ``bazel run`` this is a runfiles-relative path. Sphinx wants - # the directory containing a file literally named ``conf.py``. + # The action receives ctx.file.config.path, which is interpreted from + # the action's execution-root working directory. Resolve it locally + # instead of using runfiles lookup; interactive targets receive a + # runfiles-relative path and need that lookup before Sphinx gets the + # containing directory. config_file = Path(generated_config) - if not config_file.is_absolute(): + if is_bazel_build: + config_file = config_file.absolute() + elif not config_file.is_absolute(): config_file = get_runfiles_dir() / config_file base_arguments.extend(["-c", str(config_file.parent)]) metamodel_yaml = os.environ.get("SCORE_METAMODEL_YAML", "") if metamodel_yaml: - # ``docs`` passes a runfiles-relative path under ``bazel run``. Keep - # the workspace-relative fallback for direct invocations. - if not os.path.isabs(metamodel_yaml): + # Under ``bazel run``, this environment variable is runfiles-relative + # and must be resolved through RUNFILES_DIR. A sandboxed Needs action + # instead expands the metamodel label to an execution-root path in + # SPHINX_EXTRA_OPTS; applying runfiles lookup there would escape the + # action's declared inputs. + if not is_bazel_build and not os.path.isabs(metamodel_yaml): runfiles_dir = os.environ.get("RUNFILES_DIR", "") metamodel_yaml = str( (Path(runfiles_dir) / metamodel_yaml) @@ -259,23 +284,29 @@ def main(argv: list[str] | None = None) -> int: logger.info("Waiting for client to connect on port: " + str(args.debug_port)) debugpy.wait_for_client() + action = get_env("ACTION") + is_bazel_build = action == "build_needs_json" ws_root = Path(os.getenv("BUILD_WORKSPACE_DIRECTORY", "")) # Docs source and output are resolved relative to the package where docs() # was called; an empty PACKAGE_DIR denotes the workspace root. package_dir = ws_root / os.environ.get("PACKAGE_DIR", "") - build_dir = package_dir / "_build" + if is_bazel_build: + # Bazel owns the action's paths; never use the caller's workspace cache. + package_dir = Path.cwd() + build_dir = Path(get_env("OUTPUT_DIRECTORY")).absolute() + sentinel_files = [ ws_root / "MODULE.bazel", ws_root / "MODULE.bazel.lock", package_dir / "BUILD", ] - clean_builddir_if_stale(build_dir, sentinel_files) + if not is_bazel_build: + clean_builddir_if_stale(build_dir, sentinel_files) warning_file = build_dir / "warnings.txt" base_arguments = sphinx_arguments(ws_root, package_dir, build_dir) - action = get_env("ACTION") if action == "live_preview": sphinx_autobuild_main( base_arguments @@ -290,7 +321,7 @@ def main(argv: list[str] | None = None) -> int: if action == "incremental": builder = "html" - elif action == "check": + elif action in ("check", "build_needs_json"): builder = "needs" elif action == "linkcheck": builder = "linkcheck" @@ -304,6 +335,11 @@ def main(argv: list[str] | None = None) -> int: end_time = time.perf_counter() print(f"docs ({action}) finished in {end_time - start_time:.1f} seconds") + if is_bazel_build: + # The declared output is owned by the action. Do not record an + # interactive cache hash or write a warning marker into the workspace. + return exit_code + if exit_code == 0: update_module_hash(build_dir, sentinel_files) else: @@ -315,4 +351,9 @@ def main(argv: list[str] | None = None) -> int: if __name__ == "__main__": + # Extensions need stable runfiles paths even when Sphinx changes directory. + for variable in ("RUNFILES_DIR", "JAVA_RUNFILES"): + if os.environ.get(variable): + os.environ[variable] = str(Path(os.environ[variable]).absolute()) + sys.exit(main()) diff --git a/src/docs_cli/main_test.py b/src/docs_cli/main_test.py index d06a91b3d..04ca0b6fd 100644 --- a/src/docs_cli/main_test.py +++ b/src/docs_cli/main_test.py @@ -61,6 +61,7 @@ def workspace(fs: FFS, monkeypatch: pytest.MonkeyPatch) -> Path: ("incremental", "html"), ("check", "needs"), ("linkcheck", "linkcheck"), + ("build_needs_json", "needs"), ], ) def test_build_action_selects_sphinx_builder( @@ -74,9 +75,15 @@ def test_build_action_selects_sphinx_builder( # Arrange monkeypatch.setenv("ACTION", action) build_dir = workspace / "component/_build" + if action == "build_needs_json": + # The sandboxed action uses its declared output, not the package cache. + monkeypatch.chdir(workspace) + monkeypatch.setenv("OUTPUT_DIRECTORY", "outputs/needs") + build_dir = workspace / "outputs/needs" noop_sphinx = Mock(return_value=0) + update_hash = Mock() monkeypatch.setattr(docs_cli, "sphinx_main", noop_sphinx) - monkeypatch.setattr(docs_cli, "update_module_hash", Mock()) + monkeypatch.setattr(docs_cli, "update_module_hash", update_hash) # Act exit_code = docs_cli.main([]) @@ -87,7 +94,11 @@ def test_build_action_selects_sphinx_builder( noop_sphinx.assert_called_once() arguments = noop_sphinx.call_args.args[0] # The source and output paths are derived from the Bazel package directory. - assert arguments[:2] == [str(workspace / "component/docs"), str(build_dir)] + if action == "build_needs_json": + assert arguments[:2] == [str(workspace / "docs"), str(build_dir)] + update_hash.assert_not_called() + else: + assert arguments[:2] == [str(workspace / "component/docs"), str(build_dir)] # The action selects the builder exposed by its public Bazel target. assert arguments[-2:] == ["-b", builder]