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

741 lines
29 KiB
Python
Raw Normal View History

2026-03-29 11:01:15 +02:00
"""
Silique (https://www.silique.fr)
Copyright (C) 2024-2026
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 tiramisu import Calculation, groups
from tiramisu.error import display_list, PropertiesOptionError
from rougail.tiramisu import display_xmlfiles
from rougail.utils import PROPERTY_ATTRIBUTE
from rougail.error import VariableCalculationDependencyError, RougailWarning, ExtensionError
2026-03-29 12:42:01 +02:00
from .utils import dump, to_phrase, calc_path, add_dot
2026-03-29 11:01:15 +02:00
from .i18n import _
HIDDEN_PROPERTIES = [
"hidden",
"disabled",
]
DISABLED_PROPERTIES = ["disabled"]
class _ToString:
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 ExtensionError(
_(
'cannot find "{0}_calculation" information, '
"do you have annotate this configuration?"
).format(prop)
)
if isinstance(calculation, list):
values = []
for cal in calculation:
value = self._calculation_to_string(child, cal, prop)
if value is not None:
values.append(value)
return values
2026-03-29 12:42:01 +02:00
ret = self._calculation_to_string(child, calculation, prop, inside_list=False)
2026-03-29 11:01:15 +02:00
if isinstance(ret, list) and len(ret) == 1:
return ret[0]
return ret
2026-03-29 12:42:01 +02:00
def _calculation_to_string(self, child, calculation, attribute_type, inside_list=True):
2026-03-29 11:01:15 +02:00
if "description" in calculation:
values = calculation
if self.document_a_type and "variables" in values:
for variable in list(values["variables"]):
variable["path"] = self.doc_path(variable["path"])
2026-03-29 12:42:01 +02:00
if not inside_list:
values["description"] = add_dot(values["description"])
2026-03-29 11:01:15 +02:00
elif "type" not in calculation:
values = calculation["value"]
2026-03-29 12:42:01 +02:00
if isinstance(values, str) and not inside_list:
values = add_dot(values)
2026-03-29 11:01:15 +02:00
elif calculation["type"] == "jinja":
values = self._calculation_jinja_to_string(
child, calculation, attribute_type
)
2026-03-29 12:42:01 +02:00
if isinstance(values, str) and not inside_list:
values = add_dot(values)
2026-03-29 11:01:15 +02:00
elif calculation["type"] == "variable":
values = self._calculation_variable_to_string(
child, calculation, attribute_type
)
2026-03-29 12:42:01 +02:00
if isinstance(values, str) and not inside_list:
values = add_dot(values)
2026-03-29 11:01:15 +02:00
elif calculation["type"] == "identifier":
values = self._calculation_identifier_to_string(
child, calculation, attribute_type
)
2026-03-29 12:42:01 +02:00
if isinstance(values, str) and not inside_list:
values = add_dot(values)
2026-03-29 11:01:15 +02:00
elif calculation["type"] == "information":
if "path" in calculation:
variable_path = self.doc_path(calculation["path"])
values = _(
'the value of the information "{0}" of the variable "{1}"'
).format(calculation["information"], variable_path)
else:
values = _('the value of the global information "{0}"').format(
calculation["information"]
)
2026-03-29 12:42:01 +02:00
if not inside_list:
values = add_dot(values)
2026-03-29 11:01:15 +02:00
else:
values = _("the value of the {0}").format(calculation["type"])
2026-03-29 12:42:01 +02:00
if not inside_list:
values = add_dot(values)
2026-03-29 11:01:15 +02:00
return values
def _calculation_jinja_to_string(self, child, calculation, attribute_type):
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(
attribute_type,
self.doc_path(child.path(uncalculated=True)),
display_xmlfiles(child.information.get("ymlfiles")),
)
warn(
warning,
RougailWarning,
)
return values
def _calculation_variable_to_string(self, child, calculation, attribute_type):
if attribute_type in PROPERTY_ATTRIBUTE:
func = self._calculation_variable_to_string_known_property
else:
func = self._calculation_variable_to_string_not_properties
return func(child, calculation, attribute_type)
def _calculation_variable_to_string_known_property(self, child, informations, prop):
condition = informations["value"]
variable_path = condition["path"]
values = []
option = self.true_config.option(variable_path)
if option.isdynamic():
variables = self._get_annotation_variable(
option, condition.get("identifiers")
)
else:
option = self.true_config.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:
2026-03-29 12:42:01 +02:00
description = option.description(uncalculated=True)
2026-03-29 11:01:15 +02:00
variables = [[variable_path, description, None]]
for cpath, description, identifiers in variables:
if not cpath:
# we cannot access to this variable, so try with permissive
if condition["type"] == "transitive":
value = None
else:
value = condition["value"]
option = self.true_config.forcepermissive.option(variable_path)
try:
variable_value = self._get_unmodified_default_value(option)
except PropertiesOptionError as err:
if informations["propertyerror"] is True:
raise err from err
if informations["propertyerror"] == "transitive":
return True
return False
except VariableCalculationDependencyError:
values.append(_("depends on an undocumented variable"))
continue
except AttributeError as err:
return informations.get("default", False)
if condition["type"] == "transitive":
return True
if prop in HIDDEN_PROPERTIES:
condition_type = condition["condition"]
if (
condition_type == "when"
and value == variable_value
or condition_type == "when_not"
and value != variable_value
):
# always "prop"
return True
return False
if condition["type"] == "transitive":
submsg = _('is "{0}"').format(prop)
else:
condition_type = condition["condition"]
conditions = []
if not informations["propertyerror"]:
conditions.append(_("is accessible"))
if informations["optional"]:
conditions.append(_("is defined"))
value = condition["value"]
if not isinstance(value, str):
value = dump(value)
if condition_type == "when_not":
conditions.append(_('hasn\'t the value "{0}"').format(value))
else:
conditions.append(_('has the value "{0}"').format(value))
submsg = display_list(conditions, sort=False)
if informations["propertyerror"] == "transitive":
submsg = display_list(
[_("is {0}").format(prop), submsg], separator="or", sort=False
)
2026-03-29 12:42:01 +02:00
msg = _('when the variable "{{0}}" {0}.').format(submsg)
2026-03-29 11:01:15 +02:00
path_obj = {
"path": self.doc_path(variable_path),
}
if identifiers:
path_obj["identifiers"] = identifiers
values.append(
{
"message": msg,
"path": path_obj,
"description": description,
}
)
if len(values) == 1:
return values[0]
return values
def _calculation_variable_to_string_not_properties(
self, child, calculation, attribute_type
):
variable = self.true_config.unrestraint.option(calculation["value"]["path"])
if calculation["optional"]:
try:
variable.get()
except AttributeError:
return None
true_msg = _('the value of the variable "{0}" if it is defined')
else:
true_msg = _('the value of the variable "{0}"')
if not variable.isdynamic():
func = self._calculation_normal_variable_to_string_not_properties
else:
func = self._calculation_dynamic_variable_to_string_not_properties
return func(variable, calculation["value"], true_msg)
def _calculation_normal_variable_to_string_not_properties(
self, child, obj, true_msg
):
isfollower = child.isfollower()
if not isfollower and self.is_inaccessible_user_data(child):
uncalculated = child.value.default(uncalculated=True)
if uncalculated and not isinstance(uncalculated, Calculation):
if isinstance(uncalculated, list):
return {
"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 = _("the value of an undocumented variable")
try:
2026-03-29 12:42:01 +02:00
description = child.description(uncalculated=True)
2026-03-29 11:01:15 +02:00
except AttributeError:
description = path
return {
"message": true_msg,
"path": obj,
"description": description,
}
def _calculation_dynamic_variable_to_string_not_properties(
self, child, obj, true_msg
):
values = []
for path, description, identifiers in self._get_annotation_variable(
child, obj.get("identifiers")
):
cpath = calc_path(path, identifiers=identifiers)
variable = self.true_config.option(cpath)
path_obj = {
"path": self.doc_path(path),
}
if identifiers:
path_obj["identifiers"] = identifiers
values.append(
self._calculation_normal_variable_to_string_not_properties(
variable, path_obj, true_msg
)
)
return values
def _calculation_identifier_to_string(self, child, calculation, attribute_type):
if attribute_type in PROPERTY_ATTRIBUTE:
if calculation["condition"] == "when":
return _('when the identifier is "{0}"').format(calculation["value"])
if calculation["condition"] == "when_not":
return _('when the identifier is not "{0}"').format(
calculation["value"]
)
return True
else:
return _("the value of the identifier")
def doc_path(self, path):
if self.document_a_type:
if not "." in path:
return None
return path.split(".", 1)[-1]
return path
def _get_annotation_variable(self, child, ori_identifiers):
path = child.path(uncalculated=True)
if ori_identifiers:
if None in ori_identifiers:
all_identifiers = self.true_config.option(
calc_path(path, identifiers=ori_identifiers)
).identifiers()
else:
all_identifiers = [ori_identifiers]
else:
all_identifiers = child.identifiers()
for identifiers in all_identifiers:
cpath = calc_path(path, identifiers=identifiers)
2026-03-29 12:42:01 +02:00
description = child.description(uncalculated=True)
2026-03-29 11:01:15 +02:00
if child.isdynamic():
yield path, description, identifiers.copy()
else:
yield path, description, None
class Collect(_ToString):
def collect_families(self, family) -> dict:
informations = {}
for child in family.list(uncalculated=True):
if child.type(only_self=True) == "symlink" or (
not child.isdynamic() and self.is_inaccessible_user_data(child)
):
# do not document symlink option or inacesssible variable
# (dynamic variable could be accessible only in one context)
continue
if child.isoptiondescription():
func = self.collect_family
else:
func = self.collect_variable
func(child, informations)
return informations
def collect_family(self, family, informations: dict) -> None:
family_type = self._get_family_type(family)
family_informations = self._collect_family(family, family_type)
if family_informations is False:
return
sub_informations = self.collect_families(family)
if not sub_informations:
# a family without subfamily/subvariable
return
name = family.name(uncalculated=True)
informations[name] = {
"type": family_type,
"informations": family_informations,
"children": sub_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 _collect_family(
self,
family,
family_type,
*,
with_identifier: bool = True,
current_identifier_only: bool = False,
) -> dict:
path = family.path(uncalculated=True)
informations = {}
if not self._collect(family, informations, family_type=family_type):
return False, []
if family_type == "leadership":
informations.setdefault("help", []).append(
_("This family contains lists of variable blocks")
)
elif family_type == "dynamic":
informations.setdefault("help", []).append(
_("This family builds families dynamically")
)
if with_identifier:
identifier = self._to_string(family, "dynamic", do_not_raise=True)
if identifier is None:
identifier = family.identifiers(only_self=True)
if not isinstance(identifier, list):
identifier = [identifier]
informations["identifier"] = identifier
elif family_type == "namespace":
informations.setdefault("help", []).append(_("This family is a namespace"))
return informations
def collect_variable(
self,
child,
informations: dict,
*,
only_one=False,
) -> Optional[dict]:
name = child.name(uncalculated=True)
sub_informations = {}
if not self._collect_variable(
child,
sub_informations,
):
return None
informations[name] = sub_informations
if child.isleader():
# if not self.default_values:
# child.value.set(sub_informations["gen_examples"][0])
return sub_informations
return None
def _collect_variable(
self,
child,
informations: dict,
):
if not self._collect(child, informations):
return False
informations["type"] = "variable"
default = self._set_default(
child,
informations,
)
self._parse_type(
child,
informations,
)
if child.ismulti():
multi = not child.isfollower() or child.issubmulti()
else:
multi = False
if multi:
informations["multiple"] = True
examples = child.information.get("examples", None)
if examples is None:
examples = child.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 = child.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,
}
alternative_name = child.information.get("alternative_name", None)
if alternative_name:
informations["alternative_name"] = alternative_name
return True
def _collect(
self,
child,
informations: dict,
*,
family_type: str = None,
):
display, properties = self._get_properties(child, informations)
if not display:
return False
informations["path"] = self.doc_path(child.path(uncalculated=True))
informations["name"] = child.name(uncalculated=True)
description = self._get_description(child, family_type)
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
properties = child.property.get(uncalculated=True)
for mode in self.modes_level:
if mode not in properties:
continue
informations["mode"] = mode
break
if child.isdynamic():
informations["identifiers"] = []
path = child.path(uncalculated=True)
if child.has_identifiers():
identifiers = child.identifiers()
else:
identifiers = [child.identifiers()]
for identifier in identifiers:
cpath = calc_path(path, identifiers=identifier)
child_identifier = self.true_config.option(cpath)
if not self.is_inaccessible_user_data(child_identifier):
informations["identifiers"].append(identifier)
if not informations["identifiers"]:
informations["identifiers"] = [["example"]]
return True
def _parse_type(
self,
child,
informations,
):
variable_type = child.information.get("type")
doc_type = self.convert_option.get(variable_type, {"params": {}})
informations["variable_type"] = doc_type.get("msg", variable_type)
# extra parameters for types
option = child.get()
validators = []
if "params" in doc_type:
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)
if "doc" in msg:
validators.append(msg["doc"].format(value))
else:
validators.append(msg["description"].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 variable_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 variable_type == "choice":
choices = self._to_string(child, "choice", do_not_raise=True)
if choices is None:
choices = child.value.list(uncalculated=True)
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 _get_properties(
self,
child,
child_informations,
):
informations = []
properties = child.property.get(uncalculated=True)
if "not_for_commandline" in properties:
child_informations["not_for_commandline"] = True
for prop, translated_prop in self.property_to_string:
annotation = False
if child.information.get(f"{prop}_calculation", False):
annotation = self._to_string(child, prop)
if annotation is None and prop in HIDDEN_PROPERTIES:
return False, []
if annotation is True and prop in DISABLED_PROPERTIES:
return False, []
if not annotation:
continue
elif prop not in properties:
# this property is not in the variable so, do not comment it
continue
elif prop in HIDDEN_PROPERTIES:
return False, []
prop_obj = {
"type": "property",
"name": translated_prop,
"ori_name": prop,
"access_control": prop in HIDDEN_PROPERTIES,
}
if annotation:
prop_obj["annotation"] = annotation
informations.append(prop_obj)
return True, informations
def _set_default(
self,
child,
informations,
):
default = self._to_string(child, "default", do_not_raise=True)
if default is None and child.information.get("default_value_makes_sense", True):
default_ = child.value.default(uncalculated=True)
if not isinstance(default_, Calculation):
default = default_
if default == []:
default = None
if default is not None:
informations["default"] = {"name": _("Default"), "values": default}
def _get_description(self, child, family_type):
if child.information.get("forced_description", False):
if (
child.isoptiondescription()
or not child.isfollower()
or not child.index()
):
# all variables must have description but display error only for first slave
msg = _('No attribute "description" for "{0}" in {1}').format(
child.path(uncalculated=True),
display_xmlfiles(child.information.get("ymlfiles")),
)
warn(
msg,
RougailWarning,
)
if family_type is not None:
2026-03-29 12:42:01 +02:00
# it's a variable
return child.description(uncalculated=True)
2026-03-29 11:01:15 +02:00
return None
var_type = "variable" if family_type is None else "family"
return self._convert_description(
2026-03-29 12:42:01 +02:00
child.description(uncalculated=True), var_type
2026-03-29 11:01:15 +02:00
)
2026-03-29 12:42:01 +02:00
def _convert_description(self, description, type_):
return to_phrase(description, type_)
2026-03-29 11:01:15 +02:00
def is_inaccessible_user_data(self, child, *, only_disabled=False):
"""If family is not accessible in read_write mode (to load user_data),
do not comment this family
"""
properties = child.property.get(uncalculated=True)
if only_disabled:
hidden_properties = DISABLED_PROPERTIES
else:
hidden_properties = HIDDEN_PROPERTIES
for hidden_property in hidden_properties:
if hidden_property in properties:
return True
calculation = child.information.get(f"{hidden_property}_calculation", None)
if calculation:
calculation_type = calculation.get("type")
if (
calculation_type == "variable"
and calculation["value"]["type"] == "condition"
):
condition = calculation["value"]
variable = self.true_config.forcepermissive.option(
condition["path"]
)
try:
variable.value.get()
except AttributeError:
variable = None
if variable and self.is_inaccessible_user_data(
variable, only_disabled=only_disabled
):
try:
variable_value = self._get_unmodified_default_value(
variable
)
except VariableCalculationDependencyError:
pass
else:
condition_type = condition["condition"]
value = condition["value"]
if self.calc_condition_when(
condition_type, value, variable_value
):
return True
elif calculation_type == "identifier":
if self.calc_condition_when(
calculation["condition"],
calculation["value"],
child.identifiers()[-1],
):
return True
if not child.isoptiondescription():
for hidden_property in self.disabled_modes:
if hidden_property in properties:
return True
return False
def calc_condition_when(self, condition, value, current_value):
return (condition == "when" and value == current_value) or (
condition == "when_not" and value != current_value
)
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.true_config.forcepermissive.option(
calculation["value"]["path"]
)
if variable and self.is_inaccessible_user_data(variable):
return self._get_unmodified_default_value(variable)
raise VariableCalculationDependencyError()