Skip to content

Commit

Permalink
Merge pull request #2166 from Textualize/implement-progress-bars-with…
Browse files Browse the repository at this point in the history
…out-known-total

[progress] The `total` we pass to a Progress renderable can now be `None`
  • Loading branch information
willmcgugan committed Apr 14, 2022
2 parents 50a7ec8 + d2830c8 commit 3b36864
Show file tree
Hide file tree
Showing 4 changed files with 142 additions and 45 deletions.
2 changes: 1 addition & 1 deletion docs/source/progress.rst
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ Transient progress displays are useful if you want more minimal output in the te
Indeterminate progress
~~~~~~~~~~~~~~~~~~~~~~

When you add a task it is automatically *started*, which means it will show a progress bar at 0% and the time remaining will be calculated from the current time. This may not work well if there is a long delay before you can start updating progress; you may need to wait for a response from a server or count files in a directory (for example). In these cases you can call :meth:`~rich.progress.Progress.add_task` with ``start=False`` which will display a pulsing animation that lets the user know something is working. This is know as an *indeterminate* progress bar. When you have the number of steps you can call :meth:`~rich.progress.Progress.start_task` which will display the progress bar at 0%, then :meth:`~rich.progress.Progress.update` as normal.
When you add a task it is automatically *started*, which means it will show a progress bar at 0% and the time remaining will be calculated from the current time. This may not work well if there is a long delay before you can start updating progress; you may need to wait for a response from a server or count files in a directory (for example). In these cases you can call :meth:`~rich.progress.Progress.add_task` with ``start=False`` or ``total=None`` which will display a pulsing animation that lets the user know something is working. This is know as an *indeterminate* progress bar. When you have the number of steps you can call :meth:`~rich.progress.Progress.start_task` which will display the progress bar at 0%, then :meth:`~rich.progress.Progress.update` as normal.

Auto refresh
~~~~~~~~~~~~
Expand Down
112 changes: 88 additions & 24 deletions rich/progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
from typing_extensions import Literal # pragma: no cover

from . import filesize, get_console
from .console import Console, JustifyMethod, RenderableType, Group
from .console import Console, Group, JustifyMethod, RenderableType
from .highlighter import Highlighter
from .jupyter import JupyterMixin
from .live import Live
Expand Down Expand Up @@ -148,7 +148,7 @@ def track(
finished_style=finished_style,
pulse_style=pulse_style,
),
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
TaskProgressColumn(),
TimeRemainingColumn(),
)
)
Expand Down Expand Up @@ -651,7 +651,7 @@ def __init__(
def render(self, task: "Task") -> ProgressBar:
"""Gets a progress bar widget for a task."""
return ProgressBar(
total=max(0, task.total),
total=max(0, task.total) if task.total is not None else None,
completed=max(0, task.completed),
width=None if self.bar_width is None else max(1, self.bar_width),
pulse=not task.started,
Expand All @@ -675,6 +675,43 @@ def render(self, task: "Task") -> Text:
return Text(str(delta), style="progress.elapsed")


class TaskProgressColumn(TextColumn):
"""A column displaying the progress of a task."""

def __init__(
self,
text_format: str = "[progress.percentage]{task.percentage:>3.0f}%",
text_format_no_percentage: str = "",
style: StyleType = "none",
justify: JustifyMethod = "left",
markup: bool = True,
highlighter: Optional[Highlighter] = None,
table_column: Optional[Column] = None,
) -> None:
self.text_format_no_percentage = text_format_no_percentage
super().__init__(
text_format=text_format,
style=style,
justify=justify,
markup=markup,
highlighter=highlighter,
table_column=table_column,
)

def render(self, task: "Task") -> Text:
text_format = (
self.text_format_no_percentage if task.total is None else self.text_format
)
_text = text_format.format(task=task)
if self.markup:
text = Text.from_markup(_text, style=self.style, justify=self.justify)
else:
text = Text(_text, style=self.style, justify=self.justify)
if self.highlighter:
self.highlighter.highlight(text)
return text


class TimeRemainingColumn(ProgressColumn):
"""Renders estimated time remaining.
Expand Down Expand Up @@ -705,6 +742,9 @@ def render(self, task: "Task") -> Text:
task_time = task.time_remaining
style = "progress.remaining"

if task.total is None:
return Text("", style=style)

if task_time is None:
return Text("--:--" if self.compact else "-:--:--", style=style)

Expand Down Expand Up @@ -734,7 +774,7 @@ class TotalFileSizeColumn(ProgressColumn):

def render(self, task: "Task") -> Text:
"""Show data completed."""
data_size = filesize.decimal(int(task.total))
data_size = filesize.decimal(int(task.total)) if task.total is not None else ""
return Text(data_size, style="progress.filesize.total")


Expand All @@ -757,7 +797,7 @@ def __init__(self, separator: str = "/", table_column: Optional[Column] = None):
def render(self, task: "Task") -> Text:
"""Show completed/total."""
completed = int(task.completed)
total = int(task.total)
total = int(task.total) if task.total is not None else "?"
total_width = len(str(total))
return Text(
f"{completed:{total_width}d}{self.separator}{total}",
Expand All @@ -781,24 +821,34 @@ def __init__(
def render(self, task: "Task") -> Text:
"""Calculate common unit for completed and total."""
completed = int(task.completed)
total = int(task.total)

unit_and_suffix_calculation_base = (
int(task.total) if task.total is not None else completed
)
if self.binary_units:
unit, suffix = filesize.pick_unit_and_suffix(
total,
unit_and_suffix_calculation_base,
["bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"],
1024,
)
else:
unit, suffix = filesize.pick_unit_and_suffix(
total,
unit_and_suffix_calculation_base,
["bytes", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"],
1000,
)
completed_ratio = completed / unit
total_ratio = total / unit
precision = 0 if unit == 1 else 1

completed_ratio = completed / unit
completed_str = f"{completed_ratio:,.{precision}f}"
total_str = f"{total_ratio:,.{precision}f}"

if task.total is not None:
total = int(task.total)
total_ratio = total / unit
total_str = f"{total_ratio:,.{precision}f}"
else:
total_str = "?"

download_status = f"{completed_str}/{total_str} {suffix}"
download_text = Text(download_status, style="progress.download")
return download_text
Expand Down Expand Up @@ -839,8 +889,8 @@ class Task:
description: str
"""str: Description of the task."""

total: float
"""str: Total number of steps in this task."""
total: Optional[float]
"""Optional[float]: Total number of steps in this task."""

completed: float
"""float: Number of steps completed"""
Expand Down Expand Up @@ -883,8 +933,10 @@ def started(self) -> bool:
return self.start_time is not None

@property
def remaining(self) -> float:
"""float: Get the number of steps remaining."""
def remaining(self) -> Optional[float]:
"""Optional[float]: Get the number of steps remaining, if a non-None total was set."""
if self.total is None:
return None
return self.total - self.completed

@property
Expand All @@ -903,7 +955,7 @@ def finished(self) -> bool:

@property
def percentage(self) -> float:
"""float: Get progress of task as a percentage."""
"""float: Get progress of task as a percentage. If a None total was set, returns 0"""
if not self.total:
return 0.0
completed = (self.completed / self.total) * 100.0
Expand Down Expand Up @@ -936,7 +988,10 @@ def time_remaining(self) -> Optional[float]:
speed = self.speed
if not speed:
return None
estimate = ceil(self.remaining / speed)
remaining = self.remaining
if remaining is None:
return None
estimate = ceil(remaining / speed)
return estimate

def _reset(self) -> None:
Expand Down Expand Up @@ -1027,7 +1082,7 @@ def get_default_columns(cls) -> Tuple[ProgressColumn, ...]:
return (
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
TaskProgressColumn(),
TimeRemainingColumn(),
)

Expand Down Expand Up @@ -1358,7 +1413,11 @@ def update(
popleft()
if update_completed > 0:
_progress.append(ProgressSample(current_time, update_completed))
if task.completed >= task.total and task.finished_time is None:
if (
task.total is not None
and task.completed >= task.total
and task.finished_time is None
):
task.finished_time = task.elapsed

if refresh:
Expand Down Expand Up @@ -1423,7 +1482,11 @@ def advance(self, task_id: TaskID, advance: float = 1) -> None:
while len(_progress) > 1000:
popleft()
_progress.append(ProgressSample(current_time, update_completed))
if task.completed >= task.total and task.finished_time is None:
if (
task.total is not None
and task.completed >= task.total
and task.finished_time is None
):
task.finished_time = task.elapsed
task.finished_speed = task.speed

Expand Down Expand Up @@ -1484,7 +1547,7 @@ def add_task(
self,
description: str,
start: bool = True,
total: float = 100.0,
total: Optional[float] = 100.0,
completed: int = 0,
visible: bool = True,
**fields: Any,
Expand All @@ -1495,7 +1558,8 @@ def add_task(
description (str): A description of the task.
start (bool, optional): Start the task immediately (to calculate elapsed time). If set to False,
you will need to call `start` manually. Defaults to True.
total (float, optional): Number of total steps in the progress if know. Defaults to 100.
total (float, optional): Number of total steps in the progress if known.
Set to None to render a pulsing animation. Defaults to 100.
completed (int, optional): Number of steps completed so far.. Defaults to 0.
visible (bool, optional): Enable display of the task. Defaults to True.
**fields (str): Additional data fields required for rendering.
Expand Down Expand Up @@ -1585,12 +1649,12 @@ def remove_task(self, task_id: TaskID) -> None:
*Progress.get_default_columns(),
TimeElapsedColumn(),
console=console,
transient=True,
transient=False,
) as progress:

task1 = progress.add_task("[red]Downloading", total=1000)
task2 = progress.add_task("[green]Processing", total=1000)
task3 = progress.add_task("[yellow]Thinking", total=1000, start=False)
task3 = progress.add_task("[yellow]Thinking", total=None)

while not progress.finished:
progress.update(task1, advance=0.5)
Expand Down
24 changes: 16 additions & 8 deletions rich/progress_bar.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ class ProgressBar(JupyterMixin):
"""Renders a (progress) bar. Used by rich.progress.
Args:
total (float, optional): Number of steps in the bar. Defaults to 100.
total (float, optional): Number of steps in the bar. Defaults to 100. Set to None to render a pulsing animation.
completed (float, optional): Number of steps completed. Defaults to 0.
width (int, optional): Width of the bar, or ``None`` for maximum width. Defaults to None.
pulse (bool, optional): Enable pulse effect. Defaults to False.
pulse (bool, optional): Enable pulse effect. Defaults to False. Will pulse if a None total was passed.
style (StyleType, optional): Style for the bar background. Defaults to "bar.back".
complete_style (StyleType, optional): Style for the completed bar. Defaults to "bar.complete".
finished_style (StyleType, optional): Style for a finished bar. Defaults to "bar.done".
Expand All @@ -32,7 +32,7 @@ class ProgressBar(JupyterMixin):

def __init__(
self,
total: float = 100.0,
total: Optional[float] = 100.0,
completed: float = 0,
width: Optional[int] = None,
pulse: bool = False,
Expand All @@ -58,8 +58,10 @@ def __repr__(self) -> str:
return f"<Bar {self.completed!r} of {self.total!r}>"

@property
def percentage_completed(self) -> float:
def percentage_completed(self) -> Optional[float]:
"""Calculate percentage complete."""
if self.total is None:
return None
completed = (self.completed / self.total) * 100.0
completed = min(100, max(0.0, completed))
return completed
Expand Down Expand Up @@ -157,23 +159,29 @@ def __rich_console__(

width = min(self.width or options.max_width, options.max_width)
ascii = options.legacy_windows or options.ascii_only
if self.pulse:
should_pulse = self.pulse or self.total is None
if should_pulse:
yield from self._render_pulse(console, width, ascii=ascii)
return

completed = min(self.total, max(0, self.completed))
completed: Optional[float] = (
min(self.total, max(0, self.completed)) if self.total is not None else None
)

bar = "-" if ascii else "━"
half_bar_right = " " if ascii else "╸"
half_bar_left = " " if ascii else "╺"
complete_halves = (
int(width * 2 * completed / self.total) if self.total else width * 2
int(width * 2 * completed / self.total)
if self.total and completed is not None
else width * 2
)
bar_count = complete_halves // 2
half_bar_count = complete_halves % 2
style = console.get_style(self.style)
is_finished = self.total is None or self.completed >= self.total
complete_style = console.get_style(
self.complete_style if self.completed < self.total else self.finished_style
self.finished_style if is_finished else self.complete_style
)
_Segment = Segment
if bar_count:
Expand Down

0 comments on commit 3b36864

Please sign in to comment.