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."""