Repository: mesonbuild/meson-python
Issue category: Feature Request

Issue:
Allow `license` to be listed in the dynamic section
Meson already has `project(license:)`. Would it be possible to autodetect it dynamically? For instace, I have `'Apache-2.0 OR MIT'` as the value in `project()`. I would like this to populate the `license = { text = "XXX" }` metadata like this project already does with `version`.


Hints:
Thanks for the suggestion! However, I think the semantics are a little bit different, `XXX` in your example would be the actual license text, so "Apache-2.0 OR MIT" wouldn't be the correct value. There's no way to specify that currently, but hopefully there will be with [PEP 639](https://peps.python.org/pep-0639/), which adds a `License-Expression` core metadata field and assigns the value of `project.license` in pyproject.toml to the expression. So, this is another issue where we are blocked until that PEP, or an alternative, lands.
It's unfortunate this wasn't thought of earlier when the pyproject.toml was designed. Seems so fundamental, but hopefully the PEP is accepted.

For now, I'll just leave it as is since I see no better way to express dual licensed software.
My recommendation for that case would be to have a single license file with both licenses, and specifying it in `pyproject.tml`. But I agree, it's unfortunate, hopefully the PEP will land though and fix this.
Unfortunately that wouldn't be REUSE compliant from what I understand.

https://reuse.software/
I didn't know about REUSE, thank you for pointing it out. I've only skimmed it a bit, but I can't tell how it wouldn't be compliant? You can still have the SPDX headers, you'd just have an extra license file for the Python tooling to read. In fact, using the REUSE spec you should even be able to automatically generate this file! Am I missing something here? :rofl: 
Hmm `touch LICENSE` in the root doesn't seem to cause the tool to fail. Oh well. Just throw REUSE on the pile of reasons that PEP should be advanced.
Agreed. I have pinged the discourse thread. Let's see if we can get it to move forward.

(cc @CAM-Gerlach)
Thanks for your efforts.
Sorry, finishing what is hopefully the last major update got stuck beyond a pile of other things that came up over the past few months, but I've now got most of the next rewrite complete and it's near the top of my priority list to finish, hopefully in the next few days :crossed_fingers: I can also mention REUSE compliance and this issue in the rationale and on the thread.
@CAM-Gerlach no problem whatsoever. At least someone thought about it in the first place!
Thanks, but to be fair that person with the original idea wasn't me—credit for that is due to @pombredanne
Could one of y'all link to the discourse thread where I presume discussion of the referenced PEP is? I am not familiar with the Python PEP process. 
It's here :blush: 

https://discuss.python.org/t/pep-639-round-2-improving-license-clarity-with-better-package-metadata
Meson 1.1 now has [`license_files`](https://mesonbuild.com/Reference-manual_functions.html#project_license_files), which it would be nice to automatically distribute as a set of license files (especially if you have a dependency subproject that may be bundled in a wheel, but not if built in, e.g.,  a Linux distro).
Thanks @QuLogic. That does look potentially useful, especially for the subproject case you mention, since that cannot be done from `pyproject.toml`. I think we're still waiting for PEP 639 to land first though? Not sure what we'd do with `license_files` right now.
You can install them to *.dist-info/licenses, regardless of the availability/usefulness for `Metadata-Version: 2.1`
PEP639 is now provisional, i.e., waiting for two build backends to implement it. meson-python could be one of those two.

Patch:
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"',
 ]
 


Test patch:
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/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,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,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)