|
| 1 | +#!/usr/bin/env bash |
| 2 | +# (C) 2026 GoodData Corporation |
| 3 | +# |
| 4 | +# Python validation wrapper for AIDA validate_command. |
| 5 | +# |
| 6 | +# Contract (streaming-first): |
| 7 | +# - Emits human-readable progress lines to stdout. |
| 8 | +# - Writes raw command output to stderr only when a step fails. |
| 9 | +# - Prints the final JSON result as the last non-empty line on stdout. |
| 10 | + |
| 11 | +set -uo pipefail |
| 12 | + |
| 13 | +TOOL="validate_python" |
| 14 | + |
| 15 | +json_escape() { |
| 16 | + local s="${1-}" |
| 17 | + s="${s//\\/\\\\}" |
| 18 | + s="${s//\"/\\\"}" |
| 19 | + s="${s//$'\t'/\\t}" |
| 20 | + s="${s//$'\r'/\\r}" |
| 21 | + s="${s//$'\n'/\\n}" |
| 22 | + printf '%s' "$s" |
| 23 | +} |
| 24 | + |
| 25 | +emit_line() { |
| 26 | + local msg="${2-${1-}}" |
| 27 | + printf '%s\n' "$msg" |
| 28 | +} |
| 29 | + |
| 30 | +usage() { |
| 31 | + cat <<'EOF' |
| 32 | +Usage: |
| 33 | + validate_python.sh --project-path <path> --workspace-root <path> [options] |
| 34 | +
|
| 35 | +Options: |
| 36 | + --scope <scope> Validation scope: pre_commit or pre_push (default: pre_push) |
| 37 | + pre_commit: format,lint,types |
| 38 | + pre_push: format,lint,types,test |
| 39 | + --steps-csv "<csv>" Comma-separated steps (overrides --scope defaults) |
| 40 | + --auto-fix <true|false> Default: true (uses *-fix Make targets) |
| 41 | + --test-filter <pattern> Optional pytest selector (runs uv run pytest -v <pattern>) |
| 42 | +
|
| 43 | + -h, --help Show help |
| 44 | +EOF |
| 45 | +} |
| 46 | + |
| 47 | +PROJECT_PATH="" |
| 48 | +WORKSPACE_ROOT="" |
| 49 | +SCOPE="" |
| 50 | +STEPS_CSV="" |
| 51 | +AUTO_FIX="true" |
| 52 | +TEST_FILTER="" |
| 53 | + |
| 54 | +while [[ $# -gt 0 ]]; do |
| 55 | + case "$1" in |
| 56 | + --project-path) PROJECT_PATH="${2-}"; shift 2 ;; |
| 57 | + --workspace-root) WORKSPACE_ROOT="${2-}"; shift 2 ;; |
| 58 | + --scope) SCOPE="${2-}"; shift 2 ;; |
| 59 | + --steps-csv) STEPS_CSV="${2-}"; shift 2 ;; |
| 60 | + --auto-fix) AUTO_FIX="${2-}"; shift 2 ;; |
| 61 | + --test-filter) TEST_FILTER="${2-}"; shift 2 ;; |
| 62 | + -h|--help) usage; exit 0 ;; |
| 63 | + *) echo "Unknown arg: $1" >&2; usage >&2; exit 2 ;; |
| 64 | + esac |
| 65 | +done |
| 66 | + |
| 67 | +if [[ -z "$PROJECT_PATH" || -z "$WORKSPACE_ROOT" ]]; then |
| 68 | + echo "Missing required args: --project-path and --workspace-root" >&2 |
| 69 | + usage >&2 |
| 70 | + exit 2 |
| 71 | +fi |
| 72 | + |
| 73 | +PROJECT_NAME="$(basename "$PROJECT_PATH")" |
| 74 | + |
| 75 | +to_bool() { |
| 76 | + local v="${1-}" |
| 77 | + v="$(printf '%s' "$v" | tr '[:upper:]' '[:lower:]')" |
| 78 | + case "$v" in |
| 79 | + 1|true|yes|on) printf 'true' ;; |
| 80 | + *) printf 'false' ;; |
| 81 | + esac |
| 82 | +} |
| 83 | + |
| 84 | +AUTO_FIX_BOOL="$(to_bool "$AUTO_FIX")" |
| 85 | + |
| 86 | +split_csv() { |
| 87 | + local csv="${1-}" |
| 88 | + local out=() |
| 89 | + local item |
| 90 | + IFS=',' read -r -a out <<<"$csv" |
| 91 | + for item in "${out[@]}"; do |
| 92 | + item="$(printf '%s' "$item" | xargs)" |
| 93 | + if [[ -n "$item" ]]; then |
| 94 | + printf '%s\n' "$item" |
| 95 | + fi |
| 96 | + done |
| 97 | +} |
| 98 | + |
| 99 | +if [[ -n "${STEPS_CSV// }" ]]; then |
| 100 | + mapfile -t STEPS < <(split_csv "$STEPS_CSV") |
| 101 | +else |
| 102 | + case "${SCOPE}" in |
| 103 | + pre_commit) |
| 104 | + STEPS=("format" "lint" "types") |
| 105 | + ;; |
| 106 | + pre_push|"") |
| 107 | + STEPS=("format" "lint" "types" "test") |
| 108 | + ;; |
| 109 | + *) |
| 110 | + STEPS=("format" "lint" "types" "test") |
| 111 | + ;; |
| 112 | + esac |
| 113 | +fi |
| 114 | + |
| 115 | +if [[ ! -d "$PROJECT_PATH" ]]; then |
| 116 | + emit_line info "Python ${PROJECT_NAME}: project path not found: ${PROJECT_PATH}" |
| 117 | + printf '{"tool":"%s","success":false,"text":"%s"}\n' \ |
| 118 | + "$(json_escape "$TOOL")" \ |
| 119 | + "$(json_escape "project path not found: ${PROJECT_PATH}")" |
| 120 | + exit 1 |
| 121 | +fi |
| 122 | + |
| 123 | +if [[ ! -f "${PROJECT_PATH%/}/Makefile" ]]; then |
| 124 | + emit_line info "Python ${PROJECT_NAME}: Makefile missing (required)" |
| 125 | + printf '{"tool":"%s","success":false,"text":"%s"}\n' \ |
| 126 | + "$(json_escape "$TOOL")" \ |
| 127 | + "$(json_escape "No Makefile found in ${PROJECT_PATH}")" |
| 128 | + exit 1 |
| 129 | +fi |
| 130 | + |
| 131 | +run_in_project() { |
| 132 | + local label="$1" |
| 133 | + shift |
| 134 | + local -a cmd=("$@") |
| 135 | + local tmp_out |
| 136 | + tmp_out="$(mktemp)" |
| 137 | + |
| 138 | + emit_line progress "Python ${PROJECT_NAME}: ${label}" |
| 139 | + |
| 140 | + (cd "$PROJECT_PATH" && env -u VIRTUAL_ENV "${cmd[@]}") >"$tmp_out" 2>&1 |
| 141 | + local rc=$? |
| 142 | + if [[ "$rc" -ne 0 ]]; then |
| 143 | + cat "$tmp_out" >&2 |
| 144 | + fi |
| 145 | + rm -f "$tmp_out" |
| 146 | + return "$rc" |
| 147 | +} |
| 148 | + |
| 149 | +run_in_project_capture() { |
| 150 | + local label="$1" |
| 151 | + local out_file="$2" |
| 152 | + shift 2 |
| 153 | + local -a cmd=("$@") |
| 154 | + |
| 155 | + emit_line progress "Python ${PROJECT_NAME}: ${label}" |
| 156 | + |
| 157 | + (cd "$PROJECT_PATH" && env -u VIRTUAL_ENV "${cmd[@]}") >"$out_file" 2>&1 |
| 158 | + return $? |
| 159 | +} |
| 160 | + |
| 161 | +has_make_target() { |
| 162 | + local target="$1" |
| 163 | + local code |
| 164 | + (cd "$PROJECT_PATH" && make -q "$target" >/dev/null 2>&1) |
| 165 | + code=$? |
| 166 | + [[ $code -eq 0 || $code -eq 1 ]] |
| 167 | +} |
| 168 | + |
| 169 | +FAIL_STEP="" |
| 170 | + |
| 171 | +for step in "${STEPS[@]}"; do |
| 172 | + case "$step" in |
| 173 | + format) |
| 174 | + if [[ "$AUTO_FIX_BOOL" == "true" ]] && has_make_target "format-fix"; then |
| 175 | + if ! run_in_project "Format (make format-fix)" make format-fix; then FAIL_STEP="Format"; break; fi |
| 176 | + else |
| 177 | + if ! run_in_project "Format (make format)" make format; then FAIL_STEP="Format"; break; fi |
| 178 | + fi |
| 179 | + ;; |
| 180 | + lint) |
| 181 | + if [[ "$AUTO_FIX_BOOL" == "true" ]] && has_make_target "lint-fix"; then |
| 182 | + if ! run_in_project "Lint (make lint-fix)" make lint-fix; then FAIL_STEP="Lint"; break; fi |
| 183 | + else |
| 184 | + if ! run_in_project "Lint (make lint)" make lint; then FAIL_STEP="Lint"; break; fi |
| 185 | + fi |
| 186 | + ;; |
| 187 | + types) |
| 188 | + tmp_types="$(mktemp)" |
| 189 | + if run_in_project_capture "Types (make type-check)" "$tmp_types" make type-check; then |
| 190 | + rm -f "$tmp_types" |
| 191 | + else |
| 192 | + cat "$tmp_types" >&2 |
| 193 | + rm -f "$tmp_types" |
| 194 | + FAIL_STEP="Types" |
| 195 | + break |
| 196 | + fi |
| 197 | + ;; |
| 198 | + test) |
| 199 | + if [[ -n "${TEST_FILTER}" ]]; then |
| 200 | + if ! run_in_project "Test (pytest ${TEST_FILTER})" uv run pytest -v "$TEST_FILTER"; then |
| 201 | + FAIL_STEP="Test"; break |
| 202 | + fi |
| 203 | + else |
| 204 | + if ! run_in_project "Test (make test)" make test; then FAIL_STEP="Test"; break; fi |
| 205 | + fi |
| 206 | + ;; |
| 207 | + *) |
| 208 | + emit_line info "Python ${PROJECT_NAME}: skipping unknown step '${step}'" |
| 209 | + ;; |
| 210 | + esac |
| 211 | +done |
| 212 | + |
| 213 | +if [[ -n "$FAIL_STEP" ]]; then |
| 214 | + printf '{"tool":"%s","success":false,"text":"%s"}\n' \ |
| 215 | + "$(json_escape "$TOOL")" \ |
| 216 | + "$(json_escape "Python ${PROJECT_NAME}: FAILED at ${FAIL_STEP}")" |
| 217 | + exit 1 |
| 218 | +fi |
| 219 | + |
| 220 | +printf '{"tool":"%s","success":true,"text":"%s"}\n' \ |
| 221 | + "$(json_escape "$TOOL")" \ |
| 222 | + "$(json_escape "Python ${PROJECT_NAME}: PASSED")" |
| 223 | +exit 0 |
0 commit comments