runnable 0.1.0__py3-none-any.whl → 0.2.0__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.
- runnable/__init__.py +34 -0
- runnable/catalog.py +141 -0
- runnable/cli.py +272 -0
- runnable/context.py +34 -0
- runnable/datastore.py +686 -0
- runnable/defaults.py +179 -0
- runnable/entrypoints.py +484 -0
- runnable/exceptions.py +94 -0
- runnable/executor.py +431 -0
- runnable/experiment_tracker.py +139 -0
- runnable/extensions/catalog/__init__.py +21 -0
- runnable/extensions/catalog/file_system/__init__.py +0 -0
- runnable/extensions/catalog/file_system/implementation.py +226 -0
- runnable/extensions/catalog/k8s_pvc/__init__.py +0 -0
- runnable/extensions/catalog/k8s_pvc/implementation.py +16 -0
- runnable/extensions/catalog/k8s_pvc/integration.py +59 -0
- runnable/extensions/executor/__init__.py +714 -0
- runnable/extensions/executor/argo/__init__.py +0 -0
- runnable/extensions/executor/argo/implementation.py +1182 -0
- runnable/extensions/executor/argo/specification.yaml +51 -0
- runnable/extensions/executor/k8s_job/__init__.py +0 -0
- runnable/extensions/executor/k8s_job/implementation_FF.py +259 -0
- runnable/extensions/executor/k8s_job/integration_FF.py +69 -0
- runnable/extensions/executor/local/__init__.py +0 -0
- runnable/extensions/executor/local/implementation.py +69 -0
- runnable/extensions/executor/local_container/__init__.py +0 -0
- runnable/extensions/executor/local_container/implementation.py +367 -0
- runnable/extensions/executor/mocked/__init__.py +0 -0
- runnable/extensions/executor/mocked/implementation.py +220 -0
- runnable/extensions/experiment_tracker/__init__.py +0 -0
- runnable/extensions/experiment_tracker/mlflow/__init__.py +0 -0
- runnable/extensions/experiment_tracker/mlflow/implementation.py +94 -0
- runnable/extensions/nodes.py +675 -0
- runnable/extensions/run_log_store/__init__.py +0 -0
- runnable/extensions/run_log_store/chunked_file_system/__init__.py +0 -0
- runnable/extensions/run_log_store/chunked_file_system/implementation.py +106 -0
- runnable/extensions/run_log_store/chunked_k8s_pvc/__init__.py +0 -0
- runnable/extensions/run_log_store/chunked_k8s_pvc/implementation.py +21 -0
- runnable/extensions/run_log_store/chunked_k8s_pvc/integration.py +61 -0
- runnable/extensions/run_log_store/db/implementation_FF.py +157 -0
- runnable/extensions/run_log_store/db/integration_FF.py +0 -0
- runnable/extensions/run_log_store/file_system/__init__.py +0 -0
- runnable/extensions/run_log_store/file_system/implementation.py +136 -0
- runnable/extensions/run_log_store/generic_chunked.py +541 -0
- runnable/extensions/run_log_store/k8s_pvc/__init__.py +0 -0
- runnable/extensions/run_log_store/k8s_pvc/implementation.py +21 -0
- runnable/extensions/run_log_store/k8s_pvc/integration.py +56 -0
- runnable/extensions/secrets/__init__.py +0 -0
- runnable/extensions/secrets/dotenv/__init__.py +0 -0
- runnable/extensions/secrets/dotenv/implementation.py +100 -0
- runnable/extensions/secrets/env_secrets/__init__.py +0 -0
- runnable/extensions/secrets/env_secrets/implementation.py +42 -0
- runnable/graph.py +464 -0
- runnable/integration.py +205 -0
- runnable/interaction.py +399 -0
- runnable/names.py +546 -0
- runnable/nodes.py +489 -0
- runnable/parameters.py +183 -0
- runnable/pickler.py +102 -0
- runnable/sdk.py +470 -0
- runnable/secrets.py +95 -0
- runnable/tasks.py +392 -0
- runnable/utils.py +630 -0
- runnable-0.2.0.dist-info/METADATA +437 -0
- runnable-0.2.0.dist-info/RECORD +69 -0
- runnable-0.2.0.dist-info/entry_points.txt +44 -0
- runnable-0.1.0.dist-info/METADATA +0 -16
- runnable-0.1.0.dist-info/RECORD +0 -6
- /runnable/{.gitkeep → extensions/__init__.py} +0 -0
- {runnable-0.1.0.dist-info → runnable-0.2.0.dist-info}/LICENSE +0 -0
- {runnable-0.1.0.dist-info → runnable-0.2.0.dist-info}/WHEEL +0 -0
runnable/utils.py
ADDED
@@ -0,0 +1,630 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import hashlib
|
4
|
+
import json
|
5
|
+
import logging
|
6
|
+
import os
|
7
|
+
import subprocess
|
8
|
+
from collections import OrderedDict
|
9
|
+
from datetime import datetime
|
10
|
+
from pathlib import Path
|
11
|
+
from string import Template as str_template
|
12
|
+
from typing import TYPE_CHECKING, Any, Dict, Mapping, Tuple, Union
|
13
|
+
|
14
|
+
from ruamel.yaml import YAML
|
15
|
+
from stevedore import driver
|
16
|
+
|
17
|
+
import runnable.context as context
|
18
|
+
from runnable import defaults, names
|
19
|
+
from runnable.defaults import TypeMapVariable
|
20
|
+
|
21
|
+
if TYPE_CHECKING: # pragma: no cover
|
22
|
+
from runnable.extensions.nodes import TaskNode
|
23
|
+
from runnable.nodes import BaseNode
|
24
|
+
|
25
|
+
|
26
|
+
logger = logging.getLogger(defaults.LOGGER_NAME)
|
27
|
+
logging.getLogger("stevedore").setLevel(logging.CRITICAL)
|
28
|
+
|
29
|
+
|
30
|
+
def does_file_exist(file_path: str) -> bool:
|
31
|
+
"""Check if a file exists.
|
32
|
+
Implemented here to avoid repetition of logic.
|
33
|
+
|
34
|
+
Args:
|
35
|
+
file_path (str): The file path to check
|
36
|
+
|
37
|
+
Returns:
|
38
|
+
bool: False if it does not otherwise True
|
39
|
+
"""
|
40
|
+
my_file = Path(file_path)
|
41
|
+
return my_file.is_file()
|
42
|
+
|
43
|
+
|
44
|
+
def does_dir_exist(file_path: Union[str, Path]) -> bool:
|
45
|
+
"""Check if a directory exists.
|
46
|
+
Implemented here to avoid repetition of logic.
|
47
|
+
|
48
|
+
Args:
|
49
|
+
file_path (str or Path): The directory path to check
|
50
|
+
|
51
|
+
Returns:
|
52
|
+
bool: False if the directory does not exist, True otherwise
|
53
|
+
"""
|
54
|
+
my_file = Path(file_path)
|
55
|
+
return my_file.is_dir()
|
56
|
+
|
57
|
+
|
58
|
+
def safe_make_dir(directory: Union[str, Path]):
|
59
|
+
"""Safely make the directory.
|
60
|
+
Ignore if it exists and create the parents if necessary.
|
61
|
+
|
62
|
+
Args:
|
63
|
+
directory (str): The directory path to create
|
64
|
+
"""
|
65
|
+
Path(directory).mkdir(parents=True, exist_ok=True)
|
66
|
+
|
67
|
+
|
68
|
+
def generate_run_id(run_id: str = "") -> str:
|
69
|
+
"""Generate a new run_id.
|
70
|
+
|
71
|
+
If the input run_id is none, we create one based on time stamp.
|
72
|
+
|
73
|
+
Args:
|
74
|
+
run_id (str, optional): Input Run ID. Defaults to None
|
75
|
+
|
76
|
+
Returns:
|
77
|
+
str: A generated run_id
|
78
|
+
"""
|
79
|
+
# If we are not provided with a run_id, generate one
|
80
|
+
if not run_id:
|
81
|
+
now = datetime.now()
|
82
|
+
run_id = f"{names.get_random_name()}-{now.hour:02}{now.minute:02}"
|
83
|
+
|
84
|
+
return run_id
|
85
|
+
|
86
|
+
|
87
|
+
def apply_variables(apply_to: Dict[str, Any], variables: Dict[str, str]) -> Dict[str, Any]:
|
88
|
+
"""Safely applies the variables to a config.
|
89
|
+
|
90
|
+
For example: For config:
|
91
|
+
{'a' : ${b}}, the value of ${b} is replaced by b in the variables.
|
92
|
+
|
93
|
+
If the ${b} does not exist in the variables, it is ignored in the config.
|
94
|
+
|
95
|
+
Args:
|
96
|
+
apply_to (dict): The config to apply variables
|
97
|
+
variables (dict): The variables in key, value pairs
|
98
|
+
|
99
|
+
Raises:
|
100
|
+
Exception: If the variables is not dict
|
101
|
+
|
102
|
+
Returns:
|
103
|
+
dict: A transformed dict with variables applied
|
104
|
+
"""
|
105
|
+
if not isinstance(variables, dict):
|
106
|
+
raise Exception("Argument Variables should be dict")
|
107
|
+
|
108
|
+
json_d = json.dumps(apply_to)
|
109
|
+
transformed = str_template(json_d).substitute(**variables)
|
110
|
+
return json.loads(transformed)
|
111
|
+
|
112
|
+
|
113
|
+
def get_module_and_attr_names(command: str) -> Tuple[str, str]:
|
114
|
+
"""Given a string of module.function, this functions returns the module name and func names.
|
115
|
+
|
116
|
+
It also checks to make sure that the string is of expected 'module.func' format
|
117
|
+
|
118
|
+
Args:
|
119
|
+
command (str): String of format module.function_name
|
120
|
+
|
121
|
+
Raises:
|
122
|
+
Exception: If the string is of not format
|
123
|
+
|
124
|
+
Returns:
|
125
|
+
Tuple[str, str]: (module_name, function_name) extracted from the input string
|
126
|
+
"""
|
127
|
+
mods = command.split(".")
|
128
|
+
if len(mods) <= 1:
|
129
|
+
raise Exception("The command should be a function to call")
|
130
|
+
func = mods[-1]
|
131
|
+
module = ".".join(mods[:-1])
|
132
|
+
return module, func
|
133
|
+
|
134
|
+
|
135
|
+
def get_dag_hash(dag: Dict[str, Any]) -> str:
|
136
|
+
"""Generates the hash of the dag definition.
|
137
|
+
|
138
|
+
Args:
|
139
|
+
dag (dict): The dictionary object containing the dag definition
|
140
|
+
|
141
|
+
Returns:
|
142
|
+
str: The hash of the dag definition
|
143
|
+
"""
|
144
|
+
dag_str = json.dumps(dag, sort_keys=True, ensure_ascii=True)
|
145
|
+
return hashlib.sha1(dag_str.encode("utf-8")).hexdigest()
|
146
|
+
|
147
|
+
|
148
|
+
def load_yaml(file_path: str, load_type: str = "safe") -> Dict[str, Any]:
|
149
|
+
"""Loads an yaml and returns the dictionary.
|
150
|
+
|
151
|
+
Args:
|
152
|
+
file_path (str): The path of the yamlfile
|
153
|
+
load_type (str, optional): The load type as understood by ruamel. Defaults to 'safe'.
|
154
|
+
|
155
|
+
Returns:
|
156
|
+
dict: The mapping as defined in the yaml file
|
157
|
+
"""
|
158
|
+
with open(file_path, encoding="utf-8") as f:
|
159
|
+
yaml = YAML(typ=load_type, pure=True)
|
160
|
+
yaml_config = yaml.load(f)
|
161
|
+
return yaml_config
|
162
|
+
|
163
|
+
|
164
|
+
def is_a_git_repo() -> bool:
|
165
|
+
"""Does a git command to see if the project is git versioned.
|
166
|
+
|
167
|
+
Returns:
|
168
|
+
bool: True if it is git versioned, False otherwise
|
169
|
+
"""
|
170
|
+
command = "git rev-parse --is-inside-work-tree"
|
171
|
+
try:
|
172
|
+
subprocess.check_output(command.split()).strip().decode("utf-8")
|
173
|
+
logger.info("Found the code to be git versioned")
|
174
|
+
return True
|
175
|
+
except BaseException: # pylint: disable=W0702
|
176
|
+
logger.error("No git repo found, unsafe hash")
|
177
|
+
|
178
|
+
return False
|
179
|
+
|
180
|
+
|
181
|
+
def get_current_code_commit() -> Union[str, None]:
|
182
|
+
"""Gets the git sha id if the project is version controlled.
|
183
|
+
|
184
|
+
Returns:
|
185
|
+
Union[str, None]: SHA ID if the code is versioned, None otherwise
|
186
|
+
"""
|
187
|
+
command = "git rev-parse HEAD"
|
188
|
+
if not is_a_git_repo():
|
189
|
+
return None
|
190
|
+
try:
|
191
|
+
label = subprocess.check_output(command.split()).strip().decode("utf-8")
|
192
|
+
logger.info("Found the git commit to be: %s", label)
|
193
|
+
return label
|
194
|
+
except BaseException: # pylint: disable=W0702
|
195
|
+
logger.exception("Error getting git hash")
|
196
|
+
raise
|
197
|
+
|
198
|
+
|
199
|
+
def archive_git_tracked(name: str):
|
200
|
+
"""Generate a git archive of the tracked files.
|
201
|
+
|
202
|
+
Args:
|
203
|
+
name (str): The name to give the archive
|
204
|
+
|
205
|
+
Raises:
|
206
|
+
Exception: If its not a git repo
|
207
|
+
"""
|
208
|
+
command = f"git archive -v -o {name}.tar.gz --format=tar.gz HEAD"
|
209
|
+
|
210
|
+
if not is_a_git_repo():
|
211
|
+
raise Exception("Not a git repo")
|
212
|
+
try:
|
213
|
+
subprocess.check_output(command.split()).strip().decode("utf-8")
|
214
|
+
except BaseException: # pylint: disable=W0702
|
215
|
+
logger.exception("Error archiving repo")
|
216
|
+
raise
|
217
|
+
|
218
|
+
|
219
|
+
def is_git_clean() -> Tuple[bool, Union[None, str]]:
|
220
|
+
"""Checks if the git tree is clean and there are no modified tracked files.
|
221
|
+
|
222
|
+
Returns:
|
223
|
+
Tuple[bool, Union[None, str]]: None if its clean, comma-seperated file names if it is changed
|
224
|
+
"""
|
225
|
+
command = "git diff --name-only"
|
226
|
+
if not is_a_git_repo():
|
227
|
+
return False, None
|
228
|
+
try:
|
229
|
+
label = subprocess.check_output(command.split()).strip().decode("utf-8")
|
230
|
+
if not label:
|
231
|
+
return True, None
|
232
|
+
return False, label
|
233
|
+
except BaseException: # pylint: disable=W0702
|
234
|
+
logger.exception("Error checking if the code is git clean")
|
235
|
+
|
236
|
+
return False, None
|
237
|
+
|
238
|
+
|
239
|
+
def get_git_remote() -> Union[str, None]:
|
240
|
+
"""Gets the remote URL of git.
|
241
|
+
|
242
|
+
Returns:
|
243
|
+
Union[str, None]: Remote URL if the code is version controlled, None otherwise
|
244
|
+
"""
|
245
|
+
command = "git config --get remote.origin.url"
|
246
|
+
if not is_a_git_repo():
|
247
|
+
return None
|
248
|
+
try:
|
249
|
+
label = subprocess.check_output(command.split()).strip().decode("utf-8")
|
250
|
+
logger.info("Found the git remote to be: %s", label)
|
251
|
+
return label
|
252
|
+
except BaseException: # pylint: disable=W0702
|
253
|
+
logger.exception("Error getting git remote")
|
254
|
+
raise
|
255
|
+
|
256
|
+
|
257
|
+
def get_local_docker_image_id(image_name: str) -> str:
|
258
|
+
"""If we are running in local settings, return the docker image id.
|
259
|
+
|
260
|
+
Args:
|
261
|
+
image_name (str): The image name we need the digest for
|
262
|
+
|
263
|
+
Returns:
|
264
|
+
str: The docker image digest
|
265
|
+
"""
|
266
|
+
try:
|
267
|
+
import docker
|
268
|
+
|
269
|
+
client = docker.from_env()
|
270
|
+
image = client.images.get(image_name)
|
271
|
+
return image.attrs["Id"]
|
272
|
+
except ImportError: # pragma: no cover
|
273
|
+
logger.warning("Did not find docker installed, some functionality might be affected")
|
274
|
+
except BaseException:
|
275
|
+
logger.exception(f"Could not find the image by name {image_name}")
|
276
|
+
|
277
|
+
return ""
|
278
|
+
|
279
|
+
|
280
|
+
def get_git_code_identity():
|
281
|
+
"""Returns a code identity object for version controlled code.
|
282
|
+
|
283
|
+
Args:
|
284
|
+
run_log_store (magnus.datastore.BaseRunLogStore): The run log store used in this process
|
285
|
+
|
286
|
+
Returns:
|
287
|
+
magnus.datastore.CodeIdentity: The code identity used by the run log store.
|
288
|
+
"""
|
289
|
+
code_identity = context.run_context.run_log_store.create_code_identity()
|
290
|
+
try:
|
291
|
+
code_identity.code_identifier = get_current_code_commit()
|
292
|
+
code_identity.code_identifier_type = "git"
|
293
|
+
code_identity.code_identifier_dependable, changed = is_git_clean()
|
294
|
+
code_identity.code_identifier_url = get_git_remote()
|
295
|
+
if changed:
|
296
|
+
code_identity.code_identifier_message = "changes found in " + ", ".join(changed.split("\n"))
|
297
|
+
except BaseException:
|
298
|
+
logger.exception("Git code versioning problems")
|
299
|
+
|
300
|
+
return code_identity
|
301
|
+
|
302
|
+
|
303
|
+
def remove_prefix(text: str, prefix: str) -> str:
|
304
|
+
"""Removes a prefix if one is present in the input text.
|
305
|
+
|
306
|
+
Args:
|
307
|
+
text (str): The input text to remove the prefix from
|
308
|
+
prefix (str): The prefix that has to be removed
|
309
|
+
|
310
|
+
Returns:
|
311
|
+
str: The original string if no prefix is found, or the right prefix chomped string if present
|
312
|
+
"""
|
313
|
+
if text.startswith(prefix):
|
314
|
+
return text[len(prefix) :]
|
315
|
+
return text # or whatever is given
|
316
|
+
|
317
|
+
|
318
|
+
def get_tracked_data() -> Dict[str, str]:
|
319
|
+
"""Scans the environment variables to find any user tracked variables that have a prefix MAGNUS_TRACK_
|
320
|
+
Removes the environment variable to prevent any clashes in the future steps.
|
321
|
+
|
322
|
+
Returns:
|
323
|
+
dict: A dictionary of user tracked data
|
324
|
+
"""
|
325
|
+
tracked_data = {}
|
326
|
+
for env_var, value in os.environ.items():
|
327
|
+
if env_var.startswith(defaults.TRACK_PREFIX):
|
328
|
+
key = remove_prefix(env_var, defaults.TRACK_PREFIX)
|
329
|
+
try:
|
330
|
+
tracked_data[key.lower()] = json.loads(value)
|
331
|
+
except json.decoder.JSONDecodeError:
|
332
|
+
logger.warning(f"Tracker {key} could not be JSON decoded, adding the literal value")
|
333
|
+
tracked_data[key.lower()] = value
|
334
|
+
|
335
|
+
del os.environ[env_var]
|
336
|
+
return tracked_data
|
337
|
+
|
338
|
+
|
339
|
+
def diff_dict(d1: Dict[str, Any], d2: Dict[str, Any]) -> Dict[str, Any]:
|
340
|
+
"""
|
341
|
+
Given two dicts d1 and d2, return a new dict that has upsert items from d1.
|
342
|
+
|
343
|
+
Args:
|
344
|
+
d1 (reference): The reference dict.
|
345
|
+
d2 (compare): Any new or modified items compared to d1 would be returned back
|
346
|
+
|
347
|
+
Returns:
|
348
|
+
dict: Any new or modified items in d2 in comparison to d1 would be sent back
|
349
|
+
"""
|
350
|
+
diff = {}
|
351
|
+
|
352
|
+
for k2, v2 in d2.items():
|
353
|
+
if k2 in d1 and d1[k2] != v2:
|
354
|
+
diff[k2] = v2
|
355
|
+
continue
|
356
|
+
diff[k2] = v2
|
357
|
+
|
358
|
+
return diff
|
359
|
+
|
360
|
+
|
361
|
+
def hash_bytestr_iter(bytesiter, hasher, ashexstr=True): # pylint: disable=C0116
|
362
|
+
"""Hashes the given bytesiter using the given hasher."""
|
363
|
+
for block in bytesiter: # pragma: no cover
|
364
|
+
hasher.update(block)
|
365
|
+
return hasher.hexdigest() if ashexstr else hasher.digest() # pragma: no cover
|
366
|
+
|
367
|
+
|
368
|
+
def file_as_blockiter(afile, blocksize=65536): # pylint: disable=C0116
|
369
|
+
"""From a StackOverflow answer: that is used to generate a MD5 hash of a large files.
|
370
|
+
# https://stackoverflow.com/questions/3431825/generating-an-md5-checksum-of-a-file.
|
371
|
+
|
372
|
+
"""
|
373
|
+
with afile: # pragma: no cover
|
374
|
+
block = afile.read(blocksize)
|
375
|
+
while len(block) > 0:
|
376
|
+
yield block
|
377
|
+
block = afile.read(blocksize)
|
378
|
+
|
379
|
+
|
380
|
+
def get_data_hash(file_name: str):
|
381
|
+
"""Returns the hash of the data file.
|
382
|
+
|
383
|
+
Args:
|
384
|
+
file_name (str): The file name to generated the hash
|
385
|
+
|
386
|
+
Returns:
|
387
|
+
str: The SHA ID of the file contents
|
388
|
+
"""
|
389
|
+
# https://stackoverflow.com/questions/3431825/generating-an-md5-checksum-of-a-file
|
390
|
+
return hash_bytestr_iter(file_as_blockiter(open(file_name, "rb")), hashlib.sha256()) # pragma: no cover
|
391
|
+
|
392
|
+
|
393
|
+
def get_node_execution_command(
|
394
|
+
node: BaseNode,
|
395
|
+
map_variable: TypeMapVariable = None,
|
396
|
+
over_write_run_id: str = "",
|
397
|
+
) -> str:
|
398
|
+
"""A utility function to standardize execution call to a node via command line.
|
399
|
+
|
400
|
+
Args:
|
401
|
+
executor (object): The executor class.
|
402
|
+
node (object): The Node to execute
|
403
|
+
map_variable (str, optional): If the node belongs to a map step. Defaults to None.
|
404
|
+
|
405
|
+
Returns:
|
406
|
+
str: The execution command to run a node via command line.
|
407
|
+
"""
|
408
|
+
run_id = context.run_context.run_id
|
409
|
+
|
410
|
+
if over_write_run_id:
|
411
|
+
run_id = over_write_run_id
|
412
|
+
|
413
|
+
log_level = logging.getLevelName(logger.getEffectiveLevel())
|
414
|
+
|
415
|
+
action = f"magnus execute_single_node {run_id} " f"{node._command_friendly_name()}" f" --log-level {log_level}"
|
416
|
+
|
417
|
+
if context.run_context.pipeline_file:
|
418
|
+
action = action + f" --file {context.run_context.pipeline_file}"
|
419
|
+
|
420
|
+
if map_variable:
|
421
|
+
action = action + f" --map-variable '{json.dumps(map_variable)}'"
|
422
|
+
|
423
|
+
if context.run_context.configuration_file:
|
424
|
+
action = action + f" --config-file {context.run_context.configuration_file}"
|
425
|
+
|
426
|
+
if context.run_context.parameters_file:
|
427
|
+
action = action + f" --parameters-file {context.run_context.parameters_file}"
|
428
|
+
|
429
|
+
if context.run_context.tag:
|
430
|
+
action = action + f" --tag {context.run_context.tag}"
|
431
|
+
|
432
|
+
return action
|
433
|
+
|
434
|
+
|
435
|
+
def get_fan_command(
|
436
|
+
mode: str,
|
437
|
+
node: BaseNode,
|
438
|
+
run_id: str,
|
439
|
+
map_variable: TypeMapVariable = None,
|
440
|
+
) -> str:
|
441
|
+
"""
|
442
|
+
An utility function to return the fan "in or out" command
|
443
|
+
|
444
|
+
Args:
|
445
|
+
executor (BaseExecutor): The executor class
|
446
|
+
mode (str): in or out
|
447
|
+
node (BaseNode): The composite node that we are fanning in or out
|
448
|
+
run_id (str): The run id.
|
449
|
+
map_variable (dict, optional): If the node is a map, we have the map variable. Defaults to None.
|
450
|
+
|
451
|
+
Returns:
|
452
|
+
str: The fan in or out command
|
453
|
+
"""
|
454
|
+
log_level = logging.getLevelName(logger.getEffectiveLevel())
|
455
|
+
action = (
|
456
|
+
f"magnus fan {run_id} "
|
457
|
+
f"{node._command_friendly_name()} "
|
458
|
+
f"--mode {mode} "
|
459
|
+
f"--file {context.run_context.pipeline_file} "
|
460
|
+
f"--log-level {log_level} "
|
461
|
+
)
|
462
|
+
if context.run_context.configuration_file:
|
463
|
+
action = action + f" --config-file {context.run_context.configuration_file} "
|
464
|
+
|
465
|
+
if context.run_context.parameters_file:
|
466
|
+
action = action + f" --parameters-file {context.run_context.parameters_file}"
|
467
|
+
|
468
|
+
if map_variable:
|
469
|
+
action = action + f" --map-variable '{json.dumps(map_variable)}'"
|
470
|
+
|
471
|
+
if context.run_context.tag:
|
472
|
+
action = action + f" --tag {context.run_context.tag}"
|
473
|
+
|
474
|
+
return action
|
475
|
+
|
476
|
+
|
477
|
+
def get_job_execution_command(node: TaskNode, over_write_run_id: str = "") -> str:
|
478
|
+
"""Get the execution command to run a job via command line.
|
479
|
+
|
480
|
+
This function should be used by all executors to submit jobs in remote environment
|
481
|
+
|
482
|
+
Args:
|
483
|
+
executor (BaseExecutor): The executor class.
|
484
|
+
node (BaseNode): The node being executed.
|
485
|
+
over_write_run_id (str, optional): If the node is part of a map step. Defaults to ''.
|
486
|
+
|
487
|
+
Returns:
|
488
|
+
str: The execution command to run a job via command line.
|
489
|
+
"""
|
490
|
+
|
491
|
+
run_id = context.run_context.run_id
|
492
|
+
|
493
|
+
if over_write_run_id:
|
494
|
+
run_id = over_write_run_id
|
495
|
+
|
496
|
+
log_level = logging.getLevelName(logger.getEffectiveLevel())
|
497
|
+
|
498
|
+
cli_command, cli_options = node.executable.get_cli_options()
|
499
|
+
|
500
|
+
action = f"magnus execute_{cli_command} {run_id} " f" --log-level {log_level}"
|
501
|
+
|
502
|
+
action = action + f" --entrypoint {defaults.ENTRYPOINT.SYSTEM.value}"
|
503
|
+
|
504
|
+
if context.run_context.configuration_file:
|
505
|
+
action = action + f" --config-file {context.run_context.configuration_file}"
|
506
|
+
|
507
|
+
if context.run_context.parameters_file:
|
508
|
+
action = action + f" --parameters-file {context.run_context.parameters_file}"
|
509
|
+
|
510
|
+
if context.run_context.tag:
|
511
|
+
action = action + f" --tag {context.run_context.tag}"
|
512
|
+
|
513
|
+
for key, value in cli_options.items():
|
514
|
+
action = action + f" --{key} {value}"
|
515
|
+
|
516
|
+
return action
|
517
|
+
|
518
|
+
|
519
|
+
def get_provider_by_name_and_type(service_type: str, service_details: defaults.ServiceConfig):
|
520
|
+
"""Given a service type, one of executor, run_log_store, catalog, secrets and the config
|
521
|
+
return the exact child class implementing the service.
|
522
|
+
We use stevedore to do the work for us.
|
523
|
+
|
524
|
+
Args:
|
525
|
+
service_type (str): One of executor, run_log_store, catalog, secrets
|
526
|
+
service_details (dict): The config used to instantiate the service.
|
527
|
+
|
528
|
+
Raises:
|
529
|
+
Exception: If the service by that name does not exist
|
530
|
+
|
531
|
+
Returns:
|
532
|
+
object: A service object
|
533
|
+
"""
|
534
|
+
namespace = service_type
|
535
|
+
|
536
|
+
service_name = service_details["type"]
|
537
|
+
service_config: Mapping[str, Any] = {}
|
538
|
+
if "config" in service_details:
|
539
|
+
service_config = service_details.get("config", {})
|
540
|
+
|
541
|
+
logger.info(f"Trying to get a service of {service_type} of the name {service_name} with config: {service_config}")
|
542
|
+
try:
|
543
|
+
mgr = driver.DriverManager(
|
544
|
+
namespace=namespace,
|
545
|
+
name=service_name,
|
546
|
+
invoke_on_load=True,
|
547
|
+
invoke_kwds={**service_config},
|
548
|
+
)
|
549
|
+
return mgr.driver
|
550
|
+
except Exception as _e:
|
551
|
+
raise Exception(f"Could not find the service of type: {service_type} with config: {service_details}") from _e
|
552
|
+
|
553
|
+
|
554
|
+
def get_duration_between_datetime_strings(start_time: str, end_time: str) -> str:
|
555
|
+
"""Given two datetime strings, compute the duration between them.
|
556
|
+
|
557
|
+
Args:
|
558
|
+
start_time (str): ISO format datetime string
|
559
|
+
end_time (str): ISO format datetime string
|
560
|
+
Returns:
|
561
|
+
The duration between the time in string format
|
562
|
+
"""
|
563
|
+
start = datetime.fromisoformat(start_time)
|
564
|
+
end = datetime.fromisoformat(end_time)
|
565
|
+
|
566
|
+
return str(end - start)
|
567
|
+
|
568
|
+
|
569
|
+
def get_run_config() -> dict:
|
570
|
+
"""Given an executor with assigned services, return the run_config.
|
571
|
+
|
572
|
+
Args:
|
573
|
+
executor (object): The executor with all the services assigned.
|
574
|
+
|
575
|
+
Returns:
|
576
|
+
dict: The run_config.
|
577
|
+
"""
|
578
|
+
|
579
|
+
run_config = context.run_context.model_dump(by_alias=True)
|
580
|
+
return run_config
|
581
|
+
|
582
|
+
|
583
|
+
def json_to_ordered_dict(json_str: str) -> TypeMapVariable:
|
584
|
+
"""Decode a JSON str into OrderedDict.
|
585
|
+
|
586
|
+
Args:
|
587
|
+
json_str ([str]): The JSON string to decode
|
588
|
+
|
589
|
+
Returns:
|
590
|
+
[OrderedDict]: The decoded OrderedDict
|
591
|
+
"""
|
592
|
+
if json_str and json_str != "{}":
|
593
|
+
return json.loads(json_str, object_pairs_hook=OrderedDict)
|
594
|
+
|
595
|
+
return OrderedDict()
|
596
|
+
|
597
|
+
|
598
|
+
def set_magnus_environment_variables(run_id: str = "", configuration_file: str = "", tag: str = "") -> None:
|
599
|
+
"""Set the environment variables used by magnus. This function should be called during the prepare configurations
|
600
|
+
by all executors.
|
601
|
+
|
602
|
+
Args:
|
603
|
+
run_id (str, optional): The run id of the execution. Defaults to None.
|
604
|
+
configuration_file (str, optional): The configuration file if used. Defaults to None.
|
605
|
+
tag (str, optional): The tag associated with a run. Defaults to None.
|
606
|
+
"""
|
607
|
+
if run_id:
|
608
|
+
os.environ[defaults.ENV_RUN_ID] = run_id
|
609
|
+
|
610
|
+
if configuration_file:
|
611
|
+
os.environ[defaults.MAGNUS_CONFIG_FILE] = configuration_file
|
612
|
+
|
613
|
+
if tag:
|
614
|
+
os.environ[defaults.MAGNUS_RUN_TAG] = tag
|
615
|
+
|
616
|
+
|
617
|
+
def gather_variables() -> dict:
|
618
|
+
"""Gather all the environment variables used by magnus. All the variables start with MAGNUS_VAR_.
|
619
|
+
|
620
|
+
Returns:
|
621
|
+
dict: All the environment variables present in the environment.
|
622
|
+
"""
|
623
|
+
variables = {}
|
624
|
+
|
625
|
+
for env_var, value in os.environ.items():
|
626
|
+
if env_var.startswith(defaults.VARIABLE_PREFIX):
|
627
|
+
key = remove_prefix(env_var, defaults.VARIABLE_PREFIX)
|
628
|
+
variables[key] = value
|
629
|
+
|
630
|
+
return variables
|