rougail-output-doc/src/rougail/output_doc/utils.py

716 lines
27 KiB
Python

"""
Silique (https://www.silique.fr)
Copyright (C) 2024-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 Tuple, List, Optional
from io import BytesIO
from ruamel.yaml import YAML
import tabulate as tabulate_module
from tiramisu.error import display_list
from tabulate import tabulate
from rougail.tiramisu import normalize_family
from tiramisu import undefined
from .i18n import _
ROUGAIL_VARIABLE_TYPE = (
"https://rougail.readthedocs.io/en/latest/variable.html#variables-types"
)
ENTER = "\n\n"
DocTypes = {
"domainname": {
"params": {
"allow_startswith_dot": _("the domain name can starts by a dot"),
"allow_without_dot": _("the domain name can be a hostname"),
"allow_ip": _("the domain name can be an IP"),
"allow_cidr_network": _("the domain name can be network in CIDR format"),
},
},
"number": {
"params": {
"min_number": _("the minimum value is {0}"),
"max_number": _("the maximum value is {0}"),
},
},
"integer": {
"params": {
"min_integer": _("the minimum value is {0}"),
"max_integer": _("the maximum value is {0}"),
},
},
"ip": {
"msg": "IP",
"params": {
"cidr": _("IP must be in CIDR format"),
"private_only": _("private IP are allowed"),
"allow_reserved": _("reserved IP are allowed"),
},
},
"network": {
"params": {
"cidr": _("network must be in CIDR format"),
},
},
"hostname": {
"params": {
"allow_ip": _("the host name can be an IP"),
},
},
"web_address": {
"params": {
"allow_ip": _("the domain name in web address can be an IP"),
"allow_without_dot": _(
"the domain name in web address can be only a hostname"
),
},
},
"port": {
"params": {
"allow_range": _("can be range of port"),
"allow_protocol": _("can have the protocol"),
"allow_zero": _("port 0 is allowed"),
"allow_wellknown": _("well-known ports (1 to 1023) are allowed"),
"allow_registred": _("registred ports (1024 to 49151) are allowed"),
"allow_private": _("private ports (greater than 49152) are allowed"),
},
},
"secret": {
"params": {
"min_len": _("minimum length for the secret is {0} characters"),
"max_len": _("maximum length for the secret is {0} characters"),
"forbidden_char": _("forbidden characters: {0}"),
},
},
"unix_filename": {
"params": {
"allow_relative": _("this filename could be a relative path"),
"test_existence": _("this file must exists"),
"types": _("file type allowed: {0}"),
},
},
}
_yaml = YAML()
_yaml.indent(mapping=2, sequence=4, offset=2)
def dump(informations):
"""Dump variable, means transform bool, ... to yaml string"""
with BytesIO() as ymlfh:
_yaml.dump(informations, ymlfh)
ret = ymlfh.getvalue().decode("utf-8").strip()
if ret.endswith("..."):
ret = ret[:-3].strip()
return ret
def to_phrase(msg, type_="variable"):
"""Add maj for the first character and ends with dot
"""
if not msg:
# replace None to empty string
return ""
msg = str(msg).strip()
# a phrase must ends with a dot
if type_ == 'variable':
if not msg.endswith("."):
msg += "."
elif type_ == 'family':
if msg.endswith("."):
msg = msg[:-1]
else:
raise Exception('unknown type')
# and start with a maj
return msg[0].upper() + msg[1:]
class CommonFormater:
"""Class with common function for formater"""
enter_table = "\n"
# tabulate module name
name = None
def __init__(self, with_family: bool):
tabulate_module.PRESERVE_WHITESPACE = True
self.header_setted = False
self.with_family = with_family
# Class you needs implement to your Formater
def title(
self,
title: str,
level: int,
) -> str:
"""Display family name as a title"""
raise NotImplementedError()
def join(
self,
lst: List[str],
) -> str:
"""Display line in table from a list"""
raise NotImplementedError()
def bold(
self,
msg: str,
) -> str:
"""Set a text to bold"""
raise NotImplementedError()
def stripped(
self,
text: str,
) -> str:
"""Return stripped text (as help)"""
raise NotImplementedError()
def list(
self,
choices: list,
) -> str:
"""Display a liste of element"""
raise NotImplementedError()
def prop(
self,
prop: str,
italic: bool,
) -> str:
"""Display property"""
raise NotImplementedError()
def link(
self,
comment: str,
link: str,
) -> str:
"""Add a link"""
raise NotImplementedError()
##################
def family_informations(self) -> str:
return ''
def end_family_informations(self) -> str:
return ''
def display_paths(
self,
informations: dict,
modified_attributes: dict,
) -> str:
ret_paths = []
path = informations["path"]
if "identifiers" in modified_attributes:
name, previous, new = modified_attributes["identifiers"]
ret_paths.extend([self.bold(self.delete(calc_path(path, self, identifier))) for identifier in previous])
else:
new = []
if "identifiers" in informations:
for identifier in informations["identifiers"]:
path_ = calc_path(path, self, identifier)
if identifier in new:
path_ = self.underline(path_)
ret_paths.append(self.bold(path_))
else:
ret_paths.append(self.bold(path))
return ret_paths
def after_family_paths(self) -> str:
return ENTER
def after_family_properties(self) -> str:
return ENTER
def table_header(
self,
lst: list,
) -> tuple:
"""Manage the header of a table"""
return lst
def run(self, informations: dict, level: int, *, dico_is_already_treated=False) -> str:
"""Transform to string"""
if informations:
return self._run(informations, level, dico_is_already_treated)
return ""
def _run(self, dico: dict, level: int, dico_is_already_treated: bool) -> str:
"""Parse the dict to transform to dict"""
if dico_is_already_treated:
return "".join(dico)
return "".join([msg for msg in self.dict_to_dict(dico, level, init=True)])
def dict_to_dict(self, dico: dict, level: int, *, ori_table_datas: list = None, init: bool = False) -> str:
"""Parse the dict to transform to dict"""
msg = []
if ori_table_datas is not None:
table_datas = ori_table_datas
else:
table_datas = []
ori_level = None
for value in dico.values():
if value["type"] == "variable":
self.variable_to_string(value, table_datas)
else:
if self.with_family:
if value["type"] == "namespace":
if ori_level is None:
ori_level = level
level += 1
informations = value["informations"]
msg.append(self.namespace_to_title(informations, ori_level))
msg.append(self.family_informations())
msg.extend(self.display_paths(informations, {}))
msg.append(self.after_family_paths())
msg.append(self.property_to_string(informations, {}, {})[1] + ENTER)
msg.append(self.end_family_informations())
msg.extend(self.dict_to_dict(value["children"], level))
msg.append(self.end_namespace(ori_level))
else:
if table_datas:
msg.append(self.table(table_datas))
table_datas = []
msg.extend(self.family_to_string(value["informations"], level))
msg.extend(self.dict_to_dict(value["children"], level + 1))
msg.append(self.end_family(level))
else:
self.dict_to_dict(value["children"], level + 1, ori_table_datas=table_datas)
if (init or ori_table_datas is None) and table_datas:
msg.append(self.table(table_datas))
return msg
# FAMILY
def namespace_to_title(self, informations: dict, level: int) -> str:
"""manage namespace family"""
return self.title(
_('Variables for "{0}"').format(self.get_description("family", informations)),
level,
)
def end_namespace(self, level: int) -> str:
return self.end_family(level)
def family_to_string(self, informations: dict, level: int) -> str:
"""manage other family type"""
msg = [self.title(self.get_description("family", informations), level)]
helps = informations.get("help")
if helps:
for help_ in helps:
msg.append(self.display_family_help(help_.strip()))
msg.append(self.family_informations())
msg.append(self.join(self.display_paths(informations, {})
) + self.after_family_paths()
)
calculated_properties = []
msg.append(self.property_to_string(informations, calculated_properties, {})[1] + ENTER)
if calculated_properties:
msg.append(self.join(calculated_properties) + self.after_family_properties())
if "identifier" in informations:
msg.append(
self.section(_("Identifiers"), informations["identifier"]) + self.after_family_properties()
)
msg.append(self.end_family_informations())
return msg
def end_family(self, level: int) -> str:
return ''
def convert_list_to_string(self, attribute: str, informations: dict, modified_attributes: dict) -> str():
datas = []
if attribute in modified_attributes:
name, previous, new = modified_attributes[attribute]
for data in previous:
datas.append(self.delete(self.to_phrase(data)))
else:
new = []
if attribute in informations:
for data in informations[attribute]:
if isinstance(data, dict):
if attribute.endswith('s'):
attr = attribute[:-1]
else:
attr = attribute
data = data[attr].replace('{{ identifier }}', self.italic(data["identifier"]))
if data in new:
data = self.underline(data)
datas.append(self.to_phrase(data))
return self.stripped(self.join(datas))
def get_description(self, type_: str, informations: dict, modified_attributes: dict={}) -> str():
def _get_description(description, identifiers, delete=False, new=[]):
if "{{ identifier }}" in description:
if type_ == "variable":
identifiers_text = display_list([self.italic(i[-1]) for i in identifiers], separator="or")
description = description.replace('{{ identifier }}', identifiers_text)
else:
d = []
for i in identifiers:
new_description = description.replace('{{ identifier }}', self.italic(i[-1]))
if new_description not in d:
d.append(self.to_phrase(new_description))
description = display_list(d, separator="or")
else:
description = self.to_phrase(description)
if description in new:
description = self.underline(description)
if delete:
description = self.delete(description)
return description
if "description" in modified_attributes:
name, previous, new = modified_attributes["description"]
modified_description = _get_description(previous, modified_attributes.get("identifiers", []), delete=True)
else:
modified_description = None
new = []
description = _get_description(informations["description"], informations.get("identifiers"), new=new)
if modified_description:
if description:
description = self.join([modified_description, description])
else:
description = modified_description
if not description:
return None
return self.stripped(description)
# VARIABLE
def variable_to_string(self, informations: dict, table_datas: list, modified_attributes: dict={}) -> None:
"""Manage variable"""
calculated_properties = []
multi, first_column = self.variable_first_column(informations, calculated_properties, modified_attributes)
table_datas.append(
[
self.join(first_column),
self.join(
self.variable_second_column(informations, calculated_properties, modified_attributes, multi=multi)
),
]
)
def variable_first_column(
self, informations: dict, calculated_properties: list, modified_attributes: Optional[dict]
) -> list:
"""Collect string for the first column"""
multi, properties = self.property_to_string(informations, calculated_properties, modified_attributes)
first_col = [
self.join(
self.display_paths(informations, modified_attributes)
), properties
]
self.columns(first_col)
return multi, first_col
def variable_second_column(
self, informations: dict, calculated_properties: list, modified_attributes: dict, multi: bool
) -> list:
"""Collect string for the second column"""
second_col = []
#
if "description" in informations:
description = self.get_description("variable", informations, modified_attributes)
second_col.append(description)
#
help_ = self.convert_list_to_string("help", informations, modified_attributes)
if help_:
second_col.append(help_)
#
validators = self.convert_section_to_string("validators", informations, modified_attributes, multi=True)
if validators:
second_col.append(validators)
default_is_already_set, choices = self.convert_choices_to_string(informations, modified_attributes)
if choices:
second_col.append(choices)
if not default_is_already_set and "default" in informations:
self.convert_section_to_string("default", informations, modified_attributes, multi=multi)
second_col.append(self.convert_section_to_string("default", informations, modified_attributes, multi=multi))
examples = self.convert_section_to_string("examples", informations, modified_attributes, multi=True)
if examples:
second_col.append(examples)
second_col.extend(calculated_properties)
self.columns(second_col)
return second_col
def convert_section_to_string(self, attribute: str, informations: dict, modified_attributes: dict, multi: bool) -> str():
values = []
submessage = ""
if modified_attributes and attribute in modified_attributes:
name, previous, new = modified_attributes[attribute]
# if "identifiers" in modified_attributes:
# iname, iprevious, inew = modified_attributes["identifiers"]
# identifiers = iprevious.copy()
# for identifier in informations.get("identifiers", []):
# if identifier not in inew:
# identifiers.append(identifier)
#
# else:
# identifiers = informations.get("identifiers", [])
if isinstance(previous, list):
for p in previous:
submessage, m = self.message_to_string(p, submessage)
values.append(self.delete(m))
else:
submessage, old_values = self.message_to_string(previous, submessage)
values.append(self.delete(old_values))
else:
new = []
if attribute in informations:
old = informations[attribute]
name = old["name"]
if isinstance(old["values"], list):
for value in old["values"]:
submessage, old_value = self.message_to_string(value, submessage)
if value in new:
old_value = self.underline(old_value)
values.append(old_value)
if multi:
values = self.list(values)
else:
values = self.join(values)
elif values:
old_values = old["values"]
submessage, old_values = self.message_to_string(old_values, submessage)
if old["values"] in new:
old_values = self.underline(old_values)
values.append(old_values)
values = self.join(values)
else:
submessage, values = self.message_to_string(old["values"], submessage)
if old["values"] in new:
values = self.underline(values)
if values != []:
return self.section(name, values, submessage=submessage)
def convert_choices_to_string(self, informations: dict, modified_attributes: dict) -> str():
default_is_already_set = False
if "choices" in informations:
choices = informations["choices"]
choices_values = choices["values"]
if not isinstance(choices_values, list):
choices_values = [choices_values]
default_is_a_list = False
else:
default_is_a_list = True
if "default" in modified_attributes:
name, old_default, new_default = modified_attributes["default"]
if not old_default:
old_default = [None]
if not isinstance(old_default, list):
old_default = [old_default]
for value in old_default.copy():
if isinstance(value, str) and value.endswith(".") and value not in choices_values:
old_default.remove(value)
old_default.append(value[:-1])
else:
old_default = new_default = []
# check if all default values are in choices (could be from a calculation)
if "default" in informations:
default = informations["default"]["values"]
else:
default = []
if not isinstance(default, list):
default = [default]
default_value_not_in_choices = set(default) - set(choices_values)
if default_value_not_in_choices:
default_is_changed = False
for val in default_value_not_in_choices.copy():
if isinstance(val, str) and val.endswith('.') and val[:-1] in choices_values:
default.remove(val)
default.append(val[:-1])
default_is_changed = True
if val in new_default:
new_default.remove(val)
new_default.append(val[:-1])
if default_is_changed:
default_value_not_in_choices = set(default) - set(choices_values)
if default_value_not_in_choices:
old_default = []
new_default = []
default = []
else:
default_is_already_set = True
if "choices" in modified_attributes:
name, previous, new = modified_attributes["choices"]
for choice in reversed(previous):
if choice in old_default:
choices_values.insert(0, self.delete(dump(choice) + "" + _("(default)")))
else:
choices_values.insert(0, self.delete(dump(choice)))
else:
new = []
for idx, val in enumerate(choices_values):
if val in old_default:
choices_values[idx] = dump(val) + " " + self.delete("" + _("(default)"))
elif val in default:
if val in new_default:
if val in new:
choices_values[idx] = self.underline(dump(val) + " " + self.bold("" + _("(default)")))
else:
choices_values[idx] = dump(val) + " " + self.underline(self.bold("" + _("(default)")))
else:
choices_values[idx] = dump(val) + " " + self.bold("" + _("(default)"))
elif val in new:
choices_values[idx] = self.underline(dump(val))
# if old value and new value is a list, display a list
if not default_is_a_list and len(choices_values) == 1:
choices_values = choices_values[0]
return default_is_already_set, self.section(choices["name"], choices_values)
return default_is_already_set, None
# OTHERs
def to_phrase(self, text: str) -> str:
return text
def display_family_help(self, help_):
return self.to_phrase(help_) + ENTER
def property_to_string(
self, informations: dict, calculated_properties: list, modified_attributes: dict,
) -> str:
"""Transform properties to string"""
properties = []
local_calculated_properties = {}
multi = False
if "properties" in modified_attributes:
previous, new = self.get_modified_properties(*modified_attributes["properties"][1:])
for p, annotation in previous.items():
if p not in new:
properties.append(self.prop(self.delete(p), italic=False))
if annotation is not None:
local_calculated_properties[p] = [self.delete(annotation)]
else:
previous = new = []
for prop in informations.get("properties", []):
if prop["type"] == "type":
properties.append(self.link(prop["name"], ROUGAIL_VARIABLE_TYPE))
else:
if prop["type"] == "multiple":
multi = True
prop_name = prop["name"]
if "annotation" in prop:
italic = True
prop_annotation = prop["annotation"]
if prop_name in new and (prop_name not in previous or new[prop_name] != previous[prop_name]):
prop_annotation = self.underline(prop_annotation)
local_calculated_properties.setdefault(prop["name"], []).append(prop_annotation)
else:
italic = False
if prop_name not in previous and prop_name in new:
prop_name = self.underline(prop_name)
properties.append(self.prop(prop_name, italic=italic))
if local_calculated_properties:
for calculated_property_name, calculated_property in local_calculated_properties.items():
if len(calculated_property) > 1:
calculated_property = self.join(calculated_property)
else:
calculated_property = calculated_property[0]
calculated_properties.append(
self.section(calculated_property_name.capitalize(), calculated_property)
)
if not properties:
return multi, ""
return multi, " ".join(properties)
def get_modified_properties(self, previous: List[dict], new: List[dict]) -> Tuple[dict, dict]:
def modified_properties_parser(dico):
return {d["name"]: d.get("annotation") for d in dico}
return modified_properties_parser(previous), modified_properties_parser(new)
def columns(
self,
col: List[str], # pylint: disable=unused-argument
) -> None:
"""Manage column"""
return
def table(self, datas: list) -> str:
"""Transform list to a table in string format"""
msg = (
tabulate(
datas,
headers=self.table_header([_("Variable"), _("Description")]),
tablefmt=self._table_name,
)
+ "\n\n"
)
datas.clear()
return msg
def message_to_string(self, msg, ret, identifiers=[]):
if isinstance(msg, dict):
if "submessage" in msg:
ret += msg["submessage"]
msg = msg["values"]
elif "message" in msg:
path = calc_path(msg["path"], self, identifiers)
msg = msg["message"].format(path)
return ret, msg
def section(
self,
name: str,
msg: str,
submessage: str = "",
) -> str:
"""Return something like Name: msg"""
submessage, msg = self.message_to_string(msg, submessage)
if isinstance(msg, list):
if len(msg) == 1:
msg = calc_path(msg[0], self)
else:
lst = []
for p in msg:
submessage, elt = self.message_to_string(p, submessage)
lst.append(elt)
submessage += self.list(lst)
msg = ""
if not isinstance(msg, str):
submessage += dump(msg)
else:
submessage += msg
return _("{0}: {1}").format(self.bold(name), submessage)
def calc_path(path, formater=None, identifiers: List[str]=None) -> str:
def _path_with_identifier(path, identifier):
identifier = normalize_family(str(identifier))
if formater:
identifier = formater.italic(identifier)
return path.replace('{{ identifier }}', identifier, 1)
if isinstance(path, dict):
path_ = path["path"]
for identifier in path["identifiers"]:
path_ = _path_with_identifier(path_, identifier)
elif identifiers:
path_ = path
for identifier in identifiers:
path_ = _path_with_identifier(path_, identifier)
else:
path_ = path
return path_