Skip to content

Commit e994a52

Browse files
authored
Merge pull request #531 from mmahut/mmahut/rich
feat: introducing Rich for polling progress bars
2 parents b80af6f + b738358 commit e994a52

6 files changed

Lines changed: 175 additions & 67 deletions

File tree

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ pyyaml>=3.10
77
aiohttp>=3.10
88
setuptools>=39.0
99
async-lru>=2.0
10+
rich>=13.0
1011
build>=0.7.0

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ install_requires =
3333
aiohttp>=3.10
3434
setuptools>=39.0
3535
async-lru>=2.0
36+
rich>=13.0
3637
package_dir =
3738
= src
3839
zip_safe = True

src/badfish/helpers/progress.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from contextlib import contextmanager
2+
3+
from rich.progress import BarColumn, Progress, TaskProgressColumn, TextColumn
4+
5+
6+
@contextmanager
7+
def polling_progress(console, total, prompt, disable=False):
8+
progress = Progress(
9+
TextColumn("- POLLING:"),
10+
BarColumn(bar_width=20),
11+
TaskProgressColumn(),
12+
TextColumn("- {task.fields[prompt]}: {task.fields[state]}"),
13+
console=console,
14+
transient=True,
15+
disable=disable or not console.is_terminal,
16+
)
17+
with progress:
18+
task_id = progress.add_task("", total=total, prompt=prompt, state="")
19+
yield progress, task_id

src/badfish/main.py

Lines changed: 111 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,14 @@
1313
import tempfile
1414
from urllib.parse import urlparse
1515

16+
from rich.console import Console
17+
1618
from badfish.helpers import get_now
1719
from badfish.helpers.parser import parse_arguments
1820
from badfish.helpers.logger import BadfishLogger
1921
from badfish.helpers.http_client import HTTPClient
2022
from badfish.helpers.exceptions import BadfishException
23+
from badfish.helpers.progress import polling_progress
2124

2225
from logging import (
2326
DEBUG,
@@ -30,18 +33,49 @@
3033
RETRIES = 15
3134

3235

33-
async def badfish_factory(_host, _username, _password, _logger=None, _retries=RETRIES, _loop=None, _insecure=False):
36+
async def badfish_factory(
37+
_host,
38+
_username,
39+
_password,
40+
_logger=None,
41+
_retries=RETRIES,
42+
_loop=None,
43+
_insecure=False,
44+
_console=None,
45+
_progress_disabled=False,
46+
):
3447
if not _logger:
3548
bfl = BadfishLogger()
3649
_logger = bfl.logger
3750

38-
badfish = Badfish(_host, _username, _password, _logger, _retries, _loop, _insecure)
51+
badfish = Badfish(
52+
_host,
53+
_username,
54+
_password,
55+
_logger,
56+
_retries,
57+
_loop,
58+
_insecure,
59+
_console,
60+
_progress_disabled,
61+
)
3962
await badfish.init()
4063
return badfish
4164

4265

4366
class Badfish:
44-
def __init__(self, _host, _username, _password, _logger, _retries, _loop=None, _insecure=False):
67+
def __init__(
68+
self,
69+
_host,
70+
_username,
71+
_password,
72+
_logger,
73+
_retries,
74+
_loop=None,
75+
_insecure=False,
76+
_console=None,
77+
_progress_disabled=False,
78+
):
4579
self.host = _host
4680
self.username = _username
4781
self.password = _password
@@ -63,6 +97,8 @@ def __init__(self, _host, _username, _password, _logger, _retries, _loop=None, _
6397
self.session_id = None
6498
self.token = None
6599
self.vendor = None
100+
self.console = _console if _console is not None else Console()
101+
self._progress_disabled = _progress_disabled
66102

67103
async def __aenter__(self):
68104
await self.init()
@@ -86,19 +122,6 @@ async def init(self):
86122
self.bios_uri = None
87123
self.manager_resource = await self.find_managers_resource()
88124

89-
@staticmethod
90-
def progress_bar(value, end_value, state, prompt="Host state", bar_length=20):
91-
ratio = float(value) / end_value
92-
arrow = "-" * int(round(ratio * bar_length) - 1) + ">"
93-
spaces = " " * (bar_length - len(arrow))
94-
percent = int(round(ratio * 100))
95-
96-
if state.lower() == "on":
97-
state = "On "
98-
ret = "\r" if percent != 100 else "\n"
99-
sys.stdout.write(f"\r- POLLING: [{arrow + spaces}] {percent}% - {prompt}: {state}{ret}")
100-
sys.stdout.flush()
101-
102125
async def error_handler(self, _response, message=None):
103126
try:
104127
raw = await _response.text("utf-8", "ignore")
@@ -844,35 +867,38 @@ async def check_schedule_job_status(self, job_id):
844867
return False
845868

846869
async def check_job_status(self, job_id):
847-
for count in range(self.retries):
848-
_url = f"{self.host_uri}{self.manager_resource}/Jobs/{job_id}"
849-
self.http_client.get_request.cache_clear()
850-
_response = await self.get_request(_url)
870+
with polling_progress(
871+
self.console, self.retries, "Status", disable=self._progress_disabled
872+
) as (progress, task_id):
873+
for count in range(self.retries):
874+
_url = f"{self.host_uri}{self.manager_resource}/Jobs/{job_id}"
875+
self.http_client.get_request.cache_clear()
876+
_response = await self.get_request(_url)
851877

852-
status_code = _response.status
853-
raw = await _response.text("utf-8", "ignore")
854-
data = json.loads(raw.strip())
855-
if status_code != 200:
856-
self.logger.error(f"Command failed to check job status, return code is {status_code}")
857-
self.logger.debug(f"Extended Info Message: {data}")
858-
return False
878+
status_code = _response.status
879+
raw = await _response.text("utf-8", "ignore")
880+
data = json.loads(raw.strip())
881+
if status_code != 200:
882+
self.logger.error(f"Command failed to check job status, return code is {status_code}")
883+
self.logger.debug(f"Extended Info Message: {data}")
884+
return False
859885

860-
if "Message" not in data:
861-
self.logger.warning("Job status response missing Message field")
862-
return False
886+
if "Message" not in data:
887+
self.logger.warning("Job status response missing Message field")
888+
return False
863889

864-
if "Fail" in data["Message"] or "fail" in data["Message"]:
865-
self.logger.debug(f"\n{job_id} job failed.")
866-
return False
867-
elif data["Message"] == "Job completed successfully.":
868-
self.logger.info(f"JobID: {data[u'Id']}")
869-
self.logger.info(f"Name: {data[u'Name']}")
870-
self.logger.info(f"Message: {data[u'Message']}")
871-
self.logger.info(f"PercentComplete: {str(data[u'PercentComplete'])}")
872-
break
873-
else:
874-
self.progress_bar(count, self.retries, data["Message"], prompt="Status")
875-
await asyncio.sleep(30)
890+
if "Fail" in data["Message"] or "fail" in data["Message"]:
891+
self.logger.debug(f"\n{job_id} job failed.")
892+
return False
893+
elif data["Message"] == "Job completed successfully.":
894+
self.logger.info(f"JobID: {data[u'Id']}")
895+
self.logger.info(f"Name: {data[u'Name']}")
896+
self.logger.info(f"Message: {data[u'Message']}")
897+
self.logger.info(f"PercentComplete: {str(data[u'PercentComplete'])}")
898+
break
899+
else:
900+
progress.update(task_id, completed=count + 1, state=data["Message"])
901+
await asyncio.sleep(30)
876902

877903
def _extract_job_id_from_response(self, response, warn_on_missing=True, context=""):
878904
"""
@@ -1293,32 +1319,38 @@ async def polling_host_state(self, state, equals=True):
12931319
state_str = "Not %s" % state if not equals else state
12941320
self.logger.info("Polling for host state: %s" % state_str)
12951321
desired_state = False
1296-
for count in range(self.retries):
1297-
current_state = await self.get_power_state()
1298-
if equals:
1299-
desired_state = current_state.lower() == state.lower()
1300-
else:
1301-
desired_state = current_state.lower() != state.lower()
1302-
await asyncio.sleep(5)
1303-
if desired_state:
1304-
self.progress_bar(self.retries, self.retries, current_state)
1305-
break
1306-
self.progress_bar(count, self.retries, current_state)
1322+
with polling_progress(
1323+
self.console, self.retries, "Host state", disable=self._progress_disabled
1324+
) as (progress, task_id):
1325+
for count in range(self.retries):
1326+
current_state = await self.get_power_state()
1327+
if equals:
1328+
desired_state = current_state.lower() == state.lower()
1329+
else:
1330+
desired_state = current_state.lower() != state.lower()
1331+
await asyncio.sleep(5)
1332+
if desired_state:
1333+
progress.update(task_id, completed=self.retries, state=current_state)
1334+
break
1335+
progress.update(task_id, completed=count + 1, state=current_state)
13071336

13081337
return desired_state
13091338

13101339
async def poll_until_ready(self, check_func, description, sleep_interval=5, clear_cache=False):
13111340
self.logger.info("Polling for %s" % description)
1312-
for count in range(self.retries):
1313-
if clear_cache:
1314-
self.http_client.get_request.cache_clear()
1315-
ready = await check_func()
1316-
if ready:
1317-
self.progress_bar(self.retries, self.retries, "Ready")
1318-
self.logger.info("%s is ready." % description)
1319-
return True
1320-
self.progress_bar(count, self.retries, "Not Ready")
1321-
await asyncio.sleep(sleep_interval)
1341+
with polling_progress(
1342+
self.console, self.retries, "Host state", disable=self._progress_disabled
1343+
) as (progress, task_id):
1344+
for count in range(self.retries):
1345+
if clear_cache:
1346+
self.http_client.get_request.cache_clear()
1347+
ready = await check_func()
1348+
if ready:
1349+
progress.update(task_id, completed=self.retries, state="Ready")
1350+
self.logger.info("%s is ready." % description)
1351+
return True
1352+
progress.update(task_id, completed=count + 1, state="Not Ready")
1353+
await asyncio.sleep(sleep_interval)
13221354
self.logger.warning("%s did not become ready after %d retry attempts." % (description, self.retries))
13231355
return False
13241356

@@ -2710,7 +2742,7 @@ async def set_nic_attribute(self, fqdd, attribute, value):
27102742
return True
27112743

27122744

2713-
async def execute_badfish(_host, _args, logger, format_handler=None):
2745+
async def execute_badfish(_host, _args, logger, format_handler=None, console=None, progress_disabled=False):
27142746
_username = _args.get("u") or os.environ.get("BADFISH_USERNAME")
27152747
_password = _args.get("p") or os.environ.get("BADFISH_PASSWORD")
27162748

@@ -2793,6 +2825,8 @@ async def execute_badfish(_host, _args, logger, format_handler=None):
27932825
_logger=logger,
27942826
_retries=retries,
27952827
_insecure=insecure,
2828+
_console=console,
2829+
_progress_disabled=progress_disabled,
27962830
)
27972831

27982832
if _args["host_list"] and not _args["output"]:
@@ -2949,6 +2983,9 @@ def main(argv=None):
29492983
output = _args["output"]
29502984
bfl = BadfishLogger(_args["verbose"], multi_host, _args["log"], output)
29512985

2986+
console = Console()
2987+
progress_disabled = bool(output) or multi_host or bool(_args["log"]) or not console.is_terminal
2988+
29522989
try:
29532990
loop = asyncio.get_event_loop()
29542991
except RuntimeError:
@@ -2975,6 +3012,8 @@ def main(argv=None):
29753012
_args,
29763013
logger,
29773014
bfl.queue_listener.handlers[0] if output else None,
3015+
console=console,
3016+
progress_disabled=progress_disabled,
29783017
)
29793018
tasks.append(fn)
29803019
except IOError as ex:
@@ -3004,7 +3043,14 @@ def main(argv=None):
30043043
else:
30053044
try:
30063045
_host, result = loop.run_until_complete(
3007-
execute_badfish(host, _args, bfl.logger, bfl.queue_listener.handlers[0])
3046+
execute_badfish(
3047+
host,
3048+
_args,
3049+
bfl.logger,
3050+
bfl.queue_listener.handlers[0],
3051+
console=console,
3052+
progress_disabled=progress_disabled,
3053+
)
30083054
)
30093055
except KeyboardInterrupt:
30103056
bfl.logger.warning("Badfish terminated")

tests/test_execution.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@
2222
from tests.test_base import TestBase
2323

2424

25-
def raise_keyb_interrupt_stub(ignore1, ignore2, ignore3, ignore4=None):
25+
def raise_keyb_interrupt_stub(ignore1, ignore2, ignore3, ignore4=None, **kwargs):
2626
raise KeyboardInterrupt
2727

2828

29-
def raise_badfish_exception_stub(ignore1, ignore2, ignore3, ignore4=None):
29+
def raise_badfish_exception_stub(ignore1, ignore2, ignore3, ignore4=None, **kwargs):
3030
raise BadfishException
3131

3232

tests/test_progress.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import io
2+
3+
from rich.console import Console
4+
5+
from badfish.helpers.progress import polling_progress
6+
7+
8+
def _render(console_kwargs, disable):
9+
buf = io.StringIO()
10+
console = Console(file=buf, **console_kwargs)
11+
with polling_progress(console, 3, "Host state", disable=disable) as (progress, task_id):
12+
for i in range(3):
13+
progress.update(task_id, completed=i + 1, state="Off")
14+
return buf.getvalue()
15+
16+
17+
def _has_progress_ui(out):
18+
"""Progress UI is considered rendered if any bar-specific token appears.
19+
Rich may emit a trailing newline in no-op modes on some versions; we only
20+
care that no actual POLLING frame was drawn."""
21+
return "POLLING" in out or "\x1b[" in out or "Host state" in out
22+
23+
24+
def test_disabled_emits_no_progress_ui_even_on_terminal():
25+
"""Explicit disable=True must suppress all progress rendering."""
26+
out = _render({"force_terminal": True, "width": 80}, disable=True)
27+
assert not _has_progress_ui(out), f"unexpected progress output: {out!r}"
28+
29+
30+
def test_non_terminal_emits_no_progress_ui():
31+
"""Non-TTY console (pipe/redirect/capsys) must not render the progress bar."""
32+
out = _render({"force_terminal": False, "width": 80}, disable=False)
33+
assert not _has_progress_ui(out), f"unexpected progress output: {out!r}"
34+
35+
36+
def test_terminal_renders_progress_when_enabled():
37+
"""TTY + disable=False is the only combination that produces visible output."""
38+
out = _render({"force_terminal": True, "width": 80}, disable=False)
39+
assert "POLLING" in out
40+
assert "Host state" in out
41+
assert "\x1b[" in out # ANSI escape sequences from Rich styling

0 commit comments

Comments
 (0)