license-radar 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 license-radar contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: license-radar
3
+ Version: 0.1.0
4
+ Summary: Scan project dependency manifests for license compliance risk (GPL/AGPL/LGPL exposure) before it becomes a legal problem.
5
+ Author: license-radar
6
+ License: MIT
7
+ Keywords: license,compliance,sca,dependencies,security,gpl,audit
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Topic :: Software Development :: Quality Assurance
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Dynamic: license-file
15
+
16
+ # license-radar
17
+
18
+ Scan a project's dependency manifests (`requirements.txt`, `pyproject.toml`,
19
+ `package.json`) for license compliance risk before a GPL/AGPL dependency
20
+ turns into a legal problem for a closed-source product.
21
+
22
+ ## Why
23
+
24
+ Pulling in a GPL- or AGPL-licensed dependency can obligate a company to open
25
+ its own source, or expose it to a lawsuit — and it usually happens by
26
+ accident, several dependency layers deep. Existing SCA/security scanners
27
+ focus on vulnerabilities; license risk is often an afterthought bolted onto
28
+ an expensive enterprise product. This is a small, focused tool that does
29
+ just the license check, fast, in CI.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install license-radar
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```bash
40
+ license-radar scan . # scan a directory (auto-detects manifests)
41
+ license-radar scan requirements.txt # scan a single manifest
42
+ license-radar scan . --online # also query PyPI/npm for packages not in the local DB
43
+ license-radar scan . --json # machine-readable output for CI
44
+ license-radar scan . --policy policy.json
45
+ ```
46
+
47
+ Exit code is `1` if any dependency violates the policy (useful as a CI gate),
48
+ `0` otherwise.
49
+
50
+ ### Policy
51
+
52
+ By default, any `strong-copyleft` (GPL/AGPL/SSPL) or `unknown` license is a
53
+ violation. Override with a JSON file:
54
+
55
+ ```json
56
+ {
57
+ "fail_at_or_above": "weak-copyleft",
58
+ "treat_unknown_as_violation": false
59
+ }
60
+ ```
61
+
62
+ ## How it classifies
63
+
64
+ Licenses are normalized to an SPDX id and bucketed into four tiers:
65
+ `permissive` < `weak-copyleft` < `strong-copyleft` < `unknown`. See
66
+ `license_radar/classify.py` for the exact lists.
67
+
68
+ ## Limitations
69
+
70
+ The offline database (`license_radar/license_db.py`) is a small, hand-curated
71
+ table of common packages, not a registry mirror — use `--online` for full
72
+ coverage against live PyPI/npm metadata (adds a network dependency and is
73
+ not covered by the deterministic test suite).
74
+
75
+ ## License
76
+
77
+ MIT
@@ -0,0 +1,62 @@
1
+ # license-radar
2
+
3
+ Scan a project's dependency manifests (`requirements.txt`, `pyproject.toml`,
4
+ `package.json`) for license compliance risk before a GPL/AGPL dependency
5
+ turns into a legal problem for a closed-source product.
6
+
7
+ ## Why
8
+
9
+ Pulling in a GPL- or AGPL-licensed dependency can obligate a company to open
10
+ its own source, or expose it to a lawsuit — and it usually happens by
11
+ accident, several dependency layers deep. Existing SCA/security scanners
12
+ focus on vulnerabilities; license risk is often an afterthought bolted onto
13
+ an expensive enterprise product. This is a small, focused tool that does
14
+ just the license check, fast, in CI.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install license-radar
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```bash
25
+ license-radar scan . # scan a directory (auto-detects manifests)
26
+ license-radar scan requirements.txt # scan a single manifest
27
+ license-radar scan . --online # also query PyPI/npm for packages not in the local DB
28
+ license-radar scan . --json # machine-readable output for CI
29
+ license-radar scan . --policy policy.json
30
+ ```
31
+
32
+ Exit code is `1` if any dependency violates the policy (useful as a CI gate),
33
+ `0` otherwise.
34
+
35
+ ### Policy
36
+
37
+ By default, any `strong-copyleft` (GPL/AGPL/SSPL) or `unknown` license is a
38
+ violation. Override with a JSON file:
39
+
40
+ ```json
41
+ {
42
+ "fail_at_or_above": "weak-copyleft",
43
+ "treat_unknown_as_violation": false
44
+ }
45
+ ```
46
+
47
+ ## How it classifies
48
+
49
+ Licenses are normalized to an SPDX id and bucketed into four tiers:
50
+ `permissive` < `weak-copyleft` < `strong-copyleft` < `unknown`. See
51
+ `license_radar/classify.py` for the exact lists.
52
+
53
+ ## Limitations
54
+
55
+ The offline database (`license_radar/license_db.py`) is a small, hand-curated
56
+ table of common packages, not a registry mirror — use `--online` for full
57
+ coverage against live PyPI/npm metadata (adds a network dependency and is
58
+ not covered by the deterministic test suite).
59
+
60
+ ## License
61
+
62
+ MIT
@@ -0,0 +1,3 @@
1
+ """license-radar: scan project dependency manifests for license compliance risk."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,68 @@
1
+ """Classify an SPDX license identifier into a compliance risk tier."""
2
+
3
+ PERMISSIVE = {
4
+ "MIT", "ISC", "BSD-2-Clause", "BSD-3-Clause", "Apache-2.0", "HPND",
5
+ "Python-2.0", "Unlicense", "0BSD", "Zlib", "BSL-1.0",
6
+ }
7
+
8
+ WEAK_COPYLEFT = {
9
+ "MPL-2.0", "LGPL-2.1-only", "LGPL-2.1-or-later",
10
+ "LGPL-3.0-only", "LGPL-3.0-or-later", "EPL-1.0", "EPL-2.0",
11
+ }
12
+
13
+ STRONG_COPYLEFT = {
14
+ "GPL-2.0-only", "GPL-2.0-or-later", "GPL-3.0-only", "GPL-3.0-or-later",
15
+ "AGPL-3.0-only", "AGPL-3.0-or-later", "SSPL-1.0",
16
+ }
17
+
18
+ TIER_PERMISSIVE = "permissive"
19
+ TIER_WEAK_COPYLEFT = "weak-copyleft"
20
+ TIER_STRONG_COPYLEFT = "strong-copyleft"
21
+ TIER_UNKNOWN = "unknown"
22
+
23
+ _RANK = {
24
+ TIER_PERMISSIVE: 0,
25
+ TIER_WEAK_COPYLEFT: 1,
26
+ TIER_STRONG_COPYLEFT: 2,
27
+ TIER_UNKNOWN: 3,
28
+ }
29
+
30
+ # Loose aliases seen in free-text "license" fields returned by registry
31
+ # APIs (as opposed to a strict SPDX id from our own curated DB).
32
+ _ALIASES = {
33
+ "MIT LICENSE": "MIT",
34
+ "BSD": "BSD-3-Clause",
35
+ "BSD LICENSE": "BSD-3-Clause",
36
+ "APACHE SOFTWARE LICENSE": "Apache-2.0",
37
+ "APACHE 2.0": "Apache-2.0",
38
+ "GPLV3": "GPL-3.0-only",
39
+ "GPL V3": "GPL-3.0-only",
40
+ "GPL-3.0": "GPL-3.0-only",
41
+ "GPL-2.0": "GPL-2.0-only",
42
+ "LGPLV3": "LGPL-3.0-only",
43
+ "LGPL-3.0": "LGPL-3.0-only",
44
+ "AGPLV3": "AGPL-3.0-only",
45
+ "AGPL-3.0": "AGPL-3.0-only",
46
+ }
47
+
48
+
49
+ def _normalize(spdx: str) -> str:
50
+ key = spdx.strip().upper()
51
+ return _ALIASES.get(key, spdx.strip())
52
+
53
+
54
+ def classify_license(spdx: str | None) -> str:
55
+ if not spdx:
56
+ return TIER_UNKNOWN
57
+ normalized = _normalize(spdx)
58
+ if normalized in PERMISSIVE:
59
+ return TIER_PERMISSIVE
60
+ if normalized in WEAK_COPYLEFT:
61
+ return TIER_WEAK_COPYLEFT
62
+ if normalized in STRONG_COPYLEFT:
63
+ return TIER_STRONG_COPYLEFT
64
+ return TIER_UNKNOWN
65
+
66
+
67
+ def tier_rank(tier: str) -> int:
68
+ return _RANK.get(tier, _RANK[TIER_UNKNOWN])
@@ -0,0 +1,68 @@
1
+ """Command-line entry point: `license-radar scan <path>`."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from license_radar.policy import load_policy, violates
9
+ from license_radar.scanner import scan_path
10
+
11
+
12
+ def build_parser() -> argparse.ArgumentParser:
13
+ parser = argparse.ArgumentParser(prog="license-radar")
14
+ subparsers = parser.add_subparsers(dest="command", required=True)
15
+
16
+ scan = subparsers.add_parser("scan", help="Scan a project for license risk")
17
+ scan.add_argument("path", type=Path, help="Directory or manifest file to scan")
18
+ scan.add_argument("--policy", type=Path, default=None, help="Path to a JSON policy file")
19
+ scan.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
20
+ scan.add_argument(
21
+ "--online", action="store_true",
22
+ help="Query PyPI/npm registries for packages missing from the local DB",
23
+ )
24
+
25
+ return parser
26
+
27
+
28
+ def run_scan(args) -> int:
29
+ findings = scan_path(args.path, online=args.online)
30
+ policy = load_policy(args.policy)
31
+
32
+ rows = [
33
+ {
34
+ "ecosystem": f.ecosystem,
35
+ "package": f.package,
36
+ "license": f.license or "UNKNOWN",
37
+ "tier": f.tier,
38
+ "violation": violates(f, policy),
39
+ }
40
+ for f in findings
41
+ ]
42
+ has_violation = any(row["violation"] for row in rows)
43
+
44
+ if args.json:
45
+ print(json.dumps({"findings": rows, "violation": has_violation}, indent=2))
46
+ else:
47
+ if not rows:
48
+ print("No dependencies found.")
49
+ for row in rows:
50
+ flag = " !!" if row["violation"] else ""
51
+ print(f"[{row['ecosystem']}] {row['package']}: {row['license']} ({row['tier']}){flag}")
52
+ print()
53
+ print("VIOLATIONS FOUND" if has_violation else "OK — no policy violations")
54
+
55
+ return 1 if has_violation else 0
56
+
57
+
58
+ def main(argv=None) -> int:
59
+ parser = build_parser()
60
+ args = parser.parse_args(argv)
61
+ if args.command == "scan":
62
+ return run_scan(args)
63
+ parser.print_help()
64
+ return 1
65
+
66
+
67
+ if __name__ == "__main__":
68
+ sys.exit(main())
@@ -0,0 +1,88 @@
1
+ """Static package -> SPDX license lookup table.
2
+
3
+ This is a small, hand-curated offline database used when no network lookup
4
+ is available (or desired, e.g. in CI). Entries are keyed by
5
+ ``(ecosystem, normalized_package_name)``. Ecosystem is one of "pypi" or
6
+ "npm". Package names are lowercased and, for pypi, normalized by replacing
7
+ ``_``/``.`` with ``-`` per PEP 503.
8
+
9
+ A handful of entries with the ``test-`` prefix are synthetic fixtures used
10
+ only by the test suite to exercise the strong-copyleft / unknown-license
11
+ code paths without asserting real-world facts about a live package's
12
+ license.
13
+ """
14
+
15
+ PYPI_LICENSES = {
16
+ "requests": "Apache-2.0",
17
+ "flask": "BSD-3-Clause",
18
+ "django": "BSD-3-Clause",
19
+ "numpy": "BSD-3-Clause",
20
+ "pandas": "BSD-3-Clause",
21
+ "pytest": "MIT",
22
+ "click": "BSD-3-Clause",
23
+ "pyyaml": "MIT",
24
+ "sqlalchemy": "MIT",
25
+ "celery": "BSD-3-Clause",
26
+ "scrapy": "BSD-3-Clause",
27
+ "twisted": "MIT",
28
+ "pillow": "HPND",
29
+ "setuptools": "MIT",
30
+ "wheel": "MIT",
31
+ "six": "MIT",
32
+ "certifi": "MPL-2.0",
33
+ "idna": "BSD-3-Clause",
34
+ "urllib3": "MIT",
35
+ "jinja2": "BSD-3-Clause",
36
+ "markupsafe": "BSD-3-Clause",
37
+ "itsdangerous": "BSD-3-Clause",
38
+ "werkzeug": "BSD-3-Clause",
39
+ "gunicorn": "MIT",
40
+ "redis": "MIT",
41
+ "boto3": "Apache-2.0",
42
+ "botocore": "Apache-2.0",
43
+ "pyqt5": "GPL-3.0-only",
44
+ "psycopg2": "LGPL-3.0-only",
45
+ "paramiko": "LGPL-2.1-only",
46
+ "chardet": "LGPL-2.1-only",
47
+ # synthetic fixtures (not a claim about any real package)
48
+ "test-strong-copyleft-pkg": "AGPL-3.0-only",
49
+ "test-weak-copyleft-pkg": "MPL-2.0",
50
+ "test-permissive-pkg": "MIT",
51
+ "test-proprietary-pkg": "LicenseRef-Proprietary",
52
+ }
53
+
54
+ NPM_LICENSES = {
55
+ "react": "MIT",
56
+ "lodash": "MIT",
57
+ "express": "MIT",
58
+ "axios": "MIT",
59
+ "moment": "MIT",
60
+ "chalk": "MIT",
61
+ "commander": "MIT",
62
+ "webpack": "MIT",
63
+ "eslint": "MIT",
64
+ "typescript": "Apache-2.0",
65
+ "rxjs": "Apache-2.0",
66
+ "core-js": "MIT",
67
+ "sqlite3": "BSD-3-Clause",
68
+ # synthetic fixtures (not a claim about any real package)
69
+ "test-strong-copyleft-pkg": "GPL-3.0-only",
70
+ "test-weak-copyleft-pkg": "LGPL-3.0-only",
71
+ "test-permissive-pkg": "ISC",
72
+ }
73
+
74
+ _DB = {
75
+ "pypi": PYPI_LICENSES,
76
+ "npm": NPM_LICENSES,
77
+ }
78
+
79
+
80
+ def normalize_pypi_name(name: str) -> str:
81
+ return name.strip().lower().replace("_", "-").replace(".", "-")
82
+
83
+
84
+ def lookup(ecosystem: str, name: str) -> str | None:
85
+ """Return the SPDX license id for a package, or None if unknown."""
86
+ table = _DB.get(ecosystem, {})
87
+ key = normalize_pypi_name(name) if ecosystem == "pypi" else name.strip().lower()
88
+ return table.get(key)
@@ -0,0 +1,72 @@
1
+ """Extract dependency names from common manifest file formats."""
2
+
3
+ import json
4
+ import re
5
+ import tomllib
6
+ from pathlib import Path
7
+
8
+ _REQ_LINE_RE = re.compile(r"^\s*([A-Za-z0-9._-]+)")
9
+
10
+
11
+ def parse_requirements_txt(path: Path) -> list[str]:
12
+ names = []
13
+ for raw_line in path.read_text().splitlines():
14
+ line = raw_line.split("#", 1)[0].strip()
15
+ if not line or line.startswith(("-", "--")):
16
+ continue
17
+ match = _REQ_LINE_RE.match(line)
18
+ if match:
19
+ names.append(match.group(1))
20
+ return names
21
+
22
+
23
+ def parse_pyproject_toml(path: Path) -> list[str]:
24
+ data = tomllib.loads(path.read_text())
25
+ names = []
26
+
27
+ project_deps = data.get("project", {}).get("dependencies", [])
28
+ for dep in project_deps:
29
+ match = _REQ_LINE_RE.match(dep.strip())
30
+ if match:
31
+ names.append(match.group(1))
32
+
33
+ poetry_deps = (
34
+ data.get("tool", {}).get("poetry", {}).get("dependencies", {})
35
+ )
36
+ for name in poetry_deps:
37
+ if name.lower() != "python":
38
+ names.append(name)
39
+
40
+ return names
41
+
42
+
43
+ def parse_package_json(path: Path) -> list[str]:
44
+ data = json.loads(path.read_text())
45
+ names = []
46
+ for section in ("dependencies", "devDependencies"):
47
+ names.extend(data.get(section, {}).keys())
48
+ return names
49
+
50
+
51
+ def _matches_manifest(name: str) -> tuple[str, object] | None:
52
+ if name == "pyproject.toml":
53
+ return "pypi", parse_pyproject_toml
54
+ if name == "package.json":
55
+ return "npm", parse_package_json
56
+ if name.startswith("requirements") and name.endswith(".txt"):
57
+ return "pypi", parse_requirements_txt
58
+ return None
59
+
60
+
61
+ def discover_manifests(root: Path) -> list[Path]:
62
+ if root.is_file():
63
+ return [root] if _matches_manifest(root.name) else []
64
+ return sorted(p for p in root.iterdir() if p.is_file() and _matches_manifest(p.name))
65
+
66
+
67
+ def parse_manifest(path: Path) -> tuple[str, list[str]]:
68
+ match = _matches_manifest(path.name)
69
+ if match is None:
70
+ raise ValueError(f"unrecognized manifest file: {path.name}")
71
+ ecosystem, parser = match
72
+ return ecosystem, parser(path)
@@ -0,0 +1,31 @@
1
+ """Decide pass/fail for a scan based on a configurable risk policy.
2
+
3
+ The default policy fails a scan (non-zero exit code) if any dependency
4
+ falls into the "strong-copyleft" or "unknown" tier. This mirrors the most
5
+ common compliance requirement for closed-source commercial projects.
6
+ """
7
+
8
+ import json
9
+ from pathlib import Path
10
+
11
+ from license_radar.classify import tier_rank
12
+
13
+ DEFAULT_POLICY = {
14
+ "fail_at_or_above": "strong-copyleft",
15
+ "treat_unknown_as_violation": True,
16
+ }
17
+
18
+
19
+ def load_policy(path: Path | None) -> dict:
20
+ if path is None:
21
+ return dict(DEFAULT_POLICY)
22
+ policy = dict(DEFAULT_POLICY)
23
+ policy.update(json.loads(path.read_text()))
24
+ return policy
25
+
26
+
27
+ def violates(finding, policy: dict) -> bool:
28
+ if finding.tier == "unknown":
29
+ return bool(policy.get("treat_unknown_as_violation", True))
30
+ threshold = policy.get("fail_at_or_above", "strong-copyleft")
31
+ return tier_rank(finding.tier) >= tier_rank(threshold)
@@ -0,0 +1,58 @@
1
+ """Optional live license lookup against public registry APIs.
2
+
3
+ Used only when the caller passes --online, since it requires network
4
+ access and is therefore excluded from the deterministic offline test
5
+ suite. Falls back to None (unknown) on any network or parsing error —
6
+ a scan should degrade gracefully, not crash, when a registry is
7
+ unreachable.
8
+ """
9
+
10
+ import json
11
+ import urllib.parse
12
+ import urllib.request
13
+
14
+ _TIMEOUT = 5.0
15
+
16
+
17
+ def fetch_pypi_license(name: str) -> str | None:
18
+ url = f"https://pypi.org/pypi/{name}/json"
19
+ try:
20
+ with urllib.request.urlopen(url, timeout=_TIMEOUT) as resp:
21
+ data = json.loads(resp.read())
22
+ except (OSError, ValueError):
23
+ return None
24
+
25
+ info = data.get("info", {})
26
+ license_field = (info.get("license") or "").strip()
27
+ if license_field and len(license_field) < 40:
28
+ return license_field
29
+
30
+ for classifier in info.get("classifiers", []):
31
+ if classifier.startswith("License :: OSI Approved ::"):
32
+ return classifier.rsplit("::", 1)[-1].strip()
33
+
34
+ return None
35
+
36
+
37
+ def fetch_npm_license(name: str) -> str | None:
38
+ url = f"https://registry.npmjs.org/{urllib.parse.quote(name, safe='')}"
39
+ try:
40
+ with urllib.request.urlopen(url, timeout=_TIMEOUT) as resp:
41
+ data = json.loads(resp.read())
42
+ except (OSError, ValueError):
43
+ return None
44
+
45
+ license_field = data.get("license")
46
+ if isinstance(license_field, str):
47
+ return license_field
48
+ if isinstance(license_field, dict):
49
+ return license_field.get("type")
50
+ return None
51
+
52
+
53
+ def fetch_remote_license(ecosystem: str, name: str) -> str | None:
54
+ if ecosystem == "pypi":
55
+ return fetch_pypi_license(name)
56
+ if ecosystem == "npm":
57
+ return fetch_npm_license(name)
58
+ return None
@@ -0,0 +1,36 @@
1
+ """Combine parsing, license lookup, and risk classification into a report."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from license_radar import license_db
7
+ from license_radar.classify import classify_license
8
+ from license_radar.parsers import discover_manifests, parse_manifest
9
+
10
+
11
+ @dataclass
12
+ class Finding:
13
+ ecosystem: str
14
+ package: str
15
+ license: str | None
16
+ tier: str
17
+
18
+
19
+ def scan_manifest(path: Path, online: bool = False) -> list[Finding]:
20
+ ecosystem, names = parse_manifest(path)
21
+ findings = []
22
+ for name in names:
23
+ spdx = license_db.lookup(ecosystem, name)
24
+ if spdx is None and online:
25
+ from license_radar.remote import fetch_remote_license
26
+
27
+ spdx = fetch_remote_license(ecosystem, name)
28
+ findings.append(Finding(ecosystem, name, spdx, classify_license(spdx)))
29
+ return findings
30
+
31
+
32
+ def scan_path(root: Path, online: bool = False) -> list[Finding]:
33
+ findings = []
34
+ for manifest in discover_manifests(root):
35
+ findings.extend(scan_manifest(manifest, online=online))
36
+ return findings
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: license-radar
3
+ Version: 0.1.0
4
+ Summary: Scan project dependency manifests for license compliance risk (GPL/AGPL/LGPL exposure) before it becomes a legal problem.
5
+ Author: license-radar
6
+ License: MIT
7
+ Keywords: license,compliance,sca,dependencies,security,gpl,audit
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Topic :: Software Development :: Quality Assurance
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Dynamic: license-file
15
+
16
+ # license-radar
17
+
18
+ Scan a project's dependency manifests (`requirements.txt`, `pyproject.toml`,
19
+ `package.json`) for license compliance risk before a GPL/AGPL dependency
20
+ turns into a legal problem for a closed-source product.
21
+
22
+ ## Why
23
+
24
+ Pulling in a GPL- or AGPL-licensed dependency can obligate a company to open
25
+ its own source, or expose it to a lawsuit — and it usually happens by
26
+ accident, several dependency layers deep. Existing SCA/security scanners
27
+ focus on vulnerabilities; license risk is often an afterthought bolted onto
28
+ an expensive enterprise product. This is a small, focused tool that does
29
+ just the license check, fast, in CI.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install license-radar
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```bash
40
+ license-radar scan . # scan a directory (auto-detects manifests)
41
+ license-radar scan requirements.txt # scan a single manifest
42
+ license-radar scan . --online # also query PyPI/npm for packages not in the local DB
43
+ license-radar scan . --json # machine-readable output for CI
44
+ license-radar scan . --policy policy.json
45
+ ```
46
+
47
+ Exit code is `1` if any dependency violates the policy (useful as a CI gate),
48
+ `0` otherwise.
49
+
50
+ ### Policy
51
+
52
+ By default, any `strong-copyleft` (GPL/AGPL/SSPL) or `unknown` license is a
53
+ violation. Override with a JSON file:
54
+
55
+ ```json
56
+ {
57
+ "fail_at_or_above": "weak-copyleft",
58
+ "treat_unknown_as_violation": false
59
+ }
60
+ ```
61
+
62
+ ## How it classifies
63
+
64
+ Licenses are normalized to an SPDX id and bucketed into four tiers:
65
+ `permissive` < `weak-copyleft` < `strong-copyleft` < `unknown`. See
66
+ `license_radar/classify.py` for the exact lists.
67
+
68
+ ## Limitations
69
+
70
+ The offline database (`license_radar/license_db.py`) is a small, hand-curated
71
+ table of common packages, not a registry mirror — use `--online` for full
72
+ coverage against live PyPI/npm metadata (adds a network dependency and is
73
+ not covered by the deterministic test suite).
74
+
75
+ ## License
76
+
77
+ MIT
@@ -0,0 +1,19 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ license_radar/__init__.py
5
+ license_radar/classify.py
6
+ license_radar/cli.py
7
+ license_radar/license_db.py
8
+ license_radar/parsers.py
9
+ license_radar/policy.py
10
+ license_radar/remote.py
11
+ license_radar/scanner.py
12
+ license_radar.egg-info/PKG-INFO
13
+ license_radar.egg-info/SOURCES.txt
14
+ license_radar.egg-info/dependency_links.txt
15
+ license_radar.egg-info/entry_points.txt
16
+ license_radar.egg-info/top_level.txt
17
+ tests/test_classify.py
18
+ tests/test_parsers.py
19
+ tests/test_scanner.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ license-radar = license_radar.cli:main
@@ -0,0 +1 @@
1
+ license_radar
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "license-radar"
3
+ version = "0.1.0"
4
+ description = "Scan project dependency manifests for license compliance risk (GPL/AGPL/LGPL exposure) before it becomes a legal problem."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "license-radar" }]
9
+ keywords = ["license", "compliance", "sca", "dependencies", "security", "gpl", "audit"]
10
+ classifiers = [
11
+ "Programming Language :: Python :: 3",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Topic :: Software Development :: Quality Assurance",
14
+ ]
15
+
16
+ [project.scripts]
17
+ license-radar = "license_radar.cli:main"
18
+
19
+ [build-system]
20
+ requires = ["setuptools>=68"]
21
+ build-backend = "setuptools.build_meta"
22
+
23
+ [tool.setuptools]
24
+ packages = ["license_radar"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,25 @@
1
+ from license_radar.classify import classify_license, tier_rank
2
+
3
+ def test_permissive():
4
+ assert classify_license("MIT") == "permissive"
5
+ assert classify_license("Apache-2.0") == "permissive"
6
+
7
+ def test_weak_copyleft():
8
+ assert classify_license("MPL-2.0") == "weak-copyleft"
9
+ assert classify_license("LGPL-3.0-only") == "weak-copyleft"
10
+
11
+ def test_strong_copyleft():
12
+ assert classify_license("GPL-3.0-only") == "strong-copyleft"
13
+ assert classify_license("AGPL-3.0-only") == "strong-copyleft"
14
+
15
+ def test_unknown():
16
+ assert classify_license(None) == "unknown"
17
+ assert classify_license("LicenseRef-Proprietary") == "unknown"
18
+
19
+ def test_aliases_from_free_text_registry_fields():
20
+ assert classify_license("MIT License") == "permissive"
21
+ assert classify_license("GPLv3") == "strong-copyleft"
22
+ assert classify_license("BSD") == "permissive"
23
+
24
+ def test_tier_rank_orders_by_risk():
25
+ assert tier_rank("permissive") < tier_rank("weak-copyleft") < tier_rank("strong-copyleft") < tier_rank("unknown")
@@ -0,0 +1,32 @@
1
+ from pathlib import Path
2
+
3
+ from license_radar.parsers import discover_manifests, parse_manifest
4
+
5
+ FIXTURES = Path(__file__).parent / "fixtures"
6
+
7
+
8
+ def test_parse_requirements_txt():
9
+ ecosystem, names = parse_manifest(FIXTURES / "pypi_project" / "requirements.txt")
10
+ assert ecosystem == "pypi"
11
+ assert "requests" in names
12
+ assert "flask" in names
13
+ assert "pyqt5" in names
14
+ assert len(names) == 13
15
+
16
+
17
+ def test_parse_package_json():
18
+ ecosystem, names = parse_manifest(FIXTURES / "npm_project" / "package.json")
19
+ assert ecosystem == "npm"
20
+ assert "react" in names
21
+ assert "eslint" in names
22
+ assert len(names) == 7
23
+
24
+
25
+ def test_discover_manifests_in_directory():
26
+ found = discover_manifests(FIXTURES / "pypi_project")
27
+ found_names = {p.name for p in found}
28
+ assert found_names == {"requirements.txt"}
29
+
30
+ found = discover_manifests(FIXTURES / "npm_project")
31
+ found_names = {p.name for p in found}
32
+ assert found_names == {"package.json"}
@@ -0,0 +1,103 @@
1
+ """Accuracy validation against a hand-labeled ground-truth fixture set.
2
+
3
+ This is the numeric check required by the project's "definition of done":
4
+ it measures how well the offline scanner (no --online network lookups)
5
+ reproduces known-correct license tiers and policy violation calls.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ from license_radar.policy import load_policy, violates
11
+ from license_radar.scanner import scan_manifest
12
+
13
+ FIXTURES = Path(__file__).parent / "fixtures"
14
+
15
+ # ground truth: (ecosystem, package) -> (expected_tier, expected_violation)
16
+ GROUND_TRUTH = {
17
+ ("pypi", "requests"): ("permissive", False),
18
+ ("pypi", "flask"): ("permissive", False),
19
+ ("pypi", "numpy"): ("permissive", False),
20
+ ("pypi", "pytest"): ("permissive", False),
21
+ ("pypi", "pyyaml"): ("permissive", False),
22
+ ("pypi", "certifi"): ("weak-copyleft", False),
23
+ ("pypi", "psycopg2"): ("weak-copyleft", False),
24
+ ("pypi", "pyqt5"): ("strong-copyleft", True),
25
+ ("pypi", "unlisted-package-not-in-db"): ("unknown", True),
26
+ ("pypi", "test-strong-copyleft-pkg"): ("strong-copyleft", True),
27
+ ("pypi", "test-weak-copyleft-pkg"): ("weak-copyleft", False),
28
+ ("pypi", "test-permissive-pkg"): ("permissive", False),
29
+ ("pypi", "test-proprietary-pkg"): ("unknown", True),
30
+ ("npm", "react"): ("permissive", False),
31
+ ("npm", "lodash"): ("permissive", False),
32
+ ("npm", "axios"): ("permissive", False),
33
+ ("npm", "test-strong-copyleft-pkg"): ("strong-copyleft", True),
34
+ ("npm", "eslint"): ("permissive", False),
35
+ ("npm", "typescript"): ("permissive", False),
36
+ ("npm", "unlisted-dev-package"): ("unknown", True),
37
+ }
38
+
39
+
40
+ def _run_scan():
41
+ policy = load_policy(None)
42
+ findings = []
43
+ findings.extend(scan_manifest(FIXTURES / "pypi_project" / "requirements.txt"))
44
+ findings.extend(scan_manifest(FIXTURES / "npm_project" / "package.json"))
45
+ return findings, policy
46
+
47
+
48
+ def test_ground_truth_coverage_is_complete():
49
+ findings, _ = _run_scan()
50
+ keys = {(f.ecosystem, f.package) for f in findings}
51
+ assert keys == set(GROUND_TRUTH.keys()), "fixture files and ground truth table drifted apart"
52
+
53
+
54
+ def test_tier_classification_accuracy():
55
+ findings, _ = _run_scan()
56
+ correct = 0
57
+ for f in findings:
58
+ expected_tier, _ = GROUND_TRUTH[(f.ecosystem, f.package)]
59
+ if f.tier == expected_tier:
60
+ correct += 1
61
+ accuracy = correct / len(findings)
62
+ assert accuracy == 1.0, f"tier classification accuracy was {accuracy:.2%}, expected 100%"
63
+
64
+
65
+ def test_policy_violation_precision_and_recall():
66
+ findings, policy = _run_scan()
67
+
68
+ tp = fp = fn = tn = 0
69
+ for f in findings:
70
+ _, expected_violation = GROUND_TRUTH[(f.ecosystem, f.package)]
71
+ predicted_violation = violates(f, policy)
72
+ if predicted_violation and expected_violation:
73
+ tp += 1
74
+ elif predicted_violation and not expected_violation:
75
+ fp += 1
76
+ elif not predicted_violation and expected_violation:
77
+ fn += 1
78
+ else:
79
+ tn += 1
80
+
81
+ precision = tp / (tp + fp) if (tp + fp) else 1.0
82
+ recall = tp / (tp + fn) if (tp + fn) else 1.0
83
+
84
+ assert precision == 1.0, f"precision was {precision:.2%} (fp={fp})"
85
+ assert recall == 1.0, f"recall was {recall:.2%} (fn={fn})"
86
+
87
+
88
+ def test_local_db_coverage_on_fixture_set():
89
+ """What fraction of fixture packages resolve to a known license without --online.
90
+
91
+ This is the honest limitation metric: the static DB is a small
92
+ curated table (~40 packages), not a full registry mirror. Packages
93
+ outside it report as 'unknown' unless --online is used.
94
+ """
95
+ findings, _ = _run_scan()
96
+ known = sum(1 for f in findings if f.license is not None)
97
+ coverage = known / len(findings)
98
+ # 18 of 20 fixture packages resolve to a license string in the static
99
+ # DB by construction (test-proprietary-pkg has a non-SPDX license
100
+ # string but is still "known"); only the two deliberately
101
+ # unlisted-* packages are missing. This documents that ratio rather
102
+ # than aspiring to 100%.
103
+ assert coverage == 18 / 20