Skip to content

Commit ccb0e11

Browse files
authored
feat(tests): Cover --hook-config=--tool-version resolution with pytest (#1004)
### Description of your changes #### What <sub>This section was generated by AI.</sub> - Add `tests/pytest/tool_version_test.py`: black-box pytest suite for `--hook-config=--tool-version=` / `--tool-version-mode=` / `--tf-path` resolution added on `downloadable_and_version_controlled_hooks`. Tests invoke real `hooks/*.sh` scripts as subprocesses and assert only on exit code / cache filesystem state / output - never on bash-internal function names - so they need minimal changes if a hook is ever rewritten in another language. - **Assertions prove which binary actually ran.** Each stub echoes a unique marker; every network-free test asserts that marker plus the absence of the losing candidate's. `--tool-version-mode=strict` in particular logs its NOTE *before* choosing a binary. - Hooks now run against a coreutils-only `PATH` (no wrapped CLI tool) and an allowlisted environment (`PRE_COMMIT_COLOR=never`, `LC_ALL=C`, no inherited `PCT_TFPATH`/`TERRAGRUNT_TFPATH`/`TF_*`), so a host install or an exported variable cannot change what a test resolves. The temp repo is created with `git init --template=` and committed with `--no-verify`, because this project's own README tells users to point `init.templateDir` at a directory with pre-commit installed. - Coverage: cache-hit reuse, `strict` vs `prefer-local`, the `--tf-path=terraform|opentofu|tofu` selector and its invalid-value error, `--tf-path` auto-detect **including Terraform winning when both binaries are on `$PATH`**, `--tf-path` literal passthrough when `--tool-version` is *not* set, the `XDG_CACHE_HOME` and `$HOME/.cache` cache roots, the documented `checkov` no-op, the actionable "tool not found" error, the `tool_name` wired by **each of the 14 resolving hooks**, and one real end-to-end download on a cache miss. - `fix(hooks)`: resolve a pinned tool **once** per run. `common::per_dir_hook` now takes the already-resolved `tool_path` instead of re-resolving it, so hooks that need it before the per-dir loop (`tflint --init`; the terragrunt hooks choosing `hcl format` vs `hclfmt`) no longer trigger a duplicate NOTE log and, on a cache miss, a duplicate download. - `fix(hooks)`: reject an unrecognized `--tool-version-mode` instead of treating it as `strict`, so a typo like `prefer_local` no longer silently does the opposite of what was asked. Documented in README. - Package `hooks/`, `lib_getopt` and `tools/install/` into the sdist (`hatch.toml`), since the CI `tests` job runs pytest against a built sdist and needs the hook scripts this suite subprocesses into. - Fix bash 3.2 (macOS default) `${var^^}` usage in `hooks/_common.sh` / `tools/install/_common.sh` (portable `tr` pipeline) and an OpenTofu release-asset version-regex bug. - Wire `GITHUB_TOKEN` into the tox test job so the real-download test authenticates its `api.github.com` call instead of sharing the unauthenticated per-IP rate limit across the whole CI matrix. - Mark the download test `network` (registered in `pytest.ini`, which `--strict-markers` requires) so `-m 'not network'` runs the suite offline. - Flat test functions rather than test classes, matching this repo's existing `_cli_test.py` / `terraform_docs_replace_test.py` convention; parametrized cases are bundled into `NamedTuple`s to satisfy `PLR0913`/`WPS211` without suppressions. `.flake8` ignores `WPS226` (string-literal overuse, same precedent as `_cli_test.py`) plus `WPS202`/`WPS204`, which a wide table-driven suite trips by construction. Windows-skip coverage pragmas satisfy the `covdefaults` 100% coverage gate. - Merge `stderr` into `stdout` in `_run_hook`, because `common::colorify` writes every diagnostic to stderr while a wrapped tool's own output goes to stdout - one ready-to-grep string per caller. #### Why > [!IMPORTANT] > Somehow related/conflicts with #990. So far - I can't say what will be chosen, waiting for python-guru's review Main idea - make some black-box testing for bash hooks on pytest, which then can be applied on hooks reimplementation on Python, when/if it time comes Or if that's a shity approach - then just make some black-box testing, not in pytest ### How can we test changes <sub>This section was generated by AI.</sub> - Offline: `pytest tests/pytest/ -m 'not network'` - 43 passed, 1 deselected. - Full suite: `pytest tests/pytest/` - 44 passed, coverage 100.00% (the `covdefaults` gate). Network test alone: `pytest -m network` - 1 passed. - **Mutation-tested.** Each of these, applied on its own, turns the suite red: strict mode falling back to `$PATH`; inverting the `--tf-path` auto-detect precedence; dropping the `XDG_CACHE_HOME` fallback; a `terraform-docs` -> `terraform_docs` `tool_name` typo; `--tf-path` passthrough hijacked by the pinning code; `prefer-local` ignored; the `opentofu` -> `tofu` binary rename forgotten; the `--tool-version-mode` validation removed; and re-introducing the duplicate resolution inside `common::per_dir_hook`. - Each new commit was checked out into a separate `git worktree` and its test suite run there, to confirm no intermediate commit is broken. - This PR targets `downloadable_and_version_controlled_hooks` (not `master`) so this repo's GH Actions matrix runs the suite for real, including the network-dependent download test. - All pre-commit hooks pass locally: `shellcheck`, `shfmt`, `ruff check`, `ruff format`, `wemake-python-styleguide`, `mypy` (py3.10/3.12/3.14).
1 parent cc08a68 commit ccb0e11

19 files changed

Lines changed: 1106 additions & 36 deletions

.flake8

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ per-file-ignores =
7878
WPS226,
7979
# WPS115: "Require snake_case for naming class attributes" -- testing legitimate case, ignored in main code
8080
WPS115,
81+
tests/pytest/tool_version_test.py:
82+
# WPS226: "Forbid the overuse of string literals" -- same legitimate test-fixture rationale as _cli_test.py above
83+
WPS226,
84+
# WPS202: "Found too many module members" -- this suite covers one resolution branch per test, so the module is intentionally wide rather than deep
85+
WPS202,
86+
# WPS204: "Found overused expression" -- every test wires the same hermetic env/PATH sandbox and re-asserts "no download happened"; deduplicating that into helpers would hide what each test actually guarantees
87+
WPS204,
8188
# We will not spend time on fixing complexity in deprecated hook
8289
src/pre_commit_terraform/terraform_docs_replace.py: WPS232
8390

.github/workflows/reusable-tox.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,13 @@ jobs:
311311
id: tox-run
312312
shell: bash
313313
env:
314+
# Consumed by tools/install/_common.sh (via `pass_env = GITHUB_*`
315+
# in tox.ini) to authenticate GitHub API calls made while
316+
# resolving `--tool-version` pins - without it, requests are
317+
# unauthenticated and share GitHub's low per-IP rate limit
318+
# across every job in this workflow's matrix, which is exactly
319+
# what test_real_download_on_cache_miss can hit.
320+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
314321
INPUT_TOX_RUN_POSARGS: ${{ inputs.tox-run-posargs }}
315322
run: |-
316323
tox_common_args=(

hatch.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
include = [
33
'.codecov.yml',
44
'.coveragerc',
5+
'hooks/',
6+
'lib_getopt',
57
'src/',
68
'tests/',
9+
'tools/install/',
710
'pytest.ini',
811
'tox.ini',
912
]

hooks/_common.sh

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -309,14 +309,19 @@ function common::get_cpu_num {
309309
# 3. Complete hook execution and return exit code
310310
# Arguments:
311311
# hook_id (string) hook ID, see `- id` for details in .pre-commit-hooks.yaml file
312-
# tool_name (string) name of the wrapped tool, used to resolve its path
312+
# tool_path (string) path to the wrapped tool's binary, already resolved
313+
# by the calling hook via `common::resolve_tool_path`
314+
# (empty for hooks with no resolvable binary, e.g. checkov).
315+
# Deliberately NOT resolved here: each hook resolves once in `main`,
316+
# because resolution logs a NOTE and downloads on a cache miss, so
317+
# doing it again here would duplicate both
313318
# args_array_length (integer) Count of arguments in args array.
314319
# args (array) arguments that configure wrapped tool behavior
315320
# files (array) filenames to check
316321
#######################################################################
317322
function common::per_dir_hook {
318323
local -r hook_id="$1"
319-
local -r tool_name="$2"
324+
local -r tool_path="$2"
320325
local -i args_array_length=$3
321326
shift 3
322327
local -a args=()
@@ -330,11 +335,6 @@ function common::per_dir_hook {
330335
# despite there's only one positional ARG left
331336
local -a -r files=("$@")
332337

333-
local -r tool_version=$(common::get_hook_config_value "--tool-version")
334-
local tool_path
335-
tool_path=$(common::resolve_tool_path "$tool_name" "$tool_version") || exit $?
336-
readonly tool_path
337-
338338
# check is (optional) function defined
339339
if [ "$(type -t run_hook_on_whole_repo)" == function ] &&
340340
# check is hook run via `pre-commit run --all`
@@ -616,6 +616,19 @@ function common::resolve_tool_path {
616616
#
617617
local -r tool_version_mode=$(common::get_hook_config_value "--tool-version-mode")
618618

619+
# Reject unknown values instead of silently treating them as "strict":
620+
# a typo like "prefer_local" would otherwise do the exact opposite of
621+
# what the user asked for, with no indication of why.
622+
case "$tool_version_mode" in
623+
"" | strict | prefer-local) ;;
624+
*)
625+
common::colorify "red" \
626+
"ERROR: '--tool-version-mode=$tool_version_mode' is not a valid value.\n" \
627+
"'--tool-version-mode=' must be either 'strict' (default) or 'prefer-local'."
628+
exit 1
629+
;;
630+
esac
631+
619632
if command -v "$tool_name" &> /dev/null; then
620633
if [[ $tool_version_mode == "prefer-local" ]]; then
621634
common::colorify "green" \
@@ -664,7 +677,8 @@ function common::resolve_tool_path {
664677
common::detect_os_arch
665678

666679
local env_var_name="${tool_name//-/_}"
667-
env_var_name="${env_var_name^^}_VERSION"
680+
# `${var^^}` is bash 4+ only; macOS ships bash 3.2 by default.
681+
env_var_name="$(tr '[:lower:]' '[:upper:]' <<< "${env_var_name}_VERSION")"
668682

669683
mkdir -p "$cache_dir"
670684

hooks/terraform_fmt.sh

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,13 @@ function main {
2020

2121
local -r tool_name="tf" # Will be resolved into real tool inside 'common::resolve_tool_path'
2222

23+
local -r tool_version=$(common::get_hook_config_value "--tool-version")
24+
local tool_path
25+
tool_path=$(common::resolve_tool_path "$tool_name" "$tool_version") || exit $?
26+
readonly tool_path
27+
2328
# shellcheck disable=SC2153 # False positive
24-
common::per_dir_hook "$HOOK_ID" "$tool_name" "${#ARGS[@]}" "${ARGS[@]}" "${FILES[@]}"
29+
common::per_dir_hook "$HOOK_ID" "$tool_path" "${#ARGS[@]}" "${ARGS[@]}" "${FILES[@]}"
2530
}
2631

2732
#######################################################################

hooks/terraform_providers_lock.sh

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,13 @@ function main {
1717

1818
local -r tool_name="tf" # Will be resolved into real tool inside 'common::resolve_tool_path'
1919

20+
local -r tool_version=$(common::get_hook_config_value "--tool-version")
21+
local tool_path
22+
tool_path=$(common::resolve_tool_path "$tool_name" "$tool_version") || exit $?
23+
readonly tool_path
24+
2025
# shellcheck disable=SC2153 # False positive
21-
common::per_dir_hook "$HOOK_ID" "$tool_name" "${#ARGS[@]}" "${ARGS[@]}" "${FILES[@]}"
26+
common::per_dir_hook "$HOOK_ID" "$tool_path" "${#ARGS[@]}" "${ARGS[@]}" "${FILES[@]}"
2227
}
2328

2429
#######################################################################

hooks/terraform_tflint.sh

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,6 @@ function main {
1717
# JFYI: tflint color already suppressed via PRE_COMMIT_COLOR=never
1818

1919
local -r tool_name="tflint"
20-
# Needed early for the `tflint --init` pre-flight check below, which
21-
# runs once, before (and separately from) common::per_dir_hook's own
22-
# resolution for the actual per-dir tflint runs.
2320
local -r tool_version=$(common::get_hook_config_value "--tool-version")
2421
local tool_path
2522
tool_path=$(common::resolve_tool_path "$tool_name" "$tool_version") || exit $?
@@ -39,7 +36,7 @@ function main {
3936
return ${exit_code}
4037
}
4138

42-
common::per_dir_hook "$HOOK_ID" "$tool_name" "${#ARGS[@]}" "${ARGS[@]}" "${FILES[@]}"
39+
common::per_dir_hook "$HOOK_ID" "$tool_path" "${#ARGS[@]}" "${ARGS[@]}" "${FILES[@]}"
4340
}
4441

4542
#######################################################################

hooks/terraform_tfsec.sh

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,12 @@ function main {
2323

2424
local -r tool_name="tfsec"
2525

26-
common::per_dir_hook "$HOOK_ID" "$tool_name" "${#ARGS[@]}" "${ARGS[@]}" "${FILES[@]}"
26+
local -r tool_version=$(common::get_hook_config_value "--tool-version")
27+
local tool_path
28+
tool_path=$(common::resolve_tool_path "$tool_name" "$tool_version") || exit $?
29+
readonly tool_path
30+
31+
common::per_dir_hook "$HOOK_ID" "$tool_path" "${#ARGS[@]}" "${ARGS[@]}" "${FILES[@]}"
2732
}
2833

2934
#######################################################################

hooks/terraform_trivy.sh

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,13 @@ function main {
1515

1616
local -r tool_name="trivy"
1717

18+
local -r tool_version=$(common::get_hook_config_value "--tool-version")
19+
local tool_path
20+
tool_path=$(common::resolve_tool_path "$tool_name" "$tool_version") || exit $?
21+
readonly tool_path
22+
1823
# shellcheck disable=SC2153 # ARGS is set in common::parse_cmdline
19-
common::per_dir_hook "$HOOK_ID" "$tool_name" "${#ARGS[@]}" "${ARGS[@]}" "${FILES[@]}"
24+
common::per_dir_hook "$HOOK_ID" "$tool_path" "${#ARGS[@]}" "${ARGS[@]}" "${FILES[@]}"
2025
}
2126

2227
#######################################################################

hooks/terraform_validate.sh

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,13 @@ function main {
2424

2525
local -r tool_name="tf" # Will be resolved into real tool inside 'common::resolve_tool_path'
2626

27+
local -r tool_version=$(common::get_hook_config_value "--tool-version")
28+
local tool_path
29+
tool_path=$(common::resolve_tool_path "$tool_name" "$tool_version") || exit $?
30+
readonly tool_path
31+
2732
# shellcheck disable=SC2153 # False positive
28-
common::per_dir_hook "$HOOK_ID" "$tool_name" "${#ARGS[@]}" "${ARGS[@]}" "${FILES[@]}"
33+
common::per_dir_hook "$HOOK_ID" "$tool_path" "${#ARGS[@]}" "${ARGS[@]}" "${FILES[@]}"
2934
}
3035

3136
#######################################################################

0 commit comments

Comments
 (0)