tabpull 0.1.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.
tabpull/tui/forms.py ADDED
@@ -0,0 +1,757 @@
1
+ import threading
2
+ from collections.abc import Callable
3
+ from typing import ClassVar, override
4
+
5
+ from playwright.sync_api import sync_playwright
6
+ from textual.app import ComposeResult
7
+ from textual.binding import Binding, BindingType
8
+ from textual.containers import Horizontal, Vertical
9
+ from textual.content import Content
10
+ from textual.screen import ModalScreen
11
+ from textual.widget import Widget
12
+ from textual.widgets import (
13
+ Button,
14
+ Checkbox,
15
+ Input,
16
+ Label,
17
+ OptionList,
18
+ RadioButton,
19
+ RadioSet,
20
+ RichLog,
21
+ SelectionList,
22
+ Static,
23
+ TextArea,
24
+ )
25
+ from textual.widgets.option_list import Option
26
+ from textual.worker import active_worker, get_current_worker
27
+
28
+ from tabpull import ui
29
+ from tabpull.filters import make_filter
30
+ from tabpull.jobs import JobError, RangeFilter, ValuesFilter
31
+ from tabpull.tableau import (
32
+ MissingSettingsError,
33
+ Settings,
34
+ UnknownSiteError,
35
+ check_site_name,
36
+ home_url,
37
+ load_site,
38
+ parse_tableau_url,
39
+ rest_session,
40
+ save_site,
41
+ site_auth_path,
42
+ sso_login,
43
+ )
44
+ from tabpull.wizard import launch_url
45
+
46
+ FORM_CSS = """
47
+ Form, ConfirmScreen, ChoiceScreen, PickScreen, TaskScreen { align: center middle; }
48
+ #form {
49
+ width: 72; height: auto; max-height: 90%;
50
+ border: round $accent; background: $surface; padding: 1 2;
51
+ }
52
+ #form .title { text-style: bold; margin-bottom: 1; }
53
+ #form > Vertical { height: auto; }
54
+ #form Input, #form RadioSet, #form TextArea, #form Checkbox,
55
+ #form SelectionList, #form OptionList { margin-bottom: 1; }
56
+ #form TextArea { height: 8; }
57
+ #form SelectionList, #form OptionList { height: auto; max-height: 16; }
58
+ #error { color: $error; height: auto; display: none; }
59
+ #form .hint { color: $text-muted; }
60
+ #form .buttons { height: auto; }
61
+ #form Button { margin-right: 2; }
62
+ ChoiceScreen #form, PickScreen #form { width: 100; }
63
+ TaskScreen #form { height: 22; width: 80; }
64
+ TaskScreen #log { height: 1fr; margin-bottom: 1; }
65
+ ConfirmScreen #form .title { width: 1fr; }
66
+ """
67
+
68
+
69
+ class Form[T](ModalScreen[T | None]):
70
+ BINDINGS: ClassVar[list[BindingType]] = [
71
+ Binding('escape', 'cancel', 'cancel'),
72
+ Binding('ctrl+s', 'submit', 'save'),
73
+ ]
74
+
75
+ def __init__(self, title: str, commit: Callable[[T], None]) -> None:
76
+ super().__init__()
77
+ self.title_text = title
78
+ self.commit = commit
79
+
80
+ def fields(self) -> ComposeResult:
81
+ raise NotImplementedError
82
+
83
+ def build(self) -> T:
84
+ raise NotImplementedError
85
+
86
+ def compose(self) -> ComposeResult:
87
+ with Vertical(id='form'):
88
+ yield Label(self.title_text, classes='title')
89
+ yield from self.fields()
90
+ yield Static('', id='error')
91
+ with Horizontal(classes='buttons'):
92
+ yield Button('Save', variant='primary', id='save')
93
+ yield Button('Cancel', id='cancel')
94
+
95
+ def on_button_pressed(self, event: Button.Pressed) -> None:
96
+ if event.button.id == 'save':
97
+ self.action_submit()
98
+ else:
99
+ self.action_cancel()
100
+
101
+ def on_input_submitted(self, _event: Input.Submitted) -> None:
102
+ self.action_submit()
103
+
104
+ def action_cancel(self) -> None:
105
+ self.dismiss(None)
106
+
107
+ def action_submit(self) -> None:
108
+ try:
109
+ value = self.build()
110
+ self.commit(value)
111
+ except JobError as e:
112
+ error = self.query_one('#error', Static)
113
+ error.update(str(e))
114
+ error.display = True
115
+ return
116
+ self.dismiss(value)
117
+
118
+ def text(self, widget_id: str) -> str:
119
+ return self.query_one(f'#{widget_id}', Input).value
120
+
121
+
122
+ class TextForm(Form[str]):
123
+ def __init__(
124
+ self, title: str, label: str, value: str, commit: Callable[[str], None]
125
+ ) -> None:
126
+ super().__init__(title, commit)
127
+ self.label, self.value = label, value
128
+
129
+ def fields(self) -> ComposeResult:
130
+ yield Label(self.label)
131
+ yield Input(self.value, id='value')
132
+
133
+ def build(self) -> str:
134
+ value = self.text('value').strip()
135
+ if not value:
136
+ msg = f'{self.label} is empty'
137
+ raise JobError(msg)
138
+ return value
139
+
140
+
141
+ class ParamForm(Form[tuple[str, str]]):
142
+ def __init__(
143
+ self,
144
+ title: str,
145
+ name: str,
146
+ value: str,
147
+ commit: Callable[[tuple[str, str]], None],
148
+ ) -> None:
149
+ super().__init__(title, commit)
150
+ self.param_name, self.value = name, value
151
+
152
+ def fields(self) -> ComposeResult:
153
+ yield Label('Parameter name (as Tableau shows it)')
154
+ yield Input(self.param_name, id='name')
155
+ yield Label('Value')
156
+ yield Input(self.value, id='value')
157
+
158
+ def build(self) -> tuple[str, str]:
159
+ name = self.text('name').strip()
160
+ if not name:
161
+ msg = 'parameter needs a name'
162
+ raise JobError(msg)
163
+ return name, self.text('value').strip()
164
+
165
+
166
+ class SheetsForm(Form[list[str]]):
167
+ def __init__(
168
+ self, title: str, sheets: list[str], commit: Callable[[list[str]], None]
169
+ ) -> None:
170
+ super().__init__(title, commit)
171
+ self.sheets = sheets
172
+
173
+ def fields(self) -> ComposeResult:
174
+ yield Label('One worksheet per line, exactly as named in the dashboard')
175
+ yield TextArea('\n'.join(self.sheets), id='sheets')
176
+
177
+ def build(self) -> list[str]:
178
+ text = self.query_one('#sheets', TextArea).text
179
+ sheets = [line.strip() for line in text.splitlines() if line.strip()]
180
+ if not sheets:
181
+ msg = 'needs at least one sheet'
182
+ raise JobError(msg)
183
+ return sheets
184
+
185
+
186
+ class FilterForm(Form[ValuesFilter | RangeFilter]):
187
+ def __init__(
188
+ self,
189
+ title: str,
190
+ item: ValuesFilter | RangeFilter | None,
191
+ default_sheet: str,
192
+ commit: Callable[[ValuesFilter | RangeFilter], None],
193
+ ) -> None:
194
+ super().__init__(title, commit)
195
+ self.item = item
196
+ self.default_sheet = default_sheet
197
+
198
+ def fields(self) -> ComposeResult:
199
+ item = self.item
200
+ is_range = item is not None and item.kind == RangeFilter.kind
201
+ yield Label('Field (as Tableau shows it)')
202
+ yield Input(item.field if item else '', id='field')
203
+ yield Label('Sheet the filter is set on')
204
+ yield Input(item.sheet if item else self.default_sheet, id='sheet')
205
+ with RadioSet(id='kind'):
206
+ yield RadioButton(
207
+ 'Values (pick list)', value=not is_range, id='values-kind'
208
+ )
209
+ yield RadioButton(
210
+ 'Range (dates or numbers)', value=is_range, id='range-kind'
211
+ )
212
+ with Vertical(id='values-box'):
213
+ yield Label('Values, separated by |')
214
+ yield Input(item.pick_list if item else '', id='values')
215
+ low, high = item.bounds if item else ('', '')
216
+ with Vertical(id='range-box'):
217
+ yield Label('From (YYYY-MM-DD, M/D/YYYY, or a number; blank = open)')
218
+ yield Input(low, id='low')
219
+ yield Label('To (blank = open)')
220
+ yield Input(high, id='high')
221
+
222
+ def on_mount(self) -> None:
223
+ self._show_kind()
224
+
225
+ def on_radio_set_changed(self, _event: RadioSet.Changed) -> None:
226
+ self._show_kind()
227
+
228
+ def _is_range(self) -> bool:
229
+ return self.query_one('#range-kind', RadioButton).value
230
+
231
+ def _show_kind(self) -> None:
232
+ is_range = self._is_range()
233
+ self.query_one('#values-box').display = not is_range
234
+ self.query_one('#range-box').display = is_range
235
+
236
+ def build(self) -> ValuesFilter | RangeFilter:
237
+ field_name, sheet = self.text('field'), self.text('sheet')
238
+ if self._is_range():
239
+ return make_filter(
240
+ field_name, sheet, low=self.text('low'), high=self.text('high')
241
+ )
242
+ return make_filter(field_name, sheet, values=self.text('values'))
243
+
244
+
245
+ class ConfirmScreen(ModalScreen[None]):
246
+ BINDINGS: ClassVar[list[BindingType]] = [
247
+ Binding('y', 'yes', 'yes'),
248
+ Binding('n,escape', 'no', 'no'),
249
+ ]
250
+
251
+ def __init__(self, message: str, on_yes: Callable[[], None]) -> None:
252
+ super().__init__()
253
+ self.message = message
254
+ self.on_yes = on_yes
255
+
256
+ def compose(self) -> ComposeResult:
257
+ with Vertical(id='form'):
258
+ yield Static(self.message, classes='title', markup=False)
259
+ with Horizontal(classes='buttons'):
260
+ yield Button('Yes (y)', variant='error', id='yes')
261
+ yield Button('No (n)', id='no')
262
+
263
+ def on_button_pressed(self, event: Button.Pressed) -> None:
264
+ if event.button.id == 'yes':
265
+ self.action_yes()
266
+ else:
267
+ self.action_no()
268
+
269
+ def action_yes(self) -> None:
270
+ self.dismiss()
271
+ self.on_yes()
272
+
273
+ def action_no(self) -> None:
274
+ self.dismiss()
275
+
276
+
277
+ def _failure_text(exc: BaseException) -> str:
278
+ if isinstance(exc, KeyboardInterrupt):
279
+ return 'Cancelled.'
280
+ if isinstance(exc, SystemExit):
281
+ if isinstance(exc.code, str) and exc.code:
282
+ return exc.code
283
+ return f'Failed (exit {exc.code})'
284
+ text = str(exc).partition('\n')[0].strip()
285
+ return text or exc.__class__.__name__
286
+
287
+
288
+ def _show_error(screen: Widget, message: str) -> None:
289
+ error = screen.query_one('#error', Static)
290
+ error.update(message)
291
+ error.display = True
292
+
293
+
294
+ class ChoiceScreen(ModalScreen[None]):
295
+ BINDINGS: ClassVar[list[BindingType]] = [
296
+ Binding('enter', 'pick', 'pick', priority=True),
297
+ Binding('escape', 'cancel', 'cancel'),
298
+ ]
299
+
300
+ def __init__(
301
+ self,
302
+ title: str,
303
+ options: list[tuple[str, str]],
304
+ commit: Callable[[str], None],
305
+ *,
306
+ disabled: set[str] | None = None,
307
+ ) -> None:
308
+ super().__init__()
309
+ self.title_text = title
310
+ self._options = options
311
+ self._commit = commit
312
+ self._disabled = disabled or set()
313
+
314
+ def compose(self) -> ComposeResult:
315
+ with Vertical(id='form'):
316
+ yield Label(self.title_text, classes='title')
317
+ yield OptionList(
318
+ *[
319
+ Option(label, id=value, disabled=value in self._disabled)
320
+ for label, value in self._options
321
+ ],
322
+ id='choices',
323
+ markup=False,
324
+ )
325
+
326
+ def on_mount(self) -> None:
327
+ self.query_one(OptionList).focus()
328
+
329
+ def action_pick(self) -> None:
330
+ self.query_one(OptionList).action_select()
331
+
332
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
333
+ option = event.option
334
+ if option.id is None or option.disabled:
335
+ return
336
+ self.dismiss()
337
+ self._commit(option.id)
338
+
339
+ def action_cancel(self) -> None:
340
+ self.dismiss()
341
+
342
+
343
+ class PickScreen(ModalScreen[None]):
344
+ """A list that stays open until Done. Enter on a row calls on_pick."""
345
+
346
+ BINDINGS: ClassVar[list[BindingType]] = [
347
+ Binding('enter', 'pick', 'pick', priority=True),
348
+ Binding('escape', 'cancel', 'cancel'),
349
+ ]
350
+
351
+ def __init__(
352
+ self,
353
+ title: str,
354
+ options: list[tuple[str, str]],
355
+ on_pick: Callable[[str], None],
356
+ on_done: Callable[[], None],
357
+ ) -> None:
358
+ super().__init__()
359
+ self.title_text = title
360
+ self._options = options
361
+ self._on_pick = on_pick
362
+ self._on_done = on_done
363
+
364
+ def compose(self) -> ComposeResult:
365
+ with Vertical(id='form'):
366
+ yield Label(self.title_text, classes='title')
367
+ yield Static(
368
+ 'Enter picks a row. Done moves on. Escape cancels.', classes='hint'
369
+ )
370
+ yield OptionList(
371
+ *[Option(label, id=value) for label, value in self._options],
372
+ Option('Done', id='done'),
373
+ id='choices',
374
+ markup=False,
375
+ )
376
+
377
+ def on_mount(self) -> None:
378
+ self.query_one(OptionList).focus()
379
+
380
+ def action_pick(self) -> None:
381
+ self.query_one(OptionList).action_select()
382
+
383
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
384
+ if event.option.id == 'done':
385
+ self.dismiss()
386
+ self._on_done()
387
+ return
388
+ if event.option.id is None:
389
+ return
390
+ self._on_pick(event.option.id)
391
+
392
+ def action_cancel(self) -> None:
393
+ self.dismiss()
394
+
395
+
396
+ class ChecksForm(Form[list[str]]):
397
+ BINDINGS: ClassVar[list[BindingType]] = [
398
+ Binding('space', 'tick', 'tick', priority=True),
399
+ ]
400
+
401
+ def __init__(
402
+ self,
403
+ title: str,
404
+ options: list[str],
405
+ commit: Callable[[list[str]], None],
406
+ *,
407
+ ticked: set[str] | None = None,
408
+ label_suffix: dict[str, str] | None = None,
409
+ ) -> None:
410
+ super().__init__(title, commit)
411
+ self._options = options
412
+ self._ticked = ticked or set()
413
+ self._label_suffix = label_suffix or {}
414
+
415
+ def fields(self) -> ComposeResult:
416
+ yield Label('Space toggles a sheet. Save keeps the ticked ones.')
417
+ suffixes, ticked = self._label_suffix, self._ticked
418
+ yield SelectionList[str](
419
+ *(
420
+ (
421
+ Content(f'{name}{suffixes.get(name, "")}'),
422
+ name,
423
+ name in ticked,
424
+ )
425
+ for name in self._options
426
+ ),
427
+ id='checks',
428
+ )
429
+
430
+ def on_mount(self) -> None:
431
+ self.query_one(SelectionList).focus()
432
+
433
+ def action_tick(self) -> None:
434
+ self.query_one(SelectionList).action_select()
435
+
436
+ def build(self) -> list[str]:
437
+ picked = set(self.query_one(SelectionList).selected)
438
+ chosen = [name for name in self._options if name in picked]
439
+ if not chosen:
440
+ msg = 'Pick at least one sheet'
441
+ raise JobError(msg)
442
+ return chosen
443
+
444
+
445
+ _TASK_POLL_S = 0.05
446
+
447
+
448
+ class TaskScreen[T](ModalScreen[None]):
449
+ BINDINGS: ClassVar[list[BindingType]] = [
450
+ Binding('ctrl+c,escape', 'cancel', 'cancel', priority=True),
451
+ ]
452
+
453
+ def __init__(
454
+ self,
455
+ title: str,
456
+ work: Callable[[], T],
457
+ done: Callable[[T], None],
458
+ fail: Callable[[str], None],
459
+ on_cancel: Callable[[], None] | None = None,
460
+ *,
461
+ hold_failure: bool = False,
462
+ ) -> None:
463
+ super().__init__()
464
+ self.title_text = title
465
+ self._work = work
466
+ self._done_cb = done
467
+ self._fail = fail
468
+ self._on_cancel = on_cancel
469
+ self._hold_failure = hold_failure
470
+ self._settled = False
471
+ self._failed_message: str | None = None
472
+ self._call: Callable[..., object] | None = None
473
+ self._release: Callable[[int], None] | None = None
474
+ self._task_key = 0
475
+ self.transcript: list[str] = []
476
+
477
+ def compose(self) -> ComposeResult:
478
+ with Vertical(id='form'):
479
+ yield Label(self.title_text, classes='title')
480
+ yield RichLog(id='log', markup=False, wrap=True, auto_scroll=True)
481
+ yield Static('escape or ctrl+c cancels', id='task-hint', classes='hint')
482
+
483
+ def on_mount(self) -> None:
484
+ host = self.app
485
+ self._call = host.call_from_thread
486
+ self._task_key = id(self)
487
+ hold = getattr(host, 'hold_task', None)
488
+ if hold is not None:
489
+ hold(self._task_key)
490
+ self._release = getattr(host, 'release_task', None)
491
+ self.run_worker(
492
+ self._run,
493
+ thread=True,
494
+ exclusive=True,
495
+ exit_on_error=False,
496
+ group='tabpull-task',
497
+ )
498
+
499
+ def _run(self) -> None:
500
+ # A blocked Tableau call must not pin process shutdown. The call runs
501
+ # on a daemon thread; this worker returns once it is cancelled.
502
+ worker = get_current_worker()
503
+ finished = threading.Event()
504
+
505
+ def work() -> None:
506
+ token = active_worker.set(worker)
507
+ try:
508
+ self._work_in_thread()
509
+ finally:
510
+ active_worker.reset(token)
511
+ finished.set()
512
+
513
+ threading.Thread(target=work, name='tabpull-task', daemon=True).start()
514
+ while not finished.wait(_TASK_POLL_S):
515
+ if worker.is_cancelled:
516
+ return
517
+
518
+ def _work_in_thread(self) -> None:
519
+ call = self._call
520
+ if call is None:
521
+ return
522
+
523
+ def log(text: str) -> None:
524
+ call(self._write, text)
525
+
526
+ def stop() -> bool:
527
+ return get_current_worker().is_cancelled
528
+
529
+ try:
530
+ try:
531
+ with ui.capture(log, stop):
532
+ result = self._work()
533
+ except (KeyboardInterrupt, SystemExit, Exception) as exc: # noqa: BLE001
534
+ self._drop_task()
535
+ call(self._fail_with, _failure_text(exc))
536
+ return
537
+ self._drop_task()
538
+ call(self._succeed, result)
539
+ finally:
540
+ self._drop_task()
541
+
542
+ def _drop_task(self) -> None:
543
+ release = self._release
544
+ key = self._task_key
545
+ if release is None or key == 0:
546
+ return
547
+ self._task_key = 0
548
+ release(key)
549
+
550
+ def _write(self, text: str) -> None:
551
+ if self._settled or not self.is_attached:
552
+ return
553
+ log = self.query_one(RichLog)
554
+ for line in text.splitlines():
555
+ if line:
556
+ self.transcript.append(line)
557
+ log.write(line)
558
+
559
+ def _succeed(self, result: T) -> None:
560
+ if self._settled:
561
+ return
562
+ self._settled = True
563
+ self.dismiss()
564
+ self._done_cb(result)
565
+
566
+ def _fail_with(self, message: str) -> None:
567
+ if self._settled or not self.is_attached:
568
+ return
569
+ if not self._hold_failure:
570
+ self._settled = True
571
+ self.dismiss()
572
+ self._fail(message)
573
+ return
574
+ if self._failed_message is not None:
575
+ return
576
+ self._failed_message = message
577
+ self._write(message)
578
+ self.query_one('#task-hint', Static).update('press escape to close')
579
+
580
+ def action_cancel(self) -> None:
581
+ if self._failed_message is not None:
582
+ message = self._failed_message
583
+ self._failed_message = None
584
+ self._settled = True
585
+ self.dismiss()
586
+ self._fail(message)
587
+ return
588
+ if self._settled:
589
+ return
590
+ self._settled = True
591
+ self.workers.cancel_group(self, 'tabpull-task')
592
+ self.dismiss()
593
+ if self._on_cancel is None:
594
+ self.app.notify('Cancelled.', severity='warning')
595
+ return
596
+ self._on_cancel()
597
+
598
+
599
+ def _check_token(settings: Settings) -> str:
600
+ with rest_session(settings):
601
+ return 'ok'
602
+
603
+
604
+ def sign_in(settings: Settings) -> str:
605
+ with sync_playwright() as playwright:
606
+ sso_login(playwright, settings)
607
+ return settings.name
608
+
609
+
610
+ class SetupForm(Form[None]):
611
+ def __init__(self, on_done: Callable[[str], None] | None = None) -> None:
612
+ super().__init__('Set up a Tableau site', lambda _value: None)
613
+ self._on_done = on_done
614
+
615
+ @override
616
+ def fields(self) -> ComposeResult:
617
+ yield Static(
618
+ 'Create a personal access token on the site, then paste it here. '
619
+ 'Browser sign-in is separate and saves the session exports use.',
620
+ classes='hint',
621
+ )
622
+ yield Label('Local site name')
623
+ yield Input(placeholder='finance', id='site')
624
+ yield Label('Dashboard URL (blank keeps the saved URL)')
625
+ yield Input(placeholder='https://...', id='url')
626
+ yield Label('Token name (blank keeps the saved name)')
627
+ yield Input(placeholder='tabpull', id='pat-name')
628
+ yield Label('Token secret (blank keeps the saved secret)')
629
+ yield Input(password=True, id='pat-secret')
630
+ yield Checkbox('Sign in with the browser after saving', value=True, id='sso')
631
+ yield Button('Open account page', id='open-account')
632
+
633
+ def on_mount(self) -> None:
634
+ self.query_one('#site', Input).focus()
635
+
636
+ def on_button_pressed(self, event: Button.Pressed) -> None:
637
+ if event.button.id == 'open-account':
638
+ self._open_account()
639
+ return
640
+ super().on_button_pressed(event)
641
+
642
+ @override
643
+ def build(self) -> None:
644
+ return None
645
+
646
+ def _open_account(self) -> None:
647
+ try:
648
+ parsed = parse_tableau_url(self.text('url'))
649
+ except ValueError as e:
650
+ _show_error(self, str(e))
651
+ return
652
+ if not launch_url(home_url(parsed.server, parsed.site)):
653
+ _show_error(
654
+ self, 'Could not open a browser. Open the dashboard URL yourself.'
655
+ )
656
+
657
+ def _collect(self) -> Settings:
658
+ name = check_site_name(self.text('site').strip())
659
+ url = self.text('url').strip()
660
+ secret = self.text('pat-secret').strip()
661
+ current: Settings | None = None
662
+ try:
663
+ current = load_site(name)
664
+ except (UnknownSiteError, MissingSettingsError, ValueError):
665
+ current = None
666
+ token = self.text('pat-name').strip() or (
667
+ current.pat_name if current is not None and current.pat_name else 'tabpull'
668
+ )
669
+ if url:
670
+ parsed = parse_tableau_url(url)
671
+ server, site = parsed.server, parsed.site
672
+ elif current is not None:
673
+ server, site = current.server, current.site
674
+ else:
675
+ msg = 'Dashboard URL is empty'
676
+ raise JobError(msg)
677
+ if not secret:
678
+ if current is None or not current.pat_secret:
679
+ msg = 'Token secret is empty'
680
+ raise JobError(msg)
681
+ secret = current.pat_secret
682
+ return Settings(server, site, token, name, site_auth_path(name), secret)
683
+
684
+ def action_submit(self) -> None:
685
+ try:
686
+ settings = self._collect()
687
+ except (JobError, ValueError) as e:
688
+ _show_error(self, str(e))
689
+ return
690
+
691
+ def failed(message: str) -> None:
692
+ self.app.push_screen(
693
+ ConfirmScreen(
694
+ f'{message}\n\nSave this token anyway?',
695
+ lambda: self._checked(settings),
696
+ )
697
+ )
698
+
699
+ _start_task(
700
+ self,
701
+ TaskScreen(
702
+ 'Checking the token…',
703
+ lambda: _check_token(settings),
704
+ lambda _ok: self._checked(settings),
705
+ failed,
706
+ ),
707
+ )
708
+
709
+ def _checked(self, settings: Settings) -> None:
710
+ try:
711
+ save_site(
712
+ settings.name,
713
+ {
714
+ 'TABLEAU_SERVER_URL': settings.server,
715
+ 'TABLEAU_SITE': settings.site,
716
+ 'TABLEAU_PAT_NAME': settings.pat_name,
717
+ 'TABLEAU_PAT_SECRET': settings.pat_secret,
718
+ },
719
+ )
720
+ except OSError as e:
721
+ _show_error(self, str(e))
722
+ return
723
+ if self.query_one('#sso', Checkbox).value:
724
+ _start_task(
725
+ self,
726
+ TaskScreen(
727
+ 'Signing in…',
728
+ lambda: sign_in(settings),
729
+ lambda _name: self._finish(settings.name),
730
+ lambda message: self._finish(settings.name, error=message),
731
+ ),
732
+ )
733
+ return
734
+ self._finish(settings.name, skipped=True)
735
+
736
+ def _finish(self, name: str, *, error: str = '', skipped: bool = False) -> None:
737
+ self.dismiss()
738
+ if error:
739
+ self.app.notify(
740
+ f'{name} saved, but sign-in failed: {error}',
741
+ severity='error',
742
+ timeout=10,
743
+ )
744
+ elif skipped:
745
+ self.app.notify(f'Site {name} saved. Browser sign-in skipped.')
746
+ else:
747
+ self.app.notify(f'Site {name} saved and signed in')
748
+ if self._on_done is not None:
749
+ self._on_done(name)
750
+
751
+
752
+ def _start_task[T](screen: Widget, task: TaskScreen[T]) -> None:
753
+ start = getattr(screen.app, 'start_task', None)
754
+ if start is None:
755
+ screen.app.push_screen(task)
756
+ return
757
+ start(task)