From f5c992d0719a8bee165f2e9364a5683a04d4641c Mon Sep 17 00:00:00 2001 From: Daniele Nicolodi Date: Thu, 23 Feb 2023 22:42:10 +0100 Subject: [PATCH] ENH: add support for wheel build time dependencies version pins When "dependencies" is specified as a dynamic field in the "[project]" section in pyproject.toml, the dependencies reported for the sdist are copied from the "dependencies" field in the "[tool.meson-python]" section. More importantly, the dependencies reported for the wheels ate computed combining this field and the "build-time-pins" field in the same section completed with the build time version information. The "dependencies" and "build-time-pins" fields in the "[tool.meson-python]" section accept the standard metadata dependencies syntax as specified in PEP 440. The "build-time-pins" field cannot contain markers or extras but it is expanded as a format string where the 'v' variable is bound to the version of the package to which the dependency requirements applies present at the time of the build parsed as a packaging.version.Version object. --- mesonpy/__init__.py | 61 +++++++++++++++++-- pyproject.toml | 4 ++ .../packages/dynamic-dependencies/meson.build | 5 ++ .../dynamic-dependencies/pyproject.toml | 27 ++++++++ tests/test_metadata.py | 13 ++++ tests/test_tags.py | 2 +- tests/test_wheel.py | 24 ++++++++ 7 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 tests/packages/dynamic-dependencies/meson.build create mode 100644 tests/packages/dynamic-dependencies/pyproject.toml diff --git a/mesonpy/__init__.py b/mesonpy/__init__.py index cdf687bd3..7ded0df02 100644 --- a/mesonpy/__init__.py +++ b/mesonpy/__init__.py @@ -14,6 +14,7 @@ import argparse import collections import contextlib +import copy import difflib import functools import importlib.machinery @@ -42,6 +43,12 @@ else: import tomllib +if sys.version_info < (3, 8): + import importlib_metadata +else: + import importlib.metadata as importlib_metadata + +import packaging.requirements import packaging.version import pyproject_metadata @@ -125,6 +132,8 @@ def _init_colors() -> Dict[str, str]: _EXTENSION_SUFFIX_REGEX = re.compile(r'^\.(?:(?P[^.]+)\.)?(?:so|pyd|dll)$') assert all(re.match(_EXTENSION_SUFFIX_REGEX, x) for x in _EXTENSION_SUFFIXES) +_REQUIREMENT_NAME_REGEX = re.compile(r'^(?P[A-Za-z0-9][A-Za-z0-9-_.]+)') + def _showwarning( message: Union[Warning, str], @@ -197,6 +206,7 @@ def __init__( build_dir: pathlib.Path, sources: Dict[str, Dict[str, Any]], copy_files: Dict[str, str], + build_time_pins_templates: List[str], ) -> None: self._project = project self._source_dir = source_dir @@ -204,7 +214,7 @@ def __init__( self._build_dir = build_dir self._sources = sources self._copy_files = copy_files - + self._build_time_pins = build_time_pins_templates self._libs_build_dir = self._build_dir / 'mesonpy-wheel-libs' @cached_property @@ -550,8 +560,12 @@ def _install_path( wheel_file.write(origin, location) def _wheel_write_metadata(self, whl: mesonpy._wheelfile.WheelFile) -> None: + # copute dynamic dependencies + metadata = copy.copy(self._project.metadata) + metadata.dependencies = _compute_build_time_dependencies(metadata.dependencies, self._build_time_pins) + # add metadata - whl.writestr(f'{self.distinfo_dir}/METADATA', bytes(self._project.metadata.as_rfc822())) + whl.writestr(f'{self.distinfo_dir}/METADATA', bytes(metadata.as_rfc822())) whl.writestr(f'{self.distinfo_dir}/WHEEL', self.wheel) if self.entrypoints_txt: whl.writestr(f'{self.distinfo_dir}/entry_points.txt', self.entrypoints_txt) @@ -677,7 +691,9 @@ def _strings(value: Any, name: str) -> List[str]: scheme = _table({ 'args': _table({ name: _strings for name in _MESON_ARGS_KEYS - }) + }), + 'dependencies': _strings, + 'build-time-pins': _strings, }) table = pyproject.get('tool', {}).get('meson-python', {}) @@ -726,6 +742,7 @@ def _validate_metadata(metadata: pyproject_metadata.StandardMetadata) -> None: """Validate package metadata.""" allowed_dynamic_fields = [ + 'dependencies', 'version', ] @@ -742,9 +759,36 @@ def _validate_metadata(metadata: pyproject_metadata.StandardMetadata) -> None: raise ConfigError(f'building with Python {platform.python_version()}, version {metadata.requires_python} required') +def _compute_build_time_dependencies( + dependencies: List[packaging.requirements.Requirement], + pins: List[str]) -> List[packaging.requirements.Requirement]: + for template in pins: + match = _REQUIREMENT_NAME_REGEX.match(template) + if not match: + raise ConfigError(f'invalid requirement format in "build-time-pins": {template!r}') + name = match.group(1) + try: + version = packaging.version.parse(importlib_metadata.version(name)) + except importlib_metadata.PackageNotFoundError as exc: + raise ConfigError(f'package "{name}" specified in "build-time-pins" not found: {template!r}') from exc + pin = packaging.requirements.Requirement(template.format(v=version)) + if pin.marker: + raise ConfigError(f'requirements in "build-time-pins" cannot contain markers: {template!r}') + if pin.extras: + raise ConfigError(f'requirements in "build-time-pins" cannot contain erxtras: {template!r}') + added = False + for d in dependencies: + if d.name == name: + d.specifier = d.specifier & pin.specifier + added = True + if not added: + dependencies.append(pin) + return dependencies + + class Project(): """Meson project wrapper to generate Python artifacts.""" - def __init__( + def __init__( # noqa: C901 self, source_dir: Path, working_dir: Path, @@ -761,6 +805,7 @@ def __init__( self._meson_cross_file = self._build_dir / 'meson-python-cross-file.ini' self._meson_args: MesonArgs = collections.defaultdict(list) self._env = os.environ.copy() + self._build_time_pins = [] # prepare environment self._ninja = _env_ninja_command() @@ -846,6 +891,13 @@ def __init__( if 'version' in self._metadata.dynamic: self._metadata.version = packaging.version.Version(self._meson_version) + # set base dependencie if dynamic + if 'dependencies' in self._metadata.dynamic: + dependencies = [packaging.requirements.Requirement(d) for d in pyproject_config.get('dependencies', [])] + self._metadata.dependencies = dependencies + self._metadata.dynamic.remove('dependencies') + self._build_time_pins = pyproject_config.get('build-time-pins', []) + def _run(self, cmd: Sequence[str]) -> None: """Invoke a subprocess.""" print('{cyan}{bold}+ {}{reset}'.format(' '.join(cmd), **_STYLES)) @@ -890,6 +942,7 @@ def _wheel_builder(self) -> _WheelBuilder: self._build_dir, self._install_plan, self._copy_files, + self._build_time_pins, ) def build_commands(self, install_dir: Optional[pathlib.Path] = None) -> Sequence[Sequence[str]]: diff --git a/pyproject.toml b/pyproject.toml index 2486504d2..e0148bfff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,9 @@ build-backend = 'mesonpy' backend-path = ['.'] requires = [ + 'importlib_metadata; python_version < "3.8"', 'meson >= 0.63.3', + 'packaging', 'pyproject-metadata >= 0.7.1', 'tomli >= 1.0.0; python_version < "3.11"', 'setuptools >= 60.0; python_version >= "3.12"', @@ -29,7 +31,9 @@ classifiers = [ dependencies = [ 'colorama; os_name == "nt"', + 'importlib_metadata; python_version < "3.8"', 'meson >= 0.63.3', + 'packaging', 'pyproject-metadata >= 0.7.1', 'tomli >= 1.0.0; python_version < "3.11"', 'setuptools >= 60.0; python_version >= "3.12"', diff --git a/tests/packages/dynamic-dependencies/meson.build b/tests/packages/dynamic-dependencies/meson.build new file mode 100644 index 000000000..9f136bb9e --- /dev/null +++ b/tests/packages/dynamic-dependencies/meson.build @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: 2023 The meson-python developers +# +# SPDX-License-Identifier: MIT + +project('dynamic-dependencies', version: '1.0.0') diff --git a/tests/packages/dynamic-dependencies/pyproject.toml b/tests/packages/dynamic-dependencies/pyproject.toml new file mode 100644 index 000000000..4c6c5f4ae --- /dev/null +++ b/tests/packages/dynamic-dependencies/pyproject.toml @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2023 The meson-python developers +# +# SPDX-License-Identifier: MIT + +[build-system] +build-backend = 'mesonpy' +requires = ['meson-python'] + +[project] +name = 'dynamic-dependencies' +version = '1.0.0' +dynamic = [ + 'dependencies', +] + +[tool.meson-python] +# base dependencies, used for the sdist +dependencies = [ + 'meson >= 0.63.0', + 'meson-python >= 0.13.0', +] +# additional requirements based on the versions of the dependencies +# used during the build of the wheels, used for the wheels +build-time-pins = [ + 'meson >= {v}', + 'packaging ~= {v.major}.{v.minor}', +] diff --git a/tests/test_metadata.py b/tests/test_metadata.py index cdcbdfa27..59778253b 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -68,3 +68,16 @@ def test_dynamic_version(sdist_dynamic_version): Name: dynamic-version Version: 1.0.0 ''') + + +def test_dynamic_dependencies(sdist_dynamic_dependencies): + with tarfile.open(sdist_dynamic_dependencies, 'r:gz') as sdist: + sdist_pkg_info = sdist.extractfile('dynamic_dependencies-1.0.0/PKG-INFO').read().decode() + + assert sdist_pkg_info == textwrap.dedent('''\ + Metadata-Version: 2.1 + Name: dynamic-dependencies + Version: 1.0.0 + Requires-Dist: meson>=0.63.0 + Requires-Dist: meson-python>=0.13.0 + ''') diff --git a/tests/test_tags.py b/tests/test_tags.py index 206b2e968..4f3bfcf94 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -56,7 +56,7 @@ def wheel_builder_test_factory(monkeypatch, content): files = defaultdict(list) files.update({key: [(pathlib.Path(x), os.path.join('build', x)) for x in value] for key, value in content.items()}) monkeypatch.setattr(mesonpy._WheelBuilder, '_wheel_files', files) - return mesonpy._WheelBuilder(None, pathlib.Path(), pathlib.Path(), pathlib.Path(), {}, {}) + return mesonpy._WheelBuilder(None, pathlib.Path(), pathlib.Path(), pathlib.Path(), {}, {}, []) def test_tag_empty_wheel(monkeypatch): diff --git a/tests/test_wheel.py b/tests/test_wheel.py index 0d55c4102..8af72d868 100644 --- a/tests/test_wheel.py +++ b/tests/test_wheel.py @@ -12,7 +12,14 @@ import sysconfig import textwrap + +if sys.version_info < (3, 8): + import importlib_metadata +else: + import importlib.metadata as importlib_metadata + import packaging.tags +import packaging.version import pytest import wheel.wheelfile @@ -287,3 +294,20 @@ def test_editable_broken_non_existent_build_dir( venv.pip('install', os.path.join(tmp_path, mesonpy.build_editable(tmp_path))) assert venv.python('-c', 'import plat; print(plat.foo())').strip() == 'bar' + + +def test_build_time_pins(wheel_dynamic_dependencies): + artifact = wheel.wheelfile.WheelFile(wheel_dynamic_dependencies) + + meson_version = packaging.version.parse(importlib_metadata.version('meson')) + packaging_version = packaging.version.parse(importlib_metadata.version('packaging')) + + with artifact.open('dynamic_dependencies-1.0.0.dist-info/METADATA') as f: + assert f.read().decode() == textwrap.dedent(f'''\ + Metadata-Version: 2.1 + Name: dynamic-dependencies + Version: 1.0.0 + Requires-Dist: meson>=0.63.0,>={meson_version} + Requires-Dist: meson-python>=0.13.0 + Requires-Dist: packaging~={packaging_version.major}.{packaging_version.minor} + ''')