From 982f5205fc29c6d978ebd5b9bf6b343198f825d3 Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Thu, 16 Jul 2026 10:56:24 +0200 Subject: [PATCH 1/4] Fix: remove root location enforcement --- docs.bzl | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs.bzl b/docs.bzl index 7f5ad0ced..ff5aac4c2 100644 --- a/docs.bzl +++ b/docs.bzl @@ -142,11 +142,6 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = file instead of the default metamodel shipped with score_metamodel. """ - call_path = native.package_name() - - if call_path != "": - fail("docs() must be called from the root package. Current package: " + call_path) - metamodel_data = [] metamodel_env = {} metamodel_opts = [] From 16482dd218980fc222c24161796b1d40cd023779 Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Thu, 16 Jul 2026 14:37:36 +0200 Subject: [PATCH 2/4] WIP: Adding name attribute to docs macro --- docs.bzl | 113 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 75 insertions(+), 38 deletions(-) diff --git a/docs.bzl b/docs.bzl index ff5aac4c2..a6ccd4f39 100644 --- a/docs.bzl +++ b/docs.bzl @@ -127,12 +127,21 @@ def _missing_requirements(deps): fail(msg) fail("This case should be unreachable?!") -def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = None, metamodel = None): +def docs(name = "docs", source_dir = "docs", data = [], deps = [], scan_code = [], known_good = None, metamodel = None): """Creates all targets related to documentation. By using this function, you'll get any and all updates for documentation targets in one place. + The macro can be called multiple times in the same BUILD file as long as each call uses a + distinct `name`. When `name` is left at its default ("docs"), all generated targets keep their + historical, unprefixed names (e.g. `needs_json`, `sourcelinks_json`, `docs_check`) for backward + compatibility. For any other `name`, every generated target is prefixed with it + (e.g. `name = "foo"` yields `foo`, `foo_check`, `foo_needs_json`, `foo_sourcelinks_json`, ...). + Args: + name: Name of the main documentation target. Defaults to "docs". All other targets created + by this macro (needs_json, sourcelinks_json, docs_check, ide_support, etc.) are derived + from this name, so multiple calls to docs() in the same BUILD file must use distinct names. source_dir: The source directory containing documentation files. Defaults to "docs". data: Additional data files to include in the documentation build. deps: Additional dependencies for the documentation build. @@ -142,6 +151,32 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = file instead of the default metamodel shipped with score_metamodel. """ + def _n(default_name): + """Derive a target name: keep historical unprefixed names when name == "docs", otherwise prefix with `name`.""" + if name == "docs": + return default_name + if default_name == "docs": + return name + if default_name.startswith("docs_"): + return name + "_" + default_name[len("docs_"):] + return name + "_" + default_name + + sphinx_build_name = _n("sphinx_build") + docs_sources_name = _n("docs_sources") + sourcelinks_json_name = _n("sourcelinks_json") + merged_sourcelinks_name = _n("merged_sourcelinks") + docs_name = _n("docs") + docs_combo_name = _n("docs_combo") + docs_combo_experimental_name = _n("docs_combo_experimental") + docs_link_check_name = _n("docs_link_check") + docs_check_name = _n("docs_check") + live_preview_name = _n("live_preview") + live_preview_combo_name = _n("live_preview_combo_experimental") + ide_support_name = _n("ide_support") + needs_json_name = _n("needs_json") + metrics_json_name = _n("metrics_json") + traceability_gate_name = _n("traceability_gate") + metamodel_data = [] metamodel_env = {} metamodel_opts = [] @@ -160,7 +195,7 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = incremental_src = Label("//src:incremental.py") sphinx_build_binary( - name = "sphinx_build", + name = sphinx_build_name, visibility = ["//visibility:private"], data = data + metamodel_data, deps = deps, @@ -174,7 +209,7 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = source_prefix = source_dir + "/" native.filegroup( - name = "docs_sources", + name = docs_sources_name, srcs = native.glob([ source_prefix + "**/*.png", source_prefix + "**/*.svg", @@ -192,23 +227,23 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = visibility = ["//visibility:public"], ) - _sourcelinks_json(name = "sourcelinks_json", srcs = scan_code) + _sourcelinks_json(name = sourcelinks_json_name, 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 + metamodel_data + [":sourcelinks_json"] - combo_data = data_with_docs_sources + metamodel_data + [":merged_sourcelinks"] + _merge_sourcelinks(name = merged_sourcelinks_name, sourcelinks = [":" + sourcelinks_json_name] + additional_combo_sourcelinks, known_good = known_good) + docs_data = data + metamodel_data + [":" + sourcelinks_json_name] + combo_data = data_with_docs_sources + metamodel_data + [":" + merged_sourcelinks_name] docs_env = { "SOURCE_DIRECTORY": source_dir, "DATA": str(data), - "SCORE_SOURCELINKS": "$(location :sourcelinks_json)", + "SCORE_SOURCELINKS": "$(location :%s)" % sourcelinks_json_name, } | metamodel_env docs_sources_env = { "SOURCE_DIRECTORY": source_dir, "DATA": str(data_with_docs_sources), - "SCORE_SOURCELINKS": "$(location :merged_sourcelinks)", + "SCORE_SOURCELINKS": "$(location :%s)" % merged_sourcelinks_name, } | metamodel_env if known_good: known_good_str = str(known_good) @@ -220,8 +255,8 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = docs_env["ACTION"] = "incremental" py_binary( - name = "docs", - tags = ["cli_help=Build documentation:\nbazel run //:docs"], + name = docs_name, + tags = ["cli_help=Build documentation:\nbazel run //:%s" % docs_name], srcs = [incremental_src], data = docs_data, deps = deps, @@ -230,8 +265,8 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = docs_sources_env["ACTION"] = "incremental" py_binary( - name = "docs_combo", - tags = ["cli_help=Build full documentation with all dependencies:\nbazel run //:docs_combo"], + name = docs_combo_name, + tags = ["cli_help=Build full documentation with all dependencies:\nbazel run //:%s" % docs_combo_name], srcs = [incremental_src], data = combo_data, deps = deps, @@ -239,15 +274,15 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = ) native.alias( - name = "docs_combo_experimental", - actual = ":docs_combo", - deprecation = "Target '//:docs_combo_experimental' is deprecated. Use '//:docs_combo' instead.", + name = docs_combo_experimental_name, + actual = ":" + docs_combo_name, + deprecation = "Target '//:%s' is deprecated. Use '//:%s' instead." % (docs_combo_experimental_name, docs_combo_name), ) docs_env["ACTION"] = "linkcheck" py_binary( - name = "docs_link_check", - tags = ["cli_help=Verify Links inside Documentation:\nbazel run //:link_check\n (Note: this could take a long time)"], + name = docs_link_check_name, + tags = ["cli_help=Verify Links inside Documentation:\nbazel run //:%s\n (Note: this could take a long time)" % docs_link_check_name], srcs = [incremental_src], data = docs_data, deps = deps, @@ -256,8 +291,8 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = docs_env["ACTION"] = "check" py_binary( - name = "docs_check", - tags = ["cli_help=Verify documentation:\nbazel run //:docs_check"], + name = docs_check_name, + tags = ["cli_help=Verify documentation:\nbazel run //:%s" % docs_check_name], srcs = [incremental_src], data = docs_data, deps = deps, @@ -266,8 +301,8 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = docs_env["ACTION"] = "live_preview" py_binary( - name = "live_preview", - tags = ["cli_help=Live preview documentation in the browser:\nbazel run //:live_preview"], + name = live_preview_name, + tags = ["cli_help=Live preview documentation in the browser:\nbazel run //:%s" % live_preview_name], srcs = [incremental_src], data = docs_data, deps = deps, @@ -276,26 +311,27 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = 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"], + name = live_preview_combo_name, + tags = ["cli_help=Live preview full documentation with all dependencies in the browser:\nbazel run //:%s" % live_preview_combo_name], srcs = [incremental_src], data = combo_data, deps = deps, env = docs_sources_env ) + venv_name = ".venv_docs" if name == "docs" else ".venv_" + name py_venv( - name = "ide_support", - tags = ["cli_help=Create virtual environment (.venv_docs) for documentation support:\nbazel run //:ide_support"], - venv_name = ".venv_docs", + name = ide_support_name, + tags = ["cli_help=Create virtual environment (%s) for documentation support:\nbazel run //:%s" % (venv_name, ide_support_name)], + venv_name = venv_name, deps = deps, data = data, package_collisions = "warning", ) sphinx_docs( - name = "needs_json", - srcs = [":docs_sources"], + name = needs_json_name, + srcs = [":" + docs_sources_name], config = ":" + source_prefix + "conf.py", extra_opts = [ "-W", @@ -304,30 +340,31 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = "--jobs", "auto", "--define=external_needs_source=" + str(data), - "--define=score_sourcelinks_json=$(location :sourcelinks_json)", + "--define=score_sourcelinks_json=$(location :%s)" % sourcelinks_json_name, "--define=score_source_code_linker_plain_links=1", ], formats = ["needs"], - sphinx = ":sphinx_build", - tools = data + [":sourcelinks_json"], + sphinx = ":" + sphinx_build_name, + tools = data + [":" + sourcelinks_json_name], visibility = ["//visibility:public"], # Persistent workers cause stale symlinks after dependency version # changes, corrupting the Bazel cache. allow_persistent_workers = False, ) + metrics_json_out = "metrics.json" if name == "docs" else name + "_metrics.json" native.genrule( - name = "metrics_json", - srcs = [":needs_json"], - outs = ["metrics.json"], - cmd = "cp $(location :needs_json)/metrics.json $@", + name = metrics_json_name, + srcs = [":" + needs_json_name], + outs = [metrics_json_out], + cmd = "cp $(location :%s)/metrics.json $@" % needs_json_name, visibility = ["//visibility:public"], ) native.alias( - name = "traceability_gate", + name = traceability_gate_name, actual = Label("//scripts_bazel:traceability_gate"), - tags = ["cli_help=Enforce traceability coverage thresholds:\nbazel run //:traceability_gate -- --metrics-json $(location //:metrics_json)"], + tags = ["cli_help=Enforce traceability coverage thresholds:\nbazel run //:%s -- --metrics-json $(location //:%s)" % (traceability_gate_name, metrics_json_name)], ) def _sourcelinks_json(name, srcs): From 9b0a7b6d854a8d061cc86fcfb5f28c05e22d8581 Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Fri, 17 Jul 2026 10:42:46 +0200 Subject: [PATCH 3/4] WIP: Adding name attribute to docs --- docs.bzl | 18 +++++- src/extensions/score_metamodel/__init__.py | 5 +- .../score_metamodel/external_needs.py | 59 ++++++++++++++----- .../tests/test_external_needs.py | 47 +++++++++++++-- src/extensions/score_plantuml.py | 3 +- .../score_sphinx_bundle/__init__.py | 11 +++- src/helper_lib/__init__.py | 31 +++++++++- src/helper_lib/test_helper_lib.py | 16 +++++ 8 files changed, 163 insertions(+), 27 deletions(-) diff --git a/docs.bzl b/docs.bzl index a6ccd4f39..331d5ed38 100644 --- a/docs.bzl +++ b/docs.bzl @@ -46,24 +46,36 @@ load("@docs_as_code_hub_env//:requirements.bzl", "all_requirements") load("@rules_python//sphinxdocs:sphinx.bzl", "sphinx_build_binary", "sphinx_docs") def _rewrite_needs_json_to_docs_sources(labels): - """Replace '@repo//:needs_json' -> '@repo//:docs_sources' for every item.""" + """Replace '@repo//:needs_json' -> '@repo//:docs_sources' for every item. + + Also handles a producer using a custom docs() `name` + (e.g. '@repo//:foo_needs_json' -> '@repo//:foo_docs_sources'). + """ out = [] for x in labels: s = str(x) if s.endswith("//:needs_json"): out.append(s.replace("//:needs_json", "//:docs_sources")) + elif "//:" in s and s.endswith("_needs_json"): + out.append(s[:-len("needs_json")] + "docs_sources") else: out.append(s) return out def _rewrite_needs_json_to_sourcelinks(labels): - """Replace '@repo//:needs_json' -> '@repo//:sourcelinks_json' for every item.""" + """Replace '@repo//:needs_json' -> '@repo//:sourcelinks_json' for every item. + + Also handles a producer using a custom docs() `name` + (e.g. '@repo//:foo_needs_json' -> '@repo//:foo_sourcelinks_json'). + """ out = [] for x in labels: s = str(x) if s.endswith("//:needs_json"): out.append(s.replace("//:needs_json", "//:sourcelinks_json")) - #Items which do not end up with '//:needs_json' shall not be appended to 'out'. + elif "//:" in s and s.endswith("_needs_json"): + out.append(s[:-len("needs_json")] + "sourcelinks_json") + #Items which do not end up with a 'needs_json' target shall not be appended to 'out'. #They are treated separately and are not related to source code linking. return out diff --git a/src/extensions/score_metamodel/__init__.py b/src/extensions/score_metamodel/__init__.py index 641025dc7..d2724e9f2 100644 --- a/src/extensions/score_metamodel/__init__.py +++ b/src/extensions/score_metamodel/__init__.py @@ -33,7 +33,7 @@ default_options as default_options, load_metamodel_data as load_metamodel_data, ) -from src.helper_lib import config_setdefault +from src.helper_lib import add_config_value_if_absent, config_setdefault logger = logging.get_logger(__name__) @@ -237,6 +237,9 @@ def setup(app: Sphinx) -> dict[str, str | bool]: app.add_config_value("external_needs_source", "", rebuild="env") app.add_config_value("score_metamodel_yaml", "", rebuild="env") app.add_config_value("required_in_id", [], rebuild="env") + # Registered here too (guarded) so score_metamodel also works when used standalone, + # without score_sphinx_bundle (e.g. this extension's own file-based test fixtures). + add_config_value_if_absent(app, "docs_target_name", "docs", rebuild="env") config_setdefault(app.config, "needs_id_required", True) config_setdefault(app.config, "needs_id_regex", "^[A-Za-z0-9_-]{6,}") diff --git a/src/extensions/score_metamodel/external_needs.py b/src/extensions/score_metamodel/external_needs.py index a39736661..291d8d508 100644 --- a/src/extensions/score_metamodel/external_needs.py +++ b/src/extensions/score_metamodel/external_needs.py @@ -48,7 +48,12 @@ def _parse_bazel_external_need(s: str) -> ExternalNeedsSource | None: repo, path_to_target = repo_and_path.split("//", 1) repo = repo.lstrip("@") - if path_to_target == "" and target in ("needs_json", "docs_sources"): + # The producer's docs() macro may have used a custom `name`, in which case its targets + # are prefixed (e.g. 'foo_needs_json', 'foo_docs_sources') instead of the default, + # unprefixed 'needs_json' / 'docs_sources'. Match both. + is_needs_json = target == "needs_json" or target.endswith("_needs_json") + is_docs_sources = target == "docs_sources" or target.endswith("_docs_sources") + if path_to_target == "" and (is_needs_json or is_docs_sources): return ExternalNeedsSource( bazel_module=repo, path_to_target=path_to_target, target=target ) @@ -69,13 +74,21 @@ def parse_external_needs_sources_from_DATA(v: str) -> list[ExternalNeedsSource]: return res -def parse_external_needs_sources_from_bazel_query() -> list[ExternalNeedsSource]: +def parse_external_needs_sources_from_bazel_query( + docs_target_name: str = "docs", +) -> list[ExternalNeedsSource]: """ This function detects if the Sphinx app is running without Bazel and sets the `external_needs_source` config value accordingly. When running with Bazel, we pass the `external_needs_source` config value from the bazel config. + + Args: + docs_target_name: The `name` the local repo's docs() Bazel macro invocation uses + (see docs.bzl). Defaults to "docs", the macro's own default. Repos using a + custom name must set `docs_target_name` in their conf.py to match, so this + queries the right local target. """ try: logger.debug( @@ -85,7 +98,7 @@ def parse_external_needs_sources_from_bazel_query() -> list[ExternalNeedsSource] # We could parse it or query bazel. # Parsing would be MUCH faster, but querying bazel would be more robust. p = subprocess.run( - ["bazel", "query", "labels(data, //:docs)"], + ["bazel", "query", f"labels(data, //:{docs_target_name})"], check=True, capture_output=True, text=True, @@ -138,20 +151,26 @@ def temp(self: NeedsList): NeedsList._finalise = temp # pyright: ignore[reportPrivateUsage] -def get_external_needs_source(external_needs_source: str) -> list[ExternalNeedsSource]: +def get_external_needs_source( + external_needs_source: str, docs_target_name: str = "docs" +) -> list[ExternalNeedsSource]: if external_needs_source: # Path taken for all invocations via `bazel` external_needs = parse_external_needs_sources_from_DATA(external_needs_source) else: # This is the path taken for anything that doesn't # run via `bazel` e.g. esbonio or other direct executions - external_needs = parse_external_needs_sources_from_bazel_query() # pyright: ignore[reportAny] + external_needs = parse_external_needs_sources_from_bazel_query( + docs_target_name + ) # pyright: ignore[reportAny] return external_needs -def add_external_needs_json(e: ExternalNeedsSource, config: Config): +def add_external_needs_json( + e: ExternalNeedsSource, config: Config, docs_target_name: str = "docs" +): json_file_raw = f"{e.bazel_module}+/{e.target}/_build/needs/needs.json" - r = get_runfiles_dir() + r = get_runfiles_dir(docs_target_name) json_file = r / json_file_raw logger.debug(f"External needs.json: {json_file}") try: @@ -174,11 +193,18 @@ def add_external_needs_json(e: ExternalNeedsSource, config: Config): ) -def add_external_docs_sources(e: ExternalNeedsSource, config: Config): +def add_external_docs_sources( + e: ExternalNeedsSource, config: Config, docs_target_name: str = "docs" +): # Note that bazel does NOT write the files under e.target! # {e.bazel_module}+ matches the original git layout! - r = get_runfiles_dir() - if "ide_support.runfiles" in str(r): + r = get_runfiles_dir(docs_target_name) + ide_support_name = ( + "ide_support" + if docs_target_name == "docs" + else docs_target_name + "_ide_support" + ) + if f"{ide_support_name}.runfiles" in str(r): logger.error("Combo builds are currently only supported with Bazel.") return docs_source_path = Path(r) / f"{e.bazel_module}+" @@ -197,7 +223,10 @@ def add_external_docs_sources(e: ExternalNeedsSource, config: Config): def connect_external_needs(app: Sphinx, config: Config): extend_needs_json_exporter(config, ["project_url"]) - external_needs = get_external_needs_source(app.config.external_needs_source) + docs_target_name = app.config.docs_target_name + external_needs = get_external_needs_source( + app.config.external_needs_source, docs_target_name + ) # this sets the default value - required for the needs-config-writer # setting 'needscfg_exclude_defaults = True' to see the diff @@ -206,10 +235,10 @@ def connect_external_needs(app: Sphinx, config: Config): for e in external_needs: assert not e.path_to_target # path_to_target is always empty - if e.target == "needs_json": - add_external_needs_json(e, app.config) - elif e.target == "docs_sources": - add_external_docs_sources(e, app.config) + if e.target == "needs_json" or e.target.endswith("_needs_json"): + add_external_needs_json(e, app.config, docs_target_name) + elif e.target == "docs_sources" or e.target.endswith("_docs_sources"): + add_external_docs_sources(e, app.config, docs_target_name) else: raise ValueError( f"Internal Error. Unknown external needs target: {e.target}" diff --git a/src/extensions/score_metamodel/tests/test_external_needs.py b/src/extensions/score_metamodel/tests/test_external_needs.py index a9db5052e..e96116a78 100644 --- a/src/extensions/score_metamodel/tests/test_external_needs.py +++ b/src/extensions/score_metamodel/tests/test_external_needs.py @@ -90,6 +90,26 @@ def test_invalid_entry(): _ = parse_external_needs_sources_from_DATA('["@not_a_valid_string"]') +def test_custom_name_needs_json_entry(): + """A producer using docs(name="foo") publishes 'foo_needs_json' instead of 'needs_json'.""" + result = parse_external_needs_sources_from_DATA('["@repo//:foo_needs_json"]') + assert result == [ + ExternalNeedsSource( + bazel_module="repo", path_to_target="", target="foo_needs_json" + ) + ] + + +def test_custom_name_docs_sources_entry(): + """A producer using docs(name="foo") publishes 'foo_docs_sources' instead of 'docs_sources'.""" + result = parse_external_needs_sources_from_DATA('["@repo//:foo_docs_sources"]') + assert result == [ + ExternalNeedsSource( + bazel_module="repo", path_to_target="", target="foo_docs_sources" + ) + ] + + def test_add_external_needs_json_appends_entry( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: @@ -109,7 +129,7 @@ def test_add_external_needs_json_appends_entry( json.dumps({"project_url": "https://example.test/repo"}), encoding="utf-8" ) - monkeypatch.setattr(ext_needs, "get_runfiles_dir", lambda: runfiles_dir) + monkeypatch.setattr(ext_needs, "get_runfiles_dir", lambda *_: runfiles_dir) add_external_needs_json(e, config) @@ -131,7 +151,7 @@ def test_add_external_needs_json_missing_file_keeps_list_empty( config = Config() config.needs_external_needs = [] - monkeypatch.setattr(ext_needs, "get_runfiles_dir", lambda: tmp_path) + monkeypatch.setattr(ext_needs, "get_runfiles_dir", lambda *_: tmp_path) add_external_needs_json(e, config) @@ -149,7 +169,7 @@ def test_add_external_docs_sources_adds_collection( config = Config() config.collections = {} - monkeypatch.setattr(ext_needs, "get_runfiles_dir", lambda: tmp_path) + monkeypatch.setattr(ext_needs, "get_runfiles_dir", lambda *_: tmp_path) add_external_docs_sources(e, config) @@ -172,9 +192,28 @@ def test_add_external_docs_sources_ide_support_returns_without_changes( config.collections = {} monkeypatch.setattr( - ext_needs, "get_runfiles_dir", lambda: Path("/tmp/ide_support.runfiles") + ext_needs, "get_runfiles_dir", lambda *_: Path("/tmp/ide_support.runfiles") ) add_external_docs_sources(e, config) assert config.collections == {} + + +def test_add_external_docs_sources_custom_name_ide_support_returns_without_changes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """With a custom docs_target_name, the ide_support runfiles dir is '_ide_support.runfiles'.""" + e = ExternalNeedsSource( + bazel_module="third_party_docs", target="docs_sources", path_to_target="" + ) + config = Config() + config.collections = {} + + monkeypatch.setattr( + ext_needs, "get_runfiles_dir", lambda *_: Path("/tmp/foo_ide_support.runfiles") + ) + + add_external_docs_sources(e, config, docs_target_name="foo") + + assert config.collections == {} diff --git a/src/extensions/score_plantuml.py b/src/extensions/score_plantuml.py index d3d2854cb..e894d7530 100644 --- a/src/extensions/score_plantuml.py +++ b/src/extensions/score_plantuml.py @@ -54,7 +54,8 @@ def find_correct_path(runfiles: Path) -> Path: def setup(app: Sphinx): # we must overwrite the plantuml path due to Bazel - app.config.plantuml = str(find_correct_path(get_runfiles_dir())) + docs_target_name = getattr(app.config, "docs_target_name", "docs") + app.config.plantuml = str(find_correct_path(get_runfiles_dir(docs_target_name))) config_setdefault(app.config, "plantuml_output_format", "svg_obj") config_setdefault(app.config, "plantuml_syntax_error_image", True) config_setdefault(app.config, "needs_build_needumls", "_plantuml_sources") diff --git a/src/extensions/score_sphinx_bundle/__init__.py b/src/extensions/score_sphinx_bundle/__init__.py index ded7ea095..2baa6d73d 100644 --- a/src/extensions/score_sphinx_bundle/__init__.py +++ b/src/extensions/score_sphinx_bundle/__init__.py @@ -12,7 +12,7 @@ # ******************************************************************************* from sphinx.application import Sphinx -from src.helper_lib import config_setdefault +from src.helper_lib import add_config_value_if_absent, config_setdefault # Note: order matters! # Extensions are loaded in this order. @@ -36,6 +36,15 @@ def setup(app: Sphinx) -> dict[str, object]: + # The `name` used for this repo's own docs() Bazel macro invocation (see docs.bzl). + # Defaults to "docs", matching the macro's own default. Repos that call + # docs(name="something_else") must set `docs_target_name = "something_else"` in their + # conf.py, so that IDE/esbonio (non-Bazel) execution can resolve the correctly-named + # local targets (e.g. `_ide_support`) instead of the default `ide_support`. + # Registered here (guarded, since score_metamodel also registers it when used + # standalone) so it's available before score_plantuml, loaded first below, needs it. + add_config_value_if_absent(app, "docs_target_name", "docs", rebuild="env") + config_setdefault(app.config, "html_copy_source", False) config_setdefault(app.config, "html_show_sourcelink", False) diff --git a/src/helper_lib/__init__.py b/src/helper_lib/__init__.py index 74e275a1a..0befe8700 100644 --- a/src/helper_lib/__init__.py +++ b/src/helper_lib/__init__.py @@ -18,6 +18,7 @@ from typing import Any from python.runfiles import Runfiles +from sphinx.application import Sphinx from sphinx.config import Config from sphinx_needs.logging import get_logger @@ -34,6 +35,22 @@ def config_setdefault(config: Config, name: str, value: Any) -> None: setattr(config, name, value) +def add_config_value_if_absent( + app: Sphinx, name: str, default: Any, rebuild: str +) -> None: + """Register a Sphinx config value, unless another extension already has. + + Several of our extensions may be used either standalone or bundled together via + score_sphinx_bundle, in either load order, and may each want the same config value + registered. Sphinx raises if `add_config_value` is called twice for the same name, + so this guards the registration to make it safe to call from multiple extensions. + """ + # Sphinx has no public API for this check either; `_options` is the internal dict + # `Config.add` itself consults to reject duplicate registrations. + if name not in app.config._options: # pyright: ignore [reportPrivateUsage] + app.add_config_value(name, default, rebuild=rebuild) + + def find_ws_root() -> Path | None: """ Find the current MODULE.bazel workspace root directory. @@ -187,10 +204,15 @@ def get_current_git_hash(git_root: Path) -> str: raise -def get_runfiles_dir() -> Path: +def get_runfiles_dir(docs_target_name: str = "docs") -> Path: """ Find the Bazel runfiles directory using bazel_runfiles convention, fallback to RUNFILES_DIR or relative traversal if needed. + + Args: + docs_target_name: The `name` the local repo's docs() Bazel macro invocation uses + (see docs.bzl). Defaults to "docs", the macro's own default. Only relevant for + the non-Bazel fallback below, to find the correctly-named `ide_support` runfiles. """ if (r := Runfiles.Create()) and (rd := r.EnvVars().get("RUNFILES_DIR")): runfiles_dir = Path(rd) @@ -207,7 +229,12 @@ def get_runfiles_dir() -> Path: if git_root is None: sys.exit("Could not find git root.") - runfiles_dir = git_root / "bazel-bin" / "ide_support.runfiles" + ide_support_name = ( + "ide_support" + if docs_target_name == "docs" + else docs_target_name + "_ide_support" + ) + runfiles_dir = git_root / "bazel-bin" / (ide_support_name + ".runfiles") if not runfiles_dir.exists(): sys.exit( diff --git a/src/helper_lib/test_helper_lib.py b/src/helper_lib/test_helper_lib.py index dfca2b261..3ef2ed0e5 100644 --- a/src/helper_lib/test_helper_lib.py +++ b/src/helper_lib/test_helper_lib.py @@ -301,6 +301,22 @@ def test_git_root_search_success(git_repo: Path, monkeypatch: pytest.MonkeyPatch os.environ.pop("RUNFILES_DIR", None) +def test_git_root_search_custom_docs_target_name( + git_repo: Path, monkeypatch: pytest.MonkeyPatch +): + """A custom docs_target_name looks for '_ide_support.runfiles' instead.""" + docs_dir = git_repo / "docs" + runfiles_dir = git_repo / "bazel-bin" / "foo_ide_support.runfiles" + docs_dir.mkdir() + runfiles_dir.mkdir(parents=True) + os.environ.pop("RUNFILES_DIR", None) + + monkeypatch.setattr(Path, "cwd", lambda: docs_dir) + result = get_runfiles_dir(docs_target_name="foo") + assert Path(result) == runfiles_dir + os.environ.pop("RUNFILES_DIR", None) + + def test_git_root_search_not_found(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): """ Test fallback when no .git is found (should sys.exit). From 6c4154e593853cc5a47047209fd991b748ffc023 Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Fri, 17 Jul 2026 16:00:53 +0200 Subject: [PATCH 4/4] chore: Linting & Formatting & Type errors --- .../score_metamodel/external_needs.py | 4 +--- .../tests/test_rules_file_based.py | 17 ++++++++++------- src/extensions/score_metamodel/yaml_parser.py | 2 +- src/helper_lib/__init__.py | 4 ++-- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/extensions/score_metamodel/external_needs.py b/src/extensions/score_metamodel/external_needs.py index 291d8d508..56dd2cc45 100644 --- a/src/extensions/score_metamodel/external_needs.py +++ b/src/extensions/score_metamodel/external_needs.py @@ -160,9 +160,7 @@ def get_external_needs_source( else: # This is the path taken for anything that doesn't # run via `bazel` e.g. esbonio or other direct executions - external_needs = parse_external_needs_sources_from_bazel_query( - docs_target_name - ) # pyright: ignore[reportAny] + external_needs = parse_external_needs_sources_from_bazel_query(docs_target_name) # pyright: ignore[reportAny] return external_needs diff --git a/src/extensions/score_metamodel/tests/test_rules_file_based.py b/src/extensions/score_metamodel/tests/test_rules_file_based.py index 72dad21ae..60240fcd1 100644 --- a/src/extensions/score_metamodel/tests/test_rules_file_based.py +++ b/src/extensions/score_metamodel/tests/test_rules_file_based.py @@ -23,6 +23,7 @@ from sphinx.testing.util import SphinxTestApp from sphinx_needs.data import NeedsExtendType, SphinxNeedsData from sphinx_needs.need_item import NeedItem +from sphinx_needs.views import NeedsView from score_pytest.attribute_plugin import apply_test_metadata @@ -194,7 +195,7 @@ def _get_default_metadata_need() -> NeedItem: ) -def _get_test_metadata_need(needs_view, rst_data: RstData) -> NeedItem: +def _get_test_metadata_need(needs_view: NeedsView, rst_data: RstData) -> NeedItem: ### Return the single 'test_metadata' need, failing if there isn't exactly one. test_metadata_needs = needs_view.filter_types(["test_metadata"]).values() if not test_metadata_needs: @@ -233,8 +234,9 @@ def _check_need_warnings( line_nr = need.get("lineno") - for raw in need.get("expect") or []: - expected = raw.strip() + expect: list[str] = need.get("expect") or [] + for raw in expect: + expected: str = raw.strip() if warning_matches(rst_data, line_nr, expected, warnings): continue actual = filter_warnings_by_position(rst_data, line_nr, warnings) @@ -247,8 +249,9 @@ def _check_need_warnings( pytrace=False, ) - for raw in need.get("expect_not") or []: - not_expected = raw.strip() + expect_not: list[str] = need.get("expect_not") or [] + for raw in expect_not: + not_expected: str = raw.strip() unexpected = warning_matches(rst_data, line_nr, not_expected, warnings) if not unexpected: continue @@ -263,8 +266,8 @@ def _check_need_warnings( @pytest.mark.parametrize("rst_file", RST_FILES) def test_rst_files( - record_property, - record_xml_attribute, + record_property: Callable[[str, object], None], + record_xml_attribute: Callable[[str, object], None], rst_file: str, sphinx_app_setup: Callable[[Path], SphinxTestApp], monkeypatch: pytest.MonkeyPatch, diff --git a/src/extensions/score_metamodel/yaml_parser.py b/src/extensions/score_metamodel/yaml_parser.py index ae387cd15..533d5ef4f 100644 --- a/src/extensions/score_metamodel/yaml_parser.py +++ b/src/extensions/score_metamodel/yaml_parser.py @@ -197,7 +197,7 @@ def _collect_all_custom_options( # # Note: "" is not encoded in the metamodel.yaml as there is no generic # demand exists at the moment. - params_str = {"schema": {"type": "string"}, "default": ""} + params_str: dict[str, Any] = {"schema": {"type": "string"}, "default": ""} params_int: dict[str, Any] = {"schema": {"type": "integer"}, "default": 0} return { diff --git a/src/helper_lib/__init__.py b/src/helper_lib/__init__.py index 0befe8700..ae22ba46f 100644 --- a/src/helper_lib/__init__.py +++ b/src/helper_lib/__init__.py @@ -19,7 +19,7 @@ from python.runfiles import Runfiles from sphinx.application import Sphinx -from sphinx.config import Config +from sphinx.config import Config, _ConfigRebuild # pyright: ignore [reportPrivateUsage] from sphinx_needs.logging import get_logger LOGGER = get_logger(__name__) @@ -36,7 +36,7 @@ def config_setdefault(config: Config, name: str, value: Any) -> None: def add_config_value_if_absent( - app: Sphinx, name: str, default: Any, rebuild: str + app: Sphinx, name: str, default: Any, rebuild: _ConfigRebuild ) -> None: """Register a Sphinx config value, unless another extension already has.