qodalis-cli-server-plugin-filesystem-json 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.
- qodalis_cli_server_plugin_filesystem_json-1.0.0b1/.gitignore +43 -0
- qodalis_cli_server_plugin_filesystem_json-1.0.0b1/LICENSE +21 -0
- qodalis_cli_server_plugin_filesystem_json-1.0.0b1/PKG-INFO +21 -0
- qodalis_cli_server_plugin_filesystem_json-1.0.0b1/pyproject.toml +39 -0
- qodalis_cli_server_plugin_filesystem_json-1.0.0b1/qodalis_cli_filesystem_json/__init__.py +10 -0
- qodalis_cli_server_plugin_filesystem_json-1.0.0b1/qodalis_cli_filesystem_json/json_file_provider.py +380 -0
|
@@ -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,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qodalis-cli-server-plugin-filesystem-json
|
|
3
|
+
Version: 1.0.0b1
|
|
4
|
+
Summary: JSON file-based storage provider for the Qodalis CLI filesystem plugin
|
|
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,json,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
|
|
21
|
+
Requires-Dist: qodalis-cli-server-plugin-filesystem
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "qodalis-cli-server-plugin-filesystem-json"
|
|
7
|
+
version = "1.0.0b1"
|
|
8
|
+
description = "JSON file-based storage provider for the Qodalis CLI filesystem plugin"
|
|
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", "json", "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
|
+
dependencies = [
|
|
27
|
+
"qodalis-cli-server-plugin-filesystem",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/qodalis-solutions/cli-server-python"
|
|
32
|
+
Repository = "https://github.com/qodalis-solutions/cli-server-python"
|
|
33
|
+
Issues = "https://github.com/qodalis-solutions/cli-server-python/issues"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["qodalis_cli_filesystem_json"]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.sdist]
|
|
39
|
+
include = ["qodalis_cli_filesystem_json/**"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Qodalis CLI filesystem-json plugin — JSON file-based storage provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .json_file_provider import JsonFileProviderOptions, JsonFileStorageProvider
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"JsonFileProviderOptions",
|
|
9
|
+
"JsonFileStorageProvider",
|
|
10
|
+
]
|
qodalis_cli_server_plugin_filesystem_json-1.0.0b1/qodalis_cli_filesystem_json/json_file_provider.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any, AsyncIterator
|
|
8
|
+
|
|
9
|
+
from qodalis_cli_filesystem import (
|
|
10
|
+
FileEntry,
|
|
11
|
+
FileStat,
|
|
12
|
+
FileStorageExistsError,
|
|
13
|
+
FileStorageIsADirectoryError,
|
|
14
|
+
FileStorageNotADirectoryError,
|
|
15
|
+
FileStorageNotFoundError,
|
|
16
|
+
FileStoragePermissionError,
|
|
17
|
+
IFileStorageProvider,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class JsonFileProviderOptions:
|
|
23
|
+
"""Configuration for the JSON file storage provider."""
|
|
24
|
+
|
|
25
|
+
file_path: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class _FileNode:
|
|
30
|
+
"""Internal tree node representing a file or directory."""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
type: str # 'file' or 'directory'
|
|
34
|
+
content: str = "" # base64 or plain text stored as string
|
|
35
|
+
children: list[_FileNode] = field(default_factory=list)
|
|
36
|
+
created_at: str = ""
|
|
37
|
+
modified_at: str = ""
|
|
38
|
+
size: int = 0
|
|
39
|
+
permissions: str | None = None
|
|
40
|
+
|
|
41
|
+
def __post_init__(self) -> None:
|
|
42
|
+
if not self.created_at:
|
|
43
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
44
|
+
self.created_at = now
|
|
45
|
+
self.modified_at = now
|
|
46
|
+
if self.type == "directory":
|
|
47
|
+
self.permissions = "drwxr-xr-x"
|
|
48
|
+
else:
|
|
49
|
+
self.permissions = "-rw-r--r--"
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict[str, Any]:
|
|
52
|
+
"""Serialize node to a JSON-compatible dict."""
|
|
53
|
+
return {
|
|
54
|
+
"name": self.name,
|
|
55
|
+
"type": self.type,
|
|
56
|
+
"content": self.content,
|
|
57
|
+
"children": [child.to_dict() for child in self.children],
|
|
58
|
+
"created_at": self.created_at,
|
|
59
|
+
"modified_at": self.modified_at,
|
|
60
|
+
"size": self.size,
|
|
61
|
+
"permissions": self.permissions,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_dict(cls, data: dict[str, Any]) -> _FileNode:
|
|
66
|
+
"""Deserialize a node from a dict."""
|
|
67
|
+
node = cls(
|
|
68
|
+
name=data["name"],
|
|
69
|
+
type=data["type"],
|
|
70
|
+
content=data.get("content", ""),
|
|
71
|
+
created_at=data.get("created_at", ""),
|
|
72
|
+
modified_at=data.get("modified_at", ""),
|
|
73
|
+
size=data.get("size", 0),
|
|
74
|
+
permissions=data.get("permissions"),
|
|
75
|
+
)
|
|
76
|
+
node.children = [
|
|
77
|
+
cls.from_dict(child) for child in data.get("children", [])
|
|
78
|
+
]
|
|
79
|
+
return node
|
|
80
|
+
|
|
81
|
+
def get_child(self, name: str) -> _FileNode | None:
|
|
82
|
+
"""Find a child by name."""
|
|
83
|
+
for child in self.children:
|
|
84
|
+
if child.name == name:
|
|
85
|
+
return child
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
def add_child(self, child: _FileNode) -> None:
|
|
89
|
+
"""Add a child node."""
|
|
90
|
+
self.children.append(child)
|
|
91
|
+
|
|
92
|
+
def remove_child(self, name: str) -> None:
|
|
93
|
+
"""Remove a child by name."""
|
|
94
|
+
self.children = [c for c in self.children if c.name != name]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _now_iso() -> str:
|
|
98
|
+
return datetime.now(timezone.utc).isoformat()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _normalize_path(path: str) -> str:
|
|
102
|
+
"""Normalize *path* to a canonical form (strip leading/trailing slashes)."""
|
|
103
|
+
path = path.strip("/").strip()
|
|
104
|
+
if path in ("", "."):
|
|
105
|
+
return ""
|
|
106
|
+
parts: list[str] = []
|
|
107
|
+
for segment in path.split("/"):
|
|
108
|
+
if segment in ("", "."):
|
|
109
|
+
continue
|
|
110
|
+
if segment == "..":
|
|
111
|
+
if parts:
|
|
112
|
+
parts.pop()
|
|
113
|
+
else:
|
|
114
|
+
parts.append(segment)
|
|
115
|
+
return "/".join(parts)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class JsonFileStorageProvider(IFileStorageProvider):
|
|
119
|
+
"""File storage provider backed by a JSON file on disk.
|
|
120
|
+
|
|
121
|
+
Uses the same tree structure as ``InMemoryFileStorageProvider`` but
|
|
122
|
+
persists all data to a JSON file after every write mutation.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(self, options: JsonFileProviderOptions) -> None:
|
|
126
|
+
self._file_path = options.file_path
|
|
127
|
+
self._root = self._load()
|
|
128
|
+
|
|
129
|
+
# -- Persistence -----------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def _load(self) -> _FileNode:
|
|
132
|
+
"""Read the JSON file and deserialize the tree.
|
|
133
|
+
|
|
134
|
+
If the file does not exist, return an empty root node.
|
|
135
|
+
"""
|
|
136
|
+
if not os.path.exists(self._file_path):
|
|
137
|
+
return _FileNode(name="", type="directory")
|
|
138
|
+
with open(self._file_path, "r", encoding="utf-8") as f:
|
|
139
|
+
data = json.load(f)
|
|
140
|
+
return _FileNode.from_dict(data)
|
|
141
|
+
|
|
142
|
+
def _save(self) -> None:
|
|
143
|
+
"""Serialize the tree to JSON and write it to disk.
|
|
144
|
+
|
|
145
|
+
Creates parent directories if they do not exist.
|
|
146
|
+
"""
|
|
147
|
+
parent_dir = os.path.dirname(self._file_path)
|
|
148
|
+
if parent_dir:
|
|
149
|
+
os.makedirs(parent_dir, exist_ok=True)
|
|
150
|
+
with open(self._file_path, "w", encoding="utf-8") as f:
|
|
151
|
+
json.dump(self._root.to_dict(), f, indent=2)
|
|
152
|
+
|
|
153
|
+
# -- IFileStorageProvider interface ----------------------------------------
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def name(self) -> str:
|
|
157
|
+
return "json-file"
|
|
158
|
+
|
|
159
|
+
async def list(self, path: str) -> list[FileEntry]:
|
|
160
|
+
node = self._resolve(_normalize_path(path))
|
|
161
|
+
if node is None:
|
|
162
|
+
raise FileStorageNotFoundError(path)
|
|
163
|
+
if node.type != "directory":
|
|
164
|
+
raise FileStorageNotADirectoryError(path)
|
|
165
|
+
|
|
166
|
+
entries: list[FileEntry] = []
|
|
167
|
+
for child in sorted(node.children, key=lambda n: n.name):
|
|
168
|
+
entries.append(
|
|
169
|
+
FileEntry(
|
|
170
|
+
name=child.name,
|
|
171
|
+
type=child.type,
|
|
172
|
+
size=child.size,
|
|
173
|
+
modified=child.modified_at,
|
|
174
|
+
permissions=child.permissions,
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
return entries
|
|
178
|
+
|
|
179
|
+
async def read_file(self, path: str) -> str:
|
|
180
|
+
node = self._resolve(_normalize_path(path))
|
|
181
|
+
if node is None:
|
|
182
|
+
raise FileStorageNotFoundError(path)
|
|
183
|
+
if node.type == "directory":
|
|
184
|
+
raise FileStorageIsADirectoryError(path)
|
|
185
|
+
return node.content
|
|
186
|
+
|
|
187
|
+
async def write_file(self, path: str, content: str | bytes) -> None:
|
|
188
|
+
norm = _normalize_path(path)
|
|
189
|
+
if not norm:
|
|
190
|
+
raise FileStorageIsADirectoryError(path)
|
|
191
|
+
|
|
192
|
+
parts = norm.split("/")
|
|
193
|
+
parent = self._ensure_parents(parts[:-1], path)
|
|
194
|
+
file_name = parts[-1]
|
|
195
|
+
|
|
196
|
+
text = content.decode("utf-8", errors="replace") if isinstance(content, bytes) else content
|
|
197
|
+
now = _now_iso()
|
|
198
|
+
|
|
199
|
+
existing = parent.get_child(file_name)
|
|
200
|
+
if existing is not None and existing.type == "directory":
|
|
201
|
+
raise FileStorageIsADirectoryError(path)
|
|
202
|
+
|
|
203
|
+
if existing is not None:
|
|
204
|
+
existing.content = text
|
|
205
|
+
existing.size = len(text.encode("utf-8"))
|
|
206
|
+
existing.modified_at = now
|
|
207
|
+
else:
|
|
208
|
+
parent.add_child(
|
|
209
|
+
_FileNode(
|
|
210
|
+
name=file_name,
|
|
211
|
+
type="file",
|
|
212
|
+
content=text,
|
|
213
|
+
created_at=now,
|
|
214
|
+
modified_at=now,
|
|
215
|
+
size=len(text.encode("utf-8")),
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
self._save()
|
|
219
|
+
|
|
220
|
+
async def stat(self, path: str) -> FileStat:
|
|
221
|
+
node = self._resolve(_normalize_path(path))
|
|
222
|
+
if node is None:
|
|
223
|
+
raise FileStorageNotFoundError(path)
|
|
224
|
+
return FileStat(
|
|
225
|
+
name=node.name,
|
|
226
|
+
type=node.type,
|
|
227
|
+
size=node.size,
|
|
228
|
+
created=node.created_at,
|
|
229
|
+
modified=node.modified_at,
|
|
230
|
+
permissions=node.permissions,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
async def mkdir(self, path: str, recursive: bool = False) -> None:
|
|
234
|
+
norm = _normalize_path(path)
|
|
235
|
+
if not norm:
|
|
236
|
+
return # root always exists
|
|
237
|
+
|
|
238
|
+
parts = norm.split("/")
|
|
239
|
+
if recursive:
|
|
240
|
+
self._ensure_parents(parts, path)
|
|
241
|
+
else:
|
|
242
|
+
parent = self._ensure_parents(parts[:-1], path, create=False)
|
|
243
|
+
dir_name = parts[-1]
|
|
244
|
+
existing = parent.get_child(dir_name)
|
|
245
|
+
if existing is not None:
|
|
246
|
+
if existing.type != "directory":
|
|
247
|
+
raise FileStorageExistsError(path)
|
|
248
|
+
return # already exists
|
|
249
|
+
parent.add_child(_FileNode(name=dir_name, type="directory"))
|
|
250
|
+
self._save()
|
|
251
|
+
|
|
252
|
+
async def remove(self, path: str, recursive: bool = False) -> None:
|
|
253
|
+
norm = _normalize_path(path)
|
|
254
|
+
if not norm:
|
|
255
|
+
raise FileStoragePermissionError(path)
|
|
256
|
+
|
|
257
|
+
parts = norm.split("/")
|
|
258
|
+
parent = self._resolve("/".join(parts[:-1]))
|
|
259
|
+
if parent is None or parent.get_child(parts[-1]) is None:
|
|
260
|
+
raise FileStorageNotFoundError(path)
|
|
261
|
+
|
|
262
|
+
node = parent.get_child(parts[-1])
|
|
263
|
+
if node is not None and node.type == "directory" and node.children and not recursive:
|
|
264
|
+
raise FileStorageNotADirectoryError(path)
|
|
265
|
+
|
|
266
|
+
parent.remove_child(parts[-1])
|
|
267
|
+
self._save()
|
|
268
|
+
|
|
269
|
+
async def copy(self, src: str, dest: str) -> None:
|
|
270
|
+
src_node = self._resolve(_normalize_path(src))
|
|
271
|
+
if src_node is None:
|
|
272
|
+
raise FileStorageNotFoundError(src)
|
|
273
|
+
|
|
274
|
+
dest_norm = _normalize_path(dest)
|
|
275
|
+
if not dest_norm:
|
|
276
|
+
raise FileStorageIsADirectoryError(dest)
|
|
277
|
+
|
|
278
|
+
parts = dest_norm.split("/")
|
|
279
|
+
parent = self._ensure_parents(parts[:-1], dest)
|
|
280
|
+
# Remove existing child with same name if any
|
|
281
|
+
parent.remove_child(parts[-1])
|
|
282
|
+
parent.add_child(self._deep_copy(src_node, parts[-1]))
|
|
283
|
+
self._save()
|
|
284
|
+
|
|
285
|
+
async def move(self, src: str, dest: str) -> None:
|
|
286
|
+
src_norm = _normalize_path(src)
|
|
287
|
+
if not src_norm:
|
|
288
|
+
raise FileStoragePermissionError(src)
|
|
289
|
+
|
|
290
|
+
src_node = self._resolve(src_norm)
|
|
291
|
+
if src_node is None:
|
|
292
|
+
raise FileStorageNotFoundError(src)
|
|
293
|
+
|
|
294
|
+
dest_norm = _normalize_path(dest)
|
|
295
|
+
if not dest_norm:
|
|
296
|
+
raise FileStorageIsADirectoryError(dest)
|
|
297
|
+
|
|
298
|
+
dest_parts = dest_norm.split("/")
|
|
299
|
+
dest_parent = self._ensure_parents(dest_parts[:-1], dest)
|
|
300
|
+
|
|
301
|
+
# Copy node to new location
|
|
302
|
+
dest_parent.remove_child(dest_parts[-1])
|
|
303
|
+
dest_parent.add_child(self._deep_copy(src_node, dest_parts[-1]))
|
|
304
|
+
|
|
305
|
+
# Remove from old location
|
|
306
|
+
src_parts = src_norm.split("/")
|
|
307
|
+
src_parent = self._resolve("/".join(src_parts[:-1]))
|
|
308
|
+
if src_parent is not None:
|
|
309
|
+
src_parent.remove_child(src_parts[-1])
|
|
310
|
+
self._save()
|
|
311
|
+
|
|
312
|
+
async def exists(self, path: str) -> bool:
|
|
313
|
+
return self._resolve(_normalize_path(path)) is not None
|
|
314
|
+
|
|
315
|
+
async def get_download_stream(self, path: str) -> AsyncIterator[bytes]:
|
|
316
|
+
node = self._resolve(_normalize_path(path))
|
|
317
|
+
if node is None:
|
|
318
|
+
raise FileStorageNotFoundError(path)
|
|
319
|
+
if node.type == "directory":
|
|
320
|
+
raise FileStorageIsADirectoryError(path)
|
|
321
|
+
|
|
322
|
+
async def _stream() -> AsyncIterator[bytes]:
|
|
323
|
+
yield node.content.encode("utf-8")
|
|
324
|
+
|
|
325
|
+
return _stream()
|
|
326
|
+
|
|
327
|
+
async def upload_file(self, path: str, content: bytes) -> None:
|
|
328
|
+
await self.write_file(path, content)
|
|
329
|
+
|
|
330
|
+
# -- Private helpers -------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
def _resolve(self, norm_path: str) -> _FileNode | None:
|
|
333
|
+
"""Walk the tree and return the node at *norm_path*, or ``None``."""
|
|
334
|
+
if not norm_path:
|
|
335
|
+
return self._root
|
|
336
|
+
node = self._root
|
|
337
|
+
for part in norm_path.split("/"):
|
|
338
|
+
child = node.get_child(part)
|
|
339
|
+
if child is None or node.type != "directory":
|
|
340
|
+
return None
|
|
341
|
+
node = child
|
|
342
|
+
return node
|
|
343
|
+
|
|
344
|
+
def _ensure_parents(
|
|
345
|
+
self, parts: list[str], original_path: str, *, create: bool = True
|
|
346
|
+
) -> _FileNode:
|
|
347
|
+
"""Ensure all parent directories exist for *parts*.
|
|
348
|
+
|
|
349
|
+
Returns the final parent node. If *create* is ``False``, raises
|
|
350
|
+
``FileStorageNotFoundError`` when a segment is missing.
|
|
351
|
+
"""
|
|
352
|
+
node = self._root
|
|
353
|
+
for part in parts:
|
|
354
|
+
child = node.get_child(part)
|
|
355
|
+
if child is None:
|
|
356
|
+
if not create:
|
|
357
|
+
raise FileStorageNotFoundError(original_path)
|
|
358
|
+
new_dir = _FileNode(name=part, type="directory")
|
|
359
|
+
node.add_child(new_dir)
|
|
360
|
+
child = new_dir
|
|
361
|
+
if child.type != "directory":
|
|
362
|
+
raise FileStorageNotADirectoryError(original_path)
|
|
363
|
+
node = child
|
|
364
|
+
return node
|
|
365
|
+
|
|
366
|
+
def _deep_copy(self, node: _FileNode, new_name: str) -> _FileNode:
|
|
367
|
+
"""Recursively copy a node tree, assigning *new_name* to the root copy."""
|
|
368
|
+
now = _now_iso()
|
|
369
|
+
copy = _FileNode(
|
|
370
|
+
name=new_name,
|
|
371
|
+
type=node.type,
|
|
372
|
+
content=node.content,
|
|
373
|
+
created_at=now,
|
|
374
|
+
modified_at=now,
|
|
375
|
+
size=node.size,
|
|
376
|
+
permissions=node.permissions,
|
|
377
|
+
)
|
|
378
|
+
for child in node.children:
|
|
379
|
+
copy.add_child(self._deep_copy(child, child.name))
|
|
380
|
+
return copy
|