resqui 0.2.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.
- resqui/__init__.py +0 -0
- resqui/api.py +33 -0
- resqui/cli.py +261 -0
- resqui/config.py +43 -0
- resqui/core.py +98 -0
- resqui/docopt.py +579 -0
- resqui/executors/__init__.py +5 -0
- resqui/executors/base.py +4 -0
- resqui/executors/docker.py +36 -0
- resqui/executors/python.py +78 -0
- resqui/plugins/__init__.py +19 -0
- resqui/plugins/base.py +13 -0
- resqui/plugins/cffconvert.py +45 -0
- resqui/plugins/gitleaks.py +63 -0
- resqui/plugins/howfairis.py +48 -0
- resqui/plugins/openssfscorecard.py +254 -0
- resqui/plugins/rsfc.py +379 -0
- resqui/plugins/superlinter.py +75 -0
- resqui/tools.py +68 -0
- resqui/version.py +24 -0
- resqui-0.2.0.dist-info/METADATA +88 -0
- resqui-0.2.0.dist-info/RECORD +26 -0
- resqui-0.2.0.dist-info/WHEEL +5 -0
- resqui-0.2.0.dist-info/entry_points.txt +2 -0
- resqui-0.2.0.dist-info/licenses/LICENSE +21 -0
- resqui-0.2.0.dist-info/top_level.txt +1 -0
resqui/__init__.py
ADDED
|
File without changes
|
resqui/api.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import http.client
|
|
4
|
+
import os
|
|
5
|
+
from resqui.version import version
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class APIClient:
|
|
9
|
+
endpoint_url = "api.dashverse.cloud"
|
|
10
|
+
|
|
11
|
+
def __init__(self, bearer_token=None):
|
|
12
|
+
if bearer_token is None:
|
|
13
|
+
bearer_token = os.environ.get("DASHVERSE_TOKEN")
|
|
14
|
+
if bearer_token is None or bearer_token == "":
|
|
15
|
+
raise ValueError("Missing authentication token")
|
|
16
|
+
self.headers = {
|
|
17
|
+
"accept": "application/json",
|
|
18
|
+
"Authorization": f"Bearer {bearer_token}",
|
|
19
|
+
"Content-Type": "application/json",
|
|
20
|
+
# TODO: turn this into minimal in production
|
|
21
|
+
"Prefer": "return=representation",
|
|
22
|
+
"User-Agent": f"resqui/{version}",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
def post(self, payload):
|
|
26
|
+
conn = http.client.HTTPSConnection(self.endpoint_url)
|
|
27
|
+
conn.request("POST", "/assessment", payload, self.headers)
|
|
28
|
+
res = conn.getresponse()
|
|
29
|
+
status = res.status
|
|
30
|
+
reason = res.reason
|
|
31
|
+
data = res.read().decode("utf-8")
|
|
32
|
+
if not (200 <= status < 300):
|
|
33
|
+
raise RuntimeError(f"Request failed with {status} {reason}: {data}")
|
resqui/cli.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Usage:
|
|
3
|
+
resqui [options]
|
|
4
|
+
resqui indicators
|
|
5
|
+
|
|
6
|
+
Options:
|
|
7
|
+
-u <repository_url> URL of the repository to be analyzed.
|
|
8
|
+
-c <config_file> Path to the configuration file.
|
|
9
|
+
-o <output_file> Path to the output file [default: resqui_summary.json].
|
|
10
|
+
-t <github_token> GitHub API token.
|
|
11
|
+
-d <dashverse_token> DashVerse API token.
|
|
12
|
+
-b <branch> The Git branch to be checked.
|
|
13
|
+
-v Verbose output.
|
|
14
|
+
--version Show the version of the script.
|
|
15
|
+
--help Show this help message.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import itertools
|
|
19
|
+
import time
|
|
20
|
+
import threading
|
|
21
|
+
import importlib
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
|
|
28
|
+
from resqui.core import Context, Summary
|
|
29
|
+
from resqui.config import Configuration
|
|
30
|
+
from resqui.tools import indented, to_https, project_name_from_url, ensure_list
|
|
31
|
+
from resqui.plugins import IndicatorPlugin, PluginInitError
|
|
32
|
+
from resqui.executors import ExecutorInitError
|
|
33
|
+
from resqui.docopt import docopt
|
|
34
|
+
from resqui.version import __version__
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Spinner:
|
|
38
|
+
"""
|
|
39
|
+
A simple spinner class to indicate progress in the console.
|
|
40
|
+
Use it as a context manager.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, print_time=True):
|
|
44
|
+
self.spinning = False
|
|
45
|
+
self.spinner_thread = None
|
|
46
|
+
self.start_time = time.time()
|
|
47
|
+
self.print_time = print_time
|
|
48
|
+
|
|
49
|
+
def start(self):
|
|
50
|
+
self.spinning = True
|
|
51
|
+
self.spinner_thread = threading.Thread(target=self._spinner)
|
|
52
|
+
self.spinner_thread.start()
|
|
53
|
+
|
|
54
|
+
def stop(self):
|
|
55
|
+
self.spinning = False
|
|
56
|
+
elapsed_time = time.time() - self.start_time
|
|
57
|
+
if self.spinner_thread:
|
|
58
|
+
self.spinner_thread.join()
|
|
59
|
+
if self.print_time:
|
|
60
|
+
print(f"({elapsed_time:.1f}s)", end=": ")
|
|
61
|
+
|
|
62
|
+
def _spinner(self):
|
|
63
|
+
for char in itertools.cycle("|/-\\"):
|
|
64
|
+
sys.stdout.write(char)
|
|
65
|
+
sys.stdout.flush()
|
|
66
|
+
time.sleep(0.1)
|
|
67
|
+
sys.stdout.write("\b")
|
|
68
|
+
if not self.spinning:
|
|
69
|
+
break
|
|
70
|
+
|
|
71
|
+
def __enter__(self):
|
|
72
|
+
if not sys.stdout.isatty():
|
|
73
|
+
return self
|
|
74
|
+
self.start()
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
78
|
+
self.stop()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class GitInspector:
|
|
82
|
+
def __init__(self, path="."):
|
|
83
|
+
self.path = os.path.abspath(path)
|
|
84
|
+
|
|
85
|
+
def git(self, *args):
|
|
86
|
+
return subprocess.check_output(
|
|
87
|
+
["git", "-C", self.path] + list(args), text=True
|
|
88
|
+
).strip()
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def version(self):
|
|
92
|
+
return self.git("describe", "--tags", "--always")
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def project_name_from_url(self):
|
|
96
|
+
return project_name_from_url(self.remote_url)
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def current_commit_hash(self):
|
|
100
|
+
return self.git("rev-parse", "HEAD")
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def author(self):
|
|
104
|
+
return self.git("show", "-s", "--pretty=format:%an", "HEAD")
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def email(self):
|
|
108
|
+
return self.git("show", "-s", "--pretty=format:%ae", "HEAD")
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def remote_url(self):
|
|
112
|
+
return self.git("config", "--get", "remote.origin.url")
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def remote_https_url(self):
|
|
116
|
+
return to_https(self.remote_url)
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def is_a_git_repository(self):
|
|
120
|
+
return os.path.isdir(os.path.join(self.path, ".git"))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def resqui():
|
|
124
|
+
args = docopt(__doc__, version=__version__)
|
|
125
|
+
|
|
126
|
+
if args["indicators"]:
|
|
127
|
+
print_indicator_plugins()
|
|
128
|
+
exit(0)
|
|
129
|
+
|
|
130
|
+
configuration = Configuration(args["-c"])
|
|
131
|
+
output_file = args["-o"]
|
|
132
|
+
url = args["-u"]
|
|
133
|
+
branch = args["-b"]
|
|
134
|
+
github_token = args["-t"]
|
|
135
|
+
dashverse_token = args["-d"]
|
|
136
|
+
verbose = args["-v"]
|
|
137
|
+
|
|
138
|
+
temp_dir = None
|
|
139
|
+
if url is None:
|
|
140
|
+
gitinspector = GitInspector()
|
|
141
|
+
if not gitinspector.is_a_git_repository:
|
|
142
|
+
print(
|
|
143
|
+
"Error: Not a Git repository. Either run resqui from within a repository or specify one with -u <url>"
|
|
144
|
+
)
|
|
145
|
+
exit(1)
|
|
146
|
+
else:
|
|
147
|
+
temp_dir = tempfile.mkdtemp()
|
|
148
|
+
try:
|
|
149
|
+
subprocess.run(
|
|
150
|
+
["git", "clone", url, temp_dir],
|
|
151
|
+
check=True,
|
|
152
|
+
stdout=subprocess.DEVNULL,
|
|
153
|
+
stderr=subprocess.DEVNULL,
|
|
154
|
+
)
|
|
155
|
+
except subprocess.CalledProcessError as e:
|
|
156
|
+
print(f"Error cloning {url}: {e}")
|
|
157
|
+
raise
|
|
158
|
+
gitinspector = GitInspector(temp_dir)
|
|
159
|
+
|
|
160
|
+
url = gitinspector.remote_https_url
|
|
161
|
+
project_name = gitinspector.project_name_from_url
|
|
162
|
+
author = gitinspector.author
|
|
163
|
+
email = gitinspector.email
|
|
164
|
+
software_version = gitinspector.version
|
|
165
|
+
|
|
166
|
+
branch_hash_or_tag = gitinspector.current_commit_hash if branch is None else branch
|
|
167
|
+
|
|
168
|
+
if temp_dir is not None:
|
|
169
|
+
shutil.rmtree(temp_dir)
|
|
170
|
+
|
|
171
|
+
if github_token is not None:
|
|
172
|
+
print("GitHub API token \033[92m✔\033[0m")
|
|
173
|
+
else:
|
|
174
|
+
print("GitHub API token \033[91m✖\033[0m")
|
|
175
|
+
|
|
176
|
+
context = Context(github_token=github_token, dashverse_token=dashverse_token)
|
|
177
|
+
|
|
178
|
+
print(f"Repository URL: {url}")
|
|
179
|
+
print(f"Project name: {project_name}")
|
|
180
|
+
print(f"Author: {author}")
|
|
181
|
+
print(f"Email: {email}")
|
|
182
|
+
print(f"Version: {software_version}")
|
|
183
|
+
print(f"Branch, tag or commit hash: {branch_hash_or_tag}")
|
|
184
|
+
print("Checking indicators ...")
|
|
185
|
+
|
|
186
|
+
summary = Summary(
|
|
187
|
+
author, email, project_name, url, software_version, branch_hash_or_tag
|
|
188
|
+
)
|
|
189
|
+
plugin_instances = {}
|
|
190
|
+
for indicator in configuration._cfg["indicators"]:
|
|
191
|
+
print(
|
|
192
|
+
f" {indicator['name']}/{indicator['plugin']}",
|
|
193
|
+
end=" ",
|
|
194
|
+
)
|
|
195
|
+
sys.stdout.flush()
|
|
196
|
+
|
|
197
|
+
base_package = __name__.rsplit(".", 1)[0]
|
|
198
|
+
plugin_class_name = indicator["plugin"]
|
|
199
|
+
|
|
200
|
+
if plugin_class_name not in plugin_instances:
|
|
201
|
+
plugin_module = importlib.import_module(base_package + ".plugins")
|
|
202
|
+
plugin_class = getattr(plugin_module, plugin_class_name)
|
|
203
|
+
with Spinner(print_time=False):
|
|
204
|
+
try:
|
|
205
|
+
plugin_instances[plugin_class_name] = plugin_class(context)
|
|
206
|
+
except (ExecutorInitError, PluginInitError) as e:
|
|
207
|
+
print(f"⚠️ {e} (skipping its indicators)")
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
plugin_instance = plugin_instances[plugin_class_name]
|
|
211
|
+
plugin_method = indicator["name"]
|
|
212
|
+
|
|
213
|
+
with Spinner():
|
|
214
|
+
results = getattr(plugin_instance, plugin_method)(url, branch_hash_or_tag)
|
|
215
|
+
|
|
216
|
+
for result in ensure_list(results):
|
|
217
|
+
status = "\033[92m✔\033[0m" if result else "\033[91m✖\033[0m"
|
|
218
|
+
if verbose:
|
|
219
|
+
print(indented("\n" + result.evidence + status, 4), end="")
|
|
220
|
+
else:
|
|
221
|
+
print(status, end=" ")
|
|
222
|
+
|
|
223
|
+
summary.add_indicator_result(indicator, plugin_class, result)
|
|
224
|
+
print()
|
|
225
|
+
|
|
226
|
+
summary.write(output_file)
|
|
227
|
+
print(f"Summary has been written to {output_file}")
|
|
228
|
+
|
|
229
|
+
print("Publishing summary ", end="")
|
|
230
|
+
sys.stdout.flush()
|
|
231
|
+
try:
|
|
232
|
+
summary.upload(context.dashverse_token)
|
|
233
|
+
except (RuntimeError, ValueError) as e:
|
|
234
|
+
print(f"\033[91m✖\033[0m {e}")
|
|
235
|
+
else:
|
|
236
|
+
print("\033[92m✔\033[0m")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def print_indicator_plugins():
|
|
240
|
+
"""
|
|
241
|
+
Prints a list of available indicator plugins.
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
def subclasses(cls):
|
|
245
|
+
return set(cls.__subclasses__()).union(
|
|
246
|
+
s for c in cls.__subclasses__() for s in subclasses(c)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
for cls in sorted(subclasses(IndicatorPlugin), key=lambda c: c.__name__):
|
|
250
|
+
print(f"Class: {cls.__name__}")
|
|
251
|
+
for attr in ["name", "version", "id"]:
|
|
252
|
+
value = getattr(cls, attr, None)
|
|
253
|
+
print(f" {attr.capitalize()}: {value}")
|
|
254
|
+
indicators = getattr(cls, "indicators", [])
|
|
255
|
+
print(" Indicators:")
|
|
256
|
+
if indicators:
|
|
257
|
+
for ind in indicators:
|
|
258
|
+
print(f" - {ind}")
|
|
259
|
+
else:
|
|
260
|
+
print(" (none)")
|
|
261
|
+
print()
|
resqui/config.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
DEFAULT_CONFIG = {
|
|
4
|
+
"indicators": [
|
|
5
|
+
{
|
|
6
|
+
"name": "has_license",
|
|
7
|
+
"plugin": "HowFairIs",
|
|
8
|
+
"@id": "https://w3id.org/everse/i/indicators/license",
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"name": "has_citation",
|
|
12
|
+
"plugin": "CFFConvert",
|
|
13
|
+
"@id": "https://w3id.org/everse/i/indicators/citation",
|
|
14
|
+
},
|
|
15
|
+
{"name": "has_ci_tests", "plugin": "OpenSSFScorecard", "@id": "missing"},
|
|
16
|
+
{
|
|
17
|
+
"name": "human_code_review_requirement",
|
|
18
|
+
"plugin": "OpenSSFScorecard",
|
|
19
|
+
"@id": "missing",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"name": "has_published_package",
|
|
23
|
+
"plugin": "OpenSSFScorecard",
|
|
24
|
+
"@id": "missing",
|
|
25
|
+
},
|
|
26
|
+
{"name": "has_no_security_leak", "plugin": "Gitleaks", "@id": "missing"},
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Configuration:
|
|
32
|
+
"""
|
|
33
|
+
A basic wrapper for the configuration.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, filepath=None):
|
|
37
|
+
if filepath is None:
|
|
38
|
+
print("Loading default configuration.")
|
|
39
|
+
self._cfg = DEFAULT_CONFIG
|
|
40
|
+
else:
|
|
41
|
+
print(f"Loading configuration from '{filepath}'.")
|
|
42
|
+
with open(filepath) as f:
|
|
43
|
+
self._cfg = json.load(f)
|
resqui/core.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from resqui.api import APIClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Context:
|
|
12
|
+
"""A basic context to hold"""
|
|
13
|
+
|
|
14
|
+
github_token: Optional[str] = None
|
|
15
|
+
dashverse_token: Optional[str] = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class CheckResult:
|
|
20
|
+
"""
|
|
21
|
+
Datatype for indicator check results.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
process: str = "Undefined process"
|
|
25
|
+
status_id: str = "missing"
|
|
26
|
+
output: str = "missing"
|
|
27
|
+
evidence: str = "missing"
|
|
28
|
+
success: bool = False
|
|
29
|
+
|
|
30
|
+
def __bool__(self):
|
|
31
|
+
return self.success
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Summary:
|
|
35
|
+
"""
|
|
36
|
+
Summary of the software quality assessment.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
author,
|
|
42
|
+
email,
|
|
43
|
+
project_name,
|
|
44
|
+
repo_url,
|
|
45
|
+
software_version,
|
|
46
|
+
branch_hash_or_tag,
|
|
47
|
+
):
|
|
48
|
+
self.author = author
|
|
49
|
+
self.email = email
|
|
50
|
+
self.project_name = project_name
|
|
51
|
+
self.repo_url = repo_url
|
|
52
|
+
self.software_version = software_version
|
|
53
|
+
self.branch_hash_or_tag = branch_hash_or_tag
|
|
54
|
+
self.checks = []
|
|
55
|
+
|
|
56
|
+
def add_indicator_result(self, indicator, checking_software, result):
|
|
57
|
+
self.checks.append(
|
|
58
|
+
{
|
|
59
|
+
"@type": "CheckResult",
|
|
60
|
+
"assessesIndicator": {"@id": indicator["@id"]},
|
|
61
|
+
"checkingSoftware": {
|
|
62
|
+
"name": checking_software.name,
|
|
63
|
+
"version": checking_software.version,
|
|
64
|
+
},
|
|
65
|
+
"process": result.process,
|
|
66
|
+
"status": {"@id": result.status_id},
|
|
67
|
+
"output": result.output,
|
|
68
|
+
"evidence": result.evidence,
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def to_json(self):
|
|
73
|
+
return json.dumps(
|
|
74
|
+
{
|
|
75
|
+
"@context": "https://w3id.org/everse/rsqa/0.0.1/",
|
|
76
|
+
"@type": "SoftwareQualityAssessment",
|
|
77
|
+
"dateCreated": str(datetime.now()),
|
|
78
|
+
"license": "CC0-1.0",
|
|
79
|
+
"author": {"@type": "Person", "name": "Quality Pipeline"},
|
|
80
|
+
"assessedSoftware": {
|
|
81
|
+
"@type": "SoftwareApplication",
|
|
82
|
+
"name": self.project_name,
|
|
83
|
+
"softwareVersion": self.software_version,
|
|
84
|
+
"url": self.repo_url,
|
|
85
|
+
},
|
|
86
|
+
"checks": self.checks,
|
|
87
|
+
},
|
|
88
|
+
sort_keys=True,
|
|
89
|
+
indent=4,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def write(self, filename):
|
|
93
|
+
with open(filename, "w") as f:
|
|
94
|
+
f.write(self.to_json())
|
|
95
|
+
|
|
96
|
+
def upload(self, dashverse_token=None):
|
|
97
|
+
api = APIClient(dashverse_token)
|
|
98
|
+
api.post(self.to_json())
|