Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .github/scripts/lint-changed.sh
Original file line number Diff line number Diff line change
@@ -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
102 changes: 102 additions & 0 deletions .github/scripts/noxfile-lint.py
Original file line number Diff line number Diff line change
@@ -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.")
39 changes: 39 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -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 }}"
27 changes: 27 additions & 0 deletions .internal/tests/fixtures/test_pass.py
Original file line number Diff line number Diff line change
@@ -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)
Loading