~launchpad-pqm/launchpad/devel

« back to all changes in this revision

Viewing changes to utilities/check-sampledata.py

  • Committer: Julian Edwards
  • Date: 2011-07-28 20:46:18 UTC
  • mfrom: (13553 devel)
  • mto: This revision was merged to the branch mainline in revision 13555.
  • Revision ID: julian.edwards@canonical.com-20110728204618-tivj2wx2oa9s32bx
merge trunk

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
#! /usr/bin/python -S
 
2
#
 
3
# Copyright 2009 Canonical Ltd.  This software is licensed under the
 
4
# GNU Affero General Public License version 3 (see the file LICENSE).
 
5
 
 
6
"""
 
7
check-sampledata.py - Perform various checks on Sample Data
 
8
 
 
9
= Launchpad Sample Data Consistency Checks =
 
10
 
 
11
XXX flacoste 2007/03/08 Once all problems exposed by this script are solved,
 
12
it should be integrated to our automated test suite.
 
13
 
 
14
This script verify that all objects in sample data provides the interfaces
 
15
they are supposed to. It also makes sure that the object pass its schema
 
16
validation.
 
17
 
 
18
Finally, it can also be used to report about sample data lacking in breadth.
 
19
 
 
20
"""
 
21
 
 
22
__metatype__ = type
 
23
 
 
24
import _pythonpath
 
25
 
 
26
import inspect
 
27
from optparse import OptionParser
 
28
import re
 
29
from textwrap import dedent
 
30
 
 
31
from psycopg2 import ProgrammingError
 
32
 
 
33
from zope.interface import providedBy
 
34
from zope.interface.exceptions import (
 
35
    BrokenImplementation, BrokenMethodImplementation)
 
36
from zope.interface.verify import verifyObject
 
37
from zope.schema.interfaces import IField, ValidationError
 
38
 
 
39
from canonical.database.sqlbase import SQLBase
 
40
import canonical.launchpad.database
 
41
from canonical.lp import initZopeless
 
42
from canonical.launchpad.scripts import execute_zcml_for_scripts
 
43
 
 
44
 
 
45
def get_class_name(cls):
 
46
    """Return the class name without its package prefix."""
 
47
    return cls.__name__.split('.')[-1]
 
48
 
 
49
 
 
50
def error_msg(error):
 
51
    """Convert an exception to a proper error.
 
52
 
 
53
    It make sure that the exception type is in the message and takes care
 
54
    of possible unicode conversion error.
 
55
    """
 
56
    try:
 
57
        return "%s: %s" % (get_class_name(error.__class__), str(error))
 
58
    except UnicodeEncodeError:
 
59
        return "UnicodeEncodeError in str(%s)" % error.__class__.__name__
 
60
 
 
61
 
 
62
class SampleDataVerification:
 
63
    """Runs various checks on sample data and report about them."""
 
64
 
 
65
    def __init__(self, dbname="launchpad_ftest_template", dbuser="launchpad",
 
66
                 table_filter=None, min_rows=10, only_summary=False):
 
67
        """Initialize the verification object.
 
68
 
 
69
        :param dbname: The database which contains the sample data to check.
 
70
        :param dbuser: The user to connect as.
 
71
        """
 
72
        self.txn = initZopeless(dbname=dbname, dbuser=dbuser)
 
73
        execute_zcml_for_scripts()
 
74
        self.classes_with_error = {}
 
75
        self.class_rows = {}
 
76
        self.table_filter = table_filter
 
77
        self.min_rows = min_rows
 
78
        self.only_summary = only_summary
 
79
 
 
80
    def findSQLBaseClasses(self):
 
81
        """Return an iterator over the classes in canonical.launchpad.database
 
82
        that extends SQLBase.
 
83
        """
 
84
        if self.table_filter:
 
85
            include_only_re = re.compile(self.table_filter)
 
86
        for class_name in dir(canonical.launchpad.database):
 
87
            if self.table_filter and not include_only_re.search(class_name):
 
88
                continue
 
89
            cls = getattr(canonical.launchpad.database, class_name)
 
90
            if inspect.isclass(cls) and issubclass(cls, SQLBase):
 
91
                yield cls
 
92
 
 
93
    def fetchTableRowsCount(self):
 
94
        """Fetch the number of rows of each tables.
 
95
 
 
96
        The count are stored in the table_rows_count attribute.
 
97
        """
 
98
        self.table_rows_count = {}
 
99
        for cls in self.findSQLBaseClasses():
 
100
            class_name = get_class_name(cls)
 
101
            try:
 
102
                self.table_rows_count[class_name] = cls.select().count()
 
103
            except ProgrammingError, error:
 
104
                self.classes_with_error[class_name] = str(error)
 
105
                # Transaction is borked, start another one.
 
106
                self.txn.begin()
 
107
 
 
108
    def checkSampleDataInterfaces(self):
 
109
        """Check that all sample data objects complies with the interfaces it
 
110
        declares.
 
111
        """
 
112
        self.validation_errors = {}
 
113
        self.broken_instances= {}
 
114
        for cls in self.findSQLBaseClasses():
 
115
            class_name = get_class_name(cls)
 
116
            if class_name in self.classes_with_error:
 
117
                continue
 
118
            try:
 
119
                for object in cls.select():
 
120
                    self.checkObjectInterfaces(object)
 
121
                    self.validateObjectSchemas(object)
 
122
            except ProgrammingError, error:
 
123
                self.classes_with_error[get_class_name(cls)] = str(error)
 
124
                # Transaction is borked, start another one.
 
125
                self.txn.begin()
 
126
 
 
127
    def checkObjectInterfaces(self, object):
 
128
        """Check that object provides every attributes in its declared interfaces.
 
129
 
 
130
        Collect errors in broken_instances dictionary attribute.
 
131
        """
 
132
        for interface in providedBy(object):
 
133
            interface_name = get_class_name(interface)
 
134
            try:
 
135
                result = verifyObject(interface, object)
 
136
            except BrokenImplementation, error:
 
137
                self.setInterfaceError(
 
138
                    interface, object, "missing attribute %s" % error.name)
 
139
            except BrokenMethodImplementation, error:
 
140
                self.setInterfaceError(
 
141
                     interface, object,
 
142
                    "invalid method %s: %s" % (error.method, error.mess))
 
143
 
 
144
    def setInterfaceError(self, interface, object, error_msg):
 
145
        """Store an error about an interface in the broken_instances dictionary
 
146
 
 
147
        The errors data structure looks like:
 
148
 
 
149
        {interface: {
 
150
            error_msg: {
 
151
                class_name: [instance_id...]}}}
 
152
        """
 
153
        interface_errors = self.broken_instances.setdefault(
 
154
            get_class_name(interface), {})
 
155
        classes_with_error = interface_errors.setdefault(error_msg, {})
 
156
        object_ids_with_error = classes_with_error.setdefault(
 
157
            get_class_name(object.__class__), [])
 
158
        object_ids_with_error.append(object.id)
 
159
 
 
160
    def validateObjectSchemas(self, object):
 
161
        """Check that object validates with the schemas it says it provides.
 
162
 
 
163
        Collect errors in validation_errors. Data structure format is
 
164
        {schema:
 
165
            [[class_name, object_id,
 
166
                [(field, error), ...]],
 
167
             ...]}
 
168
        """
 
169
        for schema in providedBy(object):
 
170
            field_errors = []
 
171
            for name in schema.names(all=True):
 
172
                description = schema[name]
 
173
                if not IField.providedBy(description):
 
174
                    continue
 
175
                try:
 
176
                    value = getattr(object, name)
 
177
                except AttributeError:
 
178
                    # This is an already reported verifyObject failures.
 
179
                    continue
 
180
                try:
 
181
                    description.validate(value)
 
182
                except ValidationError, error:
 
183
                    field_errors.append((name, error_msg(error)))
 
184
                except (KeyboardInterrupt, SystemExit):
 
185
                    # We should never catch KeyboardInterrupt or SystemExit.
 
186
                    raise
 
187
                except ProgrammingError, error:
 
188
                    field_errors.append((name, error_msg(error)))
 
189
                    # We need to restart the transaction after these errors.
 
190
                    self.txn.begin()
 
191
                except Exception, error:
 
192
                    # Exception usually indicates a deeper problem with
 
193
                    # the interface declaration or the validation code, than
 
194
                    # the expected ValidationError.
 
195
                    field_errors.append((name, error_msg(error)))
 
196
            if field_errors:
 
197
                schema_errors= self.validation_errors.setdefault(
 
198
                    get_class_name(schema), [])
 
199
                schema_errors.append([
 
200
                    get_class_name(object.__class__), object.id,
 
201
                    field_errors])
 
202
 
 
203
    def getShortTables(self):
 
204
        """Return a list of tables which have less rows than self.min_rows.
 
205
 
 
206
        :return: [(table, rows_count)...]
 
207
        """
 
208
        return [
 
209
            (table, rows_count)
 
210
            for table, rows_count in self.table_rows_count.items()
 
211
            if rows_count < self.min_rows]
 
212
 
 
213
    def reportShortTables(self):
 
214
        """Report about tables with less than self.min_rows."""
 
215
        short_tables = self.getShortTables()
 
216
        if not short_tables:
 
217
            print """All tables have more than %d rows!!!""" % self.min_rows
 
218
            return
 
219
 
 
220
        print dedent("""\
 
221
            %d Tables with less than %d rows
 
222
            --------------------------------""" % (
 
223
                len(short_tables), self.min_rows))
 
224
        for table, rows_count in sorted(short_tables):
 
225
            print "%-20s: %2d" % (table, rows_count)
 
226
 
 
227
    def reportErrors(self):
 
228
        """Report about classes with database error.
 
229
 
 
230
        This will usually be classes without a database table.
 
231
        """
 
232
        if not self.classes_with_error:
 
233
            return
 
234
        print dedent("""\
 
235
            Classes with database errors
 
236
            ----------------------------""")
 
237
        for class_name, error_msg in sorted(self.classes_with_error.items()):
 
238
            print "%-20s %s" % (class_name, error_msg)
 
239
 
 
240
    def reportInterfaceErrors(self):
 
241
        """Report objects failing the verifyObject and schema validation."""
 
242
        if not self.broken_instances:
 
243
            print "All sample data comply with its provided interfaces!!!"
 
244
            return
 
245
        print dedent("""\
 
246
            %d Interfaces with broken instances
 
247
            -----------------------------------""" % len(
 
248
                self.broken_instances))
 
249
        for interface, errors in sorted(
 
250
            self.broken_instances.items()):
 
251
            print "%-20s:" % interface
 
252
            for error_msg, classes_with_error in sorted(errors.items()):
 
253
                print "    %s:" % error_msg
 
254
                for class_name, object_ids in sorted(
 
255
                    classes_with_error.items()):
 
256
                    print "        %s: %s" % (
 
257
                        class_name, ", ".join([
 
258
                            str(id) for id in sorted(object_ids)]))
 
259
 
 
260
    def reportValidationErrors(self):
 
261
        """Report object that fails their validation."""
 
262
        if not self.validation_errors:
 
263
            print "All sample data pass validation!!!"
 
264
            return
 
265
 
 
266
        print dedent("""\
 
267
            %d Schemas with instances failing validation
 
268
            --------------------------------------------""" % len(
 
269
                self.validation_errors))
 
270
        for schema, instances in sorted(self.validation_errors.items()):
 
271
            print "%-20s (%d objects with errors):" % (schema, len(instances))
 
272
            for class_name, object_id, errors in sorted(instances):
 
273
                print "    <%s %s> (%d errors):" % (
 
274
                    class_name, object_id, len(errors))
 
275
                for field, error in sorted(errors):
 
276
                    print "        %s: %s" % (field, error)
 
277
 
 
278
    def reportSummary(self):
 
279
        """Only report the name of the classes with errors."""
 
280
 
 
281
        short_tables = dict(self.getShortTables())
 
282
 
 
283
        # Compute number of implementation error by classes.
 
284
        verify_errors_count = {}
 
285
        for interface_errors in self.broken_instances.values():
 
286
            for broken_classes in interface_errors.values():
 
287
                for class_name in broken_classes.keys():
 
288
                    verify_errors_count.setdefault(class_name, 0)
 
289
                    verify_errors_count[class_name] += 1
 
290
 
 
291
        # Compute number of instances with validation error.
 
292
        validation_errors_count = {}
 
293
        for instances in self.validation_errors.values():
 
294
            for class_name, object_id, errors in instances:
 
295
                validation_errors_count.setdefault(class_name, 0)
 
296
                validation_errors_count[class_name] += 1
 
297
 
 
298
        classes_with_errors = set(short_tables.keys())
 
299
        classes_with_errors.update(verify_errors_count.keys())
 
300
        classes_with_errors.update(validation_errors_count.keys())
 
301
 
 
302
        print dedent("""\
 
303
            %d Classes with errors:
 
304
            -----------------------""" % len(classes_with_errors))
 
305
        for class_name in sorted(classes_with_errors):
 
306
            errors = []
 
307
            if class_name in short_tables:
 
308
                errors.append('%d rows' % short_tables[class_name])
 
309
            if class_name in verify_errors_count:
 
310
                errors.append(
 
311
                    '%d verify errors' % verify_errors_count[class_name])
 
312
            if class_name in validation_errors_count:
 
313
                errors.append(
 
314
                    '%d validation errors' %
 
315
                        validation_errors_count[class_name])
 
316
            print "%s: %s" % (class_name, ", ".join(errors))
 
317
 
 
318
    def run(self):
 
319
        """Check and report on sample data."""
 
320
        self.fetchTableRowsCount()
 
321
        self.checkSampleDataInterfaces()
 
322
        print dedent("""\
 
323
            Verified %d content classes.
 
324
            ============================
 
325
            """ % len(self.table_rows_count))
 
326
        if self.only_summary:
 
327
            self.reportSummary()
 
328
        else:
 
329
            self.reportShortTables()
 
330
            print
 
331
            self.reportInterfaceErrors()
 
332
            print
 
333
            self.reportValidationErrors()
 
334
        print
 
335
        self.reportErrors()
 
336
        self.txn.abort()
 
337
 
 
338
 
 
339
if __name__ == '__main__':
 
340
    parser = OptionParser()
 
341
    parser.add_option('-d', '--database', action="store", type="string",
 
342
                      default="launchpad_ftest_template",
 
343
                      help="Database to connect to for testing.")
 
344
    parser.add_option('-u', '--user', action="store", type="string",
 
345
                      default="launchpad",
 
346
                      help="Username to connect with.")
 
347
    parser.add_option('-i', '--table-filter', dest="table_filter",
 
348
                      action="store", type="string", default=None,
 
349
                      help="Limit classes to test using a regular expression.")
 
350
    parser.add_option('-m', '--min-rows', dest="min_rows",
 
351
                      action="store", type="int", default=10,
 
352
                      help="Minimum number of rows a table is expected to have.")
 
353
    parser.add_option('-s', '--summary',
 
354
                      action='store_true', dest="summary", default=False,
 
355
                      help=(
 
356
                        "Only report the name of the classes with "
 
357
                        "validation errors."))
 
358
    options, arguments = parser.parse_args()
 
359
    SampleDataVerification(
 
360
        dbname=options.database,
 
361
        dbuser=options.user,
 
362
        table_filter=options.table_filter,
 
363
        min_rows=options.min_rows,
 
364
        only_summary=options.summary).run()