Skip to content

Commit f5c992d

Browse files
committed
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.
1 parent 03992fe commit f5c992d

File tree

7 files changed

+131
-5
lines changed

7 files changed

+131
-5
lines changed

mesonpy/__init__.py

+57-4
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import argparse
1515
import collections
1616
import contextlib
17+
import copy
1718
import difflib
1819
import functools
1920
import importlib.machinery
@@ -42,6 +43,12 @@
4243
else:
4344
import tomllib
4445

46+
if sys.version_info < (3, 8):
47+
import importlib_metadata
48+
else:
49+
import importlib.metadata as importlib_metadata
50+
51+
import packaging.requirements
4552
import packaging.version
4653
import pyproject_metadata
4754

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

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

129138
def _showwarning(
130139
message: Union[Warning, str],
@@ -197,14 +206,15 @@ def __init__(
197206
build_dir: pathlib.Path,
198207
sources: Dict[str, Dict[str, Any]],
199208
copy_files: Dict[str, str],
209+
build_time_pins_templates: List[str],
200210
) -> None:
201211
self._project = project
202212
self._source_dir = source_dir
203213
self._install_dir = install_dir
204214
self._build_dir = build_dir
205215
self._sources = sources
206216
self._copy_files = copy_files
207-
217+
self._build_time_pins = build_time_pins_templates
208218
self._libs_build_dir = self._build_dir / 'mesonpy-wheel-libs'
209219

210220
@cached_property
@@ -550,8 +560,12 @@ def _install_path(
550560
wheel_file.write(origin, location)
551561

552562
def _wheel_write_metadata(self, whl: mesonpy._wheelfile.WheelFile) -> None:
563+
# copute dynamic dependencies
564+
metadata = copy.copy(self._project.metadata)
565+
metadata.dependencies = _compute_build_time_dependencies(metadata.dependencies, self._build_time_pins)
566+
553567
# add metadata
554-
whl.writestr(f'{self.distinfo_dir}/METADATA', bytes(self._project.metadata.as_rfc822()))
568+
whl.writestr(f'{self.distinfo_dir}/METADATA', bytes(metadata.as_rfc822()))
555569
whl.writestr(f'{self.distinfo_dir}/WHEEL', self.wheel)
556570
if self.entrypoints_txt:
557571
whl.writestr(f'{self.distinfo_dir}/entry_points.txt', self.entrypoints_txt)
@@ -677,7 +691,9 @@ def _strings(value: Any, name: str) -> List[str]:
677691
scheme = _table({
678692
'args': _table({
679693
name: _strings for name in _MESON_ARGS_KEYS
680-
})
694+
}),
695+
'dependencies': _strings,
696+
'build-time-pins': _strings,
681697
})
682698

683699
table = pyproject.get('tool', {}).get('meson-python', {})
@@ -726,6 +742,7 @@ def _validate_metadata(metadata: pyproject_metadata.StandardMetadata) -> None:
726742
"""Validate package metadata."""
727743

728744
allowed_dynamic_fields = [
745+
'dependencies',
729746
'version',
730747
]
731748

@@ -742,9 +759,36 @@ def _validate_metadata(metadata: pyproject_metadata.StandardMetadata) -> None:
742759
raise ConfigError(f'building with Python {platform.python_version()}, version {metadata.requires_python} required')
743760

744761

762+
def _compute_build_time_dependencies(
763+
dependencies: List[packaging.requirements.Requirement],
764+
pins: List[str]) -> List[packaging.requirements.Requirement]:
765+
for template in pins:
766+
match = _REQUIREMENT_NAME_REGEX.match(template)
767+
if not match:
768+
raise ConfigError(f'invalid requirement format in "build-time-pins": {template!r}')
769+
name = match.group(1)
770+
try:
771+
version = packaging.version.parse(importlib_metadata.version(name))
772+
except importlib_metadata.PackageNotFoundError as exc:
773+
raise ConfigError(f'package "{name}" specified in "build-time-pins" not found: {template!r}') from exc
774+
pin = packaging.requirements.Requirement(template.format(v=version))
775+
if pin.marker:
776+
raise ConfigError(f'requirements in "build-time-pins" cannot contain markers: {template!r}')
777+
if pin.extras:
778+
raise ConfigError(f'requirements in "build-time-pins" cannot contain erxtras: {template!r}')
779+
added = False
780+
for d in dependencies:
781+
if d.name == name:
782+
d.specifier = d.specifier & pin.specifier
783+
added = True
784+
if not added:
785+
dependencies.append(pin)
786+
return dependencies
787+
788+
745789
class Project():
746790
"""Meson project wrapper to generate Python artifacts."""
747-
def __init__(
791+
def __init__( # noqa: C901
748792
self,
749793
source_dir: Path,
750794
working_dir: Path,
@@ -761,6 +805,7 @@ def __init__(
761805
self._meson_cross_file = self._build_dir / 'meson-python-cross-file.ini'
762806
self._meson_args: MesonArgs = collections.defaultdict(list)
763807
self._env = os.environ.copy()
808+
self._build_time_pins = []
764809

765810
# prepare environment
766811
self._ninja = _env_ninja_command()
@@ -846,6 +891,13 @@ def __init__(
846891
if 'version' in self._metadata.dynamic:
847892
self._metadata.version = packaging.version.Version(self._meson_version)
848893

894+
# set base dependencie if dynamic
895+
if 'dependencies' in self._metadata.dynamic:
896+
dependencies = [packaging.requirements.Requirement(d) for d in pyproject_config.get('dependencies', [])]
897+
self._metadata.dependencies = dependencies
898+
self._metadata.dynamic.remove('dependencies')
899+
self._build_time_pins = pyproject_config.get('build-time-pins', [])
900+
849901
def _run(self, cmd: Sequence[str]) -> None:
850902
"""Invoke a subprocess."""
851903
print('{cyan}{bold}+ {}{reset}'.format(' '.join(cmd), **_STYLES))
@@ -890,6 +942,7 @@ def _wheel_builder(self) -> _WheelBuilder:
890942
self._build_dir,
891943
self._install_plan,
892944
self._copy_files,
945+
self._build_time_pins,
893946
)
894947

895948
def build_commands(self, install_dir: Optional[pathlib.Path] = None) -> Sequence[Sequence[str]]:

pyproject.toml

+4
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
build-backend = 'mesonpy'
77
backend-path = ['.']
88
requires = [
9+
'importlib_metadata; python_version < "3.8"',
910
'meson >= 0.63.3',
11+
'packaging',
1012
'pyproject-metadata >= 0.7.1',
1113
'tomli >= 1.0.0; python_version < "3.11"',
1214
'setuptools >= 60.0; python_version >= "3.12"',
@@ -29,7 +31,9 @@ classifiers = [
2931

3032
dependencies = [
3133
'colorama; os_name == "nt"',
34+
'importlib_metadata; python_version < "3.8"',
3235
'meson >= 0.63.3',
36+
'packaging',
3337
'pyproject-metadata >= 0.7.1',
3438
'tomli >= 1.0.0; python_version < "3.11"',
3539
'setuptools >= 60.0; python_version >= "3.12"',
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# SPDX-FileCopyrightText: 2023 The meson-python developers
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
project('dynamic-dependencies', version: '1.0.0')
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# SPDX-FileCopyrightText: 2023 The meson-python developers
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
[build-system]
6+
build-backend = 'mesonpy'
7+
requires = ['meson-python']
8+
9+
[project]
10+
name = 'dynamic-dependencies'
11+
version = '1.0.0'
12+
dynamic = [
13+
'dependencies',
14+
]
15+
16+
[tool.meson-python]
17+
# base dependencies, used for the sdist
18+
dependencies = [
19+
'meson >= 0.63.0',
20+
'meson-python >= 0.13.0',
21+
]
22+
# additional requirements based on the versions of the dependencies
23+
# used during the build of the wheels, used for the wheels
24+
build-time-pins = [
25+
'meson >= {v}',
26+
'packaging ~= {v.major}.{v.minor}',
27+
]

tests/test_metadata.py

+13
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,16 @@ def test_dynamic_version(sdist_dynamic_version):
6868
Name: dynamic-version
6969
Version: 1.0.0
7070
''')
71+
72+
73+
def test_dynamic_dependencies(sdist_dynamic_dependencies):
74+
with tarfile.open(sdist_dynamic_dependencies, 'r:gz') as sdist:
75+
sdist_pkg_info = sdist.extractfile('dynamic_dependencies-1.0.0/PKG-INFO').read().decode()
76+
77+
assert sdist_pkg_info == textwrap.dedent('''\
78+
Metadata-Version: 2.1
79+
Name: dynamic-dependencies
80+
Version: 1.0.0
81+
Requires-Dist: meson>=0.63.0
82+
Requires-Dist: meson-python>=0.13.0
83+
''')

tests/test_tags.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def wheel_builder_test_factory(monkeypatch, content):
5656
files = defaultdict(list)
5757
files.update({key: [(pathlib.Path(x), os.path.join('build', x)) for x in value] for key, value in content.items()})
5858
monkeypatch.setattr(mesonpy._WheelBuilder, '_wheel_files', files)
59-
return mesonpy._WheelBuilder(None, pathlib.Path(), pathlib.Path(), pathlib.Path(), {}, {})
59+
return mesonpy._WheelBuilder(None, pathlib.Path(), pathlib.Path(), pathlib.Path(), {}, {}, [])
6060

6161

6262
def test_tag_empty_wheel(monkeypatch):

tests/test_wheel.py

+24
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,14 @@
1212
import sysconfig
1313
import textwrap
1414

15+
16+
if sys.version_info < (3, 8):
17+
import importlib_metadata
18+
else:
19+
import importlib.metadata as importlib_metadata
20+
1521
import packaging.tags
22+
import packaging.version
1623
import pytest
1724
import wheel.wheelfile
1825

@@ -287,3 +294,20 @@ def test_editable_broken_non_existent_build_dir(
287294
venv.pip('install', os.path.join(tmp_path, mesonpy.build_editable(tmp_path)))
288295

289296
assert venv.python('-c', 'import plat; print(plat.foo())').strip() == 'bar'
297+
298+
299+
def test_build_time_pins(wheel_dynamic_dependencies):
300+
artifact = wheel.wheelfile.WheelFile(wheel_dynamic_dependencies)
301+
302+
meson_version = packaging.version.parse(importlib_metadata.version('meson'))
303+
packaging_version = packaging.version.parse(importlib_metadata.version('packaging'))
304+
305+
with artifact.open('dynamic_dependencies-1.0.0.dist-info/METADATA') as f:
306+
assert f.read().decode() == textwrap.dedent(f'''\
307+
Metadata-Version: 2.1
308+
Name: dynamic-dependencies
309+
Version: 1.0.0
310+
Requires-Dist: meson>=0.63.0,>={meson_version}
311+
Requires-Dist: meson-python>=0.13.0
312+
Requires-Dist: packaging~={packaging_version.major}.{packaging_version.minor}
313+
''')

0 commit comments

Comments
 (0)