From 810cec9e46804e5e9e7983c94d772154e18bdb0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Batuhan=20=C5=9Eenol?= Date: Sun, 5 Jul 2026 08:54:28 +0300 Subject: [PATCH 1/2] Fixed argparser.py:422 --- httpie/cli/argparser.py | 3 +-- tests/test_cli.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/httpie/cli/argparser.py b/httpie/cli/argparser.py index 9bf09b3b73..4df8de7813 100644 --- a/httpie/cli/argparser.py +++ b/httpie/cli/argparser.py @@ -419,8 +419,7 @@ def _guess_method(self): else: self.args.method = HTTP_GET - # FIXME: False positive, e.g., "localhost" matches but is a valid URL. - elif not re.match('^[a-zA-Z]+$', self.args.method): + elif not re.match('^[a-zA-Z]+$', self.args.method) or self.args.method.lower() == 'localhost': # Invoked as `http URL item+'. The URL is now in `args.method` # and the first ITEM is now incorrectly in `args.url`. try: diff --git a/tests/test_cli.py b/tests/test_cli.py index 2cd27574af..8006363118 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -315,6 +315,21 @@ def test_guess_when_method_set_but_invalid_and_item_exists(self): KeyValueArg( key='old_item', value='b', sep='=', orig='old_item=b'), ] + + + def test_guess_when_method_set_but_invalid_bare_hostname(self): + self.parser.args = argparse.Namespace() + self.parser.args.method = 'localhost' + self.parser.args.url = 'name=example-data' + self.parser.args.request_items = [] + self.parser.args.ignore_stdin = False + self.parser.env = MockEnvironment() + self.parser._guess_method() + assert self.parser.args.method == 'POST' + assert self.parser.args.url == 'localhost' + assert self.parser.args.request_items == [ + KeyValueArg(key='name', value='example-data', sep='=', orig='name=example-data') + ] class TestNoOptions: From 0bf4998ef2282fbae4f0d2be26dfe5008a520465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Batuhan=20=C5=9Eenol?= Date: Sun, 5 Jul 2026 09:34:13 +0300 Subject: [PATCH 2/2] Refactor _setup_standard_streams --- httpie/cli/argparser.py | 38 +++++++++--------- tests/test_stream.py | 86 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 103 insertions(+), 21 deletions(-) diff --git a/httpie/cli/argparser.py b/httpie/cli/argparser.py index 4df8de7813..be821656a5 100644 --- a/httpie/cli/argparser.py +++ b/httpie/cli/argparser.py @@ -226,44 +226,42 @@ def _process_url(self): def _setup_standard_streams(self): """ - Modify `env.stdout` and `env.stdout_isatty` based on args, if needed. - + Modify env.stdout and env.stdout_isatty based on args. """ self.args.output_file_specified = bool(self.args.output_file) + + # Select the default output stream. + target_stream = self.env.stdout + target_isatty = self.env.stdout_isatty + if self.args.download: - # FIXME: Come up with a cleaner solution. if not self.args.output_file and not self.env.stdout_isatty: - # Use stdout as the download output file. self.args.output_file = self.env.stdout - # With `--download`, we write everything that would normally go to - # `stdout` to `stderr` instead. Let's replace the stream so that - # we don't have to use many `if`s throughout the codebase. - # The response body will be treated separately. - self.env.stdout = self.env.stderr - self.env.stdout_isatty = self.env.stderr_isatty + + target_stream = self.env.stderr + target_isatty = self.env.stderr_isatty elif self.args.output_file: - # When not `--download`ing, then `--output` simply replaces - # `stdout`. The file is opened for appending, which isn't what - # we want in this case. self.args.output_file.seek(0) try: self.args.output_file.truncate() except OSError as e: - if e.errno == errno.EINVAL: - # E.g. /dev/null on Linux. - pass - else: + if e.errno != errno.EINVAL: raise - self.env.stdout = self.args.output_file - self.env.stdout_isatty = False + + target_stream = self.args.output_file + target_isatty = False + + self.env.stdout = target_stream + self.env.stdout_isatty = target_isatty if self.args.quiet: - self.env.quiet = self.args.quiet + self.env.quiet = True self.env.stderr = self.env.devnull if not (self.args.output_file_specified and not self.args.download): self.env.stdout = self.env.devnull + self.env.apply_warnings_filter() def _process_ssl_cert(self): diff --git a/tests/test_stream.py b/tests/test_stream.py index b0b9b8bde8..22baed8d7b 100644 --- a/tests/test_stream.py +++ b/tests/test_stream.py @@ -2,13 +2,14 @@ import pytest import responses -from unittest.mock import Mock +from unittest.mock import Mock, MagicMock from httpie.compat import is_windows from httpie.cli.constants import PRETTY_MAP from httpie.output.streams import BINARY_SUPPRESSED_NOTICE from httpie.plugins import ConverterPlugin from httpie.plugins.registry import plugin_manager +from httpie.cli.argparser import HTTPieArgumentParser from .utils import StdinBytesIO, http, MockEnvironment, DUMMY_URL from .fixtures import ( @@ -49,6 +50,89 @@ def test_pretty_redirected_stream(httpbin): httpbin + '/get', env=env) assert BINARY_SUPPRESSED_NOTICE.decode() in r +@pytest.fixture +def mock_env_args(): + """ + Provides an isolated mock instance containing 'args' and 'env' attributes + to safely test stream hijacking without affecting real system streams. + """ + instance = MagicMock() + + # Mock CLI arguments + instance.args.output_file = None + instance.args.download = False + instance.args.quiet = False + + # Mock Environment streams + instance.env.stdout = MagicMock() + instance.env.stdout_isatty = True + instance.env.stderr = MagicMock() + instance.env.stderr_isatty = True + instance.env.devnull = MagicMock() + instance.env.quiet = False + + # Mock warning filter + instance.env.apply_warnings_filter = MagicMock() + + return instance + + +def test_setup_streams_download_with_pipe(mock_env_args): + """ + Test that standard streams are correctly redirected when '--download' is used + and stdout is piped (isatty is False). + Expected: output_file becomes the original stdout, and stdout is hijacked to stderr. + """ + # Setup state + mock_env_args.args.download = True + mock_env_args.env.stdout_isatty = False # Simulating piped stdout + + original_stdout = mock_env_args.env.stdout + original_stderr = mock_env_args.env.stderr + original_stderr_isatty = mock_env_args.env.stderr_isatty + + # Sınıf üzerinden asıl fonksiyonu çağırıyoruz + HTTPieArgumentParser._setup_standard_streams(mock_env_args) + + # Verify stream redirection + assert mock_env_args.args.output_file == original_stdout, "output_file should be assigned to original stdout" + assert mock_env_args.env.stdout == original_stderr, "stdout should still be hijacked to stderr" + assert mock_env_args.env.stdout_isatty == original_stderr_isatty + + +def test_setup_streams_download_without_pipe(mock_env_args): + """ + Test stream behavior when '--download' is active but running in a standard TTY. + Expected: output_file remains untouched, but stdout is still hijacked to stderr. + """ + mock_env_args.args.download = True + mock_env_args.env.stdout_isatty = True # Standard TTY + + original_stderr = mock_env_args.env.stderr + + # Sınıf üzerinden asıl fonksiyonu çağırıyoruz + HTTPieArgumentParser._setup_standard_streams(mock_env_args) + + assert mock_env_args.args.output_file is None, "output_file should not be modified in TTY mode" + assert mock_env_args.env.stdout == original_stderr, "stdout should still be hijacked to stderr" + + +def test_setup_streams_output_file_resets_pointer(mock_env_args): + """ + Test that standard output files (non-download) are properly truncated + and the file pointer is reset to 0 before appending new data. + """ + mock_env_args.args.download = False + + # Mock an open file object + mock_file = MagicMock() + mock_env_args.args.output_file = mock_file + + # Sınıf üzerinden asıl fonksiyonu çağırıyoruz + HTTPieArgumentParser._setup_standard_streams(mock_env_args) + + # Verify file operations + mock_file.seek.assert_called_once_with(0) def test_pretty_stream_ensure_full_stream_is_retrieved(httpbin): env = MockEnvironment(