Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for license expressions #707

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion doc/pyproject_toml.rst
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ 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
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
65 changes: 44 additions & 21 deletions flit_core/flit_core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,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 +534,35 @@ 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']
# TODO Validate and normalize license expression
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']
# TODO Normalize license if it's a valid SPDX expression
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 +578,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'"), # TODO
(
{'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), # TODO should be "MIT"
('{text = "Apache Software License"}', "Apache Software License", None),
('{text = "mit"}\nclassifiers = ["License :: OSI Approved :: MIT License"]', "mit", None), # TODO should be "MIT"
# Accept and normalize valid SPDX expressions for 'license = ...'
('"mit"', None, "mit"), # TODO should be "MIT"
('"apache-2.0"', None, "apache-2.0"), # TODO should be "Apache-2.0"
('"mit and (apache-2.0 or bsd-2-clause)"', None, "mit and (apache-2.0 or bsd-2-clause)"), # TODO should be "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