1#!/usr/bin/env python3
2"""
3
4"""
5# ruff: noqa:
6# mypy: disable-error-code="attr-defined"
7# Imports:
8from __future__ import annotations
9
10# ##-- stdlib imports
11import atexit# for @atexit.register
12import collections
13import contextlib
14import datetime
15import enum
16import faulthandler
17import functools as ftz
18import hashlib
19import itertools as itz
20import logging as logmod
21import pathlib as pl
22import re
23import sys
24import time
25import types
26from copy import deepcopy
27from uuid import UUID, uuid1
28from weakref import ref
29
30# ##-- end stdlib imports
31
32# ##-- 3rd party imports
33from jgdv import Mixin, Proto
34from jgdv.logging._interface import PRINTER_NAME
35
36# ##-- end 3rd party imports
37
38# ##-- 1st party imports
39import doot
40
41# ##-- end 1st party imports
42
43# ##-| Local
44from . import _interface as API # noqa: N812
45from .formatter import ReportFormatter
46
47# # End of Imports.
48
49# ##-- types
50# isort: off
51import abc
52import collections.abc
53from typing import TYPE_CHECKING, cast, assert_type, assert_never
54from typing import Generic, NewType, Never
55# Protocols:
56from typing import Protocol, runtime_checkable
57# Typing Decorators:
58from typing import no_type_check, final, override, overload
59
60if TYPE_CHECKING:
61 from jgdv import Maybe, DateTime
62 from typing import Final
63 from typing import ClassVar, Any, LiteralString
64 from typing import Self, Literal
65 from typing import TypeGuard
66 from collections.abc import Iterable, Iterator, Callable, Generator
67 from collections.abc import Sequence, Mapping, MutableMapping, Hashable
68
69 from logmod import Logger
70 from ._interface import Reporter_p
71 from doot.workflow._interface import TaskName_p
72 type TreeElem = None | str | list[TreeElem] | dict[str, TreeElem] | tuple[str, TreeElem]
73##--|
74
75# isort: on
76# ##-- end types
77
78##-- logging
79logging = logmod.getLogger(__name__)
80logging.setLevel(logmod.WARN)
81##-- end logging
82
83# Vars:
84LINE_LEN : Final[int] = 46
85LINE_CHAR : Final[str] = "-"
86INIT_LEVEL : Final[int] = logmod.WARN
87START_COUNT : Final[int] = 0
88DEFAULT_HEADER : Final[str] = "Doot"
89# Body:
90
[docs]
91class BaseGroup:
92
93 _log : Logger
94 _fmt : API.ReportFormatter_p
95
96 _lvl : int
97
98 def __init__(self, *, log:Logger, fmt:ReportFormatter, lvl:int=logmod.DEBUG) -> None:
99 self._log = log
100 self._fmt = fmt
101 self._lvl = lvl
102 self._entry_count = 0
103 self._stack = [
104 API.ReportStackEntry_d(state="initial",
105 data={},
106 log_extra={"colour":"blue"},
107 log_level=INIT_LEVEL,
108 ),
109 ]
110
111 def __enter__(self) -> Self:
112 self._entry_count += 1
113 self.push_state("ctx_manager")
114 return self
115
116 def __exit__(self, *exc:Any) -> bool:
117 match self._entry_count:
118 case int() as x if x < 1:
119 raise ValueError("Reporter enter/exit pairs count has gone negative")
120 case int() as x if x != len(self._stack) - 1:
121 raise ValueError("Mismatch between reporter stack and enter/exit pairs", x, len(self._stack))
122 case _:
123 self._entry_count -= 1
124 self.pop_state()
125
126 match exc:
127 case (None, None, None):
128 return True
129 case _:
130 return False
131
[docs]
132 @property
133 def state(self) -> API.ReportStackEntry_d:
134 return self._stack[-1]
135
[docs]
136 def _out(self, key:str, *, info:Maybe=None, msg:Maybe=None, level:int=0) -> None:
137 """ reporter groups delegate formatting and logging/printing to this method """
138 result = self._fmt(key, info=info, msg=msg, ctx=self.state.prefix)
139 self._log.log(self._lvl+level, result)
140
[docs]
141 def push_state(self, state:str, **kwargs:Any) -> Self:
142 new_top : API.ReportStackEntry_d
143 new_top = deepcopy(self._stack[-1])
144 new_top.data = dict(kwargs)
145 new_top.depth += 1
146 new_top.state = state
147 match self._fmt.get_segment("inactive"):
148 case None:
149 pass
150 case str() as val:
151 new_top.prefix.append(val)
152 self._stack.append(new_top)
153 logging.info("Report State Set To: %s", state)
154 return self
155
[docs]
156 def pop_state(self) -> Self:
157 match self._stack:
158 case [x]:
159 pass
160 case _:
161 self._stack.pop()
162
163 return self
164
[docs]
165 def gap(self) -> Self:
166 self._log.info("")
167 return self
168
[docs]
169 def line(self, msg:Maybe[str]=None, char:Maybe[str]=None) -> Self:
170 char = char or LINE_CHAR
171 match msg:
172 case str() as x:
173 val = x.strip()
174 val = val.center(len(val) + 4, " ")
175 val = val.center(LINE_LEN, char)
176 self._log.info(val)
177 case _:
178 self._log.info(char*LINE_LEN)
179
180 return self
181
[docs]
182class TreeGroup(BaseGroup, API.TreeGroup_p):
183 """ Methods to report a tree of data.
184
185 eg: a tree of jobs/tasks and their dependencies
186
187 data format is a nesting of list,
188 where each sublist is a branch
189 """
190 _labels : dict
191
192 def __init__(self, **kwargs:Any) -> None:
193 super().__init__(**kwargs)
194 self._labels = {
195 "root" : "Root",
196 "branch" : "Branch",
197 "leaf" : "Leaf",
198 "end" : "End",
199 }
200
[docs]
201 def _label(self, key:str) -> str:
202 return self._labels.get(key, key)
203
[docs]
204 def tree(self, data:dict|list, *, title:Maybe[str]=None) -> Self:
205 queued : list[TreeElem] = [data]
206 self.root(title=title)
207 while bool(queued):
208 curr = queued.pop()
209 match curr:
210 case None:
211 self.unbranch()
212 case str() as x, list() as y:
213 self.branch(x)
214 queued.append(None)
215 queued += reversed(y)
216 case str() as x, dict() as y:
217 self.branch(x)
218 queued.append(None)
219 queued += reversed(y.items())
220 case str() as x:
221 self.leaf(x)
222 case list() | dict() if not bool(curr):
223 pass
224 case dict() as x:
225 queued += reversed(x.items())
226 case [*xs]:
227 queued += reversed(xs)
228 else:
229 self.finished()
230 return self
231
[docs]
232 def root(self, title:Maybe[str]=None) -> Self:
233 self._out("root", info=self._label("root"), msg=title)
234 return self
235
[docs]
236 def branch(self, name:str|TaskName_p, info:Maybe[str]=None) -> Self:
237 self._out("branch")
238 self._out("begin", info=self._label(info or "branch"), msg=name)
239 self.push_state("branch")
240 return self
241
[docs]
242 def leaf(self, msg:str, level:int=0) -> Self:
243 self._out("act", info=self._label("leaf"), msg=msg)
244 return self
245
[docs]
246 def unbranch(self) -> Self:
247 self._out("finished")
248 self.pop_state()
249 return self
250
[docs]
251 def finished(self) -> Self:
252 self._out("finished", info=self._label("end"), msg="")
253 return self
254
[docs]
255class WorkflowGroup(BaseGroup, API.WorkflowGroup_p):
256 """ Methods for reporting the progress of a workflow
257
258 eg: marking start/end of workflow, entry/exit of tasks,
259 action content...
260 """
261
[docs]
262 @override
263 def root(self) -> Self:
264 self._out("root", level=6)
265 return self
266
[docs]
267 @override
268 def wait(self) -> Self:
269 self._out("wait")
270 return self
271
[docs]
272 @override
273 def act(self, info:str, msg:str, level:int=0) -> Self:
274 self._out("act", info=info, msg=msg, level=level)
275 return self
276
[docs]
277 @override
278 def branch(self, name:str|TaskName_p, info:Maybe[str]=None) -> Self:
279 self._out("branch", level=5)
280 self._out("begin", info=info or "Start", msg=name, level=5)
281 self.push_state("branch")
282 return self
283
[docs]
284 @override
285 def resume(self, name:str|TaskName_p) -> Self:
286 self._out("resume", msg=name, level=5)
287 self.push_state("resume")
288 return self
289
[docs]
290 @override
291 def pause (self, reason:str) -> Self:
292 self.pop_state()
293 self._out("pause", msg=reason, level=5)
294 return self
295
[docs]
296 @override
297 def result(self, state:list[str], info:Maybe[str]=None) -> Self:
298 assert(isinstance(state, list))
299 self.pop_state()
300 self._out("result" , msg=",".join(str(x) for x in state), info=info, level=5)
301 return self
302
[docs]
303 @override
304 def fail(self, *, info:Maybe[str]=None, msg:Maybe[str]=None) -> Self:
305 self.pop_state()
306 self._out("fail", info=info, msg=msg, level=40)
307 return self
308
[docs]
309 @override
310 def finished(self) -> Self:
311 self._out("finished", level=5)
312 return self
313
[docs]
314 @override
315 def queue(self, num:int) -> Self:
316 raise NotImplementedError()
317
[docs]
318 @override
319 def state_result(self, *vals:str) -> Self:
320 raise NotImplementedError()
321
[docs]
322class GenGroup(BaseGroup, API.GeneralGroup_p):
323 """ General user level messaging """
324
331
[docs]
332 @override
333 def user(self, msg:str, *rest:Any, **kwargs:Any) -> Self:
334 self._log.warning(msg, *rest, **kwargs)
335 return self
336
[docs]
337 @override
338 def trace(self, msg:str, *rest:Any, **kwargs:Any) -> Self:
339 self._log.info(msg, *rest, **kwargs)
340 return self
341
[docs]
342 @override
343 def detail(self, msg:str, *rest:Any, **kwargs:Any) -> Self:
344 self._log.debug(msg, *rest, **kwargs)
345 return self
346
[docs]
347 @override
348 def failure(self, msg:str, *rest:Any, **kwargs:Any) -> Self:
349 match doot.is_setup:
350 case False:
351 print(msg % rest, file=sys.stderr)
352 case _:
353 self._log.exception(msg, *rest, **kwargs)
354
355 return self
356
[docs]
357 @override
358 def warn(self, msg:str, *rest:Any, **kwargs:Any) -> Self:
359 match doot.is_setup:
360 case False:
361 print(msg % rest, file=sys.stderr)
362 case _:
363 self._log.warn(msg, *rest)
364
365 return self
366
[docs]
367 @override
368 def error(self, msg:str, *rest:Any, **kwargs:Any) -> Self:
369 match doot.is_setup:
370 case False:
371 print(msg % rest, file=sys.stderr)
372 case _:
373 self._log.error(msg, *rest)
374
375 return self
376
[docs]
377class SummaryGroup(BaseGroup, API.SummaryGroup_p):
378 """ A reporter group for producing a summary at end of the workflow.
379
380 eg: success/failures, actions performed, time taken...
381 """
382 _start : Maybe[DateTime]
383 _end : Maybe[DateTime]
384 _subgroups : dict[str, list]
385
386 def __init__(self, **kwargs:Any) -> None:
387 super().__init__(**kwargs)
388 self._subgroups = {}
389 self._start = None
390 self._end = None
391
[docs]
392 @override
393 def start(self) -> None:
394 """ Set the Start Time """
395 raise NotImplementedError()
396
[docs]
397 @override
398 def finish(self) -> None:
399 """ Set the End Time """
400 raise NotImplementedError()
401
[docs]
402 @override
403 def add(self, key:str, *vals:Any) -> Self:
404 """ Add a summary group value """
405 raise NotImplementedError()
406
[docs]
407 @override
408 def summarise(self, *, state:bool=True) -> Self:
409 """ Output the summary that has been accumulated """
410 msg : str
411 match state:
412 case False:
413 self.state.log_extra['colour'] = "red"
414 msg = doot.config.on_fail("Errored").shutdown.notify.fail_msg()
415 case True:
416 msg = doot.config.on_fail("Success").shutdown.notify.success_msg()
417
418 self.line(msg)
419 # TODO the report
420 self.gap()
421 return self
422
423##--|
424
[docs]
425@Proto(API.Reporter_p)
426class BasicReporter:
427 """ The initial reporter for prior to configuration """
428
429 def __init__(self, *args:Any, logger:Maybe[Logger]=None, segments:Maybe[dict]=None, **kwargs:Any) -> None:
430 super().__init__(*args, **kwargs)
431
432 self._entry_count = START_COUNT
433 self._fmt = ReportFormatter(segments=segments or API.TRACE_LINES_ASCII)
434 self._logger = logger or logmod.getLogger(PRINTER_NAME)
435 self._stack = []
436 self._tree = TreeGroup(log=self._logger, fmt=self._fmt)
437 self._workflow = WorkflowGroup(log=self._logger, fmt=self._fmt)
438 self._general = GenGroup(log=self._logger, fmt=self._fmt)
439 self._summary = SummaryGroup(log=self._logger, fmt=self._fmt)
440
441 initial_entry = API.ReportStackEntry_d(state="initial",
442 data={},
443 log_extra={"colour":"blue"},
444 log_level=INIT_LEVEL,
445 )
446 self._stack.append(initial_entry)
447
448 @override
449 def __repr__(self) -> str:
450 return f"<{self.__class__.__name__} : {self.log.name} : {self.log.level} >"
451
452 def __enter__(self) -> Self:
453 self._entry_count += 1
454 self.push_state("ctx_manager")
455 return self
456
457 def __exit__(self, *exc:Any) -> bool:
458 match self._entry_count:
459 case int() as x if x < 1:
460 raise ValueError("Reporter enter/exit pairs count has gone negative")
461 case int() as x if x != len(self._stack) - 1:
462 raise ValueError("Mismatch between reporter stack and enter/exit pairs", x, len(self._stack))
463 case _:
464 self._entry_count -= 1
465 self.pop_state()
466
467 match exc:
468 case (None, None, None):
469 return True
470 case _:
471 return False
472
473 ##--|
474
[docs]
475 @property
476 def state(self) -> API.ReportStackEntry_d:
477 return self._stack[-1]
478
[docs]
479 @property
480 def wf(self) -> API.WorkflowGroup_p:
481 return self._workflow
482
[docs]
483 @property
484 def gen(self) -> API.GeneralGroup_p:
485 return self._general
486
[docs]
487 @property
488 def tree(self) -> API.TreeGroup_p:
489 return self._tree
490
[docs]
491 @property
492 def summary(self) -> API.SummaryGroup_p:
493 return self._summary
494
495 @property
496 def log(self) -> Logger:
497 return self._logger
498
[docs]
499 @log.setter
500 def log(self, logger:Maybe[Logger]) -> None:
501 match logger:
502 case None:
503 self._logger = logging
504 case logmod.Logger():
505 self._logger = logger
506 case x:
507 raise TypeError(type(x))
508
509 ##--|
510
[docs]
511 def active_level(self, level:int) -> None:
512 """ Set the base level the reporter will log at. """
513 self.state.log_level = level
514
[docs]
515 def push_state(self, state:str, **kwargs:Any) -> Self:
516 new_top : API.ReportStackEntry_d
517 new_top = deepcopy(self._stack[-1])
518 new_top.data = dict(kwargs)
519 new_top.depth += 1
520 new_top.state = state
521 match self._fmt.get_segment("inactive"):
522 case None:
523 pass
524 case str() as val:
525 new_top.prefix.append(val)
526 self._stack.append(new_top)
527 logging.info("Report State Set To: %s", state)
528 return self
529
[docs]
530 def pop_state(self) -> Self:
531 match self._stack:
532 case [x]:
533 pass
534 case _:
535 self._stack.pop()
536
537 return self