Skip to content

Commit 0f08696

Browse files
adamtheturtleclaude
andcommitted
Add asyncio support with async client classes
Implement async versions of all three client classes (AsyncVWS, AsyncCloudRecoService, AsyncVuMarkService) alongside transport abstraction. Adds AsyncTransport protocol and AsyncHTTPXTransport using httpx.AsyncClient. Includes 99 new async integration tests with complete exception coverage. All 287 tests pass with strict mypy and ruff validation. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 60e5a6e commit 0f08696

15 files changed

+2458
-2
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: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
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
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 query(
67+
self,
68+
*,
69+
image: _ImageType,
70+
max_num_results: int = 1,
71+
include_target_data: CloudRecoIncludeTargetData = (
72+
CloudRecoIncludeTargetData.TOP
73+
),
74+
) -> list[QueryResult]:
75+
"""Use the Vuforia Web Query API to make an Image
76+
Recognition Query.
77+
78+
See
79+
https://developer.vuforia.com/library/web-api/vuforia-query-web-api
80+
for parameter details.
81+
82+
Args:
83+
image: The image to make a query against.
84+
max_num_results: The maximum number of matching
85+
targets to be returned.
86+
include_target_data: Indicates if target_data
87+
records shall be returned for the matched
88+
targets. Accepted values are top (default
89+
value, only return target_data for top ranked
90+
match), none (return no target_data), all
91+
(for all matched targets).
92+
93+
Raises:
94+
~vws.exceptions.cloud_reco_exceptions.AuthenticationFailureError:
95+
The client access key pair is not correct.
96+
~vws.exceptions.cloud_reco_exceptions.MaxNumResultsOutOfRangeError:
97+
``max_num_results`` is not within the range (1, 50).
98+
~vws.exceptions.cloud_reco_exceptions.InactiveProjectError: The
99+
project is inactive.
100+
~vws.exceptions.cloud_reco_exceptions.RequestTimeTooSkewedError:
101+
There is an error with the time sent to Vuforia.
102+
~vws.exceptions.cloud_reco_exceptions.BadImageError: There is a
103+
problem with the given image. For example, it must be a JPEG or
104+
PNG file in the grayscale or RGB color space.
105+
~vws.exceptions.custom_exceptions.RequestEntityTooLargeError: The
106+
given image is too large.
107+
~vws.exceptions.custom_exceptions.ServerError: There is an
108+
error with Vuforia's servers.
109+
110+
Returns:
111+
An ordered list of target details of matching
112+
targets.
113+
"""
114+
image_content = _get_image_data(image=image)
115+
body: dict[str, Any] = {
116+
"image": (
117+
"image.jpeg",
118+
image_content,
119+
"image/jpeg",
120+
),
121+
"max_num_results": (
122+
None,
123+
int(max_num_results),
124+
"text/plain",
125+
),
126+
"include_target_data": (
127+
None,
128+
include_target_data.value,
129+
"text/plain",
130+
),
131+
}
132+
date = rfc_1123_date()
133+
request_path = "/v1/query"
134+
content, content_type_header = encode_multipart_formdata(fields=body)
135+
method = HTTPMethod.POST
136+
137+
authorization_string = authorization_header(
138+
access_key=self._client_access_key,
139+
secret_key=self._client_secret_key,
140+
method=method,
141+
content=content,
142+
# Note that this is not the actual Content-Type
143+
# header value sent.
144+
content_type="multipart/form-data",
145+
date=date,
146+
request_path=request_path,
147+
)
148+
149+
headers = {
150+
"Authorization": authorization_string,
151+
"Date": date,
152+
"Content-Type": content_type_header,
153+
}
154+
155+
response = await self._transport(
156+
method=method,
157+
url=self._base_vwq_url.rstrip("/") + request_path,
158+
headers=headers,
159+
data=content,
160+
request_timeout=self._request_timeout_seconds,
161+
)
162+
163+
if response.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE:
164+
raise RequestEntityTooLargeError(response=response)
165+
166+
if "Integer out of range" in response.text:
167+
raise MaxNumResultsOutOfRangeError(
168+
response=response,
169+
)
170+
171+
if (
172+
response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR
173+
): # pragma: no cover
174+
raise ServerError(response=response)
175+
176+
result_code = json.loads(s=response.text)["result_code"]
177+
if result_code != "Success":
178+
exception = {
179+
"AuthenticationFailure": (AuthenticationFailureError),
180+
"BadImage": BadImageError,
181+
"InactiveProject": InactiveProjectError,
182+
"RequestTimeTooSkewed": (RequestTimeTooSkewedError),
183+
}[result_code]
184+
raise exception(response=response)
185+
186+
result: list[QueryResult] = []
187+
result_list = list(
188+
json.loads(s=response.text)["results"],
189+
)
190+
for item in result_list:
191+
target_data: TargetData | None = None
192+
if "target_data" in item:
193+
target_data_dict = item["target_data"]
194+
metadata = target_data_dict["application_metadata"]
195+
timestamp_string = target_data_dict["target_timestamp"]
196+
target_timestamp = datetime.datetime.fromtimestamp(
197+
timestamp=timestamp_string,
198+
tz=datetime.UTC,
199+
)
200+
target_data = TargetData(
201+
name=target_data_dict["name"],
202+
application_metadata=metadata,
203+
target_timestamp=target_timestamp,
204+
)
205+
206+
query_result = QueryResult(
207+
target_id=item["target_id"],
208+
target_data=target_data,
209+
)
210+
211+
result.append(query_result)
212+
return result

0 commit comments

Comments
 (0)