QGIS/tests/code_layout/doxygen_parser.py
Denis Rouzaud 518ffe3f62 fix doxygen test on Travis
it seems that doxygen 1.18.13 is not behaving the same as 1.1811 (Travis one).
one might need to remove some spaces in the signature as they are different depending on Doxygen's version
2017-08-06 23:57:14 +02:00

530 lines
19 KiB
Python

# -*- coding: utf-8 -*-
"""
***************************************************************************
mocked
---------------------
Date : May 2017
Copyright : (C) 2017 by Denis Rouzaud
Email : denis.rouzaud@gmail.com
***************************************************************************
* *
* This program is free software; you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation; either version 2 of the License, or *
* (at your option) any later version. *
* *
***************************************************************************
"""
__author__ = 'Denis Rouzaud'
__date__ = 'May 2017'
__copyright__ = '(C) 2017, Denis Rouzaud'
# This will get replaced with a git SHA1 when you do a git archive
__revision__ = ':%H$'
import re
import glob
import os
try:
import xml.etree.cElementTree as ET
except ImportError:
import xml.etree.ElementTree as ET
class DoxygenParser():
"""
Parses the XML files generated by Doxygen which describe the API docs
"""
def __init__(self, path, acceptable_missing={}, acceptable_missing_added_note=[], acceptable_missing_brief=[]):
"""
Initializes the parser.
:param path: Path to Doxygen XML output
"""
self.acceptable_missing = acceptable_missing
self.acceptable_missing_added_note = acceptable_missing_added_note
self.acceptable_missing_brief = acceptable_missing_brief
self.documentable_members = 0
self.documented_members = 0
self.undocumented_members = {}
self.bindable_members = []
self.groups = {}
self.classes_missing_group = []
self.classes_missing_brief = []
self.classes_missing_version_added = []
# for some reason the Doxygen generation on Travis refuses to assign these classes to groups
self.acceptable_missing_group = ['QgsOgcUtils::LayerProperties',
'QgsSQLStatement::Node',
'QgsSQLStatement::NodeBinaryOperator',
'QgsSQLStatement::NodeColumnRef',
'QgsSQLStatement::NodeFunction',
'QgsSQLStatement::NodeInOperator',
'QgsSQLStatement::NodeList',
'QgsSQLStatement::NodeLiteral',
'QgsSQLStatement::NodeUnaryOperator',
'QgsRuleBasedLabeling::Rule',
'QgsSQLStatement::Visitor']
self.version_regex = re.compile(r'QGIS [\d\.]+.*')
self.parseFiles(path)
def parseFiles(self, path):
""" Parses all the Doxygen XML files in a folder
:param path: Path to Doxygen XML output
"""
found = False
# find groups
for f in glob.glob(os.path.join(path, 'group__*.xml')):
found = True
group, members = self.parseGroup(f)
self.groups[group] = members
assert found, "Could not find doxygen groups xml"
found = False
# parse docs
for f in glob.glob(os.path.join(path, '*.xml')):
found = True
self.parseFile(f)
assert found, "Could not find doxygen files xml"
def parseGroup(self, f):
""" Parses a single Doxygen Group XML file
:param f: XML file path
"""
name = None
members = []
# Wrap everything in a try, as sometimes Doxygen XML is malformed
try:
for event, elem in ET.iterparse(f):
if event == 'end' and elem.tag == 'compoundname':
name = elem.text
if event == 'end' and elem.tag == 'innerclass':
members.append(elem.text)
except:
pass
return name, members
def hasGroup(self, class_name):
""" Returns true if a class has been assigned to a group
:param class_name class name to test
"""
for g in self.groups:
if class_name in self.groups[g]:
return True
return False
def parseFile(self, f):
""" Parses a single Doxygen XML file
:param f: XML file path
"""
documentable_members = 0
documented_members = 0
# Wrap everything in a try, as sometimes Doxygen XML is malformed
try:
for event, elem in ET.iterparse(f):
if event == 'end' and elem.tag == 'compounddef':
if self.elemIsPublicClass(elem):
# store documentation status
members, documented, undocumented, bindable, has_brief_description, found_version_added = self.parseClassElem(elem)
documentable_members += members
documented_members += documented
class_name = elem.find('compoundname').text
acceptable_missing = self.acceptable_missing.get(class_name, [])
if not self.hasGroup(class_name) and class_name not in self.acceptable_missing_group:
self.classes_missing_group.append(class_name)
if class_name not in self.acceptable_missing_brief and not has_brief_description:
self.classes_missing_brief.append(class_name)
if class_name not in self.acceptable_missing_added_note and not found_version_added:
self.classes_missing_version_added.append(class_name)
# GEN LIST
# if len(undocumented) > 0:
# print('"%s": [%s],' % (class_name, ", ".join(['"%s"' % e.replace('"', '\\"') for e in undocumented])))
unacceptable_undocumented = undocumented - set(acceptable_missing)
# do a case insensitive check too
unacceptable_undocumented_insensitive = set([u.lower() for u in undocumented]) - set([u.lower() for u in acceptable_missing])
if len(unacceptable_undocumented_insensitive) > 0:
self.undocumented_members[class_name] = {}
self.undocumented_members[class_name]['documented'] = documented
self.undocumented_members[class_name]['members'] = members
self.undocumented_members[class_name]['missing_members'] = unacceptable_undocumented
# store bindable members
if self.classElemIsBindable(elem):
for m in bindable:
self.bindable_members.append(m)
elem.clear()
except ET.ParseError as e:
# sometimes Doxygen generates malformed xml (e.g., for < and > operators)
line_num, col = e.position
with open(f, 'r') as xml_file:
for i, l in enumerate(xml_file):
if i == line_num - 1:
line = l
break
caret = '{:=>{}}'.format('^', col)
print(('ParseError in {}\n{}\n{}\n{}'.format(f, e, line, caret)))
self.documentable_members += documentable_members
self.documented_members += documented_members
def elemIsPublicClass(self, elem):
""" Tests whether an XML element corresponds to a public (or protected) class
:param elem: XML element
"""
# only looking for classes
if not elem.get('kind') == 'class':
return False
# only looking for public or protected classes
return elem.get('prot') in ('public', 'protected')
def classElemIsBindable(self, elem):
""" Tests whether a class should have SIP bindings
:param elem: XML element corresponding to a class
"""
try:
# check for classes with special python doc notes (probably 'not available' or renamed classes, either way
# they should be safe to ignore as obviously some consideration has been given to Python bindings)
detailed_sec = elem.find('detaileddescription')
for p in detailed_sec.getiterator('para'):
for s in p.getiterator('simplesect'):
for ps in s.getiterator('para'):
if ps.text and 'python' in ps.text.lower():
return False
return True
except:
return True
def parseClassElem(self, e):
""" Parses an XML element corresponding to a Doxygen class
:param e: XML element
"""
documentable_members = 0
documented_members = 0
undocumented_members = set()
bindable_members = []
# loop through all members
for m in e.getiterator('memberdef'):
signature = self.memberSignature(m)
if signature is None:
continue
if self.elemIsBindableMember(m):
bindable_member = [e.find('compoundname').text, m.find('name').text]
if bindable_member not in bindable_members:
bindable_members.append(bindable_member)
if self.elemIsDocumentableMember(m):
documentable_members += 1
if self.memberIsDocumented(m):
documented_members += 1
else:
undocumented_members.add(signature)
# test for brief description
d = e.find('briefdescription')
has_brief_description = False
if d:
has_brief_description = True
# test for "added in QGIS xxx" string
d = e.find('detaileddescription')
found_version_added = False
for para in d.getiterator('para'):
for s in para.getiterator('simplesect'):
if s.get('kind') == 'since':
for p in s.getiterator('para'):
if self.version_regex.match(p.text):
found_version_added = True
break
if found_version_added:
break
return documentable_members, documented_members, undocumented_members, bindable_members, has_brief_description, found_version_added
def memberSignature(self, elem):
""" Returns the signature for a member
:param elem: XML element for a class member
"""
a = elem.find('argsstring')
try:
if a is not None:
return elem.find('name').text + a.text
else:
return elem.find('name').text
except:
return None
def elemIsBindableMember(self, elem):
""" Tests whether an member should be included in SIP bindings
:param elem: XML element for a class member
"""
# only public or protected members are bindable
if not self.visibility(elem) in ('public', 'protected'):
return False
# property themselves are not bound, only getters and setters
if self.isProperty(elem):
return False
# ignore friend classes
if self.isFriendClass(elem):
return False
# ignore typedefs (can't test for them)
if self.isTypeDef(elem):
return False
if self.isVariable(elem) and self.visibility(elem) == 'protected':
# protected variables can't be bound in SIP
return False
# check for members with special python doc notes (probably 'not available' or renamed methods, either way
# they should be safe to ignore as obviously some consideration has been given to Python bindings)
try:
detailed_sec = elem.find('detaileddescription')
for p in detailed_sec.getiterator('para'):
for s in p.getiterator('simplesect'):
for ps in s.getiterator('para'):
if ps.text and 'python' in ps.text.lower():
return False
except:
pass
# ignore constructors and destructor, can't test for these
if self.isDestructor(elem) or self.isConstructor(elem):
return False
# ignore operators, also can't test
if self.isOperator(elem):
return False
# ignore deprecated members
if self.isDeprecated(elem):
return False
return True
def elemIsDocumentableMember(self, elem):
""" Tests whether an member should be included in Doxygen docs
:param elem: XML element for a class member
"""
# ignore variables (for now, eventually public/protected variables should be documented)
if self.isVariable(elem):
return False
# only public or protected members should be documented
if not self.visibility(elem) in ('public', 'protected'):
return False
# ignore reimplemented methods
if self.isReimplementation(elem):
return False
# ignore friend classes
if self.isFriendClass(elem):
return False
# ignore destructor
if self.isDestructor(elem):
return False
# ignore constructors with no arguments
if self.isConstructor(elem):
try:
if elem.find('argsstring').text == '()':
return False
except:
pass
name = elem.find('name')
# ignore certain obvious operators
try:
if name.text in ('operator=', 'operator==', 'operator!='):
return False
except:
pass
# ignore on_* slots
try:
if name.text.startswith('on_'):
return False
except:
pass
# ignore deprecated members
if self.isDeprecated(elem):
return False
return True
def visibility(self, elem):
""" Returns the visibility of a class or member
:param elem: XML element for a class or member
"""
try:
return elem.get('prot')
except:
return ''
def isVariable(self, member_elem):
""" Tests whether an member is a variable
:param member_elem: XML element for a class member
"""
try:
if member_elem.get('kind') == 'variable':
return True
except:
pass
return False
def isProperty(self, member_elem):
""" Tests whether an member is a property
:param member_elem: XML element for a class member
"""
try:
if member_elem.get('kind') == 'property':
return True
except:
pass
return False
def isDestructor(self, member_elem):
""" Tests whether an member is a destructor
:param member_elem: XML element for a class member
"""
try:
name = member_elem.find('name').text
if name.startswith('~'):
# destructor
return True
except:
pass
return False
def isConstructor(self, member_elem):
""" Tests whether an member is a constructor
:param member_elem: XML element for a class member
"""
try:
definition = member_elem.find('definition').text
name = member_elem.find('name').text
if '{}::{}'.format(name, name) in definition:
return True
except:
pass
return False
def isOperator(self, member_elem):
""" Tests whether an member is an operator
:param member_elem: XML element for a class member
"""
try:
name = member_elem.find('name').text
if re.match('^operator\W.*', name):
return True
except:
pass
return False
def isFriendClass(self, member_elem):
""" Tests whether an member is a friend class
:param member_elem: XML element for a class member
"""
try:
if member_elem.get('kind') == 'friend':
return True
except:
pass
return False
def isTypeDef(self, member_elem):
""" Tests whether an member is a type def
:param member_elem: XML element for a class member
"""
try:
if member_elem.get('kind') == 'typedef':
return True
except:
pass
return False
def isReimplementation(self, member_elem):
""" Tests whether an member is a reimplementation
:param member_elem: XML element for a class member
"""
# use two different tests, as Doxygen will not detect reimplemented Qt methods
try:
if member_elem.find('reimplements') is not None:
return True
if ' override' in member_elem.find('argsstring').text:
return True
except:
pass
return False
def isDeprecated(self, member_elem):
""" Tests whether an member is deprecated
:param member_elem: XML element for a class member
"""
# look for both Q_DECL_DEPRECATED and Doxygen deprecated tag
decl_deprecated = False
type_elem = member_elem.find('type')
try:
if 'Q_DECL_DEPRECATED' in type_elem.text:
decl_deprecated = True
except:
pass
doxy_deprecated = False
try:
for p in member_elem.find('detaileddescription').getiterator('para'):
for s in p.getiterator('xrefsect'):
if s.find('xreftitle') is not None and 'Deprecated' in s.find('xreftitle').text:
doxy_deprecated = True
break
except:
assert 0, member_elem.find('definition').text
if not decl_deprecated and not doxy_deprecated:
return False
# only functions for now, but in future this should also apply for enums and variables
if member_elem.get('kind') in ('function', 'variable'):
assert decl_deprecated, 'Error: Missing Q_DECL_DEPRECATED for {}'.format(member_elem.find('definition').text)
assert doxy_deprecated, 'Error: Missing Doxygen deprecated tag for {}'.format(member_elem.find('definition').text)
return True
def memberIsDocumented(self, member_elem):
""" Tests whether an member has documentation
:param member_elem: XML element for a class member
"""
for doc_type in ('inbodydescription', 'briefdescription', 'detaileddescription'):
doc = member_elem.find(doc_type)
if doc is not None and list(doc):
return True
return False