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/__init__.py +0 -0
- tabpull/__main__.py +3 -0
- tabpull/add.py +384 -0
- tabpull/app.py +385 -0
- tabpull/cli.py +17 -0
- tabpull/embed.py +248 -0
- tabpull/filters.py +172 -0
- tabpull/home.py +187 -0
- tabpull/jobs.py +201 -0
- tabpull/remove.py +88 -0
- tabpull/run.py +748 -0
- tabpull/tableau.py +457 -0
- tabpull/tui/__init__.py +3 -0
- tabpull/tui/app.py +1078 -0
- tabpull/tui/forms.py +757 -0
- tabpull/ui.py +76 -0
- tabpull/wizard.py +288 -0
- tabpull-0.1.0.dist-info/METADATA +170 -0
- tabpull-0.1.0.dist-info/RECORD +23 -0
- tabpull-0.1.0.dist-info/WHEEL +5 -0
- tabpull-0.1.0.dist-info/entry_points.txt +2 -0
- tabpull-0.1.0.dist-info/licenses/LICENSE +21 -0
- tabpull-0.1.0.dist-info/top_level.txt +1 -0
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)
|