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.
- ic_python_db/__init__.py +41 -0
- ic_python_db/_cdk.py +14 -0
- ic_python_db/constants.py +6 -0
- ic_python_db/context.py +37 -0
- ic_python_db/db_engine.py +335 -0
- ic_python_db/entity.py +941 -0
- ic_python_db/hooks.py +51 -0
- ic_python_db/mixins.py +65 -0
- ic_python_db/properties.py +639 -0
- ic_python_db/py.typed +0 -0
- ic_python_db/storage.py +69 -0
- ic_python_db/system_time.py +88 -0
- ic_python_db-0.7.1.dist-info/METADATA +356 -0
- ic_python_db-0.7.1.dist-info/RECORD +17 -0
- ic_python_db-0.7.1.dist-info/WHEEL +5 -0
- ic_python_db-0.7.1.dist-info/licenses/LICENSE +21 -0
- ic_python_db-0.7.1.dist-info/top_level.txt +1 -0
ic_python_db/__init__.py
ADDED
|
@@ -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
|
ic_python_db/context.py
ADDED
|
@@ -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
|