mempalace-code 1.0.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.
mempalace/migrate.py ADDED
@@ -0,0 +1,153 @@
1
+ """
2
+ migrate.py — ChromaDB → LanceDB palace migration
3
+ =================================================
4
+
5
+ Provides migrate_chroma_to_lance() to copy all drawers from a
6
+ ChromaDB palace to a LanceDB palace. Used by the 'mempalace migrate-storage'
7
+ CLI subcommand.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import tarfile
14
+ from datetime import datetime
15
+ from typing import Optional
16
+
17
+
18
+ class VerificationError(Exception):
19
+ """Raised when post-migration count verification fails."""
20
+
21
+
22
+ def migrate_chroma_to_lance(
23
+ src_path: str,
24
+ dst_path: str,
25
+ backup_dir: Optional[str] = None,
26
+ force: bool = False,
27
+ embed_model: Optional[str] = None,
28
+ verify: bool = False,
29
+ no_backup: bool = False,
30
+ ) -> tuple[int, int]:
31
+ """
32
+ Copy all drawers from a ChromaDB palace to a LanceDB palace.
33
+
34
+ Returns (src_count, dst_count).
35
+
36
+ Args:
37
+ src_path: Path to the source ChromaDB palace directory.
38
+ dst_path: Path to the destination LanceDB palace directory.
39
+ backup_dir: Directory where the source backup tar.gz is written.
40
+ Defaults to the parent directory of src_path.
41
+ force: If True, allow writing to a non-empty destination
42
+ (appends rather than refusing).
43
+ embed_model: Sentence-transformers model name for the destination
44
+ LanceDB store. None = LanceStore default (all-MiniLM-L6-v2).
45
+ verify: If True, compare accumulated per-wing source counts against
46
+ the destination counts after migration. Raises VerificationError
47
+ on any mismatch.
48
+ no_backup: If True, skip creating a backup of the source palace.
49
+ Intended for tests; not exposed in the public CLI.
50
+ """
51
+ from .storage import ChromaStore, LanceStore
52
+
53
+ # Open source ChromaDB palace — wrap chromadb ImportError with a helpful message.
54
+ try:
55
+ src_store = ChromaStore(src_path, create=False)
56
+ except ImportError:
57
+ raise RuntimeError("chromadb not installed — run: pip install mempalace[chroma]")
58
+
59
+ if src_store._col is None:
60
+ raise RuntimeError(f"No ChromaDB collection found at {src_path}")
61
+
62
+ src_total = src_store.count()
63
+ if src_total == 0:
64
+ print("Source palace is empty — nothing to migrate.")
65
+ return (0, 0)
66
+
67
+ # Open destination LanceDB palace.
68
+ os.makedirs(dst_path, exist_ok=True)
69
+ dst_store = LanceStore(dst_path, create=True, embed_model=embed_model)
70
+
71
+ # Guard against writing to a non-empty destination.
72
+ if dst_store.count() > 0 and not force:
73
+ raise RuntimeError(
74
+ f"Destination palace at {dst_path!r} already contains rows. "
75
+ "Pass --force to append to it."
76
+ )
77
+
78
+ # Backup source palace.
79
+ if not no_backup:
80
+ ts = datetime.now().strftime("%Y%m%d-%H%M%S")
81
+ backup_filename = f"chroma-pre-migrate-{ts}.tar.gz"
82
+ if backup_dir is None:
83
+ effective_backup_dir = str(os.path.dirname(os.path.abspath(src_path)))
84
+ else:
85
+ effective_backup_dir = backup_dir
86
+ os.makedirs(effective_backup_dir, exist_ok=True)
87
+ backup_path = os.path.join(effective_backup_dir, backup_filename)
88
+ print(f"Backing up {src_path} → {backup_path} ...")
89
+ with tarfile.open(backup_path, "w:gz") as tar:
90
+ tar.add(src_path, arcname=os.path.basename(src_path))
91
+ print(f"Backup created: {backup_path}")
92
+
93
+ # Migrate in pages of 1000.
94
+ BATCH_SIZE = 1000
95
+ offset = 0
96
+ running_total = 0
97
+ src_wing_counts: dict[str, int] = {}
98
+
99
+ print(f"Migrating {src_total} drawers from ChromaDB → LanceDB ...")
100
+
101
+ while offset < src_total:
102
+ try:
103
+ batch = src_store.get(
104
+ limit=BATCH_SIZE,
105
+ offset=offset,
106
+ include=["documents", "metadatas"],
107
+ )
108
+ except Exception as e:
109
+ raise RuntimeError(
110
+ f"Error reading batch at offset {offset}: {e}. "
111
+ f"Migration aborted. {running_total} drawers written so far."
112
+ ) from e
113
+
114
+ batch_ids = batch.get("ids", [])
115
+ batch_docs = batch.get("documents", [])
116
+ batch_metas = batch.get("metadatas", [])
117
+
118
+ if not batch_ids:
119
+ break
120
+
121
+ # Accumulate per-wing counts from source metadata (free — we already read them).
122
+ for meta in batch_metas:
123
+ wing = (meta.get("wing", "") if meta else "") or ""
124
+ src_wing_counts[wing] = src_wing_counts.get(wing, 0) + 1
125
+
126
+ try:
127
+ dst_store.add(ids=batch_ids, documents=batch_docs, metadatas=batch_metas)
128
+ except Exception as e:
129
+ raise RuntimeError(
130
+ f"Error writing batch at offset {offset}: {e}. "
131
+ f"Migration aborted. {running_total} drawers written so far."
132
+ ) from e
133
+
134
+ running_total += len(batch_ids)
135
+ print(f"Migrated {running_total}/{src_total} drawers...", flush=True)
136
+ offset += BATCH_SIZE
137
+
138
+ dst_total = dst_store.count()
139
+
140
+ # Optional per-wing verification.
141
+ if verify:
142
+ dst_wing_counts = dst_store.count_by("wing")
143
+ mismatches = []
144
+ for wing, src_cnt in src_wing_counts.items():
145
+ dst_cnt = dst_wing_counts.get(wing, 0)
146
+ if src_cnt != dst_cnt:
147
+ mismatches.append(f" wing={wing!r}: src={src_cnt}, dst={dst_cnt}")
148
+ if mismatches:
149
+ diff = "\n".join(mismatches)
150
+ raise VerificationError(f"Per-wing count mismatch after migration:\n{diff}")
151
+
152
+ print(f"Migration complete: {src_total} drawers migrated.")
153
+ return (src_total, dst_total)