blob: 17d50ab65930c3072a6f1f12afb3d03005ff075f [file] [log] [blame]
Daniel Dunbar1a9db992009-08-06 21:15:33 +00001#!/usr/bin/env python
2
3"""
4CmpRuns - A simple tool for comparing two static analyzer runs to determine
5which reports have been added, removed, or changed.
6
7This is designed to support automated testing using the static analyzer, from
Ted Kremenek3a0678e2015-09-08 03:50:52 +00008two perspectives:
George Karpenkova8076602017-10-02 17:59:12 +00009 1. To monitor changes in the static analyzer's reports on real code bases,
10 for regression testing.
Daniel Dunbar1a9db992009-08-06 21:15:33 +000011
12 2. For use by end users who want to integrate regular static analyzer testing
13 into a buildbot like environment.
Anna Zaks9b7d7142012-07-16 20:21:42 +000014
15Usage:
16
17 # Load the results of both runs, to obtain lists of the corresponding
18 # AnalysisDiagnostic objects.
Anna Zaks45a992b2012-08-02 00:41:40 +000019 #
Anna Zaksc80313b2012-10-15 22:48:21 +000020 resultsA = loadResultsFromSingleRun(singleRunInfoA, deleteEmpty)
21 resultsB = loadResultsFromSingleRun(singleRunInfoB, deleteEmpty)
Ted Kremenek3a0678e2015-09-08 03:50:52 +000022
23 # Generate a relation from diagnostics in run A to diagnostics in run B
24 # to obtain a list of triples (a, b, confidence).
Anna Zaks9b7d7142012-07-16 20:21:42 +000025 diff = compareResults(resultsA, resultsB)
Ted Kremenek3a0678e2015-09-08 03:50:52 +000026
Daniel Dunbar1a9db992009-08-06 21:15:33 +000027"""
28
Mikhail R. Gadelha8af2e692018-05-28 15:40:39 +000029from collections import defaultdict
30
George Karpenkovb7043222018-02-01 22:25:18 +000031from math import log
George Karpenkov39590412018-02-09 18:48:31 +000032from optparse import OptionParser
Mikhail R. Gadelha8af2e692018-05-28 15:40:39 +000033import json
34import os
35import plistlib
36import re
37import sys
Daniel Dunbar1a9db992009-08-06 21:15:33 +000038
Mikhail R. Gadelha8af2e692018-05-28 15:40:39 +000039STATS_REGEXP = re.compile(r"Statistics: (\{.+\})", re.MULTILINE | re.DOTALL)
40
41class Colors:
42 """
43 Color for terminal highlight.
44 """
45 RED = '\x1b[2;30;41m'
46 GREEN = '\x1b[6;30;42m'
47 CLEAR = '\x1b[0m'
George Karpenkova8076602017-10-02 17:59:12 +000048
Anna Zaksc80313b2012-10-15 22:48:21 +000049# Information about analysis run:
50# path - the analysis output directory
Ted Kremenek3a0678e2015-09-08 03:50:52 +000051# root - the name of the root directory, which will be disregarded when
Anna Zaksc80313b2012-10-15 22:48:21 +000052# determining the source file name
53class SingleRunInfo:
54 def __init__(self, path, root="", verboseLog=None):
55 self.path = path
Gabor Horvathc3177f22015-07-08 18:39:31 +000056 self.root = root.rstrip("/\\")
Anna Zaksc80313b2012-10-15 22:48:21 +000057 self.verboseLog = verboseLog
58
George Karpenkova8076602017-10-02 17:59:12 +000059
Anna Zaks9b7d7142012-07-16 20:21:42 +000060class AnalysisDiagnostic:
61 def __init__(self, data, report, htmlReport):
62 self._data = data
63 self._loc = self._data['location']
64 self._report = report
65 self._htmlReport = htmlReport
George Karpenkovb7043222018-02-01 22:25:18 +000066 self._reportSize = len(self._data['path'])
Anna Zaks9b7d7142012-07-16 20:21:42 +000067
68 def getFileName(self):
Anna Zaksc80313b2012-10-15 22:48:21 +000069 root = self._report.run.root
Anna Zaks639b4042012-10-17 21:09:26 +000070 fileName = self._report.files[self._loc['file']]
Gabor Horvathc3177f22015-07-08 18:39:31 +000071 if fileName.startswith(root) and len(root) > 0:
George Karpenkova8076602017-10-02 17:59:12 +000072 return fileName[len(root) + 1:]
Anna Zaksc80313b2012-10-15 22:48:21 +000073 return fileName
74
Anna Zaks9b7d7142012-07-16 20:21:42 +000075 def getLine(self):
76 return self._loc['line']
Ted Kremenek3a0678e2015-09-08 03:50:52 +000077
Anna Zaks9b7d7142012-07-16 20:21:42 +000078 def getColumn(self):
79 return self._loc['col']
80
George Karpenkovb7043222018-02-01 22:25:18 +000081 def getPathLength(self):
82 return self._reportSize
83
Anna Zaks9b7d7142012-07-16 20:21:42 +000084 def getCategory(self):
85 return self._data['category']
86
87 def getDescription(self):
88 return self._data['description']
89
George Karpenkova8076602017-10-02 17:59:12 +000090 def getIssueIdentifier(self):
Anna Zaksc80313b2012-10-15 22:48:21 +000091 id = self.getFileName() + "+"
George Karpenkova8076602017-10-02 17:59:12 +000092 if 'issue_context' in self._data:
93 id += self._data['issue_context'] + "+"
94 if 'issue_hash_content_of_line_in_context' in self._data:
95 id += str(self._data['issue_hash_content_of_line_in_context'])
Anna Zaksc80313b2012-10-15 22:48:21 +000096 return id
Anna Zaks9b7d7142012-07-16 20:21:42 +000097
98 def getReport(self):
99 if self._htmlReport is None:
100 return " "
101 return os.path.join(self._report.run.path, self._htmlReport)
102
103 def getReadableName(self):
George Karpenkov986dd452018-02-06 17:22:09 +0000104 if 'issue_context' in self._data:
105 funcnamePostfix = "#" + self._data['issue_context']
106 else:
107 funcnamePostfix = ""
108 return '%s%s:%d:%d, %s: %s' % (self.getFileName(),
109 funcnamePostfix,
110 self.getLine(),
111 self.getColumn(), self.getCategory(),
112 self.getDescription())
Ted Kremenek3a0678e2015-09-08 03:50:52 +0000113
114 # Note, the data format is not an API and may change from one analyzer
115 # version to another.
Anna Zaks639b4042012-10-17 21:09:26 +0000116 def getRawData(self):
117 return self._data
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000118
George Karpenkova8076602017-10-02 17:59:12 +0000119
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000120class AnalysisReport:
Anna Zaksfab9bb62012-11-15 22:42:44 +0000121 def __init__(self, run, files):
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000122 self.run = run
Anna Zaks639b4042012-10-17 21:09:26 +0000123 self.files = files
124 self.diagnostics = []
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000125
George Karpenkova8076602017-10-02 17:59:12 +0000126
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000127class AnalysisRun:
Anna Zaksc80313b2012-10-15 22:48:21 +0000128 def __init__(self, info):
129 self.path = info.path
130 self.root = info.root
131 self.info = info
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000132 self.reports = []
Anna Zaks639b4042012-10-17 21:09:26 +0000133 # Cumulative list of all diagnostics from all the reports.
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000134 self.diagnostics = []
Anna Zaksfab9bb62012-11-15 22:42:44 +0000135 self.clang_version = None
Mikhail R. Gadelha8af2e692018-05-28 15:40:39 +0000136 self.stats = []
Ted Kremenek3a0678e2015-09-08 03:50:52 +0000137
Anna Zaksfab9bb62012-11-15 22:42:44 +0000138 def getClangVersion(self):
139 return self.clang_version
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000140
Jordan Roseb042cc72013-03-23 01:21:26 +0000141 def readSingleFile(self, p, deleteEmpty):
142 data = plistlib.readPlist(p)
Mikhail R. Gadelha8af2e692018-05-28 15:40:39 +0000143 if 'statistics' in data:
144 self.stats.append(json.loads(data['statistics']))
145 data.pop('statistics')
Jordan Roseb042cc72013-03-23 01:21:26 +0000146
Ted Kremenek3a0678e2015-09-08 03:50:52 +0000147 # We want to retrieve the clang version even if there are no
148 # reports. Assume that all reports were created using the same
Jordan Roseb042cc72013-03-23 01:21:26 +0000149 # clang version (this is always true and is more efficient).
150 if 'clang_version' in data:
George Karpenkova8076602017-10-02 17:59:12 +0000151 if self.clang_version is None:
Jordan Roseb042cc72013-03-23 01:21:26 +0000152 self.clang_version = data.pop('clang_version')
153 else:
154 data.pop('clang_version')
155
156 # Ignore/delete empty reports.
157 if not data['files']:
George Karpenkova8076602017-10-02 17:59:12 +0000158 if deleteEmpty:
Jordan Roseb042cc72013-03-23 01:21:26 +0000159 os.remove(p)
160 return
161
162 # Extract the HTML reports, if they exists.
163 if 'HTMLDiagnostics_files' in data['diagnostics'][0]:
164 htmlFiles = []
165 for d in data['diagnostics']:
166 # FIXME: Why is this named files, when does it have multiple
167 # files?
168 assert len(d['HTMLDiagnostics_files']) == 1
169 htmlFiles.append(d.pop('HTMLDiagnostics_files')[0])
170 else:
171 htmlFiles = [None] * len(data['diagnostics'])
Ted Kremenek3a0678e2015-09-08 03:50:52 +0000172
Jordan Roseb042cc72013-03-23 01:21:26 +0000173 report = AnalysisReport(self, data.pop('files'))
Ted Kremenek3a0678e2015-09-08 03:50:52 +0000174 diagnostics = [AnalysisDiagnostic(d, report, h)
George Karpenkova8076602017-10-02 17:59:12 +0000175 for d, h in zip(data.pop('diagnostics'), htmlFiles)]
Jordan Roseb042cc72013-03-23 01:21:26 +0000176
177 assert not data
178
179 report.diagnostics.extend(diagnostics)
180 self.reports.append(report)
181 self.diagnostics.extend(diagnostics)
182
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000183
George Karpenkova8076602017-10-02 17:59:12 +0000184def loadResults(path, opts, root="", deleteEmpty=True):
185 """
186 Backwards compatibility API.
187 """
Anna Zaksc80313b2012-10-15 22:48:21 +0000188 return loadResultsFromSingleRun(SingleRunInfo(path, root, opts.verboseLog),
189 deleteEmpty)
190
George Karpenkova8076602017-10-02 17:59:12 +0000191
Anna Zaksc80313b2012-10-15 22:48:21 +0000192def loadResultsFromSingleRun(info, deleteEmpty=True):
George Karpenkova8076602017-10-02 17:59:12 +0000193 """
194 # Load results of the analyzes from a given output folder.
195 # - info is the SingleRunInfo object
196 # - deleteEmpty specifies if the empty plist files should be deleted
197
198 """
Anna Zaksc80313b2012-10-15 22:48:21 +0000199 path = info.path
200 run = AnalysisRun(info)
Jordan Roseb042cc72013-03-23 01:21:26 +0000201
202 if os.path.isfile(path):
203 run.readSingleFile(path, deleteEmpty)
204 else:
205 for (dirpath, dirnames, filenames) in os.walk(path):
206 for f in filenames:
207 if (not f.endswith('plist')):
208 continue
209 p = os.path.join(dirpath, f)
210 run.readSingleFile(p, deleteEmpty)
211
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000212 return run
213
George Karpenkova8076602017-10-02 17:59:12 +0000214
215def cmpAnalysisDiagnostic(d):
Anna Zaks9b7d7142012-07-16 20:21:42 +0000216 return d.getIssueIdentifier()
Anna Zaksd60367b2012-06-08 01:50:49 +0000217
George Karpenkova8076602017-10-02 17:59:12 +0000218
George Karpenkovb7043222018-02-01 22:25:18 +0000219def compareResults(A, B, opts):
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000220 """
221 compareResults - Generate a relation from diagnostics in run A to
222 diagnostics in run B.
223
George Karpenkovf37c07c2018-02-01 22:40:01 +0000224 The result is the relation as a list of triples (a, b) where
225 each element {a,b} is None or a matching element from the respective run
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000226 """
227
228 res = []
229
George Karpenkovb7043222018-02-01 22:25:18 +0000230 # Map size_before -> size_after
231 path_difference_data = []
232
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000233 # Quickly eliminate equal elements.
234 neqA = []
235 neqB = []
236 eltsA = list(A.diagnostics)
237 eltsB = list(B.diagnostics)
George Karpenkova8076602017-10-02 17:59:12 +0000238 eltsA.sort(key=cmpAnalysisDiagnostic)
239 eltsB.sort(key=cmpAnalysisDiagnostic)
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000240 while eltsA and eltsB:
241 a = eltsA.pop()
242 b = eltsB.pop()
George Karpenkova8076602017-10-02 17:59:12 +0000243 if (a.getIssueIdentifier() == b.getIssueIdentifier()):
George Karpenkovb7043222018-02-01 22:25:18 +0000244 if a.getPathLength() != b.getPathLength():
245 if opts.relative_path_histogram:
246 path_difference_data.append(
247 float(a.getPathLength()) / b.getPathLength())
248 elif opts.relative_log_path_histogram:
249 path_difference_data.append(
250 log(float(a.getPathLength()) / b.getPathLength()))
251 elif opts.absolute_path_histogram:
252 path_difference_data.append(
253 a.getPathLength() - b.getPathLength())
254
George Karpenkovf37c07c2018-02-01 22:40:01 +0000255 res.append((a, b))
Anna Zaks639b4042012-10-17 21:09:26 +0000256 elif a.getIssueIdentifier() > b.getIssueIdentifier():
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000257 eltsB.append(b)
Anna Zaks639b4042012-10-17 21:09:26 +0000258 neqA.append(a)
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000259 else:
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000260 eltsA.append(a)
Anna Zaks639b4042012-10-17 21:09:26 +0000261 neqB.append(b)
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000262 neqA.extend(eltsA)
263 neqB.extend(eltsB)
264
George Karpenkova8076602017-10-02 17:59:12 +0000265 # FIXME: Add fuzzy matching. One simple and possible effective idea would
266 # be to bin the diagnostics, print them in a normalized form (based solely
267 # on the structure of the diagnostic), compute the diff, then use that as
268 # the basis for matching. This has the nice property that we don't depend
269 # in any way on the diagnostic format.
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000270
271 for a in neqA:
George Karpenkovf37c07c2018-02-01 22:40:01 +0000272 res.append((a, None))
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000273 for b in neqB:
George Karpenkovf37c07c2018-02-01 22:40:01 +0000274 res.append((None, b))
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000275
George Karpenkovb7043222018-02-01 22:25:18 +0000276 if opts.relative_log_path_histogram or opts.relative_path_histogram or \
277 opts.absolute_path_histogram:
278 from matplotlib import pyplot
279 pyplot.hist(path_difference_data, bins=100)
280 pyplot.show()
281
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000282 return res
283
Mikhail R. Gadelha8af2e692018-05-28 15:40:39 +0000284def deriveStats(results):
285 # Assume all keys are the same in each statistics bucket.
286 combined_data = defaultdict(list)
287 for stat in results.stats:
288 for key, value in stat.iteritems():
289 combined_data[key].append(value)
290 combined_stats = {}
291 for key, values in combined_data.iteritems():
292 combined_stats[str(key)] = {
293 "max": max(values),
294 "min": min(values),
295 "mean": sum(values) / len(values),
296 "median": sorted(values)[len(values) / 2],
297 "total": sum(values)
298 }
299 return combined_stats
300
301
302def compareStats(resultsA, resultsB):
303 statsA = deriveStats(resultsA)
304 statsB = deriveStats(resultsB)
305 keys = sorted(statsA.keys())
306 for key in keys:
307 print key
308 for kkey in statsA[key]:
309 valA = float(statsA[key][kkey])
310 valB = float(statsB[key][kkey])
311 report = "%.3f -> %.3f" % (valA, valB)
312 # Only apply highlighting when writing to TTY and it's not Windows
313 if sys.stdout.isatty() and os.name != 'nt':
314 if valA != 0:
315 ratio = (valB - valA) / valB
316 if ratio < -0.2:
317 report = Colors.GREEN + report + Colors.CLEAR
318 elif ratio > 0.2:
319 report = Colors.RED + report + Colors.CLEAR
320 print "\t %s %s" % (kkey, report)
George Karpenkova8076602017-10-02 17:59:12 +0000321
George Karpenkovb7120c92018-02-13 23:36:01 +0000322def dumpScanBuildResultsDiff(dirA, dirB, opts, deleteEmpty=True,
323 Stdout=sys.stdout):
Anna Zaksb80d8362011-09-12 21:32:41 +0000324 # Load the run results.
Anna Zaks45a992b2012-08-02 00:41:40 +0000325 resultsA = loadResults(dirA, opts, opts.rootA, deleteEmpty)
326 resultsB = loadResults(dirB, opts, opts.rootB, deleteEmpty)
Mikhail R. Gadelha8af2e692018-05-28 15:40:39 +0000327 if resultsA.stats:
328 compareStats(resultsA, resultsB)
329 if opts.stats_only:
330 return
Ted Kremenek3a0678e2015-09-08 03:50:52 +0000331
Anna Zaksb80d8362011-09-12 21:32:41 +0000332 # Open the verbose log, if given.
333 if opts.verboseLog:
334 auxLog = open(opts.verboseLog, "wb")
335 else:
336 auxLog = None
337
George Karpenkovb7043222018-02-01 22:25:18 +0000338 diff = compareResults(resultsA, resultsB, opts)
Anna Zaks767d3562011-11-08 19:56:31 +0000339 foundDiffs = 0
George Karpenkovdece62a2018-02-01 02:38:42 +0000340 totalAdded = 0
341 totalRemoved = 0
Anna Zaksb80d8362011-09-12 21:32:41 +0000342 for res in diff:
George Karpenkovf37c07c2018-02-01 22:40:01 +0000343 a, b = res
Anna Zaksb80d8362011-09-12 21:32:41 +0000344 if a is None:
George Karpenkovb7120c92018-02-13 23:36:01 +0000345 Stdout.write("ADDED: %r\n" % b.getReadableName())
Anna Zaks767d3562011-11-08 19:56:31 +0000346 foundDiffs += 1
George Karpenkovdece62a2018-02-01 02:38:42 +0000347 totalAdded += 1
Anna Zaksb80d8362011-09-12 21:32:41 +0000348 if auxLog:
George Karpenkovb7120c92018-02-13 23:36:01 +0000349 auxLog.write("('ADDED', %r, %r)\n" % (b.getReadableName(),
350 b.getReport()))
Anna Zaksb80d8362011-09-12 21:32:41 +0000351 elif b is None:
George Karpenkovb7120c92018-02-13 23:36:01 +0000352 Stdout.write("REMOVED: %r\n" % a.getReadableName())
Anna Zaks767d3562011-11-08 19:56:31 +0000353 foundDiffs += 1
George Karpenkovdece62a2018-02-01 02:38:42 +0000354 totalRemoved += 1
Anna Zaksb80d8362011-09-12 21:32:41 +0000355 if auxLog:
George Karpenkovb7120c92018-02-13 23:36:01 +0000356 auxLog.write("('REMOVED', %r, %r)\n" % (a.getReadableName(),
357 a.getReport()))
Anna Zaksb80d8362011-09-12 21:32:41 +0000358 else:
359 pass
360
Anna Zaks767d3562011-11-08 19:56:31 +0000361 TotalReports = len(resultsB.diagnostics)
George Karpenkovb7120c92018-02-13 23:36:01 +0000362 Stdout.write("TOTAL REPORTS: %r\n" % TotalReports)
363 Stdout.write("TOTAL ADDED: %r\n" % totalAdded)
364 Stdout.write("TOTAL REMOVED: %r\n" % totalRemoved)
Anna Zaksb80d8362011-09-12 21:32:41 +0000365 if auxLog:
George Karpenkovb7120c92018-02-13 23:36:01 +0000366 auxLog.write("('TOTAL NEW REPORTS', %r)\n" % TotalReports)
367 auxLog.write("('TOTAL DIFFERENCES', %r)\n" % foundDiffs)
368 auxLog.close()
Ted Kremenek3a0678e2015-09-08 03:50:52 +0000369
Gabor Horvath93fde942015-06-30 15:31:17 +0000370 return foundDiffs, len(resultsA.diagnostics), len(resultsB.diagnostics)
Anna Zaksb80d8362011-09-12 21:32:41 +0000371
George Karpenkovfc782a32018-02-09 18:39:47 +0000372def generate_option_parser():
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000373 parser = OptionParser("usage: %prog [options] [dir A] [dir B]")
Anna Zaks45a992b2012-08-02 00:41:40 +0000374 parser.add_option("", "--rootA", dest="rootA",
375 help="Prefix to ignore on source files for directory A",
376 action="store", type=str, default="")
377 parser.add_option("", "--rootB", dest="rootB",
378 help="Prefix to ignore on source files for directory B",
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000379 action="store", type=str, default="")
380 parser.add_option("", "--verbose-log", dest="verboseLog",
George Karpenkova8076602017-10-02 17:59:12 +0000381 help="Write additional information to LOG \
George Karpenkovfc782a32018-02-09 18:39:47 +0000382 [default=None]",
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000383 action="store", type=str, default=None,
384 metavar="LOG")
George Karpenkovb7043222018-02-01 22:25:18 +0000385 parser.add_option("--relative-path-differences-histogram",
386 action="store_true", dest="relative_path_histogram",
387 default=False,
388 help="Show histogram of relative paths differences. \
George Karpenkovfc782a32018-02-09 18:39:47 +0000389 Requires matplotlib")
George Karpenkovb7043222018-02-01 22:25:18 +0000390 parser.add_option("--relative-log-path-differences-histogram",
391 action="store_true", dest="relative_log_path_histogram",
392 default=False,
393 help="Show histogram of log relative paths differences. \
George Karpenkovfc782a32018-02-09 18:39:47 +0000394 Requires matplotlib")
George Karpenkovb7043222018-02-01 22:25:18 +0000395 parser.add_option("--absolute-path-differences-histogram",
396 action="store_true", dest="absolute_path_histogram",
397 default=False,
398 help="Show histogram of absolute paths differences. \
George Karpenkovfc782a32018-02-09 18:39:47 +0000399 Requires matplotlib")
Mikhail R. Gadelha8af2e692018-05-28 15:40:39 +0000400 parser.add_option("--stats-only", action="store_true", dest="stats_only",
401 default=False, help="Only show statistics on reports")
George Karpenkovfc782a32018-02-09 18:39:47 +0000402 return parser
403
404
405def main():
406 parser = generate_option_parser()
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000407 (opts, args) = parser.parse_args()
408
409 if len(args) != 2:
410 parser.error("invalid number of arguments")
411
George Karpenkova8076602017-10-02 17:59:12 +0000412 dirA, dirB = args
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000413
Ted Kremenek3a0678e2015-09-08 03:50:52 +0000414 dumpScanBuildResultsDiff(dirA, dirB, opts)
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000415
George Karpenkova8076602017-10-02 17:59:12 +0000416
Daniel Dunbar1a9db992009-08-06 21:15:33 +0000417if __name__ == '__main__':
418 main()