"""Module containing util."""
import argparse
import stat
import subprocess
from pathlib import Path
from typing import Any
from typing import Callable
from typing import Optional
REPO_FOLDER: Path = Path(__file__).resolve().parent.parent
MAIN_BRANCH: str = "main"
DEVELOP_BRANCH: str = "develop"
class MissingDependencyError(Exception):
"""Exception raised when a depedency is missing from the system running setup-repo."""
def __init__(self, project: Path, dependency: str):
"""Initializes MisssingDependencyError."""
message_lines: list[str] = [
f"Unable to find {dependency=}.",
f"Please ensure that {dependency} is installed before setting up the repo at {project.absolute()}",
]
message: str = "\n".join(message_lines)
super().__init__(message)
def check_dependencies(path: Path, dependencies: list[str]) -> None:
"""Checks for any passed dependencies."""
for dependency in dependencies:
try:
subprocess.check_call([dependency, "--version"], cwd=path)
except subprocess.CalledProcessError as e:
raise MissingDependencyError(path, dependency) from e
def require_clean_and_up_to_date_repo() -> None:
"""Checks if the repo is clean and up to date with any important branches."""
commands: list[list[str]] = [
["git", "fetch"],
["git", "merge-base", "--is-ancestor", MAIN_BRANCH, f"origin/{MAIN_BRANCH}"],
["git", "merge-base", "--is-ancestor", f"origin/{MAIN_BRANCH}", MAIN_BRANCH],
["git", "merge-base", "--is-ancestor", DEVELOP_BRANCH, f"origin/{DEVELOP_BRANCH}"],
["git", "merge-base", "--is-ancestor", f"origin/{DEVELOP_BRANCH}", DEVELOP_BRANCH],
["git", "merge-base", "--is-ancestor", MAIN_BRANCH, DEVELOP_BRANCH],
["git", "status", "--porcelain"],
]
for command in commands:
subprocess.run(command, cwd=REPO_FOLDER, check=True)
def existing_dir(value: str) -> Path:
"""Responsible for validating argparse inputs and returning them as pathlib Path's if they meet criteria."""
path = Path(value).expanduser().resolve()
if not path.exists():
raise argparse.ArgumentTypeError(f"{path} does not exist.")
if not path.is_dir():
raise argparse.ArgumentTypeError(f"{path} is not a directory.")
return path
def remove_readonly(func: Callable[[str], Any], path: str, _: Any) -> None:
"""Clears the readonly bit and attempts to call the provided function.
This is passed to shutil.rmtree as the onerror kwarg.
"""
Path(path).chmod(stat.S_IWRITE)
func(path)
def get_package_version() -> str:
"""Gets the package version."""
result: subprocess.CompletedProcess = subprocess.run(
["uvx", "--from", "commitizen", "cz", "version", "-p"], cwd=REPO_FOLDER, capture_output=True
)
return result.stdout.decode("utf-8").strip()
def get_bumped_package_version(increment: Optional[str] = None) -> str:
"""Gets the bumped package version."""
args: list[str] = ["uvx", "--from", "commitizen", "cz", "bump", "--get-next", "--yes", "--dry-run"]
if increment is not None:
args.extend(["--increment", increment])
result: subprocess.CompletedProcess = subprocess.run(args, cwd=REPO_FOLDER, capture_output=True)
return result.stdout.decode("utf-8").strip()
def create_release_branch(new_version: str) -> None:
"""Creates a release branch."""
commands: list[list[str]] = [
["git", "status", "--porcelain"],
["git", "checkout", "-b", f"release/{new_version}", "develop"],
]
for command in commands:
subprocess.run(command, cwd=REPO_FOLDER, capture_output=True, check=True)
def bump_version(increment: Optional[str] = None) -> None:
"""Bumps the package version."""
bump_cmd: list[str] = ["uvx", "--from", "commitizen", "cz", "bump", "--yes", "--files-only", "--changelog"]
if increment is not None:
bump_cmd.extend(["--increment", increment])
subprocess.run(bump_cmd, cwd=REPO_FOLDER, check=True)
def get_latest_tag() -> Optional[str]:
"""Gets the latest git tag."""
sort_tags: list[str] = ["git", "tag", "--sort=-creatordate"]
find_last: list[str] = ["grep", "-v", '"${GITHUB_REF_NAME}"']
echo_none: list[str] = ["echo", "''"]
result: subprocess.CompletedProcess = subprocess.run(
[*sort_tags, "|", *find_last, "|", "tail", "-n1", "||", *echo_none], cwd=REPO_FOLDER, capture_output=True
)
tag: str = result.stdout.decode("utf-8").strip()
if tag == "":
return None
return tag
def get_latest_release_notes() -> str:
"""Gets the release notes.
Assumes the latest_tag hasn't been applied yet.
"""
latest_tag: Optional[str] = get_latest_tag()
latest_version: str = get_package_version()
if latest_tag == latest_version:
raise ValueError(
"The latest tag and version are the same. Please ensure the release notes are taken before tagging."
)
rev_range: str = "" if latest_tag is None else f"{latest_tag}..{latest_version}"
command: list[str] = [
"uvx",
"--from",
"commitizen",
"cz",
"changelog",
rev_range,
"--dry-run",
"--unreleased-version",
latest_version,
]
result: subprocess.CompletedProcess = subprocess.run(command, cwd=REPO_FOLDER, capture_output=True, check=True)
return result.stdout.decode("utf-8")
def tag_release() -> None:
"""Tags the release using commitizen bump with tag only."""
subprocess.run(["uvx", "--from", "commitizen", "cz", "bump", "--tag-only", "--yes"], cwd=REPO_FOLDER, check=True)