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
6 changes: 3 additions & 3 deletions progressbar/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,7 @@ def excepthook(
self.flush()


class AttributeDict(dict):
class AttributeDict(dict[str, T], typing.Generic[T]):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep generic metadata out of AttributeDict contents

When a caller instantiates the new runtime generic form (AttributeDict[int]()), typing.Generic writes __orig_class__ onto the instance. Because this class routes every __setattr__ into self[name], that metadata becomes a real dictionary entry, so an empty typed AttributeDict has length 1 and iterates/serializes with a spurious __orig_class__ key. The added test only annotates an unparameterized constructor, so it misses the advertised usage; special-case this internal attribute or avoid storing all real attributes as dict items.

Useful? React with 👍 / 👎.

"""
A dict that can be accessed with .attribute.

Expand Down Expand Up @@ -563,13 +563,13 @@ class AttributeDict(dict):
AttributeError: No such attribute: spam
"""

def __getattr__(self, name: str) -> typing.Any:
def __getattr__(self, name: str) -> T:
if name in self:
return self[name]
else:
raise AttributeError(f'No such attribute: {name}')

def __setattr__(self, name: str, value: typing.Any) -> None:
def __setattr__(self, name: str, value: T) -> None:
self[name] = value

def __delattr__(self, name: str) -> None:
Expand Down
68 changes: 68 additions & 0 deletions tests/test_stream.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import io
import logging
import os
import subprocess
import sys
import textwrap

import pytest

Expand Down Expand Up @@ -485,3 +487,69 @@ def flush(self) -> None:
written = wrapper.write('hello\n')
assert written == 6
assert target.flushes >= 1


def test_redirect_stdout_unwrapped_after_keyboard_interrupt() -> None:
# #212: when redirect_stdout wraps sys.stdout and iteration is abandoned
# by a KeyboardInterrupt, the bar must unwrap stdout again. Runs in a
# subprocess because stream wrapping mutates process-global sys.stdout.
child = textwrap.dedent(
"""
import sys
import progressbar
from progressbar.utils import WrappingIO

try:
for i in progressbar.progressbar(range(100), redirect_stdout=True):
print('text', i)
if i == 50:
raise KeyboardInterrupt
except KeyboardInterrupt:
pass

sys.exit(2 if isinstance(sys.stdout, WrappingIO) else 0)
"""
)
result = subprocess.run(
[sys.executable, '-c', child], capture_output=True, text=True
)
Comment on lines +513 to +515

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure that the subprocess can reliably import the progressbar package across different test runners and environments (e.g., when the package is not installed globally or in editable mode but is present in sys.path), propagate the current sys.path via the PYTHONPATH environment variable.

Suggested change
result = subprocess.run(
[sys.executable, '-c', child], capture_output=True, text=True
)
env = os.environ.copy()
env["PYTHONPATH"] = os.path.pathsep.join(sys.path)
result = subprocess.run(
[sys.executable, "-c", child],
capture_output=True,
text=True,
env=env,
)

assert result.returncode == 0, (
f'stdout left wrapped after interrupt; rc={result.returncode}\n'
f'stderr={result.stderr[-500:]}'
)


def test_wrap_logging_deduplicates_shared_handler(monkeypatch) -> None:
# A handler attached to more than one logger must be wrapped only once.
# _iter_loggers yields the root logger then named loggers, so a handler
# shared by both is seen twice and the second visit is skipped.
for _ in range(5):
progressbar.streams.unwrap(stderr=True, stdout=True)
progressbar.streams.unwrap_logging()
Comment on lines +526 to +528

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instead of manually unwrapping the streams with a hardcoded loop, use the existing helper function reset_wrapped_streams() which is defined at the top of this file. This ensures consistent, robust, and clean reset of all wrapped streams across all tests.

    reset_wrapped_streams()


stream = io.StringIO()
monkeypatch.setattr(sys, 'stderr', stream)
monkeypatch.setattr(progressbar.streams, 'original_stderr', stream)
monkeypatch.setattr(progressbar.streams, 'stderr', stream)

root = logging.getLogger()
named = logging.getLogger('progressbar-test-shared-handler')
named.handlers = []
named.propagate = False
handler = logging.StreamHandler(sys.stderr)
named.addHandler(handler)
root.addHandler(handler) # same handler object on two loggers

progressbar.streams.wrap_stderr()
try:
progressbar.streams.wrap_logging()
# Recorded exactly once despite being reachable via two loggers.
shared = [
h for h, _ in progressbar.streams.logging_handlers if h is handler
]
assert len(shared) == 1
finally:
progressbar.streams.unwrap_logging()
progressbar.streams.unwrap(stderr=True)
root.removeHandler(handler)
named.handlers = []
15 changes: 15 additions & 0 deletions tests/test_terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,18 @@ def test_redirect_blank_line_off_by_default(monkeypatch) -> None:

monkeypatch.setattr(utils.streams, 'needs_clear', lambda: True)
assert '\n' not in _redirect_update_output(redirect_blank_line=False)


def test_redirect_blank_line_position(monkeypatch) -> None:
# #295: the blank line must sit immediately AFTER the clear sequence and
# BEFORE the bar redraw, not merely 'somewhere in the output'.
from progressbar import utils

monkeypatch.setattr(utils.streams, 'needs_clear', lambda: True)
output = _redirect_update_output(redirect_blank_line=True)
clear = '\r' + ' ' * 40 + '\r'
assert (clear + '\n') in output, repr(output)
assert output.index(clear + '\n') < output.index('50%'), repr(output)

output_off = _redirect_update_output(redirect_blank_line=False)
assert (clear + '\n') not in output_off, repr(output_off)
14 changes: 14 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,20 @@ def test_attribute_dict_set_get_del() -> None:
del attrs.spam


def test_attribute_dict_generic_value_type() -> None:
# The generic upgrade lets homogeneous, explicitly-typed instances flow the
# real value type. Runtime contract asserted here; the static benefit
# (reveal_type -> int, mis-typed assignment flagged) is checked by pyright.
attrs: utils.AttributeDict[int] = utils.AttributeDict()
attrs.count = 5
assert attrs.count == 5
assert attrs['count'] == 5

mixed = utils.AttributeDict(a=1, b='x')
assert mixed.a == 1
assert mixed.b == 'x'


def test_stream_wrapper_unwrap_restores_excepthook() -> None:
# Regression: C7 - unwrap_stdout/unwrap_stderr left the custom
# excepthook installed forever.
Expand Down
30 changes: 30 additions & 0 deletions tests/test_with.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,33 @@ def test_context_manager_and_iterable_no_duplicate() -> None:
# The completed bar must be rendered exactly once; the bug finished the
# bar twice (StopIteration and then __exit__), drawing it a second time.
assert fd.getvalue().count('100% (10 of 10)') == 1, repr(fd.getvalue())


def test_context_manager_and_iterable_reporter_widgets_no_duplicate() -> None:
# Regression #301 with the reporter's exact widget set and a generator:
# using the bar as BOTH context manager and iterable must render the
# completed bar exactly once.
from progressbar.widgets import (
AnimatedMarker,
GranularBar,
SimpleProgress,
Timer,
)

fd = io.StringIO()
widgets = [
AnimatedMarker(),
' ',
SimpleProgress(),
' ',
GranularBar(),
' ',
Timer(),
]
with progressbar.ProgressBar(
max_value=10, fd=fd, is_terminal=True, term_width=60, widgets=widgets
) as bar:
for _ in bar(i for i in range(10)):
pass

assert fd.getvalue().count('10 of 10') == 1, repr(fd.getvalue())
Loading