Skip to content

Commit 7a6e9b2

Browse files
authored
Merge pull request #731 from AA-Turner/compound-spdx
Support compound SPDX licence expressions
2 parents e2caa01 + 0b07d23 commit 7a6e9b2

File tree

3 files changed

+222
-20
lines changed

3 files changed

+222
-20
lines changed

flit_core/flit_core/config.py

+80-15
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
except ImportError:
1818
import tomli as tomllib
1919

20+
from ._spdx_data import licenses
2021
from .common import normalise_core_metadata_name
2122
from .versionno import normalise_version
2223

@@ -586,7 +587,8 @@ def read_pep621_metadata(proj, path) -> LoadedConfig:
586587
if 'license' in proj:
587588
_check_types(proj, 'license', (str, dict))
588589
if isinstance(proj['license'], str):
589-
md_dict['license_expression'] = normalize_license_expr(proj['license'])
590+
licence_expr = proj['license']
591+
md_dict['license_expression'] = normalise_compound_license_expr(licence_expr)
590592
else:
591593
license_tbl = proj['license']
592594
unrec_keys = set(license_tbl.keys()) - {'text', 'file'}
@@ -821,34 +823,97 @@ def isabs_ish(path):
821823
return os.path.isabs(path) or path.startswith(('/', '\\'))
822824

823825

824-
def normalize_license_expr(s: str):
825-
"""Validate & normalise an SPDX license expression
826+
def normalise_compound_license_expr(s: str) -> str:
827+
"""Validate and normalise a compund SPDX license expression.
826828
827-
For now this only handles simple expressions (referring to 1 license)
829+
Per the specification, licence expression operators (AND, OR and WITH)
830+
are matched case-sensitively. The WITH operator is not currently supported.
831+
832+
Spec: https://spdx.github.io/spdx-spec/v2.2.2/SPDX-license-expressions/
833+
"""
834+
invalid_msg = "'{s}' is not a valid SPDX license expression: {reason}"
835+
if not s or s.isspace():
836+
raise ConfigError(f"The SPDX license expression must not be empty")
837+
838+
stack = 0
839+
parts = []
840+
try:
841+
for part in filter(None, re.split(r' +|([()])', s)):
842+
if part.upper() == 'WITH':
843+
# provide a sensible error message for the WITH operator
844+
raise ConfigError(f"The SPDX 'WITH' operator is not yet supported!")
845+
elif part in {'AND', 'OR'}:
846+
if not parts or parts[-1] in {' AND ', ' OR ', ' WITH ', '('}:
847+
reason = f"a license ID is missing before '{part}'"
848+
raise ConfigError(invalid_msg.format(s=s, reason=reason))
849+
parts.append(f' {part} ')
850+
elif part.lower() in {'and', 'or', 'with'}:
851+
# provide a sensible error message for non-uppercase operators
852+
reason = f"operators must be uppercase, not '{part}'"
853+
raise ConfigError(invalid_msg.format(s=s, reason=reason))
854+
elif part == '(':
855+
if parts and parts[-1] not in {' AND ', ' OR ', '('}:
856+
reason = f"'(' must follow either AND, OR, or '('"
857+
raise ConfigError(invalid_msg.format(s=s, reason=reason))
858+
stack += 1
859+
parts.append(part)
860+
elif part == ')':
861+
if not parts or parts[-1] in {' AND ', ' OR ', ' WITH ', '('}:
862+
reason = f"a license ID is missing before '{part}'"
863+
raise ConfigError(invalid_msg.format(s=s, reason=reason))
864+
stack -= 1
865+
if stack < 0:
866+
reason = 'unbalanced brackets'
867+
raise ConfigError(invalid_msg.format(s=s, reason=reason))
868+
parts.append(part)
869+
else:
870+
if parts and parts[-1] not in {' AND ', ' OR ', '('}:
871+
reason = f"a license ID must follow either AND, OR, or '('"
872+
raise ConfigError(invalid_msg.format(s=s, reason=reason))
873+
simple_expr = normalise_simple_license_expr(part)
874+
parts.append(simple_expr)
875+
876+
if stack != 0:
877+
reason = 'unbalanced brackets'
878+
raise ConfigError(invalid_msg.format(s=s, reason=reason))
879+
if parts[-1] in {' AND ', ' OR ', ' WITH '}:
880+
last_part = parts[-1].strip()
881+
reason = f"a license ID or expression should follow '{last_part}'"
882+
raise ConfigError(invalid_msg.format(s=s, reason=reason))
883+
except ConfigError:
884+
if os.environ.get('FLIT_ALLOW_INVALID'):
885+
log.warning(f"Invalid license ID {s!r} allowed by FLIT_ALLOW_INVALID")
886+
return s
887+
raise
888+
889+
return ''.join(parts)
890+
891+
892+
def normalise_simple_license_expr(s: str) -> str:
893+
"""Normalise a simple SPDX license expression.
894+
895+
https://spdx.github.io/spdx-spec/v2.2.2/SPDX-license-expressions/#d3-simple-license-expressions
828896
"""
829-
from ._spdx_data import licenses
830897
ls = s.lower()
831898
if ls.startswith('licenseref-'):
832-
ref = s.partition('-')[2]
833-
if re.match(r'([a-zA-Z0-9\-.])+$', ref):
899+
ref = s[11:]
900+
if re.fullmatch(r'[a-zA-Z0-9\-.]+', ref):
834901
# Normalise case of LicenseRef, leave the rest alone
835-
return "LicenseRef-" + ref
902+
return f"LicenseRef-{ref}"
836903
raise ConfigError(
837904
"LicenseRef- license expression can only contain ASCII letters "
838905
"& digits, - and ."
839906
)
840907

841-
or_later = s.endswith('+')
908+
or_later = ls.endswith('+')
842909
if or_later:
843910
ls = ls[:-1]
844911

845912
try:
846-
info = licenses[ls]
913+
normalised_id = licenses[ls]['id']
847914
except KeyError:
848-
if os.environ.get('FLIT_ALLOW_INVALID'):
849-
log.warning("Invalid license ID {!r} allowed by FLIT_ALLOW_INVALID"
850-
.format(s))
851-
return s
852915
raise ConfigError(f"{s!r} is not a recognised SPDX license ID")
853916

854-
return info['id'] + ('+' if or_later else '')
917+
if or_later:
918+
return f'{normalised_id}+'
919+
return normalised_id

flit_core/tests_core/test_common.py

+4
Original file line numberDiff line numberDiff line change
@@ -215,8 +215,12 @@ def test_metadata_2_3_provides_extra(provides_extra, expected_result):
215215
('value', 'expected_license', 'expected_license_expression'),
216216
[
217217
({'license': 'MIT'}, 'MIT', None),
218+
({'license': 'MIT OR Apache-2.0'}, 'MIT OR Apache-2.0', None),
219+
({'license': 'MIT AND Apache-2.0'}, 'MIT AND Apache-2.0', None),
218220
({'license_expression': 'MIT'}, None, 'MIT'),
219221
({'license_expression': 'Apache-2.0'}, None, 'Apache-2.0'),
222+
({'license_expression': 'MIT OR Apache-2.0'}, None, 'MIT OR Apache-2.0'),
223+
({'license_expression': 'MIT AND Apache-2.0'}, None, 'MIT AND Apache-2.0'),
220224
],
221225
)
222226
def test_metadata_license(value, expected_license, expected_license_expression):

flit_core/tests_core/test_config.py

+138-5
Original file line numberDiff line numberDiff line change
@@ -222,8 +222,17 @@ def test_bad_pep621_readme(readme, err_match):
222222
("mit", "MIT"),
223223
("apache-2.0", "Apache-2.0"),
224224
("APACHE-2.0+", "Apache-2.0+"),
225-
# TODO: compound expressions
226-
#("mit and (apache-2.0 or bsd-2-clause)", "MIT AND (Apache-2.0 OR BSD-2-Clause)"),
225+
("mit AND (apache-2.0 OR bsd-2-clause)", "MIT AND (Apache-2.0 OR BSD-2-Clause)"),
226+
("(mit)", "(MIT)"),
227+
("MIT OR Apache-2.0", "MIT OR Apache-2.0"),
228+
("MIT AND Apache-2.0", "MIT AND Apache-2.0"),
229+
("MIT AND Apache-2.0+ OR 0BSD", "MIT AND Apache-2.0+ OR 0BSD"),
230+
("MIT AND (Apache-2.0+ OR (0BSD))", "MIT AND (Apache-2.0+ OR (0BSD))"),
231+
("MIT OR(mit)", "MIT OR (MIT)"),
232+
("(mit)AND mit", "(MIT) AND MIT"),
233+
("MIT OR (MIT OR ( MIT )) AND ((MIT) AND MIT) OR MIT", "MIT OR (MIT OR (MIT)) AND ((MIT) AND MIT) OR MIT"),
234+
("LICENSEREF-Public-Domain OR cc0-1.0 OR unlicense", "LicenseRef-Public-Domain OR CC0-1.0 OR Unlicense"),
235+
("mit AND ( apache-2.0+ OR mpl-2.0+ )", "MIT AND (Apache-2.0+ OR MPL-2.0+)"),
227236
# LicenseRef expressions: only the LicenseRef is normalised
228237
("LiceNseref-Public-DoMain", "LicenseRef-Public-DoMain"),
229238
])
@@ -235,19 +244,143 @@ def test_license_expr(value, license_expression):
235244
assert 'license' not in info.metadata
236245
assert info.metadata['license_expression'] == license_expression
237246

238-
def test_license_expr_error():
247+
@pytest.mark.parametrize('invalid_expr', [
248+
"LicenseRef-foo_bar",
249+
"LicenseRef-foo~bar",
250+
"LicenseRef-foo:bar",
251+
"LicenseRef-foo[bar]",
252+
"LicenseRef-foo-bar+",
253+
])
254+
def test_license_expr_error_licenseref(invalid_expr: str):
239255
proj = {
240256
'name': 'module1', 'version': '1.0', 'description': 'x',
241-
'license': 'LicenseRef-foo_bar', # Underscore not allowed
257+
'license': invalid_expr,
242258
}
243259
with pytest.raises(config.ConfigError, match="can only contain"):
244260
config.read_pep621_metadata(proj, samples_dir / 'pep621' / 'pyproject.toml')
245261

246-
proj['license'] = "BSD-33-Clause" # Not a real license
262+
263+
@pytest.mark.parametrize('invalid_expr', [
264+
# Not a real licence
265+
"BSD-33-Clause",
266+
"MIT OR BSD-33-Clause",
267+
"MIT OR (MIT AND BSD-33-Clause)",
268+
])
269+
def test_license_expr_error_not_recognised(invalid_expr: str):
270+
proj = {
271+
'name': 'module1', 'version': '1.0', 'description': 'x',
272+
'license': invalid_expr,
273+
}
247274
with pytest.raises(config.ConfigError, match="recognised"):
248275
config.read_pep621_metadata(proj, samples_dir / 'pep621' / 'pyproject.toml')
249276

250277

278+
@pytest.mark.parametrize('invalid_expr', [
279+
# No operator
280+
"MIT MIT",
281+
"MIT OR (MIT MIT)",
282+
# Only operator
283+
"AND",
284+
"OR",
285+
"AND AND AND",
286+
"OR OR OR",
287+
"OR AND OR",
288+
"AND OR OR AND OR OR AND",
289+
# Too many operators
290+
"MIT AND AND MIT",
291+
"MIT OR OR OR MIT",
292+
"MIT AND OR MIT",
293+
# Mixed case operator
294+
"MIT aND MIT",
295+
"MIT oR MIT",
296+
"MIT AND MIT oR MIT",
297+
# Missing operand
298+
"MIT AND",
299+
"AND MIT",
300+
"MIT OR",
301+
"OR MIT",
302+
"MIT (AND MIT)",
303+
"(MIT OR) MIT",
304+
# Unbalanced brackets
305+
")(",
306+
"(",
307+
")",
308+
"MIT OR ()",
309+
") AND MIT",
310+
"MIT OR (",
311+
"MIT OR (MIT))",
312+
# Only brackets
313+
"()",
314+
"()()",
315+
"()(())",
316+
"( )",
317+
" ( )",
318+
"( ) ",
319+
" ( ) ",
320+
])
321+
def test_license_expr_error(invalid_expr: str):
322+
proj = {
323+
'name': 'module1', 'version': '1.0', 'description': 'x',
324+
'license': invalid_expr,
325+
}
326+
with pytest.raises(config.ConfigError, match="is not a valid"):
327+
config.read_pep621_metadata(proj, samples_dir / 'pep621' / 'pyproject.toml')
328+
329+
330+
@pytest.mark.parametrize('invalid_expr', [
331+
"",
332+
" ",
333+
"\t",
334+
"\r",
335+
"\n",
336+
"\f",
337+
" \t \n \r \f ",
338+
])
339+
def test_license_expr_error_empty(invalid_expr: str):
340+
proj = {
341+
'name': 'module1', 'version': '1.0', 'description': 'x',
342+
'license': invalid_expr,
343+
}
344+
with pytest.raises(config.ConfigError, match="must not be empty"):
345+
config.read_pep621_metadata(proj, samples_dir / 'pep621' / 'pyproject.toml')
346+
347+
348+
@pytest.mark.parametrize('invalid_expr', [
349+
"mit or mit",
350+
"or",
351+
"and",
352+
"MIT and MIT",
353+
"MIT AND MIT or MIT",
354+
"MIT AND (MIT or MIT)",
355+
])
356+
def test_license_expr_error_lowercase(invalid_expr: str):
357+
proj = {
358+
'name': 'module1', 'version': '1.0', 'description': 'x',
359+
'license': invalid_expr,
360+
}
361+
with pytest.raises(config.ConfigError, match="must be uppercase"):
362+
config.read_pep621_metadata(proj, samples_dir / 'pep621' / 'pyproject.toml')
363+
364+
365+
@pytest.mark.parametrize('invalid_expr', [
366+
"WITH",
367+
"with",
368+
"WiTh",
369+
"wiTH",
370+
"MIT WITH MIT-Exception",
371+
"(MIT WITH MIT-Exception)",
372+
"MIT OR MIT WITH MIT-Exception",
373+
"MIT WITH MIT-Exception OR (MIT AND MIT)",
374+
])
375+
def test_license_expr_error_unsupported_with(invalid_expr: str):
376+
proj = {
377+
'name': 'module1', 'version': '1.0', 'description': 'x',
378+
'license': invalid_expr,
379+
}
380+
with pytest.raises(config.ConfigError, match="not yet supported"):
381+
config.read_pep621_metadata(proj, samples_dir / 'pep621' / 'pyproject.toml')
382+
383+
251384
def test_license_file_defaults_with_old_metadata():
252385
metadata = {'module': 'mymod', 'author': ''}
253386
info = config._prep_metadata(metadata, samples_dir / 'pep621_license_files' / 'pyproject.toml')

0 commit comments

Comments
 (0)