diff --git a/flit_core/flit_core/_spdx_data.py b/flit_core/flit_core/_spdx_data.py index 5af71dbb..38b32bef 100644 --- a/flit_core/flit_core/_spdx_data.py +++ b/flit_core/flit_core/_spdx_data.py @@ -649,3 +649,80 @@ 'zpl-1.1': {'id': 'ZPL-1.1'}, 'zpl-2.0': {'id': 'ZPL-2.0'}, 'zpl-2.1': {'id': 'ZPL-2.1'}} +exceptions = \ +{'389-exception': {'id': '389-exception'}, + 'asterisk-exception': {'id': 'Asterisk-exception'}, + 'asterisk-linking-protocols-exception': {'id': 'Asterisk-linking-protocols-exception'}, + 'autoconf-exception-2.0': {'id': 'Autoconf-exception-2.0'}, + 'autoconf-exception-3.0': {'id': 'Autoconf-exception-3.0'}, + 'autoconf-exception-generic': {'id': 'Autoconf-exception-generic'}, + 'autoconf-exception-generic-3.0': {'id': 'Autoconf-exception-generic-3.0'}, + 'autoconf-exception-macro': {'id': 'Autoconf-exception-macro'}, + 'bison-exception-1.24': {'id': 'Bison-exception-1.24'}, + 'bison-exception-2.2': {'id': 'Bison-exception-2.2'}, + 'bootloader-exception': {'id': 'Bootloader-exception'}, + 'cgal-linking-exception': {'id': 'CGAL-linking-exception'}, + 'classpath-exception-2.0': {'id': 'Classpath-exception-2.0'}, + 'clisp-exception-2.0': {'id': 'CLISP-exception-2.0'}, + 'cryptsetup-openssl-exception': {'id': 'cryptsetup-OpenSSL-exception'}, + 'digirule-foss-exception': {'id': 'DigiRule-FOSS-exception'}, + 'ecos-exception-2.0': {'id': 'eCos-exception-2.0'}, + 'erlang-otp-linking-exception': {'id': 'erlang-otp-linking-exception'}, + 'fawkes-runtime-exception': {'id': 'Fawkes-Runtime-exception'}, + 'fltk-exception': {'id': 'FLTK-exception'}, + 'fmt-exception': {'id': 'fmt-exception'}, + 'font-exception-2.0': {'id': 'Font-exception-2.0'}, + 'freertos-exception-2.0': {'id': 'freertos-exception-2.0'}, + 'gcc-exception-2.0': {'id': 'GCC-exception-2.0'}, + 'gcc-exception-2.0-note': {'id': 'GCC-exception-2.0-note'}, + 'gcc-exception-3.1': {'id': 'GCC-exception-3.1'}, + 'gmsh-exception': {'id': 'Gmsh-exception'}, + 'gnat-exception': {'id': 'GNAT-exception'}, + 'gnome-examples-exception': {'id': 'GNOME-examples-exception'}, + 'gnu-compiler-exception': {'id': 'GNU-compiler-exception'}, + 'gnu-javamail-exception': {'id': 'gnu-javamail-exception'}, + 'gpl-3.0-389-ds-base-exception': {'id': 'GPL-3.0-389-ds-base-exception'}, + 'gpl-3.0-interface-exception': {'id': 'GPL-3.0-interface-exception'}, + 'gpl-3.0-linking-exception': {'id': 'GPL-3.0-linking-exception'}, + 'gpl-3.0-linking-source-exception': {'id': 'GPL-3.0-linking-source-exception'}, + 'gpl-cc-1.0': {'id': 'GPL-CC-1.0'}, + 'gstreamer-exception-2005': {'id': 'GStreamer-exception-2005'}, + 'gstreamer-exception-2008': {'id': 'GStreamer-exception-2008'}, + 'harbour-exception': {'id': 'harbour-exception'}, + 'i2p-gpl-java-exception': {'id': 'i2p-gpl-java-exception'}, + 'independent-modules-exception': {'id': 'Independent-modules-exception'}, + 'kicad-libraries-exception': {'id': 'KiCad-libraries-exception'}, + 'lgpl-3.0-linking-exception': {'id': 'LGPL-3.0-linking-exception'}, + 'libpri-openh323-exception': {'id': 'libpri-OpenH323-exception'}, + 'libtool-exception': {'id': 'Libtool-exception'}, + 'linux-syscall-note': {'id': 'Linux-syscall-note'}, + 'llgpl': {'id': 'LLGPL'}, + 'llvm-exception': {'id': 'LLVM-exception'}, + 'lzma-exception': {'id': 'LZMA-exception'}, + 'mif-exception': {'id': 'mif-exception'}, + 'mxml-exception': {'id': 'mxml-exception'}, + 'ocaml-lgpl-linking-exception': {'id': 'OCaml-LGPL-linking-exception'}, + 'occt-exception-1.0': {'id': 'OCCT-exception-1.0'}, + 'openjdk-assembly-exception-1.0': {'id': 'OpenJDK-assembly-exception-1.0'}, + 'openvpn-openssl-exception': {'id': 'openvpn-openssl-exception'}, + 'pcre2-exception': {'id': 'PCRE2-exception'}, + 'ps-or-pdf-font-exception-20170817': {'id': 'PS-or-PDF-font-exception-20170817'}, + 'qpl-1.0-inria-2004-exception': {'id': 'QPL-1.0-INRIA-2004-exception'}, + 'qt-gpl-exception-1.0': {'id': 'Qt-GPL-exception-1.0'}, + 'qt-lgpl-exception-1.1': {'id': 'Qt-LGPL-exception-1.1'}, + 'qwt-exception-1.0': {'id': 'Qwt-exception-1.0'}, + 'romic-exception': {'id': 'romic-exception'}, + 'rrdtool-floss-exception-2.0': {'id': 'RRDtool-FLOSS-exception-2.0'}, + 'sane-exception': {'id': 'SANE-exception'}, + 'shl-2.0': {'id': 'SHL-2.0'}, + 'shl-2.1': {'id': 'SHL-2.1'}, + 'stunnel-exception': {'id': 'stunnel-exception'}, + 'swi-exception': {'id': 'SWI-exception'}, + 'swift-exception': {'id': 'Swift-exception'}, + 'texinfo-exception': {'id': 'Texinfo-exception'}, + 'u-boot-exception-2.0': {'id': 'u-boot-exception-2.0'}, + 'ubdl-exception': {'id': 'UBDL-exception'}, + 'universal-foss-exception-1.0': {'id': 'Universal-FOSS-exception-1.0'}, + 'vsftpd-openssl-exception': {'id': 'vsftpd-openssl-exception'}, + 'wxwindows-exception-3.1': {'id': 'WxWindows-exception-3.1'}, + 'x11vnc-openssl-exception': {'id': 'x11vnc-openssl-exception'}} diff --git a/flit_core/flit_core/config.py b/flit_core/flit_core/config.py index e1e55b91..3f2c09d3 100644 --- a/flit_core/flit_core/config.py +++ b/flit_core/flit_core/config.py @@ -17,7 +17,7 @@ except ImportError: import tomli as tomllib -from ._spdx_data import licenses +from ._spdx_data import exceptions, licenses from .common import normalise_core_metadata_name from .versionno import normalise_version @@ -700,7 +700,7 @@ def normalise_compound_license_expr(s: str) -> str: """Validate and normalise a compound SPDX license expression. Per the specification, licence expression operators (AND, OR and WITH) - are matched case-sensitively. The WITH operator is not currently supported. + are matched case-sensitively. Spec: https://spdx.github.io/spdx-spec/v2.2.2/SPDX-license-expressions/ """ @@ -712,9 +712,15 @@ def normalise_compound_license_expr(s: str) -> str: parts = [] try: for part in filter(None, re.split(r' +|([()])', s)): - if part.upper() == 'WITH': - # provide a sensible error message for the WITH operator - raise ConfigError("The SPDX 'WITH' operator is not yet supported!") + if part == 'WITH': + if ( + not parts + or parts[-1] in {' AND ', ' OR ', ' WITH ', '('} + or parts[-1].lower() in exceptions + ): + reason = "a license ID is missing before 'WITH'" + raise ConfigError(invalid_msg.format(s=s, reason=reason)) + parts.append(' WITH ') elif part in {'AND', 'OR'}: if not parts or parts[-1] in {' AND ', ' OR ', ' WITH ', '('}: reason = f"a license ID is missing before '{part}'" @@ -740,10 +746,13 @@ def normalise_compound_license_expr(s: str) -> str: raise ConfigError(invalid_msg.format(s=s, reason=reason)) parts.append(part) else: - if parts and parts[-1] not in {' AND ', ' OR ', '('}: + if parts and parts[-1] not in {' AND ', ' OR ', ' WITH ', '('}: reason = "a license ID must follow either AND, OR, or '('" raise ConfigError(invalid_msg.format(s=s, reason=reason)) - simple_expr = normalise_simple_license_expr(part) + if parts and parts[-1] == ' WITH ': + simple_expr = normalise_license_exception(part) + else: + simple_expr = normalise_simple_license_expr(part) parts.append(simple_expr) if stack != 0: @@ -790,3 +799,11 @@ def normalise_simple_license_expr(s: str) -> str: if or_later: return f'{normalised_id}+' return normalised_id + + +def normalise_license_exception(s: str) -> str: + """Normalise an SPDX license exception ID.""" + try: + return exceptions[s.lower()]['id'] + except KeyError: + raise ConfigError(f"{s!r} is not a recognised SPDX license exception ID") diff --git a/flit_core/tests_core/test_config.py b/flit_core/tests_core/test_config.py index 5270696c..36b49196 100644 --- a/flit_core/tests_core/test_config.py +++ b/flit_core/tests_core/test_config.py @@ -218,6 +218,8 @@ def test_bad_pep621_readme(readme, err_match): ("MIT AND Apache-2.0", "MIT AND Apache-2.0"), ("MIT AND Apache-2.0+ OR 0BSD", "MIT AND Apache-2.0+ OR 0BSD"), ("MIT AND (Apache-2.0+ OR (0BSD))", "MIT AND (Apache-2.0+ OR (0BSD))"), + ("mit WITH classpath-exception-2.0", "MIT WITH Classpath-exception-2.0"), + ("MIT OR MIT WITH Classpath-exception-2.0", "MIT OR MIT WITH Classpath-exception-2.0"), ("MIT OR(mit)", "MIT OR (MIT)"), ("(mit)AND mit", "(MIT) AND MIT"), ("MIT OR (MIT OR ( MIT )) AND ((MIT) AND MIT) OR MIT", "MIT OR (MIT OR (MIT)) AND ((MIT) AND MIT) OR MIT"), @@ -342,6 +344,7 @@ def test_license_expr_error_empty(invalid_expr: str): "MIT and MIT", "MIT AND MIT or MIT", "MIT AND (MIT or MIT)", + "MIT with Classpath-exception-2.0", ]) def test_license_expr_error_lowercase(invalid_expr: str): proj = { @@ -360,14 +363,18 @@ def test_license_expr_error_lowercase(invalid_expr: str): "MIT WITH MIT-Exception", "(MIT WITH MIT-Exception)", "MIT OR MIT WITH MIT-Exception", + "MIT WITH Classpath-exception-2.0 WITH Autoconf-exception-2.0", "MIT WITH MIT-Exception OR (MIT AND MIT)", ]) -def test_license_expr_error_unsupported_with(invalid_expr: str): +def test_license_expr_error_with(invalid_expr: str): proj = { 'name': 'module1', 'version': '1.0', 'description': 'x', 'license': invalid_expr, } - with pytest.raises(config.ConfigError, match="not yet supported"): + with pytest.raises( + config.ConfigError, + match="(license ID is missing before|must be uppercase|not a recognised SPDX license exception ID)", + ): config.read_pep621_metadata(proj, samples_dir / 'pep621' / 'pyproject.toml') diff --git a/prepare_license_list.py b/prepare_license_list.py index 8f8476e5..49441bdd 100644 --- a/prepare_license_list.py +++ b/prepare_license_list.py @@ -9,15 +9,23 @@ list_data_repo = Path(sys.argv[1]) with (list_data_repo / 'json' / 'licenses.json').open('rb') as f: licenses_json = json.load(f) +with (list_data_repo / 'json' / 'exceptions.json').open('rb') as f: + exceptions_json = json.load(f) -condensed = { +licenses = { l['licenseId'].lower(): {'id': l['licenseId']} for l in licenses_json['licenses'] if not l['isDeprecatedLicenseId'] } +exceptions = { + e['licenseExceptionId'].lower(): {'id': e['licenseExceptionId']} + for e in exceptions_json['exceptions'] + if not e['isDeprecatedLicenseId'] +} with Path('flit_core', 'flit_core', '_spdx_data.py').open('w') as f: f.write("# This file is generated from SPDX license data; don't edit it manually.\n\n") - f.write("licenses = \\\n") - pprint.pprint(condensed, f) + for name, data in (("licenses", licenses), ("exceptions", exceptions)): + f.write(f"{name} = \\\n") + pprint.pprint(data, f)