diff --git a/.github/scripts/check_licenses.py b/.github/scripts/check_licenses.py new file mode 100644 index 0000000..8abb807 --- /dev/null +++ b/.github/scripts/check_licenses.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +""" +Check that every installed dependency is distributed under an allowed license. + +Run it with the interpreter of an environment that holds the project, its +dependencies and nothing else: + + python -m venv ../license-check + ../license-check/bin/python -m pip install . + ../license-check/bin/python .github/scripts/check_licenses.py + +Every license a distribution declares must be allowed: the License-Expression, +each "License ::" classifier, and the free-text License field. A distribution +that declares none fails. Operators in an expression are not interpreted, so +"A OR B" needs both A and B to be allowed. + +A distribution that fails and has been reviewed by hand goes in EXCEPTIONS, +together with the licenses it declared at the time. If those change, the check +fails again, so the review is repeated. +""" + +import importlib.metadata +import re +import sys + +PROJECT = "cryptnox-id-cli" + +ALLOWED_SPDX = { + "0BSD", + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "CNRI-Python", + "ISC", + "MIT", + "MIT-0", + "MPL-2.0", + "PSF-2.0", + "Python-2.0", + "Unlicense", + "Zlib", +} + +ALLOWED_CLASSIFIERS = { + "Apache Software License", + "BSD License", + "ISC License (ISCL)", + "MIT License", + "MIT No Attribution License (MIT-0)", + "Mozilla Public License 2.0 (MPL 2.0)", + "Public Domain", + "Python Software Foundation License", + "Repoze Public License", + "The Unlicense (Unlicense)", + "zlib/libpng License", +} + +# Values of the free-text License field that are not SPDX expressions. +ALLOWED_TEXT = { + "Apache 2.0", + "Apache License 2.0", + "BSD, Public Domain", + "BSD-derived (http://www.repoze.org/LICENSE.txt)", + "BSD-like (http://repoze.org/license.html)", + "Python Software Foundation License", +} + +# Reviewed by hand: name -> (licenses declared at review time, reason). +EXCEPTIONS = { + "pyscard": ( + {"GNU Lesser General Public License v2 or later (LGPLv2+)"}, + "used as a library, unmodified", + ), +} + +# Installers, not dependencies of the project. +TOOLING = {"pip", "setuptools", "wheel"} + +OPERATORS = {"AND", "OR", "WITH"} + + +def normalize(name): + """Distribution name in the canonical form of PEP 503.""" + return re.sub(r"[-_.]+", "-", name).lower() + + +def declared_licenses(metadata): + """(source, value) for every license a distribution declares.""" + found = [] + expression = (metadata.get("License-Expression") or "").strip() + if expression: + found.append(("expression", expression)) + for classifier in metadata.get_all("Classifier") or []: + parts = [part.strip() for part in classifier.split("::")] + # "License :: OSI Approved" on its own names no license + if len(parts) > 1 and parts[0] == "License" and parts[-1] != "OSI Approved": + found.append(("classifier", parts[-1])) + text = (metadata.get("License") or "").strip() + if text and text.upper() != "UNKNOWN": + found.append(("field", text)) + return found + + +def spdx_allowed(expression): + """True when every identifier in an SPDX-style expression is allowed.""" + allowed = {identifier.lower() for identifier in ALLOWED_SPDX} + identifiers = [ + token for token in re.findall(r"[^\s()]+", expression) if token.upper() not in OPERATORS + ] + return bool(identifiers) and all(token.lower() in allowed for token in identifiers) + + +def is_allowed(source, value): + if source == "classifier": + return value in ALLOWED_CLASSIFIERS + if source == "field" and value in ALLOWED_TEXT: + return True + return spdx_allowed(value) + + +def installed_distributions(path=None): + """One distribution per name; the first found on the path wins, as for imports.""" + distributions = {} + for distribution in importlib.metadata.distributions(path=path or sys.path): + name = distribution.metadata["Name"] + if name: + distributions.setdefault(normalize(name), distribution) + return distributions + + +def check(distributions): + """Returns (report lines, failure count, notices).""" + lines, failures, notices = [], 0, [] + exceptions = {normalize(name): entry for name, entry in EXCEPTIONS.items()} + for key in sorted(distributions): + if key in TOOLING or key == normalize(PROJECT): + continue + distribution = distributions[key] + label = f"{distribution.metadata['Name']} {distribution.version}" + licenses = declared_licenses(distribution.metadata) + shown = "; ".join(value.splitlines()[0][:60] for _, value in licenses) or "none declared" + rejected = [value for source, value in licenses if not is_allowed(source, value)] + + if key in exceptions: + recorded, reason = exceptions[key] + if {value for _, value in licenses} == recorded: + lines.append(f"ok {label:<36} {shown} (reviewed: {reason})") + if licenses and not rejected: + notices.append(f"{label} passes without its exception; the entry can go") + else: + failures += 1 + was = "; ".join(sorted(recorded)) or "none" + lines.append( + f"FAILED {label:<36} {shown} " + f"(declared licenses changed since review; recorded: {was})" + ) + elif not licenses: + failures += 1 + lines.append(f"FAILED {label:<36} declares no license") + elif rejected: + failures += 1 + lines.append(f"FAILED {label:<36} {shown} (not allowed: {'; '.join(rejected)})") + else: + lines.append(f"ok {label:<36} {shown}") + + for name in sorted(exceptions): + if name not in distributions: + notices.append( + f"exception for {name} matches no installed distribution; the entry can go" + ) + return lines, failures, notices + + +def main(argv): + path = [argv[argv.index("--path") + 1]] if "--path" in argv else None + distributions = installed_distributions(path) + lines, failures, notices = check(distributions) + print("\n".join(lines)) + for notice in notices: + print("notice: " + notice) + checked = len(lines) + if failures: + print( + f"\n{failures} of {checked} distributions have a license that is not allowed. " + "Replace the dependency, or review its license and add it to ALLOWED_* or " + f"EXCEPTIONS in {__file__}." + ) + return 1 + print(f"\nAll {checked} distributions have allowed licenses.") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/.github/workflows/licenses.yml b/.github/workflows/licenses.yml new file mode 100644 index 0000000..5f253bf --- /dev/null +++ b/.github/workflows/licenses.yml @@ -0,0 +1,26 @@ +name: Dependency licenses + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + licenses: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential swig libpcsclite-dev + # A separate environment, so the check sees what the package pulls in and + # nothing that happens to be installed on the runner + - run: python -m venv ../license-check + - run: ../license-check/bin/python -m pip install --upgrade pip + - run: ../license-check/bin/python -m pip install . + - run: ../license-check/bin/python .github/scripts/check_licenses.py