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.
- github_license_scanner-0.1.0.dist-info/METADATA +307 -0
- github_license_scanner-0.1.0.dist-info/RECORD +24 -0
- github_license_scanner-0.1.0.dist-info/WHEEL +4 -0
- github_license_scanner-0.1.0.dist-info/entry_points.txt +3 -0
- github_license_scanner-0.1.0.dist-info/licenses/LICENSE +21 -0
- gls/__init__.py +1 -0
- gls/auth.py +229 -0
- gls/cli.py +358 -0
- gls/config.py +161 -0
- gls/dependency_scanner.py +621 -0
- gls/deploy_advisor.py +286 -0
- gls/docs/LEGAL_DISCLAIMER.md +57 -0
- gls/docs/PRIVACY.md +69 -0
- gls/docs/TERMS.md +52 -0
- gls/github_api.py +514 -0
- gls/history_store.py +177 -0
- gls/i18n.py +530 -0
- gls/license_analyzer.py +855 -0
- gls/models.py +148 -0
- gls/rate_limit.py +64 -0
- gls/report.py +150 -0
- gls/sbom_export.py +312 -0
- gls/spdx_engine.py +441 -0
- gls/webui.py +1828 -0
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]
|