Skip to content
Open
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
6 changes: 6 additions & 0 deletions quality-gates-ai-code/.github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Changes to the quality gates need a code owner's review.
# Replace @your-username with your GitHub user or team.
/.github/ @your-username
/.pre-commit-config.yaml @your-username
/pyproject.toml @your-username
/scripts/agent_diff_check.py @your-username
39 changes: 39 additions & 0 deletions quality-gates-ai-code/.github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: quality-gates
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
gates:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10
with:
version: "0.13.3"
enable-cache: true
- run: uv sync --locked
- run: uv run pre-commit run check-yaml --all-files
- run: uv run pre-commit run detect-private-key --all-files
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run mypy src tests
- run: uv run bandit -q -c pyproject.toml -r src
- run: uv export --no-dev --no-hashes --no-emit-project -o requirements.txt
- run: uv run pip-audit -r requirements.txt --no-deps --disable-pip
- run: uv run pytest
diff-check:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- env:
BASE: ${{ github.event.pull_request.base.sha }}
run: |
# Run the base branch's copy, so the PR can't weaken it.
git show "$BASE:scripts/agent_diff_check.py" > "$RUNNER_TEMP/check.py"
python3 "$RUNNER_TEMP/check.py" --base "$BASE"
6 changes: 6 additions & 0 deletions quality-gates-ai-code/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.venv/
__pycache__/
.coverage
*.pyc
.mypy_cache/
.ruff_cache/
31 changes: 31 additions & 0 deletions quality-gates-ai-code/.pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-yaml
- id: detect-private-key
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.17.1
hooks:
- id: ruff-check
- id: ruff-format
- repo: local
hooks:
- id: mypy
name: mypy
entry: uv run mypy src tests
language: unsupported
pass_filenames: false
types: [python]
- id: bandit
name: bandit
entry: uv run bandit -q -c pyproject.toml -r src
language: unsupported
pass_filenames: false
types: [python]
- id: agent-diff-check
name: agent diff sanity
entry: uv run python scripts/agent_diff_check.py
language: unsupported
pass_filenames: false
always_run: true
1 change: 1 addition & 0 deletions quality-gates-ai-code/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.13
28 changes: 28 additions & 0 deletions quality-gates-ai-code/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# How to Set Up Quality Gates for AI Code in Python

This folder contains the sample project for the Real Python tutorial [How to Set Up Quality Gates for AI Code in Python](https://realpython.com/quality-gates-ai-code/).

## Contents

- `pyproject.toml` and `uv.lock`: the pinned gate tools and their settings
- `.pre-commit-config.yaml`: the local pre-commit hooks
- `.github/workflows/ci.yml`: the GitHub Actions workflow with the `gates` and `diff-check` jobs
- `.github/CODEOWNERS`: code-owner review for the gate-defining files
- `scripts/agent_diff_check.py`: the agent diff sanity check
- `src/demo/ai_module.py` and `tests/test_ai_module.py`: the fixed module and its five tests
- `examples/ai_module_flawed.py` and `examples/test_ai_module_flawed.py`: the agent's flawed module and its two tests

The `examples/` folder holds deliberately broken code, so `pyproject.toml` excludes it from Ruff and pytest.

## Setup

You need [uv](https://docs.astral.sh/uv/) 0.13.3 and Git:

```console
$ uv sync --locked
$ uv run pytest
```

To reproduce the flawed state from the tutorial, copy the files from `examples/` over `src/demo/ai_module.py` and `tests/test_ai_module.py`.

Replace `@your-username` in `.github/CODEOWNERS` with your GitHub user or team before you push.
18 changes: 18 additions & 0 deletions quality-gates-ai-code/examples/ai_module_flawed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import os
import subprocess

API_TOKEN = "sk-live-1234567890abcdef"

def run_report(name: str) -> str:
out = subprocess.run(f"echo report for {name}", shell=True, capture_output=True, text=True)
return out.stdout

def parse_age(value: str) -> int:
return value

def discount(price: float, tier: str) -> float:
if tier == "gold":
return price * 0.8
elif tier == "silver":
return price * 0.9
return price
9 changes: 9 additions & 0 deletions quality-gates-ai-code/examples/test_ai_module_flawed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from demo.ai_module import discount, run_report


def test_discount_gold():
assert discount(100, "gold") == 80


def test_run_report():
assert "report" in run_report("x")
47 changes: 47 additions & 0 deletions quality-gates-ai-code/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
[project]
name = "demo"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"requests==2.34.2",
]

[project.scripts]
demo = "demo:main"

[build-system]
requires = ["uv_build>=0.13.3,<0.14"]
build-backend = "uv_build"

[dependency-groups]
dev = [
"bandit==1.9.4",
"mypy==2.4.0",
"pip-audit==2.10.1",
"pre-commit==4.7.0",
"pytest==9.1.1",
"pytest-cov==7.1.0",
"ruff==0.17.1",
]

[tool.ruff]
extend-exclude = ["examples"]

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "S"]

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101"]
"scripts/**" = ["S603", "S607"]

[tool.mypy]
strict = true

[tool.pytest.ini_options]
addopts = "--cov=demo --cov-report=term-missing --cov-fail-under=90"
testpaths = ["tests"]

[tool.bandit]
exclude_dirs = ["tests", ".venv"]
65 changes: 65 additions & 0 deletions quality-gates-ai-code/scripts/agent_diff_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Fail when a diff looks like an agent run went off the rails.

Without arguments, check the staged diff (pre-commit mode).
With --base REF, check REF...HEAD (CI mode).
"""

import argparse
import subprocess
import sys

MAX_CHANGED_LINES = 200
IGNORED = ("uv.lock",)
PROTECTED = (
".github/",
".pre-commit-config.yaml",
"pyproject.toml",
"scripts/agent_diff_check.py",
)


def changes(base: str | None) -> list[tuple[int, int, str]]:
# Three dots diff against the merge base: only the branch's own work.
revs = [f"{base}...HEAD"] if base else ["--cached"]
out = subprocess.run(
["git", "diff", *revs, "--numstat", "--no-renames", "-z"],
capture_output=True,
text=True,
check=True,
).stdout
rows = []
for line in out.split("\0")[:-1]: # -z ends every row with NUL
added, deleted, path = line.split("\t", 2)
if path in IGNORED:
continue
if added == "-": # A binary file: keep the path, count no lines
added = deleted = "0"
rows.append((int(added), int(deleted), path))
return rows


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base", help="compare BASE...HEAD instead of the index")
args = parser.parse_args()
try:
rows = changes(args.base)
except subprocess.CalledProcessError as error:
print(f"agent-diff-check: git diff failed: {error.stderr.strip()}")
print("agent-diff-check: is the checkout shallow? Use fetch-depth: 0.")
return 2
problems = [
f"protected path touched: {path}"
for _, _, path in rows
if path.startswith(PROTECTED)
]
total = sum(a + d for a, d, _ in rows)
if total > MAX_CHANGED_LINES:
problems.append(f"diff changes {total} lines (limit {MAX_CHANGED_LINES})")
for problem in problems:
print(f"agent-diff-check: {problem}")
return 1 if problems else 0


if __name__ == "__main__":
sys.exit(main())
2 changes: 2 additions & 0 deletions quality-gates-ai-code/src/demo/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
def main() -> None:
print("Hello from demo!")
29 changes: 29 additions & 0 deletions quality-gates-ai-code/src/demo/ai_module.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import os
import subprocess # nosec B404
import sys


def run_report(name: str) -> str:
out = subprocess.run( # noqa: S603 # nosec B603
[sys.executable, "-c", "import sys; print('report for', sys.argv[1])", name],
capture_output=True,
text=True,
check=True,
)
return out.stdout


def parse_age(value: str) -> int:
return int(value)


def discount(price: float, tier: str) -> float:
if tier == "gold":
return price * 0.8
if tier == "silver":
return price * 0.9
return price


def api_token() -> str:
return os.environ["API_TOKEN"]
25 changes: 25 additions & 0 deletions quality-gates-ai-code/tests/test_ai_module.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import pytest

from demo.ai_module import api_token, discount, parse_age, run_report


def test_discount_gold() -> None:
assert discount(100, "gold") == 80


def test_discount_silver_and_none() -> None:
assert discount(100, "silver") == 90
assert discount(100, "bronze") == 100


def test_run_report() -> None:
assert run_report("x") == "report for x\n"


def test_parse_age() -> None:
assert parse_age("42") == 42


def test_api_token(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("API_TOKEN", "abc")
assert api_token() == "abc"
Loading
Loading