qodalis-cli-server-plugin-filesystem-s3 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,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: qodalis-cli-server-plugin-filesystem-s3
3
+ Version: 1.0.0b1
4
+ Summary: AWS S3-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: aws,cli,filesystem,plugin,qodalis,s3,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: boto3>=1.26.0
22
+ Requires-Dist: qodalis-cli-server-plugin-filesystem
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "qodalis-cli-server-plugin-filesystem-s3"
7
+ version = "1.0.0b1"
8
+ description = "AWS S3-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", "s3", "aws", "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
+ "boto3>=1.26.0",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/qodalis-solutions/cli-server-python"
33
+ Repository = "https://github.com/qodalis-solutions/cli-server-python"
34
+ Issues = "https://github.com/qodalis-solutions/cli-server-python/issues"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["qodalis_cli_filesystem_s3"]
38
+
39
+ [tool.hatch.build.targets.sdist]
40
+ include = ["qodalis_cli_filesystem_s3/**"]
@@ -0,0 +1,10 @@
1
+ """Qodalis CLI filesystem-s3 plugin — AWS S3-based storage provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .s3_provider import S3FileStorageProvider, S3ProviderOptions
6
+
7
+ __all__ = [
8
+ "S3FileStorageProvider",
9
+ "S3ProviderOptions",
10
+ ]
@@ -0,0 +1,459 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timezone
6
+ from functools import partial
7
+ from typing import Any, AsyncIterator
8
+
9
+ import boto3
10
+ from botocore.exceptions import ClientError
11
+
12
+ from qodalis_cli_filesystem import (
13
+ FileEntry,
14
+ FileStat,
15
+ FileStorageIsADirectoryError,
16
+ FileStorageNotADirectoryError,
17
+ FileStorageNotFoundError,
18
+ IFileStorageProvider,
19
+ )
20
+
21
+
22
+ @dataclass
23
+ class S3ProviderOptions:
24
+ """Configuration for the S3 file storage provider."""
25
+
26
+ bucket: str
27
+ region: str | None = None
28
+ prefix: str | None = None
29
+ endpoint_url: str | None = None
30
+ aws_access_key_id: str | None = None
31
+ aws_secret_access_key: str | None = None
32
+
33
+
34
+ def _now_iso() -> str:
35
+ return datetime.now(timezone.utc).isoformat()
36
+
37
+
38
+ def _normalize_path(path: str) -> str:
39
+ """Normalize a filesystem path, stripping leading/trailing slashes."""
40
+ path = path.strip("/").strip()
41
+ if path in ("", "."):
42
+ return ""
43
+ parts: list[str] = []
44
+ for segment in path.split("/"):
45
+ if segment in ("", "."):
46
+ continue
47
+ if segment == "..":
48
+ if parts:
49
+ parts.pop()
50
+ else:
51
+ parts.append(segment)
52
+ return "/".join(parts)
53
+
54
+
55
+ class S3FileStorageProvider(IFileStorageProvider):
56
+ """File storage provider backed by an AWS S3 bucket.
57
+
58
+ Uses ``boto3`` to interact with S3. All async methods wrap synchronous
59
+ boto3 calls via ``asyncio.get_event_loop().run_in_executor``.
60
+
61
+ Directories are virtual: ``mkdir`` creates a zero-byte object with a
62
+ trailing ``/`` as a directory marker.
63
+ """
64
+
65
+ def __init__(self, options: S3ProviderOptions) -> None:
66
+ self._bucket = options.bucket
67
+ self._prefix = (options.prefix or "").strip("/")
68
+ if self._prefix:
69
+ self._prefix += "/"
70
+
71
+ client_kwargs: dict[str, Any] = {}
72
+ if options.region:
73
+ client_kwargs["region_name"] = options.region
74
+ if options.endpoint_url:
75
+ client_kwargs["endpoint_url"] = options.endpoint_url
76
+ if options.aws_access_key_id:
77
+ client_kwargs["aws_access_key_id"] = options.aws_access_key_id
78
+ if options.aws_secret_access_key:
79
+ client_kwargs["aws_secret_access_key"] = options.aws_secret_access_key
80
+
81
+ self._client = boto3.client("s3", **client_kwargs)
82
+
83
+ def _to_key(self, norm_path: str) -> str:
84
+ """Convert a normalized path to an S3 object key."""
85
+ return f"{self._prefix}{norm_path}" if norm_path else self._prefix.rstrip("/")
86
+
87
+ def _dir_key(self, norm_path: str) -> str:
88
+ """Return the S3 key for a directory marker."""
89
+ key = self._to_key(norm_path)
90
+ if not key.endswith("/"):
91
+ key += "/"
92
+ return key
93
+
94
+ def _dir_prefix(self, norm_path: str) -> str:
95
+ """Return the prefix to use when listing objects under a directory."""
96
+ if not norm_path:
97
+ return self._prefix
98
+ return f"{self._prefix}{norm_path}/"
99
+
100
+ async def _run(self, func: Any, *args: Any, **kwargs: Any) -> Any:
101
+ """Run a synchronous boto3 call in the default executor."""
102
+ loop = asyncio.get_event_loop()
103
+ return await loop.run_in_executor(None, partial(func, *args, **kwargs))
104
+
105
+ # -- IFileStorageProvider interface ----------------------------------------
106
+
107
+ @property
108
+ def name(self) -> str:
109
+ return "s3"
110
+
111
+ async def list(self, path: str) -> list[FileEntry]:
112
+ norm = _normalize_path(path)
113
+ prefix = self._dir_prefix(norm)
114
+
115
+ response = await self._run(
116
+ self._client.list_objects_v2,
117
+ Bucket=self._bucket,
118
+ Prefix=prefix,
119
+ Delimiter="/",
120
+ )
121
+
122
+ # If the directory doesn't exist and there are no contents, raise
123
+ if (
124
+ not response.get("Contents")
125
+ and not response.get("CommonPrefixes")
126
+ ):
127
+ # Check if the path is actually a file
128
+ if norm and await self._is_file(norm):
129
+ raise FileStorageNotADirectoryError(path)
130
+ # For root path, return empty list; for others, raise not found
131
+ if norm:
132
+ raise FileStorageNotFoundError(path)
133
+ return []
134
+
135
+ entries: list[FileEntry] = []
136
+
137
+ # Directories (common prefixes)
138
+ for cp in response.get("CommonPrefixes", []):
139
+ dir_name = cp["Prefix"][len(prefix):].rstrip("/")
140
+ if dir_name:
141
+ entries.append(
142
+ FileEntry(
143
+ name=dir_name,
144
+ type="directory",
145
+ size=0,
146
+ modified=_now_iso(),
147
+ permissions="drwxr-xr-x",
148
+ )
149
+ )
150
+
151
+ # Files
152
+ for obj in response.get("Contents", []):
153
+ key = obj["Key"]
154
+ # Skip the directory marker itself
155
+ if key == prefix:
156
+ continue
157
+ name = key[len(prefix):]
158
+ if not name or name.endswith("/"):
159
+ continue
160
+ modified = obj.get("LastModified")
161
+ mod_str = modified.isoformat() if modified else _now_iso()
162
+ entries.append(
163
+ FileEntry(
164
+ name=name,
165
+ type="file",
166
+ size=obj.get("Size", 0),
167
+ modified=mod_str,
168
+ permissions="-rw-r--r--",
169
+ )
170
+ )
171
+
172
+ entries.sort(key=lambda e: e.name)
173
+ return entries
174
+
175
+ async def read_file(self, path: str) -> str:
176
+ norm = _normalize_path(path)
177
+ if not norm:
178
+ raise FileStorageIsADirectoryError(path)
179
+
180
+ key = self._to_key(norm)
181
+ try:
182
+ response = await self._run(
183
+ self._client.get_object,
184
+ Bucket=self._bucket,
185
+ Key=key,
186
+ )
187
+ except ClientError as e:
188
+ if e.response["Error"]["Code"] == "NoSuchKey":
189
+ raise FileStorageNotFoundError(path)
190
+ raise
191
+
192
+ body = response["Body"].read()
193
+ return body.decode("utf-8", errors="replace")
194
+
195
+ async def write_file(self, path: str, content: str | bytes) -> None:
196
+ norm = _normalize_path(path)
197
+ if not norm:
198
+ raise FileStorageIsADirectoryError(path)
199
+
200
+ key = self._to_key(norm)
201
+ body = content.encode("utf-8") if isinstance(content, str) else content
202
+
203
+ await self._run(
204
+ self._client.put_object,
205
+ Bucket=self._bucket,
206
+ Key=key,
207
+ Body=body,
208
+ )
209
+
210
+ async def stat(self, path: str) -> FileStat:
211
+ norm = _normalize_path(path)
212
+
213
+ if not norm:
214
+ # Root directory
215
+ return FileStat(
216
+ name="/",
217
+ type="directory",
218
+ size=0,
219
+ created=_now_iso(),
220
+ modified=_now_iso(),
221
+ permissions="drwxr-xr-x",
222
+ )
223
+
224
+ # Try as file first
225
+ key = self._to_key(norm)
226
+ try:
227
+ head = await self._run(
228
+ self._client.head_object,
229
+ Bucket=self._bucket,
230
+ Key=key,
231
+ )
232
+ last_modified = head.get("LastModified")
233
+ mod_str = last_modified.isoformat() if last_modified else _now_iso()
234
+ return FileStat(
235
+ name=norm.rsplit("/", 1)[-1],
236
+ type="file",
237
+ size=head.get("ContentLength", 0),
238
+ created=mod_str,
239
+ modified=mod_str,
240
+ permissions="-rw-r--r--",
241
+ )
242
+ except ClientError as e:
243
+ if e.response["Error"]["Code"] != "404":
244
+ raise
245
+
246
+ # Try as directory marker
247
+ dir_key = self._dir_key(norm)
248
+ try:
249
+ head = await self._run(
250
+ self._client.head_object,
251
+ Bucket=self._bucket,
252
+ Key=dir_key,
253
+ )
254
+ last_modified = head.get("LastModified")
255
+ mod_str = last_modified.isoformat() if last_modified else _now_iso()
256
+ return FileStat(
257
+ name=norm.rsplit("/", 1)[-1],
258
+ type="directory",
259
+ size=0,
260
+ created=mod_str,
261
+ modified=mod_str,
262
+ permissions="drwxr-xr-x",
263
+ )
264
+ except ClientError as e:
265
+ if e.response["Error"]["Code"] != "404":
266
+ raise
267
+
268
+ # Check if there are objects with this prefix (virtual directory)
269
+ prefix = self._dir_prefix(norm)
270
+ response = await self._run(
271
+ self._client.list_objects_v2,
272
+ Bucket=self._bucket,
273
+ Prefix=prefix,
274
+ MaxKeys=1,
275
+ )
276
+ if response.get("Contents") or response.get("CommonPrefixes"):
277
+ return FileStat(
278
+ name=norm.rsplit("/", 1)[-1],
279
+ type="directory",
280
+ size=0,
281
+ created=_now_iso(),
282
+ modified=_now_iso(),
283
+ permissions="drwxr-xr-x",
284
+ )
285
+
286
+ raise FileStorageNotFoundError(path)
287
+
288
+ async def mkdir(self, path: str, recursive: bool = False) -> None:
289
+ norm = _normalize_path(path)
290
+ if not norm:
291
+ return # root always exists
292
+
293
+ dir_key = self._dir_key(norm)
294
+ await self._run(
295
+ self._client.put_object,
296
+ Bucket=self._bucket,
297
+ Key=dir_key,
298
+ Body=b"",
299
+ )
300
+
301
+ async def remove(self, path: str, recursive: bool = False) -> None:
302
+ norm = _normalize_path(path)
303
+ if not norm:
304
+ raise FileStorageNotFoundError(path)
305
+
306
+ key = self._to_key(norm)
307
+
308
+ # Check if it's a file
309
+ if await self._is_file(norm):
310
+ await self._run(
311
+ self._client.delete_object,
312
+ Bucket=self._bucket,
313
+ Key=key,
314
+ )
315
+ return
316
+
317
+ # Try as directory
318
+ prefix = self._dir_prefix(norm)
319
+ response = await self._run(
320
+ self._client.list_objects_v2,
321
+ Bucket=self._bucket,
322
+ Prefix=prefix,
323
+ )
324
+
325
+ objects = response.get("Contents", [])
326
+ if not objects:
327
+ raise FileStorageNotFoundError(path)
328
+
329
+ if not recursive and len(objects) > 1:
330
+ raise FileStorageNotADirectoryError(path)
331
+ # If there's exactly one object and it's the dir marker, allow removal
332
+ if (
333
+ not recursive
334
+ and len(objects) == 1
335
+ and not objects[0]["Key"].endswith("/")
336
+ ):
337
+ raise FileStorageNotADirectoryError(path)
338
+
339
+ # Batch delete all objects under the prefix
340
+ delete_keys = [{"Key": obj["Key"]} for obj in objects]
341
+ await self._run(
342
+ self._client.delete_objects,
343
+ Bucket=self._bucket,
344
+ Delete={"Objects": delete_keys},
345
+ )
346
+
347
+ async def copy(self, src: str, dest: str) -> None:
348
+ src_norm = _normalize_path(src)
349
+ dest_norm = _normalize_path(dest)
350
+ if not src_norm:
351
+ raise FileStorageNotFoundError(src)
352
+ if not dest_norm:
353
+ raise FileStorageIsADirectoryError(dest)
354
+
355
+ src_key = self._to_key(src_norm)
356
+ dest_key = self._to_key(dest_norm)
357
+
358
+ copy_source = {"Bucket": self._bucket, "Key": src_key}
359
+ try:
360
+ await self._run(
361
+ self._client.copy_object,
362
+ Bucket=self._bucket,
363
+ CopySource=copy_source,
364
+ Key=dest_key,
365
+ )
366
+ except ClientError as e:
367
+ if e.response["Error"]["Code"] == "NoSuchKey":
368
+ raise FileStorageNotFoundError(src)
369
+ raise
370
+
371
+ async def move(self, src: str, dest: str) -> None:
372
+ await self.copy(src, dest)
373
+ src_norm = _normalize_path(src)
374
+ src_key = self._to_key(src_norm)
375
+ await self._run(
376
+ self._client.delete_object,
377
+ Bucket=self._bucket,
378
+ Key=src_key,
379
+ )
380
+
381
+ async def exists(self, path: str) -> bool:
382
+ norm = _normalize_path(path)
383
+ if not norm:
384
+ return True # root always exists
385
+
386
+ # Check as file
387
+ if await self._is_file(norm):
388
+ return True
389
+
390
+ # Check as directory marker
391
+ dir_key = self._dir_key(norm)
392
+ try:
393
+ await self._run(
394
+ self._client.head_object,
395
+ Bucket=self._bucket,
396
+ Key=dir_key,
397
+ )
398
+ return True
399
+ except ClientError:
400
+ pass
401
+
402
+ # Check if any objects exist under this prefix (virtual dir)
403
+ prefix = self._dir_prefix(norm)
404
+ response = await self._run(
405
+ self._client.list_objects_v2,
406
+ Bucket=self._bucket,
407
+ Prefix=prefix,
408
+ MaxKeys=1,
409
+ )
410
+ return bool(response.get("Contents") or response.get("CommonPrefixes"))
411
+
412
+ async def get_download_stream(self, path: str) -> AsyncIterator[bytes]:
413
+ norm = _normalize_path(path)
414
+ if not norm:
415
+ raise FileStorageIsADirectoryError(path)
416
+
417
+ key = self._to_key(norm)
418
+ try:
419
+ response = await self._run(
420
+ self._client.get_object,
421
+ Bucket=self._bucket,
422
+ Key=key,
423
+ )
424
+ except ClientError as e:
425
+ if e.response["Error"]["Code"] == "NoSuchKey":
426
+ raise FileStorageNotFoundError(path)
427
+ raise
428
+
429
+ body = response["Body"]
430
+
431
+ async def _stream() -> AsyncIterator[bytes]:
432
+ chunk_size = 8192
433
+ while True:
434
+ chunk = await asyncio.get_event_loop().run_in_executor(
435
+ None, body.read, chunk_size
436
+ )
437
+ if not chunk:
438
+ break
439
+ yield chunk
440
+
441
+ return _stream()
442
+
443
+ async def upload_file(self, path: str, content: bytes) -> None:
444
+ await self.write_file(path, content)
445
+
446
+ # -- Private helpers ------------------------------------------------------
447
+
448
+ async def _is_file(self, norm_path: str) -> bool:
449
+ """Check if a normalized path corresponds to a file object."""
450
+ key = self._to_key(norm_path)
451
+ try:
452
+ await self._run(
453
+ self._client.head_object,
454
+ Bucket=self._bucket,
455
+ Key=key,
456
+ )
457
+ return True
458
+ except ClientError:
459
+ return False