rougail-output-doc/src/rougail/output_doc/doc.py

613 lines
23 KiB
Python

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