From ced7e0500088875d6faa136cfb17e44f02383a1c Mon Sep 17 00:00:00 2001 From: Marco Heinemann Date: Fri, 22 May 2026 18:27:42 +0200 Subject: [PATCH] feat: integrate sphinx-mounts for external source bundles Wires the sphinx-mounts extension into the docs() Bazel macro so RST or Markdown content that lives outside docs/ (generated under bazel-bin, or in-repo next to its source code) can be surfaced in the Sphinx build without copying or symlinking. Files stay where they live and are visible to Sphinx via absolute paths registered through TOML. Why the TOML angle matters: ubCode (and any non-Python tool) reads ubproject.toml to discover the project. With mount entries in the host TOML and a sanitized ubproject.toml at each in-repo bundle's source root, the IDE resolves both the host and the bundle without ever invoking Sphinx -- giving as-you-type validation that a Sphinx-mediated workflow cannot match. This is an alternative to the materialize-a-tree approach; both can coexist, but the mount surface is lighter-weight and IDE-friendly. Surface added ------------- * mount(label, mount_at, attach_to=None, entry_doc="index", src_root=None) helper in docs.bzl. Mount entries pass through env MOUNTS as JSON, symmetric to data = [...] / external_needs_source. * files_to_dir Starlark rule that materialises a glob of files into a single declared-directory output under bazel-bin (the shape that sphinx-mounts' dir mode walks). * score_mounts Sphinx extension that: - parses MOUNTS, resolves runfile paths, - sets config.mounts in-memory for sphinx-mounts at build time, - emits a [[mounts]] fragment with portable confdir-relative paths pointing at the *real source location* (not the bazel-bin copy), so IDE jump-to-definition lands on the file the author wrote, - generates a sanitized per-bundle ubproject.toml at each in-repo bundle's src_root for ubCode's directory-walk project lookup. * New //:docs_html sandboxed HTML build target alongside //:needs_json. * Comprehensive how-to at docs/how-to/mount_external_sources.rst. Demo ---- * src/docs/ ships a small "Code Docs" bundle (index, overview, requirements). Mounted under docs/internals/code_docs/ with attach_to = "internals/index" so the host toctree auto-extends. * stkh_req__docs__mounts (in the bundle) is referenced from the host via :satisfies: on tool_req__docs_mount_traceability. The link uses only stock relations from score_metamodel (tool_req's existing optional_links permits satisfying stkh_req); no metamodel change is needed to demonstrate cross-bundle traceability. Ancillary fix ------------- * score_sync_toml: flip needscfg_exclude_defaults to False. needs.types, needs.links, needs.fields were being stripped from the generated ubproject.toml because they happen to compare equal to sphinx-needs' declared defaults; the result was a TOML with no type catalogue, so IDE tooling could not resolve project directives like tool_req. --- .gitignore | 1 + BUILD | 10 +- docs.bzl | 108 ++++- docs/how-to/index.rst | 1 + docs/how-to/mount_external_sources.rst | 382 ++++++++++++++++++ docs/internals/requirements/requirements.rst | 17 + docs/reference/commands.md | 9 +- src/BUILD | 9 + src/docs/index.rst | 19 + src/docs/overview.rst | 15 + src/docs/requirements.rst | 21 + src/extensions/score_mounts/BUILD | 56 +++ src/extensions/score_mounts/__init__.py | 156 +++++++ src/extensions/score_mounts/_emit.py | 130 ++++++ src/extensions/score_mounts/_resolver.py | 128 ++++++ src/extensions/score_mounts/tests/__init__.py | 0 .../score_mounts/tests/test_emit.py | 111 +++++ .../score_mounts/tests/test_resolver.py | 107 +++++ src/extensions/score_sphinx_bundle/BUILD | 1 + .../score_sphinx_bundle/__init__.py | 2 + src/extensions/score_sync_toml/__init__.py | 2 +- src/incremental.py | 1 + src/requirements.in | 6 + src/requirements.txt | 161 +++++++- 24 files changed, 1423 insertions(+), 30 deletions(-) create mode 100644 docs/how-to/mount_external_sources.rst create mode 100644 src/docs/index.rst create mode 100644 src/docs/overview.rst create mode 100644 src/docs/requirements.rst create mode 100644 src/extensions/score_mounts/BUILD create mode 100644 src/extensions/score_mounts/__init__.py create mode 100644 src/extensions/score_mounts/_emit.py create mode 100644 src/extensions/score_mounts/_resolver.py create mode 100644 src/extensions/score_mounts/tests/__init__.py create mode 100644 src/extensions/score_mounts/tests/test_emit.py create mode 100644 src/extensions/score_mounts/tests/test_resolver.py diff --git a/.gitignore b/.gitignore index d19638d07..16dd4cdfa 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ user.bazelrc # docs build artifacts /_build* docs/ubproject.toml +src/docs/ubproject.toml # Vale - editorial style guide .vale.ini diff --git a/BUILD b/BUILD index 80284fe40..1e3c75d13 100644 --- a/BUILD +++ b/BUILD @@ -11,7 +11,7 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -load("//:docs.bzl", "docs") +load("//:docs.bzl", "docs", "mount") package(default_visibility = ["//visibility:public"]) exports_files(["pyproject.toml"]) @@ -20,6 +20,14 @@ docs( data = [ "@score_process//:needs_json", ], + mounts = [ + mount( + label = "//src:docs_dir", + mount_at = "internals/code_docs", + attach_to = "internals/index", + src_root = "src/docs", + ), + ], scan_code = [ "//scripts_bazel:sources", "//src:all_sources", diff --git a/docs.bzl b/docs.bzl index 954d24103..3bf155fe5 100644 --- a/docs.bzl +++ b/docs.bzl @@ -45,6 +45,38 @@ load("@aspect_rules_py//py:defs.bzl", "py_binary", "py_venv") load("@docs_as_code_hub_env//:requirements.bzl", "all_requirements") load("@rules_python//sphinxdocs:sphinx.bzl", "sphinx_build_binary", "sphinx_docs") +def _files_to_dir_impl(ctx): + out = ctx.actions.declare_directory(ctx.label.name) + prefix = ctx.attr.strip_prefix + cmds = ["set -euo pipefail"] + for f in ctx.files.srcs: + rel = f.short_path + if prefix and rel.startswith(prefix): + rel = rel[len(prefix):] + rel = rel.lstrip("/") + parent = "/".join(rel.split("/")[:-1]) + if parent: + cmds.append("mkdir -p '{}/{}'".format(out.path, parent)) + cmds.append("cp '{}' '{}/{}'".format(f.path, out.path, rel)) + ctx.actions.run_shell( + inputs = ctx.files.srcs, + outputs = [out], + command = "\n".join(cmds), + progress_message = "Materializing files into directory %{label}", + ) + return [DefaultInfo(files = depset([out]))] + +files_to_dir = rule( + implementation = _files_to_dir_impl, + attrs = { + "srcs": attr.label_list(allow_files = True, mandatory = True), + "strip_prefix": attr.string(default = ""), + }, + doc = "Materialize a list of source files into a single output " + + "directory under bazel-bin. Used by docs() to produce mountable " + + "directories for sphinx-mounts.", +) + def _rewrite_needs_json_to_docs_sources(labels): """Replace '@repo//:needs_json' -> '@repo//:docs_sources' for every item.""" out = [] @@ -125,7 +157,34 @@ 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 mount(label, mount_at, attach_to = None, entry_doc = "index", src_root = None): + """Declarative mount entry for the docs() macro. + + Args: + label: Bazel label producing a single output directory (typically a + ``files_to_dir`` target). Examples: ``"//src:docs_dir"``, + ``"@score_process//:docs_dir"``. + mount_at: Docname prefix under which the bundle appears in the host + Sphinx project. Example: ``"_mounted/internal"``. + attach_to: Optional host docname whose toctree should receive the + bundle's entry_doc. + entry_doc: Mount-relative docname of the bundle's entry document. + Defaults to ``"index"``. + src_root: Optional in-repo source directory for the bundle (e.g. + ``"src/docs"``). When set, a ``ubproject.toml`` is generated at + ``//`` during ``bazel run //:docs_check`` so + ubCode and similar IDE extensions can resolve the project's type + system when opening files inside the bundle. + """ + return struct( + label = label, + mount_at = mount_at, + attach_to = attach_to, + entry_doc = entry_doc, + src_root = src_root, + ) + +def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = None, metamodel = None, mounts = []): """Creates all targets related to documentation. By using this function, you'll get any and all updates for documentation targets in one place. @@ -138,6 +197,8 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = known_good: Optional label to a "known good" JSON file for source 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. + mounts: List of mount() entries describing documentation bundles to overlay into + this project's Sphinx source tree. """ call_path = native.package_name() @@ -153,6 +214,18 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = metamodel_env = {"SCORE_METAMODEL_YAML": "$(location " + str(metamodel) + ")"} metamodel_opts = ["--define=score_metamodel_yaml=$(location " + str(metamodel) + ")"] + mounts_payload = json.encode([ + { + "label": m.label, + "mount_at": m.mount_at, + "attach_to": m.attach_to, + "entry_doc": m.entry_doc, + "src_root": m.src_root, + } + for m in mounts + ]) if mounts else "" + mount_labels = [m.label for m in mounts] + module_deps = deps deps = deps + _missing_requirements(deps) deps = deps + [ @@ -163,7 +236,7 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = sphinx_build_binary( name = "sphinx_build", visibility = ["//visibility:private"], - data = data + metamodel_data, + data = data + metamodel_data + mount_labels, deps = deps, ) @@ -198,17 +271,19 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = 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"] + docs_data = data + metamodel_data + [":sourcelinks_json"] + mount_labels + combo_data = data_with_docs_sources + metamodel_data + [":merged_sourcelinks"] + mount_labels docs_env = { "SOURCE_DIRECTORY": source_dir, "DATA": str(data), + "MOUNTS": mounts_payload, "SCORE_SOURCELINKS": "$(location :sourcelinks_json)", } | metamodel_env docs_sources_env = { "SOURCE_DIRECTORY": source_dir, "DATA": str(data_with_docs_sources), + "MOUNTS": mounts_payload, "SCORE_SOURCELINKS": "$(location :merged_sourcelinks)", } | metamodel_env if known_good: @@ -304,18 +379,41 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = "--jobs", "auto", "--define=external_needs_source=" + str(data), + "--define=mounts_source=" + mounts_payload, "--define=score_sourcelinks_json=$(location :sourcelinks_json)", "--define=score_source_code_linker_plain_links=1", ], formats = ["needs"], sphinx = ":sphinx_build", - tools = data + [":sourcelinks_json"], + tools = data + [":sourcelinks_json"] + mount_labels, visibility = ["//visibility:public"], # Persistent workers cause stale symlinks after dependency version # changes, corrupting the Bazel cache. allow_persistent_workers = False, ) + sphinx_docs( + name = "docs_html", + srcs = [":docs_sources"], + config = ":" + source_prefix + "conf.py", + extra_opts = [ + "-W", + "--keep-going", + "-T", + "--jobs", + "auto", + "--define=external_needs_source=" + str(data), + "--define=mounts_source=" + mounts_payload, + "--define=score_sourcelinks_json=$(location :sourcelinks_json)", + "--define=score_source_code_linker_plain_links=1", + ], + formats = ["html"], + sphinx = ":sphinx_build", + tools = data + [":sourcelinks_json"] + mount_labels, + visibility = ["//visibility:public"], + allow_persistent_workers = False, + ) + native.alias( name = "traceability_gate", actual = "@score_docs_as_code//scripts_bazel:traceability_gate", diff --git a/docs/how-to/index.rst b/docs/how-to/index.rst index 7cae52036..b8d426a98 100644 --- a/docs/how-to/index.rst +++ b/docs/how-to/index.rst @@ -31,3 +31,4 @@ Here you find practical guides on how to use docs-as-code. source_to_doc_links test_to_doc_links add_extensions + mount_external_sources diff --git a/docs/how-to/mount_external_sources.rst b/docs/how-to/mount_external_sources.rst new file mode 100644 index 000000000..8952b9e5f --- /dev/null +++ b/docs/how-to/mount_external_sources.rst @@ -0,0 +1,382 @@ +.. + # ******************************************************************************* + # 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 + # ******************************************************************************* + +.. _mount_external_sources: + +Mounting external source bundles +================================ + +This guide explains how to surface RST or Markdown content that lives +**outside** ``docs/`` into the Sphinx build, without copying or +symlinking. It also covers why the underlying ``sphinx-mounts`` +extension was introduced and how it compares to alternative +"materialize-a-tree" approaches. + +.. contents:: + :local: + :depth: 2 + + +The problem +----------- + +The S-CORE documentation toolchain has historically assumed that every +RST/Markdown file under a Sphinx project lives under its source +directory (``docs/`` in this repository). Two situations break that +assumption: + +* **Generated content** — RST produced by a Bazel rule lands under + ``bazel-bin/...`` and is therefore outside ``docs/`` by construction. + Examples: API reference tables generated from code, requirement + catalogues exported from upstream modules, traceability matrices. + +* **In-repo content owned by another tree** — for example, README-style + documentation that lives next to its source code under ``src/`` and + must remain there for code-ownership reasons but should still appear + in the rendered docs site. + +Historical workarounds either (a) copied or symlinked the files into +``docs/`` — which loses the original source location for IDE +navigation, complicates ``git blame``, and risks stale copies — or +(b) materialized an entire merged source tree at build time and +pointed Sphinx at that. The latter solves the build-side problem but +keeps Sphinx on the IDE critical path. Useful editing in an IDE +requires validation **as you type**, and that is hard to achieve from +any tool without live knowledge of every file and dependency in the +project. Sphinx is built for batch document processing, not for the +millisecond-latency feedback an editor needs; routing IDE feedback +through it therefore caps the editing experience at the speed and +scope of the next rebuild. + + +What ``sphinx-mounts`` does +--------------------------- + +`sphinx-mounts`_ is a Sphinx extension that registers external source +trees with Sphinx's project map by **absolute path**, without copying +or staging. The original files stay exactly where they live; Sphinx +reads them from there. Configuration is declarative TOML in +``ubproject.toml``, the file already shared with Sphinx-Needs, +sphinx-codelinks, and ubCode. + +.. _sphinx-mounts: https://sphinx-mounts.useblocks.com/ + +The key consequence: **every consumer reads the same file**. ubCode, +language servers, indexers, and CI gates can all parse +``ubproject.toml`` to discover where a project's RST sources live — +including the mounted ones — without ever invoking Sphinx. That +preserves the IDE editing experience (real-time validation, jump-to- +definition pointing at the real source, schema-aware autocomplete) +while still letting Sphinx produce the published HTML. + + +Why this matters for IDE support +-------------------------------- + +ubCode (and similar tooling) walks **up** the directory tree from an +open ``.rst`` / ``.md`` file to find the nearest ``ubproject.toml``, +treats that directory as the project root, and reads the file to +learn the type system, link types, layouts, and field defaults the +project uses. For files inside ``docs/``, the host's +``docs/ubproject.toml`` is found naturally. For files inside a +**mounted** bundle (for example, ``src/docs/overview.rst``), the +walk-up never crosses into ``docs/``, so the host's TOML is invisible. + +To close this gap, the ``docs()`` macro also generates a *bundle* +``ubproject.toml`` at each in-repo bundle's source root. The bundle +TOML is a sanitized copy of the host's: it preserves the type +system, link types, layouts, fields, and parse extensions but drops +path-bound entries (external needs JSON paths, ``[[mounts]]``, +schema-path settings) that would otherwise create dead links from +the bundle's location. The result is self-contained TOML that ubCode +can read no matter where the user opens a file from. + + +Comparison with the materialization approach +-------------------------------------------- + ++---------------------------------------+---------------------------------------+---------------------------------------+ +| Concern | Materialize-then-Sphinx | sphinx-mounts (this approach) | ++=======================================+=======================================+=======================================+ +| IDE feedback latency | bounded by next Sphinx rebuild | direct file access via TOML | ++---------------------------------------+---------------------------------------+---------------------------------------+ +| As-you-type validation | not feasible (Sphinx is a batch tool) | works on real files directly | ++---------------------------------------+---------------------------------------+---------------------------------------+ +| Live preview | autobuild-based | ``sphinx-autobuild`` works as-is | ++---------------------------------------+---------------------------------------+---------------------------------------+ +| "Go to definition" lands in | the materialized copy under bazel-bin | the real source file | ++---------------------------------------+---------------------------------------+---------------------------------------+ +| ``conf.py`` execution required for IDE| yes | no — TOML is enough | ++---------------------------------------+---------------------------------------+---------------------------------------+ +| Sandbox-friendly Bazel build | yes | yes | ++---------------------------------------+---------------------------------------+---------------------------------------+ + +The two approaches are not mutually exclusive — a materialized-tree +rule can coexist if a downstream consumer needs it. But sphinx-mounts +is the lighter-weight surface and the primary entry point for new +bundles. + + +Using mounts in ``docs()`` +-------------------------- + +The ``docs()`` macro accepts a ``mounts`` parameter taking a list of +``mount(...)`` entries: + +.. code-block:: starlark + + load("//:docs.bzl", "docs", "mount") + + docs( + data = [ + "@score_process//:needs_json", + ], + mounts = [ + mount( + label = "//src:docs_dir", + mount_at = "internals/code_docs", + attach_to = "internals/index", + src_root = "src/docs", + ), + ], + source_dir = "docs", + ) + +Each argument: + +* ``label`` — a Bazel label that produces a **single output + directory** suitable for sphinx-mounts to walk. For in-repo bundles, + use the ``files_to_dir`` helper from ``docs.bzl`` (see + below). External labels work the same way — for example + ``"@some_upstream//:docs_dir"``. + +* ``mount_at`` — the docname prefix at which the bundle appears in + the host project. With ``mount_at = "internals/code_docs"``, a + bundle file ``overview.rst`` is reachable in the host as the + docname ``internals/code_docs/overview``. + +* ``attach_to`` (optional) — a host docname whose toctree should + automatically receive the bundle's entry document. With + ``attach_to = "internals/index"``, the bundle's ``index`` doc is + appended to the first toctree in ``docs/internals/index.rst`` at + build time; that host doc does not need a manual entry. + +* ``entry_doc`` (optional, default ``"index"``) — the + mount-relative docname of the bundle's entry document, used in + conjunction with ``attach_to``. + +* ``src_root`` (optional) — the in-repo path of the bundle's source + directory (for example ``"src/docs"``). When set, a per-bundle + ``ubproject.toml`` is generated at that path during + ``bazel run //:docs_check`` so that ubCode resolves the + project's type system when opening files inside the bundle. The + generated file is gitignored. See `Per-bundle ubproject.toml`_. + + +Exposing a directory artifact with ``files_to_dir`` +--------------------------------------------------- + +sphinx-mounts walks one directory per mount, so the macro needs a +Bazel target that produces a single output directory rather than a +filegroup. The ``docs.bzl`` macro file exports a small Starlark rule, +``files_to_dir``, that materializes a glob of files into a single +``ctx.actions.declare_directory(...)`` output under ``bazel-bin``. + +A typical in-repo usage: + +.. code-block:: starlark + + # src/BUILD + load("//:docs.bzl", "files_to_dir") + + files_to_dir( + name = "docs_dir", + srcs = glob(["docs/**/*.rst"]), + strip_prefix = "src/docs/", + visibility = ["//visibility:public"], + ) + +The resulting target ``//src:docs_dir`` is the right shape to pass to +``mount(label = ...)``. The ``strip_prefix`` attribute trims the +package-relative prefix off each source path so the bundle is laid out +the way the mount expects. + + +How the wiring works +-------------------- + +The pieces fit together like this: + +.. code-block:: text + + docs(mounts = [...]) ← BUILD declares mounts + │ + ▼ + docs.bzl ← sets env MOUNTS = '[{...}]' + bundles mount runfiles + │ + ▼ + sphinx-build + │ + ▼ + score_mounts extension ← parses MOUNTS, computes two paths: + • absolute runfile path (for sphinx-mounts) + • portable bazel-bin path (for the TOML) + │ + ┌───┴────┐ + ▼ ▼ + sphinx_mounts needs_config_writer + walks the dir writes docs/ubproject.toml with the + via abs path portable [[mounts]] block + +After a successful ``bazel run //:docs_check``, the host's +``docs/ubproject.toml`` contains a ``[[mounts]]`` entry like: + +.. code-block:: toml + + [[mounts]] + dir = "../src/docs" + mount_at = "internals/code_docs" + attach_to = "internals/index" + +The ``dir`` value points at the bundle's **real source location** +(here, ``src/docs/`` — the directory passed to the mount via +``src_root``), not at the materialised bazel-bin copy. ubCode and +similar tools that follow this mount entry therefore navigate to +the original files; jump-to-definition and ``git blame`` work as +the author wrote them. + +This block is what every external consumer of the project (ubCode, +sphinx-build, CI) reads to discover the bundle. + +The architectural symmetry with the existing +``data = [@x//:needs_json]`` flow is intentional — mounts +travel the same transport (a JSON env var), the same runfiles +resolver, and the same TOML serializer (``needs-config-writer`` via +``score_sync_toml``). + +Building from Bazel +~~~~~~~~~~~~~~~~~~~ + +Three relevant targets are wired by the ``docs()`` macro: + +* ``bazel run //:docs`` — incremental HTML build for day-to-day + editing; outputs to ``_build/``. Resolves mounts via runfiles + (fast, dev-local). + +* ``bazel run //:docs_check`` — same as above but with the ``check`` + action; also regenerates ``docs/ubproject.toml`` (host) and any + bundle ``ubproject.toml`` (e.g. ``src/docs/ubproject.toml`` for + the demo mount). Run this after editing the mount list or to + refresh the IDE-facing TOML. + +* ``bazel build //:docs_html`` — sandboxed HTML build. Outputs to + ``bazel-bin/docs_html/_build/html/``. Useful in CI; verifies that + mounted bundles resolve correctly without ``bazel run``. + +``bazel build //:needs_json`` (also sandboxed) keeps working with +mounts active — the existing needs-only path is unchanged. + + +Per-bundle ``ubproject.toml`` +----------------------------- + +When a ``mount(...)`` entry sets ``src_root``, the ``docs()`` macro +arranges for a bundle ``ubproject.toml`` to be generated at that +path during ``bazel run //:docs_check``. + +The generated file is a **sanitized copy** of the host's TOML. It +preserves: + +* ``[[needs.types]]`` — the project's need types (req, spec, + feat_req, ...). +* ``[needs.links]`` — link kinds (satisfies, fulfils, ...). +* ``[needs.layouts]`` — display layouts. +* ``[needs.fields.*]`` — field defaults. +* ``[needs.flow_configs]`` / ``[needs.graphviz_styles.*]`` — diagram + configuration. +* ``[parse.extend_directives.*]`` — parsing extensions. +* ``[server]`` — ubCode server settings. + +And drops: + +* Top-level ``mounts = [...]`` — bundles never nest mounts. +* ``needs.external_needs`` — relative paths that would not resolve + from the bundle's location. +* ``needs.schema_definitions_from_json``, + ``needs.schema_debug_path``, ``needs.build_needumls`` — host-only + filesystem paths. + +The bundle TOML is gitignored. Each bundle's ``.gitignore`` entry +should be added explicitly per ``src_root`` to avoid masking real +configuration files elsewhere. + + +Caveats and known limitations +----------------------------- + +* **Workspace-only generation.** The bundle ``ubproject.toml`` is + written only under ``bazel run`` (when + ``BUILD_WORKSPACE_DIRECTORY`` is available). Sandboxed builds skip + it; this is by design — the sandbox's workspace mirror is discarded + after the build. + +* **External-repository bundles.** The current implementation focuses + on in-repo bundles. Mounting a bundle from another Bazel module + works for the host build, but ``src_root`` does not apply because + the bundle's source files do not live in this repository's workspace. + +* **No mount nesting.** Bundles do not themselves declare mounts. + A bundle's ``ubproject.toml`` always has the top-level ``mounts`` + array stripped. + +* **One `files_to_dir` per bundle.** Each mount must point at a + single output directory. The ``files_to_dir`` helper is the + recommended way to assemble a directory from a ``glob``; other + shapes (e.g. a ``sphinx_docs_library``) are not yet supported. + + +Cross-bundle references work +---------------------------- + +A need authored inside a mounted bundle can be linked from anywhere in +the host project, just like a need authored in ``docs/`` itself. For +example, the stakeholder requirement that motivates this feature lives +in the mounted "Code Docs" bundle at ``src/docs/requirements.rst``, +and the host-side ``tool_req__docs_mount_traceability`` carries a +``:satisfies:`` link directly to it: + + See :need:`stkh_req__docs__mounts` — a stakeholder requirement + authored in the mounted bundle, satisfied by a tool requirement + in ``docs/``, with the link enforced by ``sphinx-needs`` schema + validation. + +That cross-boundary link uses only stock relations from +``score_metamodel`` (``tool_req`` may satisfy ``stkh_req`` without any +metamodel extension): the bundle owns its own ``.rst`` and lives next +to its code, but its needs participate in the host's traceability +graph as first-class citizens. + + +Further reading +--------------- + +* `sphinx-mounts documentation`_ — full configuration reference, + TOML schema, behaviour of ``attach_to`` and ``entry_doc``. +* `ubCode`_ — the IDE extension that reads ``ubproject.toml``. +* :doc:`add_extensions` — how to plug other Sphinx extensions into + the docs-as-code build. + +.. _sphinx-mounts documentation: https://sphinx-mounts.useblocks.com/ +.. _ubCode: https://ubcode.useblocks.com/ diff --git a/docs/internals/requirements/requirements.rst b/docs/internals/requirements/requirements.rst index 382cb8f03..1c9fc8328 100644 --- a/docs/internals/requirements/requirements.rst +++ b/docs/internals/requirements/requirements.rst @@ -1156,6 +1156,23 @@ Testing Docs-As-Code shall enforce that every Safety Analysis has a short description of the failure effect (e.g. failure lead to an unintended actuation of the analysed element) + +.. tool_req:: Cross-bundle traceability via mounts + :id: tool_req__docs_mount_traceability + :implemented: YES + :tags: Architecture + :version: 1 + :satisfies: stkh_req__docs__mounts + + Needs authored inside mounted source bundles shall participate in + the host project's traceability graph as first-class citizens. + This host-side tool requirement carries a ``:satisfies:`` link + to :need:`stkh_req__docs__mounts`, the bundle-side stakeholder + requirement that motivates the mount feature; the link resolves + at host build time without any copy or materialisation, proving + cross-bundle traceability works as a first-class sphinx-needs + relation. + ---------------------------------------------------------------- Safety Analysis (DFA + FMEA) Process to Tool Requirement Mapping ---------------------------------------------------------------- diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 279476f02..7d720267c 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -18,7 +18,8 @@ ## Internal targets (do not use directly) -| Target | What it does | -| ----------------------------- | ------------------------------------------- | -| `bazel build //:needs_json` | Creates a 'needs.json' file | -| `bazel build //:docs_sources` | Provides all the documentation source files | +| Target | What it does | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `bazel build //:needs_json` | Creates a 'needs.json' file | +| `bazel build //:docs_html` | Sandboxed HTML build. Useful in CI; produces the rendered site at `bazel-bin/docs_html/_build/html/`. For day-to-day editing prefer `bazel run //:docs` (faster, incremental). | +| `bazel build //:docs_sources` | Provides all the documentation source files | diff --git a/src/BUILD b/src/BUILD index f12df2fa2..294dee5a0 100644 --- a/src/BUILD +++ b/src/BUILD @@ -14,6 +14,7 @@ load("@aspect_rules_py//py:defs.bzl", "py_library") load("@rules_java//java:java_binary.bzl", "java_binary") load("@rules_python//python:pip.bzl", "compile_pip_requirements") +load("//:docs.bzl", "files_to_dir") # These are only exported because they're passed as files to the //docs.bzl # macros, and thus must be visible to other packages. They should only be @@ -38,6 +39,7 @@ filegroup( "//src/extensions/score_draw_uml_funcs:all_sources", "//src/extensions/score_layout:all_sources", "//src/extensions/score_metamodel:all_sources", + "//src/extensions/score_mounts:all_sources", "//src/extensions/score_source_code_linker:all_sources", "//src/extensions/score_sphinx_bundle:all_sources", "//src/extensions/score_sync_toml:all_sources", @@ -94,3 +96,10 @@ filegroup( ], visibility = ["//visibility:public"], ) + +files_to_dir( + name = "docs_dir", + srcs = glob(["docs/**/*.rst"]), + strip_prefix = "src/docs/", + visibility = ["//visibility:public"], +) diff --git a/src/docs/index.rst b/src/docs/index.rst new file mode 100644 index 000000000..79ac3fa88 --- /dev/null +++ b/src/docs/index.rst @@ -0,0 +1,19 @@ +.. + 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 + +Code Docs +========= + +.. toctree:: + + overview + requirements diff --git a/src/docs/overview.rst b/src/docs/overview.rst new file mode 100644 index 000000000..c8ddace2a --- /dev/null +++ b/src/docs/overview.rst @@ -0,0 +1,15 @@ +.. + Copyright (c) 2026 Contributors to the Eclipse Foundation + + SPDX-License-Identifier: Apache-2.0 + +Overview +======== + +The ``src/`` directory contains the Python extensions and Bazel helpers +that make up the docs-as-code toolchain. This "Code Docs" bundle +documents that surface — kept next to the code so it can evolve with +the code, and mounted into the host docs site via ``sphinx-mounts``. + +Files live under ``src/docs/`` in the repository but are visible to +the host at the docname prefix ``_mounted/internal/``. diff --git a/src/docs/requirements.rst b/src/docs/requirements.rst new file mode 100644 index 000000000..390362c84 --- /dev/null +++ b/src/docs/requirements.rst @@ -0,0 +1,21 @@ +.. + Copyright (c) 2026 Contributors to the Eclipse Foundation + + SPDX-License-Identifier: Apache-2.0 + +Requirements +============ + +.. stkh_req:: Mount external source trees for IDE consumption + :id: stkh_req__docs__mounts + :reqtype: Functional + :safety: QM + :security: NO + :status: valid + :rationale: Out-of-tree documentation must reach the rendered site without copying, and IDE tooling must read the same project configuration Sphinx reads so editing inside a mounted bundle gets as-you-type validation without invoking Sphinx. + + As a stakeholder of S-CORE docs-as-code, I want the ``docs()`` + Bazel macro to expose mounted external source bundles via a + declarative ``[[mounts]]`` block in the generated + ``ubproject.toml``, so that IDE extensions can resolve documents + inside those bundles without invoking Sphinx. diff --git a/src/extensions/score_mounts/BUILD b/src/extensions/score_mounts/BUILD new file mode 100644 index 000000000..37fb4698a --- /dev/null +++ b/src/extensions/score_mounts/BUILD @@ -0,0 +1,56 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* + +load("@aspect_rules_py//py:defs.bzl", "py_library") +load("@docs_as_code_hub_env//:requirements.bzl", "requirement") +load("//:score_pytest.bzl", "score_pytest") + +filegroup( + name = "sources", + srcs = glob(["*.py"]), +) + +filegroup( + name = "tests", + srcs = glob(["tests/*.py"]), +) + +filegroup( + name = "all_sources", + srcs = [ + ":sources", + ":tests", + ], + visibility = ["//visibility:public"], +) + +py_library( + name = "score_mounts", + srcs = [":sources"], + imports = ["."], + visibility = ["//visibility:public"], + deps = [ + requirement("sphinx"), + requirement("sphinx-mounts"), + requirement("tomli-w"), + "//src/helper_lib", + ], +) + +score_pytest( + name = "score_mounts_tests", + size = "small", + srcs = glob(["tests/*.py"]), + deps = [":score_mounts"], + pytest_config = "//:pyproject.toml", +) diff --git a/src/extensions/score_mounts/__init__.py b/src/extensions/score_mounts/__init__.py new file mode 100644 index 000000000..48f2ed527 --- /dev/null +++ b/src/extensions/score_mounts/__init__.py @@ -0,0 +1,156 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Bridge extension: translate the MOUNTS env-var supplied by docs.bzl into +``config.mounts`` consumed by ``sphinx_mounts``, and into a TOML fragment +merged into the generated ``ubproject.toml`` by ``needs_config_writer``. + +Sibling of ``score_metamodel.external_needs`` — same transport (Bazel env +var of label-shaped JSON), same runfiles resolver, same TOML serializer.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from sphinx.application import Sphinx +from sphinx.config import Config +from sphinx.util import logging + +from src.extensions.score_mounts._emit import ( + hook_into_needs_config_writer, + write_bundle_tomls, + write_fragment, +) +from src.extensions.score_mounts._resolver import ( + label_to_bazel_bin_path, + parse_mounts_source, + resolve_mount_dir, +) +from src.helper_lib import find_ws_root + +logger = logging.getLogger(__name__) + + +def _on_config_inited(app: Sphinx, config: Config) -> None: + raw = getattr(config, "mounts_source", "") or os.environ.get("MOUNTS", "") + specs = parse_mounts_source(raw) + if not specs: + return + + # Compute confdir for portable path relativisation. find_ws_root() returns + # None inside a Bazel sandbox (bazel build), so fall back to Path.cwd(). + ws_root = find_ws_root() or Path.cwd() + confdir = Path(app.confdir) + + # In-memory: absolute runfile paths so sphinx-mounts can walk the + # directory during this build. + runtime_mounts: list[dict[str, object]] = [] + # On-disk: confdir-relative bazel-bin paths so the merged + # ubproject.toml stays portable for IDE consumers. + portable_mounts: list[dict[str, object]] = [] + + for spec in specs: + abs_dir = resolve_mount_dir(spec) + if not abs_dir.is_dir(): + logger.warning( + "score_mounts: resolved mount dir does not exist: %s (label=%s)", + abs_dir, + spec.label, + ) + common = { + "mount_at": spec.mount_at, + "attach_to": spec.attach_to, + "entry_doc": spec.entry_doc, + } + runtime_mounts.append({"dir": str(abs_dir), **common}) + + # Portable path: when src_root is set the bundle has an in-repo + # source location and the IDE should jump to those originals, not + # to the bazel-bin copy. Without src_root (e.g. external bundles) + # fall back to the bazel-bin path so the IDE at least sees the + # built artifact. + if spec.src_root: + portable_target = ws_root / spec.src_root.lstrip("/") + else: + portable_target = ws_root / label_to_bazel_bin_path(spec.label) + try: + portable_dir = os.path.relpath(portable_target, confdir) + except ValueError: + # On Windows, relpath may fail across drives; fall back to + # the workspace-root-relative form. + portable_dir = ( + spec.src_root.lstrip("/") + if spec.src_root + else label_to_bazel_bin_path(spec.label) + ) + portable_mounts.append({"dir": portable_dir, **common}) + + config.mounts = runtime_mounts + # Prevent sphinx_mounts._on_load_toml from overwriting our config with a + # possibly-stale docs/ubproject.toml entry. + config.mounts_from_toml = None + + fragment_path = write_fragment(portable_mounts) + hook_into_needs_config_writer(config, fragment_path) + + logger.info("score_mounts: registered %d mount(s)", len(runtime_mounts)) + + +def _on_build_finished(app: Sphinx, exception: Exception | None) -> None: + """Generate per-bundle ubproject.toml after needs_config_writer has + written the host's ubproject.toml. Only fires when running under + bazel run (workspace dir available). Silently skips bundles without + src_root.""" + if exception is not None: + return # don't pollute a failing build + + ws_root = find_ws_root() + if ws_root is None: + # Sandbox build — no workspace to write into. The next + # `bazel run //:docs_check` will regenerate. + return + + raw = getattr(app.config, "mounts_source", "") or os.environ.get("MOUNTS", "") + specs = parse_mounts_source(raw) + src_roots = [ + ws_root / spec.src_root + for spec in specs + if spec.src_root + ] + if not src_roots: + return + + host_toml = ws_root / "docs" / "ubproject.toml" + if not host_toml.is_file(): + logger.warning( + "score_mounts: host ubproject.toml not found at %s, " + "skipping bundle TOML generation. Run `bazel run //:docs_check` " + "to regenerate.", + host_toml, + ) + return + + try: + written = write_bundle_tomls(host_toml, src_roots) + except RuntimeError as exc: + logger.warning("score_mounts: %s", exc) + return + + for path in written: + logger.info("score_mounts: wrote bundle TOML at %s", path) + + +def setup(app: Sphinx) -> dict[str, object]: + app.add_config_value("mounts_source", default="", rebuild="env", types=(str,)) + # Priority must be < 400 so we run before sphinx_mounts._on_load_toml. + app.connect("config-inited", _on_config_inited, priority=300) + app.connect("build-finished", _on_build_finished, priority=900) + return { + "version": "0.1", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/src/extensions/score_mounts/_emit.py b/src/extensions/score_mounts/_emit.py new file mode 100644 index 000000000..c24a47310 --- /dev/null +++ b/src/extensions/score_mounts/_emit.py @@ -0,0 +1,130 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Emit a TOML fragment containing [[mounts]] entries and register it with +needs-config-writer so the same content lands in docs/ubproject.toml.""" + +from __future__ import annotations + +import tempfile +from pathlib import Path +from typing import Any + +from sphinx.config import Config + + +def _escape(value: str) -> str: + """Escape a string for safe inclusion in a TOML basic string.""" + return value.replace("\\", "\\\\").replace('"', '\\"') + + +def write_fragment( + resolved_mounts: list[dict[str, Any]], + outdir: Path | None = None, +) -> Path: + """Write a TOML fragment containing one ``[[mounts]]`` table per resolved entry. + + Keys are emitted only when non-default so that the rendered TOML mirrors + what a user would write by hand. Returns the absolute path of the file + written. + """ + if outdir is None: + outdir = Path(tempfile.mkdtemp(prefix="score_mounts_")) + outdir.mkdir(parents=True, exist_ok=True) + fragment = outdir / "score_mounts_fragment.toml" + + lines: list[str] = [] + for m in resolved_mounts: + lines.append("[[mounts]]") + lines.append(f'dir = "{_escape(m["dir"])}"') + lines.append(f'mount_at = "{_escape(m["mount_at"])}"') + if m.get("attach_to"): + lines.append(f'attach_to = "{_escape(m["attach_to"])}"') + if m.get("entry_doc") and m["entry_doc"] != "index": + lines.append(f'entry_doc = "{_escape(m["entry_doc"])}"') + lines.append("") + + fragment.write_text("\n".join(lines), encoding="utf-8") + return fragment + + +_BUNDLE_BANNED_NEEDS_KEYS = frozenset( + { + "schema_definitions_from_json", + "schema_debug_path", + "build_needumls", + } +) + + +def sanitize_bundle_toml_text(host_toml_text: str) -> str: + """Strip path-bound entries from a copy of host ubproject.toml content. + + The result is suitable for placement at any in-repo bundle's source + root: it preserves the type system (types, links, layouts, fields, + parse extensions, server settings) but removes references to paths + that exist only relative to the host project's confdir. + + Operates on the original text to preserve key order / comments; + drops entries via TOML-aware parsing and re-emit only for the + affected tables. + """ + import tomllib + try: + import tomli_w + except ImportError as exc: + raise RuntimeError( + "score_mounts: tomli-w is required to emit bundle ubproject.toml; " + "add 'tomli-w' to src/requirements.in and regenerate." + ) from exc + + data = tomllib.loads(host_toml_text) + # Strip top-level path-bound entries. + data.pop("mounts", None) + needs = data.get("needs") + if isinstance(needs, dict): + needs.pop("external_needs", None) + for key in _BUNDLE_BANNED_NEEDS_KEYS: + needs.pop(key, None) + return tomli_w.dumps(data) + + +def write_bundle_tomls( + host_toml_path: Path, + bundle_src_roots: list[Path], +) -> list[Path]: + """Write a sanitized ubproject.toml at each bundle src_root. + + Returns the list of written file paths. Bundles whose src_root does + not exist (e.g. a typo in mount()) are skipped with a logger.warning. + Raises FileNotFoundError if host_toml_path does not exist — caller + should handle by warn-and-skip during early builds. + """ + text = host_toml_path.read_text(encoding="utf-8") + sanitized = sanitize_bundle_toml_text(text) + + from sphinx.util import logging as sphinx_logging + logger = sphinx_logging.getLogger(__name__) + + written: list[Path] = [] + for src_root in bundle_src_roots: + if not src_root.is_dir(): + logger.warning( + "score_mounts: bundle src_root does not exist, skipping: %s", + src_root, + ) + continue + target = src_root / "ubproject.toml" + target.write_text(sanitized, encoding="utf-8") + written.append(target) + return written + + +def hook_into_needs_config_writer(config: Config, fragment_path: Path) -> None: + """Register the fragment with needs-config-writer so it lands in ubproject.toml.""" + merge_files = getattr(config, "needscfg_merge_toml_files", None) + if isinstance(merge_files, list): + merge_files.append(str(fragment_path)) diff --git a/src/extensions/score_mounts/_resolver.py b/src/extensions/score_mounts/_resolver.py new file mode 100644 index 000000000..ef703177a --- /dev/null +++ b/src/extensions/score_mounts/_resolver.py @@ -0,0 +1,128 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Parse the MOUNTS env-var payload supplied by docs.bzl and resolve each +Bazel label to its runfiles-relative path.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + +from src.helper_lib import get_runfiles_dir + + +@dataclass(frozen=True) +class MountSpec: + label: str + mount_at: str + attach_to: str | None = None + entry_doc: str = "index" + src_root: str | None = None + + +def parse_mounts_source(value: str) -> list[MountSpec]: + """Decode the MOUNTS env-var JSON into MountSpec instances.""" + if not value or value.strip() in ("", "[]"): + return [] + raw = json.loads(value) + if not isinstance(raw, list): + raise ValueError( + f"MOUNTS must decode to a list, got {type(raw).__name__}: {raw!r}" + ) + out: list[MountSpec] = [] + for entry in raw: + if not isinstance(entry, dict): + raise ValueError(f"MOUNTS entry must be a dict, got {entry!r}") + if "label" not in entry or "mount_at" not in entry: + raise ValueError( + f"MOUNTS entry missing required keys 'label'/'mount_at': {entry!r}" + ) + out.append( + MountSpec( + label=entry["label"], + mount_at=entry["mount_at"], + attach_to=entry.get("attach_to") or None, + entry_doc=entry.get("entry_doc") or "index", + src_root=entry.get("src_root") or None, + ) + ) + return out + + +def label_to_runfile_path(label: str) -> str: + """Convert a Bazel label to its runfiles-relative path. + + Main-workspace labels go under ``_main/...``; external module labels + go under ``+/...``. The target name is appended as the final + path segment. + """ + if label.startswith("@"): + rest = label[1:] + if "//" not in rest: + raise ValueError(f"malformed external label: {label!r}") + module, path_and_target = rest.split("//", 1) + prefix = f"{module}+" + elif label.startswith("//"): + path_and_target = label[2:] + prefix = "_main" + else: + raise ValueError(f"label must start with '//' or '@': {label!r}") + + if ":" in path_and_target: + path, target = path_and_target.split(":", 1) + else: + path = path_and_target + target = path.rsplit("/", 1)[-1] if "/" in path else path + + parts = [prefix] + if path: + parts.append(path) + parts.append(target) + return "/".join(parts) + + +def label_to_bazel_bin_path(label: str) -> str: + """Convert a Bazel label to its bazel-bin-relative path. + + Returns a workspace-root-relative path of the form ``bazel-bin//``. + + Examples:: + + "//src:docs_dir" -> "bazel-bin/src/docs_dir" + "//:docs_dir" -> "bazel-bin/docs_dir" + "@score_process//:docs_sources" -> "bazel-bin/external/score_process+/docs_sources" + "@x//foo/bar:baz" -> "bazel-bin/external/x+/foo/bar/baz" + """ + if label.startswith("@"): + rest = label[1:] + if "//" not in rest: + raise ValueError(f"malformed external label: {label!r}") + module, path_and_target = rest.split("//", 1) + prefix = f"bazel-bin/external/{module}+" + elif label.startswith("//"): + path_and_target = label[2:] + prefix = "bazel-bin" + else: + raise ValueError(f"label must start with '//' or '@': {label!r}") + + if ":" in path_and_target: + path, target = path_and_target.split(":", 1) + else: + path = path_and_target + target = path.rsplit("/", 1)[-1] if "/" in path else path + + parts = [prefix] + if path: + parts.append(path) + parts.append(target) + return "/".join(parts) + + +def resolve_mount_dir(spec: MountSpec) -> Path: + """Resolve the runfile path of ``spec.label`` to an absolute filesystem path.""" + return get_runfiles_dir() / label_to_runfile_path(spec.label) diff --git a/src/extensions/score_mounts/tests/__init__.py b/src/extensions/score_mounts/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/extensions/score_mounts/tests/test_emit.py b/src/extensions/score_mounts/tests/test_emit.py new file mode 100644 index 000000000..e40348814 --- /dev/null +++ b/src/extensions/score_mounts/tests/test_emit.py @@ -0,0 +1,111 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +import tomllib +from pathlib import Path + +from src.extensions.score_mounts._emit import sanitize_bundle_toml_text, write_fragment + + +def test_write_fragment_single_entry(tmp_path: Path): + resolved = [ + { + "dir": "/abs/path/to/docs_dir", + "mount_at": "_mounted/internal", + "attach_to": "index", + "entry_doc": "index", + } + ] + fragment = write_fragment(resolved, outdir=tmp_path) + assert fragment.is_file() + parsed = tomllib.loads(fragment.read_text()) + assert parsed == { + "mounts": [ + { + "dir": "/abs/path/to/docs_dir", + "mount_at": "_mounted/internal", + "attach_to": "index", + } + ] + } + + +def test_write_fragment_omits_attach_to_when_none(tmp_path: Path): + resolved = [ + { + "dir": "/abs/path", + "mount_at": "_mounted/x", + "attach_to": None, + "entry_doc": "index", + } + ] + fragment = write_fragment(resolved, outdir=tmp_path) + parsed = tomllib.loads(fragment.read_text()) + assert "attach_to" not in parsed["mounts"][0] + + +def test_write_fragment_emits_non_default_entry_doc(tmp_path: Path): + resolved = [ + { + "dir": "/abs/path", + "mount_at": "_mounted/x", + "attach_to": None, + "entry_doc": "start", + } + ] + fragment = write_fragment(resolved, outdir=tmp_path) + parsed = tomllib.loads(fragment.read_text()) + assert parsed["mounts"][0]["entry_doc"] == "start" + + +def test_write_fragment_multiple_entries(tmp_path: Path): + resolved = [ + {"dir": "/a", "mount_at": "_mounted/a", "attach_to": None, "entry_doc": "index"}, + {"dir": "/b", "mount_at": "_mounted/b", "attach_to": "index", "entry_doc": "index"}, + ] + fragment = write_fragment(resolved, outdir=tmp_path) + parsed = tomllib.loads(fragment.read_text()) + assert len(parsed["mounts"]) == 2 + assert parsed["mounts"][0]["mount_at"] == "_mounted/a" + assert parsed["mounts"][1]["attach_to"] == "index" + + +def test_sanitize_drops_top_level_mounts(): + host = '''[needs] +build_json = true + +[[mounts]] +dir = "../bazel-bin/src/docs_dir" +mount_at = "_mounted/internal" +''' + result = sanitize_bundle_toml_text(host) + assert "mounts" not in tomllib.loads(result) + assert tomllib.loads(result)["needs"]["build_json"] is True + + +def test_sanitize_drops_external_needs_and_banned_keys(): + host = '''[needs] +build_json = true +schema_definitions_from_json = "schemas.json" +schema_debug_path = "/abs/path" +build_needumls = "_plantuml_sources" + +[[needs.external_needs]] +base_url = "https://example.com" +json_path = "../bazel-bin/foo/needs.json" + +[[needs.types]] +directive = "req" +title = "Requirement" +prefix = "R_" +''' + parsed = tomllib.loads(sanitize_bundle_toml_text(host)) + assert "external_needs" not in parsed["needs"] + assert "schema_definitions_from_json" not in parsed["needs"] + assert "schema_debug_path" not in parsed["needs"] + assert "build_needumls" not in parsed["needs"] + # Preserved entries: + assert parsed["needs"]["build_json"] is True + assert parsed["needs"]["types"][0]["directive"] == "req" diff --git a/src/extensions/score_mounts/tests/test_resolver.py b/src/extensions/score_mounts/tests/test_resolver.py new file mode 100644 index 000000000..715c42b24 --- /dev/null +++ b/src/extensions/score_mounts/tests/test_resolver.py @@ -0,0 +1,107 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +import pytest + +from src.extensions.score_mounts._resolver import ( + MountSpec, + label_to_bazel_bin_path, + label_to_runfile_path, + parse_mounts_source, +) + + +def test_parse_empty_string(): + assert parse_mounts_source("") == [] + + +def test_parse_empty_list(): + assert parse_mounts_source("[]") == [] + + +def test_parse_single_entry(): + raw = '[{"label": "//src:docs_dir", "mount_at": "_mounted/internal"}]' + assert parse_mounts_source(raw) == [ + MountSpec(label="//src:docs_dir", mount_at="_mounted/internal"), + ] + + +def test_parse_entry_with_attach_to_and_entry_doc(): + raw = ( + '[{"label": "//src:docs_dir", "mount_at": "_mounted/x", ' + '"attach_to": "index", "entry_doc": "start"}]' + ) + assert parse_mounts_source(raw) == [ + MountSpec( + label="//src:docs_dir", + mount_at="_mounted/x", + attach_to="index", + entry_doc="start", + ), + ] + + +def test_parse_multiple_entries(): + raw = ( + '[{"label": "//src:docs_dir", "mount_at": "_mounted/a"},' + ' {"label": "@x//:docs_sources", "mount_at": "_mounted/b"}]' + ) + result = parse_mounts_source(raw) + assert len(result) == 2 + assert result[0].mount_at == "_mounted/a" + assert result[1].label == "@x//:docs_sources" + + +def test_parse_missing_required_key_raises(): + raw = '[{"label": "//src:docs_dir"}]' + with pytest.raises(ValueError, match="missing required keys"): + parse_mounts_source(raw) + + +def test_parse_non_list_raises(): + raw = '{"label": "//src:docs_dir", "mount_at": "x"}' + with pytest.raises(ValueError, match="must decode to a list"): + parse_mounts_source(raw) + + +def test_label_to_runfile_main_repo_with_path(): + assert label_to_runfile_path("//src:docs_dir") == "_main/src/docs_dir" + + +def test_label_to_runfile_main_repo_root_package(): + assert label_to_runfile_path("//:docs_dir") == "_main/docs_dir" + + +def test_label_to_runfile_external_root_package(): + assert label_to_runfile_path("@score_process//:docs_sources") == ( + "score_process+/docs_sources" + ) + + +def test_label_to_runfile_external_with_path(): + assert label_to_runfile_path("@x//foo/bar:baz") == "x+/foo/bar/baz" + + +def test_label_to_runfile_invalid_raises(): + with pytest.raises(ValueError): + label_to_runfile_path("not_a_label") + + +def test_label_to_bazel_bin_main_repo_with_path(): + assert label_to_bazel_bin_path("//src:docs_dir") == "bazel-bin/src/docs_dir" + + +def test_label_to_bazel_bin_main_repo_root_package(): + assert label_to_bazel_bin_path("//:docs_dir") == "bazel-bin/docs_dir" + + +def test_label_to_bazel_bin_external_root_package(): + assert label_to_bazel_bin_path("@score_process//:docs_sources") == ( + "bazel-bin/external/score_process+/docs_sources" + ) + + +def test_label_to_bazel_bin_external_with_path(): + assert label_to_bazel_bin_path("@x//foo/bar:baz") == "bazel-bin/external/x+/foo/bar/baz" diff --git a/src/extensions/score_sphinx_bundle/BUILD b/src/extensions/score_sphinx_bundle/BUILD index 832e35a61..0ac4d4937 100644 --- a/src/extensions/score_sphinx_bundle/BUILD +++ b/src/extensions/score_sphinx_bundle/BUILD @@ -28,6 +28,7 @@ py_library( "@score_docs_as_code//src/extensions/score_draw_uml_funcs", "@score_docs_as_code//src/extensions/score_layout", "@score_docs_as_code//src/extensions/score_metamodel", + "@score_docs_as_code//src/extensions/score_mounts", "@score_docs_as_code//src/extensions/score_source_code_linker", "@score_docs_as_code//src/extensions/score_sync_toml", "@score_docs_as_code//src/helper_lib", diff --git a/src/extensions/score_sphinx_bundle/__init__.py b/src/extensions/score_sphinx_bundle/__init__.py index 6ae04008b..35e6f2b6c 100644 --- a/src/extensions/score_sphinx_bundle/__init__.py +++ b/src/extensions/score_sphinx_bundle/__init__.py @@ -24,6 +24,8 @@ "score_metamodel", "sphinx_design", "myst_parser", + "sphinx_mounts", + "score_mounts", "score_source_code_linker", "score_draw_uml_funcs", "score_layout", diff --git a/src/extensions/score_sync_toml/__init__.py b/src/extensions/score_sync_toml/__init__.py index 42709b218..6b164ef6e 100644 --- a/src/extensions/score_sync_toml/__init__.py +++ b/src/extensions/score_sync_toml/__init__.py @@ -33,7 +33,7 @@ def setup(app: Sphinx) -> dict[str, str | bool]: config_setdefault(app.config, "needscfg_write_all", True) """Write full config, so the final configuration is visible in one file.""" - config_setdefault(app.config, "needscfg_exclude_defaults", True) + config_setdefault(app.config, "needscfg_exclude_defaults", False) """Exclude default values from the generated configuration.""" # This is disabled for right now as it causes a lot of issues diff --git a/src/incremental.py b/src/incremental.py index 92449a24b..0d1e5df4e 100644 --- a/src/incremental.py +++ b/src/incremental.py @@ -82,6 +82,7 @@ def get_env(name: str) -> str: "--jobs", "auto", f"--define=external_needs_source={get_env('DATA')}", + f"--define=mounts_source={os.environ.get('MOUNTS', '')}", ] metamodel_yaml = os.environ.get("SCORE_METAMODEL_YAML", "") diff --git a/src/requirements.in b/src/requirements.in index aff2c3d05..f3436281a 100644 --- a/src/requirements.in +++ b/src/requirements.in @@ -30,6 +30,12 @@ needs-config-writer == 0.2.4 # use this for a specific commit for fast development iterations # needs-config-writer @ https://github.com/useblocks/needs-config-writer/archive/032a5f8.zip +# Mount external source trees into the Sphinx project without copying. +sphinx-mounts + +# Used by score_mounts to write per-bundle ubproject.toml files. +tomli-w + # Need this to enable non bazel execution bazel-runfiles diff --git a/src/requirements.txt b/src/requirements.txt index e0a0c2733..b8c1359e4 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -33,10 +33,10 @@ babel==2.18.0 \ basedpyright==1.39.0 \ --hash=sha256:6666f51c378c7ac45877c4c1c7041ee0b5b83d755ebc82f898f47b6fafe0cc4f \ --hash=sha256:91b8ad50bc85ee4a985b928f9368c35c99eee5a56c44e99b2442fa12ecc3d670 - # via -r requirements.in + # via -r src/requirements.in bazel-runfiles==1.9.0 \ --hash=sha256:66fb0f221e72ad904086eda6b208e873c76a2f5511ea308e7eae449534abc202 - # via -r requirements.in + # via -r src/requirements.in beautifulsoup4==4.14.3 \ --hash=sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb \ --hash=sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86 @@ -438,7 +438,7 @@ debugpy==1.8.20 \ --hash=sha256:da11dea6447b2cadbf8ce2bec59ecea87cc18d2c574980f643f2d2dfe4862393 \ --hash=sha256:eada6042ad88fa1571b74bd5402ee8b86eded7a8f7b827849761700aff171f1b \ --hash=sha256:eb506e45943cab2efb7c6eafdd65b842f3ae779f020c82221f55aca9de135ed7 - # via -r requirements.in + # via -r src/requirements.in docutils==0.22.4 \ --hash=sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968 \ --hash=sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de @@ -449,7 +449,7 @@ docutils==0.22.4 \ esbonio==0.16.5 \ --hash=sha256:04ba926e3603f7b1fde1abc690b47afd60749b64b1029b6bce8e1de0bb284921 \ --hash=sha256:acab2e16c6cf8f7232fb04e0d48514ce50566516b1f6fcf669ccf2f247e8b10f - # via -r requirements.in + # via -r src/requirements.in fonttools==4.62.1 \ --hash=sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04 \ --hash=sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a \ @@ -520,6 +520,122 @@ idna==3.11 \ # via # anyio # requests +ignore-python==0.3.3 \ + --hash=sha256:000ae12f6187791bc4748e40e7073b9769ca6ddb0cabfc11d1c682ca0e6a3953 \ + --hash=sha256:05ccc5bdf2ad1f840a0a2296efff5f4761a26f015185ed0bb835ad1901f08ee8 \ + --hash=sha256:0a6b2d7900ce82acd61ab6714b10103c37d0a1c16ee7af46fb6e94d14a2e4dca \ + --hash=sha256:0c9408949156bf4ae07e60d5c8f356114be1482dc8f708ecd5785151e9ae21fa \ + --hash=sha256:0c9ea5d81e6b1a1284f5463203f57cffb3c7e391c0d8ce3fe0e4d838621367fb \ + --hash=sha256:0dfa531bbf65f45f9a233e2809f8b0a49aa9078686dbb0e4f3e30848e09cc208 \ + --hash=sha256:0f0edb622f5a8b7f735e14a7ca13d4cb7ca04b6fd7e844f5fa0fd9f86622b986 \ + --hash=sha256:0f50dd3c3f0ef982e256d9e702b44c1d81cbe0da2d98746d5b189bc1fd5191cd \ + --hash=sha256:1206189e1c988a3d0fb7de3298e5f7dd5284b4a23781878fb8f9f6224659b270 \ + --hash=sha256:1225e6210e302e0725265504a11a367f0b8cb882e3e2e231748559d5b05179c8 \ + --hash=sha256:12827fc970d57865b6f31a82f79838bea24b63d85d9f61f1821b7cf8bd01128c \ + --hash=sha256:130d9ace07988b69669e871ed84dc77088d7b5ce35265efbb0b0d425085e2a99 \ + --hash=sha256:152c5aecf42e709138c8e3da2ac733d00f5efdcd004c240a95193e62b6bd024e \ + --hash=sha256:17749af58a6fe6aaa3198b285c874fbf0f036ef2cb684225ef62288b62948d26 \ + --hash=sha256:1ac4491082df61d370f7fc087d5c0b16bc84b647e126e3f45a97d31cf3b2f514 \ + --hash=sha256:1b23770925db422fe9c94920da82e0606516aca09fd851880bc8c8ed68fe6455 \ + --hash=sha256:1b2ff29dbe59bbbd370feacb99e5bec7791abe730dd535b5db848de5b5210d7e \ + --hash=sha256:1ed9c8c858dfe2ba91bc4ab60ebd9d12dd4602a4d0d555e0fee9c8621f9ca292 \ + --hash=sha256:22c216e3130077060eb4cce99a8bf79074826655bbea6c594d36d8a0735fac6c \ + --hash=sha256:275f5b3e4c5b25fcb58ad1eedd983a346866939a6140e564b9c56ee9f8fc7760 \ + --hash=sha256:2af502d988282cc360094dc7b2733a7b68e54a8e3cf0128178ab1ba84f9ec290 \ + --hash=sha256:2b608a30d3505720f9aceaba7d6d26769867d71c212fc5c8a80fae1a8afec663 \ + --hash=sha256:2d637f1a6b2ec7bcb74b2ac11f75e15eaf6092535b8dc31415305a73a3762e3e \ + --hash=sha256:3001adecf33250dfa90c6e727affd559b31ad3b32b8519920c14bef53410444f \ + --hash=sha256:356b783877c7e88eba69c99bcc5e2d9fb07c2fe54f2c64e03897b7ae2adce2a7 \ + --hash=sha256:3623ce12eb96976c0db36a0ad99c65c669fdafece71e7221a538f12fa6fa41ef \ + --hash=sha256:365ab0bf94b64f3def2264fdca58f6e9811763830ad1a48a41d70574a496e5b9 \ + --hash=sha256:39e7b9d976c12c68b09b9944328a000b012725f1f7c4655510eb890761016fb3 \ + --hash=sha256:3b6536698628af08b6db260d338b41e7c48b2a6c5c93b12de4d1ecafc1bb86ae \ + --hash=sha256:3c40627f3bde32a37e75950b97733b586e9167fd545c958195dbf1bb73d96a81 \ + --hash=sha256:3da9e102800f162468ddb5d2d392b79e2481d3709e886f875037ef7066df5481 \ + --hash=sha256:4252f803f3cdae6c8775f1e905f5babd6e77860781f4c29cf798452ee5fd6936 \ + --hash=sha256:429a9b792afdca7dd9cff59f5ace44c2f55d01fc30b0ce3d28d63bee223116ed \ + --hash=sha256:44bf617535f5ead500a6f83178426f7c607e015bcf9e614e18ae88aa3de1a340 \ + --hash=sha256:477bb090189b79b8a81753c74a59ccb6c0ebf91985f30e926177f062c8616d05 \ + --hash=sha256:48cf09c7668b5b8bb7dcaf185618130b8ea7839df0eb0f6ae4fae7b7162a0c37 \ + --hash=sha256:49ccb834be168a7e72b104ffc02024d4eb4d91840bc30e2948787551f5242ff0 \ + --hash=sha256:4aac18bf9346f06fd17412d5bcfca53090a72456f53f3ffdf3b1e896f15cc356 \ + --hash=sha256:4ba31985a790af71bc45c16643d4773b829b8233acf4175bba2af466a2bbc959 \ + --hash=sha256:4f88aa2ee7335399c823aa050edec118e28ddafe7859e0db4093de0ca815467e \ + --hash=sha256:4f8c85a6738c632abd477c217297f8929ec1cafb261b94fa05a7fcf28095d70f \ + --hash=sha256:5055772fa6f09148a09e6958770c4e4f4435f6e3de33476f815af9c5db13e29c \ + --hash=sha256:5134be429fb954ec589857dbc6152dc09014902313e3d2293af9338a4b7799ae \ + --hash=sha256:558f79f48d2cd7bd42ce3e6747752be9483504d3f2f84c1d39c39bcf1961bac3 \ + --hash=sha256:56e8f00aef89a19b0305cb233fb59736ad14aa8c6db05720b13ac0bca811b5e7 \ + --hash=sha256:5756229d699ec5ab8caf4cec3ce9827d02145059c79a2416f363e2df4406d378 \ + --hash=sha256:593679d7714b4228d7e8c3bda0005badb9f0d4cb37cd8dda8385ef734e675e23 \ + --hash=sha256:59e26bf7bbbd5937a196f01480050d3d80418ade8933164b28e3e36734baecd7 \ + --hash=sha256:5f3d88554e779f03567c05286f31d2ce21f6103892c7412bdf350ef2fb50184a \ + --hash=sha256:62afd51edaf5634e21206a65e9e244e038b747e39ba969ebcc9361b63825a4f9 \ + --hash=sha256:68f393318292a6346c6d72c2b8ee301a081bff778dfb0e6ef6f0c36da4053374 \ + --hash=sha256:71dc7505c0520e066c5d567f49d7173703c34192af1b8f89ce401a34098391f5 \ + --hash=sha256:7ad2cc34fb600ab4aa22014bc8cc9b8bae2d467f772074d3a4929deb4adf64d6 \ + --hash=sha256:7d63688dc696b72d54623dd55f269d5203fcd5de5eeeba7bc276864300a8d790 \ + --hash=sha256:7e3b89f96cda6df85687b9532eac4faffdf15d2d918b239399cab6c78dd5282b \ + --hash=sha256:7e49780796e39812ade8001b0c7d2a2f1a9aeac90964e8e757c2013d30dfbe4e \ + --hash=sha256:7e9bea86436a59eb3f24e8e15bb3b3a36cdb98aec950c70aa619e33f35eb6beb \ + --hash=sha256:82fecbeb7fa309245aaaa7e3aaa09c744f1059fc238e2f7acd889d803f1a0be7 \ + --hash=sha256:842572b228382c9bb6283428f14ff4481b3822cb7488ce4388281a8c6c465a81 \ + --hash=sha256:8528c819c151ccabbd1bb9e591dd99495c2d0423b10ccdef47d48fe25da2b2d6 \ + --hash=sha256:85f3ec2d15ba134e6159ddebdb1a0af89f99431d93336f8b3cb71aa2de9f3324 \ + --hash=sha256:875fdfa0e3e9102164a540509ca2d5ad959f1f53858cf11a4174a1845e3c575c \ + --hash=sha256:89b2efb712c5bc0c57cc5a9deabfbcb2196504139d2d108a4afedd140c02063f \ + --hash=sha256:8d26f91ac1fea52abd16dab224b4ac016d8914f28db02e582f4e589ee2b5faa5 \ + --hash=sha256:8dd30b865dfd3206756212796cb13686b6c45befa5cc495ccc9866108215f7c1 \ + --hash=sha256:8e9085cec8d730b43ac8c86ef5da0c902f0f57da8da2ee009045e7006fe4860f \ + --hash=sha256:8f0e8379d4eff6842b61c01c7f03dc7415afac994b96e4a7d24f80b1078d5c1d \ + --hash=sha256:8fc3b2fcb6ee1c2b1512a0b29d26bb9b9e945d0d50c0a85673a675622fbfb0f4 \ + --hash=sha256:901a862196bf610745e164d29c518e0cbf727eaaedc83d5058a7895721be2173 \ + --hash=sha256:931744cdcbb84d77159daf6b54e3b459e9f0a0ba52ea6b99d1d228e32556c2b9 \ + --hash=sha256:97db61620be5c56a78115967d05e0d7a130a27a68f401eb98bf0753d3f770cb5 \ + --hash=sha256:9bccb48b57b7a85677c1022afbaaf86e5cda8c1ecff00ad96877e10166d1eddc \ + --hash=sha256:a2cdfbd3c9df9e98dd067858fce7d6ab919f2fb038f6b3124fc5f05b8825b546 \ + --hash=sha256:a30070520aa114133feffc2413ba62bfcd8ef2f9826ebe7616de123f73b57977 \ + --hash=sha256:a894e6bf85988edee94474ff1399b61685d5fa7399d1c25a40efa28f7a2799ea \ + --hash=sha256:a999ef004caa048e5ecccb5f3383d857105baa37b8895a0a2b7cd66f9cb0b0b4 \ + --hash=sha256:af35c6b8c3a9a27721e5c2a849e2ee21973e14b8b7d2e76e15940475a8ace443 \ + --hash=sha256:b0f0bc9eab99a36f54e0a4e3043768e529107565bc67a7f420b4febff8006f32 \ + --hash=sha256:b123951f9befe6052a7397778fa64ade18983345d79fd9477160b11dfd736df0 \ + --hash=sha256:b2c072a4615c9610bd43cde816882ede0c910599702e54cbc07371874d1ca95b \ + --hash=sha256:b4f2df2bbca999f9749430e54dcd13bcc35289520b63e09ed4d2e1877a524260 \ + --hash=sha256:b730d11384a02bc4fb195b8a73555cb450e325d2676b39c8b5d20a0786102f37 \ + --hash=sha256:be6a4e3244c33f133d3c0bc43f9725c61dc9a11f7100c615639ce1daee064766 \ + --hash=sha256:c3430b73a99af300b0b1203da2cd30f1831f504466d94343b065b1ef0435802c \ + --hash=sha256:c478ee58fd2d6f5f7b75b32288d8a099904f710e83dd878f40058a309a0f6060 \ + --hash=sha256:c545cfd062a463c6d8e90b2738ec93fb9228f12c0aa80866fdc80f64fbc8f1a3 \ + --hash=sha256:c68dc5db03a22aada43f23f55e359109e00afe3886824d6fecfbd6821dcdbd6f \ + --hash=sha256:c73743c4c8622ebed7998990c5f18a2c2a4fae915288798aeb8fef6d5411743b \ + --hash=sha256:c8b112dd1906487fe56525361cbeca57b07adcc9496b641560327654aced7e8f \ + --hash=sha256:cb82264552ae789f2d8c2543b3b1eb2b3e091977c22f180ab268f5b677825279 \ + --hash=sha256:cc49302f8fdbd3426eba4b99671ba10f0d150d90ea4f344a0204e4ac8e4fcb66 \ + --hash=sha256:d01b0578252d0df17b64400103ffbfea5b8332483a3da01d116f4919467128de \ + --hash=sha256:d4f8b4137b0153c95ea7e4e3acff4c5e6f6c25206d3e3b86dbe879658b00c927 \ + --hash=sha256:d8be9b991c63fd8c76a6a9deac24ed164533824da1bdb2356a33511bfa446d54 \ + --hash=sha256:d94d9b91eece76104077a7e0b3276bb14728a4bff00ba23b0f0bea2a10c0e6e8 \ + --hash=sha256:d9778479aaeac4f000d2b9c0f6da76149926011588b08a6b22892ae921c4f563 \ + --hash=sha256:da0ebc45da1d4e918fba53a2bc059339d00a1f666b3b72b9acd4fe4eeaec8343 \ + --hash=sha256:dab6441f4403483a420de9481987e68ab75a28ade5bac8f1d82a3533ed83a6eb \ + --hash=sha256:dc80ac80ace112da6d02f44681b6beb2ccecb68d6ac2b5e1b82d7f84347e1cf6 \ + --hash=sha256:dda677506171a0c4f27925e13f9f8b4b652941969f0e0e6b555ce795e18b7467 \ + --hash=sha256:de10b31770a8978e381c8f0c05479e18d6ea1defa18a0aa825b0487acd1f082c \ + --hash=sha256:df78545bc5d54abf875a70a70db7e1e12e606377d1c9f4e68b6763e8c701a748 \ + --hash=sha256:e3754a2529b0c163bece327be6c5e92fe885cd01d066bc4a3fc13224eae5d221 \ + --hash=sha256:e397f034d675ef14980f2df0a074dec3218963a912b7011d0e8e94f9a3d87d41 \ + --hash=sha256:e4786eac47d2e9dab245fc376157bdc458f4b3269b81006b80a74d514b37efb7 \ + --hash=sha256:e571e0e3de9bca0b70afc4261aa2df6a305bfff94b092942ccd081fa07b8a148 \ + --hash=sha256:e61a305384a59997fa1971a6c886db9b8e2ff59fca3ee9bb4e1fe191b6d02593 \ + --hash=sha256:e9c95f8c3a68449f7d3bd5860ea832b5827a04803337796da72d8340786e9268 \ + --hash=sha256:ebb00b54e5a01d8b10a51a7de7d2f884359375f4abb5352378f6f7f2c8878a58 \ + --hash=sha256:f24c6af8fd78b5c58e0f2683004e0b6fac146ee047b2d8fc8d7b16c69e0a3aaa \ + --hash=sha256:f65475871a57413dee3094cc5d479ed202af85c38bc90bda5d7ee4435ff96819 \ + --hash=sha256:f93a3425169961aa7f0c7193dc12f01414700f86f4fb99ec078c18780432b77a \ + --hash=sha256:fabb25c4352d3d4f1a2a197d6fb403848026344aab73e1674bcb31e4a7f54914 \ + --hash=sha256:fb712f94825c04fa605b989d6f5889195b51c927f5045c34b9ff7a448cd0a6f0 + # via sphinx-mounts imagesize==2.0.0 \ --hash=sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96 \ --hash=sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3 @@ -853,11 +969,11 @@ minijinja==2.19.0 \ myst-parser==5.0.0 \ --hash=sha256:ab31e516024918296e169139072b81592336f2fef55b8986aa31c9f04b5f7211 \ --hash=sha256:f6f231452c56e8baa662cc352c548158f6a16fcbd6e3800fc594978002b94f3a - # via -r requirements.in + # via -r src/requirements.in needs-config-writer==0.2.4 \ --hash=sha256:0f0702574081bb8ed7d896aadfb73c0e48af099dc0d4227cc2bac957ed8ea4f6 \ --hash=sha256:7c89375848c822e891b3cca48783f3cc3f7cbd3c02cba19418de146ca077f212 - # via -r requirements.in + # via -r src/requirements.in nodejs-wheel-binaries==24.14.1 \ --hash=sha256:404b563467129e6a0ea7006a38b3d8af0ebfbc340b31a6a0af2c59ea3af90b7c \ --hash=sha256:634f57829ebfdfe95d096f32a50c5cdd3a6c72a94dcf2b92a8bef9868cccb13e \ @@ -1061,11 +1177,11 @@ pycparser==3.0 \ pydata-sphinx-theme==0.17.0 \ --hash=sha256:529c5631582cb3328cf4814fb9eb80611d1704c854406d282a75c9c86e3a1955 \ --hash=sha256:cec5c92f41f4a11541b6df8210c446b4aa9c3badb7fcf2db7893405b786d5c99 - # via -r requirements.in + # via -r src/requirements.in pygithub==2.9.0 \ --hash=sha256:5e2b260ce327bffce9b00f447b65953ef7078ffe93e5a5425624a3075483927c \ --hash=sha256:a26abda1222febba31238682634cad11d8b966137ed6cc3c5e445b29a11cb0a4 - # via -r requirements.in + # via -r src/requirements.in pygls==1.3.1 \ --hash=sha256:140edceefa0da0e9b3c533547c892a42a7d2fd9217ae848c330c53d266a55018 \ --hash=sha256:6e00f11efc56321bdeb6eac04f6d86131f654c7d49124344a9ebb968da3dd91e @@ -1121,7 +1237,7 @@ pyspellchecker==0.9.0 \ pytest==9.0.3 \ --hash=sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9 \ --hash=sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c - # via -r requirements.in + # via -r src/requirements.in python-dateutil==2.9.0.post0 \ --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 @@ -1218,7 +1334,7 @@ requests-file==2.1.0 \ rich==14.3.3 \ --hash=sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d \ --hash=sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b - # via -r requirements.in + # via -r src/requirements.in roman-numerals==4.1.0 \ --hash=sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2 \ --hash=sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7 @@ -1226,7 +1342,7 @@ roman-numerals==4.1.0 \ ruamel-yaml==0.19.1 \ --hash=sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93 \ --hash=sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993 - # via -r requirements.in + # via -r src/requirements.in six==1.17.0 \ --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 @@ -1247,7 +1363,7 @@ sphinx==9.1.0 \ --hash=sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb \ --hash=sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978 # via - # -r requirements.in + # -r src/requirements.in # esbonio # myst-parser # needs-config-writer @@ -1256,6 +1372,7 @@ sphinx==9.1.0 \ # sphinx-collections # sphinx-data-viewer # sphinx-design + # sphinx-mounts # sphinx-needs # sphinxcontrib-jquery # sphinxcontrib-mermaid @@ -1263,11 +1380,11 @@ sphinx==9.1.0 \ sphinx-autobuild==2025.8.25 \ --hash=sha256:9cf5aab32853c8c31af572e4fecdc09c997e2b8be5a07daf2a389e270e85b213 \ --hash=sha256:b750ac7d5a18603e4665294323fd20f6dcc0a984117026d1986704fa68f0379a - # via -r requirements.in + # via -r src/requirements.in sphinx-collections==0.3.1 \ --hash=sha256:4dda762479d2ad2163ccb074b15f36f72810d9cd08be4daa69854a6e34c99f92 \ --hash=sha256:fb93b979cc9275bd2ad980a71fd57be5521c0f879f90f8189917a8f7ca0436ab - # via -r requirements.in + # via -r src/requirements.in sphinx-data-viewer==0.1.5 \ --hash=sha256:a7d5e58613562bb745380bfe61ca8b69997998167fd6fa9aea55606c9a4b17e4 \ --hash=sha256:b74b1d304c505c464d07c7b225ed0d84ea02dcc88bc1c49cdad7c2275fbbdad4 @@ -1275,12 +1392,16 @@ sphinx-data-viewer==0.1.5 \ sphinx-design==0.7.0 \ --hash=sha256:d2a3f5b19c24b916adb52f97c5f00efab4009ca337812001109084a740ec9b7a \ --hash=sha256:f82bf179951d58f55dca78ab3706aeafa496b741a91b1911d371441127d64282 - # via -r requirements.in + # via -r src/requirements.in +sphinx-mounts==0.1.0 \ + --hash=sha256:aff3450756727d0ca43538ea72b67fddb8b8799c52110f41de8871c59d3d1540 \ + --hash=sha256:d1818e3a0b7e0b327c6fa265afcf4d43f5bb7147276e69a9b35cd046deeb9439 + # via -r src/requirements.in sphinx-needs[plotting]==8.0.0 \ --hash=sha256:540c380c074d4088a557ea353e91513bfc1cb7712b10925c13ac9e5ebb7be091 \ --hash=sha256:c4336ee0e3c949eff9eb11a14910f7b6b68cb8284d731cfddf97694037337674 # via - # -r requirements.in + # -r src/requirements.in # needs-config-writer sphinxcontrib-applehelp==2.0.0 \ --hash=sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1 \ @@ -1305,10 +1426,10 @@ sphinxcontrib-jsmath==1.0.1 \ sphinxcontrib-mermaid==2.0.1 \ --hash=sha256:9dca7fbe827bad5e7e2b97c4047682cfd26e3e07398cfdc96c7a8842ae7f06e7 \ --hash=sha256:a21a385a059a6cafd192aa3a586b14bf5c42721e229db67b459dc825d7f0a497 - # via -r requirements.in + # via -r src/requirements.in sphinxcontrib-plantuml==0.31 \ --hash=sha256:fd74752f8ea070e641c3f8a402fccfa1d4a4056e0967b56033d2a76282d9f956 - # via -r requirements.in + # via -r src/requirements.in sphinxcontrib-qthelp==2.0.0 \ --hash=sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab \ --hash=sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb @@ -1373,7 +1494,9 @@ tomli==2.4.1 \ tomli-w==1.2.0 \ --hash=sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90 \ --hash=sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021 - # via needs-config-writer + # via + # -r src/requirements.in + # needs-config-writer typing-extensions==4.15.0 \ --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548