phobos-templates-utils 0.0.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.
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: phobos-templates-utils
3
+ Version: 0.0.1
4
+ Summary: Package with utilities for Phobos Templates scripts
5
+ Author-email: Matheus Castello <matheus@castello.eng.br>
6
+ Project-URL: Homepage, https://github.com/gaiaBuildSystem/vscode-phobos-templates
7
+ Project-URL: Issues, https://github.com/gaiaBuildSystem/vscode-phobos-templates/issues
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: POSIX :: Linux
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: torizon-io-api
14
+ Requires-Dist: requests
15
+ Requires-Dist: pyyaml
16
+ Requires-Dist: debugpy
17
+ Requires-Dist: pkginfo>=1.12
18
+
19
+ # Phobos Templates Utils
20
+
21
+ This package contains a set of utilities to help the scripts that generate the Torizon OS applications and containers.
22
+
23
+ If you want to know more about check [Toradex developer website](https://developer.toradex.com/torizon/application-development/ide-extension/).
@@ -0,0 +1,12 @@
1
+ torizon_templates_utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ torizon_templates_utils/animations.py,sha256=06THXo79OilHogufkSPTsAVORFzZKJwvRNKHaw6bOTc,1841
3
+ torizon_templates_utils/args.py,sha256=nN1b9Ts2lEYbpAGzaMWpAcmcYIrTUFJHehM_UqvnCWA,2142
4
+ torizon_templates_utils/colors.py,sha256=Y1FZ0HtKvXfLGYTDclsXubtZCQ-epuch5Dgk1DJdjps,1143
5
+ torizon_templates_utils/debug.py,sha256=OfdKSdN1kypuJnV_WcPa9bvonjyDvXvA3QTmc1iKyjw,556
6
+ torizon_templates_utils/errors.py,sha256=Wkk0AeCx0KQW-Qlu_5A6GYaGUkaJU79Mok8lZuZKHjM,1425
7
+ torizon_templates_utils/network.py,sha256=NpBeJlvpCY0Gvdc8ZzCKfmAk6zGYD_gLx-rTzZn9ZsU,1528
8
+ torizon_templates_utils/tasks.py,sha256=_Kzyiln9TjHJ8D1JNTzW_a-9thcDs7dFFtt7oG-jD_g,32815
9
+ phobos_templates_utils-0.0.1.dist-info/METADATA,sha256=TEpXYjRSAK5w-k7tCSyysS-dI8Ci0337b0910qPICB4,982
10
+ phobos_templates_utils-0.0.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
11
+ phobos_templates_utils-0.0.1.dist-info/top_level.txt,sha256=qlOkS1ASF5C-kSfpbiWbVHJFgmQ0RN6iunmLArNt6zc,24
12
+ phobos_templates_utils-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ torizon_templates_utils
File without changes
@@ -0,0 +1,67 @@
1
+ import sys
2
+ import time
3
+ import threading
4
+ from itertools import cycle
5
+
6
+ def run_command_with_wait_animation(call, *args):
7
+ anima_frames = ["🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚", "🕛"]
8
+
9
+ def animate():
10
+ for frame in cycle(anima_frames):
11
+ if not running[0]:
12
+ break
13
+ sys.stdout.write(f"\r{frame} :: RUNNING PLEASE WAIT :: {frame}")
14
+ sys.stdout.flush()
15
+ time.sleep(0.1)
16
+
17
+ # Clear the line
18
+ sys.stdout.write("\r\x1b[2K")
19
+
20
+ if running[1]:
21
+ sys.stdout.write("❌ :: TASK FAILED :: ❌\n")
22
+ else:
23
+ sys.stdout.write("✅ :: TASK COMPLETED :: ✅\n")
24
+
25
+ # clear anything the command printed below the animation line
26
+ # while it was pinned (e.g. the scan live device list)
27
+ sys.stdout.write("\x1b[J")
28
+ sys.stdout.flush()
29
+
30
+ def target():
31
+ nonlocal output
32
+ try:
33
+ output = call(*args)
34
+ except Exception as e:
35
+ output = e
36
+ running[1] = True
37
+ finally:
38
+ running[0] = False
39
+
40
+ # [0] is if it's running [1] if it has failed
41
+ running = [True, False]
42
+ output = None
43
+
44
+ animation_thread = threading.Thread(target=animate)
45
+ command_thread = threading.Thread(target=target)
46
+
47
+ animation_thread.start()
48
+ command_thread.start()
49
+
50
+ command_thread.join()
51
+ animation_thread.join()
52
+
53
+ if isinstance(output, Exception):
54
+ raise output
55
+
56
+ return output
57
+
58
+
59
+ # # Example usage
60
+ # def example_script(duration):
61
+ # time.sleep(duration)
62
+ # return "Task finished"
63
+
64
+ # if __name__ == "__main__":
65
+ # print("LET'S RUN A SCRIPT THAT TAKES 5 SECONDS TO FINISH")
66
+ # result = run_command_in_background_with_wait_animation(example_script, 5)
67
+ # print(result)
@@ -0,0 +1,83 @@
1
+
2
+ import sys
3
+ from typing import TypeVar, Type
4
+ from torizon_templates_utils.errors import Error, Error_Out
5
+
6
+ T = TypeVar('T')
7
+
8
+ def get_arg_not_empty(index: int) -> str:
9
+ """
10
+ Get an argument from the command line.
11
+ If the argument is an empty string, an error is raised.
12
+ """
13
+ _arg = sys.argv[index]
14
+
15
+ if _arg == "":
16
+ Error_Out(
17
+ "Error: Argument cannot be empty",
18
+ Error.EUSER
19
+ )
20
+
21
+ return _arg
22
+
23
+
24
+ def get_optional_arg(index: int, default: T) -> T | bool | str:
25
+ """
26
+ Get an optional argument from the command line.
27
+ If the argument is not provided, the default value is returned.
28
+ """
29
+ if len(sys.argv) > index:
30
+ # sys.argv return string
31
+ # we need to return T
32
+ # FIXME: this only convert string to bool for now
33
+ if type(default) is bool:
34
+ if sys.argv[index] == "True" or sys.argv[index] == "true" or sys.argv[index] == "1":
35
+ return True
36
+ else:
37
+ return False
38
+
39
+ return sys.argv[index]
40
+
41
+ return default
42
+
43
+
44
+ def get_arg_iterative(
45
+ index: int, prompt: str, default_type: Type, default: T | None = None, iterative: bool = False
46
+ ) -> T | None | bool | str:
47
+ """
48
+ Get an argument from the command line.
49
+ If the argument is not provided, an error is raised.
50
+ """
51
+ if len(sys.argv) > index:
52
+ if default_type is bool:
53
+ if sys.argv[index] == "True":
54
+ return True
55
+ else:
56
+ return False
57
+
58
+ return sys.argv[index]
59
+ elif default != None:
60
+ return default
61
+ elif iterative:
62
+ _input = input(prompt)
63
+ if _input == "":
64
+ Error_Out(
65
+ "Error: Argument cannot be empty",
66
+ Error.EUSER
67
+ )
68
+ else:
69
+ if default_type is bool:
70
+ if _input == "True":
71
+ return True
72
+ else:
73
+ return False
74
+
75
+ return _input
76
+
77
+ else:
78
+ Error_Out(
79
+ f"Error: Argument for prompt [{prompt}] not provided",
80
+ Error.EUSER
81
+ )
82
+
83
+ return default
@@ -0,0 +1,56 @@
1
+
2
+ import builtins
3
+ import enum
4
+
5
+ class Color(enum.IntEnum):
6
+ NONE = 0
7
+ BLACK = 30
8
+ RED = 31
9
+ GREEN = 32
10
+ YELLOW = 33
11
+ BLUE = 34
12
+ MAGENTA = 35
13
+ CYAN = 36
14
+ WHITE = 37
15
+
16
+ class BgColor(enum.IntEnum):
17
+ NONE = 0
18
+ BLACK = 40
19
+ RED = 41
20
+ BRIGTH_RED = 101
21
+ GREEN = 42
22
+ BRIGTH_GREEN = 102
23
+ YELLOW = 43
24
+ BRIGTH_YELLOW = 103
25
+ BLUE = 44
26
+ BRIGTH_BLUE = 104
27
+ MAGENTA = 45
28
+ BRIGTH_MAGENTA = 105
29
+ CYAN = 46
30
+ BRIGTH_CYAN = 106
31
+ WHITE = 47
32
+ BRIGTH_WHITE = 107
33
+
34
+ # override print to have color and background color
35
+ def print(
36
+ *args,
37
+ color: Color = Color.NONE,
38
+ bg_color: BgColor = BgColor.NONE,
39
+ **kwargs
40
+ ) -> None:
41
+ _color: int = color.value
42
+ _bg_color: int = bg_color.value
43
+
44
+ start_escape = ""
45
+
46
+ if _color != 0 and _bg_color != 0:
47
+ start_escape += f"\033[{_color};{_bg_color}m"
48
+ elif _color != 0:
49
+ start_escape += f"\033[{_color}m"
50
+ elif _bg_color != 0:
51
+ start_escape += f"\033[{_bg_color}m"
52
+
53
+ end_escape = "\033[0m"
54
+
55
+ text = ' '.join(map(str, args))
56
+ builtins.print(f'{start_escape}{text}{end_escape}', **kwargs)
@@ -0,0 +1,22 @@
1
+ # pylint: disable=missing-function-docstring
2
+ # pylint: disable=missing-module-docstring
3
+ import debugpy # type: ignore[import-untyped]
4
+
5
+ DEBUG_INITIALIZED = False
6
+
7
+ def vscode_prepare(port: int = 5679) -> None:
8
+ global DEBUG_INITIALIZED
9
+
10
+ if DEBUG_INITIALIZED:
11
+ return
12
+
13
+ print("__debugpy__")
14
+ debugpy.listen(("0.0.0.0", port))
15
+ print("__debugpy__ go")
16
+ debugpy.wait_for_client()
17
+ print(f"__debugpy__ is connected [{debugpy.is_client_connected()}]")
18
+
19
+ DEBUG_INITIALIZED = True
20
+
21
+ def breakpoint() -> None:
22
+ debugpy.breakpoint()
@@ -0,0 +1,58 @@
1
+
2
+ import sys
3
+ from enum import Enum
4
+ from torizon_templates_utils.colors import Color, BgColor, print
5
+
6
+ class _error_struct:
7
+ code: int
8
+ message: str
9
+
10
+ def __init__(self, code: int, message: str):
11
+ self.code = code
12
+ self.message = message
13
+
14
+ # Standard Unix errors. There may be other standard error codes also:
15
+ # https://github.com/kaushalparikh/nuttx/blob/master/include/errno.h
16
+ class Error(Enum):
17
+ ENOCONF = _error_struct(
18
+ 1, "Not configured"
19
+ )
20
+ EINVAL = _error_struct(
21
+ 22, "Invalid argument"
22
+ )
23
+ ENOPKG = _error_struct(
24
+ 65, "Package not installed"
25
+ )
26
+ EUSER = _error_struct(
27
+ 69, "User fault"
28
+ )
29
+ EABORT = _error_struct(
30
+ 170, "Abort"
31
+ )
32
+ ETASKEXEC = _error_struct(
33
+ 310, "Task execution error"
34
+ )
35
+ ENOFOUND = _error_struct(
36
+ 404, "Not found"
37
+ )
38
+ EFAIL = _error_struct(
39
+ 500, "Failed"
40
+ )
41
+ EUNKNOWN = _error_struct(
42
+ 666, "Unknown error"
43
+ )
44
+ ETOMCRUISE = _error_struct(
45
+ 999, "Impossible condition"
46
+ )
47
+
48
+
49
+ def Error_Out(msg: str, error: Error) -> None:
50
+ print(f"\n{msg}", color=Color.RED)
51
+ print(f"Error cause: {error.value.message}\n", color=Color.RED)
52
+ sys.exit(error.value.code)
53
+
54
+
55
+ def last_return_code() -> int:
56
+ # we are ignoring the type here because this will get the current
57
+ # xonsh shell instance
58
+ return __xonsh__.last.returncode # type: ignore
@@ -0,0 +1,35 @@
1
+
2
+ import os
3
+ import re
4
+ import subprocess
5
+
6
+
7
+ def get_host_ip():
8
+ if 'WSL_DISTRO_NAME' in os.environ and os.environ['WSL_DISTRO_NAME'] != '':
9
+ command = ["/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", "-c", '(Get-NetIPAddress -AddressFamily IPv4 -InterfaceAlias (Get-NetRoute -DestinationPrefix "0.0.0.0/0").InterfaceAlias).IPAddress']
10
+
11
+ # if inside a container we need to run the command in the host
12
+ if "APOLLOX_CONTAINER" in os.environ:
13
+ command = [
14
+ "sudo", "nsenter", "-t", "1", "-m", "-u", "-n", "-i",
15
+ "--",
16
+ "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", "-c", '(Get-NetIPAddress -AddressFamily IPv4 -InterfaceAlias (Get-NetRoute -DestinationPrefix "0.0.0.0/0").InterfaceAlias).IPAddress'
17
+ ]
18
+
19
+ result = subprocess.run(command, capture_output=True, text=True)
20
+ ip_address = re.search(r'((1?[0-9][0-9]?|2[0-4][0-9]|25[0-5])\.){3}(1?[0-9][0-9]?|2[0-4][0-9]|25[0-5])', result.stdout)
21
+ if ip_address:
22
+ return ip_address.group(0)
23
+ else:
24
+ command = ["hostname", "-I"]
25
+
26
+ # if inside a container we need to run the command in the host
27
+ if "APOLLOX_CONTAINER" in os.environ:
28
+ command = [
29
+ "sudo", "nsenter", "-t", "1", "-m", "-u", "-n", "-i",
30
+ "--",
31
+ "hostname", "-I"
32
+ ]
33
+
34
+ result = subprocess.run(command, capture_output=True, text=True)
35
+ return result.stdout.split()[0]
@@ -0,0 +1,941 @@
1
+
2
+ import os
3
+ import re
4
+ import yaml # type: ignore[import-untyped]
5
+ import json
6
+ import inspect
7
+ import mimetypes
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import List, Dict, Type, TypeVar, Union, Tuple, Optional, Literal
11
+ from torizon_templates_utils.colors import print, Color
12
+
13
+ T = TypeVar('T')
14
+
15
+ def replace_tasks_input():
16
+ for file in Path('.').rglob('*.json'):
17
+ print(file, flush=True)
18
+ mime_type, _ = mimetypes.guess_type(file)
19
+
20
+ if mime_type is None or mime_type.startswith("application/octet-stream"):
21
+ if "id_rsa" not in str(file):
22
+ with open(file, 'r') as f:
23
+ content = f.read()
24
+
25
+ content = content.replace("input:dockerLogin", "command:docker_login")
26
+ content = content.replace("input:dockerImageRegistry", "command:docker_registry")
27
+ content = content.replace("input:dockerPsswd", "command:docker_password")
28
+
29
+ with open(file, 'w') as f:
30
+ f.write(content)
31
+
32
+
33
+ def _cast_from_json(json_data, cls: Type[T]) -> T:
34
+ # check on json_data if there is some key with . like "name.prop"
35
+ # if so, we need change the key to something like "name_prop"
36
+ keys = list(json_data.keys())
37
+ for key in keys:
38
+ if '.' in key:
39
+ new_key = key.replace('.', '_')
40
+ json_data[new_key] = json_data.pop(key)
41
+
42
+ expected_args = inspect.signature(cls.__init__).parameters
43
+ filtered_data = {k: v for k, v in json_data.items() if k in expected_args}
44
+
45
+ # check if the cls type has the any attribute
46
+ # the any attribute is a Dict[str, str]
47
+ # and it store the non expected args
48
+ if 'any' in expected_args:
49
+ non_expected_args = {k: v for k, v in json_data.items() if k not in expected_args}
50
+ filtered_data['any'] = non_expected_args
51
+
52
+ return cls(**filtered_data)
53
+
54
+
55
+ # For Settings interface we are mapping only the Torizon specific settings
56
+ class TorizonSettings:
57
+ """
58
+ TorizonSettings is a interface to map specific VS Code settings defined
59
+ by the Torizon extension.
60
+ """
61
+ def __init__(
62
+ self,
63
+ torizon_psswd: Optional[str] = None,
64
+ torizon_login: Optional[str] = None,
65
+ torizon_ip: Optional[str] = None,
66
+ torizon_ssh_port: Optional[str] = None,
67
+ host_ip: Optional[str] = None,
68
+ torizon_workspace: Optional[str] = None,
69
+ torizon_debug_ssh_port: Optional[str] = None,
70
+ torizon_debug_port1: Optional[str] = None,
71
+ torizon_debug_port2: Optional[str] = None,
72
+ torizon_debug_port3: Optional[str] = None,
73
+ torizon_gpu: Optional[str] = None,
74
+ torizon_arch: Optional[str] = None,
75
+ wait_sync: Optional[str] = None,
76
+ torizon_run_as: Optional[str] = None,
77
+ torizon_app_root: Optional[str] = None,
78
+ docker_tag: Optional[str] = None,
79
+ tcb_packageName: Optional[str] = None,
80
+ tcb_version: Optional[str] = None,
81
+ torizon_gpuPrefixRC: Optional[str] = None,
82
+ any: Optional[Dict[str, str]] = None
83
+ ):
84
+
85
+ self.torizon_psswd = torizon_psswd
86
+ self.torizon_login = torizon_login
87
+ self.torizon_ip = torizon_ip
88
+ self.torizon_ssh_port = torizon_ssh_port
89
+ self.host_ip = host_ip
90
+ self.torizon_workspace = torizon_workspace
91
+ self.torizon_debug_ssh_port = torizon_debug_ssh_port
92
+ self.torizon_debug_port1 = torizon_debug_port1
93
+ self.torizon_debug_port2 = torizon_debug_port2
94
+ self.torizon_debug_port3 = torizon_debug_port3
95
+ self.torizon_gpu = torizon_gpu
96
+ self.torizon_arch = torizon_arch
97
+ self.wait_sync = wait_sync
98
+ self.torizon_run_as = torizon_run_as
99
+ self.torizon_app_root = torizon_app_root
100
+ self.docker_tag = docker_tag
101
+ self.tcb_packageName = tcb_packageName
102
+ self.tcb_version = tcb_version
103
+ self.torizon_gpuPrefixRC = torizon_gpuPrefixRC
104
+ self.any = any
105
+
106
+
107
+ # These are from:
108
+ # https://code.visualstudio.com/docs/editor/tasks-appendix
109
+
110
+ class ShellConfiguration:
111
+ def __init__(self, executable: str, args: Optional[List[str]]):
112
+ self.executable = executable
113
+ self.args = args
114
+
115
+ class CommandOptions:
116
+ def __init__(
117
+ self,
118
+ cwd: Optional[str] = None,
119
+ env: Optional[Dict[str, str]] = None,
120
+ shell: Optional[ShellConfiguration] = None
121
+ ):
122
+
123
+ self.cwd = cwd
124
+ self.env = env
125
+ self.shell = shell
126
+
127
+ # we are getting this data from json
128
+ # so we need to cast the classes dependencies
129
+ if shell:
130
+ self.shell = _cast_from_json(shell, ShellConfiguration)
131
+
132
+
133
+ class PresentationOptions:
134
+ def __init__(
135
+ self,
136
+ reveal: Optional[Literal['never', 'silent', 'always']] = None,
137
+ echo: Optional[bool] = None,
138
+ focus: Optional[bool] = None,
139
+ panel: Optional[Literal['shared', 'dedicated', 'new']] = None,
140
+ showReuseMessage: Optional[bool] = None,
141
+ clear: Optional[bool] = None,
142
+ group: Optional[str] = None
143
+ ):
144
+
145
+ self.reveal = reveal
146
+ self.echo = echo
147
+ self.focus = focus
148
+ self.panel = panel
149
+ self.showReuseMessage = showReuseMessage
150
+ self.clear = clear
151
+ self.group = group
152
+
153
+
154
+ class ProblemPattern:
155
+ def __init__(
156
+ self,
157
+ regexp: str,
158
+ kind: Optional[Literal['file', 'location']] = None,
159
+ file: Union[int, float] = 0,
160
+ location: Optional[Union[int, float]] = None,
161
+ line: Optional[Union[int, float]] =None,
162
+ column: Optional[Union[int, float]] = None,
163
+ endLine: Optional[Union[int, float]] = None,
164
+ endColumn: Optional[Union[int, float]] = None,
165
+ severity: Optional[Union[int, float]] = None,
166
+ code: Optional[Union[int, float]] = None,
167
+ message: Union[int, float] = 0,
168
+ loop: Optional[bool] = False
169
+ ):
170
+
171
+ self.regexp = regexp
172
+ self.kind = kind
173
+ self.file = file
174
+ self.location = location
175
+ self.line = line
176
+ self.column = column
177
+ self.endLine = endLine
178
+ self.endColumn = endColumn
179
+ self.severity = severity
180
+ self.code = code
181
+ self.message = message
182
+ self.loop = loop
183
+
184
+
185
+ class BackgroundMatcher:
186
+ def __init__(
187
+ self,
188
+ activeOnStart: Optional[bool] = False,
189
+ beginsPattern: Optional[str] = None,
190
+ endsPattern: Optional[str] = None
191
+ ):
192
+
193
+ self.activeOnStart = activeOnStart
194
+ self.beginsPattern = beginsPattern
195
+ self.endsPattern = endsPattern
196
+
197
+
198
+ class ProblemMatcher:
199
+ def __init__(
200
+ self,
201
+ base: Optional[str] = None,
202
+ owner: Optional[str] = 'external',
203
+ source: Optional[str] = None,
204
+ severity: Optional[Literal['error', 'warning', 'info']] = 'error',
205
+ fileLocation: Optional[str | List[str] | List[
206
+ Union[
207
+ Literal['search'],
208
+ Dict[str, Optional[List[str]]]
209
+ ]
210
+ ]] = None,
211
+ pattern: Optional[str | ProblemPattern | List[ProblemPattern]] = None,
212
+ background: Optional[BackgroundMatcher] = None
213
+ ):
214
+
215
+ self.base = base
216
+ self.owner = owner
217
+ self.source = source
218
+ self.severity = severity
219
+ self.fileLocation = fileLocation
220
+ self.pattern = pattern
221
+ self.background = background
222
+
223
+ # we are getting this data from json
224
+ # so we need to cast the classes dependencies
225
+ if pattern:
226
+ self.pattern = _cast_from_json(pattern, ProblemPattern)
227
+
228
+ if background:
229
+ self.background = _cast_from_json(background, BackgroundMatcher)
230
+
231
+
232
+ class RunOptions:
233
+ def __init__(
234
+ self,
235
+ reevaluateOnRerun: Optional[bool] = True,
236
+ runOn: Optional[Literal['default', 'folderOpen']] = 'default'
237
+ ):
238
+
239
+ self.reevaluateOnRerun = reevaluateOnRerun
240
+ self.runOn = runOn
241
+
242
+
243
+ class IconOptions:
244
+ def __init__(
245
+ self,
246
+ id: str,
247
+ color: Optional[str] = None
248
+ ):
249
+
250
+ self.id = id
251
+ self.color = color
252
+
253
+
254
+ class InputOptions:
255
+ def __init__(
256
+ self,
257
+ id: str,
258
+ description: str,
259
+ default: Optional[str] = None,
260
+ type: Optional[Literal['promptString', 'pickString']] = 'promptString',
261
+ options: Optional[List[str]] = None
262
+ ):
263
+
264
+ self.id = id
265
+ self.description = description
266
+ self.default = default
267
+ self.type = type
268
+ self.options = options
269
+
270
+
271
+ class TaskDescription:
272
+ def __init__(
273
+ self,
274
+ label: str,
275
+ type: Literal['shell', 'process'],
276
+ command: str,
277
+ hide: Optional[bool] = None,
278
+ isBackground: Optional[bool] = None,
279
+ args: Optional[List[str]] = None,
280
+ options: Optional[CommandOptions] = None,
281
+ group: Optional[Literal['build', 'test']] = None,
282
+ presentation: Optional[PresentationOptions] = None,
283
+ problemMatcher: Optional[str | ProblemMatcher | List[str] | List[ProblemMatcher]] = None,
284
+ runOptions: Optional[RunOptions] = None,
285
+ dependsOrder: Optional[Literal['sequence', 'parallel']] = None,
286
+ dependsOn: Optional[List[str]] = None,
287
+ icon: Optional[IconOptions] = None
288
+ ):
289
+
290
+ self.label = label
291
+ self.type = type
292
+ self.command = command
293
+ self.hide: bool = (hide if hide is not None else False)
294
+ self.isBackground = isBackground
295
+ self.options = options
296
+ self.args = args
297
+ self.group = group
298
+ self.presentation = presentation
299
+ self.problemMatcher = problemMatcher
300
+ self.runOptions = runOptions
301
+ self.dependsOrder = dependsOrder
302
+ self.dependsOn = dependsOn
303
+ self.icon = icon
304
+
305
+ # we are getting this data from json
306
+ # so we need to cast the classes dependencies
307
+ if options:
308
+ self.options = _cast_from_json(options, CommandOptions)
309
+
310
+ if presentation:
311
+ self.presentation = _cast_from_json(presentation, PresentationOptions)
312
+
313
+ if runOptions:
314
+ self.runOptions = _cast_from_json(runOptions, RunOptions)
315
+
316
+ if icon:
317
+ self.icon = _cast_from_json(icon, IconOptions)
318
+
319
+ def to_dict(self):
320
+ return {
321
+ 'label': self.label,
322
+ 'type': self.type,
323
+ 'command': self.command,
324
+ 'isBackground': self.isBackground,
325
+ 'args': self.args,
326
+ 'options': self.options.__dict__ if self.options else None,
327
+ 'group': self.group,
328
+ 'presentation': self.presentation.__dict__ if self.presentation else None,
329
+ 'problemMatcher': self.problemMatcher,
330
+ 'runOptions': self.runOptions.__dict__ if self.runOptions else None,
331
+ 'dependsOrder': self.dependsOrder,
332
+ 'dependsOn': self.dependsOn,
333
+ 'icon': self.icon.__dict__ if self.icon else None
334
+ }
335
+
336
+
337
+ class BaseTaskConfiguration:
338
+ def __init__(
339
+ self,
340
+ type: str,
341
+ command: str,
342
+ isBackground: Optional[bool] = None,
343
+ options: Optional[CommandOptions] = None,
344
+ args: Optional[str] = None,
345
+ presentation: Optional[PresentationOptions] = None,
346
+ problemMatcher: Optional[str | ProblemMatcher | List[str] | List[ProblemMatcher]] = None,
347
+ tasks: Optional[List[TaskDescription]] = None
348
+ ):
349
+
350
+ self.type = type
351
+ self.command = command
352
+ self.isBackground = isBackground
353
+ self.options = options
354
+ self.args = args
355
+ self.presentation = presentation
356
+ self.problemMatcher = problemMatcher
357
+ self.tasks = tasks
358
+
359
+
360
+ class TaskConfiguration:
361
+ """
362
+ TorizonConfiguration is a interface to map tasks.json file
363
+ """
364
+
365
+ def __init__(
366
+ self,
367
+ version: Literal['2.0.0'] = '2.0.0',
368
+ tasks: Optional[List[TaskDescription]] = None,
369
+ inputs: Optional[List[InputOptions]] = None,
370
+ windows: Optional[BaseTaskConfiguration] = None,
371
+ osx: Optional[BaseTaskConfiguration] = None,
372
+ linux: Optional[BaseTaskConfiguration] = None
373
+ ):
374
+
375
+ self.version = version
376
+ self.tasks = tasks
377
+ self.inputs = inputs
378
+ self.windows = windows
379
+ self.osx = osx
380
+ self.linux = linux
381
+
382
+ # as this could be from json dict, we need to cast the tasks
383
+ if tasks:
384
+ self.tasks = [_cast_from_json(task, TaskDescription) for task in tasks]
385
+
386
+ if inputs:
387
+ self.inputs = [_cast_from_json(_input, InputOptions) for _input in inputs]
388
+
389
+ # TODO:
390
+ # for now we are not casting the other configurations
391
+ # as them are not used in the templates
392
+
393
+
394
+ def get_tasks_json(file_path: str) -> TaskConfiguration:
395
+ with open(f"{file_path}/.vscode/tasks.json", 'r') as file:
396
+ return _cast_from_json(json.load(file), TaskConfiguration)
397
+
398
+
399
+ def get_settings_json(
400
+ file_path: str,
401
+ custom_file: str | None = None
402
+ ) -> TorizonSettings:
403
+ _file = custom_file if custom_file else "settings.json"
404
+
405
+ with open(f"{file_path}/.vscode/{_file}", 'r') as file:
406
+ _tor_settings = _cast_from_json(json.load(file), TorizonSettings)
407
+
408
+
409
+
410
+ return _tor_settings
411
+
412
+
413
+ class TaskRunner:
414
+ """
415
+ TaskRunner is a class to run tasks from tasks.json file
416
+ """
417
+
418
+ def __init__(
419
+ self,
420
+ tasks: List[TaskDescription],
421
+ inputs: List[InputOptions],
422
+ settings: TorizonSettings,
423
+ debug: bool = False
424
+ ):
425
+
426
+ self.__tasks = tasks
427
+ self.__inputs = inputs
428
+ self.__settings = settings
429
+ self.__debug = debug
430
+ self.__gitlab_ci = False
431
+ self.__tasks_override_env = False
432
+ self.__cli_inputs: Dict[str, str] = {}
433
+ self.__can_receive_interactive_input = False
434
+
435
+ # check if we have stdin
436
+ if os.isatty(0) and (("TASKS_DISABLE_INTERACTIVE_INPUT" not in os.environ) or (os.environ["TASKS_DISABLE_INTERACTIVE_INPUT"] != "True")):
437
+ self.__can_receive_interactive_input = True
438
+
439
+ # environment configs
440
+ if "DOCKER_PSSWD" in os.environ:
441
+ os.environ["config:docker_password"] = os.environ["DOCKER_PSSWD"]
442
+
443
+ if "GITLAB_CI" in os.environ:
444
+ self.__gitlab_ci = True
445
+
446
+ if "TASKS_OVERRIDE_ENV" in os.environ and os.environ["TASKS_OVERRIDE_ENV"] == "True":
447
+ self.__tasks_override_env = True
448
+
449
+ if "TASKS_DEBUG" in os.environ and os.environ["TASKS_DEBUG"] == "True":
450
+ self.__debug = True
451
+
452
+ self.__settings_to_env()
453
+
454
+
455
+ def __settings_to_env(self):
456
+ # for keys in settings, we are adding to env
457
+ for key, value in self.__settings.__dict__.items():
458
+ if value is not None:
459
+ os.environ[f"config:{key}"] = f"{value}"
460
+
461
+ # also for non Torizon ones
462
+ for key, value in self.__settings.any.items():
463
+ if isinstance(value, str) or \
464
+ isinstance(value, int) or \
465
+ isinstance(value, float):
466
+
467
+ os.environ[f"config:{key}"] = str(value)
468
+
469
+
470
+ def list_labels(self, show_hidden=False, no_index: bool = False):
471
+ i = 0
472
+
473
+ for task in self.__tasks:
474
+ if no_index:
475
+ if show_hidden or not task.hide:
476
+ print(task.label, flush=True)
477
+ else:
478
+ if show_hidden or not task.hide:
479
+ print(f"{i}. \t{task.label}", flush=True)
480
+
481
+ i += 1
482
+
483
+
484
+ def desc_input(self, id: str):
485
+ for _input in self.__inputs:
486
+ if _input.id == id:
487
+ print(json.dumps(_input.__dict__, indent=4))
488
+ return
489
+
490
+ raise ReferenceError(f"Input with id [{id}] not found")
491
+
492
+
493
+ def desc_task(self, label: int | str):
494
+ task = None
495
+
496
+ if isinstance(label, int):
497
+ task = self.__tasks[label]
498
+ else:
499
+ for _task in self.__tasks:
500
+ if _task.label == label:
501
+ task = _task
502
+ break
503
+
504
+ if task is not None:
505
+ task_txt = json.dumps(task.to_dict(), indent=4)
506
+ print(task_txt, flush=True)
507
+ else:
508
+ raise ReferenceError(f"Task with index [{label}] not found")
509
+
510
+
511
+ def __replace_env_var(self, var: str, env: str):
512
+ if f"${{{var}}}" in env:
513
+ return env.replace(f"${{{var}}}", os.environ[var])
514
+
515
+
516
+ def __check_workspace_folder(self, env: List[str]) -> List[str]:
517
+ ret: List[str] = []
518
+
519
+ for value in env:
520
+ if "workspaceFolderBasename" in value:
521
+ value = self.__replace_env_var("workspaceFolderBasename", value)
522
+ if "workspaceFolder" in value:
523
+ value = self.__replace_env_var("workspaceFolder", value)
524
+ ret.append(value)
525
+
526
+ return ret
527
+
528
+
529
+ def __check_torizon_inputs(self, env: List[str]) -> List[str]:
530
+ ret: List[str] = []
531
+
532
+ for value in env:
533
+ if "${command:torizon_" in value:
534
+ value = value.replace("${command:torizon_", "${config:torizon_")
535
+ ret.append(value)
536
+
537
+ return ret
538
+
539
+
540
+ def __check_docker_inputs(self, env: List[str]) -> List[str]:
541
+ ret: List[str] = []
542
+
543
+ for value in env:
544
+ if "${command:docker_" in value:
545
+ value = value.replace("${command:docker_", "${config:docker_")
546
+ ret.append(value)
547
+
548
+ return ret
549
+
550
+
551
+ def __check_tcb_inputs(self, env: List[str]) -> List[str]:
552
+ ret: List[str] = []
553
+
554
+ for value in env:
555
+ if "${command:tcb" in value:
556
+ if "tcb.getNextPackageVersion" in value:
557
+ # call the xonsh script
558
+ _p_ret = subprocess.run(
559
+ [
560
+ "xonsh",
561
+ "./.conf/torizon-io.xsh",
562
+ "package", "latest", "version",
563
+ os.environ["config:tcb_packageName"]
564
+ ],
565
+ capture_output=True,
566
+ text=True,
567
+ env=os.environ
568
+ )
569
+
570
+ if _p_ret.returncode != 0:
571
+ # Sometimes the error is presented on stdout and not stderr
572
+ raise RuntimeError(f"Error running torizon-io.xsh: {_p_ret}")
573
+
574
+ # TODO: Maybe not use this and instead disable colors on the terminal
575
+ # Remove ANSI escape sequences. Regex from this thread:
576
+ # https://stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python
577
+ ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
578
+ _latest_ver = ansi_escape.sub('', _p_ret.stdout.strip())
579
+
580
+ _latest_ver_number = _latest_ver.rsplit('-', 1)[-1]
581
+ _latest_ver_last_number = _latest_ver_number.rsplit('.', 1)[-1]
582
+ try:
583
+ _next = int(_latest_ver_last_number) +1
584
+ except:
585
+ raise ValueError(f"Package version should be in one of the following formats: <int>, <string-int>, <major.minor.patch>, or <string-major.minor.patch>. Depending on format, int or patch value will be incremented")
586
+
587
+ if self.__debug:
588
+ print(f"Next package version: {_next}", flush=True)
589
+
590
+ value = value.replace(f"${{command:tcb.getNextPackageVersion}}", f"{_next}")
591
+
592
+ elif "tcb.outputTEZIFolder" in value:
593
+ # load the tcbuild.yaml
594
+ with open("tcbuild.yaml", 'r') as file:
595
+ _tcbuild = yaml.load(file, Loader=yaml.FullLoader)
596
+
597
+ _tezi_folder = None
598
+ try:
599
+ _tezi_folder = _tcbuild["output"]["easy-installer"]["local"]
600
+ except KeyError:
601
+ raise RuntimeError("Error replacing variable tcb.outputTEZIFolder, make sure the tcbuild.yaml has the output.easy-installer.local property")
602
+
603
+ value = value.replace(f"${{command:tcb.outputTEZIFolder}}", _tezi_folder)
604
+
605
+ # for all the items we need to replace ${command:tcb. with ${config:tcb.
606
+ _pattern = r"(?<=\$\{command:tcb\.).*?(?=\s*})"
607
+ _matches = re.findall(_pattern, value)
608
+
609
+ for match in _matches:
610
+ value = value.replace(f"${{command:tcb.{match}}}", f"${{config:tcb.{match}}}")
611
+
612
+ ret.append(value)
613
+
614
+ return ret
615
+
616
+
617
+ def __contains_special_chars(self, str: str) -> bool:
618
+ _pattern = r"[^a-zA-Z0-9\.\-_|>\/=+&_]"
619
+ return re.search(_pattern, str) is not None
620
+
621
+
622
+ def __scape_args(self, args: List[str]) -> List[str]:
623
+ ret: List[str] = []
624
+
625
+ for arg in args:
626
+ if "\"" in arg:
627
+ arg = arg.replace("\"", "\\\"")
628
+
629
+ ret.append(arg)
630
+
631
+ return ret
632
+
633
+
634
+ def __check_config(self, args: List[str]) -> List[str]:
635
+ """
636
+ This method will make the config replacement in the args
637
+ """
638
+ ret: List[str] = []
639
+
640
+ for arg in args:
641
+ if "${config:" in arg:
642
+ _pattern = r"(?<=\$\{config:).*?(?=\s*})"
643
+ _matches = re.findall(_pattern, arg)
644
+
645
+ for match in _matches:
646
+ if "." in match:
647
+ _match = match.replace(".", "_")
648
+ else:
649
+ _match = match
650
+
651
+ # first check if the config exists
652
+ if f"config:{_match}" not in os.environ:
653
+ raise ReferenceError(f"Config with id [{match}] not found. Check your settings.json")
654
+
655
+ # edge case for docker_registry
656
+ if _match == "docker_registry" and os.environ[f"config:{_match}"] == "":
657
+ os.environ[f"config:{_match}"] = "registry-1.docker.io"
658
+
659
+ arg = arg.replace(f"${{config:{match}}}", os.environ[f"config:{_match}"])
660
+
661
+ ret.append(arg)
662
+
663
+ return ret
664
+
665
+
666
+ def __check_vscode_env(self, args: List[str]) -> List[str]:
667
+ """
668
+ handle the VS Code ${env:VAR} replacement
669
+ """
670
+ ret: List[str] = []
671
+
672
+ for arg in args:
673
+ if "${env:" in arg:
674
+ _pattern = r"(?<=\$\{env:).*?(?=\s*})"
675
+ _matches = re.findall(_pattern, arg)
676
+
677
+ for match in _matches:
678
+ if match not in os.environ:
679
+ raise ReferenceError(f"Environment variable with id [{match}] not found")
680
+
681
+ arg = arg.replace(f"${{env:{match}}}", os.environ[match])
682
+
683
+ ret.append(arg)
684
+
685
+ return ret
686
+
687
+
688
+ def __check_long_args(self, args: List[str]) -> List[str]:
689
+ ret: List[str] = []
690
+
691
+ for arg in args:
692
+ if " " in arg:
693
+ arg = f"'{arg}'"
694
+
695
+ ret.append(arg)
696
+
697
+ return ret
698
+
699
+
700
+ def __quoting_special_chars(self, args: List[str]) -> List[str]:
701
+ ret: List[str] = []
702
+
703
+ for arg in args:
704
+ _has_special_chars = self.__contains_special_chars(arg)
705
+ _hash_space = " " in arg
706
+
707
+ if _has_special_chars and not _hash_space:
708
+ arg = f"'{arg}'"
709
+
710
+ ret.append(arg)
711
+
712
+ return ret
713
+
714
+
715
+ def __check_input(self, args: List[str]) -> List[str]:
716
+ ret: List[str] = []
717
+
718
+ for arg in args:
719
+ if "${input:" in arg:
720
+ _pattern = r"(?<=\$\{input:).*?(?=\s*})"
721
+ _matches = re.findall(_pattern, arg)
722
+
723
+ for match in _matches:
724
+ _input = None
725
+ _input_value = "None"
726
+
727
+ for inp in self.__inputs:
728
+ if inp.id == match:
729
+ _input = inp
730
+ break
731
+
732
+ if _input is None:
733
+ raise ReferenceError(f"Input with id [{match}] not found")
734
+
735
+ # first check if the input was set by cli
736
+ if match in self.__cli_inputs:
737
+ _input_value = self.__cli_inputs[match]
738
+ elif _input.default:
739
+ _input_value = _input.default
740
+ else:
741
+ if not self.__can_receive_interactive_input:
742
+ raise RuntimeError("CLI inputs not set and interactive input is disabled")
743
+
744
+ if _input.type == "promptString":
745
+ _input_value = input(f"{_input.description}: ")
746
+ elif _input.type == "pickString":
747
+ for _inp in self.__inputs:
748
+ if _inp.id == match:
749
+ # print options
750
+ assert _inp.options is not None, "pickString option has a valid id but options is empty. Check your tasks.json"
751
+ print(f"Options for [{match}]:")
752
+ _i = 0
753
+ _indexed_options = {}
754
+
755
+ for _opt in _inp.options:
756
+ _indexed_options[str(_i)] = _opt
757
+ print(f"{_i}. {_opt}")
758
+ _i += 1
759
+
760
+ _input_value = input(f"{_input.description} (option index): ")
761
+
762
+ # check if the input is in the options
763
+ if _input_value not in _indexed_options:
764
+ raise ValueError(f"Input value for [{match}] is not in the possible options")
765
+ else:
766
+ _input_value = _indexed_options[_input_value]
767
+
768
+ if _input_value is None:
769
+ raise ValueError(f"Input value for [{match}] could not be None")
770
+
771
+ arg = arg.replace(f"${{input:{match}}}", _input_value)
772
+
773
+ ret.append(arg)
774
+
775
+ return ret
776
+
777
+
778
+ def __parse_envs(self, env: str, task: TaskDescription) -> str | None :
779
+ """
780
+ It's christmas time 🎅
781
+ """
782
+ if task.options:
783
+ value = task.options.env
784
+
785
+ # get the env from the task
786
+ if value:
787
+ _env_value = value.get(env)
788
+
789
+ if _env_value:
790
+ expvalue = [_env_value]
791
+ expvalue = self.__check_workspace_folder(expvalue)
792
+ expvalue = self.__check_torizon_inputs(expvalue)
793
+ expvalue = self.__check_docker_inputs(expvalue)
794
+ expvalue = self.__check_tcb_inputs(expvalue)
795
+ expvalue = self.__check_input(expvalue)
796
+ expvalue = self.__check_config(expvalue)
797
+ exp_value_str = " ".join(expvalue)
798
+
799
+ if self.__debug:
800
+ print(f"Env: {env}={_env_value}", color=Color.YELLOW, flush=True)
801
+ print(f"Parsed Env: {env}={exp_value_str}", color=Color.YELLOW, flush=True)
802
+
803
+ return exp_value_str
804
+
805
+ return None
806
+
807
+
808
+ def __replace_docker_host(self, arg: str) -> str:
809
+ if "DOCKER_HOST" in arg:
810
+ arg = arg.replace("DOCKER_HOST=", "DOCKER_HOST=tcp://docker:2375")
811
+
812
+ return arg
813
+
814
+
815
+ def set_cli_inputs(self, cli_inputs: Dict[str, str]) -> None:
816
+ """
817
+ Set the cli inputs to be used in the tasks.
818
+ """
819
+ for key, value in cli_inputs.items():
820
+ # validate if the key is in the inputs
821
+ _input = None
822
+ _input = next((inp for inp in self.__inputs if inp.id == key), None)
823
+
824
+ if _input is None:
825
+ raise ReferenceError(f"Input with id [{key}] not found")
826
+
827
+ self.__cli_inputs[key] = value
828
+
829
+
830
+ def run_task(self, label: str) -> None:
831
+ # query the task
832
+ _task = None
833
+ _task = next((task for task in self.__tasks if task.label == label), None)
834
+
835
+ if _task is None:
836
+ raise ReferenceError(f"Task with label [{label}] not found")
837
+
838
+ _depends = []
839
+ if _task.dependsOn is not None:
840
+ _depends = _task.dependsOn
841
+
842
+ # first we need to run the dependencies
843
+ for dep in _depends:
844
+ self.run_task(dep)
845
+
846
+ print(f"> Executing task: {label} <", color=Color.GREEN, flush=True)
847
+
848
+ # prepare the command
849
+ _cmd = _task.command
850
+
851
+ # the cmd itself can use the mechanism to replace stuff
852
+ _cmd = self.__check_workspace_folder([_cmd])[0]
853
+ _cmd = self.__check_torizon_inputs([_cmd])[0]
854
+ _cmd = self.__check_docker_inputs([_cmd])[0]
855
+ _cmd = self.__check_tcb_inputs([_cmd])[0]
856
+ _cmd = self.__check_input([_cmd])[0]
857
+ _cmd = self.__check_vscode_env([_cmd])[0]
858
+ _cmd = self.__check_config([_cmd])[0]
859
+
860
+ _args = []
861
+ if _task.args is not None:
862
+ _args = _task.args
863
+
864
+ _env: Dict[str, str] | None = {}
865
+ _cwd = None
866
+ _last_cwd = os.getcwd()
867
+ if _task.options is not None:
868
+ _env = _task.options.env
869
+ _cwd = _task.options.cwd
870
+
871
+ _is_background = ""
872
+ if _task.isBackground:
873
+ _is_background = " &"
874
+
875
+ _shell = _task.type == "shell"
876
+
877
+ # FIXME: The scape args was in the powershell implementation
878
+ # but when used on Python it generates weird behavior
879
+ # _args = self.__scape_args(_args)
880
+ _args = self.__check_workspace_folder(_args)
881
+ _args = self.__check_torizon_inputs(_args)
882
+ _args = self.__check_docker_inputs(_args)
883
+ _args = self.__check_tcb_inputs(_args)
884
+ _args = self.__check_input(_args)
885
+ _args = self.__check_vscode_env(_args)
886
+ _args = self.__check_config(_args)
887
+ _args = self.__check_workspace_folder(_args)
888
+ # FIXME: These was in the powershell implementation
889
+ # but when used on Python it generates weird behavior
890
+ # _args = self.__check_long_args(_args)
891
+ # _args = self.__quoting_special_chars(_args)
892
+
893
+ # if in gitlab ci env we need to replace the DOCKER_HOST
894
+ if self.__gitlab_ci:
895
+ _cmd = self.__replace_docker_host(_cmd)
896
+
897
+ task_env = os.environ.copy()
898
+ # If TASKS_OVERRIDE_ENV is true, override the env vars with the
899
+ # env var values present on the task. If false, set just the env vars
900
+ # present on the task that doesn't already exist on the env var
901
+ if _env is not None:
902
+ for env, _ in _env.items():
903
+ if self.__tasks_override_env == True or env not in os.environ:
904
+ __parsed_env_value = self.__parse_envs(env, _task)
905
+ task_env[env] = __parsed_env_value
906
+
907
+ # we need to change the cwd if it's set
908
+ if _cwd is not None:
909
+ _cwd = self.__check_workspace_folder([_cwd])[0]
910
+ _cwd = self.__check_config([_cwd])[0]
911
+ _cwd = self.__check_vscode_env([_cwd])[0]
912
+
913
+ os.chdir(_cwd)
914
+
915
+ # execute the task
916
+ _cmd_join = f"{_cmd} {' '.join(_args)}{_is_background}"
917
+
918
+ if self.__debug:
919
+ print(f"Command: {_task.command}", color=Color.YELLOW, flush=True)
920
+ print(f"Args: {_task.args}", color=Color.YELLOW, flush=True)
921
+ print(f"Parsed Args: {_args}", color=Color.YELLOW, flush=True)
922
+ print(f"Parsed Command: {_cmd_join}", color=Color.YELLOW, flush=True)
923
+
924
+ # use bash to execute the VSCode tasks commands and scripts, as they
925
+ # are written and tested in bash. Valid just for commands of shell
926
+ # type, not process type ones
927
+ _ret = subprocess.run(
928
+ [_cmd, *_args] if not _shell else _cmd_join,
929
+ stdout=None,
930
+ stderr=None,
931
+ env=task_env,
932
+ shell=_shell,
933
+ executable="/bin/bash" if _shell else None
934
+ )
935
+
936
+ # go back to the last cwd
937
+ os.chdir(_last_cwd)
938
+
939
+ if _ret.returncode != 0:
940
+ print(f"> TASK [{label}] exited with error code [{_ret.returncode}] <", color=Color.RED, flush=True)
941
+ raise RuntimeError(f"Error running task: {label}")