From 1d223b47f8ae3414cad66d0a8f3b97e0064b6657 Mon Sep 17 00:00:00 2001 From: Malte Mindedal Date: Wed, 5 Feb 2025 19:42:32 +0100 Subject: [PATCH 1/2] Refactor HTTP client to support HEAD and OPTIONS methods, add verbose logging and header handling --- README.md | 92 ++++++++++++++++++++++++++----------- setup.py | 6 +-- src/http_cli/cli.py | 49 ++++++++++++++++++-- src/http_cli/http_client.py | 71 +++++++++++++++++++++------- tests/test_exceptions.py | 2 +- tests/test_http_client.py | 20 ++++++++ 6 files changed, 189 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 476a84e..2609322 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,20 @@ -# HTTP CLI +# PyFetch: A Lightweight HTTP CLI A lightweight command-line interface for making HTTP requests. Built with Python, -this tool provides an easy way to make GET, POST, PUT, PATCH and DELETE requests with support for JSON data and customizable timeouts. +this tool provides an easy way to make GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS requests with support for JSON data, customizable timeouts, automatic retries on failures, and a verbose mode for detailed logging. ## Features - Simple command-line interface - Case-insensitive commands (GET/get, POST/post etc.) -- Support for GET, PUT, POST, PATCH and DELETE requests +- Support for GET, PUT, POST, PATCH, DELETE, HEAD, and OPTIONS requests - JSON data handling for POST requests -- Customizable timeout settings +- Customizable timeout settings and automatic retries on failures +- Verbose mode for debugging: logs requests and responses - Detailed response output - - Status code - - Response headers - - Response body + - Status code + - Response headers + - Response body (pretty-printed if JSON) - Comprehensive error handling - Built-in help system @@ -23,7 +24,7 @@ this tool provides an easy way to make GET, POST, PUT, PATCH and DELETE requests - Python 3.7 or higher - pip (Python package installer) -- virtualenv *(recommended)* +- virtualenv _(recommended)_ ### Setup @@ -34,7 +35,7 @@ git clone https://github.com/yourusername/http-cli.git cd http-cli ``` -2. Create and activate a virtual environment *(recommended)*: +2. Create and activate a virtual environment _(recommended)_: ```bash # Windows @@ -58,22 +59,28 @@ pip install -e . ```bash # Make a GET request -http_cli GET https://api.example.com +pyfetch GET https://api.example.com # Make a POST request with JSON data -http_cli POST https://api.example.com -d '{"key": "value"}' +pyfetch POST https://api.example.com -d '{"key": "value"}' # Update a resource with PUT -http_cli PUT https://api.example.com/users/1 -d '{"name": "John"}' +pyfetch PUT https://api.example.com/users/1 -d '{"name": "John"}' # Partially update with PATCH -http_cli PATCH https://api.example.com/users/1 -d '{"email": "john@example.com"}' +pyfetch PATCH https://api.example.com/users/1 -d '{"email": "john@example.com"}' # Delete a resource -http_cli DELETE https://api.example.com/users/1 +pyfetch DELETE https://api.example.com/users/1 + +# Make a HEAD request (fetch headers only) +pyfetch HEAD https://api.example.com + +# Make an OPTIONS request (see allowed methods) +pyfetch OPTIONS https://api.example.com # Show help message -http_cli HELP +pyfetch HELP ``` ## Testing @@ -96,6 +103,7 @@ python setup.py test ### Test Coverage The test suite covers: + - HTTP client functionality - CLI commands and arguments - Error handling and exceptions @@ -105,11 +113,13 @@ The test suite covers: ### Writing Tests Tests are organized in three main files: + - `tests/test_cli.py` - Command-line interface tests - `tests/test_http_client.py` - HTTP client functionality tests - `tests/test_exceptions.py` - Exception handling tests To add new tests: + 1. Choose the appropriate test file based on functionality 2. Create a new test method in the relevant test class 3. Use unittest assertions to verify behavior @@ -120,7 +130,7 @@ To add new tests: ### GET Request ``` -usage: http_cli GET [-h] [-t TIMEOUT] url +usage: pyfetch GET [-h] [-t TIMEOUT] url positional arguments: url Target URL @@ -134,7 +144,7 @@ options: ### POST Request ``` -usage: http_cli POST [-h] [-t TIMEOUT] [-d DATA] url +usage: pyfetch POST [-h] [-t TIMEOUT] [-d DATA] url positional arguments: url Target URL @@ -149,7 +159,7 @@ options: ### PUT Request ``` -usage: http_cli PUT [-h] [-t TIMEOUT] [-d DATA] url +usage: pyfetch PUT [-h] [-t TIMEOUT] [-d DATA] url positional arguments: url Target URL @@ -164,7 +174,7 @@ options: ### PATCH Request ``` -usage: http_cli PATCH [-h] [-t TIMEOUT] [-d DATA] url +usage: pyfetch PATCH [-h] [-t TIMEOUT] [-d DATA] url positional arguments: url Target URL @@ -179,7 +189,35 @@ options: ### DELETE Request ``` -usage: http_cli DELETE [-h] [-t TIMEOUT] url +usage: pyfetch DELETE [-h] [-t TIMEOUT] url + +positional arguments: + url Target URL + +options: + -h, --help show this help message and exit + -t TIMEOUT, --timeout TIMEOUT + Request timeout in seconds (default: 30) +``` + +### HEAD Request + +``` +usage: pyfetch HEAD [-h] [-t TIMEOUT] url + +positional arguments: + url Target URL + +options: + -h, --help show this help message and exit + -t TIMEOUT, --timeout TIMEOUT + Request timeout in seconds (default: 30) +``` + +### OPTIONS Request + +``` +usage: pyfetch OPTIONS [-h] [-t TIMEOUT] url positional arguments: url Target URL @@ -233,15 +271,15 @@ All errors are displayed with descriptive messages to help diagnose the issue. ### Common Issues 1. Command not found: - - Make sure the package is installed (```pip list | findstr http-cli```) - - Ensure your virtual environment is activated + - Make sure the package is installed (`pip list | findstr http-cli`) + - Ensure your virtual environment is activated 2. Import errors: - - Try reinstalling the package: ```pip install -e .``` - - Make sure you're using the correct Python environment + - Try reinstalling the package: `pip install -e .` + - Make sure you're using the correct Python environment 3. JSON errors: - - Verify your JSON data is properly formatted - - Use single quotes around the entire JSON string and double quotes inside + - Verify your JSON data is properly formatted + - Use single quotes around the entire JSON string and double quotes inside ## License -This project is licensed under the Apache License, Version 2.0 - see the LICENSE file for details. \ No newline at end of file +This project is licensed under the Apache License, Version 2.0 - see the LICENSE file for details. diff --git a/setup.py b/setup.py index 32c7989..2094a7c 100644 --- a/setup.py +++ b/setup.py @@ -3,8 +3,8 @@ from setuptools import find_packages, setup setup( - name="http-cli", - version="0.1.0", + name="PyFetch", # Changed from http-cli + version="1.0.0", packages=find_packages(where="src"), package_dir={"": "src"}, install_requires=[ @@ -12,7 +12,7 @@ ], entry_points={ "console_scripts": [ - "http_cli=http_cli.cli:main", + "pyfetch=http_cli.cli:main", # Changed from http_cli=http_cli.cli:main ], }, author="Malte Mindedal", diff --git a/src/http_cli/cli.py b/src/http_cli/cli.py index 81bb458..92e24cf 100644 --- a/src/http_cli/cli.py +++ b/src/http_cli/cli.py @@ -46,6 +46,18 @@ def add_common_arguments(parser): default=30, help="Request timeout in seconds (default: 30)", ) + parser.add_argument( + "-H", + "--header", + action="append", + help="HTTP header in 'Key: Value' format. Can be used multiple times.", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable verbose logging for debugging.", + ) def create_parser(): @@ -60,7 +72,7 @@ def _split_lines(self, text, width): return super()._split_lines(text, width) parser = argparse.ArgumentParser( - description="HTTP CLI client supporting GET, POST, PUT, PATCH, and DELETE methods", + description="HTTP CLI client supporting GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS methods", formatter_class=CustomFormatter, add_help=True, ) @@ -117,6 +129,18 @@ def _split_lines(self, text, width): ) add_common_arguments(delete_parser) + # HEAD command + head_parser = subparsers.add_parser( + "HEAD", help="Make a HEAD request", aliases=["head"] + ) + add_common_arguments(head_parser) + + # OPTIONS command + options_parser = subparsers.add_parser( + "OPTIONS", help="Make an OPTIONS request", aliases=["options"] + ) + add_common_arguments(options_parser) + return parser @@ -134,13 +158,22 @@ def main(suppress_output=False): show_examples(suppress_output) return - client = HTTPClient(timeout=args.timeout) + client = HTTPClient(timeout=args.timeout, verbose=args.verbose) try: # Prepare request kwargs kwargs = {} if hasattr(args, "data") and args.data: kwargs["json"] = json.loads(args.data) + # Parse headers if provided + if hasattr(args, "header") and args.header: + headers = {} + for item in args.header: + if ":" in item: + key, value = item.split(":", 1) + headers[key.strip()] = value.strip() + if headers: + kwargs["headers"] = headers # Make the request based on the command response = getattr(client, command.lower())(args.url, **kwargs) @@ -150,8 +183,16 @@ def main(suppress_output=False): print("\nHeaders:") for key, value in response.headers.items(): print(f"{key}: {value}") - print("\nResponse Body:") - print(response.text) + + # Only print Response Body if there is content + if response.text.strip(): + print("\nResponse Body:") + try: + json_data = json.loads(response.text) + pretty_response = json.dumps(json_data, indent=4) + print(pretty_response) + except ValueError: + print(response.text) except json.JSONDecodeError: print("Error: Invalid JSON data") diff --git a/src/http_cli/http_client.py b/src/http_cli/http_client.py index cad3129..1da3476 100644 --- a/src/http_cli/http_client.py +++ b/src/http_cli/http_client.py @@ -8,29 +8,60 @@ class HTTPClient: """HTTP client for making HTTP requests.""" - def __init__(self, timeout=30): + def __init__(self, timeout=30, retries=3, verbose=False): self.timeout = timeout - self.allowed_methods = ["GET", "POST", "PUT", "PATCH", "DELETE"] + self.retries = retries + self.verbose = verbose + self.allowed_methods = [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS", + ] def make_request(self, method, url, **kwargs): - """Make an HTTP request.""" + """Make an HTTP request with automatic retries.""" if method.upper() not in self.allowed_methods: raise ValueError( f"Unsupported HTTP method. Allowed methods: {', '.join(self.allowed_methods)}" ) - - try: - response = requests.request( - method=method, url=url, timeout=self.timeout, **kwargs - ) - response.raise_for_status() - return response - except requests.exceptions.ConnectionError as e: - raise HTTPConnectionError(f"Failed to connect to {url}: {str(e)}") from e - except requests.exceptions.HTTPError as e: - raise ResponseError(f"HTTP error occurred: {str(e)}") from e - except requests.exceptions.RequestException as e: - raise HTTPClientError(f"Request failed: {str(e)}") from e + for attempt in range(self.retries): + if self.verbose: + print( + f"[VERBOSE] Attempt {attempt + 1} of {self.retries}: Sending {method} request to {url} with {kwargs}" + ) + try: + response = requests.request( + method=method, url=url, timeout=self.timeout, **kwargs + ) + response.raise_for_status() + if self.verbose: + print( + f"[VERBOSE] Received response with status {response.status_code} and headers {response.headers}" + ) + return response + except requests.exceptions.ConnectionError as e: + if self.verbose: + print(f"[VERBOSE] ConnectionError on attempt {attempt + 1}: {e}") + if attempt == self.retries - 1: + raise HTTPConnectionError( + f"Failed to connect to {url}: {str(e)}" + ) from e + except requests.exceptions.HTTPError as e: + if self.verbose: + print(f"[VERBOSE] HTTPError on attempt {attempt + 1}: {e}") + if attempt == self.retries - 1: + raise ResponseError(f"HTTP error occurred: {str(e)}") from e + except requests.exceptions.RequestException as e: + if self.verbose: + print(f"[VERBOSE] RequestException on attempt {attempt + 1}: {e}") + if attempt == self.retries - 1: + raise HTTPClientError(f"Request failed: {str(e)}") from e + # Optionally add a delay between retries + # time.sleep(delay) def get(self, url, **kwargs): """Make a GET request.""" @@ -51,3 +82,11 @@ def patch(self, url, **kwargs): def delete(self, url, **kwargs): """Make a DELETE request.""" return self.make_request("DELETE", url, **kwargs) + + def head(self, url, **kwargs): + """Make a HEAD request.""" + return self.make_request("HEAD", url, **kwargs) + + def options(self, url, **kwargs): + """Make an OPTIONS request.""" + return self.make_request("OPTIONS", url, **kwargs) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index f0f4031..44ddb35 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -1,6 +1,6 @@ """Test cases for the exceptions module""" -import unittest +import unittest from http_cli.exceptions import HTTPClientError diff --git a/tests/test_http_client.py b/tests/test_http_client.py index 1523c7f..ce1dbda 100644 --- a/tests/test_http_client.py +++ b/tests/test_http_client.py @@ -28,6 +28,26 @@ def test_get_request_failure(self, _): with self.assertRaises(HTTPClientError): client.get("https://api.example.com") + @patch("requests.request") + def test_head_request_success(self, mock_request): + """Test a successful HEAD request""" + mock_request.return_value.status_code = 200 + mock_request.return_value.text = "" + client = HTTPClient() + response = client.head("https://api.example.com") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.text, "") + + @patch("requests.request") + def test_options_request_success(self, mock_request): + """Test a successful OPTIONS request""" + mock_request.return_value.status_code = 200 + mock_request.return_value.text = "" + client = HTTPClient() + response = client.options("https://api.example.com") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.text, "") + if __name__ == "__main__": unittest.main() From 1b905f3e530243300303d47a93b2a35c26515cba Mon Sep 17 00:00:00 2001 From: Malte Mindedal Date: Wed, 5 Feb 2025 19:47:44 +0100 Subject: [PATCH 2/2] Update copyright year and owner in LICENSE file --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 261eeb9..9f56f09 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2025 Malte Mindedal Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.