Add test for coverage of SIP bindings

Not perfect, but good for a quick warning if a new class or member
has been added to the public API without Python bindings. The test
only considers the name of members, since it seems to be impossible
to test for the signature of a Python member. (So adding a new
overloaded method without bindings will still unfortunately pass).

You can avoid the test where bindings are not applicable:
- for a whole class by placing "@note not available in Python bindings"
in the class' Doxygen comments
- or by placing the @note inside a member's Doxygen comments for a
specific member

Additionally, classes which aren't included in the API docs
will not be tested.
This commit is contained in:
Nyall Dawson 2015-10-15 18:31:17 +11:00
parent 7842391eba
commit fc49f8dfa9
18 changed files with 493 additions and 174 deletions

View File

@ -69,6 +69,10 @@ class QgsPaintEffectRegistry
public:
/** Returns a reference to the singleton instance of the paint effect registry.
*/
static QgsPaintEffectRegistry* instance();
/** Returns the metadata for a specific effect.
* @param name unique string name for paint effect class
* @returns paint effect metadata if found, otherwise NULL

View File

@ -44,4 +44,7 @@ class QgsFieldComboBox : QComboBox
//! setField sets the currently selected field
void setField( const QString& fieldName );
protected slots:
void indexChanged( int i );
};

View File

@ -154,6 +154,9 @@ class CORE_EXPORT QgsPaintEffectMetadata : public QgsPaintEffectAbstractMetadata
class CORE_EXPORT QgsPaintEffectRegistry
{
public:
/** Returns a reference to the singleton instance of the paint effect registry.
*/
static QgsPaintEffectRegistry* instance();
/** Returns the metadata for a specific effect.

View File

@ -98,6 +98,10 @@ class GEOSInit
static GEOSInit geosinit;
/**
* @brief Scoped GEOS pointer
* @note not available in Python bindings
*/
class GEOSGeomScopedPtr
{
public:

View File

@ -18,6 +18,11 @@
#include <QList>
#include "rtree.hpp"
/**
* \class pal::CostCalculator
* \note not available in Python bindings
*/
namespace pal
{
class Feats;
@ -48,9 +53,11 @@ namespace pal
/**
* \brief Data structure to compute polygon's candidates costs
*
* eight segment from center of candidat to (rpx,rpy) points (0°, 45°, 90°, ..., 315°)
* Eight segments from center of candidate to (rpx,rpy) points (0°, 45°, 90°, ..., 315°)
* dist store the shortest square distance from the center to an object
* ok[i] is the to true whether the corresponding dist[i] is set
*
* \note not available in Python bindings
*/
class PolygonCostCalculator
{

View File

@ -41,6 +41,11 @@
#include "qgslabelingenginev2.h"
/**
* \class pal::LabelInfo
* \note not available in Python bindings
*/
namespace pal
{
/** Optional additional info about label (for curved labels) */
@ -75,6 +80,8 @@ namespace pal
/**
* \brief Main class to handle feature
* \class pal::FeaturePart
* \note not available in Python bindings
*/
class CORE_EXPORT FeaturePart : public PointSet
{

View File

@ -44,6 +44,8 @@ namespace pal
/**
* \brief LabelPosition is a candidate feature label position
* \class pal::LabelPosition
* \note not available in Python bindings
*/
class CORE_EXPORT LabelPosition : public PointSet
{

View File

@ -48,11 +48,9 @@ namespace pal
class LabelInfo;
/**
* \brief A layer of spacial entites
*
* a layer is a bog of feature with some data which influence the labelling process
*
* \author Maxence Laurent (maxence _dot_ laurent _at_ heig-vd _dot_ ch)
* \brief A set of features which influence the labelling process
* \class pal::Layer
* \note not available in Python bindings
*/
class CORE_EXPORT Layer
{

View File

@ -41,14 +41,6 @@
class QgsAbstractLabelProvider;
/**
*
* \section intro_sec Introduction
*
* Pal is a labelling library released under the GPLv3 license
*
*/
namespace pal
{
/** Get GEOS context handle to be used in all GEOS library calls with reentrant API */
@ -101,12 +93,12 @@ namespace pal
};
/**
* \brief Pal main class.
* \brief Main Pal labelling class
*
* A pal object will contains layers and global information such as which search method
* will be used.
*
* \author Maxence Laurent (maxence _dot_ laurent _at_ heig-vd _dot_ ch)
* \class pal::Pal
* \note not available in Python bindings
*/
class CORE_EXPORT Pal
{

View File

@ -36,8 +36,11 @@ namespace pal
{
/**
* Summury of problem
* \brief Summary statistics of labelling problem.
* \class pal::PalStat
* \note not available in Python bindings
*/
class PalStat
{

View File

@ -56,7 +56,10 @@ namespace pal
double length;
} CHullBox;
/**
* \class pal::PointSet
* \note not available in Python bindings
*/
class CORE_EXPORT PointSet
{
friend class FeaturePart;

View File

@ -39,7 +39,10 @@
namespace pal
{
/**
* \class pal::PriorityQueue
* \note not available in Python bindings
*/
class PriorityQueue
{

View File

@ -41,6 +41,10 @@ namespace pal
class LabelPosition;
class Label;
/**
* \class pal::Sol
* \note not available in Python bindings
*/
class Sol
{
public:
@ -88,7 +92,9 @@ namespace pal
} Chain;
/**
* \brief Represent a problem
* \brief Representation of a labeling problem
* \class pal::Problem
* \note not available in Python bindings
*/
class CORE_EXPORT Problem
{

View File

@ -46,6 +46,7 @@ namespace pal
/**
* \brief For usage in problem solving algorithm
* \note not available in Python bindings
*/
class Feats
{

View File

@ -79,6 +79,8 @@ ENDIF (ENABLE_PGTEST)
IF (WITH_APIDOC)
ADD_PYTHON_TEST(PyQgsDocCoverage test_qgsdoccoverage.py)
#SIP coverage test relies on API doc parsing to identify members which should be in bindings
ADD_PYTHON_TEST(PyQgsSipCoverage test_qgssipcoverage.py)
ENDIF (WITH_APIDOC)
IF (WITH_SERVER)

View File

@ -13,16 +13,10 @@ __copyright__ = 'Copyright 2015, The QGIS Project'
__revision__ = '$Format:%H$'
import os
import glob
from utilities import (TestCase,
unittest,
printImportant)
try:
import xml.etree.cElementTree as ET
except ImportError:
import xml.etree.ElementTree as ET
printImportant,
DoxygenParser)
from PyQt4.QtCore import qDebug
@ -33,146 +27,7 @@ from PyQt4.QtCore import qDebug
# DON'T RAISE THIS THRESHOLD!!!
# (changes which lower this threshold are welcomed though!)
ACCEPTABLE_MISSING_DOCS = 4024
def elemIsDocumentableClass(elem):
if not elem.get('kind') == 'class':
return False
# public or protected classes should be documented
return elem.get('prot') in ('public', 'protected')
def memberSignature(elem):
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 elemIsDocumentableMember(elem):
if elem.get('kind') == 'variable':
return False
# only public or protected members should be documented
if not elem.get('prot') in ('public', 'protected'):
return False
# ignore reimplemented methods
# use two different tests, as doxygen will not detect reimplemented qt methods
if elem.find('reimplements') is not None:
return False
args = elem.find('argsstring')
if args is not None and args.text and ' override' in args.text:
return False
# ignore destructor
name = elem.find('name')
try:
if name.text and name.text.startswith('~'):
return False
except:
pass
# ignore constructors with no arguments
definition = elem.find('definition')
argsstring = elem.find('argsstring')
try:
if definition.text == '{}::{}'.format(name.text, name.text) and argsstring.text == '()':
return False
except:
pass
# ignore certain obvious operators
try:
if name.text in ('operator=', 'operator=='):
return False
except:
pass
# ignore on_* slots
try:
if name.text.startswith('on_'):
return False
except:
pass
# ignore deprecated members
typeelem = elem.find('type')
try:
if typeelem.text and 'Q_DECL_DEPRECATED' in typeelem.text:
return False
except:
pass
return True
def memberIsDocumented(m):
for doc_type in ('inbodydescription', 'briefdescription', 'detaileddescription'):
doc = m.find(doc_type)
if doc is not None and list(doc):
return True
return False
def parseClassElem(e):
documentable_members = 0
documented_members = 0
undocumented_members = []
for m in e.getiterator('memberdef'):
if elemIsDocumentableMember(m):
documentable_members += 1
if memberIsDocumented(m):
documented_members += 1
else:
undocumented_members.append(memberSignature(m))
return documentable_members, documented_members, undocumented_members
def parseFile(f):
documentable_members = 0
documented_members = 0
try:
for event, elem in ET.iterparse(f):
if event == 'end' and elem.tag == 'compounddef':
if elemIsDocumentableClass(elem):
members, documented, undocumented = parseClassElem(elem)
documentable_members += members
documented_members += documented
if documented < members:
print "Class {}, {}/{} members documented".format(elem.find('compoundname').text, documented, members)
for u in undocumented:
print ' Missing: {}'.format(u)
print "\n"
elem.clear()
except ET.ParseError as e:
# sometimes Doxygen generates malformed xml (eg 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)
return documentable_members, documented_members
def parseDocs(path):
documentable_members = 0
documented_members = 0
for f in glob.glob(os.path.join(path, '*.xml')):
members, documented = parseFile(f)
documentable_members += members
documented_members += documented
return documentable_members, documented_members
ACCEPTABLE_MISSING_DOCS = 4020
class TestQgsDocCoverage(TestCase):
@ -181,17 +36,19 @@ class TestQgsDocCoverage(TestCase):
print 'CTEST_FULL_OUTPUT'
prefixPath = os.environ['QGIS_PREFIX_PATH']
docPath = os.path.join(prefixPath, '..', 'doc', 'api', 'xml')
parser = DoxygenParser(docPath)
documentable, documented = parseDocs(docPath)
coverage = 100.0 * documented / documentable
missing = documentable - documented
coverage = 100.0 * parser.documented_members / parser.documentable_members
missing = parser.documentable_members - parser.documented_members
print "---------------------------------"
printImportant("{} total documentable members".format(documentable))
printImportant("{} total contain valid documentation".format(documented))
printImportant("{} total documentable members".format(parser.documentable_members))
printImportant("{} total contain valid documentation".format(parser.documented_members))
printImportant("Total documentation coverage {}%".format(coverage))
printImportant("---------------------------------")
printImportant("{} members missing documentation, out of {} allowed".format(missing, ACCEPTABLE_MISSING_DOCS))
print "---------------------------------"
print parser.undocumented_string
assert missing <= ACCEPTABLE_MISSING_DOCS, 'FAIL: new undocumented members have been introduced, please add documentation for these members'

View File

@ -0,0 +1,111 @@
# -*- coding: utf-8 -*-
"""QGIS Unit tests for SIP binding coverage.
.. note:: 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__ = 'Nyall Dawson'
__date__ = '15/10/2015'
__copyright__ = 'Copyright 2015, The QGIS Project'
# This will get replaced with a git SHA1 when you do a git archive
__revision__ = '$Format:%H$'
import os
from utilities import (TestCase,
unittest,
printImportant,
DoxygenParser)
from PyQt4.QtCore import qDebug
#Import all the things!
from qgis.analysis import *
from qgis.core import *
from qgis.gui import *
from qgis.networkanalysis import *
try:
from qgis.server import *
except:
pass
# BINDING THRESHOLD
#
# The minimum number of unbound functions in QGIS api
#
# DON'T RAISE THIS THRESHOLD!!!
# (changes which lower this threshold are welcomed though!)
ACCEPTABLE_MISSING_CLASSES = 198
ACCEPTABLE_MISSING_MEMBERS = 530
class TestQgsSipCoverage(TestCase):
def testCoverage(self):
print 'CTEST_FULL_OUTPUT'
prefixPath = os.environ['QGIS_PREFIX_PATH']
docPath = os.path.join(prefixPath, '..', 'doc', 'api', 'xml')
parser = DoxygenParser(docPath)
#first look for objects without any bindings
objects = set([m[0] for m in parser.bindable_members])
missing_objects = []
bound_objects = {}
for o in objects:
try:
bound_objects[o] = globals()[o]
except:
missing_objects.append(o)
missing_objects.sort()
missing_count = len(missing_objects)
present_count = len(objects) - missing_count
coverage = 100.0 * present_count / len(objects)
print "---------------------------------"
printImportant("{} total bindable classes".format(len(objects)))
printImportant("{} total have bindings".format(present_count))
printImportant("Binding coverage by classes {}%".format(coverage))
printImportant("---------------------------------")
printImportant("{} classes missing bindings, out of {} allowed".format(missing_count, ACCEPTABLE_MISSING_CLASSES))
print "---------------------------------"
assert missing_count <= ACCEPTABLE_MISSING_CLASSES, """\n\nFAIL: new unbound classes have been introduced, please add SIP bindings for these classes
If these classes are not suitable for the Python bindings, please add the Doxygen tag
"@note not available in Python bindings" to the CLASS Doxygen comments"""
#next check for individual members
parser.bindable_members.sort()
missing_members = []
for m in parser.bindable_members:
if m[0] in bound_objects:
obj = bound_objects[m[0]]
if not hasattr(obj, m[1]):
missing_members.append('{}.{}'.format(m[0], m[1]))
missing_members.sort()
missing_count = len(missing_members)
present_count = len(parser.bindable_members) - missing_count
coverage = 100.0 * present_count / len(parser.bindable_members)
print "---------------------------------"
printImportant("{} total bindable members".format(len(parser.bindable_members)))
printImportant("{} total have bindings".format(present_count))
printImportant("Binding coverage by members {}%".format(coverage))
printImportant("---------------------------------")
printImportant("{} members missing bindings, out of {} allowed".format(missing_count, ACCEPTABLE_MISSING_MEMBERS))
print "---------------------------------"
print 'Missing classes:\n {}'.format('\n '.join(missing_objects))
print "---------------------------------"
print 'Missing members:\n {}'.format('\n '.join(missing_members))
assert missing_count <= ACCEPTABLE_MISSING_MEMBERS, """\n\nFAIL: new unbound members have been introduced, please add SIP bindings for these members
If these members are not suitable for the Python bindings, please add the Doxygen tag
"@note not available in Python bindings" to the MEMBER Doxygen comments"""
if __name__ == '__main__':
unittest.main()

View File

@ -14,6 +14,7 @@ __revision__ = '$Format:%H$'
import qgis
import os
import sys
import glob
import platform
import tempfile
@ -35,6 +36,10 @@ from qgis_interface import QgisInterface
import hashlib
import re
from itertools import izip
try:
import xml.etree.cElementTree as ET
except ImportError:
import xml.etree.ElementTree as ET
import webbrowser
import subprocess
@ -401,3 +406,311 @@ def printImportant(info):
print(info)
with open(os.path.join(tempfile.gettempdir(), 'ctest-important.log'), 'a+') as f:
f.write(u'{}\n'.format(info))
class DoxygenParser():
"""
Parses the XML files generated by Doxygen which describe the API docs
"""
def __init__(self, path):
"""
Initializes the parser.
:param path: Path to Doxygen XML output
"""
self.documentable_members = 0
self.documented_members = 0
self.undocumented_string = ''
self.bindable_members = []
self.parseFiles(path)
def parseFiles(self, path):
""" Parses all the Doxygen XML files in a folder
:param path: Path to Doxygen XML output
"""
for f in glob.glob(os.path.join(path, '*.xml')):
self.parseFile(f)
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 = self.parseClassElem(elem)
documentable_members += members
documented_members += documented
class_name = elem.find('compoundname').text
if documented < members:
self.undocumented_string += "Class {}, {}/{} members documented\n".format(class_name, documented, members)
for u in undocumented:
self.undocumented_string += ' Missing: {}\n'.format(u)
self.undocumented_string += "\n"
#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 (eg 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 'not available in python bindings' note in class docs
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 'not available in python bindings' 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 = []
bindable_members = []
# loop through all members
for m in e.getiterator('memberdef'):
if self.elemIsBindableMember(m):
bindable_member = [e.find('compoundname').text, m.find('name').text]
if not bindable_member 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.append(self.memberSignature(m))
return documentable_members, documented_members, undocumented_members, bindable_members
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
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 '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
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 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=='):
return False
except:
pass
# ignore on_* slots
try:
if name.text.startswith('on_'):
return False
except:
pass
# ignore deprecated members
typeelem = elem.find('type')
try:
if typeelem.text and 'Q_DECL_DEPRECATED' in typeelem.text:
return False
except:
pass
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 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 definition == '{}::{}'.format(name, name):
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 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 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