Skip to content

Commit b1c34db

Browse files
adamtheturtleclaude
andcommitted
Add Database Reco Counts report client support
Add ``request_database_reco_counts_report``, ``download_reco_counts_report`` and ``wait_for_reco_counts_report`` to ``VWS`` and ``AsyncVWS``, along with an optional ``database_id`` constructor argument which the report endpoint needs. The download is not signed, is not against ``base_vws_url``, and 404s until the report is generated, so it does not go through ``make_request``. Closes #3133 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 7bea124 commit b1c34db

12 files changed

Lines changed: 1015 additions & 1 deletion

File tree

conftest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,18 +46,21 @@ def fixture_mock_vws(
4646
server_secret_key = uuid.uuid4().hex
4747
client_access_key = uuid.uuid4().hex
4848
client_secret_key = uuid.uuid4().hex
49+
database_id = uuid.uuid4().hex
4950

5051
database = CloudDatabase(
5152
server_access_key=server_access_key,
5253
server_secret_key=server_secret_key,
5354
client_access_key=client_access_key,
5455
client_secret_key=client_secret_key,
56+
database_id=database_id,
5557
)
5658

5759
monkeypatch.setenv(name="VWS_SERVER_ACCESS_KEY", value=server_access_key)
5860
monkeypatch.setenv(name="VWS_SERVER_SECRET_KEY", value=server_secret_key)
5961
monkeypatch.setenv(name="VWS_CLIENT_ACCESS_KEY", value=client_access_key)
6062
monkeypatch.setenv(name="VWS_CLIENT_SECRET_KEY", value=client_secret_key)
63+
monkeypatch.setenv(name="VWS_DATABASE_ID", value=database_id)
6164
# We use a low processing time so that tests run quickly.
6265
with MockVWS(processing_time_seconds=0.2) as mock:
6366
mock.add_cloud_database(cloud_database=database)

docs/source/index.rst

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,55 @@ See the :doc:`api-reference` for full usage details.
6060
6161
assert matching_targets[0].target_id == target_id
6262
63+
Recognition counts
64+
------------------
65+
66+
Vuforia can generate a report of the number of recognitions of each target in a database in a month.
67+
Only the current month and the previous month can be requested.
68+
69+
This needs the ID of the database, which is shown in the Vuforia target manager.
70+
71+
The report is generated in the background, and the URL it is served from expires just under seven days after it is requested.
72+
73+
.. clear-namespace
74+
75+
.. code-block:: python
76+
77+
"""Get the number of recognitions of each target this month."""
78+
79+
import datetime
80+
import os
81+
82+
from vws import VWS
83+
84+
server_access_key = os.environ["VWS_SERVER_ACCESS_KEY"]
85+
server_secret_key = os.environ["VWS_SERVER_SECRET_KEY"]
86+
database_id = os.environ["VWS_DATABASE_ID"]
87+
88+
vws_client = VWS(
89+
server_access_key=server_access_key,
90+
server_secret_key=server_secret_key,
91+
database_id=database_id,
92+
)
93+
94+
now = datetime.datetime.now(tz=datetime.UTC)
95+
this_month = now.strftime(format="%Y-%m")
96+
97+
report_request = vws_client.request_database_reco_counts_report(
98+
month=this_month,
99+
)
100+
101+
report = vws_client.wait_for_reco_counts_report(
102+
presigned_url=report_request.presigned_url,
103+
)
104+
105+
reco_counts_by_target_id = {
106+
item.target_id: item.reco_count for item in report.reco_counts
107+
}
108+
109+
# This database has no targets, so nothing has been recognized.
110+
assert not reco_counts_by_target_id
111+
63112
Testing
64113
-------
65114

newsfragments/3133.change.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add support for the Database Reco Counts report.
2+
``VWS`` and ``AsyncVWS`` take an optional ``database_id``, and have new ``request_database_reco_counts_report``, ``download_reco_counts_report`` and ``wait_for_reco_counts_report`` methods.

spelling_private_dict.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ changelog
4141
chunked
4242
cmyk
4343
connectionerror
44+
csv
4445
customizable
4546
dataclasses
4647
datetime
@@ -88,6 +89,7 @@ pyright
8889
pytest
8990
readme
9091
readthedocs
92+
reco
9193
recognitions
9294
refactoring
9395
regex

src/vws/_reco_counts.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Internal helpers for the database reco counts report endpoints."""
2+
3+
import json
4+
from http import HTTPStatus
5+
6+
from beartype import BeartypeConf, beartype
7+
8+
from vws.exceptions.custom_exceptions import (
9+
DatabaseIdNotSetError,
10+
RecoCountsReportDownloadError,
11+
RecoCountsReportNotReadyError,
12+
)
13+
from vws.reports import RecoCountsReport
14+
from vws.response import Response # noqa: TC001
15+
16+
17+
@beartype(conf=BeartypeConf(is_pep484_tower=True))
18+
def reco_counts_report_path(*, database_id: str | None) -> str:
19+
"""Get the path of the reco counts report endpoint for a database.
20+
21+
Args:
22+
database_id: The ID of the database to get the path for.
23+
24+
Returns:
25+
The path of the reco counts report endpoint.
26+
27+
Raises:
28+
~vws.exceptions.custom_exceptions.DatabaseIdNotSetError: No
29+
``database_id`` was given to the client.
30+
"""
31+
if database_id is None:
32+
msg = (
33+
"A database ID is needed to request a reco counts report. Give "
34+
"``database_id`` when creating the client."
35+
)
36+
raise DatabaseIdNotSetError(msg)
37+
38+
return f"/imagetargets/databases/{database_id}/reports/recoCounts"
39+
40+
41+
@beartype(conf=BeartypeConf(is_pep484_tower=True))
42+
def reco_counts_report_body(*, month: str) -> bytes:
43+
"""Get the request body for requesting a reco counts report.
44+
45+
Args:
46+
month: The month to request the report for, in ``YYYY-mm`` form.
47+
48+
Returns:
49+
The body of the request.
50+
"""
51+
return json.dumps(obj={"month": month}).encode(encoding="utf-8")
52+
53+
54+
@beartype(conf=BeartypeConf(is_pep484_tower=True))
55+
def report_from_download_response(*, response: Response) -> RecoCountsReport:
56+
"""Get a reco counts report from a response from a report's URL.
57+
58+
Args:
59+
response: The response from a report's download URL.
60+
61+
Returns:
62+
The downloaded report.
63+
64+
Raises:
65+
~vws.exceptions.custom_exceptions.RecoCountsReportNotReadyError:
66+
Vuforia has not finished generating the report.
67+
~vws.exceptions.custom_exceptions.RecoCountsReportDownloadError: The
68+
report could not be downloaded. For example, the report's URL may
69+
have expired.
70+
"""
71+
if response.status_code == HTTPStatus.NOT_FOUND:
72+
raise RecoCountsReportNotReadyError(response=response)
73+
74+
if response.status_code != HTTPStatus.OK:
75+
raise RecoCountsReportDownloadError(response=response)
76+
77+
return RecoCountsReport.from_csv(csv_bytes=response.content)

src/vws/async_vws.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import asyncio
44
import base64
55
import json
6+
import time
67
from http import HTTPMethod, HTTPStatus
78
from typing import Self
89

@@ -11,14 +12,23 @@
1112
from vws._async_vws_request import async_target_api_request
1213
from vws._image_utils import ImageType as _ImageType
1314
from vws._image_utils import get_image_data as _get_image_data
15+
from vws._reco_counts import (
16+
reco_counts_report_body,
17+
reco_counts_report_path,
18+
report_from_download_response,
19+
)
1420
from vws.exceptions.base_exceptions import VWSError
1521
from vws.exceptions.custom_exceptions import (
22+
RecoCountsReportNotReadyError,
23+
RecoCountsReportTimeoutError,
1624
ServerError,
1725
TargetProcessingTimeoutError,
1826
)
1927
from vws.exceptions.vws_exceptions import TooManyRequestsError
2028
from vws.reports import (
2129
DatabaseSummaryReport,
30+
RecoCountsReport,
31+
RecoCountsReportRequest,
2232
TargetStatusAndRecord,
2333
TargetStatuses,
2434
TargetSummaryReport,
@@ -37,6 +47,7 @@ def __init__(
3747
server_access_key: str,
3848
server_secret_key: str,
3949
base_vws_url: str = "https://vws.vuforia.com",
50+
database_id: str | None = None,
4051
request_timeout_seconds: float | tuple[float, float] = 30.0,
4152
transport: AsyncTransport | None = None,
4253
) -> None:
@@ -45,6 +56,10 @@ def __init__(
4556
server_access_key: A VWS server access key.
4657
server_secret_key: A VWS server secret key.
4758
base_vws_url: The base URL for the VWS API.
59+
database_id: The ID of the database which the
60+
given keys belong to. This is shown in the
61+
target manager. It is needed only by
62+
:meth:`request_database_reco_counts_report`.
4863
request_timeout_seconds: The timeout for each
4964
HTTP request. This can be a float to set both
5065
the connect and read timeouts, or a
@@ -56,6 +71,7 @@ def __init__(
5671
self._server_access_key = server_access_key
5772
self._server_secret_key = server_secret_key
5873
self._base_vws_url = base_vws_url
74+
self._database_id = database_id
5975
self._request_timeout_seconds = request_timeout_seconds
6076
self._transport = (
6177
transport if transport is not None else AsyncHTTPXTransport()
@@ -439,6 +455,133 @@ async def get_database_summary_report(
439455
response_dict=response_data,
440456
)
441457

458+
async def request_database_reco_counts_report(
459+
self,
460+
*,
461+
month: str,
462+
) -> RecoCountsReportRequest:
463+
"""Request a per-target recognition count report for the database.
464+
465+
Vuforia generates the report in the background, so the report is not
466+
available to download immediately. Use
467+
:meth:`wait_for_reco_counts_report` to wait for it.
468+
469+
Args:
470+
month: The month to get recognition counts for, in ``YYYY-mm``
471+
form. Vuforia accepts only the current month and the previous
472+
month.
473+
474+
Returns:
475+
The URL to download the report from, and the transaction ID of
476+
the request.
477+
478+
Raises:
479+
~vws.exceptions.custom_exceptions.DatabaseIdNotSetError: No
480+
``database_id`` was given to the client.
481+
~vws.exceptions.vws_exceptions.AuthenticationFailureError: The
482+
secret key is not correct, or the client's ``database_id`` is
483+
not the ID of the database which the client's keys belong to.
484+
~vws.exceptions.vws_exceptions.FailError: There was an error with
485+
the request. For example, the given month is not the current
486+
month or the previous month.
487+
~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is
488+
an error with the time sent to Vuforia.
489+
~vws.exceptions.custom_exceptions.ServerError: There is an error
490+
with Vuforia's servers.
491+
~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is
492+
rate limiting access.
493+
"""
494+
response = await self.make_request(
495+
method=HTTPMethod.POST,
496+
data=reco_counts_report_body(month=month),
497+
request_path=reco_counts_report_path(
498+
database_id=self._database_id,
499+
),
500+
expected_result_code="Success",
501+
content_type="application/json",
502+
)
503+
504+
response_data = dict(json.loads(s=response.text))
505+
return RecoCountsReportRequest.from_response_dict(
506+
response_dict=response_data,
507+
)
508+
509+
async def download_reco_counts_report(
510+
self,
511+
*,
512+
presigned_url: str,
513+
) -> RecoCountsReport:
514+
"""Download a requested reco counts report.
515+
516+
The report's URL is not part of the VWS API, so this request is not
517+
authorized with the client's keys.
518+
519+
Args:
520+
presigned_url: The URL of the report, as given by
521+
:meth:`request_database_reco_counts_report`.
522+
523+
Returns:
524+
The downloaded report.
525+
526+
Raises:
527+
~vws.exceptions.custom_exceptions.RecoCountsReportNotReadyError:
528+
Vuforia has not finished generating the report.
529+
~vws.exceptions.custom_exceptions.RecoCountsReportDownloadError:
530+
The report could not be downloaded. For example, the report's
531+
URL may have expired.
532+
"""
533+
response = await self._transport(
534+
method=HTTPMethod.GET,
535+
url=presigned_url,
536+
headers={},
537+
data=b"",
538+
request_timeout=self._request_timeout_seconds,
539+
)
540+
541+
return report_from_download_response(response=response)
542+
543+
async def wait_for_reco_counts_report(
544+
self,
545+
*,
546+
presigned_url: str,
547+
seconds_between_requests: float = 0.2,
548+
timeout_seconds: float = 60 * 5,
549+
) -> RecoCountsReport:
550+
"""Wait for a requested reco counts report to be generated, then
551+
download it.
552+
553+
Args:
554+
presigned_url: The URL of the report, as given by
555+
:meth:`request_database_reco_counts_report`.
556+
seconds_between_requests: The number of seconds to wait between
557+
requests made while polling the report's URL.
558+
timeout_seconds: The maximum number of seconds to wait for the
559+
report to be generated.
560+
561+
Returns:
562+
The downloaded report.
563+
564+
Raises:
565+
~vws.exceptions.custom_exceptions.RecoCountsReportTimeoutError:
566+
The report was not generated within ``timeout_seconds``
567+
seconds.
568+
~vws.exceptions.custom_exceptions.RecoCountsReportDownloadError:
569+
The report could not be downloaded. For example, the report's
570+
URL may have expired.
571+
"""
572+
start_time = time.monotonic()
573+
while True:
574+
try:
575+
return await self.download_reco_counts_report(
576+
presigned_url=presigned_url,
577+
)
578+
except RecoCountsReportNotReadyError:
579+
elapsed_time = time.monotonic() - start_time
580+
if elapsed_time > timeout_seconds:
581+
raise RecoCountsReportTimeoutError from None
582+
583+
await asyncio.sleep(delay=seconds_between_requests)
584+
442585
async def delete_target(self, target_id: str) -> None:
443586
"""Delete a given target.
444587

0 commit comments

Comments
 (0)