blob: 0b297bbe41c82817d33f34a3acb4aaa268355a04 [file]
#!/usr/bin/env bash
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
# Run a single static-check / test command across every workspace
# member declared in the root `pyproject.toml`'s
# `[tool.uv.workspace] members` list. Used by the four
# workspace-* hooks in `.pre-commit-config.yaml` so adding a new
# tool doesn't require editing the pre-commit config at all —
# just append the new member's path to `[tool.uv.workspace]`.
#
# Usage:
# tools/dev/run-workspace-check.sh <check-key> <check-cmd> [args...]
#
# `check-key` selects which auto-discovery rule applies and which
# per-member opt-out entry to consult. Recognised keys:
#
# ruff → runs only on members with `[tool.ruff]`
# ruff-format → runs only on members with `[tool.ruff]`
# mypy → runs only on members with `[tool.mypy]`
# pytest → runs only on members with `[tool.pytest.ini_options]`
#
# `check-cmd` is the actual command-line passed to `uv run`. It
# may be a multi-token string (e.g. "ruff check") quoted as a
# single argv item.
#
# Per-member opt-out: a workspace member can skip one or more
# checks by adding a `[tool.magpie.checks]` block:
#
# [tool.magpie.checks]
# skip = ["mypy", "pytest"]
#
# Examples:
# tools/dev/run-workspace-check.sh ruff "ruff check"
# tools/dev/run-workspace-check.sh ruff-format "ruff format --check"
# tools/dev/run-workspace-check.sh mypy mypy
# tools/dev/run-workspace-check.sh pytest "pytest --color=yes"
#
# Exit code: 0 iff the check succeeded on every applicable
# member; 1 otherwise. The script does NOT short-circuit on the
# first failure — every member runs so the operator sees all
# failures in one pass.
set -uo pipefail
if [ "$#" -lt 2 ]; then
echo "usage: $0 <check-key> <check-cmd> [args...]" >&2
exit 64
fi
CHECK_KEY="$1"
CHECK_CMD="$2"
shift 2
# Scrub location-redirecting git env vars. When pre-commit runs this script
# as a git hook, git exports GIT_DIR / GIT_INDEX_FILE / GIT_WORK_TREE (etc.)
# pointing at *this* repository. Any member test that shells out to `git` in
# a throwaway repo (e.g. tools/agent-isolation, tools/vcs) would otherwise
# inherit those and operate on the wrong repository — passing standalone but
# failing under `git commit`. Unsetting them makes git resolve the repo from
# each test's own cwd. This script reads pyproject.toml via Python, not git,
# so it needs none of these itself.
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX
# Every check runs through `uv` against the member's own project — never a
# bare interpreter — so the tool version comes from that sub-project's
# pyproject.toml + dev group (see each member's `[dependency-groups] dev`).
# mypy and pytest are invoked as `python -m <tool>` rather than via their
# console scripts: a console script bakes an absolute interpreter path into
# its shebang, which breaks when the shared workspace venv is reused across
# repos (a stale `#!/…/other-repo/.venv/bin/python3` fails to spawn). The
# `-m` form resolves the tool from the live interpreter and sidesteps that.
# Discover workspace members + per-check applicability. The Python
# helper walks the root `[tool.uv.workspace] members` list, opens
# each member's pyproject.toml, and emits one line per applicable
# member: "<check>\t<path>" — with the `<check>` filter already
# applied based on (a) the presence of the relevant `[tool.*]`
# section and (b) the member's `[tool.magpie.checks] skip` list.
applicable=$(python3 - "$CHECK_KEY" <<'PY'
import sys
import tomllib
check = sys.argv[1]
# Which `[tool.*]` section signals that this check is configured
# for a given member. ruff-check and ruff-format both look at
# `[tool.ruff]` because the same config drives both.
SECTION_FOR_CHECK = {
"ruff": "ruff",
"ruff-format": "ruff",
"mypy": "mypy",
"pytest": "pytest",
}
section_key = SECTION_FOR_CHECK.get(check)
if section_key is None:
sys.stderr.write(f"error: unknown check-key {check!r}\n")
sys.exit(2)
with open("pyproject.toml", "rb") as f:
root = tomllib.load(f)
try:
members = root["tool"]["uv"]["workspace"]["members"]
except KeyError:
sys.stderr.write("error: [tool.uv.workspace] members not in root pyproject.toml\n")
sys.exit(2)
for member in members:
try:
with open(f"{member}/pyproject.toml", "rb") as f:
data = tomllib.load(f)
except FileNotFoundError:
sys.stderr.write(f"warning: workspace member missing pyproject.toml: {member}\n")
continue
tool = data.get("tool", {})
# Opt-out check.
skip = tool.get("magpie", {}).get("checks", {}).get("skip", [])
if check in skip:
continue
# Auto-discovery check — the [tool.<X>] block must exist.
# `pytest` looks at the nested `[tool.pytest.ini_options]` table.
if section_key == "pytest":
if "ini_options" not in tool.get("pytest", {}):
continue
else:
if section_key not in tool:
continue
print(member)
PY
)
status=$?
if [ "$status" -ne 0 ]; then
exit "$status"
fi
if [ -z "$applicable" ]; then
echo "→ ${CHECK_KEY}: no applicable workspace members (nothing to run)"
exit 0
fi
count=$(echo "$applicable" | wc -l | tr -d ' ')
echo "→ workspace-check: ${CHECK_KEY} (${CHECK_CMD} $*) across ${count} members"
failed=()
for member in $applicable; do
name=$(basename "$member")
# Run every check via uv, against the member's own project:
# --directory "$member" → cwd is the member, so ruff / mypy / pytest
# configs resolve paths relative to the member root
# --project . → the environment is that member's project (its
# pyproject.toml + dev group), resolved after the
# --directory chdir
# mypy and pytest go through `python -m` to bypass console-script shebangs;
# ruff is a native binary with no interpreter shebang, so it runs directly.
# shellcheck disable=SC2086 # CHECK_CMD may legitimately be multi-token
if [[ "$CHECK_KEY" == "mypy" ]] || [[ "$CHECK_KEY" == "pytest" ]]; then
if ! uv run --directory "$member" --project . python -m $CHECK_CMD "$@"; then
failed+=("$name")
fi
elif ! uv run --directory "$member" --project . $CHECK_CMD "$@"; then
failed+=("$name")
fi
done
if [ "${#failed[@]}" -gt 0 ]; then
echo "✗ ${CHECK_KEY} failed for: ${failed[*]}" >&2
exit 1
fi
echo "✓ ${CHECK_KEY} passed for all ${count} applicable members"