""" 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 tabulate import tabulate from rougail.tiramisu import normalize_family from tiramisu import undefined from tiramisu.error import PropertiesOptionError, display_list try: from tiramisu_cmdline_parser.api import gen_argument_name except: gen_argument_name = None 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_ in ["family", "description"]: if msg.endswith("."): msg = msg[:-1] else: raise Exception("unknown type") # and start with a maj return msg[0].upper() + msg[1:] class CommonFormatter: """Class with common function for formatter""" enter_table = "\n" # tabulate module name name = None def __init__(self, rougailconfig, support_namespace, **kwarg): tabulate_module.PRESERVE_WHITESPACE = True self.header_setted = False self.rougailconfig = rougailconfig self.support_namespace = support_namespace def run( self, informations: dict, *, dico_is_already_treated=False ) -> str: """Transform to string""" if informations: level = self.rougailconfig["doc.title_level"] self.options() if self.root: current = informations for path in self.root.split('.'): info = current[path]['informations'] current = current[path]["children"] informations = {"informations": info, "children": current} return self._run(informations, level, dico_is_already_treated) return "" def options(self): self.with_commandline = self.rougailconfig["doc.with_commandline"] self.with_environment = self.rougailconfig["doc.with_environment"] if self.with_environment and not gen_argument_name: raise Exception('please install tiramisu_cmdline_parser') if not self.rougailconfig["main_namespace"] and self.with_environment: if self.support_namespace: self.prefix = "" else: self.prefix = self.rougailconfig["doc.environment_default_environment_name"] + "_" self.with_family = not self.rougailconfig["doc.without_family"] self.root = self.rougailconfig["doc.root"] self.other_root_filenames = None if self.root: try: other_root_filenames = self.rougailconfig["doc.other_root_filenames"] except PropertiesOptionError: pass else: if other_root_filenames: self.other_root_filenames = dict(zip(other_root_filenames["root_path"], other_root_filenames["filename"])) def compute(self, data): return "".join([d for d in data if d]) # Class you needs implement to your Formatter 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 underline( self, msg: str, ) -> str: """Set a text to underline""" raise NotImplementedError() def anchor(self, path: str, true_path: str, ) -> str: """Set a text to a link anchor""" return path def link_variable(self, path: str, true_path: str, description: str, filename: Optional[str], ) -> str: """Set a text link to variable anchor""" return path def stripped( self, text: str, ) -> str: """Return stripped text (as help)""" raise NotImplementedError() def list( self, choices: list, *, inside_table: bool=True, type_: str="variable", ) -> str: """Display a liste of element""" raise NotImplementedError() def prop( self, prop: str, italic: bool, delete: bool, underline: bool, ) -> str: """Display property""" raise NotImplementedError() def link( self, comment: str, link: str, underline: bool, ) -> str: """Add a link""" raise NotImplementedError() ################## def family_informations(self) -> str: return "" def end_family_informations(self) -> str: return ENTER def display_paths( self, informations: dict, modified_attributes: dict, force_identifiers: Optional[str], *, is_variable=False, variable_prefix: str="", is_bold: bool=True, is_upper: bool=False, ) -> str: ret_paths = [] path = informations["path"] if is_bold: bold = self.bold else: def bold(value): return value if is_upper: def upper(value): return value.upper() else: def upper(value): return value if "identifiers" in modified_attributes: name, previous, new = modified_attributes["identifiers"] ret_paths.extend( [ bold(self.delete(upper(variable_prefix + calc_path(path, self, identifier)))) for identifier in previous ] ) else: new = [] if "identifiers" in informations: for idx, identifier in enumerate(informations["identifiers"]): if force_identifiers and identifier != force_identifiers: continue path_ = calc_path(path, self, identifier) if variable_prefix: path_ = variable_prefix + upper(path_) if not idx: path_ = self.anchor(path_, path) if identifier in new: path_ = self.underline(path_) ret_paths.append(bold(path_)) else: ret_paths.append(bold(self.anchor(variable_prefix + upper(path), path))) return ret_paths def table_header( self, lst: list, ) -> tuple: """Manage the header of a table""" return lst 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 if init and self.root: if self.with_family: msg.extend(self.family_to_string(dico["informations"], level, False)) dico = dico["children"] 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": namespace = ori_level is None else: namespace = False if table_datas: msg.append(self.table(table_datas)) table_datas = [] msg.extend(self.family_to_string(value["informations"], level, namespace)) 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( self.get_description("family", informations, {}, None), level, ) def family_to_string(self, informations: dict, level: int, namespace: bool) -> str: """manage other family type""" if namespace: ret = [self.namespace_to_title(informations, level)] else: ret = [self.title(self.get_description("family", informations, {}, None), level)] fam_info = self.family_informations() if fam_info: ret.append(fam_info) msg = self.display_paths(informations, {}, None) helps = informations.get("help") if helps: for help_ in helps: msg.append(to_phrase(help_.strip())) calculated_properties = [] property_str = self.property_to_string(informations, calculated_properties, {})[1] if property_str: msg.append(property_str) if calculated_properties: msg.append(self.join(calculated_properties) ) if "identifier" in informations: msg.append( self.section(_("Identifiers"), informations["identifier"], type_="family") ) starts_line = self.family_informations_starts_line() ret.append(self.family_informations_ends_line().join([starts_line + m for m in msg]) + self.end_family_informations()) return ret def family_informations_starts_line(self) -> str: return "" def family_informations_ends_line(self) -> str: return ENTER 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"]) ) data = self.to_phrase(data) if data in new: data = self.underline(data) datas.append(data) return self.stripped(self.join(datas)) def get_description( self, type_: str, informations: dict, modified_attributes: dict, force_identifiers: Optional[str] ) -> str(): def _get_description(description, identifiers, delete=False, new=[]): if identifiers and "{{ identifier }}" in description: if type_ == "variable": identifiers_text = display_list( [self.italic(i[-1]) for i in identifiers if not force_identifiers or i == force_identifiers], separator="or" ) description = description.replace( "{{ identifier }}", identifiers_text ) else: d = [] for i in identifiers: if force_identifiers and i != force_identifiers: continue 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"] if previous: modified_description = _get_description( previous[0], modified_attributes.get("identifiers", []), delete=True ) else: modified_description = None 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 = {}, force_identifiers: Optional[str]=None ) -> None: """Manage variable""" calculated_properties = [] multi, first_column = self.variable_first_column( informations, calculated_properties, modified_attributes, force_identifiers ) table_datas.append( [ self.join(first_column), self.join( self.variable_second_column( informations, calculated_properties, modified_attributes, multi, force_identifiers, ) ), ] ) def variable_first_column( self, informations: dict, calculated_properties: list, modified_attributes: Optional[dict], force_identifiers: Optional[str], ) -> list: """Collect string for the first column""" multi, properties = self.property_to_string( informations, calculated_properties, modified_attributes, ) paths = self.display_paths(informations, modified_attributes, force_identifiers, is_variable=True) first_col = [ self.join(paths), properties, ] if self.with_commandline: paths = self.display_paths(informations, modified_attributes, force_identifiers, is_variable=True, is_bold=False, variable_prefix="--") variable_type = informations["properties"][0]["name"] if variable_type == 'boolean': for path in list(paths): paths.append(gen_argument_name(path, False, True, False)) if "alternative_name" in informations: alternative_name = informations["alternative_name"] paths[0] += f", -{alternative_name}" if variable_type == 'boolean': paths[1] += ", -" + gen_argument_name(alternative_name, True, True, False) first_col.append(self.section(_("Command line"), paths)) if self.with_environment: paths = self.display_paths(informations, modified_attributes, force_identifiers, is_variable=True, is_bold=False, is_upper=True, variable_prefix=self.prefix) first_col.append(self.section(_("Environment variable"), paths)) self.columns(first_col) return multi, first_col def variable_second_column( self, informations: dict, calculated_properties: list, modified_attributes: dict, multi: bool, force_identifiers: Optional[str], ) -> list: """Collect string for the second column""" second_col = [] # if "description" in informations: description = self.get_description( "variable", informations, modified_attributes, force_identifiers, ) 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) tags = self.convert_section_to_string( "tags", informations, modified_attributes, multi=True ) if tags: second_col.append(tags) 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 for idx, choice in enumerate(choices_values.copy()): if isinstance(choice, dict): choices_values[idx] = self.message_to_string(choice, None)[1] 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] for idx, value in enumerate(default.copy()): if isinstance(value, dict): default[idx] = self.message_to_string(value, None)[1] 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 isinstance(choice, dict): choice = self.message_to_string(choice, None)[1] 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 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(p, italic=False, delete=True, underline=False)) if annotation is not None: local_calculated_properties[p] = [{"annotation": annotation, "delete": True}] else: previous = new = [] for prop in informations.get("properties", []): prop_name = prop["name"] if prop_name not in previous and prop_name in new: underline = True else: underline = False if prop["type"] == "type": properties.append(self.link(prop_name, ROUGAIL_VARIABLE_TYPE, underline)) else: if prop["type"] == "multiple": multi = True 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] ): underline_ = True # prop_annotation = self.underline(prop_annotation) else: underline_ = False local_calculated_properties.setdefault(prop["name"], []).append( {"annotation": prop_annotation, "underline": underline_} ) else: italic = False properties.append(self.prop(prop_name, italic=italic, delete=False, underline=underline)) if local_calculated_properties: for ( calculated_property_name, calculated_property, ) in local_calculated_properties.items(): # calculated_property = calculated_property_data["annotation"] data = [] for calc in calculated_property: annotation = self.message_to_string(calc["annotation"], None)[1] if calc.get("underline", False): annotation = self.underline(annotation) if calc.get("delete", False): annotation = self.delete(annotation) data.append(annotation) if len(calculated_property) > 1: calculated_property = self.join(data) else: calculated_property = data[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, with_header: bool = True) -> str: """Transform list to a table in string format""" if with_header: headers = self.table_header([_("Variable"), _("Description")]) else: headers = () msg = ( tabulate( datas, headers=headers, 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: filename = None if self.other_root_filenames: path = msg["path"]["path"] for root in self.other_root_filenames: if path == root or path.startswith(f'{root}.'): filename = self.other_root_filenames[root] break if "identifiers" in msg["path"]: msg["identifiers"] = [msg["path"]["identifiers"]] path = self.link_variable(calc_path(msg["path"], self, identifiers), msg["path"]["path"], self.get_description("variable", msg, {}, None), filename=filename) msg = msg["message"].format(path) elif "description" in msg: if "variables" in msg: paths = [] for variable in msg["variables"]: filename = None if self.other_root_filenames: path = msg["path"] for root in self.other_root_filenames: if path == root or path.startswith(f'{root}.'): filename = self.other_root_filenames[root] break identifiers = variable.get("identifiers") path = calc_path(variable, self, identifiers) paths.append(self.link_variable(path, variable["path"], self.get_description("variable", variable, {}, force_identifiers=identifiers), filename=filename)) msg = msg["description"].format(*paths) else: msg = msg["description"] return ret, msg def section( self, name: str, msg: str, submessage: str = "", type_ = "variable", ) -> str: """Return something like Name: msg""" submessage, msg = self.message_to_string(msg, submessage) if isinstance(msg, list): if len(msg) == 1: submessage, elt = self.message_to_string(msg[0], submessage) if isinstance(elt, list): submessage += self.list(elt, type_=type_) else: submessage += elt else: lst = [] for p in msg: submessage, elt = self.message_to_string(p, submessage) lst.append(elt) submessage += self.list(lst, type_=type_) msg = "" if not isinstance(msg, str): submessage += dump(msg) else: submessage += msg return _("{0}: {1}").format(self.bold(name), submessage) def calc_path(path, formatter=None, identifiers: List[str] = None) -> str: def _path_with_identifier(path, identifier): identifier = normalize_family(str(identifier)) if formatter: identifier = formatter.italic(identifier) return path.replace("{{ identifier }}", identifier, 1) if isinstance(path, dict): path_ = path["path"] if "identifiers" in 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_