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 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())