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/filters.py ADDED
@@ -0,0 +1,172 @@
1
+ import re
2
+ from collections.abc import Sequence
3
+ from dataclasses import replace
4
+ from datetime import date
5
+
6
+ from tabpull import ui
7
+ from tabpull.jobs import Job, JobError, RangeFilter, ValuesFilter
8
+
9
+ # Tableau's range-filter API only accepts a Date or a number.
10
+ _US_DATE = re.compile(r'^(\d{1,2})/(\d{1,2})/(\d{4})$')
11
+ _ISO_DATE = re.compile(r'^(\d{4})-(\d{2})-(\d{2})$')
12
+ _NUMBER = re.compile(r'^-?\d+(\.\d+)?$')
13
+
14
+
15
+ def resolved_filters(job: Job) -> list[ValuesFilter | RangeFilter]:
16
+ if not job.sheets:
17
+ msg = f'job {job.name!r}: needs at least one sheet'
18
+ raise JobError(msg)
19
+ default = job.sheets[0]
20
+ resolved: list[ValuesFilter | RangeFilter] = []
21
+ for item in job.filters:
22
+ if item.sheet:
23
+ resolved.append(item)
24
+ continue
25
+ _note_default_sheet(job, item.field, default)
26
+ resolved.append(replace(item, sheet=default))
27
+ return resolved
28
+
29
+
30
+ def _note_default_sheet(job: Job, field_name: str, sheet: str) -> None:
31
+ ui.emit(
32
+ f' {job.name}: filter {field_name!r} names no sheet; '
33
+ f'applying it on {sheet!r}, the first sheet in the job.'
34
+ )
35
+
36
+
37
+ def same_filter(
38
+ item: ValuesFilter | RangeFilter,
39
+ existing: ValuesFilter | RangeFilter,
40
+ default_sheet: str,
41
+ ) -> bool:
42
+ """Report whether `item` is the same filter row as `existing`.
43
+
44
+ Field names must match. A sheetless item matches that field on every
45
+ sheet, which is how a run override with no `` @Sheet`` replaces it
46
+ everywhere. Otherwise the saved sheet, or the job's first sheet when
47
+ the saved row names none, must equal the item's sheet.
48
+ """
49
+ return existing.field == item.field and (
50
+ not item.sheet or (existing.sheet or default_sheet) == item.sheet
51
+ )
52
+
53
+
54
+ def filters_for_run(job: Job, overrides: Sequence[ValuesFilter | RangeFilter]) -> Job:
55
+ if not overrides:
56
+ return job
57
+ default = job.sheets[0]
58
+ filters: list[ValuesFilter | RangeFilter] = list(job.filters)
59
+ for item in overrides:
60
+ if any(same_filter(item, existing, default) for existing in filters):
61
+ filters = [
62
+ replace(item, sheet=item.sheet or existing.sheet)
63
+ if same_filter(item, existing, default)
64
+ else existing
65
+ for existing in filters
66
+ ]
67
+ continue
68
+ if not item.sheet:
69
+ _note_default_sheet(job, item.field, default)
70
+ filters.append(replace(item, sheet=item.sheet or default))
71
+ return replace(job, filters=filters)
72
+
73
+
74
+ def params_for_run(job: Job, overrides: Sequence[tuple[str, str]]) -> Job:
75
+ if not overrides:
76
+ return job
77
+ return replace(job, params={**job.params, **dict(overrides)})
78
+
79
+
80
+ def _calendar_day(year: int, month: int, day: int, original: str) -> date:
81
+ try:
82
+ return date(year, month, day)
83
+ except ValueError:
84
+ msg = f'{original!r} is not a real date; use YYYY-MM-DD or M/D/YYYY'
85
+ raise JobError(msg) from None
86
+
87
+
88
+ def normalize_range_bound(value: str | None) -> str | None:
89
+ if value is None:
90
+ return None
91
+ text = value.strip()
92
+ if not text:
93
+ return None
94
+ if _NUMBER.fullmatch(text):
95
+ return text
96
+ us = _US_DATE.fullmatch(text)
97
+ if us is not None:
98
+ month, day, year = (int(part) for part in us.groups())
99
+ return _calendar_day(year, month, day, value).isoformat()
100
+ iso = _ISO_DATE.fullmatch(text)
101
+ if iso is not None:
102
+ year, month, day = (int(part) for part in iso.groups())
103
+ return _calendar_day(year, month, day, value).isoformat()
104
+ msg = (
105
+ f'{value!r} is a relative date or a date computed at run time; '
106
+ 'use YYYY-MM-DD or M/D/YYYY'
107
+ )
108
+ raise JobError(msg)
109
+
110
+
111
+ def split_values(value: str) -> list[str]:
112
+ return [v.strip() for v in value.split('|')]
113
+
114
+
115
+ def parse_filter_spec(spec: str) -> ValuesFilter | RangeFilter:
116
+ body, sep, sheet = spec.rpartition(' @')
117
+ if not sep:
118
+ body, sheet = spec, ''
119
+ field_name, eq, value = body.partition('=')
120
+ sheet = sheet.strip()
121
+ if not eq or not field_name.strip():
122
+ msg = (
123
+ f'filter {spec!r} should look like Field=a|b or Field=min..max, '
124
+ 'with an optional " @Sheet"'
125
+ )
126
+ raise JobError(msg)
127
+ if '..' in value:
128
+ low, _, high = value.partition('..')
129
+ if not low.strip() and not high.strip():
130
+ msg = (
131
+ f'filter {spec!r} should look like Field=a|b or Field=min..max, '
132
+ 'with an optional " @Sheet"'
133
+ )
134
+ raise JobError(msg)
135
+ return make_filter(field_name, sheet, low=low, high=high)
136
+ return make_filter(field_name, sheet, values=value)
137
+
138
+
139
+ def make_filter(
140
+ field_name: str,
141
+ sheet: str,
142
+ *,
143
+ values: str | None = None,
144
+ low: str | None = None,
145
+ high: str | None = None,
146
+ ) -> ValuesFilter | RangeFilter:
147
+ sheet = sheet.strip()
148
+ if not field_name.strip():
149
+ msg = 'filter needs a field name'
150
+ raise JobError(msg)
151
+ if values is not None:
152
+ return ValuesFilter(field_name, split_values(values), sheet)
153
+ low, high = (low or '').strip() or None, (high or '').strip() or None
154
+ if low is None and high is None:
155
+ msg = f'range filter {field_name!r} needs a min, a max, or both'
156
+ raise JobError(msg)
157
+ normalize_range_bound(low)
158
+ normalize_range_bound(high)
159
+ return RangeFilter(field_name, sheet, low, high)
160
+
161
+
162
+ def format_filter(item: ValuesFilter | RangeFilter) -> str:
163
+ spec = f'{item.field}={item.shown}'
164
+ return f'{spec} @{item.sheet}' if item.sheet else spec
165
+
166
+
167
+ def parse_param_spec(spec: str) -> tuple[str, str]:
168
+ key, sep, value = spec.partition('=')
169
+ if not sep or not key.strip():
170
+ msg = f'param {spec!r} should look like Name=value'
171
+ raise JobError(msg)
172
+ return key.strip(), value.strip()
tabpull/home.py ADDED
@@ -0,0 +1,187 @@
1
+ import argparse
2
+ import json
3
+ import re
4
+ import shlex
5
+ import sys
6
+ import tomllib
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+
10
+ from rich import box
11
+ from rich.markup import escape
12
+ from rich.panel import Panel
13
+ from rich.table import Table
14
+
15
+ from tabpull import ui
16
+ from tabpull.jobs import Job, JobError, load_jobs
17
+ from tabpull.tableau import MissingSettingsError, list_sites, load_site
18
+
19
+ _TOON_NUMBER = re.compile(r'^[+-]?[0-9]+(?:\.[0-9]+)?(?:e[+-]?[0-9]+)?$', re.IGNORECASE)
20
+ _TOON_QUOTE = re.compile(r'[,:"\\\[\]{}\x00-\x1f]')
21
+
22
+
23
+ def _toon(value: object) -> str:
24
+ text = str(value)
25
+ if isinstance(value, int):
26
+ return text
27
+ if (
28
+ text in {'', 'true', 'false', 'null'}
29
+ or text != text.strip(' \t')
30
+ or text.startswith(('-', '#'))
31
+ or _TOON_NUMBER.match(text)
32
+ or _TOON_QUOTE.search(text)
33
+ ):
34
+ return json.dumps(text, ensure_ascii=False)
35
+ return text
36
+
37
+
38
+ def _toon_table(
39
+ name: str, fields: Sequence[str], rows: Sequence[Sequence[object]]
40
+ ) -> list[str]:
41
+ head = f'{name}[{len(rows)}]{{{",".join(fields)}}}:'
42
+ return [head, *(' ' + ','.join(map(_toon, row)) for row in rows)]
43
+
44
+
45
+ def file_flags(args: argparse.Namespace) -> list[str]:
46
+ flags = []
47
+ if args.jobs:
48
+ flags += ['--jobs', str(args.jobs)]
49
+ if args.out:
50
+ flags += ['--out', str(args.out)]
51
+ return flags
52
+
53
+
54
+ def sso_badge(name: str, server: str) -> str:
55
+ if server == '(incomplete)':
56
+ return '[red]incomplete[/]'
57
+ if load_site(name).auth_path.exists():
58
+ return '[green]● saved[/]'
59
+ return '[yellow]● not signed in[/]'
60
+
61
+
62
+ def _home_help(
63
+ args: argparse.Namespace,
64
+ sites: Sequence[Sequence[str]],
65
+ jobs: Sequence[Job],
66
+ ) -> list[str]:
67
+ help_lines: list[str] = []
68
+ if not sites:
69
+ help_lines.append('Run `tabpull setup` to connect a Tableau site')
70
+ quoted = shlex.join(file_flags(args))
71
+ flags = f' {quoted}' if quoted else ''
72
+ if jobs:
73
+ help_lines += [
74
+ f'Run `tabpull run{flags}` to export every job into the out folder',
75
+ f'Run `tabpull run <name>{flags}` to export one job',
76
+ ]
77
+ if sites:
78
+ jobs_flag = f' --jobs {shlex.quote(str(args.jobs))}' if args.jobs else ''
79
+ help_lines.append(
80
+ f'Run `tabpull add --view <Workbook/View> --sheet "<sheet>"{jobs_flag}` to save a job'
81
+ )
82
+ return help_lines
83
+
84
+
85
+ def _print_home(
86
+ sites: Sequence[Sequence[str]],
87
+ jobs: Sequence[Job],
88
+ jobs_error: str | None,
89
+ jobs_file: Path,
90
+ out_dir: Path,
91
+ ) -> None:
92
+ if sites:
93
+ sites_table = Table(box=box.SIMPLE_HEAD, expand=True)
94
+ for col in ('site', 'server', 'tableau site', 'sso'):
95
+ sites_table.add_column(col)
96
+ for name, server, site in sites:
97
+ sites_table.add_row(
98
+ f'[bold]{escape(name)}[/]',
99
+ escape(server),
100
+ escape(site),
101
+ sso_badge(name, server),
102
+ )
103
+ sites_body: Table | str = sites_table
104
+ else:
105
+ sites_body = '0 configured'
106
+ ui.console.print(
107
+ Panel(
108
+ sites_body,
109
+ title='[bold]Sites[/]',
110
+ title_align='left',
111
+ border_style='blue',
112
+ )
113
+ )
114
+ jobs_body: Table | str
115
+ if jobs_error is not None:
116
+ jobs_body = escape(jobs_error)
117
+ elif jobs:
118
+ jobs_table = Table(box=box.SIMPLE_HEAD, expand=True)
119
+ for col in ('job', 'site', 'view', 'sheets', 'filters'):
120
+ jobs_table.add_column(col)
121
+ for job in jobs:
122
+ jobs_table.add_row(
123
+ f'[bold]{escape(job.name)}[/]',
124
+ escape(job.site),
125
+ escape(job.view),
126
+ '\n'.join(escape(sheet) for sheet in job.sheets),
127
+ str(len(job.filters)),
128
+ )
129
+ jobs_body = jobs_table
130
+ else:
131
+ jobs_body = '0 saved'
132
+ ui.console.print(
133
+ Panel(jobs_body, title='[bold]Jobs[/]', title_align='left', border_style='blue')
134
+ )
135
+ ui.console.print(
136
+ f' [dim]jobs file[/] {escape(ui.home_path(jobs_file))}\n'
137
+ f' [dim]exports [/] {escape(ui.home_path(out_dir))}/<job>/<sheet>.csv'
138
+ )
139
+
140
+
141
+ def site_rows() -> list[list[str]]:
142
+ rows = []
143
+ for name in list_sites():
144
+ try:
145
+ settings = load_site(name)
146
+ except (MissingSettingsError, ValueError):
147
+ rows.append([name, '(incomplete)', ''])
148
+ else:
149
+ rows.append([name, settings.server, settings.site])
150
+ return rows
151
+
152
+
153
+ def show_home(args: argparse.Namespace, jobs_file: Path, out_dir: Path) -> None:
154
+ sites = site_rows()
155
+ jobs_error: str | None = None
156
+ try:
157
+ jobs = load_jobs(jobs_file)
158
+ except (JobError, tomllib.TOMLDecodeError, OSError) as e:
159
+ jobs_error = f'unreadable: {e}'
160
+ jobs = []
161
+ help_lines = _home_help(args, sites, jobs)
162
+ if ui.rich_output():
163
+ _print_home(sites, jobs, jobs_error, jobs_file, out_dir)
164
+ if help_lines:
165
+ body = '\n'.join(f' {escape(line)}' for line in help_lines)
166
+ ui.console.print(f'\n [bold]next[/]\n{body}\n')
167
+ return
168
+ lines = [
169
+ f'bin: {_toon(ui.home_path(sys.argv[0]))}',
170
+ 'description: Export Tableau dashboard sheet crosstabs to CSV',
171
+ f'jobs_file: {_toon(ui.home_path(jobs_file))}',
172
+ f'out: {_toon(ui.home_path(out_dir))}',
173
+ ]
174
+ if sites:
175
+ lines += _toon_table('sites', ('name', 'server', 'site'), sites)
176
+ else:
177
+ lines.append('sites: 0 configured')
178
+ if jobs_error is not None:
179
+ lines.append(f'jobs: {_toon(jobs_error)}')
180
+ elif jobs:
181
+ rows = [[j.name, j.site, j.view, len(j.sheets)] for j in jobs]
182
+ lines += _toon_table('jobs', ('name', 'site', 'view', 'sheets'), rows)
183
+ else:
184
+ lines.append('jobs: 0 saved')
185
+ lines.append(f'help[{len(help_lines)}]:')
186
+ lines += [f' {line}' for line in help_lines]
187
+ print('\n'.join(lines))
tabpull/jobs.py ADDED
@@ -0,0 +1,201 @@
1
+ import json
2
+ import re
3
+ import tomllib
4
+ from collections.abc import Sequence
5
+ from dataclasses import asdict, dataclass, field
6
+ from pathlib import Path
7
+ from typing import ClassVar
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class ValuesFilter:
12
+ field: str
13
+ values: list[str]
14
+ sheet: str = ''
15
+ kind: ClassVar[str] = 'values'
16
+
17
+ @property
18
+ def pick_list(self) -> str:
19
+ return '|'.join(self.values)
20
+
21
+ @property
22
+ def bounds(self) -> tuple[str, str]:
23
+ return '', ''
24
+
25
+ @property
26
+ def shown(self) -> str:
27
+ return self.pick_list
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class RangeFilter:
32
+ field: str
33
+ sheet: str
34
+ min: str | None = None
35
+ max: str | None = None
36
+ kind: ClassVar[str] = 'range'
37
+
38
+ @property
39
+ def pick_list(self) -> str:
40
+ return ''
41
+
42
+ @property
43
+ def bounds(self) -> tuple[str, str]:
44
+ return self.min or '', self.max or ''
45
+
46
+ @property
47
+ def shown(self) -> str:
48
+ return '..'.join(self.bounds)
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Job:
53
+ name: str
54
+ view: str
55
+ sheets: list[str]
56
+ site: str
57
+ filters: list[ValuesFilter | RangeFilter] = field(default_factory=list)
58
+ params: dict[str, str] = field(default_factory=dict)
59
+
60
+
61
+ class JobError(Exception):
62
+ pass
63
+
64
+
65
+ def _parse_filter(raw: object) -> ValuesFilter | RangeFilter:
66
+ if not isinstance(raw, dict):
67
+ msg = f'filter {raw!r} is not a table'
68
+ raise JobError(msg)
69
+ raw = dict(raw)
70
+ raw['sheet'] = str(raw.get('sheet') or '').strip()
71
+ if 'values' not in raw:
72
+ bounds = [raw.get('min'), raw.get('max')]
73
+ if any(bound is not None and not isinstance(bound, str) for bound in bounds):
74
+ msg = (
75
+ f'range filter {raw.get("field")!r}: quote min and max, '
76
+ 'as in min = "2024-01-01"'
77
+ )
78
+ raise JobError(msg)
79
+ if not any((bound or '').strip() for bound in bounds):
80
+ msg = f'range filter {raw.get("field")!r} needs a min, a max, or both'
81
+ raise JobError(msg)
82
+ try:
83
+ return ValuesFilter(**raw) if 'values' in raw else RangeFilter(**raw)
84
+ except TypeError as e:
85
+ msg = str(e)
86
+ raise JobError(msg) from e
87
+
88
+
89
+ def parse_job(raw: object) -> Job:
90
+ if not isinstance(raw, dict):
91
+ msg = f'job {raw!r} is not a table'
92
+ raise JobError(msg)
93
+ raw = dict(raw)
94
+ name = raw.get('name', '?')
95
+ if not raw.get('sheets'):
96
+ msg = f'job {name!r}: needs at least one sheet'
97
+ raise JobError(msg)
98
+ site = raw.get('site')
99
+ if not isinstance(site, str) or not site.strip():
100
+ msg = f'job {name!r}: needs a site'
101
+ raise JobError(msg)
102
+ raw['site'] = site.strip()
103
+ try:
104
+ filters = [_parse_filter(item) for item in raw.pop('filters', [])]
105
+ return Job(**raw, filters=filters)
106
+ except (TypeError, KeyError, JobError) as e:
107
+ msg = f'job {raw.get("name", "?")!r}: {e}'
108
+ raise JobError(msg) from e
109
+
110
+
111
+ def _jobs_from_text(text: str) -> list[Job]:
112
+ raw_jobs = tomllib.loads(text).get('job', [])
113
+ if not isinstance(raw_jobs, list):
114
+ msg = '`job` must be an array of tables'
115
+ raise JobError(msg)
116
+ return [parse_job(raw) for raw in raw_jobs]
117
+
118
+
119
+ def load_jobs(path: Path) -> list[Job]:
120
+ if not path.exists():
121
+ return []
122
+ return _jobs_from_text(path.read_text(encoding='utf-8'))
123
+
124
+
125
+ def save_jobs(path: Path, jobs: Sequence[Job]) -> None:
126
+ text = '\n'.join(job_to_toml(job) for job in jobs)
127
+ _jobs_from_text(text)
128
+ path.parent.mkdir(parents=True, exist_ok=True)
129
+ scratch = path.with_name(path.name + '.tmp')
130
+ scratch.write_text(text, encoding='utf-8')
131
+ scratch.replace(path)
132
+
133
+
134
+ def saved_jobs_or_exit(path: Path) -> list[Job]:
135
+ try:
136
+ jobs = load_jobs(path)
137
+ except (JobError, tomllib.TOMLDecodeError, OSError) as e:
138
+ msg = f'{path}: {e}'
139
+ raise SystemExit(msg) from e
140
+ if not jobs:
141
+ msg = f'No jobs in {path}. Run: tabpull add'
142
+ raise SystemExit(msg)
143
+ return jobs
144
+
145
+
146
+ def check_known(names: Sequence[str], jobs: Sequence[Job]) -> None:
147
+ if unknown := sorted(set(names) - {job.name for job in jobs}):
148
+ saved = ', '.join(job.name for job in jobs) or 'none'
149
+ msg = f'Unknown jobs: {", ".join(unknown)}. Saved jobs: {saved}'
150
+ raise JobError(msg)
151
+
152
+
153
+ def update_job(path: Path, old_name: str, job: Job) -> list[Job]:
154
+ jobs = load_jobs(path)
155
+ check_known([old_name], jobs)
156
+ index = next(i for i, saved in enumerate(jobs) if saved.name == old_name)
157
+ if job.name != old_name and any(saved.name == job.name for saved in jobs):
158
+ msg = f'A job named {job.name!r} already exists in {path}.'
159
+ raise JobError(msg)
160
+ jobs[index] = job
161
+ save_jobs(path, jobs)
162
+ return jobs
163
+
164
+
165
+ def delete_jobs(path: Path, names: Sequence[str]) -> list[Job]:
166
+ jobs = load_jobs(path)
167
+ check_known(names, jobs)
168
+ kept = [job for job in jobs if job.name not in names]
169
+ save_jobs(path, kept)
170
+ return kept
171
+
172
+
173
+ def _toml(value: object) -> str:
174
+ if isinstance(value, list):
175
+ return '[' + ', '.join(_toml(v) for v in value) + ']'
176
+ if isinstance(value, dict):
177
+ return (
178
+ '{ '
179
+ + ', '.join(f'{json.dumps(k)} = {_toml(v)}' for k, v in value.items())
180
+ + ' }'
181
+ )
182
+ return json.dumps(value, ensure_ascii=False)
183
+
184
+
185
+ def job_to_toml(job: Job) -> str:
186
+ fields = asdict(job)
187
+ fields['filters'] = [
188
+ {k: v for k, v in item.items() if v or isinstance(v, list)}
189
+ for item in fields['filters']
190
+ ]
191
+ lines = [
192
+ '[[job]]',
193
+ f'name = {_toml(fields.pop("name"))}',
194
+ f'site = {_toml(fields.pop("site"))}',
195
+ ]
196
+ lines += [f'{key} = {_toml(value)}' for key, value in fields.items() if value]
197
+ return '\n'.join(lines) + '\n'
198
+
199
+
200
+ def slug(text: str) -> str:
201
+ return re.sub(r'[^\w.-]+', '_', text).strip('_') or 'export'
tabpull/remove.py ADDED
@@ -0,0 +1,88 @@
1
+ import tomllib
2
+ from collections.abc import Sequence
3
+ from pathlib import Path
4
+
5
+ import questionary
6
+ from rich.markup import escape
7
+ from rich.panel import Panel
8
+
9
+ from tabpull import ui
10
+ from tabpull.jobs import Job, JobError, delete_jobs, saved_jobs_or_exit
11
+
12
+
13
+ def _saved(jobs: Sequence[Job]) -> str:
14
+ return ', '.join(job.name for job in jobs)
15
+
16
+
17
+ def _pick(jobs: Sequence[Job]) -> list[str]:
18
+ picked = ui.ask(
19
+ questionary.checkbox(
20
+ 'Jobs to remove',
21
+ choices=[
22
+ questionary.Choice(
23
+ f'{job.name} {job.site} {job.view}', value=job.name
24
+ )
25
+ for job in jobs
26
+ ],
27
+ style=ui.STYLE,
28
+ )
29
+ )
30
+ if not picked:
31
+ return []
32
+ shown = ', '.join(picked)
33
+ if not ui.ask(
34
+ questionary.confirm(f'Remove {shown}?', default=False, style=ui.STYLE)
35
+ ):
36
+ return []
37
+ return list(picked)
38
+
39
+
40
+ def _names_to_remove(jobs: Sequence[Job], names: Sequence[str]) -> list[str]:
41
+ if names:
42
+ return list(names)
43
+ if not ui.interactive():
44
+ msg = f'Pass job names to remove. Saved jobs: {_saved(jobs)}'
45
+ raise SystemExit(msg)
46
+ return _pick(jobs)
47
+
48
+
49
+ def _report(path: Path, removed: Sequence[Job]) -> None:
50
+ names = ', '.join(job.name for job in removed)
51
+ if not ui.rich_output():
52
+ print(f'Removed {names} from {path}')
53
+ return
54
+ lines = [
55
+ f'[bold]{escape(job.name)}[/] {escape(job.site)} {escape(job.view)}'
56
+ for job in removed
57
+ ]
58
+ lines.append(escape(str(path)))
59
+ ui.console.print(
60
+ Panel(
61
+ '\n'.join(lines),
62
+ title='[green]✓ Removed[/]',
63
+ title_align='left',
64
+ border_style='green',
65
+ )
66
+ )
67
+
68
+
69
+ def _rewrite(path: Path, jobs: Sequence[Job], names: Sequence[str]) -> None:
70
+ drop = set(names)
71
+ removed = [job for job in jobs if job.name in drop]
72
+ try:
73
+ delete_jobs(path, names)
74
+ except JobError as e:
75
+ raise SystemExit(str(e)) from e
76
+ except (tomllib.TOMLDecodeError, OSError) as e:
77
+ msg = f'{path}: {e}'
78
+ raise SystemExit(msg) from e
79
+ _report(path, removed)
80
+
81
+
82
+ def remove_jobs(path: Path, names: Sequence[str]) -> None:
83
+ jobs = saved_jobs_or_exit(path)
84
+ chosen = _names_to_remove(jobs, names)
85
+ if not chosen:
86
+ print('Nothing removed.')
87
+ return
88
+ _rewrite(path, jobs, chosen)