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,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"
@@ -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)