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