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.
Files changed (35) hide show
  1. cloud_dog_storage/__init__.py +68 -0
  2. cloud_dog_storage/api/__init__.py +8 -0
  3. cloud_dog_storage/api/fastapi/__init__.py +12 -0
  4. cloud_dog_storage/api/fastapi/deps.py +42 -0
  5. cloud_dog_storage/async_wrapper.py +133 -0
  6. cloud_dog_storage/backends/__init__.py +45 -0
  7. cloud_dog_storage/backends/base.py +89 -0
  8. cloud_dog_storage/backends/ftp.py +252 -0
  9. cloud_dog_storage/backends/google_drive.py +386 -0
  10. cloud_dog_storage/backends/local.py +209 -0
  11. cloud_dog_storage/backends/s3.py +287 -0
  12. cloud_dog_storage/backends/webdav.py +280 -0
  13. cloud_dog_storage/config/__init__.py +28 -0
  14. cloud_dog_storage/config/models.py +89 -0
  15. cloud_dog_storage/errors.py +53 -0
  16. cloud_dog_storage/factory.py +49 -0
  17. cloud_dog_storage/models.py +54 -0
  18. cloud_dog_storage/observability/__init__.py +12 -0
  19. cloud_dog_storage/observability/logging.py +65 -0
  20. cloud_dog_storage/path_utils.py +224 -0
  21. cloud_dog_storage/security/__init__.py +13 -0
  22. cloud_dog_storage/security/path_sanitiser.py +62 -0
  23. cloud_dog_storage/security/tls.py +37 -0
  24. cloud_dog_storage/testing/__init__.py +13 -0
  25. cloud_dog_storage/testing/conformance.py +88 -0
  26. cloud_dog_storage/testing/fixtures.py +111 -0
  27. cloud_dog_storage/testing/mock_backend.py +164 -0
  28. cloud_dog_storage/traceability_ids.py +36 -0
  29. cloud_dog_storage/transfer.py +157 -0
  30. cloud_dog_storage-0.1.4.dist-info/METADATA +129 -0
  31. cloud_dog_storage-0.1.4.dist-info/RECORD +35 -0
  32. cloud_dog_storage-0.1.4.dist-info/WHEEL +4 -0
  33. cloud_dog_storage-0.1.4.dist-info/licenses/LICENCE +190 -0
  34. cloud_dog_storage-0.1.4.dist-info/licenses/LICENSE +176 -0
  35. 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)}"