| #!/usr/bin/env python3 | |
| # Copyright (c) 2020, the R8 project authors. Please see the AUTHORS file | |
| # for details. All rights reserved. Use of this source code is governed by a | |
| # BSD-style license that can be found in the LICENSE file. | |
| import argparse | |
| from concurrent.futures import ThreadPoolExecutor | |
| import os | |
| import re | |
| from subprocess import DEVNULL, PIPE, Popen | |
| import sys | |
| # Support running both directly as a script (where tools/ is in sys.path) | |
| # and when imported as a module from the repository root (e.g. PRESUBMIT.py). | |
| try: | |
| from tools import defines, google_java_format_diff, jdk, utils | |
| except ImportError: | |
| import defines | |
| import google_java_format_diff | |
| import jdk | |
| import utils | |
| GOOGLE_JAVA_FORMAT_DIR = os.path.join(defines.THIRD_PARTY, 'google', | |
| 'google-java-format', '1.24.0') | |
| GOOGLE_JAVA_FORMAT_JAR = os.path.join(GOOGLE_JAVA_FORMAT_DIR, | |
| 'google-java-format-1.24.0-all-deps.jar') | |
| GOOGLE_KOTLIN_FORMAT_DIR = os.path.join(defines.THIRD_PARTY, 'google', | |
| 'google-kotlin-format', '0.54') | |
| GOOGLE_KOTLIN_FORMAT_JAR = os.path.join(GOOGLE_KOTLIN_FORMAT_DIR, | |
| 'ktfmt-0.54-jar-with-dependencies.jar') | |
| GOOGLE_YAPF_DIR = os.path.join(defines.THIRD_PARTY, 'google', 'yapf', | |
| '20231013') | |
| GOOGLE_YAPF = os.path.join(GOOGLE_YAPF_DIR, 'yapf') | |
| YAPF_PYTHON_PATH = [ | |
| GOOGLE_YAPF_DIR, | |
| os.path.join(GOOGLE_YAPF_DIR, 'third_party') | |
| ] | |
| NODE_DIR = os.path.join(defines.THIRD_PARTY, 'node', '24.16.0', 'linux') | |
| NODE_BIN = os.path.join(NODE_DIR, 'bin', 'node') | |
| PRETTIER_DIR = os.path.join(defines.THIRD_PARTY, 'prettier', '3.8.3') | |
| PRETTIER_BIN = os.path.join(PRETTIER_DIR, 'node_modules', 'prettier', 'bin', | |
| 'prettier.cjs') | |
| PLATFORMDIRS_ERROR_MSG = ( | |
| "Error: Could not find a Python interpreter with `platformdirs` installed.\n" | |
| "Please ensure it is installed in your environment:\n" | |
| " $ python3 -m venv .venv\n" | |
| " $ source .venv/bin/activate\n" | |
| " $ pip3 install platformdirs") | |
| INCLUDED_FILES = { | |
| 'PRESUBMIT.py', | |
| 'build.gradle.kts', | |
| 'settings.gradle.kts', | |
| } | |
| INCLUDED_DIRS = ( | |
| 'd8_r8', | |
| 'scripts', | |
| 'src', | |
| 'tools', | |
| ) | |
| EXCLUDED_SUBDIRS = { | |
| '__pycache__', | |
| 'build', | |
| 'out', | |
| } | |
| EXCLUDED_DIRS = ( | |
| 'tools/linux', | |
| 'tools/mac', | |
| 'tools/windows', | |
| ) | |
| REPO_ROOT_ABS = os.path.abspath(defines.REPO_ROOT) | |
| def is_java_file(file_path): | |
| return file_path.endswith('.java') | |
| def is_kotlin_file(file_path): | |
| return file_path.endswith('.kt') or file_path.endswith('.kts') | |
| def is_python_file(file_path): | |
| return file_path.endswith('.py') | |
| def is_web_file(file_path): | |
| return file_path.endswith(('.js', '.html', '.css')) | |
| def is_formattable_file(file_path): | |
| return (is_java_file(file_path) or is_kotlin_file(file_path) or | |
| is_python_file(file_path) or is_web_file(file_path)) | |
| def get_repo_rel_path(path): | |
| abs_path = os.path.abspath(path) | |
| rel = os.path.relpath(abs_path, REPO_ROOT_ABS).replace('\\', '/') | |
| if rel.startswith('..'): | |
| rel = os.path.relpath(os.path.realpath(path), | |
| defines.REPO_ROOT).replace('\\', '/') | |
| return rel | |
| def is_within_included_dirs(rel_dir): | |
| return any( | |
| rel_dir == d or rel_dir.startswith(d + '/') for d in INCLUDED_DIRS) | |
| def has_excluded_component(rel_path): | |
| if any(rel_path == d or rel_path.startswith(d + '/') | |
| for d in EXCLUDED_DIRS): | |
| return True | |
| return any( | |
| p.startswith('.') or p in EXCLUDED_SUBDIRS for p in rel_path.split('/')) | |
| def is_included_rel_file(rel_file): | |
| if rel_file == '.' or rel_file.startswith('..'): | |
| return False | |
| if rel_file in INCLUDED_FILES: | |
| return True | |
| if not any(rel_file.startswith(d + '/') for d in INCLUDED_DIRS): | |
| return False | |
| return not has_excluded_component(rel_file) | |
| def is_included_file(file_path): | |
| return is_included_rel_file(get_repo_rel_path(file_path)) | |
| def is_included_dir(rel_dir): | |
| if rel_dir == '.': | |
| return True | |
| if rel_dir.startswith('..') or has_excluded_component(rel_dir): | |
| return False | |
| return (is_within_included_dirs(rel_dir) or | |
| any(d.startswith(rel_dir + '/') for d in INCLUDED_DIRS)) | |
| def get_changed_files(upstream, file_predicate=None): | |
| returncode, stdout, stderr = run_command( | |
| ['git', 'diff', '--name-only', upstream], cwd=defines.REPO_ROOT) | |
| if returncode != 0: | |
| err_msg = stderr or stdout or f"Failed to diff against {upstream}." | |
| print(f"Git error:\n{err_msg}", file=sys.stderr) | |
| return None | |
| return [ | |
| f for f in stdout.splitlines() if is_included_rel_file(f) and | |
| (file_predicate is None or file_predicate(f)) and | |
| os.path.exists(os.path.join(defines.REPO_ROOT, f)) | |
| ] | |
| def get_path_files(path, file_predicate=None): | |
| predicate = file_predicate or is_formattable_file | |
| if not os.path.exists(path): | |
| print(f"Error: Directory or file not found: {path}", file=sys.stderr) | |
| return [], False | |
| if os.path.isfile(path): | |
| if not is_included_file(path): | |
| print(f"Error: File is not in included paths: {path}", | |
| file=sys.stderr) | |
| return [], False | |
| norm_path = os.path.normpath(path) | |
| if predicate(norm_path): | |
| return [norm_path], True | |
| return [], True | |
| start_rel = get_repo_rel_path(path) | |
| if not is_included_dir(start_rel): | |
| print(f"Error: Directory is not in included paths: {path}", | |
| file=sys.stderr) | |
| return [], False | |
| files = [] | |
| for root, dirs, filenames in os.walk(path): | |
| rel_from_start = os.path.relpath(root, path).replace('\\', '/') | |
| if rel_from_start == '.': | |
| root_rel = start_rel | |
| elif start_rel == '.': | |
| root_rel = rel_from_start | |
| else: | |
| root_rel = f'{start_rel}/{rel_from_start}' | |
| dirs[:] = [ | |
| d for d in dirs | |
| if is_included_dir(d if root_rel == '.' else f'{root_rel}/{d}') | |
| ] | |
| for filename in filenames: | |
| rel_file = (filename | |
| if root_rel == '.' else f'{root_rel}/{filename}') | |
| if not is_included_rel_file(rel_file): | |
| continue | |
| file_path = os.path.normpath(os.path.join(root, filename)) | |
| if predicate(file_path): | |
| files.append(file_path) | |
| return files, True | |
| def chunk_list(items, chunk_size): | |
| for i in range(0, len(items), chunk_size): | |
| yield items[i:i + chunk_size] | |
| def partition_kotlin_files(files): | |
| kotlin_files = [] | |
| google_files = [] | |
| for p in files: | |
| if get_repo_rel_path(p).startswith('src/keepanno/'): | |
| kotlin_files.append(p) | |
| else: | |
| google_files.append(p) | |
| return { | |
| '--kotlinlang-style': kotlin_files, | |
| '--google-style': google_files, | |
| } | |
| def get_env_with_python_path(): | |
| new_env = os.environ.copy() | |
| new_env['PYTHONPATH'] = os.pathsep.join(YAPF_PYTHON_PATH) | |
| return new_env | |
| class PythonRuntime: | |
| def __init__(self): | |
| self.interpreter = None | |
| def initialize_runtime(self): | |
| python_env = get_env_with_python_path() | |
| for candidate in [ | |
| sys.executable, | |
| 'python3', | |
| os.path.join(defines.REPO_ROOT, '.venv', 'bin', 'python3'), | |
| os.path.join(defines.REPO_ROOT, '.venv', 'bin', 'python'), | |
| ]: | |
| try: | |
| returncode, _, _ = run_command( | |
| [candidate, '-c', 'import platformdirs'], env=python_env) | |
| if returncode == 0: | |
| self.interpreter = candidate | |
| return None | |
| except FileNotFoundError: | |
| continue | |
| return PLATFORMDIRS_ERROR_MSG | |
| def get_java_format_base_command(): | |
| java_exec = jdk.GetJavaExecutable(jdk.GetDefaultJdkHome()) | |
| return [ | |
| java_exec, | |
| # Disable hsperfdata to prevent lock contention on /tmp/hsperfdata_<user> during parallel execution. | |
| '-XX:+PerfDisableSharedMem', | |
| '--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED', | |
| '--add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED', | |
| '--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED', | |
| '--add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED', | |
| '--add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED', | |
| '--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED', | |
| '-jar', | |
| GOOGLE_JAVA_FORMAT_JAR, | |
| ] | |
| def get_kotlin_format_base_command(): | |
| java_exec = jdk.GetJavaExecutable(jdk.GetDefaultJdkHome()) | |
| # Disable hsperfdata to prevent lock contention on /tmp/hsperfdata_<user> during parallel execution. | |
| return [ | |
| java_exec, '-XX:+PerfDisableSharedMem', '-jar', GOOGLE_KOTLIN_FORMAT_JAR | |
| ] | |
| def get_python_format_base_command(python_runtime): | |
| return [python_runtime.interpreter, GOOGLE_YAPF, '--style', 'google'] | |
| def get_prettier_base_command(): | |
| return [NODE_BIN, PRETTIER_BIN] | |
| def get_git_upstream(): | |
| returncode, stdout, stderr = run_command(['git', 'cl', 'upstream'], | |
| cwd=defines.REPO_ROOT) | |
| if returncode != 0: | |
| err_msg = stderr or stdout or "Failed to determine git upstream." | |
| print(f"Git error:\n{err_msg}", file=sys.stderr) | |
| return None | |
| return stdout.strip() | |
| JAVA_FORMATTER_TAG_RE = re.compile(r'(?://|/\*)\s*@formatter:(off|on)\b') | |
| KTFMT_DISABLE_TAG_RE = re.compile(r'(?://|/\*)\s*ktfmt:disable-file\b') | |
| def is_kotlin_file_disabled(file_path): | |
| if not os.path.exists(file_path): | |
| return False | |
| with open(file_path, 'rb') as f: | |
| content = f.read() | |
| if b'ktfmt:disable-file' not in content: | |
| return False | |
| return bool( | |
| KTFMT_DISABLE_TAG_RE.search(content.decode('utf-8', errors='ignore'))) | |
| # Scans `filename` for `@formatter:off` and `@formatter:on` comments. | |
| # Returns: | |
| # - None: if `@formatter:off` never appears in the file (the entire file is enabled). | |
| # - list of (start_line, end_line) 1-based inclusive tuples: the enabled line ranges | |
| # when `@formatter:off` is used (empty list `[]` if the entire file is disabled). | |
| def get_java_enabled_ranges(filename): | |
| if not os.path.exists(filename): | |
| return [] | |
| with open(filename, 'rb') as f: | |
| raw_content = f.read() | |
| if b'@formatter:off' not in raw_content: | |
| return None | |
| enabled_ranges = [] | |
| enabled = True | |
| start_line = 1 | |
| saw_formatter_off = False | |
| line_num = 0 | |
| for line_num, line in enumerate(raw_content.decode('utf-8').splitlines(), | |
| start=1): | |
| if '@formatter:' in line: | |
| match = JAVA_FORMATTER_TAG_RE.search(line) | |
| if match: | |
| state = match.group(1) | |
| if state == 'off': | |
| saw_formatter_off = True | |
| if enabled: | |
| if start_line < line_num: | |
| enabled_ranges.append((start_line, line_num - 1)) | |
| enabled = False | |
| elif state == 'on' and not enabled: | |
| enabled = True | |
| start_line = line_num + 1 | |
| if not saw_formatter_off: | |
| return None | |
| if enabled and start_line <= line_num: | |
| enabled_ranges.append((start_line, line_num)) | |
| return enabled_ranges | |
| def ranges_to_cli_args(line_ranges): | |
| args = [] | |
| for start, end in line_ranges: | |
| args.extend(['-lines', f'{start}:{end}']) | |
| return args | |
| def run_command(command, env=None, cwd=None): | |
| p = Popen(command, | |
| stdout=PIPE, | |
| stderr=PIPE, | |
| stdin=DEVNULL, | |
| env=env, | |
| cwd=cwd) | |
| stdout, stderr = p.communicate() | |
| return p.returncode, stdout.decode('utf-8'), stderr.decode('utf-8') | |
| def format_java_file(filename, base_command, line_ranges, fix_formatting): | |
| if not os.path.exists(filename): | |
| return [] | |
| command = base_command[:] | |
| if fix_formatting: | |
| command.append('-i') | |
| if line_ranges: | |
| command.extend(ranges_to_cli_args(line_ranges)) | |
| command.append(filename) | |
| returncode, stdout, stderr = run_command(command) | |
| if returncode != 0: | |
| err_msg = stderr if stderr else "Unknown error" | |
| return [f"Java formatting error on {filename}:\n{err_msg}"] | |
| if not fix_formatting: | |
| diff_string = google_java_format_diff.create_diff(filename, stdout) | |
| if diff_string: | |
| return [diff_string] | |
| return [] | |
| def segment_java_files(files): | |
| full_files = [] | |
| partial_files = [] | |
| for file_path in files: | |
| enabled_ranges = get_java_enabled_ranges(file_path) | |
| if enabled_ranges is None: | |
| full_files.append(file_path) | |
| elif enabled_ranges: | |
| partial_files.append((file_path, enabled_ranges)) | |
| return full_files, partial_files | |
| def apply_batch_java_formatting(files, base_command): | |
| returncode, _, stderr = run_command(base_command + ['-i'] + files) | |
| if returncode != 0: | |
| err_msg = stderr if stderr else "Unknown error" | |
| return [f"Java formatting error:\n{err_msg}"] | |
| return [] | |
| def check_batch_java_formatting(files, base_command): | |
| returncode, stdout, stderr = run_command( | |
| base_command + ['--dry-run', '--set-exit-if-changed'] + files) | |
| errors = [] | |
| if returncode != 0: | |
| if stdout: | |
| for file_path in stdout.splitlines(): | |
| errors.extend( | |
| format_java_file(file_path, base_command, None, False)) | |
| if stderr: | |
| errors.append(f"Java formatting error:\n{stderr}") | |
| elif not errors and not stdout: | |
| errors.append("Java formatting error:\nUnknown error") | |
| return errors | |
| # This function must be threadsafe. | |
| # prepare_java_files() must be called first. | |
| def format_batch_java_files(files, base_command, fix_formatting): | |
| if not files: | |
| return [] | |
| if fix_formatting: | |
| return apply_batch_java_formatting(files, base_command) | |
| else: | |
| return check_batch_java_formatting(files, base_command) | |
| def prepare_java_files(): | |
| utils.ensure_google_download(GOOGLE_JAVA_FORMAT_DIR) | |
| def prepare_kotlin_files(): | |
| utils.ensure_google_download(GOOGLE_KOTLIN_FORMAT_DIR) | |
| # This function must be threadsafe. | |
| # prepare_kotlin_files() must be called first. | |
| def format_kotlin_files(files, format_style, fix_formatting=False): | |
| if not files: | |
| return [] | |
| cmd = get_kotlin_format_base_command() + [format_style] | |
| if not fix_formatting: | |
| cmd.append('-n') | |
| cmd.extend(files) | |
| returncode, stdout, stderr = run_command(cmd) | |
| if returncode != 0: | |
| err_msg = stderr if stderr else "Unknown error" | |
| return [f"Kotlin formatting error:\n{err_msg}"] | |
| if not fix_formatting and stdout: | |
| return [ | |
| f"File {file_path} needs formatting" | |
| for file_path in stdout.splitlines() | |
| ] | |
| return [] | |
| def prepare_python_files(python_runtime: PythonRuntime = None): | |
| utils.ensure_google_download(GOOGLE_YAPF_DIR) | |
| if python_runtime: | |
| return python_runtime.initialize_runtime() | |
| return None | |
| # This function must be threadsafe. | |
| # prepare_python_files() must be called first. | |
| def format_python_files(files, | |
| python_runtime: PythonRuntime, | |
| fix_formatting=False): | |
| if not files: | |
| return [] | |
| mode_flag = '--in-place' if fix_formatting else '--diff' | |
| format_cmd = get_python_format_base_command(python_runtime) + [mode_flag | |
| ] + files | |
| returncode, stdout, stderr = run_command(format_cmd, | |
| env=get_env_with_python_path()) | |
| if returncode != 0: | |
| errors = [] | |
| if stdout: | |
| errors.append(stdout) | |
| if stderr: | |
| errors.append(f"Python formatting error:\n{stderr}") | |
| if not errors: | |
| errors.append("Python formatting error:\nUnknown error") | |
| return errors | |
| return [] | |
| def prepare_web_files(): | |
| utils.ensure_google_download(NODE_DIR) | |
| utils.ensure_google_download(PRETTIER_DIR) | |
| # This function must be threadsafe. | |
| # prepare_web_files() must be called first. | |
| def format_web_files(files, fix_formatting=False): | |
| if not files: | |
| return [] | |
| mode_flag = '--write' if fix_formatting else '--check' | |
| format_cmd = get_prettier_base_command() + [mode_flag] + files | |
| returncode, stdout, stderr = run_command(format_cmd) | |
| if returncode != 0: | |
| err_msg = stderr or stdout or "Unknown error" | |
| return [f"Web formatting error:\n{err_msg}"] | |
| return [] | |
| def check(files, fix_formatting=False, batch_size=50): | |
| java_files = [] | |
| kotlin_files = [] | |
| python_files = [] | |
| web_files = [] | |
| for file_path in files: | |
| if not os.path.exists(file_path) or not is_included_file(file_path): | |
| pass | |
| elif is_kotlin_file(file_path): | |
| if not is_kotlin_file_disabled(file_path): | |
| kotlin_files.append(file_path) | |
| elif is_java_file(file_path): | |
| java_files.append(file_path) | |
| elif is_python_file(file_path): | |
| python_files.append(file_path) | |
| elif is_web_file(file_path): | |
| web_files.append(file_path) | |
| results = { | |
| 'Java': [], | |
| 'Kotlin': [], | |
| 'Python': [], | |
| 'Web (JS, HTML, CSS)': [], | |
| } | |
| full_java_files = [] | |
| partial_java_files = [] | |
| java_base_command = None | |
| if java_files: | |
| prepare_java_files() | |
| java_base_command = get_java_format_base_command() | |
| full_java_files, partial_java_files = segment_java_files(java_files) | |
| if kotlin_files: | |
| prepare_kotlin_files() | |
| python_runtime = PythonRuntime() | |
| if python_files: | |
| init_error = prepare_python_files(python_runtime) | |
| if init_error: | |
| results['Python'].append(init_error) | |
| return results | |
| if web_files: | |
| prepare_web_files() | |
| futures = [] | |
| with ThreadPoolExecutor() as executor: | |
| for batch in chunk_list(full_java_files, batch_size): | |
| futures.append(('Java', | |
| executor.submit(format_batch_java_files, batch, | |
| java_base_command, fix_formatting))) | |
| for file_path, line_ranges in partial_java_files: | |
| futures.append( | |
| ('Java', | |
| executor.submit(format_java_file, file_path, java_base_command, | |
| line_ranges, fix_formatting))) | |
| for format_style, style_paths in partition_kotlin_files( | |
| kotlin_files).items(): | |
| for batch in chunk_list(style_paths, batch_size): | |
| futures.append(('Kotlin', | |
| executor.submit(format_kotlin_files, | |
| batch, | |
| format_style, | |
| fix_formatting=fix_formatting))) | |
| for batch in chunk_list(python_files, batch_size): | |
| futures.append(('Python', | |
| executor.submit(format_python_files, | |
| batch, | |
| python_runtime, | |
| fix_formatting=fix_formatting))) | |
| for batch in chunk_list(web_files, batch_size): | |
| futures.append(('Web (JS, HTML, CSS)', | |
| executor.submit(format_web_files, | |
| batch, | |
| fix_formatting=fix_formatting))) | |
| for lang, future in futures: | |
| errors = future.result() | |
| if errors: | |
| results[lang].extend(errors) | |
| return results | |
| def parse_options(): | |
| result = argparse.ArgumentParser() | |
| result.add_argument('paths', | |
| nargs='*', | |
| help='Files or directories to format.') | |
| result.add_argument('--check', | |
| help='Check formatting without modifying files.', | |
| action='store_true', | |
| default=False) | |
| result.add_argument( | |
| '--upstream', | |
| help= | |
| 'Upstream branch/commit to find changed files against (default: git cl upstream).', | |
| default=None) | |
| only_group = result.add_mutually_exclusive_group() | |
| only_group.add_argument('--only-java', | |
| help='Only run google-java-format.', | |
| action='store_true', | |
| default=False) | |
| only_group.add_argument('--only-kotlin', | |
| help='Only run google-kotlin-format.', | |
| action='store_true', | |
| default=False) | |
| only_group.add_argument('--only-python', | |
| help='Only run YAPF.', | |
| action='store_true', | |
| default=False) | |
| only_group.add_argument('--only-web', | |
| help='Only run Prettier.', | |
| action='store_true', | |
| default=False) | |
| return result.parse_args() | |
| def format_files(files, fix_formatting=True): | |
| results = check(files, fix_formatting=fix_formatting) | |
| has_errors = False | |
| for errors in results.values(): | |
| for err in errors: | |
| has_errors = True | |
| print(err, file=sys.stderr) | |
| return not has_errors | |
| def get_predicate(options): | |
| if options.only_java: | |
| return is_java_file | |
| elif options.only_kotlin: | |
| return is_kotlin_file | |
| elif options.only_python: | |
| return is_python_file | |
| elif options.only_web: | |
| return is_web_file | |
| return None | |
| def format_all_changed_files(upstream, options): | |
| predicate = get_predicate(options) | |
| changed_files = get_changed_files(upstream, predicate) | |
| if changed_files is None: | |
| return False | |
| return format_files(changed_files, fix_formatting=not options.check) | |
| def format_paths(paths, options): | |
| predicate = get_predicate(options) | |
| files = [] | |
| all_paths_ok = True | |
| for path in paths: | |
| path_files, ok = get_path_files(path, predicate) | |
| if not ok: | |
| all_paths_ok = False | |
| files.extend(path_files) | |
| formatted_ok = format_files(list(dict.fromkeys(files)), | |
| fix_formatting=not options.check) | |
| return all_paths_ok and formatted_ok | |
| def resolve_cli_path(path): | |
| rel = get_repo_rel_path(path) | |
| if rel.startswith('..'): | |
| return os.path.abspath(path) | |
| return rel | |
| def main(): | |
| options = parse_options() | |
| if options.paths and options.upstream: | |
| print("Error: Cannot specify both paths and --upstream.", | |
| file=sys.stderr) | |
| return 1 | |
| resolved_paths = [resolve_cli_path(p) for p in options.paths] | |
| os.chdir(defines.REPO_ROOT) | |
| if resolved_paths: | |
| ok = format_paths(resolved_paths, options) | |
| else: | |
| upstream = options.upstream or get_git_upstream() | |
| if not upstream: | |
| return 1 | |
| ok = format_all_changed_files(upstream, options) | |
| return 0 if ok else 1 | |
| if __name__ == '__main__': | |
| sys.exit(main()) |