Skip to content

Commit

Permalink
ENH: add support for wheel build time dependencies version pins
Browse files Browse the repository at this point in the history
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
are 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.
  • Loading branch information
dnicolodi committed Mar 4, 2023
1 parent 03992fe commit 4af1454
Show file tree
Hide file tree
Showing 7 changed files with 131 additions and 5 deletions.
61 changes: 57 additions & 4 deletions mesonpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import argparse
import collections
import contextlib
import copy
import difflib
import functools
import importlib.machinery
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -125,6 +132,8 @@ def _init_colors() -> Dict[str, str]:
_EXTENSION_SUFFIX_REGEX = re.compile(r'^\.(?:(?P<abi>[^.]+)\.)?(?:so|pyd|dll)$')
assert all(re.match(_EXTENSION_SUFFIX_REGEX, x) for x in _EXTENSION_SUFFIXES)

_REQUIREMENT_NAME_REGEX = re.compile(r'^(?P<name>[A-Za-z0-9][A-Za-z0-9-_.]+)')


def _showwarning(
message: Union[Warning, str],
Expand Down Expand Up @@ -197,14 +206,15 @@ 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
self._install_dir = install_dir
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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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', {})
Expand Down Expand Up @@ -726,6 +742,7 @@ def _validate_metadata(metadata: pyproject_metadata.StandardMetadata) -> None:
"""Validate package metadata."""

allowed_dynamic_fields = [
'dependencies',
'version',
]

Expand All @@ -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,
Expand All @@ -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()
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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]]:
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"',
Expand All @@ -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"',
Expand Down
5 changes: 5 additions & 0 deletions tests/packages/dynamic-dependencies/meson.build
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# SPDX-FileCopyrightText: 2023 The meson-python developers
#
# SPDX-License-Identifier: MIT

project('dynamic-dependencies', version: '1.0.0')
27 changes: 27 additions & 0 deletions tests/packages/dynamic-dependencies/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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}',
]
13 changes: 13 additions & 0 deletions tests/test_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
''')
2 changes: 1 addition & 1 deletion tests/test_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
24 changes: 24 additions & 0 deletions tests/test_wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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}
''')

0 comments on commit 4af1454

Please sign in to comment.