Skip to content

Commit 958a4e3

Browse files
author
Vic Putz
committed
automated push from c6b2d6be2ed21d94f1b8a868c28719fe8ba196e9
1 parent da710da commit 958a4e3

File tree

8 files changed

+139
-25
lines changed

8 files changed

+139
-25
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ classifiers = [
1616
"Operating System :: OS Independent",
1717
]
1818
requires = [
19+
"aiohttp >= 3.7.3",
1920
"backoff >= 1.10.0",
2021
"requests >= 2.24.0",
2122
"python-decouple >= 3.3",

qcware/api_calls/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1-
from .api_call import (api_call, post_call, wait_for_call, handle_result,
2-
retrieve_result, async_retrieve_result, status, cancel)
1+
from .api_call import (api_call, post_call, async_post_call, wait_for_call,
2+
handle_result, retrieve_result, async_retrieve_result,
3+
status, cancel)

qcware/api_calls/api_call.py

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from urllib.parse import urljoin
44
import backoff
55
from ..request import post, get
6+
from ..async_request import post as async_post, get as async_get
67
from ..exceptions import ApiCallExecutionError, ApiTimeoutError
78
from ..util.transforms import client_result_from_wire
89
from ..config import (client_timeout, do_client_api_compatibility_check_once,
@@ -12,6 +13,17 @@
1213

1314

1415
def post_call(endpoint: str, data: dict):
16+
api_call_context = data.get('api_call_context', None)
17+
if api_call_context is None:
18+
api_call_context = current_context()
19+
host = api_call_context.qcware_host
20+
# replace the ApiCallContext class with a jsonable dict
21+
data['api_call_context'] = api_call_context.dict()
22+
url = urljoin(host, endpoint)
23+
return post(url, data)
24+
25+
26+
async def async_post_call(endpoint: str, data: dict):
1527
"""
1628
Centralizes the post for the API call. Assumes the data dict
1729
contains an entry with key 'api_key'; if this is missing or set to
@@ -25,31 +37,37 @@ def post_call(endpoint: str, data: dict):
2537
# replace the ApiCallContext class with a jsonable dict
2638
data['api_call_context'] = api_call_context.dict()
2739
url = urljoin(host, endpoint)
28-
return post(url, data)
40+
return await async_post(url, data)
2941

3042

3143
def api_call(api_call_context: ApiCallContext, call_token: str):
3244
api_call_context = current_context(
3345
) if api_call_context is None else api_call_context
34-
api_call_context = api_call_context.dict()
3546
do_client_api_compatibility_check_once()
36-
return post(f'{api_call_context["qcware_host"]}/api_calls', locals())
47+
return post(
48+
f'{api_call_context.qcware_host}/api_calls',
49+
dict(api_call_context=api_call_context.dict(), call_token=call_token))
50+
51+
52+
async def async_api_call(api_call_context: ApiCallContext, call_token: str):
53+
api_call_context = current_context(
54+
) if api_call_context is None else api_call_context
55+
do_client_api_compatibility_check_once()
56+
return await async_post(
57+
f'{api_call_context.qcware_host}/api_calls',
58+
dict(api_call_context=api_call_context.dict(), call_token=call_token))
3759

3860

3961
def status(call_token: str):
4062
api_call_context = current_context()
41-
api_call_context = api_call_context.dict()
4263
do_client_api_compatibility_check_once()
43-
return post(f'{api_call_context["qcware_host"]}/api_calls/status',
44-
locals())
64+
return post(f'{api_call_context.qcware_host}/api_calls/status', locals())
4565

4666

4767
def cancel(call_token: str):
4868
api_call_context = current_context()
49-
api_call_context = api_call_context.dict()
5069
do_client_api_compatibility_check_once()
51-
return post(f'{api_call_context["qcware_host"]}/api_calls/cancel',
52-
locals())
70+
return post(f'{api_call_context.qcware_host}/api_calls/cancel', locals())
5371

5472

5573
def _print_waiting_handler(details: Dict):
@@ -70,6 +88,18 @@ def wait_for_call(call_token: str, api_call_context=None):
7088
return api_call(api_call_context, call_token)
7189

7290

91+
@backoff.on_predicate(backoff.constant,
92+
interval=1,
93+
predicate=lambda a: a.get('state') == 'open',
94+
max_time=client_timeout,
95+
on_backoff=_print_waiting_handler)
96+
async def async_wait_for_call(call_token: str, api_call_context=None):
97+
api_call_context = current_context(
98+
) if api_call_context is None else api_call_context
99+
# backoff.on_predicate is mildly problematic.
100+
return await async_api_call(api_call_context, call_token)
101+
102+
73103
def handle_result(api_call):
74104
if api_call['state'] == 'error':
75105
if 'result_url' in api_call:
@@ -145,6 +175,6 @@ async def async_retrieve_result(call_token: str,
145175
"""
146176
while True:
147177
try:
148-
return handle_result(wait_for_call(call_token))
178+
return handle_result(await async_wait_for_call(call_token))
149179
except ApiTimeoutError as e:
150180
await asyncio.sleep(async_interval_between_tries())

qcware/async_request.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import backoff
2+
import requests
3+
import asyncio
4+
import aiohttp
5+
6+
from .exceptions import ApiCallFailedError, ApiCallResultUnavailableError
7+
8+
_client_session = None
9+
10+
11+
def client_session() -> aiohttp.ClientSession:
12+
"""
13+
Singleton guardian for client session. This may need to be moved
14+
to being a contextvar, and it could be that the whole python Client
15+
needs to be made instantiable (for sessions). But since aiohttp is
16+
single-threaded this should be OK for now.
17+
"""
18+
global _client_session
19+
if _client_session is None:
20+
_client_session = aiohttp.ClientSession()
21+
return _client_session
22+
23+
24+
def _fatal_code(e):
25+
return 400 <= e.response.status_code < 500
26+
27+
28+
# By default, evidently aiohttp doesn't raise exceptions for non-200
29+
# statuses, so backoff has trouble unless you specifically ask
30+
# using raise_for_status = True; see
31+
# https://stackoverflow.com/questions/56152651/how-to-retry-async-aiohttp-requests-depending-on-the-status-code
32+
33+
34+
@backoff.on_exception(backoff.expo,
35+
requests.exceptions.RequestException,
36+
max_tries=3,
37+
giveup=_fatal_code)
38+
def post_request(url, data):
39+
return client_session().post(url, json=data, raise_for_status=True)
40+
41+
42+
@backoff.on_exception(backoff.expo,
43+
requests.exceptions.RequestException,
44+
max_tries=3,
45+
giveup=_fatal_code)
46+
def get_request(url):
47+
return client_session().get(url, raise_for_status=True)
48+
49+
50+
async def post(url, data):
51+
async with post_request(url, data) as response:
52+
if response.status >= 400:
53+
raise ApiCallFailedError(response.json()['message'])
54+
return await response.json()
55+
56+
57+
async def get(url):
58+
async with get_request(url) as response:
59+
if response.status >= 400:
60+
raise ApiCallResultUnavailableError(
61+
'Unable to retrieve result, please try again later or contact support'
62+
)
63+
return await response.text()

qcware/config/config.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
from decouple import config, UndefinedValueError
1+
from decouple import config, UndefinedValueError # type: ignore
22
from urllib.parse import urlparse, urljoin
33
from typing import Optional
44
from functools import reduce
55
from packaging import version
66
import requests
7-
import colorama
7+
import colorama # type: ignore
88
from .api_semver import api_semver
99
import os
1010
from pydantic import BaseModel
@@ -53,13 +53,17 @@ def qcware_api_key(override: Optional[str] = None) -> str:
5353
return result
5454

5555

56-
def is_valid_host_url(url: str) -> bool:
56+
def is_valid_host_url(url: Optional[str]) -> bool:
5757
"""
5858
Checks if a host url is valid. A valid host url is just a scheme
5959
(http/https), a net location, and no path.
6060
"""
61-
result = urlparse(url)
62-
return all([result.scheme, result.netloc]) and not result.path
61+
if url is None:
62+
result = False
63+
else:
64+
parse_result = urlparse(url)
65+
result = all([parse_result.scheme, parse_result.netloc]) and not parse_result.path
66+
return result
6367

6468

6569
def qcware_host(override: Optional[str] = None) -> str:
@@ -80,7 +84,8 @@ def qcware_host(override: Optional[str] = None) -> str:
8084
# check to make sure the host is a valid url
8185

8286
if is_valid_host_url(result):
83-
return result
87+
# type ignored below because if we get here, it's a valid string
88+
return result # type:ignore
8489
else:
8590
raise ConfigurationError(
8691
f"Configured QCWARE_HOST ({result}): does not seem to be"
@@ -328,7 +333,7 @@ class ApiCredentials(BaseModel):
328333

329334
class ApiCallContext(BaseModel):
330335
qcware_host: Optional[str] = None
331-
credentials: Optional[ApiCredentials] = None
336+
credentials = ApiCredentials()
332337
server_timeout: Optional[int] = None
333338
client_timeout: Optional[int] = None
334339
async_interval_between_tries: Optional[float] = None
@@ -355,7 +360,7 @@ def root_context() -> ApiCallContext:
355360
default=SchedulingMode.immediate))
356361

357362

358-
_contexts = contextvars.ContextVar('contexts', default=[])
363+
_contexts: contextvars.ContextVar = contextvars.ContextVar('contexts', default=[])
359364

360365

361366
def push_context(**kwargs):

qcware/optimization/api/solve_binary.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import asyncio
66
from ... import logger
7-
from ...api_calls import post_call, wait_for_call, handle_result, async_retrieve_result
7+
from ...api_calls import post_call, async_post_call, wait_for_call, handle_result, async_retrieve_result
88
from ...util.transforms import client_args_to_wire
99
from ...exceptions import ApiTimeoutError
1010
from ...config import (ApiCallContext, client_timeout,
@@ -991,7 +991,7 @@ async def async_solve_binary(
991991
:rtype: dict
992992
"""
993993
data = client_args_to_wire('optimization.solve_binary', **locals())
994-
api_call = post_call('optimization/solve_binary', data)
994+
api_call = await async_post_call('optimization/solve_binary', data)
995995
logger.info(
996996
f'API call to optimization.solve_binary successful. Your API token is {api_call["uid"]}'
997997
)

qcware/request.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,19 @@
33

44
from .exceptions import ApiCallFailedError, ApiCallResultUnavailableError
55

6+
_client_session = None
7+
8+
9+
def client_session() -> requests.Session:
10+
"""
11+
Singleton guardian for client session
12+
"""
13+
global _client_session
14+
if _client_session is None:
15+
_client_session = requests.Session()
16+
return _client_session
17+
18+
619

720
def _fatal_code(e):
821
return 400 <= e.response.status_code < 500
@@ -13,15 +26,15 @@ def _fatal_code(e):
1326
max_tries=3,
1427
giveup=_fatal_code)
1528
def post_request(url, data):
16-
return requests.post(url, json=data)
29+
return client_session().post(url, json=data)
1730

1831

1932
@backoff.on_exception(backoff.expo,
2033
requests.exceptions.RequestException,
2134
max_tries=3,
2235
giveup=_fatal_code)
2336
def get_request(url):
24-
return requests.get(url)
37+
return client_session().get(url)
2538

2639

2740
def post(url, data):

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
aiohttp==3.7.3
12
pydantic==1.7.3
23
wheel==0.36.2
34
backoff==1.10.0
@@ -11,4 +12,4 @@ lz4==3.1.3
1112
# git+ssh://git@github.com:/qcware/quasar-dev.git@4dd310ed594f829e85f7ff49139289119b049e6b
1213
qubovert==1.2.3
1314
tabulate==0.8.8
14-
qcware-quasar==1.0.2
15+
qcware-quasar==1.0.2

0 commit comments

Comments
 (0)