Skip to content

Commit ab3562b

Browse files
feat: Add progress bar to CLI from feast apply (#5867)
* feat: Add progress bar to CLI from feast apply Signed-off-by: Francisco Javier Arceo <farceo@redhat.com> * format nicer Signed-off-by: Francisco Javier Arceo <farceo@redhat.com> * fix Signed-off-by: Francisco Javier Arceo <farceo@redhat.com> * fix Signed-off-by: Francisco Javier Arceo <farceo@redhat.com> * fix Signed-off-by: Francisco Javier Arceo <farceo@redhat.com> --------- Signed-off-by: Francisco Javier Arceo <farceo@redhat.com>
1 parent b997361 commit ab3562b

File tree

5 files changed

+294
-25
lines changed

5 files changed

+294
-25
lines changed

sdk/python/feast/cli/cli.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,9 +271,17 @@ def plan_command(
271271
is_flag=True,
272272
help="Don't validate feature views. Use with caution as this skips important checks.",
273273
)
274+
@click.option(
275+
"--no-progress",
276+
is_flag=True,
277+
help="Disable progress bars during apply operation.",
278+
)
274279
@click.pass_context
275280
def apply_total_command(
276-
ctx: click.Context, skip_source_validation: bool, skip_feature_view_validation: bool
281+
ctx: click.Context,
282+
skip_source_validation: bool,
283+
skip_feature_view_validation: bool,
284+
no_progress: bool,
277285
):
278286
"""
279287
Create or update a feature store deployment
@@ -283,9 +291,19 @@ def apply_total_command(
283291
cli_check_repo(repo, fs_yaml_file)
284292

285293
repo_config = load_repo_config(repo, fs_yaml_file)
294+
295+
# Set environment variable to disable progress if requested
296+
if no_progress:
297+
import os
298+
299+
os.environ["FEAST_NO_PROGRESS"] = "1"
300+
286301
try:
287302
apply_total(
288-
repo_config, repo, skip_source_validation, skip_feature_view_validation
303+
repo_config,
304+
repo,
305+
skip_source_validation,
306+
skip_feature_view_validation,
289307
)
290308
except FeastProviderLoginError as e:
291309
print(str(e))
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
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

sdk/python/feast/diff/infra_diff.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
from dataclasses import dataclass
2-
from typing import Generic, Iterable, List, Optional, Tuple, TypeVar
2+
from typing import TYPE_CHECKING, Generic, Iterable, List, Optional, Tuple, TypeVar
3+
4+
if TYPE_CHECKING:
5+
from feast.diff.apply_progress import ApplyProgressContext
36

47
from feast.diff.property_diff import PropertyDiff, TransitionType
58
from feast.infra.infra_object import (
@@ -33,8 +36,9 @@ class InfraDiff:
3336
def __init__(self):
3437
self.infra_object_diffs = []
3538

36-
def update(self):
39+
def update(self, progress_ctx: Optional["ApplyProgressContext"] = None):
3740
"""Apply the infrastructure changes specified in this object."""
41+
3842
for infra_object_diff in self.infra_object_diffs:
3943
if infra_object_diff.transition_type in [
4044
TransitionType.DELETE,
@@ -43,6 +47,10 @@ def update(self):
4347
infra_object = InfraObject.from_proto(
4448
infra_object_diff.current_infra_object
4549
)
50+
if progress_ctx:
51+
progress_ctx.update_phase_progress(
52+
f"Tearing down {infra_object_diff.name}"
53+
)
4654
infra_object.teardown()
4755
elif infra_object_diff.transition_type in [
4856
TransitionType.CREATE,
@@ -51,6 +59,10 @@ def update(self):
5159
infra_object = InfraObject.from_proto(
5260
infra_object_diff.new_infra_object
5361
)
62+
if progress_ctx:
63+
progress_ctx.update_phase_progress(
64+
f"Creating/updating {infra_object_diff.name}"
65+
)
5466
infra_object.update()
5567

5668
def to_string(self):

sdk/python/feast/feature_store.py

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from datetime import datetime, timedelta
1919
from pathlib import Path
2020
from typing import (
21+
TYPE_CHECKING,
2122
Any,
2223
Callable,
2324
Dict,
@@ -31,6 +32,9 @@
3132
cast,
3233
)
3334

35+
if TYPE_CHECKING:
36+
from feast.diff.apply_progress import ApplyProgressContext
37+
3438
import pandas as pd
3539
import pyarrow as pa
3640
from colorama import Fore, Style
@@ -726,6 +730,7 @@ def plan(
726730
self,
727731
desired_repo_contents: RepoContents,
728732
skip_feature_view_validation: bool = False,
733+
progress_ctx: Optional["ApplyProgressContext"] = None,
729734
) -> Tuple[RegistryDiff, InfraDiff, Infra]:
730735
"""Dry-run registering objects to metadata store.
731736
@@ -793,6 +798,9 @@ def plan(
793798
self._registry, self.project, desired_repo_contents
794799
)
795800

801+
if progress_ctx:
802+
progress_ctx.update_phase_progress("Computing infrastructure diff")
803+
796804
# Compute the desired difference between the current infra, as stored in the registry,
797805
# and the desired infra.
798806
self._registry.refresh(project=self.project)
@@ -807,21 +815,49 @@ def plan(
807815
return registry_diff, infra_diff, new_infra
808816

809817
def _apply_diffs(
810-
self, registry_diff: RegistryDiff, infra_diff: InfraDiff, new_infra: Infra
818+
self,
819+
registry_diff: RegistryDiff,
820+
infra_diff: InfraDiff,
821+
new_infra: Infra,
822+
progress_ctx: Optional["ApplyProgressContext"] = None,
811823
):
812824
"""Applies the given diffs to the metadata store and infrastructure.
813825
814826
Args:
815827
registry_diff: The diff between the current registry and the desired registry.
816828
infra_diff: The diff between the current infra and the desired infra.
817829
new_infra: The desired infra.
830+
progress_ctx: Optional progress context for tracking apply progress.
818831
"""
819-
infra_diff.update()
820-
apply_diff_to_registry(
821-
self._registry, registry_diff, self.project, commit=False
822-
)
832+
try:
833+
# Infrastructure phase
834+
if progress_ctx:
835+
infra_ops_count = len(infra_diff.infra_object_diffs)
836+
progress_ctx.start_phase("Updating infrastructure", infra_ops_count)
837+
838+
infra_diff.update(progress_ctx=progress_ctx)
839+
840+
if progress_ctx:
841+
progress_ctx.complete_phase()
842+
progress_ctx.start_phase("Updating registry", 2)
843+
844+
# Registry phase
845+
apply_diff_to_registry(
846+
self._registry, registry_diff, self.project, commit=False
847+
)
848+
849+
if progress_ctx:
850+
progress_ctx.update_phase_progress("Committing registry changes")
851+
852+
self._registry.update_infra(new_infra, self.project, commit=True)
823853

824-
self._registry.update_infra(new_infra, self.project, commit=True)
854+
if progress_ctx:
855+
progress_ctx.update_phase_progress("Registry update complete")
856+
progress_ctx.complete_phase()
857+
finally:
858+
# Always cleanup progress bars
859+
if progress_ctx:
860+
progress_ctx.cleanup()
825861

826862
def apply(
827863
self,

0 commit comments

Comments
 (0)