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