Skip to content

Commit afecff6

Browse files
committed
Sample code for: How to Set Up Quality Gates for AI Code in Python
1 parent 4e6d0c6 commit afecff6

14 files changed

Lines changed: 1559 additions & 0 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Changes to the quality gates need a code owner's review.
2+
# Replace @your-username with your GitHub user or team.
3+
/.github/ @your-username
4+
/.pre-commit-config.yaml @your-username
5+
/pyproject.toml @your-username
6+
/scripts/agent_diff_check.py @your-username
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: quality-gates
2+
on:
3+
pull_request:
4+
push:
5+
branches: [main]
6+
permissions:
7+
contents: read
8+
jobs:
9+
gates:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v7
13+
- uses: astral-sh/setup-uv@v10
14+
with:
15+
version: "0.13.3"
16+
enable-cache: true
17+
- run: uv sync --locked
18+
- run: uv run pre-commit run check-yaml --all-files
19+
- run: uv run pre-commit run detect-private-key --all-files
20+
- run: uv run ruff check .
21+
- run: uv run ruff format --check .
22+
- run: uv run mypy src tests
23+
- run: uv run bandit -q -c pyproject.toml -r src
24+
- run: uv export --no-dev --no-hashes --no-emit-project -o requirements.txt
25+
- run: uv run pip-audit -r requirements.txt --no-deps --disable-pip
26+
- run: uv run pytest
27+
diff-check:
28+
if: github.event_name == 'pull_request'
29+
runs-on: ubuntu-latest
30+
steps:
31+
- uses: actions/checkout@v7
32+
with:
33+
fetch-depth: 0
34+
- env:
35+
BASE: ${{ github.event.pull_request.base.sha }}
36+
run: |
37+
# Run the base branch's copy, so the PR can't weaken it.
38+
git show "$BASE:scripts/agent_diff_check.py" > "$RUNNER_TEMP/check.py"
39+
python3 "$RUNNER_TEMP/check.py" --base "$BASE"

‎quality-gates-ai-code/.gitignore‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
.venv/
2+
__pycache__/
3+
.coverage
4+
*.pyc
5+
.mypy_cache/
6+
.ruff_cache/
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
repos:
2+
- repo: https://github.com/pre-commit/pre-commit-hooks
3+
rev: v6.0.0
4+
hooks:
5+
- id: check-yaml
6+
- id: detect-private-key
7+
- repo: https://github.com/astral-sh/ruff-pre-commit
8+
rev: v0.17.1
9+
hooks:
10+
- id: ruff-check
11+
- id: ruff-format
12+
- repo: local
13+
hooks:
14+
- id: mypy
15+
name: mypy
16+
entry: uv run mypy src tests
17+
language: unsupported
18+
pass_filenames: false
19+
types: [python]
20+
- id: bandit
21+
name: bandit
22+
entry: uv run bandit -q -c pyproject.toml -r src
23+
language: unsupported
24+
pass_filenames: false
25+
types: [python]
26+
- id: agent-diff-check
27+
name: agent diff sanity
28+
entry: uv run python scripts/agent_diff_check.py
29+
language: unsupported
30+
pass_filenames: false
31+
always_run: true
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.13

‎quality-gates-ai-code/README.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# How to Set Up Quality Gates for AI Code in Python
2+
3+
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/).
4+
5+
## Contents
6+
7+
- `pyproject.toml` and `uv.lock`: the pinned gate tools and their settings
8+
- `.pre-commit-config.yaml`: the local pre-commit hooks
9+
- `.github/workflows/ci.yml`: the GitHub Actions workflow with the `gates` and `diff-check` jobs
10+
- `.github/CODEOWNERS`: code-owner review for the gate-defining files
11+
- `scripts/agent_diff_check.py`: the agent diff sanity check
12+
- `src/demo/ai_module.py` and `tests/test_ai_module.py`: the fixed module and its five tests
13+
- `examples/ai_module_flawed.py` and `examples/test_ai_module_flawed.py`: the agent's flawed module and its two tests
14+
15+
The `examples/` folder holds deliberately broken code, so `pyproject.toml` excludes it from Ruff and pytest.
16+
17+
## Setup
18+
19+
You need [uv](https://docs.astral.sh/uv/) 0.13.3 and Git:
20+
21+
```console
22+
$ uv sync --locked
23+
$ uv run pytest
24+
```
25+
26+
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`.
27+
28+
Replace `@your-username` in `.github/CODEOWNERS` with your GitHub user or team before you push.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import os
2+
import subprocess
3+
4+
API_TOKEN = "sk-live-1234567890abcdef"
5+
6+
def run_report(name: str) -> str:
7+
out = subprocess.run(f"echo report for {name}", shell=True, capture_output=True, text=True)
8+
return out.stdout
9+
10+
def parse_age(value: str) -> int:
11+
return value
12+
13+
def discount(price: float, tier: str) -> float:
14+
if tier == "gold":
15+
return price * 0.8
16+
elif tier == "silver":
17+
return price * 0.9
18+
return price
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from demo.ai_module import discount, run_report
2+
3+
4+
def test_discount_gold():
5+
assert discount(100, "gold") == 80
6+
7+
8+
def test_run_report():
9+
assert "report" in run_report("x")
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
[project]
2+
name = "demo"
3+
version = "0.1.0"
4+
description = "Add your description here"
5+
readme = "README.md"
6+
requires-python = ">=3.11"
7+
dependencies = [
8+
"requests==2.34.2",
9+
]
10+
11+
[project.scripts]
12+
demo = "demo:main"
13+
14+
[build-system]
15+
requires = ["uv_build>=0.13.3,<0.14"]
16+
build-backend = "uv_build"
17+
18+
[dependency-groups]
19+
dev = [
20+
"bandit==1.9.4",
21+
"mypy==2.4.0",
22+
"pip-audit==2.10.1",
23+
"pre-commit==4.7.0",
24+
"pytest==9.1.1",
25+
"pytest-cov==7.1.0",
26+
"ruff==0.17.1",
27+
]
28+
29+
[tool.ruff]
30+
extend-exclude = ["examples"]
31+
32+
[tool.ruff.lint]
33+
select = ["E", "F", "I", "UP", "B", "S"]
34+
35+
[tool.ruff.lint.per-file-ignores]
36+
"tests/**" = ["S101"]
37+
"scripts/**" = ["S603", "S607"]
38+
39+
[tool.mypy]
40+
strict = true
41+
42+
[tool.pytest.ini_options]
43+
addopts = "--cov=demo --cov-report=term-missing --cov-fail-under=90"
44+
testpaths = ["tests"]
45+
46+
[tool.bandit]
47+
exclude_dirs = ["tests", ".venv"]
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Fail when a diff looks like an agent run went off the rails.
2+
3+
Without arguments, check the staged diff (pre-commit mode).
4+
With --base REF, check REF...HEAD (CI mode).
5+
"""
6+
7+
import argparse
8+
import subprocess
9+
import sys
10+
11+
MAX_CHANGED_LINES = 200
12+
IGNORED = ("uv.lock",)
13+
PROTECTED = (
14+
".github/",
15+
".pre-commit-config.yaml",
16+
"pyproject.toml",
17+
"scripts/agent_diff_check.py",
18+
)
19+
20+
21+
def changes(base: str | None) -> list[tuple[int, int, str]]:
22+
# Three dots diff against the merge base: only the branch's own work.
23+
revs = [f"{base}...HEAD"] if base else ["--cached"]
24+
out = subprocess.run(
25+
["git", "diff", *revs, "--numstat", "--no-renames", "-z"],
26+
capture_output=True,
27+
text=True,
28+
check=True,
29+
).stdout
30+
rows = []
31+
for line in out.split("\0")[:-1]: # -z ends every row with NUL
32+
added, deleted, path = line.split("\t", 2)
33+
if path in IGNORED:
34+
continue
35+
if added == "-": # A binary file: keep the path, count no lines
36+
added = deleted = "0"
37+
rows.append((int(added), int(deleted), path))
38+
return rows
39+
40+
41+
def main() -> int:
42+
parser = argparse.ArgumentParser(description=__doc__)
43+
parser.add_argument("--base", help="compare BASE...HEAD instead of the index")
44+
args = parser.parse_args()
45+
try:
46+
rows = changes(args.base)
47+
except subprocess.CalledProcessError as error:
48+
print(f"agent-diff-check: git diff failed: {error.stderr.strip()}")
49+
print("agent-diff-check: is the checkout shallow? Use fetch-depth: 0.")
50+
return 2
51+
problems = [
52+
f"protected path touched: {path}"
53+
for _, _, path in rows
54+
if path.startswith(PROTECTED)
55+
]
56+
total = sum(a + d for a, d, _ in rows)
57+
if total > MAX_CHANGED_LINES:
58+
problems.append(f"diff changes {total} lines (limit {MAX_CHANGED_LINES})")
59+
for problem in problems:
60+
print(f"agent-diff-check: {problem}")
61+
return 1 if problems else 0
62+
63+
64+
if __name__ == "__main__":
65+
sys.exit(main())

0 commit comments

Comments
 (0)