Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,49 @@ 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*

{{% warning %}}
This is an **experimental** configuration and can be subject to changes in upcoming releases until it is considered
stable.
{{% /warning %}}

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