ic-python-db 0.7.1__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.
@@ -0,0 +1,41 @@
1
+ """
2
+ IC Python DB - A lightweight key-value database with entity relationships and audit logging
3
+ """
4
+
5
+ from .constants import ACTION_CREATE, ACTION_DELETE, ACTION_MODIFY
6
+ from .db_engine import Database
7
+ from .entity import Entity
8
+ from .mixins import TimestampedMixin
9
+ from .properties import (
10
+ Boolean,
11
+ Float,
12
+ Integer,
13
+ ManyToMany,
14
+ ManyToOne,
15
+ OneToMany,
16
+ OneToOne,
17
+ String,
18
+ )
19
+ from .storage import MemoryStorage, Storage
20
+ from .system_time import SystemTime
21
+
22
+ __version__ = "0.7.1"
23
+ __all__ = [
24
+ "Database",
25
+ "Entity",
26
+ "Storage",
27
+ "MemoryStorage",
28
+ "TimestampedMixin",
29
+ "String",
30
+ "Integer",
31
+ "Float",
32
+ "Boolean",
33
+ "OneToOne",
34
+ "OneToMany",
35
+ "ManyToOne",
36
+ "ManyToMany",
37
+ "SystemTime",
38
+ "ACTION_CREATE",
39
+ "ACTION_MODIFY",
40
+ "ACTION_DELETE",
41
+ ]
ic_python_db/_cdk.py ADDED
@@ -0,0 +1,14 @@
1
+ """CDK compatibility layer.
2
+
3
+ This module centralizes all imports from the Internet Computer CDK (currently Basilisk).
4
+ To switch CDKs, only this file needs to be modified.
5
+ """
6
+
7
+ try:
8
+ from basilisk import * # noqa: F401, F403
9
+ from basilisk import ic # noqa: F401 - explicit for IDE support
10
+
11
+ HAS_CDK = True
12
+ except ImportError:
13
+ HAS_CDK = False
14
+ ic = None # type: ignore
@@ -0,0 +1,6 @@
1
+ LEVEL_MAX_DEFAULT = 3
2
+
3
+ # Hook action types
4
+ ACTION_CREATE = "create"
5
+ ACTION_MODIFY = "modify"
6
+ ACTION_DELETE = "delete"
@@ -0,0 +1,37 @@
1
+ """Context management for caller identity.
2
+
3
+ Note: This implementation uses a simple module-level variable instead of
4
+ contextvars.ContextVar for compatibility with the Internet Computer (Basilisk)
5
+ environment, which does not support ContextVar.
6
+
7
+ This is safe because:
8
+ - IC canisters are single-threaded (no concurrent request handling)
9
+ - Each canister call is processed sequentially
10
+ - No thread safety is needed in the IC environment
11
+
12
+ Warning: This implementation is NOT thread-safe. Do not use in multi-threaded
13
+ environments outside of the Internet Computer.
14
+ """
15
+
16
+ # Module-level storage for current caller ID
17
+ # IC-compatible: Simple variable since IC canisters are single-threaded
18
+ _current_caller: str = "system"
19
+
20
+
21
+ def get_caller_id() -> str:
22
+ """Get the current caller ID.
23
+
24
+ Returns:
25
+ str: Current caller ID (defaults to 'system')
26
+ """
27
+ return _current_caller
28
+
29
+
30
+ def set_caller_id(caller_id: str) -> None:
31
+ """Set the current caller ID.
32
+
33
+ Args:
34
+ caller_id: ID of the caller to set
35
+ """
36
+ global _current_caller
37
+ _current_caller = caller_id
@@ -0,0 +1,335 @@
1
+ """
2
+ Core database engine implementation
3
+ """
4
+
5
+ import json
6
+ import time
7
+ import weakref
8
+ from typing import Any, Dict, List, Optional, Tuple
9
+
10
+ from ic_python_logging import get_logger
11
+
12
+ from .storage import MemoryStorage, Storage
13
+
14
+ logger = get_logger(__name__)
15
+
16
+
17
+ class Database:
18
+ """Main database class providing high-level operations"""
19
+
20
+ _instance = None
21
+ _audit_enabled = False
22
+
23
+ @classmethod
24
+ def get_instance(cls) -> "Database":
25
+ if not cls._instance:
26
+ cls._instance = cls.init(audit_enabled=True)
27
+ return cls._instance
28
+
29
+ @classmethod
30
+ def init(
31
+ cls,
32
+ audit_enabled: bool = False,
33
+ db_storage: Storage = None,
34
+ db_audit: Storage = None,
35
+ ) -> "Database":
36
+ if cls._instance:
37
+ raise RuntimeError("Database instance already exists")
38
+ cls._instance = cls(audit_enabled, db_storage, db_audit)
39
+ return cls._instance
40
+
41
+ def __init__(
42
+ self,
43
+ audit_enabled: bool = False,
44
+ db_storage: Storage = None,
45
+ db_audit: Storage = None,
46
+ ):
47
+ self._db_storage = db_storage if db_storage else MemoryStorage()
48
+ self._audit_enabled = audit_enabled
49
+ self._db_audit = None
50
+ if self._audit_enabled:
51
+ self._db_audit = db_audit if db_audit else MemoryStorage()
52
+
53
+ if self._db_audit:
54
+ self._db_audit.insert("_min_id", "0")
55
+ self._db_audit.insert("_max_id", "0")
56
+
57
+ logger.debug(
58
+ f"Audit database initialized with {len(list(self._db_audit.items()))} items"
59
+ )
60
+
61
+ self._entity_types = {}
62
+ # Entity registry: {(type_name, entity_id): weakref to entity instance}
63
+ self._entity_registry = {}
64
+
65
+ def as_user(self, user_id: str):
66
+ """Context manager for running operations as a specific user.
67
+
68
+ Usage:
69
+ with db.as_user("alice"):
70
+ doc = Document(title="My Doc") # Owner is alice
71
+
72
+ Args:
73
+ user_id: ID of the user to impersonate
74
+
75
+ Returns:
76
+ Context manager that sets and resets caller ID
77
+ """
78
+ from .context import get_caller_id, set_caller_id
79
+
80
+ class UserContext:
81
+ def __init__(self, user_id):
82
+ self.user_id = user_id
83
+ self.previous_caller = None
84
+
85
+ def __enter__(self):
86
+ self.previous_caller = get_caller_id()
87
+ set_caller_id(self.user_id)
88
+ return self
89
+
90
+ def __exit__(self, *args):
91
+ set_caller_id(self.previous_caller)
92
+
93
+ return UserContext(user_id)
94
+
95
+ def clear(self):
96
+ keys = list(self._db_storage.keys())
97
+ for key in keys:
98
+ self._db_storage.remove(key)
99
+
100
+ # Also clear the entity registry
101
+ self.clear_registry()
102
+
103
+ if not self._db_audit:
104
+ return
105
+
106
+ keys = list(self._db_audit.keys())
107
+ for key in keys:
108
+ self._db_audit.remove(key)
109
+
110
+ self._db_audit.insert("_min_id", "0")
111
+ self._db_audit.insert("_max_id", "0")
112
+
113
+ def register_entity(self, entity_instance):
114
+ """Register an entity instance in the identity map."""
115
+ key = (entity_instance._type, entity_instance._id)
116
+ # Use weak reference to allow garbage collection
117
+ self._entity_registry[key] = weakref.ref(entity_instance)
118
+
119
+ def get_entity(self, type_name: str, entity_id: str):
120
+ """Get entity from registry if it exists."""
121
+ key = (type_name, entity_id)
122
+ if key in self._entity_registry:
123
+ entity_ref = self._entity_registry[key]
124
+ entity = entity_ref() # Get object from weak reference
125
+ if entity is not None:
126
+ return entity
127
+ else:
128
+ # Clean up dead reference
129
+ del self._entity_registry[key]
130
+ return None
131
+
132
+ def clear_registry(self):
133
+ """Clear the entity registry (useful for testing)."""
134
+ self._entity_registry.clear()
135
+
136
+ def unregister_entity(self, type_name: str, entity_id: str):
137
+ """Remove an entity from the registry (used when deleting)."""
138
+ key = (type_name, entity_id)
139
+ if key in self._entity_registry:
140
+ del self._entity_registry[key]
141
+
142
+ def _audit(self, op: str, key: str, data: Any) -> None:
143
+ if self._db_audit and self._audit_enabled:
144
+ timestamp = int(time.time() * 1000)
145
+ id = self._db_audit.get("_max_id")
146
+ logger.debug(f"Audit: Recording {op} operation with ID {id}")
147
+ self._db_audit.insert(str(id), json.dumps([op, timestamp, key, data]))
148
+ self._db_audit.insert("_max_id", str(int(id) + 1))
149
+
150
+ def save(self, type_name: str, id: str, data: dict) -> None:
151
+ """Store the data under the given key
152
+
153
+ Args:
154
+ type_name: Type of the entity
155
+ id: ID of the entity
156
+ data: Data to store
157
+ """
158
+ key = f"{type_name}@{id}"
159
+ self._db_storage.insert(key, json.dumps(data))
160
+ self._audit("save", key, data)
161
+
162
+ def load(self, type_name: str, id: str) -> Optional[dict]:
163
+ """Load and return the data associated with the key
164
+
165
+ Args:
166
+ type_name: Type of the entity
167
+ id: ID of the entity
168
+
169
+ Returns:
170
+ Dict if found, None otherwise
171
+ """
172
+ key = f"{type_name}@{id}"
173
+ data = self._db_storage.get(key)
174
+ if data:
175
+ return json.loads(data)
176
+ return None
177
+
178
+ def delete(self, type_name: str, entity_id: str) -> None:
179
+ """Delete the data associated with the key
180
+
181
+ Args:
182
+ type_name: Type of the entity
183
+ id: ID of the entity
184
+ """
185
+ logger.debug(f"Database: Deleting entity {type_name}@{entity_id}")
186
+ key = f"{type_name}@{entity_id}"
187
+ data = self._db_storage.get(key)
188
+ self._db_storage.remove(key)
189
+ self._audit("delete", key, data)
190
+ logger.debug(f"Database: Deleted entity {type_name}@{entity_id}")
191
+
192
+ def update(self, type_name: str, id: str, field: str, value: Any) -> None:
193
+ """Update a specific field in the stored data
194
+
195
+ Args:
196
+ type_name: Type of the entity
197
+ id: ID of the entity
198
+ field: Field to update
199
+ value: New value
200
+ """
201
+ data = self.load(type_name, id)
202
+ if data:
203
+ data[field] = value
204
+ self.save(type_name, id, data)
205
+ self._audit("update", f"{type_name}@{id}", data)
206
+
207
+ def get_all(self) -> Dict[str, Any]:
208
+ """Return all stored data"""
209
+ return {k: json.loads(v) for k, v in self._db_storage.items()}
210
+
211
+ def _extract_class_name(self, type_name: str) -> str:
212
+ """Extract the class name from a potentially namespaced type name.
213
+
214
+ Args:
215
+ type_name: Type name, possibly with namespace (e.g., "app::User")
216
+
217
+ Returns:
218
+ The class name without namespace (e.g., "User")
219
+ """
220
+ if "::" in type_name:
221
+ return type_name.split("::")[-1]
222
+ return type_name
223
+
224
+ def register_entity_type(self, type_obj, type_name: str = None):
225
+ """Register an entity type with the database.
226
+
227
+ Args:
228
+ type_obj: Type object to register
229
+ type_name: Optional full type name (including namespace). If not provided, uses class name.
230
+ """
231
+ if type_name is None:
232
+ type_name = type_obj.__name__
233
+ logger.debug(
234
+ f"Registering type {type_name} (class: {type_obj.__name__}) with bases {[b.__name__ for b in type_obj.__bases__]}"
235
+ )
236
+
237
+ # Always register under the full type name
238
+ self._entity_types[type_name] = type_obj
239
+
240
+ # For backward compatibility, also register under class name if:
241
+ # 1. No namespace (type_name == class name) - allows non-namespaced lookups, OR
242
+ # 2. Class name slot is available (no collision) - allows simple name lookups
243
+ # This dual registration enables both "User" and "app::User" lookups while
244
+ # preventing collisions when multiple namespaced entities share a class name
245
+ if (
246
+ type_name == type_obj.__name__
247
+ or type_obj.__name__ not in self._entity_types
248
+ ):
249
+ self._entity_types[type_obj.__name__] = type_obj
250
+ else:
251
+ # Class name already registered - log warning about potential collision
252
+ existing = self._entity_types[type_obj.__name__]
253
+ if existing != type_obj:
254
+ logger.warning(
255
+ f"Class name '{type_obj.__name__}' collision: '{type_name}' not registered under class name. "
256
+ f"Existing registration: '{getattr(existing, '__module__', 'unknown')}.{existing.__name__}'. "
257
+ f"Use full type name '{type_name}' in relations."
258
+ )
259
+
260
+ def is_subclass(self, type_name, parent_type):
261
+ """Check if a type is a subclass of another type.
262
+
263
+ Args:
264
+ type_name: Name of the type to check (may include namespace)
265
+ parent_type: Parent type to check against
266
+
267
+ Returns:
268
+ bool: True if type_name is a subclass of parent_type
269
+ """
270
+ type_obj = self._entity_types.get(type_name)
271
+ if not type_obj:
272
+ # Try to extract class name from namespaced type (e.g., "app::User" -> "User")
273
+ class_name = self._extract_class_name(type_name)
274
+ type_obj = self._entity_types.get(class_name)
275
+ logger.debug(f"Type check: {type_name} -> {parent_type.__name__}")
276
+ return type_obj and issubclass(type_obj, parent_type)
277
+
278
+ def dump_json(self, pretty: bool = False) -> str:
279
+ """Dump the entire database as a JSON string.
280
+
281
+ Args:
282
+ pretty: If True, format the JSON with indentation for readability
283
+
284
+ Returns:
285
+ JSON string containing all database data organized by type
286
+ """
287
+ result = {}
288
+ for key in self._db_storage.keys():
289
+ if key.startswith("_"): # Skip internal keys
290
+ continue
291
+ try:
292
+ type_name, id = key.split("@")
293
+ if type_name not in result:
294
+ result[type_name] = {}
295
+ result[type_name][id] = json.loads(self._db_storage.get(key))
296
+ except (ValueError, json.JSONDecodeError):
297
+ continue # Skip invalid entries
298
+
299
+ if pretty:
300
+ return json.dumps(result, indent=2)
301
+ return json.dumps(result)
302
+
303
+ def raw_dump_json(self, pretty: bool = False) -> str:
304
+ """Dump the raw contents of the storage as a JSON string.
305
+
306
+ Args:
307
+ pretty: If True, format the JSON with indentation for readability
308
+
309
+ Returns:
310
+ A JSON string representation of the raw storage contents
311
+ """
312
+ result = {}
313
+ for key in self._db_storage.keys():
314
+ result[key] = self._db_storage.get(key)
315
+ if pretty:
316
+ return json.dumps(result, indent=2)
317
+ return json.dumps(result)
318
+
319
+ def get_audit(
320
+ self, id_from: Optional[int] = None, id_to: Optional[int] = None
321
+ ) -> Dict[str, str]:
322
+ """Get audit log entries between the specified IDs"""
323
+ if not self._db_audit:
324
+ return {}
325
+
326
+ id_from = id_from or int(self._db_audit.get("_min_id"))
327
+ id_to = id_to or int(self._db_audit.get("_max_id"))
328
+
329
+ ret = {}
330
+ for id in range(id_from, id_to):
331
+ id_str = str(id)
332
+ entry = self._db_audit.get(id_str)
333
+ if entry:
334
+ ret[id_str] = json.loads(entry)
335
+ return ret