s3ui 1.0.0__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.
@@ -0,0 +1,260 @@
1
+ """Download worker — handles single and ranged-GET downloads as a QRunnable."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import random
7
+ import threading
8
+ import time
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ from PyQt6.QtCore import QObject, QRunnable, pyqtSignal
13
+
14
+ from s3ui.constants import DEFAULT_PART_SIZE, MAX_RETRY_ATTEMPTS, MULTIPART_THRESHOLD
15
+
16
+ if TYPE_CHECKING:
17
+ from s3ui.core.s3_client import S3Client
18
+ from s3ui.db.database import Database
19
+
20
+ logger = logging.getLogger("s3ui.download_worker")
21
+
22
+
23
+ class DownloadWorkerSignals(QObject):
24
+ progress = pyqtSignal(int, int, int) # transfer_id, bytes_done, total
25
+ speed = pyqtSignal(int, float) # transfer_id, bytes_per_sec
26
+ finished = pyqtSignal(int) # transfer_id
27
+ failed = pyqtSignal(int, str, str) # transfer_id, user_msg, detail
28
+
29
+
30
+ class DownloadWorker(QRunnable):
31
+ """Downloads a file from S3, with ranged GET and resume support."""
32
+
33
+ def __init__(
34
+ self,
35
+ transfer_id: int,
36
+ s3_client: S3Client,
37
+ db: Database,
38
+ bucket: str,
39
+ pause_event: threading.Event,
40
+ cancel_event: threading.Event,
41
+ ) -> None:
42
+ super().__init__()
43
+ self.setAutoDelete(True)
44
+ self.signals = DownloadWorkerSignals()
45
+ self.transfer_id = transfer_id
46
+ self._s3 = s3_client
47
+ self._db = db
48
+ self._bucket = bucket
49
+ self._pause = pause_event
50
+ self._cancel = cancel_event
51
+
52
+ self._speed_window: list[tuple[float, int]] = []
53
+ self._last_speed_emit = 0.0
54
+
55
+ def run(self) -> None:
56
+ try:
57
+ self._do_download()
58
+ except Exception as e:
59
+ import traceback
60
+
61
+ logger.error("Download %d failed: %s", self.transfer_id, e)
62
+ try:
63
+ self._mark_failed(str(e))
64
+ except Exception:
65
+ logger.exception("Failed to mark download %d as failed", self.transfer_id)
66
+ self.signals.failed.emit(self.transfer_id, str(e), traceback.format_exc())
67
+
68
+ def _do_download(self) -> None:
69
+ row = self._db.fetchone("SELECT * FROM transfers WHERE id = ?", (self.transfer_id,))
70
+ if not row:
71
+ self.signals.failed.emit(self.transfer_id, "Transfer record not found.", "")
72
+ return
73
+
74
+ local_path = Path(row["local_path"])
75
+ object_key = row["object_key"]
76
+
77
+ # Validate destination directory
78
+ if not local_path.parent.exists():
79
+ self._mark_failed("Destination directory does not exist.")
80
+ self.signals.failed.emit(
81
+ self.transfer_id,
82
+ "Destination directory does not exist.",
83
+ str(local_path.parent),
84
+ )
85
+ return
86
+
87
+ self._db.execute(
88
+ "UPDATE transfers SET status = 'in_progress', updated_at = datetime('now') "
89
+ "WHERE id = ?",
90
+ (self.transfer_id,),
91
+ )
92
+
93
+ # Get object metadata
94
+ item = self._s3.head_object(self._bucket, object_key)
95
+ total_size = item.size or 0
96
+
97
+ self._db.execute(
98
+ "UPDATE transfers SET total_bytes = ? WHERE id = ?",
99
+ (total_size, self.transfer_id),
100
+ )
101
+
102
+ temp_path = local_path.parent / f".s3ui-download-{self.transfer_id}.tmp"
103
+
104
+ if total_size < MULTIPART_THRESHOLD:
105
+ self._single_download(object_key, local_path, temp_path, total_size)
106
+ else:
107
+ self._ranged_download(object_key, local_path, temp_path, total_size)
108
+
109
+ def _single_download(self, key: str, final_path: Path, temp_path: Path, total: int) -> None:
110
+ body = self._s3.get_object(self._bucket, key)
111
+ data = body.read()
112
+ temp_path.write_bytes(data)
113
+ temp_path.rename(final_path)
114
+ self._complete(total)
115
+
116
+ def _ranged_download(self, key: str, final_path: Path, temp_path: Path, total: int) -> None:
117
+ chunk_size = DEFAULT_PART_SIZE
118
+
119
+ # Resume from existing temp file
120
+ offset = 0
121
+ if temp_path.exists():
122
+ offset = temp_path.stat().st_size
123
+ self._db.execute(
124
+ "UPDATE transfers SET transferred = ? WHERE id = ?",
125
+ (offset, self.transfer_id),
126
+ )
127
+
128
+ cancelled = False
129
+ paused = False
130
+ with open(temp_path, "ab") as f:
131
+ while offset < total:
132
+ if self._cancel.is_set():
133
+ cancelled = True
134
+ break
135
+ if self._pause.is_set():
136
+ paused = True
137
+ break
138
+
139
+ end = min(offset + chunk_size - 1, total - 1)
140
+ range_header = f"bytes={offset}-{end}"
141
+
142
+ data = self._download_chunk_with_retry(key, range_header)
143
+ if data is None:
144
+ return # failed signal already emitted
145
+
146
+ f.write(data)
147
+ offset += len(data)
148
+
149
+ self._db.execute(
150
+ "UPDATE transfers SET transferred = ?, "
151
+ "updated_at = datetime('now') WHERE id = ?",
152
+ (offset, self.transfer_id),
153
+ )
154
+ self.signals.progress.emit(self.transfer_id, offset, total)
155
+ self._update_speed(len(data))
156
+
157
+ if cancelled:
158
+ self._do_cancel(temp_path)
159
+ return
160
+ if paused:
161
+ self._do_pause(offset)
162
+ return
163
+
164
+ # Verify size
165
+ actual_size = temp_path.stat().st_size
166
+ if actual_size != total:
167
+ msg = f"Size mismatch: expected {total}, got {actual_size}"
168
+ self._mark_failed(msg)
169
+ self.signals.failed.emit(self.transfer_id, msg, "")
170
+ return
171
+
172
+ # Atomic rename
173
+ temp_path.rename(final_path)
174
+ self._complete(total)
175
+
176
+ def _download_chunk_with_retry(self, key: str, range_header: str) -> bytes | None:
177
+ for attempt in range(MAX_RETRY_ATTEMPTS):
178
+ try:
179
+ body = self._s3.get_object(self._bucket, key, range_header)
180
+ return body.read()
181
+ except Exception as e:
182
+ if attempt < MAX_RETRY_ATTEMPTS - 1:
183
+ delay = _backoff_delay(attempt)
184
+ logger.warning(
185
+ "Download chunk attempt %d failed, retrying in %.1fs: %s",
186
+ attempt + 1,
187
+ delay,
188
+ e,
189
+ )
190
+ time.sleep(delay)
191
+ else:
192
+ self._mark_failed(str(e))
193
+ self.signals.failed.emit(
194
+ self.transfer_id,
195
+ f"Download failed after {MAX_RETRY_ATTEMPTS} attempts.",
196
+ str(e),
197
+ )
198
+ return None
199
+
200
+ def _complete(self, total: int) -> None:
201
+ self._db.execute(
202
+ "UPDATE transfers SET status = 'completed', transferred = ?, "
203
+ "updated_at = datetime('now') WHERE id = ?",
204
+ (total, self.transfer_id),
205
+ )
206
+ self.signals.progress.emit(self.transfer_id, total, total)
207
+ self.signals.finished.emit(self.transfer_id)
208
+ logger.info("Download %d completed", self.transfer_id)
209
+
210
+ def _mark_failed(self, msg: str) -> None:
211
+ self._db.execute(
212
+ "UPDATE transfers SET status = 'failed', error_message = ?, "
213
+ "updated_at = datetime('now') WHERE id = ?",
214
+ (msg, self.transfer_id),
215
+ )
216
+
217
+ def _do_cancel(self, temp_path: Path) -> None:
218
+ if temp_path.exists():
219
+ try:
220
+ temp_path.unlink()
221
+ except Exception:
222
+ logger.exception(
223
+ "Failed to remove temp file for transfer %d: %s",
224
+ self.transfer_id,
225
+ temp_path,
226
+ )
227
+ self._db.execute(
228
+ "UPDATE transfers SET status = 'cancelled', updated_at = datetime('now') WHERE id = ?",
229
+ (self.transfer_id,),
230
+ )
231
+ logger.info("Download %d cancelled", self.transfer_id)
232
+
233
+ def _do_pause(self, offset: int) -> None:
234
+ self._db.execute(
235
+ "UPDATE transfers SET status = 'paused', transferred = ?, "
236
+ "updated_at = datetime('now') WHERE id = ?",
237
+ (offset, self.transfer_id),
238
+ )
239
+ logger.info("Download %d paused at offset %d", self.transfer_id, offset)
240
+
241
+ def _update_speed(self, chunk_bytes: int) -> None:
242
+ now = time.monotonic()
243
+ self._speed_window.append((now, chunk_bytes))
244
+ self._speed_window = [(t, b) for t, b in self._speed_window if now - t <= 3.0]
245
+ if now - self._last_speed_emit >= 0.5 and self._speed_window:
246
+ window_time = now - self._speed_window[0][0]
247
+ if window_time > 0:
248
+ total_bytes = sum(b for _, b in self._speed_window)
249
+ bps = total_bytes / window_time
250
+ self.signals.speed.emit(self.transfer_id, bps)
251
+ self._last_speed_emit = now
252
+
253
+
254
+ def _backoff_delay(attempt: int) -> float:
255
+ """Exponential backoff with jitter."""
256
+ if attempt == 0:
257
+ return 0.0
258
+ base = 4 ** (attempt - 1)
259
+ jitter_max = base * 0.5
260
+ return base + random.uniform(0, jitter_max)
s3ui/core/errors.py ADDED
@@ -0,0 +1,104 @@
1
+ """Maps boto3/botocore exceptions to plain-language error messages."""
2
+
3
+ import logging
4
+
5
+ logger = logging.getLogger("s3ui.errors")
6
+
7
+ # Maps AWS error codes to (user-facing message, suggestion)
8
+ ERROR_MESSAGES: dict[str, tuple[str, str]] = {
9
+ "InvalidAccessKeyId": (
10
+ "Invalid access key.",
11
+ "Check that your Access Key ID is correct in Settings.",
12
+ ),
13
+ "SignatureDoesNotMatch": (
14
+ "Invalid secret key.",
15
+ "Check that your Secret Access Key is correct in Settings.",
16
+ ),
17
+ "AccessDenied": (
18
+ "Access denied.",
19
+ "Your AWS credentials don't have permission for this action. Check your IAM policy.",
20
+ ),
21
+ "NoSuchBucket": (
22
+ "Bucket not found.",
23
+ "The bucket may have been deleted or you may have a typo in the name.",
24
+ ),
25
+ "NoSuchKey": (
26
+ "File not found.",
27
+ "The file may have been deleted or moved by someone else.",
28
+ ),
29
+ "BucketAlreadyOwnedByYou": (
30
+ "You already own this bucket.",
31
+ "",
32
+ ),
33
+ "BucketNotEmpty": (
34
+ "Bucket is not empty.",
35
+ "Delete all files in the bucket before deleting it.",
36
+ ),
37
+ "EntityTooLarge": (
38
+ "File is too large for a single upload.",
39
+ "This shouldn't happen — the app should use multipart upload. Please report this bug.",
40
+ ),
41
+ "SlowDown": (
42
+ "S3 is asking us to slow down.",
43
+ "Too many requests. The app will retry automatically.",
44
+ ),
45
+ "ServiceUnavailable": (
46
+ "S3 is temporarily unavailable.",
47
+ "Try again in a few moments.",
48
+ ),
49
+ "InternalError": (
50
+ "S3 encountered an internal error.",
51
+ "Try again in a few moments.",
52
+ ),
53
+ "RequestTimeout": (
54
+ "The request timed out.",
55
+ "Check your network connection and try again.",
56
+ ),
57
+ "ExpiredToken": (
58
+ "Your credentials have expired.",
59
+ "Update your credentials in Settings.",
60
+ ),
61
+ "InvalidBucketName": (
62
+ "Invalid bucket name.",
63
+ "Bucket names must be 3-63 characters, lowercase letters, numbers, and hyphens.",
64
+ ),
65
+ "KeyTooLongError": (
66
+ "File name is too long.",
67
+ "S3 keys can be at most 1024 bytes.",
68
+ ),
69
+ }
70
+
71
+
72
+ def translate_error(exc: Exception) -> tuple[str, str]:
73
+ """Translate a boto3 exception to (user_message, raw_detail).
74
+
75
+ Returns a tuple of (plain-language message for the user, raw error string for
76
+ the "Show Details" expander).
77
+ """
78
+ raw_detail = str(exc)
79
+
80
+ # botocore ClientError
81
+ if hasattr(exc, "response"):
82
+ code = exc.response.get("Error", {}).get("Code", "")
83
+ if code in ERROR_MESSAGES:
84
+ user_msg, suggestion = ERROR_MESSAGES[code]
85
+ if suggestion:
86
+ user_msg = f"{user_msg} {suggestion}"
87
+ return user_msg, raw_detail
88
+ # Unknown AWS error code
89
+ message = exc.response.get("Error", {}).get("Message", "")
90
+ return f"AWS error: {message}" if message else "An AWS error occurred.", raw_detail
91
+
92
+ # Connection errors
93
+ err_type = type(exc).__name__
94
+ if "ConnectionError" in err_type or "EndpointConnectionError" in err_type:
95
+ return (
96
+ "Could not connect to S3. Check your network connection and try again.",
97
+ raw_detail,
98
+ )
99
+
100
+ if "ReadTimeoutError" in err_type or "ConnectTimeoutError" in err_type:
101
+ return "The connection timed out. Check your network connection.", raw_detail
102
+
103
+ # Fallback
104
+ return "An unexpected error occurred.", raw_detail
@@ -0,0 +1,178 @@
1
+ """Stale-while-revalidate listing cache for S3 prefix listings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ import time
8
+ from collections import OrderedDict
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass
11
+
12
+ from s3ui.constants import LISTING_CACHE_MAX_ENTRIES, LISTING_CACHE_STALE_SECONDS
13
+ from s3ui.models.s3_objects import S3Item
14
+
15
+ logger = logging.getLogger("s3ui.listing_cache")
16
+
17
+
18
+ @dataclass
19
+ class CachedListing:
20
+ """A single cached listing result."""
21
+
22
+ prefix: str
23
+ items: list[S3Item]
24
+ fetched_at: float # monotonic time
25
+ dirty: bool = False
26
+ mutation_counter: int = 0
27
+
28
+
29
+ class ListingCache:
30
+ """LRU cache for S3 prefix listings with mutation tracking.
31
+
32
+ Thread-safe: all access is protected by a lock.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ max_entries: int = LISTING_CACHE_MAX_ENTRIES,
38
+ stale_seconds: float = LISTING_CACHE_STALE_SECONDS,
39
+ ) -> None:
40
+ self._max_entries = max_entries
41
+ self._stale_seconds = stale_seconds
42
+ self._cache: OrderedDict[str, CachedListing] = OrderedDict()
43
+ self._lock = threading.Lock()
44
+
45
+ def get(self, prefix: str) -> CachedListing | None:
46
+ """Get a cached listing, promoting it to MRU. Returns None on miss."""
47
+ with self._lock:
48
+ entry = self._cache.get(prefix)
49
+ if entry is not None:
50
+ self._cache.move_to_end(prefix)
51
+ return entry
52
+ return None
53
+
54
+ def put(self, prefix: str, items: list[S3Item]) -> None:
55
+ """Store a listing, evicting LRU if over capacity."""
56
+ with self._lock:
57
+ if prefix in self._cache:
58
+ existing = self._cache[prefix]
59
+ existing.items = list(items)
60
+ existing.fetched_at = time.monotonic()
61
+ existing.dirty = False
62
+ # Don't reset mutation_counter — revalidation handles that
63
+ self._cache.move_to_end(prefix)
64
+ else:
65
+ self._cache[prefix] = CachedListing(
66
+ prefix=prefix,
67
+ items=list(items),
68
+ fetched_at=time.monotonic(),
69
+ )
70
+ self._evict_if_needed()
71
+
72
+ def invalidate(self, prefix: str) -> bool:
73
+ """Remove one entry. Returns True if it existed."""
74
+ with self._lock:
75
+ if prefix in self._cache:
76
+ del self._cache[prefix]
77
+ return True
78
+ return False
79
+
80
+ def invalidate_all(self) -> None:
81
+ """Clear the entire cache."""
82
+ with self._lock:
83
+ self._cache.clear()
84
+
85
+ def is_stale(self, prefix: str) -> bool:
86
+ """True if entry is missing or older than stale threshold."""
87
+ with self._lock:
88
+ entry = self._cache.get(prefix)
89
+ if entry is None:
90
+ return True
91
+ age = time.monotonic() - entry.fetched_at
92
+ return age > self._stale_seconds
93
+
94
+ def apply_mutation(self, prefix: str, fn: Callable[[list[S3Item]], None]) -> bool:
95
+ """Apply a mutation function to a cached listing's items.
96
+
97
+ Sets dirty=True and increments mutation_counter.
98
+ Returns False if prefix is not cached.
99
+ """
100
+ with self._lock:
101
+ entry = self._cache.get(prefix)
102
+ if entry is None:
103
+ return False
104
+ fn(entry.items)
105
+ entry.dirty = True
106
+ entry.mutation_counter += 1
107
+ return True
108
+
109
+ def get_mutation_counter(self, prefix: str) -> int:
110
+ """Returns current mutation counter for a prefix. 0 if not cached."""
111
+ with self._lock:
112
+ entry = self._cache.get(prefix)
113
+ if entry is None:
114
+ return 0
115
+ return entry.mutation_counter
116
+
117
+ def safe_revalidate(
118
+ self,
119
+ prefix: str,
120
+ new_items: list[S3Item],
121
+ counter_at_fetch_start: int,
122
+ ) -> bool:
123
+ """Apply background revalidation results safely.
124
+
125
+ If mutation_counter matches counter_at_fetch_start, does a standard replace.
126
+ If mutations happened since fetch started, merges: preserves optimistic
127
+ additions while incorporating external changes.
128
+
129
+ Returns True if the cache was updated.
130
+ """
131
+ with self._lock:
132
+ entry = self._cache.get(prefix)
133
+ if entry is None:
134
+ # Cache was cleared while we were fetching — just store the result
135
+ self._cache[prefix] = CachedListing(
136
+ prefix=prefix,
137
+ items=list(new_items),
138
+ fetched_at=time.monotonic(),
139
+ )
140
+ self._evict_if_needed()
141
+ return True
142
+
143
+ if entry.mutation_counter == counter_at_fetch_start:
144
+ # No mutations since fetch started — safe to replace
145
+ entry.items = list(new_items)
146
+ entry.fetched_at = time.monotonic()
147
+ entry.dirty = False
148
+ return True
149
+
150
+ # Mutations happened — merge strategy:
151
+ # Keep items that exist in new_items (server truth)
152
+ # Also keep items that were added by optimistic mutations
153
+ # (items in current cache but NOT in the old server state)
154
+ new_keys = {item.key for item in new_items}
155
+
156
+ # Optimistic items: in current cache but not from server
157
+ optimistic = [item for item in entry.items if item.key not in new_keys]
158
+
159
+ # Build merged list: server items + optimistic items
160
+ merged = list(new_items) + optimistic
161
+
162
+ entry.items = merged
163
+ entry.fetched_at = time.monotonic()
164
+ entry.dirty = bool(optimistic) # still dirty if we have optimistic items
165
+ # Don't reset mutation_counter — it tracks total mutations
166
+ logger.debug(
167
+ "Merged revalidation for '%s': %d server + %d optimistic items",
168
+ prefix,
169
+ len(new_items),
170
+ len(optimistic),
171
+ )
172
+ return True
173
+
174
+ def _evict_if_needed(self) -> None:
175
+ """Evict LRU entries if over capacity. Must be called with lock held."""
176
+ while len(self._cache) > self._max_entries:
177
+ evicted_key, _ = self._cache.popitem(last=False)
178
+ logger.debug("Evicted cache entry: '%s'", evicted_key)