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.
Files changed (71) hide show
  1. runnable/__init__.py +34 -0
  2. runnable/catalog.py +141 -0
  3. runnable/cli.py +272 -0
  4. runnable/context.py +34 -0
  5. runnable/datastore.py +686 -0
  6. runnable/defaults.py +179 -0
  7. runnable/entrypoints.py +484 -0
  8. runnable/exceptions.py +94 -0
  9. runnable/executor.py +431 -0
  10. runnable/experiment_tracker.py +139 -0
  11. runnable/extensions/catalog/__init__.py +21 -0
  12. runnable/extensions/catalog/file_system/__init__.py +0 -0
  13. runnable/extensions/catalog/file_system/implementation.py +226 -0
  14. runnable/extensions/catalog/k8s_pvc/__init__.py +0 -0
  15. runnable/extensions/catalog/k8s_pvc/implementation.py +16 -0
  16. runnable/extensions/catalog/k8s_pvc/integration.py +59 -0
  17. runnable/extensions/executor/__init__.py +714 -0
  18. runnable/extensions/executor/argo/__init__.py +0 -0
  19. runnable/extensions/executor/argo/implementation.py +1182 -0
  20. runnable/extensions/executor/argo/specification.yaml +51 -0
  21. runnable/extensions/executor/k8s_job/__init__.py +0 -0
  22. runnable/extensions/executor/k8s_job/implementation_FF.py +259 -0
  23. runnable/extensions/executor/k8s_job/integration_FF.py +69 -0
  24. runnable/extensions/executor/local/__init__.py +0 -0
  25. runnable/extensions/executor/local/implementation.py +69 -0
  26. runnable/extensions/executor/local_container/__init__.py +0 -0
  27. runnable/extensions/executor/local_container/implementation.py +367 -0
  28. runnable/extensions/executor/mocked/__init__.py +0 -0
  29. runnable/extensions/executor/mocked/implementation.py +220 -0
  30. runnable/extensions/experiment_tracker/__init__.py +0 -0
  31. runnable/extensions/experiment_tracker/mlflow/__init__.py +0 -0
  32. runnable/extensions/experiment_tracker/mlflow/implementation.py +94 -0
  33. runnable/extensions/nodes.py +675 -0
  34. runnable/extensions/run_log_store/__init__.py +0 -0
  35. runnable/extensions/run_log_store/chunked_file_system/__init__.py +0 -0
  36. runnable/extensions/run_log_store/chunked_file_system/implementation.py +106 -0
  37. runnable/extensions/run_log_store/chunked_k8s_pvc/__init__.py +0 -0
  38. runnable/extensions/run_log_store/chunked_k8s_pvc/implementation.py +21 -0
  39. runnable/extensions/run_log_store/chunked_k8s_pvc/integration.py +61 -0
  40. runnable/extensions/run_log_store/db/implementation_FF.py +157 -0
  41. runnable/extensions/run_log_store/db/integration_FF.py +0 -0
  42. runnable/extensions/run_log_store/file_system/__init__.py +0 -0
  43. runnable/extensions/run_log_store/file_system/implementation.py +136 -0
  44. runnable/extensions/run_log_store/generic_chunked.py +541 -0
  45. runnable/extensions/run_log_store/k8s_pvc/__init__.py +0 -0
  46. runnable/extensions/run_log_store/k8s_pvc/implementation.py +21 -0
  47. runnable/extensions/run_log_store/k8s_pvc/integration.py +56 -0
  48. runnable/extensions/secrets/__init__.py +0 -0
  49. runnable/extensions/secrets/dotenv/__init__.py +0 -0
  50. runnable/extensions/secrets/dotenv/implementation.py +100 -0
  51. runnable/extensions/secrets/env_secrets/__init__.py +0 -0
  52. runnable/extensions/secrets/env_secrets/implementation.py +42 -0
  53. runnable/graph.py +464 -0
  54. runnable/integration.py +205 -0
  55. runnable/interaction.py +399 -0
  56. runnable/names.py +546 -0
  57. runnable/nodes.py +489 -0
  58. runnable/parameters.py +183 -0
  59. runnable/pickler.py +102 -0
  60. runnable/sdk.py +470 -0
  61. runnable/secrets.py +95 -0
  62. runnable/tasks.py +392 -0
  63. runnable/utils.py +630 -0
  64. runnable-0.2.0.dist-info/METADATA +437 -0
  65. runnable-0.2.0.dist-info/RECORD +69 -0
  66. runnable-0.2.0.dist-info/entry_points.txt +44 -0
  67. runnable-0.1.0.dist-info/METADATA +0 -16
  68. runnable-0.1.0.dist-info/RECORD +0 -6
  69. /runnable/{.gitkeep → extensions/__init__.py} +0 -0
  70. {runnable-0.1.0.dist-info → runnable-0.2.0.dist-info}/LICENSE +0 -0
  71. {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