""" 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") self.outputs = OutPuts().get() 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.rougailconfig = rougailconfig self.informations = None try: groups.namespace self.support_namespace = True except AttributeError: self.support_namespace = False self.property_to_string = get_properties_to_string() self.formatter = None super().__init__() def run(self) -> str: """Print documentation in stdout""" self.load() self.load_formatter() return_string = "" contents = self.rougailconfig["doc.contents"] if "variables" in contents: return_string += self.formatter.run(self.informations) if "example" in contents: return_string += self.gen_doc_examples() if "changelog" in contents: return_string += self.gen_doc_changelog() return True, return_string def load_formatter(self) -> str: output_format = self.rougailconfig["doc.output_format"] self.formatter = self.outputs[output_format](self.rougailconfig) 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=config) informations = self.parse_families(config) if informations is None: informations = {} self.informations = informations def populate_dynamics(self, *, config=None, reload=False): if config is None: config = self.conf.unrestraint self._populate_dynamics(config, reload) def _populate_dynamics(self, family, reload, uncalculated=False) -> None: def populate(child, uncalculated): if child.isoptiondescription(): type_ = "family" else: type_ = "variable" if child.isdynamic(): self.populate_dynamic(child, type_, reload, uncalculated) if child.isoptiondescription(): self._populate_dynamics(child, reload, uncalculated) for child in family.list(uncalculated=uncalculated): populate(child, uncalculated) if not uncalculated: for child in family.list(uncalculated=True): if child.isdynamic() and child.path(uncalculated=True) not in self.dynamic_paths: populate(family, uncalculated=True) def populate_dynamic(self, obj, type_, reload, uncalculated) -> None: path = obj.path(uncalculated=True) 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, type_, its_a_path=False ) elif obj.isoptiondescription(): self.dynamic_paths[path]["description"] = self._convert_description( description, type_, its_a_path=True ) if uncalculated: return dynamic_obj = self.dynamic_paths[path] if reload and obj.identifiers() in dynamic_obj["identifiers"]: return 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 if not child.isoptiondescription(): leader = self.parse_variable(child, leader, informations) else: self.parse_family(child, informations) 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.get() except AttributeError: variable = None if variable and self.is_inaccessible_user_data(variable): try: variable_value = self._get_unmodified_default_value(variable) except VariableCalculationDependencyError: pass else: if (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, *, force_injection=False) -> None: path = family.path(uncalculated=True) name = family.name(uncalculated=True) sub_informations = self.parse_families(family) if not force_injection and 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, } def parse_variable( self, variable, leader: dict, informations: dict, *, only_one=False, ) -> Optional[dict]: path = variable.path(uncalculated=True) name = variable.name(uncalculated=True) potential_leader = None if variable.isdynamic(): # information is already set potential_leader = self._parse_variable_dynamic( variable, leader, name, path, informations, only_one ) elif variable.isfollower() and variable.index(): self._parse_variable_follower_with_index( variable, leader, name, 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] 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 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, name: str, informations: dict ) -> None: if (variable.index() + 1) > len(leader["gen_examples"][-1]): return informations[name]["gen_examples"][-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: dynamic_variable["gen_examples"].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: name = _("Example") values = examples[0] else: name = _("Examples") values = list(examples) informations["examples"] = { "name": name, "values": values } tags = variable.information.get("tags", None) if tags: if len(tags) == 1: name = _("Tag") values = tags[0] else: name = _("Tags") values = list(tags) informations["tags"] = { "name": name, "values": values, } 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), type_, its_a_path=True ) else: description = None else: description = self._convert_description( child.description(uncalculated=True), 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, type_, its_a_path=False): if not its_a_path: description = to_phrase(description, type_) return description def _add_examples(self, variable, informations: dict, leader) -> None: if not variable.index(): example = self._get_example(variable, informations, leader) informations["gen_examples"] = [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["gen_examples"][-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: values = self._calculation_variable_to_string_known_property(child, calculation, prop) 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') if "{{ identifier }}" in calculation["ori_path"]: values = [] all_is_undocumented = False for cpath, description, identifiers in self.get_annotation_variable(calculation["value"], calculation["ori_path"]): if cpath: all_is_undocumented = False path_obj = { "path": cpath, } if identifiers: path_obj["identifiers"] = identifiers values.append({ "message": true_msg, "path": path_obj, "description": description, }) else: if all_is_undocumented is None: all_is_undocumented = True values.append(_("the value of an undocumented variable")) if all_is_undocumented: if len(values) > 1: values = _("the values of undocumented variables") else: values = values[0] else: # FIXME A MUTUALISER AUSSI 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: try: description = self._convert_description(self.conf.option(calculation["ori_path"]).description(uncalculated=True), "description", its_a_path=False) except AttributeError: description = calculation["ori_path"] values = { "message": true_msg, "path": { "path": calculation["ori_path"], }, "description": description, } else: values = None return values def _calculation_variable_to_string_known_property(self, child, calculation, prop): variable_path, value, condition = calculation["value"] if isinstance(value, str): str_value = value else: str_value = dump(value) values = [] if "{{ identifier }}" in calculation["ori_path"] or "{{ identifier }}" in variable_path: variables = self.get_annotation_variable(variable_path, calculation["ori_path"]) else: option = self.conf.option(variable_path) try: is_inaccessible = self.is_inaccessible_user_data(option) except AttributeError as err: if err.code != "option-not-found": raise err from err is_inaccessible = True if is_inaccessible: variables = [[None, None, None]] else: description = self._convert_description(option.description(uncalculated=True), "description", its_a_path=False) variables = [[variable_path, description, None]] for cpath, description, identifiers in variables: if not cpath: variable = self.conf.forcepermissive.option(variable_path) try: variable_value = self._get_unmodified_default_value(variable) except PropertiesOptionError as err: if calculation["propertyerror"]: raise err from err variable_value = value except VariableCalculationDependencyError: values.append(_("depends on an undocumented variable")) continue except AttributeError as err: # if err.code != "option-not-found" or not calculation.get("optional", False): # raise err from err return calculation.get("default", False) if ( condition == "when" and value == variable_value or condition == "when_not" and value != variable_value ): if prop in HIDDEN_PROPERTIES: return False # always "prop" return True # never "prop" return False else: if condition == "when_not": if calculation["optional"]: if not calculation["propertyerror"]: msg = _( 'when the variable "{{0}}" is defined, accessible and hasn\'t the value "{0}"' ) else: msg = _( 'when the variable "{{0}}" is defined and hasn\'t the value "{0}"' ) elif not calculation["propertyerror"]: msg = _('when the variable "{{0}}" is accessible and hasn\'t the value "{0}"') else: msg = _('when the variable "{{0}}" hasn\'t the value "{0}"') else: if calculation["optional"]: if not calculation["propertyerror"]: msg = _( 'when the variable "{{0}}" is defined, is accessible and has the value "{0}"' ) else: msg = _( 'when the variable "{{0}}" is defined and has the value "{0}"' ) elif not calculation["propertyerror"]: msg = _('when the variable "{{0}}" is accessible and has the value "{0}"') else: msg = _('when the variable "{{0}}" has the value "{0}"') path_obj = { "path": variable_path, } if identifiers: path_obj["identifiers"] = identifiers values.append({ "message": msg.format(str_value), "path": path_obj, "description": description, }) if len(values) == 1: return values[0] return values def get_annotation_variable(self, current_path, ori_path): if current_path == ori_path: regexp = None else: regexp = compile( "^" + ori_path.replace("{{ identifier }}", "(.*)") + "$" ) information = self.dynamic_paths[current_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)): yield None, None, None else: description = self._convert_description(self.conf.option(path).description(uncalculated=True), "description", its_a_path=False) if "{{ identifier }}" in path: yield path, description, identifiers.copy() else: yield path, description, None 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()