2828
2929The extension creates the symlinks on ``builder-inited``,
3030before Sphinx starts reading any documents.
31- Existing correct symlinks are left in place(idempotent);
31+ Existing correct symlinks are left in place (idempotent);
3232a symlink pointing to the wrong target is replaced.
3333
3434Symlinks created by this extension are removed again on ``build-finished``.
3535Misconfigured pairs (absolute paths, non-symlink path at the target location)
3636are 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
3953from pathlib import Path
4054
4155from sphinx .application import Sphinx
4559
4660_APP_ATTRIBUTE = "_score_any_folder_created_links"
4761
62+
4863def 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+
82162def _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
0 commit comments