Files
my-botty/tools/_codemod.py
T
2026-06-07 18:17:44 +02:00

325 lines
11 KiB
Python

"""Code-mod helpers for botty route scaffolding.
Uses line-based insertion for reliable idempotency and clean undo.
"""
import os
import re
from pathlib import Path
def _read(path):
return Path(path).read_text()
def _write(path, text):
Path(path).write_text(text)
NL = chr(10)
Q1 = chr(39)
Q2 = chr(34)
LB = chr(123)
RB = chr(125)
# ─── __init__.py import ───────────────────────────────────────────────
def insert_init_import(init_path, name, class_name):
text = _read(init_path)
line = "from ." + name + " import " + class_name
if line in text:
return False
if not text.endswith(NL):
text += NL
text += line + NL
_write(init_path, text)
return True
def undo_init_import(init_path, name):
text = _read(init_path)
ll = text.splitlines(keepends=True)
new = [l for l in ll if not l.strip().startswith("from ." + name + " import")]
if len(new) == len(ll):
return False
_write(init_path, "".join(new))
return True
# ─── _do_runs dict ────────────────────────────────────────────────────
def insert_into_do_runs(bot_path, name):
text = _read(bot_path)
if Q2 + "run_" + name + Q2 in text:
return False
# Find the last "run_XX" entry line before the closing }
m = re.search(
r'(self\._do_runs = \{.*?)(\s+"run_\w+":\s*Config\(\)\.routes\.get\([^)]+\),\s*\n)(\s*\})',
text, re.DOTALL
)
if not m:
raise ValueError("Cannot find last entry in self._do_runs dict")
last_entry = m.group(2)
indent = " " * (len(last_entry) - len(last_entry.lstrip()))
entry = indent + Q2 + "run_" + name + Q2
entry += ": Config().routes.get(" + Q2 + "run_" + name + Q2 + ")," + NL
result = text[:m.start(2)] + m.group(2) + entry + m.group(3) + text[m.end():]
_write(bot_path, result)
return True
def undo_do_runs(bot_path, name):
text = _read(bot_path)
lines = text.split(NL)
en = name
new_lines = []
removed = False
for i, line in enumerate(lines):
stripped = line.strip()
if stripped.startswith(Q2 + "run_" + en + Q2 + ":") and "Config().routes.get" in stripped:
removed = True
continue
new_lines.append(line)
if not removed:
return False
_write(bot_path, NL.join(new_lines))
return True
# ─── instantiation block ──────────────────────────────────────────────
def insert_instantiation(bot_path, name, class_name):
text = _read(bot_path)
if "self._" + name + " = " in text:
return False
pat = r'(self\._[a-z_]+ = [A-Z][a-zA-Z]*\(self\._pather, self\._town_manager[^)]+\))(\s*\n)'
matches = list(re.finditer(pat, text))
if not matches:
raise ValueError("Cannot find run instantiation pattern")
last = matches[-1]
# Extract indentation from the start of the matched line
before_match = text[:last.start(1)]
line_start = before_match.rfind(NL) + 1
indent = before_match[line_start:last.start(1)]
new_line = indent + "self._" + name + " = " + class_name
new_line += "(self._pather, self._town_manager, self._char, self._pickit, self._do_runs)" + NL
result = text[:last.end()] + new_line + text[last.end():]
_write(bot_path, result)
return True
def undo_instantiation(bot_path, name):
text = _read(bot_path)
lines = text.split(NL)
new_lines = []
removed = False
for line in lines:
stripped = line.strip()
if stripped.startswith("self._" + name + " = ") and "(self._pather" in stripped:
removed = True
continue
new_lines.append(line)
if not removed:
return False
_write(bot_path, NL.join(new_lines))
return True
# ─── _states list ─────────────────────────────────────────────────────
def insert_state(bot_path, name):
text = _read(bot_path)
m = re.search(r'(self\._states\s*=\s*\[)(.*?)(\])', text, re.DOTALL)
if not m:
raise ValueError("Cannot find self._states list")
inner = m.group(2)
needle = Q1 + name + Q1
if needle in inner:
return False
# Append with comma
if inner.rstrip().endswith(","):
inner = inner + " " + needle
else:
inner = inner + ", " + needle
result = text[:m.start(2)] + inner + m.group(3) + text[m.end():]
_write(bot_path, result)
return True
def undo_state(bot_path, name):
text = _read(bot_path)
m = re.search(r'(self\._states\s*=\s*\[)(.*?)(\])', text, re.DOTALL)
if not m:
return False
inner = m.group(2)
en = re.escape(name)
new_inner = re.sub(r',\s*' + Q1 + en + Q1, '', inner)
new_inner = re.sub(Q1 + en + Q1, '', new_inner)
if new_inner == inner:
return False
text = text[:m.start(2)] + new_inner + text[m.end(2):]
_write(bot_path, text)
return True
# ─── _transitions list ────────────────────────────────────────────────
def insert_transitions(bot_path, name):
text = _read(bot_path)
if Q1 + "trigger" + Q1 + ": " + Q1 + "run_" + name + Q1 in text:
return False
marker = "# End run / game"
if marker not in text:
raise ValueError("Cannot find # End run / game")
idx = text.index(marker)
# Find the line start for proper indentation — insert BEFORE the comment line
ls = text.rfind(NL, 0, idx) + 1
indent = text[ls:idx]
# Build the new transition row with proper formatting
tl = indent + LB + " 'trigger': 'run_" + name + "', 'source': 'town', 'dest': '"
tl += name + "', 'before': " + Q2 + "on_run_" + name + Q2 + " " + RB + ","
tl += NL
# Insert at line start, before the comment line (preserving its indentation)
text = text[:ls] + tl + text[ls:]
# Add name to end_run source list
m = re.search(r"('trigger': 'end_run', 'source': \[)([^\]]+)(\])", text, re.DOTALL)
if m:
s = m.group(2).strip()
if not s.endswith(","):
s += ","
s += " " + Q1 + name + Q1
text = text[:m.start(2)] + s + text[m.end(2):]
# Add name to end_game source list
m = re.search(r"('trigger': 'end_game', 'source': \[)([^\]]+)(\])", text, re.DOTALL)
if m:
s = m.group(2).strip()
if not s.endswith(","):
s += ","
s += " " + Q1 + name + Q1
text = text[:m.start(2)] + s + text[m.end(2):]
_write(bot_path, text)
return True
def undo_transitions(bot_path, name):
text = _read(bot_path)
en = name
changed = False
# Remove the transition row for this route using line-by-line removal
lines = text.split(NL)
new_lines = []
removed_transition = False
for line in lines:
stripped = line.strip()
# Match lines containing the transition dict for this route
if (stripped.startswith(LB) and
f"'run_{en}'" in stripped and
f"on_run_{en}" in stripped and
stripped.endswith(RB + ",")):
removed_transition = True
changed = True
continue
new_lines.append(line)
if removed_transition:
text = NL.join(new_lines)
# Remove name from end_run and end_game source lists
for trigger in ["end_run", "end_game"]:
pat2 = r"('trigger': 'end_run', 'source': \[)([^\]]+)(\])" if trigger == "end_run" else r"('trigger': 'end_game', 'source': \[)([^\]]+)(\])"
m = re.search(pat2, text, re.DOTALL)
if m:
sources = m.group(2)
ns = re.sub(r',\s*' + Q1 + en + Q1, '', sources)
ns = re.sub(Q1 + en + Q1, '', ns)
if ns != sources:
text = text[:m.start(2)] + ns + text[m.end(2):]
changed = True
if changed:
_write(bot_path, text)
return changed
# ─── on_run_* handler ─────────────────────────────────────────────────
def insert_handler(bot_path, name, display):
text = _read(bot_path)
if "def on_run_" + name in text:
return False
pat = r'( def on_run_[a-z_]+\(self\):\n(?: [^\n]*\n)+)'
matches = list(re.finditer(pat, text, re.DOTALL))
if not matches:
raise ValueError("Cannot find existing handler pattern")
last = matches[-1]
h = [
"",
" def on_run_" + name + "(self):",
" self._game_stats.update_location(" + Q2 + display + Q2 + ")",
" self._run_wrapper(" + Q2 + "run_" + name + Q2 + ", self._" + name + ", (), (not self._pre_buffed,))",
]
handler = NL.join(h) + NL
result = text[:last.end()] + handler + text[last.end():]
_write(bot_path, result)
return True
def undo_handler(bot_path, name):
text = _read(bot_path)
pat = re.compile(
r'\n\s*def on_run_' + re.escape(name) + r'\(self\):\n(?: [^\n]*\n)+',
re.DOTALL
)
t = pat.sub("", text)
if t == text:
return False
_write(bot_path, t)
return True
# ─── batch operations ──────────────────────────────────────────────────
def apply_all(base_dir, name, class_name, display, act, location_id=None):
bp = os.path.join(base_dir, "src", "bot.py")
ip = os.path.join(base_dir, "src", "run", "__init__.py")
actions = []
if insert_init_import(ip, name, class_name):
actions.append("init_import")
if insert_into_do_runs(bp, name):
actions.append("do_runs")
if insert_instantiation(bp, name, class_name):
actions.append("instantiation")
if insert_state(bp, name):
actions.append("state")
if insert_transitions(bp, name):
actions.append("transitions")
if insert_handler(bp, name, display):
actions.append("handler")
return actions
def undo_all(base_dir, name):
bp = os.path.join(base_dir, "src", "bot.py")
ip = os.path.join(base_dir, "src", "run", "__init__.py")
text = _read(ip)
m = re.search(r'from \.' + re.escape(name) + r' import ([A-Za-z_]+)', text)
cn = m.group(1) if m else name.replace("_", " ").title().replace(" ", "")
actions = []
if undo_handler(bp, name):
actions.append("handler")
if undo_transitions(bp, name):
actions.append("transitions")
if undo_state(bp, name):
actions.append("state")
if undo_instantiation(bp, name):
actions.append("instantiation")
if undo_do_runs(bp, name):
actions.append("do_runs")
if undo_init_import(ip, name):
actions.append("init_import")
rf = os.path.join(base_dir, "src", "run", name + ".py")
if os.path.exists(rf):
os.remove(rf)
actions.append("removed_run_file")
return actions