qodalis-cli-server-plugin-filesystem 1.0.0b1__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.
- qodalis_cli_filesystem/__init__.py +24 -0
- qodalis_cli_filesystem/errors.py +41 -0
- qodalis_cli_filesystem/i_file_storage_provider.py +74 -0
- qodalis_cli_filesystem/models.py +26 -0
- qodalis_cli_filesystem/providers/__init__.py +12 -0
- qodalis_cli_filesystem/providers/in_memory_provider.py +286 -0
- qodalis_cli_filesystem/providers/os_provider.py +215 -0
- qodalis_cli_server_plugin_filesystem-1.0.0b1.dist-info/METADATA +20 -0
- qodalis_cli_server_plugin_filesystem-1.0.0b1.dist-info/RECORD +11 -0
- qodalis_cli_server_plugin_filesystem-1.0.0b1.dist-info/WHEEL +4 -0
- qodalis_cli_server_plugin_filesystem-1.0.0b1.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|
|
@@ -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,11 @@
|
|
|
1
|
+
qodalis_cli_filesystem/__init__.py,sha256=kea2H1P0wqeuhBDJ8nULXhvnMbIYDTHpK4lGaI8uDoU,646
|
|
2
|
+
qodalis_cli_filesystem/errors.py,sha256=JbSsLrzg-JGIDLHhn0fQg745vvF17iNhyMHY3L7GrGk,1160
|
|
3
|
+
qodalis_cli_filesystem/i_file_storage_provider.py,sha256=-Fiza_3tiqdk6I4_Nl33BXMO9Y0JyQY_xHa907aR_Uw,2060
|
|
4
|
+
qodalis_cli_filesystem/models.py,sha256=-BzGJTex9P7j7negU0O9EpD3TdoIbRhhb18qdl8KmrM,537
|
|
5
|
+
qodalis_cli_filesystem/providers/__init__.py,sha256=R1XEwS2gFAW3W4FSEdhx2_QQTL7NwDGjKq21V0au-bs,312
|
|
6
|
+
qodalis_cli_filesystem/providers/in_memory_provider.py,sha256=zc3BMdfcs3Q36FO_60SqLZipz9ebP9c_vBBlwurbRC8,9732
|
|
7
|
+
qodalis_cli_filesystem/providers/os_provider.py,sha256=KzbjalluXZNqxhrpfv9iEIs41uMRnzPBxEFEEeQQv18,7084
|
|
8
|
+
qodalis_cli_server_plugin_filesystem-1.0.0b1.dist-info/METADATA,sha256=JOTpy1pGOQkj0uNHBkyZf8fJo_MjKT5vk_PuUsD_s4U,1020
|
|
9
|
+
qodalis_cli_server_plugin_filesystem-1.0.0b1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
10
|
+
qodalis_cli_server_plugin_filesystem-1.0.0b1.dist-info/licenses/LICENSE,sha256=uagko_O2JboeVOW3cldvxKW4QIDjTHCCk5T32qqg_As,1079
|
|
11
|
+
qodalis_cli_server_plugin_filesystem-1.0.0b1.dist-info/RECORD,,
|
|
@@ -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.
|