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.
- qodalis_cli_server_plugin_filesystem_sqlite-1.0.0b1/.gitignore +43 -0
- qodalis_cli_server_plugin_filesystem_sqlite-1.0.0b1/LICENSE +21 -0
- qodalis_cli_server_plugin_filesystem_sqlite-1.0.0b1/PKG-INFO +21 -0
- qodalis_cli_server_plugin_filesystem_sqlite-1.0.0b1/pyproject.toml +39 -0
- qodalis_cli_server_plugin_filesystem_sqlite-1.0.0b1/qodalis_cli_filesystem_sqlite/__init__.py +10 -0
- qodalis_cli_server_plugin_filesystem_sqlite-1.0.0b1/qodalis_cli_filesystem_sqlite/sqlite_provider.py +410 -0
|
@@ -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
|
+
]
|
qodalis_cli_server_plugin_filesystem_sqlite-1.0.0b1/qodalis_cli_filesystem_sqlite/sqlite_provider.py
ADDED
|
@@ -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()
|