Omar Ismail | 95b79e7 | 2024-09-11 09:47:52 -0400 | [diff] [blame] | 1 | #!/usr/bin/env python3 |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 2 | |
| 3 | """ |
| 4 | Parses information about failing tests, and then generates a change to disable them. |
| 5 | |
| 6 | Requires that the `bugged` command-line tool is installed, see go/bugged . |
| 7 | """ |
| 8 | |
| 9 | import argparse, csv, os, subprocess |
| 10 | |
| 11 | parser = argparse.ArgumentParser( |
| 12 | description=__doc__ |
| 13 | ) |
Jeff Gaston | a7c7772 | 2023-11-02 15:15:59 -0400 | [diff] [blame] | 14 | parser.add_argument("-v", help="Verbose", action="store_true") |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 15 | |
| 16 | dirOfThisScript = os.path.dirname(os.path.realpath(__file__)) |
| 17 | supportRoot = os.path.dirname(dirOfThisScript) |
| 18 | |
Jeff Gaston | a7c7772 | 2023-11-02 15:15:59 -0400 | [diff] [blame] | 19 | logger = None |
| 20 | |
| 21 | class PrintLogger(object): |
| 22 | def log(self, message): |
| 23 | print(message) |
| 24 | |
| 25 | class DisabledLogger(object): |
| 26 | def log(self, message): |
| 27 | pass |
| 28 | |
| 29 | def log(message): |
| 30 | logger.log(message) |
| 31 | |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 32 | class LocatedFailure(object): |
Jeff Gaston | a7c7772 | 2023-11-02 15:15:59 -0400 | [diff] [blame] | 33 | def __init__(self, failure, location, bugId): |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 34 | self.failure = failure |
| 35 | self.location = location |
Jeff Gaston | a7c7772 | 2023-11-02 15:15:59 -0400 | [diff] [blame] | 36 | self.bugId = bugId |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 37 | |
| 38 | class TestFailure(object): |
Jeff Gaston | a7c7772 | 2023-11-02 15:15:59 -0400 | [diff] [blame] | 39 | def __init__(self, qualifiedClassName, methodName, testDefinitionName, branchName, testFailureUrl, bugId): |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 40 | self.qualifiedClassName = qualifiedClassName |
| 41 | self.methodName = methodName |
| 42 | self.testDefinitionName = testDefinitionName |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 43 | self.branchName = branchName |
Jeff Gaston | a7c7772 | 2023-11-02 15:15:59 -0400 | [diff] [blame] | 44 | self.failureUrl = testFailureUrl |
| 45 | self.bugId = bugId |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 46 | |
| 47 | def getUrl(self): |
Jeff Gaston | a7c7772 | 2023-11-02 15:15:59 -0400 | [diff] [blame] | 48 | return self.testFailureUrl |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 49 | |
| 50 | class FailuresDatabase(object): |
| 51 | """A collection of LocatedFailure instances, organized by their locations""" |
| 52 | def __init__(self): |
| 53 | self.failuresByPath = {} |
| 54 | |
| 55 | def add(self, locatedFailure): |
| 56 | path = locatedFailure.location.filePath |
| 57 | if path not in self.failuresByPath: |
| 58 | self.failuresByPath[path] = {} |
| 59 | failuresAtPath = self.failuresByPath[path] |
| 60 | |
| 61 | lineNumber = locatedFailure.location.lineNumber |
| 62 | if lineNumber not in failuresAtPath: |
| 63 | failuresAtPath[lineNumber] = locatedFailure |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 64 | |
| 65 | # returns Map<String, LocatedFailure> with key being filePath |
| 66 | def getAll(self): |
| 67 | results = {} |
| 68 | for path, failuresAtPath in self.failuresByPath.items(): |
| 69 | lineNumbers = sorted(failuresAtPath.keys(), reverse=True) |
| 70 | resultsAtPath = [] |
| 71 | # add failures in reverse order to make it easier to modify methods without adjusting line numbers for other methods |
| 72 | for line in lineNumbers: |
| 73 | resultsAtPath.append(failuresAtPath[line]) |
| 74 | results[path] = resultsAtPath |
| 75 | return results |
| 76 | |
Jeff Gaston | a7c7772 | 2023-11-02 15:15:59 -0400 | [diff] [blame] | 77 | def parseBugLine(bugId, line): |
| 78 | components = line.split(" | ") |
| 79 | if len(components) < 3: |
| 80 | return None |
| 81 | testLink = components[1] |
| 82 | # Example test link: [compose-ui-uidebugAndroidTest.xml androidx.compose.ui.window.PopupAlignmentTest#popup_correctPosition_alignmentTopCenter_rtl](https://android-build.googleplex.com/builds/tests/view?testResultId=TR96929024659298098) |
| 83 | closeBracketIndex = testLink.rindex("]") |
| 84 | if closeBracketIndex <= 0: |
| 85 | raise Exception("Failed to parse b/" + bugId + " '" + line + "', testLink '" + testLink + "', closeBracketIndex = " + str(closeBracketIndex)) |
| 86 | linkText = testLink[1:closeBracketIndex] |
| 87 | linkDest = testLink[closeBracketIndex + 1:] |
| 88 | # Example linkText: compose-ui-uidebugAndroidTest.xml androidx.compose.ui.window.PopupAlignmentTest#popup_correctPosition_alignmentTopCenter_rtl |
| 89 | # Example linkDest: (https://android-build.googleplex.com/builds/tests/view?testResultId=TR96929024659298098) |
| 90 | testResultUrl = linkDest.replace("(", "").replace(")", "") |
| 91 | # Example testResultUrl: https://android-build.googleplex.com/builds/tests/view?testResultId=TR96929024659298098 |
| 92 | spaceIndex = linkText.index(" ") |
| 93 | if spaceIndex <= 0: |
| 94 | raise Exception("Failed to parse b/" + bugId + " '" + line + "', linkText = '" + linkText + ", spaceIndex = " + str(spaceIndex)) |
| 95 | testDefinitionName = linkText[:spaceIndex] |
| 96 | testPath = linkText[spaceIndex+1:] |
| 97 | # Example test path: androidx.compose.ui.window.PopupAlignmentTest#popup_correctPosition_alignmentTopCenter_rtl |
| 98 | testPathSplit = testPath.split("#") |
| 99 | if len(testPathSplit) != 2: |
| 100 | raise Exception("Failed to parse b/" + bugId + " '" + line + "', testPath = '" + testPath + "', len(testPathSplit) = " + str(len(testPathSplit))) |
| 101 | testClass, testMethod = testPathSplit |
| 102 | |
| 103 | branchName = components[2].strip() |
| 104 | print(" parsed test failure class=" + testClass + " method='" + testMethod + "' definition=" + testDefinitionName + " branch=" + branchName + " failureUrl=" + testResultUrl + " bugId=" + bugId) |
| 105 | return TestFailure(testClass, testMethod, testDefinitionName, branchName, testResultUrl, bugId) |
| 106 | |
| 107 | def parseBug(bugId): |
| 108 | bugText = shellRunner.runAndGetOutput(["bugged", "show", bugId]) |
| 109 | log("bug text = '" + bugText + "'") |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 110 | failures = [] |
Jeff Gaston | a7c7772 | 2023-11-02 15:15:59 -0400 | [diff] [blame] | 111 | bugLines = bugText.split("\n") |
| 112 | |
| 113 | stillFailing = True |
| 114 | listingTests = False |
| 115 | for i in range(len(bugLines)): |
| 116 | line = bugLines[i] |
| 117 | #log("parsing bug line " + line) |
| 118 | if listingTests: |
| 119 | failure = parseBugLine(bugId, line) |
| 120 | if failure is not None: |
| 121 | failures.append(failure) |
| 122 | if "---|---|---|---|---" in line: # table start |
| 123 | listingTests = True |
| 124 | if " # " in line: # start of new section |
| 125 | listingTests = False |
| 126 | if "There are no more failing tests in this regression" in line or "ATN has not seen a failure for this regression recently." in line or "This regression has been identified as a duplicate of another one" in line: |
| 127 | stillFailing = False |
| 128 | if len(failures) < 1: |
| 129 | raise Exception("Failed to parse b/" + bugId + ": identified 0 failures. Rerun with -v for more information") |
| 130 | if not stillFailing: |
| 131 | print("tests no longer failing") |
| 132 | return [] |
| 133 | return failures |
| 134 | |
| 135 | # identifies failing tests |
| 136 | def getFailureData(): |
| 137 | bugsQuery = ["bugged", "search", "hotlistid:5083126 status:open", "--columns", "issue"] |
| 138 | print("Searching for bugs: " + str(bugsQuery)) |
| 139 | bugsOutput = shellRunner.runAndGetOutput(bugsQuery) |
| 140 | bugIds = bugsOutput.split("\n") |
| 141 | print("Checking " + str(len(bugIds)) + " bugs") |
| 142 | failures = [] |
| 143 | for i in range(len(bugIds)): |
| 144 | bugId = bugIds[i].strip() |
| 145 | if bugId != "issue" and bugId != "": |
| 146 | print("") |
| 147 | print("Parsing bug " + bugId + " (" + str(i) + "/" + str(len(bugIds)) + ")") |
| 148 | failures += parseBug(bugId) |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 149 | return failures |
| 150 | |
| 151 | class FileLocation(object): |
| 152 | def __init__(self, filePath, lineNumber): |
| 153 | self.filePath = filePath |
| 154 | self.lineNumber = lineNumber |
| 155 | |
| 156 | def __str__(self): |
| 157 | return self.filePath + "#" + str(self.lineNumber) |
| 158 | |
| 159 | class ShellRunner(object): |
| 160 | def __init__(self): |
| 161 | return |
| 162 | |
| 163 | def runAndGetOutput(self, args): |
| 164 | result = subprocess.run(args, capture_output=True, text=True).stdout |
| 165 | return result |
| 166 | |
| 167 | def run(self, args): |
| 168 | subprocess.run(args, capture_output=False) |
| 169 | |
| 170 | shellRunner = ShellRunner() |
| 171 | |
| 172 | class FileFinder(object): |
| 173 | def __init__(self, rootPath): |
| 174 | self.rootPath = rootPath |
| 175 | self.resultsCache = {} |
| 176 | |
| 177 | def findIname(self, name): |
| 178 | if name not in self.resultsCache: |
| 179 | text = shellRunner.runAndGetOutput(["find", self.rootPath , "-type", "f", "-iname", name]) |
| 180 | filePaths = [path.strip() for path in text.split("\n")] |
| 181 | filePaths = [path for path in filePaths if path != ""] |
| 182 | self.resultsCache[name] = filePaths |
| 183 | return self.resultsCache[name] |
| 184 | fileFinder = FileFinder(supportRoot) |
| 185 | |
| 186 | class ClassFinder(object): |
| 187 | """Locates the file path and line number for classes and methods""" |
| 188 | def __init__(self): |
| 189 | self.classToFile_cache = {} |
| 190 | self.methodLocations_cache = {} |
| 191 | |
| 192 | def findMethod(self, qualifiedClassName, methodName): |
| 193 | bracketIndex = methodName.find("[") |
| 194 | if bracketIndex >= 0: |
| 195 | methodName = methodName[:bracketIndex] |
| 196 | fullName = qualifiedClassName + "." + methodName |
| 197 | containingFile = self.findFileContainingClass(qualifiedClassName) |
| 198 | if containingFile is None: |
| 199 | return None |
| 200 | if fullName not in self.methodLocations_cache: |
| 201 | index = -1 |
| 202 | foundLineNumber = None |
| 203 | with open(containingFile) as f: |
| 204 | for line in f: |
| 205 | index += 1 |
| 206 | if (" " + methodName + "(") in line: |
| 207 | if foundLineNumber is not None: |
| 208 | # found two matches, can't choose one |
| 209 | foundLineNumber = None |
| 210 | break |
| 211 | foundLineNumber = index |
| 212 | result = None |
| 213 | if foundLineNumber is not None: |
| 214 | result = FileLocation(containingFile, foundLineNumber) |
| 215 | self.methodLocations_cache[fullName] = result |
| 216 | return self.methodLocations_cache[fullName] |
| 217 | |
| 218 | |
| 219 | def findFileContainingClass(self, qualifiedName): |
| 220 | if qualifiedName not in self.classToFile_cache: |
| 221 | lastDotIndex = qualifiedName.rindex(".") |
| 222 | if lastDotIndex >= 0: |
| 223 | packageName = qualifiedName[:lastDotIndex] |
| 224 | className = qualifiedName[lastDotIndex + 1:] |
| 225 | else: |
| 226 | packageName = "" |
| 227 | className = qualifiedName |
| 228 | options = fileFinder.findIname(className + ".*") |
| 229 | possibleContainingFiles = sorted(options) |
| 230 | result = None |
| 231 | for f in possibleContainingFiles: |
| 232 | if self.getPackage(f) == packageName: |
| 233 | result = f |
| 234 | break |
| 235 | self.classToFile_cache[qualifiedName] = result |
| 236 | return self.classToFile_cache[qualifiedName] |
| 237 | |
| 238 | def getPackage(self, filePath): |
| 239 | prefix = "package " |
| 240 | with open(filePath) as f: |
| 241 | for line in f: |
| 242 | line = line.strip() |
| 243 | if line.startswith(prefix): |
| 244 | suffix = line[len(prefix):] |
| 245 | if suffix.endswith(";"): |
| 246 | return suffix[:-1] |
| 247 | return suffix |
| 248 | return None |
| 249 | |
| 250 | classFinder = ClassFinder() |
| 251 | |
| 252 | def readFile(path): |
| 253 | f = open(path) |
| 254 | text = f.read() |
| 255 | f.close() |
| 256 | return text |
| 257 | |
| 258 | def writeFile(path, text): |
| 259 | f = open(path, "w") |
| 260 | f.write(text) |
| 261 | f.close() |
| 262 | |
| 263 | def extractIndent(text): |
| 264 | indentSize = 0 |
| 265 | for c in text: |
| 266 | if c == " ": |
| 267 | indentSize += 1 |
| 268 | else: |
| 269 | break |
| 270 | return " " * indentSize |
| 271 | |
| 272 | class SourceFile(object): |
| 273 | """An in-memory model of a source file (java, kotlin) that can be manipulated and saved""" |
| 274 | def __init__(self, path): |
| 275 | text = readFile(path) |
| 276 | self.lines = text.split("\n") |
| 277 | self.path = path |
| 278 | |
| 279 | def isKotlin(self): |
| 280 | return self.path.endswith(".kt") |
| 281 | |
| 282 | def maybeSemicolon(self): |
| 283 | if self.isKotlin(): |
| 284 | return "" |
| 285 | return ";" |
| 286 | |
| 287 | def addAnnotation(self, methodLineNumber, annotation): |
| 288 | parenIndex = annotation.find("(") |
| 289 | if parenIndex > 0: |
| 290 | baseName = annotation[:parenIndex] |
| 291 | else: |
| 292 | baseName = annotation |
| 293 | if self.findAnnotationLine(methodLineNumber, baseName) is not None: |
| 294 | # already have an annotation, don't need to add another |
| 295 | return |
| 296 | indent = extractIndent(self.lines[methodLineNumber]) |
| 297 | self.insertLine(methodLineNumber, indent + annotation) |
| 298 | |
| 299 | # Adds an import to this file |
| 300 | # Attempts to preserve alphabetical import ordering: |
| 301 | # If two consecutive imports can be found such that one should precede this import and |
| 302 | # one should follow this import, inserts between those two imports |
| 303 | # Otherwise attempts to add this import after the last import or before the first import |
| 304 | # (Note that imports might be grouped into multiple blocks, each separated by a blank line) |
| 305 | def addImport(self, symbolText): |
| 306 | insertText = "import " + symbolText + self.maybeSemicolon() |
| 307 | if insertText in self.lines: |
| 308 | return # already added |
| 309 | # set of lines that the insertion could immediately precede |
| 310 | beforeLineNumbers = set() |
| 311 | # set of lines that the insertion could immediately follow |
| 312 | afterLineNumbers = set() |
| 313 | for i in range(len(self.lines)): |
| 314 | line = self.lines[i] |
| 315 | if line.startswith("import"): |
| 316 | # found an import. Should our import be before or after? |
| 317 | if insertText < line: |
| 318 | beforeLineNumbers.add(i) |
| 319 | else: |
| 320 | afterLineNumbers.add(i) |
| 321 | # search for two adjacent lines that the line can be inserted between |
| 322 | insertionLineNumber = None |
| 323 | for i in range(len(self.lines) - 1): |
| 324 | if i in afterLineNumbers and (i + 1) in beforeLineNumbers: |
| 325 | insertionLineNumber = i + 1 |
| 326 | break |
| 327 | # search for a line we can insert after |
| 328 | if insertionLineNumber is None: |
| 329 | for i in range(len(self.lines) - 1): |
| 330 | if i in afterLineNumbers and (i + 1) not in afterLineNumbers: |
| 331 | insertionLineNumber = i + 1 |
| 332 | break |
| 333 | # search for a line we can insert before |
| 334 | if insertionLineNumber is None: |
| 335 | for i in range(len(self.lines) - 1, 0, -1): |
| 336 | if i in beforeLineNumbers and (i - 1) not in beforeLineNumbers: |
| 337 | insertionLineNumber = i |
| 338 | break |
| 339 | |
| 340 | if insertionLineNumber is not None: |
| 341 | self.insertLine(insertionLineNumber, insertText) |
| 342 | |
| 343 | def insertLine(self, beforeLineNumber, text): |
| 344 | self.lines = self.lines[:beforeLineNumber] + [text] + self.lines[beforeLineNumber:] |
| 345 | |
| 346 | def findAnnotationLine(self, methodLineNumber, annotationText): |
| 347 | lineNumber = methodLineNumber |
| 348 | while True: |
| 349 | if lineNumber < 0: |
| 350 | return None |
| 351 | if annotationText in self.lines[lineNumber]: |
| 352 | return lineNumber |
| 353 | if self.lines[lineNumber].strip() == "": |
| 354 | return None |
| 355 | lineNumber -= 1 |
| 356 | |
| 357 | def removeLine(self, index): |
| 358 | self.lines = self.lines[:index] + self.lines[index + 1:] |
| 359 | |
| 360 | def hasAnnotation(self, methodLineNumber, annotation): |
| 361 | return self.findAnnotationLine(methodLineNumber, annotation) is not None |
| 362 | |
| 363 | def save(self): |
| 364 | writeFile(self.path, "\n".join(self.lines)) |
| 365 | |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 366 | # converts from a List<TestFailure> to a FailuresDatabase containing LocatedFailure |
| 367 | def locate(failures): |
| 368 | db = FailuresDatabase() |
| 369 | for failure in failures: |
| 370 | location = classFinder.findMethod(failure.qualifiedClassName, failure.methodName) |
| 371 | if location is not None: |
Jeff Gaston | a7c7772 | 2023-11-02 15:15:59 -0400 | [diff] [blame] | 372 | db.add(LocatedFailure(failure, location, failure.bugId)) |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 373 | else: |
Jeff Gaston | a7c7772 | 2023-11-02 15:15:59 -0400 | [diff] [blame] | 374 | message = "Could not locate " + str(failure.qualifiedClassName) + "#" + str(failure.methodName) + " for " + str(failure.bugId) |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 375 | if failure.branchName != "aosp-androidx-main": |
| 376 | message += ", should be in " + failure.branchName |
| 377 | print(message) |
| 378 | return db |
| 379 | |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 380 | # Given a FailureDatabase, disables all of the tests mentioned in it, by adding the appropriate |
| 381 | # annotations: |
Jeff Gaston | a7c7772 | 2023-11-02 15:15:59 -0400 | [diff] [blame] | 382 | # failures get annotated with @Ignore , |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 383 | # Annotations link to the associated bug if possible |
| 384 | def disable(failuresDatabase): |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 385 | numUpdates = 0 |
| 386 | failuresByPath = failuresDatabase.getAll() |
| 387 | for path, failuresAtPath in failuresByPath.items(): |
| 388 | source = SourceFile(path) |
| 389 | addedIgnore = False |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 390 | for failure in failuresAtPath: |
| 391 | lineNumber = failure.location.lineNumber |
Jeff Gaston | a7c7772 | 2023-11-02 15:15:59 -0400 | [diff] [blame] | 392 | if source.hasAnnotation(lineNumber, "@Ignore"): |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 393 | continue |
Jeff Gaston | a7c7772 | 2023-11-02 15:15:59 -0400 | [diff] [blame] | 394 | bugId = failure.bugId |
| 395 | bugText = '"b/' + bugId + '"' |
| 396 | source.addAnnotation(lineNumber, "@Ignore(" + bugText + ")") |
| 397 | addedIgnore = True |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 398 | if addedIgnore: |
| 399 | source.addImport("org.junit.Ignore") |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 400 | source.save() |
| 401 | numUpdates += 1 |
Jeff Gaston | a7c7772 | 2023-11-02 15:15:59 -0400 | [diff] [blame] | 402 | print("Made " + str(numUpdates) + " updates") |
| 403 | |
| 404 | def commit(): |
| 405 | print("Generating git commit per OWNERS file") |
| 406 | os.chdir(supportRoot) |
| 407 | commitMessage = """Autogenerated suppression of test failures |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 408 | |
| 409 | This commit was created with the help of development/suppressFailingTests.py |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 410 | """ |
Jeff Gaston | a7c7772 | 2023-11-02 15:15:59 -0400 | [diff] [blame] | 411 | shellRunner.run(["development/split_change_into_owners.sh", commitMessage]) |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 412 | |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 413 | |
| 414 | def main(): |
Jeff Gaston | a7c7772 | 2023-11-02 15:15:59 -0400 | [diff] [blame] | 415 | global logger |
| 416 | arguments = parser.parse_args() |
| 417 | if arguments.v: |
| 418 | logger = PrintLogger() |
| 419 | else: |
| 420 | logger = DisabledLogger() |
| 421 | failures = getFailureData() |
| 422 | if len(failures) < 1: |
| 423 | print("Found 0 failures") |
| 424 | return |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 425 | locations = locate(failures) |
| 426 | disable(locations) |
Jeff Gaston | a7c7772 | 2023-11-02 15:15:59 -0400 | [diff] [blame] | 427 | commit() |
Jeff Gaston | a7df092 | 2021-06-02 17:39:47 -0400 | [diff] [blame] | 428 | |
| 429 | if __name__ == "__main__": |
| 430 | main() |
| 431 | |