trcli 1.12.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trcli/__init__.py +1 -0
- trcli/api/__init__.py +0 -0
- trcli/api/api_client.py +331 -0
- trcli/api/api_request_handler.py +1731 -0
- trcli/api/api_response_verify.py +74 -0
- trcli/api/project_based_client.py +276 -0
- trcli/api/results_uploader.py +420 -0
- trcli/backports.py +6 -0
- trcli/cli.py +359 -0
- trcli/commands/__init__.py +0 -0
- trcli/commands/cmd_add_run.py +152 -0
- trcli/commands/cmd_labels.py +682 -0
- trcli/commands/cmd_parse_junit.py +233 -0
- trcli/commands/cmd_parse_openapi.py +67 -0
- trcli/commands/cmd_parse_robot.py +45 -0
- trcli/commands/cmd_references.py +224 -0
- trcli/commands/results_parser_helpers.py +105 -0
- trcli/constants.py +133 -0
- trcli/data_classes/__init__.py +0 -0
- trcli/data_classes/data_parsers.py +111 -0
- trcli/data_classes/dataclass_testrail.py +248 -0
- trcli/data_classes/validation_exception.py +14 -0
- trcli/data_providers/api_data_provider.py +267 -0
- trcli/readers/__init__.py +0 -0
- trcli/readers/file_parser.py +28 -0
- trcli/readers/junit_xml.py +348 -0
- trcli/readers/openapi_yml.py +202 -0
- trcli/readers/robot_xml.py +149 -0
- trcli/settings.py +6 -0
- trcli-1.12.4.dist-info/METADATA +19 -0
- trcli-1.12.4.dist-info/RECORD +35 -0
- trcli-1.12.4.dist-info/WHEEL +5 -0
- trcli-1.12.4.dist-info/entry_points.txt +2 -0
- trcli-1.12.4.dist-info/licenses/LICENSE.md +373 -0
- trcli-1.12.4.dist-info/top_level.txt +1 -0
trcli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.12.4"
|
trcli/api/__init__.py
ADDED
|
File without changes
|
trcli/api/api_client.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import platform
|
|
4
|
+
import os
|
|
5
|
+
import base64
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
from beartype.typing import Union, Callable, Dict, List
|
|
9
|
+
from time import sleep
|
|
10
|
+
from base64 import b64encode
|
|
11
|
+
|
|
12
|
+
import urllib3
|
|
13
|
+
from urllib.parse import urlparse
|
|
14
|
+
from requests.auth import HTTPBasicAuth
|
|
15
|
+
from json import JSONDecodeError
|
|
16
|
+
from requests.exceptions import RequestException, Timeout, ConnectionError, ProxyError, SSLError, InvalidProxyURL
|
|
17
|
+
from trcli.constants import FAULT_MAPPING
|
|
18
|
+
from trcli.settings import DEFAULT_API_CALL_TIMEOUT, DEFAULT_API_CALL_RETRIES
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class APIClientResult:
|
|
24
|
+
"""
|
|
25
|
+
status_code - status code returned by GET/POST request or -1 if error occurred during request handling
|
|
26
|
+
response_text - json object or bare text string is response could not be parsed
|
|
27
|
+
error_message - custom error message when -1 was returned in status_code"""
|
|
28
|
+
|
|
29
|
+
status_code: int
|
|
30
|
+
response_text: Union[Dict, str, List]
|
|
31
|
+
error_message: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class APIClient:
|
|
35
|
+
"""
|
|
36
|
+
Class to be used for basic communication over API.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
PREFIX = "index.php?"
|
|
40
|
+
VERSION = "/api/v2/"
|
|
41
|
+
SUFFIX_API_V2_VERSION = f"{PREFIX}{VERSION}"
|
|
42
|
+
RETRY_ON = [429, 500, 502]
|
|
43
|
+
USER_AGENT = "TRCLI"
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
host_name: str,
|
|
48
|
+
verbose_logging_function: Callable = print,
|
|
49
|
+
logging_function: Callable = print,
|
|
50
|
+
retries: int = DEFAULT_API_CALL_RETRIES,
|
|
51
|
+
timeout: int = DEFAULT_API_CALL_TIMEOUT,
|
|
52
|
+
verify: bool = True,
|
|
53
|
+
proxy: str = None, # added proxy params
|
|
54
|
+
proxy_user: str = None,
|
|
55
|
+
noproxy: str = None,
|
|
56
|
+
uploader_metadata: str = None,
|
|
57
|
+
):
|
|
58
|
+
self.username = ""
|
|
59
|
+
self.password = ""
|
|
60
|
+
self.api_key = ""
|
|
61
|
+
self.timeout = None
|
|
62
|
+
self.retries = retries
|
|
63
|
+
self.verify = verify
|
|
64
|
+
self.verbose_logging_function = verbose_logging_function
|
|
65
|
+
self.logging_function = logging_function
|
|
66
|
+
self.__validate_and_set_timeout(timeout)
|
|
67
|
+
self.proxy = proxy
|
|
68
|
+
self.proxy_user = proxy_user
|
|
69
|
+
self.noproxy = noproxy.split(",") if noproxy else []
|
|
70
|
+
self.uploader_metadata = uploader_metadata
|
|
71
|
+
|
|
72
|
+
if not host_name.endswith("/"):
|
|
73
|
+
host_name = host_name + "/"
|
|
74
|
+
self.__url = host_name + self.SUFFIX_API_V2_VERSION
|
|
75
|
+
if not verify:
|
|
76
|
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
77
|
+
|
|
78
|
+
def send_get(self, uri: str) -> APIClientResult:
|
|
79
|
+
"""
|
|
80
|
+
Sends GET request to host specified by host_name.
|
|
81
|
+
Handles retries taking into consideration retries parameter. Retry will occur when one of the following happens:
|
|
82
|
+
* got status code 429 in a response from host
|
|
83
|
+
* timeout occurred
|
|
84
|
+
* connection error occurred
|
|
85
|
+
"""
|
|
86
|
+
return self.__send_request("GET", uri, None)
|
|
87
|
+
|
|
88
|
+
def send_post(
|
|
89
|
+
self, uri: str, payload: dict = None, files: Dict[str, Path] = None, as_form_data: bool = False
|
|
90
|
+
) -> APIClientResult:
|
|
91
|
+
"""
|
|
92
|
+
Sends POST request to host specified by host_name.
|
|
93
|
+
Handles retries taking into consideration retries parameter. Retry will occur when one of the following happens:
|
|
94
|
+
* got status code 429 in a response from host
|
|
95
|
+
* timeout occurred
|
|
96
|
+
* connection error occurred
|
|
97
|
+
"""
|
|
98
|
+
return self.__send_request("POST", uri, payload, files, as_form_data)
|
|
99
|
+
|
|
100
|
+
def __send_request(
|
|
101
|
+
self, method: str, uri: str, payload: dict, files: Dict[str, Path] = None, as_form_data: bool = False
|
|
102
|
+
) -> APIClientResult:
|
|
103
|
+
status_code = -1
|
|
104
|
+
response_text = ""
|
|
105
|
+
error_message = ""
|
|
106
|
+
url = self.__url + uri
|
|
107
|
+
password = self.__get_password()
|
|
108
|
+
auth = HTTPBasicAuth(username=self.username, password=password)
|
|
109
|
+
headers = {"User-Agent": self.USER_AGENT}
|
|
110
|
+
headers.update(self.__get_proxy_headers())
|
|
111
|
+
headers.update(self.__get_uploader_metadata_headers())
|
|
112
|
+
if files is None and not as_form_data:
|
|
113
|
+
headers["Content-Type"] = "application/json"
|
|
114
|
+
verbose_log_message = ""
|
|
115
|
+
proxies = self._get_proxies_for_request(url)
|
|
116
|
+
for i in range(self.retries + 1):
|
|
117
|
+
error_message = ""
|
|
118
|
+
try:
|
|
119
|
+
verbose_log_message = APIClient.format_request_for_vlog(
|
|
120
|
+
method=method, url=url, payload=payload, headers=headers
|
|
121
|
+
)
|
|
122
|
+
if method == "POST":
|
|
123
|
+
request_kwargs = {
|
|
124
|
+
"url": url,
|
|
125
|
+
"auth": auth,
|
|
126
|
+
"headers": headers,
|
|
127
|
+
"timeout": self.timeout,
|
|
128
|
+
"verify": self.verify,
|
|
129
|
+
"proxies": proxies,
|
|
130
|
+
}
|
|
131
|
+
if files:
|
|
132
|
+
request_kwargs["files"] = files
|
|
133
|
+
request_kwargs["data"] = payload if payload else {}
|
|
134
|
+
elif as_form_data:
|
|
135
|
+
request_kwargs["data"] = payload
|
|
136
|
+
else:
|
|
137
|
+
request_kwargs["json"] = payload
|
|
138
|
+
|
|
139
|
+
response = requests.post(**request_kwargs)
|
|
140
|
+
else:
|
|
141
|
+
response = requests.get(
|
|
142
|
+
url=url,
|
|
143
|
+
auth=auth,
|
|
144
|
+
json=payload,
|
|
145
|
+
timeout=self.timeout,
|
|
146
|
+
verify=self.verify,
|
|
147
|
+
headers=headers,
|
|
148
|
+
proxies=proxies,
|
|
149
|
+
)
|
|
150
|
+
except InvalidProxyURL:
|
|
151
|
+
error_message = FAULT_MAPPING["proxy_invalid_configuration"]
|
|
152
|
+
self.verbose_logging_function(verbose_log_message)
|
|
153
|
+
break
|
|
154
|
+
except ProxyError:
|
|
155
|
+
error_message = FAULT_MAPPING["proxy_connection_error"]
|
|
156
|
+
self.verbose_logging_function(verbose_log_message)
|
|
157
|
+
break
|
|
158
|
+
except SSLError:
|
|
159
|
+
error_message = FAULT_MAPPING["ssl_error_on_proxy"]
|
|
160
|
+
self.verbose_logging_function(verbose_log_message)
|
|
161
|
+
break
|
|
162
|
+
except Timeout:
|
|
163
|
+
error_message = FAULT_MAPPING["no_response_from_host"]
|
|
164
|
+
self.verbose_logging_function(verbose_log_message)
|
|
165
|
+
continue
|
|
166
|
+
except ConnectionError:
|
|
167
|
+
error_message = FAULT_MAPPING["connection_error"]
|
|
168
|
+
self.verbose_logging_function(verbose_log_message)
|
|
169
|
+
continue
|
|
170
|
+
except RequestException as e:
|
|
171
|
+
error_message = FAULT_MAPPING["unexpected_error_during_request_send"].format(request=e.request)
|
|
172
|
+
self.verbose_logging_function(verbose_log_message)
|
|
173
|
+
break
|
|
174
|
+
else:
|
|
175
|
+
status_code = response.status_code
|
|
176
|
+
if status_code == 429:
|
|
177
|
+
retry_time = float(response.headers["Retry-After"])
|
|
178
|
+
sleep(retry_time)
|
|
179
|
+
try:
|
|
180
|
+
# workaround for buggy legacy TR server version response
|
|
181
|
+
if response.content.startswith(b"USER AUTHENTICATION SUCCESSFUL!\n"):
|
|
182
|
+
response_text = response.content.replace(b"USER AUTHENTICATION SUCCESSFUL!\n", b"", 1)
|
|
183
|
+
response_text = json.loads(response_text)
|
|
184
|
+
else:
|
|
185
|
+
response_text = response.json()
|
|
186
|
+
error_message = response_text.get("error", "")
|
|
187
|
+
except (JSONDecodeError, ValueError):
|
|
188
|
+
if len(response.content) == 0:
|
|
189
|
+
# Empty response with HTTP 200 is valid for certain operations like delete
|
|
190
|
+
response_text = {}
|
|
191
|
+
error_message = ""
|
|
192
|
+
else:
|
|
193
|
+
response_preview = response.content[:200].decode("utf-8", errors="ignore")
|
|
194
|
+
response_text = str(response.content)
|
|
195
|
+
error_message = FAULT_MAPPING["invalid_json_response"].format(
|
|
196
|
+
status_code=status_code, response_preview=response_preview
|
|
197
|
+
)
|
|
198
|
+
except AttributeError:
|
|
199
|
+
error_message = ""
|
|
200
|
+
verbose_log_message = verbose_log_message + APIClient.format_response_for_vlog(
|
|
201
|
+
response.status_code, response_text
|
|
202
|
+
)
|
|
203
|
+
if verbose_log_message:
|
|
204
|
+
self.verbose_logging_function(verbose_log_message)
|
|
205
|
+
|
|
206
|
+
if status_code not in self.RETRY_ON:
|
|
207
|
+
break
|
|
208
|
+
|
|
209
|
+
return APIClientResult(status_code, response_text, error_message)
|
|
210
|
+
|
|
211
|
+
def __get_proxy_headers(self) -> Dict[str, str]:
|
|
212
|
+
"""
|
|
213
|
+
Returns headers for proxy authentication using Basic Authentication if proxy_user is provided.
|
|
214
|
+
"""
|
|
215
|
+
headers = {}
|
|
216
|
+
if self.proxy_user:
|
|
217
|
+
user_pass_encoded = b64encode(self.proxy_user.encode("utf-8")).decode("utf-8")
|
|
218
|
+
|
|
219
|
+
# Add Proxy-Authorization header
|
|
220
|
+
headers["Proxy-Authorization"] = f"Basic {user_pass_encoded}"
|
|
221
|
+
print(f"Proxy authentication header added: {headers['Proxy-Authorization']}")
|
|
222
|
+
|
|
223
|
+
return headers
|
|
224
|
+
|
|
225
|
+
def __get_uploader_metadata_headers(self) -> Dict[str, str]:
|
|
226
|
+
"""
|
|
227
|
+
Returns headers for uploader metadata.
|
|
228
|
+
"""
|
|
229
|
+
headers = {}
|
|
230
|
+
if self.uploader_metadata:
|
|
231
|
+
headers["X-Uploader-Metadata"] = self.uploader_metadata
|
|
232
|
+
return headers
|
|
233
|
+
|
|
234
|
+
def _get_proxies_for_request(self, url: str) -> Dict[str, str]:
|
|
235
|
+
"""
|
|
236
|
+
Returns the appropriate proxy dictionary for a given request URL.
|
|
237
|
+
Will return None if the URL matches a proxy bypass host.
|
|
238
|
+
"""
|
|
239
|
+
parsed_url = urlparse(url)
|
|
240
|
+
scheme = parsed_url.scheme # The scheme of the target URL (http or https)
|
|
241
|
+
host = parsed_url.hostname
|
|
242
|
+
|
|
243
|
+
# If proxy or noproxy is None, return None, and requests will not use nor bypass a proxy server
|
|
244
|
+
if self.proxy is None:
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
# Bypass the proxy if the host is in the noproxy list
|
|
248
|
+
if self.noproxy:
|
|
249
|
+
# Ensure noproxy is a list or tuple
|
|
250
|
+
if isinstance(self.noproxy, str):
|
|
251
|
+
self.noproxy = self.noproxy.split(",")
|
|
252
|
+
if host in self.noproxy:
|
|
253
|
+
print(f"Bypassing proxy for host: {host}")
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
# Ensure proxy has a scheme (either http or https)
|
|
257
|
+
if self.proxy and not self.proxy.startswith("http://") and not self.proxy.startswith("https://"):
|
|
258
|
+
self.proxy = "http://" + self.proxy # Default to http if scheme is missing
|
|
259
|
+
|
|
260
|
+
# print(f"Parsed URL: {url}, Proxy: {self.proxy} , NoProxy: {self.noproxy}")
|
|
261
|
+
|
|
262
|
+
# Define the proxy dictionary
|
|
263
|
+
proxy_dict = {}
|
|
264
|
+
if self.proxy:
|
|
265
|
+
# Use HTTP proxy for both HTTP and HTTPS traffic
|
|
266
|
+
if self.proxy.startswith("http://"):
|
|
267
|
+
proxy_dict = {
|
|
268
|
+
"http": self.proxy, # Use HTTP proxy for HTTP traffic
|
|
269
|
+
"https": self.proxy, # Also use HTTP proxy for HTTPS traffic
|
|
270
|
+
}
|
|
271
|
+
else:
|
|
272
|
+
# If the proxy is HTTPS, route accordingly
|
|
273
|
+
proxy_dict = {scheme: self.proxy} # Match the proxy scheme with the target URL scheme
|
|
274
|
+
|
|
275
|
+
# print(f"Using proxy: {proxy_dict}")
|
|
276
|
+
return proxy_dict
|
|
277
|
+
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
def __get_password(self) -> str:
|
|
281
|
+
"""Based on what is set, choose to use api_key or password as authentication method"""
|
|
282
|
+
if self.api_key:
|
|
283
|
+
password = self.api_key
|
|
284
|
+
else:
|
|
285
|
+
password = self.password
|
|
286
|
+
return password
|
|
287
|
+
|
|
288
|
+
def __validate_and_set_timeout(self, timeout):
|
|
289
|
+
try:
|
|
290
|
+
self.timeout = float(timeout)
|
|
291
|
+
except ValueError:
|
|
292
|
+
self.logging_function(
|
|
293
|
+
f"Warning. Could not convert provided 'timeout' to float. "
|
|
294
|
+
f"Please make sure that timeout format is correct. Setting to default: "
|
|
295
|
+
f"{DEFAULT_API_CALL_TIMEOUT}"
|
|
296
|
+
)
|
|
297
|
+
self.timeout = DEFAULT_API_CALL_TIMEOUT
|
|
298
|
+
|
|
299
|
+
@staticmethod
|
|
300
|
+
def build_uploader_metadata(version: str) -> str:
|
|
301
|
+
"""
|
|
302
|
+
Build uploader metadata as base64-encoded JSON.
|
|
303
|
+
|
|
304
|
+
:param version: Application version
|
|
305
|
+
:returns: Base64-encoded metadata string
|
|
306
|
+
"""
|
|
307
|
+
data = {
|
|
308
|
+
"app_name": "trcli",
|
|
309
|
+
"app_version": version,
|
|
310
|
+
"os": platform.system().lower(),
|
|
311
|
+
"arch": platform.machine(),
|
|
312
|
+
"run_mode": "ci" if os.getenv("CI") else "other",
|
|
313
|
+
"container": os.path.exists("/.dockerenv"),
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return base64.b64encode(json.dumps(data).encode()).decode()
|
|
317
|
+
|
|
318
|
+
@staticmethod
|
|
319
|
+
def format_request_for_vlog(method: str, url: str, payload: dict, headers: dict = None):
|
|
320
|
+
log_message = f"\n**** API Call\n" f"method: {method}\n" f"url: {url}\n"
|
|
321
|
+
if headers:
|
|
322
|
+
log_message += "headers:\n"
|
|
323
|
+
for key, value in headers.items():
|
|
324
|
+
log_message += f" {key}: {value}\n"
|
|
325
|
+
if payload:
|
|
326
|
+
log_message += f"payload: {payload}\n"
|
|
327
|
+
return log_message
|
|
328
|
+
|
|
329
|
+
@staticmethod
|
|
330
|
+
def format_response_for_vlog(status_code, body):
|
|
331
|
+
return f"response status code: {status_code}\nresponse body: {body}\n****"
|