diff --git a/.github/scripts/lint-changed.sh b/.github/scripts/lint-changed.sh new file mode 100644 index 00000000000..0597909b9ae --- /dev/null +++ b/.github/scripts/lint-changed.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# 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. + +set -euo pipefail + +EVENT_NAME="${1:-local}" +BASE_REF="${2:-main}" + +echo "Configuring target diff for event: $EVENT_NAME" + +# Determine the target reference to diff against +if [ "$EVENT_NAME" = "pull_request" ]; then + echo "Fetching origin/$BASE_REF metadata..." + # Ensure we have the target branch metadata fetched. + git fetch origin "$BASE_REF" --depth=1 --quiet + + # Isolate to only changes in the PR + DIFF_COMMAND="origin/$BASE_REF..." +else + # Local fallback + # diff against local main branch. + echo "Running locally. Diffing against local $BASE_REF..." + DIFF_COMMAND="$BASE_REF" +fi + +# Gather modified/added Python files, explicitly ignoring deleted files via --diff-filter=d +DIFF_OUTPUT=$(git diff --name-only --diff-filter=d "$DIFF_COMMAND" -- '*.py' 2>/dev/null || true) + +if [ -n "$DIFF_OUTPUT" ]; then + mapfile -t CHANGED_FILES <<< "$DIFF_OUTPUT" +else + CHANGED_FILES=() +fi + +# Execute linters if changed Python files exist +if [ ${#CHANGED_FILES[@]} -gt 0 ]; then + echo "Files to lint:" + printf ' - %s\n' "${CHANGED_FILES[@]}" + + # Track execution success manually so both tools get a chance to run. + # This prevents the workflow from dying on Black without showing Flake8 errors. + BLACK_EXIT=0 + LINT_EXIT=0 + + # adding -q to silence some of the extraneous logging + echo "Running blacken..." + nox -s blacken -- "${CHANGED_FILES[@]}" || BLACK_EXIT=$? + + echo "Running flake8 lint..." + nox -s lint -- "${CHANGED_FILES[@]}" || LINT_EXIT=$? + + if [ $BLACK_EXIT -ne 0 ] || [ $LINT_EXIT -ne 0 ]; then + echo "❌ One or more linting checks failed." + exit 1 + fi +else + echo "✅ No Python files changed in this scope. Skipping checks." +fi diff --git a/.github/scripts/noxfile-lint.py b/.github/scripts/noxfile-lint.py new file mode 100644 index 00000000000..c1ce614150d --- /dev/null +++ b/.github/scripts/noxfile-lint.py @@ -0,0 +1,102 @@ +# 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. + +from __future__ import annotations + +import os + +import nox + +# Use a stable Python version for running the style utilities +LINTING_VERSION = "3.10" + +# Error out if the runner is missing the target python interpreter +nox.options.error_on_missing_interpreters = True + + +def _determine_local_import_names(start_dir: str) -> list[str]: + """Determines local import names to assist Flake8 with import order checks.""" + try: + file_ext_pairs = [os.path.splitext(path) for path in os.listdir(start_dir)] + return [ + basename + for basename, extension in file_ext_pairs + if extension == ".py" + or (os.path.isdir(os.path.join(start_dir, basename)) and basename != "__pycache__") + ] + except Exception: + return [] + +# Linting with flake8. +# +# We ignore the following rules: +# ANN101: missing type annotation for `self` in method +# ANN102: missing type annotation for `cls` in method +# E203: whitespace before ‘:’ +# E266: too many leading ‘#’ for block comment +# E501: line too long +# I202: Additional newline in a section of imports +# +# We also need to specify the rules which are ignored by default: +# ['E226', 'W504', 'E126', 'E123', 'W503', 'E24', 'E704', 'E121'] +# +# For more information see: https://pypi.org/project/flake8-annotations + +# Standardize style configuration parameters +FLAKE8_COMMON_ARGS = [ + "--show-source", + "--builtin=gettext", + "--max-complexity=20", + "--import-order-style=google", + "--exclude=.nox,.cache,env,lib,generated_pb2,*_pb2.py,*_pb2_grpc.py", + "--ignore=ANN101,ANN102,E121,E123,E126,E203,E226,E24,E266,E501,E704,W503,W504,I202", + "--max-line-length=88", +] + + +@nox.session(python=LINTING_VERSION) +def lint(session: nox.sessions.Session) -> None: + """Runs flake8 linting checks. Honors incremental PR file arguments.""" + session.install("flake8", "flake8-import-order") + + local_names = _determine_local_import_names(".") + args = FLAKE8_COMMON_ARGS + [ + "--application-import-names", + ",".join(local_names), + ] + + if session.posargs: + args.extend(session.posargs) + else: + args.append(".") + + session.run("flake8", *args) + + +@nox.session(python=LINTING_VERSION) +def blacken(session: nox.sessions.Session) -> None: + """Runs black code formatting checks. Honors incremental PR file arguments.""" + session.install("black") + + # If explicit target files are passed via posargs, target ONLY those files. + if session.posargs: + targets = session.posargs + else: + # Fallback to scanning immediate root Python files if run purely locally without args + targets = [path for path in os.listdir(".") if path.endswith(".py")] + + if targets: + session.run("black", *targets) + else: + session.log("No specific Python targets identified for formatting validations.") diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000000..9701c2f0de8 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,39 @@ +name: Lint + +on: + pull_request: + branches: [ main ] + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.10' + + - name: Install nox + run: | + python -m pip install --upgrade pip + python -m pip install nox + + - name: Prepare noxfile + # copy from customized noxfile so that we don't lint everything in the repository + # we don't expect contributors to fix past lint issues + run: cp .github/scripts/noxfile-lint.py noxfile.py + + - name: Make script executable + run: chmod +x .github/scripts/lint-changed.sh + + - name: Run lint script for changed files + run: | + .github/scripts/lint-changed.sh "pull_request" "${{ github.base_ref }}" diff --git a/.internal/tests/fixtures/test_pass.py b/.internal/tests/fixtures/test_pass.py new file mode 100644 index 00000000000..e9cbf0f0225 --- /dev/null +++ b/.internal/tests/fixtures/test_pass.py @@ -0,0 +1,27 @@ +# 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. + +# test_pass.py + + +def calculate_square(number: int) -> int: + """Calculates the square of a given integer.""" + result = number * number + print(f"The result is: {result}") + return result + + +if __name__ == "__main__": + # Ensure a basic execution works cleanly + calculate_square(5)