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