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/ui.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from collections.abc import Callable, Iterator
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import questionary
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
STYLE = questionary.Style(
|
|
15
|
+
[
|
|
16
|
+
('qmark', 'fg:#8ab4ff bold'),
|
|
17
|
+
('pointer', 'fg:#8ab4ff bold'),
|
|
18
|
+
('highlighted', 'fg:#8ab4ff bold'),
|
|
19
|
+
('selected', 'fg:#d1dedc'),
|
|
20
|
+
('answer', 'fg:#d1dedc bold'),
|
|
21
|
+
]
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_sink: ContextVar[Callable[[str], None] | None] = ContextVar(
|
|
25
|
+
'tabpull_sink', default=None
|
|
26
|
+
)
|
|
27
|
+
_stop: ContextVar[Callable[[], bool] | None] = ContextVar('tabpull_stop', default=None)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def emit(text: str) -> None:
|
|
31
|
+
sink = _sink.get()
|
|
32
|
+
if sink is None:
|
|
33
|
+
print(text)
|
|
34
|
+
return
|
|
35
|
+
sink(text)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def stopped() -> bool:
|
|
39
|
+
stop = _stop.get()
|
|
40
|
+
return bool(stop and stop())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def captures_stop() -> bool:
|
|
44
|
+
return _stop.get() is not None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@contextmanager
|
|
48
|
+
def capture(
|
|
49
|
+
sink: Callable[[str], None], stop: Callable[[], bool] | None = None
|
|
50
|
+
) -> Iterator[None]:
|
|
51
|
+
"""Send emit() lines to sink in this thread, and let stopped() see stop."""
|
|
52
|
+
sink_token = _sink.set(sink)
|
|
53
|
+
stop_token = _stop.set(stop)
|
|
54
|
+
try:
|
|
55
|
+
yield
|
|
56
|
+
finally:
|
|
57
|
+
_sink.reset(sink_token)
|
|
58
|
+
_stop.reset(stop_token)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def rich_output() -> bool:
|
|
62
|
+
return sys.stdout.isatty()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def interactive() -> bool:
|
|
66
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def ask(question: questionary.Question) -> Any:
|
|
70
|
+
return question.unsafe_ask()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def home_path(path: Path | str) -> str:
|
|
74
|
+
text = os.path.normpath(Path(path).absolute())
|
|
75
|
+
home = str(Path.home())
|
|
76
|
+
return '~' + text[len(home) :] if text.startswith(home + os.sep) else text
|
tabpull/wizard.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import itertools
|
|
2
|
+
import platform
|
|
3
|
+
import shutil
|
|
4
|
+
import sys
|
|
5
|
+
import webbrowser
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import questionary
|
|
10
|
+
from playwright.sync_api import sync_playwright
|
|
11
|
+
from rich.markup import escape
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.rule import Rule
|
|
14
|
+
from tableauserverclient.server.endpoint.exceptions import TableauError
|
|
15
|
+
|
|
16
|
+
from tabpull import ui
|
|
17
|
+
from tabpull.tableau import (
|
|
18
|
+
Settings,
|
|
19
|
+
check_site_name,
|
|
20
|
+
home_url,
|
|
21
|
+
jobs_path,
|
|
22
|
+
parse_tableau_url,
|
|
23
|
+
read_env_file,
|
|
24
|
+
rest_session,
|
|
25
|
+
site_auth_path,
|
|
26
|
+
site_env_path,
|
|
27
|
+
sso_login,
|
|
28
|
+
upsert_env,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _Run:
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self.reset()
|
|
35
|
+
|
|
36
|
+
def reset(self) -> None:
|
|
37
|
+
self.env_path = Path()
|
|
38
|
+
self.auth_path = Path()
|
|
39
|
+
self.written: list[str] = []
|
|
40
|
+
self.skipped: list[str] = []
|
|
41
|
+
self.stages = itertools.count(1)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_run = _Run()
|
|
45
|
+
TOTAL_STAGES = 3
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def banner(title: str) -> None:
|
|
49
|
+
ui.console.clear()
|
|
50
|
+
ui.console.print(
|
|
51
|
+
Panel(
|
|
52
|
+
f'[dim]{TOTAL_STAGES} stages. Each site keeps its own token and browser session.[/]',
|
|
53
|
+
title=f'[bold]{escape(title)}[/]',
|
|
54
|
+
title_align='left',
|
|
55
|
+
border_style='blue',
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
pause('Press Enter to start…')
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def stage(name: str) -> None:
|
|
62
|
+
number = next(_run.stages)
|
|
63
|
+
ui.console.clear()
|
|
64
|
+
ui.console.print(
|
|
65
|
+
Rule(
|
|
66
|
+
f'[dim]Stage {number} of {TOTAL_STAGES}[/] [bold]{escape(name)}',
|
|
67
|
+
align='left',
|
|
68
|
+
style='blue',
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _line(markup: str) -> None:
|
|
74
|
+
ui.console.print(f' {markup}', soft_wrap=True)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def say(text: str) -> None:
|
|
78
|
+
_line(escape(text))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def step(text: str) -> None:
|
|
82
|
+
_line(f'[blue]•[/] {escape(text)}')
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def note(text: str) -> None:
|
|
86
|
+
_line(f'[dim]{escape(text)}[/]')
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def warn(text: str) -> None:
|
|
90
|
+
_line(f'[yellow]⚠ {escape(text)}[/]')
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def launch_url(url: str) -> bool:
|
|
94
|
+
if 'microsoft' in platform.release().lower() and (
|
|
95
|
+
opener := shutil.which('wslview') or shutil.which('explorer.exe')
|
|
96
|
+
):
|
|
97
|
+
return bool(webbrowser.GenericBrowser(opener).open(url))
|
|
98
|
+
return bool(webbrowser.open(url))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def open_url(url: str) -> None:
|
|
102
|
+
say(f'Opening {url}')
|
|
103
|
+
if not launch_url(url):
|
|
104
|
+
warn('Could not open a browser; open the URL above yourself.')
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def pause(text: str = 'Press Enter when done…') -> None:
|
|
108
|
+
ui.ask(questionary.press_any_key_to_continue(text, style=ui.STYLE))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def confirm(question: str, *, default: bool = False) -> bool:
|
|
112
|
+
return bool(ui.ask(questionary.confirm(question, default=default, style=ui.STYLE)))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _checked(check: Callable[[str], object]) -> Callable[[str], bool | str]:
|
|
116
|
+
|
|
117
|
+
def validate(value: str) -> bool | str:
|
|
118
|
+
try:
|
|
119
|
+
check(value.strip())
|
|
120
|
+
except ValueError as e:
|
|
121
|
+
return str(e)
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
return validate
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def ask(key: str, prompt: str) -> str:
|
|
128
|
+
existing = read_env_file(_run.env_path).get(key, '')
|
|
129
|
+
answer = ui.ask(questionary.text(prompt, default=existing, style=ui.STYLE))
|
|
130
|
+
return str(answer).strip() or existing
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def ask_secret(key: str, prompt: str) -> str:
|
|
134
|
+
existing = read_env_file(_run.env_path).get(key, '')
|
|
135
|
+
instruction = '(Enter keeps the current one)' if existing else None
|
|
136
|
+
answer = ui.ask(
|
|
137
|
+
questionary.password(prompt, instruction=instruction, style=ui.STYLE)
|
|
138
|
+
)
|
|
139
|
+
return str(answer).strip() or existing
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def write_env(key: str, value: str) -> None:
|
|
143
|
+
upsert_env(_run.env_path, key, value)
|
|
144
|
+
_run.written.append(key)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def finish(next_step: str) -> None:
|
|
148
|
+
ui.console.clear()
|
|
149
|
+
ui.console.print(Rule('[green bold]Done', align='left', style='green'))
|
|
150
|
+
for key in dict.fromkeys(_run.written):
|
|
151
|
+
_line(f'[green]✓[/] {key} → {escape(str(_run.env_path))}')
|
|
152
|
+
for item in _run.skipped:
|
|
153
|
+
warn(item)
|
|
154
|
+
note(f'Jobs default to {jobs_path()}.')
|
|
155
|
+
note('Exports go to the folder you run tabpull from, or --out.')
|
|
156
|
+
_line(f'\n Next: [bold]{escape(next_step)}[/]\n')
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _prompt_site_name() -> str:
|
|
160
|
+
note('Jobs refer to this name. Letters, numbers, ".", "_" and "-".')
|
|
161
|
+
note('The Tableau site content URL is a good name when you have one.')
|
|
162
|
+
name = ui.ask(
|
|
163
|
+
questionary.text(
|
|
164
|
+
'Site name', validate=_checked(check_site_name), style=ui.STYLE
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
return check_site_name(str(name).strip())
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _site_stage(preset: str | None) -> tuple[str, str, str]:
|
|
171
|
+
stage('Tableau site')
|
|
172
|
+
name = preset or _prompt_site_name()
|
|
173
|
+
_run.env_path = site_env_path(name)
|
|
174
|
+
_run.auth_path = site_auth_path(name)
|
|
175
|
+
env = read_env_file(_run.env_path)
|
|
176
|
+
server = env.get('TABLEAU_SERVER_URL', '')
|
|
177
|
+
if server:
|
|
178
|
+
note(
|
|
179
|
+
f'Current: {server}, site {env.get("TABLEAU_SITE") or "(default)"}. Press Enter to keep it.'
|
|
180
|
+
)
|
|
181
|
+
step('In your usual browser, open any dashboard you normally crosstab from.')
|
|
182
|
+
step('Copy the full URL from the address bar.')
|
|
183
|
+
validate = _checked(parse_tableau_url)
|
|
184
|
+
while True:
|
|
185
|
+
url = str(
|
|
186
|
+
ui.ask(
|
|
187
|
+
questionary.text(
|
|
188
|
+
'Dashboard URL',
|
|
189
|
+
validate=lambda value: (
|
|
190
|
+
(not value.strip() and bool(server)) or validate(value)
|
|
191
|
+
),
|
|
192
|
+
style=ui.STYLE,
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
).strip()
|
|
196
|
+
if not url:
|
|
197
|
+
return name, server.rstrip('/'), env.get('TABLEAU_SITE', '')
|
|
198
|
+
parsed = parse_tableau_url(url)
|
|
199
|
+
say(f'Server: {parsed.server}')
|
|
200
|
+
say(f'Site: {parsed.site or "(default site)"}')
|
|
201
|
+
say(f'Name: {name}')
|
|
202
|
+
if confirm('Is that right?', default=True):
|
|
203
|
+
write_env('TABLEAU_SERVER_URL', parsed.server)
|
|
204
|
+
write_env('TABLEAU_SITE', parsed.site)
|
|
205
|
+
return name, parsed.server, parsed.site
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _pat_stage(name: str, server: str, site: str) -> Settings:
|
|
209
|
+
stage('Personal access token')
|
|
210
|
+
say(
|
|
211
|
+
'The token lets tabpull find views. Exporting a sheet uses the browser sign-in in the next step.'
|
|
212
|
+
)
|
|
213
|
+
open_url(home_url(server, site))
|
|
214
|
+
step('Click your profile picture (top right) → My Account Settings.')
|
|
215
|
+
step('Under Personal Access Tokens, type a name (e.g. tabpull) → Create Token.')
|
|
216
|
+
step('Copy the secret now: Tableau shows it only once.')
|
|
217
|
+
note(
|
|
218
|
+
'No Personal Access Tokens section? Your site admin has disabled them for your role.'
|
|
219
|
+
)
|
|
220
|
+
while True:
|
|
221
|
+
settings = Settings(
|
|
222
|
+
server=server,
|
|
223
|
+
site=site,
|
|
224
|
+
pat_name=ask('TABLEAU_PAT_NAME', 'Token name'),
|
|
225
|
+
name=name,
|
|
226
|
+
auth_path=_run.auth_path,
|
|
227
|
+
pat_secret=ask_secret('TABLEAU_PAT_SECRET', 'Token secret'),
|
|
228
|
+
)
|
|
229
|
+
say('Checking the token…')
|
|
230
|
+
try:
|
|
231
|
+
with rest_session(settings):
|
|
232
|
+
pass
|
|
233
|
+
except (TableauError, OSError) as e:
|
|
234
|
+
warn(f'Sign-in failed: {e}')
|
|
235
|
+
if confirm('Try again?', default=True):
|
|
236
|
+
continue
|
|
237
|
+
_run.skipped.append(
|
|
238
|
+
'PAT sign-in failed; saved anyway. Re-run tabpull setup once it works.'
|
|
239
|
+
)
|
|
240
|
+
else:
|
|
241
|
+
_line('[green]✓ Signed in.[/]')
|
|
242
|
+
write_env('TABLEAU_PAT_NAME', settings.pat_name)
|
|
243
|
+
write_env('TABLEAU_PAT_SECRET', settings.pat_secret)
|
|
244
|
+
return settings
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _sso_stage(settings: Settings) -> None:
|
|
248
|
+
stage('Browser sign-in (SSO)')
|
|
249
|
+
say(
|
|
250
|
+
'Crosstabs of dashboard sheets run in a hidden browser using your normal SSO sign-in.'
|
|
251
|
+
)
|
|
252
|
+
say(
|
|
253
|
+
'A browser window opens: sign in as usual (SSO, MFA). It closes itself once you are in.'
|
|
254
|
+
)
|
|
255
|
+
note(
|
|
256
|
+
f'The session cookies are saved to {settings.auth_path}. Keep that file private.'
|
|
257
|
+
)
|
|
258
|
+
if not confirm('Sign in now?', default=True):
|
|
259
|
+
_run.skipped.append(
|
|
260
|
+
f'Skipped SSO sign-in. Run: tabpull login --site {settings.name}'
|
|
261
|
+
)
|
|
262
|
+
return
|
|
263
|
+
with sync_playwright() as pw:
|
|
264
|
+
sso_login(pw, settings)
|
|
265
|
+
pause()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def main(site_name: str | None = None) -> str:
|
|
269
|
+
if site_name is not None:
|
|
270
|
+
try:
|
|
271
|
+
check_site_name(site_name)
|
|
272
|
+
except ValueError as e:
|
|
273
|
+
raise SystemExit(str(e)) from e
|
|
274
|
+
if not ui.interactive():
|
|
275
|
+
msg = 'tabpull setup needs a terminal: it asks for a token and opens a browser.'
|
|
276
|
+
raise SystemExit(msg)
|
|
277
|
+
_run.reset()
|
|
278
|
+
banner('tabpull setup')
|
|
279
|
+
name, server, site = _site_stage(site_name)
|
|
280
|
+
settings = _pat_stage(name, server, site)
|
|
281
|
+
_sso_stage(settings)
|
|
282
|
+
finish(f'tabpull add --site {name}')
|
|
283
|
+
return name
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
if __name__ == '__main__':
|
|
287
|
+
print('Run: tabpull setup', file=sys.stderr)
|
|
288
|
+
sys.exit(2)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tabpull
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Export Tableau dashboard sheet crosstabs with filters
|
|
5
|
+
Author-email: Jeremy Castro <160854558+jer-castro@users.noreply.github.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jer-castro/tabpull
|
|
8
|
+
Project-URL: Repository, https://github.com/jer-castro/tabpull
|
|
9
|
+
Project-URL: Issues, https://github.com/jer-castro/tabpull/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/jer-castro/tabpull/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: tableau,crosstab,csv,cli
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: playwright==1.63.0
|
|
26
|
+
Requires-Dist: questionary==2.1.1
|
|
27
|
+
Requires-Dist: rich==15.0.0
|
|
28
|
+
Requires-Dist: tableauserverclient==0.41
|
|
29
|
+
Requires-Dist: textual==8.2.8
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
<p align="center">
|
|
33
|
+
<img src="https://raw.githubusercontent.com/jer-castro/tabpull/main/assets/hero.svg" alt="tabpull" width="100%" />
|
|
34
|
+
</p>
|
|
35
|
+
|
|
36
|
+
tabpull exports named worksheets from a published Tableau dashboard to UTF-8 CSV.
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
pip install tabpull
|
|
40
|
+
uv tool install tabpull
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
That installs the `tabpull` command. Bare `tabpull` at a terminal opens an interactive screen. Add, setup, login, and run stay on that screen. `setup`, `add`, `remove`, `run`, and `login` are also subcommands and work the same with or without it. Pin a release with `tabpull==X.Y.Z` or `uv tool install tabpull==X.Y.Z`. Upgrade a uv tool with `uv tool upgrade tabpull`. A commit that is not published yet still installs with `uv tool install git+https://github.com/jer-castro/tabpull`. Add `@vX.Y.Z` on that URL to pin a tag.
|
|
44
|
+
|
|
45
|
+
tabpull launches installed Chrome, then Edge. When neither is installed, install Chromium after the package. After `pip install tabpull`, run `playwright install chromium` in that environment. After `uv tool install tabpull`, the `playwright` command is not on `PATH`. Run `uvx playwright==<version> install chromium` with the version tabpull prints when Chrome and Edge are missing. That command uses the same browser revision and the default Playwright cache as the installed package.
|
|
46
|
+
|
|
47
|
+
## Why Embedding API
|
|
48
|
+
|
|
49
|
+
REST crosstab and data endpoints only export the first sheet when the view is a dashboard. A worksheet that lives only inside a published dashboard is not published as its own view, so it has no REST view.
|
|
50
|
+
|
|
51
|
+
Those sheets go through the Embedding API (`exportCrosstabAsync`) in a headless browser signed in with your SSO session. The personal access token is used to find views.
|
|
52
|
+
|
|
53
|
+
## Setup
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
tabpull setup
|
|
57
|
+
tabpull setup --site finance # local name jobs will use
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The wizard asks for that name (unless you passed `--site`), a dashboard URL, and a personal access token. It checks the token, then opens a browser for SSO.
|
|
61
|
+
|
|
62
|
+
Run it again for another server or site. Each site keeps its own token and browser session. Re-running a name keeps the current values when you press Enter.
|
|
63
|
+
|
|
64
|
+
The browser is installed Chrome, then Edge. If neither is installed, tabpull prints `uvx playwright==<version> install chromium` for the Playwright version it has. It does not download a browser while Chrome or Edge is already there.
|
|
65
|
+
|
|
66
|
+
## Use
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
tabpull # interactive screen at a terminal; a listing when piped
|
|
70
|
+
tabpull add # search a view or paste its URL, pick sheets and filters
|
|
71
|
+
tabpull add --site finance \
|
|
72
|
+
--view SalesWorkbook/Overview \
|
|
73
|
+
--sheet "Order Detail" --sheet Totals \
|
|
74
|
+
--filter "Region=West|Central" \
|
|
75
|
+
--filter "Order Date=2026-09-01..2026-09-25 @Totals" \
|
|
76
|
+
--param "Top N=25" \
|
|
77
|
+
--name daily-west
|
|
78
|
+
tabpull run # every job, into the current folder
|
|
79
|
+
tabpull run daily-west
|
|
80
|
+
tabpull remove daily-west # delete that saved job
|
|
81
|
+
tabpull remove # pick jobs to delete
|
|
82
|
+
tabpull login # refresh SSO for the only site
|
|
83
|
+
tabpull login --site finance
|
|
84
|
+
tabpull --version
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
With no command at a terminal, tabpull opens a full-screen list of sites and jobs. Open a job to change its filters (values or range bounds), parameters, sheets, view, site, and name. Adding a filter or parameter, changing which field a row uses, and choosing sheets pick from the live view when it opens, and from the typed forms when it cannot, with the reason shown. Editing a value that is already chosen stays a form and does not open the view. Each change is checked and saved to the jobs file right away. `r` runs the selected or marked jobs, `a` adds, `d` removes after a confirm, `s` and `l` run setup and login. Run, add, setup, and login happen on the screen; escape or ctrl+c cancels a running step. The footer lists the keys, `?` shows all of them, `q` quits.
|
|
88
|
+
|
|
89
|
+
With no command and no terminal (a pipe or a script), tabpull prints configured sites, saved jobs, the jobs file, the output folder, and the next commands in [TOON](https://toonformat.dev/). It never waits for keys. Errors, including an unknown flag, print `error: ...` on stdout. A usage error exits 2. Any other failure exits 1.
|
|
90
|
+
|
|
91
|
+
`add` saves the job and prints the `run` command. It does not export. With `--view` and at least one `--sheet`, it writes the job and does not prompt. Leave those flags off and it asks.
|
|
92
|
+
|
|
93
|
+
`remove` deletes the named jobs and rewrites the jobs file. With no names, a terminal asks which jobs to delete and confirms before writing. With no terminal, pass the names. An unknown name exits 1 and lists the saved jobs, and the file is left unchanged. Removing every job leaves the file empty.
|
|
94
|
+
|
|
95
|
+
`add` with `--view` and `--sheet` still opens the view far enough to refuse a story: `Stories are not supported; use the dashboard inside it.` `run` refuses a story the same way, before it exports.
|
|
96
|
+
|
|
97
|
+
`--filter` is `Field=a|b` or `Field=min..max`. ` @Sheet` names the worksheet. Either side of a range may be omitted, but not both: `Field=min..` runs from that date through the latest value the filter allows, and `Field=..max` runs from the earliest value through that date. An open side needs a range filter on that field in the workbook, since tabpull reads its endpoint from there. A filter with no sheet is applied on the first sheet in the job, and tabpull prints that. With one configured site, `--site` can be omitted. With several, pass `--site` or pick one at the prompt.
|
|
98
|
+
|
|
99
|
+
`run` keeps going when one job fails. `--parallel N` exports that many jobs at once (default 1, so a run that does not ask for more still goes one job at a time). Above 1, each of those N slots runs in its own process with one browser it reuses for every job it takes, each job in a new context signed in from that site's saved session, and two jobs whose paths are one file on the output disk are refused before anything is exported. A terminal shows a progress bar and a summary panel titled `done: <ok>/<total> jobs exported`. A pipe prints that same `done` line and a check or cross per job. If any job failed, it prints the `tabpull run` command that reruns only those and exits 1. Each job uses the site it names. When that site's SSO session is missing or expired, tabpull opens the sign-in window once at a terminal before that site's jobs start, and otherwise exits and tells you to run `tabpull login --site <name>`. With `--parallel` above 1, a job that finds the session missing after that check fails with that same login line. Crosstab CSVs are rewritten from Tableau's UTF-16 tab-separated format to UTF-8 CSV.
|
|
100
|
+
|
|
101
|
+
`run --filter` uses the same repeatable syntax as `add`. Each override applies to every job in that run and is not written back to the jobs file. With no ` @Sheet` it replaces the job's saved filter on that field on every sheet. With ` @Sheet` it replaces only that sheet's. A field the job does not already filter is added on ` @Sheet`, or with no sheet on the first sheet, and tabpull prints that. Leave one side of `min..max` empty when that end should stay the filter's own limit. Relative dates are refused, so compute them in the caller:
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
start=2026-09-01
|
|
105
|
+
end=$(date +%F)
|
|
106
|
+
tabpull run daily-west \
|
|
107
|
+
--filter "Order Date=${start}.." \
|
|
108
|
+
--out "./exports/${end}"
|
|
109
|
+
tabpull run daily-west weekly-east \
|
|
110
|
+
--filter "Order Date=${start}..${end}" \
|
|
111
|
+
--out "./exports/${end}"
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`run --param` uses the same repeatable Name=value syntax as `add`. Each override applies to every job in that run and is not written back to the jobs file. A saved param of that name is replaced. A name the job does not have is added for this run. `tabpull run daily-west --param "Top N=10"`.
|
|
115
|
+
|
|
116
|
+
`run` writes `<job>/<sheet>.csv` under the current folder (spaces in names become `_`). `--jobs` and `--out` work before or after the subcommand:
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
tabpull run daily-west --jobs ./jobs.toml --out ./exports
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Where files live
|
|
123
|
+
|
|
124
|
+
Tokens, cookies, and jobs stay in the config directory. Exports go to the working directory, or `--out`. Deleting the config directory removes tabpull's settings. A `.env` file and `TABLEAU_*` environment variables are not read.
|
|
125
|
+
|
|
126
|
+
| | Linux | macOS | Windows |
|
|
127
|
+
| --- | --- | --- | --- |
|
|
128
|
+
| Config (site tokens, SSO cookies, `jobs.toml`) | `$XDG_CONFIG_HOME/tabpull` or `~/.config/tabpull` | `~/Library/Application Support/tabpull` | `%APPDATA%\tabpull` |
|
|
129
|
+
|
|
130
|
+
`XDG_CONFIG_HOME` wins on every OS when it is set. `<config>` below is that directory.
|
|
131
|
+
|
|
132
|
+
```text
|
|
133
|
+
<config>/sites/<name>.env server, Tableau site, token name, token secret
|
|
134
|
+
<config>/jobs.toml
|
|
135
|
+
<config>/auth/<name>.json SSO cookies; mode 600
|
|
136
|
+
./<job>/<sheet>.csv exports, relative to where you ran tabpull
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The token secret is not printed. Setup and login print the server and the Tableau site.
|
|
140
|
+
|
|
141
|
+
## Jobs file
|
|
142
|
+
|
|
143
|
+
`add` writes this. `remove` and edits on the interactive screen rewrite it from the saved jobs, which drops comments. You can edit the file by hand:
|
|
144
|
+
|
|
145
|
+
```toml
|
|
146
|
+
[[job]]
|
|
147
|
+
name = "daily-west"
|
|
148
|
+
site = "finance" # local name from `tabpull setup`
|
|
149
|
+
view = "SalesWorkbook/Overview" # Workbook/View from the URL
|
|
150
|
+
sheets = ["Order Detail", "Totals"]
|
|
151
|
+
filters = [
|
|
152
|
+
{ field = "Region", values = ["West", "Central"] },
|
|
153
|
+
{ field = "Order Date", min = "2026-09-01", max = "2026-09-25", sheet = "Totals" },
|
|
154
|
+
]
|
|
155
|
+
params = { "Top N" = "25" }
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Parameters are set first, then filters. A filter applies on `sheet`. When `sheet` is omitted, tabpull applies that filter on the first entry in `sheets` and prints a line saying so. It does not copy the filter onto every sheet. Other sheets update only as they would in the dashboard when that filter changes.
|
|
159
|
+
|
|
160
|
+
`YYYY-MM-DD`, `M/D/YYYY`, and plain numbers are converted for range filters. An impossible date such as `2024-02-31` or `2/31/2024` is rejected. A relative date (`yesterday`, `last week`, `today`, `7 days ago`) or a date computed at run time is refused. Use a parameter, or write an absolute `YYYY-MM-DD` or `M/D/YYYY` date. Omit `min` or `max` to leave that end open: `{ field = "Order Date", min = "2026-09-01", sheet = "Totals" }` runs from that date through the latest value the filter allows, and `{ field = "Order Date", max = "2026-09-25", sheet = "Totals" }` runs from the earliest value through that date.
|
|
161
|
+
|
|
162
|
+
Stories are refused. Use the dashboard inside the story.
|
|
163
|
+
|
|
164
|
+
## Develop
|
|
165
|
+
|
|
166
|
+
From a checkout, `uv tool install .` installs the command, and `uv run tabpull` runs it without installing. `uv run playwright install chromium` installs the Chromium fallback for that checkout. An installed `tabpull` prints `uvx playwright==<version> install chromium` for the Playwright version it has.
|
|
167
|
+
|
|
168
|
+
```sh
|
|
169
|
+
uv run ruff check && uv run ruff format && uv run ty check && uv run pytest
|
|
170
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
tabpull/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
tabpull/__main__.py,sha256=phpyNISjoqyoR4uJJjn8lru66zs6Z0T6xcnlb0eFloA,37
|
|
3
|
+
tabpull/add.py,sha256=A5BOTUx4ybDqbKuER3PmGQpSHbAxya7TQhqoHdvwpzo,12463
|
|
4
|
+
tabpull/app.py,sha256=dnT-dpIC5ngJv-SmuTufLDGVaK0x3UNtWacTUMEu8qw,12715
|
|
5
|
+
tabpull/cli.py,sha256=GxRUDkZvvsNn93eII-eGJ8dBlkxedsbw_Uts7iRWY_4,345
|
|
6
|
+
tabpull/embed.py,sha256=-3XVueF-nBGLV6QFkajJsYlcxhfhZqeWts6i_oHjNY8,9075
|
|
7
|
+
tabpull/filters.py,sha256=m_m5HFtGpgCB3q3e1ut41i0rzP5Y2g7NjzjhNlfZ__Y,5827
|
|
8
|
+
tabpull/home.py,sha256=6_u9Ja4meDNPxf653CqnNe7SoFtNPrqKeMjIOdvm9S8,5918
|
|
9
|
+
tabpull/jobs.py,sha256=Sg_Dtwbbv0OCcGDmDBazLvUWGnU3Mozx12SEGkAxLU8,5854
|
|
10
|
+
tabpull/remove.py,sha256=SCY5t9aahswdEb6HMpPoWdTeag6Z2N_R_NimePQKBhw,2403
|
|
11
|
+
tabpull/run.py,sha256=A62M8wn8o2wREqcmkxYMRfsvmfsXlrtjsMu7J614OwE,23106
|
|
12
|
+
tabpull/tableau.py,sha256=DFR_ypplYX3TQtv6IqVrSSQzBIfoW4Pjqd78p6jos5E,14194
|
|
13
|
+
tabpull/ui.py,sha256=Bwl8jXen8b8UsBdEbva08BR9YEy0qtsNnZyWDMWPBc0,1756
|
|
14
|
+
tabpull/wizard.py,sha256=WtM76xnukHUNIws08lfdRZCCOqpI-EV8R4zO-FE03kY,8688
|
|
15
|
+
tabpull/tui/__init__.py,sha256=76UvKlH9C_s3B5DkHrMzFvx2ovPbcCrzccUWzIZ4i2o,59
|
|
16
|
+
tabpull/tui/app.py,sha256=zHGUnd1dibiIzH_BqVfScCJV6lh5HfrWB_EyBckhMzI,36509
|
|
17
|
+
tabpull/tui/forms.py,sha256=tEGHAdosVu38NVBhN_8CtFE6xkfC6XqVETBN6X8oPgY,24149
|
|
18
|
+
tabpull-0.1.0.dist-info/licenses/LICENSE,sha256=EICu6VFjcVTNP8zfoWc_mSb9R4tzGwdO8DLkzVUUjWw,1065
|
|
19
|
+
tabpull-0.1.0.dist-info/METADATA,sha256=1gN0fsGslBVqT0j5SweKKqlaBmKGb9KmMphsviEv0GU,12006
|
|
20
|
+
tabpull-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
21
|
+
tabpull-0.1.0.dist-info/entry_points.txt,sha256=HY7preLYEm6Lzvf6x15FWX5veVFmy_KvJr3LSBWPvO4,45
|
|
22
|
+
tabpull-0.1.0.dist-info/top_level.txt,sha256=UfTS4TP-4l1v0j6-NtQA4Yun5ToG7rYPDInilkMXpQQ,8
|
|
23
|
+
tabpull-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Jeremy Castro
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tabpull
|