# yaml-language-server: $schema=https://json.schemastore.org/github-action.json # permissions: # - contents: write # Required for committing and pushing lint fixes # - security-events: write # Required for uploading SARIF reports --- name: Python Lint and Fix description: 'Lints and fixes Python files, commits changes, and uploads SARIF report.' author: 'Ismo Vuorinen' branding: icon: 'code' color: 'yellow' inputs: python-version: description: 'Python version to use' required: false default: '3.11' flake8-version: description: 'Flake8 version to use' required: false default: '7.0.0' autopep8-version: description: 'Autopep8 version to use' required: false default: '2.0.4' max-retries: description: 'Maximum number of retry attempts for installations and linting' required: false default: '3' working-directory: description: 'Directory containing Python files to lint' required: false default: '.' fail-on-error: description: 'Whether to fail the action if linting errors are found' required: false default: 'true' token: description: 'GitHub token for authentication' required: false username: description: 'GitHub username for commits' required: false default: 'github-actions' email: description: 'GitHub email for commits' required: false default: 'github-actions@github.com' outputs: lint-result: description: 'Result of the linting process (success/failure)' value: ${{ steps.lint.outputs.result }} fixed-files: description: 'Number of files that were fixed' value: ${{ steps.fix.outputs.fixed_count }} error-count: description: 'Number of errors found' value: ${{ steps.lint.outputs.error_count }} runs: using: composite steps: - name: Validate Inputs id: validate uses: ivuorinen/actions/validate-inputs@8fb52522ab00fe73cf181ef299e56066f0b2c8d8 with: action-type: 'python-lint-fix' token: ${{ inputs.token }} email: ${{ inputs.email }} username: ${{ inputs.username }} python-version: ${{ inputs.python-version }} flake8-version: ${{ inputs.flake8-version }} autopep8-version: ${{ inputs.autopep8-version }} working-directory: ${{ inputs.working-directory }} max-retries: ${{ inputs.max-retries }} fail-on-error: ${{ inputs.fail-on-error }} - name: Checkout Repository uses: actions/checkout@71cf2267d89c5cb81562390fa70a37fa40b1305e # v6-beta with: token: ${{ inputs.token || github.token }} - name: Detect Python Version id: python-version shell: sh env: DEFAULT_VERSION: "${{ inputs.python-version || '3.11' }}" run: | set -eu # Function to validate version format validate_version() { version=$1 case "$version" in [0-9]*\.[0-9]* | [0-9]*\.[0-9]*\.[0-9]*) return 0 ;; *) return 1 ;; esac } # Function to clean version string clean_version() { printf '%s' "$1" | sed 's/^[vV]//' | tr -d ' \n\r' } detected_version="" # Parse .tool-versions file if [ -f .tool-versions ]; then echo "Checking .tool-versions for python..." >&2 version=$(awk '/^python[[:space:]]/ {gsub(/#.*/, ""); print $2; exit}' .tool-versions 2>/dev/null || echo "") if [ -n "$version" ]; then version=$(clean_version "$version") if validate_version "$version"; then echo "Found Python version in .tool-versions: $version" >&2 detected_version="$version" fi fi fi # Parse Dockerfile if [ -z "$detected_version" ] && [ -f Dockerfile ]; then echo "Checking Dockerfile for python..." >&2 version=$(grep -iF "FROM" Dockerfile | grep -F "python:" | head -1 | \ sed -n -E "s/.*python:([0-9]+(\.[0-9]+)*)(-[^:]*)?.*/\1/p" || echo "") if [ -n "$version" ]; then version=$(clean_version "$version") if validate_version "$version"; then echo "Found Python version in Dockerfile: $version" >&2 detected_version="$version" fi fi fi # Parse devcontainer.json if [ -z "$detected_version" ] && [ -f .devcontainer/devcontainer.json ]; then echo "Checking devcontainer.json for python..." >&2 if command -v jq >/dev/null 2>&1; then version=$(jq -r '.image // empty' .devcontainer/devcontainer.json 2>/dev/null | sed -n -E "s/.*python:([0-9]+(\.[0-9]+)*)(-[^:]*)?.*/\1/p" || echo "") if [ -n "$version" ]; then version=$(clean_version "$version") if validate_version "$version"; then echo "Found Python version in devcontainer: $version" >&2 detected_version="$version" fi fi else echo "jq not found; skipping devcontainer.json parsing" >&2 fi fi # Parse .python-version file if [ -z "$detected_version" ] && [ -f .python-version ]; then echo "Checking .python-version..." >&2 version=$(tr -d '\r' < .python-version | head -1) if [ -n "$version" ]; then version=$(clean_version "$version") if validate_version "$version"; then echo "Found Python version in .python-version: $version" >&2 detected_version="$version" fi fi fi # Parse pyproject.toml if [ -z "$detected_version" ] && [ -f pyproject.toml ]; then echo "Checking pyproject.toml..." >&2 if grep -q '^\[project\]' pyproject.toml; then version=$(grep -A 20 '^\[project\]' pyproject.toml | grep -E '^[[:space:]]*requires-python[[:space:]]*=' | sed -n -E 's/[^0-9]*([0-9]+\.[0-9]+(\.[0-9]+)?).*/\1/p' | head -1) if [ -n "$version" ]; then version=$(clean_version "$version") if validate_version "$version"; then echo "Found Python version in pyproject.toml: $version" >&2 detected_version="$version" fi fi fi fi # Use default version if nothing detected if [ -z "$detected_version" ]; then detected_version="$DEFAULT_VERSION" echo "Using default Python version: $detected_version" >&2 fi # Set output printf 'detected-version=%s\n' "$detected_version" >> "$GITHUB_OUTPUT" echo "Final detected Python version: $detected_version" >&2 - name: Detect Package Manager id: package-manager shell: sh run: | set -eu # Detect Python package manager based on lock files and config package_manager="pip" if [ -f "uv.lock" ]; then # uv uses pip-compatible caching, so we use 'pip' as cache type package_manager="pip" echo "Detected uv (using pip-compatible caching)" >&2 elif [ -f "poetry.lock" ]; then package_manager="poetry" echo "Detected Poetry" >&2 elif [ -f "Pipfile.lock" ] || [ -f "Pipfile" ]; then package_manager="pipenv" echo "Detected Pipenv" >&2 elif [ -f "requirements.txt" ] || [ -f "requirements-dev.txt" ] || [ -f "setup.py" ] || [ -f "pyproject.toml" ]; then package_manager="pip" echo "Detected pip" >&2 else package_manager="pip" echo "No package manager detected, defaulting to pip" >&2 fi printf 'package-manager=%s\n' "$package_manager" >> "$GITHUB_OUTPUT" echo "Using package manager: $package_manager" >&2 - name: Setup Python (pip) if: steps.package-manager.outputs.package-manager == 'pip' uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ steps.python-version.outputs.detected-version }} cache: 'pip' cache-dependency-path: | **/requirements.txt **/requirements-dev.txt **/pyproject.toml **/setup.py **/uv.lock - name: Setup Python (pipenv) if: steps.package-manager.outputs.package-manager == 'pipenv' uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ steps.python-version.outputs.detected-version }} cache: 'pipenv' cache-dependency-path: | **/Pipfile **/Pipfile.lock - name: Setup Python (poetry) if: steps.package-manager.outputs.package-manager == 'poetry' uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ steps.python-version.outputs.detected-version }} cache: 'poetry' cache-dependency-path: | **/poetry.lock **/pyproject.toml - name: Check for Python Files id: check-files shell: sh env: WORKING_DIRECTORY: ${{ inputs.working-directory }} run: | set -eu cd "$WORKING_DIRECTORY" if ! find . -name "*.py" -type f -not -path "*/\.*" | grep -q .; then echo "No Python files found. Skipping lint and fix." printf '%s\n' "result=skipped" >> "$GITHUB_OUTPUT" exit 0 fi printf '%s\n' "result=found" >> "$GITHUB_OUTPUT" - name: Install Dependencies if: steps.check-files.outputs.result == 'found' id: install shell: sh env: MAX_RETRIES: ${{ inputs.max-retries }} FLAKE8_VERSION: ${{ inputs.flake8-version }} AUTOPEP8_VERSION: ${{ inputs.autopep8-version }} run: | set -eu # Create virtual environment python -m venv .venv . .venv/bin/activate # Install dependencies (pip has built-in retry logic) pip install "flake8==$FLAKE8_VERSION" pip install flake8-sarif==0.6.0 pip install "autopep8==$AUTOPEP8_VERSION" # Verify installations flake8 --version || exit 1 autopep8 --version || exit 1 - name: Run flake8 if: steps.check-files.outputs.result == 'found' id: lint shell: sh env: WORKING_DIRECTORY: ${{ inputs.working-directory }} FAIL_ON_ERROR: ${{ inputs.fail-on-error }} run: | set -eu . .venv/bin/activate cd "$WORKING_DIRECTORY" # Create temporary directory for reports mkdir -p reports # Run flake8 with error handling error_count=0 if ! flake8 --format=sarif --output-file=reports/flake8.sarif .; then error_count=$(grep -c "level\": \"error\"" reports/flake8.sarif || echo 0) echo "Found $error_count linting errors" printf '%s\n' "error_count=$error_count" >> "$GITHUB_OUTPUT" if [ "$FAIL_ON_ERROR" = "true" ]; then echo "::error::Linting failed with $error_count errors" printf '%s\n' "result=failure" >> "$GITHUB_OUTPUT" exit 1 fi fi printf '%s\n' "result=success" >> "$GITHUB_OUTPUT" printf '%s\n' "error_count=$error_count" >> "$GITHUB_OUTPUT" - name: Run autopep8 Fix if: steps.check-files.outputs.result == 'found' id: fix shell: sh env: WORKING_DIRECTORY: ${{ inputs.working-directory }} run: | set -eu . .venv/bin/activate cd "$WORKING_DIRECTORY" # Create temporary file for tracking changes touch /tmp/changed_files # Run autopep8 with change detection find . -name "*.py" -type f -not -path "*/\.*" | while read -r file; do if autopep8 --diff "$file" | grep -q '^[+-]'; then autopep8 --in-place "$file" echo "$file" >> /tmp/changed_files fi done # Count fixed files fixed_count=$(wc -l < /tmp/changed_files || echo 0) echo "Fixed $fixed_count files" printf '%s\n' "fixed_count=$fixed_count" >> "$GITHUB_OUTPUT" # Cleanup rm /tmp/changed_files - name: Commit Fixes if: ${{ fromJSON(steps.fix.outputs.fixed_count) > 0 }} uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v7.0.0 with: commit_message: 'style: apply python lint fixes' commit_user_name: ${{ inputs.username }} commit_user_email: ${{ inputs.email }} file_pattern: '*.py' - name: Upload SARIF Report if: steps.check-files.outputs.result == 'found' uses: github/codeql-action/upload-sarif@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4 with: sarif_file: ${{ inputs.working-directory }}/reports/flake8.sarif category: 'python-lint' - name: Cleanup if: always() shell: sh run: |- set -eu # Remove virtual environment rm -rf .venv # Remove temporary files rm -rf reports