diff --git a/src/extensions/score_draw_uml_funcs/__init__.py b/src/extensions/score_draw_uml_funcs/__init__.py index 8276b80f..d4a95f39 100644 --- a/src/extensions/score_draw_uml_funcs/__init__.py +++ b/src/extensions/score_draw_uml_funcs/__init__.py @@ -60,7 +60,8 @@ def setup(app: Sphinx) -> dict[str, object]: - app.config.needs_render_context = draw_uml_function_context + for key, value in draw_uml_function_context.items(): + app.config.needs_render_context.setdefault(key, value) return { "version": "0.1", "parallel_read_safe": True, diff --git a/src/extensions/score_layout/__init__.py b/src/extensions/score_layout/__init__.py index 6d54f0b7..62ec548f 100644 --- a/src/extensions/score_layout/__init__.py +++ b/src/extensions/score_layout/__init__.py @@ -18,6 +18,8 @@ import sphinx_options from sphinx.application import Sphinx +from src.helper_lib import config_setdefault + logger = logging.getLogger(__name__) @@ -35,11 +37,18 @@ def setup(app: Sphinx) -> dict[str, str | bool]: def update_config(app: Sphinx, _config: Any): logger.debug("score_layout update_config called") - app.config.needs_layouts = sphinx_options.needs_layouts - app.config.needs_global_options = sphinx_options.needs_global_options - app.config.html_theme = html_options.html_theme - app.config.html_context = html_options.return_html_context(app) - app.config.html_theme_options = html_options.return_html_theme_options(app) + # Merge: user's entries take precedence over our defaults + app.config.needs_layouts = {**sphinx_options.needs_layouts, **app.config.needs_layouts} + app.config.needs_global_options = { + **sphinx_options.needs_global_options, + **app.config.needs_global_options, + } + config_setdefault(app.config, "html_theme", html_options.html_theme) + app.config.html_context = {**html_options.return_html_context(app), **app.config.html_context} + app.config.html_theme_options = { + **html_options.return_html_theme_options(app), + **app.config.html_theme_options, + } logger.debug(f"score_layout __file__: {__file__}") @@ -49,7 +58,7 @@ def update_config(app: Sphinx, _config: Any): app.config.html_static_path.append(str(score_layout_path / "assets")) puml = score_layout_path / "assets" / "puml-theme-score.puml" - app.config.needs_flow_configs = {"score_config": f"!include {puml}"} + app.config.needs_flow_configs.setdefault("score_config", f"!include {puml}") app.add_css_file("css/score.css", priority=500) app.add_css_file("css/score_needs.css", priority=500) diff --git a/src/extensions/score_metamodel/__init__.py b/src/extensions/score_metamodel/__init__.py index 0a6c4dae..fddff21f 100644 --- a/src/extensions/score_metamodel/__init__.py +++ b/src/extensions/score_metamodel/__init__.py @@ -18,6 +18,8 @@ from sphinx.application import Sphinx from sphinx_needs import logging + +from src.helper_lib import config_setdefault from sphinx_needs.data import NeedsView, SphinxNeedsData from sphinx_needs.need_item import NeedItem @@ -231,25 +233,25 @@ def postprocess_need_links(needs_types_list: list[ScoreNeedType]): def setup(app: Sphinx) -> dict[str, str | bool]: app.add_config_value("external_needs_source", "", rebuild="env") - app.config.needs_id_required = True - app.config.needs_id_regex = "^[A-Za-z0-9_-]{6,}" + config_setdefault(app.config, "needs_id_required", True) + config_setdefault(app.config, "needs_id_regex", "^[A-Za-z0-9_-]{6,}") # load metamodel.yaml via ruamel.yaml metamodel = load_metamodel_data() - # Assign everything to Sphinx config - app.config.needs_types = metamodel.needs_types - app.config.needs_extra_links = metamodel.needs_extra_links - app.config.needs_extra_options = metamodel.needs_extra_options + # Extend sphinx-needs config rather than overwriting + app.config.needs_types += metamodel.needs_types + app.config.needs_extra_links += metamodel.needs_extra_links + app.config.needs_extra_options += metamodel.needs_extra_options app.config.graph_checks = metamodel.needs_graph_check app.config.prohibited_words_checks = metamodel.prohibited_words_checks # app.config.stop_words = metamodel["stop_words"] # app.config.weak_words = metamodel["weak_words"] # Ensure that 'needs.json' is always build. - app.config.needs_build_json = True - app.config.needs_reproducible_json = True - app.config.needs_json_remove_defaults = True + config_setdefault(app.config, "needs_build_json", True) + config_setdefault(app.config, "needs_reproducible_json", True) + config_setdefault(app.config, "needs_json_remove_defaults", True) # sphinx-collections runs on default prio 500. # We need to populate the sphinx-collections config before that happens. diff --git a/src/extensions/score_plantuml.py b/src/extensions/score_plantuml.py index 3dbbd138..b46b51aa 100644 --- a/src/extensions/score_plantuml.py +++ b/src/extensions/score_plantuml.py @@ -29,7 +29,7 @@ from sphinx.application import Sphinx from sphinx.util import logging -from src.helper_lib import get_runfiles_dir +from src.helper_lib import config_setdefault, get_runfiles_dir logger = logging.getLogger(__name__) @@ -53,10 +53,11 @@ def find_correct_path(runfiles: Path) -> Path: def setup(app: Sphinx): - app.config.plantuml = str(find_correct_path(get_runfiles_dir())) - app.config.plantuml_output_format = "svg_obj" - app.config.plantuml_syntax_error_image = True - app.config.needs_build_needumls = "_plantuml_sources" + if not app.config.plantuml: + app.config.plantuml = str(find_correct_path(get_runfiles_dir())) + config_setdefault(app.config, "plantuml_output_format", "svg_obj") + config_setdefault(app.config, "plantuml_syntax_error_image", True) + config_setdefault(app.config, "needs_build_needumls", "_plantuml_sources") logger.debug(f"PlantUML binary found at {app.config.plantuml}") diff --git a/src/extensions/score_source_code_linker/__init__.py b/src/extensions/score_source_code_linker/__init__.py index 094ebf4a..cf9843dc 100644 --- a/src/extensions/score_source_code_linker/__init__.py +++ b/src/extensions/score_source_code_linker/__init__.py @@ -173,14 +173,15 @@ def setup_source_code_linker(app: Sphinx, ws_root: Path): # Define need_string_links here to not have it in conf.py # source_code_link and testlinks have the same schema - app.config.needs_string_links = { - "source_code_linker": { + app.config.needs_string_links.setdefault( + "source_code_linker", + { "regex": r"(?P.+)<>(?P.+)", "link_url": "{{url}}", "link_name": "{{name}}", "options": ["source_code_link", "testlink"], }, - } + ) score_sourcelinks_json = os.environ.get("SCORE_SOURCELINKS") if score_sourcelinks_json: diff --git a/src/extensions/score_sphinx_bundle/__init__.py b/src/extensions/score_sphinx_bundle/__init__.py index d30df6c3..9dfd8680 100644 --- a/src/extensions/score_sphinx_bundle/__init__.py +++ b/src/extensions/score_sphinx_bundle/__init__.py @@ -12,6 +12,8 @@ # ******************************************************************************* from sphinx.application import Sphinx +from src.helper_lib import config_setdefault + # Note: order matters! # Extensions are loaded in this order. # e.g. plantuml MUST be loaded before sphinx-needs @@ -33,43 +35,41 @@ def setup(app: Sphinx) -> dict[str, object]: - app.config.html_copy_source = False - app.config.html_show_sourcelink = False + config_setdefault(app.config, "html_copy_source", False) + config_setdefault(app.config, "html_show_sourcelink", False) # Global settings # Note: the "sub-extensions" also set their own config values # Same as current VS Code extension - app.config.mermaid_version = "11.6.0" - - # enable "..."-syntax in markdown - app.config.myst_enable_extensions = ["colon_fence"] + config_setdefault(app.config, "mermaid_version", "11.6.0") - app.config.exclude_patterns = [ - # The following entries are not required when building the documentation via - # 'bazel build //:docs', as that command runs in a sandboxed environment. - # However, when building the documentation via 'bazel run //:docs' or esbonio, - # these entries are required to prevent the build from failing. - "bazel-*", - ".venv*", - ] + # The following entries are not required when building the documentation via + # 'bazel build //:docs', as that command runs in a sandboxed environment. + # However, when building the documentation via 'bazel run //:docs' or esbonio, + # these entries are required to prevent the build from failing. + app.config.exclude_patterns += ["bazel-*", ".venv*"] # Enable markdown rendering - app.config.source_suffix = { - ".rst": "restructuredtext", - ".md": "markdown", - } + app.config.source_suffix.setdefault(".rst", "restructuredtext") + app.config.source_suffix.setdefault(".md", "markdown") - app.config.templates_path = ["templates"] + if "templates" not in app.config.templates_path: + app.config.templates_path += ["templates"] - app.config.numfig = True + config_setdefault(app.config, "numfig", True) - app.config.author = "S-CORE" + if not app.config.author: + app.config.author = "S-CORE" # Load the actual extensions list for e in score_extensions: app.setup_extension(e) + # enable "..."-syntax in markdown — must come after myst_parser is loaded above + if "colon_fence" not in app.config.myst_enable_extensions: + app.config.myst_enable_extensions = set(app.config.myst_enable_extensions) | {"colon_fence"} + return { "version": "3.0.0", # Keep this in sync with the score_docs_as_code version in MODULE.bazel diff --git a/src/extensions/score_sync_toml/__init__.py b/src/extensions/score_sync_toml/__init__.py index 79ebfb7a..069e017c 100644 --- a/src/extensions/score_sync_toml/__init__.py +++ b/src/extensions/score_sync_toml/__init__.py @@ -14,6 +14,8 @@ from sphinx.application import Sphinx +from src.helper_lib import config_setdefault + def setup(app: Sphinx) -> dict[str, str | bool]: """ @@ -22,29 +24,31 @@ def setup(app: Sphinx) -> dict[str, str | bool]: See https://needs-config-writer.useblocks.com """ - app.config.needscfg_outpath = "ubproject.toml" + config_setdefault(app.config, "needscfg_outpath", "ubproject.toml") """Write to the confdir directory.""" - app.config.needscfg_overwrite = True + config_setdefault(app.config, "needscfg_overwrite", True) """Any changes to the shared/local configuration updates the generated config.""" - app.config.needscfg_write_all = True + config_setdefault(app.config, "needscfg_write_all", True) """Write full config, so the final configuration is visible in one file.""" - app.config.needscfg_exclude_defaults = True + config_setdefault(app.config, "needscfg_exclude_defaults", True) """Exclude default values from the generated configuration.""" # This is disabled for right now as it causes a lot of issues # While we are not using the generated file anywhere - app.config.needscfg_warn_on_diff = False + config_setdefault(app.config, "needscfg_warn_on_diff", False) """Running Sphinx with -W will fail the CI for uncommitted TOML changes.""" - app.config.needscfg_merge_toml_files = [ - str(Path(__file__).parent / "shared.toml"), - ] + app.config.needscfg_merge_toml_files = ( + app.config.needscfg_merge_toml_files or [] + ) + [str(Path(__file__).parent / "shared.toml")] """Merge the static TOML file into the generated configuration.""" - app.config.needscfg_relative_path_fields = [ + app.config.needscfg_relative_path_fields = ( + app.config.needscfg_relative_path_fields or [] + ) + [ "needs_external_needs[*].json_path", { "field": "needs_flow_configs.score_config", diff --git a/src/helper_lib/__init__.py b/src/helper_lib/__init__.py index 156a50fc..b48536c5 100644 --- a/src/helper_lib/__init__.py +++ b/src/helper_lib/__init__.py @@ -15,13 +15,25 @@ import subprocess import sys from pathlib import Path +from typing import Any from runfiles import Runfiles +from sphinx.config import Config from sphinx_needs.logging import get_logger LOGGER = get_logger(__name__) +def config_setdefault(config: Config, name: str, value: Any) -> None: + """Set a Sphinx config value only if the user hasn't explicitly set it in conf.py.""" + + # Sphinx has no public API for this check. We use ``_raw_config`` which is the + # de-facto standard across the ecosystem (Furo, RTD-theme, etc.). If Sphinx + # ever adds a public alternative, update this single function. + if name not in config._raw_config: + setattr(config, name, value) + + def find_ws_root() -> Path | None: """ Find the current MODULE.bazel workspace root directory. diff --git a/src/helper_lib/test_helper_lib.py b/src/helper_lib/test_helper_lib.py index 83bbc30e..9878bdbd 100644 --- a/src/helper_lib/test_helper_lib.py +++ b/src/helper_lib/test_helper_lib.py @@ -18,6 +18,7 @@ import pytest from src.helper_lib import ( + config_setdefault, get_current_git_hash, get_github_repo_info, get_runfiles_dir, @@ -25,6 +26,26 @@ ) +class _FakeConfig: + """Minimal stand-in for sphinx.config.Config sufficient to test config_setdefault.""" + + def __init__(self, raw: dict): + self._raw_config = raw + + +def test_config_setdefault_sets_when_not_in_raw_config(): + cfg = _FakeConfig(raw={}) + config_setdefault(cfg, "html_copy_source", False) + assert cfg.html_copy_source is False + + +def test_config_setdefault_does_not_overwrite_user_value(): + cfg = _FakeConfig(raw={"html_copy_source": True}) + cfg.html_copy_source = True + config_setdefault(cfg, "html_copy_source", False) + assert cfg.html_copy_source is True + + @pytest.fixture def temp_dir(): """Create a temporary directory for tests."""