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
41 changes: 19 additions & 22 deletions httpie/cli/argparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -419,8 +417,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:
Expand Down
15 changes: 15 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
86 changes: 85 additions & 1 deletion tests/test_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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(
Expand Down
Loading