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/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