diff --git a/BUILD b/BUILD index d88c5da69..5fefff6e6 100644 --- a/BUILD +++ b/BUILD @@ -20,6 +20,9 @@ docs( data = [ "@score_process//:needs_json", ], + extra_docs = [ + "//src:src_docs", + ], scan_code = [ "//scripts_bazel:sources", "//src:all_sources", diff --git a/docs.bzl b/docs.bzl index 699c2370f..8213b5cc7 100644 --- a/docs.bzl +++ b/docs.bzl @@ -44,6 +44,59 @@ Easy streamlined way for S-CORE docs-as-code. load("@aspect_rules_py//py:defs.bzl", "py_binary", "py_venv") load("@docs_as_code_hub_env//:requirements.bzl", "all_requirements") load("@rules_python//sphinxdocs:sphinx.bzl", "sphinx_build_binary", "sphinx_docs") +load("@rules_python//sphinxdocs:sphinx_docs_library.bzl", "sphinx_docs_library") +load("@rules_python//sphinxdocs/private:sphinx_docs_library_info.bzl", "SphinxDocsLibraryInfo") + +def _docs_source_tree_impl(ctx): + """Materializes a sphinx_docs_library into a single directory for incremental builds.""" + output_dir = ctx.actions.declare_directory(ctx.label.name) + + all_inputs = [] + pairs = [] # list of (src_exec_path, dest_rel_path) + + # conf.py at its natural short_path position (e.g. "docs/conf.py") + config = ctx.file.config + all_inputs.append(config) + pairs.append((config.path, config.short_path)) + + for t in ctx.attr.lib: + info = t[SphinxDocsLibraryInfo] + for entry in info.transitive.to_list(): + for f in entry.files: + dest_rel = entry.prefix + f.short_path.removeprefix(entry.strip_prefix) + all_inputs.append(f) + pairs.append((f.path, dest_rel)) + + cmds = ["set -euo pipefail"] + for src, dest_rel in pairs: + parent = dest_rel.rsplit("/", 1)[0] if "/" in dest_rel else "" + if parent: + cmds.append("mkdir -p '{}/{}'".format(output_dir.path, parent)) + cmds.append("ln '{}' '{}/{}'".format(src, output_dir.path, dest_rel)) + + ctx.actions.run_shell( + inputs = all_inputs, + outputs = [output_dir], + command = "\n".join(cmds), + progress_message = "Materializing docs source tree for %{label}", + ) + + return [DefaultInfo(files = depset([output_dir]))] + +_docs_source_tree = rule( + implementation = _docs_source_tree_impl, + attrs = { + "lib": attr.label_list( + providers = [SphinxDocsLibraryInfo], + doc = "sphinx_docs_library targets whose files to merge into the source tree.", + ), + "config": attr.label( + allow_single_file = True, + mandatory = True, + doc = "The conf.py file to include in the source tree.", + ), + }, +) def _rewrite_needs_json_to_docs_sources(labels): """Replace '@repo//:needs_json' -> '@repo//:docs_sources' for every item.""" @@ -125,7 +178,9 @@ def _missing_requirements(deps): fail(msg) fail("This case should be unreachable?!") -def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = None): + + +def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = None, extra_docs = []): """Creates all targets related to documentation. By using this function, you'll get any and all updates for documentation targets in one place. @@ -135,6 +190,9 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = data: Additional data files to include in the documentation build. deps: Additional dependencies for the documentation build. scan_code: List of code targets to scan for source code links. + extra_docs: List of sphinx_docs_library targets to merge into the source tree. + Each target controls placement via its strip_prefix/prefix attributes. + See sphinx_docs_library in rules_python for details. """ call_path = native.package_name() @@ -163,7 +221,7 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = else: source_prefix = source_dir + "/" - native.filegroup( + sphinx_docs_library( name = "docs_sources", srcs = native.glob([ source_prefix + "**/*.png", @@ -179,6 +237,7 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = source_prefix + "**/*.csv", source_prefix + "**/*.inc", ], allow_empty = True), + deps = extra_docs, visibility = ["//visibility:public"], ) @@ -187,11 +246,19 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = data_with_docs_sources = _rewrite_needs_json_to_docs_sources(data) additional_combo_sourcelinks = _rewrite_needs_json_to_sourcelinks(data) _merge_sourcelinks(name = "merged_sourcelinks", sourcelinks = [":sourcelinks_json"] + additional_combo_sourcelinks, known_good = known_good) - docs_data = data + [":sourcelinks_json"] - combo_data = data_with_docs_sources + [":merged_sourcelinks"] + _docs_source_tree( + name = "docs_src_dir", + lib = [":docs_sources"], + config = ":" + source_prefix + "conf.py", + visibility = ["//visibility:private"], + ) + + docs_data = data + [":sourcelinks_json", ":docs_sources", ":docs_src_dir"] + combo_data = data_with_docs_sources + [":merged_sourcelinks", ":docs_sources"] docs_env = { "SOURCE_DIRECTORY": source_dir, + "DOCS_SOURCE_TREE": "$(rlocationpath :docs_src_dir)", "DATA": str(data), "SCORE_SOURCELINKS": "$(location :sourcelinks_json)", } @@ -253,24 +320,12 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = env = docs_env ) - docs_env["ACTION"] = "live_preview" - py_binary( - name = "live_preview", - tags = ["cli_help=Live preview documentation in the browser:\nbazel run //:live_preview"], - srcs = ["@score_docs_as_code//src:incremental.py"], - data = docs_data, - deps = deps, - env = docs_env - ) - - docs_sources_env["ACTION"] = "live_preview" py_binary( - name = "live_preview_combo_experimental", - tags = ["cli_help=Live preview full documentation with all dependencies in the browser:\nbazel run //:live_preview_combo_experimental"], - srcs = ["@score_docs_as_code//src:incremental.py"], - data = combo_data, + name = "gen_live_preview", + tags = ["cli_help=Generate ./live_preview script (run that script for a live preview):\nbazel run //:gen_live_preview"], + srcs = ["@score_docs_as_code//src:gen_live_preview.py"], deps = deps, - env = docs_sources_env + env = {"SOURCE_DIRECTORY": source_dir}, ) py_venv( @@ -284,8 +339,9 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = sphinx_docs( name = "needs_json", - srcs = [":docs_sources"], + srcs = [], config = ":" + source_prefix + "conf.py", + deps = [":docs_sources"], extra_opts = [ "-W", "--keep-going", diff --git a/docs/index.rst b/docs/index.rst index 53ce0df59..6c7072669 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -53,3 +53,4 @@ It provides documentation, requirements, and traceability. reference/index concepts/index internals/index + BLA/index diff --git a/src/BUILD b/src/BUILD index f12df2fa2..be7b4ca78 100644 --- a/src/BUILD +++ b/src/BUILD @@ -14,6 +14,7 @@ load("@aspect_rules_py//py:defs.bzl", "py_library") load("@rules_java//java:java_binary.bzl", "java_binary") load("@rules_python//python:pip.bzl", "compile_pip_requirements") +load("@rules_python//sphinxdocs:sphinx_docs_library.bzl", "sphinx_docs_library") # These are only exported because they're passed as files to the //docs.bzl # macros, and thus must be visible to other packages. They should only be @@ -26,6 +27,7 @@ exports_files( "incremental.py", "dummy.py", "generate_sourcelinks_cli.py", + "gen_live_preview.py", ], visibility = ["//visibility:public"], ) @@ -94,3 +96,11 @@ filegroup( ], visibility = ["//visibility:public"], ) + +sphinx_docs_library( + name = "src_docs", + srcs = glob(["docs/**/*.rst"]), + strip_prefix = "src/docs/", + prefix = "docs/BLA/", # must be under docs(source_dir=X) ! + visibility = ["//visibility:public"], +) diff --git a/src/docs/index.rst b/src/docs/index.rst new file mode 100644 index 000000000..ed3b5d1be --- /dev/null +++ b/src/docs/index.rst @@ -0,0 +1,19 @@ +.. + 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 Version 2.0 which is available at + https://www.apache.org/licenses/LICENSE-2.0 + + SPDX-License-Identifier: Apache-2.0 + +Source Extensions +================= + +.. toctree:: + + overview + requirements diff --git a/src/docs/overview.rst b/src/docs/overview.rst new file mode 100644 index 000000000..c35e2d88b --- /dev/null +++ b/src/docs/overview.rst @@ -0,0 +1,18 @@ +.. + 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 Version 2.0 which is available at + https://www.apache.org/licenses/LICENSE-2.0 + + SPDX-License-Identifier: Apache-2.0 + +Overview +======== + +The ``src/`` directory contains the Python extensions and Bazel helpers that +make up the docs-as-code toolchain. +Each extension is a standalone Sphinx plugin loaded via ``score_sphinx_bundle``. diff --git a/src/docs/requirements.rst b/src/docs/requirements.rst new file mode 100644 index 000000000..eca3cd896 --- /dev/null +++ b/src/docs/requirements.rst @@ -0,0 +1,25 @@ +.. + 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 Version 2.0 which is available at + https://www.apache.org/licenses/LICENSE-2.0 + + SPDX-License-Identifier: Apache-2.0 + +Requirements +============ + +.. tool_req:: Supports extra_docs in docs() macro + :id: tool_req__docs_extra_docs + :implemented: YES + :tags: Architecture + :version: 1 + + The ``docs()`` Bazel macro shall accept an ``extra_docs`` parameter + containing a list of ``sphinx_docs_library`` targets. + Their files shall be merged into the Sphinx source tree at the paths + determined by each library's ``strip_prefix`` and ``prefix`` attributes. diff --git a/src/extensions/score_metamodel/__init__.py b/src/extensions/score_metamodel/__init__.py index f0b90c8ee..e36714e92 100644 --- a/src/extensions/score_metamodel/__init__.py +++ b/src/extensions/score_metamodel/__init__.py @@ -114,7 +114,13 @@ def _run_checks(app: Sphinx, exception: Exception | None) -> None: ws_root = os.environ.get("BUILD_WORKSPACE_DIRECTORY", None) cwd_or_ws_root = Path(ws_root) if ws_root else Path.cwd() - prefix = str(Path(app.srcdir).relative_to(cwd_or_ws_root)) + try: + prefix = str(Path(app.srcdir).relative_to(cwd_or_ws_root)) + except ValueError: + # srcdir is outside the workspace (e.g. a Bazel runfiles tree). + # prefix is only used for log-message locations, which log.py already + # skips when RUNFILES_DIR is set, so any fallback is fine here. + prefix = str(app.srcdir) log = CheckLogger(logger, prefix) diff --git a/src/gen_live_preview.py b/src/gen_live_preview.py new file mode 100644 index 000000000..f4e6d56de --- /dev/null +++ b/src/gen_live_preview.py @@ -0,0 +1,101 @@ +# ******************************************************************************* +# 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 Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Generates a live_preview script in the workspace root. + +Usage: bazel run //:gen_live_preview +The generated ./live_preview script uses ibazel to keep :docs_src_dir fresh +and sphinx-autobuild to serve with browser auto-reload. +""" + +import os +import stat +import textwrap +from pathlib import Path + +if __name__ == "__main__": + workspace = os.environ.get("BUILD_WORKSPACE_DIRECTORY", "") + if not workspace: + raise SystemExit("Must be run via 'bazel run', not directly.") + + source_dir = os.environ["SOURCE_DIRECTORY"] + + script = textwrap.dedent(f"""\ + #!/usr/bin/env python3 + # Generated by: bazel run //:gen_live_preview + # Re-run that command to regenerate after changing docs() parameters. + + import os + import subprocess + import sys + import threading + from pathlib import Path + + WORKSPACE = Path({repr(workspace)}) + SOURCE_DIR = {repr(source_dir)} + SRC_TREE = WORKSPACE / "bazel-bin" / "docs_src_dir" / SOURCE_DIR + BUILD_DIR = WORKSPACE / "_build" + VENV = WORKSPACE / ".venv_docs" + PORT = int(os.environ.get("PORT", "8000")) + + + def _ibazel_loop() -> None: + \"\"\"Runs ibazel to keep :docs_src_dir up-to-date.\"\"\" + subprocess.run( + ["ibazel", "build", ":docs_src_dir"], + cwd=WORKSPACE, + ) + + + if __name__ == "__main__": + sphinx_autobuild = VENV / "bin" / "sphinx-autobuild" + python = VENV / "bin" / "python" + if not python.exists(): + sys.exit( + "Missing .venv_docs — run: bazel run //:ide_support" + ) + + # Initial build of the source tree + print("Building source tree (initial)...", flush=True) + subprocess.run( + ["bazel", "build", ":docs_src_dir"], cwd=WORKSPACE, check=True + ) + + # Start ibazel in background to keep source tree fresh + threading.Thread(target=_ibazel_loop, daemon=True).start() + + # sphinx-autobuild watches the materialized tree and handles reload + print(f"Serving at http://localhost:{{PORT}}") + print("Edit docs sources — the page will reload automatically.") + print("Ctrl-C to stop.") + cmd = [str(sphinx_autobuild)] if sphinx_autobuild.exists() else [ + str(python), "-m", "sphinx_autobuild", + ] + subprocess.run( + cmd + [ + str(SRC_TREE), + str(BUILD_DIR), + "--jobs", "auto", + "--define=skip_rescanning_via_source_code_linker=1", + f"--define=needscfg_outpath={{WORKSPACE / SOURCE_DIR / 'ubproject.toml'}}", + f"--port={{PORT}}", + ], + cwd=WORKSPACE, + ) + """) + + output = Path(workspace) / "live_preview" + output.write_text(script) + output.chmod(output.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + print(f"Generated {output}") + print("Run it with: ./live_preview") diff --git a/src/helper_lib/__init__.py b/src/helper_lib/__init__.py index a72fffb0b..bf6f8969a 100644 --- a/src/helper_lib/__init__.py +++ b/src/helper_lib/__init__.py @@ -30,7 +30,9 @@ def config_setdefault(config: Config, name: str, value: Any) -> None: # Sphinx has no public API for this check. We use ``_raw_config`` which is the # de-facto standard across the ecosystem (Furo, RTD-theme, etc.). If Sphinx # ever adds a public alternative, update this single function. - if name not in config._raw_config: # pyright: ignore [reportPrivateUsage] + # + # Also check config.overrides so -D flags to Sphinx can override extension defaults + if name not in config._raw_config and name not in config.overrides: # pyright: ignore [reportPrivateUsage] setattr(config, name, value) diff --git a/src/incremental.py b/src/incremental.py index fa1c5abc2..3ddd758ee 100644 --- a/src/incremental.py +++ b/src/incremental.py @@ -67,21 +67,23 @@ def get_env(name: str) -> str: logger.info("Waiting for client to connect on port: " + str(args.debug_port)) debugpy.wait_for_client() - workspace = os.getenv("BUILD_WORKSPACE_DIRECTORY") - if workspace: - workspace += "/" - else: - workspace = "" + workspace = Path(get_env("BUILD_WORKSPACE_DIRECTORY")) + source_directory = get_env("SOURCE_DIRECTORY") + sphinx_source = os.path.join( + get_env("RUNFILES_DIR"), get_env("DOCS_SOURCE_TREE"), source_directory + ) base_arguments = [ - workspace + get_env("SOURCE_DIRECTORY"), - workspace + "_build", + sphinx_source, + str(workspace / "_build"), "-W", # treat warning as errors "--keep-going", # do not abort after one error "-T", # show details in case of errors in extensions "--jobs", "auto", f"--define=external_needs_source={get_env('DATA')}", + # Source tree is read-only; redirect generated ubproject.toml to the workspace. + f"--define=needscfg_outpath={workspace / source_directory / 'ubproject.toml'}", ] # configure sphinx build with GitHub user and repo from CLI @@ -89,13 +91,13 @@ def get_env(name: str) -> str: base_arguments.append(f"-A=github_user={args.github_user}") base_arguments.append(f"-A=github_repo={args.github_repo}") base_arguments.append("-A=github_version=main") - base_arguments.append(f"-A=doc_path={get_env('SOURCE_DIRECTORY')}") + base_arguments.append(f"-A=doc_path={source_directory}") if os.getenv("KNOWN_GOOD_JSON"): base_arguments.append(f"--define=KNOWN_GOOD_JSON={get_env('KNOWN_GOOD_JSON')}") action = get_env("ACTION") if action == "live_preview": - Path(workspace + "/_build/score_source_code_linker_cache.json").unlink( + (workspace / "_build" / "score_source_code_linker_cache.json").unlink( missing_ok=True ) sphinx_autobuild_main(