Skip to content

Commit

Permalink
Add support for license expressions
Browse files Browse the repository at this point in the history
  • Loading branch information
cdce8p committed Nov 24, 2024
1 parent a281c8a commit cbcbb87
Show file tree
Hide file tree
Showing 6 changed files with 129 additions and 26 deletions.
5 changes: 3 additions & 2 deletions doc/pyproject_toml.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://peps.python.org/pep-0639/#term-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
Expand Down
13 changes: 12 additions & 1 deletion flit_core/flit_core/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ class Metadata(object):
maintainer = None
maintainer_email = None
license = None
license_expression = None
description = None
keywords = None
download_url = None
Expand Down Expand Up @@ -398,7 +399,6 @@ def write_metadata_file(self, fp):
optional_fields = [
'Summary',
'Home-page',
'License',
'Keywords',
'Author',
'Author-email',
Expand All @@ -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))

Expand Down
79 changes: 58 additions & 21 deletions flit_core/flit_core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down
3 changes: 1 addition & 2 deletions flit_core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
24 changes: 24 additions & 0 deletions flit_core/tests_core/test_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
31 changes: 31 additions & 0 deletions flit_core/tests_core/test_config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import logging
import re
from pathlib import Path
from unittest.mock import patch
import pytest

from flit_core import config
Expand Down Expand Up @@ -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 <class 'str'> or <class 'dict'>, not <class 'int'>"),
({'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'),
Expand Down Expand Up @@ -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

0 comments on commit cbcbb87

Please sign in to comment.