1# SPDX-License-Identifier: GPL-2.0+ 2# Copyright (c) 2011 The Chromium OS Authors. 3# 4 5import re 6import os 7import subprocess 8import sys 9 10from patman import command 11from patman import settings 12from patman import terminal 13from patman import tools 14 15# True to use --no-decorate - we check this in Setup() 16use_no_decorate = True 17 18def LogCmd(commit_range, git_dir=None, oneline=False, reverse=False, 19 count=None): 20 """Create a command to perform a 'git log' 21 22 Args: 23 commit_range: Range expression to use for log, None for none 24 git_dir: Path to git repository (None to use default) 25 oneline: True to use --oneline, else False 26 reverse: True to reverse the log (--reverse) 27 count: Number of commits to list, or None for no limit 28 Return: 29 List containing command and arguments to run 30 """ 31 cmd = ['git'] 32 if git_dir: 33 cmd += ['--git-dir', git_dir] 34 cmd += ['--no-pager', 'log', '--no-color'] 35 if oneline: 36 cmd.append('--oneline') 37 if use_no_decorate: 38 cmd.append('--no-decorate') 39 if reverse: 40 cmd.append('--reverse') 41 if count is not None: 42 cmd.append('-n%d' % count) 43 if commit_range: 44 cmd.append(commit_range) 45 46 # Add this in case we have a branch with the same name as a directory. 47 # This avoids messages like this, for example: 48 # fatal: ambiguous argument 'test': both revision and filename 49 cmd.append('--') 50 return cmd 51 52def CountCommitsToBranch(branch): 53 """Returns number of commits between HEAD and the tracking branch. 54 55 This looks back to the tracking branch and works out the number of commits 56 since then. 57 58 Args: 59 branch: Branch to count from (None for current branch) 60 61 Return: 62 Number of patches that exist on top of the branch 63 """ 64 if branch: 65 us, msg = GetUpstream('.git', branch) 66 rev_range = '%s..%s' % (us, branch) 67 else: 68 rev_range = '@{upstream}..' 69 pipe = [LogCmd(rev_range, oneline=True)] 70 result = command.RunPipe(pipe, capture=True, capture_stderr=True, 71 oneline=True, raise_on_error=False) 72 if result.return_code: 73 raise ValueError('Failed to determine upstream: %s' % 74 result.stderr.strip()) 75 patch_count = len(result.stdout.splitlines()) 76 return patch_count 77 78def NameRevision(commit_hash): 79 """Gets the revision name for a commit 80 81 Args: 82 commit_hash: Commit hash to look up 83 84 Return: 85 Name of revision, if any, else None 86 """ 87 pipe = ['git', 'name-rev', commit_hash] 88 stdout = command.RunPipe([pipe], capture=True, oneline=True).stdout 89 90 # We expect a commit, a space, then a revision name 91 name = stdout.split(' ')[1].strip() 92 return name 93 94def GuessUpstream(git_dir, branch): 95 """Tries to guess the upstream for a branch 96 97 This lists out top commits on a branch and tries to find a suitable 98 upstream. It does this by looking for the first commit where 99 'git name-rev' returns a plain branch name, with no ! or ^ modifiers. 100 101 Args: 102 git_dir: Git directory containing repo 103 branch: Name of branch 104 105 Returns: 106 Tuple: 107 Name of upstream branch (e.g. 'upstream/master') or None if none 108 Warning/error message, or None if none 109 """ 110 pipe = [LogCmd(branch, git_dir=git_dir, oneline=True, count=100)] 111 result = command.RunPipe(pipe, capture=True, capture_stderr=True, 112 raise_on_error=False) 113 if result.return_code: 114 return None, "Branch '%s' not found" % branch 115 for line in result.stdout.splitlines()[1:]: 116 commit_hash = line.split(' ')[0] 117 name = NameRevision(commit_hash) 118 if '~' not in name and '^' not in name: 119 if name.startswith('remotes/'): 120 name = name[8:] 121 return name, "Guessing upstream as '%s'" % name 122 return None, "Cannot find a suitable upstream for branch '%s'" % branch 123 124def GetUpstream(git_dir, branch): 125 """Returns the name of the upstream for a branch 126 127 Args: 128 git_dir: Git directory containing repo 129 branch: Name of branch 130 131 Returns: 132 Tuple: 133 Name of upstream branch (e.g. 'upstream/master') or None if none 134 Warning/error message, or None if none 135 """ 136 try: 137 remote = command.OutputOneLine('git', '--git-dir', git_dir, 'config', 138 'branch.%s.remote' % branch) 139 merge = command.OutputOneLine('git', '--git-dir', git_dir, 'config', 140 'branch.%s.merge' % branch) 141 except: 142 upstream, msg = GuessUpstream(git_dir, branch) 143 return upstream, msg 144 145 if remote == '.': 146 return merge, None 147 elif remote and merge: 148 leaf = merge.split('/')[-1] 149 return '%s/%s' % (remote, leaf), None 150 else: 151 raise ValueError("Cannot determine upstream branch for branch " 152 "'%s' remote='%s', merge='%s'" % (branch, remote, merge)) 153 154 155def GetRangeInBranch(git_dir, branch, include_upstream=False): 156 """Returns an expression for the commits in the given branch. 157 158 Args: 159 git_dir: Directory containing git repo 160 branch: Name of branch 161 Return: 162 Expression in the form 'upstream..branch' which can be used to 163 access the commits. If the branch does not exist, returns None. 164 """ 165 upstream, msg = GetUpstream(git_dir, branch) 166 if not upstream: 167 return None, msg 168 rstr = '%s%s..%s' % (upstream, '~' if include_upstream else '', branch) 169 return rstr, msg 170 171def CountCommitsInRange(git_dir, range_expr): 172 """Returns the number of commits in the given range. 173 174 Args: 175 git_dir: Directory containing git repo 176 range_expr: Range to check 177 Return: 178 Number of patches that exist in the supplied range or None if none 179 were found 180 """ 181 pipe = [LogCmd(range_expr, git_dir=git_dir, oneline=True)] 182 result = command.RunPipe(pipe, capture=True, capture_stderr=True, 183 raise_on_error=False) 184 if result.return_code: 185 return None, "Range '%s' not found or is invalid" % range_expr 186 patch_count = len(result.stdout.splitlines()) 187 return patch_count, None 188 189def CountCommitsInBranch(git_dir, branch, include_upstream=False): 190 """Returns the number of commits in the given branch. 191 192 Args: 193 git_dir: Directory containing git repo 194 branch: Name of branch 195 Return: 196 Number of patches that exist on top of the branch, or None if the 197 branch does not exist. 198 """ 199 range_expr, msg = GetRangeInBranch(git_dir, branch, include_upstream) 200 if not range_expr: 201 return None, msg 202 return CountCommitsInRange(git_dir, range_expr) 203 204def CountCommits(commit_range): 205 """Returns the number of commits in the given range. 206 207 Args: 208 commit_range: Range of commits to count (e.g. 'HEAD..base') 209 Return: 210 Number of patches that exist on top of the branch 211 """ 212 pipe = [LogCmd(commit_range, oneline=True), 213 ['wc', '-l']] 214 stdout = command.RunPipe(pipe, capture=True, oneline=True).stdout 215 patch_count = int(stdout) 216 return patch_count 217 218def Checkout(commit_hash, git_dir=None, work_tree=None, force=False): 219 """Checkout the selected commit for this build 220 221 Args: 222 commit_hash: Commit hash to check out 223 """ 224 pipe = ['git'] 225 if git_dir: 226 pipe.extend(['--git-dir', git_dir]) 227 if work_tree: 228 pipe.extend(['--work-tree', work_tree]) 229 pipe.append('checkout') 230 if force: 231 pipe.append('-f') 232 pipe.append(commit_hash) 233 result = command.RunPipe([pipe], capture=True, raise_on_error=False, 234 capture_stderr=True) 235 if result.return_code != 0: 236 raise OSError('git checkout (%s): %s' % (pipe, result.stderr)) 237 238def Clone(git_dir, output_dir): 239 """Checkout the selected commit for this build 240 241 Args: 242 commit_hash: Commit hash to check out 243 """ 244 pipe = ['git', 'clone', git_dir, '.'] 245 result = command.RunPipe([pipe], capture=True, cwd=output_dir, 246 capture_stderr=True) 247 if result.return_code != 0: 248 raise OSError('git clone: %s' % result.stderr) 249 250def Fetch(git_dir=None, work_tree=None): 251 """Fetch from the origin repo 252 253 Args: 254 commit_hash: Commit hash to check out 255 """ 256 pipe = ['git'] 257 if git_dir: 258 pipe.extend(['--git-dir', git_dir]) 259 if work_tree: 260 pipe.extend(['--work-tree', work_tree]) 261 pipe.append('fetch') 262 result = command.RunPipe([pipe], capture=True, capture_stderr=True) 263 if result.return_code != 0: 264 raise OSError('git fetch: %s' % result.stderr) 265 266def CheckWorktreeIsAvailable(git_dir): 267 """Check if git-worktree functionality is available 268 269 Args: 270 git_dir: The repository to test in 271 272 Returns: 273 True if git-worktree commands will work, False otherwise. 274 """ 275 pipe = ['git', '--git-dir', git_dir, 'worktree', 'list'] 276 result = command.RunPipe([pipe], capture=True, capture_stderr=True, 277 raise_on_error=False) 278 return result.return_code == 0 279 280def AddWorktree(git_dir, output_dir, commit_hash=None): 281 """Create and checkout a new git worktree for this build 282 283 Args: 284 git_dir: The repository to checkout the worktree from 285 output_dir: Path for the new worktree 286 commit_hash: Commit hash to checkout 287 """ 288 # We need to pass --detach to avoid creating a new branch 289 pipe = ['git', '--git-dir', git_dir, 'worktree', 'add', '.', '--detach'] 290 if commit_hash: 291 pipe.append(commit_hash) 292 result = command.RunPipe([pipe], capture=True, cwd=output_dir, 293 capture_stderr=True) 294 if result.return_code != 0: 295 raise OSError('git worktree add: %s' % result.stderr) 296 297def PruneWorktrees(git_dir): 298 """Remove administrative files for deleted worktrees 299 300 Args: 301 git_dir: The repository whose deleted worktrees should be pruned 302 """ 303 pipe = ['git', '--git-dir', git_dir, 'worktree', 'prune'] 304 result = command.RunPipe([pipe], capture=True, capture_stderr=True) 305 if result.return_code != 0: 306 raise OSError('git worktree prune: %s' % result.stderr) 307 308def CreatePatches(branch, start, count, ignore_binary, series, signoff = True): 309 """Create a series of patches from the top of the current branch. 310 311 The patch files are written to the current directory using 312 git format-patch. 313 314 Args: 315 branch: Branch to create patches from (None for current branch) 316 start: Commit to start from: 0=HEAD, 1=next one, etc. 317 count: number of commits to include 318 ignore_binary: Don't generate patches for binary files 319 series: Series object for this series (set of patches) 320 Return: 321 Filename of cover letter (None if none) 322 List of filenames of patch files 323 """ 324 if series.get('version'): 325 version = '%s ' % series['version'] 326 cmd = ['git', 'format-patch', '-M' ] 327 if signoff: 328 cmd.append('--signoff') 329 if ignore_binary: 330 cmd.append('--no-binary') 331 if series.get('cover'): 332 cmd.append('--cover-letter') 333 prefix = series.GetPatchPrefix() 334 if prefix: 335 cmd += ['--subject-prefix=%s' % prefix] 336 brname = branch or 'HEAD' 337 cmd += ['%s~%d..%s~%d' % (brname, start + count, brname, start)] 338 339 stdout = command.RunList(cmd) 340 files = stdout.splitlines() 341 342 # We have an extra file if there is a cover letter 343 if series.get('cover'): 344 return files[0], files[1:] 345 else: 346 return None, files 347 348def BuildEmailList(in_list, tag=None, alias=None, raise_on_error=True): 349 """Build a list of email addresses based on an input list. 350 351 Takes a list of email addresses and aliases, and turns this into a list 352 of only email address, by resolving any aliases that are present. 353 354 If the tag is given, then each email address is prepended with this 355 tag and a space. If the tag starts with a minus sign (indicating a 356 command line parameter) then the email address is quoted. 357 358 Args: 359 in_list: List of aliases/email addresses 360 tag: Text to put before each address 361 alias: Alias dictionary 362 raise_on_error: True to raise an error when an alias fails to match, 363 False to just print a message. 364 365 Returns: 366 List of email addresses 367 368 >>> alias = {} 369 >>> alias['fred'] = ['f.bloggs@napier.co.nz'] 370 >>> alias['john'] = ['j.bloggs@napier.co.nz'] 371 >>> alias['mary'] = ['Mary Poppins <m.poppins@cloud.net>'] 372 >>> alias['boys'] = ['fred', ' john'] 373 >>> alias['all'] = ['fred ', 'john', ' mary '] 374 >>> BuildEmailList(['john', 'mary'], None, alias) 375 ['j.bloggs@napier.co.nz', 'Mary Poppins <m.poppins@cloud.net>'] 376 >>> BuildEmailList(['john', 'mary'], '--to', alias) 377 ['--to "j.bloggs@napier.co.nz"', \ 378'--to "Mary Poppins <m.poppins@cloud.net>"'] 379 >>> BuildEmailList(['john', 'mary'], 'Cc', alias) 380 ['Cc j.bloggs@napier.co.nz', 'Cc Mary Poppins <m.poppins@cloud.net>'] 381 """ 382 quote = '"' if tag and tag[0] == '-' else '' 383 raw = [] 384 for item in in_list: 385 raw += LookupEmail(item, alias, raise_on_error=raise_on_error) 386 result = [] 387 for item in raw: 388 if not item in result: 389 result.append(item) 390 if tag: 391 return ['%s %s%s%s' % (tag, quote, email, quote) for email in result] 392 return result 393 394def CheckSuppressCCConfig(): 395 """Check if sendemail.suppresscc is configured correctly. 396 397 Returns: 398 True if the option is configured correctly, False otherwise. 399 """ 400 suppresscc = command.OutputOneLine('git', 'config', 'sendemail.suppresscc', 401 raise_on_error=False) 402 403 # Other settings should be fine. 404 if suppresscc == 'all' or suppresscc == 'cccmd': 405 col = terminal.Color() 406 407 print((col.Color(col.RED, "error") + 408 ": git config sendemail.suppresscc set to %s\n" % (suppresscc)) + 409 " patman needs --cc-cmd to be run to set the cc list.\n" + 410 " Please run:\n" + 411 " git config --unset sendemail.suppresscc\n" + 412 " Or read the man page:\n" + 413 " git send-email --help\n" + 414 " and set an option that runs --cc-cmd\n") 415 return False 416 417 return True 418 419def EmailPatches(series, cover_fname, args, dry_run, raise_on_error, cc_fname, 420 self_only=False, alias=None, in_reply_to=None, thread=False, 421 smtp_server=None): 422 """Email a patch series. 423 424 Args: 425 series: Series object containing destination info 426 cover_fname: filename of cover letter 427 args: list of filenames of patch files 428 dry_run: Just return the command that would be run 429 raise_on_error: True to raise an error when an alias fails to match, 430 False to just print a message. 431 cc_fname: Filename of Cc file for per-commit Cc 432 self_only: True to just email to yourself as a test 433 in_reply_to: If set we'll pass this to git as --in-reply-to. 434 Should be a message ID that this is in reply to. 435 thread: True to add --thread to git send-email (make 436 all patches reply to cover-letter or first patch in series) 437 smtp_server: SMTP server to use to send patches 438 439 Returns: 440 Git command that was/would be run 441 442 # For the duration of this doctest pretend that we ran patman with ./patman 443 >>> _old_argv0 = sys.argv[0] 444 >>> sys.argv[0] = './patman' 445 446 >>> alias = {} 447 >>> alias['fred'] = ['f.bloggs@napier.co.nz'] 448 >>> alias['john'] = ['j.bloggs@napier.co.nz'] 449 >>> alias['mary'] = ['m.poppins@cloud.net'] 450 >>> alias['boys'] = ['fred', ' john'] 451 >>> alias['all'] = ['fred ', 'john', ' mary '] 452 >>> alias[os.getenv('USER')] = ['this-is-me@me.com'] 453 >>> series = {} 454 >>> series['to'] = ['fred'] 455 >>> series['cc'] = ['mary'] 456 >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \ 457 False, alias) 458 'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \ 459"m.poppins@cloud.net" --cc-cmd "./patman send --cc-cmd cc-fname" cover p1 p2' 460 >>> EmailPatches(series, None, ['p1'], True, True, 'cc-fname', False, \ 461 alias) 462 'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \ 463"m.poppins@cloud.net" --cc-cmd "./patman send --cc-cmd cc-fname" p1' 464 >>> series['cc'] = ['all'] 465 >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \ 466 True, alias) 467 'git send-email --annotate --to "this-is-me@me.com" --cc-cmd "./patman \ 468send --cc-cmd cc-fname" cover p1 p2' 469 >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \ 470 False, alias) 471 'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \ 472"f.bloggs@napier.co.nz" --cc "j.bloggs@napier.co.nz" --cc \ 473"m.poppins@cloud.net" --cc-cmd "./patman send --cc-cmd cc-fname" cover p1 p2' 474 475 # Restore argv[0] since we clobbered it. 476 >>> sys.argv[0] = _old_argv0 477 """ 478 to = BuildEmailList(series.get('to'), '--to', alias, raise_on_error) 479 if not to: 480 git_config_to = command.Output('git', 'config', 'sendemail.to', 481 raise_on_error=False) 482 if not git_config_to: 483 print("No recipient.\n" 484 "Please add something like this to a commit\n" 485 "Series-to: Fred Bloggs <f.blogs@napier.co.nz>\n" 486 "Or do something like this\n" 487 "git config sendemail.to u-boot@lists.denx.de") 488 return 489 cc = BuildEmailList(list(set(series.get('cc')) - set(series.get('to'))), 490 '--cc', alias, raise_on_error) 491 if self_only: 492 to = BuildEmailList([os.getenv('USER')], '--to', alias, raise_on_error) 493 cc = [] 494 cmd = ['git', 'send-email', '--annotate'] 495 if smtp_server: 496 cmd.append('--smtp-server=%s' % smtp_server) 497 if in_reply_to: 498 cmd.append('--in-reply-to="%s"' % in_reply_to) 499 if thread: 500 cmd.append('--thread') 501 502 cmd += to 503 cmd += cc 504 cmd += ['--cc-cmd', '"%s send --cc-cmd %s"' % (sys.argv[0], cc_fname)] 505 if cover_fname: 506 cmd.append(cover_fname) 507 cmd += args 508 cmdstr = ' '.join(cmd) 509 if not dry_run: 510 os.system(cmdstr) 511 return cmdstr 512 513 514def LookupEmail(lookup_name, alias=None, raise_on_error=True, level=0): 515 """If an email address is an alias, look it up and return the full name 516 517 TODO: Why not just use git's own alias feature? 518 519 Args: 520 lookup_name: Alias or email address to look up 521 alias: Dictionary containing aliases (None to use settings default) 522 raise_on_error: True to raise an error when an alias fails to match, 523 False to just print a message. 524 525 Returns: 526 tuple: 527 list containing a list of email addresses 528 529 Raises: 530 OSError if a recursive alias reference was found 531 ValueError if an alias was not found 532 533 >>> alias = {} 534 >>> alias['fred'] = ['f.bloggs@napier.co.nz'] 535 >>> alias['john'] = ['j.bloggs@napier.co.nz'] 536 >>> alias['mary'] = ['m.poppins@cloud.net'] 537 >>> alias['boys'] = ['fred', ' john', 'f.bloggs@napier.co.nz'] 538 >>> alias['all'] = ['fred ', 'john', ' mary '] 539 >>> alias['loop'] = ['other', 'john', ' mary '] 540 >>> alias['other'] = ['loop', 'john', ' mary '] 541 >>> LookupEmail('mary', alias) 542 ['m.poppins@cloud.net'] 543 >>> LookupEmail('arthur.wellesley@howe.ro.uk', alias) 544 ['arthur.wellesley@howe.ro.uk'] 545 >>> LookupEmail('boys', alias) 546 ['f.bloggs@napier.co.nz', 'j.bloggs@napier.co.nz'] 547 >>> LookupEmail('all', alias) 548 ['f.bloggs@napier.co.nz', 'j.bloggs@napier.co.nz', 'm.poppins@cloud.net'] 549 >>> LookupEmail('odd', alias) 550 Traceback (most recent call last): 551 ... 552 ValueError: Alias 'odd' not found 553 >>> LookupEmail('loop', alias) 554 Traceback (most recent call last): 555 ... 556 OSError: Recursive email alias at 'other' 557 >>> LookupEmail('odd', alias, raise_on_error=False) 558 Alias 'odd' not found 559 [] 560 >>> # In this case the loop part will effectively be ignored. 561 >>> LookupEmail('loop', alias, raise_on_error=False) 562 Recursive email alias at 'other' 563 Recursive email alias at 'john' 564 Recursive email alias at 'mary' 565 ['j.bloggs@napier.co.nz', 'm.poppins@cloud.net'] 566 """ 567 if not alias: 568 alias = settings.alias 569 lookup_name = lookup_name.strip() 570 if '@' in lookup_name: # Perhaps a real email address 571 return [lookup_name] 572 573 lookup_name = lookup_name.lower() 574 col = terminal.Color() 575 576 out_list = [] 577 if level > 10: 578 msg = "Recursive email alias at '%s'" % lookup_name 579 if raise_on_error: 580 raise OSError(msg) 581 else: 582 print(col.Color(col.RED, msg)) 583 return out_list 584 585 if lookup_name: 586 if not lookup_name in alias: 587 msg = "Alias '%s' not found" % lookup_name 588 if raise_on_error: 589 raise ValueError(msg) 590 else: 591 print(col.Color(col.RED, msg)) 592 return out_list 593 for item in alias[lookup_name]: 594 todo = LookupEmail(item, alias, raise_on_error, level + 1) 595 for new_item in todo: 596 if not new_item in out_list: 597 out_list.append(new_item) 598 599 #print("No match for alias '%s'" % lookup_name) 600 return out_list 601 602def GetTopLevel(): 603 """Return name of top-level directory for this git repo. 604 605 Returns: 606 Full path to git top-level directory 607 608 This test makes sure that we are running tests in the right subdir 609 610 >>> os.path.realpath(os.path.dirname(__file__)) == \ 611 os.path.join(GetTopLevel(), 'tools', 'patman') 612 True 613 """ 614 return command.OutputOneLine('git', 'rev-parse', '--show-toplevel') 615 616def GetAliasFile(): 617 """Gets the name of the git alias file. 618 619 Returns: 620 Filename of git alias file, or None if none 621 """ 622 fname = command.OutputOneLine('git', 'config', 'sendemail.aliasesfile', 623 raise_on_error=False) 624 if fname: 625 fname = os.path.join(GetTopLevel(), fname.strip()) 626 return fname 627 628def GetDefaultUserName(): 629 """Gets the user.name from .gitconfig file. 630 631 Returns: 632 User name found in .gitconfig file, or None if none 633 """ 634 uname = command.OutputOneLine('git', 'config', '--global', 'user.name') 635 return uname 636 637def GetDefaultUserEmail(): 638 """Gets the user.email from the global .gitconfig file. 639 640 Returns: 641 User's email found in .gitconfig file, or None if none 642 """ 643 uemail = command.OutputOneLine('git', 'config', '--global', 'user.email') 644 return uemail 645 646def GetDefaultSubjectPrefix(): 647 """Gets the format.subjectprefix from local .git/config file. 648 649 Returns: 650 Subject prefix found in local .git/config file, or None if none 651 """ 652 sub_prefix = command.OutputOneLine('git', 'config', 'format.subjectprefix', 653 raise_on_error=False) 654 655 return sub_prefix 656 657def Setup(): 658 """Set up git utils, by reading the alias files.""" 659 # Check for a git alias file also 660 global use_no_decorate 661 662 alias_fname = GetAliasFile() 663 if alias_fname: 664 settings.ReadGitAliases(alias_fname) 665 cmd = LogCmd(None, count=0) 666 use_no_decorate = (command.RunPipe([cmd], raise_on_error=False) 667 .return_code == 0) 668 669def GetHead(): 670 """Get the hash of the current HEAD 671 672 Returns: 673 Hash of HEAD 674 """ 675 return command.OutputOneLine('git', 'show', '-s', '--pretty=format:%H') 676 677if __name__ == "__main__": 678 import doctest 679 680 doctest.testmod() 681