diff --git a/httpie/cli/utils.py b/httpie/cli/utils.py index ad27da37f7..956dd0e9dd 100644 --- a/httpie/cli/utils.py +++ b/httpie/cli/utils.py @@ -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__( @@ -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(), diff --git a/httpie/downloads.py b/httpie/downloads.py index 9c4b895e6f..22bf6214b7 100644 --- a/httpie/downloads.py +++ b/httpie/downloads.py @@ -216,12 +216,21 @@ def start( """ assert not self.status.time_started - # FIXME: some servers still might sent Content-Encoding: gzip - # - 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 . + 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( diff --git a/tests/test_cli_utils.py b/tests/test_cli_utils.py index 8041727b57..1e09b25802 100644 --- a/tests/test_cli_utils.py +++ b/tests/test_cli_utils.py @@ -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(): @@ -69,10 +70,19 @@ 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() @@ -80,7 +90,10 @@ def test_lazy_choices_help(): 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) diff --git a/tests/test_downloads.py b/tests/test_downloads.py index b646a0e6a5..6396693c0d 100644 --- a/tests/test_downloads.py +++ b/tests/test_downloads.py @@ -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'