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/app.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""Export Tableau crosstab CSVs for the dashboard sheets you name.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
setup save a site's personal access token and SSO session
|
|
5
|
+
add record a job (prompts, or flags for the site, view, sheets, and filters)
|
|
6
|
+
remove delete saved jobs from the jobs file
|
|
7
|
+
run export jobs from the jobs file into the current folder
|
|
8
|
+
login refresh a site's SSO session
|
|
9
|
+
|
|
10
|
+
Run tabpull with no command at a terminal to open the interactive screen.
|
|
11
|
+
Piped, it prints configured sites and saved jobs.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import shlex
|
|
16
|
+
import sys
|
|
17
|
+
import tomllib
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import NoReturn
|
|
21
|
+
|
|
22
|
+
import questionary
|
|
23
|
+
from playwright.sync_api import Error as PlaywrightError
|
|
24
|
+
from playwright.sync_api import sync_playwright
|
|
25
|
+
from rich.rule import Rule
|
|
26
|
+
|
|
27
|
+
from tabpull import ui, wizard
|
|
28
|
+
from tabpull.add import add_job, add_job_from_flags
|
|
29
|
+
from tabpull.cli import VERSION_FLAGS, version
|
|
30
|
+
from tabpull.filters import (
|
|
31
|
+
filters_for_run,
|
|
32
|
+
params_for_run,
|
|
33
|
+
parse_filter_spec,
|
|
34
|
+
parse_param_spec,
|
|
35
|
+
)
|
|
36
|
+
from tabpull.home import file_flags, show_home
|
|
37
|
+
from tabpull.jobs import JobError, check_known, saved_jobs_or_exit
|
|
38
|
+
from tabpull.remove import remove_jobs
|
|
39
|
+
from tabpull.run import open_report, run_jobs
|
|
40
|
+
from tabpull.tableau import (
|
|
41
|
+
MissingSettingsError,
|
|
42
|
+
Settings,
|
|
43
|
+
UnknownSiteError,
|
|
44
|
+
check_site_name,
|
|
45
|
+
jobs_path,
|
|
46
|
+
list_sites,
|
|
47
|
+
load_site,
|
|
48
|
+
sso_login,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _require_flag_shape(args: argparse.Namespace) -> None:
|
|
53
|
+
if not args.view:
|
|
54
|
+
msg = (
|
|
55
|
+
'Pass --view Workbook/View, and --sheet at least once, '
|
|
56
|
+
'to add a job without prompts.'
|
|
57
|
+
)
|
|
58
|
+
raise SystemExit(msg)
|
|
59
|
+
if not args.sheets:
|
|
60
|
+
msg = 'Pass --sheet at least once to add a job without prompts.'
|
|
61
|
+
raise SystemExit(msg)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _configured_name(explicit: str | None) -> str:
|
|
65
|
+
if explicit:
|
|
66
|
+
try:
|
|
67
|
+
return check_site_name(explicit)
|
|
68
|
+
except ValueError as e:
|
|
69
|
+
raise SystemExit(str(e)) from e
|
|
70
|
+
names = list_sites()
|
|
71
|
+
if len(names) == 1:
|
|
72
|
+
return names[0]
|
|
73
|
+
if not names:
|
|
74
|
+
msg = 'No Tableau site configured. Run: tabpull setup'
|
|
75
|
+
else:
|
|
76
|
+
msg = f'Pass --site. Configured sites: {", ".join(names)}'
|
|
77
|
+
raise SystemExit(msg)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _pick_site(explicit: str | None) -> Settings:
|
|
81
|
+
if explicit or not ui.interactive():
|
|
82
|
+
return load_site(_configured_name(explicit))
|
|
83
|
+
names = list_sites()
|
|
84
|
+
if len(names) == 1:
|
|
85
|
+
return load_site(names[0])
|
|
86
|
+
if not names:
|
|
87
|
+
print('No Tableau site configured. Starting setup.')
|
|
88
|
+
return load_site(wizard.main(None))
|
|
89
|
+
choices = []
|
|
90
|
+
for name in names:
|
|
91
|
+
settings = load_site(name)
|
|
92
|
+
label = f'{name} ({settings.server}, site {settings.site or "(default)"})'
|
|
93
|
+
choices.append(questionary.Choice(label, value=name))
|
|
94
|
+
return load_site(
|
|
95
|
+
ui.ask(questionary.select('Site', choices=choices, style=ui.STYLE))
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _cmd_login(site: str | None) -> None:
|
|
100
|
+
try:
|
|
101
|
+
settings = _pick_site(site)
|
|
102
|
+
except (UnknownSiteError, MissingSettingsError, ValueError) as e:
|
|
103
|
+
raise SystemExit(str(e)) from e
|
|
104
|
+
print(
|
|
105
|
+
f'Signing in to {settings.server}, site {settings.site or "(default)"} '
|
|
106
|
+
f'(local name {settings.name}).'
|
|
107
|
+
)
|
|
108
|
+
with sync_playwright() as pw:
|
|
109
|
+
sso_login(pw, settings)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _cmd_add(args: argparse.Namespace, jobs_file: Path) -> None:
|
|
113
|
+
flag_mode = args.view or args.sheets or args.filter_specs or args.params
|
|
114
|
+
try:
|
|
115
|
+
if flag_mode or not ui.interactive():
|
|
116
|
+
_require_flag_shape(args)
|
|
117
|
+
add_job_from_flags(load_site(_configured_name(args.site)), args, jobs_file)
|
|
118
|
+
return
|
|
119
|
+
ui.console.print(Rule('[bold]tabpull add', align='left', style='blue'))
|
|
120
|
+
add_job(_pick_site(args.site), jobs_file, args.name)
|
|
121
|
+
except (
|
|
122
|
+
JobError,
|
|
123
|
+
UnknownSiteError,
|
|
124
|
+
MissingSettingsError,
|
|
125
|
+
PlaywrightError,
|
|
126
|
+
tomllib.TOMLDecodeError,
|
|
127
|
+
OSError,
|
|
128
|
+
ValueError,
|
|
129
|
+
) as e:
|
|
130
|
+
message = str(e).partition('\n')[0] or repr(e)
|
|
131
|
+
raise SystemExit(message) from e
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _cmd_run(args: argparse.Namespace, jobs_file: Path, out_dir: Path) -> int:
|
|
135
|
+
jobs = saved_jobs_or_exit(jobs_file)
|
|
136
|
+
selected = [job for job in jobs if not args.names or job.name in args.names]
|
|
137
|
+
try:
|
|
138
|
+
check_known(args.names, jobs)
|
|
139
|
+
overrides = [parse_filter_spec(spec) for spec in args.filter_specs or []]
|
|
140
|
+
param_overrides = [parse_param_spec(spec) for spec in args.params or []]
|
|
141
|
+
selected = [
|
|
142
|
+
params_for_run(filters_for_run(job, overrides), param_overrides)
|
|
143
|
+
for job in selected
|
|
144
|
+
]
|
|
145
|
+
except JobError as e:
|
|
146
|
+
raise SystemExit(str(e)) from e
|
|
147
|
+
report = open_report(selected, out_dir)
|
|
148
|
+
try:
|
|
149
|
+
failed = run_jobs(selected, out_dir, report, parallel=args.parallel)
|
|
150
|
+
except JobError as e:
|
|
151
|
+
raise SystemExit(str(e)) from e
|
|
152
|
+
filter_flags = [
|
|
153
|
+
flag for spec in args.filter_specs or [] for flag in ('--filter', spec)
|
|
154
|
+
]
|
|
155
|
+
param_flags = [flag for spec in args.params or [] for flag in ('--param', spec)]
|
|
156
|
+
parallel_flags = ['--parallel', str(args.parallel)] if args.parallel != 1 else []
|
|
157
|
+
rerun = shlex.join(
|
|
158
|
+
[
|
|
159
|
+
'tabpull',
|
|
160
|
+
'run',
|
|
161
|
+
*file_flags(args),
|
|
162
|
+
*parallel_flags,
|
|
163
|
+
*filter_flags,
|
|
164
|
+
*param_flags,
|
|
165
|
+
'--',
|
|
166
|
+
*failed,
|
|
167
|
+
]
|
|
168
|
+
)
|
|
169
|
+
report.summary(
|
|
170
|
+
len(selected) - len(failed), len(selected), rerun if failed else None
|
|
171
|
+
)
|
|
172
|
+
return 1 if failed else 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class _Parser(argparse.ArgumentParser):
|
|
176
|
+
def error(self, message: str) -> NoReturn:
|
|
177
|
+
print(f'error: {message}')
|
|
178
|
+
print(self.format_usage().rstrip())
|
|
179
|
+
print(f'help: run `{self.prog} --help` for flags and examples')
|
|
180
|
+
sys.exit(2)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _add_file_flags(
|
|
184
|
+
parser: argparse.ArgumentParser, *, out: bool, default: object
|
|
185
|
+
) -> None:
|
|
186
|
+
parser.add_argument(
|
|
187
|
+
'--jobs',
|
|
188
|
+
type=Path,
|
|
189
|
+
default=default,
|
|
190
|
+
help='jobs file for this run (default: the config directory jobs.toml)',
|
|
191
|
+
)
|
|
192
|
+
if out:
|
|
193
|
+
parser.add_argument(
|
|
194
|
+
'--out',
|
|
195
|
+
type=Path,
|
|
196
|
+
default=default,
|
|
197
|
+
help='output folder for this run (default: the current folder)',
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _at_least_one(text: str) -> int:
|
|
202
|
+
try:
|
|
203
|
+
value = int(text)
|
|
204
|
+
except ValueError as e:
|
|
205
|
+
msg = f'parallel must be an integer, not {text!r}'
|
|
206
|
+
raise argparse.ArgumentTypeError(msg) from e
|
|
207
|
+
if value < 1:
|
|
208
|
+
msg = 'parallel must be at least 1'
|
|
209
|
+
raise argparse.ArgumentTypeError(msg)
|
|
210
|
+
return value
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _parser() -> tuple[_Parser, dict[str, _Parser]]:
|
|
214
|
+
parser = _Parser(
|
|
215
|
+
prog='tabpull',
|
|
216
|
+
description=__doc__,
|
|
217
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
218
|
+
)
|
|
219
|
+
parser.add_argument(*VERSION_FLAGS, action='version', version=version())
|
|
220
|
+
_add_file_flags(parser, out=True, default=None)
|
|
221
|
+
commands = parser.add_subparsers(dest='command')
|
|
222
|
+
setup = commands.add_parser('setup', help='save a site token and browser session')
|
|
223
|
+
setup.add_argument('--site', help='local name for this site; each job refers to it')
|
|
224
|
+
add = commands.add_parser(
|
|
225
|
+
'add',
|
|
226
|
+
help='save a job, prompting unless view and sheets are flags',
|
|
227
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
228
|
+
epilog="""examples:
|
|
229
|
+
tabpull add
|
|
230
|
+
tabpull add --view SalesWorkbook/Overview --sheet "Order Detail" --name daily
|
|
231
|
+
tabpull add --view SalesWorkbook/Overview --sheet Totals \\
|
|
232
|
+
--filter "Region=West|Central" --filter "Order Date=2026-09-01..2026-09-25"
|
|
233
|
+
""",
|
|
234
|
+
)
|
|
235
|
+
add.add_argument('--site', help='local site name from tabpull setup')
|
|
236
|
+
add.add_argument('--view', help='Workbook/View, or a view URL')
|
|
237
|
+
add.add_argument('--name', help='job name')
|
|
238
|
+
add.add_argument(
|
|
239
|
+
'--sheet',
|
|
240
|
+
action='append',
|
|
241
|
+
dest='sheets',
|
|
242
|
+
metavar='SHEET',
|
|
243
|
+
help='worksheet to crosstab (repeat for several)',
|
|
244
|
+
)
|
|
245
|
+
add.add_argument(
|
|
246
|
+
'--filter',
|
|
247
|
+
action='append',
|
|
248
|
+
dest='filter_specs',
|
|
249
|
+
metavar='SPEC',
|
|
250
|
+
help='Field=a|b or Field=min..max (min.. or ..max leaves that side open); optional " @Sheet" (repeatable)',
|
|
251
|
+
)
|
|
252
|
+
add.add_argument(
|
|
253
|
+
'--param',
|
|
254
|
+
action='append',
|
|
255
|
+
dest='params',
|
|
256
|
+
metavar='NAME=VALUE',
|
|
257
|
+
help='parameter Name=value (repeatable)',
|
|
258
|
+
)
|
|
259
|
+
_add_file_flags(add, out=False, default=argparse.SUPPRESS)
|
|
260
|
+
remove = commands.add_parser(
|
|
261
|
+
'remove',
|
|
262
|
+
help='delete saved jobs from the jobs file',
|
|
263
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
264
|
+
epilog="""examples:
|
|
265
|
+
tabpull remove daily-west
|
|
266
|
+
tabpull remove daily-west weekly-east
|
|
267
|
+
tabpull remove
|
|
268
|
+
|
|
269
|
+
With no names, a terminal asks which jobs to remove, then confirms.
|
|
270
|
+
Otherwise pass the names. The jobs file is rewritten from the jobs that
|
|
271
|
+
remain. Removing every job leaves that file empty.
|
|
272
|
+
""",
|
|
273
|
+
)
|
|
274
|
+
remove.add_argument(
|
|
275
|
+
'names',
|
|
276
|
+
nargs='*',
|
|
277
|
+
help='jobs to delete (prompt when omitted on a terminal)',
|
|
278
|
+
)
|
|
279
|
+
_add_file_flags(remove, out=False, default=argparse.SUPPRESS)
|
|
280
|
+
run = commands.add_parser(
|
|
281
|
+
'run',
|
|
282
|
+
help='export jobs into the current folder',
|
|
283
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
284
|
+
epilog="""writes <out>/<job>/<sheet>.csv, spaces in names become _
|
|
285
|
+
|
|
286
|
+
--filter uses the same Field=a|b or Field=min..max syntax as add, including
|
|
287
|
+
an open side (min.. or ..max). It applies to every job in this run and
|
|
288
|
+
does not rewrite the jobs file. With no @Sheet it replaces the job's saved
|
|
289
|
+
filter on that field on every sheet; with @Sheet, only that sheet's. A field
|
|
290
|
+
the job does not filter is added, on @Sheet or else the first sheet.
|
|
291
|
+
|
|
292
|
+
--param uses the same Name=value syntax as add. It applies to every job in
|
|
293
|
+
this run and does not rewrite the jobs file. A saved param of that name is
|
|
294
|
+
replaced. A name the job does not have is added for this run.
|
|
295
|
+
|
|
296
|
+
examples:
|
|
297
|
+
tabpull run
|
|
298
|
+
tabpull run daily-west
|
|
299
|
+
tabpull run daily-west --filter "Order Date=2026-09-01.." --out ~/reports
|
|
300
|
+
tabpull run daily-west weekly-east --filter "Order Date=2026-09-01..2026-09-07"
|
|
301
|
+
tabpull run daily-west --param "Top N=10"
|
|
302
|
+
tabpull run --parallel 4
|
|
303
|
+
""",
|
|
304
|
+
)
|
|
305
|
+
run.add_argument('names', nargs='*', help='only these jobs (default: all)')
|
|
306
|
+
run.add_argument(
|
|
307
|
+
'--filter',
|
|
308
|
+
action='append',
|
|
309
|
+
dest='filter_specs',
|
|
310
|
+
metavar='SPEC',
|
|
311
|
+
help=(
|
|
312
|
+
'override Field=a|b or Field=min..max for this run only '
|
|
313
|
+
'(repeatable; applied to every selected job; jobs file unchanged)'
|
|
314
|
+
),
|
|
315
|
+
)
|
|
316
|
+
run.add_argument(
|
|
317
|
+
'--param',
|
|
318
|
+
action='append',
|
|
319
|
+
dest='params',
|
|
320
|
+
metavar='NAME=VALUE',
|
|
321
|
+
help=(
|
|
322
|
+
'override Name=value for this run only '
|
|
323
|
+
'(repeatable; applied to every selected job; jobs file unchanged)'
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
run.add_argument(
|
|
327
|
+
'--parallel',
|
|
328
|
+
type=_at_least_one,
|
|
329
|
+
default=1,
|
|
330
|
+
help='how many jobs to export at once (default: 1)',
|
|
331
|
+
)
|
|
332
|
+
_add_file_flags(run, out=True, default=argparse.SUPPRESS)
|
|
333
|
+
login = commands.add_parser('login', help='refresh a site SSO browser session')
|
|
334
|
+
login.add_argument(
|
|
335
|
+
'--site', help='local site name (default: the only configured site)'
|
|
336
|
+
)
|
|
337
|
+
return parser, commands.choices
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
341
|
+
parser, commands = _parser()
|
|
342
|
+
args, extra = parser.parse_known_args(argv)
|
|
343
|
+
if extra:
|
|
344
|
+
(commands.get(args.command) or parser).error(
|
|
345
|
+
f'unrecognized arguments: {" ".join(extra)}'
|
|
346
|
+
)
|
|
347
|
+
jobs_file = args.jobs or jobs_path()
|
|
348
|
+
out_dir = args.out or Path()
|
|
349
|
+
match args.command:
|
|
350
|
+
case 'setup':
|
|
351
|
+
wizard.main(args.site)
|
|
352
|
+
case 'login':
|
|
353
|
+
_cmd_login(args.site)
|
|
354
|
+
case 'add':
|
|
355
|
+
_cmd_add(args, jobs_file)
|
|
356
|
+
case 'remove':
|
|
357
|
+
remove_jobs(jobs_file, args.names)
|
|
358
|
+
case 'run':
|
|
359
|
+
return _cmd_run(args, jobs_file, out_dir)
|
|
360
|
+
case _ if ui.interactive():
|
|
361
|
+
from tabpull.tui import run_tui
|
|
362
|
+
|
|
363
|
+
run_tui(jobs_file, out_dir)
|
|
364
|
+
case _:
|
|
365
|
+
show_home(args, jobs_file, out_dir)
|
|
366
|
+
return 0
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def cli() -> None:
|
|
370
|
+
try:
|
|
371
|
+
code = main()
|
|
372
|
+
except KeyboardInterrupt:
|
|
373
|
+
print(file=sys.stderr)
|
|
374
|
+
code = 130
|
|
375
|
+
except SystemExit as e:
|
|
376
|
+
if not isinstance(e.code, str):
|
|
377
|
+
raise
|
|
378
|
+
print(f'error: {e.code}')
|
|
379
|
+
code = 1
|
|
380
|
+
sys.exit(code)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
if __name__ == '__main__':
|
|
384
|
+
print('Run: tabpull setup | add | remove | run | login', file=sys.stderr)
|
|
385
|
+
sys.exit(2)
|
tabpull/cli.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from importlib.metadata import version as installed_version
|
|
3
|
+
|
|
4
|
+
VERSION_FLAGS = ('-v', '-V', '--version')
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def version() -> str:
|
|
8
|
+
return installed_version('tabpull')
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> None:
|
|
12
|
+
if any(sys.argv[1:] == [flag] for flag in VERSION_FLAGS):
|
|
13
|
+
print(version())
|
|
14
|
+
return
|
|
15
|
+
from tabpull.app import cli
|
|
16
|
+
|
|
17
|
+
cli()
|
tabpull/embed.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import codecs
|
|
2
|
+
import csv
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import string
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import asdict
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, TypedDict
|
|
10
|
+
|
|
11
|
+
from playwright.sync_api import BrowserContext, Page
|
|
12
|
+
from playwright.sync_api import Error as PlaywrightError
|
|
13
|
+
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
|
|
14
|
+
|
|
15
|
+
from tabpull import ui
|
|
16
|
+
from tabpull.filters import normalize_range_bound, resolved_filters
|
|
17
|
+
from tabpull.jobs import Job, JobError, RangeFilter, ValuesFilter, slug
|
|
18
|
+
from tabpull.tableau import Settings
|
|
19
|
+
|
|
20
|
+
VIZ_LOAD_TIMEOUT_MS = 180_000
|
|
21
|
+
DOWNLOAD_TIMEOUT_MS = 300_000
|
|
22
|
+
# A fake page on the Tableau origin: the embedded viz then loads first-party, so the SSO
|
|
23
|
+
# cookies apply and "restrict embedding to these domains" site settings can't block it.
|
|
24
|
+
HOST_PATH = '/__crosstab_host__'
|
|
25
|
+
|
|
26
|
+
HOST_PAGE = string.Template("""<!doctype html>
|
|
27
|
+
<meta charset="utf-8">
|
|
28
|
+
<body>
|
|
29
|
+
<script type="module">
|
|
30
|
+
try {
|
|
31
|
+
const api = await import($script_url);
|
|
32
|
+
window.tableauEnums = { CrosstabFileFormat: api.CrosstabFileFormat, FilterDomainType: api.FilterDomainType, FilterUpdateType: api.FilterUpdateType };
|
|
33
|
+
const viz = new api.TableauViz();
|
|
34
|
+
viz.src = $view_url;
|
|
35
|
+
viz.toolbar = 'hidden';
|
|
36
|
+
viz.hideTabs = true;
|
|
37
|
+
viz.addEventListener(api.TableauEventType.FirstInteractive, () => { window.vizState = 'ready'; });
|
|
38
|
+
viz.addEventListener(api.TableauEventType.VizLoadError, (e) => {
|
|
39
|
+
window.vizState = 'error: ' + (e.detail?.message ?? JSON.stringify(e.detail));
|
|
40
|
+
});
|
|
41
|
+
window.viz = viz;
|
|
42
|
+
document.body.appendChild(viz);
|
|
43
|
+
} catch (e) {
|
|
44
|
+
window.vizState = 'error: could not load the Tableau Embedding API: ' + e;
|
|
45
|
+
}
|
|
46
|
+
</script>
|
|
47
|
+
""")
|
|
48
|
+
|
|
49
|
+
STORY_REFUSAL = 'Stories are not supported; use the dashboard inside it.'
|
|
50
|
+
_STORY_GUARD_JS = (
|
|
51
|
+
"if (window.viz.workbook.activeSheet.sheetType === 'story') "
|
|
52
|
+
'throw new Error(' + json.dumps(STORY_REFUSAL) + ');'
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
STORY_JS = '() => {\n ' + _STORY_GUARD_JS + '\n}'
|
|
56
|
+
|
|
57
|
+
INSPECT_JS = (
|
|
58
|
+
'async () => {\n '
|
|
59
|
+
+ _STORY_GUARD_JS
|
|
60
|
+
+ """
|
|
61
|
+
const active = window.viz.workbook.activeSheet;
|
|
62
|
+
const worksheets = active.sheetType === 'worksheet' ? [active] : active.worksheets;
|
|
63
|
+
const current = (f) => {
|
|
64
|
+
if (f.filterType === 'categorical') return f.isAllSelected ? '(All)' : f.appliedValues.map((v) => v.formattedValue).join(' | ');
|
|
65
|
+
if (f.filterType === 'range') return (f.minValue?.formattedValue ?? '') + ' .. ' + (f.maxValue?.formattedValue ?? '');
|
|
66
|
+
return '';
|
|
67
|
+
};
|
|
68
|
+
const sheets = [];
|
|
69
|
+
for (const ws of worksheets) {
|
|
70
|
+
const filters = await ws.getFiltersAsync();
|
|
71
|
+
sheets.push({ name: ws.name, filters: filters.map((f) => ({ field: f.fieldName, type: f.filterType, current: current(f) })) });
|
|
72
|
+
}
|
|
73
|
+
const params = await window.viz.workbook.getParametersAsync();
|
|
74
|
+
return { sheets, params: params.map((p) => ({ name: p.name, current: p.currentValue.formattedValue })) };
|
|
75
|
+
}"""
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
APPLY_JS = """async ({ filters, params }) => {
|
|
79
|
+
const viz = window.viz;
|
|
80
|
+
const active = viz.workbook.activeSheet;
|
|
81
|
+
const worksheets = active.sheetType === 'worksheet' ? [active] : active.worksheets;
|
|
82
|
+
const toValue = (v) => {
|
|
83
|
+
if (typeof v !== 'string') return v;
|
|
84
|
+
if (v.trim() === '') return v;
|
|
85
|
+
// Tableau reads range-filter Dates as UTC calendar days, whatever the browser timezone.
|
|
86
|
+
// An impossible ISO date must not be returned: `new Date` shifts it (2024-02-31 -> March 2).
|
|
87
|
+
const iso = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(v);
|
|
88
|
+
if (iso) {
|
|
89
|
+
const year = Number(iso[1]);
|
|
90
|
+
const month = Number(iso[2]);
|
|
91
|
+
const day = Number(iso[3]);
|
|
92
|
+
const parsed = new Date(v + 'T00:00:00Z');
|
|
93
|
+
if (
|
|
94
|
+
Number.isNaN(parsed.getTime()) ||
|
|
95
|
+
parsed.getUTCFullYear() !== year ||
|
|
96
|
+
parsed.getUTCMonth() !== month - 1 ||
|
|
97
|
+
parsed.getUTCDate() !== day
|
|
98
|
+
) throw new Error(v + ' is not a real date; use YYYY-MM-DD or M/D/YYYY');
|
|
99
|
+
return parsed;
|
|
100
|
+
}
|
|
101
|
+
if (/^-?\\d+(\\.\\d+)?$/.test(v)) return Number(v);
|
|
102
|
+
throw new Error(v + ' is a relative date or a date computed at run time; use YYYY-MM-DD or M/D/YYYY');
|
|
103
|
+
};
|
|
104
|
+
// applyRangeFilterAsync refuses a null bound, so an open side takes the filter's own endpoint.
|
|
105
|
+
const domainOf = async (ws, f) => {
|
|
106
|
+
const found = (await ws.getFiltersAsync()).find((x) => x.fieldName === f.field && x.filterType === 'range');
|
|
107
|
+
if (!found) throw new Error('No range filter on ' + f.field + ' in ' + f.sheet + ' to take an open end from; give both min and max');
|
|
108
|
+
return found.getDomainAsync(window.tableauEnums.FilterDomainType.Database);
|
|
109
|
+
};
|
|
110
|
+
for (const [name, value] of Object.entries(params)) await viz.workbook.changeParameterValueAsync(name, value);
|
|
111
|
+
for (const f of filters) {
|
|
112
|
+
const ws = worksheets.find((w) => w.name === f.sheet);
|
|
113
|
+
if (!ws) throw new Error('Sheet not in this view: ' + f.sheet);
|
|
114
|
+
if (f.values) {
|
|
115
|
+
await ws.applyFilterAsync(f.field, f.values, window.tableauEnums.FilterUpdateType.Replace);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const domain = f.min == null || f.max == null ? await domainOf(ws, f) : null;
|
|
119
|
+
await ws.applyRangeFilterAsync(f.field, {
|
|
120
|
+
min: f.min == null ? domain.min.value : toValue(f.min),
|
|
121
|
+
max: f.max == null ? domain.max.value : toValue(f.max),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}"""
|
|
125
|
+
|
|
126
|
+
EXPORT_JS = '(sheet) => window.viz.exportCrosstabAsync(sheet, window.tableauEnums.CrosstabFileFormat.CSV)'
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class SheetInfo(TypedDict):
|
|
130
|
+
name: str
|
|
131
|
+
filters: list[dict[str, str]]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class ViewInfo(TypedDict):
|
|
135
|
+
sheets: list[SheetInfo]
|
|
136
|
+
params: list[dict[str, str]]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def filter_payload(item: ValuesFilter | RangeFilter) -> dict[str, Any]:
|
|
140
|
+
raw = asdict(item)
|
|
141
|
+
if isinstance(item, RangeFilter):
|
|
142
|
+
raw['min'] = normalize_range_bound(item.min)
|
|
143
|
+
raw['max'] = normalize_range_bound(item.max)
|
|
144
|
+
return raw
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def normalize_csv(raw: bytes) -> str:
|
|
148
|
+
"""Tableau's crosstab "CSV" is UTF-16 and tab-separated."""
|
|
149
|
+
if not raw.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
|
|
150
|
+
return raw.decode('utf-8-sig')
|
|
151
|
+
out = io.StringIO()
|
|
152
|
+
csv.writer(out, lineterminator='\n').writerows(
|
|
153
|
+
csv.reader(io.StringIO(raw.decode('utf-16')), delimiter='\t')
|
|
154
|
+
)
|
|
155
|
+
return out.getvalue()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _refuse_story(page: Page) -> None:
|
|
159
|
+
try:
|
|
160
|
+
page.evaluate(STORY_JS)
|
|
161
|
+
except PlaywrightError as e:
|
|
162
|
+
if STORY_REFUSAL not in str(e):
|
|
163
|
+
raise
|
|
164
|
+
msg = STORY_REFUSAL
|
|
165
|
+
raise JobError(msg) from e
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _publish_csv(path: Path, text: str) -> None:
|
|
169
|
+
if ui.stopped():
|
|
170
|
+
msg = 'Cancelled.'
|
|
171
|
+
raise JobError(msg)
|
|
172
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
partial = path.with_name(f'.{path.name}.partial')
|
|
174
|
+
try:
|
|
175
|
+
partial.write_text(text, encoding='utf-8-sig')
|
|
176
|
+
if ui.stopped():
|
|
177
|
+
msg = 'Cancelled.'
|
|
178
|
+
raise JobError(msg)
|
|
179
|
+
partial.replace(path)
|
|
180
|
+
finally:
|
|
181
|
+
partial.unlink(missing_ok=True)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def open_view(context: BrowserContext, settings: Settings, view: str) -> Page:
|
|
185
|
+
page = context.new_page()
|
|
186
|
+
host = settings.server + HOST_PATH
|
|
187
|
+
html = HOST_PAGE.substitute(
|
|
188
|
+
script_url=json.dumps(
|
|
189
|
+
f'{settings.server}/javascripts/api/tableau.embedding.3.latest.min.js'
|
|
190
|
+
),
|
|
191
|
+
view_url=json.dumps(settings.view_url(view)),
|
|
192
|
+
)
|
|
193
|
+
page.route(host, lambda route: route.fulfill(content_type='text/html', body=html))
|
|
194
|
+
page.goto(host)
|
|
195
|
+
try:
|
|
196
|
+
page.wait_for_function('() => window.vizState', timeout=VIZ_LOAD_TIMEOUT_MS)
|
|
197
|
+
except PlaywrightTimeoutError as e:
|
|
198
|
+
msg = f'{view!r} did not load within {VIZ_LOAD_TIMEOUT_MS // 1000}s. Check the path and that you can open it in a browser.'
|
|
199
|
+
raise JobError(msg) from e
|
|
200
|
+
state = page.evaluate('() => window.vizState')
|
|
201
|
+
if state != 'ready':
|
|
202
|
+
msg = f'{view!r}: {state}'
|
|
203
|
+
raise JobError(msg)
|
|
204
|
+
try:
|
|
205
|
+
_refuse_story(page)
|
|
206
|
+
except (JobError, PlaywrightError):
|
|
207
|
+
page.close()
|
|
208
|
+
raise
|
|
209
|
+
return page
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def export_embed(
|
|
213
|
+
context: BrowserContext,
|
|
214
|
+
settings: Settings,
|
|
215
|
+
job: Job,
|
|
216
|
+
out_dir: Path,
|
|
217
|
+
*,
|
|
218
|
+
on_sheet: Callable[[int, str], None] | None = None,
|
|
219
|
+
) -> list[Path]:
|
|
220
|
+
filters = resolved_filters(job)
|
|
221
|
+
payloads = [filter_payload(item) for item in filters]
|
|
222
|
+
page = open_view(context, settings, job.view)
|
|
223
|
+
try:
|
|
224
|
+
page.evaluate(
|
|
225
|
+
APPLY_JS,
|
|
226
|
+
{
|
|
227
|
+
'filters': payloads,
|
|
228
|
+
'params': job.params,
|
|
229
|
+
},
|
|
230
|
+
)
|
|
231
|
+
written = []
|
|
232
|
+
for sheet in job.sheets:
|
|
233
|
+
if ui.stopped():
|
|
234
|
+
msg = 'Cancelled.'
|
|
235
|
+
raise JobError(msg)
|
|
236
|
+
if on_sheet is not None:
|
|
237
|
+
on_sheet(len(written), sheet)
|
|
238
|
+
with page.expect_download(timeout=DOWNLOAD_TIMEOUT_MS) as download:
|
|
239
|
+
page.evaluate(EXPORT_JS, sheet)
|
|
240
|
+
if ui.stopped():
|
|
241
|
+
msg = 'Cancelled.'
|
|
242
|
+
raise JobError(msg)
|
|
243
|
+
path = out_dir / slug(job.name) / f'{slug(sheet)}.csv'
|
|
244
|
+
_publish_csv(path, normalize_csv(Path(download.value.path()).read_bytes()))
|
|
245
|
+
written.append(path)
|
|
246
|
+
return written
|
|
247
|
+
finally:
|
|
248
|
+
page.close()
|