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
@@ -0,0 +1,254 @@
1
+ import glob
2
+ import logging
3
+ import os
4
+ import subprocess
5
+ import uuid
6
+ from pathlib import Path
7
+ from typing import Set
8
+
9
+ from simstack.core.definitions import TaskStatus
10
+ from simstack.core.simstack_result import SimstackResult
11
+ from simstack.models.files import FileStack
12
+
13
+ local_logger = logging.getLogger("NodeRunner")
14
+
15
+
16
+ class NodeRunner(SimstackResult):
17
+ """
18
+ A task runner class that extends SimstackResult for executing subprocess commands and managing task execution.
19
+
20
+ This class provides functionality for running shell commands, collecting output files, and managing
21
+ task status with comprehensive logging capabilities.
22
+
23
+ Attributes:
24
+ task_id (ObjectId): Unique identifier for the task
25
+ name (str): Name of the task runner instance
26
+ logger: Logger instance for recording task activities
27
+ last_stdout (str): Most recent stdout from subprocess execution
28
+ last_stderr (str): Most recent stderr from subprocess execution
29
+ info_file_patterns (Set[str]): File patterns used to collect information files
30
+ """
31
+
32
+ def __init__(self, name: str = "node", logger=None, **kwargs):
33
+ """
34
+ Initialize the NodeRunner instance.
35
+
36
+ Args:
37
+ name (str, optional): Name for this task runner. Defaults to "node".
38
+ logger (optional): Logger instance to use. If None, uses local_logger.
39
+ **kwargs: Additional keyword arguments. 'task_id' can be provided here.
40
+ """
41
+ super().__init__()
42
+ self.task_id = kwargs.get("task_id", "NA")
43
+ self.name = name
44
+ self.logger = logger or local_logger
45
+ self.last_stdout = ""
46
+ self.last_stderr = ""
47
+ self.info_file_patterns = {"*.in", "*.out", "*.err", "*.log"}
48
+ self.info("started")
49
+
50
+ async def make_info_files(self, *args):
51
+ """
52
+ Collect and process information files based on patterns and explicit file paths.
53
+
54
+ This method processes the provided arguments to identify file patterns and explicit
55
+ file paths, then collects all matching files into FileStack objects for later use.
56
+
57
+ Args:
58
+ *args: Variable arguments that can be:
59
+ - File patterns (strings containing '*')
60
+ - Explicit file paths (existing readable files)
61
+
62
+ Note:
63
+ Files are added to the info_files list as FileStack objects.
64
+ Only readable files are processed.
65
+ """
66
+ try:
67
+ files_set: Set[str] = set()
68
+
69
+ # Process args for patterns and files
70
+ for value in args:
71
+ if isinstance(value, str):
72
+ if "*" in value:
73
+ self.info_file_patterns.add(value)
74
+ elif os.path.exists(value) and os.access(value, os.R_OK):
75
+ files_set.add(value)
76
+
77
+ self.info(f"Processing patterns: {self.info_file_patterns}")
78
+
79
+ # Add files matching patterns
80
+ for pattern in self.info_file_patterns:
81
+ for filepath in glob.glob(pattern):
82
+ if os.path.exists(filepath) and os.access(filepath, os.R_OK):
83
+ files_set.add(filepath)
84
+
85
+ self.info(f"Found info files: {files_set}")
86
+
87
+ for file_path in files_set:
88
+ if os.path.exists(file_path):
89
+ file_stack = FileStack.from_local_file(
90
+ file_path, in_memory=True, is_hashable=True, secure_source=True
91
+ )
92
+ self.info_files.append(file_stack)
93
+ except Exception as finally_error:
94
+ self.error(f"Error in finally block: {str(finally_error)}")
95
+
96
+ def debug(self, msg):
97
+ """
98
+ Log a debug message with task context.
99
+
100
+ Args:
101
+ msg (str): Debug message to log
102
+ """
103
+ self.logger.debug(f"Task {self.name}: {msg} for task_id: {self.task_id}")
104
+
105
+ def info(self, msg):
106
+ """
107
+ Log an info message with the task context.
108
+
109
+ Args:
110
+ msg (str): Info message to log
111
+ """
112
+ self.logger.info(f"Task {self.name}: {msg} task_id: {self.task_id}")
113
+
114
+ def warning(self, msg):
115
+ """
116
+ Log a warning message with the task context.
117
+
118
+ Args:
119
+ msg (str): Warning message to log
120
+ """
121
+ self.logger.warning(f"Task {self.name}: {msg} task_id: {self.task_id}")
122
+
123
+ def error(self, msg):
124
+ """
125
+ Log an error message with task context.
126
+
127
+ Args:
128
+ msg (str): Error message to log
129
+ """
130
+ self.logger.error(f"Task {self.name}: {msg} task_id: {self.task_id}")
131
+
132
+ def subprocess(self, name: str, command: str, cwd: str = "") -> bool:
133
+ """
134
+ Execute a shell command as a subprocess and capture its output.
135
+
136
+ This method runs a shell command, captures stdout and stderr, creates a log file
137
+ with the execution details, and adds the log file to the info_files collection.
138
+
139
+ Args:
140
+ name (str): Name identifier for the subprocess (used for log file naming)
141
+ command (str): Shell command to execute
142
+ cwd (str, optional): Working directory for command execution. Defaults to current directory.
143
+
144
+ Returns:
145
+ bool: True if the subprocess completed successfully (return code 0), False otherwise
146
+
147
+ Note:
148
+ - Creates a log file named "{name}.log" containing command details and output
149
+ - Updates last_stdout and last_stderr attributes with the most recent output
150
+ - Log file is automatically added to info_files collection
151
+ """
152
+ if name == "":
153
+ name = "process"
154
+ with open(f"{name}.log", "w") as process_log:
155
+ process_log.write(f"Command: {name}\n{command}\n")
156
+ # TODO adapt for docker
157
+ process = subprocess.run(
158
+ command,
159
+ shell=True, # Important: use shell=True for shell operators like &&
160
+ capture_output=True,
161
+ text=True,
162
+ cwd=cwd if cwd else None,
163
+ )
164
+ self.info(f"run script {name} finished: {process.returncode}")
165
+ self.last_stdout = process.stdout
166
+ self.last_stderr = process.stderr
167
+ process_log.write(f"Process return code:\n{process.returncode}\n\n")
168
+ process_log.write(f"Process output:\n{process.stdout}\n\n")
169
+ process_log.write(f"Process error:\n{process.stderr}\n\n")
170
+ file_stack = FileStack.from_local_file(
171
+ f"{name}.log", in_memory=True, is_hashable=True, secure_source=True
172
+ )
173
+ self.info_files.append(file_stack)
174
+ return process.returncode == 0
175
+
176
+ def submit_to_watchdog(self, name: str, command: str) -> bool:
177
+ """
178
+ Submit a command to an external watchdog process for execution.
179
+
180
+ This method prepares the necessary files and environment to submit a command
181
+ to a watchdog process that will handle its execution. It uses a specified queue directory
182
+ to manage job files and signals.
183
+
184
+ Args:
185
+ name (str): Name identifier for the watchdog job (used for log file naming)
186
+ command (str): Command to be executed by the watchdog
187
+ Returns:
188
+ SimstackResult: Self reference with status updated based on the submission outcome
189
+ Note:
190
+ - The method sets the task status to SUBMITTED if the submission is successful.
191
+ - If the submission fails, it sets the status to FAILED and records an error message.
192
+ """
193
+ # we are inside a slurm job, so we need to use the queue dir of the job
194
+ queue_dir = Path.cwd() / "queue"
195
+ queue_dir.mkdir(parents=True, exist_ok=True)
196
+ if name == "":
197
+ name = "process"
198
+ from simstack.util.submit_to_watchdog import submit_to_watchdog
199
+
200
+ try:
201
+ with open(f"{name}.log", "w") as process_log:
202
+ process_log.write(f"Command: {name}\n{command}\n")
203
+
204
+ job_id = str(self.task_id) + "_" + uuid.uuid4().hex
205
+ result = submit_to_watchdog(command, job_id, queue_dir)
206
+ if result.status == "ok":
207
+ self.info(f"watchdog {result.exit_code}")
208
+ else:
209
+ err_msg = f"watchdog submission failed: {result.returncode}"
210
+ self.fail(err_msg)
211
+ self.last_stdout = result.stdout
212
+ self.last_stderr = result.stderr
213
+ process_log.write(f"Process return code:\n{result.exit_code}\n\n")
214
+ process_log.write(f"Process output:\n{result.stdout}\n\n")
215
+ process_log.write(f"Process error:\n{result.stderr}\n\n")
216
+ file_stack = FileStack.from_local_file(
217
+ f"{name}.log", in_memory=True, is_hashable=True, secure_source=True
218
+ )
219
+ self.info_files.append(file_stack)
220
+ return result.status == "ok" and result.returncode == 0
221
+ except Exception as e:
222
+ err_msg = f"watchdog submission exception: {str(e)}"
223
+ self.fail(err_msg)
224
+ return False
225
+
226
+ def fail(self, msg: str) -> SimstackResult:
227
+ """
228
+ Mark the task as failed with an error message.
229
+
230
+ Args:
231
+ msg (str): Error message describing the failure reason
232
+
233
+ Returns:
234
+ SimstackResult: Self reference with status set to FAILED
235
+ """
236
+ self.logger.exception(f"Task {self.name}: {msg} task_id: {self.task_id}")
237
+ self.error_message = msg
238
+ self.status = TaskStatus.FAILED
239
+ return self
240
+
241
+ def succeed(self, msg: str = "") -> SimstackResult:
242
+ """
243
+ Mark the task as successfully completed.
244
+
245
+ Args:
246
+ msg (str, optional): Success message. Defaults to empty string.
247
+
248
+ Returns:
249
+ SimstackResult: Self reference with status set to COMPLETED
250
+ """
251
+ self.info(f"succeeded {msg}")
252
+ self.message = msg
253
+ self.status = TaskStatus.COMPLETED
254
+ return self
@@ -0,0 +1,380 @@
1
+ import argparse
2
+ import asyncio
3
+ import inspect
4
+ import logging
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Callable, List, Optional, get_type_hints, Dict, Any
8
+
9
+ from simstack.core.context import context
10
+ from simstack.core.find_simstack_modules import find_simstack_modules
11
+ from simstack.models import Parameters
12
+ from simstack.models.models import NodeModel, ModelMapping
13
+ from simstack.util.import_module import import_module_from_file
14
+
15
+ logger = logging.getLogger("NodeTable")
16
+
17
+
18
+ def is_node_function(func: Callable) -> bool:
19
+ """Check if a function is marked as a node using the @node decorator."""
20
+ return hasattr(func, "_is_node") and getattr(func, "_is_node", False) is True
21
+
22
+
23
+ def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]:
24
+ """Parse docstring to extract description, parameters, and return values."""
25
+ if not docstring:
26
+ return {"description": "", "params": {}, "returns": {}}
27
+
28
+ # Clean up docstring
29
+ docstring = inspect.cleandoc(docstring)
30
+
31
+ # Extract the main description (before any parameters)
32
+ description_match = re.search(
33
+ r"^(.*?)(?:Args:|Parameters:|Returns:|$)", docstring, re.DOTALL
34
+ )
35
+ description = description_match.group(1).strip() if description_match else ""
36
+
37
+ # Extract parameters
38
+ params = {}
39
+ param_section = re.search(
40
+ r"(?:Args:|Parameters:)(.*?)(?:Returns:|$)", docstring, re.DOTALL
41
+ )
42
+ if param_section:
43
+ param_text = param_section.group(1)
44
+ param_matches = re.finditer(
45
+ r"(\w+)\s*(?:\(([^)]+)\))?\s*:\s*(.+?)(?=\n\s*\w+\s*:|$)",
46
+ param_text,
47
+ re.DOTALL,
48
+ )
49
+ for match in param_matches:
50
+ param_name = match.group(1)
51
+ param_type = match.group(2) # May be None
52
+ param_desc = match.group(3).strip()
53
+ params[param_name] = {"type": param_type, "description": param_desc}
54
+
55
+ # Extract return information
56
+ returns = {}
57
+ return_section = re.search(r"Returns:(.*?)$", docstring, re.DOTALL)
58
+ if return_section:
59
+ return_text = return_section.group(1).strip()
60
+ returns["description"] = return_text
61
+
62
+ return {"description": description, "params": params, "returns": returns}
63
+
64
+
65
+ class CreateNodeTable:
66
+ """
67
+ Helper class to build the node table without passing around many parameters.
68
+
69
+ Usage:
70
+ creator = CreateNodeTable(engine)
71
+ await creator.make_node_table()
72
+ """
73
+
74
+ def __init__(self, engine):
75
+ # Ensure context is initialized and store frequently used objects
76
+ if not context.initialized:
77
+ context.initialize()
78
+
79
+ self.context = context
80
+ self.engine = engine
81
+ self.path_manager = context.path_manager
82
+
83
+ async def make_node_table(self):
84
+ """Entry point to (re)build the node table."""
85
+ # First, process all simstack modules
86
+ all_modules = find_simstack_modules()
87
+ for module_name in all_modules:
88
+ logger.info(f"Processing node module: {module_name}")
89
+ try:
90
+ # We import modules lazily via the path manager where possible,
91
+ # but for discovered package modules we can use standard import.
92
+ module = __import__(module_name, fromlist=["*"])
93
+ except Exception as exc:
94
+ logger.warning("Failed to import module %s: %s", module_name, exc)
95
+ continue
96
+
97
+ await self._register_nodes_from_module(module, drops="")
98
+
99
+ # Then process all configured paths (user/project code)
100
+ for path_name in self.path_manager.paths.keys():
101
+ await self._make_nodes_for_path(path_name)
102
+
103
+ async def _make_nodes_for_path(self, path_name: str):
104
+ """Discover/register nodes for all Python files under a configured path."""
105
+ path_info = self.path_manager.get_path(path_name)
106
+ base_path = path_info["path"]
107
+ drops = path_info["drops"]
108
+
109
+ logger.info(f"Making node_table entries for files in {base_path}")
110
+
111
+ for file_path in self.path_manager.find_python_files(path_name):
112
+ await self._create_nodes_from_file(file_path, drops)
113
+
114
+ async def _create_nodes_from_file(self, file_path: str, drops: str):
115
+ """Create node entries for function definitions in the specified Python file."""
116
+ logger.debug(f"Processing nodes from: {file_path}")
117
+ module = import_module_from_file(Path(file_path))
118
+ if not module:
119
+ logger.debug("Skipping %s because module import returned None", file_path)
120
+ return
121
+
122
+ await self._register_nodes_from_module(module, drops)
123
+
124
+ async def _register_nodes_from_module(self, module, drops: str):
125
+ """
126
+ Core logic to discover node functions in a module and (re)create NodeModel entries.
127
+
128
+ Heuristic:
129
+ - All top-level callables (functions) whose names do not start with '_'
130
+ - Only functions actually defined in this module.
131
+ """
132
+ functions: List[tuple[str, Callable]] = inspect.getmembers(
133
+ module, inspect.isfunction
134
+ )
135
+ # Filter for functions defined in this module only
136
+ module_name = module.__name__
137
+ functions = [
138
+ (func_name, func)
139
+ for func_name, func in functions
140
+ if func.__module__ == module_name
141
+ ]
142
+
143
+ for func_name, func in functions:
144
+ if not is_node_function(func):
145
+ continue
146
+
147
+ # Get function signature
148
+ sig = inspect.signature(func)
149
+
150
+ # Parse docstring
151
+ docstring_info = parse_docstring(inspect.getdoc(func))
152
+
153
+ # Get type hints
154
+ type_hints = get_type_hints(func)
155
+
156
+ # Create inputs list from parameters
157
+ inputs = []
158
+ for param_name, param in sig.parameters.items():
159
+ if param_name == "self": # Skip self parameter for methods
160
+ continue
161
+
162
+ param_info = {
163
+ "name": param_name,
164
+ "type": type_hints.get(param_name, param.annotation.__name__),
165
+ "type_str": str(
166
+ type_hints.get(
167
+ param_name,
168
+ param.annotation.__name__
169
+ if param.annotation != inspect.Parameter.empty
170
+ else "Any",
171
+ )
172
+ ),
173
+ }
174
+
175
+ # Add description from docstring if available
176
+ if param_name in docstring_info["params"]:
177
+ param_info["description"] = docstring_info["params"][param_name][
178
+ "description"
179
+ ]
180
+
181
+ # Add default value if available
182
+ if param.default != inspect.Parameter.empty:
183
+ param_info["default"] = param.default
184
+
185
+ inputs.append(param_info)
186
+
187
+ # Create output information
188
+ outputs = []
189
+ return_type = type_hints.get("return", None)
190
+ if return_type and return_type != type(
191
+ None
192
+ ): # Check for actual return type
193
+ output_info = {
194
+ "name": "result",
195
+ "type_str": str(return_type),
196
+ "type": return_type,
197
+ }
198
+
199
+ # Add description from docstring if available
200
+ if "returns" in docstring_info and docstring_info["returns"]:
201
+ output_info["description"] = docstring_info["returns"][
202
+ "description"
203
+ ]
204
+
205
+ outputs.append(output_info)
206
+
207
+ # Create default parameters - ensure it's never None
208
+ parameters = Parameters()
209
+ # First check if the parameters are stored as an attribute
210
+ if hasattr(func, "_node_parameters"):
211
+ parameters = func._node_parameters
212
+ # Otherwise, try to find them in closures
213
+ else:
214
+ closures = inspect.getclosurevars(func)
215
+ for name, values in closures._asdict().items():
216
+ if name == "nonlocals":
217
+ continue
218
+ if isinstance(values, dict):
219
+ kwargs_node = values.get("kwargs_node", None)
220
+ if kwargs_node and "parameters" in kwargs_node:
221
+ parameters = kwargs_node["parameters"]
222
+ break
223
+
224
+ # # Verify parameters is a valid Parameters object
225
+ # if not isinstance(parameters, Parameters):
226
+ # if parameters is None:
227
+ # # Use empty Parameters if None
228
+ # parameters = Parameters()
229
+ # elif hasattr(parameters, '__dict__'):
230
+ # # Try to convert to Parameters
231
+ # try:
232
+ # parameters = Parameters(**parameters.__dict__)
233
+ # except Exception as e:
234
+ # logger.error(f"Failed to convert parameters to Parameters object: {e}")
235
+ # parameters = Parameters()
236
+ # else:
237
+ # logger.warning(f"Invalid parameters type: {type(parameters)}. Using default.")
238
+ # parameters = Parameters()
239
+
240
+ # Use node-specific metadata if available
241
+ node_name = getattr(func, "_node_name", func_name)
242
+ node_description = getattr(
243
+ func, "_node_description", docstring_info["description"]
244
+ )
245
+
246
+ # Check if there are valid inputs
247
+ if not inputs:
248
+ logger.warning(f"{node_name} has no inputs.")
249
+
250
+ # Safely get input mapping
251
+ input_mappings = []
252
+ if inputs:
253
+ try:
254
+ for specific_input in inputs:
255
+ if (
256
+ specific_input.get("type")
257
+ and hasattr(specific_input["type"], "__module__")
258
+ and hasattr(specific_input["type"], "__name__")
259
+ ):
260
+ input_mapping = (
261
+ specific_input["type"].__module__
262
+ + "."
263
+ + specific_input["type"].__name__
264
+ )
265
+ if drops and input_mapping.startswith(drops + "."):
266
+ input_mapping = input_mapping[len(drops) + 1 :]
267
+ input_mapping_found = await self.engine.find_one(
268
+ ModelMapping, ModelMapping.mapping == input_mapping
269
+ )
270
+ if not input_mapping_found and input_mapping:
271
+ logger.error(
272
+ f"Processing node: {node_name} model {input_mapping} not found in db!"
273
+ )
274
+ input_mappings.append(input_mapping)
275
+ except Exception as e:
276
+ logger.error(f"Error getting input mapping: {e}")
277
+
278
+ function_mapping = module.__name__ + "." + func_name
279
+
280
+ try:
281
+ existing_model = await self.engine.find_one(
282
+ NodeModel, NodeModel.name == node_name
283
+ )
284
+ existing_favorite = False # Default value if no existing model
285
+
286
+ if existing_model:
287
+ if function_mapping != existing_model.function_mapping:
288
+ logger.error(
289
+ f"Processing module {module.__name__} NodeModel {node_name} already exists in the database\n"
290
+ + f" DB Mapping: {existing_model.function_mapping}\n"
291
+ + f" New Mapping: {function_mapping} skipping."
292
+ )
293
+ continue
294
+
295
+ # Capture the favorite flag from the existing model
296
+ existing_favorite = getattr(existing_model, "favorite", False)
297
+
298
+ # If it has a pickle_function, delete the corresponding FunctionPickle
299
+ if existing_model.pickle_function:
300
+ try:
301
+ # Delete the FunctionPickle directly using the reference
302
+ await self.engine.delete(existing_model.pickle_function)
303
+ logger.debug(f"Deleted FunctionPickle for {node_name}")
304
+ except Exception as e:
305
+ logger.error(
306
+ f"Error deleting FunctionPickle for {node_name}: {e}"
307
+ )
308
+
309
+ # Delete the NodeModel entry
310
+ await self.engine.delete(existing_model)
311
+ logger.debug(f"Deleted NodeModel entry for {node_name}")
312
+
313
+ function_pickle = None
314
+
315
+ # Create and save the node model
316
+ node_model = NodeModel(
317
+ name=node_name,
318
+ function_mapping=function_mapping,
319
+ description=node_description,
320
+ input_mappings=input_mappings,
321
+ default_parameters=parameters,
322
+ pickle_function=function_pickle,
323
+ favorite=existing_favorite, # Set the favorite flag from the existing model
324
+ )
325
+
326
+ logger.debug(
327
+ f"NodeModel: {node_model.name}, {node_model.function_mapping}, {node_model.input_mappings}"
328
+ )
329
+ await self.engine.save(node_model)
330
+ # node_models.append(node_model)
331
+ except Exception as e:
332
+ logger.error(f"Error creating/saving NodeModel {node_name}: {e}")
333
+ import traceback
334
+
335
+ traceback.print_exc()
336
+
337
+
338
+ # Public API preserved for existing callers (e.g. tests)
339
+ async def make_node_table(engine):
340
+ """
341
+ Rebuild the node table using the given engine.
342
+
343
+ This is a thin wrapper around CreateNodeTable for backward compatibility.
344
+ """
345
+ creator = CreateNodeTable(engine)
346
+ await creator.make_node_table()
347
+
348
+
349
+ def create_node_table_main():
350
+ """
351
+ CLI-style entry point to (re)build the node table.
352
+
353
+ Uses a dedicated event loop, matching the pattern used for model table creation.
354
+ """
355
+ parser = argparse.ArgumentParser()
356
+ parser.add_argument("-v", "--verbose", action="count", default=0)
357
+ args = parser.parse_args()
358
+
359
+ level = logging.WARNING
360
+ if args.verbose == 1:
361
+ level = logging.INFO
362
+ elif args.verbose >= 2:
363
+ level = logging.DEBUG
364
+
365
+ loop = asyncio.new_event_loop()
366
+ asyncio.set_event_loop(loop)
367
+
368
+ context.initialize(log_level=level)
369
+
370
+ # Set pymongo logger level to INFO
371
+ logging.getLogger("pymongo").setLevel(logging.INFO)
372
+
373
+ try:
374
+ loop.run_until_complete(make_node_table(context.db.engine))
375
+ finally:
376
+ loop.close()
377
+
378
+
379
+ if __name__ == "__main__":
380
+ create_node_table_main()