Skip to content
Draft
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
38 changes: 38 additions & 0 deletions httpie/cli/argparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,10 @@ def _parse_items(self):
self.args.params = request_items.params
self.args.multipart_data = request_items.multipart_data

# Process --headers-file if provided
if getattr(self.args, 'headers_file', None):
self._parse_headers_file()

if self.args.files and not self.args.form:
# `http url @/path/to/file`
request_file = None
Expand All @@ -489,6 +493,40 @@ def _parse_items(self):
if content_type:
self.args.headers['Content-Type'] = content_type

def _parse_headers_file(self):
"""
Parse headers from a file specified by --headers-file.

Each line should be in "Name: Value" format.
Empty lines and lines starting with '#' are ignored.
"""
headers_file = self.args.headers_file
try:
with open(os.path.expanduser(headers_file), 'r', encoding='utf-8') as f:
for line_num, line in enumerate(f, 1):
line = line.strip()
# Skip empty lines and comments
if not line or line.startswith('#'):
continue
# Parse "Name: Value" format
if ':' not in line:
self.error(
f'{headers_file}:{line_num}: Invalid header format. '
f'Expected "Name: Value", got: {line!r}'
)
name, value = line.split(':', 1)
name = name.strip()
value = value.strip()
if not name:
self.error(
f'{headers_file}:{line_num}: Empty header name in: {line!r}'
)
# Add header (headers from file have lower priority than CLI headers)
if name not in self.args.headers:
self.args.headers[name] = value
except OSError as e:
self.error(f'Cannot read headers file: {headers_file}: {e}')

def _process_output_options(self):
"""Apply defaults to output options, or validate the provided ones.

Expand Down
30 changes: 30 additions & 0 deletions httpie/cli/definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,36 @@ def format_style_help(available_styles, *, isolation_mode: bool = False):
)


#######################################################################
# Headers from file
#######################################################################

headers_from_file = options.add_group('Headers from file')

headers_from_file.add_argument(
'--headers-file',
dest='headers_file',
metavar='HEADERS_FILE',
default=None,
short_help='Read headers from a file.',
help="""
Read multiple headers from a file. The file should contain one header
per line in the format "Name: Value". Empty lines and lines starting
with '#' are ignored.

Example file content:

Authorization: Bearer token123
X-Custom-Header: value
# This is a comment
Accept: application/json

This is similar to curl's ability to read headers from a file with -H @file.

""",
)


#######################################################################
# Authentication
#######################################################################
Expand Down
140 changes: 140 additions & 0 deletions tests/test_headers_file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""Tests for --headers-file feature."""
import os
import tempfile
import pytest
from httpie.status import ExitStatus


class TestHeadersFile:
"""Test cases for --headers-file argument."""

def test_headers_file_basic(self, httpbin):
"""Test reading headers from a file."""
from tests.utils import http

with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write("X-Custom-Header: custom-value\n")
f.write("X-Another-Header: another-value\n")
headers_file = f.name

try:
r = http('--headers-file', headers_file, httpbin.url + '/headers')
assert 'X-Custom-Header' in r
assert 'custom-value' in r
assert 'X-Another-Header' in r
assert 'another-value' in r
finally:
os.unlink(headers_file)

def test_headers_file_with_comments(self, httpbin):
"""Test that comment lines (starting with #) are ignored."""
from tests.utils import http

with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write("# This is a comment\n")
f.write("X-Valid-Header: valid-value\n")
f.write("# Another comment\n")
headers_file = f.name

try:
r = http('--headers-file', headers_file, httpbin.url + '/headers')
assert 'X-Valid-Header' in r
assert 'valid-value' in r
# Comments should not appear as headers
assert 'This is a comment' not in r
finally:
os.unlink(headers_file)

def test_headers_file_with_empty_lines(self, httpbin):
"""Test that empty lines are ignored."""
from tests.utils import http

with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write("X-First-Header: first-value\n")
f.write("\n")
f.write(" \n") # whitespace only
f.write("X-Second-Header: second-value\n")
headers_file = f.name

try:
r = http('--headers-file', headers_file, httpbin.url + '/headers')
assert 'X-First-Header' in r
assert 'X-Second-Header' in r
finally:
os.unlink(headers_file)

def test_headers_file_cli_headers_take_precedence(self, httpbin):
"""Test that CLI headers override headers from file."""
from tests.utils import http

with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write("X-Override: file-value\n")
headers_file = f.name

try:
r = http('--headers-file', headers_file,
httpbin.url + '/headers', 'X-Override:cli-value')
assert 'cli-value' in r
assert 'file-value' not in r
finally:
os.unlink(headers_file)

def test_headers_file_nonexistent_file(self):
"""Test error when headers file doesn't exist."""
from tests.utils import http, MockEnvironment

env = MockEnvironment()
r = http('--headers-file', '/nonexistent/path/headers.txt',
'https://example.com', env=env, tolerate_error_exit_status=True)
assert r.exit_status == ExitStatus.ERROR
assert 'Cannot read headers file' in r.stderr

def test_headers_file_invalid_format(self):
"""Test error when header format is invalid (no colon)."""
from tests.utils import http, MockEnvironment

with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write("Invalid header without colon\n")
headers_file = f.name

try:
env = MockEnvironment()
r = http('--headers-file', headers_file, 'https://example.com',
env=env, tolerate_error_exit_status=True)
assert r.exit_status == ExitStatus.ERROR
assert 'Invalid' in r.stderr and 'header format' in r.stderr
finally:
os.unlink(headers_file)

def test_headers_file_header_with_colon_in_value(self, httpbin):
"""Test headers with colons in the value (e.g., Authorization: Bearer token)."""
from tests.utils import http

with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write("Authorization: Bearer token:with:colons\n")
f.write("X-URL: https://example.com:8080/path\n")
headers_file = f.name

try:
r = http('--headers-file', headers_file, httpbin.url + '/headers')
assert 'Authorization' in r
assert 'Bearer token:with:colons' in r
assert 'https://example.com:8080/path' in r
finally:
os.unlink(headers_file)

def test_headers_file_multiple_headers_same_name(self, httpbin):
"""Test multiple headers with the same name (last one wins in file)."""
from tests.utils import http

with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write("X-Duplicate: first-value\n")
f.write("X-Duplicate: second-value\n")
headers_file = f.name

try:
r = http('--headers-file', headers_file, httpbin.url + '/headers')
# The second value should be used (or both depending on implementation)
assert 'X-Duplicate' in r
finally:
os.unlink(headers_file)
Loading