qodalis-cli-server-plugin-filesystem-s3 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_s3/__init__.py +10 -0
- qodalis_cli_filesystem_s3/s3_provider.py +459 -0
- qodalis_cli_server_plugin_filesystem_s3-1.0.0b1.dist-info/METADATA +22 -0
- qodalis_cli_server_plugin_filesystem_s3-1.0.0b1.dist-info/RECORD +6 -0
- qodalis_cli_server_plugin_filesystem_s3-1.0.0b1.dist-info/WHEEL +4 -0
- qodalis_cli_server_plugin_filesystem_s3-1.0.0b1.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
|
@@ -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,6 @@
|
|
|
1
|
+
qodalis_cli_filesystem_s3/__init__.py,sha256=h7OXBWx0D9FgZPSfPe5D7Hy-UEv6Pc59OPHmnveGD5E,246
|
|
2
|
+
qodalis_cli_filesystem_s3/s3_provider.py,sha256=pk8pgJ6tcQZMGzSGD_ZVovVdkpgrI-RdrU7lw8BCA_k,14537
|
|
3
|
+
qodalis_cli_server_plugin_filesystem_s3-1.0.0b1.dist-info/METADATA,sha256=ioj_CkCeleq7g3jugdm9EgMSaKvT2D-x6aopWRHfZ1M,1055
|
|
4
|
+
qodalis_cli_server_plugin_filesystem_s3-1.0.0b1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
5
|
+
qodalis_cli_server_plugin_filesystem_s3-1.0.0b1.dist-info/licenses/LICENSE,sha256=uagko_O2JboeVOW3cldvxKW4QIDjTHCCk5T32qqg_As,1079
|
|
6
|
+
qodalis_cli_server_plugin_filesystem_s3-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.
|