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.
@@ -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
+ ]
@@ -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