artefacts-cli 0.7.3__py3-none-any.whl → 0.9.1__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.
@@ -1,11 +1,13 @@
1
1
  import os
2
2
  from pathlib import Path
3
+ from typing import Optional
3
4
 
4
5
  import click
5
6
 
6
7
  from c2d.core import Converter
7
8
 
8
9
  from artefacts.cli.constants import DEFAULT_API_URL
10
+ from artefacts.cli.i18n import localise
9
11
  from artefacts.cli.utils import config_validation, read_config
10
12
  from artefacts.cli.containers.utils import ContainerMgr
11
13
 
@@ -22,28 +24,45 @@ def containers(ctx: click.Context, debug: bool):
22
24
  @click.option(
23
25
  "--path",
24
26
  default=".",
25
- 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
+ ),
26
30
  )
27
31
  @click.option(
28
32
  "--root",
29
33
  default=".",
30
- help="Path to the root of the project.",
34
+ help=localise("Path to the root of the project."),
31
35
  )
32
36
  @click.option(
33
37
  "--dockerfile",
34
38
  default="Dockerfile",
35
- 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
+ ),
36
42
  )
37
43
  @click.option(
38
44
  "--name",
39
45
  required=False,
40
- 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
+ ),
41
49
  )
42
50
  @click.option(
43
51
  "--config",
44
52
  callback=config_validation,
45
53
  default="artefacts.yaml",
46
- 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
+ ),
57
+ )
58
+ @click.option(
59
+ "--only",
60
+ required=False,
61
+ type=Optional[list],
62
+ default=None,
63
+ help=localise(
64
+ "Optional list of job names to process. The default is to process all jobs."
65
+ ),
47
66
  )
48
67
  @click.pass_context
49
68
  def build(
@@ -53,17 +72,26 @@ def build(
53
72
  dockerfile: str,
54
73
  name: str,
55
74
  config: str,
75
+ only: Optional[list] = None,
56
76
  ):
57
77
  try:
58
78
  artefacts_config = read_config(config)
59
79
  except FileNotFoundError:
60
80
  raise click.ClickException(
61
- 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
+ )
62
86
  )
63
87
  prefix = artefacts_config["project"].strip().lower()
64
88
  dockerfiles = []
65
89
  if os.path.exists(dockerfile):
66
- for job_name in artefacts_config["jobs"]:
90
+ if only:
91
+ jobs = only
92
+ else:
93
+ jobs = artefacts_config["jobs"]
94
+ for job_name in jobs:
67
95
  dockerfiles.append(
68
96
  dict(
69
97
  path=root,
@@ -71,6 +99,15 @@ def build(
71
99
  name=f"{prefix}/{job_name.strip().lower()}",
72
100
  )
73
101
  )
102
+ elif dockerfile != "Dockerfile" and not os.path.exists(dockerfile):
103
+ # The user asks explicitly for using a specific Dockerfile, so fast fail if we cannot find it
104
+ raise click.ClickException(
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
+ )
110
+ )
74
111
  else:
75
112
  # The split on `prefix` is to ensure there is no slash (project names are org/project) confusing the path across supported OS.
76
113
  dest_root = (
@@ -82,7 +119,11 @@ def build(
82
119
  )
83
120
  if not dest_root.exists():
84
121
  click.echo(
85
- 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
+ )
86
127
  )
87
128
  # No condition on generating the Dockerfiles as:
88
129
  # - Fast
@@ -90,11 +131,20 @@ def build(
90
131
  scenarios = Converter().process(config, as_text=False)
91
132
  for idx, df in enumerate(scenarios.values()):
92
133
  job_name = df.job_name.strip().lower()
134
+ if only and job_name not in only:
135
+ continue
93
136
  dest = dest_root / Path(job_name)
94
137
  dest.mkdir(parents=True, exist_ok=True)
95
138
  _dockerfile = os.path.join(dest, "Dockerfile")
96
139
  df.dump(_dockerfile)
97
- 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
+ )
98
148
  dockerfiles.append(
99
149
  dict(
100
150
  path=root,
@@ -108,43 +158,78 @@ def build(
108
158
  # No condition on building the images, as relatively fast when already exists, and straightforward logic.
109
159
  image, _ = handler.build(**specs)
110
160
  else:
111
- click.echo("No Dockerfile, nothing to do.")
161
+ click.echo(localise("No Dockerfile, nothing to do."))
112
162
 
113
163
 
114
164
  @containers.command()
115
- @click.argument("name")
165
+ @click.argument("image_name", metavar=localise("IMAGE_NAME"))
116
166
  @click.pass_context
117
- def check(ctx: click.Context, name: str):
118
- if name is None:
119
- name = "artefacts"
167
+ def check(ctx: click.Context, image_name: str):
168
+ if image_name is None:
169
+ image_name = "artefacts"
120
170
  handler = ContainerMgr()
121
- result = handler.check(name)
171
+ result = handler.check(image_name)
122
172
  if ctx.parent is None:
123
173
  # Print only if the command is called directly.
124
- 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
+ )
125
179
  return result
126
180
 
127
181
 
128
- @containers.command()
129
- @click.argument("jobname")
182
+ @containers.command(
183
+ context_settings=dict(
184
+ ignore_unknown_options=True,
185
+ )
186
+ )
187
+ @click.argument("jobname", metavar=localise("JOBNAME"))
130
188
  @click.option(
131
189
  "--config",
132
190
  callback=config_validation,
133
191
  default="artefacts.yaml",
134
- 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
+ ),
135
195
  )
136
196
  @click.option(
137
197
  "--with-gui",
138
198
  "with_gui",
139
199
  default=False,
140
- 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
+ ),
141
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)
142
210
  @click.pass_context
143
- 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
+
144
227
  try:
145
228
  artefacts_config = read_config(config)
146
229
  except FileNotFoundError:
147
- 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
+ )
148
233
  project = artefacts_config["project"]
149
234
  handler = ContainerMgr()
150
235
  params = dict(
@@ -155,11 +240,18 @@ def run(ctx: click.Context, jobname: str, config: str, with_gui: bool):
155
240
  # Hidden settings primarily useful to Artefacts developers
156
241
  api_url=os.environ.get("ARTEFACTS_API_URL", DEFAULT_API_URL),
157
242
  api_key=os.environ.get("ARTEFACTS_KEY", None),
243
+ engine_args=list(engine_args),
158
244
  )
159
245
  container, logs = handler.run(**params)
160
246
  if container:
161
- 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
+ )
162
254
  else:
163
- print("Package run failed:")
255
+ print(localise("Package run failed:"))
164
256
  for entry in logs:
165
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