sonnet-storage 0.1.0__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,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: sonnet-storage
3
+ Version: 0.1.0
4
+ Summary: Virtual file storage backed by PostgreSQL for sonnet-server applications
5
+ Author-email: Wolfgang Miller <wolfgang.miller@petrarca-labs.com>
6
+ License-Expression: Apache-2.0
7
+ Classifier: Intended Audience :: Developers
8
+ Classifier: Programming Language :: Python
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.14
11
+ Requires-Python: <4.0,>=3.14
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: sonnet-server>=0.1.12
14
+ Requires-Dist: universal-pathlib>=0.3.10
15
+ Requires-Dist: loguru>=0.7.3
16
+ Requires-Dist: typer>=0.12.0
17
+ Requires-Dist: rich>=13.0.0
18
+ Provides-Extra: dev
19
+ Requires-Dist: ruff>=0.3.0; extra == "dev"
20
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
21
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
22
+
23
+ # sonnet-storage
24
+
25
+ Virtual file storage backed by PostgreSQL for sonnet-server applications.
26
+
27
+ Provides a [UPath](https://github.com/fsspec/universal_pathlib)-compatible
28
+ filesystem abstraction over a database table. Consumers use standard
29
+ pathlib operations (`read_text()`, `write_text()`, `iterdir()`, etc.)
30
+ and the backend (local filesystem or database) is transparent.
31
+
32
+ ## Quick Start
33
+
34
+ ```python
35
+ from sonnet_storage import get_storage_root, register_protocol
36
+
37
+ # Register the db:// protocol (once at startup)
38
+ register_protocol()
39
+
40
+ # Use like pathlib
41
+ root = get_storage_root()
42
+ rules = root / "rules"
43
+ for f in rules.iterdir():
44
+ content = f.read_text()
45
+ ```
46
+
47
+ ## Features
48
+
49
+ - Text, JSON (native JSONB), and binary content storage
50
+ - Soft delete with recovery
51
+ - Versioning (monotonic counter per file)
52
+ - Seeding from local directories / package data
53
+ - Configurable UPath protocol name (default: `db://`)
54
+ - Pre-built REST router factory
55
+ - Optional `StorageExtension` for auto-registration
56
+
57
+ ## Database
58
+
59
+ PostgreSQL only. See [docs/data-model.md](docs/data-model.md) for the
60
+ complete schema reference. Consumers own their Alembic migrations --
61
+ see [ADR-0002](../../docs/adr/0002-no-shipped-migrations.md).
62
+
63
+ ## Documentation
64
+
65
+ - [Design document](docs/design/virtual-file-storage.md)
66
+ - [Data model reference](docs/data-model.md)
@@ -0,0 +1,44 @@
1
+ # sonnet-storage
2
+
3
+ Virtual file storage backed by PostgreSQL for sonnet-server applications.
4
+
5
+ Provides a [UPath](https://github.com/fsspec/universal_pathlib)-compatible
6
+ filesystem abstraction over a database table. Consumers use standard
7
+ pathlib operations (`read_text()`, `write_text()`, `iterdir()`, etc.)
8
+ and the backend (local filesystem or database) is transparent.
9
+
10
+ ## Quick Start
11
+
12
+ ```python
13
+ from sonnet_storage import get_storage_root, register_protocol
14
+
15
+ # Register the db:// protocol (once at startup)
16
+ register_protocol()
17
+
18
+ # Use like pathlib
19
+ root = get_storage_root()
20
+ rules = root / "rules"
21
+ for f in rules.iterdir():
22
+ content = f.read_text()
23
+ ```
24
+
25
+ ## Features
26
+
27
+ - Text, JSON (native JSONB), and binary content storage
28
+ - Soft delete with recovery
29
+ - Versioning (monotonic counter per file)
30
+ - Seeding from local directories / package data
31
+ - Configurable UPath protocol name (default: `db://`)
32
+ - Pre-built REST router factory
33
+ - Optional `StorageExtension` for auto-registration
34
+
35
+ ## Database
36
+
37
+ PostgreSQL only. See [docs/data-model.md](docs/data-model.md) for the
38
+ complete schema reference. Consumers own their Alembic migrations --
39
+ see [ADR-0002](../../docs/adr/0002-no-shipped-migrations.md).
40
+
41
+ ## Documentation
42
+
43
+ - [Design document](docs/design/virtual-file-storage.md)
44
+ - [Data model reference](docs/data-model.md)
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sonnet-storage"
7
+ version = "0.1.0"
8
+ authors = [
9
+ { name = "Wolfgang Miller", email = "wolfgang.miller@petrarca-labs.com" },
10
+ ]
11
+ description = "Virtual file storage backed by PostgreSQL for sonnet-server applications"
12
+ readme = "README.md"
13
+ license = "Apache-2.0"
14
+ requires-python = ">=3.14,<4.0"
15
+ classifiers = [
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.14",
20
+ ]
21
+ dependencies = [
22
+ "sonnet-server>=0.1.12",
23
+ "universal-pathlib>=0.3.10",
24
+ "loguru>=0.7.3",
25
+ "typer>=0.12.0",
26
+ "rich>=13.0.0",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ dev = [
31
+ "ruff>=0.3.0",
32
+ "pytest>=7.0.0",
33
+ "pytest-asyncio>=0.23.0",
34
+ ]
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
38
+
39
+ [tool.pytest.ini_options]
40
+ markers = [
41
+ "unit: marks tests as unit tests (default)",
42
+ "integration: marks tests as integration tests",
43
+ ]
44
+ testpaths = ["tests"]
45
+ addopts = [
46
+ "-m unit",
47
+ "--strict-markers",
48
+ ]
49
+ asyncio_mode = "auto"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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 []