From fc93365a37e71eefb7aa2f40b4c7b7f51bd63d41 Mon Sep 17 00:00:00 2001 From: Dom Batten Date: Thu, 9 Oct 2025 08:33:15 +0100 Subject: [PATCH] Update package with latest from template The `scripts/` folder in particular was left behind when creating the cookiecutter template from cookiecutter-robust-python --- .cruft.json | 27 ++++++ noxfile.py | 2 +- scripts/bump-version.py | 30 +++++++ scripts/get-release-notes.py | 36 ++++++++ scripts/setup-git.py | 53 +++++++++++ scripts/setup-release.py | 68 ++++++++++++++ scripts/setup-remote.py | 81 +++++++++++++++++ scripts/setup-venv.py | 63 +++++++++++++ scripts/util.py | 166 +++++++++++++++++++++++++++++++++++ src/maison/protocols.py | 15 ---- 10 files changed, 525 insertions(+), 16 deletions(-) create mode 100644 .cruft.json create mode 100644 scripts/bump-version.py create mode 100644 scripts/get-release-notes.py create mode 100644 scripts/setup-git.py create mode 100644 scripts/setup-release.py create mode 100644 scripts/setup-remote.py create mode 100644 scripts/setup-venv.py create mode 100644 scripts/util.py diff --git a/.cruft.json b/.cruft.json new file mode 100644 index 0000000..f4c645b --- /dev/null +++ b/.cruft.json @@ -0,0 +1,27 @@ +{ + "template": "https://github.com/56kyle/cookiecutter-robust-python.git", + "commit": "bf289288fe0cacd836ebd606dbb657193c34340f", + "checkout": null, + "context": { + "cookiecutter": { + "project_name": "maison", + "package_name": "maison", + "friendly_name": "Maison", + "min_python_version": "3.9", + "max_python_version": "3.13", + "add_rust_extension": false, + "author": "Dom Batten", + "email": "dominic.batten@googlemail.com", + "repository_provider": "github", + "repository_host": "github.com", + "repository_path": "dbatten/maison", + "version": "2.0.1", + "copyright_year": "2025", + "license": "MIT", + "development_status": "Development Status :: 5 - Production/Stable", + "_template": "https://github.com/56kyle/cookiecutter-robust-python.git", + "_commit": "bf289288fe0cacd836ebd606dbb657193c34340f" + } + }, + "directory": null +} diff --git a/noxfile.py b/noxfile.py index 1122c87..627755c 100644 --- a/noxfile.py +++ b/noxfile.py @@ -176,7 +176,7 @@ def docs_build(session: Session) -> None: @nox.session(python=DEFAULT_PYTHON_VERSION, name="docs", tags=[DOCS, BUILD]) def docs(session: Session) -> None: - """Build the project documentation (Sphinx).""" + """Build and serve the project documentation (Sphinx).""" session.log("Installing documentation dependencies...") session.install("-e", ".", "--group", "docs") diff --git a/scripts/bump-version.py b/scripts/bump-version.py new file mode 100644 index 0000000..d206d68 --- /dev/null +++ b/scripts/bump-version.py @@ -0,0 +1,30 @@ +"""Script responsible for bumping the version of the maison package.""" + +import argparse + +from util import bump_version + + +def main() -> None: + """Parses args and passes through to bump_version.""" + parser: argparse.ArgumentParser = get_parser() + args: argparse.Namespace = parser.parse_args() + bump_version(increment=args.increment) + + +def get_parser() -> argparse.ArgumentParser: + """Creates the argument parser for prepare-release.""" + parser: argparse.ArgumentParser = argparse.ArgumentParser( + prog="bump-version", usage="python ./scripts/bump-version.py patch" + ) + parser.add_argument( + "increment", + type=str, + help="Increment type to use when preparing the release.", + choices=["MAJOR", "MINOR", "PATCH", "PRERELEASE"], + ) + return parser + + +if __name__ == "__main__": + main() diff --git a/scripts/get-release-notes.py b/scripts/get-release-notes.py new file mode 100644 index 0000000..6580e2b --- /dev/null +++ b/scripts/get-release-notes.py @@ -0,0 +1,36 @@ +"""Script responsible for getting the release notes of the maison package.""" + +import argparse +from pathlib import Path + +from util import get_latest_release_notes + + +RELEASE_NOTES_PATH: Path = Path("body.md") + + +def main() -> None: + """Parses args and passes through to bump_version.""" + parser: argparse.ArgumentParser = get_parser() + args: argparse.Namespace = parser.parse_args() + release_notes: str = get_latest_release_notes() + path: Path = RELEASE_NOTES_PATH if args.path is None else args.path + path.write_text(release_notes) + + +def get_parser() -> argparse.ArgumentParser: + """Creates the argument parser for prepare-release.""" + parser: argparse.ArgumentParser = argparse.ArgumentParser( + prog="get-release-notes", usage="python ./scripts/get-release-notes.py" + ) + parser.add_argument( + "path", + type=Path, + metavar="PATH", + help="Path the changelog will be written to.", + ) + return parser + + +if __name__ == "__main__": + main() diff --git a/scripts/setup-git.py b/scripts/setup-git.py new file mode 100644 index 0000000..c3f57a7 --- /dev/null +++ b/scripts/setup-git.py @@ -0,0 +1,53 @@ +"""Script responsible for first time setup of the project's git repo. + +Since this is a first time setup script, we intentionally only use builtin Python dependencies. +""" + +import argparse +import subprocess +from pathlib import Path + +from util import check_dependencies +from util import existing_dir + + +def main() -> None: + """Parses command line input and passes it through to setup_git.""" + parser: argparse.ArgumentParser = get_parser() + args: argparse.Namespace = parser.parse_args() + setup_git(path=args.path) + + +def setup_git(path: Path) -> None: + """Set up the provided cookiecutter-robust-python project's git repo.""" + commands: list[list[str]] = [ + ["git", "init"], + ["git", "branch", "-m", "master", "main"], + ["git", "add", "."], + ["git", "commit", "-m", "feat: initial commit"], + ["git", "checkout", "-b", "develop", "main"], + ] + check_dependencies(path=path, dependencies=["git"]) + + for command in commands: + subprocess.run(command, cwd=path, stderr=subprocess.STDOUT) + + +def get_parser() -> argparse.ArgumentParser: + """Creates the argument parser for setup-git.""" + parser: argparse.ArgumentParser = argparse.ArgumentParser( + prog="setup-git", + usage="python ./scripts/setup-git.py . -u 56kyle -n robust-python-demo", + description="Set up the provided cookiecutter-robust-python project's git repo.", + ) + parser.add_argument( + "path", + type=existing_dir, + metavar="PATH", + help="Path to the repo's root directory (must already exist).", + ) + return parser + + +if __name__ == "__main__": + main() diff --git a/scripts/setup-release.py b/scripts/setup-release.py new file mode 100644 index 0000000..365c288 --- /dev/null +++ b/scripts/setup-release.py @@ -0,0 +1,68 @@ +"""Script responsible for preparing a release of the maison package.""" + +import argparse +import subprocess +from typing import Optional + +from util import REPO_FOLDER +from util import bump_version +from util import check_dependencies +from util import create_release_branch +from util import get_bumped_package_version +from util import get_package_version + + +def main() -> None: + """Parses args and passes through to setup_release.""" + parser: argparse.ArgumentParser = get_parser() + args: argparse.Namespace = parser.parse_args() + setup_release(increment=args.increment) + + +def get_parser() -> argparse.ArgumentParser: + """Creates the argument parser for prepare-release.""" + parser: argparse.ArgumentParser = argparse.ArgumentParser( + prog="prepare-release", usage="python ./scripts/prepare-release.py patch" + ) + parser.add_argument( + "increment", + nargs="?", + default=None, + type=str, + help="Increment type to use when preparing the release.", + choices=["MAJOR", "MINOR", "PATCH", "PRERELEASE"], + ) + return parser + + +def setup_release(increment: Optional[str] = None) -> None: + """Prepares a release of the maison package. + + Sets up a release branch from the branch develop, bumps the version, and creates a release commit. Does not tag the + release or push any changes. + """ + check_dependencies(path=REPO_FOLDER, dependencies=["git"]) + + current_version: str = get_package_version() + new_version: str = get_bumped_package_version(increment=increment) + create_release_branch(new_version=new_version) + bump_version(increment=increment) + + commands: list[list[str]] = [ + ["uv", "sync", "--all-groups"], + ["git", "add", "."], + [ + "git", + "commit", + "-m", + f"bump: version {current_version} → {new_version}", + "--no-verify", + ], + ] + + for command in commands: + subprocess.run(command, cwd=REPO_FOLDER, capture_output=True, check=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/setup-remote.py b/scripts/setup-remote.py new file mode 100644 index 0000000..f88c20b --- /dev/null +++ b/scripts/setup-remote.py @@ -0,0 +1,81 @@ +"""Script responsible for first time setup of the project's git repo's remote connection. + +Since this is a first time setup script, we intentionally only use builtin Python dependencies. +""" + +import argparse +import subprocess +from pathlib import Path + +from util import check_dependencies +from util import existing_dir + + +def main() -> None: + """Parses command line input and passes it through to setup_git.""" + parser: argparse.ArgumentParser = get_parser() + args: argparse.Namespace = parser.parse_args() + setup_remote( + path=args.path, + repository_host=args.repository_host, + repository_path=args.repository_path, + ) + + +def setup_remote(path: Path, repository_host: str, repository_path: str) -> None: + """Set up the provided cookiecutter-robust-python project's git repo.""" + commands: list[list[str]] = [ + [ + "git", + "remote", + "add", + "origin", + f"https://{repository_host}/{repository_path}.git", + ], + [ + "git", + "remote", + "set-url", + "origin", + f"https://{repository_host}/{repository_path}.git", + ], + ["git", "fetch", "origin"], + ["git", "checkout", "main"], + ["git", "push", "-u", "origin", "main"], + ["git", "checkout", "develop"], + ["git", "push", "-u", "origin", "develop"], + ] + check_dependencies(path=path, dependencies=["git"]) + + for command in commands: + subprocess.run(command, cwd=path, stderr=subprocess.STDOUT) + + +def get_parser() -> argparse.ArgumentParser: + """Creates the argument parser for setup-remote.""" + parser: argparse.ArgumentParser = argparse.ArgumentParser( + prog="setup-remote", + usage="python ./scripts/setup-remote.py . --host github.com --path 56kyle/robust-python-demo", + description="Set up the provided cookiecutter-robust-python project's remote repo connection.", + ) + parser.add_argument( + "path", + type=existing_dir, + metavar="PATH", + help="Path to the repo's root directory (must already exist).", + ) + parser.add_argument( + "--host", + dest="repository_host", + help="Repository host (e.g., github.com, gitlab.com).", + ) + parser.add_argument( + "--path", + dest="repository_path", + help="Repository path (e.g., user/repo, group/subgroup/repo).", + ) + return parser + + +if __name__ == "__main__": + main() diff --git a/scripts/setup-venv.py b/scripts/setup-venv.py new file mode 100644 index 0000000..2e149fe --- /dev/null +++ b/scripts/setup-venv.py @@ -0,0 +1,63 @@ +"""Script responsible for first time setup of the project's venv. + +Since this is a first time setup script, we intentionally only use builtin Python dependencies. +""" + +import argparse +import shutil +import subprocess +from pathlib import Path + +from util import check_dependencies +from util import existing_dir +from util import remove_readonly + + +def main() -> None: + """Parses args and passes through to setup_venv.""" + parser: argparse.ArgumentParser = get_parser() + args: argparse.Namespace = parser.parse_args() + setup_venv(path=args.path, python_version=args.python_version) + + +def get_parser() -> argparse.ArgumentParser: + """Creates the argument parser for setup-venv.""" + parser: argparse.ArgumentParser = argparse.ArgumentParser( + prog="setup-venv", usage="python ./scripts/setup-venv.py . -p '3.9'" + ) + parser.add_argument( + "path", + type=existing_dir, + metavar="PATH", + help="Path to the repo's root directory (must already exist).", + ) + parser.add_argument( + "-p", + "--python", + dest="python_version", + help="The Python version that will serve as the main working version used by the IDE.", + ) + return parser + + +def setup_venv(path: Path, python_version: str) -> None: + """Set up the provided cookiecutter-robust-python project's venv.""" + commands: list[list[str]] = [ + ["uv", "lock"], + ["uv", "venv", ".venv"], + ["uv", "python", "install", python_version], + ["uv", "python", "pin", python_version], + ["uv", "sync", "--all-groups"], + ] + check_dependencies(path=path, dependencies=["uv"]) + + venv_path: Path = path / ".venv" + if venv_path.exists(): + shutil.rmtree(venv_path, onerror=remove_readonly) + + for command in commands: + subprocess.run(command, cwd=path, capture_output=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/util.py b/scripts/util.py new file mode 100644 index 0000000..69e6512 --- /dev/null +++ b/scripts/util.py @@ -0,0 +1,166 @@ +"""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 + + +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 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, + ) diff --git a/src/maison/protocols.py b/src/maison/protocols.py index 83aca9a..a276988 100644 --- a/src/maison/protocols.py +++ b/src/maison/protocols.py @@ -6,21 +6,6 @@ from maison import typedefs -class Parser(typing.Protocol): - """Defines the interface for a parser used to parse a config file.""" - - def parse_config(self, file_path: pathlib.Path) -> typedefs.ConfigValues: - """Parses a config file. - - Args: - file_path: the path to the config file - - Returns: - the parsed config values - """ - ... - - class IsSchema(typing.Protocol): """Protocol for config schemas."""