Module 3: AI-Specific CI Checks
4. Linting and Type Checking in CI
Overview
Linting and type checking are standard checks in any CI pipeline. But in AI projects they have their own quirks: how do you configure mypy so it doesn't complain about openai, langchain, or tiktoken, which have no type stubs? Which ruff rules are relevant for projects with HTTP calls and JSON? Why does something pass locally but fail in CI?
This capsule is not "install ruff and mypy." This is: configure both tools for a real AI project with dependencies that don't always cooperate with the type system, integrate them as CI steps that block the merge, and handle the differences between your machine and the runner.
Ruff in CI
Why ruff?
Ruff replaces flake8, isort, pycodestyle, and other tools with a single command that is 10-100x faster. In CI, speed matters: a linter that takes 30 seconds on 1,000 files isn't viable. Ruff does it in under 1 second.
Installation and basic usage
pip install ruff
ruff check src/ tests/ scripts/ # Check for errors
ruff check src/ tests/ scripts/ --fix # Fix automatically
ruff format src/ tests/ scripts/ # Format the code
Output with errors:
src/ai_app/chain.py:3:1: F401 [*] `os` imported but unused
src/ai_app/chain.py:15:5: E722 Do not use bare `except`
src/ai_app/utils.py:8:1: I001 [*] Import block is un-sorted or un-formatted
Found 3 errors.
[*] 2 fixable with the `--fix` option.
Configuration in pyproject.toml
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"N", # pep8-naming
"UP", # pyupgrade
"B", # flake8-bugbear
"SIM", # flake8-simplify
"T20", # flake8-print (detects print() in production)
"RET", # flake8-return
"PTH", # flake8-use-pathlib
]
ignore = [
"E501", # line too long (the formatter handles it)
"T201", # print found (allowed in CLI scripts)
]
[tool.ruff.lint.per-file-ignores]
"scripts/*" = ["T201"]
"tests/*" = ["S101"]
[tool.ruff.lint.isort]
known-first-party = ["src"]
Rules relevant to AI projects
| Rule | Code | Why it matters in AI |
|---|---|---|
| Unused imports | F401 | LLM projects import many libs; the unused ones create clutter |
| Bare except | E722 | A catch-all hides API errors (rate limits, timeouts) |
| Print statements | T201 | print() in production isn't proper logging |
| Mutable default args | B006 | def func(data=[]): causes subtle bugs in chains |
Use pathlib | PTH | Path manipulation with strings is fragile |
Ruff in CI
- name: Lint with ruff
run: ruff check src/ tests/ scripts/
- name: Check formatting
run: ruff format --check src/ tests/ scripts/
--check verifies the formatting without modifying files. If there are differences, it fails — forcing the developer to run ruff format locally.
mypy in CI
Why mypy in AI projects?
Python is dynamically typed. In AI projects, types are especially important because:
- The APIs return complex objects:
ChatCompletion,Embedding,Message— without types, you don't know which attributes exist - The pipelines have multiple steps: A type error in step 3 isn't detected until runtime
- The data flows as dicts and JSON: Without types, it's easy to access a key that doesn't exist
Example: mypy catches a real bug
def get_response(client: OpenAI, prompt: str) -> str:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
)
return response.choices[0].message.content
src/ai_app/chain.py:8: error: Incompatible return value type
(got "str | None", expected "str") [return-value]
mypy detected that .content can be None, but the function declares that it returns str:
def get_response(client: OpenAI, prompt: str) -> str:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
)
content = response.choices[0].message.content
if content is None:
return ""
return content
The problem: libraries without type stubs
Many AI libraries don't have complete type stubs. Without configuration, mypy fails on every import of openai, langchain, tiktoken, etc.
mypy configuration for AI projects
[tool.mypy]
python_version = "3.12"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
check_untyped_defs = true
[[tool.mypy.overrides]]
module = [
"openai.*",
"tiktoken.*",
"langchain.*",
"langchain_openai.*",
"langchain_community.*",
"chromadb.*",
"pinecone.*",
"sentence_transformers.*",
"transformers.*",
"anthropic.*",
]
ignore_missing_imports = true
| Option | Effect |
|---|---|
warn_return_any | Warns if you return Any — prevents types from getting lost |
disallow_untyped_defs | Requires type hints on functions |
check_untyped_defs | Checks functions without annotations |
ignore_missing_imports | Doesn't fail on imports without stubs (necessary for AI libs) |
If you add a new AI library, add it to the overrides list.
- name: Type check with mypy
run: mypy src/ scripts/
The gap: Local vs CI
You run ruff check and mypy locally — all green. You push, and CI fails. The most common causes:
Cause 1: Different tool versions
Solution: Pin exact versions in requirements-dev.txt:
ruff==0.9.2
mypy==1.14.1
Cause 2: Uncommitted configuration
Solution: Always commit pyproject.toml:
git add pyproject.toml
git commit -m "Add mypy and ruff config"
Cause 3: Missing type dependencies in CI
Solution: Add type stubs to requirements-dev.txt:
mypy==1.14.1
types-requests==2.31.0
types-pyyaml==6.0.12
The definitive test: simulate CI locally
python -m venv .venv-ci-test
source .venv-ci-test/bin/activate
pip install -r requirements-dev.txt
ruff check src/ tests/ scripts/
mypy src/ scripts/
deactivate
rm -rf .venv-ci-test
If it passes here, it passes in CI.
Quality gates: Blocking the merge
A quality gate is a check that must pass for a PR to be mergeable. Configure it on GitHub:
Settings → Branches → Branch protection rules → Add rule
Branch name pattern: main
✅ Require a pull request before merging
✅ Require status checks to pass before merging
→ Search and add: "AI Quality Gate"
✅ Require branches to be up to date before merging
Without branch protection, the checks are informational — you see a ❌ but you can still merge. With branch protection, the merge is blocked until every check passes.
The complete workflow
# .github/workflows/ai-quality-gate.yml
name: AI Quality Gate
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
quality-gate:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
# Steps ordered cheapest-first: if ruff fails in <1s, you skip mypy, tests, and API calls entirely
- name: Lint with ruff
run: ruff check src/ tests/ scripts/
- name: Check formatting
run: ruff format --check src/ tests/ scripts/
- name: Type check with mypy
run: mypy src/ scripts/
- name: Run unit tests
run: pytest tests/ -v --tb=short --junitxml=test-report.xml
env:
PYTHONPATH: ${{ github.workspace }}
- name: Cost estimation
# Runs before prompt regression — catches cost spikes without spending API credits
run: python scripts/estimate_costs.py
env:
COST_INCREASE_THRESHOLD_PCT: "20"
- name: Download prompt baseline
uses: actions/download-artifact@v4
with:
name: prompt-eval-baseline
path: .
continue-on-error: true
- name: Prompt regression testing
run: python scripts/evaluate_prompts.py
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
PYTHONPATH: ${{ github.workspace }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
# if: always() ensures reports are saved even when checks fail — critical for post-mortem
if: always()
with:
name: ci-reports
path: |
test-report.xml
cost_report.json
prompt_eval_report.json
- name: Update baseline on main
if: github.ref == 'refs/heads/main' && success()
uses: actions/upload-artifact@v4
with:
name: prompt-eval-baseline
path: prompt_eval_report.json
The order of the steps (deliberate)
1. ruff check → Free, < 1s → Style errors
2. ruff format → Free, < 1s → Incorrect formatting
3. mypy → Free, < 5s → Type errors
4. pytest → Free, < 30s → Logic errors
5. Cost estimation → Free, < 2s → Cost increases
6. Prompt regression → ~$0.10, < 60s → Quality degradation
The free and fast checks go first. If ruff fails in 0.5 seconds, you don't spend $0.10 on prompt regression.
Troubleshooting
"mypy fails with 'Library stubs not installed for X'"
Cause: A library has no type stubs and isn't in the overrides list.
Solution: Add the library to [[tool.mypy.overrides]] in pyproject.toml.
"ruff and mypy aren't on the PATH in CI"
Cause: They're not in requirements-dev.txt.
Solution:
# requirements-dev.txt
-r requirements.txt
pytest==8.3.4
ruff==0.9.2
mypy==1.14.1
"ruff format fails but the code looks fine locally"
Cause: Your editor formats with black, but CI uses ruff format.
Solution: Configure your editor to use ruff format:
{
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.formatOnSave": true
}
}
Exercises
Exercise 1: Configure a complete pyproject.toml
Create a pyproject.toml with ruff and mypy configuration for an AI project that uses openai, langchain, tiktoken, and pytest.
See solution
[project]
name = "my-ai-app"
version = "0.1.0"
requires-python = ">=3.11"
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
addopts = "-v --tb=short"
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
select = [
"E", "W", "F", "I", "N", "UP", "B", "SIM", "T20", "RET", "PTH", "PGH",
]
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
"scripts/*" = ["T201"]
"tests/*" = ["S101"]
[tool.ruff.lint.isort]
known-first-party = ["src"]
[tool.mypy]
python_version = "3.12"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
check_untyped_defs = true
[[tool.mypy.overrides]]
module = [
"openai.*",
"tiktoken.*",
"langchain.*",
"langchain_openai.*",
"langchain_community.*",
"langchain_core.*",
"chromadb.*",
"anthropic.*",
]
ignore_missing_imports = true
Exercise 2: Diagnose mypy errors in AI code
This code has 3 errors that mypy detects. Find them and fix them:
from openai import OpenAI
def process_query(query, client):
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": query}],
)
content = response.choices[0].message.content
tokens = response.usage.total_tokens
return {
"content": content.strip(),
"tokens": tokens,
"cost": tokens * 0.00000015,
}
See solution
Error 1: process_query has no type annotations → disallow_untyped_defs fails.
Error 2: content can be None → .strip() fails if content is None.
Error 3: response.usage can be None → .total_tokens fails if usage is None.
from openai import OpenAI
def process_query(query: str, client: OpenAI) -> dict[str, str | int | float]:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": query}],
)
content = response.choices[0].message.content
if content is None:
content = ""
tokens = 0
if response.usage is not None:
tokens = response.usage.total_tokens
return {
"content": content.strip(),
"tokens": tokens,
"cost": tokens * 0.00000015,
}
These are real errors that happen in production. Without mypy, the code works 99% of the time — but when the API returns None, it crashes.
Exercise 3: A workflow with lint and tests in parallel jobs
Create a workflow with two parallel jobs: one for lint+types and one for tests. Both must pass for the workflow to succeed.
See solution
name: CI Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint-and-types:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- run: pip install ruff mypy
- name: Lint
run: ruff check src/ tests/ scripts/
- name: Format check
run: ruff format --check src/ tests/ scripts/
- name: Type check
run: mypy src/ scripts/
tests:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- run: pip install -r requirements-dev.txt
- name: Run tests
run: pytest tests/ -v --tb=short
env:
PYTHONPATH: ${{ github.workspace }}
Both jobs start at the same time. If lint fails in 3 seconds, you don't wait 2 minutes for the tests. The total time is that of the slowest job, not the sum.
Exercise 4: Caching mypy in CI
mypy's first run is slow. Add caching so subsequent runs are faster.
See solution
- name: Cache mypy results
uses: actions/cache@v4
with:
path: .mypy_cache
key: mypy-${{ runner.os }}-py3.12-${{ hashFiles('pyproject.toml', 'requirements*.txt') }}
restore-keys: |
mypy-${{ runner.os }}-py3.12-
- name: Type check with mypy
run: mypy src/ scripts/
The cache key: different per OS, Python version, and hash of the config/dependencies. restore-keys gives you a partial cache if the exact one doesn't exist.
| Run | Without cache | With cache |
|---|---|---|
| First | 15s | 15s (cache miss) |
| Second | 15s | 3s (cache hit) |
| After a config change | 15s | 15s (invalidated) |
Summary
- ✅ ruff replaces multiple linters with a single ultra-fast command — ideal for CI
- ✅ mypy catches type errors that would only show up at runtime — critical for AI projects
- ✅ The mypy overrides configuration is mandatory: libraries without type stubs need
ignore_missing_imports - ✅ pyproject.toml centralizes the ruff, mypy, and pytest configuration
- ✅ The local/CI gap is caused by different versions, uncommitted config, or missing dependencies
- ✅ Quality gates with branch protection turn informational checks into mandatory ones
- ✅ Order matters: free and fast checks go before checks that cost money
- ✅ Caching mypy speeds up subsequent runs from 15s to 3s
Additional resources
- ruff Documentation — Complete documentation with all the rules
- mypy Documentation — Configuration guide and advanced usage
- mypy — Missing Imports — How to handle libraries without stubs
- GitHub Branch Protection Rules — Configuring quality gates
- ruff Rules Reference — Complete list of rules with examples