This commit is contained in:
nose
2025-12-21 16:59:37 -08:00
parent 11a13edb84
commit d0b821b5dd
7 changed files with 508 additions and 136 deletions

287
models.py
View File

@@ -681,6 +681,7 @@ class PipelineLiveProgress:
self._overall: Optional[Progress] = None
self._pipe_progress: Optional[Progress] = None
self._subtasks: Optional[Progress] = None
self._status: Optional[Progress] = None
self._transfers: Optional[Progress] = None
self._overall_task: Optional[TaskID] = None
@@ -688,6 +689,17 @@ class PipelineLiveProgress:
self._transfer_tasks: Dict[str, TaskID] = {}
# Per-pipe status line shown below the pipe bars.
self._status_tasks: Dict[int, TaskID] = {}
# When a pipe is operating on a single item, allow percent-based progress
# updates on the pipe bar (0..100) so it doesn't sit at 0% until emit().
self._pipe_percent_mode: Dict[int, bool] = {}
# Per-pipe step counters used for status lines and percent mapping.
self._pipe_step_total: Dict[int, int] = {}
self._pipe_step_done: Dict[int, int] = {}
# Per-pipe state
self._pipe_totals: List[int] = [0 for _ in self._pipe_labels]
self._pipe_done: List[int] = [0 for _ in self._pipe_labels]
@@ -700,26 +712,11 @@ class PipelineLiveProgress:
def _title_text(self) -> str:
"""Compute the Pipeline panel title.
We keep per-pipe elapsed time on the pipe rows. The panel title is used
to show the currently active item (cmd + url/path) with a lightweight
spinner so the UI reads as "working on X".
The title remains stable ("Pipeline"). Per-item step detail is rendered
using a dedicated progress bar within the panel.
"""
active = str(self._active_subtask_text or "").strip()
if not active:
return "Pipeline"
# Lightweight spinner frames (similar intent to Rich's simpleDots).
try:
import time
frames = [".", "..", "..."]
idx = int(time.monotonic() * 4) % len(frames)
prefix = frames[idx]
except Exception:
prefix = "..."
return f"{prefix} {active}"
return "Pipeline"
def set_active_subtask_text(self, text: Optional[str]) -> None:
"""Update the Pipeline panel title to reflect the current in-item step.
@@ -744,6 +741,7 @@ class PipelineLiveProgress:
"""
pipe_progress = self._pipe_progress
status = self._status
transfers = self._transfers
overall = self._overall
if pipe_progress is None or transfers is None or overall is None:
@@ -751,23 +749,27 @@ class PipelineLiveProgress:
yield Panel("", title="Pipeline", expand=False)
return
yield Group(
Panel(Group(pipe_progress, transfers), title=self._title_text(), expand=False),
overall,
)
body_parts: List[Any] = [pipe_progress]
if status is not None and self._status_tasks:
body_parts.append(status)
body_parts.append(transfers)
yield Group(Panel(Group(*body_parts), title=self._title_text(), expand=False), overall)
def _render_group(self) -> Group:
# Backward-compatible helper (some callers may still expect a Group).
pipe_progress = self._pipe_progress
status = self._status
transfers = self._transfers
overall = self._overall
assert pipe_progress is not None
assert transfers is not None
assert overall is not None
return Group(
Panel(Group(pipe_progress, transfers), title=self._title_text(), expand=False),
overall,
)
body_parts: List[Any] = [pipe_progress]
if status is not None and self._status_tasks:
body_parts.append(status)
body_parts.append(transfers)
return Group(Panel(Group(*body_parts), title=self._title_text(), expand=False), overall)
def start(self) -> None:
if not self._enabled:
@@ -803,6 +805,14 @@ class PipelineLiveProgress:
transient=False,
)
# Status line below the pipe bars. Kept simple (no extra bar) so it
# doesn't visually offset the main pipe bar columns.
self._status = Progress(
TextColumn(" [bold]└─ {task.description}[/bold]"),
console=self._console,
transient=False,
)
# Byte-based transfer bars (download/upload) integrated into the Live view.
self._transfers = Progress(
TextColumn(" {task.description}"),
@@ -878,12 +888,178 @@ class PipelineLiveProgress:
self._overall = None
self._pipe_progress = None
self._subtasks = None
self._status = None
self._transfers = None
self._overall_task = None
self._pipe_tasks = []
self._transfer_tasks = {}
self._status_tasks = {}
self._pipe_percent_mode = {}
self._pipe_step_total = {}
self._pipe_step_done = {}
self._active_subtask_text = None
def _hide_pipe_subtasks(self, pipe_index: int) -> None:
"""Hide any visible per-item spinner rows for a pipe."""
subtasks = self._subtasks
if subtasks is None:
return
try:
for sub_id in self._subtask_ids[int(pipe_index)]:
try:
subtasks.stop_task(sub_id)
subtasks.update(sub_id, visible=False)
except Exception:
pass
except Exception:
pass
def set_pipe_status_text(self, pipe_index: int, text: str) -> None:
"""Set a status line under the pipe bars for the given pipe."""
if not self._enabled:
return
if not self._ensure_pipe(int(pipe_index)):
return
prog = self._status
if prog is None:
return
try:
pidx = int(pipe_index)
msg = str(text or "").strip()
except Exception:
return
# For long single-item work, hide the per-item spinner line and use this
# dedicated status line instead.
if self._pipe_percent_mode.get(pidx, False):
try:
self._hide_pipe_subtasks(pidx)
except Exception:
pass
task_id = self._status_tasks.get(pidx)
if task_id is None:
try:
task_id = prog.add_task(msg)
except Exception:
return
self._status_tasks[pidx] = task_id
try:
prog.update(task_id, description=msg, refresh=True)
except Exception:
pass
def clear_pipe_status_text(self, pipe_index: int) -> None:
prog = self._status
if prog is None:
return
try:
pidx = int(pipe_index)
except Exception:
return
task_id = self._status_tasks.pop(pidx, None)
if task_id is None:
return
try:
prog.remove_task(task_id)
except Exception:
pass
def set_pipe_percent(self, pipe_index: int, percent: int) -> None:
"""Update the pipe bar as a percent (only when single-item mode is enabled)."""
if not self._enabled:
return
if not self._ensure_pipe(int(pipe_index)):
return
pipe_progress = self._pipe_progress
if pipe_progress is None:
return
try:
pidx = int(pipe_index)
except Exception:
return
if not self._pipe_percent_mode.get(pidx, False):
return
try:
pct = max(0, min(100, int(percent)))
pipe_task = self._pipe_tasks[pidx]
pipe_progress.update(pipe_task, completed=pct, total=100, refresh=True)
except Exception:
pass
def begin_pipe_steps(self, pipe_index: int, *, total_steps: int) -> None:
"""Initialize step tracking for a pipe.
The cmdlet must call this once up-front so we can map steps to percent.
"""
if not self._enabled:
return
if not self._ensure_pipe(int(pipe_index)):
return
try:
pidx = int(pipe_index)
tot = max(1, int(total_steps))
except Exception:
return
self._pipe_step_total[pidx] = tot
self._pipe_step_done[pidx] = 0
# Reset status line and percent.
try:
self.clear_pipe_status_text(pidx)
except Exception:
pass
try:
self.set_pipe_percent(pidx, 0)
except Exception:
pass
def advance_pipe_step(self, pipe_index: int, text: str) -> None:
"""Advance the pipe's step counter by one.
Each call is treated as a new step (no in-place text rewrites).
Updates:
- status line: "i/N step: {text}"
- pipe percent (single-item pipes only): round(i/N*100)
"""
if not self._enabled:
return
if not self._ensure_pipe(int(pipe_index)):
return
try:
pidx = int(pipe_index)
except Exception:
return
total = int(self._pipe_step_total.get(pidx, 0) or 0)
if total <= 0:
# If steps weren't declared, treat as a single-step operation.
total = 1
self._pipe_step_total[pidx] = total
done = int(self._pipe_step_done.get(pidx, 0) or 0) + 1
done = min(done, total)
self._pipe_step_done[pidx] = done
msg = str(text or "").strip()
line = f"{done}/{total} step: {msg}" if msg else f"{done}/{total} step"
try:
self.set_pipe_status_text(pidx, line)
except Exception:
pass
# Percent mapping only applies when the pipe is in percent mode (single-item).
try:
pct = 100 if done >= total else int(round((done / max(1, total)) * 100.0))
self.set_pipe_percent(pidx, pct)
except Exception:
pass
def begin_transfer(self, *, label: str, total: Optional[int] = None) -> None:
if not self._enabled:
return
@@ -962,8 +1138,23 @@ class PipelineLiveProgress:
self._subtask_active_index[pipe_index] = 0
self._subtask_ids[pipe_index] = []
# Reset per-item step progress for this pipe.
try:
self.clear_pipe_status_text(pipe_index)
except Exception:
pass
try:
self._pipe_step_total.pop(pipe_index, None)
self._pipe_step_done.pop(pipe_index, None)
except Exception:
pass
# If this pipe will process exactly one item, allow percent-based updates.
percent_mode = bool(int(total_items) == 1)
self._pipe_percent_mode[pipe_index] = percent_mode
pipe_task = self._pipe_tasks[pipe_index]
pipe_progress.update(pipe_task, completed=0, total=total_items)
pipe_progress.update(pipe_task, completed=0, total=(100 if percent_mode else total_items))
# Start the per-pipe timer now that the pipe is actually running.
try:
pipe_progress.start_task(pipe_task)
@@ -974,6 +1165,12 @@ class PipelineLiveProgress:
if isinstance(items_preview, list) and items_preview:
labels = [_pipeline_progress_item_label(x) for x in items_preview]
# For single-item pipes, keep the UI clean: don't show a spinner row.
if percent_mode:
self._subtask_ids[pipe_index] = []
self._active_subtask_text = None
return
for i in range(total_items):
suffix = labels[i] if i < len(labels) else f"item {i + 1}/{total_items}"
# Use start=False so elapsed time starts when we explicitly start_task().
@@ -1038,7 +1235,21 @@ class PipelineLiveProgress:
self._pipe_done[pipe_index] = done
pipe_task = self._pipe_tasks[pipe_index]
pipe_progress.update(pipe_task, completed=done)
if self._pipe_percent_mode.get(pipe_index, False):
pipe_progress.update(pipe_task, completed=100, total=100)
else:
pipe_progress.update(pipe_task, completed=done)
# Clear any status line now that it emitted.
try:
self.clear_pipe_status_text(pipe_index)
except Exception:
pass
try:
self._pipe_step_total.pop(pipe_index, None)
self._pipe_step_done.pop(pipe_index, None)
except Exception:
pass
# Start next subtask spinner.
next_index = active + 1
@@ -1072,7 +1283,10 @@ class PipelineLiveProgress:
# Ensure the pipe bar finishes even if cmdlet didnt emit per item.
if force_complete and done < total:
pipe_task = self._pipe_tasks[pipe_index]
pipe_progress.update(pipe_task, completed=total)
if self._pipe_percent_mode.get(pipe_index, False):
pipe_progress.update(pipe_task, completed=100, total=100)
else:
pipe_progress.update(pipe_task, completed=total)
self._pipe_done[pipe_index] = total
# Hide any remaining subtask spinners.
@@ -1086,6 +1300,17 @@ class PipelineLiveProgress:
# If we just finished the active pipe, clear the title context.
self._active_subtask_text = None
# Ensure status line is cleared when a pipe finishes.
try:
self.clear_pipe_status_text(pipe_index)
except Exception:
pass
try:
self._pipe_step_total.pop(pipe_index, None)
self._pipe_step_done.pop(pipe_index, None)
except Exception:
pass
# Stop the per-pipe timer once the pipe is finished.
try:
pipe_task = self._pipe_tasks[pipe_index]
@@ -1112,12 +1337,14 @@ class PipelineStageContext:
self,
stage_index: int,
total_stages: int,
pipe_index: Optional[int] = None,
worker_id: Optional[str] = None,
on_emit: Optional[Callable[[Any], None]] = None,
):
self.stage_index = stage_index
self.total_stages = total_stages
self.is_last_stage = (stage_index == total_stages - 1)
self.pipe_index = int(pipe_index) if pipe_index is not None else None
self.worker_id = worker_id
self._on_emit = on_emit
self.emits: List[Any] = []
@@ -1141,7 +1368,7 @@ class PipelineStageContext:
def __repr__(self) -> str:
return (
f"PipelineStageContext(stage={self.stage_index}/{self.total_stages}, "
f"is_last={self.is_last_stage}, worker_id={self.worker_id})"
f"pipe_index={self.pipe_index}, is_last={self.is_last_stage}, worker_id={self.worker_id})"
)