Repository: scikit-build/scikit-build-core
Pull request #460 (2023-08-11 18:11:00)

fix: support Literals in settings
Better schema validation, more immediate feedback for invalid literals, better typing.

Will help with #459.


Patch:
diff --git a/src/scikit_build_core/resources/scikit-build.schema.json b/src/scikit_build_core/resources/scikit-build.schema.json
index e639e88..bab6eb7 100644
--- a/src/scikit_build_core/resources/scikit-build.schema.json
+++ b/src/scikit_build_core/resources/scikit-build.schema.json
@@ -82,7 +82,7 @@
       "additionalProperties": false,
       "properties": {
         "level": {
-          "type": "string",
+          "enum": ["NOTSET", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
           "default": "WARNING",
           "description": "The logging level to display, \"DEBUG\", \"INFO\", \"WARNING\", and \"ERROR\" are possible options."
         }
@@ -169,7 +169,7 @@
       "additionalProperties": false,
       "properties": {
         "mode": {
-          "type": "string",
+          "enum": ["redirect"],
           "default": "redirect",
           "description": "Select the editable mode to use. Currently only \"redirect\" is supported."
         },
diff --git a/src/scikit_build_core/settings/json_schema.py b/src/scikit_build_core/settings/json_schema.py
index 9959405..f489387 100644
--- a/src/scikit_build_core/settings/json_schema.py
+++ b/src/scikit_build_core/settings/json_schema.py
@@ -8,7 +8,7 @@ from typing import Any, Union
 from packaging.version import Version
 
 from .._compat.builtins import ExceptionGroup
-from .._compat.typing import get_args, get_origin
+from .._compat.typing import Literal, get_args, get_origin
 from .documentation import pull_docs
 
 __all__ = ["to_json_schema", "convert_type", "FailedConversion"]
@@ -43,6 +43,7 @@ def to_json_schema(dclass: type[Any], *, normalize_keys: bool) -> dict[str, Any]
                 # pylint: disable-next=no-member
                 err.add_note(f"Field: {field.name}")
             errs.append(err)
+            continue
 
         if field.default is not dataclasses.MISSING and field.default is not None:
             props[field.name]["default"] = (
@@ -86,6 +87,8 @@ def convert_type(t: Any) -> dict[str, Any]:
         if len(args) == 2 and any(a is type(None) for a in args):
             return convert_type(next(iter(a for a in args if a is not type(None))))
         return {"oneOf": [convert_type(a) for a in args]}
+    if origin is Literal:
+        return {"enum": list(args)}
 
     msg = f"Cannot convert type {t} to JSON Schema"
     raise FailedConversion(msg)
diff --git a/src/scikit_build_core/settings/skbuild_model.py b/src/scikit_build_core/settings/skbuild_model.py
index 9335275..b998b77 100644
--- a/src/scikit_build_core/settings/skbuild_model.py
+++ b/src/scikit_build_core/settings/skbuild_model.py
@@ -4,6 +4,8 @@ from typing import Any, Dict, List, Optional, Union
 
 from packaging.version import Version
 
+from .._compat.typing import Literal
+
 __all__ = [
     "BackportSettings",
     "CMakeSettings",
@@ -84,7 +86,9 @@ class NinjaSettings:
 
 @dataclasses.dataclass
 class LoggingSettings:
-    level: str = "WARNING"
+    level: Literal[
+        "NOTSET", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"
+    ] = "WARNING"
     """
     The logging level to display, "DEBUG", "INFO", "WARNING", and "ERROR" are
     possible options.
@@ -172,7 +176,7 @@ class BackportSettings:
 
 @dataclasses.dataclass
 class EditableSettings:
-    mode: str = "redirect"
+    mode: Literal["redirect"] = "redirect"
     """
     Select the editable mode to use. Currently only "redirect" is supported.
     """
diff --git a/src/scikit_build_core/settings/skbuild_read_settings.py b/src/scikit_build_core/settings/skbuild_read_settings.py
index d46167d..257123b 100644
--- a/src/scikit_build_core/settings/skbuild_read_settings.py
+++ b/src/scikit_build_core/settings/skbuild_read_settings.py
@@ -49,12 +49,6 @@ class SettingsReader:
                 )
                 raise CMakeConfigError(msg)
 
-        if self.settings.editable.mode != "redirect":
-            if self.settings.strict_config:
-                rich_print("[red][bold]ERROR:[/bold] editable mode must be 'redirect'")
-                raise SystemExit(7)
-            logger.warning("editable mode must be 'redirect', ignoring")
-
         if self.settings.editable.rebuild and not self.settings.build_dir:
             rich_print(
                 "[red][bold]ERROR:[/bold] editable mode with rebuild requires build_dir"
diff --git a/src/scikit_build_core/settings/sources.py b/src/scikit_build_core/settings/sources.py
index e663a02..b969458 100644
--- a/src/scikit_build_core/settings/sources.py
+++ b/src/scikit_build_core/settings/sources.py
@@ -36,6 +36,7 @@ When setting up your dataclasses, these types are handled:
 - ``Union[str, ...]``: Supports other input types in TOML form (bool currently). Otherwise a string.
 - ``List[T]``: A list of items. `;` separated supported in EnvVar/config forms.
 - ``Dict[str, T]``: A table of items. TOML supports a layer of nesting. Any is supported as an item type.
+- ``Literal[...]``: A list of strings, the result must be in the list.
 
 These are supported for JSON schema generation for the TOML, as well.
 
@@ -52,7 +53,7 @@ from collections.abc import Generator, Iterator, Mapping, Sequence
 from typing import Any, TypeVar, Union
 
 from .._compat.builtins import ExceptionGroup
-from .._compat.typing import Protocol, get_args, get_origin
+from .._compat.typing import Literal, Protocol, get_args, get_origin
 
 T = TypeVar("T")
 
@@ -104,9 +105,9 @@ def _process_union(target: type[Any]) -> Any:
 
 def _get_target_raw_type(target: type[Any]) -> Any:
     """
-    Takes a type like ``Optional[str]`` and returns str,
-    or ``Optional[Dict[str, int]]`` and returns dict. Returns
-    Union for a Union with more than one non-none type.
+    Takes a type like ``Optional[str]`` and returns str, or ``Optional[Dict[str,
+    int]]`` and returns dict. Returns Union for a Union with more than one
+    non-none type. Literal is also a valid return.
     """
 
     target = _process_union(target)
@@ -225,6 +226,12 @@ class EnvSource:
         if raw_target is Union and str in get_args(target):
             return item
 
+        if raw_target is Literal:
+            if item not in get_args(_process_union(target)):
+                msg = f"{item!r} not in {get_args(_process_union(target))!r}"
+                raise TypeError(msg)
+            return item
+
         if callable(raw_target):
             return raw_target(item)
         msg = f"Can't convert target {target}"
@@ -335,6 +342,11 @@ class ConfSource:
             return item.strip().lower() not in {"0", "false", "off", "no", ""}
         if raw_target is Union and str in get_args(target):
             return item
+        if raw_target is Literal:
+            if item not in get_args(_process_union(target)):
+                msg = f"{item!r} not in {get_args(_process_union(target))!r}"
+                raise TypeError(msg)
+            return item
         if callable(raw_target):
             return raw_target(item)
         msg = f"Can't convert target {target}"
@@ -406,6 +418,11 @@ class TOMLSource:
             return item
         if raw_target is Union and type(item) in get_args(target):
             return item
+        if raw_target is Literal:
+            if item not in get_args(_process_union(target)):
+                msg = f"{item!r} not in {get_args(_process_union(target))!r}"
+                raise TypeError(msg)
+            return item
         if callable(raw_target):
             return raw_target(item)
         msg = f"Can't convert target {target}"


Test patch:
diff --git a/tests/test_schema.py b/tests/test_schema.py
index 68d9d8f..87a099e 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -40,7 +40,12 @@ def test_valid_schemas_files(filepath: Path) -> None:
 
 
 @pytest.mark.parametrize(
-    "addition", [{"minimum-version": 0.3}, {"random": "not valid"}]
+    "addition",
+    [
+        {"minimum-version": 0.3},
+        {"random": "not valid"},
+        {"logging": {"level": "POODLE"}},
+    ],
 )
 def test_invalid_schemas(addition: dict[str, Any]) -> None:
     fastjsonschema = pytest.importorskip("fastjsonschema")
diff --git a/tests/test_settings.py b/tests/test_settings.py
index f225137..8e1e7c7 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -1,10 +1,13 @@
 import dataclasses
+import re
 from pathlib import Path
 from typing import Any, Dict, List, Optional, Union
 
 import pytest
 from packaging.version import Version
 
+from scikit_build_core._compat.builtins import ExceptionGroup
+from scikit_build_core._compat.typing import Literal
 from scikit_build_core.settings.sources import (
     ConfSource,
     EnvSource,
@@ -25,6 +28,7 @@ class SettingChecker:
     seven: Union[int, None] = None
     eight: Dict[str, Union[str, bool]] = dataclasses.field(default_factory=dict)
     nine: Dict[str, int] = dataclasses.field(default_factory=dict)
+    literal: Literal["one", "two", "three"] = "one"
     # TOML only
     ten: Dict[str, Any] = dataclasses.field(default_factory=dict)
 
@@ -52,6 +56,7 @@ def test_empty(monkeypatch):
     assert settings.seven is None
     assert settings.eight == {}
     assert settings.nine == {}
+    assert settings.literal == "one"
 
 
 def test_env(monkeypatch):
@@ -65,6 +70,7 @@ def test_env(monkeypatch):
     monkeypatch.setenv("SKBUILD_SEVEN", "7")
     monkeypatch.setenv("SKBUILD_EIGHT", "thing=8;thought=9")
     monkeypatch.setenv("SKBUILD_NINE", "thing=8")
+    monkeypatch.setenv("SKBUILD_LITERAL", "two")
 
     sources = SourceChain(
         EnvSource("SKBUILD"),
@@ -83,6 +89,7 @@ def test_env(monkeypatch):
     assert settings.seven == 7
     assert settings.eight == {"thing": "8", "thought": "9"}
     assert settings.nine == {"thing": 8}
+    assert settings.literal == "two"
 
 
 def test_conf():
@@ -98,6 +105,7 @@ def test_conf():
         "eight.foo": "one",
         "eight.bar": "two",
         "nine.thing": "8",
+        "literal": "three",
     }
 
     sources = SourceChain(
@@ -117,6 +125,7 @@ def test_conf():
     assert settings.seven == 7
     assert settings.eight == {"foo": "one", "bar": "two"}
     assert settings.nine == {"thing": 8}
+    assert settings.literal == "three"
 
 
 def test_toml():
@@ -132,6 +141,7 @@ def test_toml():
         "eight": {"one": "one", "two": "two", "bool": False},
         "nine": {"thing": 8},
         "ten": {"a": {"b": 3}},
+        "literal": "three",
     }
 
     sources = SourceChain(
@@ -152,6 +162,7 @@ def test_toml():
     assert settings.eight == {"one": "one", "two": "two", "bool": False}
     assert settings.nine == {"thing": 8}
     assert settings.ten == {"a": {"b": 3}}
+    assert settings.literal == "three"
 
 
 def test_all_names():
@@ -512,3 +523,58 @@ def test_versions(monkeypatch):
     assert settings.third_opt == Version("3.5")
     assert settings.fourth_req == Version("4.5")
     assert settings.fourth_opt == Version("4.6")
+
+
+@dataclasses.dataclass
+class SettingLiteral:
+    literal: Literal["one", "two", "three"]
+    optional: Optional[Literal["foo", "bar"]] = None
+
+
+def test_literal_failure_empty():
+    sources = SourceChain(
+        EnvSource("SKBUILD"),
+        ConfSource(settings={}),
+        TOMLSource(settings={}),
+    )
+
+    with pytest.raises(ValueError, match="Missing value for 'literal'"):  # noqa: PT012
+        try:
+            sources.convert_target(SettingLiteral)
+        except ExceptionGroup as e:
+            assert len(e.exceptions) == 1  # noqa: PT017
+            raise e.exceptions[0] from None
+
+
+def test_literal_failure_not_contained():
+    sources = SourceChain(
+        EnvSource("SKBUILD"),
+        ConfSource(settings={"literal": "four"}),
+        TOMLSource(settings={}),
+    )
+
+    with pytest.raises(  # noqa: PT012
+        TypeError, match=re.escape("'four' not in ('one', 'two', 'three')")
+    ):
+        try:
+            sources.convert_target(SettingLiteral)
+        except ExceptionGroup as e:
+            assert len(e.exceptions) == 1  # noqa: PT017
+            raise e.exceptions[0] from None
+
+
+def test_literal_failure_not_contained_optional():
+    sources = SourceChain(
+        EnvSource("SKBUILD"),
+        ConfSource(settings={"literal": "three"}),
+        TOMLSource(settings={"optional": "five"}),
+    )
+
+    with pytest.raises(  # noqa: PT012
+        TypeError, match=re.escape("'five' not in ('foo', 'bar')")
+    ):
+        try:
+            sources.convert_target(SettingLiteral)
+        except ExceptionGroup as e:
+            assert len(e.exceptions) == 1  # noqa: PT017
+            raise e.exceptions[0] from None