| #!/usr/bin/env python3 |
| # |
| # Copyright (C) 2023 The Android Open Source Project |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| |
| import argparse |
| import os |
| import re |
| import subprocess |
| import sys |
| import shutil |
| from typing import Optional |
| |
| # Color codes |
| RED = '\033[0;31m' |
| GREEN = '\033[0;32m' |
| YELLOW = '\033[0;33m' |
| NC = '\033[0m' # No Color |
| |
| # Constants for paths. Assumes the script is run from the repository root. |
| SETUP_PY_PATH = 'python/setup.py' |
| VENV_PYTHON = os.path.abspath('.venv/bin/python') |
| |
| |
| def info(msg: str) -> None: |
| print(f"{GREEN}INFO:{NC} {msg}") |
| |
| |
| def warn(msg: str) -> None: |
| print(f"{YELLOW}WARN:{NC} {msg}") |
| |
| |
| def error(msg: str) -> None: |
| print(f"{RED}ERROR:{NC} {msg}", file=sys.stderr) |
| sys.exit(1) |
| |
| |
| def prompt(msg: str) -> str: |
| return input(f"{YELLOW}ACTION:{NC} {msg}") |
| |
| |
| def confirm(msg: str) -> None: |
| reply = input(f"{msg} [y/N] ").lower().strip() |
| if reply not in ['y', 'yes']: |
| error("Aborted by user.") |
| |
| |
| def run_cmd(*args: str, check: bool = True, cwd: Optional[str] = None) -> None: |
| info(f"Running command: {' '.join(args)}") |
| subprocess.run(args, check=check, cwd=cwd) |
| |
| |
| def check_git_clean() -> None: |
| """Checks if the git working directory is clean.""" |
| info("Checking for clean git working directory...") |
| try: |
| subprocess.check_call(['git', 'diff', '--quiet']) |
| subprocess.check_call(['git', 'diff', '--cached', '--quiet']) |
| except subprocess.CalledProcessError: |
| error("Git working directory is not clean. Please commit or stash changes.") |
| info("Git working directory is clean.") |
| |
| |
| def get_current_branch() -> str: |
| """Returns the current git branch.""" |
| return subprocess.check_output(['git', 'rev-parse', '--abbrev-ref', |
| 'HEAD']).decode('utf-8').strip() |
| |
| |
| def get_setup_py_content() -> str: |
| with open(SETUP_PY_PATH, 'r') as f: |
| return f.read() |
| |
| |
| def write_setup_py_content(content: str) -> None: |
| with open(SETUP_PY_PATH, 'w') as f: |
| f.write(content) |
| |
| |
| def get_current_version(content: str) -> str: |
| """Extracts version from setup.py using a robust regex.""" |
| match = re.search(r"version\s*=\s*'([^']*)'", content) |
| if not match: |
| error(f"Could not find version in {SETUP_PY_PATH}") |
| sys.exit(1) # Unreachable, but satisfies type checker |
| return match.group(1) |
| |
| |
| def bump_version() -> None: |
| """Stage 1: Creates a commit with a bumped version number.""" |
| info("--- Stage 1: Bumping version ---") |
| content = get_setup_py_content() |
| current_version = get_current_version(content) |
| info(f"Current version is {current_version}") |
| |
| new_version = prompt("Enter the new version (e.g., X.Y.Z): ") |
| if not re.match(r'\d+\.\d+\.\d+', new_version): |
| error("Invalid version format. Please use 'X.Y.Z'.") |
| |
| branch_name = prompt("Enter a name for the new release branch: ") |
| run_cmd('git', 'checkout', '-b', branch_name) |
| |
| info(f"Updating version in {SETUP_PY_PATH} to {new_version}...") |
| new_content = re.sub(r"version\s*=\s*'[^']*'", f"version='{new_version}'", |
| content) |
| write_setup_py_content(new_content) |
| |
| run_cmd('git', 'add', SETUP_PY_PATH) |
| run_cmd('git', 'commit', '-m', |
| f'perfetto(python): Bump version to {new_version}') |
| |
| info(f"Version bump commit created on branch '{branch_name}'.") |
| info("Please push this branch, create a pull request, and wait for it to " |
| "be landed.") |
| |
| |
| def publish(commit: str) -> None: |
| """Stage 2: Publishes the package and creates a commit to update the URL.""" |
| info(f"--- Stage 2: Publishing release for commit {commit} ---") |
| |
| original_branch = get_current_branch() |
| info(f"Checking out commit {commit}...") |
| run_cmd('git', 'checkout', commit) |
| |
| # Read the original content of setup.py at the release commit. |
| content_at_commit = get_setup_py_content() |
| new_version = get_current_version(content_at_commit) |
| download_url = f"'https://github.com/google/perfetto/archive/{commit}.zip'" |
| |
| # Temporarily update download_url just for building the package. |
| info(f"Temporarily setting download_url to {download_url}") |
| temp_content = re.sub(r"download_url\s*=\s*'[^']*'", |
| f"download_url={download_url}", content_at_commit) |
| write_setup_py_content(temp_content) |
| |
| try: |
| info("Installing build dependencies into the virtual environment...") |
| run_cmd(VENV_PYTHON, '-m', 'pip', 'install', 'build', 'twine') |
| |
| info("Building python package...") |
| run_cmd(VENV_PYTHON, '-m', 'build', cwd='python') |
| |
| confirm("Ready to upload to PyPI. This is not reversible. Continue?") |
| run_cmd( |
| VENV_PYTHON, |
| '-m', |
| 'twine', |
| 'upload', |
| 'dist/*', |
| '--verbose', |
| cwd='python') |
| info("Successfully published to PyPI.") |
| |
| finally: |
| # Always clean up and restore the repository to its original state. |
| info("Cleaning up build artifacts...") |
| shutil.rmtree('python/dist', ignore_errors=True) |
| shutil.rmtree('python/perfetto.egg-info', ignore_errors=True) |
| |
| info(f"Restoring {SETUP_PY_PATH} to its state at commit {commit}...") |
| write_setup_py_content(content_at_commit) |
| |
| info(f"Returning to original branch '{original_branch}'...") |
| run_cmd('git', 'checkout', original_branch) |
| |
| # After successful publishing, create the final commit with the download_url. |
| info("--- Creating final commit to update download_url ---") |
| final_branch_name = prompt( |
| "Enter a name for the final download_url update branch: ") |
| run_cmd('git', 'checkout', '-b', final_branch_name) |
| |
| info(f"Updating download_url permanently in {SETUP_PY_PATH}...") |
| final_content = get_setup_py_content() |
| final_content = re.sub(r"download_url\s*=\s*'[^']*'", |
| f"download_url={download_url}", final_content) |
| write_setup_py_content(final_content) |
| |
| run_cmd('git', 'add', SETUP_PY_PATH) |
| run_cmd('git', 'commit', '-m', |
| f'perfetto(python): Update download_url for v{new_version}') |
| |
| info(f"Commit for download_url created on branch '{final_branch_name}'.") |
| info("Please push this branch and create a pull request to complete the " |
| "release.") |
| |
| |
| def main() -> None: |
| parser = argparse.ArgumentParser( |
| description="Automates the Perfetto Python library release process.") |
| parser.add_argument( |
| '--bump-version', |
| action='store_true', |
| help="Stage 1: Bump version and create a release CL.") |
| parser.add_argument( |
| '--publish', |
| action='store_true', |
| help="Stage 2: Publish the release to PyPI.") |
| parser.add_argument( |
| '--commit', |
| metavar='HASH', |
| help="The landed commit hash of the version bump CL (for --publish).") |
| |
| args = parser.parse_args() |
| |
| # Ensure script is run from the repository root. |
| if not os.path.exists(SETUP_PY_PATH): |
| error(f"This script must be run from the root of the " |
| f"perfetto repository.") |
| |
| # Ensure the virtual environment exists. |
| if not os.path.exists(VENV_PYTHON): |
| error(f"This script requires a virtual environment at '{VENV_PYTHON}'.") |
| |
| check_git_clean() |
| |
| if args.bump_version: |
| if args.publish or args.commit: |
| parser.error("--bump-version cannot be used with --publish or --commit.") |
| bump_version() |
| elif args.publish: |
| if not args.commit: |
| parser.error("--publish requires --commit.") |
| publish(args.commit) |
| else: |
| parser.print_help() |
| |
| |
| if __name__ == '__main__': |
| main() |