artefacts-cli 0.9.3__py3-none-any.whl → 0.9.4__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
@@ -52,6 +51,7 @@ class WarpJob:
52
51
  noisolation=False,
53
52
  context=None,
54
53
  run_offset=0,
54
+ n_subjobs=1, # Total Number of Runs
55
55
  ):
56
56
  self.project_id = project_id
57
57
  self.job_id = os.environ.get("ARTEFACTS_JOB_ID", None)
@@ -67,6 +67,7 @@ class WarpJob:
67
67
  self.noupload = noupload
68
68
  self.noisolation = noisolation
69
69
  self.context = context
70
+ self.n_subjobs = n_subjobs
70
71
 
71
72
  if dryrun:
72
73
  self.job_id = "dryrun"
@@ -79,16 +80,13 @@ class WarpJob:
79
80
  "project": self.project_id,
80
81
  "jobname": self.jobname,
81
82
  "timeout": self.params.get("timeout", 5) * 60,
83
+ "n_subjobs": self.n_subjobs,
82
84
  }
83
85
  if context is not None:
84
86
  data["message"] = context["description"]
85
87
  data["commit"] = context["commit"]
86
88
  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
- )
89
+ response = self.api_conf.create("job", data)
92
90
  if response.status_code != 200:
93
91
  if response.status_code == 403:
94
92
  msg = response.json()["message"]
@@ -111,7 +109,7 @@ class WarpJob:
111
109
  def log_tests_result(self, success):
112
110
  self.success = success
113
111
 
114
- def stop(self):
112
+ def update(self, last_run_success: bool):
115
113
  end = datetime.now(timezone.utc).timestamp()
116
114
  if self.dryrun:
117
115
  return
@@ -119,14 +117,10 @@ class WarpJob:
119
117
  data = {
120
118
  "end": round(end),
121
119
  "duration": round(end - self.start),
122
- "success": self.success, # need to be determined based on all runs, can be an AND in the API
120
+ "success": last_run_success,
123
121
  "status": "finished", # need to be determined based on all runs
124
122
  }
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
- )
123
+ self.api_conf.update("job", self.job_id, data)
130
124
 
131
125
  return
132
126
 
@@ -153,6 +147,7 @@ class WarpRun:
153
147
  self.logger = logger
154
148
  os.makedirs(self.output_path, exist_ok=True)
155
149
  data = {
150
+ "project_id": self.job.project_id,
156
151
  "job_id": job.job_id,
157
152
  "run_n": self.run_n,
158
153
  "start": round(self.start),
@@ -163,14 +158,8 @@ class WarpRun:
163
158
 
164
159
  if self.job.dryrun:
165
160
  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
- )
161
+
162
+ response = self.job.api_conf.create("run", data)
174
163
  if response.status_code != 200:
175
164
  if response.status_code == 403:
176
165
  msg = response.json()["message"]
@@ -266,6 +255,7 @@ class WarpRun:
266
255
 
267
256
  # Log metadata
268
257
  data = {
258
+ "project_id": self.job.project_id,
269
259
  "job_id": self.job.job_id,
270
260
  "run_n": self.run_n,
271
261
  "start": math.floor(self.start),
@@ -280,11 +270,7 @@ class WarpRun:
280
270
  if not self.job.noupload:
281
271
  data["uploads"] = self.uploads
282
272
 
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
- )
273
+ response = self.job.api_conf.update("run", self.run_n, data)
288
274
 
289
275
  # use s3 presigned urls to upload the artifacts
290
276
  if self.job.noupload:
@@ -307,11 +293,8 @@ class WarpRun:
307
293
  )
308
294
  )
309
295
  )
310
- # TODO: add a retry policy
311
- requests.post(
312
- upload_info["url"],
313
- data=upload_info["fields"],
314
- files=files,
296
+ self.job.api_conf.upload(
297
+ upload_info["url"], upload_info["fields"], files
315
298
  )
316
299
  except OverflowError:
317
300
  self.logger.warning(
@@ -342,6 +325,7 @@ def init_job(
342
325
  noisolation: bool = False,
343
326
  context: Optional[dict] = None,
344
327
  run_offset=0,
328
+ n_subjobs: int = 1,
345
329
  ):
346
330
  return WarpJob(
347
331
  project_id,
@@ -354,6 +338,7 @@ def init_job(
354
338
  noisolation,
355
339
  context,
356
340
  run_offset,
341
+ n_subjobs,
357
342
  )
358
343
 
359
344
 
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,6 +18,10 @@ 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__(
@@ -60,3 +72,130 @@ class APIConf:
60
72
  )
61
73
  )
62
74
  )
75
+
76
+ #
77
+ # Retry settings
78
+ #
79
+ self.session = Session()
80
+ retries = Retry(
81
+ total=3,
82
+ backoff_factor=0.1,
83
+ status_forcelist=[502, 503, 504],
84
+ allowed_methods=Retry.DEFAULT_ALLOWED_METHODS | {"POST"},
85
+ )
86
+ # Default connect timeout set to a small value above the default 3s for TCP
87
+ # Default read timeout a bit aggressive to ensure snappy experience
88
+ # (note: read timeout is between byte sent, not the whole read)
89
+ self.request_timeout = (3.03, 7)
90
+ self.session.mount("https://", HTTPAdapter(max_retries=retries))
91
+
92
+ @contextmanager
93
+ def _api(self):
94
+ try:
95
+ yield self.session
96
+ except ConnectionError:
97
+ raise click.ClickException(
98
+ localise(
99
+ "Unable to interact with the Artefacts API at this time. "
100
+ "Our apologies, this looks like us. Please retry "
101
+ "later, or perhaps confirm your internet connectivity."
102
+ )
103
+ )
104
+
105
+ def _conn_info(self, obj: str, data: Optional[dict] = None) -> Tuple[str, dict]:
106
+ """
107
+ Prepare connection information for a given resource kind (`obj`).
108
+
109
+ Returns a tuple (url, payload), where url is the endpoint for
110
+ the resource, and payload the prepared data that needs be sent.
111
+
112
+ Note the prepared data does not validate the content. It simply
113
+ remove any extra data used internally to the code here.
114
+ """
115
+ try:
116
+ if "url" == obj:
117
+ return obj, None
118
+ elif "job" == obj:
119
+ return f"{self.api_url}/{data['project_id']}/job", data
120
+ elif "run" == obj:
121
+ project_id = data.pop("project_id")
122
+ return f"{self.api_url}/{project_id}/job/{data['job_id']}/run", data
123
+ else:
124
+ raise Exception(
125
+ f"Unable to determine API URL for unknown object kind: {obj}"
126
+ )
127
+ except KeyError as e:
128
+ raise Exception(f"Missing parameter for building a {obj} URL: {e}")
129
+
130
+ def create(self, obj: str, data: dict) -> Response:
131
+ """
132
+ Create a resource. Typical for endpoints of the form POST /obj
133
+ """
134
+ url, payload = self._conn_info(obj, data)
135
+ with self._api() as session:
136
+ return session.post(
137
+ url,
138
+ json=payload,
139
+ headers=self.headers,
140
+ timeout=self.request_timeout,
141
+ )
142
+
143
+ def read(self, obj: str, obj_id: Optional[str]) -> Response:
144
+ """
145
+ Read a resource content. Typical for endpoints of the form GET /obj/id
146
+ """
147
+ url, _ = self._conn_info(obj)
148
+ if obj_id:
149
+ url = f"{url}/{obj_id}"
150
+ with self._api() as session:
151
+ return session.get(
152
+ url,
153
+ headers=self.headers,
154
+ timeout=self.request_timeout,
155
+ )
156
+
157
+ def update(self, obj: str, obj_id: str, data: dict) -> Response:
158
+ """
159
+ Update (modify) a resource content. Typical for endpoints of the form PUT /obj/id
160
+ """
161
+ url, payload = self._conn_info(obj, data)
162
+ with self._api() as session:
163
+ return session.put(
164
+ f"{url}/{obj_id}",
165
+ json=payload,
166
+ headers=self.headers,
167
+ timeout=self.request_timeout,
168
+ )
169
+
170
+ def upload(self, url: str, data: dict, files: list) -> Response:
171
+ """
172
+ Upload files.
173
+
174
+ Note this is temporary helper, as we expect to turn files as
175
+ first-order resource at the API level, so move to CRUD model, etc.
176
+ """
177
+ with self._api() as session:
178
+ return session.post(
179
+ url,
180
+ data=data,
181
+ files=files,
182
+ timeout=self.request_timeout,
183
+ )
184
+
185
+ def direct(self, verb: str) -> Callable:
186
+ """
187
+ Direct access to the common session.
188
+
189
+ Important: This exposes this object session. It is not the
190
+ guarded session from self._api, because:
191
+ 1. This is temporary anyway to accommodate irregular API
192
+ calls (that is, breach to CRUD/REST models).
193
+ 2. Using a context manager leads to "leaking" the session,
194
+ without the context extras (as this returns and so exits
195
+ the context).
196
+ """
197
+ return partial(
198
+ getattr(self.session, verb),
199
+ headers=self.headers,
200
+ timeout=self.request_timeout,
201
+ )
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