simstack 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 (91) hide show
  1. simstack/__init__.py +0 -0
  2. simstack/_version.py +34 -0
  3. simstack/core/__init__.py +0 -0
  4. simstack/core/artifacts.py +447 -0
  5. simstack/core/asnyc_helper.py +14 -0
  6. simstack/core/config_file.py +60 -0
  7. simstack/core/context.py +167 -0
  8. simstack/core/data.py +139 -0
  9. simstack/core/definitions.py +21 -0
  10. simstack/core/engine.py +116 -0
  11. simstack/core/find_simstack_modules.py +43 -0
  12. simstack/core/hash.py +177 -0
  13. simstack/core/model_table.py +199 -0
  14. simstack/core/node.py +911 -0
  15. simstack/core/node_runner.py +254 -0
  16. simstack/core/node_table.py +380 -0
  17. simstack/core/recompute_artifacts.py +120 -0
  18. simstack/core/reset_db.py +12 -0
  19. simstack/core/resources.py +101 -0
  20. simstack/core/run_node.py +69 -0
  21. simstack/core/runner.py +435 -0
  22. simstack/core/simstack_result.py +41 -0
  23. simstack/core/submit_node.py +143 -0
  24. simstack/core/task_id.py +22 -0
  25. simstack/data/__init__.py +0 -0
  26. simstack/methods/__init__.py +0 -0
  27. simstack/methods/get_file.py +10 -0
  28. simstack/methods/multiple_executor.py +307 -0
  29. simstack/models/__init__.py +37 -0
  30. simstack/models/array_list.py +48 -0
  31. simstack/models/array_storage.py +54 -0
  32. simstack/models/artifact_models.py +127 -0
  33. simstack/models/base_types.py +208 -0
  34. simstack/models/charts_artifact.py +411 -0
  35. simstack/models/dataset.py +432 -0
  36. simstack/models/dataset_metadata.py +303 -0
  37. simstack/models/file_instance.py +77 -0
  38. simstack/models/file_list.py +321 -0
  39. simstack/models/files.py +229 -0
  40. simstack/models/log_entry_model.py +37 -0
  41. simstack/models/models.py +40 -0
  42. simstack/models/node_registry.py +105 -0
  43. simstack/models/pandas_model.py +210 -0
  44. simstack/models/parameters.py +388 -0
  45. simstack/models/pickle_models.py +311 -0
  46. simstack/models/runner_model.py +39 -0
  47. simstack/models/simple_table.py +32 -0
  48. simstack/models/simstack_model.py +97 -0
  49. simstack/models/slurm_info.py +17 -0
  50. simstack/models/table_artifact.py +87 -0
  51. simstack/util/__init__.py +0 -0
  52. simstack/util/async_zip_utils.py +18 -0
  53. simstack/util/cleaned_json_schema.py +20 -0
  54. simstack/util/config_reader.py +273 -0
  55. simstack/util/custom_model_dump.py +75 -0
  56. simstack/util/db.py +457 -0
  57. simstack/util/db_logger.py +117 -0
  58. simstack/util/default_from_dict.py +57 -0
  59. simstack/util/directory_iterator.py +216 -0
  60. simstack/util/file_hashing.py +68 -0
  61. simstack/util/files.py +396 -0
  62. simstack/util/find_python_files.py +33 -0
  63. simstack/util/generate_ui_schema.py +80 -0
  64. simstack/util/geometry_utils.py +118 -0
  65. simstack/util/get_module_path.py +26 -0
  66. simstack/util/git_pull.py +65 -0
  67. simstack/util/git_repository_status.py +93 -0
  68. simstack/util/import_module.py +65 -0
  69. simstack/util/importer.py +163 -0
  70. simstack/util/make_graph.py +107 -0
  71. simstack/util/make_table.py +827 -0
  72. simstack/util/minimal_route_finder.py +72 -0
  73. simstack/util/mock_db.py +144 -0
  74. simstack/util/module_path_checker.py +38 -0
  75. simstack/util/path_manager.py +234 -0
  76. simstack/util/project_root_finder.py +36 -0
  77. simstack/util/queue_watcher.py +262 -0
  78. simstack/util/route_finder.py +74 -0
  79. simstack/util/runner_util.py +162 -0
  80. simstack/util/safe_code_executor.py +194 -0
  81. simstack/util/sample_config.py +98 -0
  82. simstack/util/sensitive_content_filter.py +48 -0
  83. simstack/util/setup_logging.py +49 -0
  84. simstack/util/submit_to_watchdog.py +106 -0
  85. simstack/util/ui_tools.py +213 -0
  86. simstack-0.2.0.dist-info/METADATA +361 -0
  87. simstack-0.2.0.dist-info/RECORD +91 -0
  88. simstack-0.2.0.dist-info/WHEEL +5 -0
  89. simstack-0.2.0.dist-info/entry_points.txt +10 -0
  90. simstack-0.2.0.dist-info/licenses/LICENSE +21 -0
  91. simstack-0.2.0.dist-info/top_level.txt +1 -0
simstack/core/node.py ADDED
@@ -0,0 +1,911 @@
1
+ import asyncio
2
+ import functools
3
+ import inspect
4
+ import logging
5
+ import os
6
+ from datetime import datetime
7
+ try:
8
+ from multiprocessing.reduction import duplicate
9
+ except ImportError:
10
+ import os
11
+
12
+
13
+ def duplicate(handle, target_process=None):
14
+ return os.dup(handle)
15
+
16
+ from pathlib import Path
17
+ from typing import Callable, Optional, TypeVar, cast, List, ParamSpec, Union, overload
18
+
19
+ import coolname
20
+ import nest_asyncio
21
+ from odmantic import Model, ObjectId
22
+
23
+ from simstack.core.artifacts import create_artifacts, ArtifactArguments
24
+ from simstack.core.context import context
25
+ from simstack.core.definitions import TaskStatus
26
+ from simstack.core.engine import current_engine_context
27
+ from simstack.core.hash import complex_hash_function
28
+ from simstack.core.node_runner import NodeRunner
29
+ from simstack.core.simstack_result import SimstackResult
30
+ from simstack.core.task_id import set_task_id, clear_task_id
31
+ from simstack.models import ModelMapping, Parameters
32
+ from simstack.models import NodeModel
33
+ from simstack.models import NodeRegistry
34
+ from simstack.models.file_list import FileListModel
35
+ from simstack.models.files import FileStack
36
+ from simstack.models.parameters import Resource
37
+ from simstack.models.simstack_model import is_simstack_model
38
+ from simstack.util.importer import import_function, import_class
39
+
40
+ logger = logging.getLogger("Node")
41
+
42
+ nest_asyncio.apply()
43
+
44
+ T = TypeVar("T")
45
+
46
+
47
+ def default_name_generator():
48
+ return "-".join(coolname.generate(2))
49
+
50
+
51
+ def hashable_inputs(arg):
52
+ """
53
+ Get the hashable inputs for the node. This allows exclusion of some fields from the hash.
54
+
55
+ Returns:
56
+ dict: The hashable inputs.
57
+ """
58
+ return {key: value for key, value in arg.__dict__.items() if key not in ["id"]}
59
+
60
+
61
+ def compute_arg_hash(args: List[Model]) -> str:
62
+ """
63
+ Computes a hash for a list of arguments provided, where each argument
64
+ is an instance of the Model class or can be processed into a hashable
65
+ format. Uses a complex hashing function for the resulting computation.
66
+
67
+ Parameters:
68
+ args: List[Model]
69
+ A list of objects where each object must be an instance of the
70
+ Model class. The objects are used to compute their respective
71
+ hash values via a specified complex hashing mechanism.
72
+
73
+ Returns:
74
+ str
75
+ A string representation of the computed hash for the provided
76
+ list of arguments.
77
+
78
+ Raises:
79
+ TypeError
80
+ If any item in the provided list is not an instance of the
81
+ Model class.
82
+ """
83
+ arg_hashes = []
84
+ for arg in args:
85
+ if not isinstance(arg, Model):
86
+ raise TypeError(f"Argument {arg} is not an instance of {Model}")
87
+ arg_hash = (
88
+ arg.complex_hash()
89
+ if hasattr(arg, "complex_hash")
90
+ else complex_hash_function(hashable_inputs(arg))
91
+ )
92
+ arg_hashes.append(arg_hash)
93
+ return complex_hash_function(arg_hashes)
94
+
95
+ class Node:
96
+ """
97
+ represents a computational task or node in the workflow, managing its lifecycle,
98
+ execution environment, and interactions with the database. This class helps in
99
+ defining tasks, storing their details, and ensuring they are executed either
100
+ locally or remotely, with their statuses tracked within a shared database
101
+ context.
102
+
103
+ :ivar name: The name of the function associated with the node.
104
+ :type name: str
105
+ :ivar custom_name: A custom name for the node, generated if not specified.
106
+ :type custom_name: str or None
107
+ :ivar registry_entry: The registry entry associated with the node in the database.
108
+ :type registry_entry: NodeRegistry or None
109
+ :ivar parent_id: The parent node's unique identifier, if applicable.
110
+ :type parent_id: ObjectId or None
111
+ :ivar parameters: Additional parameters for the node.
112
+ :type parameters: Parameters
113
+ :ivar _func: The function represented by the node.
114
+ :type _func: Callable[[Model], Model]
115
+ :ivar *args: The arguments passed to the function.
116
+ :type *args: List[Model]
117
+ """
118
+
119
+ def __init__(
120
+ self,
121
+ *args: List[Model],
122
+ **kwargs,
123
+ ):
124
+ super().__init__()
125
+
126
+ self._args = list(args) # Convert to list to allow appending
127
+
128
+ # Extract specific known parameters
129
+ self._func = kwargs.pop("func")
130
+ self.name = self._func.__name__
131
+ self.is_async = kwargs.pop("is_async")
132
+ self.parent_id = kwargs.pop("parent_id", None)
133
+ self.call_path = kwargs.pop("call_path", "")
134
+
135
+ # Get function signature to identify argument names
136
+ sig = inspect.signature(self._func)
137
+ param_names = list(sig.parameters.keys())
138
+
139
+ # Move kwargs that match function parameter names to args
140
+ for param_name in param_names:
141
+ if param_name in kwargs:
142
+ self._args.append(kwargs.pop(param_name))
143
+
144
+ if "custom_name" in kwargs:
145
+ self.custom_name = kwargs["custom_name"] # pass to children
146
+ else:
147
+ self.custom_name = default_name_generator()
148
+
149
+ self.parameters = kwargs.pop("parameters")
150
+ self.recompute_artifacts = kwargs.pop("recompute_artifacts", False) or (
151
+ self.parameters.recompute_artifacts or False
152
+ )
153
+
154
+ self._function_kwargs = (
155
+ kwargs # what is left over here must be kwargs of the function
156
+ )
157
+ self.registry_entry = None
158
+
159
+ @property
160
+ def id(self):
161
+ if self.registry_entry is None:
162
+ return None
163
+ else:
164
+ return self.registry_entry.id
165
+
166
+ @property
167
+ def status(self):
168
+ return getattr(self.registry_entry, "status", TaskStatus.FAILED)
169
+
170
+ async def make_registry_entry(self, function_hash, arg_hash) -> NodeRegistry:
171
+ """
172
+ Creates a registry entry for the node in the database.
173
+
174
+ This method is used to create a new entry in the database for the node,
175
+ including its inputs and outputs. It ensures that the task is properly
176
+ registered with all necessary details.
177
+
178
+ :rtype: NodeRegistry
179
+ """
180
+
181
+ function_mapping = await context.db.find_one(NodeModel, NodeModel.name == self.name)
182
+ if function_mapping is None:
183
+ logger.error(f"Could not find function mapping for name: {self.name}")
184
+ raise ValueError(f"Could not find function mapping for name: {self.name}")
185
+
186
+ input_ids = []
187
+ input_tables = []
188
+ for arg in self._args:
189
+ # if there is no table for an arg raise an error
190
+ input_table_name = await context.db.find_one(
191
+ ModelMapping, ModelMapping.name == arg.__class__.__name__
192
+ )
193
+ if input_table_name is None:
194
+ logger.error(f"Could not find table name for {arg.__class__.__name__}")
195
+ raise ValueError(
196
+ f"Could not find table name for {arg.__class__.__name__}"
197
+ )
198
+ if not isinstance(arg, Model):
199
+ logger.error(f"{arg.__class__.__name__} is not an odmantic Model")
200
+ raise ValueError(f"{arg.__class__.__name__} is not an odmantic Model")
201
+
202
+ argument_entry = await context.db.upsert(arg)
203
+
204
+ # Check if the upsert operation was successful and returned a valid ID
205
+ if argument_entry is None or argument_entry.id is None:
206
+ logger.error(
207
+ f"Failed to upsert argument {arg} - returned None or invalid ID"
208
+ )
209
+ raise ValueError(
210
+ f"Failed to upsert argument of type {arg.__class__.__name__}"
211
+ )
212
+
213
+ input_ids.append(argument_entry.id)
214
+ input_tables.append(input_table_name.mapping)
215
+
216
+ new_parameters = Parameters(**self.parameters.__dict__)
217
+
218
+ self.registry_entry = NodeRegistry(
219
+ name=self.name,
220
+ input_tables=input_tables,
221
+ input_ids=input_ids,
222
+ is_async=self.is_async,
223
+ status=TaskStatus.SUBMITTED,
224
+ custom_name=self.custom_name,
225
+ function_hash=function_hash,
226
+ arg_hash=arg_hash,
227
+ parent_ids=[] if self.parent_id is None else [self.parent_id],
228
+ parameters=new_parameters,
229
+ func_mapping=function_mapping.function_mapping,
230
+ call_path=self.call_path,
231
+ )
232
+ await context.db.upsert(self.registry_entry)
233
+ logger.info(
234
+ f"Task task_id: {self.id} with name {self.name} created for resource: {new_parameters.resource} queue: {new_parameters.queue}"
235
+ )
236
+ return self.registry_entry
237
+
238
+
239
+
240
+ async def get_node_registry(self) -> TaskStatus:
241
+ """
242
+ Reads or initializes the task registry entry in the database.
243
+
244
+ This method ensures that a task entry exists in the database for the
245
+ current task. It computes hashes of its arguments and function,
246
+ checks if a database entry already matches these hashes, and creates
247
+ a new entry if no match is found. If the database is not connected,
248
+ an exception is raised.
249
+
250
+ :raises ValueError: if the database is not connected.
251
+ :return: Status of the task retrieved or created.
252
+ :rtype: TaskStatus
253
+ """
254
+ if context.db is None:
255
+ raise ValueError("Database is not connected")
256
+
257
+ arg_hash = compute_arg_hash(self._args)
258
+ function_hash = complex_hash_function(self._func)
259
+
260
+ self.registry_entry = (
261
+ await context.db.load_task(self.name, arg_hash, function_hash)
262
+ if not self.parameters.force_rerun
263
+ else None
264
+ )
265
+
266
+ if self.registry_entry is None:
267
+ await self.make_registry_entry(function_hash, arg_hash)
268
+ else:
269
+ if self.parent_id:
270
+ logger.info(
271
+ f"Task task_id: {self.id} adding parent_id {self.parent_id} to task: {self.name}"
272
+ )
273
+ if isinstance(self.parent_id, str):
274
+ logger.error(
275
+ f"Task task_id: {self.id} parent_id is a string: {self.parent_id}"
276
+ )
277
+ self.parent_id = ObjectId(self.parent_id)
278
+ self.registry_entry.parent_ids.append(self.parent_id)
279
+ await context.db.save(self.registry_entry)
280
+ # whenever a task is found in the database we may have to redo all child artifacts because the children
281
+ # will not be loaded
282
+ if self.recompute_artifacts:
283
+ logger.info(
284
+ f"Task task_id: {self.id} recomputing artifacts for task: {self.name}"
285
+ )
286
+ from simstack.core.recompute_artifacts import recompute_artifacts
287
+
288
+ await recompute_artifacts(self.registry_entry)
289
+ else:
290
+ logger.warning(
291
+ f"Task task_id: {self.id} was found in the database with status: {self.registry_entry.status}. Terminating execution."
292
+ )
293
+
294
+ return self.registry_entry.status
295
+
296
+ async def load_results(self) -> Union[Model, SimstackResult, None]:
297
+ """
298
+ Loads the results associated with a specific task from the database. This
299
+ method verifies whether the task has valid result identifiers. If valid
300
+ identifiers (results_id and results_table_name) exist, it attempts to fetch
301
+ the outputs.
302
+
303
+ If the task status is not TaskStatus.COMPLETED None is returned which results in a RuntimeError
304
+ If the results are not found or if the retrieval process fails, a `ValueError` is raised.
305
+
306
+ :raises ValueError: If the task has completed but lacks output identifiers
307
+ (`results_id` or `results_table_name`), or if there is any error
308
+ during the process of loading the results.
309
+
310
+ :return: The retrieved task outputs from the database.
311
+ """
312
+ engine = current_engine_context.get()
313
+ logger.info(f"Task task_id: {self.id} loading results {self.status}")
314
+ try:
315
+ if self.registry_entry.status != TaskStatus.COMPLETED:
316
+ return None
317
+ if len(self.registry_entry.result_tables) == 0:
318
+ logger.warning(f"Task task_id: {self.id} completed but has no outputs")
319
+ if len(self.registry_entry.result_tables) != len(
320
+ self.registry_entry.result_ids
321
+ ):
322
+ raise ValueError(f"Task task_id: {self.id} has inconsistent results")
323
+ simstack_result = SimstackResult(status=self.registry_entry.status)
324
+ result = None
325
+ for result_name, table_name, table_id in zip(
326
+ self.registry_entry.result_names,
327
+ self.registry_entry.result_tables,
328
+ self.registry_entry.result_ids,
329
+ ):
330
+ model = await import_class(table_name)
331
+ result = await engine.find_one(model, model.id == table_id)
332
+ if result is None:
333
+ await self.set_status(TaskStatus.FAILED)
334
+ logger.error(
335
+ f"Task task_id: {self.id} could not find result with id {table_id} in table {table_name}"
336
+ )
337
+ raise ValueError(
338
+ f"Task task_id: {self.id} could not find result with id {table_id} in table {table_name}"
339
+ )
340
+ simstack_result.__setattr__(result_name, result)
341
+
342
+ logger.info(f"Task task_id: {self.id} loaded outputs")
343
+
344
+ if len(self.registry_entry.result_tables) == 1:
345
+ return result # there is only one result, return it directly
346
+ else:
347
+ return simstack_result # return the SimstackResult with all results
348
+
349
+ except Exception as e:
350
+ await self.set_status(TaskStatus.FAILED)
351
+ logger.exception(f"Task task_id: {self.id} failed to load outputs: {e}")
352
+ raise ValueError(f"Task task_id: {self.id} failed to load outputs: {e}")
353
+
354
+ async def run_somewhere(self) -> Union[Model, SimstackResult, None]:
355
+ """
356
+ Executes the task either locally or on a remote resource. This function ensures that
357
+ if the task is meant to execute on a remote resource, it waits for the task to complete
358
+ remotely and fetches its results. If the task executes locally, it directly runs the task
359
+ and retrieves the results.
360
+
361
+ If any exception occurs during the execution, the status is updated to `FAILED`
362
+ and the exception is logged.
363
+
364
+ :return: A single Model or a list of Model instances of the task results or None. If the task
365
+ is not completed successfully.
366
+ :rtype: Model | SimstackResult
367
+
368
+ :raises RunTimeError: When task execution fails due to an unexpected exception.
369
+ """
370
+ resource_self = Resource(value="self")
371
+
372
+ logger.info(
373
+ f"Task task_id: {self.id} run_somewhere context resource: {context.config.resource} target resource: {self.parameters.resource}"
374
+ )
375
+ if (
376
+ self.parameters.resource == resource_self
377
+ or context.config.resource == self.parameters.resource
378
+ ):
379
+ result = await self.execute_node_locally()
380
+ return result
381
+ else:
382
+ # the task will be executed somewhere else
383
+ # wait for the database status to change
384
+ while True:
385
+ new_registry_entry = await context.db.load_task_by_id(self.id)
386
+ # TODO add timeout mechanism here
387
+ new_status = new_registry_entry.status
388
+ if (
389
+ new_status != TaskStatus.RUNNING
390
+ and new_status != TaskStatus.SUBMITTED
391
+ and new_status != TaskStatus.SLURM_QUEUED
392
+ and new_status != TaskStatus.SLURM_RUNNING
393
+ ):
394
+ break
395
+
396
+ print(f"Task task_id: {self.id} is waiting for results")
397
+ await asyncio.sleep(5)
398
+
399
+ if new_status == TaskStatus.COMPLETED:
400
+ logger.info(f"Task task_id: {self.id} completed remotely")
401
+ self.registry_entry = new_registry_entry
402
+ return await self.load_results()
403
+ else:
404
+ return None
405
+
406
+ async def execute_node_locally(self) -> Union[Model, SimstackResult, None]:
407
+ """
408
+ Executes a specified node in the current context locally, either asynchronously or
409
+ synchronously, managing task status updates, directory changes, and result persistence.
410
+
411
+ This method handles the execution of a computational task represented as a "node". It
412
+ manages the task's status transitions, file system operations for managing working
413
+ directories, and handling output results, including their persistence in a database.
414
+ The method supports both asynchronous and synchronous node execution. It verifies
415
+ results, handles exceptions, and manages task metadata updates.
416
+
417
+ Nodes can either return
418
+ * a single `Model` instance
419
+ * a `SimstackResult` instance
420
+ * None (for failure).
421
+ * a boolean value (for failure or success if there are no results)
422
+
423
+ There is a try-except block around the actual execution of the node which generates a log entry
424
+ "node function error for node" that catches all uncaught exceptions within the node. These error are
425
+ not propagated, but the task status is set to TaskStatus.FAILED.
426
+
427
+ :param self: Instance of the class invoking this method.
428
+
429
+ :raises Exception: for failures of the Simstack logic
430
+
431
+ :return: The processed result of the node execution. Depending on the task's output,
432
+ it could be of the type `Model`, `SimstackResult`, or be None if no valid result
433
+ was produced.
434
+
435
+ """
436
+ self.registry_entry.started_at = datetime.now()
437
+ await self.set_status(TaskStatus.RUNNING)
438
+ logger.info(f"Task task_id: {self.id} is started on {self.parameters.resource}")
439
+ original_dir = Path.cwd()
440
+ try:
441
+ node_runner = NodeRunner(self._func.__name__, None, task_id=self.id)
442
+ node_kwargs = {
443
+ "node_runner": node_runner,
444
+ "parent_id": self.id,
445
+ "task_id": self.id,
446
+ "call_path": self.call_path,
447
+ "parent_parameters": self.parameters, # this must have a name different from parameters, because
448
+ # otherwise this setting will override all the parameters of
449
+ # the child nodes
450
+ "recompute_artifacts": self.recompute_artifacts,
451
+ "custom_name": self.custom_name,
452
+ }
453
+
454
+ if self.parameters.force_rerun:
455
+ node_kwargs["force_rerun"] = True
456
+
457
+ path = Path(context.config.workdir) / self.name / str(self.id)
458
+ # Create the directory if it doesn't exist
459
+ path.mkdir(parents=True, exist_ok=True)
460
+ os.chdir(path)
461
+ logger.info(
462
+ f"Task task_id: {self.id} successfully changed to directory: {path.absolute()}"
463
+ )
464
+
465
+ # real_func_wrapper = await import_function_by_name(self._func.__name__,self.id)
466
+ # real_func = getattr(real_func_wrapper, '_inner', real_func_wrapper)
467
+ real_func = self._func
468
+ result = None
469
+ set_task_id(self.registry_entry.id)
470
+ try:
471
+ if self.is_async:
472
+ result = await real_func(*self._args, **node_kwargs)
473
+ else:
474
+ result = real_func(*self._args, **node_kwargs)
475
+ except Exception as e:
476
+ logger.exception(
477
+ f"Task task_id: {self.id} node function error for node: {self.name} msg: {str(e)}"
478
+ )
479
+ # save what we can, in particular the info_files
480
+ await self.process_results(node_runner)
481
+ await self.set_status(TaskStatus.FAILED)
482
+ raise
483
+ finally:
484
+ clear_task_id()
485
+
486
+ self.registry_entry.completed_at = datetime.now()
487
+
488
+ new_task_status, result = await self.process_results(result)
489
+
490
+ if new_task_status == TaskStatus.COMPLETED:
491
+ artifact_arguments = ArtifactArguments(result, self.id)
492
+ artifact_arguments.add_attributes(
493
+ self._func, *self._args, **node_kwargs
494
+ )
495
+ self.registry_entry.artifact_ids = await create_artifacts(
496
+ artifact_arguments, self.registry_entry
497
+ )
498
+ await self.set_status(
499
+ new_task_status
500
+ ) # this will also commit the registry entry
501
+
502
+ logger.info(
503
+ f"Task task_id: {self.id} is finished on resource: {self.parameters.resource} with task status: {new_task_status}"
504
+ )
505
+ # code in 'finally' will be executed anyway
506
+ return result
507
+ except Exception:
508
+ await self.set_status(TaskStatus.FAILED)
509
+ raise
510
+ finally:
511
+ os.chdir(original_dir)
512
+ logger.debug(
513
+ f"Task task_id: {self.id} successfully back to directory: {original_dir.absolute()}"
514
+ )
515
+
516
+ async def process_results(self, result):
517
+ # each of the following if sets the result either to a valid value or None
518
+ new_task_status = TaskStatus.COMPLETED
519
+ if result is None:
520
+ logger.warning(f"Task task_id: {self.id} returned None")
521
+ new_task_status = TaskStatus.FAILED # result is None
522
+ elif isinstance(result, bool):
523
+ if not result:
524
+ new_task_status = TaskStatus.FAILED
525
+ result = None
526
+ elif is_simstack_model(result): # backward compatibility
527
+ result_model = await context.db.upsert(result)
528
+ result_table_name = await context.db.find_one(
529
+ ModelMapping, ModelMapping.name == result.__class__.__name__
530
+ )
531
+ if result_table_name is None:
532
+ logger.error(
533
+ f"Task task_id: {self.id} could not find table name for {result.__class__.__name__}"
534
+ )
535
+ raise ValueError(
536
+ f"Could not find table name for {result.__class__.__name__}"
537
+ )
538
+ self.registry_entry.result_ids = [result_model.id]
539
+ self.registry_entry.result_tables = [result_table_name.mapping]
540
+ self.registry_entry.result_names = [result.__class__.__name__]
541
+ if hasattr(result, "task_status"):
542
+ new_task_status = result.task_status
543
+ elif isinstance(result, SimstackResult):
544
+ # it is possible that the task failed internally but returned logging info which we process anyway
545
+ new_task_status = result.status
546
+ result_ids = []
547
+ result_tables = []
548
+ result_models = []
549
+ result_names = []
550
+
551
+ if hasattr(result, "custom_name"):
552
+ self.registry_entry.custom_name = result.custom_name
553
+
554
+ # check if there are files in the result
555
+ if len(result.files) > 0:
556
+ file_list_model = (
557
+ FileListModel()
558
+ ) # this goes into the results must be a model
559
+ for file_stack in result.files:
560
+ if file_stack:
561
+ if isinstance(file_stack, FileStack):
562
+ logger.info(
563
+ f"Task task_id: {self.id} saving file: {file_stack.name} {file_stack.id}"
564
+ )
565
+ saved = await context.db.save(file_stack)
566
+ file_list_model.append(saved)
567
+ else:
568
+ logger.error(
569
+ f"Task task_id: {self.id} cannot save info_file: FileStack expected but got {type(file_stack)}"
570
+ )
571
+ raise ValueError(
572
+ f"Task task_id: {self.id} cannot save info_file: FileStack expected but got {type(file_stack)}"
573
+ )
574
+ else:
575
+ logger.error(f"Task task_id: {self.id} saving file is NONE")
576
+ raise ValueError("saving file is NONE")
577
+
578
+ result_table_name = await context.db.find_one(
579
+ ModelMapping, ModelMapping.name == FileListModel.__name__
580
+ )
581
+ if result_table_name is None:
582
+ logger.error(
583
+ f"Task task_id: {self.id} could not find table name for {FileListModel.__name__}"
584
+ )
585
+ raise ValueError(
586
+ f"Could not find table name for {FileListModel.__name__}"
587
+ )
588
+ result_tables.append(result_table_name.mapping)
589
+ result_names.append("files")
590
+ saved = await context.db.save(file_list_model)
591
+ result_ids.append(saved.id)
592
+ result_models.append(saved)
593
+
594
+ for file_stack in result.info_files:
595
+ if file_stack:
596
+ if isinstance(file_stack, FileStack):
597
+ saved = await context.db.save(file_stack)
598
+ logger.info(
599
+ f"Task task_id: {self.id} saving info file: {file_stack.name} {file_stack.id}"
600
+ )
601
+ self.registry_entry.info_files.append(saved)
602
+ else:
603
+ logger.error(
604
+ f"Task task_id: {self.id} cannot save info_file: FileStack expected but got {type(file_stack)}"
605
+ )
606
+ else:
607
+ logger.error(f"Task task_id: {self.id} saving info-file is NONE")
608
+ raise ValueError("saving info file is NONE")
609
+
610
+ for key, value in getattr(result, "__pydantic_extra__", {}).items():
611
+ if not callable(value) and is_simstack_model(value):
612
+ if isinstance(value, Model):
613
+ result_model = await context.db.upsert(value)
614
+ result_models.append(result_model)
615
+ result_ids.append(result_model.id)
616
+ result_names.append(key)
617
+ result_table_name = await context.db.find_one(
618
+ ModelMapping, ModelMapping.name == value.__class__.__name__
619
+ )
620
+ if result_table_name is None:
621
+ logger.error(
622
+ f"Task task_id: {self.id} could not find table name for {value.__class__.__name__}"
623
+ )
624
+ raise ValueError(
625
+ f"Could not find table name for {value.__class__.__name__}"
626
+ )
627
+ result_tables.append(result_table_name.mapping)
628
+ else:
629
+ raise ValueError(
630
+ f"task_id: {self.id} cannot save model: {key} is not a model"
631
+ )
632
+
633
+ self.registry_entry.result_ids = result_ids
634
+ self.registry_entry.result_tables = result_tables
635
+ self.registry_entry.result_names = result_names
636
+
637
+ if result.error_message is not None and result.error_message != "":
638
+ logger.error(
639
+ f"Task task_id: {self.id} returned with error: {result.error_message}"
640
+ )
641
+ if result.message is not None and result.message != "":
642
+ logger.info(f"Task task_id: {self.id} message: {result.message}")
643
+ if len(result_ids) == 1:
644
+ result = result_models[
645
+ 0
646
+ ] # this is a SimstackResult with just one returned model
647
+ return new_task_status, result
648
+
649
+ async def set_status(self, status: TaskStatus):
650
+ if self.registry_entry is None:
651
+ raise ValueError("Task has no registry entry")
652
+ if isinstance(status, TaskStatus):
653
+ self.registry_entry.status = status
654
+ else:
655
+ logger.warning(f"Task task_id: {self.id} {status} is not a TaskStatus")
656
+ self.registry_entry.status = TaskStatus(status)
657
+ engine = current_engine_context.get()
658
+ await engine.save(self.registry_entry)
659
+ logger.info(f"Task task_id: {self.id} is set to {status}")
660
+
661
+
662
+ async def node_from_database(registry_entry: NodeRegistry) -> Union["Node", None]:
663
+ """
664
+ Constructs an instance of the class from database information encoded in a
665
+ registry entry.
666
+
667
+ This method retrieves input arguments and the serialized function from the
668
+ database using information provided in the `registry_entry`. It then deserializes
669
+ the function and initializes a corresponding Node instance, associating it
670
+ with the given registry entry.
671
+
672
+ This function can delete the registry_entry !!!
673
+ The only way that registry_entry.function_hash is "NOT INITIALIZED" is when the node
674
+ is created from the frontend. No other node is listening specifically for this registry_entry to complete.
675
+ If a duplicate is found the node from the duplication is returned
676
+
677
+ :param registry_entry: The registry entry containing information necessary to
678
+ reconstruct the Node instance. Includes input table names, function pickled
679
+ as a string, and other metadata.
680
+ :type registry_entry: NodeRegistry
681
+
682
+ :return: A reconstructed Node instance based on the registry entry, or None if
683
+ the deserialized function is not valid or there was an error.
684
+ :rtype: Optional[Node]
685
+ """
686
+ args = []
687
+ engine = current_engine_context.get()
688
+
689
+ for table, table_id in zip(registry_entry.input_tables, registry_entry.input_ids):
690
+ try:
691
+ model = await import_class(table)
692
+ arg = await engine.find_one(model, model.id == table_id)
693
+ args.append(arg)
694
+ except Exception as e:
695
+ logger.exception(
696
+ f"Task task_id: {registry_entry.id} failed to load input {table} with id {table_id}: {str(e)}"
697
+ )
698
+ return None
699
+
700
+ if registry_entry.arg_hash == "NOT INITIALIZED":
701
+ logger.debug(f"Task task_id: {registry_entry.id} computes arg hashes")
702
+ registry_entry.arg_hash = compute_arg_hash(args)
703
+
704
+ logger.debug(f"Task task_id: {registry_entry.id} loaded {len(args)} inputs")
705
+ try:
706
+ wrapped_func = await import_function(
707
+ registry_entry.func_mapping, registry_entry.id
708
+ )
709
+ if wrapped_func is None:
710
+ logger.error(f"Task task_id: {registry_entry.id} could not import function {registry_entry.func_mapping}")
711
+ return None
712
+ # for nodes the mapping points to the wrapped func to we use that
713
+ func = (wrapped_func if not hasattr(wrapped_func, "_inner") else wrapped_func._inner)
714
+ logger.info(
715
+ f"Task task_id: {registry_entry.id} inner: {hasattr(wrapped_func, '_inner')} imported function: {func.__name__}"
716
+ )
717
+ if registry_entry.function_hash == "NOT INITIALIZED":
718
+ registry_entry.function_hash = complex_hash_function(func)
719
+ registry_entry.is_async = asyncio.iscoroutinefunction(func)
720
+ duplicate_entry = await engine.find_one(
721
+ NodeRegistry,
722
+ (NodeRegistry.name == registry_entry.name)
723
+ & (NodeRegistry.arg_hash == registry_entry.arg_hash)
724
+ & (NodeRegistry.function_hash == registry_entry.function_hash))
725
+ await engine.save(registry_entry) # save the fixed entry AFTER checking for duplicates
726
+ # the calling function may have the originial entry unsaved !
727
+ if duplicate_entry is not None:
728
+ logger.info(f"Original Entry: {duplicate_entry.id} {duplicate_entry.arg_hash} {duplicate_entry.function_hash}")
729
+ logger.info(f"Current Entry: {registry_entry.id} {registry_entry.arg_hash} {registry_entry.function_hash} ")
730
+ logger.info(f"Task task_id: {registry_entry.id} found duplicate entry {duplicate_entry.id} {duplicate_entry.name}")
731
+ if duplicate_entry.id == registry_entry.id:
732
+ logger.error(f"Task task_id: {registry_entry.id} recovered itself. This should not happen")
733
+
734
+ if duplicate_entry.id != registry_entry.id:
735
+ await engine.delete(registry_entry)
736
+ registry_entry = duplicate_entry
737
+
738
+ except Exception as e:
739
+ logger.exception(
740
+ f"Task task_id: {registry_entry.id} failed to import function {registry_entry.func_mapping} {str(e)}"
741
+ )
742
+ return None
743
+
744
+ kwargs = {
745
+ "func": func,
746
+ "is_async": False,
747
+ "call_path": registry_entry.call_path,
748
+ "parameters": registry_entry.parameters,
749
+ "custom_name": registry_entry.custom_name,
750
+ }
751
+ if hasattr(registry_entry, "is_async"):
752
+ kwargs["is_async"] = registry_entry.is_async
753
+
754
+ kwargs["parent_id"] = (
755
+ registry_entry.parent_ids[0] if registry_entry.parent_ids else None
756
+ )
757
+ logger.info(
758
+ f"Task task_id: {registry_entry.id} is_async: {kwargs['is_async']} parent_id: {kwargs['parent_id']}"
759
+ )
760
+
761
+ new_node = Node(*args, **kwargs)
762
+ new_node.registry_entry = registry_entry
763
+ return new_node
764
+
765
+
766
+ # Add a return type annotation for async functions
767
+ # T_co = TypeVar("T_co", covariant=True)
768
+ #
769
+ # # Create overloaded function type annotations
770
+ # @overload
771
+ # def node(
772
+ # _func: Callable[..., Awaitable[T_co]],
773
+ # ) -> Callable[..., Awaitable[T_co]]: ...
774
+ #
775
+ # @overload
776
+ # def node(
777
+ # _func: Callable[..., T_co],
778
+ # ) -> Callable[..., T_co]: ...
779
+ #
780
+ # @overload
781
+ # def node(
782
+ # _func: None = None,
783
+ # *,
784
+ # name: Optional[str] = None,
785
+ # version: Optional[str] = None,
786
+ # cache: bool = True,
787
+ # **kwargs_node,
788
+ # ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: ...
789
+
790
+
791
+ P = ParamSpec("P")
792
+
793
+
794
+ @overload
795
+ def node(_func: Callable[P, T]) -> Callable[..., T]:
796
+ ...
797
+
798
+
799
+ @overload
800
+ def node(
801
+ _func: None = None,
802
+ *,
803
+ name: Optional[str] = None,
804
+ version: Optional[str] = None,
805
+ cache: bool = True,
806
+ **kwargs_node,
807
+ ) -> Callable[[Callable[P, T]], Callable[..., T]]:
808
+ ...
809
+
810
+
811
+ def node(
812
+ _func: Optional[Callable[P, T]] = None,
813
+ *,
814
+ version: Optional[str] = None,
815
+ **kwargs_node,
816
+ ) -> Union[Callable[..., T], Callable[[Callable[P, T]], Callable[..., T]]]:
817
+ """
818
+ Decorator to mark a function as a node in the computation graph.
819
+ Supports both synchronous and asynchronous functions.
820
+ Can be used with or without parameters:
821
+ @node
822
+ def func(): ...
823
+
824
+ @node(name="example")
825
+ def func(): ...
826
+
827
+ """
828
+
829
+ def decorator(func: Callable[P, T]) -> Callable[..., T]:
830
+ is_async = asyncio.iscoroutinefunction(func)
831
+
832
+ func._is_node = True
833
+ func._inner = func
834
+ func._node_parameters = kwargs_node.get("parameters", Parameters())
835
+
836
+ def update_kwargs(kwargs):
837
+ kwargs["func"] = func
838
+ kwargs["is_async"] = is_async
839
+ kwargs["parameters"] = kwargs.pop(
840
+ "parameters", kwargs_node.get("parameters", Parameters())
841
+ )
842
+ kwargs["custom_name"] = kwargs_node.get(
843
+ "custom_name", default_name_generator()
844
+ )
845
+ call_path = kwargs.pop("call_path", "")
846
+ if not call_path:
847
+ call_path = ""
848
+ logger.debug(f"Task call_path: {call_path} {func.__name__}")
849
+ # Fix call path construction - handle empty call_path for root nodes
850
+ if call_path == "":
851
+ call_path = "." + func.__name__
852
+ else:
853
+ call_path = call_path + "." + func.__name__
854
+ kwargs["call_path"] = call_path
855
+
856
+ @functools.wraps(func)
857
+ async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
858
+ update_kwargs(kwargs)
859
+ # Create Node with the remaining kwargs
860
+ execution_node = Node(*args, **kwargs)
861
+
862
+ status = await execution_node.get_node_registry()
863
+ result = None
864
+ if status == TaskStatus.COMPLETED:
865
+ result = await execution_node.load_results()
866
+ elif status == TaskStatus.SUBMITTED:
867
+ result = await execution_node.run_somewhere()
868
+ if result is None or execution_node.status != TaskStatus.COMPLETED:
869
+ raise RuntimeError(
870
+ f"Task task_id: {execution_node.id} node: {execution_node.name} terminated with status {execution_node.status}"
871
+ )
872
+ return result
873
+
874
+ @functools.wraps(func)
875
+ def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
876
+ import asyncio
877
+
878
+ update_kwargs(kwargs)
879
+ # Create Node with the remaining kwargs
880
+ execution_node = Node(*args, **kwargs)
881
+
882
+ # If it's an async function but called in a sync context, run it in the event loop
883
+ loop = asyncio.get_event_loop()
884
+ status = loop.run_until_complete(execution_node.get_node_registry())
885
+ result = None
886
+ if status == TaskStatus.COMPLETED:
887
+ return loop.run_until_complete(execution_node.load_results())
888
+ elif status == TaskStatus.SUBMITTED:
889
+ return loop.run_until_complete(execution_node.run_somewhere())
890
+ if result is None or execution_node.status != TaskStatus.COMPLETED:
891
+ raise RuntimeError(
892
+ f"Task task_id: {execution_node.id} node: {execution_node.name} terminated with status {execution_node.status}"
893
+ )
894
+ return result
895
+
896
+ async_wrapper.is_node = True
897
+ sync_wrapper.is_node = True
898
+ # Return the appropriate wrapper based on whether the function is async
899
+ if is_async:
900
+ return cast(Callable[..., T], async_wrapper)
901
+ else:
902
+ return cast(Callable[..., T], sync_wrapper)
903
+
904
+ decorator.is_node = True
905
+
906
+ if _func is None:
907
+ # Called with parameters: @node(...)
908
+ return decorator
909
+ else:
910
+ # Called without parameters: @node
911
+ return decorator(_func)