""" 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 warnings import warn from typing import Optional from itertools import chain from re import compile from tiramisu import Calculation, groups from tiramisu.error import ConfigError, display_list, PropertiesOptionError from rougail.tiramisu import display_xmlfiles, normalize_family from rougail.utils import undefined, get_properties_to_string, PROPERTY_ATTRIBUTE from rougail.error import VariableCalculationDependencyError, RougailWarning from .config import OutPuts from .i18n import _ from .utils import DocTypes, dump, to_phrase, calc_path from .example import Examples from .changelog import Changelog HIDDEN_PROPERTIES = [ "hidden", "disabled", ] class RougailOutputDoc(Examples, Changelog): """Rougail Output Doc: Generate documentation from rougail description files """ def __init__( self, config: "Config", *, rougailconfig: "RougailConfig" = None, **kwarg, ): # Import here to avoid circular import from rougail.tiramisu import CONVERT_OPTION self.convert_option = CONVERT_OPTION if rougailconfig is None: from rougail import RougailConfig rougailconfig = RougailConfig if rougailconfig["step.output"] != "doc": rougailconfig["step.output"] = "doc" if rougailconfig["step.output"] != "doc": raise Exception("doc is not set as step.output") outputs = OutPuts().get() output_format = rougailconfig["doc.output_format"] if output_format not in outputs: raise Exception( f'cannot find output "{output_format}", available outputs: {list(outputs)}' ) self.conf = config self.modes_level = rougailconfig["modes_level"] if self.modes_level: self.disabled_modes = rougailconfig["doc.disabled_modes"] if self.disabled_modes: self.conf.property.setdefault( frozenset(self.disabled_modes), "read_write", "append" ) else: self.disabled_modes = [] self.conf.property.read_write() # self.conf.property.remove("cache") self.output_format = output_format self.level = rougailconfig["doc.title_level"] self.contents = rougailconfig["doc.contents"] self.example = "example" in self.contents if "variables" in self.contents: self.with_family = not rougailconfig["doc.without_family"] else: self.with_family = True if "changelog" in self.contents: self.previous_json_file = rougailconfig['doc.previous_json_file'] self.formater = outputs[output_format](self.with_family) self.informations = None try: groups.namespace self.support_namespace = True except AttributeError: self.support_namespace = False self.property_to_string = get_properties_to_string() super().__init__() def run(self) -> str: """Print documentation in stdout""" self.load() return_string = '' if "variables" in self.contents: return_string += self.formater.run(self.informations, self.level) if "example" in self.contents: return_string += self.gen_doc_examples() if "changelog" in self.contents: return_string += self.gen_doc_changelog() return True, return_string def print(self) -> None: ret, data = self.run() print(data) return ret def load(self): self.dynamic_paths = {} config = self.conf.unrestraint self._populate_dynamics(config) informations = self._parse_families(config) if informations is None: informations = {} self.informations = informations def _populate_dynamics(self, family) -> None: for child in family.list(): if child.isoptiondescription(): type_ = "family" else: type_ = "variable" if child.isdynamic(): self._populate_dynamic(child, child.path(uncalculated=True), type_) if child.isoptiondescription(): self._populate_dynamics(child) def _populate_dynamic(self, obj, path, type_) -> None: if path not in self.dynamic_paths: new_name = True description = obj.description(uncalculated=True) name = obj.name(uncalculated=True) self.dynamic_paths[path] = {"names": [], "identifiers": [], "path": path, } if not obj.information.get("forced_description", False): self.dynamic_paths[path]["description"] = self._convert_description(description, obj, type_, its_a_path=False) elif obj.isoptiondescription(): self.dynamic_paths[path]["description"] = self._convert_description(description, obj, type_, its_a_path=True) dynamic_obj = self.dynamic_paths[path] dynamic_obj["names"].append(obj.name()) dynamic_obj["identifiers"].append(obj.identifiers()) def _parse_families(self, family) -> dict: informations = {} leader = None for child in family.list(): if self._is_inaccessible_user_data(child): continue if child.type(only_self=True) == "symlink": continue name = child.name(uncalculated=True) path = child.path(uncalculated=True) if not child.isoptiondescription(): leader = self.parse_variable(child, leader, name, path, informations) else: self._parse_family(child, informations, name, path) return informations def _is_inaccessible_user_data(self, child): """If family is not accessible in read_write mode (to load user_data), do not comment this family """ properties = child.property.get(uncalculated=True) for hidden_property in HIDDEN_PROPERTIES: if hidden_property in properties: return True calculation = child.information.get(f"{hidden_property}_calculation", None) if calculation and calculation.get("type") == "variable": variable_path, value, condition = calculation["value"] variable = self.conf.forcepermissive.option(variable_path) try: variable_value = variable.value.get() except AttributeError as err: pass else: uncalculated = variable.value.get(uncalculated=True) if ( not isinstance(uncalculated, Calculation) and self._is_inaccessible_user_data(variable) and ( condition == "when" and value == variable_value or condition == "when_not" and value != variable_value ) ): return True if not child.isoptiondescription(): for hidden_property in self.disabled_modes: if hidden_property in properties: return True return False def _parse_family(self, family, informations: dict, name: str, path: str) -> None: sub_informations = self._parse_families(family) if not sub_informations: return # if self.with_family: family_informations = self._populate_family( family, path, ) if family_informations is not False: informations[name] = { "type": self._get_family_type(family), "informations": family_informations, "children": sub_informations, } # else: # informations.update(sub_informations) def parse_variable( self, variable, leader: dict, name: str, path: str, informations: dict, *, only_one=False, ) -> Optional[dict]: potential_leader = None if variable.isdynamic(): # information is already set potential_leader = self._parse_variable_dynamic( variable, leader, name, path, informations, only_one ) else: if variable.isfollower() and variable.index(): self._parse_variable_follower_with_index( variable, leader, path, informations ) potential_leader = self._parse_variable_normal( variable, leader, name, path, informations ) if potential_leader: leader = potential_leader return leader def _parse_variable_normal( self, variable, leader, name: str, path: str, informations: dict ) -> Optional[dict]: if variable.isdynamic(): sub_informations = self.dynamic_paths[path] elif variable.isfollower() and path in informations: # variable.index(): sub_informations = informations[name] else: sub_informations = {} if not self._populate_variable( variable, sub_informations, ): return None if self.example: self._add_examples(variable, sub_informations, leader) informations[name] = sub_informations if variable.isleader(): return sub_informations return None def _parse_variable_follower_with_index( self, variable, leader: dict, path: str, informations: dict ) -> None: if not self.example or (variable.index() + 1) > len(leader["example"][-1]): return informations[name]["example"][-1][variable.index()] = self._get_example( variable, informations[name], None ) def _parse_variable_dynamic( self, variable, leader, name, path, informations, only_one ) -> None: if path not in self.dynamic_paths: self._populate_dynamic(variable, path) dynamic_variable = self.dynamic_paths[path] if (not only_one or path in informations) and "type" in dynamic_variable: if self.example: dynamic_variable["example"].append( self._get_example(variable, dynamic_variable, leader) ) if variable.isleader(): return dynamic_variable if not only_one: return None return self._parse_variable_normal(variable, leader, name, path, informations) def _get_family_type(self, family) -> str: if self.support_namespace and family.group_type() is groups.namespace: return "namespace" if family.isleadership(): return "leadership" if family.isdynamic(only_self=True): return "dynamic" return "family" def _populate_family( self, family, path: str, ) -> dict: if family.isdynamic(): informations = self.dynamic_paths[path] else: informations = {} if not self._populate(family, informations, 'family'): return False if family.isleadership(): informations.setdefault("help", []).append( _("This family contains lists of variable blocks.") ) if family.isdynamic(only_self=True): identifiers = self._to_string(family, "dynamic", do_not_raise=True) if identifiers is None: identifiers = family.identifiers(only_self=True) if not isinstance(identifiers, list): identifiers = [identifiers] informations["identifier"] = identifiers informations.setdefault("help", []).append( _("This family builds families dynamically.") ) return informations def _populate_variable( self, variable, informations: dict, ): informations["type"] = "variable" default = self._get_default( variable, ) if default is not None: informations["default"] = {"name": _("Default"), "values": default} self._parse_type( variable, informations, ) if not self._populate(variable, informations, 'variable'): return False if variable.ismulti(): multi = not variable.isfollower() or variable.issubmulti() else: multi = False if multi: informations["properties"].append( { "type": "multiple", "name": _("multiple"), } ) examples = variable.information.get("examples", None) if examples is None: examples = variable.information.get("test", None) if examples is not None: if len(examples) == 1: informations["examples"] = {"name": _("Example"), "values": examples[0]} else: informations["examples"] = { "name": _("Examples"), "values": list(examples), } return True def _populate( self, child, informations: dict, type_: str, ): need_disabled, properties = self._parse_properties(child) if not need_disabled: return False name = child.name(uncalculated=True) if child.information.get("forced_description", False): if not child.isoptiondescription() or not self.support_namespace or child.group_type() is not groups.namespace: if child.isoptiondescription() or not child.isfollower() or not child.index(): warning = _('No attribute "description" for "{0}" in {1}').format( child.path(uncalculated=True), display_xmlfiles(child.information.get("ymlfiles")), ) warn(warning, RougailWarning, ) if child.isoptiondescription(): description = self._convert_description(child.description(uncalculated=True), child, type_, its_a_path=True) else: description = None else: description = self._convert_description(child.description(uncalculated=True), child, type_, its_a_path=False) if not child.isdynamic(): informations["path"] = child.path(uncalculated=True) informations["names"] = [child.name()] if description is not None: informations["description"] = description help_ = child.information.get("help", None) if help_: informations["help"] = [to_phrase(help_)] if "properties" in informations: informations["properties"].extend(properties) else: informations["properties"] = properties return True def _convert_description(self, description, obj, type_, its_a_path=False): if not its_a_path: description = to_phrase(description, type_) # if "{{ identifier }}" in description: # description = {"description": description, # "identifier": obj.identifiers()[-1], # } return description def _add_examples(self, variable, informations: dict, leader) -> None: if not variable.index(): example = self._get_example(variable, informations, leader) informations["example"] = [example] informations["mandatory_without_value"] = "mandatory" in variable.property.get( uncalculated=True ) and ( not variable.information.get("default_value_makes_sense", True) or variable.value.get(uncalculated=True) in [None, []] ) def _get_example(self, variable, informations: dict, leader): example = informations.get("examples", {}).get("values") if example is not None: if isinstance(example, tuple): example = list(example) for prop in informations["properties"]: if prop["type"] == "multiple": if not isinstance(example, list): example = [example] break else: if isinstance(example, list): index = variable.index() if index is None or len(example) - 1 >= index: index = 0 example = example[index] else: if variable.information.get("fake_default", False): default = None else: try: default = variable.value.get() except ConfigError: default = None if default not in [None, []]: example = default else: example = self.get_type_default_value( variable, informations["properties"] ) if leader is not None and variable.isfollower(): example = [example] + [undefined] * (len(leader["example"][-1]) - 1) return example def get_type_default_value(self, variable, properties): example = self.convert_option.get(variable.information.get("type"), {}).get( "example", None ) if example is None: example = "xxx" for prop in properties: if prop["type"] == "multiple": multi = True break else: multi = False if multi: example = [example] return example def _parse_type( self, child, informations, ): variable_type = child.information.get("type") doc_type = DocTypes.get(variable_type, {"params": {}}) informations["properties"] = [ { "type": "type", "name": doc_type.get("msg", variable_type), } ] # extra parameters for types option = child.get() validators = [] for param, msg in doc_type["params"].items(): value = option.impl_get_extra(f"_{param}") if value is None: value = option.impl_get_extra(param) if value is not None and value is not False: if isinstance(value, set): value = list(value) if isinstance(value, list): value = display_list(value, add_quote=True) validators.append(msg.format(value)) # get validation information from annotator for name in child.information.list(): if not name.startswith("validators_calculation"): continue validators.extend( self._to_string( child, "validators", ) ) break if child.information.get("type") == "regexp": validators.append( _('text based with regular expressions "{0}"').format( child.pattern() ) ) if validators: if len(validators) == 1: key = _("Validator") validators = validators[0] else: key = _("Validators") informations["validators"] = {"name": key, "values": validators} if child.information.get("type") == "choice": choices = self._to_string(child, "choice", do_not_raise=True) if choices is None: choices = child.value.list() for idx, val in enumerate(choices): if isinstance(val, Calculation): choices[idx] = self._to_string(child, "choice", f"_{idx}") informations["choices"] = {"name": _("Choices"), "values": choices} def _parse_properties( self, child, ): informations = [] properties = child.property.get(uncalculated=True) for mode in self.modes_level: if mode not in properties: continue informations.append( { "type": "mode", "name": mode, } ) break for prop, translated_prop in self.property_to_string: if prop in properties: prop_obj = { "type": "property", "name": translated_prop, } elif child.information.get(f"{prop}_calculation", False): annotation = self._to_string(child, prop) if annotation is None or isinstance(annotation, bool): if annotation is None and prop in HIDDEN_PROPERTIES: return False, {} if not annotation: continue prop_obj = { "type": "property", "name": translated_prop, } else: prop_obj = { "type": "property", "name": translated_prop, "annotation": annotation, } else: # this property is not in the variable so, do not comment it continue informations.append(prop_obj) return True, informations def _get_default( self, variable, ): default = self._to_string(variable, "default", do_not_raise=True) if default is not None: if default == []: default = None return default if variable.information.get("default_value_makes_sense", True): default_ = variable.value.get(uncalculated=True) if not isinstance(default_, Calculation): default = default_ if default == []: default = None return default def _to_string( self, child, prop, do_not_raise=False, ): calculation = child.information.get(f"{prop}_calculation", None) if not calculation: if do_not_raise: return None raise Exception( f'cannot find "{prop}_calculation" information, ' "do you have declare doc has a plugins?" ) if isinstance(calculation, list): values = [] for cal in calculation: value = self._calculation_to_string(child, cal, prop, inside_list=True) if value is not None: values.append(value) return values return self._calculation_to_string(child, calculation, prop) def _calculation_to_string(self, child, calculation, prop, inside_list=False): if "description" in calculation: values = calculation["description"] if not values.endswith("."): values += "." return values if "type" not in calculation: return calculation["value"] if calculation["type"] == "jinja": values = self._calculation_jinja_to_string(child, calculation, prop) elif calculation["type"] == "variable": values = self._calculation_variable_to_string(child, calculation, prop) elif calculation["type"] == "identifier": if prop in PROPERTY_ATTRIBUTE: values = calculation["value"] else: values = _("the value of the identifier") elif calculation["type"] == "information": values = calculation["value"] else: values = _("the value of the {0}").format(calculation["type"]) if not inside_list and isinstance(values, str) and not values.endswith("."): values += "." return values def _calculation_jinja_to_string(self, child, calculation, prop): if calculation["value"] is not True: values = calculation["value"] else: values = _("depends on a calculation") if ( child.isoptiondescription() or not child.isfollower() or not child.index() ): warning = _( '"{0}" is a calculation for {1} but has no description in {2}' ).format( prop, child.path(), display_xmlfiles(child.information.get("ymlfiles")), ) warn(warning, RougailWarning, ) return values def _calculation_variable_to_string(self, child, calculation, prop): if prop in PROPERTY_ATTRIBUTE: variable_path, value, condition = calculation["value"] variable = self.conf.forcepermissive.option(variable_path) try: variable.value.get() except AttributeError as err: if prop in HIDDEN_PROPERTIES: return False variable = None if variable and self._is_inaccessible_user_data(variable): try: variable_value = self._get_unmodified_default_value(variable) except VariableCalculationDependencyError: msg = _("depends on an undocumented variable") else: if condition == "when" and value == variable_value or condition == "when_not" and value != variable_value: if prop in HIDDEN_PROPERTIES: return # always "{prop}" (but depends on an undocumented variable) return True # depends on an undocumented variable but is never "{prop}" return False elif condition == "when_not": if not calculation["optional"]: msg = _('when the variable "{0}" hasn\'t the value "{1}"') else: msg = _('when the variable "{0}" is defined and hasn\'t the value "{1}"') else: if not calculation["optional"]: msg = _('when the variable "{0}" has the value "{1}"') else: msg = _('when the variable "{0}" is defined and has the value "{1}"') if not isinstance(value, str): value = dump(value) values = msg.format(variable_path, value) else: if calculation["optional"]: path = calculation["value"] if "{{ identifier }}" in path: if path not in self.dynamic_paths: return None else: try: self.conf.forcepermissive.option(path).get() except AttributeError: return None if not calculation["optional"]: true_msg = _('the value of the variable "{0}"') else: true_msg = _('the value of the variable "{0}" if it is defined') hidden_msg = _("the value of an undocumented variable") if "{{ identifier }}" in calculation["ori_path"]: if calculation["value"] == calculation["ori_path"]: regexp = None else: regexp = compile( "^" + calculation["ori_path"].replace( "{{ identifier }}", "(.*)" ) + "$" ) informations = [self.dynamic_paths[calculation["value"]]] values = [] all_is_undocumented = None for information in informations: # if calculation["ori_path"] == information['path']: path = information["path"] for identifiers in information["identifiers"]: cpath = calc_path(path, identifiers=identifiers) if regexp and not regexp.search(cpath): continue if self._is_inaccessible_user_data(self.conf.option(cpath)): if all_is_undocumented is None: all_is_undocumented = True msg = hidden_msg else: if "{{ identifier }}" in path: msg = {"message": true_msg, "path": {"path": path, "identifiers": identifiers.copy()}, } else: msg = true_msg.format(path) all_is_undocumented = False values.append(msg) if all_is_undocumented and len(values) > 1: values = _("the values of undocumented variables") else: variable_path = calculation["ori_path"] variable = self.conf.forcepermissive.option(variable_path) try: isfollower = variable.isfollower() except AttributeError as err: pass else: if not isfollower and self._is_inaccessible_user_data(variable): try: uncalculated = variable.value.get(uncalculated=True) except PropertiesOptionError: true_msg = None else: if uncalculated and not isinstance( uncalculated, Calculation ): if isinstance(uncalculated, list): true_msg = {"submessage": _("(from an undocumented variable)"), "values": uncalculated, } else: if not isinstance(uncalculated, str): uncalculated = dump(uncalculated) true_msg = _( "{0} (from an undocumented variable)" ).format(uncalculated) else: true_msg = _("depends on an undocumented variable") if true_msg: if isinstance(true_msg, dict): values = true_msg else: values = true_msg.format(calculation["ori_path"]) else: values = None return values def _get_unmodified_default_value(self, child): calculation = child.information.get(f"default_calculation", None) if not calculation: return child.value.get() if calculation["type"] == "variable": variable = self.conf.forcepermissive.option(calculation["value"]) if variable and self._is_inaccessible_user_data(variable): return self._get_unmodified_default_value(variable) raise VariableCalculationDependencyError()