diff --git a/mesonpy/__init__.py b/mesonpy/__init__.py index 4b41afc59..adcc04577 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 @@ -57,9 +64,7 @@ if typing.TYPE_CHECKING: # pragma: no cover - from typing import ( - Any, Callable, ClassVar, DefaultDict, List, Literal, Optional, Sequence, TextIO, Tuple, Type, TypeVar, Union - ) + from typing import Any, Callable, DefaultDict, List, Literal, Optional, Sequence, TextIO, Tuple, Type, TypeVar, Union from mesonpy._compat import Iterator, ParamSpec, Path @@ -132,6 +137,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-_.]+)') + # Maps wheel installation paths to Meson installation path placeholders. # See https://docs.python.org/3/library/sysconfig.html#installation-paths @@ -219,17 +226,16 @@ class _WheelBuilder(): def __init__( self, project: Project, - metadata: Optional[pyproject_metadata.StandardMetadata], source_dir: pathlib.Path, build_dir: pathlib.Path, sources: Dict[str, Dict[str, Any]], + build_time_pins_templates: List[str], ) -> None: self._project = project - self._metadata = metadata self._source_dir = source_dir self._build_dir = build_dir self._sources = sources - + self._build_time_pins = build_time_pins_templates self._libs_build_dir = self._build_dir / 'mesonpy-wheel-libs' @cached_property @@ -316,13 +322,13 @@ def wheel(self) -> bytes: @property def entrypoints_txt(self) -> bytes: """dist-info entry_points.txt.""" - if not self._metadata: + if not self._project.metadata: return b'' - data = self._metadata.entrypoints.copy() + data = self._project.metadata.entrypoints.copy() data.update({ - 'console_scripts': self._metadata.scripts, - 'gui_scripts': self._metadata.gui_scripts, + 'console_scripts': self._project.metadata.scripts, + 'gui_scripts': self._project.metadata.gui_scripts, }) text = '' @@ -474,8 +480,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', self._project.metadata) + 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) @@ -575,7 +585,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', {}) @@ -620,15 +632,60 @@ def _string_or_strings(value: Any, name: str) -> List[str]: return config -class Project(): - """Meson project wrapper to generate Python artifacts.""" +def _validate_metadata(metadata: pyproject_metadata.StandardMetadata) -> None: + """Validate package metadata.""" - _ALLOWED_DYNAMIC_FIELDS: ClassVar[List[str]] = [ + allowed_dynamic_fields = [ + 'dependencies', 'version', ] - _metadata: pyproject_metadata.StandardMetadata - def __init__( + # check for unsupported dynamic fields + unsupported_dynamic = {key for key in metadata.dynamic if key not in allowed_dynamic_fields} + if unsupported_dynamic: + s = ', '.join(f'"{x}"' for x in unsupported_dynamic) + raise ConfigError(f'unsupported dynamic metadata fields: {s}') + + # check if we are running on an unsupported interpreter + if metadata.requires_python: + metadata.requires_python.prereleases = True + if platform.python_version().rstrip('+') not in metadata.requires_python: + 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 + if version.is_devrelease or version.is_prerelease: + print('meson-python: build-time pin for pre-release version "{version}" of "{name}" not generared: {template!r}') + continue + 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 extras: {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__( # noqa: C901 self, source_dir: Path, working_dir: Path, @@ -645,6 +702,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 = [] _check_meson_version() @@ -725,12 +783,19 @@ def __init__( '{yellow}{bold}! Using Meson to generate the project metadata ' '(no `project` section in pyproject.toml){reset}'.format(**_STYLES) ) - self._validate_metadata() + _validate_metadata(self._metadata) # set version from meson.build if dynamic 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)) @@ -767,35 +832,14 @@ def _configure(self, reconfigure: bool = False) -> None: self._run(['meson', 'setup', *setup_args]) - def _validate_metadata(self) -> None: - """Check the pyproject.toml metadata and see if there are any issues.""" - - # check for unsupported dynamic fields - unsupported_dynamic = { - key for key in self._metadata.dynamic - if key not in self._ALLOWED_DYNAMIC_FIELDS - } - if unsupported_dynamic: - s = ', '.join(f'"{x}"' for x in unsupported_dynamic) - raise MesonBuilderError(f'Unsupported dynamic fields: {s}') - - # check if we are running on an unsupported interpreter - if self._metadata.requires_python: - self._metadata.requires_python.prereleases = True - if platform.python_version().rstrip('+') not in self._metadata.requires_python: - raise MesonBuilderError( - f'Unsupported Python version {platform.python_version()}, ' - f'expected {self._metadata.requires_python}' - ) - @cached_property def _wheel_builder(self) -> _WheelBuilder: return _WheelBuilder( self, - self._metadata, self._source_dir, self._build_dir, self._install_plan, + self._build_time_pins, ) def build_commands(self, install_dir: Optional[pathlib.Path] = None) -> Sequence[Sequence[str]]: @@ -887,10 +931,10 @@ def version(self) -> str: """Project version.""" return str(self._metadata.version) - @cached_property - def metadata(self) -> bytes: - """Project metadata as an RFC822 message.""" - return bytes(self._metadata.as_rfc822()) + @property + def metadata(self) -> pyproject_metadata.StandardMetadata: + """Project metadata.""" + return self._metadata @property def license_file(self) -> Optional[pathlib.Path]: @@ -960,8 +1004,9 @@ def sdist(self, directory: Path) -> pathlib.Path: pkginfo_info = tarfile.TarInfo(f'{dist_name}/PKG-INFO') if mtime: pkginfo_info.mtime = mtime - pkginfo_info.size = len(self.metadata) - tar.addfile(pkginfo_info, fileobj=io.BytesIO(self.metadata)) + metadata = bytes(self._metadata.as_rfc822()) + pkginfo_info.size = len(metadata) + tar.addfile(pkginfo_info, fileobj=io.BytesIO(metadata)) return sdist 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/packages/unsupported-dynamic/pyproject.toml b/tests/packages/unsupported-dynamic/pyproject.toml index ee63c29b9..2886363e7 100644 --- a/tests/packages/unsupported-dynamic/pyproject.toml +++ b/tests/packages/unsupported-dynamic/pyproject.toml @@ -10,5 +10,5 @@ requires = ['meson-python'] name = 'unsupported-dynamic' version = '1.0.0' dynamic = [ - 'dependencies', + 'requires-python', ] 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_project.py b/tests/test_project.py index 4c68a50c0..b826e4f72 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -45,14 +45,14 @@ def test_version(package): def test_unsupported_dynamic(package_unsupported_dynamic): - with pytest.raises(mesonpy.MesonBuilderError, match='Unsupported dynamic fields: "dependencies"'): + with pytest.raises(mesonpy.ConfigError, match='unsupported dynamic metadata fields: "requires-python"'): with mesonpy.Project.with_temp_working_dir(): pass def test_unsupported_python_version(package_unsupported_python_version): - with pytest.raises(mesonpy.MesonBuilderError, match=( - f'Unsupported Python version {platform.python_version()}, expected ==1.0.0' + with pytest.raises(mesonpy.ConfigError, match=( + f'building with Python {platform.python_version()}, version ==1.0.0 required' )): with mesonpy.Project.with_temp_working_dir(): pass diff --git a/tests/test_tags.py b/tests/test_tags.py index 5779c098b..78f77d38c 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, None, pathlib.Path(), pathlib.Path(), pathlib.Path()) + return mesonpy._WheelBuilder(None, pathlib.Path(), pathlib.Path(), {}, []) def test_tag_empty_wheel(monkeypatch): diff --git a/tests/test_wheel.py b/tests/test_wheel.py index da10d760f..b7b0e62c5 100644 --- a/tests/test_wheel.py +++ b/tests/test_wheel.py @@ -11,7 +11,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 @@ -240,3 +247,46 @@ def test_top_level_modules(package_module_types): 'namespace', 'native', } + + +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} + ''') + + +def test_compute_build_time_dependencies(monkeypatch): + versions = { + 'aaa': '1.2.3', + 'bbb': '4.5.6', + 'ddd': '1.0.0rc1', # pre-release will not be added to build-time dependencies + } + monkeypatch.setattr(importlib_metadata, 'version', lambda package: versions.get(package)) + deps = [ + 'bbb>=0.1', + 'ccc>=0.2', + 'ddd>=0.3', + ] + pins = [ + 'aaa>={v}', + 'bbb~={v.major}.{v.minor}', + 'ddd=={v}', + ] + r = mesonpy._compute_build_time_dependencies([packaging.requirements.Requirement(x) for x in deps], pins) + assert sorted(str(x) for x in r) == [ + 'aaa>=1.2.3', + 'bbb>=0.1,~=4.5', + 'ccc>=0.2', + 'ddd>=0.3', + ]