diff --git a/docs/conf.py b/docs/conf.py index 0255915c2..48385fcf2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,3 +18,7 @@ extensions = [ "score_sphinx_bundle", ] + +score_any_folder_mapping = { + "../src/extensions/docs": "internals/extensions", +} diff --git a/docs/how-to/any_folder.rst b/docs/how-to/any_folder.rst new file mode 100644 index 000000000..0e3c14276 --- /dev/null +++ b/docs/how-to/any_folder.rst @@ -0,0 +1,29 @@ +Use Any Folder for Documentation +================================ + +Generally, your documentation must be ``docs/``, +but the RST files for a module may live closer to the code they describe, +for example in ``src/my_module/docs/``. +You can symlink the folders by adding to your ``conf.py``: + +.. code-block:: python + + score_any_folder_mapping = { + "../score/containers/docs": "component/containers", + } + +With this configuration, all files in ``score/containers/docs/`` become available at ``docs/component/containers/``. + +If you have ``docs/component/overview.rst``, for example, +you can include the component documentation via ``toctree``: + +.. code-block:: rst + + .. toctree:: + + containers/index + +Only relative links are allowed. + +The symlinks will show up in your sources. +**Don't commit the symlinks to git!** diff --git a/docs/how-to/index.rst b/docs/how-to/index.rst index 36888a92e..866910c3f 100644 --- a/docs/how-to/index.rst +++ b/docs/how-to/index.rst @@ -16,3 +16,4 @@ Here you find practical guides on how to use docs-as-code. source_to_doc_links test_to_doc_links add_extensions + any_folder diff --git a/src/BUILD b/src/BUILD index 44bd84836..71e3a43f3 100644 --- a/src/BUILD +++ b/src/BUILD @@ -37,6 +37,7 @@ filegroup( srcs = glob( ["*.py"], ) + [ + "//src/extensions/score_any_folder:all_sources", "//src/extensions/score_draw_uml_funcs:all_sources", "//src/extensions/score_header_service:all_sources", "//src/extensions/score_layout:all_sources", diff --git a/src/extensions/docs/any_folder.rst b/src/extensions/docs/any_folder.rst new file mode 100644 index 000000000..d69864ba6 --- /dev/null +++ b/src/extensions/docs/any_folder.rst @@ -0,0 +1,38 @@ +.. + # ******************************************************************************* + # 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 + # ******************************************************************************* + +Any Folder +========== + +The extension ``score_any_folder`` allows documentation roots to stay in ``docs/`` +while pulling in source files from anywhere else in the repository. + +It does this by creating symlinks inside the Sphinx source directory (``confdir``) that point to the configured external directories. +Sphinx then discovers and buildsthose files as if they were part of ``docs/`` from the start. + +The extension hooks into the ``builder-inited`` event, +which fires before Sphinx reads any documents. + +Difference to Sphinx-Collections +-------------------------------- + +The extension `sphinx-collections `_ +is very similar to this extension. +We use it for including external modules +as it allows conditional inclusion +and we need to switch between "normal" and "combo" builds. + +The relevant difference is that this extension allows to include folders to anywhere +and is not restricted to a ``_collections/`` folder. +We consider this additional control over folder placement necessary. diff --git a/docs/internals/extensions/data_flow.png b/src/extensions/docs/data_flow.png similarity index 100% rename from docs/internals/extensions/data_flow.png rename to src/extensions/docs/data_flow.png diff --git a/docs/internals/extensions/extension_guide.md b/src/extensions/docs/extension_guide.md similarity index 100% rename from docs/internals/extensions/extension_guide.md rename to src/extensions/docs/extension_guide.md diff --git a/docs/internals/extensions/header_service.md b/src/extensions/docs/header_service.md similarity index 100% rename from docs/internals/extensions/header_service.md rename to src/extensions/docs/header_service.md diff --git a/docs/internals/extensions/index.rst b/src/extensions/docs/index.rst similarity index 67% rename from docs/internals/extensions/index.rst rename to src/extensions/docs/index.rst index 2ecec2d26..a9c48b306 100644 --- a/docs/internals/extensions/index.rst +++ b/src/extensions/docs/index.rst @@ -1,15 +1,16 @@ -.. # ******************************************************************************* - # 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 - # ******************************************************************************* +.. + # ******************************************************************************* + # 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 + # ******************************************************************************* .. _extensions: @@ -71,6 +72,16 @@ Hello there `ubCode `__ VS Code extension. Getting IDE support for Sphinx-Needs in a Bazel context made easy. + .. grid-item-card:: + + Any Folder + ^^^^^^^^^^ + + Learn about the :doc:`any_folder` extension that creates symlinks + from arbitrary repository locations into the docs folder, + allowing Sphinx to discover and build source files + that live outside the documentation root. + .. toctree:: @@ -83,3 +94,4 @@ Hello there Source Code Linker Extension Guide Sync TOML + Any Folder diff --git a/docs/internals/extensions/metamodel.md b/src/extensions/docs/metamodel.md similarity index 100% rename from docs/internals/extensions/metamodel.md rename to src/extensions/docs/metamodel.md diff --git a/docs/internals/extensions/rst_filebased_testing.md b/src/extensions/docs/rst_filebased_testing.md similarity index 100% rename from docs/internals/extensions/rst_filebased_testing.md rename to src/extensions/docs/rst_filebased_testing.md diff --git a/docs/internals/extensions/source_code_linker.md b/src/extensions/docs/source_code_linker.md similarity index 100% rename from docs/internals/extensions/source_code_linker.md rename to src/extensions/docs/source_code_linker.md diff --git a/docs/internals/extensions/sync_toml.rst b/src/extensions/docs/sync_toml.rst similarity index 78% rename from docs/internals/extensions/sync_toml.rst rename to src/extensions/docs/sync_toml.rst index 96a494d71..df2effb91 100644 --- a/docs/internals/extensions/sync_toml.rst +++ b/src/extensions/docs/sync_toml.rst @@ -1,3 +1,17 @@ +.. + # ******************************************************************************* + # 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 + # ******************************************************************************* + .. _`toml_sync`: ubproject.toml sync diff --git a/src/extensions/score_any_folder/BUILD b/src/extensions/score_any_folder/BUILD new file mode 100644 index 000000000..0a5923278 --- /dev/null +++ b/src/extensions/score_any_folder/BUILD @@ -0,0 +1,50 @@ +# ******************************************************************************* +# 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_tooling//:defs.bzl", "score_py_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_any_folder", + srcs = [":sources"], + imports = ["."], + visibility = ["//visibility:public"], + deps = [requirement("sphinx")], +) + +score_py_pytest( + name = "score_any_folder_tests", + size = "small", + srcs = glob(["tests/*.py"]), + deps = [":score_any_folder"], +) diff --git a/src/extensions/score_any_folder/__init__.py b/src/extensions/score_any_folder/__init__.py new file mode 100644 index 000000000..13526b775 --- /dev/null +++ b/src/extensions/score_any_folder/__init__.py @@ -0,0 +1,118 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Sphinx extension that creates symlinks from arbitrary locations into the +documentation source directory, allowing sphinx-build to include source +files that live outside ``docs/``. + +Configuration in ``conf.py``:: + + score_any_folder_mapping = { + "../src/my_module/docs": "my_module", + } + +Each entry is a ``source: target`` pair where: + +* ``source`` – path to the directory to expose, relative to ``confdir`` + (the directory containing ``conf.py``). +* ``target`` – path of the symlink to create, relative to ``confdir``. + +The extension creates the symlinks on ``builder-inited``, +before Sphinx starts reading any documents. +Existing correct symlinks are left in place(idempotent); +a symlink pointing to the wrong target is replaced. + +Symlinks created by this extension are removed again on ``build-finished``. +Misconfigured pairs (absolute paths, non-symlink path at the target location) +are logged as errors and skipped. +""" + +from pathlib import Path + +from sphinx.application import Sphinx +from sphinx.util.logging import getLogger + +logger = getLogger(__name__) + +_APP_ATTRIBUTE = "_score_any_folder_created_links" + +def setup(app: Sphinx) -> dict[str, str | bool]: + app.add_config_value("score_any_folder_mapping", default={}, rebuild="env") + app.connect("builder-inited", _create_symlinks) + app.connect("build-finished", _cleanup_symlinks) + return { + "version": "0.1", + "parallel_read_safe": True, + "parallel_write_safe": True, + } + + +def _symlink_pairs(app: Sphinx) -> list[tuple[Path, Path]]: + """Return ``(resolved_source, link_path)`` pairs from the mapping.""" + confdir = Path(app.confdir) + pairs = [] + for source_rel, target_rel in app.config.score_any_folder_mapping.items(): + if Path(source_rel).is_absolute(): + logger.error( + "score_any_folder: source path must be relative, got: %r; skipping", + source_rel, + ) + continue + if Path(target_rel).is_absolute(): + logger.error( + "score_any_folder: target path must be relative, got: %r; skipping", + target_rel, + ) + continue + source = (confdir / source_rel).resolve() + link = confdir / target_rel + pairs.append((source, link)) + return pairs + + +def _create_symlinks(app: Sphinx) -> None: + created_links: set[Path] = set() + + for source, link in _symlink_pairs(app): + if link.is_symlink(): + if link.resolve() == source: + logger.debug("score_any_folder: symlink already correct: %s", link) + continue + logger.info( + "score_any_folder: replacing stale symlink %s -> %s", link, source + ) + link.unlink() + elif link.exists(): + logger.error( + "score_any_folder: target path already exists and is not a symlink: " + "%s; skipping", + link, + ) + continue + + link.parent.mkdir(parents=True, exist_ok=True) + link.symlink_to(source) + created_links.add(link) + logger.debug("score_any_folder: created symlink %s -> %s", link, source) + + setattr(app, _APP_ATTRIBUTE, created_links) + + +def _cleanup_symlinks(app: Sphinx, exception: Exception | None) -> None: + del exception + + created_links: set[Path] = getattr(app, _APP_ATTRIBUTE, set()) + for link in created_links: + if not link.is_symlink(): + continue + link.unlink() + logger.debug("score_any_folder: removed temporary symlink %s", link) diff --git a/src/extensions/score_any_folder/tests/__init__.py b/src/extensions/score_any_folder/tests/__init__.py new file mode 100644 index 000000000..ca5de742e --- /dev/null +++ b/src/extensions/score_any_folder/tests/__init__.py @@ -0,0 +1,12 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* diff --git a/src/extensions/score_any_folder/tests/test_score_any_folder.py b/src/extensions/score_any_folder/tests/test_score_any_folder.py new file mode 100644 index 000000000..0f04d6ce5 --- /dev/null +++ b/src/extensions/score_any_folder/tests/test_score_any_folder.py @@ -0,0 +1,177 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +import os +from collections.abc import Callable, Generator +from contextlib import suppress +from pathlib import Path + +import pytest +from sphinx.testing.util import SphinxTestApp + + +def _make_app(srcdir: Path, outdir: Path) -> SphinxTestApp: + original_cwd = None + with suppress(FileNotFoundError): + original_cwd = os.getcwd() + os.chdir(srcdir) + try: + return SphinxTestApp( + freshenv=True, + srcdir=srcdir, + confdir=srcdir, + outdir=outdir, + buildername="html", + ) + finally: + if original_cwd is not None: + with suppress(FileNotFoundError, OSError): + os.chdir(original_cwd) + + +@pytest.fixture +def docs_dir(tmp_path: Path) -> Path: + d = tmp_path / "docs" + d.mkdir() + return d + + +@pytest.fixture +def make_sphinx_app( + docs_dir: Path, tmp_path: Path +) -> Generator[Callable[[dict[str, str]], SphinxTestApp], None, None]: + """Factory: writes conf + index, returns a SphinxTestApp, cleans up on teardown.""" + apps: list[SphinxTestApp] = [] + + def _factory(mapping: dict[str, str]) -> SphinxTestApp: + (docs_dir / "conf.py").write_text( + 'extensions = ["score_any_folder"]\n' + f"score_any_folder_mapping = {mapping!r}\n" + ) + (docs_dir / "index.rst").write_text("Root\n====\n") + app = _make_app(docs_dir, tmp_path / "out") + apps.append(app) + return app + + yield _factory + + for app in apps: + app.cleanup() + + +def test_symlink_exposes_files_at_target_path( + make_sphinx_app: Callable[[dict[str, str]], SphinxTestApp], + docs_dir: Path, + tmp_path: Path, +) -> None: + """Files in the source directory are readable via the symlinked target path.""" + src_docs = tmp_path / "src" / "module_docs" + src_docs.mkdir(parents=True) + content = "Remote Page\n===========\n\nContent here.\n" + (src_docs / "page.rst").write_text(content) + + make_sphinx_app({"../src/module_docs": "module"}) + + assert (docs_dir / "module" / "page.rst").read_text() == content + + +def test_symlink_is_idempotent( + make_sphinx_app: Callable[[dict[str, str]], SphinxTestApp], + docs_dir: Path, + tmp_path: Path, +) -> None: + """Build cleanup removes temporary links and a second build still succeeds.""" + src_docs = tmp_path / "external" + src_docs.mkdir() + + make_sphinx_app({"../external": "notes"}).build() + link = docs_dir / "notes" + assert not link.exists() + + make_sphinx_app({"../external": "notes"}).build() + + assert not link.exists() + + +def test_stale_symlink_is_replaced( + make_sphinx_app: Callable[[dict[str, str]], SphinxTestApp], + docs_dir: Path, + tmp_path: Path, +) -> None: + """A symlink pointing to a stale target is replaced with the correct one.""" + correct_src = tmp_path / "correct" + correct_src.mkdir() + wrong_target = tmp_path / "wrong" + wrong_target.mkdir() + (docs_dir / "module").symlink_to(wrong_target) + + make_sphinx_app({"../correct": "module"}) + + assert (docs_dir / "module").resolve() == correct_src.resolve() + + +def test_existing_non_symlink_logs_error_and_skips( + make_sphinx_app: Callable[[dict[str, str]], SphinxTestApp], + docs_dir: Path, + tmp_path: Path, +) -> None: + """A real directory at the target path is left untouched and an error is logged.""" + (tmp_path / "external").mkdir() + real_dir = docs_dir / "module" + real_dir.mkdir() + + app: SphinxTestApp = make_sphinx_app({"../external": "module"}) + + assert real_dir.is_dir() and not real_dir.is_symlink() + assert "not a symlink" in app.warning.getvalue() + + +def test_empty_mapping_is_a_no_op( + make_sphinx_app: Callable[[dict[str, str]], SphinxTestApp], docs_dir: Path +) -> None: + """An empty mapping produces no symlinks and no errors.""" + make_sphinx_app({}).build() + + assert [p for p in docs_dir.iterdir() if p.is_symlink()] == [] + + +def test_multiple_mappings( + make_sphinx_app: Callable[[dict[str, str]], SphinxTestApp], + docs_dir: Path, + tmp_path: Path, +) -> None: + """Multiple mapping entries each produce their own symlink.""" + for name in ("alpha", "beta"): + (tmp_path / name).mkdir() + + make_sphinx_app({"../alpha": "alpha", "../beta": "beta"}) + + for name in ("alpha", "beta"): + link = docs_dir / name + assert link.is_symlink(), f"symlink for {name!r} was not created" + assert link.resolve() == (tmp_path / name).resolve() + + +def test_target_in_subfolder( + make_sphinx_app: Callable[[dict[str, str]], SphinxTestApp], + docs_dir: Path, + tmp_path: Path, +) -> None: + """A target path with intermediate directories creates the parent dirs.""" + src_docs = tmp_path / "external" + src_docs.mkdir() + + make_sphinx_app({"../external": "foo/other"}) + + link = docs_dir / "foo" / "other" + assert link.is_symlink() + assert link.resolve() == src_docs.resolve() diff --git a/src/extensions/score_source_code_linker/xml_parser.py b/src/extensions/score_source_code_linker/xml_parser.py index d91358bdb..8432e1fc3 100644 --- a/src/extensions/score_source_code_linker/xml_parser.py +++ b/src/extensions/score_source_code_linker/xml_parser.py @@ -234,7 +234,7 @@ def build_test_needs_from_files( tcns: list[DataOfTestCase] = [] for file in xml_paths: # Last value can be ignored. The 'is_valid' function already prints infos - test_cases, tests_missing_all_props,_ = read_test_xml_file(file) + test_cases, tests_missing_all_props, _ = read_test_xml_file(file) non_prop_tests = ", ".join(n for n in tests_missing_all_props) if non_prop_tests: logger.info(f"Tests missing all properties: {non_prop_tests}") diff --git a/src/extensions/score_sphinx_bundle/BUILD b/src/extensions/score_sphinx_bundle/BUILD index c8d0a0a79..7fb69b719 100644 --- a/src/extensions/score_sphinx_bundle/BUILD +++ b/src/extensions/score_sphinx_bundle/BUILD @@ -25,6 +25,7 @@ py_library( visibility = ["//visibility:public"], deps = all_requirements + [ "@score_docs_as_code//src/extensions:score_plantuml", + "@score_docs_as_code//src/extensions/score_any_folder", "@score_docs_as_code//src/extensions/score_draw_uml_funcs", "@score_docs_as_code//src/extensions/score_header_service", "@score_docs_as_code//src/extensions/score_layout", diff --git a/src/extensions/score_sphinx_bundle/__init__.py b/src/extensions/score_sphinx_bundle/__init__.py index 6ae04008b..152088365 100644 --- a/src/extensions/score_sphinx_bundle/__init__.py +++ b/src/extensions/score_sphinx_bundle/__init__.py @@ -31,6 +31,7 @@ "sphinxcontrib.mermaid", "needs_config_writer", "score_sync_toml", + "score_any_folder", ] diff --git a/src/helper_lib/test_helper_lib.py b/src/helper_lib/test_helper_lib.py index 0486821a5..5e335b2eb 100644 --- a/src/helper_lib/test_helper_lib.py +++ b/src/helper_lib/test_helper_lib.py @@ -29,7 +29,7 @@ class _FakeConfig: """Minimal stand-in for sphinx.config.Config.""" - def __init__(self, raw: dict): + def __init__(self, raw: dict[str, object]): self._raw_config = raw