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,388 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Optional, List, ClassVar, Dict, Any
|
|
3
|
+
|
|
4
|
+
from odmantic import Field, EmbeddedModel
|
|
5
|
+
from pydantic import field_validator, model_validator
|
|
6
|
+
|
|
7
|
+
from simstack.core.resources import allowed_resources
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Resource(EmbeddedModel):
|
|
11
|
+
"""
|
|
12
|
+
Resource whose value is validated against the allowed resources
|
|
13
|
+
only when the value is *read*, not when it is *set* or constructed.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
# Regular model field, no leading underscore
|
|
17
|
+
value: str
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def allowed_values(cls) -> List[str]:
|
|
21
|
+
"""
|
|
22
|
+
Get the list of allowed resource values.
|
|
23
|
+
"""
|
|
24
|
+
# Use the AllowedResources singleton
|
|
25
|
+
return allowed_resources.get_resources()
|
|
26
|
+
|
|
27
|
+
def _validate_value_on_read(self, v: str) -> str:
|
|
28
|
+
"""
|
|
29
|
+
Validate the value against allowed resources when it is read.
|
|
30
|
+
"""
|
|
31
|
+
if v not in self.allowed_values():
|
|
32
|
+
allowed_str = ", ".join(repr(val) for val in self.allowed_values())
|
|
33
|
+
raise ValueError(
|
|
34
|
+
f"Invalid resource value: {v!r}. Allowed values are: {allowed_str}"
|
|
35
|
+
)
|
|
36
|
+
return v
|
|
37
|
+
|
|
38
|
+
def __getattribute__(self, name: str):
|
|
39
|
+
"""
|
|
40
|
+
Intercept reads of `value` and validate at access time.
|
|
41
|
+
Setting/constructing does not perform validation.
|
|
42
|
+
"""
|
|
43
|
+
if name == "value":
|
|
44
|
+
# Get raw stored value without triggering this override again
|
|
45
|
+
raw_value = object.__getattribute__(self, "__dict__").get("value")
|
|
46
|
+
return object.__getattribute__(self, "_validate_value_on_read")(raw_value)
|
|
47
|
+
return object.__getattribute__(self, name)
|
|
48
|
+
|
|
49
|
+
def __str__(self) -> str:
|
|
50
|
+
"""String representation of the resource (validated on access)."""
|
|
51
|
+
return self.value
|
|
52
|
+
|
|
53
|
+
def __repr__(self) -> str:
|
|
54
|
+
"""Debug representation of the resource (validated on access)."""
|
|
55
|
+
return f"{self.__class__.__name__}(value={self.value!r})"
|
|
56
|
+
|
|
57
|
+
def __eq__(self, other) -> bool:
|
|
58
|
+
"""Equal comparison operator (uses validated value)."""
|
|
59
|
+
if isinstance(other, Resource):
|
|
60
|
+
return self.value == other.value
|
|
61
|
+
elif isinstance(other, str):
|
|
62
|
+
return self.value == other
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
def __ne__(self, other) -> bool:
|
|
66
|
+
"""Not equal comparison operator."""
|
|
67
|
+
return not self.__eq__(other)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Queue(str, Enum):
|
|
71
|
+
DEFAULT = "default"
|
|
72
|
+
SLURM_QUEUE = "slurm-queue"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# TODO Fix Slurm Parameters
|
|
76
|
+
class SlurmParameters(EmbeddedModel):
|
|
77
|
+
# Essential Resource Allocation Parameters
|
|
78
|
+
nodes: Optional[int] = Field(default=1, ge=1, description="Number of compute nodes")
|
|
79
|
+
tasks: Optional[int] = Field(
|
|
80
|
+
default=1, ge=1, description="Total number of tasks/processes"
|
|
81
|
+
)
|
|
82
|
+
tasks_per_node: Optional[int] = Field(
|
|
83
|
+
default=1, ge=1, description="Number of tasks per node"
|
|
84
|
+
)
|
|
85
|
+
cpus_per_task: Optional[int] = Field(
|
|
86
|
+
default=1, ge=1, description="Number of CPU cores per task"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Memory Parameters
|
|
90
|
+
mem: Optional[str] = Field(
|
|
91
|
+
default="1G", description="Memory per node (e.g., '32G', '1024M')"
|
|
92
|
+
)
|
|
93
|
+
mem_per_cpu: Optional[str] = Field(
|
|
94
|
+
default=None, description="Memory per CPU core (e.g., '4G')"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Time Parameters
|
|
98
|
+
time: Optional[str] = Field(
|
|
99
|
+
default="1:00:00",
|
|
100
|
+
description="Maximum runtime (e.g., '24:00:00', '1-12:00:00')",
|
|
101
|
+
)
|
|
102
|
+
begin: Optional[str] = Field(
|
|
103
|
+
default=None, description="Defer job start until specified time"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Queue/Partition Parameters
|
|
107
|
+
partition: Optional[str] = Field(
|
|
108
|
+
default=None, description="Queue/partition to submit to"
|
|
109
|
+
)
|
|
110
|
+
qos: Optional[str] = Field(default=None, description="Quality of Service level")
|
|
111
|
+
|
|
112
|
+
# Job Information Parameters
|
|
113
|
+
job_name: Optional[str] = Field(default="simstack", description="Job name")
|
|
114
|
+
output: Optional[str] = Field(default=None, description="Standard output file path")
|
|
115
|
+
error: Optional[str] = Field(default=None, description="Standard error file path")
|
|
116
|
+
|
|
117
|
+
# Notification Parameters
|
|
118
|
+
mail_type: Optional[str] = Field(
|
|
119
|
+
default=None, description="Email notification triggers (BEGIN,END,FAIL,ALL)"
|
|
120
|
+
)
|
|
121
|
+
mail_user: Optional[str] = Field(
|
|
122
|
+
default=None, description="Email address for notifications"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# GPU Parameters
|
|
126
|
+
gres: Optional[str] = Field(
|
|
127
|
+
default=None, description="Generic resources (e.g., 'gpu:2', 'gpu:v100:1')"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Advanced Scheduling Parameters
|
|
131
|
+
account: Optional[str] = Field(default=None, description="Billing account")
|
|
132
|
+
priority: Optional[int] = Field(default=None, description="Job priority")
|
|
133
|
+
reservation: Optional[str] = Field(
|
|
134
|
+
default=None, description="Use specific reservation"
|
|
135
|
+
)
|
|
136
|
+
constraint: Optional[str] = Field(
|
|
137
|
+
default=None, description="Node feature constraints"
|
|
138
|
+
)
|
|
139
|
+
exclusive: Optional[bool] = Field(default=None, description="Exclusive node access")
|
|
140
|
+
nice: Optional[int] = Field(default=None, description="Adjust scheduling priority")
|
|
141
|
+
|
|
142
|
+
# Dependency and Array Parameters
|
|
143
|
+
dependency: Optional[str] = Field(
|
|
144
|
+
default=None, description="Job dependencies (e.g., 'afterok:jobid')"
|
|
145
|
+
)
|
|
146
|
+
array: Optional[str] = Field(
|
|
147
|
+
default=None, description="Job arrays (e.g., '1-100', '1-10:2')"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Additional Commands
|
|
151
|
+
startup_commands: List[str] = Field(
|
|
152
|
+
default_factory=list, description="Commands to run before main job"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Working Directory
|
|
156
|
+
chdir: Optional[str] = Field(
|
|
157
|
+
default=None, description="Working directory for the job"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Export Environment
|
|
161
|
+
export: Optional[str] = Field(
|
|
162
|
+
default=None, description="Environment variables to export"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Signal handling
|
|
166
|
+
signal: Optional[str] = Field(
|
|
167
|
+
default=None, description="Signal to send when time limit is reached"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Requeue options
|
|
171
|
+
requeue: Optional[bool] = Field(
|
|
172
|
+
default=None, description="Allow job to be requeued"
|
|
173
|
+
)
|
|
174
|
+
no_requeue: Optional[bool] = Field(
|
|
175
|
+
default=None, description="Prevent job from being requeued"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
model_config: ClassVar[Dict[str, Any]] = {
|
|
179
|
+
"extra": "forbid",
|
|
180
|
+
"json_schema_extra": {
|
|
181
|
+
"title": "SlurmParameters",
|
|
182
|
+
"description": "Comprehensive parameters for Slurm job submission",
|
|
183
|
+
"examples": [
|
|
184
|
+
{
|
|
185
|
+
"nodes": 2,
|
|
186
|
+
"ntasks_per_node": 8,
|
|
187
|
+
"cpus_per_task": 4,
|
|
188
|
+
"mem": "64G",
|
|
189
|
+
"time": "12:00:00",
|
|
190
|
+
"partition": "compute",
|
|
191
|
+
"gres": "gpu:2",
|
|
192
|
+
"job_name": "my_simulation",
|
|
193
|
+
"output": "job_%j.out",
|
|
194
|
+
"error": "job_%j.err",
|
|
195
|
+
"mail_type": "END,FAIL",
|
|
196
|
+
"mail_user": "user@institution.edu",
|
|
197
|
+
}
|
|
198
|
+
],
|
|
199
|
+
},
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
def to_sbatch_args(self) -> List[str]:
|
|
203
|
+
"""Convert parameters to SBATCH arguments list."""
|
|
204
|
+
args = []
|
|
205
|
+
|
|
206
|
+
# Validate memory parameters first
|
|
207
|
+
memory_options = []
|
|
208
|
+
if self.mem is not None:
|
|
209
|
+
memory_options.append("mem")
|
|
210
|
+
if self.mem_per_cpu is not None:
|
|
211
|
+
memory_options.append("mem_per_cpu")
|
|
212
|
+
if hasattr(self, "mem_per_gpu") and getattr(self, "mem_per_gpu") is not None:
|
|
213
|
+
memory_options.append("mem_per_gpu")
|
|
214
|
+
|
|
215
|
+
if len(memory_options) > 1:
|
|
216
|
+
raise ValueError(
|
|
217
|
+
f"SLURM memory parameters are mutually exclusive. "
|
|
218
|
+
f"Found: {', '.join(memory_options)}. "
|
|
219
|
+
f"Please specify only one of: --mem, --mem-per-cpu, or --mem-per-gpu"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# Map field names to SBATCH parameters
|
|
223
|
+
field_mapping = {
|
|
224
|
+
"nodes": "--nodes",
|
|
225
|
+
"tasks": "--ntasks",
|
|
226
|
+
"tasks_per_node": "--ntasks-per-node",
|
|
227
|
+
"cpus_per_task": "--cpus-per-task",
|
|
228
|
+
"mem": "--mem",
|
|
229
|
+
"mem_per_cpu": "--mem-per-cpu",
|
|
230
|
+
"time": "--time",
|
|
231
|
+
"begin": "--begin",
|
|
232
|
+
"partition": "--partition",
|
|
233
|
+
"qos": "--qos",
|
|
234
|
+
"job_name": "--job-name",
|
|
235
|
+
"output": "--output",
|
|
236
|
+
"error": "--error",
|
|
237
|
+
"mail_type": "--mail-type",
|
|
238
|
+
"mail_user": "--mail-user",
|
|
239
|
+
"gres": "--gres",
|
|
240
|
+
"account": "--account",
|
|
241
|
+
"priority": "--priority",
|
|
242
|
+
"reservation": "--reservation",
|
|
243
|
+
"constraint": "--constraint",
|
|
244
|
+
"dependency": "--dependency",
|
|
245
|
+
"array": "--array",
|
|
246
|
+
"chdir": "--chdir",
|
|
247
|
+
"export": "--export",
|
|
248
|
+
"signal": "--signal",
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
# Add mem_per_gpu to field mapping if it exists
|
|
252
|
+
if hasattr(self, "mem_per_gpu"):
|
|
253
|
+
field_mapping["mem_per_gpu"] = "--mem-per-gpu"
|
|
254
|
+
|
|
255
|
+
# Add parameters with values
|
|
256
|
+
for field_name, sbatch_param in field_mapping.items():
|
|
257
|
+
value = getattr(self, field_name, None)
|
|
258
|
+
if value is not None:
|
|
259
|
+
args.append(f"{sbatch_param}={value}")
|
|
260
|
+
|
|
261
|
+
# Add boolean flags
|
|
262
|
+
if self.exclusive:
|
|
263
|
+
args.append("--exclusive")
|
|
264
|
+
if self.requeue:
|
|
265
|
+
args.append("--requeue")
|
|
266
|
+
if self.no_requeue:
|
|
267
|
+
args.append("--no-requeue")
|
|
268
|
+
if self.nice is not None:
|
|
269
|
+
args.append(f"--nice={self.nice}")
|
|
270
|
+
|
|
271
|
+
return args
|
|
272
|
+
|
|
273
|
+
def to_sbatch_header(self) -> str:
|
|
274
|
+
"""Convert parameters to SBATCH header string for script files."""
|
|
275
|
+
lines = ["#!/bin/bash"]
|
|
276
|
+
|
|
277
|
+
for arg in self.to_sbatch_args():
|
|
278
|
+
lines.append(f"#SBATCH {arg}")
|
|
279
|
+
|
|
280
|
+
if self.startup_commands:
|
|
281
|
+
lines.append("")
|
|
282
|
+
lines.extend(self.startup_commands)
|
|
283
|
+
|
|
284
|
+
return "\n".join(lines)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class Parameters(EmbeddedModel):
|
|
288
|
+
force_rerun: bool = False
|
|
289
|
+
resource: Resource = Field(default_factory=lambda: Resource(value="self"))
|
|
290
|
+
queue: str = Field(default="default")
|
|
291
|
+
recompute_artifacts: Optional[bool] = Field(
|
|
292
|
+
default=False, description="Recompute artifacts for this node"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
other_value: str = Field(default="other")
|
|
296
|
+
test_dict: Dict[str, Any] = Field(default_factory=lambda: {"test": "value"})
|
|
297
|
+
|
|
298
|
+
slurm_parameters: SlurmParameters = Field(default=None)
|
|
299
|
+
# slurm_parameters_data: Dict[str, Any] = Field(default_factory=dict)
|
|
300
|
+
|
|
301
|
+
model_config = {
|
|
302
|
+
"extra": "forbid",
|
|
303
|
+
"json_schema_extra": {
|
|
304
|
+
"title": "Parameters",
|
|
305
|
+
"description": "Parameters for running a simulation",
|
|
306
|
+
"examples": [
|
|
307
|
+
{
|
|
308
|
+
"resource": "self",
|
|
309
|
+
"queue": "default",
|
|
310
|
+
"slurm_parameters": {
|
|
311
|
+
"nodes": 2,
|
|
312
|
+
},
|
|
313
|
+
}
|
|
314
|
+
],
|
|
315
|
+
},
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
@model_validator(mode="before")
|
|
319
|
+
@classmethod
|
|
320
|
+
def migrate_slurm_parameters(cls, data):
|
|
321
|
+
if "slurm_parameters_data" in data and "slurm_parameters" not in data:
|
|
322
|
+
data["slurm_parameters"] = SlurmParameters(**data["slurm_parameters_data"])
|
|
323
|
+
del data["slurm_parameters_data"]
|
|
324
|
+
if "slurm_parameters" not in data or data["slurm_parameters"] is None:
|
|
325
|
+
data["slurm_parameters"] = SlurmParameters()
|
|
326
|
+
return data
|
|
327
|
+
|
|
328
|
+
@field_validator("resource", mode="before")
|
|
329
|
+
@classmethod
|
|
330
|
+
def validate_resource(cls, v):
|
|
331
|
+
"""
|
|
332
|
+
Validate and convert resource input to a Resource object.
|
|
333
|
+
|
|
334
|
+
Accepts string, Resource objects, and dictionary representations.
|
|
335
|
+
If a string is provided, converts it to a Resource object.
|
|
336
|
+
If a dictionary is provided (e.g., during deserialization),
|
|
337
|
+
extracts the value and creates a Resource object.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
v: The value to validate (str, Resource, or dict)
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
Resource: The validated Resource object
|
|
344
|
+
"""
|
|
345
|
+
if isinstance(v, str):
|
|
346
|
+
return Resource(value=v)
|
|
347
|
+
elif isinstance(v, Resource):
|
|
348
|
+
return v
|
|
349
|
+
elif isinstance(v, dict):
|
|
350
|
+
# Handle deserialization from the dictionary
|
|
351
|
+
if "value" in v:
|
|
352
|
+
return Resource(value=v["value"])
|
|
353
|
+
else:
|
|
354
|
+
raise ValueError(
|
|
355
|
+
f"Dictionary representation of resource must contain 'value' key, got {v}"
|
|
356
|
+
)
|
|
357
|
+
else:
|
|
358
|
+
raise ValueError(
|
|
359
|
+
f"resource must be a string, Resource object, or dictionary, got {type(v)}"
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# # property getters and setters to handle the slurm_parameters
|
|
363
|
+
# @property
|
|
364
|
+
# def slurm_parameters(self) -> Optional[SlurmParameters]:
|
|
365
|
+
# if not self.slurm_parameters_data:
|
|
366
|
+
# default_slurm = SlurmParameters()
|
|
367
|
+
# self.slurm_parameters_data = default_slurm.model_dump()
|
|
368
|
+
# return SlurmParameters(**self.slurm_parameters_data)
|
|
369
|
+
#
|
|
370
|
+
# @slurm_parameters.setter
|
|
371
|
+
# def slurm_parameters(self, value: Optional[SlurmParameters]) -> None:
|
|
372
|
+
# if value is None:
|
|
373
|
+
# self.slurm_parameters_data = {}
|
|
374
|
+
# else:
|
|
375
|
+
# self.slurm_parameters_data = value.model_dump()
|
|
376
|
+
#
|
|
377
|
+
# # Add any convenience methods to work with slurm parameters
|
|
378
|
+
# def set_slurm_config(self, **kwargs):
|
|
379
|
+
# """Update slurm parameters with the given keyword arguments."""
|
|
380
|
+
# current = self.slurm_parameters
|
|
381
|
+
# if current is None:
|
|
382
|
+
# current = SlurmParameters()
|
|
383
|
+
#
|
|
384
|
+
# for key, value in kwargs.items():
|
|
385
|
+
# if hasattr(current, key):
|
|
386
|
+
# setattr(current, key, value)
|
|
387
|
+
#
|
|
388
|
+
# self.slurm_parameters = current
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import logging
|
|
3
|
+
import sys
|
|
4
|
+
import types
|
|
5
|
+
from typing import Optional, Type, Any, Callable
|
|
6
|
+
|
|
7
|
+
import cloudpickle
|
|
8
|
+
from odmantic import Model
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("PickleModels")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _BytesB64Mixin:
|
|
14
|
+
"""
|
|
15
|
+
Mixin that teaches Pydantic/ODMantic to serialise *bytes* fields as
|
|
16
|
+
base-64-encoded ASCII strings when exporting to JSON (dict / response).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
model_config = {
|
|
20
|
+
"json_encoders": {bytes: lambda b: base64.b64encode(b).decode("ascii")}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ClassPickle(_BytesB64Mixin, Model):
|
|
25
|
+
"""
|
|
26
|
+
Persist an *arbitrary Python class* in MongoDB.
|
|
27
|
+
|
|
28
|
+
Fields
|
|
29
|
+
------
|
|
30
|
+
name – class __name__ (for reference / debugging)
|
|
31
|
+
module_path – original module path (dotted)
|
|
32
|
+
pickle_data – base64-encoded pickled bytes of the class
|
|
33
|
+
|
|
34
|
+
Methods
|
|
35
|
+
-------
|
|
36
|
+
store_class(cls)
|
|
37
|
+
Serialise and save the given class into `pickle_data`.
|
|
38
|
+
load_class()
|
|
39
|
+
Reconstruct the class object from `pickle_data`.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
name: str
|
|
43
|
+
module_path: str
|
|
44
|
+
pickle_data: Optional[bytes] = None
|
|
45
|
+
|
|
46
|
+
def store_class(self, cls: Type[Any]) -> None:
|
|
47
|
+
self.name = cls.__name__
|
|
48
|
+
self.module_path = cls.__module__
|
|
49
|
+
self.pickle_data = cloudpickle.dumps(cls)
|
|
50
|
+
logger.debug("Stored class %s (%s)", self.name, self.module_path)
|
|
51
|
+
|
|
52
|
+
def load_class(self) -> Type[Any]:
|
|
53
|
+
if self.pickle_data is None:
|
|
54
|
+
raise ValueError("pickle_data is empty")
|
|
55
|
+
return cloudpickle.loads(self.pickle_data)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class FunctionPickle(_BytesB64Mixin, Model):
|
|
59
|
+
"""
|
|
60
|
+
Persist an *arbitrary Python function* in MongoDB.
|
|
61
|
+
|
|
62
|
+
Fields
|
|
63
|
+
------
|
|
64
|
+
name – function __name__
|
|
65
|
+
module_path – original module path
|
|
66
|
+
pickle_data – base64 pickled bytes of the function
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
name: str
|
|
70
|
+
module_path: str
|
|
71
|
+
pickle_data: Optional[bytes] = None
|
|
72
|
+
|
|
73
|
+
def _is_problematic_object(self, obj) -> bool:
|
|
74
|
+
"""Check if an object is problematic for pickling."""
|
|
75
|
+
try:
|
|
76
|
+
# Quick type checks
|
|
77
|
+
# if isinstance(obj, (asyncio.Future, concurrent.futures.Future, asyncio.AbstractEventLoop)):
|
|
78
|
+
# return True
|
|
79
|
+
#
|
|
80
|
+
# Check type name for Windows-specific objects
|
|
81
|
+
type_name = type(obj).__name__
|
|
82
|
+
problematic_types = {
|
|
83
|
+
"_OverlappedFuture",
|
|
84
|
+
"ProactorEventLoop",
|
|
85
|
+
"WindowsProactorEventLoopPolicy",
|
|
86
|
+
"_ProactorBasePipeTransport",
|
|
87
|
+
"_ProactorSocketTransport",
|
|
88
|
+
"Handle",
|
|
89
|
+
"_WindowsSelectorEventLoop",
|
|
90
|
+
"_ProactorReadPipeTransport",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if type_name in problematic_types:
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
# Check module origin
|
|
97
|
+
if hasattr(obj, "__class__") and hasattr(obj.__class__, "__module__"):
|
|
98
|
+
module_name = obj.__class__.__module__ or ""
|
|
99
|
+
if any(x in module_name for x in ["_overlapped", "_winapi"]):
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
except Exception:
|
|
105
|
+
return True # If we can't inspect it safely, consider it problematic
|
|
106
|
+
|
|
107
|
+
def _clean_object_recursively(self, obj, seen=None):
|
|
108
|
+
"""Recursively clean an object, removing problematic references."""
|
|
109
|
+
if seen is None:
|
|
110
|
+
seen = set()
|
|
111
|
+
|
|
112
|
+
# Avoid infinite recursion
|
|
113
|
+
obj_id = id(obj)
|
|
114
|
+
if obj_id in seen:
|
|
115
|
+
return obj
|
|
116
|
+
seen.add(obj_id)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
if self._is_problematic_object(obj):
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
# Handle different types
|
|
123
|
+
if isinstance(obj, dict):
|
|
124
|
+
cleaned = {}
|
|
125
|
+
for key, value in obj.items():
|
|
126
|
+
if not self._is_problematic_object(value):
|
|
127
|
+
cleaned_value = self._clean_object_recursively(value, seen)
|
|
128
|
+
if cleaned_value is not None:
|
|
129
|
+
cleaned[key] = cleaned_value
|
|
130
|
+
return cleaned
|
|
131
|
+
|
|
132
|
+
elif isinstance(obj, (list, tuple)):
|
|
133
|
+
cleaned_items = []
|
|
134
|
+
for item in obj:
|
|
135
|
+
if not self._is_problematic_object(item):
|
|
136
|
+
cleaned_item = self._clean_object_recursively(item, seen)
|
|
137
|
+
if cleaned_item is not None:
|
|
138
|
+
cleaned_items.append(cleaned_item)
|
|
139
|
+
return type(obj)(cleaned_items)
|
|
140
|
+
|
|
141
|
+
# For other objects, try to pickle test
|
|
142
|
+
try:
|
|
143
|
+
cloudpickle.dumps(obj)
|
|
144
|
+
return obj
|
|
145
|
+
except Exception:
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
except Exception:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
def _clean_globals(self, func_globals: dict) -> dict:
|
|
152
|
+
"""Remove unpickleable objects from function globals."""
|
|
153
|
+
cleaned_globals = {}
|
|
154
|
+
|
|
155
|
+
# Essential builtins that we need to keep
|
|
156
|
+
essential_builtins = {
|
|
157
|
+
"__builtins__",
|
|
158
|
+
"__name__",
|
|
159
|
+
"__doc__",
|
|
160
|
+
"__package__",
|
|
161
|
+
"print",
|
|
162
|
+
"len",
|
|
163
|
+
"str",
|
|
164
|
+
"int",
|
|
165
|
+
"float",
|
|
166
|
+
"bool",
|
|
167
|
+
"dict",
|
|
168
|
+
"list",
|
|
169
|
+
"tuple",
|
|
170
|
+
"Exception",
|
|
171
|
+
"ValueError",
|
|
172
|
+
"TypeError",
|
|
173
|
+
"AttributeError",
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for key, value in func_globals.items():
|
|
177
|
+
try:
|
|
178
|
+
# Always skip certain names
|
|
179
|
+
skip_names = {"_OverlappedFuture", "_overlapped", "_winapi"}
|
|
180
|
+
|
|
181
|
+
if any(skip_name in key.lower() for skip_name in skip_names):
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
# Handle builtins specially
|
|
185
|
+
if key == "__builtins__":
|
|
186
|
+
if isinstance(value, dict):
|
|
187
|
+
# Keep only essential builtins
|
|
188
|
+
cleaned_builtins = {
|
|
189
|
+
k: v
|
|
190
|
+
for k, v in value.items()
|
|
191
|
+
if k in essential_builtins
|
|
192
|
+
and not self._is_problematic_object(v)
|
|
193
|
+
}
|
|
194
|
+
cleaned_globals[key] = cleaned_builtins
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
# Skip modules entirely to avoid complex dependencies
|
|
198
|
+
if isinstance(value, types.ModuleType):
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
# Deep clean the object
|
|
202
|
+
cleaned_value = self._clean_object_recursively(value)
|
|
203
|
+
if cleaned_value is not None:
|
|
204
|
+
# Final pickle test
|
|
205
|
+
try:
|
|
206
|
+
cloudpickle.dumps(cleaned_value)
|
|
207
|
+
cleaned_globals[key] = cleaned_value
|
|
208
|
+
except Exception as e:
|
|
209
|
+
logger.debug(
|
|
210
|
+
f"Skipping '{key}' after cleaning failed pickle test: {e}"
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
logger.debug(f"Skipping '{key}': cleaned to None")
|
|
214
|
+
|
|
215
|
+
except Exception as e:
|
|
216
|
+
logger.debug(f"Error processing global '{key}': {e}")
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
return cleaned_globals
|
|
220
|
+
|
|
221
|
+
def _clean_closure(self, closure):
|
|
222
|
+
"""Clean closure variables of problematic objects."""
|
|
223
|
+
if not closure:
|
|
224
|
+
return closure
|
|
225
|
+
|
|
226
|
+
cleaned_closure = []
|
|
227
|
+
for cell in closure:
|
|
228
|
+
try:
|
|
229
|
+
cell_contents = cell.cell_contents
|
|
230
|
+
if not self._is_problematic_object(cell_contents):
|
|
231
|
+
cleaned_contents = self._clean_object_recursively(cell_contents)
|
|
232
|
+
if cleaned_contents is not None:
|
|
233
|
+
# Create new cell with cleaned contents
|
|
234
|
+
new_cell = types.CellType(cleaned_contents)
|
|
235
|
+
cleaned_closure.append(new_cell)
|
|
236
|
+
else:
|
|
237
|
+
# Create cell with None if we had to remove the contents
|
|
238
|
+
cleaned_closure.append(types.CellType(None))
|
|
239
|
+
else:
|
|
240
|
+
# Replace problematic closure variable with None
|
|
241
|
+
cleaned_closure.append(types.CellType(None))
|
|
242
|
+
except (ValueError, AttributeError):
|
|
243
|
+
# Cell is empty or has issues, create empty cell
|
|
244
|
+
cleaned_closure.append(types.CellType(None))
|
|
245
|
+
except Exception as e:
|
|
246
|
+
logger.debug(f"Error cleaning closure cell: {e}")
|
|
247
|
+
cleaned_closure.append(types.CellType(None))
|
|
248
|
+
|
|
249
|
+
return tuple(cleaned_closure) if cleaned_closure else None
|
|
250
|
+
|
|
251
|
+
def store_function(self, func: Callable) -> None:
|
|
252
|
+
self.name = func.__name__
|
|
253
|
+
self.module_path = func.__module__
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
# Clean the function's globals and closure
|
|
257
|
+
cleaned_globals = self._clean_globals(func.__globals__)
|
|
258
|
+
cleaned_closure = self._clean_closure(func.__closure__)
|
|
259
|
+
|
|
260
|
+
# Create a copy of the function with cleaned components
|
|
261
|
+
func_copy = types.FunctionType(
|
|
262
|
+
func.__code__,
|
|
263
|
+
cleaned_globals,
|
|
264
|
+
func.__name__,
|
|
265
|
+
func.__defaults__,
|
|
266
|
+
cleaned_closure,
|
|
267
|
+
)
|
|
268
|
+
func_copy.__module__ = "__pickled_function__"
|
|
269
|
+
|
|
270
|
+
# Final test - try to pickle the cleaned function
|
|
271
|
+
test_pickle = cloudpickle.dumps(func_copy)
|
|
272
|
+
|
|
273
|
+
self.pickle_data = test_pickle
|
|
274
|
+
logger.debug("Stored function %s (%s)", self.name, self.module_path)
|
|
275
|
+
|
|
276
|
+
except Exception as e:
|
|
277
|
+
logger.error(f"Failed to pickle function {self.name}: {e}")
|
|
278
|
+
self.pickle_data = None
|
|
279
|
+
logger.warning(
|
|
280
|
+
f"Function {self.name} will use regular import instead of pickle"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
def load_function(self) -> Callable:
|
|
284
|
+
if self.pickle_data is None:
|
|
285
|
+
raise ValueError("pickle_data is empty")
|
|
286
|
+
|
|
287
|
+
# Create a dummy module to satisfy cloudpickle's import requirements
|
|
288
|
+
if "__pickled_function__" not in sys.modules:
|
|
289
|
+
dummy_module = types.ModuleType("__pickled_function__")
|
|
290
|
+
sys.modules["__pickled_function__"] = dummy_module
|
|
291
|
+
|
|
292
|
+
func = cloudpickle.loads(self.pickle_data)
|
|
293
|
+
|
|
294
|
+
# Ensure the function has the correct __name__ attribute
|
|
295
|
+
if not hasattr(func, "__name__") or func.__name__ is None:
|
|
296
|
+
func.__name__ = self.name
|
|
297
|
+
logger.debug(f"Restored missing __name__ attribute: {self.name}")
|
|
298
|
+
|
|
299
|
+
# Ensure other essential attributes are present
|
|
300
|
+
if not hasattr(func, "__module__") or func.__module__ is None:
|
|
301
|
+
func.__module__ = self.module_path
|
|
302
|
+
logger.debug(f"Restored missing __module__ attribute: {self.module_path}")
|
|
303
|
+
|
|
304
|
+
# Ensure __qualname__ exists (needed for some introspection)
|
|
305
|
+
if not hasattr(func, "__qualname__"):
|
|
306
|
+
func.__qualname__ = self.name
|
|
307
|
+
|
|
308
|
+
logger.debug(
|
|
309
|
+
"Loaded function %s (%s)", self.name, getattr(func, "__name__", "unnamed")
|
|
310
|
+
)
|
|
311
|
+
return func
|