Skip to content

Commit 783a00e

Browse files
committed
Allow to configure a different metamodel
Fixes #415
1 parent ac7b065 commit 783a00e

8 files changed

Lines changed: 174 additions & 22 deletions

File tree

docs.bzl

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ def _missing_requirements(deps):
126126
fail(msg)
127127
fail("This case should be unreachable?!")
128128

129-
def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = None):
129+
def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = None, metamodel = None):
130130
"""Creates all targets related to documentation.
131131
132132
By using this function, you'll get any and all updates for documentation targets in one place.
@@ -136,13 +136,24 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good =
136136
data: Additional data files to include in the documentation build.
137137
deps: Additional dependencies for the documentation build.
138138
scan_code: List of code targets to scan for source code links.
139+
known_good: Optional label to a "known good" JSON file for source links.
140+
metamodel: Optional label to a metamodel.yaml file. When set, the extension loads this
141+
file instead of the default metamodel shipped with score_metamodel.
139142
"""
140143

141144
call_path = native.package_name()
142145

143146
if call_path != "":
144147
fail("docs() must be called from the root package. Current package: " + call_path)
145148

149+
metamodel_data = []
150+
metamodel_env = {}
151+
metamodel_opts = []
152+
if metamodel != None:
153+
metamodel_data = [metamodel]
154+
metamodel_env = {"SCORE_METAMODEL_YAML": "$(location " + str(metamodel) + ")"}
155+
metamodel_opts = ["--define=score_metamodel_yaml=$(location " + str(metamodel) + ")"]
156+
146157
module_deps = deps
147158
deps = deps + _missing_requirements(deps)
148159
deps = deps + [
@@ -153,7 +164,7 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good =
153164
sphinx_build_binary(
154165
name = "sphinx_build",
155166
visibility = ["//visibility:private"],
156-
data = data,
167+
data = data + metamodel_data,
157168
deps = deps,
158169
)
159170

@@ -207,19 +218,29 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good =
207218
name = "docs",
208219
tags = ["cli_help=Build documentation:\nbazel run //:docs"],
209220
srcs = ["@score_docs_as_code//src:incremental.py"],
210-
data = docs_data,
221+
data = data + [":sourcelinks_json"] + metamodel_data,
211222
deps = deps,
212-
env = docs_env
223+
env = {
224+
"SOURCE_DIRECTORY": source_dir,
225+
"DATA": str(data),
226+
"ACTION": "incremental",
227+
"SCORE_SOURCELINKS": "$(location :sourcelinks_json)",
228+
} | metamodel_env,
213229
)
214230

215231
docs_sources_env["ACTION"] = "incremental"
216232
py_binary(
217233
name = "docs_combo",
218234
tags = ["cli_help=Build full documentation with all dependencies:\nbazel run //:docs_combo"],
219235
srcs = ["@score_docs_as_code//src:incremental.py"],
220-
data = combo_data,
236+
data = data_with_docs_sources + [":merged_sourcelinks"] + metamodel_data,
221237
deps = deps,
222-
env = docs_sources_env
238+
env = {
239+
"SOURCE_DIRECTORY": source_dir,
240+
"DATA": str(data_with_docs_sources),
241+
"ACTION": "incremental",
242+
"SCORE_SOURCELINKS": "$(location :merged_sourcelinks)",
243+
} | metamodel_env,
223244
)
224245

225246
native.alias(
@@ -233,47 +254,67 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good =
233254
name = "docs_link_check",
234255
tags = ["cli_help=Verify Links inside Documentation:\nbazel run //:link_check\n (Note: this could take a long time)"],
235256
srcs = ["@score_docs_as_code//src:incremental.py"],
236-
data = docs_data,
257+
data = data + metamodel_data,
237258
deps = deps,
238-
env = docs_env
259+
env = {
260+
"SOURCE_DIRECTORY": source_dir,
261+
"DATA": str(data),
262+
"ACTION": "linkcheck",
263+
} | metamodel_env,
239264
)
240265

241266
docs_env["ACTION"] = "check"
242267
py_binary(
243268
name = "docs_check",
244269
tags = ["cli_help=Verify documentation:\nbazel run //:docs_check"],
245270
srcs = ["@score_docs_as_code//src:incremental.py"],
246-
data = docs_data,
271+
data = data + [":sourcelinks_json"] + metamodel_data,
247272
deps = deps,
248-
env = docs_env
273+
env = {
274+
"SOURCE_DIRECTORY": source_dir,
275+
"DATA": str(data),
276+
"ACTION": "check",
277+
"SCORE_SOURCELINKS": "$(location :sourcelinks_json)",
278+
} | metamodel_env,
249279
)
250280

251281
docs_env["ACTION"] = "live_preview"
252282
py_binary(
253283
name = "live_preview",
254284
tags = ["cli_help=Live preview documentation in the browser:\nbazel run //:live_preview"],
255285
srcs = ["@score_docs_as_code//src:incremental.py"],
256-
data = docs_data,
286+
data = data + [":sourcelinks_json"] + metamodel_data,
257287
deps = deps,
258-
env = docs_env
288+
env = {
289+
"SOURCE_DIRECTORY": source_dir,
290+
"DATA": str(data),
291+
"ACTION": "live_preview",
292+
"SCORE_SOURCELINKS": "$(location :sourcelinks_json)",
293+
} | metamodel_env,
259294
)
260295

261296
docs_sources_env["ACTION"] = "live_preview"
262297
py_binary(
263298
name = "live_preview_combo_experimental",
264299
tags = ["cli_help=Live preview full documentation with all dependencies in the browser:\nbazel run //:live_preview_combo_experimental"],
265300
srcs = ["@score_docs_as_code//src:incremental.py"],
266-
data = combo_data,
301+
data = data_with_docs_sources + [":merged_sourcelinks"] + metamodel_data,
267302
deps = deps,
268-
env = docs_sources_env
303+
env = {
304+
"SOURCE_DIRECTORY": source_dir,
305+
"DATA": str(data_with_docs_sources),
306+
"ACTION": "live_preview",
307+
"SCORE_SOURCELINKS": "$(location :merged_sourcelinks)",
308+
} | metamodel_env,
269309
)
270310

271311
py_venv(
272312
name = "ide_support",
273313
tags = ["cli_help=Create virtual environment (.venv_docs) for documentation support:\nbazel run //:ide_support"],
274314
venv_name = ".venv_docs",
275315
deps = deps,
276-
data = data,
316+
# Add dependencies to ide_support, so esbonio has access to them.
317+
data = data + metamodel_data,
277318
package_collisions = "warning",
278319
)
279320

@@ -288,10 +329,10 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good =
288329
"--jobs",
289330
"auto",
290331
"--define=external_needs_source=" + str(data),
291-
],
332+
] + metamodel_opts,
292333
formats = ["needs"],
293334
sphinx = ":sphinx_build",
294-
tools = data,
335+
tools = data + metamodel_data,
295336
visibility = ["//visibility:public"],
296337
# Persistent workers cause stale symlinks after dependency version
297338
# changes, corrupting the Bazel cache.

docs/how-to/setup.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,10 @@ The `docs()` macro accepts the following arguments:
6868
| Parameter | Description | Required |
6969
|-----------|-------------|----------|
7070
| `source_dir` | Directory of documentation source files (RST, MD) | Yes |
71-
| `data` | List of `needs_json` targets that should be included in the documentation| No |
72-
71+
| `data` | List of `needs_json` targets that should be included in the documentation | No |
72+
| `deps` | Additional Bazel Python dependencies | No |
73+
| `scan_code` | Source code targets to scan for traceability tags | No |
74+
| `metamodel` | Label to a custom `metamodel.yaml` that replaces the default metamodel | No |
7375

7476
### 4. Copy conf.py
7577

docs/reference/bazel_macros.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,29 @@ Minimal example (root ``BUILD``)
6060
If you don't provide the necessary Sphinx packages,
6161
this function adds its own (but checks for conflicts).
6262

63+
- ``scan_code`` (list of bazel labels)
64+
Source code targets to scan for traceability tags (``req-Id:`` annotations).
65+
Used to generate the source-code-link JSON that maps tags back to source files.
66+
67+
- ``metamodel`` (bazel label, optional)
68+
Path to a custom ``metamodel.yaml`` file.
69+
When set, the ``score_metamodel`` extension loads **this file instead of** the default metamodel.
70+
The label is automatically added to the ``data`` and ``tools`` of every generated target
71+
so the file is available in the Bazel sandbox at build time.
72+
73+
Example:
74+
75+
.. code-block:: python
76+
77+
docs(
78+
source_dir = "docs",
79+
metamodel = "//:my_metamodel.yaml",
80+
)
81+
82+
The custom ``metamodel.yaml`` must follow the same schema as the default one
83+
(see :doc:`score_metamodel </internals/extensions/metamodel>`).
84+
When ``metamodel`` is omitted the default metamodel is used unchanged.
85+
6386
Edge cases
6487
----------
6588

src/extensions/score_metamodel/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,11 +228,14 @@ def postprocess_need_links(needs_types_list: list[ScoreNeedType]):
228228

229229
def setup(app: Sphinx) -> dict[str, str | bool]:
230230
app.add_config_value("external_needs_source", "", rebuild="env")
231+
app.add_config_value("score_metamodel_yaml", "", rebuild="env")
231232
config_setdefault(app.config, "needs_id_required", True)
232233
config_setdefault(app.config, "needs_id_regex", "^[A-Za-z0-9_-]{6,}")
233234

234235
# load metamodel.yaml via ruamel.yaml
235-
metamodel = load_metamodel_data()
236+
raw_metamodel_path = app.config.score_metamodel_yaml
237+
override_path = Path(raw_metamodel_path) if raw_metamodel_path else None
238+
metamodel = load_metamodel_data(override_path)
236239

237240
# Extend sphinx-needs config rather than overwriting
238241
app.config.needs_types += metamodel.needs_types

src/extensions/score_metamodel/tests/test_metamodel__init__.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
#
1111
# SPDX-License-Identifier: Apache-2.0
1212
# *******************************************************************************
13+
import sys
14+
from pathlib import Path
15+
from unittest.mock import MagicMock, patch
16+
1317
import pytest
1418
from attribute_plugin import add_test_properties # type: ignore[import-untyped]
1519
from sphinx.application import Sphinx
@@ -21,9 +25,15 @@
2125
graph_checks,
2226
local_checks,
2327
parse_checks_filter,
28+
setup,
2429
)
2530
from src.extensions.score_metamodel.tests import need
2631

32+
# The module where setup() looks up load_metamodel_data at runtime.
33+
# Using __globals__ avoids breakage when the same file is imported under
34+
# two different names (e.g. "score_metamodel" and "src.extensions.score_metamodel").
35+
_setup_module = sys.modules[setup.__module__]
36+
2737

2838
def dummy_local_check(app: Sphinx, need: NeedItem, log: CheckLogger) -> None:
2939
pass
@@ -176,3 +186,58 @@ def test_combined_core_links_and_extras(self):
176186
assert n.get("output", []) == ["output_wp"]
177187
# Extra fields
178188
assert n.get("custom_attr") == "custom_value"
189+
190+
191+
# =============================================================================
192+
# Tests for setup() — score_metamodel_yaml config value wiring
193+
# =============================================================================
194+
195+
196+
def _make_mock_app(metamodel_yaml_value: str = "") -> MagicMock:
197+
"""Return a minimal mock Sphinx app suitable for calling setup()."""
198+
app = MagicMock()
199+
app.config.score_metamodel_yaml = metamodel_yaml_value
200+
app.config.needs_types = []
201+
app.config.needs_extra_links = []
202+
app.config.needs_extra_options = []
203+
return app
204+
205+
206+
def _mock_metamodel_return():
207+
return MagicMock(
208+
needs_types=[],
209+
needs_extra_links=[],
210+
needs_extra_options=[],
211+
needs_graph_check={},
212+
prohibited_words_checks=[],
213+
)
214+
215+
216+
def test_setup_uses_default_path_when_config_empty():
217+
"""setup() calls load_metamodel_data(None) when score_metamodel_yaml is empty."""
218+
app = _make_mock_app(metamodel_yaml_value="")
219+
220+
with patch.object(
221+
_setup_module,
222+
"load_metamodel_data",
223+
return_value=_mock_metamodel_return(),
224+
) as mock_load:
225+
setup(app)
226+
227+
mock_load.assert_called_once_with(None)
228+
229+
230+
def test_setup_passes_path_when_config_set(tmp_path):
231+
"""setup() calls load_metamodel_data(Path(...)) when score_metamodel_yaml is set."""
232+
yaml_file = tmp_path / "custom_metamodel.yaml"
233+
yaml_file.write_text("needs_types: {}\n")
234+
app = _make_mock_app(metamodel_yaml_value=str(yaml_file))
235+
236+
with patch.object(
237+
_setup_module,
238+
"load_metamodel_data",
239+
return_value=_mock_metamodel_return(),
240+
) as mock_load:
241+
setup(app)
242+
243+
mock_load.assert_called_once_with(Path(str(yaml_file)))

src/extensions/score_metamodel/tests/test_metamodel_load.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@ def load_model_data(model_file: str) -> str:
2525
return f.read()
2626

2727

28+
def test_load_metamodel_data_explicit_path():
29+
"""When an explicit path is given, load_metamodel_data reads that file."""
30+
explicit_path = MODEL_DIR / "simple_model.yaml"
31+
result = load_metamodel_data(yaml_path=explicit_path)
32+
33+
assert len(result.needs_types) == 1
34+
assert result.needs_types[0]["directive"] == "type1"
35+
36+
2837
def test_load_metamodel_data():
2938
model_data: str = load_model_data("simple_model.yaml")
3039

src/extensions/score_metamodel/yaml_parser.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,11 +182,16 @@ def _collect_all_custom_options(
182182
}
183183

184184

185-
def load_metamodel_data() -> MetaModelData:
185+
def load_metamodel_data(yaml_path: Path | None = None) -> MetaModelData:
186186
"""
187187
Load metamodel.yaml and prepare data fields as needed for sphinx-needs.
188+
189+
Args:
190+
yaml_path: Path to the metamodel YAML file. When None, the default
191+
metamodel shipped with this extension is used.
188192
"""
189-
yaml_path = Path(__file__).resolve().parent / "metamodel.yaml"
193+
if yaml_path is None:
194+
yaml_path = Path(__file__).resolve().parent / "metamodel.yaml"
190195

191196
with open(yaml_path, encoding="utf-8") as f:
192197
data = cast(dict[str, Any], YAML().load(f))

src/incremental.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ def get_env(name: str) -> str:
8484
f"--define=external_needs_source={get_env('DATA')}",
8585
]
8686

87+
metamodel_yaml = os.environ.get("SCORE_METAMODEL_YAML", "")
88+
if metamodel_yaml:
89+
base_arguments.append(f"--define=score_metamodel_yaml={metamodel_yaml}")
90+
8791
# configure sphinx build with GitHub user and repo from CLI
8892
if args.github_user and args.github_repo:
8993
base_arguments.append(f"-A=github_user={args.github_user}")

0 commit comments

Comments
 (0)