diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f121cac2ad..54f92ab876 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -84,6 +84,7 @@ repos: - --per-file-ignores=tests/*.py:T201 # prints are allowed in test files # Ignore errors for generated protocol buffer stubs - --exclude=src/snowflake/snowpark/_internal/proto/generated/ast_pb2.py + - --exclude=scripts/release/prepare_release_branch.py # Use mypy for static type checking. - repo: https://github.com/pre-commit/mirrors-mypy rev: 'v0.991' diff --git a/scripts/release/prepare_release_branch.py b/scripts/release/prepare_release_branch.py new file mode 100755 index 0000000000..2d78221900 --- /dev/null +++ b/scripts/release/prepare_release_branch.py @@ -0,0 +1,380 @@ +# +# Copyright (c) 2012-2025 Snowflake Computing Inc. All rights reserved. +# + +import os +import sys +import re +import subprocess +import argparse +from datetime import date, datetime +import glob + + +def setup_argument_parser(): + """Set up command line argument parser""" + parser = argparse.ArgumentParser( + description="Snowpark Python Release Preparation Script", + epilog=""" +Examples: + # Interactive mode (no arguments - prompts for input) + python prepare_release_branch.py + + # Non-interactive mode (any arguments provided - requires --version) + python prepare_release_branch.py --version 1.39.0 --release-date 2024-12-31 --base-ref origin/main + + # Non-interactive mode with minimal options (uses defaults for date and base-ref) + python prepare_release_branch.py --version 1.39.0 + + # Use a specific commit as base + python prepare_release_branch.py --version 1.39.0 --base-ref abc1234 + + # Use custom release date with version + python prepare_release_branch.py --version 1.39.0 --release-date 2024-12-31 + """, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + parser.add_argument( + "--version", + help="Release version in format x.y.z (e.g., 1.39.0). Required in non-interactive mode.", + type=str, + ) + + parser.add_argument( + "--release-date", + help="Release date in format YYYY-MM-DD (default: today)", + type=str, + ) + + parser.add_argument( + "--base-ref", + help="Base reference for release branch - commit ID, branch name, or origin/main (default: origin/main)", + type=str, + default="origin/main", + ) + + return parser + + +def check_snowpark_directory(): + """Check if we're in a snowpark-python directory""" + current_dir = os.getcwd() + if "snowpark-python" not in current_dir: + print("Error: This script must be run from within a snowpark-python directory") + sys.exit(1) + + +def validate_version(version_str): + """Validate version format and return parsed components""" + if not version_str: + return None + + # Validate version format (x.y.z) + if not re.match(r"^\d+\.\d+\.\d+$", version_str): + print("Error: Version must be in format x.y.z (e.g., 1.39.0)") + sys.exit(1) + + parts = version_str.split(".") + major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2]) + + return version_str, major, minor, patch + + +def validate_date(date_str): + """Validate date format and return formatted date""" + if not date_str: + return date.today().strftime("%Y-%m-%d") + + # Validate date format (YYYY-MM-DD) + if not re.match(r"^\d{4}-\d{2}-\d{2}$", date_str): + print("Error: Date must be in format YYYY-MM-DD (e.g., 2024-12-31)") + sys.exit(1) + + # Validate that it's a valid date + try: + datetime.strptime(date_str, "%Y-%m-%d") + except ValueError: + print("Error: Invalid date. Please enter a valid date in format YYYY-MM-DD") + sys.exit(1) + + return date_str + + +def validate_base_ref(base_ref): + """Validate and format base reference""" + if not base_ref or base_ref == "origin/main": + return "origin/main", "origin/main" + elif re.match(r"^[a-fA-F0-9]{7,40}$", base_ref): + return base_ref, f"commit {base_ref}" + else: + return base_ref, f"branch {base_ref}" + + +def get_version_input(args=None, is_interactive=True): + """Get version input from CLI args or user and validate format""" + if args and args.version: + return validate_version(args.version) + + if not is_interactive: + # This shouldn't happen as we validate --version is required in non-interactive mode + print("Error: Version is required in non-interactive mode") + sys.exit(1) + + print("Enter the next release version (format: x.y.z, e.g., 1.39.0)") + version_str = input("Version: ").strip() + return validate_version(version_str) + + +def get_base_reference(args=None, is_interactive=True): + """Get optional base commit/branch for the release branch from CLI args or user input""" + if args and args.base_ref: + return validate_base_ref(args.base_ref) + + if not is_interactive: + # Use default value in non-interactive mode + return validate_base_ref("origin/main") + + print("\nOptional: Specify a base for the release branch") + print("- Enter a commit ID (e.g., abc1234)") + print("- Enter a local branch name (e.g., feature-branch)") + print("- Press Enter to use origin/main (default)") + base_ref = input("Base reference (default: origin/main): ").strip() + + return validate_base_ref(base_ref) + + +def get_release_date_input(args=None, is_interactive=True): + """Get optional release date from CLI args or user input and validate format""" + if args and args.release_date: + return validate_date(args.release_date) + + if not is_interactive: + # Use today's date as default in non-interactive mode + return validate_date(None) # None will default to today + + print("\nOptional: Specify a release date (format: YYYY-MM-DD)") + print("- Press Enter to use today's date (default)") + date_str = input("Release date (default: today): ").strip() + + return validate_date(date_str) + + +def run_git_command(command): + """Run git command and handle errors""" + try: + result = subprocess.run( + command, shell=True, check=True, capture_output=True, text=True + ) + return result.stdout + except subprocess.CalledProcessError as e: + print(f"Git command failed: {command}") + print(f"Error: {e.stderr}") + sys.exit(1) + + +def update_version_py(version_str, major, minor, patch): + """Update src/snowflake/snowpark/version.py""" + version_file = "src/snowflake/snowpark/version.py" + + if not os.path.exists(version_file): + print(f"Error: {version_file} not found") + sys.exit(1) + + with open(version_file) as f: + content = f.read() + + # Replace VERSION tuple + new_content = re.sub( + r"VERSION = \(\d+, \d+, \d+\)", + f"VERSION = ({major}, {minor}, {patch})", + content, + ) + + with open(version_file, "w") as f: + f.write(new_content) + + print(f"āœ“ Updated {version_file}") + + +def update_meta_yaml(version_str): + """Update recipe/meta.yaml""" + meta_file = "recipe/meta.yaml" + + if not os.path.exists(meta_file): + print(f"Error: {meta_file} not found") + sys.exit(1) + + with open(meta_file) as f: + content = f.read() + + # Replace version string + new_content = re.sub( + r'{% set version = "[^"]*" %}', + f'{{% set version = "{version_str}" %}}', + content, + ) + + with open(meta_file, "w") as f: + f.write(new_content) + + print(f"āœ“ Updated {meta_file}") + + +def update_changelog(version_str, release_date): + """Update CHANGELOG.md by replacing the version date with the specified date""" + changelog_file = "CHANGELOG.md" + + if not os.path.exists(changelog_file): + print(f"Error: {changelog_file} not found") + sys.exit(1) + + with open(changelog_file) as f: + content = f.read() + + # Find the first version line and replace it with the new version and specified date + # Pattern matches the first: ## x.y.z (any_date) + first_version_pattern = r"## \d+\.\d+\.\d+ \([^)]+\)" + replacement = f"## {version_str} ({release_date})" + + new_content = re.sub(first_version_pattern, replacement, content, count=1) + + if new_content == content: + print(f"Warning: No version entry found in {changelog_file}") + else: + with open(changelog_file, "w") as f: + f.write(new_content) + print(f"āœ“ Updated {changelog_file}") + + +def update_test_files(major, minor, patch): + """Update all .test and .test.DISABLED files in tests/ast/data""" + test_dir = "tests/ast/data" + + if not os.path.exists(test_dir): + print(f"Error: {test_dir} not found") + sys.exit(1) + + # Get both .test and .test.DISABLED files + test_files = glob.glob(os.path.join(test_dir, "*.test")) + test_files.extend(glob.glob(os.path.join(test_dir, "*.test.DISABLED"))) + + if not test_files: + print(f"Warning: No .test or .test.DISABLED files found in {test_dir}") + return + + # Determine client_version format based on patch version + if patch == 0: + # Major/minor release - only major and minor fields + client_version_replacement = f"""client_version {{ + major: {major} + minor: {minor} +}}""" + else: + # Patch release - major, minor, and patch fields + client_version_replacement = f"""client_version {{ + major: {major} + minor: {minor} + patch: {patch} +}}""" + + updated_count = 0 + + for test_file in test_files: + with open(test_file) as f: + content = f.read() + + # Replace client_version blocks + # This regex handles both formats (with and without patch) + pattern = r"client_version\s*\{\s*major:\s*\d+\s*minor:\s*\d+\s*(?:patch:\s*\d+\s*)?\}" + + if re.search(pattern, content): + new_content = re.sub(pattern, client_version_replacement, content) + + if new_content != content: + with open(test_file, "w") as f: + f.write(new_content) + updated_count += 1 + + print(f"āœ“ Updated {updated_count} .test and .test.DISABLED files in {test_dir}") + + +def main(): + # Parse command line arguments + parser = setup_argument_parser() + args = parser.parse_args() + + # Determine if we're in interactive or non-interactive mode + # Non-interactive mode: any command-line arguments provided + # Interactive mode: no command-line arguments (just script name) + is_interactive = len(sys.argv) == 1 + + # In non-interactive mode, --version is required + if not is_interactive and not args.version: + print("Error: --version is required when using non-interactive mode") + sys.exit(1) + + if is_interactive: + print("Snowpark Python Release Preparation Script") + print("==========================================") + else: + print("Snowpark Python Release Preparation Script (Non-Interactive Mode)") + print("==================================================================") + + # Check if we're in the right directory + check_snowpark_directory() + + # Get version input + version_result = get_version_input(args, is_interactive) + if not version_result: + print("Error: Version is required") + sys.exit(1) + version_str, major, minor, patch = version_result + + # Get release date + release_date = get_release_date_input(args, is_interactive) + if is_interactive or args.release_date: + print(f"\nRelease date: {release_date}") + + print(f"\nPreparing release for version {version_str}") + print(f"Major: {major}, Minor: {minor}, Patch: {patch}") + + is_patch_release = patch != 0 + print(f"Release type: {'Patch' if is_patch_release else 'Major/Minor'}") + + # Get base reference for release branch + base_ref, base_description = get_base_reference(args, is_interactive) + + # Checkout release branch + branch_name = f"release-v{version_str}" + print(f"\nšŸ”€ Creating release branch: {branch_name} from {base_description}") + + if base_ref == "origin/main": + # Fetch origin/main and create branch from it + print("Fetching latest origin/main...") + run_git_command("git fetch origin main") + run_git_command(f"git checkout -b {branch_name} origin/main") + else: + # Create branch from specified commit or branch + run_git_command(f"git checkout -b {branch_name} {base_ref}") + + print("\nšŸ“ Updating version files...") + + # Update files + update_version_py(version_str, major, minor, patch) + update_meta_yaml(version_str) + update_changelog(version_str, release_date) + update_test_files(major, minor, patch) + + print( + f"\nšŸŽ‰ Release preparation completed successfully!" + f"\n\nNext steps:" + f"\n1. Sync the dependency updates in version.py and meta.yaml if they are inconsistent" + f"\n2. Review the changes with: git diff" + f"\n3. Commit the changes with: git commit -am 'Prepare release v{version_str}'" + f"\n4. Push the branch with: git push origin {branch_name}" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/release/readme.md b/scripts/release/readme.md new file mode 100644 index 0000000000..d48be115c7 --- /dev/null +++ b/scripts/release/readme.md @@ -0,0 +1,358 @@ +# Snowpark Python Release Preparation Script + +This script automates the process of preparing a release branch for Snowpark Python. It supports both **interactive** and **non-interactive** modes, making it perfect for both manual releases and CI/CD automation. The script handles version updates, changelog management, and test file modifications in a single automated workflow. + +## What It Does + +The `prepare_release_branch.py` script performs the following tasks: + +1. **Validates Environment**: Ensures you're running from within a snowpark-python directory +2. **Mode Detection**: Automatically detects interactive vs non-interactive mode based on command-line arguments +3. **Creates Release Branch**: Creates a new git branch from a specified base (commit, branch, or origin/main) +4. **Updates Version Files**: + - Updates `src/snowflake/snowpark/version.py` with the new VERSION tuple + - Updates `recipe/meta.yaml` with the new version string +5. **Updates Changelog**: Replaces the first version entry in `CHANGELOG.md` with the new version and specified/today's date +6. **Updates Test Files**: Updates all `.test` and `.test.DISABLED` files in `tests/ast/data/` with the correct `client_version` format + +## Prerequisites + +- You must be in a snowpark-python project directory +- Git must be available and the repository must be initialized +- Python 3.x with standard library modules + +## Usage + +The script supports both **interactive** and **non-interactive** modes with automatic detection: + +- **Interactive Mode**: Run without arguments - prompts for all inputs +- **Non-Interactive Mode**: Provide any command-line arguments - uses CLI values and smart defaults + +### Interactive Mode + +```bash +python ./scripts/release/prepare_release_branch.py +``` + +### Non-Interactive Mode + +```bash +# View help and examples +python ./scripts/release/prepare_release_branch.py --help + +# Minimal usage (uses today's date and origin/main as defaults) +python ./scripts/release/prepare_release_branch.py --version 1.40.0 + +# Full customization +python ./scripts/release/prepare_release_branch.py --version 1.40.0 --release-date 2025-01-15 --base-ref origin/main + +# Use specific commit as base +python ./scripts/release/prepare_release_branch.py --version 1.40.0 --base-ref abc1234 + +# Custom date only (still requires --version in non-interactive mode) +python ./scripts/release/prepare_release_branch.py --version 1.40.0 --release-date 2025-12-31 +``` + +### Command-Line Arguments + +- `--version VERSION`: Release version in format x.y.z (e.g., 1.39.0). **Required in non-interactive mode**. +- `--release-date RELEASE_DATE`: Release date in format YYYY-MM-DD (default: today) +- `--base-ref BASE_REF`: Base reference for release branch - commit ID, branch name, or origin/main (default: origin/main) +- `--help`: Show help message with examples + +### Interactive Mode Prompts + +When running in interactive mode (no arguments), you'll be prompted for: + +#### 1. Version Input +``` +Enter the next release version (format: x.y.z, e.g., 1.39.0) +Version: _ +``` +- Enter the version in `x.y.z` format (e.g., `1.40.0`, `2.1.3`) +- The script validates the format and determines if it's a patch release (patch > 0) or major/minor release (patch = 0) + +#### 2. Release Date (Optional) +``` +Optional: Specify a release date (format: YYYY-MM-DD) +- Press Enter to use today's date (default) +Release date (default: today): _ +``` + +#### 3. Base Reference (Optional) +``` +Optional: Specify a base for the release branch +- Enter a commit ID (e.g., abc1234) +- Enter a local branch name (e.g., feature-branch) +- Press Enter to use origin/main (default) +Base reference (default: origin/main): _ +``` + +**Options:** +- **Press Enter**: Uses `origin/main` (fetches latest and creates branch from it) +- **Commit ID**: Enter a 7-40 character hex string (e.g., `abc1234`, `1a2b3c4d5e6f7890`) +- **Branch Name**: Enter any local branch name (e.g., `feature-branch`, `bugfix-123`) + +## Examples + +### Interactive Mode Examples + +#### Example 1: Standard Release from origin/main +```bash +./scripts/release/prepare_release_branch.py + +# Prompts: +Version: 1.40.0 +Release date (default: today): [Press Enter] +Base reference (default: origin/main): [Press Enter] + +# Creates: release-v1.40.0 branch from latest origin/main with today's date +``` + +#### Example 2: Patch Release from Specific Commit +```bash +./scripts/release/prepare_release_branch.py + +# Prompts: +Version: 1.39.1 +Release date (default: today): [Press Enter] +Base reference (default: origin/main): abc123def + +# Creates: release-v1.39.1 branch from commit abc123def with today's date +``` + +#### Example 3: Release from Feature Branch with Custom Date +```bash +./scripts/release/prepare_release_branch.py + +# Prompts: +Version: 2.0.0 +Release date (default: today): 2025-12-31 +Base reference (default: origin/main): feature-new-api + +# Creates: release-v2.0.0 branch from feature-new-api branch with specified date +``` + +### Non-Interactive Mode Examples + +#### Example 4: Quick Release (Minimal Arguments) +```bash +./scripts/release/prepare_release_branch.py --version 1.40.0 + +# Uses defaults: +# - Release date: today's date +# - Base reference: origin/main +# Creates: release-v1.40.0 branch from latest origin/main with today's date +``` + +#### Example 5: Full Automation (All Arguments) +```bash +./scripts/release/prepare_release_branch.py \ + --version 1.40.0 \ + --release-date 2025-01-15 \ + --base-ref origin/main + +# Creates: release-v1.40.0 branch from origin/main with specified date +``` + +#### Example 6: CI/CD Pipeline Usage +```bash +# Perfect for automation and CI/CD +./scripts/release/prepare_release_branch.py \ + --version 1.39.1 \ + --base-ref abc123def + +# Uses today's date, creates release-v1.39.1 from specific commit +``` + +## What Gets Updated + +### 1. Version Files +- **`src/snowflake/snowpark/version.py`**: Updates the `VERSION = (major, minor, patch)` tuple +- **`recipe/meta.yaml`**: Updates the `{% set version = "x.y.z" %}` line + +### 2. Changelog +- **`CHANGELOG.md`**: Finds the first version entry and updates it with the new version and today's date +- Example: `## 1.39.0 (YYYY-MM-DD)` → `## 1.40.0 (2025-09-10)` + +### 3. AST Test Files +Updates all `.test` and `.test.DISABLED` files in `tests/ast/data/` with the correct `client_version` format: + +**For Major/Minor Releases (patch = 0):** +``` +client_version { + major: 1 + minor: 40 +} +``` + +**For Patch Releases (patch > 0):** +``` +client_version { + major: 1 + minor: 39 + patch: 1 +} +``` + +## Sample Output + +### Interactive Mode Output +``` +Snowpark Python Release Preparation Script +========================================== + +Enter the next release version (format: x.y.z, e.g., 1.39.0) +Version: 1.40.0 + +Optional: Specify a release date (format: YYYY-MM-DD) +- Press Enter to use today's date (default) +Release date (default: today): + +Release date: 2025-09-12 + +Preparing release for version 1.40.0 +Major: 1, Minor: 40, Patch: 0 +Release type: Major/Minor + +Optional: Specify a base for the release branch +- Enter a commit ID (e.g., abc1234) +- Enter a local branch name (e.g., feature-branch) +- Press Enter to use origin/main (default) +Base reference (default: origin/main): + +šŸ”€ Creating release branch: release-v1.40.0 from origin/main +Fetching latest origin/main... + +šŸ“ Updating version files... +āœ“ Updated src/snowflake/snowpark/version.py +āœ“ Updated recipe/meta.yaml +āœ“ Updated CHANGELOG.md +āœ“ Updated 124 .test and .test.DISABLED files in tests/ast/data + +šŸŽ‰ Release preparation completed successfully! + +Next steps: +1. Sync the dependency updates in version.py and meta.yaml if they are inconsistent +2. Review the changes with: git diff +3. Commit the changes with: git commit -am 'Prepare release v1.40.0' +4. Push the branch with: git push origin release-v1.40.0 +``` + +### Non-Interactive Mode Output +``` +Snowpark Python Release Preparation Script (Non-Interactive Mode) +================================================================== + +Preparing release for version 1.40.0 +Major: 1, Minor: 40, Patch: 0 +Release type: Major/Minor + +šŸ”€ Creating release branch: release-v1.40.0 from origin/main +Fetching latest origin/main... + +šŸ“ Updating version files... +āœ“ Updated src/snowflake/snowpark/version.py +āœ“ Updated recipe/meta.yaml +āœ“ Updated CHANGELOG.md +āœ“ Updated 124 .test and .test.DISABLED files in tests/ast/data + +šŸŽ‰ Release preparation completed successfully! + +Next steps: +1. Sync the dependency updates in version.py and meta.yaml if they are inconsistent +2. Review the changes with: git diff +3. Commit the changes with: git commit -am 'Prepare release v1.40.0' +4. Push the branch with: git push origin release-v1.40.0 +``` + +### Help Output +```bash +$ python ./scripts/release/prepare_release_branch.py --help + +usage: prepare_release_branch.py [-h] [--version VERSION] + [--release-date RELEASE_DATE] + [--base-ref BASE_REF] + +Snowpark Python Release Preparation Script + +options: + -h, --help show this help message and exit + --version VERSION Release version in format x.y.z (e.g., 1.39.0). + Required in non-interactive mode. + --release-date RELEASE_DATE + Release date in format YYYY-MM-DD (default: today) + --base-ref BASE_REF Base reference for release branch - commit ID, branch + name, or origin/main (default: origin/main) + +Examples: + # Interactive mode (no arguments - prompts for input) + python prepare_release_branch.py + + # Non-interactive mode (any arguments provided - requires --version) + python prepare_release_branch.py --version 1.39.0 --release-date 2024-12-31 --base-ref origin/main + + # Non-interactive mode with minimal options (uses defaults for date and base-ref) + python prepare_release_branch.py --version 1.39.0 + + # Use a specific commit as base + python prepare_release_branch.py --version 1.39.0 --base-ref abc1234 + + # Use custom release date with version + python prepare_release_branch.py --version 1.39.0 --release-date 2024-12-31 +``` + +## Error Handling + +The script will exit with an error if: +- Not run from a snowpark-python directory +- Invalid version format provided (must be x.y.z format) +- Invalid date format provided (must be YYYY-MM-DD format) +- Invalid date value provided (e.g., 2025-13-45) +- `--version` is missing when using non-interactive mode (any CLI arguments provided) +- Required files are missing (`version.py`, `meta.yaml`, `CHANGELOG.md`) +- Git commands fail (e.g., branch already exists, uncommitted changes, invalid base reference) +- Test directory doesn't exist (`tests/ast/data/`) + +## Next Steps After Running + +After the script completes successfully: + +1. **Review Changes**: `git diff` +2. **Commit Changes**: `git commit -am 'Prepare release v1.40.0'` +3. **Push Branch**: `git push origin release-v1.40.0` +4. Create pull request for the release branch +5. Follow your standard release process + +## Notes + +### Mode Detection +- **Interactive mode**: Triggered when no command-line arguments are provided +- **Non-interactive mode**: Automatically triggered when any CLI arguments are provided +- No need to specify `--non-interactive` flag - the script detects mode automatically + +### Smart Defaults (Non-Interactive Mode) +- **Release date**: Uses today's date if not specified +- **Base reference**: Uses `origin/main` if not specified +- **Version**: Always required in non-interactive mode + +### Technical Details +- The script uses only Python standard library modules (no external dependencies) +- All print statement linter warnings are suppressed with `# noqa: T201` +- The script creates a new branch and doesn't modify your current working branch until checkout +- If a release branch with the same name already exists, git will show an error +- Perfect for CI/CD automation with the non-interactive mode + +### Automation-Friendly +The non-interactive mode makes this script perfect for: +- **CI/CD Pipelines**: Automated release preparation +- **Batch Processing**: Multiple releases with different parameters +- **Shell Scripts**: Integration with larger automation workflows +- **Scheduled Releases**: Cron jobs or scheduled tasks + +## Future Improvements + +- Auto sync dependency updates +- Use Python based unparser to update ast code +- Support for pre-release versions (e.g., 1.40.0-rc1) +- Integration with GitHub Actions for automated PRs