hypershell 2.5.1__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 +114 -0
- hypershell/client.py +1256 -0
- hypershell/cluster/__init__.py +378 -0
- hypershell/cluster/local.py +209 -0
- hypershell/cluster/remote.py +720 -0
- hypershell/cluster/ssh.py +351 -0
- hypershell/config.py +452 -0
- hypershell/core/__init__.py +4 -0
- hypershell/core/config.py +357 -0
- hypershell/core/exceptions.py +120 -0
- hypershell/core/fsm.py +67 -0
- hypershell/core/heartbeat.py +70 -0
- hypershell/core/logging.py +201 -0
- hypershell/core/platform.py +121 -0
- hypershell/core/queue.py +152 -0
- hypershell/core/remote.py +192 -0
- hypershell/core/signal.py +57 -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 +225 -0
- hypershell/data/model.py +630 -0
- hypershell/server.py +1210 -0
- hypershell/submit.py +806 -0
- hypershell/task.py +1158 -0
- hypershell-2.5.1.dist-info/LICENSE +201 -0
- hypershell-2.5.1.dist-info/METADATA +114 -0
- hypershell-2.5.1.dist-info/RECORD +31 -0
- hypershell-2.5.1.dist-info/WHEEL +4 -0
- hypershell-2.5.1.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2024 Geoffrey Lentner
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Signal handling facility."""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# type annotations
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from typing import Optional, Final, Dict
|
|
10
|
+
from types import FrameType
|
|
11
|
+
|
|
12
|
+
# standard libs
|
|
13
|
+
from signal import signal as register
|
|
14
|
+
from signal import SIGUSR1, SIGUSR2, SIGINT, SIGTERM, SIGKILL
|
|
15
|
+
|
|
16
|
+
# internal libs
|
|
17
|
+
from hypershell.core.logging import Logger
|
|
18
|
+
|
|
19
|
+
# public interface
|
|
20
|
+
__all__ = ['check_signal', 'RECEIVED', 'SIGNAL_MAP',
|
|
21
|
+
'handler', 'register_handlers', 'register',
|
|
22
|
+
'SIGUSR1', 'SIGUSR2', 'SIGINT', 'SIGTERM', 'SIGKILL']
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# initialize logger
|
|
26
|
+
log = Logger.with_name(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Global signal value set by handler when received
|
|
30
|
+
RECEIVED: Optional[int] = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def check_signal() -> Optional[int]:
|
|
34
|
+
"""Check for signal received and return if so."""
|
|
35
|
+
return RECEIVED
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
SIGNAL_MAP: Final[Dict[int, str]] = {
|
|
39
|
+
SIGUSR1: 'SIGUSR1',
|
|
40
|
+
SIGUSR2: 'SIGUSR2',
|
|
41
|
+
SIGINT: 'SIGINT',
|
|
42
|
+
SIGTERM: 'SIGTERM',
|
|
43
|
+
SIGKILL: 'SIGKILL',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def handler(signum: int, frame: Optional[FrameType]) -> None:
|
|
48
|
+
"""Generic handler assigns `signum` to global variable."""
|
|
49
|
+
log.debug(f'Received signal {signum}: {SIGNAL_MAP.get(signum, "???")}')
|
|
50
|
+
global RECEIVED
|
|
51
|
+
RECEIVED = signum
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def register_handlers() -> None:
|
|
55
|
+
"""Register signal handlers for client."""
|
|
56
|
+
register(SIGUSR1, handler)
|
|
57
|
+
register(SIGUSR2, handler)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2024 Geoffrey Lentner
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Template expansion facility for task execution."""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# type annotations
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from typing import Dict, Callable
|
|
10
|
+
from types import ModuleType
|
|
11
|
+
|
|
12
|
+
# standard libs
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import math
|
|
16
|
+
import datetime
|
|
17
|
+
import subprocess
|
|
18
|
+
import functools
|
|
19
|
+
|
|
20
|
+
# internal libs
|
|
21
|
+
from hypershell.core.types import smart_coerce
|
|
22
|
+
|
|
23
|
+
# public interface
|
|
24
|
+
__all__ = ['Template', 'DEFAULT_TEMPLATE', ]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Matched in template and expanded accordingly
|
|
28
|
+
PATTERN: re.Pattern = re.compile(r'{(.*?)}')
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# A plain {} is replaced verbatim with the input arguments
|
|
32
|
+
DEFAULT_TEMPLATE = '{}'
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Exposed modules for lambda expressions in templates
|
|
36
|
+
EXPOSED_MODULES: Dict[str, ModuleType] = {
|
|
37
|
+
'os': os,
|
|
38
|
+
'path': os.path,
|
|
39
|
+
'math': math,
|
|
40
|
+
'dt': datetime,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Template:
|
|
45
|
+
"""Manage template expansion from command arguments."""
|
|
46
|
+
|
|
47
|
+
_template: str
|
|
48
|
+
|
|
49
|
+
def __init__(self: Template, template: str = DEFAULT_TEMPLATE) -> None:
|
|
50
|
+
"""Initialize template with input `args` and `template` for expansion."""
|
|
51
|
+
self.template = template
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def template(self: Template) -> str:
|
|
55
|
+
"""Access underlying raw string value."""
|
|
56
|
+
return self._template
|
|
57
|
+
|
|
58
|
+
@template.setter
|
|
59
|
+
def template(self: Template, value: str) -> None:
|
|
60
|
+
"""Set underlying raw string value."""
|
|
61
|
+
if isinstance(value, str):
|
|
62
|
+
self._template = value
|
|
63
|
+
else:
|
|
64
|
+
raise AttributeError(f'Expected type \'str\' for member `template`')
|
|
65
|
+
|
|
66
|
+
class Error(Exception):
|
|
67
|
+
"""Base exception type for Template exceptions."""
|
|
68
|
+
|
|
69
|
+
class UnmatchedPattern(Error):
|
|
70
|
+
"""Template pattern does not match any implemented expansion."""
|
|
71
|
+
|
|
72
|
+
class FailedExpansion(Error):
|
|
73
|
+
"""Failure to successfully expand a pattern given input arguments."""
|
|
74
|
+
|
|
75
|
+
def expand(self: Template, args: str) -> str:
|
|
76
|
+
"""Expand template against input `args`."""
|
|
77
|
+
index = 0
|
|
78
|
+
expansion = ''
|
|
79
|
+
if not PATTERN.search(self.template):
|
|
80
|
+
return self.template
|
|
81
|
+
for match in PATTERN.finditer(self.template):
|
|
82
|
+
(key, ), start, end = match.groups(), match.start(), match.end()
|
|
83
|
+
expansion += self.template[index:start] + self._expand(args, key, start)
|
|
84
|
+
index = end
|
|
85
|
+
else:
|
|
86
|
+
return expansion + self.template[index:]
|
|
87
|
+
|
|
88
|
+
def _expand(self: Template, args: str, key: str, start: int) -> str:
|
|
89
|
+
"""Determine simple vs complex pattern expansion."""
|
|
90
|
+
key = key.strip() # allow whitespace (likely in shell and lambda patterns)
|
|
91
|
+
if key in self.simple_patterns:
|
|
92
|
+
return self.simple_patterns[key](args)
|
|
93
|
+
else:
|
|
94
|
+
return self._expand_complex(args, key, start)
|
|
95
|
+
|
|
96
|
+
def _expand_complex(self: Template, args: str, key: str, start: int) -> str:
|
|
97
|
+
"""Expand complex (nested) patterns in template against `args`."""
|
|
98
|
+
key_ = '{' + key + '}'
|
|
99
|
+
for pattern, routine in self.complex_patterns.items():
|
|
100
|
+
if inner_match := re.match(pattern, key):
|
|
101
|
+
try:
|
|
102
|
+
inner_key, = inner_match.groups()
|
|
103
|
+
return routine(args, inner_key)
|
|
104
|
+
except Exception as error:
|
|
105
|
+
raise self.FailedExpansion(f'Could not expand \'{key_}\' for args ({args}): {error}')
|
|
106
|
+
else:
|
|
107
|
+
raise self.UnmatchedPattern(f'\'{key_}\' in template (at position {start})')
|
|
108
|
+
|
|
109
|
+
@functools.cached_property
|
|
110
|
+
def simple_patterns(self: Template) -> Dict[str, Callable[[str], str]]:
|
|
111
|
+
"""Map of pattern literals to their expansion routines."""
|
|
112
|
+
return {
|
|
113
|
+
'': self.expand_null,
|
|
114
|
+
'.': self.expand_first_dirname,
|
|
115
|
+
'..': self.expand_second_dirname,
|
|
116
|
+
'/': self.expand_basename,
|
|
117
|
+
'/-': self.expand_basename_without_ext,
|
|
118
|
+
'-': self.expand_fullpath_without_ext,
|
|
119
|
+
'+': self.expand_file_extension,
|
|
120
|
+
'++': self.expand_file_extension_without_dot,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@functools.cached_property
|
|
124
|
+
def complex_patterns(self: Template) -> Dict[str, Callable[[str, str], str]]:
|
|
125
|
+
"""Map of complex patterns to their expansion routines."""
|
|
126
|
+
return {
|
|
127
|
+
r'\[(.*?)]': self.expand_slice,
|
|
128
|
+
'=(.*?)=': self.expand_lambda,
|
|
129
|
+
'%(.*?)%': self.expand_shell,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def expand_null(args: str) -> str:
|
|
134
|
+
"""Return `args` without change."""
|
|
135
|
+
return args
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def expand_first_dirname(args: str) -> str:
|
|
139
|
+
"""Return parent directory of `args` assuming a valid path."""
|
|
140
|
+
return os.path.dirname(args)
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def expand_second_dirname(args: str) -> str:
|
|
144
|
+
"""Return second parent directory of `args` assuming a valid path."""
|
|
145
|
+
return os.path.dirname(os.path.dirname(args))
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def expand_basename(args: str) -> str:
|
|
149
|
+
"""Expand to basename of `args` assuming a valid path."""
|
|
150
|
+
return os.path.basename(args)
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def expand_basename_without_ext(args: str) -> str:
|
|
154
|
+
"""Expand to basename of `args` without the file extension assuming a valid path."""
|
|
155
|
+
return os.path.splitext(os.path.basename(args))[0]
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def expand_fullpath_without_ext(args: str) -> str:
|
|
159
|
+
"""Drop file extension from `args` assuming a valid path."""
|
|
160
|
+
return os.path.splitext(args)[0]
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def expand_file_extension(args: str) -> str:
|
|
164
|
+
"""Return file extension of `args` assuming a valid path."""
|
|
165
|
+
return os.path.splitext(args)[1]
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def expand_file_extension_without_dot(args: str) -> str:
|
|
169
|
+
"""Return file extension of `args` without the leading dot assuming a valid path."""
|
|
170
|
+
return os.path.splitext(args)[1].strip('.')
|
|
171
|
+
|
|
172
|
+
def expand_slice(self: Template, args: str, key: str) -> str:
|
|
173
|
+
"""Expand slice `key` ([start][:stop][:step]) against args (on white space)."""
|
|
174
|
+
if re.match(r'(\d+)?:?(\d+)?:?(\d+)?$', key):
|
|
175
|
+
result = eval(f'chunks[{key}]', {'chunks': args.split()})
|
|
176
|
+
return ' '.join(result if isinstance(result, list) else [result, ])
|
|
177
|
+
else:
|
|
178
|
+
raise self.FailedExpansion(f'Invalid slice expression \'{key}\'')
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def expand_shell(args: str, key: str) -> str:
|
|
182
|
+
"""Expand `key` as a shell command with @ replaced with `args`."""
|
|
183
|
+
return subprocess.check_output(key.replace('@', args), shell=True).decode().strip()
|
|
184
|
+
|
|
185
|
+
def expand_lambda(self: Template, args: str, key: str) -> str:
|
|
186
|
+
"""Expand `key` as a lambda expression in `x` and evaluate against `args`."""
|
|
187
|
+
return str(self.build_lambda(key)(smart_coerce(args)))
|
|
188
|
+
|
|
189
|
+
@staticmethod
|
|
190
|
+
@functools.lru_cache(maxsize=None)
|
|
191
|
+
def build_lambda(expression: str) -> Callable[[str], str]:
|
|
192
|
+
"""Construct lambda `expression` with single argument 'x'."""
|
|
193
|
+
return eval(f'lambda x: {expression}', EXPOSED_MODULES)
|
|
194
|
+
|
|
195
|
+
def __repr__(self: Template) -> str:
|
|
196
|
+
"""Interactive representation."""
|
|
197
|
+
return f'Template(\'{self.template}\')'
|
|
198
|
+
|
|
199
|
+
def __str__(self: Template) -> str:
|
|
200
|
+
"""String representation."""
|
|
201
|
+
return self.template
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2024 Geoffrey Lentner
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Thread base class implementation."""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# type annotations
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from typing import Optional, Type
|
|
10
|
+
|
|
11
|
+
# standard libs
|
|
12
|
+
import threading
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
|
|
15
|
+
# public interface
|
|
16
|
+
__all__ = ['Thread', ]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Thread(threading.Thread, ABC):
|
|
20
|
+
"""Extends threading.Thread to provide exception handling."""
|
|
21
|
+
|
|
22
|
+
__exception: Exception = None
|
|
23
|
+
__should_halt: bool = False
|
|
24
|
+
|
|
25
|
+
def __init__(self: Thread, name: str) -> None:
|
|
26
|
+
super().__init__(name=name, daemon=True)
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def run_with_exceptions(self: Thread) -> None:
|
|
30
|
+
"""Implement `run` which may raise exceptions."""
|
|
31
|
+
|
|
32
|
+
def run(self: Thread) -> None:
|
|
33
|
+
"""Call `run_with_exceptions` within a try/except block."""
|
|
34
|
+
try:
|
|
35
|
+
self.run_with_exceptions()
|
|
36
|
+
except Exception as exc:
|
|
37
|
+
self.__exception = exc
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def new(cls: Type[Thread], *args, **kwargs) -> Thread:
|
|
41
|
+
"""Initialize and start the thread."""
|
|
42
|
+
thread = cls(*args, **kwargs)
|
|
43
|
+
thread.start()
|
|
44
|
+
return thread
|
|
45
|
+
|
|
46
|
+
def stop(self: Thread, wait: bool = False, timeout: int = None) -> None:
|
|
47
|
+
"""Signal to terminate."""
|
|
48
|
+
self.__should_halt = True
|
|
49
|
+
if wait:
|
|
50
|
+
self.join(timeout=timeout)
|
|
51
|
+
|
|
52
|
+
def join(self: Thread, timeout: Optional[float] = None) -> None:
|
|
53
|
+
"""Calls Thread.join but re-raises exceptions."""
|
|
54
|
+
super().join(timeout=timeout)
|
|
55
|
+
if self.__exception:
|
|
56
|
+
raise self.__exception
|
hypershell/core/types.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2024 Geoffrey Lentner
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Automatic type coercion of input data."""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# type annotations
|
|
8
|
+
from typing import TypeVar
|
|
9
|
+
|
|
10
|
+
# public interface
|
|
11
|
+
__all__ = ['smart_coerce', 'JSONValue']
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Each possible input type
|
|
15
|
+
JSONValue = TypeVar('JSONValue', bool, int, float, str, type(None))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def smart_coerce(value: str) -> JSONValue:
|
|
19
|
+
"""Automatically coerce string to typed value."""
|
|
20
|
+
cmp_val = value.lower()
|
|
21
|
+
if cmp_val in ('null', 'none'):
|
|
22
|
+
return None
|
|
23
|
+
if cmp_val in ('true', 'false'):
|
|
24
|
+
return cmp_val == 'true'
|
|
25
|
+
try:
|
|
26
|
+
return int(value)
|
|
27
|
+
except ValueError:
|
|
28
|
+
pass
|
|
29
|
+
try:
|
|
30
|
+
return float(value)
|
|
31
|
+
except ValueError:
|
|
32
|
+
return value
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2024 Geoffrey Lentner
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Database interface, models, and methods."""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# type annotations
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
# standard libs
|
|
12
|
+
import sys
|
|
13
|
+
import functools
|
|
14
|
+
|
|
15
|
+
# external libs
|
|
16
|
+
from cmdkit.app import Application, exit_status
|
|
17
|
+
from cmdkit.cli import Interface
|
|
18
|
+
from cmdkit.config import ConfigurationError
|
|
19
|
+
from sqlalchemy import inspect
|
|
20
|
+
from sqlalchemy.orm import close_all_sessions
|
|
21
|
+
from sqlalchemy.exc import OperationalError
|
|
22
|
+
|
|
23
|
+
# internal libs
|
|
24
|
+
from hypershell.core.logging import Logger
|
|
25
|
+
from hypershell.core.config import config
|
|
26
|
+
from hypershell.core.exceptions import handle_exception, DatabaseUninitialized, get_shared_exception_mapping
|
|
27
|
+
from hypershell.data.core import engine, in_memory, schema
|
|
28
|
+
from hypershell.data.model import Entity, Task
|
|
29
|
+
|
|
30
|
+
# public interface
|
|
31
|
+
__all__ = ['InitDBApp', 'initdb', 'truncatedb', 'checkdb', 'ensuredb', 'DATABASE_ENABLED', ]
|
|
32
|
+
|
|
33
|
+
# initialize logger
|
|
34
|
+
log = Logger.with_name(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
DATABASE_ENABLED: Final[bool] = not in_memory
|
|
38
|
+
"""Set if database has been configured."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def initdb() -> None:
|
|
42
|
+
"""Initialize database tables."""
|
|
43
|
+
Entity.metadata.create_all(engine)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def truncatedb() -> None:
|
|
47
|
+
"""Truncate database tables."""
|
|
48
|
+
# NOTE: We still might hang here if other sessions exist outside this app instance
|
|
49
|
+
close_all_sessions()
|
|
50
|
+
log.trace('Dropping all tables')
|
|
51
|
+
Entity.metadata.drop_all(engine)
|
|
52
|
+
log.trace('Creating all tables')
|
|
53
|
+
Entity.metadata.create_all(engine)
|
|
54
|
+
log.warning(f'Truncated database')
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def checkdb() -> None:
|
|
58
|
+
"""Ensure database connection and tables exist."""
|
|
59
|
+
if not inspect(engine).has_table('task', schema=schema):
|
|
60
|
+
raise DatabaseUninitialized('Use \'initdb\' to initialize the database')
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def ensuredb(auto_init: bool = False) -> None:
|
|
64
|
+
"""
|
|
65
|
+
Ensure database configuration before applying any operations.
|
|
66
|
+
|
|
67
|
+
If SQLite and `auto_init` we run :meth:`initdb`, else :meth:`checkdb`.
|
|
68
|
+
"""
|
|
69
|
+
db = config.database.get('file', None) or config.database.get('database', None)
|
|
70
|
+
if config.database.provider == 'sqlite' and db in ('', ':memory:', None):
|
|
71
|
+
raise ConfigurationError('Missing database configuration')
|
|
72
|
+
if config.database.provider == 'sqlite' or auto_init is True:
|
|
73
|
+
initdb()
|
|
74
|
+
else:
|
|
75
|
+
checkdb()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
INITDB_PROGRAM = 'hs initdb'
|
|
79
|
+
INITDB_USAGE = f"""\
|
|
80
|
+
Usage:
|
|
81
|
+
{INITDB_PROGRAM} [-h] [--truncate [--yes]]
|
|
82
|
+
|
|
83
|
+
Initialize database (not needed for SQLite).
|
|
84
|
+
Use --truncate to zero out the task metadata.\
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
INITDB_HELP = f"""\
|
|
88
|
+
{INITDB_USAGE}
|
|
89
|
+
|
|
90
|
+
Options:
|
|
91
|
+
-t, --truncate Truncate database (task metadata will be lost).
|
|
92
|
+
-y, --yes Auto-confirm truncation (default will prompt).
|
|
93
|
+
-h, --help Show this message and exit.\
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class InitDBApp(Application):
|
|
98
|
+
"""Initialize database (not needed for SQLite)."""
|
|
99
|
+
|
|
100
|
+
interface = Interface(INITDB_PROGRAM, INITDB_USAGE, INITDB_HELP)
|
|
101
|
+
|
|
102
|
+
ALLOW_NOARGS = True
|
|
103
|
+
|
|
104
|
+
truncate: bool = False
|
|
105
|
+
interface.add_argument('-t', '--truncate', action='store_true')
|
|
106
|
+
|
|
107
|
+
auto_confirm: bool = False
|
|
108
|
+
interface.add_argument('-y', '--yes', action='store_true', dest='auto_confirm')
|
|
109
|
+
|
|
110
|
+
exceptions = {
|
|
111
|
+
OperationalError: functools.partial(handle_exception, logger=log, status=exit_status.runtime_error),
|
|
112
|
+
**get_shared_exception_mapping(__name__),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
def run(self: InitDBApp) -> None:
|
|
116
|
+
"""Business logic for `initdb`."""
|
|
117
|
+
if not DATABASE_ENABLED:
|
|
118
|
+
raise ConfigurationError('No database configured')
|
|
119
|
+
elif not self.truncate:
|
|
120
|
+
initdb()
|
|
121
|
+
elif self.auto_confirm:
|
|
122
|
+
truncatedb()
|
|
123
|
+
elif not sys.stdout.isatty():
|
|
124
|
+
raise RuntimeError('Non-interactive prompt cannot confirm --truncate (see --yes).')
|
|
125
|
+
else:
|
|
126
|
+
if config.database.provider == 'sqlite':
|
|
127
|
+
site = config.database.file
|
|
128
|
+
else:
|
|
129
|
+
site = config.database.get('host', 'localhost')
|
|
130
|
+
print(f'Connected to: {config.database.provider} ({site})')
|
|
131
|
+
response = input(f'Truncate database ({Task.count()} tasks)? [Y]es/no: ').strip()
|
|
132
|
+
if response.lower() in ['', 'y', 'yes']:
|
|
133
|
+
truncatedb()
|
|
134
|
+
elif response.lower() in ['n', 'no']:
|
|
135
|
+
print('Stopping')
|
|
136
|
+
else:
|
|
137
|
+
raise RuntimeError(f'Stopping (invalid response: "{response}")')
|