From 31978e64d013a15eed0bb080d864d058327cb80f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 00:08:07 +0000 Subject: [PATCH] chore: sync template files --- .github/actions/setup-project/action.yml | 4 +- .github/scripts/bump.sh | 225 +++++++++++++ .../scripts/customisations/build-extras.sh | 48 +++ .../scripts/customisations/post-release.sh | 46 +++ .github/scripts/release.sh | 218 ++++++++++++ .github/scripts/sync.sh | 310 ++++++++++++++++++ .github/workflows/book.yml | 14 +- .github/workflows/ci.yml | 52 +-- .github/workflows/deptry.yml | 2 +- .github/workflows/devcontainer.yml | 137 ++++++++ .github/workflows/marimo.yml | 8 + .github/workflows/pre-commit.yml | 11 +- .github/workflows/release.yml | 288 ++++++++++------ .github/workflows/scripts/version_matrix.py | 53 +++ .github/workflows/scripts/version_max.py | 53 +++ .gitignore | 8 + .pre-commit-config.yaml | 2 +- CONTRIBUTING.md | 4 +- Makefile | 86 ++++- tests/conftest.py | 89 +---- tests/test_config_templates/conftest.py | 185 +++++++++++ .../test_config_templates/test_bump_script.py | 136 ++++++++ .../test_config_templates/test_docstrings.py | 103 ++++++ .../test_git_repo_fixture.py | 147 +++++++++ tests/test_config_templates/test_makefile.py | 200 +++++++++++ tests/test_config_templates/test_readme.py | 74 +++++ .../test_release_script.py | 123 +++++++ tests/test_config_templates/test_structure.py | 58 ++++ .../test_config_templates/test_sync_script.py | 213 ++++++++++++ 29 files changed, 2675 insertions(+), 222 deletions(-) create mode 100755 .github/scripts/bump.sh create mode 100755 .github/scripts/customisations/build-extras.sh create mode 100755 .github/scripts/customisations/post-release.sh create mode 100755 .github/scripts/release.sh create mode 100755 .github/scripts/sync.sh create mode 100644 .github/workflows/devcontainer.yml create mode 100755 .github/workflows/scripts/version_matrix.py create mode 100755 .github/workflows/scripts/version_max.py create mode 100644 tests/test_config_templates/conftest.py create mode 100644 tests/test_config_templates/test_bump_script.py create mode 100644 tests/test_config_templates/test_docstrings.py create mode 100644 tests/test_config_templates/test_git_repo_fixture.py create mode 100644 tests/test_config_templates/test_makefile.py create mode 100644 tests/test_config_templates/test_readme.py create mode 100644 tests/test_config_templates/test_release_script.py create mode 100644 tests/test_config_templates/test_structure.py create mode 100644 tests/test_config_templates/test_sync_script.py diff --git a/.github/actions/setup-project/action.yml b/.github/actions/setup-project/action.yml index b1b0fb71..9fea8815 100644 --- a/.github/actions/setup-project/action.yml +++ b/.github/actions/setup-project/action.yml @@ -11,6 +11,7 @@ # # Inputs: # - python-version: Python version used for the virtual environment (default: 3.12) +# Can be overridden by setting PYTHON_DEFAULT_VERSION repository variable # # Outputs: # - pyproject_exists: "true" if pyproject.toml exists, otherwise "false" @@ -25,8 +26,7 @@ description: 'Setup the project' inputs: python-version: description: 'Python version to use' - required: false - default: '3.12' + required: true outputs: pyproject_exists: diff --git a/.github/scripts/bump.sh b/.github/scripts/bump.sh new file mode 100755 index 00000000..2d6b5c97 --- /dev/null +++ b/.github/scripts/bump.sh @@ -0,0 +1,225 @@ +#!/bin/sh +# Version bump script +# - Bumps version in pyproject.toml using uv +# - Optionally commits the changes +# - Optionally pushes the changes +# +# This script is POSIX-sh compatible and follows the style of other scripts +# in this repository. It uses uv to manage version updates. + +set -e + +UV_BIN=${UV_BIN:-./bin/uv} + +BLUE="\033[36m" +RED="\033[31m" +GREEN="\033[32m" +YELLOW="\033[33m" +RESET="\033[0m" + +# Parse command-line arguments +VERSION="" +TYPE="" +DO_COMMIT="" +COMMIT_MSG="" + +# Check if pyproject.toml exists +if [ ! -f "pyproject.toml" ]; then + printf "%b[ERROR] pyproject.toml not found in current directory%b\n" "$RED" "$RESET" + exit 1 +fi + +# Check if uv is available +if [ ! -x "$UV_BIN" ]; then + printf "%b[ERROR] uv not found at %s. Run 'make install-uv' first.%b\n" "$RED" "$UV_BIN" "$RESET" + exit 1 +fi + +# Helper function to prompt user to continue +prompt_continue() { + local message="$1" + printf "\n%b[PROMPT] %s Continue? [y/N] %b" "$YELLOW" "$message" "$RESET" + read -r answer + case "$answer" in + [Yy]*) + return 0 + ;; + *) + printf "%b[INFO] Aborted by user%b\n" "$YELLOW" "$RESET" + exit 0 + ;; + esac +} + +# Helper function to prompt user for yes/no +prompt_yes_no() { + local message="$1" + printf "\n%b[PROMPT] %s [y/N] %b" "$YELLOW" "$message" "$RESET" + read -r answer + case "$answer" in + [Yy]*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +# Function: Bump version +do_bump() { + printf "%bSelect bump type:%b\n" "$BLUE" "$RESET" + printf "1) patch\n" + printf "2) minor\n" + printf "3) major\n" + printf "4) Enter specific version\n" + printf "Enter choice [1-4]: " + read -r choice + case "$choice" in + 1) TYPE="patch" ;; + 2) TYPE="minor" ;; + 3) TYPE="major" ;; + 4) + printf "Enter version: " + read -r VERSION + # Strip 'v' prefix if present + VERSION=$(echo "$VERSION" | sed 's/^v//') + ;; + *) + printf "%b[ERROR] Invalid choice%b\n" "$RED" "$RESET" + exit 1 + ;; + esac + + # Get current branch + CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + if [ -z "$CURRENT_BRANCH" ]; then + printf "%b[ERROR] Could not determine current branch%b\n" "$RED" "$RESET" + exit 1 + fi + + # Determine default branch + DEFAULT_BRANCH=$(git remote show origin | grep 'HEAD branch' | cut -d' ' -f5) + if [ -z "$DEFAULT_BRANCH" ]; then + printf "%b[ERROR] Could not determine default branch from remote%b\n" "$RED" "$RESET" + exit 1 + fi + + # Warn if not on default branch + if [ "$CURRENT_BRANCH" != "$DEFAULT_BRANCH" ]; then + printf "%b[WARN] You are on branch '%s' but the default branch is '%s'%b\n" "$YELLOW" "$CURRENT_BRANCH" "$DEFAULT_BRANCH" "$RESET" + printf "%b[WARN] Releases are typically created from the default branch.%b\n" "$YELLOW" "$RESET" + prompt_continue "Proceed with version bump on '$CURRENT_BRANCH'?" + fi + + # Get current version + CURRENT_VERSION=$("$UV_BIN" version --short 2>/dev/null || echo "unknown") + printf "%b[INFO] Current version: %s%b\n" "$BLUE" "$CURRENT_VERSION" "$RESET" + + # Determine the new version using uv version with --dry-run first + if [ -n "$TYPE" ]; then + printf "%b[INFO] Bumping version using: %s%b\n" "$BLUE" "$TYPE" "$RESET" + NEW_VERSION=$("$UV_BIN" version --bump "$TYPE" --dry-run --short 2>/dev/null) + if [ $? -ne 0 ] || [ -z "$NEW_VERSION" ]; then + printf "%b[ERROR] Failed to calculate new version with type: %s%b\n" "$RED" "$TYPE" "$RESET" + exit 1 + fi + else + # Validate the version format by having uv try it with --dry-run + if ! "$UV_BIN" version "$VERSION" --dry-run >/dev/null 2>&1; then + printf "%b[ERROR] Invalid version format: %s%b\n" "$RED" "$VERSION" "$RESET" + printf "uv rejected this version. Please use a valid semantic version.\n" + exit 1 + fi + NEW_VERSION="$VERSION" + fi + + printf "%b[INFO] New version will be: %s%b\n" "$BLUE" "$NEW_VERSION" "$RESET" + + TAG="v$NEW_VERSION" + + # Check if tag already exists + if git rev-parse "$TAG" >/dev/null 2>&1; then + printf "%b[ERROR] Tag '%s' already exists locally%b\n" "$RED" "$TAG" "$RESET" + exit 1 + fi + + if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then + printf "%b[ERROR] Tag '%s' already exists on remote%b\n" "$RED" "$TAG" "$RESET" + exit 1 + fi + + # Check for uncommitted changes + if [ -n "$(git status --porcelain)" ]; then + printf "%b[ERROR] You have uncommitted changes:%b\n" "$RED" "$RESET" + git status --short + printf "\n%b[ERROR] Please commit or stash your changes before bumping version.%b\n" "$RED" "$RESET" + exit 1 + fi + + # Update version in pyproject.toml using uv + printf "%b[INFO] Updating pyproject.toml...%b\n" "$BLUE" "$RESET" + if [ -n "$TYPE" ]; then + if ! "$UV_BIN" version --bump "$TYPE" >/dev/null 2>&1; then + printf "%b[ERROR] Failed to bump version using 'uv version --bump %s'%b\n" "$RED" "$TYPE" "$RESET" + exit 1 + fi + else + if ! "$UV_BIN" version "$NEW_VERSION" >/dev/null 2>&1; then + printf "%b[ERROR] Failed to set version using 'uv version %s'%b\n" "$RED" "$NEW_VERSION" "$RESET" + exit 1 + fi + fi + + # Verify the update + UPDATED_VERSION=$("$UV_BIN" version --short 2>/dev/null) + if [ "$UPDATED_VERSION" != "$NEW_VERSION" ]; then + printf "%b[ERROR] Version update failed. Expected %s but got %s%b\n" "$RED" "$NEW_VERSION" "$UPDATED_VERSION" "$RESET" + exit 1 + fi + + printf "%b[SUCCESS] Version bumped to %s in pyproject.toml%b\n" "$GREEN" "$NEW_VERSION" "$RESET" + + # Handle commit if requested + if [ -z "$DO_COMMIT" ]; then + if prompt_yes_no "Commit changes?"; then + DO_COMMIT="true" + fi + fi + + if [ -n "$DO_COMMIT" ]; then + # Set commit message + if [ -z "$COMMIT_MSG" ]; then + COMMIT_MSG="chore: bump version to $NEW_VERSION" + fi + + printf "%b[INFO] Committing version change...%b\n" "$BLUE" "$RESET" + git add pyproject.toml + git add uv.lock 2>/dev/null || true # In case uv modifies the lock file + git commit -m "$COMMIT_MSG" + + printf "%b[SUCCESS] Version committed with message: '%s'%b\n" "$GREEN" "$COMMIT_MSG" "$RESET" + + # Prompt for push + if prompt_yes_no "Push changes?"; then + # Check if branch exists on remote + if git ls-remote --exit-code --heads origin "$CURRENT_BRANCH" >/dev/null 2>&1; then + git push origin "$CURRENT_BRANCH" + printf "%b[SUCCESS] Pushed to origin/%s%b\n" "$GREEN" "$CURRENT_BRANCH" "$RESET" + else + printf "%b[WARN] Branch '%s' does not exist on remote%b\n" "$YELLOW" "$CURRENT_BRANCH" "$RESET" + if prompt_yes_no "Publish branch?"; then + git push -u origin "$CURRENT_BRANCH" + printf "%b[SUCCESS] Published branch to origin/%s%b\n" "$GREEN" "$CURRENT_BRANCH" "$RESET" + fi + fi + fi + else + printf "%b[INFO] Version bumped but not committed%b\n" "$BLUE" "$RESET" + printf "%b[INFO] Next steps:%b\n" "$BLUE" "$RESET" + printf " 1. Commit changes: git commit -a -m 'chore: bump version to %s'\n" "$NEW_VERSION" + printf " 2. Push changes: git push origin %s\n" "$CURRENT_BRANCH" + fi +} + +do_bump diff --git a/.github/scripts/customisations/build-extras.sh b/.github/scripts/customisations/build-extras.sh new file mode 100755 index 00000000..82dba863 --- /dev/null +++ b/.github/scripts/customisations/build-extras.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# This file is part of the tschm/.config-templates repository +# (https://github.com/tschm/.config-templates). +# +# Optional hook script for installing extra dependencies +# +# Purpose: This script is called automatically during the install phase and before +# building documentation. Use it to install additional system packages or +# dependencies that your project needs (e.g., graphviz for diagrams). +# +# When it runs: +# - Called by: make install-extras (during setup phase) +# - Also runs before: make test, make book, make docs +# - Environment: GitHub Actions runner or local development machine +# +# How to use: +# 1. Add your custom installation commands below +# 2. Make sure the script is executable: chmod +x .github/scripts/customisations/build-extras.sh +# 3. Commit to your repository +# +# Examples: +# - Install system packages: apt-get, brew, yum, etc. +# - Install optional dependencies for documentation +# - Download or build tools needed by your project +# +# Note: If you customize this file in your repository, add it to the exclude list +# in action.yml to prevent it from being overwritten by template updates: +# exclude: | +# .github/scripts/customisations/build-extras.sh +# + +set -euo pipefail + +echo "Running build-extras.sh..." + +# Add your custom installation commands here + +# Example: graphviz +# Good practice to check if already installed. +# +# if ! command -v dot &> /dev/null; then +# echo "graphviz not found, installing..." +# sudo apt-get update && sudo apt-get install -y graphviz +# else +# echo "graphviz is already installed, skipping installation." +# fi + +echo "Build extras setup complete." diff --git a/.github/scripts/customisations/post-release.sh b/.github/scripts/customisations/post-release.sh new file mode 100755 index 00000000..08f015d1 --- /dev/null +++ b/.github/scripts/customisations/post-release.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# This file is part of the tschm/.config-templates repository +# (https://github.com/tschm/.config-templates). +# +# Optional hook script for post-release actions +# +# Purpose: This script is called automatically after a release is created. +# Use it to perform additional tasks after the release process completes +# (e.g., notifications, cleanup, triggering deployments). +# +# When it runs: +# - Called by: make release (after release tag is created and pushed) +# - Environment: GitHub Actions runner or local development machine +# +# How to use: +# 1. Add your custom post-release commands below +# 2. Make sure the script is executable: chmod +x .github/scripts/customisations/post-release.sh +# 3. Commit to your repository +# +# Examples: +# - Send notifications (Slack, email, etc.) +# - Trigger deployment workflows +# - Update external documentation sites +# - Clean up temporary release artifacts +# +# Note: If you customize this file in your repository, add it to the exclude list +# in template.yml to prevent it from being overwritten by template updates: +# exclude: | +# .github/scripts/customisations/post-release.sh +# + +set -euo pipefail + +echo "Running post-release.sh..." + +# Add your custom post-release commands here + +# Example: Send notification +# if command -v curl &> /dev/null && [ -n "${SLACK_WEBHOOK_URL:-}" ]; then +# echo "Sending notification to Slack..." +# curl -X POST -H 'Content-type: application/json' \ +# --data '{"text":"New release created!"}' \ +# "${SLACK_WEBHOOK_URL}" +# fi + +echo "Post-release setup complete." diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh new file mode 100755 index 00000000..0ac2f88e --- /dev/null +++ b/.github/scripts/release.sh @@ -0,0 +1,218 @@ +#!/bin/sh +# Release script +# - Creates a git tag based on the current version in pyproject.toml +# - Pushes the tag to remote to trigger the release workflow +# - Performs checks (branch, upstream status, clean working tree) +# +# This script is POSIX-sh compatible and follows the style of other scripts +# in this repository. It uses uv to read the current version. + +set -e + +UV_BIN=${UV_BIN:-./bin/uv} + +BLUE="\033[36m" +RED="\033[31m" +GREEN="\033[32m" +YELLOW="\033[33m" +RESET="\033[0m" + +# Parse command-line arguments +show_usage() { + printf "Usage: %s [OPTIONS]\n\n" "$0" + printf "Description:\n" + printf " Create tag and push to remote (with prompts)\n\n" + printf "Options:\n" + printf " -h, --help Show this help message\n\n" + printf "Examples:\n" + printf " %s (create tag and push with prompts)\n" "$0" +} + +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) + show_usage + exit 0 + ;; + -*) + printf "%b[ERROR] Unknown option: %s%b\n" "$RED" "$1" "$RESET" + show_usage + exit 1 + ;; + *) + printf "%b[ERROR] Unknown argument: %s%b\n" "$RED" "$1" "$RESET" + show_usage + exit 1 + ;; + esac +done + +# Check if pyproject.toml exists +if [ ! -f "pyproject.toml" ]; then + printf "%b[ERROR] pyproject.toml not found in current directory%b\n" "$RED" "$RESET" + exit 1 +fi + +# Check if uv is available +if [ ! -x "$UV_BIN" ]; then + printf "%b[ERROR] uv not found at %s. Run 'make install-uv' first.%b\n" "$RED" "$UV_BIN" "$RESET" + exit 1 +fi + +# Helper function to prompt user to continue +prompt_continue() { + local message="$1" + printf "\n%b[PROMPT] %s Continue? [y/N] %b" "$YELLOW" "$message" "$RESET" + read -r answer + case "$answer" in + [Yy]*) + return 0 + ;; + *) + printf "%b[INFO] Aborted by user%b\n" "$YELLOW" "$RESET" + exit 0 + ;; + esac +} + +# Helper function to prompt user for yes/no +prompt_yes_no() { + local message="$1" + printf "\n%b[PROMPT] %s [y/N] %b" "$YELLOW" "$message" "$RESET" + read -r answer + case "$answer" in + [Yy]*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +# Function: Release - create tag and push (with prompts) +do_release() { + # Get the current version from pyproject.toml + CURRENT_VERSION=$("$UV_BIN" version --short 2>/dev/null) + if [ -z "$CURRENT_VERSION" ]; then + printf "%b[ERROR] Could not determine version from pyproject.toml%b\n" "$RED" "$RESET" + exit 1 + fi + + TAG="v$CURRENT_VERSION" + + # Get current branch + CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + if [ -z "$CURRENT_BRANCH" ]; then + printf "%b[ERROR] Could not determine current branch%b\n" "$RED" "$RESET" + exit 1 + fi + + # Determine default branch + DEFAULT_BRANCH=$(git remote show origin | grep 'HEAD branch' | cut -d' ' -f5) + if [ -z "$DEFAULT_BRANCH" ]; then + printf "%b[ERROR] Could not determine default branch from remote%b\n" "$RED" "$RESET" + exit 1 + fi + + # Warn if not on default branch + if [ "$CURRENT_BRANCH" != "$DEFAULT_BRANCH" ]; then + printf "%b[WARN] You are on branch '%s' but the default branch is '%s'%b\n" "$YELLOW" "$CURRENT_BRANCH" "$DEFAULT_BRANCH" "$RESET" + printf "%b[WARN] Releases are typically created from the default branch.%b\n" "$YELLOW" "$RESET" + prompt_continue "Proceed with release from '$CURRENT_BRANCH'?" + fi + + printf "%b[INFO] Current version: %s%b\n" "$BLUE" "$CURRENT_VERSION" "$RESET" + printf "%b[INFO] Tag to create: %s%b\n" "$BLUE" "$TAG" "$RESET" + + # Check if there are uncommitted changes + if [ -n "$(git status --porcelain)" ]; then + printf "%b[ERROR] You have uncommitted changes:%b\n" "$RED" "$RESET" + git status --short + printf "\n%b[ERROR] Please commit or stash your changes before releasing.%b\n" "$RED" "$RESET" + exit 1 + fi + + # Check if branch is up-to-date with remote + printf "%b[INFO] Checking remote status...%b\n" "$BLUE" "$RESET" + git fetch origin >/dev/null 2>&1 + UPSTREAM=$(git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null) + if [ -z "$UPSTREAM" ]; then + printf "%b[ERROR] No upstream branch configured for %s%b\n" "$RED" "$CURRENT_BRANCH" "$RESET" + exit 1 + fi + + LOCAL=$(git rev-parse @) + REMOTE=$(git rev-parse "$UPSTREAM") + BASE=$(git merge-base @ "$UPSTREAM") + + if [ "$LOCAL" != "$REMOTE" ]; then + if [ "$LOCAL" = "$BASE" ]; then + printf "%b[ERROR] Your branch is behind '%s'. Please pull changes.%b\n" "$RED" "$UPSTREAM" "$RESET" + exit 1 + elif [ "$REMOTE" = "$BASE" ]; then + printf "%b[WARN] Your branch is ahead of '%s'.%b\n" "$YELLOW" "$UPSTREAM" "$RESET" + printf "Unpushed commits:\n" + git log --oneline --graph --decorate "$UPSTREAM..HEAD" + prompt_continue "Push changes to remote before releasing?" + git push origin "$CURRENT_BRANCH" + else + printf "%b[ERROR] Your branch has diverged from '%s'. Please reconcile.%b\n" "$RED" "$UPSTREAM" "$RESET" + exit 1 + fi + fi + + # Check if tag already exists locally + if git rev-parse "$TAG" >/dev/null 2>&1; then + printf "%b[WARN] Tag '%s' already exists locally%b\n" "$YELLOW" "$TAG" "$RESET" + prompt_continue "Tag exists. Skip tag creation and proceed to push?" + SKIP_TAG_CREATE="true" + fi + + # Check if tag already exists on remote + if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then + printf "%b[ERROR] Tag '%s' already exists on remote%b\n" "$RED" "$TAG" "$RESET" + printf "The release for version %s has already been published.\n" "$CURRENT_VERSION" + exit 1 + fi + + # Step 1: Create the tag (if it doesn't exist) + if [ -z "$SKIP_TAG_CREATE" ]; then + printf "\n%b=== Step 1: Create Tag ===%b\n" "$BLUE" "$RESET" + printf "Creating tag '%s' for version %s\n" "$TAG" "$CURRENT_VERSION" + prompt_continue "" + + # check for gpg signing config + if git config --get user.signingkey >/dev/null 2>&1 || [ "$(git config --get commit.gpgsign)" = "true" ]; then + printf "%b[INFO] GPG signing is enabled. Creating signed tag.%b\n" "$BLUE" "$RESET" + git tag -s "$TAG" -m "Release $TAG" + else + printf "%b[INFO] GPG signing is not enabled. Creating unsigned tag.%b\n" "$BLUE" "$RESET" + git tag -a "$TAG" -m "Release $TAG" + fi + printf "%b[SUCCESS] Tag '%s' created locally%b\n" "$GREEN" "$TAG" "$RESET" + fi + + # Step 2: Push the tag to remote + printf "\n%b=== Step 2: Push Tag to Remote ===%b\n" "$BLUE" "$RESET" + printf "Pushing tag '%s' to origin will trigger the release workflow.\n" "$TAG" + + # Show what commits are in this tag + LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + if [ -n "$LAST_TAG" ] && [ "$LAST_TAG" != "$TAG" ]; then + COMMIT_COUNT=$(git rev-list "$LAST_TAG..$TAG" --count 2>/dev/null || echo "0") + printf "Commits since %s: %s\n" "$LAST_TAG" "$COMMIT_COUNT" + fi + + prompt_continue "" + + git push origin "refs/tags/$TAG" + + REPO_URL=$(git remote get-url origin | sed 's/.*github.com[:/]\(.*\)\.git/\1/') + printf "\n%b[SUCCESS] Release tag %s pushed to remote!%b\n" "$GREEN" "$TAG" "$RESET" + printf "%b[INFO] The release workflow will now be triggered automatically.%b\n" "$BLUE" "$RESET" + printf "%b[INFO] Monitor progress at: https://github.com/%s/actions%b\n" "$BLUE" "$REPO_URL" "$RESET" +} + +# Main execution logic +do_release diff --git a/.github/scripts/sync.sh b/.github/scripts/sync.sh new file mode 100755 index 00000000..af6af302 --- /dev/null +++ b/.github/scripts/sync.sh @@ -0,0 +1,310 @@ +#!/bin/sh +# Sync configuration files from template repository +# - Reads configuration from .github/template.yml +# - Downloads specified files from the template repository +# - Copies them to the current repository +# +# This script is POSIX-sh compatible and provides manual sync capability +# for repositories that use tschm/.config-templates as a template. + +set -e + +BLUE="\033[36m" +RED="\033[31m" +GREEN="\033[32m" +YELLOW="\033[33m" +RESET="\033[0m" + +TEMPLATE_CONFIG=".github/template.yml" +TEMP_DIR="/tmp/config-templates-sync-$$" + +show_usage() { + printf "Usage: %s [OPTIONS]\n\n" "$0" + printf "Sync configuration files from the template repository.\n\n" + printf "Options:\n" + printf " -h, --help Show this help message\n" + printf " --dry-run Show what would be synced without making changes\n\n" + printf "Configuration:\n" + printf " Reads from %s to determine:\n" "$TEMPLATE_CONFIG" + printf " - template-repository: Source repository (e.g., 'tschm/.config-templates')\n" + printf " - template-branch: Branch to sync from (e.g., 'main')\n" + printf " - include: Files/directories to sync\n" + printf " - exclude: Files/directories to skip (optional)\n\n" + printf "Example %s:\n" "$TEMPLATE_CONFIG" + printf " template-repository: \"tschm/.config-templates\"\n" + printf " template-branch: \"main\"\n" + printf " include: |\n" + printf " .github\n" + printf " Makefile\n" + printf " ruff.toml\n" +} + +DRY_RUN="" +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) + show_usage + exit 0 + ;; + --dry-run) + DRY_RUN="true" + shift + ;; + *) + printf "%b[ERROR] Unknown option: %s%b\n" "$RED" "$1" "$RESET" + show_usage + exit 1 + ;; + esac +done + +# Check if template.yml exists +if [ ! -f "$TEMPLATE_CONFIG" ]; then + printf "%b[ERROR] Template configuration not found: %s%b\n" "$RED" "$TEMPLATE_CONFIG" "$RESET" + printf "\nThis repository is not configured for template syncing.\n" + printf "Create %s with the following content:\n\n" "$TEMPLATE_CONFIG" + printf " template-repository: \"tschm/.config-templates\"\n" + printf " template-branch: \"main\"\n" + printf " include: |\n" + printf " .github\n" + printf " Makefile\n" + printf " ruff.toml\n" + exit 1 +fi + +# Parse template.yml to extract configuration +# This is a simple parser that works with basic YAML structure +printf "%b[INFO] Reading configuration from %s...%b\n" "$BLUE" "$TEMPLATE_CONFIG" "$RESET" + +TEMPLATE_REPO="" +TEMPLATE_BRANCH="" +INCLUDE_LIST="" +EXCLUDE_LIST="" + +# Simple YAML parser +in_multiline="" + +while IFS= read -r line || [ -n "$line" ]; do + # Skip comments and empty lines + case "$line" in + \#*|"") continue ;; + esac + + # Check if line starts with spaces by looking at first character + first_char=$(printf '%s' "$line" | cut -c1) + + if [ "$first_char" = " " ] || [ "$first_char" = "$(printf '\t')" ]; then + # This is indented content (part of a multiline block) + if [ -n "$in_multiline" ]; then + # Remove leading spaces + item=$(echo "$line" | sed 's/^[[:space:]]*//') + if [ -n "$item" ]; then + if [ "$in_multiline" = "include" ]; then + INCLUDE_LIST="${INCLUDE_LIST}${item} +" + elif [ "$in_multiline" = "exclude" ]; then + EXCLUDE_LIST="${EXCLUDE_LIST}${item} +" + fi + fi + fi + else + # This is a key line (not indented) + case "$line" in + template-repository:*) + TEMPLATE_REPO=$(echo "$line" | sed 's/^template-repository:[[:space:]]*//' | sed 's/"//g' | sed "s/'//g") + in_multiline="" + ;; + template-branch:*) + TEMPLATE_BRANCH=$(echo "$line" | sed 's/^template-branch:[[:space:]]*//' | sed 's/"//g' | sed "s/'//g") + in_multiline="" + ;; + include:*) + in_multiline="include" + ;; + exclude:*) + in_multiline="exclude" + ;; + esac + fi +done < "$TEMPLATE_CONFIG" + +# Validate required fields +if [ -z "$TEMPLATE_REPO" ]; then + printf "%b[ERROR] template-repository not found in %s%b\n" "$RED" "$TEMPLATE_CONFIG" "$RESET" + exit 1 +fi + +if [ -z "$TEMPLATE_BRANCH" ]; then + printf "%b[WARN] template-branch not specified, using 'main'%b\n" "$YELLOW" "$RESET" + TEMPLATE_BRANCH="main" +fi + +if [ -z "$INCLUDE_LIST" ]; then + printf "%b[ERROR] include list is empty in %s%b\n" "$RED" "$TEMPLATE_CONFIG" "$RESET" + exit 1 +fi + +printf "%b[INFO] Template repository: %s%b\n" "$GREEN" "$TEMPLATE_REPO" "$RESET" +printf "%b[INFO] Template branch: %s%b\n" "$GREEN" "$TEMPLATE_BRANCH" "$RESET" +printf "%b[INFO] Files to sync:%b\n" "$GREEN" "$RESET" +echo "$INCLUDE_LIST" | while IFS= read -r item; do + [ -n "$item" ] && printf " - %s\n" "$item" +done || true + +if [ -n "$EXCLUDE_LIST" ]; then + printf "%b[INFO] Files to exclude:%b\n" "$YELLOW" "$RESET" + echo "$EXCLUDE_LIST" | while IFS= read -r item; do + [ -n "$item" ] && printf " - %s\n" "$item" + done || true +fi + +if [ -n "$DRY_RUN" ]; then + printf "\n%b[DRY RUN] No changes will be made%b\n" "$YELLOW" "$RESET" + exit 0 +fi + +# Create temporary directory +mkdir -p "$TEMP_DIR" +trap 'rm -rf "$TEMP_DIR"' EXIT INT TERM + +# Backup this script to avoid being overwritten during sync +SELF_SCRIPT=".github/scripts/sync.sh" +if [ -f "$SELF_SCRIPT" ]; then + cp "$SELF_SCRIPT" "$TEMP_DIR/sync.sh.bak" +fi + +# Clone the template repository +printf "\n%b[INFO] Cloning template repository...%b\n" "$BLUE" "$RESET" +REPO_URL="https://github.com/${TEMPLATE_REPO}.git" + +if ! git clone --depth 1 --branch "$TEMPLATE_BRANCH" "$REPO_URL" "$TEMP_DIR/template" 2>/dev/null; then + printf "%b[ERROR] Failed to clone template repository from %s%b\n" "$RED" "$REPO_URL" "$RESET" + exit 1 +fi + +# Function to check if a file path should be excluded +is_file_excluded() { + file_path="$1" + if [ -z "$EXCLUDE_LIST" ]; then + return 1 # Not excluded (false) + fi + + while IFS= read -r exclude_item || [ -n "$exclude_item" ]; do + [ -z "$exclude_item" ] && continue + if [ "$file_path" = "$exclude_item" ]; then + return 0 # Is excluded (true) + fi + done </dev/null || true + printf " %b[SYNC]%b .github/scripts/sync.sh (finalized)\n" "$GREEN" "$RESET" + fi +fi + +printf "\n%b[INFO] Sync complete!%b\n" "$GREEN" "$RESET" +printf " Synced: %d files/directories\n" "$synced_count" +if [ "$skipped_count" -gt 0 ]; then + printf " Skipped: %d files/directories\n" "$skipped_count" +fi + +printf "\n%b[INFO] Review the changes with: git status%b\n" "$BLUE" "$RESET" +printf "%b[INFO] Commit the changes with: git add . && git commit -m 'chore: sync template files'%b\n" "$BLUE" "$RESET" diff --git a/.github/workflows/book.yml b/.github/workflows/book.yml index bc048ffd..689e159a 100644 --- a/.github/workflows/book.yml +++ b/.github/workflows/book.yml @@ -40,9 +40,17 @@ jobs: with: lfs: true + # Determine the Python version to use + - name: Get Python version + id: get-python + run: | + echo "python-version=$(python ./.github/workflows/scripts/version_max.py)" >> "$GITHUB_OUTPUT" + # Use the composite action to set up the project - name: Setup the project uses: ./.github/actions/setup-project + with: + python-version: ${{ steps.get-python.outputs.python-version }} - name: "Make the book" run: | @@ -57,6 +65,10 @@ jobs: # Step 6: Deploy the packaged artifacts to GitHub Pages # This step publishes the content to GitHub Pages + # The deployment is conditional based on whether the repository is a fork and the PUBLISH_COMPANION_BOOK variable is set + # If the repository is a fork, deployment is skipped to avoid unauthorized publishing + # If PUBLISH_COMPANION_BOOK is not set, it defaults to allowing deployment - name: Deploy to GitHub Pages - if: ${{ !github.event.repository.fork && github.event.repository.visibility == 'public'}} + if: ${{ !github.event.repository.fork && (vars.PUBLISH_COMPANION_BOOK == 'true' || vars.PUBLISH_COMPANION_BOOK == '') }} uses: actions/deploy-pages@v4 # Official GitHub Pages deployment action + continue-on-error: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf2d5d16..dbf24111 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,17 +3,11 @@ # # Workflow: Continuous Integration # -# Purpose: This workflow runs tests on multiple Python versions to ensure -# compatibility and code quality across different environments. +# Purpose: Run tests on multiple Python versions to ensure compatibility. # -# Trigger: This workflow runs on every push and on pull requests to main/master -# branches (including from forks) -# -# Components: -# - πŸ§ͺ Run tests on multiple Python versions -# - πŸ”„ Matrix strategy for testing on different environments +# Trigger: On push and pull requests to main/master branches. -name: "CI" +name: CI permissions: contents: read @@ -21,39 +15,51 @@ permissions: on: push: pull_request: - branches: [ main, master ] + branches: [main, master] jobs: - test: - # if: github.repository != 'tschm/.config-templates' - # The type of runner that the job will run on - runs-on: ${{ matrix.os }} + generate-matrix: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.versions.outputs.list }} + steps: + - uses: actions/checkout@v6 + + - id: versions + run: | + # Generate Python versions JSON from the script + JSON=$(python ./.github/workflows/scripts/version_matrix.py) + echo "list=$JSON" >> "$GITHUB_OUTPUT" + - name: Debug matrix + run: | + echo "Python versions: ${{ steps.versions.outputs.list }}" + + test: + needs: generate-matrix + runs-on: ubuntu-latest strategy: matrix: - # mongo container action only supported on Linux - os: [ ubuntu-latest ] - python-version: [ '3.11', '3.12', '3.13', '3.14'] + python-version: ${{ fromJson(needs.generate-matrix.outputs.matrix) }} + fail-fast: false - # Steps represent a sequence of tasks that will be executed as part of the job steps: - name: Checkout repository uses: actions/checkout@v6 with: lfs: true - # Use the composite action to set up the project - name: Setup the project - id: setup uses: ./.github/actions/setup-project with: python-version: ${{ matrix.python-version }} - - name: "Run the tests" + - name: Install dependencies run: | - # if there is a tests/requirements.txt file, install it if [ -f "tests/requirements.txt" ]; then uv pip install -r tests/requirements.txt fi uv pip install pytest - uv run pytest tests + + - name: Run tests + run: uv run pytest tests diff --git a/.github/workflows/deptry.yml b/.github/workflows/deptry.yml index 5af508cf..8111bac1 100644 --- a/.github/workflows/deptry.yml +++ b/.github/workflows/deptry.yml @@ -26,7 +26,7 @@ jobs: name: Check dependencies with deptry runs-on: ubuntu-latest container: - image: ghcr.io/astral-sh/uv:0.9.15-python3.12-trixie + image: ghcr.io/astral-sh/uv:0.9.16-python3.12-trixie steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/devcontainer.yml b/.github/workflows/devcontainer.yml new file mode 100644 index 00000000..54c387cb --- /dev/null +++ b/.github/workflows/devcontainer.yml @@ -0,0 +1,137 @@ +# This file is part of the tschm/.config-templates repository +# (https://github.com/tschm/.config-templates). +# +# Devcontainer CI Workflow +# +# Purpose: +# Validates that the devcontainer image builds successfully when devcontainer-related +# files are changed. This workflow does NOT publish/push the image to any registry. +# +# Trigger Conditions: +# - Push to any branch when files in .devcontainer/ change +# - Pull requests to main/master when files in .devcontainer/ change +# - Changes to this workflow file itself +# +# Image Configuration: +# - Registry: Defaults to ghcr.io (override with DEVCONTAINER_REGISTRY variable) +# - Image name: {registry}/{owner}/{repository}/devcontainer +# - Image tag: {branch}-{commit-sha} (e.g., main-abc123def456) +# - Config file: Always .devcontainer/devcontainer.json +# +# Safeguards: +# - Checks if .devcontainer/devcontainer.json exists before building +# - Skips gracefully with a warning if devcontainer.json is not found +# - Converts repository owner to lowercase for Docker compatibility +# +# Publishing: +# Publishing only happens during releases via release.yml when the +# PUBLISH_DEVCONTAINER repository variable is set to "true". +# +# For repos without devcontainers: +# This workflow won't trigger unless .devcontainer/ files exist and are modified, +# or this workflow file itself is changed (in which case it skips gracefully). + + +name: DEVCONTAINER + +on: + push: + branches: + - '**' + paths: + - ".devcontainer/**" + - ".github/workflows/devcontainer.yml" + + pull_request: + branches: [ main, master ] + paths: + - ".devcontainer/**" + - ".github/workflows/devcontainer.yml" + +permissions: + contents: read + packages: write + +jobs: + build: + name: Build Devcontainer Image + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set registry + id: registry + run: | + REGISTRY="${{ vars.DEVCONTAINER_REGISTRY }}" + if [ -z "$REGISTRY" ]; then + REGISTRY="ghcr.io" + fi + echo "registry=$REGISTRY" >> $GITHUB_OUTPUT + + - name: Login to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ steps.registry.outputs.registry }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Additional gate to skip build if no devcontainer.json exists + - name: Check devcontainer exists + id: check + run: | + if [ ! -f ".devcontainer/devcontainer.json" ]; then + echo "exists=false" >> $GITHUB_OUTPUT + echo "::warning::No .devcontainer/devcontainer.json found, skipping build" + else + echo "exists=true" >> $GITHUB_OUTPUT + fi + # repository owner to lowercase for Docker image naming, as devcontainers/ci does not safeguard + - name: Get lowercase repository owner + id: repo_owner + run: echo "owner_lc=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT + + - name: Get Image Name + if: steps.check.outputs.exists == 'true' + id: image_name + run: | + # Check if custom name is provided, otherwise use default + if [ -z "${{ vars.DEVCONTAINER_IMAGE_NAME }}" ]; then + REPO_NAME_LC=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]') + # Sanitize repo name: replace invalid characters with hyphens + # Docker image names must match [a-z0-9]+([._-][a-z0-9]+)* + # Replace leading dots and multiple consecutive separators + REPO_NAME_SANITIZED=$(echo "$REPO_NAME_LC" | sed 's/^[._-]*//; s/[._-][._-]*/-/g') + IMAGE_NAME="$REPO_NAME_SANITIZED/devcontainer" + echo "Using default image name component: $IMAGE_NAME" + else + IMAGE_NAME="${{ vars.DEVCONTAINER_IMAGE_NAME }}" + echo "Using custom image name component: $IMAGE_NAME" + fi + + # Validate the image component matches [a-z0-9]+([._-][a-z0-9]+)* with optional / separators + if ! echo "$IMAGE_NAME" | grep -qE '^[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*$'; then + echo "::error::Invalid image name component: $IMAGE_NAME" + echo "::error::Each component must match [a-z0-9]+([._-][a-z0-9]+)* separated by /" + exit 1 + fi + + IMAGE_NAME="${{ steps.registry.outputs.registry }}/${{ steps.repo_owner.outputs.owner_lc }}/$IMAGE_NAME" + echo "βœ… Final image name: $IMAGE_NAME" + echo "image_name=$IMAGE_NAME" >> $GITHUB_OUTPUT + + - name: Sanitize Image Tag + if: steps.check.outputs.exists == 'true' + id: sanitized_tag + run: | + SANITIZED_TAG=$(echo "${{ github.ref_name }}-${{ github.sha }}" | tr '/' '-') + echo "sanitized_tag=$SANITIZED_TAG" >> $GITHUB_OUTPUT + + - name: Build Devcontainer Image + uses: devcontainers/ci@v0.3 + if: steps.check.outputs.exists == 'true' + with: + configFile: .devcontainer/devcontainer.json + push: never + imageName: ${{ steps.image_name.outputs.image_name }} + imageTag: ${{ steps.sanitized_tag.outputs.sanitized_tag }} diff --git a/.github/workflows/marimo.yml b/.github/workflows/marimo.yml index 1fa8b0d1..f96edef9 100644 --- a/.github/workflows/marimo.yml +++ b/.github/workflows/marimo.yml @@ -75,9 +75,17 @@ jobs: with: lfs: true + # Determine the Python version to use + - name: Get Python version + id: get-python + run: | + echo "python-version=$(python ./.github/workflows/scripts/version_max.py)" >> "$GITHUB_OUTPUT" + # Use the composite action to set up the project - name: Setup the project uses: ./.github/actions/setup-project + with: + python-version: ${{ steps.get-python.outputs.python-version }} # Execute the notebook with the appropriate runner based on its content - name: Run notebook diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 24bd880d..a8b2f83e 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -29,7 +29,14 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 + + - name: Get Python version + id: get-python + run: | + echo "python-version=$(python ./.github/workflows/scripts/version_max.py)" >> "$GITHUB_OUTPUT" + + - uses: ./.github/actions/setup-project with: - python-version: '3.14' + python-version: ${{ steps.get-python.outputs.python-version }} + - uses: pre-commit/action@v3.0.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 93036739..86216ca8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,44 +1,64 @@ # This file is part of the tschm/.config-templates repository # (https://github.com/tschm/.config-templates). # -# Manual Release Workflow for Python Package using Hatch and -# Trusted Publisher (OIDC) +# Release Workflow for Python Packages with Optional Devcontainer Publishing # -# This workflow implements a secure, maintainable release pipeline -# by separating concerns: -# - πŸ” Pre-flight checks (Tag/Release existence) -# - πŸ”– Tagging the release (Git tag) -# - πŸ—οΈ Building the package with Hatch -# - 🐳 Building and publishing Devcontainer image (if present) -# - πŸš€ Publishing to PyPI using OIDC (no passwords or secrets) or custom feed -# - πŸ“¦ Creating a GitHub Release with artifacts (dist + devcontainer image) +# This workflow implements a secure, maintainable release pipeline with distinct phases: +# +# πŸ“‹ Pipeline Phases: +# 1. πŸ” Validate Tag - Check tag format and ensure release doesn't already exist +# 2. πŸ—οΈ Build - Build Python package with Hatch (if pyproject.toml exists) +# 3. πŸ“ Draft Release - Create draft GitHub release with build artifacts +# 4. πŸš€ Publish to PyPI - Publish package using OIDC or custom feed +# 5. 🐳 Publish Devcontainer - Build and publish devcontainer image (conditional) +# 6. βœ… Finalize Release - Publish the GitHub release with links +# +# 🐳 Devcontainer Publishing: +# - Only occurs when PUBLISH_DEVCONTAINER repository variable is set to "true" +# - Requires .devcontainer directory to exist +# - Uses the release tag (e.g., v1.2.3) as the image tag +# - Image name: {registry}/{owner}/{repository}/devcontainer +# - Registry defaults to ghcr.io (override with DEVCONTAINER_REGISTRY variable) +# - Config file: Always .devcontainer/devcontainer.json +# - Adds devcontainer image link to GitHub release notes +# +# πŸš€ PyPI Publishing: +# - Skipped if no dist/ artifacts exist +# - Skipped if pyproject.toml contains "Private :: Do Not Upload" +# - Uses Trusted Publishing (OIDC) for PyPI (no stored credentials) +# - For custom feeds, use PYPI_REPOSITORY_URL and PYPI_TOKEN secrets +# - Adds PyPI/custom feed link to GitHub release notes # # πŸ” Security: -# - No PyPI credentials are stored; relies on Trusted Publishing via GitHub OIDC. -# - For custom feeds, secrets are used, default username is `__token__`, which is set in action by default anyway. +# - No PyPI credentials stored; relies on Trusted Publishing via GitHub OIDC +# - For custom feeds, PYPI_TOKEN secret is used with default username __token__ +# - Container registry uses GITHUB_TOKEN for authentication # # πŸ“„ Requirements: -# - `pyproject.toml` with a top-level `version = "..."` -# - Package is registered on PyPI as a Trusted Publisher with this repository +# - pyproject.toml with top-level version field (for Python packages) +# - Package registered on PyPI as Trusted Publisher (for PyPI publishing) +# - PUBLISH_DEVCONTAINER variable set to "true" (for devcontainer publishing) +# - .devcontainer/devcontainer.json file (for devcontainer publishing) +# +# βœ… To Trigger: +# Create and push a version tag: +# git tag v1.2.3 +# git push origin v1.2.3 +# +# 🎯 For repos without Python packages: +# The workflow gracefully skips Python-related steps if pyproject.toml doesn't exist. # -# βœ… To trigger: -# - Go to the "Actions" tab -# - Run this workflow manually with a tag input like `v1.2.3` +# 🎯 For repos without devcontainers: +# The workflow gracefully skips devcontainer steps if PUBLISH_DEVCONTAINER is not +# set to "true" or .devcontainer directory doesn't exist. + name: RELEASE on: - workflow_dispatch: - inputs: - tag: - description: 'Release tag (e.g. v1.2.3)' - required: true - type: string - release_devcontainer: - description: 'Release devcontainer image' - required: false - default: false - type: boolean + push: + tags: + - 'v*' permissions: contents: write # Needed to create releases @@ -47,24 +67,28 @@ permissions: jobs: tag: - name: Create Git Tag + name: Validate Tag runs-on: ubuntu-latest outputs: - devcontainer_exists: ${{ steps.check_devcontainer.outputs.exists }} - image_name: ${{ steps.image_name.outputs.name }} + tag: ${{ steps.set_tag.outputs.tag }} steps: - name: Checkout Code uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.ref }} + + - name: Set Tag Variable + id: set_tag + run: | + TAG="${GITHUB_REF#refs/tags/}" + echo "tag=$TAG" >> $GITHUB_OUTPUT - - name: Validate Tag and Release + - name: Validate Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ github.event.inputs.tag }} + TAG: ${{ steps.set_tag.outputs.tag }} run: | - if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then - echo "::error::Tag '$TAG' already exists. Please use a new tag." - exit 1 - fi if gh release view "$TAG" >/dev/null 2>&1; then DRAFT_STATUS=$(gh release view "$TAG" --json isDraft --jq '.isDraft') if [ "$DRAFT_STATUS" != "true" ]; then @@ -75,29 +99,6 @@ jobs: fi fi - - name: Check for .devcontainer - id: check_devcontainer - run: | - if [[ "${{ github.event.inputs.release_devcontainer }}" == "true" ]] && [[ -d ".devcontainer" ]]; then - echo "exists=true" >> $GITHUB_OUTPUT - else - echo "exists=false" >> $GITHUB_OUTPUT - fi - - - name: Calculate Image Name - id: image_name - run: | - REPO="${{ github.event.repository.name }}" - CLEAN="${REPO#.}" - echo "name=${CLEAN}/devcontainer" >> $GITHUB_OUTPUT - - - name: Create Git Tag - run: | - git config user.name "${{ github.actor }}" - git config user.email "${{ github.actor }}@users.noreply.github.com" - git tag ${{ github.event.inputs.tag }} - git push origin ${{ github.event.inputs.tag }} - build: name: Build runs-on: ubuntu-latest @@ -107,20 +108,30 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 + ref: ${{ github.ref }} # Use the composite action to set up the project - name: Setup the project uses: ./.github/actions/setup-project + with: + python-version: ${{ vars.PYTHON_DEFAULT_VERSION || '3.14' }} - - name: Build + - name: Verify version matches tag if: hashFiles('pyproject.toml') != '' run: | - version=${{ github.event.inputs.tag }} - version=${version#v} - echo "Setting version to $version" - sed -i.bak "s/^version = .*/version = \"$version\"/" pyproject.toml - rm pyproject.toml.bak + TAG_VERSION="${{ needs.tag.outputs.tag }}" + TAG_VERSION=${TAG_VERSION#v} + PROJECT_VERSION=$(uv version --short) + if [[ "$PROJECT_VERSION" != "$TAG_VERSION" ]]; then + echo "::error::Version mismatch: pyproject.toml has '$PROJECT_VERSION' but tag is '$TAG_VERSION'" + exit 1 + fi + echo "Version verified: $PROJECT_VERSION matches tag" + + - name: Build + if: hashFiles('pyproject.toml') != '' + run: | printf "[INFO] Building package...\n" uvx hatch build @@ -133,27 +144,19 @@ jobs: name: dist path: dist + # Don't try to upload artifacts to GitHub Release at all draft-release: name: Draft GitHub Release runs-on: ubuntu-latest needs: [tag, build] steps: - - name: Download dist artifact - uses: actions/download-artifact@v6 - with: - name: dist - path: dist - # Fail fast if dist artifact is missing; don’t create empty releases. - # continue-on-error: true - - name: Create GitHub Release with artifacts uses: softprops/action-gh-release@v2.5.0 with: - tag_name: ${{ github.event.inputs.tag }} - name: ${{ github.event.inputs.tag }} + tag_name: ${{ needs.tag.outputs.tag }} + name: ${{ needs.tag.outputs.tag }} generate_release_notes: true - files: "dist/**" draft: true # Decide at step-level whether to publish @@ -161,19 +164,23 @@ jobs: name: Publish to PyPI runs-on: ubuntu-latest environment: release - needs: [build, tag, draft-release] + needs: [tag, build, draft-release] outputs: should_publish: ${{ steps.check_dist.outputs.should_publish }} steps: - name: Checkout Code uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.ref }} - name: Download dist artifact uses: actions/download-artifact@v6 with: name: dist path: dist + # Continue if dist folder is not found. Don't fail the job. continue-on-error: true - name: Check if dist contains artifacts and not marked as private @@ -202,44 +209,123 @@ jobs: repository-url: ${{ vars.PYPI_REPOSITORY_URL }} password: ${{ secrets.PYPI_TOKEN }} - publish-devcontainer: - name: Publish Devcontainer + devcontainer: + name: Publish Devcontainer Image + runs-on: ubuntu-latest + environment: release needs: [tag, build, draft-release] - if: needs.tag.outputs.devcontainer_exists == 'true' - uses: ./.github/workflows/_devcontainer.yml - with: - publish: true - image_name: ${{ needs.tag.outputs.image_name }} - registry: ghcr.io - image_tags: ${{ github.event.inputs.tag }} - secrets: inherit + outputs: + should_publish: ${{ steps.check_publish.outputs.should_publish }} + image_name: ${{ steps.image_name.outputs.image_name }} + steps: + - name: Checkout Code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.ref }} + + - name: Check if devcontainer should be published + id: check_publish + env: + PUBLISH_DEVCONTAINER: ${{ vars.PUBLISH_DEVCONTAINER }} + run: | + if [[ "$PUBLISH_DEVCONTAINER" == "true" ]] && [[ -d ".devcontainer" ]]; then + echo "should_publish=true" >> $GITHUB_OUTPUT + echo "πŸš€ Will build and publish devcontainer image" + else + echo "should_publish=false" >> $GITHUB_OUTPUT + echo "⏭️ Skipping devcontainer publish (PUBLISH_DEVCONTAINER not true or .devcontainer missing)" + fi + + - name: Set registry + if: steps.check_publish.outputs.should_publish == 'true' + id: registry + run: | + REGISTRY="${{ vars.DEVCONTAINER_REGISTRY }}" + if [ -z "$REGISTRY" ]; then + REGISTRY="ghcr.io" + fi + echo "registry=$REGISTRY" >> $GITHUB_OUTPUT + + - name: Login to Container Registry + if: steps.check_publish.outputs.should_publish == 'true' + uses: docker/login-action@v3 + with: + registry: ${{ steps.registry.outputs.registry }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Get lowercase repository owner + if: steps.check_publish.outputs.should_publish == 'true' + id: repo_owner + run: echo "owner_lc=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT + + - name: Get Image Name + if: steps.check_publish.outputs.should_publish == 'true' + id: image_name + run: | + # Check if custom name is provided, otherwise use default + if [ -z "${{ vars.DEVCONTAINER_IMAGE_NAME }}" ]; then + REPO_NAME_LC=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]') + # Sanitize repo name: replace invalid characters with hyphens + # Docker image names must match [a-z0-9]+([._-][a-z0-9]+)* + # Replace leading dots and multiple consecutive separators + REPO_NAME_SANITIZED=$(echo "$REPO_NAME_LC" | sed 's/^[._-]*//; s/[._-][._-]*/-/g') + IMAGE_NAME="$REPO_NAME_SANITIZED/devcontainer" + echo "Using default image name component: $IMAGE_NAME" + else + IMAGE_NAME="${{ vars.DEVCONTAINER_IMAGE_NAME }}" + echo "Using custom image name component: $IMAGE_NAME" + fi + + # Validate the image component matches [a-z0-9]+([._-][a-z0-9]+)* with optional / separators + if ! echo "$IMAGE_NAME" | grep -qE '^[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*$'; then + echo "::error::Invalid image name component: $IMAGE_NAME" + echo "::error::Each component must match [a-z0-9]+([._-][a-z0-9]+)* separated by /" + exit 1 + fi + + IMAGE_NAME="${{ steps.registry.outputs.registry }}/${{ steps.repo_owner.outputs.owner_lc }}/$IMAGE_NAME" + echo "βœ… Final image name: $IMAGE_NAME" + echo "image_name=$IMAGE_NAME" >> $GITHUB_OUTPUT + + - name: Build and Publish Devcontainer Image + if: steps.check_publish.outputs.should_publish == 'true' + uses: devcontainers/ci@v0.3 + with: + configFile: .devcontainer/devcontainer.json + push: always + imageName: ${{ steps.image_name.outputs.image_name }} + imageTag: ${{ needs.tag.outputs.tag }} finalise-release: name: Finalise Release runs-on: ubuntu-latest - needs: [tag, draft-release, pypi, publish-devcontainer] - if: | - always() && - needs.tag.result == 'success' && - needs.draft-release.result == 'success' && - (needs.pypi.result == 'success' || needs.pypi.result == 'skipped') && - (needs.publish-devcontainer.result == 'success' || needs.publish-devcontainer.result == 'skipped') + needs: [tag, pypi, devcontainer] + if: needs.pypi.result == 'success' || needs.devcontainer.result == 'success' steps: - name: Checkout Code uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.ref }} + + - name: Get Python version + id: get-python + run: | + echo "python-version=$(python ./.github/workflows/scripts/version_max.py)" >> "$GITHUB_OUTPUT" - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.14' + python-version: ${{ fromJSON(steps.get-python.outputs.python-version) }} - name: Generate Devcontainer Link id: devcontainer_link - if: needs.publish-devcontainer.result == 'success' + if: needs.devcontainer.outputs.should_publish == 'true' && needs.devcontainer.result == 'success' run: | OWNER_LC=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') - IMAGE_NAME="${{ needs.tag.outputs.image_name }}" - FULL_IMAGE="ghcr.io/$OWNER_LC/$IMAGE_NAME:${{ github.event.inputs.tag }}" + FULL_IMAGE="${{ needs.devcontainer.outputs.image_name }}:${{ needs.tag.outputs.tag }}" { echo "message< list[str]: + """Return all supported Python versions declared in pyproject.toml. + + Reads project.requires-python, evaluates candidate versions against the + specifier, and returns the subset that satisfy the constraint, in ascending order. + + Returns: + list[str]: The supported versions (e.g., ["3.11", "3.12"]). + """ + with PYPROJECT.open("rb") as f: + data = tomllib.load(f) + + spec_str = data.get("project", {}).get("requires-python") + if not spec_str: + msg = "pyproject.toml: missing 'project.requires-python'" + raise KeyError(msg) + + spec = SpecifierSet(spec_str) + + versions: list[str] = [] + for v in CANDIDATES: + if Version(v) in spec: + versions.append(v) + + if not versions: + msg = "pyproject.toml: no supported Python versions match 'project.requires-python'" + raise ValueError(msg) + + return versions + + +if __name__ == "__main__": + if PYPROJECT.exists(): + print(json.dumps(supported_versions())) + else: + print(json.dumps(CANDIDATES)) diff --git a/.github/workflows/scripts/version_max.py b/.github/workflows/scripts/version_max.py new file mode 100755 index 00000000..6b116337 --- /dev/null +++ b/.github/workflows/scripts/version_max.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Emit the maximum supported Python version from pyproject.toml. + +This helper is used in GitHub Actions to pick a default interpreter. +""" + +import json +import tomllib +from pathlib import Path + +from packaging.specifiers import SpecifierSet +from packaging.version import Version + +PYPROJECT = Path(__file__).resolve().parents[3] / "pyproject.toml" +CANDIDATES = ["3.11", "3.12", "3.13", "3.14"] # extend as needed + + +def max_supported_version() -> str: + """Return the highest supported Python version from pyproject.toml. + + Reads project.requires-python, evaluates candidate versions against the + specifier, and returns the maximum version that satisfies the constraint. + + Returns: + str: The maximum Python version (e.g., "3.13") satisfying the spec. + """ + with PYPROJECT.open("rb") as f: + data = tomllib.load(f) + + spec_str = data.get("project", {}).get("requires-python") + if not spec_str: + msg = "pyproject.toml: missing 'project.requires-python'" + raise KeyError(msg) + + spec = SpecifierSet(spec_str) + max_version = None + + for v in CANDIDATES: + if Version(v) in spec: + max_version = v + + if max_version is None: + msg = "pyproject.toml: no supported Python versions match 'project.requires-python'" + raise ValueError(msg) + + return max_version + + +if __name__ == "__main__": + if PYPROJECT.exists(): + print(json.dumps(max_supported_version())) + else: + print(json.dumps("3.13")) diff --git a/.gitignore b/.gitignore index 3b058818..0c95f872 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,14 @@ __pycache__/ # C extensions *.so +# Simplify the move of the test scripts +tests/test_makefile.py +tests/test_readme.py +tests/test_release_script.py +tests/test_sync_script.py +tests/test_docstrings.py +tests/test_bump_script.py + # Distribution / packaging .Python build/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dcf138f7..000db6f7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: - id: check-yaml - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.14.7' + rev: 'v0.14.8' hooks: - id: ruff args: [ --fix, --exit-non-zero-on-fix, --unsafe-fixes ] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ceab78d..d8603155 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ Contributions are made through [pull requests](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests). Before sending a pull request, make sure you do the following: -- Run 'make check' to make sure your code adheres to our [coding style](#code-style) +- Run `make fmt` to make sure your code adheres to our [coding style](#code-style) and all tests pass. - [Write unit tests](#writing-unit-tests) for new functionality added. @@ -62,7 +62,7 @@ Before sending us a pull request, navigate to the project root and run ```bash -make check +make fmt ``` to make sure that your changes abide by our style conventions. diff --git a/Makefile b/Makefile index 1fa51fd8..e25d0fee 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ RESET := \033[0m .DEFAULT_GOAL := help # Declare phony targets (they don't produce files) -.PHONY: install-uv install clean test marimo marimushka book fmt deptry docs help all +.PHONY: install-uv install clean test marimo marimushka book fmt deptry docs release release-dry-run post-release sync help all UV_INSTALL_DIR := ./bin UV_BIN := ${UV_INSTALL_DIR}/uv @@ -27,6 +27,8 @@ TESTS_FOLDER := tests SOURCE_FOLDER := src BOOK_TITLE := Project Documentation BOOK_SUBTITLE := Generated by minibook +SCRIPTS_FOLDER := .github/scripts +CUSTOM_SCRIPTS_FOLDER := .github/scripts/customisations export UV_NO_MODIFY_PATH := 1 export UV_VENV_CLEAR := 1 @@ -48,14 +50,14 @@ install-uv: ## ensure uv/uvx is installed fi install-extras: ## run custom build script (if exists) - @if [ -x ".github/scripts/build-extras.sh" ]; then \ - printf "${BLUE}[INFO] Running custom build script...${RESET}\n"; \ - ./.github/scripts/build-extras.sh; \ - elif [ -f ".github/scripts/build-extras.sh" ]; then \ - printf "${BLUE}[INFO] Running custom build script...${RESET}\n"; \ - /bin/sh .github/scripts/build-extras.sh; \ + @if [ -x "${CUSTOM_SCRIPTS_FOLDER}/build-extras.sh" ]; then \ + printf "${BLUE}[INFO] Running custom build script from customisations folder...${RESET}\n"; \ + "${CUSTOM_SCRIPTS_FOLDER}"/build-extras.sh; \ + elif [ -f "${CUSTOM_SCRIPTS_FOLDER}/build-extras.sh" ]; then \ + printf "${BLUE}[INFO] Running custom build script from customisations folder...${RESET}\n"; \ + /bin/sh "${CUSTOM_SCRIPTS_FOLDER}/build-extras.sh"; \ else \ - printf "${BLUE}[INFO] No custom build script found, skipping...${RESET}\n"; \ + printf "${BLUE}[INFO] No custom build script in ${CUSTOM_SCRIPTS_FOLDER}, skipping...${RESET}\n"; \ fi install: install-uv install-extras ## install @@ -116,7 +118,7 @@ marimushka: install ## export Marimo notebooks to HTML printf "${YELLOW}[WARN] Directory '${MARIMO_FOLDER}' does not exist. Skipping marimushka.${RESET}\n"; \ else \ ${UV_BIN} pip install marimo; \ - MARIMO_FOLDER="${MARIMO_FOLDER}" UV_BIN="${UV_BIN}" UVX_BIN="${UVX_BIN}" /bin/sh .github/scripts/marimushka.sh; \ + MARIMO_FOLDER="${MARIMO_FOLDER}" UV_BIN="${UV_BIN}" UVX_BIN="${UVX_BIN}" /bin/sh "${SCRIPTS_FOLDER}/marimushka.sh"; \ fi deptry: install-uv ## run deptry if pyproject.toml exists @@ -128,16 +130,21 @@ deptry: install-uv ## run deptry if pyproject.toml exists ##@ Documentation docs: install-uv ## create documentation with pdoc - @if [ -d ${SOURCE_FOLDER} ]; then \ - ${UV_BIN} pip install pdoc; \ - ${UV_BIN} run pdoc -o _pdoc ${SOURCE_FOLDER}/*; \ + @if [ -d "${SOURCE_FOLDER}" ]; then \ + PKGS=""; for d in "${SOURCE_FOLDER}"/*; do [ -d "$$d" ] && PKGS="$$PKGS $$(basename "$$d")"; done; \ + if [ -z "$$PKGS" ]; then \ + printf "${YELLOW}[WARN] No packages found under ${SOURCE_FOLDER}, skipping docs${RESET}\n"; \ + else \ + ${UV_BIN} pip install pdoc && \ + PYTHONPATH="${SOURCE_FOLDER}" ${UV_BIN} run pdoc --docformat google --output-dir _pdoc $$PKGS; \ + fi; \ else \ printf "${YELLOW}[WARN] Source folder ${SOURCE_FOLDER} not found, skipping docs${RESET}\n"; \ fi book: test docs marimushka ## compile the companion book @${UV_BIN} pip install marimo - @/bin/sh .github/scripts/book.sh + @/bin/sh "${SCRIPTS_FOLDER}/book.sh" @${UVX_BIN} minibook --title "${BOOK_TITLE}" --subtitle "${BOOK_SUBTITLE}" --links "$$(python3 -c 'import json,sys; print(json.dumps(json.load(open("_book/links.json"))))')" --output "_book" @touch "_book/.nojekyll" @@ -147,13 +154,62 @@ fmt: install-uv ## check the pre-commit hooks and the linting all: fmt deptry book ## Run everything echo "Run fmt, deptry, test and book" +##@ Releasing and Versioning +bump: install-uv ## bump version + @UV_BIN="${UV_BIN}" /bin/sh "${SCRIPTS_FOLDER}/bump.sh" + +release: install-uv ## create tag and push to remote with prompts + @UV_BIN="${UV_BIN}" /bin/sh "${SCRIPTS_FOLDER}/release.sh" + +post-release: install-uv ## perform post-release tasks + @if [ -x "${CUSTOM_SCRIPTS_FOLDER}/post-release.sh" ]; then \ + printf "${BLUE}[INFO] Running post-release script from customisations folder...${RESET}\n"; \ + "${CUSTOM_SCRIPTS_FOLDER}"/post-release.sh; \ + elif [ -f "${CUSTOM_SCRIPTS_FOLDER}/post-release.sh" ]; then \ + printf "${BLUE}[INFO] Running post-release script from customisations folder...${RESET}\n"; \ + /bin/sh "${CUSTOM_SCRIPTS_FOLDER}/post-release.sh"; \ + else \ + printf "${BLUE}[INFO] No post-release script in ${CUSTOM_SCRIPTS_FOLDER}, skipping...${RESET}\n"; \ + fi + ##@ Meta +sync: ## sync with template repository as defined in .github/template.yml + @/bin/sh "${SCRIPTS_FOLDER}/sync.sh" + help: ## Display this help message +@printf "$(BOLD)Usage:$(RESET)\n" +@printf " make $(BLUE)$(RESET)\n\n" +@printf "$(BOLD)Targets:$(RESET)\n" +@awk 'BEGIN {FS = ":.*##"; printf ""} /^[a-zA-Z_-]+:.*?##/ { printf " $(BLUE)%-15s$(RESET) %s\n", $$1, $$2 } /^##@/ { printf "\n$(BOLD)%s$(RESET)\n", substr($$0, 5) }' $(MAKEFILE_LIST) +customisations: ## list available customisation scripts + @printf "${BLUE}${BOLD}Customisation scripts available in ${CUSTOM_SCRIPTS_FOLDER}:$(RESET)\n" + @if [ -d "${CUSTOM_SCRIPTS_FOLDER}" ]; then \ + ls -1 "${CUSTOM_SCRIPTS_FOLDER}"/*.sh 2>/dev/null || printf " (none)\n"; \ + else \ + printf "${YELLOW}[INFO] No customisations found in ${CUSTOM_SCRIPTS_FOLDER}${RESET}\n"; \ + fi + # debugger tools -print-% : - @echo $* = $($*) \ No newline at end of file +custom-%: ## run a custom script (usage: make custom-scriptname) + @SCRIPT="${CUSTOM_SCRIPTS_FOLDER}/$*.sh"; \ + if [ -x "$$SCRIPT" ]; then \ + printf "${BLUE}[INFO] Running custom script $$SCRIPT...${RESET}\n"; \ + "$$SCRIPT"; \ + elif [ -f "$$SCRIPT" ]; then \ + printf "${BLUE}[INFO] Running custom script $$SCRIPT with /bin/sh...${RESET}\n"; \ + /bin/sh "$$SCRIPT"; \ + else \ + printf "${RED}[ERROR] Custom script '$$SCRIPT' not found.${RESET}\n"; \ + printf "Available scripts:\n"; \ + ls -1 "${CUSTOM_SCRIPTS_FOLDER}"/*.sh 2>/dev/null | xargs -n1 basename | sed 's/\.sh$$//' | sed 's/^/ - /'; \ + exit 1; \ + fi + +print-% : ## print the value of a variable (usage: make print-VARIABLE) + @printf "${BLUE}[INFO] Printing value of variable '$*':${RESET}\n" + @printf "${BOLD}Value of $*:${RESET}\n" + @printf "${GREEN}" + @printf "%s\n" "$($*)" + @printf "${RESET}" + @printf "${BLUE}[INFO] End of value for '$*'${RESET}\n" diff --git a/tests/conftest.py b/tests/conftest.py index 00843ee6..27a7a830 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,88 +1,29 @@ -"""Pytest configuration and fixtures for the tests.""" +"""Shared pytest fixtures for the test suite. -import json -from pathlib import Path - -import pandas as pd -import pytest -from pandas import DataFrame - - -@pytest.fixture(scope="session", name="resource_dir") -def resource_fixture() -> Path: - """Fixture that provides the path to the test resources directory. - - This fixture is session-scoped, meaning it's created once per test session. - - Returns: - Path: The path to the test resources directory. - """ - return Path(__file__).parent / "resources" - - -@pytest.fixture(name="root_dir") -def root_fixture(resource_dir: Path) -> Path: - """Fixture that provides the path to the project root directory. - - Args: - resource_dir: Path to the test resources directory. - - Returns: - Path: The path to the project root directory. - """ - return resource_dir.parent.parent.parent +Provides the 'root' fixture that returns the repository root as a pathlib.Path, +enabling tests to locate files and scripts relative to the project root. +""" +import logging +import pathlib -@pytest.fixture(scope="session") -def prices(resource_dir: Path) -> DataFrame: - """Fixture that provides a DataFrame of stock prices. - - This fixture is session-scoped, meaning it's created once per test session. - It loads stock price data from a CSV file and converts column names to Asset objects. - - Args: - resource_dir: Path to the test resources directory. - - Returns: - DataFrame: A DataFrame containing stock prices with Asset objects as column names. - """ - # Load price data from CSV file - _prices = pd.read_csv(resource_dir / "stock_prices.csv", parse_dates=True, index_col="date").truncate( - before="2017-01-01" - ) - - return _prices +import pytest @pytest.fixture(scope="session") -def returns(prices: DataFrame) -> DataFrame: - """Fixture that provides a DataFrame of stock returns. - - This fixture is session-scoped, meaning it's created once per test session. - It calculates returns from the prices DataFrame and handles missing values. - - Args: - prices: DataFrame of stock prices. +def root(): + """Return the repository root directory as a pathlib.Path. - Returns: - DataFrame: A DataFrame containing stock returns. + Used by tests to locate files and scripts relative to the project root. """ - # Calculate returns from prices and handle missing values - return prices.pct_change().dropna(axis=0, how="all").fillna(0.0) + return pathlib.Path(__file__).parent.parent @pytest.fixture(scope="session") -def market_data(resource_dir: Path) -> dict: - """Fixture that provides market data in JSON format. - - This fixture is session-scoped, meaning it's created once per test session. - It loads market data from a JSON file in the test resources directory. - - Args: - resource_dir: Path to the test resources directory. +def logger(): + """Provide a session-scoped logger for tests. Returns: - dict: A dictionary containing market data loaded from JSON. + logging.Logger: Logger configured for the test session. """ - with open(resource_dir / "market_data.json") as f: - return json.load(f) + return logging.getLogger(__name__) diff --git a/tests/test_config_templates/conftest.py b/tests/test_config_templates/conftest.py new file mode 100644 index 00000000..a7171cde --- /dev/null +++ b/tests/test_config_templates/conftest.py @@ -0,0 +1,185 @@ +"""Pytest configuration and fixtures for setting up a mock git repository with versioning and GPG signing.""" + +import os +import shutil +import subprocess + +import pytest + +MOCK_UV_SCRIPT = """#!/usr/bin/env python3 +import sys +import re + +def get_version(): + with open("pyproject.toml", "r") as f: + content = f.read() + match = re.search(r'version = "(.*?)"', content) + return match.group(1) if match else "0.0.0" + +def set_version(new_version): + with open("pyproject.toml", "r") as f: + content = f.read() + new_content = re.sub(r'version = ".*?"', f'version = "{new_version}"', content) + with open("pyproject.toml", "w") as f: + f.write(new_content) + +def bump_version(current, bump_type): + major, minor, patch = map(int, current.split('.')) + if bump_type == "major": + return f"{major + 1}.0.0" + elif bump_type == "minor": + return f"{major}.{minor + 1}.0" + elif bump_type == "patch": + return f"{major}.{minor}.{patch + 1}" + return current + +def main(): + args = sys.argv[1:] + # Expected invocations from release.sh start with 'version' + if not args or args[0] != "version": + sys.exit(1) + + # uv version --short + if "--short" in args and "--bump" not in args: + print(get_version()) + return + + # uv version --bump --dry-run --short + if "--bump" in args and "--dry-run" in args and "--short" in args: + bump_idx = args.index("--bump") + 1 + bump_type = args[bump_idx] + current = get_version() + print(bump_version(current, bump_type)) + return + + # uv version --bump (actual update) + if "--bump" in args and "--dry-run" not in args: + bump_idx = args.index("--bump") + 1 + bump_type = args[bump_idx] + current = get_version() + new_ver = bump_version(current, bump_type) + set_version(new_ver) + return + + # uv version --dry-run + if len(args) >= 2 and not args[1].startswith("-") and "--dry-run" in args: + # Just exit 0 if valid + return + + # uv version (actual update) + if len(args) == 2 and not args[1].startswith("-"): + set_version(args[1]) + return + +if __name__ == "__main__": + main() +""" + + +@pytest.fixture +def git_repo(root, tmp_path, monkeypatch): + """Sets up a remote bare repo and a local clone with necessary files.""" + remote_dir = tmp_path / "remote.git" + local_dir = tmp_path / "local" + gnupg_home = tmp_path / "gnupg" + + # 1. Create bare remote + remote_dir.mkdir() + subprocess.run(["git", "init", "--bare", str(remote_dir)], check=True) + # Ensure the remote's default HEAD points to master for predictable behavior + subprocess.run(["git", "symbolic-ref", "HEAD", "refs/heads/master"], cwd=remote_dir, check=True) + + # 2. Clone to local + subprocess.run(["git", "clone", str(remote_dir), str(local_dir)], check=True) + + # Use monkeypatch to safely change cwd for the duration of the test + monkeypatch.chdir(local_dir) + + # Ensure local default branch is 'master' to match test expectations + subprocess.run(["git", "checkout", "-b", "master"], check=True) + + # Create pyproject.toml + with open("pyproject.toml", "w") as f: + f.write('[project]\nname = "test-project"\nversion = "0.1.0"\n') + + # Create dummy uv.lock + with open("uv.lock", "w") as f: + f.write("") + + # Create bin/uv mock + bin_dir = local_dir / "bin" + bin_dir.mkdir() + + uv_path = bin_dir / "uv" + with open(uv_path, "w") as f: + f.write(MOCK_UV_SCRIPT) + uv_path.chmod(0o755) + + # Create bin/gpg mock and add bin to PATH + gpg_path = bin_dir / "gpg" + with open(gpg_path, "w") as f: + f.write("""#!/bin/sh +# Mock gpg that produces a dummy signature for git tag -s +echo "ARGS: $@" >> /tmp/gpg_args.log + +# Check if we are signing (look for -s or -b or -bsau) +SIGN=0 +for arg in "$@"; do + case "$arg" in + *-bsau*) SIGN=1 ;; + *-s*) SIGN=1 ;; + *-b*) SIGN=1 ;; + esac +done + +if [ "$SIGN" = "1" ]; then + # Output status to stderr (fd 2) as requested by --status-fd=2 + echo "[GNUPG:] SIG_CREATED D 1 2 00 1234567890 1" >&2 + + # Output signature to stdout + echo "-----BEGIN PGP SIGNATURE-----" + echo "" + echo "Dummy Signature" + echo "-----END PGP SIGNATURE-----" +fi +exit 0 +""") + gpg_path.chmod(0o755) + + # Also provide a gpgv shim that delegates to our gpg mock (git may use gpgv for verification) + gpgv_path = bin_dir / "gpgv" + with open(gpgv_path, "w") as f: + f.write("#!/bin/sh\nexit 0\n") + gpgv_path.chmod(0o755) + + # Ensure our bin comes first on PATH so 'gpg' and 'uv' resolve to mocks + monkeypatch.setenv("PATH", f"{bin_dir}:{os.environ.get('PATH', '')}") + + # Provide a git shim to make `git tag -v` succeed in this mocked environment + git_wrapper = bin_dir / "git" + with open(git_wrapper, "w") as f: + f.write('#!/bin/sh\nif [ "$1" = "tag" ] && [ "$2" = "-v" ]; then exit 0; fi\nexec /usr/bin/git "$@"\n') + git_wrapper.chmod(0o755) + + # Copy scripts + script_dir = local_dir / ".github" / "scripts" + script_dir.mkdir(parents=True) + + shutil.copy(root / ".github" / "scripts" / "release.sh", script_dir / "release.sh") + shutil.copy(root / ".github" / "scripts" / "bump.sh", script_dir / "bump.sh") + + (script_dir / "release.sh").chmod(0o755) + (script_dir / "bump.sh").chmod(0o755) + + # Set up a test GPG key for tag signing + gnupg_home.mkdir(mode=0o700) + monkeypatch.setenv("GNUPGHOME", str(gnupg_home)) + + # Commit and push initial state + subprocess.run(["git", "config", "user.email", "test@example.com"], check=True) + subprocess.run(["git", "config", "user.name", "Test User"], check=True) + subprocess.run(["git", "add", "."], check=True) + subprocess.run(["git", "commit", "-m", "Initial commit"], check=True) + subprocess.run(["git", "push", "origin", "master"], check=True) + + yield local_dir diff --git a/tests/test_config_templates/test_bump_script.py b/tests/test_config_templates/test_bump_script.py new file mode 100644 index 00000000..ae6a44a8 --- /dev/null +++ b/tests/test_config_templates/test_bump_script.py @@ -0,0 +1,136 @@ +"""Tests for the bump.sh script using a sandboxed git environment.""" + +import subprocess + +import pytest + + +@pytest.mark.parametrize( + "choice, expected_version", + [ + ("1", "0.1.1"), # patch + ("2", "0.2.0"), # minor + ("3", "1.0.0"), # major + ], +) +def test_bump_updates_version_no_commit(git_repo, choice, expected_version): + """Running `bump` interactively updates pyproject.toml correctly.""" + script = git_repo / ".github" / "scripts" / "bump.sh" + + # Input: choice -> n (no commit) + input_str = f"{choice}\nn\n" + + result = subprocess.run([str(script)], cwd=git_repo, input=input_str, capture_output=True, text=True) + + assert result.returncode == 0 + assert f"Version bumped to {expected_version}" in result.stdout + + # Verify pyproject.toml updated + with open(git_repo / "pyproject.toml") as f: + content = f.read() + assert f'version = "{expected_version}"' in content + + # Verify no tag created yet + tags = subprocess.check_output(["git", "tag"], cwd=git_repo, text=True) + assert f"v{expected_version}" not in tags + + +def test_bump_commit_push(git_repo): + """Bump with commit and push.""" + script = git_repo / ".github" / "scripts" / "bump.sh" + + # Input: 1 (patch) -> y (commit) -> y (push) + input_str = "1\ny\ny\n" + + result = subprocess.run([str(script)], cwd=git_repo, input=input_str, capture_output=True, text=True) + + assert result.returncode == 0 + assert "Version committed" in result.stdout + assert "Pushed to origin/master" in result.stdout + + # Verify commit on remote + remote_log = subprocess.check_output(["git", "log", "origin/master", "-1", "--pretty=%B"], cwd=git_repo, text=True) + assert "chore: bump version to 0.1.1" in remote_log + + +def test_uncommitted_changes_failure(git_repo): + """Script fails if there are uncommitted changes.""" + script = git_repo / ".github" / "scripts" / "bump.sh" + + # Create a tracked file and commit it + tracked_file = git_repo / "tracked_file.txt" + tracked_file.touch() + subprocess.run(["git", "add", "tracked_file.txt"], cwd=git_repo, check=True) + subprocess.run(["git", "commit", "-m", "Add tracked file"], cwd=git_repo, check=True) + + # Modify tracked file to create uncommitted change + with open(tracked_file, "a") as f: + f.write("\n# change") + + # Input: 1 (patch) + result = subprocess.run([str(script)], cwd=git_repo, input="1\n", capture_output=True, text=True) + + assert result.returncode == 1 + assert "You have uncommitted changes" in result.stdout + + +def test_bump_explicit_version(git_repo): + """Bump with explicit version.""" + script = git_repo / ".github" / "scripts" / "bump.sh" + version = "1.2.3" + + # Input: 4 (explicit) -> 1.2.3 -> n (no commit) + input_str = f"4\n{version}\nn\n" + + result = subprocess.run([str(script)], cwd=git_repo, input=input_str, capture_output=True, text=True) + + assert result.returncode == 0 + assert f"Version bumped to {version}" in result.stdout + with open(git_repo / "pyproject.toml") as f: + content = f.read() + assert f'version = "{version}"' in content + + +def test_bump_fails_existing_tag(git_repo): + """Bump fails if tag already exists.""" + script = git_repo / ".github" / "scripts" / "bump.sh" + + # Create tag v0.1.1 + subprocess.run(["git", "tag", "v0.1.1"], cwd=git_repo, check=True) + + # Try to bump to 0.1.1 (patch bump from 0.1.0) + # Input: 1 (patch) + result = subprocess.run([str(script)], cwd=git_repo, input="1\n", capture_output=True, text=True) + + assert result.returncode == 1 + assert "Tag 'v0.1.1' already exists locally" in result.stdout + + +def test_warn_on_non_default_branch(git_repo): + """Script warns if not on default branch.""" + script = git_repo / ".github" / "scripts" / "bump.sh" + + # Create and switch to new branch + subprocess.run(["git", "checkout", "-b", "feature"], cwd=git_repo, check=True) + + # Run bump (input 1 (patch), then 'y' to proceed with non-default branch, then n (no commit)) + input_str = "1\ny\nn\n" + + result = subprocess.run([str(script)], cwd=git_repo, input=input_str, capture_output=True, text=True) + assert result.returncode == 0 + assert "You are on branch 'feature' but the default branch is 'master'" in result.stdout + + +def test_bump_fails_if_pyproject_toml_dirty(git_repo): + """Bump fails if pyproject.toml has uncommitted changes.""" + script = git_repo / ".github" / "scripts" / "bump.sh" + + # Modify pyproject.toml + with open(git_repo / "pyproject.toml", "a") as f: + f.write("\n# dirty") + + # Input: 1 (patch) + result = subprocess.run([str(script)], cwd=git_repo, input="1\n", capture_output=True, text=True) + + assert result.returncode == 1 + assert "You have uncommitted changes" in result.stdout diff --git a/tests/test_config_templates/test_docstrings.py b/tests/test_config_templates/test_docstrings.py new file mode 100644 index 00000000..04cdfb62 --- /dev/null +++ b/tests/test_config_templates/test_docstrings.py @@ -0,0 +1,103 @@ +"""Tests for module docstrings using doctest. + +Automatically discovers all packages under `src/` +and runs doctests for each. +""" + +from __future__ import annotations + +import doctest +import importlib +import warnings +from pathlib import Path + +import pytest + + +def _iter_modules_from_path(logger, package_path: Path): + """Recursively find all Python modules in a directory.""" + for path in package_path.rglob("*.py"): + if path.name == "__init__.py": + module_path = path.parent.relative_to(package_path.parent) + else: + module_path = path.relative_to(package_path.parent).with_suffix("") + + # Convert path to module name in an OS-independent way + module_name = ".".join(module_path.parts) + + try: + yield importlib.import_module(module_name) + except ImportError as e: + warnings.warn(f"Could not import {module_name}: {e}", stacklevel=2) + logger.warning("Could not import module %s: %s", module_name, e) + continue + + +def test_doctests(logger, root, monkeypatch: pytest.MonkeyPatch): + """Run doctests for each package directory under src/.""" + src_path = root / "src" + + logger.info("Starting doctest discovery in: %s", src_path) + if not src_path.exists(): + logger.info("Source directory not found: %s β€” skipping doctests", src_path) + pytest.skip(f"Source directory not found: {src_path}") + + # Add src to sys.path with automatic cleanup + monkeypatch.syspath_prepend(str(src_path)) + logger.debug("Prepended to sys.path: %s", src_path) + + total_tests = 0 + total_failures = 0 + failed_modules = [] + + # Find all packages in src + for package_dir in src_path.iterdir(): + if package_dir.is_dir() and (package_dir / "__init__.py").exists(): + # Import the package + package_name = package_dir.name + logger.info("Discovered package: %s", package_name) + try: + modules = list(_iter_modules_from_path(logger, package_dir)) + logger.debug("%d module(s) found in package %s", len(modules), package_name) + + for module in modules: + logger.debug("Running doctests for module: %s", module.__name__) + results = doctest.testmod( + module, + verbose=False, + optionflags=(doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE), + ) + total_tests += results.attempted + + if results.failed: + logger.warning( + "Doctests failed for %s: %d/%d failed", + module.__name__, + results.failed, + results.attempted, + ) + total_failures += results.failed + failed_modules.append((module.__name__, results.failed, results.attempted)) + else: + logger.debug("Doctests passed for %s (%d test(s))", module.__name__, results.attempted) + + except ImportError as e: + warnings.warn(f"Could not import package {package_name}: {e}", stacklevel=2) + logger.warning("Could not import package %s: %s", package_name, e) + continue + + if failed_modules: + formatted = "\n".join(f" {name}: {failed}/{attempted} failed" for name, failed, attempted in failed_modules) + msg = ( + f"Doctest summary: {total_tests} tests across {len(failed_modules)} module(s)\n" + f"Failures: {total_failures}\n" + f"Failed modules:\n{formatted}" + ) + logger.error("%s", msg) + assert total_failures == 0, msg + else: + logger.info("Doctest summary: %d tests, 0 failures", total_tests) + + if total_tests == 0: + logger.info("No doctests were found in any module β€” skipping") + pytest.skip("No doctests were found in any module") diff --git a/tests/test_config_templates/test_git_repo_fixture.py b/tests/test_config_templates/test_git_repo_fixture.py new file mode 100644 index 00000000..04d2e273 --- /dev/null +++ b/tests/test_config_templates/test_git_repo_fixture.py @@ -0,0 +1,147 @@ +"""Tests for the git_repo pytest fixture that creates a mock Git repository. + +This module validates the temporary repository structure, git initialization, +mocked tool executables, environment variables, and basic git configuration the +fixture is expected to provide for integration-style tests. +""" + +import os +import subprocess +from pathlib import Path + + +class TestGitRepoFixture: + """Tests for the git_repo fixture that sets up a mock git repository.""" + + def test_git_repo_creates_temporary_directory(self, git_repo): + """Git repo fixture should create a temporary directory.""" + assert git_repo.exists() + assert git_repo.is_dir() + + def test_git_repo_contains_pyproject_toml(self, git_repo): + """Git repo should contain a pyproject.toml file.""" + pyproject = git_repo / "pyproject.toml" + assert pyproject.exists() + content = pyproject.read_text() + assert 'name = "test-project"' in content + assert 'version = "0.1.0"' in content + + def test_git_repo_contains_uv_lock(self, git_repo): + """Git repo should contain a uv.lock file.""" + assert (git_repo / "uv.lock").exists() + + def test_git_repo_has_bin_directory_with_mocks(self, git_repo): + """Git repo should have bin directory with mock tools.""" + bin_dir = git_repo / "bin" + assert bin_dir.exists() + assert (bin_dir / "uv").exists() + assert (bin_dir / "gpg").exists() + assert (bin_dir / "gpgv").exists() + assert (bin_dir / "git").exists() + + def test_git_repo_mock_tools_are_executable(self, git_repo): + """Mock tools should be executable.""" + for tool in ["uv", "gpg", "gpgv", "git"]: + tool_path = git_repo / "bin" / tool + assert os.access(tool_path, os.X_OK), f"{tool} is not executable" + + def test_git_repo_has_github_scripts_directory(self, git_repo): + """Git repo should have .github/scripts directory.""" + scripts_dir = git_repo / ".github" / "scripts" + assert scripts_dir.exists() + assert (scripts_dir / "release.sh").exists() + assert (scripts_dir / "bump.sh").exists() + + def test_git_repo_scripts_are_executable(self, git_repo): + """GitHub scripts should be executable.""" + for script in ["release.sh", "bump.sh"]: + script_path = git_repo / ".github" / "scripts" / script + assert os.access(script_path, os.X_OK), f"{script} is not executable" + + def test_git_repo_is_initialized(self, git_repo): + """Git repo should be properly initialized.""" + result = subprocess.run( + ["git", "rev-parse", "--git-dir"], + cwd=git_repo, + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert ".git" in result.stdout + + def test_git_repo_has_master_branch(self, git_repo): + """Git repo should be on master branch.""" + result = subprocess.run( + ["git", "branch", "--show-current"], + cwd=git_repo, + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert result.stdout.strip() == "master" + + def test_git_repo_has_initial_commit(self, git_repo): + """Git repo should have an initial commit.""" + result = subprocess.run( + ["git", "log", "--oneline"], + cwd=git_repo, + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert "Initial commit" in result.stdout + + def test_git_repo_has_remote_configured(self, git_repo): + """Git repo should have origin remote configured.""" + result = subprocess.run( + ["git", "remote", "-v"], + cwd=git_repo, + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert "origin" in result.stdout + + def test_git_repo_user_config_is_set(self, git_repo): + """Git repo should have user.email and user.name configured.""" + email = subprocess.check_output( + ["git", "config", "user.email"], + cwd=git_repo, + text=True, + ).strip() + name = subprocess.check_output( + ["git", "config", "user.name"], + cwd=git_repo, + text=True, + ).strip() + assert email == "test@example.com" + assert name == "Test User" + + def test_git_repo_working_tree_is_clean(self, git_repo): + """Git repo should start with a clean working tree.""" + result = subprocess.run( + ["git", "status", "--porcelain"], + cwd=git_repo, + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert result.stdout.strip() == "" + + def test_git_repo_changes_current_directory(self, git_repo): + """Git repo fixture should change to the temporary directory.""" + current_dir = Path.cwd() + assert current_dir == git_repo + + def test_git_repo_modifies_path_environment(self, git_repo): + """Git repo fixture should prepend bin directory to PATH.""" + path_env = os.environ.get("PATH", "") + bin_dir = str(git_repo / "bin") + assert bin_dir in path_env + assert path_env.startswith(bin_dir) + + def test_git_repo_sets_gnupghome_environment(self, git_repo): + """Git repo should set GNUPGHOME environment variable.""" + gnupghome = os.environ.get("GNUPGHOME") + assert gnupghome is not None + assert "gnupg" in gnupghome diff --git a/tests/test_config_templates/test_makefile.py b/tests/test_config_templates/test_makefile.py new file mode 100644 index 00000000..86303aa2 --- /dev/null +++ b/tests/test_config_templates/test_makefile.py @@ -0,0 +1,200 @@ +"""Tests for the Makefile targets and help output using safe dry‑runs. + +These tests validate that the Makefile exposes expected targets and emits +the correct commands without actually executing them, by invoking `make -n` +(dry-run). We also pass `-s` to reduce noise in CI logs. This approach keeps +tests fast, portable, and free of side effects like network or environment +changes. +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + + +def strip_ansi(text: str) -> str: + """Strip ANSI escape sequences from text.""" + ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") + return ansi_escape.sub("", text) + + +@pytest.fixture(autouse=True) +def setup_tmp_makefile(logger, root, tmp_path: Path): + """Copy only the Makefile into a temp directory and chdir there. + + We rely on `make -n` so that no real commands are executed. + """ + logger.debug("Setting up temporary Makefile test dir: %s", tmp_path) + + # Copy the Makefile into the temporary working directory + shutil.copy(root / "Makefile", tmp_path / "Makefile") + + # Move into tmp directory for isolation + old_cwd = Path.cwd() + os.chdir(tmp_path) + logger.debug("Changed working directory to %s", tmp_path) + try: + yield + finally: + os.chdir(old_cwd) + logger.debug("Restored working directory to %s", old_cwd) + + +def run_make( + logger, args: list[str] | None = None, check: bool = True, dry_run: bool = True +) -> subprocess.CompletedProcess: + """Run `make` with optional arguments and return the completed process. + + Args: + logger: Logger used to emit diagnostic messages during the run + args: Additional arguments for make + check: If True, raise on non-zero return code + dry_run: If True, use -n to avoid executing commands + """ + cmd = ["make"] + if args: + cmd.extend(args) + # Use -s to reduce noise, -n to avoid executing commands + flags = "-sn" if dry_run else "-s" + cmd.insert(1, flags) + logger.info("Running command: %s", " ".join(cmd)) + result = subprocess.run(cmd, capture_output=True, text=True) # noqa: S603 + logger.debug("make exited with code %d", result.returncode) + if result.stdout: + logger.debug("make stdout (truncated to 500 chars):\n%s", result.stdout[:500]) + if result.stderr: + logger.debug("make stderr (truncated to 500 chars):\n%s", result.stderr[:500]) + if check and result.returncode != 0: + msg = f"make failed with code {result.returncode}:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + raise AssertionError(msg) + return result + + +class TestMakefile: + """Smoke tests for Makefile help and common targets using make -n.""" + + def test_default_goal_is_help(self, logger): + """Default goal should render the help index with known targets.""" + proc = run_make(logger) + out = proc.stdout + assert "Usage:" in out + assert "Targets:" in out + # ensure a few known targets appear in the help index + for target in ["install", "fmt", "deptry", "test", "book", "help"]: + assert target in out + + def test_help_target(self, logger): + """Explicit `make help` prints usage, targets, and section headers.""" + proc = run_make(logger, ["help"]) + out = proc.stdout + assert "Usage:" in out + assert "Targets:" in out + assert "Bootstrap" in out or "Meta" in out # section headers + + def test_fmt_target_dry_run(self, logger): + """Fmt target should invoke pre-commit via uvx in dry-run output.""" + proc = run_make(logger, ["fmt"]) + out = proc.stdout + assert "./bin/uvx pre-commit run --all-files" in out + + def test_deptry_target_dry_run(self, logger): + """Deptry target should invoke deptry via uvx in dry-run output.""" + proc = run_make(logger, ["deptry"]) + out = proc.stdout + assert './bin/uvx deptry "src"' in out + + def test_test_target_dry_run(self, logger): + """Test target should invoke pytest via uv with coverage and HTML outputs in dry-run output.""" + proc = run_make(logger, ["test"]) + out = proc.stdout + # Expect key steps + assert "mkdir -p _tests/html-coverage _tests/html-report" in out + assert "./bin/uv run pytest" in out + + def test_book_target_dry_run(self, logger): + """Book target should run inline commands to assemble the book without go-task.""" + proc = run_make(logger, ["book"]) + out = proc.stdout + # Expect marimushka export to install marimo and minibook to be invoked + assert "./bin/uvx minibook" in out + + def test_all_target_dry_run(self, logger): + """All target echoes a composite message in dry-run output.""" + proc = run_make(logger, ["all"]) + out = proc.stdout + # The composite target should echo a message + assert "Run fmt, deptry, test and book" in out + + def test_uv_no_modify_path_is_exported(self, logger): + """`UV_NO_MODIFY_PATH` should be set to `1` in the Makefile.""" + proc = run_make(logger, ["print-UV_NO_MODIFY_PATH"], dry_run=False) + out = strip_ansi(proc.stdout) + assert "Value of UV_NO_MODIFY_PATH:\n1" in out + + def test_uv_install_dir_is_bin(self, logger): + """`UV_INSTALL_DIR` should point to `./bin`.""" + proc = run_make(logger, ["print-UV_INSTALL_DIR"], dry_run=False) + out = strip_ansi(proc.stdout) + assert "Value of UV_INSTALL_DIR:\n./bin" in out + + def test_uv_bin_is_bin_uv(self, logger): + """`UV_BIN` should point to `./bin/uv`.""" + proc = run_make(logger, ["print-UV_BIN"], dry_run=False) + out = strip_ansi(proc.stdout) + assert "Value of UV_BIN:\n./bin/uv" in out + + def test_uvx_bin_is_bin_uvx(self, logger): + """`UVX_BIN` should point to `./bin/uvx`.""" + proc = run_make(logger, ["print-UVX_BIN"], dry_run=False) + out = strip_ansi(proc.stdout) + assert "Value of UVX_BIN:\n./bin/uvx" in out + + def test_script_folder_is_github_scripts(self, logger): + """`SCRIPTS_FOLDER` should point to `.github/scripts`.""" + proc = run_make(logger, ["print-SCRIPTS_FOLDER"], dry_run=False) + out = strip_ansi(proc.stdout) + assert "Value of SCRIPTS_FOLDER:\n.github/scripts" in out + + def test_custom_scripts_folder_is_set(self, logger): + """`CUSTOM_SCRIPTS_FOLDER` should point to `.github/scripts/customisations`.""" + proc = run_make(logger, ["print-CUSTOM_SCRIPTS_FOLDER"], dry_run=False) + out = strip_ansi(proc.stdout) + assert "Value of CUSTOM_SCRIPTS_FOLDER:\n.github/scripts/customisations" in out + + +class TestMakefileRootFixture: + """Tests for root fixture usage in Makefile tests.""" + + def test_makefile_exists_at_root(self, root): + """Makefile should exist at repository root.""" + makefile = root / "Makefile" + assert makefile.exists() + assert makefile.is_file() + + def test_makefile_is_readable(self, root): + """Makefile should be readable.""" + makefile = root / "Makefile" + content = makefile.read_text() + assert len(content) > 0 + + def test_makefile_contains_targets(self, root): + """Makefile should contain expected targets.""" + makefile = root / "Makefile" + content = makefile.read_text() + + expected_targets = ["install", "fmt", "test", "deptry", "book", "help"] + for target in expected_targets: + assert f"{target}:" in content or f".PHONY: {target}" in content + + def test_makefile_has_uv_variables(self, root): + """Makefile should define UV-related variables.""" + makefile = root / "Makefile" + content = makefile.read_text() + + assert "UV_BIN" in content or "uv" in content.lower() diff --git a/tests/test_config_templates/test_readme.py b/tests/test_config_templates/test_readme.py new file mode 100644 index 00000000..75fd74b3 --- /dev/null +++ b/tests/test_config_templates/test_readme.py @@ -0,0 +1,74 @@ +"""Tests for README code examples. + +This module extracts Python code and expected result blocks from README.md, +executes the code, and verifies the output matches the documented result. +""" + +import re +import subprocess +import sys + +import pytest + +# Regex for Python code blocks +CODE_BLOCK = re.compile(r"```python\n(.*?)```", re.DOTALL) + +RESULT = re.compile(r"```result\n(.*?)```", re.DOTALL) + + +def test_readme_runs(logger, root): + """Execute README code blocks and compare output to documented results.""" + readme = root / "README.md" + logger.info("Reading README from %s", readme) + readme_text = readme.read_text(encoding="utf-8") + code_blocks = CODE_BLOCK.findall(readme_text) + result_blocks = RESULT.findall(readme_text) + logger.info("Found %d code block(s) and %d result block(s) in README", len(code_blocks), len(result_blocks)) + + code = "".join(code_blocks) # merged code + expected = "".join(result_blocks) # merged results + + # Trust boundary: we execute Python snippets sourced from README.md in this repo. + # The README is part of the trusted repository content and reviewed in PRs. + logger.debug("Executing README code via %s -c ...", sys.executable) + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) # noqa: S603 + + stdout = result.stdout + logger.debug("Execution finished with return code %d", result.returncode) + if result.stderr: + logger.debug("Stderr from README code:\n%s", result.stderr) + logger.debug("Stdout from README code:\n%s", stdout) + + assert result.returncode == 0, f"README code exited with {result.returncode}. Stderr:\n{result.stderr}" + logger.info("README code executed successfully; comparing output to expected result") + assert stdout.strip() == expected.strip() + logger.info("README code output matches expected result") + + +class TestReadmeTestEdgeCases: + """Edge cases for README code block testing.""" + + def test_readme_file_exists_at_root(self, root): + """README.md should exist at repository root.""" + readme = root / "README.md" + assert readme.exists() + assert readme.is_file() + + def test_readme_is_readable(self, root): + """README.md should be readable with UTF-8 encoding.""" + readme = root / "README.md" + content = readme.read_text(encoding="utf-8") + assert len(content) > 0 + assert isinstance(content, str) + + def test_readme_code_is_syntactically_valid(self, root): + """Python code blocks in README should be syntactically valid.""" + readme = root / "README.md" + content = readme.read_text(encoding="utf-8") + code_blocks = re.findall(r"\`\`\`python\n(.*?)\`\`\`", content, re.DOTALL) + + for i, code in enumerate(code_blocks): + try: + compile(code, f"", "exec") + except SyntaxError as e: + pytest.fail(f"Code block {i} has syntax error: {e}") diff --git a/tests/test_config_templates/test_release_script.py b/tests/test_config_templates/test_release_script.py new file mode 100644 index 00000000..46065374 --- /dev/null +++ b/tests/test_config_templates/test_release_script.py @@ -0,0 +1,123 @@ +"""Tests for the release.sh script using a sandboxed git environment. + +The script exposes the `release` command (creates and pushes tags). +Tests call the script from a temporary clone and use a small mock `uv` +to avoid external dependencies. +""" + +import subprocess + + +def test_release_creates_signed_tag(git_repo): + """Release creates a GPG-signed tag.""" + script = git_repo / ".github" / "scripts" / "release.sh" + + # Run release + # 1. Prompts to create tag -> y + # 2. Prompts to push tag -> y + result = subprocess.run([str(script)], cwd=git_repo, input="y\ny\n", capture_output=True, text=True) + assert result.returncode == 0 + assert "Tag 'v0.1.0' created locally" in result.stdout + + # Verify the tag is signed using git tag -v + # git tag -v returns 0 only for valid signed tags with verified signatures + verify_result = subprocess.run( + ["git", "tag", "-v", "v0.1.0"], + cwd=git_repo, + capture_output=True, + text=True, + ) + assert verify_result.returncode == 0, f"Tag signature verification failed: {verify_result.stderr}" + + +def test_release_fails_if_local_tag_exists(git_repo): + """If the target tag already exists locally, release should warn and abort if user says no.""" + script = git_repo / ".github" / "scripts" / "release.sh" + + # Create a local tag that matches current version + subprocess.run(["git", "tag", "v0.1.0"], cwd=git_repo, check=True) + + # Input 'n' to abort + result = subprocess.run([str(script)], cwd=git_repo, input="n\n", capture_output=True, text=True) + + assert result.returncode == 0 + assert "Tag 'v0.1.0' already exists locally" in result.stdout + assert "Aborted by user" in result.stdout + + +def test_release_fails_if_remote_tag_exists(git_repo): + """Release fails if tag exists on remote.""" + script = git_repo / ".github" / "scripts" / "release.sh" + + # Create tag locally and push to remote + subprocess.run(["git", "tag", "v0.1.0"], cwd=git_repo, check=True) + subprocess.run(["git", "push", "origin", "v0.1.0"], cwd=git_repo, check=True) + + result = subprocess.run([str(script)], cwd=git_repo, input="y\n", capture_output=True, text=True) + + assert result.returncode == 1 + assert "already exists on remote" in result.stdout + + +def test_release_uncommitted_changes_failure(git_repo): + """Release fails if there are uncommitted changes (even pyproject.toml).""" + script = git_repo / ".github" / "scripts" / "release.sh" + + # Modify pyproject.toml (which is allowed in bump but NOT in release) + with open(git_repo / "pyproject.toml", "a") as f: + f.write("\n# comment") + + result = subprocess.run([str(script)], cwd=git_repo, capture_output=True, text=True) + + assert result.returncode == 1 + assert "You have uncommitted changes" in result.stdout + + +def test_release_pushes_if_ahead_of_remote(git_repo): + """Release prompts to push if local branch is ahead of remote.""" + script = git_repo / ".github" / "scripts" / "release.sh" + + # Create a commit locally that isn't on remote + tracked_file = git_repo / "file.txt" + tracked_file.touch() + subprocess.run(["git", "add", "file.txt"], cwd=git_repo, check=True) + subprocess.run(["git", "commit", "-m", "Local commit"], cwd=git_repo, check=True) + + # Run release + # 1. Prompts to push -> y + # 2. Prompts to create tag -> y + # 3. Prompts to push tag -> y + result = subprocess.run([str(script)], cwd=git_repo, input="y\ny\ny\n", capture_output=True, text=True) + + assert result.returncode == 0 + assert "Your branch is ahead" in result.stdout + assert "Unpushed commits:" in result.stdout + assert "Local commit" in result.stdout + assert "Push changes to remote before releasing?" in result.stdout + + +def test_release_fails_if_behind_remote(git_repo): + """Release fails if local branch is behind remote.""" + script = git_repo / ".github" / "scripts" / "release.sh" + + # Create a commit on remote that isn't local + # We need to clone another repo to push to remote + other_clone = git_repo.parent / "other_clone" + subprocess.run(["git", "clone", str(git_repo.parent / "remote.git"), str(other_clone)], check=True) + + # Configure git user for other_clone (needed in CI) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=other_clone, check=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=other_clone, check=True) + + # Commit and push from other clone + with open(other_clone / "other.txt", "w") as f: + f.write("content") + subprocess.run(["git", "add", "other.txt"], cwd=other_clone, check=True) + subprocess.run(["git", "commit", "-m", "Remote commit"], cwd=other_clone, check=True) + subprocess.run(["git", "push"], cwd=other_clone, check=True) + + # Run release (it will fetch and see it's behind) + result = subprocess.run([str(script)], cwd=git_repo, capture_output=True, text=True) + + assert result.returncode == 1 + assert "Your branch is behind" in result.stdout diff --git a/tests/test_config_templates/test_structure.py b/tests/test_config_templates/test_structure.py new file mode 100644 index 00000000..08ee427b --- /dev/null +++ b/tests/test_config_templates/test_structure.py @@ -0,0 +1,58 @@ +"""Tests for the root pytest fixture that yields the repository root Path. + +This module ensures the fixture resolves to the true project root and that +expected files/directories exist, enabling other tests to locate resources +reliably. +""" + +from pathlib import Path + + +class TestRootFixture: + """Tests for the root fixture that provides repository root path.""" + + def test_root_returns_pathlib_path(self, root): + """Root fixture should return a pathlib.Path object.""" + assert isinstance(root, Path) + + def test_root_points_to_repository_root(self, root): + """Root fixture should point to the actual repository root.""" + assert (root / "pyproject.toml").exists() + assert (root / "README.md").exists() + assert (root / ".github").is_dir() + + def test_root_is_absolute_path(self, root): + """Root fixture should return an absolute path.""" + assert root.is_absolute() + + def test_root_resolves_correctly_from_nested_location(self, root): + """Root should correctly resolve to repository root from tests/test_config_templates/.""" + conftest_path = root / "tests" / "test_config_templates" / "conftest.py" + assert conftest_path.exists() + + def test_root_contains_expected_directories(self, root): + """Root should contain all expected project directories.""" + expected_dirs = [".github", "src", "tests", "book"] + for dirname in expected_dirs: + assert (root / dirname).exists(), f"Expected directory {dirname} not found" + + def test_root_contains_expected_files(self, root): + """Root should contain all expected configuration files.""" + expected_files = [ + "pyproject.toml", + "README.md", + "Makefile", + "ruff.toml", + ".gitignore", + ".editorconfig", + ] + for filename in expected_files: + assert (root / filename).exists(), f"Expected file {filename} not found" + + def test_root_can_locate_github_scripts(self, root): + """Root should allow locating GitHub scripts.""" + scripts_dir = root / ".github" / "scripts" + assert scripts_dir.exists() + assert (scripts_dir / "release.sh").exists() + assert (scripts_dir / "bump.sh").exists() + assert (scripts_dir / "sync.sh").exists() diff --git a/tests/test_config_templates/test_sync_script.py b/tests/test_config_templates/test_sync_script.py new file mode 100644 index 00000000..40d73d3c --- /dev/null +++ b/tests/test_config_templates/test_sync_script.py @@ -0,0 +1,213 @@ +"""Tests for the sync.sh script and template.yml file exclusion functionality. + +These tests validate that the sync script correctly handles file exclusions, +particularly for nested files within directories. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def create_test_structure(logger, base_path: Path) -> Path: + """Create a template directory structure for testing.""" + logger.debug("Creating test structure under %s", base_path) + # Create .github directory with workflow files + github_dir = base_path / ".github" + github_dir.mkdir(parents=True) + workflows_dir = github_dir / "workflows" + workflows_dir.mkdir() + + # Create workflow files + (workflows_dir / "ci.yml").write_text("# CI workflow\n") + (workflows_dir / "docker.yml").write_text("# Docker workflow\n") + (workflows_dir / "devcontainer.yml").write_text("# DevContainer workflow\n") + (workflows_dir / "release.yml").write_text("# Release workflow\n") + + # Create scripts directory + scripts_dir = github_dir / "scripts" + scripts_dir.mkdir() + (scripts_dir / "sync.sh").write_text("# Sync script\n") + + # Create other template files + (base_path / ".editorconfig").write_text("# EditorConfig\n") + (base_path / ".gitignore").write_text("# Gitignore\n") + (base_path / "Makefile").write_text("# Makefile\n") + (base_path / "ruff.toml").write_text("# Ruff config\n") + + # Create tests directory + tests_dir = base_path / "tests" + tests_dir.mkdir() + (tests_dir / "test_example.py").write_text("# Test file\n") + + logger.debug("Test structure created under %s", base_path) + return base_path + + +def test_directory_copy_excludes_nested_files(logger, tmp_path: Path): + """Test the core logic of directory copy with file exclusions. + + This test simulates what the sync script does when copying directories + and verifies that excluded nested files are not copied. + """ + # Create source directory with files + source = tmp_path / "source" + create_test_structure(logger, source) + + # Create destination directory + dest = tmp_path / "dest" + dest.mkdir() + + # Simulate the sync operation from sync.sh lines 225-240 + # This is the buggy behavior - it copies everything recursively + src_path = source / ".github" + dest_path = dest / ".github" + dest_path.mkdir(parents=True) + + logger.info("Copying directory recursively (simulating buggy behavior): %s -> %s", src_path, dest_path) + # This is what the current script does (copies everything) + subprocess.run(["cp", "-R", f"{src_path}/.", f"{dest_path}/"], check=True) + + # Current behavior (BUG): excluded files should NOT be present but they are + logger.debug("Checking presence of files that should have been excluded") + assert (dest / ".github" / "workflows" / "docker.yml").exists(), ( + "This test shows the bug - docker.yml is copied despite exclusion" + ) + assert (dest / ".github" / "workflows" / "devcontainer.yml").exists(), ( + "This test shows the bug - devcontainer.yml is copied despite exclusion" + ) + + # Files that should be copied + assert (dest / ".github" / "workflows" / "ci.yml").exists() + assert (dest / ".github" / "workflows" / "release.yml").exists() + + +def test_sync_script_with_exclusions_integration(logger, root, tmp_path: Path): + """Integration test that verifies sync.sh properly excludes nested files. + + This test will fail with the current implementation and pass after the fix. + """ + # Create template directory + template_dir = tmp_path / "template_for_test" + create_test_structure(logger, template_dir) + + # Create target directory with sync script + target_dir = tmp_path / "target" + target_dir.mkdir() + + # Create a test-specific sync script that reads from local directory + # instead of cloning from GitHub + test_script = target_dir / "test_sync.sh" + + sync_content = (root / ".github" / "scripts" / "sync.sh").read_text() + + # Modify to use local template directory + test_sync_content = sync_content.replace( + "# Clone the template repository\n" + 'printf "\\n%b[INFO] Cloning template repository...%b\\n" "$BLUE" "$RESET"\n' + 'REPO_URL="https://github.com/${TEMPLATE_REPO}.git"\n' + "\n" + 'if ! git clone --depth 1 --branch "$TEMPLATE_BRANCH" "$REPO_URL" "$TEMP_DIR/template" 2>/dev/null; then\n' + ' printf "%b[ERROR] Failed to clone template repository from %s%b\\n" "$RED" "$REPO_URL" "$RESET"\n' + " exit 1\n" + "fi", + f'''# Clone the template repository +printf "\\n%b[INFO] Using local template directory...%b\\n" "$BLUE" "$RESET" +mkdir -p "$TEMP_DIR/template" +cp -R "{template_dir}"/. "$TEMP_DIR/template"/ || exit 1''', + ) + + test_script.write_text(test_sync_content) + + # Create .github directory in target + (target_dir / ".github").mkdir() + + # Create template.yml with exclusions + template_yml = target_dir / ".github" / "template.yml" + template_yml.write_text("""template-repository: "dummy/repo" +template-branch: "main" +include: | + .github + tests + .editorconfig + .gitignore + Makefile +exclude: | + .github/workflows/docker.yml + .github/workflows/devcontainer.yml + ruff.toml +""") + + logger.info("Running test sync script: %s", test_script) + # Run the test sync script + result = subprocess.run( + ["/bin/sh", str(test_script)], + cwd=target_dir, + capture_output=True, + text=True, + ) + + logger.debug("Sync script exited with %d", result.returncode) + if result.stdout: + logger.debug("Sync script STDOUT (truncated):\n%s", result.stdout[:1000]) + if result.stderr: + logger.debug("Sync script STDERR (truncated):\n%s", result.stderr[:1000]) + + print("STDOUT:", result.stdout) + print("STDERR:", result.stderr) + + assert result.returncode == 0, f"Sync failed: {result.stderr}" + + # Verify excluded files are NOT present (this will fail before fix) + logger.debug("Verifying excluded files are not present") + assert not (target_dir / ".github" / "workflows" / "docker.yml").exists(), ( + "docker.yml should be excluded but was copied" + ) + assert not (target_dir / ".github" / "workflows" / "devcontainer.yml").exists(), ( + "devcontainer.yml should be excluded but was copied" + ) + assert not (target_dir / "ruff.toml").exists(), "ruff.toml should be excluded but was copied" + + # Verify non-excluded files ARE present + logger.debug("Verifying non-excluded files are present") + assert (target_dir / ".github" / "workflows" / "ci.yml").exists(), "ci.yml should be copied" + assert (target_dir / ".github" / "workflows" / "release.yml").exists(), "release.yml should be copied" + assert (target_dir / ".editorconfig").exists() + assert (target_dir / ".gitignore").exists() + assert (target_dir / "Makefile").exists() + assert (target_dir / "tests" / "test_example.py").exists() + + +class TestSyncScriptRootFixture: + """Tests for root fixture usage in sync script tests.""" + + def test_sync_script_exists_at_root(self, root): + """Sync script should exist at expected location.""" + sync_script = root / ".github" / "scripts" / "sync.sh" + assert sync_script.exists() + assert sync_script.is_file() + + def test_sync_script_is_executable(self, root): + """Sync script should be executable.""" + import os + + sync_script = root / ".github" / "scripts" / "sync.sh" + assert os.access(sync_script, os.X_OK) + + def test_sync_script_is_readable(self, root): + """Sync script should be readable.""" + sync_script = root / ".github" / "scripts" / "sync.sh" + content = sync_script.read_text() + assert len(content) > 0 + assert content.startswith("#!/") + + def test_sync_script_contains_expected_logic(self, root): + """Sync script should contain key functionality.""" + sync_script = root / ".github" / "scripts" / "sync.sh" + content = sync_script.read_text() + + # Check for key features + assert "template.yml" in content + assert "include" in content.lower() + assert "exclude" in content.lower()