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
8 changes: 7 additions & 1 deletion httpie/cli/utils.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import argparse
import sys
from typing import Any, Callable, Generic, Iterator, Iterable, Optional, TypeVar

T = TypeVar('T')

# Python 3.14 added an eager _check_help() call in add_argument() that
# validates the help string immediately (see cpython#65865). This causes
# LazyChoices.help to invoke the getter at argument-registration time.
_ARGPARSE_CHECKS_HELP_EAGERLY = sys.version_info >= (3, 14)


class Manual(argparse.Action):
def __init__(
Expand Down Expand Up @@ -54,7 +60,7 @@ def load(self) -> T:
return self._obj

@property
def help(self) -> str:
def help(self) -> Optional[str]:
if self._help is None and self.help_formatter is not None:
self._help = self.help_formatter(
self.load(),
Expand Down
19 changes: 14 additions & 5 deletions httpie/downloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,12 +216,21 @@ def start(
"""
assert not self.status.time_started

# FIXME: some servers still might sent Content-Encoding: gzip
# <https://github.com/httpie/cli/issues/423>
try:
total_size = int(final_response.headers['Content-Length'])
except (KeyError, ValueError, TypeError):
# When Content-Encoding is present (e.g. gzip), the `requests` library
# transparently decodes the response body, so the bytes written to disk
# will be the *decompressed* size — which typically exceeds the
# Content-Length value (which reflects the compressed wire size).
# Using Content-Length as the total size in that case would make us
# flag the download as incomplete even when everything is fine.
# See <https://github.com/httpie/cli/issues/1642>.
content_encoding = final_response.headers.get('Content-Encoding', '').strip()
if content_encoding:
total_size = None
else:
try:
total_size = int(final_response.headers['Content-Length'])
except (KeyError, ValueError, TypeError):
total_size = None

if not self._output_file:
self._output_file = self._get_output_file_from_response(
Expand Down
27 changes: 20 additions & 7 deletions tests/test_cli_utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import sys
import pytest
from argparse import ArgumentParser
from unittest.mock import Mock
from httpie.cli.utils import LazyChoices
from httpie.cli.utils import LazyChoices, _ARGPARSE_CHECKS_HELP_EAGERLY


def test_lazy_choices():
Expand Down Expand Up @@ -69,18 +70,30 @@ def test_lazy_choices_help():
cache=False # for test purposes
)

# Parser initialization doesn't call it.
getter.assert_not_called()

# If we don't use `--help`, we don't use it.
# Python 3.14 added an eager _check_help() call in add_argument() that
# validates the help string immediately (cpython#65865). On 3.14+, the
# getter will therefore be invoked once at registration time.
if _ARGPARSE_CHECKS_HELP_EAGERLY:
getter.assert_called_once()
getter.reset_mock()
help_formatter.assert_called_once()
help_formatter.reset_mock()
else:
# Parser initialization must not call getter or help_formatter.
getter.assert_not_called()

# If we don't use `--help`, we don't use them beyond registration.
parser.parse_args([])
getter.assert_not_called()
help_formatter.assert_not_called()

parser.parse_args(['--lazy-option', 'b'])
help_formatter.assert_not_called()

# If we use --help, then we call it with styles
# If we use --help, then we call it with styles.
# On Python 3.14+, help_formatter was already called during registration
# and the cached result is reused — it should not be called again.
with pytest.raises(SystemExit):
parser.parse_args(['--help'])
help_formatter.assert_called_once_with(['a', 'b', 'c'], isolation_mode=False)
if not _ARGPARSE_CHECKS_HELP_EAGERLY:
help_formatter.assert_called_once_with(['a', 'b', 'c'], isolation_mode=False)
25 changes: 25 additions & 0 deletions tests/test_downloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,31 @@ def test_download_resumed(self, mock_env, httpbin_both):
downloader.chunk_downloaded(b'45')
downloader.finish()

def test_download_with_content_encoding_does_not_report_incomplete(self, mock_env, httpbin_both):
"""Regression test for https://github.com/httpie/cli/issues/1642.

When Content-Encoding (e.g. gzip) is present, the `requests` library
transparently decodes the body, so the bytes written to disk may exceed
the compressed Content-Length. The downloader must not report the
download as incomplete in that case.
"""
with open(os.devnull, 'w') as devnull:
downloader = Downloader(mock_env, output_file=devnull)
downloader.start(
final_response=Response(
url=httpbin_both.url + '/',
headers={
'Content-Length': 5, # compressed size
'Content-Encoding': 'gzip',
}
),
initial_url='/'
)
# Simulate receiving more decompressed bytes than Content-Length.
downloader.chunk_downloaded(b'12345678') # 8 bytes > 5 bytes
downloader.finish()
assert not downloader.interrupted

def test_download_with_redirect_original_url_used_for_filename(self, httpbin):
# Redirect from `/redirect/1` to `/get`.
expected_filename = '1.json'
Expand Down
Loading