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