github-license-scanner 0.1.0__py3-none-any.whl

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.
gls/spdx_engine.py ADDED
@@ -0,0 +1,441 @@
1
+ """
2
+ SPDX license-expression parser and risk classifier.
3
+
4
+ Implements a practical subset of SPDX 2.x expressions:
5
+ - LicenseIds and LicenseRefs
6
+ - AND / OR (case-insensitive)
7
+ - WITH exception
8
+ - Parentheses for grouping
9
+
10
+ Risk composition rules (conservative for closed-source sale heuristics):
11
+ - OR → best (lowest risk) among alternatives [choice of license]
12
+ - AND → worst among conjuncts [all apply]
13
+ - WITH → risk of the base license [exception does not lower risk]
14
+
15
+ This is NOT a full SPDX validation suite and is NOT legal advice.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from dataclasses import dataclass
22
+ from enum import Enum
23
+ from typing import Iterable
24
+
25
+
26
+ class Risk(str, Enum):
27
+ PERMISSIVE = "permissive"
28
+ WEAK_COPYLEFT = "weak_copyleft"
29
+ STRONG_COPYLEFT = "strong_copyleft"
30
+ UNKNOWN = "unknown"
31
+
32
+ @property
33
+ def rank(self) -> int:
34
+ """Higher = more restrictive / concerning for closed-source sale."""
35
+ return {
36
+ Risk.PERMISSIVE: 0,
37
+ Risk.WEAK_COPYLEFT: 1,
38
+ Risk.UNKNOWN: 2,
39
+ Risk.STRONG_COPYLEFT: 3,
40
+ }[self]
41
+
42
+
43
+ # Canonical-ish license id → risk (lowercase keys, hyphenated)
44
+ _PERMISSIVE = {
45
+ "mit",
46
+ "apache-2.0",
47
+ "apache-1.0",
48
+ "apache-1.1",
49
+ "bsd-2-clause",
50
+ "bsd-3-clause",
51
+ "bsd-1-clause",
52
+ "bsd-3-clause-clear",
53
+ "0bsd",
54
+ "isc",
55
+ "unlicense",
56
+ "cc0-1.0",
57
+ "zlib",
58
+ "bsl-1.0",
59
+ "python-2.0",
60
+ "psf-2.0",
61
+ "blueoak-1.0.0",
62
+ "artistic-2.0",
63
+ "wtfpl",
64
+ "postgresql",
65
+ "openssl",
66
+ "x11",
67
+ "hpnd",
68
+ "ncsa",
69
+ "ms-pl", # Microsoft Public is OSI permissive-ish; treat as weak if unsure — keep permissive
70
+ "afl-3.0",
71
+ }
72
+
73
+ _WEAK = {
74
+ "lgpl-2.0",
75
+ "lgpl-2.0-only",
76
+ "lgpl-2.0-or-later",
77
+ "lgpl-2.1",
78
+ "lgpl-2.1-only",
79
+ "lgpl-2.1-or-later",
80
+ "lgpl-3.0",
81
+ "lgpl-3.0-only",
82
+ "lgpl-3.0-or-later",
83
+ "mpl-1.1",
84
+ "mpl-2.0",
85
+ "epl-1.0",
86
+ "epl-2.0",
87
+ "cpl-1.0",
88
+ "cddl-1.0",
89
+ "cddl-1.1",
90
+ "eupl-1.1",
91
+ "eupl-1.2",
92
+ "osl-3.0", # often treated stronger; keep weak only if already in strong map elsewhere
93
+ }
94
+
95
+ _STRONG = {
96
+ "gpl-1.0",
97
+ "gpl-1.0-only",
98
+ "gpl-1.0-or-later",
99
+ "gpl-2.0",
100
+ "gpl-2.0-only",
101
+ "gpl-2.0-or-later",
102
+ "gpl-3.0",
103
+ "gpl-3.0-only",
104
+ "gpl-3.0-or-later",
105
+ "agpl-1.0",
106
+ "agpl-1.0-only",
107
+ "agpl-1.0-or-later",
108
+ "agpl-3.0",
109
+ "agpl-3.0-only",
110
+ "agpl-3.0-or-later",
111
+ "sspl-1.0",
112
+ "sleepycat",
113
+ "osl-3.0",
114
+ "cecill-2.1",
115
+ "gfdl-1.3",
116
+ }
117
+
118
+ # Aliases → canonical SPDX-ish id
119
+ _ALIASES = {
120
+ "apache": "apache-2.0",
121
+ "apache2": "apache-2.0",
122
+ "apache-2": "apache-2.0",
123
+ "apache 2.0": "apache-2.0",
124
+ "bsd": "bsd-3-clause",
125
+ "bsd2": "bsd-2-clause",
126
+ "bsd3": "bsd-3-clause",
127
+ "gpl": "gpl-3.0",
128
+ "gpl2": "gpl-2.0",
129
+ "gpl3": "gpl-3.0",
130
+ "gplv2": "gpl-2.0",
131
+ "gplv3": "gpl-3.0",
132
+ "lgpl": "lgpl-3.0",
133
+ "lgplv2": "lgpl-2.1",
134
+ "lgplv3": "lgpl-3.0",
135
+ "agpl": "agpl-3.0",
136
+ "agplv3": "agpl-3.0",
137
+ "mpl": "mpl-2.0",
138
+ "mpl2": "mpl-2.0",
139
+ "cc0": "cc0-1.0",
140
+ "boost": "bsl-1.0",
141
+ "bsl": "bsl-1.0",
142
+ "psf": "psf-2.0",
143
+ "python": "python-2.0",
144
+ "unlicensed": "unlicensed", # proprietary marker
145
+ "proprietary": "proprietary",
146
+ "commercial": "proprietary",
147
+ }
148
+
149
+
150
+ class TokenKind(str, Enum):
151
+ LPAREN = "("
152
+ RPAREN = ")"
153
+ AND = "AND"
154
+ OR = "OR"
155
+ WITH = "WITH"
156
+ ID = "ID"
157
+ EOF = "EOF"
158
+
159
+
160
+ @dataclass(frozen=True)
161
+ class Token:
162
+ kind: TokenKind
163
+ value: str
164
+ pos: int
165
+
166
+
167
+ _ID_RE = re.compile(
168
+ r"""
169
+ (?:
170
+ DocumentRef-[A-Za-z0-9\-.]+:
171
+ )?
172
+ (?:
173
+ LicenseRef-[A-Za-z0-9\-.]+
174
+ | [A-Za-z0-9][A-Za-z0-9.+_-]*
175
+ )
176
+ """,
177
+ re.VERBOSE,
178
+ )
179
+
180
+
181
+ def tokenize(expression: str) -> list[Token]:
182
+ """Lex an SPDX expression into tokens."""
183
+ s = expression.strip()
184
+ # Normalize common non-SPDX separators from registries
185
+ s = s.replace("||", " OR ").replace("&&", " AND ")
186
+ # npm sometimes uses "/" as OR
187
+ if " OR " not in s.upper() and " AND " not in s.upper() and "/" in s and "(" not in s:
188
+ # only for simple "MIT/Apache-2.0" style — not paths
189
+ if re.fullmatch(r"[A-Za-z0-9.+_\-]+(?:\s*/\s*[A-Za-z0-9.+_\-]+)+", s.strip()):
190
+ s = re.sub(r"\s*/\s*", " OR ", s)
191
+
192
+ tokens: list[Token] = []
193
+ i = 0
194
+ n = len(s)
195
+ while i < n:
196
+ ch = s[i]
197
+ if ch.isspace():
198
+ i += 1
199
+ continue
200
+ if ch == "(":
201
+ tokens.append(Token(TokenKind.LPAREN, "(", i))
202
+ i += 1
203
+ continue
204
+ if ch == ")":
205
+ tokens.append(Token(TokenKind.RPAREN, ")", i))
206
+ i += 1
207
+ continue
208
+ # Word / id
209
+ m = _ID_RE.match(s, i)
210
+ if not m:
211
+ raise ValueError(f"Unexpected character at {i}: {s[i]!r}")
212
+ word = m.group(0)
213
+ upper = word.upper()
214
+ if upper == "AND":
215
+ tokens.append(Token(TokenKind.AND, "AND", i))
216
+ elif upper == "OR":
217
+ tokens.append(Token(TokenKind.OR, "OR", i))
218
+ elif upper == "WITH":
219
+ tokens.append(Token(TokenKind.WITH, "WITH", i))
220
+ else:
221
+ tokens.append(Token(TokenKind.ID, word, i))
222
+ i = m.end()
223
+ tokens.append(Token(TokenKind.EOF, "", n))
224
+ return tokens
225
+
226
+
227
+ @dataclass
228
+ class ExprNode:
229
+ """AST node for SPDX expressions."""
230
+
231
+ op: str # 'id' | 'and' | 'or' | 'with'
232
+ value: str | None = None
233
+ children: list[ExprNode] | None = None
234
+
235
+ def license_ids(self) -> list[str]:
236
+ if self.op == "id":
237
+ return [self.value or ""]
238
+ if self.op == "with":
239
+ return (self.children or [])[0].license_ids() if self.children else []
240
+ out: list[str] = []
241
+ for c in self.children or []:
242
+ out.extend(c.license_ids())
243
+ return out
244
+
245
+
246
+ class _Parser:
247
+ def __init__(self, tokens: list[Token]) -> None:
248
+ self.tokens = tokens
249
+ self.i = 0
250
+
251
+ def _cur(self) -> Token:
252
+ return self.tokens[self.i]
253
+
254
+ def _eat(self, kind: TokenKind) -> Token:
255
+ tok = self._cur()
256
+ if tok.kind != kind:
257
+ raise ValueError(f"Expected {kind}, got {tok.kind} ({tok.value!r}) at {tok.pos}")
258
+ self.i += 1
259
+ return tok
260
+
261
+ def parse(self) -> ExprNode:
262
+ node = self._parse_or()
263
+ if self._cur().kind != TokenKind.EOF:
264
+ raise ValueError(f"Trailing tokens at {self._cur().pos}")
265
+ return node
266
+
267
+ def _parse_or(self) -> ExprNode:
268
+ left = self._parse_and()
269
+ nodes = [left]
270
+ while self._cur().kind == TokenKind.OR:
271
+ self._eat(TokenKind.OR)
272
+ nodes.append(self._parse_and())
273
+ if len(nodes) == 1:
274
+ return nodes[0]
275
+ return ExprNode(op="or", children=nodes)
276
+
277
+ def _parse_and(self) -> ExprNode:
278
+ left = self._parse_with()
279
+ nodes = [left]
280
+ while self._cur().kind == TokenKind.AND:
281
+ self._eat(TokenKind.AND)
282
+ nodes.append(self._parse_with())
283
+ if len(nodes) == 1:
284
+ return nodes[0]
285
+ return ExprNode(op="and", children=nodes)
286
+
287
+ def _parse_with(self) -> ExprNode:
288
+ base = self._parse_primary()
289
+ if self._cur().kind == TokenKind.WITH:
290
+ self._eat(TokenKind.WITH)
291
+ exc = self._eat(TokenKind.ID)
292
+ return ExprNode(
293
+ op="with",
294
+ value=exc.value,
295
+ children=[base],
296
+ )
297
+ return base
298
+
299
+ def _parse_primary(self) -> ExprNode:
300
+ tok = self._cur()
301
+ if tok.kind == TokenKind.LPAREN:
302
+ self._eat(TokenKind.LPAREN)
303
+ node = self._parse_or()
304
+ self._eat(TokenKind.RPAREN)
305
+ return node
306
+ if tok.kind == TokenKind.ID:
307
+ self._eat(TokenKind.ID)
308
+ return ExprNode(op="id", value=tok.value)
309
+ raise ValueError(f"Unexpected token {tok.kind} at {tok.pos}")
310
+
311
+
312
+ def parse_expression(text: str) -> ExprNode:
313
+ """Parse SPDX expression text into an AST."""
314
+ return _Parser(tokenize(text)).parse()
315
+
316
+
317
+ def normalize_license_token(raw: str) -> str:
318
+ """Normalize a single license id token for lookup."""
319
+ t = raw.strip().strip("()")
320
+ # Drop trailing + (or-later shorthand in some registries)
321
+ plus = t.endswith("+")
322
+ if plus:
323
+ t = t[:-1]
324
+ t = t.lower().replace(" ", "-").replace("_", "-")
325
+ t = t.replace("gnu-", "").replace("licence", "license")
326
+ if t in _ALIASES:
327
+ t = _ALIASES[t]
328
+ if plus and not t.endswith("-or-later") and not t.endswith("+"):
329
+ # map gpl-2.0+ → gpl-2.0-or-later when possible
330
+ if re.match(r"^(agpl|gpl|lgpl)-\d+(\.\d+)?$", t):
331
+ t = f"{t}-or-later"
332
+ return t
333
+
334
+
335
+ def classify_id(license_id: str) -> Risk:
336
+ """Classify a single license identifier."""
337
+ t = normalize_license_token(license_id)
338
+ if t in {"unlicensed", "proprietary", "commercial", "none", "noassertion"}:
339
+ return Risk.UNKNOWN
340
+ if t.startswith("licenseref-"):
341
+ return Risk.UNKNOWN
342
+ if t in _PERMISSIVE:
343
+ return Risk.PERMISSIVE
344
+ if t in _WEAK:
345
+ return Risk.WEAK_COPYLEFT
346
+ if t in _STRONG:
347
+ return Risk.STRONG_COPYLEFT
348
+
349
+ # Substring heuristics for versioned / odd ids
350
+ if "agpl" in t or "sspl" in t:
351
+ return Risk.STRONG_COPYLEFT
352
+ if re.search(r"(^|-)gpl", t) and "lgpl" not in t:
353
+ return Risk.STRONG_COPYLEFT
354
+ if "lgpl" in t or "mpl" in t or t.startswith("epl") or "eupl" in t or "cddl" in t:
355
+ return Risk.WEAK_COPYLEFT
356
+ if any(p in t for p in ("mit", "apache", "bsd", "isc", "unlicense", "zlib", "boost", "cc0")):
357
+ return Risk.PERMISSIVE
358
+ return Risk.UNKNOWN
359
+
360
+
361
+ def _worst(risks: Iterable[Risk]) -> Risk:
362
+ return max(risks, key=lambda r: r.rank, default=Risk.UNKNOWN)
363
+
364
+
365
+ def _best(risks: Iterable[Risk]) -> Risk:
366
+ return min(risks, key=lambda r: r.rank, default=Risk.UNKNOWN)
367
+
368
+
369
+ def classify_node(node: ExprNode) -> Risk:
370
+ """Classify an AST node."""
371
+ if node.op == "id":
372
+ return classify_id(node.value or "")
373
+ if node.op == "with":
374
+ # Exception does not reduce base risk for our closed-sale heuristic
375
+ kids = node.children or []
376
+ return classify_node(kids[0]) if kids else Risk.UNKNOWN
377
+ if node.op == "or":
378
+ return _best(classify_node(c) for c in (node.children or []))
379
+ if node.op == "and":
380
+ return _worst(classify_node(c) for c in (node.children or []))
381
+ return Risk.UNKNOWN
382
+
383
+
384
+ def classify_expression(text: str | None) -> tuple[str, ExprNode | None, str | None]:
385
+ """
386
+ Classify a license string / SPDX expression.
387
+
388
+ Returns:
389
+ (risk_bucket, ast_or_none, error_or_none)
390
+ risk_bucket is one of: permissive | weak_copyleft | strong_copyleft | unknown
391
+ """
392
+ if not text or not str(text).strip():
393
+ return Risk.UNKNOWN.value, None, None
394
+
395
+ raw = str(text).strip()
396
+ # Strip wrapping quotes
397
+ if (raw.startswith('"') and raw.endswith('"')) or (raw.startswith("'") and raw.endswith("'")):
398
+ raw = raw[1:-1].strip()
399
+
400
+ # Quick proprietary markers
401
+ if raw.upper() in {"UNLICENSED", "PROPRIETARY", "COMMERCIAL", "NONE", "NOASSERTION"}:
402
+ return Risk.UNKNOWN.value, None, None
403
+
404
+ # Try full expression parse
405
+ try:
406
+ # Prefer parenthesized / multi-token forms; also simple ids
407
+ node = parse_expression(raw)
408
+ risk = classify_node(node)
409
+ return risk.value, node, None
410
+ except ValueError as exc:
411
+ # Fallback: single-token classify
412
+ risk = classify_id(raw)
413
+ return risk.value, None, str(exc)
414
+
415
+
416
+ def is_network_copyleft_expression(text: str | None) -> bool:
417
+ """True if any license id in the expression is AGPL/SSPL-class."""
418
+ if not text:
419
+ return False
420
+ risk, node, _ = classify_expression(text)
421
+ if node is not None:
422
+ ids = node.license_ids()
423
+ else:
424
+ ids = [text]
425
+ for lid in ids:
426
+ n = normalize_license_token(lid)
427
+ if "agpl" in n or "sspl" in n:
428
+ return True
429
+ # Also if whole string matches
430
+ lower = text.lower()
431
+ return "agpl" in lower or "sspl" in lower
432
+
433
+
434
+ def expression_to_spdx_ids(text: str | None) -> list[str]:
435
+ """Extract license ids from an expression (best effort)."""
436
+ if not text:
437
+ return []
438
+ _, node, _ = classify_expression(text)
439
+ if node is None:
440
+ return [normalize_license_token(text)]
441
+ return [normalize_license_token(x) for x in node.license_ids() if x]