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.
File without changes
@@ -0,0 +1,295 @@
1
+ """S3 object data structures and Qt table model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import bisect
6
+ import logging
7
+ from dataclasses import dataclass
8
+ from datetime import UTC, datetime
9
+
10
+ from PyQt6.QtCore import QAbstractTableModel, QModelIndex, Qt
11
+ from PyQt6.QtWidgets import QFileIconProvider
12
+
13
+ logger = logging.getLogger("s3ui.s3_objects")
14
+
15
+
16
+ @dataclass
17
+ class S3Item:
18
+ """Represents a single S3 object or prefix (folder) in a listing."""
19
+
20
+ name: str
21
+ key: str
22
+ is_prefix: bool
23
+ size: int | None = None
24
+ last_modified: datetime | None = None
25
+ storage_class: str | None = None
26
+ etag: str | None = None
27
+
28
+
29
+ def _sort_key(item: S3Item) -> tuple[int, str]:
30
+ """Sort key: prefixes first (0), then objects (1), alphabetical by name."""
31
+ return (0 if item.is_prefix else 1, item.name.lower())
32
+
33
+
34
+ def _format_size(size_bytes: int | None) -> str:
35
+ """Format bytes into human-readable string."""
36
+ if size_bytes is None:
37
+ return ""
38
+ if size_bytes < 1024:
39
+ return f"{size_bytes} B"
40
+ elif size_bytes < 1024 * 1024:
41
+ return f"{size_bytes / 1024:.1f} KB"
42
+ elif size_bytes < 1024 * 1024 * 1024:
43
+ return f"{size_bytes / (1024 * 1024):.1f} MB"
44
+ else:
45
+ return f"{size_bytes / (1024**3):.1f} GB"
46
+
47
+
48
+ def _format_date(dt: datetime | None) -> str:
49
+ """Format datetime into human-readable string."""
50
+ if dt is None:
51
+ return ""
52
+ now = datetime.now(UTC)
53
+ if dt.tzinfo is None:
54
+ dt = dt.replace(tzinfo=UTC)
55
+ delta = now - dt
56
+ if delta.total_seconds() < 0:
57
+ return dt.strftime("%b %d, %Y")
58
+ if delta.total_seconds() < 60:
59
+ return "Just now"
60
+ if delta.total_seconds() < 3600:
61
+ mins = int(delta.total_seconds() / 60)
62
+ return f"{mins} minute{'s' if mins != 1 else ''} ago"
63
+ if delta.total_seconds() < 86400:
64
+ hours = int(delta.total_seconds() / 3600)
65
+ return f"{hours} hour{'s' if hours != 1 else ''} ago"
66
+ if dt.year == now.year:
67
+ return dt.strftime("%b %d")
68
+ return dt.strftime("%b %d, %Y")
69
+
70
+
71
+ # Column indices
72
+ COL_NAME = 0
73
+ COL_SIZE = 1
74
+ COL_MODIFIED = 2
75
+
76
+ _COLUMN_HEADERS = ["Name", "Size", "Date Modified"]
77
+ _COLUMN_COUNT = len(_COLUMN_HEADERS)
78
+
79
+
80
+ class S3ObjectModel(QAbstractTableModel):
81
+ """Table model for S3 objects with granular mutation API."""
82
+
83
+ def __init__(self, parent=None) -> None:
84
+ super().__init__(parent)
85
+ self._items: list[S3Item] = []
86
+ self._icon_provider = QFileIconProvider()
87
+
88
+ # --- Qt model interface ---
89
+
90
+ _EMPTY_INDEX = QModelIndex()
91
+
92
+ def rowCount(self, parent: QModelIndex = _EMPTY_INDEX) -> int:
93
+ if parent.isValid():
94
+ return 0
95
+ return len(self._items)
96
+
97
+ def columnCount(self, parent: QModelIndex = _EMPTY_INDEX) -> int:
98
+ if parent.isValid():
99
+ return 0
100
+ return _COLUMN_COUNT
101
+
102
+ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
103
+ if not index.isValid() or index.row() >= len(self._items):
104
+ return None
105
+
106
+ item = self._items[index.row()]
107
+ col = index.column()
108
+
109
+ if role == Qt.ItemDataRole.DisplayRole:
110
+ if col == COL_NAME:
111
+ return item.name
112
+ elif col == COL_SIZE:
113
+ if item.is_prefix:
114
+ return ""
115
+ return _format_size(item.size)
116
+ elif col == COL_MODIFIED:
117
+ return _format_date(item.last_modified)
118
+ return None
119
+
120
+ if role == Qt.ItemDataRole.DecorationRole and col == COL_NAME:
121
+ if item.is_prefix:
122
+ return self._icon_provider.icon(QFileIconProvider.IconType.Folder)
123
+ return self._icon_provider.icon(QFileIconProvider.IconType.File)
124
+
125
+ if role == Qt.ItemDataRole.TextAlignmentRole:
126
+ if col == COL_SIZE:
127
+ return Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
128
+ return Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
129
+
130
+ if role == Qt.ItemDataRole.UserRole:
131
+ return item
132
+
133
+ return None
134
+
135
+ def headerData(
136
+ self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole.DisplayRole
137
+ ):
138
+ if (
139
+ orientation == Qt.Orientation.Horizontal
140
+ and role == Qt.ItemDataRole.DisplayRole
141
+ and 0 <= section < _COLUMN_COUNT
142
+ ):
143
+ return _COLUMN_HEADERS[section]
144
+ return None
145
+
146
+ def flags(self, index: QModelIndex) -> Qt.ItemFlag:
147
+ base = super().flags(index)
148
+ if index.isValid():
149
+ return base | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsDragEnabled
150
+ return base
151
+
152
+ # --- Data access ---
153
+
154
+ def get_item(self, row: int) -> S3Item | None:
155
+ """Get item at row index."""
156
+ if 0 <= row < len(self._items):
157
+ return self._items[row]
158
+ return None
159
+
160
+ def total_size(self) -> int:
161
+ """Sum of all item sizes."""
162
+ return sum(item.size or 0 for item in self._items if not item.is_prefix)
163
+
164
+ def item_count(self) -> int:
165
+ return len(self._items)
166
+
167
+ def items(self) -> list[S3Item]:
168
+ """Return a copy of the current items list."""
169
+ return list(self._items)
170
+
171
+ # --- Bulk operations ---
172
+
173
+ def set_items(self, items: list[S3Item]) -> None:
174
+ """Replace all items. Only for initial load."""
175
+ self.beginResetModel()
176
+ self._items = sorted(items, key=_sort_key)
177
+ self.endResetModel()
178
+
179
+ def clear(self) -> None:
180
+ """Remove all items."""
181
+ if not self._items:
182
+ return
183
+ self.beginResetModel()
184
+ self._items.clear()
185
+ self.endResetModel()
186
+
187
+ # --- Granular mutation methods ---
188
+
189
+ def insert_item(self, item: S3Item) -> int:
190
+ """Insert item in sorted position. Returns the row index."""
191
+ key = _sort_key(item)
192
+ keys = [_sort_key(i) for i in self._items]
193
+ row = bisect.bisect_left(keys, key)
194
+ self.beginInsertRows(QModelIndex(), row, row)
195
+ self._items.insert(row, item)
196
+ self.endInsertRows()
197
+ return row
198
+
199
+ def remove_item(self, key: str) -> bool:
200
+ """Remove item by key. Returns True if found."""
201
+ for row, item in enumerate(self._items):
202
+ if item.key == key:
203
+ self.beginRemoveRows(QModelIndex(), row, row)
204
+ self._items.pop(row)
205
+ self.endRemoveRows()
206
+ return True
207
+ return False
208
+
209
+ def remove_items(self, keys: set[str]) -> int:
210
+ """Batch remove items by keys. Removes highest index first. Returns count removed."""
211
+ rows_to_remove = []
212
+ for row, item in enumerate(self._items):
213
+ if item.key in keys:
214
+ rows_to_remove.append(row)
215
+ if not rows_to_remove:
216
+ return 0
217
+ # Remove from highest index first to avoid shifting
218
+ for row in reversed(rows_to_remove):
219
+ self.beginRemoveRows(QModelIndex(), row, row)
220
+ self._items.pop(row)
221
+ self.endRemoveRows()
222
+ return len(rows_to_remove)
223
+
224
+ def update_item(self, item_key: str, **fields) -> bool:
225
+ """Update fields on an existing item. Emits dataChanged for that row only."""
226
+ for row, item in enumerate(self._items):
227
+ if item.key == item_key:
228
+ for field, value in fields.items():
229
+ if hasattr(item, field):
230
+ setattr(item, field, value)
231
+ top_left = self.index(row, 0)
232
+ bottom_right = self.index(row, _COLUMN_COUNT - 1)
233
+ self.dataChanged.emit(top_left, bottom_right)
234
+ return True
235
+ return False
236
+
237
+ def append_items(self, items: list[S3Item]) -> None:
238
+ """Append items at end (for incremental page loading)."""
239
+ if not items:
240
+ return
241
+ start = len(self._items)
242
+ end = start + len(items) - 1
243
+ self.beginInsertRows(QModelIndex(), start, end)
244
+ self._items.extend(items)
245
+ self.endInsertRows()
246
+
247
+ def diff_apply(self, new_items: list[S3Item]) -> bool:
248
+ """Compute diff against current data and apply changes.
249
+
250
+ Returns True if any changes were made.
251
+ Used by background revalidation to minimize UI disruption.
252
+ """
253
+ new_sorted = sorted(new_items, key=_sort_key)
254
+ old_by_key = {item.key: item for item in self._items}
255
+ new_by_key = {item.key: item for item in new_sorted}
256
+
257
+ old_keys = set(old_by_key.keys())
258
+ new_keys = set(new_by_key.keys())
259
+
260
+ removed_keys = old_keys - new_keys
261
+ added_keys = new_keys - old_keys
262
+ common_keys = old_keys & new_keys
263
+
264
+ changed = False
265
+
266
+ # Remove missing items
267
+ if removed_keys:
268
+ self.remove_items(removed_keys)
269
+ changed = True
270
+
271
+ # Update changed items
272
+ for key in common_keys:
273
+ old_item = old_by_key[key]
274
+ new_item = new_by_key[key]
275
+ updates = {}
276
+ if old_item.size != new_item.size:
277
+ updates["size"] = new_item.size
278
+ if old_item.last_modified != new_item.last_modified:
279
+ updates["last_modified"] = new_item.last_modified
280
+ if old_item.storage_class != new_item.storage_class:
281
+ updates["storage_class"] = new_item.storage_class
282
+ if old_item.etag != new_item.etag:
283
+ updates["etag"] = new_item.etag
284
+ if old_item.name != new_item.name:
285
+ updates["name"] = new_item.name
286
+ if updates:
287
+ self.update_item(key, **updates)
288
+ changed = True
289
+
290
+ # Insert new items
291
+ for key in added_keys:
292
+ self.insert_item(new_by_key[key])
293
+ changed = True
294
+
295
+ return changed
@@ -0,0 +1,282 @@
1
+ """Transfer model for the transfer panel with signal coalescing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING
8
+
9
+ from PyQt6.QtCore import QAbstractTableModel, QModelIndex, Qt, QTimer
10
+
11
+ if TYPE_CHECKING:
12
+ from s3ui.db.database import Database
13
+
14
+ logger = logging.getLogger("s3ui.transfer_model")
15
+
16
+ # Column indices
17
+ COL_DIRECTION = 0
18
+ COL_FILE = 1
19
+ COL_PROGRESS = 2
20
+ COL_SPEED = 3
21
+ COL_ETA = 4
22
+ COL_STATUS = 5
23
+
24
+ _COLUMN_HEADERS = ["", "File", "Progress", "Speed", "ETA", "Status"]
25
+ _COLUMN_COUNT = len(_COLUMN_HEADERS)
26
+
27
+
28
+ @dataclass
29
+ class TransferRow:
30
+ """In-memory representation of a transfer for display."""
31
+
32
+ transfer_id: int
33
+ direction: str # "upload" or "download"
34
+ filename: str
35
+ local_path: str
36
+ s3_key: str
37
+ total_bytes: int
38
+ transferred_bytes: int = 0
39
+ speed_bps: float = 0.0
40
+ eta_seconds: float = 0.0
41
+ _smoothed_eta: float = 0.0
42
+ status: str = "queued"
43
+ error_message: str = ""
44
+
45
+
46
+ def _format_speed(bps: float) -> str:
47
+ if bps <= 0:
48
+ return "—"
49
+ if bps < 1024:
50
+ return f"{bps:.0f} B/s"
51
+ if bps < 1024 * 1024:
52
+ return f"{bps / 1024:.1f} KB/s"
53
+ return f"{bps / (1024 * 1024):.1f} MB/s"
54
+
55
+
56
+ def _format_eta(seconds: float) -> str:
57
+ if seconds <= 0:
58
+ return "—"
59
+ if seconds < 60:
60
+ return f"~{int(seconds)} sec"
61
+ if seconds < 3600:
62
+ return f"~{int(seconds / 60)} min"
63
+ return f"~{seconds / 3600:.1f} hr"
64
+
65
+
66
+ def _format_progress(transferred: int, total: int) -> str:
67
+ if total <= 0:
68
+ return "0%"
69
+ pct = (transferred / total) * 100
70
+ return f"{pct:.0f}%"
71
+
72
+
73
+ def _format_status(row: TransferRow) -> str:
74
+ if row.status == "completed":
75
+ return "Complete"
76
+ if row.status == "failed":
77
+ return "Failed"
78
+ if row.status == "cancelled":
79
+ return "Cancelled"
80
+ if row.status == "paused":
81
+ return "Paused"
82
+ if row.status == "in_progress":
83
+ return _format_progress(row.transferred_bytes, row.total_bytes)
84
+ return "Queued"
85
+
86
+
87
+ class TransferModel(QAbstractTableModel):
88
+ """Table model for transfers with 100ms coalesced updates."""
89
+
90
+ _EMPTY_INDEX = QModelIndex()
91
+
92
+ def __init__(self, db: Database | None = None, parent=None) -> None:
93
+ super().__init__(parent)
94
+ self._db = db
95
+ self._rows: list[TransferRow] = []
96
+ self._id_to_row: dict[int, int] = {}
97
+ self._pending_updates: dict[int, dict] = {}
98
+ self._dirty_rows: set[int] = set()
99
+
100
+ # Coalescing timer
101
+ self._timer = QTimer(self)
102
+ self._timer.setInterval(100)
103
+ self._timer.timeout.connect(self._flush_updates)
104
+
105
+ # --- Qt model interface ---
106
+
107
+ def rowCount(self, parent: QModelIndex = _EMPTY_INDEX) -> int:
108
+ if parent.isValid():
109
+ return 0
110
+ return len(self._rows)
111
+
112
+ def columnCount(self, parent: QModelIndex = _EMPTY_INDEX) -> int:
113
+ if parent.isValid():
114
+ return 0
115
+ return _COLUMN_COUNT
116
+
117
+ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
118
+ if not index.isValid() or index.row() >= len(self._rows):
119
+ return None
120
+
121
+ row = self._rows[index.row()]
122
+ col = index.column()
123
+
124
+ if role == Qt.ItemDataRole.DisplayRole:
125
+ if col == COL_DIRECTION:
126
+ return "↑" if row.direction == "upload" else "↓"
127
+ if col == COL_FILE:
128
+ return row.filename
129
+ if col == COL_PROGRESS:
130
+ return _format_progress(row.transferred_bytes, row.total_bytes)
131
+ if col == COL_SPEED:
132
+ if row.status == "in_progress":
133
+ return _format_speed(row.speed_bps)
134
+ return "—"
135
+ if col == COL_ETA:
136
+ if row.status == "in_progress":
137
+ return _format_eta(row.eta_seconds)
138
+ return "—"
139
+ if col == COL_STATUS:
140
+ return _format_status(row)
141
+ return None
142
+
143
+ if role == Qt.ItemDataRole.TextAlignmentRole:
144
+ if col in (COL_PROGRESS, COL_SPEED, COL_ETA):
145
+ return Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
146
+ if col == COL_DIRECTION:
147
+ return Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignVCenter
148
+ return Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
149
+
150
+ if role == Qt.ItemDataRole.UserRole:
151
+ return row
152
+
153
+ if role == Qt.ItemDataRole.ToolTipRole and col == COL_STATUS:
154
+ if row.status == "failed":
155
+ return row.error_message
156
+ return None
157
+
158
+ return None
159
+
160
+ def headerData(
161
+ self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole.DisplayRole
162
+ ):
163
+ if (
164
+ orientation == Qt.Orientation.Horizontal
165
+ and role == Qt.ItemDataRole.DisplayRole
166
+ and 0 <= section < _COLUMN_COUNT
167
+ ):
168
+ return _COLUMN_HEADERS[section]
169
+ return None
170
+
171
+ # --- Public API ---
172
+
173
+ def add_transfer(self, transfer_id: int) -> None:
174
+ """Add a transfer from the database."""
175
+ if self._db is None:
176
+ return
177
+ db_row = self._db.fetchone("SELECT * FROM transfers WHERE id = ?", (transfer_id,))
178
+ if not db_row:
179
+ return
180
+
181
+ from pathlib import Path
182
+
183
+ filename = Path(db_row["local_path"]).name
184
+
185
+ row = TransferRow(
186
+ transfer_id=transfer_id,
187
+ direction=db_row["direction"],
188
+ filename=filename,
189
+ local_path=db_row["local_path"],
190
+ s3_key=db_row["object_key"],
191
+ total_bytes=db_row["total_bytes"] or 0,
192
+ transferred_bytes=db_row["transferred"] or 0,
193
+ status=db_row["status"],
194
+ )
195
+
196
+ idx = len(self._rows)
197
+ self.beginInsertRows(QModelIndex(), idx, idx)
198
+ self._rows.append(row)
199
+ self._id_to_row[transfer_id] = idx
200
+ self.endInsertRows()
201
+
202
+ if not self._timer.isActive():
203
+ self._timer.start()
204
+
205
+ def get_transfer_row(self, transfer_id: int) -> TransferRow | None:
206
+ idx = self._id_to_row.get(transfer_id)
207
+ if idx is not None and idx < len(self._rows):
208
+ return self._rows[idx]
209
+ return None
210
+
211
+ # --- Signal handlers (buffer into pending updates) ---
212
+
213
+ def on_progress(self, transfer_id: int, bytes_done: int, total: int) -> None:
214
+ self._buffer_update(transfer_id, transferred_bytes=bytes_done, total_bytes=total)
215
+
216
+ def on_speed(self, transfer_id: int, bps: float) -> None:
217
+ idx = self._id_to_row.get(transfer_id)
218
+ if idx is not None:
219
+ row = self._rows[idx]
220
+ remaining = row.total_bytes - row.transferred_bytes
221
+ eta = remaining / bps if bps > 0 else 0
222
+ # ETA smoothing
223
+ smoothed = 0.7 * eta + 0.3 * row._smoothed_eta if row._smoothed_eta > 0 else eta
224
+ self._buffer_update(
225
+ transfer_id, speed_bps=bps, eta_seconds=smoothed, _smoothed_eta=smoothed
226
+ )
227
+
228
+ def on_status_changed(self, transfer_id: int, new_status: str) -> None:
229
+ self._buffer_update(transfer_id, status=new_status)
230
+
231
+ def on_error(self, transfer_id: int, user_msg: str, detail: str) -> None:
232
+ self._buffer_update(transfer_id, status="failed", error_message=user_msg)
233
+
234
+ def on_finished(self, transfer_id: int) -> None:
235
+ self._buffer_update(
236
+ transfer_id, status="completed", speed_bps=0, eta_seconds=0, _smoothed_eta=0
237
+ )
238
+
239
+ # --- Internal ---
240
+
241
+ def _buffer_update(self, transfer_id: int, **fields) -> None:
242
+ if transfer_id not in self._pending_updates:
243
+ self._pending_updates[transfer_id] = {}
244
+ self._pending_updates[transfer_id].update(fields)
245
+
246
+ idx = self._id_to_row.get(transfer_id)
247
+ if idx is not None:
248
+ self._dirty_rows.add(idx)
249
+
250
+ def _flush_updates(self) -> None:
251
+ if not self._pending_updates:
252
+ return
253
+
254
+ for transfer_id, fields in self._pending_updates.items():
255
+ idx = self._id_to_row.get(transfer_id)
256
+ if idx is None or idx >= len(self._rows):
257
+ continue
258
+ row = self._rows[idx]
259
+ for field_name, value in fields.items():
260
+ if hasattr(row, field_name):
261
+ setattr(row, field_name, value)
262
+
263
+ if self._dirty_rows:
264
+ min_row = min(self._dirty_rows)
265
+ max_row = max(self._dirty_rows)
266
+ top_left = self.index(min_row, 0)
267
+ bottom_right = self.index(max_row, _COLUMN_COUNT - 1)
268
+ self.dataChanged.emit(top_left, bottom_right)
269
+
270
+ self._pending_updates.clear()
271
+ self._dirty_rows.clear()
272
+
273
+ # Stop timer if no active transfers
274
+ has_active = any(r.status in ("queued", "in_progress", "paused") for r in self._rows)
275
+ if not has_active:
276
+ self._timer.stop()
277
+
278
+ def active_count(self) -> int:
279
+ return sum(1 for r in self._rows if r.status == "in_progress")
280
+
281
+ def queued_count(self) -> int:
282
+ return sum(1 for r in self._rows if r.status == "queued")
File without changes
Binary file
s3ui/ui/__init__.py ADDED
File without changes