Skip to content

Commit 018cb4e

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 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.
1 parent 94387da commit 018cb4e

File tree

7 files changed

+131
-5
lines changed

7 files changed

+131
-5
lines changed

mesonpy/__init__.py

Lines changed: 57 additions & 4 deletions
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

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

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

136145
# Maps wheel installation paths to Meson installation path placeholders.
137146
# See https://docs.python.org/3/library/sysconfig.html#installation-paths
@@ -222,12 +231,13 @@ def __init__(
222231
source_dir: pathlib.Path,
223232
build_dir: pathlib.Path,
224233
sources: Dict[str, Dict[str, Any]],
234+
build_time_pins_templates: List[str],
225235
) -> None:
226236
self._project = project
227237
self._source_dir = source_dir
228238
self._build_dir = build_dir
229239
self._sources = sources
230-
240+
self._build_time_pins = build_time_pins_templates
231241
self._libs_build_dir = self._build_dir / 'mesonpy-wheel-libs'
232242

233243
@cached_property
@@ -472,8 +482,12 @@ def _install_path(
472482
wheel_file.write(origin, location)
473483

474484
def _wheel_write_metadata(self, whl: mesonpy._wheelfile.WheelFile) -> None:
485+
# copute dynamic dependencies
486+
metadata = copy.copy(self._project.metadata)
487+
metadata.dependencies = _compute_build_time_dependencies(metadata.dependencies, self._build_time_pins)
488+
475489
# add metadata
476-
whl.writestr(f'{self.distinfo_dir}/METADATA', bytes(self._project.metadata.as_rfc822()))
490+
whl.writestr(f'{self.distinfo_dir}/METADATA', bytes(metadata.as_rfc822()))
477491
whl.writestr(f'{self.distinfo_dir}/WHEEL', self.wheel)
478492
if self.entrypoints_txt:
479493
whl.writestr(f'{self.distinfo_dir}/entry_points.txt', self.entrypoints_txt)
@@ -573,7 +587,9 @@ def _strings(value: Any, name: str) -> List[str]:
573587
scheme = _table({
574588
'args': _table({
575589
name: _strings for name in _MESON_ARGS_KEYS
576-
})
590+
}),
591+
'dependencies': _strings,
592+
'build-time-pins': _strings,
577593
})
578594

579595
table = pyproject.get('tool', {}).get('meson-python', {})
@@ -622,6 +638,7 @@ def _validate_metadata(metadata: pyproject_metadata.StandardMetadata) -> None:
622638
"""Validate package metadata."""
623639

624640
allowed_dynamic_fields = [
641+
'dependencies',
625642
'version',
626643
]
627644

@@ -638,9 +655,36 @@ def _validate_metadata(metadata: pyproject_metadata.StandardMetadata) -> None:
638655
raise ConfigError(f'building with Python {platform.python_version()}, version {metadata.requires_python} required')
639656

640657

658+
def _compute_build_time_dependencies(
659+
dependencies: List[packaging.requirements.Requirement],
660+
pins: List[str]) -> List[packaging.requirements.Requirement]:
661+
for template in pins:
662+
match = _REQUIREMENT_NAME_REGEX.match(template)
663+
if not match:
664+
raise ConfigError(f'invalid requirement format in "build-time-pins": {template!r}')
665+
name = match.group(1)
666+
try:
667+
version = packaging.version.parse(importlib_metadata.version(name))
668+
except importlib_metadata.PackageNotFoundError as exc:
669+
raise ConfigError(f'package "{name}" specified in "build-time-pins" not found: {template!r}') from exc
670+
pin = packaging.requirements.Requirement(template.format(v=version))
671+
if pin.marker:
672+
raise ConfigError(f'requirements in "build-time-pins" cannot contain markers: {template!r}')
673+
if pin.extras:
674+
raise ConfigError(f'requirements in "build-time-pins" cannot contain extras: {template!r}')
675+
added = False
676+
for d in dependencies:
677+
if d.name == name:
678+
d.specifier = d.specifier & pin.specifier
679+
added = True
680+
if not added:
681+
dependencies.append(pin)
682+
return dependencies
683+
684+
641685
class Project():
642686
"""Meson project wrapper to generate Python artifacts."""
643-
def __init__(
687+
def __init__( # noqa: C901
644688
self,
645689
source_dir: Path,
646690
working_dir: Path,
@@ -657,6 +701,7 @@ def __init__(
657701
self._meson_cross_file = self._build_dir / 'meson-python-cross-file.ini'
658702
self._meson_args: MesonArgs = collections.defaultdict(list)
659703
self._env = os.environ.copy()
704+
self._build_time_pins = []
660705

661706
_check_meson_version()
662707

@@ -743,6 +788,13 @@ def __init__(
743788
if 'version' in self._metadata.dynamic:
744789
self._metadata.version = packaging.version.Version(self._meson_version)
745790

791+
# set base dependencie if dynamic
792+
if 'dependencies' in self._metadata.dynamic:
793+
dependencies = [packaging.requirements.Requirement(d) for d in pyproject_config.get('dependencies', [])]
794+
self._metadata.dependencies = dependencies
795+
self._metadata.dynamic.remove('dependencies')
796+
self._build_time_pins = pyproject_config.get('build-time-pins', [])
797+
746798
def _run(self, cmd: Sequence[str]) -> None:
747799
"""Invoke a subprocess."""
748800
print('{cyan}{bold}+ {}{reset}'.format(' '.join(cmd), **_STYLES))
@@ -786,6 +838,7 @@ def _wheel_builder(self) -> _WheelBuilder:
786838
self._source_dir,
787839
self._build_dir,
788840
self._install_plan,
841+
self._build_time_pins,
789842
)
790843

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

pyproject.toml

Lines changed: 4 additions & 0 deletions
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"',
Lines changed: 5 additions & 0 deletions
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')
Lines changed: 27 additions & 0 deletions
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

Lines changed: 13 additions & 0 deletions
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

Lines changed: 1 addition & 1 deletion
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(), {}, [])
6060

6161

6262
def test_tag_empty_wheel(monkeypatch):

tests/test_wheel.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,14 @@
1111
import sysconfig
1212
import textwrap
1313

14+
15+
if sys.version_info < (3, 8):
16+
import importlib_metadata
17+
else:
18+
import importlib.metadata as importlib_metadata
19+
1420
import packaging.tags
21+
import packaging.version
1522
import pytest
1623
import wheel.wheelfile
1724

@@ -240,3 +247,20 @@ def test_top_level_modules(package_module_types):
240247
'namespace',
241248
'native',
242249
}
250+
251+
252+
def test_build_time_pins(wheel_dynamic_dependencies):
253+
artifact = wheel.wheelfile.WheelFile(wheel_dynamic_dependencies)
254+
255+
meson_version = packaging.version.parse(importlib_metadata.version('meson'))
256+
packaging_version = packaging.version.parse(importlib_metadata.version('packaging'))
257+
258+
with artifact.open('dynamic_dependencies-1.0.0.dist-info/METADATA') as f:
259+
assert f.read().decode() == textwrap.dedent(f'''\
260+
Metadata-Version: 2.1
261+
Name: dynamic-dependencies
262+
Version: 1.0.0
263+
Requires-Dist: meson>=0.63.0,>={meson_version}
264+
Requires-Dist: meson-python>=0.13.0
265+
Requires-Dist: packaging~={packaging_version.major}.{packaging_version.minor}
266+
''')

0 commit comments

Comments
 (0)