| #!/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" |