diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0bfca7e2d..cc48c21c0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 diff --git a/docs/reference/meson-compatibility.rst b/docs/reference/meson-compatibility.rst index 743dde4a2..6b339d60e 100644 --- a/docs/reference/meson-compatibility.rst +++ b/docs/reference/meson-compatibility.rst @@ -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 + ``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 diff --git a/mesonpy/__init__.py b/mesonpy/__init__.py index 6f982c71d..103a87408 100644 --- a/mesonpy/__init__.py +++ b/mesonpy/__init__.py @@ -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 @@ -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 @@ -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 @@ -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.""" @@ -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') @@ -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: @@ -829,17 +868,44 @@ 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.""" @@ -847,7 +913,8 @@ def sdist(self, directory: Path) -> pathlib.Path: 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 @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 2247a46df..a64a26d1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"', ] @@ -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"', ] diff --git a/tests/conftest.py b/tests/conftest.py index 1280a5d32..b53161d61 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,8 +26,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 diff --git a/tests/packages/license-pep639/LICENSES/BSD-3-Clause.txt b/tests/packages/license-pep639/LICENSES/BSD-3-Clause.txt new file mode 100644 index 000000000..ce9b60c27 --- /dev/null +++ b/tests/packages/license-pep639/LICENSES/BSD-3-Clause.txt @@ -0,0 +1 @@ +Placeholder, just for testing. diff --git a/tests/packages/license-pep639/LICENSES/MIT.txt b/tests/packages/license-pep639/LICENSES/MIT.txt new file mode 100644 index 000000000..ce9b60c27 --- /dev/null +++ b/tests/packages/license-pep639/LICENSES/MIT.txt @@ -0,0 +1 @@ +Placeholder, just for testing. diff --git a/tests/packages/license-pep639/meson.build b/tests/packages/license-pep639/meson.build new file mode 100644 index 000000000..495a55636 --- /dev/null +++ b/tests/packages/license-pep639/meson.build @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: 2022 The meson-python developers +# +# SPDX-License-Identifier: MIT + +project('license-pep639', version: '1.0.0') diff --git a/tests/packages/license-pep639/pyproject.toml b/tests/packages/license-pep639/pyproject.toml new file mode 100644 index 000000000..97416f3a8 --- /dev/null +++ b/tests/packages/license-pep639/pyproject.toml @@ -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/*'] diff --git a/tests/packages/missing-version/meson.build b/tests/packages/missing-version/meson.build deleted file mode 100644 index 4ccf75bce..000000000 --- a/tests/packages/missing-version/meson.build +++ /dev/null @@ -1,5 +0,0 @@ -# SPDX-FileCopyrightText: 2023 The meson-python developers -# -# SPDX-License-Identifier: MIT - -project('missing-version', version: '1.0.0') diff --git a/tests/packages/missing-version/pyproject.toml b/tests/packages/missing-version/pyproject.toml deleted file mode 100644 index 6538dd09e..000000000 --- a/tests/packages/missing-version/pyproject.toml +++ /dev/null @@ -1,10 +0,0 @@ -# SPDX-FileCopyrightText: 2023 The meson-python developers -# -# SPDX-License-Identifier: MIT - -[build-system] -build-backend = 'mesonpy' -requires = ['meson-python'] - -[project] -name = 'missing-version' diff --git a/tests/test_metadata.py b/tests/test_metadata.py index d219b90ce..6609cd8fd 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -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')) @@ -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', }} @@ -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()) diff --git a/tests/test_project.py b/tests/test_project.py index d2a4e5e47..693fec6aa 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -19,7 +19,7 @@ import mesonpy -from .conftest import in_git_repo_context, package_dir +from .conftest import MESON_VERSION, in_git_repo_context, metadata, package_dir def test_unsupported_python_version(package_unsupported_python_version): @@ -40,6 +40,135 @@ def test_missing_dynamic_version(package_missing_dynamic_version): pass +@pytest.mark.skipif(MESON_VERSION < (1, 6, 0), reason='meson too old') +@pytest.mark.filterwarnings('ignore:canonicalization and validation of license expression') +def test_meson_build_metadata(tmp_path): + tmp_path.joinpath('pyproject.toml').write_text(textwrap.dedent(''' + [build-system] + build-backend = 'mesonpy' + requires = ['meson-python'] + '''), encoding='utf8') + + tmp_path.joinpath('meson.build').write_text(textwrap.dedent(''' + project('test', version: '1.2.3', license: 'MIT', license_files: 'LICENSE') + '''), encoding='utf8') + + tmp_path.joinpath('LICENSE').write_text('') + + p = mesonpy.Project(tmp_path, tmp_path / 'build') + + assert metadata(bytes(p._metadata.as_rfc822())) == metadata(textwrap.dedent('''\ + Metadata-Version: 2.4 + Name: test + Version: 1.2.3 + License-Expression: MIT + License-File: LICENSE + ''')) + + +@pytest.mark.skipif(MESON_VERSION < (1, 6, 0), reason='meson too old') +@pytest.mark.filterwarnings('ignore:canonicalization and validation of license expression') +def test_dynamic_license(tmp_path): + tmp_path.joinpath('pyproject.toml').write_text(textwrap.dedent(''' + [build-system] + build-backend = 'mesonpy' + requires = ['meson-python'] + + [project] + name = 'test' + version = '1.0.0' + dynamic = ['license'] + '''), encoding='utf8') + + tmp_path.joinpath('meson.build').write_text(textwrap.dedent(''' + project('test', license: 'MIT') + '''), encoding='utf8') + + p = mesonpy.Project(tmp_path, tmp_path / 'build') + + assert metadata(bytes(p._metadata.as_rfc822())) == metadata(textwrap.dedent('''\ + Metadata-Version: 2.4 + Name: test + Version: 1.0.0 + License-Expression: MIT + ''')) + + +@pytest.mark.skipif(MESON_VERSION < (1, 6, 0), reason='meson too old') +@pytest.mark.filterwarnings('ignore:canonicalization and validation of license expression') +def test_dynamic_license_list(tmp_path): + tmp_path.joinpath('pyproject.toml').write_text(textwrap.dedent(''' + [build-system] + build-backend = 'mesonpy' + requires = ['meson-python'] + + [project] + name = 'test' + version = '1.0.0' + dynamic = ['license'] + '''), encoding='utf8') + + tmp_path.joinpath('meson.build').write_text(textwrap.dedent(''' + project('test', license: ['MIT', 'BSD-3-Clause']) + '''), encoding='utf8') + + with pytest.raises(pyproject_metadata.ConfigurationError, match='Using a list of strings for the license'): + mesonpy.Project(tmp_path, tmp_path / 'build') + + +@pytest.mark.skipif(MESON_VERSION < (1, 6, 0), reason='meson too old') +@pytest.mark.filterwarnings('ignore:canonicalization and validation of license expression') +def test_dynamic_license_missing(tmp_path): + tmp_path.joinpath('pyproject.toml').write_text(textwrap.dedent(''' + [build-system] + build-backend = 'mesonpy' + requires = ['meson-python'] + + [project] + name = 'test' + version = '1.0.0' + dynamic = ['license'] + '''), encoding='utf8') + + tmp_path.joinpath('meson.build').write_text(textwrap.dedent(''' + project('test') + '''), encoding='utf8') + + with pytest.raises(pyproject_metadata.ConfigurationError, match='Field "license" declared as dynamic but'): + mesonpy.Project(tmp_path, tmp_path / 'build') + + +@pytest.mark.skipif(MESON_VERSION < (1, 6, 0), reason='meson too old') +@pytest.mark.filterwarnings('ignore:canonicalization and validation of license expression') +def test_dynamic_license_files(tmp_path): + tmp_path.joinpath('pyproject.toml').write_text(textwrap.dedent(''' + [build-system] + build-backend = 'mesonpy' + requires = ['meson-python'] + + [project] + name = 'test' + version = '1.0.0' + dynamic = ['license', 'license-files'] + '''), encoding='utf8') + + tmp_path.joinpath('meson.build').write_text(textwrap.dedent(''' + project('test', license: 'MIT', license_files: ['LICENSE']) + '''), encoding='utf8') + + tmp_path.joinpath('LICENSE').write_text('') + + p = mesonpy.Project(tmp_path, tmp_path / 'build') + + assert metadata(bytes(p._metadata.as_rfc822())) == metadata(textwrap.dedent('''\ + Metadata-Version: 2.4 + Name: test + Version: 1.0.0 + License-Expression: MIT + License-File: LICENSE + ''')) + + def test_user_args(package_user_args, tmp_path, monkeypatch): project_run = mesonpy.Project._run cmds = [] diff --git a/tests/test_sdist.py b/tests/test_sdist.py index ac56bcf06..a98b27d10 100644 --- a/tests/test_sdist.py +++ b/tests/test_sdist.py @@ -16,7 +16,7 @@ from .conftest import in_git_repo_context, metadata -def test_no_pep621(sdist_library): +def test_meson_build_metadata(sdist_library): with tarfile.open(sdist_library, 'r:gz') as sdist: sdist_pkg_info = sdist.extractfile('library-1.0.0/PKG-INFO').read() @@ -27,20 +27,14 @@ def test_no_pep621(sdist_library): ''')) -def test_pep621(sdist_full_metadata): +def test_pep621_metadata(sdist_full_metadata): with tarfile.open(sdist_full_metadata, 'r:gz') as sdist: sdist_pkg_info = sdist.extractfile('full_metadata-1.2.3/PKG-INFO').read() meta = metadata(sdist_pkg_info) - - # pyproject-metadata prior to 0.9.0 strips trailing newlines + # Including the trailing newline in the expected value is inconvenient. meta['license'] = meta['license'].rstrip() - # pyproject-metadata 0.9.0 and later does not emit Home-Page - meta.pop('home_page', None) - # nor normalizes Project-URL keys - meta['project_urls'] = {k.lower(): v for k, v in meta['project_urls'].items()} - assert meta == metadata(textwrap.dedent('''\ Metadata-Version: 2.1 Name: full-metadata diff --git a/tests/test_wheel.py b/tests/test_wheel.py index 1b8198235..20ae0ba72 100644 --- a/tests/test_wheel.py +++ b/tests/test_wheel.py @@ -6,7 +6,6 @@ import re import shutil import stat -import subprocess import sys import sysconfig import textwrap @@ -17,12 +16,9 @@ import mesonpy -from .conftest import adjust_packaging_platform_tag +from .conftest import MESON_VERSION, adjust_packaging_platform_tag, metadata -_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])) - EXT_SUFFIX = sysconfig.get_config_var('EXT_SUFFIX') if sys.version_info <= (3, 8, 7): if MESON_VERSION >= (0, 99): @@ -140,6 +136,28 @@ def test_contents_license_file(wheel_license_file): assert artifact.read('license_file-1.0.0.dist-info/LICENSE.custom').rstrip() == b'Hello!' +@pytest.mark.filterwarnings('ignore:canonicalization and validation of license expression') +def test_license_pep639(wheel_license_pep639): + artifact = wheel.wheelfile.WheelFile(wheel_license_pep639) + + assert wheel_contents(artifact) == { + 'license_pep639-1.0.0.dist-info/METADATA', + 'license_pep639-1.0.0.dist-info/RECORD', + 'license_pep639-1.0.0.dist-info/WHEEL', + 'license_pep639-1.0.0.dist-info/licenses/LICENSES/BSD-3-Clause.txt', + 'license_pep639-1.0.0.dist-info/licenses/LICENSES/MIT.txt', + } + + assert metadata(artifact.read('license_pep639-1.0.0.dist-info/METADATA')) == metadata(textwrap.dedent('''\ + Metadata-Version: 2.4 + Name: license-pep639 + Version: 1.0.0 + License-Expression: MIT OR BSD-3-Clause + License-File: LICENSES/BSD-3-Clause.txt + License-File: LICENSES/MIT.txt + ''')) + + @pytest.mark.skipif(sys.platform not in {'linux', 'darwin', 'sunos5'}, reason='Not supported on this platform') def test_contents(package_library, wheel_library): artifact = wheel.wheelfile.WheelFile(wheel_library)