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/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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tabpull = tabpull.cli:main
@@ -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