hivemind-sqlite-database 0.3.0a4__tar.gz → 0.4.0a1__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.
- {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/PKG-INFO +1 -1
- {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/hivemind_sqlite_database/__init__.py +143 -23
- {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/hivemind_sqlite_database/version.py +2 -2
- {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/hivemind_sqlite_database.egg-info/PKG-INFO +1 -1
- {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/tests/test_sqlitedb.py +331 -2
- {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/LICENSE.md +0 -0
- {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/README.md +0 -0
- {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/hivemind_sqlite_database.egg-info/SOURCES.txt +0 -0
- {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/hivemind_sqlite_database.egg-info/dependency_links.txt +0 -0
- {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/hivemind_sqlite_database.egg-info/entry_points.txt +0 -0
- {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/hivemind_sqlite_database.egg-info/requires.txt +0 -0
- {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/hivemind_sqlite_database.egg-info/top_level.txt +0 -0
- {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/pyproject.toml +0 -0
- {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/setup.cfg +0 -0
|
@@ -65,6 +65,7 @@ class SQLiteDB(AbstractDB):
|
|
|
65
65
|
self.conn.execute("PRAGMA journal_mode=WAL")
|
|
66
66
|
self._write_lock = threading.Lock()
|
|
67
67
|
self._initialize_database()
|
|
68
|
+
self._maybe_migrate()
|
|
68
69
|
|
|
69
70
|
def _initialize_database(self):
|
|
70
71
|
"""Initialize the database schema."""
|
|
@@ -98,6 +99,81 @@ class SQLiteDB(AbstractDB):
|
|
|
98
99
|
if "metadata" not in columns:
|
|
99
100
|
self.conn.execute("ALTER TABLE clients ADD COLUMN metadata TEXT")
|
|
100
101
|
|
|
102
|
+
def _maybe_migrate(self) -> None:
|
|
103
|
+
"""Run schema migration if the on-disk version is behind ``SCHEMA_VERSION``.
|
|
104
|
+
|
|
105
|
+
Persisted version lives in SQLite's ``PRAGMA user_version`` (a
|
|
106
|
+
signed integer slot reserved exactly for this use case).
|
|
107
|
+
Tolerates older HPM versions that predate ``SCHEMA_VERSION``.
|
|
108
|
+
"""
|
|
109
|
+
target = getattr(AbstractDB, "SCHEMA_VERSION", 1)
|
|
110
|
+
stored = self.conn.execute("PRAGMA user_version").fetchone()[0]
|
|
111
|
+
# Tolerate older HPM that predates the forward-compat guard, the
|
|
112
|
+
# same way SCHEMA_VERSION is read defensively above.
|
|
113
|
+
if hasattr(self, "_check_forward_compat"):
|
|
114
|
+
self._check_forward_compat(int(stored))
|
|
115
|
+
if stored < target:
|
|
116
|
+
LOG.info("SQLiteDB: migrating schema v%d -> v%d", stored, target)
|
|
117
|
+
# Migrate row rewrites and the user_version bump share one
|
|
118
|
+
# transaction so a crash never leaves the DB at "migrated rows
|
|
119
|
+
# but stale sentinel" or vice versa.
|
|
120
|
+
with self._write_lock, self.conn:
|
|
121
|
+
self._migrate_locked(from_version=stored)
|
|
122
|
+
self.conn.execute(f"PRAGMA user_version = {int(target)}")
|
|
123
|
+
|
|
124
|
+
def migrate(self, from_version: int) -> None:
|
|
125
|
+
"""Migrate on-disk rows to the current ``SCHEMA_VERSION``.
|
|
126
|
+
|
|
127
|
+
Idempotent and crash-safe: a partial migration re-run produces the
|
|
128
|
+
same final state because the merge is ``setdefault``-style (never
|
|
129
|
+
clobbers explicit metadata values) and the legacy columns are
|
|
130
|
+
unconditionally NULLed in the same transaction.
|
|
131
|
+
|
|
132
|
+
v1 -> v2: fold ``intent_blacklist`` / ``skill_blacklist`` column
|
|
133
|
+
values into each row's ``metadata`` JSON dict, then NULL the
|
|
134
|
+
legacy columns. ``message_blacklist`` is purged outright (the
|
|
135
|
+
field is not part of the ``Client`` data model). The columns
|
|
136
|
+
themselves remain in the table (SQLite ``ALTER TABLE ... DROP
|
|
137
|
+
COLUMN`` is unreliable on older versions) but are no longer
|
|
138
|
+
written by ``add_item``.
|
|
139
|
+
"""
|
|
140
|
+
if from_version >= 2:
|
|
141
|
+
return
|
|
142
|
+
with self._write_lock, self.conn:
|
|
143
|
+
self._migrate_locked(from_version=from_version)
|
|
144
|
+
|
|
145
|
+
def _migrate_locked(self, from_version: int) -> None:
|
|
146
|
+
"""Inner migration body — assumes the caller already holds
|
|
147
|
+
``_write_lock`` and is inside a ``with self.conn`` transaction.
|
|
148
|
+
Lets ``_maybe_migrate`` bundle the version-sentinel bump into the
|
|
149
|
+
same transaction as the row rewrites."""
|
|
150
|
+
if from_version >= 2:
|
|
151
|
+
return
|
|
152
|
+
for row in self.conn.execute(
|
|
153
|
+
"SELECT client_id, intent_blacklist, skill_blacklist, "
|
|
154
|
+
"message_blacklist, metadata FROM clients"
|
|
155
|
+
).fetchall():
|
|
156
|
+
metadata = self._metadata_from_row(row) or {}
|
|
157
|
+
# Drop any pre-existing metadata["message_blacklist"]
|
|
158
|
+
# from earlier migration runs that folded it in.
|
|
159
|
+
metadata.pop("message_blacklist", None)
|
|
160
|
+
for key in ("intent_blacklist", "skill_blacklist"):
|
|
161
|
+
raw = row[key]
|
|
162
|
+
if not raw:
|
|
163
|
+
continue
|
|
164
|
+
try:
|
|
165
|
+
legacy = json.loads(raw)
|
|
166
|
+
except (TypeError, ValueError):
|
|
167
|
+
continue
|
|
168
|
+
if legacy and key not in metadata:
|
|
169
|
+
metadata[key] = list(legacy)
|
|
170
|
+
self.conn.execute(
|
|
171
|
+
"UPDATE clients SET intent_blacklist = NULL, "
|
|
172
|
+
"skill_blacklist = NULL, message_blacklist = NULL, "
|
|
173
|
+
"metadata = ? WHERE client_id = ?",
|
|
174
|
+
(self._metadata_to_json(metadata), int(row["client_id"])),
|
|
175
|
+
)
|
|
176
|
+
|
|
101
177
|
def add_item(self, client: Client) -> bool:
|
|
102
178
|
"""
|
|
103
179
|
Add a client to the SQLite database.
|
|
@@ -121,9 +197,14 @@ class SQLiteDB(AbstractDB):
|
|
|
121
197
|
""", (
|
|
122
198
|
client.client_id, client.api_key, client.name, client.description,
|
|
123
199
|
client.is_admin, client.last_seen,
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
200
|
+
# Legacy OVOS blacklist columns are no longer written —
|
|
201
|
+
# the data lives in ``Client.metadata`` (see SCHEMA_VERSION
|
|
202
|
+
# v2 migration). Columns kept in the table for back-compat
|
|
203
|
+
# with older readers; NULLed on write so the disk stays
|
|
204
|
+
# clean.
|
|
205
|
+
None,
|
|
206
|
+
None,
|
|
207
|
+
None,
|
|
127
208
|
json.dumps(client.allowed_types),
|
|
128
209
|
client.crypto_key, client.password,
|
|
129
210
|
client.can_broadcast, client.can_escalate, client.can_propagate,
|
|
@@ -157,6 +238,27 @@ class SQLiteDB(AbstractDB):
|
|
|
157
238
|
LOG.error(f"Failed to search clients in SQLite: {e}")
|
|
158
239
|
return []
|
|
159
240
|
|
|
241
|
+
def get_client_by_id(self, client_id: int) -> Optional[Client]:
|
|
242
|
+
"""Fetch a single client row by primary key.
|
|
243
|
+
|
|
244
|
+
Targeted lookup used by :meth:`refresh` on the admission hot
|
|
245
|
+
path — avoids the full ``search_by_value`` fallback. Returns
|
|
246
|
+
``None`` if the row does not exist or on any DB error.
|
|
247
|
+
"""
|
|
248
|
+
if client_id is None:
|
|
249
|
+
return None
|
|
250
|
+
try:
|
|
251
|
+
cur = self.conn.execute(
|
|
252
|
+
"SELECT * FROM clients WHERE client_id = ?", (int(client_id),),
|
|
253
|
+
)
|
|
254
|
+
row = cur.fetchone()
|
|
255
|
+
if row is None:
|
|
256
|
+
return None
|
|
257
|
+
return self._row_to_client(row)
|
|
258
|
+
except (sqlite3.Error, TypeError, ValueError) as e:
|
|
259
|
+
LOG.error(f"Failed to fetch client {client_id} from SQLite: {e}")
|
|
260
|
+
return None
|
|
261
|
+
|
|
160
262
|
def __len__(self) -> int:
|
|
161
263
|
"""Get the number of clients in the database."""
|
|
162
264
|
try:
|
|
@@ -189,26 +291,44 @@ class SQLiteDB(AbstractDB):
|
|
|
189
291
|
|
|
190
292
|
@staticmethod
|
|
191
293
|
def _row_to_client(row: sqlite3.Row) -> Client:
|
|
192
|
-
"""Convert a database row to a Client instance.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
294
|
+
"""Convert a database row to a Client instance.
|
|
295
|
+
|
|
296
|
+
Legacy OVOS blacklist columns are no longer passed as kwargs to
|
|
297
|
+
``Client(...)`` — after the v2 migration they are NULL on disk
|
|
298
|
+
and the canonical data lives in ``metadata``. If a row predates
|
|
299
|
+
migration (e.g. read by an older plugin version that didn't
|
|
300
|
+
migrate, then read again here), the values are folded into
|
|
301
|
+
``metadata`` locally as a defensive fallback.
|
|
302
|
+
"""
|
|
303
|
+
metadata = SQLiteDB._metadata_from_row(row) or {}
|
|
304
|
+
# message_blacklist was removed from the Client data model — drop
|
|
305
|
+
# any residual metadata key from earlier migrations.
|
|
306
|
+
metadata.pop("message_blacklist", None)
|
|
307
|
+
for key in ("intent_blacklist", "skill_blacklist"):
|
|
308
|
+
raw = row[key] if key in row.keys() else None
|
|
309
|
+
if not raw or key in metadata:
|
|
310
|
+
continue
|
|
311
|
+
try:
|
|
312
|
+
legacy = json.loads(raw)
|
|
313
|
+
except (TypeError, ValueError):
|
|
314
|
+
continue
|
|
315
|
+
if legacy:
|
|
316
|
+
metadata[key] = list(legacy)
|
|
317
|
+
return Client(
|
|
318
|
+
client_id=int(row["client_id"]),
|
|
319
|
+
api_key=row["api_key"],
|
|
320
|
+
name=row["name"],
|
|
321
|
+
description=row["description"],
|
|
322
|
+
is_admin=bool(row["is_admin"]),
|
|
323
|
+
last_seen=row["last_seen"],
|
|
324
|
+
allowed_types=json.loads(row["allowed_types"] or "[]"),
|
|
325
|
+
crypto_key=row["crypto_key"],
|
|
326
|
+
password=row["password"],
|
|
327
|
+
can_broadcast=bool(row["can_broadcast"]),
|
|
328
|
+
can_escalate=bool(row["can_escalate"]),
|
|
329
|
+
can_propagate=bool(row["can_propagate"]),
|
|
330
|
+
metadata=metadata,
|
|
331
|
+
)
|
|
212
332
|
|
|
213
333
|
@staticmethod
|
|
214
334
|
def _metadata_to_json(metadata: object) -> str:
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# START_VERSION_BLOCK
|
|
2
2
|
VERSION_MAJOR = 0
|
|
3
|
-
VERSION_MINOR =
|
|
3
|
+
VERSION_MINOR = 4
|
|
4
4
|
VERSION_BUILD = 0
|
|
5
|
-
VERSION_ALPHA =
|
|
5
|
+
VERSION_ALPHA = 1
|
|
6
6
|
# END_VERSION_BLOCK
|
|
7
7
|
|
|
8
8
|
__version__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}" + (f"a{VERSION_ALPHA}" if VERSION_ALPHA else "")
|
{hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/tests/test_sqlitedb.py
RENAMED
|
@@ -388,7 +388,6 @@ class TestSQLiteDBRoundTrip(unittest.TestCase):
|
|
|
388
388
|
last_seen=1234567890.0,
|
|
389
389
|
intent_blacklist=["a:b"],
|
|
390
390
|
skill_blacklist=["c:d"],
|
|
391
|
-
message_blacklist=["e:f"],
|
|
392
391
|
allowed_types=["recognizer_loop:utterance"],
|
|
393
392
|
crypto_key="1234567890123456",
|
|
394
393
|
password="secret",
|
|
@@ -409,7 +408,18 @@ class TestSQLiteDBRoundTrip(unittest.TestCase):
|
|
|
409
408
|
self.assertEqual(r.crypto_key, "1234567890123456")
|
|
410
409
|
self.assertEqual(r.password, "secret")
|
|
411
410
|
self.assertFalse(r.can_escalate)
|
|
412
|
-
|
|
411
|
+
# After SCHEMA_VERSION=2: legacy skill/intent kwargs auto-migrate
|
|
412
|
+
# into ``Client.metadata`` via Client.__init__. message_blacklist
|
|
413
|
+
# is gone from the data model — not accepted as a kwarg and not
|
|
414
|
+
# carried in metadata.
|
|
415
|
+
self.assertEqual(r.metadata, {
|
|
416
|
+
"owner_id": "owner-123",
|
|
417
|
+
"intent_blacklist": ["a:b"],
|
|
418
|
+
"skill_blacklist": ["c:d"],
|
|
419
|
+
})
|
|
420
|
+
# Property shims surface skill/intent blacklists at legacy names.
|
|
421
|
+
self.assertEqual(r.skill_blacklist, ["c:d"])
|
|
422
|
+
self.assertEqual(r.intent_blacklist, ["a:b"])
|
|
413
423
|
|
|
414
424
|
|
|
415
425
|
class TestSQLiteDBCommit(unittest.TestCase):
|
|
@@ -532,5 +542,324 @@ class TestSQLiteDBMissingCipher(unittest.TestCase):
|
|
|
532
542
|
self.assertIn("sqlcipher3", str(ctx.exception))
|
|
533
543
|
|
|
534
544
|
|
|
545
|
+
class TestSQLiteDBMigration(unittest.TestCase):
|
|
546
|
+
"""v1 -> v2: legacy OVOS blacklist columns folded into metadata."""
|
|
547
|
+
|
|
548
|
+
def _make_v1_db_with_legacy_rows(self) -> SQLiteDB:
|
|
549
|
+
"""Construct a DB in the v1 shape: legacy columns populated,
|
|
550
|
+
``PRAGMA user_version`` left at 0 (the SQLite default)."""
|
|
551
|
+
db = object.__new__(SQLiteDB)
|
|
552
|
+
db.name = "clients"
|
|
553
|
+
db.subfolder = "hivemind-core"
|
|
554
|
+
db.conn = sqlite3.connect(":memory:", check_same_thread=False)
|
|
555
|
+
db.conn.row_factory = sqlite3.Row
|
|
556
|
+
db._write_lock = threading.Lock()
|
|
557
|
+
db._initialize_database()
|
|
558
|
+
# Write a row directly with legacy column data — bypass add_item
|
|
559
|
+
# which would NULL them.
|
|
560
|
+
db.conn.execute(
|
|
561
|
+
"INSERT INTO clients (client_id, api_key, intent_blacklist, "
|
|
562
|
+
"skill_blacklist, message_blacklist, allowed_types, metadata) "
|
|
563
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
564
|
+
(7, "legacy-key",
|
|
565
|
+
'["i:1"]', '["s:1"]', '["m:1"]', '[]', '{"owner": "u"}'),
|
|
566
|
+
)
|
|
567
|
+
db.conn.commit()
|
|
568
|
+
return db
|
|
569
|
+
|
|
570
|
+
def test_pragma_user_version_starts_at_zero(self):
|
|
571
|
+
db = self._make_v1_db_with_legacy_rows()
|
|
572
|
+
stored = db.conn.execute("PRAGMA user_version").fetchone()[0]
|
|
573
|
+
self.assertEqual(stored, 0)
|
|
574
|
+
|
|
575
|
+
def test_migrate_folds_legacy_columns_into_metadata(self):
|
|
576
|
+
db = self._make_v1_db_with_legacy_rows()
|
|
577
|
+
db.migrate(from_version=1)
|
|
578
|
+
row = db.conn.execute(
|
|
579
|
+
"SELECT intent_blacklist, skill_blacklist, message_blacklist, "
|
|
580
|
+
"metadata FROM clients WHERE client_id = 7"
|
|
581
|
+
).fetchone()
|
|
582
|
+
self.assertIsNone(row["intent_blacklist"])
|
|
583
|
+
self.assertIsNone(row["skill_blacklist"])
|
|
584
|
+
self.assertIsNone(row["message_blacklist"])
|
|
585
|
+
import json as _json
|
|
586
|
+
meta = _json.loads(row["metadata"])
|
|
587
|
+
self.assertEqual(meta["owner"], "u")
|
|
588
|
+
self.assertEqual(meta["intent_blacklist"], ["i:1"])
|
|
589
|
+
self.assertEqual(meta["skill_blacklist"], ["s:1"])
|
|
590
|
+
# message_blacklist is purged outright, NOT folded into metadata.
|
|
591
|
+
self.assertNotIn("message_blacklist", meta)
|
|
592
|
+
|
|
593
|
+
def test_migrate_purges_residual_metadata_message_blacklist(self):
|
|
594
|
+
"""An older plugin version may have folded message_blacklist
|
|
595
|
+
into metadata before HPM removed the field. The newer migrate()
|
|
596
|
+
must purge it on re-run, leaving the disk clean."""
|
|
597
|
+
db = self._make_v1_db_with_legacy_rows()
|
|
598
|
+
# Seed an already-half-migrated row: legacy columns NULL, but
|
|
599
|
+
# metadata still carries the old key from the prior migration.
|
|
600
|
+
db.conn.execute(
|
|
601
|
+
"UPDATE clients SET intent_blacklist = NULL, "
|
|
602
|
+
"skill_blacklist = NULL, message_blacklist = NULL, "
|
|
603
|
+
"metadata = ? WHERE client_id = 7",
|
|
604
|
+
('{"owner": "u", "message_blacklist": ["m:1"]}',),
|
|
605
|
+
)
|
|
606
|
+
db.conn.commit()
|
|
607
|
+
|
|
608
|
+
db.migrate(from_version=1)
|
|
609
|
+
|
|
610
|
+
import json as _json
|
|
611
|
+
meta = _json.loads(db.conn.execute(
|
|
612
|
+
"SELECT metadata FROM clients WHERE client_id = 7"
|
|
613
|
+
).fetchone()["metadata"])
|
|
614
|
+
self.assertNotIn("message_blacklist", meta)
|
|
615
|
+
self.assertEqual(meta["owner"], "u")
|
|
616
|
+
|
|
617
|
+
def test_migrate_is_idempotent(self):
|
|
618
|
+
db = self._make_v1_db_with_legacy_rows()
|
|
619
|
+
db.migrate(from_version=1)
|
|
620
|
+
db.migrate(from_version=1) # second run = no-op on already-migrated row
|
|
621
|
+
row = db.conn.execute(
|
|
622
|
+
"SELECT metadata FROM clients WHERE client_id = 7"
|
|
623
|
+
).fetchone()
|
|
624
|
+
import json as _json
|
|
625
|
+
meta = _json.loads(row["metadata"])
|
|
626
|
+
self.assertEqual(meta["skill_blacklist"], ["s:1"])
|
|
627
|
+
|
|
628
|
+
def test_migrate_setdefault_does_not_clobber_explicit_metadata(self):
|
|
629
|
+
db = self._make_v1_db_with_legacy_rows()
|
|
630
|
+
# Explicit metadata.skill_blacklist takes precedence over the
|
|
631
|
+
# legacy column.
|
|
632
|
+
db.conn.execute(
|
|
633
|
+
"UPDATE clients SET metadata = ? WHERE client_id = 7",
|
|
634
|
+
('{"owner": "u", "skill_blacklist": ["explicit"]}',),
|
|
635
|
+
)
|
|
636
|
+
db.migrate(from_version=1)
|
|
637
|
+
import json as _json
|
|
638
|
+
meta = _json.loads(db.conn.execute(
|
|
639
|
+
"SELECT metadata FROM clients WHERE client_id = 7"
|
|
640
|
+
).fetchone()["metadata"])
|
|
641
|
+
self.assertEqual(meta["skill_blacklist"], ["explicit"])
|
|
642
|
+
|
|
643
|
+
def test_migrate_skips_when_already_at_target(self):
|
|
644
|
+
db = self._make_v1_db_with_legacy_rows()
|
|
645
|
+
# Stub: a from_version >= 2 should not touch the row.
|
|
646
|
+
db.migrate(from_version=2)
|
|
647
|
+
row = db.conn.execute(
|
|
648
|
+
"SELECT intent_blacklist FROM clients WHERE client_id = 7"
|
|
649
|
+
).fetchone()
|
|
650
|
+
self.assertEqual(row["intent_blacklist"], '["i:1"]')
|
|
651
|
+
|
|
652
|
+
def test_maybe_migrate_bumps_user_version(self):
|
|
653
|
+
db = self._make_v1_db_with_legacy_rows()
|
|
654
|
+
db._maybe_migrate()
|
|
655
|
+
stored = db.conn.execute("PRAGMA user_version").fetchone()[0]
|
|
656
|
+
self.assertEqual(stored, 2)
|
|
657
|
+
# second invocation is a no-op
|
|
658
|
+
db._maybe_migrate()
|
|
659
|
+
self.assertEqual(
|
|
660
|
+
db.conn.execute("PRAGMA user_version").fetchone()[0], 2,
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
def test_post_init_runs_migration_on_existing_db(self):
|
|
664
|
+
"""End-to-end: open a v1 DB file, observe v2 on-disk shape."""
|
|
665
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
666
|
+
db_path = os.path.join(tmp, "hivemind-core", "clients.db")
|
|
667
|
+
os.makedirs(os.path.dirname(db_path))
|
|
668
|
+
# Seed a v1-shape DB with legacy column data.
|
|
669
|
+
seed = sqlite3.connect(db_path)
|
|
670
|
+
seed.execute("""
|
|
671
|
+
CREATE TABLE clients (
|
|
672
|
+
client_id INTEGER PRIMARY KEY,
|
|
673
|
+
api_key VARCHAR(255) NOT NULL,
|
|
674
|
+
name VARCHAR(255),
|
|
675
|
+
description VARCHAR(255),
|
|
676
|
+
is_admin BOOLEAN DEFAULT FALSE,
|
|
677
|
+
last_seen REAL DEFAULT -1,
|
|
678
|
+
intent_blacklist TEXT,
|
|
679
|
+
skill_blacklist TEXT,
|
|
680
|
+
message_blacklist TEXT,
|
|
681
|
+
allowed_types TEXT,
|
|
682
|
+
crypto_key VARCHAR(16),
|
|
683
|
+
password TEXT,
|
|
684
|
+
can_broadcast BOOLEAN DEFAULT TRUE,
|
|
685
|
+
can_escalate BOOLEAN DEFAULT TRUE,
|
|
686
|
+
can_propagate BOOLEAN DEFAULT TRUE,
|
|
687
|
+
metadata TEXT
|
|
688
|
+
)
|
|
689
|
+
""")
|
|
690
|
+
seed.execute(
|
|
691
|
+
"INSERT INTO clients (client_id, api_key, skill_blacklist) "
|
|
692
|
+
"VALUES (?, ?, ?)",
|
|
693
|
+
(9, "k", '["legacy.skill"]'),
|
|
694
|
+
)
|
|
695
|
+
seed.commit()
|
|
696
|
+
seed.close()
|
|
697
|
+
# Now open it via SQLiteDB — __post_init__ should migrate.
|
|
698
|
+
import unittest.mock as mock
|
|
699
|
+
with mock.patch("hivemind_sqlite_database.xdg_data_home",
|
|
700
|
+
return_value=tmp):
|
|
701
|
+
db = SQLiteDB(name="clients", subfolder="hivemind-core")
|
|
702
|
+
self.assertEqual(
|
|
703
|
+
db.conn.execute("PRAGMA user_version").fetchone()[0], 2,
|
|
704
|
+
)
|
|
705
|
+
row = db.conn.execute(
|
|
706
|
+
"SELECT skill_blacklist, metadata FROM clients "
|
|
707
|
+
"WHERE client_id = 9"
|
|
708
|
+
).fetchone()
|
|
709
|
+
self.assertIsNone(row["skill_blacklist"])
|
|
710
|
+
import json as _json
|
|
711
|
+
self.assertEqual(_json.loads(row["metadata"])["skill_blacklist"],
|
|
712
|
+
["legacy.skill"])
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
class TestSQLiteDBEmptyDatabaseMigration(unittest.TestCase):
|
|
716
|
+
"""A fresh DB (no rows, user_version=0) must still bump to the
|
|
717
|
+
current SCHEMA_VERSION on first open. Validates the cotransactional
|
|
718
|
+
migrate + sentinel write."""
|
|
719
|
+
|
|
720
|
+
def test_empty_new_db_bumps_user_version(self):
|
|
721
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
722
|
+
import unittest.mock as mock
|
|
723
|
+
with mock.patch("hivemind_sqlite_database.xdg_data_home",
|
|
724
|
+
return_value=tmp):
|
|
725
|
+
db = SQLiteDB(name="clients", subfolder="hivemind-core")
|
|
726
|
+
stored = db.conn.execute("PRAGMA user_version").fetchone()[0]
|
|
727
|
+
from hivemind_plugin_manager.database import AbstractDB
|
|
728
|
+
target = getattr(AbstractDB, "SCHEMA_VERSION", 1)
|
|
729
|
+
self.assertEqual(stored, target)
|
|
730
|
+
# No rows in the clients table — migration must be a no-op
|
|
731
|
+
# over rows but the sentinel still moves.
|
|
732
|
+
count = db.conn.execute(
|
|
733
|
+
"SELECT COUNT(*) FROM clients"
|
|
734
|
+
).fetchone()[0]
|
|
735
|
+
self.assertEqual(count, 0)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
class TestSQLiteDBForwardCompat(unittest.TestCase):
|
|
739
|
+
"""A DB whose ``user_version`` is newer than this backend supports
|
|
740
|
+
must fail loudly with a RuntimeError instead of silently downgrading.
|
|
741
|
+
"""
|
|
742
|
+
|
|
743
|
+
def test_forward_version_raises_runtime_error(self):
|
|
744
|
+
import unittest.mock as mock
|
|
745
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
746
|
+
with mock.patch("hivemind_sqlite_database.xdg_data_home",
|
|
747
|
+
return_value=tmp):
|
|
748
|
+
db = SQLiteDB(name="clients", subfolder="hivemind-core")
|
|
749
|
+
db.conn.execute("PRAGMA user_version = 999")
|
|
750
|
+
db.conn.commit()
|
|
751
|
+
db.conn.close()
|
|
752
|
+
with self.assertRaises(RuntimeError) as ctx:
|
|
753
|
+
SQLiteDB(name="clients", subfolder="hivemind-core")
|
|
754
|
+
self.assertIn("999", str(ctx.exception))
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
class TestSQLiteDBGetClientByID(unittest.TestCase):
|
|
758
|
+
def test_get_client_by_id_returns_row(self):
|
|
759
|
+
import unittest.mock as mock
|
|
760
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
761
|
+
with mock.patch("hivemind_sqlite_database.xdg_data_home",
|
|
762
|
+
return_value=tmp):
|
|
763
|
+
db = SQLiteDB(name="clients", subfolder="hivemind-core")
|
|
764
|
+
from hivemind_plugin_manager.database import Client
|
|
765
|
+
db.add_item(Client(client_id=42, api_key="k", name="alice"))
|
|
766
|
+
got = db.get_client_by_id(42)
|
|
767
|
+
self.assertIsNotNone(got)
|
|
768
|
+
self.assertEqual(got.client_id, 42)
|
|
769
|
+
self.assertIsNone(db.get_client_by_id(999))
|
|
770
|
+
|
|
771
|
+
def test_refresh_picks_up_updates(self):
|
|
772
|
+
import unittest.mock as mock
|
|
773
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
774
|
+
with mock.patch("hivemind_sqlite_database.xdg_data_home",
|
|
775
|
+
return_value=tmp):
|
|
776
|
+
db = SQLiteDB(name="clients", subfolder="hivemind-core")
|
|
777
|
+
from hivemind_plugin_manager.database import Client
|
|
778
|
+
db.add_item(Client(client_id=1, api_key="k", name="a",
|
|
779
|
+
allowed_types=["x"]))
|
|
780
|
+
self.assertEqual(db.refresh(1).allowed_types, ["x"])
|
|
781
|
+
db.add_item(Client(client_id=1, api_key="k", name="a",
|
|
782
|
+
allowed_types=["y"]))
|
|
783
|
+
self.assertEqual(db.refresh(1).allowed_types, ["y"])
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
class TestSQLiteDBSchemaV2RoundTrip(unittest.TestCase):
|
|
787
|
+
"""v2 schema: allowed_types + skill/intent blacklists (in metadata) survive
|
|
788
|
+
add→search and add→refresh cycles without loss or mutation."""
|
|
789
|
+
|
|
790
|
+
def test_allowed_types_survives_round_trip(self):
|
|
791
|
+
db = make_db()
|
|
792
|
+
allowed = ["recognizer_loop:utterance", "speak:b64_audio"]
|
|
793
|
+
db.add_item(make_client(1, "k", allowed_types=allowed))
|
|
794
|
+
found = db.search_by_value("api_key", "k")
|
|
795
|
+
self.assertEqual(len(found), 1)
|
|
796
|
+
self.assertEqual(found[0].allowed_types, allowed)
|
|
797
|
+
|
|
798
|
+
def test_skill_blacklist_in_metadata_survives_round_trip(self):
|
|
799
|
+
db = make_db()
|
|
800
|
+
c = make_client(2, "k2", metadata={"skill_blacklist": ["my.skill"]})
|
|
801
|
+
db.add_item(c)
|
|
802
|
+
found = db.search_by_value("api_key", "k2")
|
|
803
|
+
self.assertEqual(len(found), 1)
|
|
804
|
+
self.assertEqual(found[0].skill_blacklist, ["my.skill"])
|
|
805
|
+
self.assertEqual(found[0].metadata["skill_blacklist"], ["my.skill"])
|
|
806
|
+
|
|
807
|
+
def test_intent_blacklist_in_metadata_survives_round_trip(self):
|
|
808
|
+
db = make_db()
|
|
809
|
+
c = make_client(3, "k3", metadata={"intent_blacklist": ["my.skill:action"]})
|
|
810
|
+
db.add_item(c)
|
|
811
|
+
found = db.search_by_value("api_key", "k3")
|
|
812
|
+
self.assertEqual(len(found), 1)
|
|
813
|
+
self.assertEqual(found[0].intent_blacklist, ["my.skill:action"])
|
|
814
|
+
self.assertEqual(found[0].metadata["intent_blacklist"], ["my.skill:action"])
|
|
815
|
+
|
|
816
|
+
def test_message_blacklist_not_present_in_stored_record(self):
|
|
817
|
+
"""message_blacklist must not appear in a freshly-stored record."""
|
|
818
|
+
db = make_db()
|
|
819
|
+
db.add_item(make_client(4, "k4"))
|
|
820
|
+
row = db.conn.execute(
|
|
821
|
+
"SELECT message_blacklist, metadata FROM clients WHERE client_id = 4"
|
|
822
|
+
).fetchone()
|
|
823
|
+
self.assertIsNone(row["message_blacklist"])
|
|
824
|
+
import json as _json
|
|
825
|
+
meta = _json.loads(row["metadata"] or "{}")
|
|
826
|
+
self.assertNotIn("message_blacklist", meta)
|
|
827
|
+
|
|
828
|
+
def test_v1_row_reads_cleanly_forward_compat(self):
|
|
829
|
+
"""A v1 row (legacy columns populated) must deserialize via
|
|
830
|
+
_row_to_client without crashing."""
|
|
831
|
+
db = make_db()
|
|
832
|
+
db.conn.execute(
|
|
833
|
+
"INSERT INTO clients (client_id, api_key, skill_blacklist, "
|
|
834
|
+
"intent_blacklist, message_blacklist, allowed_types, metadata) "
|
|
835
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
836
|
+
(5, "k5", '["old.skill"]', '[]', '["drop.me"]',
|
|
837
|
+
'["recognizer_loop:utterance"]', "{}"),
|
|
838
|
+
)
|
|
839
|
+
db.conn.commit()
|
|
840
|
+
found = db.search_by_value("api_key", "k5")
|
|
841
|
+
self.assertEqual(len(found), 1)
|
|
842
|
+
self.assertEqual(found[0].api_key, "k5")
|
|
843
|
+
self.assertEqual(found[0].allowed_types, ["recognizer_loop:utterance"])
|
|
844
|
+
|
|
845
|
+
def test_refresh_returns_v2_fields(self):
|
|
846
|
+
db = make_db()
|
|
847
|
+
import unittest.mock as mock
|
|
848
|
+
import tempfile
|
|
849
|
+
import os
|
|
850
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
851
|
+
with mock.patch("hivemind_sqlite_database.xdg_data_home",
|
|
852
|
+
return_value=tmp):
|
|
853
|
+
filedb = SQLiteDB(name="clients", subfolder="hivemind-core")
|
|
854
|
+
allowed = ["recognizer_loop:utterance"]
|
|
855
|
+
meta = {"skill_blacklist": ["s:1"], "intent_blacklist": ["i:1"]}
|
|
856
|
+
filedb.add_item(make_client(6, "k6", allowed_types=allowed, metadata=meta))
|
|
857
|
+
got = filedb.refresh(6)
|
|
858
|
+
self.assertIsNotNone(got)
|
|
859
|
+
self.assertEqual(got.allowed_types, allowed)
|
|
860
|
+
self.assertEqual(got.skill_blacklist, ["s:1"])
|
|
861
|
+
self.assertEqual(got.intent_blacklist, ["i:1"])
|
|
862
|
+
|
|
863
|
+
|
|
535
864
|
if __name__ == "__main__":
|
|
536
865
|
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|