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/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)
|
tabpull/tui/__init__.py
ADDED