Coverage for openhcs/textual_tui/windows/debug_class_explorer.py: 0.0%
380 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-04 02:09 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-04 02:09 +0000
1"""Dynamic Visual AST Inspector for OpenHCS debugging."""
3import ast
4import inspect
5import importlib
6from typing import Dict, Any, Optional
7from textual.app import ComposeResult
8from textual.widgets import Static, Tree, Button, Input, Label
9from textual.containers import Horizontal, Vertical, ScrollableContainer
10from textual.reactive import reactive
12from openhcs.textual_tui.windows.base_window import BaseOpenHCSWindow
15class ASTNodeInfo:
16 """Information about an AST node."""
18 def __init__(self, node: ast.AST, parent: Optional['ASTNodeInfo'] = None):
19 self.node = node
20 self.parent = parent
21 self.children = []
22 self.node_type = type(node).__name__
23 self.description = self._get_description()
24 self.details = self._get_details()
26 def _get_description(self) -> str:
27 """Get a human-readable description of the node."""
28 if isinstance(self.node, ast.ClassDef):
29 bases = [self._get_name(base) for base in self.node.bases]
30 base_str = f"({', '.join(bases)})" if bases else ""
31 return f"Class: {self.node.name}{base_str}"
32 elif isinstance(self.node, ast.FunctionDef):
33 args = [arg.arg for arg in self.node.args.args]
34 return f"Function: {self.node.name}({', '.join(args)})"
35 elif isinstance(self.node, ast.AsyncFunctionDef):
36 args = [arg.arg for arg in self.node.args.args]
37 return f"Async Function: {self.node.name}({', '.join(args)})"
38 elif isinstance(self.node, ast.Import):
39 names = [alias.name for alias in self.node.names]
40 return f"Import: {', '.join(names)}"
41 elif isinstance(self.node, ast.ImportFrom):
42 names = [alias.name for alias in self.node.names]
43 return f"From {self.node.module}: {', '.join(names)}"
44 elif isinstance(self.node, ast.Assign):
45 targets = [self._get_name(target) for target in self.node.targets]
46 return f"Assignment: {', '.join(targets)}"
47 elif isinstance(self.node, ast.AnnAssign):
48 target = self._get_name(self.node.target)
49 return f"Annotated Assignment: {target}"
50 elif isinstance(self.node, ast.Expr):
51 return f"Expression: {self._get_name(self.node.value)}"
52 else:
53 return f"{self.node_type}"
55 def _get_details(self) -> Dict[str, Any]:
56 """Get detailed information about the node."""
57 details = {'type': self.node_type}
59 if isinstance(self.node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
60 details['name'] = self.node.name
61 details['docstring'] = ast.get_docstring(self.node)
63 if isinstance(self.node, ast.ClassDef):
64 details['bases'] = [self._get_name(base) for base in self.node.bases]
65 details['decorators'] = [self._get_name(dec) for dec in self.node.decorator_list]
67 if isinstance(self.node, (ast.FunctionDef, ast.AsyncFunctionDef)):
68 details['args'] = [arg.arg for arg in self.node.args.args]
69 details['decorators'] = [self._get_name(dec) for dec in self.node.decorator_list]
70 details['returns'] = self._get_name(self.node.returns) if self.node.returns else None
72 if hasattr(self.node, 'lineno'):
73 details['line'] = self.node.lineno
75 return details
77 def _get_name(self, node: ast.AST) -> str:
78 """Get the name of a node."""
79 if isinstance(node, ast.Name):
80 return node.id
81 elif isinstance(node, ast.Attribute):
82 return f"{self._get_name(node.value)}.{node.attr}"
83 elif isinstance(node, ast.Constant):
84 return repr(node.value)
85 elif isinstance(node, ast.Call):
86 func_name = self._get_name(node.func)
87 return f"{func_name}(...)"
88 else:
89 return f"<{type(node).__name__}>"
92class DynamicASTAnalyzer:
93 """Dynamic AST analyzer that can inspect any Python module or file."""
95 @staticmethod
96 def get_inspection_categories() -> Dict[str, Dict[str, str]]:
97 """Get predefined categories of things to inspect."""
98 return {
99 "Config Classes": {
100 "config_form": "openhcs.textual_tui.widgets.config_form",
101 "config_dialog": "openhcs.textual_tui.screens.config_dialog",
102 "config_window": "openhcs.textual_tui.windows.config_window",
103 "config_form_screen": "openhcs.textual_tui.screens.config_form"
104 },
105 "Core Components": {
106 "pipeline": "openhcs.core.pipeline",
107 "orchestrator": "openhcs.core.orchestrator.orchestrator",
108 "step_base": "openhcs.core.step_base",
109 "config": "openhcs.core.config"
110 },
111 "TUI Widgets": {
112 "main_content": "openhcs.textual_tui.widgets.main_content",
113 "plate_manager": "openhcs.textual_tui.widgets.plate_manager",
114 "function_pane": "openhcs.textual_tui.widgets.function_pane",
115 "pipeline_editor": "openhcs.textual_tui.widgets.pipeline_editor"
116 },
117 "Processing Backends": {
118 "cupy_processor": "openhcs.processing.backends.enhance.basic_processor_cupy",
119 "numpy_processor": "openhcs.processing.backends.enhance.basic_processor_numpy",
120 "torch_processor": "openhcs.processing.backends.enhance.n2v2_processor_torch"
121 },
122 "Window System": {
123 "base_window": "openhcs.textual_tui.windows.base_window",
124 "help_window": "openhcs.textual_tui.windows.help_window",
125 "file_browser": "openhcs.textual_tui.windows.file_browser_window"
126 }
127 }
129 @staticmethod
130 def analyze_module(module_name: str) -> Optional[ASTNodeInfo]:
131 """Analyze a Python module and return AST tree."""
132 try:
133 # Import the module to get its file path
134 module = importlib.import_module(module_name)
135 file_path = inspect.getfile(module)
137 # Read and parse the source
138 with open(file_path, 'r') as f:
139 source = f.read()
141 tree = ast.parse(source)
142 return DynamicASTAnalyzer._build_ast_tree(tree)
144 except Exception as e:
145 # Create error node
146 error_node = ast.Module(body=[], type_ignores=[])
147 error_info = ASTNodeInfo(error_node)
148 error_info.description = f"Error: {e}"
149 return error_info
151 @staticmethod
152 def analyze_file(file_path: str) -> Optional[ASTNodeInfo]:
153 """Analyze a Python file and return AST tree."""
154 try:
155 with open(file_path, 'r') as f:
156 source = f.read()
158 tree = ast.parse(source)
159 return DynamicASTAnalyzer._build_ast_tree(tree)
161 except Exception as e:
162 # Create error node
163 error_node = ast.Module(body=[], type_ignores=[])
164 error_info = ASTNodeInfo(error_node)
165 error_info.description = f"Error: {e}"
166 return error_info
168 @staticmethod
169 def _build_ast_tree(node: ast.AST, parent: Optional[ASTNodeInfo] = None) -> ASTNodeInfo:
170 """Recursively build AST tree."""
171 node_info = ASTNodeInfo(node, parent)
173 # Add children
174 for child in ast.iter_child_nodes(node):
175 child_info = DynamicASTAnalyzer._build_ast_tree(child, node_info)
176 node_info.children.append(child_info)
178 return node_info
181class DebugClassExplorerWindow(BaseOpenHCSWindow):
182 """Dynamic Visual AST Inspector for OpenHCS debugging."""
184 DEFAULT_CSS = """
185 DebugClassExplorerWindow {
186 width: 95; height: 35;
187 min-width: 80; min-height: 25;
188 }
190 .category-buttons {
191 height: auto;
192 width: 1fr;
193 }
195 .custom-input {
196 height: auto;
197 width: 1fr;
198 }
200 .ast-tree {
201 width: 1fr;
202 height: 1fr;
203 }
205 .ast-details {
206 width: 1fr;
207 height: 1fr;
208 }
210 .detail-text {
211 height: 1fr;
212 border: solid $primary;
213 padding: 1;
214 }
216 .breadcrumbs {
217 height: auto;
218 width: 1fr;
219 margin: 1 0;
220 }
222 #breadcrumb_path {
223 margin: 0 1;
224 color: $text-muted;
225 }
227 /* Use standard button spacing from global styles */
228 .category-buttons Button {
229 margin: 0 1;
230 }
232 .custom-input Button {
233 margin: 0 1;
234 }
236 .breadcrumbs Button {
237 margin: 0 1;
238 }
239 """
241 selected_node = reactive(None)
242 current_ast_tree = reactive(None)
243 navigation_stack = [] # Stack for breadcrumb navigation
245 def __init__(self, **kwargs):
246 super().__init__(
247 window_id="debug_ast_inspector",
248 title="Dynamic Visual AST Inspector",
249 mode="temporary",
250 **kwargs
251 )
253 # Get inspection categories
254 self.categories = DynamicASTAnalyzer.get_inspection_categories()
256 def on_mount(self) -> None:
257 """Initialize the window with a default view."""
258 # Show available categories in the tree initially
259 self._show_initial_state()
263 def compose(self) -> ComposeResult:
264 """Compose the dynamic AST inspector."""
265 with Vertical():
266 # Top panel - Category buttons and custom input
267 with Horizontal(classes="category-buttons"):
268 yield Static("[bold]Inspect:[/bold]")
269 for category_name in self.categories.keys():
270 yield Button(category_name, id=f"cat_{category_name.lower().replace(' ', '_')}", compact=True)
272 # Custom module input
273 with Horizontal(classes="custom-input"):
274 yield Label("Custom Module:")
275 yield Input(placeholder="e.g., openhcs.core.pipeline", id="custom_module_input")
276 yield Button("Analyze", id="analyze_custom", compact=True)
277 yield Button("Browse File", id="browse_file", compact=True)
279 # Navigation breadcrumbs
280 with Horizontal(classes="breadcrumbs"):
281 yield Button("🏠 Home", id="nav_home", compact=True)
282 yield Button("⬅️ Back", id="nav_back", compact=True, disabled=True)
283 yield Static("", id="breadcrumb_path")
285 # Main content - AST tree and details
286 with Horizontal():
287 # Left panel - AST tree
288 with Vertical(classes="ast-tree"):
289 yield Static("[bold]AST Structure[/bold]")
290 tree = Tree("🔍 Click a category button or enter a module name")
291 tree.id = "ast_tree"
292 tree.show_root = True
293 yield tree
295 # Right panel - node details
296 with Vertical(classes="ast-details"):
297 yield Static("[bold]Node Details[/bold]", id="details_title")
299 with ScrollableContainer(classes="detail-text"):
300 yield Static("Select a node to see details", id="node_details")
302 def on_button_pressed(self, event: Button.Pressed) -> None:
303 """Handle button presses."""
304 button_id = event.button.id
306 if button_id == "close":
307 self.close_window()
308 elif button_id.startswith("cat_"):
309 # Category button pressed
310 category_name = button_id[4:].replace('_', ' ').title()
311 self._analyze_category(category_name)
312 elif button_id == "analyze_custom":
313 # Custom module analysis
314 self._analyze_custom_module()
315 elif button_id == "browse_file":
316 # File browser (placeholder for now)
317 self._show_file_browser()
318 elif button_id == "nav_home":
319 # Navigate back to home
320 self._navigate_home()
321 elif button_id == "nav_back":
322 # Navigate back one level
323 self._navigate_back()
325 def on_input_submitted(self, event: Input.Submitted) -> None:
326 """Handle input submission."""
327 if event.input.id == "custom_module_input":
328 self._analyze_custom_module()
330 def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
331 """Handle AST tree node selection."""
332 if hasattr(event.node, 'data'):
333 if isinstance(event.node.data, ASTNodeInfo):
334 self.selected_node = event.node.data
335 self._update_node_details()
336 elif isinstance(event.node.data, dict):
337 data = event.node.data
338 if data.get("type") == "expandable":
339 # Expand this node
340 self._expand_node(event.node, data["parent_node"])
341 elif data.get("type") == "module_placeholder":
342 # Analyze this specific module
343 self._analyze_single_module(data["module_name"], data["label"])
345 def _expand_node(self, tree_node, ast_node_info: ASTNodeInfo):
346 """Expand a node that was previously collapsed."""
347 # Remove the placeholder
348 tree_node.remove()
350 # Add the actual children
351 parent_tree_node = tree_node.parent
352 self._populate_ast_tree(parent_tree_node, ast_node_info, max_depth=3, current_depth=0)
354 def _analyze_single_module(self, module_name: str, module_label: str):
355 """Analyze a single module from the category view."""
356 # Add to navigation stack
357 self.navigation_stack.append({
358 "type": "module",
359 "module_name": module_name,
360 "label": module_label
361 })
362 self._update_navigation_ui()
364 tree = self.query_one("#ast_tree", Tree)
365 tree.clear()
366 tree.root.label = f"📦 {module_label} ({module_name})"
368 try:
369 # Show loading message
370 try:
371 details_widget = self.query_one("#node_details", Static)
372 details_widget.update(f"[yellow]Analyzing {module_label}...[/yellow]")
373 except:
374 pass
376 ast_tree = DynamicASTAnalyzer.analyze_module(module_name)
377 if ast_tree:
378 tree.root.data = ast_tree
379 self._populate_ast_tree(tree.root, ast_tree, max_depth=3) # Deeper analysis for single module
380 tree.root.expand()
382 # Update details
383 try:
384 details_widget = self.query_one("#node_details", Static)
385 details_widget.update(
386 f"[bold cyan]Module: {module_label}[/bold cyan]\n"
387 f"[dim]({module_name})[/dim]\n\n"
388 f"[green]✅ Analysis complete![/green]\n"
389 f"[yellow]AST nodes:[/yellow] {len(ast_tree.children)}\n\n"
390 "[blue]Click any node to see details[/blue]\n"
391 "[green]Use navigation buttons to go back[/green]"
392 )
393 except:
394 pass
395 else:
396 error_node = tree.root.add("❌ Failed to analyze")
398 except Exception as e:
399 error_node = tree.root.add(f"❌ Error: {str(e)[:50]}")
400 try:
401 details_widget = self.query_one("#node_details", Static)
402 details_widget.update(f"[red]❌ Error analyzing {module_label}: {e}[/red]")
403 except:
404 pass
406 def _navigate_home(self):
407 """Navigate back to the home screen."""
408 self.navigation_stack = []
409 self._update_navigation_ui()
410 self._show_initial_state()
412 def _navigate_back(self):
413 """Navigate back one level."""
414 if not self.navigation_stack:
415 return
417 # Remove current level
418 self.navigation_stack.pop()
419 self._update_navigation_ui()
421 if not self.navigation_stack:
422 # Back to home
423 self._show_initial_state()
424 else:
425 # Recreate the previous level (but don't add to stack again)
426 previous = self.navigation_stack[-1]
427 if previous["type"] == "category":
428 # Temporarily remove from stack to avoid double-adding
429 temp_item = self.navigation_stack.pop()
430 self._analyze_category(previous["name"])
431 elif previous["type"] == "module":
432 temp_item = self.navigation_stack.pop()
433 self._analyze_single_module(previous["module_name"], previous["label"])
434 elif previous["type"] == "custom_module":
435 temp_item = self.navigation_stack.pop()
436 # Set the input field and analyze
437 try:
438 input_widget = self.query_one("#custom_module_input", Input)
439 input_widget.value = previous["module_name"]
440 self._analyze_custom_module()
441 except:
442 pass
444 def _update_navigation_ui(self):
445 """Update the navigation breadcrumbs and button states."""
446 try:
447 # Update back button state
448 back_button = self.query_one("#nav_back", Button)
449 back_button.disabled = len(self.navigation_stack) == 0
451 # Update breadcrumb path
452 breadcrumb = self.query_one("#breadcrumb_path", Static)
453 if not self.navigation_stack:
454 breadcrumb.update("🏠 Home")
455 else:
456 path_parts = ["🏠"] + [item["label"] for item in self.navigation_stack]
457 breadcrumb.update(" → ".join(path_parts))
458 except:
459 pass
461 def _show_initial_state(self):
462 """Show initial state with available categories."""
463 # Clear navigation stack
464 self.navigation_stack = []
465 self._update_navigation_ui()
467 tree = self.query_one("#ast_tree", Tree)
468 tree.clear()
469 tree.root.label = "🔍 Available Analysis Categories"
471 for category_name, modules in self.categories.items():
472 category_node = tree.root.add(f"📂 {category_name} ({len(modules)} modules)")
473 for module_label, module_name in modules.items():
474 module_node = category_node.add(f"📄 {module_label}")
475 module_node.data = {"type": "module_placeholder", "module_name": module_name, "label": module_label}
477 tree.root.expand()
479 # Update details
480 try:
481 details_widget = self.query_one("#node_details", Static)
482 details_widget.update(
483 "[bold cyan]Dynamic AST Inspector[/bold cyan]\n\n"
484 "[yellow]Instructions:[/yellow]\n"
485 "• Click a category button to analyze all modules in that category\n"
486 "• Enter a custom module name and click 'Analyze'\n"
487 "• Click any node in the AST tree to see detailed information\n"
488 "• Use 🏠 Home or ⬅️ Back buttons to navigate\n\n"
489 "[green]Available Categories:[/green]\n" +
490 "\n".join(f"• {name}: {len(modules)} modules" for name, modules in self.categories.items())
491 )
492 except:
493 pass
495 def _analyze_category(self, category_name: str):
496 """Analyze all modules in a category."""
497 if category_name not in self.categories:
498 return
500 # Add to navigation stack
501 self.navigation_stack.append({
502 "type": "category",
503 "name": category_name,
504 "label": f"Category: {category_name}"
505 })
506 self._update_navigation_ui()
508 tree = self.query_one("#ast_tree", Tree)
509 tree.clear()
510 tree.root.label = f"📂 {category_name} - AST Analysis"
512 modules = self.categories[category_name]
513 for module_label, module_name in modules.items():
514 try:
515 ast_tree = DynamicASTAnalyzer.analyze_module(module_name)
516 if ast_tree:
517 module_node = tree.root.add(f"📦 {module_label}")
518 module_node.data = ast_tree
519 self._populate_ast_tree(module_node, ast_tree, max_depth=1) # Shallow depth for category view
520 else:
521 error_node = tree.root.add(f"❌ {module_label} (failed to analyze)")
522 except Exception as e:
523 error_node = tree.root.add(f"❌ {module_label} (error: {str(e)[:50]})")
525 tree.root.expand()
527 # Update details to show category info
528 try:
529 details_widget = self.query_one("#node_details", Static)
530 details_widget.update(
531 f"[bold cyan]Category: {category_name}[/bold cyan]\n\n"
532 f"[yellow]Modules analyzed:[/yellow] {len(modules)}\n\n"
533 "[green]Click any module to see detailed AST[/green]\n"
534 "[blue]Use navigation buttons to go back[/blue]"
535 )
536 except:
537 pass
539 def _analyze_custom_module(self):
540 """Analyze a custom module entered by user."""
541 try:
542 input_widget = self.query_one("#custom_module_input", Input)
543 module_name = input_widget.value.strip()
545 if not module_name:
546 # Show help message
547 try:
548 details_widget = self.query_one("#node_details", Static)
549 details_widget.update("[yellow]Please enter a module name (e.g., openhcs.core.config)[/yellow]")
550 except:
551 pass
552 return
554 # Add to navigation stack
555 self.navigation_stack.append({
556 "type": "custom_module",
557 "module_name": module_name,
558 "label": f"Custom: {module_name}"
559 })
560 self._update_navigation_ui()
562 tree = self.query_one("#ast_tree", Tree)
563 tree.clear()
564 tree.root.label = f"📦 Custom: {module_name}"
566 # Show loading message
567 try:
568 details_widget = self.query_one("#node_details", Static)
569 details_widget.update(f"[yellow]Analyzing module: {module_name}...[/yellow]")
570 except:
571 pass
573 ast_tree = DynamicASTAnalyzer.analyze_module(module_name)
574 if ast_tree:
575 tree.root.data = ast_tree
576 self._populate_ast_tree(tree.root, ast_tree, max_depth=3)
577 tree.root.expand()
579 # Update details with success message
580 try:
581 details_widget = self.query_one("#node_details", Static)
582 details_widget.update(
583 f"[bold cyan]Custom Module: {module_name}[/bold cyan]\n\n"
584 f"[green]✅ Analysis complete![/green]\n"
585 f"[yellow]AST nodes found:[/yellow] {len(ast_tree.children)}\n\n"
586 "[blue]Click any node in the tree to see details[/blue]\n"
587 "[green]Use navigation buttons to go back[/green]"
588 )
589 except:
590 pass
591 else:
592 # Show error in tree
593 error_node = tree.root.add("❌ Failed to analyze module")
594 try:
595 details_widget = self.query_one("#node_details", Static)
596 details_widget.update(f"[red]❌ Failed to analyze module: {module_name}[/red]")
597 except:
598 pass
600 except Exception as e:
601 # Show error in details
602 try:
603 details_widget = self.query_one("#node_details", Static)
604 details_widget.update(f"[red]❌ Error analyzing module: {e}[/red]")
605 except:
606 pass
608 # Show error in tree
609 tree = self.query_one("#ast_tree", Tree)
610 tree.clear()
611 tree.root.label = f"❌ Error: {module_name}"
612 error_node = tree.root.add(f"Error: {str(e)[:100]}")
614 def _show_file_browser(self):
615 """Show file browser (placeholder)."""
616 try:
617 details_widget = self.query_one("#node_details", Static)
618 details_widget.update("[yellow]File browser not implemented yet. Use custom module input instead.[/yellow]")
619 except:
620 pass
622 def _populate_ast_tree(self, parent_node, ast_node_info: ASTNodeInfo, max_depth: int = 2, current_depth: int = 0):
623 """Recursively populate the AST tree with depth control."""
624 if current_depth >= max_depth:
625 return
627 for child in ast_node_info.children:
628 # Choose appropriate icon based on node type
629 icon = self._get_node_icon(child.node_type)
630 child_node = parent_node.add(f"{icon} {child.description}")
631 child_node.data = child
633 # Recursively add children up to max depth
634 if child.children and current_depth < max_depth - 1:
635 self._populate_ast_tree(child_node, child, max_depth, current_depth + 1)
636 elif child.children:
637 # Add a placeholder to show there are more children
638 placeholder = child_node.add(f"📁 ... {len(child.children)} more nodes (click to expand)")
639 placeholder.data = {"type": "expandable", "parent_node": child}
641 def _get_node_icon(self, node_type: str) -> str:
642 """Get appropriate icon for AST node type."""
643 icons = {
644 "ClassDef": "🏛️",
645 "FunctionDef": "⚙️",
646 "AsyncFunctionDef": "⚡",
647 "Import": "📥",
648 "ImportFrom": "📦",
649 "Assign": "📝",
650 "AnnAssign": "🏷️",
651 "Expr": "💭",
652 "If": "🔀",
653 "For": "🔄",
654 "While": "🔁",
655 "Try": "🛡️",
656 "With": "🔒",
657 "Return": "↩️",
658 "Yield": "⤴️"
659 }
660 return icons.get(node_type, "🔹")
662 def _update_node_details(self):
663 """Update the node details panel."""
664 if not self.selected_node:
665 return
667 node_info = self.selected_node
668 details = []
670 details.append(f"[bold cyan]AST Node: {node_info.node_type}[/bold cyan]")
671 details.append(f"[dim]Description: {node_info.description}[/dim]")
672 details.append("")
674 # Show node details
675 for key, value in node_info.details.items():
676 if value is not None:
677 if isinstance(value, list) and value:
678 details.append(f"[bold yellow]{key.title()}:[/bold yellow]")
679 for item in value:
680 details.append(f" • {item}")
681 elif not isinstance(value, list):
682 details.append(f"[bold yellow]{key.title()}:[/bold yellow] {value}")
684 # Show children count
685 if node_info.children:
686 details.append("")
687 details.append(f"[bold blue]Children:[/bold blue] {len(node_info.children)}")
688 for child in node_info.children[:5]:
689 details.append(f" • {child.description}")
690 if len(node_info.children) > 5:
691 details.append(f" ... and {len(node_info.children) - 5} more")
693 # Update the details widget
694 try:
695 details_widget = self.query_one("#node_details", Static)
696 details_widget.update("\n".join(details))
697 except Exception:
698 pass