""" 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 warnings import warn from typing import Optional from itertools import chain from tiramisu import Calculation, undefined, groups from tiramisu.error import ConfigError, display_list from rougail.error import display_xmlfiles from rougail.object_model import PROPERTY_ATTRIBUTE from .config import OutPuts from .i18n import _ from .utils import DocTypes, get_display_path from .example import Examples class RougailOutputDoc(Examples): """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 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 = rougailconfig["doc.output_format"] if output not in outputs: raise Exception( f'cannot find output "{output}", 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.formater = outputs[output]() self.level = rougailconfig["doc.title_level"] self.dynamic_paths = {} self.example = rougailconfig["doc.example"] self.with_family = not rougailconfig["doc.without_family"] self.informations = None try: groups.namespace self.support_namespace = True except AttributeError: self.support_namespace = False self.property_to_string = [ ("mandatory", _("mandatory")), ("hidden", _("hidden")), ("disabled", _("disabled")), ("unique", _("unique")), ("force_store_value", _("auto modified")), ] super().__init__() def run(self) -> str: """Print documentation in stdout""" self._tiramisu_to_internal_object() if not self.example: return_string = self.formater.run(self.informations, self.level) else: return_string = self.gen_doc_examples() return True, return_string def print(self) -> None: ret, data = self.run() print(data) return ret def _tiramisu_to_internal_object(self): 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(): path = child.path(uncalculated=True) if not child.isoptiondescription(): func = self._populate_dynamic_variable else: func = self._populate_dynamic_family func(child, path) def _populate_dynamic_variable(self, variable, path) -> None: if not variable.isdynamic(): return if path not in self.dynamic_paths: self.dynamic_paths[path] = {"paths": [], "names": []} self._dyn_path_to_italic(self.dynamic_paths[path], variable, path) self.dynamic_paths[path]["names"].append(variable.name()) def _populate_dynamic_family(self, family, path) -> None: if family.isdynamic(): if path not in self.dynamic_paths: self.dynamic_paths[path] = {"paths": [], "names": []} self._dyn_path_to_italic(self.dynamic_paths[path], family, path) self.dynamic_paths[path]["names"].append(family.name()) self._populate_dynamics(family) 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", "disabled", ]: # chain(["hidden", "disabled"], self.disabled_modes): if hidden_property in properties: 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: informations[name] = { "type": self._get_family_type(family), "informations": self._populate_family( family, path, ), "children": sub_informations, } else: informations.update(sub_informations) def _parse_variable( self, variable, leader: dict, name: str, path: str, informations: dict ) -> Optional[dict]: if variable.isdynamic(): # information is already set potential_leader = self._parse_variable_dynamic( variable, leader, name, path, informations ) elif variable.isfollower() and variable.index(): potential_leader = self._parse_variable_follower_with_index( variable, path, informations ) else: 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] else: sub_informations = {} self._populate_variable( variable, sub_informations, ) if self.example: self._add_examples(variable, sub_informations, leader) informations[path] = sub_informations if variable.isleader(): return sub_informations return None def _parse_variable_follower_with_index( self, variable, path: str, informations: dict ) -> None: if not self.example: return None informations[path]["example"][-1][variable.index()] = self._get_example( variable, informations[path], None ) return None def _parse_variable_dynamic( self, variable, leader, name, path, informations ) -> None: dynamic_variable = self.dynamic_paths[path] if "type" in dynamic_variable: if self.example: dynamic_variable["example"].append( self._get_example(variable, dynamic_variable, leader) ) description = self.formater.to_phrase(variable.description(uncalculated=True)) if "{{ identifier }}" in description: description = self._convert_description(description, variable) dynamic_variable["descriptions"].append(self.formater.to_phrase(description)) if variable.isleader(): return dynamic_variable 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 _dyn_path_to_italic(self, dico, child, path: str) -> str: display_path = path for identifier in child.identifiers(): display_path = display_path.replace( "{{ identifier }}", self.formater.italic(identifier), 1 ) path = path.replace("{{ identifier }}", str(identifier), 1) if display_path != path: if "display_paths" not in dico: dico["display_paths"] = {} dico["display_paths"][len(dico["paths"])] = display_path dico["paths"].append(path) def _populate_family( self, family, path: str, ) -> dict: if family.isdynamic(): informations = self.dynamic_paths[path] else: informations = {} self._populate(family, informations) 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) informations["identifiers"] = 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"] = default self._parse_type( variable, informations, ) self._populate(variable, informations) if "description" in informations: informations["descriptions"] = [self.formater.to_phrase(informations.pop("description"))] 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: informations["examples"] = list(examples) def _populate( self, obj, informations: dict, ): if not obj.isdynamic(): informations["paths"] = [obj.path(uncalculated=True)] informations["names"] = [obj.name()] description = obj.description(uncalculated=True) if obj.name(uncalculated=True) == description and ( not obj.isoptiondescription() or (self.support_namespace and obj.group_type() is not groups.namespace) ): warning = _('No attribute "description" for "{0}" in {1}').format( obj.path(uncalculated=True), display_xmlfiles(obj.information.get("dictionaries")), ) warn(warning) else: informations["description"] = self._convert_description(description, obj) help_ = obj.information.get("help", None) if help_: informations["help"] = [self.formater.to_phrase(help_)] self._parse_properties( obj, informations, ) def _convert_description(self, description, obj): if "{{ identifier }}" in description: return description.replace( "{{ identifier }}", self.formater.italic(obj.identifiers()[-1]) ) return description def _add_examples(self, variable, informations: dict, leader) -> None: example = self._get_example(variable, informations, leader) informations["example"] = [example] informations["mandatory_without_value"] = "mandatory" in variable.property.get( uncalculated=True ) and variable.value.get(uncalculated=True) in [None, []] def _get_example(self, variable, informations: dict, leader): example = informations.get("examples") if example is not None: if isinstance(example, tuple): example = list(example) for prop in informations["properties"]: if prop["type"] == "multiple": multi = True break else: multi = False if not multi: example = example[0] 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.convert_option.get( variable.information.get("type"), {} ).get("example", None) if example is None: example = "xxx" for prop in informations["properties"]: if prop["type"] == "multiple": multi = True break else: multi = False if multi: example = [example] if leader is not None and variable.isfollower(): example = [example] + [undefined] * (len(leader["example"][-1]) - 1) return example def _parse_type( self, variable, informations, ): variable_type = variable.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 = variable.get() 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) informations.setdefault("validators", []).append(msg.format(value)) # get validation information from annotator for name in variable.information.list(): if not name.startswith("validators_calculation"): continue informations.setdefault("validators", []).extend( self._to_string( variable, "validators", ) ) break if variable.information.get("type") == "choice": choices = self._to_string(variable, "choice", do_not_raise=True) if choices is None: choices = variable.value.list() for idx, val in enumerate(choices): if not isinstance(val, Calculation): default = informations.get("default") if default is not None and val == default: choices[idx] = str(val) + " " + self.formater.bold("← " + _("(default)")) informations["display_default"] = False continue choices[idx] = self._to_string(variable, "choice", f"_{idx}") informations["choices"] = choices if variable.information.get("type") == "regexp": informations.setdefault("validators", []).append( _('text based with regular expressions "{0}"').format( variable.pattern() ) ) def _parse_properties( self, variable, informations, ): properties = variable.property.get(uncalculated=True) for mode in self.modes_level: if mode not in properties: continue informations.setdefault("properties", []).append( { "type": "mode", "name": mode, } ) break for prop, msg in self.property_to_string: if prop in properties: prop_obj = { "type": "property", "name": msg, } elif variable.information.get(f"{prop}_calculation", False): prop_obj = { "type": "property", "name": msg, "annotation": self._to_string(variable, prop), } else: continue informations.setdefault("properties", []).append(prop_obj) def _get_default( self, variable, ): if not variable.information.get("default_value_makes_sense", True): return None default = self._to_string(variable, "default", do_not_raise=True) if default is not None: if default == []: default = None return default default = variable.value.get(uncalculated=True) if default == []: default = None return default def _to_string( self, variable, prop, identifier="", do_not_raise=False, ): if identifier: raise Exception("pfff") calculation = variable.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 do_not_raise and calculation.get('optional', False): # return None if isinstance(calculation, list): values = [] for cal in calculation: value = self._calculation_to_string(variable, cal, prop) if value is not None: values.append(value) return values return self._calculation_to_string(variable, calculation, prop) def _calculation_to_string(self, variable, calculation, prop): if "type" not in calculation: return calculation["value"] if calculation["type"] == "jinja": if calculation["value"] is not True: values = calculation["value"] else: values = _("depends on a calculation") warning = _( '"{0}" is a calculation for {1} but has no description in {2}' ).format( prop, variable.path(), display_xmlfiles(variable.information.get("dictionaries")), ) warn(warning) elif calculation["type"] == "variable": if prop in PROPERTY_ATTRIBUTE: values = calculation["value"] else: if calculation.get("optional", False): path = calculation["value"] if "{{ identifier }}" in path: if path not in self.dynamic_paths: return None else: try: self.conf.option(path).get() except AttributeError: return None true_msg = _('the value of the variable "{0}"') hidden_msg = _("the value of an undocumented variable") if "{{ identifier }}" in calculation["value"]: informations = self.dynamic_paths[calculation["value"]] values = [] all_is_undocumented = None for idx, path in enumerate(informations["paths"]): if self._is_inaccessible_user_data(self.conf.option(path)): msg = true_msg.format(get_display_path(informations, index)) all_is_undocumented = False else: if all_is_undocumented is None: all_is_undocumented = True msg = hidden_msg values.append(msg) if all_is_undocumented and len(values) > 1: values = _("the values of undocumented variables") else: values = true_msg.format(calculation["value"]) 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 isinstance(values, str) and not values.endswith("."): values += "." return values