diff --git a/pyproject.toml b/pyproject.toml index 2550c5b..e7c901c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ Download = "https://pypi.org/project/validate-pyproject/#files" [project.optional-dependencies] all = [ - "packaging>=20.4", + "packaging>=24.2", "tomli>=1.2.1; python_version<'3.11'", "trove-classifiers>=2021.10.20", ] diff --git a/src/validate_pyproject/formats.py b/src/validate_pyproject/formats.py index 39320a8..1cf4a46 100644 --- a/src/validate_pyproject/formats.py +++ b/src/validate_pyproject/formats.py @@ -378,7 +378,25 @@ def int(value: builtins.int) -> bool: return -(2**63) <= value < 2**63 -def SPDX(value: str) -> bool: - """Should validate eventually""" - # TODO: validate conditional to the presence of (the right version) of packaging - return True +try: + from packaging import licenses as _licenses + + def SPDX(value: str) -> bool: + """See :ref:`PyPA's License-Expression specification + ` (added in :pep:`639`). + """ + try: + _licenses.canonicalize_license_expression(value) + return True + except _licenses.InvalidLicenseExpression: + return False + +except ImportError: # pragma: no cover + _logger.warning( + "Could not find an up-to-date installation of `packaging`. " + "License expressions might not be validated. " + "To enforce validation, please install `packaging>=24.2`." + ) + + def SPDX(value: str) -> bool: + return True diff --git a/tests/examples/simple/pep638.toml b/tests/examples/simple/pep639.toml similarity index 100% rename from tests/examples/simple/pep638.toml rename to tests/examples/simple/pep639.toml diff --git a/tests/invalid-examples/simple/pep639.errors.txt b/tests/invalid-examples/simple/pep639.errors.txt new file mode 100644 index 0000000..e371926 --- /dev/null +++ b/tests/invalid-examples/simple/pep639.errors.txt @@ -0,0 +1 @@ +`project.license` must be valid exactly by one definition (0 matches found) diff --git a/tests/invalid-examples/simple/pep639.toml b/tests/invalid-examples/simple/pep639.toml new file mode 100644 index 0000000..8b30929 --- /dev/null +++ b/tests/invalid-examples/simple/pep639.toml @@ -0,0 +1,5 @@ +[project] +name = "example" +version = "1.2.3" +license = "Apache Software License" # should be "Apache-2.0" +license-files = ["licenses/LICENSE"] diff --git a/tests/test_formats.py b/tests/test_formats.py index bd6965f..cdff3ee 100644 --- a/tests/test_formats.py +++ b/tests/test_formats.py @@ -293,6 +293,54 @@ def test_invalid_module_name_relaxed(example): assert formats.python_module_name_relaxed(example) is False +@pytest.mark.parametrize( + "example", + [ + "MIT", + "Bsd-3-clause", + "mit and (apache-2.0 or bsd-2-clause)", + "MIT OR GPL-2.0-or-later OR (FSFUL AND BSD-2-Clause)", + "GPL-3.0-only WITH Classpath-exception-2.0 OR BSD-3-Clause", + "LicenseRef-Special-License OR CC0-1.0 OR Unlicense", + "LicenseRef-Public-Domain", + "licenseref-proprietary", + "LicenseRef-Beerware-4.2", + "(LicenseRef-Special-License OR LicenseRef-OtherLicense) OR Unlicense", + ], +) +def test_valid_pep639_license_expression(example): + assert formats.SPDX(example) is True + + +@pytest.mark.parametrize( + "example", + [ + "", + "Use-it-after-midnight", + "LicenseRef-License with spaces", + "LicenseRef-License_with_underscores", + "or", + "and", + "with", + "mit or", + "mit and", + "mit with", + "or mit", + "and mit", + "with mit", + "(mit", + "mit)", + "mit or or apache-2.0", + # Missing an operator before `(`. + "mit or apache-2.0 (bsd-3-clause and MPL-2.0)", + # "2-BSD-Clause is not a valid license. + "Apache-2.0 OR 2-BSD-Clause", + ], +) +def test_invalid_pep639_license_expression(example): + assert formats.SPDX(example) is False + + class TestClassifiers: """The ``_TroveClassifier`` class and ``_download_classifiers`` are part of the private API and therefore need to be tested.