Skip to content

Commit 0173d50

Browse files
Merge pull request #2878 from VWS-Python/adamtheturtle/asyncio-support-analysis
Add asyncio support with async client classes
2 parents 60e5a6e + db3fb18 commit 0173d50

15 files changed

+2656
-4
lines changed

docs/source/api-reference.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ API Reference
55
:undoc-members:
66
:members:
77

8+
.. automodule:: vws.async_vws
9+
:undoc-members:
10+
:members:
11+
12+
.. automodule:: vws.async_query
13+
:undoc-members:
14+
:members:
15+
16+
.. automodule:: vws.async_vumark_service
17+
:undoc-members:
18+
:members:
19+
820
.. automodule:: vws.reports
921
:undoc-members:
1022
:members:

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ optional-dependencies.dev = [
6060
"pyright==1.1.408",
6161
"pyroma==5.0.1",
6262
"pytest==9.0.2",
63+
"pytest-asyncio==1.3.0",
6364
"pytest-cov==7.0.0",
6465
"pyyaml==6.0.3",
6566
"ruff==0.15.2",
@@ -359,6 +360,7 @@ ignore_path = [
359360
# but Vulture does not enable this.
360361
ignore_names = [
361362
# Public API classes imported by users from vws.transports
363+
"AsyncHTTPXTransport",
362364
"HTTPXTransport",
363365
# pytest configuration
364366
"pytest_collect_file",

spelling_private_dict.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ admin
2727
api
2828
args
2929
ascii
30+
async
31+
asyncio
3032
beartype
3133
bool
3234
boolean

src/vws/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
"""A library for Vuforia Web Services."""
22

3+
from .async_query import AsyncCloudRecoService
4+
from .async_vumark_service import AsyncVuMarkService
5+
from .async_vws import AsyncVWS
36
from .query import CloudRecoService
47
from .vumark_service import VuMarkService
58
from .vws import VWS
69

710
__all__ = [
811
"VWS",
12+
"AsyncCloudRecoService",
13+
"AsyncVWS",
14+
"AsyncVuMarkService",
915
"CloudRecoService",
1016
"VuMarkService",
1117
]

src/vws/_async_vws_request.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Internal helper for making authenticated async requests to the
2+
Vuforia Target API.
3+
"""
4+
5+
from beartype import BeartypeConf, beartype
6+
from vws_auth_tools import authorization_header, rfc_1123_date
7+
8+
from vws.response import Response
9+
from vws.transports import AsyncTransport
10+
11+
12+
@beartype(conf=BeartypeConf(is_pep484_tower=True))
13+
async def async_target_api_request(
14+
*,
15+
content_type: str,
16+
server_access_key: str,
17+
server_secret_key: str,
18+
method: str,
19+
data: bytes,
20+
request_path: str,
21+
base_vws_url: str,
22+
request_timeout_seconds: float | tuple[float, float],
23+
extra_headers: dict[str, str],
24+
transport: AsyncTransport,
25+
) -> Response:
26+
"""Make an async request to the Vuforia Target API.
27+
28+
Args:
29+
content_type: The content type of the request.
30+
server_access_key: A VWS server access key.
31+
server_secret_key: A VWS server secret key.
32+
method: The HTTP method which will be used in the
33+
request.
34+
data: The request body which will be used in the
35+
request.
36+
request_path: The path to the endpoint which will be
37+
used in the request.
38+
base_vws_url: The base URL for the VWS API.
39+
request_timeout_seconds: The timeout for the request.
40+
This can be a float to set both the connect and
41+
read timeouts, or a (connect, read) tuple.
42+
extra_headers: Additional headers to include in the
43+
request.
44+
transport: The async HTTP transport to use for the
45+
request.
46+
47+
Returns:
48+
The response to the request.
49+
"""
50+
date_string = rfc_1123_date()
51+
52+
signature_string = authorization_header(
53+
access_key=server_access_key,
54+
secret_key=server_secret_key,
55+
method=method,
56+
content=data,
57+
content_type=content_type,
58+
date=date_string,
59+
request_path=request_path,
60+
)
61+
62+
headers = {
63+
"Authorization": signature_string,
64+
"Date": date_string,
65+
"Content-Type": content_type,
66+
**extra_headers,
67+
}
68+
69+
url = base_vws_url.rstrip("/") + request_path
70+
71+
return await transport(
72+
method=method,
73+
url=url,
74+
headers=headers,
75+
data=data,
76+
request_timeout=request_timeout_seconds,
77+
)

src/vws/async_query.py

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
"""Async tools for interacting with the Vuforia Cloud Recognition
2+
Web APIs.
3+
"""
4+
5+
import datetime
6+
import json
7+
from http import HTTPMethod, HTTPStatus
8+
from typing import Any, Self
9+
10+
from beartype import BeartypeConf, beartype
11+
from urllib3.filepost import encode_multipart_formdata
12+
from vws_auth_tools import authorization_header, rfc_1123_date
13+
14+
from vws._image_utils import ImageType as _ImageType
15+
from vws._image_utils import get_image_data as _get_image_data
16+
from vws.exceptions.cloud_reco_exceptions import (
17+
AuthenticationFailureError,
18+
BadImageError,
19+
InactiveProjectError,
20+
MaxNumResultsOutOfRangeError,
21+
RequestTimeTooSkewedError,
22+
)
23+
from vws.exceptions.custom_exceptions import (
24+
RequestEntityTooLargeError,
25+
ServerError,
26+
)
27+
from vws.include_target_data import CloudRecoIncludeTargetData
28+
from vws.reports import QueryResult, TargetData
29+
from vws.transports import AsyncHTTPXTransport, AsyncTransport
30+
31+
32+
@beartype(conf=BeartypeConf(is_pep484_tower=True))
33+
class AsyncCloudRecoService:
34+
"""An async interface to the Vuforia Cloud Recognition Web
35+
APIs.
36+
"""
37+
38+
def __init__(
39+
self,
40+
*,
41+
client_access_key: str,
42+
client_secret_key: str,
43+
base_vwq_url: str = "https://cloudreco.vuforia.com",
44+
request_timeout_seconds: float | tuple[float, float] = 30.0,
45+
transport: AsyncTransport | None = None,
46+
) -> None:
47+
"""
48+
Args:
49+
client_access_key: A VWS client access key.
50+
client_secret_key: A VWS client secret key.
51+
base_vwq_url: The base URL for the VWQ API.
52+
request_timeout_seconds: The timeout for each
53+
HTTP request. This can be a float to set both
54+
the connect and read timeouts, or a
55+
(connect, read) tuple.
56+
transport: The async HTTP transport to use for
57+
requests. Defaults to
58+
``AsyncHTTPXTransport()``.
59+
"""
60+
self._client_access_key = client_access_key
61+
self._client_secret_key = client_secret_key
62+
self._base_vwq_url = base_vwq_url
63+
self._request_timeout_seconds = request_timeout_seconds
64+
self._transport = transport or AsyncHTTPXTransport()
65+
66+
async def aclose(self) -> None:
67+
"""Close the underlying transport if it supports closing."""
68+
close = getattr(self._transport, "aclose", None)
69+
if close is not None:
70+
await close()
71+
72+
async def __aenter__(self) -> Self:
73+
"""Enter the async context manager."""
74+
return self
75+
76+
async def __aexit__(self, *_args: object) -> None:
77+
"""Exit the async context manager and close the transport."""
78+
await self.aclose()
79+
80+
async def query(
81+
self,
82+
*,
83+
image: _ImageType,
84+
max_num_results: int = 1,
85+
include_target_data: CloudRecoIncludeTargetData = (
86+
CloudRecoIncludeTargetData.TOP
87+
),
88+
) -> list[QueryResult]:
89+
"""Use the Vuforia Web Query API to make an Image
90+
Recognition Query.
91+
92+
See
93+
https://developer.vuforia.com/library/web-api/vuforia-query-web-api
94+
for parameter details.
95+
96+
Args:
97+
image: The image to make a query against.
98+
max_num_results: The maximum number of matching
99+
targets to be returned.
100+
include_target_data: Indicates if target_data
101+
records shall be returned for the matched
102+
targets. Accepted values are top (default
103+
value, only return target_data for top ranked
104+
match), none (return no target_data), all
105+
(for all matched targets).
106+
107+
Raises:
108+
~vws.exceptions.cloud_reco_exceptions.AuthenticationFailureError:
109+
The client access key pair is not correct.
110+
~vws.exceptions.cloud_reco_exceptions.MaxNumResultsOutOfRangeError:
111+
``max_num_results`` is not within the range (1, 50).
112+
~vws.exceptions.cloud_reco_exceptions.InactiveProjectError: The
113+
project is inactive.
114+
~vws.exceptions.cloud_reco_exceptions.RequestTimeTooSkewedError:
115+
There is an error with the time sent to Vuforia.
116+
~vws.exceptions.cloud_reco_exceptions.BadImageError: There is a
117+
problem with the given image. For example, it must be a JPEG or
118+
PNG file in the grayscale or RGB color space.
119+
~vws.exceptions.custom_exceptions.RequestEntityTooLargeError: The
120+
given image is too large.
121+
~vws.exceptions.custom_exceptions.ServerError: There is an
122+
error with Vuforia's servers.
123+
124+
Returns:
125+
An ordered list of target details of matching
126+
targets.
127+
"""
128+
image_content = _get_image_data(image=image)
129+
body: dict[str, Any] = {
130+
"image": (
131+
"image.jpeg",
132+
image_content,
133+
"image/jpeg",
134+
),
135+
"max_num_results": (
136+
None,
137+
int(max_num_results),
138+
"text/plain",
139+
),
140+
"include_target_data": (
141+
None,
142+
include_target_data.value,
143+
"text/plain",
144+
),
145+
}
146+
date = rfc_1123_date()
147+
request_path = "/v1/query"
148+
content, content_type_header = encode_multipart_formdata(fields=body)
149+
method = HTTPMethod.POST
150+
151+
authorization_string = authorization_header(
152+
access_key=self._client_access_key,
153+
secret_key=self._client_secret_key,
154+
method=method,
155+
content=content,
156+
# Note that this is not the actual Content-Type
157+
# header value sent.
158+
content_type="multipart/form-data",
159+
date=date,
160+
request_path=request_path,
161+
)
162+
163+
headers = {
164+
"Authorization": authorization_string,
165+
"Date": date,
166+
"Content-Type": content_type_header,
167+
}
168+
169+
response = await self._transport(
170+
method=method,
171+
url=self._base_vwq_url.rstrip("/") + request_path,
172+
headers=headers,
173+
data=content,
174+
request_timeout=self._request_timeout_seconds,
175+
)
176+
177+
if response.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE:
178+
raise RequestEntityTooLargeError(response=response)
179+
180+
if "Integer out of range" in response.text:
181+
raise MaxNumResultsOutOfRangeError(
182+
response=response,
183+
)
184+
185+
if (
186+
response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR
187+
): # pragma: no cover
188+
raise ServerError(response=response)
189+
190+
result_code = json.loads(s=response.text)["result_code"]
191+
if result_code != "Success":
192+
exception = {
193+
"AuthenticationFailure": (AuthenticationFailureError),
194+
"BadImage": BadImageError,
195+
"InactiveProject": InactiveProjectError,
196+
"RequestTimeTooSkewed": (RequestTimeTooSkewedError),
197+
}[result_code]
198+
raise exception(response=response)
199+
200+
result: list[QueryResult] = []
201+
result_list = list(
202+
json.loads(s=response.text)["results"],
203+
)
204+
for item in result_list:
205+
target_data: TargetData | None = None
206+
if "target_data" in item:
207+
target_data_dict = item["target_data"]
208+
metadata = target_data_dict["application_metadata"]
209+
timestamp_string = target_data_dict["target_timestamp"]
210+
target_timestamp = datetime.datetime.fromtimestamp(
211+
timestamp=timestamp_string,
212+
tz=datetime.UTC,
213+
)
214+
target_data = TargetData(
215+
name=target_data_dict["name"],
216+
application_metadata=metadata,
217+
target_timestamp=target_timestamp,
218+
)
219+
220+
query_result = QueryResult(
221+
target_id=item["target_id"],
222+
target_data=target_data,
223+
)
224+
225+
result.append(query_result)
226+
return result

0 commit comments

Comments
 (0)