mirror of
https://github.com/langgenius/dify.git
synced 2026-07-14 08:57:02 +08:00
224 lines
7.5 KiB
Python
224 lines
7.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Report direct SQLAlchemy usage in controller code.
|
|
|
|
This is the full-inventory companion to ``check_no_new_controller_sqlalchemy.py``.
|
|
It scans the current tree instead of a git diff, so migration work can inspect
|
|
existing controller usage without turning historical debt into a CI failure.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from collections import Counter
|
|
from collections.abc import Iterable, Sequence
|
|
from dataclasses import asdict, dataclass
|
|
from pathlib import Path
|
|
|
|
import check_no_new_controller_sqlalchemy as controller_sqlalchemy_guard
|
|
from ast_grep_guard import Match, REPO_ROOT, run_ast_grep
|
|
|
|
|
|
DEFAULT_CONTROLLER_DIR = REPO_ROOT / "api" / "controllers"
|
|
MAX_TEXT_LENGTH = 180
|
|
SESSION_METHOD_PATTERN = re.compile(r"^(?:db\.session|session)\.(?P<method>[A-Za-z_][A-Za-z0-9_]*)\(")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Finding:
|
|
classification: str
|
|
category: str
|
|
file: str
|
|
line: int
|
|
text: str
|
|
source_line: str
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"paths",
|
|
nargs="*",
|
|
help="Files or directories to scan. Defaults to api/controllers.",
|
|
)
|
|
parser.add_argument("--include-allowed", action="store_true", help="Print allowed flush()/commit() findings.")
|
|
parser.add_argument("--include-suppressed", action="store_true", help="Print reasoned noqa suppressions.")
|
|
parser.add_argument("--summary-only", action="store_true", help="Print counts without per-finding details.")
|
|
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
|
|
parser.add_argument(
|
|
"--fail-on-findings",
|
|
action="store_true",
|
|
help="Treat reportable direct SQLAlchemy usage as a failure. By default this scanner is report-only.",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def iter_python_files(paths: Iterable[Path]) -> Iterable[Path]:
|
|
for path in paths:
|
|
if path.is_file() and path.suffix in {".py", ".pyi"}:
|
|
yield path
|
|
elif path.is_dir():
|
|
yield from sorted(child for child in path.rglob("*.py") if child.is_file())
|
|
|
|
|
|
def display_path(path: Path) -> str:
|
|
try:
|
|
return str(path.relative_to(REPO_ROOT))
|
|
except ValueError:
|
|
return str(path)
|
|
|
|
|
|
def compact_text(text: str) -> str:
|
|
compact = " ".join(text.split())
|
|
if len(compact) <= MAX_TEXT_LENGTH:
|
|
return compact
|
|
return f"{compact[: MAX_TEXT_LENGTH - 3]}..."
|
|
|
|
|
|
def category_for_match(match: Match) -> str:
|
|
text = match.text.strip()
|
|
method_match = SESSION_METHOD_PATTERN.match(text)
|
|
method = method_match.group("method") if method_match else None
|
|
|
|
if text.startswith("db.session."):
|
|
return f"scoped-session.{method or 'call'}"
|
|
if text.startswith("session."):
|
|
return f"explicit-session.{method or 'call'}"
|
|
if text.startswith(("Session(", "sessionmaker(")):
|
|
return "session-factory"
|
|
if text.startswith(("sa.", "sqlalchemy.", "db.")):
|
|
return "sql-expression-qualified"
|
|
return "sql-expression"
|
|
|
|
|
|
def classification_for_match(match: Match) -> str:
|
|
if controller_sqlalchemy_guard.is_allowed_session_boundary(match):
|
|
return "allowed"
|
|
if controller_sqlalchemy_guard.is_suppressed(match):
|
|
return "suppressed"
|
|
if controller_sqlalchemy_guard.is_flask_session_get(match):
|
|
return "ignored"
|
|
return "reportable"
|
|
|
|
|
|
def findings_for_file(path: Path) -> list[Finding]:
|
|
source = path.read_text(encoding="utf-8")
|
|
findings: list[Finding] = []
|
|
for match in run_ast_grep(source, rule=controller_sqlalchemy_guard.RULE_PATH):
|
|
findings.append(
|
|
Finding(
|
|
classification=classification_for_match(match),
|
|
category=category_for_match(match),
|
|
file=display_path(path),
|
|
line=match.line_number,
|
|
text=compact_text(match.text),
|
|
source_line=match.source_line.strip(),
|
|
)
|
|
)
|
|
return findings
|
|
|
|
|
|
def print_text_report(
|
|
findings: Sequence[Finding],
|
|
*,
|
|
include_allowed: bool,
|
|
include_suppressed: bool,
|
|
summary_only: bool,
|
|
) -> None:
|
|
counts = Counter(finding.classification for finding in findings)
|
|
reportable = [finding for finding in findings if finding.classification == "reportable"]
|
|
category_counts = Counter(finding.category for finding in reportable)
|
|
file_counts = Counter(finding.file for finding in reportable)
|
|
sys.stdout.write(
|
|
"Controller SQLAlchemy scan: "
|
|
f"{counts['reportable']} reportable, "
|
|
f"{counts['allowed']} allowed, "
|
|
f"{counts['suppressed']} suppressed, "
|
|
f"{counts['ignored']} ignored\n"
|
|
)
|
|
|
|
if category_counts:
|
|
sys.stdout.write("\nREPORTABLE BY CATEGORY:\n")
|
|
for category, count in sorted(category_counts.items()):
|
|
sys.stdout.write(f"- {category}: {count}\n")
|
|
|
|
if file_counts:
|
|
sys.stdout.write("\nREPORTABLE BY FILE:\n")
|
|
for file, count in sorted(file_counts.items()):
|
|
sys.stdout.write(f"- {file}: {count}\n")
|
|
|
|
if summary_only:
|
|
return
|
|
|
|
print_findings("REPORTABLE", reportable)
|
|
if include_allowed:
|
|
print_findings("ALLOWED", [finding for finding in findings if finding.classification == "allowed"])
|
|
if include_suppressed:
|
|
print_findings("SUPPRESSED", [finding for finding in findings if finding.classification == "suppressed"])
|
|
|
|
|
|
def print_findings(title: str, findings: Sequence[Finding]) -> None:
|
|
if not findings:
|
|
return
|
|
|
|
sys.stdout.write(f"\n{title}:\n")
|
|
for finding in findings:
|
|
sys.stdout.write(f"- {finding.file}:{finding.line} [{finding.category}] {finding.text}\n")
|
|
|
|
|
|
def jsonable_findings(
|
|
findings: Sequence[Finding],
|
|
*,
|
|
include_allowed: bool,
|
|
include_suppressed: bool,
|
|
summary_only: bool,
|
|
) -> dict[str, object]:
|
|
visible = [
|
|
finding
|
|
for finding in findings
|
|
if finding.classification == "reportable"
|
|
or (include_allowed and finding.classification == "allowed")
|
|
or (include_suppressed and finding.classification == "suppressed")
|
|
]
|
|
counts = Counter(finding.classification for finding in findings)
|
|
return {
|
|
"summary": dict(sorted(counts.items())),
|
|
"findings": [] if summary_only else [asdict(finding) for finding in visible],
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
raw_paths = args.paths or [str(DEFAULT_CONTROLLER_DIR)]
|
|
paths = [path if path.is_absolute() else REPO_ROOT / path for path in map(Path, raw_paths)]
|
|
|
|
findings: list[Finding] = []
|
|
for path in iter_python_files(paths):
|
|
findings.extend(findings_for_file(path.resolve()))
|
|
findings.sort(key=lambda finding: (finding.file, finding.line, finding.category, finding.text))
|
|
|
|
if args.json:
|
|
payload = jsonable_findings(
|
|
findings,
|
|
include_allowed=bool(args.include_allowed),
|
|
include_suppressed=bool(args.include_suppressed),
|
|
summary_only=bool(args.summary_only),
|
|
)
|
|
sys.stdout.write(f"{json.dumps(payload, indent=2, sort_keys=True)}\n")
|
|
else:
|
|
print_text_report(
|
|
findings,
|
|
include_allowed=bool(args.include_allowed),
|
|
include_suppressed=bool(args.include_suppressed),
|
|
summary_only=bool(args.summary_only),
|
|
)
|
|
|
|
has_reportable = any(finding.classification == "reportable" for finding in findings)
|
|
return int(bool(args.fail_on_findings) and has_reportable)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|