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