diff --git a/BUILD b/BUILD index 5cb1d36cb4a..17d760064c1 100644 --- a/BUILD +++ b/BUILD @@ -14,6 +14,7 @@ load("@score_docs_as_code//:docs.bzl", "docs") load("@score_tooling//:defs.bzl", "setup_starpls") load("@score_tooling//third_party/format:macros.bzl", "use_format_targets") +load("//bazel_common:docs_bundles.bzl", "DOCS_BUNDLES") # Alias causing doc build here being independet of what doc-as-code do. # This allows to changge labels of real doc build indepedent of pull_request_target @@ -24,46 +25,14 @@ alias( ) # Docs-as-code +# +# The bundle mounts are generated from known_good.json into +# //bazel_common:docs_bundles.bzl: every module is mounted under its group's section +# (target_sw -> modules/, tooling -> process_methods_tools/) unless it sets +# '"docs": false' there. Change the module list in known_good.json, not here, and +# regenerate with scripts/known_good/update_module_from_known_good.py. docs( - bundles = [ - # Software components - # TODO Remove and replace with generation for know_good.json ! - # "@score_communication//:needs_json", # no docs_sources - { - "bundle": "@score_lifecycle//:docs_bundle", - "mount_at": "modules/score_lifecycle", - }, - { - "bundle": "@score_logging//:docs_bundle", - "mount_at": "modules/score_logging", - }, - { - "bundle": "@score_persistency//:docs_bundle", - "mount_at": "modules/score_persistency", - }, - { - "bundle": "@score_kyron//:docs_bundle", - "mount_at": "modules/score_kyron", - }, - { - "bundle": "@score_baselibs//:docs_bundle", - "mount_at": "modules/score_baselibs", - }, - - # Process methods and tools (PMT) - { - "bundle": "@score_platform//:docs_bundle", - "mount_at": "process_methods_tools/score_platform", - }, - { - "bundle": "@score_process_description//:docs_bundle", - "mount_at": "process_methods_tools/score_process_description", - }, - { - "bundle": "@score_docs_as_code//:docs_bundle", - "mount_at": "process_methods_tools/score_docs_as_code", - }, - ], + bundles = DOCS_BUNDLES, known_good = "known_good.json", source_dir = "docs", ) diff --git a/bazel_common/docs_bundles.bzl b/bazel_common/docs_bundles.bzl new file mode 100644 index 00000000000..3f41d506650 --- /dev/null +++ b/bazel_common/docs_bundles.bzl @@ -0,0 +1,55 @@ +# ******************************************************************************* +# Copyright (c) 2025 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 +# ******************************************************************************* + +# Generated from known_good.json at 2026-08-28T09:19:12Z +# Do not edit manually - use scripts/known_good/update_module_from_known_good.py --known known_good.json --output-dir-modules bazel_common + +# Documentation bundle mounts for the docs() macro in the root BUILD file. +DOCS_BUNDLES = [ + { + "bundle": "@score_baselibs//:docs_bundle", + "mount_at": "modules/score_baselibs", + }, + { + "bundle": "@score_persistency//:docs_bundle", + "mount_at": "modules/score_persistency", + }, + { + "bundle": "@score_kyron//:docs_bundle", + "mount_at": "modules/score_kyron", + }, + { + "bundle": "@score_lifecycle//:docs_bundle", + "mount_at": "modules/score_lifecycle", + }, + { + "bundle": "@score_logging//:docs_bundle", + "mount_at": "modules/score_logging", + }, + { + "bundle": "@score_itf//:docs_bundle", + "mount_at": "process_methods_tools/score_itf", + }, + { + "bundle": "@score_platform//:docs_bundle", + "mount_at": "process_methods_tools/score_platform", + }, + { + "bundle": "@score_docs_as_code//:docs_bundle", + "mount_at": "process_methods_tools/score_docs_as_code", + }, + { + "bundle": "@score_process_description//:docs_bundle", + "mount_at": "process_methods_tools/score_process_description", + }, +] diff --git a/docs/integration_process/checklist.rst b/docs/integration_process/checklist.rst index bee5f6a4249..12e95a34500 100644 --- a/docs/integration_process/checklist.rst +++ b/docs/integration_process/checklist.rst @@ -29,8 +29,8 @@ Use this as a final review before opening your pull request: patches referenced if needed; ``known_good_correct`` and ``bzlmod-lock`` checks green. * - 2 · Documentation - - ``needs_json`` added to ``//:docs`` data and toctree entry in - ``modules.rst``; ``//:docs_combo`` shows the module. + - mounted automatically from ``known_good.json`` (no ``"docs": false``); + ``bazel run //:docs`` shows the module in its section. * - 3 · Unit tests (default platform) - module compiles and unit tests pass on ``--config=linux-x86_64`` via ``quality_runners.py``; ``metadata`` tuned if needed. diff --git a/docs/integration_process/reference.rst b/docs/integration_process/reference.rst index 9b01c4653c6..e23011075e9 100644 --- a/docs/integration_process/reference.rst +++ b/docs/integration_process/reference.rst @@ -119,7 +119,7 @@ The consolidated outputs published by the integration. They are built by - Source / target * - Consolidated documentation - All integrated module docs merged into one Sphinx site. - - `BUILD `_ ``docs(...)`` → ``bazel run //:docs_combo`` + - `BUILD `_ ``docs(...)`` → ``bazel run //:docs`` * - Platform verification report - Per-release requirements/architecture verification, safety analyses and per-test-case results. diff --git a/docs/integration_process/step_2_documentation.rst b/docs/integration_process/step_2_documentation.rst index e8b91fdab8b..44ff4a07abe 100644 --- a/docs/integration_process/step_2_documentation.rst +++ b/docs/integration_process/step_2_documentation.rst @@ -38,75 +38,72 @@ docs build (and therefore CI) fails — so wiring your module in here is what ge its process artifacts continuously checked, not just published. The integration builds one Sphinx site that merges the docs of every integrated -module. To pull yours in: - -#. Add your module's ``needs_json`` to the ``docs(...)`` rule's ``data`` list in - the top-level `BUILD `_ file: - - .. code-block:: python - - docs( - data = [ - "@score_persistency//:needs_json", - # ... - "@score_my_module//:needs_json", - ], - known_good = "known_good.json", - source_dir = "docs", - ) - - This requires your module to expose a ``//:needs_json`` target (i.e. it is a - docs-as-code module). - -#. Add a toctree entry so the module shows up in the navigation. **Which page - you edit depends on what kind of module it is** — the site groups modules - into two top-level sections, each backed by its own ``.rst`` page: - - .. list-table:: - :header-rows: 1 - :widths: 22 30 48 - - * - Section - - Page to edit - - Put your module here if it is… - * - **Modules** - - `docs/modules.rst `_ - - an S-CORE **software module** that ships in the integration — i.e. it - lives under ``modules.target_sw`` in ``known_good.json`` (communication, - persistency, logging, kyron, baselibs, …). This is the common - case. - * - **Process, Methods & Tools** - - `docs/process_methods_tools.rst `_ - - a **tooling / process** repo from ``modules.tooling`` (platform, process, - docs-as-code) rather than a shipped software module. - - For a normal software module, add the entry to the ``Modules`` toctree in - `docs/modules.rst `_, next to the existing - modules: - - .. code-block:: rst - - Modules - ======= - - .. toctree:: - :titlesonly: - :maxdepth: 1 - - _collections/score_persistency/docs/index - _collections/score_logging/docs/index - # ... - _collections/score_my_module/docs/index - - The ``_collections/score_my_module/docs/index`` path is **not** a file in this - repository — the docs build mounts every module's documentation under - ``_collections//`` at build time. So the path must be: - - * ``score_my_module`` — the **module/repository name** as registered in - ``known_good.json`` (the ``@score_my_module`` repo), and - * ``docs/index`` — the path of the **documentation root** *inside that - module's repository* (most modules use ``docs/index``; match whatever your - module actually exposes via its ``needs_json`` docs target). +module, and it does so straight from ``known_good.json``: **a module that is in +the integration has its documentation in the site by default**. There is nothing +to add to the top-level ``BUILD`` file. + +What happens under the hood: a module that calls docs-as-code's ``docs()`` macro +automatically exposes a public ``//:docs_bundle`` target. +``scripts/known_good/update_module_from_known_good.py`` turns every module in +``known_good.json`` into one mount entry in +`bazel_common/docs_bundles.bzl `_, +which the ``docs(bundles = DOCS_BUNDLES, ...)`` call in the top-level `BUILD +`_ file +consumes. **Which of the two site sections your module lands in follows from the +group it sits in** — no extra declaration, and no toctree to edit: the section +pages (``docs/modules/index.rst`` and ``docs/process_methods_tools/index.rst``) +carry empty toctrees that the mounts fill at build time. + +.. list-table:: + :header-rows: 1 + :widths: 26 26 48 + + * - Group in ``known_good.json`` + - Mounted under + - Your module belongs here if it is… + * - ``modules.target_sw`` + - ``modules/`` + - an S-CORE **software module** that ships in the integration + (communication, persistency, logging, kyron, baselibs, …). This is the + common case. + * - ``modules.tooling`` + - ``process_methods_tools/`` + - a **tooling / process** repo (platform, process_description, + docs-as-code, …) rather than a shipped software module. + +So for a normal module you do nothing beyond adding it to ``known_good.json`` +and re-running the generator: + +.. code-block:: bash + + scripts/known_good/update_module_from_known_good.py --known known_good.json \ + --output-dir-modules bazel_common + +Commit the regenerated files together with your ``known_good.json`` change — +the :ref:`ci_checks` regenerate them and fail if they drift. + +Opting a module out +~~~~~~~~~~~~~~~~~~~ + +Some modules expose no ``//:docs_bundle``: they do not call ``docs()`` at all, +or their root package cannot be loaded from the integration's dependency graph. +Mounting those would fail the docs build on a missing target, so they opt out +explicitly with ``"docs": false``: + +.. code-block:: json + + "score_bazel_platforms": { + "repo": "https://github.com/eclipse-score/bazel_platforms.git", + "docs": false, + "hash": "607672cdf2f05f21af7113dab0849726dd59bf86" + } + +Treat ``"docs": false`` as a gap to close, not a normal state — the point of the +default is that documentation and its process checks are opt-out, not opt-in. + +If you need a non-default mount, ``docs`` also accepts an object with +``bundle``, ``mount_at`` and ``attach_to`` keys, which are passed through to the +``docs()`` macro unchanged. Build the full docs locally to verify your module shows up — or use the live-preview server which rebuilds on every change: @@ -114,10 +111,10 @@ live-preview server which rebuilds on every change: .. code-block:: bash # one-shot full build incl. all modules - bazel run //:docs_combo + bazel run //:docs # live preview in the browser (auto-rebuild) - bazel run //:live_preview_combo_experimental + bazel run //:live_preview The docs are built and published by the ``test_and_docs`` workflow (see :ref:`ci_checks`). diff --git a/known_good.json b/known_good.json index 6f17f83e85f..37854499f44 100644 --- a/known_good.json +++ b/known_good.json @@ -33,6 +33,7 @@ }, "score_communication": { "repo": "https://github.com/eclipse-score/communication.git", + "docs": false, "hash": "b22ed6d314313c87eb06ca916efda9a71ec53cc6", "bazel_patches": [ "//patches/communication:001-expose-comm-examples.patch", @@ -138,6 +139,7 @@ }, "score_time": { "repo": "https://github.com/eclipse-score/time.git", + "docs": false, "hash": "c4f0194a3b496757e5c193ed8618fc3f45a868a5", "bazel_patches": [], "metadata": { @@ -152,6 +154,7 @@ }, "score_config_management": { "repo": "https://github.com/eclipse-score/config_management.git", + "docs": false, "hash": "e0489b190ed6d2fa183b9a8063e2dba4f6378505", "bazel_patches": [ "//patches/config_management:001-adapt-proxy-api-and-remove-internal-targets.patch" @@ -182,6 +185,7 @@ "tooling": { "score_crates": { "repo": "https://github.com/eclipse-score/score-crates.git", + "docs": false, "hash": "4656dda8f04a3d88c8089f63111195840cfbd9e3" }, "score_itf": { @@ -190,6 +194,7 @@ }, "score_tooling": { "repo": "https://github.com/eclipse-score/tooling.git", + "docs": false, "bazel_patches": [ "//patches/tooling:001-expose-aspect-rules-lint-non-dev.patch" ], @@ -204,10 +209,12 @@ }, "score_bazel_platforms": { "repo": "https://github.com/eclipse-score/bazel_platforms.git", + "docs": false, "hash": "607672cdf2f05f21af7113dab0849726dd59bf86" }, "score_test_scenarios": { "repo": "https://github.com/eclipse-score/testing_tools.git", + "docs": false, "hash": "fec712d490ff5d46fae91332bafac4777b300026" }, "score_docs_as_code": { @@ -220,6 +227,7 @@ }, "score_bazel_cpp_toolchains": { "repo": "https://github.com/eclipse-score/bazel_cpp_toolchains.git", + "docs": false, "hash": "bd12c5344053f842fae4623dada5716dfb43e61d", "metadata": { "code_root_path": "//score/...", diff --git a/scripts/known_good/models/__init__.py b/scripts/known_good/models/__init__.py index edf7659729d..edb9c5f8152 100644 --- a/scripts/known_good/models/__init__.py +++ b/scripts/known_good/models/__init__.py @@ -12,6 +12,6 @@ # ******************************************************************************* """Models for score reference integration tools.""" -from .module import Module, Metadata +from .module import Docs, Module, Metadata -__all__ = ["Module", "Metadata"] +__all__ = ["Docs", "Module", "Metadata"] diff --git a/scripts/known_good/models/module.py b/scripts/known_good/models/module.py index 72cae75c678..65bb27191d1 100644 --- a/scripts/known_good/models/module.py +++ b/scripts/known_good/models/module.py @@ -20,6 +20,80 @@ from urllib.parse import urlparse +@dataclass +class Docs: + """How a module's documentation bundle is mounted into the ref_int docs site. + + Every module is mounted by default: a module that calls docs-as-code's ``docs()`` + macro automatically exposes a public ``//:docs_bundle``, and the integration wants + that documentation in the combined site. A module that does not expose one (it has + no ``docs()`` call, or its root package cannot be loaded from ref_int's graph) must + opt out with ``"docs": false``, otherwise the docs build fails on a missing target. + + Attributes: + enabled: Whether the module's bundle is mounted (default: True). + bundle: Bundle label. Defaults to ``@//:docs_bundle``. + mount_at: Site path to mount at. Defaults to ``
/``, + where the section is derived from the module's known_good group. + attach_to: Optional document to attach the bundle to; passed through to + ``docs()``, which defaults it to the mount_at parent's index. + """ + + enabled: bool = True + bundle: str | None = None + mount_at: str | None = None + attach_to: str | None = None + + @classmethod + def from_value(cls, value: Any) -> Docs: + """Create a Docs instance from a known_good.json ``docs`` value. + + Accepts the key being absent (``None``), a bool, or a dict of overrides: + ``{"bundle": ..., "mount_at": ..., "attach_to": ...}``. + + Args: + value: Raw value of the module's ``docs`` key. + + Returns: + Docs instance + """ + if value is None or value is True: + return cls() + if value is False: + return cls(enabled=False) + if isinstance(value, dict): + unknown = set(value) - {"bundle", "mount_at", "attach_to"} + if unknown: + raise ValueError(f"Unknown keys in 'docs': {', '.join(sorted(unknown))}") + return cls( + enabled=True, + bundle=value.get("bundle"), + mount_at=value.get("mount_at"), + attach_to=value.get("attach_to"), + ) + raise ValueError(f"Invalid 'docs' value {value!r} (expected false, true or an object)") + + def to_value(self) -> Any: + """Convert to the known_good.json ``docs`` value, or None when it is the default. + + Returns: + False when disabled, a dict of overrides when any is set, else None so + the key is omitted for the (default) plain-mounted case. + """ + if not self.enabled: + return False + overrides = { + key: value + for key, value in ( + ("bundle", self.bundle), + ("mount_at", self.mount_at), + ("attach_to", self.attach_to), + ) + if value is not None + } + return overrides or None + + @dataclass class Metadata: """Metadata configuration for a module. @@ -80,6 +154,7 @@ class Module: metadata: Metadata = field(default_factory=Metadata) branch: str = "main" pin_version: bool = False + docs: Docs = field(default_factory=Docs) @classmethod def from_dict(cls, name: str, module_data: Dict[str, Any]) -> Module: @@ -103,6 +178,9 @@ def from_dict(cls, name: str, module_data: Dict[str, Any]) -> Module: - branch (str, optional): Git branch name (default: main) - pin_version (bool, optional): If true, module hash is not updated to latest HEAD by update scripts (default: false) + - docs (bool | dict, optional): Documentation mount for the combined + docs site. Mounted by default; use false for a + module that exposes no //:docs_bundle. See Docs. Returns: Module instance @@ -137,6 +215,11 @@ def from_dict(cls, name: str, module_data: Dict[str, Any]) -> Module: branch = module_data.get("branch", "main") pin_version = module_data.get("pin_version", False) + try: + docs = Docs.from_value(module_data.get("docs")) + except ValueError as e: + raise ValueError(f"Module '{name}': {e}") from None + return cls( name=name, hash=commit_hash, @@ -146,6 +229,7 @@ def from_dict(cls, name: str, module_data: Dict[str, Any]) -> Module: metadata=metadata, branch=branch, pin_version=pin_version, + docs=docs, ) @classmethod @@ -208,4 +292,7 @@ def to_dict(self) -> Dict[str, Any]: result["branch"] = self.branch if self.pin_version: result["pin_version"] = True + docs = self.docs.to_value() + if docs is not None: + result["docs"] = docs return result diff --git a/scripts/known_good/tests/test_docs_bundles.py b/scripts/known_good/tests/test_docs_bundles.py new file mode 100644 index 00000000000..38ba3b80109 --- /dev/null +++ b/scripts/known_good/tests/test_docs_bundles.py @@ -0,0 +1,193 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Unit tests for generating the docs() bundle mounts from known_good.json. + +Self-contained: builds KnownGood objects in memory. Needs no Bazel, git or network. +""" + +import sys +from pathlib import Path + +import pytest + +# Make scripts/ importable so known_good.* package resolves when run via plain pytest. +_SCRIPTS_DIR = Path(__file__).resolve().parents[2] +if str(_SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPTS_DIR)) + +from known_good.models.known_good import KnownGood # noqa: E402 +from known_good.models.module import Docs, Module # noqa: E402 +from known_good.update_module_from_known_good import ( # noqa: E402 + generate_docs_bundles_content, +) + + +def _known_good(groups: dict[str, list[Module]]) -> KnownGood: + return KnownGood( + modules={group: {m.name: m for m in modules} for group, modules in groups.items()}, + timestamp="2026-01-01T00:00:00Z", + ) + + +def _module(name: str, **kwargs) -> Module: + return Module(name=name, hash="0" * 40, repo=f"https://github.com/eclipse-score/{name}.git", **kwargs) + + +def test_group_decides_the_section(): + """target_sw mounts under modules/, tooling under process_methods_tools/.""" + content = generate_docs_bundles_content( + _known_good({"target_sw": [_module("score_logging")], "tooling": [_module("score_platform")]}) + ) + + assert '"bundle": "@score_logging//:docs_bundle",' in content + assert '"mount_at": "modules/score_logging",' in content + assert '"mount_at": "process_methods_tools/score_platform",' in content + + +def test_docs_are_mounted_by_default(): + """A module that says nothing about docs is mounted: that is the common case.""" + content = generate_docs_bundles_content(_known_good({"target_sw": [_module("score_baselibs")]})) + + assert '"mount_at": "modules/score_baselibs",' in content + + +def test_docs_false_opts_a_module_out(): + """A module exposing no //:docs_bundle must be excludable, or the docs build breaks.""" + content = generate_docs_bundles_content( + _known_good( + { + "target_sw": [ + _module("score_communication", docs=Docs(enabled=False)), + _module("score_logging"), + ] + } + ) + ) + + assert "score_communication" not in content + assert '"mount_at": "modules/score_logging",' in content + + +def test_overrides_are_passed_through(): + """bundle, mount_at and attach_to override the defaults.""" + content = generate_docs_bundles_content( + _known_good( + { + "target_sw": [ + _module( + "score_kyron", + docs=Docs(bundle="@score_kyron//docs:bundle", mount_at="modules/kyron", attach_to="index"), + ) + ] + } + ) + ) + + assert '"bundle": "@score_kyron//docs:bundle",' in content + assert '"mount_at": "modules/kyron",' in content + assert '"attach_to": "index",' in content + + +def test_attach_to_is_omitted_when_unset(): + """docs() defaults attach_to to the mount parent's index; do not emit an empty one.""" + content = generate_docs_bundles_content(_known_good({"target_sw": [_module("score_logging")]})) + + assert "attach_to" not in content + + +def test_unmapped_group_with_docs_fails(): + """A new group needs a docs section; silently dropping its modules would hide docs.""" + with pytest.raises(SystemExit, match="no docs section"): + generate_docs_bundles_content(_known_good({"new_group": [_module("score_new")]})) + + +def test_unmapped_group_is_fine_when_opted_out(): + """A group of modules that all opt out needs no section.""" + content = generate_docs_bundles_content( + _known_good( + { + "target_sw": [_module("score_logging")], + "new_group": [_module("score_new", docs=Docs(enabled=False))], + } + ) + ) + + assert "score_new" not in content + + +def test_duplicate_mount_fails(): + """Two bundles at one mount point would silently shadow each other.""" + with pytest.raises(SystemExit, match="both mount their docs at"): + generate_docs_bundles_content( + _known_good( + { + "target_sw": [ + _module("score_logging"), + _module("score_other", docs=Docs(mount_at="modules/score_logging")), + ] + } + ) + ) + + +def test_no_mounts_at_all_fails(): + """An empty DOCS_BUNDLES means a docs site with no module documentation in it.""" + with pytest.raises(SystemExit, match="No modules to mount"): + generate_docs_bundles_content(_known_good({"target_sw": [_module("score_logging", docs=Docs(enabled=False))]})) + + +def test_generated_banner_names_the_generator(): + """The file is generated; a reader who edits it by hand must be told not to.""" + content = generate_docs_bundles_content( + _known_good({"target_sw": [_module("score_logging")]}), "2026-01-01T00:00:00Z" + ) + + assert "# Generated from known_good.json at 2026-01-01T00:00:00Z" in content + assert "Do not edit manually" in content + assert "SPDX-License-Identifier: Apache-2.0" in content + + +@pytest.mark.parametrize( + ("value", "enabled"), + [(None, True), (True, True), (False, False), ({}, True), ({"mount_at": "modules/x"}, True)], +) +def test_docs_parsed_from_known_good_values(value, enabled): + """known_good.json may omit docs, or give a bool or a dict of overrides.""" + assert Module.from_dict("score_x", {"repo": "r", "hash": "h", "docs": value}).docs.enabled == enabled + + +def test_docs_key_absent_means_enabled(): + assert Module.from_dict("score_x", {"repo": "r", "hash": "h"}).docs.enabled + + +def test_invalid_docs_value_is_rejected(): + with pytest.raises(ValueError, match="Invalid 'docs' value"): + Module.from_dict("score_x", {"repo": "r", "hash": "h", "docs": "yes"}) + + +def test_unknown_docs_key_is_rejected(): + """A typo like 'mount' instead of 'mount_at' would otherwise be silently ignored.""" + with pytest.raises(ValueError, match="Unknown keys in 'docs': mount"): + Module.from_dict("score_x", {"repo": "r", "hash": "h", "docs": {"mount": "modules/x"}}) + + +@pytest.mark.parametrize("value", [None, False, {"mount_at": "modules/x"}]) +def test_docs_survives_a_round_trip(value): + """Scripts that rewrite known_good.json must not drop or invent a docs entry.""" + data = {"repo": "r", "hash": "h"} + if value is not None: + data["docs"] = value + + round_tripped = Module.from_dict("score_x", data).to_dict() + + assert round_tripped.get("docs") == value diff --git a/scripts/known_good/update_module_from_known_good.py b/scripts/known_good/update_module_from_known_good.py index b6ae9282ef7..555ffa3ea11 100755 --- a/scripts/known_good/update_module_from_known_good.py +++ b/scripts/known_good/update_module_from_known_good.py @@ -14,13 +14,16 @@ """ Read a known_good.json file and generate a score_modules.MODULE.bazel file with `bazel_dep` and `git_override` calls for each module in the JSON. -It generates also rust_coverage/BUILD file with `rust_coverage_report` for each module with rust impl. +It generates also rust_coverage/BUILD file with `rust_coverage_report` for each module with rust impl, +and bazel_common/docs_bundles.bzl with the documentation bundle mounts consumed by the +`docs()` macro in the root BUILD file. Usage: python3 scripts/known_good/update_module_from_known_good.py \ --known known_good.json \ --output-dir-modules bazel_common \ - --output-dir-coverage rust_coverage + --output-dir-coverage rust_coverage \ + --output-dir-docs-bundles bazel_common The generated score_modules_NAME_.MODULE.bazel file is included by MODULE.bazel. @@ -32,15 +35,35 @@ import logging import os import re +import sys from pathlib import Path from typing import Dict, List, Optional -from models import Module -from models.known_good import load_known_good +# Importable both as a package module (from the tests and the py_library) and runnable as a +# plain script from the repo root, which is how CI invokes it. Same fallback as +# resolved_dependencies.py. +try: + from known_good.models import Module + from known_good.models.known_good import KnownGood, load_known_good +except ImportError: + _HERE = str(Path(__file__).resolve().parent) + if _HERE not in sys.path: + sys.path.insert(0, _HERE) + from models import Module # noqa: E402 + from models.known_good import KnownGood, load_known_good # noqa: E402 # Configure logging logging.basicConfig(level=logging.WARNING, format="%(levelname)s: %(message)s") +# Where each known_good group's documentation is mounted in the combined docs site. The two +# sections are a property of ref_int's site structure (docs/modules/index.rst and +# docs/process_methods_tools/index.rst, whose toctrees are filled from these mounts at build +# time), not of the module set, so the mapping lives here rather than in known_good.json. +DOCS_SECTION_BY_GROUP = { + "target_sw": "modules", + "tooling": "process_methods_tools", +} + def generate_git_override_blocks(modules: List[Module], repo_commit_dict: Dict[str, str]) -> List[str]: """Generate bazel_dep and git_override blocks for each module.""" @@ -159,6 +182,93 @@ def generate_coverage_blocks(modules: List[Module]) -> List[str]: return blocks +LICENSE_HEADER = ( + "# *******************************************************************************\n" + "# Copyright (c) 2025 Contributors to the Eclipse Foundation\n" + "#\n" + "# See the NOTICE file(s) distributed with this work for additional\n" + "# information regarding copyright ownership.\n" + "#\n" + "# This program and the accompanying materials are made available under the\n" + "# terms of the Apache License Version 2.0 which is available at\n" + "# https://www.apache.org/licenses/LICENSE-2.0\n" + "#\n" + "# SPDX-License-Identifier: Apache-2.0\n" + "# *******************************************************************************\n" + "\n" +) + + +def generated_banner(timestamp: str) -> str: + """Return the 'do not edit' banner naming the generator, for a generated file.""" + return ( + f"# Generated from known_good.json at {timestamp}\n" + "# Do not edit manually - use scripts/known_good/update_module_from_known_good.py" + " --known known_good.json --output-dir-modules bazel_common\n" + "\n" + ) + + +def generate_docs_bundles_content(known_good: KnownGood, timestamp: Optional[str] = None) -> str: + """Generate docs_bundles.bzl: the docs() bundle mounts for the combined docs site. + + Unlike the other generated artifacts this one spans every group, since ref_int builds a + single Sphinx site: each group's modules mount under that group's section (see + DOCS_SECTION_BY_GROUP). Modules are mounted unless they opt out with ``"docs": false``. + + Args: + known_good: Parsed known_good.json. + timestamp: known_good.json timestamp, for the generated banner. + + Returns: + Content of docs_bundles.bzl + """ + entries: List[str] = [] + mounted_by: Dict[str, str] = {} + + for group_name, group_modules in known_good.modules.items(): + section = DOCS_SECTION_BY_GROUP.get(group_name) + + for module in group_modules.values(): + if not module.docs.enabled: + continue + + if section is None: + raise SystemExit( + f"Module {module.name} is in group '{group_name}', which has no docs section. " + f"Add it to DOCS_SECTION_BY_GROUP in {Path(__file__).name} (alongside a matching " + f"docs/
/index.rst), or set '\"docs\": false' for the group's modules." + ) + + bundle = module.docs.bundle or f"@{module.name}//:docs_bundle" + mount_at = module.docs.mount_at or f"{section}/{module.name}" + + if mount_at in mounted_by: + raise SystemExit( + f"Modules {mounted_by[mount_at]} and {module.name} both mount their docs at " + f"'{mount_at}'. Give one an explicit 'docs.mount_at'." + ) + mounted_by[mount_at] = module.name + + lines = [" {", f' "bundle": "{bundle}",', f' "mount_at": "{mount_at}",'] + if module.docs.attach_to: + lines.append(f' "attach_to": "{module.docs.attach_to}",') + lines.append(" },") + entries.append("\n".join(lines) + "\n") + + if not entries: + raise SystemExit("No modules to mount: every module in known_good.json has 'docs': false") + + header = LICENSE_HEADER + if timestamp: + header += generated_banner(timestamp) + + return ( + header + "# Documentation bundle mounts for the docs() macro in the root BUILD file.\n" + "DOCS_BUNDLES = [\n" + "".join(entries) + "]\n" + ) + + def generate_file_content( args: argparse.Namespace, modules: List[Module], @@ -167,30 +277,10 @@ def generate_file_content( file_type: str = "module", ) -> str: """Generate the complete content for score_modules.MODULE.bazel.""" - # License header - header = ( - "# *******************************************************************************\n" - "# Copyright (c) 2025 Contributors to the Eclipse Foundation\n" - "#\n" - "# See the NOTICE file(s) distributed with this work for additional\n" - "# information regarding copyright ownership.\n" - "#\n" - "# This program and the accompanying materials are made available under the\n" - "# terms of the Apache License Version 2.0 which is available at\n" - "# https://www.apache.org/licenses/LICENSE-2.0\n" - "#\n" - "# SPDX-License-Identifier: Apache-2.0\n" - "# *******************************************************************************\n" - "\n" - ) + header = LICENSE_HEADER if timestamp: - header += ( - f"# Generated from known_good.json at {timestamp}\n" - "# Do not edit manually - use scripts/known_good/update_module_from_known_good.py" - " --known known_good.json --output-dir-modules bazel_common\n" - "\n" - ) + header += generated_banner(timestamp) if file_type == "module": if args.override_type == "git": blocks = generate_git_override_blocks(modules, repo_commit_dict) @@ -230,6 +320,7 @@ def main() -> None: Note: - Generates score_modules_{group}.MODULE.bazel for each group + - Generates docs_bundles.bzl once, covering every group's documentation mounts - To override repository commits, use scripts/known_good/override_known_good_repo.py first. """, ) @@ -248,6 +339,11 @@ def main() -> None: default=Path(__file__).parents[2] / "rust_coverage", help="Output directory for BUILD coverage file (default: rust_coverage in repo root)", ) + parser.add_argument( + "--output-dir-docs-bundles", + default=Path(__file__).parents[2] / "bazel_common", + help="Output directory for docs_bundles.bzl (default: bazel_common in repo root)", + ) parser.add_argument( "--dry-run", action="store_true", @@ -356,6 +452,22 @@ def main() -> None: generated_files.append(output_path_coverage) print(f"Generated {output_path_coverage}") + # Docs bundle mounts span every group (one Sphinx site), so they are generated once, after + # the per-group loop above. + content_docs_bundles = generate_docs_bundles_content(known_good, known_good.timestamp) + output_path_docs_bundles = Path(args.output_dir_docs_bundles) / "docs_bundles.bzl" + + if args.dry_run: + print(f"\nDry run: would write to {output_path_docs_bundles}\n") + print("---- BEGIN GENERATED CONTENT FOR DOCS BUNDLES ----") + print(content_docs_bundles, end="") + print("---- END GENERATED CONTENT FOR DOCS BUNDLES ----") + else: + with open(output_path_docs_bundles, "w", encoding="utf-8") as f: + f.write(content_docs_bundles) + generated_files.append(str(output_path_docs_bundles)) + print(f"Generated {output_path_docs_bundles}") + if not args.dry_run and generated_files: print(f"\nSuccessfully generated {len(generated_files)} file(s) with {total_module_count} total modules") diff --git a/scripts/tooling/lib/known_good/module.py b/scripts/tooling/lib/known_good/module.py index c9c562ece05..fd42a6cfa1d 100644 --- a/scripts/tooling/lib/known_good/module.py +++ b/scripts/tooling/lib/known_good/module.py @@ -60,6 +60,12 @@ class Module: branch: str = "main" pin_version: bool = False + # Documentation mount for the combined docs site. Absent/True means mounted with + # defaults; False opts a module out; a dict carries bundle/mount_at/attach_to + # overrides. Kept verbatim so a round-trip through this copy of the model does not + # drop what //scripts/known_good generates from it. + docs: bool | Dict[str, Any] | None = None + @classmethod def from_dict(cls, name: str, data: Dict[str, Any]) -> Module: repo = data.get("repo", "") @@ -86,6 +92,7 @@ def from_dict(cls, name: str, data: Dict[str, Any]) -> Module: metadata=metadata, branch=data.get("branch", "main"), pin_version=data.get("pin_version", False), + docs=data.get("docs"), ) @classmethod @@ -124,4 +131,6 @@ def to_dict(self) -> Dict[str, Any]: result["branch"] = self.branch if self.pin_version: result["pin_version"] = True + if self.docs is not None: + result["docs"] = self.docs return result