diff --git a/bin/run-linters.sh b/bin/run-linters.sh index 36ec2bf..4b2887b 100755 --- a/bin/run-linters.sh +++ b/bin/run-linters.sh @@ -118,6 +118,10 @@ for linter in "${REQUIRED_LINTERS[@]}"; do run_args=("${run_args_common[@]}") "${LIB_ROOT}/checks/linters/markdownlint/run.sh" "${run_args[@]}" ;; + python) + run_args=("${run_args_common[@]}") + "${LIB_ROOT}/checks/linters/python/run.sh" "${run_args[@]}" + ;; codespell) run_args=("${run_args_common[@]}") "${LIB_ROOT}/checks/linters/codespell/run.sh" "${run_args[@]}" diff --git a/checks/detect-linters.sh b/checks/detect-linters.sh index e1c6112..38e820e 100755 --- a/checks/detect-linters.sh +++ b/checks/detect-linters.sh @@ -13,6 +13,7 @@ linter_require_common_args shellcheck_needed=0 groovylint_needed=0 markdownlint_needed=0 +python_needed=0 codespell_needed=0 text_hygiene_needed=0 filename_portability_needed=0 @@ -34,6 +35,10 @@ while IFS= read -r file_path; do if linter_is_markdown_candidate "${file_path}"; then markdownlint_needed=1 fi + + if linter_is_python_candidate "${file_path}"; then + python_needed=1 + fi done < <(linter_get_candidate_files_acmr) if [[ ${shellcheck_needed} -eq 1 ]]; then @@ -45,6 +50,9 @@ fi if [[ ${markdownlint_needed} -eq 1 ]]; then echo 'markdownlint' fi +if [[ ${python_needed} -eq 1 ]]; then + echo 'python' +fi if [[ ${codespell_needed} -eq 1 ]]; then echo 'codespell' fi diff --git a/checks/install-linter-tools.sh b/checks/install-linter-tools.sh index abee4f0..68e0c0f 100755 --- a/checks/install-linter-tools.sh +++ b/checks/install-linter-tools.sh @@ -250,6 +250,7 @@ for linter in "${REQUIRED_LINTERS[@]}"; do shellcheck) PACKAGES+=("shellcheck") ;; groovylint) PACKAGES+=("npm") ;; markdownlint) PACKAGES+=("npm") ;; + python) PACKAGES+=("flake8" "pylint") ;; codespell) PACKAGES+=("codespell") ;; *) continue ;; esac @@ -321,12 +322,29 @@ else LINUX_PACKAGES=() for package in "${MISSING_PACKAGES[@]}"; do - if [[ "${package}" == "npm" ]]; then - # Ubuntu/WSL reliability: install both nodejs and npm together. - LINUX_PACKAGES+=("nodejs" "npm") - else - LINUX_PACKAGES+=("${package}") - fi + case "${package}" in + npm) + # Ubuntu/WSL reliability: install both nodejs and npm together. + LINUX_PACKAGES+=("nodejs" "npm") + ;; + flake8) + if command -v dnf >/dev/null 2>&1 || command -v yum >/dev/null 2>&1; then + LINUX_PACKAGES+=("python3-flake8") + else + LINUX_PACKAGES+=("flake8") + fi + ;; + pylint) + if command -v dnf >/dev/null 2>&1 || command -v yum >/dev/null 2>&1; then + LINUX_PACKAGES+=("python3-pylint") + else + LINUX_PACKAGES+=("pylint") + fi + ;; + *) + LINUX_PACKAGES+=("${package}") + ;; + esac done if command -v apt-get >/dev/null 2>&1; then diff --git a/checks/linter-common.sh b/checks/linter-common.sh index a1418a1..bf9724a 100755 --- a/checks/linter-common.sh +++ b/checks/linter-common.sh @@ -142,12 +142,12 @@ linter_is_shell_script_candidate() { return 0 fi - # Files without an extension may still be shell scripts when they - # declare a shell interpreter in a shebang line. + # Files with any extension are linted by their extension only. if [[ "${base_name}" == *.* ]]; then return 1 fi + # Files without an extension may declare a shell interpreter via a shebang. IFS= read -r first_line < "${absolute_path}" || true if printf '%s\n' "${first_line}" | LC_ALL=C grep -Eq \ '^#![[:space:]]*([^[:space:]]+/)?(env([[:space:]]+-S)?[[:space:]]+)?(bash|sh|dash|ksh|zsh)([[:space:]]|$)'; then @@ -187,3 +187,32 @@ linter_is_markdown_candidate() { return 1 } + +linter_is_python_candidate() { + local file_path="$1" + local absolute_path="${TARGET_ROOT}/${file_path}" + local first_line="" + + [[ -f "${absolute_path}" ]] || return 1 + + case "${file_path}" in + *.py) + return 0 + ;; + esac + + # Files with any extension are linted by their extension only. + local base_name="${file_path##*/}" + if [[ "${base_name}" == *.* ]]; then + return 1 + fi + + # Files without an extension may declare a Python interpreter via a shebang. + IFS= read -r first_line < "${absolute_path}" || true + if printf '%s\n' "${first_line}" | LC_ALL=C grep -Eq \ + '^#![[:space:]]*([^[:space:]]+/)?(env([[:space:]]+-S)?[[:space:]]+)?python([[:space:]]|$)'; then + return 0 + fi + + return 1 +} diff --git a/checks/linters/python/run.ps1 b/checks/linters/python/run.ps1 new file mode 100644 index 0000000..84ca5b7 --- /dev/null +++ b/checks/linters/python/run.ps1 @@ -0,0 +1,9 @@ +# Copyright 2026 Hewlett Packard Enterprise Development LP +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$scriptDir = Split-Path -Parent $PSCommandPath +$checksRoot = Split-Path -Parent (Split-Path -Parent $scriptDir) + +. (Join-Path $checksRoot 'invoke-bash.ps1') +Invoke-BashScript -ScriptPath (Join-Path $scriptDir 'run.sh') -ScriptArgs $args diff --git a/checks/linters/python/run.sh b/checks/linters/python/run.sh new file mode 100755 index 0000000..bd41f8f --- /dev/null +++ b/checks/linters/python/run.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Copyright 2026 Hewlett Packard Enterprise Development LP +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/../../linter-common.sh" + +linter_parse_common_args "$@" +linter_fail_on_unknown_args +linter_require_common_args + +if ! command -v flake8 >/dev/null 2>&1; then + echo "flake8 is required but was not found in PATH" >&2 + exit 127 +fi +if ! command -v pylint >/dev/null 2>&1; then + echo "pylint is required but was not found in PATH" >&2 + exit 127 +fi + +files_to_check=() +while IFS= read -r file_path; do + linter_should_skip_candidate_path "${file_path}" && continue + + if linter_is_python_candidate "${file_path}"; then + files_to_check+=("${TARGET_ROOT}/${file_path}") + fi +done < <(linter_get_candidate_files_acmr) + +if [[ ${#files_to_check[@]} -eq 0 ]]; then + echo "[python] no Python files to lint" + exit 0 +fi + +echo "[python] linting ${#files_to_check[@]} file(s)" +flake8 "${files_to_check[@]}" +pylint "${files_to_check[@]}" diff --git a/docs/linters.md b/docs/linters.md index dc3b3d6..dd8b77e 100644 --- a/docs/linters.md +++ b/docs/linters.md @@ -61,6 +61,7 @@ The changed-file linter set currently includes: - `groovylint` (Groovy and Jenkins DSL linting for `*.groovy`, `*.gradle`, and `Jenkinsfile*`) - `markdownlint` (Markdown linting for `*.md` and `*.markdown`) +- `python` (shared Python linting for `*.py` using `flake8` and `pylint`) Groovylint execution also includes a post-lint guard that rejects implicit script-binding assignments (bare `name = value` at statement start) to prevent @@ -122,6 +123,8 @@ Current tool preflight mapping: - `markdownlint` linter requires the `markdownlint` CLI on PATH; `markdownlint-cli` is installed via npm (see the markdownlint version note in the Current Linters section above) +- `python` linter requires both `flake8` and `pylint` on PATH and runs both + tools against the same changed Python file set On Linux/macOS targets, preflight failures include install hints for common package managers. diff --git a/vscode-project-words.txt b/vscode-project-words.txt index b521f95..7f6cac7 100644 --- a/vscode-project-words.txt +++ b/vscode-project-words.txt @@ -21,6 +21,7 @@ nicolasvuillamy nonblank Pipenv pyenv +pylint pylintrc pyproject pyyaml