""" 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 . """ 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_