Skip to content

Commit 2818f41

Browse files
authored
Merge pull request #712 from pypa/license-expression
Support simple SPDX license expressions (PEP 639)
2 parents c13d414 + 0d0d06b commit 2818f41

File tree

8 files changed

+831
-24
lines changed

8 files changed

+831
-24
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
license-files
102103
A list of glob patterns for license files to include.

flit_core/flit_core/_spdx_data.py

Lines changed: 651 additions & 0 deletions
Large diffs are not rendered by default.

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
@@ -399,7 +400,6 @@ def write_metadata_file(self, fp):
399400
optional_fields = [
400401
'Summary',
401402
'Home-page',
402-
'License',
403403
'Keywords',
404404
'Author',
405405
'Author-email',
@@ -423,6 +423,17 @@ def write_metadata_file(self, fp):
423423
value = '\n '.join(value.splitlines())
424424
fp.write(u"{}: {}\n".format(field, value))
425425

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

flit_core/flit_core/config.py

Lines changed: 80 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,14 @@ def _check_type(d, field_name, cls):
495495
"{} field should be {}, not {}".format(field_name, cls, type(d[field_name]))
496496
)
497497

498+
def _check_types(d, field_name, cls_list) -> None:
499+
if not isinstance(d[field_name], cls_list):
500+
raise ConfigError(
501+
"{} field should be {}, not {}".format(
502+
field_name, ' or '.join(map(str, cls_list)), type(d[field_name])
503+
)
504+
)
505+
498506
def _check_list_of_str(d, field_name):
499507
if not isinstance(d[field_name], list) or not all(
500508
isinstance(e, str) for e in d[field_name]
@@ -577,30 +585,38 @@ def read_pep621_metadata(proj, path) -> LoadedConfig:
577585

578586
license_files = set()
579587
if 'license' in proj:
580-
_check_type(proj, 'license', dict)
581-
license_tbl = proj['license']
582-
unrec_keys = set(license_tbl.keys()) - {'text', 'file'}
583-
if unrec_keys:
584-
raise ConfigError(
585-
"Unrecognised keys in [project.license]: {}".format(unrec_keys)
586-
)
588+
_check_types(proj, 'license', (str, dict))
589+
if isinstance(proj['license'], str):
590+
md_dict['license_expression'] = normalize_license_expr(proj['license'])
591+
else:
592+
license_tbl = proj['license']
593+
unrec_keys = set(license_tbl.keys()) - {'text', 'file'}
594+
if unrec_keys:
595+
raise ConfigError(
596+
"Unrecognised keys in [project.license]: {}".format(unrec_keys)
597+
)
587598

588-
# TODO: Do something with license info.
589-
# The 'License' field in packaging metadata is a brief description of
590-
# a license, not the full text or a file path. PEP 639 will improve on
591-
# how licenses are recorded.
592-
if 'file' in license_tbl:
593-
if 'text' in license_tbl:
599+
# The 'License' field in packaging metadata is a brief description of
600+
# a license, not the full text or a file path.
601+
if 'file' in license_tbl:
602+
if 'text' in license_tbl:
603+
raise ConfigError(
604+
"[project.license] should specify file or text, not both"
605+
)
606+
license_f = license_tbl['file']
607+
if isabs_ish(license_f):
608+
raise ConfigError(
609+
f"License file path ({license_f}) cannot be an absolute path"
610+
)
611+
if not (path.parent / license_f).is_file():
612+
raise ConfigError(f"License file {license_f} does not exist")
613+
license_files.add(license_tbl['file'])
614+
elif 'text' in license_tbl:
615+
pass
616+
else:
594617
raise ConfigError(
595-
"[project.license] should specify file or text, not both"
618+
"file or text field required in [project.license] table"
596619
)
597-
license_files.add(license_tbl['file'])
598-
elif 'text' in license_tbl:
599-
pass
600-
else:
601-
raise ConfigError(
602-
"file or text field required in [project.license] table"
603-
)
604620

605621
if 'license-files' in proj:
606622
_check_type(proj, 'license-files', list)
@@ -635,6 +651,16 @@ def read_pep621_metadata(proj, path) -> LoadedConfig:
635651

636652
if 'classifiers' in proj:
637653
_check_list_of_str(proj, 'classifiers')
654+
classifiers = proj['classifiers']
655+
license_expr = md_dict.get('license_expression', None)
656+
if license_expr:
657+
for cl in classifiers:
658+
if not cl.startswith('License :: '):
659+
continue
660+
raise ConfigError(
661+
"License classifier are deprecated in favor of the license expression. "
662+
"Remove the '{}' classifier".format(cl)
663+
)
638664
md_dict['classifiers'] = proj['classifiers']
639665

640666
if 'urls' in proj:
@@ -788,3 +814,36 @@ def isabs_ish(path):
788814
absolute paths, we also want to reject these odd halfway paths.
789815
"""
790816
return os.path.isabs(path) or path.startswith(('/', '\\'))
817+
818+
819+
def normalize_license_expr(s: str):
820+
"""Validate & normalise an SPDX license expression
821+
822+
For now this only handles simple expressions (referring to 1 license)
823+
"""
824+
from ._spdx_data import licenses
825+
ls = s.lower()
826+
if ls.startswith('licenseref-'):
827+
ref = s.partition('-')[2]
828+
if re.match(r'([a-zA-Z0-9\-.])+$', ref):
829+
# Normalise case of LicenseRef, leave the rest alone
830+
return "LicenseRef-" + ref
831+
raise ConfigError(
832+
"LicenseRef- license expression can only contain ASCII letters "
833+
"& digits, - and ."
834+
)
835+
836+
or_later = s.endswith('+')
837+
if or_later:
838+
ls = ls[:-1]
839+
840+
try:
841+
info = licenses[ls]
842+
except KeyError:
843+
if os.environ.get('FLIT_ALLOW_INVALID'):
844+
log.warning("Invalid license ID {!r} allowed by FLIT_ALLOW_INVALID"
845+
.format(s))
846+
return s
847+
raise ConfigError(f"{s!r} is not a recognised SPDX license ID")
848+
849+
return info['id'] + ('+' if or_later else '')

flit_core/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ 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 = "BSD-3-Clause"
1516
license-files = ["LICENSE*", "flit_core/vendor/**/LICENSE*"]
1617
classifiers = [
17-
"License :: OSI Approved :: BSD License",
1818
"Topic :: Software Development :: Libraries :: Python Modules",
1919
]
2020
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: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,12 @@ def test_bad_include_paths(path, err_match):
140140
({'license': {'fromage': 2}}, '[Uu]nrecognised'),
141141
({'license': {'file': 'LICENSE', 'text': 'xyz'}}, 'both'),
142142
({'license': {}}, 'required'),
143+
({'license': 1}, "license field should be <class 'str'> or <class 'dict'>, not <class 'int'>"),
144+
# ({'license': "MIT License"}, "Invalid license expression: 'MIT License'"), # TODO
145+
(
146+
{'license': 'MIT', 'classifiers': ['License :: OSI Approved :: MIT License']},
147+
"License classifier are deprecated in favor of the license expression",
148+
),
143149
({'license-files': 1}, r"\blist\b"),
144150
({'license-files': ["/LICENSE"]}, r"'/LICENSE'.+must not start with '/'"),
145151
({'license-files': ["../LICENSE"]}, r"'../LICENSE'.+must not contain '..'"),
@@ -202,6 +208,37 @@ def test_bad_pep621_readme(readme, err_match):
202208
config.read_pep621_metadata(proj, samples_dir / 'pep621' / 'pyproject.toml')
203209

204210

211+
@pytest.mark.parametrize(('value', 'license_expression'), [
212+
# Accept and normalize valid SPDX expressions for 'license = ...'
213+
("mit", "MIT"),
214+
("apache-2.0", "Apache-2.0"),
215+
("APACHE-2.0+", "Apache-2.0+"),
216+
# TODO: compound expressions
217+
#("mit and (apache-2.0 or bsd-2-clause)", "MIT AND (Apache-2.0 OR BSD-2-Clause)"),
218+
# LicenseRef expressions: only the LicenseRef is normalised
219+
("LiceNseref-Public-DoMain", "LicenseRef-Public-DoMain"),
220+
])
221+
def test_license_expr(value, license_expression):
222+
proj = {
223+
'name': 'module1', 'version': '1.0', 'description': 'x', 'license': value
224+
}
225+
info = config.read_pep621_metadata(proj, samples_dir / 'pep621' / 'pyproject.toml')
226+
assert 'license' not in info.metadata
227+
assert info.metadata['license_expression'] == license_expression
228+
229+
def test_license_expr_error():
230+
proj = {
231+
'name': 'module1', 'version': '1.0', 'description': 'x',
232+
'license': 'LicenseRef-foo_bar', # Underscore not allowed
233+
}
234+
with pytest.raises(config.ConfigError, match="can only contain"):
235+
config.read_pep621_metadata(proj, samples_dir / 'pep621' / 'pyproject.toml')
236+
237+
proj['license'] = "BSD-33-Clause" # Not a real license
238+
with pytest.raises(config.ConfigError, match="recognised"):
239+
config.read_pep621_metadata(proj, samples_dir / 'pep621' / 'pyproject.toml')
240+
241+
205242
def test_license_file_defaults_with_old_metadata():
206243
metadata = {'module': 'mymod', 'author': ''}
207244
info = config._prep_metadata(metadata, samples_dir / 'pep621_license_files' / 'pyproject.toml')

prepare_license_list.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Call with path to SPDX license-list-data repo, cloned from:
2+
# https://github.com/spdx/license-list-data
3+
4+
import json
5+
import pprint
6+
import sys
7+
from pathlib import Path
8+
9+
list_data_repo = Path(sys.argv[1])
10+
with (list_data_repo / 'json' / 'licenses.json').open('rb') as f:
11+
licenses_json = json.load(f)
12+
13+
condensed = {
14+
l['licenseId'].lower() : {'id': l['licenseId']}
15+
for l in licenses_json['licenses']
16+
if not l['isDeprecatedLicenseId']
17+
}
18+
19+
with Path('flit_core', 'flit_core', '_spdx_data.py').open('w') as f:
20+
f.write("# This file is generated from SPDX license data; don't edit it manually.\n\n")
21+
22+
f.write("licenses = \\\n")
23+
pprint.pprint(condensed, f)
24+

0 commit comments

Comments
 (0)