Skip to content

Commit 4ebfb6b

Browse files
Update upstream racetrack libs (#27)
* Merge upstream changes from racetrack * Switch async endpoints to non-blocking * Fix Path parameters cannot have a default value * Add type import compatible with python3.8 * Fix fastapi ENCODERS_BY_TYPE
1 parent 2eda99d commit 4ebfb6b

30 files changed

Lines changed: 317 additions & 96 deletions

docs/compatibility.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ This document describes compatibility of the versions of this plugin with the Ra
88
| 2.9.0 | `>= 2.18.0` |
99
| 2.9.1 | `>= 2.19.0` |
1010
| 2.9.2 | `>= 2.19.0` |
11+
| 2.10.0 | `>= 2.19.0` |
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
name: python3-job-type
2-
version: 2.9.2
2+
version: 2.10.0
33
url: 'https://github.com/TheRacetrack/plugin-python-job-type'
44
category: 'job-type'

python3-job-type/python_wrapper/racetrack_client/log/context_error.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,10 @@ def wrap_context(context_name: str, log_debug: bool = False):
4040
yield
4141
except Exception as e:
4242
raise ContextError(context_name) from e
43+
44+
45+
def unwrap(e: BaseException) -> BaseException:
46+
"""Find root cause of the error"""
47+
while e.__cause__ is not None:
48+
e = e.__cause__
49+
return e

python3-job-type/python_wrapper/racetrack_client/log/errors.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,7 @@ class EntityNotFound(RuntimeError):
44

55
class AlreadyExists(RuntimeError):
66
pass
7+
8+
9+
class ValidationError(RuntimeError):
10+
pass

python3-job-type/python_wrapper/racetrack_client/log/logs.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import Optional
44

55
LOG_FORMAT = '\033[2m[%(asctime)s]\033[0m %(levelname)s %(message)s'
6+
LOG_FORMAT_DEBUG = '\033[2m[%(asctime)s]\033[0m %(name)s %(filename)s %(lineno)s %(levelname)s %(message)s'
67
LOG_DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
78

89

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
PyYAML >= 5.4
2-
pydantic >= 1.9, < 2
1+
PyYAML >= 6.0
2+
pydantic >= 1.10, < 2
33
backoff >= 2.2
4-
python-socketio >= 5.7
4+
python-socketio[client] >= 5.8
5+
typer[all] >= 0.7.0
6+
jsonschema >= 4.17.3

python3-job-type/python_wrapper/racetrack_client/utils/datamodel.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212

1313
def parse_dict_datamodel(
14-
obj_dict: Dict,
14+
obj_dict: Dict,
1515
clazz: Type[T],
1616
) -> T:
1717
"""
@@ -70,6 +70,12 @@ def convert_to_json(obj) -> str:
7070
return json.dumps(obj)
7171

7272

73+
def convert_to_yaml(obj) -> str:
74+
obj = convert_to_json_serializable(obj)
75+
obj = remove_none(obj)
76+
return yaml.dump(obj, sort_keys=False)
77+
78+
7379
def datamodel_to_dict(dt: BaseModel) -> Dict:
7480
data_dict = dt.dict()
7581
data_dict = remove_none(data_dict)

python3-job-type/python_wrapper/racetrack_client/utils/quantity.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def __init__(self, quantity_str: str):
4343
self.base_number: float = float(match.group('number'))
4444
self.suffix: str = match.group('suffix')
4545

46-
if not self.suffix in self._suffix_multipliers:
46+
if self.suffix not in self._suffix_multipliers:
4747
raise ValueError(f'invalid suffix: {self.suffix}, use one of {self._suffix_multipliers.keys()}')
4848

4949
def __str__(self) -> str:
@@ -81,3 +81,22 @@ def __truediv__(self, other) -> 'Quantity':
8181
def plain_number(self) -> float:
8282
"""Convert quantity to a plain number without any suffixes"""
8383
return self.base_number * self._suffix_multipliers[self.suffix]
84+
85+
@classmethod
86+
def __get_validators__(cls):
87+
# Needed to make it compatible with pydantic's Custom Data Types
88+
# https://docs.pydantic.dev/usage/types/#custom-data-types
89+
yield cls.validate
90+
91+
@classmethod
92+
def validate(cls, v):
93+
if v is None:
94+
return None
95+
return Quantity(str(v))
96+
97+
@classmethod
98+
def __modify_schema__(cls, field_schema):
99+
# Needed to make it compatible with pydantic's Custom Data Types
100+
field_schema.update(
101+
examples=["128974848", "129M", "128974848000m", "123Mi", "0.129G"],
102+
)

python3-job-type/python_wrapper/racetrack_client/utils/request.py

Lines changed: 84 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@
33
from email.message import Message
44
from http.client import HTTPResponse
55
from http.client import responses
6-
from typing import Any, Optional, Dict
6+
from typing import Any, List, Optional, Dict, Union
77
from urllib import request
88
from urllib.error import HTTPError, URLError
99
import urllib.parse as urlparse
1010
from urllib.parse import urlencode
1111

1212
from racetrack_client.log.context_error import ContextError
1313

14+
DEBUG_MODE = False # print the actual bytes sent in requests and responses
15+
1416

1517
class RequestError(ContextError):
1618
"""HTTP Request error due to network error, bad URL, unreachable address"""
@@ -27,13 +29,16 @@ def __init__(self, error_context: str, status_code: int):
2729

2830
class Response:
2931

30-
def __init__(self,
32+
def __init__(
33+
self,
3134
url: str,
35+
method: str,
3236
status_code: int,
3337
content: bytes,
3438
headers: Message,
3539
):
3640
self._url: str = url
41+
self._method: str = method.upper()
3742
self._status_code: int = status_code
3843
self._content: bytes = content
3944
self._headers: Message = headers
@@ -62,7 +67,7 @@ def content(self) -> bytes:
6267
def text(self) -> str:
6368
return self._content.decode('utf8')
6469

65-
def json(self) -> Dict:
70+
def json(self) -> Union[Dict, List]:
6671
response_data = self._content.decode('utf8')
6772
if not response_data:
6873
raise RuntimeError('no content in response to decode as JSON')
@@ -76,82 +81,96 @@ def is_json(self) -> bool:
7681
def url(self) -> str:
7782
return self._url
7883

84+
@property
85+
def method(self) -> str:
86+
return self._method
87+
7988
@property
8089
def headers(self) -> Message:
8190
return self._headers
8291

8392
def header(self, name: str) -> Optional[str]:
8493
return self._headers[name]
8594

95+
@property
96+
def error_details(self) -> str:
97+
return f'{self.status_code} {self.status_reason} for url: {self.method} {self.url}'
98+
8699

87100
class Requests:
88101

89102
insecure: bool = True # whether to verify SSL certificates
90103

91104
@classmethod
92-
def get(cls,
105+
def get(
106+
cls,
93107
url: str,
94108
params: Optional[Dict[str, Any]] = None,
95109
headers: Optional[Dict[str, str]] = None,
96-
timeout: float = None,
97-
) -> Response:
110+
timeout: Optional[float] = None,
111+
) -> Response:
98112
return cls._make_request('GET', url, None, None, params, headers, timeout)
99113

100114
@classmethod
101-
def post(cls,
115+
def post(
116+
cls,
102117
url: str,
103118
json: Optional[Any] = None,
104119
data: Optional[bytes] = None,
105120
params: Optional[Dict[str, Any]] = None,
106121
headers: Optional[Dict[str, str]] = None,
107-
timeout: float = None,
108-
) -> Response:
122+
timeout: Optional[float] = None,
123+
) -> Response:
109124
return cls._make_request('POST', url, json, data, params, headers, timeout)
110125

111126
@classmethod
112-
def put(cls,
127+
def put(
128+
cls,
113129
url: str,
114130
json: Optional[Any] = None,
115131
data: Optional[bytes] = None,
116132
params: Optional[Dict[str, Any]] = None,
117133
headers: Optional[Dict[str, str]] = None,
118-
timeout: float = None,
119-
) -> Response:
134+
timeout: Optional[float] = None,
135+
) -> Response:
120136
return cls._make_request('PUT', url, json, data, params, headers, timeout)
121137

122138
@classmethod
123-
def delete(cls,
139+
def delete(
140+
cls,
124141
url: str,
125142
json: Optional[Any] = None,
126143
data: Optional[bytes] = None,
127144
params: Optional[Dict[str, Any]] = None,
128145
headers: Optional[Dict[str, str]] = None,
129-
timeout: float = None,
130-
) -> Response:
146+
timeout: Optional[float] = None,
147+
) -> Response:
131148
return cls._make_request('DELETE', url, json, data, params, headers, timeout)
132149

133150
@classmethod
134-
def request(cls,
151+
def request(
152+
cls,
135153
method: str,
136154
url: str,
137155
json: Optional[Any] = None,
138156
data: Optional[bytes] = None,
139157
params: Optional[Dict[str, Any]] = None,
140158
headers: Optional[Dict[str, str]] = None,
141-
timeout: float = None,
142-
) -> Response:
159+
timeout: Optional[float] = None,
160+
) -> Response:
143161
return cls._make_request(method.upper(), url, json, data, params, headers, timeout)
144162

145163
@classmethod
146-
def _make_request(cls,
164+
def _make_request(
165+
cls,
147166
method: str,
148167
url: str,
149168
jsondata: Optional[Any] = None,
150169
data: Optional[bytes] = None,
151170
params: Optional[Dict[str, Any]] = None,
152171
headers: Optional[Dict[str, str]] = None,
153-
timeout: float = None,
154-
) -> Response:
172+
timeout: Optional[float] = None,
173+
) -> Response:
155174
"""
156175
Make HTTP request and return response object.
157176
:param method: HTTP method: GET, POST, PUT, DELETE
@@ -190,19 +209,33 @@ def _make_request(cls,
190209
if timeout is not None:
191210
kwargs['timeout'] = timeout
192211

212+
if not req.has_header('User-Agent'):
213+
req.add_header('User-Agent', 'request')
214+
193215
kwargs['context'] = cls._get_ssl_context()
194216

217+
if DEBUG_MODE:
218+
http_handler = request.HTTPHandler(debuglevel=2)
219+
https_handler = request.HTTPSHandler(context=kwargs['context'], debuglevel=2)
220+
opener = request.build_opener(http_handler, https_handler)
221+
request.install_opener(opener)
222+
# Passing 'context' argument to urllib.request.urlopen rebuilds the URL opener,
223+
# not giving a chance to turn the debug mode on.
224+
del kwargs['context']
225+
195226
try:
196227
http_response: HTTPResponse = request.urlopen(req, **kwargs)
197228
return Response(
198229
url=url,
230+
method=method,
199231
status_code=http_response.status,
200232
content=http_response.read(),
201233
headers=http_response.headers,
202234
)
203235
except HTTPError as e:
204236
return Response(
205237
url=url,
238+
method=method,
206239
status_code=e.code,
207240
content=e.read(),
208241
headers=e.headers,
@@ -232,7 +265,7 @@ def build_url_with_params(
232265
return urlparse.urlunparse(url_parts)
233266

234267

235-
def parse_response(response: Response, error_context: str) -> Optional[Dict]:
268+
def parse_response(response: Response, error_context: str) -> Optional[Union[Dict, List]]:
236269
"""
237270
Ensure response was successful. If not, try to extract error message from it.
238271
:return: response parsed as JSON object
@@ -248,34 +281,53 @@ def parse_response(response: Response, error_context: str) -> Optional[Dict]:
248281
raise "Deployment error: 500 Internal Server Error: you have no power here"
249282
"""
250283
try:
251-
result: Optional[Dict] = None
284+
result: Optional[Union[Dict, List]] = None
252285
if 'application/json' in response.headers['content-type']:
253286
result = response.json()
254287

255288
if response.ok:
256289
return result
257290

258-
if result is not None and 'error' in result:
259-
raise RuntimeError(f'{response.status_reason}: {result.get("error")}')
260-
response.raise_for_status()
261-
return result
291+
if result is not None and isinstance(result, dict) and 'error' in result:
292+
raise ContextError(response.status_reason, RuntimeError(result.get("error")))
293+
raise ResponseError(response.error_details, response.status_code)
262294
except Exception as e:
263295
raise ResponseError(error_context, response.status_code) from e
264296

265297

266298
def parse_response_object(response: Response, error_context: str) -> Dict:
267299
try:
268300
if 'application/json' not in response.headers['content-type']:
269-
raise RuntimeError('expected JSON response')
301+
raise RuntimeError(f'expected JSON response, got "{response.headers["content-type"]}", '
302+
f'{response.error_details}')
303+
304+
result = response.json()
305+
306+
if response.ok:
307+
assert isinstance(result, dict), f'response JSON expected to be a dictionary, got {type(result)}'
308+
return result
309+
310+
if result is not None and isinstance(result, dict) and 'error' in result:
311+
raise ContextError(response.status_reason, RuntimeError(result.get("error")))
312+
raise ResponseError(response.error_details, response.status_code)
313+
except Exception as e:
314+
raise ResponseError(error_context, response.status_code) from e
315+
316+
317+
def parse_response_list(response: Response, error_context: str) -> List:
318+
try:
319+
if 'application/json' not in response.headers['content-type']:
320+
raise RuntimeError(f'expected JSON response, got "{response.headers["content-type"]}", '
321+
f'{response.error_details}')
270322

271323
result = response.json()
272324

273325
if response.ok:
326+
assert isinstance(result, list), f'response JSON expected to be a list, got {type(result)}'
274327
return result
275328

276-
if result is not None and 'error' in result:
277-
raise RuntimeError(f'{response.status_reason}: {result.get("error")}')
278-
response.raise_for_status()
279-
return result
329+
if result is not None and isinstance(result, dict) and 'error' in result:
330+
raise ContextError(response.status_reason, RuntimeError(result.get("error")))
331+
raise ResponseError(response.error_details, response.status_code)
280332
except Exception as e:
281333
raise ResponseError(error_context, response.status_code) from e

python3-job-type/python_wrapper/racetrack_commons/api/asgi/access_log.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import time
2+
13
from fastapi import FastAPI, Request, Response
24

35
from racetrack_client.log.logs import get_logger
46
from racetrack_commons.api.asgi.asgi_server import HIDDEN_ACCESS_LOGS
7+
from racetrack_commons.api.metrics import metric_request_duration, metric_requests_done, metric_requests_started
58
from racetrack_commons.api.tracing import RequestTracingLogger, get_caller_header_name, get_tracing_header_name
69

710
logger = get_logger(__name__)
@@ -45,6 +48,8 @@ def enable_response_access_log(fastapi_app: FastAPI):
4548

4649
@fastapi_app.middleware('http')
4750
async def access_log(request: Request, call_next) -> Response:
51+
metric_requests_started.inc()
52+
start_time = time.time()
4853
try:
4954
response: Response = await call_next(request)
5055
except RuntimeError as exc:
@@ -59,6 +64,9 @@ async def access_log(request: Request, call_next) -> Response:
5964
logger.error(f"Request cancelled by the client: {method} {uri}")
6065
return Response(status_code=204) # No Content
6166
raise
67+
finally:
68+
metric_request_duration.observe(time.time() - start_time)
69+
metric_requests_done.inc()
6270

6371
method = request.method
6472
uri = request.url.replace(scheme='', netloc='')

0 commit comments

Comments
 (0)