859 lines · plain
1#!/usr/bin/env python32#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-exception8#9# ===----------------------------------------------------------------------=== #10 11r"""12clang-format git integration13============================14 15This file provides a clang-format integration for git. Put it somewhere in your16path and ensure that it is executable. Then, "git clang-format" will invoke17clang-format on the changes in current files or a specific commit.18 19For further details, run:20git clang-format -h21 22Requires Python version >=3.823"""24 25from __future__ import absolute_import, division, print_function26import argparse27import collections28import contextlib29import errno30import os31import re32import subprocess33import sys34 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 differ39between the working directory and <commit>, which defaults to HEAD. Changes are40only 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-format45 46 To also format everything touched in the most recent commit:47 git clang-format HEAD~148 49 If you're on a branch off main, to format everything touched on your branch:50 git clang-format main51 52If two commits are given (requires --diff), run clang-format on all lines in the53second <commit> that differ from the first <commit>.54 55The following git-config settings set the default of the corresponding option:56 clangFormat.binary57 clangFormat.commit58 clangFormat.extensions59 clangFormat.style60"""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 to74 # check for '--' ourselves. (Setting nargs='*' throws away the '--', while75 # 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 case88 "c",89 "h", # C90 "m", # ObjC91 "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++ Modules105 "cu",106 "cuh", # CUDA107 "cl", # OpenCL108 # Other languages that clang-format supports109 "proto",110 "protodevel", # Protocol Buffers111 "java", # Java112 "js",113 "mjs",114 "cjs", # JavaScript115 "ts", # TypeScript116 "cs", # C Sharp117 "json",118 "ipynb", # JSON119 "sv",120 "svh",121 "v",122 "vh", # Verilog123 "td", # TableGen124 "txtpb",125 "textpb",126 "pb.txt",127 "textproto",128 "asciipb", # TextProto129 ]130 )131 132 p = argparse.ArgumentParser(133 usage=usage,134 formatter_class=argparse.RawDescriptionHelpFormatter,135 description=desc,136 )137 p.add_argument(138 "--binary",139 default=config.get("clangformat.binary", "clang-format"),140 help="path to clang-format",141 ),142 p.add_argument(143 "--commit",144 default=config.get("clangformat.commit", "HEAD"),145 help="default commit to use if none is specified",146 ),147 p.add_argument(148 "--diff",149 action="store_true",150 help="print a diff instead of applying the changes",151 )152 p.add_argument(153 "--diffstat",154 action="store_true",155 help="print a diffstat instead of applying the changes",156 )157 p.add_argument(158 "--extensions",159 default=config.get("clangformat.extensions", default_extensions),160 help=(161 "comma-separated list of file extensions to format, "162 "excluding the period and case-insensitive"163 ),164 ),165 p.add_argument(166 "-f",167 "--force",168 action="store_true",169 help="allow changes to unstaged files",170 )171 p.add_argument(172 "-p", "--patch", action="store_true", help="select hunks interactively"173 )174 p.add_argument(175 "-q",176 "--quiet",177 action="count",178 default=0,179 help="print less information",180 )181 p.add_argument(182 "--staged",183 "--cached",184 action="store_true",185 help="format lines in the stage instead of the working dir",186 )187 p.add_argument(188 "--style",189 default=config.get("clangformat.style", None),190 help="passed to clang-format",191 ),192 p.add_argument(193 "-v",194 "--verbose",195 action="count",196 default=0,197 help="print extra information",198 )199 p.add_argument(200 "--diff_from_common_commit",201 action="store_true",202 help=(203 "diff from the last common commit for commits in "204 "separate branches rather than the exact point of the "205 "commits"206 ),207 )208 # We gather all the remaining positional arguments into 'args' since we need209 # to use some heuristics to determine whether or not <commit> was present.210 # However, to print pretty messages, we make use of metavar and help.211 p.add_argument(212 "args",213 nargs="*",214 metavar="<commit>",215 help="revision from which to compute the diff",216 )217 p.add_argument(218 "ignored",219 nargs="*",220 metavar="<file>...",221 help="if specified, only consider differences in these files",222 )223 opts = p.parse_args(argv)224 225 opts.verbose -= opts.quiet226 del opts.quiet227 228 commits, files = interpret_args(opts.args, dash_dash, opts.commit)229 if len(commits) > 2:230 die("at most two commits allowed; %d given" % len(commits))231 if len(commits) == 2:232 if opts.staged:233 die("--staged is not allowed when two commits are given")234 if not opts.diff:235 die("--diff is required when two commits are given")236 elif opts.diff_from_common_commit:237 die("--diff_from_common_commit is only allowed when two commits are given")238 239 if os.path.dirname(opts.binary):240 opts.binary = os.path.abspath(opts.binary)241 242 changed_lines = compute_diff_and_extract_lines(243 commits, files, opts.staged, opts.diff_from_common_commit244 )245 if opts.verbose >= 1:246 ignored_files = set(changed_lines)247 filter_by_extension(changed_lines, opts.extensions.lower().split(","))248 # The computed diff outputs absolute paths, so we must cd before accessing249 # those files.250 cd_to_toplevel()251 filter_symlinks(changed_lines)252 filter_ignored_files(changed_lines, binary=opts.binary)253 if opts.verbose >= 1:254 ignored_files.difference_update(changed_lines)255 if ignored_files:256 print(257 "Ignoring the following files (wrong extension, symlink, or "258 "ignored by clang-format):"259 )260 for filename in ignored_files:261 print(" %s" % filename)262 if changed_lines:263 print("Running clang-format on the following files:")264 for filename in changed_lines:265 print(" %s" % filename)266 267 if not changed_lines:268 if opts.verbose >= 0:269 print("no modified files to format")270 return 0271 272 if len(commits) > 1:273 old_tree = commits[1]274 revision = old_tree275 elif opts.staged:276 old_tree = create_tree_from_index(changed_lines)277 revision = ""278 else:279 old_tree = create_tree_from_workdir(changed_lines)280 revision = None281 new_tree = run_clang_format_and_save_to_tree(282 changed_lines, revision, binary=opts.binary, style=opts.style283 )284 if opts.verbose >= 1:285 print("old tree: %s" % old_tree)286 print("new tree: %s" % new_tree)287 288 if old_tree == new_tree:289 if opts.verbose >= 0:290 print("clang-format did not modify any files")291 return 0292 293 if opts.diff:294 return print_diff(old_tree, new_tree)295 if opts.diffstat:296 return print_diffstat(old_tree, new_tree)297 298 changed_files = apply_changes(299 old_tree, new_tree, force=opts.force, patch_mode=opts.patch300 )301 if (opts.verbose >= 0 and not opts.patch) or opts.verbose >= 1:302 print("changed files:")303 for filename in changed_files:304 print(" %s" % filename)305 306 return 1307 308 309def load_git_config(non_string_options=None):310 """Return the git configuration as a dictionary.311 312 All options are assumed to be strings unless in `non_string_options`, in313 which is a dictionary mapping option name (in lower case) to either "--bool"314 or "--int"."""315 if non_string_options is None:316 non_string_options = {}317 out = {}318 for entry in run("git", "config", "--list", "--null").split("\0"):319 if entry:320 if "\n" in entry:321 name, value = entry.split("\n", 1)322 else:323 # A setting with no '=' ('\n' with --null) is implicitly 'true'324 name = entry325 value = "true"326 if name in non_string_options:327 value = run("git", "config", non_string_options[name], name)328 out[name] = value329 return out330 331 332def interpret_args(args, dash_dash, default_commit):333 """Interpret `args` as "[commits] [--] [files]" and return (commits, files).334 335 It is assumed that "--" and everything that follows has been removed from336 args and placed in `dash_dash`.337 338 If "--" is present (i.e., `dash_dash` is non-empty), the arguments to its339 left (if present) are taken as commits. Otherwise, the arguments are340 checked from left to right if they are commits or files. If commits are not341 given, a list with `default_commit` is used."""342 if dash_dash:343 if len(args) == 0:344 commits = [default_commit]345 else:346 commits = args347 for commit in commits:348 object_type = get_object_type(commit)349 if object_type not in ("commit", "tag"):350 if object_type is None:351 die("'%s' is not a commit" % commit)352 else:353 die(354 "'%s' is a %s, but a commit was expected"355 % (commit, object_type)356 )357 files = dash_dash[1:]358 elif args:359 commits = []360 while args:361 if not disambiguate_revision(args[0]):362 break363 commits.append(args.pop(0))364 if not commits:365 commits = [default_commit]366 files = args367 else:368 commits = [default_commit]369 files = []370 return commits, files371 372 373def disambiguate_revision(value):374 """Returns True if `value` is a revision, False if it is a file, or dies."""375 # If `value` is ambiguous (neither a commit nor a file), the following376 # command will die with an appropriate error message.377 run("git", "rev-parse", value, verbose=False)378 object_type = get_object_type(value)379 if object_type is None:380 return False381 if object_type in ("commit", "tag"):382 return True383 die("`%s` is a %s, but a commit or filename was expected" % (value, object_type))384 385 386def get_object_type(value):387 """Returns a string description of an object's type, or None if it is not388 a valid git object."""389 cmd = ["git", "cat-file", "-t", value]390 p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)391 stdout, stderr = p.communicate()392 if p.returncode != 0:393 return None394 return convert_string(stdout.strip())395 396 397def compute_diff_and_extract_lines(commits, files, staged, diff_common_commit):398 """Calls compute_diff() followed by extract_lines()."""399 diff_process = compute_diff(commits, files, staged, diff_common_commit)400 changed_lines = extract_lines(diff_process.stdout)401 diff_process.stdout.close()402 diff_process.wait()403 if diff_process.returncode != 0:404 # Assume error was already printed to stderr.405 sys.exit(2)406 return changed_lines407 408 409def compute_diff(commits, files, staged, diff_common_commit):410 """Return a subprocess object producing the diff from `commits`.411 412 The return value's `stdin` file object will produce a patch with the413 differences between the working directory (or stage if --staged is used) and414 the first commit if a single one was specified, or the difference between415 both specified commits, filtered on `files` (if non-empty).416 Zero context lines are used in the patch."""417 git_tool = "diff-index"418 extra_args = []419 if len(commits) == 2:420 git_tool = "diff-tree"421 if diff_common_commit:422 extra_args += ["--merge-base"]423 elif staged:424 extra_args += ["--cached"]425 426 cmd = ["git", git_tool, "-p", "-U0"] + extra_args + commits + ["--"]427 cmd.extend(files)428 p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)429 p.stdin.close()430 return p431 432 433def extract_lines(patch_file):434 """Extract the changed lines in `patch_file`.435 436 The return value is a dictionary mapping filename to a list of (start_line,437 line_count) pairs.438 439 The input must have been produced with ``-U0``, meaning unidiff format with440 zero lines of context. The return value is a dict mapping filename to a441 list of line `Range`s."""442 matches = {}443 for line in patch_file:444 line = convert_string(line)445 match = re.search(r"^\+\+\+\ [^/]+/(.*)", line)446 if match:447 filename = match.group(1).rstrip("\r\n\t")448 match = re.search(r"^@@ -[0-9,]+ \+(\d+)(,(\d+))?", line)449 if match:450 start_line = int(match.group(1))451 line_count = 1452 if match.group(3):453 line_count = int(match.group(3))454 if line_count == 0:455 line_count = 1456 if start_line == 0:457 continue458 matches.setdefault(filename, []).append(Range(start_line, line_count))459 return matches460 461 462def filter_by_extension(dictionary, allowed_extensions):463 """Delete every key in `dictionary` that doesn't have an allowed extension.464 465 `allowed_extensions` must be a collection of lowercase file extensions,466 excluding the period."""467 allowed_extensions = frozenset(allowed_extensions)468 for filename in list(dictionary.keys()):469 base_ext = filename.rsplit(".", 1)470 if len(base_ext) == 1 and "" in allowed_extensions:471 continue472 if len(base_ext) == 1 or base_ext[1].lower() not in allowed_extensions:473 del dictionary[filename]474 475 476def filter_symlinks(dictionary):477 """Delete every key in `dictionary` that is a symlink."""478 for filename in list(dictionary.keys()):479 if os.path.islink(filename):480 del dictionary[filename]481 482 483def filter_ignored_files(dictionary, binary):484 """Delete every key in `dictionary` that is ignored by clang-format."""485 ignored_files = run(binary, "-list-ignored", *dictionary.keys())486 if not ignored_files:487 return488 ignored_files = ignored_files.split("\n")489 for filename in ignored_files:490 del dictionary[filename]491 492 493def cd_to_toplevel():494 """Change to the top level of the git repository."""495 toplevel = run("git", "rev-parse", "--show-toplevel")496 os.chdir(toplevel)497 498 499def create_tree_from_workdir(filenames):500 """Create a new git tree with the given files from the working directory.501 502 Returns the object ID (SHA-1) of the created tree."""503 return create_tree(filenames, "--stdin")504 505 506def create_tree_from_index(filenames):507 # Copy the environment, because the files have to be read from the original508 # index.509 env = os.environ.copy()510 511 def index_contents_generator():512 for filename in filenames:513 git_ls_files_cmd = [514 "git",515 "ls-files",516 "--stage",517 "-z",518 "--",519 filename,520 ]521 git_ls_files = subprocess.Popen(522 git_ls_files_cmd,523 env=env,524 stdin=subprocess.PIPE,525 stdout=subprocess.PIPE,526 )527 stdout = git_ls_files.communicate()[0]528 yield convert_string(stdout.split(b"\0")[0])529 530 return create_tree(index_contents_generator(), "--index-info")531 532 533def run_clang_format_and_save_to_tree(534 changed_lines, revision=None, binary="clang-format", style=None535):536 """Run clang-format on each file and save the result to a git tree.537 538 Returns the object ID (SHA-1) of the created tree."""539 # Copy the environment when formatting the files in the index, because the540 # files have to be read from the original index.541 env = os.environ.copy() if revision == "" else None542 543 def iteritems(container):544 try:545 return container.iteritems() # Python 2546 except AttributeError:547 return container.items() # Python 3548 549 def index_info_generator():550 for filename, line_ranges in iteritems(changed_lines):551 if revision is not None:552 if len(revision) > 0:553 git_metadata_cmd = [554 "git",555 "ls-tree",556 "%s:%s" % (revision, os.path.dirname(filename)),557 os.path.basename(filename),558 ]559 else:560 git_metadata_cmd = [561 "git",562 "ls-files",563 "--stage",564 "--",565 filename,566 ]567 git_metadata = subprocess.Popen(568 git_metadata_cmd,569 env=env,570 stdin=subprocess.PIPE,571 stdout=subprocess.PIPE,572 )573 stdout = git_metadata.communicate()[0]574 mode = oct(int(stdout.split()[0], 8))575 else:576 mode = oct(os.stat(filename).st_mode)577 # Adjust python3 octal format so that it matches what git expects578 if mode.startswith("0o"):579 mode = "0" + mode[2:]580 blob_id = clang_format_to_blob(581 filename,582 line_ranges,583 revision=revision,584 binary=binary,585 style=style,586 env=env,587 )588 yield "%s %s\t%s" % (mode, blob_id, filename)589 590 return create_tree(index_info_generator(), "--index-info")591 592 593def create_tree(input_lines, mode):594 """Create a tree object from the given input.595 596 If mode is '--stdin', it must be a list of filenames. If mode is597 '--index-info' is must be a list of values suitable for "git update-index598 --index-info", such as "<mode> <SP> <sha1> <TAB> <filename>". Any other599 mode is invalid."""600 assert mode in ("--stdin", "--index-info")601 cmd = ["git", "update-index", "--add", "-z", mode]602 with temporary_index_file():603 p = subprocess.Popen(cmd, stdin=subprocess.PIPE)604 for line in input_lines:605 p.stdin.write(to_bytes("%s\0" % line))606 p.stdin.close()607 if p.wait() != 0:608 die("`%s` failed" % " ".join(cmd))609 tree_id = run("git", "write-tree")610 return tree_id611 612 613def clang_format_to_blob(614 filename,615 line_ranges,616 revision=None,617 binary="clang-format",618 style=None,619 env=None,620):621 """Run clang-format on the given file and save the result to a git blob.622 623 Runs on the file in `revision` if not None, or on the file in the working624 directory if `revision` is None. Revision can be set to an empty string to625 run clang-format on the file in the index.626 627 Returns the object ID (SHA-1) of the created blob."""628 clang_format_cmd = [binary]629 if style:630 clang_format_cmd.extend(["--style=" + style])631 clang_format_cmd.extend(632 [633 "--lines=%s:%s" % (start_line, start_line + line_count - 1)634 for start_line, line_count in line_ranges635 ]636 )637 if revision is not None:638 clang_format_cmd.extend(["--assume-filename=" + filename])639 git_show_cmd = [640 "git",641 "cat-file",642 "blob",643 "%s:%s" % (revision, filename),644 ]645 git_show = subprocess.Popen(646 git_show_cmd, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE647 )648 git_show.stdin.close()649 clang_format_stdin = git_show.stdout650 else:651 clang_format_cmd.extend([filename])652 git_show = None653 clang_format_stdin = subprocess.PIPE654 try:655 clang_format = subprocess.Popen(656 clang_format_cmd, stdin=clang_format_stdin, stdout=subprocess.PIPE657 )658 if clang_format_stdin == subprocess.PIPE:659 clang_format_stdin = clang_format.stdin660 except OSError as e:661 if e.errno == errno.ENOENT:662 die('cannot find executable "%s"' % binary)663 else:664 raise665 clang_format_stdin.close()666 hash_object_cmd = [667 "git",668 "hash-object",669 "-w",670 "--path=" + filename,671 "--stdin",672 ]673 hash_object = subprocess.Popen(674 hash_object_cmd, stdin=clang_format.stdout, stdout=subprocess.PIPE675 )676 clang_format.stdout.close()677 stdout = hash_object.communicate()[0]678 if hash_object.returncode != 0:679 die("`%s` failed" % " ".join(hash_object_cmd))680 if clang_format.wait() != 0:681 die("`%s` failed" % " ".join(clang_format_cmd))682 if git_show and git_show.wait() != 0:683 die("`%s` failed" % " ".join(git_show_cmd))684 return convert_string(stdout).rstrip("\r\n")685 686 687@contextlib.contextmanager688def temporary_index_file(tree=None):689 """Context manager for setting GIT_INDEX_FILE to a temporary file and690 deleting the file afterward."""691 index_path = create_temporary_index(tree)692 old_index_path = os.environ.get("GIT_INDEX_FILE")693 os.environ["GIT_INDEX_FILE"] = index_path694 try:695 yield696 finally:697 if old_index_path is None:698 del os.environ["GIT_INDEX_FILE"]699 else:700 os.environ["GIT_INDEX_FILE"] = old_index_path701 os.remove(index_path)702 703 704def create_temporary_index(tree=None):705 """Create a temporary index file and return the created file's path.706 707 If `tree` is not None, use that as the tree to read in. Otherwise, an708 empty index is created."""709 gitdir = run("git", "rev-parse", "--git-dir")710 path = os.path.join(gitdir, temp_index_basename)711 if tree is None:712 tree = "--empty"713 run("git", "read-tree", "--index-output=" + path, tree)714 return path715 716 717def print_diff(old_tree, new_tree):718 """Print the diff between the two trees to stdout."""719 # We use the porcelain 'diff' and not plumbing 'diff-tree' because the720 # output is expected to be viewed by the user, and only the former does nice721 # things like color and pagination.722 #723 # We also only print modified files since `new_tree` only contains the files724 # that were modified, so unmodified files would show as deleted without the725 # filter.726 return subprocess.run(727 ["git", "diff", "--diff-filter=M", "--exit-code", old_tree, new_tree]728 ).returncode729 730 731def print_diffstat(old_tree, new_tree):732 """Print the diffstat between the two trees to stdout."""733 # We use the porcelain 'diff' and not plumbing 'diff-tree' because the734 # output is expected to be viewed by the user, and only the former does nice735 # things like color and pagination.736 #737 # We also only print modified files since `new_tree` only contains the files738 # that were modified, so unmodified files would show as deleted without the739 # filter.740 return subprocess.run(741 [742 "git",743 "diff",744 "--diff-filter=M",745 "--exit-code",746 "--stat",747 old_tree,748 new_tree,749 ]750 ).returncode751 752 753def apply_changes(old_tree, new_tree, force=False, patch_mode=False):754 """Apply the changes in `new_tree` to the working directory.755 756 Bails if there are local changes in those files and not `force`. If757 `patch_mode`, runs `git checkout --patch` to select hunks interactively."""758 changed_files = (759 run(760 "git",761 "diff-tree",762 "--diff-filter=M",763 "-r",764 "-z",765 "--name-only",766 old_tree,767 new_tree,768 )769 .rstrip("\0")770 .split("\0")771 )772 if not force:773 unstaged_files = run("git", "diff-files", "--name-status", *changed_files)774 if unstaged_files:775 print(776 "The following files would be modified but have unstaged changes:",777 file=sys.stderr,778 )779 print(unstaged_files, file=sys.stderr)780 print("Please commit, stage, or stash them first.", file=sys.stderr)781 sys.exit(2)782 if patch_mode:783 # In patch mode, we could just as well create an index from the new tree784 # and checkout from that, but then the user will be presented with a785 # message saying "Discard ... from worktree". Instead, we use the old786 # tree as the index and checkout from new_tree, which gives the slightly787 # better message, "Apply ... to index and worktree". This is not quite788 # right, since it won't be applied to the user's index, but oh well.789 with temporary_index_file(old_tree):790 subprocess.run(["git", "checkout", "--patch", new_tree], check=True)791 index_tree = old_tree792 else:793 with temporary_index_file(new_tree):794 run("git", "checkout-index", "-f", "--", *changed_files)795 return changed_files796 797 798def run(*args, **kwargs):799 stdin = kwargs.pop("stdin", "")800 verbose = kwargs.pop("verbose", True)801 strip = kwargs.pop("strip", True)802 for name in kwargs:803 raise TypeError("run() got an unexpected keyword argument '%s'" % name)804 p = subprocess.Popen(805 args,806 stdout=subprocess.PIPE,807 stderr=subprocess.PIPE,808 stdin=subprocess.PIPE,809 )810 stdout, stderr = p.communicate(input=stdin)811 812 stdout = convert_string(stdout)813 stderr = convert_string(stderr)814 815 if p.returncode == 0:816 if stderr:817 if verbose:818 print("`%s` printed to stderr:" % " ".join(args), file=sys.stderr)819 print(stderr.rstrip(), file=sys.stderr)820 if strip:821 stdout = stdout.rstrip("\r\n")822 return stdout823 if verbose:824 print("`%s` returned %s" % (" ".join(args), p.returncode), file=sys.stderr)825 if stderr:826 print(stderr.rstrip(), file=sys.stderr)827 sys.exit(2)828 829 830def die(message):831 print("error:", message, file=sys.stderr)832 sys.exit(2)833 834 835def to_bytes(str_input):836 # Encode to UTF-8 to get binary data.837 if isinstance(str_input, bytes):838 return str_input839 return str_input.encode("utf-8")840 841 842def to_string(bytes_input):843 if isinstance(bytes_input, str):844 return bytes_input845 return bytes_input.encode("utf-8")846 847 848def convert_string(bytes_input):849 try:850 return to_string(bytes_input.decode("utf-8"))851 except AttributeError: # 'str' object has no attribute 'decode'.852 return str(bytes_input)853 except UnicodeError:854 return str(bytes_input)855 856 857if __name__ == "__main__":858 sys.exit(main())859