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/__init__.py +115 -0
- hypershell/client.py +1255 -0
- hypershell/cluster/__init__.py +378 -0
- hypershell/cluster/local.py +210 -0
- hypershell/cluster/remote.py +721 -0
- hypershell/cluster/ssh.py +352 -0
- hypershell/config.py +440 -0
- hypershell/core/__init__.py +4 -0
- hypershell/core/config.py +359 -0
- hypershell/core/exceptions.py +120 -0
- hypershell/core/fsm.py +82 -0
- hypershell/core/heartbeat.py +70 -0
- hypershell/core/logging.py +201 -0
- hypershell/core/platform.py +121 -0
- hypershell/core/queue.py +155 -0
- hypershell/core/remote.py +192 -0
- hypershell/core/signal.py +79 -0
- hypershell/core/sys.py +39 -0
- hypershell/core/tag.py +47 -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 +232 -0
- hypershell/data/model.py +674 -0
- hypershell/server.py +1207 -0
- hypershell/submit.py +815 -0
- hypershell/task.py +1141 -0
- hypershell-2.6.4.dist-info/LICENSE +201 -0
- hypershell-2.6.4.dist-info/METADATA +117 -0
- hypershell-2.6.4.dist-info/RECORD +33 -0
- hypershell-2.6.4.dist-info/WHEEL +4 -0
- hypershell-2.6.4.dist-info/entry_points.txt +4 -0
hypershell/client.py
ADDED
|
@@ -0,0 +1,1255 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025 Geoffrey Lentner
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
Connect to server and run tasks.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
>>> from hypershell.client import run_client
|
|
9
|
+
>>> run_client(num_tasks=4, address=('<IP ADDRESS>', 8080), auth='<secret>')
|
|
10
|
+
|
|
11
|
+
Embed a `ClientThread` in your application directly. Call `stop()` to stop early.
|
|
12
|
+
Clients cannot connect to a remote machine unless you set the server's `bind` address
|
|
13
|
+
to 0.0.0.0 (as opposed to localhost which is the default).
|
|
14
|
+
|
|
15
|
+
Example:
|
|
16
|
+
>>> from hypershell.client import ClientThread
|
|
17
|
+
>>> client_thread = ClientThread.new(num_tasks=4, address=('<IP ADDRESS>', 8080), auth='<secret>')
|
|
18
|
+
|
|
19
|
+
Note:
|
|
20
|
+
In order for the `ClientThread` to actively monitor the state set by `stop` and
|
|
21
|
+
halt execution (a requirement because of how CPython does threading), the implementation
|
|
22
|
+
uses a finite state machine. *You should not instantiate this machine directly*.
|
|
23
|
+
|
|
24
|
+
Warning:
|
|
25
|
+
Because the `ClientThread` checks state actively to decide whether to halt, it may take
|
|
26
|
+
some few moments before it shutsdown on its own. If your main program exits however,
|
|
27
|
+
the thread will be stopped regardless because it runs as a `daemon`.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# type annotations
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
from typing import List, Tuple, Optional, Callable, Dict, IO, Type, Final
|
|
34
|
+
from types import TracebackType
|
|
35
|
+
|
|
36
|
+
# standard libs
|
|
37
|
+
import os
|
|
38
|
+
import sys
|
|
39
|
+
import time
|
|
40
|
+
import json
|
|
41
|
+
import random
|
|
42
|
+
import functools
|
|
43
|
+
from enum import Enum
|
|
44
|
+
from datetime import datetime, timedelta
|
|
45
|
+
from queue import Queue, Empty as QueueEmpty, Full as QueueFull
|
|
46
|
+
from subprocess import Popen, TimeoutExpired
|
|
47
|
+
from socket import gaierror
|
|
48
|
+
from dataclasses import dataclass
|
|
49
|
+
from multiprocessing import AuthenticationError, cpu_count
|
|
50
|
+
|
|
51
|
+
# external libs
|
|
52
|
+
from cmdkit.app import Application, exit_status
|
|
53
|
+
from cmdkit.cli import Interface, ArgumentError
|
|
54
|
+
from cmdkit.config import Namespace
|
|
55
|
+
|
|
56
|
+
# internal libs
|
|
57
|
+
from hypershell.data.model import Task
|
|
58
|
+
from hypershell.core.heartbeat import Heartbeat, ClientState
|
|
59
|
+
from hypershell.core.platform import default_path
|
|
60
|
+
from hypershell.core.config import default, config, load_task_env, SSH_GROUPS
|
|
61
|
+
from hypershell.core.fsm import State, StateMachine
|
|
62
|
+
from hypershell.core.thread import Thread
|
|
63
|
+
from hypershell.core.signal import check_signal, SIGNAL_MAP, SIGUSR1, SIGUSR2, SIGINT
|
|
64
|
+
from hypershell.core.queue import QueueClient, QueueConfig
|
|
65
|
+
from hypershell.core.logging import HOSTNAME, INSTANCE, Logger
|
|
66
|
+
from hypershell.core.template import Template, DEFAULT_TEMPLATE
|
|
67
|
+
from hypershell.core.exceptions import (handle_exception, handle_disconnect,
|
|
68
|
+
handle_address_unknown, HostAddressInfo, get_shared_exception_mapping)
|
|
69
|
+
|
|
70
|
+
# public interface
|
|
71
|
+
__all__ = ['run_client', 'ClientThread', 'ClientApp', 'ClientInfo',
|
|
72
|
+
'DEFAULT_BUNDLESIZE', 'DEFAULT_BUNDLEWAIT', 'DEFAULT_TEMPLATE',
|
|
73
|
+
'DEFAULT_NUM_TASKS', 'DEFAULT_DELAY', 'DEFAULT_SIGNALWAIT',
|
|
74
|
+
'DEFAULT_HEARTRATE', 'DEFAULT_HOST', 'DEFAULT_PORT', 'DEFAULT_AUTH',
|
|
75
|
+
'set_client_standalone']
|
|
76
|
+
|
|
77
|
+
# initialize logger
|
|
78
|
+
log = Logger.with_name(__name__)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# NOTE:
|
|
82
|
+
# The UNIX signal facility works on stand-alone server/client, but when running a LocalCluster with
|
|
83
|
+
# a client as a local thread, the USR1/USR2 signals prevent clients from sending the proper finalization
|
|
84
|
+
# messages. This flag is set by LocalCluster to prevent greedy client-side shutdown behavior.
|
|
85
|
+
CLIENT_STANDALONE_MODE: bool = True
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def set_client_standalone(mode: bool) -> None:
|
|
89
|
+
"""Set global flag to prevent greedy shutdown from USR1/USR2 signals."""
|
|
90
|
+
global CLIENT_STANDALONE_MODE
|
|
91
|
+
CLIENT_STANDALONE_MODE = mode
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class ClientInfo:
|
|
96
|
+
"""Client instance ID/hostname and task ID mapping."""
|
|
97
|
+
|
|
98
|
+
client_id: str
|
|
99
|
+
client_host: str
|
|
100
|
+
task_ids: List[str]
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def from_dict(cls: Type[ClientInfo], data: dict) -> ClientInfo:
|
|
104
|
+
"""Initialize from existing dictionary."""
|
|
105
|
+
return cls(**data)
|
|
106
|
+
|
|
107
|
+
def to_dict(self: ClientInfo) -> dict:
|
|
108
|
+
"""Export to dictionary."""
|
|
109
|
+
return {'client_id': self.client_id, 'client_host': self.client_host, 'task_ids': self.task_ids}
|
|
110
|
+
|
|
111
|
+
def pack(self: ClientInfo) -> bytes:
|
|
112
|
+
"""Serialize data."""
|
|
113
|
+
return json.dumps(self.to_dict()).encode('utf-8')
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def unpack(cls: Type[ClientInfo], data: bytes) -> ClientInfo:
|
|
117
|
+
"""Deserialize from raw `data`."""
|
|
118
|
+
return cls.from_dict(json.loads(data.decode('utf-8')))
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def from_tasks(cls: Type[ClientInfo], tasks: List[Task]) -> ClientInfo:
|
|
122
|
+
"""Initialize from list of existing Task instances."""
|
|
123
|
+
return cls(client_id=INSTANCE, client_host=HOSTNAME,
|
|
124
|
+
task_ids=[task.id for task in tasks])
|
|
125
|
+
|
|
126
|
+
def transpose(self: ClientInfo) -> List[Dict[str, str]]:
|
|
127
|
+
"""Represent as list of dicts for database update."""
|
|
128
|
+
return [{'id': task_id, 'client_id': self.client_id, 'client_host': self.client_host}
|
|
129
|
+
for task_id in self.task_ids]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class SchedulerState(State, Enum):
|
|
133
|
+
"""Finite states for scheduler."""
|
|
134
|
+
START = 0
|
|
135
|
+
GET_REMOTE = 1
|
|
136
|
+
UNPACK = 2
|
|
137
|
+
PUT_CONFIRM = 3
|
|
138
|
+
POP_TASK = 4
|
|
139
|
+
PUT_LOCAL = 5
|
|
140
|
+
FINAL = 6
|
|
141
|
+
HALT = 7
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ClientScheduler(StateMachine):
|
|
145
|
+
"""Receive task bundles from server and schedule locally."""
|
|
146
|
+
|
|
147
|
+
queue: QueueClient
|
|
148
|
+
local: Queue[Optional[Task]]
|
|
149
|
+
bundle: List[bytes]
|
|
150
|
+
client_info: Optional[bytes]
|
|
151
|
+
no_confirm: bool
|
|
152
|
+
timeout: Optional[timedelta]
|
|
153
|
+
|
|
154
|
+
previous_received: datetime
|
|
155
|
+
|
|
156
|
+
task: Task
|
|
157
|
+
tasks: List[Task]
|
|
158
|
+
|
|
159
|
+
state = SchedulerState.START
|
|
160
|
+
states = SchedulerState
|
|
161
|
+
|
|
162
|
+
def __init__(self: ClientScheduler,
|
|
163
|
+
queue: QueueClient,
|
|
164
|
+
local: Queue[Optional[Task]],
|
|
165
|
+
no_confirm: bool = False,
|
|
166
|
+
timeout: int = None) -> None:
|
|
167
|
+
"""Assign remote queue client and local task queue."""
|
|
168
|
+
self.queue = queue
|
|
169
|
+
self.local = local
|
|
170
|
+
self.bundle = []
|
|
171
|
+
self.tasks = []
|
|
172
|
+
self.client_info = None
|
|
173
|
+
self.no_confirm = no_confirm
|
|
174
|
+
self.timeout = None if not timeout else timedelta(seconds=timeout)
|
|
175
|
+
self.previous_received = datetime.now()
|
|
176
|
+
|
|
177
|
+
@functools.cached_property
|
|
178
|
+
def actions(self: ClientScheduler) -> Dict[SchedulerState, Callable[[], SchedulerState]]:
|
|
179
|
+
return {
|
|
180
|
+
SchedulerState.START: self.start,
|
|
181
|
+
SchedulerState.GET_REMOTE: self.get_remote,
|
|
182
|
+
SchedulerState.UNPACK: self.unpack_bundle,
|
|
183
|
+
SchedulerState.PUT_CONFIRM: self.put_confirm,
|
|
184
|
+
SchedulerState.POP_TASK: self.pop_task,
|
|
185
|
+
SchedulerState.PUT_LOCAL: self.put_local,
|
|
186
|
+
SchedulerState.FINAL: self.finalize,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
def start(self: ClientScheduler) -> SchedulerState:
|
|
190
|
+
"""Jump to GET_REMOTE state."""
|
|
191
|
+
timeout_label = self.timeout or 'no'
|
|
192
|
+
log.debug(f'Started (scheduler: {timeout_label} timeout)')
|
|
193
|
+
return SchedulerState.GET_REMOTE
|
|
194
|
+
|
|
195
|
+
def get_remote(self: ClientScheduler) -> SchedulerState:
|
|
196
|
+
"""Get the next task bundle from the server."""
|
|
197
|
+
if check_signal() in (SIGUSR1, SIGUSR2) and CLIENT_STANDALONE_MODE:
|
|
198
|
+
log.warning(f'Signal interrupt ({SIGNAL_MAP[check_signal()]})')
|
|
199
|
+
return SchedulerState.FINAL
|
|
200
|
+
try:
|
|
201
|
+
self.bundle = self.queue.scheduled.get(timeout=2)
|
|
202
|
+
self.queue.scheduled.task_done()
|
|
203
|
+
self.previous_received = datetime.now()
|
|
204
|
+
if self.bundle is not None:
|
|
205
|
+
log.debug(f'Received {len(self.bundle)} tasks ({HOSTNAME}: {INSTANCE})')
|
|
206
|
+
return SchedulerState.UNPACK
|
|
207
|
+
else:
|
|
208
|
+
log.debug('Disconnect received')
|
|
209
|
+
return SchedulerState.FINAL
|
|
210
|
+
except QueueEmpty:
|
|
211
|
+
waited = datetime.now() - self.previous_received
|
|
212
|
+
if self.timeout is None or waited < self.timeout:
|
|
213
|
+
return SchedulerState.GET_REMOTE
|
|
214
|
+
else:
|
|
215
|
+
log.debug(f'Timeout reached ({waited})')
|
|
216
|
+
return SchedulerState.FINAL
|
|
217
|
+
|
|
218
|
+
def unpack_bundle(self: ClientScheduler) -> SchedulerState:
|
|
219
|
+
"""Unpack latest bundle of tasks."""
|
|
220
|
+
self.tasks = [Task.unpack(data) for data in self.bundle]
|
|
221
|
+
if not self.no_confirm:
|
|
222
|
+
self.client_info = ClientInfo.from_tasks(self.tasks).pack()
|
|
223
|
+
return SchedulerState.PUT_CONFIRM
|
|
224
|
+
else:
|
|
225
|
+
return SchedulerState.POP_TASK
|
|
226
|
+
|
|
227
|
+
def put_confirm(self: ClientScheduler) -> SchedulerState:
|
|
228
|
+
"""Put confirmation details back on remote queue."""
|
|
229
|
+
try:
|
|
230
|
+
self.queue.confirmed.put(self.client_info, timeout=2)
|
|
231
|
+
log.debug(f'Confirmed {len(self.tasks)} tasks ({HOSTNAME}: {INSTANCE})')
|
|
232
|
+
return SchedulerState.POP_TASK
|
|
233
|
+
except QueueFull:
|
|
234
|
+
return SchedulerState.PUT_CONFIRM
|
|
235
|
+
|
|
236
|
+
def pop_task(self: ClientScheduler) -> SchedulerState:
|
|
237
|
+
"""Pop next task off current task list."""
|
|
238
|
+
try:
|
|
239
|
+
self.task = self.tasks.pop(0)
|
|
240
|
+
return SchedulerState.PUT_LOCAL
|
|
241
|
+
except IndexError:
|
|
242
|
+
return SchedulerState.GET_REMOTE
|
|
243
|
+
|
|
244
|
+
def put_local(self: ClientScheduler) -> SchedulerState:
|
|
245
|
+
"""Put latest task on the local task queue."""
|
|
246
|
+
try:
|
|
247
|
+
self.local.put(self.task, timeout=1)
|
|
248
|
+
return SchedulerState.POP_TASK
|
|
249
|
+
except QueueFull:
|
|
250
|
+
return SchedulerState.PUT_LOCAL
|
|
251
|
+
|
|
252
|
+
@staticmethod
|
|
253
|
+
def finalize() -> SchedulerState:
|
|
254
|
+
"""Stop scheduler."""
|
|
255
|
+
log.debug('Done (scheduler)')
|
|
256
|
+
return SchedulerState.HALT
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class ClientSchedulerThread(Thread):
|
|
260
|
+
"""Run client scheduler in dedicated thread."""
|
|
261
|
+
|
|
262
|
+
def __init__(self: ClientSchedulerThread,
|
|
263
|
+
queue: QueueClient,
|
|
264
|
+
local: Queue[Optional[bytes]],
|
|
265
|
+
no_confirm: bool = False,
|
|
266
|
+
timeout: int = None) -> None:
|
|
267
|
+
"""Initialize machine."""
|
|
268
|
+
super().__init__(name='hypershell-client-scheduler')
|
|
269
|
+
self.machine = ClientScheduler(queue=queue, local=local, no_confirm=no_confirm, timeout=timeout)
|
|
270
|
+
|
|
271
|
+
def run_with_exceptions(self: ClientSchedulerThread) -> None:
|
|
272
|
+
"""Run machine."""
|
|
273
|
+
self.machine.run()
|
|
274
|
+
|
|
275
|
+
def stop(self: ClientSchedulerThread, wait: bool = False, timeout: int = None) -> None:
|
|
276
|
+
"""Stop machine."""
|
|
277
|
+
log.warning('Stopping (scheduler)')
|
|
278
|
+
self.machine.halt()
|
|
279
|
+
super().stop(wait=wait, timeout=timeout)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
DEFAULT_BUNDLESIZE: Final[int] = default.client.bundlesize
|
|
283
|
+
"""Default size of task bundles."""
|
|
284
|
+
|
|
285
|
+
DEFAULT_BUNDLEWAIT: Final[int] = default.client.bundlewait
|
|
286
|
+
"""Default waiting period before forcing task bundle push."""
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class CollectorState(State, Enum):
|
|
290
|
+
"""Finite states of collector."""
|
|
291
|
+
START = 0
|
|
292
|
+
GET_LOCAL = 1
|
|
293
|
+
CHECK_BUNDLE = 2
|
|
294
|
+
PACK_BUNDLE = 3
|
|
295
|
+
PUT_REMOTE = 4
|
|
296
|
+
FINAL = 5
|
|
297
|
+
HALT = 6
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class ClientCollector(StateMachine):
|
|
301
|
+
"""Collect finished tasks and bundle for outgoing queue."""
|
|
302
|
+
|
|
303
|
+
tasks: List[Task]
|
|
304
|
+
bundle: List[bytes]
|
|
305
|
+
|
|
306
|
+
queue: QueueClient
|
|
307
|
+
local: Queue[Optional[Task]]
|
|
308
|
+
|
|
309
|
+
bundlesize: int
|
|
310
|
+
bundlewait: int
|
|
311
|
+
previous_send: datetime
|
|
312
|
+
|
|
313
|
+
state = CollectorState.START
|
|
314
|
+
states = CollectorState
|
|
315
|
+
|
|
316
|
+
def __init__(self: ClientCollector, queue: QueueClient, local: Queue[Optional[Task]],
|
|
317
|
+
bundlesize: int = DEFAULT_BUNDLESIZE, bundlewait: int = DEFAULT_BUNDLEWAIT) -> None:
|
|
318
|
+
"""Collect tasks from local queue of finished tasks and push them to the server."""
|
|
319
|
+
self.tasks = []
|
|
320
|
+
self.bundle = []
|
|
321
|
+
self.local = local
|
|
322
|
+
self.queue = queue
|
|
323
|
+
self.bundlesize = bundlesize
|
|
324
|
+
self.bundlewait = bundlewait
|
|
325
|
+
|
|
326
|
+
@functools.cached_property
|
|
327
|
+
def actions(self: ClientCollector) -> Dict[CollectorState, Callable[[], CollectorState]]:
|
|
328
|
+
return {
|
|
329
|
+
CollectorState.START: self.start,
|
|
330
|
+
CollectorState.GET_LOCAL: self.get_local,
|
|
331
|
+
CollectorState.CHECK_BUNDLE: self.check_bundle,
|
|
332
|
+
CollectorState.PACK_BUNDLE: self.pack_bundle,
|
|
333
|
+
CollectorState.PUT_REMOTE: self.put_remote,
|
|
334
|
+
CollectorState.FINAL: self.finalize,
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
def start(self: ClientCollector) -> CollectorState:
|
|
338
|
+
"""Jump to GET_LOCAL state."""
|
|
339
|
+
log.debug('Started (collector)')
|
|
340
|
+
self.previous_send = datetime.now()
|
|
341
|
+
return CollectorState.GET_LOCAL
|
|
342
|
+
|
|
343
|
+
def get_local(self: ClientCollector) -> CollectorState:
|
|
344
|
+
"""Get the next task from the local completed task queue."""
|
|
345
|
+
try:
|
|
346
|
+
task = self.local.get(timeout=1)
|
|
347
|
+
self.local.task_done()
|
|
348
|
+
if task:
|
|
349
|
+
self.tasks.append(task)
|
|
350
|
+
return CollectorState.CHECK_BUNDLE
|
|
351
|
+
else:
|
|
352
|
+
return CollectorState.FINAL
|
|
353
|
+
except QueueEmpty:
|
|
354
|
+
return CollectorState.CHECK_BUNDLE
|
|
355
|
+
|
|
356
|
+
def check_bundle(self: ClientCollector) -> CollectorState:
|
|
357
|
+
"""Check state of task bundle and proceed with return if necessary."""
|
|
358
|
+
wait_time = (datetime.now() - self.previous_send)
|
|
359
|
+
since_last = wait_time.total_seconds()
|
|
360
|
+
if len(self.tasks) >= self.bundlesize:
|
|
361
|
+
log.trace(f'Bundle size reached ({len(self.tasks)} tasks)')
|
|
362
|
+
return CollectorState.PACK_BUNDLE
|
|
363
|
+
elif since_last >= self.bundlewait:
|
|
364
|
+
log.trace(f'Bundle wait exceeded ({wait_time})')
|
|
365
|
+
return CollectorState.PACK_BUNDLE
|
|
366
|
+
else:
|
|
367
|
+
return CollectorState.GET_LOCAL
|
|
368
|
+
|
|
369
|
+
def pack_bundle(self: ClientCollector) -> CollectorState:
|
|
370
|
+
"""Pack tasks into bundle before pushing back to server."""
|
|
371
|
+
self.bundle = [task.pack() for task in self.tasks]
|
|
372
|
+
return CollectorState.PUT_REMOTE
|
|
373
|
+
|
|
374
|
+
def put_remote(self: ClientCollector) -> CollectorState:
|
|
375
|
+
"""Push out bundle of completed tasks."""
|
|
376
|
+
try:
|
|
377
|
+
if self.bundle:
|
|
378
|
+
self.queue.completed.put(self.bundle, timeout=2)
|
|
379
|
+
log.trace(f'Bundle returned ({len(self.bundle)} tasks)')
|
|
380
|
+
self.tasks.clear()
|
|
381
|
+
self.bundle.clear()
|
|
382
|
+
self.previous_send = datetime.now()
|
|
383
|
+
else:
|
|
384
|
+
log.trace('Bundle empty')
|
|
385
|
+
return CollectorState.GET_LOCAL
|
|
386
|
+
except QueueFull:
|
|
387
|
+
return CollectorState.PUT_REMOTE
|
|
388
|
+
|
|
389
|
+
def finalize(self: ClientCollector) -> CollectorState:
|
|
390
|
+
"""Push out any remaining tasks and halt."""
|
|
391
|
+
self.put_remote()
|
|
392
|
+
log.debug('Done (collector)')
|
|
393
|
+
return CollectorState.HALT
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class ClientCollectorThread(Thread):
|
|
397
|
+
"""Run client collector within dedicated thread."""
|
|
398
|
+
|
|
399
|
+
def __init__(self: ClientCollectorThread, queue: QueueClient, local: Queue[Optional[bytes]],
|
|
400
|
+
bundlesize: int = DEFAULT_BUNDLESIZE, bundlewait: int = DEFAULT_BUNDLEWAIT) -> None:
|
|
401
|
+
"""Initialize machine."""
|
|
402
|
+
super().__init__(name='hypershell-client-collector')
|
|
403
|
+
self.machine = ClientCollector(queue=queue, local=local, bundlesize=bundlesize, bundlewait=bundlewait)
|
|
404
|
+
|
|
405
|
+
def run_with_exceptions(self: ClientCollectorThread) -> None:
|
|
406
|
+
"""Run machine."""
|
|
407
|
+
self.machine.run()
|
|
408
|
+
|
|
409
|
+
def stop(self: ClientCollectorThread, wait: bool = False, timeout: int = None) -> None:
|
|
410
|
+
"""Stop machine."""
|
|
411
|
+
log.warning('Stopping (collector)')
|
|
412
|
+
self.machine.halt()
|
|
413
|
+
super().stop(wait=wait, timeout=timeout)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
DEFAULT_SIGNALWAIT: Final[int] = default.task.signalwait
|
|
417
|
+
"""Default signal escalation wait period in seconds."""
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def task_env(task: Task) -> Dict[str, str]:
|
|
421
|
+
"""Build environment dictionary for the given `task`."""
|
|
422
|
+
task_data = task.to_json()
|
|
423
|
+
try:
|
|
424
|
+
# We have to flatten tag data separately, otherwise we'd have TASK_TAG='{...}'
|
|
425
|
+
tag_data = Namespace(task_data.pop('tag')).to_env().flatten(prefix='TASK_TAG')
|
|
426
|
+
except Exception: # noqa: any exception
|
|
427
|
+
tag_data = {}
|
|
428
|
+
return {
|
|
429
|
+
**os.environ,
|
|
430
|
+
**load_task_env(),
|
|
431
|
+
**Namespace.from_dict(task_data).to_env().flatten(prefix='TASK'),
|
|
432
|
+
**tag_data,
|
|
433
|
+
'TASK_CWD': config.task.cwd,
|
|
434
|
+
'TASK_OUTPATH': os.path.join(default_path.lib, 'task', f'{task.id}.out'),
|
|
435
|
+
'TASK_ERRPATH': os.path.join(default_path.lib, 'task', f'{task.id}.err'),
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class TaskState(State, Enum):
|
|
440
|
+
"""Finite states for task executor."""
|
|
441
|
+
START = 0
|
|
442
|
+
GET_LOCAL = 1
|
|
443
|
+
CREATE_TASK = 2
|
|
444
|
+
START_TASK = 3
|
|
445
|
+
WAIT_TASK = 4
|
|
446
|
+
CHECK_TASK = 5
|
|
447
|
+
WAIT_SIGNAL = 6
|
|
448
|
+
STOP_TASK = 7
|
|
449
|
+
TERM_TASK = 8
|
|
450
|
+
KILL_TASK = 9
|
|
451
|
+
PUT_LOCAL = 10
|
|
452
|
+
FINAL = 11
|
|
453
|
+
HALT = 12
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
class TaskExecutor(StateMachine):
|
|
457
|
+
"""Run tasks locally."""
|
|
458
|
+
|
|
459
|
+
id: int
|
|
460
|
+
task: Task
|
|
461
|
+
process: Popen
|
|
462
|
+
template: Template
|
|
463
|
+
redirect_output: IO
|
|
464
|
+
redirect_errors: IO
|
|
465
|
+
capture: bool
|
|
466
|
+
|
|
467
|
+
elapsed: timedelta
|
|
468
|
+
timeout: Optional[int]
|
|
469
|
+
signalwait: int
|
|
470
|
+
stop_requested: Optional[datetime]
|
|
471
|
+
attempted_sigint: bool
|
|
472
|
+
attempted_sigterm: bool
|
|
473
|
+
attempted_sigkill: bool
|
|
474
|
+
|
|
475
|
+
inbound: Queue[Optional[Task]]
|
|
476
|
+
outbound: Queue[Optional[Task]]
|
|
477
|
+
|
|
478
|
+
state = TaskState.START
|
|
479
|
+
states = TaskState
|
|
480
|
+
|
|
481
|
+
def __init__(self: TaskExecutor,
|
|
482
|
+
id: int,
|
|
483
|
+
inbound: Queue[Optional[Task]],
|
|
484
|
+
outbound: Queue[Optional[Task]],
|
|
485
|
+
template: str = DEFAULT_TEMPLATE,
|
|
486
|
+
redirect_output: IO = None,
|
|
487
|
+
redirect_errors: IO = None,
|
|
488
|
+
capture: bool = False,
|
|
489
|
+
timeout: int = None,
|
|
490
|
+
signalwait: int = DEFAULT_SIGNALWAIT) -> None:
|
|
491
|
+
"""Initialize task executor."""
|
|
492
|
+
self.id = id
|
|
493
|
+
self.template = Template(template)
|
|
494
|
+
self.inbound = inbound
|
|
495
|
+
self.outbound = outbound
|
|
496
|
+
self.redirect_output = redirect_output or sys.stdout
|
|
497
|
+
self.redirect_errors = redirect_errors or sys.stderr
|
|
498
|
+
self.capture = capture
|
|
499
|
+
self.timeout = timeout
|
|
500
|
+
self.signalwait = signalwait
|
|
501
|
+
|
|
502
|
+
@functools.cached_property
|
|
503
|
+
def actions(self: TaskExecutor) -> Dict[TaskState, Callable[[], TaskState]]:
|
|
504
|
+
return {
|
|
505
|
+
TaskState.START: self.start,
|
|
506
|
+
TaskState.GET_LOCAL: self.get_local,
|
|
507
|
+
TaskState.CREATE_TASK: self.create_task,
|
|
508
|
+
TaskState.START_TASK: self.start_task,
|
|
509
|
+
TaskState.WAIT_TASK: self.wait_task,
|
|
510
|
+
TaskState.CHECK_TASK: self.check_task,
|
|
511
|
+
TaskState.WAIT_SIGNAL: self.wait_signal,
|
|
512
|
+
TaskState.STOP_TASK: self.stop_task,
|
|
513
|
+
TaskState.TERM_TASK: self.term_task,
|
|
514
|
+
TaskState.KILL_TASK: self.kill_task,
|
|
515
|
+
TaskState.PUT_LOCAL: self.put_local,
|
|
516
|
+
TaskState.FINAL: self.finalize,
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
def start(self: TaskExecutor) -> TaskState:
|
|
520
|
+
"""Jump to GET_LOCAL state."""
|
|
521
|
+
log.debug(f'Started (executor-{self.id})')
|
|
522
|
+
return TaskState.GET_LOCAL
|
|
523
|
+
|
|
524
|
+
def get_local(self: TaskExecutor) -> TaskState:
|
|
525
|
+
"""Get the next task from the local queue of new tasks."""
|
|
526
|
+
try:
|
|
527
|
+
self.task = self.inbound.get(timeout=1)
|
|
528
|
+
self.inbound.task_done()
|
|
529
|
+
return TaskState.CREATE_TASK if self.task else TaskState.FINAL
|
|
530
|
+
except QueueEmpty:
|
|
531
|
+
return TaskState.GET_LOCAL
|
|
532
|
+
|
|
533
|
+
def create_task(self: TaskExecutor) -> TaskState:
|
|
534
|
+
"""Expand template and initialize task command-line."""
|
|
535
|
+
try:
|
|
536
|
+
self.task.client_id = INSTANCE
|
|
537
|
+
self.task.client_host = HOSTNAME
|
|
538
|
+
self.task.command = self.template.expand(self.task.args)
|
|
539
|
+
return TaskState.START_TASK
|
|
540
|
+
except Exception as error:
|
|
541
|
+
log.error(f'{error.__class__.__name__}: {error}')
|
|
542
|
+
self.task.start_time = datetime.now().astimezone()
|
|
543
|
+
self.task.completion_time = datetime.now().astimezone()
|
|
544
|
+
self.task.exit_status = -1
|
|
545
|
+
return TaskState.PUT_LOCAL
|
|
546
|
+
|
|
547
|
+
def start_task(self: TaskExecutor) -> TaskState:
|
|
548
|
+
"""Start current task locally."""
|
|
549
|
+
# NOTE: enforce tz aware submit_time (in case of sqlite backend)
|
|
550
|
+
self.task.start_time = datetime.now().astimezone()
|
|
551
|
+
self.task.waited = int((self.task.start_time - self.task.submit_time.astimezone()).total_seconds())
|
|
552
|
+
env = task_env(self.task)
|
|
553
|
+
if self.capture:
|
|
554
|
+
self.task.outpath = env['TASK_OUTPATH']
|
|
555
|
+
self.task.errpath = env['TASK_ERRPATH']
|
|
556
|
+
self.redirect_output = open(self.task.outpath, mode='w')
|
|
557
|
+
self.redirect_errors = open(self.task.errpath, mode='w')
|
|
558
|
+
self.stop_requested = None
|
|
559
|
+
self.attempted_sigint = False
|
|
560
|
+
self.attempted_sigterm = False
|
|
561
|
+
self.attempted_sigkill = False
|
|
562
|
+
self.process = Popen(self.task.command, shell=True,
|
|
563
|
+
stdout=self.redirect_output, stderr=self.redirect_errors,
|
|
564
|
+
cwd=config.task.cwd, env=env)
|
|
565
|
+
log.info(f'Running task ({self.task.id})')
|
|
566
|
+
log.debug(f'Running task ({self.task.id})[{self.process.pid}]: {self.task.command}')
|
|
567
|
+
return TaskState.WAIT_TASK
|
|
568
|
+
|
|
569
|
+
def wait_task(self: TaskExecutor) -> TaskState:
|
|
570
|
+
"""Wait for current task to complete."""
|
|
571
|
+
try:
|
|
572
|
+
self.task.exit_status = self.process.wait(timeout=1)
|
|
573
|
+
self.task.completion_time = datetime.now().astimezone()
|
|
574
|
+
self.task.duration = int((self.task.completion_time - self.task.start_time).total_seconds())
|
|
575
|
+
log.debug(f'Completed task ({self.task.id})')
|
|
576
|
+
if self.capture:
|
|
577
|
+
self.redirect_output.close()
|
|
578
|
+
self.redirect_errors.close()
|
|
579
|
+
return TaskState.PUT_LOCAL
|
|
580
|
+
except TimeoutExpired:
|
|
581
|
+
# Only display time elapsed to the nearest second
|
|
582
|
+
self.elapsed = timedelta(seconds=round((datetime.now().astimezone() -
|
|
583
|
+
self.task.start_time).total_seconds()))
|
|
584
|
+
log.trace(f'Waiting on task ({self.task.id}: {self.elapsed})')
|
|
585
|
+
if self.stop_requested:
|
|
586
|
+
return TaskState.WAIT_SIGNAL
|
|
587
|
+
else:
|
|
588
|
+
return TaskState.CHECK_TASK
|
|
589
|
+
|
|
590
|
+
def check_task(self: TaskExecutor) -> TaskState:
|
|
591
|
+
"""Check for timeout or interrupts."""
|
|
592
|
+
if check_signal() == SIGUSR2: # NOTE: regardless of CLIENT_STANDALONE_MODE
|
|
593
|
+
log.warning(f'Signal interrupt (SIGUSR2: executor-{self.id})')
|
|
594
|
+
self.stop_requested = datetime.now()
|
|
595
|
+
return TaskState.WAIT_SIGNAL
|
|
596
|
+
elif self.timeout is None or self.elapsed.total_seconds() < self.timeout:
|
|
597
|
+
return TaskState.WAIT_TASK
|
|
598
|
+
else:
|
|
599
|
+
log.warning(f'Task exceeded walltime limit ({self.elapsed})')
|
|
600
|
+
self.stop_requested = datetime.now()
|
|
601
|
+
return TaskState.WAIT_SIGNAL
|
|
602
|
+
|
|
603
|
+
def wait_signal(self: TaskExecutor) -> TaskState:
|
|
604
|
+
"""Wait on interrupts."""
|
|
605
|
+
if self.attempted_sigint is False:
|
|
606
|
+
return TaskState.STOP_TASK
|
|
607
|
+
elif (datetime.now() - self.stop_requested).total_seconds() < 1 * self.signalwait:
|
|
608
|
+
return TaskState.WAIT_TASK
|
|
609
|
+
elif self.attempted_sigterm is False:
|
|
610
|
+
log.error(f'Interrupt ignored ({self.task.id})')
|
|
611
|
+
return TaskState.TERM_TASK
|
|
612
|
+
elif (datetime.now() - self.stop_requested).total_seconds() < 2 * self.signalwait:
|
|
613
|
+
return TaskState.WAIT_TASK
|
|
614
|
+
elif self.attempted_sigkill is False:
|
|
615
|
+
log.error(f'Terminate ignored ({self.task.id})')
|
|
616
|
+
return TaskState.KILL_TASK
|
|
617
|
+
elif (datetime.now() - self.stop_requested).total_seconds() < 3 * self.signalwait:
|
|
618
|
+
return TaskState.WAIT_TASK
|
|
619
|
+
else:
|
|
620
|
+
log.critical(f'Process ignored SIGKILL ({self.task.id}: {self.process.pid})')
|
|
621
|
+
log.critical(f'Shutting down executor ({self.id})')
|
|
622
|
+
return TaskState.FINAL
|
|
623
|
+
|
|
624
|
+
def stop_task(self: TaskExecutor) -> TaskState:
|
|
625
|
+
"""Send SIGINT to task process."""
|
|
626
|
+
log.debug(f'Sending SIGINT ({self.task.id}: {self.process.pid})')
|
|
627
|
+
self.process.send_signal(SIGINT)
|
|
628
|
+
self.attempted_sigint = True
|
|
629
|
+
return TaskState.WAIT_TASK
|
|
630
|
+
|
|
631
|
+
def term_task(self: TaskExecutor) -> TaskState:
|
|
632
|
+
"""Send SIGTERM to task process."""
|
|
633
|
+
log.debug(f'Sending SIGTERM ({self.task.id}: {self.process.pid})')
|
|
634
|
+
self.process.terminate()
|
|
635
|
+
self.attempted_sigterm = True
|
|
636
|
+
return TaskState.WAIT_TASK
|
|
637
|
+
|
|
638
|
+
def kill_task(self: TaskExecutor) -> TaskState:
|
|
639
|
+
"""Send SIGKILL or halt executor if ignored."""
|
|
640
|
+
log.debug(f'Sending SIGKILL ({self.task.id}: {self.process.pid})')
|
|
641
|
+
self.process.kill()
|
|
642
|
+
self.attempted_sigkill = True
|
|
643
|
+
return TaskState.WAIT_TASK
|
|
644
|
+
|
|
645
|
+
def put_local(self: TaskExecutor) -> TaskState:
|
|
646
|
+
"""Put completed task on outbound queue."""
|
|
647
|
+
try:
|
|
648
|
+
self.outbound.put(self.task, timeout=1)
|
|
649
|
+
return TaskState.GET_LOCAL
|
|
650
|
+
except QueueFull:
|
|
651
|
+
return TaskState.PUT_LOCAL
|
|
652
|
+
|
|
653
|
+
def finalize(self: TaskExecutor) -> TaskState:
|
|
654
|
+
"""Push out any remaining tasks and halt."""
|
|
655
|
+
log.debug(f'Done (executor-{self.id})')
|
|
656
|
+
if self.redirect_output is not sys.stdout:
|
|
657
|
+
self.redirect_output.close()
|
|
658
|
+
if self.redirect_errors is not sys.stderr:
|
|
659
|
+
self.redirect_errors.close()
|
|
660
|
+
return TaskState.HALT
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
class TaskThread(Thread):
|
|
664
|
+
"""Run task executor within dedicated thread."""
|
|
665
|
+
|
|
666
|
+
id: int
|
|
667
|
+
|
|
668
|
+
def __init__(self: TaskThread,
|
|
669
|
+
id: int,
|
|
670
|
+
inbound: Queue[Optional[str]],
|
|
671
|
+
outbound: Queue[Optional[str]],
|
|
672
|
+
template: str = DEFAULT_TEMPLATE,
|
|
673
|
+
capture: bool = False,
|
|
674
|
+
redirect_output: IO = None,
|
|
675
|
+
redirect_errors: IO = None,
|
|
676
|
+
timeout: int = None,
|
|
677
|
+
signalwait: int = DEFAULT_SIGNALWAIT) -> None:
|
|
678
|
+
"""Initialize task executor."""
|
|
679
|
+
self.id = id
|
|
680
|
+
super().__init__(name=f'hypershell-executor-{id}')
|
|
681
|
+
self.machine = TaskExecutor(id=id, inbound=inbound, outbound=outbound, template=template,
|
|
682
|
+
redirect_output=redirect_output, redirect_errors=redirect_errors,
|
|
683
|
+
capture=capture, timeout=timeout, signalwait=signalwait)
|
|
684
|
+
|
|
685
|
+
def run_with_exceptions(self: TaskThread) -> None:
|
|
686
|
+
"""Run machine."""
|
|
687
|
+
self.machine.run()
|
|
688
|
+
|
|
689
|
+
def stop(self: TaskThread, wait: bool = False, timeout: int = None) -> None:
|
|
690
|
+
"""Stop machine."""
|
|
691
|
+
log.warning(f'Stopping (executor-{self.id})')
|
|
692
|
+
self.machine.halt()
|
|
693
|
+
super().stop(wait=wait, timeout=timeout)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
class HeartbeatState(State, Enum):
|
|
697
|
+
"""Finite states for heartbeat machine."""
|
|
698
|
+
START = 0
|
|
699
|
+
SUBMIT = 1
|
|
700
|
+
WAIT = 2
|
|
701
|
+
FINAL = 3
|
|
702
|
+
HALT = 4
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
DEFAULT_HEARTRATE: Final[int] = default.client.heartrate
|
|
706
|
+
"""Period in seconds to wait between heartbeats."""
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
class ClientHeartbeat(StateMachine):
|
|
710
|
+
"""Register heartbeats with remote server."""
|
|
711
|
+
|
|
712
|
+
queue: QueueClient
|
|
713
|
+
heartrate: timedelta
|
|
714
|
+
previous: datetime = None
|
|
715
|
+
|
|
716
|
+
no_wait: bool = False
|
|
717
|
+
client_state: ClientState = ClientState.RUNNING
|
|
718
|
+
|
|
719
|
+
state = HeartbeatState.START
|
|
720
|
+
states = HeartbeatState
|
|
721
|
+
|
|
722
|
+
def __init__(self: ClientHeartbeat, queue: QueueClient, heartrate: int = DEFAULT_HEARTRATE) -> None:
|
|
723
|
+
"""Initialize heartbeat machine."""
|
|
724
|
+
self.queue = queue
|
|
725
|
+
self.previous = datetime.now()
|
|
726
|
+
self.heartrate = timedelta(seconds=heartrate)
|
|
727
|
+
|
|
728
|
+
@functools.cached_property
|
|
729
|
+
def actions(self: ClientHeartbeat) -> Dict[HeartbeatState, Callable[[], HeartbeatState]]:
|
|
730
|
+
return {
|
|
731
|
+
HeartbeatState.START: self.start,
|
|
732
|
+
HeartbeatState.SUBMIT: self.submit,
|
|
733
|
+
HeartbeatState.WAIT: self.wait,
|
|
734
|
+
HeartbeatState.FINAL: self.finalize,
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
@staticmethod
|
|
738
|
+
def start() -> HeartbeatState:
|
|
739
|
+
"""Jump to SUBMIT state."""
|
|
740
|
+
log.debug(f'Started (heartbeat)')
|
|
741
|
+
return HeartbeatState.SUBMIT
|
|
742
|
+
|
|
743
|
+
def submit(self: ClientHeartbeat) -> HeartbeatState:
|
|
744
|
+
"""Publish new heartbeat to remote queue."""
|
|
745
|
+
try:
|
|
746
|
+
client_state = self.client_state # atomic
|
|
747
|
+
heartbeat = Heartbeat.new(state=client_state)
|
|
748
|
+
self.queue.heartbeat.put(heartbeat.pack(), timeout=2)
|
|
749
|
+
if client_state is ClientState.RUNNING:
|
|
750
|
+
log.trace(f'Heartbeat - running ({heartbeat.host}: {heartbeat.uuid})')
|
|
751
|
+
return HeartbeatState.WAIT
|
|
752
|
+
else:
|
|
753
|
+
log.trace(f'Heartbeat - final ({heartbeat.host}: {heartbeat.uuid})')
|
|
754
|
+
return HeartbeatState.FINAL
|
|
755
|
+
except QueueEmpty:
|
|
756
|
+
return HeartbeatState.SUBMIT
|
|
757
|
+
|
|
758
|
+
def wait(self: ClientHeartbeat) -> HeartbeatState:
|
|
759
|
+
"""Wait until next needed heartbeat."""
|
|
760
|
+
if self.no_wait:
|
|
761
|
+
return HeartbeatState.SUBMIT
|
|
762
|
+
now = datetime.now()
|
|
763
|
+
if (now - self.previous) < self.heartrate:
|
|
764
|
+
time.sleep(1)
|
|
765
|
+
return HeartbeatState.WAIT
|
|
766
|
+
else:
|
|
767
|
+
self.previous = now
|
|
768
|
+
return HeartbeatState.SUBMIT
|
|
769
|
+
|
|
770
|
+
@staticmethod
|
|
771
|
+
def finalize() -> HeartbeatState:
|
|
772
|
+
"""Stop heartbeats."""
|
|
773
|
+
log.debug(f'Done (heartbeat)')
|
|
774
|
+
return HeartbeatState.HALT
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
class ClientHeartbeatThread(Thread):
|
|
778
|
+
"""Run heartbeat machine within dedicated thread."""
|
|
779
|
+
|
|
780
|
+
def __init__(self: ClientHeartbeatThread, queue: QueueClient, heartrate: int = DEFAULT_HEARTRATE) -> None:
|
|
781
|
+
"""Initialize heartbeat machine."""
|
|
782
|
+
super().__init__(name=f'hypershell-heartbeat')
|
|
783
|
+
self.machine = ClientHeartbeat(queue=queue, heartrate=heartrate)
|
|
784
|
+
|
|
785
|
+
def run_with_exceptions(self: ClientHeartbeatThread) -> None:
|
|
786
|
+
"""Run machine."""
|
|
787
|
+
self.machine.run()
|
|
788
|
+
|
|
789
|
+
def signal_finished(self: ClientHeartbeatThread) -> None:
|
|
790
|
+
"""Set client state to communicate completion."""
|
|
791
|
+
self.machine.client_state = ClientState.FINISHED
|
|
792
|
+
self.machine.no_wait = True
|
|
793
|
+
|
|
794
|
+
def stop(self: ClientHeartbeatThread, wait: bool = False, timeout: int = None) -> None:
|
|
795
|
+
"""Stop machine."""
|
|
796
|
+
log.warning('Stopping (heartbeat)')
|
|
797
|
+
self.machine.halt()
|
|
798
|
+
super().stop(wait=wait, timeout=timeout)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
DEFAULT_NUM_TASKS: Final[int] = 1
|
|
802
|
+
"""Default number of task executors per client."""
|
|
803
|
+
|
|
804
|
+
# We do not delay connecting to the server unless explicitly specified
|
|
805
|
+
DEFAULT_DELAY: Final[int] = 0
|
|
806
|
+
"""Default delay in seconds on client startup."""
|
|
807
|
+
|
|
808
|
+
DEFAULT_HOST: Final[str] = QueueConfig.host
|
|
809
|
+
"""Default host for server connection."""
|
|
810
|
+
|
|
811
|
+
DEFAULT_PORT: Final[int] = QueueConfig.port
|
|
812
|
+
"""Default port for server connection."""
|
|
813
|
+
|
|
814
|
+
DEFAULT_AUTH: Final[str] = QueueConfig.auth
|
|
815
|
+
"""Default authentication key for server (**DO NOT USE THIS**)."""
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
class ClientThread(Thread):
|
|
819
|
+
"""
|
|
820
|
+
Run client within dedicated thread.
|
|
821
|
+
Run until either disconnect requested from server or `client_timeout` reached.
|
|
822
|
+
|
|
823
|
+
Args:
|
|
824
|
+
num_tasks (int, optional):
|
|
825
|
+
Number of parallel task executor threads.
|
|
826
|
+
See :const:`DEFAULT_NUM_TASKS`.
|
|
827
|
+
|
|
828
|
+
bundlesize (int optional):
|
|
829
|
+
Size of task bundles returned to server.
|
|
830
|
+
See :const:`DEFAULT_BUNDLESIZE`.
|
|
831
|
+
|
|
832
|
+
bundlewait (int optional):
|
|
833
|
+
Waiting period in seconds before forcing return of task bundle to server.
|
|
834
|
+
See :const:`DEFAULT_BUNDLEWAIT`.
|
|
835
|
+
|
|
836
|
+
address (tuple, optional):
|
|
837
|
+
Server host address for server with port number.
|
|
838
|
+
See :const:`DEFAULT_HOST` and :const:`DEFAULT_PORT`.
|
|
839
|
+
|
|
840
|
+
auth (str, optional):
|
|
841
|
+
Server authentication key.
|
|
842
|
+
See :const:`DEFAULT_AUTH`.
|
|
843
|
+
|
|
844
|
+
template (str, optional):
|
|
845
|
+
Template command pattern. See :const:`DEFAULT_TEMPLATE`.
|
|
846
|
+
|
|
847
|
+
redirect_output (IO, optional):
|
|
848
|
+
Optional file-like object for <stdout> redirect.
|
|
849
|
+
|
|
850
|
+
redirect_errors (IO, optional):
|
|
851
|
+
Optional file-like object for <stderr> redirect.
|
|
852
|
+
|
|
853
|
+
heartrate (int, optional):
|
|
854
|
+
Period in seconds to wait between heartbeats.
|
|
855
|
+
See :const:`DEFAULT_HEARTRATE`,
|
|
856
|
+
|
|
857
|
+
capture (bool, optional):
|
|
858
|
+
Isolate task <stdout> and <stderr> in discrete files.
|
|
859
|
+
Defaults to `False`.
|
|
860
|
+
|
|
861
|
+
delay_start (float, optional):
|
|
862
|
+
Delay in seconds before connecting to server.
|
|
863
|
+
See :const:`DEFAULT_DELAY`.
|
|
864
|
+
|
|
865
|
+
no_confirm (bool, optional):
|
|
866
|
+
Disable client confirmation of tasks received.
|
|
867
|
+
|
|
868
|
+
client_timeout (int, optional):
|
|
869
|
+
Timeout in seconds before disconnecting from server.
|
|
870
|
+
By default, the client waits for server tor request disconnect.
|
|
871
|
+
|
|
872
|
+
task_timeout (int, optional):
|
|
873
|
+
Task-level walltime limit in seconds.
|
|
874
|
+
By default, the client waits indefinitely on tasks.
|
|
875
|
+
|
|
876
|
+
task_signalwait (int, optional):
|
|
877
|
+
Signal escalation waiting period in seconds on task timeout.
|
|
878
|
+
See :const:`DEFAULT_SIGNALWAIT`.
|
|
879
|
+
|
|
880
|
+
Example:
|
|
881
|
+
>>> from hypershell.client import ClientThread
|
|
882
|
+
>>> client = ClientThread.new(num_tasks=16, address=('localhost', 54321),
|
|
883
|
+
... auth='my-secret-key', capture=True)
|
|
884
|
+
>>> client.join()
|
|
885
|
+
|
|
886
|
+
See Also:
|
|
887
|
+
- :meth:`run_client`
|
|
888
|
+
"""
|
|
889
|
+
|
|
890
|
+
client: QueueClient
|
|
891
|
+
num_tasks: int
|
|
892
|
+
delay_start: float
|
|
893
|
+
no_confirm: bool
|
|
894
|
+
|
|
895
|
+
inbound: Queue[Optional[Task]]
|
|
896
|
+
outbound: Queue[Optional[Task]]
|
|
897
|
+
scheduler: ClientSchedulerThread
|
|
898
|
+
collector: ClientCollectorThread
|
|
899
|
+
executors: List[TaskThread]
|
|
900
|
+
|
|
901
|
+
def __init__(self: ClientThread,
|
|
902
|
+
num_tasks: int = DEFAULT_NUM_TASKS,
|
|
903
|
+
bundlesize: int = DEFAULT_BUNDLESIZE,
|
|
904
|
+
bundlewait: int = DEFAULT_BUNDLEWAIT,
|
|
905
|
+
address: Tuple[str, int] = (DEFAULT_HOST, DEFAULT_PORT),
|
|
906
|
+
auth: str = DEFAULT_AUTH,
|
|
907
|
+
template: str = DEFAULT_TEMPLATE,
|
|
908
|
+
redirect_output: IO = None,
|
|
909
|
+
redirect_errors: IO = None,
|
|
910
|
+
heartrate: int = DEFAULT_HEARTRATE,
|
|
911
|
+
capture: bool = False,
|
|
912
|
+
delay_start: float = DEFAULT_DELAY,
|
|
913
|
+
no_confirm: bool = False,
|
|
914
|
+
client_timeout: int = None,
|
|
915
|
+
task_timeout: int = None,
|
|
916
|
+
task_signalwait: int = DEFAULT_SIGNALWAIT) -> None:
|
|
917
|
+
"""Initialize queue manager and child threads."""
|
|
918
|
+
super().__init__(name='hypershell-client')
|
|
919
|
+
self.num_tasks = num_tasks
|
|
920
|
+
self.delay_start = delay_start
|
|
921
|
+
self.no_confirm = no_confirm
|
|
922
|
+
self.client = QueueClient(config=QueueConfig(host=address[0], port=address[1], auth=auth))
|
|
923
|
+
self.inbound = Queue(maxsize=bundlesize)
|
|
924
|
+
self.outbound = Queue(maxsize=bundlesize)
|
|
925
|
+
self.scheduler = ClientSchedulerThread(queue=self.client, local=self.inbound,
|
|
926
|
+
no_confirm=no_confirm, timeout=client_timeout)
|
|
927
|
+
self.heartbeat = ClientHeartbeatThread(queue=self.client, heartrate=heartrate)
|
|
928
|
+
self.collector = ClientCollectorThread(queue=self.client, local=self.outbound,
|
|
929
|
+
bundlesize=bundlesize, bundlewait=bundlewait)
|
|
930
|
+
self.executors = [TaskThread(id=count+1,
|
|
931
|
+
inbound=self.inbound, outbound=self.outbound,
|
|
932
|
+
redirect_output=redirect_output, redirect_errors=redirect_errors,
|
|
933
|
+
template=template, capture=capture, timeout=task_timeout,
|
|
934
|
+
signalwait=task_signalwait)
|
|
935
|
+
for count in range(num_tasks)]
|
|
936
|
+
|
|
937
|
+
def run_with_exceptions(self: ClientThread) -> None:
|
|
938
|
+
"""Start child threads, wait."""
|
|
939
|
+
log.debug(f'Started ({self.num_tasks} executors)')
|
|
940
|
+
self.wait_start()
|
|
941
|
+
with self.client:
|
|
942
|
+
self.start_threads()
|
|
943
|
+
self.wait_scheduler()
|
|
944
|
+
self.wait_executors()
|
|
945
|
+
self.wait_collector()
|
|
946
|
+
self.wait_heartbeat()
|
|
947
|
+
log.debug('Done')
|
|
948
|
+
|
|
949
|
+
def wait_start(self: ClientThread) -> None:
|
|
950
|
+
"""Wait constant period or random interval."""
|
|
951
|
+
if self.delay_start == 0:
|
|
952
|
+
return
|
|
953
|
+
if self.delay_start > 0:
|
|
954
|
+
log.debug(f'Waiting ({self.delay_start} seconds)')
|
|
955
|
+
time.sleep(self.delay_start)
|
|
956
|
+
else:
|
|
957
|
+
delay = random.uniform(0, -1 * self.delay_start)
|
|
958
|
+
log.debug(f'Waiting random ({delay:.1f} seconds)')
|
|
959
|
+
time.sleep(delay)
|
|
960
|
+
|
|
961
|
+
def start_threads(self: ClientThread) -> None:
|
|
962
|
+
"""Start child threads."""
|
|
963
|
+
self.scheduler.start()
|
|
964
|
+
self.collector.start()
|
|
965
|
+
self.heartbeat.start()
|
|
966
|
+
for executor in self.executors:
|
|
967
|
+
executor.start()
|
|
968
|
+
|
|
969
|
+
def wait_scheduler(self: ClientThread) -> None:
|
|
970
|
+
"""Wait for all tasks to be completed."""
|
|
971
|
+
log.trace('Waiting (scheduler)')
|
|
972
|
+
self.scheduler.join()
|
|
973
|
+
|
|
974
|
+
def wait_collector(self: ClientThread) -> None:
|
|
975
|
+
"""Signal collector to halt."""
|
|
976
|
+
log.trace('Waiting (collector)')
|
|
977
|
+
self.outbound.put(None)
|
|
978
|
+
self.collector.join()
|
|
979
|
+
|
|
980
|
+
def wait_executors(self: ClientThread) -> None:
|
|
981
|
+
"""Send disconnect signal to each task executor thread."""
|
|
982
|
+
for _ in self.executors:
|
|
983
|
+
self.inbound.put(None) # signal executors to shut down
|
|
984
|
+
for thread in self.executors:
|
|
985
|
+
log.trace(f'Waiting (executor-{thread.id})')
|
|
986
|
+
thread.join()
|
|
987
|
+
|
|
988
|
+
def wait_heartbeat(self: ClientThread) -> None:
|
|
989
|
+
"""Signal HALT on heartbeat."""
|
|
990
|
+
log.trace('Waiting (heartbeat)')
|
|
991
|
+
self.heartbeat.signal_finished()
|
|
992
|
+
self.heartbeat.join()
|
|
993
|
+
|
|
994
|
+
def stop(self: ClientThread, wait: bool = False, timeout: int = None) -> None:
|
|
995
|
+
"""Stop child threads before main thread."""
|
|
996
|
+
log.warning('Stopping')
|
|
997
|
+
self.scheduler.stop(wait=wait, timeout=timeout)
|
|
998
|
+
self.collector.stop(wait=wait, timeout=timeout)
|
|
999
|
+
super().stop(wait=wait, timeout=timeout)
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def run_client(num_tasks: int = DEFAULT_NUM_TASKS,
|
|
1003
|
+
bundlesize: int = DEFAULT_BUNDLESIZE,
|
|
1004
|
+
bundlewait: int = DEFAULT_BUNDLEWAIT,
|
|
1005
|
+
address: Tuple[str, int] = (DEFAULT_HOST, DEFAULT_PORT),
|
|
1006
|
+
auth: str = DEFAULT_AUTH,
|
|
1007
|
+
template: str = DEFAULT_TEMPLATE,
|
|
1008
|
+
redirect_output: IO = None,
|
|
1009
|
+
redirect_errors: IO = None,
|
|
1010
|
+
capture: bool = False,
|
|
1011
|
+
heartrate: int = DEFAULT_HEARTRATE,
|
|
1012
|
+
delay_start: float = DEFAULT_DELAY,
|
|
1013
|
+
no_confirm: bool = False,
|
|
1014
|
+
client_timeout: int = None,
|
|
1015
|
+
task_timeout: int = None,
|
|
1016
|
+
task_signalwait: int = DEFAULT_SIGNALWAIT) -> None:
|
|
1017
|
+
"""
|
|
1018
|
+
Run client until disconnect signal received or `client_timeout` reached.
|
|
1019
|
+
|
|
1020
|
+
Args:
|
|
1021
|
+
num_tasks (int, optional):
|
|
1022
|
+
Number of parallel task executor threads.
|
|
1023
|
+
See :const:`DEFAULT_NUM_TASKS`.
|
|
1024
|
+
|
|
1025
|
+
bundlesize (int optional):
|
|
1026
|
+
Size of task bundles returned to server.
|
|
1027
|
+
See :const:`DEFAULT_BUNDLESIZE`.
|
|
1028
|
+
|
|
1029
|
+
bundlewait (int optional):
|
|
1030
|
+
Waiting period in seconds before forcing return of task bundle to server.
|
|
1031
|
+
See :const:`DEFAULT_BUNDLEWAIT`.
|
|
1032
|
+
|
|
1033
|
+
address (tuple, optional):
|
|
1034
|
+
Server host address for server with port number.
|
|
1035
|
+
See :const:`DEFAULT_HOST` and :const:`DEFAULT_PORT`.
|
|
1036
|
+
|
|
1037
|
+
auth (str, optional):
|
|
1038
|
+
Server authentication key.
|
|
1039
|
+
See :const:`DEFAULT_AUTH`.
|
|
1040
|
+
|
|
1041
|
+
template (str, optional):
|
|
1042
|
+
Template command pattern. See :const:`DEFAULT_TEMPLATE`.
|
|
1043
|
+
|
|
1044
|
+
redirect_output (IO, optional):
|
|
1045
|
+
Optional file-like object for <stdout> redirect.
|
|
1046
|
+
|
|
1047
|
+
redirect_errors (IO, optional):
|
|
1048
|
+
Optional file-like object for <stderr> redirect.
|
|
1049
|
+
|
|
1050
|
+
heartrate (int, optional):
|
|
1051
|
+
Period in seconds to wait between heartbeats.
|
|
1052
|
+
See :const:`DEFAULT_HEARTRATE`,
|
|
1053
|
+
|
|
1054
|
+
capture (bool, optional):
|
|
1055
|
+
Isolate task <stdout> and <stderr> in discrete files.
|
|
1056
|
+
Defaults to `False`.
|
|
1057
|
+
|
|
1058
|
+
delay_start (float, optional):
|
|
1059
|
+
Delay in seconds before connecting to server.
|
|
1060
|
+
See :const:`DEFAULT_DELAY`.
|
|
1061
|
+
|
|
1062
|
+
no_confirm (bool, optional):
|
|
1063
|
+
Disable client confirmation of tasks received.
|
|
1064
|
+
|
|
1065
|
+
client_timeout (int, optional):
|
|
1066
|
+
Timeout in seconds before disconnecting from server.
|
|
1067
|
+
By default, the client waits for server tor request disconnect.
|
|
1068
|
+
|
|
1069
|
+
task_timeout (int, optional):
|
|
1070
|
+
Task-level walltime limit in seconds.
|
|
1071
|
+
By default, the client waits indefinitely on tasks.
|
|
1072
|
+
|
|
1073
|
+
task_signalwait (int, optional):
|
|
1074
|
+
Signal escalation waiting period in seconds on task timeout.
|
|
1075
|
+
See :const:`DEFAULT_SIGNALWAIT`.
|
|
1076
|
+
|
|
1077
|
+
Example:
|
|
1078
|
+
>>> from hypershell.client import run_client
|
|
1079
|
+
>>> run_client(num_tasks=16, address=('localhost', 54321),
|
|
1080
|
+
... auth='my-secret-key', capture=True)
|
|
1081
|
+
|
|
1082
|
+
See Also:
|
|
1083
|
+
- :meth:`ClientThread`
|
|
1084
|
+
"""
|
|
1085
|
+
thread = ClientThread.new(num_tasks=num_tasks,
|
|
1086
|
+
bundlesize=bundlesize,
|
|
1087
|
+
bundlewait=bundlewait,
|
|
1088
|
+
address=address,
|
|
1089
|
+
auth=auth,
|
|
1090
|
+
template=template,
|
|
1091
|
+
capture=capture,
|
|
1092
|
+
redirect_output=redirect_output,
|
|
1093
|
+
redirect_errors=redirect_errors,
|
|
1094
|
+
heartrate=heartrate,
|
|
1095
|
+
delay_start=delay_start,
|
|
1096
|
+
no_confirm=no_confirm,
|
|
1097
|
+
client_timeout=client_timeout,
|
|
1098
|
+
task_timeout=task_timeout,
|
|
1099
|
+
task_signalwait=task_signalwait)
|
|
1100
|
+
try:
|
|
1101
|
+
thread.join()
|
|
1102
|
+
except Exception:
|
|
1103
|
+
thread.stop()
|
|
1104
|
+
raise
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
APP_NAME = 'hs client'
|
|
1108
|
+
APP_USAGE = f"""\
|
|
1109
|
+
Usage:
|
|
1110
|
+
hs client [-h] [-N NUM] [-t CMD] [-b SIZE] [-w SEC] [-H ADDR] [-p PORT] [-k KEY]
|
|
1111
|
+
[--capture | [-o PATH] [-e PATH]] [--no-confirm] [-d SEC] [-T SEC] [-W SEC] [-S SEC]
|
|
1112
|
+
|
|
1113
|
+
Launch client directly, run tasks in parallel.\
|
|
1114
|
+
"""
|
|
1115
|
+
|
|
1116
|
+
APP_HELP = f"""\
|
|
1117
|
+
{APP_USAGE}
|
|
1118
|
+
|
|
1119
|
+
Tasks are pulled off of the shared queue in bundles from the server and run
|
|
1120
|
+
locally within the same shell as the client. By default the bundle size is one,
|
|
1121
|
+
meaning that at small scales there is greater responsiveness. It is recommended
|
|
1122
|
+
to coordinate these parameters to be the same as the server.
|
|
1123
|
+
|
|
1124
|
+
Options:
|
|
1125
|
+
-N, --num-tasks NUM Number of tasks to run in parallel (default: {DEFAULT_NUM_TASKS}).
|
|
1126
|
+
-t, --template CMD Command-line template pattern (default: "{DEFAULT_TEMPLATE}").
|
|
1127
|
+
-b, --bundlesize SIZE Bundle size for finished tasks (default: {DEFAULT_BUNDLESIZE}).
|
|
1128
|
+
-w, --bundlewait SEC Seconds to wait before flushing tasks (default: {DEFAULT_BUNDLEWAIT}).
|
|
1129
|
+
-H, --host ADDR Hostname for server.
|
|
1130
|
+
-p, --port NUM Port number for server.
|
|
1131
|
+
-k, --auth KEY Cryptographic key to connect to server.
|
|
1132
|
+
-d, --delay-start SEC Seconds to wait before start-up (default: {DEFAULT_DELAY}).
|
|
1133
|
+
--no-confirm Disable confirmation of task bundle received.
|
|
1134
|
+
-o, --output PATH Redirect task output (default: <stdout>).
|
|
1135
|
+
-e, --errors PATH Redirect task errors (default: <stderr>).
|
|
1136
|
+
-c, --capture Capture individual task <stdout> and <stderr>.
|
|
1137
|
+
-T, --timeout SEC Automatically shutdown if no tasks received (default: never).
|
|
1138
|
+
-W, --task-timeout SEC Task-level walltime limit (default: none).
|
|
1139
|
+
-S, --signalwait SEC Task-level signal escalation wait period (default: {DEFAULT_SIGNALWAIT}).
|
|
1140
|
+
-h, --help Show this message and exit.\
|
|
1141
|
+
"""
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
class ClientApp(Application):
|
|
1145
|
+
"""Run individual client directly."""
|
|
1146
|
+
|
|
1147
|
+
name = APP_NAME
|
|
1148
|
+
interface = Interface(APP_NAME, APP_USAGE, APP_HELP)
|
|
1149
|
+
|
|
1150
|
+
num_tasks: int = DEFAULT_NUM_TASKS
|
|
1151
|
+
interface.add_argument('-N', '--num-tasks', type=int, default=num_tasks)
|
|
1152
|
+
|
|
1153
|
+
host: str = config.server.bind
|
|
1154
|
+
interface.add_argument('-H', '--host', default=host)
|
|
1155
|
+
|
|
1156
|
+
port: int = config.server.port
|
|
1157
|
+
interface.add_argument('-p', '--port', type=int, default=port)
|
|
1158
|
+
|
|
1159
|
+
auth: str = config.server.auth
|
|
1160
|
+
interface.add_argument('-k', '--auth', default=auth)
|
|
1161
|
+
|
|
1162
|
+
template: str = DEFAULT_TEMPLATE
|
|
1163
|
+
interface.add_argument('-t', '--template', default=template)
|
|
1164
|
+
|
|
1165
|
+
bundlesize: int = config.submit.bundlesize
|
|
1166
|
+
interface.add_argument('-b', '--bundlesize', type=int, default=bundlesize)
|
|
1167
|
+
|
|
1168
|
+
bundlewait: int = config.submit.bundlewait
|
|
1169
|
+
interface.add_argument('-w', '--bundlewait', type=int, default=bundlewait)
|
|
1170
|
+
|
|
1171
|
+
delay_start: float = DEFAULT_DELAY
|
|
1172
|
+
interface.add_argument('-d', '--delay-start', type=float, default=delay_start)
|
|
1173
|
+
|
|
1174
|
+
task_timeout: int = config.task.timeout
|
|
1175
|
+
client_timeout: int = config.client.timeout
|
|
1176
|
+
interface.add_argument('-T', '--timeout', type=int, default=client_timeout, dest='client_timeout')
|
|
1177
|
+
interface.add_argument('-W', '--task-timeout', type=int, default=task_timeout, dest='task_timeout')
|
|
1178
|
+
|
|
1179
|
+
task_signalwait: int = config.task.signalwait
|
|
1180
|
+
interface.add_argument('-S', '--task-signalwait', type=int, default=task_signalwait, dest='task_signalwait')
|
|
1181
|
+
|
|
1182
|
+
no_confirm: bool = False
|
|
1183
|
+
interface.add_argument('--no-confirm', action='store_true')
|
|
1184
|
+
|
|
1185
|
+
output_path: str = None
|
|
1186
|
+
errors_path: str = None
|
|
1187
|
+
interface.add_argument('-o', '--output', default=None, dest='output_path')
|
|
1188
|
+
interface.add_argument('-e', '--errors', default=None, dest='errors_path')
|
|
1189
|
+
|
|
1190
|
+
capture: bool = False
|
|
1191
|
+
interface.add_argument('-c', '--capture', action='store_true')
|
|
1192
|
+
|
|
1193
|
+
# Hidden options used as helpers for shell completion
|
|
1194
|
+
interface.add_argument('--available-cores', action='version', version=str(cpu_count()))
|
|
1195
|
+
interface.add_argument('--available-ssh-groups', action='version', version='\n'.join(SSH_GROUPS))
|
|
1196
|
+
|
|
1197
|
+
exceptions = {
|
|
1198
|
+
EOFError: functools.partial(handle_disconnect, logger=log),
|
|
1199
|
+
ConnectionResetError: functools.partial(handle_disconnect, logger=log),
|
|
1200
|
+
ConnectionRefusedError: functools.partial(handle_exception, logger=log, status=exit_status.runtime_error),
|
|
1201
|
+
AuthenticationError: functools.partial(handle_exception, logger=log, status=exit_status.runtime_error),
|
|
1202
|
+
HostAddressInfo: functools.partial(handle_address_unknown, logger=log, status=exit_status.runtime_error),
|
|
1203
|
+
**get_shared_exception_mapping(__name__),
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
def run(self: ClientApp) -> None:
|
|
1207
|
+
"""Run client."""
|
|
1208
|
+
try:
|
|
1209
|
+
self.check_args()
|
|
1210
|
+
run_client(num_tasks=self.num_tasks,
|
|
1211
|
+
bundlesize=self.bundlesize,
|
|
1212
|
+
bundlewait=self.bundlewait,
|
|
1213
|
+
address=(self.host, self.port),
|
|
1214
|
+
auth=self.auth,
|
|
1215
|
+
template=self.template,
|
|
1216
|
+
redirect_output=self.output_stream,
|
|
1217
|
+
redirect_errors=self.errors_stream,
|
|
1218
|
+
capture=self.capture,
|
|
1219
|
+
delay_start=self.delay_start,
|
|
1220
|
+
no_confirm=self.no_confirm,
|
|
1221
|
+
heartrate=config.client.heartrate,
|
|
1222
|
+
client_timeout=self.client_timeout,
|
|
1223
|
+
task_timeout=self.task_timeout,
|
|
1224
|
+
task_signalwait=self.task_signalwait)
|
|
1225
|
+
except gaierror:
|
|
1226
|
+
raise HostAddressInfo(f'Could not resolve host \'{self.host}\'')
|
|
1227
|
+
|
|
1228
|
+
def check_args(self: ClientApp) -> None:
|
|
1229
|
+
"""Check for logical errors in command-line arguments."""
|
|
1230
|
+
if self.capture and (self.output_path or self.errors_path):
|
|
1231
|
+
raise ArgumentError('Cannot specify --capture with either --output or --errors')
|
|
1232
|
+
if self.client_timeout is not None and self.client_timeout <= 0:
|
|
1233
|
+
raise ArgumentError('Client --timeout should be positive integer')
|
|
1234
|
+
if self.task_timeout is not None and self.task_timeout <= 0:
|
|
1235
|
+
raise ArgumentError('Client --task-timeout should be positive integer')
|
|
1236
|
+
|
|
1237
|
+
@functools.cached_property
|
|
1238
|
+
def output_stream(self: ClientApp) -> IO:
|
|
1239
|
+
"""IO stream for task outputs."""
|
|
1240
|
+
return sys.stdout if not self.output_path else open(self.output_path, mode='w')
|
|
1241
|
+
|
|
1242
|
+
@functools.cached_property
|
|
1243
|
+
def errors_stream(self: ClientApp) -> IO:
|
|
1244
|
+
"""IO stream for task errors."""
|
|
1245
|
+
return sys.stderr if not self.errors_path else open(self.errors_path, mode='w')
|
|
1246
|
+
|
|
1247
|
+
def __exit__(self: ClientApp,
|
|
1248
|
+
exc_type: Optional[Type[Exception]],
|
|
1249
|
+
exc_val: Optional[Exception],
|
|
1250
|
+
exc_tb: Optional[TracebackType]) -> None:
|
|
1251
|
+
"""Close IO streams if necessary."""
|
|
1252
|
+
if self.output_stream is not sys.stdout:
|
|
1253
|
+
self.output_stream.close()
|
|
1254
|
+
if self.errors_stream is not sys.stderr:
|
|
1255
|
+
self.errors_stream.close()
|