mirror of
https://gitlab.freedesktop.org/NetworkManager/NetworkManager
synced 2024-10-15 12:34:55 +00:00
28a1afca63
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
561 lines
18 KiB
Python
Executable file
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")
|