Source code for doot.reporters.basic

  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
[docs] 325 @override 326 def header(self, *, header:Maybe[str]=None) -> Self: 327 self.line() 328 self.line(header or DEFAULT_HEADER) 329 self.line() 330 return self
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