1#!/usr/bin/env python3 2# 3# ===- git-clang-format - ClangFormat Git Integration ---------*- python -*--===# 4# 5# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions. 6# See https://llvm.org/LICENSE.txt for license information. 7# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception 8# 9# ===------------------------------------------------------------------------===# 10 11r""" 12clang-format git integration 13============================ 14 15This file provides a clang-format integration for git. Put it somewhere in your 16path and ensure that it is executable. Then, "git clang-format" will invoke 17clang-format on the changes in current files or a specific commit. 18 19For further details, run: 20git clang-format -h 21 22Requires Python 2.7 or Python 3 23""" 24 25from __future__ import absolute_import, division, print_function 26import argparse 27import collections 28import contextlib 29import errno 30import os 31import re 32import subprocess 33import sys 34 35usage = "git clang-format [OPTIONS] [<commit>] [<commit>|--staged] " "[--] [<file>...]" 36 37desc = """ 38If zero or one commits are given, run clang-format on all lines that differ 39between the working directory and <commit>, which defaults to HEAD. Changes are 40only applied to the working directory, or in the stage/index. 41 42Examples: 43 To format staged changes, i.e everything that's been `git add`ed: 44 git clang-format 45 46 To also format everything touched in the most recent commit: 47 git clang-format HEAD~1 48 49 If you're on a branch off main, to format everything touched on your branch: 50 git clang-format main 51 52If two commits are given (requires --diff), run clang-format on all lines in the 53second <commit> that differ from the first <commit>. 54 55The following git-config settings set the default of the corresponding option: 56 clangFormat.binary 57 clangFormat.commit 58 clangFormat.extensions 59 clangFormat.style 60""" 61 62# Name of the temporary index file in which save the output of clang-format. 63# This file is created within the .git directory. 64temp_index_basename = "clang-format-index" 65 66 67Range = collections.namedtuple("Range", "start, count") 68 69 70def main(): 71 config = load_git_config() 72 73 # In order to keep '--' yet allow options after positionals, we need to 74 # check for '--' ourselves. (Setting nargs='*' throws away the '--', while 75 # nargs=argparse.REMAINDER disallows options after positionals.) 76 argv = sys.argv[1:] 77 try: 78 idx = argv.index("--") 79 except ValueError: 80 dash_dash = [] 81 else: 82 dash_dash = argv[idx:] 83 argv = argv[:idx] 84 85 default_extensions = ",".join( 86 [ 87 # From clang/lib/Frontend/FrontendOptions.cpp, all lower case 88 "c", 89 "h", # C 90 "m", # ObjC 91 "mm", # ObjC++ 92 "cc", 93 "cp", 94 "cpp", 95 "c++", 96 "cxx", 97 "hh", 98 "hpp", 99 "hxx", 100 "inc", # C++ 101 "ccm", 102 "cppm", 103 "cxxm", 104 "c++m", # C++ Modules 105 "cu", 106 "cuh", # CUDA 107 # Other languages that clang-format supports 108 "proto", 109 "protodevel", # Protocol Buffers 110 "java", # Java 111 "js", 112 "mjs", 113 "cjs", # JavaScript 114 "ts", # TypeScript 115 "cs", # C Sharp 116 "json", # Json 117 "sv", 118 "svh", 119 "v", 120 "vh", # Verilog 121 "td", # TableGen 122 "txtpb", 123 "textpb", 124 "pb.txt", 125 "textproto", 126 "asciipb", # TextProto 127 ] 128 ) 129 130 p = argparse.ArgumentParser( 131 usage=usage, 132 formatter_class=argparse.RawDescriptionHelpFormatter, 133 description=desc, 134 ) 135 p.add_argument( 136 "--binary", 137 default=config.get("clangformat.binary", "clang-format"), 138 help="path to clang-format", 139 ), 140 p.add_argument( 141 "--commit", 142 default=config.get("clangformat.commit", "HEAD"), 143 help="default commit to use if none is specified", 144 ), 145 p.add_argument( 146 "--diff", 147 action="store_true", 148 help="print a diff instead of applying the changes", 149 ) 150 p.add_argument( 151 "--diffstat", 152 action="store_true", 153 help="print a diffstat instead of applying the changes", 154 ) 155 p.add_argument( 156 "--extensions", 157 default=config.get("clangformat.extensions", default_extensions), 158 help=( 159 "comma-separated list of file extensions to format, " 160 "excluding the period and case-insensitive" 161 ), 162 ), 163 p.add_argument( 164 "-f", "--force", action="store_true", help="allow changes to unstaged files" 165 ) 166 p.add_argument( 167 "-p", "--patch", action="store_true", help="select hunks interactively" 168 ) 169 p.add_argument( 170 "-q", "--quiet", action="count", default=0, help="print less information" 171 ) 172 p.add_argument( 173 "--staged", 174 "--cached", 175 action="store_true", 176 help="format lines in the stage instead of the working dir", 177 ) 178 p.add_argument( 179 "--style", 180 default=config.get("clangformat.style", None), 181 help="passed to clang-format", 182 ), 183 p.add_argument( 184 "-v", "--verbose", action="count", default=0, help="print extra information" 185 ) 186 p.add_argument( 187 "--diff_from_common_commit", 188 action="store_true", 189 help=( 190 "diff from the last common commit for commits in " 191 "separate branches rather than the exact point of the " 192 "commits" 193 ), 194 ) 195 # We gather all the remaining positional arguments into 'args' since we need 196 # to use some heuristics to determine whether or not <commit> was present. 197 # However, to print pretty messages, we make use of metavar and help. 198 p.add_argument( 199 "args", 200 nargs="*", 201 metavar="<commit>", 202 help="revision from which to compute the diff", 203 ) 204 p.add_argument( 205 "ignored", 206 nargs="*", 207 metavar="<file>...", 208 help="if specified, only consider differences in these files", 209 ) 210 opts = p.parse_args(argv) 211 212 opts.verbose -= opts.quiet 213 del opts.quiet 214 215 commits, files = interpret_args(opts.args, dash_dash, opts.commit) 216 if len(commits) > 2: 217 die("at most two commits allowed; %d given" % len(commits)) 218 if len(commits) == 2: 219 if opts.staged: 220 die("--staged is not allowed when two commits are given") 221 if not opts.diff: 222 die("--diff is required when two commits are given") 223 elif opts.diff_from_common_commit: 224 die("--diff_from_common_commit is only allowed when two commits are given") 225 226 if os.path.dirname(opts.binary): 227 opts.binary = os.path.abspath(opts.binary) 228 229 changed_lines = compute_diff_and_extract_lines( 230 commits, files, opts.staged, opts.diff_from_common_commit 231 ) 232 if opts.verbose >= 1: 233 ignored_files = set(changed_lines) 234 filter_by_extension(changed_lines, opts.extensions.lower().split(",")) 235 # The computed diff outputs absolute paths, so we must cd before accessing 236 # those files. 237 cd_to_toplevel() 238 filter_symlinks(changed_lines) 239 filter_ignored_files(changed_lines, binary=opts.binary) 240 if opts.verbose >= 1: 241 ignored_files.difference_update(changed_lines) 242 if ignored_files: 243 print( 244 "Ignoring the following files (wrong extension, symlink, or " 245 "ignored by clang-format):" 246 ) 247 for filename in ignored_files: 248 print(" %s" % filename) 249 if changed_lines: 250 print("Running clang-format on the following files:") 251 for filename in changed_lines: 252 print(" %s" % filename) 253 254 if not changed_lines: 255 if opts.verbose >= 0: 256 print("no modified files to format") 257 return 0 258 259 if len(commits) > 1: 260 old_tree = commits[1] 261 revision = old_tree 262 elif opts.staged: 263 old_tree = create_tree_from_index(changed_lines) 264 revision = "" 265 else: 266 old_tree = create_tree_from_workdir(changed_lines) 267 revision = None 268 new_tree = run_clang_format_and_save_to_tree( 269 changed_lines, revision, binary=opts.binary, style=opts.style 270 ) 271 if opts.verbose >= 1: 272 print("old tree: %s" % old_tree) 273 print("new tree: %s" % new_tree) 274 275 if old_tree == new_tree: 276 if opts.verbose >= 0: 277 print("clang-format did not modify any files") 278 return 0 279 280 if opts.diff: 281 return print_diff(old_tree, new_tree) 282 if opts.diffstat: 283 return print_diffstat(old_tree, new_tree) 284 285 changed_files = apply_changes( 286 old_tree, new_tree, force=opts.force, patch_mode=opts.patch 287 ) 288 if (opts.verbose >= 0 and not opts.patch) or opts.verbose >= 1: 289 print("changed files:") 290 for filename in changed_files: 291 print(" %s" % filename) 292 293 return 1 294 295 296def load_git_config(non_string_options=None): 297 """Return the git configuration as a dictionary. 298 299 All options are assumed to be strings unless in `non_string_options`, in which 300 is a dictionary mapping option name (in lower case) to either "--bool" or 301 "--int".""" 302 if non_string_options is None: 303 non_string_options = {} 304 out = {} 305 for entry in run("git", "config", "--list", "--null").split("\0"): 306 if entry: 307 if "\n" in entry: 308 name, value = entry.split("\n", 1) 309 else: 310 # A setting with no '=' ('\n' with --null) is implicitly 'true' 311 name = entry 312 value = "true" 313 if name in non_string_options: 314 value = run("git", "config", non_string_options[name], name) 315 out[name] = value 316 return out 317 318 319def interpret_args(args, dash_dash, default_commit): 320 """Interpret `args` as "[commits] [--] [files]" and return (commits, files). 321 322 It is assumed that "--" and everything that follows has been removed from 323 args and placed in `dash_dash`. 324 325 If "--" is present (i.e., `dash_dash` is non-empty), the arguments to its 326 left (if present) are taken as commits. Otherwise, the arguments are checked 327 from left to right if they are commits or files. If commits are not given, 328 a list with `default_commit` is used.""" 329 if dash_dash: 330 if len(args) == 0: 331 commits = [default_commit] 332 else: 333 commits = args 334 for commit in commits: 335 object_type = get_object_type(commit) 336 if object_type not in ("commit", "tag"): 337 if object_type is None: 338 die("'%s' is not a commit" % commit) 339 else: 340 die( 341 "'%s' is a %s, but a commit was expected" 342 % (commit, object_type) 343 ) 344 files = dash_dash[1:] 345 elif args: 346 commits = [] 347 while args: 348 if not disambiguate_revision(args[0]): 349 break 350 commits.append(args.pop(0)) 351 if not commits: 352 commits = [default_commit] 353 files = args 354 else: 355 commits = [default_commit] 356 files = [] 357 return commits, files 358 359 360def disambiguate_revision(value): 361 """Returns True if `value` is a revision, False if it is a file, or dies.""" 362 # If `value` is ambiguous (neither a commit nor a file), the following 363 # command will die with an appropriate error message. 364 run("git", "rev-parse", value, verbose=False) 365 object_type = get_object_type(value) 366 if object_type is None: 367 return False 368 if object_type in ("commit", "tag"): 369 return True 370 die("`%s` is a %s, but a commit or filename was expected" % (value, object_type)) 371 372 373def get_object_type(value): 374 """Returns a string description of an object's type, or None if it is not 375 a valid git object.""" 376 cmd = ["git", "cat-file", "-t", value] 377 p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 378 stdout, stderr = p.communicate() 379 if p.returncode != 0: 380 return None 381 return convert_string(stdout.strip()) 382 383 384def compute_diff_and_extract_lines(commits, files, staged, diff_common_commit): 385 """Calls compute_diff() followed by extract_lines().""" 386 diff_process = compute_diff(commits, files, staged, diff_common_commit) 387 changed_lines = extract_lines(diff_process.stdout) 388 diff_process.stdout.close() 389 diff_process.wait() 390 if diff_process.returncode != 0: 391 # Assume error was already printed to stderr. 392 sys.exit(2) 393 return changed_lines 394 395 396def compute_diff(commits, files, staged, diff_common_commit): 397 """Return a subprocess object producing the diff from `commits`. 398 399 The return value's `stdin` file object will produce a patch with the 400 differences between the working directory (or stage if --staged is used) and 401 the first commit if a single one was specified, or the difference between 402 both specified commits, filtered on `files` (if non-empty). 403 Zero context lines are used in the patch.""" 404 git_tool = "diff-index" 405 extra_args = [] 406 if len(commits) == 2: 407 git_tool = "diff-tree" 408 if diff_common_commit: 409 commits = [f"{commits[0]}...{commits[1]}"] 410 elif staged: 411 extra_args += ["--cached"] 412 413 cmd = ["git", git_tool, "-p", "-U0"] + extra_args + commits + ["--"] 414 cmd.extend(files) 415 p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE) 416 p.stdin.close() 417 return p 418 419 420def extract_lines(patch_file): 421 """Extract the changed lines in `patch_file`. 422 423 The return value is a dictionary mapping filename to a list of (start_line, 424 line_count) pairs. 425 426 The input must have been produced with ``-U0``, meaning unidiff format with 427 zero lines of context. The return value is a dict mapping filename to a 428 list of line `Range`s.""" 429 matches = {} 430 for line in patch_file: 431 line = convert_string(line) 432 match = re.search(r"^\+\+\+\ [^/]+/(.*)", line) 433 if match: 434 filename = match.group(1).rstrip("\r\n\t") 435 match = re.search(r"^@@ -[0-9,]+ \+(\d+)(,(\d+))?", line) 436 if match: 437 start_line = int(match.group(1)) 438 line_count = 1 439 if match.group(3): 440 line_count = int(match.group(3)) 441 if line_count == 0: 442 line_count = 1 443 if start_line == 0: 444 continue 445 matches.setdefault(filename, []).append(Range(start_line, line_count)) 446 return matches 447 448 449def filter_by_extension(dictionary, allowed_extensions): 450 """Delete every key in `dictionary` that doesn't have an allowed extension. 451 452 `allowed_extensions` must be a collection of lowercase file extensions, 453 excluding the period.""" 454 allowed_extensions = frozenset(allowed_extensions) 455 for filename in list(dictionary.keys()): 456 base_ext = filename.rsplit(".", 1) 457 if len(base_ext) == 1 and "" in allowed_extensions: 458 continue 459 if len(base_ext) == 1 or base_ext[1].lower() not in allowed_extensions: 460 del dictionary[filename] 461 462 463def filter_symlinks(dictionary): 464 """Delete every key in `dictionary` that is a symlink.""" 465 for filename in list(dictionary.keys()): 466 if os.path.islink(filename): 467 del dictionary[filename] 468 469 470def filter_ignored_files(dictionary, binary): 471 """Delete every key in `dictionary` that is ignored by clang-format.""" 472 ignored_files = run(binary, "-list-ignored", *dictionary.keys()) 473 if not ignored_files: 474 return 475 ignored_files = ignored_files.split("\n") 476 for filename in ignored_files: 477 del dictionary[filename] 478 479 480def cd_to_toplevel(): 481 """Change to the top level of the git repository.""" 482 toplevel = run("git", "rev-parse", "--show-toplevel") 483 os.chdir(toplevel) 484 485 486def create_tree_from_workdir(filenames): 487 """Create a new git tree with the given files from the working directory. 488 489 Returns the object ID (SHA-1) of the created tree.""" 490 return create_tree(filenames, "--stdin") 491 492 493def create_tree_from_index(filenames): 494 # Copy the environment, because the files have to be read from the original 495 # index. 496 env = os.environ.copy() 497 498 def index_contents_generator(): 499 for filename in filenames: 500 git_ls_files_cmd = ["git", "ls-files", "--stage", "-z", "--", filename] 501 git_ls_files = subprocess.Popen( 502 git_ls_files_cmd, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE 503 ) 504 stdout = git_ls_files.communicate()[0] 505 yield convert_string(stdout.split(b"\0")[0]) 506 507 return create_tree(index_contents_generator(), "--index-info") 508 509 510def run_clang_format_and_save_to_tree( 511 changed_lines, revision=None, binary="clang-format", style=None 512): 513 """Run clang-format on each file and save the result to a git tree. 514 515 Returns the object ID (SHA-1) of the created tree.""" 516 # Copy the environment when formatting the files in the index, because the 517 # files have to be read from the original index. 518 env = os.environ.copy() if revision == "" else None 519 520 def iteritems(container): 521 try: 522 return container.iteritems() # Python 2 523 except AttributeError: 524 return container.items() # Python 3 525 526 def index_info_generator(): 527 for filename, line_ranges in iteritems(changed_lines): 528 if revision is not None: 529 if len(revision) > 0: 530 git_metadata_cmd = [ 531 "git", 532 "ls-tree", 533 "%s:%s" % (revision, os.path.dirname(filename)), 534 os.path.basename(filename), 535 ] 536 else: 537 git_metadata_cmd = ["git", "ls-files", "--stage", "--", filename] 538 git_metadata = subprocess.Popen( 539 git_metadata_cmd, 540 env=env, 541 stdin=subprocess.PIPE, 542 stdout=subprocess.PIPE, 543 ) 544 stdout = git_metadata.communicate()[0] 545 mode = oct(int(stdout.split()[0], 8)) 546 else: 547 mode = oct(os.stat(filename).st_mode) 548 # Adjust python3 octal format so that it matches what git expects 549 if mode.startswith("0o"): 550 mode = "0" + mode[2:] 551 blob_id = clang_format_to_blob( 552 filename, 553 line_ranges, 554 revision=revision, 555 binary=binary, 556 style=style, 557 env=env, 558 ) 559 yield "%s %s\t%s" % (mode, blob_id, filename) 560 561 return create_tree(index_info_generator(), "--index-info") 562 563 564def create_tree(input_lines, mode): 565 """Create a tree object from the given input. 566 567 If mode is '--stdin', it must be a list of filenames. If mode is 568 '--index-info' is must be a list of values suitable for "git update-index 569 --index-info", such as "<mode> <SP> <sha1> <TAB> <filename>". Any other mode 570 is invalid.""" 571 assert mode in ("--stdin", "--index-info") 572 cmd = ["git", "update-index", "--add", "-z", mode] 573 with temporary_index_file(): 574 p = subprocess.Popen(cmd, stdin=subprocess.PIPE) 575 for line in input_lines: 576 p.stdin.write(to_bytes("%s\0" % line)) 577 p.stdin.close() 578 if p.wait() != 0: 579 die("`%s` failed" % " ".join(cmd)) 580 tree_id = run("git", "write-tree") 581 return tree_id 582 583 584def clang_format_to_blob( 585 filename, line_ranges, revision=None, binary="clang-format", style=None, env=None 586): 587 """Run clang-format on the given file and save the result to a git blob. 588 589 Runs on the file in `revision` if not None, or on the file in the working 590 directory if `revision` is None. Revision can be set to an empty string to run 591 clang-format on the file in the index. 592 593 Returns the object ID (SHA-1) of the created blob.""" 594 clang_format_cmd = [binary] 595 if style: 596 clang_format_cmd.extend(["--style=" + style]) 597 clang_format_cmd.extend( 598 [ 599 "--lines=%s:%s" % (start_line, start_line + line_count - 1) 600 for start_line, line_count in line_ranges 601 ] 602 ) 603 if revision is not None: 604 clang_format_cmd.extend(["--assume-filename=" + filename]) 605 git_show_cmd = ["git", "cat-file", "blob", "%s:%s" % (revision, filename)] 606 git_show = subprocess.Popen( 607 git_show_cmd, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE 608 ) 609 git_show.stdin.close() 610 clang_format_stdin = git_show.stdout 611 else: 612 clang_format_cmd.extend([filename]) 613 git_show = None 614 clang_format_stdin = subprocess.PIPE 615 try: 616 clang_format = subprocess.Popen( 617 clang_format_cmd, stdin=clang_format_stdin, stdout=subprocess.PIPE 618 ) 619 if clang_format_stdin == subprocess.PIPE: 620 clang_format_stdin = clang_format.stdin 621 except OSError as e: 622 if e.errno == errno.ENOENT: 623 die('cannot find executable "%s"' % binary) 624 else: 625 raise 626 clang_format_stdin.close() 627 hash_object_cmd = ["git", "hash-object", "-w", "--path=" + filename, "--stdin"] 628 hash_object = subprocess.Popen( 629 hash_object_cmd, stdin=clang_format.stdout, stdout=subprocess.PIPE 630 ) 631 clang_format.stdout.close() 632 stdout = hash_object.communicate()[0] 633 if hash_object.returncode != 0: 634 die("`%s` failed" % " ".join(hash_object_cmd)) 635 if clang_format.wait() != 0: 636 die("`%s` failed" % " ".join(clang_format_cmd)) 637 if git_show and git_show.wait() != 0: 638 die("`%s` failed" % " ".join(git_show_cmd)) 639 return convert_string(stdout).rstrip("\r\n") 640 641 642@contextlib.contextmanager 643def temporary_index_file(tree=None): 644 """Context manager for setting GIT_INDEX_FILE to a temporary file and deleting 645 the file afterward.""" 646 index_path = create_temporary_index(tree) 647 old_index_path = os.environ.get("GIT_INDEX_FILE") 648 os.environ["GIT_INDEX_FILE"] = index_path 649 try: 650 yield 651 finally: 652 if old_index_path is None: 653 del os.environ["GIT_INDEX_FILE"] 654 else: 655 os.environ["GIT_INDEX_FILE"] = old_index_path 656 os.remove(index_path) 657 658 659def create_temporary_index(tree=None): 660 """Create a temporary index file and return the created file's path. 661 662 If `tree` is not None, use that as the tree to read in. Otherwise, an 663 empty index is created.""" 664 gitdir = run("git", "rev-parse", "--git-dir") 665 path = os.path.join(gitdir, temp_index_basename) 666 if tree is None: 667 tree = "--empty" 668 run("git", "read-tree", "--index-output=" + path, tree) 669 return path 670 671 672def print_diff(old_tree, new_tree): 673 """Print the diff between the two trees to stdout.""" 674 # We use the porcelain 'diff' and not plumbing 'diff-tree' because the output 675 # is expected to be viewed by the user, and only the former does nice things 676 # like color and pagination. 677 # 678 # We also only print modified files since `new_tree` only contains the files 679 # that were modified, so unmodified files would show as deleted without the 680 # filter. 681 return subprocess.run( 682 ["git", "diff", "--diff-filter=M", "--exit-code", old_tree, new_tree] 683 ).returncode 684 685 686def print_diffstat(old_tree, new_tree): 687 """Print the diffstat between the two trees to stdout.""" 688 # We use the porcelain 'diff' and not plumbing 'diff-tree' because the output 689 # is expected to be viewed by the user, and only the former does nice things 690 # like color and pagination. 691 # 692 # We also only print modified files since `new_tree` only contains the files 693 # that were modified, so unmodified files would show as deleted without the 694 # filter. 695 return subprocess.run( 696 ["git", "diff", "--diff-filter=M", "--exit-code", "--stat", old_tree, new_tree] 697 ).returncode 698 699 700def apply_changes(old_tree, new_tree, force=False, patch_mode=False): 701 """Apply the changes in `new_tree` to the working directory. 702 703 Bails if there are local changes in those files and not `force`. If 704 `patch_mode`, runs `git checkout --patch` to select hunks interactively.""" 705 changed_files = ( 706 run( 707 "git", 708 "diff-tree", 709 "--diff-filter=M", 710 "-r", 711 "-z", 712 "--name-only", 713 old_tree, 714 new_tree, 715 ) 716 .rstrip("\0") 717 .split("\0") 718 ) 719 if not force: 720 unstaged_files = run("git", "diff-files", "--name-status", *changed_files) 721 if unstaged_files: 722 print( 723 "The following files would be modified but " "have unstaged changes:", 724 file=sys.stderr, 725 ) 726 print(unstaged_files, file=sys.stderr) 727 print("Please commit, stage, or stash them first.", file=sys.stderr) 728 sys.exit(2) 729 if patch_mode: 730 # In patch mode, we could just as well create an index from the new tree 731 # and checkout from that, but then the user will be presented with a 732 # message saying "Discard ... from worktree". Instead, we use the old 733 # tree as the index and checkout from new_tree, which gives the slightly 734 # better message, "Apply ... to index and worktree". This is not quite 735 # right, since it won't be applied to the user's index, but oh well. 736 with temporary_index_file(old_tree): 737 subprocess.run(["git", "checkout", "--patch", new_tree], check=True) 738 index_tree = old_tree 739 else: 740 with temporary_index_file(new_tree): 741 run("git", "checkout-index", "-f", "--", *changed_files) 742 return changed_files 743 744 745def run(*args, **kwargs): 746 stdin = kwargs.pop("stdin", "") 747 verbose = kwargs.pop("verbose", True) 748 strip = kwargs.pop("strip", True) 749 for name in kwargs: 750 raise TypeError("run() got an unexpected keyword argument '%s'" % name) 751 p = subprocess.Popen( 752 args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE 753 ) 754 stdout, stderr = p.communicate(input=stdin) 755 756 stdout = convert_string(stdout) 757 stderr = convert_string(stderr) 758 759 if p.returncode == 0: 760 if stderr: 761 if verbose: 762 print("`%s` printed to stderr:" % " ".join(args), file=sys.stderr) 763 print(stderr.rstrip(), file=sys.stderr) 764 if strip: 765 stdout = stdout.rstrip("\r\n") 766 return stdout 767 if verbose: 768 print("`%s` returned %s" % (" ".join(args), p.returncode), file=sys.stderr) 769 if stderr: 770 print(stderr.rstrip(), file=sys.stderr) 771 sys.exit(2) 772 773 774def die(message): 775 print("error:", message, file=sys.stderr) 776 sys.exit(2) 777 778 779def to_bytes(str_input): 780 # Encode to UTF-8 to get binary data. 781 if isinstance(str_input, bytes): 782 return str_input 783 return str_input.encode("utf-8") 784 785 786def to_string(bytes_input): 787 if isinstance(bytes_input, str): 788 return bytes_input 789 return bytes_input.encode("utf-8") 790 791 792def convert_string(bytes_input): 793 try: 794 return to_string(bytes_input.decode("utf-8")) 795 except AttributeError: # 'str' object has no attribute 'decode'. 796 return str(bytes_input) 797 except UnicodeError: 798 return str(bytes_input) 799 800 801if __name__ == "__main__": 802 sys.exit(main()) 803