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/util/db.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import List, Type, TypeVar, Dict, Any, Union, Optional
|
|
3
|
+
|
|
4
|
+
from bson import ObjectId
|
|
5
|
+
from motor.motor_asyncio import AsyncIOMotorClient
|
|
6
|
+
from odmantic import Model
|
|
7
|
+
|
|
8
|
+
from simstack.core.definitions import DBType, TaskStatus
|
|
9
|
+
from simstack.core.engine import current_engine_context, AIOEngineProxy
|
|
10
|
+
from simstack.models import NodeModel
|
|
11
|
+
from simstack.models.node_registry import NodeRegistry
|
|
12
|
+
from simstack.util.importer import import_class
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T", bound=Model)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Database:
|
|
20
|
+
"""
|
|
21
|
+
Asynchronous MongoDB database access class using ODMantic ORM.
|
|
22
|
+
Provides a cleaner interface for database operations.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self, db_type: DBType, db_name: str = "simstack", connection_string: str = ""
|
|
27
|
+
):
|
|
28
|
+
"""
|
|
29
|
+
Initialize the MongoDB connection
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
db_type: Type of database configuration
|
|
33
|
+
connection_string: MongoDB connection string (if not using default)
|
|
34
|
+
db_name: Name of the MongoDB database
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
if db_type == DBType.IN_MEMORY:
|
|
38
|
+
# For tests, use in-memory MongoDB (mongomock)
|
|
39
|
+
try:
|
|
40
|
+
# import mongomock
|
|
41
|
+
from mongomock_motor import AsyncMongoMockClient
|
|
42
|
+
|
|
43
|
+
self.client = AsyncMongoMockClient()
|
|
44
|
+
logger.info("Using in-memory MongoDB mock")
|
|
45
|
+
except ImportError:
|
|
46
|
+
logger.warning(
|
|
47
|
+
"mongomock not installed, falling back to localhost MongoDB"
|
|
48
|
+
)
|
|
49
|
+
self.client = AsyncIOMotorClient("mongodb://localhost:27017")
|
|
50
|
+
|
|
51
|
+
elif db_type == DBType.MONGODB:
|
|
52
|
+
if not connection_string:
|
|
53
|
+
connection_string = "mongodb://localhost:27017"
|
|
54
|
+
self.client = AsyncIOMotorClient(connection_string)
|
|
55
|
+
logger.info("Connected to MongoDB")
|
|
56
|
+
|
|
57
|
+
else:
|
|
58
|
+
raise ValueError(f"Unsupported database type for MongoDB: {db_type}")
|
|
59
|
+
|
|
60
|
+
# Create engine
|
|
61
|
+
self.engine = AIOEngineProxy(client=self.client, database=db_name)
|
|
62
|
+
# this will set the engine for all functions that are either called from the core package or the server
|
|
63
|
+
current_engine_context.set(self.engine)
|
|
64
|
+
self.db_name = db_name
|
|
65
|
+
|
|
66
|
+
async def list_collections(self):
|
|
67
|
+
"""
|
|
68
|
+
List all collections in the database
|
|
69
|
+
"""
|
|
70
|
+
db = self.client[self.db_name]
|
|
71
|
+
return await db.list_collection_names()
|
|
72
|
+
|
|
73
|
+
async def upsert(self, model: Model) -> Model:
|
|
74
|
+
"""
|
|
75
|
+
Save or update a model instance including all references and list fields
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
model: The ODMantic model instance to save
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
The saved model with updated fields
|
|
82
|
+
"""
|
|
83
|
+
# First, handle all references to ensure they exist in the database
|
|
84
|
+
if isinstance(Model, NodeRegistry):
|
|
85
|
+
if not hasattr(model, "name"):
|
|
86
|
+
logger.exception(
|
|
87
|
+
f"Fatal Error A trying to save node_registry: {model.model_dump()} for task_id: {model.id}"
|
|
88
|
+
)
|
|
89
|
+
return model
|
|
90
|
+
if model.name is None:
|
|
91
|
+
logger.exception(
|
|
92
|
+
f"Fatal Error B trying to save node_registry: {model.model_dump()} for task_id: {model.id}"
|
|
93
|
+
)
|
|
94
|
+
return model
|
|
95
|
+
|
|
96
|
+
await self._save_references(model)
|
|
97
|
+
|
|
98
|
+
# Then save the model itself
|
|
99
|
+
return await self.engine.save(model)
|
|
100
|
+
|
|
101
|
+
async def _save_references(self, model: Model, visited=None):
|
|
102
|
+
"""
|
|
103
|
+
Recursively save all references within a model
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
model: The model containing references to save
|
|
107
|
+
visited: Set of object IDs already processed to prevent infinite recursion
|
|
108
|
+
"""
|
|
109
|
+
if visited is None:
|
|
110
|
+
visited = set()
|
|
111
|
+
|
|
112
|
+
# Skip if we've already processed this object (prevents circular references)
|
|
113
|
+
model_id = id(model)
|
|
114
|
+
if model_id in visited:
|
|
115
|
+
return
|
|
116
|
+
visited.add(model_id)
|
|
117
|
+
|
|
118
|
+
# Get all model fields, including those with default_factory
|
|
119
|
+
model_fields = getattr(model.__class__, "model_fields", {})
|
|
120
|
+
|
|
121
|
+
# Process each field in the model
|
|
122
|
+
for field_name, field_value in model.__dict__.items():
|
|
123
|
+
if field_value is None:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
# Get field info if available
|
|
127
|
+
field_info = model_fields.get(field_name)
|
|
128
|
+
if not field_info:
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
# Handle different field types
|
|
132
|
+
|
|
133
|
+
# Case 1: Direct Reference fields
|
|
134
|
+
if hasattr(field_info, "annotation") and "Reference" in str(
|
|
135
|
+
field_info.annotation
|
|
136
|
+
):
|
|
137
|
+
if field_value is not None:
|
|
138
|
+
logger.info(
|
|
139
|
+
f"Saving reference field {field_name} of type {type(field_value).__name__}"
|
|
140
|
+
)
|
|
141
|
+
await self._save_references(field_value, visited)
|
|
142
|
+
await self.engine.save(field_value)
|
|
143
|
+
|
|
144
|
+
# Case 2: List fields that might contain models
|
|
145
|
+
elif isinstance(field_value, list):
|
|
146
|
+
for item in field_value:
|
|
147
|
+
if isinstance(item, Model):
|
|
148
|
+
logger.info(
|
|
149
|
+
f"Saving list item of type {type(item).__name__} in field {field_name}"
|
|
150
|
+
)
|
|
151
|
+
await self._save_references(item, visited)
|
|
152
|
+
await self.engine.save(item)
|
|
153
|
+
|
|
154
|
+
# Case 3: Embedded models (like in FileInstance within FileStack)
|
|
155
|
+
elif isinstance(field_value, Model):
|
|
156
|
+
logger.info(
|
|
157
|
+
f"Saving embedded model of type {type(field_value).__name__} in field {field_name}"
|
|
158
|
+
)
|
|
159
|
+
await self._save_references(field_value, visited)
|
|
160
|
+
|
|
161
|
+
async def save(self, model: Model) -> Model:
|
|
162
|
+
return await self.upsert(model)
|
|
163
|
+
|
|
164
|
+
async def find_one(self, model_class: Type[T], query=None, **kwargs) -> Optional[T]:
|
|
165
|
+
"""
|
|
166
|
+
Find a single document matching the query
|
|
167
|
+
:param model_class:
|
|
168
|
+
:param query:
|
|
169
|
+
Returns:
|
|
170
|
+
The found model instance or None
|
|
171
|
+
|
|
172
|
+
"""
|
|
173
|
+
return await self.engine.find_one(model_class, query, **kwargs)
|
|
174
|
+
|
|
175
|
+
async def find_one_by_model_name(
|
|
176
|
+
self, model_mapping: str, item_id: str
|
|
177
|
+
) -> Optional[Any]:
|
|
178
|
+
"""
|
|
179
|
+
Find a single document matching the query by model name
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
model_name: The name of the ODMantic model class as a string
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
The found model instance or None
|
|
186
|
+
|
|
187
|
+
Raises:
|
|
188
|
+
ValueError: If model name is not found in the global namespace
|
|
189
|
+
"""
|
|
190
|
+
# Import common models that might be used
|
|
191
|
+
|
|
192
|
+
# Find the model class based on its name
|
|
193
|
+
# model_class = import_class(model_mapping)
|
|
194
|
+
# model_elements = model_mapping.split(".")
|
|
195
|
+
# if len(model_elements) > 1:
|
|
196
|
+
# model_name = model_mapping.split(".")[-1]
|
|
197
|
+
# else:
|
|
198
|
+
# model_name = model_mapping
|
|
199
|
+
# # Search through modules in current namespace
|
|
200
|
+
# for module_name, module in sys.modules.items():
|
|
201
|
+
# if hasattr(module, model_name):
|
|
202
|
+
# potential_class = getattr(module, model_name)
|
|
203
|
+
# # Check if it's likely a model class (has attributes like id, __collection__)
|
|
204
|
+
# if hasattr(potential_class, "id") and hasattr(
|
|
205
|
+
# potential_class, "__collection__"
|
|
206
|
+
# ):
|
|
207
|
+
# model_class = potential_class
|
|
208
|
+
# break
|
|
209
|
+
# if model_class is None:
|
|
210
|
+
# logger.info(f"Trying to import model: {model_mapping}")
|
|
211
|
+
|
|
212
|
+
model_class = await import_class(model_mapping)
|
|
213
|
+
if model_class is None:
|
|
214
|
+
raise ValueError(
|
|
215
|
+
f"DB: model class {model_mapping} not found in the available modules"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if isinstance(item_id, str):
|
|
219
|
+
item_id = ObjectId(item_id)
|
|
220
|
+
|
|
221
|
+
instance = await self.engine.find_one(model_class, model_class.id == item_id)
|
|
222
|
+
if not instance:
|
|
223
|
+
logger.error(
|
|
224
|
+
f"Instance of '{model_class.__name__}' with id '{item_id}' does not exist"
|
|
225
|
+
)
|
|
226
|
+
raise ValueError(
|
|
227
|
+
f"Instance of '{model_class.__name__}' with id '{item_id}' does not exist"
|
|
228
|
+
)
|
|
229
|
+
return instance
|
|
230
|
+
|
|
231
|
+
async def find_all(self, model_class: Type[T], **kwargs) -> List[T]:
|
|
232
|
+
"""
|
|
233
|
+
Find all documents of a given model class
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
model_class: The ODMantic model class
|
|
237
|
+
**kwargs: Query filters
|
|
238
|
+
"""
|
|
239
|
+
return await self.engine.find(model_class, **kwargs)
|
|
240
|
+
|
|
241
|
+
async def find_many(self, model_class: Type[T], query, **kwargs) -> List[T]:
|
|
242
|
+
"""
|
|
243
|
+
Find multiple documents matching the query
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
model_class: The ODMantic model class
|
|
247
|
+
:param query:
|
|
248
|
+
**kwargs: Query filters
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
List of matching model instances
|
|
252
|
+
|
|
253
|
+
"""
|
|
254
|
+
return await self.engine.find(model_class, query, **kwargs)
|
|
255
|
+
|
|
256
|
+
async def delete(self, model: Model) -> None:
|
|
257
|
+
"""
|
|
258
|
+
Delete a model instance
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
model: The model instance to delete
|
|
262
|
+
"""
|
|
263
|
+
await self.engine.delete(model)
|
|
264
|
+
|
|
265
|
+
async def delete_by_id(
|
|
266
|
+
self, model_class: Type[T], id: Union[str, ObjectId]
|
|
267
|
+
) -> None:
|
|
268
|
+
"""
|
|
269
|
+
Delete a document by its ID
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
model_class: The ODMantic model class
|
|
273
|
+
id: The document ID (either string or ObjectId)
|
|
274
|
+
"""
|
|
275
|
+
# Convert string ID to ObjectId if needed
|
|
276
|
+
if isinstance(id, str):
|
|
277
|
+
id = ObjectId(id)
|
|
278
|
+
|
|
279
|
+
instance = await self.engine.find_one(model_class, model_class.id == id)
|
|
280
|
+
if instance:
|
|
281
|
+
await self.engine.delete(instance)
|
|
282
|
+
else:
|
|
283
|
+
logger.error(f"No data found in '{model_class.__name__}' with id '{id}'")
|
|
284
|
+
|
|
285
|
+
async def drop_collection(self, model_class: Type[T]) -> None:
|
|
286
|
+
"""
|
|
287
|
+
Drop the collection for the given model class
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
model_class: The ODMantic model class
|
|
291
|
+
"""
|
|
292
|
+
collection = self.engine.get_collection(model_class)
|
|
293
|
+
if collection is None:
|
|
294
|
+
logger.error(f"Could not drop collection {model_class.__name__}")
|
|
295
|
+
await collection.drop()
|
|
296
|
+
|
|
297
|
+
# collection_name = model_class.__collection__
|
|
298
|
+
# db = self.client[self.db_name]
|
|
299
|
+
# await db[collection_name].drop()
|
|
300
|
+
|
|
301
|
+
async def load_from_collection(
|
|
302
|
+
self, model_class: Type[T], id: Union[str, ObjectId]
|
|
303
|
+
) -> Optional[T]:
|
|
304
|
+
"""
|
|
305
|
+
Load a document by its ID
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
model_class: The ODMantic model class
|
|
309
|
+
id: The document ID (either string or ObjectId)
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
The found model instance or None
|
|
313
|
+
|
|
314
|
+
Raises:
|
|
315
|
+
ValueError: If document is not found
|
|
316
|
+
"""
|
|
317
|
+
# Convert string ID to ObjectId if needed
|
|
318
|
+
if isinstance(id, str):
|
|
319
|
+
id = ObjectId(id)
|
|
320
|
+
|
|
321
|
+
instance = await self.engine.find_one(model_class, model_class.id == id)
|
|
322
|
+
if not instance:
|
|
323
|
+
logger.error(f"No data found in '{model_class.__name__}' with id '{id}'")
|
|
324
|
+
raise ValueError(
|
|
325
|
+
f"No data found in '{model_class.__name__}' with id '{id}'"
|
|
326
|
+
)
|
|
327
|
+
return instance
|
|
328
|
+
|
|
329
|
+
async def load_task(
|
|
330
|
+
self, name: str, arg_hash: str, function_hash: str
|
|
331
|
+
) -> Optional[NodeRegistry]:
|
|
332
|
+
"""
|
|
333
|
+
Load a task based on name, arg_hash and function_hash
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
name: Node name
|
|
337
|
+
arg_hash: Hash of the arguments
|
|
338
|
+
function_hash: Hash of the function
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
The found NodeRegistry instance or None
|
|
342
|
+
"""
|
|
343
|
+
result = await self.engine.find_one(
|
|
344
|
+
NodeRegistry,
|
|
345
|
+
(NodeRegistry.name == name)
|
|
346
|
+
& (NodeRegistry.arg_hash == arg_hash)
|
|
347
|
+
& (NodeRegistry.function_hash == function_hash),
|
|
348
|
+
)
|
|
349
|
+
return result
|
|
350
|
+
|
|
351
|
+
async def load_node_model_by_name(
|
|
352
|
+
self, node_model_name: str
|
|
353
|
+
) -> Optional[NodeModel]:
|
|
354
|
+
"""
|
|
355
|
+
Load a node based on its name
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
node_model_name: The node name
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
The found NodeRegistry instance or None
|
|
362
|
+
"""
|
|
363
|
+
return await self.engine.find_one(NodeModel, NodeModel.name == node_model_name)
|
|
364
|
+
|
|
365
|
+
async def load_task_by_id(
|
|
366
|
+
self, task_id: Union[str, ObjectId]
|
|
367
|
+
) -> Optional[NodeRegistry]:
|
|
368
|
+
"""
|
|
369
|
+
Load a task based on its ID
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
task_id: The task ID
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
The found NodeRegistry instance or None
|
|
376
|
+
"""
|
|
377
|
+
if isinstance(task_id, str):
|
|
378
|
+
task_id = ObjectId(task_id)
|
|
379
|
+
|
|
380
|
+
return await self.engine.find_one(NodeRegistry, NodeRegistry.id == task_id)
|
|
381
|
+
|
|
382
|
+
async def load_waiting_tasks_for_resource(
|
|
383
|
+
self, resource: str
|
|
384
|
+
) -> List[NodeRegistry]:
|
|
385
|
+
"""
|
|
386
|
+
Load all waiting tasks for a specific resource
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
resource: The resource name
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
List of matching NodeRegistry instances
|
|
393
|
+
"""
|
|
394
|
+
submitted_tasks = await self.engine.find(
|
|
395
|
+
NodeRegistry, NodeRegistry.status == TaskStatus.SUBMITTED
|
|
396
|
+
)
|
|
397
|
+
# Then filter them in Python by checking the resource field
|
|
398
|
+
matching_tasks = []
|
|
399
|
+
for task in submitted_tasks:
|
|
400
|
+
# Check if parameters has a resource attribute and if it matches our resource
|
|
401
|
+
# the local runner will also do the immidiate tasks
|
|
402
|
+
if hasattr(task.parameters, "resource") and (
|
|
403
|
+
task.parameters.resource == resource
|
|
404
|
+
or (resource == "local" and task.parameters.resource == "self")
|
|
405
|
+
):
|
|
406
|
+
if resource == "local" and task.parameters.resource == "self":
|
|
407
|
+
logger.info(f"local runner taking job for 'self' with {task.id}")
|
|
408
|
+
matching_tasks.append(task)
|
|
409
|
+
return matching_tasks
|
|
410
|
+
|
|
411
|
+
async def reset_database(self) -> None:
|
|
412
|
+
"""
|
|
413
|
+
Reset the database by dropping all collections and recreating them
|
|
414
|
+
"""
|
|
415
|
+
db = self.client[self.db_name]
|
|
416
|
+
collections = await db.list_collection_names()
|
|
417
|
+
|
|
418
|
+
for collection in collections:
|
|
419
|
+
await db[collection].drop()
|
|
420
|
+
|
|
421
|
+
logger.info(f"Database {self.db_name} has been reset")
|
|
422
|
+
|
|
423
|
+
async def count(self, model_class: Type[T], **kwargs) -> int:
|
|
424
|
+
"""
|
|
425
|
+
Count documents matching the query
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
model_class: The ODMantic model class
|
|
429
|
+
**kwargs: Query filters
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
Number of matching documents
|
|
433
|
+
"""
|
|
434
|
+
return await self.engine.count(model_class, **kwargs)
|
|
435
|
+
|
|
436
|
+
async def aggregate(
|
|
437
|
+
self, model_class: Type[Model], pipeline: List[Dict]
|
|
438
|
+
) -> List[Dict]:
|
|
439
|
+
"""
|
|
440
|
+
Perform an aggregation operation
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
model_class: The ODMantic model class
|
|
444
|
+
pipeline: MongoDB aggregation pipeline
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
List of aggregation results
|
|
448
|
+
"""
|
|
449
|
+
collection = self.engine.get_collection(model_class)
|
|
450
|
+
cursor = collection.aggregate(pipeline)
|
|
451
|
+
return await cursor.to_list(length=None)
|
|
452
|
+
|
|
453
|
+
async def close(self) -> None:
|
|
454
|
+
"""
|
|
455
|
+
Close database connections
|
|
456
|
+
"""
|
|
457
|
+
self.client.close()
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Optional, Dict, Any
|
|
5
|
+
|
|
6
|
+
from pymongo import MongoClient
|
|
7
|
+
|
|
8
|
+
# Regular expression patterns
|
|
9
|
+
task_pattern = re.compile(r"task_id: ([\w\-\.]+)")
|
|
10
|
+
resource_pattern = re.compile(r"resource_id: ([\w\-\.]+)")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def extract_task_id(message: str) -> Optional[str]:
|
|
14
|
+
"""Extract task ID from log message."""
|
|
15
|
+
match = task_pattern.search(message)
|
|
16
|
+
return match.group(1) if match else None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def extract_resource_id(message: str) -> Optional[str]:
|
|
20
|
+
"""Extract resource ID from log message."""
|
|
21
|
+
match = resource_pattern.search(message)
|
|
22
|
+
return match.group(1) if match else None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DBLogHandler(logging.Handler):
|
|
26
|
+
"""Logging handler that stores logs in MongoDB using pymongo."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
connection_string: str,
|
|
31
|
+
db_name: str,
|
|
32
|
+
collection_name: str = "logs",
|
|
33
|
+
):
|
|
34
|
+
super().__init__()
|
|
35
|
+
|
|
36
|
+
# Validate configuration
|
|
37
|
+
if not connection_string or not isinstance(connection_string, str):
|
|
38
|
+
raise ValueError("Invalid connection string")
|
|
39
|
+
if not db_name or not isinstance(db_name, str):
|
|
40
|
+
raise ValueError("Invalid database name")
|
|
41
|
+
if not collection_name or not isinstance(collection_name, str):
|
|
42
|
+
raise ValueError("Invalid collection name")
|
|
43
|
+
|
|
44
|
+
# Store configuration
|
|
45
|
+
self.connection_string = connection_string
|
|
46
|
+
self.db_name = db_name
|
|
47
|
+
self.collection_name = collection_name
|
|
48
|
+
|
|
49
|
+
# Create MongoDB client
|
|
50
|
+
self.client = MongoClient(self.connection_string)
|
|
51
|
+
self.db = self.client[self.db_name]
|
|
52
|
+
self.collection = self.db[self.collection_name]
|
|
53
|
+
|
|
54
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
55
|
+
"""Process a log record by converting it to a log entry and inserting it into MongoDB."""
|
|
56
|
+
try:
|
|
57
|
+
# Create log entry as a dictionary
|
|
58
|
+
log_entry = self.create_log_entry(record)
|
|
59
|
+
|
|
60
|
+
# Insert the log entry into the collection
|
|
61
|
+
self.collection.insert_one(log_entry)
|
|
62
|
+
|
|
63
|
+
except Exception as e:
|
|
64
|
+
# Use print instead of logging to avoid potential recursion
|
|
65
|
+
print(f"Error in emit: {e}")
|
|
66
|
+
self.handleError(record)
|
|
67
|
+
|
|
68
|
+
def create_log_entry(self, record: logging.LogRecord) -> Dict[str, Any]:
|
|
69
|
+
# Format message
|
|
70
|
+
message = self.format(record)
|
|
71
|
+
|
|
72
|
+
# Extract task and resource IDs
|
|
73
|
+
task_id = extract_task_id(message)
|
|
74
|
+
resource_id = extract_resource_id(message)
|
|
75
|
+
|
|
76
|
+
# Create log entry dictionary
|
|
77
|
+
log_entry_dict = {
|
|
78
|
+
"timestamp": datetime.fromtimestamp(record.created),
|
|
79
|
+
"level": record.levelname,
|
|
80
|
+
"logger_name": record.name,
|
|
81
|
+
"message": message,
|
|
82
|
+
"module": record.module,
|
|
83
|
+
"function": record.funcName,
|
|
84
|
+
"line": record.lineno,
|
|
85
|
+
"task_id": task_id,
|
|
86
|
+
"resource": resource_id,
|
|
87
|
+
"thread_name": record.threadName,
|
|
88
|
+
"process_name": record.processName,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# Add exception info if available
|
|
92
|
+
if record.exc_info:
|
|
93
|
+
log_entry_dict.update(
|
|
94
|
+
{
|
|
95
|
+
"exception_type": record.exc_info[0].__name__
|
|
96
|
+
if record.exc_info and record.exc_info[0]
|
|
97
|
+
else None,
|
|
98
|
+
"exception_message": str(record.exc_info[1])
|
|
99
|
+
if record.exc_info and record.exc_info[1]
|
|
100
|
+
else None,
|
|
101
|
+
"exception_traceback": record.exc_text,
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return log_entry_dict
|
|
106
|
+
|
|
107
|
+
def close(self) -> None:
|
|
108
|
+
"""Close the MongoDB client connection."""
|
|
109
|
+
try:
|
|
110
|
+
if hasattr(self, "client") and self.client:
|
|
111
|
+
self.client.close()
|
|
112
|
+
except Exception as e:
|
|
113
|
+
print(f"Error closing DB log handler client: {e}")
|
|
114
|
+
|
|
115
|
+
def __del__(self):
|
|
116
|
+
"""Ensure connection is closed when handler is destroyed."""
|
|
117
|
+
self.close()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from odmantic import Model
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def default_from_model(cls_method, model: Model, **kwargs) -> Model:
|
|
10
|
+
model_dict = model.model_dump(exclude={"id"})
|
|
11
|
+
return cls_method.from_dict(model_dict, **kwargs)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def default_from_dict(cls_method, data: dict, **kwargs) -> Any:
|
|
15
|
+
"""
|
|
16
|
+
Create an instance of the model from a dictionary.
|
|
17
|
+
Handles nested models and enum values.
|
|
18
|
+
"""
|
|
19
|
+
if not isinstance(data, dict):
|
|
20
|
+
return data # Return as is if it's not a dictionary
|
|
21
|
+
|
|
22
|
+
processed_data = {}
|
|
23
|
+
for field_name, field in cls_method.model_fields.items():
|
|
24
|
+
if field_name not in data:
|
|
25
|
+
continue
|
|
26
|
+
|
|
27
|
+
field_type = field.annotation
|
|
28
|
+
field_value = data[field_name]
|
|
29
|
+
|
|
30
|
+
# Handle enum values
|
|
31
|
+
if hasattr(field_type, "__members__"):
|
|
32
|
+
logger.debug("Handling enum field: %s", field_name)
|
|
33
|
+
if isinstance(field_value, field_type):
|
|
34
|
+
# If it's already an enum instance, use it directly
|
|
35
|
+
logger.debug("Field value is enum instance: %s", field_value)
|
|
36
|
+
processed_data[field_name] = field_value
|
|
37
|
+
else:
|
|
38
|
+
# Try to create enum from the value
|
|
39
|
+
try:
|
|
40
|
+
processed_data[field_name] = field_type(field_value)
|
|
41
|
+
logger.debug(
|
|
42
|
+
"Converted field value to enum: %s", processed_data[field_name]
|
|
43
|
+
)
|
|
44
|
+
except ValueError:
|
|
45
|
+
logger.debug(
|
|
46
|
+
"Invalid enum value for field %s: %s", field_name, field_value
|
|
47
|
+
)
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"Invalid enum value '{field_value}' for field {field_name}"
|
|
50
|
+
)
|
|
51
|
+
# Handle nested models
|
|
52
|
+
elif hasattr(field_type, "from_dict") and isinstance(field_value, dict):
|
|
53
|
+
processed_data[field_name] = field_type.from_dict(field_value)
|
|
54
|
+
else:
|
|
55
|
+
processed_data[field_name] = field_value
|
|
56
|
+
|
|
57
|
+
return cls_method.model_validate(processed_data)
|