Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,15 @@ jobs:
tox-env: py313
- python-version: '3.14'
tox-env: py314
# Python 3.15 is a pre-release and currently unsupported by
# typing_extensions (no released version survives
# `from typing_extensions import *` on 3.15 because
# no_type_check_decorator is still listed in __all__ after its
# removal from typing), which breaks the python_utils import.
# Failures are advisory until upstream catches up.
- python-version: '3.15-dev'
tox-env: py315
experimental: true
- python-version: '3.14'
tox-env: docs
- python-version: '3.14'
Expand All @@ -44,4 +51,8 @@ jobs:
run: |
python -m pip install --upgrade pip tox
- name: Test with tox
# Step-level continue-on-error keeps the job green for
# experimental (pre-release Python) environments while still
# showing the failing step in the logs
continue-on-error: ${{ matrix.experimental || false }}
run: tox -e ${{ matrix.tox-env }}
5 changes: 5 additions & 0 deletions examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ def do_something(bar):
# Increment one of the progress bars at random
multibar[bar_label].increment()

# The multibar context manager waits for all bars to finish on
# exit, so finish them explicitly
for bar_label in bar_labels:
multibar[bar_label].finish()


@example
def multiple_bars_line_offset_example() -> None:
Expand Down
43 changes: 34 additions & 9 deletions progressbar/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,8 +342,8 @@

# Initialize the progress bar
bar = progressbar.ProgressBar(
# widgets=widgets,
max_value=total_size or None,
widgets=widgets,
max_value=total_size if filesize_available else None,
max_error=False,
)

Expand All @@ -354,12 +354,20 @@
total_transferred = 0

bar.start()
with contextlib.suppress(KeyboardInterrupt):
with contextlib.suppress(KeyboardInterrupt, BrokenPipeError):
for input_path in input_paths:
if isinstance(input_path, pathlib.Path):
input_stream = stack.enter_context(
input_path.open('r' if args.line_mode else 'rb')
)
if args.line_mode:
# newline='' disables universal-newline
# translation so the byte count matches the file
# size for CRLF files as well
input_stream = stack.enter_context(
input_path.open('r', newline=''),
)
else:
input_stream = stack.enter_context(
input_path.open('rb'),
)
else:
input_stream = input_path

Expand All @@ -374,7 +382,18 @@
break

output_stream.write(data)
total_transferred += len(data)
if isinstance(data, str):
# The total size is measured in bytes, so progress
# must be tracked in bytes as well
encoding = (
getattr(input_stream, 'encoding', None) or 'utf-8'
)
total_transferred += len(
data.encode(encoding, errors='replace'),
)
Comment thread
wolph marked this conversation as resolved.
else:
total_transferred += len(data)

bar.update(total_transferred)

bar.finish(dirty=True)
Expand All @@ -386,8 +405,14 @@
stack: contextlib.ExitStack,
) -> typing.IO[typing.Any]:
if output and output != '-':
mode = 'w' if line_mode else 'wb'
return stack.enter_context(open(output, mode)) # noqa: SIM115
if line_mode:
# newline='' passes the data through without newline
# translation, mirroring the input handling
return stack.enter_context(
open(output, 'w', newline=''), # noqa: SIM115

Check warning

Code scanning / CodeQL

File is not always closed Warning

File is opened but is not closed.
Comment thread
wolph marked this conversation as resolved.
Dismissed
)

return stack.enter_context(open(output, 'wb')) # noqa: SIM115

Check warning

Code scanning / CodeQL

File is not always closed Warning

File is opened but is not closed.
Comment thread
wolph marked this conversation as resolved.
Dismissed
elif line_mode:
return sys.stdout
else:
Expand Down
22 changes: 16 additions & 6 deletions progressbar/algorithms.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,15 @@ class ExponentialMovingAverage(SmoothingAlgorithm):

def __init__(self, alpha: float = 0.5) -> None:
self.alpha = alpha
self.value = 0
self.value: float | None = None

def update(self, new_value: float, elapsed: timedelta) -> float:
self.value = self.alpha * new_value + (1 - self.alpha) * self.value
if self.value is None:
# Seed with the first observation instead of biasing towards 0
self.value = new_value
else:
self.value = self.alpha * new_value + (1 - self.alpha) * self.value

return self.value


Expand All @@ -43,10 +48,15 @@ class DoubleExponentialMovingAverage(SmoothingAlgorithm):

def __init__(self, alpha: float = 0.5) -> None:
self.alpha = alpha
self.ema1 = 0
self.ema2 = 0
self.ema1: float | None = None
self.ema2: float | None = None

def update(self, new_value: float, elapsed: timedelta) -> float:
self.ema1 = self.alpha * new_value + (1 - self.alpha) * self.ema1
self.ema2 = self.alpha * self.ema1 + (1 - self.alpha) * self.ema2
if self.ema1 is None or self.ema2 is None:
# Seed with the first observation instead of biasing towards 0
self.ema1 = self.ema2 = new_value
else:
self.ema1 = self.alpha * new_value + (1 - self.alpha) * self.ema1
self.ema2 = self.alpha * self.ema1 + (1 - self.alpha) * self.ema2

return 2 * self.ema1 - self.ema2
96 changes: 75 additions & 21 deletions progressbar/bar.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import timeit
import typing
import warnings
import weakref
from copy import deepcopy
from datetime import datetime
from types import FrameType
Expand Down Expand Up @@ -132,10 +133,12 @@ def finish(self): # pragma: no cover
def __del__(self):
if not self._finished and self._started: # pragma: no cover
# We're not using contextlib.suppress here because during teardown
# contextlib is not available anymore.
# contextlib is not available anymore. Any exception can occur
# here during interpreter shutdown (closed streams, partially
# torn down modules), so we suppress all of them.
try: # noqa: SIM105
self.finish()
except AttributeError:
except Exception: # noqa: BLE001, S110
pass

def __getstate__(self):
Expand Down Expand Up @@ -385,6 +388,62 @@ def _to_unicode(cls, args: typing.Any):
yield converters.to_unicode(arg)


class _ResizeRegistry:
"""
Shared SIGWINCH handling for all resizable progressbars.

A single signal handler dispatches to every live bar. The original
handler is saved when the first bar registers and restored when the
last one unregisters, so overlapping bars can finish in any order
without leaving a dangling handler installed.
"""

bars: typing.ClassVar[weakref.WeakSet[ResizableMixin]] = weakref.WeakSet()
previous_handler: typing.ClassVar[typing.Any] = None

@classmethod
def install(cls, bar: ResizableMixin) -> None:
import signal

if not hasattr(signal, 'SIGWINCH'): # pragma: no cover
# Not available on Windows
return

if not cls.bars:
cls.previous_handler = signal.getsignal(
signal.SIGWINCH # type: ignore[attr-defined]
)
signal.signal(
signal.SIGWINCH, # type: ignore[attr-defined]
cls.handle_resize,
)

cls.bars.add(bar)

@classmethod
def uninstall(cls, bar: ResizableMixin) -> None:
import signal

if not hasattr(signal, 'SIGWINCH'): # pragma: no cover
# Not available on Windows
return

cls.bars.discard(bar)
if not cls.bars:
signal.signal(
signal.SIGWINCH, # type: ignore[attr-defined]
cls.previous_handler,
)
cls.previous_handler = None
Comment thread
wolph marked this conversation as resolved.

@classmethod
def handle_resize(
cls, signum: int | None = None, frame: None | FrameType = None
) -> None:
for bar in list(cls.bars):
bar._handle_resize(signum, frame)


class ResizableMixin(ProgressBarMixinBase):
def __init__(self, term_width: int | None = None, **kwargs: typing.Any):
ProgressBarMixinBase.__init__(self, **kwargs)
Expand All @@ -395,15 +454,7 @@ def __init__(self, term_width: int | None = None, **kwargs: typing.Any):
else: # pragma: no cover
with contextlib.suppress(Exception):
self._handle_resize()
import signal

self._prev_handle = signal.getsignal(
signal.SIGWINCH # type: ignore
)
signal.signal(
signal.SIGWINCH,
self._handle_resize, # type: ignore
)
_ResizeRegistry.install(self)
self.signal_set = True

def _handle_resize(
Expand All @@ -417,12 +468,8 @@ def finish(self): # pragma: no cover
ProgressBarMixinBase.finish(self)
if self.signal_set:
with contextlib.suppress(Exception):
import signal

signal.signal(
signal.SIGWINCH,
self._prev_handle, # type: ignore
)
_ResizeRegistry.uninstall(self)
self.signal_set = False


class StdRedirectMixin(DefaultFdMixin):
Expand Down Expand Up @@ -686,6 +733,8 @@ def init(self):
self.end_time = None
self.extra = dict()
self._last_update_timer = timeit.default_timer()
self._started = False
self._finished = False

@property
def percentage(self) -> float | None:
Expand Down Expand Up @@ -749,7 +798,7 @@ def data(self) -> types.Dict[str, types.Any]:
- `minutes_elapsed`: The minutes since the bar started modulo
60
- `hours_elapsed`: The hours since the bar started modulo 24
- `days_elapsed`: The hours since the bar started
- `days_elapsed`: The days since the bar started
- `time_elapsed`: The raw elapsed `datetime.timedelta` object
- `percentage`: Percentage as a float or `None` if no max_value
is available
Expand Down Expand Up @@ -788,8 +837,8 @@ def data(self) -> types.Dict[str, types.Any]:
minutes_elapsed=(elapsed.seconds / 60) % 60,
# The hours since the bar started modulo 24
hours_elapsed=(elapsed.seconds / (60 * 60)) % 24,
# The hours since the bar started
days_elapsed=(elapsed.seconds / (60 * 60 * 24)),
# The days since the bar started
days_elapsed=(elapsed.total_seconds() / (60 * 60 * 24)),
# The raw elapsed `datetime.timedelta` object
time_elapsed=elapsed,
# Percentage as a float or `None` if no max_value is available
Expand Down Expand Up @@ -954,7 +1003,7 @@ def _update_variables(self, kwargs):
if key not in self.variables:
raise TypeError(
'update() got an unexpected variable name as argument '
'{key!r}',
f'{key!r}',
)
elif self.variables[key] != value_:
self.variables[key] = kwargs[key]
Expand Down Expand Up @@ -1080,6 +1129,11 @@ def finish(self, end: str = '\n', dirty: bool = False):
dirty (bool): When True the progressbar kept the current state and
won't be set to 100 percent
"""
if self._finished:
# Finishing twice would corrupt the global stream-wrapping
# state, so extra calls are no-ops
return

if not dirty:
self.end_time = datetime.now()
self.update(self.max_value, force=True)
Expand Down
7 changes: 0 additions & 7 deletions progressbar/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import typing
from typing import IO, TextIO


Expand All @@ -9,12 +8,6 @@ class FalseMeta(type):
def __bool__(cls) -> bool: # pragma: no cover
return False

@classmethod
def __cmp__(cls, other: typing.Any) -> int: # pragma: no cover
return -1

__nonzero__ = __bool__


class UnknownLength(metaclass=FalseMeta):
pass
Expand Down
5 changes: 5 additions & 0 deletions progressbar/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ def from_env(cls) -> ColorSupport:
support = max(cls.XTERM_256, support)
elif value == 'xterm':
support = max(cls.XTERM, support)
elif env_flag(variable, default=False):
# Generic truthy flags such as `FORCE_COLOR=1` enable
# color support but don't specify the depth; assume full
# color support analogous to the Jupyter handling above.
return cls.XTERM_TRUECOLOR

return support

Expand Down
Loading
Loading