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,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