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 PEP 639 #681

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
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
4 changes: 0 additions & 4 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,6 @@ jobs:
- os: windows-latest
python: '3.12'
meson: '@git+https://github.com/mesonbuild/meson.git'
# Test with oldest supported pyproject-metadata
- os: ubuntu-latest
python: '3.12'
pyproject_metadata: '==0.8.0'

steps:
- name: Checkout
Expand Down
8 changes: 8 additions & 0 deletions docs/reference/meson-compatibility.rst
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ versions.
Meson 1.3.0 or later is required for compiling extension modules
targeting the Python limited API.

.. option:: 1.6.0

Meson 1.6.0 or later is required to support ``license`` and
dnicolodi marked this conversation as resolved.
Show resolved Hide resolved
``license-files`` dynamic fields in ``pyproject.toml`` and to
populate the package license and license files from the ones
declared via the ``project()`` call in ``meson.build``. This also
requires ``pyproject-metadata`` version 0.9.0 or later.

Build front-ends by default build packages in an isolated Python
environment where build dependencies are installed. Most often, unless
a package or its build dependencies declare explicitly a version
Expand Down
117 changes: 92 additions & 25 deletions mesonpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,20 @@
from mesonpy._compat import cached_property, read_binary


try:
from packaging.licenses import InvalidLicenseExpression, canonicalize_license_expression
except ImportError:
# PEP-639 support requires packaging >= 24.2.
def canonicalize_license_expression(s: str) -> str: # type: ignore[misc]
warnings.warn(
'canonicalization and validation of license expression in "project.license" '
'as defined by PEP-639 requires packaging version 24.2 or later.', stacklevel=2)
return s

class InvalidLicenseExpression(Exception): # type: ignore[no-redef]
pass


if typing.TYPE_CHECKING: # pragma: no cover
from typing import Any, Callable, DefaultDict, Dict, List, Literal, Optional, Sequence, TextIO, Tuple, Type, TypeVar, Union

Expand All @@ -65,6 +79,9 @@
MesonArgs = Mapping[MesonArgsKeys, List[str]]


_PYPROJECT_METADATA_VERSION = tuple(map(int, pyproject_metadata.__version__.split('.')[:2]))
_SUPPORTED_DYNAMIC_FIELDS = {'version', } if _PYPROJECT_METADATA_VERSION < (0, 9) else {'version', 'license', 'license-files'}

_NINJA_REQUIRED_VERSION = '1.8.2'
_MESON_REQUIRED_VERSION = '0.63.3' # keep in sync with the version requirement in pyproject.toml

Expand Down Expand Up @@ -246,11 +263,15 @@ def from_pyproject( # type: ignore[override]
metadata = super().from_pyproject(data, project_dir, metadata_version)

# Check for unsupported dynamic fields.
unsupported_dynamic = set(metadata.dynamic) - {'version', }
unsupported_dynamic = set(metadata.dynamic) - _SUPPORTED_DYNAMIC_FIELDS # type: ignore[operator]
if unsupported_dynamic:
fields = ', '.join(f'"{x}"' for x in unsupported_dynamic)
raise pyproject_metadata.ConfigurationError(f'Unsupported dynamic fields: {fields}')

# Validate license field to be a valid SDPX license expression.
if isinstance(metadata.license, str):
metadata.license = canonicalize_license_expression(metadata.license)

return metadata

@property
Expand Down Expand Up @@ -339,13 +360,6 @@ def _data_dir(self) -> str:
def _libs_dir(self) -> str:
return f'.{self._metadata.distribution_name}.mesonpy.libs'

@property
def _license_file(self) -> Optional[pathlib.Path]:
license_ = self._metadata.license
if license_ and isinstance(license_, pyproject_metadata.License):
return license_.file
return None

@property
def wheel(self) -> bytes:
"""Return WHEEL file for dist-info."""
Expand Down Expand Up @@ -428,9 +442,17 @@ def _wheel_write_metadata(self, whl: mesonpy._wheelfile.WheelFile) -> None:
if self.entrypoints_txt:
whl.writestr(f'{self._distinfo_dir}/entry_points.txt', self.entrypoints_txt)

# add license (see https://github.com/mesonbuild/meson-python/issues/88)
if self._license_file:
whl.write(self._license_file, f'{self._distinfo_dir}/{os.path.basename(self._license_file)}')
# Add pre-PEP-639 license files.
if isinstance(self._metadata.license, pyproject_metadata.License):
license_file = self._metadata.license.file
if license_file:
whl.write(license_file, f'{self._distinfo_dir}/{os.path.basename(license_file)}')

# Add PEP-639 license-files. Use ``getattr()`` for compatibility with pyproject-metadata < 0.9.0.
license_files = getattr(self._metadata, 'license_files', None)
if license_files:
for f in license_files:
whl.write(f, f'{self._distinfo_dir}/licenses/{pathlib.Path(f).as_posix()}')

def build(self, directory: Path) -> pathlib.Path:
wheel_file = pathlib.Path(directory, f'{self.name}.whl')
Expand Down Expand Up @@ -708,17 +730,34 @@ def __init__(
# set version from meson.build if version is declared as dynamic
if 'version' in self._metadata.dynamic:
version = self._meson_version
if version == 'undefined':
if version is None:
raise pyproject_metadata.ConfigurationError(
'Field "version" declared as dynamic but version is not defined in meson.build')
self._metadata.version = packaging.version.Version(version)
if 'license' in self._metadata.dynamic:
license = self._meson_license
if license is None:
raise pyproject_metadata.ConfigurationError(
'Field "license" declared as dynamic but license is not specified in meson.build')
# mypy is not happy when analyzing typing based on
# pyproject-metadata < 0.9 where license needs to be of
# License type. However, this code is not executed if
# pyproject-metadata is older than 0.9 because then dynamic
# license is not allowed.
self._metadata.license = license # type: ignore[assignment, unused-ignore]
if 'license-files' in self._metadata.dynamic:
self._metadata.license_files = self._meson_license_files
else:
# if project section is missing, use minimal metdata from meson.build
name, version = self._meson_name, self._meson_version
if version == 'undefined':
if not version:
raise pyproject_metadata.ConfigurationError(
'Section "project" missing in pyproject.toml and version is not defined in meson.build')
self._metadata = Metadata(name=name, version=packaging.version.Version(version))
kwargs = {
'license': self._meson_license,
'license_files': self._meson_license_files
} if _PYPROJECT_METADATA_VERSION >= (0, 9) else {}
self._metadata = Metadata(name=name, version=packaging.version.Version(version), **kwargs)

# verify that we are running on a supported interpreter
if self._metadata.requires_python:
Expand Down Expand Up @@ -829,25 +868,53 @@ def _manifest(self) -> DefaultDict[str, List[Tuple[pathlib.Path, str]]]:

@property
def _meson_name(self) -> str:
"""Name in meson.build."""
name = self._info('intro-projectinfo')['descriptive_name']
assert isinstance(name, str)
return name
"""The project name specified with ``project()`` in meson.build."""
value = self._info('intro-projectinfo')['descriptive_name']
assert isinstance(value, str)
return value

@property
def _meson_version(self) -> Optional[str]:
"""The version specified with the ``version`` argument to ``project()`` in meson.build."""
value = self._info('intro-projectinfo')['version']
assert isinstance(value, str)
if value == 'undefined':
return None
return value

@property
def _meson_license(self) -> Optional[str]:
"""The license specified with the ``license`` argument to ``project()`` in meson.build."""
value = self._info('intro-projectinfo').get('license', None)
if value is None:
return None
assert isinstance(value, list)
if len(value) > 1:
raise pyproject_metadata.ConfigurationError(
'Using a list of strings for the license declared in meson.build is ambiguous: use a SPDX license expression')
value = value[0]
assert isinstance(value, str)
if value == 'unknown':
return None
return str(canonicalize_license_expression(value)) # str() is to make mypy happy

@property
def _meson_version(self) -> str:
"""Version in meson.build."""
name = self._info('intro-projectinfo')['version']
assert isinstance(name, str)
return name
def _meson_license_files(self) -> Optional[List[pathlib.Path]]:
"""The license files specified with the ``license_files`` argument to ``project()`` in meson.build."""
value = self._info('intro-projectinfo').get('license_files', None)
if not value:
return None
assert isinstance(value, list)
return [pathlib.Path(x) for x in value]

def sdist(self, directory: Path) -> pathlib.Path:
"""Generates a sdist (source distribution) in the specified directory."""
# Generate meson dist file.
self._run(self._meson + ['dist', '--allow-dirty', '--no-tests', '--formats', 'gztar', *self._meson_args['dist']])

dist_name = f'{self._metadata.distribution_name}-{self._metadata.version}'
meson_dist_name = f'{self._meson_name}-{self._meson_version}'
meson_version = self._meson_version or 'undefined'
meson_dist_name = f'{self._meson_name}-{meson_version}'
meson_dist_path = pathlib.Path(self._build_dir, 'meson-dist', f'{meson_dist_name}.tar.gz')
sdist_path = pathlib.Path(directory, f'{dist_name}.tar.gz')
pyproject_toml_mtime = 0
Expand Down Expand Up @@ -1023,7 +1090,7 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
warnings.showwarning = _showwarning
try:
return func(*args, **kwargs)
except (Error, pyproject_metadata.ConfigurationError) as exc:
except (Error, InvalidLicenseExpression, pyproject_metadata.ConfigurationError) as exc:
prefix = f'{style.ERROR}meson-python: error:{style.RESET} '
_log('\n' + textwrap.indent(str(exc), prefix))
raise SystemExit(1) from exc
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ requires = [
'meson >= 0.64.0; python_version < "3.12"',
'meson >= 1.2.3; python_version >= "3.12"',
'packaging >= 19.0',
'pyproject-metadata >= 0.8.0',
'pyproject-metadata >= 0.9.0',
'tomli >= 1.0.0; python_version < "3.11"',
]

Expand Down Expand Up @@ -37,7 +37,7 @@ dependencies = [
'meson >= 0.64.0; python_version < "3.12"',
'meson >= 1.2.3; python_version >= "3.12"',
'packaging >= 19.0',
'pyproject-metadata >= 0.8.0',
'pyproject-metadata >= 0.9.0',
'tomli >= 1.0.0; python_version < "3.11"',
]

Expand Down
15 changes: 15 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,23 @@
from mesonpy._util import chdir


_meson_ver_str = subprocess.run(['meson', '--version'], check=True, stdout=subprocess.PIPE, text=True).stdout
MESON_VERSION = tuple(map(int, _meson_ver_str.split('.')[:3]))


def metadata(data):
meta, other = packaging.metadata.parse_email(data)
# PEP-639 support requires packaging >= 24.1. Add minimal
# handling of PEP-639 fields here to allow testing with older
# packaging releases.
value = other.pop('license-expression', None)
if value is not None:
# The ``License-Expression`` header should appear only once.
assert len(value) == 1
meta['license-expression'] = value[0]
value = other.pop('license-file', None)
if value is not None:
meta['license-file'] = value
assert not other
return meta

Expand Down
1 change: 1 addition & 0 deletions tests/packages/license-pep639/LICENSES/BSD-3-Clause.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Placeholder, just for testing.
1 change: 1 addition & 0 deletions tests/packages/license-pep639/LICENSES/MIT.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Placeholder, just for testing.
5 changes: 5 additions & 0 deletions tests/packages/license-pep639/meson.build
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# SPDX-FileCopyrightText: 2022 The meson-python developers
#
# SPDX-License-Identifier: MIT

project('license-pep639', version: '1.0.0')
13 changes: 13 additions & 0 deletions tests/packages/license-pep639/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# SPDX-FileCopyrightText: 2022 The meson-python developers
#
# SPDX-License-Identifier: MIT

[build-system]
build-backend = 'mesonpy'
requires = ['meson-python']

[project]
name = 'license-pep639'
version = '1.0.0'
license = 'MIT OR BSD-3-Clause'
license-files = ['LICENSES/*']
5 changes: 0 additions & 5 deletions tests/packages/missing-version/meson.build

This file was deleted.

10 changes: 0 additions & 10 deletions tests/packages/missing-version/pyproject.toml

This file was deleted.

30 changes: 29 additions & 1 deletion tests/test_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@
from mesonpy import Metadata


try:
import packaging.licenses as packaging_licenses
except ImportError:
packaging_licenses = None


def test_package_name():
name = 'package.Test'
metadata = Metadata(name='package.Test', version=packaging.version.Version('0.0.1'))
Expand Down Expand Up @@ -45,7 +51,7 @@ def test_unsupported_dynamic():
Metadata.from_pyproject(pyproject, pathlib.Path())


def test_missing_version(package_missing_version):
def test_missing_version():
pyproject = {'project': {
'name': 'missing-version',
}}
Expand All @@ -57,3 +63,25 @@ def test_missing_version(package_missing_version):
))
with pytest.raises(pyproject_metadata.ConfigurationError, match=match):
Metadata.from_pyproject(pyproject, pathlib.Path())


@pytest.mark.skipif(packaging_licenses is None, reason='packaging too old')
def test_normalize_license():
pyproject = {'project': {
'name': 'test',
'version': '1.2.3',
'license': 'mit or bsd-3-clause',
}}
metadata = Metadata.from_pyproject(pyproject, pathlib.Path())
assert metadata.license == 'MIT OR BSD-3-Clause'


@pytest.mark.skipif(packaging_licenses is None, reason='packaging too old')
def test_invalid_license():
pyproject = {'project': {
'name': 'test',
'version': '1.2.3',
'license': 'Foo',
}}
with pytest.raises(packaging_licenses.InvalidLicenseExpression, match='Unknown license: \'foo\''):
Metadata.from_pyproject(pyproject, pathlib.Path())
Loading
Loading