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,262 @@
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import os
4
+ import signal
5
+ import subprocess
6
+ import sys
7
+ import time
8
+ from concurrent.futures import ThreadPoolExecutor, as_completed
9
+ from pathlib import Path
10
+ from typing import Dict, Optional, Tuple
11
+
12
+
13
+ def parse_spec(
14
+ cmd_file: Path
15
+ ) -> Tuple[Optional[str], Dict[str, str], Optional[str], Optional[int]]:
16
+ cwd: Optional[str] = None
17
+ env: Dict[str, str] = {}
18
+ command: Optional[str] = None
19
+ timeout: Optional[int] = None
20
+
21
+ with cmd_file.open("r", encoding="utf-8") as f:
22
+ for raw in f:
23
+ line = raw.strip()
24
+ if not line:
25
+ continue
26
+ if line.startswith("CWD="):
27
+ cwd = line[len("CWD=") :]
28
+ elif line.startswith("ENV[") and "]=" in line:
29
+ key = line[len("ENV[") : line.index("]=", len("ENV["))]
30
+ val = line.split("]=", 1)[1]
31
+ env[key] = val
32
+ elif line.startswith("CMD="):
33
+ command = line[len("CMD=") :]
34
+ elif line.startswith("TIMEOUT="):
35
+ try:
36
+ timeout = int(line[len("TIMEOUT=") :])
37
+ except ValueError:
38
+ timeout = None
39
+ return cwd, env, command, timeout
40
+
41
+
42
+ def run_command(
43
+ command: str,
44
+ cwd: Optional[str],
45
+ env: Dict[str, str],
46
+ timeout: Optional[int],
47
+ out_file: Path,
48
+ err_file: Path,
49
+ ) -> int:
50
+ proc_env = os.environ.copy()
51
+ proc_env.update(env)
52
+ with out_file.open("w", encoding="utf-8") as out, err_file.open(
53
+ "w", encoding="utf-8"
54
+ ) as err:
55
+ try:
56
+ proc = subprocess.run(
57
+ command,
58
+ shell=True,
59
+ cwd=cwd if cwd else None,
60
+ env=proc_env,
61
+ stdout=out,
62
+ stderr=err,
63
+ text=True,
64
+ timeout=timeout if (timeout and timeout > 0) else None,
65
+ check=False,
66
+ )
67
+ return proc.returncode
68
+ except subprocess.TimeoutExpired:
69
+ err.write("\n[watcher] Command timed out\n")
70
+ return 124
71
+ except Exception as e:
72
+ err.write(f"\n[watcher] Execution error: {e}\n")
73
+ return 1
74
+
75
+
76
+ def process_job_from_claim(base_stem: Path, claimed_by_lock: bool) -> None:
77
+ """
78
+ Process a job using base path (without suffix). If claimed_by_lock is True, we did not
79
+ rename .ready; we will remove it at the end. Otherwise, we operate on .claim and remove it.
80
+ """
81
+ cmd_file = base_stem.with_suffix(".cmd")
82
+ ready_file = base_stem.with_suffix(".ready")
83
+ claim_file = base_stem.with_suffix(".claim")
84
+ lock_file = base_stem.with_suffix(".lock")
85
+ out_file = base_stem.with_suffix(".stdout")
86
+ err_file = base_stem.with_suffix(".stderr")
87
+ exit_file = base_stem.with_suffix(".exit")
88
+ done_file = base_stem.with_suffix(".done")
89
+ fail_file = base_stem.with_suffix(".fail")
90
+
91
+ try:
92
+ if not cmd_file.exists():
93
+ fail_file.touch()
94
+ return
95
+
96
+ cwd, env, command, timeout = parse_spec(cmd_file)
97
+ if not command:
98
+ err_file.write_text("[watcher] CMD missing in spec\n", encoding="utf-8")
99
+ exit_file.write_text("2\n", encoding="utf-8")
100
+ fail_file.touch()
101
+ return
102
+
103
+ rc = run_command(command, cwd, env, timeout, out_file, err_file)
104
+ print(f"[watcher] Job {base_stem.name} finished with rc={rc}")
105
+ exit_file.write_text(f"{rc}\n", encoding="utf-8")
106
+ print(f"[watcher] Job {base_stem.name} finished")
107
+ (done_file if rc == 0 else fail_file).touch()
108
+ finally:
109
+ # Cleanup markers depending on the claim type
110
+ if claimed_by_lock:
111
+ try:
112
+ if ready_file.exists():
113
+ ready_file.unlink()
114
+ except Exception as e:
115
+ print(
116
+ f"[watcher] Warning: failed to remove ready {ready_file}: {e}",
117
+ file=sys.stderr,
118
+ )
119
+ try:
120
+ if lock_file.exists():
121
+ lock_file.unlink()
122
+ except Exception as e:
123
+ print(
124
+ f"[watcher] Warning: failed to remove lock {lock_file}: {e}",
125
+ file=sys.stderr,
126
+ )
127
+ else:
128
+ try:
129
+ if claim_file.exists():
130
+ claim_file.unlink()
131
+ except Exception as e:
132
+ print(
133
+ f"[watcher] Warning: failed to remove claim {claim_file}: {e}",
134
+ file=sys.stderr,
135
+ )
136
+
137
+
138
+ def try_claim(
139
+ ready_file: Path, retries: int = 5, backoff: float = 0.05
140
+ ) -> Optional[Path]:
141
+ """
142
+ Atomically rename *.ready -> *.claim. Retries on transient failures.
143
+ Returns claim path if successful, else None.
144
+ """
145
+ claim = ready_file.with_suffix(".claim")
146
+ for i in range(max(1, retries)):
147
+ try:
148
+ os.replace(ready_file, claim)
149
+ return claim
150
+ except FileNotFoundError:
151
+ return None
152
+ except PermissionError:
153
+ # Windows/NFS transient locks: backoff and retry
154
+ time.sleep(backoff * (i + 1))
155
+ except OSError:
156
+ # Other transient FS issues: backoff and retry
157
+ time.sleep(backoff * (i + 1))
158
+ return None
159
+
160
+
161
+ def try_lock(base_stem: Path) -> bool:
162
+ """
163
+ Create an exclusive lock file <id>.lock using O_CREAT|O_EXCL.
164
+ Returns True if lock acquired, False otherwise.
165
+ """
166
+ lock_path = base_stem.with_suffix(".lock")
167
+ flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
168
+ try:
169
+ fd = os.open(lock_path, flags, 0o644)
170
+ os.close(fd)
171
+ return True
172
+ except FileExistsError:
173
+ return False
174
+ except OSError as e:
175
+ print(f"[watcher] Lock create failed for {lock_path}: {e}", file=sys.stderr)
176
+ return False
177
+
178
+
179
+ def main() -> int:
180
+ parser = argparse.ArgumentParser(
181
+ description="Concurrent file-queue watcher with robust claiming"
182
+ )
183
+ parser.add_argument(
184
+ "--queue-dir", type=Path, required=False, default=Path.cwd() / "queue"
185
+ )
186
+ parser.add_argument("--poll-interval", type=float, default=1.0)
187
+ parser.add_argument("--workers", type=int, default=min(4, (os.cpu_count() or 2)))
188
+ parser.add_argument(
189
+ "--use-srun",
190
+ action="store_true",
191
+ help="(deprecated; wrapper removed for simplicity)",
192
+ )
193
+ parser.add_argument("--srun-args", type=str, default="")
194
+ parser.add_argument(
195
+ "--once", action="store_true", help="Process current claims and exit"
196
+ )
197
+ args = parser.parse_args()
198
+
199
+ qd = args.queue_dir
200
+ qd.mkdir(parents=True, exist_ok=True)
201
+ print(f"[watcher] Watching {qd}, workers={args.workers}")
202
+
203
+ stop = {"flag": False}
204
+
205
+ def handle_sig(_s, _f):
206
+ stop["flag"] = True
207
+
208
+ signal.signal(signal.SIGINT, handle_sig)
209
+ signal.signal(signal.SIGTERM, handle_sig)
210
+
211
+ with ThreadPoolExecutor(max_workers=max(1, args.workers)) as pool:
212
+ futures = set()
213
+
214
+ while not stop["flag"]:
215
+ claimed_any = False
216
+
217
+ # Scan for all .ready files
218
+ ready_list = list(qd.glob("*.ready"))
219
+ if ready_list:
220
+ print(f"[watcher] Found {len(ready_list)} ready file(s)")
221
+ for ready in ready_list:
222
+ base_stem = ready.with_suffix("") # drop .ready
223
+ # 1) Preferred: atomic rename to .claim (with retries)
224
+ claim = try_claim(ready)
225
+ if claim is not None:
226
+ claimed_any = True
227
+ futures.add(pool.submit(process_job_from_claim, base_stem, False))
228
+ continue
229
+
230
+ # 2) Fallback: exclusive lock file if rename failed
231
+ if try_lock(base_stem):
232
+ print(f"[watcher] Claimed by lock: {base_stem.name}")
233
+ claimed_any = True
234
+ futures.add(pool.submit(process_job_from_claim, base_stem, True))
235
+
236
+ # Reap done tasks
237
+ done_now = {f for f in futures if f.done()}
238
+ for f in done_now:
239
+ try:
240
+ f.result()
241
+ except Exception as e:
242
+ print(f"[watcher] Job raised: {e}", file=sys.stderr)
243
+ futures.discard(f)
244
+
245
+ if args.once:
246
+ # Drain: wait for all running tasks to finish
247
+ for f in as_completed(list(futures)):
248
+ try:
249
+ f.result()
250
+ except Exception as e:
251
+ print(f"[watcher] Job raised: {e}", file=sys.stderr)
252
+ break
253
+
254
+ if not claimed_any:
255
+ time.sleep(max(0.05, args.poll_interval))
256
+
257
+ print("[watcher] Exiting")
258
+ return 0
259
+
260
+
261
+ if __name__ == "__main__":
262
+ sys.exit(main())
@@ -0,0 +1,74 @@
1
+ # TODO rmeove duplicate route finder this is a test implementation
2
+ routes_table = [
3
+ {"target": "local", "source": "int-nano", "host": "local"},
4
+ {"target": "int-nano", "source": "local", "host": "local"},
5
+ {
6
+ "target": "horeka",
7
+ "source": "local",
8
+ "host": "horeka",
9
+ },
10
+ {
11
+ "target": "local",
12
+ "source": "horeka",
13
+ "host": "horeka",
14
+ },
15
+ ]
16
+
17
+
18
+ def find_route(target, source):
19
+ """
20
+ Find the minimal route from source to target using available routes.
21
+
22
+ Args:
23
+ target (str): The target system
24
+ source (str): The source system
25
+
26
+ Returns:
27
+ list: A list of systems to visit in order, or None if no route exists
28
+ """
29
+ # If source and target are the same, no transfer needed
30
+ if source == target:
31
+ return [source]
32
+
33
+ # Build a graph of available routes
34
+ graph = {}
35
+ for route in routes_table:
36
+ if route["source"] not in graph:
37
+ graph[route["source"]] = []
38
+ graph[route["source"]].append(route["target"])
39
+
40
+ # If source isn't in our graph, we can't start a route
41
+ if source not in graph:
42
+ return None
43
+
44
+ # Use BFS to find the shortest path
45
+ visited = {source}
46
+ queue = [(source, [source])] # (current_node, path_so_far)
47
+
48
+ while queue:
49
+ current, path = queue.pop(0)
50
+
51
+ # Check neighbors (available transfer targets)
52
+ if current in graph:
53
+ for neighbor in graph[current]:
54
+ if neighbor == target:
55
+ # Found a path to target
56
+ return path + [neighbor]
57
+
58
+ if neighbor not in visited:
59
+ visited.add(neighbor)
60
+ queue.append((neighbor, path + [neighbor]))
61
+
62
+ # If we've exhausted all possibilities without finding a path
63
+ return None
64
+
65
+
66
+ if __name__ == "__main__":
67
+ # Example usage
68
+ target = "int-nano"
69
+ source = "horeka"
70
+ route = find_route(target, source)
71
+ if route:
72
+ print(f"Route from {source} to {target}: {' -> '.join(route)}")
73
+ else:
74
+ print(f"No route found from {source} to {target}")
@@ -0,0 +1,162 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ import re
5
+ import socket
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ from simstack.core.context import context
10
+ from simstack.models.runner_model import RunnerEvent, RunnerType, RunnerEventEnum
11
+
12
+ logger = logging.getLogger("RunnerUtil")
13
+
14
+
15
+ def ensure_crontab_entry(command, schedule="*/10 * * * *"):
16
+ """
17
+ Checks if a crontab entry exists and adds it if it doesn't.
18
+
19
+ Args:
20
+ command (str): The command to be executed by cron
21
+ schedule (str): The cron schedule expression (default: every 10 minutes)
22
+
23
+ Returns:
24
+ bool: True if entry was added, False if it already existed
25
+ """
26
+ # The full crontab entry to look for
27
+ cron_entry = f"{schedule} {command}"
28
+
29
+ try:
30
+ # Get current crontab
31
+ result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
32
+
33
+ # Check if command succeeded
34
+ if result.returncode == 0:
35
+ current_crontab = result.stdout
36
+ else:
37
+ # If crontab is empty or doesn't exist
38
+ current_crontab = ""
39
+
40
+ # Check if our entry already exists (using regex to handle whitespace variations)
41
+ entry_pattern = (
42
+ re.escape(schedule.strip()) + r"\s+" + re.escape(command.strip())
43
+ )
44
+ if re.search(entry_pattern, current_crontab):
45
+ logger.info(f"Crontab entry for '{command}' already exists.")
46
+
47
+ return False
48
+
49
+ # Add our entry to crontab
50
+ new_crontab = current_crontab
51
+ if new_crontab and not new_crontab.endswith("\n"):
52
+ new_crontab += "\n"
53
+ new_crontab += cron_entry + "\n"
54
+
55
+ # Write back to crontab
56
+ process = subprocess.Popen(["crontab", "-"], stdin=subprocess.PIPE, text=True)
57
+ process.communicate(input=new_crontab)
58
+
59
+ if process.returncode == 0:
60
+ logger.info(f"Successfully added crontab entry: '{cron_entry}'")
61
+ return True
62
+ else:
63
+ logger.error(f"Failed to update crontab, return code: {process.returncode}")
64
+ return False
65
+
66
+ except Exception as e:
67
+ logger.error(f"Error managing crontab: {str(e)}")
68
+ return False
69
+
70
+
71
+ async def graceful_shutdown(resource_name):
72
+ # Log shutdown
73
+
74
+ pid = os.getpid()
75
+ user = os.environ.get("USER", os.environ.get("USERNAME", "unknown"))
76
+ hostname = socket.gethostname()
77
+
78
+ # Capture current user info for the subprocess
79
+ current_user = os.environ.get("USER", os.environ.get("USERNAME"))
80
+
81
+ runner_event = RunnerEvent(
82
+ resource=resource_name,
83
+ runner_type=RunnerType.RESOURCE_RUNNER,
84
+ event=RunnerEventEnum.SHUTDOWN,
85
+ pid=pid,
86
+ user=user,
87
+ hostname=hostname,
88
+ message=f"Graceful shutdown of {pid} by {current_user}",
89
+ )
90
+ await context.db.save(runner_event)
91
+
92
+ # Cancel all remaining tasks
93
+ for task in asyncio.all_tasks():
94
+ if task is not asyncio.current_task():
95
+ task.cancel()
96
+
97
+ # Give tasks time to cancel
98
+ await asyncio.sleep(0.5)
99
+
100
+ # Signal to the main function to exit
101
+ raise SystemExit(0)
102
+
103
+
104
+ async def restart(resource_name):
105
+ logger.info(
106
+ f"Restarting node runner for resource: {resource_name} {context.config.python_path}"
107
+ )
108
+
109
+ cmd = Path(context.config.python_path[0]) / "scripts" / "check_runner.sh"
110
+
111
+ # Get current user information
112
+ current_uid = os.getuid()
113
+ current_gid = os.getgid()
114
+ current_pid = os.getpid()
115
+ logger.info(f"Current user ID: {current_uid}, group ID: {current_gid}")
116
+
117
+ # Create environment with preserved user information
118
+ env = os.environ.copy()
119
+
120
+ # Start a new process with the same arguments
121
+
122
+ logger.info(f"Restarting node runner for resource: {resource_name} {str(cmd)}")
123
+
124
+ result = subprocess.Popen(str(cmd), start_new_session=True, env=env)
125
+ # Log the restart
126
+ message = f"user: {current_uid} group: {current_gid} pid: {current_pid} result: {result.pid}"
127
+ runner_event = RunnerEvent(
128
+ resource=resource_name,
129
+ runner_type=RunnerType.RESOURCE_RUNNER,
130
+ event=RunnerEventEnum.RESTART,
131
+ message=message,
132
+ )
133
+ await context.db.save(runner_event)
134
+ # Exit current process
135
+ logger.info(f"Restarted node runner for resource: {resource_name}")
136
+ # await graceful_shutdown(resource_name)
137
+
138
+
139
+ async def schedule_restart(resource_name, restart_minutes):
140
+ """Schedule a restart after the specified minutes"""
141
+ try:
142
+ logger.info(
143
+ f"Runner will restart in {restart_minutes} minutes on resource {resource_name} "
144
+ )
145
+ await asyncio.sleep(restart_minutes * 60) # Convert minutes to seconds
146
+ # try:
147
+ # if context.config.python_path:
148
+ # path = Path(context.config.python_path[0])
149
+ # command = str(path / "scripts" / "check_runner.sh") + " >> " + str(path / "cron_log.log") + " 2>&1"
150
+ # logger.info(f"Path: {command} ")
151
+ # if not ensure_crontab_entry(command):
152
+ # runner_entry = RunnerEvent(resource=resource_name, runner_type=RunnerType.RESOURCE_RUNNER,
153
+ # event=RunnerEventEnum.CRONTAB_GONE,
154
+ # message="Crontab entry for resource runner was not found")
155
+ # await context.db.save(runner_entry)
156
+ # except Exception as e:
157
+ # logger.error("Something went wrong for crontab")
158
+ await restart(resource_name)
159
+ await graceful_shutdown(resource_name)
160
+ except asyncio.CancelledError:
161
+ logger.info("Restart task was cancelled")
162
+ raise
@@ -0,0 +1,194 @@
1
+ import threading
2
+ from typing import Dict
3
+
4
+ from simstack.core.artifacts import ArtifactArguments
5
+
6
+
7
+ def safe_code_executor(
8
+ code_string: str, artifact_arguments: ArtifactArguments, timeout: int = 30
9
+ ) -> Dict:
10
+ """
11
+ Safely executes Python code from a string with a controlled environment.
12
+
13
+ Args:
14
+ code_string (str): The Python code to execute
15
+ artifact_arguments (ArtifactArguments): Artifact arguments to be used in the execution environment
16
+ timeout (int, optional): Maximum execution time in seconds before timeout
17
+
18
+ Returns:
19
+ Dict: A dictionary containing:
20
+ 'success' (bool): Whether execution was successful
21
+ 'result': The return value if successful
22
+ 'error': Error message if unsuccessful
23
+ 'error_type': Type of error if unsuccessful
24
+
25
+ """
26
+ import ast
27
+ import traceback
28
+
29
+ # Default result structure
30
+ result = {"success": False, "result": None, "error": None, "error_type": None}
31
+
32
+ # Verify code is not empty
33
+ if not code_string or not code_string.strip():
34
+ result["error"] = "Empty code string provided"
35
+ result["error_type"] = "ValueError"
36
+ return result
37
+
38
+ # Create a safe globals dictionary
39
+ safe_globals = {"__builtins__": {}}
40
+
41
+ # Add only whitelisted builtins that are considered safe
42
+ for name in [
43
+ "abs",
44
+ "all",
45
+ "any",
46
+ "bool",
47
+ "dict",
48
+ "enumerate",
49
+ "filter",
50
+ "float",
51
+ "frozenset",
52
+ "int",
53
+ "isinstance",
54
+ "issubclass",
55
+ "len",
56
+ "list",
57
+ "map",
58
+ "max",
59
+ "min",
60
+ "print",
61
+ "range",
62
+ "reversed",
63
+ "round",
64
+ "set",
65
+ "slice",
66
+ "sorted",
67
+ "str",
68
+ "sum",
69
+ "tuple",
70
+ "zip",
71
+ ]:
72
+ safe_globals["__builtins__"][name] = __builtins__[name]
73
+
74
+ # Add artifact models to safe globals
75
+ from simstack.models.charts_artifact import (
76
+ ChartArtifactModel,
77
+ AGLineSeriesConfig,
78
+ AGChartAxisConfig,
79
+ AGChartTitleConfig,
80
+ AGChartLegendConfig,
81
+ )
82
+ from simstack.models.table_artifact import TableArtifactModel
83
+ from examples.science.electronic_structure.spectra.plot_spectra import (
84
+ make_multi_line_chart,
85
+ )
86
+ from simstack.models import ArtifactModel
87
+
88
+ safe_globals.update(
89
+ {
90
+ "ChartArtifactModel": ChartArtifactModel,
91
+ "TableArtifactModel": TableArtifactModel,
92
+ "AGLineSeriesConfig": AGLineSeriesConfig,
93
+ "AGChartAxisConfig": AGChartAxisConfig,
94
+ "AGChartTitleConfig": AGChartTitleConfig,
95
+ "AGChartLegendConfig": AGChartLegendConfig,
96
+ "make_multi_line_chart": make_multi_line_chart,
97
+ "ArtifactModel": ArtifactModel,
98
+ "ArtifactArguments": ArtifactArguments,
99
+ }
100
+ )
101
+
102
+ # Add artifact_arguments attributes to safe globals
103
+ # Extract all attributes from the artifact_arguments instance
104
+ for attr_name in dir(artifact_arguments):
105
+ if not attr_name.startswith("_"): # Skip private/magic methods
106
+ attr_value = getattr(artifact_arguments, attr_name)
107
+ if not callable(attr_value): # Skip methods, only include data attributes
108
+ safe_globals[attr_name] = attr_value
109
+
110
+ # Also add the full artifact_arguments object itself for backward compatibility
111
+ safe_globals["arg"] = artifact_arguments
112
+
113
+ # Verify code is syntactically correct
114
+ try:
115
+ ast.parse(code_string)
116
+ except SyntaxError as e:
117
+ result["error"] = str(e)
118
+ result["error_type"] = "SyntaxError"
119
+ return result
120
+
121
+ # Cross-platform timeout implementation using threading
122
+ execution_result = {
123
+ "completed": False,
124
+ "exception": None,
125
+ "local_vars": {},
126
+ "function_result": None,
127
+ }
128
+
129
+ def execute_code():
130
+ try:
131
+ local_vars = {}
132
+ exec(code_string, safe_globals, local_vars)
133
+ execution_result["local_vars"] = local_vars
134
+
135
+ # Look for a function in local_vars and call it with artifact_arguments
136
+ function_result = None
137
+ for var_name, var_value in local_vars.items():
138
+ if callable(var_value) and not var_name.startswith("_"):
139
+ # Found a function, call it with artifact_arguments
140
+ try:
141
+ function_result = var_value(artifact_arguments)
142
+ execution_result["function_result"] = function_result
143
+ break
144
+ except Exception as func_e:
145
+ execution_result["exception"] = func_e
146
+ execution_result["completed"] = True
147
+ return
148
+
149
+ execution_result["completed"] = True
150
+ except Exception as e:
151
+ execution_result["exception"] = e
152
+ execution_result["completed"] = True
153
+
154
+ try:
155
+ # Start execution in a separate thread
156
+ thread = threading.Thread(target=execute_code, daemon=True)
157
+ thread.start()
158
+ thread.join(timeout=timeout)
159
+
160
+ if thread.is_alive():
161
+ # Timeout occurred
162
+ result["error"] = f"Code execution timed out after {timeout} seconds"
163
+ result["error_type"] = "TimeoutError"
164
+ return result
165
+
166
+ if execution_result["exception"]:
167
+ # Exception occurred during execution
168
+ e = execution_result["exception"]
169
+ result["error"] = str(e)
170
+ result["error_type"] = e.__class__.__name__
171
+ result["traceback"] = traceback.format_exc()
172
+ return result
173
+
174
+ # Successful execution
175
+ # Return the function result if available, otherwise return local vars
176
+ if execution_result["function_result"] is not None:
177
+ result["result"] = execution_result["function_result"]
178
+ else:
179
+ local_vars = execution_result["local_vars"]
180
+ if "result" in local_vars:
181
+ result["result"] = local_vars["result"]
182
+ else:
183
+ result["result"] = {
184
+ k: v for k, v in local_vars.items() if not k.startswith("_")
185
+ }
186
+
187
+ result["success"] = True
188
+
189
+ except Exception as e:
190
+ result["error"] = str(e)
191
+ result["error_type"] = e.__class__.__name__
192
+ result["traceback"] = traceback.format_exc()
193
+
194
+ return result