sonnet-storage 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sonnet_storage/__init__.py +46 -0
- sonnet_storage/api/__init__.py +5 -0
- sonnet_storage/api/files.py +131 -0
- sonnet_storage/cli/__init__.py +20 -0
- sonnet_storage/cli/files.py +283 -0
- sonnet_storage/extension.py +46 -0
- sonnet_storage/models/__init__.py +7 -0
- sonnet_storage/models/db.py +69 -0
- sonnet_storage/providers/__init__.py +14 -0
- sonnet_storage/providers/db_path.py +388 -0
- sonnet_storage/providers/factory.py +64 -0
- sonnet_storage/providers/registry.py +68 -0
- sonnet_storage/seed.py +142 -0
- sonnet_storage-0.1.0.dist-info/METADATA +66 -0
- sonnet_storage-0.1.0.dist-info/RECORD +17 -0
- sonnet_storage-0.1.0.dist-info/WHEEL +5 -0
- sonnet_storage-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""sonnet-storage -- Virtual file storage backed by PostgreSQL.
|
|
2
|
+
|
|
3
|
+
Provides a UPath-compatible filesystem abstraction over a PostgreSQL
|
|
4
|
+
table. Consumers use standard pathlib operations (read_text, write_text,
|
|
5
|
+
iterdir, glob, etc.) and the backend is transparent.
|
|
6
|
+
|
|
7
|
+
Public API
|
|
8
|
+
----------
|
|
9
|
+
|
|
10
|
+
Models:
|
|
11
|
+
FileStorage SQLModel table class (for Alembic env.py import)
|
|
12
|
+
|
|
13
|
+
Providers:
|
|
14
|
+
DBPath UPath subclass for database-backed storage
|
|
15
|
+
Extra method: undelete() -- recover soft-deleted file
|
|
16
|
+
get_storage_root Factory: returns UPath for configured backend
|
|
17
|
+
get_storage_root_db Factory: always returns db:// UPath
|
|
18
|
+
register_protocol Register custom protocol name (default: "db")
|
|
19
|
+
|
|
20
|
+
Extension:
|
|
21
|
+
StorageExtension Optional auto-registration + REST router mount
|
|
22
|
+
|
|
23
|
+
API:
|
|
24
|
+
create_storage_router Pre-built FastAPI router factory
|
|
25
|
+
|
|
26
|
+
Seed:
|
|
27
|
+
seed_from_directory Copy local files into database storage
|
|
28
|
+
SeedResult Result dataclass for seed operations
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from .extension import StorageExtension
|
|
32
|
+
from .models import FileStorage
|
|
33
|
+
from .providers import get_storage_root, get_storage_root_db, register_protocol
|
|
34
|
+
from .providers.db_path import DBPath
|
|
35
|
+
from .seed import SeedResult, seed_from_directory
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"DBPath",
|
|
39
|
+
"FileStorage",
|
|
40
|
+
"SeedResult",
|
|
41
|
+
"StorageExtension",
|
|
42
|
+
"get_storage_root",
|
|
43
|
+
"get_storage_root_db",
|
|
44
|
+
"register_protocol",
|
|
45
|
+
"seed_from_directory",
|
|
46
|
+
]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""File storage REST endpoints.
|
|
2
|
+
|
|
3
|
+
Provides a router factory that consumers mount at their preferred prefix.
|
|
4
|
+
Uses the ambient DB session via db_router().
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Body, HTTPException, Query
|
|
10
|
+
from loguru import logger
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from sonnet_storage.providers import get_storage_root
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FileInfo(BaseModel):
|
|
17
|
+
"""File information returned by list endpoints."""
|
|
18
|
+
|
|
19
|
+
path: str
|
|
20
|
+
name: str
|
|
21
|
+
is_file: bool
|
|
22
|
+
size: int | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class StorageInfo(BaseModel):
|
|
26
|
+
"""Storage backend information."""
|
|
27
|
+
|
|
28
|
+
provider: str
|
|
29
|
+
root: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def create_storage_router(prefix: str = "/storage", tags: list[str] | None = None) -> APIRouter:
|
|
33
|
+
"""Create a FastAPI router for file storage operations.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
prefix: URL prefix for all endpoints (default: "/storage").
|
|
37
|
+
tags: OpenAPI tags (default: ["storage"]).
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
A configured APIRouter ready to be included in the application.
|
|
41
|
+
"""
|
|
42
|
+
from sonnet_server.api.dependencies import db_router
|
|
43
|
+
|
|
44
|
+
router = db_router(prefix=prefix, tags=tags or ["storage"])
|
|
45
|
+
|
|
46
|
+
router.add_api_route("/info", _get_storage_info, methods=["GET"], response_model=StorageInfo)
|
|
47
|
+
router.add_api_route("/files", _list_files, methods=["GET"], response_model=list[FileInfo])
|
|
48
|
+
router.add_api_route("/files/{filepath:path}", _get_file, methods=["GET"])
|
|
49
|
+
router.add_api_route("/files/{filepath:path}", _put_file, methods=["PUT"])
|
|
50
|
+
router.add_api_route("/files/{filepath:path}", _delete_file, methods=["DELETE"])
|
|
51
|
+
|
|
52
|
+
return router
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _get_storage_info() -> StorageInfo:
|
|
56
|
+
"""Get storage backend information."""
|
|
57
|
+
root = get_storage_root()
|
|
58
|
+
root_str = str(root)
|
|
59
|
+
if root_str.startswith("file://") or root_str.startswith("/"):
|
|
60
|
+
provider = "file"
|
|
61
|
+
else:
|
|
62
|
+
provider = root.protocol if hasattr(root, "protocol") else "unknown"
|
|
63
|
+
return StorageInfo(provider=provider, root=root_str)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _list_files(path: str = Query("/", description="Directory path to list")) -> list[FileInfo]:
|
|
67
|
+
"""List files in a storage directory."""
|
|
68
|
+
logger.debug("Listing storage files (path={})", path)
|
|
69
|
+
root = get_storage_root()
|
|
70
|
+
target = root / path.lstrip("/") if path != "/" else root
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
if not target.exists():
|
|
74
|
+
raise HTTPException(status_code=404, detail=f"Path not found: {path}")
|
|
75
|
+
items = list(target.iterdir())
|
|
76
|
+
except HTTPException:
|
|
77
|
+
raise
|
|
78
|
+
except Exception as e:
|
|
79
|
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
80
|
+
|
|
81
|
+
result = []
|
|
82
|
+
# Sort: directories first, then files alphabetically
|
|
83
|
+
for item in sorted(items, key=lambda x: (not x.is_dir(), str(x))):
|
|
84
|
+
rel_path = "/" + str(item).replace(str(root), "").lstrip("/")
|
|
85
|
+
try:
|
|
86
|
+
size = item.stat().st_size if item.is_file() else None
|
|
87
|
+
except OSError, FileNotFoundError:
|
|
88
|
+
size = None
|
|
89
|
+
result.append(FileInfo(path=rel_path, name=item.name, is_file=item.is_file(), size=size))
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _get_file(filepath: str) -> dict:
|
|
94
|
+
"""Get file content by path."""
|
|
95
|
+
root = get_storage_root()
|
|
96
|
+
target = root / filepath
|
|
97
|
+
try:
|
|
98
|
+
if not target.is_file():
|
|
99
|
+
raise HTTPException(status_code=404, detail=f"File not found: /{filepath}")
|
|
100
|
+
content = target.read_text()
|
|
101
|
+
except HTTPException:
|
|
102
|
+
raise
|
|
103
|
+
except FileNotFoundError as e:
|
|
104
|
+
raise HTTPException(status_code=404, detail=f"File not found: /{filepath}") from e
|
|
105
|
+
except Exception as e:
|
|
106
|
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
107
|
+
return {"path": f"/{filepath}", "name": target.name, "content": content}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _put_file(filepath: str, content: str = Body(..., media_type="text/plain")) -> dict:
|
|
111
|
+
"""Create or update a text file."""
|
|
112
|
+
root = get_storage_root()
|
|
113
|
+
target = root / filepath
|
|
114
|
+
try:
|
|
115
|
+
target.write_text(content)
|
|
116
|
+
except Exception as e:
|
|
117
|
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
118
|
+
return {"path": f"/{filepath}", "name": target.name, "status": "ok"}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _delete_file(filepath: str) -> dict:
|
|
122
|
+
"""Soft-delete a file."""
|
|
123
|
+
root = get_storage_root()
|
|
124
|
+
target = root / filepath
|
|
125
|
+
try:
|
|
126
|
+
target.unlink()
|
|
127
|
+
except FileNotFoundError as e:
|
|
128
|
+
raise HTTPException(status_code=404, detail=f"File not found: /{filepath}") from e
|
|
129
|
+
except Exception as e:
|
|
130
|
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
131
|
+
return {"path": f"/{filepath}", "status": "deleted"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""sonnet-storage CLI -- file storage management commands.
|
|
2
|
+
|
|
3
|
+
Consumer usage:
|
|
4
|
+
|
|
5
|
+
from sonnet_storage.cli import files as storage_cli
|
|
6
|
+
|
|
7
|
+
files_app = typer.Typer(help="File storage management")
|
|
8
|
+
|
|
9
|
+
@files_app.callback()
|
|
10
|
+
def _files_setup():
|
|
11
|
+
init_settings(log_level="WARNING")
|
|
12
|
+
register_protocol("db")
|
|
13
|
+
|
|
14
|
+
storage_cli.register(files_app)
|
|
15
|
+
main_app.add_typer(files_app, name="files")
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from . import files
|
|
19
|
+
|
|
20
|
+
__all__ = ["files"]
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""File storage CLI commands -- ls, cat, rm, tree, purge.
|
|
2
|
+
|
|
3
|
+
Provides command functions that consumers register on their own Typer app.
|
|
4
|
+
The consumer is responsible for settings initialization and protocol
|
|
5
|
+
registration in their Typer callback.
|
|
6
|
+
|
|
7
|
+
Example (consumer's main.py):
|
|
8
|
+
|
|
9
|
+
from sonnet_storage.cli import files as storage_cli
|
|
10
|
+
|
|
11
|
+
files_app = typer.Typer(help="File storage management")
|
|
12
|
+
|
|
13
|
+
@files_app.callback()
|
|
14
|
+
def _files_setup():
|
|
15
|
+
init_settings(log_level="INFO")
|
|
16
|
+
register_protocol("db")
|
|
17
|
+
|
|
18
|
+
storage_cli.register(files_app)
|
|
19
|
+
main_app.add_typer(files_app, name="files")
|
|
20
|
+
|
|
21
|
+
Transaction contract: each command opens a borrow_db_session().
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
import typer
|
|
29
|
+
from loguru import logger
|
|
30
|
+
from rich.console import Console
|
|
31
|
+
from rich.tree import Tree
|
|
32
|
+
|
|
33
|
+
console = Console()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get_root():
|
|
37
|
+
from sonnet_storage.providers import get_storage_root_db
|
|
38
|
+
|
|
39
|
+
return get_storage_root_db()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def register(app: typer.Typer) -> None:
|
|
43
|
+
"""Register all storage CLI commands on the given Typer app."""
|
|
44
|
+
app.command(name="ls")(_ls)
|
|
45
|
+
app.command(name="cat")(_cat)
|
|
46
|
+
app.command(name="rm")(_rm)
|
|
47
|
+
app.command(name="tree")(_tree)
|
|
48
|
+
app.command(name="purge")(_purge)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# ls
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _ls(
|
|
57
|
+
path: str = typer.Argument("/", help="Path to list"),
|
|
58
|
+
recursive: bool = typer.Option(False, "--recursive", "-r", help="List recursively"),
|
|
59
|
+
long_format: bool = typer.Option(False, "--long", "-l", help="Show file sizes"),
|
|
60
|
+
) -> None:
|
|
61
|
+
"""List files in storage."""
|
|
62
|
+
from sonnet_server.database import borrow_db_session
|
|
63
|
+
|
|
64
|
+
with borrow_db_session():
|
|
65
|
+
root = _get_root()
|
|
66
|
+
target = root / path.lstrip("/") if path != "/" else root
|
|
67
|
+
|
|
68
|
+
console.print(f"[bold]Path:[/bold] {path}\n")
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
items = list(target.rglob("*")) if recursive else list(target.iterdir())
|
|
72
|
+
except (OSError, FileNotFoundError) as e:
|
|
73
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
74
|
+
raise typer.Exit(1) from None
|
|
75
|
+
|
|
76
|
+
if not items:
|
|
77
|
+
console.print("[yellow]No files found.[/yellow]")
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
dirs = sorted([i for i in items if i.is_dir()], key=str)
|
|
81
|
+
files = sorted([i for i in items if i.is_file()], key=str)
|
|
82
|
+
|
|
83
|
+
for item in dirs:
|
|
84
|
+
rel = item._db_path if hasattr(item, "_db_path") else str(item)
|
|
85
|
+
console.print(f"[blue]{rel.lstrip('/')}/[/blue]")
|
|
86
|
+
|
|
87
|
+
for item in files:
|
|
88
|
+
rel = (item._db_path if hasattr(item, "_db_path") else str(item)).lstrip("/")
|
|
89
|
+
if long_format:
|
|
90
|
+
try:
|
|
91
|
+
console.print(f" {item.stat().st_size:>8} {rel}")
|
|
92
|
+
except OSError, FileNotFoundError:
|
|
93
|
+
console.print(f" {'?':>8} {rel}")
|
|
94
|
+
else:
|
|
95
|
+
console.print(f" {rel}")
|
|
96
|
+
|
|
97
|
+
dir_word = "directories" if len(dirs) != 1 else "directory"
|
|
98
|
+
file_word = "files" if len(files) != 1 else "file"
|
|
99
|
+
console.print(f"\n[dim]{len(dirs)} {dir_word}, {len(files)} {file_word}[/dim]")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# cat
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _cat(
|
|
108
|
+
file_path: str = typer.Argument(..., help="File path to display"),
|
|
109
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Save to file"),
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Display file content from storage."""
|
|
112
|
+
from sonnet_server.database import borrow_db_session
|
|
113
|
+
|
|
114
|
+
with borrow_db_session():
|
|
115
|
+
root = _get_root()
|
|
116
|
+
target = root / file_path.lstrip("/")
|
|
117
|
+
|
|
118
|
+
if not target.is_file():
|
|
119
|
+
console.print(f"[red]File not found: {file_path}[/red]")
|
|
120
|
+
raise typer.Exit(1)
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
content = target.read_text()
|
|
124
|
+
except UnicodeDecodeError:
|
|
125
|
+
if output:
|
|
126
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
output.write_bytes(target.read_bytes())
|
|
128
|
+
console.print(f"[green]Saved binary file to {output}[/green]")
|
|
129
|
+
else:
|
|
130
|
+
console.print("[yellow]Binary file -- use --output to save.[/yellow]")
|
|
131
|
+
return
|
|
132
|
+
except (OSError, FileNotFoundError) as e:
|
|
133
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
134
|
+
raise typer.Exit(1) from None
|
|
135
|
+
|
|
136
|
+
if output:
|
|
137
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
output.write_text(content)
|
|
139
|
+
console.print(f"[green]Saved to {output}[/green]")
|
|
140
|
+
else:
|
|
141
|
+
console.print(content)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# rm
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _rm(
|
|
150
|
+
pattern: str = typer.Argument(..., help="File path or glob pattern"),
|
|
151
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Soft-delete files from storage."""
|
|
154
|
+
from sonnet_server.database import borrow_db_session
|
|
155
|
+
|
|
156
|
+
with borrow_db_session():
|
|
157
|
+
root = _get_root()
|
|
158
|
+
files_to_delete = _find_files(root, pattern)
|
|
159
|
+
|
|
160
|
+
if not files_to_delete:
|
|
161
|
+
console.print(f"[yellow]No files found matching: {pattern}[/yellow]")
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
console.print(f"[yellow]Found {len(files_to_delete)} file(s) to delete:[/yellow]")
|
|
165
|
+
for f in files_to_delete[:10]:
|
|
166
|
+
console.print(f" {f._db_path if hasattr(f, '_db_path') else str(f)}")
|
|
167
|
+
if len(files_to_delete) > 10:
|
|
168
|
+
console.print(f" ... and {len(files_to_delete) - 10} more")
|
|
169
|
+
|
|
170
|
+
if not yes and not typer.confirm("\nSoft-delete these files?"):
|
|
171
|
+
console.print("[yellow]Cancelled.[/yellow]")
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
deleted, errors = 0, []
|
|
175
|
+
with borrow_db_session():
|
|
176
|
+
for f in files_to_delete:
|
|
177
|
+
try:
|
|
178
|
+
f.unlink()
|
|
179
|
+
deleted += 1
|
|
180
|
+
except (OSError, FileNotFoundError) as e:
|
|
181
|
+
errors.append(str(e))
|
|
182
|
+
|
|
183
|
+
console.print(f"[green]Deleted {deleted} file(s).[/green]")
|
|
184
|
+
for err in errors:
|
|
185
|
+
logger.warning("Delete error: {}", err)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
# tree
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _tree(
|
|
194
|
+
path: str = typer.Argument("/", help="Root path for the tree"),
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Display storage contents as a tree."""
|
|
197
|
+
from sonnet_server.database import borrow_db_session
|
|
198
|
+
|
|
199
|
+
with borrow_db_session():
|
|
200
|
+
root = _get_root()
|
|
201
|
+
target = root / path.lstrip("/") if path != "/" else root
|
|
202
|
+
tree = Tree(f"[bold]{path or '/'}[/bold]")
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
items = sorted(target.rglob("*"), key=str)
|
|
206
|
+
except (OSError, FileNotFoundError) as e:
|
|
207
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
208
|
+
raise typer.Exit(1) from None
|
|
209
|
+
|
|
210
|
+
tree_nodes: dict[str, Tree] = {"": tree}
|
|
211
|
+
for item in items:
|
|
212
|
+
rel = (item._db_path if hasattr(item, "_db_path") else str(item)).lstrip("/")
|
|
213
|
+
parts = rel.split("/")
|
|
214
|
+
for i in range(len(parts)):
|
|
215
|
+
current = "/".join(parts[: i + 1])
|
|
216
|
+
parent_key = "/".join(parts[:i])
|
|
217
|
+
if current not in tree_nodes:
|
|
218
|
+
parent_node = tree_nodes.get(parent_key, tree)
|
|
219
|
+
if i == len(parts) - 1 and item.is_file():
|
|
220
|
+
parent_node.add(parts[i])
|
|
221
|
+
else:
|
|
222
|
+
tree_nodes[current] = parent_node.add(f"[blue]{parts[i]}/[/blue]")
|
|
223
|
+
|
|
224
|
+
console.print(tree)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
# purge
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _purge(
|
|
233
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
234
|
+
) -> None:
|
|
235
|
+
"""Permanently hard-delete all soft-deleted files."""
|
|
236
|
+
from sqlmodel import delete, select
|
|
237
|
+
|
|
238
|
+
from sonnet_server.database import borrow_db_session, get_current_session
|
|
239
|
+
from sonnet_storage.models.db import FileStorage
|
|
240
|
+
|
|
241
|
+
with borrow_db_session():
|
|
242
|
+
session = get_current_session()
|
|
243
|
+
deleted_files = session.exec(select(FileStorage).where(FileStorage.is_deleted.is_(True))).all()
|
|
244
|
+
|
|
245
|
+
if not deleted_files:
|
|
246
|
+
console.print("[green]No soft-deleted files. Nothing to purge.[/green]")
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
console.print(f"[yellow]Found {len(deleted_files)} soft-deleted file(s):[/yellow]")
|
|
250
|
+
for f in deleted_files[:10]:
|
|
251
|
+
console.print(f" {f.file_path}")
|
|
252
|
+
if len(deleted_files) > 10:
|
|
253
|
+
console.print(f" ... and {len(deleted_files) - 10} more")
|
|
254
|
+
|
|
255
|
+
if not yes:
|
|
256
|
+
console.print("\n[bold red]WARNING: This permanently deletes these files.[/bold red]")
|
|
257
|
+
if not typer.confirm("Proceed?"):
|
|
258
|
+
console.print("[yellow]Cancelled.[/yellow]")
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
result = session.exec(delete(FileStorage).where(FileStorage.is_deleted.is_(True)))
|
|
262
|
+
console.print(f"[green]Purged {result.rowcount} file(s).[/green]")
|
|
263
|
+
# borrow_db_session() commits on clean exit -- no explicit commit needed.
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
# Internal helpers
|
|
268
|
+
# ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _find_files(root, pattern: str) -> list:
|
|
272
|
+
"""Find files matching path or glob pattern."""
|
|
273
|
+
if "*" in pattern:
|
|
274
|
+
base = pattern.rsplit("/", 1)[0] if "/" in pattern else ""
|
|
275
|
+
glob_pat = pattern.rsplit("/", 1)[-1] if "/" in pattern else pattern
|
|
276
|
+
search = root / base.lstrip("/") if base else root
|
|
277
|
+
try:
|
|
278
|
+
return list(search.glob(glob_pat))
|
|
279
|
+
except OSError, FileNotFoundError:
|
|
280
|
+
return []
|
|
281
|
+
else:
|
|
282
|
+
target = root / pattern.lstrip("/")
|
|
283
|
+
return [target] if target.is_file() else []
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""StorageExtension -- optional extension for auto-registration and REST mounting.
|
|
2
|
+
|
|
3
|
+
Registers the db:// protocol at startup and mounts the file storage
|
|
4
|
+
REST router. Infrastructure extension (no profile) -- always active.
|
|
5
|
+
|
|
6
|
+
Consumers who prefer manual wiring can skip this extension and call
|
|
7
|
+
register_protocol() + create_storage_router() directly.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter
|
|
13
|
+
|
|
14
|
+
from sonnet_server.extensions import Extension
|
|
15
|
+
from sonnet_server.settings import Settings
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StorageExtension(Extension):
|
|
19
|
+
"""Virtual file storage extension.
|
|
20
|
+
|
|
21
|
+
Registers the UPath protocol and mounts the REST router.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
protocol: UPath protocol scheme (default: "db").
|
|
25
|
+
router_prefix: URL prefix for REST endpoints (default: "/storage").
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, protocol: str = "db", router_prefix: str = "/storage"):
|
|
29
|
+
# protocol must be set before any DBPath instances are created.
|
|
30
|
+
# Changing it after startup has no effect -- register_protocol() is idempotent.
|
|
31
|
+
self._protocol = protocol
|
|
32
|
+
self._prefix = router_prefix
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def name(self) -> str:
|
|
36
|
+
return "storage"
|
|
37
|
+
|
|
38
|
+
async def on_startup(self, settings: Settings) -> None:
|
|
39
|
+
from sonnet_storage.providers import register_protocol
|
|
40
|
+
|
|
41
|
+
register_protocol(self._protocol)
|
|
42
|
+
|
|
43
|
+
def routers(self) -> list[APIRouter]:
|
|
44
|
+
from sonnet_storage.api import create_storage_router
|
|
45
|
+
|
|
46
|
+
return [create_storage_router(prefix=self._prefix)]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""FileStorage database model.
|
|
2
|
+
|
|
3
|
+
Stores arbitrary files (text, JSON, binary) with metadata and versioning.
|
|
4
|
+
Supports path-based organization, soft delete, and content type optimization.
|
|
5
|
+
|
|
6
|
+
PostgreSQL only. JSON content uses native JSONB for queryability.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Any
|
|
11
|
+
from uuid import UUID, uuid7
|
|
12
|
+
|
|
13
|
+
import arrow
|
|
14
|
+
from sqlalchemy import ARRAY, JSON, LargeBinary, String
|
|
15
|
+
from sqlmodel import Field, SQLModel
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _utcnow():
|
|
19
|
+
return arrow.utcnow().datetime
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FileStorage(SQLModel, table=True):
|
|
23
|
+
"""Virtual file storage backed by PostgreSQL.
|
|
24
|
+
|
|
25
|
+
Each row represents a file. Directories are virtual -- inferred from
|
|
26
|
+
path prefixes. Only one content column is populated per row, based
|
|
27
|
+
on content_type.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
__tablename__ = "file_storage"
|
|
31
|
+
|
|
32
|
+
# Primary key -- UUID v7 (time-ordered)
|
|
33
|
+
id: UUID = Field(default_factory=uuid7, primary_key=True)
|
|
34
|
+
|
|
35
|
+
# File identification
|
|
36
|
+
file_path: str = Field(index=True, nullable=False)
|
|
37
|
+
file_name: str = Field(nullable=False)
|
|
38
|
+
|
|
39
|
+
# Content type dispatch -- "text", "json", or "binary"
|
|
40
|
+
content_type: str = Field(nullable=False)
|
|
41
|
+
|
|
42
|
+
# Content storage -- only one populated based on content_type
|
|
43
|
+
text_content: str | None = Field(default=None)
|
|
44
|
+
json_content: dict[str, Any] | list | None = Field(default=None, sa_type=JSON)
|
|
45
|
+
binary_content: bytes | None = Field(default=None, sa_type=LargeBinary)
|
|
46
|
+
|
|
47
|
+
# File metadata
|
|
48
|
+
file_size: int | None = Field(default=None)
|
|
49
|
+
mime_type: str | None = Field(default=None)
|
|
50
|
+
content_encoding: str | None = Field(default=None)
|
|
51
|
+
|
|
52
|
+
# Classification
|
|
53
|
+
tags: list[str] = Field(default_factory=list, sa_type=ARRAY(String))
|
|
54
|
+
namespace: str | None = Field(default=None, index=True)
|
|
55
|
+
description: str | None = Field(default=None)
|
|
56
|
+
|
|
57
|
+
# Soft delete
|
|
58
|
+
is_deleted: bool = Field(default=False, nullable=False)
|
|
59
|
+
|
|
60
|
+
# Versioning -- incremented on every write, no history kept
|
|
61
|
+
version: int = Field(default=1, nullable=False)
|
|
62
|
+
|
|
63
|
+
# Audit timestamps (UTC)
|
|
64
|
+
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
|
65
|
+
updated_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
|
66
|
+
|
|
67
|
+
# Audit users
|
|
68
|
+
created_by: str | None = Field(default=None)
|
|
69
|
+
updated_by: str | None = Field(default=None)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""UPath providers for database-backed file storage.
|
|
2
|
+
|
|
3
|
+
Provides DBPath (a UPath subclass) and factory functions for
|
|
4
|
+
transparent backend switching between file:// and db:// storage.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .factory import get_storage_root, get_storage_root_db
|
|
8
|
+
from .registry import register_protocol
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"get_storage_root",
|
|
12
|
+
"get_storage_root_db",
|
|
13
|
+
"register_protocol",
|
|
14
|
+
]
|