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/tableau.py ADDED
@@ -0,0 +1,457 @@
1
+ import contextvars
2
+ import importlib.metadata
3
+ import os
4
+ import re
5
+ import shutil
6
+ import signal
7
+ import sys
8
+ import threading
9
+ import time
10
+ from collections.abc import Iterator
11
+ from contextlib import contextmanager, suppress
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from urllib.parse import urlsplit
15
+
16
+ import tableauserverclient as tsc
17
+ from playwright.sync_api import Browser, BrowserContext, Page, Playwright
18
+ from playwright.sync_api import Error as PlaywrightError
19
+
20
+ from tabpull import ui
21
+
22
+ SETTING_KEYS = (
23
+ 'TABLEAU_SERVER_URL',
24
+ 'TABLEAU_SITE',
25
+ 'TABLEAU_PAT_NAME',
26
+ 'TABLEAU_PAT_SECRET',
27
+ )
28
+ LOGIN_TIMEOUT_S = 300
29
+ LOGIN_POLL_MS = 2000
30
+ _CANCEL_POLL_S = 0.05
31
+ _browser_pids: contextvars.ContextVar[set[int] | None] = contextvars.ContextVar(
32
+ 'tabpull_browser_pids', default=None
33
+ )
34
+ _browser_pids_lock = threading.Lock()
35
+ BROWSER_CHANNELS = ('chrome', 'msedge', None)
36
+ _APP = 'tabpull'
37
+ _SITE_NAME = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$')
38
+
39
+ _SITE_RE = re.compile(r'^/+(?:t|site)/([^/?]+)')
40
+ _VIEW_RE = re.compile(r'/views/([^/?]+)/([^/?]+)')
41
+
42
+
43
+ class MissingSettingsError(Exception):
44
+ def __init__(self, keys: list[str], path: Path) -> None:
45
+ self.keys = keys
46
+ self.path = path
47
+ super().__init__(
48
+ f'Missing {", ".join(keys)} in {path}. Run: tabpull setup --site {path.stem}'
49
+ )
50
+
51
+
52
+ class UnknownSiteError(Exception):
53
+ pass
54
+
55
+
56
+ def check_site_name(name: str) -> str:
57
+ if _SITE_NAME.fullmatch(name):
58
+ return name
59
+ msg = (
60
+ f'Site name {name!r} should start with a letter or number and contain only '
61
+ 'letters, numbers, ".", "_" and "-".'
62
+ )
63
+ raise ValueError(msg)
64
+
65
+
66
+ def _windows_dir(env_var: str, fallback: Path) -> Path:
67
+ value = os.environ.get(env_var)
68
+ return Path(value) if value else fallback
69
+
70
+
71
+ def _rooted(env_var: str, unix_default: Path, windows: Path, mac: Path) -> Path:
72
+ if override := os.environ.get(env_var):
73
+ return Path(override) / _APP
74
+ if sys.platform == 'win32':
75
+ return windows / _APP
76
+ if sys.platform == 'darwin':
77
+ return mac / _APP
78
+ return unix_default / _APP
79
+
80
+
81
+ def config_dir() -> Path:
82
+ return _rooted(
83
+ 'XDG_CONFIG_HOME',
84
+ Path.home() / '.config',
85
+ _windows_dir('APPDATA', Path.home() / 'AppData' / 'Roaming'),
86
+ Path.home() / 'Library' / 'Application Support',
87
+ )
88
+
89
+
90
+ def data_dir() -> Path:
91
+ share = Path.home() / '.local' / 'share'
92
+ return _rooted(
93
+ 'XDG_DATA_HOME',
94
+ share,
95
+ _windows_dir('LOCALAPPDATA', Path.home() / 'AppData' / 'Local'),
96
+ share,
97
+ )
98
+
99
+
100
+ def _legacy_data_dir() -> Path:
101
+ if os.environ.get('XDG_DATA_HOME'):
102
+ return data_dir()
103
+ if sys.platform == 'darwin':
104
+ return Path.home() / 'Library' / 'Application Support' / _APP
105
+ return data_dir()
106
+
107
+
108
+ def _move_dir(source: Path, dest: Path) -> None:
109
+ if source == dest or not source.is_dir() or dest.exists():
110
+ return
111
+ dest.parent.mkdir(parents=True, exist_ok=True)
112
+ shutil.move(source, dest)
113
+
114
+
115
+ def jobs_path() -> Path:
116
+ return config_dir() / 'jobs.toml'
117
+
118
+
119
+ def site_env_path(name: str) -> Path:
120
+ return config_dir() / 'sites' / f'{check_site_name(name)}.env'
121
+
122
+
123
+ def site_auth_path(name: str) -> Path:
124
+ source = _legacy_data_dir() / 'auth'
125
+ dest = config_dir() / 'auth'
126
+ live = Path.home() / 'Library' / 'Application Support' / _APP / 'auth'
127
+ # On macOS the legacy auth dir is also the default config auth dir, so a
128
+ # redirected XDG_CONFIG_HOME (tests, one-off runs) must not move it away.
129
+ if source != live:
130
+ _move_dir(source, dest)
131
+ return dest / f'{check_site_name(name)}.json'
132
+
133
+
134
+ def list_sites() -> list[str]:
135
+ directory = config_dir() / 'sites'
136
+ if not directory.is_dir():
137
+ return []
138
+ return sorted(
139
+ path.stem for path in directory.glob('*.env') if _SITE_NAME.fullmatch(path.stem)
140
+ )
141
+
142
+
143
+ def home_url(server: str, site: str) -> str:
144
+ return f'{server}/#/site/{site}/home' if site else f'{server}/#/home'
145
+
146
+
147
+ @dataclass(frozen=True)
148
+ class Settings:
149
+ server: str
150
+ site: str
151
+ pat_name: str
152
+ name: str
153
+ auth_path: Path
154
+ pat_secret: str = field(repr=False)
155
+
156
+ @property
157
+ def home_url(self) -> str:
158
+ return home_url(self.server, self.site)
159
+
160
+ def view_url(self, view: str) -> str:
161
+ site = f'/t/{self.site}' if self.site else ''
162
+ return f'{self.server}{site}/views/{view}'
163
+
164
+
165
+ @dataclass(frozen=True)
166
+ class TableauUrl:
167
+ server: str
168
+ site: str
169
+ view: str | None
170
+
171
+
172
+ def parse_tableau_url(url: str) -> TableauUrl:
173
+ parts = urlsplit(url.strip())
174
+ if not parts.scheme or not parts.netloc:
175
+ msg = f'Not a URL: {url!r}'
176
+ raise ValueError(msg)
177
+ route = parts.path + parts.fragment
178
+ site = _SITE_RE.search(route)
179
+ view = _VIEW_RE.search(route)
180
+ return TableauUrl(
181
+ server=f'{parts.scheme}://{parts.netloc}',
182
+ site=site.group(1) if site else '',
183
+ view=f'{view.group(1)}/{view.group(2)}' if view else None,
184
+ )
185
+
186
+
187
+ def read_env_file(path: Path) -> dict[str, str]:
188
+ if not path.exists():
189
+ return {}
190
+ values = {}
191
+ for line in path.read_text(encoding='utf-8').splitlines():
192
+ key, sep, value = line.partition('=')
193
+ if sep and not key.lstrip().startswith('#'):
194
+ values[key.strip()] = value.strip().strip('"\'')
195
+ return values
196
+
197
+
198
+ def upsert_env(path: Path, key: str, value: str) -> None:
199
+ path.parent.mkdir(parents=True, exist_ok=True)
200
+ lines = path.read_text(encoding='utf-8').splitlines() if path.exists() else []
201
+ kept = [line for line in lines if line.partition('=')[0].strip() != key]
202
+ path.write_text('\n'.join([*kept, f'{key}={value}']) + '\n', encoding='utf-8')
203
+ path.chmod(0o600)
204
+
205
+
206
+ def save_site(name: str, values: dict[str, str]) -> Path:
207
+ path = site_env_path(name)
208
+ for key in SETTING_KEYS:
209
+ if key in values:
210
+ upsert_env(path, key, values[key])
211
+ return path
212
+
213
+
214
+ def load_site(name: str) -> Settings:
215
+ path = site_env_path(name)
216
+ if not path.is_file():
217
+ known = ', '.join(list_sites()) or '(none)'
218
+ msg = (
219
+ f'No site named {name!r}. Configured sites: {known}. '
220
+ f'Run: tabpull setup --site {name}'
221
+ )
222
+ raise UnknownSiteError(msg)
223
+ values = read_env_file(path)
224
+ # TABLEAU_SITE is legitimately empty for a Tableau Server default site.
225
+ missing = [
226
+ key
227
+ for key in SETTING_KEYS
228
+ if key not in values or (not values[key] and key != 'TABLEAU_SITE')
229
+ ]
230
+ if missing:
231
+ raise MissingSettingsError(missing, path)
232
+ return Settings(
233
+ server=values['TABLEAU_SERVER_URL'].rstrip('/'),
234
+ site=values['TABLEAU_SITE'],
235
+ pat_name=values['TABLEAU_PAT_NAME'],
236
+ name=name,
237
+ auth_path=site_auth_path(name),
238
+ pat_secret=values['TABLEAU_PAT_SECRET'],
239
+ )
240
+
241
+
242
+ @contextmanager
243
+ def rest_session(settings: Settings) -> Iterator[tsc.Server]:
244
+ """Signing in with a PAT ends any other session using the same PAT, so don't share one token across parallel runs."""
245
+ server = tsc.Server(settings.server, use_server_version=True)
246
+ auth = tsc.PersonalAccessTokenAuth(
247
+ settings.pat_name, settings.pat_secret, site_id=settings.site
248
+ )
249
+ with server.auth.sign_in(auth):
250
+ yield server
251
+
252
+
253
+ def _chromium_install() -> str:
254
+ """`uvx playwright==<version>` uses the same browser revision and the default `ms-playwright` cache as the Playwright package installed with tabpull."""
255
+ version = importlib.metadata.version('playwright')
256
+ return f'uvx playwright=={version} install chromium'
257
+
258
+
259
+ def launch_browser(pw: Playwright, *, headless: bool) -> Browser:
260
+ for channel in BROWSER_CHANNELS:
261
+ try:
262
+ browser = pw.chromium.launch(channel=channel, headless=headless)
263
+ except PlaywrightError:
264
+ continue
265
+ _track(browser)
266
+ return browser
267
+ msg = f'No Chrome or Edge found. Install one, or run: {_chromium_install()}'
268
+ raise SystemExit(msg)
269
+
270
+
271
+ def session_valid(context: BrowserContext, settings: Settings) -> bool:
272
+ """Uses getSessionInfo, the internal endpoint Tableau's own web client calls on every page load."""
273
+ xsrf = next(
274
+ (
275
+ c['value']
276
+ for c in context.cookies(settings.server)
277
+ if c['name'] == 'XSRF-TOKEN'
278
+ ),
279
+ None,
280
+ )
281
+ if not xsrf:
282
+ return False
283
+ response = context.request.post(
284
+ f'{settings.server}/vizportal/api/web/v1/getSessionInfo',
285
+ data={'method': 'getSessionInfo', 'params': {}},
286
+ headers={'X-XSRF-TOKEN': xsrf, 'Accept': 'application/json'},
287
+ fail_on_status_code=False,
288
+ )
289
+ return response.ok
290
+
291
+
292
+ def _browser_pid(browser: Browser) -> int:
293
+ cdp = browser.new_browser_cdp_session()
294
+ try:
295
+ info = cdp.send('SystemInfo.getProcessInfo')
296
+ finally:
297
+ cdp.detach()
298
+ return next(p['id'] for p in info['processInfo'] if p['type'] == 'browser')
299
+
300
+
301
+ def _track(browser: Browser) -> None:
302
+ pids = _browser_pids.get()
303
+ if pids is None:
304
+ return
305
+ pid = _browser_pid(browser)
306
+ with _browser_pids_lock:
307
+ pids.add(pid)
308
+
309
+ def forget(_browser: Browser) -> None:
310
+ with _browser_pids_lock:
311
+ pids.discard(pid)
312
+
313
+ browser.on('disconnected', forget)
314
+
315
+
316
+ @contextmanager
317
+ def close_on_stop() -> Iterator[None]:
318
+ """Hard-kill tracked browsers once ui.stopped() is true.
319
+
320
+ Sync Playwright is not thread-safe and cancel arrives from the UI thread, so
321
+ the watcher only signals pids and never touches a Playwright object. The
322
+ driver stays up, so the blocked call raises and later close() calls return.
323
+ """
324
+ if _browser_pids.get() is not None or not ui.captures_stop():
325
+ yield
326
+ return
327
+ pids: set[int] = set()
328
+ token = _browser_pids.set(pids)
329
+ ctx = contextvars.copy_context()
330
+ done = threading.Event()
331
+
332
+ def watch() -> None:
333
+ while not done.wait(_CANCEL_POLL_S):
334
+ if not ctx.run(ui.stopped):
335
+ continue
336
+ # Windows has no SIGKILL; os.kill with SIGTERM there is TerminateProcess.
337
+ hard_kill = getattr(signal, 'SIGKILL', signal.SIGTERM)
338
+ with _browser_pids_lock:
339
+ for pid in pids:
340
+ with suppress(OSError):
341
+ os.kill(pid, hard_kill)
342
+ pids.clear()
343
+
344
+ watcher = threading.Thread(target=watch, name='tabpull-cancel', daemon=True)
345
+ watcher.start()
346
+ try:
347
+ yield
348
+ finally:
349
+ done.set()
350
+ watcher.join(timeout=1)
351
+ _browser_pids.reset(token)
352
+
353
+
354
+ def _wait_for_sign_in(page: Page, context: BrowserContext, settings: Settings) -> bool:
355
+ deadline = time.monotonic() + LOGIN_TIMEOUT_S
356
+ while True:
357
+ if ui.stopped():
358
+ return True
359
+ try:
360
+ if session_valid(context, settings):
361
+ return False
362
+ if ui.stopped():
363
+ return True
364
+ except Exception:
365
+ if ui.stopped():
366
+ return True
367
+ raise
368
+ if time.monotonic() > deadline:
369
+ msg = f'Gave up waiting for sign-in after {LOGIN_TIMEOUT_S}s.'
370
+ raise SystemExit(msg)
371
+ try:
372
+ page.wait_for_timeout(LOGIN_POLL_MS)
373
+ except Exception:
374
+ if ui.stopped():
375
+ return True
376
+ raise
377
+
378
+
379
+ def sso_login(pw: Playwright, settings: Settings) -> None:
380
+ with close_on_stop():
381
+ browser = launch_browser(pw, headless=False)
382
+ context = browser.new_context(
383
+ storage_state=settings.auth_path if settings.auth_path.exists() else None,
384
+ no_viewport=True,
385
+ )
386
+ page = context.new_page()
387
+ try:
388
+ page.goto(settings.home_url)
389
+ except Exception as e:
390
+ browser.close()
391
+ if ui.stopped():
392
+ msg = 'Sign-in cancelled.'
393
+ raise SystemExit(msg) from e
394
+ raise
395
+ ui.emit(
396
+ f'Sign in to {settings.server}, site {settings.site or "(default)"} '
397
+ f'(local name {settings.name}). The window closes once you are in.'
398
+ )
399
+ try:
400
+ cancelled_login = _wait_for_sign_in(page, context, settings)
401
+ except PlaywrightError as e:
402
+ browser.close()
403
+ if ui.stopped():
404
+ msg = 'Sign-in cancelled.'
405
+ raise SystemExit(msg) from e
406
+ msg = 'The browser closed before sign-in finished.'
407
+ raise SystemExit(msg) from e
408
+ if cancelled_login:
409
+ browser.close()
410
+ msg = 'Sign-in cancelled.'
411
+ raise SystemExit(msg)
412
+ settings.auth_path.parent.mkdir(parents=True, exist_ok=True)
413
+ context.storage_state(path=settings.auth_path)
414
+ settings.auth_path.chmod(0o600)
415
+ browser.close()
416
+ ui.emit(f'Saved browser session to {settings.auth_path}')
417
+
418
+
419
+ def _saved_context(browser: Browser, settings: Settings) -> BrowserContext | None:
420
+ if not settings.auth_path.exists():
421
+ return None
422
+ context = browser.new_context(storage_state=settings.auth_path)
423
+ try:
424
+ valid = session_valid(context, settings)
425
+ except PlaywrightError:
426
+ context.close()
427
+ raise
428
+ if valid:
429
+ return context
430
+ context.close()
431
+ return None
432
+
433
+
434
+ def _expired_session(settings: Settings) -> SystemExit:
435
+ return SystemExit(
436
+ f'Tableau browser session for site {settings.name!r} is missing or expired. '
437
+ f'Run: tabpull login --site {settings.name}'
438
+ )
439
+
440
+
441
+ def saved_session(browser: Browser, settings: Settings) -> BrowserContext:
442
+ """A new context in browser from the site's saved session, never a sign-in window."""
443
+ saved = _saved_context(browser, settings)
444
+ if saved is None:
445
+ raise _expired_session(settings)
446
+ return saved
447
+
448
+
449
+ def browser_session(pw: Playwright, settings: Settings) -> BrowserContext:
450
+ browser = launch_browser(pw, headless=True)
451
+ saved = _saved_context(browser, settings)
452
+ if saved is not None:
453
+ return saved
454
+ if sys.stdin.isatty():
455
+ sso_login(pw, settings)
456
+ return browser.new_context(storage_state=settings.auth_path)
457
+ raise _expired_session(settings)
@@ -0,0 +1,3 @@
1
+ from tabpull.tui.app import run_tui
2
+
3
+ __all__ = ['run_tui']