artefacts-cli 0.8.0__py3-none-any.whl → 0.9.2__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.
@@ -7,6 +7,7 @@ import click
7
7
  from c2d.core import Converter
8
8
 
9
9
  from artefacts.cli.constants import DEFAULT_API_URL
10
+ from artefacts.cli.i18n import localise
10
11
  from artefacts.cli.utils import config_validation, read_config
11
12
  from artefacts.cli.containers.utils import ContainerMgr
12
13
 
@@ -23,35 +24,45 @@ def containers(ctx: click.Context, debug: bool):
23
24
  @click.option(
24
25
  "--path",
25
26
  default=".",
26
- help="[Deprecated since 0.8.0; please see --root] Path to the root of the project.",
27
+ help=localise(
28
+ "[Deprecated since 0.8.0; please see --root] Path to the root of the project."
29
+ ),
27
30
  )
28
31
  @click.option(
29
32
  "--root",
30
33
  default=".",
31
- help="Path to the root of the project.",
34
+ help=localise("Path to the root of the project."),
32
35
  )
33
36
  @click.option(
34
37
  "--dockerfile",
35
38
  default="Dockerfile",
36
- help="Path to a custom Dockerfile. Defaults to Dockerfile under `path` (see option of the same name).",
39
+ help=localise(
40
+ "Path to a custom Dockerfile. Defaults to Dockerfile under `path` (see option of the same name)."
41
+ ),
37
42
  )
38
43
  @click.option(
39
44
  "--name",
40
45
  required=False,
41
- help="[Deprecated since 0.8.0; not used and will disappear after 0.8.0] Name for the generated image",
46
+ help=localise(
47
+ "[Deprecated since 0.8.0; not used and will disappear after 0.8.0] Name for the generated image"
48
+ ),
42
49
  )
43
50
  @click.option(
44
51
  "--config",
45
52
  callback=config_validation,
46
53
  default="artefacts.yaml",
47
- help="Path to the Artefacts configuration file. It defaults to `./artefacts.yaml`",
54
+ help=localise(
55
+ "Path to the Artefacts configuration file. It defaults to `./artefacts.yaml`"
56
+ ),
48
57
  )
49
58
  @click.option(
50
59
  "--only",
51
60
  required=False,
52
61
  type=Optional[list],
53
62
  default=None,
54
- help="Optional list of job names to process. The default is to process all jobs.",
63
+ help=localise(
64
+ "Optional list of job names to process. The default is to process all jobs."
65
+ ),
55
66
  )
56
67
  @click.pass_context
57
68
  def build(
@@ -67,7 +78,11 @@ def build(
67
78
  artefacts_config = read_config(config)
68
79
  except FileNotFoundError:
69
80
  raise click.ClickException(
70
- f"Project config file not found: {config}. Please provide an Artefacts configuration file to proceed (running `artefacts init` allows to generate one)."
81
+ localise(
82
+ "Project config file not found: {config}. Please provide an Artefacts configuration file to proceed (running `artefacts init` allows to generate one).".format(
83
+ config=config
84
+ )
85
+ )
71
86
  )
72
87
  prefix = artefacts_config["project"].strip().lower()
73
88
  dockerfiles = []
@@ -87,7 +102,11 @@ def build(
87
102
  elif dockerfile != "Dockerfile" and not os.path.exists(dockerfile):
88
103
  # The user asks explicitly for using a specific Dockerfile, so fast fail if we cannot find it
89
104
  raise click.ClickException(
90
- f"Dockerfile `{dockerfile}` not found. Please ensure the file exits. Automatic Dockerfile generation may also work by dropping the --dockerfile option."
105
+ localise(
106
+ "Dockerfile `{dockerfile}` not found. Please ensure the file exits. Automatic Dockerfile generation may also work by dropping the --dockerfile option.".format(
107
+ dockerfile=dockerfile
108
+ )
109
+ )
91
110
  )
92
111
  else:
93
112
  # The split on `prefix` is to ensure there is no slash (project names are org/project) confusing the path across supported OS.
@@ -100,7 +119,11 @@ def build(
100
119
  )
101
120
  if not dest_root.exists():
102
121
  click.echo(
103
- f"No {dockerfile} found here. Let's generate one per scenario based on artefacts.yaml. They will be available under the `{dest_root}` folder and used from there."
122
+ localise(
123
+ "No {dockerfile} found here. Let's generate one per scenario based on artefacts.yaml. They will be available under the `{dest_root}` folder and used from there.".format(
124
+ dockerfile=dockerfile, dest_root=dest_root
125
+ )
126
+ )
104
127
  )
105
128
  # No condition on generating the Dockerfiles as:
106
129
  # - Fast
@@ -114,7 +137,14 @@ def build(
114
137
  dest.mkdir(parents=True, exist_ok=True)
115
138
  _dockerfile = os.path.join(dest, "Dockerfile")
116
139
  df.dump(_dockerfile)
117
- click.echo(f"[{job_name}] Using generated Dockerfile at: {_dockerfile}")
140
+ click.echo(
141
+ f"[{job_name}] "
142
+ + localise(
143
+ "Using generated Dockerfile at: {dockerfile}".format(
144
+ dockerfile=_dockerfile
145
+ )
146
+ )
147
+ )
118
148
  dockerfiles.append(
119
149
  dict(
120
150
  path=root,
@@ -128,43 +158,78 @@ def build(
128
158
  # No condition on building the images, as relatively fast when already exists, and straightforward logic.
129
159
  image, _ = handler.build(**specs)
130
160
  else:
131
- click.echo("No Dockerfile, nothing to do.")
161
+ click.echo(localise("No Dockerfile, nothing to do."))
132
162
 
133
163
 
134
164
  @containers.command()
135
- @click.argument("name")
165
+ @click.argument("image_name", metavar=localise("IMAGE_NAME"))
136
166
  @click.pass_context
137
- def check(ctx: click.Context, name: str):
138
- if name is None:
139
- name = "artefacts"
167
+ def check(ctx: click.Context, image_name: str):
168
+ if image_name is None:
169
+ image_name = "artefacts"
140
170
  handler = ContainerMgr()
141
- result = handler.check(name)
171
+ result = handler.check(image_name)
142
172
  if ctx.parent is None:
143
173
  # Print only if the command is called directly.
144
- print(f"Package {name} exists and ready to use.")
174
+ print(
175
+ localise("Container image {name} exists and ready to use.").format(
176
+ name=image_name
177
+ )
178
+ )
145
179
  return result
146
180
 
147
181
 
148
- @containers.command()
149
- @click.argument("jobname")
182
+ @containers.command(
183
+ context_settings=dict(
184
+ ignore_unknown_options=True,
185
+ )
186
+ )
187
+ @click.argument("jobname", metavar=localise("JOBNAME"))
150
188
  @click.option(
151
189
  "--config",
152
190
  callback=config_validation,
153
191
  default="artefacts.yaml",
154
- help="Path to the Artefacts configuration file. It defaults to `./artefacts.yaml`",
192
+ help=localise(
193
+ "Path to the Artefacts configuration file. It defaults to `./artefacts.yaml`"
194
+ ),
155
195
  )
156
196
  @click.option(
157
197
  "--with-gui",
158
198
  "with_gui",
159
199
  default=False,
160
- 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.",
200
+ help=localise(
201
+ "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."
202
+ ),
161
203
  )
204
+ # Not ready, placeholder for the introduction of Podman
205
+ # @click.option(
206
+ # "--backend",
207
+ # help="Container backend to use, mostly useful if you are using several at the same time. When this option is not specified, Artefacts will try to automatically detect available backends, with a preference for Docker if found. The `--engine-args` allows to pass engine arguments like `--gpu` for Docker, etc.",
208
+ # )
209
+ @click.argument("engine-args", nargs=-1, type=click.UNPROCESSED)
162
210
  @click.pass_context
163
- def run(ctx: click.Context, jobname: str, config: str, with_gui: bool):
211
+ def run(
212
+ ctx: click.Context,
213
+ jobname: str,
214
+ config: str,
215
+ with_gui: bool,
216
+ # backend: str,
217
+ engine_args: tuple,
218
+ ):
219
+ # Workaround for job names coming after engine arguments
220
+ # Idea: Job names do not start with hyphens.
221
+ if jobname.startswith("-") and engine_args is not None:
222
+ _fix = list(engine_args)
223
+ _fix.insert(0, jobname)
224
+ jobname = _fix.pop()
225
+ engine_args = tuple(_fix)
226
+
164
227
  try:
165
228
  artefacts_config = read_config(config)
166
229
  except FileNotFoundError:
167
- raise click.ClickException(f"Project config file not found: {config}")
230
+ raise click.ClickException(
231
+ localise("Project config file not found: {config}".format(config=config))
232
+ )
168
233
  project = artefacts_config["project"]
169
234
  handler = ContainerMgr()
170
235
  params = dict(
@@ -175,11 +240,18 @@ def run(ctx: click.Context, jobname: str, config: str, with_gui: bool):
175
240
  # Hidden settings primarily useful to Artefacts developers
176
241
  api_url=os.environ.get("ARTEFACTS_API_URL", DEFAULT_API_URL),
177
242
  api_key=os.environ.get("ARTEFACTS_KEY", None),
243
+ engine_args=list(engine_args),
178
244
  )
179
245
  container, logs = handler.run(**params)
180
246
  if container:
181
- print(f"Package run complete: Container Id for inspection: {container['Id']}")
247
+ print(
248
+ localise(
249
+ "Container run complete: Container Id for inspection: {container_id}".format(
250
+ container_id=container["Id"]
251
+ )
252
+ )
253
+ )
182
254
  else:
183
- print("Package run failed:")
255
+ print(localise("Package run failed:"))
184
256
  for entry in logs:
185
257
  print("\t- " + entry)
@@ -0,0 +1,3 @@
1
+ from typing import Any
2
+
3
+ def __getattr__(name: str) -> Any: ...
@@ -1,5 +1,9 @@
1
1
  import sqlite3
2
+
3
+ # pyre-fixme[21]
2
4
  from rosidl_runtime_py.utilities import get_message
5
+
6
+ # pyre-fixme[21]
3
7
  from rclpy.serialization import deserialize_message
4
8
  from mcap.reader import make_reader
5
9
  from mcap_ros2.decoder import DecoderFactory
@@ -0,0 +1,62 @@
1
+ import os
2
+ import platform
3
+ from typing import Optional
4
+
5
+ import click
6
+
7
+ from artefacts.cli.i18n import localise
8
+ from artefacts.cli.helpers import (
9
+ get_conf_from_file,
10
+ get_artefacts_api_url,
11
+ )
12
+
13
+
14
+ class APIConf:
15
+ def __init__(
16
+ self, project_name: str, api_version: str, job_name: Optional[str] = None
17
+ ) -> None:
18
+ config = get_conf_from_file()
19
+ if project_name in config:
20
+ profile = config[project_name]
21
+ else:
22
+ profile = {}
23
+ self.api_url = get_artefacts_api_url(profile)
24
+ self.api_key = os.environ.get("ARTEFACTS_KEY", profile.get("ApiKey", None))
25
+ if self.api_key is None:
26
+ batch_id = os.environ.get("AWS_BATCH_JOB_ID", None)
27
+ job_id = os.environ.get("ARTEFACTS_JOB_ID", None)
28
+ if batch_id is None or job_id is None:
29
+ raise click.ClickException(
30
+ localise(
31
+ "No API KEY set. Please run `artefacts config add {project_name}`".format(
32
+ project_name=project_name
33
+ )
34
+ )
35
+ )
36
+ auth_type = "Internal"
37
+ # Batch id for array jobs contains array index
38
+ batch_id = batch_id.split(":")[0]
39
+ self.headers = {"Authorization": f"{auth_type} {job_id}:{batch_id}"}
40
+ else:
41
+ auth_type = "ApiKey"
42
+ self.headers = {"Authorization": f"{auth_type} {self.api_key}"}
43
+ self.headers["User-Agent"] = (
44
+ f"ArtefactsClient/{api_version} ({platform.platform()}/{platform.python_version()})"
45
+ )
46
+ if job_name:
47
+ click.echo(
48
+ f"[{job_name}] "
49
+ + localise(
50
+ "Connecting to {api_url} using {auth_type}".format(
51
+ api_url=self.api_url, auth_type=auth_type
52
+ )
53
+ )
54
+ )
55
+ else:
56
+ click.echo(
57
+ localise(
58
+ "Connecting to {api_url} using {auth_type}".format(
59
+ api_url=self.api_url, auth_type=auth_type
60
+ )
61
+ )
62
+ )
@@ -1,3 +1,6 @@
1
+ import os
2
+
3
+
1
4
  DEFAULT_API_URL = "https://app.artefacts.com/api"
2
5
 
3
6
  SUPPORTED_FRAMEWORKS = [
@@ -16,3 +19,7 @@ DEPRECATED_FRAMEWORKS = {
16
19
  "ros2:0": "ros2:galactic",
17
20
  "ros1:0": "ros1:noetic",
18
21
  }
22
+
23
+ HOME = os.path.expanduser("~")
24
+ CONFIG_DIR = f"{HOME}/.artefacts"
25
+ CONFIG_PATH = f"{CONFIG_DIR}/config"
@@ -1,4 +1,4 @@
1
- from collections.abc import Generator
1
+ from collections.abc import Iterator
2
2
  import configparser
3
3
  import os
4
4
  from pathlib import Path
@@ -8,7 +8,7 @@ from artefacts.cli.constants import DEFAULT_API_URL
8
8
 
9
9
 
10
10
  class CMgr:
11
- def build(self, **kwargs) -> Tuple[str, Generator]:
11
+ def build(self, **kwargs) -> Tuple[str, Iterator]:
12
12
  """
13
13
  Returns the build image ID (e.g. sha256:abcdefghi)
14
14
  and an iterator over the build log entries.
@@ -25,11 +25,11 @@ class CMgr:
25
25
  self,
26
26
  image: str,
27
27
  project: str,
28
- jobname: str = None,
29
- artefacts_dir: str = Path("~/.artefacts").expanduser(),
28
+ jobname: Optional[str] = None,
29
+ artefacts_dir: Union[str, Path] = Path("~/.artefacts").expanduser(),
30
30
  api_url: str = DEFAULT_API_URL,
31
31
  with_gui: bool = False,
32
- ) -> Tuple[Any, Generator]:
32
+ ) -> Tuple[Any, Iterator]:
33
33
  """
34
34
  Returns a container (Any type as depends on the framework)
35
35
  and an iterator over the container log entries.
@@ -0,0 +1,175 @@
1
+ from collections.abc import Iterator
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+ import platform
6
+ from typing import Any, Optional, Tuple, Union
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.containers.docker_utils import cli2sdk
12
+ from artefacts.cli.i18n import localise
13
+ from artefacts.cli.utils import ensure_available
14
+
15
+ ensure_available("docker")
16
+
17
+ import docker # noqa: E402
18
+ from docker import APIClient # noqa: E402
19
+
20
+
21
+ class DockerManager(CMgr):
22
+ def __init__(self):
23
+ self.client = APIClient()
24
+
25
+ def build(self, **kwargs) -> Tuple[str, Iterator]:
26
+ kwargs["tag"] = kwargs.pop("name")
27
+ # Ensure `path` is a string, the Docker package does not support pathlib.
28
+ kwargs["path"] = str(kwargs.pop("path"))
29
+ # Remove intermediate containers
30
+ kwargs["rm"] = True
31
+ logs = []
32
+ img_id = None
33
+ for entry in self.client.build(**kwargs):
34
+ line_data = [
35
+ json.loads(v) for v in entry.decode("utf-8").split("\r\n") if len(v) > 0
36
+ ]
37
+ for data in line_data:
38
+ if "stream" in data:
39
+ line = data["stream"].strip()
40
+ if not line.startswith("---") and len(line) > 0:
41
+ print(f"[{kwargs['tag'].split('/')[-1]}] {line}")
42
+ logs.append(line)
43
+ elif "aux" in data and "ID" in data["aux"]:
44
+ img_id = str(data["aux"]["ID"])
45
+ if img_id is None:
46
+ img_id = str(self.client.inspect_image(kwargs["tag"])["Id"])
47
+ return img_id, iter(logs)
48
+
49
+ def check(
50
+ self,
51
+ image: str,
52
+ ) -> bool:
53
+ return len(self.client.images(name=image)) > 0
54
+
55
+ def run(
56
+ self,
57
+ image: str,
58
+ project: str,
59
+ jobname: Optional[str] = None,
60
+ artefacts_dir: Union[str, Path] = Path("~/.artefacts").expanduser(),
61
+ api_url: str = DEFAULT_API_URL,
62
+ api_key: Optional[str] = None,
63
+ with_gui: bool = False,
64
+ engine_args: Optional[list] = None,
65
+ ) -> Tuple[Any, Iterator]:
66
+ """
67
+ Run an application as an Artefacts-enabled container in a Docker engine
68
+ """
69
+ env = {
70
+ "JOB_ID": str(uuid4()),
71
+ "ARTEFACTS_JOB_NAME": jobname,
72
+ "ARTEFACTS_API_URL": api_url,
73
+ }
74
+
75
+ env["ARTEFACTS_KEY"] = api_key or self._get_artefacts_api_key(
76
+ project, artefacts_dir
77
+ )
78
+ if env["ARTEFACTS_KEY"] is None:
79
+ return None, iter(
80
+ [
81
+ localise(
82
+ "Missing API key for the project. Does `{path}/config` exist and contain your key? Alternatively ARTEFACTS_KEY can be set with the key.".format(
83
+ path=artefacts_dir
84
+ )
85
+ )
86
+ ]
87
+ )
88
+ try:
89
+ if platform.system() in ["Darwin", "Windows"]:
90
+ # Assume we run in Docker Desktop
91
+ env["DISPLAY"] = "host.docker.internal:0"
92
+ else:
93
+ env["DISPLAY"] = os.environ.get("DISPLAY", ":0")
94
+
95
+ if not with_gui:
96
+ env["QT_QPA_PLATFORM"] = "offscreen"
97
+
98
+ # Default configs
99
+ host_conf = dict(
100
+ network_mode="host",
101
+ )
102
+
103
+ container_conf = dict(
104
+ image=image,
105
+ environment=env,
106
+ detach=False,
107
+ )
108
+
109
+ # Apply user config and overrides, if any
110
+ if engine_args:
111
+ option = None
112
+ # Add a marker to detect end of args
113
+ engine_args.append("--end--")
114
+ for current in engine_args:
115
+ is_option = current.startswith("-")
116
+ if is_option and not option:
117
+ option = current.lstrip("-")
118
+ if "=" in option:
119
+ option, value = option.split("=")
120
+ cli2sdk(host_conf, container_conf, option, value)
121
+ option = None
122
+ elif not is_option and option:
123
+ cli2sdk(host_conf, container_conf, option, current)
124
+ option = None
125
+ elif is_option and option:
126
+ # Assuming detection of concatenated flags, all set.
127
+ for flag in str(option):
128
+ cli2sdk(host_conf, container_conf, flag, True)
129
+ if current != "--end--":
130
+ option = current
131
+
132
+ # Final container config
133
+ container_conf["host_config"] = self.client.create_host_config(**host_conf)
134
+
135
+ try:
136
+ container = self.client.create_container(**container_conf)
137
+ except Exception as e:
138
+ # SDK errors may have an explanation
139
+ # E.g. in 7.1.0 https://github.com/docker/docker-py/blob/7.1.0/docker/errors.py#L53
140
+ known = str(e)
141
+ if known.endswith(")"):
142
+ # Error message: info ("explanation")
143
+ # We return explanation
144
+ detail = known[known.index("(") + 2 : -2]
145
+ else:
146
+ # Error message: info
147
+ # We return info
148
+ detail = known[known.index(":") + 2 :]
149
+ raise Exception(f"Invalid container configuration: {detail}")
150
+ self.client.start(container=container.get("Id"))
151
+
152
+ for entry in self.client.logs(container=container.get("Id"), stream=True):
153
+ print(entry.decode("utf-8").strip())
154
+
155
+ return container, iter([])
156
+ except docker.errors.ImageNotFound:
157
+ return None, iter(
158
+ [
159
+ localise(
160
+ "Image {image} not found by Docker. Perhaps need to build first?".format(
161
+ image=image
162
+ )
163
+ )
164
+ ]
165
+ )
166
+ except Exception as e:
167
+ return None, iter(
168
+ [
169
+ localise(
170
+ "Failed to run from {image}. All we know: {message}".format(
171
+ image=image, message=e
172
+ )
173
+ )
174
+ ]
175
+ )
@@ -0,0 +1,98 @@
1
+ from typing import Any
2
+
3
+ import docker # noqa: E402
4
+
5
+ from artefacts.cli.i18n import localise
6
+
7
+
8
+ def _identity(v: Any) -> Any:
9
+ return v
10
+
11
+
12
+ def _make_gpu_device_request(gpus: str) -> list:
13
+ """
14
+ Code based on Docker documented use, not on the source code.
15
+
16
+ https://docs.docker.com/reference/cli/docker/container/run/#gpus
17
+ """
18
+ if '"device=' in gpus:
19
+ try:
20
+ # Docs: `--gpus '"device=1,2"'`
21
+ _g = gpus.strip('"')
22
+ # There must be 2 double quotes.
23
+ assert len(_g) == len(gpus) - 2
24
+ ids = sorted(_g[_g.index("=") + 1 :].split(","))
25
+ except Exception as e:
26
+ raise Exception(
27
+ localise(
28
+ 'Invalid GPU device for Docker: {g} ({e}). Accepted device formats are all, device=1 and "device=1,2" (with a list of devices, quotes must be included'.format(
29
+ g=gpus, e=e
30
+ )
31
+ )
32
+ )
33
+ elif "device=" in gpus:
34
+ # Docs: `--gpus device=1` or `--gpus device=GPU-3faa8-219`
35
+ if "," in gpus:
36
+ raise Exception(
37
+ localise(
38
+ 'Invalid GPU device for Docker: {g}. List of devices must be double-quoted. Accepted device formats are all, device=1 and "device=1,2" (with a list of devices, quotes must be included'.format(
39
+ g=gpus,
40
+ )
41
+ )
42
+ )
43
+ ids = [gpus.split("=")[-1]]
44
+ elif "all" == gpus:
45
+ ids = [gpus]
46
+ else:
47
+ raise Exception(
48
+ localise(
49
+ 'Invalid GPU device for Docker: {g}. Accepted device formats are all, device=1 and "device=1,2" (with a list of devices, quotes must be included'.format(
50
+ g=gpus
51
+ )
52
+ )
53
+ )
54
+ return [docker.types.DeviceRequest(device_ids=ids, capabilities=[["gpu"]])]
55
+
56
+
57
+ _cli_sdk_option_map = {
58
+ "net": {
59
+ "t": "host",
60
+ "o": "network_mode",
61
+ },
62
+ "gpus": {
63
+ "t": "host",
64
+ "o": "device_requests",
65
+ "f": _make_gpu_device_request,
66
+ },
67
+ "t": {
68
+ "t": "container",
69
+ "o": "tty",
70
+ },
71
+ "i": {
72
+ "t": "container",
73
+ "o": "stdin_open",
74
+ },
75
+ "tty": {
76
+ "t": "container",
77
+ },
78
+ "interactive": {
79
+ "t": "container",
80
+ "o": "stdin_open",
81
+ },
82
+ }
83
+
84
+
85
+ def cli2sdk(host: dict, container: dict, option: str, value: Any) -> None:
86
+ """
87
+ `host` and `container` are IO map arguments.
88
+ """
89
+ method = _cli_sdk_option_map.get(option)
90
+ if method:
91
+ if method["t"] == "host":
92
+ host[method.get("o") or option] = (method.get("f") or _identity)(value)
93
+ elif method["t"] == "container":
94
+ container[method.get("o") or option] = (method.get("f") or _identity)(value)
95
+ else:
96
+ # Based on current knowledge of the SDK, it seems
97
+ # we can opt for the host config as default.
98
+ host[option] = value