Skip to content

Commit d4e323d

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 d4e323d

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)