Coverage for pyTooling/TerminalUI/__init__.py: 80%
575 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +0000
1# ==================================================================================================================== #
2# _____ _ _ _____ _ _ _ _ ___ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _|_ _|__ _ __ _ __ ___ (_)_ __ __ _| | | | |_ _| #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | |/ _ \ '__| '_ ` _ \| | '_ \ / _` | | | | || | #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| | __/ | | | | | | | | | | | (_| | | |_| || | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)_|\___|_| |_| |_| |_|_|_| |_|\__,_|_|\___/|___| #
7# |_| |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2017-2026 Patrick Lehmann - Bötzingen, Germany #
15# Copyright 2007-2016 Patrick Lehmann - Dresden, Germany #
16# #
17# Licensed under the Apache License, Version 2.0 (the "License"); #
18# you may not use this file except in compliance with the License. #
19# You may obtain a copy of the License at #
20# #
21# http://www.apache.org/licenses/LICENSE-2.0 #
22# #
23# Unless required by applicable law or agreed to in writing, software #
24# distributed under the License is distributed on an "AS IS" BASIS, #
25# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
26# See the License for the specific language governing permissions and #
27# limitations under the License. #
28# #
29# SPDX-License-Identifier: Apache-2.0 #
30# ==================================================================================================================== #
31#
32"""
33A set of helpers to implement a text user interface (TUI) in a terminal.
35:raises MissingDependencyError: If the 'terminal' extra isn't installed.
37.. seealso::
39 :mod:`pyTooling.Attributes.ArgParse`
40 |rarr| Declaring the commands and options the application accepts.
41 :mod:`pyTooling.CLIAbstraction`
42 |rarr| Calling other programs from such an application.
43 :mod:`pyTooling.Warning`
44 |rarr| Collecting warnings that the application then writes.
45"""
46from __future__ import annotations
48from datetime import datetime
49from enum import Enum, unique
50from io import TextIOWrapper
51from sys import stdin, stdout, stderr
52from textwrap import dedent
53from types import ModuleType
54from typing import NoReturn, Any, Optional as Nullable, Callable, ClassVar
55from pyTooling.Exceptions import MissingDependencyError
56from pyTooling.Versioning import PythonVersion
58try:
59 from colorama import Fore as Foreground
60except ImportError as ex: # pragma: no cover
61 raise MissingDependencyError(dependency="colorama", extra="terminal") from ex
63from pyTooling.Decorators import export, readonly
64from pyTooling.MetaClasses import ExtendedType, mixin
65from pyTooling.Exceptions import PlatformNotSupportedError, ExceptionBase
66from pyTooling.Common import lastItem, getFullyQualifiedName
67from pyTooling.Platform import Platform
70@export
71class TerminalBaseApplication(metaclass=ExtendedType, slots=True, singleton=True):
72 """
73 The class offers a basic terminal application base-class.
75 It offers basic colored output via `colorama <https://GitHub.com/tartley/colorama>`__ as well as retrieving the
76 terminal's width.
77 """
79 NOT_IMPLEMENTED_EXCEPTION_EXIT_CODE: ClassVar[int] = 240 #: Return code, if unimplemented methods or code sections were called.
80 UNHANDLED_EXCEPTION_EXIT_CODE: ClassVar[int] = 241 #: Return code, if an unhandled exception reached the topmost exception handler.
81 #: Return code (242), if an optional dependency is missing. The value lives on the exception, which stays
82 #: importable when this module is not - see :meth:`PrintMissingDependencyError`.
83 MISSING_DEPENDENCY_EXIT_CODE: ClassVar[int] = MissingDependencyError.EXIT_CODE
84 FATAL_EXIT_CODE: ClassVar[int] = 255 #: Return code for fatal exits.
85 ISSUE_TRACKER_URL: ClassVar[str] = None #: URL to the issue tracker for reporting bugs.
86 INDENT: ClassVar[str] = " " #: Indentation. Default: ``" "`` (2 spaces)
88 try:
89 from colorama import Fore as Foreground
90 Foreground: ClassVar[dict[str, str]] = {
91 "RED": Foreground.LIGHTRED_EX,
92 "DARK_RED": Foreground.RED,
93 "GREEN": Foreground.LIGHTGREEN_EX,
94 "DARK_GREEN": Foreground.GREEN,
95 "YELLOW": Foreground.LIGHTYELLOW_EX,
96 "DARK_YELLOW": Foreground.YELLOW,
97 "MAGENTA": Foreground.LIGHTMAGENTA_EX,
98 "BLUE": Foreground.LIGHTBLUE_EX,
99 "DARK_BLUE": Foreground.BLUE,
100 "CYAN": Foreground.LIGHTCYAN_EX,
101 "DARK_CYAN": Foreground.CYAN,
102 "GRAY": Foreground.WHITE,
103 "DARK_GRAY": Foreground.LIGHTBLACK_EX,
104 "WHITE": Foreground.LIGHTWHITE_EX,
105 "NOCOLOR": Foreground.RESET,
107 "HEADLINE": Foreground.LIGHTMAGENTA_EX,
108 "ERROR": Foreground.LIGHTRED_EX,
109 "WARNING": Foreground.LIGHTYELLOW_EX
110 } #: Terminal colors
111 except ImportError: # pragma: no cover
112 Foreground: ClassVar[dict[str, str]] = {
113 "RED": "",
114 "DARK_RED": "",
115 "GREEN": "",
116 "DARK_GREEN": "",
117 "YELLOW": "",
118 "DARK_YELLOW": "",
119 "MAGENTA": "",
120 "BLUE": "",
121 "DARK_BLUE": "",
122 "CYAN": "",
123 "DARK_CYAN": "",
124 "GRAY": "",
125 "DARK_GRAY": "",
126 "WHITE": "",
127 "NOCOLOR": "",
129 "HEADLINE": "",
130 "ERROR": "",
131 "WARNING": ""
132 } #: Terminal colors
134 _stdin: TextIOWrapper #: STDIN
135 _stdout: TextIOWrapper #: STDOUT
136 _stderr: TextIOWrapper #: STDERR
137 _width: int #: Terminal width in characters
138 _height: int #: Terminal height in characters
140 def __init__(self) -> None:
141 """
142 Initialize a terminal.
144 If the Python package `colorama <https://pypi.org/project/colorama/>`_ [#f_colorama]_ is available, then initialize
145 it for colored outputs.
147 .. [#f_colorama] Colorama on Github: https://GitHub.com/tartley/colorama
148 """
150 self._stdin = stdin
151 self._stdout = stdout
152 self._stderr = stderr
153 if stdout.isatty(): 153 ↛ 154line 153 didn't jump to line 154 because the condition on line 153 was never true
154 self.InitializeColors()
155 else:
156 self.UninitializeColors()
157 self._width, self._height = self.GetTerminalSize()
159 def InitializeColors(self) -> bool:
160 """
161 Initialize the terminal for color support by `colorama <https://GitHub.com/tartley/colorama>`__.
163 :returns: True, if 'colorama' package could be imported and initialized.
164 """
165 try:
166 from colorama import init
168 init()
169 return True
170 except ImportError: # pragma: no cover
171 return False
173 def UninitializeColors(self) -> bool:
174 """
175 Uninitialize the terminal for color support by `colorama <https://GitHub.com/tartley/colorama>`__.
177 :returns: True, if 'colorama' package could be imported and uninitialized.
178 """
179 try:
180 from colorama import deinit
182 deinit()
183 return True
184 except ImportError: # pragma: no cover
185 return False
187 @readonly
188 def Width(self) -> int:
189 """
190 Read-only property to access the terminal's width.
192 :returns: The terminal window's width in characters.
193 """
194 return self._width
196 @readonly
197 def Height(self) -> int:
198 """
199 Read-only property to access the terminal's height.
201 :returns: The terminal window's height in characters.
202 """
203 return self._height
205 @staticmethod
206 def GetTerminalSize() -> tuple[int, int]:
207 """
208 Returns the terminal size as tuple (width, height) for Windows, macOS (Darwin), Linux, cygwin (Windows), MinGW32/64 (Windows).
210 :returns: A tuple containing width and height of the terminal's size in characters.
211 :raises PlatformNotSupportedError: When a platform is not yet supported.
212 """
213 platform = Platform()
214 if platform.IsNativeWindows:
215 size = TerminalBaseApplication.__GetTerminalSizeOnWindows()
216 elif (platform.IsNativeLinux or platform.IsNativeFreeBSD or platform.IsNativeMacOS or platform.IsMinGW32OnWindows or platform.IsMinGW64OnWindows
217 or platform.IsUCRT64OnWindows or platform.IsCygwin32OnWindows or platform.IsClang64OnWindows):
218 size = TerminalBaseApplication.__GetTerminalSizeOnLinux()
219 else: # pragma: no cover
220 raise PlatformNotSupportedError(f"Platform '{platform}' not yet supported.")
222 if size is None: # pragma: no cover
223 size = (80, 25) # default size
225 return size
227 @staticmethod
228 def __GetTerminalSizeOnWindows() -> Nullable[tuple[int, int]]:
229 """
230 Returns the current terminal window's size for Windows.
232 ``kernel32.dll:GetConsoleScreenBufferInfo()`` is used to retrieve the information.
234 :returns: A tuple containing width and height of the terminal's size in characters.
235 """
236 try:
237 from ctypes import windll, create_string_buffer
238 from struct import unpack as struct_unpack
240 hStdError = windll.kernel32.GetStdHandle(-12) # stderr handle = -12
241 stringBuffer = create_string_buffer(22)
242 result = windll.kernel32.GetConsoleScreenBufferInfo(hStdError, stringBuffer)
243 if result: 243 ↛ 244line 243 didn't jump to line 244 because the condition on line 243 was never true
244 bufx, bufy, curx, cury, wattr, left, top, right, bottom, maxx, maxy = struct_unpack("hhhhHhhhhhh", stringBuffer.raw)
245 width = right - left + 1
246 height = bottom - top + 1
247 return width, height
248 except ImportError:
249 pass
251 return None
252 # return Terminal.__GetTerminalSizeWithTPut()
254 # @staticmethod
255 # def __GetTerminalSizeWithTPut() -> tuple[int, int]:
256 # """
257 # Returns the current terminal window's size for Windows.
258 #
259 # ``tput`` is used to retrieve the information.
260 #
261 # :returns: A tuple containing width and height of the terminal's size in characters.
262 # """
263 # from subprocess import check_output
264 #
265 # try:
266 # width = int(check_output(("tput", "cols")))
267 # height = int(check_output(("tput", "lines")))
268 # return (width, height)
269 # except:
270 # pass
272 @staticmethod
273 def __GetTerminalSizeOfFileDescriptor(fd: int) -> Nullable[tuple[int, int]]:
274 """
275 Get window size of a file descriptor.
277 Call `ioctl` with ``TIOCGWINSZ`` (GetWindowsSize) for the given file descriptor.
279 :param fd: File descriptor to query.
280 :returns: A 2-tuple of terminal width and height, or ``None`` if the size couldn't be determined.
281 """
282 try:
283 from array import array
284 from fcntl import ioctl
285 from termios import TIOCGWINSZ
286 except ImportError:
287 return None
289 # Allocate an array of 4x unsigned short (C struct)
290 # H = unsigned short (16-bit)
291 buffer = array('H', [0, 0, 0, 0]) # rows, columns, x-pixels, y-pixels
292 try:
293 ioctl(fd, TIOCGWINSZ, buffer, True)
294 return buffer[1], buffer[0]
295 except OSError:
296 return None
298 @staticmethod
299 def __GetTerminalSizeOnLinux() -> Nullable[tuple[int, int]]:
300 """
301 Returns the current terminal window's size for Linux.
303 ``ioctl(TIOCGWINSZ)`` is used to retrieve the information. As a fallback, environment variables ``COLUMNS`` and
304 ``LINES`` are checked.
306 :returns: A tuple containing width and height of the terminal's size in characters.
307 """
308 # STDIN, STDOUT, STDERR
309 for fd in range(3):
310 if (size := TerminalBaseApplication.__GetTerminalSizeOfFileDescriptor(fd)) is not None: 310 ↛ 311line 310 didn't jump to line 311 because the condition on line 310 was never true
311 return size
313 # Fallback
314 fd = None
315 try:
316 from os import open, close, ctermid, O_RDONLY
318 fd = open(ctermid(), O_RDONLY)
319 if (size := TerminalBaseApplication.__GetTerminalSizeOfFileDescriptor(fd)) is not None:
320 return size
321 except (ImportError, OSError):
322 # ImportError - If ctermid is not available (e.g. MSYS2)
323 # OSError - If ctermid() or open() fails
324 pass
325 finally:
326 if fd is not None: 326 ↛ 327line 326 didn't jump to line 327 because the condition on line 326 was never true
327 try:
328 close(fd)
329 except OSError:
330 pass
332 # Fall-fallback
333 from os import getenv
335 try:
336 columns = int(getenv("COLUMNS"))
337 lines = int(getenv("LINES"))
338 return columns, lines
339 except TypeError:
340 pass
342 return None
344 def WriteToStdOut(self, message: str) -> int:
345 """
346 Low-level method for writing to ``STDOUT``.
348 :param message: Message to write to ``STDOUT``.
349 :returns: Number of written characters.
350 """
351 return self._stdout.write(message)
353 def WriteLineToStdOut(self, message: str, end: str = "\n") -> int:
354 """
355 Low-level method for writing to ``STDOUT``.
357 :param message: Message to write to ``STDOUT``.
358 :param end: Optional, use newline character. Default: ``\\n``.
359 :returns: Number of written characters.
360 """
361 return self._stdout.write(message + end)
363 def WriteToStdErr(self, message: str) -> int:
364 """
365 Low-level method for writing to ``STDERR``.
367 :param message: Message to write to ``STDERR``.
368 :returns: Number of written characters.
369 """
370 return self._stderr.write(message)
372 def WriteLineToStdErr(self, message: str, end: str = "\n") -> int:
373 """
374 Low-level method for writing to ``STDERR``.
376 :param message: Message to write to ``STDERR``.
377 :param end: Optional, use newline character. Default: ``\\n``.
378 :returns: Number of written characters.
379 """
380 return self._stderr.write(message + end)
382 def FatalExit(self, returnCode: int = 0) -> NoReturn:
383 """
384 Exit the terminal application by uninitializing color support and returning a fatal Exit code.
386 :param returnCode: Optional, return code for application exit.
387 """
388 self.Exit(self.FATAL_EXIT_CODE if returnCode == 0 else returnCode)
390 def Exit(self, returnCode: int = 0) -> NoReturn:
391 """
392 Exit the terminal application by uninitializing color support and returning an Exit code.
394 :param returnCode: Optional, return code for application exit.
395 """
396 self.UninitializeColors()
397 exit(returnCode)
399 def PrintException(self, ex: Exception) -> NoReturn:
400 """
401 Prints an exception of type :exc:`Exception` and its traceback.
403 If the exception as a nested action, the cause is printed as well.
405 If ``ISSUE_TRACKER_URL`` is configured, a URL to the issue tracker is added.
407 :param ex: The exception to print.
408 """
409 from traceback import format_tb, walk_tb
411 frame, sourceLine = lastItem(walk_tb(ex.__traceback__))
412 filename = frame.f_code.co_filename
413 funcName = frame.f_code.co_name
415 exceptionType = getFullyQualifiedName(ex)
417 message = f"{{RED}}[FATAL] An unknown or unhandled exception reached the topmost exception handler!{{NOCOLOR}}\n"
418 message += f"{{indent}}{{YELLOW}}Exception type:{{NOCOLOR}} {{DARK_RED}}{exceptionType}{{NOCOLOR}}\n"
419 message += f"{{indent}}{{YELLOW}}Exception message:{{NOCOLOR}} {{RED}}{ex!s}{{NOCOLOR}}\n"
421 if hasattr(ex, "__notes__") and len(ex.__notes__) > 0:
422 note = next(iterator := iter(ex.__notes__))
423 message += f"{{indent}}{{YELLOW}}Notes:{{NOCOLOR}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n"
424 for note in iterator:
425 message += f"{{indent}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n"
427 message += f"{{indent}}{{YELLOW}}Caused in:{{NOCOLOR}} {funcName}(...) in file '{filename}' at line {sourceLine}\n"
429 if (ex2 := ex.__cause__) is not None:
430 causeType = getFullyQualifiedName(ex2)
432 message += f"{{indent2}}{{DARK_YELLOW}}Caused by ex. type:{{NOCOLOR}} {{DARK_RED}}{causeType}{{NOCOLOR}}\n"
433 message += f"{{indent2}}{{DARK_YELLOW}}Caused by message:{{NOCOLOR}} {ex2!s}{{NOCOLOR}}\n"
435 if hasattr(ex2, "__notes__") and len(ex2.__notes__) > 0: 435 ↛ 441line 435 didn't jump to line 441 because the condition on line 435 was always true
436 note = next(iterator := iter(ex2.__notes__))
437 message += f"{{indent2}}{{DARK_YELLOW}}Notes:{{NOCOLOR}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n"
438 for note in iterator:
439 message += f"{{indent2}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n"
441 message += f"{{indent}}{{RED}}{'-' * 120}{{NOCOLOR}}\n"
442 for line in format_tb(ex.__traceback__):
443 message += f"{line.replace('{', '{{').replace('}', '}}')}"
444 message += f"{{indent}}{{RED}}{'-' * 120}{{NOCOLOR}}"
446 if self.ISSUE_TRACKER_URL is not None:
447 message += f"\n{{indent}}{{DARK_CYAN}}Please report this bug at GitHub: {self.ISSUE_TRACKER_URL}{{NOCOLOR}}\n"
448 message += f"{{indent}}{{RED}}{'-' * 120}{{NOCOLOR}}"
450 self.WriteLineToStdErr(message.format(indent=self.INDENT, indent2=self.INDENT*2, **self.Foreground))
451 self.Exit(self.UNHANDLED_EXCEPTION_EXIT_CODE)
453 def PrintMissingDependencyError(self, ex: MissingDependencyError) -> NoReturn:
454 """
455 Print a missing optional dependency and the command lines installing it.
457 Unlike the other printers, this one does **not** report a bug: there is no traceback, and no invitation to
458 open an issue, because nothing is wrong with the program - a package it can use is not installed. The message
459 names the missing package and every installation option the exception carries
460 (:attr:`~pyTooling.Exceptions.MissingDependencyError.InstallCommands`).
462 .. attention::
464 :mod:`pyTooling.TerminalUI` raises this exception **itself** when *colorama* is missing, and that happens
465 while the module is imported - long before an application object exists, so this method cannot report that
466 case. An application that wants to survive it catches the exception around its own imports and prints the
467 commands directly:
469 .. code-block:: python
471 from pyTooling.Exceptions import MissingDependencyError
473 try:
474 from pyTooling.TerminalUI import TerminalApplication
475 except MissingDependencyError as ex:
476 print(f"{ex}\n" + "\n".join(f" {command}" for command in ex.InstallCommands))
477 raise SystemExit(MissingDependencyError.EXIT_CODE) from ex
479 :param ex: The exception to print.
480 :returns: Never - the method exits the application with :attr:`MISSING_DEPENDENCY_EXIT_CODE`.
482 .. seealso::
484 :meth:`PrintException`
485 |rarr| Print an unhandled exception and its traceback.
486 :meth:`PrintNotImplementedError`
487 |rarr| Print a call to an unimplemented function or abstract method.
488 """
489 message = f"{{RED}}[MISSING DEPENDENCY] An optional dependency is not installed!{{NOCOLOR}}\n"
490 message += f"{{indent}}{{YELLOW}}Missing package:{{NOCOLOR}} {{DARK_RED}}{ex.Dependency}{{NOCOLOR}}\n"
492 commands = iter(ex.InstallCommands)
493 message += f"{{indent}}{{YELLOW}}Install it with:{{NOCOLOR}} {{DARK_CYAN}}{next(commands)}{{NOCOLOR}}\n"
494 for command in commands:
495 message += f"{{indent}} {{DARK_CYAN}}{command}{{NOCOLOR}}\n"
497 if (cause := ex.__cause__) is not None:
498 message += f"{{indent}}{{YELLOW}}Caused by:{{NOCOLOR}} {{RED}}{cause!s}{{NOCOLOR}}\n"
500 self.WriteLineToStdErr(message.format(indent=self.INDENT, indent2=self.INDENT * 2, **self.Foreground))
501 self.Exit(self.MISSING_DEPENDENCY_EXIT_CODE)
503 def PrintNotImplementedError(self, ex: NotImplementedError) -> NoReturn:
504 """
505 Prints a not-implemented exception of type :exc:`NotImplementedError`.
507 If ``ISSUE_TRACKER_URL`` is configured, a URL to the issue tracker is added.
509 :param ex: The exception to print.
510 """
511 from traceback import walk_tb
513 frame, sourceLine = lastItem(walk_tb(ex.__traceback__))
514 filename = frame.f_code.co_filename
515 funcName = frame.f_code.co_name
517 message = f"{{RED}}[NOT IMPLEMENTED] An unimplemented function or abstract method was called!{{NOCOLOR}}\n"
518 message += f"{{indent}}{{YELLOW}}Function or method:{{NOCOLOR}} {{DARK_RED}}{funcName}(...){{NOCOLOR}}\n"
519 message += f"{{indent}}{{YELLOW}}Exception message:{{NOCOLOR}} {{RED}}{ex!s}{{NOCOLOR}}\n"
521 if hasattr(ex, "__notes__") and len(ex.__notes__) > 0: 521 ↛ 522line 521 didn't jump to line 522 because the condition on line 521 was never true
522 note = next(iterator := iter(ex.__notes__))
523 message += f"{{indent}}{{YELLOW}}Notes:{{NOCOLOR}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n"
524 for note in iterator:
525 message += f"{{indent}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n"
527 message += f"{{indent}}{{YELLOW}}Caused in:{{NOCOLOR}} {funcName}(...) in file '{filename}' at line {sourceLine}\n"
529 if self.ISSUE_TRACKER_URL is not None: 529 ↛ 533line 529 didn't jump to line 533 because the condition on line 529 was always true
530 message += f"\n{{indent}}{{DARK_CYAN}}Please report this bug at GitHub: {self.ISSUE_TRACKER_URL}{{NOCOLOR}}\n"
531 message += f"{{indent}}{{RED}}{'-' * 120}{{NOCOLOR}}"
533 self.WriteLineToStdErr(message.format(indent=self.INDENT, indent2=self.INDENT * 2, **self.Foreground))
534 self.Exit(self.NOT_IMPLEMENTED_EXCEPTION_EXIT_CODE)
536 def PrintExceptionBase(self, ex: Exception) -> NoReturn:
537 """
538 Prints an exception of type :exc:`~pyTooling.Exceptions.ExceptionBase` and its traceback.
540 If the exception as a nested action, the cause is printed as well.
542 If ``ISSUE_TRACKER_URL`` is configured, a URL to the issue tracker is added.
544 :param ex: The exception to print.
545 """
546 from traceback import print_tb, walk_tb
548 frame, sourceLine = lastItem(walk_tb(ex.__traceback__))
549 filename = frame.f_code.co_filename
550 funcName = frame.f_code.co_name
552 exceptionType = getFullyQualifiedName(ex)
554 self.WriteLineToStdErr(dedent(f"""\
555 {{RED}}[FATAL] A known but unhandled exception reached the topmost exception handler!{{NOCOLOR}}
556 {{indent}}{{YELLOW}}Exception type:{{NOCOLOR}} {{DARK_RED}}{exceptionType}{{NOCOLOR}}
557 {{indent}}{{YELLOW}}Exception message:{{NOCOLOR}} {{RED}}{ex!s}{{NOCOLOR}}
558 {{indent}}{{YELLOW}}Caused in:{{NOCOLOR}} {funcName}(...) in file '{filename}' at line {sourceLine}\
559 """).format(indent=self.INDENT, **self.Foreground))
561 if ex.__cause__ is not None:
562 causeType = getFullyQualifiedName(ex.__cause__)
564 self.WriteLineToStdErr(dedent(f"""\
565 {{indent2}}{{DARK_YELLOW}}Caused by ex. type:{{NOCOLOR}} {{DARK_RED}}{causeType}{{NOCOLOR}}
566 {{indent2}}{{DARK_YELLOW}}Caused by message:{{NOCOLOR}} {{RED}}{ex.__cause__!s}{{NOCOLOR}}\
567 """).format(indent2=self.INDENT * 2, **self.Foreground))
569 self.WriteLineToStdErr(f"""{{indent}}{{RED}}{'-' * 80}{{NOCOLOR}}""".format(indent=self.INDENT, **self.Foreground))
570 print_tb(ex.__traceback__, file=self._stderr)
571 self.WriteLineToStdErr(f"""{{indent}}{{RED}}{'-' * 80}{{NOCOLOR}}""".format(indent=self.INDENT, **self.Foreground))
573 if self.ISSUE_TRACKER_URL is not None: 573 ↛ 579line 573 didn't jump to line 579 because the condition on line 573 was always true
574 self.WriteLineToStdErr(dedent(f"""\
575 {{indent}}{{DARK_CYAN}}Please report this bug at GitHub: {self.ISSUE_TRACKER_URL}{{NOCOLOR}}
576 {{indent}}{{RED}}{'-' * 80}{{NOCOLOR}}\
577 """).format(indent=self.INDENT, **self.Foreground))
579 self.Exit(self.UNHANDLED_EXCEPTION_EXIT_CODE)
582@export
583@unique
584class Severity(Enum):
585 """Logging message severity levels."""
587 Exception = 120 #: Unhandled exception messages
588 ExceptionCause = 115 #: Exception cause
589 ExceptionNote = 110 #: Exception notes
590 Fatal = 100 #: Fatal messages
591 Error = 80 #: Error messages
592 ErrorNote = 75 #: Error notes
593 Quiet = 70 #: Always visible messages, even in quiet mode.
595 Critical = 60 #: Critical messages
596 CriticalNote = 55 #: Critical notes
597 Warning = 50 #: Warning messages
598 WarningNote = 45 #: Warning notes
599 Silent = 40 #: Severity level for silenced messages.
601 Info = 20 #: Informative messages
602 Normal = 10 #: Normal messages
603 DryRun = 8 #: Messages visible in a dry-run
604 Verbose = 5 #: Verbose messages
605 Debug = 2 #: Debug messages
606 All = 0 #: All messages
608 def __hash__(self) -> int:
609 """
610 Compute a hash of the severity level, so it can be used as a key in a dictionary.
612 :returns: Hash of the severity level's name.
613 """
614 return hash(self.name)
616 def __eq__(self, other: Any) -> bool:
617 """
618 Compare two Severity instances (severity level) for equality.
620 :param other: Operand to compare against.
621 :returns: ``True``, if both severity levels are equal.
622 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
623 """
624 if isinstance(other, Severity):
625 return self.value == other.value
626 else:
627 ex = TypeError("Second operand is not supported by == operator.")
628 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
629 ex.add_note("Supported types for second operand: Severity")
630 raise ex
632 def __ne__(self, other: Any) -> bool:
633 """
634 Compare two Severity instances (severity level) for inequality.
636 :param other: Operand to compare against.
637 :returns: ``True``, if both severity levels are unequal.
638 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
639 """
640 if isinstance(other, Severity):
641 return self.value != other.value
642 else:
643 ex = TypeError("Second operand is not supported by != operator.")
644 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
645 ex.add_note("Supported types for second operand: Severity")
646 raise ex
648 def __lt__(self, other: Any) -> bool:
649 """
650 Compare two Severity instances (severity level) for less-than.
652 :param other: Operand to compare against.
653 :returns: ``True``, if severity levels is less than other severity level.
654 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
655 """
656 if isinstance(other, Severity):
657 return self.value < other.value
658 else:
659 ex = TypeError("Second operand is not supported by < operator.")
660 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
661 ex.add_note("Supported types for second operand: Severity")
662 raise ex
664 def __le__(self, other: Any) -> bool:
665 """
666 Compare two Severity instances (severity level) for less-than-or-equal.
668 :param other: Operand to compare against.
669 :returns: ``True``, if severity levels is less than or equal other severity level.
670 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
671 """
672 if isinstance(other, Severity):
673 return self.value <= other.value
674 else:
675 ex = TypeError("Second operand is not supported by <= operator.")
676 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
677 ex.add_note("Supported types for second operand: Severity")
678 raise ex
680 def __gt__(self, other: Any) -> bool:
681 """
682 Compare two Severity instances (severity level) for greater-than.
684 :param other: Operand to compare against.
685 :returns: ``True``, if severity levels is greater than other severity level.
686 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
687 """
688 if isinstance(other, Severity):
689 return self.value > other.value
690 else:
691 ex = TypeError("Second operand is not supported by > operator.")
692 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
693 ex.add_note("Supported types for second operand: Severity")
694 raise ex
696 def __ge__(self, other: Any) -> bool:
697 """
698 Compare two Severity instances (severity level) for greater-than-or-equal.
700 :param other: Operand to compare against.
701 :returns: ``True``, if severity levels is greater than or equal other severity level.
702 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
703 """
704 if isinstance(other, Severity):
705 return self.value >= other.value
706 else:
707 ex = TypeError("Second operand is not supported by >= operator.")
708 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
709 ex.add_note("Supported types for second operand: Severity")
710 raise ex
713@export
714@unique
715class Mode(Enum):
716 """Routing modes deciding to which stream (``STDOUT``/``STDERR``) a message of a certain severity is written."""
718 TextToStdOut_ErrorsToStdErr = 0 #: Warnings and higher severities to ``STDERR``, except :attr:`Severity.Quiet`.
719 AllLinearToStdOut = 1 #: All messages to ``STDOUT``, so the message order is preserved in a log file.
720 DataToStdOut_OtherToStdErr = 2 #: All messages to ``STDERR``, leaving ``STDOUT`` for the program's data.
723@export
724class Line(metaclass=ExtendedType, slots=True):
725 """
726 Represents a single message line with a severity and indentation level.
727 """
729 _LOG_MESSAGE_FORMAT__: ClassVar[dict[Severity, str]] = {
730 Severity.Exception: "EXCEPTION: {message}",
731 Severity.ExceptionNote: " > {message}",
732 Severity.Fatal: "FATAL: {message}",
733 Severity.Error: "ERROR: {message}",
734 Severity.ErrorNote: " > {message}",
735 Severity.Quiet: "{message}",
736 Severity.Critical: "CRITICAL: {message}",
737 Severity.CriticalNote: " > {message}",
738 Severity.Warning: "WARNING: {message}",
739 Severity.WarningNote: " > {message}",
740 Severity.Info: "INFO: {message}",
741 Severity.Normal: "{message}",
742 Severity.DryRun: "DRYRUN: {message}",
743 Severity.Verbose: "VERBOSE: {message}",
744 Severity.Debug: "DEBUG: {message}",
745 } #: Message line formatting rules.
747 _timestamp: datetime #: Timestamp when the line was created.
748 _message: str #: Text message (line content).
749 _severity: Severity #: Message severity
750 _indent: int #: Indentation
751 _appendLinebreak: bool #: True, if a trailing linebreak should be added when printing this line object.
753 def __init__(
754 self,
755 message: str,
756 severity: Severity = Severity.Normal,
757 *,
758 indent: int = 0,
759 appendLinebreak: bool = True
760 ) -> None:
761 """
762 Initialize a line object representing the single-line message.
764 :param message: Message to display.
765 :param severity: Optional, severity level of the message.
766 :param indent: Optional, indentation level of the message.
767 :param appendLinebreak: Optional, if ``True``, append a line break at the end of the message.
768 """
769 self._timestamp = datetime.now()
770 self._severity = severity
771 self._message = message
772 self._indent = indent
773 self._appendLinebreak = appendLinebreak
775 @readonly
776 def Message(self) -> str:
777 """
778 Read-only property to access the line's raw message.
780 :returns: Raw message of the line.
781 """
782 return self._message
784 @readonly
785 def Severity(self) -> Severity:
786 """
787 Read-only property to access the line's severity level.
789 :returns: Severity level of the message line.
790 """
791 return self._severity
793 @readonly
794 def Indent(self) -> int:
795 """
796 Read-only property to access the line's indentation level.
798 :returns: Indentation level of the message line.
799 """
800 return self._indent
802 def IndentBy(self, indent: int) -> int:
803 """
804 Increase a line's indentation level.
806 :param indent: Optional, indentation level added to the current indentation level.
807 :returns: The new indentation level.
808 """
809 self._indent = (newIndent := self._indent + indent)
810 return newIndent
812 @readonly
813 def AppendLinebreak(self) -> bool:
814 """
815 Read-only property to access if a linebreak is added after the line's message.
817 :returns: True, if a linebreak should be added.
818 """
819 return self._appendLinebreak
821 def __str__(self) -> str:
822 """
823 Returns a formatted version of a ``Line`` objects as a string.
825 The formatting is defined in :attr:`_LOG_MESSAGE_FORMAT__`.
827 :returns: Formatted version of a ``Line`` object.
828 """
829 return self._LOG_MESSAGE_FORMAT__[self._severity].format(message=self._message)
832@export
833@mixin
834class ILineTerminal:
835 """A mixin class (interface) to provide class-local terminal writing methods."""
837 _terminal: Nullable[TerminalApplication] #: The terminal application the messages are written to.
839 def __init__(self, terminal: Nullable[TerminalApplication] = None) -> None:
840 """
841 Mixin initializer.
843 :param terminal: Optional, the terminal to write to. If ``None``, every writing method does nothing.
844 """
845 self._terminal = terminal
847 # FIXME: Alter methods if a terminal is present or set dummy methods
849 @readonly
850 def Terminal(self) -> Nullable[TerminalApplication]:
851 """
852 Read-only property to access the local terminal instance (:attr:`_terminal`).
854 :returns: The terminal instance, or ``None`` if no terminal is attached.
855 """
856 return self._terminal
858 def WriteLine(self, line: Line, condition: bool = True) -> bool:
859 """
860 Write a line to the local terminal if ``condition`` is ``True``.
862 :param line: Line object to write.
863 :param condition: Optional, write the line only if this condition is ``True``. Default: ``True``.
864 :returns: True, if the line was actually written.
865 """
866 if (self._terminal is not None) and condition:
867 return self._terminal.WriteLine(line)
868 return False
870 # def _TryWriteLine(self, *args: Any, condition: bool = True, **kwargs: Any):
871 # if (self._terminal is not None) and condition:
872 # return self._terminal.TryWrite(*args, **kwargs)
873 # return False
875 def WriteFatal(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
876 """
877 Write a fatal message to the local terminal if ``condition`` is ``True``.
879 :param args: Positional parameters forwarded to the terminal's writing method.
880 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
881 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
882 :returns: True, if the message was actually written.
883 """
884 if (self._terminal is not None) and condition:
885 return self._terminal.WriteFatal(*args, **kwargs)
886 return False
888 def WriteError(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
889 """
890 Write an error message to the local terminal if ``condition`` is ``True``.
892 :param args: Positional parameters forwarded to the terminal's writing method.
893 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
894 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
895 :returns: True, if the message was actually written.
896 """
897 if (self._terminal is not None) and condition:
898 return self._terminal.WriteError(*args, **kwargs)
899 return False
901 def WriteCritical(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
902 """
903 Write a critical warning message to the local terminal if ``condition`` is ``True``.
905 :param args: Positional parameters forwarded to the terminal's writing method.
906 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
907 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
908 :returns: True, if the message was actually written.
909 """
910 if (self._terminal is not None) and condition:
911 return self._terminal.WriteCritical(*args, **kwargs)
912 return False
914 def WriteWarning(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
915 """
916 Write a warning message to the local terminal if ``condition`` is ``True``.
918 :param args: Positional parameters forwarded to the terminal's writing method.
919 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
920 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
921 :returns: True, if the message was actually written.
922 """
923 if (self._terminal is not None) and condition:
924 return self._terminal.WriteWarning(*args, **kwargs)
925 return False
927 def WriteInfo(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
928 """
929 Write an info message to the local terminal if ``condition`` is ``True``.
931 :param args: Positional parameters forwarded to the terminal's writing method.
932 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
933 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
934 :returns: True, if the message was actually written.
935 """
936 if (self._terminal is not None) and condition:
937 return self._terminal.WriteInfo(*args, **kwargs)
938 return False
940 def WriteQuiet(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
941 """
942 Write an always visible message, even in quiet mode, to the local terminal if ``condition`` is ``True``.
944 :param args: Positional parameters forwarded to the terminal's writing method.
945 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
946 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
947 :returns: True, if the message was actually written.
948 """
949 if (self._terminal is not None) and condition:
950 return self._terminal.WriteQuiet(*args, **kwargs)
951 return False
953 def WriteNormal(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
954 """
955 Write a *normal* message to the local terminal if ``condition`` is ``True``.
957 :param args: Positional parameters forwarded to the terminal's writing method.
958 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
959 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
960 :returns: True, if the message was actually written.
961 """
962 if (self._terminal is not None) and condition:
963 return self._terminal.WriteNormal(*args, **kwargs)
964 return False
966 def WriteVerbose(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
967 """
968 Write a verbose message to the local terminal if ``condition`` is ``True``.
970 :param args: Positional parameters forwarded to the terminal's writing method.
971 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
972 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
973 :returns: True, if the message was actually written.
974 """
975 if (self._terminal is not None) and condition:
976 return self._terminal.WriteVerbose(*args, **kwargs)
977 return False
979 def WriteDebug(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
980 """
981 Write a debug message to the local terminal if ``condition`` is ``True``.
983 :param args: Positional parameters forwarded to the terminal's writing method.
984 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
985 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
986 :returns: True, if the message was actually written.
987 """
988 if (self._terminal is not None) and condition:
989 return self._terminal.WriteDebug(*args, **kwargs)
990 return False
992 def WriteDryRun(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
993 """
994 Write a dry-run message to the local terminal if ``condition`` is ``True``.
996 :param args: Positional parameters forwarded to the terminal's writing method.
997 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
998 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
999 :returns: True, if the message was actually written.
1000 """
1001 if (self._terminal is not None) and condition:
1002 return self._terminal.WriteDryRun(*args, **kwargs)
1003 return False
1006@export
1007class TerminalApplication(TerminalBaseApplication): #, ILineTerminal):
1008 """
1009 A base-class for implementation of terminal applications emitting line-by-line messages.
1010 """
1011 _LOG_MESSAGE_FORMAT__: ClassVar[dict[Severity, str]] = {
1012 Severity.Exception: "{RED}[EXCEPTION] {message}{NOCOLOR}",
1013 Severity.ExceptionNote: "{DARK_RED} > {message}{NOCOLOR}",
1014 Severity.Fatal: "{DARK_RED}[FATAL] {message}{NOCOLOR}",
1015 Severity.Error: "{RED}[ERROR] {message}{NOCOLOR}",
1016 Severity.ErrorNote: "{DARK_RED} > {message}{NOCOLOR}",
1017 Severity.Quiet: "{WHITE}{message}{NOCOLOR}",
1018 Severity.Critical: "{DARK_YELLOW}[CRITICAL] {message}{NOCOLOR}",
1019 Severity.CriticalNote: "{DARK_YELLOW} > {message}{NOCOLOR}",
1020 Severity.Warning: "{YELLOW}[WARNING] {message}{NOCOLOR}",
1021 Severity.WarningNote: "{DARK_YELLOW} > {message}{NOCOLOR}",
1022 Severity.Info: "{WHITE}{message}{NOCOLOR}",
1023 Severity.Normal: "{WHITE}{message}{NOCOLOR}",
1024 Severity.DryRun: "{DARK_CYAN}[DRY] {message}{NOCOLOR}",
1025 Severity.Verbose: "{GRAY}{message}{NOCOLOR}",
1026 Severity.Debug: "{DARK_GRAY}{message}{NOCOLOR}"
1027 } #: Message formatting rules.
1029 _LOG_LEVEL_ROUTING__: dict[Severity, tuple[Callable[[str, str], int]]] #: Message routing rules.
1030 _verbose: bool #: ``True``, if verbose messages are written.
1031 _debug: bool #: ``True``, if debug messages are written.
1032 _silent: bool #: ``True``, if no messages are written at all.
1033 _quiet: bool #: ``True``, if only errors and quiet messages are written.
1034 _writeLevel: Severity #: Minimal severity a message needs to be written.
1035 _writeToStdOut: bool #: ``True``, if messages are written to ``STDOUT`` instead of ``STDERR``.
1037 _lines: list[Line] #: Every message written so far, in the order it was written.
1038 _baseIndent: int #: Indentation level added to every message's own indentation.
1040 _errorCount: int #: Number of errors written so far.
1041 _criticalWarningCount: int #: Number of critical warnings written so far.
1042 _warningCount: int #: Number of warnings written so far.
1044 HeadLine: ClassVar[str] #: Headline of the application, printed by :meth:`_PrintHeadline`.
1046 def __init__(self, mode: Mode = Mode.AllLinearToStdOut) -> None:
1047 """
1048 Initializer of a line-based terminal interface.
1050 :param mode: Optional, defines what output (normal, error, data) to write where. Default: a linear flow all to
1051 *STDOUT*.
1052 """
1053 TerminalBaseApplication.__init__(self)
1054 # ILineTerminal.__init__(self, self)
1056 self._LOG_LEVEL_ROUTING__ = {}
1057 self.__InitializeLogLevelRouting(mode)
1059 self._verbose = False
1060 self._debug = False
1061 self._silent = False
1062 self._quiet = False
1063 self._writeLevel = Severity.Normal
1064 self._writeToStdOut = True
1066 self._lines = []
1067 self._baseIndent = 0
1069 self._errorCount = 0
1070 self._criticalWarningCount = 0
1071 self._warningCount = 0
1073 def __InitializeLogLevelRouting(self, mode: Mode = Mode.AllLinearToStdOut) -> None:
1074 """
1075 Expand a routing mode into a routing table containing one writing method per severity level.
1077 :param mode: Optional, routing mode to expand.
1078 :raises ExceptionBase: If the routing mode is not supported. |br|
1079 The note lists the modes that are supported.
1080 """
1081 if mode is Mode.TextToStdOut_ErrorsToStdErr:
1082 for severity in Severity:
1083 if severity >= Severity.Silent and severity != Severity.Quiet:
1084 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdErr,)
1085 else:
1086 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdOut,)
1087 elif mode is Mode.AllLinearToStdOut:
1088 for severity in Severity:
1089 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdOut, )
1090 elif mode is Mode.DataToStdOut_OtherToStdErr:
1091 for severity in Severity:
1092 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdErr, )
1093 else: # pragma: no cover
1094 ex = ExceptionBase(f"Unsupported mode '{mode}'.")
1095 ex.add_note(f"Unsupported modes '{', '.join(m.name for m in Mode)}'.")
1096 raise ex
1098 def _PrintHeadline(self, width: int = 80) -> None:
1099 """
1100 Helper method to print the program headline.
1102 :param width: Optional, number of characters for horizontal lines.
1104 .. admonition:: Generated output
1106 .. code-block::
1108 =========================
1109 centered headline
1110 =========================
1111 """
1112 if width == 0: 1112 ↛ 1113line 1112 didn't jump to line 1113 because the condition on line 1112 was never true
1113 width = self._width
1115 self.WriteNormal(f"{{HEADLINE}}{'=' * width}".format(**TerminalApplication.Foreground))
1116 self.WriteNormal(f"{{HEADLINE}}{{headline: ^{width}s}}".format(headline=self.HeadLine, **TerminalApplication.Foreground))
1117 self.WriteNormal(f"{{HEADLINE}}{'=' * width}".format(**TerminalApplication.Foreground))
1119 def _PrintVersion(
1120 self,
1121 dunderModule: ModuleType,
1122 packageName: Nullable[str] = None,
1123 versionCheckTimeout: int = 1
1124 ) -> None:
1125 """
1126 Helper method to print the version information.
1128 :param dunderModule: The Python module containing the dunder variables for author(s), email, copyright,
1129 version, ...
1130 :param packageName: Optional, name of the package on PyPI. If given, the latest released version is
1131 queried and reported as an available update. Default: ``None``.
1132 :param versionCheckTimeout: Optional, timeout in seconds for the PyPI request. Default: ``1``.
1134 .. admonition:: Example usage
1136 .. code-block:: Python
1138 def _PrintVersion(self):
1139 import myPackage.MyModule as DunderModule
1141 super()._PrintVersion(
1142 DunderModule,
1143 "MyModule"
1144 )
1145 """
1146 copyrights = getattr(dunderModule, "__copyright__", "{RED}Copyright not set!".format(RED=Foreground.RED)).split("\n", 1)
1147 self.WriteNormal(f"Copyright: {copyrights[0]}")
1148 for copyright in copyrights[1:]: 1148 ↛ 1149line 1148 didn't jump to line 1149 because the loop on line 1148 never started
1149 self.WriteNormal(f" {copyright}")
1151 license = getattr(dunderModule, "__license__", "{RED}License not set!".format(RED=Foreground.RED))
1152 self.WriteNormal(f"License: {license}")
1154 authors = getattr(dunderModule, "__author__", "{RED}Unknown author!".format(RED=Foreground.RED)).split(", ")
1155 self.WriteNormal(f"Authors: {authors[0]}")
1156 for author in authors[1:]: 1156 ↛ 1157line 1156 didn't jump to line 1157 because the loop on line 1156 never started
1157 self.WriteNormal(f" {author}")
1159 if (email := getattr(dunderModule, "__email__", None)) is not None: 1159 ↛ 1162line 1159 didn't jump to line 1162 because the condition on line 1159 was always true
1160 self.WriteNormal(f"Email: {email}")
1162 if (version := getattr(dunderModule, "__version__", None)) is None: 1162 ↛ 1163line 1162 didn't jump to line 1163 because the condition on line 1162 was never true
1163 self.WriteNormal("Version: {RED}Version not set!".format(RED=Foreground.RED))
1164 else:
1165 currentVersion = PythonVersion.Parse(version)
1166 if packageName is None: 1166 ↛ 1167line 1166 didn't jump to line 1167 because the condition on line 1166 was never true
1167 update = ""
1168 elif (pypiVersion := self._GetLatestVersion(packageName, versionCheckTimeout)) is not None: 1168 ↛ 1172line 1168 didn't jump to line 1172 because the condition on line 1168 was always true
1169 latestVersion = PythonVersion.Parse(pypiVersion)
1170 update = f" (Update available: v{latestVersion})" if currentVersion < latestVersion else " (latest)"
1171 else:
1172 update = " (PyPI timeout)"
1173 self.WriteNormal(f"Version: v{version}{update}")
1175 if (projectURL := getattr(dunderModule, "__project_url__", None)) is not None: 1175 ↛ 1178line 1175 didn't jump to line 1178 because the condition on line 1175 was always true
1176 self.WriteNormal(f"Project: {projectURL}")
1178 if (documentationURL := getattr(dunderModule, "__documentation_url__", None)) is not None: 1178 ↛ 1181line 1178 didn't jump to line 1181 because the condition on line 1178 was always true
1179 self.WriteNormal(f"Documentation: {documentationURL}")
1181 if (issueTrackerURL := getattr(dunderModule, "__issue_tracker_url__", None)) is not None: 1181 ↛ exitline 1181 didn't return from function '_PrintVersion' because the condition on line 1181 was always true
1182 self.WriteNormal(f"Issue tracker: {issueTrackerURL}")
1184 def _GetLatestVersion(self, packageName: str, timeout: int = 1) -> Nullable[str]:
1185 """
1186 Query PyPI for the latest released version of a package.
1188 Every error - an unreachable index, a timeout, an unknown package - is answered with ``None``, because a version
1189 check must not fail the application it is printing the version of.
1191 :param packageName: Optional, name of the package on PyPI.
1192 :param timeout: Optional, timeout in seconds for the request. Default: ``1``.
1193 :returns: The latest version as a string, or ``None``, if it couldn't be determined.
1194 """
1195 from json import loads
1196 from urllib.request import urlopen, Request
1198 request = Request(
1199 url=f"https://pypi.org/pypi/{packageName}/json",
1200 headers={'User-Agent': f'{packageName}-Version-Check'}
1201 )
1202 try:
1203 with urlopen(request, timeout=timeout) as response:
1204 data: dict[str, dict[str, str]] = loads(response.read().decode())
1205 return data["info"]["version"]
1206 except Exception:
1207 return None
1209 def Configure(
1210 self,
1211 *,
1212 verbose: bool = False,
1213 debug: bool = False,
1214 silent: bool = False,
1215 quiet: bool = False,
1216 writeToStdOut: bool = True
1217 ) -> None:
1218 """
1219 Configure the verbosity of the application, usually from the command line switches.
1221 The resulting :attr:`LogLevel` is the minimum severity a message needs to be written: ``Severity.Debug`` in debug
1222 mode, ``Severity.Verbose`` in verbose mode, ``Severity.Silent`` in silent mode, ``Severity.Quiet`` in quiet mode,
1223 otherwise ``Severity.Normal``. Debug mode implies verbose mode.
1225 :param verbose: Optional, write verbose messages. Default: ``False``.
1226 :param debug: Optional, write debug messages, implying verbose messages. Default: ``False``.
1227 :param silent: Optional, reduce the messages to warnings and higher severities. Default: ``False``.
1228 :param quiet: Optional, reduce the messages to errors and always visible messages. Default: ``False``.
1229 :param writeToStdOut: Optional, write to ``STDOUT``. Default: ``True``.
1230 """
1231 self._verbose = True if debug else verbose
1232 self._debug = debug
1233 self._silent = silent
1234 self._quiet = quiet
1236 if quiet:
1237 self._writeLevel = Severity.Quiet
1238 elif silent: 1238 ↛ 1239line 1238 didn't jump to line 1239 because the condition on line 1238 was never true
1239 self._writeLevel = Severity.Silent
1240 elif debug: 1240 ↛ 1241line 1240 didn't jump to line 1241 because the condition on line 1240 was never true
1241 self._writeLevel = Severity.Debug
1242 elif verbose: 1242 ↛ 1243line 1242 didn't jump to line 1243 because the condition on line 1242 was never true
1243 self._writeLevel = Severity.Verbose
1244 else:
1245 self._writeLevel = Severity.Normal
1247 self._writeToStdOut = writeToStdOut
1249 @readonly
1250 def Verbose(self) -> bool:
1251 """
1252 Check if verbose messages are enabled.
1254 :returns: ``True``, if verbose messages are written.
1255 """
1256 return self._verbose
1258 @readonly
1259 def Debug(self) -> bool:
1260 """
1261 Check if debug messages are enabled.
1263 :returns: ``True``, if debug messages are written.
1264 """
1265 return self._debug
1267 @readonly
1268 def Silent(self) -> bool:
1269 """
1270 Check if silent mode is enabled.
1272 :returns: ``True``, if silent mode is enabled.
1273 """
1274 return self._silent
1276 @readonly
1277 def Quiet(self) -> bool:
1278 """
1279 Check if quiet mode is enabled.
1281 :returns: ``True``, if quiet mode is enabled.
1282 """
1283 return self._quiet
1285 @property
1286 def LogLevel(self) -> Severity:
1287 """
1288 Property to access the minimal severity level a message needs to be written (:attr:`_writeLevel`).
1290 Assigning a level replaces what :meth:`Configure` computed from the verbosity switches.
1292 :returns: The current minimal severity level.
1293 """
1294 return self._writeLevel
1296 @LogLevel.setter
1297 def LogLevel(self, value: Severity) -> None:
1298 self._writeLevel = value
1300 @property
1301 def BaseIndent(self) -> int:
1302 """
1303 Property to access the base indentation level of written messages (:attr:`_baseIndent`).
1305 The assigned level is added to every message's own indentation.
1307 :returns: Base indentation level.
1308 """
1309 return self._baseIndent
1311 @BaseIndent.setter
1312 def BaseIndent(self, value: int) -> None:
1313 self._baseIndent = value
1315 @readonly
1316 def WarningCount(self) -> int:
1317 """
1318 Read-only property to access the number of counted warnings.
1320 :returns: Number of warnings.
1321 """
1322 return self._warningCount
1324 @readonly
1325 def CriticalWarningCount(self) -> int:
1326 """
1327 Read-only property to access the number of counted critical warnings.
1329 :returns: Number of critical warnings.
1330 """
1331 return self._criticalWarningCount
1333 @readonly
1334 def ErrorCount(self) -> int:
1335 """
1336 Read-only property to access the number of counted errors.
1338 :returns: Number of errors.
1339 """
1340 return self._errorCount
1342 @readonly
1343 def Lines(self) -> list[Line]:
1344 """
1345 Read-only property to access the list of printed lines (messages).
1347 :returns: List of lines.
1348 """
1349 return self._lines
1351 def ExitOnPreviousErrors(self) -> None:
1352 """
1353 Exit application if errors have been printed.
1354 """
1355 if self._errorCount > 0:
1356 self.WriteFatal("Too many errors in previous steps.")
1358 def ExitOnPreviousCriticalWarnings(
1359 self,
1360 includeErrors: bool = True
1361 ) -> None:
1362 """
1363 Exit application if error or critical warnings have been printed.
1365 :param includeErrors: Optional, if ``True``, count previous errors as well as critical warnings.
1366 """
1367 if includeErrors and (self._errorCount > 0): 1367 ↛ 1368line 1367 didn't jump to line 1368 because the condition on line 1367 was never true
1368 if self._criticalWarningCount > 0:
1369 self.WriteFatal("Too many errors and critical warnings in previous steps.")
1370 else:
1371 self.WriteFatal("Too many errors in previous steps.")
1372 elif self._criticalWarningCount > 0:
1373 self.WriteFatal("Too many critical warnings in previous steps.")
1375 def ExitOnPreviousWarnings(
1376 self,
1377 includeCriticalWarnings: bool = True,
1378 includeErrors: bool = True
1379 ) -> None:
1380 """
1381 Exit application if error or (critical) warnings have been printed.
1383 :param includeCriticalWarnings: Optional, if ``True``, count previous critical warnings as well as warnings.
1384 :param includeErrors: Optional, if ``True``, count previous errors as well.
1385 """
1386 if includeErrors and (self._errorCount > 0): 1386 ↛ 1387line 1386 didn't jump to line 1387 because the condition on line 1386 was never true
1387 if includeCriticalWarnings and (self._criticalWarningCount > 0):
1388 if self._warningCount > 0:
1389 self.WriteFatal("Too many errors and (critical) warnings in previous steps.")
1390 else:
1391 self.WriteFatal("Too many errors and critical warnings in previous steps.")
1392 elif self._warningCount > 0:
1393 self.WriteFatal("Too many warnings in previous steps.")
1394 else:
1395 self.WriteFatal("Too many errors in previous steps.")
1396 elif includeCriticalWarnings and (self._criticalWarningCount > 0): 1396 ↛ 1397line 1396 didn't jump to line 1397 because the condition on line 1396 was never true
1397 if self._warningCount > 0:
1398 self.WriteFatal("Too many (critical) warnings in previous steps.")
1399 else:
1400 self.WriteFatal("Too many critical warnings in previous steps.")
1401 elif self._warningCount > 0:
1402 self.WriteFatal("Too many warnings in previous steps.")
1404 def WriteLine(self, line: Line) -> bool:
1405 """
1406 Print a formatted line to the underlying terminal/console offered by the operating system.
1408 The message is indented by :attr:`INDENT` repeated :attr:`Line.Indent` times. The indentation is applied to the
1409 message, not to the whole line, so the severity markers stay in one column.
1411 :param line: Line object to indent, format and print.
1412 :returns: True, if line was actually written.
1413 """
1414 if line.Severity < self._writeLevel:
1415 return False
1417 self._lines.append(line)
1418 for method in self._LOG_LEVEL_ROUTING__[line.Severity]:
1419 indentedMessage = self.INDENT * line.Indent + line.Message
1420 method(self._LOG_MESSAGE_FORMAT__[line.Severity].format(message=indentedMessage, **self.Foreground), end="\n" if line.AppendLinebreak else "")
1422 return True
1424 def TryWriteLine(self, line) -> bool:
1425 """
1426 Check if a line object of a certain severity would be written.
1428 :param line: Line object to check.
1429 :returns: True, if line would be written.
1430 """
1431 severity: Severity = line.Severity # '@readonly' hands out 'Any' until it is typed - see T75
1432 return severity >= self._writeLevel
1434 def WriteFatal(
1435 self,
1436 message: str,
1437 *,
1438 indent: int = 0,
1439 appendLinebreak: bool = True,
1440 exitCode: int = 0,
1441 immediateExit: bool = True
1442 ) -> bool:
1443 """
1444 Write a fatal message and exit.
1446 Depending on internal settings and rules, a message might be skipped.
1448 :param message: Message to write.
1449 :param indent: Optional, indentation level of the message.
1450 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1451 :param exitCode: Optional, exit application with this exit code. Default: ``0`` |br|
1452 If ``0``, use :attr:`FATAL_EXIT_CODE` as exit code.
1453 :param immediateExit: Optional, exit application immediately. Default: ``True``
1454 :returns: True, if message was actually written.
1455 """
1456 ret = self.WriteLine(Line(message, Severity.Fatal, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1457 if immediateExit:
1458 self.FatalExit(exitCode)
1459 return ret
1461 def WriteError(
1462 self,
1463 message: str,
1464 *,
1465 indent: int = 0,
1466 appendLinebreak: bool = True
1467 ) -> bool:
1468 """
1469 Write an error message.
1471 Depending on internal settings and rules, a message might be skipped.
1473 :param message: Message to write.
1474 :param indent: Optional, indentation level of the message.
1475 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1476 :returns: True, if message was actually written.
1477 """
1478 self._errorCount += 1
1479 return self.WriteLine(Line(message, Severity.Error, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1481 def WriteQuiet(
1482 self,
1483 message: str,
1484 *,
1485 indent: int = 0,
1486 appendLinebreak: bool = True
1487 ) -> bool:
1488 """
1489 Write an always visible message.
1491 This message is even visible in quiet mode.
1493 Depending on internal settings and rules, a message might be skipped.
1495 :param message: Message to write.
1496 :param indent: Optional, indentation level of the message.
1497 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1498 :returns: True, if message was actually written.
1499 """
1500 return self.WriteLine(Line(message, Severity.Quiet, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1502 def WriteCritical(
1503 self,
1504 message: str,
1505 *,
1506 indent: int = 0,
1507 appendLinebreak: bool = True
1508 ) -> bool:
1509 """
1510 Write a critical message.
1512 Depending on internal settings and rules, a message might be skipped.
1514 :param message: Message to write.
1515 :param indent: Optional, indentation level of the message.
1516 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1517 :returns: True, if message was actually written.
1518 """
1519 self._criticalWarningCount += 1
1520 return self.WriteLine(Line(message, Severity.Critical, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1522 def WriteErrorNote(
1523 self,
1524 message: str,
1525 *,
1526 indent: int = 0,
1527 appendLinebreak: bool = True
1528 ) -> bool:
1529 """
1530 Write a note belonging to an error, which is where the advice for fixing it goes.
1532 Depending on internal settings and rules, a note might be skipped.
1534 :param message: Message to write.
1535 :param indent: Optional, indentation level of the note.
1536 :param appendLinebreak: Optional, append a linebreak after the note. Default: ``True``
1537 :returns: True, if note was actually written.
1538 """
1539 return self.WriteLine(
1540 Line(message, Severity.ErrorNote, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak)
1541 )
1543 def WriteCriticalNote(
1544 self,
1545 message: str,
1546 *,
1547 indent: int = 0,
1548 appendLinebreak: bool = True
1549 ) -> bool:
1550 """
1551 Write a critical note.
1553 Depending on internal settings and rules, a note might be skipped.
1555 :param message: Message to write.
1556 :param indent: Optional, indentation level of the note.
1557 :param appendLinebreak: Optional, append a linebreak after the note. Default: ``True``
1558 :returns: True, if note was actually written.
1559 """
1560 return self.WriteLine(Line(message, Severity.CriticalNote, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1562 def WriteWarning(
1563 self,
1564 message: str,
1565 *,
1566 indent: int = 0,
1567 appendLinebreak: bool = True
1568 ) -> bool:
1569 """
1570 Write a warning message.
1572 Depending on internal settings and rules, a message might be skipped.
1574 :param message: Message to write.
1575 :param indent: Optional, indentation level of the message.
1576 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1577 :returns: True, if message was actually written.
1578 """
1579 self._warningCount += 1
1580 return self.WriteLine(Line(message, Severity.Warning, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1582 def WriteWarningNote(
1583 self,
1584 message: str,
1585 *,
1586 indent: int = 0,
1587 appendLinebreak: bool = True
1588 ) -> bool:
1589 """
1590 Write a warning note.
1592 Depending on internal settings and rules, a note might be skipped.
1594 :param message: Message to write.
1595 :param indent: Optional, indentation level of the note.
1596 :param appendLinebreak: Optional, append a linebreak after the note. Default: ``True``
1597 :returns: True, if note was actually written.
1598 """
1599 return self.WriteLine(Line(message, Severity.WarningNote, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1601 def WriteInfo(
1602 self,
1603 message: str,
1604 *,
1605 indent: int = 0,
1606 appendLinebreak: bool = True
1607 ) -> bool:
1608 """
1609 Write an info message.
1611 Depending on internal settings and rules, a message might be skipped.
1613 :param message: Message to write.
1614 :param indent: Optional, indentation level of the message.
1615 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1616 :returns: True, if message was actually written.
1617 """
1618 return self.WriteLine(Line(message, Severity.Info, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1620 def WriteNormal(
1621 self,
1622 message: str,
1623 *,
1624 indent: int = 0,
1625 appendLinebreak: bool = True
1626 ) -> bool:
1627 """
1628 Write a normal message.
1630 Depending on internal settings and rules, a message might be skipped.
1632 :param message: Message to write.
1633 :param indent: Optional, indentation level of the message.
1634 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1635 :returns: True, if message was actually written.
1636 """
1637 return self.WriteLine(Line(message, Severity.Normal, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1639 def WriteVerbose(
1640 self,
1641 message: str,
1642 *,
1643 indent: int = 0,
1644 appendLinebreak: bool = True
1645 ) -> bool:
1646 """
1647 Write a verbose message.
1649 Depending on internal settings and rules, a message might be skipped.
1651 :param message: Message to write.
1652 :param indent: Optional, indentation level of the message.
1653 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1654 :returns: True, if message was actually written.
1655 """
1656 return self.WriteLine(Line(message, Severity.Verbose, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1658 def WriteDebug(
1659 self,
1660 message: str,
1661 *,
1662 indent: int = 0,
1663 appendLinebreak: bool = True
1664 ) -> bool:
1665 """
1666 Write a debug message.
1668 Depending on internal settings and rules, a message might be skipped.
1670 :param message: Message to write.
1671 :param indent: Optional, indentation level of the message.
1672 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1673 :returns: True, if message was actually written.
1674 """
1675 return self.WriteLine(Line(message, Severity.Debug, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1677 def WriteDryRun(
1678 self,
1679 message: str,
1680 *,
1681 indent: int = 0,
1682 appendLinebreak: bool = True
1683 ) -> bool:
1684 """
1685 Write a dry-run message message.
1687 Depending on internal settings and rules, a message might be skipped.
1689 :param message: Message to write.
1690 :param indent: Optional, indentation level of the message.
1691 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1692 :returns: True, if message was actually written.
1693 """
1694 return self.WriteLine(Line(message, Severity.DryRun, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))