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
simstack/core/context.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import logging # Import logging before using it
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from urllib.parse import urlparse, urlunparse
|
|
5
|
+
|
|
6
|
+
from simstack.core.definitions import DBType
|
|
7
|
+
from simstack.util.config_reader import ConfigReader
|
|
8
|
+
from simstack.util.path_manager import PathManager
|
|
9
|
+
|
|
10
|
+
# from simstack.core.model_table import make_models_for_path
|
|
11
|
+
# from simstack.core.node_table import make_nodes_for_path
|
|
12
|
+
from simstack.util.project_root_finder import find_project_root
|
|
13
|
+
from simstack.util.setup_logging import setup_logging
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def remove_password_from_connection_string(connection_string):
|
|
17
|
+
parsed_url = urlparse(connection_string)
|
|
18
|
+
|
|
19
|
+
# Extract username and rebuild netloc without password
|
|
20
|
+
netloc = parsed_url.hostname
|
|
21
|
+
if parsed_url.username:
|
|
22
|
+
netloc = f"{parsed_url.username}@{netloc}"
|
|
23
|
+
if parsed_url.port:
|
|
24
|
+
netloc += f":{parsed_url.port}"
|
|
25
|
+
|
|
26
|
+
clean_url = parsed_url._replace(netloc=netloc)
|
|
27
|
+
|
|
28
|
+
return urlunparse(clean_url)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GlobalState:
|
|
32
|
+
_instance = None
|
|
33
|
+
_initialized = False
|
|
34
|
+
|
|
35
|
+
def __new__(cls, *args, **kwargs):
|
|
36
|
+
if not cls._instance:
|
|
37
|
+
cls._instance = super().__new__(cls)
|
|
38
|
+
# TODO the DB used in simstack applications is fixed and comes from the config file, the db in the server changes by user
|
|
39
|
+
cls._instance.db = None
|
|
40
|
+
cls._instance.log_handler = None
|
|
41
|
+
cls._instance.path_manager = None
|
|
42
|
+
cls._instance.config = None
|
|
43
|
+
|
|
44
|
+
return cls._instance
|
|
45
|
+
|
|
46
|
+
def __init__(self, **kwargs):
|
|
47
|
+
"""Initialize GlobalState instance.
|
|
48
|
+
|
|
49
|
+
This method only runs once due to the singleton pattern.
|
|
50
|
+
Use the initialize() method to set up the instance with database settings.
|
|
51
|
+
"""
|
|
52
|
+
if not hasattr(self, "_initialized"):
|
|
53
|
+
self._initialized = True
|
|
54
|
+
|
|
55
|
+
self.db = None
|
|
56
|
+
self.log_handler = None
|
|
57
|
+
self.path_manager = None
|
|
58
|
+
self.config = None
|
|
59
|
+
|
|
60
|
+
self.initialize(**kwargs)
|
|
61
|
+
|
|
62
|
+
def __getattribute__(self, name):
|
|
63
|
+
# These special attributes should always be accessible
|
|
64
|
+
if name in ("_initialized", "initialize", "initialized"):
|
|
65
|
+
return object.__getattribute__(self, name)
|
|
66
|
+
|
|
67
|
+
# For other attributes, check initialization
|
|
68
|
+
if not object.__getattribute__(self, "_initialized"):
|
|
69
|
+
raise RuntimeError(
|
|
70
|
+
"GlobalState must be initialized with database settings before use"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return object.__getattribute__(self, name)
|
|
74
|
+
|
|
75
|
+
# @async_helper
|
|
76
|
+
# async def remake_models_and_nodes(self,path: str):
|
|
77
|
+
# if path is not None: # rescan all files in the path if pickling is needed
|
|
78
|
+
# parent_path = self.path_manager.find_parent_path(path)
|
|
79
|
+
# if not parent_path:
|
|
80
|
+
# raise ValueError(f"Path '{path}' not found in paths. Please check your configuration.")
|
|
81
|
+
# await make_models_for_path(parent_path, self.path_manager, context.db.engine)
|
|
82
|
+
# await make_nodes_for_path(parent_path, self.path_manager, context.db.engine)
|
|
83
|
+
|
|
84
|
+
def initialize(self, **kwargs):
|
|
85
|
+
db_name = kwargs.get("db_name", None)
|
|
86
|
+
connection_string = kwargs.get("connection_string", None)
|
|
87
|
+
resource_str: str = kwargs.get("resource", "self")
|
|
88
|
+
|
|
89
|
+
"""Initialize the GlobalState with database settings"""
|
|
90
|
+
if self._initialized:
|
|
91
|
+
raise RuntimeError("GlobalState already initialized")
|
|
92
|
+
self._initialized = True
|
|
93
|
+
|
|
94
|
+
is_test = kwargs.get("is_test", False)
|
|
95
|
+
self.config = ConfigReader(
|
|
96
|
+
db_name=db_name,
|
|
97
|
+
connection_string=connection_string,
|
|
98
|
+
resource=resource_str,
|
|
99
|
+
is_test=is_test,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# check that the database can be reached and set logging up
|
|
103
|
+
from simstack.util.db import Database
|
|
104
|
+
|
|
105
|
+
# Use in-memory database for tests
|
|
106
|
+
db_type = DBType.IN_MEMORY if is_test and db_name is None else DBType.MONGODB
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
self.db = Database(
|
|
110
|
+
db_type, self.config.database_name, self.config.connection_string
|
|
111
|
+
)
|
|
112
|
+
if db_type == DBType.MONGODB:
|
|
113
|
+
# Only ping real MongoDB connections
|
|
114
|
+
self.db.client.admin.command("ping")
|
|
115
|
+
except ConnectionError as e:
|
|
116
|
+
if not is_test:
|
|
117
|
+
print(f"Could not connect to the database: {e}")
|
|
118
|
+
sys.exit(-1)
|
|
119
|
+
else:
|
|
120
|
+
# For tests, continue without the database connection failure
|
|
121
|
+
print(f"Warning: Database connection failed in test mode: {e}")
|
|
122
|
+
|
|
123
|
+
if is_test:
|
|
124
|
+
# For tests, use simple console logging without the database handler
|
|
125
|
+
logging.basicConfig(
|
|
126
|
+
level=logging.ERROR,
|
|
127
|
+
format="%(asctime)s - %(name)-15s - %(levelname)-10s - %(filename)-20s:%(lineno)4d - %(message)s",
|
|
128
|
+
)
|
|
129
|
+
self.log_handler = logging.getLogger()
|
|
130
|
+
else:
|
|
131
|
+
self.log_handler = setup_logging(
|
|
132
|
+
self.config.connection_string,
|
|
133
|
+
self.config.database_name,
|
|
134
|
+
kwargs.get("log_level", "INFO"),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# initialize the rest of the variables in the config, but now we can get the errors in the database
|
|
138
|
+
self.config.secondary_init(kwargs.get("workdir", None))
|
|
139
|
+
|
|
140
|
+
logger = logging.getLogger("Context")
|
|
141
|
+
safe_connection_string = remove_password_from_connection_string(
|
|
142
|
+
self.config.connection_string
|
|
143
|
+
)
|
|
144
|
+
logger.info(
|
|
145
|
+
f"Database connection established to {self.config.database_name} at {safe_connection_string}"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Initialize PathManager from config
|
|
149
|
+
self.path_manager = PathManager.from_config(self.config)
|
|
150
|
+
|
|
151
|
+
# Set any additional parameters
|
|
152
|
+
for key, value in kwargs.items():
|
|
153
|
+
setattr(self, key, value)
|
|
154
|
+
return self
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def initialized(self):
|
|
158
|
+
return self._initialized
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
root_dir = find_project_root()
|
|
162
|
+
path_dir = os.path.join(root_dir, "src")
|
|
163
|
+
if path_dir not in os.sys.path:
|
|
164
|
+
os.sys.path.append(path_dir)
|
|
165
|
+
|
|
166
|
+
# Create the singleton instance, but it's not initialized yet
|
|
167
|
+
context = GlobalState()
|
simstack/core/data.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import uuid
|
|
3
|
+
from typing import get_origin, get_args, Any, Type, Optional
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
from sqlmodel import SQLModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_recursive(data: Any, key: str):
|
|
10
|
+
elements = key.split(".")
|
|
11
|
+
if len(elements) == 1:
|
|
12
|
+
if key == "$$":
|
|
13
|
+
return data
|
|
14
|
+
else:
|
|
15
|
+
return data.__dict__[key]
|
|
16
|
+
else:
|
|
17
|
+
return get_recursive(data.__dict__[elements[0]], ".".join(elements[1:]))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Assignment(BaseModel):
|
|
21
|
+
"""
|
|
22
|
+
Represents a mapping container class inherited from BaseModel.
|
|
23
|
+
|
|
24
|
+
This class stores a default dictionary mapping and provides a callable
|
|
25
|
+
method that enables operations using a source data and a target class.
|
|
26
|
+
It will be used to generate instances of workflow nodes
|
|
27
|
+
or workflow results from the workflow data
|
|
28
|
+
|
|
29
|
+
In the mapping dict the keys specify the target attribute names of the target class.
|
|
30
|
+
|
|
31
|
+
If the target class is None, the special key "$$" is used to specify that value.
|
|
32
|
+
The value specifies the source attribute name of the source data. The special value "$$" means
|
|
33
|
+
that the value is the source data itself will be used.
|
|
34
|
+
|
|
35
|
+
This mechanism allows to map non-class types to each other and to create instances of classes
|
|
36
|
+
where needed.
|
|
37
|
+
|
|
38
|
+
For BaseModel classes the assignment is driven by the model_fields of the target class. For fields that
|
|
39
|
+
are BaseModel classes, the assignment is recursive.
|
|
40
|
+
|
|
41
|
+
:ivar mapping: Stores the dictionary for mapping values between the
|
|
42
|
+
source and the target class.
|
|
43
|
+
:type mapping: dict
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
# TODO doe SQLModel for dict
|
|
47
|
+
id: int = Field(default=None, primary_key=True)
|
|
48
|
+
# mapping: dict = Field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
model_config = {
|
|
51
|
+
"frozen": True,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
def __init__(self, mapping: dict):
|
|
55
|
+
super().__init__(mapping=mapping)
|
|
56
|
+
|
|
57
|
+
def __call__(self, target_class: Type[SQLModel] | None, source: Any):
|
|
58
|
+
"""
|
|
59
|
+
Create a new instance of the class from the source data
|
|
60
|
+
|
|
61
|
+
:param source:
|
|
62
|
+
:return:
|
|
63
|
+
"""
|
|
64
|
+
if target_class is None:
|
|
65
|
+
return get_recursive(source, self.mapping["$$"])
|
|
66
|
+
else:
|
|
67
|
+
return target_class.from_data(source, self)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def is_data_type(annotation):
|
|
71
|
+
"""Check if a type annotation is or contains Data"""
|
|
72
|
+
# Direct subclass check
|
|
73
|
+
if inspect.isclass(annotation) and issubclass(annotation, Data):
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
# Handle Union types (like Optional[Type])
|
|
77
|
+
origin = get_origin(annotation)
|
|
78
|
+
if origin is not None:
|
|
79
|
+
# For union types (X | Y or Union[X, Y])
|
|
80
|
+
args = get_args(annotation)
|
|
81
|
+
return any(is_data_type(arg) for arg in args)
|
|
82
|
+
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Data(SQLModel, table=False):
|
|
87
|
+
id: Optional[uuid.UUID] = Field(default=uuid.uuid4(), primary_key=True)
|
|
88
|
+
# item_type: str = Field(sa_column_kwargs={"nullable": False})
|
|
89
|
+
#
|
|
90
|
+
# __mapper_args__ = {
|
|
91
|
+
# "polymorphic_on": "item_type",
|
|
92
|
+
# "polymorphic_identity": "base_item",
|
|
93
|
+
# }
|
|
94
|
+
|
|
95
|
+
# TODO automatically set the type to the name of the class
|
|
96
|
+
# @validator(mode='before')
|
|
97
|
+
# def set_type_to_class_name(cls, data):
|
|
98
|
+
# if isinstance(data, dict):
|
|
99
|
+
# # Don't override if explicitly provided
|
|
100
|
+
# if 'type' not in data:
|
|
101
|
+
# data['type'] = cls.__name__
|
|
102
|
+
# return data
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def from_data(cls, data: SQLModel, assignment: Assignment):
|
|
106
|
+
value_dict = {}
|
|
107
|
+
mapping = assignment.mapping
|
|
108
|
+
for key, field_info in cls.model_fields.items():
|
|
109
|
+
if key == "type":
|
|
110
|
+
value_dict["type"] = cls.__name__
|
|
111
|
+
else:
|
|
112
|
+
annotation = field_info.annotation
|
|
113
|
+
# Check if this field is a Data subclass
|
|
114
|
+
if is_data_type(annotation) and key in mapping:
|
|
115
|
+
# If mapping is a dict, it's a nested mapping for a Data subclass
|
|
116
|
+
if isinstance(mapping[key], dict):
|
|
117
|
+
# Get the concrete subclass to use
|
|
118
|
+
if inspect.isclass(annotation) and issubclass(annotation, Data):
|
|
119
|
+
nested_class = annotation
|
|
120
|
+
else:
|
|
121
|
+
# TODO Handle Union types - this is simplified, might need enhancement
|
|
122
|
+
for arg in get_args(annotation):
|
|
123
|
+
if inspect.isclass(arg) and issubclass(arg, Data):
|
|
124
|
+
nested_class = arg
|
|
125
|
+
break
|
|
126
|
+
|
|
127
|
+
# Create an instance using the nested mapping
|
|
128
|
+
# TODO handle error if nested_class is not defined
|
|
129
|
+
value_dict[key] = nested_class.from_data(data, mapping[key])
|
|
130
|
+
else:
|
|
131
|
+
# It's a direct reference, get from source data
|
|
132
|
+
value_dict[key] = get_recursive(data, mapping[key])
|
|
133
|
+
else:
|
|
134
|
+
# Not a Data subclass, handle normally
|
|
135
|
+
value_dict[key] = get_recursive(data, mapping[key])
|
|
136
|
+
return cls(**value_dict)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# Number = float | int
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TaskStatus(str, Enum):
|
|
5
|
+
SUBMITTED = "submitted"
|
|
6
|
+
RUNNING = "running"
|
|
7
|
+
SLURM_QUEUED = "slurm_queued"
|
|
8
|
+
SLURM_RUNNING = "slurm_running"
|
|
9
|
+
TIME_OUT = "timeout"
|
|
10
|
+
FAILED = "failed"
|
|
11
|
+
COMPLETED = "completed"
|
|
12
|
+
RECOVERED = "recovered"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# TODO: eliminate
|
|
16
|
+
class DBType(str, Enum):
|
|
17
|
+
SQLITE = "sqlite"
|
|
18
|
+
MONGODB = "mongodb"
|
|
19
|
+
POSTGRES = "postgres"
|
|
20
|
+
IN_MEMORY = "in_memory"
|
|
21
|
+
WITH_PATH = "with_path"
|
simstack/core/engine.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from contextvars import ContextVar
|
|
2
|
+
from typing import Optional, Any, Iterable
|
|
3
|
+
|
|
4
|
+
from odmantic import AIOEngine
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AIOEngineProxy(AIOEngine):
|
|
8
|
+
"""
|
|
9
|
+
A proxy engine that inherits all behavior from AIOEngine but overrides `save`.
|
|
10
|
+
|
|
11
|
+
The overridden `save` will:
|
|
12
|
+
- If the model has a `save` member function, call it with the engine instance.
|
|
13
|
+
- Else, if any direct part/attribute of the model has a `save` member function, call those with the engine instance.
|
|
14
|
+
- Otherwise, fall back to the original AIOEngine.save implementation.
|
|
15
|
+
|
|
16
|
+
Notes:
|
|
17
|
+
- Supports single model instances or iterables of model instances (list/tuple/set).
|
|
18
|
+
- Avoids recursion by delegating to super().save for the fallback.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
async def save(self, obj: Any, *args, **kwargs) -> Any: # type: ignore[override]
|
|
22
|
+
# Handle collections of models
|
|
23
|
+
if isinstance(obj, (list, tuple, set)):
|
|
24
|
+
results = []
|
|
25
|
+
for item in obj:
|
|
26
|
+
results.append(await self._save_one(item, *args, **kwargs))
|
|
27
|
+
return results
|
|
28
|
+
# Single model
|
|
29
|
+
return await self._save_one(obj, *args, **kwargs)
|
|
30
|
+
|
|
31
|
+
async def save_unchecked(self, obj: Any, *args, **kwargs) -> Any:
|
|
32
|
+
"""
|
|
33
|
+
Save without checking for custom save methods.
|
|
34
|
+
This is a direct call to the original AIOEngine.save.
|
|
35
|
+
"""
|
|
36
|
+
return await super().save(obj, *args, **kwargs)
|
|
37
|
+
|
|
38
|
+
async def _save_one(self, model: Any, *args, **kwargs) -> Any:
|
|
39
|
+
# 1) Try model's own `save`
|
|
40
|
+
if await self._maybe_call_custom_save(model):
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
# 2) Try parts' `save` (shallow scan of attributes and common containers)
|
|
44
|
+
parts_saved = await self._call_parts_saves(model)
|
|
45
|
+
|
|
46
|
+
# 3) If neither the model nor any part handled saving, fallback to AIOEngine
|
|
47
|
+
if not parts_saved:
|
|
48
|
+
return await super().save(model, *args, **kwargs)
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
async def _maybe_call_custom_save(self, target: Any) -> bool:
|
|
52
|
+
"""
|
|
53
|
+
If `target` has a callable `save`, invoke it with this engine.
|
|
54
|
+
Tries positional engine and keyword engine forms, awaits if coroutine.
|
|
55
|
+
Returns True if a save was invoked, False otherwise.
|
|
56
|
+
"""
|
|
57
|
+
if not hasattr(target, "save"):
|
|
58
|
+
return False
|
|
59
|
+
save_attr = getattr(target, "save")
|
|
60
|
+
if not callable(save_attr):
|
|
61
|
+
raise AttributeError(f"Model {target} has no callable `save` method")
|
|
62
|
+
return await target.save(self)
|
|
63
|
+
|
|
64
|
+
async def _call_parts_saves(self, model: Any) -> bool:
|
|
65
|
+
"""
|
|
66
|
+
Inspect immediate parts/attributes of the model and call their `save` if present.
|
|
67
|
+
Handles:
|
|
68
|
+
- Direct attributes from vars(model)
|
|
69
|
+
- Items inside lists/tuples/sets
|
|
70
|
+
- Values of dicts
|
|
71
|
+
Returns True if at least one part save was called.
|
|
72
|
+
"""
|
|
73
|
+
any_saved = False
|
|
74
|
+
|
|
75
|
+
def iter_parts(root: Any) -> Iterable[Any]:
|
|
76
|
+
# Direct attributes
|
|
77
|
+
try:
|
|
78
|
+
for v in vars(root).values():
|
|
79
|
+
yield v
|
|
80
|
+
except TypeError:
|
|
81
|
+
# Objects without __dict__
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
# For convenience, also scan common container-typed attributes one level deep
|
|
85
|
+
try:
|
|
86
|
+
for v in vars(root).values():
|
|
87
|
+
if isinstance(v, (list, tuple, set)):
|
|
88
|
+
for item in v:
|
|
89
|
+
yield item
|
|
90
|
+
elif isinstance(v, dict):
|
|
91
|
+
for item in v.values():
|
|
92
|
+
yield item
|
|
93
|
+
except TypeError:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
seen_ids = set()
|
|
97
|
+
for part in iter_parts(model):
|
|
98
|
+
pid = id(part)
|
|
99
|
+
if pid in seen_ids:
|
|
100
|
+
continue
|
|
101
|
+
seen_ids.add(pid)
|
|
102
|
+
|
|
103
|
+
if await self._maybe_call_custom_save(part):
|
|
104
|
+
any_saved = True
|
|
105
|
+
|
|
106
|
+
return any_saved
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
current_engine_context: ContextVar[Optional[AIOEngineProxy]] = ContextVar(
|
|
110
|
+
"current_engine", default=None
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_current_engine_from_context() -> Optional[AIOEngineProxy]:
|
|
115
|
+
"""Get the current engine from context"""
|
|
116
|
+
return current_engine_context.get()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import pkgutil
|
|
3
|
+
from importlib.metadata import entry_points
|
|
4
|
+
from logging import getLogger
|
|
5
|
+
|
|
6
|
+
logger = getLogger("find_simstack_modules")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def discover_simstack_modules():
|
|
10
|
+
"""Discover all packages and modules within the simstack package."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def find_simstack_modules():
|
|
14
|
+
"""Find all packages and modules within the simstack package."""
|
|
15
|
+
|
|
16
|
+
all_modules = []
|
|
17
|
+
|
|
18
|
+
def walk_packages(package_name):
|
|
19
|
+
"""Walk through all packages and modules."""
|
|
20
|
+
try:
|
|
21
|
+
package = importlib.import_module(package_name)
|
|
22
|
+
package_path = package.__path__
|
|
23
|
+
|
|
24
|
+
logger.info(f"Package: {package_name}")
|
|
25
|
+
# print(f"Path: {package_path}")
|
|
26
|
+
|
|
27
|
+
for importer, modname, ispkg in pkgutil.walk_packages(
|
|
28
|
+
package_path, prefix=f"{package_name}."
|
|
29
|
+
):
|
|
30
|
+
if ispkg:
|
|
31
|
+
logger.debug(f" Subpackage: {modname}")
|
|
32
|
+
else:
|
|
33
|
+
logger.debug(f" Module: {modname}")
|
|
34
|
+
# Split module name by periods
|
|
35
|
+
all_modules.append(modname)
|
|
36
|
+
|
|
37
|
+
except Exception as e:
|
|
38
|
+
logger.error(f"Error walking {package_name}: {e}")
|
|
39
|
+
|
|
40
|
+
entry_point_list = entry_points(group="simstack.modules")
|
|
41
|
+
for entry_point in entry_point_list:
|
|
42
|
+
walk_packages(entry_point.value)
|
|
43
|
+
return all_modules
|
simstack/core/hash.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import inspect
|
|
3
|
+
|
|
4
|
+
from odmantic import ObjectId
|
|
5
|
+
|
|
6
|
+
max_iterable_hash_count = 10000
|
|
7
|
+
|
|
8
|
+
hash_exclusions = [
|
|
9
|
+
"pandas",
|
|
10
|
+
"numpy",
|
|
11
|
+
"ase",
|
|
12
|
+
"rdkit",
|
|
13
|
+
"openbabel",
|
|
14
|
+
"pybel",
|
|
15
|
+
"rdkit",
|
|
16
|
+
"pymatgen",
|
|
17
|
+
"ase",
|
|
18
|
+
"numpy",
|
|
19
|
+
"sqlalchemy",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_iterable(obj):
|
|
24
|
+
try:
|
|
25
|
+
iter(obj)
|
|
26
|
+
return True
|
|
27
|
+
except TypeError:
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_primitive_type(obj):
|
|
32
|
+
return isinstance(obj, (int, float, bytes, bool, bytearray, type(None)))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def hash_value(value):
|
|
36
|
+
return hashlib.sha256(str(value).encode()).hexdigest()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def hash_class_def(cls):
|
|
40
|
+
try:
|
|
41
|
+
source_code = inspect.getsource(cls)
|
|
42
|
+
source_hash = hashlib.sha256(source_code.encode("utf-8")).hexdigest()
|
|
43
|
+
return source_hash
|
|
44
|
+
except Exception:
|
|
45
|
+
mro = cls.__mro__
|
|
46
|
+
for mro_class in mro:
|
|
47
|
+
try:
|
|
48
|
+
source_code = inspect.getsource(mro_class)
|
|
49
|
+
source_hash = hashlib.sha256(source_code.encode("utf-8")).hexdigest()
|
|
50
|
+
return source_hash
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
53
|
+
return "no source code"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def hash_iterable(iterable):
|
|
57
|
+
hash_value = b""
|
|
58
|
+
for count, item in enumerate(iterable):
|
|
59
|
+
hash_value += complex_hash_function(item)
|
|
60
|
+
if count > max_iterable_hash_count:
|
|
61
|
+
break
|
|
62
|
+
return hashlib.sha256(hash_value.encode("utf-8")).hexdigest()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def hash_function_body(func):
|
|
66
|
+
# Get the source code of the function
|
|
67
|
+
source_code = inspect.getsource(func)
|
|
68
|
+
# Compute the hash of the source code
|
|
69
|
+
hash_object = hashlib.sha256(source_code.encode("utf-8"))
|
|
70
|
+
hash_digest = hash_object.hexdigest()
|
|
71
|
+
return hash_digest
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def hash_non_callable_members(instance):
|
|
75
|
+
hashed_values = {}
|
|
76
|
+
for attr_name, attr_value in vars(instance).items():
|
|
77
|
+
if not callable(attr_value):
|
|
78
|
+
hashed_values[attr_name] = hashlib.sha256(
|
|
79
|
+
str(attr_value).encode()
|
|
80
|
+
).hexdigest()
|
|
81
|
+
return hashed_values
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ComplexHash:
|
|
85
|
+
def __init__(self, obj):
|
|
86
|
+
self.hash_history = []
|
|
87
|
+
|
|
88
|
+
def hash_dict(self, obj):
|
|
89
|
+
hashed_values = {}
|
|
90
|
+
for k, v in obj.items():
|
|
91
|
+
hashed_values[k] = self.complex_hash(v)
|
|
92
|
+
combined_hash = "".join(f"{k}:{v}" for k, v in sorted(hashed_values.items()))
|
|
93
|
+
return hashlib.sha256(combined_hash.encode("utf-8")).hexdigest()
|
|
94
|
+
|
|
95
|
+
def hash_class(self, cls_obj):
|
|
96
|
+
# check is the class name starts with a name in the hash_exclusions list
|
|
97
|
+
class_type = cls_obj.__class__
|
|
98
|
+
class_name = class_type.__module__ + "." + class_type.__name__
|
|
99
|
+
if class_type.__module__ == "builtins":
|
|
100
|
+
return "builtin"
|
|
101
|
+
if any([class_name.startswith(exclusion) for exclusion in hash_exclusions]):
|
|
102
|
+
return "excluded"
|
|
103
|
+
# print("hashing class", cls_obj.__class__.__name__)
|
|
104
|
+
class_hash = hash_class_def(cls_obj.__class__)
|
|
105
|
+
# TODO is __dict__ better than vars ?
|
|
106
|
+
# dict_hash = self.hash_dict(vars(cls_obj))
|
|
107
|
+
|
|
108
|
+
obj_dict = cls_obj.__dict__.copy()
|
|
109
|
+
if hasattr(cls_obj, "__class__") and hasattr(cls_obj.__class__, "__bases__"):
|
|
110
|
+
for base in cls_obj.__class__.__bases__:
|
|
111
|
+
if base.__name__ == "Model" and "odmantic.model" in str(
|
|
112
|
+
base.__module__
|
|
113
|
+
):
|
|
114
|
+
if "id" in obj_dict:
|
|
115
|
+
del obj_dict["id"]
|
|
116
|
+
|
|
117
|
+
dict_hash = self.hash_dict(obj_dict)
|
|
118
|
+
if hasattr(cls_obj, "model_extra") and cls_obj.model_extra is not None:
|
|
119
|
+
dict_hash = dict_hash + self.hash_dict(cls_obj.model_extra)
|
|
120
|
+
if hasattr(cls_obj, "model_fields") and cls_obj.model_fields is not None:
|
|
121
|
+
dict_hash = dict_hash + self.hash_dict(cls_obj.model_fields)
|
|
122
|
+
combined_hash = class_hash + dict_hash
|
|
123
|
+
return hashlib.sha256(combined_hash.encode("utf-8")).hexdigest()
|
|
124
|
+
|
|
125
|
+
def complex_hash(self, obj):
|
|
126
|
+
# TODO: are the functions of a class hashed correctly?
|
|
127
|
+
|
|
128
|
+
if isinstance(obj, type):
|
|
129
|
+
return hash_class_def(obj)
|
|
130
|
+
elif is_primitive_type(obj):
|
|
131
|
+
return hash(obj)
|
|
132
|
+
elif isinstance(obj, ObjectId):
|
|
133
|
+
return str(obj)
|
|
134
|
+
elif isinstance(
|
|
135
|
+
obj, str
|
|
136
|
+
): # strings are iterable but we want to hash them directly
|
|
137
|
+
return hash_value(obj)
|
|
138
|
+
elif inspect.isfunction(obj):
|
|
139
|
+
return hash_function_body(obj)
|
|
140
|
+
elif inspect.ismethod(obj):
|
|
141
|
+
if hasattr(obj, "__self__"):
|
|
142
|
+
if obj in self.hash_history:
|
|
143
|
+
return "recursive"
|
|
144
|
+
self.hash_history.append(obj)
|
|
145
|
+
if hasattr(obj, "complex_hash"):
|
|
146
|
+
return obj.complex_hash()
|
|
147
|
+
return self.hash_class(obj.__self__)
|
|
148
|
+
else:
|
|
149
|
+
return hash_function_body(obj)
|
|
150
|
+
# this should be instantiated classes
|
|
151
|
+
elif hasattr(obj, "__dict__"):
|
|
152
|
+
if obj in self.hash_history:
|
|
153
|
+
return "recursive"
|
|
154
|
+
self.hash_history.append(obj)
|
|
155
|
+
if hasattr(obj, "complex_hash"):
|
|
156
|
+
return obj.complex_hash()
|
|
157
|
+
return self.hash_class(obj)
|
|
158
|
+
elif isinstance(obj, dict):
|
|
159
|
+
return self.hash_dict(obj)
|
|
160
|
+
elif is_iterable(obj):
|
|
161
|
+
hashed_values = {}
|
|
162
|
+
count = 0
|
|
163
|
+
for i, item in enumerate(obj):
|
|
164
|
+
hashed_values[i] = self.complex_hash(item)
|
|
165
|
+
count += 1
|
|
166
|
+
if count > max_iterable_hash_count:
|
|
167
|
+
break
|
|
168
|
+
combined_hash = "".join(
|
|
169
|
+
f"{k}:{v}" for k, v in sorted(hashed_values.items())
|
|
170
|
+
)
|
|
171
|
+
return hashlib.sha256(combined_hash.encode("utf-8")).hexdigest()
|
|
172
|
+
else:
|
|
173
|
+
return hash_value(obj)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def complex_hash_function(obj):
|
|
177
|
+
return ComplexHash(obj).complex_hash(obj)
|