scripts/p5_wave3_mypy_fix.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""Apply mypy-guided micro-fixes to *_ops.py (P5 wave3)."""
from __future__ import annotations
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def mypy_errors(path: Path) -> list[tuple[int, str]]:
r = subprocess.run(
[sys.executable, "-m", "mypy", str(path), "--follow-imports=skip", "--strict"],
cwd=ROOT,
capture_output=True,
text=True,
)
out = []
for ln in (r.stdout + r.stderr).splitlines():
m = re.search(r":(\d+): error: (.+)$", ln)
if m:
out.append((int(m.group(1)), m.group(2)))
return out
def ensure_any_import(src: str) -> str:
if re.search(r"from typing import[^\n]*\bAny\b", src):
return src
if "from typing import" in src:
return re.sub(r"(from typing import [^\n]+)", r"\1, Any", src, count=1)
return "from typing import Any\n" + src
def ensure_cast_import(src: str) -> str:
if re.search(r"from typing import[^\n]*\bcast\b", src):
return src
if "from typing import" in src:
return re.sub(r"(from typing import [^\n]+)", r"\1, cast", src, count=1)
return "from typing import cast\n" + src
def fix_file(path: Path) -> bool:
rel = path.relative_to(ROOT)
errors = mypy_errors(path)
if not errors:
return False
lines = path.read_text(encoding="utf-8").splitlines()
changed = False
for lineno, err in errors:
idx = lineno - 1
if idx < 0 or idx >= len(lines):
continue
line = lines[idx]
if "import-untyped" in err and "yaml" in line:
if "type: ignore" not in line:
lines[idx] = line.replace("import yaml", "import yaml # type: ignore[import-untyped]")
changed = True
elif "import-untyped" in err and "qrcode" in line:
if "type: ignore" not in line:
lines[idx] = line.replace("import qrcode", "import qrcode # type: ignore[import-untyped]")
changed = True
elif "import-not-found" in err and "butler.core.session_key" in line:
lines[idx] = line.replace("butler.core.session_key", "butler.execution_context")
changed = True
elif "Missing type arguments" in err and "dict" in err:
lines[idx] = re.sub(r"\bdict\b(?!\[)", "dict[str, Any]", line)
changed = True
elif "Missing type arguments" in err and "list" in err:
lines[idx] = re.sub(r"\blist\b(?!\[)", "list[Any]", line)
changed = True
elif "no-any-return" in err and line.strip().startswith("return "):
expr = line.strip()[7:]
if "-> str" in err or 'return "str"' in err or "str |" in err:
if not expr.startswith("str("):
lines[idx] = line.replace(f"return {expr}", f"return str({expr})")
changed = True
elif "-> bool" in err:
if not expr.startswith("bool("):
lines[idx] = line.replace(f"return {expr}", f"return bool({expr})")
changed = True
elif "-> int" in err and "int |" not in err:
if not expr.startswith("int("):
lines[idx] = line.replace(f"return {expr}", f"return int({expr})")
changed = True
elif "Path" in err:
if "cast(" not in expr:
lines[idx] = line.replace(f"return {expr}", f"return cast(Path, {expr})")
changed = True
elif "return safe_best_effort(" in line:
# skip - handled manually
pass
if changed:
src = "\n".join(lines) + ("\n" if path.read_text(encoding="utf-8").endswith("\n") else "")
if "dict[str, Any]" in src or "list[Any]" in src:
src = ensure_any_import(src)
if "cast(Path," in src:
src = ensure_cast_import(src)
path.write_text(src, encoding="utf-8")
return changed
def main() -> int:
strict = {
ln.strip()
for ln in (ROOT / "scripts/p5_ops_strict_modules.txt").read_text().splitlines()
if ln.strip()
}
targets = [
p
for p in sorted((ROOT / "butler").rglob("*_ops.py"))
if str(p.relative_to(ROOT)).replace("\\", "/") not in strict
]
rounds = 0
total = 0
while rounds < 5:
rounds += 1
n = 0
for p in targets:
if fix_file(p):
n += 1
print("fixed", p.relative_to(ROOT))
total += n
if n == 0:
break
print(f"rounds={rounds} file-fixes={total}")
remaining = sum(1 for p in targets if mypy_errors(p))
print(f"remaining failing files: {remaining}/{len(targets)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())