simstack 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (354) hide show
  1. simstack/__init__.py +0 -0
  2. simstack/_version.py +24 -0
  3. simstack/core/__init__.py +0 -0
  4. simstack/core/artifacts.py +406 -0
  5. simstack/core/asnyc_helper.py +14 -0
  6. simstack/core/config_file.py +60 -0
  7. simstack/core/context.py +283 -0
  8. simstack/core/definitions.py +22 -0
  9. simstack/core/engine.py +115 -0
  10. simstack/core/find_simstack_modules.py +41 -0
  11. simstack/core/hash.py +179 -0
  12. simstack/core/node.py +1137 -0
  13. simstack/core/node_claim.py +31 -0
  14. simstack/core/node_runner.py +362 -0
  15. simstack/core/process_results.py +102 -0
  16. simstack/core/recompute_artifacts.py +117 -0
  17. simstack/core/reset_db.py +12 -0
  18. simstack/core/resource_assignment.py +222 -0
  19. simstack/core/resources.py +90 -0
  20. simstack/core/route_table.py +24 -0
  21. simstack/core/run_docker.py +109 -0
  22. simstack/core/run_node.py +71 -0
  23. simstack/core/runner.py +169 -0
  24. simstack/core/services/__init__.py +11 -0
  25. simstack/core/services/base_service.py +191 -0
  26. simstack/core/services/file_transfer_service.py +117 -0
  27. simstack/core/services/git_restart_service.py +12 -0
  28. simstack/core/services/git_uv_update_service.py +105 -0
  29. simstack/core/services/node_execution_service.py +182 -0
  30. simstack/core/services/resource_branch_monitor_service.py +71 -0
  31. simstack/core/services/runner_cleanup_service.py +149 -0
  32. simstack/core/services/runner_manager.py +163 -0
  33. simstack/core/services/runner_status_service.py +10 -0
  34. simstack/core/services/slurm_status_service.py +67 -0
  35. simstack/core/services/stop_check_service.py +27 -0
  36. simstack/core/services/timeout_restart_service.py +26 -0
  37. simstack/core/simstack_result.py +39 -0
  38. simstack/core/submit_node.py +217 -0
  39. simstack/core/task_id.py +22 -0
  40. simstack/methods/__init__.py +0 -0
  41. simstack/methods/archive_files.py +292 -0
  42. simstack/methods/fire_and_forget_runner.py +113 -0
  43. simstack/methods/generate_test.py +125 -0
  44. simstack/methods/get_file.py +26 -0
  45. simstack/methods/mass_runner.py +154 -0
  46. simstack/methods/multiple_executor.py +307 -0
  47. simstack/methods/switch_git.py +16 -0
  48. simstack/methods/upload_helpers.py +81 -0
  49. simstack/models/__init__.py +74 -0
  50. simstack/models/array_list.py +21 -0
  51. simstack/models/array_storage.py +83 -0
  52. simstack/models/artifact_models.py +127 -0
  53. simstack/models/base_lists.py +51 -0
  54. simstack/models/base_types.py +209 -0
  55. simstack/models/charts_artifact.py +690 -0
  56. simstack/models/dataset.py +451 -0
  57. simstack/models/dataset_metadata.py +390 -0
  58. simstack/models/datasettuple.py +587 -0
  59. simstack/models/file_instance.py +150 -0
  60. simstack/models/file_list.py +45 -0
  61. simstack/models/files.py +424 -0
  62. simstack/models/fire_and_forget_result.py +19 -0
  63. simstack/models/images2d.py +113 -0
  64. simstack/models/log_entry_model.py +37 -0
  65. simstack/models/models.py +52 -0
  66. simstack/models/named_data_reference.py +36 -0
  67. simstack/models/node_registry.py +96 -0
  68. simstack/models/pandas_model.py +239 -0
  69. simstack/models/parameters.py +402 -0
  70. simstack/models/pickle_models.py +300 -0
  71. simstack/models/project.py +13 -0
  72. simstack/models/resource_assignment.py +170 -0
  73. simstack/models/resource_definition.py +125 -0
  74. simstack/models/runner_model.py +39 -0
  75. simstack/models/simple_table.py +79 -0
  76. simstack/models/simstack_model.py +97 -0
  77. simstack/models/slurm_info.py +17 -0
  78. simstack/models/table_artifact.py +87 -0
  79. simstack/models/tag.py +12 -0
  80. simstack/models/unused_test_file.py +5 -0
  81. simstack/simstack_marker.com +1 -0
  82. simstack/static/docs/.buildinfo +4 -0
  83. simstack/static/docs/_images/GUI Concept.webp +0 -0
  84. simstack/static/docs/_modules/index.html +434 -0
  85. simstack/static/docs/_modules/simstack/core/artifacts.html +743 -0
  86. simstack/static/docs/_modules/simstack/core/asnyc_helper.html +330 -0
  87. simstack/static/docs/_modules/simstack/core/config_file.html +379 -0
  88. simstack/static/docs/_modules/simstack/core/definitions.html +341 -0
  89. simstack/static/docs/_modules/simstack/core/engine.html +437 -0
  90. simstack/static/docs/_modules/simstack/core/find_simstack_modules.html +360 -0
  91. simstack/static/docs/_modules/simstack/core/hash.html +528 -0
  92. simstack/static/docs/_modules/simstack/core/node.html +1392 -0
  93. simstack/static/docs/_modules/simstack/core/node_claim.html +347 -0
  94. simstack/static/docs/_modules/simstack/core/node_runner.html +711 -0
  95. simstack/static/docs/_modules/simstack/core/process_results.html +418 -0
  96. simstack/static/docs/_modules/simstack/core/recompute_artifacts.html +433 -0
  97. simstack/static/docs/_modules/simstack/core/reset_db.html +328 -0
  98. simstack/static/docs/_modules/simstack/core/resource_assignment.html +553 -0
  99. simstack/static/docs/_modules/simstack/core/resources.html +424 -0
  100. simstack/static/docs/_modules/simstack/core/route_table.html +349 -0
  101. simstack/static/docs/_modules/simstack/core/run_docker.html +425 -0
  102. simstack/static/docs/_modules/simstack/core/run_node.html +390 -0
  103. simstack/static/docs/_modules/simstack/core/runner.html +491 -0
  104. simstack/static/docs/_modules/simstack/core/services/base_service.html +522 -0
  105. simstack/static/docs/_modules/simstack/core/services/file_transfer_service.html +436 -0
  106. simstack/static/docs/_modules/simstack/core/services/git_restart_service.html +331 -0
  107. simstack/static/docs/_modules/simstack/core/services/git_uv_update_service.html +427 -0
  108. simstack/static/docs/_modules/simstack/core/services/node_execution_service.html +507 -0
  109. simstack/static/docs/_modules/simstack/core/services/resource_branch_monitor_service.html +390 -0
  110. simstack/static/docs/_modules/simstack/core/services/runner_cleanup_service.html +468 -0
  111. simstack/static/docs/_modules/simstack/core/services/runner_manager.html +485 -0
  112. simstack/static/docs/_modules/simstack/core/services/runner_status_service.html +329 -0
  113. simstack/static/docs/_modules/simstack/core/services/slurm_status_service.html +386 -0
  114. simstack/static/docs/_modules/simstack/core/services/stop_check_service.html +346 -0
  115. simstack/static/docs/_modules/simstack/core/services/timeout_restart_service.html +345 -0
  116. simstack/static/docs/_modules/simstack/core/simstack_result.html +355 -0
  117. simstack/static/docs/_modules/simstack/core/submit_node.html +536 -0
  118. simstack/static/docs/_modules/simstack/core/task_id.html +344 -0
  119. simstack/static/docs/_modules/simstack/methods/archive_files.html +598 -0
  120. simstack/static/docs/_modules/simstack/methods/fire_and_forget_runner.html +432 -0
  121. simstack/static/docs/_modules/simstack/methods/generate_test.html +443 -0
  122. simstack/static/docs/_modules/simstack/methods/get_file.html +342 -0
  123. simstack/static/docs/_modules/simstack/methods/mass_runner.html +476 -0
  124. simstack/static/docs/_modules/simstack/methods/multiple_executor.html +674 -0
  125. simstack/static/docs/_modules/simstack/methods/switch_git.html +332 -0
  126. simstack/static/docs/_modules/simstack/methods/upload_helpers.html +403 -0
  127. simstack/static/docs/_modules/simstack/models/array_list.html +337 -0
  128. simstack/static/docs/_modules/simstack/models/array_storage.html +417 -0
  129. simstack/static/docs/_modules/simstack/models/base_lists.html +374 -0
  130. simstack/static/docs/_modules/simstack/models/base_types.html +588 -0
  131. simstack/static/docs/_modules/simstack/models/charts_artifact.html +1035 -0
  132. simstack/static/docs/_modules/simstack/models/dataset.html +866 -0
  133. simstack/static/docs/_modules/simstack/models/dataset_metadata.html +711 -0
  134. simstack/static/docs/_modules/simstack/models/datasettuple.html +1005 -0
  135. simstack/static/docs/_modules/simstack/models/file_instance.html +472 -0
  136. simstack/static/docs/_modules/simstack/models/file_list.html +367 -0
  137. simstack/static/docs/_modules/simstack/models/files.html +773 -0
  138. simstack/static/docs/_modules/simstack/models/fire_and_forget_result.html +335 -0
  139. simstack/static/docs/_modules/simstack/models/images2d.html +447 -0
  140. simstack/static/docs/_modules/simstack/models/log_entry_model.html +356 -0
  141. simstack/static/docs/_modules/simstack/models/models.html +374 -0
  142. simstack/static/docs/_modules/simstack/models/named_data_reference.html +355 -0
  143. simstack/static/docs/_modules/simstack/models/node_registry.html +415 -0
  144. simstack/static/docs/_modules/simstack/models/pandas_model.html +570 -0
  145. simstack/static/docs/_modules/simstack/models/parameters.html +739 -0
  146. simstack/static/docs/_modules/simstack/models/pickle_models.html +631 -0
  147. simstack/static/docs/_modules/simstack/models/project.html +329 -0
  148. simstack/static/docs/_modules/simstack/models/resource_assignment.html +501 -0
  149. simstack/static/docs/_modules/simstack/models/resource_definition.html +477 -0
  150. simstack/static/docs/_modules/simstack/models/runner_model.html +361 -0
  151. simstack/static/docs/_modules/simstack/models/simple_table.html +413 -0
  152. simstack/static/docs/_modules/simstack/models/simstack_model.html +416 -0
  153. simstack/static/docs/_modules/simstack/models/slurm_info.html +333 -0
  154. simstack/static/docs/_modules/simstack/models/table_artifact.html +406 -0
  155. simstack/static/docs/_modules/simstack/models/tag.html +328 -0
  156. simstack/static/docs/_modules/simstack/tables/model_table.html +519 -0
  157. simstack/static/docs/_modules/simstack/tables/node_children.html +538 -0
  158. simstack/static/docs/_modules/simstack/tables/node_table.html +854 -0
  159. simstack/static/docs/_modules/simstack/tables/table_builder.html +642 -0
  160. simstack/static/docs/_modules/simstack/util/async_zip_utils.html +337 -0
  161. simstack/static/docs/_modules/simstack/util/b64mixin.html +339 -0
  162. simstack/static/docs/_modules/simstack/util/cleaned_json_schema.html +336 -0
  163. simstack/static/docs/_modules/simstack/util/config_reader.html +498 -0
  164. simstack/static/docs/_modules/simstack/util/custom_model_dump.html +391 -0
  165. simstack/static/docs/_modules/simstack/util/database_information.html +488 -0
  166. simstack/static/docs/_modules/simstack/util/db.html +1392 -0
  167. simstack/static/docs/_modules/simstack/util/db_logger.html +448 -0
  168. simstack/static/docs/_modules/simstack/util/default_from_dict.html +376 -0
  169. simstack/static/docs/_modules/simstack/util/directory_iterator.html +553 -0
  170. simstack/static/docs/_modules/simstack/util/docstring_parser.html +526 -0
  171. simstack/static/docs/_modules/simstack/util/file_hashing.html +394 -0
  172. simstack/static/docs/_modules/simstack/util/file_transfer_client.html +791 -0
  173. simstack/static/docs/_modules/simstack/util/files.html +733 -0
  174. simstack/static/docs/_modules/simstack/util/find_python_files.html +349 -0
  175. simstack/static/docs/_modules/simstack/util/generate_ui_schema.html +396 -0
  176. simstack/static/docs/_modules/simstack/util/generic_list_mixin.html +487 -0
  177. simstack/static/docs/_modules/simstack/util/get_module_path.html +336 -0
  178. simstack/static/docs/_modules/simstack/util/git_pull.html +381 -0
  179. simstack/static/docs/_modules/simstack/util/git_repository_status.html +411 -0
  180. simstack/static/docs/_modules/simstack/util/import_module.html +377 -0
  181. simstack/static/docs/_modules/simstack/util/importer.html +637 -0
  182. simstack/static/docs/_modules/simstack/util/init_data_source.html +357 -0
  183. simstack/static/docs/_modules/simstack/util/make_graph.html +423 -0
  184. simstack/static/docs/_modules/simstack/util/make_table.html +1171 -0
  185. simstack/static/docs/_modules/simstack/util/mappings.html +378 -0
  186. simstack/static/docs/_modules/simstack/util/minimal_route_finder.html +388 -0
  187. simstack/static/docs/_modules/simstack/util/mock_db.html +472 -0
  188. simstack/static/docs/_modules/simstack/util/module_path_checker.html +354 -0
  189. simstack/static/docs/_modules/simstack/util/object_list_mixin.html +649 -0
  190. simstack/static/docs/_modules/simstack/util/path_manager.html +566 -0
  191. simstack/static/docs/_modules/simstack/util/project_root_finder.html +357 -0
  192. simstack/static/docs/_modules/simstack/util/queue_watcher.html +593 -0
  193. simstack/static/docs/_modules/simstack/util/resource_config.html +532 -0
  194. simstack/static/docs/_modules/simstack/util/route_finder.html +390 -0
  195. simstack/static/docs/_modules/simstack/util/runner_util.html +487 -0
  196. simstack/static/docs/_modules/simstack/util/runner_utils.html +468 -0
  197. simstack/static/docs/_modules/simstack/util/safe_code_executor.html +510 -0
  198. simstack/static/docs/_modules/simstack/util/sample_config.html +414 -0
  199. simstack/static/docs/_modules/simstack/util/sensitive_content_filter.html +364 -0
  200. simstack/static/docs/_modules/simstack/util/setup_logging.html +365 -0
  201. simstack/static/docs/_modules/simstack/util/submit_to_watchdog.html +425 -0
  202. simstack/static/docs/_modules/simstack/util/toml_reader.html +482 -0
  203. simstack/static/docs/_modules/simstack/util/transform_file_name.html +357 -0
  204. simstack/static/docs/_modules/simstack/util/ui_tools.html +541 -0
  205. simstack/static/docs/_sources/architecture.rst.txt +25 -0
  206. simstack/static/docs/_sources/array_lists.rst.txt +60 -0
  207. simstack/static/docs/_sources/ci_pipeline.rst.txt +34 -0
  208. simstack/static/docs/_sources/data.rst.txt +175 -0
  209. simstack/static/docs/_sources/database_migrations.rst.txt +310 -0
  210. simstack/static/docs/_sources/datasets.rst.txt +93 -0
  211. simstack/static/docs/_sources/developer_guide.rst.txt +25 -0
  212. simstack/static/docs/_sources/docker_nodes.rst.txt +149 -0
  213. simstack/static/docs/_sources/files.rst.txt +159 -0
  214. simstack/static/docs/_sources/fire_and_forget_runner.rst.txt +67 -0
  215. simstack/static/docs/_sources/index.rst.txt +25 -0
  216. simstack/static/docs/_sources/installation.rst.txt +213 -0
  217. simstack/static/docs/_sources/lists.rst.txt +111 -0
  218. simstack/static/docs/_sources/mass_runner.rst.txt +91 -0
  219. simstack/static/docs/_sources/misc.rst.txt +106 -0
  220. simstack/static/docs/_sources/overview.rst.txt +91 -0
  221. simstack/static/docs/_sources/recovery.rst.txt +38 -0
  222. simstack/static/docs/_sources/resource_config.rst.txt +141 -0
  223. simstack/static/docs/_sources/simstack/modules/modules.rst.txt +7 -0
  224. simstack/static/docs/_sources/simstack/modules/simstack.core.rst.txt +205 -0
  225. simstack/static/docs/_sources/simstack/modules/simstack.core.services.rst.txt +109 -0
  226. simstack/static/docs/_sources/simstack/modules/simstack.methods.rst.txt +77 -0
  227. simstack/static/docs/_sources/simstack/modules/simstack.models.rst.txt +261 -0
  228. simstack/static/docs/_sources/simstack/modules/simstack.rst.txt +22 -0
  229. simstack/static/docs/_sources/simstack/modules/simstack.tables.rst.txt +45 -0
  230. simstack/static/docs/_sources/simstack/modules/simstack.util.rst.txt +381 -0
  231. simstack/static/docs/_sources/submit.rst.txt +141 -0
  232. simstack/static/docs/_sources/tables.rst.txt +50 -0
  233. simstack/static/docs/_sources/testing_complex_nodes.rst.txt +92 -0
  234. simstack/static/docs/_sources/ui_documentation.rst.txt +272 -0
  235. simstack/static/docs/_sources/workflows.rst.txt +446 -0
  236. simstack/static/docs/_static/base-stemmer.js +476 -0
  237. simstack/static/docs/_static/basic.css +906 -0
  238. simstack/static/docs/_static/cobalt2-theme.css +1 -0
  239. simstack/static/docs/_static/debug.css +69 -0
  240. simstack/static/docs/_static/docs-build-badge.css +51 -0
  241. simstack/static/docs/_static/docs-build-badge.js +51 -0
  242. simstack/static/docs/_static/doctools.js +150 -0
  243. simstack/static/docs/_static/documentation_options.js +13 -0
  244. simstack/static/docs/_static/english-stemmer.js +1066 -0
  245. simstack/static/docs/_static/file.png +0 -0
  246. simstack/static/docs/_static/language_data.js +13 -0
  247. simstack/static/docs/_static/minus.png +0 -0
  248. simstack/static/docs/_static/plus.png +0 -0
  249. simstack/static/docs/_static/pygments.css +250 -0
  250. simstack/static/docs/_static/scripts/furo-extensions.js +0 -0
  251. simstack/static/docs/_static/scripts/furo.js +3 -0
  252. simstack/static/docs/_static/scripts/furo.js.LICENSE.txt +7 -0
  253. simstack/static/docs/_static/scripts/furo.js.map +1 -0
  254. simstack/static/docs/_static/searchtools.js +693 -0
  255. simstack/static/docs/_static/skeleton.css +296 -0
  256. simstack/static/docs/_static/sphinx_highlight.js +159 -0
  257. simstack/static/docs/_static/styles/furo-extensions.css +2 -0
  258. simstack/static/docs/_static/styles/furo-extensions.css.map +1 -0
  259. simstack/static/docs/_static/styles/furo.css +2 -0
  260. simstack/static/docs/_static/styles/furo.css.map +1 -0
  261. simstack/static/docs/architecture.html +340 -0
  262. simstack/static/docs/array_lists.html +678 -0
  263. simstack/static/docs/ci_pipeline.html +385 -0
  264. simstack/static/docs/data.html +545 -0
  265. simstack/static/docs/database_migrations.html +652 -0
  266. simstack/static/docs/datasets.html +1268 -0
  267. simstack/static/docs/developer_guide.html +359 -0
  268. simstack/static/docs/docker_nodes.html +503 -0
  269. simstack/static/docs/files.html +718 -0
  270. simstack/static/docs/fire_and_forget_runner.html +641 -0
  271. simstack/static/docs/genindex.html +6390 -0
  272. simstack/static/docs/index.html +516 -0
  273. simstack/static/docs/installation.html +553 -0
  274. simstack/static/docs/lists.html +478 -0
  275. simstack/static/docs/mass_runner.html +481 -0
  276. simstack/static/docs/misc.html +454 -0
  277. simstack/static/docs/objects.inv +0 -0
  278. simstack/static/docs/overview.html +469 -0
  279. simstack/static/docs/py-modindex.html +1111 -0
  280. simstack/static/docs/recovery.html +371 -0
  281. simstack/static/docs/resource_config.html +590 -0
  282. simstack/static/docs/search.html +327 -0
  283. simstack/static/docs/searchindex.js +1 -0
  284. simstack/static/docs/simstack/modules/modules.html +489 -0
  285. simstack/static/docs/simstack/modules/simstack.core.html +1883 -0
  286. simstack/static/docs/simstack/modules/simstack.core.services.html +698 -0
  287. simstack/static/docs/simstack/modules/simstack.html +3060 -0
  288. simstack/static/docs/simstack/modules/simstack.methods.html +1150 -0
  289. simstack/static/docs/simstack/modules/simstack.models.html +13113 -0
  290. simstack/static/docs/simstack/modules/simstack.tables.html +635 -0
  291. simstack/static/docs/simstack/modules/simstack.util.html +3320 -0
  292. simstack/static/docs/submit.html +487 -0
  293. simstack/static/docs/tables.html +539 -0
  294. simstack/static/docs/testing_complex_nodes.html +458 -0
  295. simstack/static/docs/ui_documentation.html +788 -0
  296. simstack/static/docs/workflows.html +766 -0
  297. simstack/tables/__init__.py +0 -0
  298. simstack/tables/model_table.py +191 -0
  299. simstack/tables/node_children.py +219 -0
  300. simstack/tables/node_table.py +517 -0
  301. simstack/tables/table_builder.py +314 -0
  302. simstack/util/__init__.py +0 -0
  303. simstack/util/async_zip_utils.py +18 -0
  304. simstack/util/b64mixin.py +23 -0
  305. simstack/util/cleaned_json_schema.py +20 -0
  306. simstack/util/config_reader.py +179 -0
  307. simstack/util/custom_model_dump.py +75 -0
  308. simstack/util/database_information.py +160 -0
  309. simstack/util/db.py +1013 -0
  310. simstack/util/db_logger.py +117 -0
  311. simstack/util/default_from_dict.py +57 -0
  312. simstack/util/directory_iterator.py +216 -0
  313. simstack/util/docstring_parser.py +186 -0
  314. simstack/util/file_hashing.py +72 -0
  315. simstack/util/file_transfer_client.py +427 -0
  316. simstack/util/files.py +396 -0
  317. simstack/util/find_python_files.py +33 -0
  318. simstack/util/generate_ui_schema.py +80 -0
  319. simstack/util/generic_list_mixin.py +120 -0
  320. simstack/util/geometry_utils.py +118 -0
  321. simstack/util/get_module_path.py +20 -0
  322. simstack/util/git_pull.py +65 -0
  323. simstack/util/git_repository_status.py +95 -0
  324. simstack/util/import_module.py +63 -0
  325. simstack/util/importer.py +312 -0
  326. simstack/util/init_data_source.py +41 -0
  327. simstack/util/make_graph.py +107 -0
  328. simstack/util/make_table.py +837 -0
  329. simstack/util/mappings.py +41 -0
  330. simstack/util/minimal_route_finder.py +72 -0
  331. simstack/util/mock_db.py +144 -0
  332. simstack/util/module_path_checker.py +38 -0
  333. simstack/util/object_list_mixin.py +282 -0
  334. simstack/util/path_manager.py +223 -0
  335. simstack/util/project_root_finder.py +41 -0
  336. simstack/util/queue_watcher.py +262 -0
  337. simstack/util/resource_config.py +198 -0
  338. simstack/util/route_finder.py +74 -0
  339. simstack/util/runner_util.py +166 -0
  340. simstack/util/runner_utils.py +143 -0
  341. simstack/util/safe_code_executor.py +194 -0
  342. simstack/util/sample_config.py +98 -0
  343. simstack/util/sensitive_content_filter.py +48 -0
  344. simstack/util/setup_logging.py +49 -0
  345. simstack/util/submit_to_watchdog.py +106 -0
  346. simstack/util/toml_reader.py +142 -0
  347. simstack/util/transform_file_name.py +41 -0
  348. simstack/util/ui_tools.py +213 -0
  349. simstack-2.0.1.dist-info/METADATA +454 -0
  350. simstack-2.0.1.dist-info/RECORD +354 -0
  351. simstack-2.0.1.dist-info/WHEEL +5 -0
  352. simstack-2.0.1.dist-info/entry_points.txt +12 -0
  353. simstack-2.0.1.dist-info/licenses/LICENSE +21 -0
  354. simstack-2.0.1.dist-info/top_level.txt +1 -0
simstack/core/node.py ADDED
@@ -0,0 +1,1137 @@
1
+ import asyncio
2
+ import functools
3
+ import inspect
4
+ import logging
5
+ import os
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import (
9
+ Any,
10
+ Callable,
11
+ Optional,
12
+ TypeVar,
13
+ cast,
14
+ List,
15
+ ParamSpec,
16
+ Union,
17
+ overload, Tuple,
18
+ )
19
+
20
+ import coolname # type: ignore[import-untyped]
21
+ import nest_asyncio # type: ignore[import-untyped]
22
+ from odmantic import Model, ObjectId
23
+ from pydantic import BaseModel
24
+
25
+ from simstack.core.artifacts import create_artifacts, ArtifactArguments
26
+ from simstack.core.context import context
27
+ from simstack.core.definitions import TaskStatus
28
+ from simstack.core.hash import complex_hash_function
29
+ from simstack.core.node_claim import claim_submitted_node
30
+ from simstack.core.node_runner import NodeRunner
31
+ from simstack.core.process_results import process_result_helper
32
+ from simstack.core.resource_assignment import apply_resource_assignment_to_node_registry
33
+ from simstack.core.simstack_result import SimstackResult
34
+ from simstack.core.task_id import set_task_id, clear_task_id
35
+ from simstack.models import ModelMapping, Parameters, Project
36
+ from simstack.models import NodeModel
37
+ from simstack.models import NodeRegistry, NamedDataReference
38
+ from simstack.models.file_list import FileList
39
+ from simstack.models.files import FileStack
40
+ from simstack.models.parameters import Resource, Queue
41
+ from simstack.models.simstack_model import is_simstack_model
42
+ from simstack.util.importer import import_function, import_class
43
+
44
+ logger = logging.getLogger("Node")
45
+
46
+ nest_asyncio.apply()
47
+
48
+ T = TypeVar("T")
49
+
50
+
51
+ def default_name_generator() -> str:
52
+ return str("-".join(coolname.generate(2)))
53
+
54
+
55
+ def hashable_value(value: Any) -> Any:
56
+ if isinstance(value, BaseModel):
57
+ return hashable_inputs(value)
58
+ if isinstance(value, list):
59
+ return [hashable_value(item) for item in value]
60
+ if isinstance(value, tuple):
61
+ return tuple(hashable_value(item) for item in value)
62
+ if isinstance(value, dict):
63
+ return {key: hashable_value(item) for key, item in value.items()}
64
+ return value
65
+
66
+
67
+ def hashable_inputs(arg: Any) -> dict[str, Any]:
68
+ """
69
+ Get the hashable inputs for the node. This allows exclusion of some fields from the hash.
70
+
71
+ Returns:
72
+ dict: The hashable inputs.
73
+ """
74
+ return {
75
+ key: hashable_value(value)
76
+ for key, value in arg.__dict__.items()
77
+ if key not in ["id"]
78
+ }
79
+
80
+
81
+ def compute_arg_hash(args: List[Model]) -> str:
82
+ """
83
+ Computes a hash for a list of arguments provided, where each argument
84
+ is an instance of the Model class or can be processed into a hashable
85
+ format. Uses a complex hashing function for the resulting computation.
86
+
87
+ Args:
88
+ args (List[Model]): A list of objects where each object must be an instance of the
89
+ Model class. The objects are used to compute their respective
90
+ hash values via a specified complex hashing mechanism.
91
+
92
+ Returns:
93
+ str: A string representation of the computed hash for the provided
94
+ list of arguments.
95
+
96
+ Raises:
97
+ TypeError: If any item in the provided list is not an instance of the
98
+ Model class.
99
+ """
100
+ arg_hashes = []
101
+ for arg in args:
102
+ if isinstance(arg, Model):
103
+ arg_hash = (
104
+ arg.complex_hash()
105
+ if hasattr(arg, "complex_hash")
106
+ else complex_hash_function(hashable_inputs(arg))
107
+ )
108
+ arg_hashes.append(arg_hash)
109
+ else:
110
+ raise TypeError(f"Argument {arg} is not an instance of {Model}")
111
+ return cast(str, complex_hash_function(arg_hashes))
112
+
113
+
114
+ def _parameters_field_values(parameters: Parameters) -> dict[str, Any]:
115
+ raw_values = object.__getattribute__(parameters, "__dict__")
116
+ return {
117
+ field_name: raw_values[field_name]
118
+ for field_name in Parameters.model_fields
119
+ if field_name in raw_values
120
+ }
121
+
122
+
123
+ def _parameters_from_node_kwargs(kwargs_node: dict[str, Any]) -> Parameters:
124
+ base_parameters = kwargs_node.get("parameters")
125
+ if isinstance(base_parameters, Parameters):
126
+ values = _parameters_field_values(base_parameters)
127
+ elif base_parameters is None:
128
+ values = _parameters_field_values(Parameters())
129
+ else:
130
+ values = _parameters_field_values(Parameters.model_validate(base_parameters))
131
+
132
+ for field_name in Parameters.model_fields:
133
+ if field_name in kwargs_node:
134
+ values[field_name] = kwargs_node[field_name]
135
+
136
+ return Parameters.model_validate(values)
137
+
138
+
139
+ class Node:
140
+ """
141
+ represents a computational task or node in the workflow, managing its lifecycle,
142
+ execution environment, and interactions with the database. This class helps in
143
+ defining tasks, storing their details, and ensuring they are executed either
144
+ locally or remotely, with their statuses tracked within a shared database
145
+ context.
146
+
147
+ :ivar name: The name of the function associated with the node.
148
+ :type name: str
149
+ :ivar custom_name: A custom name for the node, generated if not specified.
150
+ :type custom_name: str or None
151
+ :ivar registry_entry: The registry entry associated with the node in the database.
152
+ :type registry_entry: NodeRegistry or None
153
+ :ivar parent_id: The parent node's unique identifier, if applicable.
154
+ :type parent_id: ObjectId or None
155
+ :ivar parameters: Additional parameters for the node.
156
+ :type parameters: Parameters
157
+ :ivar _func: The function represented by the node.
158
+ :type _func: Callable[[Model], Model]
159
+ """
160
+
161
+ def __init__(
162
+ self,
163
+ *args: Any,
164
+ **kwargs: Any,
165
+ ) -> None:
166
+ super().__init__()
167
+
168
+ self._args = list(args) # Convert to list to allow appending
169
+
170
+ # Extract specific known parameters
171
+ self._func = kwargs.pop("func")
172
+ self.name = self._func.__name__
173
+ self.is_async = kwargs.pop("is_async")
174
+ self.parent_id = kwargs.pop("parent_id", None)
175
+ self.call_path = kwargs.pop("call_path", "") or "." + self.name
176
+ self._arg_hash = kwargs.pop("arg_hash", None)
177
+ self._function_hash = kwargs.pop("function_hash", None)
178
+
179
+ # Get function signature to identify argument names
180
+ sig = inspect.signature(self._func)
181
+ param_names = list(sig.parameters.keys())
182
+
183
+ # Move kwargs that match function parameter names to args
184
+ for param_name in param_names:
185
+ if param_name in kwargs:
186
+ self._args.append(kwargs.pop(param_name))
187
+
188
+ if "custom_name" in kwargs:
189
+ self.custom_name = kwargs["custom_name"] # pass to children
190
+ else:
191
+ self.custom_name = default_name_generator()
192
+
193
+ self.parameters = kwargs.pop("parameters")
194
+ self.recompute_artifacts = kwargs.pop("recompute_artifacts", False) or (
195
+ self.parameters.recompute_artifacts or False
196
+ )
197
+
198
+ self._function_kwargs = (
199
+ kwargs # what is left over here must be kwargs of the function
200
+ )
201
+ self.registry_entry: NodeRegistry | None = None
202
+
203
+ @property
204
+ def id(self) -> ObjectId | None:
205
+ if self.registry_entry is None:
206
+ return None
207
+ else:
208
+ return self.registry_entry.id
209
+
210
+ @property
211
+ def status(self) -> TaskStatus:
212
+ return getattr(self.registry_entry, "status", TaskStatus.FAILED)
213
+
214
+ async def make_registry_entry(
215
+ self, function_hash: str, arg_hash: str
216
+ ) -> NodeRegistry:
217
+ """
218
+ Creates a registry entry for the node in the database.
219
+
220
+ This method is used to create a new entry in the database for the node,
221
+ including its inputs and outputs. It ensures that the task is properly
222
+ registered with all necessary details.
223
+
224
+ :rtype: NodeRegistry
225
+ """
226
+ # TODO why does this fail when nodemapping succeeds ?
227
+ # function_mapping = await context.db.find_one(NodeModel, NodeModel.name == self.name)
228
+ function_mapping = context.node_mappings.get_by_name(self.name)
229
+ if function_mapping is None:
230
+ logger.error(f"Could not find function mapping for name: {self.name}")
231
+ raise ValueError(f"Could not find function mapping for name: {self.name}")
232
+
233
+ input_references = []
234
+ # Get function signature to identify argument names
235
+ sig = inspect.signature(self._func)
236
+ param_names = list(sig.parameters.keys())
237
+
238
+ for i, arg in enumerate(self._args):
239
+ # if there is no table for an arg raise an error
240
+ # input_table_name = await context.db.find_one(ModelMapping, ModelMapping.name == arg.__class__.__name__)
241
+ input_table_name = context.model_mappings.get_by_name(arg.__class__.__name__)
242
+ if input_table_name is None:
243
+ logger.error(f"Could not find table name for {arg.__class__.__name__}")
244
+ raise ValueError(f"Could not find table name for {arg.__class__.__name__}")
245
+ if not isinstance(arg, Model):
246
+ logger.error(f"{arg.__class__.__name__} is not an odmantic Model")
247
+ raise ValueError(f"{arg.__class__.__name__} is not an odmantic Model")
248
+
249
+ argument_entry = await context.db.save(arg)
250
+
251
+ # Check if the save operation was successful and returned a valid ID
252
+ if argument_entry is None or argument_entry.id is None:
253
+ logger.error(f"Failed to save argument {arg} - returned None or invalid ID")
254
+ raise ValueError(f"Failed to save argument of type {arg.__class__.__name__}")
255
+
256
+ variable_name = param_names[i] if i < len(param_names) else f"arg_{i}"
257
+
258
+ input_references.append(NamedDataReference.from_variable(
259
+ argument_entry,
260
+ variable_name=variable_name,
261
+ task_id=str(self.id)
262
+ ))
263
+
264
+ delayed_message = "" # there is no task_id yet
265
+ if self.parent_id is None:
266
+ projects = await context.db.find(Project)
267
+ if projects is None or len(projects) == 0:
268
+ project = Project(field_name="default")
269
+ await context.db.save(project)
270
+ project_id = project.id
271
+ delayed_message = f"default project: {project_id} "
272
+ else:
273
+ project = projects[0]
274
+ project_id = project.id
275
+ delayed_message = f"project: {project_id} "
276
+ else:
277
+ parent_registry_entry = await context.db.load_task_by_id(self.parent_id)
278
+ project_id = parent_registry_entry.project
279
+ delayed_message = f"using parent project: {project_id} "
280
+
281
+ self.registry_entry = NodeRegistry(
282
+ name=self.name,
283
+ input_references=input_references,
284
+ is_async=self.is_async,
285
+ status=TaskStatus.SUBMITTED,
286
+ custom_name=self.custom_name,
287
+ function_hash=function_hash,
288
+ arg_hash=arg_hash,
289
+ project=project_id,
290
+ parent_ids=[] if self.parent_id is None else [self.parent_id],
291
+ parameters=self.parameters,
292
+ func_mapping=function_mapping.function_mapping,
293
+ call_path=self.call_path,
294
+ )
295
+
296
+ if delayed_message:
297
+ logger.info(f"Task task_id: {self.id} with name {self.name} {delayed_message}")
298
+ parent_parameters = self._function_kwargs.get("parent_parameters", None)
299
+ registry_entry = self.registry_entry
300
+ assert registry_entry is not None
301
+ await apply_resource_assignment_to_node_registry(
302
+ context.db,
303
+ registry_entry,
304
+ parent_parameters=parent_parameters
305
+ if isinstance(parent_parameters, Parameters)
306
+ else None,
307
+ )
308
+ self.parameters = registry_entry.parameters
309
+ await context.db.save(registry_entry)
310
+ logger.info(
311
+ f"Task task_id: {self.id} with name {self.name} created for resource: {registry_entry.parameters.resource} queue: {registry_entry.parameters.queue} with id: {self.id}"
312
+ )
313
+ return registry_entry
314
+
315
+ async def get_node_registry(self) -> TaskStatus:
316
+ """
317
+ Reads or initializes the task registry entry in the database.
318
+
319
+ This method ensures that a task entry exists in the database for the
320
+ current task. It computes hashes of its arguments and function,
321
+ checks if a database entry already matches these hashes, and creates
322
+ a new entry if no match is found. If the database is not connected,
323
+ an exception is raised.
324
+
325
+ :raises ValueError: if the database is not connected.
326
+ :return: Status of the task retrieved or created.
327
+ :rtype: TaskStatus
328
+ """
329
+ if context.db is None:
330
+ raise ValueError("Database is not connected")
331
+
332
+ arg_hash = compute_arg_hash(self._args)
333
+ function_hash = cast(str, complex_hash_function(self._func))
334
+ self._arg_hash = arg_hash
335
+ self._function_hash = function_hash
336
+
337
+ self.registry_entry = (
338
+ await context.db.load_task(self.name, arg_hash, function_hash)
339
+ if not self.parameters.force_rerun
340
+ else None
341
+ )
342
+
343
+ if self.registry_entry is None:
344
+ await self.make_registry_entry(function_hash, arg_hash)
345
+ else:
346
+
347
+ if self.parent_id:
348
+ logger.debug(
349
+ f"Task task_id: {self.id} adding parent_id {self.parent_id} to task: {self.name}"
350
+ )
351
+ if isinstance(self.parent_id, str):
352
+ logger.error(
353
+ f"Task task_id: {self.id} parent_id is a string: {self.parent_id}"
354
+ )
355
+ self.parent_id = ObjectId(self.parent_id)
356
+ self.registry_entry.parent_ids.append(self.parent_id)
357
+ await context.db.save(self.registry_entry)
358
+ # whenever a task is found in the database, we may have to redo all child artifacts because the children
359
+ # will not be loaded
360
+ if self.recompute_artifacts:
361
+ logger.debug(
362
+ f"Task task_id: {self.id} recomputing artifacts for task: {self.name}"
363
+ )
364
+ from simstack.core.recompute_artifacts import recompute_artifacts
365
+
366
+ await recompute_artifacts(self.registry_entry)
367
+ else:
368
+ logger.warning(
369
+ f"Task task_id: {self.id} was found in the database with status: {self.registry_entry.status}. Terminating execution."
370
+ )
371
+
372
+ assert self.registry_entry is not None
373
+ return self.registry_entry.status
374
+
375
+ async def load_results(self) -> Union[Model, SimstackResult, None]:
376
+ """
377
+ Loads the results associated with a specific task from the database. This
378
+ method verifies whether the task has valid result identifiers. If valid
379
+ identifiers (results_id and results_table_name) exist, it attempts to fetch
380
+ the outputs.
381
+
382
+ If the task status is not TaskStatus.COMPLETED None is returned which results in a RuntimeError
383
+ If the results are not found or if the retrieval process fails, a `ValueError` is raised.
384
+
385
+ :raises ValueError: If the task has completed but lacks output identifiers
386
+ (`results_id` or `results_table_name`), or if there is any error
387
+ during the process of loading the results.
388
+
389
+ :return: The retrieved task outputs from the database.
390
+ """
391
+ db = context.db
392
+ assert self.registry_entry is not None
393
+ logger.info(
394
+ f"Task task_id: {self.id} loading results with task status {self.status}"
395
+ )
396
+ try:
397
+ if self.registry_entry.status != TaskStatus.COMPLETED:
398
+ return None
399
+
400
+ simstack_result = SimstackResult(status=self.registry_entry.status)
401
+ result = None
402
+ for ref in self.registry_entry.results_references:
403
+ model = await import_class(ref.variable_mapping, db)
404
+ result = await db.find_one(model, model.id == ref.reference)
405
+ if result is None:
406
+ await self.set_status(TaskStatus.FAILED)
407
+ logger.error(
408
+ f"Task task_id: {self.id} could not find result with id {ref.reference} in table {ref.variable_mapping}"
409
+ )
410
+ raise ValueError(
411
+ f"Task task_id: {self.id} could not find result with id {ref.reference} in table {ref.variable_mapping}"
412
+ )
413
+ simstack_result.__setattr__(ref.variable_name, result)
414
+
415
+ logger.info(f"Task task_id: {self.id} loaded outputs")
416
+
417
+ if len(self.registry_entry.results_references) == 1:
418
+ return result # there is only one result, return it directly
419
+ else:
420
+ return simstack_result # return the SimstackResult with all results
421
+
422
+ except Exception as e:
423
+ await self.set_status(TaskStatus.FAILED)
424
+ logger.exception(f"Task task_id: {self.id} failed to load outputs: {e}")
425
+ raise ValueError(f"Task task_id: {self.id} failed to load outputs: {e}")
426
+
427
+ async def run_somewhere(self) -> Union[Model, SimstackResult, None]:
428
+ """
429
+ Executes the task either locally or on a remote resource. This function ensures that
430
+ if the task is meant to execute on a remote resource, it waits for the task to complete
431
+ remotely and fetches its results. If the task executes locally, it directly runs the task
432
+ and retrieves the results.
433
+
434
+ If any exception occurs during the execution, the status is updated to `FAILED`
435
+ and the exception is logged.
436
+
437
+ :return: A single Model or a list of Model instances of the task results or None. If the task
438
+ is not completed successfully.
439
+ :rtype: Model | SimstackResult
440
+
441
+ :raises RunTimeError: When task execution fails due to an unexpected exception.
442
+ """
443
+ resource_self = Resource(value="self")
444
+
445
+ logger.info(
446
+ f"Task task_id: {self.id} run_somewhere context resource: {context.config.resource} target resource: {self.parameters.resource} queue: {self.parameters.queue}"
447
+ )
448
+ if self.parameters.resource == resource_self or (
449
+ context.config.resource == self.parameters.resource
450
+ and self.parameters.queue == Queue.DEFAULT
451
+ ):
452
+ result = await self.execute_node_locally()
453
+ return result
454
+ else:
455
+ if await self._submit_same_resource_slurm_node():
456
+ logger.info(
457
+ "Task task_id: %s submitted Slurm node directly from resource %s",
458
+ self.id,
459
+ context.config.resource,
460
+ )
461
+ # the task will be executed somewhere else
462
+ # wait for the database status to change
463
+ while True:
464
+ new_registry_entry = await context.db.load_task_by_id(self.id)
465
+ # TODO add timeout mechanism here
466
+ if new_registry_entry is None:
467
+ raise RuntimeError(
468
+ f"Task task_id: {self.id} could not be found in the database"
469
+ )
470
+ new_status = new_registry_entry.status
471
+ if (
472
+ new_status != TaskStatus.RUNNING
473
+ and new_status != TaskStatus.SUBMITTED
474
+ and new_status != TaskStatus.SLURM_QUEUED
475
+ and new_status != TaskStatus.RETRIEVED
476
+ ):
477
+ break
478
+
479
+ print(f"Task task_id: {self.id} is waiting for results")
480
+ await asyncio.sleep(5)
481
+
482
+ if new_status == TaskStatus.COMPLETED:
483
+ logger.info(f"Task task_id: {self.id} completed remotely")
484
+ self.registry_entry = new_registry_entry
485
+ return await self.load_results()
486
+ else:
487
+ return None
488
+
489
+ async def _submit_same_resource_slurm_node(self) -> bool:
490
+ """Submit a same-resource Slurm node before polling it.
491
+
492
+ This is primarily needed when a parent already running on a resource
493
+ creates a Slurm child for that resource. The same path also supports a
494
+ top-level Python caller on the resource. The atomic claim prevents a
495
+ concurrent resource runner from submitting the node twice.
496
+ """
497
+ if self.registry_entry is None:
498
+ return False
499
+ if self.parameters.queue != Queue.SLURM_QUEUE:
500
+ return False
501
+ if self.parameters.resource != context.config.resource:
502
+ return False
503
+ if not await claim_submitted_node(self.registry_entry):
504
+ return False
505
+
506
+ from simstack.core.submit_node import submit_node
507
+
508
+ return await submit_node(self.registry_entry)
509
+
510
+ async def execute_node_locally(self) -> Union[Model, SimstackResult, None]:
511
+ """
512
+ Executes a specified node in the current context locally, either asynchronously or
513
+ synchronously, managing task status updates, directory changes, and result persistence.
514
+
515
+ This method handles the execution of a computational task represented as a "node". It
516
+ manages the task's status transitions, file system operations for managing working
517
+ directories, and handling output results, including their persistence in a database.
518
+ The method supports both asynchronous and synchronous node execution. It verifies
519
+ results, handles exceptions, and manages task metadata updates.
520
+
521
+ Nodes can either return
522
+ * a single `Model` instance
523
+ * a `SimstackResult` instance
524
+ * None (for failure).
525
+ * a boolean value (for failure or success if there are no results)
526
+
527
+ There is a try-except block around the actual execution of the node which generates a log entry
528
+ "node function error for node" that catches all uncaught exceptions within the node. These error are
529
+ not propagated, but the task status is set to TaskStatus.FAILED.
530
+
531
+ :param self: Instance of the class invoking this method.
532
+
533
+ :raises Exception: for failures of the Simstack logic
534
+
535
+ :return: The processed result of the node execution. Depending on the task's output,
536
+ it could be of the type `Model`, `SimstackResult`, or be None if no valid result
537
+ was produced.
538
+
539
+ """
540
+ assert self.registry_entry is not None
541
+ self.registry_entry.started_at = datetime.now()
542
+ await self.set_status(TaskStatus.RUNNING)
543
+ logger.info(
544
+ f"Task task_id: {self.id} is started on {self.parameters.resource} in Node:execute_node_locally"
545
+ )
546
+ original_dir = Path.cwd()
547
+ try:
548
+ node_runner = NodeRunner(self._func.__name__, self.id)
549
+ node_kwargs = {
550
+ "node_runner": node_runner,
551
+ "parent_id": self.id,
552
+ "task_id": self.id,
553
+ "call_path": self.call_path,
554
+ "parent_parameters": self.parameters, # this must have a name different from parameters, because
555
+ # otherwise this setting will override all the parameters of
556
+ # the child nodes
557
+ "recompute_artifacts": self.recompute_artifacts,
558
+ "custom_name": self.custom_name,
559
+ "arg_hash": self._arg_hash,
560
+ "function_hash": self._function_hash,
561
+ }
562
+
563
+ if self.parameters.force_rerun:
564
+ node_kwargs["force_rerun"] = True
565
+
566
+ path = Path(context.config.workdir) / self.name / str(self.id)
567
+ # Create the directory if it doesn't exist
568
+ path.mkdir(parents=True, exist_ok=True)
569
+ os.chdir(path)
570
+ logger.debug(
571
+ f"Task task_id: {self.id} successfully changed to directory: {path.absolute()}"
572
+ )
573
+
574
+ # real_func_wrapper = await import_function_by_name(self._func.__name__,self.id)
575
+ # real_func = getattr(real_func_wrapper, '_inner', real_func_wrapper)
576
+ real_func = self._func
577
+ result = None
578
+ set_task_id(self.registry_entry.id)
579
+ try:
580
+ if self.is_async:
581
+ result = await real_func(*self._args, **node_kwargs)
582
+ else:
583
+ result = real_func(*self._args, **node_kwargs)
584
+ except Exception as e:
585
+ # Save the error message if possible
586
+ if self.registry_entry:
587
+ self.registry_entry.error = str(e)
588
+ await context.db.save(self.registry_entry)
589
+ logger.exception(
590
+ f"Task task_id: {self.id} node function error for node: {self.name} msg: {str(e)}"
591
+ )
592
+ # save what we can, in particular the info_files
593
+ await self.process_results(node_runner)
594
+ await self.set_status(TaskStatus.FAILED)
595
+ raise
596
+ finally:
597
+ clear_task_id()
598
+
599
+ self.registry_entry.completed_at = datetime.now()
600
+
601
+ new_task_status, result = await self.process_results(result)
602
+
603
+ if new_task_status == TaskStatus.COMPLETED:
604
+ artifact_arguments = ArtifactArguments(result, self.id)
605
+ artifact_arguments.add_attributes(
606
+ self._func, *self._args, **node_kwargs
607
+ )
608
+ self.registry_entry.artifact_ids = await create_artifacts(
609
+ artifact_arguments, self.registry_entry
610
+ )
611
+ await self.set_status(
612
+ new_task_status
613
+ ) # this will also commit the registry entry
614
+
615
+ logger.info(
616
+ f"Task task_id: {self.id} is finished on resource: {self.parameters.resource} with task status: {new_task_status}"
617
+ )
618
+ if new_task_status != TaskStatus.COMPLETED:
619
+ return None
620
+ return result
621
+ except Exception:
622
+ await self.set_status(TaskStatus.FAILED)
623
+ raise
624
+ finally:
625
+ os.chdir(original_dir)
626
+ logger.debug(
627
+ f"Task task_id: {self.id} successfully back to directory: {original_dir.absolute()}"
628
+ )
629
+
630
+ async def process_results(self, result: Any) -> tuple[TaskStatus, Any]:
631
+ assert self.registry_entry is not None
632
+ # each of the following if sets the result either to a valid value or None
633
+ new_task_status = TaskStatus.COMPLETED
634
+ if result is None:
635
+ logger.warning(f"Task task_id: {self.id} returned None")
636
+ new_task_status = TaskStatus.FAILED # result is None
637
+ elif isinstance(result, bool):
638
+ if not result:
639
+ new_task_status = TaskStatus.FAILED
640
+ result = None
641
+ elif is_simstack_model(result) or isinstance(result, SimstackResult):
642
+ if isinstance(result, SimstackResult):
643
+ new_task_status = result.status
644
+ if hasattr(result, "custom_name"):
645
+ self.registry_entry.custom_name = result.custom_name
646
+
647
+ for file_stack in result.info_files:
648
+ if file_stack:
649
+ if isinstance(file_stack, FileStack):
650
+ logger.info(
651
+ f"Task task_id: {self.id} saving info file: {file_stack.name} {file_stack.id}"
652
+ )
653
+ if self.registry_entry.info_files is None:
654
+ self.registry_entry.info_files = FileList()
655
+ await context.db.save(file_stack)
656
+ self.registry_entry.info_files.append(file_stack)
657
+ else:
658
+ logger.error(
659
+ f"Task task_id: {self.id} cannot save info_file: FileStack expected but got {type(file_stack)}"
660
+ )
661
+ else:
662
+ logger.error(f"Task task_id: {self.id} saving info-file is NONE")
663
+ raise ValueError("saving info file is NONE")
664
+
665
+ if result.error_message is not None and result.error_message != "":
666
+ logger.error(
667
+ f"Task task_id: {self.id} returned with error: {result.error_message}"
668
+ )
669
+ if result.message is not None and result.message != "":
670
+ logger.info(f"Task task_id: {self.id} message: {result.message}")
671
+ else:
672
+ if hasattr(result, "status"):
673
+ new_task_status = result.status
674
+ elif hasattr(result, "task_status"):
675
+ new_task_status = result.task_status
676
+
677
+ results_references, result_models = await process_result_helper(result, str(self.id))
678
+
679
+ self.registry_entry.results_references = results_references
680
+ self.registry_entry.status = new_task_status
681
+
682
+ if len(results_references) == 1:
683
+ result = result_models[0] # for a SimstackResult with just one returned model we return the model directly
684
+ else:
685
+ logger.warning(
686
+ f"Task task_id: {self.id} returned a result of type {type(result)} which is not a SimstackModel or a SimstackResult"
687
+ )
688
+ new_task_status = TaskStatus.FAILED
689
+ return new_task_status, result
690
+
691
+ async def set_status(self, status: TaskStatus) -> None:
692
+ if self.registry_entry is None:
693
+ raise ValueError("Task has no registry entry")
694
+ if isinstance(status, TaskStatus):
695
+ self.registry_entry.status = status
696
+ else:
697
+ logger.warning(f"Task task_id: {self.id} {status} is not a TaskStatus")
698
+ self.registry_entry.status = TaskStatus(status)
699
+ await context.db.save(self.registry_entry)
700
+ logger.info(f"Task task_id: {self.id} {self.name} is set to {status}, id is: {self.id}")
701
+
702
+
703
+ async def _hydrate_embedded_file_stacks(
704
+ value: Any,
705
+ db: Any,
706
+ resolved: dict[ObjectId, FileStack],
707
+ seen: set[int] | None = None,
708
+ ) -> Any:
709
+ """
710
+ Compatibility workaround, not the intended persistence pattern.
711
+
712
+ FileStack objects must be stored as references. Embedding them directly
713
+ inside other models violates this storage contract. This hydration logic
714
+ compensates for existing models and records that violate the rule, but new
715
+ models must not rely on it. The affected models and persisted data should
716
+ eventually be migrated to proper references, after which this workaround
717
+ should be removed.
718
+ """
719
+ if isinstance(value, FileStack):
720
+ if value.content is not None or value.locations:
721
+ return value
722
+
723
+ file_stack_id = value.id
724
+ if file_stack_id in resolved:
725
+ return resolved[file_stack_id]
726
+
727
+ canonical = await db.find_one(FileStack, FileStack.id == file_stack_id)
728
+ if canonical is None:
729
+ raise ValueError(f"Referenced FileStack {file_stack_id} not found")
730
+ if canonical.id != file_stack_id:
731
+ raise ValueError(
732
+ f"Referenced FileStack {file_stack_id} resolved to {canonical.id}"
733
+ )
734
+ if canonical.content is None and not canonical.locations:
735
+ raise ValueError(
736
+ f"Referenced FileStack {file_stack_id} has no content or locations"
737
+ )
738
+
739
+ resolved[file_stack_id] = canonical
740
+ return canonical
741
+
742
+ if isinstance(value, tuple):
743
+ hydrated_items = [
744
+ await _hydrate_embedded_file_stacks(item, db, resolved, seen)
745
+ for item in value
746
+ ]
747
+ if all(item is hydrated for item, hydrated in zip(value, hydrated_items)):
748
+ return value
749
+ return tuple(hydrated_items)
750
+
751
+ if not isinstance(value, (BaseModel, list, dict)):
752
+ return value
753
+
754
+ if seen is None:
755
+ seen = set()
756
+ value_id = id(value)
757
+ if value_id in seen:
758
+ return value
759
+ seen.add(value_id)
760
+
761
+ try:
762
+ if isinstance(value, BaseModel):
763
+ for field_name in type(value).model_fields:
764
+ field_value = getattr(value, field_name)
765
+ hydrated = await _hydrate_embedded_file_stacks(
766
+ field_value, db, resolved, seen
767
+ )
768
+ if hydrated is not field_value:
769
+ setattr(value, field_name, hydrated)
770
+ elif isinstance(value, list):
771
+ for index, item in enumerate(value):
772
+ value[index] = await _hydrate_embedded_file_stacks(
773
+ item, db, resolved, seen
774
+ )
775
+ else:
776
+ for key, item in value.items():
777
+ value[key] = await _hydrate_embedded_file_stacks(
778
+ item, db, resolved, seen
779
+ )
780
+ return value
781
+ finally:
782
+ seen.remove(value_id)
783
+
784
+
785
+ async def node_from_database(registry_entry: NodeRegistry) -> Union["Node", None]:
786
+ """
787
+ Constructs an instance of the class from database information encoded in a
788
+ registry entry.
789
+
790
+ This method retrieves input arguments and the serialized function from the
791
+ database using information provided in the `registry_entry`. It then deserializes
792
+ the function and initializes a corresponding Node instance, associating it
793
+ with the given registry entry.
794
+
795
+ This function can delete the registry_entry !!!
796
+ The only way that registry_entry.function_hash is "NOT INITIALIZED" is when the node
797
+ is created from the frontend. No other node is listening specifically for this registry_entry to complete.
798
+ If a duplicate is found the node from the duplication is returned
799
+
800
+ :param registry_entry: The registry entry containing information necessary to
801
+ reconstruct the Node instance. Includes input table names, function pickled
802
+ as a string, and other metadata.
803
+ :type registry_entry: NodeRegistry
804
+
805
+ :return: A reconstructed Node instance based on the registry entry, or None if
806
+ the deserialized function is not valid or there was an error.
807
+ :rtype: Optional[Node]
808
+ """
809
+ args = []
810
+ db = context.db
811
+ resolved_file_stacks: dict[ObjectId, FileStack] = {}
812
+
813
+ for ref in registry_entry.input_references:
814
+ try:
815
+ model = await import_class(ref.variable_mapping, db)
816
+ arg = await db.find_one(model, model.id == ref.reference)
817
+ arg = await _hydrate_embedded_file_stacks(arg, db, resolved_file_stacks)
818
+ args.append(arg)
819
+ except Exception as e:
820
+ logger.exception(
821
+ f"Task task_id: {registry_entry.id} failed to load input {ref.variable_mapping} with id {ref.reference}: {str(e)}"
822
+ )
823
+ return None
824
+
825
+ if registry_entry.arg_hash == "NOT INITIALIZED":
826
+ logger.debug(f"Task task_id: {registry_entry.id} computes arg hashes")
827
+ registry_entry.arg_hash = compute_arg_hash(args)
828
+
829
+
830
+ logger.debug(
831
+ f"Task task_id: {registry_entry.id} {registry_entry.name} loaded {len(args)} inputs in Node:node_from_database status: {registry_entry.status}"
832
+ )
833
+ func = None
834
+ try:
835
+ wrapped_func = await import_function(
836
+ registry_entry.func_mapping, db, task_id=registry_entry.id
837
+ )
838
+ if wrapped_func is not None:
839
+ # for nodes the mapping points to the wrapped func to we use that
840
+ func = (
841
+ wrapped_func if not hasattr(wrapped_func, "_inner") else wrapped_func._inner
842
+ )
843
+ logger.debug(
844
+ f"Task task_id: {registry_entry.id} inner: {hasattr(wrapped_func, '_inner')} imported function: {func.__name__}"
845
+ )
846
+ if registry_entry.function_hash == "NOT INITIALIZED":
847
+ registry_entry.function_hash = cast(str, complex_hash_function(func))
848
+ registry_entry.is_async = asyncio.iscoroutinefunction(func)
849
+ else:
850
+ logger.error(
851
+ f"Task task_id: {registry_entry.id} could not import function {registry_entry.func_mapping}"
852
+ )
853
+ except Exception as e:
854
+ logger.error(
855
+ f"Task task_id: {registry_entry.id} failed to import function {registry_entry.func_mapping} {str(e)}"
856
+ )
857
+
858
+ if func is None and registry_entry.function_hash == "NOT INITIALIZED":
859
+ return None
860
+
861
+ try:
862
+ duplicate_entry = await db.find_one(
863
+ NodeRegistry,
864
+ (NodeRegistry.name == registry_entry.name)
865
+ & (NodeRegistry.arg_hash == registry_entry.arg_hash)
866
+ & (NodeRegistry.function_hash == registry_entry.function_hash)
867
+ & (NodeRegistry.id != registry_entry.id),
868
+ )
869
+ if duplicate_entry is None:
870
+ await db.save(
871
+ registry_entry
872
+ ) # save the fixed entry AFTER checking for duplicates
873
+ # the calling function may have the originial entry unsaved !
874
+ else:
875
+ logger.info(
876
+ f"Task task_id: {registry_entry.id} found duplicate entry {duplicate_entry.id} {duplicate_entry.name}"
877
+ )
878
+
879
+ # the parameters of the new job may be different
880
+ duplicate_entry.parameters = registry_entry.parameters
881
+ await db.delete(registry_entry)
882
+ registry_entry = duplicate_entry
883
+
884
+ if func is None:
885
+ # we recovered a duplicate, let's try to import the function from the duplicate's mapping
886
+ try:
887
+ wrapped_func = await import_function(
888
+ registry_entry.func_mapping, db, task_id=registry_entry.id
889
+ )
890
+ if wrapped_func is not None:
891
+ func = (
892
+ wrapped_func
893
+ if not hasattr(wrapped_func, "_inner")
894
+ else wrapped_func._inner
895
+ )
896
+ except Exception as e:
897
+ logger.error(
898
+ f"Task task_id: {registry_entry.id} failed to import function from duplicate {registry_entry.func_mapping} {str(e)}"
899
+ )
900
+
901
+ if func is None:
902
+ return None
903
+
904
+ except Exception as e:
905
+ logger.exception(
906
+ f"Task task_id: {registry_entry.id} failed during duplicate detection or secondary import {str(e)}"
907
+ )
908
+ return None
909
+
910
+ kwargs = {
911
+ "func": func,
912
+ "is_async": False,
913
+ "call_path": registry_entry.call_path,
914
+ "parameters": registry_entry.parameters,
915
+ "custom_name": registry_entry.custom_name,
916
+ "arg_hash": registry_entry.arg_hash,
917
+ "function_hash": registry_entry.function_hash,
918
+ }
919
+ if hasattr(registry_entry, "is_async"):
920
+ kwargs["is_async"] = registry_entry.is_async
921
+
922
+ kwargs["parent_id"] = (
923
+ registry_entry.parent_ids[0] if registry_entry.parent_ids else None
924
+ )
925
+ logger.debug(
926
+ f"Task task_id: {registry_entry.id} is_async: {kwargs['is_async']} parent_id: {kwargs['parent_id']}"
927
+ )
928
+
929
+ new_node = Node(*args, **kwargs)
930
+ new_node.registry_entry = registry_entry
931
+ return new_node
932
+
933
+
934
+ # Add a return type annotation for async functions
935
+ # T_co = TypeVar("T_co", covariant=True)
936
+ #
937
+ # # Create overloaded function type annotations
938
+ # @overload
939
+ # def node(
940
+ # _func: Callable[..., Awaitable[T_co]],
941
+ # ) -> Callable[..., Awaitable[T_co]]: ...
942
+ #
943
+ # @overload
944
+ # def node(
945
+ # _func: Callable[..., T_co],
946
+ # ) -> Callable[..., T_co]: ...
947
+ #
948
+ # @overload
949
+ # def node(
950
+ # _func: None = None,
951
+ # *,
952
+ # name: Optional[str] = None,
953
+ # version: Optional[str] = None,
954
+ # cache: bool = True,
955
+ # **kwargs_node,
956
+ # ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: ...
957
+
958
+
959
+ P = ParamSpec("P")
960
+
961
+
962
+ @overload
963
+ def node(_func: Callable[P, T]) -> Callable[..., T]:
964
+ ...
965
+
966
+
967
+ @overload
968
+ def node(
969
+ _func: None = None,
970
+ *,
971
+ name: Optional[str] = None,
972
+ version: Optional[str] = None,
973
+ cache: bool = True,
974
+ **kwargs_node: Any,
975
+ ) -> Callable[[Callable[P, T]], Callable[..., T]]:
976
+ ...
977
+
978
+
979
+ def node(
980
+ _func: Optional[Callable[P, T]] = None,
981
+ *,
982
+ version: Optional[str] = None,
983
+ **kwargs_node: Any,
984
+ ) -> Union[Callable[..., T], Callable[[Callable[P, T]], Callable[..., T]]]:
985
+ """
986
+ Decorator to mark a function as a node in the computation graph.
987
+ Supports both synchronous and asynchronous functions.
988
+ Can be used with or without parameters:
989
+ @node
990
+ def func(): ...
991
+
992
+ @node(name="example")
993
+ def func(): ...
994
+
995
+ """
996
+
997
+ def decorator(func: Callable[P, T]) -> Callable[..., T]:
998
+ is_async = asyncio.iscoroutinefunction(func)
999
+
1000
+ setattr(func, "_is_node", True)
1001
+ setattr(func, "_inner", func)
1002
+ setattr(func, "_node_parameters", _parameters_from_node_kwargs(kwargs_node))
1003
+
1004
+ def update_kwargs(kwargs: dict[str, Any]) -> None:
1005
+ kwargs["func"] = func
1006
+ kwargs["is_async"] = is_async
1007
+ explicit_parameters = kwargs.pop("parameters", None)
1008
+ if explicit_parameters is None:
1009
+ kwargs["parameters"] = getattr(func, "_node_parameters").model_copy(
1010
+ deep=True
1011
+ )
1012
+ elif isinstance(explicit_parameters, Parameters):
1013
+ kwargs["parameters"] = explicit_parameters.model_copy(deep=True)
1014
+ else:
1015
+ kwargs["parameters"] = Parameters.model_validate(
1016
+ explicit_parameters
1017
+ ).model_copy(deep=True)
1018
+ kwargs["custom_name"] = kwargs.pop(
1019
+ "custom_name", kwargs_node.get("custom_name", default_name_generator())
1020
+ )
1021
+ call_path = kwargs.pop("call_path", "")
1022
+ if not call_path:
1023
+ call_path = ""
1024
+ logger.debug(f"Task call_path: {call_path} {func.__name__}")
1025
+ # Fix call path construction - handle empty call_path for root nodes
1026
+ if call_path == "":
1027
+ call_path = "." + func.__name__
1028
+ else:
1029
+ call_path = call_path + "." + func.__name__
1030
+ kwargs["call_path"] = call_path
1031
+
1032
+ @functools.wraps(func)
1033
+ async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
1034
+ update_kwargs(kwargs)
1035
+ # Create Node with the remaining kwargs
1036
+ execution_node = Node(*args, **kwargs)
1037
+
1038
+ status = await execution_node.get_node_registry()
1039
+ result = None
1040
+ if status == TaskStatus.COMPLETED:
1041
+ result = await execution_node.load_results()
1042
+ # TODO why do we run somewhere when already running ?
1043
+ elif status in [
1044
+ TaskStatus.SUBMITTED,
1045
+ TaskStatus.RETRIEVED,
1046
+ TaskStatus.SLURM_QUEUED,
1047
+ ]:
1048
+ result = await execution_node.run_somewhere()
1049
+ else:
1050
+ logger.warning(
1051
+ f"Task task_id: {execution_node.id} status: {status} was not executed"
1052
+ )
1053
+
1054
+ if result is None or execution_node.status != TaskStatus.COMPLETED:
1055
+ if execution_node.registry_entry is None:
1056
+ raise RuntimeError(
1057
+ f"Task task_id: {execution_node.id} node: {execution_node.name} has no registry entry"
1058
+ )
1059
+ current_registry_entry = await context.db.find_one(
1060
+ NodeRegistry, NodeRegistry.id == execution_node.registry_entry.id
1061
+ )
1062
+ if current_registry_entry is None:
1063
+ raise RuntimeError(
1064
+ f"Task task_id: {execution_node.id} node: {execution_node.name} registry entry disappeared"
1065
+ )
1066
+
1067
+ if (
1068
+ current_registry_entry.status == TaskStatus.FAILED
1069
+ and current_registry_entry.error
1070
+ ):
1071
+ raise RuntimeError(current_registry_entry.error)
1072
+
1073
+ raise RuntimeError(
1074
+ f"Task task_id: {current_registry_entry.id} node: {current_registry_entry.name} terminated with status {current_registry_entry.status}"
1075
+ )
1076
+ return cast(T, result)
1077
+
1078
+ @functools.wraps(func)
1079
+ def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
1080
+ import asyncio
1081
+
1082
+ update_kwargs(kwargs)
1083
+ # Create Node with the remaining kwargs
1084
+ execution_node = Node(*args, **kwargs)
1085
+
1086
+ # If it's an async function but called in a sync context, run it in the event loop
1087
+ loop = asyncio.get_event_loop()
1088
+ status = loop.run_until_complete(execution_node.get_node_registry())
1089
+ result = None
1090
+ ran_somewhere = False
1091
+ if status == TaskStatus.COMPLETED:
1092
+ result = loop.run_until_complete(execution_node.load_results())
1093
+ elif status in [
1094
+ TaskStatus.SUBMITTED,
1095
+ TaskStatus.RETRIEVED,
1096
+ TaskStatus.SLURM_QUEUED,
1097
+ ]:
1098
+ result = loop.run_until_complete(execution_node.run_somewhere())
1099
+ ran_somewhere = True
1100
+ # Keep the established sync-node contract for resultless/default-queue
1101
+ # executions. Slurm submission failures must not be mistaken for that
1102
+ # contract, because they otherwise disappear as a successful ``None``.
1103
+ if (
1104
+ ran_somewhere
1105
+ and result is None
1106
+ and execution_node.parameters.queue != Queue.SLURM_QUEUE
1107
+ ):
1108
+ return cast(T, result)
1109
+ if result is None or execution_node.status != TaskStatus.COMPLETED:
1110
+ if (
1111
+ execution_node.registry_entry is not None
1112
+ and execution_node.registry_entry.status == TaskStatus.FAILED
1113
+ and execution_node.registry_entry.error
1114
+ ):
1115
+ raise RuntimeError(execution_node.registry_entry.error)
1116
+
1117
+ raise RuntimeError(
1118
+ f"Task task_id: {execution_node.id} node: {execution_node.name} terminated with status {execution_node.status}"
1119
+ )
1120
+ return cast(T, result)
1121
+
1122
+ setattr(async_wrapper, "is_node", True)
1123
+ setattr(sync_wrapper, "is_node", True)
1124
+ # Return the appropriate wrapper based on whether the function is async
1125
+ if is_async:
1126
+ return cast(Callable[..., T], async_wrapper)
1127
+ else:
1128
+ return cast(Callable[..., T], sync_wrapper)
1129
+
1130
+ setattr(decorator, "is_node", True)
1131
+
1132
+ if _func is None:
1133
+ # Called with parameters: @node(...)
1134
+ return decorator
1135
+ else:
1136
+ # Called without parameters: @node
1137
+ return decorator(_func)