ic-python-db 0.10.0__py3-none-any.whl → 0.12.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.
- ic_python_db/__init__.py +1 -1
- ic_python_db/db_engine.py +178 -1
- ic_python_db/entity.py +191 -26
- ic_python_db/properties.py +35 -5
- ic_python_db/storage.py +51 -2
- {ic_python_db-0.10.0.dist-info → ic_python_db-0.12.0.dist-info}/METADATA +38 -1
- ic_python_db-0.12.0.dist-info/RECORD +18 -0
- {ic_python_db-0.10.0.dist-info → ic_python_db-0.12.0.dist-info}/WHEEL +1 -1
- ic_python_db-0.10.0.dist-info/RECORD +0 -18
- {ic_python_db-0.10.0.dist-info → ic_python_db-0.12.0.dist-info}/licenses/LICENSE +0 -0
- {ic_python_db-0.10.0.dist-info → ic_python_db-0.12.0.dist-info}/top_level.txt +0 -0
ic_python_db/__init__.py
CHANGED
|
@@ -20,7 +20,7 @@ from .schema import SchemaIncompatibleError, build_schema, diff_schemas, schema_
|
|
|
20
20
|
from .storage import MemoryStorage, Storage
|
|
21
21
|
from .system_time import SystemTime
|
|
22
22
|
|
|
23
|
-
__version__ = "0.
|
|
23
|
+
__version__ = "0.12.0"
|
|
24
24
|
__all__ = [
|
|
25
25
|
"Database",
|
|
26
26
|
"Entity",
|
ic_python_db/db_engine.py
CHANGED
|
@@ -9,10 +9,41 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
9
9
|
|
|
10
10
|
from ic_python_logging import get_logger
|
|
11
11
|
|
|
12
|
-
from .storage import MemoryStorage, Storage
|
|
12
|
+
from .storage import MemoryStorage, Storage, supports_range
|
|
13
13
|
|
|
14
14
|
logger = get_logger(__name__)
|
|
15
15
|
|
|
16
|
+
_DIGITS = frozenset("0123456789")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _is_sequential_id(id_str: str) -> bool:
|
|
20
|
+
"""True for canonical sequential ids: ASCII digits, no leading zero."""
|
|
21
|
+
return bool(id_str) and id_str[0] != "0" and all(c in _DIGITS for c in id_str)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _next_sequential_id(id_str: str, digits: int) -> Optional[int]:
|
|
25
|
+
"""Smallest ``digits``-digit sequential id that sorts after ``id_str``.
|
|
26
|
+
|
|
27
|
+
``id_str`` is a key suffix returned by a range over the ``digits``-length
|
|
28
|
+
bucket, so it starts with a digit but may be a custom id such as
|
|
29
|
+
``"1x"`` or ``"1-a"``. Returns None when no such id exists in the bucket.
|
|
30
|
+
"""
|
|
31
|
+
i = 0
|
|
32
|
+
while i < len(id_str) and id_str[i] in _DIGITS:
|
|
33
|
+
i += 1
|
|
34
|
+
numeric_prefix, rest = id_str[:i], id_str[i:]
|
|
35
|
+
if not rest:
|
|
36
|
+
candidate = str(int(numeric_prefix) + 1)
|
|
37
|
+
elif rest[0] > "9":
|
|
38
|
+
# "1x" > every "1<digit>": jump to the next prefix, zero-filled
|
|
39
|
+
candidate = str(int(numeric_prefix) + 1) + "0" * len(rest)
|
|
40
|
+
else:
|
|
41
|
+
# "1-a" < "10": the zero-filled prefix is the next candidate
|
|
42
|
+
candidate = numeric_prefix + "0" * len(rest)
|
|
43
|
+
if len(candidate) > digits:
|
|
44
|
+
return None
|
|
45
|
+
return max(int(candidate), 10 ** (digits - 1))
|
|
46
|
+
|
|
16
47
|
|
|
17
48
|
class Database:
|
|
18
49
|
"""Main database class providing high-level operations"""
|
|
@@ -181,6 +212,102 @@ class Database:
|
|
|
181
212
|
return json.loads(data)
|
|
182
213
|
return None
|
|
183
214
|
|
|
215
|
+
def max_id(self, type_name: str) -> int:
|
|
216
|
+
"""Highest sequential id ever issued for ``type_name`` (0 if none)."""
|
|
217
|
+
raw = self.load("_system", f"{type_name}_id")
|
|
218
|
+
return int(raw) if raw else 0
|
|
219
|
+
|
|
220
|
+
def load_page(
|
|
221
|
+
self,
|
|
222
|
+
type_name: str,
|
|
223
|
+
from_id: int,
|
|
224
|
+
count: int,
|
|
225
|
+
max_id: Optional[int] = None,
|
|
226
|
+
) -> List[Tuple[str, dict]]:
|
|
227
|
+
"""Persisted entities of ``type_name`` with sequential id >= ``from_id``.
|
|
228
|
+
|
|
229
|
+
Returns up to ``count`` ``(id, data)`` pairs in ascending numeric id
|
|
230
|
+
order, skipping ids that were deleted. Only canonical sequential ids
|
|
231
|
+
(``"1"``, ``"42"``; no leading zeros, no custom string ids) are
|
|
232
|
+
returned, matching what probing ``1..max_id`` would find.
|
|
233
|
+
|
|
234
|
+
When the storage backend supports ``range`` (Basilisk's
|
|
235
|
+
``StableBTreeMap``, ``MemoryStorage``) a page costs one range call per
|
|
236
|
+
digit-count bucket touched (usually one), independent of how many ids
|
|
237
|
+
were deleted. Otherwise it falls back to one ``get`` per id.
|
|
238
|
+
"""
|
|
239
|
+
if from_id < 1:
|
|
240
|
+
raise ValueError("from_id must be at least 1")
|
|
241
|
+
if count < 1:
|
|
242
|
+
raise ValueError("count must be at least 1")
|
|
243
|
+
if max_id is None:
|
|
244
|
+
max_id = self.max_id(type_name)
|
|
245
|
+
if max_id < from_id:
|
|
246
|
+
return []
|
|
247
|
+
if supports_range(self._db_storage):
|
|
248
|
+
return self._load_page_range(type_name, from_id, count, max_id)
|
|
249
|
+
return self._load_page_probe(type_name, from_id, count, max_id)
|
|
250
|
+
|
|
251
|
+
def _load_page_probe(
|
|
252
|
+
self, type_name: str, from_id: int, count: int, max_id: int
|
|
253
|
+
) -> List[Tuple[str, dict]]:
|
|
254
|
+
results: List[Tuple[str, dict]] = []
|
|
255
|
+
entity_id = from_id
|
|
256
|
+
while len(results) < count and entity_id <= max_id:
|
|
257
|
+
raw = self._db_storage.get(f"{type_name}@{entity_id}")
|
|
258
|
+
if raw:
|
|
259
|
+
results.append((str(entity_id), json.loads(raw)))
|
|
260
|
+
entity_id += 1
|
|
261
|
+
return results
|
|
262
|
+
|
|
263
|
+
def _load_page_range(
|
|
264
|
+
self, type_name: str, from_id: int, count: int, max_id: int
|
|
265
|
+
) -> List[Tuple[str, dict]]:
|
|
266
|
+
# Keys are "Type@<id>" with unpadded decimal ids. The backend orders
|
|
267
|
+
# str keys by byte length first, then bytewise (see storage_sort_key),
|
|
268
|
+
# so all ids with the same digit count form one contiguous, numerically
|
|
269
|
+
# ordered run. Walk one run ("bucket") per digit count, from the digit
|
|
270
|
+
# count of from_id up to that of max_id.
|
|
271
|
+
prefix = f"{type_name}@"
|
|
272
|
+
results: List[Tuple[str, dict]] = []
|
|
273
|
+
digits = len(str(from_id))
|
|
274
|
+
max_digits = len(str(max_id))
|
|
275
|
+
while digits <= max_digits and len(results) < count:
|
|
276
|
+
lo_id = max(from_id, 10 ** (digits - 1))
|
|
277
|
+
if digits == max_digits:
|
|
278
|
+
# Exclusive upper bound right after max_id (same digit count),
|
|
279
|
+
# or the whole bucket if max_id is the largest d-digit number.
|
|
280
|
+
hi_id = max_id + 1
|
|
281
|
+
end = (
|
|
282
|
+
f"{prefix}{hi_id}"
|
|
283
|
+
if len(str(hi_id)) == digits
|
|
284
|
+
else f"{prefix}{':' * digits}"
|
|
285
|
+
)
|
|
286
|
+
else:
|
|
287
|
+
# ':' is the character after '9': every same-length key of
|
|
288
|
+
# the form "Type@<digits>" sorts before "Type@:::".
|
|
289
|
+
end = f"{prefix}{':' * digits}"
|
|
290
|
+
|
|
291
|
+
start = f"{prefix}{lo_id}"
|
|
292
|
+
plen = len(prefix)
|
|
293
|
+
while len(results) < count:
|
|
294
|
+
need = count - len(results)
|
|
295
|
+
page = self._db_storage.range(start, end, need)
|
|
296
|
+
for key, raw in page:
|
|
297
|
+
id_str = key[plen:]
|
|
298
|
+
if _is_sequential_id(id_str):
|
|
299
|
+
results.append((id_str, json.loads(raw)))
|
|
300
|
+
if len(page) < need:
|
|
301
|
+
break # bucket exhausted
|
|
302
|
+
# Page was full but may have contained skipped custom ids:
|
|
303
|
+
# continue from the next possible sequential id in this bucket.
|
|
304
|
+
next_id = _next_sequential_id(page[-1][0][plen:], digits)
|
|
305
|
+
if next_id is None:
|
|
306
|
+
break
|
|
307
|
+
start = f"{prefix}{next_id}"
|
|
308
|
+
digits += 1
|
|
309
|
+
return results
|
|
310
|
+
|
|
184
311
|
def delete(self, type_name: str, entity_id: str) -> None:
|
|
185
312
|
"""Delete the data associated with the key
|
|
186
313
|
|
|
@@ -426,6 +553,56 @@ class Database:
|
|
|
426
553
|
if self._db_storage.get(key) is not None:
|
|
427
554
|
self._db_storage.remove(key)
|
|
428
555
|
|
|
556
|
+
# ── Field Index API (issue #11) ────────────────────────────────────────────
|
|
557
|
+
#
|
|
558
|
+
# Secondary indexes for scalar properties declared with indexed=True.
|
|
559
|
+
# Allow equality lookups (Entity.find_by) without scanning all entities.
|
|
560
|
+
#
|
|
561
|
+
# Key format: _fi:{Type}:{field}:{value}
|
|
562
|
+
# Value: JSON array of entity IDs, e.g. ["1", "2", "3"]
|
|
563
|
+
|
|
564
|
+
def _fi_key(self, type_name: str, field_name: str, value: str) -> str:
|
|
565
|
+
return f"_fi:{type_name}:{field_name}:{value}"
|
|
566
|
+
|
|
567
|
+
def field_index_get(self, type_name: str, field_name: str, value: str) -> List[str]:
|
|
568
|
+
"""Read the ID list for entities whose ``field_name`` equals ``value``.
|
|
569
|
+
|
|
570
|
+
Returns:
|
|
571
|
+
List of entity IDs, or empty list if no index entry exists.
|
|
572
|
+
"""
|
|
573
|
+
key = self._fi_key(type_name, field_name, value)
|
|
574
|
+
raw = self._db_storage.get(key)
|
|
575
|
+
if raw:
|
|
576
|
+
return json.loads(raw)
|
|
577
|
+
return []
|
|
578
|
+
|
|
579
|
+
def field_index_add(
|
|
580
|
+
self, type_name: str, field_name: str, value: str, entity_id: str
|
|
581
|
+
) -> None:
|
|
582
|
+
"""Add an entity ID to the index entry for ``value``."""
|
|
583
|
+
key = self._fi_key(type_name, field_name, value)
|
|
584
|
+
raw = self._db_storage.get(key)
|
|
585
|
+
ids = json.loads(raw) if raw else []
|
|
586
|
+
if entity_id not in ids:
|
|
587
|
+
ids.append(entity_id)
|
|
588
|
+
self._db_storage.insert(key, json.dumps(ids))
|
|
589
|
+
|
|
590
|
+
def field_index_remove(
|
|
591
|
+
self, type_name: str, field_name: str, value: str, entity_id: str
|
|
592
|
+
) -> None:
|
|
593
|
+
"""Remove an entity ID from the index entry for ``value``."""
|
|
594
|
+
key = self._fi_key(type_name, field_name, value)
|
|
595
|
+
raw = self._db_storage.get(key)
|
|
596
|
+
if not raw:
|
|
597
|
+
return
|
|
598
|
+
ids = json.loads(raw)
|
|
599
|
+
if entity_id in ids:
|
|
600
|
+
ids.remove(entity_id)
|
|
601
|
+
if ids:
|
|
602
|
+
self._db_storage.insert(key, json.dumps(ids))
|
|
603
|
+
else:
|
|
604
|
+
self._db_storage.remove(key)
|
|
605
|
+
|
|
429
606
|
# Reverse counters (unidirectional relations)
|
|
430
607
|
# Key format: "_rc:{parent_type}:{parent_id}:{relation_name}"
|
|
431
608
|
# Value: integer count of related entities.
|
ic_python_db/entity.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Enhanced entity implementation with support for mixins and entity types."""
|
|
2
2
|
|
|
3
|
-
from typing import Any, Dict, List, Optional, Set, Type, TypeVar
|
|
3
|
+
from typing import Any, Dict, List, Optional, Set, Tuple, Type, TypeVar
|
|
4
4
|
|
|
5
5
|
from ic_python_logging import get_logger
|
|
6
6
|
|
|
@@ -176,8 +176,11 @@ class Entity:
|
|
|
176
176
|
next_id = str(int(current_id) + 1)
|
|
177
177
|
self._id = next_id
|
|
178
178
|
db.save("_system", f"{type_name}_id", self._id)
|
|
179
|
-
|
|
180
|
-
# Update max_id if custom ID is higher than current max
|
|
179
|
+
elif not self._loaded:
|
|
180
|
+
# Update max_id if custom ID is higher than current max.
|
|
181
|
+
# Skipped when hydrating persisted state: that id already went
|
|
182
|
+
# through this check when it was first created, so re-reading the
|
|
183
|
+
# counter would only add one storage read per loaded entity.
|
|
181
184
|
db = self.db()
|
|
182
185
|
type_name = self._type
|
|
183
186
|
current_max_id = db.load("_system", f"{type_name}_id")
|
|
@@ -198,11 +201,17 @@ class Entity:
|
|
|
198
201
|
self.db().register_entity(self)
|
|
199
202
|
|
|
200
203
|
self._do_not_save = True
|
|
204
|
+
# True only while reconstructing persisted state in this constructor:
|
|
205
|
+
# property descriptors skip hooks, validation, and index maintenance
|
|
206
|
+
# (indexes are already intact). Explicit updates later — including
|
|
207
|
+
# deserialize() upserts — run the full descriptor path.
|
|
208
|
+
self._hydrating = self._loaded
|
|
201
209
|
# Set additional attributes
|
|
202
210
|
for k, v in kwargs.items():
|
|
203
211
|
if not k.startswith("_"):
|
|
204
212
|
setattr(self, k, v)
|
|
205
213
|
self._do_not_save = False
|
|
214
|
+
self._hydrating = False
|
|
206
215
|
|
|
207
216
|
if not self._loaded:
|
|
208
217
|
self._save()
|
|
@@ -412,6 +421,16 @@ class Entity:
|
|
|
412
421
|
if not data:
|
|
413
422
|
return None
|
|
414
423
|
|
|
424
|
+
return cls._hydrate(entity_id, data)
|
|
425
|
+
|
|
426
|
+
@classmethod
|
|
427
|
+
def _hydrate(cls: Type[T], entity_id: str, data: dict) -> T:
|
|
428
|
+
"""Build an instance from its persisted dict, migrating if needed.
|
|
429
|
+
|
|
430
|
+
Shared by ``load`` (single ``get``) and ``load_some`` (range page).
|
|
431
|
+
Callers must have checked the identity registry first.
|
|
432
|
+
"""
|
|
433
|
+
type_name = cls.get_full_type_name()
|
|
415
434
|
stored_version = data.get("__version__", 1)
|
|
416
435
|
current_version = cls.__version__
|
|
417
436
|
|
|
@@ -454,6 +473,140 @@ class Entity:
|
|
|
454
473
|
if all(getattr(e, k, None) == v for k, v in d.items())
|
|
455
474
|
]
|
|
456
475
|
|
|
476
|
+
@classmethod
|
|
477
|
+
def _indexed_properties(cls) -> Dict[str, Any]:
|
|
478
|
+
"""Return {name: Property} for all properties declared with indexed=True."""
|
|
479
|
+
from .properties import Property
|
|
480
|
+
|
|
481
|
+
result: Dict[str, Any] = {}
|
|
482
|
+
for klass in reversed(cls.__mro__):
|
|
483
|
+
for attr_name, attr_value in klass.__dict__.items():
|
|
484
|
+
if attr_name.startswith("_"):
|
|
485
|
+
continue
|
|
486
|
+
if isinstance(attr_value, Property) and attr_value.indexed:
|
|
487
|
+
result[attr_name] = attr_value
|
|
488
|
+
return result
|
|
489
|
+
|
|
490
|
+
@classmethod
|
|
491
|
+
def _require_indexed_field(cls, field: str) -> None:
|
|
492
|
+
if field not in cls._indexed_properties():
|
|
493
|
+
raise ValueError(
|
|
494
|
+
f"{cls.__name__}.{field} is not an indexed property "
|
|
495
|
+
f"(declare it with indexed=True)"
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
@classmethod
|
|
499
|
+
def find_by(
|
|
500
|
+
cls: Type[T],
|
|
501
|
+
field: str,
|
|
502
|
+
value: Any,
|
|
503
|
+
from_id: int = 1,
|
|
504
|
+
count: int = 50,
|
|
505
|
+
) -> Tuple[List[T], Optional[int]]:
|
|
506
|
+
"""Equality lookup on an indexed field with cursor pagination (issue #11).
|
|
507
|
+
|
|
508
|
+
IDs come from the persistent field index, so cost is proportional to
|
|
509
|
+
the number of matches, not to max_id. Results are ordered by ascending
|
|
510
|
+
entity ID, compatible with load_some-style cursors.
|
|
511
|
+
|
|
512
|
+
Args:
|
|
513
|
+
field: Name of a property declared with indexed=True
|
|
514
|
+
value: Value to match (compared via its string form, like the index)
|
|
515
|
+
from_id: Only return entities with _id >= from_id
|
|
516
|
+
count: Maximum number of entities to return
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
(entities, next_from_id) — next_from_id is None when no more
|
|
520
|
+
matches remain, otherwise pass it back as from_id for the next page.
|
|
521
|
+
|
|
522
|
+
Raises:
|
|
523
|
+
ValueError: If the field is not declared with indexed=True
|
|
524
|
+
"""
|
|
525
|
+
cls._require_indexed_field(field)
|
|
526
|
+
if from_id < 1:
|
|
527
|
+
raise ValueError("from_id must be at least 1")
|
|
528
|
+
if count < 1:
|
|
529
|
+
raise ValueError("count must be at least 1")
|
|
530
|
+
|
|
531
|
+
ids = cls.db().field_index_get(cls.get_full_type_name(), field, str(value))
|
|
532
|
+
matching = sorted((int(i) for i in ids if i.isdigit() and int(i) >= from_id))
|
|
533
|
+
|
|
534
|
+
entities: List[T] = []
|
|
535
|
+
last_id = None
|
|
536
|
+
for entity_id in matching:
|
|
537
|
+
if len(entities) >= count:
|
|
538
|
+
break
|
|
539
|
+
entity = cls.load(str(entity_id))
|
|
540
|
+
if entity is not None:
|
|
541
|
+
entities.append(entity)
|
|
542
|
+
last_id = entity_id
|
|
543
|
+
|
|
544
|
+
has_more = last_id is not None and any(i > last_id for i in matching)
|
|
545
|
+
next_from_id = (last_id + 1) if has_more else None
|
|
546
|
+
return entities, next_from_id
|
|
547
|
+
|
|
548
|
+
@classmethod
|
|
549
|
+
def count_by(cls: Type[T], field: str, value: Any) -> int:
|
|
550
|
+
"""Number of entities whose indexed ``field`` equals ``value``.
|
|
551
|
+
|
|
552
|
+
Raises:
|
|
553
|
+
ValueError: If the field is not declared with indexed=True
|
|
554
|
+
"""
|
|
555
|
+
cls._require_indexed_field(field)
|
|
556
|
+
return len(
|
|
557
|
+
cls.db().field_index_get(cls.get_full_type_name(), field, str(value))
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
@classmethod
|
|
561
|
+
def rebuild_field_index(
|
|
562
|
+
cls: Type[T],
|
|
563
|
+
field: str,
|
|
564
|
+
from_id: int = 1,
|
|
565
|
+
batch: int = 200,
|
|
566
|
+
) -> Optional[int]:
|
|
567
|
+
"""Index one batch of pre-existing entities; resumable (issue #11).
|
|
568
|
+
|
|
569
|
+
Entities created before a field was declared indexed=True are missing
|
|
570
|
+
from its index. This walks the ID range in bounded batches so callers
|
|
571
|
+
on the IC can spread the work across multiple update calls or timer
|
|
572
|
+
ticks without hitting per-message instruction limits:
|
|
573
|
+
|
|
574
|
+
cursor = 1
|
|
575
|
+
while cursor is not None:
|
|
576
|
+
cursor = Proposal.rebuild_field_index("org_scope", from_id=cursor)
|
|
577
|
+
|
|
578
|
+
Idempotent — re-indexing an already-indexed entity is a no-op.
|
|
579
|
+
|
|
580
|
+
Returns:
|
|
581
|
+
The from_id for the next batch, or None when the rebuild is done.
|
|
582
|
+
|
|
583
|
+
Raises:
|
|
584
|
+
ValueError: If the field is not declared with indexed=True
|
|
585
|
+
"""
|
|
586
|
+
cls._require_indexed_field(field)
|
|
587
|
+
if from_id < 1:
|
|
588
|
+
raise ValueError("from_id must be at least 1")
|
|
589
|
+
if batch < 1:
|
|
590
|
+
raise ValueError("batch must be at least 1")
|
|
591
|
+
|
|
592
|
+
max_id = cls.max_id()
|
|
593
|
+
if max_id == 0 or from_id > max_id:
|
|
594
|
+
return None
|
|
595
|
+
|
|
596
|
+
db = cls.db()
|
|
597
|
+
type_name = cls.get_full_type_name()
|
|
598
|
+
# One range read per batch of *live* entities (deleted ids cost nothing)
|
|
599
|
+
entities = cls.load_some(from_id, batch)
|
|
600
|
+
for entity in entities:
|
|
601
|
+
value = getattr(entity, field, None)
|
|
602
|
+
if value is not None:
|
|
603
|
+
db.field_index_add(type_name, field, str(value), entity._id)
|
|
604
|
+
|
|
605
|
+
if len(entities) < batch:
|
|
606
|
+
return None
|
|
607
|
+
next_from_id = int(entities[-1]._id) + 1
|
|
608
|
+
return next_from_id if next_from_id <= max_id else None
|
|
609
|
+
|
|
457
610
|
@classmethod
|
|
458
611
|
def instances(cls: Type[T]) -> List[T]:
|
|
459
612
|
"""Get all instances of this entity type, including subclass instances.
|
|
@@ -534,11 +687,7 @@ class Entity:
|
|
|
534
687
|
Returns:
|
|
535
688
|
int: Maximum entity ID
|
|
536
689
|
"""
|
|
537
|
-
|
|
538
|
-
db = cls.db()
|
|
539
|
-
max_id_key = f"{type_name}_id"
|
|
540
|
-
max_id = db.load("_system", max_id_key)
|
|
541
|
-
return int(max_id) if max_id else 0
|
|
690
|
+
return cls.db().max_id(cls.get_full_type_name())
|
|
542
691
|
|
|
543
692
|
@classmethod
|
|
544
693
|
def load_some(
|
|
@@ -556,29 +705,42 @@ class Entity:
|
|
|
556
705
|
List[T]: List of entities for the requested page
|
|
557
706
|
|
|
558
707
|
Raises:
|
|
559
|
-
ValueError: If
|
|
708
|
+
ValueError: If from_id or count is less than 1
|
|
709
|
+
|
|
710
|
+
Cost: with a range-capable storage backend (Basilisk's StableBTreeMap,
|
|
711
|
+
MemoryStorage) this is one ordered range read per page regardless of
|
|
712
|
+
how many ids in between were deleted; otherwise one read per id.
|
|
560
713
|
"""
|
|
561
|
-
logger.
|
|
714
|
+
logger.debug(f"Loading up to {count} entities from id {from_id}")
|
|
562
715
|
|
|
563
716
|
if from_id < 1:
|
|
564
717
|
raise ValueError("from_id must be at least 1")
|
|
565
718
|
if count < 1:
|
|
566
719
|
raise ValueError("count must be at least 1")
|
|
567
720
|
|
|
568
|
-
|
|
569
|
-
|
|
721
|
+
db = cls.db()
|
|
722
|
+
type_name = cls.get_full_type_name()
|
|
570
723
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
724
|
+
ret: List[T] = []
|
|
725
|
+
max_id = db.max_id(type_name)
|
|
726
|
+
while len(ret) < count:
|
|
727
|
+
need = count - len(ret)
|
|
728
|
+
page = db.load_page(type_name, from_id, need, max_id=max_id)
|
|
729
|
+
for entity_id, data in page:
|
|
730
|
+
try:
|
|
731
|
+
# Identity map first, so callers keep getting the same instance
|
|
732
|
+
entity = db.get_entity(type_name, entity_id)
|
|
733
|
+
if entity is None:
|
|
734
|
+
entity = cls._hydrate(entity_id, data)
|
|
576
735
|
ret.append(entity)
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
736
|
+
except (ValueError, AttributeError) as e:
|
|
737
|
+
# Skip entities with broken/dangling relation references
|
|
738
|
+
# (full fix: issue #4 — lazy relation resolution)
|
|
739
|
+
logger.warning(f"Skipping {cls.__name__}@{entity_id}: {e}")
|
|
740
|
+
if len(page) < need:
|
|
741
|
+
break # no more persisted entities of this type
|
|
742
|
+
# Page was full but something was skipped: continue after it
|
|
743
|
+
from_id = int(page[-1][0]) + 1
|
|
582
744
|
|
|
583
745
|
return ret
|
|
584
746
|
|
|
@@ -674,10 +836,7 @@ class Entity:
|
|
|
674
836
|
for attr_name, attr_value in cls.__dict__.items():
|
|
675
837
|
if attr_name.startswith("_"):
|
|
676
838
|
continue
|
|
677
|
-
if (
|
|
678
|
-
isinstance(attr_value, ManyToMany)
|
|
679
|
-
and attr_value.unidirectional
|
|
680
|
-
):
|
|
839
|
+
if isinstance(attr_value, ManyToMany) and attr_value.unidirectional:
|
|
681
840
|
my_short = (
|
|
682
841
|
self._type.split("::")[-1]
|
|
683
842
|
if "::" in self._type
|
|
@@ -692,6 +851,12 @@ class Entity:
|
|
|
692
851
|
self._type, self._id, attr_value.reverse_name
|
|
693
852
|
)
|
|
694
853
|
|
|
854
|
+
# Remove from field indexes (issue #11)
|
|
855
|
+
for field_name in self._indexed_properties():
|
|
856
|
+
value = getattr(self, field_name, None)
|
|
857
|
+
if value is not None:
|
|
858
|
+
db.field_index_remove(self._type, field_name, str(value), self._id)
|
|
859
|
+
|
|
695
860
|
db.delete(self._type, self._id)
|
|
696
861
|
|
|
697
862
|
# Remove from entity registry
|
ic_python_db/properties.py
CHANGED
|
@@ -30,12 +30,18 @@ class Property(Generic[T]):
|
|
|
30
30
|
"""Definition of an entity property.
|
|
31
31
|
|
|
32
32
|
A generic descriptor class that provides type-safe property access.
|
|
33
|
+
|
|
34
|
+
With ``indexed=True`` a persistent secondary index (issue #11) maps each
|
|
35
|
+
distinct value to the list of entity IDs holding it, enabling equality
|
|
36
|
+
lookups via ``Entity.find_by`` without scanning all entities. ``None``
|
|
37
|
+
values are not indexed.
|
|
33
38
|
"""
|
|
34
39
|
|
|
35
40
|
name: str
|
|
36
41
|
type: Type[T]
|
|
37
42
|
default: Optional[T]
|
|
38
43
|
validator: Optional[Callable[[T], bool]]
|
|
44
|
+
indexed: bool
|
|
39
45
|
|
|
40
46
|
def __init__(
|
|
41
47
|
self,
|
|
@@ -43,11 +49,13 @@ class Property(Generic[T]):
|
|
|
43
49
|
type: Type[T] = type(None), # type: ignore[assignment]
|
|
44
50
|
default: Optional[T] = None,
|
|
45
51
|
validator: Optional[Callable[[T], bool]] = None,
|
|
52
|
+
indexed: bool = False,
|
|
46
53
|
):
|
|
47
54
|
self.name = name
|
|
48
55
|
self.type = type
|
|
49
56
|
self.default = default
|
|
50
57
|
self.validator = validator
|
|
58
|
+
self.indexed = indexed
|
|
51
59
|
|
|
52
60
|
def __set_name__(self, owner: type, name: str) -> None:
|
|
53
61
|
self.name = name
|
|
@@ -63,6 +71,11 @@ class Property(Generic[T]):
|
|
|
63
71
|
from .constants import ACTION_CREATE, ACTION_MODIFY
|
|
64
72
|
from .hooks import call_entity_hook
|
|
65
73
|
|
|
74
|
+
# Fast path: hydrating from DB — store raw value, indexes are intact.
|
|
75
|
+
if getattr(obj, "_hydrating", False):
|
|
76
|
+
obj.__dict__[f"_{PROPERTY_STORAGE_PREFIX}_{self.name}"] = value
|
|
77
|
+
return
|
|
78
|
+
|
|
66
79
|
old_value = obj.__dict__.get(
|
|
67
80
|
f"_{PROPERTY_STORAGE_PREFIX}_{self.name}", self.default
|
|
68
81
|
)
|
|
@@ -100,6 +113,14 @@ class Property(Generic[T]):
|
|
|
100
113
|
raise ValueError(f"Invalid value for {self.name}: {value}")
|
|
101
114
|
|
|
102
115
|
obj.__dict__[f"_{PROPERTY_STORAGE_PREFIX}_{self.name}"] = value
|
|
116
|
+
|
|
117
|
+
if self.indexed and old_value != value and obj._id is not None:
|
|
118
|
+
db = obj.db()
|
|
119
|
+
if old_value is not None:
|
|
120
|
+
db.field_index_remove(obj._type, self.name, str(old_value), obj._id)
|
|
121
|
+
if value is not None:
|
|
122
|
+
db.field_index_add(obj._type, self.name, str(value), obj._id)
|
|
123
|
+
|
|
103
124
|
obj._save()
|
|
104
125
|
|
|
105
126
|
|
|
@@ -111,6 +132,7 @@ class String(Property[str]):
|
|
|
111
132
|
min_length: Optional[int] = None,
|
|
112
133
|
max_length: Optional[int] = None,
|
|
113
134
|
default: Optional[str] = None,
|
|
135
|
+
indexed: bool = False,
|
|
114
136
|
):
|
|
115
137
|
def validator(value: str) -> bool:
|
|
116
138
|
if min_length is not None and len(value) < min_length:
|
|
@@ -119,7 +141,9 @@ class String(Property[str]):
|
|
|
119
141
|
return False
|
|
120
142
|
return True
|
|
121
143
|
|
|
122
|
-
super().__init__(
|
|
144
|
+
super().__init__(
|
|
145
|
+
name="", type=str, default=default, validator=validator, indexed=indexed
|
|
146
|
+
)
|
|
123
147
|
|
|
124
148
|
|
|
125
149
|
class Integer(Property[int]):
|
|
@@ -130,6 +154,7 @@ class Integer(Property[int]):
|
|
|
130
154
|
min_value: Optional[int] = None,
|
|
131
155
|
max_value: Optional[int] = None,
|
|
132
156
|
default: Optional[int] = None,
|
|
157
|
+
indexed: bool = False,
|
|
133
158
|
):
|
|
134
159
|
def validator(value: int) -> bool:
|
|
135
160
|
if min_value is not None and value < min_value:
|
|
@@ -138,7 +163,9 @@ class Integer(Property[int]):
|
|
|
138
163
|
return False
|
|
139
164
|
return True
|
|
140
165
|
|
|
141
|
-
super().__init__(
|
|
166
|
+
super().__init__(
|
|
167
|
+
name="", type=int, default=default, validator=validator, indexed=indexed
|
|
168
|
+
)
|
|
142
169
|
|
|
143
170
|
|
|
144
171
|
class Float(Property[float]):
|
|
@@ -149,6 +176,7 @@ class Float(Property[float]):
|
|
|
149
176
|
min_value: Optional[float] = None,
|
|
150
177
|
max_value: Optional[float] = None,
|
|
151
178
|
default: Optional[float] = None,
|
|
179
|
+
indexed: bool = False,
|
|
152
180
|
):
|
|
153
181
|
def validator(value: float) -> bool:
|
|
154
182
|
if min_value is not None and value < min_value:
|
|
@@ -157,14 +185,16 @@ class Float(Property[float]):
|
|
|
157
185
|
return False
|
|
158
186
|
return True
|
|
159
187
|
|
|
160
|
-
super().__init__(
|
|
188
|
+
super().__init__(
|
|
189
|
+
name="", type=float, default=default, validator=validator, indexed=indexed
|
|
190
|
+
)
|
|
161
191
|
|
|
162
192
|
|
|
163
193
|
class Boolean(Property[bool]):
|
|
164
194
|
"""Boolean property."""
|
|
165
195
|
|
|
166
|
-
def __init__(self, default: Optional[bool] = None):
|
|
167
|
-
super().__init__(name="", type=bool, default=default)
|
|
196
|
+
def __init__(self, default: Optional[bool] = None, indexed: bool = False):
|
|
197
|
+
super().__init__(name="", type=bool, default=default, indexed=indexed)
|
|
168
198
|
|
|
169
199
|
|
|
170
200
|
# ── Relation Properties ───────────────────────────────────────────────────────
|
ic_python_db/storage.py
CHANGED
|
@@ -3,11 +3,39 @@ Storage backends for IC Python DB
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
from abc import ABC, abstractmethod
|
|
6
|
-
from typing import Dict, Iterator, Optional, Tuple
|
|
6
|
+
from typing import Dict, Iterator, List, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def storage_sort_key(key: str) -> Tuple[int, bytes]:
|
|
10
|
+
"""Sort key reproducing the on-chain order of ``str`` keys.
|
|
11
|
+
|
|
12
|
+
Basilisk's ``StableBTreeMap`` stores a ``str`` key as
|
|
13
|
+
``tag + 4-byte big-endian length + utf-8``, so the B-tree orders keys by
|
|
14
|
+
byte length first and bytewise second: ``"T@9" < "T@10" < "T@2x"``.
|
|
15
|
+
``MemoryStorage.range`` uses this so local tests see exactly the order a
|
|
16
|
+
canister will.
|
|
17
|
+
"""
|
|
18
|
+
raw = key.encode("utf-8")
|
|
19
|
+
return (len(raw), raw)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def supports_range(storage) -> bool:
|
|
23
|
+
"""True if ``storage`` offers ``range(start, end, limit)``.
|
|
24
|
+
|
|
25
|
+
Basilisk's ``StableBTreeMap`` (>= the version that added ``range``) and
|
|
26
|
+
``MemoryStorage`` do; older CDK builds and simple dict-backed test
|
|
27
|
+
doubles don't, and callers fall back to key probing.
|
|
28
|
+
"""
|
|
29
|
+
return callable(getattr(storage, "range", None))
|
|
7
30
|
|
|
8
31
|
|
|
9
32
|
class Storage(ABC):
|
|
10
|
-
"""Abstract base class for storage backends
|
|
33
|
+
"""Abstract base class for storage backends
|
|
34
|
+
|
|
35
|
+
``range`` is optional: backends that can walk keys in order should
|
|
36
|
+
implement it (see ``storage_sort_key`` for the order that is expected);
|
|
37
|
+
callers must check ``supports_range`` and fall back to ``get`` probing.
|
|
38
|
+
"""
|
|
11
39
|
|
|
12
40
|
@abstractmethod
|
|
13
41
|
def insert(self, key: str, value: str) -> None:
|
|
@@ -67,3 +95,24 @@ class MemoryStorage(Storage):
|
|
|
67
95
|
def keys(self) -> Iterator[str]:
|
|
68
96
|
"""Return all keys in storage"""
|
|
69
97
|
return iter(self._data.keys())
|
|
98
|
+
|
|
99
|
+
def range(
|
|
100
|
+
self, start: str, end: Optional[str] = None, limit: int = 1000
|
|
101
|
+
) -> List[Tuple[str, str]]:
|
|
102
|
+
"""Ordered page of ``(key, value)`` with ``start <= key < end``.
|
|
103
|
+
|
|
104
|
+
Same contract as Basilisk's ``StableBTreeMap.range``: half-open,
|
|
105
|
+
``end=None`` means unbounded, keys ordered by ``storage_sort_key``.
|
|
106
|
+
O(n log n) here, which is fine for an in-memory test backend.
|
|
107
|
+
"""
|
|
108
|
+
if limit < 0:
|
|
109
|
+
raise ValueError("limit must be >= 0")
|
|
110
|
+
lo = storage_sort_key(start)
|
|
111
|
+
hi = None if end is None else storage_sort_key(end)
|
|
112
|
+
selected = [
|
|
113
|
+
k
|
|
114
|
+
for k in self._data
|
|
115
|
+
if lo <= storage_sort_key(k) and (hi is None or storage_sort_key(k) < hi)
|
|
116
|
+
]
|
|
117
|
+
selected.sort(key=storage_sort_key)
|
|
118
|
+
return [(k, self._data[k]) for k in selected[:limit]]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ic-python-db
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: A lightweight key-value database written in Python, intended for use on the Internet Computer (IC)
|
|
5
5
|
Home-page: https://github.com/smart-social-contracts/ic-python-db
|
|
6
6
|
Author: Smart Social Contracts
|
|
@@ -60,6 +60,7 @@ A lightweight key-value database with entity relationships and audit logging cap
|
|
|
60
60
|
## Features
|
|
61
61
|
|
|
62
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
|
+
- **Range-based paging**: `load_some`, `find`, `instances` and `rebuild_field_index` read a page of entities with a single ordered `range` call on the stable map (Basilisk `StableBTreeMap.range`), so cost tracks the rows returned rather than the highest id ever issued, and deleted ids are free. Backends without `range` (older CDK builds, plain dict test doubles) transparently fall back to one read per id.
|
|
63
64
|
- **Entity-Relational Database**: Create, read and write entities with OneToOne, OneToMany, ManyToOne, and ManyToMany relationships.
|
|
64
65
|
- **Schema Versioning & Upgrade Safety**: Automatic schema introspection, compatibility checking, and auto-migration for safe changes. Breaking changes without a `migrate()` method are rejected, preventing data corruption on canister upgrades.
|
|
65
66
|
- **Entity Hooks**: Intercept and control entity lifecycle events (create, modify, delete) with `on_event` hooks.
|
|
@@ -194,6 +195,42 @@ Then use the defined entities to store objects:
|
|
|
194
195
|
|
|
195
196
|
For more usage examples, see the [tests](tests/src/tests).
|
|
196
197
|
|
|
198
|
+
## Indexed Fields
|
|
199
|
+
|
|
200
|
+
Declare `indexed=True` on a scalar property to maintain a persistent secondary index, enabling equality lookups without scanning all entities:
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
class Proposal(Entity):
|
|
204
|
+
title = String()
|
|
205
|
+
status = String(max_length=32, indexed=True)
|
|
206
|
+
org_scope = String(max_length=128, indexed=True)
|
|
207
|
+
|
|
208
|
+
Proposal(title="A", status="voting", org_scope="Justice")
|
|
209
|
+
Proposal(title="B", status="executed", org_scope="Justice")
|
|
210
|
+
|
|
211
|
+
# Paginated equality lookup — cost proportional to matches, not max_id
|
|
212
|
+
entities, next_from_id = Proposal.find_by("status", "voting", from_id=1, count=50)
|
|
213
|
+
|
|
214
|
+
# Count without loading entities
|
|
215
|
+
Proposal.count_by("org_scope", "Justice") # 2
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
The index is maintained automatically on create, update, and delete. `None` values are not indexed. Querying a field that is not declared `indexed=True` raises `ValueError`.
|
|
219
|
+
|
|
220
|
+
### Backfilling existing data
|
|
221
|
+
|
|
222
|
+
Entities created before a field became indexed are missing from its index. Rebuild in bounded batches (resumable, safe for IC instruction limits — spread calls across multiple update calls or timers):
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
cursor = 1
|
|
226
|
+
while cursor is not None:
|
|
227
|
+
cursor = Proposal.rebuild_field_index("status", from_id=cursor, batch=200)
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
The rebuild is idempotent. The `indexed` flag is not part of the schema descriptor, so toggling it never triggers upgrade-compatibility errors.
|
|
231
|
+
|
|
232
|
+
Note: each distinct value's ID list is stored in a single storage entry, so extremely hot values are bounded by the backing store's `max_value_size` — the same constraint as relationship reverse indexes.
|
|
233
|
+
|
|
197
234
|
## Namespaces
|
|
198
235
|
|
|
199
236
|
Organize entities with the `__namespace__` attribute to avoid type conflicts when you have the same class name in different modules:
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
ic_python_db/__init__.py,sha256=8F0p76Ez1Hhcb9iHKzJjrCWp4aZGgvAGdP9-b9Ka-CU,990
|
|
2
|
+
ic_python_db/_cdk.py,sha256=zTj8HzXUcPz8R73nh5bo6JMkCorp-KSW1hDutzi5LZs,387
|
|
3
|
+
ic_python_db/constants.py,sha256=oLvKJev-65gXFd284DdYutWBX95sTF5Xkikc6IC4mhM,118
|
|
4
|
+
ic_python_db/context.py,sha256=97F4_EAJm3adOnUfh0SaDisX3nc7F2vFIZwZUsUAy58,1080
|
|
5
|
+
ic_python_db/db_engine.py,sha256=wK6mJshJMjrmVa7LPjA5ZVz1LXSxTtRjht6MU2mDM6k,26133
|
|
6
|
+
ic_python_db/entity.py,sha256=zU_7zoYUs125DToM4Htywc9fhz5NxZhbdvYGMei5RJs,50437
|
|
7
|
+
ic_python_db/hooks.py,sha256=B1wV18dAMH-PootSripmMpG9Cr9M3W3lcNEF7QvPrL4,1620
|
|
8
|
+
ic_python_db/mixins.py,sha256=qZmb3ssXYf2NSjHJtgmdsOlkIJdouDjPHdQ9tZmrZi8,1937
|
|
9
|
+
ic_python_db/properties.py,sha256=l7q3Iv8R-57XMlYwz2gcJV9mObYV_LfR6mw2jZgGwns,27556
|
|
10
|
+
ic_python_db/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
ic_python_db/schema.py,sha256=h1RAbapExo8pMD5IQvK8ZTVkgyonbPV9qSqliSP0Y14,16249
|
|
12
|
+
ic_python_db/storage.py,sha256=899qyuA3alpAFIPM23A_07vqquUB5ZG5GjjpzlHW1Os,3828
|
|
13
|
+
ic_python_db/system_time.py,sha256=xrx8NfjLXCGsac3hUpuvsNwCH91tQ9jfH4N-XFo8iJk,2614
|
|
14
|
+
ic_python_db-0.12.0.dist-info/licenses/LICENSE,sha256=6q6XYNOGnJcVSus2bAezFn7bU_2Y5T6W4aGQHBb8X-c,1079
|
|
15
|
+
ic_python_db-0.12.0.dist-info/METADATA,sha256=9AjuP2o844gKy9w2Ee-vSZNrqBrEglF6GvsE3v-JHcI,17115
|
|
16
|
+
ic_python_db-0.12.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
17
|
+
ic_python_db-0.12.0.dist-info/top_level.txt,sha256=ZaOTqhWKtJQEuXmqR920AqbDBAqv1mvsKj3TClWimDk,13
|
|
18
|
+
ic_python_db-0.12.0.dist-info/RECORD,,
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
ic_python_db/__init__.py,sha256=LH2RcKA378wYJc5qK0lTwMZrNQMUi90Ho2hvcri5gLM,990
|
|
2
|
-
ic_python_db/_cdk.py,sha256=zTj8HzXUcPz8R73nh5bo6JMkCorp-KSW1hDutzi5LZs,387
|
|
3
|
-
ic_python_db/constants.py,sha256=oLvKJev-65gXFd284DdYutWBX95sTF5Xkikc6IC4mhM,118
|
|
4
|
-
ic_python_db/context.py,sha256=97F4_EAJm3adOnUfh0SaDisX3nc7F2vFIZwZUsUAy58,1080
|
|
5
|
-
ic_python_db/db_engine.py,sha256=sA_RkFvgHjaBtvp_lOQ3rr7yuzPJQawBeqwQtn-ZXzQ,18622
|
|
6
|
-
ic_python_db/entity.py,sha256=5ZuPYnR92_vDUfG_jnUPLTz06_bjqtwZQtt9q0Vm5Nc,43648
|
|
7
|
-
ic_python_db/hooks.py,sha256=B1wV18dAMH-PootSripmMpG9Cr9M3W3lcNEF7QvPrL4,1620
|
|
8
|
-
ic_python_db/mixins.py,sha256=qZmb3ssXYf2NSjHJtgmdsOlkIJdouDjPHdQ9tZmrZi8,1937
|
|
9
|
-
ic_python_db/properties.py,sha256=BmW4dWBUn4sGnenlvusp1snER7zvstSDu9drKHM3rns,26413
|
|
10
|
-
ic_python_db/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
-
ic_python_db/schema.py,sha256=h1RAbapExo8pMD5IQvK8ZTVkgyonbPV9qSqliSP0Y14,16249
|
|
12
|
-
ic_python_db/storage.py,sha256=2F6m_01g2jwoF40s4BrdO2SffJNSO3gPf0QU3Co7T5Y,1873
|
|
13
|
-
ic_python_db/system_time.py,sha256=xrx8NfjLXCGsac3hUpuvsNwCH91tQ9jfH4N-XFo8iJk,2614
|
|
14
|
-
ic_python_db-0.10.0.dist-info/licenses/LICENSE,sha256=6q6XYNOGnJcVSus2bAezFn7bU_2Y5T6W4aGQHBb8X-c,1079
|
|
15
|
-
ic_python_db-0.10.0.dist-info/METADATA,sha256=dlFpBN0E5WDXrdK9y4Xz1d-5pIZtcK3x464c-Y5HXpo,15131
|
|
16
|
-
ic_python_db-0.10.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
17
|
-
ic_python_db-0.10.0.dist-info/top_level.txt,sha256=ZaOTqhWKtJQEuXmqR920AqbDBAqv1mvsKj3TClWimDk,13
|
|
18
|
-
ic_python_db-0.10.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|