Skip to content

Commit e69f391

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 5cd2f1a commit e69f391

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

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

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

134143
# Maps wheel installation paths to Meson installation path placeholders.
135144
# See https://docs.python.org/3/library/sysconfig.html#installation-paths
@@ -220,12 +229,13 @@ def __init__(
220229
source_dir: pathlib.Path,
221230
build_dir: pathlib.Path,
222231
sources: Dict[str, Dict[str, Any]],
232+
build_time_pins_templates: List[str],
223233
) -> None:
224234
self._project = project
225235
self._source_dir = source_dir
226236
self._build_dir = build_dir
227237
self._sources = sources
228-
238+
self._build_time_pins = build_time_pins_templates
229239
self._libs_build_dir = self._build_dir / 'mesonpy-wheel-libs'
230240

231241
@cached_property
@@ -470,8 +480,12 @@ def _install_path(
470480
wheel_file.write(origin, location)
471481

472482
def _wheel_write_metadata(self, whl: mesonpy._wheelfile.WheelFile) -> None:
483+
# copute dynamic dependencies
484+
metadata = copy.copy(self._project.metadata)
485+
metadata.dependencies = _compute_build_time_dependencies(metadata.dependencies, self._build_time_pins)
486+
473487
# add metadata
474-
whl.writestr(f'{self.distinfo_dir}/METADATA', bytes(self._project.metadata.as_rfc822()))
488+
whl.writestr(f'{self.distinfo_dir}/METADATA', bytes(metadata.as_rfc822()))
475489
whl.writestr(f'{self.distinfo_dir}/WHEEL', self.wheel)
476490
if self.entrypoints_txt:
477491
whl.writestr(f'{self.distinfo_dir}/entry_points.txt', self.entrypoints_txt)
@@ -571,7 +585,9 @@ def _strings(value: Any, name: str) -> List[str]:
571585
scheme = _table({
572586
'args': _table({
573587
name: _strings for name in _MESON_ARGS_KEYS
574-
})
588+
}),
589+
'dependencies': _strings,
590+
'build-time-pins': _strings,
575591
})
576592

577593
table = pyproject.get('tool', {}).get('meson-python', {})
@@ -620,6 +636,7 @@ def _validate_metadata(metadata: pyproject_metadata.StandardMetadata) -> None:
620636
"""Validate package metadata."""
621637

622638
allowed_dynamic_fields = [
639+
'dependencies',
623640
'version',
624641
]
625642

@@ -636,9 +653,36 @@ def _validate_metadata(metadata: pyproject_metadata.StandardMetadata) -> None:
636653
raise ConfigError(f'building with Python {platform.python_version()}, version {metadata.requires_python} required')
637654

638655

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

659704
_check_meson_version()
660705

@@ -741,6 +786,13 @@ def __init__(
741786
if 'version' in self._metadata.dynamic:
742787
self._metadata.version = packaging.version.Version(self._meson_version)
743788

789+
# set base dependencie if dynamic
790+
if 'dependencies' in self._metadata.dynamic:
791+
dependencies = [packaging.requirements.Requirement(d) for d in pyproject_config.get('dependencies', [])]
792+
self._metadata.dependencies = dependencies
793+
self._metadata.dynamic.remove('dependencies')
794+
self._build_time_pins = pyproject_config.get('build-time-pins', [])
795+
744796
def _run(self, cmd: Sequence[str]) -> None:
745797
"""Invoke a subprocess."""
746798
print('{cyan}{bold}+ {}{reset}'.format(' '.join(cmd), **_STYLES))
@@ -784,6 +836,7 @@ def _wheel_builder(self) -> _WheelBuilder:
784836
self._source_dir,
785837
self._build_dir,
786838
self._install_plan,
839+
self._build_time_pins,
787840
)
788841

789842
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)