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/submit.py ADDED
@@ -0,0 +1,815 @@
1
+ # SPDX-FileCopyrightText: 2025 Geoffrey Lentner
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """
5
+ Submit tasks to the database.
6
+
7
+ Any iterable of command lines can be submitted directly.
8
+ Example:
9
+ >>> from hypershell.submit import submit_from
10
+ >>> submit_from(['echo AA', 'echo BB', 'echo CC'])
11
+
12
+ A file stream is a valid iterable to pass to `submit_from`.
13
+ Use `submit_file` with the file path as shorthand.
14
+
15
+ Example:
16
+ >>> from hypershell.submit import submit_file
17
+ >>> submit_file('/path/to/commandlines.txt')
18
+
19
+ Embed a `SubmitThread` in your application directly as the `ServerThread` does.
20
+ Call `stop()` to stop early.
21
+
22
+ Example:
23
+ >>> import sys
24
+ >>> from hypershell.submit import SubmitThread
25
+ >>> submit_thread = SubmitThread.new(sys.stdin, bundlesize=10)
26
+
27
+ Note:
28
+ In order for the `SubmitThread` to actively monitor the state set by `stop` and
29
+ halt execution (a requirement because of how CPython does threading), the implementation
30
+ uses a finite state machine. *You should not instantiate this machine directly*.
31
+
32
+ Warning:
33
+ Because the `SubmitThread` checks state actively to decide whether to halt, if your
34
+ `source` is blocking (e.g., `sys.stdin`) it will not be able to halt immediately. If
35
+ your main program exits however, the thread will be stopped regardless because it
36
+ runs as a `daemon`.
37
+ """
38
+
39
+
40
+ # type annotations
41
+ from __future__ import annotations
42
+ from typing import List, Iterable, Iterator, IO, Optional, Dict, Callable, Type, Final
43
+ from types import TracebackType
44
+
45
+ # standard libs
46
+ import io
47
+ import sys
48
+ import functools
49
+ from enum import Enum
50
+ from datetime import datetime
51
+ from queue import Queue, Empty as QueueEmpty, Full as QueueFull
52
+
53
+ # external libs
54
+ from cmdkit.config import ConfigurationError
55
+ from cmdkit.app import Application
56
+ from cmdkit.cli import Interface, ArgumentError
57
+
58
+ # internal libs
59
+ from hypershell.core.logging import Logger
60
+ from hypershell.core.config import config, default
61
+ from hypershell.core.fsm import State, StateMachine
62
+ from hypershell.core.queue import QueueClient, QueueConfig
63
+ from hypershell.core.thread import Thread
64
+ from hypershell.core.template import Template, DEFAULT_TEMPLATE
65
+ from hypershell.core.exceptions import get_shared_exception_mapping
66
+ from hypershell.core.types import JSONValue
67
+ from hypershell.data.model import Task
68
+ from hypershell.data import initdb, checkdb
69
+ from hypershell.task import Tag
70
+
71
+ # public interface
72
+ __all__ = ['submit_from', 'submit_file', 'SubmitThread', 'LiveSubmitThread',
73
+ 'SubmitApp', 'DEFAULT_BUNDLESIZE', 'DEFAULT_BUNDLEWAIT', 'DEFAULT_TEMPLATE']
74
+
75
+ # initialize logger
76
+ log = Logger.with_name(__name__)
77
+
78
+
79
+ class LoaderState(State, Enum):
80
+ """Finite states of loader machine."""
81
+ START = 0
82
+ GET = 1
83
+ PUT = 2
84
+ FINAL = 3
85
+ HALT = 4
86
+
87
+
88
+ class Loader(StateMachine):
89
+ """Enqueue tasks from iterable source."""
90
+
91
+ task: Task
92
+ source: Iterator[str]
93
+ queue: Queue[Optional[Task]]
94
+ template: Template
95
+ count: int
96
+ tags: Dict[str, str]
97
+
98
+ state = LoaderState.START
99
+ states = LoaderState
100
+
101
+ def __init__(self: Loader,
102
+ source: Iterable[str],
103
+ queue: Queue[Optional[Task]],
104
+ template: str = DEFAULT_TEMPLATE,
105
+ tags: Dict[str, str] = None) -> None:
106
+ """Initialize source to read tasks and submit to database."""
107
+ self.template = Template(template)
108
+ self.source = map(self.template.expand, map(str.strip, map(str, source)))
109
+ self.queue = queue
110
+ self.tags = tags
111
+ self.count = 0
112
+
113
+ @functools.cached_property
114
+ def actions(self: Loader) -> Dict[LoaderState, Callable[[], LoaderState]]:
115
+ return {
116
+ LoaderState.START: self.start,
117
+ LoaderState.GET: self.get_task,
118
+ LoaderState.PUT: self.put_task,
119
+ LoaderState.FINAL: self.finalize,
120
+ }
121
+
122
+ @staticmethod
123
+ def start() -> LoaderState:
124
+ """Jump to GET state."""
125
+ log.debug('Started (loader)')
126
+ return LoaderState.GET
127
+
128
+ def get_task(self: Loader) -> LoaderState:
129
+ """Get the next task from the source."""
130
+ try:
131
+ self.task = Task.new(args=next(self.source), tag=self.tags)
132
+ log.trace(f'Loaded task ({self.task.args})')
133
+ return LoaderState.PUT
134
+ except StopIteration:
135
+ return LoaderState.FINAL
136
+
137
+ def put_task(self: Loader) -> LoaderState:
138
+ """Enqueue loaded task."""
139
+ try:
140
+ self.queue.put(self.task, timeout=1)
141
+ self.count += 1
142
+ return LoaderState.GET
143
+ except QueueFull:
144
+ return LoaderState.PUT
145
+
146
+ @staticmethod
147
+ def finalize() -> LoaderState:
148
+ """Return HALT."""
149
+ log.debug('Done (loader)')
150
+ return LoaderState.HALT
151
+
152
+
153
+ class LoaderThread(Thread):
154
+ """Run loader within dedicated thread."""
155
+
156
+ def __init__(self: LoaderThread,
157
+ source: Iterable[str],
158
+ queue: Queue[Optional[Task]],
159
+ template: str = DEFAULT_TEMPLATE,
160
+ tags: Dict[str, str] = None) -> None:
161
+ """Initialize machine."""
162
+ super().__init__(name='hypershell-submit-loader')
163
+ self.machine = Loader(source=source, queue=queue, template=template, tags=tags)
164
+
165
+ def run_with_exceptions(self: LoaderThread) -> None:
166
+ """Run machine."""
167
+ self.machine.run()
168
+
169
+ def stop(self: LoaderThread, wait: bool = False, timeout: int = None) -> None:
170
+ """Stop machine."""
171
+ log.warning('Stopping (loader)')
172
+ self.machine.halt()
173
+ super().stop(wait=wait, timeout=timeout)
174
+
175
+
176
+ class DatabaseCommitterState(State, Enum):
177
+ """Finite states for database submitter."""
178
+ START = 0
179
+ GET = 1
180
+ COMMIT = 2
181
+ FINAL = 3
182
+ HALT = 4
183
+
184
+
185
+ DEFAULT_BUNDLESIZE: Final[int] = default.submit.bundlesize
186
+ """Default size of task bundles."""
187
+
188
+ DEFAULT_BUNDLEWAIT: Final[int] = default.submit.bundlewait
189
+ """Default waiting period before forcing task bundle push."""
190
+
191
+
192
+ class DatabaseCommitter(StateMachine):
193
+ """Commit tasks from local queue to database."""
194
+
195
+ queue: Queue[Optional[Task]]
196
+ tasks: List[Task]
197
+ bundlesize: int
198
+ bundlewait: int
199
+ previous_submit: datetime
200
+
201
+ state = DatabaseCommitterState.START
202
+ states = DatabaseCommitterState
203
+
204
+ def __init__(self: DatabaseCommitter,
205
+ queue: Queue[Optional[Task]],
206
+ bundlesize: int = DEFAULT_BUNDLESIZE,
207
+ bundlewait: int = DEFAULT_BUNDLEWAIT) -> None:
208
+ """Initialize with task queue and buffering parameters."""
209
+ self.queue = queue
210
+ self.tasks = []
211
+ self.bundlesize = bundlesize
212
+ self.bundlewait = bundlewait
213
+
214
+ @functools.cached_property
215
+ def actions(self: DatabaseCommitter) -> Dict[DatabaseCommitterState, Callable[[], DatabaseCommitterState]]:
216
+ return {
217
+ DatabaseCommitterState.START: self.start,
218
+ DatabaseCommitterState.GET: self.get_task,
219
+ DatabaseCommitterState.COMMIT: self.commit,
220
+ DatabaseCommitterState.FINAL: self.finalize,
221
+ }
222
+
223
+ def start(self: DatabaseCommitter) -> DatabaseCommitterState:
224
+ """Jump to GET state."""
225
+ log.debug('Started (committer: database)')
226
+ self.previous_submit = datetime.now()
227
+ return DatabaseCommitterState.GET
228
+
229
+ def get_task(self: DatabaseCommitter) -> DatabaseCommitterState:
230
+ """Get tasks from local queue and check buffer."""
231
+ try:
232
+ task = self.queue.get(timeout=1)
233
+ except QueueEmpty:
234
+ return DatabaseCommitterState.GET
235
+ if task is not None:
236
+ self.tasks.append(task)
237
+ since_last = (datetime.now() - self.previous_submit).total_seconds()
238
+ if len(self.tasks) >= self.bundlesize or since_last >= self.bundlewait:
239
+ return DatabaseCommitterState.COMMIT
240
+ else:
241
+ return DatabaseCommitterState.GET
242
+ else:
243
+ return DatabaseCommitterState.FINAL
244
+
245
+ def commit(self: DatabaseCommitter) -> DatabaseCommitterState:
246
+ """Commit tasks to database."""
247
+ if self.tasks:
248
+ Task.add_all(self.tasks)
249
+ log.debug(f'Submitted {len(self.tasks)} tasks')
250
+ self.tasks.clear()
251
+ self.previous_submit = datetime.now()
252
+ return DatabaseCommitterState.GET
253
+
254
+ def finalize(self: DatabaseCommitter) -> DatabaseCommitterState:
255
+ """Force final commit of tasks and halt."""
256
+ self.commit()
257
+ log.debug('Done (committer: database)')
258
+ return DatabaseCommitterState.HALT
259
+
260
+
261
+ class DatabaseCommitterThread(Thread):
262
+ """Run committer within dedicated thread."""
263
+
264
+ def __init__(self: DatabaseCommitterThread,
265
+ queue: Queue[Optional[Task]],
266
+ bundlesize: int = DEFAULT_BUNDLESIZE,
267
+ bundlewait: int = DEFAULT_BUNDLEWAIT) -> None:
268
+ """Initialize machine."""
269
+ super().__init__(name='hypershell-submit-committer')
270
+ self.machine = DatabaseCommitter(queue=queue, bundlesize=bundlesize, bundlewait=bundlewait)
271
+
272
+ def run_with_exceptions(self: DatabaseCommitterThread) -> None:
273
+ """Run machine."""
274
+ self.machine.run()
275
+
276
+ def stop(self: DatabaseCommitterThread, wait: bool = False, timeout: int = None) -> None:
277
+ """Stop machine."""
278
+ log.warning('Stopping (committer: database)')
279
+ self.machine.halt()
280
+ super().stop(wait=wait, timeout=timeout)
281
+
282
+
283
+ class SubmitThread(Thread):
284
+ """
285
+ Submit tasks to database within dedicated thread.
286
+
287
+ Args:
288
+ source (Iterable[str]):
289
+ Any iterable of command-line tasks.
290
+
291
+ bundlesize (int, optional):
292
+ Size of task bundles.
293
+ See :const:`DEFAULT_BUNDLESIZE`.
294
+
295
+ bundlewait (int, optional):
296
+ Waiting period before forcing task bundle push.
297
+ See :const:`DEFAULT_BUNDLEWAIT`.
298
+
299
+ template (str, optional):
300
+ Task command-line template pattern.
301
+ See :const:`DEFAULT_TEMPLATE`.
302
+
303
+ tags (Dict[str, JSONValue], optional):
304
+ Tag dictionary for all submitted tasks.
305
+
306
+ Example:
307
+ >>> from hypershell.submit import SubmitThread
308
+ >>> submitter = SubmitThread.new(['AAA', 'BBB', 'CCC'],
309
+ ... template='my-script {}'
310
+ ... tags={'site': 'zzz', 'group': 37})
311
+ >>> submitter.join()
312
+
313
+ See Also:
314
+ - :meth:`submit_from`
315
+ - :meth:`submit_file`
316
+ """
317
+
318
+ source: Iterable[str]
319
+ queue: Queue[Optional[Task]]
320
+ loader: LoaderThread
321
+ committer: DatabaseCommitterThread
322
+
323
+ def __init__(self: SubmitThread,
324
+ source: Iterable[str],
325
+ bundlesize: int = DEFAULT_BUNDLESIZE,
326
+ bundlewait: int = DEFAULT_BUNDLEWAIT,
327
+ template: str = DEFAULT_TEMPLATE,
328
+ tags: Dict[str, JSONValue] = None) -> None:
329
+ """Initialize queue and child threads."""
330
+ self.source = source
331
+ self.queue = Queue(maxsize=bundlesize)
332
+ self.loader = LoaderThread(source=source, queue=self.queue, template=template, tags=tags)
333
+ self.committer = DatabaseCommitterThread(queue=self.queue, bundlesize=bundlesize, bundlewait=bundlewait)
334
+ super().__init__(name='hypershell-submit')
335
+
336
+ def run_with_exceptions(self: SubmitThread) -> None:
337
+ """Start child threads, wait."""
338
+ log.debug(f'Started ({self.source_name})')
339
+ self.loader.start()
340
+ self.committer.start()
341
+ self.loader.join()
342
+ self.queue.put(None)
343
+ self.committer.join()
344
+ log.debug('Done')
345
+
346
+ @functools.cached_property
347
+ def source_name(self: SubmitThread) -> str:
348
+ """Log details of source."""
349
+ if self.source is sys.stdin:
350
+ return '<stdin>'
351
+ elif isinstance(self.source, io.TextIOWrapper):
352
+ return self.source.name
353
+ else:
354
+ return '<iterable>'
355
+
356
+ def stop(self: SubmitThread, wait: bool = False, timeout: int = None) -> None:
357
+ """Stop child threads before main thread."""
358
+ log.warning('Stopping')
359
+ self.loader.stop(wait=wait, timeout=timeout)
360
+ self.queue.put(None)
361
+ self.committer.stop(wait=wait, timeout=timeout)
362
+ super().stop(wait=wait, timeout=timeout)
363
+
364
+ @property
365
+ def task_count(self: SubmitThread) -> int:
366
+ """Count of submitted tasks."""
367
+ return self.loader.machine.count
368
+
369
+
370
+ class QueueCommitterState(State, Enum):
371
+ """Finite states for queue submitter."""
372
+ START = 0
373
+ GET = 1
374
+ PACK = 2
375
+ COMMIT = 3
376
+ FINAL = 4
377
+ HALT = 5
378
+
379
+
380
+ class QueueCommitter(StateMachine):
381
+ """Commit tasks from local queue directly to remote server queue."""
382
+
383
+ local: Queue[Optional[Task]]
384
+ client: QueueClient
385
+
386
+ tasks: List[Task]
387
+ bundle: List[bytes]
388
+
389
+ bundlesize: int
390
+ bundlewait: int
391
+ previous_submit: datetime
392
+
393
+ state = QueueCommitterState.START
394
+ states = QueueCommitterState
395
+
396
+ def __init__(self: QueueCommitter, local: Queue[Optional[Task]], client: QueueClient,
397
+ bundlesize: int = DEFAULT_BUNDLESIZE, bundlewait: int = DEFAULT_BUNDLEWAIT) -> None:
398
+ """Initialize with queue handles and buffering parameters."""
399
+ self.local = local
400
+ self.client = client
401
+ self.tasks = []
402
+ self.bundle = []
403
+ self.bundlesize = bundlesize
404
+ self.bundlewait = bundlewait
405
+
406
+ @functools.cached_property
407
+ def actions(self: QueueCommitter) -> Dict[QueueCommitterState, Callable[[], QueueCommitterState]]:
408
+ return {
409
+ QueueCommitterState.START: self.start,
410
+ QueueCommitterState.GET: self.get_task,
411
+ QueueCommitterState.PACK: self.pack_bundle,
412
+ QueueCommitterState.COMMIT: self.commit,
413
+ QueueCommitterState.FINAL: self.finalize,
414
+ }
415
+
416
+ def start(self: QueueCommitter) -> QueueCommitterState:
417
+ """Jump to GET state."""
418
+ log.debug('Started (committer: no database)')
419
+ self.previous_submit = datetime.now()
420
+ return QueueCommitterState.GET
421
+
422
+ def get_task(self: QueueCommitter) -> QueueCommitterState:
423
+ """Get tasks from local queue and check buffer."""
424
+ try:
425
+ task = self.local.get(timeout=1)
426
+ except QueueEmpty:
427
+ return QueueCommitterState.GET
428
+ if task is not None:
429
+ self.tasks.append(task)
430
+ since_last = (datetime.now() - self.previous_submit).total_seconds()
431
+ if len(self.tasks) >= self.bundlesize or since_last >= self.bundlewait:
432
+ return QueueCommitterState.PACK
433
+ else:
434
+ return QueueCommitterState.GET
435
+ else:
436
+ return QueueCommitterState.FINAL
437
+
438
+ def pack_bundle(self: QueueCommitter) -> QueueCommitterState:
439
+ """Pack tasks into bundle for remote queue."""
440
+ if self.tasks:
441
+ self.bundle = [task.pack() for task in self.tasks]
442
+ return QueueCommitterState.COMMIT
443
+ else:
444
+ return QueueCommitterState.GET
445
+
446
+ def commit(self: QueueCommitter) -> QueueCommitterState:
447
+ """Commit tasks to server scheduling queue."""
448
+ try:
449
+ if self.tasks:
450
+ self.client.scheduled.put(self.bundle, timeout=2)
451
+ for task in self.tasks:
452
+ log.trace(f'Scheduled task ({task.id})')
453
+ self.tasks = []
454
+ self.bundle = []
455
+ self.previous_submit = datetime.now()
456
+ return QueueCommitterState.GET
457
+ except QueueFull:
458
+ return QueueCommitterState.COMMIT
459
+
460
+ def finalize(self) -> QueueCommitterState:
461
+ """Force final commit of tasks and halt."""
462
+ self.pack_bundle()
463
+ self.commit()
464
+ log.debug('Done (committer: no database)')
465
+ return QueueCommitterState.HALT
466
+
467
+
468
+ class QueueCommitterThread(Thread):
469
+ """Run queue committer within dedicated thread."""
470
+
471
+ def __init__(self: QueueCommitterThread, local: Queue[Optional[Task]], client: QueueClient,
472
+ bundlesize: int = DEFAULT_BUNDLESIZE, bundlewait: int = DEFAULT_BUNDLEWAIT) -> None:
473
+ """Initialize machine."""
474
+ super().__init__(name='hypershell-submit-committer')
475
+ self.machine = QueueCommitter(local=local, client=client, bundlesize=bundlesize, bundlewait=bundlewait)
476
+
477
+ def run_with_exceptions(self: QueueCommitterThread) -> None:
478
+ """Run machine."""
479
+ self.machine.run()
480
+
481
+ def stop(self: QueueCommitterThread, wait: bool = False, timeout: int = None) -> None:
482
+ """Stop machine."""
483
+ log.warning('Stopping (committer: no database)')
484
+ self.machine.halt()
485
+ super().stop(wait=wait, timeout=timeout)
486
+
487
+
488
+ class LiveSubmitThread(Thread):
489
+ """
490
+ Submit tasks directly to queue within dedicated thread.
491
+
492
+ Args:
493
+ source (Iterable[str]):
494
+ Any iterable of command-line tasks.
495
+
496
+ queue_config (:class:`~hypershell.core.queue.QueueConfig`):
497
+ QueueConfig instance with `host`, `port`, and `auth`.
498
+
499
+ bundlesize (int, optional):
500
+ Size of task bundles.
501
+ See :const:`DEFAULT_BUNDLESIZE`.
502
+
503
+ bundlewait (int, optional):
504
+ Waiting period before forcing task bundle push.
505
+ See :const:`DEFAULT_BUNDLEWAIT`.
506
+
507
+ template (str, optional):
508
+ Task command-line template pattern.
509
+ See :const:`DEFAULT_TEMPLATE`.
510
+
511
+ tags (Dict[str, JSONValue], optional):
512
+ Tag dictionary for all submitted tasks.
513
+
514
+ Example:
515
+ >>> from hypershell.submit import LiveSubmitThread
516
+ >>> from hypershell.core.queue import QueueConfig
517
+ >>> queue_config = QueueConfig(host='localhost', port=54321, auth='my-secret-key')
518
+ >>> submitter = LiveSubmitThread.new(['AAA', 'BBB', 'CCC'],
519
+ ... queue_config=queue_config,
520
+ ... template='my-script {}'
521
+ ... tags={'site': 'zzz', 'group': 37})
522
+ >>> submitter.join()
523
+
524
+ See Also:
525
+ - :meth:`submit_from`
526
+ - :meth:`submit_file`
527
+ """
528
+
529
+ source: Iterable[str]
530
+ local: Queue[Optional[Task]]
531
+ client: QueueClient
532
+ loader: LoaderThread
533
+ committer: QueueCommitterThread
534
+
535
+ def __init__(self: LiveSubmitThread,
536
+ source: Iterable[str],
537
+ queue_config: QueueConfig,
538
+ template: str = DEFAULT_TEMPLATE,
539
+ bundlesize: int = DEFAULT_BUNDLESIZE,
540
+ bundlewait: int = DEFAULT_BUNDLEWAIT,
541
+ tags: Dict[str, str] = None) -> None:
542
+ """Initialize queue and child threads."""
543
+ self.source = source
544
+ self.local = Queue(maxsize=bundlesize)
545
+ self.loader = LoaderThread(source=source, queue=self.local, template=template, tags=tags)
546
+ self.client = QueueClient(config=queue_config)
547
+ self.committer = QueueCommitterThread(local=self.local, client=self.client,
548
+ bundlesize=bundlesize, bundlewait=bundlewait)
549
+ super().__init__(name='hypershell-submit')
550
+
551
+ def run_with_exceptions(self: LiveSubmitThread) -> None:
552
+ """Start child threads, wait."""
553
+ log.debug(f'Started ({self.source_name})')
554
+ with self.client:
555
+ self.loader.start()
556
+ self.committer.start()
557
+ log.trace('Waiting (loader)')
558
+ self.loader.join()
559
+ self.local.put(None)
560
+ log.trace('Waiting (committer)')
561
+ self.committer.join()
562
+ log.debug('Done')
563
+
564
+ @functools.cached_property
565
+ def source_name(self: LiveSubmitThread) -> str:
566
+ """Log details of source."""
567
+ if self.source is sys.stdin:
568
+ return '<stdin>'
569
+ elif isinstance(self.source, io.TextIOWrapper):
570
+ return self.source.name
571
+ else:
572
+ return '<iterable>'
573
+
574
+ def stop(self: LiveSubmitThread, wait: bool = False, timeout: int = None) -> None:
575
+ """Stop child threads before main thread."""
576
+ log.warning('Stopping')
577
+ self.loader.stop(wait=wait, timeout=timeout)
578
+ self.local.put(None)
579
+ self.committer.stop(wait=wait, timeout=timeout)
580
+ super().stop(wait=wait, timeout=timeout)
581
+
582
+ @property
583
+ def task_count(self: LiveSubmitThread) -> int:
584
+ """Count of submitted tasks."""
585
+ return self.loader.machine.count
586
+
587
+
588
+ def submit_from(source: Iterable[str],
589
+ queue_config: QueueConfig = None,
590
+ bundlesize: int = DEFAULT_BUNDLESIZE,
591
+ bundlewait: int = DEFAULT_BUNDLEWAIT,
592
+ template: str = DEFAULT_TEMPLATE,
593
+ tags: Dict[str, JSONValue] = None) -> int:
594
+ """
595
+ Submit all task arguments from `source`, return count of submitted tasks.
596
+
597
+ If `queue_config` is provided, the :class:`LiveSubmitThread` is used to submit
598
+ task bundles directly to the shared queue hosted by the server. Otherwise,
599
+ the :class:`SubmitThread` submits tasks to the database.
600
+
601
+ Args:
602
+ source (Iterable[str]):
603
+ Any iterable of command-line tasks.
604
+
605
+ queue_config (:class:`~hypershell.core.queue.QueueConfig`):
606
+ QueueConfig instance with `host`, `port`, and `auth`.
607
+
608
+ bundlesize (int, optional):
609
+ Size of task bundles.
610
+ See :const:`DEFAULT_BUNDLESIZE`.
611
+
612
+ bundlewait (int, optional):
613
+ Waiting period before forcing task bundle push.
614
+ See :const:`DEFAULT_BUNDLEWAIT`.
615
+
616
+ template (str, optional):
617
+ Task command-line template pattern.
618
+ See :const:`DEFAULT_TEMPLATE`.
619
+
620
+ tags (Dict[str, JSONValue], optional):
621
+ Tag dictionary for all submitted tasks.
622
+
623
+ Example:
624
+ >>> from hypershell.submit import submit_from
625
+ >>> submit_from(['AAA', 'BBB', 'CCC'],
626
+ ... template='my-script {}'
627
+ ... tags={'site': 'zzz', 'group': 37})
628
+ 3
629
+
630
+ See Also:
631
+ - :meth:`submit_file`
632
+ - :class:`SubmitThread`
633
+ - :class:`LiveSubmitThread`
634
+
635
+ Returns:
636
+ task_count (int): Count of submitted tasks.
637
+ """
638
+ if not queue_config:
639
+ thread = SubmitThread.new(source=source,
640
+ bundlesize=bundlesize,
641
+ bundlewait=bundlewait,
642
+ template=template,
643
+ tags=tags)
644
+ else:
645
+ thread = LiveSubmitThread.new(source=source,
646
+ queue_config=queue_config,
647
+ template=template,
648
+ bundlesize=bundlesize,
649
+ bundlewait=bundlewait,
650
+ tags=tags)
651
+ try:
652
+ thread.join()
653
+ except Exception:
654
+ thread.stop()
655
+ raise
656
+ else:
657
+ return thread.task_count
658
+
659
+
660
+ def submit_file(path: str,
661
+ queue_config: QueueConfig = None,
662
+ bundlesize: int = DEFAULT_BUNDLESIZE,
663
+ bundlewait: int = DEFAULT_BUNDLEWAIT,
664
+ template: str = DEFAULT_TEMPLATE,
665
+ tags: Dict[str, JSONValue] = None,
666
+ **file_options) -> int:
667
+ """
668
+ Submit all task arguments by reading them from file `path`.
669
+
670
+ Arguments are forwarded to :func:`submit_from` with the opened file stream
671
+ from `path` as the task `source`.
672
+
673
+ Args:
674
+ path (str):
675
+ Path to file containing command-line tasks.
676
+
677
+ queue_config (:class:`~hypershell.core.queue.QueueConfig`):
678
+ QueueConfig instance with `host`, `port`, and `auth`.
679
+
680
+ bundlesize (int, optional):
681
+ Size of task bundles.
682
+ See :const:`DEFAULT_BUNDLESIZE`.
683
+
684
+ bundlewait (int, optional):
685
+ Waiting period before forcing task bundle push.
686
+ See :const:`DEFAULT_BUNDLEWAIT`.
687
+
688
+ template (str, optional):
689
+ Task command-line template pattern.
690
+ See :const:`DEFAULT_TEMPLATE`.
691
+
692
+ tags (Dict[str, JSONValue], optional):
693
+ Tag dictionary for all submitted tasks.
694
+
695
+ Example:
696
+ >>> from hypershell.submit import submit_from
697
+ >>> from hypershell.core.queue import QueueConfig
698
+ >>> queue_config = QueueConfig(host='my.server.univ.edu', port=54321, auth='my-secret-key')
699
+ >>> submit_file('/tmp/tasks.in',
700
+ ... queue_config=queue_config,
701
+ ... template='my-script {}'
702
+ ... tags={'site': 'zzz', 'group': 37})
703
+ 3
704
+
705
+ See Also:
706
+ - :meth:`submit_from`
707
+ - :class:`SubmitThread`
708
+ - :class:`LiveSubmitThread`
709
+
710
+ Returns:
711
+ task_count (int): Count of submitted tasks.
712
+ """
713
+ with open(path, mode='r', **file_options) as stream:
714
+ return submit_from(stream, queue_config=queue_config, bundlesize=bundlesize,
715
+ bundlewait=bundlewait, template=template, tags=tags)
716
+
717
+
718
+ APP_NAME = 'hs submit'
719
+ APP_USAGE = f"""\
720
+ Usage:
721
+ {APP_NAME} [-h] [FILE] [-b NUM] [-w SEC] [-t CMD] [--initdb] [--tag TAG [TAG...]]
722
+ Submit tasks from a file.\
723
+ """
724
+
725
+ APP_HELP = f"""\
726
+ {APP_USAGE}
727
+
728
+ Arguments:
729
+ FILE Path to task file ("-" for <stdin>).
730
+
731
+ Options:
732
+ -t, --template CMD Submit-time template expansion (default: "{DEFAULT_TEMPLATE}").
733
+ -b, --bundlesize NUM Number of lines to buffer (default: {DEFAULT_BUNDLESIZE}).
734
+ -w, --bundlewait SEC Seconds to wait before flushing tasks (default: {DEFAULT_BUNDLEWAIT}).
735
+ --initdb Auto-initialize database.
736
+ --tag TAG... Assign tags as `key:value`.
737
+ -h, --help Show this message and exit.\
738
+ """
739
+
740
+
741
+ class SubmitApp(Application):
742
+ """Submit tasks to the database."""
743
+
744
+ name = APP_NAME
745
+ interface = Interface(APP_NAME, APP_USAGE, APP_HELP)
746
+
747
+ source: IO
748
+ filepath: str
749
+ interface.add_argument('filepath', nargs='?', default='-')
750
+
751
+ bundlesize: int = config.submit.bundlesize
752
+ interface.add_argument('-b', '--bundlesize', type=int, default=bundlesize)
753
+
754
+ bundlewait: int = config.submit.bundlewait
755
+ interface.add_argument('-w', '--bundlewait', type=int, default=bundlewait)
756
+
757
+ template: str = DEFAULT_TEMPLATE
758
+ interface.add_argument('-t', '--template', default=template)
759
+
760
+ auto_initdb: bool = False
761
+ interface.add_argument('--initdb', action='store_true', dest='auto_initdb')
762
+
763
+ tags: Dict[str, JSONValue] = {}
764
+ taglist: List[str] = None
765
+ interface.add_argument('--tag', nargs='*', default=[], dest='taglist')
766
+
767
+ count: int = 0
768
+
769
+ exceptions = {
770
+ **get_shared_exception_mapping(__name__)
771
+ }
772
+
773
+ def run(self: SubmitApp) -> None:
774
+ """Run submit thread."""
775
+ self.submit_all()
776
+ log.info(f'Submitted {self.count} tasks')
777
+
778
+ def submit_all(self: SubmitApp) -> None:
779
+ """Submit all tasks from source."""
780
+ self.count = submit_from(self.source, template=self.template,
781
+ bundlesize=self.bundlesize, bundlewait=self.bundlewait, tags=self.tags)
782
+
783
+ @staticmethod
784
+ def check_config():
785
+ """Halt if we are not connected to database."""
786
+ db = config.database.get('file', None) or config.database.get('database', None)
787
+ if config.database.provider == 'sqlite' and db in ('', ':memory:', None):
788
+ raise ConfigurationError('Submitting tasks to in-memory database has no effect')
789
+
790
+ def check_tags(self: SubmitApp) -> None:
791
+ """Ensure valid tags."""
792
+ self.tags = {} if not self.taglist else Tag.parse_cmdline_list(self.taglist)
793
+ try:
794
+ Task.ensure_valid_tag(self.tags)
795
+ except (ValueError, TypeError) as error:
796
+ raise ArgumentError(str(error)) from error
797
+
798
+ def __enter__(self: SubmitApp) -> SubmitApp:
799
+ """Open file if not stdin."""
800
+ self.source = sys.stdin if self.filepath == '-' else open(self.filepath, mode='r')
801
+ self.check_config()
802
+ self.check_tags()
803
+ if config.database.provider == 'sqlite' or self.auto_initdb:
804
+ initdb() # Auto-initialize if local sqlite provider
805
+ else:
806
+ checkdb()
807
+ return self
808
+
809
+ def __exit__(self: SubmitApp,
810
+ exc_type: Optional[Type[Exception]],
811
+ exc_val: Optional[Exception],
812
+ exc_tb: Optional[TracebackType]) -> None:
813
+ """Close file if not stdin."""
814
+ if self.source is not sys.stdin:
815
+ self.source.close()