# -*- 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.noncompliant_members = {} self.broken_links = {} 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 @staticmethod def standardize_signature(signature): """ Standardizes a method's signature for comparison """ return signature.lower().replace('* >', '*>').replace('< ', '<') 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, noncompliant, bindable, has_brief_description, found_version_added, broken_links = 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([DoxygenParser.standardize_signature(u) for u in undocumented]) - set([DoxygenParser.standardize_signature(u) 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 if len(noncompliant) > 0: self.noncompliant_members[class_name] = noncompliant if broken_links: self.broken_links[class_name] = broken_links # 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() noncompliant_members = [] bindable_members = [] broken_links = {} # 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 error = self.memberDocIsNonCompliant(m) if error: noncompliant_members.append({m.find('name').text: error}) else: undocumented_members.add(signature) broken_see_also_links = self.checkForBrokenSeeAlsoLinks(m) if broken_see_also_links: broken_links[m.find('name').text] = broken_see_also_links # 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, noncompliant_members, bindable_members, has_brief_description, found_version_added, broken_links 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: signature = elem.find('name').text + a.text else: signature = elem.find('name').text if signature.endswith('= default'): signature = signature[:-len('= default')] return signature.strip() 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!=', 'Q_ENUM'): 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 has_description = True 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 if s.find('xrefdescription') is None or s.find('xrefdescription').find('para') is None: has_description = False break except: assert 0, member_elem.find('definition').text if not decl_deprecated and not doxy_deprecated: return False if doxy_deprecated and not has_description: assert has_description, 'Error: Missing description for deprecated method {}'.format( member_elem.find('definition').text) # 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 def memberDocIsNonCompliant(self, member_elem): """ Tests whether an member's documentation is non-compliant :param member_elem: XML element for a class member """ for doc_type in ['briefdescription']: doc = member_elem.find(doc_type) if doc is not None: for para in doc.getiterator('para'): if not para.text: continue if para.text.strip().lower().startswith('getter'): return 'Use "Returns the..." instead of "getter"' if para.text.strip().lower().startswith('get '): return 'Use "Gets..." (or better, "Returns ...") instead of "get ..."' elif para.text.strip().lower().startswith('setter'): return 'Use "Sets the..." instead of "setter"' elif para.text.strip().lower().startswith('mutator'): return 'Use "Sets the..." instead of "mutator for..."' elif para.text.strip().lower().startswith('accessor'): return 'Use "Returns the..." instead of "accessor for..."' elif para.text.strip().lower().startswith('return '): return 'Use "Returns the..." instead of "return ..."' #elif para.text.strip().lower().startswith('set '): # return 'Use "Sets the..." instead of "set ..."' return False def checkForBrokenSeeAlsoLinks(self, elem): """ Checks for any broken 'see also' links """ broken = [] detailed_sec = elem.find('detaileddescription') for p in detailed_sec.getiterator('para'): for s in p.getiterator('simplesect'): if s.get('kind') != 'see': continue para = s.getchildren()[0] if para.find('ref') is None and para.text and (not para.text.startswith('Q') or para.text.startswith('Qgs')): broken.append(para.text) return broken