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