sharefetch 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.
- sharefetch/__init__.py +11 -0
- sharefetch/__main__.py +8 -0
- sharefetch/auth.py +343 -0
- sharefetch/cli.py +782 -0
- sharefetch/config.py +374 -0
- sharefetch/engine.py +1055 -0
- sharefetch/errors.py +57 -0
- sharefetch/events.py +129 -0
- sharefetch/graph.py +474 -0
- sharefetch/gui.py +1170 -0
- sharefetch/hashes.py +123 -0
- sharefetch/listing.py +493 -0
- sharefetch/manifest.py +198 -0
- sharefetch/pacing.py +211 -0
- sharefetch/paths.py +245 -0
- sharefetch/py.typed +0 -0
- sharefetch/resolve.py +660 -0
- sharefetch/state.py +707 -0
- sharefetch/tokencache.py +214 -0
- sharefetch/transfer.py +815 -0
- sharefetch/urls.py +195 -0
- sharefetch/verify.py +378 -0
- sharefetch-1.0.0.dist-info/METADATA +193 -0
- sharefetch-1.0.0.dist-info/RECORD +28 -0
- sharefetch-1.0.0.dist-info/WHEEL +5 -0
- sharefetch-1.0.0.dist-info/entry_points.txt +5 -0
- sharefetch-1.0.0.dist-info/licenses/LICENSE +21 -0
- sharefetch-1.0.0.dist-info/top_level.txt +1 -0
sharefetch/gui.py
ADDED
|
@@ -0,0 +1,1170 @@
|
|
|
1
|
+
"""The tkinter front end: one window over one :func:`sharefetch.engine.run`.
|
|
2
|
+
|
|
3
|
+
The engine runs on a worker thread and reaches the window only by putting an
|
|
4
|
+
event on a :class:`queue.Queue`. The main thread drains that queue under
|
|
5
|
+
``root.after``, so every Tk call in this module runs on the thread that created
|
|
6
|
+
the widget and no network call runs on it. Tk is not thread safe and a
|
|
7
|
+
widget touched from the engine thread produces a corruption that appears
|
|
8
|
+
intermittently and far from its cause, so the queue is the only channel.
|
|
9
|
+
|
|
10
|
+
Three objects cross the thread boundary and nothing else does.
|
|
11
|
+
|
|
12
|
+
``_QueueSink``
|
|
13
|
+
The :class:`~sharefetch.events.EventSink` the engine emits through. Its
|
|
14
|
+
``emit`` puts the event on the queue and returns.
|
|
15
|
+
|
|
16
|
+
``_QueueLogHandler``
|
|
17
|
+
A :class:`logging.Handler` on the ``sharefetch`` logger. It formats a record
|
|
18
|
+
once, wraps it in a :class:`~sharefetch.events.LogRecord` and puts it on the
|
|
19
|
+
same queue, so the log pane receives exactly the records a log file would
|
|
20
|
+
receive with no second formatting path. It carries
|
|
21
|
+
:class:`~sharefetch.graph.RedactingFilter`, so a credential never reaches
|
|
22
|
+
the pane.
|
|
23
|
+
|
|
24
|
+
``App._publish``
|
|
25
|
+
Called once on the worker thread to hand the main thread the run's
|
|
26
|
+
:class:`~sharefetch.auth.TokenProvider` and :class:`~sharefetch.engine.Engine`
|
|
27
|
+
under a lock, which is how Cancel, Pause, Resume and Stop reach a run that
|
|
28
|
+
is already in flight. It touches no widget.
|
|
29
|
+
|
|
30
|
+
Every option the window offers is validated by the boundary validators in
|
|
31
|
+
:mod:`sharefetch.config`, so this front end and the command-line front end
|
|
32
|
+
cannot diverge on what a legal value is.
|
|
33
|
+
|
|
34
|
+
The device code and the verification URI are displayed by design and are never
|
|
35
|
+
logged. No token material is displayed or logged anywhere.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import logging
|
|
41
|
+
import queue
|
|
42
|
+
import threading
|
|
43
|
+
import time
|
|
44
|
+
import tkinter as tk
|
|
45
|
+
import webbrowser
|
|
46
|
+
from collections.abc import Callable
|
|
47
|
+
from dataclasses import dataclass
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
from tkinter import filedialog, ttk
|
|
50
|
+
from tkinter import font as tkfont
|
|
51
|
+
from typing import Any
|
|
52
|
+
|
|
53
|
+
from .auth import TokenProvider
|
|
54
|
+
from .config import (
|
|
55
|
+
CHUNK_KIB_BOUNDS,
|
|
56
|
+
CONNECTIONS_BOUNDS,
|
|
57
|
+
DEFAULT_CHUNK_KIB,
|
|
58
|
+
DEFAULT_CONNECTIONS,
|
|
59
|
+
DEFAULT_DELAY_SECONDS,
|
|
60
|
+
DELAY_BOUNDS,
|
|
61
|
+
GUI_DRAIN_INTERVAL_MS,
|
|
62
|
+
GUI_LOG_MAX_LINES,
|
|
63
|
+
GUI_STOP_JOIN_SECONDS,
|
|
64
|
+
STATUS_COMPLETE,
|
|
65
|
+
STATUS_FAILED,
|
|
66
|
+
STATUS_SKIPPED,
|
|
67
|
+
Settings,
|
|
68
|
+
check_chunk_kib,
|
|
69
|
+
check_connections,
|
|
70
|
+
check_delay,
|
|
71
|
+
check_dest,
|
|
72
|
+
check_limit_rate,
|
|
73
|
+
)
|
|
74
|
+
from .engine import Engine
|
|
75
|
+
from .errors import SharefetchError, ValidationError
|
|
76
|
+
from .events import (
|
|
77
|
+
DeviceCodeCompleted,
|
|
78
|
+
DeviceCodeRequested,
|
|
79
|
+
EnumerationComplete,
|
|
80
|
+
EnumerationProgress,
|
|
81
|
+
FileFinished,
|
|
82
|
+
FileProgress,
|
|
83
|
+
FileStarted,
|
|
84
|
+
LogRecord,
|
|
85
|
+
Reconciled,
|
|
86
|
+
Resolved,
|
|
87
|
+
RunFinished,
|
|
88
|
+
Throttled,
|
|
89
|
+
)
|
|
90
|
+
from .graph import RedactingFilter
|
|
91
|
+
from .paths import sanitise_segment
|
|
92
|
+
from .urls import ShareRef, SitePathRef
|
|
93
|
+
from .urls import parse as parse_url
|
|
94
|
+
|
|
95
|
+
logger = logging.getLogger(__name__)
|
|
96
|
+
|
|
97
|
+
PACKAGE_LOGGER_NAME = "sharefetch"
|
|
98
|
+
|
|
99
|
+
# Constants the window needs that config.py does not hold. Each one lives
|
|
100
|
+
# beside the only code that reads it, with the reason it exists.
|
|
101
|
+
|
|
102
|
+
# How many queued items one drain pass consumes. A large transfer emits one
|
|
103
|
+
# FileProgress per chunk, and an unbounded drain would let a burst hold the
|
|
104
|
+
# main thread long enough to drop a redraw. At the 100 ms drain interval this
|
|
105
|
+
# cap still clears 2000 items a second, which is far above the rate any
|
|
106
|
+
# rate-limited transfer produces.
|
|
107
|
+
DRAIN_BATCH_LIMIT = 200
|
|
108
|
+
|
|
109
|
+
# The upper stop of the rate-limit spinbox arrows. config.py bounds the rate
|
|
110
|
+
# ceiling below and leaves it unbounded above, so the arrows need a stop that
|
|
111
|
+
# the validator does not supply. Typed input is still read through
|
|
112
|
+
# config.check_limit_rate, which remains the authority on what is accepted.
|
|
113
|
+
RATE_SPINBOX_CEILING_KBPS = 1_000_000
|
|
114
|
+
|
|
115
|
+
# 0 in the rate spinbox means no ceiling, which is how config.Settings spells it
|
|
116
|
+
# as ``limit_rate_kbps=None``. The spinbox cannot hold None.
|
|
117
|
+
RATE_OFF = 0
|
|
118
|
+
|
|
119
|
+
# Point size of the device code label. The code is read off the screen and
|
|
120
|
+
# typed into a browser on another device, so it is set well above the interface
|
|
121
|
+
# font rather than inheriting it.
|
|
122
|
+
DEVICE_CODE_FONT_SIZE = 22
|
|
123
|
+
|
|
124
|
+
DELAY_SPIN_INCREMENT = 0.1
|
|
125
|
+
CHUNK_SPIN_INCREMENT_KIB = 64
|
|
126
|
+
RATE_SPIN_INCREMENT_KBPS = 64
|
|
127
|
+
|
|
128
|
+
BYTES_PER_UNIT = 1024.0
|
|
129
|
+
BYTE_UNITS = ("B", "KiB", "MiB", "GiB", "TiB", "PiB")
|
|
130
|
+
|
|
131
|
+
# Progressbar values are doubles and a byte total is an exact integer, so the
|
|
132
|
+
# bars are driven in permille of the total and never in raw bytes.
|
|
133
|
+
PROGRESS_SCALE = 1000
|
|
134
|
+
|
|
135
|
+
_STATE_IDLE = "idle"
|
|
136
|
+
_STATE_RUNNING = "running"
|
|
137
|
+
_STATE_PAUSED = "paused"
|
|
138
|
+
_STATE_STOPPING = "stopping"
|
|
139
|
+
|
|
140
|
+
# A graphical front end's exit code is rarely read, but the two front ends
|
|
141
|
+
# report the same outcome with the same number.
|
|
142
|
+
EXIT_OK = 0
|
|
143
|
+
EXIT_FAILURES = 1
|
|
144
|
+
EXIT_FATAL = 2
|
|
145
|
+
|
|
146
|
+
_ERROR_COLOUR = "#b00020"
|
|
147
|
+
_LINK_COLOUR = "#0645ad"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ----------------------------------------------------------------------
|
|
151
|
+
# Objects that cross the thread boundary
|
|
152
|
+
# ----------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(frozen=True)
|
|
156
|
+
class _RunFailed:
|
|
157
|
+
"""A fatal error ended the run. Put on the queue by the worker thread."""
|
|
158
|
+
|
|
159
|
+
message: str
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True)
|
|
163
|
+
class _RunEnded:
|
|
164
|
+
"""The worker thread is about to exit, whatever the outcome was."""
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class _QueueSink:
|
|
168
|
+
"""The event sink the engine emits through. Puts and returns, never blocks.
|
|
169
|
+
|
|
170
|
+
Implements :class:`~sharefetch.events.EventSink`. The queue is unbounded, so
|
|
171
|
+
a slow drain never stalls a transfer and a main thread blocked in a join can
|
|
172
|
+
never deadlock against an engine thread trying to emit.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def __init__(self, events: queue.Queue[Any]) -> None:
|
|
176
|
+
self._events = events
|
|
177
|
+
|
|
178
|
+
def emit(self, event: Any) -> None:
|
|
179
|
+
self._events.put(event)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class _QueueLogHandler(logging.Handler):
|
|
183
|
+
"""Turns each log record into a ``LogRecord`` event on the window's queue.
|
|
184
|
+
|
|
185
|
+
Attached to the ``sharefetch`` logger, so the pane shows exactly what a log
|
|
186
|
+
file would hold. The record is formatted here, on whichever thread logged
|
|
187
|
+
it, and only the finished string crosses to the main thread.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
def __init__(self, events: queue.Queue[Any]) -> None:
|
|
191
|
+
super().__init__()
|
|
192
|
+
self._events = events
|
|
193
|
+
self.addFilter(RedactingFilter())
|
|
194
|
+
self.setFormatter(logging.Formatter("%(asctime)s %(levelname)-8s %(name)s %(message)s"))
|
|
195
|
+
|
|
196
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
197
|
+
try:
|
|
198
|
+
self._events.put(LogRecord(level=record.levelname, message=self.format(record)))
|
|
199
|
+
except Exception:
|
|
200
|
+
self.handleError(record)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ----------------------------------------------------------------------
|
|
204
|
+
# Option reading, through the config validators and nothing else
|
|
205
|
+
# ----------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _whole_number(name: str, text: str) -> int:
|
|
209
|
+
"""Read a spinbox's text as an integer, or reject it in the validators' style."""
|
|
210
|
+
candidate = (text or "").strip()
|
|
211
|
+
try:
|
|
212
|
+
return int(candidate)
|
|
213
|
+
except ValueError as exc:
|
|
214
|
+
raise ValidationError(f"{name} must be a whole number, received {candidate!r}") from exc
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _decimal_number(name: str, text: str) -> float:
|
|
218
|
+
"""Read a spinbox's text as a float, or reject it in the validators' style."""
|
|
219
|
+
candidate = (text or "").strip()
|
|
220
|
+
try:
|
|
221
|
+
return float(candidate)
|
|
222
|
+
except ValueError as exc:
|
|
223
|
+
raise ValidationError(f"{name} must be a number, received {candidate!r}") from exc
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _read_connections(text: str) -> int:
|
|
227
|
+
return check_connections(_whole_number("connections", text))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _read_delay(text: str) -> float:
|
|
231
|
+
return check_delay(_decimal_number("delay", text))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _read_chunk_kib(text: str) -> int:
|
|
235
|
+
return check_chunk_kib(_whole_number("chunk size in KiB", text))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _read_limit_rate(text: str) -> int | None:
|
|
239
|
+
"""``RATE_OFF`` means no ceiling. Every other value goes through the validator."""
|
|
240
|
+
value = _whole_number("rate limit in KB/s", text)
|
|
241
|
+
if value == RATE_OFF:
|
|
242
|
+
return None
|
|
243
|
+
return check_limit_rate(value)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass
|
|
247
|
+
class _BoundedField:
|
|
248
|
+
"""One spinbox, its reader and the last value that reader accepted.
|
|
249
|
+
|
|
250
|
+
A value the reader refuses is reverted to ``last_good`` on focus-out, so the
|
|
251
|
+
window never holds an option the engine would reject and the operator sees
|
|
252
|
+
the validator's own message rather than a failure at Start.
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
name: str
|
|
256
|
+
var: tk.StringVar
|
|
257
|
+
read: Callable[[str], Any]
|
|
258
|
+
last_good: str
|
|
259
|
+
|
|
260
|
+
def value(self) -> Any:
|
|
261
|
+
return self.read(self.var.get())
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _format_bytes(count: int) -> str:
|
|
265
|
+
"""Render a byte count in binary units, to one decimal above bytes."""
|
|
266
|
+
size = float(max(0, count))
|
|
267
|
+
for unit in BYTE_UNITS:
|
|
268
|
+
if size < BYTES_PER_UNIT or unit == BYTE_UNITS[-1]:
|
|
269
|
+
return f"{int(size)} {unit}" if unit == BYTE_UNITS[0] else f"{size:.1f} {unit}"
|
|
270
|
+
size /= BYTES_PER_UNIT
|
|
271
|
+
return f"{size:.1f} {BYTE_UNITS[-1]}"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _describe_source(reference: SitePathRef | ShareRef) -> str:
|
|
275
|
+
"""One line describing what the pasted URL addresses, without a network call.
|
|
276
|
+
|
|
277
|
+
A site path carries its host and its path segments. Which leading segments
|
|
278
|
+
name the site and which segment names the library is decided by the probe in
|
|
279
|
+
:mod:`sharefetch.resolve`, which needs a signed-in Graph call, so the line
|
|
280
|
+
states the path as given and the ``Resolved`` event replaces it with the
|
|
281
|
+
authoritative site, library and folder once the run resolves.
|
|
282
|
+
"""
|
|
283
|
+
if isinstance(reference, ShareRef):
|
|
284
|
+
return (
|
|
285
|
+
f"host {reference.host}, sharing link; site, library and folder resolve after sign-in"
|
|
286
|
+
)
|
|
287
|
+
path = "/".join(reference.path_segments)
|
|
288
|
+
folder = reference.path_segments[-1]
|
|
289
|
+
return (
|
|
290
|
+
f"host {reference.host}, path /{path}, folder {folder}; "
|
|
291
|
+
f"site and library confirmed on resolve"
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _default_folder_name(reference: SitePathRef | ShareRef) -> str | None:
|
|
296
|
+
"""The destination folder name the URL implies, or ``None`` where it implies none."""
|
|
297
|
+
if isinstance(reference, ShareRef):
|
|
298
|
+
return None
|
|
299
|
+
name, _changed = sanitise_segment(reference.path_segments[-1])
|
|
300
|
+
return name or None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ----------------------------------------------------------------------
|
|
304
|
+
# The window
|
|
305
|
+
# ----------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class App:
|
|
309
|
+
"""The single window the front end presents.
|
|
310
|
+
|
|
311
|
+
Every method on this class runs on the Tk main thread except
|
|
312
|
+
:meth:`_run_engine` and :meth:`_publish`, which run on the worker thread and
|
|
313
|
+
touch no widget. That split is the whole of the threading contract.
|
|
314
|
+
"""
|
|
315
|
+
|
|
316
|
+
def __init__(self, root: tk.Tk) -> None:
|
|
317
|
+
self._root = root
|
|
318
|
+
self._events: queue.Queue[Any] = queue.Queue()
|
|
319
|
+
|
|
320
|
+
# Handed over by the worker thread through _publish, read under the
|
|
321
|
+
# lock by the Cancel, Stop and close handlers.
|
|
322
|
+
self._control = threading.Lock()
|
|
323
|
+
self._provider: TokenProvider | None = None
|
|
324
|
+
self._engine: Engine | None = None
|
|
325
|
+
self._cancel_pending = False
|
|
326
|
+
self._stop_pending = False
|
|
327
|
+
self._worker: threading.Thread | None = None
|
|
328
|
+
|
|
329
|
+
self._drain_job: str | None = None
|
|
330
|
+
self._closing = False
|
|
331
|
+
self._log_handler: _QueueLogHandler | None = None
|
|
332
|
+
self._package_logger = logging.getLogger(PACKAGE_LOGGER_NAME)
|
|
333
|
+
self._previous_propagate = self._package_logger.propagate
|
|
334
|
+
self._previous_level = self._package_logger.level
|
|
335
|
+
|
|
336
|
+
# Run tallies, reset by _reset_run_display.
|
|
337
|
+
self._total_bytes = 0
|
|
338
|
+
self._overall_bytes = 0
|
|
339
|
+
self._inflight: dict[str, int] = {}
|
|
340
|
+
self._current: str | None = None
|
|
341
|
+
self._transferred = 0
|
|
342
|
+
self._skipped = 0
|
|
343
|
+
self._failed = 0
|
|
344
|
+
self._exit_code = EXIT_OK
|
|
345
|
+
|
|
346
|
+
self._build()
|
|
347
|
+
self._set_state(_STATE_IDLE)
|
|
348
|
+
|
|
349
|
+
# ------------------------------------------------------------------
|
|
350
|
+
# Construction
|
|
351
|
+
# ------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
def _build(self) -> None:
|
|
354
|
+
root = self._root
|
|
355
|
+
root.title("sharefetch")
|
|
356
|
+
root.minsize(760, 620)
|
|
357
|
+
root.columnconfigure(0, weight=1)
|
|
358
|
+
root.rowconfigure(5, weight=1)
|
|
359
|
+
|
|
360
|
+
self._build_source(root)
|
|
361
|
+
self._build_options(root)
|
|
362
|
+
self._build_device_panel(root)
|
|
363
|
+
self._build_progress(root)
|
|
364
|
+
self._build_controls(root)
|
|
365
|
+
self._build_log(root)
|
|
366
|
+
|
|
367
|
+
def _build_source(self, root: tk.Tk) -> None:
|
|
368
|
+
frame = ttk.LabelFrame(root, text="Source", padding=8)
|
|
369
|
+
frame.grid(row=0, column=0, sticky="ew", padx=8, pady=(8, 4))
|
|
370
|
+
frame.columnconfigure(1, weight=1)
|
|
371
|
+
|
|
372
|
+
ttk.Label(frame, text="URL").grid(row=0, column=0, sticky="w", padx=(0, 6))
|
|
373
|
+
self._url_var = tk.StringVar()
|
|
374
|
+
self._url_entry = ttk.Entry(frame, textvariable=self._url_var)
|
|
375
|
+
self._url_entry.grid(row=0, column=1, columnspan=2, sticky="ew")
|
|
376
|
+
self._url_entry.bind("<FocusOut>", self._on_url_focus_out)
|
|
377
|
+
self._url_entry.bind("<Return>", self._on_url_focus_out)
|
|
378
|
+
|
|
379
|
+
self._source_var = tk.StringVar(value="paste a URL, then leave the field")
|
|
380
|
+
ttk.Label(frame, textvariable=self._source_var, wraplength=700).grid(
|
|
381
|
+
row=1, column=0, columnspan=3, sticky="w", pady=(4, 0)
|
|
382
|
+
)
|
|
383
|
+
self._url_error_var = tk.StringVar()
|
|
384
|
+
ttk.Label(
|
|
385
|
+
frame,
|
|
386
|
+
textvariable=self._url_error_var,
|
|
387
|
+
wraplength=700,
|
|
388
|
+
foreground=_ERROR_COLOUR,
|
|
389
|
+
).grid(row=2, column=0, columnspan=3, sticky="w", pady=(2, 0))
|
|
390
|
+
|
|
391
|
+
ttk.Label(frame, text="Destination").grid(
|
|
392
|
+
row=3, column=0, sticky="w", padx=(0, 6), pady=(6, 0)
|
|
393
|
+
)
|
|
394
|
+
self._dest_var = tk.StringVar()
|
|
395
|
+
self._dest_auto = ""
|
|
396
|
+
ttk.Entry(frame, textvariable=self._dest_var).grid(
|
|
397
|
+
row=3, column=1, sticky="ew", pady=(6, 0)
|
|
398
|
+
)
|
|
399
|
+
ttk.Button(frame, text="Browse", command=self._on_browse).grid(
|
|
400
|
+
row=3, column=2, sticky="e", padx=(6, 0), pady=(6, 0)
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
def _build_options(self, root: tk.Tk) -> None:
|
|
404
|
+
frame = ttk.LabelFrame(root, text="Transfer options", padding=8)
|
|
405
|
+
frame.grid(row=1, column=0, sticky="ew", padx=8, pady=4)
|
|
406
|
+
|
|
407
|
+
self._fields: dict[str, _BoundedField] = {}
|
|
408
|
+
self._fields["connections"] = self._spinbox(
|
|
409
|
+
frame,
|
|
410
|
+
column=0,
|
|
411
|
+
label="Connections",
|
|
412
|
+
name="connections",
|
|
413
|
+
initial=str(DEFAULT_CONNECTIONS),
|
|
414
|
+
low=CONNECTIONS_BOUNDS[0],
|
|
415
|
+
high=CONNECTIONS_BOUNDS[1],
|
|
416
|
+
increment=1,
|
|
417
|
+
read=_read_connections,
|
|
418
|
+
)
|
|
419
|
+
self._fields["delay"] = self._spinbox(
|
|
420
|
+
frame,
|
|
421
|
+
column=1,
|
|
422
|
+
label="Delay (s)",
|
|
423
|
+
name="delay",
|
|
424
|
+
initial=str(DEFAULT_DELAY_SECONDS),
|
|
425
|
+
low=DELAY_BOUNDS[0],
|
|
426
|
+
high=DELAY_BOUNDS[1],
|
|
427
|
+
increment=DELAY_SPIN_INCREMENT,
|
|
428
|
+
read=_read_delay,
|
|
429
|
+
)
|
|
430
|
+
self._fields["limit_rate"] = self._spinbox(
|
|
431
|
+
frame,
|
|
432
|
+
column=2,
|
|
433
|
+
label=f"Rate (KB/s, {RATE_OFF} is off)",
|
|
434
|
+
name="rate limit",
|
|
435
|
+
initial=str(RATE_OFF),
|
|
436
|
+
low=RATE_OFF,
|
|
437
|
+
high=RATE_SPINBOX_CEILING_KBPS,
|
|
438
|
+
increment=RATE_SPIN_INCREMENT_KBPS,
|
|
439
|
+
read=_read_limit_rate,
|
|
440
|
+
)
|
|
441
|
+
self._fields["chunk"] = self._spinbox(
|
|
442
|
+
frame,
|
|
443
|
+
column=3,
|
|
444
|
+
label="Chunk (KiB)",
|
|
445
|
+
name="chunk size",
|
|
446
|
+
initial=str(DEFAULT_CHUNK_KIB),
|
|
447
|
+
low=CHUNK_KIB_BOUNDS[0],
|
|
448
|
+
high=CHUNK_KIB_BOUNDS[1],
|
|
449
|
+
increment=CHUNK_SPIN_INCREMENT_KIB,
|
|
450
|
+
read=_read_chunk_kib,
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
self._options_error_var = tk.StringVar()
|
|
454
|
+
ttk.Label(
|
|
455
|
+
frame,
|
|
456
|
+
textvariable=self._options_error_var,
|
|
457
|
+
wraplength=700,
|
|
458
|
+
foreground=_ERROR_COLOUR,
|
|
459
|
+
).grid(row=2, column=0, columnspan=4, sticky="w", pady=(4, 0))
|
|
460
|
+
|
|
461
|
+
def _spinbox(
|
|
462
|
+
self,
|
|
463
|
+
parent: ttk.LabelFrame,
|
|
464
|
+
*,
|
|
465
|
+
column: int,
|
|
466
|
+
label: str,
|
|
467
|
+
name: str,
|
|
468
|
+
initial: str,
|
|
469
|
+
low: float,
|
|
470
|
+
high: float,
|
|
471
|
+
increment: float,
|
|
472
|
+
read: Callable[[str], Any],
|
|
473
|
+
) -> _BoundedField:
|
|
474
|
+
"""Build one bounded spinbox and the field object that guards it.
|
|
475
|
+
|
|
476
|
+
``low`` and ``high`` come from the ``config`` bounds pairs the
|
|
477
|
+
command-line front end enforces, so the arrows stop where the validator
|
|
478
|
+
stops. ``read`` is the validator itself, applied to typed input.
|
|
479
|
+
"""
|
|
480
|
+
ttk.Label(parent, text=label).grid(row=0, column=column, sticky="w", padx=(0, 12))
|
|
481
|
+
var = tk.StringVar(value=initial)
|
|
482
|
+
spin = ttk.Spinbox(
|
|
483
|
+
parent,
|
|
484
|
+
textvariable=var,
|
|
485
|
+
from_=low,
|
|
486
|
+
to=high,
|
|
487
|
+
increment=increment,
|
|
488
|
+
width=12,
|
|
489
|
+
)
|
|
490
|
+
spin.grid(row=1, column=column, sticky="w", padx=(0, 12))
|
|
491
|
+
field = _BoundedField(name=name, var=var, read=read, last_good=initial)
|
|
492
|
+
spin.bind("<FocusOut>", lambda _event, f=field: self._on_field_focus_out(f))
|
|
493
|
+
return field
|
|
494
|
+
|
|
495
|
+
def _build_device_panel(self, root: tk.Tk) -> None:
|
|
496
|
+
self._device_panel = ttk.LabelFrame(root, text="Sign in", padding=8)
|
|
497
|
+
self._device_panel.grid(row=2, column=0, sticky="ew", padx=8, pady=4)
|
|
498
|
+
self._device_panel.columnconfigure(0, weight=1)
|
|
499
|
+
|
|
500
|
+
ttk.Label(
|
|
501
|
+
self._device_panel,
|
|
502
|
+
text="Open the verification page and enter this code:",
|
|
503
|
+
).grid(row=0, column=0, columnspan=3, sticky="w")
|
|
504
|
+
|
|
505
|
+
code_font = tkfont.nametofont("TkFixedFont").copy()
|
|
506
|
+
code_font.configure(size=DEVICE_CODE_FONT_SIZE)
|
|
507
|
+
self._code_var = tk.StringVar()
|
|
508
|
+
# A read-only Entry rather than a Label, because the code has to be
|
|
509
|
+
# selectable and is shown preselected.
|
|
510
|
+
self._code_entry = tk.Entry(
|
|
511
|
+
self._device_panel,
|
|
512
|
+
textvariable=self._code_var,
|
|
513
|
+
font=code_font,
|
|
514
|
+
state="readonly",
|
|
515
|
+
justify="center",
|
|
516
|
+
width=14,
|
|
517
|
+
relief="flat",
|
|
518
|
+
)
|
|
519
|
+
self._code_entry.grid(row=1, column=0, sticky="w", pady=(4, 4))
|
|
520
|
+
ttk.Button(self._device_panel, text="Copy", command=self._on_copy_code).grid(
|
|
521
|
+
row=1, column=1, sticky="w", padx=(8, 0)
|
|
522
|
+
)
|
|
523
|
+
ttk.Button(self._device_panel, text="Cancel", command=self._on_cancel_sign_in).grid(
|
|
524
|
+
row=1, column=2, sticky="w", padx=(8, 0)
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
link_font = tkfont.nametofont("TkDefaultFont").copy()
|
|
528
|
+
link_font.configure(underline=True)
|
|
529
|
+
self._uri_var = tk.StringVar()
|
|
530
|
+
self._uri_label = ttk.Label(
|
|
531
|
+
self._device_panel,
|
|
532
|
+
textvariable=self._uri_var,
|
|
533
|
+
foreground=_LINK_COLOUR,
|
|
534
|
+
cursor="hand2",
|
|
535
|
+
font=link_font,
|
|
536
|
+
)
|
|
537
|
+
self._uri_label.grid(row=2, column=0, columnspan=3, sticky="w")
|
|
538
|
+
self._uri_label.bind("<Button-1>", self._on_open_verification_uri)
|
|
539
|
+
|
|
540
|
+
self._expiry_var = tk.StringVar()
|
|
541
|
+
ttk.Label(self._device_panel, textvariable=self._expiry_var).grid(
|
|
542
|
+
row=3, column=0, columnspan=3, sticky="w", pady=(2, 0)
|
|
543
|
+
)
|
|
544
|
+
self._device_panel.grid_remove()
|
|
545
|
+
|
|
546
|
+
def _build_progress(self, root: tk.Tk) -> None:
|
|
547
|
+
frame = ttk.LabelFrame(root, text="Progress", padding=8)
|
|
548
|
+
frame.grid(row=3, column=0, sticky="ew", padx=8, pady=4)
|
|
549
|
+
frame.columnconfigure(0, weight=1)
|
|
550
|
+
|
|
551
|
+
self._overall_bar = ttk.Progressbar(frame, maximum=PROGRESS_SCALE, value=0)
|
|
552
|
+
self._overall_bar.grid(row=0, column=0, sticky="ew")
|
|
553
|
+
self._overall_var = tk.StringVar(value="overall: nothing enumerated yet")
|
|
554
|
+
ttk.Label(frame, textvariable=self._overall_var).grid(row=1, column=0, sticky="w")
|
|
555
|
+
|
|
556
|
+
self._file_bar = ttk.Progressbar(frame, maximum=PROGRESS_SCALE, value=0)
|
|
557
|
+
self._file_bar.grid(row=2, column=0, sticky="ew", pady=(6, 0))
|
|
558
|
+
self._file_var = tk.StringVar(value="current file: none")
|
|
559
|
+
ttk.Label(frame, textvariable=self._file_var, wraplength=700).grid(
|
|
560
|
+
row=3, column=0, sticky="w"
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
self._counts_var = tk.StringVar()
|
|
564
|
+
ttk.Label(frame, textvariable=self._counts_var).grid(
|
|
565
|
+
row=4, column=0, sticky="w", pady=(4, 0)
|
|
566
|
+
)
|
|
567
|
+
self._status_var = tk.StringVar(value="idle")
|
|
568
|
+
ttk.Label(frame, textvariable=self._status_var, wraplength=700).grid(
|
|
569
|
+
row=5, column=0, sticky="w"
|
|
570
|
+
)
|
|
571
|
+
self._summary_var = tk.StringVar()
|
|
572
|
+
ttk.Label(frame, textvariable=self._summary_var, wraplength=700).grid(
|
|
573
|
+
row=6, column=0, sticky="w", pady=(2, 0)
|
|
574
|
+
)
|
|
575
|
+
self._refresh_counts()
|
|
576
|
+
|
|
577
|
+
def _build_controls(self, root: tk.Tk) -> None:
|
|
578
|
+
frame = ttk.Frame(root, padding=(8, 0))
|
|
579
|
+
frame.grid(row=4, column=0, sticky="ew")
|
|
580
|
+
self._start_button = ttk.Button(frame, text="Start", command=self._on_start)
|
|
581
|
+
self._start_button.grid(row=0, column=0, padx=(0, 6))
|
|
582
|
+
self._pause_button = ttk.Button(frame, text="Pause", command=self._on_pause)
|
|
583
|
+
self._pause_button.grid(row=0, column=1, padx=6)
|
|
584
|
+
self._resume_button = ttk.Button(frame, text="Resume", command=self._on_resume)
|
|
585
|
+
self._resume_button.grid(row=0, column=2, padx=6)
|
|
586
|
+
self._stop_button = ttk.Button(frame, text="Stop", command=self._on_stop)
|
|
587
|
+
self._stop_button.grid(row=0, column=3, padx=6)
|
|
588
|
+
|
|
589
|
+
def _build_log(self, root: tk.Tk) -> None:
|
|
590
|
+
frame = ttk.LabelFrame(root, text="Log", padding=8)
|
|
591
|
+
frame.grid(row=5, column=0, sticky="nsew", padx=8, pady=(4, 8))
|
|
592
|
+
frame.columnconfigure(0, weight=1)
|
|
593
|
+
frame.rowconfigure(0, weight=1)
|
|
594
|
+
|
|
595
|
+
self._log = tk.Text(frame, height=14, wrap="none", state="disabled")
|
|
596
|
+
self._log.grid(row=0, column=0, sticky="nsew")
|
|
597
|
+
scroll = ttk.Scrollbar(frame, orient="vertical", command=self._log.yview)
|
|
598
|
+
scroll.grid(row=0, column=1, sticky="ns")
|
|
599
|
+
self._log.configure(yscrollcommand=scroll.set)
|
|
600
|
+
|
|
601
|
+
# ------------------------------------------------------------------
|
|
602
|
+
# Lifecycle
|
|
603
|
+
# ------------------------------------------------------------------
|
|
604
|
+
|
|
605
|
+
def start(self) -> None:
|
|
606
|
+
"""Arm the window: install the log handler, the close protocol and the drain.
|
|
607
|
+
|
|
608
|
+
Separate from the Start button, which begins a run. This prepares the
|
|
609
|
+
window to receive one.
|
|
610
|
+
"""
|
|
611
|
+
self._log_handler = _QueueLogHandler(self._events)
|
|
612
|
+
self._package_logger.addHandler(self._log_handler)
|
|
613
|
+
self._package_logger.setLevel(logging.INFO)
|
|
614
|
+
# The package logger's records terminate at the pane. Without this a
|
|
615
|
+
# record reaching a root logger with no handler falls through to
|
|
616
|
+
# logging.lastResort, which writes to a stderr a windowed process may
|
|
617
|
+
# not have.
|
|
618
|
+
self._package_logger.propagate = False
|
|
619
|
+
self._root.protocol("WM_DELETE_WINDOW", self._on_close)
|
|
620
|
+
self._schedule_drain()
|
|
621
|
+
logger.info("the sharefetch window is ready")
|
|
622
|
+
|
|
623
|
+
@property
|
|
624
|
+
def exit_code(self) -> int:
|
|
625
|
+
"""The exit code the last run earned."""
|
|
626
|
+
return self._exit_code
|
|
627
|
+
|
|
628
|
+
def _schedule_drain(self) -> None:
|
|
629
|
+
if self._closing:
|
|
630
|
+
return
|
|
631
|
+
self._drain_job = self._root.after(GUI_DRAIN_INTERVAL_MS, self._drain)
|
|
632
|
+
|
|
633
|
+
def _drain(self) -> None:
|
|
634
|
+
"""Consume queued events on the main thread. The only place Tk is touched."""
|
|
635
|
+
try:
|
|
636
|
+
for _ in range(DRAIN_BATCH_LIMIT):
|
|
637
|
+
item = self._events.get_nowait()
|
|
638
|
+
self._handle(item)
|
|
639
|
+
except queue.Empty:
|
|
640
|
+
pass
|
|
641
|
+
finally:
|
|
642
|
+
self._schedule_drain()
|
|
643
|
+
|
|
644
|
+
def _handle(self, item: Any) -> None:
|
|
645
|
+
"""Dispatch one queued item, absorbing any failure so the drain survives it."""
|
|
646
|
+
try:
|
|
647
|
+
self._dispatch(item)
|
|
648
|
+
except Exception:
|
|
649
|
+
logger.exception("the window could not handle a %s event", type(item).__name__)
|
|
650
|
+
|
|
651
|
+
def _dispatch(self, item: Any) -> None:
|
|
652
|
+
if isinstance(item, LogRecord):
|
|
653
|
+
self._append_log(item.message)
|
|
654
|
+
elif isinstance(item, FileProgress):
|
|
655
|
+
self._on_file_progress(item)
|
|
656
|
+
elif isinstance(item, FileStarted):
|
|
657
|
+
self._on_file_started(item)
|
|
658
|
+
elif isinstance(item, FileFinished):
|
|
659
|
+
self._on_file_finished(item)
|
|
660
|
+
elif isinstance(item, Resolved):
|
|
661
|
+
self._source_var.set(
|
|
662
|
+
f"site {item.site_web_url}, library {item.drive_name}, "
|
|
663
|
+
f"folder {item.folder_path or '/'}"
|
|
664
|
+
)
|
|
665
|
+
elif isinstance(item, DeviceCodeRequested):
|
|
666
|
+
self._show_device_code(item)
|
|
667
|
+
elif isinstance(item, DeviceCodeCompleted):
|
|
668
|
+
self._hide_device_panel()
|
|
669
|
+
# The account identifier the event carries is not displayed, so no
|
|
670
|
+
# user principal name reaches the window or the log.
|
|
671
|
+
self._status_var.set("signed in")
|
|
672
|
+
elif isinstance(item, EnumerationProgress):
|
|
673
|
+
self._status_var.set(
|
|
674
|
+
f"enumerating: {item.folders} folder(s), {item.files} file(s), "
|
|
675
|
+
f"{_format_bytes(item.total_bytes)}"
|
|
676
|
+
)
|
|
677
|
+
elif isinstance(item, EnumerationComplete):
|
|
678
|
+
self._total_bytes = item.total_bytes
|
|
679
|
+
self._status_var.set(
|
|
680
|
+
f"enumeration complete: {item.files} file(s) in {item.folders} folder(s), "
|
|
681
|
+
f"{_format_bytes(item.total_bytes)}"
|
|
682
|
+
)
|
|
683
|
+
self._refresh_overall()
|
|
684
|
+
elif isinstance(item, Reconciled):
|
|
685
|
+
counts = ", ".join(f"{name} {value}" for name, value in sorted(item.counts.items()))
|
|
686
|
+
self._status_var.set(f"reconciled: {counts or 'nothing'}")
|
|
687
|
+
elif isinstance(item, Throttled):
|
|
688
|
+
self._status_var.set(f"throttled for {item.seconds:.1f} s ({item.reason})")
|
|
689
|
+
elif isinstance(item, RunFinished):
|
|
690
|
+
self._on_run_finished(item)
|
|
691
|
+
elif isinstance(item, _RunFailed):
|
|
692
|
+
self._exit_code = EXIT_FATAL
|
|
693
|
+
self._summary_var.set(f"the run ended with an error: {item.message}")
|
|
694
|
+
elif isinstance(item, _RunEnded):
|
|
695
|
+
self._on_run_ended()
|
|
696
|
+
else:
|
|
697
|
+
logger.warning("an unrecognised item of type %s was queued", type(item).__name__)
|
|
698
|
+
|
|
699
|
+
# ------------------------------------------------------------------
|
|
700
|
+
# Event handling
|
|
701
|
+
# ------------------------------------------------------------------
|
|
702
|
+
|
|
703
|
+
def _show_device_code(self, event: DeviceCodeRequested) -> None:
|
|
704
|
+
"""Reveal the panel and preselect the code. Neither the code nor the URI is logged."""
|
|
705
|
+
self._code_var.set(event.user_code)
|
|
706
|
+
self._uri_var.set(event.verification_uri)
|
|
707
|
+
if event.expires_at > 0:
|
|
708
|
+
self._expiry_var.set(
|
|
709
|
+
"the code expires at " + time.strftime("%H:%M:%S", time.localtime(event.expires_at))
|
|
710
|
+
)
|
|
711
|
+
else:
|
|
712
|
+
self._expiry_var.set("")
|
|
713
|
+
self._device_panel.grid()
|
|
714
|
+
self._code_entry.selection_range(0, tk.END)
|
|
715
|
+
self._code_entry.focus_set()
|
|
716
|
+
self._status_var.set("waiting for the sign-in to complete")
|
|
717
|
+
logger.info("a device code was issued and the sign-in panel is showing it")
|
|
718
|
+
|
|
719
|
+
def _hide_device_panel(self) -> None:
|
|
720
|
+
self._device_panel.grid_remove()
|
|
721
|
+
self._code_var.set("")
|
|
722
|
+
self._uri_var.set("")
|
|
723
|
+
self._expiry_var.set("")
|
|
724
|
+
|
|
725
|
+
def _on_file_started(self, event: FileStarted) -> None:
|
|
726
|
+
# Bytes already in the .part file count towards the tree, because the
|
|
727
|
+
# run does not fetch them again.
|
|
728
|
+
self._overall_bytes += max(0, event.resume_from)
|
|
729
|
+
self._inflight[event.drive_path] = max(0, event.resume_from)
|
|
730
|
+
self._current = event.drive_path
|
|
731
|
+
self._refresh_file(event.drive_path, event.resume_from, event.size)
|
|
732
|
+
self._refresh_overall()
|
|
733
|
+
|
|
734
|
+
def _on_file_progress(self, event: FileProgress) -> None:
|
|
735
|
+
previous = self._inflight.get(event.drive_path, 0)
|
|
736
|
+
if event.bytes_written > previous:
|
|
737
|
+
self._overall_bytes += event.bytes_written - previous
|
|
738
|
+
self._inflight[event.drive_path] = event.bytes_written
|
|
739
|
+
if self._current is None:
|
|
740
|
+
self._current = event.drive_path
|
|
741
|
+
if event.drive_path == self._current:
|
|
742
|
+
self._refresh_file(event.drive_path, event.bytes_written, event.size)
|
|
743
|
+
self._refresh_overall()
|
|
744
|
+
|
|
745
|
+
def _on_file_finished(self, event: FileFinished) -> None:
|
|
746
|
+
self._inflight.pop(event.drive_path, None)
|
|
747
|
+
if event.status == STATUS_COMPLETE:
|
|
748
|
+
self._transferred += 1
|
|
749
|
+
elif event.status == STATUS_SKIPPED:
|
|
750
|
+
self._skipped += 1
|
|
751
|
+
elif event.status == STATUS_FAILED:
|
|
752
|
+
self._failed += 1
|
|
753
|
+
if self._current == event.drive_path:
|
|
754
|
+
self._current = next(iter(self._inflight), None)
|
|
755
|
+
if self._current is None:
|
|
756
|
+
self._file_bar.configure(value=0)
|
|
757
|
+
self._file_var.set("current file: none")
|
|
758
|
+
self._refresh_counts()
|
|
759
|
+
note = self._verification_note(event.verification)
|
|
760
|
+
if event.error:
|
|
761
|
+
self._status_var.set(f"{event.drive_path}: {event.status}, {event.error}")
|
|
762
|
+
elif note:
|
|
763
|
+
self._status_var.set(f"{event.drive_path}: {event.status}, verification {note}")
|
|
764
|
+
|
|
765
|
+
def _verification_note(self, verification: Any) -> str:
|
|
766
|
+
"""Summarise a verification result read duck-typed.
|
|
767
|
+
|
|
768
|
+
``FileFinished.verification`` is annotated ``Any`` because
|
|
769
|
+
:mod:`sharefetch.events` depends on no other module, so the attributes
|
|
770
|
+
are read rather than the type imported.
|
|
771
|
+
"""
|
|
772
|
+
if verification is None:
|
|
773
|
+
return ""
|
|
774
|
+
status = getattr(verification, "status", None)
|
|
775
|
+
if not isinstance(status, str):
|
|
776
|
+
return ""
|
|
777
|
+
reason = getattr(verification, "reason", None)
|
|
778
|
+
warnings = getattr(verification, "warnings", ()) or ()
|
|
779
|
+
note = status
|
|
780
|
+
if reason:
|
|
781
|
+
note = f"{note} ({reason})"
|
|
782
|
+
if warnings:
|
|
783
|
+
note = f"{note}, warnings: {', '.join(str(item) for item in warnings)}"
|
|
784
|
+
return note
|
|
785
|
+
|
|
786
|
+
def _on_run_finished(self, event: RunFinished) -> None:
|
|
787
|
+
summary = event.summary
|
|
788
|
+
self._exit_code = EXIT_FAILURES if (summary.failed or summary.skipped) else EXIT_OK
|
|
789
|
+
manifest = str(event.manifest_path) if event.manifest_path else "not written"
|
|
790
|
+
self._summary_var.set(
|
|
791
|
+
f"transferred {summary.transferred}, skipped {summary.skipped}, "
|
|
792
|
+
f"failed {summary.failed}, already present {summary.already_present}, "
|
|
793
|
+
f"verified {summary.verified}, {_format_bytes(summary.bytes_transferred)} moved; "
|
|
794
|
+
f"manifest: {manifest}"
|
|
795
|
+
)
|
|
796
|
+
self._status_var.set("run finished")
|
|
797
|
+
|
|
798
|
+
def _on_run_ended(self) -> None:
|
|
799
|
+
"""The worker thread has exited. Release the run's objects and re-arm Start."""
|
|
800
|
+
with self._control:
|
|
801
|
+
self._provider = None
|
|
802
|
+
self._engine = None
|
|
803
|
+
self._cancel_pending = False
|
|
804
|
+
self._stop_pending = False
|
|
805
|
+
self._hide_device_panel()
|
|
806
|
+
self._current = None
|
|
807
|
+
self._inflight.clear()
|
|
808
|
+
self._file_bar.configure(value=0)
|
|
809
|
+
self._file_var.set("current file: none")
|
|
810
|
+
self._set_state(_STATE_IDLE)
|
|
811
|
+
|
|
812
|
+
# ------------------------------------------------------------------
|
|
813
|
+
# Display helpers
|
|
814
|
+
# ------------------------------------------------------------------
|
|
815
|
+
|
|
816
|
+
def _refresh_overall(self) -> None:
|
|
817
|
+
moved = self._overall_bytes
|
|
818
|
+
total = self._total_bytes
|
|
819
|
+
if total > 0:
|
|
820
|
+
fraction = min(1.0, moved / total)
|
|
821
|
+
self._overall_bar.configure(value=fraction * PROGRESS_SCALE)
|
|
822
|
+
self._overall_var.set(
|
|
823
|
+
f"overall: {_format_bytes(moved)} of {_format_bytes(total)} enumerated"
|
|
824
|
+
)
|
|
825
|
+
else:
|
|
826
|
+
self._overall_bar.configure(value=0)
|
|
827
|
+
self._overall_var.set(f"overall: {_format_bytes(moved)}")
|
|
828
|
+
|
|
829
|
+
def _refresh_file(self, drive_path: str, written: int, size: int) -> None:
|
|
830
|
+
if size > 0:
|
|
831
|
+
fraction = min(1.0, max(0, written) / size)
|
|
832
|
+
self._file_bar.configure(value=fraction * PROGRESS_SCALE)
|
|
833
|
+
else:
|
|
834
|
+
self._file_bar.configure(value=0)
|
|
835
|
+
self._file_var.set(
|
|
836
|
+
f"current file: {drive_path} ({_format_bytes(written)} of {_format_bytes(size)})"
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
def _refresh_counts(self) -> None:
|
|
840
|
+
self._counts_var.set(
|
|
841
|
+
f"transferred {self._transferred}, skipped {self._skipped}, failed {self._failed}"
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
def _append_log(self, message: str) -> None:
|
|
845
|
+
"""Append one already-formatted, already-redacted record and trim the pane."""
|
|
846
|
+
self._log.configure(state="normal")
|
|
847
|
+
try:
|
|
848
|
+
self._log.insert("end", message + "\n")
|
|
849
|
+
lines = int(str(self._log.index("end-1c")).split(".")[0])
|
|
850
|
+
if lines > GUI_LOG_MAX_LINES:
|
|
851
|
+
self._log.delete("1.0", f"{lines - GUI_LOG_MAX_LINES + 1}.0")
|
|
852
|
+
self._log.see("end")
|
|
853
|
+
finally:
|
|
854
|
+
self._log.configure(state="disabled")
|
|
855
|
+
|
|
856
|
+
def _reset_run_display(self) -> None:
|
|
857
|
+
self._total_bytes = 0
|
|
858
|
+
self._overall_bytes = 0
|
|
859
|
+
self._inflight.clear()
|
|
860
|
+
self._current = None
|
|
861
|
+
self._transferred = 0
|
|
862
|
+
self._skipped = 0
|
|
863
|
+
self._failed = 0
|
|
864
|
+
self._exit_code = EXIT_OK
|
|
865
|
+
self._overall_bar.configure(value=0)
|
|
866
|
+
self._file_bar.configure(value=0)
|
|
867
|
+
self._overall_var.set("overall: nothing enumerated yet")
|
|
868
|
+
self._file_var.set("current file: none")
|
|
869
|
+
self._summary_var.set("")
|
|
870
|
+
self._status_var.set("starting")
|
|
871
|
+
self._refresh_counts()
|
|
872
|
+
while True:
|
|
873
|
+
try:
|
|
874
|
+
self._events.get_nowait()
|
|
875
|
+
except queue.Empty:
|
|
876
|
+
break
|
|
877
|
+
|
|
878
|
+
def _set_state(self, state: str) -> None:
|
|
879
|
+
running = state == _STATE_RUNNING
|
|
880
|
+
paused = state == _STATE_PAUSED
|
|
881
|
+
idle = state == _STATE_IDLE
|
|
882
|
+
self._start_button.configure(state="normal" if idle else "disabled")
|
|
883
|
+
self._pause_button.configure(state="normal" if running else "disabled")
|
|
884
|
+
self._resume_button.configure(state="normal" if paused else "disabled")
|
|
885
|
+
self._stop_button.configure(state="normal" if (running or paused) else "disabled")
|
|
886
|
+
if idle:
|
|
887
|
+
self._status_var.set(self._status_var.get() or "idle")
|
|
888
|
+
|
|
889
|
+
# ------------------------------------------------------------------
|
|
890
|
+
# Widget callbacks, main thread
|
|
891
|
+
# ------------------------------------------------------------------
|
|
892
|
+
|
|
893
|
+
def _on_url_focus_out(self, _event: Any = None) -> None:
|
|
894
|
+
"""Parse the pasted URL locally. No network call is made here."""
|
|
895
|
+
text = self._url_var.get().strip()
|
|
896
|
+
if not text:
|
|
897
|
+
self._source_var.set("paste a URL, then leave the field")
|
|
898
|
+
self._url_error_var.set("")
|
|
899
|
+
return
|
|
900
|
+
try:
|
|
901
|
+
reference = parse_url(text)
|
|
902
|
+
except ValidationError as exc:
|
|
903
|
+
self._source_var.set("")
|
|
904
|
+
self._url_error_var.set(str(exc))
|
|
905
|
+
self._start_button.configure(state="disabled")
|
|
906
|
+
logger.warning("the pasted URL was refused: %s", exc)
|
|
907
|
+
return
|
|
908
|
+
self._url_error_var.set("")
|
|
909
|
+
self._source_var.set(_describe_source(reference))
|
|
910
|
+
self._fill_default_destination(reference)
|
|
911
|
+
if self._worker is None or not self._worker.is_alive():
|
|
912
|
+
self._start_button.configure(state="normal")
|
|
913
|
+
|
|
914
|
+
def _fill_default_destination(self, reference: SitePathRef | ShareRef) -> None:
|
|
915
|
+
"""Offer a destination named after the addressed folder, without overwriting."""
|
|
916
|
+
name = _default_folder_name(reference)
|
|
917
|
+
if name is None:
|
|
918
|
+
return
|
|
919
|
+
current = self._dest_var.get().strip()
|
|
920
|
+
if current and current != self._dest_auto:
|
|
921
|
+
return
|
|
922
|
+
proposed = str(Path.cwd() / name)
|
|
923
|
+
self._dest_var.set(proposed)
|
|
924
|
+
self._dest_auto = proposed
|
|
925
|
+
|
|
926
|
+
def _on_browse(self) -> None:
|
|
927
|
+
chosen = filedialog.askdirectory(title="Choose the destination directory")
|
|
928
|
+
if chosen:
|
|
929
|
+
self._dest_var.set(str(Path(chosen)))
|
|
930
|
+
self._dest_auto = ""
|
|
931
|
+
|
|
932
|
+
def _on_field_focus_out(self, field: _BoundedField) -> None:
|
|
933
|
+
"""Refuse an out-of-range value: restore the last accepted one and say why."""
|
|
934
|
+
try:
|
|
935
|
+
field.value()
|
|
936
|
+
except ValidationError as exc:
|
|
937
|
+
self._options_error_var.set(str(exc))
|
|
938
|
+
field.var.set(field.last_good)
|
|
939
|
+
logger.warning("the %s value was refused: %s", field.name, exc)
|
|
940
|
+
return
|
|
941
|
+
field.last_good = field.var.get()
|
|
942
|
+
self._options_error_var.set("")
|
|
943
|
+
|
|
944
|
+
def _on_copy_code(self) -> None:
|
|
945
|
+
code = self._code_var.get()
|
|
946
|
+
if not code:
|
|
947
|
+
return
|
|
948
|
+
self._root.clipboard_clear()
|
|
949
|
+
self._root.clipboard_append(code)
|
|
950
|
+
self._status_var.set("the device code was copied to the clipboard")
|
|
951
|
+
logger.info("the device code was copied to the clipboard")
|
|
952
|
+
|
|
953
|
+
def _on_open_verification_uri(self, _event: Any = None) -> None:
|
|
954
|
+
uri = self._uri_var.get().strip()
|
|
955
|
+
if not uri:
|
|
956
|
+
return
|
|
957
|
+
opened = webbrowser.open(uri)
|
|
958
|
+
if opened:
|
|
959
|
+
self._status_var.set("the verification page was opened in the default browser")
|
|
960
|
+
logger.info("the verification page was opened in the default browser")
|
|
961
|
+
else:
|
|
962
|
+
self._status_var.set("no browser could be opened; enter the URL shown above by hand")
|
|
963
|
+
logger.warning("no browser could be opened for the verification page")
|
|
964
|
+
|
|
965
|
+
def _on_cancel_sign_in(self) -> None:
|
|
966
|
+
"""Cancel the device flow. The provider owns the event."""
|
|
967
|
+
with self._control:
|
|
968
|
+
provider = self._provider
|
|
969
|
+
if provider is None:
|
|
970
|
+
# The click beat the worker thread to building the provider. The
|
|
971
|
+
# flag is applied in _publish, which pre-empts the request.
|
|
972
|
+
self._cancel_pending = True
|
|
973
|
+
if provider is not None:
|
|
974
|
+
provider.cancel()
|
|
975
|
+
self._hide_device_panel()
|
|
976
|
+
self._status_var.set("the sign-in was cancelled")
|
|
977
|
+
|
|
978
|
+
def _on_start(self) -> None:
|
|
979
|
+
if self._worker is not None and self._worker.is_alive():
|
|
980
|
+
logger.warning("a run is already in progress, the Start button was ignored")
|
|
981
|
+
return
|
|
982
|
+
try:
|
|
983
|
+
settings = self._collect_settings()
|
|
984
|
+
except ValidationError as exc:
|
|
985
|
+
self._options_error_var.set(str(exc))
|
|
986
|
+
logger.error("the run was refused before it started: %s", exc)
|
|
987
|
+
return
|
|
988
|
+
self._options_error_var.set("")
|
|
989
|
+
self._reset_run_display()
|
|
990
|
+
with self._control:
|
|
991
|
+
self._provider = None
|
|
992
|
+
self._engine = None
|
|
993
|
+
self._cancel_pending = False
|
|
994
|
+
self._stop_pending = False
|
|
995
|
+
self._worker = threading.Thread(
|
|
996
|
+
target=self._run_engine,
|
|
997
|
+
args=(settings,),
|
|
998
|
+
name="sharefetch-engine",
|
|
999
|
+
daemon=True,
|
|
1000
|
+
)
|
|
1001
|
+
self._set_state(_STATE_RUNNING)
|
|
1002
|
+
self._worker.start()
|
|
1003
|
+
logger.info("a run was started against %s", settings.dest)
|
|
1004
|
+
|
|
1005
|
+
def _collect_settings(self) -> Settings:
|
|
1006
|
+
"""Build the run's settings, refusing every value the validators refuse."""
|
|
1007
|
+
url = self._url_var.get().strip()
|
|
1008
|
+
parse_url(url)
|
|
1009
|
+
dest = check_dest(self._dest_var.get())
|
|
1010
|
+
return Settings(
|
|
1011
|
+
url=url,
|
|
1012
|
+
dest=dest,
|
|
1013
|
+
connections=self._fields["connections"].value(),
|
|
1014
|
+
delay=self._fields["delay"].value(),
|
|
1015
|
+
limit_rate_kbps=self._fields["limit_rate"].value(),
|
|
1016
|
+
chunk_kib=self._fields["chunk"].value(),
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
def _on_pause(self) -> None:
|
|
1020
|
+
with self._control:
|
|
1021
|
+
engine = self._engine
|
|
1022
|
+
if engine is None:
|
|
1023
|
+
logger.warning("there is no run to pause")
|
|
1024
|
+
return
|
|
1025
|
+
engine.pause()
|
|
1026
|
+
self._set_state(_STATE_PAUSED)
|
|
1027
|
+
self._status_var.set("pausing at the next chunk boundary")
|
|
1028
|
+
|
|
1029
|
+
def _on_resume(self) -> None:
|
|
1030
|
+
with self._control:
|
|
1031
|
+
engine = self._engine
|
|
1032
|
+
if engine is None:
|
|
1033
|
+
logger.warning("there is no run to resume")
|
|
1034
|
+
return
|
|
1035
|
+
engine.resume()
|
|
1036
|
+
self._set_state(_STATE_RUNNING)
|
|
1037
|
+
self._status_var.set("resuming")
|
|
1038
|
+
|
|
1039
|
+
def _on_stop(self) -> None:
|
|
1040
|
+
self._request_stop()
|
|
1041
|
+
self._set_state(_STATE_STOPPING)
|
|
1042
|
+
self._status_var.set("stopping after the file in flight")
|
|
1043
|
+
|
|
1044
|
+
def _request_stop(self) -> None:
|
|
1045
|
+
"""End the run: stop the engine and abandon any sign-in still pending.
|
|
1046
|
+
|
|
1047
|
+
The sign-in is cancelled as well as the engine stopped, because a run
|
|
1048
|
+
waiting at the device prompt is blocked inside msal's polling loop and
|
|
1049
|
+
``Engine.stop`` alone would not reach it. The provider is discarded with
|
|
1050
|
+
the run, so cancelling it never affects a later one.
|
|
1051
|
+
"""
|
|
1052
|
+
with self._control:
|
|
1053
|
+
engine = self._engine
|
|
1054
|
+
provider = self._provider
|
|
1055
|
+
if engine is None:
|
|
1056
|
+
self._stop_pending = True
|
|
1057
|
+
if provider is None:
|
|
1058
|
+
self._cancel_pending = True
|
|
1059
|
+
if engine is not None:
|
|
1060
|
+
engine.stop()
|
|
1061
|
+
if provider is not None:
|
|
1062
|
+
provider.cancel()
|
|
1063
|
+
|
|
1064
|
+
def _on_close(self) -> None:
|
|
1065
|
+
"""Stop the run, wait for the engine to flush its state, then destroy the window.
|
|
1066
|
+
|
|
1067
|
+
The join is what guarantees the state file on disk describes the run
|
|
1068
|
+
that just ended: ``Engine.run`` flushes the store before it returns, so
|
|
1069
|
+
a joined thread has written it. Where the join times out, the state on
|
|
1070
|
+
disk is still valid, because the store flushes on its own interval and
|
|
1071
|
+
the ``.part`` sizes are authoritative.
|
|
1072
|
+
"""
|
|
1073
|
+
worker = self._worker
|
|
1074
|
+
if worker is not None and worker.is_alive():
|
|
1075
|
+
self._set_state(_STATE_STOPPING)
|
|
1076
|
+
self._status_var.set(
|
|
1077
|
+
f"stopping and writing the state file, up to {GUI_STOP_JOIN_SECONDS:.0f} s"
|
|
1078
|
+
)
|
|
1079
|
+
self._root.update_idletasks()
|
|
1080
|
+
self._request_stop()
|
|
1081
|
+
worker.join(GUI_STOP_JOIN_SECONDS)
|
|
1082
|
+
if worker.is_alive():
|
|
1083
|
+
logger.warning(
|
|
1084
|
+
"the engine thread did not end within %.0f s; the state file holds the last "
|
|
1085
|
+
"flush and the .part sizes remain authoritative",
|
|
1086
|
+
GUI_STOP_JOIN_SECONDS,
|
|
1087
|
+
)
|
|
1088
|
+
else:
|
|
1089
|
+
logger.info("the engine thread ended and its state file is written")
|
|
1090
|
+
self._teardown()
|
|
1091
|
+
self._root.destroy()
|
|
1092
|
+
|
|
1093
|
+
def _teardown(self) -> None:
|
|
1094
|
+
"""Cancel the drain and detach the log handler, leaving logging as it was."""
|
|
1095
|
+
self._closing = True
|
|
1096
|
+
if self._drain_job is not None:
|
|
1097
|
+
self._root.after_cancel(self._drain_job)
|
|
1098
|
+
self._drain_job = None
|
|
1099
|
+
if self._log_handler is not None:
|
|
1100
|
+
self._package_logger.removeHandler(self._log_handler)
|
|
1101
|
+
self._log_handler.close()
|
|
1102
|
+
self._log_handler = None
|
|
1103
|
+
self._package_logger.propagate = self._previous_propagate
|
|
1104
|
+
self._package_logger.setLevel(self._previous_level)
|
|
1105
|
+
|
|
1106
|
+
# ------------------------------------------------------------------
|
|
1107
|
+
# Worker thread. Nothing below this line touches a widget.
|
|
1108
|
+
# ------------------------------------------------------------------
|
|
1109
|
+
|
|
1110
|
+
def _run_engine(self, settings: Settings) -> None:
|
|
1111
|
+
"""Run the engine. Executes on the worker thread and makes no Tk call.
|
|
1112
|
+
|
|
1113
|
+
Every outcome reaches the window as a queued item: the engine's own
|
|
1114
|
+
events, a ``_RunFailed`` for a fatal error, and a ``_RunEnded`` that
|
|
1115
|
+
always arrives last.
|
|
1116
|
+
"""
|
|
1117
|
+
sink = _QueueSink(self._events)
|
|
1118
|
+
try:
|
|
1119
|
+
provider = TokenProvider(
|
|
1120
|
+
settings.client_id,
|
|
1121
|
+
settings.tenant,
|
|
1122
|
+
sink,
|
|
1123
|
+
reauth=settings.reauth,
|
|
1124
|
+
)
|
|
1125
|
+
engine = Engine(settings, sink, tokens=provider)
|
|
1126
|
+
self._publish(provider, engine)
|
|
1127
|
+
engine.run()
|
|
1128
|
+
except SharefetchError as exc:
|
|
1129
|
+
logger.error("the run ended with %s: %s", type(exc).__name__, exc)
|
|
1130
|
+
self._events.put(_RunFailed(message=f"{type(exc).__name__}: {exc}"))
|
|
1131
|
+
except Exception as exc:
|
|
1132
|
+
logger.exception("the run ended with an unexpected error")
|
|
1133
|
+
self._events.put(_RunFailed(message=f"{type(exc).__name__}: {exc}"))
|
|
1134
|
+
finally:
|
|
1135
|
+
self._events.put(_RunEnded())
|
|
1136
|
+
|
|
1137
|
+
def _publish(self, provider: TokenProvider, engine: Engine) -> None:
|
|
1138
|
+
"""Hand the main thread this run's provider and engine. Touches no widget.
|
|
1139
|
+
|
|
1140
|
+
A Cancel or a Stop that arrived before the objects existed is applied
|
|
1141
|
+
here, under the same lock that published them, so neither is lost to the
|
|
1142
|
+
race between the click and the thread starting.
|
|
1143
|
+
"""
|
|
1144
|
+
with self._control:
|
|
1145
|
+
self._provider = provider
|
|
1146
|
+
self._engine = engine
|
|
1147
|
+
cancel = self._cancel_pending
|
|
1148
|
+
stop = self._stop_pending
|
|
1149
|
+
if cancel:
|
|
1150
|
+
provider.cancel()
|
|
1151
|
+
if stop:
|
|
1152
|
+
engine.stop()
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
def main() -> int:
|
|
1156
|
+
"""Open the window and run it to completion.
|
|
1157
|
+
|
|
1158
|
+
Returns 0 where the last run completed cleanly, 1 where it finished with
|
|
1159
|
+
failed or skipped items, and 2 where no display is available, matching the
|
|
1160
|
+
command-line exit codes.
|
|
1161
|
+
"""
|
|
1162
|
+
try:
|
|
1163
|
+
root = tk.Tk()
|
|
1164
|
+
except tk.TclError as exc:
|
|
1165
|
+
logger.error("the graphical interface needs a display: %s", exc)
|
|
1166
|
+
return EXIT_FATAL
|
|
1167
|
+
app = App(root)
|
|
1168
|
+
app.start()
|
|
1169
|
+
root.mainloop()
|
|
1170
|
+
return app.exit_code
|