Coverage for pyTooling/TerminalUI/__init__.py: 73%
572 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-08 21:24 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-08 21:24 +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 Quiet = 70 #: Always visible messages, even in quiet mode.
594 Critical = 60 #: Critical messages
595 CriticalNote = 55 #: Critical notes
596 Warning = 50 #: Warning messages
597 WarningNote = 45 #: Warning notes
598 Silent = 40 #: Severity level for silenced messages.
600 Info = 20 #: Informative messages
601 Normal = 10 #: Normal messages
602 DryRun = 8 #: Messages visible in a dry-run
603 Verbose = 5 #: Verbose messages
604 Debug = 2 #: Debug messages
605 All = 0 #: All messages
607 def __hash__(self) -> int:
608 """
609 Compute a hash of the severity level, so it can be used as a key in a dictionary.
611 :returns: Hash of the severity level's name.
612 """
613 return hash(self.name)
615 def __eq__(self, other: Any) -> bool:
616 """
617 Compare two Severity instances (severity level) for equality.
619 :param other: Operand to compare against.
620 :returns: ``True``, if both severity levels are equal.
621 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
622 """
623 if isinstance(other, Severity):
624 return self.value == other.value
625 else:
626 ex = TypeError("Second operand is not supported by == operator.")
627 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
628 ex.add_note("Supported types for second operand: Severity")
629 raise ex
631 def __ne__(self, other: Any) -> bool:
632 """
633 Compare two Severity instances (severity level) for inequality.
635 :param other: Operand to compare against.
636 :returns: ``True``, if both severity levels are unequal.
637 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
638 """
639 if isinstance(other, Severity):
640 return self.value != other.value
641 else:
642 ex = TypeError("Second operand is not supported by != operator.")
643 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
644 ex.add_note("Supported types for second operand: Severity")
645 raise ex
647 def __lt__(self, other: Any) -> bool:
648 """
649 Compare two Severity instances (severity level) for less-than.
651 :param other: Operand to compare against.
652 :returns: ``True``, if severity levels is less than other severity level.
653 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
654 """
655 if isinstance(other, Severity):
656 return self.value < other.value
657 else:
658 ex = TypeError("Second operand is not supported by < operator.")
659 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
660 ex.add_note("Supported types for second operand: Severity")
661 raise ex
663 def __le__(self, other: Any) -> bool:
664 """
665 Compare two Severity instances (severity level) for less-than-or-equal.
667 :param other: Operand to compare against.
668 :returns: ``True``, if severity levels is less than or equal other severity level.
669 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
670 """
671 if isinstance(other, Severity):
672 return self.value <= other.value
673 else:
674 ex = TypeError("Second operand is not supported by <= operator.")
675 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
676 ex.add_note("Supported types for second operand: Severity")
677 raise ex
679 def __gt__(self, other: Any) -> bool:
680 """
681 Compare two Severity instances (severity level) for greater-than.
683 :param other: Operand to compare against.
684 :returns: ``True``, if severity levels is greater than other severity level.
685 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
686 """
687 if isinstance(other, Severity):
688 return self.value > other.value
689 else:
690 ex = TypeError("Second operand is not supported by > operator.")
691 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
692 ex.add_note("Supported types for second operand: Severity")
693 raise ex
695 def __ge__(self, other: Any) -> bool:
696 """
697 Compare two Severity instances (severity level) for greater-than-or-equal.
699 :param other: Operand to compare against.
700 :returns: ``True``, if severity levels is greater than or equal other severity level.
701 :raises TypeError: If operand ``other`` is not of type :class:`Severity`.
702 """
703 if isinstance(other, Severity):
704 return self.value >= other.value
705 else:
706 ex = TypeError("Second operand is not supported by >= operator.")
707 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
708 ex.add_note("Supported types for second operand: Severity")
709 raise ex
712@export
713@unique
714class Mode(Enum):
715 """Routing modes deciding to which stream (``STDOUT``/``STDERR``) a message of a certain severity is written."""
717 TextToStdOut_ErrorsToStdErr = 0 #: Warnings and higher severities to ``STDERR``, except :attr:`Severity.Quiet`.
718 AllLinearToStdOut = 1 #: All messages to ``STDOUT``, so the message order is preserved in a log file.
719 DataToStdOut_OtherToStdErr = 2 #: All messages to ``STDERR``, leaving ``STDOUT`` for the program's data.
722@export
723class Line(metaclass=ExtendedType, slots=True):
724 """
725 Represents a single message line with a severity and indentation level.
726 """
728 _LOG_MESSAGE_FORMAT__: ClassVar[dict[Severity, str]] = {
729 Severity.Exception: "EXCEPTION: {message}",
730 Severity.ExceptionNote: " > {message}",
731 Severity.Fatal: "FATAL: {message}",
732 Severity.Error: "ERROR: {message}",
733 Severity.Quiet: "{message}",
734 Severity.Critical: "CRITICAL: {message}",
735 Severity.CriticalNote: " > {message}",
736 Severity.Warning: "WARNING: {message}",
737 Severity.WarningNote: " > {message}",
738 Severity.Info: "INFO: {message}",
739 Severity.Normal: "{message}",
740 Severity.DryRun: "DRYRUN: {message}",
741 Severity.Verbose: "VERBOSE: {message}",
742 Severity.Debug: "DEBUG: {message}",
743 } #: Message line formatting rules.
745 _timestamp: datetime #: Timestamp when the line was created.
746 _message: str #: Text message (line content).
747 _severity: Severity #: Message severity
748 _indent: int #: Indentation
749 _appendLinebreak: bool #: True, if a trailing linebreak should be added when printing this line object.
751 def __init__(
752 self,
753 message: str,
754 severity: Severity = Severity.Normal,
755 *,
756 indent: int = 0,
757 appendLinebreak: bool = True
758 ) -> None:
759 """
760 Initialize a line object representing the single-line message.
762 :param message: Message to display.
763 :param severity: Optional, severity level of the message.
764 :param indent: Optional, indentation level of the message.
765 :param appendLinebreak: Optional, if ``True``, append a line break at the end of the message.
766 """
767 self._timestamp = datetime.now()
768 self._severity = severity
769 self._message = message
770 self._indent = indent
771 self._appendLinebreak = appendLinebreak
773 @readonly
774 def Message(self) -> str:
775 """
776 Read-only property to access the line's raw message.
778 :returns: Raw message of the line.
779 """
780 return self._message
782 @readonly
783 def Severity(self) -> Severity:
784 """
785 Read-only property to access the line's severity level.
787 :returns: Severity level of the message line.
788 """
789 return self._severity
791 @readonly
792 def Indent(self) -> int:
793 """
794 Read-only property to access the line's indentation level.
796 :returns: Indentation level of the message line.
797 """
798 return self._indent
800 def IndentBy(self, indent: int) -> int:
801 """
802 Increase a line's indentation level.
804 :param indent: Optional, indentation level added to the current indentation level.
805 :returns: The new indentation level.
806 """
807 self._indent = (newIndent := self._indent + indent)
808 return newIndent
810 @readonly
811 def AppendLinebreak(self) -> bool:
812 """
813 Read-only property to access if a linebreak is added after the line's message.
815 :returns: True, if a linebreak should be added.
816 """
817 return self._appendLinebreak
819 def __str__(self) -> str:
820 """
821 Returns a formatted version of a ``Line`` objects as a string.
823 The formatting is defined in :attr:`_LOG_MESSAGE_FORMAT__`.
825 :returns: Formatted version of a ``Line`` object.
826 """
827 return self._LOG_MESSAGE_FORMAT__[self._severity].format(message=self._message)
830@export
831@mixin
832class ILineTerminal:
833 """A mixin class (interface) to provide class-local terminal writing methods."""
835 _terminal: Nullable[TerminalApplication] #: The terminal application the messages are written to.
837 def __init__(self, terminal: Nullable[TerminalApplication] = None) -> None:
838 """
839 MixIn initializer.
841 :param terminal: Optional, the terminal to write to. If ``None``, every writing method does nothing.
842 """
843 self._terminal = terminal
845 # FIXME: Alter methods if a terminal is present or set dummy methods
847 @readonly
848 def Terminal(self) -> Nullable[TerminalApplication]:
849 """
850 Read-only property to access the local terminal instance (:attr:`_terminal`).
852 :returns: The terminal instance, or ``None`` if no terminal is attached.
853 """
854 return self._terminal
856 def WriteLine(self, line: Line, condition: bool = True) -> bool:
857 """
858 Write a line to the local terminal if ``condition`` is ``True``.
860 :param line: Line object to write.
861 :param condition: Optional, write the line only if this condition is ``True``. Default: ``True``.
862 :returns: True, if the line was actually written.
863 """
864 if (self._terminal is not None) and condition:
865 return self._terminal.WriteLine(line)
866 return False
868 # def _TryWriteLine(self, *args: Any, condition: bool = True, **kwargs: Any):
869 # if (self._terminal is not None) and condition:
870 # return self._terminal.TryWrite(*args, **kwargs)
871 # return False
873 def WriteFatal(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
874 """
875 Write a fatal message to the local terminal if ``condition`` is ``True``.
877 :param args: Positional parameters forwarded to the terminal's writing method.
878 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
879 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
880 :returns: True, if the message was actually written.
881 """
882 if (self._terminal is not None) and condition:
883 return self._terminal.WriteFatal(*args, **kwargs)
884 return False
886 def WriteError(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
887 """
888 Write an error message to the local terminal if ``condition`` is ``True``.
890 :param args: Positional parameters forwarded to the terminal's writing method.
891 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
892 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
893 :returns: True, if the message was actually written.
894 """
895 if (self._terminal is not None) and condition:
896 return self._terminal.WriteError(*args, **kwargs)
897 return False
899 def WriteCritical(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
900 """
901 Write a critical warning message to the local terminal if ``condition`` is ``True``.
903 :param args: Positional parameters forwarded to the terminal's writing method.
904 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
905 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
906 :returns: True, if the message was actually written.
907 """
908 if (self._terminal is not None) and condition:
909 return self._terminal.WriteCritical(*args, **kwargs)
910 return False
912 def WriteWarning(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
913 """
914 Write a warning message to the local terminal if ``condition`` is ``True``.
916 :param args: Positional parameters forwarded to the terminal's writing method.
917 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
918 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
919 :returns: True, if the message was actually written.
920 """
921 if (self._terminal is not None) and condition:
922 return self._terminal.WriteWarning(*args, **kwargs)
923 return False
925 def WriteInfo(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
926 """
927 Write an info message to the local terminal if ``condition`` is ``True``.
929 :param args: Positional parameters forwarded to the terminal's writing method.
930 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
931 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
932 :returns: True, if the message was actually written.
933 """
934 if (self._terminal is not None) and condition:
935 return self._terminal.WriteInfo(*args, **kwargs)
936 return False
938 def WriteQuiet(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
939 """
940 Write an always visible message, even in quiet mode, to the local terminal if ``condition`` is ``True``.
942 :param args: Positional parameters forwarded to the terminal's writing method.
943 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
944 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
945 :returns: True, if the message was actually written.
946 """
947 if (self._terminal is not None) and condition:
948 return self._terminal.WriteQuiet(*args, **kwargs)
949 return False
951 def WriteNormal(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
952 """
953 Write a *normal* message to the local terminal if ``condition`` is ``True``.
955 :param args: Positional parameters forwarded to the terminal's writing method.
956 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
957 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
958 :returns: True, if the message was actually written.
959 """
960 if (self._terminal is not None) and condition:
961 return self._terminal.WriteNormal(*args, **kwargs)
962 return False
964 def WriteVerbose(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
965 """
966 Write a verbose message to the local terminal if ``condition`` is ``True``.
968 :param args: Positional parameters forwarded to the terminal's writing method.
969 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
970 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
971 :returns: True, if the message was actually written.
972 """
973 if (self._terminal is not None) and condition:
974 return self._terminal.WriteVerbose(*args, **kwargs)
975 return False
977 def WriteDebug(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
978 """
979 Write a debug message to the local terminal if ``condition`` is ``True``.
981 :param args: Positional parameters forwarded to the terminal's writing method.
982 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
983 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
984 :returns: True, if the message was actually written.
985 """
986 if (self._terminal is not None) and condition:
987 return self._terminal.WriteDebug(*args, **kwargs)
988 return False
990 def WriteDryRun(self, *args: Any, condition: bool = True, **kwargs: Any) -> bool:
991 """
992 Write a dry-run message to the local terminal if ``condition`` is ``True``.
994 :param args: Positional parameters forwarded to the terminal's writing method.
995 :param condition: Optional, write the message only if this condition is ``True``. Default: ``True``.
996 :param kwargs: Keyword parameters forwarded to the terminal's writing method.
997 :returns: True, if the message was actually written.
998 """
999 if (self._terminal is not None) and condition:
1000 return self._terminal.WriteDryRun(*args, **kwargs)
1001 return False
1004@export
1005class TerminalApplication(TerminalBaseApplication): #, ILineTerminal):
1006 """
1007 A base-class for implementation of terminal applications emitting line-by-line messages.
1008 """
1009 _LOG_MESSAGE_FORMAT__: ClassVar[dict[Severity, str]] = {
1010 Severity.Exception: "{RED}[EXCEPTION] {message}{NOCOLOR}",
1011 Severity.ExceptionNote: "{DARK_RED} > {message}{NOCOLOR}",
1012 Severity.Fatal: "{DARK_RED}[FATAL] {message}{NOCOLOR}",
1013 Severity.Error: "{RED}[ERROR] {message}{NOCOLOR}",
1014 Severity.Quiet: "{WHITE}{message}{NOCOLOR}",
1015 Severity.Critical: "{DARK_YELLOW}[CRITICAL] {message}{NOCOLOR}",
1016 Severity.CriticalNote: "{DARK_YELLOW} > {message}{NOCOLOR}",
1017 Severity.Warning: "{YELLOW}[WARNING] {message}{NOCOLOR}",
1018 Severity.WarningNote: "{DARK_YELLOW} > {message}{NOCOLOR}",
1019 Severity.Info: "{WHITE}{message}{NOCOLOR}",
1020 Severity.Normal: "{WHITE}{message}{NOCOLOR}",
1021 Severity.DryRun: "{DARK_CYAN}[DRY] {message}{NOCOLOR}",
1022 Severity.Verbose: "{GRAY}{message}{NOCOLOR}",
1023 Severity.Debug: "{DARK_GRAY}{message}{NOCOLOR}"
1024 } #: Message formatting rules.
1026 _LOG_LEVEL_ROUTING__: dict[Severity, tuple[Callable[[str, str], int]]] #: Message routing rules.
1027 _verbose: bool #: ``True``, if verbose messages are written.
1028 _debug: bool #: ``True``, if debug messages are written.
1029 _silent: bool #: ``True``, if no messages are written at all.
1030 _quiet: bool #: ``True``, if only errors and quiet messages are written.
1031 _writeLevel: Severity #: Minimal severity a message needs to be written.
1032 _writeToStdOut: bool #: ``True``, if messages are written to ``STDOUT`` instead of ``STDERR``.
1034 _lines: list[Line] #: Every message written so far, in the order it was written.
1035 _baseIndent: int #: Indentation level added to every message's own indentation.
1037 _errorCount: int #: Number of errors written so far.
1038 _criticalWarningCount: int #: Number of critical warnings written so far.
1039 _warningCount: int #: Number of warnings written so far.
1041 HeadLine: ClassVar[str] #: Headline of the application, printed by :meth:`_PrintHeadline`.
1043 def __init__(self, mode: Mode = Mode.AllLinearToStdOut) -> None:
1044 """
1045 Initializer of a line-based terminal interface.
1047 :param mode: Optional, defines what output (normal, error, data) to write where. Default: a linear flow all to
1048 *STDOUT*.
1049 """
1050 TerminalBaseApplication.__init__(self)
1051 # ILineTerminal.__init__(self, self)
1053 self._LOG_LEVEL_ROUTING__ = {}
1054 self.__InitializeLogLevelRouting(mode)
1056 self._verbose = False
1057 self._debug = False
1058 self._silent = False
1059 self._quiet = False
1060 self._writeLevel = Severity.Normal
1061 self._writeToStdOut = True
1063 self._lines = []
1064 self._baseIndent = 0
1066 self._errorCount = 0
1067 self._criticalWarningCount = 0
1068 self._warningCount = 0
1070 def __InitializeLogLevelRouting(self, mode: Mode = Mode.AllLinearToStdOut) -> None:
1071 """
1072 Expand a routing mode into a routing table containing one writing method per severity level.
1074 :param mode: Optional, routing mode to expand.
1075 :raises ExceptionBase: If the routing mode is not supported. |br|
1076 The note lists the modes that are supported.
1077 """
1078 if mode is Mode.TextToStdOut_ErrorsToStdErr:
1079 for severity in Severity:
1080 if severity >= Severity.Silent and severity != Severity.Quiet:
1081 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdErr,)
1082 else:
1083 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdOut,)
1084 elif mode is Mode.AllLinearToStdOut:
1085 for severity in Severity:
1086 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdOut, )
1087 elif mode is Mode.DataToStdOut_OtherToStdErr:
1088 for severity in Severity:
1089 self._LOG_LEVEL_ROUTING__[severity] = (self.WriteLineToStdErr, )
1090 else: # pragma: no cover
1091 ex = ExceptionBase(f"Unsupported mode '{mode}'.")
1092 ex.add_note(f"Unsupported modes '{', '.join(m.name for m in Mode)}'.")
1093 raise ex
1095 def _PrintHeadline(self, width: int = 80) -> None:
1096 """
1097 Helper method to print the program headline.
1099 :param width: Optional, number of characters for horizontal lines.
1101 .. admonition:: Generated output
1103 .. code-block::
1105 =========================
1106 centered headline
1107 =========================
1108 """
1109 if width == 0: 1109 ↛ 1110line 1109 didn't jump to line 1110 because the condition on line 1109 was never true
1110 width = self._width
1112 self.WriteNormal(f"{{HEADLINE}}{'=' * width}".format(**TerminalApplication.Foreground))
1113 self.WriteNormal(f"{{HEADLINE}}{{headline: ^{width}s}}".format(headline=self.HeadLine, **TerminalApplication.Foreground))
1114 self.WriteNormal(f"{{HEADLINE}}{'=' * width}".format(**TerminalApplication.Foreground))
1116 def _PrintVersion(
1117 self,
1118 dunderModule: ModuleType,
1119 packageName: Nullable[str] = None,
1120 versionCheckTimeout: int = 1
1121 ) -> None:
1122 """
1123 Helper method to print the version information.
1125 :param dunderModule: The Python module containing the dunder variables for author(s), email, copyright,
1126 version, ...
1127 :param packageName: Optional, name of the package on PyPI. If given, the latest released version is
1128 queried and reported as an available update. Default: ``None``.
1129 :param versionCheckTimeout: Optional, timeout in seconds for the PyPI request. Default: ``1``.
1131 .. admonition:: Example usage
1133 .. code-block:: Python
1135 def _PrintVersion(self):
1136 import myPackage.MyModule as DunderModule
1138 super()._PrintVersion(
1139 DunderModule,
1140 "MyModule"
1141 )
1142 """
1143 copyrights = getattr(dunderModule, "__copyright__", "{RED}Copyright not set!".format(RED=Foreground.RED)).split("\n", 1)
1144 self.WriteNormal(f"Copyright: {copyrights[0]}")
1145 for copyright in copyrights[1:]:
1146 self.WriteNormal(f" {copyright}")
1148 license = getattr(dunderModule, "__license__", "{RED}License not set!".format(RED=Foreground.RED))
1149 self.WriteNormal(f"License: {license}")
1151 authors = getattr(dunderModule, "__author__", "{RED}Unknown author!".format(RED=Foreground.RED)).split(", ")
1152 self.WriteNormal(f"Authors: {authors[0]}")
1153 for author in authors[1:]:
1154 self.WriteNormal(f" {author}")
1156 if (email := getattr(dunderModule, "__email__", None)) is not None:
1157 self.WriteNormal(f"Email: {email}")
1159 if (version := getattr(dunderModule, "__version__", None)) is None:
1160 self.WriteNormal("Version: {RED}Version not set!".format(RED=Foreground.RED))
1161 else:
1162 currentVersion = PythonVersion.Parse(version)
1163 if packageName is None:
1164 update = ""
1165 elif (pypiVersion := self._GetLatestVersion(packageName, versionCheckTimeout)) is not None:
1166 latestVersion = PythonVersion.Parse(pypiVersion)
1167 update = f" (Update available: v{latestVersion})" if currentVersion < latestVersion else " (latest)"
1168 else:
1169 update = " (PyPI timeout)"
1170 self.WriteNormal(f"Version: v{version}{update}")
1172 if (projectURL := getattr(dunderModule, "__project_url__", None)) is not None:
1173 self.WriteNormal(f"Project: {projectURL}")
1175 if (documentationURL := getattr(dunderModule, "__documentation_url__", None)) is not None:
1176 self.WriteNormal(f"Documentation: {documentationURL}")
1178 if (issueTrackerURL := getattr(dunderModule, "__issue_tracker_url__", None)) is not None:
1179 self.WriteNormal(f"Issue tracker: {issueTrackerURL}")
1181 def _GetLatestVersion(self, packageName: str, timeout: int = 1) -> Nullable[str]:
1182 """
1183 Query PyPI for the latest released version of a package.
1185 Every error - an unreachable index, a timeout, an unknown package - is answered with ``None``, because a version
1186 check must not fail the application it is printing the version of.
1188 :param packageName: Optional, name of the package on PyPI.
1189 :param timeout: Optional, timeout in seconds for the request. Default: ``1``.
1190 :returns: The latest version as a string, or ``None``, if it couldn't be determined.
1191 """
1192 from json import loads
1193 from urllib.request import urlopen, Request
1195 request = Request(
1196 url=f"https://pypi.org/pypi/{packageName}/json",
1197 headers={'User-Agent': f'{packageName}-Version-Check'}
1198 )
1199 try:
1200 with urlopen(request, timeout=timeout) as response:
1201 data: dict[str, dict[str, str]] = loads(response.read().decode())
1202 return data["info"]["version"]
1203 except Exception:
1204 return None
1206 def Configure(
1207 self,
1208 *,
1209 verbose: bool = False,
1210 debug: bool = False,
1211 silent: bool = False,
1212 quiet: bool = False,
1213 writeToStdOut: bool = True
1214 ) -> None:
1215 """
1216 Configure the verbosity of the application, usually from the command line switches.
1218 The resulting :attr:`LogLevel` is the minimum severity a message needs to be written: ``Severity.Debug`` in debug
1219 mode, ``Severity.Verbose`` in verbose mode, ``Severity.Silent`` in silent mode, ``Severity.Quiet`` in quiet mode,
1220 otherwise ``Severity.Normal``. Debug mode implies verbose mode.
1222 :param verbose: Optional, write verbose messages. Default: ``False``.
1223 :param debug: Optional, write debug messages, implying verbose messages. Default: ``False``.
1224 :param silent: Optional, reduce the messages to warnings and higher severities. Default: ``False``.
1225 :param quiet: Optional, reduce the messages to errors and always visible messages. Default: ``False``.
1226 :param writeToStdOut: Optional, write to ``STDOUT``. Default: ``True``.
1227 """
1228 self._verbose = True if debug else verbose
1229 self._debug = debug
1230 self._silent = silent
1231 self._quiet = quiet
1233 if quiet: 1233 ↛ 1235line 1233 didn't jump to line 1235 because the condition on line 1233 was always true
1234 self._writeLevel = Severity.Quiet
1235 elif silent:
1236 self._writeLevel = Severity.Silent
1237 elif debug:
1238 self._writeLevel = Severity.Debug
1239 elif verbose:
1240 self._writeLevel = Severity.Verbose
1241 else:
1242 self._writeLevel = Severity.Normal
1244 self._writeToStdOut = writeToStdOut
1246 @readonly
1247 def Verbose(self) -> bool:
1248 """
1249 Check if verbose messages are enabled.
1251 :returns: ``True``, if verbose messages are written.
1252 """
1253 return self._verbose
1255 @readonly
1256 def Debug(self) -> bool:
1257 """
1258 Check if debug messages are enabled.
1260 :returns: ``True``, if debug messages are written.
1261 """
1262 return self._debug
1264 @readonly
1265 def Silent(self) -> bool:
1266 """
1267 Check if silent mode is enabled.
1269 :returns: ``True``, if silent mode is enabled.
1270 """
1271 return self._silent
1273 @readonly
1274 def Quiet(self) -> bool:
1275 """
1276 Check if quiet mode is enabled.
1278 :returns: ``True``, if quiet mode is enabled.
1279 """
1280 return self._quiet
1282 @property
1283 def LogLevel(self) -> Severity:
1284 """
1285 Property to access the minimal severity level a message needs to be written (:attr:`_writeLevel`).
1287 Assigning a level replaces what :meth:`Configure` computed from the verbosity switches.
1289 :returns: The current minimal severity level.
1290 """
1291 return self._writeLevel
1293 @LogLevel.setter
1294 def LogLevel(self, value: Severity) -> None:
1295 self._writeLevel = value
1297 @property
1298 def BaseIndent(self) -> int:
1299 """
1300 Property to access the base indentation level of written messages (:attr:`_baseIndent`).
1302 The assigned level is added to every message's own indentation.
1304 :returns: Base indentation level.
1305 """
1306 return self._baseIndent
1308 @BaseIndent.setter
1309 def BaseIndent(self, value: int) -> None:
1310 self._baseIndent = value
1312 @readonly
1313 def WarningCount(self) -> int:
1314 """
1315 Read-only property to access the number of counted warnings.
1317 :returns: Number of warnings.
1318 """
1319 return self._warningCount
1321 @readonly
1322 def CriticalWarningCount(self) -> int:
1323 """
1324 Read-only property to access the number of counted critical warnings.
1326 :returns: Number of critical warnings.
1327 """
1328 return self._criticalWarningCount
1330 @readonly
1331 def ErrorCount(self) -> int:
1332 """
1333 Read-only property to access the number of counted errors.
1335 :returns: Number of errors.
1336 """
1337 return self._errorCount
1339 @readonly
1340 def Lines(self) -> list[Line]:
1341 """
1342 Read-only property to access the list of printed lines (messages).
1344 :returns: List of lines.
1345 """
1346 return self._lines
1348 def ExitOnPreviousErrors(self) -> None:
1349 """
1350 Exit application if errors have been printed.
1351 """
1352 if self._errorCount > 0:
1353 self.WriteFatal("Too many errors in previous steps.")
1355 def ExitOnPreviousCriticalWarnings(
1356 self,
1357 includeErrors: bool = True
1358 ) -> None:
1359 """
1360 Exit application if error or critical warnings have been printed.
1362 :param includeErrors: Optional, if ``True``, count previous errors as well as critical warnings.
1363 """
1364 if includeErrors and (self._errorCount > 0): 1364 ↛ 1365line 1364 didn't jump to line 1365 because the condition on line 1364 was never true
1365 if self._criticalWarningCount > 0:
1366 self.WriteFatal("Too many errors and critical warnings in previous steps.")
1367 else:
1368 self.WriteFatal("Too many errors in previous steps.")
1369 elif self._criticalWarningCount > 0:
1370 self.WriteFatal("Too many critical warnings in previous steps.")
1372 def ExitOnPreviousWarnings(
1373 self,
1374 includeCriticalWarnings: bool = True,
1375 includeErrors: bool = True
1376 ) -> None:
1377 """
1378 Exit application if error or (critical) warnings have been printed.
1380 :param includeCriticalWarnings: Optional, if ``True``, count previous critical warnings as well as warnings.
1381 :param includeErrors: Optional, if ``True``, count previous errors as well.
1382 """
1383 if includeErrors and (self._errorCount > 0): 1383 ↛ 1384line 1383 didn't jump to line 1384 because the condition on line 1383 was never true
1384 if includeCriticalWarnings and (self._criticalWarningCount > 0):
1385 if self._warningCount > 0:
1386 self.WriteFatal("Too many errors and (critical) warnings in previous steps.")
1387 else:
1388 self.WriteFatal("Too many errors and critical warnings in previous steps.")
1389 elif self._warningCount > 0:
1390 self.WriteFatal("Too many warnings in previous steps.")
1391 else:
1392 self.WriteFatal("Too many errors in previous steps.")
1393 elif includeCriticalWarnings and (self._criticalWarningCount > 0): 1393 ↛ 1394line 1393 didn't jump to line 1394 because the condition on line 1393 was never true
1394 if self._warningCount > 0:
1395 self.WriteFatal("Too many (critical) warnings in previous steps.")
1396 else:
1397 self.WriteFatal("Too many critical warnings in previous steps.")
1398 elif self._warningCount > 0:
1399 self.WriteFatal("Too many warnings in previous steps.")
1401 def WriteLine(self, line: Line) -> bool:
1402 """
1403 Print a formatted line to the underlying terminal/console offered by the operating system.
1405 The message is indented by :attr:`INDENT` repeated :attr:`Line.Indent` times. The indentation is applied to the
1406 message, not to the whole line, so the severity markers stay in one column.
1408 :param line: Line object to indent, format and print.
1409 :returns: True, if line was actually written.
1410 """
1411 if line.Severity < self._writeLevel:
1412 return False
1414 self._lines.append(line)
1415 for method in self._LOG_LEVEL_ROUTING__[line.Severity]:
1416 indentedMessage = self.INDENT * line.Indent + line.Message
1417 method(self._LOG_MESSAGE_FORMAT__[line.Severity].format(message=indentedMessage, **self.Foreground), end="\n" if line.AppendLinebreak else "")
1419 return True
1421 def TryWriteLine(self, line) -> bool:
1422 """
1423 Check if a line object of a certain severity would be written.
1425 :param line: Line object to check.
1426 :returns: True, if line would be written.
1427 """
1428 severity: Severity = line.Severity # '@readonly' hands out 'Any' until it is typed - see T75
1429 return severity >= self._writeLevel
1431 def WriteFatal(
1432 self,
1433 message: str,
1434 *,
1435 indent: int = 0,
1436 appendLinebreak: bool = True,
1437 exitCode: int = 0,
1438 immediateExit: bool = True
1439 ) -> bool:
1440 """
1441 Write a fatal message and exit.
1443 Depending on internal settings and rules, a message might be skipped.
1445 :param message: Message to write.
1446 :param indent: Optional, indentation level of the message.
1447 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1448 :param exitCode: Optional, exit application with this exit code. Default: ``0`` |br|
1449 If ``0``, use :attr:`FATAL_EXIT_CODE` as exit code.
1450 :param immediateExit: Optional, exit application immediately. Default: ``True``
1451 :returns: True, if message was actually written.
1452 """
1453 ret = self.WriteLine(Line(message, Severity.Fatal, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1454 if immediateExit:
1455 self.FatalExit(exitCode)
1456 return ret
1458 def WriteError(
1459 self,
1460 message: str,
1461 *,
1462 indent: int = 0,
1463 appendLinebreak: bool = True
1464 ) -> bool:
1465 """
1466 Write an error message.
1468 Depending on internal settings and rules, a message might be skipped.
1470 :param message: Message to write.
1471 :param indent: Optional, indentation level of the message.
1472 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1473 :returns: True, if message was actually written.
1474 """
1475 self._errorCount += 1
1476 return self.WriteLine(Line(message, Severity.Error, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1478 def WriteQuiet(
1479 self,
1480 message: str,
1481 *,
1482 indent: int = 0,
1483 appendLinebreak: bool = True
1484 ) -> bool:
1485 """
1486 Write an always visible message.
1488 This message is even visible in quiet mode.
1490 Depending on internal settings and rules, a message might be skipped.
1492 :param message: Message to write.
1493 :param indent: Optional, indentation level of the message.
1494 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1495 :returns: True, if message was actually written.
1496 """
1497 return self.WriteLine(Line(message, Severity.Quiet, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1499 def WriteCritical(
1500 self,
1501 message: str,
1502 *,
1503 indent: int = 0,
1504 appendLinebreak: bool = True
1505 ) -> bool:
1506 """
1507 Write a critical message.
1509 Depending on internal settings and rules, a message might be skipped.
1511 :param message: Message to write.
1512 :param indent: Optional, indentation level of the message.
1513 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1514 :returns: True, if message was actually written.
1515 """
1516 self._criticalWarningCount += 1
1517 return self.WriteLine(Line(message, Severity.Critical, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1519 def WriteCriticalNote(
1520 self,
1521 message: str,
1522 *,
1523 indent: int = 0,
1524 appendLinebreak: bool = True
1525 ) -> bool:
1526 """
1527 Write a critical note.
1529 Depending on internal settings and rules, a note might be skipped.
1531 :param message: Message to write.
1532 :param indent: Optional, indentation level of the note.
1533 :param appendLinebreak: Optional, append a linebreak after the note. Default: ``True``
1534 :returns: True, if note was actually written.
1535 """
1536 return self.WriteLine(Line(message, Severity.CriticalNote, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1538 def WriteWarning(
1539 self,
1540 message: str,
1541 *,
1542 indent: int = 0,
1543 appendLinebreak: bool = True
1544 ) -> bool:
1545 """
1546 Write a warning message.
1548 Depending on internal settings and rules, a message might be skipped.
1550 :param message: Message to write.
1551 :param indent: Optional, indentation level of the message.
1552 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1553 :returns: True, if message was actually written.
1554 """
1555 self._warningCount += 1
1556 return self.WriteLine(Line(message, Severity.Warning, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1558 def WriteWarningNote(
1559 self,
1560 message: str,
1561 *,
1562 indent: int = 0,
1563 appendLinebreak: bool = True
1564 ) -> bool:
1565 """
1566 Write a warning note.
1568 Depending on internal settings and rules, a note might be skipped.
1570 :param message: Message to write.
1571 :param indent: Optional, indentation level of the note.
1572 :param appendLinebreak: Optional, append a linebreak after the note. Default: ``True``
1573 :returns: True, if note was actually written.
1574 """
1575 return self.WriteLine(Line(message, Severity.WarningNote, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1577 def WriteInfo(
1578 self,
1579 message: str,
1580 *,
1581 indent: int = 0,
1582 appendLinebreak: bool = True
1583 ) -> bool:
1584 """
1585 Write an info message.
1587 Depending on internal settings and rules, a message might be skipped.
1589 :param message: Message to write.
1590 :param indent: Optional, indentation level of the message.
1591 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1592 :returns: True, if message was actually written.
1593 """
1594 return self.WriteLine(Line(message, Severity.Info, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1596 def WriteNormal(
1597 self,
1598 message: str,
1599 *,
1600 indent: int = 0,
1601 appendLinebreak: bool = True
1602 ) -> bool:
1603 """
1604 Write a normal message.
1606 Depending on internal settings and rules, a message might be skipped.
1608 :param message: Message to write.
1609 :param indent: Optional, indentation level of the message.
1610 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1611 :returns: True, if message was actually written.
1612 """
1613 return self.WriteLine(Line(message, Severity.Normal, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1615 def WriteVerbose(
1616 self,
1617 message: str,
1618 *,
1619 indent: int = 0,
1620 appendLinebreak: bool = True
1621 ) -> bool:
1622 """
1623 Write a verbose message.
1625 Depending on internal settings and rules, a message might be skipped.
1627 :param message: Message to write.
1628 :param indent: Optional, indentation level of the message.
1629 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1630 :returns: True, if message was actually written.
1631 """
1632 return self.WriteLine(Line(message, Severity.Verbose, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1634 def WriteDebug(
1635 self,
1636 message: str,
1637 *,
1638 indent: int = 0,
1639 appendLinebreak: bool = True
1640 ) -> bool:
1641 """
1642 Write a debug message.
1644 Depending on internal settings and rules, a message might be skipped.
1646 :param message: Message to write.
1647 :param indent: Optional, indentation level of the message.
1648 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1649 :returns: True, if message was actually written.
1650 """
1651 return self.WriteLine(Line(message, Severity.Debug, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))
1653 def WriteDryRun(
1654 self,
1655 message: str,
1656 *,
1657 indent: int = 0,
1658 appendLinebreak: bool = True
1659 ) -> bool:
1660 """
1661 Write a dry-run message message.
1663 Depending on internal settings and rules, a message might be skipped.
1665 :param message: Message to write.
1666 :param indent: Optional, indentation level of the message.
1667 :param appendLinebreak: Optional, append a linebreak after the message. Default: ``True``
1668 :returns: True, if message was actually written.
1669 """
1670 return self.WriteLine(Line(message, Severity.DryRun, indent=self._baseIndent + indent, appendLinebreak=appendLinebreak))