Repository: scikit-build/scikit-build-core
Pull request #1070 (2025-05-05 11:11:16)

fix: add scripts to _DICT_STR_FIELDS for dynamic metadata.
Fixes issue described in https://github.com/scikit-build/scikit-build-core/pull/1047#issuecomment-2850523570.


Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 98327bd..90e6a6c 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -25,7 +25,7 @@ repos:
         exclude: "^tests"
 
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.11.4
+    rev: v0.11.8
     hooks:
       - id: ruff
         args: ["--fix", "--show-fixes"]
@@ -129,12 +129,12 @@ repos:
         additional_dependencies: [cogapp]
 
   - repo: https://github.com/henryiii/validate-pyproject-schema-store
-    rev: 2025.04.07
+    rev: 2025.04.28
     hooks:
       - id: validate-pyproject
 
   - repo: https://github.com/python-jsonschema/check-jsonschema
-    rev: 0.32.1
+    rev: 0.33.0
     hooks:
       - id: check-dependabot
       - id: check-github-workflows
@@ -148,6 +148,6 @@ repos:
       - id: validate-cff
 
   - repo: https://github.com/scientific-python/cookie
-    rev: 2025.01.22
+    rev: 2025.05.02
     hooks:
       - id: sp-repo-review
diff --git a/src/scikit_build_core/metadata/__init__.py b/src/scikit_build_core/metadata/__init__.py
index 4ec51f4..1319fd5 100644
--- a/src/scikit_build_core/metadata/__init__.py
+++ b/src/scikit_build_core/metadata/__init__.py
@@ -32,6 +32,13 @@ _LIST_STR_FIELDS = frozenset(
 _DICT_STR_FIELDS = frozenset(
     [
         "urls",
+        "scripts",
+        "gui-scripts",
+    ]
+)
+
+_LIST_DICT_FIELDS = frozenset(
+    [
         "authors",
         "maintainers",
     ]
@@ -42,20 +49,25 @@ _ALL_FIELDS = (
     _STR_FIELDS
     | _LIST_STR_FIELDS
     | _DICT_STR_FIELDS
+    | _LIST_DICT_FIELDS
     | frozenset(
         [
             "optional-dependencies",
             "readme",
+            "entry-points",
         ]
     )
 )
 
-T = typing.TypeVar("T", bound="str | list[str] | dict[str, str] | dict[str, list[str]]")
+T = typing.TypeVar(
+    "T",
+    bound="str | list[str] | list[dict[str, str]] | dict[str, str] | dict[str, list[str]] | dict[str, dict[str, str]]",
+)
 
 
 def _process_dynamic_metadata(field: str, action: Callable[[str], str], result: T) -> T:
     """
-    Helper function for processing the an action on the various possible metadata fields.
+    Helper function for processing an action on the various possible metadata fields.
     """
 
     if field in _STR_FIELDS:
@@ -74,7 +86,28 @@ def _process_dynamic_metadata(field: str, action: Callable[[str], str], result:
         ):
             msg = f"Field {field!r} must be a dictionary of strings"
             raise RuntimeError(msg)
-        return {k: action(v) for k, v in result.items()}  # type: ignore[return-value]
+        return {action(k): action(v) for k, v in result.items()}  # type: ignore[return-value]
+    if field in _LIST_DICT_FIELDS:
+        if not isinstance(result, list) or not all(
+            isinstance(k, str) and isinstance(v, str)
+            for d in result
+            for k, v in d.items()
+        ):
+            msg = f"Field {field!r} must be a dictionary of strings"
+            raise RuntimeError(msg)
+        return [{k: action(v) for k, v in d.items()} for d in result]  # type: ignore[return-value]
+    if field == "entry-points":
+        if not isinstance(result, dict) or not all(
+            isinstance(d, dict)
+            and all(isinstance(k, str) and isinstance(v, str) for k, v in d.items())
+            for d in result.values()
+        ):
+            msg = "Field 'entry-points' must be a dictionary of dictionary of strings"
+            raise RuntimeError(msg)
+        return {
+            dk: {action(k): action(v) for k, v in dv.items()}
+            for dk, dv in result.items()
+        }  # type: ignore[return-value]
     if field == "optional-dependencies":
         if not isinstance(result, dict) or not all(
             isinstance(v, list) for v in result.values()


Test patch:
diff --git a/tests/test_dynamic_metadata_unit.py b/tests/test_dynamic_metadata_unit.py
index 8d6ea36..2b45bfd 100644
--- a/tests/test_dynamic_metadata_unit.py
+++ b/tests/test_dynamic_metadata_unit.py
@@ -1,4 +1,9 @@
+from typing import Any
+
+import pytest
+
 from scikit_build_core.builder._load_provider import process_dynamic_metadata
+from scikit_build_core.metadata import _process_dynamic_metadata
 
 
 def test_template_basic() -> None:
@@ -64,3 +69,37 @@ def test_regex() -> None:
     )
 
     assert pyproject["requires-python"] == ">=scikit_build_core"
+
+
+@pytest.mark.parametrize(
+    ("field", "input", "output"),
+    [
+        pytest.param("version", "{sub}", "42", id="str"),
+        pytest.param("classifiers", ["a", "{sub}"], ["a", "42"], id="list-str"),
+        pytest.param(
+            "scripts",
+            {"a": "{sub}", "{sub}": "b"},
+            {"a": "42", "42": "b"},
+            id="dict-str",
+        ),
+        pytest.param(
+            "authors", [{"name": "{sub}"}], [{"name": "42"}], id="list-dict-str"
+        ),
+        pytest.param(
+            "optional-dependencies",
+            {"dev": ["{sub}"]},
+            {"dev": ["42"]},
+            id="dict-list-str",
+        ),
+        pytest.param("readme", {"text": "{sub}"}, {"text": "42"}, id="readme"),
+        pytest.param(
+            "entry-points",
+            {"ep": {"{sub}": "{sub}"}},
+            {"ep": {"42": "42"}},
+            id="dict-dict-str",
+        ),
+    ],
+)
+def test_actions(field: str, input: Any, output: Any) -> None:
+    result = _process_dynamic_metadata(field, lambda x: x.format(sub=42), input)
+    assert output == result