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