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.
@@ -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,5 @@
1
+ """REST API for virtual file storage."""
2
+
3
+ from .files import create_storage_router
4
+
5
+ __all__ = ["create_storage_router"]
@@ -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,7 @@
1
+ """Storage models -- database table and API projections."""
2
+
3
+ from .db import FileStorage
4
+
5
+ __all__ = [
6
+ "FileStorage",
7
+ ]
@@ -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
+ ]