relationalai 0.11.4__py3-none-any.whl → 0.12.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. relationalai/clients/config.py +7 -0
  2. relationalai/clients/direct_access_client.py +113 -0
  3. relationalai/clients/snowflake.py +263 -189
  4. relationalai/clients/types.py +4 -1
  5. relationalai/clients/use_index_poller.py +72 -48
  6. relationalai/clients/util.py +9 -0
  7. relationalai/dsl.py +1 -2
  8. relationalai/early_access/metamodel/rewrite/__init__.py +5 -3
  9. relationalai/early_access/rel/rewrite/__init__.py +1 -1
  10. relationalai/environments/snowbook.py +10 -1
  11. relationalai/errors.py +24 -3
  12. relationalai/semantics/internal/annotations.py +1 -0
  13. relationalai/semantics/internal/internal.py +22 -3
  14. relationalai/semantics/lqp/builtins.py +1 -0
  15. relationalai/semantics/lqp/executor.py +12 -4
  16. relationalai/semantics/lqp/model2lqp.py +1 -0
  17. relationalai/semantics/lqp/passes.py +3 -4
  18. relationalai/semantics/{rel → lqp}/rewrite/__init__.py +6 -0
  19. relationalai/semantics/metamodel/builtins.py +12 -1
  20. relationalai/semantics/metamodel/executor.py +2 -1
  21. relationalai/semantics/metamodel/rewrite/__init__.py +3 -9
  22. relationalai/semantics/metamodel/rewrite/flatten.py +8 -7
  23. relationalai/semantics/reasoners/graph/core.py +1356 -258
  24. relationalai/semantics/rel/builtins.py +5 -1
  25. relationalai/semantics/rel/compiler.py +3 -3
  26. relationalai/semantics/rel/executor.py +20 -11
  27. relationalai/semantics/sql/compiler.py +2 -3
  28. relationalai/semantics/sql/executor/duck_db.py +8 -4
  29. relationalai/semantics/sql/executor/snowflake.py +1 -1
  30. relationalai/tools/cli.py +17 -6
  31. relationalai/tools/cli_controls.py +334 -352
  32. relationalai/tools/constants.py +1 -0
  33. relationalai/tools/query_utils.py +27 -0
  34. relationalai/util/otel_configuration.py +1 -1
  35. {relationalai-0.11.4.dist-info → relationalai-0.12.1.dist-info}/METADATA +5 -4
  36. {relationalai-0.11.4.dist-info → relationalai-0.12.1.dist-info}/RECORD +45 -45
  37. relationalai/semantics/metamodel/rewrite/gc_nodes.py +0 -58
  38. relationalai/semantics/metamodel/rewrite/list_types.py +0 -109
  39. /relationalai/semantics/{rel → lqp}/rewrite/cdc.py +0 -0
  40. /relationalai/semantics/{rel → lqp}/rewrite/extract_common.py +0 -0
  41. /relationalai/semantics/{metamodel → lqp}/rewrite/extract_keys.py +0 -0
  42. /relationalai/semantics/{metamodel → lqp}/rewrite/fd_constraints.py +0 -0
  43. /relationalai/semantics/{rel → lqp}/rewrite/quantify_vars.py +0 -0
  44. /relationalai/semantics/{metamodel → lqp}/rewrite/splinter.py +0 -0
  45. {relationalai-0.11.4.dist-info → relationalai-0.12.1.dist-info}/WHEEL +0 -0
  46. {relationalai-0.11.4.dist-info → relationalai-0.12.1.dist-info}/entry_points.txt +0 -0
  47. {relationalai-0.11.4.dist-info → relationalai-0.12.1.dist-info}/licenses/LICENSE +0 -0
@@ -9,6 +9,7 @@ import shutil
9
9
  import sys
10
10
  import threading
11
11
  import time
12
+ import importlib
12
13
  from dataclasses import dataclass
13
14
  from pathlib import Path
14
15
  from typing import Any, Callable, Dict, List, Sequence, TextIO, cast
@@ -116,6 +117,19 @@ def rich_str(string:str, style:str|None = None) -> str:
116
117
  console.print(string, style=style)
117
118
  return output.getvalue()
118
119
 
120
+ def _load_ipython_display() -> tuple[Any, Callable[..., Any]]:
121
+ """Load IPython display helpers, raising if unavailable."""
122
+ try:
123
+ module = importlib.import_module("IPython.display")
124
+ except ImportError as exc: # pragma: no cover - only triggered without IPython
125
+ raise RuntimeError(
126
+ "NotebookTaskProgress requires IPython when running in a notebook environment."
127
+ ) from exc
128
+
129
+ html_factory = getattr(module, "HTML")
130
+ display_fn = getattr(module, "display")
131
+ return html_factory, cast(Callable[..., Any], display_fn)
132
+
119
133
  def nat_path(path: Path, base: Path):
120
134
  resolved_path = path.resolve()
121
135
  resolved_base = base.resolve()
@@ -763,34 +777,22 @@ class _TimerManager:
763
777
 
764
778
  def _process_operation(self, task_id: str, op_type: str):
765
779
  """Process a completed delayed operation."""
780
+ progress = self._progress
766
781
  if op_type == "remove_highlighting":
767
- if hasattr(self._progress, '_highlighted_tasks') and task_id in self._progress._highlighted_tasks:
768
- del self._progress._highlighted_tasks[task_id]
769
- # For TaskProgress, invalidate cache and update display
770
- if hasattr(self._progress, '_invalidate_cache'):
771
- self._progress._invalidate_cache()
772
- self._progress._update_display()
773
- elif hasattr(self._progress, 'highlighted_tasks') and task_id in self._progress.highlighted_tasks:
774
- del self._progress.highlighted_tasks[task_id]
775
- # For NotebookTaskProgress, no special update needed
782
+ if hasattr(progress, "_highlighted_tasks") and task_id in progress._highlighted_tasks:
783
+ del progress._highlighted_tasks[task_id]
784
+ if hasattr(progress, "_after_task_update"):
785
+ progress._after_task_update()
776
786
  elif op_type == "delayed_removal":
777
- if hasattr(self._progress, '_tasks') and task_id in self._progress._tasks:
778
- del self._progress._tasks[task_id]
779
- # For TaskProgress, invalidate cache and update display
780
- if hasattr(self._progress, '_invalidate_cache'):
781
- self._progress._invalidate_cache()
782
- self._progress._update_display()
783
- elif hasattr(self._progress, 'sub_tasks') and task_id in self._progress.sub_tasks:
784
- del self._progress.sub_tasks[task_id]
785
- # For NotebookTaskProgress, no special update needed
787
+ if hasattr(progress, "_tasks") and task_id in progress._tasks:
788
+ del progress._tasks[task_id]
789
+ if hasattr(progress, "_after_task_update"):
790
+ progress._after_task_update()
786
791
  elif op_type == "delayed_hiding":
787
- if hasattr(self._progress, '_tasks') and task_id in self._progress._tasks:
788
- # Mark task as hidden but keep it in the data structure
789
- self._progress._tasks[task_id].hidden = True
790
- # For TaskProgress, invalidate cache and update display
791
- if hasattr(self._progress, '_invalidate_cache'):
792
- self._progress._invalidate_cache()
793
- self._progress._update_display()
792
+ if hasattr(progress, "_tasks") and task_id in progress._tasks:
793
+ progress._tasks[task_id].hidden = True
794
+ if hasattr(progress, "_after_task_update"):
795
+ progress._after_task_update()
794
796
 
795
797
  def stop(self):
796
798
  """Stop the timer manager."""
@@ -798,7 +800,176 @@ class _TimerManager:
798
800
  self._operations.clear()
799
801
 
800
802
 
801
- class TaskProgress:
803
+ class _TaskStateMixin:
804
+ """Shared task management helpers for notebook and terminal progress displays."""
805
+
806
+ enable_highlighting: bool = True
807
+
808
+ def _init_task_state(self, *, hide_on_completion: bool = False, show_duration_summary: bool = True) -> None:
809
+ self.hide_on_completion = hide_on_completion
810
+ self.show_duration_summary = show_duration_summary
811
+ self._tasks: dict[str, TaskInfo] = {}
812
+ self._next_task_id: int = 1
813
+ self._highlighted_tasks: dict[str, float] = {}
814
+ self._process_start_time: float | None = None
815
+ self._process_end_time: float | None = None
816
+ self.main_completed: bool = False
817
+ self.main_failed: bool = False
818
+ self._timer_manager = _TimerManager(self)
819
+
820
+ def _after_task_update(self) -> None:
821
+ """Hook for subclasses to react when task state changes."""
822
+ # Implemented by subclasses when they need to update the display immediately.
823
+ return None
824
+
825
+ def _generate_task_id(self) -> str:
826
+ """Generate a unique task ID."""
827
+ task_id = f"task_{self._next_task_id}"
828
+ self._next_task_id += 1
829
+ return task_id
830
+
831
+ def add_sub_task(self, description: str, task_id: str | None = None, category: str = "general") -> str:
832
+ """Add a new sub-task and return its unique ID."""
833
+ if task_id is None:
834
+ task_id = self._generate_task_id()
835
+
836
+ if task_id not in self._tasks:
837
+ self._tasks[task_id] = TaskInfo(description=description, category=category)
838
+ self._after_task_update()
839
+
840
+ return task_id
841
+
842
+ def update_sub_task(self, task_id: str, description: str) -> None:
843
+ """Update an existing sub-task description."""
844
+ if task_id in self._tasks:
845
+ task_info = self._tasks[task_id]
846
+ if self.enable_highlighting and task_info.description != description:
847
+ self._highlighted_tasks[task_id] = time.time() + HIGHLIGHT_DURATION
848
+ self._timer_manager.schedule_highlight_removal(task_id)
849
+
850
+ task_info.description = description
851
+ self._after_task_update()
852
+
853
+ def complete_sub_task(self, task_id: str, record_time: bool = True) -> None:
854
+ """Complete a sub-task by marking it as done."""
855
+ if task_id in self._tasks:
856
+ if task_id in self._highlighted_tasks:
857
+ del self._highlighted_tasks[task_id]
858
+
859
+ if not self._tasks[task_id].completed and record_time:
860
+ self._tasks[task_id].completed_time = time.time()
861
+ self._tasks[task_id].completed = True
862
+
863
+ self._after_task_update()
864
+ self._timer_manager.schedule_task_hiding(task_id)
865
+
866
+ def remove_sub_task(self, task_id: str, animate: bool = True) -> None:
867
+ """Remove a sub-task by ID with optional completion animation."""
868
+ if task_id in self._tasks:
869
+ if task_id in self._highlighted_tasks:
870
+ del self._highlighted_tasks[task_id]
871
+
872
+ if animate:
873
+ self.complete_sub_task(task_id)
874
+ else:
875
+ del self._tasks[task_id]
876
+ self._after_task_update()
877
+
878
+ def update_sub_status(self, sub_status: str) -> None:
879
+ """Legacy method for backward compatibility - creates/updates a default sub-task."""
880
+ self.add_sub_task(sub_status, "default")
881
+ self.update_sub_task("default", sub_status)
882
+
883
+ def update_main_status(self, message: str) -> None:
884
+ """Update the main status line with custom information."""
885
+ if getattr(self, "description", "") != message:
886
+ self.description = message
887
+ self._after_task_update()
888
+
889
+ def update_messages(self, updater: dict[str, str]) -> None:
890
+ """Update both main message and sub-status if provided."""
891
+ if "message" in updater:
892
+ self.description = updater["message"]
893
+ self._after_task_update()
894
+ if "sub_status" in updater:
895
+ self.update_sub_status(updater["sub_status"])
896
+ if "success_message" in updater:
897
+ self.success_message = updater["success_message"]
898
+ if "failure_message" in updater:
899
+ self.failure_message = updater["failure_message"]
900
+
901
+ def get_sub_task_count(self) -> int:
902
+ """Get the current number of active sub-tasks."""
903
+ return len(self._tasks)
904
+
905
+ def list_sub_tasks(self) -> list[str]:
906
+ """Get a list of all active sub-task IDs."""
907
+ return list(self._tasks.keys())
908
+
909
+ def get_task_status(self) -> str:
910
+ """Get a human-readable status of current task count vs limit."""
911
+ return f"› Active tasks: {len(self._tasks)}"
912
+
913
+ def get_task_duration(self, task_id: str) -> float:
914
+ """Get the duration of a specific task in seconds."""
915
+ if task_id in self._tasks:
916
+ return self._tasks[task_id].get_duration()
917
+ return 0.0
918
+
919
+ def get_completed_tasks(self) -> dict[str, TaskInfo]:
920
+ """Get all completed tasks with their timing information."""
921
+ return {task_id: task_info for task_id, task_info in self._tasks.items() if task_info.completed}
922
+
923
+ def get_tasks_by_category(self, category: str) -> dict[str, TaskInfo]:
924
+ """Get all tasks (completed or active) for a specific category."""
925
+ return {
926
+ task_id: task_info
927
+ for task_id, task_info in self._tasks.items()
928
+ if task_info.category == category
929
+ }
930
+
931
+ def get_completed_tasks_by_category(self, category: str) -> dict[str, TaskInfo]:
932
+ """Get all completed tasks for a specific category."""
933
+ return {
934
+ task_id: task_info
935
+ for task_id, task_info in self._tasks.items()
936
+ if task_info.category == category and task_info.completed
937
+ }
938
+
939
+ def _clear_all_tasks(self) -> None:
940
+ """Clear all tasks and related data."""
941
+ self._tasks.clear()
942
+ self._highlighted_tasks.clear()
943
+
944
+ def set_process_start_time(self) -> None:
945
+ """Set the overall process start time."""
946
+ self._process_start_time = time.time()
947
+
948
+ def set_process_end_time(self) -> None:
949
+ """Set the overall process end time."""
950
+ self._process_end_time = time.time()
951
+
952
+ def get_total_duration(self) -> float:
953
+ """Get the total duration from first task added to last task completed."""
954
+ if not self._tasks:
955
+ return 0.0
956
+
957
+ completed_tasks = self.get_completed_tasks()
958
+ if not completed_tasks:
959
+ return 0.0
960
+
961
+ start_times = [task.added_time for task in self._tasks.values()]
962
+ completion_times = [task.completed_time for task in completed_tasks.values() if task.completed_time > 0]
963
+
964
+ if not start_times or not completion_times:
965
+ return 0.0
966
+
967
+ earliest_start = min(start_times)
968
+ latest_completion = max(completion_times)
969
+ return latest_completion - earliest_start
970
+
971
+
972
+ class TaskProgress(_TaskStateMixin):
802
973
  """A progress component that uses Rich's Live system to provide proper two-line display.
803
974
 
804
975
  This class provides:
@@ -828,40 +999,36 @@ class TaskProgress:
828
999
  self.leading_newline = leading_newline
829
1000
  self.trailing_newline = trailing_newline
830
1001
  self.transient = transient
831
- self.hide_on_completion = hide_on_completion
832
- self.show_duration_summary = show_duration_summary
1002
+ self._init_task_state(
1003
+ hide_on_completion=hide_on_completion,
1004
+ show_duration_summary=show_duration_summary,
1005
+ )
1006
+ self.enable_highlighting = True
833
1007
 
834
1008
  # Detect CI environment to avoid cursor control issues
835
1009
  from ..environments import CIEnvironment
836
1010
  self.is_ci = isinstance(runtime_env, CIEnvironment)
1011
+ self.is_jupyter = isinstance(runtime_env, JupyterEnvironment)
837
1012
 
838
1013
  # Core components
839
- # In CI, don't force terminal to avoid ANSI escape sequences that cause multiple lines
840
- self.console = Console(force_terminal=not self.is_ci)
1014
+ # In CI or Jupyter, avoid forcing terminal rendering to prevent duplicate outputs
1015
+ force_terminal = not self.is_ci and not self.is_jupyter
1016
+ force_jupyter = True if self.is_jupyter else None
1017
+ self.console = Console(
1018
+ force_terminal=force_terminal,
1019
+ force_jupyter=force_jupyter,
1020
+ )
841
1021
  self.live = None
842
1022
  self.main_completed = False
843
1023
  self.main_failed = False
844
1024
 
845
- # Task management - unified data structure
846
- self._tasks = {} # task_id -> TaskInfo
847
- self._next_task_id = 1
848
-
849
- # Overall process timing
850
- self._process_start_time = None
851
- self._process_end_time = None
852
-
853
1025
  # Animation state
854
1026
  self.spinner_index = 0
855
1027
 
856
- # Highlighting system
857
- self._highlighted_tasks = {} # task_id -> highlight_until_time
858
-
859
1028
  # Performance optimizations
860
1029
  self._render_cache = None
861
1030
  self._last_state_hash = None
862
1031
 
863
- # Threading
864
- self._timer_manager = _TimerManager(self)
865
1032
  self._spinner_thread = None
866
1033
 
867
1034
  def _generate_task_id(self) -> str:
@@ -979,192 +1146,31 @@ class TaskProgress:
979
1146
  if self.live:
980
1147
  self.live.update(self._render_display())
981
1148
 
982
- def add_sub_task(self, description: str, task_id: str | None = None, category: str = "general") -> str:
983
- """Add a new sub-task and return its unique ID.
984
-
985
- Args:
986
- description: Description of the subtask
987
- task_id: Optional custom task ID, if not provided one will be generated
988
- category: Category for this task (e.g., "indexing", "provisioning", "change_tracking")
989
-
990
- Returns:
991
- str: The task ID for this subtask
992
- """
993
- if task_id is None:
994
- task_id = self._generate_task_id()
995
-
996
- if task_id not in self._tasks:
997
- self._tasks[task_id] = TaskInfo(description=description, category=category)
998
- self._invalidate_cache()
999
- self._update_display()
1000
-
1001
- return task_id
1002
-
1003
- def update_sub_task(self, task_id: str, description: str) -> None:
1004
- """Update an existing sub-task description.
1005
-
1006
- When the description text changes from the previous value, the subtask
1007
- will be highlighted in yellow for 2 seconds to make the change visible.
1008
- """
1009
- if task_id in self._tasks:
1010
- task_info = self._tasks[task_id]
1011
-
1012
- # Check if text has changed
1013
- if task_info.description != description:
1014
- # Text has changed - set up highlighting
1015
- self._highlighted_tasks[task_id] = time.time() + HIGHLIGHT_DURATION
1016
- self._timer_manager.schedule_highlight_removal(task_id)
1149
+ def _after_task_update(self) -> None:
1150
+ """Refresh the live display when task state changes."""
1151
+ self._invalidate_cache()
1152
+ self._update_display()
1017
1153
 
1018
- task_info.description = description
1019
- self._invalidate_cache()
1020
- self._update_display()
1021
-
1022
- def complete_sub_task(self, task_id: str, record_time: bool = True) -> None:
1023
- """Complete a sub-task by marking it as done."""
1024
- if task_id in self._tasks:
1025
- # Remove any highlighting when completing
1026
- if task_id in self._highlighted_tasks:
1027
- del self._highlighted_tasks[task_id]
1028
-
1029
- # Record completion time (only if not already completed and record_time is True)
1030
- if not self._tasks[task_id].completed and record_time:
1031
- self._tasks[task_id].completed_time = time.time()
1032
- self._tasks[task_id].completed = True
1033
-
1034
- self._invalidate_cache()
1035
- self._update_display()
1036
-
1037
- # Schedule hiding the task from display after a short delay
1038
- # but keep it in the data structure for summary generation
1039
- self._timer_manager.schedule_task_hiding(task_id)
1040
-
1041
- def remove_sub_task(self, task_id: str, animate: bool = True) -> None:
1042
- """Remove a sub-task by ID with optional completion animation."""
1043
- if task_id in self._tasks:
1044
- # Remove any highlighting when removing
1045
- if task_id in self._highlighted_tasks:
1046
- del self._highlighted_tasks[task_id]
1047
-
1048
- if animate:
1049
- self.complete_sub_task(task_id)
1050
- else:
1051
- del self._tasks[task_id]
1052
- self._invalidate_cache()
1053
- self._update_display()
1054
-
1055
- def update_sub_status(self, sub_status: str):
1056
- """Legacy method for backward compatibility - creates/updates a default sub-task."""
1057
- self.add_sub_task(sub_status, "default")
1058
- self.update_sub_task("default", sub_status)
1059
-
1060
- def update_main_status(self, message: str):
1061
- """Update the main status line with custom information."""
1062
- if self.description != message: # Only update if changed
1063
- self.description = message
1064
- self._invalidate_cache()
1065
- self._update_display()
1066
-
1067
- def update_messages(self, updater: dict[str, str]):
1068
- """Update both main message and sub-status if provided."""
1069
- if "message" in updater:
1070
- self.description = updater["message"]
1071
- self._invalidate_cache()
1072
- self._update_display()
1073
- if "sub_status" in updater:
1074
- self.update_sub_status(updater["sub_status"])
1075
- if "success_message" in updater:
1076
- self.success_message = updater["success_message"]
1077
- if "failure_message" in updater:
1078
- self.failure_message = updater["failure_message"]
1079
-
1080
- def get_sub_task_count(self) -> int:
1081
- """Get the current number of active sub-tasks."""
1082
- return len(self._tasks)
1083
-
1084
- def list_sub_tasks(self) -> list[str]:
1085
- """Get a list of all active sub-task IDs."""
1086
- return list(self._tasks.keys())
1087
-
1088
- def get_task_status(self) -> str:
1089
- """Get a human-readable status of current task count vs limit."""
1090
- current_count = len(self._tasks)
1091
- return f"› Active tasks: {current_count}"
1092
-
1093
- def get_task_duration(self, task_id: str) -> float:
1094
- """Get the duration of a specific task in seconds."""
1095
- if task_id in self._tasks:
1096
- return self._tasks[task_id].get_duration()
1097
- return 0.0
1098
-
1099
-
1100
- def get_completed_tasks(self) -> dict[str, TaskInfo]:
1101
- """Get all completed tasks with their timing information."""
1102
- return {task_id: task_info for task_id, task_info in self._tasks.items() if task_info.completed}
1103
-
1104
- def get_tasks_by_category(self, category: str) -> dict[str, TaskInfo]:
1105
- """Get all tasks (completed and active) for a specific category."""
1106
- return {task_id: task_info for task_id, task_info in self._tasks.items() if task_info.category == category}
1107
-
1108
- def get_completed_tasks_by_category(self, category: str) -> dict[str, TaskInfo]:
1109
- """Get all completed tasks for a specific category."""
1110
- return {task_id: task_info for task_id, task_info in self._tasks.items()
1111
- if task_info.category == category and task_info.completed}
1112
-
1113
- def set_process_start_time(self) -> None:
1114
- """Set the overall process start time."""
1115
- self._process_start_time = time.time()
1116
-
1117
- def set_process_end_time(self) -> None:
1118
- """Set the overall process end time."""
1119
- self._process_end_time = time.time()
1120
-
1121
- def get_total_duration(self) -> float:
1122
- """Get the total duration from first task added to last task completed."""
1123
- if not self._tasks:
1124
- return 0.0
1125
-
1126
- completed_tasks = self.get_completed_tasks()
1127
- if not completed_tasks:
1128
- return 0.0
1129
-
1130
- # Find earliest start time and latest completion time
1131
- start_times = [task.added_time for task in self._tasks.values()]
1132
- completion_times = [task.completed_time for task in completed_tasks.values() if task.completed_time > 0]
1133
-
1134
- if not start_times or not completion_times:
1135
- return 0.0
1136
-
1137
- earliest_start = min(start_times)
1138
- latest_completion = max(completion_times)
1139
-
1140
- return latest_completion - earliest_start
1154
+ def _clear_all_tasks(self) -> None:
1155
+ """Clear tasks and refresh display."""
1156
+ super()._clear_all_tasks()
1157
+ self._after_task_update()
1141
1158
 
1142
1159
  def generate_summary(self, categories: dict[str, str] | None = None) -> str:
1143
- """Generate a summary of completed tasks by category.
1144
-
1145
- Args:
1146
- categories: Optional dict mapping category names to display names.
1147
- Defaults to standard UseIndexPoller categories.
1148
-
1149
- Returns:
1150
- Formatted summary string, or empty string if no meaningful tasks.
1151
- """
1160
+ """Generate a summary of completed tasks by category."""
1152
1161
  if categories is None:
1153
1162
  categories = DEFAULT_SUMMARY_CATEGORIES
1154
1163
 
1155
- # Get completed tasks by category and calculate durations
1156
- category_durations = {}
1164
+ category_durations: dict[str, float] = {}
1157
1165
  for category_name in categories:
1158
1166
  tasks = self.get_completed_tasks_by_category(category_name)
1159
1167
  category_durations[category_name] = _calculate_category_duration(category_name, tasks)
1160
1168
 
1161
- # If there's nothing meaningful to show, return empty string
1162
1169
  if not any(category_durations.values()):
1163
1170
  return ""
1164
1171
 
1165
1172
  total_duration = self.get_total_duration()
1166
1173
 
1167
- # Build Rich table directly from data (not from formatted strings)
1168
1174
  try:
1169
1175
  from rich.console import Console
1170
1176
  from rich.table import Table
@@ -1173,14 +1179,12 @@ class TaskProgress:
1173
1179
  table.add_column("Operation", style="white")
1174
1180
  table.add_column("Duration", style="green", justify="right")
1175
1181
 
1176
- # Add total duration row
1177
1182
  if total_duration > 0:
1178
1183
  table.add_row(
1179
1184
  INITIALIZATION_COMPLETED_TEXT,
1180
1185
  format_duration(total_duration)
1181
1186
  )
1182
1187
 
1183
- # Add category rows
1184
1188
  for category_name, display_name in categories.items():
1185
1189
  duration = category_durations[category_name]
1186
1190
  if duration > MIN_CATEGORY_DURATION_SECONDS:
@@ -1189,7 +1193,6 @@ class TaskProgress:
1189
1193
  format_duration(duration)
1190
1194
  )
1191
1195
 
1192
- # Add blank row for spacing
1193
1196
  table.add_row("", "")
1194
1197
 
1195
1198
  console = Console()
@@ -1198,8 +1201,7 @@ class TaskProgress:
1198
1201
  return capture.get()
1199
1202
 
1200
1203
  except ImportError:
1201
- # Fallback to simple text format
1202
- lines = []
1204
+ lines: list[str] = []
1203
1205
  if total_duration > 0:
1204
1206
  lines.append(f"{INITIALIZATION_COMPLETED_TEXT} {format_duration(total_duration)}")
1205
1207
 
@@ -1305,11 +1307,6 @@ class TaskProgress:
1305
1307
  print()
1306
1308
  self._cleanup()
1307
1309
 
1308
- def _clear_all_tasks(self):
1309
- """Clear all tasks and related data."""
1310
- self._tasks.clear()
1311
- self._highlighted_tasks.clear()
1312
-
1313
1310
  def _cleanup(self):
1314
1311
  """Clean up resources."""
1315
1312
  if self.live:
@@ -1347,9 +1344,9 @@ leading_newline: bool = False, trailing_newline: bool = False, show_duration_sum
1347
1344
  Automatically detects if we're in a notebook environment (Snowflake, Jupyter, etc.)
1348
1345
  and returns the appropriate progress class.
1349
1346
  """
1350
- from ..environments import runtime_env, SnowbookEnvironment, JupyterEnvironment
1347
+ from ..environments import runtime_env, SnowbookEnvironment, NotebookRuntimeEnvironment
1351
1348
 
1352
- if isinstance(runtime_env, (SnowbookEnvironment, JupyterEnvironment)):
1349
+ if isinstance(runtime_env, (SnowbookEnvironment, NotebookRuntimeEnvironment)):
1353
1350
  # Use NotebookTaskProgress for Snowflake and Jupyter notebooks
1354
1351
  return NotebookTaskProgress(
1355
1352
  description=description,
@@ -1393,7 +1390,7 @@ class SubTaskContext:
1393
1390
  return False # Don't suppress exceptions
1394
1391
 
1395
1392
 
1396
- class NotebookTaskProgress:
1393
+ class NotebookTaskProgress(_TaskStateMixin):
1397
1394
  """A progress component specifically designed for notebook environments like Snowflake.
1398
1395
 
1399
1396
  This class copies the EXACT working Spinner code and adapts it for notebook use.
@@ -1413,15 +1410,8 @@ class NotebookTaskProgress:
1413
1410
  self.failure_message = failure_message
1414
1411
  self.leading_newline = leading_newline
1415
1412
  self.trailing_newline = trailing_newline
1416
- self.show_duration_summary = show_duration_summary
1417
-
1418
- # Task management - unified data structure
1419
- self._tasks = {} # task_id -> TaskInfo
1420
- self._next_task_id = 1
1421
-
1422
- # Overall process timing
1423
- self._process_start_time = None
1424
- self._process_end_time = None
1413
+ self._init_task_state(show_duration_summary=show_duration_summary)
1414
+ self.enable_highlighting = False
1425
1415
 
1426
1416
  self.spinner_generator = itertools.cycle(SPINNER_FRAMES)
1427
1417
 
@@ -1438,15 +1428,10 @@ class NotebookTaskProgress:
1438
1428
  self._update_lock = threading.Lock()
1439
1429
 
1440
1430
  # Add sub-task support for TaskProgress compatibility
1441
- # Note: _tasks and _next_task_id already initialized above (lines 1393-1394)
1442
- self.main_completed = False
1443
1431
  self.spinner_thread = None
1444
1432
  self._current_subtask = ""
1445
1433
  self.busy = False # Initialize busy state
1446
1434
 
1447
- # Timer manager for delayed operations
1448
- self._timer_manager = _TimerManager(self)
1449
-
1450
1435
 
1451
1436
  def _generate_task_id(self) -> str:
1452
1437
  """Generate a unique task ID."""
@@ -1500,31 +1485,78 @@ class NotebookTaskProgress:
1500
1485
  """Update the display - notebook environments only."""
1501
1486
  # Use lock to prevent race conditions between spinner thread and main thread
1502
1487
  with self._update_lock:
1503
- if message is None:
1504
- message = self.get_message(starting=starting)
1505
1488
  if self.is_jupyter:
1506
- # @NOTE: IPython isn't available in CI. This won't ever get invoked w/out IPython available though.
1507
- from IPython.display import HTML, display # pyright: ignore[reportMissingImports]
1508
- content = HTML(f"<span style='font-family: monospace;'>{message}</span>")
1489
+ _, display_fn = _load_ipython_display()
1490
+
1491
+ if message is None:
1492
+ lines = self._build_jupyter_lines(starting=starting)
1493
+ elif message == "":
1494
+ lines = []
1495
+ else:
1496
+ lines = [message]
1497
+
1498
+ rendered = "\n".join(lines)
1499
+ content = {"text/plain": rendered}
1509
1500
  if self.display is not None:
1510
- self.display.update(content)
1501
+ self.display.update(content, raw=True)
1511
1502
  else:
1512
- self.display = display(content, display_id=True)
1513
- else:
1514
- # Use the EXACT same approach as the working Spinner code
1515
- rich_string = rich_str(message)
1516
- def width(word):
1517
- return sum(wcwidth(c) for c in word)
1518
- diff = width(self.last_message) - width(rich_string)
1503
+ self.display = display_fn(content, display_id=True, raw=True)
1504
+ return
1505
+
1506
+ if message is None:
1507
+ message = self.get_message(starting=starting)
1519
1508
 
1520
- sys.stdout.write("\r") # Move to beginning
1521
- sys.stdout.write(" " * DEFAULT_TERMINAL_WIDTH) # Clear with spaces
1522
- sys.stdout.write("\r") # Move back to beginning
1509
+ rich_string = rich_str(message)
1523
1510
 
1524
- sys.stdout.write(message + (" " * diff)) # Write text directly
1525
- if self.in_notebook:
1526
- sys.stdout.flush() # Force output
1527
- self.last_message = rich_string
1511
+ def width(word):
1512
+ return sum(wcwidth(c) for c in word)
1513
+
1514
+ diff = width(self.last_message) - width(rich_string)
1515
+
1516
+ sys.stdout.write("\r") # Move to beginning
1517
+ sys.stdout.write(" " * DEFAULT_TERMINAL_WIDTH) # Clear with spaces
1518
+ sys.stdout.write("\r") # Move back to beginning
1519
+
1520
+ sys.stdout.write(message + (" " * diff)) # Write text directly
1521
+ if self.in_notebook:
1522
+ sys.stdout.flush() # Force output
1523
+ self.last_message = rich_string
1524
+
1525
+ def _build_jupyter_lines(self, starting: bool) -> list[str]:
1526
+ """Compose the main status and subtasks for Jupyter display."""
1527
+ if self.busy or starting:
1528
+ spinner = SPINNER_FRAMES[0] if starting else next(self.spinner_generator)
1529
+ main_line = f"{spinner} {self.description}"
1530
+ else:
1531
+ main_text = self.success_message or self.description
1532
+ main_line = f"{SUCCESS_ICON} {main_text}"
1533
+
1534
+ visible_tasks = self._collect_visible_tasks()
1535
+ lines = [main_line]
1536
+
1537
+ for marker, tasks in (
1538
+ (ARROW, visible_tasks["incomplete"]),
1539
+ (CHECK_MARK, visible_tasks["completed"]),
1540
+ ):
1541
+ for task_info in tasks:
1542
+ lines.append(f" {marker} {task_info.description}")
1543
+
1544
+ return lines
1545
+
1546
+ def _collect_visible_tasks(self) -> dict[str, list["TaskInfo"]]:
1547
+ """Separate visible tasks into incomplete and completed lists."""
1548
+ incomplete: list["TaskInfo"] = []
1549
+ completed: list["TaskInfo"] = []
1550
+
1551
+ for task_info in self._tasks.values():
1552
+ if task_info.hidden:
1553
+ continue
1554
+ if task_info.completed:
1555
+ completed.append(task_info)
1556
+ else:
1557
+ incomplete.append(task_info)
1558
+
1559
+ return {"incomplete": incomplete, "completed": completed}
1528
1560
 
1529
1561
  def reset_cursor(self):
1530
1562
  """Reset cursor to beginning of line - notebook environments only."""
@@ -1553,114 +1585,54 @@ class NotebookTaskProgress:
1553
1585
  # The spinner will now show the subtask instead of main task
1554
1586
 
1555
1587
  def add_sub_task(self, description: str, task_id: str | None = None, category: str = "general") -> str:
1556
- """Add a new sub-task and return its unique ID.
1557
-
1558
- Args:
1559
- description: Description of the subtask
1560
- task_id: Optional custom task ID, if not provided one will be generated
1561
- category: Category for this task (e.g., "indexing", "provisioning", "change_tracking")
1562
-
1563
- Returns:
1564
- str: The task ID for this subtask
1565
- """
1566
- if task_id is None:
1567
- task_id = self._generate_task_id()
1568
-
1569
- if task_id not in self._tasks:
1570
- self._tasks[task_id] = TaskInfo(description=description, category=category)
1571
-
1572
- # Show the subtask by updating the main task text
1573
- self._update_subtask_display(description)
1574
-
1588
+ task_id = super().add_sub_task(description, task_id, category)
1589
+ # Update spinner display with the active subtask
1590
+ if task_id in self._tasks:
1591
+ self._update_subtask_display(self._tasks[task_id].description)
1575
1592
  return task_id
1576
1593
 
1577
1594
  def update_sub_task(self, task_id: str, description: str) -> None:
1578
- """Update an existing sub-task description."""
1595
+ super().update_sub_task(task_id, description)
1579
1596
  if task_id in self._tasks:
1580
- self._tasks[task_id].description = description
1581
- # Show the updated subtask by updating the main task text
1582
1597
  self._update_subtask_display(description)
1583
1598
 
1584
1599
  def complete_sub_task(self, task_id: str, record_time: bool = True) -> None:
1585
- """Complete a sub-task by marking it as done."""
1586
- if task_id in self._tasks:
1587
- # Record completion time (only if not already completed and record_time is True)
1588
- if not self._tasks[task_id].completed and record_time:
1589
- self._tasks[task_id].completed_time = time.time()
1590
- self._tasks[task_id].completed = True
1591
-
1592
- # Clear the subtask display when completed
1593
- self._current_subtask = ""
1594
- self._current_display = ""
1595
- # The spinner will now show the main task again
1596
-
1597
- # Schedule hiding the task from display after a short delay
1598
- # but keep it in the data structure for summary generation
1599
- self._timer_manager.schedule_task_hiding(task_id)
1600
+ super().complete_sub_task(task_id, record_time=record_time)
1601
+ # Clear the subtask display when completed
1602
+ self._current_subtask = ""
1603
+ self._current_display = ""
1600
1604
 
1601
1605
  def remove_sub_task(self, task_id: str, animate: bool = True) -> None:
1602
1606
  """Remove a sub-task by ID."""
1607
+ task_description: str | None = None
1603
1608
  if task_id in self._tasks:
1604
- # Store description before deletion
1605
1609
  task_description = self._tasks[task_id].description
1606
- del self._tasks[task_id]
1607
- # Clear subtask display if this was the current one
1608
- if hasattr(self, '_current_subtask') and self._current_subtask == task_description:
1609
- self._current_subtask = ""
1610
- self._current_display = ""
1611
- # The spinner will now show the main task again
1610
+
1611
+ # Notebook display should drop the subtask immediately to clear the UI.
1612
+ super().remove_sub_task(task_id, animate=False)
1613
+
1614
+ if task_description and getattr(self, "_current_subtask", "") == task_description:
1615
+ self._current_subtask = ""
1616
+ self._current_display = ""
1617
+ # The spinner will now show the main task again
1612
1618
 
1613
1619
  def update_sub_status(self, sub_status: str):
1614
1620
  """Legacy method for backward compatibility - creates/updates a default sub-task."""
1615
- self.add_sub_task(sub_status, "default")
1616
- self.update_sub_task("default", sub_status)
1621
+ super().update_sub_status(sub_status)
1617
1622
 
1618
1623
  def update_main_status(self, message: str):
1619
1624
  """Update the main status line with real-time updates."""
1620
- self.description = message
1621
- # Clear any existing subtask when main status changes
1625
+ super().update_main_status(message)
1622
1626
  self._current_subtask = ""
1623
1627
  self._current_display = ""
1624
1628
  # The spinner will now show the updated main task
1625
1629
 
1626
1630
  def update_messages(self, updater: dict[str, str]):
1627
1631
  """Update both main message and sub-status if provided."""
1628
- if "message" in updater:
1629
- self.update_main_status(updater["message"])
1630
- if "sub_status" in updater:
1631
- self.update_sub_status(updater["sub_status"])
1632
- if "success_message" in updater:
1633
- self.success_message = updater["success_message"]
1634
- if "failure_message" in updater:
1635
- self.failure_message = updater["failure_message"]
1632
+ super().update_messages(updater)
1636
1633
 
1637
- def get_sub_task_count(self) -> int:
1638
- """Get the current number of active sub-tasks."""
1639
- return len(self._tasks)
1640
-
1641
- def list_sub_tasks(self) -> list[str]:
1642
- """Get a list of all active sub-task IDs."""
1643
- return list(self._tasks.keys())
1644
-
1645
- def get_task_status(self) -> str:
1646
- """Get a human-readable status of current task count vs limit."""
1647
- current_count = len(self._tasks)
1648
- return f"› Active tasks: {current_count}"
1649
-
1650
- def _invalidate_cache(self):
1651
- """Invalidate the render cache to force re-rendering."""
1652
- # NotebookTaskProgress doesn't use caching, but we need this for API compatibility
1653
- pass
1654
-
1655
- def _update_display(self):
1656
- """Update the display if live."""
1657
- # NotebookTaskProgress updates display through the update() method
1658
- # This is here for API compatibility with TaskProgress
1659
- pass
1660
-
1661
- def _clear_all_tasks(self):
1662
- """Clear all tasks and related data."""
1663
- self._tasks.clear()
1634
+ def _clear_all_tasks(self) -> None:
1635
+ super()._clear_all_tasks()
1664
1636
  self._current_subtask = ""
1665
1637
  self._current_display = ""
1666
1638
 
@@ -1700,14 +1672,20 @@ class NotebookTaskProgress:
1700
1672
  # Clear the spinner line completely
1701
1673
  self._clear_spinner_line()
1702
1674
 
1703
- if self.success_message != "":
1675
+ final_message: str | None = None
1676
+ if self.success_message:
1704
1677
  final_message = f"{SUCCESS_ICON} {self.success_message}"
1705
- # For Jupyter, use update() to properly handle IPython display
1706
- # For Snowflake, use print() to get a new line
1678
+ elif summary:
1679
+ final_message = f"{SUCCESS_ICON} Done"
1680
+
1681
+ if final_message:
1707
1682
  if self.is_jupyter:
1708
- self.update(final_message)
1683
+ if self.display is not None:
1684
+ self.display.update({"text/plain": final_message}, raw=True)
1685
+ else:
1686
+ _, display_fn = _load_ipython_display()
1687
+ self.display = display_fn({"text/plain": final_message}, display_id=True, raw=True)
1709
1688
  else:
1710
- # Print the success message on a clean line
1711
1689
  print(final_message)
1712
1690
  elif self.success_message == "":
1713
1691
  # When there's no success message, clear the display for notebooks
@@ -1718,9 +1696,13 @@ class NotebookTaskProgress:
1718
1696
 
1719
1697
  # Print summary if there are completed tasks
1720
1698
  if summary:
1721
- # For all notebook environments: display was cleared above, now print summary
1722
- print()
1723
- print(summary.strip()) # Summary includes visual separator line
1699
+ if self.is_jupyter:
1700
+ # Use IPython display to avoid blank stdout lines in notebooks
1701
+ _, display_fn = _load_ipython_display()
1702
+ display_fn({"text/plain": summary.strip()}, raw=True)
1703
+ else:
1704
+ print()
1705
+ print(summary.strip()) # Summary includes visual separator line
1724
1706
 
1725
1707
  # Skip trailing newline for Jupyter - it interferes with IPython display
1726
1708
  if self.trailing_newline and not self.is_jupyter: