chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:22:34 +08:00
commit 4b22cfda96
9037 changed files with 2363717 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
FROM python:3.10-slim
WORKDIR /mlflow
RUN pip install protobuf packaging
# Copy only necessary files to compile protos
COPY mlflow/protos ./mlflow/protos/
COPY dev ./dev/
COPY tests/protos ./tests/protos/
+8
View File
@@ -0,0 +1,8 @@
**/*_pb2.py
**/*.pyc
**/__pycache__/
*.class
*.jar
**/build
**/dist
**/*.egg-info
+17
View File
@@ -0,0 +1,17 @@
## MLflow Dev Scripts
This directory contains automation scripts for MLflow developers and the build infrastructure.
## Job Statuses
[![Examples Action Status](https://img.shields.io/github/actions/workflow/status/mlflow/dev/examples.yml.svg?branch=master&event=schedule&label=Examples&style=for-the-badge&logo=github)](https://github.com/mlflow/dev/actions/workflows/examples.yml?query=workflow%3AExamples+event%3Aschedule)
[![Cross Version Tests Status](https://img.shields.io/github/actions/workflow/status/mlflow/dev/cross-version-tests.yml.svg?branch=master&event=schedule&label=Cross%20version%20tests&style=for-the-badge&logo=github)](https://github.com/mlflow/dev/actions/workflows/cross-version-tests.yml?query=workflow%3A%22Cross+version+tests%22+event%3Aschedule)
[![Cross Version Test Visualization](https://img.shields.io/github/actions/workflow/status/mlflow/dev/xtest-viz.yml.svg?branch=master&event=schedule&label=Test%20Results%20Viz&style=for-the-badge&logo=github)](https://github.com/mlflow/dev/actions/workflows/xtest-viz.yml)
[![R-devel Action Status](https://img.shields.io/github/actions/workflow/status/mlflow/dev/r.yml.svg?branch=master&event=schedule&label=r-devel&style=for-the-badge&logo=github)](https://github.com/mlflow/dev/actions/workflows/r.yml?query=workflow%3AR+event%3Aschedule)
[![Test Requirements Status](https://img.shields.io/github/actions/workflow/status/mlflow/dev/test-requirements.yml.svg?branch=master&event=schedule&label=test%20requirements&logo=github&style=for-the-badge)](https://github.com/mlflow/dev/actions/workflows/test-requirements.yml?query=workflow%3A%22Test+requirements%22+event%3Aschedule)
[![Push Images Status](https://img.shields.io/github/actions/workflow/status/mlflow/mlflow/push-images.yml.svg?event=release&label=push-images&logo=github&style=for-the-badge)](https://github.com/mlflow/mlflow/actions/workflows/push-images.yml?query=event%3Arelease)
[![Slow Tests Status](https://img.shields.io/github/actions/workflow/status/mlflow/dev/slow-tests.yml.svg?branch=master&event=schedule&label=slow-tests&logo=github&style=for-the-badge)](https://github.com/mlflow/dev/actions/workflows/slow-tests.yml?query=event%3Aschedule)
[![Website E2E Tests Status](https://img.shields.io/github/actions/workflow/status/mlflow/mlflow-website/e2e.yml.svg?branch=main&event=schedule&label=website-e2e&logo=github&style=for-the-badge)](https://github.com/mlflow/mlflow-website/actions/workflows/e2e.yml?query=event%3Aschedule)
[![Update Model Catalog Status](https://img.shields.io/github/actions/workflow/status/mlflow/mlflow/update-model-catalog.yml.svg?branch=master&event=schedule&label=update-model-catalog&logo=github&style=for-the-badge)](https://github.com/mlflow/mlflow/actions/workflows/update-model-catalog.yml?query=event%3Aschedule)
[![Gateway Benchmark Status](https://img.shields.io/github/actions/workflow/status/mlflow/mlflow/gateway-benchmark.yml.svg?branch=master&event=schedule&label=gateway-benchmark&logo=github&style=for-the-badge)](https://github.com/mlflow/mlflow/actions/workflows/gateway-benchmark.yml?query=event%3Aschedule)
[![Tracing Benchmark Status](https://img.shields.io/github/actions/workflow/status/mlflow/mlflow/tracing-benchmark.yml.svg?branch=master&label=tracing-benchmark&logo=github&style=for-the-badge)](https://github.com/mlflow/mlflow/actions/workflows/tracing-benchmark.yml)
+145
View File
@@ -0,0 +1,145 @@
# MLflow AI Gateway Benchmark
Measures the **proxy overhead** of the MLflow tracking-server-backed AI Gateway under
concurrent load. A fake OpenAI server simulates the upstream provider at a fixed latency,
so results reflect pure MLflow processing time rather than provider variance.
## Prerequisites
- Python 3.10+ with [`uv`](https://docs.astral.sh/uv/) — all scripts must be run via `uv run`, which handles dependency installation automatically via inline script metadata
- Docker (required for `--database postgres` and `multi` mode)
## Quick start
```bash
cd dev/benchmarks/gateway
# 4 instances behind nginx (default, requires Docker)
uv run run.py
# Single instance, SQLite (no Docker needed)
uv run run.py --instances 1
# Single instance, PostgreSQL
uv run run.py --instances 1 --database postgres
# Scale up
uv run run.py --instances 8 --workers 8
# Benchmark an existing endpoint directly (skips all setup)
uv run run.py --url http://your-server/gateway/my-endpoint/mlflow/invocations
# Basic-auth enabled (starts MLflow with --app-name=basic-auth,
# sends Authorization: Basic on every request)
uv run run.py --instances 1 --auth
```
## What is measured
Latency is measured **client-side** using `time.perf_counter()` around each `aiohttp` request.
Each sample covers the full round-trip: client serialization → loopback → full server processing → response deserialization. Only HTTP 200 responses count toward latency stats; errors are tracked separately.
Connection pooling and HTTP keep-alive are enabled, so TCP handshake cost is amortized after the warmup phase.
### What is NOT measured
| Factor | In this benchmark | In production |
| ------------------ | ---------------------------------------------- | --------------------------- |
| Network latency | ~0 ms (loopback) | 1100 ms per hop |
| TLS/SSL | None (plain HTTP) | ~520 ms per new connection |
| Provider inference | Fixed fake delay (`--fake-delay-ms`) | Variable (50 ms 60 s+) |
| Authentication | Off by default; basic-auth opt-in via `--auth` | Token validation, RBAC |
## What MLflow does per request
Each invocation through the tracking-server gateway runs these steps:
```
1. Config resolution (DB-backed, cached after first hit)
2. Secret decryption (cached, 60 s TTL)
3. Provider instantiation
4. Tracing (if usage_tracking=True)
5. HTTP call to LLM API
```
Steps 1 (config resolution) and 4 (tracing) have historically been the dominant bottlenecks.
Config caching (enabled by default) eliminates most of step 1's cost. Tracing overhead
depends on the span processor in use.
## Architecture
### Single instance (`--instances 1`)
```
benchmark.py ──aiohttp──▶ MLflow server (:5731) ──▶ fake_server.py (:9137)
SQLite or PostgreSQL
```
### Multi-instance (`--instances N`, default)
```
benchmark.py ──aiohttp──▶ nginx LB (:5731) ──round-robin──▶ MLflow :5800
MLflow :5801
MLflow :580N
fake_server.py (:9137)
PostgreSQL (Docker)
```
MLflow instances are started **sequentially** (instance 0 first) to let it initialize the
DB schema before the others join. All instances share one PostgreSQL database.
## Options
| Flag | Default | Description |
| ----------------------------- | -------------- | ----------------------------------------------------------------------- |
| `--url URL` | — | Benchmark this URL directly, skip all setup |
| `--instances N` | 4 | MLflow instances. Use 1 for single-instance (no nginx, optional SQLite) |
| `--workers N` | 4 | MLflow worker processes per instance |
| `--database sqlite\|postgres` | `sqlite` | Database to use — only applies when `--instances 1` |
| `--no-usage-tracking` | — | Disable usage tracking (tracing) on the endpoint |
| `--port N` | 5731 | Port to benchmark (MLflow port for single, nginx LB port for multi) |
| `--base-port N` | 5800 | First MLflow instance port in multi mode (rest are +1, +2, …) |
| `--fake-server-port N` | 9137 | Fake OpenAI server port |
| `--requests N` | 2000 | Requests per run |
| `--max-concurrent N` | 50 | Max concurrent requests |
| `--runs N` | 3 | Number of benchmark runs |
| `--fake-delay-ms N` | 50 | Simulated provider latency in ms |
| `--min-rps N` | — | Fail (exit 1) if average throughput falls below N req/s |
| `--max-p50-ms N` | — | Fail (exit 1) if average P50 latency exceeds N ms (CI threshold) |
| `--max-p99-ms N` | — | Fail (exit 1) if average P99 latency exceeds N ms (CI threshold) |
| `--auth` | off | Start MLflow with `--app-name=basic-auth`; send Basic auth on requests |
| `--auth-username USER` | `admin` | Basic-auth username (matches `mlflow/server/auth/basic_auth.ini`) |
| `--auth-password PASS` | `password1234` | Basic-auth password (matches `mlflow/server/auth/basic_auth.ini`) |
All flags can also be set via environment variables (same name, uppercased):
`INSTANCES`, `WORKERS_PER_INSTANCE`, `REQUESTS`, `MAX_CONCURRENT`, `RUNS`,
`FAKE_RESPONSE_DELAY_MS`, `MLFLOW_PORT`, `BASE_PORT`, `FAKE_SERVER_PORT`,
`AUTH`, `AUTH_USERNAME`, `AUTH_PASSWORD`.
To avoid conflicts with a local PostgreSQL instance, override the port via `GATEWAY_BENCH_POSTGRES_PORT` (default: 5432).
## Known limitations
- **Loopback only** — all processes run on the same machine. Results don't include real
network latency between client, gateway, and provider.
- **No TLS** — MLflow is started with `--disable-security-middleware`. Production deployments
add TLS termination overhead.
- **Fixed provider latency** — `fake_server.py` always responds in exactly `--fake-delay-ms`.
Real providers have high variance (P99 often 510× P50).
- **Basic-auth is opt-in, no RBAC** — `--auth` enables `basic-auth` with the default
admin user (full permissions), which measures the cost of HTTP Basic authentication
and user lookup but not fine-grained RBAC checks against non-admin users.
- **Single machine resource contention** — with multiple instances, all MLflow instances, nginx,
PostgreSQL, and the benchmark client share CPU/memory. On a server with dedicated resources
per instance, throughput will be higher.
## Files
| File | Purpose |
| ---------------- | ------------------------------------------------------------------------------ |
| `run.py` | Main entry point — orchestrates servers, Docker, endpoint setup, and benchmark |
| `benchmark.py` | Async HTTP benchmark client (standalone or imported by `run.py`) |
| `fake_server.py` | Fake OpenAI-compatible server for controlled latency simulation |
+362
View File
@@ -0,0 +1,362 @@
# /// script
# requires-python = ">=3.10"
# dependencies = ["aiohttp>=3.13.3,<4", "rich>=14.3.3,<15"]
# ///
"""Async HTTP benchmark client for the MLflow AI Gateway.
Can be imported by run.py or used standalone:
uv run benchmark.py --url http://127.0.0.1:5731/gateway/benchmark-chat/mlflow/invocations
uv run benchmark.py --url http://... --requests 5000 --max-concurrent 100 --runs 3
"""
import argparse
import asyncio
import math
import statistics
import time
from dataclasses import dataclass, field
from typing import Any
import aiohttp
from rich.console import Console # type: ignore[import-not-found]
from rich.progress import ( # type: ignore[import-not-found]
BarColumn,
MofNCompleteColumn,
Progress,
SpinnerColumn,
TaskID,
TextColumn,
TimeElapsedColumn,
)
from rich.table import Table # type: ignore[import-not-found]
console = Console()
_BODY = {
"messages": [{"role": "user", "content": "benchmark request"}],
"temperature": 0.0,
"max_tokens": 50,
}
@dataclass
class RunResult:
latencies_ms: list[float] = field(default_factory=list)
failures: dict[str, int] = field(default_factory=dict)
wall_time: float = 0.0
@property
def n_success(self) -> int:
return len(self.latencies_ms)
@property
def n_failures(self) -> int:
return sum(self.failures.values())
@property
def throughput(self) -> float:
return self.n_success / self.wall_time if self.wall_time > 0 else 0.0
def percentile(self, p: float) -> float:
if not self.latencies_ms:
return 0.0
s = sorted(self.latencies_ms)
idx = max(0, math.ceil(p / 100 * len(s)) - 1)
return s[idx]
async def _send(
session: aiohttp.ClientSession,
url: str,
sem: asyncio.Semaphore,
auth: aiohttp.BasicAuth | None = None,
) -> tuple[float, str | None]:
async with sem:
t0 = time.perf_counter()
try:
async with session.post(url, json=_BODY, auth=auth) as resp:
await resp.read()
ms = (time.perf_counter() - t0) * 1000
if resp.status == 200:
return ms, None
return ms, f"HTTP {resp.status}"
except Exception as e:
return (time.perf_counter() - t0) * 1000, type(e).__name__
async def _run_once(
url: str,
n: int,
max_concurrent: int,
progress: Progress,
task_id: TaskID,
auth: aiohttp.BasicAuth | None = None,
) -> RunResult:
sem = asyncio.Semaphore(max_concurrent)
connector = aiohttp.TCPConnector(
limit=max(max_concurrent * 2, 200),
limit_per_host=max(max_concurrent, 200),
force_close=False,
enable_cleanup_closed=True,
)
result = RunResult()
total_time = 0.0
max_time = 0.0
async with aiohttp.ClientSession(connector=connector) as session:
t0 = time.perf_counter()
for coro in asyncio.as_completed([_send(session, url, sem, auth) for _ in range(n)]):
ms, error = await coro
if error:
result.failures[error] = result.failures.get(error, 0) + 1
else:
result.latencies_ms.append(ms)
total_time += ms
if ms > max_time:
max_time = ms
n_ok = result.n_success
n_fail = result.n_failures
mean = total_time / n_ok if n_ok else 0.0
fail_part = f"[red]✗{n_fail}[/red] " if n_fail else ""
live = f"{fail_part}{n_ok} mean={mean:.0f}ms max={max_time:.0f}ms"
progress.update(task_id, advance=1, live=live)
result.wall_time = time.perf_counter() - t0
return result
async def _warmup(
url: str, n: int, max_concurrent: int, auth: aiohttp.BasicAuth | None = None
) -> None:
sem = asyncio.Semaphore(max_concurrent)
connector = aiohttp.TCPConnector(limit=max(max_concurrent * 2, 200))
async with aiohttp.ClientSession(connector=connector) as session:
await asyncio.gather(*[_send(session, url, sem, auth) for _ in range(n)])
def run_benchmark(
url: str,
n_requests: int = 2000,
max_concurrent: int = 50,
runs: int = 3,
auth: aiohttp.BasicAuth | None = None,
) -> list[RunResult]:
warmup_n = min(max(50, max_concurrent), n_requests)
console.print(f" [dim]Warming up ({warmup_n} requests)...[/dim]")
asyncio.run(_warmup(url, warmup_n, max_concurrent, auth))
results = []
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
MofNCompleteColumn(),
TimeElapsedColumn(),
TextColumn(" {task.fields[live]}"),
console=console,
) as progress:
for i in range(runs):
task_id = progress.add_task(f" Run {i + 1}/{runs}", total=n_requests, live="")
results.append(
asyncio.run(_run_once(url, n_requests, max_concurrent, progress, task_id, auth))
)
return results
def results_to_dict(results: list[RunResult]) -> dict[str, Any]:
runs = [
{
"n_success": r.n_success,
"n_failures": r.n_failures,
"failures": r.failures,
"wall_time_s": r.wall_time,
"mean_ms": statistics.mean(r.latencies_ms) if r.latencies_ms else 0.0,
"p50_ms": r.percentile(50),
"p95_ms": r.percentile(95),
"p99_ms": r.percentile(99),
"max_ms": max(r.latencies_ms) if r.latencies_ms else 0.0,
"rps": r.throughput,
}
for r in results
]
summary: dict[str, Any] = (
{
"avg_mean_ms": statistics.mean(
statistics.mean(r.latencies_ms) if r.latencies_ms else 0.0 for r in results
),
"avg_p50_ms": statistics.mean(r.percentile(50) for r in results),
"avg_p99_ms": statistics.mean(r.percentile(99) for r in results),
"avg_rps": statistics.mean(r.throughput for r in results),
}
if results
else {}
)
return {"runs": runs, "summary": summary}
def print_results(results: list[RunResult]) -> None:
table = Table(show_header=True, header_style="bold cyan", box=None, padding=(0, 2))
table.add_column("Run", style="dim", width=5)
table.add_column("Mean ms", justify="right")
table.add_column("P50 ms", justify="right")
table.add_column("P95 ms", justify="right")
table.add_column("P99 ms", justify="right")
table.add_column("Max ms", justify="right")
table.add_column("Req/s", justify="right")
table.add_column("Failures", justify="right")
means = []
p50s = []
p95s = []
p99s = []
maxes = []
throughputs = []
for i, r in enumerate(results):
mean = statistics.mean(r.latencies_ms) if r.latencies_ms else 0.0
p50 = r.percentile(50)
p95 = r.percentile(95)
p99 = r.percentile(99)
mx = max(r.latencies_ms) if r.latencies_ms else 0.0
means.append(mean)
p50s.append(p50)
p95s.append(p95)
p99s.append(p99)
maxes.append(mx)
throughputs.append(r.throughput)
fail_str = f"[red]{r.n_failures}[/red]" if r.n_failures else "0"
table.add_row(
str(i + 1),
f"{mean:.1f}",
f"{p50:.1f}",
f"{p95:.1f}",
f"{p99:.1f}",
f"{mx:.1f}",
f"{r.throughput:.0f}",
fail_str,
)
if len(results) > 1:
table.add_section()
table.add_row(
"[bold]avg[/bold]",
f"[bold]{statistics.mean(means):.1f}[/bold]",
f"[bold]{statistics.mean(p50s):.1f}[/bold]",
f"[bold]{statistics.mean(p95s):.1f}[/bold]",
f"[bold]{statistics.mean(p99s):.1f}[/bold]",
f"[bold]{statistics.mean(maxes):.1f}[/bold]",
f"[bold]{statistics.mean(throughputs):.0f}[/bold]",
"",
)
console.print()
console.print(table)
combined: dict[str, int] = {}
for r in results:
for k, v in r.failures.items():
combined[k] = combined.get(k, 0) + v
if combined:
console.print()
console.print("[red]Failure breakdown:[/red]")
for reason, count in sorted(combined.items(), key=lambda x: -x[1]):
console.print(f" {reason}: {count}")
def check_thresholds(
results: list[RunResult],
min_rps: float | None = None,
max_p50_ms: float | None = None,
max_p99_ms: float | None = None,
) -> bool:
"""Check results against performance thresholds. Returns True if all pass."""
avg_rps = statistics.mean(r.throughput for r in results)
avg_p50 = statistics.mean(r.percentile(50) for r in results)
avg_p99 = statistics.mean(r.percentile(99) for r in results)
passed = True
if min_rps is not None and avg_rps < min_rps:
console.print(
f"\n[red]THRESHOLD FAILED:[/red] avg throughput {avg_rps:.0f} req/s"
f" < minimum {min_rps:.0f} req/s"
)
passed = False
if max_p50_ms is not None and avg_p50 > max_p50_ms:
console.print(
f"\n[red]THRESHOLD FAILED:[/red] avg P50 {avg_p50:.1f} ms > maximum {max_p50_ms:.1f} ms"
)
passed = False
if max_p99_ms is not None and avg_p99 > max_p99_ms:
console.print(
f"\n[red]THRESHOLD FAILED:[/red] avg P99 {avg_p99:.1f} ms > maximum {max_p99_ms:.1f} ms"
)
passed = False
if passed and (min_rps is not None or max_p50_ms is not None or max_p99_ms is not None):
console.print("\n[green]All thresholds passed.[/green]")
return passed
def main() -> None:
parser = argparse.ArgumentParser(description="Async HTTP benchmark client for MLflow Gateway")
parser.add_argument("--url", required=True, help="Gateway invocation URL")
parser.add_argument("--requests", type=int, default=2000)
parser.add_argument("--max-concurrent", type=int, default=50)
parser.add_argument("--runs", type=int, default=3)
parser.add_argument(
"--min-rps",
type=float,
default=None,
metavar="N",
help="Fail (exit 1) if average throughput falls below N req/s",
)
parser.add_argument(
"--max-p50-ms",
type=float,
default=None,
metavar="N",
help="Fail (exit 1) if average P50 latency exceeds N ms",
)
parser.add_argument(
"--max-p99-ms",
type=float,
default=None,
metavar="N",
help="Fail (exit 1) if average P99 latency exceeds N ms",
)
parser.add_argument(
"--auth-username",
default=None,
help="Basic auth username. If set together with --auth-password, sent on every request.",
)
parser.add_argument(
"--auth-password",
default=None,
help="Basic auth password. If set together with --auth-username, sent on every request.",
)
args = parser.parse_args()
auth = (
aiohttp.BasicAuth(args.auth_username, args.auth_password)
if args.auth_username and args.auth_password
else None
)
console.print(f"\n[bold]Benchmarking[/bold] {args.url}")
console.print(
f" {args.requests} requests · {args.max_concurrent} concurrent · {args.runs} runs\n"
)
results = run_benchmark(args.url, args.requests, args.max_concurrent, args.runs, auth)
print_results(results)
if not check_thresholds(
results, min_rps=args.min_rps, max_p50_ms=args.max_p50_ms, max_p99_ms=args.max_p99_ms
):
raise SystemExit(1)
if __name__ == "__main__":
main()
+68
View File
@@ -0,0 +1,68 @@
# /// script
# requires-python = ">=3.10"
# dependencies = ["fastapi>=0.115.0,<1", "uvicorn[standard]>=0.30.0,<1"]
# ///
"""Fake OpenAI-compatible server for benchmarking.
Returns synthetic responses after a configurable delay so benchmarks measure
MLflow overhead rather than provider latency.
Run standalone:
uv run fake_server.py
PORT=9200 uv run fake_server.py
Or with multiple workers (as launched by run.py):
uvicorn fake_server:app --workers 8 --port 9137
"""
import asyncio
import os
import time
from typing import Any
import uvicorn
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI()
DELAY_MS = int(os.environ.get("FAKE_RESPONSE_DELAY_MS", "50"))
class ChatRequest(BaseModel):
model: str = "gpt-4o-mini"
messages: list[dict[str, str]] = Field(min_length=1)
stream: bool = False
temperature: float = 1.0
max_tokens: int = 50
@app.post("/v1/chat/completions")
async def chat_completions(req: ChatRequest) -> dict[str, Any]:
await asyncio.sleep(DELAY_MS / 1000)
return {
"id": "chatcmpl-fake",
"object": "chat.completion",
"created": int(time.time()),
"model": req.model,
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "Hello!"},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
}
@app.get("/health")
async def health() -> dict[str, str]:
# Polled by run.py's _wait_for_port to detect when the server is ready.
return {"status": "ok"}
if __name__ == "__main__":
port = int(os.environ.get("PORT", "9137"))
host = os.environ.get("FAKE_SERVER_HOST", "127.0.0.1")
uvicorn.run("fake_server:app", host=host, port=port, log_level="warning")
+793
View File
@@ -0,0 +1,793 @@
# /// script
# requires-python = ">=3.10"
# dependencies = ["aiohttp>=3.13.3,<4", "psycopg2-binary>=2.9,<3", "rich>=14.3.3,<15"]
# ///
"""MLflow AI Gateway benchmark runner.
Orchestrates fake OpenAI server, MLflow server(s), optional PostgreSQL and
nginx (via Docker), then runs the async benchmark client.
Usage:
uv run run.py # 4 instances, PostgreSQL, nginx (Docker)
uv run run.py --instances 1 # single instance, SQLite, no Docker
uv run run.py --instances 1 --database postgres
uv run run.py --instances 8 --workers 8
uv run run.py --url http://... # benchmark an existing endpoint directly
"""
import argparse
import base64
import contextlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from collections.abc import Generator
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).parent))
import aiohttp # type: ignore[import-not-found]
import benchmark as bm # local module; path inserted above
from rich.console import Console # type: ignore[import-not-found]
from rich.panel import Panel # type: ignore[import-not-found]
from rich.progress import ( # type: ignore[import-not-found]
Progress,
SpinnerColumn,
TextColumn,
TimeElapsedColumn,
)
SCRIPT_DIR = Path(__file__).parent
FAKE_SERVER_PORT = 9137
FAKE_SERVER_WORKERS = 8
MLFLOW_PORT = 5731
INSTANCE_BASE_PORT = 5800
POSTGRES_PORT = int(os.environ.get("GATEWAY_BENCH_POSTGRES_PORT", "5432"))
POSTGRES_PASSWORD = "benchmarkpass"
ENDPOINT_NAME = "benchmark-chat"
_API_SECRET_CREATE = "gateway/secrets/create"
_API_MODEL_DEF_CREATE = "gateway/model-definitions/create"
_API_ENDPOINT_CREATE = "gateway/endpoints/create"
console = Console()
def _uv_prefix() -> list[str]:
"""Return uv run prefix when inside the mlflow repo, else empty list."""
in_repo = (
shutil.which("uv")
and subprocess.run(
["git", "rev-parse", "HEAD"], cwd=SCRIPT_DIR, capture_output=True
).returncode
== 0
)
return ["uv", "run", "--no-build-isolation", "--extra", "gateway"] if in_repo else []
def _subprocess_env() -> dict[str, str]:
return os.environ | {"OBJC_DISABLE_INITIALIZE_FORK_SAFETY": "YES"}
def _wait_for_port(port: int, label: str, log_file: Path | None = None, timeout: int = 30) -> None:
url = f"http://127.0.0.1:{port}/health"
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
TimeElapsedColumn(),
console=console,
transient=True,
) as progress:
progress.add_task(f" Waiting for {label}...", total=None)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(url, timeout=1):
break
except Exception:
time.sleep(0.5)
else:
console.print(f" [red]✗ {label} failed to start within {timeout}s[/red]")
if log_file and log_file.exists():
console.print(" [yellow]Last 20 lines of log:[/yellow]")
for line in log_file.read_text().splitlines()[-20:]:
console.print(f" [dim]{line}[/dim]")
sys.exit(1)
console.print(f" [green]✓[/green] {label} ready")
@contextlib.contextmanager
def _start_fake_server(
work_dir: str, port: int = FAKE_SERVER_PORT, workers: int = FAKE_SERVER_WORKERS
) -> Generator[None, None, None]:
prefix = _uv_prefix()
log_file = Path(work_dir) / "fake_server.log"
with (
log_file.open("w") as f,
subprocess.Popen(
[
*prefix,
"uvicorn",
"fake_server:app",
"--workers",
str(workers),
"--host",
"127.0.0.1",
"--port",
str(port),
"--log-level",
"warning",
],
cwd=SCRIPT_DIR,
stdout=f,
stderr=f,
env=_subprocess_env(),
) as proc,
):
_wait_for_port(port, "fake OpenAI server", log_file)
try:
yield
finally:
proc.terminate()
@contextlib.contextmanager
def _start_mlflow(
work_dir: str,
port: int,
workers: int,
backend_uri: str,
label: str = "MLflow server",
host: str = "127.0.0.1",
auth: bool = False,
) -> Generator[None, None, None]:
prefix = _uv_prefix()
# basic-auth requires the `auth` extra (Flask-WTF) at runtime.
if auth and prefix:
prefix = [*prefix, "--extra", "auth"]
# psycopg2-binary lives in the `db` extra.
if backend_uri.startswith("postgresql") and prefix:
prefix = [*prefix, "--extra", "db"]
log_file = Path(work_dir) / f"mlflow-{port}.log"
cmd = [
*prefix,
"mlflow",
"server",
"--backend-store-uri",
backend_uri,
"--host",
host,
"--port",
str(port),
"--workers",
str(workers),
"--disable-security-middleware",
]
if auth:
cmd += ["--app-name", "basic-auth"]
with (
log_file.open("w") as f,
subprocess.Popen(cmd, cwd=SCRIPT_DIR, stdout=f, stderr=f, env=_subprocess_env()) as proc,
):
_wait_for_port(port, label, log_file)
try:
yield
finally:
proc.terminate()
def _check_docker() -> None:
try:
result = subprocess.run(["docker", "info"], capture_output=True)
except FileNotFoundError:
console.print(
"[red]Docker is not installed. Install it at https://docs.docker.com/get-docker/[/red]"
)
sys.exit(1)
if result.returncode != 0:
console.print("[red]Docker daemon is not running. Please start Docker and try again.[/red]")
sys.exit(1)
@contextlib.contextmanager
def _start_postgres(container_name: str = "benchmark-postgres") -> Generator[str, None, None]:
"""Start a PostgreSQL Docker container. Yields the connection URI."""
subprocess.run(["docker", "rm", "-f", container_name], capture_output=True)
with subprocess.Popen(
[
"docker",
"run",
"--rm",
"--name",
container_name,
"-e",
f"POSTGRES_PASSWORD={POSTGRES_PASSWORD}",
"-e",
"POSTGRES_DB=mlflow",
"-p",
f"127.0.0.1:{POSTGRES_PORT}:5432",
"postgres:16-alpine",
"-c",
"max_connections=500",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
):
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
TimeElapsedColumn(),
console=console,
transient=True,
) as progress:
progress.add_task(" Starting PostgreSQL...", total=None)
deadline = time.monotonic() + 30
while time.monotonic() < deadline:
if (
subprocess.run(
["docker", "exec", container_name, "pg_isready", "-U", "postgres"],
capture_output=True,
).returncode
== 0
):
break
time.sleep(0.5)
else:
console.print(" [red]✗ PostgreSQL failed to start within 30s[/red]")
sys.exit(1)
console.print(" [green]✓[/green] PostgreSQL ready")
try:
yield f"postgresql://postgres:{POSTGRES_PASSWORD}@127.0.0.1:{POSTGRES_PORT}/mlflow"
finally:
subprocess.run(["docker", "kill", container_name], capture_output=True)
def _basic_auth_header(creds: tuple[str, str] | None) -> dict[str, str]:
if creds is None:
return {}
token = base64.b64encode(f"{creds[0]}:{creds[1]}".encode()).decode()
return {"Authorization": f"Basic {token}"}
def _api_post(
tracking_uri: str,
path: str,
body: dict[str, Any],
creds: tuple[str, str] | None = None,
) -> Any:
url = f"{tracking_uri.rstrip('/')}/api/3.0/mlflow/{path}"
headers = {"Content-Type": "application/json", **_basic_auth_header(creds)}
req = urllib.request.Request(url, data=json.dumps(body).encode(), headers=headers)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
console.print(f" [red]API error {e.code} at {url}: {e.read().decode()}[/red]")
sys.exit(1)
except urllib.error.URLError as e:
console.print(f" [red]API error at {url}: {e.reason}[/red]")
sys.exit(1)
def _setup_endpoint(
tracking_uri: str,
fake_server_url: str,
endpoint_name: str,
usage_tracking: bool,
creds: tuple[str, str] | None = None,
) -> str:
"""Create secret → model definition → endpoint. Returns the invocation URL."""
console.print(" Creating secret...")
secret_id = _api_post(
tracking_uri,
_API_SECRET_CREATE,
{
"secret_name": "benchmark-secret",
"secret_value": {"api_key": "fake-benchmark-key"},
"provider": "openai",
"auth_config": {"api_base": fake_server_url},
},
creds,
)["secret"]["secret_id"]
console.print(" Creating model definition...")
model_def_id = _api_post(
tracking_uri,
_API_MODEL_DEF_CREATE,
{
"name": "benchmark-model",
"secret_id": secret_id,
"provider": "openai",
"model_name": "gpt-4o-mini",
},
creds,
)["model_definition"]["model_definition_id"]
console.print(f" Creating endpoint '{endpoint_name}' (usage_tracking={usage_tracking})...")
_api_post(
tracking_uri,
_API_ENDPOINT_CREATE,
{
"name": endpoint_name,
"model_configs": [
{"model_definition_id": model_def_id, "linkage_type": "PRIMARY", "weight": 1.0}
],
"usage_tracking": usage_tracking,
},
creds,
)
invoke_url = f"{tracking_uri.rstrip('/')}/gateway/{endpoint_name}/mlflow/invocations"
console.print(f" [green]✓[/green] Endpoint ready: [cyan]{invoke_url}[/cyan]")
return invoke_url
def _sanity_check(url: str, creds: tuple[str, str] | None = None) -> None:
console.print(" Sending sanity-check request...")
body = json.dumps({"messages": [{"role": "user", "content": "test"}]}).encode()
headers = {"Content-Type": "application/json", **_basic_auth_header(creds)}
req = urllib.request.Request(url, data=body, headers=headers)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
if resp.status != 200:
console.print(f" [red]✗ Sanity check failed: HTTP {resp.status}[/red]")
sys.exit(1)
except Exception as e:
console.print(f" [red]✗ Sanity check failed: {e}[/red]")
sys.exit(1)
console.print(" [green]✓[/green] Sanity check passed")
def _run_benchmark(
url: str,
n_requests: int,
max_concurrent: int,
runs: int,
min_rps: float | None = None,
max_p50_ms: float | None = None,
max_p99_ms: float | None = None,
output: Path | None = None,
creds: tuple[str, str] | None = None,
) -> None:
auth = aiohttp.BasicAuth(*creds) if creds else None
results = bm.run_benchmark(url, n_requests, max_concurrent, runs, auth)
bm.print_results(results)
if output is not None:
output.write_text(json.dumps(bm.results_to_dict(results), indent=2))
console.print(f" Results saved to [cyan]{output}[/cyan]")
if not bm.check_thresholds(
results, min_rps=min_rps, max_p50_ms=max_p50_ms, max_p99_ms=max_p99_ms
):
raise SystemExit(1)
@contextlib.contextmanager
def _start_nginx(
work_dir: str, instance_ports: list[int], port: int, container_name: str = "benchmark-nginx"
) -> Generator[None, None, None]:
nginx_dir = Path(work_dir) / "nginx"
conf_d = nginx_dir / "conf.d"
conf_d.mkdir(parents=True)
upstream_lines = "\n".join(f" server host.docker.internal:{p};" for p in instance_ports)
(conf_d / "mlflow.conf").write_text(
f"upstream mlflow_backends {{\n"
f"{upstream_lines}\n"
f" keepalive 512;\n"
f" keepalive_requests 100000;\n"
f" keepalive_timeout 60s;\n"
f"}}\n"
f"server {{\n"
f" listen {port} reuseport backlog=65535;\n"
f" location / {{\n"
f" proxy_pass http://mlflow_backends;\n"
f" proxy_http_version 1.1;\n"
f' proxy_set_header Connection "";\n'
f" proxy_set_header Host $host;\n"
f" proxy_set_header X-Real-IP $remote_addr;\n"
f" proxy_connect_timeout 5s;\n"
f" proxy_send_timeout 60s;\n"
f" proxy_read_timeout 60s;\n"
f" }}\n"
f"}}\n"
)
(nginx_dir / "nginx.conf").write_text(
"worker_processes auto;\n"
"worker_rlimit_nofile 65535;\n"
"events {\n"
" worker_connections 16384;\n"
" use epoll;\n"
" multi_accept on;\n"
"}\n"
"http {\n"
" access_log off;\n"
" tcp_nodelay on;\n"
" keepalive_timeout 65;\n"
" keepalive_requests 100000;\n"
" reset_timedout_connection on;\n"
" include /etc/nginx/conf.d/*.conf;\n"
"}\n"
)
subprocess.run(["docker", "rm", "-f", container_name], capture_output=True)
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
TimeElapsedColumn(),
console=console,
transient=True,
) as progress:
progress.add_task(" Starting nginx...", total=None)
subprocess.run(
[
"docker",
"run",
"--rm",
"-d",
"--name",
container_name,
"--add-host=host.docker.internal:host-gateway",
"--ulimit",
"nofile=65535:65535",
"-v",
f"{nginx_dir / 'nginx.conf'}:/etc/nginx/nginx.conf:ro",
"-v",
f"{conf_d}:/etc/nginx/conf.d:ro",
"-p",
f"127.0.0.1:{port}:{port}",
"nginx:alpine",
],
check=True,
capture_output=True,
)
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
if (
subprocess.run(
["docker", "exec", container_name, "nginx", "-t"], capture_output=True
).returncode
== 0
):
break
time.sleep(0.5)
else:
console.print(" [red]✗ nginx failed to start[/red]")
sys.exit(1)
console.print(" [green]✓[/green] nginx ready")
try:
yield
finally:
subprocess.run(["docker", "kill", container_name], capture_output=True)
def cmd_bench(args: argparse.Namespace) -> None:
instances = args.instances
mode = "1 instance" if instances == 1 else f"{instances} instances, nginx LB"
creds = (args.auth_username, args.auth_password) if args.auth else None
if args.url:
console.print(
Panel.fit(
f"[bold]Gateway Benchmark[/bold] ({mode})\n"
f"URL: [cyan]{args.url}[/cyan]\n"
f"Auth: {'basic-auth as ' + args.auth_username if creds else 'disabled'}\n"
f"Requests: {args.requests} · Concurrency: {args.max_concurrent}"
f" · Runs: {args.runs}",
border_style="cyan",
)
)
console.print("\n[bold]Running benchmark[/bold]")
_run_benchmark(
args.url,
args.requests,
args.max_concurrent,
args.runs,
args.min_rps,
args.max_p50_ms,
args.max_p99_ms,
args.output,
creds,
)
return
needs_docker = instances > 1 or args.database == "postgres"
if needs_docker:
_check_docker()
with tempfile.TemporaryDirectory(prefix="mlflow-bench-") as work_dir:
port = args.port
fake_port = args.fake_server_port
instance_ports = [args.base_port + i for i in range(instances)]
auth_line = f"basic-auth as {args.auth_username}" if creds else "disabled"
if instances == 1:
panel = (
f"[bold]Gateway Benchmark[/bold] ({mode})\n"
f"Workers: {args.workers} · DB: {args.database.upper()} · "
f"Usage tracking: {args.usage_tracking} · Auth: {auth_line}\n"
f"Requests: {args.requests} · Concurrency: {args.max_concurrent} · "
f"Runs: {args.runs} · Fake delay: {args.fake_delay_ms}ms\n"
f"Ports: MLflow :{port} · Fake server :{fake_port}"
)
else:
panel = (
f"[bold]Gateway Benchmark[/bold] ({mode})\n"
f"Workers/instance: {args.workers} · "
f"Total workers: {instances * args.workers} · "
f"Usage tracking: {args.usage_tracking} · Auth: {auth_line}\n"
f"Requests: {args.requests} · Concurrency: {args.max_concurrent} · "
f"Runs: {args.runs} · Fake delay: {args.fake_delay_ms}ms\n"
f"Ports: instances {instance_ports[0]}{instance_ports[-1]}"
f" · LB :{port} · Fake server :{fake_port}"
)
console.print(Panel.fit(panel, border_style="cyan"))
with contextlib.ExitStack() as stack:
stack.callback(lambda: console.print("\n[dim]Cleaning up...[/dim]"))
# Backend
if instances > 1 or args.database == "postgres":
console.print("\n[bold]PostgreSQL[/bold]")
backend_uri = stack.enter_context(_start_postgres())
else:
db_path = Path(work_dir) / "mlflow.db"
backend_uri = f"sqlite:///{db_path}"
console.print(f"\n[dim]Using SQLite: {db_path}[/dim]")
# Servers
console.print("\n[bold]Starting servers[/bold]")
stack.enter_context(
_start_fake_server(work_dir, port=fake_port, workers=FAKE_SERVER_WORKERS)
)
if instances == 1:
stack.enter_context(
_start_mlflow(work_dir, port, args.workers, backend_uri, auth=args.auth)
)
console.print("\n[bold]Setting up gateway endpoint[/bold]")
invoke_url = _setup_endpoint(
f"http://127.0.0.1:{port}",
f"http://127.0.0.1:{fake_port}/v1",
ENDPOINT_NAME,
usage_tracking=args.usage_tracking,
creds=creds,
)
_sanity_check(invoke_url, creds)
else:
# Start instance 0 first — it initializes the DB schema.
# All instances share the same PostgreSQL DB, so starting concurrently
# can cause CREATE TABLE race conditions.
stack.enter_context(
_start_mlflow(
work_dir,
instance_ports[0],
args.workers,
backend_uri,
"MLflow instance 0",
host="0.0.0.0",
auth=args.auth,
)
)
for i, p in enumerate(instance_ports[1:], start=1):
stack.enter_context(
_start_mlflow(
work_dir,
p,
args.workers,
backend_uri,
f"MLflow instance {i}",
host="0.0.0.0",
auth=args.auth,
)
)
console.print("\n[bold]Setting up gateway endpoint[/bold]")
_setup_endpoint(
f"http://127.0.0.1:{instance_ports[0]}",
f"http://127.0.0.1:{fake_port}/v1",
ENDPOINT_NAME,
usage_tracking=args.usage_tracking,
creds=creds,
)
console.print("\n[bold]Starting nginx load balancer[/bold]")
nginx_container = "benchmark-nginx"
stack.enter_context(
_start_nginx(
work_dir, instance_ports, port=port, container_name=nginx_container
)
)
subprocess.run(
["docker", "exec", nginx_container, "nginx", "-s", "reload"],
capture_output=True,
)
time.sleep(1)
invoke_url = f"http://127.0.0.1:{port}/gateway/{ENDPOINT_NAME}/mlflow/invocations"
_sanity_check(invoke_url, creds)
console.print("\n[bold]Running benchmark[/bold]")
_run_benchmark(
invoke_url,
args.requests,
args.max_concurrent,
args.runs,
args.min_rps,
args.max_p50_ms,
args.max_p99_ms,
args.output,
creds,
)
def main() -> None:
parser = argparse.ArgumentParser(
description="MLflow AI Gateway benchmark",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--url",
metavar="URL",
help="Benchmark this endpoint URL directly, skipping server setup entirely",
)
parser.add_argument(
"--instances",
type=int,
default=int(os.environ.get("INSTANCES", "4")),
metavar="N",
help=(
"Number of MLflow instances to run (default: 4). "
"Values >1 require Docker (postgres + nginx). "
"Use --instances 1 for a single instance with optional SQLite."
),
)
parser.add_argument(
"--workers",
type=int,
default=int(os.environ.get("WORKERS_PER_INSTANCE", "4")),
metavar="N",
help="Gunicorn/uvicorn worker processes per MLflow instance (default: 4)",
)
parser.add_argument(
"--database",
choices=["sqlite", "postgres"],
default="sqlite",
help=(
"Database to use — only applies when --instances 1. "
"'postgres' auto-starts a Docker container. (default: sqlite)"
),
)
parser.add_argument(
"--no-usage-tracking",
dest="usage_tracking",
action="store_false",
default=True,
help="Disable usage tracking (tracing) on the benchmark endpoint",
)
parser.add_argument(
"--port",
type=int,
default=int(os.environ.get("MLFLOW_PORT", str(MLFLOW_PORT))),
metavar="N",
help=(
"Port the benchmark client sends requests to. "
"For --instances 1 this is the MLflow port; "
"for --instances >1 this is the nginx load balancer port. (default: 5731)"
),
)
parser.add_argument(
"--base-port",
type=int,
default=int(os.environ.get("BASE_PORT", str(INSTANCE_BASE_PORT))),
metavar="N",
help=(
"Starting port for MLflow instances in multi mode. "
"Instances listen on base-port, base-port+1, … (default: 5800)"
),
)
parser.add_argument(
"--fake-server-port",
type=int,
metavar="N",
default=int(os.environ.get("FAKE_SERVER_PORT", str(FAKE_SERVER_PORT))),
help="Port for the fake OpenAI server that simulates provider latency (default: 9137)",
)
parser.add_argument(
"--requests",
type=int,
default=int(os.environ.get("REQUESTS", "2000")),
metavar="N",
help="Total requests to send per benchmark run (default: 2000)",
)
parser.add_argument(
"--max-concurrent",
type=int,
default=int(os.environ.get("MAX_CONCURRENT", "50")),
metavar="N",
help="Maximum number of in-flight requests at any time (default: 50)",
)
parser.add_argument(
"--runs",
type=int,
default=int(os.environ.get("RUNS", "3")),
metavar="N",
help="Number of timed runs; results are reported per-run and averaged (default: 3)",
)
parser.add_argument(
"--fake-delay-ms",
type=int,
default=int(os.environ.get("FAKE_RESPONSE_DELAY_MS", "50")),
metavar="N",
help=(
"Simulated provider latency in ms. Set to 0 to measure pure MLflow overhead "
"with no provider delay. (default: 50)"
),
)
parser.add_argument(
"--output",
type=Path,
default=None,
metavar="FILE",
help="Write benchmark results as JSON to FILE (useful for CI artifact upload)",
)
parser.add_argument(
"--min-rps",
type=float,
default=None,
metavar="N",
help="Exit 1 if average throughput across runs falls below N req/s (CI threshold)",
)
parser.add_argument(
"--max-p50-ms",
type=float,
default=None,
metavar="N",
help="Exit 1 if average P50 latency across runs exceeds N ms (CI threshold)",
)
parser.add_argument(
"--max-p99-ms",
type=float,
default=None,
metavar="N",
help="Exit 1 if average P99 latency across runs exceeds N ms (CI threshold)",
)
parser.add_argument(
"--auth",
action="store_true",
default=os.environ.get("AUTH", "").lower() in ("1", "true"),
help=(
"Start MLflow with --app-name=basic-auth and authenticate every setup + "
"benchmark request using --auth-username/--auth-password."
),
)
parser.add_argument(
"--auth-username",
default=os.environ.get("AUTH_USERNAME", "admin"),
help="Basic auth username (default: admin, from basic_auth.ini)",
)
parser.add_argument(
"--auth-password",
default=os.environ.get("AUTH_PASSWORD", "password1234"),
help="Basic auth password (default: password1234, from basic_auth.ini)",
)
args = parser.parse_args()
os.environ["FAKE_RESPONSE_DELAY_MS"] = str(args.fake_delay_ms)
cmd_bench(args)
if __name__ == "__main__":
main()
+11
View File
@@ -0,0 +1,11 @@
# Tracing Benchmark
Per-commit tracing perf check. Runs on push to `master`; trend at https://mlflow.github.io/mlflow/dev/benchmarks/tracing/.
```bash
uv run pytest dev/benchmarks/tracing/ --benchmark-only
```
Add a scenario by writing a `test_*` function in `test_trace_perf.py` — it appears in the chart on the next master push. Renaming a test starts a new trend line.
Setup is modeled after [`opentelemetry-python`'s benchmark workflow](https://github.com/open-telemetry/opentelemetry-python/blob/main/.github/workflows/benchmarks.yml).
+116
View File
@@ -0,0 +1,116 @@
import json
import random
import time
import uuid
from opentelemetry import trace as trace_api
from opentelemetry.sdk.resources import Resource as _OTelResource
from opentelemetry.sdk.trace import ReadableSpan as OTelReadableSpan
from opentelemetry.trace import SpanContext
from mlflow.entities.span import Span, SpanType, create_mlflow_span
from mlflow.entities.trace_info import TraceInfo
from mlflow.entities.trace_location import TraceLocation
from mlflow.entities.trace_state import TraceState
from mlflow.store.tracking.sqlalchemy_store import SqlAlchemyStore
from mlflow.tracing.constant import SpanAttributeKey, TraceTagKey
from mlflow.tracing.utils import TraceJSONEncoder
ENV_CHOICES = ["prod", "staging", "dev"]
NAME_PREFIXES = ["agent_run", "qa_chain", "rag_pipeline", "summarizer"]
WEEK_MS = 7 * 24 * 60 * 60 * 1000
SEED_TRACES = 1000
SEED_SPANS_PER_TRACE = 10
def generate_trace_data(
experiment_id: str,
num_spans: int,
rng: random.Random,
) -> tuple[TraceInfo, list[Span]]:
trace_id = f"tr-{uuid.uuid4().hex}"
request_time = int(time.time() * 1000) - rng.randint(0, WEEK_MS)
name_prefix = rng.choice(NAME_PREFIXES)
trace_info = TraceInfo(
trace_id=trace_id,
trace_location=TraceLocation.from_experiment_id(experiment_id),
request_time=request_time,
state=rng.choice([TraceState.OK, TraceState.OK, TraceState.OK, TraceState.ERROR]),
execution_duration=rng.randint(100, 5000),
tags={
TraceTagKey.TRACE_NAME: f"{name_prefix}_{trace_id[-4:]}",
"env": rng.choice(ENV_CHOICES),
},
)
span_types = [SpanType.LLM, SpanType.RETRIEVER, SpanType.TOOL, SpanType.CHAIN]
base_ns = 1_000_000_000_000
spans: list[Span] = []
for i in range(num_spans):
is_root = i == 0
span_type = SpanType.AGENT if is_root else rng.choice(span_types)
parent_id = None if is_root else rng.choice(range(max(0, i - 3), i))
trace_num = rng.randint(1, 2**63)
ctx = SpanContext(
trace_id=trace_num,
span_id=i + 1,
is_remote=False,
trace_flags=trace_api.TraceFlags(1),
trace_state=trace_api.TraceState(),
)
parent_ctx = None
if parent_id is not None:
parent_ctx = SpanContext(
trace_id=trace_num,
span_id=parent_id + 1,
is_remote=False,
trace_flags=trace_api.TraceFlags(1),
trace_state=trace_api.TraceState(),
)
attrs: dict[str, object] = {}
if is_root:
attrs[SpanAttributeKey.INPUTS] = json.dumps(
{"query": "What is ML?"}, cls=TraceJSONEncoder
)
attrs[SpanAttributeKey.OUTPUTS] = json.dumps(
{"response": "ML is..."}, cls=TraceJSONEncoder
)
otel_span = OTelReadableSpan(
name=f"{span_type.lower()}_{i}" if not is_root else "agent_run",
context=ctx,
parent=parent_ctx,
attributes={
"mlflow.traceRequestId": json.dumps(trace_id),
"mlflow.spanType": json.dumps(span_type, cls=TraceJSONEncoder),
**attrs,
},
start_time=base_ns + i * 10_000_000,
end_time=base_ns + i * 10_000_000 + rng.randint(5_000_000, 50_000_000),
status=trace_api.Status(trace_api.StatusCode.OK),
resource=_OTelResource.get_empty(),
)
spans.append(create_mlflow_span(otel_span, trace_id, span_type))
return trace_info, spans
def seed_traces(
store: SqlAlchemyStore,
experiment_id: str,
count: int,
spans_per_trace: int,
) -> list[str]:
rng = random.Random(123)
trace_ids: list[str] = []
for _ in range(count):
ti, sp = generate_trace_data(experiment_id, spans_per_trace, rng)
store.start_trace(ti)
store.log_spans(experiment_id, sp)
trace_ids.append(ti.trace_id)
return trace_ids
+39
View File
@@ -0,0 +1,39 @@
from collections.abc import Iterator
from pathlib import Path
import pytest
from _data import SEED_SPANS_PER_TRACE, SEED_TRACES, seed_traces
import mlflow
from mlflow.store.tracking.sqlalchemy_store import SqlAlchemyStore
@pytest.fixture(scope="session")
def bench_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
return tmp_path_factory.mktemp("bench")
@pytest.fixture(scope="session")
def store(bench_dir: Path) -> SqlAlchemyStore:
db_uri = f"sqlite:///{bench_dir / 'mlflow.db'}"
(bench_dir / "artifacts").mkdir()
artifact_root = (bench_dir / "artifacts").as_uri()
return SqlAlchemyStore(db_uri, artifact_root)
@pytest.fixture(scope="session")
def experiment_id(store: SqlAlchemyStore) -> str:
return str(store.create_experiment("bench"))
@pytest.fixture(scope="session")
def seeded(store: SqlAlchemyStore, experiment_id: str) -> list[str]:
return seed_traces(store, experiment_id, SEED_TRACES, SEED_SPANS_PER_TRACE)
@pytest.fixture(scope="session")
def e2e_setup(bench_dir: Path) -> Iterator[None]:
mlflow.set_tracking_uri(f"sqlite:///{bench_dir / 'e2e.db'}")
mlflow.set_experiment("bench_e2e")
yield
mlflow.flush_trace_async_logging(terminate=True)
+131
View File
@@ -0,0 +1,131 @@
import random
from _data import generate_trace_data
from pytest_benchmark.fixture import BenchmarkFixture
import mlflow
from mlflow.entities.span import SpanType
from mlflow.store.tracking.sqlalchemy_store import SqlAlchemyStore
DEFAULT_SPANS = 100
INGEST_ROUNDS = 20
INGEST_WARMUP = 3
def test_ingest(benchmark: BenchmarkFixture, store: SqlAlchemyStore, experiment_id: str) -> None:
rng = random.Random(42)
def setup():
ti, sp = generate_trace_data(experiment_id, DEFAULT_SPANS, rng)
return (ti, sp), {}
def do(ti, sp):
store.start_trace(ti)
store.log_spans(experiment_id, sp)
benchmark.pedantic(
do, setup=setup, iterations=1, rounds=INGEST_ROUNDS, warmup_rounds=INGEST_WARMUP
)
def test_search_by_tag(
benchmark: BenchmarkFixture,
store: SqlAlchemyStore,
experiment_id: str,
seeded: list[str],
) -> None:
benchmark(
store.search_traces,
locations=[experiment_id],
max_results=100,
filter_string="tag.env = 'prod'",
)
def test_search_by_state(
benchmark: BenchmarkFixture,
store: SqlAlchemyStore,
experiment_id: str,
seeded: list[str],
) -> None:
benchmark(
store.search_traces,
locations=[experiment_id],
max_results=100,
filter_string="status = 'ERROR'",
)
def test_search_by_name_like(
benchmark: BenchmarkFixture,
store: SqlAlchemyStore,
experiment_id: str,
seeded: list[str],
) -> None:
benchmark(
store.search_traces,
locations=[experiment_id],
max_results=100,
filter_string="name LIKE 'rag_pipeline%'",
)
def test_search_by_timestamp(
benchmark: BenchmarkFixture,
store: SqlAlchemyStore,
experiment_id: str,
seeded: list[str],
) -> None:
benchmark(
store.search_traces,
locations=[experiment_id],
max_results=100,
filter_string="timestamp > 0",
order_by=["timestamp DESC"],
)
def _run_agent_workflow(num_tools: int, num_docs: int, query: str) -> None:
with mlflow.start_span(name="agent_run", span_type=SpanType.AGENT) as root:
root.set_inputs({"query": query})
with mlflow.start_span(name="retrieve", span_type=SpanType.RETRIEVER) as retr:
retr.set_inputs({"query": query})
docs = [
{"id": f"doc_{i}", "score": 0.9 - i * 0.01, "text": f"doc text {i} " * 10}
for i in range(num_docs)
]
retr.set_outputs({"documents": docs})
with mlflow.start_span(name="plan", span_type=SpanType.CHAIN) as planner:
planner.set_inputs({"query": query, "num_docs": len(docs)})
steps = [f"step_{i}" for i in range(num_tools)]
planner.set_outputs({"steps": steps})
tool_results = []
for step in steps:
with mlflow.start_span(name=f"tool:{step}", span_type=SpanType.TOOL) as tool:
tool.set_inputs({"step": step})
result = {"step": step, "status": "ok", "value": len(step)}
tool.set_outputs(result)
tool_results.append(result)
with mlflow.start_span(name="summarize", span_type=SpanType.LLM) as summ:
summ.set_inputs({"query": query, "tool_results": tool_results})
response = f"Answer to {query!r} using {num_docs} docs and {num_tools} tool calls."
summ.set_outputs({"response": response})
summ.set_attribute("model", "gpt-test")
summ.set_attribute("usage.input_tokens", 1234)
summ.set_attribute("usage.output_tokens", 567)
root.set_outputs({"response": response})
def test_e2e_agent(benchmark: BenchmarkFixture, e2e_setup: None) -> None:
counter = [0]
def do():
_run_agent_workflow(num_tools=20, num_docs=20, query=f"q-{counter[0]}")
counter[0] += 1
benchmark(do)
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env bash
# =============================================================================
# Static Site Build Script
#
# This script performs the following tasks:
# 1. Checks that NodeJS (>=18.0) is installed; if not, it prints instructions
# for installing via nvm.
# 2. Changes directory into the docs folder so that all commands run from there.
# (Note: This ensures that the DOCS_BASE_URL value is interpreted relative
# to the docs folder. For example, if running from the project root, the
# effective path will be: <root>/docs/docs/latest.)
# 3. Installs dependencies via npm.
# 4. (Optional) Builds the API docs:
# - If --build-api-docs is provided, then the API docs are built.
# - If --with-r-docs is also provided, the build includes R docs;
# otherwise, R docs are skipped.
# 5. Converts notebooks to MDX.
# 6. Exports the DOCS_BASE_URL environment variable (default: /docs/latest) so
# that Docusaurus uses the proper base URL.
# 7. Builds the static site.
#
# Once complete, the script instructs the user to navigate into the docs folder
# and run:
#
# npm run serve -- --port <your_port_number>
#
# Options:
# --build-api-docs Opt in to build the API docs (default: do not build)
# --with-r-docs When building API docs, include R documentation
# (default: skip R docs)
# --docs-base-url URL Override the default DOCS_BASE_URL (default: /docs/latest)
# -h, --help Display this help message and exit
#
# Example:
# ./dev/build-docs.sh --build-api-docs --with-r-docs --docs-base-url /docs/latest
# =============================================================================
# Exit immediately if a command exits with a non-zero status,
# treat unset variables as an error, and fail on pipeline errors.
set -euo pipefail
# -----------------------------------------------------------------------------
# Define color and style variables for styled output.
# -----------------------------------------------------------------------------
BOLD='\033[1m'
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# -----------------------------------------------------------------------------
# Logging functions for consistent output styling.
# -----------------------------------------------------------------------------
log_info() { echo -e "${BOLD}${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${BOLD}${GREEN}[SUCCESS]${NC} $1"; }
log_warning() { echo -e "${BOLD}${YELLOW}[WARNING]${NC} $1"; }
log_error() { echo -e "${BOLD}${RED}[ERROR]${NC} $1"; }
# -----------------------------------------------------------------------------
# Default configuration values.
# -----------------------------------------------------------------------------
BUILD_API_DOCS=false
WITH_R_DOCS=false
# Default DOCS_BASE_URL is set as expected when running from within the docs folder.
# Note: When running from the project root, since we change into docs/,
# the effective reference becomes: <root>/docs/docs/latest.
DOCS_BASE_URL="/docs/latest"
# -----------------------------------------------------------------------------
# Display usage information.
# -----------------------------------------------------------------------------
usage() {
cat <<EOF
Usage: $0 [options]
Options:
--build-api-docs Opt in to build the API docs (default: do not build)
--with-r-docs When building API docs, include R documentation
(default: skip R docs)
--docs-base-url URL Override the default DOCS_BASE_URL (default: /docs/latest)
-h, --help Display this help and exit
Example:
$0 --build-api-docs --with-r-docs --docs-base-url /docs/latest
EOF
}
# -----------------------------------------------------------------------------
# Parse command-line arguments manually.
# -----------------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
--build-api-docs)
BUILD_API_DOCS=true
shift
;;
--with-r-docs)
WITH_R_DOCS=true
shift
;;
--docs-base-url)
if [[ -n "${2:-}" ]]; then
DOCS_BASE_URL="$2"
shift 2
else
log_error "--docs-base-url requires an argument."
usage
exit 1
fi
;;
-h|--help)
usage
exit 0
;;
*)
log_error "Unknown option: $1"
usage
exit 1
;;
esac
done
# -----------------------------------------------------------------------------
# Function to compare semantic version numbers.
# Returns 0 (true) if version $1 is greater than or equal to version $2.
# -----------------------------------------------------------------------------
version_ge() {
# Usage: version_ge "18.15.0" "18.0.0"
[ "$(printf '%s\n' "$2" "$1" | sort -V | head -n1)" = "$2" ]
}
# -----------------------------------------------------------------------------
# Check that NodeJS is installed and meets the version requirement (>= 18.0).
# -----------------------------------------------------------------------------
if ! command -v node >/dev/null 2>&1; then
log_error "NodeJS is not installed. Please install NodeJS (>= 18.0) from https://nodejs.org/ or via nvm."
exit 1
fi
NODE_VERSION=$(node --version | sed 's/v//')
REQUIRED_VERSION="18.0.0"
if ! version_ge "$NODE_VERSION" "$REQUIRED_VERSION"; then
log_error "Detected NodeJS version $NODE_VERSION. Please install NodeJS >= $REQUIRED_VERSION."
log_info "If you have nvm installed, you can run:"
echo -e "${BOLD}nvm install node && nvm use node${NC}"
exit 1
fi
log_success "NodeJS version $NODE_VERSION is valid."
# -----------------------------------------------------------------------------
# Check that npm is installed.
# -----------------------------------------------------------------------------
if ! command -v npm >/dev/null 2>&1; then
log_error "npm is not installed. Please install npm from https://nodejs.org/."
exit 1
fi
# -----------------------------------------------------------------------------
# Change directory into the docs folder so that all commands run from there.
# This ensures that DOCS_BASE_URL is interpreted correctly.
# -----------------------------------------------------------------------------
if [ ! -d "docs" ]; then
log_error "The docs directory was not found. Make sure you're running this script from the project root."
exit 1
fi
log_info "Changing directory to docs/ ..."
cd docs
# -----------------------------------------------------------------------------
# Install dependencies via npm.
# -----------------------------------------------------------------------------
log_info "Installing dependencies with npm..."
npm install
log_success "Dependencies installed."
# -----------------------------------------------------------------------------
# Optionally build the API documentation.
# This step is opt-in via the --build-api-docs flag.
# If building API docs, the --with-r-docs flag controls whether R docs are included.
# -----------------------------------------------------------------------------
if [ "$BUILD_API_DOCS" = true ]; then
if [ "$WITH_R_DOCS" = true ]; then
log_info "Building API docs including R documentation..."
npm run build-api-docs
else
log_info "Building API docs without R documentation..."
npm run build-api-docs:no-r
fi
log_success "API docs built successfully."
else
log_info "Skipping API docs build phase."
fi
# -----------------------------------------------------------------------------
# Update the API module references for link functionality
# -----------------------------------------------------------------------------
log_info "Updating API module links..."
npm run update-api-modules
log_success "Updated API module links."
# -----------------------------------------------------------------------------
# Convert notebooks to MDX format.
# -----------------------------------------------------------------------------
log_info "Converting notebooks to MDX..."
npm run convert-notebooks
log_success "Notebooks converted to MDX."
# -----------------------------------------------------------------------------
# Export DOCS_BASE_URL and build the static site.
#
# Since we're in the docs folder, exporting DOCS_BASE_URL as "/docs/latest"
# means that when served from the project root, the built site will be available
# at <root>/docs/docs/latest.
# -----------------------------------------------------------------------------
export DOCS_BASE_URL
log_info "DOCS_BASE_URL set to '${DOCS_BASE_URL}'."
log_info "Building static site files with npm..."
npm run build
log_success "Static site built successfully."
# -----------------------------------------------------------------------------
# Final instructions for the user.
# -----------------------------------------------------------------------------
log_info "To run the site locally, please navigate to the 'docs' folder and execute:"
echo -e "${BOLD}npm run serve -- --port <your_port_number>${NC}"
log_info "For example: ${BOLD}npm run serve -- --port 3000${NC}"
log_success "Static site build process completed."
+140
View File
@@ -0,0 +1,140 @@
import argparse
import contextlib
import shutil
import subprocess
import sys
import zipfile
from collections.abc import Generator
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Package:
# name of the package on PyPI.
pypi_name: str
# type of the package, one of "dev", "skinny", "tracing", "release"
type: str
# path to the package relative to the root of the repository
build_path: str
DEV = Package("mlflow", "dev", ".")
RELEASE = Package("mlflow", "release", ".")
SKINNY = Package("mlflow-skinny", "skinny", "libs/skinny")
TRACING = Package("mlflow-tracing", "tracing", "libs/tracing")
PACKAGES = [
DEV,
SKINNY,
RELEASE,
TRACING,
]
JS_BUILD_DIR = Path("mlflow/server/js/build")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Build MLflow package.")
parser.add_argument(
"--package-type",
help="Package type to build. Default is 'dev'.",
choices=[p.type for p in PACKAGES],
default="dev",
)
parser.add_argument(
"--sha",
help="If specified, include the SHA in the wheel name as a build tag.",
)
return parser.parse_args()
@contextlib.contextmanager
def restore_changes() -> Generator[None, None, None]:
try:
yield
finally:
subprocess.check_call([
"git",
"restore",
"README.md",
"pyproject.toml",
])
def validate_ui_assets_pre_build(package: Package) -> None:
if package != RELEASE:
return
if not JS_BUILD_DIR.exists() or not any(JS_BUILD_DIR.iterdir()):
raise RuntimeError("Build the UI first before building the release package.")
def validate_ui_assets_post_build(wheel_path: Path, package: Package) -> None:
ui_asset_prefix = f"{JS_BUILD_DIR.as_posix()}/"
with zipfile.ZipFile(wheel_path) as zf:
has_ui_assets = any(name.startswith(ui_asset_prefix) for name in zf.namelist())
if package == RELEASE:
if not has_ui_assets:
raise RuntimeError(
f"UI assets are missing from the release wheel: {wheel_path}. "
"Build the UI first before building the release package."
)
elif package in (SKINNY, TRACING):
if has_ui_assets:
raise RuntimeError(
f"UI assets should not be included in the {package.type} wheel: {wheel_path}."
)
def main() -> None:
args = parse_args()
# Initialize submodules (e.g., mlflow/assistant/skills)
subprocess.check_call(["git", "submodule", "update", "--init", "--recursive"])
# Clean up build artifacts generated by previous builds
paths_to_clean_up = ["build"]
for pkg in PACKAGES:
paths_to_clean_up += [
f"{pkg.build_path}/dist",
f"{pkg.build_path}/{pkg.pypi_name}.egg_info",
]
for path in map(Path, paths_to_clean_up):
if not path.exists():
continue
if path.is_file():
path.unlink()
else:
shutil.rmtree(path)
package = next(p for p in PACKAGES if p.type == args.package_type)
validate_ui_assets_pre_build(package)
with restore_changes():
pyproject = Path("pyproject.toml")
if package == RELEASE:
pyproject.write_text(Path("pyproject.release.toml").read_text())
DIST_DIR = Path("dist").resolve()
DIST_DIR.mkdir(exist_ok=True)
subprocess.check_call([
sys.executable,
"-m",
"build",
package.build_path,
"--outdir",
DIST_DIR,
])
wheel = next(DIST_DIR.glob("mlflow*.whl"))
validate_ui_assets_post_build(wheel, package)
if args.sha:
name, version, rest = wheel.name.split("-", 2)
build_tag = f"0.sha.{args.sha}" # build tag must start with a digit
wheel.rename(wheel.with_name(f"{name}-{version}-{build_tag}-{rest}"))
if __name__ == "__main__":
main()
+257
View File
@@ -0,0 +1,257 @@
"""Validate GitHub Actions workflow and action files.
Complements `.github/policy.rego` with checks that need cross-file or remote
context.
"""
import json
import re
import subprocess
import sys
from collections import defaultdict
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
import yaml
# Matches a `uses:` line that references a remote action (not a local `./` path).
# Captures: owner/repo[/subpath] @ ref [ # comment ]
_USES_RE = re.compile(
r"""
^\s*-?\s*uses:\s+ # leading `- uses:` or `uses:`
(?P<action>[^@\s]+) # owner/repo[/subpath]
@
(?P<ref>[^\s#]+) # ref (SHA, tag, or branch)
(?:\s+\#\s*(?P<comment>\S+))? # optional # comment
""",
re.VERBOSE,
)
# A full 40-character hexadecimal SHA.
_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
# Requires at least vMAJOR.MINOR.PATCH to avoid ambiguous moving tags like v4.
_VERSION_COMMENT_RE = re.compile(r"^v\d+\.\d+\.\d+(?:\.\d+)*$")
_CACHE_PATH = Path(".cache/action-pins.json")
def _load_cache() -> dict[str, bool]:
if _CACHE_PATH.exists():
try:
return json.loads(_CACHE_PATH.read_text()) # type: ignore[no-any-return]
except (json.JSONDecodeError, OSError):
pass
return {}
def _save_cache(cache: dict[str, bool]) -> None:
try:
_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
_CACHE_PATH.write_text(json.dumps(cache, indent=2, sort_keys=True))
except OSError:
pass
def _repo_from_action(action: str) -> str:
match action.split("/"):
case [owner, repo, *_]:
return f"{owner}/{repo}"
case _:
raise ValueError(f"Invalid action format: {action!r}")
def _verify_sha_tag(action: str, sha: str, tag: str, cache: dict[str, bool]) -> bool | None:
cache_key = f"{action}@{sha}#{tag}"
if cache_key in cache:
return cache[cache_key]
repo = _repo_from_action(action)
try:
result = _resolve_tag(repo, sha, tag)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
return None
cache[cache_key] = result
return result
def _resolve_tag(repo: str, sha: str, tag: str) -> bool:
output = subprocess.check_output(
["git", "ls-remote", "--tags", f"https://github.com/{repo}.git", tag],
text=True,
timeout=10,
)
return any(line.split()[0] == sha for line in output.splitlines() if line)
def _iter_files() -> Iterator[Path]:
root = Path(".github")
for pattern in (
"workflows/*.yml",
"workflows/*.yaml",
"actions/**/*.yml",
"actions/**/*.yaml",
):
yield from root.glob(pattern)
@dataclass(frozen=True, slots=True)
class ActionRef:
prefix: str
action: str
ref: str
comment: str | None
def _iter_actions(path: Path) -> Iterator[ActionRef]:
with path.open(encoding="utf-8") as f:
for lineno, line in enumerate(f, start=1):
if m := _USES_RE.match(line):
action = m.group("action")
if not action.startswith("./"):
prefix = f"{path}:{lineno}: {line.strip()!r}"
yield ActionRef(prefix, action, m.group("ref"), m.group("comment"))
def _check_action(a: ActionRef, cache: dict[str, bool]) -> str | None:
if not _SHA_RE.match(a.ref):
return f"{a.prefix}\n error: ref '{a.ref}' is not a 40-character SHA"
if not a.comment or not _VERSION_COMMENT_RE.match(a.comment):
return (
f"{a.prefix}\n error: missing or invalid version comment"
f" (expected '# vX.Y.Z', got {a.comment!r})"
)
verified = _verify_sha_tag(a.action, a.ref, a.comment, cache)
if verified is None:
return (
f"{a.prefix}\n error: could not verify SHA against tag '{a.comment}'"
f" for {_repo_from_action(a.action)} (GitHub API unavailable)"
)
if not verified:
return (
f"{a.prefix}\n error: SHA '{a.ref}' does not match tag '{a.comment}'"
f" for {_repo_from_action(a.action)}"
)
return None
_LITERAL_CHARS = re.compile(r"[A-Za-z0-9._/\-]")
def _glob_to_regex(pattern: str) -> re.Pattern[str]:
# GitHub uses minimatch-style globs: `**` crosses `/`, `*` does not.
i = 0
n = len(pattern)
parts = ["^"]
while i < n:
c = pattern[i]
if c == "*" and i + 1 < n and pattern[i + 1] == "*":
i += 2
if i < n and pattern[i] == "/":
# `**/` matches zero or more path segments
parts.append("(?:.*/)?")
i += 1
else:
parts.append(".*")
elif c == "*":
parts.append("[^/]*")
i += 1
elif _LITERAL_CHARS.match(c):
parts.append(re.escape(c))
i += 1
else:
raise ValueError(
f"Unsupported character {c!r} at position {i} in pattern {pattern!r}."
" Extend _glob_to_regex if this is a valid GitHub path filter character."
)
parts.append("$")
return re.compile("".join(parts))
def _list_tracked_files() -> list[str]:
return subprocess.check_output(["git", "ls-files"], text=True).splitlines()
def _pattern_matches(pattern: str, files: list[str]) -> bool:
regex = _glob_to_regex(pattern.removeprefix("!"))
return any(regex.match(f) for f in files)
def _iter_path_patterns(path: Path) -> Iterator[tuple[str, str, str]]:
data = yaml.load(path.read_text(encoding="utf-8"), Loader=yaml.CSafeLoader)
if not isinstance(data, dict):
return
# PyYAML parses the literal `on:` key as the boolean True (YAML 1.1).
on = data.get("on", data.get(True))
if not isinstance(on, dict):
return
for event, cfg in on.items():
if not isinstance(cfg, dict):
continue
for key in ("paths", "paths-ignore"):
for pattern in cfg.get(key) or []:
yield str(event), key, pattern
def _iter_workflow_files() -> Iterator[Path]:
root = Path(".github/workflows")
for ext in ("*.yml", "*.yaml"):
yield from root.glob(ext)
def _check_paths() -> Iterator[str]:
files = _list_tracked_files()
for path in sorted(_iter_workflow_files()):
for event, key, pattern in _iter_path_patterns(path):
if not _pattern_matches(pattern, files):
yield (
f"{path}: [on.{event}.{key}] pattern {pattern!r} does not"
" match any tracked file"
)
def _check_version_consistency(all_action_refs: list[ActionRef]) -> Iterator[str]:
by_action: dict[str, list[ActionRef]] = defaultdict(list)
for action_ref in all_action_refs:
by_action[action_ref.action].append(action_ref)
for action, refs in sorted(by_action.items()):
versions = {(ref.ref, ref.comment) for ref in refs}
if len(versions) > 1:
lines = "\n".join(f" {ref.prefix}" for ref in sorted(refs, key=lambda r: r.prefix))
yield f"{action} is pinned to multiple versions:\n{lines}"
def main() -> int:
cache = _load_cache()
all_errors: list[str] = []
all_action_refs: list[ActionRef] = []
try:
for path in _iter_files():
for action_ref in _iter_actions(path):
if error := _check_action(action_ref, cache):
all_errors.append(error)
else:
all_action_refs.append(action_ref)
finally:
_save_cache(cache)
all_errors.extend(_check_version_consistency(all_action_refs))
all_errors.extend(_check_paths())
if all_errors:
print("check-actions: the following violations were found:\n", file=sys.stderr)
for err in all_errors:
print(err, file=sys.stderr)
print(f"\n{len(all_errors)} violation(s) found.", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+382
View File
@@ -0,0 +1,382 @@
from __future__ import annotations
import argparse
import ast
import os
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
def is_github_actions() -> bool:
return os.environ.get("GITHUB_ACTIONS") == "true"
@dataclass
class Error:
file_path: Path
line: int
column: int
lines: list[str]
def format(self, github: bool = False) -> str:
message = " ".join(self.lines)
if github:
return f"::warning file={self.file_path},line={self.line},col={self.column}::{message}"
else:
return f"{self.file_path}:{self.line}:{self.column}: {message}"
@dataclass
class Parameter:
name: str
position: int | None # None for keyword-only
is_required: bool
is_positional_only: bool
is_keyword_only: bool
lineno: int
col_offset: int
@dataclass
class Signature:
positional: list[Parameter] # Includes positional-only and regular positional
keyword_only: list[Parameter]
has_var_positional: bool # *args
has_var_keyword: bool # **kwargs
@dataclass
class ParameterError:
message: str
param_name: str
lineno: int
col_offset: int
def parse_signature(args: ast.arguments) -> Signature:
"""Convert ast.arguments to a Signature dataclass for easier processing."""
parameters_positional: list[Parameter] = []
parameters_keyword_only: list[Parameter] = []
# Process positional-only parameters
for i, arg in enumerate(args.posonlyargs):
parameters_positional.append(
Parameter(
name=arg.arg,
position=i,
is_required=True, # All positional-only are required
is_positional_only=True,
is_keyword_only=False,
lineno=arg.lineno,
col_offset=arg.col_offset,
)
)
# Process regular positional parameters
offset = len(args.posonlyargs)
first_optional_idx = len(args.posonlyargs + args.args) - len(args.defaults)
for i, arg in enumerate(args.args):
pos = offset + i
parameters_positional.append(
Parameter(
name=arg.arg,
position=pos,
is_required=pos < first_optional_idx,
is_positional_only=False,
is_keyword_only=False,
lineno=arg.lineno,
col_offset=arg.col_offset,
)
)
# Process keyword-only parameters
for arg, default in zip(args.kwonlyargs, args.kw_defaults):
parameters_keyword_only.append(
Parameter(
name=arg.arg,
position=None,
is_required=default is None,
is_positional_only=False,
is_keyword_only=True,
lineno=arg.lineno,
col_offset=arg.col_offset,
)
)
return Signature(
positional=parameters_positional,
keyword_only=parameters_keyword_only,
has_var_positional=args.vararg is not None,
has_var_keyword=args.kwarg is not None,
)
def check_signature_compatibility(
old_fn: ast.FunctionDef | ast.AsyncFunctionDef,
new_fn: ast.FunctionDef | ast.AsyncFunctionDef,
) -> list[ParameterError]:
"""
Return list of error messages when *new_fn* is not backward-compatible with *old_fn*,
or None if compatible.
Compatibility rules
-------------------
• Positional / positional-only parameters
- Cannot be reordered, renamed, or removed.
- Adding **required** ones is breaking.
- Adding **optional** ones is allowed only at the end.
- Making an optional parameter required is breaking.
• Keyword-only parameters (order does not matter)
- Cannot be renamed or removed.
- Making an optional parameter required is breaking.
- Adding a required parameter is breaking; adding an optional parameter is fine.
"""
old_sig = parse_signature(old_fn.args)
new_sig = parse_signature(new_fn.args)
errors: list[ParameterError] = []
# ------------------------------------------------------------------ #
# 1. Positional / pos-only parameters
# ------------------------------------------------------------------ #
# (a) existing parameters must line up
for idx, old_param in enumerate(old_sig.positional):
if idx >= len(new_sig.positional):
errors.append(
ParameterError(
message=f"Positional param '{old_param.name}' was removed.",
param_name=old_param.name,
lineno=old_param.lineno,
col_offset=old_param.col_offset,
)
)
continue
new_param = new_sig.positional[idx]
if old_param.name != new_param.name:
errors.append(
ParameterError(
message=(
f"Positional param order/name changed: "
f"'{old_param.name}' -> '{new_param.name}'."
),
param_name=new_param.name,
lineno=new_param.lineno,
col_offset=new_param.col_offset,
)
)
# Stop checking further positional params after first order/name mismatch
break
if (not old_param.is_required) and new_param.is_required:
errors.append(
ParameterError(
message=f"Optional positional param '{old_param.name}' became required.",
param_name=new_param.name,
lineno=new_param.lineno,
col_offset=new_param.col_offset,
)
)
# (b) any extra new positional params must be optional and appended
if len(new_sig.positional) > len(old_sig.positional):
for idx in range(len(old_sig.positional), len(new_sig.positional)):
new_param = new_sig.positional[idx]
if new_param.is_required:
errors.append(
ParameterError(
message=f"New required positional param '{new_param.name}' added.",
param_name=new_param.name,
lineno=new_param.lineno,
col_offset=new_param.col_offset,
)
)
# ------------------------------------------------------------------ #
# 2. Keyword-only parameters (order-agnostic)
# ------------------------------------------------------------------ #
old_kw_names = {p.name for p in old_sig.keyword_only}
new_kw_names = {p.name for p in new_sig.keyword_only}
# Build mappings for easier lookup
old_kw_by_name = {p.name: p for p in old_sig.keyword_only}
new_kw_by_name = {p.name: p for p in new_sig.keyword_only}
# removed or renamed
for name in old_kw_names - new_kw_names:
old_param = old_kw_by_name[name]
errors.append(
ParameterError(
message=f"Keyword-only param '{name}' was removed.",
param_name=name,
lineno=old_param.lineno,
col_offset=old_param.col_offset,
)
)
# optional -> required upgrades
for name in old_kw_names & new_kw_names:
if not old_kw_by_name[name].is_required and new_kw_by_name[name].is_required:
new_param = new_kw_by_name[name]
errors.append(
ParameterError(
message=f"Keyword-only param '{name}' became required.",
param_name=name,
lineno=new_param.lineno,
col_offset=new_param.col_offset,
)
)
# new required keyword-only params
errors.extend(
ParameterError(
message=f"New required keyword-only param '{param.name}' added.",
param_name=param.name,
lineno=param.lineno,
col_offset=param.col_offset,
)
for param in new_sig.keyword_only
if param.is_required and param.name not in old_kw_names
)
return errors
def _is_private(n: str) -> bool:
return n.startswith("_") and not n.startswith("__") and not n.endswith("__")
class FunctionSignatureExtractor(ast.NodeVisitor):
def __init__(self) -> None:
self.functions: dict[str, ast.FunctionDef | ast.AsyncFunctionDef] = {}
self.stack: list[ast.ClassDef] = []
def visit_ClassDef(self, node: ast.ClassDef) -> None:
self.stack.append(node)
self.generic_visit(node)
self.stack.pop()
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
# Is this a private function or a function in a private class?
# If so, skip it.
if _is_private(node.name) or (self.stack and _is_private(self.stack[-1].name)):
return
names = [*(c.name for c in self.stack), node.name]
self.functions[".".join(names)] = node
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
if _is_private(node.name) or (self.stack and _is_private(self.stack[-1].name)):
return
names = [*(c.name for c in self.stack), node.name]
self.functions[".".join(names)] = node
def get_changed_python_files(base_branch: str = "master") -> list[Path]:
# In GitHub Actions PR context, we need to fetch the base branch first
if is_github_actions():
# Fetch the base branch to ensure we have it locally
subprocess.check_call(
["git", "fetch", "origin", f"{base_branch}:{base_branch}"],
)
result = subprocess.check_output(
["git", "diff", "--name-only", f"{base_branch}...HEAD"], text=True
)
files = [s.strip() for s in result.splitlines()]
return [Path(f) for f in files if f]
def parse_functions(content: str) -> dict[str, ast.FunctionDef | ast.AsyncFunctionDef]:
tree = ast.parse(content)
extractor = FunctionSignatureExtractor()
extractor.visit(tree)
return extractor.functions
def get_file_content_at_revision(file_path: Path, revision: str) -> str | None:
try:
return subprocess.check_output(["git", "show", f"{revision}:{file_path}"], text=True)
except subprocess.CalledProcessError as e:
print(f"Warning: Failed to get file content at revision: {e}", file=sys.stderr)
return None
def compare_signatures(base_branch: str = "master") -> list[Error]:
errors: list[Error] = []
for file_path in get_changed_python_files(base_branch):
# Ignore non-Python files
if not file_path.suffix == ".py":
continue
# Ignore files not in the mlflow directory
if file_path.parts[0] != "mlflow":
continue
# Ignore private modules
if any(part.startswith("_") and part != "__init__.py" for part in file_path.parts):
continue
base_content = get_file_content_at_revision(file_path, base_branch)
if base_content is None:
# Find not found in the base branch, likely added in the current branch
continue
if not file_path.exists():
# File not found, likely deleted in the current branch
continue
current_content = file_path.read_text()
base_functions = parse_functions(base_content)
current_functions = parse_functions(current_content)
for func_name in set(base_functions.keys()) & set(current_functions.keys()):
base_func = base_functions[func_name]
current_func = current_functions[func_name]
if param_errors := check_signature_compatibility(base_func, current_func):
# Create individual errors for each problematic parameter
errors.extend(
Error(
file_path=file_path,
line=param_error.lineno,
column=param_error.col_offset + 1,
lines=[
"[Non-blocking | Ignore if not public API]",
param_error.message,
f"This change will break existing `{func_name}` calls.",
"If this is not intended, please fix it.",
],
)
for param_error in param_errors
)
return errors
@dataclass
class Args:
base_branch: str
def parse_args() -> Args:
parser = argparse.ArgumentParser(
description="Check for breaking changes in Python function signatures"
)
parser.add_argument("--base-branch", default=os.environ.get("GITHUB_BASE_REF", "master"))
args = parser.parse_args()
return Args(base_branch=args.base_branch)
def main() -> None:
args = parse_args()
errors = compare_signatures(args.base_branch)
for error in errors:
print(error.format(github=is_github_actions()))
if __name__ == "__main__":
main()
+54
View File
@@ -0,0 +1,54 @@
"""
Pre-commit hook to check for missing `__init__.py` files in mlflow and tests directories.
This script ensures that all directories under the mlflow package and tests directory that contain
Python files also have an `__init__.py` file. This prevents `setuptools` from excluding these
directories during package build and ensures test modules are properly structured.
Usage:
uv run dev/check_init_py.py
Requirements:
- If `mlflow/foo/bar.py` exists, `mlflow/foo/__init__.py` must exist.
- If `tests/foo/test_bar.py` exists, `tests/foo/__init__.py` must exist.
- Only test files (starting with `test_`) in the tests directory are checked.
- All parent directories of Python files are checked recursively for `__init__.py`.
- Ignore directories that do not contain any Python files (e.g., `mlflow/server/js`).
"""
import subprocess
import sys
from pathlib import Path
def get_tracked_python_files() -> list[Path]:
try:
result = subprocess.check_output(
["git", "ls-files", "mlflow/**/*.py", "tests/**/*.py"],
text=True,
)
paths = (Path(f) for f in result.splitlines() if f)
return [p for p in paths if (not p.is_relative_to("tests") or p.name.startswith("test_"))]
except subprocess.CalledProcessError as e:
print(f"Error running git ls-files: {e}", file=sys.stderr)
sys.exit(1)
def main() -> int:
python_files = get_tracked_python_files()
if not python_files:
return 0
python_dirs = {p for f in python_files for p in f.parents if p != Path(".")}
if missing_init_files := [d for d in python_dirs if not (d / "__init__.py").exists()]:
print("Error: The following directories contain Python files but lack __init__.py:")
for d in sorted(missing_init_files):
print(f" {d.as_posix()}/")
print("Please add __init__.py files to the directories listed above.")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+290
View File
@@ -0,0 +1,290 @@
import argparse
import concurrent.futures
import itertools
import os
import re
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
import requests
from packaging.version import Version
MAX_COMMITS_PER_SCRIPT = 90
def chunk_list(lst: list[str], size: int) -> list[list[str]]:
return [lst[i : i + size] for i in range(0, len(lst), size)]
def get_token() -> str | None:
if token := os.environ.get("GH_TOKEN"):
return token
try:
token = subprocess.check_output(
["gh", "auth", "token"], text=True, stderr=subprocess.DEVNULL
).strip()
if token:
return token
except (subprocess.CalledProcessError, FileNotFoundError):
pass
return None
def get_headers() -> dict[str, str]:
if token := get_token():
return {"Authorization": f"token {token}"}
return {}
def validate_version(version: str) -> None:
"""
Validate that the version has a micro version component.
Raises ValueError if the version is invalid.
"""
parsed_version = Version(version)
if len(parsed_version.release) != 3:
raise ValueError(
f"Invalid version: '{version}'. "
"Version must be in the format <major>.<minor>.<micro> (e.g., '2.10.0')"
)
def get_release_branch(version: str) -> str:
major_minor_version = ".".join(version.split(".")[:2])
return f"branch-{major_minor_version}"
@dataclass(frozen=True)
class Commit:
sha: str
pr_num: int
date: str
def get_commit_count(branch: str, since: str) -> int:
"""
Get the total count of commits in the branch since the given date using GraphQL API.
"""
query = """
query($branch: String!, $since: GitTimestamp!) {
repository(owner: "mlflow", name: "mlflow") {
ref(qualifiedName: $branch) {
target {
... on Commit {
history(since: $since) {
totalCount
}
}
}
}
}
}
"""
response = requests.post(
"https://api.github.com/graphql",
json={"query": query, "variables": {"branch": branch, "since": since}},
headers=get_headers(),
)
response.raise_for_status()
data = response.json()
ref = data["data"]["repository"]["ref"]
if ref is None:
raise ValueError(f"Branch '{branch}' not found")
total_count: int = ref["target"]["history"]["totalCount"]
return total_count
def get_commits(branch: str) -> list[Commit]:
"""
Get the commits in the release branch via GitHub API (last 90 days).
Returns commits sorted by date (oldest first).
"""
per_page = 100
pr_rgx = re.compile(r".+\s+\(#(\d+)\)$")
since = (datetime.now(timezone.utc) - timedelta(days=90)).isoformat()
# Get total commit count first
total_count = get_commit_count(branch, since)
if total_count == 0:
print(f"No commits found in {branch} since {since}")
return []
total_pages = (total_count + per_page - 1) // per_page
print(f"Total commits: {total_count}, fetching {total_pages} page(s)...")
def fetch_page(page: int) -> list[Commit]:
print(f"Fetching page {page}/{total_pages}...")
params: dict[str, str | int] = {
"sha": branch,
"per_page": per_page,
"page": page,
"since": since,
}
response = requests.get(
"https://api.github.com/repos/mlflow/mlflow/commits",
params=params,
headers=get_headers(),
)
response.raise_for_status()
commits = []
for item in response.json():
msg = item["commit"]["message"].split("\n")[0]
if m := pr_rgx.search(msg):
# Use committer date (not author date) because cherry-picked commits
# retain the original author date but get a new committer date.
date = item["commit"]["committer"]["date"]
commits.append(Commit(sha=item["sha"], pr_num=int(m.group(1)), date=date))
return commits
# Fetch all pages in parallel. executor.map preserves order.
with concurrent.futures.ThreadPoolExecutor() as executor:
results = executor.map(fetch_page, range(1, total_pages + 1))
return sorted(itertools.chain.from_iterable(results), key=lambda c: c.date)
@dataclass(frozen=True)
class PR:
pr_num: int
merged: bool
def is_closed(pr: dict[str, Any]) -> bool:
return pr["state"] == "closed" and pr["pull_request"]["merged_at"] is None
def fetch_patch_prs(version: str) -> dict[int, bool]:
"""
Fetch PRs labeled with `v{version}` from the MLflow repository.
"""
label = f"v{version}"
per_page = 100
page = 1
pulls: list[dict[str, Any]] = []
while True:
response = requests.get(
f'https://api.github.com/search/issues?q=is:pr+repo:mlflow/mlflow+label:"{label}"&per_page={per_page}&page={page}',
headers=get_headers(),
)
response.raise_for_status()
data = response.json()
# Exclude closed PRs that are not merged
pulls.extend(pr for pr in data["items"] if not is_closed(pr))
if len(data["items"]) < per_page:
break
page += 1
return {pr["number"]: pr["pull_request"].get("merged_at") is not None for pr in pulls}
def main(version: str, dry_run: bool) -> None:
validate_version(version)
release_branch = get_release_branch(version)
commits = get_commits(release_branch)
patch_prs = fetch_patch_prs(version)
if not_cherry_picked := set(patch_prs) - {c.pr_num for c in commits}:
print(f"The following patch PRs are not cherry-picked to {release_branch}:")
for idx, pr_num in enumerate(sorted(not_cherry_picked)):
merged = patch_prs[pr_num]
url = f"https://github.com/mlflow/mlflow/pull/{pr_num} (merged: {merged})"
line = f" {idx + 1}. {url}"
if not merged:
line = f"\033[91m{line}\033[0m" # Red color using ANSI escape codes
print(line)
master_commits = get_commits("master")
cherry_picks = [c.sha for c in master_commits if c.pr_num in not_cherry_picked]
# Split into chunks if needed
chunks = chunk_list(cherry_picks, MAX_COMMITS_PER_SCRIPT)
# Print warning if splitting
if len(chunks) > 1:
print(
f"\n⚠️ WARNING: {len(cherry_picks)} commits will be split into "
f"{len(chunks)} scripts."
)
print("Create one PR per script and merge them sequentially:")
print(" file PR 1 → merge PR 1 → pull release branch → file PR 2 → merge PR 2 → ...")
print("This is required to stay under GitHub's 100-commit rebase merge limit.\n")
print("\n# Steps to cherry-pick the patch PRs:")
print(
f"1. Make sure your local master and {release_branch} branches are synced with "
"upstream."
)
print(f"2. Cut a new branch from {release_branch} (e.g. {release_branch}-cherry-picks).")
# Generate script(s)
tmp_dir = Path(tempfile.gettempdir())
script_paths: list[tuple[Path, int]] = []
for i, chunk in enumerate(chunks, 1):
if len(chunks) == 1:
script_path = tmp_dir / "cherry-pick.sh"
else:
script_path = tmp_dir / f"cherry-pick-{i}.sh"
script_content = f"""\
#!/usr/bin/env bash
# Cherry-picks for v{version} -> {release_branch}
# Generated: {datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")}
# Commits: {len(chunk)} of {len(cherry_picks)} total
#
# If conflicts occur, resolve them and run:
# git cherry-pick --continue
set -euo pipefail
# Guard: Prevent running on master branch
current_branch=$(git rev-parse --abbrev-ref HEAD)
if [[ "$current_branch" == "master" ]]; then
echo "ERROR: This script must not be run on the master branch."
echo "Please checkout a release branch (e.g., {release_branch}) or a branch derived from it."
exit 1
fi
git cherry-pick {" ".join(chunk)}
"""
script_path.write_text(script_content)
script_path.chmod(0o755)
script_paths.append((script_path, len(chunk)))
if len(chunks) == 1:
print("3. Run the cherry-pick script on the new branch:\n")
print(f"Cherry-pick script written to: {script_paths[0][0]}")
print(f"\n4. File a PR against {release_branch}.")
else:
print("3. For each script (in order):")
print(f" a. Create a new branch from {release_branch}")
print(" b. Run the script")
print(f" c. File a PR against {release_branch}")
print(f" d. After merge, pull {release_branch} from remote before the next script\n")
print(" Scripts:")
for script_path, commit_count in script_paths:
print(f" {script_path} ({commit_count} commits)")
sys.exit(0 if dry_run else 1)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--version", required=True, help="The version to release")
parser.add_argument(
"--dry-run",
action="store_true",
default=os.environ.get("DRY_RUN", "true").lower() == "true",
help="Dry run mode (default: True, can be set via DRY_RUN env var)",
)
parser.add_argument(
"--no-dry-run",
action="store_false",
dest="dry_run",
help="Disable dry run mode",
)
args = parser.parse_args()
main(args.version, args.dry_run)
+65
View File
@@ -0,0 +1,65 @@
import re
import sys
from pathlib import Path
from typing import Any
import yaml
# https://agentskills.io/specification#frontmatter
NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
NAME_MAX = 64
DESCRIPTION_MAX = 1024
def parse_frontmatter(text: str) -> dict[str, Any] | None:
if not text.startswith("---\n"):
return None
end = text.find("\n---\n", 4)
if end == -1:
return None
data = yaml.safe_load(text[4:end]) or {}
return data if isinstance(data, dict) else None
def validate_name(name: Any, parent: str) -> list[str]:
if not name:
return ["missing or empty `name`"]
if not isinstance(name, str) or not NAME_RE.fullmatch(name) or len(name) > NAME_MAX:
return [
f"invalid `name`: must be 1-{NAME_MAX} lowercase alphanumeric/hyphen chars, "
"no leading/trailing or consecutive hyphens"
]
if name != parent:
return [f"`name` {name!r} does not match parent directory {parent!r}"]
return []
def validate_description(description: Any) -> list[str]:
if not description:
return ["missing or empty `description`"]
if not isinstance(description, str) or len(description) > DESCRIPTION_MAX:
return [f"invalid `description`: must be 1-{DESCRIPTION_MAX} characters"]
return []
def check(path: Path) -> list[str]:
fm = parse_frontmatter(path.read_text(encoding="utf-8"))
if fm is None:
return ["missing frontmatter"]
return [
*validate_name(fm.get("name"), path.parent.name),
*validate_description(fm.get("description")),
]
def main(argv: list[str]) -> bool:
failed = False
for arg in argv:
for err in check(Path(arg)):
print(f"{arg}: {err}", file=sys.stderr)
failed = True
return failed
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+148
View File
@@ -0,0 +1,148 @@
"""
Detect files where all changes are whitespace-only.
This helps avoid unnecessary commit history noise from whitespace-only changes.
"""
import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.request
from typing import cast
BYPASS_LABEL = "allow-whitespace-only"
_MAX_ATTEMPTS = 3
def _is_retryable(exc: Exception) -> bool:
match exc:
case urllib.error.HTTPError(code=code):
return code >= 500
case urllib.error.URLError():
return True
case _:
return False
def _retry_urlopen(request: urllib.request.Request, timeout: int = 30) -> str:
for i in range(_MAX_ATTEMPTS):
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return cast(str, response.read().decode("utf-8"))
except Exception as exc:
if not _is_retryable(exc) or i == _MAX_ATTEMPTS - 1:
raise
time.sleep(2**i)
raise RuntimeError("unreachable")
def github_api_request(url: str, accept: str) -> str:
headers = {
"Accept": accept,
"X-GitHub-Api-Version": "2022-11-28",
}
if github_token := os.environ.get("GH_TOKEN"):
headers["Authorization"] = f"Bearer {github_token}"
request = urllib.request.Request(url, headers=headers)
return _retry_urlopen(request)
def get_pr_diff(owner: str, repo: str, pull_number: int) -> str:
url = f"https://github.com/{owner}/{repo}/pull/{pull_number}.diff"
request = urllib.request.Request(url)
return _retry_urlopen(request)
def get_pr_labels(owner: str, repo: str, pull_number: int) -> list[str]:
url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pull_number}"
data = json.loads(github_api_request(url, "application/vnd.github.v3+json"))
return [label_obj["name"] for label_obj in data.get("labels", [])]
def parse_diff(diff_text: str | None) -> list[str]:
if not diff_text:
return []
files: list[str] = []
current_file: str | None = None
changes: list[str] = []
for line in diff_text.split("\n"):
if line.startswith("diff --git"):
if current_file and changes and all(c.strip() == "" for c in changes):
files.append(current_file)
current_file = None
changes = []
elif line.startswith("--- a/"):
current_file = None if line == "--- /dev/null" else line[6:]
elif line.startswith("+++ b/"):
current_file = None if line == "+++ /dev/null" else line[6:]
elif line.startswith("+") or line.startswith("-"):
content = line[1:]
changes.append(content)
if current_file and changes and all(c.strip() == "" for c in changes):
files.append(current_file)
return files
def parse_args() -> tuple[str, str, int]:
parser = argparse.ArgumentParser(
description="Check for unnecessary whitespace-only changes in the diff"
)
parser.add_argument(
"--repo",
required=True,
help='Repository in the format "owner/repo" (e.g., "mlflow/mlflow")',
)
parser.add_argument(
"--pr",
type=int,
required=True,
help="Pull request number",
)
args = parser.parse_args()
owner, repo = args.repo.split("/")
return owner, repo, args.pr
def main() -> None:
owner, repo, pull_number = parse_args()
diff_text = get_pr_diff(owner, repo, pull_number)
if files := parse_diff(diff_text):
pr_labels = get_pr_labels(owner, repo, pull_number)
has_bypass_label = BYPASS_LABEL in pr_labels
level = "warning" if has_bypass_label else "error"
message = (
f"This file only has whitespace changes (bypassed with '{BYPASS_LABEL}' label)."
if has_bypass_label
else (
f"This file only has whitespace changes. "
f"Please revert them or apply the '{BYPASS_LABEL}' label to bypass this check "
f"if they are necessary."
)
)
for file_path in files:
# https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions
print(f"::{level} file={file_path},line=1,col=1::{message}")
if not has_bypass_label:
sys.exit(1)
if __name__ == "__main__":
main()
+86
View File
@@ -0,0 +1,86 @@
# Clint
A custom linter for mlflow to enforce rules that ruff doesn't cover.
## Installation
```
pip install -e dev/clint
```
## Usage
```bash
clint file.py ...
```
## Integrating with Visual Studio Code
1. Install [the Pylint extension](https://marketplace.visualstudio.com/items?itemName=ms-python.pylint)
2. Add the following setting in your `settings.json` file:
```json
{
"pylint.path": ["${interpreter}", "-m", "clint"]
}
```
## Ignoring Rules for Specific Files or Lines
**To ignore a rule on a specific line (recommended):**
```python
foo() # clint: disable=<rule_name>
```
Replace `<rule_name>` with the actual rule you want to disable.
To disable multiple rules on the same line, use comma-separated rule names:
```python
foo() # clint: disable=rule-a,rule-b
```
The rule name is shown in the error message. For example:
```
test_file.py:4:2: pytest-mark-repeat: @pytest.mark.repeat decorator...
```
Use the rule name (`pytest-mark-repeat`) in the disable comment:
```python
@pytest.mark.repeat(3) # clint: disable=pytest-mark-repeat
def test_something():
pass
```
**For multi-line constructs (docstrings, etc.), place the disable comment on the closing line:**
```python
def func():
"""
Docstring with [markdown link](url).
""" # clint: disable=markdown-link
pass
```
This works because the linter checks both the start and end lines of the violation range.
**To ignore a rule for an entire file:**
Add the file path to the `exclude` list in your `pyproject.toml`:
```toml
[tool.clint]
exclude = [
# ...existing entries...
"path/to/file.py",
]
```
## Testing
```bash
pytest dev/clint
```
+11
View File
@@ -0,0 +1,11 @@
[project]
name = "clint"
version = "0.1.0"
description = "A custom linter for mlflow to enforce rules that ruff doesn't cover."
readme = "README.md"
authors = [{ name = "mlflow", email = "mlflow@mlflow.com" }]
dependencies = ["tomli", "typing_extensions"]
[project.scripts]
clint = "clint:main"
+110
View File
@@ -0,0 +1,110 @@
import argparse
import itertools
import json
import re
import sys
import tempfile
from concurrent.futures import ProcessPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from typing_extensions import Self
from clint.config import Config
from clint.index import SymbolIndex
from clint.linter import Violation, lint_file
from clint.utils import get_repo_root, resolve_paths
_WORKER_INDEX: SymbolIndex | None = None
_WORKER_CONFIG: Config | None = None
def _init_worker(index_path: Path, config: Config) -> None:
global _WORKER_INDEX, _WORKER_CONFIG
_WORKER_INDEX = SymbolIndex.load(index_path)
_WORKER_CONFIG = config
def _worker_lint(path: Path, code: str) -> list[Violation]:
if _WORKER_INDEX is None or _WORKER_CONFIG is None:
raise RuntimeError(
"Worker not initialized; _init_worker must be called before _worker_lint"
)
return lint_file(path, code, _WORKER_CONFIG, _WORKER_INDEX)
@dataclass
class Args:
files: list[str]
output_format: Literal["text", "json"]
@classmethod
def parse(cls) -> Self:
parser = argparse.ArgumentParser(description="Custom linter for mlflow.")
parser.add_argument(
"files",
nargs="*",
help="Files to lint. If not specified, lints all files in the current directory.",
)
parser.add_argument("--output-format", default="text")
args, _ = parser.parse_known_args()
return cls(files=args.files, output_format=args.output_format)
def main() -> None:
config = Config.load()
args = Args.parse()
input_paths = [Path(f) for f in args.files]
resolved_files = resolve_paths(input_paths)
# Apply exclude filtering
files: list[Path] = []
if config.exclude:
repo_root = get_repo_root()
cwd = Path.cwd()
regex = re.compile("|".join(map(re.escape, config.exclude)))
for f in resolved_files:
# Convert file path to be relative to repo root for exclude pattern matching
repo_relative_path = (cwd / f).resolve().relative_to(repo_root)
if not regex.match(repo_relative_path.as_posix()):
files.append(f)
else:
files = resolved_files
# Exit early if no files to lint
if not files:
return
with tempfile.TemporaryDirectory() as tmp_dir:
# Pickle `SymbolIndex` to avoid expensive serialization overhead when passing
# the large index object to multiple worker processes
index_path = Path(tmp_dir) / "symbol_index.pkl"
SymbolIndex.build().save(index_path)
with ProcessPoolExecutor(initializer=_init_worker, initargs=(index_path, config)) as pool:
futures = [pool.submit(_worker_lint, f, f.read_text()) for f in files]
violations_iter = itertools.chain.from_iterable(
f.result() for f in as_completed(futures)
)
if violations := list(violations_iter):
if args.output_format == "json":
sys.stdout.write(json.dumps([v.json() for v in violations]))
elif args.output_format == "text":
sys.stderr.write("\n".join(map(str, violations)) + "\n")
count = len(violations)
label = "error" if count == 1 else "errors"
rule_label = "this rule" if count == 1 else "these rules"
print(
f"Found {count} {label}\n"
f"See dev/clint/README.md for instructions on ignoring {rule_label}.",
file=sys.stderr,
)
sys.exit(1)
else:
print("No errors found!", file=sys.stderr)
if __name__ == "__main__":
main()
+3
View File
@@ -0,0 +1,3 @@
from clint import main
main()
+216
View File
@@ -0,0 +1,216 @@
# https://github.com/PyCQA/isort/blob/b818cec889657cb786beafe94a6641f8fc0f0e64/isort/stdlibs/py311.py
BUILTIN_MODULES = {
"_ast",
"_thread",
"abc",
"aifc",
"argparse",
"array",
"ast",
"asynchat",
"asyncio",
"asyncore",
"atexit",
"audioop",
"base64",
"bdb",
"binascii",
"bisect",
"builtins",
"bz2",
"cProfile",
"calendar",
"cgi",
"cgitb",
"chunk",
"cmath",
"cmd",
"code",
"codecs",
"codeop",
"collections",
"colorsys",
"compileall",
"concurrent",
"configparser",
"contextlib",
"contextvars",
"copy",
"copyreg",
"crypt",
"csv",
"ctypes",
"curses",
"dataclasses",
"datetime",
"dbm",
"decimal",
"difflib",
"dis",
"distutils",
"doctest",
"email",
"encodings",
"ensurepip",
"enum",
"errno",
"faulthandler",
"fcntl",
"filecmp",
"fileinput",
"fnmatch",
"fractions",
"ftplib",
"functools",
"gc",
"getopt",
"getpass",
"gettext",
"glob",
"graphlib",
"grp",
"gzip",
"hashlib",
"heapq",
"hmac",
"html",
"http",
"idlelib",
"imaplib",
"imghdr",
"imp",
"importlib",
"inspect",
"io",
"ipaddress",
"itertools",
"json",
"keyword",
"lib2to3",
"linecache",
"locale",
"logging",
"lzma",
"mailbox",
"mailcap",
"marshal",
"math",
"mimetypes",
"mmap",
"modulefinder",
"msilib",
"msvcrt",
"multiprocessing",
"netrc",
"nis",
"nntplib",
"ntpath",
"numbers",
"operator",
"optparse",
"os",
"ossaudiodev",
"pathlib",
"pdb",
"pickle",
"pickletools",
"pipes",
"pkgutil",
"platform",
"plistlib",
"poplib",
"posix",
"posixpath",
"pprint",
"profile",
"pstats",
"pty",
"pwd",
"py_compile",
"pyclbr",
"pydoc",
"queue",
"quopri",
"random",
"re",
"readline",
"reprlib",
"resource",
"rlcompleter",
"runpy",
"sched",
"secrets",
"select",
"selectors",
"shelve",
"shlex",
"shutil",
"signal",
"site",
"smtpd",
"smtplib",
"sndhdr",
"socket",
"socketserver",
"spwd",
"sqlite3",
"sre",
"sre_compile",
"sre_constants",
"sre_parse",
"ssl",
"stat",
"statistics",
"string",
"stringprep",
"struct",
"subprocess",
"sunau",
"symtable",
"sys",
"sysconfig",
"syslog",
"tabnanny",
"tarfile",
"telnetlib",
"tempfile",
"termios",
"test",
"textwrap",
"threading",
"time",
"timeit",
"tkinter",
"token",
"tokenize",
"tomllib",
"trace",
"traceback",
"tracemalloc",
"tty",
"turtle",
"turtledemo",
"types",
"typing",
"unicodedata",
"unittest",
"urllib",
"uu",
"uuid",
"venv",
"warnings",
"wave",
"weakref",
"webbrowser",
"winreg",
"winsound",
"wsgiref",
"xdrlib",
"xml",
"xmlrpc",
"zipapp",
"zipfile",
"zipimport",
"zlib",
"zoneinfo",
}
+41
View File
@@ -0,0 +1,41 @@
import io
import re
import tokenize
from dataclasses import dataclass
from typing import TYPE_CHECKING, Iterator
from typing_extensions import Self
if TYPE_CHECKING:
from clint.linter import Position
NOQA_REGEX = re.compile(r"#\s*noqa\s*:\s*([A-Z]\d+(?:\s*,\s*[A-Z]\d+)*)", re.IGNORECASE)
@dataclass
class Noqa:
start: "Position"
end: "Position"
rules: set[str]
@classmethod
def from_token(cls, token: tokenize.TokenInfo) -> Self | None:
from clint.linter import Position
if match := NOQA_REGEX.match(token.string):
rules = {r.strip() for r in match.group(1).upper().split(",")}
start = Position(token.start[0], token.start[1])
end = Position(token.end[0], token.end[1])
return cls(start=start, end=end, rules=rules)
return None
def iter_comments(code: str) -> Iterator[tokenize.TokenInfo]:
readline = io.StringIO(code).readline
try:
for token in tokenize.generate_tokens(readline):
if token.type == tokenize.COMMENT:
yield token
except tokenize.TokenError:
# Handle incomplete tokens at end of file
pass
+110
View File
@@ -0,0 +1,110 @@
import re
import typing
from dataclasses import dataclass, field
import tomli
from typing_extensions import Self
from clint.rules import ALL_RULES
from clint.utils import get_repo_root
def _validate_exclude_paths(exclude_paths: list[str]) -> None:
"""Validate that all paths in the exclude list exist.
Args:
exclude_paths: List of file/directory paths to validate (relative to repo root)
Raises:
ValueError: If any path in the exclude list does not exist
"""
if not exclude_paths:
return
repo_root = get_repo_root()
if non_existing_paths := [path for path in exclude_paths if not (repo_root / path).exists()]:
raise ValueError(
f"Non-existing paths found in exclude field: {non_existing_paths}. "
f"All paths in the exclude list must exist."
)
def _validate_typing_extensions_allowlist(allowlist: list[str]) -> None:
"""Validate that the typing-extensions-allowlist doesn't contain stdlib items.
Args:
allowlist: List of typing_extensions items to validate
Raises:
ValueError: If any item in the allowlist is available in stdlib typing
"""
if not allowlist:
return
# Extract the item name from full paths like "typing_extensions.overload"
# and check if it's available in stdlib typing
stdlib_items = []
for item in allowlist:
name = item.rsplit(".", 1)[-1]
if hasattr(typing, name):
stdlib_items.append(item)
if stdlib_items:
raise ValueError(
f"Items in typing-extensions-allowlist are available in stdlib `typing`: "
f"{stdlib_items}. Use `from typing import ...` instead."
)
@dataclass
class Config:
select: set[str] = field(default_factory=set)
exclude: list[str] = field(default_factory=list)
# Path -> List of modules that should not be imported globally under that path
forbidden_top_level_imports: dict[str, list[str]] = field(default_factory=dict)
typing_extensions_allowlist: list[str] = field(default_factory=list)
example_rules: list[str] = field(default_factory=list)
# Compiled regex pattern -> Set of rule names to ignore for files matching the pattern
per_file_ignores: dict[re.Pattern[str], set[str]] = field(default_factory=dict)
@classmethod
def load(cls) -> Self:
repo_root = get_repo_root()
pyproject = repo_root / "pyproject.toml"
if not pyproject.exists():
return cls()
with pyproject.open("rb") as f:
data = tomli.load(f)
clint = data.get("tool", {}).get("clint", {})
if not clint:
return cls()
per_file_ignores_raw = clint.get("per-file-ignores", {})
per_file_ignores: dict[re.Pattern[str], set[str]] = {}
for pattern, rules in per_file_ignores_raw.items():
per_file_ignores[re.compile(pattern)] = set(rules)
select = clint.get("select")
if select is None:
select = ALL_RULES
else:
if unknown_rules := set(select) - ALL_RULES:
raise ValueError(f"Unknown rules in 'select': {unknown_rules}")
select = set(select)
exclude_paths = clint.get("exclude", [])
_validate_exclude_paths(exclude_paths)
typing_extensions_allowlist = clint.get("typing-extensions-allowlist", [])
_validate_typing_extensions_allowlist(typing_extensions_allowlist)
return cls(
select=select,
exclude=exclude_paths,
forbidden_top_level_imports=clint.get("forbidden-top-level-imports", {}),
typing_extensions_allowlist=typing_extensions_allowlist,
example_rules=clint.get("example-rules", []),
per_file_ignores=per_file_ignores,
)
+221
View File
@@ -0,0 +1,221 @@
"""Symbol indexing for MLflow codebase.
This module provides efficient indexing and lookup of Python symbols (functions, classes)
across the MLflow codebase using AST parsing and parallel processing.
Key components:
- FunctionInfo: Lightweight function signature information
- ModuleSymbolExtractor: AST visitor for extracting symbols from modules
- SymbolIndex: Main index class for symbol resolution and lookup
Example usage:
```python
# Build an index of all MLflow symbols
index = SymbolIndex.build()
# Look up function signature information
func_info = index.resolve("mlflow.log_metric")
print(f"Arguments: {func_info.args}") # -> ['key, 'value', 'step', ...]
```
"""
import ast
import multiprocessing
import pickle
import subprocess
from concurrent.futures import ProcessPoolExecutor, as_completed
from dataclasses import dataclass, field
from pathlib import Path
from typing_extensions import Self
from clint.utils import get_repo_root
@dataclass
class FunctionInfo:
"""Lightweight function signature information for efficient serialization."""
has_vararg: bool # *args
has_kwarg: bool # **kwargs
args: list[str] = field(default_factory=list) # Regular arguments
kwonlyargs: list[str] = field(default_factory=list) # Keyword-only arguments
posonlyargs: list[str] = field(default_factory=list) # Positional-only arguments
@classmethod
def from_func_def(
cls, node: ast.FunctionDef | ast.AsyncFunctionDef, skip_self: bool = False
) -> Self:
"""Create FunctionInfo from an AST function definition node."""
args = node.args.args
if skip_self and args:
args = args[1:] # Skip 'self' for methods
return cls(
has_vararg=node.args.vararg is not None,
has_kwarg=node.args.kwarg is not None,
args=[arg.arg for arg in args],
kwonlyargs=[arg.arg for arg in node.args.kwonlyargs],
posonlyargs=[arg.arg for arg in node.args.posonlyargs],
)
@property
def all_args(self) -> list[str]:
return self.posonlyargs + self.args + self.kwonlyargs
class ModuleSymbolExtractor(ast.NodeVisitor):
"""Extracts function definitions and import mappings from a Python module."""
def __init__(self, mod: str) -> None:
self.mod = mod
self.import_mapping: dict[str, str] = {}
self.func_mapping: dict[str, FunctionInfo] = {}
def visit_Import(self, node: ast.Import) -> None:
for alias in node.names:
if not alias.name.startswith("mlflow."):
continue
if alias.asname:
self.import_mapping[f"{self.mod}.{alias.asname}"] = alias.name
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
if node.module is None or not node.module.startswith("mlflow."):
return
for alias in node.names:
if alias.name.startswith("_"):
continue
if alias.asname:
self.import_mapping[f"{self.mod}.{alias.asname}"] = f"{node.module}.{alias.name}"
else:
self.import_mapping[f"{self.mod}.{alias.name}"] = f"{node.module}.{alias.name}"
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
if node.name.startswith("_"):
return
self.func_mapping[f"{self.mod}.{node.name}"] = FunctionInfo.from_func_def(node)
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
if node.name.startswith("_"):
return
self.func_mapping[f"{self.mod}.{node.name}"] = FunctionInfo.from_func_def(node)
def visit_ClassDef(self, node: ast.ClassDef) -> None:
for stmt in node.body:
if isinstance(stmt, ast.FunctionDef):
if stmt.name == "__init__":
info = FunctionInfo.from_func_def(stmt, skip_self=True)
self.func_mapping[f"{self.mod}.{node.name}"] = info
elif any(
isinstance(deco, ast.Name) and deco.id in ("classmethod", "staticmethod")
for deco in stmt.decorator_list
):
info = FunctionInfo.from_func_def(stmt, skip_self=True)
self.func_mapping[f"{self.mod}.{node.name}.{stmt.name}"] = info
else:
# If no __init__ found, still add the class with *args and **kwargs
self.func_mapping[f"{self.mod}.{node.name}"] = FunctionInfo(
has_vararg=True, has_kwarg=True
)
def extract_symbols_from_file(
rel_path: str, content: str
) -> tuple[dict[str, str], dict[str, FunctionInfo]] | None:
"""Extract function definitions and import mappings from a Python file."""
p = Path(rel_path)
if not p.parts or p.parts[0] != "mlflow":
return None
try:
tree = ast.parse(content)
except (SyntaxError, UnicodeDecodeError):
return None
mod_name = (
".".join(p.parts[:-1]) if p.name == "__init__.py" else ".".join([*p.parts[:-1], p.stem])
)
extractor = ModuleSymbolExtractor(mod_name)
extractor.visit(tree)
return extractor.import_mapping, extractor.func_mapping
class SymbolIndex:
"""Index of all symbols (functions, classes) in the MLflow codebase."""
def __init__(
self,
import_mapping: dict[str, str],
func_mapping: dict[str, FunctionInfo],
) -> None:
self.import_mapping = import_mapping
self.func_mapping = func_mapping
def save(self, path: Path) -> None:
with path.open("wb") as f:
pickle.dump((self.import_mapping, self.func_mapping), f)
@classmethod
def load(cls, path: Path) -> Self:
with path.open("rb") as f:
import_mapping, func_mapping = pickle.load(f)
return cls(import_mapping, func_mapping)
@classmethod
def build(cls) -> Self:
repo_root = get_repo_root()
py_files = subprocess.check_output(
["git", "-C", repo_root, "ls-files", "mlflow/*.py"], text=True
).splitlines()
mapping: dict[str, str] = {}
func_mapping: dict[str, FunctionInfo] = {}
# Ensure at least 1 worker to avoid ProcessPoolExecutor ValueError
max_workers = max(1, min(multiprocessing.cpu_count(), len(py_files)))
with ProcessPoolExecutor(max_workers=max_workers) as executor:
futures = {}
for py_file in py_files:
abs_file_path = repo_root / py_file
if not abs_file_path.exists():
continue
content = abs_file_path.read_text()
f = executor.submit(extract_symbols_from_file, py_file, content)
futures[f] = py_file
for future in as_completed(futures):
if result := future.result():
file_imports, file_functions = result
mapping.update(file_imports)
func_mapping.update(file_functions)
return cls(mapping, func_mapping)
def _resolve_import(self, target: str) -> str:
resolved = target
seen = {resolved}
while v := self.import_mapping.get(resolved):
if v in seen:
# Circular import detected, break to avoid infinite loop
break
seen.add(v)
resolved = v
return resolved
def resolve(self, target: str) -> FunctionInfo | None:
"""Resolve a symbol to its actual definition, following import chains."""
if f := self.func_mapping.get(target):
return f
resolved = self._resolve_import(target)
if f := self.func_mapping.get(resolved):
return f
target, tail = target.rsplit(".", 1)
resolved = self._resolve_import(target)
if f := self.func_mapping.get(f"{resolved}.{tail}"):
return f
return None
File diff suppressed because it is too large Load Diff
View File
+89
View File
@@ -0,0 +1,89 @@
import ast
from collections.abc import Iterator
from contextlib import contextmanager
class Resolver:
def __init__(self) -> None:
self.name_map: dict[str, list[str]] = {}
self._scope_stack: list[dict[str, list[str]]] = []
def clear(self) -> None:
"""Clear all name mappings. Useful when starting to process a new file."""
self.name_map.clear()
self._scope_stack.clear()
def enter_scope(self) -> None:
"""Enter a new scope by taking a snapshot of current mappings."""
self._scope_stack.append(self.name_map.copy())
def exit_scope(self) -> None:
"""Exit current scope by restoring the previous snapshot."""
if self._scope_stack:
self.name_map = self._scope_stack.pop()
@contextmanager
def scope(self) -> Iterator[None]:
"""Context manager for automatic scope management."""
self.enter_scope()
try:
yield
finally:
self.exit_scope()
def add_import(self, node: ast.Import) -> None:
for alias in node.names:
if alias.asname:
self.name_map[alias.asname] = alias.name.split(".")
else:
toplevel = alias.name.split(".", 1)[0]
self.name_map[toplevel] = [toplevel]
def add_import_from(self, node: ast.ImportFrom) -> None:
if node.module is None:
return
for alias in node.names:
name = alias.asname or alias.name
module_parts = node.module.split(".")
self.name_map[name] = module_parts + [alias.name]
def resolve(self, node: ast.expr) -> list[str] | None:
"""
Resolve a node to its fully qualified name parts.
Args:
node: AST node to resolve, typically a Call, Name, or Attribute.
Returns:
List of name parts (e.g., ["threading", "Thread"]) or None if unresolvable
"""
if isinstance(node, ast.Call):
parts = self._extract_call_parts(node.func)
elif isinstance(node, ast.Name):
parts = [node.id]
elif isinstance(node, ast.Attribute):
parts = self._extract_call_parts(node)
else:
return None
return self._resolve_parts(parts) if parts else None
def _extract_call_parts(self, node: ast.expr) -> list[str]:
if isinstance(node, ast.Name):
return [node.id]
elif isinstance(node, ast.Attribute) and (
base_parts := self._extract_call_parts(node.value)
):
return base_parts + [node.attr]
return []
def _resolve_parts(self, parts: list[str]) -> list[str] | None:
if not parts:
return None
# Check if the first part is in our name mapping
if resolved_base := self.name_map.get(parts[0]):
return resolved_base + parts[1:]
return None
+122
View File
@@ -0,0 +1,122 @@
from clint.rules.assign_before_append import AssignBeforeAppend
from clint.rules.base import Rule
from clint.rules.do_not_disable import DoNotDisable
from clint.rules.docstring_param_order import DocstringParamOrder
from clint.rules.empty_notebook_cell import EmptyNotebookCell
from clint.rules.example_syntax_error import ExampleSyntaxError
from clint.rules.except_bool_op import ExceptBoolOp
from clint.rules.extraneous_docstring_param import ExtraneousDocstringParam
from clint.rules.forbidden_deprecation_warning import ForbiddenDeprecationWarning
from clint.rules.forbidden_make_judge_in_builtin_scorers import (
ForbiddenMakeJudgeInBuiltinScorers,
)
from clint.rules.forbidden_set_active_model_usage import ForbiddenSetActiveModelUsage
from clint.rules.forbidden_top_level_import import ForbiddenTopLevelImport
from clint.rules.forbidden_trace_ui_in_notebook import ForbiddenTraceUIInNotebook
from clint.rules.get_artifact_uri import GetArtifactUri
from clint.rules.implicit_optional import ImplicitOptional
from clint.rules.incorrect_type_annotation import IncorrectTypeAnnotation
from clint.rules.invalid_abstract_method import InvalidAbstractMethod
from clint.rules.invalid_experimental_decorator import InvalidExperimentalDecorator
from clint.rules.isinstance_union_syntax import IsinstanceUnionSyntax
from clint.rules.lazy_import import LazyImport
from clint.rules.lazy_module import LazyModule
from clint.rules.log_model_artifact_path import LogModelArtifactPath
from clint.rules.markdown_link import MarkdownLink
from clint.rules.missing_docstring_param import MissingDocstringParam
from clint.rules.missing_notebook_h1_header import MissingNotebookH1Header
from clint.rules.mlflow_class_name import MlflowClassName
from clint.rules.mock_patch_as_decorator import MockPatchAsDecorator
from clint.rules.mock_patch_dict_environ import MockPatchDictEnviron
from clint.rules.multi_assign import MultiAssign
from clint.rules.nested_mock_patch import NestedMockPatch
from clint.rules.no_class_based_tests import NoClassBasedTests
from clint.rules.no_rst import NoRst
from clint.rules.no_shebang import NoShebang
from clint.rules.os_chdir_in_test import OsChdirInTest
from clint.rules.os_environ_delete_in_test import OsEnvironDeleteInTest
from clint.rules.os_environ_set_in_test import OsEnvironSetInTest
from clint.rules.prefer_dict_union import PreferDictUnion
from clint.rules.prefer_next import PreferNext
from clint.rules.prefer_os_environ import PreferOsEnviron
from clint.rules.pytest_mark_repeat import PytestMarkRepeat
from clint.rules.redundant_mock_return_value import RedundantMockReturnValue
from clint.rules.redundant_test_docstring import RedundantTestDocstring
from clint.rules.subprocess_check_call import SubprocessCheckCall
from clint.rules.tempfile_in_test import TempfileInTest
from clint.rules.test_name_typo import TestNameTypo
from clint.rules.typing_extensions import TypingExtensions
from clint.rules.unknown_mlflow_arguments import UnknownMlflowArguments
from clint.rules.unknown_mlflow_function import UnknownMlflowFunction
from clint.rules.unnamed_thread import UnnamedThread
from clint.rules.unnamed_thread_pool import UnnamedThreadPool
from clint.rules.unparameterized_generic_type import UnparameterizedGenericType
from clint.rules.unused_disable_comment import UnusedDisableComment
from clint.rules.use_gh_token import UseGhToken
from clint.rules.use_sys_executable import UseSysExecutable
from clint.rules.use_walrus_operator import UseWalrusOperator, WalrusOperatorVisitor
from clint.rules.version_major_check import MajorVersionCheck
ALL_RULES = {rule.name for rule in Rule.__subclasses__()}
__all__ = [
"ALL_RULES",
"Rule",
"DoNotDisable",
"DocstringParamOrder",
"EmptyNotebookCell",
"ExampleSyntaxError",
"ExceptBoolOp",
"ExtraneousDocstringParam",
"ForbiddenDeprecationWarning",
"ForbiddenMakeJudgeInBuiltinScorers",
"ForbiddenSetActiveModelUsage",
"ForbiddenTopLevelImport",
"GetArtifactUri",
"ForbiddenTraceUIInNotebook",
"ImplicitOptional",
"IncorrectTypeAnnotation",
"IsinstanceUnionSyntax",
"InvalidAbstractMethod",
"InvalidExperimentalDecorator",
"LazyImport",
"LazyModule",
"LogModelArtifactPath",
"MarkdownLink",
"MissingDocstringParam",
"MissingNotebookH1Header",
"MlflowClassName",
"MockPatchDictEnviron",
"MockPatchAsDecorator",
"NestedMockPatch",
"NoClassBasedTests",
"NoRst",
"NoShebang",
"OsChdirInTest",
"OsEnvironDeleteInTest",
"OsEnvironSetInTest",
"PreferDictUnion",
"PreferNext",
"PreferOsEnviron",
"PytestMarkRepeat",
"RedundantMockReturnValue",
"RedundantTestDocstring",
"SubprocessCheckCall",
"TempfileInTest",
"TestNameTypo",
"UnnamedThreadPool",
"TypingExtensions",
"UnknownMlflowArguments",
"UnknownMlflowFunction",
"MultiAssign",
"UnnamedThread",
"UnparameterizedGenericType",
"UnusedDisableComment",
"AssignBeforeAppend",
"UseGhToken",
"UseSysExecutable",
"UseWalrusOperator",
"WalrusOperatorVisitor",
"MajorVersionCheck",
]
@@ -0,0 +1,64 @@
import ast
from clint.rules.base import Rule
class AssignBeforeAppend(Rule):
def _message(self) -> str:
return (
"Avoid unnecessary assignment before appending to a list. "
"Use a list comprehension instead."
)
@staticmethod
def check(node: ast.For, prev_stmt: ast.stmt | None) -> bool:
"""
Returns True if the for loop contains exactly two statements:
an assignment followed by appending that variable to a list, AND
the loop is immediately preceded by an empty list initialization.
Examples that should be flagged:
---
items = []
for x in data:
item = transform(x)
items.append(item)
---
"""
# Match: for loop with exactly 2 statements in body
match node:
case ast.For(body=[stmt1, stmt2]):
pass
case _:
return False
# Match stmt1: simple assignment (item = x)
match stmt1:
case ast.Assign(targets=[ast.Name(id=assigned_var)]):
pass
case _:
return False
# Match stmt2: list.append(item)
match stmt2:
case ast.Expr(
value=ast.Call(
func=ast.Attribute(value=ast.Name(id=list_name), attr="append"),
args=[ast.Name(id=appended_var)],
)
):
# Check if the appended variable is the same as the assigned variable
if appended_var != assigned_var:
return False
case _:
return False
# Only flag if prev_stmt is empty list initialization for the same list
match prev_stmt:
case ast.Assign(
targets=[ast.Name(id=prev_list_name)],
value=ast.List(elts=[]),
) if prev_list_name == list_name:
return True
case _:
return False
+31
View File
@@ -0,0 +1,31 @@
import inspect
import itertools
import re
from abc import ABC, abstractmethod
from typing import Any
_id_counter = itertools.count(start=1)
_CLASS_NAME_TO_RULE_NAME_REGEX = re.compile(r"(?<!^)(?=[A-Z])")
class Rule(ABC):
id: str
name: str
def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
# Only generate ID for concrete classes
if not inspect.isabstract(cls):
id_ = next(_id_counter)
cls.id = f"MLF{id_:04d}"
cls.name = _CLASS_NAME_TO_RULE_NAME_REGEX.sub("-", cls.__name__).lower()
@abstractmethod
def _message(self) -> str:
"""
Return a message that explains this rule.
"""
@property
def message(self) -> str:
return self._message()
@@ -0,0 +1,29 @@
from typing_extensions import Self
from clint.rules.base import Rule
class DoNotDisable(Rule):
RULES = {
"B006": "Use None as default and set value in function body instead of mutable defaults",
"F821": "Use typing.TYPE_CHECKING for forward references to optional dependencies",
}
def __init__(self, rules: set[str]) -> None:
self.rules = rules
@classmethod
def check(cls, rules: set[str]) -> Self | None:
if s := rules.intersection(DoNotDisable.RULES.keys()):
return cls(s)
return None
def _message(self) -> str:
# Build message for all rules (works for single and multiple rules)
hints = []
for rule in sorted(self.rules):
if hint := DoNotDisable.RULES.get(rule):
hints.append(f"{rule}: {hint}")
else:
hints.append(rule)
return f"DO NOT DISABLE {', '.join(hints)}"
@@ -0,0 +1,9 @@
from clint.rules.base import Rule
class DocstringParamOrder(Rule):
def __init__(self, params: list[str]) -> None:
self.params = params
def _message(self) -> str:
return f"Unordered parameters in docstring: {self.params}"
@@ -0,0 +1,6 @@
from clint.rules.base import Rule
class EmptyNotebookCell(Rule):
def _message(self) -> str:
return "Empty notebook cell. Remove it or add some content."
@@ -0,0 +1,6 @@
from clint.rules.base import Rule
class ExampleSyntaxError(Rule):
def _message(self) -> str:
return "This example has a syntax error."
@@ -0,0 +1,14 @@
import ast
from clint.rules.base import Rule
class ExceptBoolOp(Rule):
def _message(self) -> str:
return (
"Did you mean `except (X, Y):`? Using or/and in an except handler is likely a mistake."
)
@staticmethod
def check(node: ast.ExceptHandler) -> bool:
return isinstance(node.type, ast.BoolOp)
@@ -0,0 +1,9 @@
from clint.rules.base import Rule
class ExtraneousDocstringParam(Rule):
def __init__(self, params: set[str]) -> None:
self.params = params
def _message(self) -> str:
return f"Extraneous parameters in docstring: {self.params}"
@@ -0,0 +1,34 @@
import ast
from clint.resolver import Resolver
from clint.rules.base import Rule
def _is_deprecation_warning(expr: ast.expr) -> bool:
return isinstance(expr, ast.Name) and expr.id == "DeprecationWarning"
class ForbiddenDeprecationWarning(Rule):
def _message(self) -> str:
return (
"Do not use `DeprecationWarning` with `warnings.warn()`. "
"Use `FutureWarning` instead since Python does not show `DeprecationWarning` "
"by default."
)
@staticmethod
def check(node: ast.Call, resolver: Resolver) -> ast.expr | None:
"""
Checks if the given node is a call to `warnings.warn` with `DeprecationWarning`.
"""
# Check if this is a call to `warnings.warn`
if (resolved := resolver.resolve(node.func)) and resolved == ["warnings", "warn"]:
# Check if there's a `category` positional argument with `DeprecationWarning`
if len(node.args) >= 2 and _is_deprecation_warning(node.args[1]):
return node.args[1]
# Check if there's a `category` keyword argument with `DeprecationWarning`
elif kw := next((kw.value for kw in node.keywords if kw.arg == "category"), None):
if _is_deprecation_warning(kw):
return kw
return None
@@ -0,0 +1,46 @@
import ast
from pathlib import Path
from clint.resolver import Resolver
from clint.rules.base import Rule
class ForbiddenMakeJudgeInBuiltinScorers(Rule):
"""Ensure make_judge is not used in builtin_scorers.py.
After switching to InstructionsJudge in builtin_scorers.py, this rule
prevents future regressions by detecting any usage of make_judge in that file.
"""
def _message(self) -> str:
return (
"Usage of `make_judge` is forbidden in builtin_scorers.py. "
"Use `InstructionsJudge` directly instead."
)
@staticmethod
def check(node: ast.Call, resolver: Resolver, path: Path) -> bool:
"""Check if this is a call to make_judge in builtin_scorers.py.
Args:
node: The AST Call node to check
resolver: Resolver instance to resolve fully qualified names
path: Path to the file being linted
Returns:
True if this is a forbidden make_judge call, False otherwise
"""
if path.name != "builtin_scorers.py":
return False
if names := resolver.resolve(node):
match names:
case ["mlflow", "genai", "judges", "make_judge", *_]:
return True
case ["mlflow", "genai", "make_judge", *_]:
return True
case ["make_judge", *_]:
return True
case _:
return False
return False
@@ -0,0 +1,22 @@
import ast
from clint.resolver import Resolver
from clint.rules.base import Rule
class ForbiddenSetActiveModelUsage(Rule):
def _message(self) -> str:
return (
"Usage of `set_active_model` is not allowed in mlflow, use `_set_active_model` instead."
)
@staticmethod
def check(node: ast.Call, resolver: Resolver) -> bool:
"""Check if this is a call to set_active_model function."""
if names := resolver.resolve(node):
match names:
case ["mlflow", *_, "set_active_model"]:
return True
case _:
return False
return False
@@ -0,0 +1,12 @@
from clint.rules.base import Rule
class ForbiddenTopLevelImport(Rule):
def __init__(self, module: str) -> None:
self.module = module
def _message(self) -> str:
return (
f"Importing module `{self.module}` at the top level is not allowed "
"in this file. Use lazy import instead."
)
@@ -0,0 +1,11 @@
from clint.rules.base import Rule
class ForbiddenTraceUIInNotebook(Rule):
def _message(self) -> str:
return (
"Found the MLflow Trace UI iframe in the notebook. "
"The trace UI in cell outputs will not render correctly in previews or the website. "
"Please run `mlflow.tracing.disable_notebook_display()` and rerun the cell "
"to remove the iframe."
)
@@ -0,0 +1,9 @@
from clint.rules.base import Rule
class GetArtifactUri(Rule):
def _message(self) -> str:
return (
"`mlflow.get_artifact_uri` should not be used in examples. "
"Use the return value of `log_model` instead."
)
@@ -0,0 +1,60 @@
import ast
from clint.rules.base import Rule
class ImplicitOptional(Rule):
def _message(self) -> str:
return "Use `Optional` if default value is `None`"
@staticmethod
def check(node: ast.AnnAssign) -> bool:
"""
Returns True if the value to assign is `None` but the type annotation is
not `Optional[...]` or `... | None`. For example: `a: int = None`.
"""
if not ImplicitOptional._is_none(node.value):
return False
# Parse stringified annotations
if isinstance(node.annotation, ast.Constant) and isinstance(node.annotation.value, str):
try:
parsed = ast.parse(node.annotation.value, mode="eval")
ann = parsed.body
except (SyntaxError, ValueError):
# If parsing fails, the annotation is invalid and we trigger the rule
# since we cannot verify it contains Optional or | None
return True
else:
ann = node.annotation
return not (ImplicitOptional._is_optional(ann) or ImplicitOptional._is_bitor_none(ann))
@staticmethod
def _is_optional(ann: ast.expr) -> bool:
"""
Returns True if `ann` looks like `Optional[...]`.
"""
return (
isinstance(ann, ast.Subscript)
and isinstance(ann.value, ast.Name)
and ann.value.id == "Optional"
)
@staticmethod
def _is_bitor_none(ann: ast.expr) -> bool:
"""
Returns True if `ann` looks like `... | None`.
"""
return (
isinstance(ann, ast.BinOp)
and isinstance(ann.op, ast.BitOr)
and (isinstance(ann.right, ast.Constant) and ann.right.value is None)
)
@staticmethod
def _is_none(value: ast.expr | None) -> bool:
"""
Returns True if `value` represents `None`.
"""
return isinstance(value, ast.Constant) and value.value is None
@@ -0,0 +1,25 @@
import ast
from clint.rules.base import Rule
class IncorrectTypeAnnotation(Rule):
MAPPING = {
"callable": "Callable",
"any": "Any",
}
def __init__(self, type_hint: str) -> None:
self.type_hint = type_hint
@staticmethod
def check(node: ast.Name) -> bool:
return node.id in IncorrectTypeAnnotation.MAPPING
def _message(self) -> str:
if correct_hint := self.MAPPING.get(self.type_hint):
return f"Did you mean `{correct_hint}` instead of `{self.type_hint}`?"
raise ValueError(
f"Unexpected type: {self.type_hint}. It must be one of {list(self.MAPPING)}."
)
@@ -0,0 +1,50 @@
import ast
from clint.resolver import Resolver
from clint.rules.base import Rule
class InvalidAbstractMethod(Rule):
def _message(self) -> str:
return (
"Abstract method should only contain a single statement/expression, "
"and it must be `pass`, `...`, or a docstring."
)
@staticmethod
def _is_abstract_method(
node: ast.FunctionDef | ast.AsyncFunctionDef, resolver: Resolver
) -> bool:
return any(
(resolved := resolver.resolve(d)) and resolved == ["abc", "abstractmethod"]
for d in node.decorator_list
)
@staticmethod
def _has_invalid_body(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
# Does this abstract method have multiple statements/expressions?
if len(node.body) > 1:
return True
# This abstract method has a single statement/expression.
# Check if it's `pass`, `...`, or a docstring. If not, it's invalid.
stmt = node.body[0]
# Check for `pass`
if isinstance(stmt, ast.Pass):
return False
# Check for `...` or docstring
if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant):
value = stmt.value.value
# `...` literal or docstring
return not (value is ... or isinstance(value, str))
# Any other statement is invalid
return True
@staticmethod
def check(node: ast.FunctionDef | ast.AsyncFunctionDef, resolver: Resolver) -> bool:
return InvalidAbstractMethod._is_abstract_method(
node, resolver
) and InvalidAbstractMethod._has_invalid_body(node)
@@ -0,0 +1,53 @@
import ast
from packaging.version import InvalidVersion, Version
from clint.resolver import Resolver
from clint.rules.base import Rule
def _is_valid_version(version: str) -> bool:
try:
v = Version(version)
return not (v.is_devrelease or v.is_prerelease or v.is_postrelease)
except InvalidVersion:
return False
class InvalidExperimentalDecorator(Rule):
def _message(self) -> str:
return (
"Invalid usage of `@experimental` decorator. It must be used with a `version` "
"argument that is a valid semantic version string."
)
@staticmethod
def check(node: ast.expr, resolver: Resolver) -> bool:
"""
Returns True if the `@experimental` decorator from mlflow.utils.annotations is used
incorrectly.
"""
resolved = resolver.resolve(node)
if not resolved:
return False
if resolved != ["mlflow", "utils", "annotations", "experimental"]:
return False
if not isinstance(node, ast.Call):
return True
version = next((k.value for k in node.keywords if k.arg == "version"), None)
if version is None:
# No `version` argument, invalid usage
return True
if not isinstance(version, ast.Constant) or not isinstance(version.value, str):
# `version` is not a string literal, invalid usage
return True
if not _is_valid_version(version.value):
# `version` is not a valid semantic version, # invalid usage
return True
return False
@@ -0,0 +1,52 @@
import ast
from clint.rules.base import Rule
class IsinstanceUnionSyntax(Rule):
def _message(self) -> str:
return (
"Use `isinstance(obj, (X, Y))` instead of `isinstance(obj, X | Y)`. "
"The union syntax with `|` is slower than using a tuple of types."
)
@staticmethod
def check(node: ast.Call) -> bool:
"""
Returns True if the call is isinstance with union syntax (X | Y) in the second argument.
Examples that should be flagged:
- isinstance(obj, str | int)
- isinstance(obj, int | str | float)
- isinstance(value, (dict | list))
Examples that should NOT be flagged:
- isinstance(obj, (str, int))
- isinstance(obj, str)
- other_func(obj, str | int)
"""
# Check if this is an isinstance call
if not (isinstance(node.func, ast.Name) and node.func.id == "isinstance"):
return False
# Check if the second argument uses union syntax (X | Y)
match node.args:
case [_, type_arg]:
return IsinstanceUnionSyntax._has_union_syntax(type_arg)
case _:
return False
@staticmethod
def _has_union_syntax(node: ast.expr) -> bool:
"""
Returns True if the node contains union syntax with BitOr operator.
This handles nested cases like (A | B) | C.
"""
match node:
case ast.BinOp(op=ast.BitOr()):
return True
case ast.Tuple(elts=elts):
# Check if any element in the tuple has union syntax
return any(map(IsinstanceUnionSyntax._has_union_syntax, elts))
case _:
return False
+26
View File
@@ -0,0 +1,26 @@
from clint.builtin import BUILTIN_MODULES
from clint.rules.base import Rule
# Third-party packages that are always available as core dependencies of mlflow-tracing
# (the smallest installable unit of MLflow). Lazy imports of these packages are flagged
# the same way as stdlib lazy imports.
_ALWAYS_AVAILABLE_MODULES = {
"cachetools",
"packaging",
"pydantic",
}
_LAZY_IMPORT_MODULES = BUILTIN_MODULES | _ALWAYS_AVAILABLE_MODULES
class LazyImport(Rule):
def _message(self) -> str:
return "This module must be imported at the top level."
@staticmethod
def check(module: str | None) -> bool:
"""Check if importing the given module lazily should be flagged."""
if module is None:
return False
root_module = module.split(".", 1)[0]
return root_module in _LAZY_IMPORT_MODULES
+6
View File
@@ -0,0 +1,6 @@
from clint.rules.base import Rule
class LazyModule(Rule):
def _message(self) -> str:
return "Module loaded by `LazyLoader` must be imported in `TYPE_CHECKING` block."
@@ -0,0 +1,54 @@
import ast
from typing import TYPE_CHECKING
from clint.rules.base import Rule
from clint.utils import resolve_expr
if TYPE_CHECKING:
from clint.index import SymbolIndex
class LogModelArtifactPath(Rule):
def _message(self) -> str:
return "`artifact_path` parameter of `log_model` is deprecated. Use `name` instead."
@staticmethod
def check(node: ast.Call, index: "SymbolIndex") -> bool:
"""
Returns True if the call looks like `mlflow.<flavor>.log_model(...)` and
the `artifact_path` argument is specified.
"""
parts = resolve_expr(node.func)
if not parts or len(parts) != 3:
return False
first, second, third = parts
if not (first == "mlflow" and third == "log_model"):
return False
# TODO: Remove this once spark flavor supports logging models as logged model artifacts
if second == "spark":
return False
function_name = f"{first}.{second}.log_model"
artifact_path_idx = LogModelArtifactPath._find_artifact_path_index(index, function_name)
if artifact_path_idx is None:
return False
if len(node.args) > artifact_path_idx:
return True
else:
return any(kw.arg and kw.arg == "artifact_path" for kw in node.keywords)
@staticmethod
def _find_artifact_path_index(index: "SymbolIndex", function_name: str) -> int | None:
"""
Finds the index of the `artifact_path` argument in the function signature of `log_model`
using the SymbolIndex.
"""
if f := index.resolve(function_name):
try:
return f.all_args.index("artifact_path")
except ValueError:
return None
return None
@@ -0,0 +1,9 @@
from clint.rules.base import Rule
class MarkdownLink(Rule):
def _message(self) -> str:
return (
"Markdown link is not supported in docstring. "
"Use reST link instead (e.g., `Link text <link URL>`_)."
)
@@ -0,0 +1,9 @@
from clint.rules.base import Rule
class MissingDocstringParam(Rule):
def __init__(self, params: set[str]) -> None:
self.params = params
def _message(self) -> str:
return f"Missing parameters in docstring: {self.params}"
@@ -0,0 +1,6 @@
from clint.rules.base import Rule
class MissingNotebookH1Header(Rule):
def _message(self) -> str:
return "Notebook should have at least one H1 header for the title."
@@ -0,0 +1,6 @@
from clint.rules.base import Rule
class MlflowClassName(Rule):
def _message(self) -> str:
return "Should use `Mlflow` in class name, not `MLflow` or `MLFlow`."
@@ -0,0 +1,27 @@
import ast
from clint.resolver import Resolver
from clint.rules.base import Rule
class MockPatchAsDecorator(Rule):
def _message(self) -> str:
return (
"Do not use `unittest.mock.patch` as a decorator. "
"Use it as a context manager to avoid patches being active longer than needed "
"and to make it clear which code depends on them."
)
@staticmethod
def check(decorator_list: list[ast.expr], resolver: Resolver) -> ast.expr | None:
"""
Returns the decorator node if it is a `@mock.patch` or `@patch` decorator.
"""
for deco in decorator_list:
if res := resolver.resolve(deco):
match res:
# Resolver returns ["unittest", "mock", "patch", ...]
# The *_ captures variants like "object", "dict", etc.
case ["unittest", "mock", "patch", *_]:
return deco
return None
@@ -0,0 +1,46 @@
import ast
from clint.resolver import Resolver
from clint.rules.base import Rule
class MockPatchDictEnviron(Rule):
def _message(self) -> str:
return (
"Do not use `mock.patch.dict` to modify `os.environ` in tests; "
"use pytest's monkeypatch fixture (monkeypatch.setenv / monkeypatch.delenv) instead."
)
@staticmethod
def check(node: ast.Call, resolver: Resolver) -> bool:
"""
Returns True if the call is to mock.patch.dict with "os.environ" or os.environ as first arg.
Handles:
- mock.patch.dict("os.environ", {...})
- mock.patch.dict(os.environ, {...})
- @mock.patch.dict("os.environ", {...})
"""
if not isinstance(node, ast.Call):
return False
# Check if this is mock.patch.dict
resolved = resolver.resolve(node.func)
if resolved != ["unittest", "mock", "patch", "dict"]:
return False
# Check if the first argument is "os.environ" (string) or os.environ (expression)
if not node.args:
return False
first_arg = node.args[0]
# Check for string literal "os.environ"
if isinstance(first_arg, ast.Constant) and first_arg.value == "os.environ":
return True
# Check for os.environ as an expression
resolved_arg = resolver.resolve(first_arg)
if resolved_arg == ["os", "environ"]:
return True
return False
+51
View File
@@ -0,0 +1,51 @@
import ast
from clint.rules.base import Rule
class MultiAssign(Rule):
def _message(self) -> str:
return (
"Avoid multiple assignment (e.g., `x, y = func()`). Use separate assignments "
"instead for better readability and easier debugging."
)
@staticmethod
def check(node: ast.Assign) -> bool:
"""
Returns True if the assignment is a tuple assignment where the number of
targets matches the number of values, unless all values are constants.
Examples that should be flagged:
- x, y = func1(), func2()
- a, b = get_value(), other_value
Examples that should NOT be flagged:
- x, y = 1, 1 (all constants)
- a, b, c = 0, 0, 0 (all constants)
- x, y = z (unpacking from single value)
- a, b = func() (unpacking from function return)
- x, y = get_coordinates() (unpacking from function return)
"""
# Check if we have exactly one target and it's a Tuple
if len(node.targets) != 1 or not isinstance(node.targets[0], ast.Tuple):
return False
# Check if the value is also a Tuple
if not isinstance(node.value, ast.Tuple):
return False
# Get the number of targets and values
num_targets = len(node.targets[0].elts)
num_values = len(node.value.elts)
# Only flag when we have matching number of targets and values (at least 2)
if not (num_targets == num_values and num_targets >= 2):
return False
# Allow if all values are constants (e.g., x, y = 1, 1)
all_constants = all(isinstance(elt, ast.Constant) for elt in node.value.elts)
if all_constants:
return False
return True
@@ -0,0 +1,54 @@
import ast
from clint.resolver import Resolver
from clint.rules.base import Rule
class NestedMockPatch(Rule):
def _message(self) -> str:
return (
"Do not nest `unittest.mock.patch` context managers. "
"Use multiple context managers in a single `with` statement instead: "
"`with mock.patch(...), mock.patch(...): ...`"
)
@staticmethod
def check(node: ast.With, resolver: Resolver) -> bool:
"""
Returns True if the with statement uses mock.patch and contains only a single
nested with statement that also uses mock.patch.
"""
# Check if the outer with statement uses mock.patch
outer_has_mock_patch = any(
NestedMockPatch._is_mock_patch(item.context_expr, resolver) for item in node.items
)
if not outer_has_mock_patch:
return False
# Check if the body has exactly one statement and it's a with statement
if len(node.body) == 1 and isinstance(node.body[0], ast.With):
# Check if the nested with statement also uses mock.patch
inner_has_mock_patch = any(
NestedMockPatch._is_mock_patch(item.context_expr, resolver)
for item in node.body[0].items
)
if inner_has_mock_patch:
return True
return False
@staticmethod
def _is_mock_patch(node: ast.expr, resolver: Resolver) -> bool:
"""
Returns True if the node is a call to mock.patch or any of its variants.
"""
# Handle direct calls: mock.patch(...), mock.patch.object(...), etc.
if isinstance(node, ast.Call):
if res := resolver.resolve(node.func):
match res:
# Matches unittest.mock.patch, unittest.mock.patch.object, etc.
case ["unittest", "mock", "patch", *_]:
return True
return False
@@ -0,0 +1,35 @@
import ast
from typing_extensions import Self
from clint.rules.base import Rule
class NoClassBasedTests(Rule):
def __init__(self, class_name: str) -> None:
self.class_name = class_name
@classmethod
def check(cls, node: ast.ClassDef, path_name: str) -> Self | None:
# Only check in test files
if not path_name.startswith("test_"):
return None
if not node.name.startswith("Test"):
return None
# Check if the class has any test methods
if any(
isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef))
and stmt.name.startswith("test_")
for stmt in node.body
):
return cls(node.name)
return None
def _message(self) -> str:
return (
f"Class-based tests are not allowed. "
f"Convert class '{self.class_name}' to function-based tests."
)
+6
View File
@@ -0,0 +1,6 @@
from clint.rules.base import Rule
class NoRst(Rule):
def _message(self) -> str:
return "Do not use RST style. Use Google style instead."
+15
View File
@@ -0,0 +1,15 @@
from clint.rules.base import Rule
class NoShebang(Rule):
def _message(self) -> str:
return "Python scripts should not contain shebang lines"
@staticmethod
def check(file_content: str) -> bool:
"""
Returns True if the file contains a shebang line at the beginning.
A shebang line is a line that starts with '#!' (typically #!/usr/bin/env python).
"""
return file_content.startswith("#!")
@@ -0,0 +1,16 @@
import ast
from clint.resolver import Resolver
from clint.rules.base import Rule
class OsChdirInTest(Rule):
def _message(self) -> str:
return "Do not use `os.chdir` in test directly. Use `monkeypatch.chdir` (https://docs.pytest.org/en/stable/reference/reference.html#pytest.MonkeyPatch.chdir)."
@staticmethod
def check(node: ast.Call, resolver: Resolver) -> bool:
"""
Returns True if the call is to os.chdir().
"""
return resolver.resolve(node) == ["os", "chdir"]
@@ -0,0 +1,30 @@
import ast
from clint.resolver import Resolver
from clint.rules.base import Rule
class OsEnvironDeleteInTest(Rule):
def _message(self) -> str:
return (
"Do not delete `os.environ` in test directly (del os.environ[...] or "
"os.environ.pop(...)). Use `monkeypatch.delenv` "
"(https://docs.pytest.org/en/stable/reference/reference.html#pytest.MonkeyPatch.delenv)."
)
@staticmethod
def check(node: ast.Delete | ast.Call, resolver: Resolver) -> bool:
"""
Returns True if the operation is deletion from os.environ[...] or
a call to os.environ.pop().
"""
if isinstance(node, ast.Delete):
# Handle: del os.environ["KEY"]
if len(node.targets) == 1 and isinstance(node.targets[0], ast.Subscript):
resolved = resolver.resolve(node.targets[0].value)
return resolved == ["os", "environ"]
elif isinstance(node, ast.Call):
# Handle: os.environ.pop("KEY")
resolved = resolver.resolve(node)
return resolved == ["os", "environ", "pop"]
return False
@@ -0,0 +1,19 @@
import ast
from clint.resolver import Resolver
from clint.rules.base import Rule
class OsEnvironSetInTest(Rule):
def _message(self) -> str:
return "Do not set `os.environ` in test directly. Use `monkeypatch.setenv` (https://docs.pytest.org/en/stable/reference/reference.html#pytest.MonkeyPatch.setenv)."
@staticmethod
def check(node: ast.Assign, resolver: Resolver) -> bool:
"""
Returns True if the assignment is to os.environ[...].
"""
if len(node.targets) == 1 and isinstance(node.targets[0], ast.Subscript):
resolved = resolver.resolve(node.targets[0].value)
return resolved == ["os", "environ"]
return False
@@ -0,0 +1,56 @@
import ast
from clint.rules.base import Rule
def _is_simple_name_or_attribute(node: ast.expr) -> bool:
"""
Check if a node is a simple name (e.g., `a`) or a chain of attribute
accesses on a simple name (e.g., `obj.attr` or `a.b.c`).
"""
if isinstance(node, ast.Name):
return True
if isinstance(node, ast.Attribute):
return _is_simple_name_or_attribute(node.value)
return False
class PreferDictUnion(Rule):
def _message(self) -> str:
return (
"Use `|` operator for dictionary merging (e.g., `a | b`) "
"instead of `{**a, **b}` for better readability."
)
@staticmethod
def check(node: ast.Dict) -> bool:
"""
Returns True if the dictionary is composed entirely of 2+ dictionary unpacking
expressions that can be replaced with the `|` operator.
Examples that should be flagged:
- {**a, **b}
- {**a, **b, **c}
- {**obj.attr, **b}
- {**a.b.c, **d}
Examples that should NOT be flagged:
- {**a} # Single unpack
- {**a, "key": value} # Mixed with literal keys
- {**data[0], **b}, {**func(), **b} # Complex expressions
- {**a,\n**b} # Multi-line dicts
"""
# Need at least 2 elements for a merge
if len(node.keys) < 2:
return False
# Skip multi-line dicts
if node.end_lineno and node.end_lineno > node.lineno:
return False
# All keys must be None (indicating dictionary unpacking with **)
if not all(key is None for key in node.keys):
return False
# All values must be simple names or attribute access on a name
return all(_is_simple_name_or_attribute(value) for value in node.values)
+36
View File
@@ -0,0 +1,36 @@
import ast
from clint.rules.base import Rule
class PreferNext(Rule):
def _message(self) -> str:
return (
"Use `next(x for x in items if condition)` instead of "
"`[x for x in items if condition][0]` for finding the first matching element."
)
@staticmethod
def check(node: ast.Subscript) -> bool:
"""
Returns True if the node is a list comprehension with an `if` clause
subscripted with `[0]`.
Examples that should be flagged:
- [x for x in items if f(x)][0]
- [x.name for x in items if x.active][0]
Examples that should NOT be flagged:
- [x for x in items][0] (no if clause)
- [x for x in items if f(x)][1] (not [0])
- [x for x in items if f(x)][-1] (not [0])
- (x for x in items if f(x)) (already a generator)
"""
match node:
case ast.Subscript(
value=ast.ListComp(generators=generators),
slice=ast.Constant(value=0),
) if any(gen.ifs for gen in generators):
return True
case _:
return False
@@ -0,0 +1,25 @@
import ast
from typing import Literal
from typing_extensions import Self
from clint.resolver import Resolver
from clint.rules.base import Rule
# See https://github.com/astral-sh/ruff/issues/3608
class PreferOsEnviron(Rule):
def __init__(self, func: Literal["getenv", "putenv"]) -> None:
self.func = func
def _message(self) -> str:
if self.func == "putenv":
return "Use `os.environ[key] = value` instead of `os.putenv()`."
return "Use `os.environ.get()` instead of `os.getenv()`."
@classmethod
def check(cls, node: ast.Call, resolver: Resolver) -> Self | None:
match resolver.resolve(node.func):
case ["os", ("getenv" | "putenv") as func]:
return cls(func)
return None
@@ -0,0 +1,22 @@
import ast
from clint.resolver import Resolver
from clint.rules.base import Rule
class PytestMarkRepeat(Rule):
def _message(self) -> str:
return (
"@pytest.mark.repeat decorator should not be committed. "
"This decorator is meant for local testing only to check for flaky tests."
)
@staticmethod
def check(decorator_list: list[ast.expr], resolver: Resolver) -> ast.expr | None:
"""
Returns the decorator node if it is a `@pytest.mark.repeat` decorator.
"""
for deco in decorator_list:
if (res := resolver.resolve(deco)) and res == ["pytest", "mark", "repeat"]:
return deco
return None
@@ -0,0 +1,36 @@
import ast
from clint.resolver import Resolver
from clint.rules.base import Rule
class RedundantMockReturnValue(Rule):
def _message(self) -> str:
return (
"Do not pass `return_value=MagicMock()` or `return_value=Mock()` to `patch()`. "
"The default return value of a mock is already a new mock."
)
@staticmethod
def check(node: ast.Call, resolver: Resolver) -> bool:
"""
Returns True if the call is a patch() with a redundant return_value=Mock() or
return_value=MagicMock() (no arguments) keyword argument.
"""
match resolver.resolve(node.func):
case ["unittest", "mock", "patch", *_]:
pass
case _:
return False
for keyword in node.keywords:
match keyword:
case ast.keyword(
arg="return_value",
value=ast.Call(args=[], keywords=[]) as value,
):
match resolver.resolve(value.func):
case ["unittest", "mock", "Mock" | "MagicMock"]:
return True
return False
@@ -0,0 +1,64 @@
"""Rule to detect redundant docstrings in test files.
This rule flags:
- ALL single-line docstrings in test functions and classes (multi-line
function/class docstrings are allowed since they generally provide
meaningful context).
- ALL module-level docstrings in test files (single- or multi-line).
"""
import ast
from clint.rules.base import Rule
class RedundantTestDocstring(Rule):
@staticmethod
def check(
node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef, path_name: str
) -> ast.Constant | None:
if not (path_name.startswith("test_") or path_name.endswith("_test.py")):
return None
is_class = isinstance(node, ast.ClassDef)
if is_class and not node.name.startswith("Test"):
return None
if not is_class and not node.name.startswith("test_"):
return None
# Check if docstring exists and get the raw docstring for multiline detection
if (
node.body
and isinstance(node.body[0], ast.Expr)
and isinstance(node.body[0].value, ast.Constant)
and isinstance(node.body[0].value.value, str)
):
raw_docstring = node.body[0].value.value
# If raw docstring has newlines, it's multiline - always allow
if "\n" in raw_docstring:
return None
# Return the docstring node to flag
return node.body[0].value
return None
@staticmethod
def check_module(module: ast.Module, path_name: str) -> ast.Constant | None:
if not (path_name.startswith("test_") or path_name.endswith("_test.py")):
return None
if (
module.body
and isinstance(module.body[0], ast.Expr)
and isinstance(module.body[0].value, ast.Constant)
and isinstance(module.body[0].value.value, str)
):
return module.body[0].value
return None
def _message(self) -> str:
return "Docstrings in test files rarely provide meaningful context. Consider removing it."
@@ -0,0 +1,43 @@
import ast
from clint.resolver import Resolver
from clint.rules.base import Rule
class SubprocessCheckCall(Rule):
def _message(self) -> str:
return (
"Use `subprocess.check_call(...)` instead of `subprocess.run(..., check=True)` "
"for better readability. Only applies when check=True is the only keyword argument."
)
@staticmethod
def check(node: ast.Call, resolver: Resolver) -> bool:
"""
Returns True if `node` is `subprocess.run(..., check=True)` with no other keyword arguments.
"""
resolved = resolver.resolve(node)
# Check if this is subprocess.run
if resolved != ["subprocess", "run"]:
return False
# Check if there are any keyword arguments
if not node.keywords:
return False
# Check if the only keyword argument is check=True
if len(node.keywords) != 1:
return False
keyword = node.keywords[0]
# Check if the keyword is 'check' (not **kwargs)
if keyword.arg != "check":
return False
# Check if the value is True
if not isinstance(keyword.value, ast.Constant):
return False
return keyword.value.value is True
@@ -0,0 +1,23 @@
import ast
from clint.resolver import Resolver
from clint.rules.base import Rule
class TempfileInTest(Rule):
def _message(self) -> str:
return (
"Do not use `tempfile` in tests. Use the `tmp_path` fixture instead "
"(https://docs.pytest.org/en/stable/reference/reference.html#tmp-path)."
)
@staticmethod
def check(node: ast.Call, resolver: Resolver) -> bool:
match resolver.resolve(node):
case [
"tempfile",
"TemporaryDirectory" | "NamedTemporaryFile" | "TemporaryFile" | "mkdtemp",
]:
return True
case _:
return False
@@ -0,0 +1,6 @@
from clint.rules.base import Rule
class TestNameTypo(Rule):
def _message(self) -> str:
return "This function looks like a test, but its name does not start with 'test_'."
@@ -0,0 +1,15 @@
from clint.rules.base import Rule
class TypingExtensions(Rule):
def __init__(self, *, full_name: str, allowlist: list[str]) -> None:
self.full_name = full_name
self.allowlist = allowlist
def _message(self) -> str:
return (
f"`{self.full_name}` is not allowed to use. Only {self.allowlist} are allowed. "
"You can extend `tool.clint.typing-extensions-allowlist` in `pyproject.toml` if needed "
"but make sure that the version requirement for `typing-extensions` is compatible with "
"the added types."
)
@@ -0,0 +1,14 @@
from clint.rules.base import Rule
class UnknownMlflowArguments(Rule):
def __init__(self, function_name: str, unknown_args: set[str]) -> None:
self.function_name = function_name
self.unknown_args = unknown_args
def _message(self) -> str:
args_str = ", ".join(f"`{arg}`" for arg in sorted(self.unknown_args))
return (
f"Unknown arguments {args_str} passed to `{self.function_name}`. "
"Check the function signature for valid parameter names."
)
@@ -0,0 +1,12 @@
from clint.rules.base import Rule
class UnknownMlflowFunction(Rule):
def __init__(self, function_name: str) -> None:
self.function_name = function_name
def _message(self) -> str:
return (
f"Unknown MLflow function: `{self.function_name}`. "
"This function may not exist or could be misspelled."
)
@@ -0,0 +1,23 @@
import ast
from clint.resolver import Resolver
from clint.rules.base import Rule
class UnnamedThread(Rule):
def _message(self) -> str:
return (
"`threading.Thread()` must be called with a `name` argument to improve debugging "
"and traceability of thread-related issues."
)
@staticmethod
def check(node: ast.Call, resolver: Resolver) -> bool:
"""
Returns True if the call is threading.Thread() without a name parameter.
"""
if names := resolver.resolve(node):
return names == ["threading", "Thread"] and not any(
keyword.arg == "name" for keyword in node.keywords
)
return False
@@ -0,0 +1,23 @@
import ast
from clint.resolver import Resolver
from clint.rules.base import Rule
class UnnamedThreadPool(Rule):
def _message(self) -> str:
return (
"`ThreadPoolExecutor()` must be called with a `thread_name_prefix` argument to improve "
"debugging and traceability of thread-related issues."
)
@staticmethod
def check(node: ast.Call, resolver: Resolver) -> bool:
"""
Returns True if the call is ThreadPoolExecutor() without a thread_name_prefix parameter.
"""
if names := resolver.resolve(node):
return names == ["concurrent", "futures", "ThreadPoolExecutor"] and not any(
kw.arg == "thread_name_prefix" for kw in node.keywords
)
return False
@@ -0,0 +1,32 @@
import ast
from clint.resolver import Resolver
from clint.rules.base import Rule
class UnparameterizedGenericType(Rule):
def __init__(self, type_hint: str) -> None:
self.type_hint = type_hint
@staticmethod
def is_generic_type(node: ast.Name | ast.Attribute, resolver: Resolver) -> bool:
if names := resolver.resolve(node):
return tuple(names) in {
("typing", "Callable"),
("typing", "Sequence"),
}
elif isinstance(node, ast.Name):
return node.id in {
"dict",
"list",
"set",
"tuple",
"frozenset",
}
return False
def _message(self) -> str:
return (
f"Generic type `{self.type_hint}` must be parameterized "
"(e.g., `list[str]` rather than `list`)."
)
@@ -0,0 +1,9 @@
from clint.rules.base import Rule
class UnusedDisableComment(Rule):
def __init__(self, rule_name: str) -> None:
self.rule_name = rule_name
def _message(self) -> str:
return f"Unused disable comment for rule `{self.rule_name}`"
+24
View File
@@ -0,0 +1,24 @@
import ast
from clint.resolver import Resolver
from clint.rules.base import Rule
class UseGhToken(Rule):
def _message(self) -> str:
return "Use GH_TOKEN instead of GITHUB_TOKEN for the environment variable name."
@staticmethod
def check(node: ast.Call, resolver: Resolver) -> bool:
"""
Returns True if the call reads the GITHUB_TOKEN environment variable.
Handles:
- os.getenv("GITHUB_TOKEN")
- os.environ.get("GITHUB_TOKEN")
"""
match node:
case ast.Call(args=[ast.Constant(value="GITHUB_TOKEN"), *_]):
match resolver.resolve(node.func):
case ["os", "getenv"] | ["os", "environ", "get"]:
return True
return False
@@ -0,0 +1,34 @@
import ast
from clint.resolver import Resolver
from clint.rules.base import Rule
class UseSysExecutable(Rule):
def _message(self) -> str:
return (
"Use `[sys.executable, '-m', 'mlflow', ...]` when running mlflow CLI in a subprocess."
)
@staticmethod
def check(node: ast.Call, resolver: Resolver) -> bool:
"""
Returns True if `node` looks like `subprocess.Popen(["mlflow", ...])`.
"""
resolved = resolver.resolve(node)
if (
resolved
and len(resolved) == 2
and resolved[0] == "subprocess"
and resolved[1] in ["Popen", "run", "check_output", "check_call"]
and node.args
):
first_arg = node.args[0]
if isinstance(first_arg, ast.List) and first_arg.elts:
first_elem = first_arg.elts[0]
return (
isinstance(first_elem, ast.Constant)
and isinstance(first_elem.value, str)
and first_elem.value == "mlflow"
)
return False
@@ -0,0 +1,189 @@
import ast
from clint.rules.base import Rule
class UseWalrusOperator(Rule):
def _message(self) -> str:
return (
"Use the walrus operator `:=` when a variable is assigned and only used "
"within an `if` block that tests its truthiness. "
"For example, replace `a = ...; if a: use_a(a)` with `if a := ...: use_a(a)`."
)
@staticmethod
def check(
if_node: ast.If,
prev_stmt: ast.stmt,
following_stmts: list[ast.stmt],
) -> bool:
"""
Flags::
a = func()
if a:
use(a)
Ignores: comparisons, tuple unpacking, multi-line, used in elif/else,
used after if, line > 100 chars
"""
# Check if previous statement is a simple assignment (not augmented, not annotated)
if not isinstance(prev_stmt, ast.Assign):
return False
# Skip if the assignment statement spans multiple lines
if (
prev_stmt.end_lineno is not None
and prev_stmt.lineno is not None
and prev_stmt.end_lineno > prev_stmt.lineno
):
return False
# Must be a single target assignment to a Name
if len(prev_stmt.targets) != 1:
return False
target = prev_stmt.targets[0]
if not isinstance(target, ast.Name):
return False
var_name = target.id
# The if condition must be just the variable name (truthiness test)
if not isinstance(if_node.test, ast.Name):
return False
if if_node.test.id != var_name:
return False
# Check that the variable is used in the if body
if not _name_used_in_stmts(var_name, if_node.body):
return False
# Check that the variable is NOT used in elif/else branches
if if_node.orelse and _name_used_in_stmts(var_name, if_node.orelse):
return False
# Check that the variable is NOT used after the if statement
if following_stmts and _name_used_in_stmts(var_name, following_stmts):
return False
# Skip if the fixed code would exceed 100 characters
# Original: "if var:" -> Fixed: "if var := value:"
value = prev_stmt.value
if (
value.end_col_offset is None
or value.col_offset is None
or if_node.test.end_col_offset is None
):
return False
value_width = value.end_col_offset - value.col_offset
fixed_line_length = (
if_node.test.end_col_offset
+ 4 # len(" := ")
+ value_width
+ 1 # len(":")
)
if fixed_line_length > 100:
return False
return True
def _name_used_in_stmts(name: str, stmts: list[ast.stmt]) -> bool:
"""Check if a name is used (loaded) in a list of statements.
Skips nested function/class definitions to avoid false positives from
inner scopes that shadow or independently use the same name.
"""
return any(_name_used_in_node(name, stmt) for stmt in stmts)
def _name_used_in_node(name: str, node: ast.AST) -> bool:
"""Recursively check if a name is used."""
match node:
case ast.Name(id=id, ctx=ast.Load()) if id == name:
return True
case _:
return any(_name_used_in_node(name, child) for child in ast.iter_child_nodes(node))
class WalrusOperatorVisitor(ast.NodeVisitor):
"""Visits all statement blocks to check for walrus operator opportunities."""
def __init__(self) -> None:
self.violations: list[ast.stmt] = []
def _check_stmts(self, stmts: list[ast.stmt]) -> None:
for idx, stmt in enumerate(stmts[1:], start=1):
if isinstance(stmt, ast.If):
prev_stmt = stmts[idx - 1]
following_stmts = stmts[idx + 1 :]
if UseWalrusOperator.check(stmt, prev_stmt, following_stmts):
self.violations.append(prev_stmt)
def _visit_stmts(self, stmts: list[ast.stmt]) -> None:
for stmt in stmts:
self.visit(stmt)
# Walrus opportunities only exist inside statement blocks, so skip descent into
# expressions, decorators, args, etc. This trims the AST walk dramatically on
# expression-heavy functions.
def generic_visit(self, node: ast.AST) -> None:
return
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
self._check_stmts(node.body)
self._visit_stmts(node.body)
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
self._check_stmts(node.body)
self._visit_stmts(node.body)
def visit_If(self, node: ast.If) -> None:
self._check_stmts(node.body)
self._check_stmts(node.orelse)
self._visit_stmts(node.body)
self._visit_stmts(node.orelse)
def visit_For(self, node: ast.For) -> None:
self._check_stmts(node.body)
self._check_stmts(node.orelse)
self._visit_stmts(node.body)
self._visit_stmts(node.orelse)
def visit_AsyncFor(self, node: ast.AsyncFor) -> None:
self._check_stmts(node.body)
self._check_stmts(node.orelse)
self._visit_stmts(node.body)
self._visit_stmts(node.orelse)
def visit_While(self, node: ast.While) -> None:
self._check_stmts(node.body)
self._check_stmts(node.orelse)
self._visit_stmts(node.body)
self._visit_stmts(node.orelse)
def visit_With(self, node: ast.With) -> None:
self._check_stmts(node.body)
self._visit_stmts(node.body)
def visit_AsyncWith(self, node: ast.AsyncWith) -> None:
self._check_stmts(node.body)
self._visit_stmts(node.body)
def visit_Try(self, node: ast.Try) -> None:
self._check_stmts(node.body)
self._check_stmts(node.orelse)
self._check_stmts(node.finalbody)
self._visit_stmts(node.body)
for handler in node.handlers:
self._check_stmts(handler.body)
self._visit_stmts(handler.body)
self._visit_stmts(node.orelse)
self._visit_stmts(node.finalbody)
def visit_Match(self, node: ast.Match) -> None:
for case in node.cases:
self._check_stmts(case.body)
self._visit_stmts(case.body)
@@ -0,0 +1,57 @@
import ast
import re
from typing import TYPE_CHECKING
from clint.rules.base import Rule
if TYPE_CHECKING:
from clint.resolver import Resolver
class MajorVersionCheck(Rule):
def _message(self) -> str:
return (
"Use `.major` field for major version comparisons instead of full version strings. "
"This is more explicit, and efficient (avoids creating a second Version object). "
"For example, use `Version(__version__).major >= 1` instead of "
'`Version(__version__) >= Version("1.0.0")`.'
)
@staticmethod
def check(node: ast.Compare, resolver: "Resolver") -> bool:
if len(node.ops) != 1 or len(node.comparators) != 1:
return False
if not isinstance(node.ops[0], (ast.GtE, ast.LtE, ast.Gt, ast.Lt, ast.Eq, ast.NotEq)):
return False
if not (
isinstance(node.left, ast.Call)
and MajorVersionCheck._is_version_call(node.left, resolver)
):
return False
comparator = node.comparators[0]
if not (
isinstance(comparator, ast.Call)
and MajorVersionCheck._is_version_call(comparator, resolver)
):
return False
match comparator.args:
case [arg] if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
version_str = arg.value
return MajorVersionCheck._is_major_only_version(version_str)
return False
@staticmethod
def _is_version_call(node: ast.Call, resolver: "Resolver") -> bool:
if resolved := resolver.resolve(node.func):
return resolved == ["packaging", "version", "Version"]
return False
@staticmethod
def _is_major_only_version(version_str: str) -> bool:
pattern = r"^(\d+)\.0\.0$"
return re.match(pattern, version_str) is not None
+90
View File
@@ -0,0 +1,90 @@
from __future__ import annotations
import ast
import re
import subprocess
from functools import lru_cache
from pathlib import Path
@lru_cache(maxsize=1)
def get_repo_root() -> Path:
"""Find the git repository root directory with caching."""
try:
result = subprocess.check_output(["git", "rev-parse", "--show-toplevel"], text=True).strip()
return Path(result)
except (OSError, subprocess.CalledProcessError) as e:
raise RuntimeError("Failed to find git repository root") from e
def resolve_expr(expr: ast.expr) -> list[str] | None:
"""
Resolves `expr` to a list of attribute names. For example, given `expr` like
`some.module.attribute`, ['some', 'module', 'attribute'] is returned.
If `expr` is not resolvable, `None` is returned.
"""
if isinstance(expr, ast.Attribute):
base = resolve_expr(expr.value)
if base is None:
return None
return base + [expr.attr]
elif isinstance(expr, ast.Name):
return [expr.id]
return None
def get_ignored_rules_for_file(
file_path: Path, per_file_ignores: dict[re.Pattern[str], set[str]]
) -> set[str]:
"""
Returns a set of rule names that should be ignored for the given file path.
Args:
file_path: The file path to check
per_file_ignores: Dict mapping compiled regex patterns to lists of rule names to ignore
Returns:
Set of rule names to ignore for this file
"""
ignored_rules: set[str] = set()
for pattern, rules in per_file_ignores.items():
if pattern.fullmatch(file_path.as_posix()):
ignored_rules |= rules
return ignored_rules
ALLOWED_EXTS = {".md", ".mdx", ".rst", ".py", ".ipynb"}
def _git_ls_files(pathspecs: list[Path]) -> list[Path]:
"""
Return git-tracked and untracked (but not ignored) files matching the given pathspecs.
Git does not filter by extension; filtering happens in Python.
"""
try:
out = subprocess.check_output(
["git", "ls-files", "--cached", "--others", "--exclude-standard", "--", *pathspecs],
text=True,
)
except (OSError, subprocess.CalledProcessError) as e:
raise RuntimeError("Failed to list git files") from e
return [Path(line) for line in out.splitlines() if line]
def resolve_paths(paths: list[Path]) -> list[Path]:
"""
Resolve CLI arguments into a list of tracked and untracked files to lint.
- Includes git-tracked files and untracked files (but not ignored files)
- Only includes: .md, .mdx, .rst, .py, .ipynb
"""
if not paths:
paths = [Path(".")]
all_files = _git_ls_files(paths)
filtered = {p for p in all_files if p.suffix.lower() in ALLOWED_EXTS and p.exists()}
return sorted(filtered)
+7
View File
@@ -0,0 +1,7 @@
import pytest
from clint.index import SymbolIndex
@pytest.fixture(scope="session")
def index() -> SymbolIndex:
return SymbolIndex.build()
@@ -0,0 +1,148 @@
from pathlib import Path
from clint.config import Config
from clint.index import SymbolIndex
from clint.linter import Position, Range, lint_file
from clint.rules import AssignBeforeAppend
def test_assign_before_append_basic(index: SymbolIndex) -> None:
code = """
items = []
for x in data:
item = transform(x)
items.append(item)
"""
config = Config(select={AssignBeforeAppend.name})
results = lint_file(Path("test.py"), code, config, index)
assert len(results) == 1
assert all(isinstance(r.rule, AssignBeforeAppend) for r in results)
assert results[0].range == Range(Position(2, 0))
def test_assign_before_append_no_flag_different_variable(index: SymbolIndex) -> None:
code = """
items = []
for x in data:
item = transform(x)
items.append(other_var)
"""
config = Config(select={AssignBeforeAppend.name})
results = lint_file(Path("test.py"), code, config, index)
assert len(results) == 0
def test_assign_before_append_no_flag_no_empty_list_init(index: SymbolIndex) -> None:
code = """
for x in data:
item = transform(x)
items.append(item)
"""
config = Config(select={AssignBeforeAppend.name})
results = lint_file(Path("test.py"), code, config, index)
assert len(results) == 0
def test_assign_before_append_no_flag_different_list(index: SymbolIndex) -> None:
code = """
items = []
for x in data:
item = transform(x)
other_list.append(item)
"""
config = Config(select={AssignBeforeAppend.name})
results = lint_file(Path("test.py"), code, config, index)
assert len(results) == 0
def test_assign_before_append_no_flag_three_statements(index: SymbolIndex) -> None:
code = """
items = []
for x in data:
item = transform(x)
print(item)
items.append(item)
"""
config = Config(select={AssignBeforeAppend.name})
results = lint_file(Path("test.py"), code, config, index)
assert len(results) == 0
def test_assign_before_append_no_flag_one_statement(index: SymbolIndex) -> None:
code = """
items = []
for x in data:
items.append(transform(x))
"""
config = Config(select={AssignBeforeAppend.name})
results = lint_file(Path("test.py"), code, config, index)
assert len(results) == 0
def test_assign_before_append_no_flag_list_with_initial_values(index: SymbolIndex) -> None:
code = """
items = [1, 2, 3]
for x in data:
item = transform(x)
items.append(item)
"""
config = Config(select={AssignBeforeAppend.name})
results = lint_file(Path("test.py"), code, config, index)
assert len(results) == 0
def test_assign_before_append_multiple_violations(index: SymbolIndex) -> None:
code = """
items = []
for x in data:
item = transform(x)
items.append(item)
results = []
for y in other_data:
result = process(y)
results.append(result)
"""
config = Config(select={AssignBeforeAppend.name})
results = lint_file(Path("test.py"), code, config, index)
assert len(results) == 2
assert all(isinstance(r.rule, AssignBeforeAppend) for r in results)
assert results[0].range == Range(Position(2, 0))
assert results[1].range == Range(Position(7, 0))
def test_assign_before_append_no_flag_complex_assignment(index: SymbolIndex) -> None:
code = """
items = []
for x in data:
item, other = transform(x)
items.append(item)
"""
config = Config(select={AssignBeforeAppend.name})
results = lint_file(Path("test.py"), code, config, index)
assert len(results) == 0
def test_assign_before_append_no_flag_attribute_assignment(index: SymbolIndex) -> None:
code = """
items = []
for x in data:
self.item = transform(x)
items.append(self.item)
"""
config = Config(select={AssignBeforeAppend.name})
results = lint_file(Path("test.py"), code, config, index)
assert len(results) == 0
def test_assign_before_append_separated_statements(index: SymbolIndex) -> None:
code = """
items = []
other_statement()
for x in data:
item = transform(x)
items.append(item)
"""
config = Config(select={AssignBeforeAppend.name})
results = lint_file(Path("test.py"), code, config, index)
assert len(results) == 0
@@ -0,0 +1,49 @@
from pathlib import Path
from clint.config import Config
from clint.index import SymbolIndex
from clint.linter import Position, Range, lint_file
from clint.rules.do_not_disable import DoNotDisable
def test_do_not_disable(index: SymbolIndex) -> None:
code = """
# Bad B006
# noqa: B006
# Bad F821
# noqa: F821
# Good
# noqa: B004
"""
config = Config(select={DoNotDisable.name})
violations = lint_file(Path("test.py"), code, config, index)
assert len(violations) == 2
assert all(isinstance(v.rule, DoNotDisable) for v in violations)
assert violations[0].range == Range(Position(2, 0))
assert violations[1].range == Range(Position(5, 0))
def test_do_not_disable_comma_separated(index: SymbolIndex) -> None:
code = """
# Bad: B006 and F821 both should be caught
# noqa: B006, F821
# Bad: B006 and F821 both should be caught (no space after comma)
# noqa: B006,F821
# Good: B004 is allowed
# noqa: B004, B005
"""
config = Config(select={DoNotDisable.name})
violations = lint_file(Path("test.py"), code, config, index)
assert len(violations) == 2
assert all(isinstance(v.rule, DoNotDisable) for v in violations)
# Both violations should have both rules B006 and F821
assert isinstance(violations[0].rule, DoNotDisable)
assert isinstance(violations[1].rule, DoNotDisable)
assert violations[0].rule.rules == {"B006", "F821"}
assert violations[1].rule.rules == {"B006", "F821"}
assert violations[0].range == Range(Position(2, 0))
assert violations[1].range == Range(Position(5, 0))
@@ -0,0 +1,31 @@
from pathlib import Path
from clint.config import Config
from clint.index import SymbolIndex
from clint.linter import Position, Range, lint_file
from clint.rules.docstring_param_order import DocstringParamOrder
def test_docstring_param_order(index: SymbolIndex) -> None:
code = """
# Bad
def f(x: int, y: str) -> None:
'''
Args:
y: Second param.
x: First param.
'''
# Good
def f(a: int, b: str) -> None:
'''
Args:
a: First param.
b: Second param.
'''
"""
config = Config(select={DocstringParamOrder.name})
violations = lint_file(Path("test.py"), code, config, index)
assert len(violations) == 1
assert all(isinstance(v.rule, DocstringParamOrder) for v in violations)
assert violations[0].range == Range(Position(2, 0))
@@ -0,0 +1,47 @@
import json
from pathlib import Path
from clint.config import Config
from clint.index import SymbolIndex
from clint.linter import lint_file
from clint.rules.empty_notebook_cell import EmptyNotebookCell
def test_empty_notebook_cell(index: SymbolIndex) -> None:
notebook_content = {
"cells": [
{
"cell_type": "code",
"source": [], # Empty cell
"metadata": {},
"execution_count": None,
"outputs": [],
},
{
"cell_type": "code",
"source": ["x = 5"],
"metadata": {},
"execution_count": None,
"outputs": [],
},
{
"cell_type": "code",
"source": [], # Another empty cell
"metadata": {},
"execution_count": None,
"outputs": [],
},
],
"metadata": {
"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"}
},
"nbformat": 4,
"nbformat_minor": 4,
}
code = json.dumps(notebook_content)
config = Config(select={EmptyNotebookCell.name})
violations = lint_file(Path("test_notebook.ipynb"), code, config, index)
assert len(violations) == 2
assert all(isinstance(v.rule, EmptyNotebookCell) for v in violations)
assert violations[0].cell == 1
assert violations[1].cell == 3
@@ -0,0 +1,46 @@
from pathlib import Path
import pytest
from clint.config import Config
from clint.index import SymbolIndex
from clint.linter import Position, Range, lint_file
from clint.rules.example_syntax_error import ExampleSyntaxError
def test_example_syntax_error(index: SymbolIndex) -> None:
code = '''
def bad():
"""
.. code-block:: python
def f():
"""
def good():
"""
.. code-block:: python
def f():
return "This is a good example"
"""
'''
config = Config(select={ExampleSyntaxError.name})
violations = lint_file(Path("test.py"), code, config, index)
assert len(violations) == 1
assert all(isinstance(v.rule, ExampleSyntaxError) for v in violations)
assert violations[0].range == Range(Position(5, 8))
@pytest.mark.parametrize("suffix", [".md", ".mdx"])
def test_example_syntax_error_markdown(index: SymbolIndex, suffix: str) -> None:
code = """
```python
def g():
```
"""
config = Config(select={ExampleSyntaxError.name})
violations = lint_file(Path("test").with_suffix(suffix), code, config, index)
assert len(violations) == 1
assert all(isinstance(v.rule, ExampleSyntaxError) for v in violations)
assert violations[0].range == Range(Position(2, 0))
@@ -0,0 +1,54 @@
from pathlib import Path
from clint.config import Config
from clint.index import SymbolIndex
from clint.linter import Position, Range, lint_file
from clint.rules import ExceptBoolOp
def test_except_bool_op(index: SymbolIndex) -> None:
code = """
# Bad - or in except
try:
pass
except ValueError or KeyError:
pass
# Bad - and in except
try:
pass
except ValueError and KeyError:
pass
# Bad - chained or
try:
pass
except ValueError or KeyError or TypeError:
pass
# Good - tuple syntax
try:
pass
except (ValueError, KeyError):
pass
# Good - single exception
try:
pass
except ValueError:
pass
# Good - bare except
try:
pass
except:
pass
"""
config = Config(select={ExceptBoolOp.name})
results = lint_file(Path("test.py"), code, config, index)
assert all(isinstance(r.rule, ExceptBoolOp) for r in results)
assert [r.range for r in results] == [
Range(Position(4, 0)),
Range(Position(10, 0)),
Range(Position(16, 0)),
]
@@ -0,0 +1,34 @@
from pathlib import Path
from clint.config import Config
from clint.index import SymbolIndex
from clint.linter import Position, Range, lint_file
from clint.rules.extraneous_docstring_param import ExtraneousDocstringParam
def test_extraneous_docstring_param(index: SymbolIndex) -> None:
code = '''
def bad_function(param1: str) -> None:
"""
Example function docstring.
Args:
param1: First parameter
param2: This parameter doesn't exist in function signature
param3: Another non-existent parameter
"""
def good_function(param1: str, param2: int) -> None:
"""
Good function with matching parameters.
Args:
param1: First parameter
param2: Second parameter
"""
'''
config = Config(select={ExtraneousDocstringParam.name})
violations = lint_file(Path("test.py"), code, config, index)
assert len(violations) == 1
assert all(isinstance(v.rule, ExtraneousDocstringParam) for v in violations)
assert violations[0].range == Range(Position(1, 0))
@@ -0,0 +1,82 @@
from pathlib import Path
from clint.config import Config
from clint.index import SymbolIndex
from clint.linter import Position, Range, lint_file
from clint.rules import ForbiddenDeprecationWarning
def test_forbidden_deprecation_warning(index: SymbolIndex) -> None:
code = """
import warnings
# Bad - should be flagged
warnings.warn("message", category=DeprecationWarning)
warnings.warn(
"multiline message",
category=DeprecationWarning,
stacklevel=2
)
# Good - should not be flagged
warnings.warn("message", category=FutureWarning)
warnings.warn("message", category=UserWarning)
warnings.warn("message") # no category specified
warnings.warn("message", stacklevel=2) # no category specified
other_function("message", category=DeprecationWarning) # not warnings.warn
"""
config = Config(select={ForbiddenDeprecationWarning.name})
results = lint_file(Path("test.py"), code, config, index)
assert len(results) == 2
assert all(isinstance(r.rule, ForbiddenDeprecationWarning) for r in results)
assert results[0].range == Range(Position(4, 34)) # First warnings.warn call
assert results[1].range == Range(Position(7, 13)) # Second warnings.warn call
def test_forbidden_deprecation_warning_import_variants(index: SymbolIndex) -> None:
code = """
import warnings
from warnings import warn
import warnings as w
# All of these should be flagged
warnings.warn("message", category=DeprecationWarning)
warn("message", category=DeprecationWarning)
w.warn("message", category=DeprecationWarning)
"""
config = Config(select={ForbiddenDeprecationWarning.name})
results = lint_file(Path("test.py"), code, config, index)
assert len(results) == 3
assert all(isinstance(r.rule, ForbiddenDeprecationWarning) for r in results)
def test_forbidden_deprecation_warning_parameter_order(index: SymbolIndex) -> None:
code = """
import warnings
# Different parameter orders - should be flagged
warnings.warn("message", category=DeprecationWarning)
warnings.warn(category=DeprecationWarning, message="test")
"""
config = Config(select={ForbiddenDeprecationWarning.name})
results = lint_file(Path("test.py"), code, config, index)
assert len(results) == 2
assert all(isinstance(r.rule, ForbiddenDeprecationWarning) for r in results)
def test_forbidden_deprecation_warning_positional_args(index: SymbolIndex) -> None:
code = """
import warnings
# Positional arguments - should be flagged
warnings.warn("message", DeprecationWarning)
warnings.warn("message", DeprecationWarning, 2)
# Good - should not be flagged
warnings.warn("message", FutureWarning)
warnings.warn("message") # no category specified
"""
config = Config(select={ForbiddenDeprecationWarning.name})
results = lint_file(Path("test.py"), code, config, index)
assert len(results) == 2
assert all(isinstance(r.rule, ForbiddenDeprecationWarning) for r in results)
@@ -0,0 +1,86 @@
from pathlib import Path
from clint.config import Config
from clint.index import SymbolIndex
from clint.linter import lint_file
from clint.rules.forbidden_make_judge_in_builtin_scorers import (
ForbiddenMakeJudgeInBuiltinScorers,
)
def test_forbidden_make_judge_in_builtin_scorers(index: SymbolIndex) -> None:
code = """
from mlflow.genai.judges.make_judge import make_judge
from mlflow.genai.judges import InstructionsJudge
# BAD - direct call after import
judge1 = make_judge(name="test", instructions="test")
# BAD - module qualified call
from mlflow.genai import judges
judge2 = judges.make_judge(name="test", instructions="test")
# GOOD - using InstructionsJudge instead
judge3 = InstructionsJudge(name="test", instructions="test")
"""
config = Config(select={ForbiddenMakeJudgeInBuiltinScorers.name})
violations = lint_file(Path("builtin_scorers.py"), code, config, index)
# Should detect: 1 import + 2 calls = 3 violations
assert len(violations) == 3
assert all(isinstance(v.rule, ForbiddenMakeJudgeInBuiltinScorers) for v in violations)
def test_make_judge_allowed_in_other_files(index: SymbolIndex) -> None:
code = """
from mlflow.genai.judges.make_judge import make_judge
# GOOD - allowed in other files
judge = make_judge(name="test", instructions="test")
"""
config = Config(select={ForbiddenMakeJudgeInBuiltinScorers.name})
violations = lint_file(Path("some_other_file.py"), code, config, index)
# Should NOT trigger in other files
assert len(violations) == 0
def test_instructions_judge_not_flagged(index: SymbolIndex) -> None:
code = """
from mlflow.genai.judges import InstructionsJudge
# GOOD - InstructionsJudge is the correct approach
judge = InstructionsJudge(name="test", instructions="test")
"""
config = Config(select={ForbiddenMakeJudgeInBuiltinScorers.name})
violations = lint_file(Path("builtin_scorers.py"), code, config, index)
assert len(violations) == 0
def test_nested_make_judge_call(index: SymbolIndex) -> None:
code = """
from mlflow.genai.judges.make_judge import make_judge
# BAD - nested call
result = some_function(make_judge(name="test", instructions="test"))
"""
config = Config(select={ForbiddenMakeJudgeInBuiltinScorers.name})
violations = lint_file(Path("builtin_scorers.py"), code, config, index)
# Should detect: 1 import + 1 call = 2 violations
assert len(violations) == 2
assert all(isinstance(v.rule, ForbiddenMakeJudgeInBuiltinScorers) for v in violations)
def test_make_judge_in_comment_not_flagged(index: SymbolIndex) -> None:
code = """
from mlflow.genai.judges import InstructionsJudge
# This comment mentions make_judge but should not trigger
judge = InstructionsJudge(name="test", instructions="test")
"""
config = Config(select={ForbiddenMakeJudgeInBuiltinScorers.name})
violations = lint_file(Path("builtin_scorers.py"), code, config, index)
assert len(violations) == 0
@@ -0,0 +1,33 @@
from pathlib import Path
from clint.config import Config
from clint.index import SymbolIndex
from clint.linter import Position, Range, lint_file
from clint.rules.forbidden_set_active_model_usage import ForbiddenSetActiveModelUsage
def test_forbidden_set_active_model_usage(index: SymbolIndex) -> None:
code = """
import mlflow
# Bad
mlflow.set_active_model("model_name")
# Good
mlflow._set_active_model("model_name")
# Bad - with aliasing
from mlflow import set_active_model
set_active_model("model_name")
# Good - with aliasing
from mlflow import _set_active_model
_set_active_model("model_name")
"""
config = Config(select={ForbiddenSetActiveModelUsage.name})
violations = lint_file(Path("test.py"), code, config, index)
assert len(violations) == 3
assert all(isinstance(v.rule, ForbiddenSetActiveModelUsage) for v in violations)
assert violations[0].range == Range(Position(4, 0)) # mlflow.set_active_model call
assert violations[1].range == Range(Position(10, 0)) # from mlflow import set_active_model
assert violations[2].range == Range(Position(11, 0)) # direct set_active_model call
@@ -0,0 +1,45 @@
from pathlib import Path
from clint.config import Config
from clint.index import SymbolIndex
from clint.linter import Position, Range, lint_file
from clint.rules.forbidden_top_level_import import ForbiddenTopLevelImport
def test_forbidden_top_level_import(index: SymbolIndex) -> None:
code = """
# Bad
import foo
from foo import bar
# Good
import baz
"""
config = Config(
select={ForbiddenTopLevelImport.name},
forbidden_top_level_imports={"*": ["foo"]},
)
violations = lint_file(Path("test.py"), code, config, index)
assert len(violations) == 2
assert all(isinstance(v.rule, ForbiddenTopLevelImport) for v in violations)
assert violations[0].range == Range(Position(2, 0))
assert violations[1].range == Range(Position(3, 0))
def test_nested_if_in_type_checking_block(index: SymbolIndex) -> None:
code = """
from typing import TYPE_CHECKING
if TYPE_CHECKING:
if True:
pass
import databricks # Should NOT be flagged
from databricks import foo # Should NOT be flagged
"""
config = Config(
select={ForbiddenTopLevelImport.name},
forbidden_top_level_imports={"*": ["databricks"]},
)
violations = lint_file(Path("test.py"), code, config, index)
# Should have no violations since imports are inside TYPE_CHECKING
assert len(violations) == 0

Some files were not shown because too many files have changed in this diff Show More