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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.12.4"
trcli/api/__init__.py ADDED
File without changes
@@ -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****"