artefacts-cli 0.6.13__tar.gz → 0.6.17__tar.gz

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.
Files changed (63) hide show
  1. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/PKG-INFO +3 -1
  2. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/artefacts/cli/__init__.py +1 -1
  3. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/artefacts/cli/app.py +68 -52
  4. artefacts_cli-0.6.17/artefacts/cli/app_containers.py +98 -0
  5. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/artefacts/cli/constants.py +2 -0
  6. artefacts_cli-0.6.17/artefacts/cli/containers/__init__.py +68 -0
  7. artefacts_cli-0.6.17/artefacts/cli/containers/docker.py +104 -0
  8. artefacts_cli-0.6.17/artefacts/cli/containers/utils.py +57 -0
  9. artefacts_cli-0.6.17/artefacts/cli/utils.py +99 -0
  10. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/artefacts/cli/version.py +2 -2
  11. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/artefacts.yaml +3 -3
  12. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/artefacts_cli.egg-info/PKG-INFO +3 -1
  13. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/artefacts_cli.egg-info/SOURCES.txt +7 -1
  14. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/artefacts_cli.egg-info/requires.txt +2 -0
  15. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/pyproject.toml +2 -0
  16. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/pytest.ini +1 -1
  17. artefacts_cli-0.6.17/tests/cli/test_app_containers.py +54 -0
  18. artefacts_cli-0.6.17/tests/conftest.py +42 -0
  19. artefacts_cli-0.6.17/tests/utils/docker_mock.py +89 -0
  20. artefacts_cli-0.6.13/artefacts/cli/utils.py +0 -35
  21. artefacts_cli-0.6.13/tests/conftest.py +0 -8
  22. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/README.md +0 -0
  23. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/README_INTERNAL.md +0 -0
  24. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/artefacts/cli/bagparser.py +0 -0
  25. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/artefacts/cli/other.py +0 -0
  26. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/artefacts/cli/parameters.py +0 -0
  27. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/artefacts/cli/ros1.py +0 -0
  28. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/artefacts/cli/ros2.py +0 -0
  29. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/artefacts/cli/utils_ros.py +0 -0
  30. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/artefacts/wrappers/artefacts_ros1_meta.launch +0 -0
  31. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/artefacts_cli.egg-info/dependency_links.txt +0 -0
  32. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/artefacts_cli.egg-info/entry_points.txt +0 -0
  33. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/artefacts_cli.egg-info/top_level.txt +0 -0
  34. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/bin/release +0 -0
  35. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/infra-tests/turtlesim1/ros_workspace/src/turtle_odometry/CMakeLists.txt +0 -0
  36. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/infra-tests/turtlesim1/ros_workspace/src/turtle_odometry/launch/test_meta.launch +0 -0
  37. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/infra-tests/turtlesim1/ros_workspace/src/turtle_odometry/launch/test_turtle.launch +0 -0
  38. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/infra-tests/turtlesim1/ros_workspace/src/turtle_odometry/launch/turtle_odometry.launch +0 -0
  39. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/infra-tests/turtlesim1/ros_workspace/src/turtle_odometry/package.xml +0 -0
  40. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/infra-tests/turtlesim1/ros_workspace/src/turtle_odometry/setup.py +0 -0
  41. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/infra-tests/turtlesim1/ros_workspace/src/turtle_odometry/src/TestTurtle.py +0 -0
  42. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/infra-tests/turtlesim1/ros_workspace/src/turtle_odometry/src/__init__.py +0 -0
  43. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/infra-tests/turtlesim1/ros_workspace/src/turtle_odometry/src/turtle_odom.py +0 -0
  44. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/infra-tests/turtlesim1/ros_workspace/src/turtle_odometry/src/turtle_post_process.py +0 -0
  45. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/infra-tests/turtlesim1/ros_workspace/src/turtle_odometry/src/turtle_trajectory.py +0 -0
  46. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/infra-tests/turtlesim1/ros_workspace/src/turtle_odometry/test/viz_turtle_odom.xml +0 -0
  47. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/infra-tests/turtlesim2/launch_turtle.py +0 -0
  48. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/infra-tests/turtlesim2/sample_node.py +0 -0
  49. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/setup.cfg +0 -0
  50. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/tests/__init__.py +0 -0
  51. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/tests/cli/__init__.py +0 -0
  52. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/tests/cli/test_cli.py +0 -0
  53. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/tests/cli/test_config_validation.py +0 -0
  54. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/tests/cli/test_other.py +0 -0
  55. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/tests/cli/test_parameters.py +0 -0
  56. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/tests/cli/test_ros1.py +0 -0
  57. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/tests/cli/test_ros2.py +0 -0
  58. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/tests/cli/test_warp.py +0 -0
  59. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/tests/fixtures/artefacts_deprecated.yaml +0 -0
  60. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/tests/fixtures/artefacts_ros1.yaml +0 -0
  61. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/tests/fixtures/warp-env-param.yaml +0 -0
  62. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/tests/fixtures/warp.yaml +0 -0
  63. {artefacts_cli-0.6.13 → artefacts_cli-0.6.17}/tests/test_config_validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: artefacts_cli
3
- Version: 0.6.13
3
+ Version: 0.6.17
4
4
  Author-email: FD <fabian@artefacts.com>, AGC <alejandro@artefacts.com>, TN <tomo@artefacts.com>, EP <eric@artefacts.com>
5
5
  Project-URL: Homepage, https://github.com/art-e-fact/artefacts-client
6
6
  Project-URL: Bug Tracker, https://github.com/art-e-fact/artefacts-client/issues
@@ -18,9 +18,11 @@ Requires-Dist: mcap-ros2-support
18
18
  Requires-Dist: PyYAML>=6.0
19
19
  Requires-Dist: requests>=2.27.1
20
20
  Requires-Dist: setuptools-scm
21
+ Requires-Dist: setuptools>=74
21
22
  Provides-Extra: dev
22
23
  Requires-Dist: awscli; extra == "dev"
23
24
  Requires-Dist: build; extra == "dev"
25
+ Requires-Dist: docker; extra == "dev"
24
26
  Requires-Dist: lark; extra == "dev"
25
27
  Requires-Dist: pyre-check; extra == "dev"
26
28
  Requires-Dist: pytest; extra == "dev"
@@ -15,7 +15,7 @@ logging.basicConfig(level=logging.INFO)
15
15
 
16
16
 
17
17
  try:
18
- __version__ = version("package-name")
18
+ __version__ = version("artefacts-cli")
19
19
  except PackageNotFoundError:
20
20
  try:
21
21
  # Package is not installed, most likely dev/test mode
@@ -9,7 +9,6 @@ import sys
9
9
  import tarfile
10
10
  import tempfile
11
11
  import time
12
- from typing import Any, Union
13
12
  from urllib.parse import urlparse
14
13
  import webbrowser
15
14
 
@@ -20,7 +19,9 @@ from pathlib import Path
20
19
  from gitignore_parser import parse_gitignore
21
20
 
22
21
  from artefacts.cli import init_job, generate_scenarios, AuthenticationError, __version__
22
+ from artefacts.cli import app_containers as containers
23
23
  from artefacts.cli.constants import DEPRECATED_FRAMEWORKS, SUPPORTED_FRAMEWORKS
24
+ from artefacts.cli.utils import read_config, config_validation
24
25
  import artefacts_copava as copava
25
26
 
26
27
  HOME = os.path.expanduser("~")
@@ -100,55 +101,10 @@ class APIConf:
100
101
  click.echo(f"Connecting to {self.api_url} using {auth_type}")
101
102
 
102
103
 
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
104
  def validate_artefacts_config(config_file: str) -> dict:
112
105
  pass
113
106
 
114
107
 
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
108
  @click.group()
153
109
  def config():
154
110
  return
@@ -275,11 +231,64 @@ def hello(project_name):
275
231
  is_eager=True, # Necessary for callbacks to see it.
276
232
  help="Skip configuration validation, so that unsupported settings can be tried out, e.g. non-ROS settings or simulators like SAPIEN.",
277
233
  )
234
+ @click.option(
235
+ "--in-container",
236
+ is_flag=True,
237
+ default=False,
238
+ help='[Experimental] Run the job inside a package container. The container image is build if it does not exist yet, with default name as "artefacts" (please use --with-image to override the image name). This option overrides (for now) --dryrun, --nosim, --noisolation and --description.',
239
+ )
240
+ @click.option(
241
+ "--with-image",
242
+ default="artefacts",
243
+ help="[Experimental] Run the job using the image name passed here. Only used when running with --in-container set.",
244
+ )
245
+ @click.option(
246
+ "--rebuild-container",
247
+ is_flag=True,
248
+ default=False,
249
+ help="[Experimental] Rebuild the container image before running. This flag guarantees that the run uses the latest code available when building the image, and usually takes more time before the run can start (time to compile and generate the image).",
250
+ )
251
+ @click.option(
252
+ "--with-gui",
253
+ is_flag=True,
254
+ default=False,
255
+ help="Show any GUI if any is created by the test runs. By default, UI elements are run but hidden---only test logs are returned. Please note GUI often assume X11 (e.g. ROS), typically with Qt, so this may not work without a appropriate environment.",
256
+ )
278
257
  @click.argument("jobname")
258
+ @click.pass_context
279
259
  def run(
280
- config, jobname, dryrun, nosim, noisolation, description="", skip_validation=False
260
+ ctx: click.Context,
261
+ config,
262
+ jobname,
263
+ dryrun,
264
+ nosim,
265
+ noisolation,
266
+ description="",
267
+ skip_validation=False,
268
+ in_container: bool = False,
269
+ with_image: str = "artefacts",
270
+ rebuild_container: bool = False,
271
+ with_gui: bool = False,
281
272
  ):
282
- """Run JOBNAME locally"""
273
+ """
274
+ Run JOBNAME locally
275
+
276
+ * Directly in the shell by default.
277
+ * Inside a packaged container when using the --in-container option.
278
+ """
279
+ if in_container:
280
+ if rebuild_container or not ctx.invoke(containers.check, name=with_image):
281
+ ctx.invoke(
282
+ containers.build, path=".", dockerfile="Dockerfile", name=with_image
283
+ )
284
+ return ctx.invoke(
285
+ containers.run,
286
+ image=with_image,
287
+ jobname=jobname,
288
+ config=config,
289
+ with_gui=with_gui,
290
+ )
291
+
283
292
  warpconfig = read_config(config)
284
293
 
285
294
  project_id = warpconfig["project"]
@@ -334,7 +343,7 @@ def run(
334
343
  )
335
344
  except AuthenticationError:
336
345
  raise click.ClickException(
337
- "Unable to authenticate, check your Project Name and API Key"
346
+ "Unable to authenticate (Stage: Job initialisation), please check your project name and API key"
338
347
  )
339
348
 
340
349
  job_success = True
@@ -346,7 +355,7 @@ def run(
346
355
  run = warpjob.new_run(scenario)
347
356
  except AuthenticationError:
348
357
  raise click.ClickException(
349
- "Unable to authenticate, check your Project Name and API Key"
358
+ "Unable to authenticate (Stage: Job run), please check your project name and API key"
350
359
  )
351
360
  if framework is not None and framework.startswith("ros2:"):
352
361
  from artefacts.cli.ros2 import run_ros2_tests
@@ -474,7 +483,7 @@ def run_remote(config, description, jobname, skip_validation=False):
474
483
  click.echo(f"Packaging source...")
475
484
 
476
485
  with tempfile.NamedTemporaryFile(
477
- prefix=project_id, suffix=".tgz", delete=True
486
+ prefix=project_id.split("/")[-1], suffix=".tgz", delete=True
478
487
  ) as temp_file:
479
488
  # get list of patterns to be ignored in .artefactsignore
480
489
  ignore_file = Path(project_folder) / Path(".artefactsignore")
@@ -512,7 +521,13 @@ def run_remote(config, description, jobname, skip_validation=False):
512
521
  )
513
522
 
514
523
  if not upload_urls_response.ok:
515
- result = upload_urls_response.json()
524
+ try:
525
+ result = upload_urls_response.json()
526
+ except requests.exceptions.JSONDecodeError:
527
+ raise click.ClickException(
528
+ f"Apologies, problem in interacting with the Artefacts backend: {upload_urls_response.status_code} {upload_urls_response.reason}. Response text: {upload_urls_response.text}."
529
+ )
530
+
516
531
  if (
517
532
  upload_urls_response.status_code == 403
518
533
  and result["message"] == "Not allowed"
@@ -611,6 +626,7 @@ artefacts.add_command(config)
611
626
  artefacts.add_command(hello)
612
627
  artefacts.add_command(run)
613
628
  artefacts.add_command(run_remote)
629
+ artefacts.add_command(containers.containers)
614
630
 
615
631
 
616
632
  if __name__ == "__main__":
@@ -0,0 +1,98 @@
1
+ import os
2
+
3
+ import click
4
+
5
+ from artefacts.cli.constants import DEFAULT_API_URL
6
+ from artefacts.cli.utils import config_validation, read_config
7
+ from artefacts.cli.containers.utils import ContainerMgr
8
+
9
+
10
+ @click.group()
11
+ @click.option("--debug/--no-debug", default=False)
12
+ @click.pass_context
13
+ def containers(ctx: click.Context, debug: bool):
14
+ ctx.ensure_object(dict)
15
+ ctx.obj["debug"] = debug
16
+
17
+
18
+ @containers.command()
19
+ @click.option(
20
+ "--path",
21
+ default=".",
22
+ help="Path to the root of the project, where a Dockerfile is available.",
23
+ )
24
+ @click.option(
25
+ "--dockerfile",
26
+ default="Dockerfile",
27
+ help="File name of the container definition file. Defaults to the standard Dockerfile inside the project root (see --path)",
28
+ )
29
+ @click.option(
30
+ "--name",
31
+ required=False,
32
+ help="Name for the generated container",
33
+ )
34
+ @click.pass_context
35
+ def build(ctx: click.Context, path: str, dockerfile: str, name: str):
36
+ if not os.path.exists(os.path.join(path, dockerfile)):
37
+ raise click.ClickException(
38
+ f"No {dockerfile} found here. I cannot build the container."
39
+ )
40
+ if name is None:
41
+ name = "artefacts"
42
+ handler = ContainerMgr()
43
+ image, _ = handler.build(path=path, name=name)
44
+ print(f"Package complete in image: {image}")
45
+
46
+
47
+ @containers.command()
48
+ @click.argument("name")
49
+ @click.pass_context
50
+ def check(ctx: click.Context, name: str):
51
+ if name is None:
52
+ name = "artefacts"
53
+ handler = ContainerMgr()
54
+ result = handler.check(name)
55
+ if ctx.parent is None:
56
+ # Print only if the command is called directly.
57
+ print(f"Package {name} exists and ready to use.")
58
+ return result
59
+
60
+
61
+ @containers.command()
62
+ @click.argument("image")
63
+ @click.argument("jobname")
64
+ @click.option(
65
+ "--config",
66
+ callback=config_validation,
67
+ default="artefacts.yaml",
68
+ help="Artefacts config file.",
69
+ )
70
+ @click.option(
71
+ "--with-gui",
72
+ "with_gui",
73
+ default=False,
74
+ help="Show any GUI if any is created by the test runs. By default, UI elements are run but hidden---only test logs are returned. Please note GUI often assume an X11 environment, typically with Qt, so this may not work without a appropriate environment.",
75
+ )
76
+ @click.pass_context
77
+ def run(ctx: click.Context, image: str, jobname: str, config: str, with_gui: bool):
78
+ try:
79
+ artefacts_config = read_config(config)
80
+ except FileNotFoundError:
81
+ raise click.ClickException(f"Project config file not found: {config}")
82
+ project = artefacts_config["project"]
83
+ handler = ContainerMgr()
84
+ params = dict(
85
+ image=image,
86
+ project=project,
87
+ jobname=jobname,
88
+ # Hidden setting primarily useful to Artefacts developers
89
+ api_url=os.environ.get("ARTEFACTS_API_URL", DEFAULT_API_URL),
90
+ with_gui=with_gui,
91
+ )
92
+ container, logs = handler.run(**params)
93
+ if container:
94
+ print(f"Package run complete: Container Id for inspection: {container['Id']}")
95
+ else:
96
+ print(f"Package run failed:")
97
+ for entry in logs:
98
+ print("\t- " + entry)
@@ -1,3 +1,5 @@
1
+ DEFAULT_API_URL = "https://app.artefacts.com/api"
2
+
1
3
  SUPPORTED_FRAMEWORKS = [
2
4
  "ros2:iron",
3
5
  "ros2:humble",
@@ -0,0 +1,68 @@
1
+ from collections.abc import Generator
2
+ import configparser
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Any, Tuple, Union
6
+
7
+ from artefacts.cli.constants import DEFAULT_API_URL
8
+
9
+
10
+ class CMgr:
11
+ def build(self, **kwargs) -> Tuple[str, Generator]:
12
+ """
13
+ Returns the build image ID (e.g. sha256:abcdefghi)
14
+ and an iterator over the build log entries.
15
+ """
16
+ raise NotImplemented()
17
+
18
+ def check(self, image: str) -> bool:
19
+ """
20
+ Checks whether a target image exists locally.
21
+ """
22
+ raise NotImplemented()
23
+
24
+ def run(
25
+ self,
26
+ image: str,
27
+ project: str,
28
+ jobname: str = None,
29
+ artefacts_dir: str = Path("~/.artefacts").expanduser(),
30
+ api_url: str = DEFAULT_API_URL,
31
+ with_gui: bool = False,
32
+ ) -> Tuple[Any, Generator]:
33
+ """
34
+ Returns a container (Any type as depends on the framework)
35
+ and an iterator over the container log entries.
36
+ """
37
+ raise NotImplemented()
38
+
39
+ def _valid_artefacts_api_key(
40
+ self, project: str, path: Union[str, Path] = Path("~/.artefacts").expanduser()
41
+ ) -> bool:
42
+ """
43
+ Check if a valid API key is available to embed in containers.
44
+
45
+ 1. Check overrides with the ARTEFACTS_KEY environment variable.
46
+ 2. If `path` is not given, check the default .artefacts folder for the config file.
47
+ 3. If `path` is given, check the file directly is a file, or check for a `config` file if a folder.
48
+
49
+ When a config file is found, we check here if the API key for the `project` is available.
50
+
51
+ `path` set to None is an error, and aborts execution.
52
+ """
53
+ if not path:
54
+ raise Exception(
55
+ "`path` must be a string, a Path object, or excluded from the kwargs"
56
+ )
57
+ if os.environ.get("ARTEFACTS_KEY", None):
58
+ return True
59
+ path = Path(path) # Ensure we have a Path object
60
+ config = configparser.ConfigParser()
61
+ if path.is_dir():
62
+ config.read(path / "config")
63
+ else:
64
+ config.read(path)
65
+ try:
66
+ return config[project].get("apikey") != None
67
+ except KeyError:
68
+ return False
@@ -0,0 +1,104 @@
1
+ from collections.abc import Generator
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+ import platform
6
+ from typing import Any, Tuple
7
+ from uuid import uuid4
8
+
9
+ from artefacts.cli.constants import DEFAULT_API_URL
10
+ from artefacts.cli.containers import CMgr
11
+ from artefacts.cli.utils import ensure_available
12
+
13
+ ensure_available("docker")
14
+
15
+ import docker
16
+ from docker import APIClient
17
+
18
+
19
+ class DockerManager(CMgr):
20
+ def __init__(self):
21
+ self.client = APIClient()
22
+
23
+ def build(self, **kwargs) -> Tuple[str, Generator]:
24
+ kwargs["tag"] = kwargs.pop("name")
25
+ logs = []
26
+ img_id = None
27
+ for entry in self.client.build(**kwargs):
28
+ line_data = [
29
+ json.loads(v) for v in entry.decode("utf-8").split("\r\n") if len(v) > 0
30
+ ]
31
+ for data in line_data:
32
+ if "stream" in data:
33
+ line = data["stream"].strip()
34
+ if not line.startswith("---") and len(line) > 0:
35
+ print(line)
36
+ logs.append(line)
37
+ elif "aux" in data and "ID" in data["aux"]:
38
+ img_id = data["aux"]["ID"]
39
+ if img_id is None:
40
+ img_id = self.client.inspect_image(kwargs["tag"])["Id"]
41
+ return img_id, iter(logs)
42
+
43
+ def check(
44
+ self,
45
+ image: str,
46
+ ) -> bool:
47
+ return len(self.client.images(name=image)) > 0
48
+
49
+ def run(
50
+ self,
51
+ image: str,
52
+ project: str,
53
+ jobname: str = None,
54
+ artefacts_dir: str = Path("~/.artefacts").expanduser(),
55
+ api_url: str = DEFAULT_API_URL,
56
+ with_gui: bool = False,
57
+ ) -> Tuple[Any, Generator]:
58
+ if not self._valid_artefacts_api_key(project, artefacts_dir):
59
+ return None, iter(
60
+ [
61
+ "Missing API key for the project. Does `~/.artefacts/config` exist and contain your key?"
62
+ ]
63
+ )
64
+ try:
65
+ env = {
66
+ "JOB_ID": str(uuid4()),
67
+ "ARTEFACTS_JOB_NAME": jobname,
68
+ "ARTEFACTS_API_URL": api_url,
69
+ }
70
+
71
+ if platform.system() in ["Darwin", "Windows"]:
72
+ # Assume we run in Docker Desktop
73
+ env["DISPLAY"] = "host.docker.internal:0"
74
+ else:
75
+ env["DISPLAY"] = os.environ.get("DISPLAY", ":0")
76
+
77
+ if not with_gui:
78
+ env["QT_QPA_PLATFORM"] = "offscreen"
79
+
80
+ container = self.client.create_container(
81
+ image,
82
+ environment=env,
83
+ detach=False,
84
+ volumes=["/root/.artefacts"],
85
+ host_config=self.client.create_host_config(
86
+ binds={
87
+ artefacts_dir: {
88
+ "bind": "/root/.artefacts",
89
+ "mode": "ro",
90
+ },
91
+ },
92
+ network_mode="host",
93
+ ),
94
+ )
95
+ self.client.start(container=container.get("Id"))
96
+ for entry in self.client.logs(container=container.get("Id"), stream=True):
97
+ print(entry.decode("utf-8").strip())
98
+ return container, iter([])
99
+ except docker.errors.ImageNotFound:
100
+ return None, iter(
101
+ [f"Image {image} not found by Docker. Perhaps need to build first?"]
102
+ )
103
+ except Exception as e:
104
+ return None, iter([f"Failed to run from {image}. All we know: {e}"])
@@ -0,0 +1,57 @@
1
+ from collections.abc import Generator
2
+ import logging
3
+ from typing import Any, Tuple
4
+
5
+ from artefacts.cli.containers import CMgr
6
+
7
+
8
+ class ContainerMgr:
9
+ SUPPORTED_PRIORITISED_ENGINES = {
10
+ 1: "docker",
11
+ # 2: "podman",
12
+ }
13
+
14
+ def __init__(self):
15
+ self.logger = logging.getLogger(__name__)
16
+ self.mgr = self._configure()
17
+ if self.mgr is None:
18
+ raise Exception(
19
+ f"Failed to find supported container stack. Please install and start one in {list(self.SUPPORTED_PRIORITISED_ENGINES.values())}, with default settings (custom sockets not supported at this stage)"
20
+ )
21
+
22
+ def _configure(self) -> CMgr:
23
+ manager = None
24
+ for idx in sorted(self.SUPPORTED_PRIORITISED_ENGINES):
25
+ engine = self.SUPPORTED_PRIORITISED_ENGINES[idx]
26
+ try:
27
+ handler = getattr(self, f"_configure_{engine}")
28
+ manager = handler()
29
+ except AttributeError:
30
+ self.logger.warning(
31
+ f"Tried to detect an unsupported engine: {engine}. WIP? Ignore and continue."
32
+ )
33
+ except Exception as e:
34
+ self.logger.warning(
35
+ f"Problem in detecting {engine} ({e}) Ignore and continue."
36
+ )
37
+ if manager is not None:
38
+ break
39
+ return manager
40
+
41
+ def _configure_docker(self):
42
+ from artefacts.cli.containers.docker import DockerManager
43
+
44
+ return DockerManager()
45
+
46
+ # def _configure_podman(self):
47
+ # from artefacts.cli.containers import podman
48
+ # return PodmanManager()
49
+
50
+ def build(self, **kwargs) -> Tuple[str, Generator]:
51
+ return self.mgr.build(**kwargs)
52
+
53
+ def check(self, image: str) -> bool:
54
+ return self.mgr.check(image)
55
+
56
+ def run(self, **kwargs) -> Tuple[Any, Generator]:
57
+ return self.mgr.run(**kwargs)
@@ -0,0 +1,99 @@
1
+ import os
2
+ import subprocess
3
+ import sys
4
+ from typing import Any, Union
5
+
6
+ import click
7
+
8
+ import artefacts_copava as copava
9
+
10
+
11
+ def run_and_save_logs(
12
+ args, output_path, shell=False, executable=None, env=None, cwd=None
13
+ ):
14
+ """
15
+ Run a command and save stdout and stderr to a file in output_path
16
+
17
+ Note: explicitly list used named params instead of using **kwargs to avoid typing issue: https://github.com/microsoft/pyright/issues/455#issuecomment-780076232
18
+ """
19
+ output_file = open(output_path, "wb")
20
+ proc = subprocess.Popen(
21
+ args,
22
+ stdout=subprocess.PIPE, # Capture stdout
23
+ stderr=subprocess.PIPE, # Capture stderr
24
+ shell=shell,
25
+ executable=executable,
26
+ env=env,
27
+ cwd=cwd,
28
+ )
29
+ # write test-process stdout and stderr into file and stdout
30
+ if proc.stdout:
31
+ for line in proc.stdout:
32
+ decoded_line = line.decode()
33
+ sys.stdout.write(decoded_line)
34
+ output_file.write(line)
35
+ if proc.stderr:
36
+ for line in proc.stderr:
37
+ decoded_line = line.decode()
38
+ sys.stderr.write(decoded_line)
39
+ output_file.write(line)
40
+ proc.wait()
41
+ return proc.returncode
42
+
43
+
44
+ def ensure_available(package: str) -> None:
45
+ import importlib
46
+
47
+ try:
48
+ importlib.import_module(package)
49
+ except ImportError:
50
+ """
51
+ Recommended by the Python community
52
+ https://pip.pypa.io/en/latest/user_guide/#using-pip-from-your-program
53
+ """
54
+ subprocess.check_call([sys.executable, "-m", "pip", "install", package])
55
+
56
+
57
+ def read_config(filename: str) -> dict:
58
+ try:
59
+ with open(filename) as f:
60
+ return copava.parse(f.read())
61
+ except FileNotFoundError:
62
+ raise click.ClickException(f"Project config file {filename} not found.")
63
+
64
+
65
+ # Click callback syntax
66
+ def config_validation(context: dict, param: str, value: str) -> str:
67
+ if context.params.get("skip_validation", False):
68
+ return value
69
+ config = read_config(value)
70
+ errors = copava.check(config)
71
+ if len(errors) == 0:
72
+ return value
73
+ else:
74
+ raise click.BadParameter(pretty_print_config_error(errors))
75
+
76
+
77
+ def pretty_print_config_error(
78
+ errors: Union[str, list, dict], indent: int = 0, prefix: str = "", suffix: str = ""
79
+ ) -> str:
80
+ if type(errors) is str:
81
+ header = " " * indent
82
+ output = header + prefix + errors + suffix
83
+ elif type(errors) is list:
84
+ _depth = indent + 1
85
+ output = []
86
+ for value in errors:
87
+ output.append(pretty_print_config_error(value, indent=_depth, prefix="- "))
88
+ output = os.linesep.join(output)
89
+ elif type(errors) is dict:
90
+ _depth = indent + 1
91
+ output = []
92
+ for key, value in errors.items():
93
+ output.append(pretty_print_config_error(key, indent=indent, suffix=":"))
94
+ output.append(pretty_print_config_error(value, indent=_depth))
95
+ output = os.linesep.join(output)
96
+ else:
97
+ # Must not happen, so broad definition, but we want to know fast.
98
+ raise Exception(f"Unacceptable data type for config error formatting: {errors}")
99
+ return output
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '0.6.13'
16
- __version_tuple__ = version_tuple = (0, 6, 13)
15
+ __version__ = version = '0.6.17'
16
+ __version_tuple__ = version_tuple = (0, 6, 17)
@@ -1,6 +1,6 @@
1
1
  version: 0.1.0
2
2
 
3
- project: client-test-platform
3
+ project: artefacts-tests/cli-run-remote-testing
4
4
 
5
5
  on:
6
6
  push:
@@ -23,7 +23,7 @@ jobs:
23
23
  timeout: 5 # minutes
24
24
  scenarios:
25
25
  defaults: # Global to all scenarios, and overriden in specific scenarios.
26
- output_dirs:
26
+ output_dirs:
27
27
  - /tmp/outputs/
28
28
  subscriptions:
29
29
  pose: turtle1/pose
@@ -55,4 +55,4 @@ jobs:
55
55
  pose: turtle1/pose
56
56
  settings:
57
57
  - name: turtle
58
- ros_testfile: infra-tests/turtlesim2/launch_turtle.py
58
+ ros_testfile: infra-tests/turtlesim2/launch_turtle.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: artefacts_cli
3
- Version: 0.6.13
3
+ Version: 0.6.17
4
4
  Author-email: FD <fabian@artefacts.com>, AGC <alejandro@artefacts.com>, TN <tomo@artefacts.com>, EP <eric@artefacts.com>
5
5
  Project-URL: Homepage, https://github.com/art-e-fact/artefacts-client
6
6
  Project-URL: Bug Tracker, https://github.com/art-e-fact/artefacts-client/issues
@@ -18,9 +18,11 @@ Requires-Dist: mcap-ros2-support
18
18
  Requires-Dist: PyYAML>=6.0
19
19
  Requires-Dist: requests>=2.27.1
20
20
  Requires-Dist: setuptools-scm
21
+ Requires-Dist: setuptools>=74
21
22
  Provides-Extra: dev
22
23
  Requires-Dist: awscli; extra == "dev"
23
24
  Requires-Dist: build; extra == "dev"
25
+ Requires-Dist: docker; extra == "dev"
24
26
  Requires-Dist: lark; extra == "dev"
25
27
  Requires-Dist: pyre-check; extra == "dev"
26
28
  Requires-Dist: pytest; extra == "dev"
@@ -5,6 +5,7 @@ pyproject.toml
5
5
  pytest.ini
6
6
  artefacts/cli/__init__.py
7
7
  artefacts/cli/app.py
8
+ artefacts/cli/app_containers.py
8
9
  artefacts/cli/bagparser.py
9
10
  artefacts/cli/constants.py
10
11
  artefacts/cli/other.py
@@ -14,6 +15,9 @@ artefacts/cli/ros2.py
14
15
  artefacts/cli/utils.py
15
16
  artefacts/cli/utils_ros.py
16
17
  artefacts/cli/version.py
18
+ artefacts/cli/containers/__init__.py
19
+ artefacts/cli/containers/docker.py
20
+ artefacts/cli/containers/utils.py
17
21
  artefacts/wrappers/artefacts_ros1_meta.launch
18
22
  artefacts_cli.egg-info/PKG-INFO
19
23
  artefacts_cli.egg-info/SOURCES.txt
@@ -40,6 +44,7 @@ tests/__init__.py
40
44
  tests/conftest.py
41
45
  tests/test_config_validation.py
42
46
  tests/cli/__init__.py
47
+ tests/cli/test_app_containers.py
43
48
  tests/cli/test_cli.py
44
49
  tests/cli/test_config_validation.py
45
50
  tests/cli/test_other.py
@@ -50,4 +55,5 @@ tests/cli/test_warp.py
50
55
  tests/fixtures/artefacts_deprecated.yaml
51
56
  tests/fixtures/artefacts_ros1.yaml
52
57
  tests/fixtures/warp-env-param.yaml
53
- tests/fixtures/warp.yaml
58
+ tests/fixtures/warp.yaml
59
+ tests/utils/docker_mock.py
@@ -7,10 +7,12 @@ mcap-ros2-support
7
7
  PyYAML>=6.0
8
8
  requests>=2.27.1
9
9
  setuptools-scm
10
+ setuptools>=74
10
11
 
11
12
  [dev]
12
13
  awscli
13
14
  build
15
+ docker
14
16
  lark
15
17
  pyre-check
16
18
  pytest
@@ -28,6 +28,7 @@ dependencies = [
28
28
  "PyYAML>=6.0",
29
29
  "requests>=2.27.1",
30
30
  "setuptools-scm",
31
+ "setuptools>=74", # setuptools-scm requires without version, but does not work with oldish versions, so let's ensure a recent, tested one.
31
32
  ]
32
33
  dynamic = ["version"]
33
34
 
@@ -42,6 +43,7 @@ local_scheme = "no-local-version"
42
43
  dev = [
43
44
  "awscli",
44
45
  "build",
46
+ "docker",
45
47
  "lark",
46
48
  "pyre-check",
47
49
  "pytest",
@@ -1,5 +1,5 @@
1
1
  [pytest]
2
- addopts = -ra --quiet --cov-config .coveragerc --cov-report term-missing --cov-fail-under 25
2
+ addopts = -ra --cov-config .coveragerc --cov-report term-missing --cov-fail-under 25
3
3
  console_output_style = progress
4
4
  env =
5
5
  ARTEFACTS_CLI_ENV=test
@@ -0,0 +1,54 @@
1
+ import os
2
+ from uuid import uuid4
3
+
4
+ import pytest
5
+
6
+ from artefacts.cli.app_containers import containers
7
+
8
+
9
+ def test_container_package_exists(cli_runner):
10
+ result = cli_runner.invoke(containers, [])
11
+ assert result.exit_code == 0
12
+
13
+
14
+ def test_container_package_build_specific_dockerfile(
15
+ cli_runner, dockerfile_available, docker_mocker
16
+ ):
17
+ dockerfile = "non_standard_dockerfile"
18
+ result = cli_runner.invoke(containers, ["build", "--dockerfile", dockerfile])
19
+ dockerfile_available.assert_any_call(os.path.join(".", dockerfile))
20
+ assert result.exit_code == 0
21
+
22
+
23
+ def test_container_package_build_specific_dockerfile_missing(
24
+ cli_runner, dockerfile_not_available
25
+ ):
26
+ dockerfile = "non_standard_dockerfile"
27
+ result = cli_runner.invoke(containers, ["build", "--dockerfile", dockerfile])
28
+ dockerfile_not_available.assert_any_call(os.path.join(".", dockerfile))
29
+ assert result.exit_code == 1
30
+ assert (
31
+ result.output.strip()
32
+ == f"Error: No {dockerfile} found here. I cannot build the container."
33
+ )
34
+
35
+
36
+ def test_container_package_build_specific_image_name(
37
+ cli_runner, dockerfile_available, docker_mocker
38
+ ):
39
+ name = str(uuid4())
40
+ before = len(docker_mocker.images())
41
+ result = cli_runner.invoke(containers, ["build", "--name", name])
42
+ assert result.exit_code == 0
43
+ assert len(docker_mocker.images()) == before + 1
44
+ assert docker_mocker.get_image(name).Repository == name
45
+
46
+
47
+ def test_container_package_build_default_image_name(
48
+ cli_runner, dockerfile_available, docker_mocker
49
+ ):
50
+ before = len(docker_mocker.images())
51
+ result = cli_runner.invoke(containers, ["build"])
52
+ assert result.exit_code == 0
53
+ assert len(docker_mocker.images()) == before + 1
54
+ assert docker_mocker.get_image("artefacts") is not None
@@ -0,0 +1,42 @@
1
+ import pytest
2
+
3
+ import os
4
+
5
+ from click.testing import CliRunner
6
+ import docker
7
+
8
+ from tests.utils import docker_mock
9
+
10
+
11
+ def dockerfile_presence(mocker, value: bool):
12
+ original = os.path.exists
13
+
14
+ def exists(path):
15
+ if "dockerfile" in path.lower():
16
+ return value
17
+ else:
18
+ return original(path)
19
+
20
+ return mocker.patch("os.path.exists", side_effect=exists, autospec=True)
21
+
22
+
23
+ @pytest.fixture(scope="function")
24
+ def dockerfile_available(mocker):
25
+ return dockerfile_presence(mocker, True)
26
+
27
+
28
+ @pytest.fixture(scope="function")
29
+ def dockerfile_not_available(mocker):
30
+ return dockerfile_presence(mocker, False)
31
+
32
+
33
+ @pytest.fixture(scope="module")
34
+ def docker_mocker(module_mocker):
35
+ test_client = docker_mock.make_fake_api_client()
36
+ module_mocker.patch("docker.APIClient", return_value=test_client)
37
+ return test_client
38
+
39
+
40
+ @pytest.fixture(scope="module")
41
+ def cli_runner():
42
+ return CliRunner()
@@ -0,0 +1,89 @@
1
+ # Fake Docker client
2
+
3
+ import copy
4
+ from dataclasses import dataclass, asdict
5
+ import hashlib
6
+ from random import randint
7
+ from unittest import mock
8
+
9
+ from docker import DockerClient, APIClient
10
+ from docker.constants import DEFAULT_DOCKER_API_VERSION
11
+
12
+
13
+ @dataclass
14
+ class SimpleImage:
15
+ Id: str
16
+ Created: str
17
+ Repository: str
18
+ RepoTags: list
19
+
20
+
21
+ class TestDockerAPIClient(APIClient):
22
+ def __init__(self, *args, **kwargs):
23
+ super().__init__(*args, **kwargs)
24
+ self.fake_server_db = {}
25
+ self.fake_image_id_base = hashlib.new("sha256")
26
+
27
+ def build(self, *args, **kwargs):
28
+ if kwargs["tag"] in self.fake_server_db:
29
+ original = self.fake_server_db[kwargs["tag"]]
30
+ for img in original:
31
+ # Mimick what Docker seems to be doing
32
+ img.Repository = ""
33
+ img.RepoTags = []
34
+ else:
35
+ original = []
36
+
37
+ # Generate a random yet compliant image ID
38
+ # Note the use of update, to ensure calling many times will be on different input (so unique IDs).
39
+ self.fake_image_id_base.update(bytes(randint(0, 100)))
40
+ iid = "sha256:" + self.fake_image_id_base.hexdigest()
41
+
42
+ new_collection = [
43
+ SimpleImage(
44
+ **{
45
+ "Id": iid,
46
+ "Created": "1 min ago",
47
+ "Repository": kwargs["tag"],
48
+ "RepoTags": [kwargs["tag"] + ":latest"],
49
+ }
50
+ )
51
+ ]
52
+ # Ensure the new entry is in the first place
53
+ new_collection.extend(original)
54
+ self.fake_server_db[kwargs["tag"]] = new_collection
55
+
56
+ return [b'"fake"', b'"test"', b'"logs"']
57
+
58
+ def images(self, *args, **kwargs):
59
+ if kwargs.get("name", None):
60
+ return self.fake_server_db.get(kwargs["name"])
61
+ else:
62
+ return [v for vs in self.fake_server_db.values() for v in vs]
63
+
64
+ def get_image(self, name):
65
+ # Index 0 is the latest
66
+ return self.fake_server_db.get(name)[0]
67
+
68
+ def inspect_image(self, name):
69
+ return asdict(self.get_image(name))
70
+
71
+
72
+ class TestDockerClient(DockerClient):
73
+ def __init__(self, *args, **kwargs):
74
+ super().__init__(*args, **kwargs)
75
+ self.api = TestDockerAPIClient(version=kwargs["version"])
76
+
77
+
78
+ def make_fake_api_client():
79
+ """
80
+ Incomplete fake API client.
81
+ """
82
+ return TestDockerAPIClient(version=DEFAULT_DOCKER_API_VERSION)
83
+
84
+
85
+ def make_fake_client():
86
+ """
87
+ Test client with a fake API client.
88
+ """
89
+ return TestDockerClient(version=DEFAULT_DOCKER_API_VERSION)
@@ -1,35 +0,0 @@
1
- import subprocess
2
- import sys
3
-
4
-
5
- def run_and_save_logs(
6
- args, output_path, shell=False, executable=None, env=None, cwd=None
7
- ):
8
- """
9
- Run a command and save stdout and stderr to a file in output_path
10
-
11
- Note: explicitly list used named params instead of using **kwargs to avoid typing issue: https://github.com/microsoft/pyright/issues/455#issuecomment-780076232
12
- """
13
- output_file = open(output_path, "wb")
14
- proc = subprocess.Popen(
15
- args,
16
- stdout=subprocess.PIPE, # Capture stdout
17
- stderr=subprocess.PIPE, # Capture stderr
18
- shell=shell,
19
- executable=executable,
20
- env=env,
21
- cwd=cwd,
22
- )
23
- # write test-process stdout and stderr into file and stdout
24
- if proc.stdout:
25
- for line in proc.stdout:
26
- decoded_line = line.decode()
27
- sys.stdout.write(decoded_line)
28
- output_file.write(line)
29
- if proc.stderr:
30
- for line in proc.stderr:
31
- decoded_line = line.decode()
32
- sys.stderr.write(decoded_line)
33
- output_file.write(line)
34
- proc.wait()
35
- return proc.returncode
@@ -1,8 +0,0 @@
1
- import pytest
2
-
3
- from click.testing import CliRunner
4
-
5
-
6
- @pytest.fixture(scope="module")
7
- def cli_runner():
8
- return CliRunner()
File without changes
File without changes