alma-memory 0.5.0__py3-none-any.whl → 0.5.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.
Files changed (36) hide show
  1. alma/__init__.py +33 -1
  2. alma/core.py +124 -16
  3. alma/extraction/auto_learner.py +4 -3
  4. alma/graph/__init__.py +26 -1
  5. alma/graph/backends/__init__.py +14 -0
  6. alma/graph/backends/kuzu.py +624 -0
  7. alma/graph/backends/memgraph.py +432 -0
  8. alma/integration/claude_agents.py +22 -10
  9. alma/learning/protocols.py +3 -3
  10. alma/mcp/tools.py +9 -11
  11. alma/observability/__init__.py +84 -0
  12. alma/observability/config.py +302 -0
  13. alma/observability/logging.py +424 -0
  14. alma/observability/metrics.py +583 -0
  15. alma/observability/tracing.py +440 -0
  16. alma/retrieval/engine.py +65 -4
  17. alma/storage/__init__.py +29 -0
  18. alma/storage/azure_cosmos.py +343 -132
  19. alma/storage/base.py +58 -0
  20. alma/storage/constants.py +103 -0
  21. alma/storage/file_based.py +3 -8
  22. alma/storage/migrations/__init__.py +21 -0
  23. alma/storage/migrations/base.py +321 -0
  24. alma/storage/migrations/runner.py +323 -0
  25. alma/storage/migrations/version_stores.py +337 -0
  26. alma/storage/migrations/versions/__init__.py +11 -0
  27. alma/storage/migrations/versions/v1_0_0.py +373 -0
  28. alma/storage/postgresql.py +185 -78
  29. alma/storage/sqlite_local.py +149 -50
  30. alma/testing/__init__.py +46 -0
  31. alma/testing/factories.py +301 -0
  32. alma/testing/mocks.py +389 -0
  33. {alma_memory-0.5.0.dist-info → alma_memory-0.5.1.dist-info}/METADATA +42 -8
  34. {alma_memory-0.5.0.dist-info → alma_memory-0.5.1.dist-info}/RECORD +36 -19
  35. {alma_memory-0.5.0.dist-info → alma_memory-0.5.1.dist-info}/WHEEL +0 -0
  36. {alma_memory-0.5.0.dist-info → alma_memory-0.5.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,323 @@
1
+ """
2
+ ALMA Migration Framework - Migration Runner.
3
+
4
+ Executes migrations and manages schema version tracking.
5
+ """
6
+
7
+ import hashlib
8
+ import logging
9
+ from datetime import datetime, timezone
10
+ from typing import Any, Callable, Dict, List, Optional, Protocol, Type
11
+
12
+ from alma.storage.migrations.base import (
13
+ Migration,
14
+ MigrationError,
15
+ MigrationRegistry,
16
+ SchemaVersion,
17
+ get_registry,
18
+ )
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class VersionStore(Protocol):
24
+ """Protocol for schema version storage."""
25
+
26
+ def get_current_version(self) -> Optional[str]:
27
+ """Get the current schema version."""
28
+ ...
29
+
30
+ def get_version_history(self) -> List[SchemaVersion]:
31
+ """Get all applied versions in order."""
32
+ ...
33
+
34
+ def record_version(self, version: SchemaVersion) -> None:
35
+ """Record a new version."""
36
+ ...
37
+
38
+ def remove_version(self, version: str) -> None:
39
+ """Remove a version record (for rollback)."""
40
+ ...
41
+
42
+
43
+ class MigrationRunner:
44
+ """
45
+ Executes schema migrations and tracks versions.
46
+
47
+ Handles:
48
+ - Forward migrations (upgrades)
49
+ - Backward migrations (rollbacks)
50
+ - Version tracking
51
+ - Pre/post migration checks
52
+ - Dry run mode
53
+ """
54
+
55
+ # Current schema version for fresh installations
56
+ CURRENT_SCHEMA_VERSION = "1.0.0"
57
+
58
+ def __init__(
59
+ self,
60
+ version_store: VersionStore,
61
+ registry: Optional[MigrationRegistry] = None,
62
+ backend: Optional[str] = None,
63
+ ):
64
+ """
65
+ Initialize migration runner.
66
+
67
+ Args:
68
+ version_store: Storage for version tracking
69
+ registry: Migration registry (uses global if not provided)
70
+ backend: Backend name for filtering migrations
71
+ """
72
+ self.version_store = version_store
73
+ self.registry = registry or get_registry()
74
+ self.backend = backend
75
+ self._hooks: Dict[str, List[Callable]] = {
76
+ "pre_migrate": [],
77
+ "post_migrate": [],
78
+ "pre_rollback": [],
79
+ "post_rollback": [],
80
+ }
81
+
82
+ def add_hook(self, event: str, callback: Callable) -> None:
83
+ """
84
+ Add a hook callback for migration events.
85
+
86
+ Args:
87
+ event: Event name (pre_migrate, post_migrate, etc.)
88
+ callback: Function to call
89
+ """
90
+ if event in self._hooks:
91
+ self._hooks[event].append(callback)
92
+
93
+ def _run_hooks(self, event: str, *args: Any, **kwargs: Any) -> None:
94
+ """Run all hooks for an event."""
95
+ for callback in self._hooks.get(event, []):
96
+ try:
97
+ callback(*args, **kwargs)
98
+ except Exception as e:
99
+ logger.warning(f"Hook {callback.__name__} failed: {e}")
100
+
101
+ def get_current_version(self) -> Optional[str]:
102
+ """Get the current schema version."""
103
+ return self.version_store.get_current_version()
104
+
105
+ def get_pending_migrations(self) -> List[Type[Migration]]:
106
+ """Get list of migrations that need to be applied."""
107
+ current = self.get_current_version()
108
+ return self.registry.get_pending_migrations(current, self.backend)
109
+
110
+ def needs_migration(self) -> bool:
111
+ """Check if there are pending migrations."""
112
+ return len(self.get_pending_migrations()) > 0
113
+
114
+ def migrate(
115
+ self,
116
+ connection: Any,
117
+ target_version: Optional[str] = None,
118
+ dry_run: bool = False,
119
+ ) -> List[str]:
120
+ """
121
+ Apply pending migrations.
122
+
123
+ Args:
124
+ connection: Database connection or storage instance
125
+ target_version: Optional target version (applies all if not specified)
126
+ dry_run: If True, show what would be done without making changes
127
+
128
+ Returns:
129
+ List of applied migration versions
130
+
131
+ Raises:
132
+ MigrationError: If a migration fails
133
+ """
134
+ current = self.get_current_version()
135
+ pending = self.registry.get_pending_migrations(current, self.backend)
136
+
137
+ if target_version:
138
+ target_tuple = SchemaVersion._parse_version(target_version)
139
+ pending = [
140
+ m
141
+ for m in pending
142
+ if SchemaVersion._parse_version(m.version) <= target_tuple
143
+ ]
144
+
145
+ if not pending:
146
+ logger.info("No pending migrations")
147
+ return []
148
+
149
+ applied = []
150
+ logger.info(f"Found {len(pending)} pending migrations")
151
+
152
+ for migration_class in pending:
153
+ version = migration_class.version
154
+ migration = migration_class()
155
+
156
+ if dry_run:
157
+ logger.info(
158
+ f"[DRY RUN] Would apply migration {version}: {migration.description}"
159
+ )
160
+ applied.append(version)
161
+ continue
162
+
163
+ logger.info(f"Applying migration {version}: {migration.description}")
164
+
165
+ try:
166
+ # Run pre-check
167
+ if not migration.pre_check(connection):
168
+ raise MigrationError(
169
+ f"Pre-check failed for migration {version}",
170
+ version=version,
171
+ )
172
+
173
+ # Run pre-migrate hooks
174
+ self._run_hooks("pre_migrate", migration, connection)
175
+
176
+ # Apply migration
177
+ migration.upgrade(connection)
178
+
179
+ # Run post-check
180
+ if not migration.post_check(connection):
181
+ raise MigrationError(
182
+ f"Post-check failed for migration {version}",
183
+ version=version,
184
+ )
185
+
186
+ # Record version
187
+ schema_version = SchemaVersion(
188
+ version=version,
189
+ applied_at=datetime.now(timezone.utc),
190
+ description=migration.description,
191
+ checksum=self._compute_checksum(migration_class),
192
+ )
193
+ self.version_store.record_version(schema_version)
194
+
195
+ # Run post-migrate hooks
196
+ self._run_hooks("post_migrate", migration, connection)
197
+
198
+ applied.append(version)
199
+ logger.info(f"Successfully applied migration {version}")
200
+
201
+ except MigrationError:
202
+ raise
203
+ except Exception as e:
204
+ raise MigrationError(
205
+ f"Migration {version} failed: {str(e)}",
206
+ version=version,
207
+ cause=e,
208
+ ) from e
209
+
210
+ return applied
211
+
212
+ def rollback(
213
+ self,
214
+ connection: Any,
215
+ target_version: str,
216
+ dry_run: bool = False,
217
+ ) -> List[str]:
218
+ """
219
+ Roll back to a target version.
220
+
221
+ Args:
222
+ connection: Database connection or storage instance
223
+ target_version: Version to roll back to
224
+ dry_run: If True, show what would be done without making changes
225
+
226
+ Returns:
227
+ List of rolled back migration versions
228
+
229
+ Raises:
230
+ MigrationError: If a rollback fails
231
+ """
232
+ current = self.get_current_version()
233
+ if current is None:
234
+ logger.info("No migrations to roll back")
235
+ return []
236
+
237
+ rollback_migrations = self.registry.get_rollback_migrations(
238
+ current, target_version, self.backend
239
+ )
240
+
241
+ if not rollback_migrations:
242
+ logger.info("No migrations to roll back")
243
+ return []
244
+
245
+ rolled_back = []
246
+ logger.info(f"Rolling back {len(rollback_migrations)} migrations")
247
+
248
+ for migration_class in rollback_migrations:
249
+ version = migration_class.version
250
+ migration = migration_class()
251
+
252
+ if dry_run:
253
+ logger.info(f"[DRY RUN] Would roll back migration {version}")
254
+ rolled_back.append(version)
255
+ continue
256
+
257
+ logger.info(f"Rolling back migration {version}")
258
+
259
+ try:
260
+ # Run pre-rollback hooks
261
+ self._run_hooks("pre_rollback", migration, connection)
262
+
263
+ # Apply downgrade
264
+ migration.downgrade(connection)
265
+
266
+ # Remove version record
267
+ self.version_store.remove_version(version)
268
+
269
+ # Run post-rollback hooks
270
+ self._run_hooks("post_rollback", migration, connection)
271
+
272
+ rolled_back.append(version)
273
+ logger.info(f"Successfully rolled back migration {version}")
274
+
275
+ except NotImplementedError as e:
276
+ raise MigrationError(
277
+ f"Migration {version} does not support rollback",
278
+ version=version,
279
+ ) from e
280
+ except Exception as e:
281
+ raise MigrationError(
282
+ f"Rollback of {version} failed: {str(e)}",
283
+ version=version,
284
+ cause=e,
285
+ ) from e
286
+
287
+ return rolled_back
288
+
289
+ def get_status(self) -> Dict[str, Any]:
290
+ """
291
+ Get migration status information.
292
+
293
+ Returns:
294
+ Dict with current version, pending migrations, and history
295
+ """
296
+ current = self.get_current_version()
297
+ pending = self.get_pending_migrations()
298
+ history = self.version_store.get_version_history()
299
+
300
+ return {
301
+ "current_version": current,
302
+ "target_version": self.CURRENT_SCHEMA_VERSION,
303
+ "pending_count": len(pending),
304
+ "pending_versions": [m.version for m in pending],
305
+ "applied_count": len(history),
306
+ "history": [
307
+ {
308
+ "version": v.version,
309
+ "applied_at": v.applied_at.isoformat(),
310
+ "description": v.description,
311
+ }
312
+ for v in history
313
+ ],
314
+ "needs_migration": len(pending) > 0,
315
+ }
316
+
317
+ @staticmethod
318
+ def _compute_checksum(migration_class: Type[Migration]) -> str:
319
+ """Compute checksum of migration for integrity tracking."""
320
+ import inspect
321
+
322
+ source = inspect.getsource(migration_class)
323
+ return hashlib.sha256(source.encode()).hexdigest()[:16]
@@ -0,0 +1,337 @@
1
+ """
2
+ ALMA Migration Framework - Version Stores.
3
+
4
+ Implementations of version tracking for different storage backends.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ import sqlite3
10
+ from contextlib import contextmanager
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from typing import Any, List, Optional
14
+
15
+ from alma.storage.migrations.base import SchemaVersion
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class SQLiteVersionStore:
21
+ """
22
+ Version store using SQLite for tracking schema versions.
23
+
24
+ Creates a `_schema_versions` table to track applied migrations.
25
+ """
26
+
27
+ def __init__(self, db_path: Path):
28
+ """
29
+ Initialize SQLite version store.
30
+
31
+ Args:
32
+ db_path: Path to SQLite database file
33
+ """
34
+ self.db_path = Path(db_path)
35
+ self._init_version_table()
36
+
37
+ @contextmanager
38
+ def _get_connection(self):
39
+ """Get database connection with context manager."""
40
+ conn = sqlite3.connect(self.db_path)
41
+ conn.row_factory = sqlite3.Row
42
+ try:
43
+ yield conn
44
+ conn.commit()
45
+ except Exception:
46
+ conn.rollback()
47
+ raise
48
+ finally:
49
+ conn.close()
50
+
51
+ def _init_version_table(self) -> None:
52
+ """Create schema versions table if it doesn't exist."""
53
+ with self._get_connection() as conn:
54
+ cursor = conn.cursor()
55
+ cursor.execute("""
56
+ CREATE TABLE IF NOT EXISTS _schema_versions (
57
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
58
+ version TEXT NOT NULL UNIQUE,
59
+ applied_at TEXT NOT NULL,
60
+ description TEXT,
61
+ checksum TEXT
62
+ )
63
+ """)
64
+ cursor.execute(
65
+ "CREATE INDEX IF NOT EXISTS idx_schema_version "
66
+ "ON _schema_versions(version)"
67
+ )
68
+
69
+ def get_current_version(self) -> Optional[str]:
70
+ """Get the current (latest) schema version."""
71
+ with self._get_connection() as conn:
72
+ cursor = conn.cursor()
73
+ cursor.execute("""
74
+ SELECT version FROM _schema_versions
75
+ ORDER BY id DESC LIMIT 1
76
+ """)
77
+ row = cursor.fetchone()
78
+ return row["version"] if row else None
79
+
80
+ def get_version_history(self) -> List[SchemaVersion]:
81
+ """Get all applied versions in chronological order."""
82
+ with self._get_connection() as conn:
83
+ cursor = conn.cursor()
84
+ cursor.execute("""
85
+ SELECT version, applied_at, description, checksum
86
+ FROM _schema_versions
87
+ ORDER BY id ASC
88
+ """)
89
+ rows = cursor.fetchall()
90
+
91
+ return [
92
+ SchemaVersion(
93
+ version=row["version"],
94
+ applied_at=datetime.fromisoformat(row["applied_at"]),
95
+ description=row["description"] or "",
96
+ checksum=row["checksum"],
97
+ )
98
+ for row in rows
99
+ ]
100
+
101
+ def record_version(self, version: SchemaVersion) -> None:
102
+ """Record a new schema version."""
103
+ with self._get_connection() as conn:
104
+ cursor = conn.cursor()
105
+ cursor.execute(
106
+ """
107
+ INSERT INTO _schema_versions (version, applied_at, description, checksum)
108
+ VALUES (?, ?, ?, ?)
109
+ """,
110
+ (
111
+ version.version,
112
+ version.applied_at.isoformat(),
113
+ version.description,
114
+ version.checksum,
115
+ ),
116
+ )
117
+ logger.debug(f"Recorded schema version {version.version}")
118
+
119
+ def remove_version(self, version: str) -> None:
120
+ """Remove a version record (for rollback)."""
121
+ with self._get_connection() as conn:
122
+ cursor = conn.cursor()
123
+ cursor.execute(
124
+ "DELETE FROM _schema_versions WHERE version = ?",
125
+ (version,),
126
+ )
127
+ logger.debug(f"Removed schema version {version}")
128
+
129
+ def has_version_table(self) -> bool:
130
+ """Check if the version table exists."""
131
+ with self._get_connection() as conn:
132
+ cursor = conn.cursor()
133
+ cursor.execute("""
134
+ SELECT name FROM sqlite_master
135
+ WHERE type='table' AND name='_schema_versions'
136
+ """)
137
+ return cursor.fetchone() is not None
138
+
139
+
140
+ class PostgreSQLVersionStore:
141
+ """
142
+ Version store using PostgreSQL for tracking schema versions.
143
+
144
+ Creates an `_schema_versions` table in the configured schema.
145
+ """
146
+
147
+ def __init__(self, pool: Any, schema: str = "public"):
148
+ """
149
+ Initialize PostgreSQL version store.
150
+
151
+ Args:
152
+ pool: psycopg connection pool
153
+ schema: Database schema name
154
+ """
155
+ self._pool = pool
156
+ self.schema = schema
157
+ self._init_version_table()
158
+
159
+ @contextmanager
160
+ def _get_connection(self):
161
+ """Get database connection from pool."""
162
+ with self._pool.connection() as conn:
163
+ yield conn
164
+
165
+ def _init_version_table(self) -> None:
166
+ """Create schema versions table if it doesn't exist."""
167
+ with self._get_connection() as conn:
168
+ conn.execute(f"""
169
+ CREATE TABLE IF NOT EXISTS {self.schema}._schema_versions (
170
+ id SERIAL PRIMARY KEY,
171
+ version TEXT NOT NULL UNIQUE,
172
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
173
+ description TEXT,
174
+ checksum TEXT
175
+ )
176
+ """)
177
+ conn.execute(f"""
178
+ CREATE INDEX IF NOT EXISTS idx_schema_version
179
+ ON {self.schema}._schema_versions(version)
180
+ """)
181
+ conn.commit()
182
+
183
+ def get_current_version(self) -> Optional[str]:
184
+ """Get the current (latest) schema version."""
185
+ with self._get_connection() as conn:
186
+ cursor = conn.execute(f"""
187
+ SELECT version FROM {self.schema}._schema_versions
188
+ ORDER BY id DESC LIMIT 1
189
+ """)
190
+ row = cursor.fetchone()
191
+ return row["version"] if row else None
192
+
193
+ def get_version_history(self) -> List[SchemaVersion]:
194
+ """Get all applied versions in chronological order."""
195
+ with self._get_connection() as conn:
196
+ cursor = conn.execute(f"""
197
+ SELECT version, applied_at, description, checksum
198
+ FROM {self.schema}._schema_versions
199
+ ORDER BY id ASC
200
+ """)
201
+ rows = cursor.fetchall()
202
+
203
+ return [
204
+ SchemaVersion(
205
+ version=row["version"],
206
+ applied_at=row["applied_at"]
207
+ if isinstance(row["applied_at"], datetime)
208
+ else datetime.fromisoformat(str(row["applied_at"])),
209
+ description=row["description"] or "",
210
+ checksum=row["checksum"],
211
+ )
212
+ for row in rows
213
+ ]
214
+
215
+ def record_version(self, version: SchemaVersion) -> None:
216
+ """Record a new schema version."""
217
+ with self._get_connection() as conn:
218
+ conn.execute(
219
+ f"""
220
+ INSERT INTO {self.schema}._schema_versions
221
+ (version, applied_at, description, checksum)
222
+ VALUES (%s, %s, %s, %s)
223
+ """,
224
+ (
225
+ version.version,
226
+ version.applied_at,
227
+ version.description,
228
+ version.checksum,
229
+ ),
230
+ )
231
+ conn.commit()
232
+ logger.debug(f"Recorded schema version {version.version}")
233
+
234
+ def remove_version(self, version: str) -> None:
235
+ """Remove a version record (for rollback)."""
236
+ with self._get_connection() as conn:
237
+ conn.execute(
238
+ f"DELETE FROM {self.schema}._schema_versions WHERE version = %s",
239
+ (version,),
240
+ )
241
+ conn.commit()
242
+ logger.debug(f"Removed schema version {version}")
243
+
244
+ def has_version_table(self) -> bool:
245
+ """Check if the version table exists."""
246
+ with self._get_connection() as conn:
247
+ cursor = conn.execute(
248
+ """
249
+ SELECT EXISTS (
250
+ SELECT FROM information_schema.tables
251
+ WHERE table_schema = %s
252
+ AND table_name = '_schema_versions'
253
+ )
254
+ """,
255
+ (self.schema,),
256
+ )
257
+ row = cursor.fetchone()
258
+ return row["exists"] if row else False
259
+
260
+
261
+ class FileBasedVersionStore:
262
+ """
263
+ Version store using a JSON file for tracking schema versions.
264
+
265
+ Useful for file-based storage backends.
266
+ """
267
+
268
+ def __init__(self, storage_dir: Path):
269
+ """
270
+ Initialize file-based version store.
271
+
272
+ Args:
273
+ storage_dir: Directory to store version file
274
+ """
275
+ self.storage_dir = Path(storage_dir)
276
+ self.version_file = self.storage_dir / "_schema_versions.json"
277
+ self._ensure_file_exists()
278
+
279
+ def _ensure_file_exists(self) -> None:
280
+ """Create version file if it doesn't exist."""
281
+ self.storage_dir.mkdir(parents=True, exist_ok=True)
282
+ if not self.version_file.exists():
283
+ self._write_versions([])
284
+
285
+ def _read_versions(self) -> List[dict]:
286
+ """Read versions from file."""
287
+ try:
288
+ with open(self.version_file, "r") as f:
289
+ return json.load(f)
290
+ except (json.JSONDecodeError, FileNotFoundError):
291
+ return []
292
+
293
+ def _write_versions(self, versions: List[dict]) -> None:
294
+ """Write versions to file."""
295
+ with open(self.version_file, "w") as f:
296
+ json.dump(versions, f, indent=2, default=str)
297
+
298
+ def get_current_version(self) -> Optional[str]:
299
+ """Get the current (latest) schema version."""
300
+ versions = self._read_versions()
301
+ if not versions:
302
+ return None
303
+ return versions[-1]["version"]
304
+
305
+ def get_version_history(self) -> List[SchemaVersion]:
306
+ """Get all applied versions in chronological order."""
307
+ versions = self._read_versions()
308
+ return [
309
+ SchemaVersion(
310
+ version=v["version"],
311
+ applied_at=datetime.fromisoformat(v["applied_at"]),
312
+ description=v.get("description", ""),
313
+ checksum=v.get("checksum"),
314
+ )
315
+ for v in versions
316
+ ]
317
+
318
+ def record_version(self, version: SchemaVersion) -> None:
319
+ """Record a new schema version."""
320
+ versions = self._read_versions()
321
+ versions.append(
322
+ {
323
+ "version": version.version,
324
+ "applied_at": version.applied_at.isoformat(),
325
+ "description": version.description,
326
+ "checksum": version.checksum,
327
+ }
328
+ )
329
+ self._write_versions(versions)
330
+ logger.debug(f"Recorded schema version {version.version}")
331
+
332
+ def remove_version(self, version: str) -> None:
333
+ """Remove a version record (for rollback)."""
334
+ versions = self._read_versions()
335
+ versions = [v for v in versions if v["version"] != version]
336
+ self._write_versions(versions)
337
+ logger.debug(f"Removed schema version {version}")
@@ -0,0 +1,11 @@
1
+ """
2
+ ALMA Schema Migrations - Version Definitions.
3
+
4
+ Each version file contains migrations for that schema version.
5
+ Migrations are automatically registered when imported.
6
+ """
7
+
8
+ # Import all version modules to register migrations
9
+ from alma.storage.migrations.versions import v1_0_0
10
+
11
+ __all__ = ["v1_0_0"]