Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/license_check.yaml
Original file line number Diff line number Diff line change
@@ -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
67 changes: 67 additions & 0 deletions tools/LICENSE_STATEMENT.md
Original file line number Diff line number Diff line change
@@ -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.
163 changes: 163 additions & 0 deletions tools/check_licenses.py
Original file line number Diff line number Diff line change
@@ -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())
26 changes: 26 additions & 0 deletions tools/license_exceptions.txt
Original file line number Diff line number Diff line change
@@ -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
Loading