archinstall/archinstall/lib/output.py

362 lines
9.6 KiB
Python

import logging
import os
import sys
import unicodedata
from enum import Enum
from pathlib import Path
from typing import Dict, Union, List, Any, Callable, Optional, TYPE_CHECKING
from dataclasses import asdict, is_dataclass
from .storage import storage
if TYPE_CHECKING:
from _typeshed import DataclassInstance
class FormattedOutput:
@classmethod
def _get_values(
cls,
o: 'DataclassInstance',
class_formatter: Optional[Union[str, Callable]] = None,
filter_list: List[str] = []
) -> Dict[str, Any]:
"""
the original values returned a dataclass as dict thru the call to some specific methods
this version allows thru the parameter class_formatter to call a dynamically selected formatting method.
Can transmit a filter list to the class_formatter,
"""
if class_formatter:
# if invoked per reference it has to be a standard function or a classmethod.
# A method of an instance does not make sense
if callable(class_formatter):
return class_formatter(o, filter_list)
# if is invoked by name we restrict it to a method of the class. No need to mess more
elif hasattr(o, class_formatter) and callable(getattr(o, class_formatter)):
func = getattr(o, class_formatter)
return func(filter_list)
raise ValueError('Unsupported formatting call')
elif hasattr(o, 'table_data'):
return o.table_data()
elif hasattr(o, 'json'):
return o.json()
elif is_dataclass(o):
return asdict(o)
else:
return o.__dict__
@classmethod
def as_table(
cls,
obj: List[Any],
class_formatter: Optional[Union[str, Callable]] = None,
filter_list: List[str] = [],
capitalize: bool = False
) -> str:
""" variant of as_table (subtly different code) which has two additional parameters
filter which is a list of fields which will be shon
class_formatter a special method to format the outgoing data
A general comment, the format selected for the output (a string where every data record is separated by newline)
is for compatibility with a print statement
As_table_filter can be a drop in replacement for as_table
"""
raw_data = [cls._get_values(o, class_formatter, filter_list) for o in obj]
# determine the maximum column size
column_width: Dict[str, int] = {}
for o in raw_data:
for k, v in o.items():
if not filter_list or k in filter_list:
column_width.setdefault(k, 0)
column_width[k] = max([column_width[k], len(str(v)), len(k)])
if not filter_list:
filter_list = list(column_width.keys())
# create the header lines
output = ''
key_list = []
for key in filter_list:
width = column_width[key]
key = key.replace('!', '').replace('_', ' ')
if capitalize:
key = key.capitalize()
key_list.append(unicode_ljust(key, width))
output += ' | '.join(key_list) + '\n'
output += '-' * len(output) + '\n'
# create the data lines
for record in raw_data:
obj_data = []
for key in filter_list:
width = column_width.get(key, len(key))
value = record.get(key, '')
if '!' in key:
value = '*' * width
if isinstance(value, (int, float)) or (isinstance(value, str) and value.isnumeric()):
obj_data.append(unicode_rjust(str(value), width))
else:
obj_data.append(unicode_ljust(str(value), width))
output += ' | '.join(obj_data) + '\n'
return output
@classmethod
def as_columns(cls, entries: List[str], cols: int) -> str:
"""
Will format a list into a given number of columns
"""
chunks = []
output = ''
for i in range(0, len(entries), cols):
chunks.append(entries[i:i + cols])
for row in chunks:
out_fmt = '{: <30} ' * len(row)
output += out_fmt.format(*row) + '\n'
return output
class Journald:
@staticmethod
def log(message: str, level: int = logging.DEBUG) -> None:
try:
import systemd.journal # type: ignore
except ModuleNotFoundError:
return None
log_adapter = logging.getLogger('archinstall')
log_fmt = logging.Formatter("[%(levelname)s]: %(message)s")
log_ch = systemd.journal.JournalHandler()
log_ch.setFormatter(log_fmt)
log_adapter.addHandler(log_ch)
log_adapter.setLevel(logging.DEBUG)
log_adapter.log(level, message)
def _check_log_permissions():
filename = storage.get('LOG_FILE', None)
log_dir = storage.get('LOG_PATH', Path('./'))
if not filename:
raise ValueError('No log file name defined')
log_file = log_dir / filename
try:
log_dir.mkdir(exist_ok=True, parents=True)
log_file.touch(exist_ok=True)
with log_file.open('a') as fp:
fp.write('')
except PermissionError:
# Fallback to creating the log file in the current folder
fallback_dir = Path('./').absolute()
fallback_log_file = fallback_dir / filename
fallback_log_file.touch(exist_ok=True)
storage['LOG_PATH'] = fallback_dir
warn(f'Not enough permission to place log file at {log_file}, creating it in {fallback_log_file} instead')
def _supports_color() -> bool:
"""
Found first reference here:
https://stackoverflow.com/questions/7445658/how-to-detect-if-the-console-does-support-ansi-escape-codes-in-python
And re-used this:
https://github.com/django/django/blob/master/django/core/management/color.py#L12
Return True if the running system's terminal supports color,
and False otherwise.
"""
supported_platform = sys.platform != 'win32' or 'ANSICON' in os.environ
# isatty is not always implemented, #6223.
is_a_tty = hasattr(sys.stdout, 'isatty') and sys.stdout.isatty()
return supported_platform and is_a_tty
class Font(Enum):
bold = '1'
italic = '3'
underscore = '4'
blink = '5'
reverse = '7'
conceal = '8'
def _stylize_output(
text: str,
fg: str,
bg: Optional[str],
reset: bool,
font: List[Font] = [],
) -> str:
"""
Heavily influenced by:
https://github.com/django/django/blob/ae8338daf34fd746771e0678081999b656177bae/django/utils/termcolors.py#L13
Color options here:
https://askubuntu.com/questions/528928/how-to-do-underline-bold-italic-strikethrough-color-background-and-size-i
Adds styling to a text given a set of color arguments.
"""
colors = {
'black' : '0',
'red' : '1',
'green' : '2',
'yellow' : '3',
'blue' : '4',
'magenta' : '5',
'cyan' : '6',
'white' : '7',
'teal' : '8;5;109', # Extended 256-bit colors (not always supported)
'orange' : '8;5;208', # https://www.lihaoyi.com/post/BuildyourownCommandLinewithANSIescapecodes.html#256-colors
'darkorange' : '8;5;202',
'gray' : '8;5;246',
'grey' : '8;5;246',
'darkgray' : '8;5;240',
'lightgray' : '8;5;256'
}
foreground = {key: f'3{colors[key]}' for key in colors}
background = {key: f'4{colors[key]}' for key in colors}
code_list = []
if text == '' and reset:
return '\x1b[%sm' % '0'
code_list.append(foreground[str(fg)])
if bg:
code_list.append(background[str(bg)])
for o in font:
code_list.append(o.value)
ansi = ';'.join(code_list)
return f'\033[{ansi}m{text}\033[0m'
def info(
*msgs: str,
level: int = logging.INFO,
fg: str = 'white',
bg: Optional[str] = None,
reset: bool = False,
font: List[Font] = []
):
log(*msgs, level=level, fg=fg, bg=bg, reset=reset, font=font)
def debug(
*msgs: str,
level: int = logging.DEBUG,
fg: str = 'white',
bg: Optional[str] = None,
reset: bool = False,
font: List[Font] = []
):
log(*msgs, level=level, fg=fg, bg=bg, reset=reset, font=font)
def error(
*msgs: str,
level: int = logging.ERROR,
fg: str = 'red',
bg: Optional[str] = None,
reset: bool = False,
font: List[Font] = []
):
log(*msgs, level=level, fg=fg, bg=bg, reset=reset, font=font)
def warn(
*msgs: str,
level: int = logging.WARN,
fg: str = 'yellow',
bg: Optional[str] = None,
reset: bool = False,
font: List[Font] = []
):
log(*msgs, level=level, fg=fg, bg=bg, reset=reset, font=font)
def log(
*msgs: str,
level: int = logging.INFO,
fg: str = 'white',
bg: Optional[str] = None,
reset: bool = False,
font: List[Font] = []
):
# leave this check here as we need to setup the logging
# right from the beginning when the modules are loaded
_check_log_permissions()
text = orig_string = ' '.join([str(x) for x in msgs])
# Attempt to colorize the output if supported
# Insert default colors and override with **kwargs
if _supports_color():
text = _stylize_output(text, fg, bg, reset, font)
log_file: Path = storage['LOG_PATH'] / storage['LOG_FILE']
with log_file.open('a') as fp:
fp.write(f"{orig_string}\n")
Journald.log(text, level=level)
from .menu import Menu
if not Menu.is_menu_active():
# Finally, print the log unless we skipped it based on level.
# We use sys.stdout.write()+flush() instead of print() to try and
# fix issue #94
if level != logging.DEBUG or storage.get('arguments', {}).get('verbose', False):
sys.stdout.write(f"{text}\n")
sys.stdout.flush()
def _count_wchars(string: str) -> int:
"Count the total number of wide characters contained in a string"
return sum(unicodedata.east_asian_width(c) in 'FW' for c in string)
def unicode_ljust(string: str, width: int, fillbyte: str = ' ') -> str:
"""Return a left-justified unicode string of length width.
>>> unicode_ljust('Hello', 15, '*')
'Hello**********'
>>> unicode_ljust('你好', 15, '*')
'你好***********'
>>> unicode_ljust('안녕하세요', 15, '*')
'안녕하세요*****'
>>> unicode_ljust('こんにちは', 15, '*')
'こんにちは*****'
"""
return string.ljust(width - _count_wchars(string), fillbyte)
def unicode_rjust(string: str, width: int, fillbyte: str = ' ') -> str:
"""Return a right-justified unicode string of length width.
>>> unicode_rjust('Hello', 15, '*')
'**********Hello'
>>> unicode_rjust('你好', 15, '*')
'***********你好'
>>> unicode_rjust('안녕하세요', 15, '*')
'*****안녕하세요'
>>> unicode_rjust('こんにちは', 15, '*')
'*****こんにちは'
"""
return string.rjust(width - _count_wchars(string), fillbyte)