diff --git a/httpie/cli/argparser.py b/httpie/cli/argparser.py index 9bf09b3b73..c87b83b110 100644 --- a/httpie/cli/argparser.py +++ b/httpie/cli/argparser.py @@ -377,7 +377,32 @@ def _apply_no_options(self, no_options): invalid.append(option) if invalid: - self.error(f'unrecognized arguments: {" ".join(invalid)}') + # When options are placed between METHOD and URL, e.g.: + # http POST --auth-type bearer --auth TOKEN https://example.org + # argparse may fail to associate the URL with the correct + # positional argument. The method string ends up in args.url, + # and the real URL (plus any request items) lands here. + if ( + self.args.method is None + and re.match(r'^[a-zA-Z]+$', self.args.url) + ): + self.args.method = self.args.url.upper() + self.args.url = invalid.pop(0) + if invalid: + if self.args.request_items is None: + self.args.request_items = [] + item_type = KeyValueArgType( + *SEPARATOR_GROUP_ALL_ITEMS) + for arg in invalid: + try: + self.args.request_items.append( + item_type(arg)) + except argparse.ArgumentTypeError: + self.error( + f'unrecognized arguments: {arg}') + else: + self.error( + f'unrecognized arguments: {" ".join(invalid)}') def _body_from_file(self, fd): """Read the data from a file-like object. @@ -412,12 +437,23 @@ def _guess_method(self): """ if self.args.method is None: - # Invoked as `http URL'. - assert not self.args.request_items + if self.args.request_items: + # On some Python versions (3.13+), argparse may put the URL + # into request_items instead of no_options. Detect and fix. + if re.match(r'^[a-zA-Z]+$', self.args.url): + self.args.method = self.args.url.upper() + self.args.url = self.args.request_items.pop(0).orig + else: + self.error( + 'No valid HTTP method found but request items ' + 'are present. Please specify the method ' + 'explicitly, e.g. `http GET URL` or ' + '`http POST URL data=value`.' + ) if self.has_input_data: - self.args.method = HTTP_POST + self.args.method = self.args.method or HTTP_POST else: - self.args.method = HTTP_GET + self.args.method = self.args.method or HTTP_GET # FIXME: False positive, e.g., "localhost" matches but is a valid URL. elif not re.match('^[a-zA-Z]+$', self.args.method): diff --git a/tests/test_cli.py b/tests/test_cli.py index 2cd27574af..45a8164e2f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -316,6 +316,37 @@ def test_guess_when_method_set_but_invalid_and_item_exists(self): key='old_item', value='b', sep='=', orig='old_item=b'), ] + def test_guess_when_method_none_url_is_method_and_url_in_request_items(self): + self.parser.args = argparse.Namespace() + self.parser.args.method = None + self.parser.args.url = 'POST' + self.parser.args.request_items = [ + KeyValueArg( + key='https', value='//example.com/', sep=':', orig='https://example.com/') + ] + 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 == 'https://example.com/' + assert self.parser.args.request_items == [] + + +class TestOptionsBeforeURL: + + def test_method_with_options_before_url(self, httpbin): + r = http('--ignore-stdin', 'POST', '--verbose', httpbin + '/post') + assert 'POST /post HTTP/1.1' in r + + def test_method_with_auth_options_before_url(self, httpbin): + r = http( + '--ignore-stdin', 'GET', + '--auth-type', 'bearer', '--auth', 'TOKEN', + httpbin + '/get', + ) + assert HTTP_OK in r + assert 'Bearer TOKEN' in r + class TestNoOptions: