rougail-output-display/src/rougail/output_console/__init__.py

621 lines
22 KiB
Python

"""
Silique (https://www.silique.fr)
Copyright (C) 2022-2025
This program is free software: you can redistribute it and/or modify it
under the terms of the GNU Lesser General Public License as published by the
Free Software Foundation, either version 3 of the License, or (at your
option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
details.
You should have received a copy of the GNU Lesser General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
from typing import Any, List, Optional
from io import BytesIO
from rich.tree import Tree
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from ruamel.yaml import YAML
from tiramisu import owners
from tiramisu.error import PropertiesOptionError, ConfigError
from rougail.utils import undefined
from .i18n import _
from .__version__ import __version__
class RougailOutputConsole:
variable_hidden_color = "orange1"
variable_undocumented_color = "bright_blue"
variable_undocumented_and_modified_color = "red1"
value_unmodified_color = "gold1"
value_default_color = "green"
def __init__(
self,
config: "Config",
metaconfig=None,
*,
rougailconfig: "RougailConfig" = None,
user_data_errors: Optional[list] = None,
user_data_warnings: Optional[list] = None,
config_owner_is_path: bool = False,
layer_datas=None,
**kwargs,
) -> None:
if rougailconfig is None:
from rougail import RougailConfig
rougailconfig = RougailConfig
self.rougailconfig = rougailconfig
self.config = config
self.metaconfig = metaconfig
self.layer_datas = layer_datas
self.config_owner_is_path = config_owner_is_path
self.is_mandatory = self.rougailconfig["console.mandatory"]
self.show_secrets = self.rougailconfig["console.show_secrets"]
self.key_is_description = self.rougailconfig["console.key_is_description"]
try:
self.undocumented_modes = set(self.rougailconfig["console.undocumented_modes"])
except Exception:
self.undocumented_modes = set()
self.variable_default_enable = False
self.variable_hidden_enable = False
self.variable_undocumented_enable = False
self.variable_undocumented_and_modified_enable = False
self.value_modified_enable = False
self.value_unmodified_enable = False
self.value_default_enable = False
self.errors = []
self.warnings = []
if user_data_errors is None:
user_data_errors = []
self.user_data_errors = user_data_errors
if user_data_warnings is None:
user_data_warnings = []
self.user_data_warnings = user_data_warnings
self.console = Console(force_terminal=True)
self.out = []
self.root = self.get_root()
def mandatory(self):
try:
mandatories = self.config.value.mandatory()
except (ConfigError, PropertiesOptionError) as err:
self.errors.append(_("Error in config: {0}").format(err))
return
except ValueError as err:
self.errors.append(str(err))
return
options_with_error = []
options = {}
for option in mandatories:
try:
option.value.get()
except PropertiesOptionError:
options_with_error.append(option)
else:
parent = self.config
current_options = options
for child_path in option.path().split("."):
child = parent.option(child_path)
if self.key_is_description:
description = child.description(uncalculated=True)
else:
description = child.path(uncalculated=True)
if child.isdynamic() and "{{ identifier }}" in description:
description = description.replace(
"{{ identifier }}", child.identifiers()[-1]
)
if child.isoptiondescription():
current_options = current_options.setdefault(description, {})
parent = child
else:
current_options.setdefault(None, []).append(description)
if options:
self.errors.append(
{_("The following variables are mandatory but have no value:"): options}
)
elif options_with_error:
if self.key_is_description:
variables = [option.description() for option in options_with_error]
else:
variables = [option.path() for option in options_with_error]
self.errors.append(
{
_(
"The following variables are inaccessible but are empty and mandatory:"
): variables
}
)
def exporter(self) -> bool:
if self.is_mandatory:
ori_properties = self.config.property.exportation()
self.config.property.read_write()
if not self.user_data_errors and not self.errors:
self.mandatory()
self.config.property.importation(ori_properties)
warnings = self.user_data_warnings + self.warnings
if warnings:
self.display_warnings(warnings)
errors = self.user_data_errors + self.errors
if errors:
self.display_errors(errors)
return False
old_path_in_description = self.config.information.get(
"path_in_description", True
)
self.config.information.set("path_in_description", False)
self.parse_options(
self.config,
self.root,
)
self.config.information.set("path_in_description", old_path_in_description)
if self.output.tree.children:
self.header()
self.end()
return True
def run(self) -> str:
with self.console.capture() as capture:
ret = self.print()
return ret, capture.get()
def print(self) -> None:
ret = self.exporter()
for out in self.out:
self.console.print(out)
return ret
def parse_options(
self,
conf,
parent,
):
for option in conf:
if option.isoptiondescription():
family = parent.add_family(option)
if option.isleadership():
self.parse_leadership(
option,
family,
)
else:
self.parse_options(
option,
family,
)
else:
parent.add_variable(option)
def parse_leadership(
self,
conf,
parent,
):
leader, *followers = list(conf)
leader_values = leader.value.get()
for idx, leader_value in enumerate(leader_values):
leader_obj = parent.add_family(leader)
leader_obj.add_variable(
leader,
value=leader_value,
leader_index=idx,
)
for follower in followers:
if follower.index() != idx:
continue
leader_obj.add_variable(follower)
def header(self):
caption_line = ""
if self.variable_default_enable:
caption_line += _("Variable") + "\n"
if self.variable_undocumented_enable:
caption_line += f'[{self.variable_undocumented_color}]{_("Undocumented variable")}[/{self.variable_undocumented_color}]\n'
if self.variable_undocumented_and_modified_enable:
caption_line += f'[{self.variable_undocumented_and_modified_color}]{_("Undocumented but modified variable")}[/{self.variable_undocumented_and_modified_color}]\n'
if self.variable_hidden_enable:
caption_line += f'[{self.variable_hidden_color}]{_("Unmodifiable variable")}[/{self.variable_hidden_color}]\n'
header_value = ""
if self.value_unmodified_enable:
header_value = f'[{self.value_unmodified_color}]{_("Default value")}[/{self.value_unmodified_color}]\n'
if self.value_modified_enable:
header_value += _("Modified value") + "\n"
if self.value_default_enable:
header_value += f'([{self.value_default_color}]:hourglass_flowing_sand: {_("Original default value")}[/{self.value_default_color}])\n'
caption = Table.grid(padding=1, collapse_padding=True)
caption.pad_edge = False
caption.add_row(caption_line[:-1], header_value[:-1])
self.out.append(Panel.fit(caption, title=_("Caption")))
#
layers = Table.grid(padding=1, collapse_padding=True)
caption.pad_edge = False
if self.layer_datas:
max_len = 0
for datas in self.layer_datas.values():
for data in datas.values():
max_len = max(max_len, len(data))
display_layers = ["" for i in range(max_len)]
for datas in self.layer_datas.values():
for data in datas.values():
last_index = len(data) - 1
for idx in range(max_len):
if last_index < idx:
display_layers[idx] += "\n"
else:
display_layers[idx] += data[idx] + "\n"
layers.add_row(*[layer[:-1] for layer in display_layers])
self.out.append(Panel.fit(layers, title=_("Layers")))
def display_errors(
self,
errors,
) -> None:
tree = Tree(
f"[bold][bright_red]:stop_sign: {_('ERRORS')}[/bright_red][/bold]",
guide_style="bold bright_red",
)
for error in errors:
self.display_error(tree, error)
self.out.append(tree)
def display_error(self, tree, error):
if isinstance(error, list):
for err in error:
self.display_error(tree, err)
return tree
elif isinstance(error, dict):
for key, value in error.items():
if key is None:
# it's variables, no more families
self.display_error(tree, value)
else:
sub_tree = self.display_error(tree, key)
self.display_error(sub_tree, value)
else:
return tree.add(error)
def display_warnings(
self,
warnings: list,
) -> None:
tree = Tree(
f"[bold][bright_yellow]:bell: {_('WARNINGS')}[/bright_yellow][/bold]",
guide_style="bold bright_yellow",
)
for warning in warnings:
tree.add(warning)
self.out.append(tree)
def get_root(self) -> None:
yaml = YAML()
yaml.indent(mapping=2, sequence=4, offset=2)
self.output = OutputFamily(
_("Variables:"),
None,
self,
yaml,
self.key_is_description,
self.undocumented_modes,
no_icon=True,
)
return self.output
def end(self):
self.out.append(self.output.tree)
class OutputFamily:
def __init__(
self,
family,
parent,
root,
_yaml,
key_is_description,
undocumented_modes,
*,
is_leader: bool = False,
no_icon: bool = False,
) -> None:
if parent is None:
tree = Tree
else:
tree = parent.add
if is_leader:
self.tree = tree(
":notebook: " + _("{0}:").format(family),
guide_style="bold bright_blue",
)
elif no_icon:
self.tree = tree(
f"{family}",
guide_style="bold bright_blue",
)
else:
self.tree = tree(
f":open_file_folder: {family}",
guide_style="bold bright_blue",
)
self.root = root
self._yaml = _yaml
self.key_is_description = key_is_description
self.undocumented_modes = undocumented_modes
def add_family(
self,
option,
) -> None:
properties = option.property.get()
if "hidden" in properties:
self.root.variable_hidden_enable = True
color = self.root.variable_hidden_color
elif self.undocumented_modes & properties:
self.root.variable_undocumented_enable = True
color = self.root.variable_undocumented_color
else:
self.root.variable_default_enable = True
color = None
if self.key_is_description:
key_name = option.description()
else:
key_name = option.name()
return OutputFamily(
self.colorize(
[
{
"value": key_name,
"color": color,
"loaded_from": None,
}
]
),
self.tree,
self.root,
self._yaml,
self.key_is_description,
self.undocumented_modes,
)
def add_variable(
self, option, value: Any = undefined, leader_index: Optional[int] = None
):
# value is undefined but not for leader variable
properties = option.property.get()
color = None
variable_color = None
undocumented = self.undocumented_modes & properties
hidden = False
if "hidden" in properties:
if undocumented:
self.root.variable_undocumented_enable = True
variable_color = self.root.variable_undocumented_color
else:
self.root.variable_hidden_enable = True
variable_color = self.root.variable_hidden_color
elif undocumented:
self.root.variable_undocumented_and_modified_enable = True
variable_color = self.root.variable_undocumented_and_modified_color
else:
self.root.variable_default_enable = True
values = []
collect_values = []
if not option.owner.isdefault():
self.root.value_modified_enable = True
follower_index = option.index()
if follower_index is not None:
loaded_from = option.information.get(
f"loaded_from_{follower_index}", None
)
else:
loaded_from = option.information.get("loaded_from", None)
if value is undefined:
value = option.value.get()
values.append(
{
"value": value,
"color": color,
"loaded_from": loaded_from,
}
)
suboption = option
subconfig = self.root.config
if "force_store_value" not in option.property.get() and option.information.get(
"default_value_makes_sense", True
):
while True:
default_value = suboption.value.default()
if leader_index is not None:
if len(default_value) > leader_index:
default_value = default_value[leader_index]
else:
default_value = None
is_root_metaconfig = False
if (
subconfig is None
or not subconfig.path()
or "." not in subconfig.path()
):
is_root_metaconfig = True
subconfig = self.get_subconfig_with_default_value(suboption)
if (
(values and default_value is None) or default_value == []
) and is_root_metaconfig:
break
if not values:
self.root.value_unmodified_enable = True
color = self.root.value_unmodified_color
else:
self.root.value_default_enable = True
color = self.root.value_default_color
index = option.index()
parent_option = subconfig.option(option.path(), index)
if is_root_metaconfig:
loaded_from = None
else:
parent_option = subconfig.option(option.path(), index)
if index is not None:
key = f"loaded_from_{index}"
else:
key = "loaded_from"
loaded_from = parent_option.information.get(key, None)
values.append(
{
"value": default_value,
"color": color,
"loaded_from": loaded_from,
}
)
if is_root_metaconfig:
break
elif not values:
if leader_index is None and option.index() is not None:
if not option.isfollower() or not option.issubmulti():
default_value = None
else:
default_value = []
else:
default_value = None
if not values:
self.root.value_unmodified_enable = True
color = self.root.value_unmodified_color
else:
self.root.value_default_enable = True
color = self.root.value_default_color
values.append(
{
"value": None,
"color": color,
"loaded_from": loaded_from,
}
)
if self.key_is_description:
key_name = option.description()
else:
key_name = option.name()
key = self.colorize(
[
{
"value": key_name,
"color": variable_color,
"loaded_from": None,
}
]
)
value = self.colorize(
values,
option,
)
if isinstance(value, list):
subtree = self.tree.add(
":notebook: " + _("{0}:").format(key),
guide_style="bold bright_blue",
)
for val in value:
subtree.add(str(val))
else:
self.tree.add(":notebook: " + _("{0}: {1}").format(key, value))
def get_subconfig_with_default_value(self, suboption):
default_owner = suboption.owner.default()
if default_owner == owners.default:
return self.root.config
if not self.root.config_owner_is_path:
subconfig = self.root.config
while True:
subconfig = subconfig.parent()
if not subconfig.owner.isdefault():
break
else:
subconfig = self.root.metaconfig
for child in default_owner.split(".")[1:]:
subconfig = subconfig.config(child)
return subconfig
def colorize(
self,
values,
option=None,
) -> str:
multi = False
ret = []
default = []
for idx, data in enumerate(values):
value = data["value"]
if isinstance(value, list):
multi = True
else:
value = [value]
for vidx, val in enumerate(value):
if len(ret) == vidx:
ret.append("")
default.append(False)
if idx:
if not default[vidx]:
if ret[vidx]:
ret[vidx] += " "
ret[vidx] += "("
default[vidx] = True
else:
ret[vidx] += " "
ret[vidx] += ":hourglass_flowing_sand: "
if option:
val = self.convert_value(
option,
val,
)
color = data["color"]
if color is not None:
ret[vidx] += f"[{color}]{val}[/{color}]"
else:
ret[vidx] += val
loaded_from = data["loaded_from"]
if loaded_from:
ret[vidx] += f" :arrow_backward: {loaded_from}"
for idx in range(len(ret)):
if default[idx]:
ret[idx] += ")"
if not multi:
if not ret:
return ""
return ret[0]
return ret
def convert_value(
self,
option,
value: Any,
) -> str:
"""Dump variable, means transform bool, ... to yaml string"""
if (
value is not None
and not self.root.show_secrets
and option.type() == "password"
):
return "*" * 10
if isinstance(value, str):
return value
with BytesIO() as ymlfh:
self._yaml.dump(value, ymlfh)
ret = ymlfh.getvalue().decode("utf-8").strip()
if ret.endswith("..."):
ret = ret[:-3].strip()
return ret
RougailOutput = RougailOutputConsole
__all__ = ("RougailOutputConsole",)