Skip to content

Commit 49adacf

Browse files
committed
feat(config): installer.build-config-settings.pkg
This change introduces the `installer.build-config-settings.<pkg>` configuration option to allow for PEP 517 build config settings to be passed to the respective build backends when a dependency is built during installation. This feature was chosen not to be exposed via an addition to the dependency specification schema as these configurations can differ between environments. Resolves: #845
1 parent 0bb0e91 commit 49adacf

File tree

8 files changed

+489
-18
lines changed

8 files changed

+489
-18
lines changed

docs/configuration.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,49 @@ values, usage instructions and warnings.
271271

272272
Use parallel execution when using the new (`>=1.1.0`) installer.
273273

274+
### `installer.build-config-settings.<package-name>`
275+
276+
**Type**: `Serialised JSON with string or list of string properties`
277+
278+
**Default**: `None`
279+
280+
**Environment Variable**: `POETRY_INSTALLER_BUILD_CONFIG_SETTINGS_<package-name>`
281+
282+
*Introduced in 2.1.0*
283+
284+
{{% warning %}}
285+
This is an **experimental** configuration and can be subject to changes in upcoming releases until it is considered
286+
stable.
287+
{{% /warning %}}
288+
289+
Configure [PEP 517 config settings](https://peps.python.org/pep-0517/#config-settings) to be passed to a package's
290+
build backend if it has to be built from a directory or vcs source; or a source distribution during installation.
291+
292+
This is only used when a compatible binary distribution (wheel) is not available for a package. This can be used along
293+
with [`installer.no-binary`]({{< relref "configuration#installerno-binary" >}}) option to force a build with these
294+
configurations when a dependency of your project with the specified name is being installed.
295+
296+
{{% note %}}
297+
Poetry does not offer a similar option in the `pyproject.toml` file as these are, in majority of cases, not universal
298+
and vary depending on the target installation environment.
299+
300+
If you want to use a project specific configuration it is recommended that this configuration be set locally, in your
301+
project's `poetry.toml` file.
302+
303+
```bash
304+
poetry config --local installer.build-config-settings.grpcio \
305+
'{"CC": "gcc", "--global-option": ["--some-global-option"], "--build-option": ["--build-option1", "--build-option2"]}'
306+
```
307+
308+
If you want to modify a single key, you can do, by setting the same key again.
309+
310+
```bash
311+
poetry config --local installer.build-config-settings.grpcio \
312+
'{"CC": "g++"}'
313+
```
314+
315+
{{% /note %}}
316+
274317
### `requests.max-retries`
275318

276319
**Type**: `int`

src/poetry/config/config.py

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
from __future__ import annotations
22

33
import dataclasses
4+
import json
45
import logging
56
import os
67
import re
78

89
from copy import deepcopy
10+
from json import JSONDecodeError
911
from pathlib import Path
1012
from typing import TYPE_CHECKING
1113
from typing import Any
1214
from typing import ClassVar
1315

16+
from packaging.utils import NormalizedName
1417
from packaging.utils import canonicalize_name
1518

1619
from poetry.config.dict_config_source import DictConfigSource
@@ -22,6 +25,8 @@
2225

2326
if TYPE_CHECKING:
2427
from collections.abc import Callable
28+
from collections.abc import Mapping
29+
from collections.abc import Sequence
2530

2631
from poetry.config.config_source import ConfigSource
2732

@@ -38,6 +43,37 @@ def int_normalizer(val: str) -> int:
3843
return int(val)
3944

4045

46+
def build_config_setting_validator(val: str) -> bool:
47+
try:
48+
value = build_config_setting_normalizer(val)
49+
except JSONDecodeError:
50+
return False
51+
52+
if not isinstance(value, dict):
53+
return False
54+
55+
for key, item in value.items():
56+
# keys should be string
57+
if not isinstance(key, str):
58+
return False
59+
60+
# items are allowed to be a string
61+
if isinstance(item, str):
62+
continue
63+
64+
# list items should only contain strings
65+
is_valid_list = isinstance(item, list) and all(isinstance(i, str) for i in item)
66+
if not is_valid_list:
67+
return False
68+
69+
return True
70+
71+
72+
def build_config_setting_normalizer(val: str) -> Mapping[str, str | Sequence[str]]:
73+
value: Mapping[str, str | Sequence[str]] = json.loads(val)
74+
return value
75+
76+
4177
@dataclasses.dataclass
4278
class PackageFilterPolicy:
4379
policy: dataclasses.InitVar[str | list[str] | None]
@@ -128,6 +164,7 @@ class Config:
128164
"max-workers": None,
129165
"no-binary": None,
130166
"only-binary": None,
167+
"build-config-settings": {},
131168
},
132169
"solver": {
133170
"lazy-wheel": True,
@@ -208,6 +245,26 @@ def _get_environment_repositories() -> dict[str, dict[str, str]]:
208245

209246
return repositories
210247

248+
@staticmethod
249+
def _get_environment_build_config_settings() -> Mapping[
250+
NormalizedName, Mapping[str, str | Sequence[str]]
251+
]:
252+
build_config_settings = {}
253+
pattern = re.compile(r"POETRY_INSTALLER_BUILD_CONFIG_SETTINGS_(?P<name>[^.]+)")
254+
255+
for env_key in os.environ:
256+
if match := pattern.match(env_key):
257+
if not build_config_setting_validator(os.environ[env_key]):
258+
logger.debug(
259+
"Invalid value set for environment variable %s", env_key
260+
)
261+
continue
262+
build_config_settings[canonicalize_name(match.group("name"))] = (
263+
build_config_setting_normalizer(os.environ[env_key])
264+
)
265+
266+
return build_config_settings
267+
211268
@property
212269
def repository_cache_directory(self) -> Path:
213270
return Path(self.get("cache-dir")).expanduser() / "cache" / "repositories"
@@ -244,6 +301,9 @@ def get(self, setting_name: str, default: Any = None) -> Any:
244301
Retrieve a setting value.
245302
"""
246303
keys = setting_name.split(".")
304+
build_config_settings: Mapping[
305+
NormalizedName, Mapping[str, str | Sequence[str]]
306+
] = {}
247307

248308
# Looking in the environment if the setting
249309
# is set via a POETRY_* environment variable
@@ -254,12 +314,25 @@ def get(self, setting_name: str, default: Any = None) -> Any:
254314
if repositories:
255315
return repositories
256316

257-
env = "POETRY_" + "_".join(k.upper().replace("-", "_") for k in keys)
258-
env_value = os.getenv(env)
259-
if env_value is not None:
260-
return self.process(self._get_normalizer(setting_name)(env_value))
317+
build_config_settings_key = "installer.build-config-settings"
318+
if setting_name == build_config_settings_key or setting_name.startswith(
319+
f"{build_config_settings_key}."
320+
):
321+
build_config_settings = self._get_environment_build_config_settings()
322+
else:
323+
env = "POETRY_" + "_".join(k.upper().replace("-", "_") for k in keys)
324+
env_value = os.getenv(env)
325+
if env_value is not None:
326+
return self.process(self._get_normalizer(setting_name)(env_value))
261327

262328
value = self._config
329+
330+
# merge installer build config settings from the environment
331+
for package_name in build_config_settings:
332+
value["installer"]["build-config-settings"][package_name] = (
333+
build_config_settings[package_name]
334+
)
335+
263336
for key in keys:
264337
if key not in value:
265338
return self.process(default)
@@ -318,6 +391,9 @@ def _get_normalizer(name: str) -> Callable[[str], Any]:
318391
if name in ["installer.no-binary", "installer.only-binary"]:
319392
return PackageFilterPolicy.normalize
320393

394+
if name.startswith("installer.build-config-settings."):
395+
return build_config_setting_normalizer
396+
321397
return lambda val: val
322398

323399
@classmethod

src/poetry/console/commands/config.py

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,17 @@
1111

1212
from cleo.helpers import argument
1313
from cleo.helpers import option
14+
from installer.utils import canonicalize_name
1415

1516
from poetry.config.config import PackageFilterPolicy
1617
from poetry.config.config import boolean_normalizer
1718
from poetry.config.config import boolean_validator
19+
from poetry.config.config import build_config_setting_normalizer
20+
from poetry.config.config import build_config_setting_validator
1821
from poetry.config.config import int_normalizer
1922
from poetry.config.config_source import UNSET
2023
from poetry.config.config_source import ConfigSourceMigration
24+
from poetry.config.config_source import PropertyNotFoundError
2125
from poetry.console.commands.command import Command
2226

2327

@@ -149,9 +153,28 @@ def handle(self) -> int:
149153
if setting_key.split(".")[0] in self.LIST_PROHIBITED_SETTINGS:
150154
raise ValueError(f"Expected a value for {setting_key} setting.")
151155

152-
m = re.match(r"^repos?(?:itories)?(?:\.(.+))?", self.argument("key"))
153-
value: str | dict[str, Any]
154-
if m:
156+
value: str | dict[str, Any] | list[str]
157+
158+
if m := re.match(
159+
r"installer\.build-config-settings(\.([^.]+))?", self.argument("key")
160+
):
161+
if not m.group(1):
162+
if value := config.get("installer.build-config-settings"):
163+
self._list_configuration(value, value)
164+
else:
165+
self.line("No packages configured with build config settings.")
166+
else:
167+
package_name = canonicalize_name(m.group(2))
168+
key = f"installer.build-config-settings.{package_name}"
169+
170+
if value := config.get(key):
171+
self.line(json.dumps(value))
172+
else:
173+
self.line(
174+
f"No build config settings configured for <c1>{package_name}</>."
175+
)
176+
return 0
177+
elif m := re.match(r"^repos?(?:itories)?(?:\.(.+))?", self.argument("key")):
155178
if not m.group(1):
156179
value = {}
157180
if config.get("repositories") is not None:
@@ -287,6 +310,35 @@ def handle(self) -> int:
287310

288311
return 0
289312

313+
# handle build config settings
314+
m = re.match(r"installer\.build-config-settings\.([^.]+)", self.argument("key"))
315+
if m:
316+
key = f"installer.build-config-settings.{canonicalize_name(m.group(1))}"
317+
318+
if self.option("unset"):
319+
config.config_source.remove_property(key)
320+
return 0
321+
322+
try:
323+
settings = config.config_source.get_property(key)
324+
except PropertyNotFoundError:
325+
settings = {}
326+
327+
for value in values:
328+
if build_config_setting_validator(value):
329+
config_settings = build_config_setting_normalizer(value)
330+
for setting_name, item in config_settings.items():
331+
settings[setting_name] = item
332+
else:
333+
raise ValueError(
334+
f"Invalid build config setting '{value}'. "
335+
"It must be a valid JSON with each property a string or a list of strings."
336+
)
337+
338+
config.config_source.add_property(key, settings)
339+
340+
return 0
341+
290342
raise ValueError(f"Setting {self.argument('key')} does not exist")
291343

292344
def _handle_single_value(

src/poetry/installation/chef.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212

1313

1414
if TYPE_CHECKING:
15+
from collections.abc import Mapping
16+
from collections.abc import Sequence
17+
1518
from build import DistributionType
1619

1720
from poetry.repositories import RepositoryPool
@@ -31,19 +34,36 @@ def __init__(
3134
self._artifact_cache = artifact_cache
3235

3336
def prepare(
34-
self, archive: Path, output_dir: Path | None = None, *, editable: bool = False
37+
self,
38+
archive: Path,
39+
output_dir: Path | None = None,
40+
*,
41+
editable: bool = False,
42+
config_settings: Mapping[str, str | Sequence[str]] | None = None,
3543
) -> Path:
3644
if not self._should_prepare(archive):
3745
return archive
3846

3947
if archive.is_dir():
4048
destination = output_dir or Path(tempfile.mkdtemp(prefix="poetry-chef-"))
41-
return self._prepare(archive, destination=destination, editable=editable)
49+
return self._prepare(
50+
archive,
51+
destination=destination,
52+
editable=editable,
53+
config_settings=config_settings,
54+
)
4255

43-
return self._prepare_sdist(archive, destination=output_dir)
56+
return self._prepare_sdist(
57+
archive, destination=output_dir, config_settings=config_settings
58+
)
4459

4560
def _prepare(
46-
self, directory: Path, destination: Path, *, editable: bool = False
61+
self,
62+
directory: Path,
63+
destination: Path,
64+
*,
65+
editable: bool = False,
66+
config_settings: Mapping[str, str | Sequence[str]] | None = None,
4767
) -> Path:
4868
distribution: DistributionType = "editable" if editable else "wheel"
4969
with isolated_builder(
@@ -56,10 +76,16 @@ def _prepare(
5676
builder.build(
5777
distribution,
5878
destination.as_posix(),
79+
config_settings=config_settings,
5980
)
6081
)
6182

62-
def _prepare_sdist(self, archive: Path, destination: Path | None = None) -> Path:
83+
def _prepare_sdist(
84+
self,
85+
archive: Path,
86+
destination: Path | None = None,
87+
config_settings: Mapping[str, str | Sequence[str]] | None = None,
88+
) -> Path:
6389
from poetry.core.packages.utils.link import Link
6490

6591
suffix = archive.suffix
@@ -88,6 +114,7 @@ def _prepare_sdist(self, archive: Path, destination: Path | None = None) -> Path
88114
return self._prepare(
89115
sdist_dir,
90116
destination,
117+
config_settings=config_settings,
91118
)
92119

93120
def _should_prepare(self, archive: Path) -> bool:

0 commit comments

Comments
 (0)