Skip to content

Commit ee11b6c

Browse files
author
Andrei Neagu
committed
added progress reset to progress bar
1 parent 2460775 commit ee11b6c

File tree

3 files changed

+110
-58
lines changed

3 files changed

+110
-58
lines changed

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

Lines changed: 54 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import logging
23
from collections.abc import Awaitable, Callable
34
from contextlib import AsyncExitStack
@@ -13,6 +14,12 @@
1314
from models_library.utils.change_case import snake_to_camel
1415
from pydantic import BaseModel, ByteSize, ConfigDict, TypeAdapter, ValidationError
1516
from settings_library.docker_registry import RegistrySettings
17+
from tenacity import (
18+
AsyncRetrying,
19+
retry_if_exception_type,
20+
stop_after_attempt,
21+
wait_random_exponential,
22+
)
1623
from yarl import URL
1724

1825
from .logging_utils import LogLevelInt
@@ -245,39 +252,51 @@ async def pull_image(
245252

246253
client = await exit_stack.enter_async_context(aiodocker.Docker())
247254

248-
reported_progress = 0.0
249-
async for pull_progress in client.images.pull(
250-
image, stream=True, auth=registry_auth
255+
async for attempt in AsyncRetrying(
256+
wait=wait_random_exponential(),
257+
stop=stop_after_attempt(3),
258+
reraise=True,
259+
retry=retry_if_exception_type(asyncio.TimeoutError),
251260
):
252-
try:
253-
parsed_progress = TypeAdapter(_DockerPullImage).validate_python(
254-
pull_progress
255-
)
256-
except ValidationError:
257-
_logger.exception(
258-
"Unexpected error while validating '%s'. "
259-
"TIP: This is probably an unforeseen pull status text that shall be added to the code. "
260-
"The pulling process will still continue.",
261-
f"{pull_progress=}",
262-
)
263-
else:
264-
await _parse_pull_information(
265-
parsed_progress, layer_id_to_size=layer_id_to_size
266-
)
267-
268-
# compute total progress
269-
total_downloaded_size = sum(
270-
layer.downloaded for layer in layer_id_to_size.values()
271-
)
272-
total_extracted_size = sum(
273-
layer.extracted for layer in layer_id_to_size.values()
274-
)
275-
total_progress = (total_downloaded_size + total_extracted_size) / 2.0
276-
progress_to_report = total_progress - reported_progress
277-
await progress_bar.update(progress_to_report)
278-
reported_progress = total_progress
279-
280-
await log_cb(
281-
f"pulling {image_short_name}: {pull_progress}...",
282-
logging.DEBUG,
283-
)
261+
# each time there is an error progress start from zero again
262+
progress_bar.reset_progress()
263+
264+
with attempt:
265+
reported_progress = 0.0
266+
async for pull_progress in client.images.pull(
267+
image, stream=True, auth=registry_auth
268+
):
269+
try:
270+
parsed_progress = TypeAdapter(_DockerPullImage).validate_python(
271+
pull_progress
272+
)
273+
except ValidationError:
274+
_logger.exception(
275+
"Unexpected error while validating '%s'. "
276+
"TIP: This is probably an unforeseen pull status text that shall be added to the code. "
277+
"The pulling process will still continue.",
278+
f"{pull_progress=}",
279+
)
280+
else:
281+
await _parse_pull_information(
282+
parsed_progress, layer_id_to_size=layer_id_to_size
283+
)
284+
285+
# compute total progress
286+
total_downloaded_size = sum(
287+
layer.downloaded for layer in layer_id_to_size.values()
288+
)
289+
total_extracted_size = sum(
290+
layer.extracted for layer in layer_id_to_size.values()
291+
)
292+
total_progress = (
293+
total_downloaded_size + total_extracted_size
294+
) / 2.0
295+
progress_to_report = total_progress - reported_progress
296+
await progress_bar.update(progress_to_report)
297+
reported_progress = total_progress
298+
299+
await log_cb(
300+
f"pulling {image_short_name}: {pull_progress}...",
301+
logging.DEBUG,
302+
)

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,9 @@ async def update(self, steps: float = 1) -> None:
197197
await self._update_parent(parent_update_value)
198198
await self._report_external(new_progress_value)
199199

200+
def reset_progress(self) -> None:
201+
self._current_steps = _INITIAL_VALUE
202+
200203
async def set_(self, new_value: float) -> None:
201204
await self.update(new_value - self._current_steps)
202205

packages/service-library/tests/test_progress_bar.py

Lines changed: 53 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import pytest
1111
from faker import Faker
12+
from models_library.basic_types import IDStr
1213
from models_library.progress_bar import ProgressReport, ProgressStructuredMessage
1314
from pydantic import ValidationError
1415
from pytest_mock import MockerFixture
@@ -54,7 +55,7 @@ async def test_progress_bar_progress_report_cb(
5455
num_steps=outer_num_steps,
5556
progress_report_cb=mocked_cb,
5657
progress_unit="Byte",
57-
description=faker.pystr(),
58+
description=IDStr(faker.pystr()),
5859
) as root:
5960
assert root.num_steps == outer_num_steps
6061
assert root.step_weights is None # i.e. all steps have equal weight
@@ -96,7 +97,7 @@ async def test_progress_bar_progress_report_cb(
9697
# 2nd step is a sub progress bar of 10 steps
9798
inner_num_steps_step2 = 100
9899
async with root.sub_progress(
99-
steps=inner_num_steps_step2, description=faker.pystr()
100+
steps=inner_num_steps_step2, description=IDStr(faker.pystr())
100101
) as sub:
101102
assert sub._current_steps == pytest.approx(0) # noqa: SLF001
102103
assert root._current_steps == pytest.approx(1) # noqa: SLF001
@@ -125,7 +126,7 @@ async def test_progress_bar_progress_report_cb(
125126
# 3rd step is another subprogress of 50 steps
126127
inner_num_steps_step3 = 50
127128
async with root.sub_progress(
128-
steps=inner_num_steps_step3, description=faker.pystr()
129+
steps=inner_num_steps_step3, description=IDStr(faker.pystr())
129130
) as sub:
130131
assert sub._current_steps == pytest.approx(0) # noqa: SLF001
131132
assert root._current_steps == pytest.approx(2) # noqa: SLF001
@@ -147,7 +148,7 @@ async def test_progress_bar_progress_report_cb(
147148
def test_creating_progress_bar_with_invalid_unit_fails(faker: Faker):
148149
with pytest.raises(ValidationError):
149150
ProgressBarData(
150-
num_steps=321, progress_unit="invalid", description=faker.pystr()
151+
num_steps=321, progress_unit="invalid", description=IDStr(faker.pystr())
151152
)
152153

153154

@@ -158,7 +159,7 @@ async def test_progress_bar_always_reports_0_on_creation_and_1_on_finish(
158159
progress_bar = ProgressBarData(
159160
num_steps=num_steps,
160161
progress_report_cb=mocked_progress_bar_cb,
161-
description=faker.pystr(),
162+
description=IDStr(faker.pystr()),
162163
)
163164
assert progress_bar._current_steps == _INITIAL_VALUE # noqa: SLF001
164165
async with progress_bar as root:
@@ -206,7 +207,7 @@ async def test_progress_bar_always_reports_1_on_finish(
206207
progress_bar = ProgressBarData(
207208
num_steps=num_steps,
208209
progress_report_cb=mocked_progress_bar_cb,
209-
description=faker.pystr(),
210+
description=IDStr(faker.pystr()),
210211
)
211212
assert progress_bar._current_steps == _INITIAL_VALUE # noqa: SLF001
212213
async with progress_bar as root:
@@ -248,7 +249,7 @@ async def test_progress_bar_always_reports_1_on_finish(
248249

249250

250251
async def test_set_progress(caplog: pytest.LogCaptureFixture, faker: Faker):
251-
async with ProgressBarData(num_steps=50, description=faker.pystr()) as root:
252+
async with ProgressBarData(num_steps=50, description=IDStr(faker.pystr())) as root:
252253
assert root._current_steps == pytest.approx(0) # noqa: SLF001
253254
assert root.num_steps == 50
254255
assert root.step_weights is None
@@ -262,43 +263,72 @@ async def test_set_progress(caplog: pytest.LogCaptureFixture, faker: Faker):
262263
assert "TIP:" in caplog.messages[0]
263264

264265

266+
async def test_reset_progress(caplog: pytest.LogCaptureFixture, faker: Faker):
267+
async with ProgressBarData(num_steps=50, description=IDStr(faker.pystr())) as root:
268+
assert root._current_steps == pytest.approx(0) # noqa: SLF001
269+
assert root.num_steps == 50
270+
assert root.step_weights is None
271+
await root.set_(50)
272+
assert root._current_steps == pytest.approx(50) # noqa: SLF001
273+
assert "already reached maximum" not in caplog.text
274+
await root.set_(51)
275+
assert root._current_steps == pytest.approx(50) # noqa: SLF001
276+
assert "already reached maximum" in caplog.text
277+
278+
caplog.clear()
279+
root.reset_progress()
280+
281+
assert root._current_steps == pytest.approx(-1) # noqa: SLF001
282+
assert "already reached maximum" not in caplog.text
283+
284+
await root.set_(12)
285+
assert root._current_steps == pytest.approx(12) # noqa: SLF001
286+
assert "already reached maximum" not in caplog.text
287+
288+
await root.set_(51)
289+
assert root._current_steps == pytest.approx(50) # noqa: SLF001
290+
assert "already reached maximum" in caplog.text
291+
292+
265293
async def test_concurrent_progress_bar(faker: Faker):
266294
async def do_something(root: ProgressBarData):
267-
async with root.sub_progress(steps=50, description=faker.pystr()) as sub:
295+
async with root.sub_progress(steps=50, description=IDStr(faker.pystr())) as sub:
268296
assert sub.num_steps == 50
269297
assert sub.step_weights is None
270298
assert sub._current_steps == 0 # noqa: SLF001
271299
for n in range(50):
272300
await sub.update()
273301
assert sub._current_steps == (n + 1) # noqa: SLF001
274302

275-
async with ProgressBarData(num_steps=12, description=faker.pystr()) as root:
303+
async with ProgressBarData(num_steps=12, description=IDStr(faker.pystr())) as root:
276304
assert root._current_steps == pytest.approx(0) # noqa: SLF001
277305
assert root.step_weights is None
278306
await asyncio.gather(*[do_something(root) for n in range(12)])
279307
assert root._current_steps == pytest.approx(12) # noqa: SLF001
280308

281309

282310
async def test_too_many_sub_progress_bars_raises(faker: Faker):
283-
async with ProgressBarData(num_steps=2, description=faker.pystr()) as root:
311+
async with ProgressBarData(num_steps=2, description=IDStr(faker.pystr())) as root:
284312
assert root.num_steps == 2
285313
assert root.step_weights is None
286-
async with root.sub_progress(steps=50, description=faker.pystr()) as sub:
314+
async with root.sub_progress(steps=50, description=IDStr(faker.pystr())) as sub:
287315
for _ in range(50):
288316
await sub.update()
289-
async with root.sub_progress(steps=50, description=faker.pystr()) as sub:
317+
async with root.sub_progress(steps=50, description=IDStr(faker.pystr())) as sub:
290318
for _ in range(50):
291319
await sub.update()
292320

293321
with pytest.raises(RuntimeError):
294-
async with root.sub_progress(steps=50, description=faker.pystr()) as sub:
322+
async with root.sub_progress(
323+
steps=50, description=IDStr(faker.pystr())
324+
) as sub:
295325
...
296326

297327

298328
async def test_too_many_updates_does_not_raise_but_show_warning_with_stack(
299329
caplog: pytest.LogCaptureFixture, faker: Faker
300330
):
301-
async with ProgressBarData(num_steps=2, description=faker.pystr()) as root:
331+
async with ProgressBarData(num_steps=2, description=IDStr(faker.pystr())) as root:
302332
assert root.num_steps == 2
303333
assert root.step_weights is None
304334
await root.update()
@@ -314,7 +344,7 @@ async def test_weighted_progress_bar(mocked_progress_bar_cb: mock.Mock, faker: F
314344
num_steps=outer_num_steps,
315345
step_weights=[1, 3, 1],
316346
progress_report_cb=mocked_progress_bar_cb,
317-
description=faker.pystr(),
347+
description=IDStr(faker.pystr()),
318348
) as root:
319349
mocked_progress_bar_cb.assert_called_once_with(
320350
ProgressReport(
@@ -369,7 +399,7 @@ async def test_weighted_progress_bar_with_weighted_sub_progress(
369399
num_steps=outer_num_steps,
370400
step_weights=[1, 3, 1],
371401
progress_report_cb=mocked_progress_bar_cb,
372-
description=faker.pystr(),
402+
description=IDStr(faker.pystr()),
373403
) as root:
374404
mocked_progress_bar_cb.assert_called_once_with(
375405
ProgressReport(
@@ -396,7 +426,7 @@ async def test_weighted_progress_bar_with_weighted_sub_progress(
396426

397427
# 2nd step is a sub progress bar of 5 steps
398428
async with root.sub_progress(
399-
steps=5, step_weights=[2, 5, 1, 2, 3], description=faker.pystr()
429+
steps=5, step_weights=[2, 5, 1, 2, 3], description=IDStr(faker.pystr())
400430
) as sub:
401431
assert sub.step_weights == [2 / 13, 5 / 13, 1 / 13, 2 / 13, 3 / 13, 0]
402432
assert sub._current_steps == pytest.approx(0) # noqa: SLF001
@@ -457,7 +487,7 @@ async def test_weighted_progress_bar_with_weighted_sub_progress(
457487
async def test_weighted_progress_bar_wrong_num_weights_raises(faker: Faker):
458488
with pytest.raises(RuntimeError):
459489
async with ProgressBarData(
460-
num_steps=3, step_weights=[3, 1], description=faker.pystr()
490+
num_steps=3, step_weights=[3, 1], description=IDStr(faker.pystr())
461491
):
462492
...
463493

@@ -466,7 +496,7 @@ async def test_weighted_progress_bar_with_0_weights_is_equivalent_to_standard_pr
466496
faker: Faker,
467497
):
468498
async with ProgressBarData(
469-
num_steps=3, step_weights=[0, 0, 0], description=faker.pystr()
499+
num_steps=3, step_weights=[0, 0, 0], description=IDStr(faker.pystr())
470500
) as root:
471501
assert root.step_weights == [1, 1, 1, 0]
472502

@@ -479,13 +509,13 @@ async def test_concurrent_sub_progress_update_correct_sub_progress(
479509
num_steps=3,
480510
step_weights=[3, 1, 2],
481511
progress_report_cb=mocked_progress_bar_cb,
482-
description=faker.pystr(),
512+
description=IDStr(faker.pystr()),
483513
) as root:
484-
sub_progress1 = root.sub_progress(23, description=faker.pystr())
514+
sub_progress1 = root.sub_progress(23, description=IDStr(faker.pystr()))
485515
assert sub_progress1._current_steps == _INITIAL_VALUE # noqa: SLF001
486-
sub_progress2 = root.sub_progress(45, description=faker.pystr())
516+
sub_progress2 = root.sub_progress(45, description=IDStr(faker.pystr()))
487517
assert sub_progress2._current_steps == _INITIAL_VALUE # noqa: SLF001
488-
sub_progress3 = root.sub_progress(12, description=faker.pystr())
518+
sub_progress3 = root.sub_progress(12, description=IDStr(faker.pystr()))
489519
assert sub_progress3._current_steps == _INITIAL_VALUE # noqa: SLF001
490520

491521
# NOTE: in a gather call there is no control on which step finishes first

0 commit comments

Comments
 (0)