Systemd/tools/update-dbus-docs.py

326 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
# SPDX-License-Identifier: LGPL-2.1-or-later
import argparse
import collections
import sys
import os
import subprocess
import io
try:
from lxml import etree
except ModuleNotFoundError as e:
etree = e
try:
from shlex import join as shlex_join
except ImportError as e:
shlex_join = e
class NoCommand(Exception):
pass
BORING_INTERFACES = [
'org.freedesktop.DBus.Peer',
'org.freedesktop.DBus.Introspectable',
'org.freedesktop.DBus.Properties',
]
def xml_parser():
return etree.XMLParser(no_network=True,
remove_comments=False,
strip_cdata=False,
resolve_entities=False)
def print_method(declarations, elem, *, prefix, file, is_signal=False):
name = elem.get('name')
klass = 'signal' if is_signal else 'method'
declarations[klass].append(name)
print(f'''{prefix}{name}(''', file=file, end='')
lead = ',\n' + prefix + ' ' * len(name) + ' '
for num, arg in enumerate(elem.findall('./arg')):
argname = arg.get('name')
if argname is None:
if opts.print_errors:
print(f'method {name}: argument {num+1} has no name', file=sys.stderr)
argname = 'UNNAMED'
type = arg.get('type')
if not is_signal:
direction = arg.get('direction')
print(f'''{lead if num > 0 else ''}{direction:3} {type} {argname}''', file=file, end='')
else:
print(f'''{lead if num > 0 else ''}{type} {argname}''', file=file, end='')
print(f');', file=file)
ACCESS_MAP = {
'read' : 'readonly',
'write' : 'readwrite',
}
def value_ellipsis(type):
if type == 's':
return "'...'";
if type[0] == 'a':
inner = value_ellipsis(type[1:])
return f"[{inner}{', ...' if inner != '...' else ''}]";
return '...'
def print_property(declarations, elem, *, prefix, file):
name = elem.get('name')
type = elem.get('type')
access = elem.get('access')
declarations['property'].append(name)
# @org.freedesktop.DBus.Property.EmitsChangedSignal("false")
# @org.freedesktop.systemd1.Privileged("true")
# readwrite b EnableWallMessages = false;
for anno in elem.findall('./annotation'):
anno_name = anno.get('name')
anno_value = anno.get('value')
print(f'''{prefix}@{anno_name}("{anno_value}")''', file=file)
access = ACCESS_MAP.get(access, access)
print(f'''{prefix}{access} {type} {name} = {value_ellipsis(type)};''', file=file)
def print_interface(iface, *, prefix, file, print_boring, only_interface, declarations):
name = iface.get('name')
is_boring = (name in BORING_INTERFACES or
only_interface is not None and name != only_interface)
if is_boring and print_boring:
print(f'''{prefix}interface {name} {{ ... }};''', file=file)
elif not is_boring and not print_boring:
print(f'''{prefix}interface {name} {{''', file=file)
prefix2 = prefix + ' '
for num, elem in enumerate(iface.findall('./method')):
if num == 0:
print(f'''{prefix2}methods:''', file=file)
print_method(declarations, elem, prefix=prefix2 + ' ', file=file)
for num, elem in enumerate(iface.findall('./signal')):
if num == 0:
print(f'''{prefix2}signals:''', file=file)
print_method(declarations, elem, prefix=prefix2 + ' ', file=file, is_signal=True)
for num, elem in enumerate(iface.findall('./property')):
if num == 0:
print(f'''{prefix2}properties:''', file=file)
print_property(declarations, elem, prefix=prefix2 + ' ', file=file)
print(f'''{prefix}}};''', file=file)
def document_has_elem_with_text(document, elem, item_repr):
predicate = f".//{elem}" # [text() = 'foo'] doesn't seem supported :(
for loc in document.findall(predicate):
if loc.text == item_repr:
return True
return False
def check_documented(document, declarations, stats):
missing = []
for klass, items in declarations.items():
stats['total'] += len(items)
for item in items:
if klass == 'method':
elem = 'function'
item_repr = f'{item}()'
elif klass == 'signal':
elem = 'function'
item_repr = item
elif klass == 'property':
elem = 'varname'
item_repr = item
else:
assert False, (klass, item)
if not document_has_elem_with_text(document, elem, item_repr):
if opts.print_errors:
print(f'{klass} {item} is not documented :(')
missing.append((klass, item))
stats['missing'] += len(missing)
return missing
def xml_to_text(destination, xml, *, only_interface=None):
file = io.StringIO()
declarations = collections.defaultdict(list)
interfaces = []
print(f'''node {destination} {{''', file=file)
for print_boring in [False, True]:
for iface in xml.findall('./interface'):
print_interface(iface, prefix=' ', file=file,
print_boring=print_boring,
only_interface=only_interface,
declarations=declarations)
name = iface.get('name')
if not name in BORING_INTERFACES:
interfaces.append(name)
print(f'''}};''', file=file)
return file.getvalue(), declarations, interfaces
def subst_output(document, programlisting, stats):
executable = programlisting.get('executable', None)
if executable is None:
# Not our thing
return
executable = programlisting.get('executable')
node = programlisting.get('node')
interface = programlisting.get('interface')
argv = [f'{opts.build_dir}/{executable}', f'--bus-introspect={interface}']
print(f'COMMAND: {shlex_join(argv)}')
try:
out = subprocess.check_output(argv, text=True)
except FileNotFoundError:
print(f'{executable} not found, ignoring', file=sys.stderr)
return
xml = etree.fromstring(out, parser=xml_parser())
new_text, declarations, interfaces = xml_to_text(node, xml, only_interface=interface)
programlisting.text = '\n' + new_text + ' '
if declarations:
missing = check_documented(document, declarations, stats)
parent = programlisting.getparent()
# delete old comments
for child in parent:
if (child.tag == etree.Comment
and 'Autogenerated' in child.text):
parent.remove(child)
if (child.tag == etree.Comment
and 'not documented' in child.text):
parent.remove(child)
if (child.tag == "variablelist"
and child.attrib.get("generated",False) == "True"):
parent.remove(child)
# insert pointer for systemd-directives generation
the_tail = programlisting.tail #tail is erased by addnext, so save it here.
prev_element = etree.Comment("Autogenerated cross-references for systemd.directives, do not edit")
programlisting.addnext(prev_element)
programlisting.tail = the_tail
for interface in interfaces:
variablelist = etree.Element("variablelist")
variablelist.attrib['class'] = 'dbus-interface'
variablelist.attrib['generated'] = 'True'
variablelist.attrib['extra-ref'] = interface
prev_element.addnext(variablelist)
prev_element.tail = the_tail
prev_element = variablelist
for decl_type,decl_list in declarations.items():
for declaration in decl_list:
variablelist = etree.Element("variablelist")
variablelist.attrib['class'] = 'dbus-'+decl_type
variablelist.attrib['generated'] = 'True'
if decl_type == 'method' :
variablelist.attrib['extra-ref'] = declaration + '()'
else:
variablelist.attrib['extra-ref'] = declaration
prev_element.addnext(variablelist)
prev_element.tail = the_tail
prev_element = variablelist
last_element = etree.Comment("End of Autogenerated section")
prev_element.addnext(last_element)
prev_element.tail = the_tail
last_element.tail = the_tail
# insert comments for undocumented items
for item in reversed(missing):
comment = etree.Comment(f'{item[0]} {item[1]} is not documented!')
comment.tail = programlisting.tail
parent.insert(parent.index(programlisting) + 1, comment)
def process(page):
src = open(page).read()
xml = etree.fromstring(src, parser=xml_parser())
# print('parsing {}'.format(name), file=sys.stderr)
if xml.tag != 'refentry':
return
stats = collections.Counter()
pls = xml.findall('.//programlisting')
for pl in pls:
subst_output(xml, pl, stats)
out_text = etree.tostring(xml, encoding='unicode')
# massage format to avoid some lxml whitespace handling idiosyncrasies
# https://bugs.launchpad.net/lxml/+bug/526799
out_text = (src[:src.find('<refentryinfo')] +
out_text[out_text.find('<refentryinfo'):] +
'\n')
if not opts.test:
with open(page, 'w') as out:
out.write(out_text)
return dict(stats=stats, outdated=(out_text != src))
def parse_args():
p = argparse.ArgumentParser()
p.add_argument('--test', action='store_true',
help='only verify that everything is up2date')
p.add_argument('--build-dir', default='build')
p.add_argument('pages', nargs='+')
opts = p.parse_args()
opts.print_errors = not opts.test
return opts
if __name__ == '__main__':
opts = parse_args()
for item in (etree, shlex_join):
if isinstance(item, Exception):
print(item, file=sys.stderr)
exit(77 if opts.test else 1)
if not os.path.exists(f'{opts.build_dir}/systemd'):
exit(f"{opts.build_dir}/systemd doesn't exist. Use --build-dir=.")
stats = {page.split('/')[-1] : process(page) for page in opts.pages}
# Let's print all statistics at the end
mlen = max(len(page) for page in stats)
total = sum((item['stats'] for item in stats.values()), start=collections.Counter())
total = 'total', dict(stats=total, outdated=False)
outdated = []
for page, info in sorted(stats.items()) + [total]:
m = info['stats']['missing']
t = info['stats']['total']
p = page + ':'
c = 'OUTDATED' if info['outdated'] else ''
if c:
outdated.append(page)
print(f'{p:{mlen + 1}} {t - m}/{t} {c}')
if opts.test and outdated:
exit(f'Outdated pages: {", ".join(outdated)}\n'
f'Hint: ninja -C {opts.build_dir} man/update-dbus-docs')