Skip to content

Commit fcdf69d

Browse files
committed
tinkering but doesn't work yet
1 parent 8ba08cc commit fcdf69d

4 files changed

Lines changed: 264 additions & 37 deletions

File tree

docs/how-to/any_folder.rst

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ You can symlink the folders by adding to your ``conf.py``:
1212
"../score/containers/docs": "component/containers",
1313
}
1414
15-
With this configuration, all files in ``score/containers/docs/`` become available at ``docs/component/containers/``.
15+
All files in ``score/containers/docs/`` become available at ``docs/component/containers/``.
16+
Include them via ``toctree`` as usual.
1617

1718
If you have ``docs/component/overview.rst``, for example,
1819
you can include the component documentation via ``toctree``:
@@ -46,3 +47,20 @@ in your ``docs()`` call so Bazel tracks them as dependencies:
4647
4748
This is necessary for sandboxed builds.
4849
For example, when other modules use your documentation's ``needs.json`` as a dependency.
50+
51+
Combo builds
52+
------------
53+
54+
When a combo build aggregates multiple modules, only the main ``conf.py`` is
55+
processed by Sphinx. External modules mounted via ``sphinx_collections`` have
56+
their own ``score_any_folder_mapping`` that would otherwise be ignored, leaving
57+
their externally-mapped files at the wrong paths.
58+
59+
No extra configuration is required. The extension automatically scans
60+
``confdir`` subdirectories for ``conf.py`` files after the primary symlink pass
61+
and applies any ``score_any_folder_mapping`` it finds, with paths resolved
62+
relative to each module's directory. All secondary symlinks are cleaned up
63+
alongside the primary ones when the build finishes.
64+
65+
The handler runs at event priority 600 (above the default 500) to ensure
66+
``sphinx_collections`` has finished mounting modules before the scan begins.

src/extensions/docs/any_folder.rst

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,23 @@ Sphinx then discovers and buildsthose files as if they were part of ``docs/`` fr
2424
The extension hooks into the ``builder-inited`` event,
2525
which fires before Sphinx reads any documents.
2626

27+
Configuration reference
28+
-----------------------
29+
30+
``score_any_folder_mapping``
31+
*dict[str, str]*, default ``{}``
32+
33+
Maps source directories to symlink paths, both relative to ``confdir``.
34+
Applied on every Sphinx build.
35+
36+
.. code-block:: python
37+
38+
score_any_folder_mapping = {
39+
"../src/my_module/docs": "my_module",
40+
}
41+
2742
Difference to Sphinx-Collections
28-
--------------------------------
43+
---------------------------------
2944

3045
The extension `sphinx-collections <https://sphinx-collections.readthedocs.io/>`_
3146
is very similar to this extension.

src/extensions/score_any_folder/__init__.py

Lines changed: 102 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,28 @@
2828
2929
The extension creates the symlinks on ``builder-inited``,
3030
before Sphinx starts reading any documents.
31-
Existing correct symlinks are left in place(idempotent);
31+
Existing correct symlinks are left in place (idempotent);
3232
a symlink pointing to the wrong target is replaced.
3333
3434
Symlinks created by this extension are removed again on ``build-finished``.
3535
Misconfigured pairs (absolute paths, non-symlink path at the target location)
3636
are logged as errors and skipped.
37+
38+
Combo builds
39+
------------
40+
41+
When a combo build mounts external modules via ``sphinx_collections``,
42+
those modules may have their own ``score_any_folder_mapping`` in their
43+
``conf.py``. This extension automatically discovers those files by scanning
44+
``confdir`` subdirectories after the primary symlink pass and applies their
45+
mappings with paths resolved relative to each module's directory.
46+
47+
No extra configuration is required. The handler is registered at event
48+
priority 600 (above the default 500) to ensure it runs after
49+
``sphinx_collections`` has mounted its collections.
3750
"""
3851

52+
import ast
3953
from pathlib import Path
4054

4155
from sphinx.application import Sphinx
@@ -45,9 +59,11 @@
4559

4660
_APP_ATTRIBUTE = "_score_any_folder_created_links"
4761

62+
4863
def setup(app: Sphinx) -> dict[str, str | bool]:
4964
app.add_config_value("score_any_folder_mapping", default={}, rebuild="env")
50-
app.connect("builder-inited", _create_symlinks)
65+
# Priority 600 > default 500: run after sphinx_collections has mounted modules.
66+
app.connect("builder-inited", _create_symlinks, priority=600)
5167
app.connect("build-finished", _cleanup_symlinks)
5268
return {
5369
"version": "0.1",
@@ -56,11 +72,39 @@ def setup(app: Sphinx) -> dict[str, str | bool]:
5672
}
5773

5874

59-
def _symlink_pairs(app: Sphinx) -> list[tuple[Path, Path]]:
60-
"""Return ``(resolved_source, link_path)`` pairs from the mapping."""
61-
confdir = Path(app.confdir)
75+
def _extract_mapping_from_conf(conf_path: Path) -> dict[str, str]:
76+
"""Safely extract ``score_any_folder_mapping`` from a ``conf.py`` file.
77+
78+
Uses ``ast.literal_eval`` so no arbitrary code is executed.
79+
Returns an empty dict if the key is absent or cannot be parsed.
80+
"""
81+
try:
82+
tree = ast.parse(conf_path.read_text(encoding="utf-8"))
83+
for node in ast.walk(tree):
84+
if not isinstance(node, ast.Assign):
85+
continue
86+
for target in node.targets:
87+
if (
88+
isinstance(target, ast.Name)
89+
and target.id == "score_any_folder_mapping"
90+
):
91+
return ast.literal_eval(node.value)
92+
except Exception as exc: # noqa: BLE001
93+
logger.debug(
94+
"score_any_folder: could not extract mapping from %s: %s",
95+
conf_path,
96+
exc,
97+
)
98+
return {}
99+
100+
101+
def _symlink_pairs(confdir: Path, mapping: dict[str, str]) -> list[tuple[Path, Path]]:
102+
"""Return ``(resolved_source, link_path)`` pairs from *mapping*.
103+
104+
Entries with absolute paths are logged as errors and skipped.
105+
"""
62106
pairs = []
63-
for source_rel, target_rel in app.config.score_any_folder_mapping.items():
107+
for source_rel, target_rel in mapping.items():
64108
if Path(source_rel).is_absolute():
65109
logger.error(
66110
"score_any_folder: source path must be relative, got: %r; skipping",
@@ -79,39 +123,62 @@ def _symlink_pairs(app: Sphinx) -> list[tuple[Path, Path]]:
79123
return pairs
80124

81125

126+
def _maybe_create_symlink(source: Path, link: Path, created_links: set[Path]) -> None:
127+
"""Create a symlink at *link* pointing to *source*, if needed.
128+
129+
Handles the idempotent / stale-symlink / existing-path cases and logs
130+
errors without raising. Successfully created links are added to
131+
*created_links* for later cleanup.
132+
"""
133+
if link.is_symlink():
134+
if link.resolve() == source:
135+
logger.debug("score_any_folder: symlink already correct: %s", link)
136+
return
137+
logger.info("score_any_folder: replacing stale symlink %s -> %s", link, source)
138+
link.unlink()
139+
elif link.exists():
140+
logger.error(
141+
"score_any_folder: target path already exists and is not a symlink: "
142+
"%s; skipping",
143+
link,
144+
)
145+
return
146+
147+
link.parent.mkdir(parents=True, exist_ok=True)
148+
try:
149+
link.symlink_to(source)
150+
except OSError as exc:
151+
logger.error(
152+
"score_any_folder: failed to create symlink %s -> %s: %s",
153+
link,
154+
source,
155+
exc,
156+
)
157+
return
158+
created_links.add(link)
159+
logger.debug("score_any_folder: created symlink %s -> %s", link, source)
160+
161+
82162
def _create_symlinks(app: Sphinx) -> None:
83163
created_links: set[Path] = set()
164+
confdir = Path(app.confdir)
84165

85-
for source, link in _symlink_pairs(app):
86-
if link.is_symlink():
87-
if link.resolve() == source:
88-
logger.debug("score_any_folder: symlink already correct: %s", link)
89-
continue
90-
logger.info(
91-
"score_any_folder: replacing stale symlink %s -> %s", link, source
92-
)
93-
link.unlink()
94-
elif link.exists():
95-
logger.error(
96-
"score_any_folder: target path already exists and is not a symlink: "
97-
"%s; skipping",
98-
link,
99-
)
100-
continue
101-
102-
link.parent.mkdir(parents=True, exist_ok=True)
103-
try:
104-
link.symlink_to(source)
105-
except OSError as exc:
106-
logger.error(
107-
"score_any_folder: failed to create symlink %s -> %s: %s",
108-
link,
109-
source,
110-
exc,
111-
)
166+
# Primary pass — mappings defined in the main conf.py.
167+
for source, link in _symlink_pairs(confdir, app.config.score_any_folder_mapping):
168+
_maybe_create_symlink(source, link, created_links)
169+
170+
# Secondary pass — auto-discover conf.py files in subdirectories.
171+
# Picks up modules mounted by sphinx_collections (or any other mechanism).
172+
# Running at priority 600 ensures sphinx_collections has already mounted
173+
# its collections before we scan.
174+
for conf_py in sorted(confdir.rglob("conf.py")):
175+
if conf_py.parent == confdir:
176+
continue # skip the main conf.py
177+
module_mapping = _extract_mapping_from_conf(conf_py)
178+
if not module_mapping:
112179
continue
113-
created_links.add(link)
114-
logger.debug("score_any_folder: created symlink %s -> %s", link, source)
180+
for source, link in _symlink_pairs(conf_py.parent, module_mapping):
181+
_maybe_create_symlink(source, link, created_links)
115182

116183
setattr(app, _APP_ATTRIBUTE, created_links)
117184

src/extensions/score_any_folder/tests/test_score_any_folder.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from pathlib import Path
1717

1818
import pytest
19+
from score_any_folder import _extract_mapping_from_conf
1920
from sphinx.testing.util import SphinxTestApp
2021

2122

@@ -68,6 +69,49 @@ def _factory(mapping: dict[str, str]) -> SphinxTestApp:
6869
app.cleanup()
6970

7071

72+
# ---------------------------------------------------------------------------
73+
# _extract_mapping_from_conf
74+
# ---------------------------------------------------------------------------
75+
76+
77+
def test_extract_mapping_returns_dict(tmp_path: Path) -> None:
78+
conf = tmp_path / "conf.py"
79+
conf.write_text('score_any_folder_mapping = {"../src": "src"}\n')
80+
assert _extract_mapping_from_conf(conf) == {"../src": "src"}
81+
82+
83+
def test_extract_mapping_missing_key_returns_empty(tmp_path: Path) -> None:
84+
conf = tmp_path / "conf.py"
85+
conf.write_text("project = 'test'\n")
86+
assert _extract_mapping_from_conf(conf) == {}
87+
88+
89+
def test_extract_mapping_non_literal_value_returns_empty(tmp_path: Path) -> None:
90+
conf = tmp_path / "conf.py"
91+
conf.write_text("score_any_folder_mapping = dict(src='src')\n")
92+
assert _extract_mapping_from_conf(conf) == {}
93+
94+
95+
def test_extract_mapping_syntax_error_returns_empty(tmp_path: Path) -> None:
96+
conf = tmp_path / "conf.py"
97+
conf.write_text("score_any_folder_mapping = {this is not valid python\n")
98+
assert _extract_mapping_from_conf(conf) == {}
99+
100+
101+
def test_extract_mapping_multiple_assignments_returns_first(tmp_path: Path) -> None:
102+
conf = tmp_path / "conf.py"
103+
conf.write_text(
104+
'score_any_folder_mapping = {"../a": "a"}\n'
105+
'score_any_folder_mapping = {"../b": "b"}\n'
106+
)
107+
assert _extract_mapping_from_conf(conf) == {"../a": "a"}
108+
109+
110+
# ---------------------------------------------------------------------------
111+
# Primary symlink behaviour
112+
# ---------------------------------------------------------------------------
113+
114+
71115
def test_symlink_exposes_files_at_target_path(
72116
make_sphinx_app: Callable[[dict[str, str]], SphinxTestApp],
73117
docs_dir: Path,
@@ -175,3 +219,86 @@ def test_target_in_subfolder(
175219
link = docs_dir / "foo" / "other"
176220
assert link.is_symlink()
177221
assert link.resolve() == src_docs.resolve()
222+
223+
224+
# ---------------------------------------------------------------------------
225+
# Auto-discovery of module conf.py files (combo build support)
226+
# ---------------------------------------------------------------------------
227+
228+
229+
def test_autodiscovery_applies_module_mapping(
230+
make_sphinx_app: Callable[[dict[str, str]], SphinxTestApp],
231+
docs_dir: Path,
232+
tmp_path: Path,
233+
) -> None:
234+
"""A conf.py found in a subdirectory has its mapping applied automatically."""
235+
# Simulate a sphinx_collections mount at docs/_collections/module/
236+
module_docs = docs_dir / "_collections" / "module"
237+
module_docs.mkdir(parents=True)
238+
containers = tmp_path / "module_repo" / "containers" / "docs"
239+
containers.mkdir(parents=True)
240+
(containers / "page.rst").write_text("Container Page\n==============\n")
241+
(module_docs / "conf.py").write_text(
242+
'score_any_folder_mapping = {"../../../module_repo/containers/docs":'
243+
' "component/containers"}\n'
244+
)
245+
246+
make_sphinx_app({})
247+
248+
link = module_docs / "component" / "containers"
249+
assert link.is_symlink()
250+
assert link.resolve() == containers.resolve()
251+
assert (link / "page.rst").read_text() == "Container Page\n==============\n"
252+
253+
254+
def test_autodiscovery_cleans_up_secondary_symlinks(
255+
make_sphinx_app: Callable[[dict[str, str]], SphinxTestApp],
256+
docs_dir: Path,
257+
tmp_path: Path,
258+
) -> None:
259+
"""Secondary symlinks from auto-discovered modules are removed on build-finished."""
260+
module_docs = docs_dir / "_collections" / "module"
261+
module_docs.mkdir(parents=True)
262+
external = tmp_path / "external"
263+
external.mkdir()
264+
(module_docs / "conf.py").write_text(
265+
'score_any_folder_mapping = {"../../../external": "ext"}\n'
266+
)
267+
268+
make_sphinx_app({}).build()
269+
270+
assert not (module_docs / "ext").exists()
271+
272+
273+
def test_autodiscovery_ignores_conf_without_mapping(
274+
make_sphinx_app: Callable[[dict[str, str]], SphinxTestApp],
275+
docs_dir: Path,
276+
) -> None:
277+
"""A subdirectory conf.py with no score_any_folder_mapping produces no symlinks."""
278+
module_docs = docs_dir / "_collections" / "module"
279+
module_docs.mkdir(parents=True)
280+
(module_docs / "conf.py").write_text("project = 'test'\n")
281+
282+
make_sphinx_app({}).build()
283+
284+
assert [p for p in module_docs.iterdir() if p.is_symlink()] == []
285+
286+
287+
def test_autodiscovery_nested_conf(
288+
make_sphinx_app: Callable[[dict[str, str]], SphinxTestApp],
289+
docs_dir: Path,
290+
tmp_path: Path,
291+
) -> None:
292+
"""Auto-discovery works for conf.py files nested more than one level deep."""
293+
nested_docs = docs_dir / "_collections" / "org" / "module" / "docs"
294+
nested_docs.mkdir(parents=True)
295+
external = tmp_path / "external"
296+
external.mkdir()
297+
(nested_docs / "conf.py").write_text(
298+
'score_any_folder_mapping = {"../../../../../external": "ext"}\n'
299+
)
300+
301+
make_sphinx_app({})
302+
303+
assert (nested_docs / "ext").is_symlink()
304+
assert (nested_docs / "ext").resolve() == external.resolve()

0 commit comments

Comments
 (0)