Repository: scikit-build/scikit-build-core
Pull request #549 (2023-11-08 16:59:04)

feat: support if.env
Adding support for if.env, for custom environment variables.



Patch:
diff --git a/docs/configuration.md b/docs/configuration.md
index 427172f..8205287 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -590,6 +590,9 @@ take several values, based on [PEP 508][]:
 - `implementation-name`: The value of `sys.implementation.name`. Takes a regex.
 - `implementation-version`: Derived from `sys.implementation.version`, following
   PEP 508. Takes a specifier set.
+- `env`: A table of environment variables mapped to either string regexs, or
+  booleans. Valid "truthy" environment variables are case insensitive `true`,
+  `on`, `yes`, `y`, `t`, or a number more than 0.
 
 At least one must be provided. Then you can specify any collection of valid
 options, and those will override if all the items in the `if` are true. They
diff --git a/pyproject.toml b/pyproject.toml
index f45991f..2066e21 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -283,7 +283,7 @@ typing-modules = ["scikit_build_core._compat.typing"]
 
 
 [tool.ruff.lint.per-file-ignores]
-"tests/**" = ["T20", "ANN"]
+"tests/**" = ["T20", "ANN", "FBT001"]
 "noxfile.py" = ["T20", "TID251"]
 "src/scikit_build_core/resources/*.py" = ["PTH", "ARG002", "FBT", "TID251"]
 "src/scikit_build_core/_compat/**.py" = ["TID251"]
diff --git a/src/scikit_build_core/resources/scikit-build.schema.json b/src/scikit_build_core/resources/scikit-build.schema.json
index 7b0e28f..f27b600 100644
--- a/src/scikit_build_core/resources/scikit-build.schema.json
+++ b/src/scikit_build_core/resources/scikit-build.schema.json
@@ -456,6 +456,23 @@
         },
         "platform-node": {
           "type": "string"
+        },
+        "env": {
+          "type": "object",
+          "patternProperties": {
+            ".*": {
+              "oneOf": [
+                {
+                  "type": "string"
+                },
+                {
+                  "type": "boolean"
+                }
+              ]
+            }
+          },
+          "additionalProperties": false,
+          "minProperties": 1
         }
       }
     }
diff --git a/src/scikit_build_core/settings/skbuild_read_settings.py b/src/scikit_build_core/settings/skbuild_read_settings.py
index 98c2b0e..0daeda8 100644
--- a/src/scikit_build_core/settings/skbuild_read_settings.py
+++ b/src/scikit_build_core/settings/skbuild_read_settings.py
@@ -2,6 +2,7 @@ from __future__ import annotations
 
 import copy
 import difflib
+import os
 import platform
 import re
 import sys
@@ -19,7 +20,6 @@ from .skbuild_model import ScikitBuildSettings
 from .sources import ConfSource, EnvSource, SourceChain, TOMLSource
 
 if TYPE_CHECKING:
-    import os
     from collections.abc import Generator, Mapping
 
 __all__ = ["SettingsReader"]
@@ -29,6 +29,16 @@ def __dir__() -> list[str]:
     return __all__
 
 
+def strtobool(value: str) -> bool:
+    """
+    Converts a environment variable string into a boolean value.
+    """
+    value = value.lower()
+    if value.isdigit():
+        return bool(int(value))
+    return value in {"y", "yes", "on", "true", "t"}
+
+
 def version_match(value: str, match: str, name: str) -> str:
     """
     Returns a non-empty string if a version matches a specifier.
@@ -43,20 +53,24 @@ def regex_match(value: str, match: str) -> str:
     Returns a non-empty string if a value matches a regex.
     """
     did_match = re.compile(match).search(value) is not None
-    return f"{match!r} matched {value}" if did_match else ""
+    return f"{match!r} matched {value!r}" if did_match else ""
 
 
 def override_match(
     *,
     match_all: bool,
+    current_env: Mapping[str, str] | None,
     python_version: str | None = None,
     implementation_name: str | None = None,
     implementation_version: str | None = None,
     platform_system: str | None = None,
     platform_machine: str | None = None,
     platform_node: str | None = None,
+    env: dict[str, str] | None = None,
 ) -> bool:
     matches = []
+    if current_env is None:
+        current_env = os.environ
 
     if python_version is not None:
         current_python_version = ".".join(str(x) for x in sys.version_info[:2])
@@ -94,6 +108,21 @@ def override_match(
         match_msg = regex_match(current_platform_node, platform_node)
         matches.append(match_msg)
 
+    if env:
+        for key, value in env.items():
+            if isinstance(value, bool):
+                matches.append(
+                    f"env {key} is {value}"
+                    if strtobool(current_env.get(key, "")) == value
+                    else ""
+                )
+            elif key not in current_env:
+                matches.append("")
+            else:
+                current_value = current_env.get(key, "")
+                match_msg = regex_match(current_value, value)
+                matches.append(match_msg and f"env {key}: {match_msg}")
+
     if not matches:
         msg = "At least one override must be provided"
         raise ValueError(msg)
@@ -116,6 +145,7 @@ class SettingsReader:
         config_settings: Mapping[str, str | list[str]],
         *,
         verify_conf: bool = True,
+        env: Mapping[str, str] | None = None,
     ) -> None:
         pyproject = copy.deepcopy(pyproject)
 
@@ -133,10 +163,12 @@ class SettingsReader:
             if "any" in if_override:
                 any_override = if_override.pop("any")
                 select = {k.replace("-", "_"): v for k, v in any_override.items()}
-                matched = override_match(match_all=False, **select)
+                matched = override_match(match_all=False, current_env=env, **select)
             select = {k.replace("-", "_"): v for k, v in if_override.items()}
             if select:
-                matched = matched and override_match(match_all=True, **select)
+                matched = matched and override_match(
+                    match_all=True, current_env=env, **select
+                )
             if matched:
                 for key, value in override.items():
                     if isinstance(value, dict):
diff --git a/src/scikit_build_core/settings/skbuild_schema.py b/src/scikit_build_core/settings/skbuild_schema.py
index 32ee87e..b388c31 100644
--- a/src/scikit_build_core/settings/skbuild_schema.py
+++ b/src/scikit_build_core/settings/skbuild_schema.py
@@ -94,6 +94,14 @@ def generate_skbuild_schema(tool_name: str = "scikit-build") -> dict[str, Any]:
             "platform-system": {"type": "string"},
             "platform-machine": {"type": "string"},
             "platform-node": {"type": "string"},
+            "env": {
+                "type": "object",
+                "patternProperties": {
+                    ".*": {"oneOf": [{"type": "string"}, {"type": "boolean"}]}
+                },
+                "additionalProperties": False,
+                "minProperties": 1,
+            },
         },
     }
     schema["properties"]["overrides"] = {


Test patch:
diff --git a/tests/test_settings_overrides.py b/tests/test_settings_overrides.py
index 3941d9a..53988ee 100644
--- a/tests/test_settings_overrides.py
+++ b/tests/test_settings_overrides.py
@@ -1,10 +1,15 @@
-from pathlib import Path
+from __future__ import annotations
+
+import typing
 from textwrap import dedent
 
 import pytest
 
 from scikit_build_core.settings.skbuild_read_settings import SettingsReader, regex_match
 
+if typing.TYPE_CHECKING:
+    from pathlib import Path
+
 
 @pytest.mark.parametrize("python_version", ["3.9", "3.10"])
 def test_skbuild_overrides_pyver(
@@ -305,3 +310,95 @@ def test_regex_match(regex: str):
 @pytest.mark.parametrize("regex", ["^string", "this$", "other"])
 def test_not_regex_match(regex: str):
     assert not regex_match("this_is_a_string", regex)
+
+
+@pytest.mark.parametrize("envvar", ["BAR", "", None])
+def test_skbuild_env(
+    envvar: str | None, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+    if envvar is None:
+        monkeypatch.delenv("FOO", raising=False)
+    else:
+        monkeypatch.setenv("FOO", envvar)
+
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        dedent(
+            """\
+            [[tool.scikit-build.overrides]]
+            if.env.FOO = "BAR"
+            sdist.cmake = true
+            """
+        ),
+        encoding="utf-8",
+    )
+
+    settings_reader = SettingsReader.from_file(pyproject_toml, {})
+    settings = settings_reader.settings
+
+    if envvar == "BAR":
+        assert settings.sdist.cmake
+    else:
+        assert not settings.sdist.cmake
+
+
+@pytest.mark.parametrize("envvar", ["tRUE", "3", "0", "", None])
+def test_skbuild_env_bool(
+    envvar: str | None, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+    if envvar is None:
+        monkeypatch.delenv("FOO", raising=False)
+    else:
+        monkeypatch.setenv("FOO", envvar)
+
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        dedent(
+            """\
+            [[tool.scikit-build.overrides]]
+            if.env.FOO = true
+            sdist.cmake = true
+            """
+        ),
+        encoding="utf-8",
+    )
+
+    settings_reader = SettingsReader.from_file(pyproject_toml, {})
+    settings = settings_reader.settings
+
+    if envvar in {"tRUE", "3"}:
+        assert settings.sdist.cmake
+    else:
+        assert not settings.sdist.cmake
+
+
+@pytest.mark.parametrize("foo", ["true", "false"])
+@pytest.mark.parametrize("bar", ["true", "false"])
+@pytest.mark.parametrize("any", [True, False])
+def test_skbuild_env_bool_all_any(
+    foo: str, bar: str, any: bool, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+    monkeypatch.setenv("FOO", foo)
+    monkeypatch.setenv("BAR", bar)
+
+    any_str = ".any" if any else ""
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        dedent(
+            f"""\
+            [[tool.scikit-build.overrides]]
+            if{any_str}.env.FOO = true
+            if{any_str}.env.BAR = true
+            sdist.cmake = true
+            """
+        ),
+        encoding="utf-8",
+    )
+
+    settings_reader = SettingsReader.from_file(pyproject_toml, {})
+    settings = settings_reader.settings
+
+    if (foo == "true" and bar == "true") or any and (foo == "true" or bar == "true"):
+        assert settings.sdist.cmake
+    else:
+        assert not settings.sdist.cmake