-
Notifications
You must be signed in to change notification settings - Fork 22
Any folder in docs #431
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Any folder in docs #431
Changes from all commits
8d68b99
574e131
7a7faf9
7c17326
6986961
bc6884d
16e0160
229e3e3
87feae8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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!** | ||
a-zw marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <https://sphinx-collections.readthedocs.io/>`_ | ||
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"], | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
Comment on lines
+65
to
+69
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this truly be an error then?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The thing is that it might work locally but it will almost certainly break in the CI. I agree that it is weird to report an error and then simply 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): | ||
a-zw marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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, | ||
| ) | ||
a-zw marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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) | ||
a-zw marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
|
|
||
| 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) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| # ******************************************************************************* |
Uh oh!
There was an error while loading. Please reload this page.