main.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. import os
  2. import threading
  3. from typing import Any, Dict
  4. import toml
  5. from textual import work
  6. from textual.app import App, ComposeResult
  7. from textual.containers import Horizontal, Vertical
  8. from textual.widgets import (
  9. Button,
  10. Footer,
  11. Header,
  12. Input,
  13. Label,
  14. ProgressBar,
  15. RichLog,
  16. Rule,
  17. Select,
  18. TabbedContent,
  19. TabPane,
  20. )
  21. from util.progress import ProgressTracker
  22. from util.screens import OverwriteConfirmScreen
  23. from util.tasks import PIPELINE_TASKS, SCENARIOS
  24. from util.ui_logger import PipelineLogger
  25. # ==========================================
  26. # TUI Application
  27. # ==========================================
  28. class CNNHarnessApp(App):
  29. CSS_PATH = "style.tcss"
  30. def __init__(self):
  31. super().__init__()
  32. self.stop_event = threading.Event()
  33. self.pause_event = threading.Event()
  34. # Initialize our consolidated, thread-aware logger
  35. self.ui_logger = PipelineLogger(self, widget_id="#live_log")
  36. def compose(self) -> ComposeResult:
  37. yield Header()
  38. with Vertical(id="main_container"):
  39. # --- TOP SECTION: Scrollable Configuration Tabs ---
  40. with TabbedContent(initial="tab-general"):
  41. with TabPane("General Config", id="tab-general"):
  42. with Horizontal(classes="row"):
  43. with Vertical(classes="input_group"):
  44. yield Label("Load Configuration:")
  45. yield Input(placeholder="./config.toml", id="config_path")
  46. yield Button("Load Config", variant="primary", id="load_btn")
  47. with Horizontal(classes="row"):
  48. with Vertical(classes="input_group"):
  49. yield Label("Save Directory:", classes="input_label")
  50. yield Input(
  51. placeholder="./models/experiment_1", id="save_dir"
  52. )
  53. with Vertical(classes="input_group"):
  54. yield Label("Batch Size:", classes="input_label")
  55. yield Input(placeholder="32", id="batch_size")
  56. # --- BOTTOM SECTION: Controls, Progress, and Logs ---
  57. with Horizontal(id="bottom_area"):
  58. with Vertical(id="control_panel"):
  59. with Horizontal(id="status_row"):
  60. scenario_options = [
  61. (data["label"], sc_id) for sc_id, data in SCENARIOS.items()
  62. ]
  63. yield Select(
  64. scenario_options,
  65. prompt="Select Pipeline Scenario...",
  66. id="scenario_select",
  67. )
  68. yield Label("STATUS: READY", id="pipeline_status_label")
  69. yield Rule()
  70. yield Button(
  71. "Start Pipeline & Save Config",
  72. variant="success",
  73. id="btn_start",
  74. )
  75. with Horizontal(id="active_controls"):
  76. yield Button("Pause", variant="warning", id="btn_pause")
  77. yield Button("Stop", variant="error", id="btn_stop")
  78. yield Rule()
  79. with Vertical(id="progress_area"):
  80. for i in range(5):
  81. with Horizontal(
  82. id=f"progress_row_{i}", classes="progress_row"
  83. ):
  84. yield Label(
  85. id=f"progress_title_{i}", classes="progress_title"
  86. )
  87. yield ProgressBar(
  88. id=f"progress_bar_{i}",
  89. classes="progress_bar",
  90. show_eta=True,
  91. )
  92. yield Label(
  93. id=f"progress_stats_{i}", classes="progress_stats"
  94. )
  95. with Vertical(id="log_panel"):
  96. yield Label("Live Logs:")
  97. yield RichLog(id="live_log", highlight=True, markup=True)
  98. yield Footer()
  99. def on_mount(self) -> None:
  100. self.query_one("#active_controls").display = False
  101. self.ui_logger.info("Application initialized and ready.")
  102. def _set_pipeline_status(self, text: str) -> None:
  103. try:
  104. self.query_one("#pipeline_status_label", Label).update(text)
  105. except Exception:
  106. pass
  107. # ==========================================
  108. # Configuration Management Methods
  109. # ==========================================
  110. def _load_config_file(self, filepath: str) -> Dict[str, Any]:
  111. with open(filepath, "r") as f:
  112. return toml.load(f)
  113. def _save_config_file(self, filepath: str, config: Dict[str, Any]) -> None:
  114. os.makedirs(os.path.dirname(filepath), exist_ok=True)
  115. with open(filepath, "w") as f:
  116. toml.dump(config, f)
  117. def _populate_ui_from_config(self, config: Dict[str, Any]) -> None:
  118. self.query_one("#save_dir", Input).value = str(config.get("save_dir", ""))
  119. self.query_one("#batch_size", Input).value = str(config.get("batch_size", ""))
  120. loaded_scenario = config.get("scenario", "")
  121. if loaded_scenario in SCENARIOS:
  122. self.query_one("#scenario_select", Select).value = loaded_scenario
  123. def _gather_config_from_ui(self) -> Dict[str, Any]:
  124. scenario_select = self.query_one("#scenario_select", Select)
  125. scenario_id = (
  126. scenario_select.value if type(scenario_select.value) is str else None
  127. )
  128. config = {
  129. "save_dir": self.query_one("#save_dir", Input).value or "./outputs/default",
  130. "batch_size": self.query_one("#batch_size", Input).value or "32",
  131. "scenario": scenario_id,
  132. }
  133. if scenario_id and scenario_id in SCENARIOS:
  134. config["steps"] = SCENARIOS[scenario_id]["tasks"]
  135. else:
  136. config["steps"] = []
  137. return config
  138. # ==========================================
  139. # Event Handlers
  140. # ==========================================
  141. def on_button_pressed(self, event: Button.Pressed) -> None:
  142. if event.button.id == "load_btn":
  143. config_path = self.query_one("#config_path", Input).value
  144. if not config_path:
  145. self.ui_logger.error("Please specify a config path to load.")
  146. return
  147. try:
  148. loaded_config = self._load_config_file(config_path)
  149. self._populate_ui_from_config(loaded_config)
  150. self.ui_logger.info(f"Successfully loaded config from {config_path}")
  151. except Exception as e:
  152. self.ui_logger.error(f"Failed to load config: {str(e)}")
  153. elif event.button.id == "btn_start":
  154. config_dict = self._gather_config_from_ui()
  155. if not config_dict.get("scenario"):
  156. self.ui_logger.error(
  157. "No scenario selected! Please select a scenario from the dropdown."
  158. )
  159. return
  160. save_dir = config_dict["save_dir"]
  161. if os.path.exists(save_dir) and os.listdir(save_dir):
  162. def check_overwrite_callback(proceed: bool) -> None:
  163. if proceed:
  164. self._execute_pipeline(config_dict)
  165. else:
  166. self.ui_logger.error(
  167. "Pipeline start cancelled by user (folder non-empty)."
  168. )
  169. self.app.push_screen(
  170. OverwriteConfirmScreen(save_dir),
  171. check_overwrite_callback, # pyright: ignore
  172. )
  173. else:
  174. self._execute_pipeline(config_dict)
  175. elif event.button.id == "btn_pause":
  176. if self.pause_event.is_set():
  177. self.pause_event.clear()
  178. event.button.label = "Pause"
  179. event.button.variant = "warning"
  180. self._set_pipeline_status("[bold green]PIPELINE RUNNING[/bold green]")
  181. self.ui_logger.info("Pipeline Resumed.")
  182. else:
  183. self.pause_event.set()
  184. event.button.label = "Resume"
  185. event.button.variant = "success"
  186. self._set_pipeline_status("[bold yellow]PIPELINE PAUSED[/bold yellow]")
  187. self.ui_logger.info(
  188. "Pipeline Paused. Waiting for current operation to yield..."
  189. )
  190. elif event.button.id == "btn_stop":
  191. self._set_pipeline_status("[bold red]STOPPING PIPELINE...[/bold red]")
  192. self.ui_logger.error("Stop requested! Terminating gracefully...")
  193. event.button.disabled = True
  194. self.stop_event.set()
  195. # ==========================================
  196. # Pipeline Execution
  197. # ==========================================
  198. def _execute_pipeline(self, config_dict: Dict[str, Any]) -> None:
  199. self.query_one(TabbedContent).disabled = True
  200. self.query_one("#scenario_select").disabled = True
  201. self.query_one("#btn_start").display = False
  202. self.query_one("#active_controls").display = True
  203. self._set_pipeline_status("[bold green]PIPELINE RUNNING[/bold green]")
  204. self.stop_event.clear()
  205. self.pause_event.clear()
  206. self.query_one("#btn_pause", Button).label = "Pause"
  207. self.query_one("#btn_pause", Button).variant = "warning"
  208. self.run_background_pipeline(config_dict)
  209. @work(exclusive=True, thread=True)
  210. def run_background_pipeline(self, config: Dict[str, Any]) -> None:
  211. save_dir = config["save_dir"]
  212. steps = config["steps"]
  213. scenario_label = SCENARIOS[config["scenario"]]["label"]
  214. root_tracker = ProgressTracker(self, level=0)
  215. try:
  216. config_out_path = os.path.join(save_dir, "config.toml")
  217. self._save_config_file(config_out_path, config)
  218. self.ui_logger.info(f"Initiating Scenario: {scenario_label}")
  219. self.ui_logger.info(f"Configuration saved to {config_out_path}")
  220. total_tasks = len(steps)
  221. pipeline_state: Dict[str, Any] = {
  222. "events": {"stop": self.stop_event, "pause": self.pause_event}
  223. }
  224. root_tracker.reset()
  225. root_tracker.set_title("Pipeline Status")
  226. root_tracker.update(total=total_tasks, advance=0)
  227. for i, task_id in enumerate(steps):
  228. if self.stop_event.is_set():
  229. break
  230. task_name = PIPELINE_TASKS[task_id]["task_name"]
  231. task_func = PIPELINE_TASKS[task_id]["task_func"]
  232. # Generate a task-specific logger from our PipelineLogger
  233. task_logger = self.ui_logger.get_task_logger(task_name)
  234. self.ui_logger.info(f"Starting Task {i + 1}/{total_tasks}: {task_name}")
  235. task_tracker = root_tracker.get_sub_tracker(task_name)
  236. task_func(task_tracker, task_logger, config, pipeline_state)
  237. if not self.stop_event.is_set():
  238. task_logger.info("Task completed.")
  239. root_tracker.update(advance=1)
  240. if self.stop_event.is_set():
  241. self.call_from_thread(
  242. self._set_pipeline_status, "[bold red]PIPELINE STOPPED[/bold red]"
  243. )
  244. self.ui_logger.error("Pipeline stopped by user.")
  245. root_tracker.reset()
  246. else:
  247. self.call_from_thread(
  248. self._set_pipeline_status,
  249. "[bold blue]PIPELINE COMPLETE[/bold blue]",
  250. )
  251. self.ui_logger.info("All tasks finished successfully!")
  252. except InterruptedError as e:
  253. self.ui_logger.error(f"STOPPED: {str(e)}")
  254. self.call_from_thread(
  255. self._set_pipeline_status, "[bold red]PIPELINE STOPPED[/bold red]"
  256. )
  257. root_tracker.reset()
  258. except Exception as e:
  259. self.ui_logger.file_logger.error(
  260. f"Pipeline failed: {str(e)}", exc_info=True
  261. )
  262. self.ui_logger.error(f"ERROR: {str(e)}")
  263. self.call_from_thread(
  264. self._set_pipeline_status, "[bold red]PIPELINE FAILED[/bold red]"
  265. )
  266. finally:
  267. self.call_from_thread(self._reset_ui)
  268. def _reset_ui(self) -> None:
  269. self.query_one("#btn_stop", Button).disabled = False
  270. self.query_one("#btn_start").display = True
  271. self.query_one("#active_controls").display = False
  272. self.query_one(TabbedContent).disabled = False
  273. self.query_one("#scenario_select").disabled = False
  274. if __name__ == "__main__":
  275. app = CNNHarnessApp()
  276. app.run()