vws-python 2026.8.14__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,381 @@
1
+ """Async interface to the Vuforia Model Target Web API."""
2
+
3
+ import asyncio
4
+ import time
5
+ from collections.abc import Sequence # noqa: TC003
6
+ from http import HTTPMethod
7
+ from typing import Self
8
+
9
+ from beartype import BeartypeConf, beartype
10
+
11
+ from vws._model_targets import (
12
+ JSON_CONTENT_TYPE,
13
+ OAUTH2_TOKEN_BODY,
14
+ OAUTH2_TOKEN_PATH,
15
+ access_token_from_response,
16
+ dataset_collection_path,
17
+ dataset_download_path,
18
+ dataset_path,
19
+ dataset_request_body,
20
+ dataset_status_path,
21
+ dataset_uuid_from_response,
22
+ oauth2_token_headers,
23
+ raise_for_error,
24
+ status_report_from_response,
25
+ )
26
+ from vws.exceptions.model_target_exceptions import (
27
+ ModelTargetDatasetTimeoutError,
28
+ )
29
+ from vws.model_target_datasets import ( # noqa: TC001
30
+ ModelTargetDatasetType,
31
+ ModelTargetModel,
32
+ )
33
+ from vws.reports import (
34
+ ModelTargetDatasetStatuses,
35
+ ModelTargetDatasetStatusReport,
36
+ )
37
+ from vws.response import Response # noqa: TC001
38
+ from vws.transports import AsyncHTTPXTransport, AsyncTransport
39
+
40
+ _TOKEN_EXPIRY_MARGIN_SECONDS = 60.0
41
+
42
+
43
+ @beartype(conf=BeartypeConf(is_pep484_tower=True))
44
+ class AsyncModelTargetService:
45
+ """An async interface to the Vuforia Model Target Web API."""
46
+
47
+ def __init__(
48
+ self,
49
+ *,
50
+ client_id: str,
51
+ client_secret: str,
52
+ base_vws_url: str = "https://vws.vuforia.com",
53
+ request_timeout_seconds: float | tuple[float, float] = 30.0,
54
+ transport: AsyncTransport | None = None,
55
+ ) -> None:
56
+ """
57
+ Args:
58
+ client_id: A Model Target Web API OAuth2 client
59
+ ID.
60
+ client_secret: A Model Target Web API OAuth2
61
+ client secret.
62
+ base_vws_url: The base URL for the VWS API, which
63
+ also serves the Model Target Web API.
64
+ request_timeout_seconds: The timeout for each
65
+ HTTP request. This can be a float to set both
66
+ the connect and read timeouts, or a
67
+ (connect, read) tuple.
68
+ transport: The async HTTP transport to use for
69
+ requests. Defaults to
70
+ ``AsyncHTTPXTransport()``.
71
+ """
72
+ self._client_id = client_id
73
+ self._client_secret = client_secret
74
+ self._base_vws_url = base_vws_url
75
+ self._request_timeout_seconds = request_timeout_seconds
76
+ self._transport = (
77
+ transport if transport is not None else AsyncHTTPXTransport()
78
+ )
79
+ self._access_token: str | None = None
80
+ self._access_token_expiry_time = 0.0
81
+
82
+ async def aclose(self) -> None:
83
+ """Close the underlying transport if it supports closing."""
84
+ await self._transport.aclose()
85
+
86
+ async def __aenter__(self) -> Self:
87
+ """Enter the async context manager."""
88
+ return self
89
+
90
+ async def __aexit__(self, *_args: object) -> None:
91
+ """Exit the async context manager and close the transport."""
92
+ await self.aclose()
93
+
94
+ async def get_access_token(self) -> str:
95
+ """Get an OAuth2 access token for the Model Target Web API.
96
+
97
+ A token is requested only when the client has no token which is
98
+ still valid, so this can be called before each request.
99
+
100
+ Returns:
101
+ A bearer token.
102
+
103
+ Raises:
104
+ ~vws.exceptions.model_target_exceptions.ModelTargetOAuth2Error:
105
+ Vuforia did not give an access token. For example, the
106
+ given client ID and client secret may not match a set of
107
+ Model Target Web API credentials.
108
+ """
109
+ request_time = time.monotonic()
110
+ if (
111
+ self._access_token is not None
112
+ and request_time < self._access_token_expiry_time
113
+ ):
114
+ return self._access_token
115
+
116
+ response = await self._transport(
117
+ method=HTTPMethod.POST,
118
+ url=self._base_vws_url.rstrip("/") + OAUTH2_TOKEN_PATH,
119
+ headers=oauth2_token_headers(
120
+ client_id=self._client_id,
121
+ client_secret=self._client_secret,
122
+ ),
123
+ data=OAUTH2_TOKEN_BODY,
124
+ request_timeout=self._request_timeout_seconds,
125
+ )
126
+
127
+ access_token, expires_in_seconds = access_token_from_response(
128
+ response=response,
129
+ )
130
+ self._access_token = access_token
131
+ self._access_token_expiry_time = (
132
+ request_time + expires_in_seconds - _TOKEN_EXPIRY_MARGIN_SECONDS
133
+ )
134
+ return access_token
135
+
136
+ async def make_request(
137
+ self,
138
+ *,
139
+ method: str,
140
+ data: bytes,
141
+ request_path: str,
142
+ extra_headers: dict[str, str] | None = None,
143
+ ) -> Response:
144
+ """Make an authenticated request to the Model Target Web API.
145
+
146
+ Args:
147
+ method: The HTTP method which will be used in
148
+ the request.
149
+ data: The request body which will be used in the
150
+ request.
151
+ request_path: The path to the endpoint which
152
+ will be used in the request.
153
+ extra_headers: Additional headers to include in
154
+ the request.
155
+
156
+ Returns:
157
+ The response to the request.
158
+
159
+ Raises:
160
+ ~vws.exceptions.model_target_exceptions.ModelTargetError:
161
+ Vuforia returned an error.
162
+ ~vws.exceptions.custom_exceptions.ServerError:
163
+ There is an error with Vuforia's servers.
164
+ ~vws.exceptions.vws_exceptions.TooManyRequestsError:
165
+ Vuforia is rate limiting access.
166
+ """
167
+ access_token = await self.get_access_token()
168
+ headers = {
169
+ "Authorization": f"Bearer {access_token}",
170
+ **(extra_headers or {}),
171
+ }
172
+
173
+ response = await self._transport(
174
+ method=method,
175
+ url=self._base_vws_url.rstrip("/") + request_path,
176
+ headers=headers,
177
+ data=data,
178
+ request_timeout=self._request_timeout_seconds,
179
+ )
180
+
181
+ raise_for_error(response=response)
182
+ return response
183
+
184
+ async def create_dataset(
185
+ self,
186
+ *,
187
+ name: str,
188
+ target_sdk: str,
189
+ models: Sequence[ModelTargetModel],
190
+ dataset_type: ModelTargetDatasetType,
191
+ ) -> str:
192
+ """Start generating a Model Target dataset.
193
+
194
+ Vuforia generates the dataset in the background, so it is not
195
+ available to download immediately. Use
196
+ :meth:`wait_for_dataset_generated` to wait for it.
197
+
198
+ Args:
199
+ name: The name of the dataset.
200
+ target_sdk: The Vuforia Engine version to generate the dataset
201
+ for.
202
+ models: The models to generate the dataset from. A standard
203
+ dataset takes exactly one model.
204
+ dataset_type: Whether to create a standard or an advanced
205
+ dataset.
206
+
207
+ Returns:
208
+ The UUID of the new dataset.
209
+
210
+ Raises:
211
+ ~vws.exceptions.model_target_exceptions.ModelTargetAuthenticationError:
212
+ The request was not authenticated.
213
+ ~vws.exceptions.model_target_exceptions.ModelTargetValidationError:
214
+ Vuforia rejected the request. For example, a model may
215
+ give neither a CAD data URL nor a CAD data blob.
216
+ ~vws.exceptions.model_target_exceptions.ModelTargetOAuth2Error:
217
+ Vuforia did not give an access token.
218
+ """
219
+ response = await self.make_request(
220
+ method=HTTPMethod.POST,
221
+ data=dataset_request_body(
222
+ name=name,
223
+ target_sdk=target_sdk,
224
+ models=models,
225
+ ),
226
+ request_path=dataset_collection_path(dataset_type=dataset_type),
227
+ extra_headers={"Content-Type": JSON_CONTENT_TYPE},
228
+ )
229
+
230
+ return dataset_uuid_from_response(response=response)
231
+
232
+ async def get_dataset_status(
233
+ self,
234
+ *,
235
+ dataset_uuid: str,
236
+ dataset_type: ModelTargetDatasetType,
237
+ ) -> ModelTargetDatasetStatusReport:
238
+ """Get the status of a Model Target dataset.
239
+
240
+ Args:
241
+ dataset_uuid: The UUID of the dataset, as given by
242
+ :meth:`create_dataset`.
243
+ dataset_type: The kind of dataset to get the status of.
244
+
245
+ Returns:
246
+ The status of the dataset.
247
+
248
+ Raises:
249
+ ~vws.exceptions.model_target_exceptions.ModelTargetAuthenticationError:
250
+ The request was not authenticated.
251
+ ~vws.exceptions.model_target_exceptions.UnknownModelTargetDatasetError:
252
+ No dataset of the given type matches the given UUID.
253
+ ~vws.exceptions.model_target_exceptions.ModelTargetOAuth2Error:
254
+ Vuforia did not give an access token.
255
+ """
256
+ response = await self.make_request(
257
+ method=HTTPMethod.GET,
258
+ data=b"",
259
+ request_path=dataset_status_path(
260
+ dataset_type=dataset_type,
261
+ dataset_uuid=dataset_uuid,
262
+ ),
263
+ )
264
+
265
+ return status_report_from_response(response=response)
266
+
267
+ async def wait_for_dataset_generated(
268
+ self,
269
+ *,
270
+ dataset_uuid: str,
271
+ dataset_type: ModelTargetDatasetType,
272
+ seconds_between_requests: float = 0.2,
273
+ timeout_seconds: float = 60 * 5,
274
+ ) -> ModelTargetDatasetStatusReport:
275
+ """Wait for Vuforia to finish generating a Model Target dataset.
276
+
277
+ A dataset which failed to generate is also finished, so the
278
+ returned report may have a
279
+ :attr:`~.ModelTargetDatasetStatusReport.status` of
280
+ ``FAILED``.
281
+
282
+ Args:
283
+ dataset_uuid: The UUID of the dataset, as given by
284
+ :meth:`create_dataset`.
285
+ dataset_type: The kind of dataset to wait for.
286
+ seconds_between_requests: The number of seconds to wait between
287
+ requests made while polling the dataset's status.
288
+ timeout_seconds: The maximum number of seconds to wait for the
289
+ dataset to be generated.
290
+
291
+ Returns:
292
+ The status of the dataset once it is no longer processing.
293
+
294
+ Raises:
295
+ ~vws.exceptions.model_target_exceptions.ModelTargetDatasetTimeoutError:
296
+ The dataset was not generated within ``timeout_seconds``
297
+ seconds.
298
+ ~vws.exceptions.model_target_exceptions.UnknownModelTargetDatasetError:
299
+ No dataset of the given type matches the given UUID.
300
+ """
301
+ start_time = time.monotonic()
302
+ while True:
303
+ report = await self.get_dataset_status(
304
+ dataset_uuid=dataset_uuid,
305
+ dataset_type=dataset_type,
306
+ )
307
+ if report.status != ModelTargetDatasetStatuses.PROCESSING:
308
+ return report
309
+
310
+ elapsed_time = time.monotonic() - start_time
311
+ if elapsed_time > timeout_seconds:
312
+ raise ModelTargetDatasetTimeoutError
313
+
314
+ await asyncio.sleep(delay=seconds_between_requests)
315
+
316
+ async def download_dataset(
317
+ self,
318
+ *,
319
+ dataset_uuid: str,
320
+ dataset_type: ModelTargetDatasetType,
321
+ ) -> bytes:
322
+ """Download a generated Model Target dataset.
323
+
324
+ Args:
325
+ dataset_uuid: The UUID of the dataset, as given by
326
+ :meth:`create_dataset`.
327
+ dataset_type: The kind of dataset to download.
328
+
329
+ Returns:
330
+ The dataset, as the bytes of a zip file.
331
+
332
+ Raises:
333
+ ~vws.exceptions.model_target_exceptions.ModelTargetAuthenticationError:
334
+ The request was not authenticated.
335
+ ~vws.exceptions.model_target_exceptions.UnknownModelTargetDatasetError:
336
+ No dataset of the given type matches the given UUID.
337
+ ~vws.exceptions.model_target_exceptions.ModelTargetDatasetNotDoneError:
338
+ Vuforia has not generated the dataset.
339
+ ~vws.exceptions.model_target_exceptions.ModelTargetOAuth2Error:
340
+ Vuforia did not give an access token.
341
+ """
342
+ response = await self.make_request(
343
+ method=HTTPMethod.GET,
344
+ data=b"",
345
+ request_path=dataset_download_path(
346
+ dataset_type=dataset_type,
347
+ dataset_uuid=dataset_uuid,
348
+ ),
349
+ )
350
+
351
+ return response.content
352
+
353
+ async def delete_dataset(
354
+ self,
355
+ *,
356
+ dataset_uuid: str,
357
+ dataset_type: ModelTargetDatasetType,
358
+ ) -> None:
359
+ """Delete a Model Target dataset.
360
+
361
+ Args:
362
+ dataset_uuid: The UUID of the dataset, as given by
363
+ :meth:`create_dataset`.
364
+ dataset_type: The kind of dataset to delete.
365
+
366
+ Raises:
367
+ ~vws.exceptions.model_target_exceptions.ModelTargetAuthenticationError:
368
+ The request was not authenticated.
369
+ ~vws.exceptions.model_target_exceptions.UnknownModelTargetDatasetError:
370
+ No dataset of the given type matches the given UUID.
371
+ ~vws.exceptions.model_target_exceptions.ModelTargetOAuth2Error:
372
+ Vuforia did not give an access token.
373
+ """
374
+ await self.make_request(
375
+ method=HTTPMethod.DELETE,
376
+ data=b"",
377
+ request_path=dataset_path(
378
+ dataset_type=dataset_type,
379
+ dataset_uuid=dataset_uuid,
380
+ ),
381
+ )
vws/async_query.py ADDED
@@ -0,0 +1,224 @@
1
+ """Async tools for interacting with the Vuforia Cloud Recognition
2
+ Web APIs.
3
+ """
4
+
5
+ import json
6
+ from http import HTTPMethod, HTTPStatus
7
+ from typing import Any, Self
8
+
9
+ from beartype import BeartypeConf, beartype
10
+ from urllib3.filepost import encode_multipart_formdata
11
+ from vws_auth_tools import authorization_header, rfc_1123_date
12
+
13
+ from vws._image_utils import ImageType as _ImageType
14
+ from vws._image_utils import get_image_data as _get_image_data
15
+ from vws.exceptions.base_exceptions import CloudRecoError
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
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 = (
65
+ transport if transport is not None else AsyncHTTPXTransport()
66
+ )
67
+
68
+ async def aclose(self) -> None:
69
+ """Close the underlying transport if it supports closing."""
70
+ await self._transport.aclose()
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
+ ~vws.exceptions.base_exceptions.CloudRecoError: Vuforia returned
124
+ a client error without a recognized JSON body.
125
+ json.JSONDecodeError: Vuforia returned a successful response with
126
+ an invalid JSON body.
127
+
128
+ Returns:
129
+ An ordered list of target details of matching
130
+ targets.
131
+ """
132
+ image_content = _get_image_data(image=image)
133
+ body: dict[str, Any] = {
134
+ "image": (
135
+ "image.jpeg",
136
+ image_content,
137
+ "image/jpeg",
138
+ ),
139
+ "max_num_results": (
140
+ None,
141
+ int(max_num_results),
142
+ "text/plain",
143
+ ),
144
+ "include_target_data": (
145
+ None,
146
+ include_target_data.value,
147
+ "text/plain",
148
+ ),
149
+ }
150
+ date = rfc_1123_date()
151
+ request_path = "/v1/query"
152
+ content, content_type_header = encode_multipart_formdata(fields=body)
153
+ method = HTTPMethod.POST
154
+
155
+ authorization_string = authorization_header(
156
+ access_key=self._client_access_key,
157
+ secret_key=self._client_secret_key,
158
+ method=method,
159
+ content=content,
160
+ # Note that this is not the actual Content-Type
161
+ # header value sent.
162
+ content_type="multipart/form-data",
163
+ date=date,
164
+ request_path=request_path,
165
+ )
166
+
167
+ headers = {
168
+ "Authorization": authorization_string,
169
+ "Date": date,
170
+ "Content-Type": content_type_header,
171
+ }
172
+
173
+ response = await self._transport(
174
+ method=method,
175
+ url=self._base_vwq_url.rstrip("/") + request_path,
176
+ headers=headers,
177
+ data=content,
178
+ request_timeout=self._request_timeout_seconds,
179
+ )
180
+
181
+ if response.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE:
182
+ raise RequestEntityTooLargeError(response=response)
183
+
184
+ if "Integer out of range" in response.text:
185
+ raise MaxNumResultsOutOfRangeError(
186
+ response=response,
187
+ )
188
+
189
+ if (
190
+ response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR
191
+ ): # pragma: no cover
192
+ raise ServerError(response=response)
193
+
194
+ content_type = {
195
+ key.lower(): value for key, value in response.headers.items()
196
+ }.get("content-type", "")
197
+ if (
198
+ response.status_code >= HTTPStatus.BAD_REQUEST
199
+ and not content_type.lower().startswith("application/json")
200
+ ):
201
+ raise CloudRecoError(response=response)
202
+
203
+ try:
204
+ response_body = json.loads(s=response.text)
205
+ except json.JSONDecodeError as exc:
206
+ if response.status_code >= HTTPStatus.BAD_REQUEST:
207
+ raise CloudRecoError(response=response) from exc
208
+ raise
209
+
210
+ result_code = response_body["result_code"]
211
+ if result_code != "Success":
212
+ exception = {
213
+ "AuthenticationFailure": (AuthenticationFailureError),
214
+ "BadImage": BadImageError,
215
+ "InactiveProject": InactiveProjectError,
216
+ "RequestTimeTooSkewed": (RequestTimeTooSkewedError),
217
+ }[result_code]
218
+ raise exception(response=response)
219
+
220
+ result_list = list(response_body["results"])
221
+ return [
222
+ QueryResult.from_response_dict(response_dict=item)
223
+ for item in result_list
224
+ ]