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