artefacts-cli 0.9.3__py3-none-any.whl → 0.9.5__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 CHANGED
@@ -6,7 +6,6 @@ import glob
6
6
  import json
7
7
  import math
8
8
  import os
9
- import requests
10
9
 
11
10
  from .config import APIConf
12
11
  from .i18n import localise
@@ -33,7 +32,15 @@ except PackageNotFoundError:
33
32
  __version__ = "0.0.0"
34
33
 
35
34
 
36
- class AuthenticationError(Exception):
35
+ class ArtefactsAPIError(Exception):
36
+ """
37
+ Tentative base error class for Artefacts API interactions
38
+ """
39
+
40
+ pass
41
+
42
+
43
+ class AuthenticationError(ArtefactsAPIError):
37
44
  """Raised when artefacts authentication failed"""
38
45
 
39
46
  pass
@@ -52,6 +59,7 @@ class WarpJob:
52
59
  noisolation=False,
53
60
  context=None,
54
61
  run_offset=0,
62
+ n_subjobs=1, # Total Number of Runs
55
63
  ):
56
64
  self.project_id = project_id
57
65
  self.job_id = os.environ.get("ARTEFACTS_JOB_ID", None)
@@ -67,28 +75,27 @@ class WarpJob:
67
75
  self.noupload = noupload
68
76
  self.noisolation = noisolation
69
77
  self.context = context
78
+ self.n_subjobs = n_subjobs
70
79
 
71
80
  if dryrun:
72
81
  self.job_id = "dryrun"
73
82
  if self.job_id is None:
74
83
  # Only create a new job if job_id is not specified
75
84
  data = {
85
+ "project_id": self.project_id,
76
86
  "start": round(self.start),
77
87
  "status": "in progress",
78
88
  "params": json.dumps(self.params),
79
89
  "project": self.project_id,
80
90
  "jobname": self.jobname,
81
91
  "timeout": self.params.get("timeout", 5) * 60,
92
+ "n_subjobs": self.n_subjobs,
82
93
  }
83
94
  if context is not None:
84
95
  data["message"] = context["description"]
85
96
  data["commit"] = context["commit"]
86
97
  data["ref"] = context["ref"]
87
- response = requests.post(
88
- f"{api_conf.api_url}/{self.project_id}/job",
89
- json=data,
90
- headers=api_conf.headers,
91
- )
98
+ response = self.api_conf.create("job", data)
92
99
  if response.status_code != 200:
93
100
  if response.status_code == 403:
94
101
  msg = response.json()["message"]
@@ -102,7 +109,7 @@ class WarpJob:
102
109
  )
103
110
  )
104
111
  logger.warning(response.text)
105
- raise AuthenticationError(str(response.status_code))
112
+ raise ArtefactsAPIError(str(response.status_code))
106
113
  self.job_id = response.json()["job_id"]
107
114
  self.output_path = self.params.get("output_path", f"/tmp/{self.job_id}")
108
115
  os.makedirs(self.output_path, exist_ok=True)
@@ -111,24 +118,21 @@ class WarpJob:
111
118
  def log_tests_result(self, success):
112
119
  self.success = success
113
120
 
114
- def stop(self):
121
+ def update(self, last_run_success: bool) -> bool:
115
122
  end = datetime.now(timezone.utc).timestamp()
116
123
  if self.dryrun:
117
- return
124
+ return True
118
125
  # Log metadata
119
126
  data = {
127
+ "project_id": self.project_id,
120
128
  "end": round(end),
121
129
  "duration": round(end - self.start),
122
- "success": self.success, # need to be determined based on all runs, can be an AND in the API
130
+ "success": last_run_success,
123
131
  "status": "finished", # need to be determined based on all runs
124
132
  }
125
- requests.put(
126
- f"{self.api_conf.api_url}/{self.project_id}/job/{self.job_id}",
127
- json=data,
128
- headers=self.api_conf.headers,
129
- )
133
+ response = self.api_conf.update("job", self.job_id, data)
130
134
 
131
- return
135
+ return response.status_code == 200
132
136
 
133
137
  def new_run(self, scenario):
134
138
  run = WarpRun(self, scenario["name"], scenario, self.n_runs)
@@ -153,6 +157,7 @@ class WarpRun:
153
157
  self.logger = logger
154
158
  os.makedirs(self.output_path, exist_ok=True)
155
159
  data = {
160
+ "project_id": self.job.project_id,
156
161
  "job_id": job.job_id,
157
162
  "run_n": self.run_n,
158
163
  "start": round(self.start),
@@ -163,14 +168,8 @@ class WarpRun:
163
168
 
164
169
  if self.job.dryrun:
165
170
  return
166
- query_url = (
167
- f"{self.job.api_conf.api_url}/{self.job.project_id}/job/{job.job_id}/run"
168
- )
169
- response = requests.post(
170
- query_url,
171
- json=data,
172
- headers=self.job.api_conf.headers,
173
- )
171
+
172
+ response = self.job.api_conf.create("run", data)
174
173
  if response.status_code != 200:
175
174
  if response.status_code == 403:
176
175
  msg = response.json()["message"]
@@ -184,7 +183,7 @@ class WarpRun:
184
183
  )
185
184
  )
186
185
  self.logger.warning(response.text)
187
- raise AuthenticationError(str(response.status_code))
186
+ raise ArtefactsAPIError(str(response.status_code))
188
187
  return
189
188
 
190
189
  def log_params(self, params):
@@ -266,6 +265,7 @@ class WarpRun:
266
265
 
267
266
  # Log metadata
268
267
  data = {
268
+ "project_id": self.job.project_id,
269
269
  "job_id": self.job.job_id,
270
270
  "run_n": self.run_n,
271
271
  "start": math.floor(self.start),
@@ -280,11 +280,7 @@ class WarpRun:
280
280
  if not self.job.noupload:
281
281
  data["uploads"] = self.uploads
282
282
 
283
- response = requests.put(
284
- f"{self.job.api_conf.api_url}/{self.job.project_id}/job/{self.job.job_id}/run/{self.run_n}",
285
- json=data,
286
- headers=self.job.api_conf.headers,
287
- )
283
+ response = self.job.api_conf.update("run", self.run_n, data)
288
284
 
289
285
  # use s3 presigned urls to upload the artifacts
290
286
  if self.job.noupload:
@@ -307,11 +303,8 @@ class WarpRun:
307
303
  )
308
304
  )
309
305
  )
310
- # TODO: add a retry policy
311
- requests.post(
312
- upload_info["url"],
313
- data=upload_info["fields"],
314
- files=files,
306
+ self.job.api_conf.upload(
307
+ upload_info["url"], upload_info["fields"], files
315
308
  )
316
309
  except OverflowError:
317
310
  self.logger.warning(
@@ -342,6 +335,7 @@ def init_job(
342
335
  noisolation: bool = False,
343
336
  context: Optional[dict] = None,
344
337
  run_offset=0,
338
+ n_subjobs: int = 1,
345
339
  ):
346
340
  return WarpJob(
347
341
  project_id,
@@ -354,6 +348,7 @@ def init_job(
354
348
  noisolation,
355
349
  context,
356
350
  run_offset,
351
+ n_subjobs,
357
352
  )
358
353
 
359
354
 
artefacts/cli/app.py CHANGED
@@ -20,6 +20,7 @@ from artefacts.cli import (
20
20
  init_job,
21
21
  generate_scenarios,
22
22
  localise,
23
+ logger,
23
24
  AuthenticationError,
24
25
  __version__,
25
26
  )
@@ -32,6 +33,7 @@ from artefacts.cli.constants import (
32
33
  )
33
34
  from artefacts.cli.helpers import (
34
35
  add_key_to_conf,
36
+ endpoint_exists,
35
37
  get_conf_from_file,
36
38
  get_artefacts_api_url,
37
39
  get_git_revision_branch,
@@ -64,39 +66,70 @@ def add(project_name):
64
66
  profile = config[project_name]
65
67
  else:
66
68
  profile = {}
69
+
67
70
  api_url = get_artefacts_api_url(profile)
68
71
  dashboard_url = api_url.split("/api")[0]
72
+
69
73
  settings_page_url = f"{dashboard_url}/{project_name}/settings"
70
- # Check if running on WSL
71
- if "WSLENV" in os.environ:
72
- os.system(f'cmd.exe /C start "" {settings_page_url} 2>/dev/null')
73
- else:
74
- webbrowser.open(settings_page_url)
75
74
  click.echo(
76
75
  localise("Opening the project settings page: {url}").format(
77
76
  url=settings_page_url
78
77
  )
79
78
  )
80
- api_key = click.prompt(
81
- localise("Please enter your API KEY for {project}").format(
82
- project=project_name
83
- ),
84
- type=str,
85
- hide_input=True,
86
- )
87
- add_key_to_conf(project_name, api_key)
88
- click.echo(localise("API KEY saved for {project}").format(project=project_name))
89
- if click.confirm(
90
- localise(
91
- "Would you like to download the generated artefacts.yaml file? This will overwrite any existing config file in the current directory."
79
+
80
+ if endpoint_exists(settings_page_url):
81
+ # Check if running on WSL
82
+ if "WSLENV" in os.environ:
83
+ os.system(f'cmd.exe /C start "" {settings_page_url} 2>/dev/null')
84
+ else:
85
+ webbrowser.open(settings_page_url)
86
+
87
+ api_key = click.prompt(
88
+ localise("Please enter your API KEY for {project}").format(
89
+ project=project_name
90
+ ),
91
+ type=str,
92
+ hide_input=True,
93
+ )
94
+ add_key_to_conf(project_name, api_key)
95
+ click.echo(localise("API KEY saved for {project}").format(project=project_name))
96
+ if click.confirm(
97
+ localise(
98
+ "Would you like to download a pregenerated artefacts.yaml file? This will overwrite any existing config file in the current directory."
99
+ )
100
+ ):
101
+ config_file_name = "artefacts.yaml"
102
+ config_file_url = f"{api_url}/{project_name}/{config_file_name}"
103
+ # Get credentials only now, as API key set just before.
104
+ api_conf = APIConf(project_name, __version__)
105
+ config_response = api_conf.read("url", config_file_url)
106
+ if config_response.status_code == 200:
107
+ with open(config_file_name, "wb") as f:
108
+ f.write(config_response.content)
109
+ else:
110
+ click.echo(
111
+ localise(
112
+ "We encountered a problem in getting the generated configuration file. Please consider downloading it from the project page on the dashboard at {url}. Sorry for the inconvenience."
113
+ ).format(url=settings_page_url)
114
+ )
115
+ logger.debug(
116
+ localise(
117
+ "If you are using an alternative server, please also consider checking the value of ARTEFACTS_API_URL in your environment."
118
+ )
119
+ )
120
+ else:
121
+ click.echo(
122
+ localise(
123
+ "Our apologies: The project page does not seem available at the moment. If `{project_name}` is correct, please try again later.".format(
124
+ project_name=project_name
125
+ )
126
+ )
127
+ )
128
+ click.echo(
129
+ localise(
130
+ "If you are using an alternative server, please also consider checking the value of ARTEFACTS_API_URL in your environment."
131
+ )
92
132
  )
93
- ):
94
- api_conf = APIConf(project_name, __version__)
95
- config_file_name = "artefacts.yaml"
96
- config_file_url = f"{api_url}/{project_name}/{config_file_name}"
97
- r = requests.get(config_file_url, headers=api_conf.headers)
98
- with open(config_file_name, "wb") as f:
99
- f.write(r.content)
100
133
  return
101
134
 
102
135
 
@@ -118,10 +151,7 @@ def delete(project_name):
118
151
  def hello(project_name):
119
152
  """Show message to confirm credentials allow access to PROJECT_NAME"""
120
153
  api_conf = APIConf(project_name, __version__)
121
- response = requests.get(
122
- f"{api_conf.api_url}/{project_name}/info",
123
- headers=api_conf.headers,
124
- )
154
+ response = api_conf.read("url", f"{api_conf.api_url}/{project_name}/info")
125
155
  if response.status_code == 200:
126
156
  result = response.json()
127
157
  click.echo(
@@ -360,6 +390,7 @@ def run(
360
390
  noisolation,
361
391
  context,
362
392
  first,
393
+ len(scenarios),
363
394
  )
364
395
  except AuthenticationError:
365
396
  click.secho(
@@ -412,6 +443,8 @@ def run(
412
443
  )
413
444
  run.log_tests_results([result], False)
414
445
  run.stop()
446
+ warpjob.update(last_run_success=False)
447
+ continue
415
448
  if dryrun:
416
449
  click.echo(f"[{jobname}] " + localise("Performing dry run"))
417
450
  results, success = {}, True
@@ -419,7 +452,8 @@ def run(
419
452
  try:
420
453
  results, success = run_ros2_tests(run)
421
454
  except Exception as e:
422
- warpjob.stop()
455
+ run.stop()
456
+ warpjob.update(last_run_success=False)
423
457
  warpjob.log_tests_result(False)
424
458
  click.secho(e, bold=True, err=True)
425
459
  click.secho(
@@ -428,10 +462,18 @@ def run(
428
462
  err=True,
429
463
  bold=True,
430
464
  )
431
- raise click.Abort()
465
+ continue
432
466
  if success is None:
467
+ result = get_TestSuite_error_result(
468
+ scenario["name"],
469
+ localise("ROS2 environment error"),
470
+ localise(
471
+ "Not able to execute tests. Make sure that ROS2 is sourced and that your launch file syntax is correct."
472
+ ),
473
+ )
474
+ run.log_tests_results([result], False)
433
475
  run.stop()
434
- warpjob.stop()
476
+ warpjob.update(last_run_success=False)
435
477
  warpjob.log_tests_result(job_success)
436
478
  click.secho(
437
479
  f"[{jobname}] "
@@ -441,7 +483,7 @@ def run(
441
483
  err=True,
442
484
  bold=True,
443
485
  )
444
- raise click.Abort()
486
+ continue
445
487
  if not success:
446
488
  job_success = False
447
489
  elif framework is not None and framework.startswith("ros1:"):
@@ -454,7 +496,17 @@ def run(
454
496
  err=True,
455
497
  bold=True,
456
498
  )
457
- raise click.Abort()
499
+ result = get_TestSuite_error_result(
500
+ scenario["name"],
501
+ localise("launch_test file not specified error"),
502
+ localise(
503
+ "Please specify a `ros_testfile` in the artefacts.yaml scenario configuration."
504
+ ),
505
+ )
506
+ run.log_tests_results([result], False)
507
+ run.stop()
508
+ warpjob.update(last_run_success=False)
509
+ continue
458
510
  if dryrun:
459
511
  click.echo(f"[{jobname}] " + localise("Performing dry run"))
460
512
  results, success = {}, True
@@ -472,7 +524,10 @@ def run(
472
524
  err=True,
473
525
  bold=True,
474
526
  )
475
- raise click.Abort()
527
+ run.stop()
528
+ warpjob.update(last_run_success=False)
529
+ warpjob.log_tests_result(False)
530
+ continue
476
531
  if dryrun:
477
532
  click.echo(f"[{jobname}] " + localise("Performing dry run"))
478
533
  results, success = {}, True
@@ -487,12 +542,11 @@ def run(
487
542
  add_output_from_default(run)
488
543
 
489
544
  run.stop()
490
- warpjob.log_tests_result(job_success)
545
+ warpjob.log_tests_result(job_success)
546
+ warpjob.update(last_run_success=run.success)
491
547
  click.echo(f"[{jobname}] " + localise("Done"))
492
548
  time.sleep(random.random() * 1)
493
549
 
494
- warpjob.stop()
495
-
496
550
 
497
551
  @click.command()
498
552
  @click.option(
@@ -590,9 +644,10 @@ def run_remote(config, description, jobname, skip_validation=False):
590
644
  temp_file.seek(0)
591
645
 
592
646
  # Request signed upload URLs
593
- upload_urls_response = requests.put(
594
- f"{api_conf.api_url}/{project_id}/upload_source",
595
- headers=api_conf.headers,
647
+ # TODO Horrible approach, but the endpoint is neither CRUD nor REST, etc.
648
+ # So the idea to remain horrible and change this soon.
649
+ upload_urls_response = api_conf.direct("put")(
650
+ f"{api_conf.api_url}/{project_id}/upload_source"
596
651
  )
597
652
 
598
653
  if not upload_urls_response.ok:
@@ -694,10 +749,10 @@ def run_remote(config, description, jobname, skip_validation=False):
694
749
  item_show_func=lambda x: x and x[0],
695
750
  ) as bar:
696
751
  for filename, file in bar:
697
- response = requests.post(
752
+ response = api_conf.upload(
698
753
  upload_urls[filename]["url"],
699
- data=upload_urls[filename]["fields"],
700
- files={"file": file},
754
+ upload_urls[filename]["fields"],
755
+ {"file": file},
701
756
  )
702
757
  if not response.ok:
703
758
  raise click.ClickException(
artefacts/cli/config.py CHANGED
@@ -1,8 +1,16 @@
1
+ from collections.abc import Callable
2
+ from contextlib import contextmanager
3
+ from functools import partial
4
+ import logging
1
5
  import os
2
6
  import platform
3
- from typing import Optional
7
+ from typing import Optional, Tuple
4
8
 
5
9
  import click
10
+ from requests import Response, Session
11
+ from requests.adapters import HTTPAdapter
12
+ from requests.exceptions import ConnectionError
13
+ from urllib3.util import Retry
6
14
 
7
15
  from artefacts.cli.i18n import localise
8
16
  from artefacts.cli.helpers import (
@@ -10,10 +18,18 @@ from artefacts.cli.helpers import (
10
18
  get_artefacts_api_url,
11
19
  )
12
20
 
21
+ # Mask warnings from urllib, typically when it retries failed API calls
22
+ urllib3logger = logging.getLogger("urllib3")
23
+ urllib3logger.setLevel(logging.ERROR)
24
+
13
25
 
14
26
  class APIConf:
15
27
  def __init__(
16
- self, project_name: str, api_version: str, job_name: Optional[str] = None
28
+ self,
29
+ project_name: str,
30
+ api_version: str,
31
+ job_name: Optional[str] = None,
32
+ session: Optional[Session] = None,
17
33
  ) -> None:
18
34
  config = get_conf_from_file()
19
35
  if project_name in config:
@@ -60,3 +76,135 @@ class APIConf:
60
76
  )
61
77
  )
62
78
  )
79
+
80
+ #
81
+ # Retry settings
82
+ #
83
+ self.session = session or Session()
84
+ retries = Retry(
85
+ total=3,
86
+ backoff_factor=0.1,
87
+ status_forcelist=[502, 503, 504],
88
+ allowed_methods=Retry.DEFAULT_ALLOWED_METHODS | {"POST"},
89
+ )
90
+ # Default connect timeout set to a small value above the default 3s for TCP
91
+ # Default read timeout a typical value. Does not scale when too aggressive
92
+ # (note: read timeout is between byte sent, not the whole read)
93
+ self.request_timeout = (3.03, 27)
94
+ self.session.mount("https://", HTTPAdapter(max_retries=retries))
95
+
96
+ @contextmanager
97
+ def _api(self):
98
+ try:
99
+ yield self.session
100
+ except ConnectionError as e:
101
+ raise click.ClickException(
102
+ localise(
103
+ "Unable to complete the operation: Network error.\n"
104
+ "This may be a problem with an Artefacts server, or your network.\n"
105
+ "Please try again in a moment or confirm your internet connection.\n"
106
+ "If the problem persists, please contact us (info@artefacts.com)!\n"
107
+ f"All we know: {e}"
108
+ )
109
+ )
110
+
111
+ def _conn_info(self, obj: str, data: Optional[dict] = None) -> Tuple[str, dict]:
112
+ """
113
+ Prepare connection information for a given resource kind (`obj`).
114
+
115
+ Returns a tuple (url, payload), where url is the endpoint for
116
+ the resource, and payload the prepared data that needs be sent.
117
+
118
+ Note the prepared data does not validate the content. It simply
119
+ remove any extra data used internally to the code here.
120
+ """
121
+ try:
122
+ if "url" == obj:
123
+ return obj, None
124
+ elif "job" == obj:
125
+ return f"{self.api_url}/{data['project_id']}/job", data
126
+ elif "run" == obj:
127
+ project_id = data.pop("project_id")
128
+ return f"{self.api_url}/{project_id}/job/{data['job_id']}/run", data
129
+ else:
130
+ raise Exception(
131
+ f"Unable to determine API URL for unknown object kind: {obj}"
132
+ )
133
+ except KeyError as e:
134
+ raise Exception(f"Missing parameter for building a {obj} URL: {e}")
135
+
136
+ def create(self, obj: str, data: dict) -> Response:
137
+ """
138
+ Create a resource. Typical for endpoints of the form POST /obj
139
+ """
140
+ url, payload = self._conn_info(obj, data)
141
+ with self._api() as session:
142
+ return session.post(
143
+ url,
144
+ json=payload,
145
+ headers=self.headers,
146
+ timeout=self.request_timeout,
147
+ )
148
+
149
+ def read(self, obj: str, obj_id: Optional[str]) -> Response:
150
+ """
151
+ Read a resource content. Typical for endpoints of the form GET /obj/id
152
+ """
153
+ url, _ = self._conn_info(obj)
154
+ if obj_id:
155
+ url = f"{url}/{obj_id}"
156
+ with self._api() as session:
157
+ return session.get(
158
+ url,
159
+ headers=self.headers,
160
+ timeout=self.request_timeout,
161
+ )
162
+
163
+ def update(self, obj: str, obj_id: str, data: dict) -> Response:
164
+ """
165
+ Update (modify) a resource content. Typical for endpoints of the form PUT /obj/id
166
+ """
167
+ url, payload = self._conn_info(obj, data)
168
+ with self._api() as session:
169
+ return session.put(
170
+ f"{url}/{obj_id}",
171
+ json=payload,
172
+ headers=self.headers,
173
+ timeout=self.request_timeout,
174
+ )
175
+
176
+ def upload(self, url: str, data: dict, files: list) -> Response:
177
+ """
178
+ Upload files.
179
+
180
+ Note this is temporary helper, as we expect to turn files as
181
+ first-order resource at the API level, so move to CRUD model, etc.
182
+
183
+ This facility disables all timeouts, as uploads can be very
184
+ long, and we'd better wait.
185
+ """
186
+ with self._api() as session:
187
+ return session.post(
188
+ url,
189
+ data=data,
190
+ files=files,
191
+ timeout=None,
192
+ )
193
+
194
+ def direct(self, verb: str) -> Callable:
195
+ """
196
+ Direct access to the common session.
197
+
198
+ Important: This exposes this object session. It is not the
199
+ guarded session from self._api, because:
200
+ 1. This is temporary anyway to accommodate irregular API
201
+ calls (that is, breach to CRUD/REST models).
202
+ 2. Using a context manager leads to "leaking" the session,
203
+ without the context extras (as this returns and so exits
204
+ the context).
205
+ """
206
+ return partial(
207
+ getattr(self.session, verb),
208
+ headers=self.headers,
209
+ timeout=self.request_timeout,
210
+ )
artefacts/cli/helpers.py CHANGED
@@ -2,6 +2,8 @@ import configparser
2
2
  import os
3
3
  import subprocess
4
4
 
5
+ import requests
6
+
5
7
  from artefacts.cli.constants import CONFIG_PATH, CONFIG_DIR
6
8
 
7
9
 
@@ -53,3 +55,13 @@ def add_key_to_conf(project_name, api_key):
53
55
  config[project_name] = {"ApiKey": api_key}
54
56
  with open(CONFIG_PATH, "w") as f:
55
57
  config.write(f)
58
+
59
+
60
+ def endpoint_exists(url: str) -> bool:
61
+ """
62
+ Simplistic confirmation of the existance of an endpoint.
63
+
64
+ Under discussion: Use of HEAD verbs, etc.
65
+ """
66
+ access_test = requests.get(url)
67
+ return access_test.status_code < 400