From cbcbb8781a7764680a1ce363102fa6475a82d91d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 24 Nov 2024 19:04:05 +0100 Subject: [PATCH] Add support for license expressions --- doc/pyproject_toml.rst | 5 +- flit_core/flit_core/common.py | 13 ++++- flit_core/flit_core/config.py | 79 +++++++++++++++++++++-------- flit_core/pyproject.toml | 3 +- flit_core/tests_core/test_common.py | 24 +++++++++ flit_core/tests_core/test_config.py | 31 +++++++++++ 6 files changed, 129 insertions(+), 26 deletions(-) diff --git a/doc/pyproject_toml.rst b/doc/pyproject_toml.rst index 9d4f9da1..1d6b101a 100644 --- a/doc/pyproject_toml.rst +++ b/doc/pyproject_toml.rst @@ -95,8 +95,9 @@ readme requires-python A version specifier for the versions of Python this requires, e.g. ``~=3.3`` or ``>=3.3,<4``, which are equivalents. -license - A table with either a ``file`` key (a relative path to a license file) or a +license # TODO + A valid SPDX `license expression `_ + or a table with either a ``file`` key (a relative path to a license file) or a ``text`` key (the license text). authors A list of tables with ``name`` and ``email`` keys (both optional) describing diff --git a/flit_core/flit_core/common.py b/flit_core/flit_core/common.py index 5385c9c0..a8ef0049 100644 --- a/flit_core/flit_core/common.py +++ b/flit_core/flit_core/common.py @@ -336,6 +336,7 @@ class Metadata(object): maintainer = None maintainer_email = None license = None + license_expression = None description = None keywords = None download_url = None @@ -398,7 +399,6 @@ def write_metadata_file(self, fp): optional_fields = [ 'Summary', 'Home-page', - 'License', 'Keywords', 'Author', 'Author-email', @@ -422,6 +422,17 @@ def write_metadata_file(self, fp): value = '\n '.join(value.splitlines()) fp.write(u"{}: {}\n".format(field, value)) + + license_expr = getattr(self, self._normalise_field_name("License-Expression")) + license = getattr(self, self._normalise_field_name("License")) + if license_expr: + # TODO: License-Expression requires Metadata-Version '2.4' + # Backfill it to the 'License' field for now + # fp.write(u'License-Expression: {}\n'.format(license_expr)) + fp.write(u'License: {}\n'.format(license_expr)) + elif license: + fp.write(u'License: {}\n'.format(license)) + for clsfr in self.classifiers: fp.write(u'Classifier: {}\n'.format(clsfr)) diff --git a/flit_core/flit_core/config.py b/flit_core/flit_core/config.py index 25ad55d0..54c4d78b 100644 --- a/flit_core/flit_core/config.py +++ b/flit_core/flit_core/config.py @@ -18,6 +18,13 @@ except ImportError: import tomli as tomllib +try: + from .vendor.packaging import licenses +# Some downstream distributors remove the vendored packaging. +# When that is removed, import packaging from the regular location. +except ImportError: + from packaging import licenses + from .common import normalise_core_metadata_name from .versionno import normalise_version @@ -445,6 +452,14 @@ def _check_type(d, field_name, cls): "{} field should be {}, not {}".format(field_name, cls, type(d[field_name])) ) +def _check_types(d, field_name, cls_list) -> None: + if not isinstance(d[field_name], cls_list): + raise ConfigError( + "{} field should be {}, not {}".format( + field_name, ' or '.join(map(str, cls_list)), type(d[field_name]) + ) + ) + def _check_list_of_str(d, field_name): if not isinstance(d[field_name], list) or not all( isinstance(e, str) for e in d[field_name] @@ -526,30 +541,42 @@ def read_pep621_metadata(proj, path) -> LoadedConfig: md_dict['requires_python'] = proj['requires-python'] if 'license' in proj: - _check_type(proj, 'license', dict) - license_tbl = proj['license'] - unrec_keys = set(license_tbl.keys()) - {'text', 'file'} - if unrec_keys: - raise ConfigError( - "Unrecognised keys in [project.license]: {}".format(unrec_keys) - ) + _check_types(proj, 'license', (str, dict)) + if isinstance(proj['license'], str): + license_expr = proj['license'] + try: + license_expr = licenses.canonicalize_license_expression(license_expr) + except licenses.InvalidLicenseExpression as ex: + raise ConfigError(ex.args[0]) + md_dict['license_expression'] = license_expr + else: + license_tbl = proj['license'] + unrec_keys = set(license_tbl.keys()) - {'text', 'file'} + if unrec_keys: + raise ConfigError( + "Unrecognised keys in [project.license]: {}".format(unrec_keys) + ) - # TODO: Do something with license info. - # The 'License' field in packaging metadata is a brief description of - # a license, not the full text or a file path. PEP 639 will improve on - # how licenses are recorded. - if 'file' in license_tbl: - if 'text' in license_tbl: + # The 'License' field in packaging metadata is a brief description of + # a license, not the full text or a file path. + if 'file' in license_tbl: + if 'text' in license_tbl: + raise ConfigError( + "[project.license] should specify file or text, not both" + ) + lc.referenced_files.append(license_tbl['file']) + elif 'text' in license_tbl: + license = license_tbl['text'] + try: + # Normalize license if it's a valid SPDX expression + license = licenses.canonicalize_license_expression(license) + except licenses.InvalidLicenseExpression: + pass + md_dict['license'] = license + else: raise ConfigError( - "[project.license] should specify file or text, not both" + "file or text field required in [project.license] table" ) - lc.referenced_files.append(license_tbl['file']) - elif 'text' in license_tbl: - pass - else: - raise ConfigError( - "file or text field required in [project.license] table" - ) if 'authors' in proj: _check_type(proj, 'authors', list) @@ -565,6 +592,16 @@ def read_pep621_metadata(proj, path) -> LoadedConfig: if 'classifiers' in proj: _check_list_of_str(proj, 'classifiers') + classifiers = proj['classifiers'] + license_expr = md_dict.get('license_expression', None) + if license_expr: + for cl in classifiers: + if not cl.startswith('License :: '): + continue + raise ConfigError( + "License classifier are deprecated in favor of the license expression. " + "Remove the '{}' classifier".format(cl) + ) md_dict['classifiers'] = proj['classifiers'] if 'urls' in proj: diff --git a/flit_core/pyproject.toml b/flit_core/pyproject.toml index a027ff0c..6a385523 100644 --- a/flit_core/pyproject.toml +++ b/flit_core/pyproject.toml @@ -12,9 +12,8 @@ description = "Distribution-building parts of Flit. See flit package for more in dependencies = [] requires-python = '>=3.6' readme = "README.rst" -license = {file = "LICENSE"} +license = "BSD-3-Clause" classifiers = [ - "License :: OSI Approved :: BSD License", "Topic :: Software Development :: Libraries :: Python Modules", ] dynamic = ["version"] diff --git a/flit_core/tests_core/test_common.py b/flit_core/tests_core/test_common.py index 824fa5de..3823e27d 100644 --- a/flit_core/tests_core/test_common.py +++ b/flit_core/tests_core/test_common.py @@ -205,3 +205,27 @@ def test_metadata_2_3_provides_extra(provides_extra, expected_result): msg = email.parser.Parser(policy=email.policy.compat32).parse(sio) assert msg['Provides-Extra'] == expected_result assert not msg.defects + +@pytest.mark.parametrize( + ('value', 'expected_license', 'expected_license_expression'), + [ + ({'license': 'MIT'}, 'MIT', None), + ({'license_expression': 'MIT'}, 'MIT', None), # TODO Metadata 2.4 + ({'license_expression': 'Apache-2.0'}, 'Apache-2.0', None) # TODO Metadata 2.4 + ], +) +def test_metadata_license(value, expected_license, expected_license_expression): + d = { + 'name': 'foo', + 'version': '1.0', + **value, + } + md = Metadata(d) + sio = StringIO() + md.write_metadata_file(sio) + sio.seek(0) + + msg = email.parser.Parser(policy=email.policy.compat32).parse(sio) + assert msg.get('License') == expected_license + assert msg.get('License-Expression') == expected_license_expression + assert not msg.defects diff --git a/flit_core/tests_core/test_config.py b/flit_core/tests_core/test_config.py index 7d9e2c86..b09e869b 100644 --- a/flit_core/tests_core/test_config.py +++ b/flit_core/tests_core/test_config.py @@ -1,5 +1,7 @@ import logging +import re from pathlib import Path +from unittest.mock import patch import pytest from flit_core import config @@ -139,6 +141,12 @@ def test_bad_include_paths(path, err_match): ({'license': {'fromage': 2}}, '[Uu]nrecognised'), ({'license': {'file': 'LICENSE', 'text': 'xyz'}}, 'both'), ({'license': {}}, 'required'), + ({'license': 1}, "license field should be or , not "), + ({'license': "MIT License"}, "Invalid license expression: 'MIT License'"), + ( + {'license': 'MIT', 'classifiers': ['License :: OSI Approved :: MIT License']}, + "License classifier are deprecated in favor of the license expression", + ), ({'keywords': 'foo'}, 'list'), ({'keywords': ['foo', 7]}, 'strings'), ({'entry-points': {'foo': 'module1:main'}}, 'entry-point.*tables'), @@ -178,3 +186,26 @@ def test_bad_pep621_readme(readme, err_match): } with pytest.raises(config.ConfigError, match=err_match): config.read_pep621_metadata(proj, samples_dir / 'pep621') + +@pytest.mark.parametrize(('value', 'license', 'license_expression'), [ + # Normalize SPDX expressions but accept all strings for 'license = {text = ...}' + ('{text = "mit"}', "MIT", None), + ('{text = "Apache Software License"}', "Apache Software License", None), + ('{text = "mit"}\nclassifiers = ["License :: OSI Approved :: MIT License"]', "MIT", None), + # Accept and normalize valid SPDX expressions for 'license = ...' + ('"mit"', None, "MIT"), + ('"apache-2.0"', None, "Apache-2.0"), + ('"mit and (apache-2.0 or bsd-2-clause)"', None, "MIT AND (Apache-2.0 OR BSD-2-Clause)"), + ('"LicenseRef-Public-Domain"', None, "LicenseRef-Public-Domain"), +]) +def test_pep621_license(value, license, license_expression): + path = samples_dir / 'pep621' / 'pyproject.toml' + data = path.read_text() + data = re.sub( + r"(^license = )(?:\{.*\})", r"\g<1>{}".format(value), + data, count=1, flags=re.M, +) + with patch("pathlib.Path.read_text", return_value=data): + info = config.read_flit_config(path) + assert info.metadata.get('license', None) == license + assert info.metadata.get('license_expression', None) == license_expression