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/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