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

994 lines
37 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 typing import Tuple, List, Optional
from io import BytesIO
from ruamel.yaml import YAML
import tabulate as tabulate_module
from tabulate import tabulate
from rougail.tiramisu import normalize_family
from tiramisu import undefined
from tiramisu.error import PropertiesOptionError, display_list
try:
from tiramisu_cmdline_parser.api import gen_argument_name
except:
gen_argument_name = None
from .i18n import _
ROUGAIL_VARIABLE_TYPE = (
"https://rougail.readthedocs.io/en/latest/variable.html#variables-types"
)
ENTER = "\n\n"
DocTypes = {
"domainname": {
"params": {
"allow_startswith_dot": _("the domain name can starts by a dot"),
"allow_without_dot": _("the domain name can be a hostname"),
"allow_ip": _("the domain name can be an IP"),
"allow_cidr_network": _("the domain name can be network in CIDR format"),
},
},
"number": {
"params": {
"min_number": _("the minimum value is {0}"),
"max_number": _("the maximum value is {0}"),
},
},
"integer": {
"params": {
"min_integer": _("the minimum value is {0}"),
"max_integer": _("the maximum value is {0}"),
},
},
"ip": {
"msg": "IP",
"params": {
"cidr": _("IP must be in CIDR format"),
"private_only": _("private IP are allowed"),
"allow_reserved": _("reserved IP are allowed"),
},
},
"network": {
"params": {
"cidr": _("network must be in CIDR format"),
},
},
"hostname": {
"params": {
"allow_ip": _("the host name can be an IP"),
},
},
"web_address": {
"params": {
"allow_ip": _("the domain name in web address can be an IP"),
"allow_without_dot": _(
"the domain name in web address can be only a hostname"
),
},
},
"port": {
"params": {
"allow_range": _("can be range of port"),
"allow_protocol": _("can have the protocol"),
"allow_zero": _("port 0 is allowed"),
"allow_wellknown": _("well-known ports (1 to 1023) are allowed"),
"allow_registred": _("registred ports (1024 to 49151) are allowed"),
"allow_private": _("private ports (greater than 49152) are allowed"),
},
},
"secret": {
"params": {
"min_len": _("minimum length for the secret is {0} characters"),
"max_len": _("maximum length for the secret is {0} characters"),
"forbidden_char": _("forbidden characters: {0}"),
},
},
"unix_filename": {
"params": {
"allow_relative": _("this filename could be a relative path"),
"test_existence": _("this file must exists"),
"types": _("file type allowed: {0}"),
},
},
}
_yaml = YAML()
_yaml.indent(mapping=2, sequence=4, offset=2)
def dump(informations):
"""Dump variable, means transform bool, ... to yaml string"""
with BytesIO() as ymlfh:
_yaml.dump(informations, ymlfh)
ret = ymlfh.getvalue().decode("utf-8").strip()
if ret.endswith("..."):
ret = ret[:-3].strip()
return ret
def to_phrase(msg, type_="variable"):
"""Add maj for the first character and ends with dot"""
if not msg:
# replace None to empty string
return ""
msg = str(msg).strip()
# a phrase must ends with a dot
if type_ == "variable":
if not msg.endswith("."):
msg += "."
elif type_ in ["family", "description"]:
if msg.endswith("."):
msg = msg[:-1]
else:
raise Exception("unknown type")
# and start with a maj
return msg[0].upper() + msg[1:]
class CommonFormatter:
"""Class with common function for formatter"""
enter_table = "\n"
# tabulate module name
name = None
def __init__(self, rougailconfig, support_namespace, **kwarg):
tabulate_module.PRESERVE_WHITESPACE = True
self.header_setted = False
self.rougailconfig = rougailconfig
self.support_namespace = support_namespace
def run(
self, informations: dict, *, dico_is_already_treated=False
) -> str:
"""Transform to string"""
if informations:
level = self.rougailconfig["doc.title_level"]
self.options()
if self.root:
current = informations
for path in self.root.split('.'):
info = current[path]['informations']
current = current[path]["children"]
informations = {"informations": info, "children": current}
return self._run(informations, level, dico_is_already_treated)
return ""
def options(self):
self.with_commandline = self.rougailconfig["doc.with_commandline"]
self.with_environment = self.rougailconfig["doc.with_environment"]
if self.with_environment and not gen_argument_name:
raise Exception('please install tiramisu_cmdline_parser')
if not self.rougailconfig["main_namespace"] and self.with_environment:
if self.support_namespace:
self.prefix = ""
else:
self.prefix = self.rougailconfig["doc.environment_default_environment_name"] + "_"
self.with_family = not self.rougailconfig["doc.without_family"]
self.root = self.rougailconfig["doc.root"]
self.other_root_filenames = None
if self.root:
try:
other_root_filenames = self.rougailconfig["doc.other_root_filenames"]
except PropertiesOptionError:
pass
else:
if other_root_filenames:
self.other_root_filenames = dict(zip(other_root_filenames["root_path"], other_root_filenames["filename"]))
def compute(self, data):
return "".join([d for d in data if d])
# Class you needs implement to your Formatter
def title(
self,
title: str,
level: int,
) -> str:
"""Display family name as a title"""
raise NotImplementedError()
def join(
self,
lst: List[str],
) -> str:
"""Display line in table from a list"""
raise NotImplementedError()
def bold(
self,
msg: str,
) -> str:
"""Set a text to bold"""
raise NotImplementedError()
def underline(
self,
msg: str,
) -> str:
"""Set a text to underline"""
raise NotImplementedError()
def anchor(self,
path: str,
true_path: str,
) -> str:
"""Set a text to a link anchor"""
return path
def link_variable(self,
path: str,
true_path: str,
description: str,
filename: Optional[str],
) -> str:
"""Set a text link to variable anchor"""
return path
def stripped(
self,
text: str,
) -> str:
"""Return stripped text (as help)"""
raise NotImplementedError()
def list(
self,
choices: list,
*,
inside_table: bool=True,
type_: str="variable",
) -> str:
"""Display a liste of element"""
raise NotImplementedError()
def prop(
self,
prop: str,
italic: bool,
delete: bool,
underline: bool,
) -> str:
"""Display property"""
raise NotImplementedError()
def link(
self,
comment: str,
link: str,
underline: bool,
) -> str:
"""Add a link"""
raise NotImplementedError()
##################
def family_informations(self) -> str:
return ""
def end_family_informations(self) -> str:
return ENTER
def display_paths(
self,
informations: dict,
modified_attributes: dict,
force_identifiers: Optional[str],
*,
is_variable=False,
variable_prefix: str="",
is_bold: bool=True,
is_upper: bool=False,
) -> str:
ret_paths = []
path = informations["path"]
if is_bold:
bold = self.bold
else:
def bold(value):
return value
if is_upper:
def upper(value):
return value.upper()
else:
def upper(value):
return value
if "identifiers" in modified_attributes:
name, previous, new = modified_attributes["identifiers"]
ret_paths.extend(
[
bold(self.delete(upper(variable_prefix + calc_path(path, self, identifier))))
for identifier in previous
]
)
else:
new = []
if "identifiers" in informations:
for idx, identifier in enumerate(informations["identifiers"]):
if force_identifiers and identifier != force_identifiers:
continue
path_ = calc_path(path, self, identifier)
if variable_prefix:
path_ = variable_prefix + upper(path_)
if not idx:
path_ = self.anchor(path_, path)
if identifier in new:
path_ = self.underline(path_)
ret_paths.append(bold(path_))
else:
ret_paths.append(bold(self.anchor(variable_prefix + upper(path), path)))
return ret_paths
def table_header(
self,
lst: list,
) -> tuple:
"""Manage the header of a table"""
return lst
def _run(self, dico: dict, level: int, dico_is_already_treated: bool) -> str:
"""Parse the dict to transform to dict"""
if dico_is_already_treated:
return "".join(dico)
return "".join([msg for msg in self.dict_to_dict(dico, level, init=True)])
def dict_to_dict(
self,
dico: dict,
level: int,
*,
ori_table_datas: list = None,
init: bool = False,
) -> str:
"""Parse the dict to transform to dict"""
msg = []
if ori_table_datas is not None:
table_datas = ori_table_datas
else:
table_datas = []
ori_level = None
if init and self.root:
if self.with_family:
msg.extend(self.family_to_string(dico["informations"], level, False))
dico = dico["children"]
for value in dico.values():
if value["type"] == "variable":
self.variable_to_string(value, table_datas)
else:
if self.with_family:
if value["type"] == "namespace":
namespace = ori_level is None
else:
namespace = False
if table_datas:
msg.append(self.table(table_datas))
table_datas = []
msg.extend(self.family_to_string(value["informations"], level, namespace))
msg.extend(self.dict_to_dict(value["children"], level + 1))
msg.append(self.end_family(level))
else:
self.dict_to_dict(
value["children"], level + 1, ori_table_datas=table_datas
)
if (init or ori_table_datas is None) and table_datas:
msg.append(self.table(table_datas))
return msg
# FAMILY
def namespace_to_title(self, informations: dict, level: int) -> str:
"""manage namespace family"""
return self.title(
self.get_description("family", informations, {}, None),
level,
)
def family_to_string(self, informations: dict, level: int, namespace: bool) -> str:
"""manage other family type"""
if namespace:
ret = [self.namespace_to_title(informations, level)]
else:
ret = [self.title(self.get_description("family", informations, {}, None), level)]
fam_info = self.family_informations()
if fam_info:
ret.append(fam_info)
msg = self.display_paths(informations, {}, None)
helps = informations.get("help")
if helps:
for help_ in helps:
msg.append(to_phrase(help_.strip()))
calculated_properties = []
property_str = self.property_to_string(informations, calculated_properties, {})[1]
if property_str:
msg.append(property_str)
if calculated_properties:
msg.append(self.join(calculated_properties)
)
if "identifier" in informations:
msg.append(
self.section(_("Identifiers"), informations["identifier"], type_="family")
)
starts_line = self.family_informations_starts_line()
ret.append(self.family_informations_ends_line().join([starts_line + m for m in msg]) + self.end_family_informations())
return ret
def family_informations_starts_line(self) -> str:
return ""
def family_informations_ends_line(self) -> str:
return ENTER
def end_family(self, level: int) -> str:
return ""
def convert_list_to_string(
self, attribute: str, informations: dict, modified_attributes: dict
) -> str():
datas = []
if attribute in modified_attributes:
name, previous, new = modified_attributes[attribute]
for data in previous:
datas.append(self.delete(self.to_phrase(data)))
else:
new = []
if attribute in informations:
for data in informations[attribute]:
if isinstance(data, dict):
if attribute.endswith("s"):
attr = attribute[:-1]
else:
attr = attribute
data = data[attr].replace(
"{{ identifier }}", self.italic(data["identifier"])
)
data = self.to_phrase(data)
if data in new:
data = self.underline(data)
datas.append(data)
return self.stripped(self.join(datas))
def get_description(
self, type_: str, informations: dict, modified_attributes: dict, force_identifiers: Optional[str]
) -> str():
def _get_description(description, identifiers, delete=False, new=[]):
if identifiers and "{{ identifier }}" in description:
if type_ == "variable":
identifiers_text = display_list(
[self.italic(i[-1]) for i in identifiers if not force_identifiers or i == force_identifiers], separator="or"
)
description = description.replace(
"{{ identifier }}", identifiers_text
)
else:
d = []
for i in identifiers:
if force_identifiers and i != force_identifiers:
continue
new_description = description.replace(
"{{ identifier }}", self.italic(i[-1])
)
if new_description not in d:
d.append(self.to_phrase(new_description))
description = display_list(d, separator="or")
else:
description = self.to_phrase(description)
if description in new:
description = self.underline(description)
if delete:
description = self.delete(description)
return description
if "description" in modified_attributes:
name, previous, new = modified_attributes["description"]
if previous:
modified_description = _get_description(
previous[0], modified_attributes.get("identifiers", []), delete=True
)
else:
modified_description = None
else:
modified_description = None
new = []
description = _get_description(
informations["description"], informations.get("identifiers"), new=new
)
if modified_description:
if description:
description = self.join([modified_description, description])
else:
description = modified_description
if not description:
return None
return self.stripped(description)
# VARIABLE
def variable_to_string(
self, informations: dict, table_datas: list, modified_attributes: dict = {}, force_identifiers: Optional[str]=None
) -> None:
"""Manage variable"""
calculated_properties = []
multi, first_column = self.variable_first_column(
informations, calculated_properties, modified_attributes, force_identifiers
)
table_datas.append(
[
self.join(first_column),
self.join(
self.variable_second_column(
informations,
calculated_properties,
modified_attributes,
multi,
force_identifiers,
)
),
]
)
def variable_first_column(
self,
informations: dict,
calculated_properties: list,
modified_attributes: Optional[dict],
force_identifiers: Optional[str],
) -> list:
"""Collect string for the first column"""
multi, properties = self.property_to_string(
informations, calculated_properties, modified_attributes,
)
paths = self.display_paths(informations, modified_attributes, force_identifiers, is_variable=True)
first_col = [
self.join(paths),
properties,
]
if self.with_commandline:
paths = self.display_paths(informations, modified_attributes, force_identifiers, is_variable=True, is_bold=False, variable_prefix="--")
variable_type = informations["properties"][0]["name"]
if variable_type == 'boolean':
for path in list(paths):
paths.append(gen_argument_name(path, False, True, False))
if "alternative_name" in informations:
alternative_name = informations["alternative_name"]
paths[0] += f", -{alternative_name}"
if variable_type == 'boolean':
paths[1] += ", -" + gen_argument_name(alternative_name, True, True, False)
first_col.append(self.section(_("Command line"), paths))
if self.with_environment:
paths = self.display_paths(informations, modified_attributes, force_identifiers, is_variable=True, is_bold=False, is_upper=True, variable_prefix=self.prefix)
first_col.append(self.section(_("Environment variable"), paths))
self.columns(first_col)
return multi, first_col
def variable_second_column(
self,
informations: dict,
calculated_properties: list,
modified_attributes: dict,
multi: bool,
force_identifiers: Optional[str],
) -> list:
"""Collect string for the second column"""
second_col = []
#
if "description" in informations:
description = self.get_description(
"variable", informations, modified_attributes, force_identifiers,
)
second_col.append(description)
#
help_ = self.convert_list_to_string("help", informations, modified_attributes)
if help_:
second_col.append(help_)
#
validators = self.convert_section_to_string(
"validators", informations, modified_attributes, multi=True
)
if validators:
second_col.append(validators)
default_is_already_set, choices = self.convert_choices_to_string(
informations, modified_attributes
)
if choices:
second_col.append(choices)
if not default_is_already_set and "default" in informations:
self.convert_section_to_string(
"default", informations, modified_attributes, multi=multi
)
second_col.append(
self.convert_section_to_string(
"default", informations, modified_attributes, multi=multi
)
)
examples = self.convert_section_to_string(
"examples", informations, modified_attributes, multi=True
)
if examples:
second_col.append(examples)
tags = self.convert_section_to_string(
"tags", informations, modified_attributes, multi=True
)
if tags:
second_col.append(tags)
second_col.extend(calculated_properties)
self.columns(second_col)
return second_col
def convert_section_to_string(
self, attribute: str, informations: dict, modified_attributes: dict, multi: bool
) -> str():
values = []
submessage = ""
if modified_attributes and attribute in modified_attributes:
name, previous, new = modified_attributes[attribute]
# if "identifiers" in modified_attributes:
# iname, iprevious, inew = modified_attributes["identifiers"]
# identifiers = iprevious.copy()
# for identifier in informations.get("identifiers", []):
# if identifier not in inew:
# identifiers.append(identifier)
#
# else:
# identifiers = informations.get("identifiers", [])
if isinstance(previous, list):
for p in previous:
submessage, m = self.message_to_string(p, submessage)
values.append(self.delete(m))
else:
submessage, old_values = self.message_to_string(previous, submessage)
values.append(self.delete(old_values))
else:
new = []
if attribute in informations:
old = informations[attribute]
name = old["name"]
if isinstance(old["values"], list):
for value in old["values"]:
submessage, old_value = self.message_to_string(value, submessage)
if value in new:
old_value = self.underline(old_value)
values.append(old_value)
if multi:
values = self.list(values)
else:
values = self.join(values)
elif values:
old_values = old["values"]
submessage, old_values = self.message_to_string(old_values, submessage)
if old["values"] in new:
old_values = self.underline(old_values)
values.append(old_values)
values = self.join(values)
else:
submessage, values = self.message_to_string(old["values"], submessage)
if old["values"] in new:
values = self.underline(values)
if values != []:
return self.section(name, values, submessage=submessage)
def convert_choices_to_string(
self, informations: dict, modified_attributes: dict
) -> str():
default_is_already_set = False
if "choices" in informations:
choices = informations["choices"]
choices_values = choices["values"]
if not isinstance(choices_values, list):
choices_values = [choices_values]
default_is_a_list = False
else:
default_is_a_list = True
for idx, choice in enumerate(choices_values.copy()):
if isinstance(choice, dict):
choices_values[idx] = self.message_to_string(choice, None)[1]
if "default" in modified_attributes:
name, old_default, new_default = modified_attributes["default"]
if not old_default:
old_default = [None]
if not isinstance(old_default, list):
old_default = [old_default]
for value in old_default.copy():
if (
isinstance(value, str)
and value.endswith(".")
and value not in choices_values
):
old_default.remove(value)
old_default.append(value[:-1])
else:
old_default = new_default = []
# check if all default values are in choices (could be from a calculation)
if "default" in informations:
default = informations["default"]["values"]
else:
default = []
if not isinstance(default, list):
default = [default]
for idx, value in enumerate(default.copy()):
if isinstance(value, dict):
default[idx] = self.message_to_string(value, None)[1]
default_value_not_in_choices = set(default) - set(choices_values)
if default_value_not_in_choices:
default_is_changed = False
for val in default_value_not_in_choices.copy():
if (
isinstance(val, str)
and val.endswith(".")
and val[:-1] in choices_values
):
default.remove(val)
default.append(val[:-1])
default_is_changed = True
if val in new_default:
new_default.remove(val)
new_default.append(val[:-1])
if default_is_changed:
default_value_not_in_choices = set(default) - set(choices_values)
if default_value_not_in_choices:
old_default = []
new_default = []
default = []
else:
default_is_already_set = True
if "choices" in modified_attributes:
name, previous, new = modified_attributes["choices"]
for choice in reversed(previous):
if isinstance(choice, dict):
choice = self.message_to_string(choice, None)[1]
if choice in old_default:
choices_values.insert(
0, self.delete(dump(choice) + "" + _("(default)"))
)
else:
choices_values.insert(0, self.delete(dump(choice)))
else:
new = []
for idx, val in enumerate(choices_values):
if val in old_default:
choices_values[idx] = (
dump(val) + " " + self.delete("" + _("(default)"))
)
elif val in default:
if val in new_default:
if val in new:
choices_values[idx] = self.underline(
dump(val) + " " + self.bold("" + _("(default)"))
)
else:
choices_values[idx] = (
dump(val)
+ " "
+ self.underline(self.bold("" + _("(default)")))
)
else:
choices_values[idx] = (
dump(val) + " " + self.bold("" + _("(default)"))
)
elif val in new:
choices_values[idx] = self.underline(dump(val))
# if old value and new value is a list, display a list
if not default_is_a_list and len(choices_values) == 1:
choices_values = choices_values[0]
return default_is_already_set, self.section(choices["name"], choices_values)
return default_is_already_set, None
# OTHERs
def to_phrase(self, text: str) -> str:
return text
def property_to_string(
self,
informations: dict,
calculated_properties: list,
modified_attributes: dict,
) -> str:
"""Transform properties to string"""
properties = []
local_calculated_properties = {}
multi = False
if "properties" in modified_attributes:
previous, new = self.get_modified_properties(
*modified_attributes["properties"][1:]
)
for p, annotation in previous.items():
if p not in new:
properties.append(self.prop(p, italic=False, delete=True, underline=False))
if annotation is not None:
local_calculated_properties[p] = [{"annotation": annotation, "delete": True}]
else:
previous = new = []
for prop in informations.get("properties", []):
prop_name = prop["name"]
if prop_name not in previous and prop_name in new:
underline = True
else:
underline = False
if prop["type"] == "type":
properties.append(self.link(prop_name, ROUGAIL_VARIABLE_TYPE, underline))
else:
if prop["type"] == "multiple":
multi = True
if "annotation" in prop:
italic = True
prop_annotation = prop["annotation"]
if prop_name in new and (
prop_name not in previous
or new[prop_name] != previous[prop_name]
):
underline_ = True
# prop_annotation = self.underline(prop_annotation)
else:
underline_ = False
local_calculated_properties.setdefault(prop["name"], []).append(
{"annotation": prop_annotation, "underline": underline_}
)
else:
italic = False
properties.append(self.prop(prop_name, italic=italic, delete=False, underline=underline))
if local_calculated_properties:
for (
calculated_property_name,
calculated_property,
) in local_calculated_properties.items():
# calculated_property = calculated_property_data["annotation"]
data = []
for calc in calculated_property:
annotation = self.message_to_string(calc["annotation"], None)[1]
if calc.get("underline", False):
annotation = self.underline(annotation)
if calc.get("delete", False):
annotation = self.delete(annotation)
data.append(annotation)
if len(calculated_property) > 1:
calculated_property = self.join(data)
else:
calculated_property = data[0]
calculated_properties.append(
self.section(
calculated_property_name.capitalize(), calculated_property
)
)
if not properties:
return multi, ""
return multi, " ".join(properties)
def get_modified_properties(
self, previous: List[dict], new: List[dict]
) -> Tuple[dict, dict]:
def modified_properties_parser(dico):
return {d["name"]: d.get("annotation") for d in dico}
return modified_properties_parser(previous), modified_properties_parser(new)
def columns(
self,
col: List[str], # pylint: disable=unused-argument
) -> None:
"""Manage column"""
return
def table(self, datas: list, with_header: bool = True) -> str:
"""Transform list to a table in string format"""
if with_header:
headers = self.table_header([_("Variable"), _("Description")])
else:
headers = ()
msg = (
tabulate(
datas,
headers=headers,
tablefmt=self._table_name,
)
+ "\n\n"
)
datas.clear()
return msg
def message_to_string(self, msg, ret, identifiers=[]):
if isinstance(msg, dict):
if "submessage" in msg:
ret += msg["submessage"]
msg = msg["values"]
elif "message" in msg:
filename = None
if self.other_root_filenames:
path = msg["path"]["path"]
for root in self.other_root_filenames:
if path == root or path.startswith(f'{root}.'):
filename = self.other_root_filenames[root]
break
if "identifiers" in msg["path"]:
msg["identifiers"] = [msg["path"]["identifiers"]]
path = self.link_variable(calc_path(msg["path"], self, identifiers), msg["path"]["path"], self.get_description("variable", msg, {}, None), filename=filename)
msg = msg["message"].format(path)
elif "description" in msg:
if "variables" in msg:
paths = []
for variable in msg["variables"]:
filename = None
if self.other_root_filenames:
path = msg["path"]
for root in self.other_root_filenames:
if path == root or path.startswith(f'{root}.'):
filename = self.other_root_filenames[root]
break
identifiers = variable.get("identifiers")
path = calc_path(variable, self, identifiers)
paths.append(self.link_variable(path, variable["path"], self.get_description("variable", variable, {}, force_identifiers=identifiers), filename=filename))
msg = msg["description"].format(*paths)
else:
msg = msg["description"]
return ret, msg
def section(
self,
name: str,
msg: str,
submessage: str = "",
type_ = "variable",
) -> str:
"""Return something like Name: msg"""
submessage, msg = self.message_to_string(msg, submessage)
if isinstance(msg, list):
if len(msg) == 1:
submessage, elt = self.message_to_string(msg[0], submessage)
if isinstance(elt, list):
submessage += self.list(elt, type_=type_)
else:
submessage += elt
else:
lst = []
for p in msg:
submessage, elt = self.message_to_string(p, submessage)
lst.append(elt)
submessage += self.list(lst, type_=type_)
msg = ""
if not isinstance(msg, str):
submessage += dump(msg)
else:
submessage += msg
return _("{0}: {1}").format(self.bold(name), submessage)
def calc_path(path, formatter=None, identifiers: List[str] = None) -> str:
def _path_with_identifier(path, identifier):
identifier = normalize_family(str(identifier))
if formatter:
identifier = formatter.italic(identifier)
return path.replace("{{ identifier }}", identifier, 1)
if isinstance(path, dict):
path_ = path["path"]
if "identifiers" in path:
for identifier in path["identifiers"]:
path_ = _path_with_identifier(path_, identifier)
elif identifiers:
path_ = path
for identifier in identifiers:
path_ = _path_with_identifier(path_, identifier)
else:
path_ = path
return path_