Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,18 +46,21 @@ def fixture_mock_vws(
server_secret_key = uuid.uuid4().hex
client_access_key = uuid.uuid4().hex
client_secret_key = uuid.uuid4().hex
database_id = uuid.uuid4().hex

database = CloudDatabase(
server_access_key=server_access_key,
server_secret_key=server_secret_key,
client_access_key=client_access_key,
client_secret_key=client_secret_key,
database_id=database_id,
)

monkeypatch.setenv(name="VWS_SERVER_ACCESS_KEY", value=server_access_key)
monkeypatch.setenv(name="VWS_SERVER_SECRET_KEY", value=server_secret_key)
monkeypatch.setenv(name="VWS_CLIENT_ACCESS_KEY", value=client_access_key)
monkeypatch.setenv(name="VWS_CLIENT_SECRET_KEY", value=client_secret_key)
monkeypatch.setenv(name="VWS_DATABASE_ID", value=database_id)
# We use a low processing time so that tests run quickly.
with MockVWS(processing_time_seconds=0.2) as mock:
mock.add_cloud_database(cloud_database=database)
Expand Down
50 changes: 50 additions & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,56 @@ See the :doc:`api-reference` for full usage details.

assert matching_targets[0].target_id == target_id

Recognition counts
------------------

Vuforia can generate a report of the number of recognitions of each target in a database in a month.
Only the current month and the previous month can be requested.

This needs the ID of the database, which is shown in the Vuforia target manager.

The report is generated in the background, and the URL it is served from expires just under seven days after it is requested.

.. clear-namespace

.. code-block:: python

"""Get the number of recognitions of each target this month."""

import calendar
import datetime
import os

from vws import VWS

server_access_key = os.environ["VWS_SERVER_ACCESS_KEY"]
server_secret_key = os.environ["VWS_SERVER_SECRET_KEY"]
database_id = os.environ["VWS_DATABASE_ID"]

vws_client = VWS(
server_access_key=server_access_key,
server_secret_key=server_secret_key,
database_id=database_id,
)

now = datetime.datetime.now(tz=datetime.UTC)

report_request = vws_client.request_database_reco_counts_report(
year=now.year,
month=calendar.Month(value=now.month),
)

report = vws_client.wait_for_reco_counts_report(
presigned_url=report_request.presigned_url,
)

reco_counts_by_target_id = {
item.target_id: item.reco_count for item in report.reco_counts
}

# This database has no targets, so nothing has been recognized.
assert not reco_counts_by_target_id

Testing
-------

Expand Down
2 changes: 2 additions & 0 deletions newsfragments/3133.change.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Add support for the Database Reco Counts report.
``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.
2 changes: 2 additions & 0 deletions spelling_private_dict.txt
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ changelog
chunked
cmyk
connectionerror
csv
customizable
dataclasses
datetime
Expand Down Expand Up @@ -88,6 +89,7 @@ pyright
pytest
readme
readthedocs
reco
recognitions
refactoring
regex
Expand Down
80 changes: 80 additions & 0 deletions src/vws/_reco_counts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Internal helpers for the database reco counts report endpoints."""

import calendar # noqa: TC003
import json
from http import HTTPStatus

from beartype import BeartypeConf, beartype

from vws.exceptions.custom_exceptions import (
DatabaseIdNotSetError,
RecoCountsReportDownloadError,
RecoCountsReportNotReadyError,
)
from vws.reports import RecoCountsReport
from vws.response import Response # noqa: TC001


@beartype(conf=BeartypeConf(is_pep484_tower=True))
def reco_counts_report_path(*, database_id: str | None) -> str:
"""Get the path of the reco counts report endpoint for a database.

Args:
database_id: The ID of the database to get the path for.

Returns:
The path of the reco counts report endpoint.

Raises:
~vws.exceptions.custom_exceptions.DatabaseIdNotSetError: No
``database_id`` was given to the client.
"""
if database_id is None:
msg = (
"A database ID is needed to request a reco counts report. Give "
"``database_id`` when creating the client."
)
raise DatabaseIdNotSetError(msg)

return f"/imagetargets/databases/{database_id}/reports/recoCounts"


@beartype(conf=BeartypeConf(is_pep484_tower=True))
def reco_counts_report_body(*, year: int, month: calendar.Month) -> bytes:
"""Get the request body for requesting a reco counts report.

Args:
year: The year to request the report for.
month: The month of the year to request the report for.

Returns:
The body of the request.
"""
month_string = f"{year:04d}-{month:02d}"
return json.dumps(obj={"month": month_string}).encode(encoding="utf-8")


@beartype(conf=BeartypeConf(is_pep484_tower=True))
def report_from_download_response(*, response: Response) -> RecoCountsReport:
"""Get a reco counts report from a response from a report's URL.

Args:
response: The response from a report's download URL.

Returns:
The downloaded report.

Raises:
~vws.exceptions.custom_exceptions.RecoCountsReportNotReadyError:
Vuforia has not finished generating the report.
~vws.exceptions.custom_exceptions.RecoCountsReportDownloadError: The
report could not be downloaded. For example, the report's URL may
have expired.
"""
if response.status_code == HTTPStatus.NOT_FOUND:
raise RecoCountsReportNotReadyError(response=response)

if response.status_code != HTTPStatus.OK:
raise RecoCountsReportDownloadError(response=response)

return RecoCountsReport.from_csv(csv_bytes=response.content)
147 changes: 147 additions & 0 deletions src/vws/async_vws.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import asyncio
import base64
import calendar # noqa: TC003
import json
import time
from http import HTTPMethod, HTTPStatus
from typing import Self

Expand All @@ -11,14 +13,23 @@
from vws._async_vws_request import async_target_api_request
from vws._image_utils import ImageType as _ImageType
from vws._image_utils import get_image_data as _get_image_data
from vws._reco_counts import (
reco_counts_report_body,
reco_counts_report_path,
report_from_download_response,
)
from vws.exceptions.base_exceptions import VWSError
from vws.exceptions.custom_exceptions import (
RecoCountsReportNotReadyError,
RecoCountsReportTimeoutError,
ServerError,
TargetProcessingTimeoutError,
)
from vws.exceptions.vws_exceptions import TooManyRequestsError
from vws.reports import (
DatabaseSummaryReport,
RecoCountsReport,
RecoCountsReportRequest,
TargetStatusAndRecord,
TargetStatuses,
TargetSummaryReport,
Expand All @@ -37,6 +48,7 @@ def __init__(
server_access_key: str,
server_secret_key: str,
base_vws_url: str = "https://vws.vuforia.com",
database_id: str | None = None,
request_timeout_seconds: float | tuple[float, float] = 30.0,
transport: AsyncTransport | None = None,
) -> None:
Expand All @@ -45,6 +57,10 @@ def __init__(
server_access_key: A VWS server access key.
server_secret_key: A VWS server secret key.
base_vws_url: The base URL for the VWS API.
database_id: The ID of the database which the
given keys belong to. This is shown in the
target manager. It is needed only by
:meth:`request_database_reco_counts_report`.
request_timeout_seconds: The timeout for each
HTTP request. This can be a float to set both
the connect and read timeouts, or a
Expand All @@ -56,6 +72,7 @@ def __init__(
self._server_access_key = server_access_key
self._server_secret_key = server_secret_key
self._base_vws_url = base_vws_url
self._database_id = database_id
self._request_timeout_seconds = request_timeout_seconds
self._transport = (
transport if transport is not None else AsyncHTTPXTransport()
Expand Down Expand Up @@ -439,6 +456,136 @@ async def get_database_summary_report(
response_dict=response_data,
)

async def request_database_reco_counts_report(
self,
*,
year: int,
month: calendar.Month,
) -> RecoCountsReportRequest:
"""Request a per-target recognition count report for the database.

Vuforia generates the report in the background, so the report is not
available to download immediately. Use
:meth:`wait_for_reco_counts_report` to wait for it.

Args:
year: The year to get recognition counts for.
month: The month of the year to get recognition counts for.
Vuforia accepts only the current month and the previous
month. A month taken from a :class:`datetime.datetime` needs
wrapping, as in ``calendar.Month(value=now.month)``.

Returns:
The URL to download the report from, and the transaction ID of
the request.

Raises:
~vws.exceptions.custom_exceptions.DatabaseIdNotSetError: No
``database_id`` was given to the client.
~vws.exceptions.vws_exceptions.AuthenticationFailureError: The
secret key is not correct, or the client's ``database_id`` is
not the ID of the database which the client's keys belong to.
~vws.exceptions.vws_exceptions.FailError: There was an error with
the request. For example, the given year and month are not
the current month or the previous month.
~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is
an error with the time sent to Vuforia.
~vws.exceptions.custom_exceptions.ServerError: There is an error
with Vuforia's servers.
~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is
rate limiting access.
"""
response = await self.make_request(
method=HTTPMethod.POST,
data=reco_counts_report_body(year=year, month=month),
request_path=reco_counts_report_path(
database_id=self._database_id,
),
expected_result_code="Success",
content_type="application/json",
)

response_data = dict(json.loads(s=response.text))
return RecoCountsReportRequest.from_response_dict(
response_dict=response_data,
)

async def download_reco_counts_report(
self,
*,
presigned_url: str,
) -> RecoCountsReport:
"""Download a requested reco counts report.

The report's URL is not part of the VWS API, so this request is not
authorized with the client's keys.

Args:
presigned_url: The URL of the report, as given by
:meth:`request_database_reco_counts_report`.

Returns:
The downloaded report.

Raises:
~vws.exceptions.custom_exceptions.RecoCountsReportNotReadyError:
Vuforia has not finished generating the report.
~vws.exceptions.custom_exceptions.RecoCountsReportDownloadError:
The report could not be downloaded. For example, the report's
URL may have expired.
"""
response = await self._transport(
method=HTTPMethod.GET,
url=presigned_url,
headers={},
data=b"",
request_timeout=self._request_timeout_seconds,
)

return report_from_download_response(response=response)

async def wait_for_reco_counts_report(
self,
*,
presigned_url: str,
seconds_between_requests: float = 0.2,
timeout_seconds: float = 60 * 5,
) -> RecoCountsReport:
"""Wait for a requested reco counts report to be generated, then
download it.

Args:
presigned_url: The URL of the report, as given by
:meth:`request_database_reco_counts_report`.
seconds_between_requests: The number of seconds to wait between
requests made while polling the report's URL.
timeout_seconds: The maximum number of seconds to wait for the
report to be generated.

Returns:
The downloaded report.

Raises:
~vws.exceptions.custom_exceptions.RecoCountsReportTimeoutError:
The report was not generated within ``timeout_seconds``
seconds.
~vws.exceptions.custom_exceptions.RecoCountsReportDownloadError:
The report could not be downloaded. For example, the report's
URL may have expired.
"""
start_time = time.monotonic()
while True:
try:
return await self.download_reco_counts_report(
presigned_url=presigned_url,
)
except RecoCountsReportNotReadyError:
elapsed_time = time.monotonic() - start_time
if elapsed_time > timeout_seconds:
raise RecoCountsReportTimeoutError from None

await asyncio.sleep(delay=seconds_between_requests)

async def delete_target(self, target_id: str) -> None:
"""Delete a given target.

Expand Down
Loading