From 7d03e8b9dae74736299c7bf3e91b6082ee5f6540 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 4 Sep 2026 05:55:57 -0400 Subject: [PATCH 1/2] chore: make pre-commit ruff hooks package-aware for monorepo --- .pre-commit-config.yaml | 24 ++++--- ci/run_ruff.py | 143 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 10 deletions(-) create mode 100755 ci/run_ruff.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5ebecd09ac76..c0f6f6e795af 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,13 +20,17 @@ repos: hooks: - id: trailing-whitespace - id: end-of-file-fixer -- repo: https://github.com/astral-sh/ruff-pre-commit - # Ruff version. - rev: v0.14.14 - hooks: - # Run the linter. - - id: ruff-check - args: [ --select, I, --fix, --target-version=py310, --line-length=88 ] - # Run the formatter. - - id: ruff-format - args: [ --target-version=py310, --line-length=88 ] +- repo: local + hooks: + - id: ruff-check + name: ruff check (monorepo) + entry: python3 ci/run_ruff.py check + language: python + types: [python] + require_serial: true + - id: ruff-format + name: ruff format (monorepo) + entry: python3 ci/run_ruff.py format + language: python + types: [python] + require_serial: true diff --git a/ci/run_ruff.py b/ci/run_ruff.py new file mode 100755 index 000000000000..c83d5b155718 --- /dev/null +++ b/ci/run_ruff.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +# Copyright 2026 Google LLC +# +# Licensed 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. + +"""Package-aware Ruff runner for pre-commit in google-cloud-python monorepo. + +Because google-cloud-python contains 280+ packages that share the `google` +namespace, running Ruff from the monorepo root causes `isort` (I001) to +misidentify package-local modules as third-party. + +This runner groups staged files by their package directory (e.g. +`packages/`) and executes Ruff inside each package directory, +matching the exact execution environment used by `nox -s lint` in CI. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from collections import defaultdict +from pathlib import Path + + +def find_package_root(filepath: Path, repo_root: Path) -> Path: + """Find the enclosing package directory for a file, relative to repo_root. + + Returns the path to `packages/` or `preview-packages/` if the + file is located inside a package, otherwise returns `repo_root`. + """ + try: + rel_path = filepath.resolve().relative_to(repo_root.resolve()) + except ValueError: + return repo_root + + parts = rel_path.parts + for i, part in enumerate(parts): + if part in ("packages", "preview-packages") and i + 1 < len(parts): + return repo_root / Path(*parts[: i + 2]) + return repo_root + + +def run_ruff_for_package( + command: str, + pkg_root: Path, + files: list[Path], + extra_args: list[str], +) -> int: + """Run ruff check or ruff format inside a package directory.""" + rel_files = [str(f.resolve().relative_to(pkg_root.resolve())) for f in files] + + if command == "check": + cmd = [ + "ruff", + "check", + "--select", + "I", + "--fix", + "--target-version=py310", + "--line-length=88", + *extra_args, + *rel_files, + ] + elif command == "format": + cmd = [ + "ruff", + "format", + "--target-version=py310", + "--line-length=88", + *extra_args, + *rel_files, + ] + else: + cmd = ["ruff", command, *extra_args, *rel_files] + + res = subprocess.run(cmd, cwd=str(pkg_root)) + return res.returncode + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Package-aware Ruff runner for pre-commit." + ) + parser.add_argument( + "command", + choices=["check", "format"], + help="Ruff subcommand to execute ('check' or 'format')", + ) + parser.add_argument( + "files", + nargs="*", + type=Path, + help="List of files to lint/format", + ) + args, unknown = parser.parse_known_args(argv) + + if not args.files: + return 0 + + repo_root = Path.cwd() + try: + git_root_out = subprocess.check_output( + ["git", "rev-parse", "--show-toplevel"], + text=True, + stderr=subprocess.DEVNULL, + ).strip() + if git_root_out: + repo_root = Path(git_root_out) + except Exception: + pass + + # Group python files by package root + package_groups: dict[Path, list[Path]] = defaultdict(list) + for f in args.files: + if not f.exists(): + continue + if f.suffix != ".py": + continue + pkg_root = find_package_root(f, repo_root) + package_groups[pkg_root].append(f) + + overall_rc = 0 + for pkg_root, files in package_groups.items(): + rc = run_ruff_for_package(args.command, pkg_root, files, unknown) + if rc != 0: + overall_rc = rc + + return overall_rc + + +if __name__ == "__main__": + sys.exit(main()) From ec5c796cb60d1eb47f78479d5cd96ff0e4c8cb41 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Fri, 4 Sep 2026 06:23:54 -0400 Subject: [PATCH 2/2] chore: address reviewer feedback for pre-commit ruff runner --- .pre-commit-config.yaml | 6 ++++-- ci/run_ruff.py | 13 ++++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c0f6f6e795af..4d7eb566e4c8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,13 +24,15 @@ repos: hooks: - id: ruff-check name: ruff check (monorepo) - entry: python3 ci/run_ruff.py check + entry: python ci/run_ruff.py check language: python + additional_dependencies: ['ruff==0.14.14'] types: [python] require_serial: true - id: ruff-format name: ruff format (monorepo) - entry: python3 ci/run_ruff.py format + entry: python ci/run_ruff.py format language: python + additional_dependencies: ['ruff==0.14.14'] types: [python] require_serial: true diff --git a/ci/run_ruff.py b/ci/run_ruff.py index c83d5b155718..c73ea21a6eab 100755 --- a/ci/run_ruff.py +++ b/ci/run_ruff.py @@ -84,8 +84,15 @@ def run_ruff_for_package( else: cmd = ["ruff", command, *extra_args, *rel_files] - res = subprocess.run(cmd, cwd=str(pkg_root)) - return res.returncode + try: + res = subprocess.run(cmd, cwd=str(pkg_root)) + return res.returncode + except FileNotFoundError: + print( + "Error: 'ruff' command not found. Please ensure Ruff is installed and available in your PATH.", + file=sys.stderr, + ) + return 1 def main(argv: list[str] | None = None) -> int: @@ -125,7 +132,7 @@ def main(argv: list[str] | None = None) -> int: for f in args.files: if not f.exists(): continue - if f.suffix != ".py": + if f.suffix not in (".py", ".pyi", ".ipynb"): continue pkg_root = find_package_root(f, repo_root) package_groups[pkg_root].append(f)