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.
@@ -0,0 +1,79 @@
1
+ # SPDX-FileCopyrightText: 2025 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
+ import platform
14
+ from signal import signal as register
15
+
16
+
17
+ # internal libs
18
+ from hypershell.core.logging import Logger
19
+
20
+ # public interface
21
+ __all__ = ['check_signal', 'RECEIVED', 'SIGNAL_MAP',
22
+ 'handler', 'register_handlers', 'register',
23
+ 'SIGUSR1', 'SIGUSR2', 'SIGINT', 'SIGTERM', 'SIGKILL']
24
+
25
+
26
+ if platform.system() != 'Windows':
27
+ from signal import SIGUSR1, SIGUSR2, SIGINT, SIGTERM, SIGKILL
28
+ else:
29
+ # NOTE:
30
+ # Windows does not provide the signal facility
31
+ # While valid, these stubs have no effect because on Windows we never signal
32
+ SIGUSR1: Final[int] = 30
33
+ SIGUSR2: Final[int] = 31
34
+ SIGINT: Final[int] = 2
35
+ SIGTERM: Final[int] = 15
36
+ SIGKILL: Final[int] = 9
37
+
38
+
39
+ # initialize logger
40
+ log = Logger.with_name(__name__)
41
+
42
+
43
+ # Global signal value set by handler when received
44
+ RECEIVED: Optional[int] = None
45
+
46
+
47
+ def check_signal() -> Optional[int]:
48
+ """Check for signal received and return if so."""
49
+ return RECEIVED
50
+
51
+
52
+ SIGNAL_MAP: Final[Dict[int, str]] = {
53
+ SIGUSR1: 'SIGUSR1',
54
+ SIGUSR2: 'SIGUSR2',
55
+ SIGINT: 'SIGINT',
56
+ SIGTERM: 'SIGTERM',
57
+ SIGKILL: 'SIGKILL',
58
+ }
59
+
60
+
61
+ def handler(signum: int, frame: Optional[FrameType]) -> None: # noqa: unused frame
62
+ """Generic handler assigns `signum` to global variable."""
63
+ log.debug(f'Received signal {signum}: {SIGNAL_MAP.get(signum, "???")}')
64
+ global RECEIVED
65
+ RECEIVED = signum
66
+
67
+
68
+ if platform.system() == 'Windows':
69
+
70
+ def register_handlers() -> None:
71
+ """Empty function does nothing on Windows."""
72
+ pass
73
+
74
+ else:
75
+
76
+ def register_handlers() -> None:
77
+ """Register signal handlers for client."""
78
+ register(SIGUSR1, handler)
79
+ register(SIGUSR2, handler)
hypershell/core/sys.py ADDED
@@ -0,0 +1,39 @@
1
+ # SPDX-FileCopyrightText: 2025 Geoffrey Lentner
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """
5
+ Sanitize and supplement package environment.
6
+
7
+ For most installations this module does nothing and can be ignored.
8
+ For system installation as an application (non-library use) we do not want user
9
+ programs (which may be Python-based with PYTHONPATH implications) to interfere
10
+ with this program.
11
+
12
+ If we are not installed in the user-site, we strip the user site-packages from
13
+ `sys.path` as well as the current working directory. It should not matter what
14
+ directory you run this program from.
15
+
16
+ If `ctx.path.system.config`/hypershell.pth exists it will take this as truth
17
+ """
18
+
19
+ # Standard libs
20
+ import sys
21
+
22
+ if sys
23
+
24
+ # NOTE:
25
+ # This special module freezes the internal sys.path used by the application.
26
+ # We do not want to do this for library use or under any normal circumstances.
27
+ # When the program is installed system wide as a utility we need to ensure that
28
+ # we are not being polluted by some other applications environment (e.g., PYTHONPATH).
29
+ # This also allows us to suppliment with special locations of vendored dependencies.
30
+ print('FIXING IMPORTS', flush=True)
31
+ sys.path.clear()
32
+ sys.path.extend([
33
+ '/Library/Frameworks/Python.framework/Versions/3.12/lib/python312.zip',
34
+ '/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12',
35
+ '/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/lib-dynload',
36
+ '/Users/geoffrey/Library/Caches/pypoetry/virtualenvs/hypershell-NY9Seigz-py3.12/lib/python3.12/site-packages',
37
+ '/Users/geoffrey/Code/github.com/hypershell/hypershell/src'
38
+ ])
39
+ print('FIXING IMPORTS - DONE', flush=True)
hypershell/core/tag.py ADDED
@@ -0,0 +1,47 @@
1
+ # SPDX-FileCopyrightText: 2025 Geoffrey Lentner
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Tag interface and parsing."""
5
+
6
+
7
+ # type annotations
8
+ from __future__ import annotations
9
+ from typing import Dict, List, Optional, Type
10
+
11
+ # standard libs
12
+ from dataclasses import dataclass
13
+
14
+ # internal libs
15
+ from hypershell.core.types import JSONValue, smart_coerce
16
+
17
+ # public interface
18
+ __all__ = ['Tag', ]
19
+
20
+
21
+ @dataclass
22
+ class Tag:
23
+ """Tag specification."""
24
+
25
+ name: str
26
+ value: JSONValue = ''
27
+
28
+ def to_dict(self: Tag) -> Dict[str, JSONValue]:
29
+ """Format tag specification as dictionary."""
30
+ return {self.name: self.value, }
31
+
32
+ @classmethod
33
+ def from_cmdline(cls: Type[Tag], arg: str) -> Tag:
34
+ """Construct from command-line `arg`."""
35
+ tag_part = arg.strip().split(':', 1)
36
+ if len(tag_part) == 1:
37
+ return cls(name=tag_part[0].strip())
38
+ else:
39
+ name, value = tag_part[0].strip(), smart_coerce(tag_part[1].strip())
40
+ # Task.ensure_valid_tag({name: value})
41
+ return cls(name, value)
42
+
43
+ @classmethod
44
+ def parse_cmdline_list(cls: Type[Tag], args: List[str]) -> Dict[str, Optional[JSONValue]]:
45
+ """Parse command-line list of tags."""
46
+ return {tag.name: tag.value for tag in map(cls.from_cmdline, args)}
47
+
@@ -0,0 +1,201 @@
1
+ # SPDX-FileCopyrightText: 2025 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: 2025 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: 2025 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: 2025 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}")')