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/run.py
ADDED
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import multiprocessing
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
import tempfile
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import unicodedata
|
|
9
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
10
|
+
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
|
11
|
+
from contextlib import AbstractContextManager, contextmanager, nullcontext, suppress
|
|
12
|
+
from functools import partial
|
|
13
|
+
from multiprocessing.process import BaseProcess
|
|
14
|
+
from multiprocessing.queues import Queue as ProcessQueue
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from queue import Empty, Queue
|
|
17
|
+
from typing import Protocol
|
|
18
|
+
|
|
19
|
+
from playwright.sync_api import Browser, Playwright, sync_playwright
|
|
20
|
+
from playwright.sync_api import Error as PlaywrightError
|
|
21
|
+
from rich import box
|
|
22
|
+
from rich.markup import escape
|
|
23
|
+
from rich.panel import Panel
|
|
24
|
+
from rich.progress import (
|
|
25
|
+
BarColumn,
|
|
26
|
+
Progress,
|
|
27
|
+
SpinnerColumn,
|
|
28
|
+
TaskID,
|
|
29
|
+
TextColumn,
|
|
30
|
+
TimeElapsedColumn,
|
|
31
|
+
)
|
|
32
|
+
from rich.rule import Rule
|
|
33
|
+
from rich.table import Table
|
|
34
|
+
|
|
35
|
+
from tabpull import ui
|
|
36
|
+
from tabpull.embed import export_embed
|
|
37
|
+
from tabpull.jobs import Job, JobError, slug
|
|
38
|
+
from tabpull.tableau import (
|
|
39
|
+
MissingSettingsError,
|
|
40
|
+
UnknownSiteError,
|
|
41
|
+
browser_session,
|
|
42
|
+
close_on_stop,
|
|
43
|
+
launch_browser,
|
|
44
|
+
load_site,
|
|
45
|
+
saved_session,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _first_line(message: str) -> str:
|
|
50
|
+
return message.partition('\n')[0] or message
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@contextmanager
|
|
54
|
+
def _hold_sigint() -> Iterator[None]:
|
|
55
|
+
# KeyboardInterrupt inside a blocked sync Playwright call sticks driver
|
|
56
|
+
# shutdown, so Ctrl-C never returns to the shell. Hold the signal here.
|
|
57
|
+
# The driver is in this process group and still closes the browser; the
|
|
58
|
+
# interrupt is delivered once that shutdown has finished.
|
|
59
|
+
block = getattr(signal, 'pthread_sigmask', None)
|
|
60
|
+
if block is None or threading.current_thread() is not threading.main_thread():
|
|
61
|
+
yield
|
|
62
|
+
return
|
|
63
|
+
block(signal.SIG_BLOCK, {signal.SIGINT})
|
|
64
|
+
try:
|
|
65
|
+
yield
|
|
66
|
+
finally:
|
|
67
|
+
block(signal.SIG_UNBLOCK, {signal.SIGINT})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _interrupted() -> bool:
|
|
71
|
+
if ui.stopped():
|
|
72
|
+
return True
|
|
73
|
+
if threading.current_thread() is not threading.main_thread():
|
|
74
|
+
return False
|
|
75
|
+
pending = getattr(signal, 'sigpending', None)
|
|
76
|
+
return bool(pending and signal.SIGINT in pending())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _PlainRun:
|
|
80
|
+
def __init__(self, jobs: Sequence[Job], out_dir: Path) -> None:
|
|
81
|
+
print(f'Exporting {len(jobs)} job(s) to {out_dir}/')
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def live() -> AbstractContextManager[object]:
|
|
85
|
+
return nullcontext()
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def sheet(job: Job, done: int, sheet: str) -> None:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def ok(job: Job, paths: Sequence[Path]) -> None:
|
|
93
|
+
print(f' ✓ {job.name}: {", ".join(map(str, paths))}')
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def fail(job: Job, message: str) -> None:
|
|
97
|
+
print(f' ✗ {job.name}: {_first_line(message)}')
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def summary(ok: int, total: int, rerun: str | None) -> None:
|
|
101
|
+
print(f'done: {ok}/{total} jobs exported')
|
|
102
|
+
if rerun:
|
|
103
|
+
print(f'help: fix the failed jobs, then rerun them: {rerun}')
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
_BAR_REASON_MAX_CHARS = 48
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class _TTYRun:
|
|
110
|
+
def __init__(self, jobs: Sequence[Job], out_dir: Path) -> None:
|
|
111
|
+
ui.console.print(Rule('[bold]tabpull run', align='left', style='blue'))
|
|
112
|
+
ui.console.print(f'Exporting {len(jobs)} job(s) to {escape(str(out_dir))}/')
|
|
113
|
+
self.progress = Progress(
|
|
114
|
+
SpinnerColumn(),
|
|
115
|
+
TextColumn('[bold]{task.fields[job]:<12}'),
|
|
116
|
+
TextColumn('{task.description}'),
|
|
117
|
+
BarColumn(bar_width=20),
|
|
118
|
+
TextColumn('{task.completed}/{task.total} sheets'),
|
|
119
|
+
TimeElapsedColumn(),
|
|
120
|
+
console=ui.console,
|
|
121
|
+
)
|
|
122
|
+
self.tasks: dict[int, TaskID] = {
|
|
123
|
+
id(job): self.progress.add_task(
|
|
124
|
+
'queued', job=escape(job.name), total=len(job.sheets)
|
|
125
|
+
)
|
|
126
|
+
for job in jobs
|
|
127
|
+
}
|
|
128
|
+
self.rows: list[tuple[str, bool, str]] = []
|
|
129
|
+
|
|
130
|
+
def live(self) -> AbstractContextManager[object]:
|
|
131
|
+
return self.progress
|
|
132
|
+
|
|
133
|
+
def sheet(self, job: Job, done: int, sheet: str) -> None:
|
|
134
|
+
self.progress.update(
|
|
135
|
+
self.tasks[id(job)],
|
|
136
|
+
completed=done,
|
|
137
|
+
description=f'exporting {escape(sheet)}',
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def ok(self, job: Job, paths: Sequence[Path]) -> None:
|
|
141
|
+
self.progress.update(
|
|
142
|
+
self.tasks[id(job)], completed=len(paths), description='[green]done[/]'
|
|
143
|
+
)
|
|
144
|
+
self.rows.append((job.name, True, ', '.join(map(ui.home_path, paths))))
|
|
145
|
+
|
|
146
|
+
def fail(self, job: Job, message: str) -> None:
|
|
147
|
+
line = _first_line(message).strip()
|
|
148
|
+
short = line and len(line) <= _BAR_REASON_MAX_CHARS
|
|
149
|
+
self.progress.update(
|
|
150
|
+
self.tasks[id(job)],
|
|
151
|
+
description=f'[yellow]{escape(line)}[/]' if short else '[red]failed[/]',
|
|
152
|
+
)
|
|
153
|
+
self.rows.append((job.name, False, line))
|
|
154
|
+
|
|
155
|
+
def summary(self, ok: int, total: int, rerun: str | None) -> None:
|
|
156
|
+
table = Table(box=box.SIMPLE, show_header=False)
|
|
157
|
+
for name, success, info in self.rows:
|
|
158
|
+
mark = '[green]✓[/]' if success else '[red]✗[/]'
|
|
159
|
+
table.add_row(mark, f'[bold]{escape(name)}[/]', escape(info))
|
|
160
|
+
ui.console.print(
|
|
161
|
+
Panel(
|
|
162
|
+
table,
|
|
163
|
+
title=f'done: {ok}/{total} jobs exported',
|
|
164
|
+
title_align='left',
|
|
165
|
+
border_style='green' if ok == total else 'red',
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
if rerun:
|
|
169
|
+
ui.console.print(f' [dim]rerun failed:[/] [bold]{escape(rerun)}[/]\n')
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class Report(Protocol):
|
|
173
|
+
def live(self) -> AbstractContextManager[object]: ...
|
|
174
|
+
|
|
175
|
+
def sheet(self, job: Job, done: int, sheet: str) -> None: ...
|
|
176
|
+
|
|
177
|
+
def ok(self, job: Job, paths: Sequence[Path]) -> None: ...
|
|
178
|
+
|
|
179
|
+
def fail(self, job: Job, message: str) -> None: ...
|
|
180
|
+
|
|
181
|
+
def summary(self, ok: int, total: int, rerun: str | None) -> None: ...
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def open_report(jobs: Sequence[Job], out_dir: Path) -> Report:
|
|
185
|
+
return (_TTYRun if ui.rich_output() else _PlainRun)(jobs, out_dir)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _fail_all(site_jobs: Sequence[Job], message: str, report: Report) -> list[str]:
|
|
189
|
+
for job in site_jobs:
|
|
190
|
+
report.fail(job, message)
|
|
191
|
+
return [job.name for job in site_jobs]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _export_group(
|
|
195
|
+
pw: Playwright,
|
|
196
|
+
site_name: str,
|
|
197
|
+
site_jobs: Sequence[Job],
|
|
198
|
+
out_dir: Path,
|
|
199
|
+
report: Report,
|
|
200
|
+
) -> list[str]:
|
|
201
|
+
try:
|
|
202
|
+
settings = load_site(site_name)
|
|
203
|
+
except (UnknownSiteError, MissingSettingsError, ValueError) as e:
|
|
204
|
+
return _fail_all(site_jobs, str(e), report)
|
|
205
|
+
ui.emit(
|
|
206
|
+
f' site {settings.name}: {settings.server}, site {settings.site or "(default)"}'
|
|
207
|
+
)
|
|
208
|
+
try:
|
|
209
|
+
context = browser_session(pw, settings)
|
|
210
|
+
except (SystemExit, PlaywrightError) as e:
|
|
211
|
+
if _interrupted():
|
|
212
|
+
return _fail_all(site_jobs, 'Cancelled.', report)
|
|
213
|
+
if isinstance(e, PlaywrightError):
|
|
214
|
+
raise
|
|
215
|
+
message = (
|
|
216
|
+
e.code if isinstance(e.code, str) else 'could not open a browser session'
|
|
217
|
+
)
|
|
218
|
+
return _fail_all(site_jobs, message, report)
|
|
219
|
+
failed: list[str] = []
|
|
220
|
+
for job in site_jobs:
|
|
221
|
+
if _interrupted():
|
|
222
|
+
failed.append(job.name)
|
|
223
|
+
report.fail(job, 'Cancelled.')
|
|
224
|
+
continue
|
|
225
|
+
try:
|
|
226
|
+
paths = export_embed(
|
|
227
|
+
context,
|
|
228
|
+
settings,
|
|
229
|
+
job,
|
|
230
|
+
out_dir,
|
|
231
|
+
on_sheet=partial(report.sheet, job),
|
|
232
|
+
)
|
|
233
|
+
except (JobError, PlaywrightError, OSError, UnicodeError, csv.Error) as e:
|
|
234
|
+
failed.append(job.name)
|
|
235
|
+
report.fail(job, 'Cancelled.' if _interrupted() else str(e))
|
|
236
|
+
else:
|
|
237
|
+
report.ok(job, paths)
|
|
238
|
+
return failed
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
_POLL_S = 0.1
|
|
242
|
+
_CANCEL_JOIN_S = 5.0
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class _Stop(Protocol):
|
|
246
|
+
def set(self) -> None: ...
|
|
247
|
+
|
|
248
|
+
def is_set(self) -> bool: ...
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class _Running:
|
|
252
|
+
def __init__(self) -> None:
|
|
253
|
+
self.cancel = threading.Event()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _existing_dir(path: Path) -> Path:
|
|
257
|
+
current = path
|
|
258
|
+
while not current.exists():
|
|
259
|
+
parent = current.parent
|
|
260
|
+
if parent == current:
|
|
261
|
+
return current
|
|
262
|
+
current = parent
|
|
263
|
+
return current if current.is_dir() else current.parent
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _probe_same(directory: Path, name: str, other: str) -> bool | None:
|
|
267
|
+
if name == other:
|
|
268
|
+
return False
|
|
269
|
+
probe = directory / name
|
|
270
|
+
try:
|
|
271
|
+
probe.write_bytes(b'')
|
|
272
|
+
except OSError:
|
|
273
|
+
return None
|
|
274
|
+
try:
|
|
275
|
+
return (directory / other).exists()
|
|
276
|
+
finally:
|
|
277
|
+
probe.unlink(missing_ok=True)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _probe_directory(path: Path) -> Path:
|
|
281
|
+
anchor = _existing_dir(path)
|
|
282
|
+
temp = Path(tempfile.gettempdir())
|
|
283
|
+
try:
|
|
284
|
+
if (
|
|
285
|
+
temp.is_dir()
|
|
286
|
+
and anchor.exists()
|
|
287
|
+
and temp.stat().st_dev == anchor.stat().st_dev
|
|
288
|
+
):
|
|
289
|
+
return temp
|
|
290
|
+
except OSError:
|
|
291
|
+
return anchor
|
|
292
|
+
return anchor
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _volume_folds(path: Path) -> tuple[bool, bool]:
|
|
296
|
+
"""Case and Unicode-normalization folding for the volume that holds path."""
|
|
297
|
+
directory = _probe_directory(path)
|
|
298
|
+
token = f'.tabpull-{os.getpid()}-{time.monotonic_ns()}'
|
|
299
|
+
case = _probe_same(directory, f'{token}-A', f'{token}-a')
|
|
300
|
+
composed = 'é'
|
|
301
|
+
decomposed = unicodedata.normalize('NFD', composed)
|
|
302
|
+
norm = _probe_same(directory, f'{token}-{composed}', f'{token}-{decomposed}')
|
|
303
|
+
ignores_case = os.name == 'nt' if case is None else case
|
|
304
|
+
return ignores_case, bool(norm)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _output_key(path: Path, *, ignores_case: bool, ignores_norm: bool) -> str:
|
|
308
|
+
text = os.path.normpath(os.fspath(path))
|
|
309
|
+
if ignores_norm:
|
|
310
|
+
text = unicodedata.normalize('NFC', text)
|
|
311
|
+
if ignores_case:
|
|
312
|
+
text = text.casefold()
|
|
313
|
+
return text
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def shared_outputs(jobs: Sequence[Job], out_dir: Path) -> list[str]:
|
|
317
|
+
"""CSV paths that two jobs in this run would write at the same time."""
|
|
318
|
+
owners: dict[str, int] = {}
|
|
319
|
+
clashes: list[str] = []
|
|
320
|
+
ignores_case, ignores_norm = _volume_folds(out_dir) if jobs else (False, False)
|
|
321
|
+
for index, job in enumerate(jobs):
|
|
322
|
+
for sheet in job.sheets:
|
|
323
|
+
path = out_dir / slug(job.name) / f'{slug(sheet)}.csv'
|
|
324
|
+
key = _output_key(
|
|
325
|
+
path, ignores_case=ignores_case, ignores_norm=ignores_norm
|
|
326
|
+
)
|
|
327
|
+
previous = owners.get(key)
|
|
328
|
+
if previous is None:
|
|
329
|
+
owners[key] = index
|
|
330
|
+
continue
|
|
331
|
+
if previous == index:
|
|
332
|
+
continue
|
|
333
|
+
other = jobs[previous].name
|
|
334
|
+
clashes.append(f'{path} ({other}, {job.name})')
|
|
335
|
+
return clashes
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _open_sessions(jobs: Sequence[Job]) -> dict[str, str]:
|
|
339
|
+
"""Open each site's saved session once, before jobs start their own browsers."""
|
|
340
|
+
errors: dict[str, str] = {}
|
|
341
|
+
names = list(dict.fromkeys(job.site for job in jobs))
|
|
342
|
+
if not names:
|
|
343
|
+
return errors
|
|
344
|
+
with sync_playwright() as pw, close_on_stop():
|
|
345
|
+
for site_name in names:
|
|
346
|
+
if _interrupted():
|
|
347
|
+
for name in names:
|
|
348
|
+
errors.setdefault(name, 'Cancelled.')
|
|
349
|
+
return errors
|
|
350
|
+
error = _open_site(pw, site_name)
|
|
351
|
+
if error is not None:
|
|
352
|
+
errors[site_name] = error
|
|
353
|
+
return errors
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _open_site(pw: Playwright, site_name: str) -> str | None:
|
|
357
|
+
try:
|
|
358
|
+
settings = load_site(site_name)
|
|
359
|
+
except (UnknownSiteError, MissingSettingsError, ValueError) as e:
|
|
360
|
+
return str(e)
|
|
361
|
+
ui.emit(
|
|
362
|
+
f' site {settings.name}: {settings.server}, site {settings.site or "(default)"}'
|
|
363
|
+
)
|
|
364
|
+
try:
|
|
365
|
+
context = browser_session(pw, settings)
|
|
366
|
+
except (SystemExit, PlaywrightError) as e:
|
|
367
|
+
if _interrupted():
|
|
368
|
+
return 'Cancelled.'
|
|
369
|
+
if isinstance(e, PlaywrightError):
|
|
370
|
+
raise
|
|
371
|
+
if isinstance(e.code, str):
|
|
372
|
+
return e.code
|
|
373
|
+
return 'could not open a browser session'
|
|
374
|
+
else:
|
|
375
|
+
context.close()
|
|
376
|
+
return None
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _serve(
|
|
380
|
+
jobs: ProcessQueue[Job | None],
|
|
381
|
+
events: ProcessQueue[tuple[object, ...]],
|
|
382
|
+
stop: _Stop,
|
|
383
|
+
out_dir: str,
|
|
384
|
+
) -> None:
|
|
385
|
+
# Runs in its own process for the whole run: its own sync driver and one
|
|
386
|
+
# browser that exports every job this slot takes, each in a new context.
|
|
387
|
+
# The parent's stop event is polled from a thread so a blocked download
|
|
388
|
+
# still dies with the browser instead of running on after Ctrl-C.
|
|
389
|
+
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
390
|
+
stopped = {'on': False}
|
|
391
|
+
|
|
392
|
+
def watch() -> None:
|
|
393
|
+
while not stopped['on']:
|
|
394
|
+
if stop.is_set():
|
|
395
|
+
stopped['on'] = True
|
|
396
|
+
return
|
|
397
|
+
time.sleep(_POLL_S)
|
|
398
|
+
|
|
399
|
+
watcher = threading.Thread(target=watch, name='tabpull-job-cancel', daemon=True)
|
|
400
|
+
watcher.start()
|
|
401
|
+
try:
|
|
402
|
+
with ui.capture(lambda text: events.put(('note', text)), lambda: stopped['on']):
|
|
403
|
+
_serve_jobs(jobs, events, Path(out_dir))
|
|
404
|
+
finally:
|
|
405
|
+
stopped['on'] = True
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _serve_jobs(
|
|
409
|
+
jobs: ProcessQueue[Job | None],
|
|
410
|
+
events: ProcessQueue[tuple[object, ...]],
|
|
411
|
+
out_dir: Path,
|
|
412
|
+
) -> None:
|
|
413
|
+
with sync_playwright() as pw, close_on_stop():
|
|
414
|
+
browser: Browser | None = None
|
|
415
|
+
|
|
416
|
+
def open_browser() -> Browser:
|
|
417
|
+
nonlocal browser
|
|
418
|
+
if browser is None or not browser.is_connected():
|
|
419
|
+
browser = launch_browser(pw, headless=True)
|
|
420
|
+
return browser
|
|
421
|
+
|
|
422
|
+
while (job := jobs.get()) is not None:
|
|
423
|
+
events.put(_export_in(open_browser, job, out_dir, events))
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _export_in(
|
|
427
|
+
open_browser: Callable[[], Browser],
|
|
428
|
+
job: Job,
|
|
429
|
+
out_dir: Path,
|
|
430
|
+
events: ProcessQueue[tuple[object, ...]],
|
|
431
|
+
) -> tuple[object, ...]:
|
|
432
|
+
def on_sheet(done: int, sheet: str) -> None:
|
|
433
|
+
events.put(('sheet', done, sheet))
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
settings = load_site(job.site)
|
|
437
|
+
context = saved_session(open_browser(), settings)
|
|
438
|
+
try:
|
|
439
|
+
paths = export_embed(context, settings, job, out_dir, on_sheet=on_sheet)
|
|
440
|
+
finally:
|
|
441
|
+
with suppress(PlaywrightError):
|
|
442
|
+
context.close()
|
|
443
|
+
except SystemExit as e:
|
|
444
|
+
message = (
|
|
445
|
+
e.code if isinstance(e.code, str) else 'could not open a browser session'
|
|
446
|
+
)
|
|
447
|
+
return ('err', message)
|
|
448
|
+
except (
|
|
449
|
+
JobError,
|
|
450
|
+
PlaywrightError,
|
|
451
|
+
OSError,
|
|
452
|
+
UnicodeError,
|
|
453
|
+
csv.Error,
|
|
454
|
+
UnknownSiteError,
|
|
455
|
+
MissingSettingsError,
|
|
456
|
+
ValueError,
|
|
457
|
+
) as e:
|
|
458
|
+
return ('err', 'Cancelled.' if ui.stopped() else str(e))
|
|
459
|
+
return ('ok', [str(path) for path in paths])
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class _Worker:
|
|
463
|
+
"""One parallel slot's process, kept for every job that slot takes."""
|
|
464
|
+
|
|
465
|
+
def __init__(self, out_dir: Path) -> None:
|
|
466
|
+
ctx = multiprocessing.get_context('spawn')
|
|
467
|
+
self.jobs: ProcessQueue[Job | None] = ctx.Queue()
|
|
468
|
+
self.events: ProcessQueue[tuple[object, ...]] = ctx.Queue()
|
|
469
|
+
self.stop = ctx.Event()
|
|
470
|
+
self.proc = ctx.Process(
|
|
471
|
+
target=_serve,
|
|
472
|
+
args=(self.jobs, self.events, self.stop, str(out_dir)),
|
|
473
|
+
name='tabpull-worker',
|
|
474
|
+
)
|
|
475
|
+
self.proc.start()
|
|
476
|
+
|
|
477
|
+
def alive(self) -> bool:
|
|
478
|
+
return self.proc.is_alive()
|
|
479
|
+
|
|
480
|
+
def export(
|
|
481
|
+
self, job: Job, on_sheet: Callable[[int, str], None], running: _Running
|
|
482
|
+
) -> list[Path]:
|
|
483
|
+
self.jobs.put(job)
|
|
484
|
+
return _collect_export(self.proc, self.events, self.stop, on_sheet, running)
|
|
485
|
+
|
|
486
|
+
def close(self) -> None:
|
|
487
|
+
with suppress(OSError, ValueError):
|
|
488
|
+
self.jobs.put(None)
|
|
489
|
+
self.proc.join(timeout=_CANCEL_JOIN_S)
|
|
490
|
+
self.stop.set()
|
|
491
|
+
if self.proc.is_alive():
|
|
492
|
+
self.proc.terminate()
|
|
493
|
+
self.proc.join(timeout=_CANCEL_JOIN_S)
|
|
494
|
+
if self.proc.is_alive():
|
|
495
|
+
self.proc.kill()
|
|
496
|
+
self.proc.join(timeout=1)
|
|
497
|
+
for queue in (self.jobs, self.events):
|
|
498
|
+
queue.cancel_join_thread()
|
|
499
|
+
queue.close()
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _start_worker(out_dir: Path) -> _Worker:
|
|
503
|
+
return _Worker(out_dir)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
class _ExportResult:
|
|
507
|
+
def __init__(self) -> None:
|
|
508
|
+
self.paths: list[str] | None = None
|
|
509
|
+
self.error: str | None = None
|
|
510
|
+
|
|
511
|
+
@property
|
|
512
|
+
def done(self) -> bool:
|
|
513
|
+
return self.paths is not None or self.error is not None
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _note_cancel(
|
|
517
|
+
running: _Running, stop: _Stop, deadline: float | None
|
|
518
|
+
) -> float | None:
|
|
519
|
+
if not running.cancel.is_set():
|
|
520
|
+
return deadline
|
|
521
|
+
stop.set()
|
|
522
|
+
if deadline is None:
|
|
523
|
+
return time.monotonic() + _CANCEL_JOIN_S
|
|
524
|
+
if time.monotonic() > deadline:
|
|
525
|
+
msg = 'Cancelled.'
|
|
526
|
+
raise JobError(msg)
|
|
527
|
+
return deadline
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _poll_event(events: ProcessQueue[tuple[object, ...]]) -> tuple[object, ...] | None:
|
|
531
|
+
try:
|
|
532
|
+
return events.get(timeout=_POLL_S)
|
|
533
|
+
except Empty:
|
|
534
|
+
return None
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _record_event(
|
|
538
|
+
item: tuple[object, ...],
|
|
539
|
+
on_sheet: Callable[[int, str], None],
|
|
540
|
+
result: _ExportResult,
|
|
541
|
+
) -> None:
|
|
542
|
+
kind = item[0]
|
|
543
|
+
if kind == 'sheet':
|
|
544
|
+
_record_sheet(item, on_sheet)
|
|
545
|
+
return
|
|
546
|
+
if kind == 'note' and len(item) > 1:
|
|
547
|
+
ui.emit(str(item[1]))
|
|
548
|
+
return
|
|
549
|
+
if kind == 'ok' and len(item) > 1 and isinstance(item[1], list):
|
|
550
|
+
result.paths = [str(path) for path in item[1]]
|
|
551
|
+
return
|
|
552
|
+
if kind == 'err' and len(item) > 1:
|
|
553
|
+
result.error = str(item[1])
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _record_sheet(
|
|
557
|
+
item: tuple[object, ...], on_sheet: Callable[[int, str], None]
|
|
558
|
+
) -> None:
|
|
559
|
+
try:
|
|
560
|
+
_kind, done, sheet = item
|
|
561
|
+
except ValueError:
|
|
562
|
+
return
|
|
563
|
+
if isinstance(done, int) and isinstance(sheet, str):
|
|
564
|
+
on_sheet(done, sheet)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _collect_export(
|
|
568
|
+
proc: BaseProcess,
|
|
569
|
+
events: ProcessQueue[tuple[object, ...]],
|
|
570
|
+
stop: _Stop,
|
|
571
|
+
on_sheet: Callable[[int, str], None],
|
|
572
|
+
running: _Running,
|
|
573
|
+
) -> list[Path]:
|
|
574
|
+
result = _ExportResult()
|
|
575
|
+
deadline: float | None = None
|
|
576
|
+
while not result.done:
|
|
577
|
+
deadline = _note_cancel(running, stop, deadline)
|
|
578
|
+
alive = proc.is_alive()
|
|
579
|
+
item = _poll_event(events)
|
|
580
|
+
if item is None:
|
|
581
|
+
if not alive:
|
|
582
|
+
break
|
|
583
|
+
continue
|
|
584
|
+
_record_event(item, on_sheet, result)
|
|
585
|
+
return _paths_or_error(proc, result, running)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _paths_or_error(
|
|
589
|
+
proc: BaseProcess, result: _ExportResult, running: _Running
|
|
590
|
+
) -> list[Path]:
|
|
591
|
+
if result.error is not None:
|
|
592
|
+
raise JobError(result.error)
|
|
593
|
+
if result.paths is None:
|
|
594
|
+
msg = (
|
|
595
|
+
'Cancelled.'
|
|
596
|
+
if running.cancel.is_set()
|
|
597
|
+
else f'export stopped ({proc.exitcode})'
|
|
598
|
+
)
|
|
599
|
+
raise JobError(msg)
|
|
600
|
+
return [Path(path) for path in result.paths]
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _run_parallel(
|
|
604
|
+
jobs: Sequence[Job],
|
|
605
|
+
out_dir: Path,
|
|
606
|
+
report: Report,
|
|
607
|
+
parallel: int,
|
|
608
|
+
) -> list[str]:
|
|
609
|
+
with _hold_sigint(), report.live():
|
|
610
|
+
errors = _open_sessions(jobs)
|
|
611
|
+
failed_ids: set[int] = set()
|
|
612
|
+
ready: list[Job] = []
|
|
613
|
+
for job in jobs:
|
|
614
|
+
error = errors.get(job.site)
|
|
615
|
+
if error is None:
|
|
616
|
+
ready.append(job)
|
|
617
|
+
continue
|
|
618
|
+
report.fail(job, error)
|
|
619
|
+
failed_ids.add(id(job))
|
|
620
|
+
if ready and not _interrupted():
|
|
621
|
+
failed_ids.update(_export_ready(ready, out_dir, report, parallel))
|
|
622
|
+
elif ready:
|
|
623
|
+
failed_ids.update(set(_fail_ids(ready, 'Cancelled.', report)))
|
|
624
|
+
return [job.name for job in jobs if id(job) in failed_ids]
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _fail_ids(jobs: Sequence[Job], message: str, report: Report) -> list[int]:
|
|
628
|
+
_fail_all(jobs, message, report)
|
|
629
|
+
return [id(job) for job in jobs]
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _export_ready(
|
|
633
|
+
jobs: Sequence[Job],
|
|
634
|
+
out_dir: Path,
|
|
635
|
+
report: Report,
|
|
636
|
+
parallel: int,
|
|
637
|
+
) -> set[int]:
|
|
638
|
+
running = _Running()
|
|
639
|
+
report_lock = threading.Lock()
|
|
640
|
+
failed: set[int] = set()
|
|
641
|
+
todo: Queue[Job] = Queue()
|
|
642
|
+
for job in jobs:
|
|
643
|
+
todo.put(job)
|
|
644
|
+
|
|
645
|
+
def note(text: str) -> None:
|
|
646
|
+
with report_lock:
|
|
647
|
+
print(text)
|
|
648
|
+
|
|
649
|
+
def fail(job: Job, message: str) -> None:
|
|
650
|
+
with report_lock:
|
|
651
|
+
report.fail(job, message)
|
|
652
|
+
failed.add(id(job))
|
|
653
|
+
|
|
654
|
+
def work(worker: _Worker, job: Job) -> None:
|
|
655
|
+
def on_sheet(done: int, sheet: str) -> None:
|
|
656
|
+
with report_lock:
|
|
657
|
+
report.sheet(job, done, sheet)
|
|
658
|
+
|
|
659
|
+
try:
|
|
660
|
+
with ui.capture(note):
|
|
661
|
+
paths = worker.export(job, on_sheet, running)
|
|
662
|
+
except (JobError, PlaywrightError, OSError, UnicodeError, csv.Error) as e:
|
|
663
|
+
fail(job, 'Cancelled.' if running.cancel.is_set() else str(e))
|
|
664
|
+
else:
|
|
665
|
+
with report_lock:
|
|
666
|
+
report.ok(job, paths)
|
|
667
|
+
|
|
668
|
+
def slot() -> None:
|
|
669
|
+
worker: _Worker | None = None
|
|
670
|
+
try:
|
|
671
|
+
while not running.cancel.is_set():
|
|
672
|
+
try:
|
|
673
|
+
job = todo.get_nowait()
|
|
674
|
+
except Empty:
|
|
675
|
+
return
|
|
676
|
+
if worker is not None and not worker.alive():
|
|
677
|
+
worker.close()
|
|
678
|
+
worker = None
|
|
679
|
+
if worker is None:
|
|
680
|
+
try:
|
|
681
|
+
worker = _start_worker(out_dir)
|
|
682
|
+
except OSError as e:
|
|
683
|
+
fail(job, str(e))
|
|
684
|
+
continue
|
|
685
|
+
work(worker, job)
|
|
686
|
+
finally:
|
|
687
|
+
if worker is not None:
|
|
688
|
+
worker.close()
|
|
689
|
+
|
|
690
|
+
slots = min(parallel, len(jobs))
|
|
691
|
+
with ThreadPoolExecutor(
|
|
692
|
+
max_workers=slots, thread_name_prefix='tabpull-slot'
|
|
693
|
+
) as pool:
|
|
694
|
+
futures = [pool.submit(slot) for _ in range(slots)]
|
|
695
|
+
pending = set(futures)
|
|
696
|
+
while pending:
|
|
697
|
+
if _interrupted():
|
|
698
|
+
running.cancel.set()
|
|
699
|
+
break
|
|
700
|
+
_done, pending = wait(pending, timeout=_POLL_S, return_when=FIRST_COMPLETED)
|
|
701
|
+
while True:
|
|
702
|
+
try:
|
|
703
|
+
fail(todo.get_nowait(), 'Cancelled.')
|
|
704
|
+
except Empty:
|
|
705
|
+
break
|
|
706
|
+
for future in futures:
|
|
707
|
+
future.result()
|
|
708
|
+
return failed
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def run_jobs(
|
|
712
|
+
jobs: Sequence[Job],
|
|
713
|
+
out_dir: Path,
|
|
714
|
+
report: Report,
|
|
715
|
+
*,
|
|
716
|
+
parallel: int = 1,
|
|
717
|
+
) -> list[str]:
|
|
718
|
+
if parallel < 1:
|
|
719
|
+
msg = 'parallel must be at least 1'
|
|
720
|
+
raise JobError(msg)
|
|
721
|
+
if parallel == 1:
|
|
722
|
+
return _run_sequential(jobs, out_dir, report)
|
|
723
|
+
clashes = shared_outputs(jobs, out_dir)
|
|
724
|
+
if clashes:
|
|
725
|
+
msg = 'these jobs would write the same file at the same time:\n' + '\n'.join(
|
|
726
|
+
clashes
|
|
727
|
+
)
|
|
728
|
+
raise JobError(msg)
|
|
729
|
+
return _run_parallel(jobs, out_dir, report, parallel)
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _run_sequential(jobs: Sequence[Job], out_dir: Path, report: Report) -> list[str]:
|
|
733
|
+
groups: list[tuple[str, list[Job]]] = []
|
|
734
|
+
for job in jobs:
|
|
735
|
+
if groups and groups[-1][0] == job.site:
|
|
736
|
+
groups[-1][1].append(job)
|
|
737
|
+
else:
|
|
738
|
+
groups.append((job.site, [job]))
|
|
739
|
+
failed: list[str] = []
|
|
740
|
+
if not groups:
|
|
741
|
+
return failed
|
|
742
|
+
with _hold_sigint(), report.live(), sync_playwright() as pw, close_on_stop():
|
|
743
|
+
for site_name, site_jobs in groups:
|
|
744
|
+
if _interrupted():
|
|
745
|
+
failed += _fail_all(site_jobs, 'Cancelled.', report)
|
|
746
|
+
continue
|
|
747
|
+
failed += _export_group(pw, site_name, site_jobs, out_dir, report)
|
|
748
|
+
return failed
|