py-adtools 0.3.2__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.
@@ -0,0 +1,244 @@
1
+ """
2
+ Copyright (c) 2025 Rui Zhang <rzhang.cs@gmail.com>
3
+
4
+ NOTICE: This code is under MIT license. This code is intended for academic/research purposes only.
5
+ Commercial use of this software or its derivatives requires prior written permission.
6
+ """
7
+
8
+ import multiprocessing
9
+ import pickle
10
+ import time
11
+ import uuid
12
+ from multiprocessing import shared_memory, resource_tracker
13
+ from queue import Empty
14
+ from typing import Any, Dict, List, TypedDict, Optional, Tuple
15
+ import multiprocessing.managers
16
+ import traceback
17
+
18
+ import psutil
19
+
20
+ from adtools.sandbox.utils import _redirect_to_devnull
21
+
22
+ __all__ = ["ExecutionResults", "SandboxExecutor"]
23
+
24
+
25
+ class ExecutionResults(TypedDict):
26
+ result: Any
27
+ evaluate_time: float
28
+ error_msg: str
29
+
30
+
31
+ class SandboxExecutor:
32
+
33
+ def __init__(
34
+ self,
35
+ evaluate_worker: Any,
36
+ find_and_kill_children_evaluation_process: bool = False,
37
+ debug_mode: bool = False,
38
+ *,
39
+ join_timeout_seconds: int = 10,
40
+ ):
41
+ """Evaluator interface for evaluating the Python algorithm program. Override this class and implement
42
+ 'evaluate_program' method, then invoke 'self.evaluate()' or 'self.secure_evaluate()' for evaluation.
43
+
44
+ Args:
45
+ exec_code: Using 'exec()' to execute the program code and obtain the callable functions and classes,
46
+ which will be passed to 'self.evaluate_program()'. Set this parameter to 'False' if you are going to
47
+ evaluate a Python scripy. Note that if the parameter is set to 'False', the arguments 'callable_...'
48
+ in 'self.evaluate_program()' will no longer be affective.
49
+ find_and_kill_children_evaluation_process: If using 'self.secure_evaluate', kill children processes
50
+ when they are terminated. Note that it is suggested to set to 'False' if the evaluation process
51
+ does not start new processes.
52
+ debug_mode: Debug mode.
53
+ join_timeout_seconds: Timeout in seconds to wait for the process to finish. Kill the process if timeout.
54
+ """
55
+ self.evaluate_worker = evaluate_worker
56
+ self.debug_mode = debug_mode
57
+ self.find_and_kill_children_evaluation_process = (
58
+ find_and_kill_children_evaluation_process
59
+ )
60
+ self.join_timeout_seconds = join_timeout_seconds
61
+
62
+ def _kill_process_and_its_children(self, process: multiprocessing.Process):
63
+ if self.find_and_kill_children_evaluation_process:
64
+ # Find all children processes
65
+ try:
66
+ parent = psutil.Process(process.pid)
67
+ children_processes = parent.children(recursive=True)
68
+ except psutil.NoSuchProcess:
69
+ children_processes = []
70
+ else:
71
+ children_processes = []
72
+ # Terminate parent process
73
+ process.terminate()
74
+ process.join(timeout=self.join_timeout_seconds)
75
+ if process.is_alive():
76
+ process.kill()
77
+ process.join()
78
+ # Kill all children processes
79
+ for child in children_processes:
80
+ if self.debug_mode:
81
+ print(f"Killing process {process.pid}'s children process {child.pid}")
82
+ child.terminate()
83
+
84
+ def _execute_and_put_res_in_shared_memory(
85
+ self,
86
+ worker_execute_method_name: str,
87
+ method_args: Optional[List | Tuple],
88
+ method_kwargs: Optional[Dict],
89
+ meta_queue: multiprocessing.Queue,
90
+ redirect_to_devnull: bool,
91
+ shm_name_id: str,
92
+ ):
93
+ """Evaluate and store result in shared memory (for large results)."""
94
+ # Redirect STDOUT and STDERR to '/dev/null'
95
+ if redirect_to_devnull:
96
+ _redirect_to_devnull()
97
+
98
+ if hasattr(self.evaluate_worker, worker_execute_method_name): # todo
99
+ method_to_call = getattr(self.evaluate_worker, worker_execute_method_name)
100
+ else:
101
+ raise RuntimeError(
102
+ f"Method named '{worker_execute_method_name}' not found."
103
+ )
104
+
105
+ # Execute and get results
106
+ # noinspection PyBroadException
107
+ try:
108
+ # Execute the target method and get result
109
+ args = method_args or []
110
+ kwargs = method_kwargs or {}
111
+ res = method_to_call(*args, **kwargs)
112
+
113
+ # Dump the results to data
114
+ data = pickle.dumps(res, protocol=pickle.HIGHEST_PROTOCOL)
115
+ # Create shared memory using the ID provided by the parent
116
+ # We must use create=True here as the child is responsible for allocation
117
+ shm = shared_memory.SharedMemory(
118
+ create=True, name=shm_name_id, size=len(data)
119
+ )
120
+ # Unregister the shared memory block from the resource tracker in this child process
121
+ # The shared memory will be managed in the parent process
122
+ # noinspection PyProtectedMember, PyUnresolvedReferences
123
+ resource_tracker.unregister(name=shm._name, rtype="shared_memory")
124
+
125
+ # Write data
126
+ shm.buf[: len(data)] = data
127
+ # We only need to send back the size, as the parent already knows the name.
128
+ # Sending (True, size) to indicate success.
129
+ meta_queue.put((True, len(data)))
130
+ # Child closes its handle
131
+ shm.close()
132
+ except:
133
+ if self.debug_mode:
134
+ traceback.print_exc()
135
+ # Put the exception message to the queue
136
+ # Sending (False, error_message) to indicate failure.
137
+ meta_queue.put((False, str(traceback.format_exc())))
138
+
139
+ def secure_execute(
140
+ self,
141
+ worker_execute_method_name: str,
142
+ method_args: Optional[List | Tuple] = None,
143
+ method_kwargs: Optional[Dict] = None,
144
+ timeout_seconds: int | float = None,
145
+ redirect_to_devnull: bool = False,
146
+ **kwargs,
147
+ ) -> ExecutionResults:
148
+ """Evaluate program in a new process.
149
+ This enables timeout restriction and output redirection.
150
+
151
+ Args:
152
+ worker_execute_method_name: Name of the worker execute method.
153
+ method_args: Arguments of the worker execute method.
154
+ method_kwargs: Keyword arguments of the worker execute method.
155
+ timeout_seconds: return 'None' if the execution time exceeds 'timeout_seconds'.
156
+ redirect_to_devnull: redirect any output to '/dev/null'.
157
+
158
+ Returns:
159
+ Returns the evaluation results. If the 'get_evaluate_time' is True,
160
+ the return value will be (Results, Time).
161
+ """
162
+ # Evaluate and get results
163
+ # noinspection PyBroadException
164
+ try:
165
+ # Create a meta queue to get meta information from the evaluation process
166
+ meta_queue = multiprocessing.Queue()
167
+ # Generate a unique name for the shared memory block in the PARENT process.
168
+ # This allows the parent to clean it up even if the child is killed.
169
+ unique_shm_name = f"psm_{uuid.uuid4().hex[:8]}"
170
+
171
+ process = multiprocessing.Process(
172
+ target=self._execute_and_put_res_in_shared_memory,
173
+ args=(
174
+ worker_execute_method_name,
175
+ method_args,
176
+ method_kwargs,
177
+ meta_queue,
178
+ redirect_to_devnull,
179
+ unique_shm_name,
180
+ ),
181
+ )
182
+ evaluate_start_time = time.time()
183
+ process.start()
184
+
185
+ try:
186
+ # Try to get the metadata before timeout
187
+ meta = meta_queue.get(timeout=timeout_seconds)
188
+ # Calculate evaluation time
189
+ eval_time = time.time() - evaluate_start_time
190
+ except Empty:
191
+ if self.debug_mode:
192
+ print(f"DEBUG: evaluation time exceeds {timeout_seconds}s.")
193
+
194
+ # Evaluation timeout happens, we return 'None' as well as the actual evaluate time
195
+ return ExecutionResults(
196
+ result=None,
197
+ evaluate_time=time.time() - evaluate_start_time,
198
+ error_msg="Evaluation timeout.",
199
+ )
200
+
201
+ # The 'meta' is now (Success_Flag, Data_Size_or_Error_Msg)
202
+ success, payload = meta
203
+
204
+ if not success:
205
+ # Payload is the error message
206
+ error_msg = payload
207
+ result = None
208
+ else:
209
+ error_msg = ""
210
+ # Payload is the size of the data
211
+ size = payload
212
+ # Attach to the existing shared memory by name
213
+ shm = shared_memory.SharedMemory(name=unique_shm_name)
214
+ buf = bytes(shm.buf[:size])
215
+ # Load results from buffer
216
+ result = pickle.loads(buf)
217
+ shm.close()
218
+
219
+ return ExecutionResults(
220
+ result=result, evaluate_time=eval_time, error_msg=error_msg
221
+ )
222
+ except:
223
+ if self.debug_mode:
224
+ print(f"DEBUG: exception in shared evaluate:\n{traceback.format_exc()}")
225
+
226
+ return ExecutionResults(
227
+ result=None,
228
+ evaluate_time=time.time() - evaluate_start_time,
229
+ error_msg=str(traceback.format_exc()),
230
+ )
231
+ finally:
232
+ self._kill_process_and_its_children(process)
233
+ # Critical Cleanup: Ensure the shared memory is unlinked from the OS
234
+ # This runs whether the process finished, timed out, or crashed
235
+ try:
236
+ # Attempt to attach to the shared memory block
237
+ shm_cleanup = shared_memory.SharedMemory(name=unique_shm_name)
238
+ shm_cleanup.close()
239
+ # Unlink (delete) it from the system, and close the shared memory
240
+ shm_cleanup.unlink()
241
+ except FileNotFoundError:
242
+ # This is normal if the child process never reached the creation step
243
+ # (e.g. crashed during calculation before creating SHM)
244
+ pass
@@ -0,0 +1,194 @@
1
+ """
2
+ Copyright (c) 2025 Rui Zhang <rzhang.cs@gmail.com>
3
+
4
+ NOTICE: This code is under MIT license. This code is intended for academic/research purposes only.
5
+ Commercial use of this software or its derivatives requires prior written permission.
6
+ """
7
+
8
+ import logging
9
+ import os
10
+ import sys
11
+ import time
12
+ import traceback
13
+ from typing import Any, Dict, List, Optional, Tuple
14
+
15
+ from adtools.sandbox.sandbox_executor import SandboxExecutor, ExecutionResults
16
+ from adtools.sandbox.utils import _redirect_to_devnull
17
+
18
+ __all__ = ["SandboxExecutorRay"]
19
+
20
+
21
+ class SandboxExecutorRay(SandboxExecutor):
22
+
23
+ def __init__(
24
+ self,
25
+ evaluate_worker: Any,
26
+ init_ray: bool = True,
27
+ debug_mode: bool = False,
28
+ *,
29
+ ray_rotation_max_bytes: int = 50 * 1024 * 1024, # 50 MB
30
+ ray_rotation_backup_count: int = 1,
31
+ ):
32
+ """Evaluator using Ray for secure, isolated execution.
33
+
34
+ Args:
35
+ evaluate_worker: The worker object to be executed.
36
+ init_ray: Whether to initialize ray.
37
+ debug_mode: Enable debug print statements.
38
+ ray_rotation_max_bytes: Max bytes for ray log rotation.
39
+ ray_rotation_backup_count: Backup count for ray log rotation.
40
+ """
41
+ super().__init__(
42
+ evaluate_worker=evaluate_worker,
43
+ debug_mode=debug_mode,
44
+ )
45
+
46
+ import ray
47
+
48
+ if init_ray:
49
+ if ray.is_initialized():
50
+ logging.warning(
51
+ f"Ray is already initialized. "
52
+ f"If you want to disable reinit, "
53
+ f"please set '{self.__class__.__name__}(..., init_ray=False)'."
54
+ )
55
+ # Set environment variable before Ray initialization
56
+ os.environ["RAY_ACCEL_ENV_VAR_OVERRIDE_ON_ZERO"] = "0"
57
+ os.environ["RAY_ROTATION_MAX_BYTES"] = str(ray_rotation_max_bytes)
58
+ os.environ["RAY_ROTATION_BACKUP_COUNT"] = str(ray_rotation_backup_count)
59
+
60
+ # Initialize Ray
61
+ ray.init(
62
+ ignore_reinit_error=True,
63
+ include_dashboard=False,
64
+ logging_level=logging.ERROR,
65
+ log_to_driver=True,
66
+ )
67
+ elif not ray.is_initialized():
68
+ raise RuntimeError(
69
+ f"Ray is not initialized. "
70
+ f"Please set '{self.__class__.__name__}(..., init_ray=True)'."
71
+ )
72
+
73
+ def secure_execute(
74
+ self,
75
+ worker_execute_method_name: str,
76
+ method_args: Optional[List | Tuple] = None,
77
+ method_kwargs: Optional[Dict] = None,
78
+ timeout_seconds: int | float = None,
79
+ redirect_to_devnull: bool = False,
80
+ *,
81
+ ray_actor_options: dict[str, Any] = None,
82
+ **kwargs,
83
+ ) -> ExecutionResults:
84
+ """Evaluates the program in a separate Ray Actor (process).
85
+ This enables timeout restriction and output redirection.
86
+
87
+ Args:
88
+ worker_execute_method_name: Name of the worker execute method.
89
+ method_args: Arguments of the worker execute method.
90
+ method_kwargs: Keyword arguments of the worker execute method.
91
+ timeout_seconds: return 'None' if the execution time exceeds 'timeout_seconds'.
92
+ redirect_to_devnull: redirect any output to '/dev/null'.
93
+ ray_actor_options: Ray actor options.
94
+
95
+ Returns:
96
+ Returns the evaluation results. If the 'get_evaluate_time' is True,
97
+ the return value will be (Results, Time).
98
+ """
99
+ import ray
100
+ from ray.exceptions import GetTimeoutError
101
+
102
+ if ray_actor_options is None:
103
+ ray_actor_options = {}
104
+ else:
105
+ ray_actor_options = ray_actor_options.copy()
106
+
107
+ # Propagate sys.path and PYTHONPATH
108
+ runtime_env = ray_actor_options.get("runtime_env", {})
109
+ env_vars = runtime_env.get("env_vars", {})
110
+
111
+ current_paths = [p for p in sys.path if p and os.path.exists(p)]
112
+ existing_pythonpath = env_vars.get("PYTHONPATH", "")
113
+ if existing_pythonpath:
114
+ current_paths.insert(0, existing_pythonpath)
115
+
116
+ # Deduplicate preserving order
117
+ unique_paths = []
118
+ seen = set()
119
+ for p in current_paths:
120
+ if p not in seen:
121
+ unique_paths.append(p)
122
+ seen.add(p)
123
+
124
+ env_vars["PYTHONPATH"] = os.pathsep.join(unique_paths)
125
+ runtime_env["env_vars"] = env_vars
126
+ ray_actor_options["runtime_env"] = runtime_env
127
+
128
+ # Create Remote Worker Class
129
+ RemoteWorkerClass = ray.remote(max_concurrency=1)(_RayWorker)
130
+
131
+ # Create worker
132
+ worker = RemoteWorkerClass.options(**ray_actor_options).remote(
133
+ self.evaluate_worker
134
+ )
135
+
136
+ start_time = time.time()
137
+ try:
138
+ future = worker.execute.remote(
139
+ worker_execute_method_name,
140
+ method_args,
141
+ method_kwargs,
142
+ redirect_to_devnull,
143
+ )
144
+ result = ray.get(future, timeout=timeout_seconds)
145
+ return ExecutionResults(
146
+ result=result,
147
+ evaluate_time=time.time() - start_time,
148
+ error_msg="",
149
+ )
150
+ except GetTimeoutError:
151
+ if self.debug_mode:
152
+ print(f"DEBUG: Ray evaluation timed out after {timeout_seconds}s.")
153
+ return ExecutionResults(
154
+ result=None,
155
+ evaluate_time=time.time() - start_time,
156
+ error_msg="Evaluation timeout.",
157
+ )
158
+ except Exception:
159
+ if self.debug_mode:
160
+ print(f"DEBUG: Ray evaluation exception:\n{traceback.format_exc()}")
161
+ return ExecutionResults(
162
+ result=None,
163
+ evaluate_time=time.time() - start_time,
164
+ error_msg=str(traceback.format_exc()),
165
+ )
166
+ finally:
167
+ ray.kill(worker, no_restart=True)
168
+
169
+
170
+ class _RayWorker:
171
+ """A standalone Ray Actor used to execute the evaluation logic in a separate process."""
172
+
173
+ def __init__(self, evaluate_worker: Any):
174
+ self.evaluate_worker = evaluate_worker
175
+
176
+ def execute(
177
+ self,
178
+ worker_execute_method_name: str,
179
+ method_args: Optional[List | Tuple],
180
+ method_kwargs: Optional[Dict],
181
+ redirect_to_devnull: bool,
182
+ ) -> Any:
183
+ if redirect_to_devnull:
184
+ _redirect_to_devnull()
185
+
186
+ if hasattr(self.evaluate_worker, worker_execute_method_name):
187
+ method_to_call = getattr(self.evaluate_worker, worker_execute_method_name)
188
+ args = method_args or []
189
+ kwargs = method_kwargs or {}
190
+ return method_to_call(*args, **kwargs)
191
+ else:
192
+ raise RuntimeError(
193
+ f"Method named '{worker_execute_method_name}' not found in worker."
194
+ )
@@ -0,0 +1,32 @@
1
+ """
2
+ Copyright (c) 2025 Rui Zhang <rzhang.cs@gmail.com>
3
+
4
+ NOTICE: This code is under MIT license. This code is intended for academic/research purposes only.
5
+ Commercial use of this software or its derivatives requires prior written permission.
6
+ """
7
+
8
+ import multiprocessing
9
+ import os
10
+ import sys
11
+ import functools
12
+
13
+ from typing import Literal
14
+
15
+
16
+ def _set_mp_start_method(
17
+ multiprocessing_start_method: Literal["default", "auto", "fork", "spawn"],
18
+ ):
19
+ if multiprocessing_start_method == "auto":
20
+ # Force macOS and Linux use 'fork' to generate new process
21
+ if sys.platform.startswith("darwin") or sys.platform.startswith("linux"):
22
+ multiprocessing.set_start_method("fork", force=True)
23
+ elif multiprocessing_start_method == "fork":
24
+ multiprocessing.set_start_method("fork", force=True)
25
+ elif multiprocessing_start_method == "spawn":
26
+ multiprocessing.set_start_method("spawn", force=True)
27
+
28
+
29
+ def _redirect_to_devnull():
30
+ with open(os.devnull, "w") as devnull:
31
+ os.dup2(devnull.fileno(), sys.stdout.fileno())
32
+ os.dup2(devnull.fileno(), sys.stderr.fileno())