Coverage for pyTooling/TerminalUI/__init__.py: 73%
645 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 12:33 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 12:33 +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.
35Colors need the optional package colorama (extra ``terminal``); without it, the output is uncolored text.
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 os import getenv
52from sys import stdin, stdout, stderr
53from textwrap import dedent
54from types import ModuleType
55from typing import NoReturn, Any, Optional as Nullable, Callable, ClassVar, Protocol
57from pyTooling.Decorators import export, readonly, InheritDocString
58from pyTooling.MetaClasses import ExtendedType, mixin
59from pyTooling.Exceptions import MissingDependencyError, PlatformNotSupportedError, ExceptionBase
60from pyTooling.Common import lastItem, getFullyQualifiedName
61from pyTooling.Platform import Platform
62from pyTooling.Versioning import PythonVersion
65@export
66class TerminalBaseApplication(metaclass=ExtendedType, slots=True, singleton=True):
67 """
68 The class offers a basic terminal application base-class.
70 It offers basic colored output via :gh:`colorama <tartley/colorama>` as well as retrieving the
71 terminal's width.
72 """
74 NOT_IMPLEMENTED_EXCEPTION_EXIT_CODE: ClassVar[int] = 240 #: Return code, if unimplemented methods or code sections were called.
75 UNHANDLED_EXCEPTION_EXIT_CODE: ClassVar[int] = 241 #: Return code, if an unhandled exception reached the topmost exception handler.
76 MISSING_DEPENDENCY_EXIT_CODE: ClassVar[int] = MissingDependencyError.EXIT_CODE #: Return code (242), if an optional dependency is missing; the value lives on the exception - see :meth:`PrintMissingDependencyError`.
77 FATAL_EXIT_CODE: ClassVar[int] = 255 #: Return code for fatal exits.
78 ISSUE_TRACKER_URL: ClassVar[str] = None #: URL to the issue tracker for reporting bugs.
79 INDENT: ClassVar[str] = " " #: Indentation. Default: ``" "`` (2 spaces)
81 try:
82 from colorama import Fore as Foreground
83 FOREGROUND: ClassVar[dict[str, str]] = {
84 "RED": Foreground.LIGHTRED_EX,
85 "DARK_RED": Foreground.RED,
86 "GREEN": Foreground.LIGHTGREEN_EX,
87 "DARK_GREEN": Foreground.GREEN,
88 "YELLOW": Foreground.LIGHTYELLOW_EX,
89 "DARK_YELLOW": Foreground.YELLOW,
90 "MAGENTA": Foreground.LIGHTMAGENTA_EX,
91 "BLUE": Foreground.LIGHTBLUE_EX,
92 "DARK_BLUE": Foreground.BLUE,
93 "CYAN": Foreground.LIGHTCYAN_EX,
94 "DARK_CYAN": Foreground.CYAN,
95 "GRAY": Foreground.WHITE,
96 "DARK_GRAY": Foreground.LIGHTBLACK_EX,
97 "WHITE": Foreground.LIGHTWHITE_EX,
98 "NOCOLOR": Foreground.RESET,
100 "HEADLINE": Foreground.LIGHTMAGENTA_EX,
101 "ERROR": Foreground.LIGHTRED_EX,
102 "WARNING": Foreground.LIGHTYELLOW_EX
103 } #: Terminal colors
104 except ImportError: # pragma: no cover
105 FOREGROUND: ClassVar[dict[str, str]] = {
106 "RED": "",
107 "DARK_RED": "",
108 "GREEN": "",
109 "DARK_GREEN": "",
110 "YELLOW": "",
111 "DARK_YELLOW": "",
112 "MAGENTA": "",
113 "BLUE": "",
114 "DARK_BLUE": "",
115 "CYAN": "",
116 "DARK_CYAN": "",
117 "GRAY": "",
118 "DARK_GRAY": "",
119 "WHITE": "",
120 "NOCOLOR": "",
122 "HEADLINE": "",
123 "ERROR": "",
124 "WARNING": ""
125 } #: Terminal colors
127 _stdin: TextIOWrapper #: STDIN
128 _stdout: TextIOWrapper #: STDOUT
129 _stderr: TextIOWrapper #: STDERR
130 _width: int #: Terminal width in characters
131 _height: int #: Terminal height in characters
133 def __init__(self) -> None:
134 """
135 Initialize a terminal.
137 If the Python package `colorama <https://pypi.org/project/colorama/>`_ [#f_colorama]_ is available, then initialize
138 it for colored outputs.
140 .. [#f_colorama] Colorama on GitHub: :gh:`tartley/colorama`
141 """
143 self._stdin = stdin
144 self._stdout = stdout
145 self._stderr = stderr
146 if stdout.isatty(): 146 ↛ 147line 146 didn't jump to line 147 because the condition on line 146 was never true
147 self.InitializeColors()
148 else:
149 self.UninitializeColors()
150 self._width, self._height = self.GetTerminalSize()
152 def InitializeColors(self) -> bool:
153 """
154 Initialize the terminal for color support by :gh:`colorama <tartley/colorama>`.
156 :returns: True, if 'colorama' package could be imported and initialized.
157 """
158 try:
159 from colorama import init
161 init()
162 return True
163 except ImportError: # pragma: no cover
164 return False
166 def UninitializeColors(self) -> bool:
167 """
168 Uninitialize the terminal for color support by :gh:`colorama <tartley/colorama>`.
170 :returns: True, if 'colorama' package could be imported and uninitialized.
171 """
172 try:
173 from colorama import deinit
175 deinit()
176 return True
177 except ImportError: # pragma: no cover
178 return False
180 @readonly
181 def Width(self) -> int:
182 """
183 Read-only property to access the terminal's width.
185 :returns: The terminal window's width in characters.
186 """
187 return self._width
189 @readonly
190 def Height(self) -> int:
191 """
192 Read-only property to access the terminal's height.
194 :returns: The terminal window's height in characters.
195 """
196 return self._height
198 @staticmethod
199 def GetTerminalSize() -> tuple[int, int]:
200 """
201 Returns the terminal size as tuple (width, height) for Windows, macOS (Darwin), Linux, cygwin (Windows), MinGW32/64 (Windows).
203 :returns: A tuple containing width and height of the terminal's size in characters.
204 :raises PlatformNotSupportedError: When a platform is not yet supported.
205 """
206 platform = Platform()
207 if platform.IsNativeWindows:
208 size = TerminalBaseApplication.__GetTerminalSizeOnWindows()
209 elif (platform.IsNativeLinux or platform.IsNativeFreeBSD or platform.IsNativeMacOS or platform.IsMinGW32OnWindows or platform.IsMinGW64OnWindows
210 or platform.IsUCRT64OnWindows or platform.IsCygwin32OnWindows or platform.IsClang64OnWindows):
211 size = TerminalBaseApplication.__GetTerminalSizeOnLinux()
212 else: # pragma: no cover
213 raise PlatformNotSupportedError(f"Platform '{platform}' not yet supported.")
215 if size is None: # pragma: no cover
216 size = (80, 25) # default size
218 return size
220 @staticmethod
221 def __GetTerminalSizeOnWindows() -> Nullable[tuple[int, int]]:
222 """
223 Returns the current terminal window's size for Windows.
225 ``kernel32.dll:GetConsoleScreenBufferInfo()`` is used to retrieve the information.
227 :returns: A tuple containing width and height of the terminal's size in characters.
228 """
229 try:
230 from ctypes import windll, create_string_buffer
231 from struct import unpack as struct_unpack
233 hStdError = windll.kernel32.GetStdHandle(-12) # stderr handle = -12
234 stringBuffer = create_string_buffer(22)
235 result = windll.kernel32.GetConsoleScreenBufferInfo(hStdError, stringBuffer)
236 if result: 236 ↛ 237line 236 didn't jump to line 237 because the condition on line 236 was never true
237 bufx, bufy, curx, cury, wattr, left, top, right, bottom, maxx, maxy = struct_unpack("hhhhHhhhhhh", stringBuffer.raw)
238 width = right - left + 1
239 height = bottom - top + 1
240 return width, height
241 except ImportError:
242 pass
244 return None
245 # return Terminal.__GetTerminalSizeWithTPut()
247 # @staticmethod
248 # def __GetTerminalSizeWithTPut() -> tuple[int, int]:
249 # """
250 # Returns the current terminal window's size for Windows.
251 #
252 # ``tput`` is used to retrieve the information.
253 #
254 # :returns: A tuple containing width and height of the terminal's size in characters.
255 # """
256 # from subprocess import check_output
257 #
258 # try:
259 # width = int(check_output(("tput", "cols")))
260 # height = int(check_output(("tput", "lines")))
261 # return (width, height)
262 # except:
263 # pass
265 @staticmethod
266 def __GetTerminalSizeOfFileDescriptor(fd: int) -> Nullable[tuple[int, int]]:
267 """
268 Get window size of a file descriptor.
270 Call `ioctl` with ``TIOCGWINSZ`` (GetWindowsSize) for the given file descriptor.
272 :param fd: File descriptor to query.
273 :returns: A 2-tuple of terminal width and height, or ``None`` if the size couldn't be determined.
274 """
275 try:
276 from array import array
277 from fcntl import ioctl
278 from termios import TIOCGWINSZ
279 except ImportError:
280 return None
282 # Allocate an array of 4x unsigned short (C struct)
283 # H = unsigned short (16-bit)
284 buffer = array('H', [0, 0, 0, 0]) # rows, columns, x-pixels, y-pixels
285 try:
286 ioctl(fd, TIOCGWINSZ, buffer, True)
287 return buffer[1], buffer[0]
288 except OSError:
289 return None
291 @staticmethod
292 def __GetTerminalSizeOnLinux() -> Nullable[tuple[int, int]]:
293 """
294 Returns the current terminal window's size for Linux.
296 ``ioctl(TIOCGWINSZ)`` is used to retrieve the information. As a fallback, environment variables ``COLUMNS`` and
297 ``LINES`` are checked.
299 :returns: A tuple containing width and height of the terminal's size in characters.
300 """
301 # STDIN, STDOUT, STDERR
302 for fd in range(3):
303 if (size := TerminalBaseApplication.__GetTerminalSizeOfFileDescriptor(fd)) is not None: 303 ↛ 304line 303 didn't jump to line 304 because the condition on line 303 was never true
304 return size
306 # Fallback
307 fd = None
308 try:
309 from os import open, close, ctermid, O_RDONLY
311 fd = open(ctermid(), O_RDONLY)
312 if (size := TerminalBaseApplication.__GetTerminalSizeOfFileDescriptor(fd)) is not None:
313 return size
314 except (ImportError, OSError):
315 # ImportError - If ctermid is not available (e.g. MSYS2)
316 # OSError - If ctermid() or open() fails
317 pass
318 finally:
319 if fd is not None: 319 ↛ 320line 319 didn't jump to line 320 because the condition on line 319 was never true
320 try:
321 close(fd)
322 except OSError:
323 pass
325 # Fall-fallback
326 try:
327 columns = int(getenv("COLUMNS"))
328 lines = int(getenv("LINES"))
329 return columns, lines
330 except TypeError:
331 pass
333 return None
335 def WriteToStdOut(self, message: str) -> int:
336 """
337 Low-level method for writing to ``STDOUT``.
339 :param message: Message to write to ``STDOUT``.
340 :returns: Number of written characters.
341 """
342 return self._stdout.write(message)
344 def WriteLineToStdOut(self, message: str, end: str = "\n") -> int:
345 """
346 Low-level method for writing to ``STDOUT``.
348 :param message: Message to write to ``STDOUT``.
349 :param end: Optional, use newline character. Default: ``\\n``.
350 :returns: Number of written characters.
351 """
352 return self._stdout.write(message + end)
354 def WriteToStdErr(self, message: str) -> int:
355 """
356 Low-level method for writing to ``STDERR``.
358 :param message: Message to write to ``STDERR``.
359 :returns: Number of written characters.
360 """
361 return self._stderr.write(message)
363 def WriteLineToStdErr(self, message: str, end: str = "\n") -> int:
364 """
365 Low-level method for writing to ``STDERR``.
367 :param message: Message to write to ``STDERR``.
368 :param end: Optional, use newline character. Default: ``\\n``.
369 :returns: Number of written characters.
370 """
371 return self._stderr.write(message + end)
373 def FatalExit(self, returnCode: int = 0) -> NoReturn:
374 """
375 Exit the terminal application by uninitializing color support and returning a fatal Exit code.
377 :param returnCode: Optional, return code for application exit.
378 """
379 self.Exit(self.FATAL_EXIT_CODE if returnCode == 0 else returnCode)
381 def Exit(self, returnCode: int = 0) -> NoReturn:
382 """
383 Exit the terminal application by uninitializing color support and returning an Exit code.
385 :param returnCode: Optional, return code for application exit.
386 """
387 self.UninitializeColors()
388 exit(returnCode)
390 def PrintException(self, ex: Exception) -> NoReturn:
391 """
392 Prints an exception of type :exc:`Exception` and its traceback.
394 If the exception as a nested action, the cause is printed as well.
396 If ``ISSUE_TRACKER_URL`` is configured, a URL to the issue tracker is added.
398 :param ex: The exception to print.
399 """
400 from traceback import format_tb, walk_tb
402 frame, sourceLine = lastItem(walk_tb(ex.__traceback__))
403 filename = frame.f_code.co_filename
404 funcName = frame.f_code.co_name
406 exceptionType = getFullyQualifiedName(ex)
408 message = "{RED}[FATAL] An unknown or unhandled exception reached the topmost exception handler!{NOCOLOR}\n"
409 message += f"{{indent}}{{YELLOW}}Exception type:{{NOCOLOR}} {{DARK_RED}}{exceptionType}{{NOCOLOR}}\n"
410 message += f"{{indent}}{{YELLOW}}Exception message:{{NOCOLOR}} {{RED}}{ex!s}{{NOCOLOR}}\n"
412 if hasattr(ex, "__notes__") and len(ex.__notes__) > 0:
413 note = next(iterator := iter(ex.__notes__))
414 message += f"{{indent}}{{YELLOW}}Notes:{{NOCOLOR}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n"
415 for note in iterator:
416 message += f"{{indent}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n"
418 message += f"{{indent}}{{YELLOW}}Caused in:{{NOCOLOR}} {funcName}(...) in file '{filename}' at line {sourceLine}\n"
420 if (ex2 := ex.__cause__) is not None:
421 causeType = getFullyQualifiedName(ex2)
423 message += f"{{indent2}}{{DARK_YELLOW}}Caused by ex. type:{{NOCOLOR}} {{DARK_RED}}{causeType}{{NOCOLOR}}\n"
424 message += f"{{indent2}}{{DARK_YELLOW}}Caused by message:{{NOCOLOR}} {ex2!s}{{NOCOLOR}}\n"
426 if hasattr(ex2, "__notes__") and len(ex2.__notes__) > 0: 426 ↛ 432line 426 didn't jump to line 432 because the condition on line 426 was always true
427 note = next(iterator := iter(ex2.__notes__))
428 message += f"{{indent2}}{{DARK_YELLOW}}Notes:{{NOCOLOR}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n"
429 for note in iterator:
430 message += f"{{indent2}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n"
432 message += f"{{indent}}{{RED}}{'-' * 120}{{NOCOLOR}}\n"
433 for line in format_tb(ex.__traceback__):
434 message += f"{line.replace('{', '{{').replace('}', '}}')}"
435 message += f"{{indent}}{{RED}}{'-' * 120}{{NOCOLOR}}"
437 if self.ISSUE_TRACKER_URL is not None:
438 message += f"\n{{indent}}{{DARK_CYAN}}Please report this bug at GitHub: {self.ISSUE_TRACKER_URL}{{NOCOLOR}}\n"
439 message += f"{{indent}}{{RED}}{'-' * 120}{{NOCOLOR}}"
441 self.WriteLineToStdErr(message.format(indent=self.INDENT, indent2=self.INDENT*2, **self.FOREGROUND))
442 self.Exit(self.UNHANDLED_EXCEPTION_EXIT_CODE)
444 def PrintMissingDependencyError(self, ex: MissingDependencyError) -> NoReturn:
445 """
446 Print a missing optional dependency and the command lines installing it.
448 Unlike the other printers, this one does **not** report a bug: there is no traceback, and no invitation to
449 open an issue, because nothing is wrong with the program - a package it can use is not installed. The message
450 names the missing package and every installation option the exception carries
451 (:attr:`~pyTooling.Exceptions.MissingDependencyError.InstallCommands`).
453 .. attention::
455 A dependency missing while a module is imported raises before an application object exists, so this method
456 cannot report that case. An application that wants to survive it catches the exception around its own imports
457 and prints the commands directly:
459 .. code-block:: python
461 from pyTooling.Exceptions import MissingDependencyError
463 try:
464 from myPackage.Plotting import Plot
465 except MissingDependencyError as ex:
466 print(f"{ex}\\n" + "\\n".join(f" {command}" for command in ex.InstallCommands))
467 raise SystemExit(MissingDependencyError.EXIT_CODE) from ex
469 :param ex: The exception to print.
470 :returns: Never - the method exits the application with :attr:`MISSING_DEPENDENCY_EXIT_CODE`.
472 .. seealso::
474 :meth:`PrintException`
475 |rarr| Print an unhandled exception and its traceback.
476 :meth:`PrintNotImplementedError`
477 |rarr| Print a call to an unimplemented function or abstract method.
478 """
479 message = "{RED}[MISSING DEPENDENCY] An optional dependency is not installed!{NOCOLOR}\n"
480 message += f"{{indent}}{{YELLOW}}Missing package:{{NOCOLOR}} {{DARK_RED}}{ex.Dependency}{{NOCOLOR}}\n"
482 commands = iter(ex.InstallCommands)
483 message += f"{{indent}}{{YELLOW}}Install it with:{{NOCOLOR}} {{DARK_CYAN}}{next(commands)}{{NOCOLOR}}\n"
484 for command in commands:
485 message += f"{{indent}} {{DARK_CYAN}}{command}{{NOCOLOR}}\n"
487 if (cause := ex.__cause__) is not None:
488 message += f"{{indent}}{{YELLOW}}Caused by:{{NOCOLOR}} {{RED}}{cause!s}{{NOCOLOR}}\n"
490 self.WriteLineToStdErr(message.format(indent=self.INDENT, indent2=self.INDENT * 2, **self.FOREGROUND))
491 self.Exit(self.MISSING_DEPENDENCY_EXIT_CODE)
493 def PrintNotImplementedError(self, ex: NotImplementedError) -> NoReturn:
494 """
495 Prints a not-implemented exception of type :exc:`NotImplementedError`.
497 If ``ISSUE_TRACKER_URL`` is configured, a URL to the issue tracker is added.
499 :param ex: The exception to print.
500 """
501 from traceback import walk_tb
503 frame, sourceLine = lastItem(walk_tb(ex.__traceback__))
504 filename = frame.f_code.co_filename
505 funcName = frame.f_code.co_name
507 message = "{RED}[NOT IMPLEMENTED] An unimplemented function or abstract method was called!{NOCOLOR}\n"
508 message += f"{{indent}}{{YELLOW}}Function or method:{{NOCOLOR}} {{DARK_RED}}{funcName}(...){{NOCOLOR}}\n"
509 message += f"{{indent}}{{YELLOW}}Exception message:{{NOCOLOR}} {{RED}}{ex!s}{{NOCOLOR}}\n"
511 if hasattr(ex, "__notes__") and len(ex.__notes__) > 0: 511 ↛ 512line 511 didn't jump to line 512 because the condition on line 511 was never true
512 note = next(iterator := iter(ex.__notes__))
513 message += f"{{indent}}{{YELLOW}}Notes:{{NOCOLOR}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n"
514 for note in iterator:
515 message += f"{{indent}} {{DARK_CYAN}}{note}{{NOCOLOR}}\n"
517 message += f"{{indent}}{{YELLOW}}Caused in:{{NOCOLOR}} {funcName}(...) in file '{filename}' at line {sourceLine}\n"
519 if self.ISSUE_TRACKER_URL is not None: 519 ↛ 523line 519 didn't jump to line 523 because the condition on line 519 was always true
520 message += f"\n{{indent}}{{DARK_CYAN}}Please report this bug at GitHub: {self.ISSUE_TRACKER_URL}{{NOCOLOR}}\n"
521 message += f"{{indent}}{{RED}}{'-' * 120}{{NOCOLOR}}"
523 self.WriteLineToStdErr(message.format(indent=self.INDENT, indent2=self.INDENT * 2, **self.FOREGROUND))
524 self.Exit(self.NOT_IMPLEMENTED_EXCEPTION_EXIT_CODE)
526 def PrintExceptionBase(self, ex: Exception) -> NoReturn:
527 """
528 Prints an exception of type :exc:`~pyTooling.Exceptions.ExceptionBase` and its traceback.
530 If the exception as a nested action, the cause is printed as well.
532 If ``ISSUE_TRACKER_URL`` is configured, a URL to the issue tracker is added.
534 :param ex: The exception to print.
535 """
536 from traceback import print_tb, walk_tb
538 frame, sourceLine = lastItem(walk_tb(ex.__traceback__))
539 filename = frame.f_code.co_filename
540 funcName = frame.f_code.co_name
542 exceptionType = getFullyQualifiedName(ex)
544 self.WriteLineToStdErr(dedent(f"""\
545 {{RED}}[FATAL] A known but unhandled exception reached the topmost exception handler!{{NOCOLOR}}
546 {{indent}}{{YELLOW}}Exception type:{{NOCOLOR}} {{DARK_RED}}{exceptionType}{{NOCOLOR}}
547 {{indent}}{{YELLOW}}Exception message:{{NOCOLOR}} {{RED}}{ex!s}{{NOCOLOR}}
548 {{indent}}{{YELLOW}}Caused in:{{NOCOLOR}} {funcName}(...) in file '{filename}' at line {sourceLine}\
549 """).format(indent=self.INDENT, **self.FOREGROUND))
551 if ex.__cause__ is not None:
552 causeType = getFullyQualifiedName(ex.__cause__)
554 self.WriteLineToStdErr(dedent(f"""\
555 {{indent2}}{{DARK_YELLOW}}Caused by ex. type:{{NOCOLOR}} {{DARK_RED}}{causeType}{{NOCOLOR}}
556 {{indent2}}{{DARK_YELLOW}}Caused by message:{{NOCOLOR}} {{RED}}{ex.__cause__!s}{{NOCOLOR}}\
557 """).format(indent2=self.INDENT * 2, **self.FOREGROUND))
559 self.WriteLineToStdErr(f"""{{indent}}{{RED}}{'-' * 80}{{NOCOLOR}}""".format(indent=self.INDENT, **self.FOREGROUND))
560 print_tb(ex.__traceback__, file=self._stderr)
561 self.WriteLineToStdErr(f"""{{indent}}{{RED}}{'-' * 80}{{NOCOLOR}}""".format(indent=self.INDENT, **self.FOREGROUND))
563 if self.ISSUE_TRACKER_URL is not None: 563 ↛ 569line 563 didn't jump to line 569 because the condition on line 563 was always true
564 self.WriteLineToStdErr(dedent(f"""\
565 {{indent}}{{DARK_CYAN}}Please report this bug at GitHub: {self.ISSUE_TRACKER_URL}{{NOCOLOR}}
566 {{indent}}{{RED}}{'-' * 80}{{NOCOLOR}}\
567 """).format(indent=self.INDENT, **self.FOREGROUND))
569 self.Exit(self.UNHANDLED_EXCEPTION_EXIT_CODE)
572@export
573@unique
574class Severity(Enum):
575 """Logging message severity levels."""
577 Exception = 120 #: Unhandled exception messages
578 ExceptionCause = 115 #: Exception cause
579 ExceptionNote = 110 #: Exception notes
580 Fatal = 100 #: Fatal messages
581 Error = 80 #: Error messages
582 ErrorNote = 75 #: Error notes
583 Quiet = 70 #: Always visible messages, even in quiet mode.
585 Critical = 60 #: Critical messages
586 CriticalNote = 55 #: Critical notes
587 Warning = 50 #: Warning messages
588 WarningNote = 45 #: Warning notes
589 Silent = 40 #: Severity level for silenced messages.
591 Info = 20 #: Informative messages
592 Normal = 10 #: Normal messages
593 DryRun = 8 #: Messages visible in a dry-run
594 Verbose = 5 #: Verbose messages
595 Debug = 2 #: Debug messages
596 All = 0 #: All messages
598 def __hash__(self) -> int:
599 """
600 Compute a hash of the severity level, so it can be used as a key in a dictionary.
602 :returns: Hash of the severity level's name.
603 """
604 return hash(self.name)
606 def __eq__(self, other: Any) -> bool:
607 """
608 Compare two Severity instances (severity level) for equality.
610 :param other: Operand to compare against.
611 :returns: ``True``, if both severity levels are equal.
612 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
613 """
614 if isinstance(other, Severity):
615 return self.value == other.value
616 else:
617 ex = TypeError("Second operand is not supported by == operator.")
618 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
619 ex.add_note("Supported types for second operand: Severity")
620 raise ex
622 def __ne__(self, other: Any) -> bool:
623 """
624 Compare two Severity instances (severity level) for inequality.
626 :param other: Operand to compare against.
627 :returns: ``True``, if both severity levels are unequal.
628 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
629 """
630 if isinstance(other, Severity):
631 return self.value != other.value
632 else:
633 ex = TypeError("Second operand is not supported by != operator.")
634 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
635 ex.add_note("Supported types for second operand: Severity")
636 raise ex
638 def __lt__(self, other: Any) -> bool:
639 """
640 Compare two Severity instances (severity level) for less-than.
642 :param other: Operand to compare against.
643 :returns: ``True``, if severity levels is less than other severity level.
644 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
645 """
646 if isinstance(other, Severity):
647 return self.value < other.value
648 else:
649 ex = TypeError("Second operand is not supported by < operator.")
650 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
651 ex.add_note("Supported types for second operand: Severity")
652 raise ex
654 def __le__(self, other: Any) -> bool:
655 """
656 Compare two Severity instances (severity level) for less-than-or-equal.
658 :param other: Operand to compare against.
659 :returns: ``True``, if severity levels is less than or equal other severity level.
660 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
661 """
662 if isinstance(other, Severity):
663 return self.value <= other.value
664 else:
665 ex = TypeError("Second operand is not supported by <= operator.")
666 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
667 ex.add_note("Supported types for second operand: Severity")
668 raise ex
670 def __gt__(self, other: Any) -> bool:
671 """
672 Compare two Severity instances (severity level) for greater-than.
674 :param other: Operand to compare against.
675 :returns: ``True``, if severity levels is greater than other severity level.
676 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
677 """
678 if isinstance(other, Severity):
679 return self.value > other.value
680 else:
681 ex = TypeError("Second operand is not supported by > operator.")
682 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
683 ex.add_note("Supported types for second operand: Severity")
684 raise ex
686 def __ge__(self, other: Any) -> bool:
687 """
688 Compare two Severity instances (severity level) for greater-than-or-equal.
690 :param other: Operand to compare against.
691 :returns: ``True``, if severity levels is greater than or equal other severity level.
692 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
693 """
694 if isinstance(other, Severity):
695 return self.value >= other.value
696 else:
697 ex = TypeError("Second operand is not supported by >= operator.")
698 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
699 ex.add_note("Supported types for second operand: Severity")
700 raise ex
703@export
704@unique
705class Mode(Enum):
706 """Routing modes deciding to which stream (``STDOUT``/``STDERR``) a message of a certain severity is written."""
708 TextToStdOut_ErrorsToStdErr = 0 #: Warnings and higher severities to ``STDERR``, except :attr:`Severity.Quiet`.
709 AllLinearToStdOut = 1 #: All messages to ``STDOUT``, so the message order is preserved in a log file.
710 DataToStdOut_OtherToStdErr = 2 #: All messages to ``STDERR``, leaving ``STDOUT`` for the program's data.
713@export
714class Line(metaclass=ExtendedType, slots=True):
715 """
716 Represents a single message line with a severity and indentation level.
717 """
719 _LOG_MESSAGE_FORMAT__: ClassVar[dict[Severity, str]] = {
720 Severity.Exception: "EXCEPTION: {message}",
721 Severity.ExceptionNote: " > {message}",
722 Severity.Fatal: "FATAL: {message}",
723 Severity.Error: "ERROR: {message}",
724 Severity.ErrorNote: " > {message}",
725 Severity.Quiet: "{message}",
726 Severity.Critical: "CRITICAL: {message}",
727 Severity.CriticalNote: " > {message}",
728 Severity.Warning: "WARNING: {message}",
729 Severity.WarningNote: " > {message}",
730 Severity.Info: "INFO: {message}",
731 Severity.Normal: "{message}",
732 Severity.DryRun: "DRYRUN: {message}",
733 Severity.Verbose: "VERBOSE: {message}",
734 Severity.Debug: "DEBUG: {message}",
735 } #: Message line formatting rules.
737 _timestamp: datetime #: Timestamp when the line was created.
738 _message: str #: Text message (line content).
739 _severity: Severity #: Message severity
740 _indent: int #: Indentation
741 _appendLinebreak: bool #: True, if a trailing linebreak should be added when printing this line object.
743 def __init__(
744 self,
745 message: str,
746 severity: Severity = Severity.Normal,
747 *,
748 indent: int = 0,
749 appendLinebreak: bool = True
750 ) -> None:
751 """
752 Initialize a line object representing the single-line message.
754 :param message: Message to display.
755 :param severity: Optional, severity level of the message.
756 :param indent: Optional, indentation level of the message.
757 :param appendLinebreak: Optional, if ``True``, append a line break at the end of the message.
758 """
759 self._timestamp = datetime.now()
760 self._severity = severity
761 self._message = message
762 self._indent = indent
763 self._appendLinebreak = appendLinebreak
765 @readonly
766 def Message(self) -> str:
767 """
768 Read-only property to access the line's raw message.
770 :returns: Raw message of the line.
771 """
772 return self._message
774 @readonly
775 def Severity(self) -> Severity:
776 """
777 Read-only property to access the line's severity level.
779 :returns: Severity level of the message line.
780 """
781 return self._severity
783 @readonly
784 def Indent(self) -> int:
785 """
786 Read-only property to access the line's indentation level.
788 :returns: Indentation level of the message line.
789 """
790 return self._indent
792 def IndentBy(self, indent: int) -> int:
793 """
794 Increase a line's indentation level.
796 :param indent: Indentation level added to the current indentation level.
797 :returns: The new indentation level.
798 """
799 self._indent = (newIndent := self._indent + indent)
800 return newIndent
802 @readonly
803 def AppendLinebreak(self) -> bool:
804 """
805 Read-only property to access if a linebreak is added after the line's message.
807 :returns: True, if a linebreak should be added.
808 """
809 return self._appendLinebreak
811 def __str__(self) -> str:
812 """
813 Returns a formatted version of a ``Line`` objects as a string.
815 The formatting is defined in :attr:`_LOG_MESSAGE_FORMAT__`.
817 :returns: Formatted version of a ``Line`` object.
818 """
819 return self._LOG_MESSAGE_FORMAT__[self._severity].format(message=self._message)
822@export
823@mixin
824class ILineTerminal:
825 """A mixin class (interface) to provide class-local terminal writing methods."""
827 _terminal: Nullable[TerminalApplication] #: The terminal application the messages are written to.
829 def __init__(self, terminal: Nullable[TerminalApplication] = None) -> None:
830 """
831 Mixin initializer.
833 :param terminal: Optional, the terminal to write to. If ``None``, every writing method does nothing.
834 """
835 self._terminal = terminal
837 # FIXME: Alter methods if a terminal is present or set dummy methods
839 @readonly
840 def Terminal(self) -> Nullable[TerminalApplication]:
841 """
842 Read-only property to access the local terminal instance (:attr:`_terminal`).
844 :returns: The terminal instance, or ``None`` if no terminal is attached.
845 """
846 return self._terminal
848 def WriteLine(self, line: Line, condition: bool = True) -> bool:
849 """
850 Write a line to the local terminal if ``condition`` is ``True``.
852 :param line: Line object to write.
853 :param condition: Optional, write the line only if this condition is ``True``. Default: ``True``.
854 :returns: True, if the line was actually written.
855 """
856 if (self._terminal is not None) and condition:
857 return self._terminal.WriteLine(line)
858 return False
860 # def _TryWriteLine(self, *args: Any, condition: bool = True, **kwargs: Any):
861 # if (self._terminal is not None) and condition:
862 # return self._terminal.TryWrite(*args, **kwargs)
863 # return False
865 def WriteFatal(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
866 """
867 Write a fatal message to the local terminal if ``condition`` is ``True``.
869 :param args: Positional parameters forwarded to the terminal's writing method.
870 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
871 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
872 :returns: True, if the message was actually written.
873 """
874 if (self._terminal is not None) and condition:
875 return self._terminal.WriteFatal(*args, **kwargs)
876 return False
878 def WriteError(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
879 """
880 Write an error message to the local terminal if ``condition`` is ``True``.
882 :param args: Positional parameters forwarded to the terminal's writing method.
883 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
884 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
885 :returns: True, if the message was actually written.
886 """
887 if (self._terminal is not None) and condition:
888 return self._terminal.WriteError(*args, **kwargs)
889 return False
891 def WriteCritical(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
892 """
893 Write a critical warning message to the local terminal if ``condition`` is ``True``.
895 :param args: Positional parameters forwarded to the terminal's writing method.
896 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
897 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
898 :returns: True, if the message was actually written.
899 """
900 if (self._terminal is not None) and condition:
901 return self._terminal.WriteCritical(*args, **kwargs)
902 return False
904 def WriteWarning(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
905 """
906 Write a warning message to the local terminal if ``condition`` is ``True``.
908 :param args: Positional parameters forwarded to the terminal's writing method.
909 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
910 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
911 :returns: True, if the message was actually written.
912 """
913 if (self._terminal is not None) and condition:
914 return self._terminal.WriteWarning(*args, **kwargs)
915 return False
917 def WriteInfo(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
918 """
919 Write an info message to the local terminal if ``condition`` is ``True``.
921 :param args: Positional parameters forwarded to the terminal's writing method.
922 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
923 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
924 :returns: True, if the message was actually written.
925 """
926 if (self._terminal is not None) and condition:
927 return self._terminal.WriteInfo(*args, **kwargs)
928 return False
930 def WriteQuiet(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
931 """
932 Write an always visible message, even in quiet mode, to the local terminal if ``condition`` is ``True``.
934 :param args: Positional parameters forwarded to the terminal's writing method.
935 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
936 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
937 :returns: True, if the message was actually written.
938 """
939 if (self._terminal is not None) and condition:
940 return self._terminal.WriteQuiet(*args, **kwargs)
941 return False
943 def WriteNormal(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
944 """
945 Write a *normal* message to the local terminal if ``condition`` is ``True``.
947 :param args: Positional parameters forwarded to the terminal's writing method.
948 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
949 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
950 :returns: True, if the message was actually written.
951 """
952 if (self._terminal is not None) and condition:
953 return self._terminal.WriteNormal(*args, **kwargs)
954 return False
956 def WriteVerbose(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
957 """
958 Write a verbose message to the local terminal if ``condition`` is ``True``.
960 :param args: Positional parameters forwarded to the terminal's writing method.
961 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
962 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
963 :returns: True, if the message was actually written.
964 """
965 if (self._terminal is not None) and condition:
966 return self._terminal.WriteVerbose(*args, **kwargs)
967 return False
969 def WriteDebug(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
970 """
971 Write a debug message to the local terminal if ``condition`` is ``True``.
973 :param args: Positional parameters forwarded to the terminal's writing method.
974 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
975 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
976 :returns: True, if the message was actually written.
977 """
978 if (self._terminal is not None) and condition:
979 return self._terminal.WriteDebug(*args, **kwargs)
980 return False
982 def WriteDryRun(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
983 """
984 Write a dry-run message to the local terminal if ``condition`` is ``True``.
986 :param args: Positional parameters forwarded to the terminal's writing method.
987 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
988 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
989 :returns: True, if the message was actually written.
990 """
991 if (self._terminal is not None) and condition:
992 return self._terminal.WriteDryRun(*args, **kwargs)
993 return False
996@export
997class TerminalApplication(TerminalBaseApplication): #, ILineTerminal):
998 """
999 A base-class for implementation of terminal applications emitting line-by-line messages.
1000 """
1001 _LOG_MESSAGE_FORMAT__: ClassVar[dict[Severity, str]] = {
1002 Severity.Exception: "{RED}[EXCEPTION] {message}{NOCOLOR}",
1003 Severity.ExceptionNote: "{DARK_RED} > {message}{NOCOLOR}",
1004 Severity.Fatal: "{DARK_RED}[FATAL] {message}{NOCOLOR}",
1005 Severity.Error: "{RED}[ERROR] {message}{NOCOLOR}",
1006 Severity.ErrorNote: "{DARK_RED} > {message}{NOCOLOR}",
1007 Severity.Quiet: "{WHITE}{message}{NOCOLOR}",
1008 Severity.Critical: "{DARK_YELLOW}[CRITICAL] {message}{NOCOLOR}",
1009 Severity.CriticalNote: "{DARK_YELLOW} > {message}{NOCOLOR}",
1010 Severity.Warning: "{YELLOW}[WARNING] {message}{NOCOLOR}",
1011 Severity.WarningNote: "{DARK_YELLOW} > {message}{NOCOLOR}",
1012 Severity.Info: "{WHITE}{message}{NOCOLOR}",
1013 Severity.Normal: "{WHITE}{message}{NOCOLOR}",
1014 Severity.DryRun: "{DARK_CYAN}[DRY] {message}{NOCOLOR}",
1015 Severity.Verbose: "{GRAY}{message}{NOCOLOR}",
1016 Severity.Debug: "{DARK_GRAY}{message}{NOCOLOR}"
1017 } #: Message formatting rules.
1019 _LOG_LEVEL_ROUTING__: dict[Severity, tuple[Callable[[str, str], int]]] #: Message routing rules.
1020 _verbose: bool #: ``True``, if verbose messages are written.
1021 _debug: bool #: ``True``, if debug messages are written.
1022 _silent: bool #: ``True``, if no messages are written at all.
1023 _quiet: bool #: ``True``, if only errors and quiet messages are written.
1024 _writeLevel: Severity #: Minimal severity a message needs to be written.
1025 _writeToStdOut: bool #: ``True``, if messages are written to ``STDOUT`` instead of ``STDERR``.
1027 _lines: list[Line] #: Every message written so far, in the order it was written.
1028 _baseIndent: int #: Indentation level added to every message's own indentation.
1030 _errorCount: int #: Number of errors written so far.
1031 _criticalWarningCount: int #: Number of critical warnings written so far.
1032 _warningCount: int #: Number of warnings written so far.
1034 HEADLINE: ClassVar[str] #: Headline of the application, printed by :meth:`_PrintHeadline`.
1036 def __init__(self, mode: Mode = Mode.AllLinearToStdOut) -> None:
1037 """
1038 Initializer of a line-based terminal interface.
1040 :param mode: Optional, defines what output (normal, error, data) to write where. Default: a linear flow all to
1041 *STDOUT*.
1042 """
1043 TerminalBaseApplication.__init__(self)
1044 # ILineTerminal.__init__(self, self)
1046 self._LOG_LEVEL_ROUTING__ = {}
1047 self.__InitializeLogLevelRouting(mode)
1049 self._verbose = False
1050 self._debug = False
1051 self._silent = False
1052 self._quiet = False
1053 self._writeLevel = Severity.Normal
1054 self._writeToStdOut = True
1056 self._lines = []
1057 self._baseIndent = 0
1059 self._errorCount = 0
1060 self._criticalWarningCount = 0
1061 self._warningCount = 0
1063 def __InitializeLogLevelRouting(self, mode: Mode = Mode.AllLinearToStdOut) -> None:
1064 """
1065 Expand a routing mode into a routing table containing one writing method per severity level.
1067 :param mode: Optional, routing mode to expand.
1068 :raises ExceptionBase: If the routing mode is not supported. |br|
1069 The note lists the modes that are supported.
1070 """
1071 if mode is Mode.TextToStdOut_ErrorsToStdErr:
1072 for severity in Severity:
1073 if severity >= Severity.Silent and severity != Severity.Quiet:
1074 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdErr,)
1075 else:
1076 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdOut,)
1077 elif mode is Mode.AllLinearToStdOut:
1078 for severity in Severity:
1079 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdOut, )
1080 elif mode is Mode.DataToStdOut_OtherToStdErr:
1081 for severity in Severity:
1082 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdErr, )
1083 else: # pragma: no cover
1084 ex = ExceptionBase(f"Unsupported mode '{mode}'.")
1085 ex.add_note(f"Unsupported modes '{', '.join(m.name for m in Mode)}'.")
1086 raise ex
1088 def _PrintHeadline(self, width: int = 80) -> None:
1089 """
1090 Helper method to print the program headline.
1092 :param width: Optional, number of characters for horizontal lines.
1094 .. admonition:: Generated output
1096 .. code-block::
1098 =========================
1099 centered headline
1100 =========================
1101 """
1102 if width == 0: 1102 ↛ 1103line 1102 didn't jump to line 1103 because the condition on line 1102 was never true
1103 width = self._width
1105 self.WriteNormal(f"{{HEADLINE}}{'=' * width}".format(**TerminalApplication.FOREGROUND))
1106 self.WriteNormal(f"{{HEADLINE}}{{headline: ^{width}s}}".format(headline=self.HEADLINE, **TerminalApplication.FOREGROUND))
1107 self.WriteNormal(f"{{HEADLINE}}{'=' * width}".format(**TerminalApplication.FOREGROUND))
1109 def _PrintVersion(
1110 self,
1111 dunderModule: ModuleType,
1112 packageName: Nullable[str] = None,
1113 versionCheckTimeout: int = 1
1114 ) -> None:
1115 """
1116 Helper method to print the version information.
1118 :param dunderModule: The Python module containing the dunder variables for author(s), email, copyright,
1119 version, ...
1120 :param packageName: Optional, name of the package on PyPI. If given, the latest released version is
1121 queried and reported as an available update. Default: ``None``.
1122 :param versionCheckTimeout: Optional, timeout in seconds for the PyPI request. Default: ``1``.
1124 .. admonition:: Example usage
1126 .. code-block:: Python
1128 def _PrintVersion(self):
1129 import myPackage.MyModule as DunderModule
1131 super()._PrintVersion(
1132 DunderModule,
1133 "MyModule"
1134 )
1135 """
1136 red = self.FOREGROUND["DARK_RED"]
1138 copyrights = getattr(dunderModule, "__copyright__", "{RED}Copyright not set!".format(RED=red)).split("\n", 1)
1139 self.WriteNormal(f"Copyright: {copyrights[0]}")
1140 for copyright in copyrights[1:]:
1141 self.WriteNormal(f" {copyright}")
1143 license = getattr(dunderModule, "__license__", "{RED}License not set!".format(RED=red))
1144 self.WriteNormal(f"License: {license}")
1146 authors = getattr(dunderModule, "__author__", "{RED}Unknown author!".format(RED=red)).split(", ")
1147 self.WriteNormal(f"Authors: {authors[0]}")
1148 for author in authors[1:]:
1149 self.WriteNormal(f" {author}")
1151 if (email := getattr(dunderModule, "__email__", None)) is not None:
1152 self.WriteNormal(f"Email: {email}")
1154 if (version := getattr(dunderModule, "__version__", None)) is None:
1155 self.WriteNormal("Version: {RED}Version not set!".format(RED=red))
1156 else:
1157 currentVersion = PythonVersion.Parse(version)
1158 if packageName is None:
1159 update = ""
1160 elif (pypiVersion := self._GetLatestVersion(packageName, versionCheckTimeout)) is not None:
1161 latestVersion = PythonVersion.Parse(pypiVersion)
1162 update = f" (Update available: v{latestVersion})" if currentVersion < latestVersion else " (latest)"
1163 else:
1164 update = " (PyPI timeout)"
1165 self.WriteNormal(f"Version: v{version}{update}")
1167 if (projectURL := getattr(dunderModule, "__project_url__", None)) is not None:
1168 self.WriteNormal(f"Project: {projectURL}")
1170 if (documentationURL := getattr(dunderModule, "__documentation_url__", None)) is not None:
1171 self.WriteNormal(f"Documentation: {documentationURL}")
1173 if (issueTrackerURL := getattr(dunderModule, "__issue_tracker_url__", None)) is not None:
1174 self.WriteNormal(f"Issue tracker: {issueTrackerURL}")
1176 def _GetLatestVersion(self, packageName: str, timeout: int = 1) -> Nullable[str]:
1177 """
1178 Query PyPI for the latest released version of a package.
1180 Every error - an unreachable index, a timeout, an unknown package - is answered with ``None``, because a version
1181 check must not fail the application it is printing the version of.
1183 :param packageName: Name of the package on PyPI.
1184 :param timeout: Optional, timeout in seconds for the request. Default: ``1``.
1185 :returns: The latest version as a string, or ``None``, if it couldn't be determined.
1186 """
1187 from json import loads
1188 from urllib.request import urlopen, Request
1190 request = Request(
1191 url=f"https://pypi.org/pypi/{packageName}/json",
1192 headers={'User-Agent': f'{packageName}-Version-Check'}
1193 )
1194 try:
1195 with urlopen(request, timeout=timeout) as response:
1196 data: dict[str, dict[str, str]] = loads(response.read().decode())
1197 return data["info"]["version"]
1198 except Exception:
1199 return None
1201 def Configure(
1202 self,
1203 *,
1204 verbose: bool = False,
1205 debug: bool = False,
1206 silent: bool = False,
1207 quiet: bool = False,
1208 writeToStdOut: bool = True
1209 ) -> None:
1210 """
1211 Configure the verbosity of the application, usually from the command line switches.
1213 The resulting :attr:`LogLevel` is the minimum severity a message needs to be written: ``Severity.Debug`` in debug
1214 mode, ``Severity.Verbose`` in verbose mode, ``Severity.Silent`` in silent mode, ``Severity.Quiet`` in quiet mode,
1215 otherwise ``Severity.Normal``. Debug mode implies verbose mode.
1217 :param verbose: Optional, write verbose messages. Default: ``False``.
1218 :param debug: Optional, write debug messages, implying verbose messages. Default: ``False``.
1219 :param silent: Optional, reduce the messages to warnings and higher severities. Default: ``False``.
1220 :param quiet: Optional, reduce the messages to errors and always visible messages. Default: ``False``.
1221 :param writeToStdOut: Optional, write to ``STDOUT``. Default: ``True``.
1222 """
1223 self._verbose = True if debug else verbose
1224 self._debug = debug
1225 self._silent = silent
1226 self._quiet = quiet
1228 if quiet: 1228 ↛ 1230line 1228 didn't jump to line 1230 because the condition on line 1228 was always true
1229 self._writeLevel = Severity.Quiet
1230 elif silent:
1231 self._writeLevel = Severity.Silent
1232 elif debug:
1233 self._writeLevel = Severity.Debug
1234 elif verbose:
1235 self._writeLevel = Severity.Verbose
1236 else:
1237 self._writeLevel = Severity.Normal
1239 self._writeToStdOut = writeToStdOut
1241 @readonly
1242 def Verbose(self) -> bool:
1243 """
1244 Check if verbose messages are enabled.
1246 :returns: ``True``, if verbose messages are written.
1247 """
1248 return self._verbose
1250 @readonly
1251 def Debug(self) -> bool:
1252 """
1253 Check if debug messages are enabled.
1255 :returns: ``True``, if debug messages are written.
1256 """
1257 return self._debug
1259 @readonly
1260 def Silent(self) -> bool:
1261 """
1262 Check if silent mode is enabled.
1264 :returns: ``True``, if silent mode is enabled.
1265 """
1266 return self._silent
1268 @readonly
1269 def Quiet(self) -> bool:
1270 """
1271 Check if quiet mode is enabled.
1273 :returns: ``True``, if quiet mode is enabled.
1274 """
1275 return self._quiet
1277 @property
1278 def LogLevel(self) -> Severity:
1279 """
1280 Property to access the minimal severity level a message needs to be written (:attr:`_writeLevel`).
1282 Assigning a level replaces what :meth:`Configure` computed from the verbosity switches.
1284 :returns: The current minimal severity level.
1285 """
1286 return self._writeLevel
1288 @LogLevel.setter
1289 def LogLevel(self, value: Severity) -> None:
1290 self._writeLevel = value
1292 @property
1293 def BaseIndent(self) -> int:
1294 """
1295 Property to access the base indentation level of written messages (:attr:`_baseIndent`).
1297 The assigned level is added to every message's own indentation.
1299 :returns: Base indentation level.
1300 """
1301 return self._baseIndent
1303 @BaseIndent.setter
1304 def BaseIndent(self, value: int) -> None:
1305 self._baseIndent = value
1307 @readonly
1308 def WarningCount(self) -> int:
1309 """
1310 Read-only property to access the number of counted warnings.
1312 :returns: Number of warnings.
1313 """
1314 return self._warningCount
1316 @readonly
1317 def CriticalWarningCount(self) -> int:
1318 """
1319 Read-only property to access the number of counted critical warnings.
1321 :returns: Number of critical warnings.
1322 """
1323 return self._criticalWarningCount
1325 @readonly
1326 def ErrorCount(self) -> int:
1327 """
1328 Read-only property to access the number of counted errors.
1330 :returns: Number of errors.
1331 """
1332 return self._errorCount
1334 @readonly
1335 def Lines(self) -> list[Line]:
1336 """
1337 Read-only property to access the list of printed lines (messages).
1339 :returns: List of lines.
1340 """
1341 return self._lines
1343 def ExitOnPreviousErrors(self) -> None:
1344 """
1345 Exit application if errors have been printed.
1346 """
1347 if self._errorCount > 0:
1348 self.WriteFatal("Too many errors in previous steps.")
1350 def ExitOnPreviousCriticalWarnings(
1351 self,
1352 includeErrors: bool = True
1353 ) -> None:
1354 """
1355 Exit application if error or critical warnings have been printed.
1357 :param includeErrors: Optional, if ``True``, count previous errors as well as critical warnings.
1358 """
1359 if includeErrors and (self._errorCount > 0): 1359 ↛ 1360line 1359 didn't jump to line 1360 because the condition on line 1359 was never true
1360 if self._criticalWarningCount > 0:
1361 self.WriteFatal("Too many errors and critical warnings in previous steps.")
1362 else:
1363 self.WriteFatal("Too many errors in previous steps.")
1364 elif self._criticalWarningCount > 0:
1365 self.WriteFatal("Too many critical warnings in previous steps.")
1367 def ExitOnPreviousWarnings(
1368 self,
1369 includeCriticalWarnings: bool = True,
1370 includeErrors: bool = True
1371 ) -> None:
1372 """
1373 Exit application if error or (critical) warnings have been printed.
1375 :param includeCriticalWarnings: Optional, if ``True``, count previous critical warnings as well as warnings.
1376 :param includeErrors: Optional, if ``True``, count previous errors as well.
1377 """
1378 if includeErrors and (self._errorCount > 0): 1378 ↛ 1379line 1378 didn't jump to line 1379 because the condition on line 1378 was never true
1379 if includeCriticalWarnings and (self._criticalWarningCount > 0):
1380 if self._warningCount > 0:
1381 self.WriteFatal("Too many errors and (critical) warnings in previous steps.")
1382 else:
1383 self.WriteFatal("Too many errors and critical warnings in previous steps.")
1384 elif self._warningCount > 0:
1385 self.WriteFatal("Too many warnings in previous steps.")
1386 else:
1387 self.WriteFatal("Too many errors in previous steps.")
1388 elif includeCriticalWarnings and (self._criticalWarningCount > 0): 1388 ↛ 1389line 1388 didn't jump to line 1389 because the condition on line 1388 was never true
1389 if self._warningCount > 0:
1390 self.WriteFatal("Too many (critical) warnings in previous steps.")
1391 else:
1392 self.WriteFatal("Too many critical warnings in previous steps.")
1393 elif self._warningCount > 0:
1394 self.WriteFatal("Too many warnings in previous steps.")
1396 def WriteLine(self, line: Line) -> bool:
1397 """
1398 Print a formatted line to the underlying terminal/console offered by the operating system.
1400 The message is indented by :attr:`INDENT` repeated :attr:`Line.Indent` times. The indentation is applied to the
1401 message, not to the whole line, so the severity markers stay in one column.
1403 :param line: Line object to indent, format and print.
1404 :returns: True, if line was actually written.
1405 """
1406 if line.Severity < self._writeLevel:
1407 return False
1409 self._lines.append(line)
1410 for method in self._LOG_LEVEL_ROUTING__[line.Severity]:
1411 indentedMessage = self.INDENT * line.Indent + line.Message
1412 method(self._LOG_MESSAGE_FORMAT__[line.Severity].format(message=indentedMessage, **self.FOREGROUND), end="\n" if line.AppendLinebreak else "")
1414 return True
1416 def TryWriteLine(self, line) -> bool:
1417 """
1418 Check if a line object of a certain severity would be written.
1420 :param line: Line object to check.
1421 :returns: True, if line would be written.
1422 """
1423 severity: Severity = line.Severity # '@readonly' hands out 'Any' until it is typed - see T75
1424 return severity >= self._writeLevel
1426 def WriteFatal(
1427 self,
1428 message: str,
1429 *,
1430 indent: int = 0,
1431 appendLinebreak: bool = True,
1432 exitCode: int = 0,
1433 immediateExit: bool = True
1434 ) -> bool:
1435 """
1436 Write a fatal message and exit.
1438 Depending on internal settings and rules, a message might be skipped.
1440 :param message: Message to write.
1441 :param indent: Optional, indentation level of the message.
1442 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1443 :param exitCode: Optional, exit application with this exit code. Default: ``0`` |br|
1444 If ``0``, use :attr:`FATAL_EXIT_CODE` as exit code.
1445 :param immediateExit: Optional, exit application immediately. Default: ``True``
1446 :returns: True, if message was actually written.
1447 """
1448 ret = self.WriteLine(Line(message, Severity.Fatal, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1449 if immediateExit:
1450 self.FatalExit(exitCode)
1451 return ret
1453 def WriteError(
1454 self,
1455 message: str,
1456 *,
1457 indent: int = 0,
1458 appendLinebreak: bool = True
1459 ) -> bool:
1460 """
1461 Write an error message.
1463 Depending on internal settings and rules, a message might be skipped.
1465 :param message: Message to write.
1466 :param indent: Optional, indentation level of the message.
1467 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1468 :returns: True, if message was actually written.
1469 """
1470 self._errorCount += 1
1471 return self.WriteLine(Line(message, Severity.Error, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1473 def WriteQuiet(
1474 self,
1475 message: str,
1476 *,
1477 indent: int = 0,
1478 appendLinebreak: bool = True
1479 ) -> bool:
1480 """
1481 Write an always visible message.
1483 This message is even visible in quiet mode.
1485 Depending on internal settings and rules, a message might be skipped.
1487 :param message: Message to write.
1488 :param indent: Optional, indentation level of the message.
1489 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1490 :returns: True, if message was actually written.
1491 """
1492 return self.WriteLine(Line(message, Severity.Quiet, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1494 def WriteCritical(
1495 self,
1496 message: str,
1497 *,
1498 indent: int = 0,
1499 appendLinebreak: bool = True
1500 ) -> bool:
1501 """
1502 Write a critical message.
1504 Depending on internal settings and rules, a message might be skipped.
1506 :param message: Message to write.
1507 :param indent: Optional, indentation level of the message.
1508 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1509 :returns: True, if message was actually written.
1510 """
1511 self._criticalWarningCount += 1
1512 return self.WriteLine(Line(message, Severity.Critical, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1514 def WriteErrorNote(
1515 self,
1516 message: str,
1517 *,
1518 indent: int = 0,
1519 appendLinebreak: bool = True
1520 ) -> bool:
1521 """
1522 Write a note belonging to an error, which is where the advice for fixing it goes.
1524 Depending on internal settings and rules, a note might be skipped.
1526 :param message: Message to write.
1527 :param indent: Optional, indentation level of the note.
1528 :param appendLinebreak: Optional, append a linebreak after the note. Default: ``True``
1529 :returns: True, if note was actually written.
1530 """
1531 return self.WriteLine(
1532 Line(message, Severity.ErrorNote, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak)
1533 )
1535 def WriteCriticalNote(
1536 self,
1537 message: str,
1538 *,
1539 indent: int = 0,
1540 appendLinebreak: bool = True
1541 ) -> bool:
1542 """
1543 Write a critical note.
1545 Depending on internal settings and rules, a note might be skipped.
1547 :param message: Message to write.
1548 :param indent: Optional, indentation level of the note.
1549 :param appendLinebreak: Optional, append a linebreak after the note. Default: ``True``
1550 :returns: True, if note was actually written.
1551 """
1552 return self.WriteLine(Line(message, Severity.CriticalNote, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1554 def WriteWarning(
1555 self,
1556 message: str,
1557 *,
1558 indent: int = 0,
1559 appendLinebreak: bool = True
1560 ) -> bool:
1561 """
1562 Write a warning message.
1564 Depending on internal settings and rules, a message might be skipped.
1566 :param message: Message to write.
1567 :param indent: Optional, indentation level of the message.
1568 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1569 :returns: True, if message was actually written.
1570 """
1571 self._warningCount += 1
1572 return self.WriteLine(Line(message, Severity.Warning, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1574 def WriteWarningNote(
1575 self,
1576 message: str,
1577 *,
1578 indent: int = 0,
1579 appendLinebreak: bool = True
1580 ) -> bool:
1581 """
1582 Write a warning note.
1584 Depending on internal settings and rules, a note might be skipped.
1586 :param message: Message to write.
1587 :param indent: Optional, indentation level of the note.
1588 :param appendLinebreak: Optional, append a linebreak after the note. Default: ``True``
1589 :returns: True, if note was actually written.
1590 """
1591 return self.WriteLine(Line(message, Severity.WarningNote, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1593 def WriteInfo(
1594 self,
1595 message: str,
1596 *,
1597 indent: int = 0,
1598 appendLinebreak: bool = True
1599 ) -> bool:
1600 """
1601 Write an info message.
1603 Depending on internal settings and rules, a message might be skipped.
1605 :param message: Message to write.
1606 :param indent: Optional, indentation level of the message.
1607 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1608 :returns: True, if message was actually written.
1609 """
1610 return self.WriteLine(Line(message, Severity.Info, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1612 def WriteNormal(
1613 self,
1614 message: str,
1615 *,
1616 indent: int = 0,
1617 appendLinebreak: bool = True
1618 ) -> bool:
1619 """
1620 Write a normal message.
1622 Depending on internal settings and rules, a message might be skipped.
1624 :param message: Message to write.
1625 :param indent: Optional, indentation level of the message.
1626 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1627 :returns: True, if message was actually written.
1628 """
1629 return self.WriteLine(Line(message, Severity.Normal, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1631 def WriteVerbose(
1632 self,
1633 message: str,
1634 *,
1635 indent: int = 0,
1636 appendLinebreak: bool = True
1637 ) -> bool:
1638 """
1639 Write a verbose message.
1641 Depending on internal settings and rules, a message might be skipped.
1643 :param message: Message to write.
1644 :param indent: Optional, indentation level of the message.
1645 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1646 :returns: True, if message was actually written.
1647 """
1648 return self.WriteLine(Line(message, Severity.Verbose, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1650 def WriteDebug(
1651 self,
1652 message: str,
1653 *,
1654 indent: int = 0,
1655 appendLinebreak: bool = True
1656 ) -> bool:
1657 """
1658 Write a debug message.
1660 Depending on internal settings and rules, a message might be skipped.
1662 :param message: Message to write.
1663 :param indent: Optional, indentation level of the message.
1664 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1665 :returns: True, if message was actually written.
1666 """
1667 return self.WriteLine(Line(message, Severity.Debug, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1669 def WriteDryRun(
1670 self,
1671 message: str,
1672 *,
1673 indent: int = 0,
1674 appendLinebreak: bool = True
1675 ) -> bool:
1676 """
1677 Write a dry-run message message.
1679 Depending on internal settings and rules, a message might be skipped.
1681 :param message: Message to write.
1682 :param indent: Optional, indentation level of the message.
1683 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1684 :returns: True, if message was actually written.
1685 """
1686 return self.WriteLine(Line(message, Severity.DryRun, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1689@export
1690class TerminalApplicationProtocol(Protocol):
1691 """
1692 The members a mixin-class can expect from a :class:`TerminalApplication` it is mixed into.
1694 A mixin-class implementing command handlers writes messages, reads the verbosity and exits through its host class. It
1695 names this protocol as its ``expects`` class keyword argument, so a class mixing it in without being a
1696 :class:`TerminalApplication` can't be instantiated. Annotating ``self`` with the protocol lets a type-checker check
1697 the calls inside the mixin-class:
1699 .. code-block:: Python
1701 class PipelineHandlers(metaclass=ExtendedType, mixin=True, expects=TerminalApplicationProtocol):
1702 def HandlePipeline(self: TerminalApplicationProtocol, args: Namespace) -> None:
1703 self._PrintHeadline()
1704 self.WriteNormal("...")
1706 .. seealso::
1708 :class:`~pyTooling.MetaClasses.ExtendedType`
1709 |rarr| The ``expects`` class keyword argument of mixin-classes.
1710 """
1712 @InheritDocString(TerminalApplication)
1713 def _PrintHeadline(self, width: int = 80) -> None:
1714 ...
1716 @readonly
1717 @InheritDocString(TerminalApplication)
1718 def Width(self) -> int:
1719 ...
1721 @readonly
1722 @InheritDocString(TerminalApplication)
1723 def Verbose(self) -> bool:
1724 ...
1726 @readonly
1727 @InheritDocString(TerminalApplication)
1728 def Debug(self) -> bool:
1729 ...
1731 @readonly
1732 @InheritDocString(TerminalApplication)
1733 def Quiet(self) -> bool:
1734 ...
1736 @InheritDocString(TerminalApplication)
1737 def Exit(self, returnCode: int = 0) -> NoReturn:
1738 ...
1740 @InheritDocString(TerminalApplication)
1741 def ExitOnPreviousErrors(self) -> None:
1742 ...
1744 @InheritDocString(TerminalApplication)
1745 def ExitOnPreviousCriticalWarnings(
1746 self,
1747 includeErrors: bool = True
1748 ) -> None:
1749 ...
1751 @InheritDocString(TerminalApplication)
1752 def ExitOnPreviousWarnings(
1753 self,
1754 includeCriticalWarnings: bool = True,
1755 includeErrors: bool = True
1756 ) -> None:
1757 ...
1759 @InheritDocString(TerminalApplication)
1760 def WriteFatal(
1761 self,
1762 message: str,
1763 *,
1764 indent: int = 0,
1765 appendLinebreak: bool = True,
1766 exitCode: int = 0,
1767 immediateExit: bool = True
1768 ) -> bool:
1769 ...
1771 @InheritDocString(TerminalApplication)
1772 def WriteError(
1773 self,
1774 message: str,
1775 *,
1776 indent: int = 0,
1777 appendLinebreak: bool = True
1778 ) -> bool:
1779 ...
1781 @InheritDocString(TerminalApplication)
1782 def WriteErrorNote(
1783 self,
1784 message: str,
1785 *,
1786 indent: int = 0,
1787 appendLinebreak: bool = True
1788 ) -> bool:
1789 ...
1791 @InheritDocString(TerminalApplication)
1792 def WriteCritical(
1793 self,
1794 message: str,
1795 *,
1796 indent: int = 0,
1797 appendLinebreak: bool = True
1798 ) -> bool:
1799 ...
1801 @InheritDocString(TerminalApplication)
1802 def WriteCriticalNote(
1803 self,
1804 message: str,
1805 *,
1806 indent: int = 0,
1807 appendLinebreak: bool = True
1808 ) -> bool:
1809 ...
1811 @InheritDocString(TerminalApplication)
1812 def WriteWarning(
1813 self,
1814 message: str,
1815 *,
1816 indent: int = 0,
1817 appendLinebreak: bool = True
1818 ) -> bool:
1819 ...
1821 @InheritDocString(TerminalApplication)
1822 def WriteWarningNote(
1823 self,
1824 message: str,
1825 *,
1826 indent: int = 0,
1827 appendLinebreak: bool = True
1828 ) -> bool:
1829 ...
1831 @InheritDocString(TerminalApplication)
1832 def WriteInfo(
1833 self,
1834 message: str,
1835 *,
1836 indent: int = 0,
1837 appendLinebreak: bool = True
1838 ) -> bool:
1839 ...
1841 @InheritDocString(TerminalApplication)
1842 def WriteQuiet(
1843 self,
1844 message: str,
1845 *,
1846 indent: int = 0,
1847 appendLinebreak: bool = True
1848 ) -> bool:
1849 ...
1851 @InheritDocString(TerminalApplication)
1852 def WriteNormal(
1853 self,
1854 message: str,
1855 *,
1856 indent: int = 0,
1857 appendLinebreak: bool = True
1858 ) -> bool:
1859 ...
1861 @InheritDocString(TerminalApplication)
1862 def WriteVerbose(
1863 self,
1864 message: str,
1865 *,
1866 indent: int = 0,
1867 appendLinebreak: bool = True
1868 ) -> bool:
1869 ...
1871 @InheritDocString(TerminalApplication)
1872 def WriteDebug(
1873 self,
1874 message: str,
1875 *,
1876 indent: int = 0,
1877 appendLinebreak: bool = True
1878 ) -> bool:
1879 ...
1881 @InheritDocString(TerminalApplication)
1882 def WriteDryRun(
1883 self,
1884 message: str,
1885 *,
1886 indent: int = 0,
1887 appendLinebreak: bool = True
1888 ) -> bool:
1889 ...