hypershell 2.6.4__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.
- hypershell/__init__.py +115 -0
- hypershell/client.py +1255 -0
- hypershell/cluster/__init__.py +378 -0
- hypershell/cluster/local.py +210 -0
- hypershell/cluster/remote.py +721 -0
- hypershell/cluster/ssh.py +352 -0
- hypershell/config.py +440 -0
- hypershell/core/__init__.py +4 -0
- hypershell/core/config.py +359 -0
- hypershell/core/exceptions.py +120 -0
- hypershell/core/fsm.py +82 -0
- hypershell/core/heartbeat.py +70 -0
- hypershell/core/logging.py +201 -0
- hypershell/core/platform.py +121 -0
- hypershell/core/queue.py +155 -0
- hypershell/core/remote.py +192 -0
- hypershell/core/signal.py +79 -0
- hypershell/core/sys.py +39 -0
- hypershell/core/tag.py +47 -0
- hypershell/core/template.py +201 -0
- hypershell/core/thread.py +56 -0
- hypershell/core/types.py +32 -0
- hypershell/data/__init__.py +137 -0
- hypershell/data/core.py +232 -0
- hypershell/data/model.py +674 -0
- hypershell/server.py +1207 -0
- hypershell/submit.py +815 -0
- hypershell/task.py +1141 -0
- hypershell-2.6.4.dist-info/LICENSE +201 -0
- hypershell-2.6.4.dist-info/METADATA +117 -0
- hypershell-2.6.4.dist-info/RECORD +33 -0
- hypershell-2.6.4.dist-info/WHEEL +4 -0
- hypershell-2.6.4.dist-info/entry_points.txt +4 -0
hypershell/task.py
ADDED
|
@@ -0,0 +1,1141 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025 Geoffrey Lentner
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Task based operations."""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# type annotations
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from typing import List, Dict, Callable, IO, Tuple, Any, Optional, Type, Final
|
|
10
|
+
|
|
11
|
+
# standard libs
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
import csv
|
|
16
|
+
import json
|
|
17
|
+
import time
|
|
18
|
+
import functools
|
|
19
|
+
import itertools
|
|
20
|
+
from datetime import timedelta, datetime
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from shutil import copyfileobj
|
|
23
|
+
|
|
24
|
+
# external libs
|
|
25
|
+
import yaml
|
|
26
|
+
from rich.console import Console
|
|
27
|
+
from rich.syntax import Syntax
|
|
28
|
+
from rich.table import Table
|
|
29
|
+
from cmdkit.app import Application, ApplicationGroup, exit_status
|
|
30
|
+
from cmdkit.cli import Interface, ArgumentError
|
|
31
|
+
from sqlalchemy import Column, type_coerce, JSON, text, func, distinct
|
|
32
|
+
from sqlalchemy.exc import StatementError
|
|
33
|
+
from sqlalchemy.orm import Query
|
|
34
|
+
from sqlalchemy.orm.exc import StaleDataError
|
|
35
|
+
from sqlalchemy.sql.elements import BinaryExpression
|
|
36
|
+
|
|
37
|
+
# internal libs
|
|
38
|
+
from hypershell.core.platform import default_path
|
|
39
|
+
from hypershell.core.config import config
|
|
40
|
+
from hypershell.core.exceptions import handle_exception, handle_exception_silently, get_shared_exception_mapping
|
|
41
|
+
from hypershell.core.logging import Logger, HOSTNAME
|
|
42
|
+
from hypershell.core.remote import SSHConnection
|
|
43
|
+
from hypershell.core.types import smart_coerce, JSONValue
|
|
44
|
+
from hypershell.core.tag import Tag
|
|
45
|
+
from hypershell.data.core import Session
|
|
46
|
+
from hypershell.data.model import Task, to_json_type
|
|
47
|
+
from hypershell.data import ensuredb
|
|
48
|
+
|
|
49
|
+
# public interface
|
|
50
|
+
__all__ = ['TaskGroupApp', ]
|
|
51
|
+
|
|
52
|
+
# initialize logger
|
|
53
|
+
log = Logger.with_name(__name__)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
SUBMIT_PROGRAM = 'hs task submit'
|
|
57
|
+
SUBMIT_SYNOPSIS = f'{SUBMIT_PROGRAM} [-h] [-t TAG [TAG...]] -- ARGS...'
|
|
58
|
+
SUBMIT_USAGE = f"""\
|
|
59
|
+
Usage:
|
|
60
|
+
{SUBMIT_SYNOPSIS}
|
|
61
|
+
Submit individual task to database.\
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
SUBMIT_HELP = f"""\
|
|
65
|
+
{SUBMIT_USAGE}
|
|
66
|
+
|
|
67
|
+
Arguments:
|
|
68
|
+
ARGS... Command-line arguments.
|
|
69
|
+
|
|
70
|
+
Options:
|
|
71
|
+
-t, --tag TAG... Assign tags as `key:value`.
|
|
72
|
+
-h, --help Show this message and exit.\
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TaskSubmitApp(Application):
|
|
77
|
+
"""Submit task to database."""
|
|
78
|
+
|
|
79
|
+
interface = Interface(SUBMIT_PROGRAM, SUBMIT_USAGE, SUBMIT_HELP)
|
|
80
|
+
|
|
81
|
+
argv: List[str] = []
|
|
82
|
+
interface.add_argument('argv', nargs='+')
|
|
83
|
+
|
|
84
|
+
tags: Dict[str, JSONValue] = {}
|
|
85
|
+
taglist: List[str] = []
|
|
86
|
+
interface.add_argument('-t', '--tag', nargs='*', dest='taglist')
|
|
87
|
+
|
|
88
|
+
exceptions = {
|
|
89
|
+
**get_shared_exception_mapping(__name__)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
def run(self: TaskSubmitApp) -> None:
|
|
93
|
+
"""Submit task to database."""
|
|
94
|
+
ensuredb()
|
|
95
|
+
self.check_tags()
|
|
96
|
+
task = Task.new(args=' '.join(self.argv), tag=self.tags)
|
|
97
|
+
Task.add(task)
|
|
98
|
+
print(task.id)
|
|
99
|
+
|
|
100
|
+
def check_tags(self: TaskSubmitApp) -> None:
|
|
101
|
+
"""Ensure valid tags."""
|
|
102
|
+
self.tags = {} if not self.taglist else Tag.parse_cmdline_list(self.taglist)
|
|
103
|
+
try:
|
|
104
|
+
Task.ensure_valid_tag(self.tags)
|
|
105
|
+
except (ValueError, TypeError) as error:
|
|
106
|
+
raise ArgumentError(str(error)) from error
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# Catch bad UUID before we touch the database
|
|
110
|
+
UUID_PATTERN: re.Pattern = re.compile(
|
|
111
|
+
r'^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$'
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def check_uuid(value: str) -> None:
|
|
116
|
+
"""Check for valid UUID."""
|
|
117
|
+
if not UUID_PATTERN.match(value):
|
|
118
|
+
raise ArgumentError(f'Bad UUID: \'{value}\'')
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
INFO_PROGRAM = 'hs task info'
|
|
122
|
+
INFO_SYNOPSIS = f'{INFO_PROGRAM} [-h] ID [--stdout | --stderr | -x FIELD] [-f FORMAT]'
|
|
123
|
+
INFO_USAGE = f"""\
|
|
124
|
+
Usage:
|
|
125
|
+
{INFO_SYNOPSIS}
|
|
126
|
+
Get metadata and/or task outputs.\
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
INFO_HELP = f"""\
|
|
130
|
+
{INFO_USAGE}
|
|
131
|
+
|
|
132
|
+
Arguments:
|
|
133
|
+
ID Unique task UUID.
|
|
134
|
+
|
|
135
|
+
Options:
|
|
136
|
+
-f, --format FORMAT Format task info ([normal], json, yaml).
|
|
137
|
+
--json Format task metadata as JSON.
|
|
138
|
+
--yaml Format task metadata as YAML.
|
|
139
|
+
-x, --extract FIELD Print single field.
|
|
140
|
+
--stdout Print <stdout> from task.
|
|
141
|
+
--stderr Print <stderr> from task.
|
|
142
|
+
-h, --help Show this message and exit.\
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class TaskInfoApp(Application):
|
|
147
|
+
"""Get metadata/status/outputs of task."""
|
|
148
|
+
|
|
149
|
+
interface = Interface(INFO_PROGRAM, INFO_USAGE, INFO_HELP)
|
|
150
|
+
|
|
151
|
+
uuid: str
|
|
152
|
+
interface.add_argument('uuid')
|
|
153
|
+
|
|
154
|
+
print_stdout: bool = False
|
|
155
|
+
print_stderr: bool = False
|
|
156
|
+
extract_field: str = None
|
|
157
|
+
print_interface = interface.add_mutually_exclusive_group()
|
|
158
|
+
print_interface.add_argument('--stdout', action='store_true', dest='print_stdout')
|
|
159
|
+
print_interface.add_argument('--stderr', action='store_true', dest='print_stderr')
|
|
160
|
+
print_interface.add_argument('-x', '--extract', default=None, choices=Task.columns, dest='extract_field')
|
|
161
|
+
|
|
162
|
+
output_format: str = 'normal'
|
|
163
|
+
output_formats: List[str] = ['normal', 'json', 'yaml']
|
|
164
|
+
output_interface = interface.add_mutually_exclusive_group()
|
|
165
|
+
output_interface.add_argument('-f', '--format', default=output_format, dest='output_format', choices=output_formats)
|
|
166
|
+
output_interface.add_argument('--json', action='store_const', const='json', dest='output_format')
|
|
167
|
+
output_interface.add_argument('--yaml', action='store_const', const='yaml', dest='output_format')
|
|
168
|
+
|
|
169
|
+
exceptions = {
|
|
170
|
+
Task.NotFound: functools.partial(handle_exception, logger=log, status=exit_status.runtime_error),
|
|
171
|
+
**get_shared_exception_mapping(__name__)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
def run(self: TaskInfoApp) -> None:
|
|
175
|
+
"""Get metadata/status/outputs of task."""
|
|
176
|
+
ensuredb()
|
|
177
|
+
check_uuid(self.uuid)
|
|
178
|
+
if self.extract_field:
|
|
179
|
+
self.print_field()
|
|
180
|
+
elif self.print_stdout:
|
|
181
|
+
self.print_file(self.outpath, self.task.outpath, sys.stdout)
|
|
182
|
+
elif self.print_stderr:
|
|
183
|
+
self.print_file(self.errpath, self.task.errpath, sys.stderr)
|
|
184
|
+
elif self.output_format == 'normal':
|
|
185
|
+
print_normal(self.task)
|
|
186
|
+
else:
|
|
187
|
+
self.print_formatted()
|
|
188
|
+
|
|
189
|
+
def print_field(self: TaskInfoApp) -> None:
|
|
190
|
+
"""Print single field."""
|
|
191
|
+
if self.extract_field != 'tag':
|
|
192
|
+
print(json.dumps(self.task.to_json().get(self.extract_field)).strip('"'))
|
|
193
|
+
elif self.output_format == 'normal':
|
|
194
|
+
print(', '.join(f'{k}:{v}' if v else k for k, v in self.task.tag.items()))
|
|
195
|
+
else:
|
|
196
|
+
formatter = self.format_method[self.output_format]
|
|
197
|
+
output = formatter(self.task.tag)
|
|
198
|
+
if sys.stdout.isatty():
|
|
199
|
+
output = Syntax(output, self.output_format, word_wrap=True,
|
|
200
|
+
theme=config.console.theme, background_color='default')
|
|
201
|
+
Console().print(output)
|
|
202
|
+
else:
|
|
203
|
+
print(output, file=sys.stdout, flush=True)
|
|
204
|
+
|
|
205
|
+
def print_formatted(self: TaskInfoApp) -> None:
|
|
206
|
+
"""Format and print task metadata to console."""
|
|
207
|
+
formatter = self.format_method[self.output_format]
|
|
208
|
+
output = formatter(self.task.to_json()) # NOTE: to_json() just means dict with converted value types
|
|
209
|
+
if sys.stdout.isatty():
|
|
210
|
+
output = Syntax(output, self.output_format, word_wrap=True,
|
|
211
|
+
theme = config.console.theme, background_color = 'default')
|
|
212
|
+
Console().print(output)
|
|
213
|
+
else:
|
|
214
|
+
print(output, file=sys.stdout, flush=True)
|
|
215
|
+
|
|
216
|
+
def print_file(self: TaskInfoApp, local_path: str, task_path: Optional[str], out_stream: IO) -> None:
|
|
217
|
+
"""Print file contents, fetch from client if necessary."""
|
|
218
|
+
if task_path is None:
|
|
219
|
+
raise RuntimeError(f'No {out_stream.name} for task ({self.uuid})')
|
|
220
|
+
if not os.path.exists(local_path) and self.task.client_host != HOSTNAME:
|
|
221
|
+
self.copy_remote_files()
|
|
222
|
+
with open(local_path, mode='r') as in_stream:
|
|
223
|
+
copyfileobj(in_stream, out_stream)
|
|
224
|
+
|
|
225
|
+
@functools.cached_property
|
|
226
|
+
def format_method(self: TaskInfoApp) -> Dict[str, Callable[[dict], str]]:
|
|
227
|
+
"""Format data method."""
|
|
228
|
+
return {
|
|
229
|
+
'yaml': functools.partial(yaml.dump, indent=4, sort_keys=False),
|
|
230
|
+
'json': functools.partial(json.dumps, indent=4),
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
def copy_remote_files(self: TaskInfoApp) -> None:
|
|
234
|
+
"""Copy output and error files and to local host."""
|
|
235
|
+
log.debug(f'Fetching remote files ({self.task.client_host})')
|
|
236
|
+
with SSHConnection(self.task.client_host) as remote:
|
|
237
|
+
remote.get_file(self.task.outpath, self.outpath)
|
|
238
|
+
remote.get_file(self.task.errpath, self.errpath)
|
|
239
|
+
|
|
240
|
+
@functools.cached_property
|
|
241
|
+
def task(self: TaskInfoApp) -> Task:
|
|
242
|
+
"""Look up the task from the database."""
|
|
243
|
+
return Task.from_id(self.uuid)
|
|
244
|
+
|
|
245
|
+
@functools.cached_property
|
|
246
|
+
def outpath(self: TaskInfoApp) -> str:
|
|
247
|
+
"""Local task output file path."""
|
|
248
|
+
return os.path.join(default_path.lib, 'task', f'{self.task.id}.out')
|
|
249
|
+
|
|
250
|
+
@functools.cached_property
|
|
251
|
+
def errpath(self: TaskInfoApp) -> str:
|
|
252
|
+
"""Local task error file path."""
|
|
253
|
+
return os.path.join(default_path.lib, 'task', f'{self.task.id}.err')
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# Time to wait between database queries
|
|
257
|
+
DEFAULT_INTERVAL = 5
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
WAIT_PROGRAM = 'hs task wait'
|
|
261
|
+
WAIT_SYNOPSIS = f'{WAIT_PROGRAM} [-h] ID [-n SEC] [--info [-f FORMAT] | --status | --return]'
|
|
262
|
+
WAIT_USAGE = f"""\
|
|
263
|
+
Usage:
|
|
264
|
+
{WAIT_SYNOPSIS}
|
|
265
|
+
Wait for task to complete.\
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
WAIT_HELP = f"""\
|
|
269
|
+
{WAIT_USAGE}
|
|
270
|
+
|
|
271
|
+
Arguments:
|
|
272
|
+
ID Unique UUID.
|
|
273
|
+
|
|
274
|
+
Options:
|
|
275
|
+
-n, --interval SEC Time to wait between polling (default: {DEFAULT_INTERVAL}).
|
|
276
|
+
-i, --info Print info on task.
|
|
277
|
+
-f, --format FORMAT Format task info ([normal], json, yaml).
|
|
278
|
+
--json Format info as JSON.
|
|
279
|
+
--yaml Format info as YAML.
|
|
280
|
+
-s, --status Print exit status for task.
|
|
281
|
+
-r, --return Use exit status from task.
|
|
282
|
+
-h, --help Show this message and exit.\
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class TaskWaitApp(Application):
|
|
287
|
+
"""Wait for task to complete."""
|
|
288
|
+
|
|
289
|
+
interface = Interface(WAIT_PROGRAM, WAIT_USAGE, WAIT_HELP)
|
|
290
|
+
|
|
291
|
+
uuid: str
|
|
292
|
+
interface.add_argument('uuid')
|
|
293
|
+
|
|
294
|
+
interval: int = DEFAULT_INTERVAL
|
|
295
|
+
interface.add_argument('-n', '--interval', type=int, default=interval)
|
|
296
|
+
|
|
297
|
+
print_info: bool = False
|
|
298
|
+
print_status: bool = False
|
|
299
|
+
return_status: bool = False
|
|
300
|
+
print_interface = interface.add_mutually_exclusive_group()
|
|
301
|
+
print_interface.add_argument('-i', '--info', action='store_true', dest='print_info')
|
|
302
|
+
print_interface.add_argument('-s', '--status', action='store_true', dest='print_status')
|
|
303
|
+
print_interface.add_argument('-r', '--return', action='store_true', dest='return_status')
|
|
304
|
+
|
|
305
|
+
output_format: str = 'normal'
|
|
306
|
+
output_formats: List[str] = ['normal', 'json', 'yaml']
|
|
307
|
+
output_interface = interface.add_mutually_exclusive_group()
|
|
308
|
+
output_interface.add_argument('-f', '--format', default=output_format,
|
|
309
|
+
dest='output_format', choices=output_formats)
|
|
310
|
+
output_interface.add_argument('--json', action='store_const', const='json', dest='output_format')
|
|
311
|
+
output_interface.add_argument('--yaml', action='store_const', const='yaml', dest='output_format')
|
|
312
|
+
|
|
313
|
+
class NonZeroStatus(Exception):
|
|
314
|
+
"""Exception holds non-zero exit status of returned task."""
|
|
315
|
+
|
|
316
|
+
exceptions = {
|
|
317
|
+
Task.NotFound: functools.partial(handle_exception, logger=log, status=exit_status.runtime_error),
|
|
318
|
+
NonZeroStatus: handle_exception_silently,
|
|
319
|
+
**get_shared_exception_mapping(__name__)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
def run(self: TaskWaitApp) -> None:
|
|
323
|
+
"""Wait for task to complete."""
|
|
324
|
+
ensuredb()
|
|
325
|
+
check_uuid(self.uuid)
|
|
326
|
+
self.wait_task()
|
|
327
|
+
if self.print_info:
|
|
328
|
+
TaskInfoApp(uuid=self.uuid, output_format=self.output_format).run()
|
|
329
|
+
elif self.print_status:
|
|
330
|
+
TaskInfoApp(uuid=self.uuid, extract_field='exit_status').run()
|
|
331
|
+
elif self.return_status:
|
|
332
|
+
if status := Task.from_id(self.uuid).exit_status:
|
|
333
|
+
raise self.NonZeroStatus(status)
|
|
334
|
+
|
|
335
|
+
def wait_task(self: TaskWaitApp):
|
|
336
|
+
"""Wait for the task to complete."""
|
|
337
|
+
log.info(f'Waiting on task ({self.uuid})')
|
|
338
|
+
while True:
|
|
339
|
+
task = Task.from_id(self.uuid, caching=False)
|
|
340
|
+
if task.exit_status is None:
|
|
341
|
+
log.trace(f'Waiting ({self.uuid})')
|
|
342
|
+
time.sleep(self.interval)
|
|
343
|
+
continue
|
|
344
|
+
if task.exit_status != 0:
|
|
345
|
+
log.warning(f'Non-zero exit status ({task.exit_status}) for task ({task.id})')
|
|
346
|
+
log.info(f'Task completed ({task.completion_time})')
|
|
347
|
+
break
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
RUN_PROGRAM = 'hs task run'
|
|
351
|
+
RUN_SYNOPSIS = f'{RUN_PROGRAM} [-h] [-n SEC] [-t TAG [TAG...]] -- ARGS...'
|
|
352
|
+
RUN_USAGE = f"""\
|
|
353
|
+
Usage:
|
|
354
|
+
{RUN_SYNOPSIS}
|
|
355
|
+
Submit individual task and wait for completion.\
|
|
356
|
+
"""
|
|
357
|
+
|
|
358
|
+
RUN_HELP = f"""\
|
|
359
|
+
{RUN_USAGE}
|
|
360
|
+
|
|
361
|
+
Arguments:
|
|
362
|
+
ARGS Command-line arguments.
|
|
363
|
+
|
|
364
|
+
Options:
|
|
365
|
+
-n, --interval SEC Time to wait between polling (default: {DEFAULT_INTERVAL}).
|
|
366
|
+
-t, --tag TAG... Assign tags as `key:value`.
|
|
367
|
+
-h, --help Show this message and exit.\
|
|
368
|
+
"""
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class TaskRunApp(Application):
|
|
372
|
+
"""Submit task and wait for completion."""
|
|
373
|
+
|
|
374
|
+
interface = Interface(RUN_PROGRAM, RUN_USAGE, RUN_HELP)
|
|
375
|
+
|
|
376
|
+
argv: List[str] = []
|
|
377
|
+
interface.add_argument('argv', nargs='+')
|
|
378
|
+
|
|
379
|
+
interval: int = DEFAULT_INTERVAL
|
|
380
|
+
interface.add_argument('-n', '--interval', type=int, default=interval)
|
|
381
|
+
|
|
382
|
+
taglist: List[str] = []
|
|
383
|
+
interface.add_argument('-t', '--tag', nargs='*', dest='taglist')
|
|
384
|
+
|
|
385
|
+
exceptions = {
|
|
386
|
+
**get_shared_exception_mapping(__name__)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
def run(self: TaskRunApp) -> None:
|
|
390
|
+
"""Submit task and wait for completion."""
|
|
391
|
+
ensuredb()
|
|
392
|
+
task = Task.new(args=' '.join(self.argv),
|
|
393
|
+
tag=(None if not self.taglist else Tag.parse_cmdline_list(self.taglist)))
|
|
394
|
+
Task.add(task)
|
|
395
|
+
TaskWaitApp(uuid=task.id, interval=self.interval).run()
|
|
396
|
+
TaskInfoApp(uuid=task.id, print_stdout=True).run()
|
|
397
|
+
TaskInfoApp(uuid=task.id, print_stderr=True).run()
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# Listing of all field names in order (default for search)
|
|
401
|
+
ALL_FIELDS = list(Task.columns)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# Reasonable limit on output delimiter (typically just single char).
|
|
405
|
+
DELIMITER_MAX_SIZE = 100
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
class SearchableMixin:
|
|
409
|
+
"""Mixin class implements task search for multiple commands."""
|
|
410
|
+
|
|
411
|
+
field_names: List[str] = ALL_FIELDS
|
|
412
|
+
where_clauses: List[str] = None
|
|
413
|
+
taglist: List[str] = None
|
|
414
|
+
limit: int = None
|
|
415
|
+
|
|
416
|
+
# Not needed by some applications
|
|
417
|
+
order_by: str = None
|
|
418
|
+
order_desc: bool = False
|
|
419
|
+
|
|
420
|
+
show_failed: bool = False
|
|
421
|
+
show_completed: bool = False
|
|
422
|
+
show_succeeded: bool = False
|
|
423
|
+
show_remaining: bool = False
|
|
424
|
+
|
|
425
|
+
def build_query(self: SearchableMixin) -> Query:
|
|
426
|
+
"""Build original query interface."""
|
|
427
|
+
query = Task.query(*self.fields)
|
|
428
|
+
query = self.__build_order_by_clause(query)
|
|
429
|
+
query = self.__build_where_clause(query)
|
|
430
|
+
query = self.__build_where_clause_for_tags(query)
|
|
431
|
+
return query.limit(self.limit)
|
|
432
|
+
|
|
433
|
+
def __build_where_clause_for_tags(self: SearchableMixin, query: Query) -> Query:
|
|
434
|
+
"""Add JSON-based tag where-clauses to query if necessary."""
|
|
435
|
+
tags_name_only = []
|
|
436
|
+
tags_with_value = Tag.parse_cmdline_list(self.taglist)
|
|
437
|
+
for name in list(tags_with_value.keys()):
|
|
438
|
+
if isinstance(tags_with_value[name], str) and not tags_with_value[name]:
|
|
439
|
+
tags_name_only.append(name)
|
|
440
|
+
tags_with_value.pop(name)
|
|
441
|
+
for name in tags_name_only:
|
|
442
|
+
if config.database.provider == 'sqlite':
|
|
443
|
+
# NOTE: sqlalchemy adds `json_quote(json_extract(task.tag, ?)) is not null`
|
|
444
|
+
# and cannot find a way to exclude `json_quote`, so we do it ourselves
|
|
445
|
+
query = query.filter(text('json_extract(task.tag, :key) is not null')).params(key=f'$."{name}"')
|
|
446
|
+
else:
|
|
447
|
+
query = query.filter(Task.tag[name].isnot(None))
|
|
448
|
+
for name, value in tags_with_value.items():
|
|
449
|
+
if config.database.provider == 'sqlite' and value in (True, False):
|
|
450
|
+
value = int(value) # NOTE: SQLite stores as 0/1 not JSON true/false :(
|
|
451
|
+
query = query.filter(Task.tag[name] == type_coerce(value, JSON))
|
|
452
|
+
return query
|
|
453
|
+
|
|
454
|
+
def __build_where_clause(self: SearchableMixin, query: Query) -> Query:
|
|
455
|
+
"""Add explicit where-clauses to query if necessary."""
|
|
456
|
+
for where_clause in self.__build_filters():
|
|
457
|
+
query = query.filter(where_clause.compile())
|
|
458
|
+
return query
|
|
459
|
+
|
|
460
|
+
def __build_order_by_clause(self: SearchableMixin, query: Query) -> Query:
|
|
461
|
+
"""Add order by clause to query if necessary."""
|
|
462
|
+
if self.order_by:
|
|
463
|
+
field = getattr(Task, self.order_by)
|
|
464
|
+
if self.order_desc:
|
|
465
|
+
field = field.desc()
|
|
466
|
+
query = query.order_by(field)
|
|
467
|
+
return query
|
|
468
|
+
|
|
469
|
+
def __build_filters(self: SearchableMixin) -> List[WhereClause]:
|
|
470
|
+
"""Create list of field selectors from command-line arguments."""
|
|
471
|
+
if self.show_failed:
|
|
472
|
+
self.where_clauses.append('exit_status != 0')
|
|
473
|
+
if self.show_succeeded:
|
|
474
|
+
self.where_clauses.append('exit_status == 0')
|
|
475
|
+
if self.show_completed:
|
|
476
|
+
self.where_clauses.append('exit_status != null')
|
|
477
|
+
if self.show_remaining:
|
|
478
|
+
self.where_clauses.append('exit_status == null')
|
|
479
|
+
if not self.where_clauses:
|
|
480
|
+
return []
|
|
481
|
+
else:
|
|
482
|
+
return [WhereClause.from_cmdline(arg) for arg in self.where_clauses]
|
|
483
|
+
|
|
484
|
+
@functools.cached_property
|
|
485
|
+
def fields(self: SearchableMixin) -> List[Column]:
|
|
486
|
+
"""Field instances to query against."""
|
|
487
|
+
return [getattr(Task, name) for name in self.field_names]
|
|
488
|
+
|
|
489
|
+
def check_field_names(self: SearchableMixin) -> None:
|
|
490
|
+
"""Check field names are valid."""
|
|
491
|
+
for name in self.field_names:
|
|
492
|
+
if name not in Task.columns:
|
|
493
|
+
raise ArgumentError(f'Invalid field name "{name}"')
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
SEARCH_PROGRAM = 'hs task search'
|
|
497
|
+
SEARCH_SYNOPSIS = f'{SEARCH_PROGRAM} [-h] [FIELD [FIELD ...]] [-w COND [COND ...]] [-t TAG [TAG...]] ...'
|
|
498
|
+
SEARCH_USAGE = f"""\
|
|
499
|
+
Usage:
|
|
500
|
+
hs task search [-h] [FIELD [FIELD ...]] [-w COND [COND ...]] [-t TAG [TAG...]]
|
|
501
|
+
[--failed | --succeeded | --completed | --remaining]
|
|
502
|
+
[--order-by FIELD [--desc]] [--count | --limit NUM]
|
|
503
|
+
[-f FORMAT | --json | --csv] [-d CHAR]
|
|
504
|
+
|
|
505
|
+
hs task search --tag-keys
|
|
506
|
+
hs task search --tag-values KEY
|
|
507
|
+
|
|
508
|
+
Search tasks in the database.\
|
|
509
|
+
"""
|
|
510
|
+
|
|
511
|
+
SEARCH_HELP = f"""\
|
|
512
|
+
{SEARCH_USAGE}
|
|
513
|
+
|
|
514
|
+
Arguments:
|
|
515
|
+
FIELD Select specific named fields.
|
|
516
|
+
|
|
517
|
+
Options:
|
|
518
|
+
-w, --where COND... Filter on conditional expression.
|
|
519
|
+
-t, --with-tag TAG... Filter by tag.
|
|
520
|
+
-s, --order-by FIELD Order output by field.
|
|
521
|
+
--desc Descending order (requires --order-by).
|
|
522
|
+
-F, --failed Alias for `-w exit_status != 0`.
|
|
523
|
+
-S, --succeeded Alias for `-w exit_status == 0`.
|
|
524
|
+
-C, --completed Alias for `-w exit_status != null`.
|
|
525
|
+
-R, --remaining Alias for `-w exit_status == null`.
|
|
526
|
+
-f, --format FORMAT Format output (normal, plain, table, csv, json).
|
|
527
|
+
--json Format output as JSON (alias for `--format=json`).
|
|
528
|
+
--csv Format output as CSV (alias for `--format=csv`.
|
|
529
|
+
-d, --delimiter CHAR Field seperator for plain/csv formats.
|
|
530
|
+
-l, --limit NUM Limit the number of results.
|
|
531
|
+
-c, --count Show count of results.
|
|
532
|
+
--tag-keys Show all distinct tag keys.
|
|
533
|
+
--tag-values KEY Show all distinct tag values for given key.
|
|
534
|
+
-h, --help Show this message and exit.\
|
|
535
|
+
"""
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
class TaskSearchApp(Application, SearchableMixin):
|
|
539
|
+
"""Search for tasks in database."""
|
|
540
|
+
|
|
541
|
+
interface = Interface(SEARCH_PROGRAM, SEARCH_USAGE, SEARCH_HELP)
|
|
542
|
+
|
|
543
|
+
field_names: List[str] = ALL_FIELDS
|
|
544
|
+
interface.add_argument('field_names', nargs='*', default=field_names)
|
|
545
|
+
|
|
546
|
+
where_clauses: List[str] = None
|
|
547
|
+
interface.add_argument('-w', '--where', nargs='*', default=[], dest='where_clauses')
|
|
548
|
+
|
|
549
|
+
taglist: List[str] = None
|
|
550
|
+
interface.add_argument('-t', '--with-tag', nargs='*', default=[], dest='taglist')
|
|
551
|
+
|
|
552
|
+
order_by: str = None
|
|
553
|
+
order_desc: bool = False
|
|
554
|
+
interface.add_argument('-s', '--order-by', default=None, choices=field_names)
|
|
555
|
+
interface.add_argument('--desc', action='store_true', dest='order_desc')
|
|
556
|
+
|
|
557
|
+
limit: int = None
|
|
558
|
+
interface.add_argument('-l', '--limit', type=int, default=None)
|
|
559
|
+
|
|
560
|
+
show_count: bool = False
|
|
561
|
+
interface.add_argument('-c', '--count', action='store_true', dest='show_count')
|
|
562
|
+
|
|
563
|
+
show_failed: bool = False
|
|
564
|
+
show_completed: bool = False
|
|
565
|
+
show_succeeded: bool = False
|
|
566
|
+
show_remaining: bool = False
|
|
567
|
+
search_alias_interface = interface.add_mutually_exclusive_group()
|
|
568
|
+
search_alias_interface.add_argument('-F', '--failed', action='store_true', dest='show_failed')
|
|
569
|
+
search_alias_interface.add_argument('-C', '--completed', action='store_true', dest='show_completed')
|
|
570
|
+
search_alias_interface.add_argument('-S', '--succeeded', action='store_true', dest='show_succeeded')
|
|
571
|
+
search_alias_interface.add_argument('-R', '--remaining', action='store_true', dest='show_remaining')
|
|
572
|
+
search_alias_interface.add_argument('--finished', action='store_true', dest='show_completed')
|
|
573
|
+
# NOTE: --finished retained for backwards compatibility
|
|
574
|
+
|
|
575
|
+
output_format: str = '<default>' # 'plain' if field_names else 'normal'
|
|
576
|
+
output_formats: List[str] = ['normal', 'plain', 'table', 'json', 'csv']
|
|
577
|
+
output_interface = interface.add_mutually_exclusive_group()
|
|
578
|
+
output_interface.add_argument('-f', '--format', default=output_format,
|
|
579
|
+
dest='output_format', choices=output_formats)
|
|
580
|
+
output_interface.add_argument('--json', action='store_const', const='json', dest='output_format')
|
|
581
|
+
output_interface.add_argument('--csv', action='store_const', const='csv', dest='output_format')
|
|
582
|
+
|
|
583
|
+
output_delimiter: str = '<default>' # <space> if plain, ',' if --csv, else not valid
|
|
584
|
+
interface.add_argument('-d', '--delimiter', default=output_delimiter, dest='output_delimiter')
|
|
585
|
+
|
|
586
|
+
list_tag_keys: bool = False
|
|
587
|
+
list_tag_values: bool = False
|
|
588
|
+
tag_search_interface = interface.add_mutually_exclusive_group()
|
|
589
|
+
tag_search_interface.add_argument('--tag-keys', action='store_true', dest='list_tag_keys')
|
|
590
|
+
tag_search_interface.add_argument('--tag-values', action='store_true', dest='list_tag_values')
|
|
591
|
+
|
|
592
|
+
exceptions = {
|
|
593
|
+
StatementError: functools.partial(handle_exception, logger=log, status=exit_status.runtime_error),
|
|
594
|
+
**get_shared_exception_mapping(__name__)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
def run(self: TaskSearchApp) -> None:
|
|
598
|
+
"""Search for tasks in database."""
|
|
599
|
+
ensuredb()
|
|
600
|
+
if self.list_tag_keys:
|
|
601
|
+
self.print_tag_keys()
|
|
602
|
+
return
|
|
603
|
+
if self.list_tag_values:
|
|
604
|
+
self.print_tag_values()
|
|
605
|
+
return
|
|
606
|
+
self.check_field_names()
|
|
607
|
+
self.check_output_format()
|
|
608
|
+
if self.show_count:
|
|
609
|
+
print(self.build_query().count())
|
|
610
|
+
else:
|
|
611
|
+
self.print_output(self.build_query().all())
|
|
612
|
+
|
|
613
|
+
@staticmethod
|
|
614
|
+
def print_tag_keys() -> None:
|
|
615
|
+
"""Print distinct tags present in the database."""
|
|
616
|
+
if config.database.provider == 'sqlite':
|
|
617
|
+
for (key, ) in Session.execute(
|
|
618
|
+
text('select distinct tag.key from task, json_each(tag) as tag')
|
|
619
|
+
):
|
|
620
|
+
print(key)
|
|
621
|
+
else:
|
|
622
|
+
subquery = Session.query(func.jsonb_object_keys(Task.tag).label('tag_key')).subquery()
|
|
623
|
+
for (key, ) in Session.query(subquery.c.tag_key.distinct()):
|
|
624
|
+
print(key)
|
|
625
|
+
|
|
626
|
+
def print_tag_values(self: TaskSearchApp) -> None:
|
|
627
|
+
"""Print distinct values for given tag in the database."""
|
|
628
|
+
if len(self.field_names) != 1:
|
|
629
|
+
raise ArgumentError(f'Expected single name for --tag-values')
|
|
630
|
+
name, = self.field_names
|
|
631
|
+
if config.database.provider == 'sqlite':
|
|
632
|
+
for (value, ) in Session.execute(
|
|
633
|
+
text("""select distinct t.value from task, json_each(tag) as t
|
|
634
|
+
where json_extract(tag, :a) is not null and t.key = :b""")
|
|
635
|
+
.params(a=f'$."{name}"', b=name)
|
|
636
|
+
):
|
|
637
|
+
print(value)
|
|
638
|
+
else:
|
|
639
|
+
for (value, ) in Session.query(distinct(Task.tag[name])).filter(Task.tag[name].isnot(None)):
|
|
640
|
+
print(value)
|
|
641
|
+
|
|
642
|
+
@functools.cached_property
|
|
643
|
+
def print_output(self: TaskSearchApp) -> Callable[[List[Tuple]], None]:
|
|
644
|
+
"""The requested output formatter."""
|
|
645
|
+
return getattr(self, f'print_{self.output_format}')
|
|
646
|
+
|
|
647
|
+
def print_table(self: TaskSearchApp, results: List[Tuple]) -> None:
|
|
648
|
+
"""Print in table format."""
|
|
649
|
+
table = Table(title=None)
|
|
650
|
+
for name in self.field_names:
|
|
651
|
+
table.add_column(name)
|
|
652
|
+
for record in results:
|
|
653
|
+
table.add_row(*[json.dumps(to_json_type(value)).strip('"') for value in record])
|
|
654
|
+
Console().print(table)
|
|
655
|
+
|
|
656
|
+
@staticmethod
|
|
657
|
+
def print_normal(results: List[Tuple]) -> None:
|
|
658
|
+
"""Print semi-structured output with all field names."""
|
|
659
|
+
for record in results:
|
|
660
|
+
print('---')
|
|
661
|
+
print_normal(Task.from_dict(dict(zip(Task.columns, record))))
|
|
662
|
+
|
|
663
|
+
def print_plain(self: TaskSearchApp, results: List[Tuple]) -> None:
|
|
664
|
+
"""Print plain text output with given field names, one task per line."""
|
|
665
|
+
for record in results:
|
|
666
|
+
data = [json.dumps(to_json_type(value)).strip('"') for value in record]
|
|
667
|
+
print(self.output_delimiter.join(map(str, data)))
|
|
668
|
+
|
|
669
|
+
def print_json(self: TaskSearchApp, results: List[Tuple]) -> None:
|
|
670
|
+
"""Print in output in JSON format."""
|
|
671
|
+
data = [{field: to_json_type(value) for field, value in zip(self.field_names, record)}
|
|
672
|
+
for record in results]
|
|
673
|
+
if sys.stdout.isatty():
|
|
674
|
+
Console().print(Syntax(json.dumps(data, indent=4, sort_keys=False), 'json',
|
|
675
|
+
word_wrap=True, theme=config.console.theme,
|
|
676
|
+
background_color='default'))
|
|
677
|
+
else:
|
|
678
|
+
print(json.dumps(data, indent=4, sort_keys=False), file=sys.stdout, flush=True)
|
|
679
|
+
|
|
680
|
+
def print_csv(self: TaskSearchApp, results: List[Tuple]) -> None:
|
|
681
|
+
"""Print output in CVS format."""
|
|
682
|
+
writer = csv.writer(sys.stdout, delimiter=self.output_delimiter)
|
|
683
|
+
writer.writerow(self.field_names)
|
|
684
|
+
for record in results:
|
|
685
|
+
data = [to_json_type(value) for value in record]
|
|
686
|
+
data = [value if isinstance(value, str) else json.dumps(value) for value in data]
|
|
687
|
+
writer.writerow(data)
|
|
688
|
+
|
|
689
|
+
def check_output_format(self: TaskSearchApp) -> None:
|
|
690
|
+
"""Check given output format is valid."""
|
|
691
|
+
if self.field_names == ALL_FIELDS:
|
|
692
|
+
if self.output_format == '<default>':
|
|
693
|
+
self.output_format = 'normal'
|
|
694
|
+
else:
|
|
695
|
+
if self.output_format == '<default>':
|
|
696
|
+
self.output_format = 'plain'
|
|
697
|
+
elif self.output_format == 'normal':
|
|
698
|
+
raise ArgumentError('Cannot use --format=normal with subset of field names')
|
|
699
|
+
if self.output_delimiter != '<default>' and self.output_format not in ['plain', 'csv']:
|
|
700
|
+
raise ArgumentError(f'Unused --delimiter for --format={self.output_format}')
|
|
701
|
+
if len(self.output_delimiter) > DELIMITER_MAX_SIZE:
|
|
702
|
+
raise ArgumentError(f'Output delimiter exceeds max size ({len(self.output_delimiter)} '
|
|
703
|
+
f'> {DELIMITER_MAX_SIZE})')
|
|
704
|
+
if self.output_delimiter == '<default>':
|
|
705
|
+
if self.output_format == 'csv':
|
|
706
|
+
self.output_delimiter = ','
|
|
707
|
+
else:
|
|
708
|
+
self.output_delimiter = '\t'
|
|
709
|
+
elif self.output_format == 'csv' and len(self.output_delimiter) != 1:
|
|
710
|
+
# NOTE: csv module demands single-char delimiter
|
|
711
|
+
raise ArgumentError(f'Valid --csv output must use single-char delimiter')
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
# Special exit status indicates cancellation
|
|
715
|
+
CANCEL_STATUS: Final[int] = -1
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
UPDATE_PROGRAM = 'hs task update'
|
|
719
|
+
UPDATE_SYNOPSIS = f'{UPDATE_PROGRAM} [-h] ARG [ARG...] [--cancel | --revert | --delete] ...'
|
|
720
|
+
UPDATE_USAGE = f"""\
|
|
721
|
+
Usage:
|
|
722
|
+
hs task update [-h] ARG [ARG...] [--cancel | --revert | --delete] [--remove-tag TAG [TAG ...]]
|
|
723
|
+
[-w COND [COND ...]] [-t TAG [TAG...]] [--order-by FIELD [--desc]] [--limit NUM]
|
|
724
|
+
[--failed | --succeeded | --completed | --remaining] [--no-confirm]
|
|
725
|
+
|
|
726
|
+
Update task metadata.\
|
|
727
|
+
"""
|
|
728
|
+
|
|
729
|
+
UPDATE_HELP = f"""\
|
|
730
|
+
{UPDATE_USAGE}
|
|
731
|
+
|
|
732
|
+
Include any number of FIELD=VALUE or tag KEY:VALUE positional arguments.
|
|
733
|
+
The -w/--where and -t/--with-tag operate just as in the search command.
|
|
734
|
+
|
|
735
|
+
Using --cancel sets schedule_time to now and exit_status to {CANCEL_STATUS}.
|
|
736
|
+
Using --revert reverts everything as if the task was new again.
|
|
737
|
+
Using --delete drops the row from the database entirely.
|
|
738
|
+
|
|
739
|
+
The legacy interface for updating a single task with the ID, FIELD,
|
|
740
|
+
and VALUE as positional arguments remains valid.
|
|
741
|
+
|
|
742
|
+
Arguments:
|
|
743
|
+
ARGS... Assignment pairs for update.
|
|
744
|
+
|
|
745
|
+
Options:
|
|
746
|
+
--cancel Cancel specified tasks.
|
|
747
|
+
--revert Revert specified tasks.
|
|
748
|
+
--delete Delete specified tasks.
|
|
749
|
+
--remove-tag TAG... Remove specified tags by name.
|
|
750
|
+
-w, --where COND... Filter on conditional expression.
|
|
751
|
+
-t, --with-tag TAG... Filter by tag.
|
|
752
|
+
-s, --order-by FIELD Order matches by FIELD.
|
|
753
|
+
--desc Descending order (requires --order-by).
|
|
754
|
+
-l, --limit NUM Limit matches.
|
|
755
|
+
-F, --failed Alias for `-w exit_status != 0`.
|
|
756
|
+
-S, --succeeded Alias for `-w exit_status == 0`.
|
|
757
|
+
-C, --completed Alias for `-w exit_status != null`.
|
|
758
|
+
-R, --remaining Alias for `-w exit_status == null`.
|
|
759
|
+
-f, --no-confirm Do not ask for confirmation.
|
|
760
|
+
-h, --help Show this message and exit.\
|
|
761
|
+
"""
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
class TaskUpdateApp(Application, SearchableMixin):
|
|
765
|
+
"""Update task metadata."""
|
|
766
|
+
|
|
767
|
+
interface = Interface(UPDATE_PROGRAM, UPDATE_USAGE, UPDATE_HELP)
|
|
768
|
+
|
|
769
|
+
update_args: List[str]
|
|
770
|
+
interface.add_argument('update_args', nargs='*')
|
|
771
|
+
|
|
772
|
+
# Used by SearchableMixin and not part of this interface
|
|
773
|
+
# Empty list results in Session.query(Task)
|
|
774
|
+
field_names: List[str] = []
|
|
775
|
+
|
|
776
|
+
where_clauses: List[str] = None
|
|
777
|
+
interface.add_argument('-w', '--where', nargs='*', default=[], dest='where_clauses')
|
|
778
|
+
|
|
779
|
+
taglist: List[str] = None
|
|
780
|
+
interface.add_argument('-t', '--with-tag', nargs='*', default=[], dest='taglist')
|
|
781
|
+
|
|
782
|
+
order_by: str = None
|
|
783
|
+
order_desc: bool = False
|
|
784
|
+
interface.add_argument('-s', '--order-by', default=None, choices=list(Task.columns))
|
|
785
|
+
interface.add_argument('--desc', action='store_true', dest='order_desc')
|
|
786
|
+
|
|
787
|
+
limit: int = None
|
|
788
|
+
interface.add_argument('-l', '--limit', type=int, default=None)
|
|
789
|
+
|
|
790
|
+
show_failed: bool = False
|
|
791
|
+
show_completed: bool = False
|
|
792
|
+
show_succeeded: bool = False
|
|
793
|
+
show_remaining: bool = False
|
|
794
|
+
search_alias_interface = interface.add_mutually_exclusive_group()
|
|
795
|
+
search_alias_interface.add_argument('-F', '--failed', action='store_true', dest='show_failed')
|
|
796
|
+
search_alias_interface.add_argument('-C', '--completed', action='store_true', dest='show_completed')
|
|
797
|
+
search_alias_interface.add_argument('-S', '--succeeded', action='store_true', dest='show_succeeded')
|
|
798
|
+
search_alias_interface.add_argument('-R', '--remaining', action='store_true', dest='show_remaining')
|
|
799
|
+
|
|
800
|
+
revert_mode: bool = False
|
|
801
|
+
cancel_mode: bool = False
|
|
802
|
+
delete_mode: bool = False
|
|
803
|
+
action_interface = interface.add_mutually_exclusive_group()
|
|
804
|
+
action_interface.add_argument('--revert', action='store_true', dest='revert_mode')
|
|
805
|
+
action_interface.add_argument('--cancel', action='store_true', dest='cancel_mode')
|
|
806
|
+
action_interface.add_argument('--delete', action='store_true', dest='delete_mode')
|
|
807
|
+
|
|
808
|
+
remove_tag: List[str] = None
|
|
809
|
+
interface.add_argument('--remove-tag', nargs='*')
|
|
810
|
+
|
|
811
|
+
no_confirm: bool = False
|
|
812
|
+
interface.add_argument('-f', '--no-confirm', action='store_true')
|
|
813
|
+
|
|
814
|
+
exceptions = {
|
|
815
|
+
Task.NotFound: functools.partial(handle_exception, logger=log, status=exit_status.runtime_error),
|
|
816
|
+
**get_shared_exception_mapping(__name__)
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
def run(self: TaskUpdateApp) -> None:
|
|
820
|
+
"""Update task attributes in bulk."""
|
|
821
|
+
ensuredb()
|
|
822
|
+
if len(self.update_args) == 3 and UUID_PATTERN.match(self.update_args[0]):
|
|
823
|
+
self.update_legacy()
|
|
824
|
+
else:
|
|
825
|
+
self.update_tasks()
|
|
826
|
+
|
|
827
|
+
def update_legacy(self: TaskUpdateApp) -> None:
|
|
828
|
+
"""Implement legacy interface to update single task."""
|
|
829
|
+
uuid, field, value = self.update_args
|
|
830
|
+
if field not in Task.columns:
|
|
831
|
+
raise ArgumentError(f'Invalid field name "{field}"')
|
|
832
|
+
try:
|
|
833
|
+
if field == 'tag':
|
|
834
|
+
Task.update(uuid, tag={**Task.from_id(uuid).tag,
|
|
835
|
+
**Tag.parse_cmdline_list([value, ])})
|
|
836
|
+
else:
|
|
837
|
+
if Task.columns.get(field) is str:
|
|
838
|
+
value = None if value.lower() in {'none', 'null'} else value
|
|
839
|
+
else:
|
|
840
|
+
value = smart_coerce(value)
|
|
841
|
+
Task.update(uuid, **{field: value, })
|
|
842
|
+
except StaleDataError as err:
|
|
843
|
+
raise Task.NotFound(str(err)) from err
|
|
844
|
+
|
|
845
|
+
def update_tasks(self: TaskUpdateApp) -> None:
|
|
846
|
+
"""Normal mode updates many tasks."""
|
|
847
|
+
|
|
848
|
+
field_updates, tag_updates = self.process_arguments()
|
|
849
|
+
|
|
850
|
+
if config.database.provider == 'sqlite':
|
|
851
|
+
site = config.database.file
|
|
852
|
+
else:
|
|
853
|
+
site = config.database.get('host', 'localhost')
|
|
854
|
+
|
|
855
|
+
log.info(f'Searching database: {config.database.provider} ({site})')
|
|
856
|
+
|
|
857
|
+
query = self.build_query()
|
|
858
|
+
count = query.count()
|
|
859
|
+
|
|
860
|
+
if count == 0:
|
|
861
|
+
log.info(f'Update affects {count} tasks - stopping')
|
|
862
|
+
return
|
|
863
|
+
|
|
864
|
+
if not self.no_confirm:
|
|
865
|
+
response = input(f'Update affects {count} tasks, continue? yes/[no]: ').strip().lower()
|
|
866
|
+
if response in ['n', 'no', '']:
|
|
867
|
+
print('Stopping')
|
|
868
|
+
return
|
|
869
|
+
if response not in ['y', 'yes']:
|
|
870
|
+
print(f'Stopping (invalid response: "{response}")')
|
|
871
|
+
return
|
|
872
|
+
|
|
873
|
+
# Delete is handled later if a --limit is used with search
|
|
874
|
+
if self.delete_mode and self.limit is None:
|
|
875
|
+
query.delete()
|
|
876
|
+
Session.commit()
|
|
877
|
+
log.info(f'Deleted {count} tasks')
|
|
878
|
+
return
|
|
879
|
+
|
|
880
|
+
if self.cancel_mode:
|
|
881
|
+
if self.limit:
|
|
882
|
+
prev_count = sum([1 for task in query if task.schedule_time is not None])
|
|
883
|
+
else:
|
|
884
|
+
prev_count = query.filter(Task.schedule_time.isnot(None)).count()
|
|
885
|
+
if prev_count > 0:
|
|
886
|
+
log.warning(f'{prev_count} cancelled tasks already scheduled')
|
|
887
|
+
field_updates['schedule_time'] = datetime.now().astimezone()
|
|
888
|
+
field_updates['exit_status'] = CANCEL_STATUS
|
|
889
|
+
|
|
890
|
+
if self.revert_mode:
|
|
891
|
+
field_updates['schedule_time'] = None
|
|
892
|
+
field_updates['server_host'] = None
|
|
893
|
+
field_updates['server_id'] = None
|
|
894
|
+
field_updates['client_host'] = None
|
|
895
|
+
field_updates['client_id'] = None
|
|
896
|
+
field_updates['command'] = None
|
|
897
|
+
field_updates['start_time'] = None
|
|
898
|
+
field_updates['completion_time'] = None
|
|
899
|
+
field_updates['exit_status'] = None
|
|
900
|
+
field_updates['outpath'] = None
|
|
901
|
+
field_updates['errpath'] = None
|
|
902
|
+
field_updates['waited'] = None
|
|
903
|
+
field_updates['duration'] = None
|
|
904
|
+
|
|
905
|
+
if self.limit is not None:
|
|
906
|
+
# We cannot apply an UPDATE query with a LIMIT field
|
|
907
|
+
# The alternative is to pull the data and batch the update
|
|
908
|
+
# While terribly inefficient at least it has a LIMIT
|
|
909
|
+
tasks = query.all()
|
|
910
|
+
tasks_it = iter(tasks)
|
|
911
|
+
if self.delete_mode:
|
|
912
|
+
while batch := tuple(itertools.islice(tasks_it, 100)):
|
|
913
|
+
Task.delete_all(list(batch))
|
|
914
|
+
if field_updates:
|
|
915
|
+
while batch := tuple(itertools.islice(tasks_it, 100)):
|
|
916
|
+
Task.update_all([{'id': task.id, **field_updates} for task in batch])
|
|
917
|
+
if tag_updates:
|
|
918
|
+
while batch := tuple(itertools.islice(tasks_it, 100)):
|
|
919
|
+
Task.update_all([{'id': task.id, 'tag': {**task.tag, **tag_updates}} for task in batch])
|
|
920
|
+
if self.remove_tag:
|
|
921
|
+
while batch := tuple(itertools.islice(tasks_it, 100)):
|
|
922
|
+
Task.update_all([
|
|
923
|
+
{'id': task.id, 'tag': self.drop_items(task.tag, *self.remove_tag)}
|
|
924
|
+
for task in batch
|
|
925
|
+
])
|
|
926
|
+
if self.delete_mode:
|
|
927
|
+
log.info(f'Deleted {count} tasks')
|
|
928
|
+
else:
|
|
929
|
+
log.info(f'Updated {count} tasks')
|
|
930
|
+
return
|
|
931
|
+
|
|
932
|
+
if field_updates:
|
|
933
|
+
query.update(field_updates)
|
|
934
|
+
|
|
935
|
+
if tag_updates:
|
|
936
|
+
if config.database.provider == 'sqlite':
|
|
937
|
+
tag_changes = {}
|
|
938
|
+
change_expr = 'json_set(task.tag'
|
|
939
|
+
for i, (k, v) in enumerate(tag_updates.items()):
|
|
940
|
+
tag_changes[f'k{i}'] = f'$.{k}'
|
|
941
|
+
tag_changes[f'v{i}'] = v
|
|
942
|
+
change_expr += f', :k{i}, :v{i}'
|
|
943
|
+
change_expr += ')'
|
|
944
|
+
query.update({Task.tag: text(change_expr).params(tag_changes)})
|
|
945
|
+
else:
|
|
946
|
+
# We cannot stack inserts with Postgres
|
|
947
|
+
# Instead we do them with individual update queries
|
|
948
|
+
for i, (k, v) in enumerate(tag_updates.items()):
|
|
949
|
+
change_expr = 'jsonb_set(task.tag, :key, :value)'
|
|
950
|
+
params = {'key': '{' + k + '}', 'value': json.dumps(to_json_type(v))}
|
|
951
|
+
query.update({Task.tag: text(change_expr).params(**params)})
|
|
952
|
+
|
|
953
|
+
if self.remove_tag:
|
|
954
|
+
if config.database.provider == 'sqlite':
|
|
955
|
+
tag_changes = {}
|
|
956
|
+
change_expr = 'json_remove(task.tag'
|
|
957
|
+
for i, name in enumerate(self.remove_tag):
|
|
958
|
+
tag_changes[f'k{i}'] = f'$.{name}'
|
|
959
|
+
change_expr += f', :k{i}'
|
|
960
|
+
change_expr += ')'
|
|
961
|
+
query.update({Task.tag: text(change_expr).params(tag_changes)})
|
|
962
|
+
else:
|
|
963
|
+
# We cannot stack subtractions with Postgres
|
|
964
|
+
# Instead we do them with individual update queries
|
|
965
|
+
for name in self.remove_tag:
|
|
966
|
+
query.update({Task.tag: text('task.tag - :name').params(name=name)})
|
|
967
|
+
|
|
968
|
+
Session.commit()
|
|
969
|
+
log.info(f'Updated {count} tasks')
|
|
970
|
+
|
|
971
|
+
def process_arguments(self: TaskUpdateApp) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
|
972
|
+
"""Process positional arguments and build dictionaries for changes."""
|
|
973
|
+
field_updates = {}
|
|
974
|
+
tag_updates = {}
|
|
975
|
+
for arg in self.update_args:
|
|
976
|
+
if WhereClause.pattern.match(arg):
|
|
977
|
+
raise ArgumentError(f'Positional argument matches conditional ({arg}), '
|
|
978
|
+
f'maybe you intended to use -w/--where?')
|
|
979
|
+
if '=' in arg:
|
|
980
|
+
field, value = arg.split('=', 1)
|
|
981
|
+
if field == 'id':
|
|
982
|
+
raise ArgumentError(f'Cannot alter task "id" (given: {field}={value})')
|
|
983
|
+
if field not in Task.columns:
|
|
984
|
+
raise ArgumentError(f'Unrecognized task field "{field}"')
|
|
985
|
+
if Task.columns.get(field) is str:
|
|
986
|
+
# We want to coerce the value (e.g., as an int or None)
|
|
987
|
+
# But also allow for, e.g., args==1 which expects type str.
|
|
988
|
+
value = None if value.lower() in {'none', 'null'} else value
|
|
989
|
+
else:
|
|
990
|
+
value = smart_coerce(value)
|
|
991
|
+
field_updates[field] = value
|
|
992
|
+
elif ':' in arg:
|
|
993
|
+
key, value = arg.split(':', 1)
|
|
994
|
+
tag_updates[key] = smart_coerce(value)
|
|
995
|
+
else:
|
|
996
|
+
raise ArgumentError(f'Argument not recognized ({arg}): missing "=" or ":"')
|
|
997
|
+
|
|
998
|
+
Task.ensure_valid_tag(tag_updates)
|
|
999
|
+
|
|
1000
|
+
if self.order_desc and not self.order_by:
|
|
1001
|
+
raise ArgumentError('Should not provide --desc if not using -s/--order-by')
|
|
1002
|
+
|
|
1003
|
+
if self.order_by and not self.limit:
|
|
1004
|
+
raise ArgumentError('Using -s/--order-by without -l/--limit is meaningless')
|
|
1005
|
+
|
|
1006
|
+
return field_updates, tag_updates
|
|
1007
|
+
|
|
1008
|
+
@staticmethod
|
|
1009
|
+
def drop_items(d: dict, *keys: str) -> dict:
|
|
1010
|
+
"""Drop items by key if they exist."""
|
|
1011
|
+
for key in keys:
|
|
1012
|
+
d.pop(key, None)
|
|
1013
|
+
return d
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
TASK_PROGRAM = 'hs task'
|
|
1017
|
+
TASK_USAGE = f"""\
|
|
1018
|
+
Usage:
|
|
1019
|
+
{TASK_PROGRAM} [-h]
|
|
1020
|
+
{SUBMIT_SYNOPSIS}
|
|
1021
|
+
{INFO_SYNOPSIS}
|
|
1022
|
+
{WAIT_SYNOPSIS}
|
|
1023
|
+
{RUN_SYNOPSIS}
|
|
1024
|
+
{SEARCH_SYNOPSIS}
|
|
1025
|
+
{UPDATE_SYNOPSIS}
|
|
1026
|
+
|
|
1027
|
+
Search, submit, track, and manage tasks.\
|
|
1028
|
+
"""
|
|
1029
|
+
|
|
1030
|
+
TASK_HELP = f"""\
|
|
1031
|
+
{TASK_USAGE}
|
|
1032
|
+
|
|
1033
|
+
Commands:
|
|
1034
|
+
submit {TaskSubmitApp.__doc__}
|
|
1035
|
+
info {TaskInfoApp.__doc__}
|
|
1036
|
+
wait {TaskWaitApp.__doc__}
|
|
1037
|
+
run {TaskRunApp.__doc__}
|
|
1038
|
+
search {TaskSearchApp.__doc__}
|
|
1039
|
+
update {TaskUpdateApp.__doc__}
|
|
1040
|
+
|
|
1041
|
+
Options:
|
|
1042
|
+
-h, --help Show this message and exit.\
|
|
1043
|
+
"""
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
class TaskGroupApp(ApplicationGroup):
|
|
1047
|
+
"""Search, submit, track, and manage individual tasks."""
|
|
1048
|
+
|
|
1049
|
+
interface = Interface(TASK_PROGRAM, TASK_USAGE, TASK_HELP)
|
|
1050
|
+
interface.add_argument('--list-columns', action='version', version=' '.join(Task.columns))
|
|
1051
|
+
interface.add_argument('command')
|
|
1052
|
+
|
|
1053
|
+
commands = {
|
|
1054
|
+
'submit': TaskSubmitApp,
|
|
1055
|
+
'info': TaskInfoApp,
|
|
1056
|
+
'wait': TaskWaitApp,
|
|
1057
|
+
'run': TaskRunApp,
|
|
1058
|
+
'search': TaskSearchApp,
|
|
1059
|
+
'update': TaskUpdateApp,
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
@dataclass
|
|
1064
|
+
class WhereClause:
|
|
1065
|
+
"""Parse and prepare query filters based on command-line argument."""
|
|
1066
|
+
|
|
1067
|
+
field: str
|
|
1068
|
+
value: Any
|
|
1069
|
+
operand: str
|
|
1070
|
+
|
|
1071
|
+
pattern = re.compile(r'^([a-z_]+)\s*(==|!=|>|>=|<|<=|~)\s*(.*)$')
|
|
1072
|
+
op_call = {
|
|
1073
|
+
'==': lambda lhs, rhs: lhs == rhs,
|
|
1074
|
+
'!=': lambda lhs, rhs: lhs != rhs,
|
|
1075
|
+
'>=': lambda lhs, rhs: lhs >= rhs,
|
|
1076
|
+
'<=': lambda lhs, rhs: lhs <= rhs,
|
|
1077
|
+
'>': lambda lhs, rhs: lhs > rhs,
|
|
1078
|
+
'<': lambda lhs, rhs: lhs < rhs,
|
|
1079
|
+
'~': lambda lhs, rhs: lhs.regexp_match(rhs),
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
def compile(self: WhereClause) -> BinaryExpression:
|
|
1083
|
+
"""Build binary expression object out of elements."""
|
|
1084
|
+
op_call = self.op_call.get(self.operand)
|
|
1085
|
+
return op_call(getattr(Task, self.field), self.value)
|
|
1086
|
+
|
|
1087
|
+
@classmethod
|
|
1088
|
+
def from_cmdline(cls: Type[WhereClause], argument: str) -> WhereClause:
|
|
1089
|
+
"""
|
|
1090
|
+
Construct from command-line `argument`.
|
|
1091
|
+
|
|
1092
|
+
Example:
|
|
1093
|
+
>>> WhereClause.from_cmdline('exit_status != 0')
|
|
1094
|
+
WhereClause(field='exit_status', value=0, operand='!=')
|
|
1095
|
+
"""
|
|
1096
|
+
match = cls.pattern.match(argument)
|
|
1097
|
+
if match:
|
|
1098
|
+
field, operand, value = match.groups()
|
|
1099
|
+
if Task.columns.get(field) is str:
|
|
1100
|
+
# We want to coerce the value (e.g., as an int or None)
|
|
1101
|
+
# But also allow for, e.g., args==1 which expects type str.
|
|
1102
|
+
value = None if value.lower() in {'none', 'null'} else value
|
|
1103
|
+
else:
|
|
1104
|
+
value = smart_coerce(value)
|
|
1105
|
+
return WhereClause(field=field, value=value, operand=operand)
|
|
1106
|
+
else:
|
|
1107
|
+
raise ArgumentError(f'Where clause not understood ({argument})')
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def print_normal(task: Task) -> None:
|
|
1111
|
+
"""Print semi-structured task metadata with all field names."""
|
|
1112
|
+
task_data = {k: json.dumps(to_json_type(v)).strip('"') for k, v in task.to_dict().items()}
|
|
1113
|
+
task_data['waited'] = 'null' if not task.waited else timedelta(seconds=int(task_data['waited']))
|
|
1114
|
+
task_data['duration'] = 'null' if not task.duration else timedelta(seconds=int(task_data['duration']))
|
|
1115
|
+
task_data['tag'] = ', '.join(format_tag(k, v) for k, v in task.tag.items())
|
|
1116
|
+
print(f' id: {task_data["id"]}')
|
|
1117
|
+
print(f' args: {task_data["args"]}')
|
|
1118
|
+
print(f' command: {task_data["command"]}')
|
|
1119
|
+
print(f' exit_status: {task_data["exit_status"]}')
|
|
1120
|
+
print(f' submitted: {task_data["submit_time"]}')
|
|
1121
|
+
print(f' scheduled: {task_data["schedule_time"]}')
|
|
1122
|
+
print(f' started: {task_data["start_time"]} (waited: {task_data["waited"]})')
|
|
1123
|
+
print(f' completed: {task_data["completion_time"]} (duration: {task_data["duration"]})')
|
|
1124
|
+
print(f' submit_host: {task_data["submit_host"]} ({task_data["submit_id"]})')
|
|
1125
|
+
print(f' server_host: {task_data["server_host"]} ({task_data["server_id"]})')
|
|
1126
|
+
print(f' client_host: {task_data["client_host"]} ({task_data["client_id"]})')
|
|
1127
|
+
print(f' attempt: {task_data["attempt"]}')
|
|
1128
|
+
print(f' retried: {task_data["retried"]}')
|
|
1129
|
+
print(f' outpath: {task_data["outpath"]}')
|
|
1130
|
+
print(f' errpath: {task_data["errpath"]}')
|
|
1131
|
+
print(f' previous_id: {task_data["previous_id"]}')
|
|
1132
|
+
print(f' next_id: {task_data["next_id"]}')
|
|
1133
|
+
print(f' tags: {task_data["tag"]}')
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def format_tag(key: str, value: JSONValue) -> str:
|
|
1137
|
+
"""Format as `key` or `key:value` if not empty string."""
|
|
1138
|
+
if isinstance(value, str) and not value:
|
|
1139
|
+
return key
|
|
1140
|
+
else:
|
|
1141
|
+
return f'{key}:{value}'
|