Skip to content

Replace release.sh with Python implementation using Typer and loguru#286

Open
Copilot wants to merge 34 commits intomainfrom
copilot/replace-release-script-with-python
Open

Replace release.sh with Python implementation using Typer and loguru#286
Copilot wants to merge 34 commits intomainfrom
copilot/replace-release-script-with-python

Conversation

Copy link
Contributor

Copilot AI commented Jan 11, 2026

Replace release.sh with Python Script

Completed:

  • Replace shell script with Python implementation
  • All functionality preserved (tag creation, validation, prompts)
  • All 6 tests passing
  • Code review and security scan passed
  • Replace argparse with typer interface
  • Replace all sys.exit() with typer.Exit() for consistency
  • Clean up imports and remove duplicate help text
  • Add --dry-run option
  • Improve documentation clarity
  • Refactor for reduced code complexity (CodeFactor: 10.00/10)
  • Fix error message consistency
  • Replace custom color printing with loguru logger

The release script now has improved code quality with reduced complexity, consistent error messaging, and professional logging using loguru. CodeFactor rating: 10.00/10.

Logging

The script uses loguru for all output with automatic colorization:

  • logger.error() for error messages (red)
  • logger.warning() for warnings (yellow)
  • logger.info() for informational messages (white/blue)
  • logger.success() for success messages (green)

This provides cleaner code without manual color management and follows industry-standard logging practices.

Dependencies

  • typer==0.21.1 - Modern CLI framework with auto-generated help and shell completion
  • loguru==0.7.3 - Professional logging with automatic colorization
Original prompt

This section details on the original issue you should resolve

<issue_title>replace release.sh with a Python script</issue_title>
<issue_description></issue_description>

Comments on the Issue (you are @copilot in this section)


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

Copilot AI self-assigned this Jan 11, 2026
Copilot AI and others added 4 commits January 11, 2026 18:36
Co-authored-by: tschm <2046079+tschm@users.noreply.github.com>
Co-authored-by: tschm <2046079+tschm@users.noreply.github.com>
Co-authored-by: tschm <2046079+tschm@users.noreply.github.com>
Co-authored-by: tschm <2046079+tschm@users.noreply.github.com>
@tschm tschm marked this pull request as ready for review January 11, 2026 18:41
Co-authored-by: tschm <2046079+tschm@users.noreply.github.com>
Co-authored-by: tschm <2046079+tschm@users.noreply.github.com>
Copilot AI changed the title [WIP] Replace release.sh with a Python script Replace release.sh with Python implementation Jan 11, 2026
Copilot AI requested a review from tschm January 11, 2026 18:44
@tschm
Copy link
Member

tschm commented Jan 11, 2026

@copilot: use a typer interface at main

Co-authored-by: tschm <2046079+tschm@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Jan 11, 2026

@copilot: use a typer interface at main

Done in 17bf1f6. Replaced argparse with typer interface. The script now uses typer.Typer() with a decorated @app.command() main function. Added typer to .rhiza/requirements/tools.txt and updated Makefile to use uv run --with typer for execution.

Copilot AI and others added 3 commits January 11, 2026 18:57
Co-authored-by: tschm <2046079+tschm@users.noreply.github.com>
Signed-off-by: Thomas Schmelzer <thomas.schmelzer@gmail.com>
Co-authored-by: tschm <2046079+tschm@users.noreply.github.com>
Copilot AI changed the title Replace release.sh with Python implementation Replace release.sh with Python implementation using Typer Jan 11, 2026
@tschm
Copy link
Member

tschm commented Jan 12, 2026

@copilot address the codefactor issue

Co-authored-by: tschm <2046079+tschm@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Jan 12, 2026

@copilot address the codefactor issue

Done in a5b835d. Refactored do_release function by extracting helper functions to reduce complexity. The function had 18 branches (limit: 12) and 58 statements (limit: 50). Now split into smaller, focused functions: _validate_branch, _check_tag_status, _create_tag_step, _push_tag_step, _show_dry_run_summary, and _show_success_message. CodeFactor rating improved from 9.91/10 to 10.00/10.

Co-authored-by: tschm <2046079+tschm@users.noreply.github.com>
@tschm
Copy link
Member

tschm commented Jan 13, 2026

@HarryCampion: Please try to merge. Away now

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request replaces the shell script release.sh with a Python implementation release.py using the Typer CLI framework. The new implementation maintains all existing functionality including tag creation, validation checks, and prompts, while adding a new --dry-run option to preview changes without executing them.

Changes:

  • Replaced POSIX shell script with Python implementation using Typer for CLI interface
  • Added --dry-run flag for simulating releases without making changes
  • Updated all test files to run Python script instead of shell script
  • Removed obsolete tests for shell script executability

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
.rhiza/scripts/release.sh Removed the entire shell script implementation (235 lines deleted)
.rhiza/scripts/release.py New Python implementation with Typer CLI, all original functionality preserved plus dry-run support
tests/test_rhiza/test_release_script.py Updated to run Python script with sys.executable instead of shell
tests/test_rhiza/conftest.py Updated to copy release.py instead of release.sh and fixed comment reference
tests/test_rhiza/test_git_repo_fixture.py Removed obsolete tests checking for release.sh existence and executability

Comment on lines +434 to +438
# Show commits since last tag
last_tag = get_last_tag()
if last_tag and last_tag != tag:
commit_count = count_commits_since_tag(last_tag, "HEAD")
print(f"Commits since {last_tag}: {commit_count}")
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When an existing tag is being pushed (skip_tag_create is True), the commit count is calculated from last_tag to HEAD. However, the existing tag might not point to HEAD. It would be more accurate to count commits from last_tag to the tag being pushed. Consider passing the tag parameter to count_commits_since_tag(last_tag, tag) instead of using "HEAD", or document that this shows commits since the last tag to the current HEAD rather than to the tag being pushed.

Suggested change
# Show commits since last tag
last_tag = get_last_tag()
if last_tag and last_tag != tag:
commit_count = count_commits_since_tag(last_tag, "HEAD")
print(f"Commits since {last_tag}: {commit_count}")
# Show commits between the last tag and the tag being pushed
last_tag = get_last_tag()
if last_tag and last_tag != tag:
commit_count = count_commits_since_tag(last_tag, tag)
print(f"Commits from {last_tag} to {tag}: {commit_count}")

Copilot uses AI. Check for mistakes.
Comment on lines 55 to 63
def prompt_continue(message: str) -> bool:
"""Prompt user to continue with an operation.

Args:
message: Message to display to user (optional)

Returns:
True if user wants to continue, False otherwise
"""
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring states "message: Message to display to user (optional)" but the parameter is not actually optional - it has no default value. Either add a default value of "" or update the docstring to remove "(optional)".

Copilot uses AI. Check for mistakes.
answer = input().strip().lower()
if answer in ("y", "yes"):
return True
print_colored(Colors.YELLOW, "[INFO] Aborted by user")
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The prompt_continue function prints "Aborted by user" and returns False when the user declines, but the callers then raise typer.Exit(0) to exit. This creates redundant "Aborted by user" messages. Consider either removing the message from prompt_continue and letting callers handle the message, or having prompt_continue raise the Exit directly. The current pattern results in the message being printed even when the caller might want to handle the declined prompt differently.

Suggested change
print_colored(Colors.YELLOW, "[INFO] Aborted by user")

Copilot uses AI. Check for mistakes.
Comment on lines 100 to 118
def get_current_branch() -> str:
"""Get the current git branch name.

Returns:
Branch name

Raises:
SystemExit: If branch cannot be determined
"""
try:
result = run_command(["git", "rev-parse", "--abbrev-ref", "HEAD"])
branch = result.stdout.strip()
if not branch:
raise ValueError("Empty branch name")
return branch
except (subprocess.CalledProcessError, ValueError) as e:
print_colored(Colors.RED, f"[ERROR] Could not determine current branch: {e}")
raise typer.Exit(1)

Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring states "Raises: SystemExit" but the code actually raises "typer.Exit(1)". Update the docstring to accurately reflect that it raises typer.Exit.

Copilot uses AI. Check for mistakes.
Comment on lines 120 to 140
def get_default_branch() -> str:
"""Get the default branch from remote.

Returns:
Default branch name

Raises:
SystemExit: If default branch cannot be determined
"""
try:
result = run_command(["git", "remote", "show", "origin"])
for line in result.stdout.splitlines():
if "HEAD branch" in line:
branch = line.split()[-1]
if branch:
return branch
raise ValueError("Could not parse default branch")
except (subprocess.CalledProcessError, ValueError) as e:
print_colored(Colors.RED, f"[ERROR] Could not determine default branch from remote: {e}")
raise typer.Exit(1)

Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring states "Raises: SystemExit" but the code actually raises "typer.Exit(1)". Update the docstring to accurately reflect that it raises typer.Exit.

Copilot uses AI. Check for mistakes.
Comment on lines 142 to 154
def check_working_tree_clean() -> None:
"""Check if the working tree is clean.

Raises:
SystemExit: If there are uncommitted changes
"""
result = run_command(["git", "status", "--porcelain"])
if result.stdout.strip():
print_colored(Colors.RED, "[ERROR] You have uncommitted changes:")
run_command(["git", "status", "--short"], capture_output=False)
print_colored(Colors.RED, "\n[ERROR] Please commit or stash your changes before releasing.")
raise typer.Exit(1)

Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring states "Raises: SystemExit" but the code actually raises "typer.Exit(1)". Update the docstring to accurately reflect that it raises typer.Exit.

Copilot uses AI. Check for mistakes.
Comment on lines 156 to 218
def check_remote_status(current_branch: str) -> None:
"""Check if branch is in sync with remote.

Args:
current_branch: Name of the current branch

Raises:
SystemExit: If branch is behind remote or diverged
"""
print_colored(Colors.BLUE, "[INFO] Checking remote status...")

# Fetch latest changes from remote
result = run_command(["git", "fetch", "origin"], capture_output=True, check=False)
if result.returncode != 0:
print_colored(Colors.YELLOW, "[WARN] Failed to fetch from remote. Continuing with local information.")
# Continue anyway - the user might be offline or have auth issues but still want to proceed

# Get upstream branch
try:
result = run_command(
["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
check=False,
)
if result.returncode != 0:
print_colored(Colors.RED, f"[ERROR] No upstream branch configured for {current_branch}")
raise typer.Exit(1)
upstream = result.stdout.strip()
except subprocess.CalledProcessError:
print_colored(Colors.RED, f"[ERROR] No upstream branch configured for {current_branch}")
raise typer.Exit(1)

# Get commit hashes
local_result = run_command(["git", "rev-parse", "@"])
local = local_result.stdout.strip()

remote_result = run_command(["git", "rev-parse", upstream])
remote = remote_result.stdout.strip()

base_result = run_command(["git", "merge-base", "@", upstream])
base = base_result.stdout.strip()

# Check sync status
if local == remote:
# Up to date
return

if local == base:
# Local is behind remote
print_colored(Colors.RED, f"[ERROR] Your branch is behind '{upstream}'. Please pull changes.")
raise typer.Exit(1)
elif remote == base:
# Local is ahead of remote
print_colored(Colors.YELLOW, f"[WARN] Your branch is ahead of '{upstream}'.")
print("Unpushed commits:")
run_command(["git", "log", "--oneline", "--graph", "--decorate", f"{upstream}..HEAD"], capture_output=False)

if prompt_continue("Push changes to remote before releasing?"):
run_command(["git", "push", "origin", current_branch], capture_output=False)
else:
# Branches have diverged
print_colored(Colors.RED, f"[ERROR] Your branch has diverged from '{upstream}'. Please reconcile.")
raise typer.Exit(1)

Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring states "Raises: SystemExit" but the code actually raises "typer.Exit(1)". Update the docstring to accurately reflect that it raises typer.Exit.

Copilot uses AI. Check for mistakes.
run_command(["git", "log", "--oneline", "--graph", "--decorate", f"{upstream}..HEAD"], capture_output=False)

if prompt_continue("Push changes to remote before releasing?"):
run_command(["git", "push", "origin", current_branch], capture_output=False)
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the user declines to push changes to remote (prompt_continue returns False), the function continues execution without raising an exit. This means the script proceeds with creating/pushing the tag even though unpushed commits exist and the user declined to push them. This is inconsistent with other prompts where declining exits the process. Consider either exiting when the user declines, or documenting that this behavior is intentional (allowing users to release without pushing other commits).

Suggested change
run_command(["git", "push", "origin", current_branch], capture_output=False)
run_command(["git", "push", "origin", current_branch], capture_output=False)
else:
print_colored(
Colors.RED,
"[ERROR] Release aborted: branch is ahead of remote and changes were not pushed.",
)
raise typer.Exit(1)

Copilot uses AI. Check for mistakes.
Comment on lines +501 to +502
check_working_tree_clean()
check_remote_status(current_branch)
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In dry-run mode, the script still calls check_working_tree_clean() and check_remote_status() which can exit the script or prompt the user for actions. For a true dry-run experience, these validation checks should either be skipped with a message indicating what would be checked, or the functions should accept a dry_run parameter to only report issues without prompting or exiting. Currently, dry-run mode can still fail due to uncommitted changes or prompt users to push commits, which is unexpected behavior for a dry-run.

Suggested change
check_working_tree_clean()
check_remote_status(current_branch)
if dry_run:
print_colored(
Colors.YELLOW,
"[DRY RUN] Would verify that the git working tree is clean (no uncommitted changes)",
)
print_colored(
Colors.YELLOW,
"[DRY RUN] Would check that the local branch is up to date with its remote",
)
else:
check_working_tree_clean()
check_remote_status(current_branch)

Copilot uses AI. Check for mistakes.
Comment on lines 77 to 98
def get_version(uv_bin: str) -> str:
"""Get the current version from pyproject.toml using uv.

Args:
uv_bin: Path to uv binary

Returns:
Version string

Raises:
SystemExit: If version cannot be determined
"""
try:
result = run_command([uv_bin, "version", "--short"])
version = result.stdout.strip()
if not version:
raise ValueError("Empty version string")
return version
except (subprocess.CalledProcessError, ValueError) as e:
print_colored(Colors.RED, f"[ERROR] Could not determine version from pyproject.toml: {e}")
raise typer.Exit(1)

Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring states "Raises: SystemExit" but the code actually raises "typer.Exit(1)" which is not the same as SystemExit. While typer.Exit may eventually cause a system exit, it's more accurate to document this as raising typer.Exit for consistency with the actual implementation.

Copilot uses AI. Check for mistakes.
@tschm
Copy link
Member

tschm commented Jan 13, 2026

@HarryCampion The idea is to get the code working here and then move it to rhiza-tools

@HarryCampion
Copy link
Collaborator

@HarryCampion The idea is to get the code working here and then move it to rhiza-tools

I can move over the weekend into rhiza-tools. Makes sense to do it in stages like this, easier to port and test

@HarryCampion
Copy link
Collaborator

@tschm might be better to just use loguru I think copilot is generating some colour print

@tschm
Copy link
Member

tschm commented Jan 13, 2026

@copilot Don't mess with colors. Use loguru for logging.

Co-authored-by: tschm <2046079+tschm@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Jan 13, 2026

@copilot Don't mess with colors. Use loguru for logging.

Done in 650bcaa. Replaced custom color codes and print_colored function with loguru logger. All log messages now use appropriate logger methods (error, warning, info, success) with automatic colorization. Added loguru==0.7.3 to requirements. All tests pass.

Copilot AI and others added 2 commits January 13, 2026 20:14
Co-authored-by: tschm <2046079+tschm@users.noreply.github.com>
Co-authored-by: tschm <2046079+tschm@users.noreply.github.com>
Copilot AI changed the title Replace release.sh with Python implementation using Typer Replace release.sh with Python implementation using Typer and loguru Jan 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

replace release.sh with a Python script

3 participants