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,359 @@
1
+ # SPDX-FileCopyrightText: 2025 Geoffrey Lentner
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Runtime configuration for HyperShell."""
5
+
6
+
7
+ # type annotations
8
+ from __future__ import annotations
9
+ from typing import TypeVar, Union, List, Optional, Protocol, Final, Iterator
10
+
11
+ # standard libs
12
+ import os
13
+ import re
14
+ import sys
15
+ import shutil
16
+ import tomlkit
17
+ import logging
18
+ import socket
19
+ import functools
20
+ from datetime import datetime
21
+
22
+ # external libs
23
+ from cmdkit.config import Namespace, Configuration, Environ, ConfigurationError
24
+ from cmdkit.app import exit_status
25
+
26
+ # internal libs
27
+ from hypershell.core.platform import path, home
28
+ from hypershell.core.exceptions import write_traceback
29
+
30
+ # public interface
31
+ __all__ = ['config', 'update', 'default', 'ConfigurationError', 'Namespace', 'blame',
32
+ 'load', 'reload', 'reload_local', 'load_file', 'reload_file', 'load_env', 'reload_env', 'load_task_env',
33
+ 'DEFAULT_LOGGING_STYLE', 'LOGGING_STYLES', 'ACTIVE_CONFIG_VARS', 'SSH_GROUPS',
34
+ 'find_available_ports']
35
+
36
+ # partial logging (not yet configured - initialized afterward)
37
+ log = logging.getLogger(__name__)
38
+
39
+
40
+ DEFAULT_LOGGING_STYLE = 'default'
41
+ LOGGING_STYLES = {
42
+ 'default': {
43
+ 'format': ('%(ansi_bold)s%(ansi_level)s%(levelname)8s%(ansi_reset)s %(ansi_faint)s[%(name)s]%(ansi_reset)s'
44
+ ' %(message)s'),
45
+ },
46
+ 'system': {
47
+ 'format': '%(asctime)s.%(msecs)03d %(hostname)s %(levelname)8s [%(app_id)s] [%(name)s] %(message)s',
48
+ },
49
+ 'detailed': {
50
+ 'format': ('%(ansi_faint)s%(asctime)s.%(msecs)03d %(hostname)s %(ansi_reset)s'
51
+ '%(ansi_level)s%(ansi_bold)s%(levelname)8s%(ansi_reset)s '
52
+ '%(ansi_faint)s[%(name)s]%(ansi_reset)s %(message)s'),
53
+ },
54
+ 'detailed-compact': {
55
+ 'format': ('%(ansi_faint)s%(elapsed_hms)s [%(hostname_short)s] %(ansi_reset)s'
56
+ '%(ansi_level)s%(ansi_bold)s%(levelname)8s%(ansi_reset)s '
57
+ '%(ansi_faint)s[%(relative_name)s]%(ansi_reset)s %(message)s'),
58
+ }
59
+ }
60
+
61
+
62
+ # environment variables and configuration files are automatically
63
+ # depth-first merged with defaults
64
+ default = Namespace({
65
+
66
+ 'database': {
67
+ 'provider': 'sqlite',
68
+ },
69
+
70
+ 'logging': {
71
+ 'color': True,
72
+ 'level': 'warning',
73
+ 'datefmt': '%Y-%m-%d %H:%M:%S',
74
+ 'style': DEFAULT_LOGGING_STYLE,
75
+ **LOGGING_STYLES.get(DEFAULT_LOGGING_STYLE),
76
+ },
77
+
78
+ 'task': {
79
+ 'cwd': os.getcwd(),
80
+ 'timeout': None, # seconds, period to wait before killing tasks
81
+ 'signalwait': 10, # seconds to wait between signal escalation (INT, TERM, KILL)
82
+ },
83
+
84
+ 'submit': {
85
+ 'bundlesize': 1, # size of task bundle to accumulate before committing
86
+ 'bundlewait': 5 # seconds to wait before committing regardless of size
87
+ },
88
+
89
+ 'server': {
90
+ 'bind': 'localhost',
91
+ 'port': 50_001,
92
+ 'auth': '__HYPERSHELL__BAD__AUTHKEY__',
93
+ 'queuesize': 1, # only allow a single bundle (scheduler must wait)
94
+ 'bundlesize': 1,
95
+ 'bundlewait': 5, # seconds
96
+ 'attempts': 1,
97
+ 'eager': False, # prefer failed tasks to new tasks
98
+ 'wait': 5, # seconds to wait between database queries
99
+ 'evict': 600, # assume client is gone if no heartbeat after this many seconds
100
+ },
101
+
102
+ 'client': {
103
+ 'bundlesize': 1, # size of task bundle to accumulate before returning
104
+ 'bundlewait': 5, # seconds to wait before returning regardless of size
105
+ 'heartrate': 10, # seconds to wait between heartbeats
106
+ 'timeout': None, # seconds to wait for bundle from server before shutting down
107
+ },
108
+
109
+ 'ssh': {
110
+ 'config': os.path.join(home, '.ssh', 'config'),
111
+ 'nodelist': {} # Populated by user configuration
112
+ },
113
+
114
+ 'autoscale': {
115
+ 'policy': 'fixed', # Either 'fixed' or 'dynamic'
116
+ 'factor': 1,
117
+ 'period': 60, # seconds to wait between checks
118
+ 'launcher': '', # empty means just 'hs client'
119
+ 'size': {
120
+ 'init': 1,
121
+ 'min': 0,
122
+ 'max': 2,
123
+ },
124
+ },
125
+
126
+ 'console': {
127
+ 'theme': 'monokai',
128
+ },
129
+
130
+ # NOTE: defining HYPERSHELL_EXPORT_XXX defines XXX within task env
131
+ 'export': {}
132
+ })
133
+
134
+
135
+ def reload_file(filepath: str) -> Namespace:
136
+ """Force reloading configuration file."""
137
+ if not os.path.exists(filepath):
138
+ return Namespace({})
139
+ try:
140
+ return Namespace.from_toml(filepath)
141
+ except Exception as err:
142
+ raise ConfigurationError(f'(from file: {filepath}) {err.__class__.__name__}: {err}')
143
+
144
+
145
+ @functools.lru_cache(maxsize=None)
146
+ def load_file(filepath: str) -> Namespace:
147
+ """Load configuration file."""
148
+ return reload_file(filepath)
149
+
150
+
151
+ def reload_env() -> Environ:
152
+ """Force reloading environment variables and expanding hierarchy as namespace."""
153
+ return Environ(prefix='HYPERSHELL').expand()
154
+
155
+
156
+ @functools.lru_cache(maxsize=None)
157
+ def load_env() -> Environ:
158
+ """Load environment variables and expand hierarchy as namespace."""
159
+ return reload_env()
160
+
161
+
162
+ def partial_load(system: Optional[str] = path.system.config,
163
+ user: Optional[str] = path.user.config,
164
+ local: Optional[str] = path.local.config,
165
+ **preload: Namespace) -> Configuration:
166
+ """Load configuration from files and merge environment variables."""
167
+ return Configuration(**{
168
+ 'default': default, **preload,
169
+ 'system': {} if not system else load_file(system),
170
+ 'user': {} if not user else load_file(user),
171
+ 'local': {} if not user else load_file(local),
172
+ 'env': load_env(),
173
+ })
174
+
175
+
176
+ def partial_reload(system: Optional[str] = path.system.config,
177
+ user: Optional[str] = path.user.config,
178
+ local: Optional[str] = path.local.config,
179
+ **preload: Namespace) -> Configuration:
180
+ """Force reload configuration from files and merge environment variables."""
181
+ return Configuration(**{
182
+ 'default': default, **preload,
183
+ 'system': {} if not system else reload_file(system),
184
+ 'user': {} if not user else reload_file(user),
185
+ 'local': {} if not local else reload_file(local),
186
+ 'env': reload_env(),
187
+ })
188
+
189
+
190
+ def blame(base: Configuration, *varpath: str) -> Optional[str]:
191
+ """Construct filename or variable assignment string based on precedent of `varpath`."""
192
+ source = base.which(*varpath)
193
+ if not source:
194
+ return None
195
+ if source in ('system', 'user', 'local'):
196
+ return f'from: {path.get(source).config}'
197
+ elif source == 'env':
198
+ return 'from: HYPERSHELL_' + '_'.join([node.upper() for node in varpath])
199
+ else:
200
+ return f'from: <{source}>'
201
+
202
+
203
+ def get_logging_style(base: Configuration) -> str:
204
+ """Get and check valid on `config.logging.style`."""
205
+ style = base.logging.style
206
+ label = blame(base, 'logging', 'style')
207
+ if not isinstance(style, str):
208
+ raise ConfigurationError(f'Expected string for `logging.style` ({label})')
209
+ style = style.lower()
210
+ if style in LOGGING_STYLES:
211
+ return style
212
+ else:
213
+ raise ConfigurationError(f'Unrecognized `logging.style` \'{style}\' ({label})')
214
+
215
+
216
+ def build_preloads(base: Configuration) -> Namespace:
217
+ """Build 'preload' namespace from base configuration."""
218
+ return Namespace({'logging': LOGGING_STYLES.get(get_logging_style(base))})
219
+
220
+
221
+ class LoaderImpl(Protocol):
222
+ """Loader interface for building configuration."""
223
+ def __call__(self: LoaderImpl,
224
+ system: Optional[str] = path.system.config,
225
+ user: Optional[str] = path.user.config,
226
+ local: Optional[str] = path.local.config,
227
+ **preload: Namespace) -> Configuration: ...
228
+
229
+
230
+ def build_configuration(loader: LoaderImpl) -> Configuration:
231
+ """Construct full configuration."""
232
+ return loader(preload=build_preloads(base=loader()))
233
+
234
+
235
+ def load() -> Configuration:
236
+ """Load configuration from files and merge environment variables."""
237
+ return build_configuration(loader=partial_load)
238
+
239
+
240
+ def reload() -> Configuration:
241
+ """Load configuration from files and merge environment variables."""
242
+ return build_configuration(loader=partial_reload)
243
+
244
+
245
+ def reload_local(filepath: Optional[str] = None) -> Configuration:
246
+ """Load configuration but only include one file."""
247
+ loader = functools.partial(partial_reload, system=None, user=None, local=filepath)
248
+ return build_configuration(loader=loader)
249
+
250
+
251
+ try:
252
+ if (local_config := os.getenv('HYPERSHELL_CONFIG_FILE', None)) is not None:
253
+ path.local.config = local_config # Modified as to not lie to the user
254
+ config = reload_local(local_config)
255
+ else:
256
+ config = load()
257
+ except Exception as error:
258
+ write_traceback(error, module=__name__)
259
+ sys.exit(exit_status.bad_config)
260
+
261
+
262
+ ACTIVE_CONFIG_VARS: Final[List[str]] = [
263
+ re.sub(r'_(?!(env|eval))', r'.', name.lower())
264
+ for name in Namespace(config).to_env().flatten()
265
+ ]
266
+
267
+
268
+ SSH_GROUPS = []
269
+ try:
270
+ if isinstance(config.ssh.nodelist, dict):
271
+ SSH_GROUPS = list(config.ssh.nodelist)
272
+ except KeyError:
273
+ pass
274
+
275
+
276
+ def find_available_ports(start: int = default.server.port,
277
+ end: int = default.server.port + 1_000) -> Iterator[int]:
278
+ """Yield available ports by testing each in turn."""
279
+ for port in range(start, end + 1):
280
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
281
+ sock.settimeout(1)
282
+ try:
283
+ sock.bind(("0.0.0.0", port))
284
+ yield port
285
+ except socket.error:
286
+ pass
287
+ else:
288
+ raise RuntimeError(f'Could not find available port in range {start}-{end}')
289
+
290
+
291
+ DEFAULT_CONFIG_HEADERS = f"""\
292
+ # File automatically created on {datetime.now()}
293
+ # Settings here are merged automatically with defaults and environment variables
294
+ """
295
+
296
+
297
+ def update(scope: str, partial: dict) -> None:
298
+ """Extend the current configuration and commit it to disk."""
299
+ config_path = path[scope].config
300
+ os.makedirs(os.path.dirname(config_path), exist_ok=True)
301
+ if os.path.exists(config_path):
302
+ timestamp = datetime.now().strftime('%Y%m%d-%H%M%S')
303
+ config_backup_path = os.path.join(os.path.dirname(config_path), f'.config.{timestamp}.toml')
304
+ shutil.copy(config_path, config_backup_path)
305
+ shutil.copystat(config_path, config_backup_path)
306
+ log.debug(f'Created backup file ({config_backup_path})')
307
+ else:
308
+ with open(config_path, mode='w') as stream:
309
+ stream.write(DEFAULT_CONFIG_HEADERS)
310
+ with open(config_path, mode='r') as stream:
311
+ new_config = tomlkit.parse(stream.read())
312
+ _inplace_update(new_config, partial)
313
+ with open(config_path, mode='w') as stream:
314
+ tomlkit.dump(new_config, stream)
315
+
316
+
317
+ # Re-implemented from `cmdkit.config.Namespace` (but works with `tomlkit`)
318
+ def _inplace_update(original: dict, partial: dict) -> dict:
319
+ """
320
+ Like normal `dict.update` but if values in both are mappable, descend
321
+ a level deeper (recursive) and apply updates there instead.
322
+ """
323
+ for key, value in partial.items():
324
+ if isinstance(value, dict) and isinstance(original.get(key), dict):
325
+ original[key] = _inplace_update(original.get(key, {}), value)
326
+ else:
327
+ original[key] = value
328
+ return original
329
+
330
+
331
+ if os.name == 'nt':
332
+ PATH_DELIMITER = ';'
333
+ else:
334
+ PATH_DELIMITER = ':'
335
+
336
+
337
+ T = TypeVar('T')
338
+
339
+
340
+ def __collapse_if_list_impl(value: Union[T, List[str]]) -> Union[T, str]:
341
+ """If `value` is a list, collapse it to a path-like list (with ':' or ';')."""
342
+ return value if not isinstance(value, list) else PATH_DELIMITER.join([str(member) for member in value])
343
+
344
+
345
+ def __collapse_lists(ns: Namespace) -> Namespace:
346
+ """Collapse member values if they are a list, recursively."""
347
+ result = Namespace()
348
+ for key, value in ns.items():
349
+ if isinstance(value, Namespace):
350
+ result[key] = __collapse_lists(value)
351
+ else:
352
+ result[key] = __collapse_if_list_impl(value)
353
+ return result
354
+
355
+
356
+ @functools.cache
357
+ def load_task_env() -> Environ:
358
+ """Export environment defined in `config.export`."""
359
+ return __collapse_lists(config.export).to_env().flatten()
@@ -0,0 +1,120 @@
1
+ # SPDX-FileCopyrightText: 2025 Geoffrey Lentner
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Core exception handling useful for import-time issues."""
5
+
6
+
7
+ # type annotations
8
+ from __future__ import annotations
9
+ from typing import Dict, Union, Callable, Type
10
+
11
+ # standard libraries
12
+ import os
13
+ import sys
14
+ import logging
15
+ import functools
16
+ import traceback
17
+ from datetime import datetime
18
+
19
+ # external libs
20
+ from cmdkit.app import exit_status
21
+ from cmdkit.config import Namespace
22
+ from cmdkit.config import ConfigurationError
23
+ from cmdkit.ansi import faint, bold, magenta, yellow, red, COLOR_STDERR
24
+
25
+ # internal libs
26
+ from hypershell.core.platform import default_path
27
+
28
+ # public interface
29
+ __all__ = ['display_warning', 'display_error', 'display_critical', 'traceback_filepath', 'write_traceback',
30
+ 'handle_exception', 'handle_exception_silently', 'handle_disconnect', 'handle_address_unknown',
31
+ 'HostAddressInfo', 'DatabaseUninitialized',
32
+ 'get_shared_exception_mapping', ]
33
+
34
+
35
+ def _display_message(levelname: str, error: Union[Exception, str],
36
+ module: str = None, colorized: Callable[[str], str] = None) -> None:
37
+ """Generic message display for import-time warnings and errors."""
38
+ text = error if isinstance(error, str) else f'{error.__class__.__name__}: {error}'
39
+ if COLOR_STDERR:
40
+ name = '' if not module else faint(f'[{module}]')
41
+ level = levelname if colorized is None else bold(colorized(levelname))
42
+ else:
43
+ name = '' if not module else f'[{module}]'
44
+ level = levelname
45
+ print(f'{level} {name} {text}', file=sys.stderr)
46
+
47
+
48
+ # Specialized methods for each severity level
49
+ display_warning = functools.partial(_display_message, 'WARNING', colorized=yellow)
50
+ display_error = functools.partial(_display_message, 'ERROR', colorized=red)
51
+ display_critical = functools.partial(_display_message, 'CRITICAL', colorized=magenta)
52
+
53
+
54
+ class HostAddressInfo(Exception):
55
+ """Could not resolve hostname."""
56
+
57
+
58
+ class DatabaseUninitialized(Exception):
59
+ """The database needs to be initialized before operations."""
60
+
61
+
62
+ def traceback_filepath(path: Namespace = None) -> str:
63
+ """Construct filepath for writing traceback."""
64
+ path = path or default_path
65
+ time = datetime.now().strftime('%Y%m%d-%H%M%S')
66
+ return os.path.join(path.log, f'exception-{time}.log')
67
+
68
+
69
+ def write_traceback(exc: Exception, site: Namespace = None, logger: logging.Logger = None,
70
+ status: int = exit_status.uncaught_exception, module: str = None) -> int:
71
+ """Write exception to file and return exit code."""
72
+ write = functools.partial(display_critical, module=module) if not logger else logger.critical
73
+ path = traceback_filepath(site)
74
+ with open(path, mode='w') as stream:
75
+ print(traceback.format_exc(), file=stream)
76
+ write(f'{exc.__class__.__name__}: ' + str(exc).replace('\n', ' - '))
77
+ write(f'Exception traceback written to {path}')
78
+ return status
79
+
80
+
81
+ def handle_disconnect(exc: Exception, logger: logging.Logger) -> int:
82
+ """An EOFError results from the server hanging up the client."""
83
+ logger.critical(f'{exc.__class__.__name__}: server disconnected')
84
+ return exit_status.runtime_error
85
+
86
+
87
+ def handle_exception(exc: Exception, logger: logging.Logger, status: int) -> int:
88
+ """Log the exception argument and exit with `status`."""
89
+ logger.critical(f'{exc.__class__.__name__}: ' + str(exc).replace('\n', ' - '))
90
+ return status
91
+
92
+
93
+ def handle_exception_silently(exc: Exception) -> int:
94
+ """Return status held by `exc.args` without logging."""
95
+ status, = exc.args
96
+ return status
97
+
98
+
99
+ def handle_address_unknown(exc: Exception, # noqa: unused
100
+ logger: logging.Logger,
101
+ status: int = exit_status.runtime_error) -> int:
102
+ """Could not get address info for hostname (see `socket.gaierror`)."""
103
+ logger.critical(f'{exc.__class__.__name__}: {exc}')
104
+ return status
105
+
106
+
107
+ def get_shared_exception_mapping(modname: str = 'hypershell') -> Dict[Type[Exception], Callable[[Exception], int]]:
108
+ """Globally defined exception cases for all application subcommands."""
109
+ # We need a single location to define basic exception cases but cannot define them under
110
+ # `hypershell.__init__` as a class-level override of `Application.exceptions` because the
111
+ # subcommand classes are imported first. This function is now part of core and has no
112
+ # internal dependencies, so it can be imported by all other modules.
113
+ logger = logging.getLogger(modname)
114
+ return {
115
+ RuntimeError: functools.partial(handle_exception, logger=logger, status=exit_status.runtime_error),
116
+ FileNotFoundError: functools.partial(handle_exception, logger=logger, status=exit_status.runtime_error),
117
+ ConfigurationError: functools.partial(handle_exception, logger=logger, status=exit_status.bad_config),
118
+ DatabaseUninitialized: functools.partial(handle_exception, logger=logger, status=exit_status.runtime_error),
119
+ Exception: functools.partial(write_traceback, logger=logger, status=exit_status.runtime_error),
120
+ }
hypershell/core/fsm.py ADDED
@@ -0,0 +1,82 @@
1
+ # SPDX-FileCopyrightText: 2025 Geoffrey Lentner
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Instrumentation for building finite state machines."""
5
+
6
+
7
+ # type annotations
8
+ from __future__ import annotations
9
+ from typing import Dict, Callable, Type
10
+
11
+ # standard libs
12
+ from enum import Enum
13
+ from abc import ABC
14
+ # FUZZ: import time
15
+ # FUZZ: import random
16
+ # PERF: from collections import defaultdict
17
+ # PERF: from time import perf_counter
18
+
19
+ # internal libs
20
+ from hypershell.core.exceptions import write_traceback
21
+ from hypershell.core.logging import Logger
22
+
23
+ # public interface
24
+ __all__ = ['State', 'StateMachine', ]
25
+
26
+
27
+ log = Logger.with_name(__name__)
28
+
29
+
30
+ class State(Enum):
31
+ """Shared base for finite state enums (must have at least HALT)."""
32
+
33
+
34
+ class StateMachine(ABC):
35
+ """Base class for a finite state machine implementation."""
36
+
37
+ state: State
38
+ states: Type[State]
39
+ actions: Dict[State, Callable[[], State]]
40
+
41
+ __should_halt: bool = False
42
+
43
+ # NOTE: Only needed during performance profiling
44
+ # PERF: __perf_counter: float = 0
45
+ # PERF: __perf_data: Dict[int, float]
46
+
47
+ def next(self) -> State:
48
+ """Return next state (halt if necessary)."""
49
+ previous_state = self.state
50
+ try:
51
+ if self.__should_halt:
52
+ return self.states.HALT # noqa: HALT defined in implemented State enums
53
+ else:
54
+ action = self.actions.get(previous_state)
55
+ # PERF: self.__perf_counter = perf_counter()
56
+ next_state = action()
57
+ # PERF: self.__perf_data[previous_state.value] += perf_counter() - self.__perf_counter
58
+ except Exception as error:
59
+ # NOTE: Only non-RuntimeError instances are "unexpected"
60
+ if not isinstance(error, RuntimeError):
61
+ log.critical(f'Uncaught exception from {self.__class__}')
62
+ write_traceback(error, logger=log, module=__name__)
63
+ raise
64
+ else:
65
+ # NOTE: Development aids not typically engaged
66
+ # FUZZ: time.sleep(random.uniform(0, 5)) # FUZZ
67
+ # FUZZ: log.devel(f'{self.__class__.__name__}: {previous_state} -> {next_state}')
68
+ return next_state
69
+
70
+ def run(self) -> None:
71
+ """Run machine until state is set to `HALT`."""
72
+ # PERF: self.__perf_data = defaultdict(lambda: 0)
73
+ while self.state is not self.states.HALT: # noqa: HALT defined in implemented State enums
74
+ self.state = self.next()
75
+ # PERF: time_total = sum(self.__perf_data.values())
76
+ # PERF: for key, value in self.__perf_data.items():
77
+ # PERF: t = 100 * value / time_total
78
+ # PERF: log.trace(f'Profiler[{self.__class__.__name__}] {self.states(key).name}: {t:.3f}')
79
+
80
+ def halt(self) -> None:
81
+ """Set flag to signal for termination."""
82
+ self.__should_halt = True
@@ -0,0 +1,70 @@
1
+ # SPDX-FileCopyrightText: 2025 Geoffrey Lentner
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Heartbeat data passed between client and server."""
5
+
6
+
7
+ # type annotations
8
+ from __future__ import annotations
9
+ from typing import Type
10
+
11
+ # standard libs
12
+ import json
13
+ from enum import Enum
14
+ from datetime import datetime
15
+ from dataclasses import dataclass
16
+
17
+ # internal libs
18
+ from hypershell.core.logging import HOSTNAME, INSTANCE
19
+
20
+ # public interface
21
+ __all__ = ['ClientState', 'Heartbeat']
22
+
23
+
24
+ class ClientState(Enum):
25
+ """Client state."""
26
+
27
+ RUNNING = 0
28
+ FINISHED = 1
29
+
30
+ @classmethod
31
+ def from_value(cls: Type[ClientState], value: int) -> ClientState:
32
+ """Instance from associated integer value."""
33
+ return {0: cls.RUNNING, 1: cls.FINISHED}.get(value)
34
+
35
+
36
+ @dataclass
37
+ class Heartbeat:
38
+ """Momentary notice of a client's active status."""
39
+
40
+ uuid: str
41
+ host: str
42
+ time: datetime
43
+ state: ClientState
44
+
45
+ @classmethod
46
+ def new(cls: Type[Heartbeat],
47
+ uuid: str = None,
48
+ host: str = None,
49
+ time: datetime = None,
50
+ state: ClientState = None) -> Heartbeat:
51
+ """Create new instance."""
52
+ return cls(uuid=(uuid or INSTANCE),
53
+ host=(host or HOSTNAME),
54
+ time=(time or datetime.now().astimezone()),
55
+ state=(state or ClientState.RUNNING))
56
+
57
+ def pack(self: Heartbeat) -> bytes:
58
+ """Serialize data."""
59
+ return json.dumps({'uuid': self.uuid,
60
+ 'host': self.host,
61
+ 'time': str(self.time),
62
+ 'state': self.state.value}).encode('utf-8')
63
+
64
+ @classmethod
65
+ def unpack(cls: Type[Heartbeat], data: bytes) -> Heartbeat:
66
+ """Deserialize from raw `data`."""
67
+ data = json.loads(data.decode('utf-8'))
68
+ data['time'] = datetime.fromisoformat(data['time'])
69
+ data['state'] = ClientState.from_value(data['state'])
70
+ return cls(**data)