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 ADDED
File without changes
tabpull/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from tabpull.cli import main
2
+
3
+ main()
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)))