refactor(types): add strict type hints to gui_2.py and gui_legacy.py
Automated pipeline applied 217 type annotations across both UI modules: - 158 auto -> None return types via AST single-pass - 25 manual signatures (callbacks, factory methods, complex returns) - 34 variable type annotations (constants, color tuples, config) Zero untyped functions/variables remain in either file. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
353
scripts/apply_type_hints.py
Normal file
353
scripts/apply_type_hints.py
Normal file
@@ -0,0 +1,353 @@
|
||||
"""
|
||||
Type hint applicator for gui_2.py and gui_legacy.py.
|
||||
Does a single-pass AST-guided line edit to add type annotations.
|
||||
No dependency on mcp_client — operates directly on file lines.
|
||||
|
||||
Run: uv run python scripts/apply_type_hints.py
|
||||
"""
|
||||
import ast
|
||||
import re
|
||||
import sys
|
||||
import os
|
||||
|
||||
BASE = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
stats = {"auto_none": 0, "manual_sig": 0, "vars": 0, "errors": []}
|
||||
|
||||
def abs_path(filename: str) -> str:
|
||||
return os.path.join(BASE, filename)
|
||||
|
||||
def has_value_return(node: ast.AST) -> bool:
|
||||
"""Check if function has any 'return <expr>' (not bare return or return None)."""
|
||||
for child in ast.walk(node):
|
||||
if isinstance(child, ast.Return) and child.value is not None:
|
||||
if isinstance(child.value, ast.Constant) and child.value.value is None:
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
def collect_auto_none(tree: ast.Module) -> list[tuple[str, ast.AST]]:
|
||||
"""Collect functions that can safely get -> None annotation."""
|
||||
results = []
|
||||
def scan(scope, prefix=""):
|
||||
for node in ast.iter_child_nodes(scope):
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
name = f"{prefix}{node.name}" if prefix else node.name
|
||||
if node.returns is None and not has_value_return(node):
|
||||
untyped = [a.arg for a in node.args.args if a.arg not in ("self", "cls") and a.annotation is None]
|
||||
if not untyped:
|
||||
results.append((name, node))
|
||||
if isinstance(node, ast.ClassDef):
|
||||
scan(node, prefix=f"{node.name}.")
|
||||
scan(tree)
|
||||
return results
|
||||
|
||||
def apply_return_none_single_pass(filepath: str) -> int:
|
||||
"""Add -> None to all qualifying functions in a single pass."""
|
||||
fp = abs_path(filepath)
|
||||
with open(fp, 'r', encoding='utf-8') as f:
|
||||
code = f.read()
|
||||
tree = ast.parse(code)
|
||||
candidates = collect_auto_none(tree)
|
||||
if not candidates:
|
||||
return 0
|
||||
lines = code.splitlines(keepends=True)
|
||||
# For each candidate, find the line with the colon that ends the signature
|
||||
# and insert ' -> None' before it.
|
||||
# We need to find the colon on the signature line (not inside default values etc.)
|
||||
# Strategy: the signature ends at body[0].lineno - 1 (last sig line)
|
||||
# Find the last ':' on that line that's at the right indentation
|
||||
edits = [] # (line_idx, col_of_colon)
|
||||
for name, node in candidates:
|
||||
if not node.body:
|
||||
continue
|
||||
# The colon is on the last line of the signature
|
||||
# For single-line defs: `def foo(self):` -> colon at end
|
||||
# For multi-line defs: last line ends with `):` or similar
|
||||
body_start = node.body[0].lineno # 1-indexed
|
||||
sig_last_line_idx = body_start - 2 # 0-indexed, the line before body
|
||||
# But for single-line signatures, sig_last_line_idx == node.lineno - 1
|
||||
if sig_last_line_idx < node.lineno - 1:
|
||||
sig_last_line_idx = node.lineno - 1
|
||||
line = lines[sig_last_line_idx]
|
||||
# Find the last colon on this line (the def colon)
|
||||
# Must handle cases like `def foo(self, x: int):` where there are colons in annotations
|
||||
# The def colon is always the LAST colon on the line (before any comment)
|
||||
stripped = line.rstrip('\n\r')
|
||||
# Remove inline comment
|
||||
comment_pos = -1
|
||||
in_str = False
|
||||
str_char = None
|
||||
for i, c in enumerate(stripped):
|
||||
if in_str:
|
||||
if c == str_char:
|
||||
in_str = False
|
||||
continue
|
||||
if c in ('"', "'"):
|
||||
in_str = True
|
||||
str_char = c
|
||||
continue
|
||||
if c == '#':
|
||||
comment_pos = i
|
||||
break
|
||||
code_part = stripped[:comment_pos] if comment_pos >= 0 else stripped
|
||||
# Find last colon in code_part
|
||||
colon_idx = code_part.rfind(':')
|
||||
if colon_idx < 0:
|
||||
stats["errors"].append(f"no colon found: {filepath}:{name} L{sig_last_line_idx+1}")
|
||||
continue
|
||||
# Check not already annotated
|
||||
if '->' in code_part:
|
||||
continue
|
||||
edits.append((sig_last_line_idx, colon_idx))
|
||||
# Apply edits in reverse order to preserve line indices
|
||||
edits.sort(key=lambda x: x[0], reverse=True)
|
||||
count = 0
|
||||
for line_idx, colon_col in edits:
|
||||
line = lines[line_idx]
|
||||
new_line = line[:colon_col] + ' -> None' + line[colon_col:]
|
||||
lines[line_idx] = new_line
|
||||
count += 1
|
||||
with open(fp, 'w', encoding='utf-8', newline='') as f:
|
||||
f.writelines(lines)
|
||||
return count
|
||||
|
||||
# --- Manual signature replacements ---
|
||||
# These use regex on the def line to do a targeted replacement.
|
||||
# Each entry: (dotted_name, old_params_pattern, new_full_sig_line)
|
||||
# We match by finding the exact def line and replacing it.
|
||||
|
||||
def apply_manual_sigs(filepath: str, sig_replacements: list[tuple[str, str]]) -> int:
|
||||
"""Apply manual signature replacements.
|
||||
sig_replacements: list of (regex_pattern_for_old_line, replacement_line)
|
||||
"""
|
||||
fp = abs_path(filepath)
|
||||
with open(fp, 'r', encoding='utf-8') as f:
|
||||
code = f.read()
|
||||
count = 0
|
||||
for pattern, replacement in sig_replacements:
|
||||
new_code = re.sub(pattern, replacement, code, count=1)
|
||||
if new_code != code:
|
||||
code = new_code
|
||||
count += 1
|
||||
else:
|
||||
stats["errors"].append(f"manual_sig no match: {filepath}: {pattern[:60]}")
|
||||
with open(fp, 'w', encoding='utf-8', newline='') as f:
|
||||
f.write(code)
|
||||
return count
|
||||
|
||||
def apply_var_replacements(filepath: str, var_replacements: list[tuple[str, str]]) -> int:
|
||||
"""Apply variable declaration replacements.
|
||||
var_replacements: list of (regex_pattern_for_old_decl, replacement_decl)
|
||||
"""
|
||||
fp = abs_path(filepath)
|
||||
with open(fp, 'r', encoding='utf-8') as f:
|
||||
code = f.read()
|
||||
count = 0
|
||||
for pattern, replacement in var_replacements:
|
||||
new_code = re.sub(pattern, replacement, code, count=1)
|
||||
if new_code != code:
|
||||
code = new_code
|
||||
count += 1
|
||||
else:
|
||||
stats["errors"].append(f"var no match: {filepath}: {pattern[:60]}")
|
||||
with open(fp, 'w', encoding='utf-8', newline='') as f:
|
||||
f.write(code)
|
||||
return count
|
||||
|
||||
def verify_syntax(filepath: str) -> str:
|
||||
fp = abs_path(filepath)
|
||||
try:
|
||||
with open(fp, 'r', encoding='utf-8') as f:
|
||||
code = f.read()
|
||||
ast.parse(code)
|
||||
return f"Syntax OK: {filepath}"
|
||||
except SyntaxError as e:
|
||||
return f"SyntaxError in {filepath} at line {e.lineno}: {e.msg}"
|
||||
|
||||
# ============================================================
|
||||
# gui_2.py manual signatures (Tier 3 items)
|
||||
# ============================================================
|
||||
GUI2_MANUAL_SIGS = [
|
||||
(r'def resolve_pending_action\(self, action_id: str, approved: bool\):',
|
||||
r'def resolve_pending_action(self, action_id: str, approved: bool) -> bool:'),
|
||||
(r'def _cb_start_track\(self, user_data=None\):',
|
||||
r'def _cb_start_track(self, user_data: Any = None) -> None:'),
|
||||
(r'def _start_track_logic\(self, track_data\):',
|
||||
r'def _start_track_logic(self, track_data: dict[str, Any]) -> None:'),
|
||||
(r'def _cb_ticket_retry\(self, ticket_id\):',
|
||||
r'def _cb_ticket_retry(self, ticket_id: str) -> None:'),
|
||||
(r'def _cb_ticket_skip\(self, ticket_id\):',
|
||||
r'def _cb_ticket_skip(self, ticket_id: str) -> None:'),
|
||||
(r'def _render_ticket_dag_node\(self, ticket, tickets_by_id, children_map, rendered\):',
|
||||
r'def _render_ticket_dag_node(self, ticket: Ticket, tickets_by_id: dict[str, Ticket], children_map: dict[str, list[str]], rendered: set[str]) -> None:'),
|
||||
]
|
||||
|
||||
# ============================================================
|
||||
# gui_legacy.py manual signatures (Tier 3 items)
|
||||
# ============================================================
|
||||
LEGACY_MANUAL_SIGS = [
|
||||
(r'def _add_kv_row\(parent: str, key: str, val, val_color=None\):',
|
||||
r'def _add_kv_row(parent: str, key: str, val: Any, val_color: tuple[int, int, int] | None = None) -> None:'),
|
||||
(r'def _make_remove_file_cb\(self, idx: int\):',
|
||||
r'def _make_remove_file_cb(self, idx: int) -> Callable:'),
|
||||
(r'def _make_remove_shot_cb\(self, idx: int\):',
|
||||
r'def _make_remove_shot_cb(self, idx: int) -> Callable:'),
|
||||
(r'def _make_remove_project_cb\(self, idx: int\):',
|
||||
r'def _make_remove_project_cb(self, idx: int) -> Callable:'),
|
||||
(r'def _make_switch_project_cb\(self, path: str\):',
|
||||
r'def _make_switch_project_cb(self, path: str) -> Callable:'),
|
||||
(r'def cb_word_wrap_toggled\(self, sender=None, app_data=None\):',
|
||||
r'def cb_word_wrap_toggled(self, sender: Any = None, app_data: Any = None) -> None:'),
|
||||
(r'def cb_provider_changed\(self, sender, app_data\):',
|
||||
r'def cb_provider_changed(self, sender: Any, app_data: Any) -> None:'),
|
||||
(r'def cb_model_changed\(self, sender, app_data\):',
|
||||
r'def cb_model_changed(self, sender: Any, app_data: Any) -> None:'),
|
||||
(r'def _cb_new_project_automated\(self, path\):',
|
||||
r'def _cb_new_project_automated(self, path: str) -> None:'),
|
||||
(r'def cb_disc_switch\(self, sender, app_data\):',
|
||||
r'def cb_disc_switch(self, sender: Any, app_data: Any) -> None:'),
|
||||
(r'def _make_disc_remove_role_cb\(self, idx: int\):',
|
||||
r'def _make_disc_remove_role_cb(self, idx: int) -> Callable:'),
|
||||
(r'def _cb_toggle_read\(self, sender, app_data, user_data\):',
|
||||
r'def _cb_toggle_read(self, sender: Any, app_data: Any, user_data: Any) -> None:'),
|
||||
(r'def _make_disc_role_cb\(self, idx: int\):',
|
||||
r'def _make_disc_role_cb(self, idx: int) -> Callable:'),
|
||||
(r'def _make_disc_content_cb\(self, idx: int\):',
|
||||
r'def _make_disc_content_cb(self, idx: int) -> Callable:'),
|
||||
(r'def _make_disc_insert_cb\(self, idx: int\):',
|
||||
r'def _make_disc_insert_cb(self, idx: int) -> Callable:'),
|
||||
(r'def _make_disc_remove_cb\(self, idx: int\):',
|
||||
r'def _make_disc_remove_cb(self, idx: int) -> Callable:'),
|
||||
(r'def _make_disc_toggle_cb\(self, idx: int\):',
|
||||
r'def _make_disc_toggle_cb(self, idx: int) -> Callable:'),
|
||||
(r'def cb_palette_changed\(self, sender, app_data\):',
|
||||
r'def cb_palette_changed(self, sender: Any, app_data: Any) -> None:'),
|
||||
(r'def cb_scale_changed\(self, sender, app_data\):',
|
||||
r'def cb_scale_changed(self, sender: Any, app_data: Any) -> None:'),
|
||||
]
|
||||
|
||||
# ============================================================
|
||||
# gui_2.py variable type annotations
|
||||
# ============================================================
|
||||
GUI2_VAR_REPLACEMENTS = [
|
||||
(r'^CONFIG_PATH = ', 'CONFIG_PATH: Path = '),
|
||||
(r'^PROVIDERS = ', 'PROVIDERS: list[str] = '),
|
||||
(r'^COMMS_CLAMP_CHARS = ', 'COMMS_CLAMP_CHARS: int = '),
|
||||
(r'^C_OUT = ', 'C_OUT: tuple[float, ...] = '),
|
||||
(r'^C_IN = ', 'C_IN: tuple[float, ...] = '),
|
||||
(r'^C_REQ = ', 'C_REQ: tuple[float, ...] = '),
|
||||
(r'^C_RES = ', 'C_RES: tuple[float, ...] = '),
|
||||
(r'^C_TC = ', 'C_TC: tuple[float, ...] = '),
|
||||
(r'^C_TR = ', 'C_TR: tuple[float, ...] = '),
|
||||
(r'^C_TRS = ', 'C_TRS: tuple[float, ...] = '),
|
||||
(r'^C_LBL = ', 'C_LBL: tuple[float, ...] = '),
|
||||
(r'^C_VAL = ', 'C_VAL: tuple[float, ...] = '),
|
||||
(r'^C_KEY = ', 'C_KEY: tuple[float, ...] = '),
|
||||
(r'^C_NUM = ', 'C_NUM: tuple[float, ...] = '),
|
||||
(r'^C_SUB = ', 'C_SUB: tuple[float, ...] = '),
|
||||
(r'^DIR_COLORS = ', 'DIR_COLORS: dict[str, tuple[float, ...]] = '),
|
||||
(r'^KIND_COLORS = ', 'KIND_COLORS: dict[str, tuple[float, ...]] = '),
|
||||
(r'^HEAVY_KEYS = ', 'HEAVY_KEYS: set[str] = '),
|
||||
(r'^DISC_ROLES = ', 'DISC_ROLES: list[str] = '),
|
||||
(r'^AGENT_TOOL_NAMES = ', 'AGENT_TOOL_NAMES: list[str] = '),
|
||||
]
|
||||
|
||||
# ============================================================
|
||||
# gui_legacy.py variable type annotations
|
||||
# ============================================================
|
||||
LEGACY_VAR_REPLACEMENTS = [
|
||||
(r'^CONFIG_PATH = ', 'CONFIG_PATH: Path = '),
|
||||
(r'^PROVIDERS = ', 'PROVIDERS: list[str] = '),
|
||||
(r'^COMMS_CLAMP_CHARS = ', 'COMMS_CLAMP_CHARS: int = '),
|
||||
(r'^_DIR_COLORS = \{', '_DIR_COLORS: dict[str, tuple[int, int, int]] = {'),
|
||||
(r'^_KIND_COLORS = \{', '_KIND_COLORS: dict[str, tuple[int, int, int]] = {'),
|
||||
(r'^_HEAVY_KEYS = ', '_HEAVY_KEYS: set[str] = '),
|
||||
(r'^_LABEL_COLOR = ', '_LABEL_COLOR: tuple[int, int, int] = '),
|
||||
(r'^_VALUE_COLOR = ', '_VALUE_COLOR: tuple[int, int, int] = '),
|
||||
(r'^_KEY_COLOR = ', '_KEY_COLOR: tuple[int, int, int] = '),
|
||||
(r'^_NUM_COLOR = ', '_NUM_COLOR: tuple[int, int, int] = '),
|
||||
(r'^_SUBHDR_COLOR = ', '_SUBHDR_COLOR: tuple[int, int, int] = '),
|
||||
(r'^_KIND_RENDERERS = \{', '_KIND_RENDERERS: dict[str, Callable] = {'),
|
||||
(r'^DISC_ROLES = ', 'DISC_ROLES: list[str] = '),
|
||||
(r'^ _next_id = ', ' _next_id: int = '),
|
||||
]
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== Phase A: Auto-apply -> None (single-pass AST) ===")
|
||||
n = apply_return_none_single_pass("gui_2.py")
|
||||
stats["auto_none"] += n
|
||||
print(f" gui_2.py: {n} applied")
|
||||
n = apply_return_none_single_pass("gui_legacy.py")
|
||||
stats["auto_none"] += n
|
||||
print(f" gui_legacy.py: {n} applied")
|
||||
|
||||
# Verify syntax after Phase A
|
||||
for f in ["gui_2.py", "gui_legacy.py"]:
|
||||
r = verify_syntax(f)
|
||||
if "Error" in r:
|
||||
print(f" ABORT: {r}")
|
||||
sys.exit(1)
|
||||
print(" Syntax OK after Phase A")
|
||||
|
||||
print("\n=== Phase B: Manual signatures (regex) ===")
|
||||
n = apply_manual_sigs("gui_2.py", GUI2_MANUAL_SIGS)
|
||||
stats["manual_sig"] += n
|
||||
print(f" gui_2.py: {n} applied")
|
||||
n = apply_manual_sigs("gui_legacy.py", LEGACY_MANUAL_SIGS)
|
||||
stats["manual_sig"] += n
|
||||
print(f" gui_legacy.py: {n} applied")
|
||||
|
||||
# Verify syntax after Phase B
|
||||
for f in ["gui_2.py", "gui_legacy.py"]:
|
||||
r = verify_syntax(f)
|
||||
if "Error" in r:
|
||||
print(f" ABORT: {r}")
|
||||
sys.exit(1)
|
||||
print(" Syntax OK after Phase B")
|
||||
|
||||
print("\n=== Phase C: Variable annotations (regex) ===")
|
||||
# Use re.MULTILINE so ^ matches line starts
|
||||
def apply_var_replacements_m(filepath, replacements):
|
||||
fp = abs_path(filepath)
|
||||
with open(fp, 'r', encoding='utf-8') as f:
|
||||
code = f.read()
|
||||
count = 0
|
||||
for pattern, replacement in replacements:
|
||||
new_code = re.sub(pattern, replacement, code, count=1, flags=re.MULTILINE)
|
||||
if new_code != code:
|
||||
code = new_code
|
||||
count += 1
|
||||
else:
|
||||
stats["errors"].append(f"var no match: {filepath}: {pattern[:60]}")
|
||||
with open(fp, 'w', encoding='utf-8', newline='') as f:
|
||||
f.write(code)
|
||||
return count
|
||||
|
||||
n = apply_var_replacements_m("gui_2.py", GUI2_VAR_REPLACEMENTS)
|
||||
stats["vars"] += n
|
||||
print(f" gui_2.py: {n} applied")
|
||||
n = apply_var_replacements_m("gui_legacy.py", LEGACY_VAR_REPLACEMENTS)
|
||||
stats["vars"] += n
|
||||
print(f" gui_legacy.py: {n} applied")
|
||||
|
||||
print("\n=== Final Syntax Verification ===")
|
||||
all_ok = True
|
||||
for f in ["gui_2.py", "gui_legacy.py"]:
|
||||
r = verify_syntax(f)
|
||||
print(f" {f}: {r}")
|
||||
if "Error" in r:
|
||||
all_ok = False
|
||||
|
||||
print(f"\n=== Summary ===")
|
||||
print(f" Auto -> None: {stats['auto_none']}")
|
||||
print(f" Manual sigs: {stats['manual_sig']}")
|
||||
print(f" Variables: {stats['vars']}")
|
||||
print(f" Errors: {len(stats['errors'])}")
|
||||
if stats['errors']:
|
||||
print("\n=== Errors ===")
|
||||
for e in stats['errors']:
|
||||
print(f" {e}")
|
||||
if all_ok:
|
||||
print("\nAll files pass syntax check.")
|
||||
else:
|
||||
print("\nSYNTAX ERRORS DETECTED — review and fix before committing.")
|
||||
Reference in New Issue
Block a user