cloud-dog-storage 0.1.4__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.
- cloud_dog_storage/__init__.py +68 -0
- cloud_dog_storage/api/__init__.py +8 -0
- cloud_dog_storage/api/fastapi/__init__.py +12 -0
- cloud_dog_storage/api/fastapi/deps.py +42 -0
- cloud_dog_storage/async_wrapper.py +133 -0
- cloud_dog_storage/backends/__init__.py +45 -0
- cloud_dog_storage/backends/base.py +89 -0
- cloud_dog_storage/backends/ftp.py +252 -0
- cloud_dog_storage/backends/google_drive.py +386 -0
- cloud_dog_storage/backends/local.py +209 -0
- cloud_dog_storage/backends/s3.py +287 -0
- cloud_dog_storage/backends/webdav.py +280 -0
- cloud_dog_storage/config/__init__.py +28 -0
- cloud_dog_storage/config/models.py +89 -0
- cloud_dog_storage/errors.py +53 -0
- cloud_dog_storage/factory.py +49 -0
- cloud_dog_storage/models.py +54 -0
- cloud_dog_storage/observability/__init__.py +12 -0
- cloud_dog_storage/observability/logging.py +65 -0
- cloud_dog_storage/path_utils.py +224 -0
- cloud_dog_storage/security/__init__.py +13 -0
- cloud_dog_storage/security/path_sanitiser.py +62 -0
- cloud_dog_storage/security/tls.py +37 -0
- cloud_dog_storage/testing/__init__.py +13 -0
- cloud_dog_storage/testing/conformance.py +88 -0
- cloud_dog_storage/testing/fixtures.py +111 -0
- cloud_dog_storage/testing/mock_backend.py +164 -0
- cloud_dog_storage/traceability_ids.py +36 -0
- cloud_dog_storage/transfer.py +157 -0
- cloud_dog_storage-0.1.4.dist-info/METADATA +129 -0
- cloud_dog_storage-0.1.4.dist-info/RECORD +35 -0
- cloud_dog_storage-0.1.4.dist-info/WHEEL +4 -0
- cloud_dog_storage-0.1.4.dist-info/licenses/LICENCE +190 -0
- cloud_dog_storage-0.1.4.dist-info/licenses/LICENSE +176 -0
- cloud_dog_storage-0.1.4.dist-info/licenses/NOTICE +7 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
**************************************************
|
|
3
|
+
License: Apache 2.0
|
|
4
|
+
Ownership: Cloud-Dog, Viewdeck Engineering Ltd.
|
|
5
|
+
Description: Public API exports for cloud_dog_storage.
|
|
6
|
+
Standard: PS-85 (Storage Interfaces)
|
|
7
|
+
**************************************************
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from cloud_dog_storage.backends.base import StorageBackend
|
|
11
|
+
from cloud_dog_storage.errors import (
|
|
12
|
+
BackendConnectionError,
|
|
13
|
+
ConfigurationError,
|
|
14
|
+
NotSupportedError,
|
|
15
|
+
QuotaExceededError,
|
|
16
|
+
StorageError,
|
|
17
|
+
StorageFileNotFoundError,
|
|
18
|
+
StoragePermissionError,
|
|
19
|
+
)
|
|
20
|
+
from cloud_dog_storage.factory import build_storage_backend
|
|
21
|
+
from cloud_dog_storage.models import StorageEntry, StorageStat, StoredFile
|
|
22
|
+
from cloud_dog_storage import path_utils
|
|
23
|
+
from cloud_dog_storage.transfer import (
|
|
24
|
+
AttachmentDescriptor,
|
|
25
|
+
decode_base64,
|
|
26
|
+
detect_content_type,
|
|
27
|
+
encode_base64,
|
|
28
|
+
fetch_uri,
|
|
29
|
+
list_mime_attachments,
|
|
30
|
+
sanitize_filename,
|
|
31
|
+
validate_file_size,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"AsyncStorageBackend",
|
|
36
|
+
"AttachmentDescriptor",
|
|
37
|
+
"BackendConnectionError",
|
|
38
|
+
"ConfigurationError",
|
|
39
|
+
"NotSupportedError",
|
|
40
|
+
"QuotaExceededError",
|
|
41
|
+
"StorageBackend",
|
|
42
|
+
"StorageEntry",
|
|
43
|
+
"StorageError",
|
|
44
|
+
"StorageFileNotFoundError",
|
|
45
|
+
"StoragePermissionError",
|
|
46
|
+
"StorageStat",
|
|
47
|
+
"StoredFile",
|
|
48
|
+
"build_storage_backend",
|
|
49
|
+
"decode_base64",
|
|
50
|
+
"detect_content_type",
|
|
51
|
+
"encode_base64",
|
|
52
|
+
"fetch_uri",
|
|
53
|
+
"list_mime_attachments",
|
|
54
|
+
"path_utils",
|
|
55
|
+
"sanitize_filename",
|
|
56
|
+
"validate_file_size",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
__version__ = "0.1.4"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def __getattr__(name: str):
|
|
63
|
+
"""Lazy import async wrapper to avoid unnecessary asyncio imports."""
|
|
64
|
+
if name == "AsyncStorageBackend":
|
|
65
|
+
from cloud_dog_storage.async_wrapper import AsyncStorageBackend
|
|
66
|
+
|
|
67
|
+
return AsyncStorageBackend
|
|
68
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""
|
|
2
|
+
**************************************************
|
|
3
|
+
License: Apache 2.0
|
|
4
|
+
Ownership: Cloud-Dog, Viewdeck Engineering Ltd.
|
|
5
|
+
Description: API integration exports for cloud_dog_storage.
|
|
6
|
+
Standard: PS-85 (Storage Interfaces)
|
|
7
|
+
**************************************************
|
|
8
|
+
"""
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
**************************************************
|
|
3
|
+
License: Apache 2.0
|
|
4
|
+
Ownership: Cloud-Dog, Viewdeck Engineering Ltd.
|
|
5
|
+
Description: FastAPI dependency exports for storage backends.
|
|
6
|
+
Standard: PS-85 (Storage Interfaces)
|
|
7
|
+
**************************************************
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from cloud_dog_storage.api.fastapi.deps import get_storage_backend
|
|
11
|
+
|
|
12
|
+
__all__ = ["get_storage_backend"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
**************************************************
|
|
3
|
+
License: Apache 2.0
|
|
4
|
+
Ownership: Cloud-Dog, Viewdeck Engineering Ltd.
|
|
5
|
+
Description: FastAPI dependency provider for storage backend access.
|
|
6
|
+
Standard: PS-85 (Storage Interfaces)
|
|
7
|
+
**************************************************
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
# Optional dependency: only imported when FastAPI integration is used.
|
|
14
|
+
from starlette.requests import Request
|
|
15
|
+
except Exception: # pragma: no cover
|
|
16
|
+
Request = object # type: ignore[assignment]
|
|
17
|
+
|
|
18
|
+
from cloud_dog_storage.config.models import StorageConfig
|
|
19
|
+
from cloud_dog_storage.factory import build_storage_backend
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from cloud_dog_storage.backends.base import StorageBackend
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_storage_backend(request: Request) -> "StorageBackend":
|
|
26
|
+
"""Return storage backend from app state, creating it from `storage_config` if needed."""
|
|
27
|
+
app_state = request.app.state
|
|
28
|
+
backend = getattr(app_state, "storage_backend", None)
|
|
29
|
+
if backend is not None:
|
|
30
|
+
return backend
|
|
31
|
+
|
|
32
|
+
config = getattr(app_state, "storage_config", None)
|
|
33
|
+
if config is None:
|
|
34
|
+
raise RuntimeError("FastAPI app.state.storage_config or app.state.storage_backend is required")
|
|
35
|
+
|
|
36
|
+
if isinstance(config, StorageConfig):
|
|
37
|
+
backend = build_storage_backend(config)
|
|
38
|
+
else:
|
|
39
|
+
backend = build_storage_backend(StorageConfig.model_validate(config))
|
|
40
|
+
|
|
41
|
+
app_state.storage_backend = backend
|
|
42
|
+
return backend
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""
|
|
2
|
+
**************************************************
|
|
3
|
+
License: Apache 2.0
|
|
4
|
+
Ownership: Cloud-Dog, Viewdeck Engineering Ltd.
|
|
5
|
+
Description: Async wrapper that delegates sync storage operations to a thread pool.
|
|
6
|
+
Standard: PS-85 (Storage Interfaces)
|
|
7
|
+
**************************************************
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import os
|
|
14
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
15
|
+
from functools import partial
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
from cloud_dog_storage.models import StorageEntry, StorageStat, StoredFile
|
|
19
|
+
from cloud_dog_storage.security.path_sanitiser import clean_posix
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Iterable
|
|
23
|
+
|
|
24
|
+
from cloud_dog_storage.backends.base import StorageBackend
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AsyncStorageBackend:
|
|
28
|
+
"""Wrap a synchronous backend and execute operations in a thread pool.
|
|
29
|
+
|
|
30
|
+
This wrapper uses an explicit ThreadPoolExecutor instead of the event loop default
|
|
31
|
+
executor to avoid environment-specific default executor issues.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, backend: StorageBackend, *, max_workers: int | None = None) -> None:
|
|
35
|
+
self._backend = backend
|
|
36
|
+
self._executor = ThreadPoolExecutor(
|
|
37
|
+
max_workers=max_workers,
|
|
38
|
+
thread_name_prefix="cloud_dog_storage",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def backend_name(self) -> str:
|
|
43
|
+
return self._backend.backend_name
|
|
44
|
+
|
|
45
|
+
def close(self) -> None:
|
|
46
|
+
"""Shut down the internal executor."""
|
|
47
|
+
self._executor.shutdown(wait=False, cancel_futures=True)
|
|
48
|
+
|
|
49
|
+
async def _run(self, func, /, *args, **kwargs): # type: ignore[no-untyped-def]
|
|
50
|
+
# In some environments, the event-loop self-pipe wake mechanism is unreliable, causing
|
|
51
|
+
# `await loop.run_in_executor(...)` to hang if the thread finishes after the loop blocks.
|
|
52
|
+
# Submitting directly and polling via `asyncio.wait(..., timeout=...)` guarantees the loop
|
|
53
|
+
# wakes periodically to process the completion callback.
|
|
54
|
+
cfut = self._executor.submit(partial(func, *args, **kwargs))
|
|
55
|
+
afut = asyncio.wrap_future(cfut)
|
|
56
|
+
while True:
|
|
57
|
+
done, _ = await asyncio.wait({afut}, timeout=0.05)
|
|
58
|
+
if done:
|
|
59
|
+
return await afut
|
|
60
|
+
|
|
61
|
+
async def read_bytes(self, path: str) -> bytes:
|
|
62
|
+
return await self._run(self._backend.read_bytes, path)
|
|
63
|
+
|
|
64
|
+
async def write_bytes(self, path: str, data: bytes, *, overwrite: bool = True) -> None:
|
|
65
|
+
await self._run(self._backend.write_bytes, path, data, overwrite=overwrite)
|
|
66
|
+
|
|
67
|
+
async def delete_path(self, path: str, *, missing_ok: bool = False) -> None:
|
|
68
|
+
await self._run(self._backend.delete_path, path, missing_ok=missing_ok)
|
|
69
|
+
|
|
70
|
+
async def list_dir(self, path: str, *, recursive: bool = False) -> list[StorageEntry]:
|
|
71
|
+
return await self._run(self._backend.list_dir, path, recursive=recursive)
|
|
72
|
+
|
|
73
|
+
async def stat(self, path: str) -> StorageStat | None:
|
|
74
|
+
return await self._run(self._backend.stat, path)
|
|
75
|
+
|
|
76
|
+
async def exists(self, path: str) -> bool:
|
|
77
|
+
return await self._run(self._backend.exists, path)
|
|
78
|
+
|
|
79
|
+
async def create_dir(self, path: str, *, parents: bool = True, exist_ok: bool = True) -> None:
|
|
80
|
+
await self._run(self._backend.create_dir, path, parents=parents, exist_ok=exist_ok)
|
|
81
|
+
|
|
82
|
+
async def copy_path(self, src: str, dst: str, *, overwrite: bool = False) -> None:
|
|
83
|
+
await self._run(self._backend.copy_path, src, dst, overwrite=overwrite)
|
|
84
|
+
|
|
85
|
+
async def move_path(self, src: str, dst: str, *, overwrite: bool = False) -> None:
|
|
86
|
+
await self._run(self._backend.move_path, src, dst, overwrite=overwrite)
|
|
87
|
+
|
|
88
|
+
async def rename_path(self, src: str, dst: str, *, overwrite: bool = False) -> None:
|
|
89
|
+
await self._run(self._backend.rename_path, src, dst, overwrite=overwrite)
|
|
90
|
+
|
|
91
|
+
async def chmod_path(self, path: str, mode: int, *, recursive: bool = False) -> None:
|
|
92
|
+
await self._run(self._backend.chmod_path, path, mode, recursive=recursive)
|
|
93
|
+
|
|
94
|
+
async def iter_paths(self, roots: Iterable[str], *, max_depth: int | None = None) -> list[str]:
|
|
95
|
+
return await self._run(lambda: list(self._backend.iter_paths(roots, max_depth=max_depth)))
|
|
96
|
+
|
|
97
|
+
async def get_url(self, path: str) -> str:
|
|
98
|
+
return await self._run(self._backend.get_url, path)
|
|
99
|
+
|
|
100
|
+
async def store_file(
|
|
101
|
+
self,
|
|
102
|
+
content: bytes,
|
|
103
|
+
filename: str,
|
|
104
|
+
content_type: str,
|
|
105
|
+
metadata: dict | None = None,
|
|
106
|
+
) -> StoredFile:
|
|
107
|
+
logical = clean_posix(filename)
|
|
108
|
+
await self.write_bytes(logical, content, overwrite=True)
|
|
109
|
+
extension = os.path.splitext(logical)[1].lstrip(".") or (
|
|
110
|
+
content_type.split("/")[-1] if "/" in content_type else "bin"
|
|
111
|
+
)
|
|
112
|
+
return StoredFile(
|
|
113
|
+
path=logical,
|
|
114
|
+
format=extension,
|
|
115
|
+
size_bytes=len(content),
|
|
116
|
+
backend_name=self.backend_name,
|
|
117
|
+
metadata=metadata or {},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
async def get_file_content(self, path: str) -> bytes:
|
|
121
|
+
return await self.read_bytes(path)
|
|
122
|
+
|
|
123
|
+
async def delete_file(self, path: str) -> bool:
|
|
124
|
+
if not await self.exists(path):
|
|
125
|
+
return False
|
|
126
|
+
await self.delete_path(path, missing_ok=True)
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
async def file_exists(self, path: str) -> bool:
|
|
130
|
+
return await self.exists(path)
|
|
131
|
+
|
|
132
|
+
async def get_file_url(self, path: str) -> str:
|
|
133
|
+
return await self.get_url(path)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
**************************************************
|
|
3
|
+
License: Apache 2.0
|
|
4
|
+
Ownership: Cloud-Dog, Viewdeck Engineering Ltd.
|
|
5
|
+
Description: Storage backend exports with lazy imports for optional backends.
|
|
6
|
+
Standard: PS-85 (Storage Interfaces)
|
|
7
|
+
**************************************************
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from cloud_dog_storage.backends.base import StorageBackend
|
|
15
|
+
from cloud_dog_storage.backends.local import LocalStorage
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from cloud_dog_storage.backends.ftp import FtpStorage
|
|
19
|
+
from cloud_dog_storage.backends.s3 import S3Storage
|
|
20
|
+
from cloud_dog_storage.backends.webdav import WebDavStorage
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"FtpStorage",
|
|
24
|
+
"LocalStorage",
|
|
25
|
+
"S3Storage",
|
|
26
|
+
"StorageBackend",
|
|
27
|
+
"WebDavStorage",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def __getattr__(name: str):
|
|
32
|
+
# Optional backends must not be imported unless explicitly requested.
|
|
33
|
+
if name == "S3Storage":
|
|
34
|
+
from cloud_dog_storage.backends.s3 import S3Storage
|
|
35
|
+
|
|
36
|
+
return S3Storage
|
|
37
|
+
if name == "WebDavStorage":
|
|
38
|
+
from cloud_dog_storage.backends.webdav import WebDavStorage
|
|
39
|
+
|
|
40
|
+
return WebDavStorage
|
|
41
|
+
if name == "FtpStorage":
|
|
42
|
+
from cloud_dog_storage.backends.ftp import FtpStorage
|
|
43
|
+
|
|
44
|
+
return FtpStorage
|
|
45
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
**************************************************
|
|
3
|
+
License: Apache 2.0
|
|
4
|
+
Ownership: Cloud-Dog, Viewdeck Engineering Ltd.
|
|
5
|
+
Description: Base storage backend interface and default behaviour.
|
|
6
|
+
Standard: PS-85 (Storage Interfaces)
|
|
7
|
+
**************************************************
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from cloud_dog_storage.errors import NotSupportedError
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Iterable
|
|
18
|
+
|
|
19
|
+
from cloud_dog_storage.models import StorageEntry, StorageStat
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class StorageBackend:
|
|
23
|
+
"""Minimal file-like API over a backing store."""
|
|
24
|
+
|
|
25
|
+
backend_name: str = "unknown"
|
|
26
|
+
|
|
27
|
+
def read_bytes(self, path: str) -> bytes:
|
|
28
|
+
raise NotImplementedError
|
|
29
|
+
|
|
30
|
+
def write_bytes(self, path: str, data: bytes, *, overwrite: bool = True) -> None:
|
|
31
|
+
raise NotImplementedError
|
|
32
|
+
|
|
33
|
+
def delete_path(self, path: str, *, missing_ok: bool = False) -> None:
|
|
34
|
+
raise NotImplementedError
|
|
35
|
+
|
|
36
|
+
def list_dir(self, path: str, *, recursive: bool = False) -> list[StorageEntry]:
|
|
37
|
+
raise NotImplementedError
|
|
38
|
+
|
|
39
|
+
def stat(self, path: str) -> StorageStat | None:
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
|
|
42
|
+
def exists(self, path: str) -> bool:
|
|
43
|
+
"""Check path existence via `stat` by default."""
|
|
44
|
+
return self.stat(path) is not None
|
|
45
|
+
|
|
46
|
+
def create_dir(self, path: str, *, parents: bool = True, exist_ok: bool = True) -> None:
|
|
47
|
+
raise NotSupportedError("create_dir", backend=self.backend_name)
|
|
48
|
+
|
|
49
|
+
def copy_path(self, src: str, dst: str, *, overwrite: bool = False) -> None:
|
|
50
|
+
raise NotSupportedError("copy_path", backend=self.backend_name)
|
|
51
|
+
|
|
52
|
+
def move_path(self, src: str, dst: str, *, overwrite: bool = False) -> None:
|
|
53
|
+
raise NotSupportedError("move_path", backend=self.backend_name)
|
|
54
|
+
|
|
55
|
+
def rename_path(self, src: str, dst: str, *, overwrite: bool = False) -> None:
|
|
56
|
+
self.move_path(src, dst, overwrite=overwrite)
|
|
57
|
+
|
|
58
|
+
def chmod_path(self, path: str, mode: int, *, recursive: bool = False) -> None:
|
|
59
|
+
raise NotSupportedError("chmod_path", backend=self.backend_name)
|
|
60
|
+
|
|
61
|
+
def iter_paths(self, roots: Iterable[str], *, max_depth: int | None = None) -> Iterable[str]:
|
|
62
|
+
"""Enumerate file paths under the given roots."""
|
|
63
|
+
for root in roots:
|
|
64
|
+
queue: list[tuple[str, int]] = [(root, 0)]
|
|
65
|
+
while queue:
|
|
66
|
+
current, depth = queue.pop(0)
|
|
67
|
+
for entry in self.list_dir(current, recursive=False):
|
|
68
|
+
next_depth = depth + 1
|
|
69
|
+
if entry.is_dir:
|
|
70
|
+
if max_depth is None or next_depth <= max_depth:
|
|
71
|
+
queue.append((entry.path, next_depth))
|
|
72
|
+
continue
|
|
73
|
+
if max_depth is None or next_depth <= max_depth:
|
|
74
|
+
yield entry.path
|
|
75
|
+
|
|
76
|
+
def append_text(self, path: str, text: str, *, encoding: str = "utf-8") -> None:
|
|
77
|
+
"""Append text to a file."""
|
|
78
|
+
raise NotSupportedError("append_text", backend=self.backend_name)
|
|
79
|
+
|
|
80
|
+
def copy_with_metadata(self, src: str, dst: str) -> None:
|
|
81
|
+
"""Copy a file preserving metadata (timestamps, permissions)."""
|
|
82
|
+
raise NotSupportedError("copy_with_metadata", backend=self.backend_name)
|
|
83
|
+
|
|
84
|
+
def disk_usage(self) -> tuple[int, int, int]:
|
|
85
|
+
"""Return (total, used, free) bytes for the backend's storage."""
|
|
86
|
+
raise NotSupportedError("disk_usage", backend=self.backend_name)
|
|
87
|
+
|
|
88
|
+
def get_url(self, path: str) -> str:
|
|
89
|
+
raise NotSupportedError("get_url", backend=self.backend_name)
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""
|
|
2
|
+
**************************************************
|
|
3
|
+
License: Apache 2.0
|
|
4
|
+
Ownership: Cloud-Dog, Viewdeck Engineering Ltd.
|
|
5
|
+
Description: FTP/FTPS backend with MLSD fallback and connection-per-operation behaviour.
|
|
6
|
+
Standard: PS-85 (Storage Interfaces)
|
|
7
|
+
**************************************************
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import io
|
|
13
|
+
import posixpath
|
|
14
|
+
from ftplib import FTP, FTP_TLS, error_perm
|
|
15
|
+
|
|
16
|
+
from cloud_dog_storage.config.models import FtpConfig, TlsConfig
|
|
17
|
+
from cloud_dog_storage.errors import (
|
|
18
|
+
BackendConnectionError,
|
|
19
|
+
ConfigurationError,
|
|
20
|
+
NotSupportedError,
|
|
21
|
+
StorageFileNotFoundError,
|
|
22
|
+
)
|
|
23
|
+
from cloud_dog_storage.models import StorageEntry, StorageStat
|
|
24
|
+
from cloud_dog_storage.security.path_sanitiser import clean_posix
|
|
25
|
+
from cloud_dog_storage.security.tls import build_ssl_context
|
|
26
|
+
|
|
27
|
+
from .base import StorageBackend
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FtpStorage(StorageBackend):
|
|
31
|
+
"""FTP/FTPS backend with thread-safe connection-per-operation semantics."""
|
|
32
|
+
|
|
33
|
+
backend_name = "ftp"
|
|
34
|
+
|
|
35
|
+
def __init__(self, config: FtpConfig, *, tls: TlsConfig | None = None, timeout_s: int = 30) -> None:
|
|
36
|
+
if not config.host:
|
|
37
|
+
raise ConfigurationError("FTP storage requires ftp.host", backend_name=self.backend_name)
|
|
38
|
+
self._host = config.host
|
|
39
|
+
self._port = int(config.port)
|
|
40
|
+
self._username = config.username or ""
|
|
41
|
+
self._password = config.password or ""
|
|
42
|
+
self._base_dir = clean_posix(config.base_dir or "/")
|
|
43
|
+
self._use_tls = bool(config.use_tls)
|
|
44
|
+
self._timeout_s = int(timeout_s)
|
|
45
|
+
self._ssl_context = build_ssl_context(tls or TlsConfig()) if self._use_tls else None
|
|
46
|
+
|
|
47
|
+
def _connect(self) -> FTP:
|
|
48
|
+
try:
|
|
49
|
+
ftp = FTP_TLS(context=self._ssl_context) if self._use_tls else FTP()
|
|
50
|
+
ftp.connect(self._host, self._port, timeout=self._timeout_s)
|
|
51
|
+
ftp.login(self._username, self._password)
|
|
52
|
+
if self._use_tls and isinstance(ftp, FTP_TLS):
|
|
53
|
+
ftp.prot_p()
|
|
54
|
+
if self._base_dir != "/":
|
|
55
|
+
ftp.cwd(self._base_dir)
|
|
56
|
+
return ftp
|
|
57
|
+
except Exception as exc:
|
|
58
|
+
raise BackendConnectionError(f"FTP connection failed: {exc}", backend_name=self.backend_name) from exc
|
|
59
|
+
|
|
60
|
+
def _remote_path(self, path: str) -> str:
|
|
61
|
+
relative = clean_posix(path).lstrip("/")
|
|
62
|
+
if not relative:
|
|
63
|
+
return self._base_dir
|
|
64
|
+
if self._base_dir == "/":
|
|
65
|
+
return "/" + relative
|
|
66
|
+
return posixpath.join(self._base_dir, relative)
|
|
67
|
+
|
|
68
|
+
def read_bytes(self, path: str) -> bytes:
|
|
69
|
+
ftp = self._connect()
|
|
70
|
+
try:
|
|
71
|
+
buffer = io.BytesIO()
|
|
72
|
+
ftp.retrbinary(f"RETR {self._remote_path(path)}", buffer.write)
|
|
73
|
+
return buffer.getvalue()
|
|
74
|
+
except error_perm as exc:
|
|
75
|
+
if str(exc).startswith("550"):
|
|
76
|
+
raise StorageFileNotFoundError(clean_posix(path), backend=self.backend_name) from exc
|
|
77
|
+
raise
|
|
78
|
+
finally:
|
|
79
|
+
try:
|
|
80
|
+
ftp.quit()
|
|
81
|
+
except Exception:
|
|
82
|
+
ftp.close()
|
|
83
|
+
|
|
84
|
+
def write_bytes(self, path: str, data: bytes, *, overwrite: bool = True) -> None:
|
|
85
|
+
ftp = self._connect()
|
|
86
|
+
remote = self._remote_path(path)
|
|
87
|
+
try:
|
|
88
|
+
if not overwrite:
|
|
89
|
+
try:
|
|
90
|
+
ftp.size(remote)
|
|
91
|
+
raise FileExistsError(clean_posix(path))
|
|
92
|
+
except error_perm:
|
|
93
|
+
pass
|
|
94
|
+
ftp.storbinary(f"STOR {remote}", io.BytesIO(data))
|
|
95
|
+
finally:
|
|
96
|
+
try:
|
|
97
|
+
ftp.quit()
|
|
98
|
+
except Exception:
|
|
99
|
+
ftp.close()
|
|
100
|
+
|
|
101
|
+
def delete_path(self, path: str, *, missing_ok: bool = False) -> None:
|
|
102
|
+
ftp = self._connect()
|
|
103
|
+
remote = self._remote_path(path)
|
|
104
|
+
try:
|
|
105
|
+
try:
|
|
106
|
+
ftp.delete(remote)
|
|
107
|
+
return
|
|
108
|
+
except error_perm as exc:
|
|
109
|
+
if str(exc).startswith("550"):
|
|
110
|
+
try:
|
|
111
|
+
ftp.rmd(remote)
|
|
112
|
+
return
|
|
113
|
+
except error_perm as exc_dir:
|
|
114
|
+
if str(exc_dir).startswith("550") and missing_ok:
|
|
115
|
+
return
|
|
116
|
+
if str(exc_dir).startswith("550"):
|
|
117
|
+
raise StorageFileNotFoundError(clean_posix(path), backend=self.backend_name) from exc_dir
|
|
118
|
+
raise
|
|
119
|
+
raise
|
|
120
|
+
finally:
|
|
121
|
+
try:
|
|
122
|
+
ftp.quit()
|
|
123
|
+
except Exception:
|
|
124
|
+
ftp.close()
|
|
125
|
+
|
|
126
|
+
def stat(self, path: str) -> StorageStat | None:
|
|
127
|
+
ftp = self._connect()
|
|
128
|
+
remote = self._remote_path(path)
|
|
129
|
+
try:
|
|
130
|
+
try:
|
|
131
|
+
size = ftp.size(remote)
|
|
132
|
+
if size is not None:
|
|
133
|
+
return StorageStat(path=clean_posix(path), is_dir=False, size=int(size))
|
|
134
|
+
except error_perm:
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
current = ftp.pwd()
|
|
138
|
+
try:
|
|
139
|
+
ftp.cwd(remote)
|
|
140
|
+
ftp.cwd(current)
|
|
141
|
+
return StorageStat(path=clean_posix(path), is_dir=True, size=None)
|
|
142
|
+
except error_perm:
|
|
143
|
+
return None
|
|
144
|
+
finally:
|
|
145
|
+
try:
|
|
146
|
+
ftp.quit()
|
|
147
|
+
except Exception:
|
|
148
|
+
ftp.close()
|
|
149
|
+
|
|
150
|
+
def list_dir(self, path: str, *, recursive: bool = False) -> list[StorageEntry]:
|
|
151
|
+
ftp = self._connect()
|
|
152
|
+
root = clean_posix(path)
|
|
153
|
+
|
|
154
|
+
def list_one(directory: str) -> list[tuple[str, bool]]:
|
|
155
|
+
remote = self._remote_path(directory)
|
|
156
|
+
items: list[tuple[str, bool]] = []
|
|
157
|
+
try:
|
|
158
|
+
for name, facts in ftp.mlsd(remote):
|
|
159
|
+
if name in {".", ".."}:
|
|
160
|
+
continue
|
|
161
|
+
items.append((name, facts.get("type") == "dir"))
|
|
162
|
+
return items
|
|
163
|
+
except Exception:
|
|
164
|
+
for full in ftp.nlst(remote):
|
|
165
|
+
name = posixpath.basename(full.rstrip("/"))
|
|
166
|
+
if not name or name in {".", ".."}:
|
|
167
|
+
continue
|
|
168
|
+
is_dir = False
|
|
169
|
+
current = ftp.pwd()
|
|
170
|
+
try:
|
|
171
|
+
ftp.cwd(posixpath.join(remote, name))
|
|
172
|
+
ftp.cwd(current)
|
|
173
|
+
is_dir = True
|
|
174
|
+
except Exception:
|
|
175
|
+
is_dir = False
|
|
176
|
+
items.append((name, is_dir))
|
|
177
|
+
return items
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
out: list[StorageEntry] = []
|
|
181
|
+
queue: list[str] = [root]
|
|
182
|
+
while queue:
|
|
183
|
+
current = queue.pop(0)
|
|
184
|
+
for name, is_dir in list_one(current):
|
|
185
|
+
child = clean_posix(posixpath.join(current, name))
|
|
186
|
+
out.append(StorageEntry(path=child, is_dir=is_dir))
|
|
187
|
+
if recursive and is_dir:
|
|
188
|
+
queue.append(child)
|
|
189
|
+
return out
|
|
190
|
+
except error_perm as exc:
|
|
191
|
+
if str(exc).startswith("550"):
|
|
192
|
+
raise StorageFileNotFoundError(clean_posix(path), backend=self.backend_name) from exc
|
|
193
|
+
raise
|
|
194
|
+
finally:
|
|
195
|
+
try:
|
|
196
|
+
ftp.quit()
|
|
197
|
+
except Exception:
|
|
198
|
+
ftp.close()
|
|
199
|
+
|
|
200
|
+
def create_dir(self, path: str, *, parents: bool = True, exist_ok: bool = True) -> None:
|
|
201
|
+
ftp = self._connect()
|
|
202
|
+
try:
|
|
203
|
+
target = clean_posix(path)
|
|
204
|
+
parts = [part for part in target.split("/") if part]
|
|
205
|
+
current = "/"
|
|
206
|
+
for part in parts:
|
|
207
|
+
current = clean_posix(posixpath.join(current, part))
|
|
208
|
+
remote = self._remote_path(current)
|
|
209
|
+
try:
|
|
210
|
+
ftp.mkd(remote)
|
|
211
|
+
except error_perm as exc:
|
|
212
|
+
if exist_ok and str(exc).startswith("550"):
|
|
213
|
+
continue
|
|
214
|
+
raise
|
|
215
|
+
if not parents:
|
|
216
|
+
break
|
|
217
|
+
finally:
|
|
218
|
+
try:
|
|
219
|
+
ftp.quit()
|
|
220
|
+
except Exception:
|
|
221
|
+
ftp.close()
|
|
222
|
+
|
|
223
|
+
def copy_path(self, src: str, dst: str, *, overwrite: bool = False) -> None:
|
|
224
|
+
data = self.read_bytes(src)
|
|
225
|
+
self.write_bytes(dst, data, overwrite=overwrite)
|
|
226
|
+
|
|
227
|
+
def move_path(self, src: str, dst: str, *, overwrite: bool = False) -> None:
|
|
228
|
+
ftp = self._connect()
|
|
229
|
+
source = self._remote_path(src)
|
|
230
|
+
target = self._remote_path(dst)
|
|
231
|
+
try:
|
|
232
|
+
if not overwrite:
|
|
233
|
+
try:
|
|
234
|
+
ftp.size(target)
|
|
235
|
+
raise FileExistsError(clean_posix(dst))
|
|
236
|
+
except error_perm:
|
|
237
|
+
pass
|
|
238
|
+
ftp.rename(source, target)
|
|
239
|
+
finally:
|
|
240
|
+
try:
|
|
241
|
+
ftp.quit()
|
|
242
|
+
except Exception:
|
|
243
|
+
ftp.close()
|
|
244
|
+
|
|
245
|
+
def chmod_path(self, path: str, mode: int, *, recursive: bool = False) -> None:
|
|
246
|
+
_ = path
|
|
247
|
+
_ = mode
|
|
248
|
+
_ = recursive
|
|
249
|
+
raise NotSupportedError("chmod_path", backend=self.backend_name)
|
|
250
|
+
|
|
251
|
+
def get_url(self, path: str) -> str:
|
|
252
|
+
return f"ftp://{self._host}:{self._port}{self._remote_path(path)}"
|