Skip to content

Commit bfcff8c

Browse files
authored
🎨Progress bar: Add structured message in progress report (#5702)
1 parent 55d5020 commit bfcff8c

File tree

42 files changed

+783
-303
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+783
-303
lines changed

ci/github/helpers/install_rclone_docker_volume_plugin.bash

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,13 @@
44
#
55

66
# http://redsymbol.net/articles/unofficial-bash-strict-mode/
7-
set -o errexit # abort on nonzero exitstatus
8-
set -o nounset # abort on unbound variable
9-
set -o pipefail # don't hide errors within pipes
7+
set -o errexit # abort on nonzero exitstatus
8+
set -o nounset # abort on unbound variable
9+
set -o pipefail # don't hide errors within pipes
1010
IFS=$'\n\t'
1111

12-
1312
# Installation instructions from https://rclone.org/docker/
14-
R_CLONE_VERSION="1.63.1"
13+
R_CLONE_VERSION="1.66.0"
1514
mkdir --parents /var/lib/docker-plugins/rclone/config
1615
mkdir --parents /var/lib/docker-plugins/rclone/cache
1716
docker plugin install rclone/docker-volume-rclone:amd64-${R_CLONE_VERSION} args="-v" --alias rclone --grant-all-permissions

packages/models-library/src/models_library/progress_bar.py

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,81 @@
22

33
from pydantic import BaseModel
44

5+
from .basic_types import IDStr
6+
57
# NOTE: keep a list of possible unit, and please use correct official unit names
68
ProgressUnit: TypeAlias = Literal["Byte"]
79

810

11+
class ProgressStructuredMessage(BaseModel):
12+
description: IDStr
13+
current: float
14+
total: int
15+
unit: str | None
16+
sub: "ProgressStructuredMessage | None"
17+
18+
class Config:
19+
schema_extra: ClassVar[dict[str, Any]] = {
20+
"examples": [
21+
{
22+
"description": "some description",
23+
"current": 12.2,
24+
"total": 123,
25+
},
26+
{
27+
"description": "some description",
28+
"current": 12.2,
29+
"total": 123,
30+
"unit": "Byte",
31+
},
32+
{
33+
"description": "downloading",
34+
"current": 2.0,
35+
"total": 5,
36+
"sub": {
37+
"description": "port 2",
38+
"current": 12.2,
39+
"total": 123,
40+
"unit": "Byte",
41+
},
42+
},
43+
]
44+
}
45+
46+
47+
UNITLESS = None
48+
49+
950
class ProgressReport(BaseModel):
1051
actual_value: float
11-
total: float
12-
unit: ProgressUnit | None = None
52+
total: float = 1.0
53+
unit: ProgressUnit | None = UNITLESS
54+
message: ProgressStructuredMessage | None = None
1355

1456
@property
1557
def percent_value(self) -> float:
1658
if self.total != 0:
1759
return max(min(self.actual_value / self.total, 1.0), 0.0)
1860
return 0
1961

62+
def _recursive_compose_message(self, struct_msg: ProgressStructuredMessage) -> str:
63+
msg = f"{struct_msg.description}"
64+
if struct_msg.sub:
65+
return f"{msg}/{self._recursive_compose_message(struct_msg.sub)}"
66+
msg = f"{msg} {struct_msg.current} / {struct_msg.total}"
67+
return f"{msg} {struct_msg.unit}" if struct_msg.unit is not UNITLESS else msg
68+
69+
@property
70+
def composed_message(self) -> str:
71+
msg = f"{self.actual_value} / {self.total}"
72+
msg = f"{msg} {self.unit}" if self.unit is not UNITLESS else msg
73+
if self.message:
74+
msg = f"{self.message.description} ({msg})"
75+
if self.message.sub:
76+
msg = f"{msg}/{self._recursive_compose_message(self.message.sub)}"
77+
78+
return msg
79+
2080
class Config:
2181
frozen = True
2282
schema_extra: ClassVar[dict[str, Any]] = {
@@ -32,5 +92,13 @@ class Config:
3292
"total": 1024.0,
3393
"unit": "Byte",
3494
},
95+
# typical progress with sub progresses
96+
{
97+
"actual_value": 0.3,
98+
"total": 1.0,
99+
"message": ProgressStructuredMessage.Config.schema_extra[
100+
"examples"
101+
][2],
102+
},
35103
]
36104
}

packages/service-library/src/servicelib/archiving_utils.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,9 @@ async def unarchive_dir(
171171
::raise ArchiveError
172172
"""
173173
if not progress_bar:
174-
progress_bar = ProgressBarData(num_steps=1)
174+
progress_bar = ProgressBarData(
175+
num_steps=1, description=f"extracting {archive_to_extract.name}"
176+
)
175177
async with AsyncExitStack() as zip_stack:
176178
zip_file_handler = zip_stack.enter_context(
177179
zipfile.ZipFile( # pylint: disable=consider-using-with
@@ -213,7 +215,7 @@ async def unarchive_dir(
213215
)
214216
async with AsyncExitStack() as progress_stack:
215217
sub_prog = await progress_stack.enter_async_context(
216-
progress_bar.sub_progress(steps=total_file_size)
218+
progress_bar.sub_progress(steps=total_file_size, description="...")
217219
)
218220
tqdm_progress = progress_stack.enter_context(
219221
tqdm.tqdm(
@@ -355,15 +357,17 @@ async def archive_dir(
355357
::raise ArchiveError
356358
"""
357359
if not progress_bar:
358-
progress_bar = ProgressBarData(num_steps=1)
360+
progress_bar = ProgressBarData(
361+
num_steps=1, description=f"compressing {dir_to_compress.name}"
362+
)
359363

360364
async with AsyncExitStack() as stack:
361365
folder_size_bytes = sum(
362366
file.stat().st_size
363367
for file in _iter_files_to_compress(dir_to_compress, exclude_patterns)
364368
)
365369
sub_progress = await stack.enter_async_context(
366-
progress_bar.sub_progress(folder_size_bytes)
370+
progress_bar.sub_progress(folder_size_bytes, description="...")
367371
)
368372
thread_pool = stack.enter_context(
369373
non_blocking_thread_pool_executor(max_workers=1)

packages/service-library/src/servicelib/docker_utils.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,8 @@ async def pull_image(
212212
assert parsed_progress.progress_detail # nosec
213213
assert parsed_progress.progress_detail.current # nosec
214214
layer_id_to_size.setdefault(
215-
parsed_progress.id, _PulledStatus(0)
215+
parsed_progress.id,
216+
_PulledStatus(parsed_progress.progress_detail.total or 0),
216217
).extracted = parsed_progress.progress_detail.current
217218
case "pull complete":
218219
assert parsed_progress.id # nosec

packages/service-library/src/servicelib/fastapi/docker_utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ async def pull_images(
123123
num_steps=images_total_size,
124124
progress_report_cb=progress_cb,
125125
progress_unit="Byte",
126+
description=f"pulling {len(images)} images",
126127
) as pbar:
127128

128129
await asyncio.gather(

packages/service-library/src/servicelib/progress_bar.py

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
from inspect import isawaitable
55
from typing import Final, Optional, Protocol, runtime_checkable
66

7-
from models_library.progress_bar import ProgressReport, ProgressUnit
7+
from models_library.progress_bar import (
8+
ProgressReport,
9+
ProgressStructuredMessage,
10+
ProgressUnit,
11+
)
812
from pydantic import parse_obj_as
913

1014
from .logging_utils import log_catch
@@ -35,9 +39,12 @@ def _normalize_weights(steps: int, weights: list[float]) -> list[float]:
3539

3640

3741
@dataclass(slots=True, kw_only=True)
38-
class ProgressBarData:
42+
class ProgressBarData: # pylint: disable=too-many-instance-attributes
3943
"""A progress bar data allows to keep track of multiple progress(es) even in deeply nested processes.
4044
45+
BEWARE: Using weights AND concurrency is a recipe for disaster as the progress bar does not know which
46+
sub progress finished. Concurrency may only be used with a single progress bar or with equal step weights!!
47+
4148
- Simple example:
4249
async def main_fct():
4350
async with ProgressBarData(num_steps=3) as root_progress_bar:
@@ -76,10 +83,11 @@ async def main_fct():
7683
"description": "Optionally defines the step relative weight (defaults to steps of equal weights)"
7784
},
7885
)
86+
description: str = field(metadata={"description": "define the progress name"})
7987
progress_unit: ProgressUnit | None = None
8088
progress_report_cb: AsyncReportCB | ReportCB | None = None
8189
_current_steps: float = _INITIAL_VALUE
82-
_children: list = field(default_factory=list)
90+
_children: list["ProgressBarData"] = field(default_factory=list)
8391
_parent: Optional["ProgressBarData"] = None
8492
_continuous_value_lock: asyncio.Lock = field(init=False)
8593
_last_report_value: float = _INITIAL_VALUE
@@ -107,23 +115,39 @@ async def _update_parent(self, value: float) -> None:
107115
if self._parent:
108116
await self._parent.update(value)
109117

110-
async def _report_external(self, value: float, *, force: bool = False) -> None:
118+
def is_running(self) -> bool:
119+
return self._current_steps < self.num_steps
120+
121+
def compute_report_message_stuct(self) -> ProgressStructuredMessage:
122+
self_report = ProgressStructuredMessage(
123+
description=self.description,
124+
current=self._current_steps,
125+
total=self.num_steps,
126+
unit=self.progress_unit,
127+
sub=None,
128+
)
129+
for child in self._children:
130+
if child.is_running():
131+
self_report.sub = child.compute_report_message_stuct()
132+
return self_report
133+
134+
async def _report_external(self, value: float) -> None:
111135
if not self.progress_report_cb:
112136
return
113137

114138
with log_catch(_logger, reraise=False):
115139
# NOTE: only report if at least a percent was increased
116140
if (
117-
(force and value != self._last_report_value)
118-
or ((value - self._last_report_value) > _MIN_PROGRESS_UPDATE_PERCENT)
119-
or value == _FINAL_VALUE
120-
):
141+
(value - self._last_report_value) > _MIN_PROGRESS_UPDATE_PERCENT
142+
) or value == _FINAL_VALUE:
143+
# compute progress string
121144
call = self.progress_report_cb(
122145
ProgressReport(
123146
# NOTE: here we convert back to actual value since this is possibly weighted
124147
actual_value=value * self.num_steps,
125148
total=self.num_steps,
126149
unit=self.progress_unit,
150+
message=self.compute_report_message_stuct(),
127151
),
128152
)
129153
if isawaitable(call):
@@ -180,13 +204,21 @@ async def finish(self) -> None:
180204
await self.set_(self.num_steps)
181205

182206
def sub_progress(
183-
self, steps: int, step_weights: list[float] | None = None
207+
self,
208+
steps: int,
209+
description: str,
210+
step_weights: list[float] | None = None,
211+
progress_unit: ProgressUnit | None = None,
184212
) -> "ProgressBarData":
185213
if len(self._children) == self.num_steps:
186214
msg = "Too many sub progresses created already. Wrong usage of the progress bar"
187215
raise RuntimeError(msg)
188216
child = ProgressBarData(
189-
num_steps=steps, step_weights=step_weights, _parent=self
217+
num_steps=steps,
218+
description=description,
219+
step_weights=step_weights,
220+
progress_unit=progress_unit,
221+
_parent=self,
190222
)
191223
self._children.append(child)
192224
return child

packages/service-library/tests/aiohttp/test_docker_utils.py

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
from collections.abc import Awaitable, Callable
77
from typing import Any
88
from unittest import mock
9-
from unittest.mock import call
109

1110
import pytest
11+
from faker import Faker
1212
from models_library.docker import DockerGenericTag
1313
from models_library.progress_bar import ProgressReport
1414
from pydantic import parse_obj_as
@@ -93,11 +93,18 @@ async def _progress_cb(*args, **kwargs) -> None:
9393
def _assert_progress_report_values(
9494
mocked_progress_cb: mock.AsyncMock, *, total: float
9595
) -> None:
96-
assert mocked_progress_cb.call_args_list[0] == call(
97-
ProgressReport(actual_value=0, total=total)
96+
# NOTE: we exclude the message part here as this is already tested in servicelib
97+
# check first progress
98+
assert mocked_progress_cb.call_args_list[0].args[0].dict(
99+
exclude={"message"}
100+
) == ProgressReport(actual_value=0, total=total, unit="Byte").dict(
101+
exclude={"message"}
98102
)
99-
assert mocked_progress_cb.call_args_list[-1] == call(
100-
ProgressReport(actual_value=total, total=total)
103+
# check last progress
104+
assert mocked_progress_cb.call_args_list[-1].args[0].dict(
105+
exclude={"message"}
106+
) == ProgressReport(actual_value=total, total=total, unit="Byte").dict(
107+
exclude={"message"}
101108
)
102109

103110

@@ -112,6 +119,7 @@ async def test_pull_image(
112119
mocked_log_cb: mock.AsyncMock,
113120
mocked_progress_cb: mock.AsyncMock,
114121
caplog: pytest.LogCaptureFixture,
122+
faker: Faker,
115123
):
116124
# clean first
117125
await remove_images_from_host([image])
@@ -121,6 +129,8 @@ async def test_pull_image(
121129
async with progress_bar.ProgressBarData(
122130
num_steps=layer_information.layers_total_size,
123131
progress_report_cb=mocked_progress_cb,
132+
progress_unit="Byte",
133+
description=faker.pystr(),
124134
) as main_progress_bar:
125135
await pull_image(
126136
image,
@@ -130,10 +140,7 @@ async def test_pull_image(
130140
layer_information,
131141
)
132142
mocked_log_cb.assert_called()
133-
assert (
134-
main_progress_bar._current_steps # noqa: SLF001
135-
== layer_information.layers_total_size
136-
)
143+
137144
_assert_progress_report_values(
138145
mocked_progress_cb, total=layer_information.layers_total_size
139146
)
@@ -149,6 +156,8 @@ async def test_pull_image(
149156
async with progress_bar.ProgressBarData(
150157
num_steps=layer_information.layers_total_size,
151158
progress_report_cb=mocked_progress_cb,
159+
description=faker.pystr(),
160+
progress_unit="Byte",
152161
) as main_progress_bar:
153162
await pull_image(
154163
image,
@@ -158,10 +167,7 @@ async def test_pull_image(
158167
layer_information,
159168
)
160169
mocked_log_cb.assert_called()
161-
assert (
162-
main_progress_bar._current_steps # noqa: SLF001
163-
== layer_information.layers_total_size
164-
)
170+
165171
_assert_progress_report_values(
166172
mocked_progress_cb, total=layer_information.layers_total_size
167173
)

0 commit comments

Comments
 (0)