Skip to content

Commit c31f8e9

Browse files
committed
feat: add type-check-on-selection analyzer with dmypy default
Adds analyze_types_on_selection: runs a type checker over a file and returns only diagnostics within a line range (whole-module check, filtered to the selection). - Default backend dmypy: warm daemon gives fast incremental re-checks for the edit -> recheck loop (~0.09s warm vs ~0.7s one-shot mypy). - Resource caps: one-shot pyrefly/mypy run in a per-call systemd user scope (MemoryMax + CPUQuota); dmypy caps the daemon lifetime instead via a Type=forking systemd service, so the warm daemon survives. - Per-project dmypy status file + unit so projects do not share a daemon. - Runs the file in place with config_file + project_root for Django. - Parses both mypy single-line and pyrefly two-line diagnostic formats. - Declares mypy as a direct dependency (was only transitive via refurb).
1 parent d81761f commit c31f8e9

3 files changed

Lines changed: 262 additions & 1 deletion

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ dependencies = [
4343
"bandit>=1.7.0",
4444
"pip-audit>=2.6.0",
4545
"refurb>=2.0.0",
46+
"mypy>=1.17.1",
4647
]
4748

4849
[project.urls]
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Type-check a *line selection* of a file, with Django support and resource caps.
4+
5+
Why this is not a plain BaseAnalyzer
6+
------------------------------------
7+
mypy/dmypy/pyrefly cannot type-check a line range in isolation: they must see
8+
the whole module to resolve types. So "check the selection" really means
9+
"check the whole module, then keep only diagnostics whose line falls in
10+
[start_line, end_line]".
11+
12+
Why it does NOT copy to a temp file (unlike PyreflyAnalyzer)
13+
------------------------------------------------------------
14+
Django code only type-checks correctly when the file stays in its package on
15+
sys.path, the project config (mypy.ini with the django plugin) is passed, and
16+
the checker runs from the project root. Copying to /tmp strips all of that.
17+
We therefore run on the file *in place*, from ``project_root``.
18+
19+
Default backend: dmypy (warm daemon)
20+
------------------------------------
21+
The interactive refactoring loop is "check -> see error -> edit -> check again".
22+
dmypy keeps a resident daemon so the re-check is incremental (~0.09s here) vs
23+
re-parsing everything (~0.7s for one-shot mypy). That speed is the whole point.
24+
25+
Resource safety - and the dmypy-specific trap
26+
----------------------------------------------
27+
For one-shot checkers (pyrefly, plain mypy) we wrap each call in a transient
28+
systemd user scope with MemoryMax + CPUQuota; an OOM then kills only that scope.
29+
30+
dmypy is different: its RAM lives in the *daemon*, not the client. If we wrapped
31+
each ``dmypy check`` in a per-call scope, the daemon would be born into that
32+
scope's cgroup and killed the instant the client exits - destroying the warm
33+
daemon every time. So for dmypy we cap the **daemon's lifetime** instead: start
34+
it once inside a capped ``Type=forking`` systemd service (MemoryMax + CPUQuota on
35+
the daemon cgroup, verified enforced), give it ``--timeout`` so it self-reaps when
36+
idle, and let subsequent checks be thin, fast, unwrapped clients.
37+
"""
38+
39+
import hashlib
40+
import re
41+
import shutil
42+
import subprocess
43+
import tempfile
44+
import time
45+
from dataclasses import dataclass
46+
from pathlib import Path
47+
from typing import List, Literal, Optional, Tuple
48+
49+
Severity = Literal["low", "medium", "high", "critical"]
50+
51+
from ..models import RefactoringGuidance
52+
53+
# mypy / dmypy: path:line: error: msg or path:line:col: error: msg
54+
_DIAG_RE = re.compile(r"^(?P<path>.+?):(?P<line>\d+):(?:(?P<col>\d+):)?\s*(?P<sev>error|warning|note):\s*(?P<msg>.*)$")
55+
56+
# pyrefly (>=0.30) is a two-line, rustc-style diagnostic:
57+
# ERROR <message> [<code>]
58+
# --> path:line:col
59+
_PYREFLY_HDR_RE = re.compile(r"^\s*(?P<sev>ERROR|WARN(?:ING)?)\s+(?P<msg>.*\S)\s*$")
60+
_PYREFLY_LOC_RE = re.compile(r"^\s*-->\s*(?P<path>.+?):(?P<line>\d+):(?P<col>\d+)\s*$")
61+
62+
63+
@dataclass
64+
class _Diag:
65+
line: int
66+
severity: str
67+
message: str
68+
69+
70+
# --------------------------------------------------------------------------- #
71+
# Diagnostic parsing
72+
# --------------------------------------------------------------------------- #
73+
def _parse(output: str, target: Path) -> List[_Diag]:
74+
"""Parse mypy/dmypy single-line and pyrefly two-line diagnostics.
75+
76+
Only diagnostics about ``target`` are kept; errors in imported modules
77+
(e.g. a Django dependency) are dropped as noise.
78+
"""
79+
diags: List[_Diag] = []
80+
pending: Optional[Tuple[str, str]] = None # (severity, message) awaiting `-->`
81+
82+
for raw in output.splitlines():
83+
m = _DIAG_RE.match(raw)
84+
if m:
85+
pending = None
86+
if Path(m.group("path")).name == target.name:
87+
diags.append(_Diag(int(m.group("line")), m.group("sev").lower(), m.group("msg").strip()))
88+
continue
89+
90+
hdr = _PYREFLY_HDR_RE.match(raw)
91+
if hdr:
92+
sev = "error" if hdr.group("sev").startswith("ERROR") else "warning"
93+
pending = (sev, hdr.group("msg").strip())
94+
continue
95+
loc = _PYREFLY_LOC_RE.match(raw)
96+
if loc and pending:
97+
if Path(loc.group("path")).name == target.name:
98+
diags.append(_Diag(int(loc.group("line")), pending[0], pending[1]))
99+
pending = None
100+
101+
return diags
102+
103+
104+
# --------------------------------------------------------------------------- #
105+
# Resource caps
106+
# --------------------------------------------------------------------------- #
107+
def _scope_caps(memory_max: str, cpu_quota: str) -> List[str]:
108+
"""Per-call systemd user scope for one-shot checkers (pyrefly / plain mypy)."""
109+
nice = ["nice", "-n", "10"] if shutil.which("nice") else []
110+
if shutil.which("systemd-run"):
111+
return [
112+
"systemd-run", "--user", "--scope", "--quiet",
113+
"-p", f"MemoryMax={memory_max}", "-p", "MemorySwapMax=0",
114+
"-p", f"CPUQuota={cpu_quota}",
115+
] + nice
116+
return nice
117+
118+
119+
# --------------------------------------------------------------------------- #
120+
# dmypy daemon lifecycle (capped once, reused warm)
121+
# --------------------------------------------------------------------------- #
122+
def _dmypy_ids(project_root: str, config_file: Optional[str]) -> Tuple[str, str]:
123+
"""Deterministic (status_file, unit_name) per (project_root, config) so
124+
different projects get their own daemon instead of fighting over one."""
125+
key = f"{Path(project_root).resolve()}::{config_file or ''}"
126+
h = hashlib.sha1(key.encode()).hexdigest()[:12]
127+
status = str(Path(tempfile.gettempdir()) / f"mcp-dmypy-{h}.json")
128+
return status, f"mcp-dmypy-{h}"
129+
130+
131+
def _dmypy_up(dmypy: str, status_file: str) -> bool:
132+
return subprocess.run(
133+
[dmypy, "--status-file", status_file, "status"],
134+
capture_output=True, text=True,
135+
).returncode == 0
136+
137+
138+
def _ensure_capped_daemon(
139+
dmypy: str, status_file: str, unit: str, config_file: Optional[str], cwd: str,
140+
memory_max: str, cpu_quota: str, idle_timeout: int,
141+
) -> None:
142+
"""Start the dmypy daemon once, inside a capped systemd service. No-op if up."""
143+
if _dmypy_up(dmypy, status_file):
144+
return
145+
146+
mypy_flags = ["--show-column-numbers"]
147+
if config_file:
148+
mypy_flags = ["--config-file", config_file, *mypy_flags]
149+
start = [dmypy, "--status-file", status_file, "start", "--timeout", str(idle_timeout), "--", *mypy_flags]
150+
151+
if shutil.which("systemd-run"):
152+
subprocess.run(["systemctl", "--user", "reset-failed", f"{unit}.service"],
153+
capture_output=True, text=True) # clear any stale unit
154+
subprocess.run(
155+
["systemd-run", "--user", "--quiet", f"--unit={unit}",
156+
f"--working-directory={cwd}",
157+
"-p", f"MemoryMax={memory_max}", "-p", "MemorySwapMax=0",
158+
"-p", f"CPUQuota={cpu_quota}", "-p", "Type=forking", *start],
159+
capture_output=True, text=True,
160+
)
161+
else:
162+
# No systemd: still get the warm daemon, just uncapped.
163+
subprocess.Popen(start, cwd=cwd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
164+
165+
for _ in range(50): # wait up to ~5s for readiness
166+
if _dmypy_up(dmypy, status_file):
167+
return
168+
time.sleep(0.1)
169+
170+
171+
def _guidance(issue_type: str, severity: Severity, location: str, description: str, steps: List[str]) -> RefactoringGuidance:
172+
return RefactoringGuidance(
173+
issue_type=issue_type, severity=severity, location=location,
174+
description=description, precise_steps=steps,
175+
)
176+
177+
178+
# --------------------------------------------------------------------------- #
179+
# Public entry point
180+
# --------------------------------------------------------------------------- #
181+
def analyze_types_on_selection(
182+
file_path: str,
183+
start_line: int,
184+
end_line: int,
185+
*,
186+
config_file: Optional[str] = None,
187+
project_root: Optional[str] = None,
188+
checker: str = "dmypy",
189+
memory_max: str = "1500M",
190+
cpu_quota: str = "80%",
191+
timeout_seconds: int = 120,
192+
daemon_idle_timeout: int = 600,
193+
) -> List[RefactoringGuidance]:
194+
"""Type-check ``file_path`` and return only diagnostics on lines [start,end].
195+
196+
Args:
197+
file_path: Real path to the file (NOT a temp copy - Django needs it
198+
in place on sys.path).
199+
start_line, end_line: 1-based inclusive selection range.
200+
config_file: e.g. the project's ``mypy.ini`` carrying the django plugin.
201+
project_root: cwd to run from; for Django, where settings import from.
202+
Defaults to the file's parent.
203+
checker: ``"dmypy"`` (default - warm daemon, fast incremental re-checks),
204+
``"mypy"`` (one-shot, no resident RAM), or ``"pyrefly"`` (Rust, fast).
205+
"""
206+
target = Path(file_path)
207+
if not target.is_file():
208+
return [_guidance("type_check_error", "low", file_path,
209+
f"File not found: {file_path}", ["Pass an existing, in-place file path."])]
210+
checker_bin = shutil.which(checker)
211+
if not checker_bin:
212+
return [_guidance("type_check_unavailable", "low", file_path,
213+
f"Type checker '{checker}' is not installed.",
214+
["Install it, or pass checker= one of dmypy/mypy/pyrefly."])]
215+
216+
cwd = project_root or str(target.parent)
217+
218+
try:
219+
if checker == "dmypy":
220+
status_file, unit = _dmypy_ids(cwd, config_file)
221+
_ensure_capped_daemon(checker_bin, status_file, unit, config_file, cwd,
222+
memory_max, cpu_quota, daemon_idle_timeout)
223+
if not _dmypy_up(checker_bin, status_file):
224+
return [_guidance("type_check_error", "medium", file_path,
225+
"dmypy daemon failed to start.",
226+
["Try checker='mypy' for a one-shot run."])]
227+
cmd = [checker_bin, "--status-file", status_file, "check", str(target)]
228+
elif checker == "pyrefly":
229+
cmd = [*_scope_caps(memory_max, cpu_quota), "pyrefly", "check", str(target)]
230+
else: # one-shot mypy
231+
mypy_cmd = ["mypy", "--show-column-numbers"]
232+
if config_file:
233+
mypy_cmd = ["mypy", "--config-file", config_file, "--show-column-numbers"]
234+
cmd = [*_scope_caps(memory_max, cpu_quota), *mypy_cmd, str(target)]
235+
236+
proc = subprocess.run(cmd, capture_output=True, text=True, cwd=cwd, timeout=timeout_seconds)
237+
except subprocess.TimeoutExpired:
238+
return [_guidance("type_check_timeout", "medium", file_path,
239+
f"Type check exceeded {timeout_seconds}s and was killed.",
240+
["Increase timeout_seconds, or use checker='pyrefly' for speed."])]
241+
242+
in_range = [d for d in _parse(proc.stdout + "\n" + proc.stderr, target)
243+
if start_line <= d.line <= end_line]
244+
if not in_range:
245+
return []
246+
247+
return [RefactoringGuidance(
248+
issue_type="type_errors",
249+
severity="medium",
250+
location=f"lines {start_line}-{end_line} ({len(in_range)} issue(s))",
251+
description=f"{checker} reported {len(in_range)} type issue(s) in the selection.",
252+
precise_steps=[
253+
"🔍 TYPE ISSUES IN SELECTION:",
254+
*[f"• L{d.line} [{d.severity}] {d.message}" for d in in_range[:10]],
255+
],
256+
benefits=["Catches type regressions before runtime", "Fast incremental re-checks while editing"],
257+
metrics={"checker": checker, "issues_in_range": len(in_range)},
258+
)]

uv.lock

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)