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:
2026-02-28 11:01:01 -05:00
parent a2a1447f58
commit c816f65665
3 changed files with 570 additions and 217 deletions

353
scripts/apply_type_hints.py Normal file
View 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.")