Skip to content

Commit

Permalink
feat(config): installer.build-config-settings.pkg
Browse files Browse the repository at this point in the history
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
  • Loading branch information
abn committed Jan 31, 2025
1 parent 0bb0e91 commit 5ef0deb
Show file tree
Hide file tree
Showing 8 changed files with 431 additions and 18 deletions.
38 changes: 38 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,44 @@ values, usage instructions and warnings.

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

### `installer.build-config-settings.<package-name>`

**Type**: `Serialised JSON with string or list of string properties`

**Default**: `None`

**Environment Variable**: `POETRY_INSTALLER_BUILD_CONFIG_SETTINGS_<package-name>`

*Introduced in 2.1.0*

Configure [PEP 517 config settings](https://peps.python.org/pep-0517/#config-settings) to be passed to a package's
build backend if it has to be built from a directory or vcs source; or a source distribution during installation.

This is only used when a compatible binary distribution (wheel) is not available for a package. This can be used along
with [`installer.no-binary`]({{< relref "configuration#installerno-binary" >}}) option to force a build with these
configurations when a dependency of your project with the specified name is being installed.

{{% note %}}
Poetry does not offer a similar option in the `pyproject.toml` file as these are, in majority of cases, not universal
and vary depending on the target installation environment.

If you want to use a project specific configuration it is recommended that this configuration be set locally, in your
project's `poetry.toml` file.

```bash
poetry config --local installer.build-config-settings.grpcio \
'{"CC": "gcc", "--global-option": ["--some-global-option"], "--build-option": ["--build-option1", "--build-option2"]}'
```

If you want to modify a single key, you can do, by setting the same key again.

```bash
poetry config --local installer.build-config-settings.grpcio \
'{"CC": "g++"}'
```

{{% /note %}}

### `requests.max-retries`

**Type**: `int`
Expand Down
84 changes: 80 additions & 4 deletions src/poetry/config/config.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
from __future__ import annotations

import dataclasses
import json
import logging
import os
import re

from copy import deepcopy
from json import JSONDecodeError
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Any
from typing import ClassVar

from packaging.utils import NormalizedName
from packaging.utils import canonicalize_name

from poetry.config.dict_config_source import DictConfigSource
Expand All @@ -22,6 +25,8 @@

if TYPE_CHECKING:
from collections.abc import Callable
from collections.abc import Mapping
from collections.abc import Sequence

from poetry.config.config_source import ConfigSource

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


def build_config_setting_validator(val: str) -> bool:
try:
value = build_config_setting_normalizer(val)
except JSONDecodeError:
return False

if not isinstance(value, dict):
return False

for key, item in value.items():
# keys should be string
if not isinstance(key, str):
return False

# items are allowed to be a string
if isinstance(item, str):
continue

# list items should only contain strings
is_valid_list = isinstance(item, list) and all(isinstance(i, str) for i in item)
if not is_valid_list:
return False

return True


def build_config_setting_normalizer(val: str) -> Mapping[str, str | Sequence[str]]:
value: Mapping[str, str | Sequence[str]] = json.loads(val)
return value


@dataclasses.dataclass
class PackageFilterPolicy:
policy: dataclasses.InitVar[str | list[str] | None]
Expand Down Expand Up @@ -128,6 +164,7 @@ class Config:
"max-workers": None,
"no-binary": None,
"only-binary": None,
"build-config-settings": {},
},
"solver": {
"lazy-wheel": True,
Expand Down Expand Up @@ -208,6 +245,26 @@ def _get_environment_repositories() -> dict[str, dict[str, str]]:

return repositories

@staticmethod
def _get_environment_build_config_settings() -> Mapping[
NormalizedName, Mapping[str, str | Sequence[str]]
]:
build_config_settings = {}
pattern = re.compile(r"POETRY_INSTALLER_BUILD_CONFIG_SETTINGS_(?P<name>[^.]+)")

for env_key in os.environ:
if match := pattern.match(env_key):
if not build_config_setting_validator(os.environ[env_key]):
logger.debug(
"Invalid value set for environment variable %s", env_key
)
continue
build_config_settings[canonicalize_name(match.group("name"))] = (
build_config_setting_normalizer(os.environ[env_key])
)

return build_config_settings

@property
def repository_cache_directory(self) -> Path:
return Path(self.get("cache-dir")).expanduser() / "cache" / "repositories"
Expand Down Expand Up @@ -244,6 +301,9 @@ def get(self, setting_name: str, default: Any = None) -> Any:
Retrieve a setting value.
"""
keys = setting_name.split(".")
build_config_settings: Mapping[
NormalizedName, Mapping[str, str | Sequence[str]]
] = {}

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

env = "POETRY_" + "_".join(k.upper().replace("-", "_") for k in keys)
env_value = os.getenv(env)
if env_value is not None:
return self.process(self._get_normalizer(setting_name)(env_value))
build_config_settings_key = "installer.build-config-settings"
if setting_name == build_config_settings_key or setting_name.startswith(
f"{build_config_settings_key}."
):
build_config_settings = self._get_environment_build_config_settings()
else:
env = "POETRY_" + "_".join(k.upper().replace("-", "_") for k in keys)
env_value = os.getenv(env)
if env_value is not None:
return self.process(self._get_normalizer(setting_name)(env_value))

value = self._config

# merge installer build config settings from the environment
for package_name in build_config_settings:
value["installer"]["build-config-settings"][package_name] = (
build_config_settings[package_name]
)

for key in keys:
if key not in value:
return self.process(default)
Expand Down Expand Up @@ -318,6 +391,9 @@ def _get_normalizer(name: str) -> Callable[[str], Any]:
if name in ["installer.no-binary", "installer.only-binary"]:
return PackageFilterPolicy.normalize

if name.startswith("installer.build-config-settings."):
return build_config_setting_normalizer

return lambda val: val

@classmethod
Expand Down
58 changes: 55 additions & 3 deletions src/poetry/console/commands/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@

from cleo.helpers import argument
from cleo.helpers import option
from installer.utils import canonicalize_name

from poetry.config.config import PackageFilterPolicy
from poetry.config.config import boolean_normalizer
from poetry.config.config import boolean_validator
from poetry.config.config import build_config_setting_normalizer
from poetry.config.config import build_config_setting_validator
from poetry.config.config import int_normalizer
from poetry.config.config_source import UNSET
from poetry.config.config_source import ConfigSourceMigration
from poetry.config.config_source import PropertyNotFoundError
from poetry.console.commands.command import Command


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

m = re.match(r"^repos?(?:itories)?(?:\.(.+))?", self.argument("key"))
value: str | dict[str, Any]
if m:
value: str | dict[str, Any] | list[str]

if m := re.match(
r"installer\.build-config-settings(\.([^.]+))?", self.argument("key")
):
if not m.group(1):
if value := config.get("installer.build-config-settings"):
self._list_configuration(value, value)
else:
self.line("No packages configured with build config settings.")
else:
package_name = canonicalize_name(m.group(2))
key = f"installer.build-config-settings.{package_name}"

if value := config.get(key):
self.line(json.dumps(value))
else:
self.line(
f"No build config settings configured for <c1>{package_name}."
)
return 0
elif m := re.match(r"^repos?(?:itories)?(?:\.(.+))?", self.argument("key")):
if not m.group(1):
value = {}
if config.get("repositories") is not None:
Expand Down Expand Up @@ -287,6 +310,35 @@ def handle(self) -> int:

return 0

# handle build config settings
m = re.match(r"installer\.build-config-settings\.([^.]+)", self.argument("key"))
if m:
key = f"installer.build-config-settings.{canonicalize_name(m.group(1))}"

if self.option("unset"):
config.config_source.remove_property(key)
return 0

try:
settings = config.config_source.get_property(key)
except PropertyNotFoundError:
settings = {}

for value in values:
if build_config_setting_validator(value):
config_settings = build_config_setting_normalizer(value)
for setting_name, item in config_settings.items():
settings[setting_name] = item
else:
raise ValueError(
f"Invalid build config setting '{value}'. "
"It must be a valid JSON with each property a string or a list of strings."
)

config.config_source.add_property(key, settings)

return 0

raise ValueError(f"Setting {self.argument('key')} does not exist")

def _handle_single_value(
Expand Down
37 changes: 32 additions & 5 deletions src/poetry/installation/chef.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@


if TYPE_CHECKING:
from collections.abc import Mapping
from collections.abc import Sequence

from build import DistributionType

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

def prepare(
self, archive: Path, output_dir: Path | None = None, *, editable: bool = False
self,
archive: Path,
output_dir: Path | None = None,
*,
editable: bool = False,
config_settings: Mapping[str, str | Sequence[str]] | None = None,
) -> Path:
if not self._should_prepare(archive):
return archive

if archive.is_dir():
destination = output_dir or Path(tempfile.mkdtemp(prefix="poetry-chef-"))
return self._prepare(archive, destination=destination, editable=editable)
return self._prepare(
archive,
destination=destination,
editable=editable,
config_settings=config_settings,
)

return self._prepare_sdist(archive, destination=output_dir)
return self._prepare_sdist(
archive, destination=output_dir, config_settings=config_settings
)

def _prepare(
self, directory: Path, destination: Path, *, editable: bool = False
self,
directory: Path,
destination: Path,
*,
editable: bool = False,
config_settings: Mapping[str, str | Sequence[str]] | None = None,
) -> Path:
distribution: DistributionType = "editable" if editable else "wheel"
with isolated_builder(
Expand All @@ -56,10 +76,16 @@ def _prepare(
builder.build(
distribution,
destination.as_posix(),
config_settings=config_settings,
)
)

def _prepare_sdist(self, archive: Path, destination: Path | None = None) -> Path:
def _prepare_sdist(
self,
archive: Path,
destination: Path | None = None,
config_settings: Mapping[str, str | Sequence[str]] | None = None,
) -> Path:
from poetry.core.packages.utils.link import Link

suffix = archive.suffix
Expand Down Expand Up @@ -88,6 +114,7 @@ def _prepare_sdist(self, archive: Path, destination: Path | None = None) -> Path
return self._prepare(
sdist_dir,
destination,
config_settings=config_settings,
)

def _should_prepare(self, archive: Path) -> bool:
Expand Down
Loading

0 comments on commit 5ef0deb

Please sign in to comment.