From ea6875c4f227760488814141896ce7ba8d192f3d Mon Sep 17 00:00:00 2001 From: hannesdiedrich Date: Wed, 16 Sep 2026 09:32:05 +0200 Subject: [PATCH] RESEARCH-350: Add license check to CI and add a statement WRT license scan --- .github/workflows/license_check.yaml | 23 ++++ tools/LICENSE_STATEMENT.md | 67 +++++++++++ tools/check_licenses.py | 163 +++++++++++++++++++++++++++ tools/license_exceptions.txt | 26 +++++ 4 files changed, 279 insertions(+) create mode 100644 .github/workflows/license_check.yaml create mode 100644 tools/LICENSE_STATEMENT.md create mode 100644 tools/check_licenses.py create mode 100644 tools/license_exceptions.txt diff --git a/.github/workflows/license_check.yaml b/.github/workflows/license_check.yaml new file mode 100644 index 0000000000..3f58363b4f --- /dev/null +++ b/.github/workflows/license_check.yaml @@ -0,0 +1,23 @@ +name: gsy-e-license-check +on: + pull_request: + +jobs: + license-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.11 + + - name: Install dependencies + run: | + pip install --upgrade pip + pip install -r requirements/tests.txt + pip install pip-licenses + + - name: Check dependency licenses + run: python tools/check_licenses.py diff --git a/tools/LICENSE_STATEMENT.md b/tools/LICENSE_STATEMENT.md new file mode 100644 index 0000000000..4772f1776e --- /dev/null +++ b/tools/LICENSE_STATEMENT.md @@ -0,0 +1,67 @@ +# Statement: does any strong copyleft dependency reach this repository's source tree? + +Date: 2026-09-16 +Scope: `gsy-e` (this repository) only. +Author: dev@gridsingularity.com, assisted scan via `pip-licenses` + `tools/check_licenses.py`. + +## Question + +For the planned relicensing of `gsy-e`, does any strong-copyleft-licensed +third-party component reach source control (i.e. does any GPL/AGPL/SSPL/etc. +source end up committed or vendored into this repository), as opposed to +being used only as an external, separately-distributed tool? + +## Finding + +**No.** No strong-copyleft source code is vendored, copied, or otherwise +committed into this repository's source tree. + +Basis for this conclusion: + +1. **No vendoring.** All of `gsy-e`'s dependencies are resolved externally at + install time via `pip`/`requirements/*.txt` (or, for `gsy-framework` and + the optional `gsy-dex` extra, via `git+https` install from separate + repositories). A repo-wide search found no `vendor/`, `third_party/`, or + similar directory, and no embedded `LICENSE`/`COPYING` file anywhere under + `src/` — the two reliable signs of vendored third-party source. Every + third-party package lives in a virtualenv's `site-packages`, never inside + this repository's `src/` tree or its git history. + +2. **The three strong-copyleft packages present are all lint/dev tooling, + never installed as part of the shipped package.** `pylint`, + `pylint-plugin-utils` (GPL-2.0-or-later) and `pylint-pydantic` (GPLv3) are + declared only in `requirements/dev.txt` and `requirements/tests.txt`, not + in `requirements/base.txt` (the file that defines what actually ships with + `gsy-e`, per `setup.py`'s `REQUIREMENTS`). They run as external CLI + processes over the source during linting/CI and are never `import`ed by + any `gsy_e` module. See `tools/LICENSE_EXCEPTIONS.md` for the full + per-package reasoning. + +3. **The one strong-copyleft *label* on a runtime dependency is a + metadata artifact, not a real dual license.** `text-unidecode` (a + transitive runtime dependency of `python-slugify`, in `base.txt`) is + reported by `pip-licenses`' default mode as "Artistic License; GNU + General Public License (GPL); GNU General Public License v2 or later + (GPLv2+)" because that tool concatenates *all* PyPI trove classifiers. + Its actual package metadata `License` field — the authoritative, + package-author-declared value — is `Artistic License` only + (`pip-licenses --from=meta` confirms this). Artistic License is + permissive, not copyleft. + +4. **Every other copyleft hit is weak/file-level (LGPL, MPL), not strong.** + `psycopg2`/`psycopg2-binary`, `chardet` (LGPL) and `certifi` (MPL) are + runtime dependencies (`base.txt`); `astroid`, `paramiko` (LGPL) and + `hypothesis` (MPL) are dev/test-only. Weak copyleft's obligations attach + only to modifications of the library itself, not to code that merely + imports/links it, so these do not implicate this repository's own source + either way. + +## Conclusion + +On the evidence of this scan, no strong-copyleft component reaches this +repository's source control — nothing GPL/AGPL/SSPL-licensed is vendored, +committed, or otherwise part of the `gsy-e` git tree. The three strong-copyleft +packages that do appear (`pylint`, `pylint-plugin-utils`, `pylint-pydantic`) +are external dev/lint tools only, declared exclusively in +`requirements/dev.txt`/`tests.txt`, and are not installed with, linked into, +or shipped alongside the `gsy-e` product. diff --git a/tools/check_licenses.py b/tools/check_licenses.py new file mode 100644 index 0000000000..73ddb829b5 --- /dev/null +++ b/tools/check_licenses.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python +"""Fail if any installed dependency is under a strong copyleft license. + +Runs `pip-licenses` against the current Python environment and classifies +each dependency's license. Strong copyleft licenses (GPL, AGPL, SSPL, ...) +require derivative/linked works to be released under the same license and +are not compatible with this project's distribution model, so the script +exits non-zero if any are found. Permissive licenses (MIT, BSD, Apache, ...) +and weak/"file-level" copyleft licenses (LGPL, MPL, EPL) are allowed. + +Packages with license metadata that can't be classified (e.g. "UNKNOWN") +are printed as warnings for manual review but do not fail the build, since +that usually reflects missing/incomplete PyPI metadata rather than an +actual copyleft license. + +Known false positives can be silenced via an exceptions file (one package +name per line, '#' comments allowed) after manual review of the package's +actual license, see --exceptions. Every entry in the default exceptions +file (license_exceptions.txt) is documented in LICENSE_EXCEPTIONS.md. + +Requires the `pip-licenses` package (`pip install pip-licenses`). +""" +import argparse +import json +import subprocess +import sys +from pathlib import Path + +DEFAULT_EXCEPTIONS_FILE = Path(__file__).parent / "license_exceptions.txt" + +# Substrings are matched case-insensitively against the license string(s) +# reported by pip-licenses. Order matters: weak copyleft is checked first +# so that e.g. "LGPL" is not misclassified as strong "GPL" copyleft. +WEAK_COPYLEFT_MARKERS = ( + "LGPL", + "LESSER GENERAL PUBLIC", + "MPL", + "MOZILLA PUBLIC", + "EPL", + "ECLIPSE PUBLIC", +) + +STRONG_COPYLEFT_MARKERS = ( + "GPL", # also matches AGPL / GNU GENERAL PUBLIC LICENSE + "GENERAL PUBLIC LICENSE", + "SSPL", + "SERVER SIDE PUBLIC LICENSE", + "OSL", + "OPEN SOFTWARE LICENSE", + "EUPL", + "EUROPEAN UNION PUBLIC LICENCE", + "CECILL", + "RECIPROCAL PUBLIC LICENSE", + "CPAL", + "COMMON PUBLIC ATTRIBUTION", + "SLEEPYCAT", + "Q PUBLIC LICENSE", +) + +UNKNOWN_MARKERS = ("UNKNOWN", "") + + +def classify_license(license_str): + """Classify a license string as 'weak-copyleft', 'strong-copyleft', + 'unknown' or 'other' (permissive/unrestricted).""" + text = license_str.strip().upper() + if text in UNKNOWN_MARKERS: + return "unknown" + if any(marker in text for marker in WEAK_COPYLEFT_MARKERS): + return "weak-copyleft" + if any(marker in text for marker in STRONG_COPYLEFT_MARKERS): + return "strong-copyleft" + return "other" + + +def _run_pip_licenses(): + try: + output = subprocess.run( + ["pip-licenses", "--format=json", "--with-system"], + check=True, + capture_output=True, + text=True, + ).stdout + except FileNotFoundError as ex: + raise SystemExit( + "pip-licenses is not installed. Install it with `pip install pip-licenses`." + ) from ex + except subprocess.CalledProcessError as ex: + raise SystemExit(f"pip-licenses failed:\n{ex.stderr}") from ex + return json.loads(output) + + +def _load_exceptions(exceptions_file): + if not exceptions_file.exists(): + return set() + lines = exceptions_file.read_text().splitlines() + return { + line.strip().lower() for line in lines if line.strip() and not line.strip().startswith("#") + } + + +def main(): + """Main method for the scan""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--exceptions", + type=Path, + default=DEFAULT_EXCEPTIONS_FILE, + help="Path to a file listing package names to exclude from the check " + "(one per line, after manual license review).", + ) + args = parser.parse_args() + + exceptions = _load_exceptions(args.exceptions) + packages = _run_pip_licenses() + + strong_copyleft = [] + unknown = [] + exempted = [] + + for package in packages: + name = package["Name"] + license_str = package["License"] + if name.lower() in exceptions: + exempted.append((name, license_str)) + continue + classification = classify_license(license_str) + if classification == "strong-copyleft": + strong_copyleft.append((name, package["Version"], license_str)) + elif classification == "unknown": + unknown.append((name, package["Version"])) + + if unknown: + print( + f"WARNING: {len(unknown)} package(s) with unresolved license metadata " + "(please review manually):" + ) + for name, version in sorted(unknown): + print(f" - {name}=={version}") + print() + + if exempted: + print(f"NOTE: {len(exempted)} package(s) exempted via {args.exceptions}:") + for name, license_str in sorted(exempted): + print(f" - {name}: {license_str}") + print() + + if strong_copyleft: + print(f"FAIL: {len(strong_copyleft)} package(s) under a strong copyleft license:") + for name, version, license_str in sorted(strong_copyleft): + print(f" - {name}=={version}: {license_str}") + print( + "\nIf a package is misclassified (e.g. dual-licensed under a permissive " + f"license too), add it to {args.exceptions} after manual review." + ) + return 1 + + print(f"OK: no strong copyleft licenses found among {len(packages)} packages.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/license_exceptions.txt b/tools/license_exceptions.txt new file mode 100644 index 0000000000..28335d725f --- /dev/null +++ b/tools/license_exceptions.txt @@ -0,0 +1,26 @@ +# License-check exceptions for tools/check_licenses.py +# +# One package name per line (case-insensitive), '#' starts a comment. +# Every entry here must be backed by a reviewed, documented reason in +# tools/LICENSE_EXCEPTIONS.md. Do not add a package here without adding +# the matching row there first. +# +# Reviewed: 2026-09-16, dev@gridsingularity.com + +# GPL-licensed static-analysis/lint tooling. Declared only in +# requirements/dev.txt and requirements/tests.txt (never in base.txt), +# invoked as a standalone CLI during development/CI, not imported by any +# gsy-e module, and not installed as part of the shipped package. See +# "Dev/test-only tooling" in tools/LICENSE_EXCEPTIONS.md. +pylint +pylint-plugin-utils +pylint-pydantic + +# pip-licenses reports this as "Artistic License; GNU General Public +# License (GPL); GNU General Public License v2 or later (GPLv2+)" because +# it concatenates all PyPI trove classifiers. The package's actual +# declared License metadata field is "Artistic License" (permissive), +# confirmed with `pip-licenses --from=meta`. Runtime dependency, pulled in +# by python-slugify (requirements/base.txt). See "Misclassified / +# multi-classifier packages" in tools/LICENSE_EXCEPTIONS.md. +text-unidecode