ominfra 0.0.0.dev120__py3-none-any.whl → 0.0.0.dev122__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.
@@ -25,12 +25,15 @@ import logging
25
25
  import os
26
26
  import os.path
27
27
  import queue
28
+ import re
28
29
  import shlex
29
30
  import signal
31
+ import string
30
32
  import subprocess
31
33
  import sys
32
34
  import threading
33
35
  import time
36
+ import types
34
37
  import typing as ta
35
38
  import urllib.parse
36
39
  import urllib.request
@@ -49,16 +52,842 @@ if sys.version_info < (3, 8):
49
52
  ########################################
50
53
 
51
54
 
55
+ # ../../../../../omdev/toml/parser.py
56
+ TomlParseFloat = ta.Callable[[str], ta.Any]
57
+ TomlKey = ta.Tuple[str, ...]
58
+ TomlPos = int # ta.TypeAlias
59
+
52
60
  # ../../../../../omlish/lite/cached.py
53
61
  T = ta.TypeVar('T')
54
62
 
55
63
  # ../../../../../omlish/lite/contextmanagers.py
56
64
  ExitStackedT = ta.TypeVar('ExitStackedT', bound='ExitStacked')
57
65
 
66
+ # ../../../../configs.py
67
+ ConfigMapping = ta.Mapping[str, ta.Any]
68
+
58
69
  # ../../../../threadworkers.py
59
70
  ThreadWorkerT = ta.TypeVar('ThreadWorkerT', bound='ThreadWorker')
60
71
 
61
72
 
73
+ ########################################
74
+ # ../../../../../omdev/toml/parser.py
75
+ # SPDX-License-Identifier: MIT
76
+ # SPDX-FileCopyrightText: 2021 Taneli Hukkinen
77
+ # Licensed to PSF under a Contributor Agreement.
78
+ #
79
+ # PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2
80
+ # --------------------------------------------
81
+ #
82
+ # 1. This LICENSE AGREEMENT is between the Python Software Foundation ("PSF"), and the Individual or Organization
83
+ # ("Licensee") accessing and otherwise using this software ("Python") in source or binary form and its associated
84
+ # documentation.
85
+ #
86
+ # 2. Subject to the terms and conditions of this License Agreement, PSF hereby grants Licensee a nonexclusive,
87
+ # royalty-free, world-wide license to reproduce, analyze, test, perform and/or display publicly, prepare derivative
88
+ # works, distribute, and otherwise use Python alone or in any derivative version, provided, however, that PSF's License
89
+ # Agreement and PSF's notice of copyright, i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009,
90
+ # 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023 Python Software Foundation; All
91
+ # Rights Reserved" are retained in Python alone or in any derivative version prepared by Licensee.
92
+ #
93
+ # 3. In the event Licensee prepares a derivative work that is based on or incorporates Python or any part thereof, and
94
+ # wants to make the derivative work available to others as provided herein, then Licensee hereby agrees to include in
95
+ # any such work a brief summary of the changes made to Python.
96
+ #
97
+ # 4. PSF is making Python available to Licensee on an "AS IS" basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES,
98
+ # EXPRESS OR IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND DISCLAIMS ANY REPRESENTATION OR WARRANTY
99
+ # OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT INFRINGE ANY THIRD PARTY
100
+ # RIGHTS.
101
+ #
102
+ # 5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL
103
+ # DAMAGES OR LOSS AS A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, OR ANY DERIVATIVE THEREOF, EVEN IF
104
+ # ADVISED OF THE POSSIBILITY THEREOF.
105
+ #
106
+ # 6. This License Agreement will automatically terminate upon a material breach of its terms and conditions.
107
+ #
108
+ # 7. Nothing in this License Agreement shall be deemed to create any relationship of agency, partnership, or joint
109
+ # venture between PSF and Licensee. This License Agreement does not grant permission to use PSF trademarks or trade
110
+ # name in a trademark sense to endorse or promote products or services of Licensee, or any third party.
111
+ #
112
+ # 8. By copying, installing or otherwise using Python, Licensee agrees to be bound by the terms and conditions of this
113
+ # License Agreement.
114
+ #
115
+ # https://github.com/python/cpython/blob/9ce90206b7a4649600218cf0bd4826db79c9a312/Lib/tomllib/_parser.py
116
+
117
+
118
+ ##
119
+
120
+
121
+ _TOML_TIME_RE_STR = r'([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])(?:\.([0-9]{1,6})[0-9]*)?'
122
+
123
+ TOML_RE_NUMBER = re.compile(
124
+ r"""
125
+ 0
126
+ (?:
127
+ x[0-9A-Fa-f](?:_?[0-9A-Fa-f])* # hex
128
+ |
129
+ b[01](?:_?[01])* # bin
130
+ |
131
+ o[0-7](?:_?[0-7])* # oct
132
+ )
133
+ |
134
+ [+-]?(?:0|[1-9](?:_?[0-9])*) # dec, integer part
135
+ (?P<floatpart>
136
+ (?:\.[0-9](?:_?[0-9])*)? # optional fractional part
137
+ (?:[eE][+-]?[0-9](?:_?[0-9])*)? # optional exponent part
138
+ )
139
+ """,
140
+ flags=re.VERBOSE,
141
+ )
142
+ TOML_RE_LOCALTIME = re.compile(_TOML_TIME_RE_STR)
143
+ TOML_RE_DATETIME = re.compile(
144
+ rf"""
145
+ ([0-9]{{4}})-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01]) # date, e.g. 1988-10-27
146
+ (?:
147
+ [Tt ]
148
+ {_TOML_TIME_RE_STR}
149
+ (?:([Zz])|([+-])([01][0-9]|2[0-3]):([0-5][0-9]))? # optional time offset
150
+ )?
151
+ """,
152
+ flags=re.VERBOSE,
153
+ )
154
+
155
+
156
+ def toml_match_to_datetime(match: re.Match) -> ta.Union[datetime.datetime, datetime.date]:
157
+ """Convert a `RE_DATETIME` match to `datetime.datetime` or `datetime.date`.
158
+
159
+ Raises ValueError if the match does not correspond to a valid date or datetime.
160
+ """
161
+ (
162
+ year_str,
163
+ month_str,
164
+ day_str,
165
+ hour_str,
166
+ minute_str,
167
+ sec_str,
168
+ micros_str,
169
+ zulu_time,
170
+ offset_sign_str,
171
+ offset_hour_str,
172
+ offset_minute_str,
173
+ ) = match.groups()
174
+ year, month, day = int(year_str), int(month_str), int(day_str)
175
+ if hour_str is None:
176
+ return datetime.date(year, month, day)
177
+ hour, minute, sec = int(hour_str), int(minute_str), int(sec_str)
178
+ micros = int(micros_str.ljust(6, '0')) if micros_str else 0
179
+ if offset_sign_str:
180
+ tz: ta.Optional[datetime.tzinfo] = toml_cached_tz(
181
+ offset_hour_str, offset_minute_str, offset_sign_str,
182
+ )
183
+ elif zulu_time:
184
+ tz = datetime.UTC
185
+ else: # local date-time
186
+ tz = None
187
+ return datetime.datetime(year, month, day, hour, minute, sec, micros, tzinfo=tz)
188
+
189
+
190
+ @functools.lru_cache() # noqa
191
+ def toml_cached_tz(hour_str: str, minute_str: str, sign_str: str) -> datetime.timezone:
192
+ sign = 1 if sign_str == '+' else -1
193
+ return datetime.timezone(
194
+ datetime.timedelta(
195
+ hours=sign * int(hour_str),
196
+ minutes=sign * int(minute_str),
197
+ ),
198
+ )
199
+
200
+
201
+ def toml_match_to_localtime(match: re.Match) -> datetime.time:
202
+ hour_str, minute_str, sec_str, micros_str = match.groups()
203
+ micros = int(micros_str.ljust(6, '0')) if micros_str else 0
204
+ return datetime.time(int(hour_str), int(minute_str), int(sec_str), micros)
205
+
206
+
207
+ def toml_match_to_number(match: re.Match, parse_float: TomlParseFloat) -> ta.Any:
208
+ if match.group('floatpart'):
209
+ return parse_float(match.group())
210
+ return int(match.group(), 0)
211
+
212
+
213
+ TOML_ASCII_CTRL = frozenset(chr(i) for i in range(32)) | frozenset(chr(127))
214
+
215
+ # Neither of these sets include quotation mark or backslash. They are currently handled as separate cases in the parser
216
+ # functions.
217
+ TOML_ILLEGAL_BASIC_STR_CHARS = TOML_ASCII_CTRL - frozenset('\t')
218
+ TOML_ILLEGAL_MULTILINE_BASIC_STR_CHARS = TOML_ASCII_CTRL - frozenset('\t\n')
219
+
220
+ TOML_ILLEGAL_LITERAL_STR_CHARS = TOML_ILLEGAL_BASIC_STR_CHARS
221
+ TOML_ILLEGAL_MULTILINE_LITERAL_STR_CHARS = TOML_ILLEGAL_MULTILINE_BASIC_STR_CHARS
222
+
223
+ TOML_ILLEGAL_COMMENT_CHARS = TOML_ILLEGAL_BASIC_STR_CHARS
224
+
225
+ TOML_WS = frozenset(' \t')
226
+ TOML_WS_AND_NEWLINE = TOML_WS | frozenset('\n')
227
+ TOML_BARE_KEY_CHARS = frozenset(string.ascii_letters + string.digits + '-_')
228
+ TOML_KEY_INITIAL_CHARS = TOML_BARE_KEY_CHARS | frozenset("\"'")
229
+ TOML_HEXDIGIT_CHARS = frozenset(string.hexdigits)
230
+
231
+ TOML_BASIC_STR_ESCAPE_REPLACEMENTS = types.MappingProxyType(
232
+ {
233
+ '\\b': '\u0008', # backspace
234
+ '\\t': '\u0009', # tab
235
+ '\\n': '\u000A', # linefeed
236
+ '\\f': '\u000C', # form feed
237
+ '\\r': '\u000D', # carriage return
238
+ '\\"': '\u0022', # quote
239
+ '\\\\': '\u005C', # backslash
240
+ },
241
+ )
242
+
243
+
244
+ class TomlDecodeError(ValueError):
245
+ """An error raised if a document is not valid TOML."""
246
+
247
+
248
+ def toml_load(fp: ta.BinaryIO, /, *, parse_float: TomlParseFloat = float) -> ta.Dict[str, ta.Any]:
249
+ """Parse TOML from a binary file object."""
250
+ b = fp.read()
251
+ try:
252
+ s = b.decode()
253
+ except AttributeError:
254
+ raise TypeError("File must be opened in binary mode, e.g. use `open('foo.toml', 'rb')`") from None
255
+ return toml_loads(s, parse_float=parse_float)
256
+
257
+
258
+ def toml_loads(s: str, /, *, parse_float: TomlParseFloat = float) -> ta.Dict[str, ta.Any]: # noqa: C901
259
+ """Parse TOML from a string."""
260
+
261
+ # The spec allows converting "\r\n" to "\n", even in string literals. Let's do so to simplify parsing.
262
+ try:
263
+ src = s.replace('\r\n', '\n')
264
+ except (AttributeError, TypeError):
265
+ raise TypeError(f"Expected str object, not '{type(s).__qualname__}'") from None
266
+ pos = 0
267
+ out = TomlOutput(TomlNestedDict(), TomlFlags())
268
+ header: TomlKey = ()
269
+ parse_float = toml_make_safe_parse_float(parse_float)
270
+
271
+ # Parse one statement at a time (typically means one line in TOML source)
272
+ while True:
273
+ # 1. Skip line leading whitespace
274
+ pos = toml_skip_chars(src, pos, TOML_WS)
275
+
276
+ # 2. Parse rules. Expect one of the following:
277
+ # - end of file
278
+ # - end of line
279
+ # - comment
280
+ # - key/value pair
281
+ # - append dict to list (and move to its namespace)
282
+ # - create dict (and move to its namespace)
283
+ # Skip trailing whitespace when applicable.
284
+ try:
285
+ char = src[pos]
286
+ except IndexError:
287
+ break
288
+ if char == '\n':
289
+ pos += 1
290
+ continue
291
+ if char in TOML_KEY_INITIAL_CHARS:
292
+ pos = toml_key_value_rule(src, pos, out, header, parse_float)
293
+ pos = toml_skip_chars(src, pos, TOML_WS)
294
+ elif char == '[':
295
+ try:
296
+ second_char: ta.Optional[str] = src[pos + 1]
297
+ except IndexError:
298
+ second_char = None
299
+ out.flags.finalize_pending()
300
+ if second_char == '[':
301
+ pos, header = toml_create_list_rule(src, pos, out)
302
+ else:
303
+ pos, header = toml_create_dict_rule(src, pos, out)
304
+ pos = toml_skip_chars(src, pos, TOML_WS)
305
+ elif char != '#':
306
+ raise toml_suffixed_err(src, pos, 'Invalid statement')
307
+
308
+ # 3. Skip comment
309
+ pos = toml_skip_comment(src, pos)
310
+
311
+ # 4. Expect end of line or end of file
312
+ try:
313
+ char = src[pos]
314
+ except IndexError:
315
+ break
316
+ if char != '\n':
317
+ raise toml_suffixed_err(
318
+ src, pos, 'Expected newline or end of document after a statement',
319
+ )
320
+ pos += 1
321
+
322
+ return out.data.dict
323
+
324
+
325
+ class TomlFlags:
326
+ """Flags that map to parsed keys/namespaces."""
327
+
328
+ # Marks an immutable namespace (inline array or inline table).
329
+ FROZEN = 0
330
+ # Marks a nest that has been explicitly created and can no longer be opened using the "[table]" syntax.
331
+ EXPLICIT_NEST = 1
332
+
333
+ def __init__(self) -> None:
334
+ self._flags: ta.Dict[str, dict] = {}
335
+ self._pending_flags: ta.Set[ta.Tuple[TomlKey, int]] = set()
336
+
337
+ def add_pending(self, key: TomlKey, flag: int) -> None:
338
+ self._pending_flags.add((key, flag))
339
+
340
+ def finalize_pending(self) -> None:
341
+ for key, flag in self._pending_flags:
342
+ self.set(key, flag, recursive=False)
343
+ self._pending_flags.clear()
344
+
345
+ def unset_all(self, key: TomlKey) -> None:
346
+ cont = self._flags
347
+ for k in key[:-1]:
348
+ if k not in cont:
349
+ return
350
+ cont = cont[k]['nested']
351
+ cont.pop(key[-1], None)
352
+
353
+ def set(self, key: TomlKey, flag: int, *, recursive: bool) -> None: # noqa: A003
354
+ cont = self._flags
355
+ key_parent, key_stem = key[:-1], key[-1]
356
+ for k in key_parent:
357
+ if k not in cont:
358
+ cont[k] = {'flags': set(), 'recursive_flags': set(), 'nested': {}}
359
+ cont = cont[k]['nested']
360
+ if key_stem not in cont:
361
+ cont[key_stem] = {'flags': set(), 'recursive_flags': set(), 'nested': {}}
362
+ cont[key_stem]['recursive_flags' if recursive else 'flags'].add(flag)
363
+
364
+ def is_(self, key: TomlKey, flag: int) -> bool:
365
+ if not key:
366
+ return False # document root has no flags
367
+ cont = self._flags
368
+ for k in key[:-1]:
369
+ if k not in cont:
370
+ return False
371
+ inner_cont = cont[k]
372
+ if flag in inner_cont['recursive_flags']:
373
+ return True
374
+ cont = inner_cont['nested']
375
+ key_stem = key[-1]
376
+ if key_stem in cont:
377
+ cont = cont[key_stem]
378
+ return flag in cont['flags'] or flag in cont['recursive_flags']
379
+ return False
380
+
381
+
382
+ class TomlNestedDict:
383
+ def __init__(self) -> None:
384
+ # The parsed content of the TOML document
385
+ self.dict: ta.Dict[str, ta.Any] = {}
386
+
387
+ def get_or_create_nest(
388
+ self,
389
+ key: TomlKey,
390
+ *,
391
+ access_lists: bool = True,
392
+ ) -> dict:
393
+ cont: ta.Any = self.dict
394
+ for k in key:
395
+ if k not in cont:
396
+ cont[k] = {}
397
+ cont = cont[k]
398
+ if access_lists and isinstance(cont, list):
399
+ cont = cont[-1]
400
+ if not isinstance(cont, dict):
401
+ raise KeyError('There is no nest behind this key')
402
+ return cont
403
+
404
+ def append_nest_to_list(self, key: TomlKey) -> None:
405
+ cont = self.get_or_create_nest(key[:-1])
406
+ last_key = key[-1]
407
+ if last_key in cont:
408
+ list_ = cont[last_key]
409
+ if not isinstance(list_, list):
410
+ raise KeyError('An object other than list found behind this key')
411
+ list_.append({})
412
+ else:
413
+ cont[last_key] = [{}]
414
+
415
+
416
+ class TomlOutput(ta.NamedTuple):
417
+ data: TomlNestedDict
418
+ flags: TomlFlags
419
+
420
+
421
+ def toml_skip_chars(src: str, pos: TomlPos, chars: ta.Iterable[str]) -> TomlPos:
422
+ try:
423
+ while src[pos] in chars:
424
+ pos += 1
425
+ except IndexError:
426
+ pass
427
+ return pos
428
+
429
+
430
+ def toml_skip_until(
431
+ src: str,
432
+ pos: TomlPos,
433
+ expect: str,
434
+ *,
435
+ error_on: ta.FrozenSet[str],
436
+ error_on_eof: bool,
437
+ ) -> TomlPos:
438
+ try:
439
+ new_pos = src.index(expect, pos)
440
+ except ValueError:
441
+ new_pos = len(src)
442
+ if error_on_eof:
443
+ raise toml_suffixed_err(src, new_pos, f'Expected {expect!r}') from None
444
+
445
+ if not error_on.isdisjoint(src[pos:new_pos]):
446
+ while src[pos] not in error_on:
447
+ pos += 1
448
+ raise toml_suffixed_err(src, pos, f'Found invalid character {src[pos]!r}')
449
+ return new_pos
450
+
451
+
452
+ def toml_skip_comment(src: str, pos: TomlPos) -> TomlPos:
453
+ try:
454
+ char: ta.Optional[str] = src[pos]
455
+ except IndexError:
456
+ char = None
457
+ if char == '#':
458
+ return toml_skip_until(
459
+ src, pos + 1, '\n', error_on=TOML_ILLEGAL_COMMENT_CHARS, error_on_eof=False,
460
+ )
461
+ return pos
462
+
463
+
464
+ def toml_skip_comments_and_array_ws(src: str, pos: TomlPos) -> TomlPos:
465
+ while True:
466
+ pos_before_skip = pos
467
+ pos = toml_skip_chars(src, pos, TOML_WS_AND_NEWLINE)
468
+ pos = toml_skip_comment(src, pos)
469
+ if pos == pos_before_skip:
470
+ return pos
471
+
472
+
473
+ def toml_create_dict_rule(src: str, pos: TomlPos, out: TomlOutput) -> ta.Tuple[TomlPos, TomlKey]:
474
+ pos += 1 # Skip "["
475
+ pos = toml_skip_chars(src, pos, TOML_WS)
476
+ pos, key = toml_parse_key(src, pos)
477
+
478
+ if out.flags.is_(key, TomlFlags.EXPLICIT_NEST) or out.flags.is_(key, TomlFlags.FROZEN):
479
+ raise toml_suffixed_err(src, pos, f'Cannot declare {key} twice')
480
+ out.flags.set(key, TomlFlags.EXPLICIT_NEST, recursive=False)
481
+ try:
482
+ out.data.get_or_create_nest(key)
483
+ except KeyError:
484
+ raise toml_suffixed_err(src, pos, 'Cannot overwrite a value') from None
485
+
486
+ if not src.startswith(']', pos):
487
+ raise toml_suffixed_err(src, pos, "Expected ']' at the end of a table declaration")
488
+ return pos + 1, key
489
+
490
+
491
+ def toml_create_list_rule(src: str, pos: TomlPos, out: TomlOutput) -> ta.Tuple[TomlPos, TomlKey]:
492
+ pos += 2 # Skip "[["
493
+ pos = toml_skip_chars(src, pos, TOML_WS)
494
+ pos, key = toml_parse_key(src, pos)
495
+
496
+ if out.flags.is_(key, TomlFlags.FROZEN):
497
+ raise toml_suffixed_err(src, pos, f'Cannot mutate immutable namespace {key}')
498
+ # Free the namespace now that it points to another empty list item...
499
+ out.flags.unset_all(key)
500
+ # ...but this key precisely is still prohibited from table declaration
501
+ out.flags.set(key, TomlFlags.EXPLICIT_NEST, recursive=False)
502
+ try:
503
+ out.data.append_nest_to_list(key)
504
+ except KeyError:
505
+ raise toml_suffixed_err(src, pos, 'Cannot overwrite a value') from None
506
+
507
+ if not src.startswith(']]', pos):
508
+ raise toml_suffixed_err(src, pos, "Expected ']]' at the end of an array declaration")
509
+ return pos + 2, key
510
+
511
+
512
+ def toml_key_value_rule(
513
+ src: str,
514
+ pos: TomlPos,
515
+ out: TomlOutput,
516
+ header: TomlKey,
517
+ parse_float: TomlParseFloat,
518
+ ) -> TomlPos:
519
+ pos, key, value = toml_parse_key_value_pair(src, pos, parse_float)
520
+ key_parent, key_stem = key[:-1], key[-1]
521
+ abs_key_parent = header + key_parent
522
+
523
+ relative_path_cont_keys = (header + key[:i] for i in range(1, len(key)))
524
+ for cont_key in relative_path_cont_keys:
525
+ # Check that dotted key syntax does not redefine an existing table
526
+ if out.flags.is_(cont_key, TomlFlags.EXPLICIT_NEST):
527
+ raise toml_suffixed_err(src, pos, f'Cannot redefine namespace {cont_key}')
528
+ # Containers in the relative path can't be opened with the table syntax or dotted key/value syntax in following
529
+ # table sections.
530
+ out.flags.add_pending(cont_key, TomlFlags.EXPLICIT_NEST)
531
+
532
+ if out.flags.is_(abs_key_parent, TomlFlags.FROZEN):
533
+ raise toml_suffixed_err(
534
+ src,
535
+ pos,
536
+ f'Cannot mutate immutable namespace {abs_key_parent}',
537
+ )
538
+
539
+ try:
540
+ nest = out.data.get_or_create_nest(abs_key_parent)
541
+ except KeyError:
542
+ raise toml_suffixed_err(src, pos, 'Cannot overwrite a value') from None
543
+ if key_stem in nest:
544
+ raise toml_suffixed_err(src, pos, 'Cannot overwrite a value')
545
+ # Mark inline table and array namespaces recursively immutable
546
+ if isinstance(value, (dict, list)):
547
+ out.flags.set(header + key, TomlFlags.FROZEN, recursive=True)
548
+ nest[key_stem] = value
549
+ return pos
550
+
551
+
552
+ def toml_parse_key_value_pair(
553
+ src: str,
554
+ pos: TomlPos,
555
+ parse_float: TomlParseFloat,
556
+ ) -> ta.Tuple[TomlPos, TomlKey, ta.Any]:
557
+ pos, key = toml_parse_key(src, pos)
558
+ try:
559
+ char: ta.Optional[str] = src[pos]
560
+ except IndexError:
561
+ char = None
562
+ if char != '=':
563
+ raise toml_suffixed_err(src, pos, "Expected '=' after a key in a key/value pair")
564
+ pos += 1
565
+ pos = toml_skip_chars(src, pos, TOML_WS)
566
+ pos, value = toml_parse_value(src, pos, parse_float)
567
+ return pos, key, value
568
+
569
+
570
+ def toml_parse_key(src: str, pos: TomlPos) -> ta.Tuple[TomlPos, TomlKey]:
571
+ pos, key_part = toml_parse_key_part(src, pos)
572
+ key: TomlKey = (key_part,)
573
+ pos = toml_skip_chars(src, pos, TOML_WS)
574
+ while True:
575
+ try:
576
+ char: ta.Optional[str] = src[pos]
577
+ except IndexError:
578
+ char = None
579
+ if char != '.':
580
+ return pos, key
581
+ pos += 1
582
+ pos = toml_skip_chars(src, pos, TOML_WS)
583
+ pos, key_part = toml_parse_key_part(src, pos)
584
+ key += (key_part,)
585
+ pos = toml_skip_chars(src, pos, TOML_WS)
586
+
587
+
588
+ def toml_parse_key_part(src: str, pos: TomlPos) -> ta.Tuple[TomlPos, str]:
589
+ try:
590
+ char: ta.Optional[str] = src[pos]
591
+ except IndexError:
592
+ char = None
593
+ if char in TOML_BARE_KEY_CHARS:
594
+ start_pos = pos
595
+ pos = toml_skip_chars(src, pos, TOML_BARE_KEY_CHARS)
596
+ return pos, src[start_pos:pos]
597
+ if char == "'":
598
+ return toml_parse_literal_str(src, pos)
599
+ if char == '"':
600
+ return toml_parse_one_line_basic_str(src, pos)
601
+ raise toml_suffixed_err(src, pos, 'Invalid initial character for a key part')
602
+
603
+
604
+ def toml_parse_one_line_basic_str(src: str, pos: TomlPos) -> ta.Tuple[TomlPos, str]:
605
+ pos += 1
606
+ return toml_parse_basic_str(src, pos, multiline=False)
607
+
608
+
609
+ def toml_parse_array(src: str, pos: TomlPos, parse_float: TomlParseFloat) -> ta.Tuple[TomlPos, list]:
610
+ pos += 1
611
+ array: list = []
612
+
613
+ pos = toml_skip_comments_and_array_ws(src, pos)
614
+ if src.startswith(']', pos):
615
+ return pos + 1, array
616
+ while True:
617
+ pos, val = toml_parse_value(src, pos, parse_float)
618
+ array.append(val)
619
+ pos = toml_skip_comments_and_array_ws(src, pos)
620
+
621
+ c = src[pos:pos + 1]
622
+ if c == ']':
623
+ return pos + 1, array
624
+ if c != ',':
625
+ raise toml_suffixed_err(src, pos, 'Unclosed array')
626
+ pos += 1
627
+
628
+ pos = toml_skip_comments_and_array_ws(src, pos)
629
+ if src.startswith(']', pos):
630
+ return pos + 1, array
631
+
632
+
633
+ def toml_parse_inline_table(src: str, pos: TomlPos, parse_float: TomlParseFloat) -> ta.Tuple[TomlPos, dict]:
634
+ pos += 1
635
+ nested_dict = TomlNestedDict()
636
+ flags = TomlFlags()
637
+
638
+ pos = toml_skip_chars(src, pos, TOML_WS)
639
+ if src.startswith('}', pos):
640
+ return pos + 1, nested_dict.dict
641
+ while True:
642
+ pos, key, value = toml_parse_key_value_pair(src, pos, parse_float)
643
+ key_parent, key_stem = key[:-1], key[-1]
644
+ if flags.is_(key, TomlFlags.FROZEN):
645
+ raise toml_suffixed_err(src, pos, f'Cannot mutate immutable namespace {key}')
646
+ try:
647
+ nest = nested_dict.get_or_create_nest(key_parent, access_lists=False)
648
+ except KeyError:
649
+ raise toml_suffixed_err(src, pos, 'Cannot overwrite a value') from None
650
+ if key_stem in nest:
651
+ raise toml_suffixed_err(src, pos, f'Duplicate inline table key {key_stem!r}')
652
+ nest[key_stem] = value
653
+ pos = toml_skip_chars(src, pos, TOML_WS)
654
+ c = src[pos:pos + 1]
655
+ if c == '}':
656
+ return pos + 1, nested_dict.dict
657
+ if c != ',':
658
+ raise toml_suffixed_err(src, pos, 'Unclosed inline table')
659
+ if isinstance(value, (dict, list)):
660
+ flags.set(key, TomlFlags.FROZEN, recursive=True)
661
+ pos += 1
662
+ pos = toml_skip_chars(src, pos, TOML_WS)
663
+
664
+
665
+ def toml_parse_basic_str_escape(
666
+ src: str,
667
+ pos: TomlPos,
668
+ *,
669
+ multiline: bool = False,
670
+ ) -> ta.Tuple[TomlPos, str]:
671
+ escape_id = src[pos:pos + 2]
672
+ pos += 2
673
+ if multiline and escape_id in {'\\ ', '\\\t', '\\\n'}:
674
+ # Skip whitespace until next non-whitespace character or end of the doc. Error if non-whitespace is found before
675
+ # newline.
676
+ if escape_id != '\\\n':
677
+ pos = toml_skip_chars(src, pos, TOML_WS)
678
+ try:
679
+ char = src[pos]
680
+ except IndexError:
681
+ return pos, ''
682
+ if char != '\n':
683
+ raise toml_suffixed_err(src, pos, "Unescaped '\\' in a string")
684
+ pos += 1
685
+ pos = toml_skip_chars(src, pos, TOML_WS_AND_NEWLINE)
686
+ return pos, ''
687
+ if escape_id == '\\u':
688
+ return toml_parse_hex_char(src, pos, 4)
689
+ if escape_id == '\\U':
690
+ return toml_parse_hex_char(src, pos, 8)
691
+ try:
692
+ return pos, TOML_BASIC_STR_ESCAPE_REPLACEMENTS[escape_id]
693
+ except KeyError:
694
+ raise toml_suffixed_err(src, pos, "Unescaped '\\' in a string") from None
695
+
696
+
697
+ def toml_parse_basic_str_escape_multiline(src: str, pos: TomlPos) -> ta.Tuple[TomlPos, str]:
698
+ return toml_parse_basic_str_escape(src, pos, multiline=True)
699
+
700
+
701
+ def toml_parse_hex_char(src: str, pos: TomlPos, hex_len: int) -> ta.Tuple[TomlPos, str]:
702
+ hex_str = src[pos:pos + hex_len]
703
+ if len(hex_str) != hex_len or not TOML_HEXDIGIT_CHARS.issuperset(hex_str):
704
+ raise toml_suffixed_err(src, pos, 'Invalid hex value')
705
+ pos += hex_len
706
+ hex_int = int(hex_str, 16)
707
+ if not toml_is_unicode_scalar_value(hex_int):
708
+ raise toml_suffixed_err(src, pos, 'Escaped character is not a Unicode scalar value')
709
+ return pos, chr(hex_int)
710
+
711
+
712
+ def toml_parse_literal_str(src: str, pos: TomlPos) -> ta.Tuple[TomlPos, str]:
713
+ pos += 1 # Skip starting apostrophe
714
+ start_pos = pos
715
+ pos = toml_skip_until(
716
+ src, pos, "'", error_on=TOML_ILLEGAL_LITERAL_STR_CHARS, error_on_eof=True,
717
+ )
718
+ return pos + 1, src[start_pos:pos] # Skip ending apostrophe
719
+
720
+
721
+ def toml_parse_multiline_str(src: str, pos: TomlPos, *, literal: bool) -> ta.Tuple[TomlPos, str]:
722
+ pos += 3
723
+ if src.startswith('\n', pos):
724
+ pos += 1
725
+
726
+ if literal:
727
+ delim = "'"
728
+ end_pos = toml_skip_until(
729
+ src,
730
+ pos,
731
+ "'''",
732
+ error_on=TOML_ILLEGAL_MULTILINE_LITERAL_STR_CHARS,
733
+ error_on_eof=True,
734
+ )
735
+ result = src[pos:end_pos]
736
+ pos = end_pos + 3
737
+ else:
738
+ delim = '"'
739
+ pos, result = toml_parse_basic_str(src, pos, multiline=True)
740
+
741
+ # Add at maximum two extra apostrophes/quotes if the end sequence is 4 or 5 chars long instead of just 3.
742
+ if not src.startswith(delim, pos):
743
+ return pos, result
744
+ pos += 1
745
+ if not src.startswith(delim, pos):
746
+ return pos, result + delim
747
+ pos += 1
748
+ return pos, result + (delim * 2)
749
+
750
+
751
+ def toml_parse_basic_str(src: str, pos: TomlPos, *, multiline: bool) -> ta.Tuple[TomlPos, str]:
752
+ if multiline:
753
+ error_on = TOML_ILLEGAL_MULTILINE_BASIC_STR_CHARS
754
+ parse_escapes = toml_parse_basic_str_escape_multiline
755
+ else:
756
+ error_on = TOML_ILLEGAL_BASIC_STR_CHARS
757
+ parse_escapes = toml_parse_basic_str_escape
758
+ result = ''
759
+ start_pos = pos
760
+ while True:
761
+ try:
762
+ char = src[pos]
763
+ except IndexError:
764
+ raise toml_suffixed_err(src, pos, 'Unterminated string') from None
765
+ if char == '"':
766
+ if not multiline:
767
+ return pos + 1, result + src[start_pos:pos]
768
+ if src.startswith('"""', pos):
769
+ return pos + 3, result + src[start_pos:pos]
770
+ pos += 1
771
+ continue
772
+ if char == '\\':
773
+ result += src[start_pos:pos]
774
+ pos, parsed_escape = parse_escapes(src, pos)
775
+ result += parsed_escape
776
+ start_pos = pos
777
+ continue
778
+ if char in error_on:
779
+ raise toml_suffixed_err(src, pos, f'Illegal character {char!r}')
780
+ pos += 1
781
+
782
+
783
+ def toml_parse_value( # noqa: C901
784
+ src: str,
785
+ pos: TomlPos,
786
+ parse_float: TomlParseFloat,
787
+ ) -> ta.Tuple[TomlPos, ta.Any]:
788
+ try:
789
+ char: ta.Optional[str] = src[pos]
790
+ except IndexError:
791
+ char = None
792
+
793
+ # IMPORTANT: order conditions based on speed of checking and likelihood
794
+
795
+ # Basic strings
796
+ if char == '"':
797
+ if src.startswith('"""', pos):
798
+ return toml_parse_multiline_str(src, pos, literal=False)
799
+ return toml_parse_one_line_basic_str(src, pos)
800
+
801
+ # Literal strings
802
+ if char == "'":
803
+ if src.startswith("'''", pos):
804
+ return toml_parse_multiline_str(src, pos, literal=True)
805
+ return toml_parse_literal_str(src, pos)
806
+
807
+ # Booleans
808
+ if char == 't':
809
+ if src.startswith('true', pos):
810
+ return pos + 4, True
811
+ if char == 'f':
812
+ if src.startswith('false', pos):
813
+ return pos + 5, False
814
+
815
+ # Arrays
816
+ if char == '[':
817
+ return toml_parse_array(src, pos, parse_float)
818
+
819
+ # Inline tables
820
+ if char == '{':
821
+ return toml_parse_inline_table(src, pos, parse_float)
822
+
823
+ # Dates and times
824
+ datetime_match = TOML_RE_DATETIME.match(src, pos)
825
+ if datetime_match:
826
+ try:
827
+ datetime_obj = toml_match_to_datetime(datetime_match)
828
+ except ValueError as e:
829
+ raise toml_suffixed_err(src, pos, 'Invalid date or datetime') from e
830
+ return datetime_match.end(), datetime_obj
831
+ localtime_match = TOML_RE_LOCALTIME.match(src, pos)
832
+ if localtime_match:
833
+ return localtime_match.end(), toml_match_to_localtime(localtime_match)
834
+
835
+ # Integers and "normal" floats. The regex will greedily match any type starting with a decimal char, so needs to be
836
+ # located after handling of dates and times.
837
+ number_match = TOML_RE_NUMBER.match(src, pos)
838
+ if number_match:
839
+ return number_match.end(), toml_match_to_number(number_match, parse_float)
840
+
841
+ # Special floats
842
+ first_three = src[pos:pos + 3]
843
+ if first_three in {'inf', 'nan'}:
844
+ return pos + 3, parse_float(first_three)
845
+ first_four = src[pos:pos + 4]
846
+ if first_four in {'-inf', '+inf', '-nan', '+nan'}:
847
+ return pos + 4, parse_float(first_four)
848
+
849
+ raise toml_suffixed_err(src, pos, 'Invalid value')
850
+
851
+
852
+ def toml_suffixed_err(src: str, pos: TomlPos, msg: str) -> TomlDecodeError:
853
+ """Return a `TomlDecodeError` where error message is suffixed with coordinates in source."""
854
+
855
+ def coord_repr(src: str, pos: TomlPos) -> str:
856
+ if pos >= len(src):
857
+ return 'end of document'
858
+ line = src.count('\n', 0, pos) + 1
859
+ if line == 1:
860
+ column = pos + 1
861
+ else:
862
+ column = pos - src.rindex('\n', 0, pos)
863
+ return f'line {line}, column {column}'
864
+
865
+ return TomlDecodeError(f'{msg} (at {coord_repr(src, pos)})')
866
+
867
+
868
+ def toml_is_unicode_scalar_value(codepoint: int) -> bool:
869
+ return (0 <= codepoint <= 55295) or (57344 <= codepoint <= 1114111)
870
+
871
+
872
+ def toml_make_safe_parse_float(parse_float: TomlParseFloat) -> TomlParseFloat:
873
+ """A decorator to make `parse_float` safe.
874
+
875
+ `parse_float` must not return dicts or lists, because these types would be mixed with parsed TOML tables and arrays,
876
+ thus confusing the parser. The returned decorated callable raises `ValueError` instead of returning illegal types.
877
+ """
878
+ # The default `float` callable never returns illegal types. Optimize it.
879
+ if parse_float is float:
880
+ return float
881
+
882
+ def safe_parse_float(float_str: str) -> ta.Any:
883
+ float_value = parse_float(float_str)
884
+ if isinstance(float_value, (dict, list)):
885
+ raise ValueError('parse_float must not return dicts or lists') # noqa
886
+ return float_value
887
+
888
+ return safe_parse_float
889
+
890
+
62
891
  ########################################
63
892
  # ../../../../../omlish/lite/cached.py
64
893
 
@@ -80,7 +909,7 @@ class _cached_nullary: # noqa
80
909
  return bound
81
910
 
82
911
 
83
- def cached_nullary(fn: ta.Callable[..., T]) -> ta.Callable[..., T]:
912
+ def cached_nullary(fn): # ta.Callable[..., T]) -> ta.Callable[..., T]:
84
913
  return _cached_nullary(fn)
85
914
 
86
915
 
@@ -273,6 +1102,14 @@ def get_optional_alias_arg(spec: ta.Any) -> ta.Any:
273
1102
  return it
274
1103
 
275
1104
 
1105
+ def is_new_type(spec: ta.Any) -> bool:
1106
+ if isinstance(ta.NewType, type):
1107
+ return isinstance(spec, ta.NewType)
1108
+ else:
1109
+ # Before https://github.com/python/cpython/commit/c2f33dfc83ab270412bf243fb21f724037effa1a
1110
+ return isinstance(spec, types.FunctionType) and spec.__code__ is ta.NewType.__code__.co_consts[1] # type: ignore # noqa
1111
+
1112
+
276
1113
  def deep_subclasses(cls: ta.Type[T]) -> ta.Iterator[ta.Type[T]]:
277
1114
  seen = set()
278
1115
  todo = list(reversed(cls.__subclasses__()))
@@ -733,8 +1570,12 @@ class ExitStacked:
733
1570
  def __exit__(self, exc_type, exc_val, exc_tb):
734
1571
  if (es := self._exit_stack) is None:
735
1572
  return None
1573
+ self._exit_contexts()
736
1574
  return es.__exit__(exc_type, exc_val, exc_tb)
737
1575
 
1576
+ def _exit_contexts(self) -> None:
1577
+ pass
1578
+
738
1579
  def _enter_context(self, cm: ta.ContextManager[T]) -> T:
739
1580
  es = check_not_none(self._exit_stack)
740
1581
  return es.enter_context(cm)
@@ -1734,6 +2575,66 @@ class AwsLogMessageBuilder:
1734
2575
  return [post]
1735
2576
 
1736
2577
 
2578
+ ########################################
2579
+ # ../../../../configs.py
2580
+
2581
+
2582
+ def read_config_file(
2583
+ path: str,
2584
+ cls: ta.Type[T],
2585
+ *,
2586
+ prepare: ta.Optional[ta.Callable[[ConfigMapping], ConfigMapping]] = None,
2587
+ ) -> T:
2588
+ with open(path) as cf:
2589
+ if path.endswith('.toml'):
2590
+ config_dct = toml_loads(cf.read())
2591
+ else:
2592
+ config_dct = json.loads(cf.read())
2593
+
2594
+ if prepare is not None:
2595
+ config_dct = prepare(config_dct) # type: ignore
2596
+
2597
+ return unmarshal_obj(config_dct, cls)
2598
+
2599
+
2600
+ def build_config_named_children(
2601
+ o: ta.Union[
2602
+ ta.Sequence[ConfigMapping],
2603
+ ta.Mapping[str, ConfigMapping],
2604
+ None,
2605
+ ],
2606
+ *,
2607
+ name_key: str = 'name',
2608
+ ) -> ta.Optional[ta.Sequence[ConfigMapping]]:
2609
+ if o is None:
2610
+ return None
2611
+
2612
+ lst: ta.List[ConfigMapping] = []
2613
+ if isinstance(o, ta.Mapping):
2614
+ for k, v in o.items():
2615
+ check_isinstance(v, ta.Mapping)
2616
+ if name_key in v:
2617
+ n = v[name_key]
2618
+ if k != n:
2619
+ raise KeyError(f'Given names do not match: {n} != {k}')
2620
+ lst.append(v)
2621
+ else:
2622
+ lst.append({name_key: k, **v})
2623
+
2624
+ else:
2625
+ check_not_isinstance(o, str)
2626
+ lst.extend(o)
2627
+
2628
+ seen = set()
2629
+ for d in lst:
2630
+ n = d['name']
2631
+ if n in d:
2632
+ raise KeyError(f'Duplicate name: {n}')
2633
+ seen.add(n)
2634
+
2635
+ return lst
2636
+
2637
+
1737
2638
  ########################################
1738
2639
  # ../../../../journald/messages.py
1739
2640
 
@@ -1810,9 +2711,11 @@ class JournalctlMessageBuilder:
1810
2711
  ########################################
1811
2712
  # ../../../../threadworkers.py
1812
2713
  """
2714
+ FIXME:
2715
+ - group is racy af - meditate on has_started, etc
2716
+
1813
2717
  TODO:
1814
- - implement stop lol
1815
- - collective heartbeat monitoring - ThreadWorkerGroups
2718
+ - overhaul stop lol
1816
2719
  - group -> 'context'? :|
1817
2720
  - shared stop_event?
1818
2721
  """
@@ -1826,6 +2729,7 @@ class ThreadWorker(ExitStacked, abc.ABC):
1826
2729
  self,
1827
2730
  *,
1828
2731
  stop_event: ta.Optional[threading.Event] = None,
2732
+ worker_groups: ta.Optional[ta.Iterable['ThreadWorkerGroup']] = None,
1829
2733
  ) -> None:
1830
2734
  super().__init__()
1831
2735
 
@@ -1837,6 +2741,9 @@ class ThreadWorker(ExitStacked, abc.ABC):
1837
2741
  self._thread: ta.Optional[threading.Thread] = None
1838
2742
  self._last_heartbeat: ta.Optional[float] = None
1839
2743
 
2744
+ for g in worker_groups or []:
2745
+ g.add(self)
2746
+
1840
2747
  #
1841
2748
 
1842
2749
  def __enter__(self: ThreadWorkerT) -> ThreadWorkerT:
@@ -1881,13 +2788,13 @@ class ThreadWorker(ExitStacked, abc.ABC):
1881
2788
  if self._thread is not None:
1882
2789
  raise RuntimeError('Thread already started: %r', self)
1883
2790
 
1884
- thr = threading.Thread(target=self.__run)
2791
+ thr = threading.Thread(target=self.__thread_main)
1885
2792
  self._thread = thr
1886
2793
  thr.start()
1887
2794
 
1888
2795
  #
1889
2796
 
1890
- def __run(self) -> None:
2797
+ def __thread_main(self) -> None:
1891
2798
  try:
1892
2799
  self._run()
1893
2800
  except ThreadWorker.Stopping:
@@ -1905,10 +2812,17 @@ class ThreadWorker(ExitStacked, abc.ABC):
1905
2812
  def stop(self) -> None:
1906
2813
  self._stop_event.set()
1907
2814
 
1908
- def join(self, timeout: ta.Optional[float] = None) -> None:
2815
+ def join(
2816
+ self,
2817
+ timeout: ta.Optional[float] = None,
2818
+ *,
2819
+ unless_not_started: bool = False,
2820
+ ) -> None:
1909
2821
  with self._lock:
1910
2822
  if self._thread is None:
1911
- raise RuntimeError('Thread not started: %r', self)
2823
+ if not unless_not_started:
2824
+ raise RuntimeError('Thread not started: %r', self)
2825
+ return
1912
2826
  self._thread.join(timeout)
1913
2827
 
1914
2828
 
@@ -1917,24 +2831,68 @@ class ThreadWorker(ExitStacked, abc.ABC):
1917
2831
 
1918
2832
  class ThreadWorkerGroup:
1919
2833
  @dc.dataclass()
1920
- class State:
2834
+ class _State:
1921
2835
  worker: ThreadWorker
1922
2836
 
2837
+ last_heartbeat: ta.Optional[float] = None
2838
+
1923
2839
  def __init__(self) -> None:
1924
2840
  super().__init__()
1925
2841
 
1926
2842
  self._lock = threading.RLock()
1927
- self._states: ta.Dict[ThreadWorker, ThreadWorkerGroup.State] = {}
2843
+ self._states: ta.Dict[ThreadWorker, ThreadWorkerGroup._State] = {}
2844
+ self._last_heartbeat_check: ta.Optional[float] = None
2845
+
2846
+ #
1928
2847
 
1929
2848
  def add(self, *workers: ThreadWorker) -> 'ThreadWorkerGroup':
1930
2849
  with self._lock:
1931
2850
  for w in workers:
1932
2851
  if w in self._states:
1933
2852
  raise KeyError(w)
1934
- self._states[w] = ThreadWorkerGroup.State(w)
2853
+ self._states[w] = ThreadWorkerGroup._State(w)
1935
2854
 
1936
2855
  return self
1937
2856
 
2857
+ #
2858
+
2859
+ def start_all(self) -> None:
2860
+ thrs = list(self._states)
2861
+ with self._lock:
2862
+ for thr in thrs:
2863
+ if not thr.has_started():
2864
+ thr.start()
2865
+
2866
+ def stop_all(self) -> None:
2867
+ for w in reversed(list(self._states)):
2868
+ if w.has_started():
2869
+ w.stop()
2870
+
2871
+ def join_all(self, timeout: ta.Optional[float] = None) -> None:
2872
+ for w in reversed(list(self._states)):
2873
+ if w.has_started():
2874
+ w.join(timeout, unless_not_started=True)
2875
+
2876
+ #
2877
+
2878
+ def get_dead(self) -> ta.List[ThreadWorker]:
2879
+ with self._lock:
2880
+ return [thr for thr in self._states if not thr.is_alive()]
2881
+
2882
+ def check_heartbeats(self) -> ta.Dict[ThreadWorker, float]:
2883
+ with self._lock:
2884
+ dct: ta.Dict[ThreadWorker, float] = {}
2885
+ for thr, st in self._states.items():
2886
+ if not thr.has_started():
2887
+ continue
2888
+ hb = thr.last_heartbeat
2889
+ if hb is None:
2890
+ hb = time.time()
2891
+ st.last_heartbeat = hb
2892
+ dct[st.worker] = time.time() - hb
2893
+ self._last_heartbeat_check = time.time()
2894
+ return dct
2895
+
1938
2896
 
1939
2897
  ########################################
1940
2898
  # ../../../../../omlish/lite/subprocesses.py
@@ -2694,6 +3652,7 @@ class JournalctlToAwsDriver(ExitStacked):
2694
3652
  cursor_file: ta.Optional[str] = None
2695
3653
 
2696
3654
  runtime_limit: ta.Optional[float] = None
3655
+ heartbeat_age_limit: ta.Optional[float] = 60.
2697
3656
 
2698
3657
  #
2699
3658
 
@@ -2770,6 +3729,12 @@ class JournalctlToAwsDriver(ExitStacked):
2770
3729
 
2771
3730
  #
2772
3731
 
3732
+ @cached_nullary
3733
+ def _worker_group(self) -> ThreadWorkerGroup:
3734
+ return ThreadWorkerGroup()
3735
+
3736
+ #
3737
+
2773
3738
  @cached_nullary
2774
3739
  def _journalctl_message_queue(self): # type: () -> queue.Queue[ta.Sequence[JournalctlMessage]]
2775
3740
  return queue.Queue()
@@ -2796,6 +3761,8 @@ class JournalctlToAwsDriver(ExitStacked):
2796
3761
 
2797
3762
  cmd=self._config.journalctl_cmd,
2798
3763
  shell_wrap=is_debugger_attached(),
3764
+
3765
+ worker_groups=[self._worker_group()],
2799
3766
  )
2800
3767
 
2801
3768
  #
@@ -2809,26 +3776,38 @@ class JournalctlToAwsDriver(ExitStacked):
2809
3776
 
2810
3777
  ensure_locked=self._ensure_locked,
2811
3778
  dry_run=self._config.aws_dry_run,
3779
+
3780
+ worker_groups=[self._worker_group()],
2812
3781
  )
2813
3782
 
2814
3783
  #
2815
3784
 
2816
- def run(self) -> None:
2817
- pw: JournalctlToAwsPosterWorker = self._aws_poster_worker()
2818
- tw: JournalctlTailerWorker = self._journalctl_tailer_worker()
3785
+ def _exit_contexts(self) -> None:
3786
+ wg = self._worker_group()
3787
+ wg.stop_all()
3788
+ wg.join_all()
2819
3789
 
2820
- ws = [pw, tw]
3790
+ def run(self) -> None:
3791
+ self._aws_poster_worker()
3792
+ self._journalctl_tailer_worker()
2821
3793
 
2822
- for w in ws:
2823
- w.start()
3794
+ wg = self._worker_group()
3795
+ wg.start_all()
2824
3796
 
2825
3797
  start = time.time()
2826
3798
 
2827
3799
  while True:
2828
- for w in ws:
2829
- if not w.is_alive():
2830
- log.critical('Worker died: %r', w)
2831
- break
3800
+ for w in wg.get_dead():
3801
+ log.critical('Worker died: %r', w)
3802
+ break
3803
+
3804
+ if (al := self._config.heartbeat_age_limit) is not None:
3805
+ hbs = wg.check_heartbeats()
3806
+ log.debug('Worker heartbeats: %r', hbs)
3807
+ for w, age in hbs.items():
3808
+ if age > al:
3809
+ log.critical('Worker heartbeat age limit exceeded: %r %f > %f', w, age, al)
3810
+ break
2832
3811
 
2833
3812
  if (rl := self._config.runtime_limit) is not None and time.time() - start >= rl:
2834
3813
  log.warning('Runtime limit reached')
@@ -2836,9 +3815,8 @@ class JournalctlToAwsDriver(ExitStacked):
2836
3815
 
2837
3816
  time.sleep(1.)
2838
3817
 
2839
- for w in reversed(ws):
2840
- w.stop()
2841
- w.join()
3818
+ wg.stop_all()
3819
+ wg.join_all()
2842
3820
 
2843
3821
 
2844
3822
  ########################################
@@ -2870,9 +3848,7 @@ def _main() -> None:
2870
3848
 
2871
3849
  config: JournalctlToAwsDriver.Config
2872
3850
  if args.config_file:
2873
- with open(os.path.expanduser(args.config_file)) as cf:
2874
- config_dct = json.load(cf)
2875
- config = unmarshal_obj(config_dct, JournalctlToAwsDriver.Config)
3851
+ config = read_config_file(os.path.expanduser(args.config_file), JournalctlToAwsDriver.Config)
2876
3852
  else:
2877
3853
  config = JournalctlToAwsDriver.Config()
2878
3854