Skip to content

Commit 95a8099

Browse files
committed
Add support for license expressions
1 parent a281c8a commit 95a8099

File tree

6 files changed

+128
-25
lines changed

6 files changed

+128
-25
lines changed

doc/pyproject_toml.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@ requires-python
9696
A version specifier for the versions of Python this requires, e.g. ``~=3.3`` or
9797
``>=3.3,<4``, which are equivalents.
9898
license
99-
A table with either a ``file`` key (a relative path to a license file) or a
99+
A valid SPDX `license expression <https://peps.python.org/pep-0639/#term-license-expression>`_
100+
or a table with either a ``file`` key (a relative path to a license file) or a
100101
``text`` key (the license text).
101102
authors
102103
A list of tables with ``name`` and ``email`` keys (both optional) describing

flit_core/flit_core/common.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,7 @@ class Metadata(object):
336336
maintainer = None
337337
maintainer_email = None
338338
license = None
339+
license_expression = None
339340
description = None
340341
keywords = None
341342
download_url = None
@@ -398,7 +399,6 @@ def write_metadata_file(self, fp):
398399
optional_fields = [
399400
'Summary',
400401
'Home-page',
401-
'License',
402402
'Keywords',
403403
'Author',
404404
'Author-email',
@@ -422,6 +422,17 @@ def write_metadata_file(self, fp):
422422
value = '\n '.join(value.splitlines())
423423
fp.write(u"{}: {}\n".format(field, value))
424424

425+
426+
license_expr = getattr(self, self._normalise_field_name("License-Expression"))
427+
license = getattr(self, self._normalise_field_name("License"))
428+
if license_expr:
429+
# TODO: License-Expression requires Metadata-Version '2.4'
430+
# Backfill it to the 'License' field for now
431+
# fp.write(u'License-Expression: {}\n'.format(license_expr))
432+
fp.write(u'License: {}\n'.format(license_expr))
433+
elif license:
434+
fp.write(u'License: {}\n'.format(license))
435+
425436
for clsfr in self.classifiers:
426437
fp.write(u'Classifier: {}\n'.format(clsfr))
427438

flit_core/flit_core/config.py

Lines changed: 58 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@
1818
except ImportError:
1919
import tomli as tomllib
2020

21+
try:
22+
from .vendor.packaging import licenses
23+
# Some downstream distributors remove the vendored packaging.
24+
# When that is removed, import packaging from the regular location.
25+
except ImportError:
26+
from packaging import licenses
27+
2128
from .common import normalise_core_metadata_name
2229
from .versionno import normalise_version
2330

@@ -445,6 +452,14 @@ def _check_type(d, field_name, cls):
445452
"{} field should be {}, not {}".format(field_name, cls, type(d[field_name]))
446453
)
447454

455+
def _check_types(d, field_name, cls_list) -> None:
456+
if not isinstance(d[field_name], cls_list):
457+
raise ConfigError(
458+
"{} field should be {}, not {}".format(
459+
field_name, ' or '.join(map(str, cls_list)), type(d[field_name])
460+
)
461+
)
462+
448463
def _check_list_of_str(d, field_name):
449464
if not isinstance(d[field_name], list) or not all(
450465
isinstance(e, str) for e in d[field_name]
@@ -526,30 +541,42 @@ def read_pep621_metadata(proj, path) -> LoadedConfig:
526541
md_dict['requires_python'] = proj['requires-python']
527542

528543
if 'license' in proj:
529-
_check_type(proj, 'license', dict)
530-
license_tbl = proj['license']
531-
unrec_keys = set(license_tbl.keys()) - {'text', 'file'}
532-
if unrec_keys:
533-
raise ConfigError(
534-
"Unrecognised keys in [project.license]: {}".format(unrec_keys)
535-
)
544+
_check_types(proj, 'license', (str, dict))
545+
if isinstance(proj['license'], str):
546+
license_expr = proj['license']
547+
try:
548+
license_expr = licenses.canonicalize_license_expression(license_expr)
549+
except licenses.InvalidLicenseExpression as ex:
550+
raise ConfigError(ex.args[0])
551+
md_dict['license_expression'] = license_expr
552+
else:
553+
license_tbl = proj['license']
554+
unrec_keys = set(license_tbl.keys()) - {'text', 'file'}
555+
if unrec_keys:
556+
raise ConfigError(
557+
"Unrecognised keys in [project.license]: {}".format(unrec_keys)
558+
)
536559

537-
# TODO: Do something with license info.
538-
# The 'License' field in packaging metadata is a brief description of
539-
# a license, not the full text or a file path. PEP 639 will improve on
540-
# how licenses are recorded.
541-
if 'file' in license_tbl:
542-
if 'text' in license_tbl:
560+
# The 'License' field in packaging metadata is a brief description of
561+
# a license, not the full text or a file path.
562+
if 'file' in license_tbl:
563+
if 'text' in license_tbl:
564+
raise ConfigError(
565+
"[project.license] should specify file or text, not both"
566+
)
567+
lc.referenced_files.append(license_tbl['file'])
568+
elif 'text' in license_tbl:
569+
license = license_tbl['text']
570+
try:
571+
# Normalize license if it's a valid SPDX expression
572+
license = licenses.canonicalize_license_expression(license)
573+
except licenses.InvalidLicenseExpression:
574+
pass
575+
md_dict['license'] = license
576+
else:
543577
raise ConfigError(
544-
"[project.license] should specify file or text, not both"
578+
"file or text field required in [project.license] table"
545579
)
546-
lc.referenced_files.append(license_tbl['file'])
547-
elif 'text' in license_tbl:
548-
pass
549-
else:
550-
raise ConfigError(
551-
"file or text field required in [project.license] table"
552-
)
553580

554581
if 'authors' in proj:
555582
_check_type(proj, 'authors', list)
@@ -565,6 +592,16 @@ def read_pep621_metadata(proj, path) -> LoadedConfig:
565592

566593
if 'classifiers' in proj:
567594
_check_list_of_str(proj, 'classifiers')
595+
classifiers = proj['classifiers']
596+
license_expr = md_dict.get('license_expression', None)
597+
if license_expr:
598+
for cl in classifiers:
599+
if not cl.startswith('License :: '):
600+
continue
601+
raise ConfigError(
602+
"License classifier are deprecated in favor of the license expression. "
603+
"Remove the '{}' classifier".format(cl)
604+
)
568605
md_dict['classifiers'] = proj['classifiers']
569606

570607
if 'urls' in proj:

flit_core/pyproject.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,8 @@ description = "Distribution-building parts of Flit. See flit package for more in
1212
dependencies = []
1313
requires-python = '>=3.6'
1414
readme = "README.rst"
15-
license = {file = "LICENSE"}
15+
license = "BSD-3-Clause"
1616
classifiers = [
17-
"License :: OSI Approved :: BSD License",
1817
"Topic :: Software Development :: Libraries :: Python Modules",
1918
]
2019
dynamic = ["version"]

flit_core/tests_core/test_common.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,27 @@ def test_metadata_2_3_provides_extra(provides_extra, expected_result):
205205
msg = email.parser.Parser(policy=email.policy.compat32).parse(sio)
206206
assert msg['Provides-Extra'] == expected_result
207207
assert not msg.defects
208+
209+
@pytest.mark.parametrize(
210+
('value', 'expected_license', 'expected_license_expression'),
211+
[
212+
({'license': 'MIT'}, 'MIT', None),
213+
({'license_expression': 'MIT'}, 'MIT', None), # TODO Metadata 2.4
214+
({'license_expression': 'Apache-2.0'}, 'Apache-2.0', None) # TODO Metadata 2.4
215+
],
216+
)
217+
def test_metadata_license(value, expected_license, expected_license_expression):
218+
d = {
219+
'name': 'foo',
220+
'version': '1.0',
221+
**value,
222+
}
223+
md = Metadata(d)
224+
sio = StringIO()
225+
md.write_metadata_file(sio)
226+
sio.seek(0)
227+
228+
msg = email.parser.Parser(policy=email.policy.compat32).parse(sio)
229+
assert msg.get('License') == expected_license
230+
assert msg.get('License-Expression') == expected_license_expression
231+
assert not msg.defects

flit_core/tests_core/test_config.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import logging
2+
import re
23
from pathlib import Path
4+
from unittest.mock import patch
35
import pytest
46

57
from flit_core import config
@@ -139,6 +141,12 @@ def test_bad_include_paths(path, err_match):
139141
({'license': {'fromage': 2}}, '[Uu]nrecognised'),
140142
({'license': {'file': 'LICENSE', 'text': 'xyz'}}, 'both'),
141143
({'license': {}}, 'required'),
144+
({'license': 1}, "license field should be <class 'str'> or <class 'dict'>, not <class 'int'>"),
145+
({'license': "MIT License"}, "Invalid license expression: 'MIT License'"),
146+
(
147+
{'license': 'MIT', 'classifiers': ['License :: OSI Approved :: MIT License']},
148+
"License classifier are deprecated in favor of the license expression",
149+
),
142150
({'keywords': 'foo'}, 'list'),
143151
({'keywords': ['foo', 7]}, 'strings'),
144152
({'entry-points': {'foo': 'module1:main'}}, 'entry-point.*tables'),
@@ -178,3 +186,26 @@ def test_bad_pep621_readme(readme, err_match):
178186
}
179187
with pytest.raises(config.ConfigError, match=err_match):
180188
config.read_pep621_metadata(proj, samples_dir / 'pep621')
189+
190+
@pytest.mark.parametrize(('value', 'license', 'license_expression'), [
191+
# Normalize SPDX expressions but accept all strings for 'license = {text = ...}'
192+
('{text = "mit"}', "MIT", None),
193+
('{text = "Apache Software License"}', "Apache Software License", None),
194+
('{text = "mit"}\nclassifiers = ["License :: OSI Approved :: MIT License"]', "MIT", None),
195+
# Accept and normalize valid SPDX expressions for 'license = ...'
196+
('"mit"', None, "MIT"),
197+
('"apache-2.0"', None, "Apache-2.0"),
198+
('"mit and (apache-2.0 or bsd-2-clause)"', None, "MIT AND (Apache-2.0 OR BSD-2-Clause)"),
199+
('"LicenseRef-Public-Domain"', None, "LicenseRef-Public-Domain"),
200+
])
201+
def test_pep621_license(value, license, license_expression):
202+
path = samples_dir / 'pep621' / 'pyproject.toml'
203+
data = path.read_text()
204+
data = re.sub(
205+
r"(^license = )(?:\{.*\})", r"\g<1>{}".format(value),
206+
data, count=1, flags=re.M,
207+
)
208+
with patch("pathlib.Path.read_text", return_value=data):
209+
info = config.read_flit_config(path)
210+
assert info.metadata.get('license', None) == license
211+
assert info.metadata.get('license_expression', None) == license_expression

0 commit comments

Comments
 (0)