From b0d4326ffb92a8dfad0691b0ea473e203970be8a Mon Sep 17 00:00:00 2001 From: Atul Date: Sun, 8 Feb 2026 23:29:26 +0530 Subject: [PATCH] feat: Add --headers-file flag to read multiple headers from a file Fixes #1595 This adds the ability to read multiple headers from a single file, similar to curl's -H @file functionality. Features: - New --headers-file argument to specify a headers file - File format: 'Name: Value' per line - Empty lines and lines starting with '#' are treated as comments - Headers from file have lower priority than CLI-specified headers - Error handling for missing files and invalid formats Example usage: $ http --headers-file=headers.txt example.com Where headers.txt contains: Authorization: Bearer token123 X-Custom-Header: value # This is a comment Accept: application/json --- httpie/cli/argparser.py | 38 ++++++++++ httpie/cli/definition.py | 30 ++++++++ tests/test_headers_file.py | 140 +++++++++++++++++++++++++++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 tests/test_headers_file.py diff --git a/httpie/cli/argparser.py b/httpie/cli/argparser.py index 9bf09b3b73..3ef8dfa358 100644 --- a/httpie/cli/argparser.py +++ b/httpie/cli/argparser.py @@ -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 @@ -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. diff --git a/httpie/cli/definition.py b/httpie/cli/definition.py index 843b29c9cf..d6956c5282 100644 --- a/httpie/cli/definition.py +++ b/httpie/cli/definition.py @@ -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 ####################################################################### diff --git a/tests/test_headers_file.py b/tests/test_headers_file.py new file mode 100644 index 0000000000..9b1c551427 --- /dev/null +++ b/tests/test_headers_file.py @@ -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)