artefacts-cli 0.6.8__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.
- artefacts/cli/__init__.py +342 -0
- artefacts/cli/app.py +617 -0
- artefacts/cli/bagparser.py +98 -0
- artefacts/cli/constants.py +16 -0
- artefacts/cli/other.py +40 -0
- artefacts/cli/parameters.py +23 -0
- artefacts/cli/ros1.py +240 -0
- artefacts/cli/ros2.py +125 -0
- artefacts/cli/utils.py +35 -0
- artefacts/cli/utils_ros.py +68 -0
- artefacts/cli/version.py +16 -0
- artefacts/wrappers/artefacts_ros1_meta.launch +45 -0
- artefacts_cli-0.6.8.dist-info/METADATA +101 -0
- artefacts_cli-0.6.8.dist-info/RECORD +17 -0
- artefacts_cli-0.6.8.dist-info/WHEEL +5 -0
- artefacts_cli-0.6.8.dist-info/entry_points.txt +2 -0
- artefacts_cli-0.6.8.dist-info/top_level.txt +1 -0
artefacts/cli/app.py
ADDED
@@ -0,0 +1,617 @@
|
|
1
|
+
import configparser
|
2
|
+
import getpass
|
3
|
+
import json
|
4
|
+
import os
|
5
|
+
import platform
|
6
|
+
import random
|
7
|
+
import subprocess
|
8
|
+
import sys
|
9
|
+
import tarfile
|
10
|
+
import tempfile
|
11
|
+
import time
|
12
|
+
from typing import Any, Union
|
13
|
+
from urllib.parse import urlparse
|
14
|
+
import webbrowser
|
15
|
+
|
16
|
+
import yaml
|
17
|
+
import click
|
18
|
+
import requests
|
19
|
+
from pathlib import Path
|
20
|
+
from gitignore_parser import parse_gitignore
|
21
|
+
|
22
|
+
from artefacts.cli import init_job, generate_scenarios, AuthenticationError, __version__
|
23
|
+
from artefacts.cli.constants import DEPRECATED_FRAMEWORKS, SUPPORTED_FRAMEWORKS
|
24
|
+
import artefacts_copava as copava
|
25
|
+
|
26
|
+
HOME = os.path.expanduser("~")
|
27
|
+
CONFIG_DIR = f"{HOME}/.artefacts"
|
28
|
+
CONFIG_PATH = f"{CONFIG_DIR}/config"
|
29
|
+
|
30
|
+
|
31
|
+
def get_git_revision_hash() -> str:
|
32
|
+
try:
|
33
|
+
return (
|
34
|
+
subprocess.check_output(["git", "rev-parse", "HEAD"])
|
35
|
+
.decode("ascii")
|
36
|
+
.strip()
|
37
|
+
)
|
38
|
+
except subprocess.CalledProcessError:
|
39
|
+
return ""
|
40
|
+
|
41
|
+
|
42
|
+
def get_git_revision_branch() -> str:
|
43
|
+
try:
|
44
|
+
return (
|
45
|
+
subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"])
|
46
|
+
.decode("ascii")
|
47
|
+
.strip()
|
48
|
+
)
|
49
|
+
except subprocess.CalledProcessError:
|
50
|
+
return ""
|
51
|
+
|
52
|
+
|
53
|
+
def get_conf_from_file():
|
54
|
+
config = configparser.ConfigParser()
|
55
|
+
if not os.path.isfile(CONFIG_PATH):
|
56
|
+
os.makedirs(CONFIG_DIR, exist_ok=True)
|
57
|
+
config["DEFAULT"] = {}
|
58
|
+
with open(CONFIG_PATH, "w") as f:
|
59
|
+
config.write(f)
|
60
|
+
config.read(CONFIG_PATH)
|
61
|
+
return config
|
62
|
+
|
63
|
+
|
64
|
+
def get_artefacts_api_url(project_profile):
|
65
|
+
return os.environ.get(
|
66
|
+
"ARTEFACTS_API_URL",
|
67
|
+
project_profile.get(
|
68
|
+
"ApiUrl",
|
69
|
+
"https://app.artefacts.com/api",
|
70
|
+
),
|
71
|
+
)
|
72
|
+
|
73
|
+
|
74
|
+
class APIConf:
|
75
|
+
def __init__(self, project_name):
|
76
|
+
config = get_conf_from_file()
|
77
|
+
if project_name in config:
|
78
|
+
profile = config[project_name]
|
79
|
+
else:
|
80
|
+
profile = {}
|
81
|
+
self.api_url = get_artefacts_api_url(profile)
|
82
|
+
self.api_key = os.environ.get("ARTEFACTS_KEY", profile.get("ApiKey", None))
|
83
|
+
if self.api_key is None:
|
84
|
+
batch_id = os.environ.get("AWS_BATCH_JOB_ID", None)
|
85
|
+
job_id = os.environ.get("ARTEFACTS_JOB_ID", None)
|
86
|
+
if batch_id is None or job_id is None:
|
87
|
+
raise click.ClickException(
|
88
|
+
f"No API KEY set. Please run 'artefacts config add {project_name}'"
|
89
|
+
)
|
90
|
+
auth_type = "Internal"
|
91
|
+
# Batch id for array jobs contains array index
|
92
|
+
batch_id = batch_id.split(":")[0]
|
93
|
+
self.headers = {"Authorization": f"{auth_type} {job_id}:{batch_id}"}
|
94
|
+
else:
|
95
|
+
auth_type = "ApiKey"
|
96
|
+
self.headers = {"Authorization": f"{auth_type} {self.api_key}"}
|
97
|
+
self.headers["User-Agent"] = (
|
98
|
+
f"ArtefactsClient/{__version__} ({platform.platform()}/{platform.python_version()})"
|
99
|
+
)
|
100
|
+
click.echo(f"Connecting to {self.api_url} using {auth_type}")
|
101
|
+
|
102
|
+
|
103
|
+
def read_config(filename: str) -> dict:
|
104
|
+
try:
|
105
|
+
with open(filename) as f:
|
106
|
+
return copava.parse(f.read())
|
107
|
+
except FileNotFoundError:
|
108
|
+
raise click.ClickException(f"Project config file {filename} not found.")
|
109
|
+
|
110
|
+
|
111
|
+
def validate_artefacts_config(config_file: str) -> dict:
|
112
|
+
pass
|
113
|
+
|
114
|
+
|
115
|
+
def pretty_print_config_error(
|
116
|
+
errors: Union[str, list, dict], indent: int = 0, prefix: str = "", suffix: str = ""
|
117
|
+
) -> str:
|
118
|
+
if type(errors) is str:
|
119
|
+
header = " " * indent
|
120
|
+
output = header + prefix + errors + suffix
|
121
|
+
elif type(errors) is list:
|
122
|
+
_depth = indent + 1
|
123
|
+
output = []
|
124
|
+
for value in errors:
|
125
|
+
output.append(pretty_print_config_error(value, indent=_depth, prefix="- "))
|
126
|
+
output = os.linesep.join(output)
|
127
|
+
elif type(errors) is dict:
|
128
|
+
_depth = indent + 1
|
129
|
+
output = []
|
130
|
+
for key, value in errors.items():
|
131
|
+
output.append(pretty_print_config_error(key, indent=indent, suffix=":"))
|
132
|
+
output.append(pretty_print_config_error(value, indent=_depth))
|
133
|
+
output = os.linesep.join(output)
|
134
|
+
else:
|
135
|
+
# Must not happen, so broad definition, but we want to know fast.
|
136
|
+
raise Exception(f"Unacceptable data type for config error formatting: {errors}")
|
137
|
+
return output
|
138
|
+
|
139
|
+
|
140
|
+
# Click callback syntax
|
141
|
+
def config_validation(context: dict, param: str, value: str) -> str:
|
142
|
+
if context.params["skip_validation"]:
|
143
|
+
return value
|
144
|
+
config = read_config(value)
|
145
|
+
errors = copava.check(config)
|
146
|
+
if len(errors) == 0:
|
147
|
+
return value
|
148
|
+
else:
|
149
|
+
raise click.BadParameter(pretty_print_config_error(errors))
|
150
|
+
|
151
|
+
|
152
|
+
@click.group()
|
153
|
+
def config():
|
154
|
+
return
|
155
|
+
|
156
|
+
|
157
|
+
@config.command()
|
158
|
+
def path():
|
159
|
+
"""
|
160
|
+
Get the configuration file path
|
161
|
+
"""
|
162
|
+
click.echo(CONFIG_PATH)
|
163
|
+
|
164
|
+
|
165
|
+
def add_key_to_conf(project_name, api_key):
|
166
|
+
config = get_conf_from_file()
|
167
|
+
config[project_name] = {"ApiKey": api_key}
|
168
|
+
with open(CONFIG_PATH, "w") as f:
|
169
|
+
config.write(f)
|
170
|
+
|
171
|
+
|
172
|
+
@config.command()
|
173
|
+
@click.argument("project_name")
|
174
|
+
def add(project_name):
|
175
|
+
"""
|
176
|
+
Set configuration for PROJECT_NAME
|
177
|
+
"""
|
178
|
+
config = get_conf_from_file()
|
179
|
+
if project_name in config:
|
180
|
+
profile = config[project_name]
|
181
|
+
else:
|
182
|
+
profile = {}
|
183
|
+
api_url = get_artefacts_api_url(profile)
|
184
|
+
dashboard_url = api_url.split("/api")[0]
|
185
|
+
settings_page_url = f"{dashboard_url}/{project_name}/settings"
|
186
|
+
# Check if running on WSL
|
187
|
+
if "WSLENV" in os.environ:
|
188
|
+
os.system(f'cmd.exe /C start "" {settings_page_url} 2>/dev/null')
|
189
|
+
else:
|
190
|
+
webbrowser.open(settings_page_url)
|
191
|
+
click.echo(f"Opening the project settings page: {settings_page_url}")
|
192
|
+
api_key = click.prompt(
|
193
|
+
f"Please enter your API KEY for {project_name}", type=str, hide_input=True
|
194
|
+
)
|
195
|
+
add_key_to_conf(project_name, api_key)
|
196
|
+
click.echo(f"API KEY saved for {project_name}")
|
197
|
+
if click.confirm(
|
198
|
+
"Would you like to download the generated artefacts.yaml file? This will overwrite any existing config file in the current directory."
|
199
|
+
):
|
200
|
+
api_conf = APIConf(project_name)
|
201
|
+
config_file_name = "artefacts.yaml"
|
202
|
+
config_file_url = f"{api_url}/{project_name}/{config_file_name}"
|
203
|
+
r = requests.get(config_file_url, headers=api_conf.headers)
|
204
|
+
with open(config_file_name, "wb") as f:
|
205
|
+
f.write(r.content)
|
206
|
+
return
|
207
|
+
|
208
|
+
|
209
|
+
@config.command()
|
210
|
+
@click.argument("project_name")
|
211
|
+
def delete(project_name):
|
212
|
+
"""
|
213
|
+
Delete configuration for PROJECT_NAME
|
214
|
+
"""
|
215
|
+
config = get_conf_from_file()
|
216
|
+
config.remove_section(project_name)
|
217
|
+
with open(CONFIG_PATH, "w") as f:
|
218
|
+
config.write(f)
|
219
|
+
click.echo(f"{project_name} config removed")
|
220
|
+
|
221
|
+
|
222
|
+
@click.command()
|
223
|
+
@click.argument("project_name")
|
224
|
+
def hello(project_name):
|
225
|
+
"""Show message to confirm credentials allow access to PROJECT_NAME"""
|
226
|
+
api_conf = APIConf(project_name)
|
227
|
+
response = requests.get(
|
228
|
+
f"{api_conf.api_url}/{project_name}/info",
|
229
|
+
headers=api_conf.headers,
|
230
|
+
)
|
231
|
+
if response.status_code == 200:
|
232
|
+
result = response.json()
|
233
|
+
click.echo(
|
234
|
+
"Hello " + click.style(f"{result['name']}@{result['framework']}", fg="blue")
|
235
|
+
)
|
236
|
+
else:
|
237
|
+
result = response.json()
|
238
|
+
raise click.ClickException(f"Error getting project info: {result['message']}")
|
239
|
+
|
240
|
+
|
241
|
+
@click.command()
|
242
|
+
@click.option(
|
243
|
+
"--config",
|
244
|
+
callback=config_validation,
|
245
|
+
default="artefacts.yaml",
|
246
|
+
help="Artefacts config file.",
|
247
|
+
)
|
248
|
+
@click.option(
|
249
|
+
"--dryrun",
|
250
|
+
is_flag=True,
|
251
|
+
default=False,
|
252
|
+
help="Dryrun: no tracking or test execution",
|
253
|
+
)
|
254
|
+
@click.option(
|
255
|
+
"--nosim",
|
256
|
+
is_flag=True,
|
257
|
+
default=False,
|
258
|
+
help="nosim: no simulator resource provided by Artefacts",
|
259
|
+
)
|
260
|
+
@click.option(
|
261
|
+
"--noisolation",
|
262
|
+
is_flag=True,
|
263
|
+
default=False,
|
264
|
+
help="noisolation: for debugging, break the 'middleware network' isolation between the test suite and the host (in ROS1: --reuse-master flag / in ROS2: --disable-isolation flag)",
|
265
|
+
)
|
266
|
+
@click.option(
|
267
|
+
"--description",
|
268
|
+
default=None,
|
269
|
+
help="Optional description for this run",
|
270
|
+
)
|
271
|
+
@click.option(
|
272
|
+
"--skip-validation",
|
273
|
+
is_flag=True,
|
274
|
+
default=False,
|
275
|
+
is_eager=True, # Necessary for callbacks to see it.
|
276
|
+
help="Skip configuration validation, so that unsupported settings can be tried out, e.g. non-ROS settings or simulators like SAPIEN.",
|
277
|
+
)
|
278
|
+
@click.argument("jobname")
|
279
|
+
def run(
|
280
|
+
config, jobname, dryrun, nosim, noisolation, description="", skip_validation=False
|
281
|
+
):
|
282
|
+
"""Run JOBNAME locally"""
|
283
|
+
warpconfig = read_config(config)
|
284
|
+
|
285
|
+
project_id = warpconfig["project"]
|
286
|
+
api_conf = APIConf(project_id)
|
287
|
+
click.echo(f"Starting tests for {project_id}")
|
288
|
+
if jobname not in warpconfig["jobs"]:
|
289
|
+
raise click.ClickException(f"Job {jobname} not defined")
|
290
|
+
jobconf = warpconfig["jobs"][jobname]
|
291
|
+
job_type = jobconf.get("type", "test")
|
292
|
+
if job_type not in ["test"]:
|
293
|
+
click.echo(f"Job type not supported: f{job_type}")
|
294
|
+
return
|
295
|
+
|
296
|
+
framework = jobconf["runtime"].get("framework", None)
|
297
|
+
|
298
|
+
# migrate deprecated framework names
|
299
|
+
if framework in DEPRECATED_FRAMEWORKS.keys():
|
300
|
+
migrated_framework = DEPRECATED_FRAMEWORKS[framework]
|
301
|
+
click.echo(
|
302
|
+
f"The selected framework '{framework}' is deprecated. Using '{migrated_framework}' instead."
|
303
|
+
)
|
304
|
+
framework = migrated_framework
|
305
|
+
|
306
|
+
if framework not in SUPPORTED_FRAMEWORKS:
|
307
|
+
click.echo(
|
308
|
+
f"WARNING: framework: '{framework}' is not officially supported. Attempting run."
|
309
|
+
)
|
310
|
+
|
311
|
+
batch_index = os.environ.get("AWS_BATCH_JOB_ARRAY_INDEX", None)
|
312
|
+
if batch_index is not None:
|
313
|
+
batch_index = int(batch_index)
|
314
|
+
click.echo(f"AWS BATCH ARRAY DETECTED, batch_index={batch_index}")
|
315
|
+
scenarios, first = generate_scenarios(jobconf, batch_index)
|
316
|
+
context = None
|
317
|
+
execution_context = getpass.getuser() + "@" + platform.node()
|
318
|
+
context = {
|
319
|
+
"ref": get_git_revision_branch() + "~" + execution_context,
|
320
|
+
"commit": get_git_revision_hash()[:8] + "~",
|
321
|
+
}
|
322
|
+
context["description"] = description
|
323
|
+
try:
|
324
|
+
warpjob = init_job(
|
325
|
+
project_id,
|
326
|
+
api_conf,
|
327
|
+
jobname,
|
328
|
+
jobconf,
|
329
|
+
dryrun,
|
330
|
+
nosim,
|
331
|
+
noisolation,
|
332
|
+
context,
|
333
|
+
first,
|
334
|
+
)
|
335
|
+
except AuthenticationError:
|
336
|
+
raise click.ClickException(
|
337
|
+
"Unable to authenticate, check your Project Name and API Key"
|
338
|
+
)
|
339
|
+
|
340
|
+
job_success = True
|
341
|
+
for scenario_n, scenario in enumerate(scenarios):
|
342
|
+
click.echo(
|
343
|
+
f"Starting scenario {scenario_n+1}/{len(scenarios)}: {scenario['name']}"
|
344
|
+
)
|
345
|
+
try:
|
346
|
+
run = warpjob.new_run(scenario)
|
347
|
+
except AuthenticationError:
|
348
|
+
raise click.ClickException(
|
349
|
+
"Unable to authenticate, check your Project Name and API Key"
|
350
|
+
)
|
351
|
+
if framework is not None and framework.startswith("ros2:"):
|
352
|
+
from artefacts.cli.ros2 import run_ros2_tests
|
353
|
+
|
354
|
+
if "ros_testfile" not in run.params:
|
355
|
+
raise click.ClickException(
|
356
|
+
"Test launch file not specified for ros2 project"
|
357
|
+
)
|
358
|
+
if dryrun:
|
359
|
+
click.echo("performing dry run")
|
360
|
+
results, success = {}, True
|
361
|
+
else:
|
362
|
+
try:
|
363
|
+
results, success = run_ros2_tests(run)
|
364
|
+
except Exception as e:
|
365
|
+
warpjob.stop()
|
366
|
+
warpjob.log_tests_result(False)
|
367
|
+
click.secho(e, bold=True, err=True)
|
368
|
+
raise click.ClickException("artefacts failed to execute the tests")
|
369
|
+
if success is None:
|
370
|
+
run.stop()
|
371
|
+
warpjob.stop()
|
372
|
+
warpjob.log_tests_result(job_success)
|
373
|
+
raise click.ClickException(
|
374
|
+
"Not able to execute tests. Make sure that ROS2 is sourced and that your launch file syntax is correct."
|
375
|
+
)
|
376
|
+
if not success:
|
377
|
+
job_success = False
|
378
|
+
elif framework is not None and framework.startswith("ros1:"):
|
379
|
+
from artefacts.cli.ros1 import run_ros1_tests
|
380
|
+
|
381
|
+
if "ros_testfile" not in run.params:
|
382
|
+
raise click.ClickException(
|
383
|
+
"Test launch file not specified for ros1 project"
|
384
|
+
)
|
385
|
+
if dryrun:
|
386
|
+
click.echo("performing dry run")
|
387
|
+
results, success = {}, True
|
388
|
+
else:
|
389
|
+
results, success = run_ros1_tests(run)
|
390
|
+
if not success:
|
391
|
+
job_success = False
|
392
|
+
else:
|
393
|
+
from artefacts.cli.other import run_other_tests
|
394
|
+
|
395
|
+
if "run" not in run.params:
|
396
|
+
raise click.ClickException("run command not specified for scenario")
|
397
|
+
if dryrun:
|
398
|
+
click.echo("performing dry run")
|
399
|
+
results, success = {}, True
|
400
|
+
else:
|
401
|
+
results, success = run_other_tests(run)
|
402
|
+
if not success:
|
403
|
+
job_success = False
|
404
|
+
if type(run.params.get("metrics", [])) == str:
|
405
|
+
run.log_metrics()
|
406
|
+
|
407
|
+
run.stop()
|
408
|
+
warpjob.log_tests_result(job_success)
|
409
|
+
click.echo("Done")
|
410
|
+
time.sleep(random.random() * 1)
|
411
|
+
|
412
|
+
warpjob.stop()
|
413
|
+
|
414
|
+
|
415
|
+
@click.command()
|
416
|
+
@click.option(
|
417
|
+
"--config",
|
418
|
+
callback=config_validation,
|
419
|
+
default="artefacts.yaml",
|
420
|
+
help="Artefacts config file.",
|
421
|
+
)
|
422
|
+
@click.option(
|
423
|
+
"--description",
|
424
|
+
default=None,
|
425
|
+
help="Optional description for this run",
|
426
|
+
)
|
427
|
+
@click.option(
|
428
|
+
"--skip-validation",
|
429
|
+
is_flag=True,
|
430
|
+
default=False,
|
431
|
+
is_eager=True, # Necessary for callbacks to see it.
|
432
|
+
help="Skip configuration validation, so that unsupported settings can be tried out, e.g. non-ROS settings or simulators like SAPIEN.",
|
433
|
+
)
|
434
|
+
@click.argument("jobname")
|
435
|
+
def run_remote(config, description, jobname, skip_validation=False):
|
436
|
+
"""
|
437
|
+
Run JOBNAME in the cloud by packaging local sources.
|
438
|
+
if a `.artefactsignore` file is present, it will be used to exclude files from the source package.
|
439
|
+
|
440
|
+
This command requires to have a linked GitHub repository
|
441
|
+
"""
|
442
|
+
try:
|
443
|
+
warpconfig = read_config(config)
|
444
|
+
except FileNotFoundError:
|
445
|
+
raise click.ClickException(f"Project config file not found: {config}")
|
446
|
+
project_id = warpconfig["project"]
|
447
|
+
api_conf = APIConf(project_id)
|
448
|
+
project_folder = os.path.dirname(os.path.abspath(config))
|
449
|
+
dashboard_url = urlparse(api_conf.api_url)
|
450
|
+
dashboard_url = f"{dashboard_url.scheme}://{dashboard_url.netloc}/{project_id}"
|
451
|
+
|
452
|
+
try:
|
453
|
+
warpconfig["jobs"][jobname]
|
454
|
+
except KeyError:
|
455
|
+
raise click.ClickException(
|
456
|
+
f"Can't find a job named '{jobname}' in config '{config}'"
|
457
|
+
)
|
458
|
+
|
459
|
+
# Mutate job and then keep only the selected job in the config
|
460
|
+
run_config = warpconfig.copy()
|
461
|
+
job = warpconfig["jobs"][jobname]
|
462
|
+
|
463
|
+
# Use the same logic as `run` for expanding scenarios based on array params
|
464
|
+
job["scenarios"]["settings"], _ = generate_scenarios(job, None)
|
465
|
+
|
466
|
+
# Ensure unique names
|
467
|
+
for idx, scenario in enumerate(job["scenarios"]["settings"]):
|
468
|
+
scenario["name"] = f"{scenario['name']}-{idx}"
|
469
|
+
|
470
|
+
run_config["jobs"] = {jobname: job}
|
471
|
+
if "on" in run_config:
|
472
|
+
del run_config["on"]
|
473
|
+
|
474
|
+
click.echo(f"Packaging source...")
|
475
|
+
|
476
|
+
with tempfile.NamedTemporaryFile(
|
477
|
+
prefix=project_id, suffix=".tgz", delete=True
|
478
|
+
) as temp_file:
|
479
|
+
# get list of patterns to be ignored in .artefactsignore
|
480
|
+
ignore_file = Path(project_folder) / Path(".artefactsignore")
|
481
|
+
try:
|
482
|
+
ignore_matches = parse_gitignore(ignore_file)
|
483
|
+
except FileNotFoundError:
|
484
|
+
ignore_matches = lambda x: False
|
485
|
+
with tarfile.open(fileobj=temp_file, mode="w:gz") as tar_file:
|
486
|
+
for root, dirs, files in os.walk(project_folder):
|
487
|
+
for file in files:
|
488
|
+
absolute_path = os.path.join(root, file)
|
489
|
+
relative_path = os.path.relpath(absolute_path, project_folder)
|
490
|
+
# ignore .git folder
|
491
|
+
if relative_path.startswith(".git/"):
|
492
|
+
continue
|
493
|
+
# ignore paths in ignored_paths
|
494
|
+
if ignore_matches(absolute_path):
|
495
|
+
continue
|
496
|
+
# Prevent artefacts.yaml from being included twice
|
497
|
+
if os.path.basename(absolute_path) == "artefacts.yaml":
|
498
|
+
continue
|
499
|
+
tar_file.add(absolute_path, arcname=relative_path, recursive=False)
|
500
|
+
# Write the modified config file to a temp file and add it
|
501
|
+
with tempfile.NamedTemporaryFile("w") as tf:
|
502
|
+
yaml.dump(run_config, tf)
|
503
|
+
tar_file.add(tf.name, arcname="artefacts.yaml", recursive=False)
|
504
|
+
|
505
|
+
temp_file.flush()
|
506
|
+
temp_file.seek(0)
|
507
|
+
|
508
|
+
# Request signed upload URLs
|
509
|
+
upload_urls_response = requests.put(
|
510
|
+
f"{api_conf.api_url}/{project_id}/upload_source",
|
511
|
+
headers=api_conf.headers,
|
512
|
+
)
|
513
|
+
|
514
|
+
if not upload_urls_response.ok:
|
515
|
+
result = upload_urls_response.json()
|
516
|
+
if (
|
517
|
+
upload_urls_response.status_code == 403
|
518
|
+
and result["message"] == "Not allowed"
|
519
|
+
):
|
520
|
+
raise click.ClickException(
|
521
|
+
f"Missing access! Please make sure your API key is added at {dashboard_url}/settings"
|
522
|
+
)
|
523
|
+
|
524
|
+
if (
|
525
|
+
upload_urls_response.status_code == 401
|
526
|
+
and result["message"] == "no linked repository"
|
527
|
+
):
|
528
|
+
raise click.ClickException(
|
529
|
+
f"Missing linked GitHub repository. Please link a GitHub repository at {dashboard_url}/settings"
|
530
|
+
)
|
531
|
+
|
532
|
+
raise click.ClickException(
|
533
|
+
f"Error getting project info: {result['message']}"
|
534
|
+
)
|
535
|
+
|
536
|
+
upload_urls = upload_urls_response.json()["upload_urls"]
|
537
|
+
url = ""
|
538
|
+
# github specific logic should later be moved to the github action, and instead support additional options or env variables for configuration for payload
|
539
|
+
if description is None:
|
540
|
+
if "GITHUB_RUN_ID" in os.environ:
|
541
|
+
description = os.environ.get("GITHUB_WORKFLOW")
|
542
|
+
url = f"{os.environ.get('GITHUB_SERVER_URL')}/{os.environ.get('GITHUB_REPOSITORY')}/actions/runs/{os.environ.get('GITHUB_RUN_ID')}"
|
543
|
+
else:
|
544
|
+
description = "Testing local source"
|
545
|
+
# Mock the necessary parts of the GitHub event
|
546
|
+
execution_context = getpass.getuser() + "@" + platform.node()
|
547
|
+
integration_payload = {
|
548
|
+
"head_commit": {
|
549
|
+
# shown on the dashboard in the job details
|
550
|
+
"message": description,
|
551
|
+
"url": url,
|
552
|
+
},
|
553
|
+
"repository": {
|
554
|
+
# used by the container-builder for creating the ecr repo name
|
555
|
+
"full_name": os.environ.get("GITHUB_REPOSITORY", project_id),
|
556
|
+
},
|
557
|
+
# special key to distinguish the valid GitHub payload from these fabricated ones
|
558
|
+
"ref": os.environ.get(
|
559
|
+
"GITHUB_REF", get_git_revision_branch() + "~" + execution_context
|
560
|
+
),
|
561
|
+
"after": os.environ.get("GITHUB_SHA", get_git_revision_hash()[:8] + "~"),
|
562
|
+
}
|
563
|
+
|
564
|
+
uploads = [
|
565
|
+
("archive.tgz", temp_file),
|
566
|
+
("artefacts.yaml", ("artefacts.yaml", yaml.dump(run_config))),
|
567
|
+
(
|
568
|
+
"integration_payload.json",
|
569
|
+
("integration_payload.json", json.dumps(integration_payload)),
|
570
|
+
),
|
571
|
+
]
|
572
|
+
# get size of the archive file
|
573
|
+
archive_size = os.path.getsize(temp_file.name)
|
574
|
+
|
575
|
+
with click.progressbar(
|
576
|
+
uploads,
|
577
|
+
label=f"Uploading {archive_size / 1024 / 1024:.2f} MB of source code",
|
578
|
+
item_show_func=lambda x: x and x[0],
|
579
|
+
) as bar:
|
580
|
+
for filename, file in bar:
|
581
|
+
response = requests.post(
|
582
|
+
upload_urls[filename]["url"],
|
583
|
+
data=upload_urls[filename]["fields"],
|
584
|
+
files={"file": file},
|
585
|
+
)
|
586
|
+
if not response.ok:
|
587
|
+
raise click.ClickException(
|
588
|
+
f"Failed to upload {filename}: {response.text}"
|
589
|
+
)
|
590
|
+
|
591
|
+
click.echo(
|
592
|
+
f"Uploading complete! The new job will show up shortly at {dashboard_url}/tests"
|
593
|
+
)
|
594
|
+
|
595
|
+
|
596
|
+
@click.group()
|
597
|
+
@click.version_option(version=__version__)
|
598
|
+
def artefacts():
|
599
|
+
"""A command line tool to interface with ARTEFACTS"""
|
600
|
+
compute_env = os.getenv("AWS_BATCH_CE_NAME", "")
|
601
|
+
if compute_env != "":
|
602
|
+
click.echo(f"running version {__version__}")
|
603
|
+
if (
|
604
|
+
"development" in compute_env
|
605
|
+
and os.getenv("ARTEFACTS_API_URL", None) is None
|
606
|
+
):
|
607
|
+
os.environ["ARTEFACTS_API_URL"] = "https://ui.artefacts.com/api"
|
608
|
+
|
609
|
+
|
610
|
+
artefacts.add_command(config)
|
611
|
+
artefacts.add_command(hello)
|
612
|
+
artefacts.add_command(run)
|
613
|
+
artefacts.add_command(run_remote)
|
614
|
+
|
615
|
+
|
616
|
+
if __name__ == "__main__":
|
617
|
+
artefacts()
|