pycodegate 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pycodegate/__init__.py +3 -0
- pycodegate/_compat.py +15 -0
- pycodegate/api.py +26 -0
- pycodegate/cli.py +131 -0
- pycodegate/config.py +81 -0
- pycodegate/constants.py +32 -0
- pycodegate/discover.py +114 -0
- pycodegate/output.py +270 -0
- pycodegate/profile.py +107 -0
- pycodegate/rules/__init__.py +38 -0
- pycodegate/rules/architecture.py +121 -0
- pycodegate/rules/base.py +23 -0
- pycodegate/rules/complexity.py +87 -0
- pycodegate/rules/correctness.py +131 -0
- pycodegate/rules/dead_code.py +58 -0
- pycodegate/rules/dependencies.py +107 -0
- pycodegate/rules/django.py +144 -0
- pycodegate/rules/fastapi.py +70 -0
- pycodegate/rules/flask.py +115 -0
- pycodegate/rules/imports.py +96 -0
- pycodegate/rules/performance.py +104 -0
- pycodegate/rules/security.py +167 -0
- pycodegate/rules/structure.py +242 -0
- pycodegate/scan.py +201 -0
- pycodegate/score.py +69 -0
- pycodegate/types.py +65 -0
- pycodegate/utils/__init__.py +1 -0
- pycodegate/utils/ast_helpers.py +19 -0
- pycodegate/utils/badge.py +51 -0
- pycodegate/utils/diff.py +24 -0
- pycodegate/utils/file_discovery.py +62 -0
- pycodegate/utils/fixer.py +32 -0
- pycodegate/utils/precommit.py +48 -0
- pycodegate-0.1.0.dist-info/METADATA +357 -0
- pycodegate-0.1.0.dist-info/RECORD +38 -0
- pycodegate-0.1.0.dist-info/WHEEL +4 -0
- pycodegate-0.1.0.dist-info/entry_points.txt +2 -0
- pycodegate-0.1.0.dist-info/licenses/LICENSE +21 -0
pycodegate/types.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Core data types for python-doctor diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Severity(str, Enum):
|
|
10
|
+
ERROR = "error"
|
|
11
|
+
WARNING = "warning"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Category(str, Enum):
|
|
15
|
+
SECURITY = "Security"
|
|
16
|
+
PERFORMANCE = "Performance"
|
|
17
|
+
ARCHITECTURE = "Architecture"
|
|
18
|
+
CORRECTNESS = "Correctness"
|
|
19
|
+
COMPLEXITY = "Complexity"
|
|
20
|
+
DEAD_CODE = "Dead Code"
|
|
21
|
+
DJANGO = "Django"
|
|
22
|
+
FASTAPI = "FastAPI"
|
|
23
|
+
FLASK = "Flask"
|
|
24
|
+
STRUCTURE = "Structure"
|
|
25
|
+
IMPORTS = "Imports"
|
|
26
|
+
DEPENDENCIES = "Dependencies"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Diagnostic:
|
|
31
|
+
file_path: str
|
|
32
|
+
rule: str
|
|
33
|
+
severity: Severity
|
|
34
|
+
category: Category
|
|
35
|
+
message: str
|
|
36
|
+
help: str
|
|
37
|
+
line: int
|
|
38
|
+
column: int = 0
|
|
39
|
+
cost: float = field(default=1.0)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class ProjectInfo:
|
|
44
|
+
path: str
|
|
45
|
+
python_version: str | None
|
|
46
|
+
framework: str | None
|
|
47
|
+
package_manager: str | None
|
|
48
|
+
test_framework: str | None
|
|
49
|
+
has_type_hints: bool
|
|
50
|
+
source_file_count: int
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Score:
|
|
55
|
+
value: int
|
|
56
|
+
label: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class ScanResult:
|
|
61
|
+
score: Score
|
|
62
|
+
diagnostics: list[Diagnostic]
|
|
63
|
+
project: ProjectInfo
|
|
64
|
+
elapsed_ms: int
|
|
65
|
+
profile: str | None = None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Utility modules for python-doctor."""
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Common AST traversal utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_file(file_path: Path) -> tuple[str, ast.Module | None]:
|
|
13
|
+
"""Read and parse a Python file. Returns (source, tree) or (source, None) on error."""
|
|
14
|
+
try:
|
|
15
|
+
source = file_path.read_text(encoding="utf-8", errors="ignore")
|
|
16
|
+
tree = ast.parse(source)
|
|
17
|
+
return source, tree
|
|
18
|
+
except (SyntaxError, UnicodeDecodeError):
|
|
19
|
+
return "", None
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Badge and CI workflow generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import urllib.parse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def generate_badge(score: int, label: str) -> str:
|
|
9
|
+
"""Generate shields.io badge markdown."""
|
|
10
|
+
if score >= 90:
|
|
11
|
+
color = "brightgreen"
|
|
12
|
+
elif score >= 75:
|
|
13
|
+
color = "green"
|
|
14
|
+
elif score >= 50:
|
|
15
|
+
color = "yellow"
|
|
16
|
+
else:
|
|
17
|
+
color = "red"
|
|
18
|
+
|
|
19
|
+
score_text = urllib.parse.quote(f"{score}/100")
|
|
20
|
+
return f""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def generate_ci_workflow() -> str:
|
|
24
|
+
"""Generate GitHub Actions workflow YAML for auto-updating badge."""
|
|
25
|
+
return """name: Py Gate Score
|
|
26
|
+
on:
|
|
27
|
+
push:
|
|
28
|
+
branches: [main]
|
|
29
|
+
|
|
30
|
+
permissions:
|
|
31
|
+
contents: write
|
|
32
|
+
|
|
33
|
+
jobs:
|
|
34
|
+
score:
|
|
35
|
+
runs-on: ubuntu-latest
|
|
36
|
+
steps:
|
|
37
|
+
- uses: actions/checkout@v4
|
|
38
|
+
- uses: astral-sh/setup-uv@v5
|
|
39
|
+
- run: uv tool install py-gate
|
|
40
|
+
- name: Run py-gate
|
|
41
|
+
run: |
|
|
42
|
+
SCORE=$(py-gate . --score)
|
|
43
|
+
BADGE=$(py-gate . --badge)
|
|
44
|
+
# Update badge in README
|
|
45
|
+
sed -i "s|!\\[py-gate score\\](.*)|${BADGE}|" README.md
|
|
46
|
+
- name: Commit badge update
|
|
47
|
+
run: |
|
|
48
|
+
git config user.name "github-actions[bot]"
|
|
49
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
50
|
+
git diff --quiet README.md || (git add README.md && git commit -m "chore: update py-gate score badge" && git push)
|
|
51
|
+
"""
|
pycodegate/utils/diff.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Git diff utilities for scanning only changed files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_changed_files(project_path: str, base: str = "main") -> list[Path] | None:
|
|
10
|
+
"""Get Python files changed compared to a base branch. Returns None if git fails."""
|
|
11
|
+
try:
|
|
12
|
+
result = subprocess.run(
|
|
13
|
+
["git", "diff", "--name-only", "--diff-filter=ACMR", base, "--", "*.py"],
|
|
14
|
+
cwd=project_path,
|
|
15
|
+
capture_output=True,
|
|
16
|
+
text=True,
|
|
17
|
+
timeout=10,
|
|
18
|
+
)
|
|
19
|
+
if result.returncode != 0:
|
|
20
|
+
return None
|
|
21
|
+
root = Path(project_path)
|
|
22
|
+
return [root / f.strip() for f in result.stdout.strip().splitlines() if f.strip()]
|
|
23
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
24
|
+
return None
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""File discovery utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
IGNORE_DIRS = {
|
|
9
|
+
".venv",
|
|
10
|
+
"venv",
|
|
11
|
+
"env",
|
|
12
|
+
".env",
|
|
13
|
+
"node_modules",
|
|
14
|
+
"__pycache__",
|
|
15
|
+
".git",
|
|
16
|
+
".hg",
|
|
17
|
+
".svn",
|
|
18
|
+
"dist",
|
|
19
|
+
"build",
|
|
20
|
+
".eggs",
|
|
21
|
+
"*.egg-info",
|
|
22
|
+
".tox",
|
|
23
|
+
".nox",
|
|
24
|
+
".mypy_cache",
|
|
25
|
+
".pytest_cache",
|
|
26
|
+
".ruff_cache",
|
|
27
|
+
"htmlcov",
|
|
28
|
+
"site-packages",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def find_python_files(project_path: str) -> list[Path]:
|
|
33
|
+
"""Find all Python files in the project, respecting gitignore."""
|
|
34
|
+
root = Path(project_path)
|
|
35
|
+
|
|
36
|
+
# Try git ls-files first
|
|
37
|
+
try:
|
|
38
|
+
result = subprocess.run(
|
|
39
|
+
["git", "ls-files", "--cached", "--others", "--exclude-standard", "*.py"],
|
|
40
|
+
cwd=root,
|
|
41
|
+
capture_output=True,
|
|
42
|
+
text=True,
|
|
43
|
+
timeout=10,
|
|
44
|
+
)
|
|
45
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
46
|
+
return [root / f for f in result.stdout.strip().splitlines() if f.endswith(".py")]
|
|
47
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
# Fallback: walk filesystem
|
|
51
|
+
return _walk_for_python_files(root)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _walk_for_python_files(root: Path) -> list[Path]:
|
|
55
|
+
files: list[Path] = []
|
|
56
|
+
for path in root.rglob("*.py"):
|
|
57
|
+
if not any(
|
|
58
|
+
part in IGNORE_DIRS or part.endswith(".egg-info")
|
|
59
|
+
for part in path.relative_to(root).parts
|
|
60
|
+
):
|
|
61
|
+
files.append(path)
|
|
62
|
+
return files
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Auto-fix utilities using ruff."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def run_ruff_fix(project_path: str) -> int:
|
|
10
|
+
"""Run ``ruff check --fix`` on *project_path* and return the number of fixes applied.
|
|
11
|
+
|
|
12
|
+
Returns 0 if ruff is not installed or if no fixes were made.
|
|
13
|
+
Raises no exceptions — all subprocess errors are caught gracefully.
|
|
14
|
+
"""
|
|
15
|
+
try:
|
|
16
|
+
result = subprocess.run(
|
|
17
|
+
["ruff", "check", "--fix", project_path],
|
|
18
|
+
capture_output=True,
|
|
19
|
+
text=True,
|
|
20
|
+
)
|
|
21
|
+
except FileNotFoundError:
|
|
22
|
+
# ruff is not installed
|
|
23
|
+
return -1
|
|
24
|
+
except Exception:
|
|
25
|
+
return 0
|
|
26
|
+
|
|
27
|
+
# ruff prints something like "Fixed 3 errors." to stdout or stderr
|
|
28
|
+
output = result.stdout + result.stderr
|
|
29
|
+
match = re.search(r"Fixed (\d+)", output)
|
|
30
|
+
if match:
|
|
31
|
+
return int(match.group(1))
|
|
32
|
+
return 0
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Pre-commit hook installer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def install_precommit_hook(project_path: str, min_score: int = 50) -> str:
|
|
9
|
+
"""Install a git pre-commit hook that runs py-gate."""
|
|
10
|
+
hooks_dir = Path(project_path) / ".git" / "hooks"
|
|
11
|
+
if not hooks_dir.exists():
|
|
12
|
+
return "Error: not a git repository"
|
|
13
|
+
|
|
14
|
+
hook_path = hooks_dir / "pre-commit"
|
|
15
|
+
hook_content = f"""#!/bin/sh
|
|
16
|
+
# py-gate pre-commit hook
|
|
17
|
+
SCORE=$(py-gate . --score 2>/dev/null)
|
|
18
|
+
if [ $? -ne 0 ]; then
|
|
19
|
+
echo "py-gate not found. Install with: uvx py-gate"
|
|
20
|
+
exit 0
|
|
21
|
+
fi
|
|
22
|
+
if [ "$SCORE" -lt {min_score} ]; then
|
|
23
|
+
echo "py-gate: score $SCORE is below minimum {min_score}. Fix issues before committing."
|
|
24
|
+
py-gate . 2>/dev/null
|
|
25
|
+
exit 1
|
|
26
|
+
fi
|
|
27
|
+
echo "py-gate: score $SCORE/{min_score} — passed"
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
# Don't overwrite existing hook — append or warn
|
|
31
|
+
if hook_path.exists():
|
|
32
|
+
existing = hook_path.read_text()
|
|
33
|
+
if "py-gate" in existing:
|
|
34
|
+
hook_path.write_text(hook_content)
|
|
35
|
+
hook_path.chmod(0o755)
|
|
36
|
+
return f"Updated pre-commit hook at {hook_path} (min-score: {min_score})"
|
|
37
|
+
else:
|
|
38
|
+
# Append to existing hook
|
|
39
|
+
# Strip the shebang line before appending
|
|
40
|
+
lines = hook_content.splitlines(keepends=True)
|
|
41
|
+
body = "".join(lines[1:]) if lines[0].startswith("#!/") else hook_content
|
|
42
|
+
with open(hook_path, "a") as f:
|
|
43
|
+
f.write("\n" + body)
|
|
44
|
+
return f"Appended py-gate to existing pre-commit hook at {hook_path}"
|
|
45
|
+
|
|
46
|
+
hook_path.write_text(hook_content)
|
|
47
|
+
hook_path.chmod(0o755)
|
|
48
|
+
return f"Installed pre-commit hook at {hook_path} (min-score: {min_score})"
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pycodegate
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: One command. One score. Your Python quality gate.
|
|
5
|
+
Project-URL: Homepage, https://github.com/themohitkhare/pycodegate
|
|
6
|
+
Project-URL: Repository, https://github.com/themohitkhare/pycodegate
|
|
7
|
+
Project-URL: Issues, https://github.com/themohitkhare/pycodegate/issues
|
|
8
|
+
Author: Mohit Khare
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai-agents,code-quality,developer-tools,linting,python,quality-gate,static-analysis
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
22
|
+
Classifier: Topic :: Software Development :: Testing
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: click>=8.1
|
|
26
|
+
Requires-Dist: rich>=13.0
|
|
27
|
+
Requires-Dist: tomli>=2.0; python_version < '3.11'
|
|
28
|
+
Requires-Dist: vulture>=2.11
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
<h1 align="center">Py Gate</h1>
|
|
36
|
+
<p align="center">
|
|
37
|
+
<strong>One command. One score. Your Python quality gate.</strong>
|
|
38
|
+
</p>
|
|
39
|
+
<p align="center">
|
|
40
|
+
<a href="https://github.com/themohitkhare/pycodegate/actions/workflows/ci.yml"><img src="https://github.com/themohitkhare/pycodegate/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
41
|
+
<a href="https://pypi.org/project/py-gate/"><img src="https://img.shields.io/pypi/v/py-gate" alt="PyPI"></a>
|
|
42
|
+
<a href="https://pypi.org/project/py-gate/"><img src="https://img.shields.io/pypi/pyversions/py-gate" alt="Python"></a>
|
|
43
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"></a>
|
|
44
|
+
</p>
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## How it works
|
|
49
|
+
|
|
50
|
+
Py Gate starts by detecting your project's context: framework (Django, FastAPI, Flask), Python version, package manager (uv, poetry, pip), and test framework. That context drives which rules are active — Django projects get SQL injection checks, FastAPI projects get async-correctness checks, and so on.
|
|
51
|
+
|
|
52
|
+
It then runs two analysis passes **in parallel**: a lint pass that evaluates 40+ rules across 8 categories (Security, Correctness, Complexity, Architecture, Performance, Structure, Imports, Dead Code), and a dead-code pass powered by [Vulture](https://github.com/jendrikseipp/vulture) that finds unused functions, classes, imports, and variables.
|
|
53
|
+
|
|
54
|
+
Findings are filtered through your configuration and scored using a **weighted category-budget system**. Each category has a maximum deduction budget proportional to its weight. Within a category the top 3 findings apply at full cost; additional findings apply diminishing returns (10% each), so fixing the worst issues always moves the needle. The final result is a **0–100 health score** with a label: Excellent (90+), Great (75–89), Needs work (50–74), or Critical (<50).
|
|
55
|
+
|
|
56
|
+
## Install
|
|
57
|
+
|
|
58
|
+
Run instantly with `uvx` — no install needed:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
uvx py-gate .
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Install globally with pipx or uv:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pipx install py-gate
|
|
68
|
+
# or
|
|
69
|
+
uv tool install py-gate
|
|
70
|
+
# or
|
|
71
|
+
pip install py-gate
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Quick Start
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# Basic scan — score + summary
|
|
78
|
+
py-gate .
|
|
79
|
+
|
|
80
|
+
# Show file paths and line numbers for every finding
|
|
81
|
+
py-gate . --verbose
|
|
82
|
+
|
|
83
|
+
# Machine-readable output for AI agents and CI
|
|
84
|
+
py-gate . --json
|
|
85
|
+
|
|
86
|
+
# Auto-fix ruff-fixable issues, then scan
|
|
87
|
+
py-gate . --fix
|
|
88
|
+
|
|
89
|
+
# Output only the numeric score (useful in scripts)
|
|
90
|
+
py-gate . --score
|
|
91
|
+
|
|
92
|
+
# Scan only files changed vs a base branch
|
|
93
|
+
py-gate . --diff main
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## JSON Output
|
|
97
|
+
|
|
98
|
+
Pass `--json` to get structured output that AI agents and CI pipelines can parse:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
py-gate . --json
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"version": "0.1.0",
|
|
107
|
+
"path": ".",
|
|
108
|
+
"score": 87,
|
|
109
|
+
"label": "Great",
|
|
110
|
+
"errors": 1,
|
|
111
|
+
"warnings": 4,
|
|
112
|
+
"elapsed_ms": 212,
|
|
113
|
+
"project": {
|
|
114
|
+
"framework": "fastapi",
|
|
115
|
+
"python_version": "3.12",
|
|
116
|
+
"package_manager": "uv",
|
|
117
|
+
"test_framework": "pytest"
|
|
118
|
+
},
|
|
119
|
+
"diagnostics": [
|
|
120
|
+
{
|
|
121
|
+
"rule": "no-mutable-default",
|
|
122
|
+
"severity": "error",
|
|
123
|
+
"category": "Correctness",
|
|
124
|
+
"message": "Mutable default argument `[]` is shared across all calls",
|
|
125
|
+
"file_path": "src/api/routes.py",
|
|
126
|
+
"line": 34
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"rule": "high-complexity",
|
|
130
|
+
"severity": "warning",
|
|
131
|
+
"category": "Complexity",
|
|
132
|
+
"message": "Function 'process_order' has cyclomatic complexity 17 (max 15)",
|
|
133
|
+
"file_path": "src/api/orders.py",
|
|
134
|
+
"line": 88
|
|
135
|
+
}
|
|
136
|
+
]
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Agent Integration
|
|
141
|
+
|
|
142
|
+
Py Gate is designed to be used by AI coding agents. Add it to your agent's context so it runs after every Python change.
|
|
143
|
+
|
|
144
|
+
### Claude Code
|
|
145
|
+
|
|
146
|
+
Add the skill to your project:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
mkdir -p .claude/skills
|
|
150
|
+
curl -fsSL https://raw.githubusercontent.com/themohitkhare/pycodegate/main/skills/py-gate/SKILL.md \
|
|
151
|
+
-o .claude/skills/py-gate.md
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Or copy `AGENTS.md` to your project root — Claude Code picks it up automatically.
|
|
155
|
+
|
|
156
|
+
### Cursor
|
|
157
|
+
|
|
158
|
+
Add to `.cursor/rules/py-gate.mdc`:
|
|
159
|
+
|
|
160
|
+
```markdown
|
|
161
|
+
After modifying Python files, run `uvx py-gate . --json` and fix all findings
|
|
162
|
+
with severity "error" before marking the task complete. Target score: 80+.
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Windsurf
|
|
166
|
+
|
|
167
|
+
Add to `.windsurfrules`:
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
After modifying Python files, run: uvx py-gate . --json
|
|
171
|
+
Fix all "error" severity findings. Re-run to verify the score improved.
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Codex
|
|
175
|
+
|
|
176
|
+
Add to your system prompt:
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
After modifying Python files, run `uvx py-gate . --json` to check code quality.
|
|
180
|
+
Fix errors first. Target score: 80+.
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Aider
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
aider --read AGENTS.md
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## GitHub Actions
|
|
190
|
+
|
|
191
|
+
```yaml
|
|
192
|
+
name: Quality Gate
|
|
193
|
+
|
|
194
|
+
on: [push, pull_request]
|
|
195
|
+
|
|
196
|
+
jobs:
|
|
197
|
+
py-gate:
|
|
198
|
+
runs-on: ubuntu-latest
|
|
199
|
+
steps:
|
|
200
|
+
- uses: actions/checkout@v4
|
|
201
|
+
with:
|
|
202
|
+
fetch-depth: 0 # required for --diff
|
|
203
|
+
|
|
204
|
+
- uses: actions/setup-python@v5
|
|
205
|
+
with:
|
|
206
|
+
python-version: "3.12"
|
|
207
|
+
|
|
208
|
+
- name: Run Py Gate
|
|
209
|
+
run: |
|
|
210
|
+
pip install py-gate
|
|
211
|
+
py-gate . --verbose --diff main --fail-on error
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## CLI Reference
|
|
215
|
+
|
|
216
|
+
| Flag | Default | Description |
|
|
217
|
+
|------|---------|-------------|
|
|
218
|
+
| `[DIRECTORY]` | `.` | Path to the Python project to scan |
|
|
219
|
+
| `--lint / --no-lint` | on | Enable or disable lint checks |
|
|
220
|
+
| `--dead-code / --no-dead-code` | on | Enable or disable dead code detection |
|
|
221
|
+
| `--verbose` | off | Show file path and line number per finding |
|
|
222
|
+
| `--score` | off | Print only the numeric score and exit |
|
|
223
|
+
| `--json` | off | Emit structured JSON (for agents and CI) |
|
|
224
|
+
| `--fix` | off | Run `ruff --fix` before scanning |
|
|
225
|
+
| `--diff TEXT` | — | Scan only files changed vs this base branch |
|
|
226
|
+
| `--fail-on [error\|warning\|none]` | `none` | Exit code 1 when findings at this level exist |
|
|
227
|
+
| `-v, --version` | — | Show version and exit |
|
|
228
|
+
| `-h, --help` | — | Show help and exit |
|
|
229
|
+
|
|
230
|
+
## What It Checks
|
|
231
|
+
|
|
232
|
+
| Category | Weight | Max Deduction | What it catches |
|
|
233
|
+
|----------|--------|---------------|-----------------|
|
|
234
|
+
| Security | 5 | ~24 pts | `eval`, `exec`, `pickle.load`, unsafe YAML, hardcoded secrets, weak hashes |
|
|
235
|
+
| Correctness | 4 | ~19 pts | Mutable defaults, bare/broad except, assert in production, bad `__init__` return |
|
|
236
|
+
| Complexity | 3 | ~14 pts | Cyclomatic complexity > 15 (warning) or > 25 (error) |
|
|
237
|
+
| Architecture | 3 | ~14 pts | Giant modules (>500 lines), deep nesting (>5), god functions (>50 lines), too many args (>7) |
|
|
238
|
+
| Performance | 2 | ~10 pts | String concat in loops, imports inside functions, star imports |
|
|
239
|
+
| Structure | 2 | ~10 pts | Missing `__init__.py`, missing tests directory, no type hints |
|
|
240
|
+
| Imports | 1 | ~5 pts | Circular imports, wildcard imports, import order issues |
|
|
241
|
+
| Dead Code | 1 | ~5 pts | Unused functions, classes, variables, and imports via Vulture |
|
|
242
|
+
|
|
243
|
+
Framework-specific rules (Django, FastAPI, Flask) are mapped into the Security or Correctness budget.
|
|
244
|
+
|
|
245
|
+
## Scoring
|
|
246
|
+
|
|
247
|
+
Py Gate uses a **weighted category-budget system**:
|
|
248
|
+
|
|
249
|
+
1. Each category has a weight (see table above). Weights are normalized to sum to 100 points of total deduction budget.
|
|
250
|
+
2. Within a category, findings are sorted by cost (errors cost more than warnings). The top 3 findings apply at full cost; every additional finding applies **diminishing returns** (10% of its cost).
|
|
251
|
+
3. A category's deduction is capped at its budget, so a single broken category can never zero out an otherwise healthy project.
|
|
252
|
+
4. Final score = `100 - sum(capped category deductions)`, floored at 0.
|
|
253
|
+
|
|
254
|
+
| Score | Label | Meaning |
|
|
255
|
+
|-------|-------|---------|
|
|
256
|
+
| 90–100 | Excellent | Production-ready |
|
|
257
|
+
| 75–89 | Great | Minor issues to address |
|
|
258
|
+
| 50–74 | Needs work | Significant issues present |
|
|
259
|
+
| 0–49 | Critical | Blocking issues, do not ship |
|
|
260
|
+
|
|
261
|
+
## Configuration
|
|
262
|
+
|
|
263
|
+
Create `py-gate.toml` in your project root:
|
|
264
|
+
|
|
265
|
+
```toml
|
|
266
|
+
[options]
|
|
267
|
+
lint = true
|
|
268
|
+
dead_code = true
|
|
269
|
+
verbose = false
|
|
270
|
+
fail_on = "none"
|
|
271
|
+
|
|
272
|
+
[ignore]
|
|
273
|
+
rules = ["dead-code", "no-import-in-function"]
|
|
274
|
+
files = ["tests/fixtures/**", "migrations/**", "scripts/**"]
|
|
275
|
+
|
|
276
|
+
[per-file-ignores]
|
|
277
|
+
"src/legacy/*.py" = ["high-complexity", "no-god-function"]
|
|
278
|
+
|
|
279
|
+
[scoring]
|
|
280
|
+
max-deduction.Security = 20
|
|
281
|
+
max-deduction.Dead Code = 0 # disable dead-code penalty entirely
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Or use `pyproject.toml`:
|
|
285
|
+
|
|
286
|
+
```toml
|
|
287
|
+
[tool.py-gate]
|
|
288
|
+
lint = true
|
|
289
|
+
dead_code = true
|
|
290
|
+
fail_on = "error"
|
|
291
|
+
|
|
292
|
+
[tool.py-gate.ignore]
|
|
293
|
+
rules = ["no-import-in-function"]
|
|
294
|
+
files = ["tests/fixtures/**"]
|
|
295
|
+
|
|
296
|
+
[tool.py-gate.per-file-ignores]
|
|
297
|
+
"src/legacy/*.py" = ["high-complexity"]
|
|
298
|
+
|
|
299
|
+
[tool.py-gate.scoring]
|
|
300
|
+
"max-deduction" = { Security = 20, "Dead Code" = 0 }
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
If both files exist, `py-gate.toml` takes precedence. CLI flags always override config values.
|
|
304
|
+
|
|
305
|
+
## Profiles
|
|
306
|
+
|
|
307
|
+
Py Gate auto-detects a project profile and adjusts rule weights accordingly. You can also set a profile explicitly in config (`profile = "library"`).
|
|
308
|
+
|
|
309
|
+
| Profile | Auto-detected when | Adjustments |
|
|
310
|
+
|---------|--------------------|-------------|
|
|
311
|
+
| `cli` | `[project.scripts]` in pyproject.toml | Architecture rules weighted up; dead-code weighted down |
|
|
312
|
+
| `web` | Django / FastAPI / Flask detected | Security and correctness weighted up; framework rules active |
|
|
313
|
+
| `library` | No scripts, no framework, has `py.typed` | Public API checks active; dead-code weighted up |
|
|
314
|
+
| `script` | Single-file project or `scripts/` directory | Architecture rules relaxed; complexity thresholds raised |
|
|
315
|
+
|
|
316
|
+
## Pre-commit Hook
|
|
317
|
+
|
|
318
|
+
Use `--pre-commit` to run Py Gate as a pre-commit hook. It automatically scans only the staged files:
|
|
319
|
+
|
|
320
|
+
```bash
|
|
321
|
+
py-gate . --pre-commit --fail-on error
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Add to `.pre-commit-config.yaml`:
|
|
325
|
+
|
|
326
|
+
```yaml
|
|
327
|
+
repos:
|
|
328
|
+
- repo: local
|
|
329
|
+
hooks:
|
|
330
|
+
- id: py-gate
|
|
331
|
+
name: Py Gate quality check
|
|
332
|
+
entry: py-gate . --pre-commit --fail-on error
|
|
333
|
+
language: system
|
|
334
|
+
types: [python]
|
|
335
|
+
pass_filenames: false
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
## Contributing
|
|
339
|
+
|
|
340
|
+
```bash
|
|
341
|
+
git clone https://github.com/themohitkhare/pycodegate
|
|
342
|
+
cd pycodegate
|
|
343
|
+
uv sync --all-extras
|
|
344
|
+
uv run pytest -q
|
|
345
|
+
uv run py-gate . --verbose # dogfood it
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
To add a new rule:
|
|
349
|
+
|
|
350
|
+
1. Create a file in `src/pycodegate/rules/` extending `BaseRules`
|
|
351
|
+
2. Implement `check(self, source: str, filename: str) -> list[Diagnostic]`
|
|
352
|
+
3. Register it in `src/pycodegate/rules/__init__.py`
|
|
353
|
+
4. Add tests in `tests/rules/`
|
|
354
|
+
|
|
355
|
+
## License
|
|
356
|
+
|
|
357
|
+
MIT
|