|
3 | 3 |
|
4 | 4 | from sphinx.application import Sphinx |
5 | 5 | from sphinx.config import Config |
| 6 | +import tomli |
6 | 7 | import tomli_w |
7 | 8 |
|
8 | 9 | from needs_config_writer import __version__ |
|
11 | 12 | LOGGER = get_logger(__name__) |
12 | 13 |
|
13 | 14 |
|
| 15 | +def resolve_path_template(path_template: str, app: Sphinx) -> Path: |
| 16 | + """ |
| 17 | + Resolve a path template with ${outdir} and ${srcdir} variables. |
| 18 | +
|
| 19 | + Args: |
| 20 | + path_template: Path string that may contain ${outdir} or ${srcdir} |
| 21 | + app: Sphinx application instance |
| 22 | +
|
| 23 | + Returns: |
| 24 | + Resolved Path object (absolute if relative to confdir) |
| 25 | + """ |
| 26 | + path_str = path_template.replace("${outdir}", str(app.outdir)) |
| 27 | + path_str = path_str.replace("${srcdir}", str(app.srcdir)) |
| 28 | + path = Path(path_str) |
| 29 | + |
| 30 | + # Make relative paths relative to confdir (where conf.py is located) |
| 31 | + if not path.is_absolute(): |
| 32 | + path = Path(app.confdir) / path |
| 33 | + |
| 34 | + return path |
| 35 | + |
| 36 | + |
14 | 37 | def write_ubproject_file(app: Sphinx, config: Config): |
15 | 38 | def get_safe_config(obj: Any, path: str = ""): |
16 | 39 | """ |
@@ -179,21 +202,85 @@ def sort_for_reproducibility(obj: Any, path: str = "") -> Any: |
179 | 202 | if attribute in raw_needs_config: |
180 | 203 | need_attributes[config_name] = safe_value |
181 | 204 |
|
182 | | - # Sort all data structures to ensure reproducible serialization |
183 | | - sorted_attributes = sort_for_reproducibility(need_attributes) |
| 205 | + # Collect additional root-level tables/keys from merged TOML files |
| 206 | + additional_root_data = {} |
| 207 | + |
| 208 | + # Merge TOML files if configured |
| 209 | + if config.needscfg_merge_toml_files: |
| 210 | + for toml_path_template in config.needscfg_merge_toml_files: |
| 211 | + toml_path = resolve_path_template(toml_path_template, app) |
| 212 | + |
| 213 | + if not toml_path.exists(): |
| 214 | + log_warning( |
| 215 | + LOGGER, |
| 216 | + f"TOML file to merge not found: '{toml_path}' (from template '{toml_path_template}')", |
| 217 | + "merge_failed", |
| 218 | + location=None, |
| 219 | + ) |
| 220 | + continue |
184 | 221 |
|
185 | | - # Resolve output path with template substitution |
186 | | - output_path_template = config.needscfg_outpath |
187 | | - output_path_str = output_path_template.replace("${outdir}", str(app.outdir)) |
188 | | - output_path_str = output_path_str.replace("${srcdir}", str(app.srcdir)) |
189 | | - outpath = Path(output_path_str) |
| 222 | + try: |
| 223 | + with open(toml_path, "rb") as f: |
| 224 | + merge_data = tomli.load(f) |
| 225 | + except (OSError, PermissionError) as e: |
| 226 | + log_warning( |
| 227 | + LOGGER, |
| 228 | + f"Failed to read TOML file '{toml_path}': {e}", |
| 229 | + "merge_failed", |
| 230 | + location=None, |
| 231 | + ) |
| 232 | + continue |
| 233 | + except tomli.TOMLDecodeError as e: |
| 234 | + log_warning( |
| 235 | + LOGGER, |
| 236 | + f"Failed to parse TOML file '{toml_path}': {e}", |
| 237 | + "merge_failed", |
| 238 | + location=None, |
| 239 | + ) |
| 240 | + continue |
190 | 241 |
|
191 | | - # Make relative paths relative to confdir (where conf.py is located) |
192 | | - if not outpath.is_absolute(): |
193 | | - outpath = Path(app.confdir) / outpath |
| 242 | + # Shallow merge all root-level keys from the file |
| 243 | + # If file has a [needs] table, merge it into our needs attributes |
| 244 | + # All other root-level keys/tables are collected separately |
| 245 | + for key, value in merge_data.items(): |
| 246 | + if key == "needs": |
| 247 | + # Shallow merge into the needs attributes (before sorting) |
| 248 | + need_attributes.update(value) |
| 249 | + else: |
| 250 | + # Collect root-level keys and tables |
| 251 | + additional_root_data[key] = value |
| 252 | + |
| 253 | + LOGGER.info( |
| 254 | + f"Merged TOML configuration from '{toml_path}'", |
| 255 | + type="ubproject", |
| 256 | + subtype="merge", |
| 257 | + ) |
| 258 | + |
| 259 | + # Sort the needs table data with special handling for reproducibility |
| 260 | + sorted_needs = sort_for_reproducibility(need_attributes) |
| 261 | + |
| 262 | + # Build the final TOML structure with all root-level keys sorted |
| 263 | + final_toml_data = {} |
| 264 | + |
| 265 | + # First add the needs table |
| 266 | + final_toml_data["needs"] = sorted_needs |
| 267 | + |
| 268 | + # Add additional root-level data |
| 269 | + for key, value in additional_root_data.items(): |
| 270 | + # Sort dictionaries in the additional data |
| 271 | + if isinstance(value, dict): |
| 272 | + final_toml_data[key] = dict(sorted(value.items())) |
| 273 | + else: |
| 274 | + final_toml_data[key] = value |
| 275 | + |
| 276 | + # Sort all root-level keys (including 'needs') alphabetically |
| 277 | + final_toml_data = dict(sorted(final_toml_data.items())) |
| 278 | + |
| 279 | + # Resolve output path with template substitution |
| 280 | + outpath = resolve_path_template(config.needscfg_outpath, app) |
194 | 281 |
|
195 | 282 | # Generate new content |
196 | | - new_content = tomli_w.dumps({"needs": sorted_attributes}) |
| 283 | + new_content = tomli_w.dumps(final_toml_data) |
197 | 284 |
|
198 | 285 | # Add header if configured |
199 | 286 | if config.needscfg_add_header: |
@@ -290,6 +377,13 @@ def setup(app: Sphinx): |
290 | 377 | types=[list], |
291 | 378 | description="List of needs_* variable names to exclude from writing (resolved configs).", |
292 | 379 | ) |
| 380 | + app.add_config_value( |
| 381 | + "needscfg_merge_toml_files", |
| 382 | + [], |
| 383 | + "html", |
| 384 | + types=[list], |
| 385 | + description="List of TOML file paths to shallow-merge into the output configuration.", |
| 386 | + ) |
293 | 387 |
|
294 | 388 | # run this late |
295 | 389 | app.connect("config-inited", write_ubproject_file, priority=999) |
|
0 commit comments