diff --git a/README.rst b/README.rst index 10eae106b..2df878811 100644 --- a/README.rst +++ b/README.rst @@ -3,7 +3,7 @@ vws-python ========== -Python library for the Vuforia Web Services (VWS) API and the Vuforia Web Query API. +Python clients for the Vuforia Model Target Web API and VuMark generation. Installation ------------ @@ -20,47 +20,23 @@ Getting Started .. code-block:: python - """Add a target to VWS and then query it.""" + """Generate a VuMark instance.""" import os - import pathlib - import uuid - from vws import VWS, CloudRecoService + from vws import VuMarkService + from vws.vumark_accept import VuMarkAccept - server_access_key = os.environ["VWS_SERVER_ACCESS_KEY"] - server_secret_key = os.environ["VWS_SERVER_SECRET_KEY"] - client_access_key = os.environ["VWS_CLIENT_ACCESS_KEY"] - client_secret_key = os.environ["VWS_CLIENT_SECRET_KEY"] - - vws_client = VWS( - server_access_key=server_access_key, - server_secret_key=server_secret_key, + client = VuMarkService( + server_access_key=os.environ["VWS_SERVER_ACCESS_KEY"], + server_secret_key=os.environ["VWS_SERVER_SECRET_KEY"], ) - - cloud_reco_client = CloudRecoService( - client_access_key=client_access_key, - client_secret_key=client_secret_key, + image = client.generate_vumark_instance( + target_id=os.environ["VUMARK_TARGET_ID"], + instance_id="123", + accept=VuMarkAccept.PNG, ) - - name = "my_image_name_" + uuid.uuid4().hex - - image = pathlib.Path("high_quality_image.jpg") - with image.open(mode="rb") as my_image_file: - target_id = vws_client.add_target( - name=name, - width=1, - image=my_image_file, - active_flag=True, - application_metadata=None, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - - with image.open(mode="rb") as my_image_file: - matching_targets = cloud_reco_client.query(image=my_image_file) - - assert matching_targets[0].target_id == target_id + assert image.startswith(b"\x89PNG") Full Documentation ------------------ diff --git a/conftest.py b/conftest.py index 51612fc9f..b82d65b28 100644 --- a/conftest.py +++ b/conftest.py @@ -2,15 +2,14 @@ from __future__ import annotations -import io import uuid from collections.abc import Generator from doctest import ELLIPSIS -from pathlib import Path import pytest from mock_vws import MockVWS -from mock_vws.database import CloudDatabase +from mock_vws.database import VuMarkDatabase +from mock_vws.target import VuMarkTarget from sybil import Sybil from sybil.parsers.rest import ( ClearNamespaceParser, @@ -19,22 +18,6 @@ ) -@pytest.fixture(name="make_image_file") -def fixture_make_image_file( - *, - high_quality_image: io.BytesIO, -) -> Generator[None]: - """Make an image file available in the test directory. - - The path of this file matches the path in the documentation. - """ - new_image = Path("high_quality_image.jpg") - buffer = high_quality_image.getvalue() - _ = new_image.write_bytes(data=buffer) - yield - new_image.unlink() - - @pytest.fixture(name="mock_vws") def fixture_mock_vws( *, @@ -46,23 +29,17 @@ def fixture_mock_vws( """ server_access_key = uuid.uuid4().hex server_secret_key = uuid.uuid4().hex - client_access_key = uuid.uuid4().hex - client_secret_key = uuid.uuid4().hex - database_id = uuid.uuid4().hex + target = VuMarkTarget(name="example-template") - database = CloudDatabase( + database = VuMarkDatabase( 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, + vumark_targets={target}, ) 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) + monkeypatch.setenv(name="VUMARK_TARGET_ID", value=target.target_id) # The mock accepts one hard-coded pair of Model Target Web API OAuth2 # credentials, which it does not expose. monkeypatch.setenv( @@ -75,7 +52,7 @@ def fixture_mock_vws( ) # 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) + mock.add_vumark_database(vumark_database=database) yield @@ -86,5 +63,5 @@ def fixture_mock_vws( PythonCodeBlockParser(), ], patterns=["*.rst", "*.py"], - fixtures=["make_image_file", "mock_vws"], + fixtures=["mock_vws"], ).pytest() diff --git a/docs/source/api-reference.rst b/docs/source/api-reference.rst index 355b7f8a4..00554cd37 100644 --- a/docs/source/api-reference.rst +++ b/docs/source/api-reference.rst @@ -5,14 +5,6 @@ API Reference :undoc-members: :members: -.. automodule:: vws.async_vws - :undoc-members: - :members: - -.. automodule:: vws.async_query - :undoc-members: - :members: - .. automodule:: vws.async_vumark_service :undoc-members: :members: @@ -37,10 +29,6 @@ API Reference :undoc-members: :members: -.. automodule:: vws.include_target_data - :undoc-members: - :members: - .. automodule:: vws.vumark_accept :undoc-members: :members: diff --git a/docs/source/exceptions.rst b/docs/source/exceptions.rst index baea7b495..ba898602d 100644 --- a/docs/source/exceptions.rst +++ b/docs/source/exceptions.rst @@ -19,15 +19,6 @@ VWS exceptions :inherited-members: Exception :exclude-members: errno, filename, filename2, strerror -CloudRecoService exceptions ---------------------------- - -.. automodule:: vws.exceptions.cloud_reco_exceptions - :members: - :show-inheritance: - :inherited-members: Exception - :exclude-members: errno, filename, filename2, strerror - ModelTargetService exceptions ----------------------------- diff --git a/docs/source/index.rst b/docs/source/index.rst index c5047a0ef..e2710a4d9 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -18,97 +18,23 @@ See the :doc:`api-reference` for full usage details. .. code-block:: python - """Add a target to VWS and then query it.""" + """Generate a VuMark instance.""" import os - import pathlib - import uuid - from vws import VWS, CloudRecoService + from vws import VuMarkService + from vws.vumark_accept import VuMarkAccept - server_access_key = os.environ["VWS_SERVER_ACCESS_KEY"] - server_secret_key = os.environ["VWS_SERVER_SECRET_KEY"] - client_access_key = os.environ["VWS_CLIENT_ACCESS_KEY"] - client_secret_key = os.environ["VWS_CLIENT_SECRET_KEY"] - - vws_client = VWS( - server_access_key=server_access_key, - server_secret_key=server_secret_key, - ) - - cloud_reco_client = CloudRecoService( - client_access_key=client_access_key, - client_secret_key=client_secret_key, - ) - - name = "my_image_name_" + uuid.uuid4().hex - - image = pathlib.Path("high_quality_image.jpg") - with image.open(mode="rb") as my_image_file: - target_id = vws_client.add_target( - name=name, - width=1, - image=my_image_file, - active_flag=True, - application_metadata=None, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - - with image.open(mode="rb") as my_image_file: - matching_targets = cloud_reco_client.query(image=my_image_file) - - 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 as dt - 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, + client = VuMarkService( + server_access_key=os.environ["VWS_SERVER_ACCESS_KEY"], + server_secret_key=os.environ["VWS_SERVER_SECRET_KEY"], ) - - now = dt.datetime.now(tz=dt.UTC) - - report_request = vws_client.request_database_reco_counts_report( - year=now.year, - month=calendar.Month(value=now.month), + image = client.generate_vumark_instance( + target_id=os.environ["VUMARK_TARGET_ID"], + instance_id="123", + accept=VuMarkAccept.PNG, ) - - 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 reco_counts_by_target_id == {} + assert image.startswith(b"\x89PNG") Model Targets ------------- @@ -199,27 +125,6 @@ Transports are available for `requests`_, `httpx`_ and `HTTPX2`_. .. clear-namespace -.. code-block:: python - - """List targets using HTTPX2.""" - - import os - - from vws import VWS - from vws.transports import HTTPX2Transport - - server_access_key = os.environ["VWS_SERVER_ACCESS_KEY"] - server_secret_key = os.environ["VWS_SERVER_SECRET_KEY"] - - vws_client = VWS( - server_access_key=server_access_key, - server_secret_key=server_secret_key, - transport=HTTPX2Transport(), - ) - - # This database has no targets. - assert len(vws_client.list_targets()) == 0 - .. _requests: https://pypi.org/project/requests/ .. _httpx: https://pypi.org/project/httpx/ .. _HTTPX2: https://httpx2.pydantic.dev/ @@ -227,56 +132,7 @@ Transports are available for `requests`_, `httpx`_ and `HTTPX2`_. Testing ------- -To write unit tests for code which uses this library, without using your Vuforia quota, you can use the `VWS Python Mock`_ tool: - -.. code-block:: console - - $ pip install vws-python-mock - -.. clear-namespace - -.. code-block:: python - - """Add a target to VWS and then query it.""" - - import pathlib - - from mock_vws import MockVWS - from mock_vws.database import CloudDatabase - - from vws import VWS, CloudRecoService - - 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, - ) - cloud_reco_client = CloudRecoService( - client_access_key=database.client_access_key, - client_secret_key=database.client_secret_key, - ) - - image = pathlib.Path("high_quality_image.jpg") - with image.open(mode="rb") as my_image_file: - target_id = vws_client.add_target( - name="example_image_name", - width=1, - image=my_image_file, - application_metadata=None, - active_flag=True, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - matching_targets = cloud_reco_client.query(image=my_image_file) - - assert matching_targets[0].target_id == target_id - -There are some differences between the mock and the real Vuforia. -See https://vws-python.github.io/vws-python-mock/differences-to-vws for details. - -.. _VWS Python Mock: https://github.com/VWS-Python/vws-python-mock +Use `VWS Python Mock `_ to test Model Target and VuMark clients without calling Vuforia. Reference --------- diff --git a/newsfragments/3328.change.md b/newsfragments/3328.change.md new file mode 100644 index 000000000..1a65bc345 --- /dev/null +++ b/newsfragments/3328.change.md @@ -0,0 +1,3 @@ +Remove Cloud Image Recognition, cloud image target management, recognition reports, and their synchronous/asynchronous public clients. +`ModelTargetService`, `AsyncModelTargetService`, `VuMarkService`, `AsyncVuMarkService`, and their transports remain supported. +This breaks imports of `VWS`, `AsyncVWS`, `CloudRecoService`, `AsyncCloudRecoService`, and cloud-only models and exceptions. diff --git a/pyproject.toml b/pyproject.toml index ccf7af0dc..c30b2ae1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ requires = [ [project] name = "vws-python" -description = "Interact with the Vuforia Web Services (VWS) API." +description = "Clients for Vuforia Model Targets and VuMark generation." readme = { file = "README.rst", content-type = "text/x-rst" } keywords = [ "client", @@ -36,7 +36,6 @@ dependencies = [ "httpx>=0.28.0", "httpx2>=2.12", "requests>=2.32.3", - "urllib3>=2.2.3", "vws-auth-tools>=2024.7.12", ] urls.Documentation = "https://vws-python.github.io/vws-python/" @@ -97,7 +96,6 @@ dev = [ "vale==3.25.1.0", "vulture==2.16", "vws-python-mock==2026.10.13.1", - "vws-test-fixtures==2026.10.8", "yamlfix==1.19.1", "zizmor==1.31.1", ] diff --git a/src/vws/__init__.py b/src/vws/__init__.py index b4d39cc04..e039c624a 100644 --- a/src/vws/__init__.py +++ b/src/vws/__init__.py @@ -1,21 +1,13 @@ -"""A library for Vuforia Web Services.""" +"""Clients for Vuforia Model Targets and VuMark generation.""" from .async_model_target_service import AsyncModelTargetService -from .async_query import AsyncCloudRecoService from .async_vumark_service import AsyncVuMarkService -from .async_vws import AsyncVWS from .model_target_service import ModelTargetService -from .query import CloudRecoService from .vumark_service import VuMarkService -from .vws import VWS __all__ = [ - "VWS", - "AsyncCloudRecoService", "AsyncModelTargetService", - "AsyncVWS", "AsyncVuMarkService", - "CloudRecoService", "ModelTargetService", "VuMarkService", ] diff --git a/src/vws/_image_utils.py b/src/vws/_image_utils.py deleted file mode 100644 index 6f2997741..000000000 --- a/src/vws/_image_utils.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Image utility functions shared across VWS modules.""" - -import io -from typing import BinaryIO - -from beartype import beartype - -ImageType = io.BytesIO | BinaryIO - - -@beartype -def get_image_data(image: ImageType) -> bytes: - """Get the data of an image file.""" - original_tell = image.tell() - _ = image.seek(0) - image_data = image.read() - _ = image.seek(original_tell) - return image_data diff --git a/src/vws/_json_utils.py b/src/vws/_json_utils.py index 09575bffb..7fdcdfba2 100644 --- a/src/vws/_json_utils.py +++ b/src/vws/_json_utils.py @@ -60,22 +60,6 @@ def string_field(*, value: dict[str, JSONValue], name: str) -> str: return string_value(value=value[name], name=name) -@beartype -def string_list_field( - *, - value: dict[str, JSONValue], - name: str, -) -> list[str]: - """Return a required list of strings from a JSON object.""" - items = value[name] - if not _is_object_list(items) or not all( - isinstance(item, str) for item in items - ): - msg = f"{name} must be a list of strings." - raise TypeError(msg) - return [item for item in items if isinstance(item, str)] - - @beartype def object_list_field( *, diff --git a/src/vws/_reco_counts.py b/src/vws/_reco_counts.py deleted file mode 100644 index 0654174cf..000000000 --- a/src/vws/_reco_counts.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Internal helpers for the database reco counts report endpoints.""" - -import calendar -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 - - -@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_query.py b/src/vws/async_query.py deleted file mode 100644 index 209ef1f14..000000000 --- a/src/vws/async_query.py +++ /dev/null @@ -1,241 +0,0 @@ -"""Async tools for interacting with the Vuforia Cloud Recognition -Web APIs. -""" - -import json -from http import HTTPMethod, HTTPStatus -from typing import Self - -from beartype import BeartypeConf, beartype -from urllib3.fields import RequestField -from urllib3.filepost import encode_multipart_formdata -from vws_auth_tools import authorization_header, rfc_1123_date - -from vws._image_utils import ImageType as _ImageType -from vws._image_utils import get_image_data as _get_image_data -from vws._json_utils import json_object, object_list_field, string_field -from vws.exceptions.base_exceptions import CloudRecoError -from vws.exceptions.cloud_reco_exceptions import ( - AuthenticationFailureError, - BadImageError, - InactiveProjectError, - MaxNumResultsOutOfRangeError, - RequestTimeTooSkewedError, -) -from vws.exceptions.custom_exceptions import ( - RequestEntityTooLargeError, - ServerError, -) -from vws.include_target_data import CloudRecoIncludeTargetData -from vws.reports import QueryResult -from vws.transports import AsyncHTTPXTransport, AsyncTransport - - -@beartype(conf=BeartypeConf(is_pep484_tower=True)) -class AsyncCloudRecoService: - """An async interface to the Vuforia Cloud Recognition Web - APIs. - """ - - def __init__( - self, - *, - client_access_key: str, - client_secret_key: str, - base_vwq_url: str = "https://cloudreco.vuforia.com", - request_timeout_seconds: float | tuple[float, float] = 30.0, - transport: AsyncTransport | None = None, - ) -> None: - """ - Args: - client_access_key: A VWS client access key. - client_secret_key: A VWS client secret key. - base_vwq_url: The base URL for the VWQ API. - request_timeout_seconds: The timeout for each - HTTP request. This can be a float to set both - the connect and read timeouts, or a - (connect, read) tuple. - transport: The async HTTP transport to use for - requests. Defaults to - ``AsyncHTTPXTransport()``. - """ - self._client_access_key = client_access_key - self._client_secret_key = client_secret_key - self._base_vwq_url = base_vwq_url - self._request_timeout_seconds = request_timeout_seconds - self._transport = ( - transport if transport is not None else AsyncHTTPXTransport() - ) - - async def aclose(self) -> None: - """Close the underlying transport if it supports closing.""" - await self._transport.aclose() - - async def __aenter__(self) -> Self: - """Enter the async context manager.""" - return self - - async def __aexit__(self, *_args: object) -> None: - """Exit the async context manager and close the transport.""" - await self.aclose() - - async def query( - self, - *, - image: _ImageType, - max_num_results: int = 1, - include_target_data: CloudRecoIncludeTargetData = ( - CloudRecoIncludeTargetData.TOP - ), - ) -> list[QueryResult]: - """Use the Vuforia Web Query API to make an Image - Recognition Query. - - See - https://developer.vuforia.com/library/web-api/vuforia-query-web-api - for parameter details. - - Args: - image: The image to make a query against. - max_num_results: The maximum number of matching - targets to be returned. - include_target_data: Indicates if target_data - records shall be returned for the matched - targets. Accepted values are top (default - value, only return target_data for top ranked - match), none (return no target_data), all - (for all matched targets). - - Raises: - ~vws.exceptions.cloud_reco_exceptions.AuthenticationFailureError: - The client access key pair is not correct. - ~vws.exceptions.cloud_reco_exceptions.MaxNumResultsOutOfRangeError: - ``max_num_results`` is not within the range (1, 50). - ~vws.exceptions.cloud_reco_exceptions.InactiveProjectError: The - project is inactive. - ~vws.exceptions.cloud_reco_exceptions.RequestTimeTooSkewedError: - There is an error with the time sent to Vuforia. - ~vws.exceptions.cloud_reco_exceptions.BadImageError: There is a - problem with the given image. For example, it must be a JPEG or - PNG file in the grayscale or RGB color space. - ~vws.exceptions.custom_exceptions.RequestEntityTooLargeError: The - given image is too large. - ~vws.exceptions.custom_exceptions.ServerError: There is an - error with Vuforia's servers. - ~vws.exceptions.base_exceptions.CloudRecoError: Vuforia returned - a client error without a recognized JSON body. - json.JSONDecodeError: Vuforia returned a successful response with - an invalid JSON body. - - Returns: - An ordered list of target details of matching - targets. - """ - image_content = _get_image_data(image=image) - max_num_results_field = RequestField( - name="max_num_results", - data=str(object=max_num_results), - ) - max_num_results_field.make_multipart( - content_disposition="form-data", - content_type="text/plain", - ) - include_target_data_field = RequestField( - name="include_target_data", - data=include_target_data.value, - ) - include_target_data_field.make_multipart( - content_disposition="form-data", - content_type="text/plain", - ) - body = [ - RequestField.from_tuples( - fieldname="image", - value=( - "image.jpeg", - image_content, - "image/jpeg", - ), - ), - max_num_results_field, - include_target_data_field, - ] - date = rfc_1123_date() - request_path = "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/v1/query" - content, content_type_header = encode_multipart_formdata(fields=body) - method = HTTPMethod.POST - - authorization_string = authorization_header( - access_key=self._client_access_key, - secret_key=self._client_secret_key, - method=method, - content=content, - # Note that this is not the actual Content-Type - # header value sent. - content_type="multipart/form-data", - date=date, - request_path=request_path, - ) - - headers = { - "Authorization": authorization_string, - "Date": date, - "Content-Type": content_type_header, - } - - response = await self._transport( - method=method, - url=self._base_vwq_url.rstrip("/") + request_path, - headers=headers, - data=content, - request_timeout=self._request_timeout_seconds, - ) - - if response.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE: - raise RequestEntityTooLargeError(response=response) - - if "Integer out of range" in response.text: - raise MaxNumResultsOutOfRangeError( - response=response, - ) - - if response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR: - raise ServerError(response=response) - - content_type = { - key.lower(): value for key, value in response.headers.items() - }.get("content-type", "") - if ( - response.status_code >= HTTPStatus.BAD_REQUEST - and not content_type.lower().startswith("application/json") - ): - raise CloudRecoError(response=response) - - try: - response_body = json_object(value=response.text) - except json.JSONDecodeError as exc: - if response.status_code >= HTTPStatus.BAD_REQUEST: - raise CloudRecoError(response=response) from exc - raise - - result_code = string_field( - value=response_body, - name="result_code", - ) - if result_code != "Success": - exception = { - "AuthenticationFailure": (AuthenticationFailureError), - "BadImage": BadImageError, - "InactiveProject": InactiveProjectError, - "RequestTimeTooSkewed": (RequestTimeTooSkewedError), - }[result_code] - raise exception(response=response) - - result_list = object_list_field( - value=response_body, - name="results", - ) - return [ - QueryResult.from_response_dict(response_dict=item) - for item in result_list - ] diff --git a/src/vws/async_vws.py b/src/vws/async_vws.py deleted file mode 100644 index 89a3cd3db..000000000 --- a/src/vws/async_vws.py +++ /dev/null @@ -1,758 +0,0 @@ -"""Async tools for interacting with Vuforia APIs.""" - -import asyncio -import base64 -import calendar # noqa: TC003 -import json -import time -from http import HTTPMethod, HTTPStatus -from typing import Self - -from beartype import BeartypeConf, beartype - -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._json_utils import json_object, string_field, string_list_field -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, -) -from vws.response import Response # noqa: TC001 -from vws.transports import AsyncHTTPXTransport, AsyncTransport - - -@beartype(conf=BeartypeConf(is_pep484_tower=True)) -class AsyncVWS: - """An async interface to Vuforia Web Services APIs.""" - - def __init__( - self, - *, - 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: - """ - Args: - 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 - (connect, read) tuple. - transport: The async HTTP transport to use for - requests. Defaults to - ``AsyncHTTPXTransport()``. - """ - 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() - ) - - async def aclose(self) -> None: - """Close the underlying transport if it supports closing.""" - await self._transport.aclose() - - async def __aenter__(self) -> Self: - """Enter the async context manager.""" - return self - - async def __aexit__(self, *_args: object) -> None: - """Exit the async context manager and close the transport.""" - await self.aclose() - - async def make_request( - self, - *, - method: str, - data: bytes, - request_path: str, - expected_result_code: str, - content_type: str, - extra_headers: dict[str, str] | None = None, - ) -> Response: - """Make an async request to the Vuforia Target API. - - Args: - method: The HTTP method which will be used in - the request. - data: The request body which will be used in the - request. - request_path: The path to the endpoint which - will be used in the request. - expected_result_code: See - "VWS API Result Codes" on - https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api. - content_type: The content type of the request. - extra_headers: Additional headers to include in - the request. - - Returns: - The response to the request. - - Raises: - ~vws.exceptions.custom_exceptions.ServerError: - There is an error with Vuforia's servers. - ~vws.exceptions.vws_exceptions.TooManyRequestsError: - Vuforia is rate limiting access. - json.JSONDecodeError: The server did not respond - with valid JSON. This may happen if the - server address is not a valid Vuforia server. - """ - response = await async_target_api_request( - content_type=content_type, - server_access_key=self._server_access_key, - server_secret_key=self._server_secret_key, - method=method, - data=data, - request_path=request_path, - base_vws_url=self._base_vws_url, - request_timeout_seconds=self._request_timeout_seconds, - extra_headers=(extra_headers if extra_headers is not None else {}), - transport=self._transport, - ) - - if response.status_code == HTTPStatus.TOO_MANY_REQUESTS: - # The Vuforia API returns a 429 response with no JSON body. - raise TooManyRequestsError(response=response) - - if response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR: - raise ServerError(response=response) - - result_code = string_field( - value=json_object(value=response.text), - name="result_code", - ) - if result_code == expected_result_code: - return response - - raise VWSError.from_result_code( - result_code=result_code, - response=response, - ) - - async def add_target( - self, - *, - name: str, - width: float, - image: _ImageType, - application_metadata: str | None, - active_flag: bool, - ) -> str: - """Add a target to a Vuforia Web Services database. - - See - https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#add - for parameter details. - - Args: - name: The name of the target. - width: The width of the target. - image: The image of the target. - active_flag: Whether or not the target is active for query. - application_metadata: The application metadata of the target. - This must be base64 encoded, for example by using:: - - base64.b64encode('input_string').decode('ascii') - - Returns: - The target ID of the new target. - - Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The - secret key is not correct. - ~vws.exceptions.vws_exceptions.BadImageError: There is a problem - with the given image. For example, it must be a JPEG or PNG - file in the grayscale or RGB color space. - ~vws.exceptions.vws_exceptions.FailError: There was an error with - the request. For example, the given access key does not match a - known database. - ~vws.exceptions.vws_exceptions.MetadataTooLargeError: The given - metadata is too large. The maximum size is 1 MB of data when - Base64 encoded. - ~vws.exceptions.vws_exceptions.ImageTooLargeError: The given image - is too large. - ~vws.exceptions.vws_exceptions.TargetNameExistError: A target with - the given ``name`` already exists. - ~vws.exceptions.vws_exceptions.ProjectInactiveError: The project is - inactive. - ~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. This has been seen to happen when the - given name includes a bad character. - ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is - rate limiting access. - """ - image_data = _get_image_data(image=image) - image_data_encoded = base64.b64encode(s=image_data).decode( - encoding="ascii", - ) - - data = { - "name": name, - "width": width, - "image": image_data_encoded, - "active_flag": active_flag, - "application_metadata": application_metadata, - } - - content = json.dumps(obj=data).encode(encoding="utf-8") - - response = await self.make_request( - method=HTTPMethod.POST, - data=content, - request_path="/targets", - expected_result_code="TargetCreated", - content_type="application/json", - ) - - return string_field( - value=json_object(value=response.text), - name="target_id", - ) - - async def get_target_record(self, target_id: str) -> TargetStatusAndRecord: - """Get a given target's target record from the Target - Management System. - - See - https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record. - - Args: - target_id: The ID of the target to get details of. - - Returns: - Response details of a target from Vuforia. - - Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The - secret key is not correct. - ~vws.exceptions.vws_exceptions.FailError: There was an error with - the request. For example, the given access key does not match a - known database. - ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target - ID does not match a target in the database. - ~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.GET, - data=b"", - request_path=f"/targets/{target_id}", - expected_result_code="Success", - content_type="application/json", - ) - - result_data = json_object(value=response.text) - return TargetStatusAndRecord.from_response_dict( - response_dict=result_data, - ) - - async def wait_for_target_processed( - self, - *, - target_id: str, - seconds_between_requests: float = 0.2, - timeout_seconds: float = 60 * 5, - ) -> None: - """Wait up to five minutes (arbitrary) for a target to - get past the processing stage. - - Args: - target_id: The ID of the target to wait for. - seconds_between_requests: The number of seconds to - wait between requests made while polling the - target status. - We wait 0.2 seconds by default, rather than - less, than that to decrease the number of calls - made to the API, to decrease the likelihood of - hitting the request quota. - timeout_seconds: The maximum number of seconds to - wait for the target to be processed. - - Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The - secret key is not correct. - ~vws.exceptions.vws_exceptions.FailError: There was an error with - the request. For example, the given access key does not match a - known database. - ~vws.exceptions.custom_exceptions.TargetProcessingTimeoutError: The - target remained in the processing stage for more than - ``timeout_seconds`` seconds. - ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target - ID does not match a target in the database. - ~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. - """ - start_time = asyncio.get_event_loop().time() - while True: - report = await self.get_target_summary_report( - target_id=target_id, - ) - if report.status != TargetStatuses.PROCESSING: - # Guard against the target still being seen as - # processing by other endpoints due to eventual - # consistency. - await asyncio.sleep( - delay=seconds_between_requests, - ) - return - - elapsed_time = asyncio.get_event_loop().time() - start_time - if elapsed_time > timeout_seconds: - raise TargetProcessingTimeoutError - - await asyncio.sleep( - delay=seconds_between_requests, - ) - - async def list_targets(self) -> list[str]: - """List target IDs. - - See - https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#details-list. - - Returns: - The IDs of all targets in the database. - - Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The - secret key is not correct. - ~vws.exceptions.vws_exceptions.FailError: There was an error with - the request. For example, the given access key does not match a - known database. - ~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.GET, - data=b"", - request_path="/targets", - expected_result_code="Success", - content_type="application/json", - ) - - return string_list_field( - value=json_object(value=response.text), - name="results", - ) - - async def get_target_summary_report( - self, target_id: str - ) -> TargetSummaryReport: - """Get a summary report for a target. - - See - https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#summary-report. - - Args: - target_id: The ID of the target to get a summary - report for. - - Returns: - Details of the target. - - Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The - secret key is not correct. - ~vws.exceptions.vws_exceptions.FailError: There was an error with - the request. For example, the given access key does not match a - known database. - ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target - ID does not match a target in the database. - ~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.GET, - data=b"", - request_path=f"/summary/{target_id}", - expected_result_code="Success", - content_type="application/json", - ) - - result_data = json_object(value=response.text) - return TargetSummaryReport.from_response_dict( - response_dict=result_data, - ) - - async def get_database_summary_report( - self, - ) -> DatabaseSummaryReport: - """Get a summary report for the database. - - See - https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#summary-report. - - Returns: - Details of the database. - - Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The - secret key is not correct. - ~vws.exceptions.vws_exceptions.FailError: There was an error with - the request. For example, the given access key does not match a - known database. - ~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.GET, - data=b"", - request_path="/summary", - expected_result_code="Success", - content_type="application/json", - ) - - response_data = json_object(value=response.text) - return DatabaseSummaryReport.from_response_dict( - 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 = json_object(value=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. - - See - https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#delete. - - Args: - target_id: The ID of the target to delete. - - Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The - secret key is not correct. - ~vws.exceptions.vws_exceptions.FailError: There was an error with - the request. For example, the given access key does not match a - known database. - ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target - ID does not match a target in the database. - ~vws.exceptions.vws_exceptions.TargetStatusProcessingError: The - given target is in the processing state. - ~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. - """ - await self.make_request( - method=HTTPMethod.DELETE, - data=b"", - request_path=f"/targets/{target_id}", - expected_result_code="Success", - content_type="application/json", - ) - - async def get_duplicate_targets(self, target_id: str) -> list[str]: - """Get targets which may be considered duplicates of a - given target. - - See - https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#check. - - Args: - target_id: The ID of the target to delete. - - Returns: - The target IDs of duplicate targets. - - Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The - secret key is not correct. - ~vws.exceptions.vws_exceptions.FailError: There was an error with - the request. For example, the given access key does not match a - known database. - ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target - ID does not match a target in the database. - ~vws.exceptions.vws_exceptions.ProjectInactiveError: The project is - inactive. - ~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.GET, - data=b"", - request_path=f"/duplicates/{target_id}", - expected_result_code="Success", - content_type="application/json", - ) - - return string_list_field( - value=json_object(value=response.text), - name="similar_targets", - ) - - async def update_target( - self, - *, - target_id: str, - name: str | None = None, - width: float | None = None, - image: _ImageType | None = None, - active_flag: bool | None = None, - application_metadata: str | None = None, - ) -> None: - """Update a target in a Vuforia Web Services database. - - See - https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#update - for parameter details. - - Args: - target_id: The ID of the target to update. - name: The name of the target. - width: The width of the target. - image: The image of the target. - active_flag: Whether or not the target is active - for query. - application_metadata: The application metadata of - the target. - This must be base64 encoded, for example by - using:: - - base64.b64encode('input_string').decode('ascii') - - Giving ``None`` will not change the application - metadata. - - Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The - secret key is not correct. - ~vws.exceptions.vws_exceptions.BadImageError: There is a problem - with the given image. For example, it must be a JPEG or PNG - file in the grayscale or RGB color space. - ~vws.exceptions.vws_exceptions.FailError: There was an error with - the request. For example, the given access key does not match a - known database. - ~vws.exceptions.vws_exceptions.MetadataTooLargeError: The given - metadata is too large. The maximum size is 1 MB of data when - Base64 encoded. - ~vws.exceptions.vws_exceptions.ImageTooLargeError: The given image - is too large. - ~vws.exceptions.vws_exceptions.TargetNameExistError: A target with - the given ``name`` already exists. - ~vws.exceptions.vws_exceptions.ProjectInactiveError: The project is - inactive. - ~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. - """ - data: dict[str, str | bool | float | int] = {} - - if name is not None: - data["name"] = name - - if width is not None: - data["width"] = width - - if image is not None: - image_data = _get_image_data(image=image) - image_data_encoded = base64.b64encode( - s=image_data, - ).decode(encoding="ascii") - data["image"] = image_data_encoded - - if active_flag is not None: - data["active_flag"] = active_flag - - if application_metadata is not None: - data["application_metadata"] = application_metadata - - content = json.dumps(obj=data).encode(encoding="utf-8") - - await self.make_request( - method=HTTPMethod.PUT, - data=content, - request_path=f"/targets/{target_id}", - expected_result_code="Success", - content_type="application/json", - ) diff --git a/src/vws/exceptions/base_exceptions.py b/src/vws/exceptions/base_exceptions.py index 616e0682f..6d612d15c 100644 --- a/src/vws/exceptions/base_exceptions.py +++ b/src/vws/exceptions/base_exceptions.py @@ -1,8 +1,4 @@ -""" -Base exceptions for errors returned by Vuforia Web Services or the -Vuforia -Cloud Recognition Web API. -""" +"""Base exceptions for the supported Vuforia Web APIs.""" from collections.abc import Mapping # noqa: TC003 from typing import ClassVar @@ -12,24 +8,6 @@ from vws.response import Response # noqa: TC001 -@beartype -class CloudRecoError(Exception): - """Base class for Vuforia Cloud Recognition Web API exceptions.""" - - def __init__(self, response: Response) -> None: - """ - Args: - response: The response to a request to Vuforia. - """ - super().__init__(response.text) - self._response = response - - @property - def response(self) -> Response: - """The response returned by Vuforia which included this error.""" - return self._response - - @beartype class VWSError(Exception): """Base class for Vuforia Web Services errors. diff --git a/src/vws/exceptions/cloud_reco_exceptions.py b/src/vws/exceptions/cloud_reco_exceptions.py deleted file mode 100644 index b2e3ee67f..000000000 --- a/src/vws/exceptions/cloud_reco_exceptions.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Exceptions which match errors raised by the Vuforia Cloud Recognition -Web -APIs. -""" - -from beartype import beartype - -from vws.exceptions.base_exceptions import CloudRecoError - - -@beartype -class MaxNumResultsOutOfRangeError(CloudRecoError): - """ - Exception raised when the ``max_num_results`` given to the Cloud - Recognition Web API query endpoint is out of range. - """ - - -@beartype -class InactiveProjectError(CloudRecoError): - """Exception raised when Vuforia returns a response with a result code - 'InactiveProject'. - """ - - -@beartype -class BadImageError(CloudRecoError): - """Exception raised when Vuforia returns a response with a result code - 'BadImage'. - """ - - -@beartype -class AuthenticationFailureError(CloudRecoError): - """Exception raised when Vuforia returns a response with a result code - 'AuthenticationFailure'. - """ - - -@beartype -class RequestTimeTooSkewedError(CloudRecoError): - """Exception raised when Vuforia returns a response with a result code - 'RequestTimeTooSkewed'. - """ diff --git a/src/vws/exceptions/custom_exceptions.py b/src/vws/exceptions/custom_exceptions.py index 660b20709..7bf2bf619 100644 --- a/src/vws/exceptions/custom_exceptions.py +++ b/src/vws/exceptions/custom_exceptions.py @@ -1,94 +1,10 @@ -"""Exceptions which do not map to errors at the following URL, or simple -errors given by the cloud recognition service. - -https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#result-codes -""" +"""HTTP errors shared by the supported Vuforia APIs.""" from beartype import beartype from vws.response import Response # noqa: TC001 -@beartype -class RequestEntityTooLargeError(Exception): - """Exception raised when the given image is too large.""" - - def __init__(self, response: Response) -> None: - """ - Args: - response: The response returned by Vuforia. - """ - super().__init__(response.text) - self._response = response - - @property - def response(self) -> Response: - """The response returned by Vuforia which included this error.""" - return self._response - - -@beartype -class TargetProcessingTimeoutError(Exception): - """Exception raised when waiting for a target to be processed times - out. - """ - - -@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): """Exception raised when VWS returns a server error.""" diff --git a/src/vws/exceptions/vws_exceptions.py b/src/vws/exceptions/vws_exceptions.py index f1631356f..152981682 100644 --- a/src/vws/exceptions/vws_exceptions.py +++ b/src/vws/exceptions/vws_exceptions.py @@ -10,7 +10,6 @@ from beartype import beartype -from vws._json_utils import json_object, string_field from vws.exceptions.base_exceptions import VWSError @@ -52,13 +51,6 @@ class FailError(VWSError): """ -@beartype -class BadImageError(VWSError): - """Exception raised when Vuforia returns a response with a result code - 'BadImage'. - """ - - @beartype class AuthenticationFailureError(VWSError): """Exception raised when Vuforia returns a response with a result code @@ -73,32 +65,6 @@ class RequestQuotaReachedError(VWSError): """ -@beartype -class TargetStatusProcessingError(VWSError): - """Exception raised when Vuforia returns a response with a result code - 'TargetStatusProcessing'. - """ - - @property - def target_id(self) -> str: - """The processing target ID.""" - return _target_id_from_url(url=self.response.url) - - -@beartype -class DateRangeError(VWSError): - """Exception raised when Vuforia returns a response with a result code - 'DateRangeError'. - """ - - -@beartype -class TargetQuotaReachedError(VWSError): - """Exception raised when Vuforia returns a response with a result code - 'TargetQuotaReached'. - """ - - @beartype class ProjectSuspendedError(VWSError): """Exception raised when Vuforia returns a response with a result code @@ -120,13 +86,6 @@ class ProjectInactiveError(VWSError): """ -@beartype -class MetadataTooLargeError(VWSError): - """Exception raised when Vuforia returns a response with a result code - 'MetadataTooLarge'. - """ - - @beartype class RequestTimeTooSkewedError(VWSError): """Exception raised when Vuforia returns a response with a result code @@ -134,30 +93,6 @@ class RequestTimeTooSkewedError(VWSError): """ -@beartype -class TargetNameExistError(VWSError): - """Exception raised when Vuforia returns a response with a result code - 'TargetNameExist'. - """ - - @property - def target_name(self) -> str: - """The target name which already exists.""" - response_body = self.response.request_body - if not isinstance(response_body, str | bytes): - msg = "A target-name error response must have a request body." - raise TypeError(msg) - request_json = json_object(value=response_body) - return string_field(value=request_json, name="name") - - -@beartype -class ImageTooLargeError(VWSError): - """Exception raised when Vuforia returns a response with a result code - 'ImageTooLarge'. - """ - - @beartype class TargetStatusNotSuccessError(VWSError): """Exception raised when Vuforia returns a response with a result code @@ -230,26 +165,19 @@ class AuthorizationFailedError(VWSError): exceptions_by_result_code={ "AuthenticationFailure": AuthenticationFailureError, "AuthorizationFailed": AuthorizationFailedError, - "BadImage": BadImageError, "BadRequest": BadRequestError, - "DateRangeError": DateRangeError, "Fail": FailError, - "ImageTooLarge": ImageTooLargeError, "InvalidAcceptHeader": InvalidAcceptHeaderError, "InvalidInstanceId": InvalidInstanceIdError, "InvalidTargetType": InvalidTargetTypeError, "LicenseCheckFailed": LicenseCheckFailedError, - "MetadataTooLarge": MetadataTooLargeError, "ProjectHasNoApiAccess": ProjectHasNoAPIAccessError, "ProjectInactive": ProjectInactiveError, "ProjectSuspended": ProjectSuspendedError, "QuotaExceeded": QuotaExceededError, "RequestQuotaReached": RequestQuotaReachedError, "RequestTimeTooSkewed": RequestTimeTooSkewedError, - "TargetNameExist": TargetNameExistError, - "TargetQuotaReached": TargetQuotaReachedError, "TargetStatusNotSuccess": TargetStatusNotSuccessError, - "TargetStatusProcessing": TargetStatusProcessingError, "UnknownTarget": UnknownTargetError, }, ) diff --git a/src/vws/include_target_data.py b/src/vws/include_target_data.py deleted file mode 100644 index 0692a1e40..000000000 --- a/src/vws/include_target_data.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Tools for managing ``CloudRecoService.query``'s ``include_target_data``.""" - -from enum import StrEnum, auto, unique - -from beartype import beartype - - -@beartype -@unique -class CloudRecoIncludeTargetData(StrEnum): - """ - Options for the ``include_target_data`` parameter of - ``CloudRecoService.query``. - """ - - TOP = auto() - NONE = auto() - ALL = auto() diff --git a/src/vws/query.py b/src/vws/query.py deleted file mode 100644 index 6c25a745b..000000000 --- a/src/vws/query.py +++ /dev/null @@ -1,214 +0,0 @@ -"""Tools for interacting with the Vuforia Cloud Recognition Web APIs.""" - -import json -from http import HTTPMethod, HTTPStatus - -from beartype import BeartypeConf, beartype -from urllib3.fields import RequestField -from urllib3.filepost import encode_multipart_formdata -from vws_auth_tools import authorization_header, rfc_1123_date - -from vws._image_utils import ImageType as _ImageType -from vws._image_utils import get_image_data as _get_image_data -from vws._json_utils import json_object, object_list_field, string_field -from vws.exceptions.base_exceptions import CloudRecoError -from vws.exceptions.cloud_reco_exceptions import ( - AuthenticationFailureError, - BadImageError, - InactiveProjectError, - MaxNumResultsOutOfRangeError, - RequestTimeTooSkewedError, -) -from vws.exceptions.custom_exceptions import ( - RequestEntityTooLargeError, - ServerError, -) -from vws.include_target_data import CloudRecoIncludeTargetData -from vws.reports import QueryResult -from vws.transports import RequestsTransport, Transport - - -@beartype(conf=BeartypeConf(is_pep484_tower=True)) -class CloudRecoService: - """An interface to the Vuforia Cloud Recognition Web APIs.""" - - def __init__( - self, - *, - client_access_key: str, - client_secret_key: str, - base_vwq_url: str = "https://cloudreco.vuforia.com", - request_timeout_seconds: float | tuple[float, float] = 30.0, - transport: Transport | None = None, - ) -> None: - """ - Args: - client_access_key: A VWS client access key. - client_secret_key: A VWS client secret key. - base_vwq_url: The base URL for the VWQ API. - request_timeout_seconds: The timeout for each - HTTP request. This can be a float to set both - the connect and read timeouts, or a - (connect, read) tuple. - transport: The HTTP transport to use for - requests. Defaults to - ``RequestsTransport()``. - """ - self._client_access_key = client_access_key - self._client_secret_key = client_secret_key - self._base_vwq_url = base_vwq_url - self._request_timeout_seconds = request_timeout_seconds - self._transport = ( - transport if transport is not None else RequestsTransport() - ) - - def query( - self, - *, - image: _ImageType, - max_num_results: int = 1, - include_target_data: CloudRecoIncludeTargetData = ( - CloudRecoIncludeTargetData.TOP - ), - ) -> list[QueryResult]: - """Use the Vuforia Web Query API to make an Image Recognition - Query. - - See - https://developer.vuforia.com/library/web-api/vuforia-query-web-api - for parameter details. - - Args: - image: The image to make a query against. - max_num_results: The maximum number of matching targets to be - returned. - include_target_data: Indicates if target_data records shall be - returned for the matched targets. Accepted values are top - (default value, only return target_data for top ranked match), - none (return no target_data), all (for all matched targets). - - Raises: - ~vws.exceptions.cloud_reco_exceptions.AuthenticationFailureError: - The client access key pair is not correct. - ~vws.exceptions.cloud_reco_exceptions.MaxNumResultsOutOfRangeError: - ``max_num_results`` is not within the range (1, 50). - ~vws.exceptions.cloud_reco_exceptions.InactiveProjectError: The - project is inactive. - ~vws.exceptions.cloud_reco_exceptions.RequestTimeTooSkewedError: - There is an error with the time sent to Vuforia. - ~vws.exceptions.cloud_reco_exceptions.BadImageError: There is a - problem with the given image. For example, it must be a JPEG or - PNG file in the grayscale or RGB color space. - ~vws.exceptions.custom_exceptions.RequestEntityTooLargeError: The - given image is too large. - ~vws.exceptions.custom_exceptions.ServerError: There is an - error with Vuforia's servers. - ~vws.exceptions.base_exceptions.CloudRecoError: Vuforia returned - a client error without a recognized JSON body. - json.JSONDecodeError: Vuforia returned a successful response with - an invalid JSON body. - - Returns: - An ordered list of target details of matching targets. - """ - image_content = _get_image_data(image=image) - max_num_results_field = RequestField( - name="max_num_results", - data=str(object=max_num_results), - ) - max_num_results_field.make_multipart( - content_disposition="form-data", - content_type="text/plain", - ) - include_target_data_field = RequestField( - name="include_target_data", - data=include_target_data.value, - ) - include_target_data_field.make_multipart( - content_disposition="form-data", - content_type="text/plain", - ) - body = [ - RequestField.from_tuples( - fieldname="image", - value=("image.jpeg", image_content, "image/jpeg"), - ), - max_num_results_field, - include_target_data_field, - ] - date = rfc_1123_date() - request_path = "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/v1/query" - content, content_type_header = encode_multipart_formdata(fields=body) - method = HTTPMethod.POST - - authorization_string = authorization_header( - access_key=self._client_access_key, - secret_key=self._client_secret_key, - method=method, - content=content, - # Note that this is not the actual Content-Type header value sent. - content_type="multipart/form-data", - date=date, - request_path=request_path, - ) - - headers = { - "Authorization": authorization_string, - "Date": date, - "Content-Type": content_type_header, - } - - response = self._transport( - method=method, - url=self._base_vwq_url.rstrip("/") + request_path, - headers=headers, - data=content, - request_timeout=self._request_timeout_seconds, - ) - - if response.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE: - raise RequestEntityTooLargeError(response=response) - - if "Integer out of range" in response.text: - raise MaxNumResultsOutOfRangeError(response=response) - - if response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR: - raise ServerError(response=response) - - content_type = { - key.lower(): value for key, value in response.headers.items() - }.get("content-type", "") - if ( - response.status_code >= HTTPStatus.BAD_REQUEST - and not content_type.lower().startswith("application/json") - ): - raise CloudRecoError(response=response) - - try: - response_body = json_object(value=response.text) - except json.JSONDecodeError as exc: - if response.status_code >= HTTPStatus.BAD_REQUEST: - raise CloudRecoError(response=response) from exc - raise - - result_code = string_field( - value=response_body, - name="result_code", - ) - if result_code != "Success": - exception = { - "AuthenticationFailure": AuthenticationFailureError, - "BadImage": BadImageError, - "InactiveProject": InactiveProjectError, - "RequestTimeTooSkewed": RequestTimeTooSkewedError, - }[result_code] - raise exception(response=response) - - result_list = object_list_field( - value=response_body, - name="results", - ) - return [ - QueryResult.from_response_dict(response_dict=item) - for item in result_list - ] diff --git a/src/vws/reports.py b/src/vws/reports.py index de79f0588..eef6fb2ac 100644 --- a/src/vws/reports.py +++ b/src/vws/reports.py @@ -1,14 +1,12 @@ """Classes for representing Vuforia reports.""" -import csv import datetime as dt -import io from collections.abc import Mapping, Sequence from dataclasses import dataclass from enum import Enum, unique from typing import Self, TypeIs -from beartype import BeartypeConf, beartype +from beartype import beartype from beartype.door import TypeHint from vws.json_types import JSONValue @@ -27,259 +25,6 @@ def _is_type[T](value: object, hint: type[T], /) -> TypeIs[T]: return TypeHint(hint=hint).is_bearable(obj=value) -@beartype -def _number(value: object, /) -> int | float: - """Return a runtime-validated JSON number.""" - if isinstance(value, bool) or not isinstance(value, int | float): - msg = f"Expected a number, got {value!r}." - raise TypeError(msg) - return value - - -@beartype -def _optional_string(value: object, /) -> str | None: - """Return a runtime-validated optional string.""" - if value is not None and not isinstance(value, str): - msg = f"Expected an optional string, got {value!r}." - raise TypeError(msg) - return value - - -@beartype -@dataclass(frozen=True, kw_only=True) -class DatabaseSummaryReport: - """A database summary report. - - See - https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#summary-report. - """ - - active_images: int - current_month_recos: int - failed_images: int - inactive_images: int - name: str - previous_month_recos: int - processing_images: int - reco_threshold: int - request_quota: int - request_usage: int - target_quota: int - total_recos: int - - @classmethod - def from_response_dict( - cls, response_dict: Mapping[str, JSONValue] - ) -> Self: - """Construct from a VWS API response dict.""" - return cls( - active_images=int(_number(response_dict["active_images"])), - current_month_recos=int( - _number(response_dict["current_month_recos"]) - ), - failed_images=int(_number(response_dict["failed_images"])), - inactive_images=int(_number(response_dict["inactive_images"])), - name=_checked(response_dict["name"], str), - previous_month_recos=int( - _number(response_dict["previous_month_recos"]) - ), - processing_images=int(_number(response_dict["processing_images"])), - reco_threshold=int(_number(response_dict["reco_threshold"])), - request_quota=int(_number(response_dict["request_quota"])), - request_usage=int(_number(response_dict["request_usage"])), - target_quota=int(_number(response_dict["target_quota"])), - total_recos=int(_number(response_dict["total_recos"])), - ) - - -@beartype -@unique -class TargetStatuses(Enum): - """Constants representing VWS target statuses. - - See the 'status' field in - https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record - """ - - PROCESSING = "processing" - SUCCESS = "success" - FAILED = "failed" - - -@beartype -@dataclass(frozen=True, kw_only=True) -class TargetSummaryReport: - """A target summary report. - - See - https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#summary-report. - """ - - status: TargetStatuses - database_name: str - target_name: str - upload_date: dt.date - active_flag: bool - tracking_rating: int - total_recos: int - current_month_recos: int - previous_month_recos: int - - @classmethod - def from_response_dict( - cls, response_dict: Mapping[str, JSONValue] - ) -> Self: - """Construct from a VWS API response dict.""" - return cls( - status=TargetStatuses( - value=_checked(response_dict["status"], str) - ), - database_name=_checked(response_dict["database_name"], str), - target_name=_checked(response_dict["target_name"], str), - upload_date=dt.date.fromisoformat( - _checked(response_dict["upload_date"], str) - ), - active_flag=bool(response_dict["active_flag"]), - tracking_rating=int(_number(response_dict["tracking_rating"])), - total_recos=int(_number(response_dict["total_recos"])), - current_month_recos=int( - _number(response_dict["current_month_recos"]) - ), - previous_month_recos=int( - _number(response_dict["previous_month_recos"]) - ), - ) - - -@beartype(conf=BeartypeConf(is_pep484_tower=True)) -@dataclass(frozen=True, kw_only=True) -class TargetRecord: - """A target record. - - See - https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record. - """ - - target_id: str - active_flag: bool - name: str - width: float - tracking_rating: int - reco_rating: str - - -@beartype -@dataclass(frozen=True, kw_only=True) -class TargetData: - """The target data optionally included with a query match.""" - - name: str - application_metadata: str | None - target_timestamp: dt.datetime - - -@beartype -@dataclass(frozen=True, kw_only=True) -class QueryResult: - """One query match result. - - See - https://developer.vuforia.com/library/web-api/vuforia-query-web-api. - """ - - target_id: str - target_data: TargetData | None - - @classmethod - def from_response_dict( - cls, - response_dict: Mapping[str, JSONValue], - ) -> Self: - """Construct from a VWS API query result item dict.""" - target_data: TargetData | None = None - if "target_data" in response_dict: - target_data_dict = _checked( - response_dict["target_data"], dict[str, JSONValue] - ) - target_timestamp = dt.datetime.fromtimestamp( - timestamp=_number(target_data_dict["target_timestamp"]), - tz=dt.UTC, - ) - target_data = TargetData( - name=_checked(target_data_dict["name"], str), - application_metadata=_optional_string( - target_data_dict["application_metadata"] - ), - target_timestamp=target_timestamp, - ) - return cls( - target_id=_checked(response_dict["target_id"], str), - target_data=target_data, - ) - - -@beartype -@dataclass(frozen=True, kw_only=True) -class TargetStatusAndRecord: - """The target status and a target record. - - See - https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record. - """ - - status: TargetStatuses - target_record: TargetRecord - - @classmethod - def from_response_dict( - cls, response_dict: Mapping[str, JSONValue] - ) -> Self: - """Construct from a VWS API response dict.""" - status = TargetStatuses(value=_checked(response_dict["status"], str)) - target_record_dict = _checked( - response_dict["target_record"], dict[str, JSONValue] - ) - target_record = TargetRecord( - target_id=_checked(target_record_dict["target_id"], str), - active_flag=bool(target_record_dict["active_flag"]), - name=_checked(target_record_dict["name"], str), - width=float(_number(target_record_dict["width"])), - tracking_rating=int( - _number(target_record_dict["tracking_rating"]) - ), - reco_rating=_checked(target_record_dict["reco_rating"], str), - ) - 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: Mapping[str, JSONValue] - ) -> Self: - """Construct from a VWS API response dict.""" - return cls( - transaction_id=_checked(response_dict["transaction_id"], str), - presigned_url=_checked(response_dict["presigned_url"], str), - ) - - @beartype @unique class ModelTargetDatasetStatuses(Enum): @@ -422,45 +167,3 @@ def from_response_dict( error=error, warning=warning, ) - - -@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 deleted file mode 100644 index b74b078ba..000000000 --- a/src/vws/vws.py +++ /dev/null @@ -1,727 +0,0 @@ -"""Tools for interacting with Vuforia APIs.""" - -import base64 -import calendar # noqa: TC003 -import json -import time -from http import HTTPMethod, HTTPStatus - -from beartype import BeartypeConf, beartype - -from vws._image_utils import ImageType as _ImageType -from vws._image_utils import get_image_data as _get_image_data -from vws._json_utils import json_object, string_field, string_list_field -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, -) -from vws.response import Response # noqa: TC001 -from vws.transports import RequestsTransport, Transport - - -@beartype(conf=BeartypeConf(is_pep484_tower=True)) -class VWS: - """An interface to Vuforia Web Services APIs.""" - - def __init__( - self, - *, - 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: - """ - Args: - 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 - (connect, read) tuple. - transport: The HTTP transport to use for - requests. Defaults to - ``RequestsTransport()``. - """ - 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() - ) - - def make_request( - self, - *, - method: str, - data: bytes, - request_path: str, - expected_result_code: str, - content_type: str, - extra_headers: dict[str, str] | None = None, - ) -> Response: - """Make a request to the Vuforia Target API. - - Args: - method: The HTTP method which will be used in - the request. - data: The request body which will be used in the - request. - request_path: The path to the endpoint which - will be used in the request. - expected_result_code: See - "VWS API Result Codes" on - https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api. - content_type: The content type of the request. - extra_headers: Additional headers to include in - the request. - - Returns: - The response to the request. - - Raises: - ~vws.exceptions.custom_exceptions.ServerError: - There is an error with Vuforia's servers. - ~vws.exceptions.vws_exceptions.TooManyRequestsError: - Vuforia is rate limiting access. - json.JSONDecodeError: The server did not respond - with valid JSON. This may happen if the - server address is not a valid Vuforia server. - """ - response = target_api_request( - content_type=content_type, - server_access_key=self._server_access_key, - server_secret_key=self._server_secret_key, - method=method, - data=data, - request_path=request_path, - base_vws_url=self._base_vws_url, - request_timeout_seconds=self._request_timeout_seconds, - extra_headers=(extra_headers if extra_headers is not None else {}), - transport=self._transport, - ) - - if response.status_code == HTTPStatus.TOO_MANY_REQUESTS: - # The Vuforia API returns a 429 response with no JSON body. - raise TooManyRequestsError(response=response) - - if response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR: - raise ServerError(response=response) - - result_code = string_field( - value=json_object(value=response.text), - name="result_code", - ) - if result_code == expected_result_code: - return response - - raise VWSError.from_result_code( - result_code=result_code, - response=response, - ) - - def add_target( - self, - *, - name: str, - width: float, - image: _ImageType, - application_metadata: str | None, - active_flag: bool, - ) -> str: - """Add a target to a Vuforia Web Services database. - - See - https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#add - for parameter details. - - Args: - name: The name of the target. - width: The width of the target. - image: The image of the target. - active_flag: Whether or not the target is active for query. - application_metadata: The application metadata of the target. - This must be base64 encoded, for example by using:: - - base64.b64encode('input_string').decode('ascii') - - Returns: - The target ID of the new target. - - Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The - secret key is not correct. - ~vws.exceptions.vws_exceptions.BadImageError: There is a problem - with the given image. For example, it must be a JPEG or PNG - file in the grayscale or RGB color space. - ~vws.exceptions.vws_exceptions.FailError: There was an error with - the request. For example, the given access key does not match a - known database. - ~vws.exceptions.vws_exceptions.MetadataTooLargeError: The given - metadata is too large. The maximum size is 1 MB of data when - Base64 encoded. - ~vws.exceptions.vws_exceptions.ImageTooLargeError: The given image - is too large. - ~vws.exceptions.vws_exceptions.TargetNameExistError: A target with - the given ``name`` already exists. - ~vws.exceptions.vws_exceptions.ProjectInactiveError: The project is - inactive. - ~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. This has been seen to happen when the - given name includes a bad character. - ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is - rate limiting access. - """ - image_data = _get_image_data(image=image) - image_data_encoded = base64.b64encode(s=image_data).decode( - encoding="ascii", - ) - - data = { - "name": name, - "width": width, - "image": image_data_encoded, - "active_flag": active_flag, - "application_metadata": application_metadata, - } - - content = json.dumps(obj=data).encode(encoding="utf-8") - - response = self.make_request( - method=HTTPMethod.POST, - data=content, - request_path="/targets", - expected_result_code="TargetCreated", - content_type="application/json", - ) - - return string_field( - value=json_object(value=response.text), - name="target_id", - ) - - def get_target_record(self, target_id: str) -> TargetStatusAndRecord: - """Get a given target's target record from the Target Management - System. - - See - https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record. - - Args: - target_id: The ID of the target to get details of. - - Returns: - Response details of a target from Vuforia. - - Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The - secret key is not correct. - ~vws.exceptions.vws_exceptions.FailError: There was an error with - the request. For example, the given access key does not match a - known database. - ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target - ID does not match a target in the database. - ~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.GET, - data=b"", - request_path=f"/targets/{target_id}", - expected_result_code="Success", - content_type="application/json", - ) - - result_data = json_object(value=response.text) - return TargetStatusAndRecord.from_response_dict( - response_dict=result_data, - ) - - def wait_for_target_processed( - self, - *, - target_id: str, - seconds_between_requests: float = 0.2, - timeout_seconds: float = 60 * 5, - ) -> None: - """Wait up to five minutes (arbitrary) for a target to get past the - processing stage. - - Args: - target_id: The ID of the target to wait for. - seconds_between_requests: The number of seconds to wait between - requests made while polling the target status. - We wait 0.2 seconds by default, rather than less, than that to - decrease the number of calls made to the API, to decrease the - likelihood of hitting the request quota. - timeout_seconds: The maximum number of seconds to wait for the - target to be processed. - - Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The - secret key is not correct. - ~vws.exceptions.vws_exceptions.FailError: There was an error with - the request. For example, the given access key does not match a - known database. - ~vws.exceptions.custom_exceptions.TargetProcessingTimeoutError: The - target remained in the processing stage for more than - ``timeout_seconds`` seconds. - ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target - ID does not match a target in the database. - ~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. - """ - start_time = time.monotonic() - while True: - report = self.get_target_summary_report(target_id=target_id) - if report.status != TargetStatuses.PROCESSING: - # Guard against the target still being seen as - # processing by other endpoints due to eventual - # consistency. - time.sleep(seconds_between_requests) - return - - elapsed_time = time.monotonic() - start_time - if elapsed_time > timeout_seconds: - raise TargetProcessingTimeoutError - - time.sleep(seconds_between_requests) - - def list_targets(self) -> list[str]: - """List target IDs. - - See - https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#details-list. - - Returns: - The IDs of all targets in the database. - - Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The - secret key is not correct. - ~vws.exceptions.vws_exceptions.FailError: There was an error with - the request. For example, the given access key does not match a - known database. - ~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.GET, - data=b"", - request_path="/targets", - expected_result_code="Success", - content_type="application/json", - ) - - return string_list_field( - value=json_object(value=response.text), - name="results", - ) - - def get_target_summary_report(self, target_id: str) -> TargetSummaryReport: - """Get a summary report for a target. - - See - https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#summary-report. - - Args: - target_id: The ID of the target to get a summary report for. - - Returns: - Details of the target. - - Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The - secret key is not correct. - ~vws.exceptions.vws_exceptions.FailError: There was an error with - the request. For example, the given access key does not match a - known database. - ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target - ID does not match a target in the database. - ~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.GET, - data=b"", - request_path=f"/summary/{target_id}", - expected_result_code="Success", - content_type="application/json", - ) - - result_data = json_object(value=response.text) - return TargetSummaryReport.from_response_dict( - response_dict=result_data, - ) - - def get_database_summary_report(self) -> DatabaseSummaryReport: - """Get a summary report for the database. - - See - https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#summary-report. - - Returns: - Details of the database. - - Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The - secret key is not correct. - ~vws.exceptions.vws_exceptions.FailError: There was an error with - the request. For example, the given access key does not match a - known database. - ~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.GET, - data=b"", - request_path="/summary", - expected_result_code="Success", - content_type="application/json", - ) - - response_data = json_object(value=response.text) - return DatabaseSummaryReport.from_response_dict( - 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 = json_object(value=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. - - See - https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#delete. - - Args: - target_id: The ID of the target to delete. - - Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The - secret key is not correct. - ~vws.exceptions.vws_exceptions.FailError: There was an error with - the request. For example, the given access key does not match a - known database. - ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target - ID does not match a target in the database. - ~vws.exceptions.vws_exceptions.TargetStatusProcessingError: The - given target is in the processing state. - ~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. - """ - _ = self.make_request( - method=HTTPMethod.DELETE, - data=b"", - request_path=f"/targets/{target_id}", - expected_result_code="Success", - content_type="application/json", - ) - - def get_duplicate_targets(self, target_id: str) -> list[str]: - """Get targets which may be considered duplicates of a given - target. - - See - https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#check. - - Args: - target_id: The ID of the target to delete. - - Returns: - The target IDs of duplicate targets. - - Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The - secret key is not correct. - ~vws.exceptions.vws_exceptions.FailError: There was an error with - the request. For example, the given access key does not match a - known database. - ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target - ID does not match a target in the database. - ~vws.exceptions.vws_exceptions.ProjectInactiveError: The project is - inactive. - ~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.GET, - data=b"", - request_path=f"/duplicates/{target_id}", - expected_result_code="Success", - content_type="application/json", - ) - - return string_list_field( - value=json_object(value=response.text), - name="similar_targets", - ) - - def update_target( - self, - *, - target_id: str, - name: str | None = None, - width: float | None = None, - image: _ImageType | None = None, - active_flag: bool | None = None, - application_metadata: str | None = None, - ) -> None: - """Update a target in a Vuforia Web Services database. - - See - https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#update - for parameter details. - - Args: - target_id: The ID of the target to update. - name: The name of the target. - width: The width of the target. - image: The image of the target. - active_flag: Whether or not the target is active for query. - application_metadata: The application metadata of the target. - This must be base64 encoded, for example by using:: - - base64.b64encode('input_string').decode('ascii') - - Giving ``None`` will not change the application metadata. - - Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The - secret key is not correct. - ~vws.exceptions.vws_exceptions.BadImageError: There is a problem - with the given image. For example, it must be a JPEG or PNG - file in the grayscale or RGB color space. - ~vws.exceptions.vws_exceptions.FailError: There was an error with - the request. For example, the given access key does not match a - known database. - ~vws.exceptions.vws_exceptions.MetadataTooLargeError: The given - metadata is too large. The maximum size is 1 MB of data when - Base64 encoded. - ~vws.exceptions.vws_exceptions.ImageTooLargeError: The given image - is too large. - ~vws.exceptions.vws_exceptions.TargetNameExistError: A target with - the given ``name`` already exists. - ~vws.exceptions.vws_exceptions.ProjectInactiveError: The project is - inactive. - ~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. - """ - data: dict[str, str | bool | float | int] = {} - - if name is not None: - data["name"] = name - - if width is not None: - data["width"] = width - - if image is not None: - image_data = _get_image_data(image=image) - image_data_encoded = base64.b64encode(s=image_data).decode( - encoding="ascii", - ) - data["image"] = image_data_encoded - - if active_flag is not None: - data["active_flag"] = active_flag - - if application_metadata is not None: - data["application_metadata"] = application_metadata - - content = json.dumps(obj=data).encode(encoding="utf-8") - - _ = self.make_request( - method=HTTPMethod.PUT, - data=content, - request_path=f"/targets/{target_id}", - expected_result_code="Success", - content_type="application/json", - ) diff --git a/tests/async_vws/test_configuration.py b/tests/async_vws/test_configuration.py deleted file mode 100644 index 591586a79..000000000 --- a/tests/async_vws/test_configuration.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Tests for `AsyncVWS` configuration.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, BinaryIO, Literal - -import pytest -from mock_vws import MockVWS -from mock_vws.database import CloudDatabase - -from tests.image_factory import IMAGE_CASES, ImageFactory -from vws import AsyncVWS - -if TYPE_CHECKING: - import io - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -async def test_custom_base_url( - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """It is possible to add a target to a database under a - custom VWS URL. - """ - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - base_vws_url = "http://example.com" - with MockVWS(base_vws_url=base_vws_url) as mock: - database = CloudDatabase() - mock.add_cloud_database(cloud_database=database) - async_vws_client = AsyncVWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - base_vws_url=base_vws_url, - ) - - await async_vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) diff --git a/tests/async_vws/test_processing.py b/tests/async_vws/test_processing.py deleted file mode 100644 index c42d1b39c..000000000 --- a/tests/async_vws/test_processing.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Tests for `AsyncVWS` processing.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, BinaryIO, Literal - -import pytest -from mock_vws import MockVWS -from mock_vws.database import CloudDatabase - -from tests.image_factory import IMAGE_CASES, ImageFactory -from vws import AsyncVWS -from vws.exceptions.custom_exceptions import ( - TargetProcessingTimeoutError, -) -from vws.reports import ( - TargetStatuses, -) - -if TYPE_CHECKING: - import io - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -async def test_wait_for_target_processed( - *, - async_vws_client: AsyncVWS, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """It is possible to wait until a target is processed.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - target_id = await async_vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - report = await async_vws_client.get_target_summary_report( - target_id=target_id, - ) - assert report.status == TargetStatuses.PROCESSING - await async_vws_client.wait_for_target_processed( - target_id=target_id, - ) - report = await async_vws_client.get_target_summary_report( - target_id=target_id, - ) - assert report.status != TargetStatuses.PROCESSING - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -async def test_custom_timeout( - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """It is possible to set a maximum timeout.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - with MockVWS(processing_time_seconds=0.5) as mock: - database = CloudDatabase() - mock.add_cloud_database(cloud_database=database) - async_vws_client = AsyncVWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - - target_id = await async_vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - with pytest.raises( - expected_exception=(TargetProcessingTimeoutError), - ): - await async_vws_client.wait_for_target_processed( - target_id=target_id, - timeout_seconds=0.1, - ) diff --git a/tests/async_vws/test_reports.py b/tests/async_vws/test_reports.py deleted file mode 100644 index 5d189966e..000000000 --- a/tests/async_vws/test_reports.py +++ /dev/null @@ -1,299 +0,0 @@ -"""Tests for `AsyncVWS` reports.""" - -from __future__ import annotations - -import calendar -import datetime as dt -import time -import uuid -from http import HTTPStatus -from typing import TYPE_CHECKING, BinaryIO, Literal - -import pytest -from mock_vws import MockVWS -from mock_vws.database import CloudDatabase - -from tests.image_factory import IMAGE_CASES, ImageFactory -from vws import AsyncVWS -from vws.exceptions.custom_exceptions import ( - DatabaseIdNotSetError, - RecoCountsReportDownloadError, - RecoCountsReportNotReadyError, - RecoCountsReportTimeoutError, -) -from vws.exceptions.vws_exceptions import ( - AuthenticationFailureError, - FailError, -) -from vws.reports import ( - DatabaseSummaryReport, -) -from vws.response import Response - -if TYPE_CHECKING: - import io - - -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"), - ) - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -async def test_get_target_summary_report( - *, - async_vws_client: AsyncVWS, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """Details of a target are returned by - ``get_target_summary_report``. - """ - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - target_name = uuid.uuid4().hex - target_id = await async_vws_client.add_target( - name=target_name, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - report = await async_vws_client.get_target_summary_report( - target_id=target_id, - ) - - assert report.target_name == target_name - assert report.active_flag is True - assert report.total_recos == 0 - - -@pytest.mark.asyncio -async def test_get_target( - async_vws_client: AsyncVWS, -) -> None: - """Details of a database are returned by - ``get_database_summary_report``. - """ - report = await async_vws_client.get_database_summary_report() - - assert isinstance(report, DatabaseSummaryReport) - assert report.active_images == 0 - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - argnames="report_period", argvalues=["current", "previous"] -) -async def test_reco_counts_report( - *, - async_vws_client: AsyncVWS, - current_month: dt.date, - report_period: Literal["current", "previous"], -) -> None: - """A report can be requested, waited for and downloaded.""" - report_month = ( - current_month - if report_period == "current" - else current_month - dt.timedelta(days=1) - ) - 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 bool(report_request.transaction_id) - assert bool(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 bool(report.reco_counts) - assert report.raw_csv.startswith(b"target_id,reco_count") - - -@pytest.mark.asyncio -async def test_not_ready(*, current_month: dt.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 - - -@pytest.mark.asyncio -async def test_wait_timeout(*, current_month: dt.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 - - -@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 - - -@pytest.mark.asyncio -async def test_database_id_does_not_match_keys( - *, - current_month: dt.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 - - -@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 - - -@pytest.mark.asyncio -async def test_no_database_id(*, current_month: dt.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), - ) diff --git a/tests/async_vws/test_targets.py b/tests/async_vws/test_targets.py deleted file mode 100644 index 53308cc35..000000000 --- a/tests/async_vws/test_targets.py +++ /dev/null @@ -1,362 +0,0 @@ -"""Tests for `AsyncVWS` targets. - -Test for deleting a target. -""" - -from __future__ import annotations - -import base64 -import uuid -from typing import TYPE_CHECKING, BinaryIO, Literal - -import pytest - -# pytest-beartype resolves fixture annotations at runtime. -from tests.image_factory import IMAGE_CASES, ImageFactory -from vws import AsyncCloudRecoService, AsyncVWS # noqa: TC001 -from vws.reports import ( - TargetRecord, - TargetStatuses, -) - -if TYPE_CHECKING: - import io - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - argnames="application_metadata", - argvalues=[None, b"a"], -) -@pytest.mark.parametrize( - argnames="active_flag", - argvalues=[True, False], -) -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -async def test_add_target( - *, - async_vws_client: AsyncVWS, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], - application_metadata: bytes | None, - async_cloud_reco_client: AsyncCloudRecoService, - active_flag: bool, -) -> None: - """No exception is raised when adding one target.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - name = "x" - width = 1 - if application_metadata is None: - encoded_metadata = None - else: - encoded_metadata_bytes = base64.b64encode( - s=application_metadata, - ) - encoded_metadata = encoded_metadata_bytes.decode( - encoding="utf-8", - ) - - target_id = await async_vws_client.add_target( - name=name, - width=width, - image=image, - application_metadata=encoded_metadata, - active_flag=active_flag, - ) - target_record = ( - await async_vws_client.get_target_record( - target_id=target_id, - ) - ).target_record - assert target_record.name == name - assert target_record.width == width - assert target_record.active_flag is active_flag - await async_vws_client.wait_for_target_processed( - target_id=target_id, - ) - matching_targets = await async_cloud_reco_client.query( - image=image, - ) - if active_flag: - [matching_target] = matching_targets - assert matching_target.target_id == target_id - assert matching_target.target_data is not None - query_metadata = matching_target.target_data.application_metadata - assert query_metadata == encoded_metadata - else: - assert matching_targets == [] - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -async def test_add_two_targets( - *, - async_vws_client: AsyncVWS, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """No exception is raised when adding two targets with - different names. - - This demonstrates that the image seek position is not - changed. - """ - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - for name in ("a", "b"): - await async_vws_client.add_target( - name=name, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -async def test_list_targets( - *, - async_vws_client: AsyncVWS, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """It is possible to get a list of target IDs.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - id_1 = await async_vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - id_2 = await async_vws_client.add_target( - name="a", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - targets = await async_vws_client.list_targets() - assert sorted(targets) == sorted([id_1, id_2]) - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -async def test_delete_target( - *, - async_vws_client: AsyncVWS, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """It is possible to delete a target.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - target_id = await async_vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - await async_vws_client.wait_for_target_processed( - target_id=target_id, - ) - targets = await async_vws_client.list_targets() - assert targets == [target_id] - await async_vws_client.delete_target( - target_id=target_id, - ) - targets = await async_vws_client.list_targets() - assert targets == [] - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -async def test_get_target_record( - *, - async_vws_client: AsyncVWS, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """Details of a target are returned by - ``get_target_record``. - """ - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - target_id = await async_vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - result = await async_vws_client.get_target_record( - target_id=target_id, - ) - expected_target_record = TargetRecord( - target_id=target_id, - active_flag=True, - name="x", - width=1, - tracking_rating=-1, - reco_rating="", - ) - - assert result.target_record == expected_target_record - assert result.status == TargetStatuses.PROCESSING - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -async def test_get_duplicate_targets( - *, - async_vws_client: AsyncVWS, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """It is possible to get the IDs of similar targets.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - target_id = await async_vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - similar_target_id = await async_vws_client.add_target( - name="a", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - await async_vws_client.wait_for_target_processed( - target_id=target_id, - ) - await async_vws_client.wait_for_target_processed( - target_id=similar_target_id, - ) - duplicates = await async_vws_client.get_duplicate_targets( - target_id=target_id, - ) - assert duplicates == [similar_target_id] - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -async def test_update_target( - *, - async_vws_client: AsyncVWS, - async_cloud_reco_client: AsyncCloudRecoService, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], - different_high_quality_image: io.BytesIO, -) -> None: - """It is possible to update a target.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - target_id = await async_vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - await async_vws_client.wait_for_target_processed( - target_id=target_id, - ) - [matching_target] = await async_cloud_reco_client.query( - image=image, - ) - assert matching_target.target_id == target_id - query_target_data = matching_target.target_data - assert query_target_data is not None - assert query_target_data.application_metadata is None - - new_name = uuid.uuid4().hex - new_width = 2.0 - new_application_metadata = base64.b64encode( - s=b"a", - ).decode(encoding="ascii") - await async_vws_client.update_target( - target_id=target_id, - name=new_name, - width=new_width, - active_flag=True, - image=different_high_quality_image, - application_metadata=new_application_metadata, - ) - - await async_vws_client.wait_for_target_processed( - target_id=target_id, - ) - target_details = await async_vws_client.get_target_record( - target_id=target_id, - ) - assert target_details.target_record.name == new_name - assert target_details.target_record.active_flag - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -async def test_no_fields_given( - *, - async_vws_client: AsyncVWS, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """It is possible to give no update fields.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - target_id = await async_vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - await async_vws_client.wait_for_target_processed( - target_id=target_id, - ) - await async_vws_client.update_target( - target_id=target_id, - ) diff --git a/tests/conftest.py b/tests/conftest.py index cb7a259b8..e55712792 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,26 +2,17 @@ from __future__ import annotations -import datetime as dt -import io from collections.abc import AsyncGenerator, Generator -from contextlib import ExitStack -from pathlib import Path import pytest import pytest_asyncio from mock_vws import MockVWS -from mock_vws.database import CloudDatabase, VuMarkDatabase +from mock_vws.database import VuMarkDatabase from mock_vws.target import VuMarkTarget -from tests.image_factory import ImageFactory from vws import ( - VWS, - AsyncCloudRecoService, AsyncModelTargetService, AsyncVuMarkService, - AsyncVWS, - CloudRecoService, ModelTargetService, VuMarkService, ) @@ -38,16 +29,6 @@ _MODEL_TARGET_CLIENT_CREDENTIALS = ("client-id", "client-secret") -@pytest.fixture(name="_mock_database") -def fixture_mock_database() -> Generator[CloudDatabase]: - """Yield a mock ``CloudDatabase``.""" - # We use a low processing time so that tests run quickly. - with MockVWS(processing_time_seconds=0.2) as mock: - database = CloudDatabase() - mock.add_cloud_database(cloud_database=database) - yield database - - @pytest.fixture(name="_mock_vumark_database") def fixture_mock_vumark_database() -> Generator[VuMarkDatabase]: """Yield a mock ``VuMarkDatabase`` with a template target.""" @@ -77,54 +58,6 @@ def vumark_target_id(*, _mock_vumark_database: VuMarkDatabase) -> str: return target.target_id -@pytest.fixture -def vws_client(*, _mock_database: CloudDatabase) -> VWS: - """A VWS client which connects to a mock database.""" - return VWS( - server_access_key=_mock_database.server_access_key, - server_secret_key=_mock_database.server_secret_key, - database_id=_mock_database.database_id, - ) - - -@pytest.fixture -def cloud_reco_client(*, _mock_database: CloudDatabase) -> CloudRecoService: - """A ``CloudRecoService`` client which connects to a mock database.""" - return CloudRecoService( - client_access_key=_mock_database.client_access_key, - client_secret_key=_mock_database.client_secret_key, - ) - - -@pytest_asyncio.fixture -async def async_vws_client( - *, - _mock_database: CloudDatabase, -) -> AsyncGenerator[AsyncVWS]: - """An async VWS client which connects to a mock database.""" - 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 - - -@pytest_asyncio.fixture -async def async_cloud_reco_client( - *, - _mock_database: CloudDatabase, -) -> AsyncGenerator[AsyncCloudRecoService]: - """An async ``CloudRecoService`` client which connects to a mock - database. - """ - async with AsyncCloudRecoService( - client_access_key=_mock_database.client_access_key, - client_secret_key=_mock_database.client_secret_key, - ) as client: - yield client - - @pytest_asyncio.fixture async def async_vumark_service_client( *, @@ -194,25 +127,3 @@ def fixture_model_target_model() -> ModelTargetModel: ), ], ) - - -@pytest.fixture(name="current_month") -def fixture_current_month() -> dt.date: - """The current month, as the first day of that month.""" - now = dt.datetime.now(tz=dt.UTC) - return now.date().replace(day=1) - - -@pytest.fixture(name="image_factory") -def fixture_image_factory( - *, - high_quality_image: io.BytesIO, - tmp_path: Path, -) -> Generator[ImageFactory]: - """Create selected image inputs with scoped file-handle cleanup.""" - with ExitStack() as stack: - yield ImageFactory( - high_quality_image=high_quality_image, - directory=tmp_path, - stack=stack, - ) diff --git a/tests/image_factory.py b/tests/image_factory.py deleted file mode 100644 index 32a391af2..000000000 --- a/tests/image_factory.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Typed image cases and resources for API tests.""" - -from __future__ import annotations - -import io -from typing import TYPE_CHECKING, BinaryIO, Literal - -from beartype import beartype - -if TYPE_CHECKING: - from contextlib import ExitStack - from pathlib import Path - -# Keep the buffer case for each file mode, matching the original nested -# image and image_file fixture cases. -IMAGE_CASES: tuple[ - tuple[Literal["high_quality_image", "image_file"], Literal["r+b", "rb"]], - ..., -] = ( - ("high_quality_image", "r+b"), - ("high_quality_image", "rb"), - ("image_file", "r+b"), - ("image_file", "rb"), -) - - -class ImageFactory: - """Create image inputs whose file handles close with the fixture.""" - - def __init__( - self, - *, - high_quality_image: io.BytesIO, - directory: Path, - stack: ExitStack, - ) -> None: - """Keep the image bytes, temporary directory and cleanup stack.""" - self._high_quality_image = high_quality_image - self._directory = directory - self._stack = stack - - @beartype - def create( - self, - *, - source: Literal["high_quality_image", "image_file"], - mode: Literal["r+b", "rb"], - ) -> io.BytesIO | BinaryIO: - """Open the file and return the selected image input.""" - file = self._directory / "image.jpg" - _ = file.write_bytes(data=self._high_quality_image.getvalue()) - image_file = self._stack.enter_context(cm=file.open(mode=mode)) - if source == "high_quality_image": - return self._high_quality_image - return image_file diff --git a/tests/model_targets/test_datasets.py b/tests/model_targets/test_datasets.py index 8cdae58e3..70d2301e4 100644 --- a/tests/model_targets/test_datasets.py +++ b/tests/model_targets/test_datasets.py @@ -32,7 +32,10 @@ ModelTargetView, RealisticAppearance, ) -from vws.reports import ModelTargetDatasetStatuses +from vws.reports import ( + ModelTargetDatasetStatuses, + ModelTargetDatasetStatusReport, +) _DATASET_TYPES = [ ModelTargetDatasetType.STANDARD, @@ -443,3 +446,11 @@ def test_invalid_oauth2_token_response_values( with pytest.raises(expected_exception=ModelTargetOAuth2Error): _ = access_token_from_response(response=response) + + +def test_invalid_status_report_field() -> None: + """Model Target status reports reject non-string status values.""" + with pytest.raises(expected_exception=TypeError): + _ = ModelTargetDatasetStatusReport.from_response_dict( + response_dict={"status": 1}, + ) diff --git a/tests/model_targets/test_errors.py b/tests/model_targets/test_errors.py index d50ad68fa..21f51992f 100644 --- a/tests/model_targets/test_errors.py +++ b/tests/model_targets/test_errors.py @@ -82,3 +82,17 @@ def test_oauth2_error_description() -> None: assert error.error == "invalid_request" assert error.error_description == description + + +@pytest.mark.parametrize( + argnames="body", + argvalues=[ + '{"error": {"details": [{"code": 1, "message": "invalid"}]}}', + '{"error": {"details": 1}}', + ], +) +def test_invalid_error_field_types(body: str) -> None: + """Malformed Model Target error fields fail validation.""" + error = ModelTargetError(response=_response(text=body)) + with pytest.raises(expected_exception=TypeError): + _ = error.details diff --git a/tests/test_async_cloud_reco_exceptions.py b/tests/test_async_cloud_reco_exceptions.py deleted file mode 100644 index 4cbc83498..000000000 --- a/tests/test_async_cloud_reco_exceptions.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Tests for exceptions raised when using the -AsyncCloudRecoService. -""" - -from __future__ import annotations - -import json -import uuid -from http import HTTPStatus -from typing import TYPE_CHECKING - -import pytest -from mock_vws import CloudQueryFailureResponse, MockVWS -from mock_vws.database import CloudDatabase -from mock_vws.states import States - -from vws import AsyncCloudRecoService -from vws.exceptions.base_exceptions import CloudRecoError -from vws.exceptions.cloud_reco_exceptions import ( - AuthenticationFailureError, - InactiveProjectError, - MaxNumResultsOutOfRangeError, -) -from vws.exceptions.custom_exceptions import ( - RequestEntityTooLargeError, -) - -if TYPE_CHECKING: - import io - - -@pytest.mark.asyncio -async def test_too_many_max_results( - *, - async_cloud_reco_client: AsyncCloudRecoService, - high_quality_image: io.BytesIO, -) -> None: - """A ``MaxNumResultsOutOfRange`` error is raised if the given - ``max_num_results`` is out of range. - """ - with pytest.raises( - expected_exception=MaxNumResultsOutOfRangeError, - ) as exc: - await async_cloud_reco_client.query( - image=high_quality_image, - max_num_results=51, - ) - - expected_value = ( - "Integer out of range (51) in form data part " - "'max_result'. " - "Accepted range is from 1 to 50 (inclusive)." - ) - assert str(object=exc.value) == exc.value.response.text == expected_value - - -@pytest.mark.asyncio -async def test_image_too_large( - *, - async_cloud_reco_client: AsyncCloudRecoService, - jpeg_too_large: io.BytesIO | io.BufferedRandom, -) -> None: - """A ``RequestEntityTooLarge`` exception is raised if an - image which is too large is given. - """ - with pytest.raises( - expected_exception=RequestEntityTooLargeError, - ) as exc: - await async_cloud_reco_client.query( - image=jpeg_too_large, - ) - - assert ( - exc.value.response.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE - ) - - -@pytest.mark.asyncio -async def test_authentication_failure( - high_quality_image: io.BytesIO, -) -> None: - """An ``AuthenticationFailure`` exception is raised when the - client secret key is incorrect. - """ - database = CloudDatabase() - async_cloud_reco_client = AsyncCloudRecoService( - client_access_key=database.client_access_key, - client_secret_key=uuid.uuid4().hex, - ) - with MockVWS() as mock: - mock.add_cloud_database(cloud_database=database) - - with pytest.raises( - expected_exception=AuthenticationFailureError, - ) as exc: - await async_cloud_reco_client.query( - image=high_quality_image, - ) - - assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED - - -@pytest.mark.asyncio -async def test_inactive_project( - high_quality_image: io.BytesIO, -) -> None: - """An ``InactiveProject`` exception is raised when querying - an inactive database. - """ - database = CloudDatabase(state=States.PROJECT_INACTIVE) - with MockVWS() as mock: - mock.add_cloud_database(cloud_database=database) - async_cloud_reco_client = AsyncCloudRecoService( - client_access_key=database.client_access_key, - client_secret_key=database.client_secret_key, - ) - - with pytest.raises( - expected_exception=InactiveProjectError, - ) as exc: - await async_cloud_reco_client.query( - image=high_quality_image, - ) - - response = exc.value.response - assert response.status_code == HTTPStatus.FORBIDDEN - assert response.tell_position != 0 - - -@pytest.mark.parametrize( - argnames=("body", "headers"), - argvalues=[ - ("", {"X-Query-Failure": "empty"}), - ( - "Arbitrary upstream failure", - { - "Content-Type": "application/json", - "X-Query-Failure": "text", - }, - ), - ], - ids=["empty", "arbitrary-text"], -) -@pytest.mark.asyncio -async def test_non_json_client_error( - *, - high_quality_image: io.BytesIO, - body: str, - headers: dict[str, str], -) -> None: - """Non-JSON 4xx responses raise a response-carrying error.""" - database = CloudDatabase() - failure_response = CloudQueryFailureResponse( - status_code=HTTPStatus.BAD_REQUEST, - headers=headers, - body=body, - ) - cloud_reco_client = AsyncCloudRecoService( - client_access_key=database.client_access_key, - client_secret_key=database.client_secret_key, - ) - - with MockVWS(cloud_query_failure_response=failure_response) as mock: - mock.add_cloud_database(cloud_database=database) - with pytest.raises(expected_exception=CloudRecoError) as exc: - await cloud_reco_client.query(image=high_quality_image) - - response = exc.value.response - assert response.status_code == HTTPStatus.BAD_REQUEST - assert response.text == body - assert response.content == body.encode() - response_headers = { - key.lower(): value for key, value in response.headers.items() - } - assert response_headers["x-query-failure"] == headers["X-Query-Failure"] - assert bool(response.request_body) - - -@pytest.mark.asyncio -async def test_non_json_success_response( - *, - high_quality_image: io.BytesIO, -) -> None: - """Malformed successful responses retain the JSON parsing error.""" - database = CloudDatabase() - failure_response = CloudQueryFailureResponse( - status_code=HTTPStatus.OK, - headers={"Content-Type": "application/json"}, - body="Not JSON", - ) - cloud_reco_client = AsyncCloudRecoService( - client_access_key=database.client_access_key, - client_secret_key=database.client_secret_key, - ) - - with MockVWS(cloud_query_failure_response=failure_response) as mock: - mock.add_cloud_database(cloud_database=database) - with pytest.raises(expected_exception=json.JSONDecodeError): - await cloud_reco_client.query(image=high_quality_image) diff --git a/tests/test_async_query.py b/tests/test_async_query.py deleted file mode 100644 index 0c067faa3..000000000 --- a/tests/test_async_query.py +++ /dev/null @@ -1,317 +0,0 @@ -"""Tests for the ``AsyncCloudRecoService`` querying functionality.""" - -from __future__ import annotations - -import json -import secrets -import uuid -from http import HTTPStatus -from typing import TYPE_CHECKING, BinaryIO, Literal - -import pytest -from mock_vws import MockVWS -from mock_vws.database import CloudDatabase - -from tests.image_factory import IMAGE_CASES, ImageFactory -from vws import AsyncCloudRecoService, AsyncVWS -from vws.exceptions.custom_exceptions import ServerError -from vws.include_target_data import CloudRecoIncludeTargetData -from vws.response import Response - -if TYPE_CHECKING: - import io - - -class _ServerErrorTransport: - """An async transport which returns a server error.""" - - 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 server-error response.""" - del method, headers, data, request_timeout - text = json.dumps(obj={}) - content = text.encode() - return Response( - text=text, - url=url, - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - headers={"Content-Type": "application/json"}, - request_body=None, - tell_position=len(content), - content=content, - ) - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -async def test_no_matches( - *, - async_cloud_reco_client: AsyncCloudRecoService, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """An empty list is returned if there are no matches.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - result = await async_cloud_reco_client.query( - image=image, - ) - assert result == [] - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -async def test_match( - *, - async_vws_client: AsyncVWS, - async_cloud_reco_client: AsyncCloudRecoService, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """Details of matching targets are returned.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - target_id = await async_vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - await async_vws_client.wait_for_target_processed( - target_id=target_id, - ) - [matching_target] = await async_cloud_reco_client.query( - image=image, - ) - assert matching_target.target_id == target_id - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -async def test_server_error( - *, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """Server errors are exposed through the public async client.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - client = AsyncCloudRecoService( - client_access_key="access-key", - client_secret_key=secrets.token_hex(), - transport=_ServerErrorTransport(), - ) - - with pytest.raises(expected_exception=ServerError): - _ = await client.query(image=image) - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -async def test_custom_base_url( - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """It is possible to query a target in a database under - a custom VWQ URL. - """ - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - base_vwq_url = "http://example.com" - with MockVWS(base_vwq_url=base_vwq_url) as mock: - database = CloudDatabase() - mock.add_cloud_database(cloud_database=database) - async_vws_client = AsyncVWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - - target_id = await async_vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - await async_vws_client.wait_for_target_processed( - target_id=target_id, - ) - - async_cloud_reco_client = AsyncCloudRecoService( - client_access_key=database.client_access_key, - client_secret_key=database.client_secret_key, - base_vwq_url=base_vwq_url, - ) - - matches = await async_cloud_reco_client.query( - image=image, - ) - assert len(matches) == 1 - match = matches[0] - assert match.target_id == target_id - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -async def test_custom( - *, - async_vws_client: AsyncVWS, - async_cloud_reco_client: AsyncCloudRecoService, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """It is possible to set a custom - ``max_num_results``. - """ - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - target_id = await async_vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - target_id_2 = await async_vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - target_id_3 = await async_vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - await async_vws_client.wait_for_target_processed( - target_id=target_id, - ) - await async_vws_client.wait_for_target_processed( - target_id=target_id_2, - ) - await async_vws_client.wait_for_target_processed( - target_id=target_id_3, - ) - max_num_results = 2 - matches = await async_cloud_reco_client.query( - image=image, - max_num_results=max_num_results, - ) - assert len(matches) == max_num_results - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -async def test_none( - *, - async_vws_client: AsyncVWS, - async_cloud_reco_client: AsyncCloudRecoService, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """When ``CloudRecoIncludeTargetData.NONE`` is given, - target data is not returned in any match. - """ - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - target_id = await async_vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - await async_vws_client.wait_for_target_processed( - target_id=target_id, - ) - [match] = await async_cloud_reco_client.query( - image=image, - include_target_data=(CloudRecoIncludeTargetData.NONE), - ) - assert match.target_data is None - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -async def test_all( - *, - async_vws_client: AsyncVWS, - async_cloud_reco_client: AsyncCloudRecoService, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """When ``CloudRecoIncludeTargetData.ALL`` is given, - target data is returned in all matches. - """ - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - target_id = await async_vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - target_id_2 = await async_vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - await async_vws_client.wait_for_target_processed( - target_id=target_id, - ) - await async_vws_client.wait_for_target_processed( - target_id=target_id_2, - ) - top_match, second_match = await async_cloud_reco_client.query( - image=image, - max_num_results=2, - include_target_data=(CloudRecoIncludeTargetData.ALL), - ) - assert top_match.target_data is not None - assert second_match.target_data is not None diff --git a/tests/test_async_vws_exceptions.py b/tests/test_async_vws_exceptions.py index cae7ad6c1..4983e37e7 100644 --- a/tests/test_async_vws_exceptions.py +++ b/tests/test_async_vws_exceptions.py @@ -2,376 +2,22 @@ from __future__ import annotations -import base64 -import io import uuid from http import HTTPStatus import pytest from mock_vws import MockVWS, VuMarkGenerationFailure -from mock_vws.database import CloudDatabase -from mock_vws.states import States -from vws import AsyncVuMarkService, AsyncVWS -from vws.exceptions.custom_exceptions import ( - ServerError, -) +from vws import AsyncVuMarkService from vws.exceptions.vws_exceptions import ( - AuthenticationFailureError, AuthorizationFailedError, - BadImageError, - FailError, - ImageTooLargeError, InvalidInstanceIdError, LicenseCheckFailedError, - MetadataTooLargeError, - ProjectHasNoAPIAccessError, - ProjectInactiveError, - ProjectSuspendedError, QuotaExceededError, - RequestQuotaReachedError, - TargetNameExistError, - TargetQuotaReachedError, - TargetStatusProcessingError, - UnknownTargetError, ) from vws.vumark_accept import VuMarkAccept -@pytest.mark.asyncio -async def test_image_too_large( - *, - async_vws_client: AsyncVWS, - png_too_large: io.BytesIO | io.BufferedRandom, -) -> None: - """When giving an image which is too large, an - ``ImageTooLarge`` exception is raised. - """ - with pytest.raises( - expected_exception=ImageTooLargeError, - ) as exc: - await async_vws_client.add_target( - name="x", - width=1, - image=png_too_large, - active_flag=True, - application_metadata=None, - ) - - assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - - -@pytest.mark.asyncio -async def test_invalid_given_id( - async_vws_client: AsyncVWS, -) -> None: - """Giving an invalid ID causes an ``UnknownTarget`` - exception to be raised. - """ - target_id = "12345abc" - with pytest.raises( - expected_exception=UnknownTargetError, - ) as exc: - await async_vws_client.delete_target( - target_id=target_id, - ) - assert exc.value.response.status_code == HTTPStatus.NOT_FOUND - assert exc.value.target_id == target_id - - -@pytest.mark.asyncio -async def test_add_bad_name( - *, - async_vws_client: AsyncVWS, - high_quality_image: io.BytesIO, -) -> None: - """When a name with a bad character is given, a - ``ServerError`` exception is raised. - """ - max_char_value = 65535 - bad_name = chr(max_char_value + 1) - with pytest.raises( - expected_exception=ServerError, - ) as exc: - await async_vws_client.add_target( - name=bad_name, - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - - assert exc.value.response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - - -@pytest.mark.asyncio -async def test_request_quota_reached() -> None: - """A ``RequestQuotaReached`` exception is raised at the quota.""" - database = CloudDatabase(request_quota=0) - with MockVWS() as mock: - mock.add_cloud_database(cloud_database=database) - async_vws_client = AsyncVWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - - with pytest.raises(expected_exception=RequestQuotaReachedError) as exc: - await async_vws_client.list_targets() - - assert exc.value.response.status_code == HTTPStatus.FORBIDDEN - - -@pytest.mark.asyncio -async def test_target_quota_reached( - high_quality_image: io.BytesIO, -) -> None: - """A ``TargetQuotaReached`` exception is raised at the quota.""" - database = CloudDatabase(target_quota=0) - with MockVWS() as mock: - mock.add_cloud_database(cloud_database=database) - async_vws_client = AsyncVWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - - with pytest.raises(expected_exception=TargetQuotaReachedError) as exc: - await async_vws_client.add_target( - name="x", - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - - assert exc.value.response.status_code == HTTPStatus.FORBIDDEN - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - argnames=("state", "expected_exception"), - argvalues=[ - (States.PROJECT_SUSPENDED, ProjectSuspendedError), - (States.PROJECT_HAS_NO_API_ACCESS, ProjectHasNoAPIAccessError), - ], -) -async def test_project_state_error( - *, - state: States, - expected_exception: type[ - ProjectSuspendedError | ProjectHasNoAPIAccessError - ], -) -> None: - """Configured project states raise their matching exceptions.""" - database = CloudDatabase(state=state) - with MockVWS() as mock: - mock.add_cloud_database(cloud_database=database) - async_vws_client = AsyncVWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - - with pytest.raises(expected_exception=expected_exception) as exc: - await async_vws_client.list_targets() - - assert exc.value.response.status_code == HTTPStatus.FORBIDDEN - - -@pytest.mark.asyncio -async def test_fail(high_quality_image: io.BytesIO) -> None: - """A ``Fail`` exception is raised when the server access key - does not exist. - """ - with MockVWS(): - async_vws_client = AsyncVWS( - server_access_key=uuid.uuid4().hex, - server_secret_key=uuid.uuid4().hex, - ) - - with pytest.raises( - expected_exception=FailError, - ) as exc: - await async_vws_client.add_target( - name="x", - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - - assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST - - -@pytest.mark.asyncio -async def test_bad_image( - async_vws_client: AsyncVWS, -) -> None: - """A ``BadImage`` exception is raised when a non-image is - given. - """ - not_an_image = io.BytesIO(initial_bytes=b"Not an image") - with pytest.raises( - expected_exception=BadImageError, - ) as exc: - await async_vws_client.add_target( - name="x", - width=1, - image=not_an_image, - active_flag=True, - application_metadata=None, - ) - - assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - - -@pytest.mark.asyncio -async def test_target_name_exist( - *, - async_vws_client: AsyncVWS, - high_quality_image: io.BytesIO, -) -> None: - """A ``TargetNameExist`` exception is raised after adding - two targets with the same name. - """ - await async_vws_client.add_target( - name="x", - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - with pytest.raises( - expected_exception=TargetNameExistError, - ) as exc: - await async_vws_client.add_target( - name="x", - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - - assert exc.value.response.status_code == HTTPStatus.FORBIDDEN - assert exc.value.target_name == "x" - - -@pytest.mark.asyncio -async def test_project_inactive( - high_quality_image: io.BytesIO, -) -> None: - """A ``ProjectInactive`` exception is raised if adding a - target to an inactive database. - """ - database = CloudDatabase(state=States.PROJECT_INACTIVE) - with MockVWS() as mock: - mock.add_cloud_database(cloud_database=database) - async_vws_client = AsyncVWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - - with pytest.raises( - expected_exception=ProjectInactiveError, - ) as exc: - await async_vws_client.add_target( - name="x", - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - - assert exc.value.response.status_code == HTTPStatus.FORBIDDEN - - -@pytest.mark.asyncio -async def test_target_status_processing( - *, - async_vws_client: AsyncVWS, - high_quality_image: io.BytesIO, -) -> None: - """A ``TargetStatusProcessing`` exception is raised if - trying to delete a target which is processing. - """ - target_id = await async_vws_client.add_target( - name="x", - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - - with pytest.raises( - expected_exception=TargetStatusProcessingError, - ) as exc: - await async_vws_client.delete_target( - target_id=target_id, - ) - - assert exc.value.response.status_code == HTTPStatus.FORBIDDEN - assert exc.value.target_id == target_id - - -@pytest.mark.asyncio -async def test_metadata_too_large( - *, - async_vws_client: AsyncVWS, - high_quality_image: io.BytesIO, - application_metadata_near_size_limit: str, -) -> None: - """A ``MetadataTooLarge`` exception is raised if the metadata - given is too large. - """ - decoded_metadata = base64.b64decode( - s=application_metadata_near_size_limit.encode(encoding="ascii"), - ) - over_limit_metadata = base64.b64encode( - s=decoded_metadata + b"x", - ).decode(encoding="ascii") - with pytest.raises( - expected_exception=MetadataTooLargeError, - ) as exc: - await async_vws_client.add_target( - name="x", - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=over_limit_metadata, - ) - - assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - - -@pytest.mark.asyncio -async def test_authentication_failure( - high_quality_image: io.BytesIO, -) -> None: - """An ``AuthenticationFailure`` exception is raised when the - server secret key is incorrect. - """ - database = CloudDatabase() - - async_vws_client = AsyncVWS( - server_access_key=database.server_access_key, - server_secret_key=uuid.uuid4().hex, - ) - - with MockVWS() as mock: - mock.add_cloud_database(cloud_database=database) - - with pytest.raises( - expected_exception=AuthenticationFailureError, - ) as exc: - await async_vws_client.add_target( - name="x", - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - - assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED - - @pytest.mark.asyncio async def test_invalid_instance_id( *, diff --git a/tests/test_cloud_reco_exceptions.py b/tests/test_cloud_reco_exceptions.py deleted file mode 100644 index cb8d4b600..000000000 --- a/tests/test_cloud_reco_exceptions.py +++ /dev/null @@ -1,204 +0,0 @@ -"""Tests for exceptions raised when using the CloudRecoService.""" - -from __future__ import annotations - -import json -import uuid -from http import HTTPStatus -from typing import TYPE_CHECKING - -import pytest -from mock_vws import CloudQueryFailureResponse, MockVWS -from mock_vws.database import CloudDatabase -from mock_vws.states import States - -from vws import CloudRecoService -from vws.exceptions.base_exceptions import CloudRecoError -from vws.exceptions.cloud_reco_exceptions import ( - AuthenticationFailureError, - BadImageError, - InactiveProjectError, - MaxNumResultsOutOfRangeError, - RequestTimeTooSkewedError, -) -from vws.exceptions.custom_exceptions import ( - RequestEntityTooLargeError, -) - -if TYPE_CHECKING: - import io - - -def test_too_many_max_results( - *, - cloud_reco_client: CloudRecoService, - high_quality_image: io.BytesIO, -) -> None: - """ - A ``MaxNumResultsOutOfRange`` error is raised if the given - ``max_num_results`` is out of range. - """ - with pytest.raises(expected_exception=MaxNumResultsOutOfRangeError) as exc: - _ = cloud_reco_client.query( - image=high_quality_image, - max_num_results=51, - ) - - expected_value = ( - "Integer out of range (51) in form data part 'max_result'. " - "Accepted range is from 1 to 50 (inclusive)." - ) - assert str(object=exc.value) == exc.value.response.text == expected_value - - -def test_image_too_large( - *, - cloud_reco_client: CloudRecoService, - jpeg_too_large: io.BytesIO | io.BufferedRandom, -) -> None: - """ - A ``RequestEntityTooLarge`` exception is raised if an image which is - too - large is given. - """ - with pytest.raises(expected_exception=RequestEntityTooLargeError) as exc: - _ = cloud_reco_client.query(image=jpeg_too_large) - - assert ( - exc.value.response.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE - ) - - -def test_cloudrecoexception_inheritance() -> None: - """CloudRecoService-specific exceptions inherit from - CloudRecoException. - """ - subclasses = [ - MaxNumResultsOutOfRangeError, - InactiveProjectError, - BadImageError, - AuthenticationFailureError, - RequestTimeTooSkewedError, - ] - for subclass in subclasses: - assert issubclass(subclass, CloudRecoError) - - -def test_authentication_failure( - high_quality_image: io.BytesIO, -) -> None: - """ - An ``AuthenticationFailure`` exception is raised when the client - access - key - exists but the client secret key is incorrect. - """ - database = CloudDatabase() - cloud_reco_client = CloudRecoService( - client_access_key=database.client_access_key, - client_secret_key=uuid.uuid4().hex, - ) - with MockVWS() as mock: - mock.add_cloud_database(cloud_database=database) - - with pytest.raises( - expected_exception=AuthenticationFailureError - ) as exc: - _ = cloud_reco_client.query(image=high_quality_image) - - assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED - - -def test_inactive_project( - high_quality_image: io.BytesIO, -) -> None: - """ - An ``InactiveProject`` exception is raised when querying an inactive - database. - """ - database = CloudDatabase(state=States.PROJECT_INACTIVE) - with MockVWS() as mock: - mock.add_cloud_database(cloud_database=database) - cloud_reco_client = CloudRecoService( - client_access_key=database.client_access_key, - client_secret_key=database.client_secret_key, - ) - - with pytest.raises(expected_exception=InactiveProjectError) as exc: - _ = cloud_reco_client.query(image=high_quality_image) - - response = exc.value.response - assert response.status_code == HTTPStatus.FORBIDDEN - # We need one test which checks tell position - # and so we choose this one almost at random. - assert response.tell_position != 0 - - -@pytest.mark.parametrize( - argnames=("body", "headers"), - argvalues=[ - ("", {"X-Query-Failure": "empty"}), - ( - "Arbitrary upstream failure", - { - "Content-Type": "application/json", - "X-Query-Failure": "text", - }, - ), - ], - ids=["empty", "arbitrary-text"], -) -def test_non_json_client_error( - *, - high_quality_image: io.BytesIO, - body: str, - headers: dict[str, str], -) -> None: - """Non-JSON 4xx responses raise a response-carrying error.""" - database = CloudDatabase() - failure_response = CloudQueryFailureResponse( - status_code=HTTPStatus.BAD_REQUEST, - headers=headers, - body=body, - ) - cloud_reco_client = CloudRecoService( - client_access_key=database.client_access_key, - client_secret_key=database.client_secret_key, - ) - - with MockVWS(cloud_query_failure_response=failure_response) as mock: - mock.add_cloud_database(cloud_database=database) - with pytest.raises(expected_exception=CloudRecoError) as exc: - _ = cloud_reco_client.query(image=high_quality_image) - - response = exc.value.response - assert response.status_code == HTTPStatus.BAD_REQUEST - assert response.text == body - assert response.content == body.encode() - response_headers = { - key.lower(): value for key, value in response.headers.items() - } - assert response_headers["x-query-failure"] == headers["X-Query-Failure"] - assert bool(response.request_body) - - -def test_non_json_success_response( - *, - high_quality_image: io.BytesIO, -) -> None: - """Malformed successful responses retain the JSON parsing error.""" - database = CloudDatabase() - failure_response = CloudQueryFailureResponse( - status_code=HTTPStatus.OK, - headers={"Content-Type": "application/json"}, - body="Not JSON", - ) - cloud_reco_client = CloudRecoService( - client_access_key=database.client_access_key, - client_secret_key=database.client_secret_key, - ) - - with MockVWS(cloud_query_failure_response=failure_response) as mock: - mock.add_cloud_database(cloud_database=database) - with pytest.raises(expected_exception=json.JSONDecodeError): - _ = cloud_reco_client.query(image=high_quality_image) diff --git a/tests/test_query.py b/tests/test_query.py deleted file mode 100644 index 9c6b93137..000000000 --- a/tests/test_query.py +++ /dev/null @@ -1,614 +0,0 @@ -"""Tests for the ``CloudRecoService`` querying functionality.""" - -from __future__ import annotations - -import datetime as dt -import json -import secrets -import uuid -from http import HTTPStatus -from typing import TYPE_CHECKING, BinaryIO, Literal - -import pytest -import requests -from freezegun import freeze_time -from mock_vws import MockVWS -from mock_vws.database import CloudDatabase - -from tests.image_factory import IMAGE_CASES, ImageFactory -from vws import VWS, CloudRecoService -from vws.exceptions.custom_exceptions import ServerError -from vws.include_target_data import CloudRecoIncludeTargetData -from vws.response import Response - -if TYPE_CHECKING: - import io - - -class _JSONResponseTransport: - """A transport which returns one JSON response body.""" - - def __init__( - self, - *, - body: object, - status_code: HTTPStatus, - ) -> None: - """Create a transport for the given JSON body.""" - self._text = json.dumps(obj=body) - self._status_code = status_code - - 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 the configured response body.""" - del method, headers, data, request_timeout - content = self._text.encode() - return Response( - text=self._text, - url=url, - status_code=self._status_code, - headers={"Content-Type": "application/json"}, - request_body=None, - tell_position=len(content), - content=content, - ) - - -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_no_matches( - *, - cloud_reco_client: CloudRecoService, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """An empty list is returned if there are no matches.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - result = cloud_reco_client.query(image=image) - assert result == [] - - -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_match( - *, - vws_client: VWS, - cloud_reco_client: CloudRecoService, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """Details of matching targets are returned.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - target_id = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - [matching_target] = cloud_reco_client.query(image=image) - assert matching_target.target_id == target_id - - -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_invalid_results( - *, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """Query results in responses must be a list of objects.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - transport = _JSONResponseTransport( - body={"result_code": "Success", "results": 1}, - status_code=HTTPStatus.OK, - ) - client = CloudRecoService( - client_access_key="access-key", - client_secret_key=secrets.token_hex(), - transport=transport, - ) - - with pytest.raises(expected_exception=TypeError): - _ = client.query(image=image) - - -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_server_error( - *, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """Server errors are exposed through the public query client.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - transport = _JSONResponseTransport( - body={}, - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - client = CloudRecoService( - client_access_key="access-key", - client_secret_key=secrets.token_hex(), - transport=transport, - ) - - with pytest.raises(expected_exception=ServerError): - _ = client.query(image=image) - - -@pytest.mark.parametrize( - argnames=("response_delay_seconds", "expect_timeout"), - argvalues=[(29, False), (31, True)], -) -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_default_timeout( - *, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], - response_delay_seconds: int, - expect_timeout: bool, -) -> None: - """At 29 seconds there is no error; at 31 seconds there is a - timeout. - """ - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - with ( - freeze_time() as frozen_datetime, - MockVWS( - response_delay_seconds=response_delay_seconds, - sleep_fn=lambda seconds: ( - frozen_datetime.tick( - delta=dt.timedelta(seconds=seconds), - ), - None, - )[1], - ) as mock, - ): - database = CloudDatabase() - mock.add_cloud_database(cloud_database=database) - cloud_reco_client = CloudRecoService( - client_access_key=database.client_access_key, - client_secret_key=database.client_secret_key, - ) - - if expect_timeout: - with pytest.raises( - expected_exception=requests.exceptions.Timeout, - ): - _ = cloud_reco_client.query(image=image) - else: - matches = cloud_reco_client.query(image=image) - assert not bool(matches) - - -@pytest.mark.parametrize( - argnames=( - "custom_timeout", - "response_delay_seconds", - "expect_timeout", - ), - argvalues=[ - (0.1, 0.09, False), - (0.1, 0.11, True), - ((5.0, 0.1), 0.09, False), - ((5.0, 0.1), 0.11, True), - ], -) -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_custom_timeout( - *, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], - custom_timeout: float | tuple[float, float], - response_delay_seconds: float, - expect_timeout: bool, -) -> None: - """Custom timeouts are honored for both float and tuple forms.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - with ( - freeze_time() as frozen_datetime, - MockVWS( - response_delay_seconds=response_delay_seconds, - sleep_fn=lambda seconds: ( - frozen_datetime.tick( - delta=dt.timedelta(seconds=seconds), - ), - None, - )[1], - ) as mock, - ): - database = CloudDatabase() - mock.add_cloud_database(cloud_database=database) - cloud_reco_client = CloudRecoService( - client_access_key=database.client_access_key, - client_secret_key=database.client_secret_key, - request_timeout_seconds=custom_timeout, - ) - - if expect_timeout: - with pytest.raises( - expected_exception=requests.exceptions.Timeout, - ): - _ = cloud_reco_client.query(image=image) - else: - matches = cloud_reco_client.query(image=image) - assert not bool(matches) - - -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_custom_base_url( - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """ - It is possible to use query a target to a database under a - custom - VWQ - URL. - """ - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - base_vwq_url = "http://example.com" - with MockVWS(base_vwq_url=base_vwq_url) 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, - ) - - target_id = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - - cloud_reco_client = CloudRecoService( - client_access_key=database.client_access_key, - client_secret_key=database.client_secret_key, - base_vwq_url=base_vwq_url, - ) - - matches = cloud_reco_client.query(image=image) - assert len(matches) == 1 - match = matches[0] - assert match.target_id == target_id - - -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_custom_base_url_with_path_prefix( - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """ - A base VWQ URL with a path prefix is used as-is, without the - prefix being dropped. - """ - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - base_vwq_url = "http://example.com/prefix" - with MockVWS(base_vwq_url=base_vwq_url) 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, - ) - target_id = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - cloud_reco_client = CloudRecoService( - client_access_key=database.client_access_key, - client_secret_key=database.client_secret_key, - base_vwq_url=base_vwq_url, - ) - - matches = cloud_reco_client.query(image=image) - assert len(matches) == 1 - - -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_max_num_results_default( - *, - vws_client: VWS, - cloud_reco_client: CloudRecoService, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """By default the maximum number of results is 1.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - target_id_2 = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - vws_client.wait_for_target_processed(target_id=target_id_2) - matches = cloud_reco_client.query(image=image) - assert len(matches) == 1 - - -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_custom( - *, - vws_client: VWS, - cloud_reco_client: CloudRecoService, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """It is possible to set a custom ``max_num_results``.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - target_id_2 = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - target_id_3 = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - vws_client.wait_for_target_processed(target_id=target_id_2) - vws_client.wait_for_target_processed(target_id=target_id_3) - max_num_results = 2 - matches = cloud_reco_client.query( - image=image, - max_num_results=max_num_results, - ) - assert len(matches) == max_num_results - - -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_include_target_data_default( - *, - vws_client: VWS, - cloud_reco_client: CloudRecoService, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """By default, target data is only returned in the top match.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - target_id_2 = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - vws_client.wait_for_target_processed(target_id=target_id_2) - top_match, second_match = cloud_reco_client.query( - image=image, - max_num_results=2, - ) - assert top_match.target_data is not None - assert second_match.target_data is None - - -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_top( - *, - vws_client: VWS, - cloud_reco_client: CloudRecoService, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """ - When ``CloudRecoIncludeTargetData.TOP`` is given, target data is - only - returned in the top match. - """ - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - target_id_2 = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - vws_client.wait_for_target_processed(target_id=target_id_2) - top_match, second_match = cloud_reco_client.query( - image=image, - max_num_results=2, - include_target_data=CloudRecoIncludeTargetData.TOP, - ) - assert top_match.target_data is not None - assert second_match.target_data is None - - -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_none( - *, - vws_client: VWS, - cloud_reco_client: CloudRecoService, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """ - When ``CloudRecoIncludeTargetData.NONE`` is given, target data - is - not - returned in any match. - """ - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - target_id_2 = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - vws_client.wait_for_target_processed(target_id=target_id_2) - top_match, second_match = cloud_reco_client.query( - image=image, - max_num_results=2, - include_target_data=CloudRecoIncludeTargetData.NONE, - ) - assert top_match.target_data is None - assert second_match.target_data is None - - -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_all( - *, - vws_client: VWS, - cloud_reco_client: CloudRecoService, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """ - When ``CloudRecoIncludeTargetData.ALL`` is given, target data is - returned in all matches. - """ - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - target_id_2 = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - vws_client.wait_for_target_processed(target_id=target_id_2) - top_match, second_match = cloud_reco_client.query( - image=image, - max_num_results=2, - include_target_data=CloudRecoIncludeTargetData.ALL, - ) - assert top_match.target_data is not None - assert second_match.target_data is not None diff --git a/tests/test_reports.py b/tests/test_reports.py deleted file mode 100644 index 972ac002b..000000000 --- a/tests/test_reports.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Tests for public report constructors.""" - -import pytest - -from vws.reports import QueryResult - -type JSONValue = ( - bool | int | float | str | list[JSONValue] | dict[str, JSONValue] | None -) - - -@pytest.mark.parametrize( - argnames="response", - argvalues=[ - {"target_id": 1}, - { - "target_id": "target-id", - "target_data": { - "name": "target-name", - "application_metadata": None, - "target_timestamp": True, - }, - }, - { - "target_id": "target-id", - "target_data": { - "name": "target-name", - "application_metadata": 1, - "target_timestamp": 0, - }, - }, - ], -) -def test_query_result_rejects_invalid_response_values( - *, - response: dict[str, JSONValue], -) -> None: - """Query reports reject values of the wrong type.""" - with pytest.raises(expected_exception=TypeError): - _ = QueryResult.from_response_dict(response_dict=response) diff --git a/tests/test_vws_exceptions.py b/tests/test_vws_exceptions.py index 6479a2703..cb3597749 100644 --- a/tests/test_vws_exceptions.py +++ b/tests/test_vws_exceptions.py @@ -1,469 +1,53 @@ """Tests for VWS exceptions.""" -import base64 -import io import uuid from http import HTTPStatus import pytest -from freezegun import freeze_time from mock_vws import MockVWS, VuMarkGenerationFailure -from mock_vws.database import CloudDatabase -from mock_vws.states import States -from vws import VWS, VuMarkService +from vws import VuMarkService from vws.exceptions.base_exceptions import VWSError -from vws.exceptions.custom_exceptions import ( - ServerError, -) from vws.exceptions.vws_exceptions import ( AuthenticationFailureError, AuthorizationFailedError, - BadImageError, BadRequestError, - DateRangeError, FailError, - ImageTooLargeError, InvalidAcceptHeaderError, InvalidInstanceIdError, InvalidTargetTypeError, LicenseCheckFailedError, - MetadataTooLargeError, ProjectHasNoAPIAccessError, ProjectInactiveError, ProjectSuspendedError, QuotaExceededError, RequestQuotaReachedError, RequestTimeTooSkewedError, - TargetNameExistError, - TargetQuotaReachedError, TargetStatusNotSuccessError, - TargetStatusProcessingError, UnknownTargetError, ) from vws.response import Response from vws.vumark_accept import VuMarkAccept -def test_image_too_large( - *, - vws_client: VWS, - png_too_large: io.BytesIO | io.BufferedRandom, -) -> None: - """ - When giving an image which is too large, an ``ImageTooLarge`` - exception - is - raised. - """ - with pytest.raises(expected_exception=ImageTooLargeError) as exc: - _ = vws_client.add_target( - name="x", - width=1, - image=png_too_large, - active_flag=True, - application_metadata=None, - ) - - assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - - -def test_invalid_given_id(vws_client: VWS) -> None: - """ - Giving an invalid ID to a helper which requires a target ID to be - given - causes an ``UnknownTarget`` exception to be raised. - """ - target_id = "12345abc" - with pytest.raises(expected_exception=UnknownTargetError) as exc: - vws_client.delete_target(target_id=target_id) - assert exc.value.response.status_code == HTTPStatus.NOT_FOUND - assert exc.value.target_id == target_id - - -def test_add_bad_name( - *, - vws_client: VWS, - high_quality_image: io.BytesIO, -) -> None: - """ - When a name with a bad character is given, a ``ServerError`` - exception - is - raised. - """ - max_char_value = 65535 - bad_name = chr(max_char_value + 1) - with pytest.raises( - expected_exception=ServerError, - ) as exc: - _ = vws_client.add_target( - name=bad_name, - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - - assert exc.value.response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - - -def test_request_quota_reached() -> None: - """A ``RequestQuotaReached`` exception is raised at the quota.""" - database = CloudDatabase(request_quota=0) - with MockVWS() as mock: - mock.add_cloud_database(cloud_database=database) - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - - with pytest.raises(expected_exception=RequestQuotaReachedError) as exc: - _ = vws_client.list_targets() - - assert exc.value.response.status_code == HTTPStatus.FORBIDDEN - - -def test_target_quota_reached(high_quality_image: io.BytesIO) -> None: - """A ``TargetQuotaReached`` exception is raised at the quota.""" - database = CloudDatabase(target_quota=0) - with MockVWS() as mock: - mock.add_cloud_database(cloud_database=database) - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - - with pytest.raises(expected_exception=TargetQuotaReachedError) as exc: - _ = vws_client.add_target( - name="x", - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - - assert exc.value.response.status_code == HTTPStatus.FORBIDDEN - - -@pytest.mark.parametrize( - argnames=("state", "expected_exception"), - argvalues=[ - (States.PROJECT_SUSPENDED, ProjectSuspendedError), - (States.PROJECT_HAS_NO_API_ACCESS, ProjectHasNoAPIAccessError), - ], -) -def test_project_state_error( - *, - state: States, - expected_exception: type[VWSError], -) -> None: - """Configured project states raise their matching exceptions.""" - database = CloudDatabase(state=state) - with MockVWS() as mock: - mock.add_cloud_database(cloud_database=database) - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - - with pytest.raises(expected_exception=expected_exception) as exc: - _ = vws_client.list_targets() - - assert exc.value.response.status_code == HTTPStatus.FORBIDDEN - - -def test_fail(high_quality_image: io.BytesIO) -> None: - """A ``Fail`` exception is raised when the server access key does not - exist. - """ - with MockVWS(): - vws_client = VWS( - server_access_key=uuid.uuid4().hex, - server_secret_key=uuid.uuid4().hex, - ) - - with pytest.raises(expected_exception=FailError) as exc: - _ = vws_client.add_target( - name="x", - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - - assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST - - -def test_bad_image(vws_client: VWS) -> None: - """A ``BadImage`` exception is raised when a non-image is given.""" - not_an_image = io.BytesIO(initial_bytes=b"Not an image") - with pytest.raises(expected_exception=BadImageError) as exc: - _ = vws_client.add_target( - name="x", - width=1, - image=not_an_image, - active_flag=True, - application_metadata=None, - ) - - assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - - -def test_target_name_exist( - *, - vws_client: VWS, - high_quality_image: io.BytesIO, -) -> None: - """ - A ``TargetNameExist`` exception is raised after adding two targets - with - the - same name. - """ - _ = vws_client.add_target( - name="x", - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - with pytest.raises(expected_exception=TargetNameExistError) as exc: - _ = vws_client.add_target( - name="x", - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - - assert exc.value.response.status_code == HTTPStatus.FORBIDDEN - assert exc.value.target_name == "x" - - -def test_target_name_exist_requires_request_body() -> None: - """A target-name error without a request body is rejected.""" - response = Response( - text="", - url="https://vws.vuforia.com/targets", - status_code=HTTPStatus.FORBIDDEN, - headers={}, - request_body=None, - tell_position=0, - content=b"", - ) - error = TargetNameExistError(response=response) - - with pytest.raises( - expected_exception=TypeError, - match="must have a request body", - ): - _ = error.target_name - - -def test_project_inactive( - high_quality_image: io.BytesIO, -) -> None: - """ - A ``ProjectInactive`` exception is raised if adding a target to an - inactive - database. - """ - database = CloudDatabase(state=States.PROJECT_INACTIVE) - with MockVWS() as mock: - mock.add_cloud_database(cloud_database=database) - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - - with pytest.raises(expected_exception=ProjectInactiveError) as exc: - _ = vws_client.add_target( - name="x", - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - - assert exc.value.response.status_code == HTTPStatus.FORBIDDEN - - -def test_target_status_processing( - *, - vws_client: VWS, - high_quality_image: io.BytesIO, -) -> None: - """ - A ``TargetStatusProcessing`` exception is raised if trying to delete - a - target which is processing. - """ - target_id = vws_client.add_target( - name="x", - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - - with pytest.raises(expected_exception=TargetStatusProcessingError) as exc: - vws_client.delete_target(target_id=target_id) - - assert exc.value.response.status_code == HTTPStatus.FORBIDDEN - assert exc.value.target_id == target_id - - -def test_metadata_too_large( - *, - vws_client: VWS, - high_quality_image: io.BytesIO, - application_metadata_near_size_limit: str, -) -> None: - """ - A ``MetadataTooLarge`` exception is raised if the metadata given is - too - large. - """ - decoded_metadata = base64.b64decode( - s=application_metadata_near_size_limit.encode(encoding="ascii"), - ) - over_limit_metadata = base64.b64encode( - s=decoded_metadata + b"x", - ).decode(encoding="ascii") - with pytest.raises(expected_exception=MetadataTooLargeError) as exc: - _ = vws_client.add_target( - name="x", - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=over_limit_metadata, - ) - - assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - - -def test_request_time_too_skewed( - *, - vws_client: VWS, - high_quality_image: io.BytesIO, -) -> None: - """ - A ``RequestTimeTooSkewed`` exception is raised when the request time - is - more than five minutes different from the server time. - """ - target_id = vws_client.add_target( - name="x", - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - - vws_max_time_skew = 60 * 5 - leeway = 10 - time_difference_from_now = vws_max_time_skew + leeway - - # We use a custom tick because we expect the following: - # - # * At least one time check when creating the request - # * At least one time check when processing the request - # - # >= 1 ticks are acceptable. - with ( - freeze_time(auto_tick_seconds=time_difference_from_now), - pytest.raises(expected_exception=RequestTimeTooSkewedError) as exc, - ): - _ = vws_client.get_target_record(target_id=target_id) - - assert exc.value.response.status_code == HTTPStatus.FORBIDDEN - - -def test_authentication_failure( - high_quality_image: io.BytesIO, -) -> None: - """ - An ``AuthenticationFailure`` exception is raised when the server - access - key - exists but the server secret key is incorrect, or when a client key is - incorrect. - """ - database = CloudDatabase() - - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=uuid.uuid4().hex, - ) - - with MockVWS() as mock: - mock.add_cloud_database(cloud_database=database) - - with pytest.raises( - expected_exception=AuthenticationFailureError - ) as exc: - _ = vws_client.add_target( - name="x", - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - - assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED - - -def test_target_status_not_success( - *, - vws_client: VWS, - high_quality_image: io.BytesIO, -) -> None: - """ - A ``TargetStatusNotSuccess`` exception is raised when updating a - target - which has a status which is not "Success". - """ - target_id = vws_client.add_target( - name="x", - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - - with pytest.raises(expected_exception=TargetStatusNotSuccessError) as exc: - vws_client.update_target(target_id=target_id) - - assert exc.value.response.status_code == HTTPStatus.FORBIDDEN - assert exc.value.target_id == target_id - - def test_vwsexception_inheritance() -> None: """VWS-related exceptions should inherit from VWSException.""" subclasses = [ AuthenticationFailureError, AuthorizationFailedError, - BadImageError, BadRequestError, - DateRangeError, FailError, - ImageTooLargeError, InvalidAcceptHeaderError, InvalidInstanceIdError, InvalidTargetTypeError, LicenseCheckFailedError, - MetadataTooLargeError, ProjectInactiveError, ProjectHasNoAPIAccessError, ProjectSuspendedError, QuotaExceededError, RequestQuotaReachedError, RequestTimeTooSkewedError, - TargetNameExistError, - TargetQuotaReachedError, TargetStatusNotSuccessError, - TargetStatusProcessingError, UnknownTargetError, ] for subclass in subclasses: @@ -489,43 +73,6 @@ def test_invalid_instance_id( assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY -def test_invalid_target_type( - high_quality_image: io.BytesIO, -) -> None: - """ - An ``InvalidTargetType`` exception is raised when trying to generate - a VuMark instance from a non-VuMark database. - """ - database = CloudDatabase() - with MockVWS(processing_time_seconds=0.2) as mock: - mock.add_cloud_database(cloud_database=database) - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - target_id = vws_client.add_target( - name="example_target", - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - vumark_service = VuMarkService( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - with pytest.raises( - expected_exception=InvalidTargetTypeError, - ) as exc: - _ = vumark_service.generate_vumark_instance( - target_id=target_id, - instance_id="example_instance_id", - accept=VuMarkAccept.PNG, - ) - - assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - - @pytest.mark.parametrize( argnames=("failure", "exception_type", "status_code"), argvalues=[ @@ -570,26 +117,6 @@ def test_documented_vumark_error_codes( assert failure.value in exc.value.response.text -def test_base_exception( - *, - vws_client: VWS, - high_quality_image: io.BytesIO, -) -> None: - """``VWSException``s has a response property.""" - with pytest.raises(expected_exception=VWSError) as exc: - _ = vws_client.get_target_record(target_id="a") - - assert exc.value.response.status_code == HTTPStatus.NOT_FOUND - - _ = vws_client.add_target( - name="x", - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - - def test_vwserror_from_result_code() -> None: """``VWSError.from_result_code`` returns the mapped exception.""" response = Response( @@ -641,14 +168,6 @@ def test_project_has_no_api_access_casing() -> None: (UnknownTargetError, "https://example.com/prefix/targets/abc"), (UnknownTargetError, "https://example.com/prefix/summary/abc"), (UnknownTargetError, "https://example.com/prefix/duplicates/abc"), - ( - TargetStatusProcessingError, - "https://vws.vuforia.com/targets/abc", - ), - ( - TargetStatusProcessingError, - "https://example.com/prefix/targets/abc", - ), ( TargetStatusNotSuccessError, "https://vws.vuforia.com/targets/abc", @@ -665,11 +184,7 @@ def test_project_has_no_api_access_casing() -> None: ) def test_target_id_with_base_url_prefixes( *, - exception_type: type[ - UnknownTargetError - | TargetStatusProcessingError - | TargetStatusNotSuccessError - ], + exception_type: type[UnknownTargetError | TargetStatusNotSuccessError], url: str, ) -> None: """``target_id`` is correct even when ``base_vws_url`` has a path diff --git a/tests/transports/test_httpx.py b/tests/transports/test_httpx.py index 8088daca0..6797a0eae 100644 --- a/tests/transports/test_httpx.py +++ b/tests/transports/test_httpx.py @@ -4,18 +4,13 @@ import uuid from http import HTTPStatus -from typing import TYPE_CHECKING import httpx import pytest import respx from vws import ( - VWS, - AsyncCloudRecoService, AsyncVuMarkService, - AsyncVWS, - CloudRecoService, VuMarkService, ) from vws.exceptions.custom_exceptions import ServerError @@ -27,9 +22,6 @@ ) from vws.vumark_accept import VuMarkAccept -if TYPE_CHECKING: - import io - class _FalsyTransport: """A sync transport that is falsy but protocol-conforming.""" @@ -54,37 +46,14 @@ def __call__( ) -> Response: """Return a successful API response for the requested URL.""" del method, headers, request_timeout - if url.endswith("/query"): - body = '{"result_code":"Success","results":[]}' - return Response( - text=body, - url=url, - status_code=HTTPStatus.OK, - headers={}, - request_body=data, - tell_position=0, - content=body.encode(), - ) - if "/instances" in url: - content = b"vumark-bytes" - return Response( - text="", - url=url, - status_code=HTTPStatus.OK, - headers={}, - request_body=data, - tell_position=0, - content=content, - ) - body = '{"result_code":"Success","results":[]}' return Response( - text=body, + text="", url=url, status_code=HTTPStatus.OK, headers={}, request_body=data, tell_position=0, - content=body.encode(), + content=b"vumark-bytes", ) @@ -111,37 +80,14 @@ async def __call__( ) -> Response: """Return a successful API response for the requested URL.""" del method, headers, request_timeout - if url.endswith("/query"): - body = '{"result_code":"Success","results":[]}' - return Response( - text=body, - url=url, - status_code=HTTPStatus.OK, - headers={}, - request_body=data, - tell_position=0, - content=body.encode(), - ) - if "/instances" in url: - content = b"vumark-bytes" - return Response( - text="", - url=url, - status_code=HTTPStatus.OK, - headers={}, - request_body=data, - tell_position=0, - content=content, - ) - body = '{"result_code":"Success","results":[]}' return Response( - text=body, + text="", url=url, status_code=HTTPStatus.OK, headers={}, request_body=data, tell_position=0, - content=body.encode(), + content=b"vumark-bytes", ) @@ -335,29 +281,13 @@ async def test_async_httpx_transport_context_manager() -> None: assert response.status_code == HTTPStatus.OK -def test_falsy_sync_transport_is_retained( - high_quality_image: io.BytesIO, -) -> None: +def test_falsy_sync_transport_is_retained() -> None: """Falsy custom sync transports are not replaced by the default.""" access_key = uuid.uuid4().hex secret_key = uuid.uuid4().hex transport = _FalsyTransport() assert not bool(transport) - targets = VWS( - server_access_key=access_key, - server_secret_key=secret_key, - transport=transport, - ).list_targets() - assert not bool(targets) - - query_results = CloudRecoService( - client_access_key=access_key, - client_secret_key=secret_key, - transport=transport, - ).query(image=high_quality_image) - assert not bool(query_results) - vumark_bytes = VuMarkService( server_access_key=access_key, server_secret_key=secret_key, @@ -371,31 +301,13 @@ def test_falsy_sync_transport_is_retained( @pytest.mark.asyncio -async def test_falsy_async_transport_is_retained( - high_quality_image: io.BytesIO, -) -> None: +async def test_falsy_async_transport_is_retained() -> None: """Falsy custom async transports are not replaced by the default.""" access_key = uuid.uuid4().hex secret_key = uuid.uuid4().hex transport = _FalsyAsyncTransport() assert not bool(transport) - async with AsyncVWS( - server_access_key=access_key, - server_secret_key=secret_key, - transport=transport, - ) as vws_client: - assert not bool(await vws_client.list_targets()) - - async with AsyncCloudRecoService( - client_access_key=access_key, - client_secret_key=secret_key, - transport=transport, - ) as cloud_reco_client: - assert not bool( - await cloud_reco_client.query(image=high_quality_image) - ) - async with AsyncVuMarkService( server_access_key=access_key, server_secret_key=secret_key, @@ -446,26 +358,6 @@ def test_vumark_service_handles_bodyless_http_errors( assert route.called -@respx.mock -def test_vws_handles_bodyless_rate_limit_response() -> None: - """A body-less target-manager rate limit raises its specific error.""" - route = respx.get(url="https://example.com/targets").mock( - return_value=httpx.Response(status_code=HTTPStatus.TOO_MANY_REQUESTS) - ) - - with HTTPXTransport() as transport: - service = VWS( - server_access_key="access-key", - server_secret_key=uuid.uuid4().hex, - base_vws_url="https://example.com", - transport=transport, - ) - with pytest.raises(expected_exception=TooManyRequestsError): - _ = service.list_targets() - - assert route.called - - @pytest.mark.asyncio @pytest.mark.parametrize( argnames=("status_code", "exception_type"), @@ -500,24 +392,3 @@ async def test_async_vumark_service_handles_bodyless_http_errors( ) assert route.called - - -@pytest.mark.asyncio -@respx.mock -async def test_async_vws_handles_bodyless_rate_limit_response() -> None: - """An async target-manager rate limit raises its specific error.""" - route = respx.get(url="https://example.com/targets").mock( - return_value=httpx.Response(status_code=HTTPStatus.TOO_MANY_REQUESTS) - ) - - async with AsyncHTTPXTransport() as transport: - service = AsyncVWS( - server_access_key="access-key", - server_secret_key=uuid.uuid4().hex, - base_vws_url="https://example.com", - transport=transport, - ) - with pytest.raises(expected_exception=TooManyRequestsError): - _ = await service.list_targets() - - assert route.called diff --git a/tests/transports/test_httpx2.py b/tests/transports/test_httpx2.py index 57ebde9ac..a2c8e4b0c 100644 --- a/tests/transports/test_httpx2.py +++ b/tests/transports/test_httpx2.py @@ -3,22 +3,17 @@ from __future__ import annotations from http import HTTPStatus -from typing import TYPE_CHECKING import httpx import httpx2 import pytest from mock_vws import MockVWS -from mock_vws.database import CloudDatabase, VuMarkDatabase +from mock_vws.database import VuMarkDatabase from mock_vws.target import VuMarkTarget from vws import ( - VWS, - AsyncCloudRecoService, AsyncModelTargetService, AsyncVuMarkService, - AsyncVWS, - CloudRecoService, ModelTargetService, VuMarkService, ) @@ -26,7 +21,7 @@ ModelTargetDatasetType, ModelTargetModel, ) -from vws.reports import ModelTargetDatasetStatuses, TargetStatuses +from vws.reports import ModelTargetDatasetStatuses from vws.response import Response from vws.transports import ( AsyncHTTPX2Transport, @@ -34,10 +29,6 @@ ) from vws.vumark_accept import VuMarkAccept -if TYPE_CHECKING: - import io - - # The mock accepts one hard-coded pair of Model Target Web API OAuth2 # credentials, which it does not expose. _MODEL_TARGET_CLIENT_ID = "client-id" @@ -105,45 +96,6 @@ def __init__(self) -> None: return requests_seen -async def _add_and_query_target( - *, - database: CloudDatabase, - transport: AsyncHTTPX2Transport, - image: io.BytesIO, -) -> None: - """Add a target with ``AsyncVWS`` and find it with - ``AsyncCloudRecoService``. - - Args: - database: The mock database to add the target to. - transport: The transport for the clients to use. - image: The image to add as a target and then query with. - """ - vws_client = AsyncVWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - transport=transport, - ) - cloud_reco_client = AsyncCloudRecoService( - client_access_key=database.client_access_key, - client_secret_key=database.client_secret_key, - transport=transport, - ) - target_id = await vws_client.add_target( - name="example", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - await vws_client.wait_for_target_processed(target_id=target_id) - target_record = await vws_client.get_target_record(target_id=target_id) - assert target_record.status == TargetStatuses.SUCCESS - - (match,) = await cloud_reco_client.query(image=image) - assert match.target_id == target_id - - async def _generate_dataset( *, transport: AsyncHTTPX2Transport, @@ -521,43 +473,6 @@ async def test_async_httpx2_exceptions( assert not isinstance(exc.value, httpx.HTTPError) -def test_sync_mock_vws_and_cloud_reco( - high_quality_image: io.BytesIO, -) -> None: - """A target can be added with ``VWS`` and found with - ``CloudRecoService``. - """ - database = CloudDatabase() - with ( - MockVWS(processing_time_seconds=0.2) as mock, - HTTPX2Transport() as transport, - ): - mock.add_cloud_database(cloud_database=database) - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - transport=transport, - ) - cloud_reco_client = CloudRecoService( - client_access_key=database.client_access_key, - client_secret_key=database.client_secret_key, - transport=transport, - ) - target_id = vws_client.add_target( - name="example", - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - target_record = vws_client.get_target_record(target_id=target_id) - assert target_record.status == TargetStatuses.SUCCESS - - (match,) = cloud_reco_client.query(image=high_quality_image) - assert match.target_id == target_id - - def test_sync_mock_vumark() -> None: """A VuMark instance can be generated with ``VuMarkService``.""" vumark_target = VuMarkTarget(name="vumark-template") @@ -620,44 +535,30 @@ def test_sync_mock_timeout( """A response which takes longer than the read timeout raises an ``httpx2`` timeout. """ - database = CloudDatabase() + database = VuMarkDatabase() sleeps: list[float] = [] with MockVWS( response_delay_seconds=0.11, sleep_fn=sleeps.append, ) as mock: - mock.add_cloud_database(cloud_database=database) + mock.add_vumark_database(vumark_database=database) with HTTPX2Transport() as transport: - vws_client = VWS( + vws_client = VuMarkService( server_access_key=database.server_access_key, server_secret_key=database.server_secret_key, request_timeout_seconds=custom_timeout, transport=transport, ) with pytest.raises(expected_exception=httpx2.ReadTimeout): - _ = vws_client.list_targets() + _ = vws_client.generate_vumark_instance( + target_id="target", + instance_id="instance", + accept=VuMarkAccept.PNG, + ) # The mock sleeps for the read timeout before raising. assert sleeps == [0.1] -@pytest.mark.asyncio -async def test_async_mock_vws_and_cloud_reco( - high_quality_image: io.BytesIO, -) -> None: - """A target can be added with ``AsyncVWS`` and found with - ``AsyncCloudRecoService``. - """ - database = CloudDatabase() - with MockVWS(processing_time_seconds=0.2) as mock: - mock.add_cloud_database(cloud_database=database) - async with AsyncHTTPX2Transport() as transport: - await _add_and_query_target( - database=database, - transport=transport, - image=high_quality_image, - ) - - @pytest.mark.asyncio async def test_async_mock_vumark() -> None: """A VuMark instance can be generated with @@ -708,21 +609,25 @@ async def test_async_mock_timeout( """A response which takes longer than the read timeout raises an ``httpx2`` timeout. """ - database = CloudDatabase() + database = VuMarkDatabase() sleeps: list[float] = [] with MockVWS( response_delay_seconds=0.11, sleep_fn=sleeps.append, ) as mock: - mock.add_cloud_database(cloud_database=database) + mock.add_vumark_database(vumark_database=database) async with AsyncHTTPX2Transport() as transport: - vws_client = AsyncVWS( + vws_client = AsyncVuMarkService( server_access_key=database.server_access_key, server_secret_key=database.server_secret_key, request_timeout_seconds=custom_timeout, transport=transport, ) with pytest.raises(expected_exception=httpx2.ReadTimeout): - await vws_client.list_targets() + await vws_client.generate_vumark_instance( + target_id="target", + instance_id="instance", + accept=VuMarkAccept.PNG, + ) # The mock sleeps for the read timeout before raising. assert sleeps == [0.1] diff --git a/tests/vws/test_configuration.py b/tests/vws/test_configuration.py deleted file mode 100644 index dcf282975..000000000 --- a/tests/vws/test_configuration.py +++ /dev/null @@ -1,202 +0,0 @@ -"""Tests for `VWS` configuration.""" - -from __future__ import annotations - -import datetime as dt -from typing import TYPE_CHECKING, BinaryIO, Literal - -import pytest -import requests -from freezegun import freeze_time -from mock_vws import MockVWS -from mock_vws.database import CloudDatabase - -from tests.image_factory import IMAGE_CASES, ImageFactory -from vws import VWS - -if TYPE_CHECKING: - import io - - -@pytest.mark.parametrize( - argnames=("response_delay_seconds", "expect_timeout"), - argvalues=[(29, False), (31, True)], -) -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_default_timeout( - *, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], - response_delay_seconds: int, - expect_timeout: bool, -) -> None: - """At 29 seconds there is no error; at 31 seconds there is a - timeout. - """ - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - with ( - freeze_time() as frozen_datetime, - MockVWS( - response_delay_seconds=response_delay_seconds, - sleep_fn=lambda seconds: ( - frozen_datetime.tick( - delta=dt.timedelta(seconds=seconds), - ), - None, - )[1], - ) 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, - ) - - if expect_timeout: - with pytest.raises( - expected_exception=requests.exceptions.Timeout, - ): - _ = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - else: - _ = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - -@pytest.mark.parametrize( - argnames=( - "custom_timeout", - "response_delay_seconds", - "expect_timeout", - ), - argvalues=[ - (0.1, 0.09, False), - (0.1, 0.11, True), - ((5.0, 0.1), 0.09, False), - ((5.0, 0.1), 0.11, True), - ], -) -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_custom_timeout( - *, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], - custom_timeout: float | tuple[float, float], - response_delay_seconds: float, - expect_timeout: bool, -) -> None: - """Custom timeouts are honored for both float and tuple forms.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - with ( - freeze_time() as frozen_datetime, - MockVWS( - response_delay_seconds=response_delay_seconds, - sleep_fn=lambda seconds: ( - frozen_datetime.tick( - delta=dt.timedelta(seconds=seconds), - ), - None, - )[1], - ) 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, - request_timeout_seconds=custom_timeout, - ) - - if expect_timeout: - with pytest.raises( - expected_exception=requests.exceptions.Timeout, - ): - _ = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - else: - _ = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_custom_base_url( - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """ - It is possible to use add a target to a database under a custom - VWS - URL. - """ - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - base_vws_url = "http://example.com" - with MockVWS(base_vws_url=base_vws_url) 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, - base_vws_url=base_vws_url, - ) - - _ = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - -def test_custom_base_url_with_path_prefix() -> None: - """ - A base VWS URL with a path prefix is used as-is, without the - prefix being dropped. - """ - base_vws_url = "http://example.com/prefix" - with MockVWS(base_vws_url=base_vws_url) 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, - base_vws_url=base_vws_url, - ) - - assert not bool(vws_client.list_targets()) diff --git a/tests/vws/test_processing.py b/tests/vws/test_processing.py deleted file mode 100644 index 69af3cc58..000000000 --- a/tests/vws/test_processing.py +++ /dev/null @@ -1,211 +0,0 @@ -"""Tests for `VWS` processing.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, BinaryIO, Literal - -import pytest -from mock_vws import MockVWS -from mock_vws.database import CloudDatabase - -from tests.image_factory import IMAGE_CASES, ImageFactory -from vws import VWS -from vws.exceptions.custom_exceptions import ( - TargetProcessingTimeoutError, -) -from vws.reports import ( - TargetStatuses, -) - -if TYPE_CHECKING: - import io - - -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_wait_for_target_processed( - *, - vws_client: VWS, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """It is possible to wait until a target is processed.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - target_id = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - report = vws_client.get_target_summary_report(target_id=target_id) - assert report.status == TargetStatuses.PROCESSING - vws_client.wait_for_target_processed(target_id=target_id) - report = vws_client.get_target_summary_report(target_id=target_id) - assert report.status != TargetStatuses.PROCESSING - - -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_default_seconds_between_requests( - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """By default, 0.2 seconds are waited between polling requests.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - with MockVWS(processing_time_seconds=0.5) 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, - ) - - target_id = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - report = vws_client.get_database_summary_report() - expected_requests = ( - # Add target request - 1 - + - # Database summary request - 1 - + - # Initial request - 1 - + - # Request after 0.2 seconds - not processed - 1 - + - # Request after 0.4 seconds - not processed - # This assumes that there is less than 0.1 seconds taken - # between the start of the target processing and the start of - # waiting for the target to be processed. - 1 - + - # Request after 0.6 seconds - processed - 1 - ) - # At the time of writing there is a bug which prevents request - # usage from being tracked so we cannot track this. - expected_requests = 0 - assert report.request_usage == expected_requests - - -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_custom_seconds_between_requests( - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """It is possible to customize the time waited between polling - requests. - """ - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - with MockVWS(processing_time_seconds=0.5) 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, - ) - - target_id = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - vws_client.wait_for_target_processed( - target_id=target_id, - seconds_between_requests=0.3, - ) - report = vws_client.get_database_summary_report() - expected_requests = ( - # Add target request - 1 - + - # Database summary request - 1 - + - # Initial request - 1 - + - # Request after 0.3 seconds - not processed - # This assumes that there is less than 0.2 seconds taken - # between the start of the target processing and the start of - # waiting for the target to be processed. - 1 - + - # Request after 0.6 seconds - processed - 1 - ) - # At the time of writing there is a bug which prevents request - # usage from being tracked so we cannot track this. - expected_requests = 0 - assert report.request_usage == expected_requests - - -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_custom_timeout( - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """It is possible to set a maximum timeout.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - with MockVWS(processing_time_seconds=0.5) 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, - ) - - target_id = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - report = vws_client.get_target_summary_report(target_id=target_id) - assert report.status == TargetStatuses.PROCESSING - with pytest.raises(expected_exception=TargetProcessingTimeoutError): - vws_client.wait_for_target_processed( - target_id=target_id, - timeout_seconds=0.1, - ) - - vws_client.wait_for_target_processed( - target_id=target_id, - timeout_seconds=0.5, - ) - report = vws_client.get_target_summary_report(target_id=target_id) - assert report.status != TargetStatuses.PROCESSING diff --git a/tests/vws/test_reports.py b/tests/vws/test_reports.py deleted file mode 100644 index 2f87588e0..000000000 --- a/tests/vws/test_reports.py +++ /dev/null @@ -1,372 +0,0 @@ -"""Tests for `VWS` reports.""" - -from __future__ import annotations - -import calendar -import datetime as dt -import time -import uuid -from http import HTTPStatus -from typing import TYPE_CHECKING, BinaryIO, Literal - -import pytest -from freezegun import freeze_time -from mock_vws import MockVWS -from mock_vws.database import CloudDatabase - -from tests.image_factory import IMAGE_CASES, ImageFactory -from vws import VWS -from vws.exceptions.custom_exceptions import ( - DatabaseIdNotSetError, - RecoCountsReportDownloadError, - RecoCountsReportNotReadyError, - RecoCountsReportTimeoutError, -) -from vws.exceptions.vws_exceptions import ( - AuthenticationFailureError, - FailError, -) -from vws.reports import ( - DatabaseSummaryReport, - RecoCount, - RecoCountsReport, - TargetStatuses, - TargetSummaryReport, -) -from vws.response import Response - -if TYPE_CHECKING: - import io - - -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"), - ) - - -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_get_target_summary_report( - *, - vws_client: VWS, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """Details of a target are returned by - ``get_target_summary_report``. - """ - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - date = "2018-04-25" - target_name = uuid.uuid4().hex - with freeze_time(time_to_freeze=date): - target_id = vws_client.add_target( - name=target_name, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - report = vws_client.get_target_summary_report(target_id=target_id) - - expected_report = TargetSummaryReport( - status=TargetStatuses.SUCCESS, - database_name=report.database_name, - target_name=target_name, - upload_date=dt.date(year=2018, month=4, day=25), - active_flag=True, - tracking_rating=report.tracking_rating, - total_recos=0, - current_month_recos=0, - previous_month_recos=0, - ) - - assert report.status == expected_report.status - assert report.database_name == expected_report.database_name - assert report.target_name == expected_report.target_name - assert report.upload_date == expected_report.upload_date - assert report.active_flag == expected_report.active_flag - assert report.tracking_rating == expected_report.tracking_rating - assert report.total_recos == expected_report.total_recos - assert report.current_month_recos == expected_report.current_month_recos - assert report.previous_month_recos == expected_report.previous_month_recos - - assert report == expected_report - - -def test_get_target(vws_client: VWS) -> None: - """Details of a database are returned by - ``get_database_summary_report``. - """ - report = vws_client.get_database_summary_report() - - expected_report = DatabaseSummaryReport( - active_images=0, - current_month_recos=0, - failed_images=0, - inactive_images=0, - name=report.name, - previous_month_recos=0, - processing_images=0, - reco_threshold=1000, - request_quota=100000, - request_usage=0, - target_quota=1000, - total_recos=0, - ) - - assert report.active_images == expected_report.active_images - assert report.current_month_recos == expected_report.current_month_recos - assert report.failed_images == expected_report.failed_images - assert report.inactive_images == expected_report.inactive_images - assert report.name == expected_report.name - assert report.previous_month_recos == expected_report.previous_month_recos - assert report.processing_images == expected_report.processing_images - assert report.reco_threshold == expected_report.reco_threshold - assert report.request_quota == expected_report.request_quota - assert report.request_usage == expected_report.request_usage - assert report.target_quota == expected_report.target_quota - assert report.total_recos == expected_report.total_recos - - assert report == expected_report - - -@pytest.mark.parametrize( - argnames="report_period", argvalues=["current", "previous"] -) -def test_reco_counts_report( - *, - vws_client: VWS, - current_month: dt.date, - report_period: Literal["current", "previous"], -) -> None: - """A report can be requested, waited for and downloaded.""" - report_month = ( - current_month - if report_period == "current" - else current_month - dt.timedelta(days=1) - ) - report_request = vws_client.request_database_reco_counts_report( - year=report_month.year, - month=calendar.Month(value=report_month.month), - ) - assert bool(report_request.transaction_id) - assert bool(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 bool(report.reco_counts) - assert report.raw_csv.startswith(b"target_id,reco_count") - - -def test_not_ready(*, current_month: dt.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 - - -def test_wait_timeout(*, current_month: dt.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 - - -@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 - - -def test_database_id_does_not_match_keys( - *, - current_month: dt.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 - - -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 - - -def test_no_database_id(*, current_month: dt.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), - ) - - -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 - - -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 diff --git a/tests/vws/test_targets.py b/tests/vws/test_targets.py deleted file mode 100644 index 2ee5427ed..000000000 --- a/tests/vws/test_targets.py +++ /dev/null @@ -1,439 +0,0 @@ -"""Tests for `VWS` targets. - -Test for deleting a target. -""" - -from __future__ import annotations - -import base64 -import json -import secrets -import uuid -from http import HTTPStatus -from typing import TYPE_CHECKING, BinaryIO, Literal - -import pytest - -from tests.image_factory import IMAGE_CASES, ImageFactory -from vws import VWS, CloudRecoService -from vws.reports import ( - TargetRecord, - TargetStatuses, -) -from vws.response import Response - -if TYPE_CHECKING: - import io - - -class _JSONResponseTransport: - """A transport which returns one JSON response body.""" - - def __init__(self, *, body: object) -> None: - """Create a transport for the given JSON body.""" - self._text = json.dumps(obj=body) - - 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 the configured response body.""" - del method, headers, data, request_timeout - content = self._text.encode() - return Response( - text=self._text, - url=url, - status_code=HTTPStatus.OK, - headers={"Content-Type": "application/json"}, - request_body=None, - tell_position=len(content), - content=content, - ) - - -@pytest.mark.parametrize( - argnames="application_metadata", - argvalues=[None, b"a"], -) -@pytest.mark.parametrize(argnames="active_flag", argvalues=[True, False]) -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_add_target( - *, - vws_client: VWS, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], - application_metadata: bytes | None, - cloud_reco_client: CloudRecoService, - active_flag: bool, -) -> None: - """No exception is raised when adding one target.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - name = "x" - width = 1 - if application_metadata is None: - encoded_metadata = None - else: - encoded_metadata_bytes = base64.b64encode(s=application_metadata) - encoded_metadata = encoded_metadata_bytes.decode(encoding="utf-8") - - target_id = vws_client.add_target( - name=name, - width=width, - image=image, - application_metadata=encoded_metadata, - active_flag=active_flag, - ) - target_record = vws_client.get_target_record( - target_id=target_id, - ).target_record - assert target_record.name == name - assert target_record.width == width - assert target_record.active_flag is active_flag - vws_client.wait_for_target_processed(target_id=target_id) - matching_targets = cloud_reco_client.query(image=image) - if active_flag: - [matching_target] = matching_targets - assert matching_target.target_id == target_id - assert matching_target.target_data is not None - query_metadata = matching_target.target_data.application_metadata - assert query_metadata == encoded_metadata - else: - assert matching_targets == [] - - -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_add_two_targets( - *, - vws_client: VWS, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """No exception is raised when adding two targets with different - names. - - This demonstrates that the image seek position is not changed. - """ - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - for name in ("a", "b"): - _ = vws_client.add_target( - name=name, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_list_targets( - *, - vws_client: VWS, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """It is possible to get a list of target IDs.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - id_1 = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - id_2 = vws_client.add_target( - name="a", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - assert sorted(vws_client.list_targets()) == sorted([id_1, id_2]) - - -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_delete_target( - *, - vws_client: VWS, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """It is possible to delete a target.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - target_id = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - assert vws_client.list_targets() == [target_id] - vws_client.delete_target(target_id=target_id) - assert vws_client.list_targets() == [] - - -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_get_target_record( - *, - vws_client: VWS, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """Details of a target are returned by ``get_target_record``.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - target_id = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - result = vws_client.get_target_record(target_id=target_id) - expected_target_record = TargetRecord( - target_id=target_id, - active_flag=True, - name="x", - width=1, - tracking_rating=-1, - reco_rating="", - ) - - assert result.target_record == expected_target_record - - assert result.target_record.target_id == expected_target_record.target_id - assert ( - result.target_record.active_flag == expected_target_record.active_flag - ) - assert result.target_record.name == expected_target_record.name - assert result.target_record.width == expected_target_record.width - assert ( - result.target_record.tracking_rating - == expected_target_record.tracking_rating - ) - assert ( - result.target_record.reco_rating == expected_target_record.reco_rating - ) - - assert result.status == TargetStatuses.PROCESSING - - -def test_get_failed( - *, - vws_client: VWS, - image_file_failed_state: io.BytesIO, -) -> None: - """Check that the report works with a failed target.""" - target_id = vws_client.add_target( - name="x", - width=1, - image=image_file_failed_state, - active_flag=True, - application_metadata=None, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - result = vws_client.get_target_record(target_id=target_id) - - assert result.status == TargetStatuses.FAILED - - -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_get_duplicate_targets( - *, - vws_client: VWS, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """It is possible to get the IDs of similar targets.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - target_id = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - similar_target_id = vws_client.add_target( - name="a", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - vws_client.wait_for_target_processed(target_id=similar_target_id) - duplicates = vws_client.get_duplicate_targets(target_id=target_id) - assert duplicates == [similar_target_id] - - -@pytest.mark.parametrize( - argnames="similar_targets", - argvalues=[1, ["target-id", 1]], -) -def test_invalid_duplicate_target_response(*, similar_targets: object) -> None: - """Duplicate target IDs in responses must be a list of strings.""" - transport = _JSONResponseTransport( - body={ - "result_code": "Success", - "similar_targets": similar_targets, - } - ) - client = VWS( - server_access_key="access-key", - server_secret_key=secrets.token_hex(), - transport=transport, - ) - - with pytest.raises(expected_exception=TypeError): - _ = client.get_duplicate_targets(target_id="target-id") - - -@pytest.mark.parametrize( - argnames="body", - argvalues=[[], {"result_code": 1}], -) -def test_invalid_vws_response_envelope(*, body: object) -> None: - """VWS responses must be objects with a string result code.""" - transport = _JSONResponseTransport(body=body) - client = VWS( - server_access_key="access-key", - server_secret_key=secrets.token_hex(), - transport=transport, - ) - - with pytest.raises(expected_exception=TypeError): - client.delete_target(target_id="target-id") - - -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_update_target( - *, - vws_client: VWS, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], - different_high_quality_image: io.BytesIO, - cloud_reco_client: CloudRecoService, -) -> None: - """It is possible to update a target.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - old_name = uuid.uuid4().hex - old_width = secrets.choice(seq=range(1, 5000)) / 100 - target_id = vws_client.add_target( - name=old_name, - width=old_width, - image=image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - [matching_target] = cloud_reco_client.query(image=image) - assert matching_target.target_id == target_id - query_target_data = matching_target.target_data - assert query_target_data is not None - query_metadata = query_target_data.application_metadata - assert query_metadata is None - - new_name = uuid.uuid4().hex - new_width = secrets.choice(seq=range(1, 5000)) / 100 - new_application_metadata = base64.b64encode(s=b"a").decode( - encoding="ascii", - ) - vws_client.update_target( - target_id=target_id, - name=new_name, - width=new_width, - active_flag=True, - image=different_high_quality_image, - application_metadata=new_application_metadata, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - [ - matching_target, - ] = cloud_reco_client.query(image=different_high_quality_image) - assert matching_target.target_id == target_id - query_target_data = matching_target.target_data - assert query_target_data is not None - query_metadata = query_target_data.application_metadata - assert query_metadata == new_application_metadata - - vws_client.update_target( - target_id=target_id, - active_flag=False, - ) - - target_details = vws_client.get_target_record(target_id=target_id) - assert target_details.target_record.name == new_name - assert target_details.target_record.width == new_width - assert not target_details.target_record.active_flag - - -@pytest.mark.parametrize( - argnames=("image_source", "image_mode"), argvalues=IMAGE_CASES -) -def test_no_fields_given( - *, - vws_client: VWS, - image_factory: ImageFactory, - image_source: Literal["high_quality_image", "image_file"], - image_mode: Literal["r+b", "rb"], -) -> None: - """It is possible to give no update fields.""" - image: io.BytesIO | BinaryIO = image_factory.create( - source=image_source, mode=image_mode - ) - target_id = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - vws_client.update_target(target_id=target_id)