From 0c9042379897e37a965ca4657dad7152a7b8e80e Mon Sep 17 00:00:00 2001 From: Andreas Zwinkau Date: Tue, 24 Mar 2026 13:48:37 +0100 Subject: [PATCH] Allow to configure a different metamodel Fixes https://github.com/eclipse-score/docs-as-code/issues/415 --- docs.bzl | 44 ++++++++----- docs/how-to/setup.md | 6 +- docs/reference/bazel_macros.rst | 23 +++++++ src/extensions/score_metamodel/__init__.py | 5 +- .../tests/test_metamodel__init__.py | 65 +++++++++++++++++++ .../tests/test_metamodel_load.py | 9 +++ src/extensions/score_metamodel/yaml_parser.py | 9 ++- src/incremental.py | 4 ++ 8 files changed, 143 insertions(+), 22 deletions(-) diff --git a/docs.bzl b/docs.bzl index cd41a4c83..f48b6095f 100644 --- a/docs.bzl +++ b/docs.bzl @@ -120,7 +120,7 @@ def _missing_requirements(deps): fail(msg) fail("This case should be unreachable?!") -def docs(source_dir = "docs", data = [], deps = [], scan_code = []): +def docs(source_dir = "docs", data = [], deps = [], scan_code = [], 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. @@ -130,6 +130,8 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = []): 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. + metamodel: Optional label to a metamodel.yaml file. When set, the extension loads this + file instead of the default metamodel shipped with score_metamodel. """ call_path = native.package_name() @@ -137,6 +139,14 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = []): if call_path != "": fail("docs() must be called from the root package. Current package: " + call_path) + metamodel_data = [] + metamodel_env = {} + metamodel_opts = [] + if metamodel != None: + metamodel_data = [metamodel] + metamodel_env = {"SCORE_METAMODEL_YAML": "$(location " + str(metamodel) + ")"} + metamodel_opts = ["--define=score_metamodel_yaml=$(location " + str(metamodel) + ")"] + module_deps = deps deps = deps + _missing_requirements(deps) deps = deps + [ @@ -147,7 +157,7 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = []): sphinx_build_binary( name = "sphinx_build", visibility = ["//visibility:private"], - data = data, + data = data + metamodel_data, deps = deps, ) @@ -181,28 +191,28 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = []): name = "docs", tags = ["cli_help=Build documentation:\nbazel run //:docs"], srcs = ["@score_docs_as_code//src:incremental.py"], - data = data + [":sourcelinks_json"], + data = data + [":sourcelinks_json"] + metamodel_data, deps = deps, env = { "SOURCE_DIRECTORY": source_dir, "DATA": str(data), "ACTION": "incremental", "SCORE_SOURCELINKS": "$(location :sourcelinks_json)", - }, + } | metamodel_env, ) py_binary( name = "docs_combo", tags = ["cli_help=Build full documentation with all dependencies:\nbazel run //:docs_combo"], srcs = ["@score_docs_as_code//src:incremental.py"], - data = data_with_docs_sources + [":merged_sourcelinks"], + data = data_with_docs_sources + [":merged_sourcelinks"] + metamodel_data, deps = deps, env = { "SOURCE_DIRECTORY": source_dir, "DATA": str(data_with_docs_sources), "ACTION": "incremental", "SCORE_SOURCELINKS": "$(location :merged_sourcelinks)", - }, + } | metamodel_env, ) native.alias( @@ -215,55 +225,55 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = []): name = "docs_link_check", tags = ["cli_help=Verify Links inside Documentation:\nbazel run //:link_check\n (Note: this could take a long time)"], srcs = ["@score_docs_as_code//src:incremental.py"], - data = data, + data = data + metamodel_data, deps = deps, env = { "SOURCE_DIRECTORY": source_dir, "DATA": str(data), "ACTION": "linkcheck", - }, + } | metamodel_env, ) py_binary( name = "docs_check", tags = ["cli_help=Verify documentation:\nbazel run //:docs_check"], srcs = ["@score_docs_as_code//src:incremental.py"], - data = data + [":sourcelinks_json"], + data = data + [":sourcelinks_json"] + metamodel_data, deps = deps, env = { "SOURCE_DIRECTORY": source_dir, "DATA": str(data), "ACTION": "check", "SCORE_SOURCELINKS": "$(location :sourcelinks_json)", - }, + } | metamodel_env, ) 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 = data + [":sourcelinks_json"], + data = data + [":sourcelinks_json"] + metamodel_data, deps = deps, env = { "SOURCE_DIRECTORY": source_dir, "DATA": str(data), "ACTION": "live_preview", "SCORE_SOURCELINKS": "$(location :sourcelinks_json)", - }, + } | metamodel_env, ) 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 = data_with_docs_sources + [":merged_sourcelinks"], + data = data_with_docs_sources + [":merged_sourcelinks"] + metamodel_data, deps = deps, env = { "SOURCE_DIRECTORY": source_dir, "DATA": str(data_with_docs_sources), "ACTION": "live_preview", "SCORE_SOURCELINKS": "$(location :merged_sourcelinks)", - }, + } | metamodel_env, ) score_virtualenv( @@ -272,7 +282,7 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = []): venv_name = ".venv_docs", reqs = deps, # Add dependencies to ide_support, so esbonio has access to them. - data = data, + data = data + metamodel_data, ) sphinx_docs( @@ -286,10 +296,10 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = []): "--jobs", "auto", "--define=external_needs_source=" + str(data), - ], + ] + metamodel_opts, formats = ["needs"], sphinx = ":sphinx_build", - tools = data, + tools = data + metamodel_data, visibility = ["//visibility:public"], ) diff --git a/docs/how-to/setup.md b/docs/how-to/setup.md index e632ba911..f70ff4fb9 100644 --- a/docs/how-to/setup.md +++ b/docs/how-to/setup.md @@ -68,8 +68,10 @@ The `docs()` macro accepts the following arguments: | Parameter | Description | Required | |-----------|-------------|----------| | `source_dir` | Directory of documentation source files (RST, MD) | Yes | -| `data` | List of `needs_json` targets that should be included in the documentation| No | - +| `data` | List of `needs_json` targets that should be included in the documentation | No | +| `deps` | Additional Bazel Python dependencies | No | +| `scan_code` | Source code targets to scan for traceability tags | No | +| `metamodel` | Label to a custom `metamodel.yaml` that replaces the default metamodel | No | ### 4. Copy conf.py diff --git a/docs/reference/bazel_macros.rst b/docs/reference/bazel_macros.rst index b81343e15..5720676c6 100644 --- a/docs/reference/bazel_macros.rst +++ b/docs/reference/bazel_macros.rst @@ -47,6 +47,29 @@ Minimal example (root ``BUILD``) If you don't provide the necessary Sphinx packages, this function adds its own (but checks for conflicts). +- ``scan_code`` (list of bazel labels) + Source code targets to scan for traceability tags (``req-Id:`` annotations). + Used to generate the source-code-link JSON that maps tags back to source files. + +- ``metamodel`` (bazel label, optional) + Path to a custom ``metamodel.yaml`` file. + When set, the ``score_metamodel`` extension loads **this file instead of** the default metamodel. + The label is automatically added to the ``data`` and ``tools`` of every generated target + so the file is available in the Bazel sandbox at build time. + + Example: + + .. code-block:: python + + docs( + source_dir = "docs", + metamodel = "//:my_metamodel.yaml", + ) + + The custom ``metamodel.yaml`` must follow the same schema as the default one + (see :doc:`score_metamodel `). + When ``metamodel`` is omitted the default metamodel is used unchanged. + Edge cases ---------- diff --git a/src/extensions/score_metamodel/__init__.py b/src/extensions/score_metamodel/__init__.py index 220cb7676..d31aea0f8 100644 --- a/src/extensions/score_metamodel/__init__.py +++ b/src/extensions/score_metamodel/__init__.py @@ -232,11 +232,14 @@ def postprocess_need_links(needs_types_list: list[ScoreNeedType]): 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") config_setdefault(app.config, "needs_id_required", True) config_setdefault(app.config, "needs_id_regex", "^[A-Za-z0-9_-]{6,}") # load metamodel.yaml via ruamel.yaml - metamodel = load_metamodel_data() + raw_metamodel_path = app.config.score_metamodel_yaml + override_path = Path(raw_metamodel_path) if raw_metamodel_path else None + metamodel = load_metamodel_data(override_path) # Extend sphinx-needs config rather than overwriting app.config.needs_types += metamodel.needs_types diff --git a/src/extensions/score_metamodel/tests/test_metamodel__init__.py b/src/extensions/score_metamodel/tests/test_metamodel__init__.py index 9a2241ed4..0369f8106 100644 --- a/src/extensions/score_metamodel/tests/test_metamodel__init__.py +++ b/src/extensions/score_metamodel/tests/test_metamodel__init__.py @@ -10,6 +10,10 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + import pytest from attribute_plugin import add_test_properties # type: ignore[import-untyped] from sphinx.application import Sphinx @@ -21,9 +25,15 @@ graph_checks, local_checks, parse_checks_filter, + setup, ) from src.extensions.score_metamodel.tests import need +# The module where setup() looks up load_metamodel_data at runtime. +# Using __globals__ avoids breakage when the same file is imported under +# two different names (e.g. "score_metamodel" and "src.extensions.score_metamodel"). +_setup_module = sys.modules[setup.__module__] + def dummy_local_check(app: Sphinx, need: NeedItem, log: CheckLogger) -> None: pass @@ -176,3 +186,58 @@ def test_combined_core_links_and_extras(self): assert n.get("output", []) == ["output_wp"] # Extra fields assert n.get("custom_attr") == "custom_value" + + +# ============================================================================= +# Tests for setup() — score_metamodel_yaml config value wiring +# ============================================================================= + + +def _make_mock_app(metamodel_yaml_value: str = "") -> MagicMock: + """Return a minimal mock Sphinx app suitable for calling setup().""" + app = MagicMock() + app.config.score_metamodel_yaml = metamodel_yaml_value + app.config.needs_types = [] + app.config.needs_extra_links = [] + app.config.needs_extra_options = [] + return app + + +def _mock_metamodel_return(): + return MagicMock( + needs_types=[], + needs_extra_links=[], + needs_extra_options=[], + needs_graph_check={}, + prohibited_words_checks=[], + ) + + +def test_setup_uses_default_path_when_config_empty(): + """setup() calls load_metamodel_data(None) when score_metamodel_yaml is empty.""" + app = _make_mock_app(metamodel_yaml_value="") + + with patch.object( + _setup_module, + "load_metamodel_data", + return_value=_mock_metamodel_return(), + ) as mock_load: + setup(app) + + mock_load.assert_called_once_with(None) + + +def test_setup_passes_path_when_config_set(tmp_path): + """setup() calls load_metamodel_data(Path(...)) when score_metamodel_yaml is set.""" + yaml_file = tmp_path / "custom_metamodel.yaml" + yaml_file.write_text("needs_types: {}\n") + app = _make_mock_app(metamodel_yaml_value=str(yaml_file)) + + with patch.object( + _setup_module, + "load_metamodel_data", + return_value=_mock_metamodel_return(), + ) as mock_load: + setup(app) + + mock_load.assert_called_once_with(Path(str(yaml_file))) diff --git a/src/extensions/score_metamodel/tests/test_metamodel_load.py b/src/extensions/score_metamodel/tests/test_metamodel_load.py index 3cb679653..8d8254479 100644 --- a/src/extensions/score_metamodel/tests/test_metamodel_load.py +++ b/src/extensions/score_metamodel/tests/test_metamodel_load.py @@ -25,6 +25,15 @@ def load_model_data(model_file: str) -> str: return f.read() +def test_load_metamodel_data_explicit_path(): + """When an explicit path is given, load_metamodel_data reads that file.""" + explicit_path = MODEL_DIR / "simple_model.yaml" + result = load_metamodel_data(yaml_path=explicit_path) + + assert len(result.needs_types) == 1 + assert result.needs_types[0]["directive"] == "type1" + + def test_load_metamodel_data(): model_data: str = load_model_data("simple_model.yaml") diff --git a/src/extensions/score_metamodel/yaml_parser.py b/src/extensions/score_metamodel/yaml_parser.py index 64916a903..94dd2a8e2 100644 --- a/src/extensions/score_metamodel/yaml_parser.py +++ b/src/extensions/score_metamodel/yaml_parser.py @@ -182,11 +182,16 @@ def _collect_all_custom_options( return sorted(all_options - defaults) -def load_metamodel_data() -> MetaModelData: +def load_metamodel_data(yaml_path: Path | None = None) -> MetaModelData: """ Load metamodel.yaml and prepare data fields as needed for sphinx-needs. + + Args: + yaml_path: Path to the metamodel YAML file. When None, the default + metamodel shipped with this extension is used. """ - yaml_path = Path(__file__).resolve().parent / "metamodel.yaml" + if yaml_path is None: + yaml_path = Path(__file__).resolve().parent / "metamodel.yaml" with open(yaml_path, encoding="utf-8") as f: data = cast(dict[str, Any], YAML().load(f)) diff --git a/src/incremental.py b/src/incremental.py index 1c3816229..d32b4ce4d 100644 --- a/src/incremental.py +++ b/src/incremental.py @@ -84,6 +84,10 @@ def get_env(name: str) -> str: f"--define=external_needs_source={get_env('DATA')}", ] + metamodel_yaml = os.environ.get("SCORE_METAMODEL_YAML", "") + if metamodel_yaml: + base_arguments.append(f"--define=score_metamodel_yaml={metamodel_yaml}") + # configure sphinx build with GitHub user and repo from CLI if args.github_user and args.github_repo: base_arguments.append(f"-A=github_user={args.github_user}")