From 790b188623825a81866f9db3d7dbada2cdade7de Mon Sep 17 00:00:00 2001 From: Andreas Zwinkau Date: Tue, 28 Apr 2026 10:22:36 +0200 Subject: [PATCH 1/8] feat: basics working --- BUILD | 3 + docs.bzl | 106 +++++++++++++++++---- src/BUILD | 10 ++ src/docs/index.rst | 21 ++++ src/docs/overview.rst | 20 ++++ src/docs/requirements.rst | 27 ++++++ src/extensions/score_metamodel/__init__.py | 8 +- src/incremental.py | 11 ++- 8 files changed, 185 insertions(+), 21 deletions(-) create mode 100644 src/docs/index.rst create mode 100644 src/docs/overview.rst create mode 100644 src/docs/requirements.rst 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..94763e25b 100644 --- a/docs.bzl +++ b/docs.bzl @@ -44,6 +44,7 @@ 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/private:sphinx_docs_library_info.bzl", "SphinxDocsLibraryInfo") def _rewrite_needs_json_to_docs_sources(labels): """Replace '@repo//:needs_json' -> '@repo//:docs_sources' for every item.""" @@ -125,7 +126,66 @@ 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_src_dir_impl(ctx): + """Unified rule: materialises a composed source tree AND provides SphinxDocsLibraryInfo. + + Output symlinks land at / so they are accessible in + py_binary runfiles under _main//. incremental.py + points Sphinx at that subtree via COMPOSED_SOURCE_CONF. + + SphinxDocsLibraryInfo is also returned so :needs_json can use this target + as a dep directly, without a separate sphinx_docs_library wrapper. + """ + strip_prefix = ctx.attr.strip_prefix + prefix = ctx.attr.prefix + outputs = [] + + # Own srcs (strip_prefix/prefix applied) + for f in ctx.files.srcs: + path = f.short_path + if strip_prefix and path.startswith(strip_prefix): + path = path[len(strip_prefix):] + out = ctx.actions.declare_file(ctx.label.name + "/" + prefix + path) + ctx.actions.symlink(output = out, target_file = f) + outputs.append(out) + + # Deps (transitive sphinx_docs_library entries) + for dep in ctx.attr.deps: + for entry in dep[SphinxDocsLibraryInfo].transitive.to_list(): + for f in entry.files: + path = f.short_path + if entry.strip_prefix and path.startswith(entry.strip_prefix): + path = path[len(entry.strip_prefix):] + out = ctx.actions.declare_file(ctx.label.name + "/" + entry.prefix + path) + ctx.actions.symlink(output = out, target_file = f) + outputs.append(out) + + # ctx.files.srcs is frozen (from the rule framework) so it is safe as a depset element. + own_entry = struct(strip_prefix = strip_prefix, prefix = prefix, files = ctx.files.srcs) + return [ + SphinxDocsLibraryInfo( + strip_prefix = strip_prefix, + prefix = prefix, + files = depset(ctx.files.srcs), + transitive = depset( + direct = [own_entry], + transitive = [dep[SphinxDocsLibraryInfo].transitive for dep in ctx.attr.deps], + ), + ), + DefaultInfo(files = depset(outputs)), + ] + +_docs_src_dir_rule = rule( + implementation = _docs_src_dir_impl, + attrs = { + "srcs": attr.label_list(allow_files = True), + "strip_prefix": attr.string(), + "prefix": attr.string(), + "deps": attr.label_list(providers = [SphinxDocsLibraryInfo]), + }, +) + +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 +195,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() @@ -182,23 +245,39 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = visibility = ["//visibility:public"], ) + # conf.py is not captured by the docs_sources glob (*.py excluded), so we + # pass it explicitly alongside the glob results. + conf_py = source_prefix + "conf.py" + + _docs_src_dir_rule( + name = "docs_src_dir", + srcs = [":docs_sources", conf_py], + strip_prefix = source_prefix, + prefix = "", + deps = extra_docs, + visibility = ["//visibility:public"], + ) + _sourcelinks_json(name = "sourcelinks_json", srcs = scan_code) 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_data = data + [":sourcelinks_json", ":docs_src_dir"] + combo_data = data_with_docs_sources + [":merged_sourcelinks", ":docs_src_dir"] docs_env = { "SOURCE_DIRECTORY": source_dir, "DATA": str(data), "SCORE_SOURCELINKS": "$(location :sourcelinks_json)", + # incremental.py always resolves the composed source tree from runfiles. + "COMPOSED_SOURCE_CONF": "_main/docs_src_dir/conf.py", } docs_sources_env = { "SOURCE_DIRECTORY": source_dir, "DATA": str(data_with_docs_sources), "SCORE_SOURCELINKS": "$(location :merged_sourcelinks)", + "COMPOSED_SOURCE_CONF": "_main/docs_src_dir/conf.py", } if known_good: docs_env["KNOWN_GOOD_JSON"] = "$(location "+ known_good + ")" @@ -253,24 +332,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, + 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_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, - deps = deps, - env = docs_sources_env + env = {"SOURCE_DIRECTORY": source_dir}, ) py_venv( @@ -286,6 +353,7 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = name = "needs_json", srcs = [":docs_sources"], config = ":" + source_prefix + "conf.py", + deps = [":docs_src_dir"], extra_opts = [ "-W", "--keep-going", diff --git a/src/BUILD b/src/BUILD index f12df2fa2..e59acf863 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/src_extensions/", + visibility = ["//visibility:public"], +) diff --git a/src/docs/index.rst b/src/docs/index.rst new file mode 100644 index 000000000..85ebf9015 --- /dev/null +++ b/src/docs/index.rst @@ -0,0 +1,21 @@ +:orphan: + +.. + 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..425d6edcd --- /dev/null +++ b/src/docs/overview.rst @@ -0,0 +1,20 @@ +:orphan: + +.. + 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..c07a0cd85 --- /dev/null +++ b/src/docs/requirements.rst @@ -0,0 +1,27 @@ +:orphan: + +.. + 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/incremental.py b/src/incremental.py index fa1c5abc2..87a85118a 100644 --- a/src/incremental.py +++ b/src/incremental.py @@ -73,8 +73,17 @@ def get_env(name: str) -> str: else: workspace = "" + source_dir = get_env("SOURCE_DIRECTORY") + from python.runfiles import Runfiles + + composed_conf = get_env("COMPOSED_SOURCE_CONF") + conf_path = Runfiles.Create().Rlocation(composed_conf) + if conf_path is None: + raise ValueError(f"Composed source not found in runfiles: {composed_conf}") + resolved_source_dir = str(Path(conf_path).parent) + base_arguments = [ - workspace + get_env("SOURCE_DIRECTORY"), + resolved_source_dir, workspace + "_build", "-W", # treat warning as errors "--keep-going", # do not abort after one error From 77cb27628a8707c493a21d6f345ba4a76c6e8b90 Mon Sep 17 00:00:00 2001 From: "Emrich Oliver (ETAS)" Date: Wed, 29 Apr 2026 09:47:06 +0200 Subject: [PATCH 2/8] Integrate sphinx_docs_library for improved documentation handling Co-authored-by: Copilot --- docs.bzl | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs.bzl b/docs.bzl index 94763e25b..25dcd6f5b 100644 --- a/docs.bzl +++ b/docs.bzl @@ -45,6 +45,7 @@ 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/private:sphinx_docs_library_info.bzl", "SphinxDocsLibraryInfo") +load("@rules_python//sphinxdocs:sphinx_docs_library.bzl", "sphinx_docs_library") def _rewrite_needs_json_to_docs_sources(labels): """Replace '@repo//:needs_json' -> '@repo//:docs_sources' for every item.""" @@ -226,7 +227,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", @@ -242,6 +243,9 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = source_prefix + "**/*.csv", source_prefix + "**/*.inc", ], allow_empty = True), + strip_prefix = source_prefix, + prefix = "", + deps = extra_docs, visibility = ["//visibility:public"], ) @@ -249,12 +253,12 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = # pass it explicitly alongside the glob results. conf_py = source_prefix + "conf.py" - _docs_src_dir_rule( + sphinx_docs_library( name = "docs_src_dir", - srcs = [":docs_sources", conf_py], + srcs = [conf_py], strip_prefix = source_prefix, prefix = "", - deps = extra_docs, + deps = extra_docs + [":docs_sources"], visibility = ["//visibility:public"], ) @@ -351,9 +355,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_src_dir"], + deps = [":docs_src_dir", ":docs_sources"], extra_opts = [ "-W", "--keep-going", From 2910478bdcd3cc9f3607cdd7583730a4c97a7c9d Mon Sep 17 00:00:00 2001 From: "Emrich Oliver (ETAS)" Date: Wed, 29 Apr 2026 09:57:58 +0200 Subject: [PATCH 3/8] Resetted incremental.py to commit hash 23be7867eb56498cd5c2c5de5ff5e54949411e87 --- src/incremental.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/incremental.py b/src/incremental.py index 87a85118a..fa1c5abc2 100644 --- a/src/incremental.py +++ b/src/incremental.py @@ -73,17 +73,8 @@ def get_env(name: str) -> str: else: workspace = "" - source_dir = get_env("SOURCE_DIRECTORY") - from python.runfiles import Runfiles - - composed_conf = get_env("COMPOSED_SOURCE_CONF") - conf_path = Runfiles.Create().Rlocation(composed_conf) - if conf_path is None: - raise ValueError(f"Composed source not found in runfiles: {composed_conf}") - resolved_source_dir = str(Path(conf_path).parent) - base_arguments = [ - resolved_source_dir, + workspace + get_env("SOURCE_DIRECTORY"), workspace + "_build", "-W", # treat warning as errors "--keep-going", # do not abort after one error From 7f0f04d75c17f5c3d02f1458864a981c3869455c Mon Sep 17 00:00:00 2001 From: Andreas Zwinkau Date: Wed, 29 Apr 2026 14:45:00 +0200 Subject: [PATCH 4/8] feat: needs_json builds --- docs.bzl | 82 ++------------------------------------- docs/index.rst | 1 + src/BUILD | 2 +- src/docs/index.rst | 2 - src/docs/overview.rst | 2 - src/docs/requirements.rst | 2 - 6 files changed, 5 insertions(+), 86 deletions(-) diff --git a/docs.bzl b/docs.bzl index 25dcd6f5b..bf09b6f0e 100644 --- a/docs.bzl +++ b/docs.bzl @@ -44,7 +44,6 @@ 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/private:sphinx_docs_library_info.bzl", "SphinxDocsLibraryInfo") load("@rules_python//sphinxdocs:sphinx_docs_library.bzl", "sphinx_docs_library") def _rewrite_needs_json_to_docs_sources(labels): @@ -127,64 +126,7 @@ def _missing_requirements(deps): fail(msg) fail("This case should be unreachable?!") -def _docs_src_dir_impl(ctx): - """Unified rule: materialises a composed source tree AND provides SphinxDocsLibraryInfo. - Output symlinks land at / so they are accessible in - py_binary runfiles under _main//. incremental.py - points Sphinx at that subtree via COMPOSED_SOURCE_CONF. - - SphinxDocsLibraryInfo is also returned so :needs_json can use this target - as a dep directly, without a separate sphinx_docs_library wrapper. - """ - strip_prefix = ctx.attr.strip_prefix - prefix = ctx.attr.prefix - outputs = [] - - # Own srcs (strip_prefix/prefix applied) - for f in ctx.files.srcs: - path = f.short_path - if strip_prefix and path.startswith(strip_prefix): - path = path[len(strip_prefix):] - out = ctx.actions.declare_file(ctx.label.name + "/" + prefix + path) - ctx.actions.symlink(output = out, target_file = f) - outputs.append(out) - - # Deps (transitive sphinx_docs_library entries) - for dep in ctx.attr.deps: - for entry in dep[SphinxDocsLibraryInfo].transitive.to_list(): - for f in entry.files: - path = f.short_path - if entry.strip_prefix and path.startswith(entry.strip_prefix): - path = path[len(entry.strip_prefix):] - out = ctx.actions.declare_file(ctx.label.name + "/" + entry.prefix + path) - ctx.actions.symlink(output = out, target_file = f) - outputs.append(out) - - # ctx.files.srcs is frozen (from the rule framework) so it is safe as a depset element. - own_entry = struct(strip_prefix = strip_prefix, prefix = prefix, files = ctx.files.srcs) - return [ - SphinxDocsLibraryInfo( - strip_prefix = strip_prefix, - prefix = prefix, - files = depset(ctx.files.srcs), - transitive = depset( - direct = [own_entry], - transitive = [dep[SphinxDocsLibraryInfo].transitive for dep in ctx.attr.deps], - ), - ), - DefaultInfo(files = depset(outputs)), - ] - -_docs_src_dir_rule = rule( - implementation = _docs_src_dir_impl, - attrs = { - "srcs": attr.label_list(allow_files = True), - "strip_prefix": attr.string(), - "prefix": attr.string(), - "deps": attr.label_list(providers = [SphinxDocsLibraryInfo]), - }, -) def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = None, extra_docs = []): """Creates all targets related to documentation. @@ -243,45 +185,27 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = source_prefix + "**/*.csv", source_prefix + "**/*.inc", ], allow_empty = True), - strip_prefix = source_prefix, - prefix = "", deps = extra_docs, visibility = ["//visibility:public"], ) - # conf.py is not captured by the docs_sources glob (*.py excluded), so we - # pass it explicitly alongside the glob results. - conf_py = source_prefix + "conf.py" - - sphinx_docs_library( - name = "docs_src_dir", - srcs = [conf_py], - strip_prefix = source_prefix, - prefix = "", - deps = extra_docs + [":docs_sources"], - visibility = ["//visibility:public"], - ) - _sourcelinks_json(name = "sourcelinks_json", srcs = scan_code) 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", ":docs_src_dir"] - combo_data = data_with_docs_sources + [":merged_sourcelinks", ":docs_src_dir"] + docs_data = data + [":sourcelinks_json", ":docs_sources"] + combo_data = data_with_docs_sources + [":merged_sourcelinks", ":docs_sources"] docs_env = { "SOURCE_DIRECTORY": source_dir, "DATA": str(data), "SCORE_SOURCELINKS": "$(location :sourcelinks_json)", - # incremental.py always resolves the composed source tree from runfiles. - "COMPOSED_SOURCE_CONF": "_main/docs_src_dir/conf.py", } docs_sources_env = { "SOURCE_DIRECTORY": source_dir, "DATA": str(data_with_docs_sources), "SCORE_SOURCELINKS": "$(location :merged_sourcelinks)", - "COMPOSED_SOURCE_CONF": "_main/docs_src_dir/conf.py", } if known_good: docs_env["KNOWN_GOOD_JSON"] = "$(location "+ known_good + ")" @@ -357,7 +281,7 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = name = "needs_json", srcs = [], config = ":" + source_prefix + "conf.py", - deps = [":docs_src_dir", ":docs_sources"], + 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 e59acf863..be7b4ca78 100644 --- a/src/BUILD +++ b/src/BUILD @@ -101,6 +101,6 @@ sphinx_docs_library( name = "src_docs", srcs = glob(["docs/**/*.rst"]), strip_prefix = "src/docs/", - prefix = "docs/src_extensions/", + 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 index 85ebf9015..ed3b5d1be 100644 --- a/src/docs/index.rst +++ b/src/docs/index.rst @@ -1,5 +1,3 @@ -:orphan: - .. Copyright (c) 2026 Contributors to the Eclipse Foundation diff --git a/src/docs/overview.rst b/src/docs/overview.rst index 425d6edcd..c35e2d88b 100644 --- a/src/docs/overview.rst +++ b/src/docs/overview.rst @@ -1,5 +1,3 @@ -:orphan: - .. Copyright (c) 2026 Contributors to the Eclipse Foundation diff --git a/src/docs/requirements.rst b/src/docs/requirements.rst index c07a0cd85..eca3cd896 100644 --- a/src/docs/requirements.rst +++ b/src/docs/requirements.rst @@ -1,5 +1,3 @@ -:orphan: - .. Copyright (c) 2026 Contributors to the Eclipse Foundation From f957cada0495e275a42371630d358826a7a16a19 Mon Sep 17 00:00:00 2001 From: Andreas Zwinkau Date: Wed, 29 Apr 2026 16:37:12 +0200 Subject: [PATCH 5/8] fix: :docs requires a materialization --- docs.bzl | 62 +++++++++++++++++++++++++++++++++++++- src/helper_lib/__init__.py | 4 ++- src/incremental.py | 20 ++++++------ 3 files changed, 75 insertions(+), 11 deletions(-) diff --git a/docs.bzl b/docs.bzl index bf09b6f0e..d448e4cc3 100644 --- a/docs.bzl +++ b/docs.bzl @@ -45,6 +45,58 @@ 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("cp '{}' '{}/{}'".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.""" @@ -194,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", ":docs_sources"] + _docs_source_tree( + name = "docs_source_tree", + lib = [":docs_sources"], + config = ":" + source_prefix + "conf.py", + visibility = ["//visibility:private"], + ) + + docs_data = data + [":sourcelinks_json", ":docs_sources", ":docs_source_tree"] combo_data = data_with_docs_sources + [":merged_sourcelinks", ":docs_sources"] docs_env = { "SOURCE_DIRECTORY": source_dir, + "DOCS_SOURCE_TREE": "$(rlocationpath :docs_source_tree)", "DATA": str(data), "SCORE_SOURCELINKS": "$(location :sourcelinks_json)", } 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( From bf446d5ce4bf9b9c8cb1cc527fdb34b3ae44333f Mon Sep 17 00:00:00 2001 From: Andreas Zwinkau Date: Thu, 30 Apr 2026 10:37:53 +0200 Subject: [PATCH 6/8] fix: missing script and cleanup --- docs.bzl | 8 +-- src/gen_live_preview.py | 139 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 src/gen_live_preview.py diff --git a/docs.bzl b/docs.bzl index d448e4cc3..9940f31ab 100644 --- a/docs.bzl +++ b/docs.bzl @@ -72,7 +72,7 @@ def _docs_source_tree_impl(ctx): parent = dest_rel.rsplit("/", 1)[0] if "/" in dest_rel else "" if parent: cmds.append("mkdir -p '{}/{}'".format(output_dir.path, parent)) - cmds.append("cp '{}' '{}/{}'".format(src, output_dir.path, dest_rel)) + cmds.append("ln -s '{}' '{}/{}'".format(src, output_dir.path, dest_rel)) ctx.actions.run_shell( inputs = all_inputs, @@ -247,18 +247,18 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = 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_source_tree( - name = "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_source_tree"] + 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_source_tree)", + "DOCS_SOURCE_TREE": "$(rlocationpath :docs_src_dir)", "DATA": str(data), "SCORE_SOURCELINKS": "$(location :sourcelinks_json)", } diff --git a/src/gen_live_preview.py b/src/gen_live_preview.py new file mode 100644 index 000000000..d00807f1b --- /dev/null +++ b/src/gen_live_preview.py @@ -0,0 +1,139 @@ +# ******************************************************************************* +# 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 runs `bazel run //:docs` in a watch loop +and serves _build/ via HTTP. +""" + +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 http.server + import os + import subprocess + import threading + import time + from pathlib import Path + + WORKSPACE = Path({repr(workspace)}) + SOURCE_DIR = WORKSPACE / {repr(source_dir)} + BUILD_DIR = WORKSPACE / "_build" + PORT = int(os.environ.get("PORT", "8000")) + + _rebuild_event = threading.Event() + + + def _mtimes(directory: Path) -> dict: + result = {{}} + for p in directory.rglob("*"): + if p.is_file(): + try: + result[str(p)] = p.stat().st_mtime + except OSError: + pass + return result + + + def _watch_loop(watched_dirs: list) -> None: + previous = {{}} + for d in watched_dirs: + previous.update(_mtimes(Path(d))) + while True: + time.sleep(1) + current = {{}} + for d in watched_dirs: + current.update(_mtimes(Path(d))) + if current != previous: + previous = current + _rebuild_event.set() + + + def _build_loop() -> None: + while True: + _rebuild_event.wait() + _rebuild_event.clear() + print("\\nSources changed — rebuilding...", flush=True) + subprocess.run(["bazel", "run", "//:docs"], cwd=WORKSPACE) + print("Done. Refresh http://localhost:{{PORT}}", flush=True) + + + class _ReloadingHandler(http.server.SimpleHTTPRequestHandler): + \"\"\"Injects a polling auto-reload snippet into HTML responses.\"\"\" + + _SNIPPET = ( + "" + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(BUILD_DIR), **kwargs) + + def end_headers(self): + self.send_header("Cache-Control", "no-store") + super().end_headers() + + def copyfile(self, source, outputfile): + if self.path.endswith(".html") or self.path == "/": + content = source.read() + modified = content.replace( + b"", self._SNIPPET.encode() + b"", 1 + ) + outputfile.write(modified) + else: + super().copyfile(source, outputfile) + + def log_message(self, fmt, *args): + pass # silence per-request logs + + + if __name__ == "__main__": + print(f"Building docs (initial)...", flush=True) + subprocess.run(["bazel", "run", "//:docs"], cwd=WORKSPACE, check=True) + + threading.Thread( + target=_watch_loop, args=([str(SOURCE_DIR)],), daemon=True + ).start() + threading.Thread(target=_build_loop, daemon=True).start() + + print(f"Serving at http://localhost:{{PORT}}") + print("Edit docs sources — the page will reload automatically.") + print("Ctrl-C to stop.") + with http.server.ThreadingHTTPServer(("", PORT), _ReloadingHandler) as httpd: + httpd.serve_forever() + """) + + 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") From 59cbfdb5d47da3b134c0d9a533e24f3d682800a2 Mon Sep 17 00:00:00 2001 From: Andreas Zwinkau Date: Mon, 11 May 2026 14:39:05 +0200 Subject: [PATCH 7/8] fix: symlinks do not work with Bazel --- docs.bzl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs.bzl b/docs.bzl index 9940f31ab..8213b5cc7 100644 --- a/docs.bzl +++ b/docs.bzl @@ -72,7 +72,7 @@ def _docs_source_tree_impl(ctx): 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 -s '{}' '{}/{}'".format(src, output_dir.path, dest_rel)) + cmds.append("ln '{}' '{}/{}'".format(src, output_dir.path, dest_rel)) ctx.actions.run_shell( inputs = all_inputs, From f6556c21eb85eb4327e6d548c1e3761fb519b638 Mon Sep 17 00:00:00 2001 From: Andreas Zwinkau Date: Mon, 11 May 2026 15:18:38 +0200 Subject: [PATCH 8/8] fix: live_preview script --- src/gen_live_preview.py | 120 ++++++++++++++-------------------------- 1 file changed, 41 insertions(+), 79 deletions(-) diff --git a/src/gen_live_preview.py b/src/gen_live_preview.py index d00807f1b..f4e6d56de 100644 --- a/src/gen_live_preview.py +++ b/src/gen_live_preview.py @@ -14,8 +14,8 @@ """Generates a live_preview script in the workspace root. Usage: bazel run //:gen_live_preview -The generated ./live_preview script runs `bazel run //:docs` in a watch loop -and serves _build/ via HTTP. +The generated ./live_preview script uses ibazel to keep :docs_src_dir fresh +and sphinx-autobuild to serve with browser auto-reload. """ import os @@ -35,101 +35,63 @@ # Generated by: bazel run //:gen_live_preview # Re-run that command to regenerate after changing docs() parameters. - import http.server import os import subprocess + import sys import threading - import time from pathlib import Path WORKSPACE = Path({repr(workspace)}) - SOURCE_DIR = WORKSPACE / {repr(source_dir)} + 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")) - _rebuild_event = threading.Event() - - - def _mtimes(directory: Path) -> dict: - result = {{}} - for p in directory.rglob("*"): - if p.is_file(): - try: - result[str(p)] = p.stat().st_mtime - except OSError: - pass - return result - - - def _watch_loop(watched_dirs: list) -> None: - previous = {{}} - for d in watched_dirs: - previous.update(_mtimes(Path(d))) - while True: - time.sleep(1) - current = {{}} - for d in watched_dirs: - current.update(_mtimes(Path(d))) - if current != previous: - previous = current - _rebuild_event.set() - - - def _build_loop() -> None: - while True: - _rebuild_event.wait() - _rebuild_event.clear() - print("\\nSources changed — rebuilding...", flush=True) - subprocess.run(["bazel", "run", "//:docs"], cwd=WORKSPACE) - print("Done. Refresh http://localhost:{{PORT}}", flush=True) - - - class _ReloadingHandler(http.server.SimpleHTTPRequestHandler): - \"\"\"Injects a polling auto-reload snippet into HTML responses.\"\"\" - - _SNIPPET = ( - "" - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, directory=str(BUILD_DIR), **kwargs) - - def end_headers(self): - self.send_header("Cache-Control", "no-store") - super().end_headers() - - def copyfile(self, source, outputfile): - if self.path.endswith(".html") or self.path == "/": - content = source.read() - modified = content.replace( - b"", self._SNIPPET.encode() + b"", 1 - ) - outputfile.write(modified) - else: - super().copyfile(source, outputfile) - def log_message(self, fmt, *args): - pass # silence per-request logs + 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__": - print(f"Building docs (initial)...", flush=True) - subprocess.run(["bazel", "run", "//:docs"], cwd=WORKSPACE, check=True) + 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 + ) - threading.Thread( - target=_watch_loop, args=([str(SOURCE_DIR)],), daemon=True - ).start() - threading.Thread(target=_build_loop, daemon=True).start() + # 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.") - with http.server.ThreadingHTTPServer(("", PORT), _ReloadingHandler) as httpd: - httpd.serve_forever() + 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"