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/__init__.py
ADDED
pycodegate/_compat.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Python version compatibility shims."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
if sys.version_info >= (3, 11):
|
|
8
|
+
import tomllib
|
|
9
|
+
else:
|
|
10
|
+
try:
|
|
11
|
+
import tomllib
|
|
12
|
+
except ModuleNotFoundError:
|
|
13
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
14
|
+
|
|
15
|
+
__all__ = ["tomllib"]
|
pycodegate/api.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Programmatic API for python-doctor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from pycodegate.config import load_config
|
|
8
|
+
from pycodegate.scan import scan_project
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from pycodegate.types import ScanResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def diagnose(
|
|
15
|
+
project_path: str = ".",
|
|
16
|
+
*,
|
|
17
|
+
lint: bool = True,
|
|
18
|
+
dead_code: bool = True,
|
|
19
|
+
diff_base: str | None = None,
|
|
20
|
+
) -> ScanResult:
|
|
21
|
+
"""Run python-doctor analysis programmatically."""
|
|
22
|
+
config = load_config(project_path)
|
|
23
|
+
config.lint = lint
|
|
24
|
+
config.dead_code = dead_code
|
|
25
|
+
|
|
26
|
+
return scan_project(project_path, config, diff_base=diff_base)
|
pycodegate/cli.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""CLI entry point for py-doctor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from pycodegate import __version__
|
|
10
|
+
from pycodegate.config import load_config
|
|
11
|
+
from pycodegate.output import output_json, print_scan_result
|
|
12
|
+
from pycodegate.scan import scan_project
|
|
13
|
+
from pycodegate.types import Severity
|
|
14
|
+
from pycodegate.utils.badge import generate_badge, generate_ci_workflow
|
|
15
|
+
from pycodegate.utils.fixer import run_ruff_fix
|
|
16
|
+
from pycodegate.utils.precommit import install_precommit_hook
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
|
|
20
|
+
@click.version_option(__version__, "-v", "--version", prog_name="Py Gate")
|
|
21
|
+
@click.argument("directory", default=".", type=click.Path(exists=True))
|
|
22
|
+
@click.option("--lint/--no-lint", default=True, help="Enable/disable lint checks.")
|
|
23
|
+
@click.option(
|
|
24
|
+
"--dead-code/--no-dead-code", default=True, help="Enable/disable dead code detection."
|
|
25
|
+
)
|
|
26
|
+
@click.option("--verbose", is_flag=True, help="Show file details per rule.")
|
|
27
|
+
@click.option("--score", "score_only", is_flag=True, help="Output only the numeric score.")
|
|
28
|
+
@click.option("--json", "json_output", is_flag=True, help="Output results as JSON.")
|
|
29
|
+
@click.option(
|
|
30
|
+
"--diff",
|
|
31
|
+
"diff_base",
|
|
32
|
+
default=None,
|
|
33
|
+
type=str,
|
|
34
|
+
help="Scan only files changed vs base branch.",
|
|
35
|
+
)
|
|
36
|
+
@click.option(
|
|
37
|
+
"--fail-on",
|
|
38
|
+
type=click.Choice(["error", "warning", "none"]),
|
|
39
|
+
default="none",
|
|
40
|
+
help="Exit with code 1 on this severity level.",
|
|
41
|
+
)
|
|
42
|
+
@click.option("--fix", "fix", is_flag=True, default=False, help="Auto-fix issues via ruff.")
|
|
43
|
+
@click.option(
|
|
44
|
+
"--profile",
|
|
45
|
+
"profile_override",
|
|
46
|
+
type=click.Choice(["cli", "web", "library", "script"]),
|
|
47
|
+
default=None,
|
|
48
|
+
help="Override auto-detected project profile.",
|
|
49
|
+
)
|
|
50
|
+
@click.option(
|
|
51
|
+
"--badge", "badge", is_flag=True, default=False, help="Output shields.io badge markdown."
|
|
52
|
+
)
|
|
53
|
+
@click.option(
|
|
54
|
+
"--ci", "ci_workflow", is_flag=True, default=False, help="Output GitHub Actions workflow YAML."
|
|
55
|
+
)
|
|
56
|
+
@click.option(
|
|
57
|
+
"--pre-commit", "precommit", is_flag=True, default=False, help="Install git pre-commit hook."
|
|
58
|
+
)
|
|
59
|
+
@click.option(
|
|
60
|
+
"--min-score",
|
|
61
|
+
"min_score",
|
|
62
|
+
type=int,
|
|
63
|
+
default=None,
|
|
64
|
+
help="Minimum score threshold (exit 1 if below).",
|
|
65
|
+
)
|
|
66
|
+
def main(
|
|
67
|
+
directory: str,
|
|
68
|
+
lint: bool,
|
|
69
|
+
dead_code: bool,
|
|
70
|
+
verbose: bool,
|
|
71
|
+
score_only: bool,
|
|
72
|
+
json_output: bool,
|
|
73
|
+
diff_base: str | None,
|
|
74
|
+
fail_on: str,
|
|
75
|
+
fix: bool,
|
|
76
|
+
profile_override: str | None,
|
|
77
|
+
badge: bool,
|
|
78
|
+
ci_workflow: bool,
|
|
79
|
+
precommit: bool,
|
|
80
|
+
min_score: int | None,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Py Gate — Diagnose your Python project's health."""
|
|
83
|
+
if ci_workflow:
|
|
84
|
+
click.echo(generate_ci_workflow(), nl=False)
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
if precommit:
|
|
88
|
+
effective_min = min_score if min_score is not None else 50
|
|
89
|
+
msg = install_precommit_hook(directory, min_score=effective_min)
|
|
90
|
+
click.echo(msg)
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
if fix:
|
|
94
|
+
fixes = run_ruff_fix(directory)
|
|
95
|
+
if fixes == -1:
|
|
96
|
+
click.echo("ruff not found, skipping auto-fix")
|
|
97
|
+
else:
|
|
98
|
+
click.echo(f"Fixed {fixes} issues via ruff")
|
|
99
|
+
|
|
100
|
+
config = load_config(directory)
|
|
101
|
+
config.lint = lint
|
|
102
|
+
config.dead_code = dead_code
|
|
103
|
+
config.verbose = verbose
|
|
104
|
+
config.fail_on = fail_on
|
|
105
|
+
if profile_override is not None:
|
|
106
|
+
config.profile = profile_override
|
|
107
|
+
|
|
108
|
+
result = scan_project(directory, config, diff_base=diff_base)
|
|
109
|
+
|
|
110
|
+
if badge:
|
|
111
|
+
click.echo(generate_badge(result.score.value, result.score.label))
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
if json_output:
|
|
115
|
+
output_json(result)
|
|
116
|
+
elif score_only:
|
|
117
|
+
click.echo(str(result.score.value))
|
|
118
|
+
else:
|
|
119
|
+
print_scan_result(result, verbose=verbose)
|
|
120
|
+
|
|
121
|
+
# Exit code based on --fail-on
|
|
122
|
+
has_errors = any(d.severity == Severity.ERROR for d in result.diagnostics)
|
|
123
|
+
if (fail_on == "error" and has_errors) or (fail_on == "warning" and result.diagnostics):
|
|
124
|
+
sys.exit(1)
|
|
125
|
+
|
|
126
|
+
if min_score is not None and result.score.value < min_score:
|
|
127
|
+
click.echo(
|
|
128
|
+
f"py-gate: score {result.score.value} is below minimum {min_score}",
|
|
129
|
+
err=True,
|
|
130
|
+
)
|
|
131
|
+
sys.exit(1)
|
pycodegate/config.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Configuration loading for py-gate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pycodegate._compat import tomllib
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Config:
|
|
13
|
+
lint: bool = True
|
|
14
|
+
dead_code: bool = True
|
|
15
|
+
verbose: bool = False
|
|
16
|
+
fail_on: str = "none"
|
|
17
|
+
ignore_rules: list[str] = field(default_factory=list)
|
|
18
|
+
ignore_files: list[str] = field(default_factory=list)
|
|
19
|
+
profile: str | None = None
|
|
20
|
+
per_file_suppress: dict[str, list[str]] = field(default_factory=dict)
|
|
21
|
+
max_deduction: dict[str, int] = field(default_factory=dict)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_config(project_path: str) -> Config:
|
|
25
|
+
"""Load config from py-gate.toml or pyproject.toml [tool.py-gate]."""
|
|
26
|
+
root = Path(project_path)
|
|
27
|
+
|
|
28
|
+
# py-gate.toml takes precedence
|
|
29
|
+
doctor_toml = root / "py-gate.toml"
|
|
30
|
+
if doctor_toml.exists():
|
|
31
|
+
return _parse_doctor_toml(doctor_toml)
|
|
32
|
+
|
|
33
|
+
# Fall back to pyproject.toml
|
|
34
|
+
pyproject = root / "pyproject.toml"
|
|
35
|
+
if pyproject.exists():
|
|
36
|
+
return _parse_pyproject_toml(pyproject)
|
|
37
|
+
|
|
38
|
+
return Config()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _parse_doctor_toml(path: Path) -> Config:
|
|
42
|
+
with open(path, "rb") as f:
|
|
43
|
+
data = tomllib.load(f)
|
|
44
|
+
|
|
45
|
+
options = data.get("options", {})
|
|
46
|
+
ignore = data.get("ignore", {})
|
|
47
|
+
|
|
48
|
+
return Config(
|
|
49
|
+
lint=options.get("lint", True),
|
|
50
|
+
dead_code=options.get("dead_code", True),
|
|
51
|
+
verbose=options.get("verbose", False),
|
|
52
|
+
fail_on=options.get("fail_on", "none"),
|
|
53
|
+
ignore_rules=ignore.get("rules", []),
|
|
54
|
+
ignore_files=ignore.get("files", []),
|
|
55
|
+
profile=options.get("profile", None),
|
|
56
|
+
per_file_suppress=data.get("per-file-suppress", {}),
|
|
57
|
+
max_deduction=data.get("max-deduction", {}),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parse_pyproject_toml(path: Path) -> Config:
|
|
62
|
+
with open(path, "rb") as f:
|
|
63
|
+
data = tomllib.load(f)
|
|
64
|
+
|
|
65
|
+
section = data.get("tool", {}).get("py-gate", {})
|
|
66
|
+
if not section:
|
|
67
|
+
return Config()
|
|
68
|
+
|
|
69
|
+
ignore = section.get("ignore", {})
|
|
70
|
+
|
|
71
|
+
return Config(
|
|
72
|
+
lint=section.get("lint", True),
|
|
73
|
+
dead_code=section.get("dead_code", True),
|
|
74
|
+
verbose=section.get("verbose", False),
|
|
75
|
+
fail_on=section.get("fail_on", "none"),
|
|
76
|
+
ignore_rules=ignore.get("rules", []),
|
|
77
|
+
ignore_files=ignore.get("files", []),
|
|
78
|
+
profile=section.get("profile", None),
|
|
79
|
+
per_file_suppress=section.get("per-file-suppress", {}),
|
|
80
|
+
max_deduction=section.get("max-deduction", {}),
|
|
81
|
+
)
|
pycodegate/constants.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Constants and thresholds for python-doctor."""
|
|
2
|
+
|
|
3
|
+
from pycodegate.types import Category
|
|
4
|
+
|
|
5
|
+
# Score thresholds
|
|
6
|
+
SCORE_GREAT = 75
|
|
7
|
+
SCORE_NEEDS_WORK = 50
|
|
8
|
+
|
|
9
|
+
# Labels
|
|
10
|
+
LABEL_EXCELLENT = "Excellent"
|
|
11
|
+
LABEL_GREAT = "Great"
|
|
12
|
+
LABEL_NEEDS_WORK = "Needs work"
|
|
13
|
+
LABEL_CRITICAL = "Critical"
|
|
14
|
+
|
|
15
|
+
# Category weights for budget-based scoring
|
|
16
|
+
CATEGORY_WEIGHTS: dict = {
|
|
17
|
+
Category.SECURITY: 5,
|
|
18
|
+
Category.CORRECTNESS: 4,
|
|
19
|
+
Category.COMPLEXITY: 3,
|
|
20
|
+
Category.ARCHITECTURE: 3,
|
|
21
|
+
Category.PERFORMANCE: 2,
|
|
22
|
+
Category.STRUCTURE: 2,
|
|
23
|
+
Category.IMPORTS: 1,
|
|
24
|
+
Category.DEAD_CODE: 1,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# Framework categories map to their parent category for scoring
|
|
28
|
+
FRAMEWORK_CATEGORY_MAP: dict = {
|
|
29
|
+
Category.DJANGO: Category.SECURITY,
|
|
30
|
+
Category.FASTAPI: Category.CORRECTNESS,
|
|
31
|
+
Category.FLASK: Category.SECURITY,
|
|
32
|
+
}
|
pycodegate/discover.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Project discovery: detect framework, Python version, package manager, etc."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pycodegate._compat import tomllib
|
|
9
|
+
from pycodegate.types import ProjectInfo
|
|
10
|
+
from pycodegate.utils.file_discovery import find_python_files
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def discover_project(project_path: str) -> ProjectInfo:
|
|
14
|
+
"""Auto-detect project characteristics."""
|
|
15
|
+
root = Path(project_path)
|
|
16
|
+
deps = _collect_all_deps(root)
|
|
17
|
+
|
|
18
|
+
return ProjectInfo(
|
|
19
|
+
path=project_path,
|
|
20
|
+
python_version=_detect_python_version(root),
|
|
21
|
+
framework=_detect_framework(deps),
|
|
22
|
+
package_manager=_detect_package_manager(root),
|
|
23
|
+
test_framework=_detect_test_framework(deps),
|
|
24
|
+
has_type_hints=_detect_type_hints(root),
|
|
25
|
+
source_file_count=len(find_python_files(project_path)),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _collect_all_deps(root: Path) -> set[str]:
|
|
30
|
+
"""Collect dependency names from all sources."""
|
|
31
|
+
deps: set[str] = set()
|
|
32
|
+
|
|
33
|
+
# pyproject.toml
|
|
34
|
+
pyproject = root / "pyproject.toml"
|
|
35
|
+
if pyproject.exists():
|
|
36
|
+
with open(pyproject, "rb") as f:
|
|
37
|
+
data = tomllib.load(f)
|
|
38
|
+
project = data.get("project", {})
|
|
39
|
+
for dep in project.get("dependencies", []):
|
|
40
|
+
deps.add(_parse_dep_name(dep))
|
|
41
|
+
for group_deps in project.get("optional-dependencies", {}).values():
|
|
42
|
+
for dep in group_deps:
|
|
43
|
+
deps.add(_parse_dep_name(dep))
|
|
44
|
+
# Poetry format
|
|
45
|
+
poetry = data.get("tool", {}).get("poetry", {})
|
|
46
|
+
for dep in poetry.get("dependencies", {}):
|
|
47
|
+
deps.add(dep.lower())
|
|
48
|
+
for group in poetry.get("group", {}).values():
|
|
49
|
+
for dep in group.get("dependencies", {}):
|
|
50
|
+
deps.add(dep.lower())
|
|
51
|
+
|
|
52
|
+
# requirements.txt
|
|
53
|
+
for req_file in ["requirements.txt", "requirements-dev.txt", "requirements_dev.txt"]:
|
|
54
|
+
req_path = root / req_file
|
|
55
|
+
if req_path.exists():
|
|
56
|
+
for line in req_path.read_text().splitlines():
|
|
57
|
+
line = line.strip()
|
|
58
|
+
if line and not line.startswith(("#", "-")):
|
|
59
|
+
deps.add(_parse_dep_name(line))
|
|
60
|
+
|
|
61
|
+
return deps
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _parse_dep_name(dep_str: str) -> str:
|
|
65
|
+
"""Extract package name from a dependency specifier like 'django>=4.2'."""
|
|
66
|
+
return re.split(r"[><=!~\[;@\s]", dep_str)[0].strip().lower()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _detect_framework(deps: set[str]) -> str | None:
|
|
70
|
+
if "django" in deps or "django-rest-framework" in deps or "djangorestframework" in deps:
|
|
71
|
+
return "django"
|
|
72
|
+
if "fastapi" in deps:
|
|
73
|
+
return "fastapi"
|
|
74
|
+
if "flask" in deps:
|
|
75
|
+
return "flask"
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _detect_package_manager(root: Path) -> str | None:
|
|
80
|
+
if (root / "uv.lock").exists():
|
|
81
|
+
return "uv"
|
|
82
|
+
if (root / "poetry.lock").exists():
|
|
83
|
+
return "poetry"
|
|
84
|
+
if (root / "Pipfile.lock").exists():
|
|
85
|
+
return "pipenv"
|
|
86
|
+
if (root / "requirements.txt").exists():
|
|
87
|
+
return "pip"
|
|
88
|
+
if (root / "pyproject.toml").exists():
|
|
89
|
+
return "pip"
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _detect_test_framework(deps: set[str]) -> str | None:
|
|
94
|
+
if "pytest" in deps:
|
|
95
|
+
return "pytest"
|
|
96
|
+
if "unittest" in deps:
|
|
97
|
+
return "unittest"
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _detect_python_version(root: Path) -> str | None:
|
|
102
|
+
pyproject = root / "pyproject.toml"
|
|
103
|
+
if pyproject.exists():
|
|
104
|
+
with open(pyproject, "rb") as f:
|
|
105
|
+
data = tomllib.load(f)
|
|
106
|
+
requires = data.get("project", {}).get("requires-python", "")
|
|
107
|
+
match = re.search(r"(\d+\.\d+)", requires)
|
|
108
|
+
if match:
|
|
109
|
+
return match.group(1)
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _detect_type_hints(root: Path) -> bool:
|
|
114
|
+
return (root / "py.typed").exists() or (root / "mypy.ini").exists()
|
pycodegate/output.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Rich terminal output: score bar, doctor face, framed summary, category-grouped diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from pycodegate import __version__
|
|
14
|
+
from pycodegate.constants import CATEGORY_WEIGHTS, FRAMEWORK_CATEGORY_MAP
|
|
15
|
+
from pycodegate.types import Category, Diagnostic, ScanResult, Severity
|
|
16
|
+
|
|
17
|
+
BAR_WIDTH = 50
|
|
18
|
+
|
|
19
|
+
# Doctor ASCII faces
|
|
20
|
+
_HAPPY = r"""
|
|
21
|
+
┌─────┐
|
|
22
|
+
│ ◕ ◕ │
|
|
23
|
+
│ ◡ │
|
|
24
|
+
└─────┘
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
_NEUTRAL = r"""
|
|
28
|
+
┌─────┐
|
|
29
|
+
│ ◑ ◑ │
|
|
30
|
+
│ ━ │
|
|
31
|
+
└─────┘
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
_SAD = r"""
|
|
35
|
+
┌─────┐
|
|
36
|
+
│ ◔ ◔ │
|
|
37
|
+
│ ◠ │
|
|
38
|
+
└─────┘
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
# Display metadata for each category
|
|
42
|
+
CATEGORY_DISPLAY: dict[Category, tuple[str, str]] = {
|
|
43
|
+
Category.SECURITY: ("🔒", "Security"),
|
|
44
|
+
Category.CORRECTNESS: ("✓", "Correctness"),
|
|
45
|
+
Category.COMPLEXITY: ("⚡", "Complexity"),
|
|
46
|
+
Category.ARCHITECTURE: ("🏗", "Architecture"),
|
|
47
|
+
Category.PERFORMANCE: ("🐎", "Performance"),
|
|
48
|
+
Category.STRUCTURE: ("📁", "Structure"),
|
|
49
|
+
Category.IMPORTS: ("📦", "Imports"),
|
|
50
|
+
Category.DEAD_CODE: ("💀", "Dead Code"),
|
|
51
|
+
Category.DEPENDENCIES: ("🛡", "Dependencies"),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def format_score_bar(score: int) -> str:
|
|
56
|
+
"""Return a text-based score bar."""
|
|
57
|
+
filled = round(score / 100 * BAR_WIDTH)
|
|
58
|
+
empty = BAR_WIDTH - filled
|
|
59
|
+
bar = "█" * filled + "░" * empty
|
|
60
|
+
return f" {bar} {score}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def format_doctor_face(score: int) -> str:
|
|
64
|
+
"""Return doctor ASCII art based on score."""
|
|
65
|
+
if score >= 75:
|
|
66
|
+
return _HAPPY
|
|
67
|
+
elif score >= 50:
|
|
68
|
+
return _NEUTRAL
|
|
69
|
+
else:
|
|
70
|
+
return _SAD
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _score_color(score: int) -> str:
|
|
74
|
+
if score >= 75:
|
|
75
|
+
return "green"
|
|
76
|
+
elif score >= 50:
|
|
77
|
+
return "yellow"
|
|
78
|
+
return "red"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _compute_category_sub_scores(
|
|
82
|
+
diagnostics: list[Diagnostic],
|
|
83
|
+
) -> dict[Category, tuple[int, int]]:
|
|
84
|
+
"""Return {resolved_category: (earned, max)} for each category in CATEGORY_WEIGHTS."""
|
|
85
|
+
total_weight = sum(CATEGORY_WEIGHTS.values())
|
|
86
|
+
max_deductions = {cat: round(w / total_weight * 100) for cat, w in CATEGORY_WEIGHTS.items()}
|
|
87
|
+
# Fix rounding to ensure sum == 100
|
|
88
|
+
diff = 100 - sum(max_deductions.values())
|
|
89
|
+
if diff != 0:
|
|
90
|
+
highest = max(CATEGORY_WEIGHTS, key=lambda k: CATEGORY_WEIGHTS[k])
|
|
91
|
+
max_deductions[highest] += diff
|
|
92
|
+
|
|
93
|
+
# Group diagnostics by resolved category
|
|
94
|
+
by_category: dict[Category, list[Diagnostic]] = defaultdict(list)
|
|
95
|
+
for d in diagnostics:
|
|
96
|
+
resolved = FRAMEWORK_CATEGORY_MAP.get(d.category, d.category)
|
|
97
|
+
by_category[resolved].append(d)
|
|
98
|
+
|
|
99
|
+
# Compute actual deduction per category (same logic as score.py)
|
|
100
|
+
actual_deductions: dict[Category, float] = {}
|
|
101
|
+
for cat, diags in by_category.items():
|
|
102
|
+
costs = sorted([d.cost for d in diags], reverse=True)
|
|
103
|
+
cat_total = sum(c if i < 3 else c * 0.1 for i, c in enumerate(costs))
|
|
104
|
+
cap = max_deductions.get(cat, 10)
|
|
105
|
+
actual_deductions[cat] = min(cat_total, cap)
|
|
106
|
+
|
|
107
|
+
# Build (earned, max) per category
|
|
108
|
+
result: dict[Category, tuple[int, int]] = {}
|
|
109
|
+
for cat, max_ded in max_deductions.items():
|
|
110
|
+
deducted = round(actual_deductions.get(cat, 0.0))
|
|
111
|
+
earned = max_ded - deducted
|
|
112
|
+
result[cat] = (earned, max_ded)
|
|
113
|
+
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def format_summary(result: ScanResult) -> str:
|
|
118
|
+
"""Format the full scan summary as a string (for testing)."""
|
|
119
|
+
lines: list[str] = []
|
|
120
|
+
lines.append(format_doctor_face(result.score.value))
|
|
121
|
+
lines.append(f" Score: {result.score.value}/100 — {result.score.label}")
|
|
122
|
+
lines.append(format_score_bar(result.score.value))
|
|
123
|
+
lines.append("")
|
|
124
|
+
|
|
125
|
+
errors = sum(1 for d in result.diagnostics if d.severity == Severity.ERROR)
|
|
126
|
+
warnings = sum(1 for d in result.diagnostics if d.severity == Severity.WARNING)
|
|
127
|
+
files = len({d.file_path for d in result.diagnostics})
|
|
128
|
+
|
|
129
|
+
lines.append(f" {errors} errors, {warnings} warnings across {files} files")
|
|
130
|
+
lines.append(f" Completed in {result.elapsed_ms}ms")
|
|
131
|
+
return "\n".join(lines)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def print_scan_result(result: ScanResult, verbose: bool = False) -> None:
|
|
135
|
+
"""Print the full scan result to terminal using Rich."""
|
|
136
|
+
console = Console()
|
|
137
|
+
|
|
138
|
+
# Project info header
|
|
139
|
+
p = result.project
|
|
140
|
+
console.print()
|
|
141
|
+
console.print(" [bold]Py Gate[/bold] — v0.1.0")
|
|
142
|
+
console.print()
|
|
143
|
+
console.print(f" [dim]Path:[/dim] {p.path}")
|
|
144
|
+
if p.python_version:
|
|
145
|
+
console.print(f" [dim]Python:[/dim] {p.python_version}")
|
|
146
|
+
if p.package_manager:
|
|
147
|
+
console.print(f" [dim]Package manager:[/dim] {p.package_manager}")
|
|
148
|
+
if p.test_framework:
|
|
149
|
+
console.print(f" [dim]Test framework:[/dim] {p.test_framework}")
|
|
150
|
+
if result.profile:
|
|
151
|
+
console.print(f" [dim]Profile:[/dim] {result.profile}")
|
|
152
|
+
console.print(f" [dim]Source files:[/dim] {p.source_file_count}")
|
|
153
|
+
console.print()
|
|
154
|
+
|
|
155
|
+
# Category-grouped diagnostics with sub-scores
|
|
156
|
+
_print_category_groups(console, result.diagnostics, verbose)
|
|
157
|
+
|
|
158
|
+
# Summary panel
|
|
159
|
+
color = _score_color(result.score.value)
|
|
160
|
+
face = format_doctor_face(result.score.value).strip()
|
|
161
|
+
|
|
162
|
+
errors = sum(1 for d in result.diagnostics if d.severity == Severity.ERROR)
|
|
163
|
+
warnings = sum(1 for d in result.diagnostics if d.severity == Severity.WARNING)
|
|
164
|
+
files = len({d.file_path for d in result.diagnostics})
|
|
165
|
+
|
|
166
|
+
summary = Text()
|
|
167
|
+
summary.append(f"\n{face}\n\n", style="bold")
|
|
168
|
+
summary.append(" Score: ", style="dim")
|
|
169
|
+
summary.append(f"{result.score.value}/100", style=f"bold {color}")
|
|
170
|
+
summary.append(f" — {result.score.label}\n", style=color)
|
|
171
|
+
summary.append(f"{format_score_bar(result.score.value)}\n\n")
|
|
172
|
+
summary.append(f" {errors} errors, {warnings} warnings across {files} files\n", style="dim")
|
|
173
|
+
summary.append(f" Completed in {result.elapsed_ms}ms\n", style="dim")
|
|
174
|
+
|
|
175
|
+
console.print(Panel(summary, title="[bold]Results[/bold]", border_style=color))
|
|
176
|
+
console.print()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def output_json(result: ScanResult) -> None:
|
|
180
|
+
"""Output the scan result as structured JSON to stdout."""
|
|
181
|
+
p = result.project
|
|
182
|
+
errors = sum(1 for d in result.diagnostics if d.severity == Severity.ERROR)
|
|
183
|
+
warnings = sum(1 for d in result.diagnostics if d.severity == Severity.WARNING)
|
|
184
|
+
|
|
185
|
+
payload = {
|
|
186
|
+
"version": __version__,
|
|
187
|
+
"path": p.path,
|
|
188
|
+
"score": result.score.value,
|
|
189
|
+
"label": result.score.label,
|
|
190
|
+
"errors": errors,
|
|
191
|
+
"warnings": warnings,
|
|
192
|
+
"elapsed_ms": result.elapsed_ms,
|
|
193
|
+
"project": {
|
|
194
|
+
"framework": p.framework,
|
|
195
|
+
"python_version": p.python_version,
|
|
196
|
+
"package_manager": p.package_manager,
|
|
197
|
+
"test_framework": p.test_framework,
|
|
198
|
+
"source_file_count": p.source_file_count,
|
|
199
|
+
},
|
|
200
|
+
"diagnostics": [
|
|
201
|
+
{
|
|
202
|
+
"rule": d.rule,
|
|
203
|
+
"severity": d.severity.value,
|
|
204
|
+
"category": d.category.value,
|
|
205
|
+
"message": d.message,
|
|
206
|
+
"help": d.help,
|
|
207
|
+
"file_path": d.file_path,
|
|
208
|
+
"line": d.line,
|
|
209
|
+
}
|
|
210
|
+
for d in result.diagnostics
|
|
211
|
+
],
|
|
212
|
+
}
|
|
213
|
+
sys.stdout.write(json.dumps(payload, indent=2) + "\n")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _print_category_groups(
|
|
217
|
+
console: Console,
|
|
218
|
+
diagnostics: list[Diagnostic],
|
|
219
|
+
verbose: bool,
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Print diagnostics grouped by category with sub-scores."""
|
|
222
|
+
sub_scores = _compute_category_sub_scores(diagnostics)
|
|
223
|
+
|
|
224
|
+
# Build rule-grouped diagnostics per resolved category
|
|
225
|
+
by_category: dict[Category, list[Diagnostic]] = defaultdict(list)
|
|
226
|
+
for d in diagnostics:
|
|
227
|
+
resolved = FRAMEWORK_CATEGORY_MAP.get(d.category, d.category)
|
|
228
|
+
by_category[resolved].append(d)
|
|
229
|
+
|
|
230
|
+
# Iterate in canonical order (CATEGORY_WEIGHTS order)
|
|
231
|
+
for cat in CATEGORY_WEIGHTS:
|
|
232
|
+
if cat not in CATEGORY_DISPLAY:
|
|
233
|
+
continue
|
|
234
|
+
emoji, name = CATEGORY_DISPLAY[cat]
|
|
235
|
+
earned, maximum = sub_scores.get(cat, (CATEGORY_WEIGHTS[cat], CATEGORY_WEIGHTS[cat]))
|
|
236
|
+
# Recompute max from sub_scores (already normalised to 100-point scale)
|
|
237
|
+
cat_diags = by_category.get(cat, [])
|
|
238
|
+
|
|
239
|
+
if earned == maximum:
|
|
240
|
+
# Perfect score for this category
|
|
241
|
+
console.print(
|
|
242
|
+
f" [bold]{emoji} {name}[/bold] "
|
|
243
|
+
f"[green]({earned}/{maximum})[/green] [green]✓[/green]"
|
|
244
|
+
)
|
|
245
|
+
console.print(" [green]✓ All clear.[/green]")
|
|
246
|
+
else:
|
|
247
|
+
console.print(f" [bold]{emoji} {name}[/bold] [yellow]({earned}/{maximum})[/yellow]")
|
|
248
|
+
# Group by rule within this category
|
|
249
|
+
by_rule: dict[str, list[Diagnostic]] = defaultdict(list)
|
|
250
|
+
for d in cat_diags:
|
|
251
|
+
by_rule[d.rule].append(d)
|
|
252
|
+
|
|
253
|
+
# Sort: errors first, then by rule name
|
|
254
|
+
sorted_rules = sorted(
|
|
255
|
+
by_rule.keys(),
|
|
256
|
+
key=lambda r: (0 if by_rule[r][0].severity == Severity.ERROR else 1, r),
|
|
257
|
+
)
|
|
258
|
+
for rule in sorted_rules:
|
|
259
|
+
rule_diags = by_rule[rule]
|
|
260
|
+
count = len(rule_diags)
|
|
261
|
+
sev = rule_diags[0].severity
|
|
262
|
+
icon = "[red]✗[/red]" if sev == Severity.ERROR else "[yellow]⚠[/yellow]"
|
|
263
|
+
console.print(f" {icon} {rule} × {count}")
|
|
264
|
+
if verbose:
|
|
265
|
+
for d in rule_diags[:10]:
|
|
266
|
+
console.print(f" [dim]{d.file_path}:{d.line}[/dim]")
|
|
267
|
+
if count > 10:
|
|
268
|
+
console.print(f" [dim]... and {count - 10} more[/dim]")
|
|
269
|
+
|
|
270
|
+
console.print()
|