feat: Add progress bar to CLI from feast apply (#5867) · feast-dev/feast@ab3562b
1+"""
2+Enhanced progress tracking infrastructure for feast apply operations.
3+4+This module provides the ApplyProgressContext class that manages positioned,
5+color-coded progress bars during apply operations with fixed-width formatting
6+for perfect alignment.
7+"""
8+9+from dataclasses import dataclass
10+from typing import Optional
11+12+from tqdm import tqdm
13+14+try:
15+from feast.diff.progress_utils import (
16+create_positioned_tqdm,
17+get_color_for_phase,
18+is_tty_available,
19+ )
20+21+_PROGRESS_UTILS_AVAILABLE = True
22+except ImportError:
23+# Graceful fallback when progress_utils is not available (e.g., in tests)
24+_PROGRESS_UTILS_AVAILABLE = False
25+26+def create_positioned_tqdm(
27+position: int,
28+description: str,
29+total: int,
30+color: str = "blue",
31+postfix: Optional[str] = None,
32+ ) -> Optional[tqdm]:
33+return None
34+35+def get_color_for_phase(phase: str) -> str:
36+return "blue"
37+38+def is_tty_available() -> bool:
39+return False
40+41+42+@dataclass
43+class ApplyProgressContext:
44+"""
45+ Enhanced context object for tracking progress during feast apply operations.
46+47+ This class manages multiple positioned progress bars with fixed-width formatting:
48+ 1. Overall progress (position 0) - tracks main phases
49+ 2. Phase progress (position 1) - tracks operations within current phase
50+51+ Features:
52+ - Fixed-width alignment for perfect visual consistency
53+ - Color-coded progress bars by phase
54+ - Position coordination to prevent overlap
55+ - TTY detection for CI/CD compatibility
56+ """
57+58+# Core tracking state
59+current_phase: str = ""
60+overall_progress: Optional[tqdm] = None
61+phase_progress: Optional[tqdm] = None
62+63+# Progress tracking
64+total_phases: int = 3
65+completed_phases: int = 0
66+tty_available: bool = True
67+68+# Position allocation
69+OVERALL_POSITION = 0
70+PHASE_POSITION = 1
71+72+def __post_init__(self):
73+"""Initialize TTY detection after dataclass creation."""
74+self.tty_available = _PROGRESS_UTILS_AVAILABLE and is_tty_available()
75+76+def start_overall_progress(self):
77+"""Initialize the overall progress bar for apply phases."""
78+if not self.tty_available:
79+return
80+81+if self.overall_progress is None:
82+try:
83+self.overall_progress = create_positioned_tqdm(
84+position=self.OVERALL_POSITION,
85+description="Applying changes",
86+total=self.total_phases,
87+color=get_color_for_phase("overall"),
88+ )
89+except (TypeError, AttributeError):
90+# Handle case where fallback functions don't work as expected
91+self.overall_progress = None
92+93+def start_phase(self, phase_name: str, operations_count: int = 0):
94+"""
95+ Start tracking a new phase.
96+97+ Args:
98+ phase_name: Human-readable name of the phase
99+ operations_count: Number of operations in this phase (0 for unknown)
100+ """
101+if not self.tty_available:
102+return
103+104+self.current_phase = phase_name
105+106+# Close previous phase progress if exists
107+if self.phase_progress:
108+try:
109+self.phase_progress.close()
110+except (AttributeError, TypeError):
111+pass
112+self.phase_progress = None
113+114+# Create new phase progress bar if operations are known
115+if operations_count > 0:
116+try:
117+self.phase_progress = create_positioned_tqdm(
118+position=self.PHASE_POSITION,
119+description=phase_name,
120+total=operations_count,
121+color=get_color_for_phase(phase_name.lower()),
122+ )
123+except (TypeError, AttributeError):
124+# Handle case where fallback functions don't work as expected
125+self.phase_progress = None
126+127+def update_phase_progress(self, description: Optional[str] = None):
128+"""
129+ Update progress within the current phase.
130+131+ Args:
132+ description: Optional description of current operation
133+ """
134+if not self.tty_available or not self.phase_progress:
135+return
136+137+try:
138+if description:
139+# Update postfix with current operation
140+self.phase_progress.set_postfix_str(description)
141+142+self.phase_progress.update(1)
143+except (AttributeError, TypeError):
144+# Handle case where phase_progress is None or fallback function returned None
145+pass
146+147+def complete_phase(self):
148+"""Mark current phase as complete and advance overall progress."""
149+if not self.tty_available:
150+return
151+152+# Close phase progress
153+if self.phase_progress:
154+try:
155+self.phase_progress.close()
156+except (AttributeError, TypeError):
157+pass
158+self.phase_progress = None
159+160+# Update overall progress
161+if self.overall_progress:
162+try:
163+self.overall_progress.update(1)
164+# Update postfix with phase completion
165+phase_text = f"({self.completed_phases + 1}/{self.total_phases} phases)"
166+self.overall_progress.set_postfix_str(phase_text)
167+except (AttributeError, TypeError):
168+pass
169+170+self.completed_phases += 1
171+172+def cleanup(self):
173+"""Clean up all progress bars. Should be called in finally blocks."""
174+if self.phase_progress:
175+try:
176+self.phase_progress.close()
177+except (AttributeError, TypeError):
178+pass
179+self.phase_progress = None
180+if self.overall_progress:
181+try:
182+self.overall_progress.close()
183+except (AttributeError, TypeError):
184+pass
185+self.overall_progress = None