qodalis-cli-server-plugin-filesystem-sqlite 1.0.0b1__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.
@@ -0,0 +1,43 @@
1
+ # Byte-compiled / optimized
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Distribution / packaging
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ .eggs/
11
+ *.egg
12
+
13
+ # Virtual environments
14
+ .venv/
15
+ venv/
16
+ env/
17
+
18
+ # Environment variables
19
+ .env
20
+
21
+ # Testing
22
+ .pytest_cache/
23
+ .coverage
24
+ htmlcov/
25
+ TestResults/
26
+
27
+ # Type checking / linting
28
+ .mypy_cache/
29
+ .ruff_cache/
30
+
31
+ # C extensions
32
+ *.so
33
+
34
+ # IDEs and editors
35
+ .idea/
36
+ .vscode/*
37
+ !.vscode/settings.json
38
+ !.vscode/launch.json
39
+ !.vscode/extensions.json
40
+
41
+ # System files
42
+ .DS_Store
43
+ Thumbs.db
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 Qodalis Solutions
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.4
2
+ Name: qodalis-cli-server-plugin-filesystem-sqlite
3
+ Version: 1.0.0b1
4
+ Summary: SQLite-based storage provider for the Qodalis CLI filesystem plugin
5
+ Project-URL: Homepage, https://github.com/qodalis-solutions/cli-server-python
6
+ Project-URL: Repository, https://github.com/qodalis-solutions/cli-server-python
7
+ Project-URL: Issues, https://github.com/qodalis-solutions/cli-server-python/issues
8
+ Author-email: Nicolae Lupei <nicolae.lupei@qodalis.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: cli,filesystem,plugin,qodalis,sqlite,storage
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: qodalis-cli-server-plugin-filesystem
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "qodalis-cli-server-plugin-filesystem-sqlite"
7
+ version = "1.0.0b1"
8
+ description = "SQLite-based storage provider for the Qodalis CLI filesystem plugin"
9
+ readme = { text = "", content-type = "text/plain" }
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Nicolae Lupei", email = "nicolae.lupei@qodalis.com" },
14
+ ]
15
+ keywords = ["cli", "qodalis", "filesystem", "sqlite", "storage", "plugin"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ ]
26
+ dependencies = [
27
+ "qodalis-cli-server-plugin-filesystem",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/qodalis-solutions/cli-server-python"
32
+ Repository = "https://github.com/qodalis-solutions/cli-server-python"
33
+ Issues = "https://github.com/qodalis-solutions/cli-server-python/issues"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["qodalis_cli_filesystem_sqlite"]
37
+
38
+ [tool.hatch.build.targets.sdist]
39
+ include = ["qodalis_cli_filesystem_sqlite/**"]
@@ -0,0 +1,10 @@
1
+ """Qodalis CLI filesystem-sqlite plugin — SQLite-based storage provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .sqlite_provider import SqliteFileStorageProvider, SqliteProviderOptions
6
+
7
+ __all__ = [
8
+ "SqliteFileStorageProvider",
9
+ "SqliteProviderOptions",
10
+ ]
@@ -0,0 +1,410 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sqlite3
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timezone
7
+ from typing import AsyncIterator
8
+
9
+ from qodalis_cli_filesystem import (
10
+ FileEntry,
11
+ FileStat,
12
+ FileStorageExistsError,
13
+ FileStorageIsADirectoryError,
14
+ FileStorageNotADirectoryError,
15
+ FileStorageNotFoundError,
16
+ FileStoragePermissionError,
17
+ IFileStorageProvider,
18
+ )
19
+
20
+ _CREATE_TABLE = """\
21
+ CREATE TABLE IF NOT EXISTS files (
22
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
23
+ path TEXT NOT NULL UNIQUE,
24
+ name TEXT NOT NULL,
25
+ type TEXT NOT NULL CHECK(type IN ('file', 'directory')),
26
+ content TEXT,
27
+ size INTEGER NOT NULL DEFAULT 0,
28
+ permissions TEXT DEFAULT '644',
29
+ created_at TEXT NOT NULL,
30
+ modified_at TEXT NOT NULL,
31
+ parent_path TEXT
32
+ );
33
+ """
34
+
35
+ _CREATE_INDEX = """\
36
+ CREATE INDEX IF NOT EXISTS idx_files_parent ON files(parent_path);
37
+ """
38
+
39
+
40
+ @dataclass
41
+ class SqliteProviderOptions:
42
+ """Configuration for the SQLite file storage provider."""
43
+
44
+ db_path: str = "./data/files.db"
45
+
46
+
47
+ def _now_iso() -> str:
48
+ return datetime.now(timezone.utc).isoformat()
49
+
50
+
51
+ def _normalize_path(path: str) -> str:
52
+ """Normalize *path* to a canonical form with leading slash."""
53
+ path = path.strip()
54
+ if path in ("", ".", "/"):
55
+ return "/"
56
+ # Remove trailing slash, ensure leading slash
57
+ parts: list[str] = []
58
+ for segment in path.split("/"):
59
+ if segment in ("", "."):
60
+ continue
61
+ if segment == "..":
62
+ if parts:
63
+ parts.pop()
64
+ else:
65
+ parts.append(segment)
66
+ return "/" + "/".join(parts) if parts else "/"
67
+
68
+
69
+ def _parent_path(path: str) -> str | None:
70
+ """Return the parent path, or None for root."""
71
+ if path == "/":
72
+ return None
73
+ idx = path.rfind("/")
74
+ if idx <= 0:
75
+ return "/"
76
+ return path[:idx]
77
+
78
+
79
+ class SqliteFileStorageProvider(IFileStorageProvider):
80
+ """File storage provider backed by a SQLite database.
81
+
82
+ Uses Python's built-in ``sqlite3`` module with no external dependencies.
83
+ All interface methods are async but use synchronous sqlite3 calls internally.
84
+ """
85
+
86
+ def __init__(self, options: SqliteProviderOptions | None = None) -> None:
87
+ if options is None:
88
+ options = SqliteProviderOptions()
89
+ self._db_path = options.db_path
90
+
91
+ # Create parent directories for on-disk databases
92
+ if self._db_path != ":memory:":
93
+ parent_dir = os.path.dirname(self._db_path)
94
+ if parent_dir:
95
+ os.makedirs(parent_dir, exist_ok=True)
96
+
97
+ self._conn = sqlite3.connect(self._db_path)
98
+ self._conn.row_factory = sqlite3.Row
99
+ self._conn.execute("PRAGMA journal_mode=WAL")
100
+ self._conn.execute(_CREATE_TABLE)
101
+ self._conn.execute(_CREATE_INDEX)
102
+ self._conn.commit()
103
+
104
+ # Ensure root directory exists
105
+ self._ensure_root()
106
+
107
+ def _ensure_root(self) -> None:
108
+ row = self._conn.execute(
109
+ "SELECT id FROM files WHERE path = ?", ("/",)
110
+ ).fetchone()
111
+ if row is None:
112
+ now = _now_iso()
113
+ self._conn.execute(
114
+ "INSERT INTO files (path, name, type, size, permissions, created_at, modified_at, parent_path) "
115
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
116
+ ("/", "", "directory", 0, "drwxr-xr-x", now, now, None),
117
+ )
118
+ self._conn.commit()
119
+
120
+ # -- IFileStorageProvider interface ----------------------------------------
121
+
122
+ @property
123
+ def name(self) -> str:
124
+ return "sqlite"
125
+
126
+ async def list(self, path: str) -> list[FileEntry]:
127
+ norm = _normalize_path(path)
128
+ row = self._conn.execute(
129
+ "SELECT type FROM files WHERE path = ?", (norm,)
130
+ ).fetchone()
131
+ if row is None:
132
+ raise FileStorageNotFoundError(path)
133
+ if row["type"] != "directory":
134
+ raise FileStorageNotADirectoryError(path)
135
+
136
+ rows = self._conn.execute(
137
+ "SELECT name, type, size, modified_at, permissions FROM files "
138
+ "WHERE parent_path = ? ORDER BY name",
139
+ (norm,),
140
+ ).fetchall()
141
+
142
+ return [
143
+ FileEntry(
144
+ name=r["name"],
145
+ type=r["type"],
146
+ size=r["size"],
147
+ modified=r["modified_at"],
148
+ permissions=r["permissions"],
149
+ )
150
+ for r in rows
151
+ ]
152
+
153
+ async def read_file(self, path: str) -> str:
154
+ norm = _normalize_path(path)
155
+ row = self._conn.execute(
156
+ "SELECT type, content FROM files WHERE path = ?", (norm,)
157
+ ).fetchone()
158
+ if row is None:
159
+ raise FileStorageNotFoundError(path)
160
+ if row["type"] == "directory":
161
+ raise FileStorageIsADirectoryError(path)
162
+ return row["content"] or ""
163
+
164
+ async def write_file(self, path: str, content: str | bytes) -> None:
165
+ norm = _normalize_path(path)
166
+ if norm == "/":
167
+ raise FileStorageIsADirectoryError(path)
168
+
169
+ text = content.decode("utf-8", errors="replace") if isinstance(content, bytes) else content
170
+ size = len(text.encode("utf-8"))
171
+ now = _now_iso()
172
+ file_name = norm.rsplit("/", 1)[-1]
173
+ parent = _parent_path(norm)
174
+
175
+ # Ensure parent directories exist
176
+ if parent and parent != "/":
177
+ await self._ensure_parents(parent)
178
+
179
+ # Check if it already exists
180
+ existing = self._conn.execute(
181
+ "SELECT type FROM files WHERE path = ?", (norm,)
182
+ ).fetchone()
183
+ if existing is not None and existing["type"] == "directory":
184
+ raise FileStorageIsADirectoryError(path)
185
+
186
+ if existing is not None:
187
+ self._conn.execute(
188
+ "UPDATE files SET content = ?, size = ?, modified_at = ? WHERE path = ?",
189
+ (text, size, now, norm),
190
+ )
191
+ else:
192
+ self._conn.execute(
193
+ "INSERT INTO files (path, name, type, content, size, permissions, created_at, modified_at, parent_path) "
194
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
195
+ (norm, file_name, "file", text, size, "-rw-r--r--", now, now, parent),
196
+ )
197
+ self._conn.commit()
198
+
199
+ async def stat(self, path: str) -> FileStat:
200
+ norm = _normalize_path(path)
201
+ row = self._conn.execute(
202
+ "SELECT name, type, size, created_at, modified_at, permissions FROM files WHERE path = ?",
203
+ (norm,),
204
+ ).fetchone()
205
+ if row is None:
206
+ raise FileStorageNotFoundError(path)
207
+ return FileStat(
208
+ name=row["name"],
209
+ type=row["type"],
210
+ size=row["size"],
211
+ created=row["created_at"],
212
+ modified=row["modified_at"],
213
+ permissions=row["permissions"],
214
+ )
215
+
216
+ async def mkdir(self, path: str, recursive: bool = False) -> None:
217
+ norm = _normalize_path(path)
218
+ if norm == "/":
219
+ return # root always exists
220
+
221
+ if recursive:
222
+ await self._ensure_parents(norm)
223
+ else:
224
+ parent = _parent_path(norm)
225
+ # Check parent exists
226
+ if parent:
227
+ p_row = self._conn.execute(
228
+ "SELECT type FROM files WHERE path = ?", (parent,)
229
+ ).fetchone()
230
+ if p_row is None:
231
+ raise FileStorageNotFoundError(path)
232
+ if p_row["type"] != "directory":
233
+ raise FileStorageNotADirectoryError(path)
234
+
235
+ existing = self._conn.execute(
236
+ "SELECT type FROM files WHERE path = ?", (norm,)
237
+ ).fetchone()
238
+ if existing is not None:
239
+ if existing["type"] != "directory":
240
+ raise FileStorageExistsError(path)
241
+ return # already exists
242
+
243
+ dir_name = norm.rsplit("/", 1)[-1]
244
+ now = _now_iso()
245
+ self._conn.execute(
246
+ "INSERT INTO files (path, name, type, size, permissions, created_at, modified_at, parent_path) "
247
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
248
+ (norm, dir_name, "directory", 0, "drwxr-xr-x", now, now, parent),
249
+ )
250
+ self._conn.commit()
251
+
252
+ async def remove(self, path: str, recursive: bool = False) -> None:
253
+ norm = _normalize_path(path)
254
+ if norm == "/":
255
+ raise FileStoragePermissionError(path)
256
+
257
+ row = self._conn.execute(
258
+ "SELECT type FROM files WHERE path = ?", (norm,)
259
+ ).fetchone()
260
+ if row is None:
261
+ raise FileStorageNotFoundError(path)
262
+
263
+ if row["type"] == "directory" and not recursive:
264
+ # Check if directory has children
265
+ child = self._conn.execute(
266
+ "SELECT id FROM files WHERE parent_path = ? LIMIT 1", (norm,)
267
+ ).fetchone()
268
+ if child is not None:
269
+ raise FileStorageNotADirectoryError(path)
270
+
271
+ if recursive:
272
+ # Delete the entry and all descendants (path starts with norm/)
273
+ self._conn.execute(
274
+ "DELETE FROM files WHERE path = ? OR path LIKE ?",
275
+ (norm, norm + "/%"),
276
+ )
277
+ else:
278
+ self._conn.execute("DELETE FROM files WHERE path = ?", (norm,))
279
+ self._conn.commit()
280
+
281
+ async def copy(self, src: str, dest: str) -> None:
282
+ src_norm = _normalize_path(src)
283
+ dest_norm = _normalize_path(dest)
284
+
285
+ if dest_norm == "/":
286
+ raise FileStorageIsADirectoryError(dest)
287
+
288
+ src_row = self._conn.execute(
289
+ "SELECT * FROM files WHERE path = ?", (src_norm,)
290
+ ).fetchone()
291
+ if src_row is None:
292
+ raise FileStorageNotFoundError(src)
293
+
294
+ dest_parent = _parent_path(dest_norm)
295
+ if dest_parent and dest_parent != "/":
296
+ await self._ensure_parents(dest_parent)
297
+
298
+ now = _now_iso()
299
+ dest_name = dest_norm.rsplit("/", 1)[-1]
300
+
301
+ # Remove existing destination if any
302
+ self._conn.execute("DELETE FROM files WHERE path = ?", (dest_norm,))
303
+
304
+ # Copy the source entry
305
+ self._conn.execute(
306
+ "INSERT INTO files (path, name, type, content, size, permissions, created_at, modified_at, parent_path) "
307
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
308
+ (dest_norm, dest_name, src_row["type"], src_row["content"],
309
+ src_row["size"], src_row["permissions"], now, now, dest_parent),
310
+ )
311
+
312
+ # If directory, copy all descendants
313
+ if src_row["type"] == "directory":
314
+ descendants = self._conn.execute(
315
+ "SELECT * FROM files WHERE path LIKE ?", (src_norm + "/%",)
316
+ ).fetchall()
317
+ for desc in descendants:
318
+ new_path = dest_norm + desc["path"][len(src_norm):]
319
+ new_parent = _parent_path(new_path)
320
+ # Remove existing at new path
321
+ self._conn.execute("DELETE FROM files WHERE path = ?", (new_path,))
322
+ self._conn.execute(
323
+ "INSERT INTO files (path, name, type, content, size, permissions, created_at, modified_at, parent_path) "
324
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
325
+ (new_path, desc["name"], desc["type"], desc["content"],
326
+ desc["size"], desc["permissions"], now, now, new_parent),
327
+ )
328
+
329
+ self._conn.commit()
330
+
331
+ async def move(self, src: str, dest: str) -> None:
332
+ src_norm = _normalize_path(src)
333
+ if src_norm == "/":
334
+ raise FileStoragePermissionError(src)
335
+
336
+ dest_norm = _normalize_path(dest)
337
+ if dest_norm == "/":
338
+ raise FileStorageIsADirectoryError(dest)
339
+
340
+ src_row = self._conn.execute(
341
+ "SELECT * FROM files WHERE path = ?", (src_norm,)
342
+ ).fetchone()
343
+ if src_row is None:
344
+ raise FileStorageNotFoundError(src)
345
+
346
+ # Copy then remove source
347
+ await self.copy(src, dest)
348
+
349
+ # Remove source and its descendants
350
+ self._conn.execute(
351
+ "DELETE FROM files WHERE path = ? OR path LIKE ?",
352
+ (src_norm, src_norm + "/%"),
353
+ )
354
+ self._conn.commit()
355
+
356
+ async def exists(self, path: str) -> bool:
357
+ norm = _normalize_path(path)
358
+ row = self._conn.execute(
359
+ "SELECT id FROM files WHERE path = ?", (norm,)
360
+ ).fetchone()
361
+ return row is not None
362
+
363
+ async def get_download_stream(self, path: str) -> AsyncIterator[bytes]:
364
+ norm = _normalize_path(path)
365
+ row = self._conn.execute(
366
+ "SELECT type, content FROM files WHERE path = ?", (norm,)
367
+ ).fetchone()
368
+ if row is None:
369
+ raise FileStorageNotFoundError(path)
370
+ if row["type"] == "directory":
371
+ raise FileStorageIsADirectoryError(path)
372
+
373
+ content = (row["content"] or "").encode("utf-8")
374
+
375
+ async def _stream() -> AsyncIterator[bytes]:
376
+ yield content
377
+
378
+ return _stream()
379
+
380
+ async def upload_file(self, path: str, content: bytes) -> None:
381
+ await self.write_file(path, content)
382
+
383
+ # -- Private helpers -------------------------------------------------------
384
+
385
+ async def _ensure_parents(self, path: str) -> None:
386
+ """Ensure all ancestor directories exist up to and including *path*."""
387
+ norm = _normalize_path(path)
388
+ if norm == "/":
389
+ return
390
+
391
+ # Build list of ancestors that need to exist
392
+ parts = norm.strip("/").split("/")
393
+ current = ""
394
+ for part in parts:
395
+ current = current + "/" + part
396
+ existing = self._conn.execute(
397
+ "SELECT type FROM files WHERE path = ?", (current,)
398
+ ).fetchone()
399
+ if existing is not None:
400
+ if existing["type"] != "directory":
401
+ raise FileStorageNotADirectoryError(current)
402
+ continue
403
+ now = _now_iso()
404
+ parent = _parent_path(current)
405
+ self._conn.execute(
406
+ "INSERT INTO files (path, name, type, size, permissions, created_at, modified_at, parent_path) "
407
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
408
+ (current, part, "directory", 0, "drwxr-xr-x", now, now, parent),
409
+ )
410
+ self._conn.commit()