Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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]...

Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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]

Expand Down
36 changes: 36 additions & 0 deletions tests/test_api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion trcli/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.12.3"
__version__ = "1.12.4"
91 changes: 44 additions & 47 deletions trcli/api/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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 = ""
Expand All @@ -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
Expand All @@ -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"]
Expand All @@ -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:
Expand All @@ -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)
Expand All @@ -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}"
Expand Down Expand Up @@ -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
Expand All @@ -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 = {}
Expand All @@ -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
Expand Down Expand Up @@ -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():
Expand Down