qodalis-cli-server-plugin-filesystem 1.0.0b1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,43 @@
1
+ # Byte-compiled / optimized
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Distribution / packaging
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ .eggs/
11
+ *.egg
12
+
13
+ # Virtual environments
14
+ .venv/
15
+ venv/
16
+ env/
17
+
18
+ # Environment variables
19
+ .env
20
+
21
+ # Testing
22
+ .pytest_cache/
23
+ .coverage
24
+ htmlcov/
25
+ TestResults/
26
+
27
+ # Type checking / linting
28
+ .mypy_cache/
29
+ .ruff_cache/
30
+
31
+ # C extensions
32
+ *.so
33
+
34
+ # IDEs and editors
35
+ .idea/
36
+ .vscode/*
37
+ !.vscode/settings.json
38
+ !.vscode/launch.json
39
+ !.vscode/extensions.json
40
+
41
+ # System files
42
+ .DS_Store
43
+ Thumbs.db
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 Qodalis Solutions
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: qodalis-cli-server-plugin-filesystem
3
+ Version: 1.0.0b1
4
+ Summary: Filesystem abstraction layer for Qodalis CLI servers — interfaces, models, errors, and built-in providers (in-memory, OS)
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,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
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "qodalis-cli-server-plugin-filesystem"
7
+ version = "1.0.0b1"
8
+ description = "Filesystem abstraction layer for Qodalis CLI servers — interfaces, models, errors, and built-in providers (in-memory, OS)"
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", "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
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/qodalis-solutions/cli-server-python"
29
+ Repository = "https://github.com/qodalis-solutions/cli-server-python"
30
+ Issues = "https://github.com/qodalis-solutions/cli-server-python/issues"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["qodalis_cli_filesystem"]
34
+
35
+ [tool.hatch.build.targets.sdist]
36
+ include = ["qodalis_cli_filesystem/**"]
@@ -0,0 +1,24 @@
1
+ """Qodalis CLI filesystem plugin — interfaces, models, errors, and providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .errors import (
6
+ FileStorageExistsError,
7
+ FileStorageIsADirectoryError,
8
+ FileStorageNotADirectoryError,
9
+ FileStorageNotFoundError,
10
+ FileStoragePermissionError,
11
+ )
12
+ from .i_file_storage_provider import IFileStorageProvider
13
+ from .models import FileEntry, FileStat
14
+
15
+ __all__ = [
16
+ "FileEntry",
17
+ "FileStat",
18
+ "FileStorageExistsError",
19
+ "FileStorageIsADirectoryError",
20
+ "FileStorageNotADirectoryError",
21
+ "FileStorageNotFoundError",
22
+ "FileStoragePermissionError",
23
+ "IFileStorageProvider",
24
+ ]
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class FileStorageNotFoundError(Exception):
5
+ """Raised when a path does not exist."""
6
+
7
+ def __init__(self, path: str) -> None:
8
+ super().__init__(f"Path not found: {path}")
9
+ self.path = path
10
+
11
+
12
+ class FileStoragePermissionError(Exception):
13
+ """Raised when access to a path is denied."""
14
+
15
+ def __init__(self, path: str) -> None:
16
+ super().__init__(f"Access denied: {path}")
17
+ self.path = path
18
+
19
+
20
+ class FileStorageExistsError(Exception):
21
+ """Raised when a path already exists and should not."""
22
+
23
+ def __init__(self, path: str) -> None:
24
+ super().__init__(f"Path already exists: {path}")
25
+ self.path = path
26
+
27
+
28
+ class FileStorageNotADirectoryError(Exception):
29
+ """Raised when a path is not a directory but should be."""
30
+
31
+ def __init__(self, path: str) -> None:
32
+ super().__init__(f"Not a directory: {path}")
33
+ self.path = path
34
+
35
+
36
+ class FileStorageIsADirectoryError(Exception):
37
+ """Raised when a path is a directory but should not be."""
38
+
39
+ def __init__(self, path: str) -> None:
40
+ super().__init__(f"Is a directory: {path}")
41
+ self.path = path
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import AsyncIterator
5
+
6
+ from .models import FileEntry, FileStat
7
+
8
+
9
+ class IFileStorageProvider(ABC):
10
+ """Abstract base class for file storage providers.
11
+
12
+ All methods are async to support both local and remote backends.
13
+ """
14
+
15
+ @property
16
+ @abstractmethod
17
+ def name(self) -> str:
18
+ """Human-readable name for this provider."""
19
+ ...
20
+
21
+ @abstractmethod
22
+ async def list(self, path: str) -> list[FileEntry]:
23
+ """List entries in a directory."""
24
+ ...
25
+
26
+ @abstractmethod
27
+ async def read_file(self, path: str) -> str:
28
+ """Read a file and return its text content."""
29
+ ...
30
+
31
+ @abstractmethod
32
+ async def write_file(self, path: str, content: str | bytes) -> None:
33
+ """Write content to a file, creating or overwriting it."""
34
+ ...
35
+
36
+ @abstractmethod
37
+ async def stat(self, path: str) -> FileStat:
38
+ """Return metadata for a file or directory."""
39
+ ...
40
+
41
+ @abstractmethod
42
+ async def mkdir(self, path: str, recursive: bool = False) -> None:
43
+ """Create a directory."""
44
+ ...
45
+
46
+ @abstractmethod
47
+ async def remove(self, path: str, recursive: bool = False) -> None:
48
+ """Remove a file or directory."""
49
+ ...
50
+
51
+ @abstractmethod
52
+ async def copy(self, src: str, dest: str) -> None:
53
+ """Copy a file or directory from *src* to *dest*."""
54
+ ...
55
+
56
+ @abstractmethod
57
+ async def move(self, src: str, dest: str) -> None:
58
+ """Move (rename) a file or directory from *src* to *dest*."""
59
+ ...
60
+
61
+ @abstractmethod
62
+ async def exists(self, path: str) -> bool:
63
+ """Return True if the path exists."""
64
+ ...
65
+
66
+ @abstractmethod
67
+ async def get_download_stream(self, path: str) -> AsyncIterator[bytes]:
68
+ """Yield the file content as an async byte stream."""
69
+ ...
70
+
71
+ @abstractmethod
72
+ async def upload_file(self, path: str, content: bytes) -> None:
73
+ """Upload raw bytes to the given path."""
74
+ ...
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class FileEntry:
8
+ """Represents a single entry in a directory listing."""
9
+
10
+ name: str
11
+ type: str # 'file' or 'directory'
12
+ size: int
13
+ modified: str # ISO 8601
14
+ permissions: str | None = None
15
+
16
+
17
+ @dataclass
18
+ class FileStat:
19
+ """Detailed metadata for a file or directory."""
20
+
21
+ name: str
22
+ type: str # 'file' or 'directory'
23
+ size: int
24
+ created: str # ISO 8601
25
+ modified: str # ISO 8601
26
+ permissions: str | None = None
@@ -0,0 +1,12 @@
1
+ """File storage provider implementations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .in_memory_provider import InMemoryFileStorageProvider
6
+ from .os_provider import OsFileStorageProvider, OsProviderOptions
7
+
8
+ __all__ = [
9
+ "InMemoryFileStorageProvider",
10
+ "OsFileStorageProvider",
11
+ "OsProviderOptions",
12
+ ]
@@ -0,0 +1,286 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime, timezone
5
+ from typing import AsyncIterator
6
+
7
+ from ..errors import (
8
+ FileStorageExistsError,
9
+ FileStorageIsADirectoryError,
10
+ FileStorageNotADirectoryError,
11
+ FileStorageNotFoundError,
12
+ )
13
+ from ..i_file_storage_provider import IFileStorageProvider
14
+ from ..models import FileEntry, FileStat
15
+
16
+
17
+ @dataclass
18
+ class _FileNode:
19
+ """Internal tree node representing a file or directory."""
20
+
21
+ name: str
22
+ type: str # 'file' or 'directory'
23
+ content: bytes = b""
24
+ children: dict[str, _FileNode] = field(default_factory=dict)
25
+ created_at: str = ""
26
+ modified_at: str = ""
27
+ size: int = 0
28
+ permissions: str | None = None
29
+
30
+ def __post_init__(self) -> None:
31
+ if not self.created_at:
32
+ now = datetime.now(timezone.utc).isoformat()
33
+ self.created_at = now
34
+ self.modified_at = now
35
+ if self.type == "directory":
36
+ self.permissions = "drwxr-xr-x"
37
+ else:
38
+ self.permissions = "-rw-r--r--"
39
+
40
+
41
+ def _now_iso() -> str:
42
+ return datetime.now(timezone.utc).isoformat()
43
+
44
+
45
+ def _normalize_path(path: str) -> str:
46
+ """Normalize *path* to a canonical form (strip leading/trailing slashes)."""
47
+ # Treat empty, "/", "." as root
48
+ path = path.strip("/").strip()
49
+ if path in ("", "."):
50
+ return ""
51
+ # Collapse consecutive slashes and resolve "." / ".."
52
+ parts: list[str] = []
53
+ for segment in path.split("/"):
54
+ if segment in ("", "."):
55
+ continue
56
+ if segment == "..":
57
+ if parts:
58
+ parts.pop()
59
+ else:
60
+ parts.append(segment)
61
+ return "/".join(parts)
62
+
63
+
64
+ class InMemoryFileStorageProvider(IFileStorageProvider):
65
+ """In-memory file storage backed by a tree of ``_FileNode`` objects."""
66
+
67
+ def __init__(self) -> None:
68
+ self._root = _FileNode(name="", type="directory")
69
+
70
+ # -- IFileStorageProvider interface ----------------------------------------
71
+
72
+ @property
73
+ def name(self) -> str:
74
+ return "in-memory"
75
+
76
+ async def list(self, path: str) -> list[FileEntry]:
77
+ node = self._resolve(_normalize_path(path))
78
+ if node is None:
79
+ raise FileStorageNotFoundError(path)
80
+ if node.type != "directory":
81
+ raise FileStorageNotADirectoryError(path)
82
+
83
+ entries: list[FileEntry] = []
84
+ for child in sorted(node.children.values(), key=lambda n: n.name):
85
+ entries.append(
86
+ FileEntry(
87
+ name=child.name,
88
+ type=child.type,
89
+ size=child.size,
90
+ modified=child.modified_at,
91
+ permissions=child.permissions,
92
+ )
93
+ )
94
+ return entries
95
+
96
+ async def read_file(self, path: str) -> str:
97
+ node = self._resolve(_normalize_path(path))
98
+ if node is None:
99
+ raise FileStorageNotFoundError(path)
100
+ if node.type == "directory":
101
+ raise FileStorageIsADirectoryError(path)
102
+ return node.content.decode("utf-8", errors="replace")
103
+
104
+ async def write_file(self, path: str, content: str | bytes) -> None:
105
+ norm = _normalize_path(path)
106
+ if not norm:
107
+ raise FileStorageIsADirectoryError(path)
108
+
109
+ parts = norm.split("/")
110
+ parent = self._ensure_parents(parts[:-1], path)
111
+ file_name = parts[-1]
112
+
113
+ raw = content.encode("utf-8") if isinstance(content, str) else content
114
+ now = _now_iso()
115
+
116
+ existing = parent.children.get(file_name)
117
+ if existing is not None and existing.type == "directory":
118
+ raise FileStorageIsADirectoryError(path)
119
+
120
+ if existing is not None:
121
+ existing.content = raw
122
+ existing.size = len(raw)
123
+ existing.modified_at = now
124
+ else:
125
+ parent.children[file_name] = _FileNode(
126
+ name=file_name,
127
+ type="file",
128
+ content=raw,
129
+ created_at=now,
130
+ modified_at=now,
131
+ size=len(raw),
132
+ )
133
+
134
+ async def stat(self, path: str) -> FileStat:
135
+ node = self._resolve(_normalize_path(path))
136
+ if node is None:
137
+ raise FileStorageNotFoundError(path)
138
+ return FileStat(
139
+ name=node.name,
140
+ type=node.type,
141
+ size=node.size,
142
+ created=node.created_at,
143
+ modified=node.modified_at,
144
+ permissions=node.permissions,
145
+ )
146
+
147
+ async def mkdir(self, path: str, recursive: bool = False) -> None:
148
+ norm = _normalize_path(path)
149
+ if not norm:
150
+ return # root always exists
151
+
152
+ parts = norm.split("/")
153
+ if recursive:
154
+ self._ensure_parents(parts, path)
155
+ else:
156
+ parent = self._ensure_parents(parts[:-1], path, create=False)
157
+ dir_name = parts[-1]
158
+ existing = parent.children.get(dir_name)
159
+ if existing is not None:
160
+ if existing.type != "directory":
161
+ raise FileStorageExistsError(path)
162
+ return # already exists
163
+ parent.children[dir_name] = _FileNode(name=dir_name, type="directory")
164
+
165
+ async def remove(self, path: str, recursive: bool = False) -> None:
166
+ norm = _normalize_path(path)
167
+ if not norm:
168
+ raise FileStoragePermissionError(path)
169
+
170
+ parts = norm.split("/")
171
+ parent = self._resolve("/".join(parts[:-1]))
172
+ if parent is None or parts[-1] not in parent.children:
173
+ raise FileStorageNotFoundError(path)
174
+
175
+ node = parent.children[parts[-1]]
176
+ if node.type == "directory" and node.children and not recursive:
177
+ raise FileStorageNotADirectoryError(path)
178
+
179
+ del parent.children[parts[-1]]
180
+
181
+ async def copy(self, src: str, dest: str) -> None:
182
+ src_node = self._resolve(_normalize_path(src))
183
+ if src_node is None:
184
+ raise FileStorageNotFoundError(src)
185
+
186
+ dest_norm = _normalize_path(dest)
187
+ if not dest_norm:
188
+ raise FileStorageIsADirectoryError(dest)
189
+
190
+ parts = dest_norm.split("/")
191
+ parent = self._ensure_parents(parts[:-1], dest)
192
+ parent.children[parts[-1]] = self._deep_copy(src_node, parts[-1])
193
+
194
+ async def move(self, src: str, dest: str) -> None:
195
+ src_norm = _normalize_path(src)
196
+ if not src_norm:
197
+ raise FileStoragePermissionError(src)
198
+
199
+ src_node = self._resolve(src_norm)
200
+ if src_node is None:
201
+ raise FileStorageNotFoundError(src)
202
+
203
+ dest_norm = _normalize_path(dest)
204
+ if not dest_norm:
205
+ raise FileStorageIsADirectoryError(dest)
206
+
207
+ dest_parts = dest_norm.split("/")
208
+ dest_parent = self._ensure_parents(dest_parts[:-1], dest)
209
+
210
+ # Copy node to new location
211
+ dest_parent.children[dest_parts[-1]] = self._deep_copy(
212
+ src_node, dest_parts[-1]
213
+ )
214
+
215
+ # Remove from old location
216
+ src_parts = src_norm.split("/")
217
+ src_parent = self._resolve("/".join(src_parts[:-1]))
218
+ if src_parent is not None:
219
+ del src_parent.children[src_parts[-1]]
220
+
221
+ async def exists(self, path: str) -> bool:
222
+ return self._resolve(_normalize_path(path)) is not None
223
+
224
+ async def get_download_stream(self, path: str) -> AsyncIterator[bytes]:
225
+ node = self._resolve(_normalize_path(path))
226
+ if node is None:
227
+ raise FileStorageNotFoundError(path)
228
+ if node.type == "directory":
229
+ raise FileStorageIsADirectoryError(path)
230
+
231
+ async def _stream() -> AsyncIterator[bytes]:
232
+ yield node.content
233
+
234
+ return _stream()
235
+
236
+ async def upload_file(self, path: str, content: bytes) -> None:
237
+ await self.write_file(path, content)
238
+
239
+ # -- Private helpers -------------------------------------------------------
240
+
241
+ def _resolve(self, norm_path: str) -> _FileNode | None:
242
+ """Walk the tree and return the node at *norm_path*, or ``None``."""
243
+ if not norm_path:
244
+ return self._root
245
+ node = self._root
246
+ for part in norm_path.split("/"):
247
+ if node.type != "directory" or part not in node.children:
248
+ return None
249
+ node = node.children[part]
250
+ return node
251
+
252
+ def _ensure_parents(
253
+ self, parts: list[str], original_path: str, *, create: bool = True
254
+ ) -> _FileNode:
255
+ """Ensure all parent directories exist for *parts*.
256
+
257
+ Returns the final parent node. If *create* is ``False``, raises
258
+ ``FileStorageNotFoundError`` when a segment is missing.
259
+ """
260
+ node = self._root
261
+ for part in parts:
262
+ if part not in node.children:
263
+ if not create:
264
+ raise FileStorageNotFoundError(original_path)
265
+ node.children[part] = _FileNode(name=part, type="directory")
266
+ child = node.children[part]
267
+ if child.type != "directory":
268
+ raise FileStorageNotADirectoryError(original_path)
269
+ node = child
270
+ return node
271
+
272
+ def _deep_copy(self, node: _FileNode, new_name: str) -> _FileNode:
273
+ """Recursively copy a node tree, assigning *new_name* to the root copy."""
274
+ now = _now_iso()
275
+ copy = _FileNode(
276
+ name=new_name,
277
+ type=node.type,
278
+ content=bytes(node.content),
279
+ created_at=now,
280
+ modified_at=now,
281
+ size=node.size,
282
+ permissions=node.permissions,
283
+ )
284
+ for child_name, child_node in node.children.items():
285
+ copy.children[child_name] = self._deep_copy(child_node, child_name)
286
+ return copy
@@ -0,0 +1,215 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import stat as stat_mod
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime, timezone
8
+ from typing import AsyncIterator
9
+
10
+ from ..errors import (
11
+ FileStorageIsADirectoryError,
12
+ FileStorageNotADirectoryError,
13
+ FileStorageNotFoundError,
14
+ FileStoragePermissionError,
15
+ )
16
+ from ..i_file_storage_provider import IFileStorageProvider
17
+ from ..models import FileEntry, FileStat
18
+
19
+
20
+ @dataclass
21
+ class OsProviderOptions:
22
+ """Configuration for :class:`OsFileStorageProvider`.
23
+
24
+ Attributes:
25
+ allowed_paths: Absolute directory paths that clients may access.
26
+ Each requested path is resolved and checked against this
27
+ whitelist before any I/O is performed.
28
+ """
29
+
30
+ allowed_paths: list[str] = field(default_factory=list)
31
+
32
+
33
+ class OsFileStorageProvider(IFileStorageProvider):
34
+ """File storage provider backed by the operating system filesystem.
35
+
36
+ Access is restricted to the directories listed in *options.allowed_paths*.
37
+ """
38
+
39
+ def __init__(self, options: OsProviderOptions) -> None:
40
+ self._allowed_paths = [os.path.realpath(p) for p in options.allowed_paths]
41
+
42
+ # -- IFileStorageProvider interface ----------------------------------------
43
+
44
+ @property
45
+ def name(self) -> str:
46
+ return "os"
47
+
48
+ async def list(self, path: str) -> list[FileEntry]:
49
+ resolved = self._validate(path)
50
+
51
+ if not os.path.exists(resolved):
52
+ raise FileStorageNotFoundError(path)
53
+ if not os.path.isdir(resolved):
54
+ raise FileStorageNotADirectoryError(path)
55
+
56
+ entries: list[FileEntry] = []
57
+ for entry_name in sorted(os.listdir(resolved)):
58
+ full = os.path.join(resolved, entry_name)
59
+ try:
60
+ st = os.stat(full)
61
+ entry_type = "directory" if stat_mod.S_ISDIR(st.st_mode) else "file"
62
+ entries.append(
63
+ FileEntry(
64
+ name=entry_name,
65
+ type=entry_type,
66
+ size=st.st_size,
67
+ modified=datetime.fromtimestamp(
68
+ st.st_mtime, tz=timezone.utc
69
+ ).isoformat(),
70
+ permissions=stat_mod.filemode(st.st_mode),
71
+ )
72
+ )
73
+ except OSError:
74
+ continue
75
+ return entries
76
+
77
+ async def read_file(self, path: str) -> str:
78
+ resolved = self._validate(path)
79
+
80
+ if not os.path.exists(resolved):
81
+ raise FileStorageNotFoundError(path)
82
+ if os.path.isdir(resolved):
83
+ raise FileStorageIsADirectoryError(path)
84
+
85
+ with open(resolved, "r", encoding="utf-8", errors="replace") as fh:
86
+ return fh.read()
87
+
88
+ async def write_file(self, path: str, content: str | bytes) -> None:
89
+ resolved = self._validate(path)
90
+
91
+ if os.path.isdir(resolved):
92
+ raise FileStorageIsADirectoryError(path)
93
+
94
+ parent = os.path.dirname(resolved)
95
+ if not os.path.isdir(parent):
96
+ raise FileStorageNotFoundError(os.path.dirname(path))
97
+
98
+ if isinstance(content, str):
99
+ with open(resolved, "w", encoding="utf-8") as fh:
100
+ fh.write(content)
101
+ else:
102
+ with open(resolved, "wb") as fh:
103
+ fh.write(content)
104
+
105
+ async def stat(self, path: str) -> FileStat:
106
+ resolved = self._validate(path)
107
+
108
+ if not os.path.exists(resolved):
109
+ raise FileStorageNotFoundError(path)
110
+
111
+ st = os.stat(resolved)
112
+ entry_type = "directory" if stat_mod.S_ISDIR(st.st_mode) else "file"
113
+ return FileStat(
114
+ name=os.path.basename(resolved),
115
+ type=entry_type,
116
+ size=st.st_size,
117
+ created=datetime.fromtimestamp(
118
+ st.st_ctime, tz=timezone.utc
119
+ ).isoformat(),
120
+ modified=datetime.fromtimestamp(
121
+ st.st_mtime, tz=timezone.utc
122
+ ).isoformat(),
123
+ permissions=stat_mod.filemode(st.st_mode),
124
+ )
125
+
126
+ async def mkdir(self, path: str, recursive: bool = False) -> None:
127
+ resolved = self._validate(path)
128
+
129
+ if recursive:
130
+ os.makedirs(resolved, exist_ok=True)
131
+ else:
132
+ parent = os.path.dirname(resolved)
133
+ if not os.path.isdir(parent):
134
+ raise FileStorageNotFoundError(os.path.dirname(path))
135
+ if os.path.exists(resolved):
136
+ return # already exists
137
+ os.mkdir(resolved)
138
+
139
+ async def remove(self, path: str, recursive: bool = False) -> None:
140
+ resolved = self._validate(path)
141
+
142
+ if not os.path.exists(resolved):
143
+ raise FileStorageNotFoundError(path)
144
+
145
+ if os.path.isdir(resolved):
146
+ if recursive:
147
+ shutil.rmtree(resolved)
148
+ else:
149
+ os.rmdir(resolved)
150
+ else:
151
+ os.remove(resolved)
152
+
153
+ async def copy(self, src: str, dest: str) -> None:
154
+ resolved_src = self._validate(src)
155
+ resolved_dest = self._validate(dest)
156
+
157
+ if not os.path.exists(resolved_src):
158
+ raise FileStorageNotFoundError(src)
159
+
160
+ if os.path.isdir(resolved_src):
161
+ shutil.copytree(resolved_src, resolved_dest)
162
+ else:
163
+ shutil.copy2(resolved_src, resolved_dest)
164
+
165
+ async def move(self, src: str, dest: str) -> None:
166
+ resolved_src = self._validate(src)
167
+ resolved_dest = self._validate(dest)
168
+
169
+ if not os.path.exists(resolved_src):
170
+ raise FileStorageNotFoundError(src)
171
+
172
+ shutil.move(resolved_src, resolved_dest)
173
+
174
+ async def exists(self, path: str) -> bool:
175
+ resolved = self._validate(path)
176
+ return os.path.exists(resolved)
177
+
178
+ async def get_download_stream(self, path: str) -> AsyncIterator[bytes]:
179
+ resolved = self._validate(path)
180
+
181
+ if not os.path.exists(resolved):
182
+ raise FileStorageNotFoundError(path)
183
+ if os.path.isdir(resolved):
184
+ raise FileStorageIsADirectoryError(path)
185
+
186
+ async def _stream() -> AsyncIterator[bytes]:
187
+ with open(resolved, "rb") as fh:
188
+ while True:
189
+ chunk = fh.read(64 * 1024)
190
+ if not chunk:
191
+ break
192
+ yield chunk
193
+
194
+ return _stream()
195
+
196
+ async def upload_file(self, path: str, content: bytes) -> None:
197
+ await self.write_file(path, content)
198
+
199
+ # -- Private helpers -------------------------------------------------------
200
+
201
+ def _validate(self, path: str) -> str:
202
+ """Resolve *path* and check it against the allowed whitelist.
203
+
204
+ Returns the resolved absolute path.
205
+
206
+ Raises:
207
+ FileStoragePermissionError: If the path is outside allowed dirs.
208
+ """
209
+ resolved = os.path.realpath(path)
210
+
211
+ for allowed in self._allowed_paths:
212
+ if resolved == allowed or resolved.startswith(allowed + os.sep):
213
+ return resolved
214
+
215
+ raise FileStoragePermissionError(path)