diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 844310e..485784d 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -6,6 +6,13 @@ This project adheres to [Semantic Versioning](https://semver.org/). Version numb - **MINOR**: New features that are backward-compatible. - **PATCH**: Bug fixes or minor changes that do not affect backward compatibility. +## [1.12.4] + +_released 11-03-2025 + +### Fixed + - Fixed an issue where some empty API responses are blocked/treated as error due to stricter configuration checks. + ## [1.12.3] _released 10-31-2025 diff --git a/README.md b/README.md index 2bb0ab4..863573e 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ trcli ``` You should get something like this: ``` -TestRail CLI v1.12.3 +TestRail CLI v1.12.4 Copyright 2025 Gurock Software GmbH - www.gurock.com Supported and loaded modules: - parse_junit: JUnit XML Files (& Similar) @@ -47,7 +47,7 @@ CLI general reference -------- ```shell $ trcli --help -TestRail CLI v1.12.3 +TestRail CLI v1.12.4 Copyright 2025 Gurock Software GmbH - www.gurock.com Usage: trcli [OPTIONS] COMMAND [ARGS]... @@ -1094,7 +1094,7 @@ Options: ### Reference ```shell $ trcli add_run --help -TestRail CLI v1.12.3 +TestRail CLI v1.12.4 Copyright 2025 Gurock Software GmbH - www.gurock.com Usage: trcli add_run [OPTIONS] @@ -1218,7 +1218,7 @@ providing you with a solid base of test cases, which you can further expand on T ### Reference ```shell $ trcli parse_openapi --help -TestRail CLI v1.12.3 +TestRail CLI v1.12.4 Copyright 2025 Gurock Software GmbH - www.gurock.com Usage: trcli parse_openapi [OPTIONS] diff --git a/tests/test_api_client.py b/tests/test_api_client.py index 4f64f45..60d761c 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -419,3 +419,39 @@ def test_send_post_with_files_and_form_data(self, mock_post, api_resources_maker # Should NOT have JSON content type header when using files headers = call_args[1]["headers"] assert headers.get("Content-Type") != "application/json" + + @pytest.mark.api_client + def test_empty_response_is_valid(self, api_resources_maker, requests_mock): + """Test that empty response body with HTTP 200 is treated as valid (e.g., for DELETE operations).""" + api_client = api_resources_maker() + api_client.uploader_metadata = "test_metadata_value" + + # DELETE operations may return empty body with HTTP 200 + requests_mock.post(create_url("delete_section/123"), status_code=200, content=b"") + + response = api_client.send_post("delete_section/123") + + # Verify that the request was made only once (no retry) + assert requests_mock.call_count == 1 + + # Verify successful response with empty dict + check_response(200, {}, "", response) + + @pytest.mark.api_client + def test_metadata_header_sent_when_enabled(self, api_resources_maker, requests_mock): + """Test that X-Uploader-Metadata header is sent when enabled.""" + api_client = api_resources_maker() + test_metadata = "test_metadata_value" + api_client.uploader_metadata = test_metadata + + requests_mock.get(create_url("get_projects"), json=FAKE_PROJECT_DATA) + + response = api_client.send_get("get_projects") + + # Check that metadata header was sent + request_headers = requests_mock.last_request.headers + assert "X-Uploader-Metadata" in request_headers + assert request_headers["X-Uploader-Metadata"] == test_metadata + + # Verify successful response + check_response(200, FAKE_PROJECT_DATA, "", response) diff --git a/trcli/__init__.py b/trcli/__init__.py index 53d6006..19ee973 100644 --- a/trcli/__init__.py +++ b/trcli/__init__.py @@ -1 +1 @@ -__version__ = "1.12.3" +__version__ = "1.12.4" diff --git a/trcli/api/api_client.py b/trcli/api/api_client.py index bc24248..a412754 100644 --- a/trcli/api/api_client.py +++ b/trcli/api/api_client.py @@ -50,7 +50,7 @@ def __init__( retries: int = DEFAULT_API_CALL_RETRIES, timeout: int = DEFAULT_API_CALL_TIMEOUT, verify: bool = True, - proxy: str = None, #added proxy params + proxy: str = None, # added proxy params proxy_user: str = None, noproxy: str = None, uploader_metadata: str = None, @@ -66,9 +66,9 @@ def __init__( self.__validate_and_set_timeout(timeout) self.proxy = proxy self.proxy_user = proxy_user - self.noproxy = noproxy.split(',') if noproxy else [] - self.uploader_metadata = uploader_metadata - + self.noproxy = noproxy.split(",") if noproxy else [] + self.uploader_metadata = uploader_metadata + if not host_name.endswith("/"): host_name = host_name + "/" self.__url = host_name + self.SUFFIX_API_V2_VERSION @@ -85,7 +85,9 @@ def send_get(self, uri: str) -> APIClientResult: """ return self.__send_request("GET", uri, None) - def send_post(self, uri: str, payload: dict = None, files: Dict[str, Path] = None, as_form_data: bool = False) -> APIClientResult: + def send_post( + self, uri: str, payload: dict = None, files: Dict[str, Path] = None, as_form_data: bool = False + ) -> APIClientResult: """ Sends POST request to host specified by host_name. Handles retries taking into consideration retries parameter. Retry will occur when one of the following happens: @@ -95,7 +97,9 @@ def send_post(self, uri: str, payload: dict = None, files: Dict[str, Path] = Non """ return self.__send_request("POST", uri, payload, files, as_form_data) - def __send_request(self, method: str, uri: str, payload: dict, files: Dict[str, Path] = None, as_form_data: bool = False) -> APIClientResult: + def __send_request( + self, method: str, uri: str, payload: dict, files: Dict[str, Path] = None, as_form_data: bool = False + ) -> APIClientResult: status_code = -1 response_text = "" error_message = "" @@ -117,12 +121,12 @@ def __send_request(self, method: str, uri: str, payload: dict, files: Dict[str, ) if method == "POST": request_kwargs = { - 'url': url, - 'auth': auth, - 'headers': headers, - 'timeout': self.timeout, - 'verify': self.verify, - 'proxies': proxies + "url": url, + "auth": auth, + "headers": headers, + "timeout": self.timeout, + "verify": self.verify, + "proxies": proxies, } if files: request_kwargs["files"] = files @@ -131,17 +135,17 @@ def __send_request(self, method: str, uri: str, payload: dict, files: Dict[str, request_kwargs["data"] = payload else: request_kwargs["json"] = payload - + response = requests.post(**request_kwargs) else: response = requests.get( - url=url, - auth=auth, - json=payload, - timeout=self.timeout, - verify=self.verify, + url=url, + auth=auth, + json=payload, + timeout=self.timeout, + verify=self.verify, headers=headers, - proxies=proxies + proxies=proxies, ) except InvalidProxyURL: error_message = FAULT_MAPPING["proxy_invalid_configuration"] @@ -164,9 +168,7 @@ def __send_request(self, method: str, uri: str, payload: dict, files: Dict[str, self.verbose_logging_function(verbose_log_message) continue except RequestException as e: - error_message = FAULT_MAPPING[ - "unexpected_error_during_request_send" - ].format(request=e.request) + error_message = FAULT_MAPPING["unexpected_error_during_request_send"].format(request=e.request) self.verbose_logging_function(verbose_log_message) break else: @@ -183,19 +185,20 @@ def __send_request(self, method: str, uri: str, payload: dict, files: Dict[str, response_text = response.json() error_message = response_text.get("error", "") except (JSONDecodeError, ValueError): - response_preview = response.content[:200].decode('utf-8', errors='ignore') - response_text = str(response.content) - error_message = FAULT_MAPPING["invalid_json_response"].format( - status_code=status_code, - response_preview=response_preview - ) + if len(response.content) == 0: + # Empty response with HTTP 200 is valid for certain operations like delete + response_text = {} + error_message = "" + else: + response_preview = response.content[:200].decode("utf-8", errors="ignore") + response_text = str(response.content) + error_message = FAULT_MAPPING["invalid_json_response"].format( + status_code=status_code, response_preview=response_preview + ) except AttributeError: error_message = "" - verbose_log_message = ( - verbose_log_message - + APIClient.format_response_for_vlog( - response.status_code, response_text - ) + verbose_log_message = verbose_log_message + APIClient.format_response_for_vlog( + response.status_code, response_text ) if verbose_log_message: self.verbose_logging_function(verbose_log_message) @@ -211,7 +214,7 @@ def __get_proxy_headers(self) -> Dict[str, str]: """ headers = {} if self.proxy_user: - user_pass_encoded = b64encode(self.proxy_user.encode('utf-8')).decode('utf-8') + user_pass_encoded = b64encode(self.proxy_user.encode("utf-8")).decode("utf-8") # Add Proxy-Authorization header headers["Proxy-Authorization"] = f"Basic {user_pass_encoded}" @@ -243,9 +246,9 @@ def _get_proxies_for_request(self, url: str) -> Dict[str, str]: # Bypass the proxy if the host is in the noproxy list if self.noproxy: - # Ensure noproxy is a list or tuple + # Ensure noproxy is a list or tuple if isinstance(self.noproxy, str): - self.noproxy = self.noproxy.split(',') + self.noproxy = self.noproxy.split(",") if host in self.noproxy: print(f"Bypassing proxy for host: {host}") return None @@ -254,7 +257,7 @@ def _get_proxies_for_request(self, url: str) -> Dict[str, str]: if self.proxy and not self.proxy.startswith("http://") and not self.proxy.startswith("https://"): self.proxy = "http://" + self.proxy # Default to http if scheme is missing - #print(f"Parsed URL: {url}, Proxy: {self.proxy} , NoProxy: {self.noproxy}") + # print(f"Parsed URL: {url}, Proxy: {self.proxy} , NoProxy: {self.noproxy}") # Define the proxy dictionary proxy_dict = {} @@ -263,15 +266,13 @@ def _get_proxies_for_request(self, url: str) -> Dict[str, str]: if self.proxy.startswith("http://"): proxy_dict = { "http": self.proxy, # Use HTTP proxy for HTTP traffic - "https": self.proxy # Also use HTTP proxy for HTTPS traffic + "https": self.proxy, # Also use HTTP proxy for HTTPS traffic } else: # If the proxy is HTTPS, route accordingly - proxy_dict = { - scheme: self.proxy # Match the proxy scheme with the target URL scheme - } + proxy_dict = {scheme: self.proxy} # Match the proxy scheme with the target URL scheme - #print(f"Using proxy: {proxy_dict}") + # print(f"Using proxy: {proxy_dict}") return proxy_dict return None @@ -316,11 +317,7 @@ def build_uploader_metadata(version: str) -> str: @staticmethod def format_request_for_vlog(method: str, url: str, payload: dict, headers: dict = None): - log_message = ( - f"\n**** API Call\n" - f"method: {method}\n" - f"url: {url}\n" - ) + log_message = f"\n**** API Call\n" f"method: {method}\n" f"url: {url}\n" if headers: log_message += "headers:\n" for key, value in headers.items():