ic-python-db 0.7.1__tar.gz
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-0.7.1/LICENSE +21 -0
- ic_python_db-0.7.1/MANIFEST.in +6 -0
- ic_python_db-0.7.1/PKG-INFO +356 -0
- ic_python_db-0.7.1/README.md +307 -0
- ic_python_db-0.7.1/ic_python_db/__init__.py +41 -0
- ic_python_db-0.7.1/ic_python_db/_cdk.py +14 -0
- ic_python_db-0.7.1/ic_python_db/constants.py +6 -0
- ic_python_db-0.7.1/ic_python_db/context.py +37 -0
- ic_python_db-0.7.1/ic_python_db/db_engine.py +335 -0
- ic_python_db-0.7.1/ic_python_db/entity.py +941 -0
- ic_python_db-0.7.1/ic_python_db/hooks.py +51 -0
- ic_python_db-0.7.1/ic_python_db/mixins.py +65 -0
- ic_python_db-0.7.1/ic_python_db/properties.py +639 -0
- ic_python_db-0.7.1/ic_python_db/py.typed +0 -0
- ic_python_db-0.7.1/ic_python_db/storage.py +69 -0
- ic_python_db-0.7.1/ic_python_db/system_time.py +88 -0
- ic_python_db-0.7.1/ic_python_db.egg-info/PKG-INFO +356 -0
- ic_python_db-0.7.1/ic_python_db.egg-info/SOURCES.txt +23 -0
- ic_python_db-0.7.1/ic_python_db.egg-info/dependency_links.txt +1 -0
- ic_python_db-0.7.1/ic_python_db.egg-info/top_level.txt +1 -0
- ic_python_db-0.7.1/pyproject.toml +34 -0
- ic_python_db-0.7.1/requirements-dev.txt +8 -0
- ic_python_db-0.7.1/setup.cfg +37 -0
- ic_python_db-0.7.1/setup.py +21 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Smart Social Contracts
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ic_python_db
|
|
3
|
+
Version: 0.7.1
|
|
4
|
+
Summary: A lightweight key-value database written in Python, intended for use on the Internet Computer (IC)
|
|
5
|
+
Home-page: https://github.com/smart-social-contracts/ic-python-db
|
|
6
|
+
Author: Smart Social Contracts
|
|
7
|
+
Author-email: Smart Social Contracts <contact@smartsocialcontracts.org>
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2025 Smart Social Contracts
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
|
|
30
|
+
Project-URL: Homepage, https://github.com/smart-social-contracts/ic-python-db
|
|
31
|
+
Project-URL: Repository, https://github.com/smart-social-contracts/ic-python-db.git
|
|
32
|
+
Project-URL: Issues, https://github.com/smart-social-contracts/ic-python-db/issues
|
|
33
|
+
Keywords: database,key-value,entity,relationship,audit
|
|
34
|
+
Classifier: Development Status :: 3 - Alpha
|
|
35
|
+
Classifier: Intended Audience :: Developers
|
|
36
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
37
|
+
Classifier: Operating System :: OS Independent
|
|
38
|
+
Classifier: Programming Language :: Python :: 3
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
40
|
+
Classifier: Topic :: Database
|
|
41
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
42
|
+
Requires-Python: >=3.7
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
License-File: LICENSE
|
|
45
|
+
Dynamic: author
|
|
46
|
+
Dynamic: home-page
|
|
47
|
+
Dynamic: license-file
|
|
48
|
+
Dynamic: requires-python
|
|
49
|
+
|
|
50
|
+
# IC Python DB
|
|
51
|
+
|
|
52
|
+
A lightweight key-value database with entity relationships and audit logging capabilities, intended for small to medium-sized applications running on the Internet Computer. Forked from [kybra-simple-db](https://github.com/smart-social-contracts/kybra-simple-db).
|
|
53
|
+
|
|
54
|
+
[](https://github.com/smart-social-contracts/ic-python-db/actions)
|
|
55
|
+
[](https://github.com/smart-social-contracts/ic-python-db/actions)
|
|
56
|
+
[](https://badge.fury.io/py/ic-python-db)
|
|
57
|
+
[](https://www.python.org/downloads/release/python-3107/)
|
|
58
|
+
[](https://github.com/smart-social-contracts/ic-python-db/blob/main/LICENSE)
|
|
59
|
+
|
|
60
|
+
## Features
|
|
61
|
+
|
|
62
|
+
- **Persistent Storage**: Works with StableBTreeMap stable structure for persistent storage on your canister's stable memory so your data persists automatically across canister upgrades.
|
|
63
|
+
- **Entity-Relational Database**: Create, read and write entities with OneToOne, OneToMany, ManyToOne, and ManyToMany relationships.
|
|
64
|
+
- **Entity Hooks**: Intercept and control entity lifecycle events (create, modify, delete) with `on_event` hooks.
|
|
65
|
+
- **Access Control**: Thread-safe context management for user identity tracking and ownership-based permissions.
|
|
66
|
+
- **Namespaces**: Organize entities into namespaces to avoid type conflicts when you have multiple entities with the same class name.
|
|
67
|
+
- **Audit Logging**: Track all changes to your data with created/updated timestamps and who created and updated each entity.
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
## Installation
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pip install ic-python-db
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Quick Start
|
|
77
|
+
|
|
78
|
+
The database storage must be initialized before using IC Python DB. Here's an example of how to do it:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from basilisk import StableBTreeMap
|
|
82
|
+
from ic_python_db import Database
|
|
83
|
+
|
|
84
|
+
# Initialize storage and database
|
|
85
|
+
storage = StableBTreeMap[str, str](memory_id=1, max_key_size=100, max_value_size=1000) # Use a unique memory ID for each storage instance
|
|
86
|
+
Database.init(db_storage=storage)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Read [Basilisk's documentation](https://github.com/smart-social-contracts/basilisk) for more information regarding StableBTreeMap and memory IDs.
|
|
90
|
+
|
|
91
|
+
Next, define your entities:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from ic_python_db import (
|
|
95
|
+
Database, Entity, String, Integer,
|
|
96
|
+
OneToOne, OneToMany, ManyToOne, ManyToMany, TimestampedMixin
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
class Person(Entity, TimestampedMixin):
|
|
100
|
+
__alias__ = "name" # Use `name` as the alias field for lookup
|
|
101
|
+
name = String(min_length=2, max_length=50)
|
|
102
|
+
age = Integer(min_value=0, max_value=120)
|
|
103
|
+
friends = ManyToMany("Person", "friends")
|
|
104
|
+
mother = ManyToOne("Person", "children")
|
|
105
|
+
children = OneToMany("Person", "mother")
|
|
106
|
+
spouse = OneToOne("Person", "spouse")
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Entity Lookup
|
|
110
|
+
|
|
111
|
+
Entities can be retrieved using `Entity[key]` syntax with three different lookup modes:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
# Create an entity
|
|
115
|
+
john = Person(name="John", age=30)
|
|
116
|
+
|
|
117
|
+
# Lookup by ID
|
|
118
|
+
Person[1] # Returns john (by _id)
|
|
119
|
+
Person["1"] # Also works with string ID
|
|
120
|
+
|
|
121
|
+
# Lookup by alias (defined via __alias__)
|
|
122
|
+
Person["John"] # Tries ID first, then alias field "name"
|
|
123
|
+
|
|
124
|
+
# Lookup by specific field (tuple syntax)
|
|
125
|
+
Person["name", "John"] # Lookup by field "name" only
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
| Syntax | Behavior |
|
|
129
|
+
|--------|----------|
|
|
130
|
+
| `Person[1]` | Lookup by `_id` |
|
|
131
|
+
| `Person["John"]` | Try `_id` first, then `__alias__` field |
|
|
132
|
+
| `Person["name", "John"]` | Lookup by specific field `name` only |
|
|
133
|
+
|
|
134
|
+
Then use the defined entities to store objects:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
# Create and save an object
|
|
138
|
+
john = Person(name="John", age=30)
|
|
139
|
+
|
|
140
|
+
# Update an object's property
|
|
141
|
+
john.age = 33 # Type checking and validation happens automatically
|
|
142
|
+
|
|
143
|
+
# use the `_id` property to load an entity with the [] operator
|
|
144
|
+
Person(name="Peter")
|
|
145
|
+
peter = Person["Peter"]
|
|
146
|
+
|
|
147
|
+
# Delete an object
|
|
148
|
+
peter.delete()
|
|
149
|
+
|
|
150
|
+
# Create relationships
|
|
151
|
+
alice = Person(name="Alice")
|
|
152
|
+
eva = Person(name="Eva")
|
|
153
|
+
john.mother = alice
|
|
154
|
+
assert john in alice.children
|
|
155
|
+
eva.friends = [alice]
|
|
156
|
+
assert alice in eva.friends
|
|
157
|
+
assert eva in alice.friends
|
|
158
|
+
|
|
159
|
+
print(alice.serialize()) # Prints the dictionary representation of an object
|
|
160
|
+
# Prints: {'timestamp_created': '2025-09-12 22:15:35.882', 'timestamp_updated': '2025-09-12 22:15:35.883', 'creator': 'system', 'updater': 'system', 'owner': 'system', '_type': 'Person', '_id': '3', 'name': 'Alice', 'age': None, 'children': '1', 'friends': '4'}
|
|
161
|
+
|
|
162
|
+
assert Person.count() == 3
|
|
163
|
+
assert Person.max_id() == 4
|
|
164
|
+
assert Person.instances() == [john, alice, eva]
|
|
165
|
+
|
|
166
|
+
# Cursor-based pagination
|
|
167
|
+
assert Person.load_some(0, 2) == [john, alice]
|
|
168
|
+
assert Person.load_some(2, 2) == [eva]
|
|
169
|
+
|
|
170
|
+
# Retrieve database contents in JSON format
|
|
171
|
+
print(Database.get_instance().dump_json(pretty=True))
|
|
172
|
+
|
|
173
|
+
# Audit log
|
|
174
|
+
audit_records = Database.get_instance().get_audit(id_from=0, id_to=5)
|
|
175
|
+
pprint(audit_records['0'])
|
|
176
|
+
''' Prints:
|
|
177
|
+
|
|
178
|
+
['save',
|
|
179
|
+
1744138342934,
|
|
180
|
+
'Person@1',
|
|
181
|
+
{'_id': '1',
|
|
182
|
+
'_type': 'Person',
|
|
183
|
+
'age': 30,
|
|
184
|
+
'creator': 'system',
|
|
185
|
+
'name': 'John',
|
|
186
|
+
'owner': 'system',
|
|
187
|
+
'timestamp_created': '2025-04-08 20:52:22.934',
|
|
188
|
+
'timestamp_updated': '2025-04-08 20:52:22.934',
|
|
189
|
+
'updater': 'system'}]
|
|
190
|
+
|
|
191
|
+
'''
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
For more usage examples, see the [tests](tests/src/tests).
|
|
195
|
+
|
|
196
|
+
## Namespaces
|
|
197
|
+
|
|
198
|
+
Organize entities with the `__namespace__` attribute to avoid type conflicts when you have the same class name in different modules:
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
# In app/models.py
|
|
202
|
+
class User(Entity):
|
|
203
|
+
__namespace__ = "app"
|
|
204
|
+
name = String()
|
|
205
|
+
role = String()
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
# In admin/models.py
|
|
210
|
+
class User(Entity):
|
|
211
|
+
__namespace__ = "admin"
|
|
212
|
+
name = String()
|
|
213
|
+
permissions = String()
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
from app.models import User as AppUser
|
|
218
|
+
from admin.models import User as AdminUser
|
|
219
|
+
|
|
220
|
+
app_user = AppUser(name="Alice", role="developer") # Stored as "app::User"
|
|
221
|
+
admin_user = AdminUser(name="Bob", permissions="all") # Stored as "admin::User"
|
|
222
|
+
|
|
223
|
+
# Each namespace has isolated ID sequences and storage
|
|
224
|
+
assert app_user._id == "1"
|
|
225
|
+
assert admin_user._id == "1"
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Entity Hooks
|
|
229
|
+
|
|
230
|
+
Intercept and control entity changes with the `on_event` hook:
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
from ic_python_db import Entity, String, ACTION_MODIFY
|
|
234
|
+
|
|
235
|
+
class User(Entity):
|
|
236
|
+
name = String()
|
|
237
|
+
email = String()
|
|
238
|
+
|
|
239
|
+
@staticmethod
|
|
240
|
+
def on_event(entity, field_name, old_value, new_value, action):
|
|
241
|
+
# Validate email format
|
|
242
|
+
if field_name == "email" and "@" not in new_value:
|
|
243
|
+
return False, None # Reject invalid email
|
|
244
|
+
|
|
245
|
+
# Auto-capitalize names
|
|
246
|
+
if field_name == "name":
|
|
247
|
+
return True, new_value.upper()
|
|
248
|
+
|
|
249
|
+
return True, new_value
|
|
250
|
+
|
|
251
|
+
user = User(name="alice", email="alice@example.com")
|
|
252
|
+
assert user.name == "ALICE" # Auto-capitalized
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
See [docs/HOOKS.md](docs/HOOKS.md) for more patterns.
|
|
256
|
+
|
|
257
|
+
## Access Control
|
|
258
|
+
|
|
259
|
+
Thread-safe user context management with `as_user()`:
|
|
260
|
+
|
|
261
|
+
```python
|
|
262
|
+
from ic_python_db import Database, Entity, String, ACTION_MODIFY, ACTION_DELETE
|
|
263
|
+
from ic_python_db.mixins import TimestampedMixin
|
|
264
|
+
from ic_python_db.context import get_caller_id
|
|
265
|
+
|
|
266
|
+
class Document(Entity, TimestampedMixin):
|
|
267
|
+
title = String()
|
|
268
|
+
|
|
269
|
+
@staticmethod
|
|
270
|
+
def on_event(entity, field_name, old_value, new_value, action):
|
|
271
|
+
caller = get_caller_id()
|
|
272
|
+
|
|
273
|
+
# Only owner can modify or delete
|
|
274
|
+
if action in (ACTION_MODIFY, ACTION_DELETE):
|
|
275
|
+
if entity._owner != caller:
|
|
276
|
+
return False, None
|
|
277
|
+
|
|
278
|
+
return True, new_value
|
|
279
|
+
|
|
280
|
+
db = Database.get_instance()
|
|
281
|
+
|
|
282
|
+
# Alice creates a document
|
|
283
|
+
with db.as_user("alice"):
|
|
284
|
+
doc = Document(title="My Doc") # Owner: alice
|
|
285
|
+
|
|
286
|
+
# Bob cannot modify Alice's document
|
|
287
|
+
with db.as_user("bob"):
|
|
288
|
+
doc.title = "Hacked" # Raises ValueError
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
See [docs/ACCESS_CONTROL.md](docs/ACCESS_CONTROL.md) and [examples/simple_access_control.py](examples/simple_access_control.py).
|
|
292
|
+
|
|
293
|
+
## Type Hints
|
|
294
|
+
|
|
295
|
+
The library is fully typed (PEP 561 compliant). Type checkers and IDEs automatically infer property types:
|
|
296
|
+
|
|
297
|
+
```python
|
|
298
|
+
class User(Entity):
|
|
299
|
+
name = String() # Inferred as str
|
|
300
|
+
age = Integer() # Inferred as int
|
|
301
|
+
active = Boolean() # Inferred as bool
|
|
302
|
+
|
|
303
|
+
user = User(name="Alice", age=30, active=True)
|
|
304
|
+
user.name # IDE knows this is str
|
|
305
|
+
user.age # IDE knows this is int
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
For stricter typing, you can add explicit annotations:
|
|
309
|
+
|
|
310
|
+
```python
|
|
311
|
+
from typing import Optional
|
|
312
|
+
|
|
313
|
+
class User(Entity):
|
|
314
|
+
name: str = String()
|
|
315
|
+
age: int = Integer()
|
|
316
|
+
profile: Optional["Profile"] = OneToOne("Profile", "user")
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
## API Reference
|
|
320
|
+
|
|
321
|
+
- **Core**: `Database`, `Entity`
|
|
322
|
+
- **Properties**: `String`, `Integer`, `Float`, `Boolean`
|
|
323
|
+
- **Relationships**: `OneToOne`, `OneToMany`, `ManyToOne`, `ManyToMany`
|
|
324
|
+
- **Mixins**: `TimestampedMixin` (timestamps and ownership tracking)
|
|
325
|
+
- **Hooks**: `ACTION_CREATE`, `ACTION_MODIFY`, `ACTION_DELETE`
|
|
326
|
+
- **Context**: `get_caller_id()`, `set_caller_id()`, `Database.as_user()`
|
|
327
|
+
|
|
328
|
+
## Development
|
|
329
|
+
|
|
330
|
+
### Setup Development Environment
|
|
331
|
+
|
|
332
|
+
```bash
|
|
333
|
+
# Clone the repository
|
|
334
|
+
git clone https://github.com/smart-social-contracts/ic-python-db.git
|
|
335
|
+
cd ic-python-db
|
|
336
|
+
|
|
337
|
+
# Recommended setup
|
|
338
|
+
pyenv install 3.10.7
|
|
339
|
+
pyenv local 3.10.7
|
|
340
|
+
python -m venv venv
|
|
341
|
+
source venv/bin/activate
|
|
342
|
+
|
|
343
|
+
# Install development dependencies
|
|
344
|
+
pip install -r requirements-dev.txt
|
|
345
|
+
|
|
346
|
+
# Running tests
|
|
347
|
+
./run_linters.sh && (cd tests && ./run_test.sh && ./run_test_ic.sh)
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
## Contributing
|
|
351
|
+
|
|
352
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
353
|
+
|
|
354
|
+
## License
|
|
355
|
+
|
|
356
|
+
[MIT](LICENSE).
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# IC Python DB
|
|
2
|
+
|
|
3
|
+
A lightweight key-value database with entity relationships and audit logging capabilities, intended for small to medium-sized applications running on the Internet Computer. Forked from [kybra-simple-db](https://github.com/smart-social-contracts/kybra-simple-db).
|
|
4
|
+
|
|
5
|
+
[](https://github.com/smart-social-contracts/ic-python-db/actions)
|
|
6
|
+
[](https://github.com/smart-social-contracts/ic-python-db/actions)
|
|
7
|
+
[](https://badge.fury.io/py/ic-python-db)
|
|
8
|
+
[](https://www.python.org/downloads/release/python-3107/)
|
|
9
|
+
[](https://github.com/smart-social-contracts/ic-python-db/blob/main/LICENSE)
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **Persistent Storage**: Works with StableBTreeMap stable structure for persistent storage on your canister's stable memory so your data persists automatically across canister upgrades.
|
|
14
|
+
- **Entity-Relational Database**: Create, read and write entities with OneToOne, OneToMany, ManyToOne, and ManyToMany relationships.
|
|
15
|
+
- **Entity Hooks**: Intercept and control entity lifecycle events (create, modify, delete) with `on_event` hooks.
|
|
16
|
+
- **Access Control**: Thread-safe context management for user identity tracking and ownership-based permissions.
|
|
17
|
+
- **Namespaces**: Organize entities into namespaces to avoid type conflicts when you have multiple entities with the same class name.
|
|
18
|
+
- **Audit Logging**: Track all changes to your data with created/updated timestamps and who created and updated each entity.
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install ic-python-db
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick Start
|
|
28
|
+
|
|
29
|
+
The database storage must be initialized before using IC Python DB. Here's an example of how to do it:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from basilisk import StableBTreeMap
|
|
33
|
+
from ic_python_db import Database
|
|
34
|
+
|
|
35
|
+
# Initialize storage and database
|
|
36
|
+
storage = StableBTreeMap[str, str](memory_id=1, max_key_size=100, max_value_size=1000) # Use a unique memory ID for each storage instance
|
|
37
|
+
Database.init(db_storage=storage)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Read [Basilisk's documentation](https://github.com/smart-social-contracts/basilisk) for more information regarding StableBTreeMap and memory IDs.
|
|
41
|
+
|
|
42
|
+
Next, define your entities:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from ic_python_db import (
|
|
46
|
+
Database, Entity, String, Integer,
|
|
47
|
+
OneToOne, OneToMany, ManyToOne, ManyToMany, TimestampedMixin
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
class Person(Entity, TimestampedMixin):
|
|
51
|
+
__alias__ = "name" # Use `name` as the alias field for lookup
|
|
52
|
+
name = String(min_length=2, max_length=50)
|
|
53
|
+
age = Integer(min_value=0, max_value=120)
|
|
54
|
+
friends = ManyToMany("Person", "friends")
|
|
55
|
+
mother = ManyToOne("Person", "children")
|
|
56
|
+
children = OneToMany("Person", "mother")
|
|
57
|
+
spouse = OneToOne("Person", "spouse")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Entity Lookup
|
|
61
|
+
|
|
62
|
+
Entities can be retrieved using `Entity[key]` syntax with three different lookup modes:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
# Create an entity
|
|
66
|
+
john = Person(name="John", age=30)
|
|
67
|
+
|
|
68
|
+
# Lookup by ID
|
|
69
|
+
Person[1] # Returns john (by _id)
|
|
70
|
+
Person["1"] # Also works with string ID
|
|
71
|
+
|
|
72
|
+
# Lookup by alias (defined via __alias__)
|
|
73
|
+
Person["John"] # Tries ID first, then alias field "name"
|
|
74
|
+
|
|
75
|
+
# Lookup by specific field (tuple syntax)
|
|
76
|
+
Person["name", "John"] # Lookup by field "name" only
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
| Syntax | Behavior |
|
|
80
|
+
|--------|----------|
|
|
81
|
+
| `Person[1]` | Lookup by `_id` |
|
|
82
|
+
| `Person["John"]` | Try `_id` first, then `__alias__` field |
|
|
83
|
+
| `Person["name", "John"]` | Lookup by specific field `name` only |
|
|
84
|
+
|
|
85
|
+
Then use the defined entities to store objects:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
# Create and save an object
|
|
89
|
+
john = Person(name="John", age=30)
|
|
90
|
+
|
|
91
|
+
# Update an object's property
|
|
92
|
+
john.age = 33 # Type checking and validation happens automatically
|
|
93
|
+
|
|
94
|
+
# use the `_id` property to load an entity with the [] operator
|
|
95
|
+
Person(name="Peter")
|
|
96
|
+
peter = Person["Peter"]
|
|
97
|
+
|
|
98
|
+
# Delete an object
|
|
99
|
+
peter.delete()
|
|
100
|
+
|
|
101
|
+
# Create relationships
|
|
102
|
+
alice = Person(name="Alice")
|
|
103
|
+
eva = Person(name="Eva")
|
|
104
|
+
john.mother = alice
|
|
105
|
+
assert john in alice.children
|
|
106
|
+
eva.friends = [alice]
|
|
107
|
+
assert alice in eva.friends
|
|
108
|
+
assert eva in alice.friends
|
|
109
|
+
|
|
110
|
+
print(alice.serialize()) # Prints the dictionary representation of an object
|
|
111
|
+
# Prints: {'timestamp_created': '2025-09-12 22:15:35.882', 'timestamp_updated': '2025-09-12 22:15:35.883', 'creator': 'system', 'updater': 'system', 'owner': 'system', '_type': 'Person', '_id': '3', 'name': 'Alice', 'age': None, 'children': '1', 'friends': '4'}
|
|
112
|
+
|
|
113
|
+
assert Person.count() == 3
|
|
114
|
+
assert Person.max_id() == 4
|
|
115
|
+
assert Person.instances() == [john, alice, eva]
|
|
116
|
+
|
|
117
|
+
# Cursor-based pagination
|
|
118
|
+
assert Person.load_some(0, 2) == [john, alice]
|
|
119
|
+
assert Person.load_some(2, 2) == [eva]
|
|
120
|
+
|
|
121
|
+
# Retrieve database contents in JSON format
|
|
122
|
+
print(Database.get_instance().dump_json(pretty=True))
|
|
123
|
+
|
|
124
|
+
# Audit log
|
|
125
|
+
audit_records = Database.get_instance().get_audit(id_from=0, id_to=5)
|
|
126
|
+
pprint(audit_records['0'])
|
|
127
|
+
''' Prints:
|
|
128
|
+
|
|
129
|
+
['save',
|
|
130
|
+
1744138342934,
|
|
131
|
+
'Person@1',
|
|
132
|
+
{'_id': '1',
|
|
133
|
+
'_type': 'Person',
|
|
134
|
+
'age': 30,
|
|
135
|
+
'creator': 'system',
|
|
136
|
+
'name': 'John',
|
|
137
|
+
'owner': 'system',
|
|
138
|
+
'timestamp_created': '2025-04-08 20:52:22.934',
|
|
139
|
+
'timestamp_updated': '2025-04-08 20:52:22.934',
|
|
140
|
+
'updater': 'system'}]
|
|
141
|
+
|
|
142
|
+
'''
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
For more usage examples, see the [tests](tests/src/tests).
|
|
146
|
+
|
|
147
|
+
## Namespaces
|
|
148
|
+
|
|
149
|
+
Organize entities with the `__namespace__` attribute to avoid type conflicts when you have the same class name in different modules:
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
# In app/models.py
|
|
153
|
+
class User(Entity):
|
|
154
|
+
__namespace__ = "app"
|
|
155
|
+
name = String()
|
|
156
|
+
role = String()
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
# In admin/models.py
|
|
161
|
+
class User(Entity):
|
|
162
|
+
__namespace__ = "admin"
|
|
163
|
+
name = String()
|
|
164
|
+
permissions = String()
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
from app.models import User as AppUser
|
|
169
|
+
from admin.models import User as AdminUser
|
|
170
|
+
|
|
171
|
+
app_user = AppUser(name="Alice", role="developer") # Stored as "app::User"
|
|
172
|
+
admin_user = AdminUser(name="Bob", permissions="all") # Stored as "admin::User"
|
|
173
|
+
|
|
174
|
+
# Each namespace has isolated ID sequences and storage
|
|
175
|
+
assert app_user._id == "1"
|
|
176
|
+
assert admin_user._id == "1"
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Entity Hooks
|
|
180
|
+
|
|
181
|
+
Intercept and control entity changes with the `on_event` hook:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
from ic_python_db import Entity, String, ACTION_MODIFY
|
|
185
|
+
|
|
186
|
+
class User(Entity):
|
|
187
|
+
name = String()
|
|
188
|
+
email = String()
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def on_event(entity, field_name, old_value, new_value, action):
|
|
192
|
+
# Validate email format
|
|
193
|
+
if field_name == "email" and "@" not in new_value:
|
|
194
|
+
return False, None # Reject invalid email
|
|
195
|
+
|
|
196
|
+
# Auto-capitalize names
|
|
197
|
+
if field_name == "name":
|
|
198
|
+
return True, new_value.upper()
|
|
199
|
+
|
|
200
|
+
return True, new_value
|
|
201
|
+
|
|
202
|
+
user = User(name="alice", email="alice@example.com")
|
|
203
|
+
assert user.name == "ALICE" # Auto-capitalized
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
See [docs/HOOKS.md](docs/HOOKS.md) for more patterns.
|
|
207
|
+
|
|
208
|
+
## Access Control
|
|
209
|
+
|
|
210
|
+
Thread-safe user context management with `as_user()`:
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
from ic_python_db import Database, Entity, String, ACTION_MODIFY, ACTION_DELETE
|
|
214
|
+
from ic_python_db.mixins import TimestampedMixin
|
|
215
|
+
from ic_python_db.context import get_caller_id
|
|
216
|
+
|
|
217
|
+
class Document(Entity, TimestampedMixin):
|
|
218
|
+
title = String()
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def on_event(entity, field_name, old_value, new_value, action):
|
|
222
|
+
caller = get_caller_id()
|
|
223
|
+
|
|
224
|
+
# Only owner can modify or delete
|
|
225
|
+
if action in (ACTION_MODIFY, ACTION_DELETE):
|
|
226
|
+
if entity._owner != caller:
|
|
227
|
+
return False, None
|
|
228
|
+
|
|
229
|
+
return True, new_value
|
|
230
|
+
|
|
231
|
+
db = Database.get_instance()
|
|
232
|
+
|
|
233
|
+
# Alice creates a document
|
|
234
|
+
with db.as_user("alice"):
|
|
235
|
+
doc = Document(title="My Doc") # Owner: alice
|
|
236
|
+
|
|
237
|
+
# Bob cannot modify Alice's document
|
|
238
|
+
with db.as_user("bob"):
|
|
239
|
+
doc.title = "Hacked" # Raises ValueError
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
See [docs/ACCESS_CONTROL.md](docs/ACCESS_CONTROL.md) and [examples/simple_access_control.py](examples/simple_access_control.py).
|
|
243
|
+
|
|
244
|
+
## Type Hints
|
|
245
|
+
|
|
246
|
+
The library is fully typed (PEP 561 compliant). Type checkers and IDEs automatically infer property types:
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
class User(Entity):
|
|
250
|
+
name = String() # Inferred as str
|
|
251
|
+
age = Integer() # Inferred as int
|
|
252
|
+
active = Boolean() # Inferred as bool
|
|
253
|
+
|
|
254
|
+
user = User(name="Alice", age=30, active=True)
|
|
255
|
+
user.name # IDE knows this is str
|
|
256
|
+
user.age # IDE knows this is int
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
For stricter typing, you can add explicit annotations:
|
|
260
|
+
|
|
261
|
+
```python
|
|
262
|
+
from typing import Optional
|
|
263
|
+
|
|
264
|
+
class User(Entity):
|
|
265
|
+
name: str = String()
|
|
266
|
+
age: int = Integer()
|
|
267
|
+
profile: Optional["Profile"] = OneToOne("Profile", "user")
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## API Reference
|
|
271
|
+
|
|
272
|
+
- **Core**: `Database`, `Entity`
|
|
273
|
+
- **Properties**: `String`, `Integer`, `Float`, `Boolean`
|
|
274
|
+
- **Relationships**: `OneToOne`, `OneToMany`, `ManyToOne`, `ManyToMany`
|
|
275
|
+
- **Mixins**: `TimestampedMixin` (timestamps and ownership tracking)
|
|
276
|
+
- **Hooks**: `ACTION_CREATE`, `ACTION_MODIFY`, `ACTION_DELETE`
|
|
277
|
+
- **Context**: `get_caller_id()`, `set_caller_id()`, `Database.as_user()`
|
|
278
|
+
|
|
279
|
+
## Development
|
|
280
|
+
|
|
281
|
+
### Setup Development Environment
|
|
282
|
+
|
|
283
|
+
```bash
|
|
284
|
+
# Clone the repository
|
|
285
|
+
git clone https://github.com/smart-social-contracts/ic-python-db.git
|
|
286
|
+
cd ic-python-db
|
|
287
|
+
|
|
288
|
+
# Recommended setup
|
|
289
|
+
pyenv install 3.10.7
|
|
290
|
+
pyenv local 3.10.7
|
|
291
|
+
python -m venv venv
|
|
292
|
+
source venv/bin/activate
|
|
293
|
+
|
|
294
|
+
# Install development dependencies
|
|
295
|
+
pip install -r requirements-dev.txt
|
|
296
|
+
|
|
297
|
+
# Running tests
|
|
298
|
+
./run_linters.sh && (cd tests && ./run_test.sh && ./run_test_ic.sh)
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
## Contributing
|
|
302
|
+
|
|
303
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
304
|
+
|
|
305
|
+
## License
|
|
306
|
+
|
|
307
|
+
[MIT](LICENSE).
|