Skip to content

Commit

Permalink
Support appending/prepending to environment variables
Browse files Browse the repository at this point in the history
  • Loading branch information
tttapa committed Feb 9, 2025
1 parent b2a79a2 commit f101225
Show file tree
Hide file tree
Showing 7 changed files with 199 additions and 18 deletions.
8 changes: 4 additions & 4 deletions src/py_build_cmake/commands/cmake.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@
from dataclasses import dataclass
from itertools import product
from pathlib import Path
from string import Template
from typing import Generator

from distlib.version import NormalizedVersion # type: ignore[import-untyped]

from .. import __version__
from ..common import PackageInfo
from ..common.platform import BuildPlatformInfo
from ..config.environment import substitute_environment_options
from .cmd_runner import CommandRunner

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -145,9 +145,9 @@ def prepare_environment(self):
version = self.plat.macos_version_str
self.environment["MACOSX_DEPLOYMENT_TARGET"] = version
if self.conf_settings.environment:
for k, v in self.conf_settings.environment.items():
templ = Template(v)
self.environment[k] = templ.substitute(self.environment)
substitute_environment_options(
self.environment, self.conf_settings.environment
)
return self.environment

def cross_compiling(self) -> bool:
Expand Down
41 changes: 41 additions & 0 deletions src/py_build_cmake/config/environment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from __future__ import annotations

from string import Template
from typing import Mapping

from .options.string import StringOption


def substitute_environment_options(
env: dict[str, str], config_env: Mapping[str, StringOption | None]
):
"""Given the environment-like options in config_env, update the environment
in env. Supports simple template expansion using ${VAR}."""

def _template_expand(a):
return Template(a).substitute(env)

for k, v in config_env.items():
if v is None:
continue
assert isinstance(v, StringOption)
# Perform template substitution on the different components
for attr in "value", "append", "append_path", "prepend", "prepend_path":
a = getattr(v, attr)
if a is not None:
setattr(v, attr, _template_expand(a))
if v.remove:
v.remove = [_template_expand(r) for r in v.remove]
# If we're appending or prepending to the original value, we need
# to set the initial value to the current value in the environment
if (
k in env
and not v.value
and (v.append or v.prepend or v.append_path or v.prepend_path)
):
v.value = env[k]
str_v = v.finalize()
if str_v is not None:
env[k] = str_v
elif k in env:
del env[k]
6 changes: 6 additions & 0 deletions src/py_build_cmake/config/options/dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@


class DictOfStrConfigOption(ConfigOption):
def __init__(self, *args, finalize_to_str: bool = True, **kwargs):
super().__init__(*args, **kwargs)
self.finalize_to_str = finalize_to_str

def get_typename(self, md: bool = False) -> str:
return "dict"

Expand Down Expand Up @@ -55,6 +59,8 @@ def verify(self, values: ValueReference):
return valdict

def finalize(self, values: ValueReference) -> dict[str, str] | None:
if not self.finalize_to_str:
return values.values
if values.values is None:
return None
options: dict[str, str] = {}
Expand Down
6 changes: 4 additions & 2 deletions src/py_build_cmake/config/options/pyproject_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,10 +251,12 @@ def get_options(project_path: Path | PurePosixPath, *, test: bool = False):
DictOfStrConfigOption("env",
"Environment variables to set when running "
"CMake. Supports variable expansion using "
"`${VAR}` (but not `$VAR`).",
"`${VAR}`. Use a double dollar sign `$$` to "
"insert a literal `$`.",
"env = { \"CMAKE_PREFIX_PATH\" "
"= \"${HOME}/.local\" }",
default=DefaultValueValue({})),
default=DefaultValueValue({}),
finalize_to_str=False),
]) # fmt: skip

# [tool.py-build-cmake.wheel]
Expand Down
13 changes: 12 additions & 1 deletion src/py_build_cmake/config/options/string.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import os
from copy import copy
from dataclasses import dataclass
from dataclasses import dataclass, fields

from ...common import ConfigError
from .config_option import ConfigOption
Expand Down Expand Up @@ -120,6 +120,17 @@ def finalize(self) -> str | None:
final = StringOption._join_path(self.prepend_path, final)
return None if (empty and self.clear) else final

def __repr__(self):
attrs = {
f.name: getattr(self, f.name)
for f in fields(self)
if getattr(self, f.name) is not None
}
if not attrs["clear"]:
del attrs["clear"]
attr_str = ", ".join(f"{key}={value!r}" for key, value in attrs.items())
return f"{self.__class__.__name__}({attr_str})"


class StringConfigOption(ConfigOption):
def get_typename(self, md: bool = False) -> str:
Expand Down
25 changes: 14 additions & 11 deletions tests/test_config_load.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import os
from pathlib import PurePosixPath

import pytest
Expand All @@ -12,6 +11,7 @@
process_config,
)
from py_build_cmake.config.options.config_path import ConfPath
from py_build_cmake.config.options.string import StringOption


def test_process_config_no_cmake():
Expand Down Expand Up @@ -144,8 +144,8 @@ def test_inherit_cross_cmake():
"install_components": ["all_install"],
"minimum_version": "3.15",
"env": {
"foo": "bar",
"crosscompiling": "true",
"foo": StringOption(value="bar"),
"crosscompiling": StringOption(value="true"),
},
},
},
Expand All @@ -168,7 +168,7 @@ def test_inherit_cross_cmake():
"install_args": [],
"install_components": ["linux_install"],
"minimum_version": "3.15",
"env": {"foo": "bar"},
"env": {"foo": StringOption(value="bar")},
},
},
"windows": {
Expand All @@ -190,7 +190,7 @@ def test_inherit_cross_cmake():
"install_args": [],
"install_components": ["all_install", "win_install"],
"minimum_version": "3.15",
"env": {"foo": "bar"},
"env": {"foo": StringOption(value="bar")},
}
},
"mac": {
Expand All @@ -212,7 +212,7 @@ def test_inherit_cross_cmake():
"install_args": [],
"install_components": ["all_install"],
"minimum_version": "3.15",
"env": {"foo": "bar"},
"env": {"foo": StringOption(value="bar")},
}
},
}
Expand Down Expand Up @@ -317,7 +317,7 @@ def test_real_config_no_cross():
"install_args": [],
"install_components": ["linux_install"],
"minimum_version": "3.15",
"env": {"foo": "bar"},
"env": {"foo": StringOption(value="bar")},
}
},
"windows": {
Expand All @@ -339,7 +339,7 @@ def test_real_config_no_cross():
"install_args": [],
"install_components": ["win_install"],
"minimum_version": "3.15",
"env": {"foo": "bar"},
"env": {"foo": StringOption(value="bar")},
}
},
"mac": {
Expand All @@ -361,7 +361,7 @@ def test_real_config_no_cross():
"install_args": [],
"install_components": [""],
"minimum_version": "3.15",
"env": {"foo": "bar"},
"env": {"foo": StringOption(value="bar")},
},
},
}
Expand Down Expand Up @@ -600,7 +600,10 @@ def test_real_config_cli_override():
"install_args": [],
"install_components": ["linux_install"],
"minimum_version": "3.15",
"env": {"PATH": "$HOME/opt" + os.pathsep + "/usr/bin", "foo": "bar"},
"env": {
"PATH": StringOption(value="/usr/bin", prepend_path="$HOME/opt"),
"foo": StringOption(value="bar"),
},
}
},
"windows": {
Expand All @@ -622,7 +625,7 @@ def test_real_config_cli_override():
"install_args": [],
"install_components": ["win_install"],
"minimum_version": "3.15",
"env": {"foo": "bar"},
"env": {"foo": StringOption(value="bar")},
}
},
"mac": {
Expand Down
118 changes: 118 additions & 0 deletions tests/test_environment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import os

from py_build_cmake.config.environment import (
StringOption,
substitute_environment_options,
)


def test_environment_value():
env = {"FOO": "bar"}
env_opts = {"BAZ": StringOption(value="abc")}
substitute_environment_options(env, env_opts)
assert env == {"FOO": "bar", "BAZ": "abc"}


def test_environment_value_empty_string():
env = {"FOO": "bar"}
env_opts = {"BAZ": StringOption(value="")}
substitute_environment_options(env, env_opts)
assert env == {"FOO": "bar", "BAZ": ""}


def test_environment_value_expand():
env = {"FOO": "bar", "DEF": "def"}
env_opts = {"BAZ": StringOption(value="abc$DEF")}
substitute_environment_options(env, env_opts)
assert env == {"FOO": "bar", "DEF": "def", "BAZ": "abcdef"}


def test_environment_value_expand_braces():
env = {"FOO": "bar", "DEF": "def"}
env_opts = {"BAZ": StringOption(value="abc${DEF}ghi")}
substitute_environment_options(env, env_opts)
assert env == {"FOO": "bar", "DEF": "def", "BAZ": "abcdefghi"}


def test_environment_value_escape():
env = {"FOO": "bar", "DEF": "def"}
env_opts = {"BAZ": StringOption(value="abc$${DEF}")}
substitute_environment_options(env, env_opts)
assert env == {"FOO": "bar", "DEF": "def", "BAZ": "abc${DEF}"}


def test_environment_value_escape_dollar():
env = {"FOO": "bar", "DEF": "def"}
env_opts = {"BAZ": StringOption(value="abc$$f")}
substitute_environment_options(env, env_opts)
assert env == {"FOO": "bar", "DEF": "def", "BAZ": "abc$f"}


def test_environment_append():
env = {"FOO": "bar", "BAZ": "abc"}
env_opts = {"BAZ": StringOption(append="def")}
substitute_environment_options(env, env_opts)
assert env == {"FOO": "bar", "BAZ": "abcdef"}


def test_environment_append_empty():
env = {"FOO": "bar"}
env_opts = {"BAZ": StringOption(append="def")}
substitute_environment_options(env, env_opts)
assert env == {"FOO": "bar", "BAZ": "def"}


def test_environment_prepend():
env = {"FOO": "bar", "BAZ": "abc"}
env_opts = {"BAZ": StringOption(prepend="def")}
substitute_environment_options(env, env_opts)
assert env == {"FOO": "bar", "BAZ": "defabc"}


def test_environment_prepend_empty():
env = {"FOO": "bar"}
env_opts = {"BAZ": StringOption(prepend="def")}
substitute_environment_options(env, env_opts)
assert env == {"FOO": "bar", "BAZ": "def"}


def test_environment_append_path():
env = {"FOO": "bar", "BAZ": "abc"}
env_opts = {"BAZ": StringOption(append_path="def")}
substitute_environment_options(env, env_opts)
assert env == {"FOO": "bar", "BAZ": "abc" + os.pathsep + "def"}


def test_environment_append_path_empty():
env = {"FOO": "bar"}
env_opts = {"BAZ": StringOption(append_path="def")}
substitute_environment_options(env, env_opts)
assert env == {"FOO": "bar", "BAZ": "def"}


def test_environment_prepend_path():
env = {"FOO": "bar", "BAZ": "abc"}
env_opts = {"BAZ": StringOption(prepend_path="def")}
substitute_environment_options(env, env_opts)
assert env == {"FOO": "bar", "BAZ": "def" + os.pathsep + "abc"}


def test_environment_prepend_path_empty():
env = {"FOO": "bar"}
env_opts = {"BAZ": StringOption(prepend_path="def")}
substitute_environment_options(env, env_opts)
assert env == {"FOO": "bar", "BAZ": "def"}


def test_environment_clear():
env = {"FOO": "bar", "BAZ": "abc"}
env_opts = {"BAZ": StringOption(clear=True)}
substitute_environment_options(env, env_opts)
assert env == {"FOO": "bar"}


def test_environment_clear_empty():
env = {"FOO": "bar"}
env_opts = {"BAZ": StringOption(clear=True)}
substitute_environment_options(env, env_opts)
assert env == {"FOO": "bar"}

0 comments on commit f101225

Please sign in to comment.