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.
- simstack/__init__.py +0 -0
- simstack/_version.py +34 -0
- simstack/core/__init__.py +0 -0
- simstack/core/artifacts.py +447 -0
- simstack/core/asnyc_helper.py +14 -0
- simstack/core/config_file.py +60 -0
- simstack/core/context.py +167 -0
- simstack/core/data.py +139 -0
- simstack/core/definitions.py +21 -0
- simstack/core/engine.py +116 -0
- simstack/core/find_simstack_modules.py +43 -0
- simstack/core/hash.py +177 -0
- simstack/core/model_table.py +199 -0
- simstack/core/node.py +911 -0
- simstack/core/node_runner.py +254 -0
- simstack/core/node_table.py +380 -0
- simstack/core/recompute_artifacts.py +120 -0
- simstack/core/reset_db.py +12 -0
- simstack/core/resources.py +101 -0
- simstack/core/run_node.py +69 -0
- simstack/core/runner.py +435 -0
- simstack/core/simstack_result.py +41 -0
- simstack/core/submit_node.py +143 -0
- simstack/core/task_id.py +22 -0
- simstack/data/__init__.py +0 -0
- simstack/methods/__init__.py +0 -0
- simstack/methods/get_file.py +10 -0
- simstack/methods/multiple_executor.py +307 -0
- simstack/models/__init__.py +37 -0
- simstack/models/array_list.py +48 -0
- simstack/models/array_storage.py +54 -0
- simstack/models/artifact_models.py +127 -0
- simstack/models/base_types.py +208 -0
- simstack/models/charts_artifact.py +411 -0
- simstack/models/dataset.py +432 -0
- simstack/models/dataset_metadata.py +303 -0
- simstack/models/file_instance.py +77 -0
- simstack/models/file_list.py +321 -0
- simstack/models/files.py +229 -0
- simstack/models/log_entry_model.py +37 -0
- simstack/models/models.py +40 -0
- simstack/models/node_registry.py +105 -0
- simstack/models/pandas_model.py +210 -0
- simstack/models/parameters.py +388 -0
- simstack/models/pickle_models.py +311 -0
- simstack/models/runner_model.py +39 -0
- simstack/models/simple_table.py +32 -0
- simstack/models/simstack_model.py +97 -0
- simstack/models/slurm_info.py +17 -0
- simstack/models/table_artifact.py +87 -0
- simstack/util/__init__.py +0 -0
- simstack/util/async_zip_utils.py +18 -0
- simstack/util/cleaned_json_schema.py +20 -0
- simstack/util/config_reader.py +273 -0
- simstack/util/custom_model_dump.py +75 -0
- simstack/util/db.py +457 -0
- simstack/util/db_logger.py +117 -0
- simstack/util/default_from_dict.py +57 -0
- simstack/util/directory_iterator.py +216 -0
- simstack/util/file_hashing.py +68 -0
- simstack/util/files.py +396 -0
- simstack/util/find_python_files.py +33 -0
- simstack/util/generate_ui_schema.py +80 -0
- simstack/util/geometry_utils.py +118 -0
- simstack/util/get_module_path.py +26 -0
- simstack/util/git_pull.py +65 -0
- simstack/util/git_repository_status.py +93 -0
- simstack/util/import_module.py +65 -0
- simstack/util/importer.py +163 -0
- simstack/util/make_graph.py +107 -0
- simstack/util/make_table.py +827 -0
- simstack/util/minimal_route_finder.py +72 -0
- simstack/util/mock_db.py +144 -0
- simstack/util/module_path_checker.py +38 -0
- simstack/util/path_manager.py +234 -0
- simstack/util/project_root_finder.py +36 -0
- simstack/util/queue_watcher.py +262 -0
- simstack/util/route_finder.py +74 -0
- simstack/util/runner_util.py +162 -0
- simstack/util/safe_code_executor.py +194 -0
- simstack/util/sample_config.py +98 -0
- simstack/util/sensitive_content_filter.py +48 -0
- simstack/util/setup_logging.py +49 -0
- simstack/util/submit_to_watchdog.py +106 -0
- simstack/util/ui_tools.py +213 -0
- simstack-0.2.0.dist-info/METADATA +361 -0
- simstack-0.2.0.dist-info/RECORD +91 -0
- simstack-0.2.0.dist-info/WHEEL +5 -0
- simstack-0.2.0.dist-info/entry_points.txt +10 -0
- simstack-0.2.0.dist-info/licenses/LICENSE +21 -0
- 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()
|