Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH: add support for dynamic dependencies computation #319

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 91 additions & 46 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 All @@ -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

Expand Down Expand Up @@ -132,6 +137,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-_.]+)')


# Maps wheel installation paths to Meson installation path placeholders.
# See https://docs.python.org/3/library/sysconfig.html#installation-paths
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = ''
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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', {})
Expand Down Expand Up @@ -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,
Expand All @@ -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()

Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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]]:
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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

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}',
]
2 changes: 1 addition & 1 deletion tests/packages/unsupported-dynamic/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ requires = ['meson-python']
name = 'unsupported-dynamic'
version = '1.0.0'
dynamic = [
'dependencies',
'requires-python',
]
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
''')
6 changes: 3 additions & 3 deletions tests/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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, None, pathlib.Path(), pathlib.Path(), pathlib.Path())
return mesonpy._WheelBuilder(None, pathlib.Path(), pathlib.Path(), {}, [])


def test_tag_empty_wheel(monkeypatch):
Expand Down
Loading