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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions cloudsmith_cli/cli/commands/push.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
61 changes: 61 additions & 0 deletions cloudsmith_cli/cli/tests/test_push.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# pylint: disable=too-many-lines
import io
import json
import os
import tempfile
Expand All @@ -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,
Expand Down Expand Up @@ -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
Loading