diff --git a/docs/conf.py b/docs/conf.py index 0255915c..48385fcf 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 00000000..58fbffaa --- /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 4bc0ae7b..d4caf254 100644 --- a/docs/how-to/index.rst +++ b/docs/how-to/index.rst @@ -15,3 +15,4 @@ Here you find practical guides on how to use docs-as-code. other_modules source_to_doc_links test_to_doc_links + any_folder diff --git a/src/BUILD b/src/BUILD index d1b93a8c..ca0f4d87 100644 --- a/src/BUILD +++ b/src/BUILD @@ -36,6 +36,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 00000000..c5763850 --- /dev/null +++ b/src/extensions/docs/any_folder.rst @@ -0,0 +1,11 @@ +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. 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 89% rename from docs/internals/extensions/index.rst rename to src/extensions/docs/index.rst index 2ecec2d2..6bcba10f 100644 --- a/docs/internals/extensions/index.rst +++ b/src/extensions/docs/index.rst @@ -71,6 +71,15 @@ 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 +92,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 100% rename from docs/internals/extensions/sync_toml.rst rename to src/extensions/docs/sync_toml.rst diff --git a/src/extensions/score_any_folder/BUILD b/src/extensions/score_any_folder/BUILD new file mode 100644 index 00000000..0a592327 --- /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 00000000..d2cc53da --- /dev/null +++ b/src/extensions/score_any_folder/__init__.py @@ -0,0 +1,96 @@ +# ******************************************************************************* +# 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. A +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__) + + +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) + 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: + 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) + logger.debug("score_any_folder: created symlink %s -> %s", link, source) 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 00000000..ca5de742 --- /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 00000000..996b6cf4 --- /dev/null +++ b/src/extensions/score_any_folder/tests/test_score_any_folder.py @@ -0,0 +1,166 @@ +# ******************************************************************************* +# 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( + f'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, 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, docs_dir: Path, tmp_path: Path +) -> None: + """A second build with the same mapping reuses the symlink without errors.""" + src_docs = tmp_path / "external" + src_docs.mkdir() + + make_sphinx_app({"../external": "notes"}).build() + link = docs_dir / "notes" + assert link.is_symlink() + + make_sphinx_app({"../external": "notes"}).build() + + assert link.is_symlink() + assert link.resolve() == src_docs.resolve() + + +def test_stale_symlink_is_replaced( + make_sphinx_app: Callable, 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, 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 = 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, 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, 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, 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_sphinx_bundle/BUILD b/src/extensions/score_sphinx_bundle/BUILD index c8d0a0a7..7fb69b71 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 d30df6c3..abaebc43 100644 --- a/src/extensions/score_sphinx_bundle/__init__.py +++ b/src/extensions/score_sphinx_bundle/__init__.py @@ -29,6 +29,7 @@ "sphinxcontrib.mermaid", "needs_config_writer", "score_sync_toml", + "score_any_folder", ]