diff --git a/conftest.py b/conftest.py
index f82cefc93..1a2445a7c 100644
--- a/conftest.py
+++ b/conftest.py
@@ -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)
diff --git a/docs/source/index.rst b/docs/source/index.rst
index ae5712825..ab7bfb0a7 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -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
-------
diff --git a/newsfragments/3133.change.rst b/newsfragments/3133.change.rst
new file mode 100644
index 000000000..edab9049a
--- /dev/null
+++ b/newsfragments/3133.change.rst
@@ -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.
diff --git a/spelling_private_dict.txt b/spelling_private_dict.txt
index 608572ce0..74e7d3856 100644
--- a/spelling_private_dict.txt
+++ b/spelling_private_dict.txt
@@ -41,6 +41,7 @@ changelog
chunked
cmyk
connectionerror
+csv
customizable
dataclasses
datetime
@@ -88,6 +89,7 @@ pyright
pytest
readme
readthedocs
+reco
recognitions
refactoring
regex
diff --git a/src/vws/_reco_counts.py b/src/vws/_reco_counts.py
new file mode 100644
index 000000000..6ae8010ac
--- /dev/null
+++ b/src/vws/_reco_counts.py
@@ -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)
diff --git a/src/vws/async_vws.py b/src/vws/async_vws.py
index aad205768..d2306a63b 100644
--- a/src/vws/async_vws.py
+++ b/src/vws/async_vws.py
@@ -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
@@ -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,
@@ -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:
@@ -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
@@ -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()
@@ -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.
diff --git a/src/vws/exceptions/custom_exceptions.py b/src/vws/exceptions/custom_exceptions.py
index c53f5db29..70ec81e36 100644
--- a/src/vws/exceptions/custom_exceptions.py
+++ b/src/vws/exceptions/custom_exceptions.py
@@ -34,6 +34,61 @@ class TargetProcessingTimeoutError(Exception):
"""
+@beartype
+class DatabaseIdNotSetError(Exception):
+ """Exception raised when an operation which needs a database ID is used
+ on a client which was not given one.
+ """
+
+
+@beartype
+class RecoCountsReportNotReadyError(Exception):
+ """Exception raised when a reco counts report is downloaded before
+ Vuforia has generated it.
+ """
+
+ def __init__(self, response: Response) -> None:
+ """
+ Args:
+ response: The response returned by the report's download URL.
+ """
+ super().__init__(response.text)
+ self._response = response
+
+ @property
+ def response(self) -> Response:
+ """The response returned by the download URL."""
+ return self._response
+
+
+@beartype
+class RecoCountsReportDownloadError(Exception):
+ """Exception raised when downloading a reco counts report fails.
+
+ This is raised, for example, when the report's URL has expired.
+ """
+
+ def __init__(self, response: Response) -> None:
+ """
+ Args:
+ response: The response returned by the report's download URL.
+ """
+ super().__init__(response.text)
+ self._response = response
+
+ @property
+ def response(self) -> Response:
+ """The response returned by the download URL."""
+ return self._response
+
+
+@beartype
+class RecoCountsReportTimeoutError(Exception):
+ """Exception raised when waiting for a reco counts report to be
+ generated times out.
+ """
+
+
@beartype
class ServerError(Exception): # pragma: no cover
"""Exception raised when VWS returns a server error."""
diff --git a/src/vws/reports.py b/src/vws/reports.py
index 007a0d443..66a9685ed 100644
--- a/src/vws/reports.py
+++ b/src/vws/reports.py
@@ -1,6 +1,9 @@
"""Classes for representing Vuforia reports."""
+import csv
import datetime
+import io
+from collections.abc import Sequence # noqa: TC003
from dataclasses import dataclass
from enum import Enum, unique
from typing import Any, Self
@@ -186,3 +189,71 @@ def from_response_dict(cls, response_dict: dict[str, Any]) -> Self:
reco_rating=target_record_dict["reco_rating"],
)
return cls(status=status, target_record=target_record)
+
+
+@beartype
+@dataclass(frozen=True, kw_only=True)
+class RecoCountsReportRequest:
+ """A requested database reco counts report.
+
+ See
+ https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api.
+ """
+
+ transaction_id: str
+ presigned_url: str
+ """The URL to download the report from.
+
+ Real Vuforia's URLs expire just under seven days after the report is
+ requested.
+ """
+
+ @classmethod
+ def from_response_dict(cls, response_dict: dict[str, Any]) -> Self:
+ """Construct from a VWS API response dict."""
+ return cls(
+ transaction_id=response_dict["transaction_id"],
+ presigned_url=response_dict["presigned_url"],
+ )
+
+
+@beartype
+@dataclass(frozen=True, kw_only=True)
+class RecoCount:
+ """The number of recognitions of one target in a reco counts
+ report.
+ """
+
+ target_id: str
+ reco_count: int
+
+
+@beartype
+@dataclass(frozen=True, kw_only=True)
+class RecoCountsReport:
+ """A downloaded database reco counts report.
+
+ A report for a month with no recognitions has no ``reco_counts``.
+ """
+
+ reco_counts: Sequence[RecoCount]
+ raw_csv: bytes
+ """The downloaded CSV, before it was parsed.
+
+ Vuforia does not document the format of the report, so it may include
+ columns which ``reco_counts`` does not expose.
+ """
+
+ @classmethod
+ def from_csv(cls, csv_bytes: bytes) -> Self:
+ """Construct from the CSV content of a downloaded report."""
+ text = csv_bytes.decode(encoding="utf-8")
+ reader = csv.DictReader(f=io.StringIO(initial_value=text, newline=""))
+ reco_counts = [
+ RecoCount(
+ target_id=row["target_id"],
+ reco_count=int(row["reco_count"]),
+ )
+ for row in reader
+ ]
+ return cls(reco_counts=reco_counts, raw_csv=csv_bytes)
diff --git a/src/vws/vws.py b/src/vws/vws.py
index fc54c30fd..2b241ab1d 100644
--- a/src/vws/vws.py
+++ b/src/vws/vws.py
@@ -1,6 +1,7 @@
"""Tools for interacting with Vuforia APIs."""
import base64
+import calendar # noqa: TC003
import json
import time
from http import HTTPMethod, HTTPStatus
@@ -9,15 +10,24 @@
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._vws_request import target_api_request
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,
@@ -36,6 +46,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: Transport | None = None,
) -> None:
@@ -44,6 +55,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
@@ -55,6 +70,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 RequestsTransport()
@@ -413,6 +429,136 @@ def get_database_summary_report(self) -> DatabaseSummaryReport:
response_dict=response_data,
)
+ 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 = 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,
+ )
+
+ 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 = self._transport(
+ method=HTTPMethod.GET,
+ url=presigned_url,
+ headers={},
+ data=b"",
+ request_timeout=self._request_timeout_seconds,
+ )
+
+ return report_from_download_response(response=response)
+
+ 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 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
+
+ time.sleep(seconds_between_requests)
+
def delete_target(self, target_id: str) -> None:
"""Delete a given target.
diff --git a/tests/conftest.py b/tests/conftest.py
index 15ef17dab..d94099ff8 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,5 +1,6 @@
"""Configuration, plugins and fixtures for `pytest`."""
+import datetime
import io # noqa: TC003
from collections.abc import AsyncGenerator, Generator # noqa: TC003
from pathlib import Path # noqa: TC003
@@ -66,6 +67,7 @@ def vws_client(*, _mock_database: CloudDatabase) -> VWS:
return VWS(
server_access_key=_mock_database.server_access_key,
server_secret_key=_mock_database.server_secret_key,
+ database_id=_mock_database.database_id,
)
@@ -87,6 +89,7 @@ async def async_vws_client(
async with AsyncVWS(
server_access_key=_mock_database.server_access_key,
server_secret_key=_mock_database.server_secret_key,
+ database_id=_mock_database.database_id,
) as client:
yield client
@@ -121,6 +124,27 @@ async def async_vumark_service_client(
yield client
+@pytest.fixture(name="current_month")
+def fixture_current_month() -> datetime.date:
+ """The current month, as the first day of that month."""
+ now = datetime.datetime.now(tz=datetime.UTC)
+ return now.date().replace(day=1)
+
+
+@pytest.fixture(name="report_month", params=["current", "previous"])
+def fixture_report_month(*, request: pytest.FixtureRequest) -> datetime.date:
+ """A month which a reco counts report can be requested for.
+
+ Vuforia accepts only the current month and the previous month.
+ """
+ now = datetime.datetime.now(tz=datetime.UTC)
+ first_of_month = now.date().replace(day=1)
+ if request.param == "current":
+ return first_of_month
+
+ return first_of_month - datetime.timedelta(days=1)
+
+
@pytest.fixture(name="image_file", params=["r+b", "rb"])
def fixture_image_file(
*,
diff --git a/tests/test_async_vws.py b/tests/test_async_vws.py
index f59c5f2e4..c99644bec 100644
--- a/tests/test_async_vws.py
+++ b/tests/test_async_vws.py
@@ -1,8 +1,12 @@
"""Tests for async helper functions for managing a Vuforia database."""
import base64
+import calendar
+import datetime # noqa: TC003
import io # noqa: TC003
+import time
import uuid
+from http import HTTPStatus
from typing import BinaryIO
import pytest
@@ -11,13 +15,22 @@
from vws import AsyncCloudRecoService, AsyncVuMarkService, AsyncVWS
from vws.exceptions.custom_exceptions import (
+ DatabaseIdNotSetError,
+ RecoCountsReportDownloadError,
+ RecoCountsReportNotReadyError,
+ RecoCountsReportTimeoutError,
TargetProcessingTimeoutError,
)
+from vws.exceptions.vws_exceptions import (
+ AuthenticationFailureError,
+ FailError,
+)
from vws.reports import (
DatabaseSummaryReport,
TargetRecord,
TargetStatuses,
)
+from vws.response import Response
from vws.vumark_accept import VuMarkAccept
@@ -459,6 +472,224 @@ async def test_no_fields_given(
)
+class _ForbiddenDownloadTransport:
+ """An async transport which refuses to serve a report, as an expired
+ URL would.
+ """
+
+ async def aclose(self) -> None:
+ """Close the transport."""
+
+ async def __call__(
+ self,
+ *,
+ method: str,
+ url: str,
+ headers: dict[str, str],
+ data: bytes,
+ request_timeout: float | tuple[float, float],
+ ) -> Response:
+ """Return a "forbidden" response."""
+ del method, headers, request_timeout
+ body = "AccessDenied"
+ return Response(
+ text=body,
+ url=url,
+ status_code=HTTPStatus.FORBIDDEN,
+ headers={},
+ request_body=data,
+ tell_position=0,
+ content=body.encode(encoding="utf-8"),
+ )
+
+
+class TestRecoCountsReport:
+ """Tests for database reco counts reports."""
+
+ @staticmethod
+ @pytest.mark.asyncio
+ async def test_reco_counts_report(
+ *,
+ async_vws_client: AsyncVWS,
+ report_month: datetime.date,
+ ) -> None:
+ """A report can be requested, waited for and downloaded."""
+ client = async_vws_client
+ report_request = await client.request_database_reco_counts_report(
+ year=report_month.year,
+ month=calendar.Month(value=report_month.month),
+ )
+ assert report_request.transaction_id
+ assert report_request.presigned_url
+
+ report = await client.wait_for_reco_counts_report(
+ presigned_url=report_request.presigned_url,
+ )
+
+ # No targets have been recognized, so the report has no rows.
+ assert not report.reco_counts
+ assert report.raw_csv.startswith(b"target_id,reco_count")
+
+ @staticmethod
+ @pytest.mark.asyncio
+ async def test_not_ready(*, current_month: datetime.date) -> None:
+ """Downloading a report before Vuforia has generated it raises an
+ error.
+ """
+ with MockVWS(processing_time_seconds=60) as mock:
+ database = CloudDatabase()
+ mock.add_cloud_database(cloud_database=database)
+ async with AsyncVWS(
+ server_access_key=database.server_access_key,
+ server_secret_key=database.server_secret_key,
+ database_id=database.database_id,
+ ) as client:
+ report_request = (
+ await client.request_database_reco_counts_report(
+ year=current_month.year,
+ month=calendar.Month(value=current_month.month),
+ )
+ )
+
+ with pytest.raises(
+ expected_exception=RecoCountsReportNotReadyError,
+ ) as exc:
+ await client.download_reco_counts_report(
+ presigned_url=report_request.presigned_url,
+ )
+
+ assert exc.value.response.status_code == HTTPStatus.NOT_FOUND
+
+ @staticmethod
+ @pytest.mark.asyncio
+ async def test_wait_timeout(*, current_month: datetime.date) -> None:
+ """Waiting for a report which is not generated in time raises an
+ error.
+ """
+ with MockVWS(processing_time_seconds=60) as mock:
+ database = CloudDatabase()
+ mock.add_cloud_database(cloud_database=database)
+ async with AsyncVWS(
+ server_access_key=database.server_access_key,
+ server_secret_key=database.server_secret_key,
+ database_id=database.database_id,
+ ) as client:
+ report_request = (
+ await client.request_database_reco_counts_report(
+ year=current_month.year,
+ month=calendar.Month(value=current_month.month),
+ )
+ )
+
+ maximum_wait_seconds = 5
+ start_time = time.monotonic()
+
+ with pytest.raises(
+ expected_exception=RecoCountsReportTimeoutError,
+ ):
+ await client.wait_for_reco_counts_report(
+ presigned_url=report_request.presigned_url,
+ seconds_between_requests=0.01,
+ timeout_seconds=0.05,
+ )
+
+ elapsed_time = time.monotonic() - start_time
+ assert elapsed_time < maximum_wait_seconds
+
+ @staticmethod
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize(
+ argnames=("year", "month"),
+ argvalues=[
+ pytest.param(1999, calendar.Month.JANUARY, id="year-in-the-past"),
+ pytest.param(
+ 1999,
+ calendar.Month.DECEMBER,
+ id="year-in-the-past-december",
+ ),
+ ],
+ )
+ async def test_month_not_accepted(
+ *,
+ async_vws_client: AsyncVWS,
+ year: int,
+ month: calendar.Month,
+ ) -> None:
+ """Months other than the current and previous month are
+ rejected.
+ """
+ with pytest.raises(expected_exception=FailError) as exc:
+ await async_vws_client.request_database_reco_counts_report(
+ year=year,
+ month=month,
+ )
+
+ assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST
+
+ @staticmethod
+ @pytest.mark.asyncio
+ async def test_database_id_does_not_match_keys(
+ *,
+ current_month: datetime.date,
+ ) -> None:
+ """A database ID which does not match the given keys is
+ rejected.
+ """
+ with MockVWS() as mock:
+ database = CloudDatabase()
+ mock.add_cloud_database(cloud_database=database)
+ async with AsyncVWS(
+ server_access_key=database.server_access_key,
+ server_secret_key=database.server_secret_key,
+ database_id=uuid.uuid4().hex,
+ ) as client:
+ with pytest.raises(
+ expected_exception=AuthenticationFailureError,
+ ) as exc:
+ await client.request_database_reco_counts_report(
+ year=current_month.year,
+ month=calendar.Month(value=current_month.month),
+ )
+
+ assert (
+ exc.value.response.status_code == HTTPStatus.UNAUTHORIZED
+ )
+
+ @staticmethod
+ @pytest.mark.asyncio
+ async def test_download_error() -> None:
+ """An error response from the report's URL raises an error."""
+ async with AsyncVWS(
+ server_access_key=uuid.uuid4().hex,
+ server_secret_key=uuid.uuid4().hex,
+ transport=_ForbiddenDownloadTransport(),
+ ) as client:
+ with pytest.raises(
+ expected_exception=RecoCountsReportDownloadError,
+ ) as exc:
+ await client.download_reco_counts_report(
+ presigned_url="https://example.com/reports/recoCounts/x",
+ )
+
+ assert exc.value.response.status_code == HTTPStatus.FORBIDDEN
+
+ @staticmethod
+ @pytest.mark.asyncio
+ async def test_no_database_id(*, current_month: datetime.date) -> None:
+ """A client which was given no database ID cannot request a
+ report.
+ """
+ async with AsyncVWS(
+ server_access_key=uuid.uuid4().hex,
+ server_secret_key=uuid.uuid4().hex,
+ ) as client:
+ with pytest.raises(expected_exception=DatabaseIdNotSetError):
+ await client.request_database_reco_counts_report(
+ year=current_month.year,
+ month=calendar.Month(value=current_month.month),
+ )
+
+
class TestGenerateVumarkInstance:
"""Tests for generating VuMark instances."""
diff --git a/tests/test_vws.py b/tests/test_vws.py
index dff029c2f..db6d05fd8 100644
--- a/tests/test_vws.py
+++ b/tests/test_vws.py
@@ -1,10 +1,13 @@
"""Tests for helper functions for managing a Vuforia database."""
import base64
+import calendar
import datetime
import io # noqa: TC003
import secrets
+import time
import uuid
+from http import HTTPStatus
from typing import BinaryIO
import pytest
@@ -14,13 +17,26 @@
from mock_vws.database import CloudDatabase
from vws import VWS, CloudRecoService, VuMarkService
-from vws.exceptions.custom_exceptions import TargetProcessingTimeoutError
+from vws.exceptions.custom_exceptions import (
+ DatabaseIdNotSetError,
+ RecoCountsReportDownloadError,
+ RecoCountsReportNotReadyError,
+ RecoCountsReportTimeoutError,
+ TargetProcessingTimeoutError,
+)
+from vws.exceptions.vws_exceptions import (
+ AuthenticationFailureError,
+ FailError,
+)
from vws.reports import (
DatabaseSummaryReport,
+ RecoCount,
+ RecoCountsReport,
TargetRecord,
TargetStatuses,
TargetSummaryReport,
)
+from vws.response import Response
from vws.vumark_accept import VuMarkAccept
@@ -760,6 +776,247 @@ def test_no_fields_given(
vws_client.update_target(target_id=target_id)
+class _ForbiddenDownloadTransport:
+ """A transport which refuses to serve a report, as an expired URL
+ would.
+ """
+
+ def close(self) -> None:
+ """Close the transport."""
+
+ def __call__(
+ self,
+ *,
+ method: str,
+ url: str,
+ headers: dict[str, str],
+ data: bytes,
+ request_timeout: float | tuple[float, float],
+ ) -> Response:
+ """Return a "forbidden" response."""
+ del method, headers, request_timeout
+ body = "AccessDenied"
+ return Response(
+ text=body,
+ url=url,
+ status_code=HTTPStatus.FORBIDDEN,
+ headers={},
+ request_body=data,
+ tell_position=0,
+ content=body.encode(encoding="utf-8"),
+ )
+
+
+class TestRecoCountsReport:
+ """Tests for database reco counts reports."""
+
+ @staticmethod
+ def test_reco_counts_report(
+ *,
+ vws_client: VWS,
+ report_month: datetime.date,
+ ) -> None:
+ """A report can be requested, waited for and downloaded."""
+ report_request = vws_client.request_database_reco_counts_report(
+ year=report_month.year,
+ month=calendar.Month(value=report_month.month),
+ )
+ assert report_request.transaction_id
+ assert report_request.presigned_url
+
+ report = vws_client.wait_for_reco_counts_report(
+ presigned_url=report_request.presigned_url,
+ )
+
+ # No targets have been recognized, so the report has no rows.
+ assert not report.reco_counts
+ assert report.raw_csv.startswith(b"target_id,reco_count")
+
+ @staticmethod
+ def test_not_ready(*, current_month: datetime.date) -> None:
+ """Downloading a report before Vuforia has generated it raises an
+ error.
+ """
+ with MockVWS(processing_time_seconds=60) as mock:
+ database = CloudDatabase()
+ mock.add_cloud_database(cloud_database=database)
+ vws_client = VWS(
+ server_access_key=database.server_access_key,
+ server_secret_key=database.server_secret_key,
+ database_id=database.database_id,
+ )
+ report_request = vws_client.request_database_reco_counts_report(
+ year=current_month.year,
+ month=calendar.Month(value=current_month.month),
+ )
+
+ with pytest.raises(
+ expected_exception=RecoCountsReportNotReadyError,
+ ) as exc:
+ vws_client.download_reco_counts_report(
+ presigned_url=report_request.presigned_url,
+ )
+
+ assert exc.value.response.status_code == HTTPStatus.NOT_FOUND
+
+ @staticmethod
+ def test_wait_timeout(*, current_month: datetime.date) -> None:
+ """Waiting for a report which is not generated in time raises an
+ error.
+ """
+ with MockVWS(processing_time_seconds=60) as mock:
+ database = CloudDatabase()
+ mock.add_cloud_database(cloud_database=database)
+ vws_client = VWS(
+ server_access_key=database.server_access_key,
+ server_secret_key=database.server_secret_key,
+ database_id=database.database_id,
+ )
+ report_request = vws_client.request_database_reco_counts_report(
+ year=current_month.year,
+ month=calendar.Month(value=current_month.month),
+ )
+
+ maximum_wait_seconds = 5
+ start_time = time.monotonic()
+
+ with pytest.raises(
+ expected_exception=RecoCountsReportTimeoutError,
+ ):
+ vws_client.wait_for_reco_counts_report(
+ presigned_url=report_request.presigned_url,
+ seconds_between_requests=0.01,
+ timeout_seconds=0.05,
+ )
+
+ elapsed_time = time.monotonic() - start_time
+ assert elapsed_time < maximum_wait_seconds
+
+ @staticmethod
+ @pytest.mark.parametrize(
+ argnames=("year", "month"),
+ argvalues=[
+ pytest.param(1999, calendar.Month.JANUARY, id="year-in-the-past"),
+ pytest.param(
+ 1999,
+ calendar.Month.DECEMBER,
+ id="year-in-the-past-december",
+ ),
+ ],
+ )
+ def test_month_not_accepted(
+ *,
+ vws_client: VWS,
+ year: int,
+ month: calendar.Month,
+ ) -> None:
+ """Months other than the current and previous month are
+ rejected.
+ """
+ with pytest.raises(expected_exception=FailError) as exc:
+ vws_client.request_database_reco_counts_report(
+ year=year,
+ month=month,
+ )
+
+ assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST
+
+ @staticmethod
+ def test_database_id_does_not_match_keys(
+ *,
+ current_month: datetime.date,
+ ) -> None:
+ """A database ID which does not match the given keys is
+ rejected.
+ """
+ with MockVWS() as mock:
+ database = CloudDatabase()
+ mock.add_cloud_database(cloud_database=database)
+ vws_client = VWS(
+ server_access_key=database.server_access_key,
+ server_secret_key=database.server_secret_key,
+ database_id=uuid.uuid4().hex,
+ )
+
+ with pytest.raises(
+ expected_exception=AuthenticationFailureError,
+ ) as exc:
+ vws_client.request_database_reco_counts_report(
+ year=current_month.year,
+ month=calendar.Month(value=current_month.month),
+ )
+
+ assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED
+
+ @staticmethod
+ def test_download_error() -> None:
+ """An error response from the report's URL raises an error."""
+ vws_client = VWS(
+ server_access_key=uuid.uuid4().hex,
+ server_secret_key=uuid.uuid4().hex,
+ transport=_ForbiddenDownloadTransport(),
+ )
+
+ with pytest.raises(
+ expected_exception=RecoCountsReportDownloadError,
+ ) as exc:
+ vws_client.download_reco_counts_report(
+ presigned_url="https://example.com/reports/recoCounts/x",
+ )
+
+ assert exc.value.response.status_code == HTTPStatus.FORBIDDEN
+
+ @staticmethod
+ def test_no_database_id(*, current_month: datetime.date) -> None:
+ """A client which was given no database ID cannot request a
+ report.
+ """
+ vws_client = VWS(
+ server_access_key=uuid.uuid4().hex,
+ server_secret_key=uuid.uuid4().hex,
+ )
+
+ with pytest.raises(expected_exception=DatabaseIdNotSetError):
+ vws_client.request_database_reco_counts_report(
+ year=current_month.year,
+ month=calendar.Month(value=current_month.month),
+ )
+
+
+class TestRecoCountsReportParsing:
+ """Tests for parsing downloaded reco counts reports."""
+
+ @staticmethod
+ def test_rows() -> None:
+ """Each row of the CSV becomes a ``RecoCount``."""
+ csv_bytes = b"target_id,reco_count\r\nabc,3\r\ndef,0\r\n"
+
+ report = RecoCountsReport.from_csv(csv_bytes=csv_bytes)
+
+ assert report.reco_counts == [
+ RecoCount(target_id="abc", reco_count=3),
+ RecoCount(target_id="def", reco_count=0),
+ ]
+ assert report.raw_csv == csv_bytes
+
+ @staticmethod
+ def test_unknown_columns_ignored() -> None:
+ """Columns which are not known are not exposed in
+ ``reco_counts``.
+ """
+ expected_reco_count = 3
+ header = "target_id,reco_count,new_column\r\n"
+ csv_text = f"{header}abc,{expected_reco_count},x\r\n"
+
+ report = RecoCountsReport.from_csv(
+ csv_bytes=csv_text.encode(encoding="utf-8"),
+ )
+
+ (item,) = report.reco_counts
+ assert item.target_id == "abc"
+ assert item.reco_count == expected_reco_count
+
+
class TestGenerateVumarkInstance:
"""Tests for generating VuMark instances."""