Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 27 additions & 17 deletions docs.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -130,13 +130,23 @@ 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()

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 + [
Expand All @@ -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,
)

Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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"],
)

Expand Down
6 changes: 4 additions & 2 deletions docs/how-to/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 23 additions & 0 deletions docs/reference/bazel_macros.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 </internals/extensions/metamodel>`).
When ``metamodel`` is omitted the default metamodel is used unchanged.

Edge cases
----------

Expand Down
5 changes: 4 additions & 1 deletion src/extensions/score_metamodel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions src/extensions/score_metamodel/tests/test_metamodel__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)))
9 changes: 9 additions & 0 deletions src/extensions/score_metamodel/tests/test_metamodel_load.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
9 changes: 7 additions & 2 deletions src/extensions/score_metamodel/yaml_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
4 changes: 4 additions & 0 deletions src/incremental.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
Loading