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.
@@ -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
@@ -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}")')