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/app.py ADDED
@@ -0,0 +1,1078 @@
1
+ import tomllib
2
+ from collections.abc import Sequence
3
+ from contextlib import AbstractContextManager, nullcontext
4
+ from dataclasses import dataclass, field, replace
5
+ from pathlib import Path
6
+ from typing import Any, ClassVar, override
7
+
8
+ import tableauserverclient as tsc
9
+ from playwright.sync_api import sync_playwright
10
+ from rich.markup import escape
11
+ from rich.text import Text
12
+ from textual.app import App, ComposeResult
13
+ from textual.binding import Binding, BindingType
14
+ from textual.screen import Screen
15
+ from textual.widgets import DataTable, Footer, Label, Static
16
+
17
+ from tabpull import ui
18
+ from tabpull.add import (
19
+ MAX_SEARCH_RESULTS,
20
+ ListedFilter,
21
+ append_job,
22
+ filter_defaults,
23
+ listed_filters,
24
+ read_view,
25
+ search_views,
26
+ view_from_flag,
27
+ view_path,
28
+ )
29
+ from tabpull.embed import ViewInfo
30
+ from tabpull.filters import format_filter, resolved_filters, same_filter
31
+ from tabpull.home import site_rows, sso_badge
32
+ from tabpull.jobs import (
33
+ Job,
34
+ JobError,
35
+ RangeFilter,
36
+ ValuesFilter,
37
+ check_known,
38
+ delete_jobs,
39
+ load_jobs,
40
+ slug,
41
+ update_job,
42
+ )
43
+ from tabpull.run import run_jobs as export_jobs
44
+ from tabpull.tableau import (
45
+ MissingSettingsError,
46
+ Settings,
47
+ UnknownSiteError,
48
+ browser_session,
49
+ list_sites,
50
+ load_site,
51
+ parse_tableau_url,
52
+ )
53
+ from tabpull.tui.forms import (
54
+ FORM_CSS,
55
+ ChecksForm,
56
+ ChoiceScreen,
57
+ ConfirmScreen,
58
+ FilterForm,
59
+ ParamForm,
60
+ PickScreen,
61
+ SetupForm,
62
+ SheetsForm,
63
+ TaskScreen,
64
+ TextForm,
65
+ sign_in,
66
+ )
67
+
68
+ CSS = (
69
+ FORM_CSS
70
+ + """
71
+ .section { text-style: bold; color: $accent; margin-top: 1; padding-left: 1; }
72
+ .info { padding-left: 1; }
73
+ #status { dock: top; height: 1; padding-left: 1; background: $panel; }
74
+ DataTable { height: auto; max-height: 50%; }
75
+ #jobs, #filters, #params { max-height: 1fr; }
76
+ """
77
+ )
78
+ _MARK = '●'
79
+ _MISSING_SHEET = ' (not on the live view)'
80
+
81
+
82
+ def _load_error(path: Path) -> tuple[list[Job], str | None]:
83
+ try:
84
+ return load_jobs(path), None
85
+ except (JobError, tomllib.TOMLDecodeError, OSError) as e:
86
+ return [], f'{ui.home_path(path)} is unreadable: {e}'
87
+
88
+
89
+ def _row(table: DataTable[Any]) -> int | None:
90
+ return table.cursor_row if table.row_count else None
91
+
92
+
93
+ def _refill(
94
+ table: DataTable[Any],
95
+ rows: list[tuple[object, ...]],
96
+ keys: list[str] | None = None,
97
+ ) -> None:
98
+ cursor = table.cursor_row
99
+ table.clear()
100
+ for i, row in enumerate(rows):
101
+ table.add_row(*row, key=keys[i] if keys else None)
102
+ if rows:
103
+ table.move_cursor(row=min(cursor, len(rows) - 1))
104
+
105
+
106
+ def _save_error(e: Exception) -> JobError:
107
+ return e if isinstance(e, JobError) else JobError(str(e))
108
+
109
+
110
+ class HomeScreen(Screen[None]):
111
+ app: 'TabpullApp'
112
+
113
+ BINDINGS: ClassVar[list[BindingType]] = [
114
+ Binding('enter', 'open', 'open', priority=True),
115
+ Binding('space', 'mark', 'mark'),
116
+ Binding('r', 'run', 'run'),
117
+ Binding('a', 'add', 'add'),
118
+ Binding('d', 'remove', 'remove'),
119
+ Binding('s', 'setup', 'setup'),
120
+ Binding('l', 'login', 'login'),
121
+ Binding('g', 'reload', 'reload'),
122
+ ]
123
+
124
+ def __init__(self) -> None:
125
+ super().__init__()
126
+ self.jobs: list[Job] = []
127
+ self.marked: set[str] = set()
128
+
129
+ @override
130
+ def compose(self) -> ComposeResult:
131
+ yield Label('Sites', classes='section')
132
+ yield DataTable(id='sites', cursor_type='none')
133
+ yield Label('Jobs', classes='section')
134
+ yield Static('', id='jobs-error', classes='info')
135
+ yield DataTable(id='jobs', cursor_type='row', zebra_stripes=True)
136
+ yield Static('', id='status')
137
+ yield Footer()
138
+
139
+ def on_mount(self) -> None:
140
+ sites = self.query_one('#sites', DataTable)
141
+ sites.add_columns('site', 'server', 'tableau site', 'sso')
142
+ sites.can_focus = False
143
+ self.query_one('#jobs', DataTable).add_columns(
144
+ ' ', 'job', 'site', 'view', 'sheets', 'filters', 'params'
145
+ )
146
+ self.reload()
147
+ self.query_one('#jobs', DataTable).focus()
148
+
149
+ def on_screen_resume(self) -> None:
150
+ self.reload()
151
+
152
+ def reload(self) -> None:
153
+ sites = self.query_one('#sites', DataTable)
154
+ sites.clear()
155
+ rows = site_rows()
156
+ for name, server, site in rows:
157
+ sites.add_row(name, server, site, Text.from_markup(sso_badge(name, server)))
158
+ if not rows:
159
+ sites.add_row('none yet', 'press s to set up a site', '', '')
160
+ self.jobs, error = _load_error(self.app.jobs_file)
161
+ names = {job.name for job in self.jobs}
162
+ self.marked &= names
163
+ error_line = self.query_one('#jobs-error', Static)
164
+ error_line.update(escape(error or ''))
165
+ error_line.display = bool(error)
166
+ _refill(
167
+ self.query_one('#jobs', DataTable),
168
+ [
169
+ (
170
+ _MARK if job.name in self.marked else '',
171
+ job.name,
172
+ job.site,
173
+ job.view,
174
+ ', '.join(job.sheets),
175
+ str(len(job.filters)),
176
+ str(len(job.params)),
177
+ )
178
+ for job in self.jobs
179
+ ],
180
+ keys=[job.name for job in self.jobs],
181
+ )
182
+ self.query_one('#status', Static).update(
183
+ f'[bold]tabpull[/] jobs {escape(ui.home_path(self.app.jobs_file))} '
184
+ f'exports {escape(ui.home_path(self.app.out_dir))}/<job>/<sheet>.csv'
185
+ + (f' {escape(self.app.last_run)}' if self.app.last_run else '')
186
+ )
187
+
188
+ def _current(self) -> Job | None:
189
+ table = self.query_one('#jobs', DataTable)
190
+ if not self.jobs or table.cursor_row >= len(self.jobs):
191
+ return None
192
+ return self.jobs[table.cursor_row]
193
+
194
+ def _selected(self) -> list[str]:
195
+ if self.marked:
196
+ return [job.name for job in self.jobs if job.name in self.marked]
197
+ current = self._current()
198
+ return [current.name] if current else []
199
+
200
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
201
+ if event.data_table.id == 'jobs':
202
+ self.action_open()
203
+
204
+ def action_open(self) -> None:
205
+ if job := self._current():
206
+ self.app.push_screen(JobScreen(job))
207
+
208
+ def action_mark(self) -> None:
209
+ if job := self._current():
210
+ self.marked ^= {job.name}
211
+ table = self.query_one('#jobs', DataTable)
212
+ table.update_cell(
213
+ job.name,
214
+ table.ordered_columns[0].key,
215
+ _MARK if job.name in self.marked else '',
216
+ )
217
+ table.move_cursor(row=table.cursor_row + 1)
218
+
219
+ def action_run(self) -> None:
220
+ if names := self._selected():
221
+ self.app.run_jobs(names)
222
+ else:
223
+ self.notify('No jobs to run. Press a to add one.', severity='warning')
224
+
225
+ def action_remove(self) -> None:
226
+ names = self._selected()
227
+ if not names:
228
+ return
229
+
230
+ def remove() -> None:
231
+ try:
232
+ delete_jobs(self.app.jobs_file, names)
233
+ except (JobError, tomllib.TOMLDecodeError, OSError) as e:
234
+ self.notify(str(e), severity='error')
235
+ else:
236
+ self.marked -= set(names)
237
+ self.notify(f'Removed {", ".join(names)}')
238
+ self.reload()
239
+
240
+ self.app.push_screen(
241
+ ConfirmScreen(f'Remove {", ".join(names)} from the jobs file?', remove)
242
+ )
243
+
244
+ def action_add(self) -> None:
245
+ self.app.start_add()
246
+
247
+ def action_setup(self) -> None:
248
+ self.app.push_screen(SetupForm())
249
+
250
+ def action_login(self) -> None:
251
+ self.app.start_login()
252
+
253
+ def action_reload(self) -> None:
254
+ self.reload()
255
+
256
+
257
+ @dataclass
258
+ class _CachedView:
259
+ key: tuple[str, str]
260
+ info: ViewInfo
261
+
262
+
263
+ class JobScreen(Screen[None]):
264
+ app: 'TabpullApp'
265
+
266
+ BINDINGS: ClassVar[list[BindingType]] = [
267
+ Binding('escape', 'back', 'back'),
268
+ Binding('enter,e', 'edit', 'edit', priority=True),
269
+ Binding('n', 'new', 'new'),
270
+ Binding('c', 'change', 'change field'),
271
+ Binding('d', 'delete', 'delete'),
272
+ Binding('s', 'sheets', 'sheets'),
273
+ Binding('N', 'rename', 'rename'),
274
+ Binding('v', 'view', 'view'),
275
+ Binding('S', 'site', 'site'),
276
+ Binding('r', 'run', 'run'),
277
+ ]
278
+
279
+ def __init__(self, job: Job) -> None:
280
+ super().__init__()
281
+ self.job = job
282
+ self._cached_view: _CachedView | None = None
283
+
284
+ @override
285
+ def compose(self) -> ComposeResult:
286
+ yield Static('', id='summary', classes='info')
287
+ yield Label(
288
+ 'Filters (n new, c change field, e/enter edit, d delete)',
289
+ classes='section',
290
+ )
291
+ yield DataTable(id='filters', cursor_type='row', zebra_stripes=True)
292
+ yield Label(
293
+ 'Parameters (n new, c change field, e/enter edit, d delete)',
294
+ classes='section',
295
+ )
296
+ yield DataTable(id='params', cursor_type='row', zebra_stripes=True)
297
+ yield Static('', id='status')
298
+ yield Footer()
299
+
300
+ def on_mount(self) -> None:
301
+ self.query_one('#filters', DataTable).add_columns(
302
+ 'field', 'sheet', 'kind', 'value'
303
+ )
304
+ self.query_one('#params', DataTable).add_columns('parameter', 'value')
305
+ self.render_job()
306
+ self.query_one('#filters', DataTable).focus()
307
+
308
+ def render_job(self) -> None:
309
+ job = self.job
310
+ sheets = '\n'.join(f' {escape(sheet)}' for sheet in job.sheets)
311
+ self.query_one('#summary', Static).update(
312
+ f'[bold]{escape(job.name)}[/] (N rename)\n'
313
+ f'site {escape(job.site)} (S to change)\n'
314
+ f'view {escape(job.view)} (v to change)\n'
315
+ f'sheets (s to edit)\n{sheets}'
316
+ )
317
+ _refill(
318
+ self.query_one('#filters', DataTable),
319
+ [
320
+ (item.field, item.sheet or f'({job.sheets[0]})', item.kind, item.shown)
321
+ for item in job.filters
322
+ ],
323
+ )
324
+ _refill(self.query_one('#params', DataTable), list(job.params.items()))
325
+ self.query_one('#status', Static).update(
326
+ f'[bold]tabpull[/] > {escape(self.job.name)} '
327
+ f'changes save to {escape(ui.home_path(self.app.jobs_file))} right away'
328
+ )
329
+
330
+ def commit(self, job: Job) -> None:
331
+ try:
332
+ update_job(self.app.jobs_file, self.job.name, job)
333
+ except (JobError, tomllib.TOMLDecodeError, OSError) as e:
334
+ raise _save_error(e) from e
335
+ self.job = job
336
+ self.render_job()
337
+ self.notify(f'Saved {job.name}')
338
+
339
+ def _focused_table(self) -> DataTable[Any] | None:
340
+ focused = self.focused
341
+ return focused if isinstance(focused, DataTable) else None
342
+
343
+ def on_data_table_row_selected(self, _event: DataTable.RowSelected) -> None:
344
+ self.action_edit()
345
+
346
+ def _filter_form(
347
+ self,
348
+ index: int | None,
349
+ preset: ValuesFilter | RangeFilter | None = None,
350
+ ) -> None:
351
+ current = None if index is None else self.job.filters[index]
352
+ item = current if preset is None else preset
353
+
354
+ def save(new: ValuesFilter | RangeFilter) -> None:
355
+ if index is not None:
356
+ self._reject_duplicate_filter(index, new)
357
+ filters = list(self.job.filters)
358
+ if index is None:
359
+ filters.append(new)
360
+ else:
361
+ filters[index] = new
362
+ self.commit(replace(self.job, filters=filters))
363
+
364
+ if preset is not None:
365
+ title = f'Filter {preset.field}'
366
+ elif current is None:
367
+ title = 'New filter'
368
+ else:
369
+ title = f'Edit filter {current.field}'
370
+ self.app.push_screen(FilterForm(title, item, self.job.sheets[0], save))
371
+
372
+ def _reject_duplicate_filter(
373
+ self, index: int, new: ValuesFilter | RangeFilter
374
+ ) -> None:
375
+ default = self.job.sheets[0]
376
+ original = self.job.filters[index]
377
+ new_sheet = new.sheet or default
378
+ same_row = new.field == original.field and new_sheet == (
379
+ original.sheet or default
380
+ )
381
+ if same_row:
382
+ return
383
+ for other_index, other in enumerate(self.job.filters):
384
+ if other_index == index:
385
+ continue
386
+ if other.field == new.field and (other.sheet or default) == new_sheet:
387
+ msg = (
388
+ f'filter {new.field} on {new_sheet} is already on this job; '
389
+ 'edit that row'
390
+ )
391
+ raise JobError(msg)
392
+
393
+ def _param_form(
394
+ self, index: int | None, preset: tuple[str, str] | None = None
395
+ ) -> None:
396
+ names = list(self.job.params)
397
+ old = None if index is None else names[index]
398
+
399
+ def save(pair: tuple[str, str]) -> None:
400
+ name, value = pair
401
+ if name != old and name in self.job.params:
402
+ msg = f'parameter {name!r} is already set on this job'
403
+ raise JobError(msg)
404
+ items = [
405
+ (name, value) if k == old else (k, v)
406
+ for k, v in self.job.params.items()
407
+ ]
408
+ if old is None:
409
+ items.append((name, value))
410
+ self.commit(replace(self.job, params=dict(items)))
411
+
412
+ if preset is None:
413
+ value = '' if old is None else self.job.params[old]
414
+ name = old or ''
415
+ title = 'New parameter' if old is None else f'Edit parameter {old}'
416
+ else:
417
+ name, value = preset
418
+ title = name
419
+ self.app.push_screen(ParamForm(title, name, value, save))
420
+
421
+ def action_edit(self) -> None:
422
+ table = self._focused_table()
423
+ if table is None or (row := _row(table)) is None:
424
+ return
425
+ if table.id == 'filters':
426
+ self._filter_form(row)
427
+ else:
428
+ self._param_form(row)
429
+
430
+ def action_new(self) -> None:
431
+ table = self._focused_table()
432
+ if table is not None and table.id == 'params':
433
+ self._request_catalog('param-new', None)
434
+ else:
435
+ self._request_catalog('filter-new', None)
436
+
437
+ def action_change(self) -> None:
438
+ table = self._focused_table()
439
+ if table is None or (row := _row(table)) is None:
440
+ return
441
+ kind = 'param-change' if table.id == 'params' else 'filter-change'
442
+ self._request_catalog(kind, row)
443
+
444
+ def action_delete(self) -> None:
445
+ table = self._focused_table()
446
+ if table is None or (row := _row(table)) is None:
447
+ return
448
+ if table.id == 'filters':
449
+ label = format_filter(self.job.filters[row])
450
+ new = replace(
451
+ self.job,
452
+ filters=[f for i, f in enumerate(self.job.filters) if i != row],
453
+ )
454
+ else:
455
+ key = list(self.job.params)[row]
456
+ label = f'parameter {key}'
457
+ new = replace(
458
+ self.job, params={k: v for k, v in self.job.params.items() if k != key}
459
+ )
460
+
461
+ def delete() -> None:
462
+ try:
463
+ self.commit(new)
464
+ except JobError as e:
465
+ self.notify(str(e), severity='error')
466
+
467
+ self.app.push_screen(ConfirmScreen(f'Delete {label}?', delete))
468
+
469
+ def action_sheets(self) -> None:
470
+ self._request_catalog('sheets', None)
471
+
472
+ def _save_sheets(self, sheets: list[str]) -> None:
473
+ self.commit(replace(self.job, sheets=sheets))
474
+
475
+ def _sheets_form(self) -> None:
476
+ self.app.push_screen(
477
+ SheetsForm(
478
+ f'Sheets for {self.job.name}',
479
+ list(self.job.sheets),
480
+ self._save_sheets,
481
+ )
482
+ )
483
+
484
+ def _request_catalog(self, kind: str, row: int | None) -> None:
485
+ key = (self.job.site, self.job.view)
486
+ cached = self._cached_view
487
+ if cached is not None and cached.key == key:
488
+ self._open_catalog(kind, row, cached.info)
489
+ return
490
+ self._read_catalog(kind, row, key)
491
+
492
+ def _read_catalog(self, kind: str, row: int | None, key: tuple[str, str]) -> None:
493
+ view = key[1]
494
+ try:
495
+ settings = load_site(key[0])
496
+ except (UnknownSiteError, MissingSettingsError, ValueError) as exc:
497
+ self.notify(str(exc), severity='error')
498
+ self._typed_fallback(kind, row)
499
+ return
500
+
501
+ def work() -> ViewInfo:
502
+ with sync_playwright() as playwright:
503
+ return read_view(browser_session(playwright, settings), settings, view)
504
+
505
+ def opened(info: ViewInfo) -> None:
506
+ self._cached_view = _CachedView(key, info)
507
+ self._open_catalog(kind, row, info)
508
+
509
+ def failed(message: str) -> None:
510
+ self.notify(message, severity='error', timeout=10)
511
+ self._typed_fallback(kind, row)
512
+
513
+ started = self.app.start_task(
514
+ TaskScreen(
515
+ f'Opening {view}…',
516
+ work,
517
+ opened,
518
+ failed,
519
+ on_cancel=lambda: self.notify('Cancelled.', severity='warning'),
520
+ )
521
+ )
522
+ if not started:
523
+ self._typed_fallback(kind, row)
524
+
525
+ def _typed_fallback(self, kind: str, row: int | None) -> None:
526
+ if kind == 'sheets':
527
+ self._sheets_form()
528
+ elif kind == 'filter-new':
529
+ self._filter_form(None)
530
+ elif kind == 'filter-change' and row is not None:
531
+ self._filter_form(row)
532
+ elif kind == 'param-new':
533
+ self._param_form(None)
534
+ elif kind == 'param-change' and row is not None:
535
+ self._param_form(row)
536
+
537
+ def _open_catalog(self, kind: str, row: int | None, info: ViewInfo) -> None:
538
+ if kind == 'sheets':
539
+ self._open_sheet_catalog(info)
540
+ elif kind.startswith('filter'):
541
+ self._open_filter_catalog(kind, row, info)
542
+ else:
543
+ self._open_param_catalog(kind, row, info)
544
+
545
+ def _is_filter(
546
+ self, raw: ListedFilter, existing: ValuesFilter | RangeFilter
547
+ ) -> bool:
548
+ probe = ValuesFilter(raw['field'], [], raw['sheet'])
549
+ return same_filter(probe, existing, self.job.sheets[0])
550
+
551
+ def _matching_filter(self, raw: ListedFilter) -> int | None:
552
+ return next(
553
+ (i for i, f in enumerate(self.job.filters) if self._is_filter(raw, f)),
554
+ None,
555
+ )
556
+
557
+ def _filter_options(
558
+ self, listed: list[ListedFilter], *, skip: int | None
559
+ ) -> list[tuple[str, str]]:
560
+ options: list[tuple[str, str]] = []
561
+ for index, raw in enumerate(listed):
562
+ if skip is not None and self._is_filter(raw, self.job.filters[skip]):
563
+ continue
564
+ match = self._matching_filter(raw)
565
+ label = f'{raw["field"]} on {raw["sheet"]}'
566
+ if match is not None:
567
+ saved = self.job.filters[match]
568
+ label = f'{_MARK} {label} = {saved.shown}'
569
+ options.append((label, str(index)))
570
+ return options
571
+
572
+ def _open_filter_catalog(self, kind: str, row: int | None, info: ViewInfo) -> None:
573
+ listed = listed_filters(info['sheets'], self.job.sheets)
574
+ if kind == 'filter-new':
575
+ self.app.push_screen(
576
+ PickScreen(
577
+ 'Add a filter',
578
+ self._filter_options(listed, skip=None),
579
+ lambda value: self._choose_new_filter(listed, value),
580
+ lambda: None,
581
+ )
582
+ )
583
+ return
584
+ if row is None:
585
+ return
586
+ self.app.push_screen(
587
+ ChoiceScreen(
588
+ f'Change field of {self.job.filters[row].field}',
589
+ self._filter_options(listed, skip=row),
590
+ lambda value: self._choose_changed_filter(listed, row, value),
591
+ )
592
+ )
593
+
594
+ def _choose_new_filter(self, listed: list[ListedFilter], value: str) -> None:
595
+ raw = listed[int(value)]
596
+ match = self._matching_filter(raw)
597
+ if match is None:
598
+ self._filter_form(None, filter_defaults(raw))
599
+ return
600
+ self._filter_form(match)
601
+
602
+ def _choose_changed_filter(
603
+ self, listed: list[ListedFilter], row: int, value: str
604
+ ) -> None:
605
+ self._filter_form(row, filter_defaults(listed[int(value)]))
606
+
607
+ def _param_label(self, name: str) -> str:
608
+ if name in self.job.params:
609
+ return f'{_MARK} {name} = {self.job.params[name]}'
610
+ return name
611
+
612
+ def _param_options(
613
+ self, params: list[dict[str, str]], *, skip: str | None
614
+ ) -> list[tuple[str, str]]:
615
+ return [
616
+ (self._param_label(raw['name']), str(index))
617
+ for index, raw in enumerate(params)
618
+ if raw['name'] != skip
619
+ ]
620
+
621
+ def _open_param_catalog(self, kind: str, row: int | None, info: ViewInfo) -> None:
622
+ params = info['params']
623
+ if kind == 'param-new':
624
+ self.app.push_screen(
625
+ PickScreen(
626
+ 'Set a parameter',
627
+ self._param_options(params, skip=None),
628
+ lambda value: self._choose_new_param(params, value),
629
+ lambda: None,
630
+ )
631
+ )
632
+ return
633
+ if row is None:
634
+ return
635
+ current = list(self.job.params)[row]
636
+ self.app.push_screen(
637
+ ChoiceScreen(
638
+ f'Change field of {current}',
639
+ self._param_options(params, skip=current),
640
+ lambda value: self._choose_changed_param(params, row, value),
641
+ )
642
+ )
643
+
644
+ def _choose_new_param(self, params: list[dict[str, str]], value: str) -> None:
645
+ raw = params[int(value)]
646
+ names = list(self.job.params)
647
+ if raw['name'] in self.job.params:
648
+ self._param_form(names.index(raw['name']))
649
+ return
650
+ self._param_form(None, (raw['name'], raw['current']))
651
+
652
+ def _choose_changed_param(
653
+ self, params: list[dict[str, str]], row: int, value: str
654
+ ) -> None:
655
+ raw = params[int(value)]
656
+ self._param_form(row, (raw['name'], raw['current']))
657
+
658
+ def _open_sheet_catalog(self, info: ViewInfo) -> None:
659
+ live = [sheet['name'] for sheet in info['sheets']]
660
+ present = set(live)
661
+ saved = list(self.job.sheets)
662
+ options = [*saved, *[name for name in live if name not in set(saved)]]
663
+ suffix = {name: _MISSING_SHEET for name in saved if name not in present}
664
+ self.app.push_screen(
665
+ ChecksForm(
666
+ f'Sheets for {self.job.name}',
667
+ options,
668
+ self._save_sheets,
669
+ ticked=set(saved),
670
+ label_suffix=suffix,
671
+ )
672
+ )
673
+
674
+ def action_rename(self) -> None:
675
+ self.app.push_screen(
676
+ TextForm(
677
+ 'Rename job',
678
+ 'Job name (exports go to <out>/<name>/)',
679
+ self.job.name,
680
+ lambda name: self.commit(replace(self.job, name=name)),
681
+ )
682
+ )
683
+
684
+ def action_view(self) -> None:
685
+ self.app.push_screen(
686
+ TextForm(
687
+ 'Change view',
688
+ 'Workbook/View, or a view URL (sheets and filters stay as they are)',
689
+ self.job.view,
690
+ lambda text: self.commit(replace(self.job, view=view_from_flag(text))),
691
+ )
692
+ )
693
+
694
+ def action_site(self) -> None:
695
+ def save(name: str) -> None:
696
+ if name not in (sites := list_sites()):
697
+ msg = f'No site named {name!r}. Configured sites: {", ".join(sites) or "(none)"}'
698
+ raise JobError(msg)
699
+ self.commit(replace(self.job, site=name))
700
+
701
+ self.app.push_screen(
702
+ TextForm(
703
+ 'Change site', 'Local site name from tabpull setup', self.job.site, save
704
+ )
705
+ )
706
+
707
+ def action_run(self) -> None:
708
+ self.app.run_jobs([self.job.name])
709
+
710
+ def action_back(self) -> None:
711
+ self.app.pop_screen()
712
+
713
+
714
+ class TabpullApp(App[None]):
715
+ TITLE = 'tabpull'
716
+ CSS = CSS
717
+ BINDINGS: ClassVar[list[BindingType]] = [
718
+ Binding('q', 'quit', 'quit'),
719
+ Binding('question_mark', 'help', 'help'),
720
+ ]
721
+
722
+ def __init__(self, jobs_file: Path, out_dir: Path) -> None:
723
+ super().__init__()
724
+ self.jobs_file = jobs_file
725
+ self.out_dir = out_dir
726
+ self.last_run = ''
727
+ self._tasks: set[int] = set()
728
+ self._quit_armed = False
729
+
730
+ def on_mount(self) -> None:
731
+ self.push_screen(HomeScreen())
732
+
733
+ async def action_quit(self) -> None:
734
+ if self._tasks and not self._quit_armed:
735
+ self._quit_armed = True
736
+ if isinstance(self.screen, TaskScreen):
737
+ self.screen.action_cancel()
738
+ else:
739
+ for worker in self.workers:
740
+ if worker.group == 'tabpull-task':
741
+ worker.cancel()
742
+ self.notify('Stopping… (q again to force)', severity='warning')
743
+ return
744
+ self.exit()
745
+
746
+ def hold_task(self, key: int) -> None:
747
+ self._tasks.add(key)
748
+
749
+ def release_task(self, key: int) -> None:
750
+ self._tasks.discard(key)
751
+
752
+ def start_task[T](self, screen: TaskScreen[T]) -> bool:
753
+ if self._tasks:
754
+ self.notify('Still stopping the previous task…', severity='warning')
755
+ return False
756
+ self.push_screen(screen)
757
+ return True
758
+
759
+ def action_help(self) -> None:
760
+ if self.screen.query('HelpPanel'):
761
+ self.action_hide_help_panel()
762
+ else:
763
+ self.action_show_help_panel()
764
+
765
+ def run_jobs(self, names: list[str]) -> None:
766
+ def work() -> str:
767
+ if ui.stopped():
768
+ msg = 'Cancelled.'
769
+ raise SystemExit(msg)
770
+ jobs = load_jobs(self.jobs_file)
771
+ check_known(names, jobs)
772
+ wanted = set(names)
773
+ selected = [job for job in jobs if job.name in wanted]
774
+ ui.emit(f'Exporting {len(selected)} job(s) to {self.out_dir}/')
775
+ failed = export_jobs(selected, self.out_dir, _TuiReport())
776
+ if ui.stopped():
777
+ msg = 'Cancelled.'
778
+ raise SystemExit(msg)
779
+ if failed:
780
+ msg = f'{len(failed)} failed: {", ".join(failed)}'
781
+ raise SystemExit(msg)
782
+ return f'Exported {", ".join(job.name for job in selected)}'
783
+
784
+ self.start_task(
785
+ TaskScreen(
786
+ 'Export',
787
+ work,
788
+ lambda summary: self._finish_run(names, summary, failed=False),
789
+ lambda message: self._finish_run(names, message, failed=True),
790
+ on_cancel=lambda: self._finish_run(
791
+ names, 'Cancelled.', failed=False, cancelled=True
792
+ ),
793
+ hold_failure=True,
794
+ )
795
+ )
796
+
797
+ def _finish_run(
798
+ self, names: list[str], message: str, *, failed: bool, cancelled: bool = False
799
+ ) -> None:
800
+ label = ', '.join(names)
801
+ if cancelled:
802
+ self.last_run = f'last run: {label} cancelled'
803
+ self.notify('Cancelled.', severity='warning')
804
+ elif failed:
805
+ self.last_run = f'last run: {label} FAILED ({message})'
806
+ self.notify(message, severity='error', timeout=10)
807
+ else:
808
+ self.last_run = f'last run: {label} ok'
809
+ self.notify(message)
810
+ if isinstance(self.screen, HomeScreen):
811
+ self.screen.reload()
812
+
813
+ def start_login(self) -> None:
814
+ names = list_sites()
815
+ if not names:
816
+ self.notify(
817
+ 'No Tableau site configured. Press s to set up a site.',
818
+ severity='warning',
819
+ )
820
+ return
821
+ if len(names) == 1:
822
+ self._login(names[0])
823
+ return
824
+ self.push_screen(
825
+ ChoiceScreen('Site', [(name, name) for name in names], self._login)
826
+ )
827
+
828
+ def _login(self, name: str) -> None:
829
+ try:
830
+ settings = load_site(name)
831
+ except (UnknownSiteError, MissingSettingsError, ValueError) as e:
832
+ self.notify(str(e), severity='error')
833
+ return
834
+
835
+ self.start_task(
836
+ TaskScreen(
837
+ f'Signing in to {name}…',
838
+ lambda: sign_in(settings),
839
+ lambda _name: self._login_done(name),
840
+ lambda message: self.notify(message, severity='error', timeout=10),
841
+ )
842
+ )
843
+
844
+ def _login_done(self, name: str) -> None:
845
+ self.notify(f'Signed in to {name}')
846
+ if isinstance(self.screen, HomeScreen):
847
+ self.screen.reload()
848
+
849
+ def start_add(self) -> None:
850
+ names = list_sites()
851
+ if not names:
852
+ self.notify('No Tableau site configured. Starting setup.')
853
+ self.push_screen(SetupForm(self._add_site))
854
+ return
855
+ if len(names) == 1:
856
+ self._add_site(names[0])
857
+ return
858
+ self.push_screen(
859
+ ChoiceScreen('Site', [(name, name) for name in names], self._add_site)
860
+ )
861
+
862
+ def _add_site(self, name: str) -> None:
863
+ try:
864
+ settings = load_site(name)
865
+ except (UnknownSiteError, MissingSettingsError, ValueError) as e:
866
+ self.notify(str(e), severity='error')
867
+ return
868
+ self.push_screen(
869
+ TextForm(
870
+ 'Add job',
871
+ 'View URL or part of a workbook/view name',
872
+ '',
873
+ lambda query: self._queue_search(settings, query),
874
+ )
875
+ )
876
+
877
+ def _queue_search(self, settings: Settings, query: str) -> None:
878
+ self.call_later(self._search, settings, query)
879
+
880
+ def _search(self, settings: Settings, query: str) -> None:
881
+ def failed(message: str) -> None:
882
+ self.notify(message, severity='error', timeout=10)
883
+
884
+ self.start_task(
885
+ TaskScreen(
886
+ 'Searching views…',
887
+ lambda: search_views(settings, query),
888
+ lambda matches: self._views(settings, query, matches),
889
+ failed,
890
+ )
891
+ )
892
+
893
+ def _views(
894
+ self, settings: Settings, query: str, matches: list[tsc.ViewItem]
895
+ ) -> None:
896
+ if not matches:
897
+ self.notify(
898
+ f'No views you can access match {query!r}.', severity='error', timeout=8
899
+ )
900
+ return
901
+ target = parse_tableau_url(query).view if query.startswith('http') else None
902
+ if target and len(matches) == 1:
903
+ self._name_job(settings, matches[0])
904
+ return
905
+ shown = matches[:MAX_SEARCH_RESULTS]
906
+ if len(matches) > MAX_SEARCH_RESULTS:
907
+ self.notify(
908
+ f'Showing the best {MAX_SEARCH_RESULTS} of {len(matches)} matches; '
909
+ 'type more of the name to narrow it.'
910
+ )
911
+ disabled: set[str] = set()
912
+ options: list[tuple[str, str]] = []
913
+ for index, item in enumerate(shown):
914
+ kind = item.sheet_type or 'view'
915
+ options.append((f'{item.name} {item.content_url} {kind}', str(index)))
916
+ if kind.lower() == 'story':
917
+ disabled.add(str(index))
918
+ self.push_screen(
919
+ ChoiceScreen(
920
+ 'View',
921
+ options,
922
+ lambda index: self._name_job(settings, shown[int(index)]),
923
+ disabled=disabled,
924
+ )
925
+ )
926
+
927
+ def _name_job(self, settings: Settings, item: tsc.ViewItem) -> None:
928
+ view = view_path(item)
929
+ try:
930
+ existing = {job.name for job in load_jobs(self.jobs_file)}
931
+ except (JobError, tomllib.TOMLDecodeError, OSError) as e:
932
+ self.notify(str(e), severity='error')
933
+ return
934
+ default = slug(item.name or view)
935
+
936
+ def save(name: str) -> None:
937
+ if name in existing:
938
+ msg = f'A job named {name!r} already exists in {self.jobs_file}.'
939
+ raise JobError(msg)
940
+ self.call_later(self._inspect, settings, name, view)
941
+
942
+ self.push_screen(
943
+ TextForm(
944
+ 'Job name',
945
+ 'Job name (exports go to <out>/<name>/)',
946
+ default,
947
+ save,
948
+ )
949
+ )
950
+
951
+ def _inspect(self, settings: Settings, name: str, view: str) -> None:
952
+ def work() -> ViewInfo:
953
+ with sync_playwright() as playwright:
954
+ return read_view(browser_session(playwright, settings), settings, view)
955
+
956
+ def opened(info: ViewInfo) -> None:
957
+ draft = _Add(settings, view, name, info)
958
+ names = [sheet['name'] for sheet in info['sheets']]
959
+
960
+ def chosen(sheets: list[str]) -> None:
961
+ draft.sheets = sheets
962
+ draft.listed = listed_filters(info['sheets'], sheets)
963
+ self.call_later(self._pick_filters, draft)
964
+
965
+ self.push_screen(ChecksForm('Sheets to crosstab', names, chosen))
966
+
967
+ self.start_task(
968
+ TaskScreen(
969
+ f'Opening {view}…',
970
+ work,
971
+ opened,
972
+ lambda message: self.notify(message, severity='error', timeout=10),
973
+ )
974
+ )
975
+
976
+ def _pick_filters(self, draft: '_Add') -> None:
977
+ if not draft.listed:
978
+ self._params(draft)
979
+ return
980
+ options = [
981
+ (f'{item["field"]} on {item["sheet"]}', str(index))
982
+ for index, item in enumerate(draft.listed)
983
+ ]
984
+ self.push_screen(
985
+ PickScreen(
986
+ 'Add a filter',
987
+ options,
988
+ lambda index: self._add_filter(draft, draft.listed[int(index)]),
989
+ lambda: self._params(draft),
990
+ )
991
+ )
992
+
993
+ def _add_filter(self, draft: '_Add', raw: ListedFilter) -> None:
994
+ self.push_screen(
995
+ FilterForm(
996
+ f'Filter {raw["field"]}',
997
+ filter_defaults(raw),
998
+ draft.sheets[0],
999
+ draft.filters.append,
1000
+ )
1001
+ )
1002
+
1003
+ def _params(self, draft: '_Add') -> None:
1004
+ if not draft.raw_params:
1005
+ self._save_add(draft)
1006
+ return
1007
+ options = [
1008
+ (item['name'], str(index)) for index, item in enumerate(draft.raw_params)
1009
+ ]
1010
+
1011
+ def pick(index: str) -> None:
1012
+ raw = draft.raw_params[int(index)]
1013
+
1014
+ def save(pair: tuple[str, str]) -> None:
1015
+ draft.params[pair[0]] = pair[1]
1016
+
1017
+ self.push_screen(ParamForm(raw['name'], raw['name'], raw['current'], save))
1018
+
1019
+ self.push_screen(
1020
+ PickScreen('Set a parameter', options, pick, lambda: self._save_add(draft))
1021
+ )
1022
+
1023
+ def _save_add(self, draft: '_Add') -> None:
1024
+ job = Job(
1025
+ draft.name,
1026
+ draft.view,
1027
+ draft.sheets,
1028
+ draft.settings.name,
1029
+ list(draft.filters),
1030
+ dict(draft.params),
1031
+ )
1032
+ try:
1033
+ append_job(
1034
+ self.jobs_file,
1035
+ replace(job, filters=resolved_filters(job)),
1036
+ quiet=True,
1037
+ )
1038
+ except (JobError, OSError) as e:
1039
+ self.notify(str(e), severity='error')
1040
+ return
1041
+ self.notify(f'Added {draft.name}')
1042
+
1043
+
1044
+ @dataclass
1045
+ class _Add:
1046
+ settings: Settings
1047
+ view: str
1048
+ name: str
1049
+ info: ViewInfo
1050
+ sheets: list[str] = field(default_factory=list)
1051
+ filters: list[ValuesFilter | RangeFilter] = field(default_factory=list)
1052
+ params: dict[str, str] = field(default_factory=dict)
1053
+ listed: list[ListedFilter] = field(default_factory=list)
1054
+
1055
+ @property
1056
+ def raw_params(self) -> list[dict[str, str]]:
1057
+ return self.info['params']
1058
+
1059
+
1060
+ class _TuiReport:
1061
+ def live(self) -> AbstractContextManager[object]:
1062
+ return nullcontext()
1063
+
1064
+ def sheet(self, job: Job, done: int, sheet: str) -> None:
1065
+ ui.emit(f'{job.name}: exporting {sheet} ({done + 1}/{len(job.sheets)})')
1066
+
1067
+ def ok(self, job: Job, paths: Sequence[Path]) -> None:
1068
+ ui.emit(f'✓ {job.name}: {", ".join(map(str, paths))}')
1069
+
1070
+ def fail(self, job: Job, message: str) -> None:
1071
+ ui.emit(f'✗ {job.name}: {message.partition("\n")[0]}')
1072
+
1073
+ def summary(self, ok: int, total: int, rerun: str | None) -> None: # noqa: ARG002
1074
+ return None
1075
+
1076
+
1077
+ def run_tui(jobs_file: Path, out_dir: Path) -> None:
1078
+ TabpullApp(jobs_file, out_dir).run()