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/license_analyzer.py
ADDED
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
"""
|
|
2
|
+
License classification, registry lookups, compatibility verdicts, and
|
|
3
|
+
end-to-end repository analysis orchestration.
|
|
4
|
+
|
|
5
|
+
IMPORTANT: Results are automated heuristics for developer guidance only.
|
|
6
|
+
They are NOT legal advice. Always consult a qualified attorney for
|
|
7
|
+
compliance decisions involving copyleft and commercial distribution.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import re
|
|
14
|
+
import time
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from typing import Any
|
|
17
|
+
from urllib.parse import quote
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from .dependency_scanner import parse_many
|
|
22
|
+
from .deploy_advisor import recommend_deploy
|
|
23
|
+
from .github_api import (
|
|
24
|
+
GitHubAPIError,
|
|
25
|
+
create_client,
|
|
26
|
+
download_dependency_files,
|
|
27
|
+
find_dependency_files,
|
|
28
|
+
get_repo_info,
|
|
29
|
+
parse_github_url,
|
|
30
|
+
)
|
|
31
|
+
from .models import (
|
|
32
|
+
Dependency,
|
|
33
|
+
PackageLicense,
|
|
34
|
+
ReplacementSuggestion,
|
|
35
|
+
ScanResult,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
from .config import (
|
|
40
|
+
LICENSE_CACHE_MAX_ENTRIES,
|
|
41
|
+
LICENSE_CACHE_TTL_SECONDS,
|
|
42
|
+
MAX_CONCURRENT_LOOKUPS,
|
|
43
|
+
MAX_PACKAGES_LOOKUP,
|
|
44
|
+
USER_AGENT,
|
|
45
|
+
)
|
|
46
|
+
except Exception: # noqa: BLE001
|
|
47
|
+
MAX_CONCURRENT_LOOKUPS = 12
|
|
48
|
+
MAX_PACKAGES_LOOKUP = 80
|
|
49
|
+
USER_AGENT = "github-license-scanner/1.1 (license-lookup; educational)"
|
|
50
|
+
LICENSE_CACHE_TTL_SECONDS = 3600
|
|
51
|
+
LICENSE_CACHE_MAX_ENTRIES = 2000
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# License classification maps (SPDX-oriented, case-insensitive matching)
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
PERMISSIVE = {
|
|
58
|
+
"mit",
|
|
59
|
+
"apache-2.0",
|
|
60
|
+
"apache 2.0",
|
|
61
|
+
"apache2",
|
|
62
|
+
"apache-2",
|
|
63
|
+
"bsd-2-clause",
|
|
64
|
+
"bsd-3-clause",
|
|
65
|
+
"bsd",
|
|
66
|
+
"isc",
|
|
67
|
+
"unlicense",
|
|
68
|
+
"0bsd",
|
|
69
|
+
"cc0-1.0",
|
|
70
|
+
"cc0",
|
|
71
|
+
"zlib",
|
|
72
|
+
"boost",
|
|
73
|
+
"bsl-1.0",
|
|
74
|
+
"python-2.0",
|
|
75
|
+
"psf-2.0",
|
|
76
|
+
"blueoak-1.0.0",
|
|
77
|
+
"artistic-2.0",
|
|
78
|
+
"wtfpl",
|
|
79
|
+
"postgresql",
|
|
80
|
+
"openssl",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
WEAK_COPYLEFT = {
|
|
84
|
+
"lgpl-2.1",
|
|
85
|
+
"lgpl-3.0",
|
|
86
|
+
"lgpl-2.0",
|
|
87
|
+
"lgpl-2.1-only",
|
|
88
|
+
"lgpl-2.1-or-later",
|
|
89
|
+
"lgpl-3.0-only",
|
|
90
|
+
"lgpl-3.0-or-later",
|
|
91
|
+
"lgpl",
|
|
92
|
+
"mpl-2.0",
|
|
93
|
+
"mpl",
|
|
94
|
+
"epl-1.0",
|
|
95
|
+
"epl-2.0",
|
|
96
|
+
"cpl-1.0",
|
|
97
|
+
"cddl-1.0",
|
|
98
|
+
"cddl-1.1",
|
|
99
|
+
"ms-pl",
|
|
100
|
+
"eupl-1.1",
|
|
101
|
+
"eupl-1.2",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
STRONG_COPYLEFT = {
|
|
105
|
+
"gpl-2.0",
|
|
106
|
+
"gpl-3.0",
|
|
107
|
+
"gpl-2.0-only",
|
|
108
|
+
"gpl-2.0-or-later",
|
|
109
|
+
"gpl-3.0-only",
|
|
110
|
+
"gpl-3.0-or-later",
|
|
111
|
+
"gpl",
|
|
112
|
+
"agpl-3.0",
|
|
113
|
+
"agpl-1.0",
|
|
114
|
+
"agpl-3.0-only",
|
|
115
|
+
"agpl-3.0-or-later",
|
|
116
|
+
"agpl",
|
|
117
|
+
"sspl-1.0",
|
|
118
|
+
"sspl",
|
|
119
|
+
"sleepycat",
|
|
120
|
+
"osl-3.0", # treated as strong for distribution (conservative)
|
|
121
|
+
"cecill-2.1",
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
# Licenses with network-use / SaaS-sensitive obligations (subset of strong)
|
|
125
|
+
NETWORK_COPYLEFT_MARKERS = ("agpl", "sspl")
|
|
126
|
+
|
|
127
|
+
# Heuristic replacements for known strong-copyleft packages (not exhaustive)
|
|
128
|
+
REPLACEMENT_MAP: dict[str, list[str]] = {
|
|
129
|
+
# name lowercased -> permissive alternatives
|
|
130
|
+
"readline": ["prompt_toolkit (BSD)", "pyreadline3 (BSD-ish)"],
|
|
131
|
+
"gnu-readline": ["prompt_toolkit"],
|
|
132
|
+
"ffmpeg-python": ["imageio-ffmpeg (check binary license)", "moviepy with care"],
|
|
133
|
+
"mysql-connector-python": ["PyMySQL (MIT)", "mysqlclient (check license)", "asyncmy (Apache-2.0)"],
|
|
134
|
+
"psycopg2": ["psycopg (LGPL — still weak)", "asyncpg (Apache-2.0)"],
|
|
135
|
+
"qtpy": ["consider pure web UI (NiceGUI/Streamlit) to avoid Qt GPL builds"],
|
|
136
|
+
"pyside2": ["PySide6 is LGPL; or use web UI frameworks"],
|
|
137
|
+
"pyside6": ["dearpygui (MIT)", "NiceGUI (MIT)", "Streamlit (Apache-2.0)"],
|
|
138
|
+
"pyqt5": ["PySide6 (LGPL)", "NiceGUI (MIT)", "tkinter (PSF)"],
|
|
139
|
+
"pyqt6": ["PySide6 (LGPL)", "NiceGUI (MIT)"],
|
|
140
|
+
"ghostscript": ["pypdf (BSD)", "pikepdf (MPL-2.0)"],
|
|
141
|
+
"copyleft": [], # placeholder
|
|
142
|
+
# npm
|
|
143
|
+
"node-gpl-example": ["look for MIT alternatives on npm"],
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# In-process cache: (ecosystem, name_lower) -> (license_id, url, expires_at)
|
|
147
|
+
_license_cache: dict[tuple[str, str], tuple[str | None, str | None, float]] = {}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _cache_get(key: tuple[str, str]) -> tuple[str | None, str | None] | None:
|
|
151
|
+
entry = _license_cache.get(key)
|
|
152
|
+
if not entry:
|
|
153
|
+
return None
|
|
154
|
+
lic, url, expires = entry
|
|
155
|
+
if time.monotonic() > expires:
|
|
156
|
+
_license_cache.pop(key, None)
|
|
157
|
+
return None
|
|
158
|
+
return lic, url
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _cache_set(key: tuple[str, str], lic: str | None, url: str | None) -> None:
|
|
162
|
+
# Evict oldest-ish entries if over capacity (simple size guard)
|
|
163
|
+
if len(_license_cache) >= LICENSE_CACHE_MAX_ENTRIES:
|
|
164
|
+
# Drop ~10% of keys
|
|
165
|
+
for drop_key in list(_license_cache.keys())[: max(1, LICENSE_CACHE_MAX_ENTRIES // 10)]:
|
|
166
|
+
_license_cache.pop(drop_key, None)
|
|
167
|
+
_license_cache[key] = (lic, url, time.monotonic() + LICENSE_CACHE_TTL_SECONDS)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
# Classification helpers
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def normalize_license_id(raw: str | None) -> str | None:
|
|
175
|
+
"""Normalize a license string for comparison and display."""
|
|
176
|
+
if raw is None:
|
|
177
|
+
return None
|
|
178
|
+
text = str(raw).strip()
|
|
179
|
+
if not text or text.upper() in {"NOASSERTION", "NONE", "UNKNOWN", "UNLICENSED", "SEE LICENSE IN LICENSE"}:
|
|
180
|
+
# "UNLICENSED" in npm often means proprietary — keep a marker
|
|
181
|
+
if text.upper() == "UNLICENSED":
|
|
182
|
+
return "UNLICENSED"
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
# npm sometimes returns {"type": "MIT", "url": "..."}
|
|
186
|
+
# handled before calling this function
|
|
187
|
+
|
|
188
|
+
# Common cleanup
|
|
189
|
+
text = text.replace("License", "").replace("license", "").strip()
|
|
190
|
+
# Take first alternative of dual licenses for classification display
|
|
191
|
+
# e.g. "(MIT OR Apache-2.0)" -> keep original for display but classify carefully
|
|
192
|
+
return text
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def classify_license(license_id: str | None) -> str:
|
|
196
|
+
"""
|
|
197
|
+
Classify a license string / SPDX expression into a risk bucket.
|
|
198
|
+
|
|
199
|
+
Uses the SPDX expression engine (AND/OR/WITH + parentheses).
|
|
200
|
+
Returns one of: permissive | weak_copyleft | strong_copyleft | unknown
|
|
201
|
+
"""
|
|
202
|
+
from .spdx_engine import classify_expression
|
|
203
|
+
|
|
204
|
+
risk, _node, _err = classify_expression(license_id)
|
|
205
|
+
return risk
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _classify_single(token: str) -> str:
|
|
209
|
+
"""Classify a single license token (kept for tests / callers)."""
|
|
210
|
+
from .spdx_engine import classify_id
|
|
211
|
+
|
|
212
|
+
return classify_id(token).value
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def risk_color(risk: str) -> str:
|
|
216
|
+
"""Map risk to a simple color name for the UI."""
|
|
217
|
+
return {
|
|
218
|
+
"permissive": "green",
|
|
219
|
+
"weak_copyleft": "orange",
|
|
220
|
+
"strong_copyleft": "red",
|
|
221
|
+
"unknown": "orange",
|
|
222
|
+
}.get(risk, "grey")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
# Registry license lookups
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
def _extract_npm_license(data: dict[str, Any]) -> str | None:
|
|
230
|
+
"""Extract license field from npm registry JSON."""
|
|
231
|
+
lic = data.get("license")
|
|
232
|
+
if isinstance(lic, str):
|
|
233
|
+
return normalize_license_id(lic)
|
|
234
|
+
if isinstance(lic, dict):
|
|
235
|
+
return normalize_license_id(lic.get("type") or lic.get("name"))
|
|
236
|
+
# Older packages: licenses array
|
|
237
|
+
licenses = data.get("licenses")
|
|
238
|
+
if isinstance(licenses, list) and licenses:
|
|
239
|
+
first = licenses[0]
|
|
240
|
+
if isinstance(first, dict):
|
|
241
|
+
return normalize_license_id(first.get("type"))
|
|
242
|
+
if isinstance(first, str):
|
|
243
|
+
return normalize_license_id(first)
|
|
244
|
+
# Prefer latest version info
|
|
245
|
+
latest = (data.get("dist-tags") or {}).get("latest")
|
|
246
|
+
versions = data.get("versions") or {}
|
|
247
|
+
if latest and latest in versions:
|
|
248
|
+
return _extract_npm_license(versions[latest])
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _extract_pypi_license(data: dict[str, Any]) -> str | None:
|
|
253
|
+
"""Extract license from PyPI JSON API."""
|
|
254
|
+
info = data.get("info") or {}
|
|
255
|
+
lic = info.get("license") or info.get("license_expression")
|
|
256
|
+
if lic and str(lic).strip() and str(lic).strip() not in {"UNKNOWN", "License :: Other/Proprietary License"}:
|
|
257
|
+
# Sometimes license is a long text; try to grab SPDX-like token
|
|
258
|
+
text = str(lic).strip()
|
|
259
|
+
if len(text) < 80:
|
|
260
|
+
return normalize_license_id(text)
|
|
261
|
+
|
|
262
|
+
# Classifiers: License :: OSI Approved :: MIT License
|
|
263
|
+
for classifier in info.get("classifiers") or []:
|
|
264
|
+
if not isinstance(classifier, str):
|
|
265
|
+
continue
|
|
266
|
+
if classifier.startswith("License ::"):
|
|
267
|
+
parts = classifier.split("::")
|
|
268
|
+
label = parts[-1].strip()
|
|
269
|
+
# Map common classifier labels
|
|
270
|
+
mapping = {
|
|
271
|
+
"MIT License": "MIT",
|
|
272
|
+
"Apache Software License": "Apache-2.0",
|
|
273
|
+
"BSD License": "BSD",
|
|
274
|
+
"GNU General Public License v3 (GPLv3)": "GPL-3.0",
|
|
275
|
+
"GNU General Public License v2 (GPLv2)": "GPL-2.0",
|
|
276
|
+
"GNU Affero General Public License v3": "AGPL-3.0",
|
|
277
|
+
"GNU Lesser General Public License v3 (LGPLv3)": "LGPL-3.0",
|
|
278
|
+
"GNU Lesser General Public License v2 (LGPLv2)": "LGPL-2.0",
|
|
279
|
+
"Mozilla Public License 2.0 (MPL 2.0)": "MPL-2.0",
|
|
280
|
+
"ISC License (ISCL)": "ISC",
|
|
281
|
+
"The Unlicense (Unlicense)": "Unlicense",
|
|
282
|
+
"Public Domain": "Unlicense",
|
|
283
|
+
}
|
|
284
|
+
if label in mapping:
|
|
285
|
+
return mapping[label]
|
|
286
|
+
return normalize_license_id(label)
|
|
287
|
+
return normalize_license_id(str(lic) if lic else None)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
async def lookup_package_license(
|
|
291
|
+
client: httpx.AsyncClient,
|
|
292
|
+
dep: Dependency,
|
|
293
|
+
) -> tuple[str | None, str | None]:
|
|
294
|
+
"""
|
|
295
|
+
Query the official registry for a package license.
|
|
296
|
+
|
|
297
|
+
Returns (license_id, info_url). Uses an in-process cache.
|
|
298
|
+
"""
|
|
299
|
+
cache_key = (dep.ecosystem, dep.name.lower())
|
|
300
|
+
cached = _cache_get(cache_key)
|
|
301
|
+
if cached is not None:
|
|
302
|
+
return cached
|
|
303
|
+
|
|
304
|
+
license_id: str | None = None
|
|
305
|
+
info_url: str | None = None
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
if dep.ecosystem == "npm":
|
|
309
|
+
# Scoped packages: @scope/name → %40scope%2Fname
|
|
310
|
+
encoded = quote(dep.name, safe="@")
|
|
311
|
+
# httpx quote: better encode slash in scope
|
|
312
|
+
encoded = dep.name.replace("/", "%2F")
|
|
313
|
+
url = f"https://registry.npmjs.org/{encoded}"
|
|
314
|
+
info_url = f"https://www.npmjs.com/package/{dep.name}"
|
|
315
|
+
resp = await client.get(url)
|
|
316
|
+
if resp.status_code == 200:
|
|
317
|
+
license_id = _extract_npm_license(resp.json())
|
|
318
|
+
|
|
319
|
+
elif dep.ecosystem == "pypi":
|
|
320
|
+
name = dep.name
|
|
321
|
+
url = f"https://pypi.org/pypi/{quote(name)}/json"
|
|
322
|
+
info_url = f"https://pypi.org/project/{name}/"
|
|
323
|
+
resp = await client.get(url)
|
|
324
|
+
if resp.status_code == 200:
|
|
325
|
+
license_id = _extract_pypi_license(resp.json())
|
|
326
|
+
|
|
327
|
+
elif dep.ecosystem == "cargo":
|
|
328
|
+
url = f"https://crates.io/api/v1/crates/{quote(dep.name)}"
|
|
329
|
+
info_url = f"https://crates.io/crates/{dep.name}"
|
|
330
|
+
resp = await client.get(url, headers={"User-Agent": USER_AGENT})
|
|
331
|
+
if resp.status_code == 200:
|
|
332
|
+
crate = (resp.json().get("crate") or {})
|
|
333
|
+
# license field on crate
|
|
334
|
+
license_id = normalize_license_id(crate.get("license"))
|
|
335
|
+
|
|
336
|
+
elif dep.ecosystem == "go":
|
|
337
|
+
# Best-effort: pkg.go.dev does not have a stable simple JSON API.
|
|
338
|
+
# Leave unknown; go.mod often has no license metadata remotely.
|
|
339
|
+
info_url = f"https://pkg.go.dev/{dep.name}"
|
|
340
|
+
license_id = None
|
|
341
|
+
|
|
342
|
+
elif dep.ecosystem == "rubygems":
|
|
343
|
+
url = f"https://rubygems.org/api/v1/gems/{quote(dep.name)}.json"
|
|
344
|
+
info_url = f"https://rubygems.org/gems/{dep.name}"
|
|
345
|
+
resp = await client.get(url)
|
|
346
|
+
if resp.status_code == 200:
|
|
347
|
+
data = resp.json()
|
|
348
|
+
licenses = data.get("licenses") or []
|
|
349
|
+
if isinstance(licenses, list) and licenses:
|
|
350
|
+
license_id = normalize_license_id(str(licenses[0]))
|
|
351
|
+
else:
|
|
352
|
+
license_id = normalize_license_id(data.get("license"))
|
|
353
|
+
|
|
354
|
+
elif dep.ecosystem == "composer":
|
|
355
|
+
# Packagist
|
|
356
|
+
if "/" in dep.name:
|
|
357
|
+
url = f"https://repo.packagist.org/p2/{dep.name}.json"
|
|
358
|
+
info_url = f"https://packagist.org/packages/{dep.name}"
|
|
359
|
+
resp = await client.get(url)
|
|
360
|
+
if resp.status_code == 200:
|
|
361
|
+
packages = (resp.json().get("packages") or {}).get(dep.name) or []
|
|
362
|
+
if packages and isinstance(packages, list):
|
|
363
|
+
lic_list = packages[0].get("license") or []
|
|
364
|
+
if isinstance(lic_list, list) and lic_list:
|
|
365
|
+
license_id = normalize_license_id(str(lic_list[0]))
|
|
366
|
+
|
|
367
|
+
# maven / gradle: no free bulk API without coordinates API keys — skip
|
|
368
|
+
else:
|
|
369
|
+
license_id = None
|
|
370
|
+
|
|
371
|
+
except (httpx.HTTPError, ValueError, KeyError, TypeError):
|
|
372
|
+
license_id = None
|
|
373
|
+
|
|
374
|
+
_cache_set(cache_key, license_id, info_url)
|
|
375
|
+
return license_id, info_url
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
async def resolve_all_licenses(
|
|
379
|
+
dependencies: list[Dependency],
|
|
380
|
+
) -> list[PackageLicense]:
|
|
381
|
+
"""
|
|
382
|
+
Resolve licenses for a list of dependencies with bounded concurrency.
|
|
383
|
+
"""
|
|
384
|
+
deps = dependencies[:MAX_PACKAGES_LOOKUP]
|
|
385
|
+
semaphore = asyncio.Semaphore(MAX_CONCURRENT_LOOKUPS)
|
|
386
|
+
results: list[PackageLicense] = []
|
|
387
|
+
|
|
388
|
+
async with httpx.AsyncClient(
|
|
389
|
+
timeout=20.0,
|
|
390
|
+
headers={"User-Agent": USER_AGENT},
|
|
391
|
+
follow_redirects=True,
|
|
392
|
+
) as client:
|
|
393
|
+
|
|
394
|
+
async def one(dep: Dependency) -> PackageLicense:
|
|
395
|
+
async with semaphore:
|
|
396
|
+
lic, url = await lookup_package_license(client, dep)
|
|
397
|
+
risk = classify_license(lic)
|
|
398
|
+
return PackageLicense(
|
|
399
|
+
name=dep.name,
|
|
400
|
+
ecosystem=dep.ecosystem,
|
|
401
|
+
license_id=lic,
|
|
402
|
+
risk=risk,
|
|
403
|
+
source_file=dep.source_file,
|
|
404
|
+
license_url=url,
|
|
405
|
+
version_spec=dep.version_spec,
|
|
406
|
+
is_dev=dep.is_dev,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
tasks = [one(d) for d in deps]
|
|
410
|
+
results = list(await asyncio.gather(*tasks))
|
|
411
|
+
|
|
412
|
+
return results
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# ---------------------------------------------------------------------------
|
|
416
|
+
# Verdicts, replacements, copyright notice
|
|
417
|
+
# ---------------------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
def build_copyright_notice(
|
|
420
|
+
owner: str,
|
|
421
|
+
repo: str,
|
|
422
|
+
repo_license: str | None,
|
|
423
|
+
year: int | None = None,
|
|
424
|
+
) -> str:
|
|
425
|
+
"""
|
|
426
|
+
Build a plain-text *template* for notices — NOT a legal copyright claim.
|
|
427
|
+
|
|
428
|
+
Important: the GitHub owner/organization name is often NOT the copyright
|
|
429
|
+
holder (forks, orgs, employers, multi-author projects). Users must verify
|
|
430
|
+
the LICENSE file, NOTICE file, and commit authorship before relying on this.
|
|
431
|
+
"""
|
|
432
|
+
y = year or datetime.now(timezone.utc).year
|
|
433
|
+
lic = repo_license or "SEE LICENSE IN REPOSITORY"
|
|
434
|
+
return (
|
|
435
|
+
f"=== NOTICE TEMPLATE (NOT LEGAL ADVICE) ===\n"
|
|
436
|
+
f"WARNING: Do NOT treat the GitHub account name as proof of copyright\n"
|
|
437
|
+
f"ownership. Verify rights holders from LICENSE, NOTICE, and history.\n"
|
|
438
|
+
f"\n"
|
|
439
|
+
f"Repository: https://github.com/{owner}/{repo}\n"
|
|
440
|
+
f"Detected project license (heuristic): {lic}\n"
|
|
441
|
+
f"GitHub owner/org (may differ from copyright holder): {owner}\n"
|
|
442
|
+
f"\n"
|
|
443
|
+
f"--- Suggested skeleton (edit before use) ---\n"
|
|
444
|
+
f"Copyright (c) {y} [COPYRIGHT HOLDER NAME(S) — verify manually]\n"
|
|
445
|
+
f"Licensed under {lic}. See the repository LICENSE file for full terms.\n"
|
|
446
|
+
f"\n"
|
|
447
|
+
f"Third-party software: list each dependency with its license id,\n"
|
|
448
|
+
f"copyright holders, and any required attribution / NOTICE text.\n"
|
|
449
|
+
f"Permissive licenses (MIT/BSD/Apache-2.0) still require attribution\n"
|
|
450
|
+
f"and preservation of license/notice files upon distribution.\n"
|
|
451
|
+
f"\n"
|
|
452
|
+
f"Generated by GitHub License Scanner for convenience only.\n"
|
|
453
|
+
f"Always verify against upstream sources before commercial use."
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def suggest_replacements(packages: list[PackageLicense]) -> list[ReplacementSuggestion]:
|
|
458
|
+
"""
|
|
459
|
+
Suggest permissive alternatives for strong_copyleft (and notable weak) packages.
|
|
460
|
+
"""
|
|
461
|
+
suggestions: list[ReplacementSuggestion] = []
|
|
462
|
+
for pkg in packages:
|
|
463
|
+
if pkg.risk not in {"strong_copyleft", "weak_copyleft"}:
|
|
464
|
+
continue
|
|
465
|
+
key = pkg.name.lower().split("/")[-1] # strip scope / module path tail
|
|
466
|
+
alts = REPLACEMENT_MAP.get(pkg.name.lower()) or REPLACEMENT_MAP.get(key)
|
|
467
|
+
if alts:
|
|
468
|
+
note = "Curated heuristic alternatives — verify licenses yourself."
|
|
469
|
+
alternatives = alts
|
|
470
|
+
else:
|
|
471
|
+
note = (
|
|
472
|
+
"No curated alternative on file. Search the same ecosystem for "
|
|
473
|
+
"packages licensed under MIT, Apache-2.0, or BSD."
|
|
474
|
+
)
|
|
475
|
+
alternatives = [
|
|
476
|
+
f"Search {pkg.ecosystem} for MIT/Apache alternatives to '{pkg.name}'"
|
|
477
|
+
]
|
|
478
|
+
suggestions.append(
|
|
479
|
+
ReplacementSuggestion(
|
|
480
|
+
package=pkg.name,
|
|
481
|
+
ecosystem=pkg.ecosystem,
|
|
482
|
+
license_id=pkg.license_id,
|
|
483
|
+
alternatives=alternatives,
|
|
484
|
+
note=note,
|
|
485
|
+
)
|
|
486
|
+
)
|
|
487
|
+
return suggestions
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _is_network_copyleft(license_id: str | None) -> bool:
|
|
491
|
+
"""True if the license string looks like AGPL/SSPL (SaaS/network sensitive)."""
|
|
492
|
+
from .spdx_engine import is_network_copyleft_expression
|
|
493
|
+
|
|
494
|
+
return is_network_copyleft_expression(license_id)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def compute_verdict(
|
|
498
|
+
repo_license: str | None,
|
|
499
|
+
packages: list[PackageLicense],
|
|
500
|
+
) -> tuple[bool, bool, bool, bool, str, bool, bool]:
|
|
501
|
+
"""
|
|
502
|
+
Heuristic closed-source risk signals (NOT a legal determination).
|
|
503
|
+
|
|
504
|
+
Strong copyleft in **production** deps (or the repo license) sets
|
|
505
|
+
forces_open_source. Strong copyleft only in **dev** deps is reported but
|
|
506
|
+
does not auto-force open.
|
|
507
|
+
|
|
508
|
+
Linking models (static vs dynamic), distribution vs SaaS, dual-licensing,
|
|
509
|
+
additional permissions, and contractual overlays are NOT fully modeled.
|
|
510
|
+
|
|
511
|
+
Returns:
|
|
512
|
+
can_sell_closed, forces_open_source, has_weak_copyleft,
|
|
513
|
+
has_unknown_licenses, verdict_summary, strong_copyleft_dev_only,
|
|
514
|
+
has_network_copyleft
|
|
515
|
+
"""
|
|
516
|
+
repo_risk = classify_license(repo_license)
|
|
517
|
+
prod = [p for p in packages if not p.is_dev]
|
|
518
|
+
dev = [p for p in packages if p.is_dev]
|
|
519
|
+
|
|
520
|
+
strong_prod = [p for p in prod if p.risk == "strong_copyleft"]
|
|
521
|
+
strong_dev = [p for p in dev if p.risk == "strong_copyleft"]
|
|
522
|
+
weak_pkgs = [p for p in packages if p.risk == "weak_copyleft"]
|
|
523
|
+
unknown_pkgs = [p for p in packages if p.risk == "unknown"]
|
|
524
|
+
unknown_prod = [p for p in prod if p.risk == "unknown"]
|
|
525
|
+
|
|
526
|
+
network_hits = []
|
|
527
|
+
if _is_network_copyleft(repo_license):
|
|
528
|
+
network_hits.append(f"repo:{repo_license}")
|
|
529
|
+
for p in packages:
|
|
530
|
+
if _is_network_copyleft(p.license_id):
|
|
531
|
+
network_hits.append(p.name)
|
|
532
|
+
has_network = bool(network_hits)
|
|
533
|
+
|
|
534
|
+
forces = False
|
|
535
|
+
reasons: list[str] = []
|
|
536
|
+
strong_dev_only = False
|
|
537
|
+
|
|
538
|
+
if repo_risk == "strong_copyleft":
|
|
539
|
+
forces = True
|
|
540
|
+
reasons.append(
|
|
541
|
+
f"The repository license ({repo_license}) is classified as strong "
|
|
542
|
+
f"copyleft (GPL/AGPL/SSPL-class). Distributing a modified or combined "
|
|
543
|
+
f"work often requires providing corresponding source under compatible "
|
|
544
|
+
f"terms — outcome depends on how you combine and distribute the code."
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
if strong_prod:
|
|
548
|
+
forces = True
|
|
549
|
+
names = ", ".join(sorted({p.name for p in strong_prod})[:12])
|
|
550
|
+
extra = "…" if len(strong_prod) > 12 else ""
|
|
551
|
+
reasons.append(
|
|
552
|
+
f"Strong copyleft in production dependencies ({len(strong_prod)}): "
|
|
553
|
+
f"{names}{extra}. Depending on linking/distribution model, this can "
|
|
554
|
+
f"require opening source of combined works (GPL family). This tool "
|
|
555
|
+
f"does not analyze your binary linking graph."
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
if has_network:
|
|
559
|
+
names = ", ".join(sorted(set(network_hits))[:8])
|
|
560
|
+
reasons.append(
|
|
561
|
+
f"Network-copyleft / SaaS-sensitive license signal (AGPL/SSPL-class): "
|
|
562
|
+
f"{names}. Even without traditional 'distribution', offering the "
|
|
563
|
+
f"software over a network may trigger source-offer obligations. "
|
|
564
|
+
f"Review AGPL §13 / SSPL terms with counsel before SaaS deployment."
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
if strong_dev and not strong_prod:
|
|
568
|
+
strong_dev_only = True
|
|
569
|
+
names = ", ".join(sorted({p.name for p in strong_dev})[:8])
|
|
570
|
+
reasons.append(
|
|
571
|
+
f"Strong copyleft only in dev/test dependencies ({len(strong_dev)}): "
|
|
572
|
+
f"{names}. These often do not ship in production, but confirm your "
|
|
573
|
+
f"build/distribution pipeline does not bundle them."
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
has_weak = bool(weak_pkgs) or repo_risk == "weak_copyleft"
|
|
577
|
+
has_unknown = bool(unknown_pkgs) or repo_risk == "unknown" or not repo_license
|
|
578
|
+
|
|
579
|
+
can_sell = not forces
|
|
580
|
+
|
|
581
|
+
if can_sell and has_weak:
|
|
582
|
+
weak_prod = [p for p in weak_pkgs if not p.is_dev]
|
|
583
|
+
reasons.append(
|
|
584
|
+
f"Weak copyleft packages present ({len(weak_pkgs)} total, "
|
|
585
|
+
f"{len(weak_prod)} production: MPL/LGPL/EPL/EUPL-class). "
|
|
586
|
+
f"You may often keep your own code closed if you comply with "
|
|
587
|
+
f"file-level, library, or reciprocal obligations — review each license."
|
|
588
|
+
)
|
|
589
|
+
if can_sell and has_unknown:
|
|
590
|
+
reasons.append(
|
|
591
|
+
f"Some licenses could not be determined "
|
|
592
|
+
f"({len(unknown_prod)} production / {len(unknown_pkgs)} total packages"
|
|
593
|
+
f"{' and/or missing repo license' if not repo_license else ''}). "
|
|
594
|
+
f"Manual review required before commercial closed-source distribution."
|
|
595
|
+
)
|
|
596
|
+
if can_sell and not reasons:
|
|
597
|
+
reasons.append(
|
|
598
|
+
"No strong copyleft (GPL/AGPL-class) signals found in the repository "
|
|
599
|
+
"license or production dependencies. Closed-source commercial sale may "
|
|
600
|
+
"be feasible, subject to attribution, NOTICE, patent, trademark, and "
|
|
601
|
+
"export terms of each permissive license (MIT/BSD/Apache-2.0, etc.)."
|
|
602
|
+
)
|
|
603
|
+
elif can_sell:
|
|
604
|
+
reasons.append(
|
|
605
|
+
"Even without strong copyleft, permissive licenses still require "
|
|
606
|
+
"preserving copyright/license notices upon redistribution."
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
summary = " ".join(reasons)
|
|
610
|
+
summary += (
|
|
611
|
+
" DISCLAIMER: Automated heuristic only — NOT legal advice, NOT a license "
|
|
612
|
+
"compatibility opinion, and NOT a warranty of non-infringement. Consult a "
|
|
613
|
+
"qualified attorney for compliance decisions."
|
|
614
|
+
)
|
|
615
|
+
return can_sell, forces, has_weak, has_unknown, summary, strong_dev_only, has_network
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def compute_risk_score(
|
|
619
|
+
repo_license: str | None,
|
|
620
|
+
packages: list[PackageLicense],
|
|
621
|
+
) -> tuple[int, str]:
|
|
622
|
+
"""
|
|
623
|
+
Compute a 0–100 risk score (higher = more concern for closed-source sale).
|
|
624
|
+
|
|
625
|
+
Weight production deps more heavily than dev deps.
|
|
626
|
+
"""
|
|
627
|
+
score = 0
|
|
628
|
+
repo_risk = classify_license(repo_license)
|
|
629
|
+
if repo_risk == "strong_copyleft":
|
|
630
|
+
score += 55
|
|
631
|
+
elif repo_risk == "weak_copyleft":
|
|
632
|
+
score += 15
|
|
633
|
+
elif repo_risk == "unknown" or not repo_license:
|
|
634
|
+
score += 10
|
|
635
|
+
|
|
636
|
+
for pkg in packages:
|
|
637
|
+
w = 0.35 if pkg.is_dev else 1.0
|
|
638
|
+
if pkg.risk == "strong_copyleft":
|
|
639
|
+
score += 18 * w
|
|
640
|
+
elif pkg.risk == "weak_copyleft":
|
|
641
|
+
score += 6 * w
|
|
642
|
+
elif pkg.risk == "unknown":
|
|
643
|
+
score += 4 * w
|
|
644
|
+
|
|
645
|
+
score_i = int(min(100, round(score)))
|
|
646
|
+
if score_i >= 70:
|
|
647
|
+
label = "high"
|
|
648
|
+
elif score_i >= 40:
|
|
649
|
+
label = "medium"
|
|
650
|
+
elif score_i >= 15:
|
|
651
|
+
label = "low"
|
|
652
|
+
else:
|
|
653
|
+
label = "minimal"
|
|
654
|
+
return score_i, label
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def group_by_license(packages: list[PackageLicense]) -> dict[str, list[PackageLicense]]:
|
|
658
|
+
"""Group packages by license id (display key)."""
|
|
659
|
+
grouped: dict[str, list[PackageLicense]] = {}
|
|
660
|
+
for pkg in packages:
|
|
661
|
+
key = pkg.license_id or "Unknown"
|
|
662
|
+
grouped.setdefault(key, []).append(pkg)
|
|
663
|
+
# Sort groups: strong first (red), then weak, unknown, permissive
|
|
664
|
+
risk_order = {"strong_copyleft": 0, "weak_copyleft": 1, "unknown": 2, "permissive": 3}
|
|
665
|
+
|
|
666
|
+
def group_sort_key(item: tuple[str, list[PackageLicense]]) -> tuple[int, str]:
|
|
667
|
+
lic, pkgs = item
|
|
668
|
+
worst = min((risk_order.get(p.risk, 9) for p in pkgs), default=9)
|
|
669
|
+
return (worst, lic.lower())
|
|
670
|
+
|
|
671
|
+
return dict(sorted(grouped.items(), key=group_sort_key))
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
# ---------------------------------------------------------------------------
|
|
675
|
+
# Orchestration
|
|
676
|
+
# ---------------------------------------------------------------------------
|
|
677
|
+
|
|
678
|
+
async def analyze_repository(url: str) -> ScanResult:
|
|
679
|
+
"""
|
|
680
|
+
Full pipeline: parse URL → GitHub metadata → dependency files →
|
|
681
|
+
registry licenses → verdict → replacements → deploy advice.
|
|
682
|
+
"""
|
|
683
|
+
scanned_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
684
|
+
errors: list[str] = []
|
|
685
|
+
|
|
686
|
+
try:
|
|
687
|
+
owner, repo = parse_github_url(url)
|
|
688
|
+
except ValueError as exc:
|
|
689
|
+
return ScanResult(
|
|
690
|
+
owner="",
|
|
691
|
+
repo="",
|
|
692
|
+
url=url,
|
|
693
|
+
repo_license=None,
|
|
694
|
+
scanned_at=scanned_at,
|
|
695
|
+
can_sell_closed=False,
|
|
696
|
+
forces_open_source=False,
|
|
697
|
+
scan_complete=False,
|
|
698
|
+
verdict_summary=str(exc),
|
|
699
|
+
errors=[str(exc)],
|
|
700
|
+
copyright_notice="",
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
repo_license: str | None = None
|
|
704
|
+
primary_language: str | None = None
|
|
705
|
+
description: str | None = None
|
|
706
|
+
topics: list[str] = []
|
|
707
|
+
dep_paths: list[str] = []
|
|
708
|
+
has_dockerfile = False
|
|
709
|
+
packages: list[PackageLicense] = []
|
|
710
|
+
dependencies: list[Dependency] = []
|
|
711
|
+
html_url = f"https://github.com/{owner}/{repo}"
|
|
712
|
+
|
|
713
|
+
async with create_client() as gh:
|
|
714
|
+
try:
|
|
715
|
+
info = await get_repo_info(gh, owner, repo)
|
|
716
|
+
repo_license = info.get("license_spdx") or info.get("license_name")
|
|
717
|
+
primary_language = info.get("language")
|
|
718
|
+
description = info.get("description")
|
|
719
|
+
topics = list(info.get("topics") or [])
|
|
720
|
+
html_url = info.get("html_url") or html_url
|
|
721
|
+
branch = info["default_branch"]
|
|
722
|
+
|
|
723
|
+
dep_paths, has_dockerfile = await find_dependency_files(
|
|
724
|
+
gh, owner, repo, branch
|
|
725
|
+
)
|
|
726
|
+
if dep_paths:
|
|
727
|
+
contents = await download_dependency_files(
|
|
728
|
+
gh, owner, repo, dep_paths, ref=branch
|
|
729
|
+
)
|
|
730
|
+
dependencies = parse_many(contents)
|
|
731
|
+
else:
|
|
732
|
+
errors.append("No dependency manifest files found in the repository.")
|
|
733
|
+
|
|
734
|
+
except GitHubAPIError as exc:
|
|
735
|
+
errors.append(str(exc))
|
|
736
|
+
# Incomplete scan — do not imply a closed-sale decision either way
|
|
737
|
+
return ScanResult(
|
|
738
|
+
owner=owner,
|
|
739
|
+
repo=repo,
|
|
740
|
+
url=html_url,
|
|
741
|
+
repo_license=None,
|
|
742
|
+
scanned_at=scanned_at,
|
|
743
|
+
can_sell_closed=False,
|
|
744
|
+
forces_open_source=False,
|
|
745
|
+
scan_complete=False,
|
|
746
|
+
has_weak_copyleft=False,
|
|
747
|
+
has_unknown_licenses=True,
|
|
748
|
+
verdict_summary=(
|
|
749
|
+
f"Scan incomplete: {exc} "
|
|
750
|
+
"No closed-source sellability decision can be made until "
|
|
751
|
+
"GitHub data is available. Set GITHUB_TOKEN if you hit rate limits. "
|
|
752
|
+
"DISCLAIMER: Automated heuristic only — NOT legal advice."
|
|
753
|
+
),
|
|
754
|
+
errors=errors,
|
|
755
|
+
copyright_notice=build_copyright_notice(owner, repo, None),
|
|
756
|
+
)
|
|
757
|
+
except httpx.HTTPError as exc:
|
|
758
|
+
errors.append(f"Network error talking to GitHub: {exc}")
|
|
759
|
+
return ScanResult(
|
|
760
|
+
owner=owner,
|
|
761
|
+
repo=repo,
|
|
762
|
+
url=html_url,
|
|
763
|
+
repo_license=None,
|
|
764
|
+
scanned_at=scanned_at,
|
|
765
|
+
can_sell_closed=False,
|
|
766
|
+
forces_open_source=False,
|
|
767
|
+
scan_complete=False,
|
|
768
|
+
has_weak_copyleft=False,
|
|
769
|
+
has_unknown_licenses=True,
|
|
770
|
+
verdict_summary=(
|
|
771
|
+
f"Scan incomplete (network error): {exc} "
|
|
772
|
+
"DISCLAIMER: Automated heuristic only — NOT legal advice."
|
|
773
|
+
),
|
|
774
|
+
errors=errors,
|
|
775
|
+
copyright_notice=build_copyright_notice(owner, repo, None),
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
if len(dependencies) > MAX_PACKAGES_LOOKUP:
|
|
779
|
+
errors.append(
|
|
780
|
+
f"Package list truncated to {MAX_PACKAGES_LOOKUP} of {len(dependencies)} "
|
|
781
|
+
f"for registry lookup performance."
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
if dependencies:
|
|
785
|
+
try:
|
|
786
|
+
packages = await resolve_all_licenses(dependencies)
|
|
787
|
+
except httpx.HTTPError as exc:
|
|
788
|
+
errors.append(f"Registry lookup network error: {exc}")
|
|
789
|
+
packages = [
|
|
790
|
+
PackageLicense(
|
|
791
|
+
name=d.name,
|
|
792
|
+
ecosystem=d.ecosystem,
|
|
793
|
+
license_id=None,
|
|
794
|
+
risk="unknown",
|
|
795
|
+
source_file=d.source_file,
|
|
796
|
+
version_spec=d.version_spec,
|
|
797
|
+
is_dev=d.is_dev,
|
|
798
|
+
)
|
|
799
|
+
for d in dependencies[:MAX_PACKAGES_LOOKUP]
|
|
800
|
+
]
|
|
801
|
+
|
|
802
|
+
(
|
|
803
|
+
can_sell,
|
|
804
|
+
forces,
|
|
805
|
+
has_weak,
|
|
806
|
+
has_unknown,
|
|
807
|
+
summary,
|
|
808
|
+
strong_dev_only,
|
|
809
|
+
has_network,
|
|
810
|
+
) = compute_verdict(repo_license, packages)
|
|
811
|
+
risk_score, risk_label = compute_risk_score(repo_license, packages)
|
|
812
|
+
grouped = group_by_license(packages)
|
|
813
|
+
replacements = suggest_replacements(packages)
|
|
814
|
+
deploy = recommend_deploy(
|
|
815
|
+
primary_language=primary_language,
|
|
816
|
+
topics=topics,
|
|
817
|
+
dependencies=dependencies,
|
|
818
|
+
dependency_files=dep_paths,
|
|
819
|
+
has_dockerfile=has_dockerfile,
|
|
820
|
+
description=description,
|
|
821
|
+
)
|
|
822
|
+
notice = build_copyright_notice(owner, repo, repo_license)
|
|
823
|
+
prod_n = sum(1 for p in packages if not p.is_dev)
|
|
824
|
+
dev_n = sum(1 for p in packages if p.is_dev)
|
|
825
|
+
|
|
826
|
+
return ScanResult(
|
|
827
|
+
owner=owner,
|
|
828
|
+
repo=repo,
|
|
829
|
+
url=html_url,
|
|
830
|
+
repo_license=repo_license,
|
|
831
|
+
packages=packages,
|
|
832
|
+
grouped=grouped,
|
|
833
|
+
can_sell_closed=can_sell,
|
|
834
|
+
forces_open_source=forces,
|
|
835
|
+
has_weak_copyleft=has_weak,
|
|
836
|
+
has_unknown_licenses=has_unknown,
|
|
837
|
+
verdict_summary=summary,
|
|
838
|
+
copyright_notice=notice,
|
|
839
|
+
replacements=replacements,
|
|
840
|
+
deploy_advice=deploy,
|
|
841
|
+
scanned_at=scanned_at,
|
|
842
|
+
errors=errors,
|
|
843
|
+
scan_complete=True,
|
|
844
|
+
primary_language=primary_language,
|
|
845
|
+
description=description,
|
|
846
|
+
topics=topics,
|
|
847
|
+
dependency_files=dep_paths,
|
|
848
|
+
risk_score=risk_score,
|
|
849
|
+
risk_score_label=risk_label,
|
|
850
|
+
prod_package_count=prod_n,
|
|
851
|
+
dev_package_count=dev_n,
|
|
852
|
+
strong_copyleft_dev_only=strong_dev_only,
|
|
853
|
+
has_network_copyleft=has_network,
|
|
854
|
+
has_dockerfile=has_dockerfile,
|
|
855
|
+
)
|