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/__init__.py
ADDED
|
File without changes
|
tabpull/__main__.py
ADDED
tabpull/add.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import re
|
|
3
|
+
from collections.abc import Callable, Sequence
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from difflib import SequenceMatcher
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TypedDict
|
|
8
|
+
|
|
9
|
+
import questionary
|
|
10
|
+
import tableauserverclient as tsc
|
|
11
|
+
from playwright.sync_api import BrowserContext, sync_playwright
|
|
12
|
+
from rich import box
|
|
13
|
+
from rich.markup import escape
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
from tabpull import ui
|
|
18
|
+
from tabpull.embed import INSPECT_JS, SheetInfo, ViewInfo, open_view
|
|
19
|
+
from tabpull.filters import (
|
|
20
|
+
format_filter,
|
|
21
|
+
normalize_range_bound,
|
|
22
|
+
parse_filter_spec,
|
|
23
|
+
parse_param_spec,
|
|
24
|
+
resolved_filters,
|
|
25
|
+
split_values,
|
|
26
|
+
)
|
|
27
|
+
from tabpull.jobs import (
|
|
28
|
+
Job,
|
|
29
|
+
JobError,
|
|
30
|
+
RangeFilter,
|
|
31
|
+
ValuesFilter,
|
|
32
|
+
job_to_toml,
|
|
33
|
+
load_jobs,
|
|
34
|
+
slug,
|
|
35
|
+
)
|
|
36
|
+
from tabpull.tableau import (
|
|
37
|
+
Settings,
|
|
38
|
+
browser_session,
|
|
39
|
+
parse_tableau_url,
|
|
40
|
+
rest_session,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
MAX_SEARCH_RESULTS = 30
|
|
44
|
+
MIN_MATCH_SCORE = 0.75
|
|
45
|
+
_WORD_RE = re.compile(r'[^\W_]+')
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def view_path(item: tsc.ViewItem) -> str:
|
|
49
|
+
return (item.content_url or '').replace('/sheets/', '/', 1)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def match_score(query: str, text: str) -> float:
|
|
53
|
+
words = _WORD_RE.findall(text.lower())
|
|
54
|
+
terms = _WORD_RE.findall(query.lower())
|
|
55
|
+
if not words or not terms:
|
|
56
|
+
return 0.0
|
|
57
|
+
return min(
|
|
58
|
+
max(1.0 if t in w else SequenceMatcher(None, t, w).ratio() for w in words)
|
|
59
|
+
for t in terms
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ListedFilter(TypedDict):
|
|
64
|
+
field: str
|
|
65
|
+
sheet: str
|
|
66
|
+
type: str
|
|
67
|
+
current: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _validate_range_answer(value: str) -> bool | str:
|
|
71
|
+
try:
|
|
72
|
+
normalize_range_bound(value.strip() or None)
|
|
73
|
+
except JobError as e:
|
|
74
|
+
return str(e)
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _prompt_text(
|
|
79
|
+
message: str,
|
|
80
|
+
*,
|
|
81
|
+
default: str = '',
|
|
82
|
+
validate: Callable[[str], bool | str] | None = None,
|
|
83
|
+
) -> str:
|
|
84
|
+
return ui.ask(
|
|
85
|
+
questionary.text(message, default=default, validate=validate, style=ui.STYLE)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _prompt_bound(message: str, default: str) -> str | None:
|
|
90
|
+
answer = _prompt_text(message, default=default, validate=_validate_range_answer)
|
|
91
|
+
bound = answer.strip() or None
|
|
92
|
+
normalize_range_bound(bound)
|
|
93
|
+
return bound
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def search_views(settings: Settings, query: str) -> list[tsc.ViewItem]:
|
|
97
|
+
query = query.strip()
|
|
98
|
+
with rest_session(settings) as server:
|
|
99
|
+
options = tsc.RequestOptions(pagesize=1000)
|
|
100
|
+
options.fields |= {'_default_', 'sheetType'}
|
|
101
|
+
views = list(tsc.Pager(server.views, options))
|
|
102
|
+
target = parse_tableau_url(query).view if query.startswith('http') else None
|
|
103
|
+
if target:
|
|
104
|
+
return [item for item in views if view_path(item) == target]
|
|
105
|
+
scored = [
|
|
106
|
+
(match_score(query, f'{item.name} {item.content_url}'), item) for item in views
|
|
107
|
+
]
|
|
108
|
+
scored.sort(key=lambda pair: -pair[0])
|
|
109
|
+
return [item for score, item in scored if score >= MIN_MATCH_SCORE]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _find_view(settings: Settings) -> tsc.ViewItem:
|
|
113
|
+
query = _prompt_text('View URL or part of a workbook/view name').strip()
|
|
114
|
+
with ui.console.status('Searching views…'):
|
|
115
|
+
matches = search_views(settings, query)
|
|
116
|
+
if not matches:
|
|
117
|
+
msg = f'No views you can access match {query!r}.'
|
|
118
|
+
raise SystemExit(msg)
|
|
119
|
+
target = parse_tableau_url(query).view if query.startswith('http') else None
|
|
120
|
+
if target and len(matches) == 1:
|
|
121
|
+
return matches[0]
|
|
122
|
+
if len(matches) > MAX_SEARCH_RESULTS:
|
|
123
|
+
print(
|
|
124
|
+
f' Showing the best {MAX_SEARCH_RESULTS} of {len(matches)} matches; type more of the name to narrow it.'
|
|
125
|
+
)
|
|
126
|
+
matches = matches[:MAX_SEARCH_RESULTS]
|
|
127
|
+
choices = []
|
|
128
|
+
for item in matches:
|
|
129
|
+
kind = item.sheet_type or 'view'
|
|
130
|
+
choices.append(
|
|
131
|
+
questionary.Choice(
|
|
132
|
+
f'{item.name} {item.content_url} {kind}',
|
|
133
|
+
value=item,
|
|
134
|
+
disabled='stories unsupported' if kind.lower() == 'story' else None,
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
return ui.ask(
|
|
138
|
+
questionary.select(
|
|
139
|
+
'View',
|
|
140
|
+
choices=choices,
|
|
141
|
+
use_search_filter=True,
|
|
142
|
+
use_jk_keys=False,
|
|
143
|
+
instruction='(type to filter)',
|
|
144
|
+
style=ui.STYLE,
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def listed_filters(
|
|
150
|
+
sheets: list[SheetInfo], chosen: Sequence[str]
|
|
151
|
+
) -> list[ListedFilter]:
|
|
152
|
+
picked_names = set(chosen)
|
|
153
|
+
listed: list[ListedFilter] = []
|
|
154
|
+
for sheet in sheets:
|
|
155
|
+
if sheet['name'] not in picked_names:
|
|
156
|
+
continue
|
|
157
|
+
listed.extend(
|
|
158
|
+
{
|
|
159
|
+
'field': item['field'],
|
|
160
|
+
'sheet': sheet['name'],
|
|
161
|
+
'type': item['type'],
|
|
162
|
+
'current': item['current'],
|
|
163
|
+
}
|
|
164
|
+
for item in sheet['filters']
|
|
165
|
+
)
|
|
166
|
+
return listed
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _print_fields(
|
|
170
|
+
sheets: list[SheetInfo], chosen: Sequence[str], params: list[dict[str, str]]
|
|
171
|
+
) -> list[ListedFilter]:
|
|
172
|
+
table = Table(
|
|
173
|
+
title='Filters & parameters on these sheets',
|
|
174
|
+
box=box.ROUNDED,
|
|
175
|
+
title_justify='left',
|
|
176
|
+
)
|
|
177
|
+
for column in ('kind', 'name', 'type', 'sheet', 'current'):
|
|
178
|
+
table.add_column(column)
|
|
179
|
+
listed = listed_filters(sheets, chosen)
|
|
180
|
+
for row in listed:
|
|
181
|
+
table.add_row('filter', row['field'], row['type'], row['sheet'], row['current'])
|
|
182
|
+
for param in params:
|
|
183
|
+
table.add_row('param', param['name'], '', '', param['current'])
|
|
184
|
+
ui.console.print(table)
|
|
185
|
+
return listed
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def filter_defaults(raw: ListedFilter) -> ValuesFilter | RangeFilter:
|
|
189
|
+
if raw['type'] == 'range':
|
|
190
|
+
low, sep, high = raw['current'].partition(' .. ')
|
|
191
|
+
if not sep:
|
|
192
|
+
low = high = ''
|
|
193
|
+
return RangeFilter(raw['field'], raw['sheet'], low, high)
|
|
194
|
+
current = '' if raw['current'] == '(All)' else raw['current']
|
|
195
|
+
return ValuesFilter(
|
|
196
|
+
raw['field'], split_values(current) if current else [], raw['sheet']
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _prompt_filters(listed: list[ListedFilter]) -> list[ValuesFilter | RangeFilter]:
|
|
201
|
+
filters: list[ValuesFilter | RangeFilter] = []
|
|
202
|
+
if not listed:
|
|
203
|
+
return filters
|
|
204
|
+
while True:
|
|
205
|
+
choices = [
|
|
206
|
+
questionary.Choice(f'{item["field"]} on {item["sheet"]!r}', value=item)
|
|
207
|
+
for item in listed
|
|
208
|
+
]
|
|
209
|
+
item = ui.ask(
|
|
210
|
+
questionary.select(
|
|
211
|
+
'Add a filter', choices=[*choices, 'Done'], style=ui.STYLE
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
if item == 'Done':
|
|
215
|
+
return filters
|
|
216
|
+
draft = filter_defaults(item)
|
|
217
|
+
field_name, sheet = draft.field, draft.sheet
|
|
218
|
+
if isinstance(draft, RangeFilter):
|
|
219
|
+
while True:
|
|
220
|
+
start = _prompt_bound(f'{field_name} from', draft.min or '')
|
|
221
|
+
end = _prompt_bound(f'{field_name} to', draft.max or '')
|
|
222
|
+
if start or end:
|
|
223
|
+
break
|
|
224
|
+
ui.console.print('Give a from, a to, or both.')
|
|
225
|
+
filters.append(RangeFilter(field_name, sheet, start, end))
|
|
226
|
+
else:
|
|
227
|
+
values = _prompt_text(f'{field_name} values (a|b)', default=draft.pick_list)
|
|
228
|
+
filters.append(ValuesFilter(field_name, split_values(values), sheet))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _prompt_params(params: list[dict[str, str]]) -> dict[str, str]:
|
|
232
|
+
chosen: dict[str, str] = {}
|
|
233
|
+
if not params:
|
|
234
|
+
return chosen
|
|
235
|
+
while True:
|
|
236
|
+
choices = [questionary.Choice(item['name'], value=item) for item in params]
|
|
237
|
+
param = ui.ask(
|
|
238
|
+
questionary.select(
|
|
239
|
+
'Set a parameter', choices=[*choices, 'Done'], style=ui.STYLE
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
if param == 'Done':
|
|
243
|
+
return chosen
|
|
244
|
+
chosen[param['name']] = _prompt_text(param['name'], default=param['current'])
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def read_view(context: BrowserContext, settings: Settings, view: str) -> ViewInfo:
|
|
248
|
+
page = open_view(context, settings, view)
|
|
249
|
+
try:
|
|
250
|
+
info: ViewInfo = page.evaluate(INSPECT_JS)
|
|
251
|
+
finally:
|
|
252
|
+
page.close()
|
|
253
|
+
return info
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _build_embed_job(
|
|
257
|
+
context: BrowserContext, settings: Settings, name: str, view: str
|
|
258
|
+
) -> Job:
|
|
259
|
+
with ui.console.status(f'Opening {view} in headless browser…'):
|
|
260
|
+
info = read_view(context, settings, view)
|
|
261
|
+
chosen: list[str] = ui.ask(
|
|
262
|
+
questionary.checkbox(
|
|
263
|
+
'Sheets to crosstab',
|
|
264
|
+
choices=[sheet['name'] for sheet in info['sheets']],
|
|
265
|
+
validate=lambda picked: bool(picked) or 'Pick at least one sheet',
|
|
266
|
+
style=ui.STYLE,
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
listed = _print_fields(info['sheets'], chosen, info['params'])
|
|
270
|
+
job = Job(
|
|
271
|
+
name,
|
|
272
|
+
view,
|
|
273
|
+
chosen,
|
|
274
|
+
settings.name,
|
|
275
|
+
_prompt_filters(listed),
|
|
276
|
+
_prompt_params(info['params']),
|
|
277
|
+
)
|
|
278
|
+
return replace(job, filters=resolved_filters(job))
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _using_site(settings: Settings) -> None:
|
|
282
|
+
detail = (
|
|
283
|
+
f'Using site {settings.name!r} ({settings.server}, '
|
|
284
|
+
f'site {settings.site or "(default)"}).'
|
|
285
|
+
)
|
|
286
|
+
ui.console.print(f'[dim]{escape(detail)}[/]', soft_wrap=True)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def append_job(path: Path, job: Job, *, quiet: bool = False) -> None:
|
|
290
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
291
|
+
text = job_to_toml(job)
|
|
292
|
+
prefix = '\n' if path.exists() and path.stat().st_size else ''
|
|
293
|
+
with path.open('a', encoding='utf-8') as handle:
|
|
294
|
+
handle.write(prefix + text)
|
|
295
|
+
if quiet:
|
|
296
|
+
return
|
|
297
|
+
if not ui.rich_output():
|
|
298
|
+
print(f'\nSaved job {job.name!r} to {path}:\n\n{text}')
|
|
299
|
+
print(f'Run it: tabpull run {job.name}')
|
|
300
|
+
return
|
|
301
|
+
filters = '; '.join(format_filter(item) for item in job.filters) or '-'
|
|
302
|
+
params = '; '.join(f'{key}={value}' for key, value in job.params.items()) or '-'
|
|
303
|
+
body = '\n'.join(
|
|
304
|
+
(
|
|
305
|
+
f'[bold]{escape(job.name)}[/] on {escape(job.site)}',
|
|
306
|
+
f'view {escape(job.view)}',
|
|
307
|
+
f'sheets {escape(", ".join(job.sheets))}',
|
|
308
|
+
f'filters {escape(filters)}',
|
|
309
|
+
f'params {escape(params)}',
|
|
310
|
+
escape(str(path)),
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
ui.console.print(
|
|
314
|
+
Panel(
|
|
315
|
+
body,
|
|
316
|
+
title='[green]✓ Saved[/]',
|
|
317
|
+
title_align='left',
|
|
318
|
+
border_style='green',
|
|
319
|
+
subtitle=f'run it: [bold]tabpull run {escape(job.name)}[/]',
|
|
320
|
+
subtitle_align='left',
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def add_job(settings: Settings, jobs_path: Path, name: str | None = None) -> None:
|
|
326
|
+
_using_site(settings)
|
|
327
|
+
item = _find_view(settings)
|
|
328
|
+
view = view_path(item)
|
|
329
|
+
existing = {job.name for job in load_jobs(jobs_path)}
|
|
330
|
+
default_name = slug(item.name or view)
|
|
331
|
+
if name is None:
|
|
332
|
+
|
|
333
|
+
def reject_existing(value: str) -> bool | str:
|
|
334
|
+
candidate = value.strip() or default_name
|
|
335
|
+
if candidate in existing:
|
|
336
|
+
return f'A job named {candidate!r} already exists in {jobs_path}.'
|
|
337
|
+
return True
|
|
338
|
+
|
|
339
|
+
name = (
|
|
340
|
+
_prompt_text(
|
|
341
|
+
'Job name', default=default_name, validate=reject_existing
|
|
342
|
+
).strip()
|
|
343
|
+
or default_name
|
|
344
|
+
)
|
|
345
|
+
if name in existing:
|
|
346
|
+
msg = f'A job named {name!r} already exists in {jobs_path}.'
|
|
347
|
+
raise SystemExit(msg)
|
|
348
|
+
|
|
349
|
+
with sync_playwright() as pw:
|
|
350
|
+
job = _build_embed_job(browser_session(pw, settings), settings, name, view)
|
|
351
|
+
append_job(jobs_path, job)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def view_from_flag(value: str) -> str:
|
|
355
|
+
text = value.strip()
|
|
356
|
+
if text.startswith(('http://', 'https://')):
|
|
357
|
+
parsed = parse_tableau_url(text)
|
|
358
|
+
if not parsed.view:
|
|
359
|
+
msg = f'{text!r} has no view. Use Workbook/View or a view URL.'
|
|
360
|
+
raise JobError(msg)
|
|
361
|
+
return parsed.view
|
|
362
|
+
workbook, sep, view = text.partition('/')
|
|
363
|
+
if not sep or not workbook or not view or '/' in view:
|
|
364
|
+
msg = f'{text!r} should look like Workbook/View.'
|
|
365
|
+
raise JobError(msg)
|
|
366
|
+
return f'{workbook}/{view}'
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def add_job_from_flags(
|
|
370
|
+
settings: Settings, args: argparse.Namespace, jobs_file: Path
|
|
371
|
+
) -> None:
|
|
372
|
+
view = view_from_flag(args.view)
|
|
373
|
+
filters = [parse_filter_spec(spec) for spec in args.filter_specs or []]
|
|
374
|
+
params = dict(parse_param_spec(spec) for spec in args.params or [])
|
|
375
|
+
name = args.name or slug(view)
|
|
376
|
+
if any(job.name == name for job in load_jobs(jobs_file)):
|
|
377
|
+
msg = f'A job named {name!r} already exists in {jobs_file}.'
|
|
378
|
+
raise SystemExit(msg)
|
|
379
|
+
_using_site(settings)
|
|
380
|
+
with sync_playwright() as pw:
|
|
381
|
+
page = open_view(browser_session(pw, settings), settings, view)
|
|
382
|
+
page.close()
|
|
383
|
+
job = Job(name, view, list(args.sheets), settings.name, filters, params)
|
|
384
|
+
append_job(jobs_file, replace(job, filters=resolved_filters(job)))
|