From f7d0cd3e646d4720524404ae8b85626541e7794b Mon Sep 17 00:00:00 2001 From: Maxwell G Date: Dec 13 2023 04:26:16 +0000 Subject: [PATCH 1/2] Use pyproject_hooks to build wheel and call hooks This isolates build backend calls in a subprocess instead of directly importing the backends in pyproject_buildrequires.py. It also removes use of the intermediary %_pyproject_buildrequires file. It's now possible to redirect all subprocess output to stderr. It also allows us more flexibly and consistently handles config_settings between pyproject_buildrequires.py and pyproject_wheel.py. --- diff --git a/README.md b/README.md index 9700ef8..21a919b 100644 --- a/README.md +++ b/README.md @@ -186,17 +186,6 @@ backend's documentation for more information. Note that some projects don't use config settings at all and other projects may only accept config settings for one of the two steps. -Note that the current implementation of the macros uses `pip` to build wheels. -On some systems (notably on RHEL 9 with Python 3.9), -`pip` is too old to understand `--config-settings`. -Using the `-C` option for `%pyproject_wheel` (or `%pyproject_buildrequires -w`) -is not supported there and will result to an error like: - - Usage: - /usr/bin/python3 -m pip wheel [options] ... - ... - no such option: --config-settings - [config_settings]: https://peps.python.org/pep-0517/#config-settings diff --git a/_pyproject_hooks.py b/_pyproject_hooks.py new file mode 100644 index 0000000..651e79c --- /dev/null +++ b/_pyproject_hooks.py @@ -0,0 +1,186 @@ +""" +Code for working with pyproject backends +""" + +from __future__ import annotations + +import dataclasses +import os +import subprocess +import sys +from collections.abc import Callable, Mapping, Sequence +from pathlib import Path +from typing import TYPE_CHECKING, Any, Optional, Union + +from pyproject_hooks import BuildBackendHookCaller as _BuildBackendHookCaller + +if TYPE_CHECKING: + StrPath = Union[str, os.PathLike[str]] + # This should match the interface of pyproject_hooks.default_subprocess_runner + RunnerFuncT = Callable[ + [Sequence[StrPath], StrPath, Optional[Mapping[str, str]]], None + ] + + +@dataclasses.dataclass +class BackendRequirement: + """ + Represents a backend requirement and the reason it was added + """ + + name: str + reason: str | None = None + + +class BackendHookError(Exception): + """ + Unhandled backend hook failure + """ + + def __init__(self, hook_name: str, returncode: int) -> None: + self.hook_name = hook_name + self.returncode = returncode + super().__init__(f"Failed to call {self.hook_name} hook") + + def __repr__(self) -> str: + hook_name, returncode = self.hook_name, self.returncode + return f"BuildBackendHookCaller({hook_name=}, {returncode=})" + + +class BuildBackendHookCaller(_BuildBackendHookCaller): + """ + pyproject_hooks.BuildBackendHookCaller subclass that wraps backend errors + """ + + def _call_hook(self, hook_name, kwargs): + try: + return super()._call_hook(hook_name, kwargs) + except subprocess.CalledProcessError as exc: + raise BackendHookError(hook_name, exc.returncode) from None + + +DEFAULT_BUILD_BACKEND = "setuptools.build_meta:__legacy__" +DEFAULT_BUILD_REQUIRES = ( + BackendRequirement("setuptools >= 40.8", reason="default build backend"), + BackendRequirement("wheel", reason="default build backend"), +) + + +def stderr_subprocess_runner( + cmd: Sequence[StrPath], cwd: StrPath, extra_environ: Mapping[str, str] | None = None +) -> None: + """ + Alternative to pyproject_hooks.default_subprocess_runner that redirects all + stdout to stderr. + """ + extra_environ = extra_environ or {} + subprocess.run( + cmd, + stdout=sys.stderr, + cwd=cwd, + env={**os.environ, **extra_environ}, + check=True, + ) + + +class _Sentinel: + pass + + +@dataclasses.dataclass +class BuildBackend: + """ + Represents a build backend + """ + + name: str + requires: list[BackendRequirement] + path: list[str] | None = None + directory: Path = Path() + default_runner: RunnerFuncT | None = None + + @classmethod + def from_pyproject_data( + cls, + data: dict[str, Any], + directory: Path = Path(), + default_runner: RunnerFuncT | None = None, + ) -> BuildBackend: + """ + Create a BuildBackend object based on a parsed pyproject.toml dictionary + """ + buildsystem_data: dict[str, Any] = data.get("build-system", {}) + + requires: list[BackendRequirement] = [ + BackendRequirement(i, reason="build-system.requires") + for i in buildsystem_data.get("requires", []) + ] + + backend_name: str | None = buildsystem_data.get("build-backend") + + if not backend_name: + # https://www.python.org/dev/peps/pep-0517/: + # If the pyproject.toml file is absent, or the build-backend key is + # missing, the source tree is not using this specification, and tools + # should revert to the legacy behaviour of running setup.py + # (either directly, or by implicitly invoking the [following] backend). + # If setup.py is also not present program will mimick pip's behavior + # and end with an error. + if not (directory / "setup.py").exists(): + raise FileNotFoundError('"setup.py" not found for legacy project.') + backend_name = DEFAULT_BUILD_BACKEND + + # Note: For projects without pyproject.toml, this was already echoed + # by the %pyproject_buildrequires macro, but this also handles cases + # with pyproject.toml without a specified build backend. + # If the default requirements change, also change them in the macro! + requires.extend(DEFAULT_BUILD_REQUIRES) + + backend_path: list[str] = buildsystem_data.get("backend-path", []) + if isinstance(backend_path, str): + backend_path = [backend_path] + + return cls( + name=backend_name, + requires=requires, + path=backend_path, + directory=directory, + default_runner=default_runner, + ) + + @classmethod + def from_directory( + cls, directory: Path = Path(), default_runner: RunnerFuncT | None = None + ) -> BuildBackend: + """ + Create a BuildBackend object based on the pyproject.toml in `directory`. + """ + if sys.version_info[:2] >= (3, 11): + import tomllib + else: + import tomli as tomllib + + try: + f = (directory / "pyproject.toml").open("rb") + except FileNotFoundError: + pyproject_data = {} + else: + with f: + pyproject_data = tomllib.load(f) + return cls.from_pyproject_data( + pyproject_data, directory, default_runner=default_runner + ) + + _DEFAULT_RUNNER = _Sentinel() + + def get_caller( + self, + runner: RunnerFuncT | _Sentinel | None = _DEFAULT_RUNNER, + ) -> BuildBackendHookCaller: + """ + Create a BuildBackendHookCaller object based on this BuildBackend + """ + if runner is self._DEFAULT_RUNNER: + runner = self.default_runner + + return BuildBackendHookCaller(self.directory, self.name, self.path, runner) diff --git a/macros.pyproject b/macros.pyproject index 7013de2..822b0f9 100644 --- a/macros.pyproject +++ b/macros.pyproject @@ -19,7 +19,6 @@ %_pyproject_modules %{_builddir}/%{_pyproject_files_prefix}-pyproject-modules %_pyproject_ghost_distinfo %{_builddir}/%{_pyproject_files_prefix}-pyproject-ghost-distinfo %_pyproject_record %{_builddir}/%{_pyproject_files_prefix}-pyproject-record -%_pyproject_buildrequires %{_builddir}/%{_pyproject_files_prefix}-pyproject-buildrequires # Avoid leaking %%{_pyproject_builddir} to pytest collection # https://bugzilla.redhat.com/show_bug.cgi?id=1935212 @@ -171,6 +170,7 @@ echo 'pyproject-rpm-macros' # first stdout line matches the implementation in m echo 'python%{python3_pkgversion}-devel' echo 'python%{python3_pkgversion}dist(pip) >= 19' echo 'python%{python3_pkgversion}dist(packaging)' +echo 'python%{python3_pkgversion}dist(pyproject-hooks)' %{!-N:if [ -f pyproject.toml ]; then %["%{python3_pkgversion}" == "3" ? "echo '(python%{python3_pkgversion}dist(tomli) if python%{python3_pkgversion}-devel < 3.11)'" @@ -191,10 +191,8 @@ fi} rm -rfv *.dist-info/ >&2 if [ -f %{__python3} ]; then mkdir -p "%{_pyproject_builddir}" - echo -n > %{_pyproject_buildrequires} CFLAGS="${CFLAGS:-${RPM_OPT_FLAGS}}" LDFLAGS="${LDFLAGS:-${RPM_LD_FLAGS}}" TMPDIR="%{_pyproject_builddir}" \\\ - RPM_TOXENV="%{toxenv}" HOSTNAME="rpmbuild" %{__python3} -Bs %{_rpmconfigdir}/redhat/pyproject_buildrequires.py %{?!_python_no_extras_requires:--generate-extras} --python3_pkgversion %{python3_pkgversion} --wheeldir %{_pyproject_wheeldir} --output %{_pyproject_buildrequires} %{?**} >&2 - cat %{_pyproject_buildrequires} + RPM_TOXENV="%{toxenv}" HOSTNAME="rpmbuild" %{__python3} -Bs %{_rpmconfigdir}/redhat/pyproject_buildrequires.py %{?!_python_no_extras_requires:--generate-extras} --python3_pkgversion %{python3_pkgversion} --wheeldir %{_pyproject_wheeldir} %{?**} fi # Incomplete .dist-info dir might confuse importlib.metadata rm -rfv *.dist-info/ >&2 diff --git a/pyproject-rpm-macros.spec b/pyproject-rpm-macros.spec index 6f54e6b..c373d21 100644 --- a/pyproject-rpm-macros.spec +++ b/pyproject-rpm-macros.spec @@ -13,7 +13,7 @@ License: MIT # Increment Y and reset Z when new macros or features are added # Increment Z when this is a bugfix or a cosmetic change # Dropping support for EOL Fedoras is *not* considered a breaking change -Version: 1.10.0 +Version: 1.11.0 Release: 1%{?dist} # Macro files @@ -28,6 +28,7 @@ Source104: pyproject_preprocess_record.py Source105: pyproject_construct_toxenv.py Source106: pyproject_requirements_txt.py Source107: pyproject_wheel.py +Source108: _pyproject_hooks.py # Tests Source201: test_pyproject_buildrequires.py @@ -56,6 +57,7 @@ BuildRequires: python3dist(pytest-xdist) BuildRequires: python3dist(pyyaml) BuildRequires: python3dist(packaging) BuildRequires: python3dist(pip) +BuildRequires: python3dist(pyproject-hooks) BuildRequires: python3dist(setuptools) %if %{with tox_tests} BuildRequires: python3dist(tox-current-env) >= 0.0.6 @@ -134,6 +136,7 @@ install -pm 644 pyproject_preprocess_record.py %{buildroot}%{_rpmconfigdir}/redh install -pm 644 pyproject_construct_toxenv.py %{buildroot}%{_rpmconfigdir}/redhat/ install -pm 644 pyproject_requirements_txt.py %{buildroot}%{_rpmconfigdir}/redhat/ install -pm 644 pyproject_wheel.py %{buildroot}%{_rpmconfigdir}/redhat/ +install -pm 644 _pyproject_hooks.py %{buildroot}%{_rpmconfigdir}/redhat/ %check # assert the two signatures of %%pyproject_buildrequires match exactly @@ -161,6 +164,7 @@ export HOSTNAME="rpmbuild" # to speedup tox in network-less mock, see rhbz#1856 %{_rpmconfigdir}/redhat/pyproject_construct_toxenv.py %{_rpmconfigdir}/redhat/pyproject_requirements_txt.py %{_rpmconfigdir}/redhat/pyproject_wheel.py +%{_rpmconfigdir}/redhat/_pyproject_hooks.py %doc README.md %license LICENSE @@ -171,6 +175,9 @@ export HOSTNAME="rpmbuild" # to speedup tox in network-less mock, see rhbz#1856 %changelog +* Wed Dec 13 2023 Maxwell G - 1.11.0-1 +- Use pyproject_hooks to build wheel and call hooks + * Wed Sep 13 2023 Python Maint - 1.10.0-1 - Add %%_pyproject_check_import_allow_no_modules for automated environments - Fix handling of tox 4 provision without an explicit tox minversion diff --git a/pyproject_buildrequires.py b/pyproject_buildrequires.py index 7b0fc87..165e372 100644 --- a/pyproject_buildrequires.py +++ b/pyproject_buildrequires.py @@ -14,7 +14,6 @@ import pathlib import zipfile from pyproject_requirements_txt import convert_requirements_txt -from pyproject_wheel import parse_config_settings_args # Some valid Python version specifiers are not supported. @@ -41,9 +40,19 @@ except ImportError as e: # already echoed by the %pyproject_buildrequires macro sys.exit(0) +try: + from pyproject_hooks import HookMissing +except ImportError as e: + print_err('Import error:', e) + # already echoed by the %pyproject_buildrequires macro + sys.exit(0) + # uses packaging, needs to be imported after packaging is verified to be present from pyproject_convert import convert +from pyproject_wheel import parse_config_settings_args +from _pyproject_hooks import BackendHookError, BuildBackend, stderr_subprocess_runner + def guess_reason_for_invalid_requirement(requirement_str): if ':' in requirement_str: @@ -201,79 +210,19 @@ class Requirements: self.extend(requirements, **kwargs) -def toml_load(opened_binary_file): - try: - # tomllib is in the standard library since 3.11.0b1 - import tomllib - except ImportError: - try: - import tomli as tomllib - except ImportError as e: - print_err('Import error:', e) - # already echoed by the %pyproject_buildrequires macro - sys.exit(0) - return tomllib.load(opened_binary_file) - - -def get_backend(requirements): - try: - f = open('pyproject.toml', 'rb') - except FileNotFoundError: - pyproject_data = {} - else: - with f: - pyproject_data = toml_load(f) - - buildsystem_data = pyproject_data.get('build-system', {}) - requirements.extend( - buildsystem_data.get('requires', ()), - source='build-system.requires', - ) - - backend_name = buildsystem_data.get('build-backend') - if not backend_name: - # https://www.python.org/dev/peps/pep-0517/: - # If the pyproject.toml file is absent, or the build-backend key is - # missing, the source tree is not using this specification, and tools - # should revert to the legacy behaviour of running setup.py - # (either directly, or by implicitly invoking the [following] backend). - # If setup.py is also not present program will mimick pip's behavior - # and end with an error. - if not os.path.exists('setup.py'): - raise FileNotFoundError('File "setup.py" not found for legacy project.') - backend_name = 'setuptools.build_meta:__legacy__' - - # Note: For projects without pyproject.toml, this was already echoed - # by the %pyproject_buildrequires macro, but this also handles cases - # with pyproject.toml without a specified build backend. - # If the default requirements change, also change them in the macro! - requirements.add('setuptools >= 40.8', source='default build backend') - requirements.add('wheel', source='default build backend') - +def get_backend(requirements) -> BuildBackend: + backend = BuildBackend.from_directory(default_runner=stderr_subprocess_runner) + for require in backend.requires: + requirements.add(require.name, source=require.reason) requirements.check(source='build backend') + return backend - backend_path = buildsystem_data.get('backend-path') - if backend_path: - # PEP 517 example shows the path as a list, but some projects don't follow that - if isinstance(backend_path, str): - backend_path = [backend_path] - sys.path = backend_path + sys.path - - module_name, _, object_name = backend_name.partition(":") - backend_module = importlib.import_module(module_name) - - if object_name: - return getattr(backend_module, object_name) - return backend_module - - -def generate_build_requirements(backend, requirements): - get_requires = getattr(backend, 'get_requires_for_build_wheel', None) - if get_requires: - new_reqs = get_requires(config_settings=requirements.config_settings) - requirements.extend(new_reqs, source='get_requires_for_build_wheel') - requirements.check(source='get_requires_for_build_wheel') +def generate_build_requirements(backend: BuildBackend, requirements): + caller = backend.get_caller() + new_reqs = caller.get_requires_for_build_wheel(requirements.config_settings) + requirements.extend(new_reqs, source='get_requires_for_build_wheel') + requirements.check(source='get_requires_for_build_wheel') def parse_metadata_file(metadata_file): @@ -295,17 +244,18 @@ def package_name_and_requires_from_metadata_file(metadata_file): return package_name, requires -def generate_run_requirements_hook(backend, requirements): - hook_name = 'prepare_metadata_for_build_wheel' - prepare_metadata = getattr(backend, hook_name, None) - if not prepare_metadata: +def generate_run_requirements_hook(backend: BuildBackend, requirements): + try: + dir_basename = backend.get_caller().prepare_metadata_for_build_wheel( + '.', requirements.config_settings, _allow_fallback=False + ) + except HookMissing: raise ValueError( 'The build backend cannot provide build metadata ' '(incl. runtime requirements) before build. ' 'Use the provisional -w flag to build the wheel and parse the metadata from it, ' 'or use the -R flag not to generate runtime dependencies.' - ) - dir_basename = prepare_metadata('.', config_settings=requirements.config_settings) + ) from None with open(dir_basename + '/METADATA') as metadata_file: name, requires = package_name_and_requires_from_metadata_file(metadata_file) for key, req in requires.items(): @@ -328,17 +278,16 @@ def generate_run_requirements_wheel(backend, requirements, wheeldir): # Reuse the wheel from the previous round of %pyproject_buildrequires (if it exists) wheel = find_built_wheel(wheeldir) if not wheel: - import pyproject_wheel - returncode = pyproject_wheel.build_wheel( - wheeldir=wheeldir, - stdout=sys.stderr, - config_settings=requirements.config_settings, - ) - if returncode != 0: - raise RuntimeError('Failed to build the wheel for %pyproject_buildrequires -w.') - wheel = find_built_wheel(wheeldir) - if not wheel: - raise RuntimeError('Cannot locate the built wheel for %pyproject_buildrequires -w.') + os.makedirs(wheeldir, exist_ok=True) + try: + wheel = backend.get_caller().build_wheel( + wheeldir, config_settings=requirements.config_settings + ) + wheel = os.path.join(wheeldir, wheel) + except Exception as exc: + raise RuntimeError( + 'Failed to build the wheel for %pyproject_buildrequires -w.' + ) from exc print_err(f'Reading metadata from {wheel}') with zipfile.ZipFile(wheel) as wheelfile: @@ -421,7 +370,7 @@ def generate_requires( *, include_runtime=False, build_wheel=False, wheeldir=None, toxenv=None, extras=None, get_installed_version=importlib.metadata.version, # for dep injection generate_extras=False, python3_pkgversion="3", requirement_files=None, use_build_system=True, - output, config_settings=None, + config_settings=None, ): """Generate the BuildRequires for the project in the current directory @@ -457,7 +406,7 @@ def generate_requires( except EndPass: return finally: - output.write_text(os.linesep.join(requirements.output_lines) + os.linesep) + print(os.linesep.join(requirements.output_lines)) def main(argv): @@ -484,9 +433,6 @@ def main(argv): default="3", help=argparse.SUPPRESS, ) parser.add_argument( - '--output', type=pathlib.Path, required=True, help=argparse.SUPPRESS, - ) - parser.add_argument( '--wheeldir', metavar='PATH', default=None, help=argparse.SUPPRESS, ) @@ -562,9 +508,11 @@ def main(argv): python3_pkgversion=args.python3_pkgversion, requirement_files=args.requirement_files, use_build_system=args.use_build_system, - output=args.output, config_settings=parse_config_settings_args(args.config_settings), ) + except BackendHookError as exc: + print_err(exc) + exit(exc.returncode) except Exception: # Log the traceback explicitly (it's useful debug info) traceback.print_exc() diff --git a/pyproject_buildrequires_testcases.yaml b/pyproject_buildrequires_testcases.yaml index 2e6e91e..8cb1fed 100644 --- a/pyproject_buildrequires_testcases.yaml +++ b/pyproject_buildrequires_testcases.yaml @@ -85,6 +85,7 @@ Bad character in version: pyproject.toml: | [build-system] requires = ["pkg == 0.$.^.*"] + build-backend = "foo.build" except: ValueError Single value version with unsupported compatible operator: diff --git a/pyproject_wheel.py b/pyproject_wheel.py index 6d62176..78a8497 100644 --- a/pyproject_wheel.py +++ b/pyproject_wheel.py @@ -1,6 +1,9 @@ import argparse +import traceback import sys -import subprocess +from pathlib import Path + +from _pyproject_hooks import BuildBackend, BackendHookError def parse_config_settings_args(config_settings): @@ -23,46 +26,14 @@ def parse_config_settings_args(config_settings): return new_config_settings -def get_config_settings_args(config_settings): - """ - Given a dictionary of PEP 517 backend config_settings, - yield --config-settings args that can be passed to pip's CLI - """ - if not config_settings: - return - for key, values in config_settings.items(): - if not isinstance(values, list): - values = [values] - for value in values: - if value == '': - yield f'--config-settings={key}' - else: - yield f'--config-settings={key}={value}' - - -def build_wheel(*, wheeldir, stdout=None, config_settings=None): - command = ( - sys.executable, - '-m', 'pip', - 'wheel', - '--wheel-dir', wheeldir, - '--no-deps', - '--use-pep517', - '--no-build-isolation', - '--disable-pip-version-check', - '--no-clean', - '--progress-bar', 'off', - '--verbose', - *get_config_settings_args(config_settings), - '.', - ) - cp = subprocess.run(command, stdout=stdout) - return cp.returncode +def build_wheel(*, wheeldir, config_settings=None): + wheeldir.mkdir(parents=True, exist_ok=True) + BuildBackend.from_directory().get_caller().build_wheel(wheeldir, config_settings) def parse_args(argv=None): parser = argparse.ArgumentParser(prog='%pyproject_wheel') - parser.add_argument('wheeldir', help=argparse.SUPPRESS) + parser.add_argument('wheeldir', help=argparse.SUPPRESS, type=Path) parser.add_argument( '-C', dest='config_settings', @@ -75,4 +46,13 @@ def parse_args(argv=None): if __name__ == '__main__': - sys.exit(build_wheel(**vars(parse_args()))) + try: + build_wheel(**vars(parse_args())) + except BackendHookError as exc: + print(exc, file=sys.stderr) + sys.exit(exc.returncode) + except Exception: + traceback.print_exc() + sys.exit(1) + else: + sys.exit(0) diff --git a/test_pyproject_buildrequires.py b/test_pyproject_buildrequires.py index 0fa07db..c56909a 100644 --- a/test_pyproject_buildrequires.py +++ b/test_pyproject_buildrequires.py @@ -1,5 +1,6 @@ from pathlib import Path import importlib.metadata +import sys import packaging.version import pytest @@ -7,6 +8,7 @@ import setuptools import yaml from pyproject_buildrequires import generate_requires +from _pyproject_hooks import BackendHookError SETUPTOOLS_VERSION = packaging.version.parse(setuptools.__version__) SETUPTOOLS_60 = SETUPTOOLS_VERSION >= packaging.version.parse('60') @@ -25,7 +27,6 @@ def test_data(case_name, capfd, tmp_path, monkeypatch): monkeypatch.chdir(cwd) wheeldir = cwd.joinpath('wheeldir') wheeldir.mkdir() - output = tmp_path.joinpath('output.txt') if case.get('xfail'): pytest.xfail(case.get('xfail')) @@ -62,11 +63,14 @@ def test_data(case_name, capfd, tmp_path, monkeypatch): generate_extras=case.get('generate_extras', False), requirement_files=requirement_files, use_build_system=use_build_system, - output=output, config_settings=case.get('config_settings'), ) except SystemExit as e: assert e.code == case['result'] + # Mirror the way the actual code handles BackendHookError + except BackendHookError as e: + print(e, file=sys.stderr) + assert e.returncode == case['result'] except Exception as e: if 'except' not in case: raise @@ -78,8 +82,7 @@ def test_data(case_name, capfd, tmp_path, monkeypatch): # if we ever need to do that, we can remove the check or change it: assert 'expected' in case or 'stderr_contains' in case - out, err = capfd.readouterr() - dependencies = output.read_text() + dependencies, err = capfd.readouterr() if 'expected' in case: expected = case['expected'] diff --git a/tests/config-settings-test.spec b/tests/config-settings-test.spec index 15f2467..c158861 100644 --- a/tests/config-settings-test.spec +++ b/tests/config-settings-test.spec @@ -27,7 +27,7 @@ cat <<'EOF' >pyproject.toml [build-system] build-backend = "config_settings_test_backend" backend-path = ["."] -requires = ["flit-core", "packaging", "pip"] +requires = ["flit-core"] [project] name = "config_settings" @@ -38,11 +38,11 @@ EOF %generate_buildrequires %pyproject_buildrequires -C abc=123 -C xyz=456 -C--option-with-dashes=1 -C--option-with-dashes=2 -%{!?el9:%pyproject_buildrequires -C abc=123 -C xyz=456 -C--option-with-dashes=1 -C--option-with-dashes=2 -w} +%pyproject_buildrequires -C abc=123 -C xyz=456 -C--option-with-dashes=1 -C--option-with-dashes=2 -w %build -%{!?el9:%pyproject_wheel -C abc=123 -C xyz=456 -C--option-with-dashes=1 -C--option-with-dashes=2} +%pyproject_wheel -C abc=123 -C xyz=456 -C--option-with-dashes=1 -C--option-with-dashes=2 %changelog diff --git a/tests/config_settings_test_backend.py b/tests/config_settings_test_backend.py index 80f8a2c..0b1b798 100644 --- a/tests/config_settings_test_backend.py +++ b/tests/config_settings_test_backend.py @@ -4,17 +4,8 @@ It is not compliant with PEP 517 and omits some required hooks. """ from flit_core import buildapi -from packaging.version import parse -from pip import __version__ as pip_version EXPECTED_CONFIG_SETTINGS = [{"abc": "123", "xyz": "456", "--option-with-dashes": ["1", "2"]}] -# Older pip did not accept multiple values, -# but we might backport that later, -# hence we accept it both ways with older pips -if parse(pip_version) < parse("23.1"): - EXPECTED_CONFIG_SETTINGS.append( - EXPECTED_CONFIG_SETTINGS[0] | {"--option-with-dashes": "2"} - ) def _verify_config_settings(config_settings): From b2347c68a33a7a267542c816f0ee18d822b04fcb Mon Sep 17 00:00:00 2001 From: Maxwell G Date: Dec 13 2023 04:26:17 +0000 Subject: [PATCH 2/2] move parse_config_settings_args() to _pyproject_hooks This code is shared by pyproject_wheel and pyproject_buildrequires, so it makes sense to store it in the shared utility module. --- diff --git a/_pyproject_hooks.py b/_pyproject_hooks.py index 651e79c..3b4f768 100644 --- a/_pyproject_hooks.py +++ b/_pyproject_hooks.py @@ -184,3 +184,23 @@ class BuildBackend: runner = self.default_runner return BuildBackendHookCaller(self.directory, self.name, self.path, runner) + + +def parse_config_settings_args(config_settings): + """ + Given a list of config `KEY=VALUE` formatted config settings, + return a dictionary that can be passed to PEP 517 hook functions. + """ + if not config_settings: + return config_settings + new_config_settings = {} + for arg in config_settings: + key, _, value = arg.partition('=') + if key in new_config_settings: + if not isinstance(new_config_settings[key], list): + # convert the existing value to a list + new_config_settings[key] = [new_config_settings[key]] + new_config_settings[key].append(value) + else: + new_config_settings[key] = value + return new_config_settings diff --git a/pyproject_buildrequires.py b/pyproject_buildrequires.py index 165e372..584928b 100644 --- a/pyproject_buildrequires.py +++ b/pyproject_buildrequires.py @@ -50,8 +50,12 @@ except ImportError as e: # uses packaging, needs to be imported after packaging is verified to be present from pyproject_convert import convert -from pyproject_wheel import parse_config_settings_args -from _pyproject_hooks import BackendHookError, BuildBackend, stderr_subprocess_runner +from _pyproject_hooks import ( + BackendHookError, + BuildBackend, + parse_config_settings_args, + stderr_subprocess_runner, +) def guess_reason_for_invalid_requirement(requirement_str): diff --git a/pyproject_wheel.py b/pyproject_wheel.py index 78a8497..195d4ba 100644 --- a/pyproject_wheel.py +++ b/pyproject_wheel.py @@ -3,27 +3,7 @@ import traceback import sys from pathlib import Path -from _pyproject_hooks import BuildBackend, BackendHookError - - -def parse_config_settings_args(config_settings): - """ - Given a list of config `KEY=VALUE` formatted config settings, - return a dictionary that can be passed to PEP 517 hook functions. - """ - if not config_settings: - return config_settings - new_config_settings = {} - for arg in config_settings: - key, _, value = arg.partition('=') - if key in new_config_settings: - if not isinstance(new_config_settings[key], list): - # convert the existing value to a list - new_config_settings[key] = [new_config_settings[key]] - new_config_settings[key].append(value) - else: - new_config_settings[key] = value - return new_config_settings +from _pyproject_hooks import BuildBackend, BackendHookError, parse_config_settings_args def build_wheel(*, wheeldir, config_settings=None):