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/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()