diff --git a/CHANGELOG.md b/CHANGELOG.md index 3955961b..9925f945 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - `cloudsmith credential-helper install` and `uninstall` no longer rewrite a config file when they make no change to its data. Previously, the CLI always rewrote a file in a different formatting, such as the tab-indented `config.json` that Docker writes. The command also made a `.bak` copy and reported changes that it did not make. - When the API host name or the proxy host name does not exist, for example because of a typo in `--api-host`, commands now fail immediately. Previously, they retried for approximately 62 seconds without output. The CLI still retries a DNS failure that the operating system reports as temporary. - When the CLI waits before it retries a failed connection or a 5xx response, it now writes `Request failed: retrying in N second(s) ...` to stderr. Previously, it waited without output. +- The `cloudsmith push` progress bar for a single-part upload now shows the real upload progress. Before, it showed 100% long before the upload was complete. - `cloudsmith check rates` no longer stops with `TypeError` when the API does not give a value for an interval, limit, remaining count, reset time or throttled status. The command now shows `None` for that value. ## [1.30.0] - 2026-10-22 diff --git a/cloudsmith_cli/cli/commands/push.py b/cloudsmith_cli/cli/commands/push.py index 3cd83111..70581878 100644 --- a/cloudsmith_cli/cli/commands/push.py +++ b/cloudsmith_cli/cli/commands/push.py @@ -10,6 +10,7 @@ from datetime import UTC, datetime import click +from requests_toolbelt import MultipartEncoderMonitor from ...core import utils as core_utils from ...core.api.exceptions import ApiException @@ -552,8 +553,15 @@ def upload_file(ctx, opts, owner, repo, filepath, skip_errors, md5_checksum): empty_char=click.style("-", fg="red"), ) as pb: - def progress_callback(monitor): - pb.update(monitor.bytes_read) + def progress_callback(monitor: MultipartEncoderMonitor) -> None: + if monitor.len <= 0: + return + # The body also holds the form fields, so scale it to the + # file. A rewound body counts again from 0, so do not move + # the bar back. + bytes_read = min(max(monitor.bytes_read, 0), monitor.len) + uploaded = bytes_read * filesize // monitor.len + pb.update(max(uploaded - pb.pos, 0)) api_upload_file( upload_url=upload_url, diff --git a/cloudsmith_cli/cli/tests/test_push.py b/cloudsmith_cli/cli/tests/test_push.py index 16deab72..abccb9a1 100644 --- a/cloudsmith_cli/cli/tests/test_push.py +++ b/cloudsmith_cli/cli/tests/test_push.py @@ -1,4 +1,5 @@ # pylint: disable=too-many-lines +import io import json import os import tempfile @@ -8,12 +9,14 @@ import click import pytest +from requests_toolbelt import MultipartEncoder, MultipartEncoderMonitor from ...core.api.exceptions import ApiException from ..commands.push import ( _print_metadata_retry_hint, attach_metadata_to_package, resolve_push_metadata_options, + upload_file, upload_files_and_create_package, validate_metadata_payload, wait_for_package_sync, @@ -1525,3 +1528,61 @@ def test_wait_for_package_sync_json_mode_prints_status_reason_on_failure(capsys) assert envelope["detail"] == status_reason assert envelope["meta"]["stage"] == "Parsing Package Metadata" assert envelope["meta"]["status"] == "Failed" + + +def _push_single_part(tmp_path, failed_reads): + """Push a small file, and return the bar length and the progress per read.""" + filepath = tmp_path / "package.raw" + filepath.write_bytes(b"payload" * 3000) + bars = [] + progress = [] + real_progressbar = click.progressbar + + def quiet_progressbar(**kwargs): + bars.append(real_progressbar(file=io.StringIO(), **kwargs)) + return bars[-1] + + def fake_upload(upload_url, upload_fields, filepath, callback): + for reads_before_failure in (*failed_reads, None): + with open(filepath, "rb") as file_obj: + fields = [*upload_fields.items(), ("file", ("package.raw", file_obj))] + monitor = MultipartEncoderMonitor(MultipartEncoder(fields), callback) + while monitor.read(1024) and len(progress) != reads_before_failure: + progress.append((monitor.bytes_read, monitor.len, bars[0].pos)) + + with ( + patch( + "cloudsmith_cli.cli.commands.push.request_file_upload", + return_value=("id", "https://upload.example.invalid/", {"key": "value"}), + ), + patch( + "cloudsmith_cli.cli.commands.push.api_upload_file", side_effect=fake_upload + ), + patch("click.progressbar", quiet_progressbar), + ): + upload_file( + MagicMock(), + SimpleNamespace(output=None, debug=True), + "owner", + "repo", + str(filepath), + False, + "md5", + ) + + return bars[0].length, progress + + +def test_single_part_progress_is_full_only_when_the_body_is_read(tmp_path): + length, progress = _push_single_part(tmp_path, failed_reads=()) + + for bytes_read, body_length, position in progress: + assert (position == length) == (bytes_read == body_length) + + +def test_single_part_progress_does_not_go_back_on_a_retry(tmp_path): + length, progress = _push_single_part(tmp_path, failed_reads=(10,)) + positions = [position for _, _, position in progress] + + assert positions == sorted(positions) + assert positions[-1] == length