Skip to content

Commit 76b32b4

Browse files
authored
✨ Merge additional TOML files (#14)
1 parent cfc6ab5 commit 76b32b4

File tree

6 files changed

+544
-13
lines changed

6 files changed

+544
-13
lines changed

docs/configuration.rst

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,87 @@ The default list excludes:
239239
written to the output file, potentially creating circular dependencies or
240240
duplicate configurations.
241241

242+
.. _`config_merge_toml_files`:
243+
244+
needscfg_merge_toml_files
245+
-------------------------
246+
247+
**Type:** ``list[str]``
248+
249+
**Default:** ``[]`` (empty list)
250+
251+
Specifies a list of TOML file paths to shallow-merge into the final output configuration.
252+
This allows you to include additional configuration from external TOML files into the
253+
generated ``ubproject.toml``.
254+
255+
The paths support the same template variables as :ref:`config_outpath`:
256+
257+
- ``${outdir}`` - Replaced with the Sphinx output directory (build directory)
258+
- ``${srcdir}`` - Replaced with the Sphinx source directory
259+
260+
Relative paths are interpreted relative to the configuration directory (where ``conf.py`` is located).
261+
262+
**Merge behavior:**
263+
264+
- Files are processed in the order they appear in the list
265+
- Each file is shallow-merged (top-level keys only) into the configuration
266+
- If a TOML file has a ``[needs]`` table, only that table is merged
267+
- If no ``[needs]`` table exists, the entire file content is merged
268+
- Values from merged files **override** values from the Sphinx configuration
269+
- Later files in the list override earlier files
270+
271+
**Use cases:**
272+
273+
- Add project-specific metadata not available in Sphinx config
274+
- Include version information from separate TOML files
275+
- Merge team-wide configuration standards
276+
- Add deployment-specific settings
277+
278+
**Examples:**
279+
280+
.. code-block:: python
281+
282+
# Merge a single additional configuration file
283+
needscfg_merge_toml_files = ["additional_config.toml"]
284+
285+
# Merge multiple files (processed in order)
286+
needscfg_merge_toml_files = [
287+
"${srcdir}/team_defaults.toml",
288+
"project_overrides.toml",
289+
]
290+
291+
# Use build output directory
292+
needscfg_merge_toml_files = ["${outdir}/generated_metadata.toml"]
293+
294+
**Example TOML file with needs table:**
295+
296+
.. code-block:: toml
297+
298+
# additional_config.toml
299+
[needs]
300+
project_version = "1.2.3"
301+
build_date = "2025-10-28"
302+
303+
**Example TOML file without needs table:**
304+
305+
.. code-block:: toml
306+
307+
# additional_config.toml
308+
project_version = "1.2.3"
309+
build_date = "2025-10-28"
310+
311+
Both formats work - if a ``[needs]`` table exists, only its contents are merged.
312+
313+
.. note::
314+
315+
If a merge file doesn't exist, a warning is emitted but the build continues.
316+
Failed file loads (e.g., invalid TOML syntax) also emit warnings without stopping the build.
317+
318+
.. tip::
319+
320+
Use this feature to separate dynamic configuration (like version numbers or build metadata)
321+
from static Sphinx-Needs configuration in ``conf.py``.
322+
242323
Examples
243324
--------
244325

needs_config_writer/logging.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@ def get_logger(name: str) -> SphinxLoggerAdapter:
1212
return logging.getLogger(name)
1313

1414

15-
WarningSubTypes = Literal["path_conversion", "unsupported_type", "content_diff"]
15+
WarningSubTypes = Literal[
16+
"path_conversion",
17+
"unsupported_type",
18+
"content_diff",
19+
"merge_failed",
20+
]
1621

1722

1823
def log_warning(

needs_config_writer/main.py

Lines changed: 105 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from sphinx.application import Sphinx
55
from sphinx.config import Config
6+
import tomli
67
import tomli_w
78

89
from needs_config_writer import __version__
@@ -11,6 +12,28 @@
1112
LOGGER = get_logger(__name__)
1213

1314

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+
1437
def write_ubproject_file(app: Sphinx, config: Config):
1538
def get_safe_config(obj: Any, path: str = ""):
1639
"""
@@ -179,21 +202,85 @@ def sort_for_reproducibility(obj: Any, path: str = "") -> Any:
179202
if attribute in raw_needs_config:
180203
need_attributes[config_name] = safe_value
181204

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
184221

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
190241

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)
194281

195282
# Generate new content
196-
new_content = tomli_w.dumps({"needs": sorted_attributes})
283+
new_content = tomli_w.dumps(final_toml_data)
197284

198285
# Add header if configured
199286
if config.needscfg_add_header:
@@ -290,6 +377,13 @@ def setup(app: Sphinx):
290377
types=[list],
291378
description="List of needs_* variable names to exclude from writing (resolved configs).",
292379
)
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+
)
293387

294388
# run this late
295389
app.connect("config-inited", write_ubproject_file, priority=999)

pyproject.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,12 @@ classifiers = [
2727
"Topic :: Utilities",
2828
"Framework :: Sphinx :: Extension",
2929
]
30-
dependencies = ["sphinx>=4.0", "sphinx-needs>4.2.0", "tomli-w>=1.2.0"]
30+
dependencies = [
31+
"sphinx>=4.0",
32+
"sphinx-needs>4.2.0",
33+
"tomli>=2.3.0",
34+
"tomli-w>=1.2.0",
35+
]
3136

3237
[dependency-groups]
3338
dev = [

0 commit comments

Comments
 (0)