NetworkManager/tools/generate-docs-nm-property-infos.py
Thomas Haller 28a1afca63
python: reformat with black-23.7.0-2.fc39.noarch
The base image for the "check-tree" test got bumped to Fedora 39.  This
brings a new python-black version (23.7.0 vs. 22.8.0) and requires
reformatting.

Maybe we should stick to 22.8.0, via `pip install`. But it seems better
to just follow the latest black version (the one from current Fedora).

So do the reformatting instead.

https://black.readthedocs.io/en/stable/change_log.html#id38
2023-12-06 11:56:11 +01:00

561 lines
18 KiB
Python
Executable File

#!/usr/bin/env python
# SPDX-License-Identifier: LGPL-2.1-or-later
import os
import re
import sys
import collections
import xml.etree.ElementTree as ET
class LineError(Exception):
def __init__(self, line_no, msg):
Exception.__init__(self, msg)
self.line_no = line_no
enums = {}
enumvals = {}
_dbg_level = 0
try:
_dbg_level = int(os.getenv("NM_DEBUG_GENERATE_DOCS", 0))
except Exception:
pass
def dbg(msg, level=1):
if level <= _dbg_level:
print(msg)
def iter_unique(iterable, default=None):
found = False
for i in iterable:
assert not found
found = True
i0 = i
if found:
return i0
return default
def xnode_get_or_create(root_node, node_name, name):
# From root_node, get the node "<{node_name} name={name} .../>"
# or create one, if it doesn't exist.
node = iter_unique(
(node for node in root_node.findall(node_name) if node.attrib["name"] == name)
)
if node is None:
created = True
node = ET.SubElement(root_node, node_name, name=name)
else:
created = False
return node, created
def init_enumvals(girxml):
ns_map = {
"c": "http://www.gtk.org/introspection/c/1.0",
"gi": "http://www.gtk.org/introspection/core/1.0",
"glib": "http://www.gtk.org/introspection/glib/1.0",
}
type_key = "{%s}type" % ns_map["c"]
identifier_key = "{%s}identifier" % ns_map["c"]
nick_key = "{%s}nick" % ns_map["glib"]
for enum in girxml.findall("./gi:namespace/gi:enumeration", ns_map):
enum_cname = enum.attrib[type_key]
enums[enum_cname] = []
for enumval in enum.findall("./gi:member", ns_map):
cname = enumval.attrib[identifier_key]
doc = enumval.find("./gi:doc", ns_map)
enums[enum_cname].append(cname)
enumvals[cname] = {
"value": enumval.attrib["value"],
"nick": enumval.attrib.get(nick_key, cname),
"doc": doc.text if doc is not None else None,
}
for enum in girxml.findall("./gi:namespace/gi:bitfield", ns_map):
enum_cname = enum.attrib[type_key]
enums[enum_cname] = []
for enumval in enum.findall("./gi:member", ns_map):
cname = enumval.attrib[identifier_key]
doc = enumval.find("./gi:doc", ns_map)
enums[enum_cname].append(cname)
enumvals[cname] = {
"value": "0x%x" % int(enumval.attrib["value"]),
"nick": enumval.attrib.get(nick_key, cname),
"doc": doc.text if doc is not None else None,
}
def get_setting_names(source_file):
m = re.match(r"^(.*)/libnm-core-impl/(nm-setting-[^/]*)\.c$", source_file)
assert m
path_prefix, file_base = (m.group(1), m.group(2))
if file_base == "nm-setting-ip-config":
# Special case ip-config, which is a base class.
return 0, ("ipv4", "ipv6")
header_file = "%s/libnm-core-public/%s.h" % (path_prefix, file_base)
try:
f = open(header_file, "r")
except OSError:
raise Exception(
'Can not open header file "%s" for "%s"' % (header_file, source_file)
)
with f:
for line in f:
m = re.search(r"^#define +NM_SETTING_.+SETTING_NAME\s+\"(\S+)\"$", line)
if m:
return 1, (m.group(1),)
raise Exception(
'Can\'t find setting name in header file "%s" for "%s"'
% (header_file, source_file)
)
def get_file_infos(source_files):
# This function parses the source files and detects the
# used setting name. The returned sections are sorted by setting
# name.
#
# The file "nm-setting-ip-config.c" can contain information
# for "ipv4" and "ipv6" settings. Thus, to sort the files
# is a bit more involved.
# First, get a list of priority and setting-names that belong
# to the source file. Sort by priority,setting-names. It's
# important that "nm-setting-ip-config.c" gets parsed before
# "nm-setting-ip[46]-config.c".
file_infos = []
for source_file in source_files:
priority, setting_names = get_setting_names(source_file)
file_infos.append((priority, setting_names, source_file))
file_infos.sort()
d = {}
for priority, setting_names, source_file in file_infos:
for setting_name in setting_names:
l = d.get(setting_name, None)
if l is None:
l = list()
d[setting_name] = l
l.append(source_file)
for key in sorted(d.keys()):
for f in d[key]:
yield key, f
KEYWORD_XML_TYPE_NESTED = "nested"
KEYWORD_XML_TYPE_ELEM = "elem"
KEYWORD_XML_TYPE_ATTR = "attr"
keywords = collections.OrderedDict(
[
("property", KEYWORD_XML_TYPE_ATTR),
("variable", KEYWORD_XML_TYPE_ATTR),
("format", KEYWORD_XML_TYPE_ATTR),
("values", KEYWORD_XML_TYPE_ATTR),
("special-values", KEYWORD_XML_TYPE_ATTR),
("default", KEYWORD_XML_TYPE_ATTR),
("example", KEYWORD_XML_TYPE_ATTR),
("description", KEYWORD_XML_TYPE_ELEM),
("description-docbook", KEYWORD_XML_TYPE_NESTED),
]
)
def keywords_allowed(tag, keyword):
# certain keywords might not be valid for some tags.
# Currently, all of them are always valid.
assert keyword in keywords
return True
def write_data(tag, setting_node, line_no, parsed_data):
for k in parsed_data.keys():
assert keywords_allowed(tag, k)
assert k in keywords
name = parsed_data["property"]
property_node, created = xnode_get_or_create(setting_node, "property", name)
if not created:
raise LineError(line_no, 'Duplicate property <property name="%s"...' % (name,))
for k, xmltype in keywords.items():
if k == "property":
continue
v = parsed_data.get(k, None)
if v is None:
continue
if xmltype == KEYWORD_XML_TYPE_NESTED:
# Set as XML nodes. The input data is XML itself.
des = ET.fromstring("<%s>%s</%s>" % (k, v, k))
property_node.append(des)
elif xmltype == KEYWORD_XML_TYPE_ELEM:
node = ET.SubElement(property_node, k)
node.text = v
elif xmltype == KEYWORD_XML_TYPE_ATTR:
property_node.set(k, v)
else:
assert False
def expand_enumval(enumval_name, nick_first):
if enumval_name not in enumvals:
return enumval_name
enumval = enumvals[enumval_name]
if nick_first:
return "%s (%s)" % (enumval["nick"], enumval["value"])
else:
return "%s (%s)" % (enumval["value"], enumval["nick"])
def expand_all_enumvals(enum, nick_first):
assert enum in enums, "Enum name not found: %s" % enum
return ", ".join(expand_enumval(val_name, nick_first) for val_name in enums[enum])
def expand_all_enumvals_with_docs(enum, nick_first):
assert enum in enums, "Enum name not found: %s" % enum
out_str = "<itemizedlist>"
for enumval_name in enums[enum]:
assert enumval_name in enumvals
enumval = enumvals[enumval_name]
out_str += "<listitem><para><literal>%s</literal>%s</para></listitem>" % (
expand_enumval(enumval_name, nick_first),
" - " + enumval["doc"] if enumval["doc"] else "",
)
out_str += "</itemizedlist>"
return out_str
def format_descriptions(tag, parsed_data):
if (
parsed_data.get("description", None) is not None
and parsed_data.get("description-docbook", None) is None
):
# we have a description, but no docbook. Generate one.
parsed_data["description-docbook"] = ""
for line in parsed_data["description"].split("\n"):
para = ET.Element("para")
para.text = line
parsed_data["description-docbook"] += ET.tostring(para, encoding="unicode")
elif (
parsed_data.get("description-docbook", None) is not None
and parsed_data.get("description", None) is None
):
raise Exception(
'Invalid configuration. When specifying "description-docbook:" there MUST be also a "description:"'
)
elif parsed_data.get("description", None) is None:
return
# Expand enumvals expressions (%ENUM_VALUE and #EnumName:*)
nick_first = tag == "nmcli"
parsed_data["description"] = re.sub(
r"#([A-Za-z0-9_]*):\*",
lambda match: expand_all_enumvals(match.group(1), nick_first),
parsed_data["description"],
)
parsed_data["description"] = re.sub(
r"%([^%]\w*)",
lambda match: expand_enumval(match.group(1), nick_first),
parsed_data["description"],
)
parsed_data["description-docbook"] = re.sub(
r"#([A-Za-z0-9_]*):\*",
lambda match: expand_all_enumvals_with_docs(match.group(1), nick_first),
parsed_data["description-docbook"],
)
parsed_data["description-docbook"] = re.sub(
r"%([^%]\w*)",
lambda match: expand_enumval(match.group(1), nick_first),
parsed_data["description-docbook"],
)
def parse_data(tag, line_no, lines):
assert lines
parsed_data = {}
keyword = ""
indent = None
for line in lines:
assert "\n" not in line
line_no += 1
m = re.search(r"^ \*(| .*)$", line)
if not m:
raise LineError(line_no, 'Invalid formatted line "%s"' % (line,))
content = m.group(1)
m = re.search("^ ([-a-z0-9]+):(.*)$", content)
text_keyword_started = None
if m:
keyword = m.group(1)
if keyword in parsed_data:
raise LineError(line_no, 'Duplicated keyword "%s"' % (keyword,))
text = m.group(2)
text_keyword_started = text
if text:
if text[0] != " " or len(text) == 1:
raise LineError(line_no, 'Invalid formatted line "%s"' % (line,))
text = text[1:]
if not keywords_allowed(tag, keyword):
raise LineError(line_no, 'Invalid key "%s" for %s' % (keyword, tag))
if parsed_data and keyword == "property":
raise LineError(line_no, 'The "property:" keywork must be first')
parsed_data[keyword] = text
indent = None
else:
if content == "":
text = ""
elif content[0] == " " and len(content) > 1:
text = content[1:]
assert text
if indent is None:
indent = re.search("^( *)", text).group(1)
if not text.startswith(indent):
raise LineError(line_no, 'Unexpected indention in "%s"' % (line,))
text = text[len(indent) :]
else:
raise LineError(line_no, 'Unexpected line "%s"' % (line,))
if not keyword:
raise LineError(line_no, "Expected data in comment: %s" % (line))
if text and text[0] == "\\":
assert False
text = text[1:]
if separator == " " and text == "":
# No separator to add. This is a blank line
pass
else:
parsed_data[keyword] = parsed_data[keyword] + separator + text
if keywords[keyword] == KEYWORD_XML_TYPE_NESTED:
# This is plain XML. They lines are joined by newlines.
separator = "\n"
elif text_keyword_started == "":
# If the previous line was just "tag:$", we don't need a separator
# the next time.
separator = ""
elif not text:
# A blank line is used to mark a line break, while otherwise
# lines are joined by space.
separator = "\n"
else:
separator = " "
if "property" not in parsed_data:
raise LineError(line_no, 'Missing "property:" tag')
for keyword in keywords.keys():
if not keywords_allowed(tag, keyword):
continue
if keyword not in parsed_data:
parsed_data[keyword] = None
return parsed_data
def process_setting(tag, root_node, source_file, setting_name):
dbg(
"> > tag:%s, source_file:%s, setting_name:%s" % (tag, source_file, setting_name)
)
start_tag = "---" + tag + "---"
end_tag = "---end---"
setting_node, created = xnode_get_or_create(root_node, "setting", setting_name)
try:
f = open(source_file, "r")
except OSError:
raise Exception("Can not open file: %s" % (source_file))
lines = None
with f:
line_no = 0
just_had_end_tag = False
line_no_start = None
for line in f:
line_no += 1
if line and line[-1] == "\n":
line = line[:-1]
if just_had_end_tag:
# After the end-tag, we still expect one particular line. Be strict about
# this.
just_had_end_tag = False
if line != " */":
raise LineError(
line_no,
'Invalid end tag "%s". Expects literally " */" after end-tag'
% (line,),
)
elif start_tag in line:
if line != " /* " + start_tag:
raise LineError(
line_no,
'Invalid start tag "%s". Expects literally " /* %s"'
% (line, start_tag),
)
if lines is not None:
raise LineError(
line_no, 'Invalid start tag "%s", missing end-tag' % (line,)
)
lines = []
line_no_start = line_no
elif end_tag in line and lines is not None:
if line != " * " + end_tag:
raise LineError(line_no, 'Invalid end tag: "%s"' % (line,))
parsed_data = parse_data(tag, line_no_start, lines)
if not parsed_data:
raise Exception('invalid data: line %s, "%s"' % (line_no, lines))
format_descriptions(tag, parsed_data)
dbg("> > > property: %s" % (parsed_data["property"],))
if _dbg_level > 1:
for keyword in sorted(parsed_data.keys()):
v = parsed_data[keyword]
if v is not None:
v = '"%s"' % (v,)
dbg(
"> > > > [%s] (%s) = %s" % (keyword, keywords[keyword], v),
level=2,
)
write_data(tag, setting_node, line_no_start, parsed_data)
lines = None
elif lines is not None:
lines.append(line)
if lines is not None or just_had_end_tag:
raise LineError(line_no_start, "Unterminated start tag")
def process_settings_docs(tag, output, gir_file, source_files):
dbg("> tag:%s, output:%s" % (tag, output))
root_node = ET.Element("nm-setting-docs")
init_enumvals(ET.parse(gir_file).getroot())
for setting_name, source_file in get_file_infos(source_files):
try:
process_setting(tag, root_node, source_file, setting_name)
except LineError as e:
raise Exception(
"Error parsing %s, line %s (tag:%s, setting_name:%s): %s"
% (source_file, e.line_no, tag, setting_name, str(e))
)
except Exception as e:
raise Exception(
"Error parsing %s (tag:%s, setting_name:%s): %s"
% (source_file, tag, setting_name, str(e))
)
ET.ElementTree(root_node).write(output)
def main():
if len(sys.argv) < 4:
print(
"Usage: %s [tag] [output-xml-file] [gir-file] [srcfiles...]" % (sys.argv[0])
)
exit(1)
process_settings_docs(
tag=sys.argv[1],
output=sys.argv[2],
gir_file=sys.argv[3],
source_files=sys.argv[4:],
)
if __name__ == "__main__":
main()
###############################################################################
# Tests
###############################################################################
def setup_module():
global pytest
import pytest
def t_srcdir():
return os.path.abspath(os.path.dirname(__file__) + "/..")
def t_setting_c(name):
return t_srcdir() + "/src/libnm-core-impl/nm-setting-" + name + ".c"
def test_file_location():
assert t_srcdir() + "/tools/generate-docs-nm-property-infos.py" == os.path.abspath(
__file__
)
assert os.path.isfile(t_srcdir() + "/src/libnm-core-impl/nm-setting-connection.c")
assert os.path.isfile(t_setting_c("ip-config"))
def test_get_setting_names():
assert (1, ("connection",)) == get_setting_names(
t_srcdir() + "/src/libnm-core-impl/nm-setting-connection.c"
)
assert (1, ("ipv4",)) == get_setting_names(
t_srcdir() + "/src/libnm-core-impl/nm-setting-ip4-config.c"
)
assert (0, ("ipv4", "ipv6")) == get_setting_names(
t_srcdir() + "/src/libnm-core-impl/nm-setting-ip-config.c"
)
def test_get_file_infos():
t = ["connection", "ip-config", "ip4-config", "proxy", "wired"]
assert [
(
"802-3-ethernet",
t_setting_c("wired"),
),
(
"connection",
t_setting_c("connection"),
),
(
"ipv4",
t_setting_c("ip-config"),
),
(
"ipv4",
t_setting_c("ip4-config"),
),
(
"ipv6",
t_setting_c("ip-config"),
),
("proxy", t_setting_c("proxy")),
] == list(get_file_infos([t_setting_c(x) for x in t]))
def test_process_setting():
root_node = ET.Element("nm-setting-docs")
process_setting("nmcli", root_node, t_setting_c("connection"), "connection")