hivemind-sqlite-database 0.0.3a1__tar.gz → 0.3.0a3__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.0a3/PKG-INFO +15 -0
- hivemind_sqlite_database-0.3.0a3/README.md +76 -0
- hivemind_sqlite_database-0.3.0a3/hivemind_sqlite_database/__init__.py +244 -0
- hivemind_sqlite_database-0.3.0a3/hivemind_sqlite_database/version.py +8 -0
- hivemind_sqlite_database-0.3.0a3/hivemind_sqlite_database.egg-info/PKG-INFO +15 -0
- {hivemind-sqlite-database-0.0.3a1 → hivemind_sqlite_database-0.3.0a3}/hivemind_sqlite_database.egg-info/SOURCES.txt +3 -2
- hivemind_sqlite_database-0.3.0a3/hivemind_sqlite_database.egg-info/entry_points.txt +2 -0
- hivemind_sqlite_database-0.3.0a3/hivemind_sqlite_database.egg-info/requires.txt +6 -0
- hivemind_sqlite_database-0.3.0a3/pyproject.toml +36 -0
- hivemind_sqlite_database-0.3.0a3/tests/test_sqlitedb.py +536 -0
- hivemind-sqlite-database-0.0.3a1/PKG-INFO +0 -10
- hivemind-sqlite-database-0.0.3a1/README.md +0 -1
- hivemind-sqlite-database-0.0.3a1/hivemind_sqlite_database/__init__.py +0 -155
- hivemind-sqlite-database-0.0.3a1/hivemind_sqlite_database/version.py +0 -6
- hivemind-sqlite-database-0.0.3a1/hivemind_sqlite_database.egg-info/PKG-INFO +0 -10
- hivemind-sqlite-database-0.0.3a1/hivemind_sqlite_database.egg-info/entry_points.txt +0 -3
- hivemind-sqlite-database-0.0.3a1/hivemind_sqlite_database.egg-info/requires.txt +0 -1
- hivemind-sqlite-database-0.0.3a1/setup.py +0 -55
- {hivemind-sqlite-database-0.0.3a1 → hivemind_sqlite_database-0.3.0a3}/LICENSE.md +0 -0
- {hivemind-sqlite-database-0.0.3a1 → hivemind_sqlite_database-0.3.0a3}/hivemind_sqlite_database.egg-info/dependency_links.txt +0 -0
- {hivemind-sqlite-database-0.0.3a1 → hivemind_sqlite_database-0.3.0a3}/hivemind_sqlite_database.egg-info/top_level.txt +0 -0
- {hivemind-sqlite-database-0.0.3a1 → hivemind_sqlite_database-0.3.0a3}/setup.cfg +0 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hivemind-sqlite-database
|
|
3
|
+
Version: 0.3.0a3
|
|
4
|
+
Summary: sqlite database plugin for hivemind-core
|
|
5
|
+
Author-email: jarbasAi <jarbasai@mailfence.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/JarbasHiveMind/hivemind-sqlite-database
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
License-File: LICENSE.md
|
|
10
|
+
Requires-Dist: hivemind-plugin-manager<1.0.0,>=0.5.0
|
|
11
|
+
Requires-Dist: hivemind-bus-client
|
|
12
|
+
Requires-Dist: ovos-utils
|
|
13
|
+
Provides-Extra: cipher
|
|
14
|
+
Requires-Dist: sqlcipher3; extra == "cipher"
|
|
15
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# HiveMind SQLite Database
|
|
2
|
+
|
|
3
|
+
SQLite database plugin for [hivemind-core](https://github.com/JarbasHiveMind/HiveMind-core).
|
|
4
|
+
|
|
5
|
+
Implements the `AbstractDB` interface via `hivemind-plugin-manager` and stores HiveMind
|
|
6
|
+
client records (API keys, crypto keys, access-control lists) in a local SQLite file.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install hivemind-sqlite-database
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
### With encryption support (SQLCipher)
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# 1. Install the SQLCipher system library
|
|
18
|
+
# Debian/Ubuntu:
|
|
19
|
+
sudo apt install libsqlcipher0
|
|
20
|
+
|
|
21
|
+
# 2. Install the Python binding via the optional extra
|
|
22
|
+
pip install "hivemind-sqlite-database[cipher]"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
> The `sqlcipher3` wheel on PyPI ships its own libsqlcipher for x86_64 Linux, so the
|
|
26
|
+
> `apt` step may be optional on that platform. On ARM or Alpine you must build from
|
|
27
|
+
> source and will need the system library.
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
### Plain (unencrypted) database — default
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from hivemind_sqlite_database import SQLiteDB
|
|
35
|
+
|
|
36
|
+
db = SQLiteDB() # stores data in XDG_DATA_HOME/hivemind-core/clients.db
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Encrypted database (SQLCipher / AES-256)
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from hivemind_sqlite_database import SQLiteDB
|
|
43
|
+
|
|
44
|
+
db = SQLiteDB(password="your-strong-passphrase")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Pass the same `password` every time you open the database. The encryption is
|
|
48
|
+
transparent — all existing methods (`add_item`, `search_by_value`, etc.) work
|
|
49
|
+
identically.
|
|
50
|
+
|
|
51
|
+
> **Data-loss warning**: There is no password recovery. If you lose the passphrase
|
|
52
|
+
> the database is permanently unrecoverable. Back up your passphrase securely.
|
|
53
|
+
|
|
54
|
+
### hivemind-core configuration
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"database": {
|
|
59
|
+
"module": "hivemind-sqlite-db-plugin",
|
|
60
|
+
"hivemind-sqlite-db-plugin": {
|
|
61
|
+
"name": "clients",
|
|
62
|
+
"subfolder": "hivemind-core",
|
|
63
|
+
"password": "your-strong-passphrase"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Leave `"password"` out (or set it to `null`) to use an unencrypted database.
|
|
70
|
+
|
|
71
|
+
## Notes
|
|
72
|
+
|
|
73
|
+
- An encrypted database cannot be opened by the plain `sqlite3` CLI or stdlib module.
|
|
74
|
+
- A plaintext database cannot be opened as encrypted. There is no automatic migration.
|
|
75
|
+
- The `password` field maps directly to SQLCipher's `PRAGMA key`.
|
|
76
|
+
- WAL journal mode is enabled for both encrypted and unencrypted databases.
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os.path
|
|
3
|
+
import sqlite3
|
|
4
|
+
import threading
|
|
5
|
+
from typing import List, Optional, Union, Iterable
|
|
6
|
+
|
|
7
|
+
from ovos_utils.log import LOG
|
|
8
|
+
from ovos_utils.xdg_utils import xdg_data_home
|
|
9
|
+
|
|
10
|
+
from hivemind_plugin_manager.database import Client, AbstractDB
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_VALID_COLUMNS = frozenset({
|
|
16
|
+
"client_id", "api_key", "name", "description", "is_admin",
|
|
17
|
+
"last_seen", "intent_blacklist", "skill_blacklist", "message_blacklist",
|
|
18
|
+
"allowed_types", "crypto_key", "password",
|
|
19
|
+
"can_broadcast", "can_escalate", "can_propagate", "metadata",
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class SQLiteDB(AbstractDB):
|
|
25
|
+
"""Database implementation using SQLite."""
|
|
26
|
+
name: str = "clients"
|
|
27
|
+
subfolder: str = "hivemind-core"
|
|
28
|
+
password: Optional[str] = None
|
|
29
|
+
|
|
30
|
+
def __post_init__(self):
|
|
31
|
+
"""
|
|
32
|
+
Initialize the SQLiteDB connection.
|
|
33
|
+
|
|
34
|
+
When *password* is set the database is opened via ``sqlcipher3`` and
|
|
35
|
+
encrypted with AES-256 (SQLCipher). The system library
|
|
36
|
+
``libsqlcipher0`` must be installed and ``sqlcipher3`` must be
|
|
37
|
+
available (``pip install hivemind-sqlite-database[cipher]``).
|
|
38
|
+
|
|
39
|
+
When *password* is ``None`` (default) the standard ``sqlite3`` module
|
|
40
|
+
is used and the database file is unencrypted.
|
|
41
|
+
"""
|
|
42
|
+
db_path = os.path.join(xdg_data_home(), self.subfolder, self.name + ".db")
|
|
43
|
+
LOG.debug(f"sqlite database path: {db_path}")
|
|
44
|
+
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
|
45
|
+
|
|
46
|
+
if self.password is not None:
|
|
47
|
+
if self.password == "":
|
|
48
|
+
raise ValueError("password must be non-empty when encryption is enabled")
|
|
49
|
+
try:
|
|
50
|
+
import sqlcipher3 as _sqlcipher
|
|
51
|
+
except ImportError:
|
|
52
|
+
raise ImportError(
|
|
53
|
+
"sqlcipher3 is required to open an encrypted SQLite database. "
|
|
54
|
+
"Install the system library (e.g. 'apt install libsqlcipher-dev') "
|
|
55
|
+
"then: pip install hivemind-sqlite-database[cipher]"
|
|
56
|
+
)
|
|
57
|
+
self.conn = _sqlcipher.connect(db_path, check_same_thread=False)
|
|
58
|
+
self.conn.row_factory = _sqlcipher.Row
|
|
59
|
+
escaped_password = self.password.replace("'", "''")
|
|
60
|
+
self.conn.execute(f"PRAGMA key='{escaped_password}'")
|
|
61
|
+
else:
|
|
62
|
+
self.conn = sqlite3.connect(db_path, check_same_thread=False)
|
|
63
|
+
self.conn.row_factory = sqlite3.Row
|
|
64
|
+
|
|
65
|
+
self.conn.execute("PRAGMA journal_mode=WAL")
|
|
66
|
+
self._write_lock = threading.Lock()
|
|
67
|
+
self._initialize_database()
|
|
68
|
+
|
|
69
|
+
def _initialize_database(self):
|
|
70
|
+
"""Initialize the database schema."""
|
|
71
|
+
with self.conn:
|
|
72
|
+
# crypto key is always 16 chars
|
|
73
|
+
# name description and api_key shouldnt be allowed to go over 255
|
|
74
|
+
self.conn.execute("""
|
|
75
|
+
CREATE TABLE IF NOT EXISTS clients (
|
|
76
|
+
client_id INTEGER PRIMARY KEY,
|
|
77
|
+
api_key VARCHAR(255) NOT NULL,
|
|
78
|
+
name VARCHAR(255),
|
|
79
|
+
description VARCHAR(255),
|
|
80
|
+
is_admin BOOLEAN DEFAULT FALSE,
|
|
81
|
+
last_seen REAL DEFAULT -1,
|
|
82
|
+
intent_blacklist TEXT,
|
|
83
|
+
skill_blacklist TEXT,
|
|
84
|
+
message_blacklist TEXT,
|
|
85
|
+
allowed_types TEXT,
|
|
86
|
+
crypto_key VARCHAR(16),
|
|
87
|
+
password TEXT,
|
|
88
|
+
can_broadcast BOOLEAN DEFAULT TRUE,
|
|
89
|
+
can_escalate BOOLEAN DEFAULT TRUE,
|
|
90
|
+
can_propagate BOOLEAN DEFAULT TRUE,
|
|
91
|
+
metadata TEXT
|
|
92
|
+
)
|
|
93
|
+
""")
|
|
94
|
+
columns = {
|
|
95
|
+
row["name"]
|
|
96
|
+
for row in self.conn.execute("PRAGMA table_info(clients)").fetchall()
|
|
97
|
+
}
|
|
98
|
+
if "metadata" not in columns:
|
|
99
|
+
self.conn.execute("ALTER TABLE clients ADD COLUMN metadata TEXT")
|
|
100
|
+
|
|
101
|
+
def add_item(self, client: Client) -> bool:
|
|
102
|
+
"""
|
|
103
|
+
Add a client to the SQLite database.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
client: The client to be added.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
True if the addition was successful, False otherwise.
|
|
110
|
+
"""
|
|
111
|
+
try:
|
|
112
|
+
metadata_json = self._metadata_to_json(client.metadata or {})
|
|
113
|
+
with self._write_lock, self.conn:
|
|
114
|
+
self.conn.execute("""
|
|
115
|
+
INSERT OR REPLACE INTO clients (
|
|
116
|
+
client_id, api_key, name, description, is_admin,
|
|
117
|
+
last_seen, intent_blacklist, skill_blacklist,
|
|
118
|
+
message_blacklist, allowed_types, crypto_key, password,
|
|
119
|
+
can_broadcast, can_escalate, can_propagate, metadata
|
|
120
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
121
|
+
""", (
|
|
122
|
+
client.client_id, client.api_key, client.name, client.description,
|
|
123
|
+
client.is_admin, client.last_seen,
|
|
124
|
+
json.dumps(client.intent_blacklist),
|
|
125
|
+
json.dumps(client.skill_blacklist),
|
|
126
|
+
json.dumps(client.message_blacklist),
|
|
127
|
+
json.dumps(client.allowed_types),
|
|
128
|
+
client.crypto_key, client.password,
|
|
129
|
+
client.can_broadcast, client.can_escalate, client.can_propagate,
|
|
130
|
+
metadata_json,
|
|
131
|
+
))
|
|
132
|
+
return True
|
|
133
|
+
except sqlite3.Error as e:
|
|
134
|
+
LOG.error(f"Failed to add client to SQLite: {e}")
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
def search_by_value(self, key: str, val: Union[str, bool, int, float]) -> List[Client]:
|
|
138
|
+
"""
|
|
139
|
+
Search for clients by a specific key-value pair in the SQLite database.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
key: The key to search by.
|
|
143
|
+
val: The value to search for.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
A list of clients that match the search criteria.
|
|
147
|
+
"""
|
|
148
|
+
if key not in _VALID_COLUMNS:
|
|
149
|
+
LOG.error(f"Invalid search key: {key!r}")
|
|
150
|
+
return []
|
|
151
|
+
try:
|
|
152
|
+
with self.conn:
|
|
153
|
+
cur = self.conn.execute(f"SELECT * FROM clients WHERE {key} = ?", (val,))
|
|
154
|
+
rows = cur.fetchall()
|
|
155
|
+
return [self._row_to_client(row) for row in rows]
|
|
156
|
+
except sqlite3.Error as e:
|
|
157
|
+
LOG.error(f"Failed to search clients in SQLite: {e}")
|
|
158
|
+
return []
|
|
159
|
+
|
|
160
|
+
def __len__(self) -> int:
|
|
161
|
+
"""Get the number of clients in the database."""
|
|
162
|
+
try:
|
|
163
|
+
cur = self.conn.execute("SELECT COUNT(*) FROM clients")
|
|
164
|
+
return cur.fetchone()[0]
|
|
165
|
+
except sqlite3.Error as e:
|
|
166
|
+
LOG.error(f"Failed to count clients in SQLite: {e}")
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
def __iter__(self) -> Iterable['Client']:
|
|
170
|
+
"""
|
|
171
|
+
Iterate over all clients in the SQLite database.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
An iterator over the clients in the database.
|
|
175
|
+
"""
|
|
176
|
+
cur = self.conn.execute("SELECT * FROM clients")
|
|
177
|
+
for row in cur:
|
|
178
|
+
yield self._row_to_client(row)
|
|
179
|
+
|
|
180
|
+
def commit(self) -> bool:
|
|
181
|
+
"""Commit changes to the SQLite database."""
|
|
182
|
+
try:
|
|
183
|
+
with self._write_lock:
|
|
184
|
+
self.conn.commit()
|
|
185
|
+
return True
|
|
186
|
+
except sqlite3.Error as e:
|
|
187
|
+
LOG.error(f"Failed to commit SQLite database: {e}")
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _row_to_client(row: sqlite3.Row) -> Client:
|
|
192
|
+
"""Convert a database row to a Client instance."""
|
|
193
|
+
kwargs = {
|
|
194
|
+
"client_id": int(row["client_id"]),
|
|
195
|
+
"api_key": row["api_key"],
|
|
196
|
+
"name": row["name"],
|
|
197
|
+
"description": row["description"],
|
|
198
|
+
"is_admin": bool(row["is_admin"]),
|
|
199
|
+
"last_seen": row["last_seen"],
|
|
200
|
+
"intent_blacklist": json.loads(row["intent_blacklist"] or "[]"),
|
|
201
|
+
"skill_blacklist": json.loads(row["skill_blacklist"] or "[]"),
|
|
202
|
+
"message_blacklist": json.loads(row["message_blacklist"] or "[]"),
|
|
203
|
+
"allowed_types": json.loads(row["allowed_types"] or "[]"),
|
|
204
|
+
"crypto_key": row["crypto_key"],
|
|
205
|
+
"password": row["password"],
|
|
206
|
+
"can_broadcast": bool(row["can_broadcast"]),
|
|
207
|
+
"can_escalate": bool(row["can_escalate"]),
|
|
208
|
+
"can_propagate": bool(row["can_propagate"]),
|
|
209
|
+
"metadata": SQLiteDB._metadata_from_row(row),
|
|
210
|
+
}
|
|
211
|
+
return Client(**kwargs)
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
def _metadata_to_json(metadata: object) -> str:
|
|
215
|
+
"""Serialize ``Client.metadata`` for storage in the ``metadata`` column.
|
|
216
|
+
|
|
217
|
+
``metadata`` is documented as a free-form dict; we use ``default=str``
|
|
218
|
+
so callers can stash convenience values like ``datetime`` or ``UUID``
|
|
219
|
+
without crashing on insert. Note that these come back as strings on
|
|
220
|
+
read — the column is opaque JSON, not a typed map. Returns ``"{}"``
|
|
221
|
+
for non-dict input and on any (unexpected) serialisation failure
|
|
222
|
+
rather than corrupting the row.
|
|
223
|
+
"""
|
|
224
|
+
if not isinstance(metadata, dict):
|
|
225
|
+
return "{}"
|
|
226
|
+
try:
|
|
227
|
+
return json.dumps(metadata, default=str)
|
|
228
|
+
except (TypeError, ValueError):
|
|
229
|
+
return "{}"
|
|
230
|
+
|
|
231
|
+
@staticmethod
|
|
232
|
+
def _metadata_from_row(row: sqlite3.Row) -> dict:
|
|
233
|
+
"""Decode the ``metadata`` column into a dict, swallowing garbage.
|
|
234
|
+
|
|
235
|
+
Returns ``{}`` for NULL, malformed JSON, or valid-JSON-that-isn't-an-object.
|
|
236
|
+
Lets a single bad row not poison iteration over the table.
|
|
237
|
+
"""
|
|
238
|
+
if "metadata" not in row.keys() or not row["metadata"]:
|
|
239
|
+
return {}
|
|
240
|
+
try:
|
|
241
|
+
metadata = json.loads(row["metadata"])
|
|
242
|
+
except (TypeError, ValueError):
|
|
243
|
+
return {}
|
|
244
|
+
return metadata if isinstance(metadata, dict) else {}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hivemind-sqlite-database
|
|
3
|
+
Version: 0.3.0a3
|
|
4
|
+
Summary: sqlite database plugin for hivemind-core
|
|
5
|
+
Author-email: jarbasAi <jarbasai@mailfence.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/JarbasHiveMind/hivemind-sqlite-database
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
License-File: LICENSE.md
|
|
10
|
+
Requires-Dist: hivemind-plugin-manager<1.0.0,>=0.5.0
|
|
11
|
+
Requires-Dist: hivemind-bus-client
|
|
12
|
+
Requires-Dist: ovos-utils
|
|
13
|
+
Provides-Extra: cipher
|
|
14
|
+
Requires-Dist: sqlcipher3; extra == "cipher"
|
|
15
|
+
Dynamic: license-file
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
LICENSE.md
|
|
2
2
|
README.md
|
|
3
|
-
|
|
3
|
+
pyproject.toml
|
|
4
4
|
hivemind_sqlite_database/__init__.py
|
|
5
5
|
hivemind_sqlite_database/version.py
|
|
6
6
|
hivemind_sqlite_database.egg-info/PKG-INFO
|
|
@@ -8,4 +8,5 @@ hivemind_sqlite_database.egg-info/SOURCES.txt
|
|
|
8
8
|
hivemind_sqlite_database.egg-info/dependency_links.txt
|
|
9
9
|
hivemind_sqlite_database.egg-info/entry_points.txt
|
|
10
10
|
hivemind_sqlite_database.egg-info/requires.txt
|
|
11
|
-
hivemind_sqlite_database.egg-info/top_level.txt
|
|
11
|
+
hivemind_sqlite_database.egg-info/top_level.txt
|
|
12
|
+
tests/test_sqlitedb.py
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hivemind-sqlite-database"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "sqlite database plugin for hivemind-core"
|
|
9
|
+
license = { text = "Apache-2.0" }
|
|
10
|
+
authors = [{ name = "jarbasAi", email = "jarbasai@mailfence.com" }]
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"hivemind-plugin-manager>=0.5.0,<1.0.0",
|
|
14
|
+
"hivemind-bus-client",
|
|
15
|
+
"ovos-utils",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
cipher = ["sqlcipher3"]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/JarbasHiveMind/hivemind-sqlite-database"
|
|
24
|
+
|
|
25
|
+
[project.entry-points."hivemind.database"]
|
|
26
|
+
"hivemind-sqlite-db-plugin" = "hivemind_sqlite_database:SQLiteDB"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.dynamic]
|
|
29
|
+
version = {attr = "hivemind_sqlite_database.version.__version__"}
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
where = ["."]
|
|
33
|
+
include = ["hivemind_sqlite_database*"]
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for SQLiteDB using an in-memory database.
|
|
3
|
+
No external services required.
|
|
4
|
+
"""
|
|
5
|
+
import sqlite3
|
|
6
|
+
import tempfile
|
|
7
|
+
import threading
|
|
8
|
+
import os
|
|
9
|
+
import unittest
|
|
10
|
+
|
|
11
|
+
from hivemind_plugin_manager.database import Client
|
|
12
|
+
from hivemind_sqlite_database import SQLiteDB
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def make_db() -> SQLiteDB:
|
|
16
|
+
"""Return a fresh in-memory SQLiteDB instance."""
|
|
17
|
+
db = object.__new__(SQLiteDB)
|
|
18
|
+
db.name = "clients"
|
|
19
|
+
db.subfolder = "hivemind-core"
|
|
20
|
+
db.conn = sqlite3.connect(":memory:", check_same_thread=False)
|
|
21
|
+
db.conn.row_factory = sqlite3.Row
|
|
22
|
+
db.conn.execute("PRAGMA journal_mode=WAL")
|
|
23
|
+
db._write_lock = threading.Lock()
|
|
24
|
+
db._initialize_database()
|
|
25
|
+
return db
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def make_client(client_id: int = 1, api_key: str = "key-abc", **kwargs) -> Client:
|
|
29
|
+
return Client(client_id=client_id, api_key=api_key, **kwargs)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestSQLiteDBWAL(unittest.TestCase):
|
|
33
|
+
def test_wal_mode_pragma_in_source(self):
|
|
34
|
+
"""WAL pragma must be present in __post_init__ source code."""
|
|
35
|
+
import inspect
|
|
36
|
+
from hivemind_sqlite_database import SQLiteDB as _DB
|
|
37
|
+
src = inspect.getsource(_DB.__post_init__)
|
|
38
|
+
self.assertIn("journal_mode=WAL", src)
|
|
39
|
+
|
|
40
|
+
def test_wal_mode_active_on_file_db(self):
|
|
41
|
+
"""WAL mode is confirmed active on a real file-based SQLite DB."""
|
|
42
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
43
|
+
path = f.name
|
|
44
|
+
try:
|
|
45
|
+
conn = sqlite3.connect(path, check_same_thread=False)
|
|
46
|
+
conn.row_factory = sqlite3.Row
|
|
47
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
48
|
+
cur = conn.execute("PRAGMA journal_mode")
|
|
49
|
+
mode = cur.fetchone()[0]
|
|
50
|
+
conn.close()
|
|
51
|
+
self.assertEqual(mode, "wal")
|
|
52
|
+
finally:
|
|
53
|
+
os.unlink(path)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TestSQLiteDBLen(unittest.TestCase):
|
|
57
|
+
def test_empty_db_has_len_zero(self):
|
|
58
|
+
db = make_db()
|
|
59
|
+
self.assertEqual(len(db), 0)
|
|
60
|
+
|
|
61
|
+
def test_len_increments_after_add(self):
|
|
62
|
+
db = make_db()
|
|
63
|
+
db.add_item(make_client(1, "k1"))
|
|
64
|
+
db.add_item(make_client(2, "k2"))
|
|
65
|
+
self.assertEqual(len(db), 2)
|
|
66
|
+
|
|
67
|
+
def test_len_includes_revoked_entries(self):
|
|
68
|
+
db = make_db()
|
|
69
|
+
c = make_client(1, "k1")
|
|
70
|
+
db.add_item(c)
|
|
71
|
+
db.delete_item(c)
|
|
72
|
+
self.assertEqual(len(db), 1)
|
|
73
|
+
|
|
74
|
+
def test_len_returns_zero_on_error(self):
|
|
75
|
+
db = make_db()
|
|
76
|
+
db.add_item(make_client(1, "k1"))
|
|
77
|
+
db.conn.close()
|
|
78
|
+
self.assertEqual(len(db), 0)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TestSQLiteDBAddItem(unittest.TestCase):
|
|
82
|
+
def test_add_returns_true_on_success(self):
|
|
83
|
+
db = make_db()
|
|
84
|
+
result = db.add_item(make_client(1, "key"))
|
|
85
|
+
self.assertTrue(result)
|
|
86
|
+
|
|
87
|
+
def test_add_upserts_existing_client_id(self):
|
|
88
|
+
db = make_db()
|
|
89
|
+
db.add_item(make_client(1, "original"))
|
|
90
|
+
db.add_item(make_client(1, "updated"))
|
|
91
|
+
results = db.search_by_value("api_key", "updated")
|
|
92
|
+
self.assertEqual(len(results), 1)
|
|
93
|
+
self.assertEqual(results[0].api_key, "updated")
|
|
94
|
+
# original key is gone
|
|
95
|
+
self.assertEqual(db.search_by_value("api_key", "original"), [])
|
|
96
|
+
|
|
97
|
+
def test_add_returns_false_on_error(self):
|
|
98
|
+
db = make_db()
|
|
99
|
+
db.conn.close() # closing the connection causes sqlite3.Error on next operation
|
|
100
|
+
result = db.add_item(make_client(1, "key"))
|
|
101
|
+
self.assertFalse(result)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TestSQLiteDBDeleteItem(unittest.TestCase):
|
|
105
|
+
def test_delete_sets_api_key_to_revoked(self):
|
|
106
|
+
db = make_db()
|
|
107
|
+
c = make_client(1, "live-key")
|
|
108
|
+
db.add_item(c)
|
|
109
|
+
db.delete_item(c)
|
|
110
|
+
results = db.search_by_value("api_key", "revoked")
|
|
111
|
+
self.assertEqual(len(results), 1)
|
|
112
|
+
self.assertEqual(results[0].client_id, 1)
|
|
113
|
+
|
|
114
|
+
def test_delete_does_not_remove_row(self):
|
|
115
|
+
db = make_db()
|
|
116
|
+
c = make_client(1, "live-key")
|
|
117
|
+
db.add_item(c)
|
|
118
|
+
db.delete_item(c)
|
|
119
|
+
self.assertEqual(len(db), 1)
|
|
120
|
+
|
|
121
|
+
def test_original_key_no_longer_searchable_after_delete(self):
|
|
122
|
+
db = make_db()
|
|
123
|
+
c = make_client(1, "live-key")
|
|
124
|
+
db.add_item(c)
|
|
125
|
+
db.delete_item(c)
|
|
126
|
+
self.assertEqual(db.search_by_value("api_key", "live-key"), [])
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class TestSQLiteDBSearchByValue(unittest.TestCase):
|
|
130
|
+
def test_search_by_api_key_returns_client(self):
|
|
131
|
+
db = make_db()
|
|
132
|
+
db.add_item(make_client(1, "my-key"))
|
|
133
|
+
results = db.search_by_value("api_key", "my-key")
|
|
134
|
+
self.assertEqual(len(results), 1)
|
|
135
|
+
self.assertEqual(results[0].api_key, "my-key")
|
|
136
|
+
|
|
137
|
+
def test_search_returns_empty_list_on_no_match(self):
|
|
138
|
+
db = make_db()
|
|
139
|
+
db.add_item(make_client(1, "real-key"))
|
|
140
|
+
self.assertEqual(db.search_by_value("api_key", "ghost"), [])
|
|
141
|
+
|
|
142
|
+
def test_search_by_is_admin_true(self):
|
|
143
|
+
db = make_db()
|
|
144
|
+
db.add_item(make_client(1, "admin-key", is_admin=True))
|
|
145
|
+
db.add_item(make_client(2, "user-key", is_admin=False))
|
|
146
|
+
results = db.search_by_value("is_admin", True)
|
|
147
|
+
self.assertEqual(len(results), 1)
|
|
148
|
+
self.assertTrue(results[0].is_admin)
|
|
149
|
+
|
|
150
|
+
def test_search_by_is_admin_false(self):
|
|
151
|
+
db = make_db()
|
|
152
|
+
db.add_item(make_client(1, "admin-key", is_admin=True))
|
|
153
|
+
db.add_item(make_client(2, "user-key", is_admin=False))
|
|
154
|
+
results = db.search_by_value("is_admin", False)
|
|
155
|
+
self.assertEqual(len(results), 1)
|
|
156
|
+
self.assertFalse(results[0].is_admin)
|
|
157
|
+
|
|
158
|
+
def test_search_by_name(self):
|
|
159
|
+
db = make_db()
|
|
160
|
+
db.add_item(make_client(1, "k1", name="alice"))
|
|
161
|
+
db.add_item(make_client(2, "k2", name="bob"))
|
|
162
|
+
results = db.search_by_value("name", "alice")
|
|
163
|
+
self.assertEqual(len(results), 1)
|
|
164
|
+
self.assertEqual(results[0].name, "alice")
|
|
165
|
+
|
|
166
|
+
def test_search_by_nonexistent_name_returns_empty(self):
|
|
167
|
+
db = make_db()
|
|
168
|
+
db.add_item(make_client(1, "k1", name="alice"))
|
|
169
|
+
self.assertEqual(db.search_by_value("name", "ghost"), [])
|
|
170
|
+
|
|
171
|
+
def test_search_returns_empty_on_sqlite_error(self):
|
|
172
|
+
db = make_db()
|
|
173
|
+
db.add_item(make_client(1, "k1"))
|
|
174
|
+
db.conn.close()
|
|
175
|
+
results = db.search_by_value("api_key", "k1")
|
|
176
|
+
self.assertEqual(results, [])
|
|
177
|
+
|
|
178
|
+
def test_search_rejects_invalid_column_name(self):
|
|
179
|
+
db = make_db()
|
|
180
|
+
db.add_item(make_client(1, "k1"))
|
|
181
|
+
results = db.search_by_value("1=1; DROP TABLE clients; --", "x")
|
|
182
|
+
self.assertEqual(results, [])
|
|
183
|
+
# table must still exist
|
|
184
|
+
self.assertEqual(len(db), 1)
|
|
185
|
+
|
|
186
|
+
def test_search_rejects_unknown_column(self):
|
|
187
|
+
db = make_db()
|
|
188
|
+
results = db.search_by_value("nonexistent_column", "value")
|
|
189
|
+
self.assertEqual(results, [])
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class TestSQLiteDBIter(unittest.TestCase):
|
|
193
|
+
def test_iter_yields_all_rows(self):
|
|
194
|
+
db = make_db()
|
|
195
|
+
db.add_item(make_client(1, "k1"))
|
|
196
|
+
db.add_item(make_client(2, "k2"))
|
|
197
|
+
all_clients = list(db)
|
|
198
|
+
self.assertEqual(len(all_clients), 2)
|
|
199
|
+
|
|
200
|
+
def test_iter_includes_revoked_entries(self):
|
|
201
|
+
db = make_db()
|
|
202
|
+
c = make_client(1, "k1")
|
|
203
|
+
db.add_item(c)
|
|
204
|
+
db.delete_item(c)
|
|
205
|
+
all_clients = list(db)
|
|
206
|
+
self.assertEqual(len(all_clients), 1)
|
|
207
|
+
self.assertEqual(all_clients[0].api_key, "revoked")
|
|
208
|
+
|
|
209
|
+
def test_iter_yields_client_instances(self):
|
|
210
|
+
db = make_db()
|
|
211
|
+
db.add_item(make_client(1, "k1"))
|
|
212
|
+
for client in db:
|
|
213
|
+
self.assertIsInstance(client, Client)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class TestSQLiteDBRoundTrip(unittest.TestCase):
|
|
217
|
+
def test_list_fields_survive_round_trip(self):
|
|
218
|
+
db = make_db()
|
|
219
|
+
intent_bl = ["skill:action1", "skill:action2"]
|
|
220
|
+
allowed = ["recognizer_loop:utterance", "speak:b64_audio"]
|
|
221
|
+
c = make_client(1, "k1",
|
|
222
|
+
intent_blacklist=intent_bl,
|
|
223
|
+
allowed_types=allowed)
|
|
224
|
+
db.add_item(c)
|
|
225
|
+
results = db.search_by_value("api_key", "k1")
|
|
226
|
+
self.assertEqual(len(results), 1)
|
|
227
|
+
self.assertEqual(results[0].intent_blacklist, intent_bl)
|
|
228
|
+
self.assertEqual(results[0].allowed_types, allowed)
|
|
229
|
+
|
|
230
|
+
def test_boolean_fields_remain_bool_after_round_trip(self):
|
|
231
|
+
db = make_db()
|
|
232
|
+
db.add_item(make_client(1, "k1", is_admin=True, can_broadcast=False))
|
|
233
|
+
results = db.search_by_value("api_key", "k1")
|
|
234
|
+
self.assertIs(type(results[0].is_admin), bool)
|
|
235
|
+
self.assertTrue(results[0].is_admin)
|
|
236
|
+
self.assertIs(type(results[0].can_broadcast), bool)
|
|
237
|
+
self.assertFalse(results[0].can_broadcast)
|
|
238
|
+
|
|
239
|
+
def test_metadata_survives_round_trip(self):
|
|
240
|
+
db = make_db()
|
|
241
|
+
db.add_item(
|
|
242
|
+
make_client(
|
|
243
|
+
1,
|
|
244
|
+
"k1",
|
|
245
|
+
metadata={"owner_id": "owner-123"},
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
results = db.search_by_value("api_key", "k1")
|
|
250
|
+
|
|
251
|
+
self.assertEqual(len(results), 1)
|
|
252
|
+
self.assertEqual(results[0].metadata, {"owner_id": "owner-123"})
|
|
253
|
+
|
|
254
|
+
def test_metadata_can_be_searched(self):
|
|
255
|
+
db = make_db()
|
|
256
|
+
db.add_item(
|
|
257
|
+
make_client(
|
|
258
|
+
1,
|
|
259
|
+
"k1",
|
|
260
|
+
metadata={"owner_id": "owner-123"},
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
results = db.search_by_value("metadata", '{"owner_id": "owner-123"}')
|
|
265
|
+
|
|
266
|
+
self.assertEqual(len(results), 1)
|
|
267
|
+
self.assertEqual(results[0].api_key, "k1")
|
|
268
|
+
|
|
269
|
+
def test_initialize_database_migrates_legacy_clients_table(self):
|
|
270
|
+
db = object.__new__(SQLiteDB)
|
|
271
|
+
db.name = "clients"
|
|
272
|
+
db.subfolder = "hivemind-core"
|
|
273
|
+
db.conn = sqlite3.connect(":memory:", check_same_thread=False)
|
|
274
|
+
db.conn.row_factory = sqlite3.Row
|
|
275
|
+
db._write_lock = threading.Lock()
|
|
276
|
+
with db.conn:
|
|
277
|
+
db.conn.execute("""
|
|
278
|
+
CREATE TABLE clients (
|
|
279
|
+
client_id INTEGER PRIMARY KEY,
|
|
280
|
+
api_key VARCHAR(255) NOT NULL,
|
|
281
|
+
name VARCHAR(255),
|
|
282
|
+
description VARCHAR(255),
|
|
283
|
+
is_admin BOOLEAN DEFAULT FALSE,
|
|
284
|
+
last_seen REAL DEFAULT -1,
|
|
285
|
+
intent_blacklist TEXT,
|
|
286
|
+
skill_blacklist TEXT,
|
|
287
|
+
message_blacklist TEXT,
|
|
288
|
+
allowed_types TEXT,
|
|
289
|
+
crypto_key VARCHAR(16),
|
|
290
|
+
password TEXT,
|
|
291
|
+
can_broadcast BOOLEAN DEFAULT TRUE,
|
|
292
|
+
can_escalate BOOLEAN DEFAULT TRUE,
|
|
293
|
+
can_propagate BOOLEAN DEFAULT TRUE
|
|
294
|
+
)
|
|
295
|
+
""")
|
|
296
|
+
db.conn.execute("INSERT INTO clients (client_id, api_key) VALUES (1, 'k1')")
|
|
297
|
+
|
|
298
|
+
db._initialize_database()
|
|
299
|
+
|
|
300
|
+
columns = {
|
|
301
|
+
row["name"]
|
|
302
|
+
for row in db.conn.execute("PRAGMA table_info(clients)").fetchall()
|
|
303
|
+
}
|
|
304
|
+
self.assertIn("metadata", columns)
|
|
305
|
+
client = db.search_by_value("api_key", "k1")[0]
|
|
306
|
+
self.assertEqual(client.metadata, {})
|
|
307
|
+
|
|
308
|
+
def test_metadata_nested_dict_round_trip(self):
|
|
309
|
+
db = make_db()
|
|
310
|
+
meta = {
|
|
311
|
+
"owner": {"id": "owner-1", "tags": ["a", "b"]},
|
|
312
|
+
"counts": {"x": 1, "y": 2},
|
|
313
|
+
}
|
|
314
|
+
db.add_item(make_client(1, "k1", metadata=meta))
|
|
315
|
+
results = db.search_by_value("api_key", "k1")
|
|
316
|
+
self.assertEqual(len(results), 1)
|
|
317
|
+
self.assertEqual(results[0].metadata, meta)
|
|
318
|
+
|
|
319
|
+
def test_metadata_non_ascii_round_trip(self):
|
|
320
|
+
db = make_db()
|
|
321
|
+
meta = {"name": "Zé Ninguém", "emoji": "🚀", "ru": "Привет"}
|
|
322
|
+
db.add_item(make_client(1, "k1", metadata=meta))
|
|
323
|
+
results = db.search_by_value("api_key", "k1")
|
|
324
|
+
self.assertEqual(len(results), 1)
|
|
325
|
+
self.assertEqual(results[0].metadata, meta)
|
|
326
|
+
|
|
327
|
+
def test_metadata_survives_iteration(self):
|
|
328
|
+
db = make_db()
|
|
329
|
+
db.add_item(
|
|
330
|
+
make_client(
|
|
331
|
+
1,
|
|
332
|
+
"k1",
|
|
333
|
+
metadata={"owner_id": "owner-123"},
|
|
334
|
+
)
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
clients = list(db)
|
|
338
|
+
|
|
339
|
+
self.assertEqual(len(clients), 1)
|
|
340
|
+
self.assertEqual(clients[0].metadata, {"owner_id": "owner-123"})
|
|
341
|
+
|
|
342
|
+
def test_metadata_defaults_to_empty_dict_when_not_provided(self):
|
|
343
|
+
db = make_db()
|
|
344
|
+
db.add_item(make_client(1, "k1"))
|
|
345
|
+
results = db.search_by_value("api_key", "k1")
|
|
346
|
+
self.assertEqual(results[0].metadata, {})
|
|
347
|
+
|
|
348
|
+
def test_metadata_overwritten_on_reinsert_with_same_client_id(self):
|
|
349
|
+
db = make_db()
|
|
350
|
+
db.add_item(make_client(1, "k1", metadata={"v": 1}))
|
|
351
|
+
db.add_item(make_client(1, "k1", metadata={"v": 2, "extra": "x"}))
|
|
352
|
+
results = db.search_by_value("api_key", "k1")
|
|
353
|
+
self.assertEqual(len(results), 1)
|
|
354
|
+
self.assertEqual(results[0].metadata, {"v": 2, "extra": "x"})
|
|
355
|
+
|
|
356
|
+
def test_metadata_to_json_returns_empty_for_non_dict(self):
|
|
357
|
+
self.assertEqual(SQLiteDB._metadata_to_json("not a dict"), "{}")
|
|
358
|
+
self.assertEqual(SQLiteDB._metadata_to_json(None), "{}")
|
|
359
|
+
self.assertEqual(SQLiteDB._metadata_to_json(42), "{}")
|
|
360
|
+
|
|
361
|
+
def test_metadata_from_row_returns_empty_for_garbage_or_missing(self):
|
|
362
|
+
db = make_db()
|
|
363
|
+
# legacy-style row with explicit NULL metadata
|
|
364
|
+
db.add_item(make_client(1, "k1"))
|
|
365
|
+
with db.conn:
|
|
366
|
+
db.conn.execute("UPDATE clients SET metadata = NULL WHERE client_id = 1")
|
|
367
|
+
results = db.search_by_value("api_key", "k1")
|
|
368
|
+
self.assertEqual(results[0].metadata, {})
|
|
369
|
+
# garbage JSON in the metadata column → coerce to {}
|
|
370
|
+
with db.conn:
|
|
371
|
+
db.conn.execute("UPDATE clients SET metadata = 'not json{' WHERE client_id = 1")
|
|
372
|
+
results = db.search_by_value("api_key", "k1")
|
|
373
|
+
self.assertEqual(results[0].metadata, {})
|
|
374
|
+
# valid JSON but not an object → coerce to {}
|
|
375
|
+
with db.conn:
|
|
376
|
+
db.conn.execute("UPDATE clients SET metadata = '[1,2,3]' WHERE client_id = 1")
|
|
377
|
+
results = db.search_by_value("api_key", "k1")
|
|
378
|
+
self.assertEqual(results[0].metadata, {})
|
|
379
|
+
|
|
380
|
+
def test_full_client_fields_preserved(self):
|
|
381
|
+
db = make_db()
|
|
382
|
+
c = make_client(
|
|
383
|
+
client_id=42,
|
|
384
|
+
api_key="full-key",
|
|
385
|
+
name="test-client",
|
|
386
|
+
description="a test",
|
|
387
|
+
is_admin=False,
|
|
388
|
+
last_seen=1234567890.0,
|
|
389
|
+
intent_blacklist=["a:b"],
|
|
390
|
+
skill_blacklist=["c:d"],
|
|
391
|
+
message_blacklist=["e:f"],
|
|
392
|
+
allowed_types=["recognizer_loop:utterance"],
|
|
393
|
+
crypto_key="1234567890123456",
|
|
394
|
+
password="secret",
|
|
395
|
+
can_broadcast=True,
|
|
396
|
+
can_escalate=False,
|
|
397
|
+
can_propagate=True,
|
|
398
|
+
metadata={"owner_id": "owner-123"},
|
|
399
|
+
)
|
|
400
|
+
db.add_item(c)
|
|
401
|
+
results = db.search_by_value("api_key", "full-key")
|
|
402
|
+
self.assertEqual(len(results), 1)
|
|
403
|
+
r = results[0]
|
|
404
|
+
self.assertEqual(r.client_id, 42)
|
|
405
|
+
self.assertEqual(r.name, "test-client")
|
|
406
|
+
self.assertEqual(r.description, "a test")
|
|
407
|
+
self.assertFalse(r.is_admin)
|
|
408
|
+
self.assertEqual(r.last_seen, 1234567890.0)
|
|
409
|
+
self.assertEqual(r.crypto_key, "1234567890123456")
|
|
410
|
+
self.assertEqual(r.password, "secret")
|
|
411
|
+
self.assertFalse(r.can_escalate)
|
|
412
|
+
self.assertEqual(r.metadata, {"owner_id": "owner-123"})
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
class TestSQLiteDBCommit(unittest.TestCase):
|
|
416
|
+
def test_commit_returns_true(self):
|
|
417
|
+
db = make_db()
|
|
418
|
+
self.assertTrue(db.commit())
|
|
419
|
+
|
|
420
|
+
def test_commit_returns_false_on_error(self):
|
|
421
|
+
db = make_db()
|
|
422
|
+
db.conn.close()
|
|
423
|
+
result = db.commit()
|
|
424
|
+
self.assertFalse(result)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class TestSQLiteDBUpdateAndReplace(unittest.TestCase):
|
|
428
|
+
def test_update_item_changes_fields(self):
|
|
429
|
+
db = make_db()
|
|
430
|
+
db.add_item(make_client(1, "k1", name="old"))
|
|
431
|
+
updated = make_client(1, "k1", name="new")
|
|
432
|
+
db.update_item(updated)
|
|
433
|
+
results = db.search_by_value("name", "new")
|
|
434
|
+
self.assertEqual(len(results), 1)
|
|
435
|
+
self.assertEqual(results[0].name, "new")
|
|
436
|
+
|
|
437
|
+
def test_replace_item(self):
|
|
438
|
+
db = make_db()
|
|
439
|
+
old = make_client(1, "old-key")
|
|
440
|
+
new = make_client(2, "new-key")
|
|
441
|
+
db.add_item(old)
|
|
442
|
+
db.replace_item(old, new)
|
|
443
|
+
self.assertEqual(db.search_by_value("api_key", "new-key")[0].client_id, 2)
|
|
444
|
+
# old entry is revoked, not gone
|
|
445
|
+
self.assertEqual(db.search_by_value("api_key", "revoked")[0].client_id, 1)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
try:
|
|
449
|
+
import sqlcipher3 as _sqlcipher3 # noqa: F401
|
|
450
|
+
_SQLCIPHER_AVAILABLE = True
|
|
451
|
+
except ImportError:
|
|
452
|
+
_SQLCIPHER_AVAILABLE = False
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@unittest.skipUnless(_SQLCIPHER_AVAILABLE, "sqlcipher3 not installed")
|
|
456
|
+
class TestSQLiteDBEncrypted(unittest.TestCase):
|
|
457
|
+
"""Tests for the SQLCipher-encrypted path. Skipped when sqlcipher3 is absent."""
|
|
458
|
+
|
|
459
|
+
def _make_encrypted_db(self, path: str, password: str = "hunter2") -> SQLiteDB:
|
|
460
|
+
import unittest.mock as mock
|
|
461
|
+
db = SQLiteDB.__new__(SQLiteDB)
|
|
462
|
+
db.name = os.path.splitext(os.path.basename(path))[0]
|
|
463
|
+
db.subfolder = ""
|
|
464
|
+
db.password = password
|
|
465
|
+
with mock.patch(
|
|
466
|
+
"hivemind_sqlite_database.xdg_data_home",
|
|
467
|
+
return_value=os.path.dirname(path),
|
|
468
|
+
):
|
|
469
|
+
db.__post_init__()
|
|
470
|
+
return db
|
|
471
|
+
|
|
472
|
+
def test_encrypted_file_unreadable_by_stdlib_sqlite3(self):
|
|
473
|
+
"""A file created with a password must be opaque to plain sqlite3."""
|
|
474
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
475
|
+
path = f.name
|
|
476
|
+
os.unlink(path)
|
|
477
|
+
try:
|
|
478
|
+
db = self._make_encrypted_db(path, password="secret123")
|
|
479
|
+
db.add_item(make_client(1, "enc-key"))
|
|
480
|
+
db.commit()
|
|
481
|
+
# stdlib sqlite3 should not be able to read it
|
|
482
|
+
plain_conn = sqlite3.connect(path)
|
|
483
|
+
with self.assertRaises(sqlite3.DatabaseError):
|
|
484
|
+
plain_conn.execute("SELECT * FROM clients").fetchall()
|
|
485
|
+
plain_conn.close()
|
|
486
|
+
finally:
|
|
487
|
+
if os.path.exists(path):
|
|
488
|
+
os.unlink(path)
|
|
489
|
+
|
|
490
|
+
def test_encrypted_round_trip(self):
|
|
491
|
+
"""add_item then search_by_value works through the encryption layer."""
|
|
492
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
493
|
+
path = f.name
|
|
494
|
+
os.unlink(path)
|
|
495
|
+
try:
|
|
496
|
+
db = self._make_encrypted_db(path, password="roundtrip")
|
|
497
|
+
db.add_item(make_client(1, "enc-api-key", name="alice"))
|
|
498
|
+
db.commit()
|
|
499
|
+
# Reopen with same password
|
|
500
|
+
db2 = self._make_encrypted_db(path, password="roundtrip")
|
|
501
|
+
results = db2.search_by_value("api_key", "enc-api-key")
|
|
502
|
+
self.assertEqual(len(results), 1)
|
|
503
|
+
self.assertEqual(results[0].name, "alice")
|
|
504
|
+
finally:
|
|
505
|
+
if os.path.exists(path):
|
|
506
|
+
os.unlink(path)
|
|
507
|
+
|
|
508
|
+
def test_sqlitedb_password_kwarg_raises_importerror_without_sqlcipher3(self):
|
|
509
|
+
"""Confirmed separately in test_sqlitedb_no_sqlcipher.py; skip here."""
|
|
510
|
+
pass
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
class TestSQLiteDBMissingCipher(unittest.TestCase):
|
|
514
|
+
"""Verify ImportError is raised when sqlcipher3 is absent and password is given."""
|
|
515
|
+
|
|
516
|
+
def test_importerror_when_sqlcipher3_missing(self):
|
|
517
|
+
import sys
|
|
518
|
+
import unittest.mock as mock
|
|
519
|
+
|
|
520
|
+
# Simulate sqlcipher3 not being installed
|
|
521
|
+
with mock.patch.dict(sys.modules, {"sqlcipher3": None}):
|
|
522
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
523
|
+
with self.assertRaises(ImportError) as ctx:
|
|
524
|
+
db = SQLiteDB.__new__(SQLiteDB)
|
|
525
|
+
db.name = "test"
|
|
526
|
+
db.subfolder = tmpdir
|
|
527
|
+
db.password = "secret"
|
|
528
|
+
# Patch xdg_data_home so db_path resolves inside tmpdir
|
|
529
|
+
with mock.patch("hivemind_sqlite_database.xdg_data_home",
|
|
530
|
+
return_value=tmpdir):
|
|
531
|
+
db.__post_init__()
|
|
532
|
+
self.assertIn("sqlcipher3", str(ctx.exception))
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
if __name__ == "__main__":
|
|
536
|
+
unittest.main()
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 1.0
|
|
2
|
-
Name: hivemind-sqlite-database
|
|
3
|
-
Version: 0.0.3a1
|
|
4
|
-
Summary: sqlite database plugin for hivemind-core
|
|
5
|
-
Home-page: https://github.com/JarbasHiveMind/hivemind-sqlite-database
|
|
6
|
-
Author: jarbasAi
|
|
7
|
-
Author-email: jarbasai@mailfence.com
|
|
8
|
-
License: Apache-2.0
|
|
9
|
-
Description: UNKNOWN
|
|
10
|
-
Platform: UNKNOWN
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
# HiveMind SQLite Database
|
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import os.path
|
|
3
|
-
import sqlite3
|
|
4
|
-
from typing import List, Union, Iterable
|
|
5
|
-
|
|
6
|
-
from ovos_utils.log import LOG
|
|
7
|
-
from ovos_utils.xdg_utils import xdg_data_home
|
|
8
|
-
|
|
9
|
-
from hivemind_plugin_manager.database import Client, AbstractDB
|
|
10
|
-
|
|
11
|
-
from dataclasses import dataclass
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@dataclass
|
|
15
|
-
class SQLiteDB(AbstractDB):
|
|
16
|
-
"""Database implementation using SQLite."""
|
|
17
|
-
name: str = "clients"
|
|
18
|
-
subfolder: str = "hivemind-core"
|
|
19
|
-
|
|
20
|
-
def __post_init__(self):
|
|
21
|
-
"""
|
|
22
|
-
Initialize the SQLiteDB connection.
|
|
23
|
-
"""
|
|
24
|
-
db_path = os.path.join(xdg_data_home(), self.subfolder, self.name + ".db")
|
|
25
|
-
LOG.debug(f"sqlite database path: {db_path}")
|
|
26
|
-
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
|
27
|
-
|
|
28
|
-
self.conn = sqlite3.connect(db_path)
|
|
29
|
-
self.conn.row_factory = sqlite3.Row
|
|
30
|
-
self._initialize_database()
|
|
31
|
-
|
|
32
|
-
def _initialize_database(self):
|
|
33
|
-
"""Initialize the database schema."""
|
|
34
|
-
with self.conn:
|
|
35
|
-
# crypto key is always 16 chars
|
|
36
|
-
# name description and api_key shouldnt be allowed to go over 255
|
|
37
|
-
self.conn.execute("""
|
|
38
|
-
CREATE TABLE IF NOT EXISTS clients (
|
|
39
|
-
client_id INTEGER PRIMARY KEY,
|
|
40
|
-
api_key VARCHAR(255) NOT NULL,
|
|
41
|
-
name VARCHAR(255),
|
|
42
|
-
description VARCHAR(255),
|
|
43
|
-
is_admin BOOLEAN DEFAULT FALSE,
|
|
44
|
-
last_seen REAL DEFAULT -1,
|
|
45
|
-
intent_blacklist TEXT,
|
|
46
|
-
skill_blacklist TEXT,
|
|
47
|
-
message_blacklist TEXT,
|
|
48
|
-
allowed_types TEXT,
|
|
49
|
-
crypto_key VARCHAR(16),
|
|
50
|
-
password TEXT,
|
|
51
|
-
can_broadcast BOOLEAN DEFAULT TRUE,
|
|
52
|
-
can_escalate BOOLEAN DEFAULT TRUE,
|
|
53
|
-
can_propagate BOOLEAN DEFAULT TRUE
|
|
54
|
-
)
|
|
55
|
-
""")
|
|
56
|
-
|
|
57
|
-
def add_item(self, client: Client) -> bool:
|
|
58
|
-
"""
|
|
59
|
-
Add a client to the SQLite database.
|
|
60
|
-
|
|
61
|
-
Args:
|
|
62
|
-
client: The client to be added.
|
|
63
|
-
|
|
64
|
-
Returns:
|
|
65
|
-
True if the addition was successful, False otherwise.
|
|
66
|
-
"""
|
|
67
|
-
try:
|
|
68
|
-
with self.conn:
|
|
69
|
-
self.conn.execute("""
|
|
70
|
-
INSERT OR REPLACE INTO clients (
|
|
71
|
-
client_id, api_key, name, description, is_admin,
|
|
72
|
-
last_seen, intent_blacklist, skill_blacklist,
|
|
73
|
-
message_blacklist, allowed_types, crypto_key, password,
|
|
74
|
-
can_broadcast, can_escalate, can_propagate
|
|
75
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
76
|
-
""", (
|
|
77
|
-
client.client_id, client.api_key, client.name, client.description,
|
|
78
|
-
client.is_admin, client.last_seen,
|
|
79
|
-
json.dumps(client.intent_blacklist),
|
|
80
|
-
json.dumps(client.skill_blacklist),
|
|
81
|
-
json.dumps(client.message_blacklist),
|
|
82
|
-
json.dumps(client.allowed_types),
|
|
83
|
-
client.crypto_key, client.password,
|
|
84
|
-
client.can_broadcast, client.can_escalate, client.can_propagate
|
|
85
|
-
))
|
|
86
|
-
return True
|
|
87
|
-
except sqlite3.Error as e:
|
|
88
|
-
LOG.error(f"Failed to add client to SQLite: {e}")
|
|
89
|
-
return False
|
|
90
|
-
|
|
91
|
-
def search_by_value(self, key: str, val: Union[str, bool, int, float]) -> List[Client]:
|
|
92
|
-
"""
|
|
93
|
-
Search for clients by a specific key-value pair in the SQLite database.
|
|
94
|
-
|
|
95
|
-
Args:
|
|
96
|
-
key: The key to search by.
|
|
97
|
-
val: The value to search for.
|
|
98
|
-
|
|
99
|
-
Returns:
|
|
100
|
-
A list of clients that match the search criteria.
|
|
101
|
-
"""
|
|
102
|
-
try:
|
|
103
|
-
with self.conn:
|
|
104
|
-
cur = self.conn.execute(f"SELECT * FROM clients WHERE {key} = ?", (val,))
|
|
105
|
-
rows = cur.fetchall()
|
|
106
|
-
return [self._row_to_client(row) for row in rows]
|
|
107
|
-
except sqlite3.Error as e:
|
|
108
|
-
LOG.error(f"Failed to search clients in SQLite: {e}")
|
|
109
|
-
return []
|
|
110
|
-
|
|
111
|
-
def __len__(self) -> int:
|
|
112
|
-
"""Get the number of clients in the database."""
|
|
113
|
-
cur = self.conn.execute("SELECT COUNT(*) FROM clients")
|
|
114
|
-
return cur.fetchone()[0]
|
|
115
|
-
|
|
116
|
-
def __iter__(self) -> Iterable['Client']:
|
|
117
|
-
"""
|
|
118
|
-
Iterate over all clients in the SQLite database.
|
|
119
|
-
|
|
120
|
-
Returns:
|
|
121
|
-
An iterator over the clients in the database.
|
|
122
|
-
"""
|
|
123
|
-
cur = self.conn.execute("SELECT * FROM clients")
|
|
124
|
-
for row in cur:
|
|
125
|
-
yield self._row_to_client(row)
|
|
126
|
-
|
|
127
|
-
def commit(self) -> bool:
|
|
128
|
-
"""Commit changes to the SQLite database."""
|
|
129
|
-
try:
|
|
130
|
-
self.conn.commit()
|
|
131
|
-
return True
|
|
132
|
-
except sqlite3.Error as e:
|
|
133
|
-
LOG.error(f"Failed to commit SQLite database: {e}")
|
|
134
|
-
return False
|
|
135
|
-
|
|
136
|
-
@staticmethod
|
|
137
|
-
def _row_to_client(row: sqlite3.Row) -> Client:
|
|
138
|
-
"""Convert a database row to a Client instance."""
|
|
139
|
-
return Client(
|
|
140
|
-
client_id=int(row["client_id"]),
|
|
141
|
-
api_key=row["api_key"],
|
|
142
|
-
name=row["name"],
|
|
143
|
-
description=row["description"],
|
|
144
|
-
is_admin=row["is_admin"] or False,
|
|
145
|
-
last_seen=row["last_seen"],
|
|
146
|
-
intent_blacklist=json.loads(row["intent_blacklist"] or "[]"),
|
|
147
|
-
skill_blacklist=json.loads(row["skill_blacklist"] or "[]"),
|
|
148
|
-
message_blacklist=json.loads(row["message_blacklist"] or "[]"),
|
|
149
|
-
allowed_types=json.loads(row["allowed_types"] or "[]"),
|
|
150
|
-
crypto_key=row["crypto_key"],
|
|
151
|
-
password=row["password"],
|
|
152
|
-
can_broadcast=row["can_broadcast"],
|
|
153
|
-
can_escalate=row["can_escalate"],
|
|
154
|
-
can_propagate=row["can_propagate"]
|
|
155
|
-
)
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 1.0
|
|
2
|
-
Name: hivemind-sqlite-database
|
|
3
|
-
Version: 0.0.3a1
|
|
4
|
-
Summary: sqlite database plugin for hivemind-core
|
|
5
|
-
Home-page: https://github.com/JarbasHiveMind/hivemind-sqlite-database
|
|
6
|
-
Author: jarbasAi
|
|
7
|
-
Author-email: jarbasai@mailfence.com
|
|
8
|
-
License: Apache-2.0
|
|
9
|
-
Description: UNKNOWN
|
|
10
|
-
Platform: UNKNOWN
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
hivemind-plugin-manager<1.0.0,>=0.1.0
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
from setuptools import setup
|
|
3
|
-
|
|
4
|
-
BASEDIR = os.path.abspath(os.path.dirname(__file__))
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def get_version():
|
|
8
|
-
""" Find the version of the package"""
|
|
9
|
-
version_file = os.path.join(BASEDIR, 'hivemind_sqlite_database', 'version.py')
|
|
10
|
-
major, minor, build, alpha = (None, None, None, None)
|
|
11
|
-
with open(version_file) as f:
|
|
12
|
-
for line in f:
|
|
13
|
-
if 'VERSION_MAJOR' in line:
|
|
14
|
-
major = line.split('=')[1].strip()
|
|
15
|
-
elif 'VERSION_MINOR' in line:
|
|
16
|
-
minor = line.split('=')[1].strip()
|
|
17
|
-
elif 'VERSION_BUILD' in line:
|
|
18
|
-
build = line.split('=')[1].strip()
|
|
19
|
-
elif 'VERSION_ALPHA' in line:
|
|
20
|
-
alpha = line.split('=')[1].strip()
|
|
21
|
-
|
|
22
|
-
if ((major and minor and build and alpha) or
|
|
23
|
-
'# END_VERSION_BLOCK' in line):
|
|
24
|
-
break
|
|
25
|
-
version = f"{major}.{minor}.{build}"
|
|
26
|
-
if int(alpha) > 0:
|
|
27
|
-
version += f"a{alpha}"
|
|
28
|
-
return version
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def required(requirements_file):
|
|
32
|
-
""" Read requirements file and remove comments and empty lines. """
|
|
33
|
-
with open(os.path.join(BASEDIR, requirements_file), 'r') as f:
|
|
34
|
-
requirements = f.read().splitlines()
|
|
35
|
-
if 'MYCROFT_LOOSE_REQUIREMENTS' in os.environ:
|
|
36
|
-
print('USING LOOSE REQUIREMENTS!')
|
|
37
|
-
requirements = [r.replace('==', '>=').replace('~=', '>=') for r in requirements]
|
|
38
|
-
return [pkg for pkg in requirements
|
|
39
|
-
if pkg.strip() and not pkg.startswith("#")]
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
PLUGIN_ENTRY_POINT = 'hivemind-sqlite-db-plugin=hivemind_sqlite_database:SQLiteDB'
|
|
43
|
-
|
|
44
|
-
setup(
|
|
45
|
-
name='hivemind-sqlite-database',
|
|
46
|
-
version=get_version(),
|
|
47
|
-
packages=['hivemind_sqlite_database'],
|
|
48
|
-
url='https://github.com/JarbasHiveMind/hivemind-sqlite-database',
|
|
49
|
-
license='Apache-2.0',
|
|
50
|
-
author='jarbasAi',
|
|
51
|
-
install_requires=required("requirements.txt"),
|
|
52
|
-
entry_points={'hivemind.database': PLUGIN_ENTRY_POINT},
|
|
53
|
-
author_email='jarbasai@mailfence.com',
|
|
54
|
-
description='sqlite database plugin for hivemind-core'
|
|
55
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|