diff --git a/pyproject.toml b/pyproject.toml
index 0e9a995c7..a7f734f1e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -148,7 +148,7 @@ lint.per-file-ignores."docs/source/*.py" = [
# Allow asserts in docs.
"S101",
]
-lint.per-file-ignores."tests/*.py" = [
+lint.per-file-ignores."tests/**/*.py" = [
# Allow asserts in tests.
"S101",
]
@@ -410,6 +410,7 @@ plugins = [
num_workers = "auto"
[tool.pyrefly]
+search-path = [ "." ]
errors.non-exhaustive-match = "error"
enabled-ignores = [ "pyrefly" ]
preset = "all"
diff --git a/tests/async_model_targets/__init__.py b/tests/async_model_targets/__init__.py
new file mode 100644
index 000000000..7bca5dad6
--- /dev/null
+++ b/tests/async_model_targets/__init__.py
@@ -0,0 +1 @@
+"""Async model targets tests."""
diff --git a/tests/async_model_targets/test_authentication.py b/tests/async_model_targets/test_authentication.py
new file mode 100644
index 000000000..a580a7923
--- /dev/null
+++ b/tests/async_model_targets/test_authentication.py
@@ -0,0 +1,143 @@
+"""Tests for asynchronous Model Target authentication."""
+
+import secrets
+from http import HTTPStatus
+
+import pytest
+from mock_vws import (
+ MockVWS,
+ ModelTargetFailureResponse,
+)
+
+from tests.model_target_credentials import CLIENT_CREDENTIALS, CLIENT_ID
+from vws import AsyncModelTargetService
+from vws.exceptions.custom_exceptions import ServerError
+from vws.exceptions.model_target_exceptions import (
+ ModelTargetAuthenticationError,
+ ModelTargetError,
+ ModelTargetOAuth2Error,
+)
+from vws.exceptions.vws_exceptions import TooManyRequestsError
+from vws.model_target_datasets import (
+ ModelTargetDatasetType,
+ ModelTargetModel,
+)
+
+
+async def _assert_dataset_error_response(
+ *,
+ model_target_model: ModelTargetModel,
+ status_code: HTTPStatus,
+ body: str,
+ expected_exception: (
+ type[ModelTargetError | TooManyRequestsError | ServerError]
+ ),
+) -> None:
+ """Assert that a mocked dataset failure maps to an exception."""
+ async with AsyncModelTargetService(
+ client_id=CLIENT_ID,
+ client_secret=CLIENT_CREDENTIALS[1],
+ ) as client:
+ with pytest.raises(
+ expected_exception=(
+ ModelTargetError,
+ TooManyRequestsError,
+ ServerError,
+ )
+ ) as exc:
+ await client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[model_target_model],
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ assert isinstance(exc.value, expected_exception)
+ assert exc.value.response.status_code == status_code
+ assert exc.value.response.text == body
+
+
+@pytest.mark.asyncio
+async def test_token_is_a_bearer_token(
+ *,
+ async_model_target_client: AsyncModelTargetService,
+) -> None:
+ """An access token is given for valid credentials."""
+ assert bool(await async_model_target_client.get_access_token())
+
+
+@pytest.mark.asyncio
+@pytest.mark.usefixtures("_mock_model_targets")
+async def test_invalid_credentials() -> None:
+ """An exception is raised when the credentials are not known."""
+ async with AsyncModelTargetService(
+ client_id="not-a-client-id",
+ client_secret=secrets.token_hex(),
+ ) as client:
+ with pytest.raises(
+ expected_exception=ModelTargetOAuth2Error,
+ ) as exc:
+ await client.get_access_token()
+
+ assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED
+ assert exc.value.error == "invalid_client"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ argnames=("status_code", "body", "expected_exception"),
+ argvalues=[
+ pytest.param(
+ HTTPStatus.UNAUTHORIZED,
+ '{"error":{"code":"AUTHENTICATION_ERROR","message":"No"}}',
+ ModelTargetAuthenticationError,
+ id="authentication",
+ ),
+ pytest.param(
+ HTTPStatus.FORBIDDEN,
+ '{"error":{"code":"FORBIDDEN","message":"Denied"}}',
+ ModelTargetError,
+ id="generic-json",
+ ),
+ pytest.param(
+ HTTPStatus.CONFLICT,
+ "not json",
+ ModelTargetError,
+ id="generic-non-json",
+ ),
+ pytest.param(
+ HTTPStatus.TOO_MANY_REQUESTS,
+ "rate limited",
+ TooManyRequestsError,
+ id="rate-limit",
+ ),
+ pytest.param(
+ HTTPStatus.BAD_GATEWAY,
+ "server error",
+ ServerError,
+ id="server-error",
+ ),
+ ],
+)
+async def test_dataset_error_response(
+ *,
+ model_target_model: ModelTargetModel,
+ status_code: HTTPStatus,
+ body: str,
+ expected_exception: (
+ type[ModelTargetError | TooManyRequestsError | ServerError]
+ ),
+) -> None:
+ """Dataset failures map to exceptions through the mock."""
+ failure = ModelTargetFailureResponse(
+ status_code=status_code,
+ body=body,
+ )
+
+ with MockVWS(model_target_failure_response=failure):
+ await _assert_dataset_error_response(
+ model_target_model=model_target_model,
+ status_code=status_code,
+ body=body,
+ expected_exception=expected_exception,
+ )
diff --git a/tests/async_model_targets/test_datasets.py b/tests/async_model_targets/test_datasets.py
new file mode 100644
index 000000000..5c4a18bc7
--- /dev/null
+++ b/tests/async_model_targets/test_datasets.py
@@ -0,0 +1,350 @@
+"""Tests for asynchronous Model Target datasets."""
+
+import io
+import uuid
+import zipfile
+from http import HTTPStatus
+
+import pytest
+from mock_vws import (
+ MockVWS,
+ ModelTargetGenerationFailure,
+ ModelTargetGenerationWarning,
+)
+
+from tests.model_target_credentials import CLIENT_CREDENTIALS, CLIENT_ID
+from vws import AsyncModelTargetService
+from vws.exceptions.model_target_exceptions import (
+ ModelTargetDatasetNotDoneError,
+ ModelTargetDatasetTimeoutError,
+ ModelTargetValidationError,
+ UnknownModelTargetDatasetError,
+)
+from vws.model_target_datasets import (
+ CadDataFormat,
+ ModelTargetDatasetType,
+ ModelTargetModel,
+ RealisticAppearance,
+)
+from vws.reports import ModelTargetDatasetStatuses
+
+_DATASET_TYPES = [
+ ModelTargetDatasetType.STANDARD,
+ ModelTargetDatasetType.ADVANCED,
+]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ argnames="dataset_type",
+ argvalues=_DATASET_TYPES,
+)
+async def test_create_wait_download_delete(
+ *,
+ async_model_target_client: AsyncModelTargetService,
+ model_target_model: ModelTargetModel,
+ dataset_type: ModelTargetDatasetType,
+) -> None:
+ """A dataset can be created, downloaded and then deleted."""
+ dataset_uuid = await async_model_target_client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[model_target_model],
+ dataset_type=dataset_type,
+ )
+
+ report = await async_model_target_client.wait_for_dataset_generated(
+ dataset_uuid=dataset_uuid,
+ dataset_type=dataset_type,
+ )
+
+ assert report.status == ModelTargetDatasetStatuses.DONE
+ assert report.dataset_uuid == dataset_uuid
+ assert report.completed_at is not None
+ assert report.eta is None
+ assert report.error is None
+ assert report.warning is None
+
+ dataset = await async_model_target_client.download_dataset(
+ dataset_uuid=dataset_uuid,
+ dataset_type=dataset_type,
+ )
+
+ with zipfile.ZipFile(file=io.BytesIO(initial_bytes=dataset)) as archive:
+ assert archive.namelist() == ["MTDataset.dat", "MTDataset.xml"]
+
+ await async_model_target_client.delete_dataset(
+ dataset_uuid=dataset_uuid,
+ dataset_type=dataset_type,
+ )
+
+ with pytest.raises(expected_exception=UnknownModelTargetDatasetError):
+ await async_model_target_client.get_dataset_status(
+ dataset_uuid=dataset_uuid,
+ dataset_type=dataset_type,
+ )
+
+
+@pytest.mark.asyncio
+async def test_status_while_processing(
+ *,
+ async_model_target_client: AsyncModelTargetService,
+ model_target_model: ModelTargetModel,
+) -> None:
+ """A processing dataset has an estimated completion time."""
+ dataset_uuid = await async_model_target_client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[model_target_model],
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ report = await async_model_target_client.get_dataset_status(
+ dataset_uuid=dataset_uuid,
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ assert report.status == ModelTargetDatasetStatuses.PROCESSING
+ assert report.eta is not None
+ assert report.completed_at is None
+
+
+@pytest.mark.asyncio
+async def test_download_while_processing(
+ *,
+ async_model_target_client: AsyncModelTargetService,
+ model_target_model: ModelTargetModel,
+) -> None:
+ """A dataset cannot be downloaded before it is generated."""
+ dataset_uuid = await async_model_target_client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[model_target_model],
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ with pytest.raises(
+ expected_exception=ModelTargetDatasetNotDoneError,
+ ) as exc:
+ await async_model_target_client.download_dataset(
+ dataset_uuid=dataset_uuid,
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
+ assert exc.value.code == "UNSUPPORTED_STATE"
+
+
+@pytest.mark.asyncio
+async def test_dataset_is_visible_to_other_type(
+ *,
+ async_model_target_client: AsyncModelTargetService,
+ model_target_model: ModelTargetModel,
+) -> None:
+ """Standard and advanced routes share datasets by UUID."""
+ dataset_uuid = await async_model_target_client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[model_target_model],
+ dataset_type=ModelTargetDatasetType.ADVANCED,
+ )
+
+ report = await async_model_target_client.get_dataset_status(
+ dataset_uuid=dataset_uuid,
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ assert report.dataset_uuid == dataset_uuid
+
+
+@pytest.mark.asyncio
+async def test_advanced_dataset_takes_multiple_models(
+ *,
+ async_model_target_client: AsyncModelTargetService,
+ model_target_model: ModelTargetModel,
+) -> None:
+ """An advanced dataset can be generated from multiple models."""
+ other_model = ModelTargetModel(
+ name="other-model",
+ cad_data_blob="ZmFrZS1jYWQtZGF0YQ==",
+ cad_data_format=CadDataFormat.GLB,
+ realistic_appearance=RealisticAppearance.TRUE,
+ views=[],
+ )
+
+ assert bool(
+ await async_model_target_client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[model_target_model, other_model],
+ dataset_type=ModelTargetDatasetType.ADVANCED,
+ )
+ )
+
+
+@pytest.mark.asyncio
+async def test_get_status(
+ *,
+ async_model_target_client: AsyncModelTargetService,
+) -> None:
+ """An exception is raised for an unknown dataset."""
+ dataset_uuid = uuid.uuid4().hex
+ with pytest.raises(
+ expected_exception=UnknownModelTargetDatasetError,
+ ) as exc:
+ await async_model_target_client.get_dataset_status(
+ dataset_uuid=dataset_uuid,
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ assert exc.value.response.status_code == HTTPStatus.NOT_FOUND
+ assert dataset_uuid in exc.value.message
+
+
+@pytest.mark.asyncio
+async def test_download(
+ *,
+ async_model_target_client: AsyncModelTargetService,
+) -> None:
+ """An exception is raised for an unknown dataset."""
+ with pytest.raises(expected_exception=UnknownModelTargetDatasetError):
+ await async_model_target_client.download_dataset(
+ dataset_uuid=uuid.uuid4().hex,
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+
+@pytest.mark.asyncio
+async def test_delete(
+ *,
+ async_model_target_client: AsyncModelTargetService,
+) -> None:
+ """An exception is raised for an unknown dataset."""
+ with pytest.raises(expected_exception=UnknownModelTargetDatasetError):
+ await async_model_target_client.delete_dataset(
+ dataset_uuid=uuid.uuid4().hex,
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+
+@pytest.mark.asyncio
+async def test_no_cad_data(
+ *,
+ async_model_target_client: AsyncModelTargetService,
+) -> None:
+ """A model needs exactly one CAD data source."""
+ with pytest.raises(
+ expected_exception=ModelTargetValidationError,
+ ) as exc:
+ await async_model_target_client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[ModelTargetModel(name="model")],
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST
+ (detail,) = exc.value.details
+ assert detail.code == "VALIDATION_ERROR"
+
+
+@pytest.mark.asyncio
+async def test_generation_failure(
+ *,
+ model_target_model: ModelTargetModel,
+) -> None:
+ """A dataset which fails to generate reports the failure."""
+ message = "Model Target dataset generation failed"
+ failure = ModelTargetGenerationFailure(message=message)
+ with MockVWS(
+ processing_time_seconds=0.2,
+ model_target_generation_failure=failure,
+ ):
+ async with AsyncModelTargetService(
+ client_id=CLIENT_ID,
+ client_secret=CLIENT_CREDENTIALS[1],
+ ) as client:
+ dataset_uuid = await client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[model_target_model],
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ report = await client.wait_for_dataset_generated(
+ dataset_uuid=dataset_uuid,
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ assert report.status == ModelTargetDatasetStatuses.FAILED
+ assert report.error is not None
+ assert report.error.message == message
+
+
+@pytest.mark.asyncio
+async def test_generation_warning(
+ *,
+ model_target_model: ModelTargetModel,
+) -> None:
+ """A dataset which generates with a warning reports the
+ warning.
+ """
+ warning = ModelTargetGenerationWarning()
+ with MockVWS(
+ processing_time_seconds=0.2,
+ model_target_generation_warning=warning,
+ ):
+ async with AsyncModelTargetService(
+ client_id=CLIENT_ID,
+ client_secret=CLIENT_CREDENTIALS[1],
+ ) as client:
+ dataset_uuid = await client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[model_target_model],
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ report = await client.wait_for_dataset_generated(
+ dataset_uuid=dataset_uuid,
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ assert report.status == ModelTargetDatasetStatuses.DONE
+ assert report.warning is not None
+ assert report.warning.target == dataset_uuid
+ (detail,) = report.warning.details
+ assert detail.code == "LOW_RECOGNITION_QUALITY"
+
+
+@pytest.mark.asyncio
+async def test_timeout(*, model_target_model: ModelTargetModel) -> None:
+ """An exception is raised when the wait times out."""
+ with MockVWS(processing_time_seconds=60):
+ async with AsyncModelTargetService(
+ client_id=CLIENT_ID,
+ client_secret=CLIENT_CREDENTIALS[1],
+ ) as client:
+ dataset_uuid = await client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[model_target_model],
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ with pytest.raises(
+ expected_exception=ModelTargetDatasetTimeoutError,
+ ):
+ await client.wait_for_dataset_generated(
+ dataset_uuid=dataset_uuid,
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ seconds_between_requests=0.01,
+ timeout_seconds=0.05,
+ )
+
+ report = await client.get_dataset_status(
+ dataset_uuid=dataset_uuid,
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ assert report.status == ModelTargetDatasetStatuses.PROCESSING
diff --git a/tests/async_vws/__init__.py b/tests/async_vws/__init__.py
new file mode 100644
index 000000000..a5dc1e863
--- /dev/null
+++ b/tests/async_vws/__init__.py
@@ -0,0 +1 @@
+"""Async vws tests."""
diff --git a/tests/async_vws/test_configuration.py b/tests/async_vws/test_configuration.py
new file mode 100644
index 000000000..d873f563a
--- /dev/null
+++ b/tests/async_vws/test_configuration.py
@@ -0,0 +1,40 @@
+"""Tests for `AsyncVWS` configuration."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, BinaryIO
+
+import pytest
+from mock_vws import MockVWS
+from mock_vws.database import CloudDatabase
+
+from vws import AsyncVWS
+
+if TYPE_CHECKING:
+ import io
+
+
+@pytest.mark.asyncio
+async def test_custom_base_url(
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """It is possible to add a target to a database under a
+ custom VWS URL.
+ """
+ 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
new file mode 100644
index 000000000..c4014d8e5
--- /dev/null
+++ b/tests/async_vws/test_processing.py
@@ -0,0 +1,77 @@
+"""Tests for `AsyncVWS` processing."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, BinaryIO
+
+import pytest
+from mock_vws import MockVWS
+from mock_vws.database import CloudDatabase
+
+from vws import AsyncVWS
+from vws.exceptions.custom_exceptions import (
+ TargetProcessingTimeoutError,
+)
+from vws.reports import (
+ TargetStatuses,
+)
+
+if TYPE_CHECKING:
+ import io
+
+
+@pytest.mark.asyncio
+async def test_wait_for_target_processed(
+ *,
+ async_vws_client: AsyncVWS,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """It is possible to wait until a target is processed."""
+ 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
+async def test_custom_timeout(
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """It is possible to set a maximum timeout."""
+ 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
new file mode 100644
index 000000000..eac3ceb13
--- /dev/null
+++ b/tests/async_vws/test_reports.py
@@ -0,0 +1,281 @@
+"""Tests for `AsyncVWS` reports."""
+
+from __future__ import annotations
+
+import calendar
+import time
+import uuid
+from http import HTTPStatus
+from typing import TYPE_CHECKING, BinaryIO
+
+import pytest
+from mock_vws import MockVWS
+from mock_vws.database import CloudDatabase
+
+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 datetime
+ 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
+async def test_get_target_summary_report(
+ *,
+ async_vws_client: AsyncVWS,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """Details of a target are returned by
+ ``get_target_summary_report``.
+ """
+ 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
+async def test_reco_counts_report(
+ *,
+ async_vws_client: AsyncVWS,
+ report_month: datetime.date,
+) -> None:
+ """A report can be requested, waited for and downloaded."""
+ client = async_vws_client
+ report_request = await client.request_database_reco_counts_report(
+ year=report_month.year,
+ month=calendar.Month(value=report_month.month),
+ )
+ assert 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: datetime.date) -> None:
+ """Downloading a report before Vuforia has generated it raises an
+ error.
+ """
+ with MockVWS(processing_time_seconds=60) as mock:
+ database = CloudDatabase()
+ mock.add_cloud_database(cloud_database=database)
+ async with AsyncVWS(
+ server_access_key=database.server_access_key,
+ server_secret_key=database.server_secret_key,
+ database_id=database.database_id,
+ ) as client:
+ report_request = await client.request_database_reco_counts_report(
+ year=current_month.year,
+ month=calendar.Month(value=current_month.month),
+ )
+
+ with pytest.raises(
+ expected_exception=RecoCountsReportNotReadyError,
+ ) as exc:
+ await client.download_reco_counts_report(
+ presigned_url=report_request.presigned_url,
+ )
+
+ assert exc.value.response.status_code == HTTPStatus.NOT_FOUND
+
+
+@pytest.mark.asyncio
+async def test_wait_timeout(*, current_month: datetime.date) -> None:
+ """Waiting for a report which is not generated in time raises an
+ error.
+ """
+ with MockVWS(processing_time_seconds=60) as mock:
+ database = CloudDatabase()
+ mock.add_cloud_database(cloud_database=database)
+ async with AsyncVWS(
+ server_access_key=database.server_access_key,
+ server_secret_key=database.server_secret_key,
+ database_id=database.database_id,
+ ) as client:
+ report_request = await client.request_database_reco_counts_report(
+ year=current_month.year,
+ month=calendar.Month(value=current_month.month),
+ )
+
+ maximum_wait_seconds = 5
+ start_time = time.monotonic()
+
+ with pytest.raises(
+ expected_exception=RecoCountsReportTimeoutError,
+ ):
+ await client.wait_for_reco_counts_report(
+ presigned_url=report_request.presigned_url,
+ seconds_between_requests=0.01,
+ timeout_seconds=0.05,
+ )
+
+ elapsed_time = time.monotonic() - start_time
+ assert elapsed_time < maximum_wait_seconds
+
+
+@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: datetime.date,
+) -> None:
+ """A database ID which does not match the given keys is
+ rejected.
+ """
+ with MockVWS() as mock:
+ database = CloudDatabase()
+ mock.add_cloud_database(cloud_database=database)
+ async with AsyncVWS(
+ server_access_key=database.server_access_key,
+ server_secret_key=database.server_secret_key,
+ database_id=uuid.uuid4().hex,
+ ) as client:
+ with pytest.raises(
+ expected_exception=AuthenticationFailureError,
+ ) as exc:
+ await client.request_database_reco_counts_report(
+ year=current_month.year,
+ month=calendar.Month(value=current_month.month),
+ )
+
+ assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED
+
+
+@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: datetime.date) -> None:
+ """A client which was given no database ID cannot request a
+ report.
+ """
+ async with AsyncVWS(
+ server_access_key=uuid.uuid4().hex,
+ server_secret_key=uuid.uuid4().hex,
+ ) as client:
+ with pytest.raises(expected_exception=DatabaseIdNotSetError):
+ await client.request_database_reco_counts_report(
+ year=current_month.year,
+ month=calendar.Month(value=current_month.month),
+ )
diff --git a/tests/async_vws/test_targets.py b/tests/async_vws/test_targets.py
new file mode 100644
index 000000000..ff16890a0
--- /dev/null
+++ b/tests/async_vws/test_targets.py
@@ -0,0 +1,297 @@
+"""Tests for `AsyncVWS` targets.
+
+Test for deleting a target.
+"""
+
+from __future__ import annotations
+
+import base64
+import uuid
+from typing import TYPE_CHECKING, BinaryIO
+
+import pytest
+
+# pytest-beartype resolves fixture annotations at runtime.
+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],
+)
+async def test_add_target(
+ *,
+ async_vws_client: AsyncVWS,
+ image: io.BytesIO | BinaryIO,
+ application_metadata: bytes | None,
+ async_cloud_reco_client: AsyncCloudRecoService,
+ active_flag: bool,
+) -> None:
+ """No exception is raised when adding one target."""
+ 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
+async def test_add_two_targets(
+ *,
+ async_vws_client: AsyncVWS,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """No exception is raised when adding two targets with
+ different names.
+
+ This demonstrates that the image seek position is not
+ changed.
+ """
+ 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
+async def test_list_targets(
+ *,
+ async_vws_client: AsyncVWS,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """It is possible to get a list of target IDs."""
+ 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
+async def test_delete_target(
+ *,
+ async_vws_client: AsyncVWS,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """It is possible to delete a target."""
+ 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
+async def test_get_target_record(
+ *,
+ async_vws_client: AsyncVWS,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """Details of a target are returned by
+ ``get_target_record``.
+ """
+ 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
+async def test_get_duplicate_targets(
+ *,
+ async_vws_client: AsyncVWS,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """It is possible to get the IDs of similar targets."""
+ 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
+async def test_update_target(
+ *,
+ async_vws_client: AsyncVWS,
+ async_cloud_reco_client: AsyncCloudRecoService,
+ image: io.BytesIO | BinaryIO,
+ different_high_quality_image: io.BytesIO,
+) -> None:
+ """It is possible to update a target."""
+ 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
+async def test_no_fields_given(
+ *,
+ async_vws_client: AsyncVWS,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """It is possible to give no update fields."""
+ 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/async_vws/test_vumark.py b/tests/async_vws/test_vumark.py
new file mode 100644
index 000000000..2d94289ff
--- /dev/null
+++ b/tests/async_vws/test_vumark.py
@@ -0,0 +1,46 @@
+"""Tests for `AsyncVWS` vumark."""
+
+from __future__ import annotations
+
+import pytest
+
+# pytest-beartype resolves fixture annotations at runtime.
+from vws import AsyncVuMarkService # noqa: TC001
+from vws.vumark_accept import VuMarkAccept
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ argnames=("accept", "expected_prefix"),
+ argvalues=[
+ pytest.param(
+ VuMarkAccept.PNG,
+ b"\x89PNG\r\n\x1a\n",
+ id="png",
+ ),
+ pytest.param(
+ VuMarkAccept.SVG,
+ b"<",
+ id="svg",
+ ),
+ pytest.param(
+ VuMarkAccept.PDF,
+ b"%PDF",
+ id="pdf",
+ ),
+ ],
+)
+async def test_generate_vumark_instance(
+ *,
+ async_vumark_service_client: AsyncVuMarkService,
+ vumark_target_id: str,
+ accept: VuMarkAccept,
+ expected_prefix: bytes,
+) -> None:
+ """The returned bytes match the requested format."""
+ result = await async_vumark_service_client.generate_vumark_instance(
+ target_id=vumark_target_id,
+ instance_id="12345",
+ accept=accept,
+ )
+ assert result.startswith(expected_prefix)
diff --git a/tests/model_target_credentials.py b/tests/model_target_credentials.py
new file mode 100644
index 000000000..6e42b5a39
--- /dev/null
+++ b/tests/model_target_credentials.py
@@ -0,0 +1,6 @@
+"""Credentials accepted by the mock Model Target Web API."""
+
+# The mock accepts one hard-coded pair of Model Target Web API OAuth2
+# credentials, which it does not expose.
+CLIENT_ID = "client-id"
+CLIENT_CREDENTIALS = ("client-id", "client-secret")
diff --git a/tests/model_targets/__init__.py b/tests/model_targets/__init__.py
new file mode 100644
index 000000000..5a18c1e4a
--- /dev/null
+++ b/tests/model_targets/__init__.py
@@ -0,0 +1 @@
+"""Model targets tests."""
diff --git a/tests/model_targets/responses.py b/tests/model_targets/responses.py
new file mode 100644
index 000000000..fa7c8c334
--- /dev/null
+++ b/tests/model_targets/responses.py
@@ -0,0 +1,34 @@
+"""Response builders for Model Target API tests."""
+
+from http import HTTPStatus
+
+from beartype import beartype
+
+from vws.response import Response
+
+
+@beartype
+def response_with_status(
+ *,
+ text: str,
+ status_code: HTTPStatus,
+) -> Response:
+ """Get a response with a given body.
+
+ Args:
+ text: The body of the response.
+ status_code: The response status code.
+
+ Returns:
+ A response with the given body.
+ """
+ content = text.encode(encoding="utf-8")
+ return Response(
+ text=text,
+ url="https://vws.vuforia.com/modeltargets/datasets",
+ status_code=status_code,
+ headers={},
+ request_body=None,
+ tell_position=len(content),
+ content=content,
+ )
diff --git a/tests/model_targets/test_authentication.py b/tests/model_targets/test_authentication.py
new file mode 100644
index 000000000..c410ada7f
--- /dev/null
+++ b/tests/model_targets/test_authentication.py
@@ -0,0 +1,236 @@
+"""Tests for Model Target authentication."""
+
+import secrets
+from http import HTTPStatus
+
+import pytest
+from beartype import beartype
+from freezegun import freeze_time
+from mock_vws import (
+ MockVWS,
+ ModelTargetFailureResponse,
+)
+
+from tests.model_target_credentials import CLIENT_CREDENTIALS, CLIENT_ID
+from vws import ModelTargetService
+from vws.exceptions.custom_exceptions import ServerError
+from vws.exceptions.model_target_exceptions import (
+ ModelTargetAuthenticationError,
+ ModelTargetError,
+ ModelTargetOAuth2Error,
+)
+from vws.exceptions.vws_exceptions import TooManyRequestsError
+from vws.model_target_datasets import (
+ ModelTargetDatasetType,
+ ModelTargetModel,
+)
+
+# beartype resolves the decorated transport's return type at runtime.
+from vws.response import Response # noqa: TC001
+from vws.transports import RequestsTransport, Transport
+
+
+@beartype
+class _CountingTransport:
+ """A transport which counts the requests made to each path."""
+
+ def __init__(self, *, transport: Transport) -> None:
+ """
+ Args:
+ transport: The transport to make requests with.
+ """
+ self._transport = transport
+ self.urls: list[str] = []
+
+ def close(self) -> None:
+ """Close the wrapped transport."""
+ self._transport.close()
+
+ def __call__(
+ self,
+ *,
+ method: str,
+ url: str,
+ headers: dict[str, str],
+ data: bytes,
+ request_timeout: float | tuple[float, float],
+ ) -> Response:
+ """Make a request, recording the URL.
+
+ Args:
+ method: The HTTP method.
+ url: The full URL.
+ headers: Request headers.
+ data: The request body.
+ request_timeout: The request timeout.
+
+ Returns:
+ A Response populated from the HTTP response.
+ """
+ self.urls.append(url)
+ return self._transport(
+ method=method,
+ url=url,
+ headers=headers,
+ data=data,
+ request_timeout=request_timeout,
+ )
+
+
+@pytest.mark.usefixtures("_mock_model_targets")
+def test_token_is_a_bearer_token() -> None:
+ """An access token is given for valid credentials."""
+ client = ModelTargetService(
+ client_id=CLIENT_ID,
+ client_secret=CLIENT_CREDENTIALS[1],
+ )
+
+ assert bool(client.get_access_token())
+
+
+@pytest.mark.usefixtures("_mock_model_targets")
+def test_token_is_reused(
+ *,
+ model_target_model: ModelTargetModel,
+) -> None:
+ """One access token is used for multiple requests."""
+ transport = _CountingTransport(transport=RequestsTransport())
+ client = ModelTargetService(
+ client_id=CLIENT_ID,
+ client_secret=CLIENT_CREDENTIALS[1],
+ transport=transport,
+ )
+
+ for _ in range(2):
+ _ = client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[model_target_model],
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ token_urls = [url for url in transport.urls if "oauth2" in url]
+ assert len(token_urls) == 1
+ transport.close()
+
+
+@pytest.mark.usefixtures("_mock_model_targets")
+def test_expired_token_is_replaced(
+ *,
+ model_target_model: ModelTargetModel,
+) -> None:
+ """A new access token is requested once the old one expires."""
+ transport = _CountingTransport(transport=RequestsTransport())
+ client = ModelTargetService(
+ client_id=CLIENT_ID,
+ client_secret=CLIENT_CREDENTIALS[1],
+ transport=transport,
+ )
+
+ with freeze_time(time_to_freeze="2026-01-01") as frozen_time:
+ _ = client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[model_target_model],
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+ # Mock tokens last an hour.
+ _ = frozen_time.tick(delta=60 * 60 + 1)
+ _ = client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[model_target_model],
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ token_urls = [url for url in transport.urls if "oauth2" in url]
+ expected_token_request_count = 2
+ assert len(token_urls) == expected_token_request_count
+
+
+@pytest.mark.usefixtures("_mock_model_targets")
+def test_invalid_credentials() -> None:
+ """An exception is raised when the credentials are not known."""
+ client = ModelTargetService(
+ client_id="not-a-client-id",
+ client_secret=secrets.token_hex(),
+ )
+
+ with pytest.raises(
+ expected_exception=ModelTargetOAuth2Error,
+ ) as exc:
+ _ = client.get_access_token()
+
+ assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED
+ assert exc.value.error == "invalid_client"
+ assert not bool(exc.value.error_description)
+
+
+@pytest.mark.parametrize(
+ argnames=("status_code", "body", "expected_exception"),
+ argvalues=[
+ pytest.param(
+ HTTPStatus.UNAUTHORIZED,
+ '{"error":{"code":"AUTHENTICATION_ERROR","message":"No"}}',
+ ModelTargetAuthenticationError,
+ id="authentication",
+ ),
+ pytest.param(
+ HTTPStatus.FORBIDDEN,
+ '{"error":{"code":"FORBIDDEN","message":"Denied"}}',
+ ModelTargetError,
+ id="generic-json",
+ ),
+ pytest.param(
+ HTTPStatus.CONFLICT,
+ "not json",
+ ModelTargetError,
+ id="generic-non-json",
+ ),
+ pytest.param(
+ HTTPStatus.TOO_MANY_REQUESTS,
+ "rate limited",
+ TooManyRequestsError,
+ id="rate-limit",
+ ),
+ pytest.param(
+ HTTPStatus.BAD_GATEWAY,
+ "server error",
+ ServerError,
+ id="server-error",
+ ),
+ ],
+)
+def test_dataset_error_response(
+ *,
+ model_target_model: ModelTargetModel,
+ status_code: HTTPStatus,
+ body: str,
+ expected_exception: (
+ type[ModelTargetError | TooManyRequestsError | ServerError]
+ ),
+) -> None:
+ """Dataset failures map to exceptions through the mock."""
+ failure = ModelTargetFailureResponse(
+ status_code=status_code,
+ body=body,
+ )
+ client = ModelTargetService(
+ client_id=CLIENT_ID,
+ client_secret=CLIENT_CREDENTIALS[1],
+ )
+
+ with (
+ MockVWS(model_target_failure_response=failure),
+ pytest.raises(expected_exception=expected_exception) as exc,
+ ):
+ _ = client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[model_target_model],
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ assert isinstance(exc.value, expected_exception)
+ assert exc.value.response.status_code == status_code
+ assert exc.value.response.text == body
diff --git a/tests/model_targets/test_configuration.py b/tests/model_targets/test_configuration.py
new file mode 100644
index 000000000..00a0736a9
--- /dev/null
+++ b/tests/model_targets/test_configuration.py
@@ -0,0 +1,44 @@
+"""Tests for Model Target configuration."""
+
+from mock_vws import (
+ MockVWS,
+)
+
+from tests.model_target_credentials import CLIENT_CREDENTIALS, CLIENT_ID
+from vws import ModelTargetService
+from vws.model_target_datasets import (
+ ModelTargetDatasetType,
+ ModelTargetModel,
+)
+
+
+def test_custom_base_url(
+ *,
+ model_target_model: ModelTargetModel,
+) -> None:
+ """The Model Target Web API can be served from a URL with a
+ path.
+ """
+ base_vws_url = "https://example.com/vws"
+ with MockVWS(
+ base_vws_url=base_vws_url,
+ processing_time_seconds=0.2,
+ ):
+ client = ModelTargetService(
+ client_id=CLIENT_ID,
+ client_secret=CLIENT_CREDENTIALS[1],
+ base_vws_url=base_vws_url,
+ )
+ dataset_uuid = client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[model_target_model],
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ report = client.get_dataset_status(
+ dataset_uuid=dataset_uuid,
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ assert report.dataset_uuid == dataset_uuid
diff --git a/tests/model_targets/test_datasets.py b/tests/model_targets/test_datasets.py
new file mode 100644
index 000000000..8cdae58e3
--- /dev/null
+++ b/tests/model_targets/test_datasets.py
@@ -0,0 +1,445 @@
+"""Tests for Model Target datasets."""
+
+import io
+import json
+import uuid
+import zipfile
+from http import HTTPStatus
+
+import pytest
+from mock_vws import (
+ MockVWS,
+ ModelTargetGenerationFailure,
+ ModelTargetGenerationWarning,
+)
+
+from tests.model_target_credentials import CLIENT_CREDENTIALS, CLIENT_ID
+from tests.model_targets.responses import response_with_status
+from vws import ModelTargetService
+from vws._model_targets import access_token_from_response
+from vws.exceptions.model_target_exceptions import (
+ ModelTargetDatasetNotDoneError,
+ ModelTargetDatasetTimeoutError,
+ ModelTargetOAuth2Error,
+ ModelTargetValidationError,
+ UnknownModelTargetDatasetError,
+)
+from vws.model_target_datasets import (
+ CadDataFormat,
+ GuideViewPosition,
+ ModelTargetDatasetType,
+ ModelTargetModel,
+ ModelTargetView,
+ RealisticAppearance,
+)
+from vws.reports import ModelTargetDatasetStatuses
+
+_DATASET_TYPES = [
+ ModelTargetDatasetType.STANDARD,
+ ModelTargetDatasetType.ADVANCED,
+]
+
+
+@pytest.mark.parametrize(
+ argnames="dataset_type",
+ argvalues=_DATASET_TYPES,
+)
+def test_create_wait_download_delete(
+ *,
+ model_target_client: ModelTargetService,
+ model_target_model: ModelTargetModel,
+ dataset_type: ModelTargetDatasetType,
+) -> None:
+ """A dataset can be created, downloaded and then deleted."""
+ dataset_uuid = model_target_client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[model_target_model],
+ dataset_type=dataset_type,
+ )
+
+ report = model_target_client.wait_for_dataset_generated(
+ dataset_uuid=dataset_uuid,
+ dataset_type=dataset_type,
+ )
+
+ assert report.status == ModelTargetDatasetStatuses.DONE
+ assert report.dataset_uuid == dataset_uuid
+ assert report.completed_at is not None
+ assert report.completed_at >= report.created_at
+ assert report.eta is None
+ assert report.error is None
+ assert report.warning is None
+
+ dataset = model_target_client.download_dataset(
+ dataset_uuid=dataset_uuid,
+ dataset_type=dataset_type,
+ )
+
+ with zipfile.ZipFile(file=io.BytesIO(initial_bytes=dataset)) as archive:
+ assert archive.namelist() == ["MTDataset.dat", "MTDataset.xml"]
+
+ model_target_client.delete_dataset(
+ dataset_uuid=dataset_uuid,
+ dataset_type=dataset_type,
+ )
+
+ with pytest.raises(expected_exception=UnknownModelTargetDatasetError):
+ _ = model_target_client.get_dataset_status(
+ dataset_uuid=dataset_uuid,
+ dataset_type=dataset_type,
+ )
+
+
+def test_status_while_processing(
+ *,
+ model_target_client: ModelTargetService,
+ model_target_model: ModelTargetModel,
+) -> None:
+ """A processing dataset has an estimated completion time."""
+ dataset_uuid = model_target_client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[model_target_model],
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ report = model_target_client.get_dataset_status(
+ dataset_uuid=dataset_uuid,
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ assert report.status == ModelTargetDatasetStatuses.PROCESSING
+ assert report.eta is not None
+ assert report.eta >= report.created_at
+ assert report.completed_at is None
+
+
+def test_download_while_processing(
+ *,
+ model_target_client: ModelTargetService,
+ model_target_model: ModelTargetModel,
+) -> None:
+ """A dataset cannot be downloaded before it is generated."""
+ dataset_uuid = model_target_client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[model_target_model],
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ with pytest.raises(
+ expected_exception=ModelTargetDatasetNotDoneError,
+ ) as exc:
+ _ = model_target_client.download_dataset(
+ dataset_uuid=dataset_uuid,
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
+ assert exc.value.code == "UNSUPPORTED_STATE"
+ assert exc.value.target == dataset_uuid
+
+
+def test_dataset_is_visible_to_other_type(
+ *,
+ model_target_client: ModelTargetService,
+ model_target_model: ModelTargetModel,
+) -> None:
+ """Standard and advanced routes share datasets by UUID."""
+ dataset_uuid = model_target_client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[model_target_model],
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ report = model_target_client.get_dataset_status(
+ dataset_uuid=dataset_uuid,
+ dataset_type=ModelTargetDatasetType.ADVANCED,
+ )
+
+ assert report.dataset_uuid == dataset_uuid
+
+
+def test_advanced_dataset_takes_multiple_models(
+ *,
+ model_target_client: ModelTargetService,
+ model_target_model: ModelTargetModel,
+) -> None:
+ """An advanced dataset can be generated from multiple models."""
+ other_model = ModelTargetModel(
+ name="other-model",
+ cad_data_blob="ZmFrZS1jYWQtZGF0YQ==",
+ cad_data_format=CadDataFormat.GLB,
+ realistic_appearance=RealisticAppearance.TRUE,
+ views=[],
+ )
+
+ dataset_uuid = model_target_client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[model_target_model, other_model],
+ dataset_type=ModelTargetDatasetType.ADVANCED,
+ )
+
+ assert bool(dataset_uuid)
+
+
+def test_state_based_model(
+ *,
+ model_target_client: ModelTargetService,
+) -> None:
+ """A State-Based Model Target dataset can be created."""
+ configuration = json.dumps(obj={"states": {"open": {}, "closed": {}}})
+ model = ModelTargetModel(
+ name="model",
+ cad_data_url="https://example.com/model.zip",
+ cad_data_format=CadDataFormat.ZIP,
+ state_based_configuration_json_string=configuration,
+ views=[
+ ModelTargetView(
+ name="front",
+ guide_view_position=GuideViewPosition(
+ rotation=[0.0, 0.0, 0.0, 1.0],
+ translation=[0.0, 0.0, 1.0],
+ ),
+ states=["open"],
+ ),
+ ],
+ )
+
+ assert bool(
+ model_target_client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[model],
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+ )
+
+
+def test_get_status(*, model_target_client: ModelTargetService) -> None:
+ """An exception is raised for an unknown dataset."""
+ dataset_uuid = uuid.uuid4().hex
+ with pytest.raises(
+ expected_exception=UnknownModelTargetDatasetError,
+ ) as exc:
+ _ = model_target_client.get_dataset_status(
+ dataset_uuid=dataset_uuid,
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ assert exc.value.response.status_code == HTTPStatus.NOT_FOUND
+ assert exc.value.code == "NOT_FOUND"
+ assert dataset_uuid in exc.value.message
+
+
+def test_download(*, model_target_client: ModelTargetService) -> None:
+ """An exception is raised for an unknown dataset."""
+ with pytest.raises(expected_exception=UnknownModelTargetDatasetError):
+ _ = model_target_client.download_dataset(
+ dataset_uuid=uuid.uuid4().hex,
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+
+def test_delete(*, model_target_client: ModelTargetService) -> None:
+ """An exception is raised for an unknown dataset."""
+ with pytest.raises(expected_exception=UnknownModelTargetDatasetError):
+ model_target_client.delete_dataset(
+ dataset_uuid=uuid.uuid4().hex,
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+
+def test_no_cad_data(
+ *,
+ model_target_client: ModelTargetService,
+) -> None:
+ """A model needs exactly one CAD data source."""
+ with pytest.raises(
+ expected_exception=ModelTargetValidationError,
+ ) as exc:
+ _ = model_target_client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[ModelTargetModel(name="model", views=[])],
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST
+ assert exc.value.code == "BAD_REQUEST"
+ (detail,) = exc.value.details
+ assert detail.code == "VALIDATION_ERROR"
+ assert "cadDataUrl" in detail.message
+
+
+def test_two_cad_data_sources(
+ *,
+ model_target_client: ModelTargetService,
+) -> None:
+ """A model cannot give two CAD data sources."""
+ model = ModelTargetModel(
+ name="model",
+ cad_data_url="https://example.com/model.zip",
+ cad_data_blob="ZmFrZS1jYWQtZGF0YQ==",
+ )
+
+ with pytest.raises(expected_exception=ModelTargetValidationError):
+ _ = model_target_client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[model],
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+
+def test_two_models_in_a_standard_dataset(
+ *,
+ model_target_client: ModelTargetService,
+ model_target_model: ModelTargetModel,
+) -> None:
+ """A standard dataset takes exactly one model."""
+ with pytest.raises(
+ expected_exception=ModelTargetValidationError,
+ ) as exc:
+ _ = model_target_client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[model_target_model, model_target_model],
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ (detail,) = exc.value.details
+ assert detail.message == "exactly one model should be provided"
+
+
+def test_generation_failure(
+ *,
+ model_target_model: ModelTargetModel,
+) -> None:
+ """A dataset which fails to generate reports the failure."""
+ message = "Model Target dataset generation failed"
+ failure = ModelTargetGenerationFailure(message=message)
+ with MockVWS(
+ processing_time_seconds=0.2,
+ model_target_generation_failure=failure,
+ ):
+ client = ModelTargetService(
+ client_id=CLIENT_ID,
+ client_secret=CLIENT_CREDENTIALS[1],
+ )
+ dataset_uuid = client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[model_target_model],
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ report = client.wait_for_dataset_generated(
+ dataset_uuid=dataset_uuid,
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ assert report.status == ModelTargetDatasetStatuses.FAILED
+ assert report.error is not None
+ assert report.error.message == message
+ assert report.warning is None
+
+ with pytest.raises(
+ expected_exception=ModelTargetDatasetNotDoneError,
+ ):
+ _ = client.download_dataset(
+ dataset_uuid=dataset_uuid,
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+
+def test_generation_warning(
+ *,
+ model_target_model: ModelTargetModel,
+) -> None:
+ """A dataset which generates with a warning reports the
+ warning.
+ """
+ warning = ModelTargetGenerationWarning()
+ with MockVWS(
+ processing_time_seconds=0.2,
+ model_target_generation_warning=warning,
+ ):
+ client = ModelTargetService(
+ client_id=CLIENT_ID,
+ client_secret=CLIENT_CREDENTIALS[1],
+ )
+ dataset_uuid = client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[model_target_model],
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ report = client.wait_for_dataset_generated(
+ dataset_uuid=dataset_uuid,
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ assert report.status == ModelTargetDatasetStatuses.DONE
+ assert report.error is None
+ assert report.warning is not None
+ assert report.warning.message == warning.message
+ assert report.warning.target == dataset_uuid
+ (detail,) = report.warning.details
+ assert detail.code == "LOW_RECOGNITION_QUALITY"
+
+ assert bool(
+ client.download_dataset(
+ dataset_uuid=dataset_uuid,
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+ )
+
+
+def test_timeout(*, model_target_model: ModelTargetModel) -> None:
+ """An exception is raised when the wait times out."""
+ with MockVWS(processing_time_seconds=60):
+ client = ModelTargetService(
+ client_id=CLIENT_ID,
+ client_secret=CLIENT_CREDENTIALS[1],
+ )
+ dataset_uuid = client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[model_target_model],
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+ with pytest.raises(
+ expected_exception=ModelTargetDatasetTimeoutError,
+ ):
+ _ = client.wait_for_dataset_generated(
+ dataset_uuid=dataset_uuid,
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ seconds_between_requests=0.01,
+ timeout_seconds=0.05,
+ )
+
+
+@pytest.mark.parametrize(
+ argnames="payload",
+ argvalues=[
+ {"access_token": 1, "expires_in": 3600},
+ {"access_token": "token", "expires_in": []},
+ ],
+)
+def test_invalid_oauth2_token_response_values(
+ *, payload: dict[str, object]
+) -> None:
+ """OAuth token responses must contain correctly typed values."""
+ response = response_with_status(
+ text=json.dumps(obj=payload),
+ status_code=HTTPStatus.OK,
+ )
+
+ with pytest.raises(expected_exception=ModelTargetOAuth2Error):
+ _ = access_token_from_response(response=response)
diff --git a/tests/model_targets/test_errors.py b/tests/model_targets/test_errors.py
new file mode 100644
index 000000000..d50ad68fa
--- /dev/null
+++ b/tests/model_targets/test_errors.py
@@ -0,0 +1,84 @@
+"""Tests for Model Target errors."""
+
+import json
+from http import HTTPStatus
+
+import pytest
+from beartype import beartype
+
+from tests.model_targets.responses import response_with_status
+from vws.exceptions.model_target_exceptions import (
+ ModelTargetError,
+ ModelTargetOAuth2Error,
+)
+from vws.response import Response
+
+
+@beartype
+def _response(*, text: str) -> Response:
+ """Get a bad-request response with a given body."""
+ return response_with_status(
+ text=text,
+ status_code=HTTPStatus.BAD_REQUEST,
+ )
+
+
+@pytest.mark.parametrize(
+ argnames="text",
+ argvalues=[
+ "",
+ "Not JSON",
+ "[]",
+ "{}",
+ '{"error": "not-an-object"}',
+ '{"transaction_id": "abc", "result_code": "Fail"}',
+ ],
+)
+def test_unknown_error_shape(*, text: str) -> None:
+ """An error without a Model Target error object gives empty
+ values.
+ """
+ error = ModelTargetError(response=_response(text=text))
+
+ assert not bool(error.code)
+ assert not bool(error.message)
+ assert not bool(error.target)
+ assert not bool(error.details)
+
+
+def test_error_without_details() -> None:
+ """An error which gives no details has no details."""
+ text = json.dumps(obj={"error": {"code": "ERROR", "message": "No"}})
+ error = ModelTargetError(response=_response(text=text))
+
+ assert error.code == "ERROR"
+ assert error.message == "No"
+ assert not bool(error.target)
+ assert not bool(error.details)
+
+
+@pytest.mark.parametrize(
+ argnames="text",
+ argvalues=["Not JSON", "[]", "{}"],
+)
+def test_unknown_oauth2_error_shape(*, text: str) -> None:
+ """An OAuth2 error without an error code gives empty values."""
+ error = ModelTargetOAuth2Error(response=_response(text=text))
+
+ assert not bool(error.error)
+ assert not bool(error.error_description)
+
+
+def test_oauth2_error_description() -> None:
+ """An OAuth2 error description is given when Vuforia gives one."""
+ description = "Missing or invalid authorization header"
+ text = json.dumps(
+ obj={
+ "error": "invalid_request",
+ "error_description": description,
+ },
+ )
+ error = ModelTargetOAuth2Error(response=_response(text=text))
+
+ assert error.error == "invalid_request"
+ assert error.error_description == description
diff --git a/tests/test_async_model_targets.py b/tests/test_async_model_targets.py
deleted file mode 100644
index ca4662694..000000000
--- a/tests/test_async_model_targets.py
+++ /dev/null
@@ -1,508 +0,0 @@
-"""Tests for the async Model Target Web API client."""
-
-import io
-import secrets
-import uuid
-import zipfile
-from http import HTTPStatus
-
-import pytest
-from mock_vws import (
- MockVWS,
- ModelTargetFailureResponse,
- ModelTargetGenerationFailure,
- ModelTargetGenerationWarning,
-)
-
-from vws import AsyncModelTargetService
-from vws.exceptions.custom_exceptions import ServerError
-from vws.exceptions.model_target_exceptions import (
- ModelTargetAuthenticationError,
- ModelTargetDatasetNotDoneError,
- ModelTargetDatasetTimeoutError,
- ModelTargetError,
- ModelTargetOAuth2Error,
- ModelTargetValidationError,
- UnknownModelTargetDatasetError,
-)
-from vws.exceptions.vws_exceptions import TooManyRequestsError
-from vws.model_target_datasets import (
- CadDataFormat,
- ModelTargetDatasetType,
- ModelTargetModel,
- RealisticAppearance,
-)
-from vws.reports import ModelTargetDatasetStatuses
-
-# The mock accepts one hard-coded pair of Model Target Web API OAuth2
-# credentials, which it does not expose.
-_CLIENT_ID = "client-id"
-_CLIENT_CREDENTIALS = ("client-id", "client-secret")
-
-_DATASET_TYPES = [
- ModelTargetDatasetType.STANDARD,
- ModelTargetDatasetType.ADVANCED,
-]
-
-
-async def _assert_dataset_error_response(
- *,
- model_target_model: ModelTargetModel,
- status_code: HTTPStatus,
- body: str,
- expected_exception: (
- type[ModelTargetError | TooManyRequestsError | ServerError]
- ),
-) -> None:
- """Assert that a mocked dataset failure maps to an exception."""
- async with AsyncModelTargetService(
- client_id=_CLIENT_ID,
- client_secret=_CLIENT_CREDENTIALS[1],
- ) as client:
- with pytest.raises(
- expected_exception=(
- ModelTargetError,
- TooManyRequestsError,
- ServerError,
- )
- ) as exc:
- await client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[model_target_model],
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- assert isinstance(exc.value, expected_exception)
- assert exc.value.response.status_code == status_code
- assert exc.value.response.text == body
-
-
-class TestAccessToken:
- """Tests for getting an access token."""
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_token_is_a_bearer_token(
- *,
- async_model_target_client: AsyncModelTargetService,
- ) -> None:
- """An access token is given for valid credentials."""
- assert bool(await async_model_target_client.get_access_token())
-
- @staticmethod
- @pytest.mark.asyncio
- @pytest.mark.usefixtures("_mock_model_targets")
- async def test_invalid_credentials() -> None:
- """An exception is raised when the credentials are not known."""
- async with AsyncModelTargetService(
- client_id="not-a-client-id",
- client_secret=secrets.token_hex(),
- ) as client:
- with pytest.raises(
- expected_exception=ModelTargetOAuth2Error,
- ) as exc:
- await client.get_access_token()
-
- assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED
- assert exc.value.error == "invalid_client"
-
- @staticmethod
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- argnames=("status_code", "body", "expected_exception"),
- argvalues=[
- pytest.param(
- HTTPStatus.UNAUTHORIZED,
- '{"error":{"code":"AUTHENTICATION_ERROR","message":"No"}}',
- ModelTargetAuthenticationError,
- id="authentication",
- ),
- pytest.param(
- HTTPStatus.FORBIDDEN,
- '{"error":{"code":"FORBIDDEN","message":"Denied"}}',
- ModelTargetError,
- id="generic-json",
- ),
- pytest.param(
- HTTPStatus.CONFLICT,
- "not json",
- ModelTargetError,
- id="generic-non-json",
- ),
- pytest.param(
- HTTPStatus.TOO_MANY_REQUESTS,
- "rate limited",
- TooManyRequestsError,
- id="rate-limit",
- ),
- pytest.param(
- HTTPStatus.BAD_GATEWAY,
- "server error",
- ServerError,
- id="server-error",
- ),
- ],
- )
- async def test_dataset_error_response(
- *,
- model_target_model: ModelTargetModel,
- status_code: HTTPStatus,
- body: str,
- expected_exception: (
- type[ModelTargetError | TooManyRequestsError | ServerError]
- ),
- ) -> None:
- """Dataset failures map to exceptions through the mock."""
- failure = ModelTargetFailureResponse(
- status_code=status_code,
- body=body,
- )
-
- with MockVWS(model_target_failure_response=failure):
- await _assert_dataset_error_response(
- model_target_model=model_target_model,
- status_code=status_code,
- body=body,
- expected_exception=expected_exception,
- )
-
-
-class TestDatasetLifecycle:
- """Tests for the dataset lifecycle."""
-
- @staticmethod
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- argnames="dataset_type",
- argvalues=_DATASET_TYPES,
- )
- async def test_create_wait_download_delete(
- *,
- async_model_target_client: AsyncModelTargetService,
- model_target_model: ModelTargetModel,
- dataset_type: ModelTargetDatasetType,
- ) -> None:
- """A dataset can be created, downloaded and then deleted."""
- dataset_uuid = await async_model_target_client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[model_target_model],
- dataset_type=dataset_type,
- )
-
- report = await async_model_target_client.wait_for_dataset_generated(
- dataset_uuid=dataset_uuid,
- dataset_type=dataset_type,
- )
-
- assert report.status == ModelTargetDatasetStatuses.DONE
- assert report.dataset_uuid == dataset_uuid
- assert report.completed_at is not None
- assert report.eta is None
- assert report.error is None
- assert report.warning is None
-
- dataset = await async_model_target_client.download_dataset(
- dataset_uuid=dataset_uuid,
- dataset_type=dataset_type,
- )
-
- with zipfile.ZipFile(
- file=io.BytesIO(initial_bytes=dataset)
- ) as archive:
- assert archive.namelist() == ["MTDataset.dat", "MTDataset.xml"]
-
- await async_model_target_client.delete_dataset(
- dataset_uuid=dataset_uuid,
- dataset_type=dataset_type,
- )
-
- with pytest.raises(expected_exception=UnknownModelTargetDatasetError):
- await async_model_target_client.get_dataset_status(
- dataset_uuid=dataset_uuid,
- dataset_type=dataset_type,
- )
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_status_while_processing(
- *,
- async_model_target_client: AsyncModelTargetService,
- model_target_model: ModelTargetModel,
- ) -> None:
- """A processing dataset has an estimated completion time."""
- dataset_uuid = await async_model_target_client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[model_target_model],
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- report = await async_model_target_client.get_dataset_status(
- dataset_uuid=dataset_uuid,
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- assert report.status == ModelTargetDatasetStatuses.PROCESSING
- assert report.eta is not None
- assert report.completed_at is None
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_download_while_processing(
- *,
- async_model_target_client: AsyncModelTargetService,
- model_target_model: ModelTargetModel,
- ) -> None:
- """A dataset cannot be downloaded before it is generated."""
- dataset_uuid = await async_model_target_client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[model_target_model],
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- with pytest.raises(
- expected_exception=ModelTargetDatasetNotDoneError,
- ) as exc:
- await async_model_target_client.download_dataset(
- dataset_uuid=dataset_uuid,
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- assert (
- exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
- )
- assert exc.value.code == "UNSUPPORTED_STATE"
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_dataset_is_visible_to_other_type(
- *,
- async_model_target_client: AsyncModelTargetService,
- model_target_model: ModelTargetModel,
- ) -> None:
- """Standard and advanced routes share datasets by UUID."""
- dataset_uuid = await async_model_target_client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[model_target_model],
- dataset_type=ModelTargetDatasetType.ADVANCED,
- )
-
- report = await async_model_target_client.get_dataset_status(
- dataset_uuid=dataset_uuid,
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- assert report.dataset_uuid == dataset_uuid
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_advanced_dataset_takes_multiple_models(
- *,
- async_model_target_client: AsyncModelTargetService,
- model_target_model: ModelTargetModel,
- ) -> None:
- """An advanced dataset can be generated from multiple models."""
- other_model = ModelTargetModel(
- name="other-model",
- cad_data_blob="ZmFrZS1jYWQtZGF0YQ==",
- cad_data_format=CadDataFormat.GLB,
- realistic_appearance=RealisticAppearance.TRUE,
- views=[],
- )
-
- assert bool(
- await async_model_target_client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[model_target_model, other_model],
- dataset_type=ModelTargetDatasetType.ADVANCED,
- )
- )
-
-
-class TestUnknownDataset:
- """Tests for requests for datasets which do not exist."""
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_get_status(
- *,
- async_model_target_client: AsyncModelTargetService,
- ) -> None:
- """An exception is raised for an unknown dataset."""
- dataset_uuid = uuid.uuid4().hex
- with pytest.raises(
- expected_exception=UnknownModelTargetDatasetError,
- ) as exc:
- await async_model_target_client.get_dataset_status(
- dataset_uuid=dataset_uuid,
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- assert exc.value.response.status_code == HTTPStatus.NOT_FOUND
- assert dataset_uuid in exc.value.message
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_download(
- *,
- async_model_target_client: AsyncModelTargetService,
- ) -> None:
- """An exception is raised for an unknown dataset."""
- with pytest.raises(expected_exception=UnknownModelTargetDatasetError):
- await async_model_target_client.download_dataset(
- dataset_uuid=uuid.uuid4().hex,
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_delete(
- *,
- async_model_target_client: AsyncModelTargetService,
- ) -> None:
- """An exception is raised for an unknown dataset."""
- with pytest.raises(expected_exception=UnknownModelTargetDatasetError):
- await async_model_target_client.delete_dataset(
- dataset_uuid=uuid.uuid4().hex,
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
-
-class TestValidation:
- """Tests for requests which Vuforia rejects."""
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_no_cad_data(
- *,
- async_model_target_client: AsyncModelTargetService,
- ) -> None:
- """A model needs exactly one CAD data source."""
- with pytest.raises(
- expected_exception=ModelTargetValidationError,
- ) as exc:
- await async_model_target_client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[ModelTargetModel(name="model")],
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST
- (detail,) = exc.value.details
- assert detail.code == "VALIDATION_ERROR"
-
-
-class TestGenerationResult:
- """Tests for datasets which Vuforia does not generate cleanly."""
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_generation_failure(
- *,
- model_target_model: ModelTargetModel,
- ) -> None:
- """A dataset which fails to generate reports the failure."""
- message = "Model Target dataset generation failed"
- failure = ModelTargetGenerationFailure(message=message)
- with MockVWS(
- processing_time_seconds=0.2,
- model_target_generation_failure=failure,
- ):
- async with AsyncModelTargetService(
- client_id=_CLIENT_ID,
- client_secret=_CLIENT_CREDENTIALS[1],
- ) as client:
- dataset_uuid = await client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[model_target_model],
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- report = await client.wait_for_dataset_generated(
- dataset_uuid=dataset_uuid,
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- assert report.status == ModelTargetDatasetStatuses.FAILED
- assert report.error is not None
- assert report.error.message == message
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_generation_warning(
- *,
- model_target_model: ModelTargetModel,
- ) -> None:
- """A dataset which generates with a warning reports the
- warning.
- """
- warning = ModelTargetGenerationWarning()
- with MockVWS(
- processing_time_seconds=0.2,
- model_target_generation_warning=warning,
- ):
- async with AsyncModelTargetService(
- client_id=_CLIENT_ID,
- client_secret=_CLIENT_CREDENTIALS[1],
- ) as client:
- dataset_uuid = await client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[model_target_model],
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- report = await client.wait_for_dataset_generated(
- dataset_uuid=dataset_uuid,
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- assert report.status == ModelTargetDatasetStatuses.DONE
- assert report.warning is not None
- assert report.warning.target == dataset_uuid
- (detail,) = report.warning.details
- assert detail.code == "LOW_RECOGNITION_QUALITY"
-
-
-class TestWaitForDatasetGenerated:
- """Tests for waiting for a dataset to be generated."""
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_timeout(*, model_target_model: ModelTargetModel) -> None:
- """An exception is raised when the wait times out."""
- with MockVWS(processing_time_seconds=60):
- async with AsyncModelTargetService(
- client_id=_CLIENT_ID,
- client_secret=_CLIENT_CREDENTIALS[1],
- ) as client:
- dataset_uuid = await client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[model_target_model],
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- with pytest.raises(
- expected_exception=ModelTargetDatasetTimeoutError,
- ):
- await client.wait_for_dataset_generated(
- dataset_uuid=dataset_uuid,
- dataset_type=ModelTargetDatasetType.STANDARD,
- seconds_between_requests=0.01,
- timeout_seconds=0.05,
- )
-
- report = await client.get_dataset_status(
- dataset_uuid=dataset_uuid,
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- assert report.status == ModelTargetDatasetStatuses.PROCESSING
diff --git a/tests/test_async_query.py b/tests/test_async_query.py
index 9d9e1dbdc..200a4d4fa 100644
--- a/tests/test_async_query.py
+++ b/tests/test_async_query.py
@@ -17,6 +17,9 @@
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."""
@@ -48,231 +51,207 @@ async def __call__(
)
-if TYPE_CHECKING:
- import io
-
-
-class TestQuery:
- """Tests for making async image queries."""
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_no_matches(
- *,
- async_cloud_reco_client: AsyncCloudRecoService,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """An empty list is returned if there are no matches."""
- result = await async_cloud_reco_client.query(
- image=image,
- )
- assert result == []
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_match(
- *,
- async_vws_client: AsyncVWS,
- async_cloud_reco_client: AsyncCloudRecoService,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """Details of matching targets are returned."""
- 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
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_server_error(*, image: io.BytesIO | BinaryIO) -> None:
- """Server errors are exposed through the public async client."""
- 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)
-
-
-class TestCustomBaseVWQURL:
- """Tests for using a custom base VWQ URL."""
+@pytest.mark.asyncio
+async def test_no_matches(
+ *,
+ async_cloud_reco_client: AsyncCloudRecoService,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """An empty list is returned if there are no matches."""
+ result = await async_cloud_reco_client.query(
+ image=image,
+ )
+ assert result == []
- @staticmethod
- @pytest.mark.asyncio
- async def test_custom_base_url(
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """It is possible to query a target in a database under
- a custom VWQ URL.
- """
- 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,
- )
+@pytest.mark.asyncio
+async def test_match(
+ *,
+ async_vws_client: AsyncVWS,
+ async_cloud_reco_client: AsyncCloudRecoService,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """Details of matching targets are returned."""
+ 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
- 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,
- )
+@pytest.mark.asyncio
+async def test_server_error(*, image: io.BytesIO | BinaryIO) -> None:
+ """Server errors are exposed through the public async client."""
+ client = AsyncCloudRecoService(
+ client_access_key="access-key",
+ client_secret_key=secrets.token_hex(),
+ transport=_ServerErrorTransport(),
+ )
- matches = await async_cloud_reco_client.query(
- image=image,
- )
- assert len(matches) == 1
- match = matches[0]
- assert match.target_id == target_id
+ with pytest.raises(expected_exception=ServerError):
+ _ = await client.query(image=image)
-class TestMaxNumResults:
- """Tests for the ``max_num_results`` parameter of
- ``query``.
+@pytest.mark.asyncio
+async def test_custom_base_url(
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """It is possible to query a target in a database under
+ a custom VWQ URL.
"""
+ 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,
+ )
- @staticmethod
- @pytest.mark.asyncio
- async def test_custom(
- *,
- async_vws_client: AsyncVWS,
- async_cloud_reco_client: AsyncCloudRecoService,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """It is possible to set a custom
- ``max_num_results``.
- """
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,
+ 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.wait_for_target_processed(
- target_id=target_id_2,
- )
- await async_vws_client.wait_for_target_processed(
- target_id=target_id_3,
+
+ 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,
)
- 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
+ assert len(matches) == 1
+ match = matches[0]
+ assert match.target_id == target_id
-class TestIncludeTargetData:
- """Tests for the ``include_target_data`` parameter of
- ``query``.
+@pytest.mark.asyncio
+async def test_custom(
+ *,
+ async_vws_client: AsyncVWS,
+ async_cloud_reco_client: AsyncCloudRecoService,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """It is possible to set a custom
+ ``max_num_results``.
"""
+ 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
- @staticmethod
- @pytest.mark.asyncio
- async def test_none(
- *,
- async_vws_client: AsyncVWS,
- async_cloud_reco_client: AsyncCloudRecoService,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """When ``CloudRecoIncludeTargetData.NONE`` is given,
- target data is not returned in any match.
- """
- 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
- @staticmethod
- @pytest.mark.asyncio
- async def test_all(
- *,
- async_vws_client: AsyncVWS,
- async_cloud_reco_client: AsyncCloudRecoService,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """When ``CloudRecoIncludeTargetData.ALL`` is given,
- target data is returned in all matches.
- """
- 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
+@pytest.mark.asyncio
+async def test_none(
+ *,
+ async_vws_client: AsyncVWS,
+ async_cloud_reco_client: AsyncCloudRecoService,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """When ``CloudRecoIncludeTargetData.NONE`` is given,
+ target data is not returned in any match.
+ """
+ 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
+async def test_all(
+ *,
+ async_vws_client: AsyncVWS,
+ async_cloud_reco_client: AsyncCloudRecoService,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """When ``CloudRecoIncludeTargetData.ALL`` is given,
+ target data is returned in all matches.
+ """
+ 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.py b/tests/test_async_vws.py
deleted file mode 100644
index 7b4e4fbb5..000000000
--- a/tests/test_async_vws.py
+++ /dev/null
@@ -1,735 +0,0 @@
-"""Tests for async helper functions for managing a Vuforia database."""
-
-from __future__ import annotations
-
-import base64
-import calendar
-import time
-import uuid
-from http import HTTPStatus
-from typing import TYPE_CHECKING, BinaryIO
-
-import pytest
-from mock_vws import MockVWS
-from mock_vws.database import CloudDatabase
-
-from vws import AsyncCloudRecoService, AsyncVuMarkService, AsyncVWS
-from vws.exceptions.custom_exceptions import (
- DatabaseIdNotSetError,
- RecoCountsReportDownloadError,
- RecoCountsReportNotReadyError,
- RecoCountsReportTimeoutError,
- TargetProcessingTimeoutError,
-)
-from vws.exceptions.vws_exceptions import (
- AuthenticationFailureError,
- FailError,
-)
-from vws.reports import (
- DatabaseSummaryReport,
- TargetRecord,
- TargetStatuses,
-)
-from vws.response import Response
-from vws.vumark_accept import VuMarkAccept
-
-if TYPE_CHECKING:
- import datetime
- import io
-
-
-class TestAddTarget:
- """Tests for adding a target."""
-
- @staticmethod
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- argnames="application_metadata",
- argvalues=[None, b"a"],
- )
- @pytest.mark.parametrize(
- argnames="active_flag",
- argvalues=[True, False],
- )
- async def test_add_target(
- *,
- async_vws_client: AsyncVWS,
- image: io.BytesIO | BinaryIO,
- application_metadata: bytes | None,
- async_cloud_reco_client: AsyncCloudRecoService,
- active_flag: bool,
- ) -> None:
- """No exception is raised when adding one target."""
- 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 == []
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_add_two_targets(
- *,
- async_vws_client: AsyncVWS,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """No exception is raised when adding two targets with
- different names.
-
- This demonstrates that the image seek position is not
- changed.
- """
- for name in ("a", "b"):
- await async_vws_client.add_target(
- name=name,
- width=1,
- image=image,
- active_flag=True,
- application_metadata=None,
- )
-
-
-class TestCustomBaseVWSURL:
- """Tests for using a custom base VWS URL."""
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_custom_base_url(
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """It is possible to add a target to a database under a
- custom VWS URL.
- """
- 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,
- )
-
-
-class TestListTargets:
- """Tests for listing targets."""
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_list_targets(
- *,
- async_vws_client: AsyncVWS,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """It is possible to get a list of target IDs."""
- 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])
-
-
-class TestDelete:
- """Test for deleting a target."""
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_delete_target(
- *,
- async_vws_client: AsyncVWS,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """It is possible to delete a target."""
- 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 == []
-
-
-class TestGetTargetSummaryReport:
- """Tests for getting a summary report for a target."""
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_get_target_summary_report(
- *,
- async_vws_client: AsyncVWS,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """Details of a target are returned by
- ``get_target_summary_report``.
- """
- 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
-
-
-class TestGetDatabaseSummaryReport:
- """Tests for getting a summary report for a database."""
-
- @staticmethod
- @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
-
-
-class TestGetTargetRecord:
- """Tests for getting a record of a target."""
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_get_target_record(
- *,
- async_vws_client: AsyncVWS,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """Details of a target are returned by
- ``get_target_record``.
- """
- 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
-
-
-class TestWaitForTargetProcessed:
- """Tests for waiting for a target to be processed."""
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_wait_for_target_processed(
- *,
- async_vws_client: AsyncVWS,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """It is possible to wait until a target is processed."""
- 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
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_custom_timeout(
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """It is possible to set a maximum timeout."""
- 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,
- )
-
-
-class TestGetDuplicateTargets:
- """Tests for getting duplicate targets."""
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_get_duplicate_targets(
- *,
- async_vws_client: AsyncVWS,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """It is possible to get the IDs of similar targets."""
- 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]
-
-
-class TestUpdateTarget:
- """Tests for updating a target."""
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_update_target(
- *,
- async_vws_client: AsyncVWS,
- async_cloud_reco_client: AsyncCloudRecoService,
- image: io.BytesIO | BinaryIO,
- different_high_quality_image: io.BytesIO,
- ) -> None:
- """It is possible to update a target."""
- 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
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_no_fields_given(
- *,
- async_vws_client: AsyncVWS,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """It is possible to give no update fields."""
- 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,
- )
-
-
-class _ForbiddenDownloadTransport:
- """An async transport which refuses to serve a report, as an expired
- URL would.
- """
-
- async def aclose(self) -> None:
- """Close the transport."""
-
- async def __call__(
- self,
- *,
- method: str,
- url: str,
- headers: dict[str, str],
- data: bytes,
- request_timeout: float | tuple[float, float],
- ) -> Response:
- """Return a "forbidden" response."""
- del method, headers, request_timeout
- body = "AccessDenied"
- return Response(
- text=body,
- url=url,
- status_code=HTTPStatus.FORBIDDEN,
- headers={},
- request_body=data,
- tell_position=0,
- content=body.encode(encoding="utf-8"),
- )
-
-
-class TestRecoCountsReport:
- """Tests for database reco counts reports."""
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_reco_counts_report(
- *,
- async_vws_client: AsyncVWS,
- report_month: datetime.date,
- ) -> None:
- """A report can be requested, waited for and downloaded."""
- client = async_vws_client
- report_request = await client.request_database_reco_counts_report(
- year=report_month.year,
- month=calendar.Month(value=report_month.month),
- )
- assert 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")
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_not_ready(*, current_month: datetime.date) -> None:
- """Downloading a report before Vuforia has generated it raises an
- error.
- """
- with MockVWS(processing_time_seconds=60) as mock:
- database = CloudDatabase()
- mock.add_cloud_database(cloud_database=database)
- async with AsyncVWS(
- server_access_key=database.server_access_key,
- server_secret_key=database.server_secret_key,
- database_id=database.database_id,
- ) as client:
- report_request = (
- await client.request_database_reco_counts_report(
- year=current_month.year,
- month=calendar.Month(value=current_month.month),
- )
- )
-
- with pytest.raises(
- expected_exception=RecoCountsReportNotReadyError,
- ) as exc:
- await client.download_reco_counts_report(
- presigned_url=report_request.presigned_url,
- )
-
- assert exc.value.response.status_code == HTTPStatus.NOT_FOUND
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_wait_timeout(*, current_month: datetime.date) -> None:
- """Waiting for a report which is not generated in time raises an
- error.
- """
- with MockVWS(processing_time_seconds=60) as mock:
- database = CloudDatabase()
- mock.add_cloud_database(cloud_database=database)
- async with AsyncVWS(
- server_access_key=database.server_access_key,
- server_secret_key=database.server_secret_key,
- database_id=database.database_id,
- ) as client:
- report_request = (
- await client.request_database_reco_counts_report(
- year=current_month.year,
- month=calendar.Month(value=current_month.month),
- )
- )
-
- maximum_wait_seconds = 5
- start_time = time.monotonic()
-
- with pytest.raises(
- expected_exception=RecoCountsReportTimeoutError,
- ):
- await client.wait_for_reco_counts_report(
- presigned_url=report_request.presigned_url,
- seconds_between_requests=0.01,
- timeout_seconds=0.05,
- )
-
- elapsed_time = time.monotonic() - start_time
- assert elapsed_time < maximum_wait_seconds
-
- @staticmethod
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- argnames=("year", "month"),
- argvalues=[
- pytest.param(1999, calendar.Month.JANUARY, id="year-in-the-past"),
- pytest.param(
- 1999,
- calendar.Month.DECEMBER,
- id="year-in-the-past-december",
- ),
- ],
- )
- async def test_month_not_accepted(
- *,
- async_vws_client: AsyncVWS,
- year: int,
- month: calendar.Month,
- ) -> None:
- """Months other than the current and previous month are
- rejected.
- """
- with pytest.raises(expected_exception=FailError) as exc:
- await async_vws_client.request_database_reco_counts_report(
- year=year,
- month=month,
- )
-
- assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_database_id_does_not_match_keys(
- *,
- current_month: datetime.date,
- ) -> None:
- """A database ID which does not match the given keys is
- rejected.
- """
- with MockVWS() as mock:
- database = CloudDatabase()
- mock.add_cloud_database(cloud_database=database)
- async with AsyncVWS(
- server_access_key=database.server_access_key,
- server_secret_key=database.server_secret_key,
- database_id=uuid.uuid4().hex,
- ) as client:
- with pytest.raises(
- expected_exception=AuthenticationFailureError,
- ) as exc:
- await client.request_database_reco_counts_report(
- year=current_month.year,
- month=calendar.Month(value=current_month.month),
- )
-
- assert (
- exc.value.response.status_code == HTTPStatus.UNAUTHORIZED
- )
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_download_error() -> None:
- """An error response from the report's URL raises an error."""
- async with AsyncVWS(
- server_access_key=uuid.uuid4().hex,
- server_secret_key=uuid.uuid4().hex,
- transport=_ForbiddenDownloadTransport(),
- ) as client:
- with pytest.raises(
- expected_exception=RecoCountsReportDownloadError,
- ) as exc:
- await client.download_reco_counts_report(
- presigned_url="https://example.com/reports/recoCounts/x",
- )
-
- assert exc.value.response.status_code == HTTPStatus.FORBIDDEN
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_no_database_id(*, current_month: datetime.date) -> None:
- """A client which was given no database ID cannot request a
- report.
- """
- async with AsyncVWS(
- server_access_key=uuid.uuid4().hex,
- server_secret_key=uuid.uuid4().hex,
- ) as client:
- with pytest.raises(expected_exception=DatabaseIdNotSetError):
- await client.request_database_reco_counts_report(
- year=current_month.year,
- month=calendar.Month(value=current_month.month),
- )
-
-
-class TestGenerateVumarkInstance:
- """Tests for generating VuMark instances."""
-
- @staticmethod
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- argnames=("accept", "expected_prefix"),
- argvalues=[
- pytest.param(
- VuMarkAccept.PNG,
- b"\x89PNG\r\n\x1a\n",
- id="png",
- ),
- pytest.param(
- VuMarkAccept.SVG,
- b"<",
- id="svg",
- ),
- pytest.param(
- VuMarkAccept.PDF,
- b"%PDF",
- id="pdf",
- ),
- ],
- )
- async def test_generate_vumark_instance(
- *,
- async_vumark_service_client: AsyncVuMarkService,
- vumark_target_id: str,
- accept: VuMarkAccept,
- expected_prefix: bytes,
- ) -> None:
- """The returned bytes match the requested format."""
- result = await async_vumark_service_client.generate_vumark_instance(
- target_id=vumark_target_id,
- instance_id="12345",
- accept=accept,
- )
- assert result.startswith(expected_prefix)
diff --git a/tests/test_model_targets.py b/tests/test_model_targets.py
deleted file mode 100644
index 4c55e40dc..000000000
--- a/tests/test_model_targets.py
+++ /dev/null
@@ -1,831 +0,0 @@
-"""Tests for the Model Target Web API client."""
-
-import io
-import json
-import secrets
-import uuid
-import zipfile
-from http import HTTPStatus
-
-import pytest
-from beartype import beartype
-from freezegun import freeze_time
-from mock_vws import (
- MockVWS,
- ModelTargetFailureResponse,
- ModelTargetGenerationFailure,
- ModelTargetGenerationWarning,
-)
-
-from vws import ModelTargetService
-from vws._model_targets import access_token_from_response
-from vws.exceptions.custom_exceptions import ServerError
-from vws.exceptions.model_target_exceptions import (
- ModelTargetAuthenticationError,
- ModelTargetDatasetNotDoneError,
- ModelTargetDatasetTimeoutError,
- ModelTargetError,
- ModelTargetOAuth2Error,
- ModelTargetValidationError,
- UnknownModelTargetDatasetError,
-)
-from vws.exceptions.vws_exceptions import TooManyRequestsError
-from vws.model_target_datasets import (
- CadDataFormat,
- GuideViewPosition,
- ModelTargetDatasetType,
- ModelTargetModel,
- ModelTargetView,
- RealisticAppearance,
-)
-from vws.reports import ModelTargetDatasetStatuses
-from vws.response import Response
-from vws.transports import RequestsTransport, Transport
-
-# The mock accepts one hard-coded pair of Model Target Web API OAuth2
-# credentials, which it does not expose.
-_CLIENT_ID = "client-id"
-_CLIENT_CREDENTIALS = ("client-id", "client-secret")
-
-_DATASET_TYPES = [
- ModelTargetDatasetType.STANDARD,
- ModelTargetDatasetType.ADVANCED,
-]
-
-
-@beartype
-def _response_with_status(
- *,
- text: str,
- status_code: HTTPStatus,
-) -> Response:
- """Get a response with a given body.
-
- Args:
- text: The body of the response.
- status_code: The response status code.
-
- Returns:
- A response with the given body.
- """
- content = text.encode(encoding="utf-8")
- return Response(
- text=text,
- url="https://vws.vuforia.com/modeltargets/datasets",
- status_code=status_code,
- headers={},
- request_body=None,
- tell_position=len(content),
- content=content,
- )
-
-
-@beartype
-def _response(*, text: str) -> Response:
- """Get a bad-request response with a given body."""
- return _response_with_status(
- text=text,
- status_code=HTTPStatus.BAD_REQUEST,
- )
-
-
-@beartype
-class _CountingTransport:
- """A transport which counts the requests made to each path."""
-
- def __init__(self, *, transport: Transport) -> None:
- """
- Args:
- transport: The transport to make requests with.
- """
- self._transport = transport
- self.urls: list[str] = []
-
- def close(self) -> None:
- """Close the wrapped transport."""
- self._transport.close()
-
- def __call__(
- self,
- *,
- method: str,
- url: str,
- headers: dict[str, str],
- data: bytes,
- request_timeout: float | tuple[float, float],
- ) -> Response:
- """Make a request, recording the URL.
-
- Args:
- method: The HTTP method.
- url: The full URL.
- headers: Request headers.
- data: The request body.
- request_timeout: The request timeout.
-
- Returns:
- A Response populated from the HTTP response.
- """
- self.urls.append(url)
- return self._transport(
- method=method,
- url=url,
- headers=headers,
- data=data,
- request_timeout=request_timeout,
- )
-
-
-class TestAccessToken:
- """Tests for getting an access token."""
-
- @staticmethod
- @pytest.mark.usefixtures("_mock_model_targets")
- def test_token_is_a_bearer_token() -> None:
- """An access token is given for valid credentials."""
- client = ModelTargetService(
- client_id=_CLIENT_ID,
- client_secret=_CLIENT_CREDENTIALS[1],
- )
-
- assert bool(client.get_access_token())
-
- @staticmethod
- @pytest.mark.usefixtures("_mock_model_targets")
- def test_token_is_reused(
- *,
- model_target_model: ModelTargetModel,
- ) -> None:
- """One access token is used for multiple requests."""
- transport = _CountingTransport(transport=RequestsTransport())
- client = ModelTargetService(
- client_id=_CLIENT_ID,
- client_secret=_CLIENT_CREDENTIALS[1],
- transport=transport,
- )
-
- for _ in range(2):
- _ = client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[model_target_model],
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- token_urls = [url for url in transport.urls if "oauth2" in url]
- assert len(token_urls) == 1
- transport.close()
-
- @staticmethod
- @pytest.mark.usefixtures("_mock_model_targets")
- def test_expired_token_is_replaced(
- *,
- model_target_model: ModelTargetModel,
- ) -> None:
- """A new access token is requested once the old one expires."""
- transport = _CountingTransport(transport=RequestsTransport())
- client = ModelTargetService(
- client_id=_CLIENT_ID,
- client_secret=_CLIENT_CREDENTIALS[1],
- transport=transport,
- )
-
- with freeze_time(time_to_freeze="2026-01-01") as frozen_time:
- _ = client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[model_target_model],
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
- # Mock tokens last an hour.
- _ = frozen_time.tick(delta=60 * 60 + 1)
- _ = client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[model_target_model],
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- token_urls = [url for url in transport.urls if "oauth2" in url]
- expected_token_request_count = 2
- assert len(token_urls) == expected_token_request_count
-
- @staticmethod
- @pytest.mark.usefixtures("_mock_model_targets")
- def test_invalid_credentials() -> None:
- """An exception is raised when the credentials are not known."""
- client = ModelTargetService(
- client_id="not-a-client-id",
- client_secret=secrets.token_hex(),
- )
-
- with pytest.raises(
- expected_exception=ModelTargetOAuth2Error,
- ) as exc:
- _ = client.get_access_token()
-
- assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED
- assert exc.value.error == "invalid_client"
- assert not bool(exc.value.error_description)
-
- @staticmethod
- @pytest.mark.parametrize(
- argnames=("status_code", "body", "expected_exception"),
- argvalues=[
- pytest.param(
- HTTPStatus.UNAUTHORIZED,
- '{"error":{"code":"AUTHENTICATION_ERROR","message":"No"}}',
- ModelTargetAuthenticationError,
- id="authentication",
- ),
- pytest.param(
- HTTPStatus.FORBIDDEN,
- '{"error":{"code":"FORBIDDEN","message":"Denied"}}',
- ModelTargetError,
- id="generic-json",
- ),
- pytest.param(
- HTTPStatus.CONFLICT,
- "not json",
- ModelTargetError,
- id="generic-non-json",
- ),
- pytest.param(
- HTTPStatus.TOO_MANY_REQUESTS,
- "rate limited",
- TooManyRequestsError,
- id="rate-limit",
- ),
- pytest.param(
- HTTPStatus.BAD_GATEWAY,
- "server error",
- ServerError,
- id="server-error",
- ),
- ],
- )
- def test_dataset_error_response(
- *,
- model_target_model: ModelTargetModel,
- status_code: HTTPStatus,
- body: str,
- expected_exception: (
- type[ModelTargetError | TooManyRequestsError | ServerError]
- ),
- ) -> None:
- """Dataset failures map to exceptions through the mock."""
- failure = ModelTargetFailureResponse(
- status_code=status_code,
- body=body,
- )
- client = ModelTargetService(
- client_id=_CLIENT_ID,
- client_secret=_CLIENT_CREDENTIALS[1],
- )
-
- with (
- MockVWS(model_target_failure_response=failure),
- pytest.raises(expected_exception=expected_exception) as exc,
- ):
- _ = client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[model_target_model],
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- assert isinstance(exc.value, expected_exception)
- assert exc.value.response.status_code == status_code
- assert exc.value.response.text == body
-
-
-class TestDatasetLifecycle:
- """Tests for the dataset lifecycle."""
-
- @staticmethod
- @pytest.mark.parametrize(
- argnames="dataset_type",
- argvalues=_DATASET_TYPES,
- )
- def test_create_wait_download_delete(
- *,
- model_target_client: ModelTargetService,
- model_target_model: ModelTargetModel,
- dataset_type: ModelTargetDatasetType,
- ) -> None:
- """A dataset can be created, downloaded and then deleted."""
- dataset_uuid = model_target_client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[model_target_model],
- dataset_type=dataset_type,
- )
-
- report = model_target_client.wait_for_dataset_generated(
- dataset_uuid=dataset_uuid,
- dataset_type=dataset_type,
- )
-
- assert report.status == ModelTargetDatasetStatuses.DONE
- assert report.dataset_uuid == dataset_uuid
- assert report.completed_at is not None
- assert report.completed_at >= report.created_at
- assert report.eta is None
- assert report.error is None
- assert report.warning is None
-
- dataset = model_target_client.download_dataset(
- dataset_uuid=dataset_uuid,
- dataset_type=dataset_type,
- )
-
- with zipfile.ZipFile(
- file=io.BytesIO(initial_bytes=dataset)
- ) as archive:
- assert archive.namelist() == ["MTDataset.dat", "MTDataset.xml"]
-
- model_target_client.delete_dataset(
- dataset_uuid=dataset_uuid,
- dataset_type=dataset_type,
- )
-
- with pytest.raises(expected_exception=UnknownModelTargetDatasetError):
- _ = model_target_client.get_dataset_status(
- dataset_uuid=dataset_uuid,
- dataset_type=dataset_type,
- )
-
- @staticmethod
- def test_status_while_processing(
- *,
- model_target_client: ModelTargetService,
- model_target_model: ModelTargetModel,
- ) -> None:
- """A processing dataset has an estimated completion time."""
- dataset_uuid = model_target_client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[model_target_model],
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- report = model_target_client.get_dataset_status(
- dataset_uuid=dataset_uuid,
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- assert report.status == ModelTargetDatasetStatuses.PROCESSING
- assert report.eta is not None
- assert report.eta >= report.created_at
- assert report.completed_at is None
-
- @staticmethod
- def test_download_while_processing(
- *,
- model_target_client: ModelTargetService,
- model_target_model: ModelTargetModel,
- ) -> None:
- """A dataset cannot be downloaded before it is generated."""
- dataset_uuid = model_target_client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[model_target_model],
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- with pytest.raises(
- expected_exception=ModelTargetDatasetNotDoneError,
- ) as exc:
- _ = model_target_client.download_dataset(
- dataset_uuid=dataset_uuid,
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- assert (
- exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
- )
- assert exc.value.code == "UNSUPPORTED_STATE"
- assert exc.value.target == dataset_uuid
-
- @staticmethod
- def test_dataset_is_visible_to_other_type(
- *,
- model_target_client: ModelTargetService,
- model_target_model: ModelTargetModel,
- ) -> None:
- """Standard and advanced routes share datasets by UUID."""
- dataset_uuid = model_target_client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[model_target_model],
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- report = model_target_client.get_dataset_status(
- dataset_uuid=dataset_uuid,
- dataset_type=ModelTargetDatasetType.ADVANCED,
- )
-
- assert report.dataset_uuid == dataset_uuid
-
- @staticmethod
- def test_advanced_dataset_takes_multiple_models(
- *,
- model_target_client: ModelTargetService,
- model_target_model: ModelTargetModel,
- ) -> None:
- """An advanced dataset can be generated from multiple models."""
- other_model = ModelTargetModel(
- name="other-model",
- cad_data_blob="ZmFrZS1jYWQtZGF0YQ==",
- cad_data_format=CadDataFormat.GLB,
- realistic_appearance=RealisticAppearance.TRUE,
- views=[],
- )
-
- dataset_uuid = model_target_client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[model_target_model, other_model],
- dataset_type=ModelTargetDatasetType.ADVANCED,
- )
-
- assert bool(dataset_uuid)
-
- @staticmethod
- def test_state_based_model(
- *,
- model_target_client: ModelTargetService,
- ) -> None:
- """A State-Based Model Target dataset can be created."""
- configuration = json.dumps(obj={"states": {"open": {}, "closed": {}}})
- model = ModelTargetModel(
- name="model",
- cad_data_url="https://example.com/model.zip",
- cad_data_format=CadDataFormat.ZIP,
- state_based_configuration_json_string=configuration,
- views=[
- ModelTargetView(
- name="front",
- guide_view_position=GuideViewPosition(
- rotation=[0.0, 0.0, 0.0, 1.0],
- translation=[0.0, 0.0, 1.0],
- ),
- states=["open"],
- ),
- ],
- )
-
- assert bool(
- model_target_client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[model],
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
- )
-
-
-class TestUnknownDataset:
- """Tests for requests for datasets which do not exist."""
-
- @staticmethod
- def test_get_status(*, model_target_client: ModelTargetService) -> None:
- """An exception is raised for an unknown dataset."""
- dataset_uuid = uuid.uuid4().hex
- with pytest.raises(
- expected_exception=UnknownModelTargetDatasetError,
- ) as exc:
- _ = model_target_client.get_dataset_status(
- dataset_uuid=dataset_uuid,
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- assert exc.value.response.status_code == HTTPStatus.NOT_FOUND
- assert exc.value.code == "NOT_FOUND"
- assert dataset_uuid in exc.value.message
-
- @staticmethod
- def test_download(*, model_target_client: ModelTargetService) -> None:
- """An exception is raised for an unknown dataset."""
- with pytest.raises(expected_exception=UnknownModelTargetDatasetError):
- _ = model_target_client.download_dataset(
- dataset_uuid=uuid.uuid4().hex,
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- @staticmethod
- def test_delete(*, model_target_client: ModelTargetService) -> None:
- """An exception is raised for an unknown dataset."""
- with pytest.raises(expected_exception=UnknownModelTargetDatasetError):
- model_target_client.delete_dataset(
- dataset_uuid=uuid.uuid4().hex,
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
-
-class TestValidation:
- """Tests for requests which Vuforia rejects."""
-
- @staticmethod
- def test_no_cad_data(
- *,
- model_target_client: ModelTargetService,
- ) -> None:
- """A model needs exactly one CAD data source."""
- with pytest.raises(
- expected_exception=ModelTargetValidationError,
- ) as exc:
- _ = model_target_client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[ModelTargetModel(name="model", views=[])],
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST
- assert exc.value.code == "BAD_REQUEST"
- (detail,) = exc.value.details
- assert detail.code == "VALIDATION_ERROR"
- assert "cadDataUrl" in detail.message
-
- @staticmethod
- def test_two_cad_data_sources(
- *,
- model_target_client: ModelTargetService,
- ) -> None:
- """A model cannot give two CAD data sources."""
- model = ModelTargetModel(
- name="model",
- cad_data_url="https://example.com/model.zip",
- cad_data_blob="ZmFrZS1jYWQtZGF0YQ==",
- )
-
- with pytest.raises(expected_exception=ModelTargetValidationError):
- _ = model_target_client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[model],
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- @staticmethod
- def test_two_models_in_a_standard_dataset(
- *,
- model_target_client: ModelTargetService,
- model_target_model: ModelTargetModel,
- ) -> None:
- """A standard dataset takes exactly one model."""
- with pytest.raises(
- expected_exception=ModelTargetValidationError,
- ) as exc:
- _ = model_target_client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[model_target_model, model_target_model],
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- (detail,) = exc.value.details
- assert detail.message == "exactly one model should be provided"
-
-
-class TestGenerationResult:
- """Tests for datasets which Vuforia does not generate cleanly."""
-
- @staticmethod
- def test_generation_failure(
- *,
- model_target_model: ModelTargetModel,
- ) -> None:
- """A dataset which fails to generate reports the failure."""
- message = "Model Target dataset generation failed"
- failure = ModelTargetGenerationFailure(message=message)
- with MockVWS(
- processing_time_seconds=0.2,
- model_target_generation_failure=failure,
- ):
- client = ModelTargetService(
- client_id=_CLIENT_ID,
- client_secret=_CLIENT_CREDENTIALS[1],
- )
- dataset_uuid = client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[model_target_model],
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- report = client.wait_for_dataset_generated(
- dataset_uuid=dataset_uuid,
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- assert report.status == ModelTargetDatasetStatuses.FAILED
- assert report.error is not None
- assert report.error.message == message
- assert report.warning is None
-
- with pytest.raises(
- expected_exception=ModelTargetDatasetNotDoneError,
- ):
- _ = client.download_dataset(
- dataset_uuid=dataset_uuid,
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- @staticmethod
- def test_generation_warning(
- *,
- model_target_model: ModelTargetModel,
- ) -> None:
- """A dataset which generates with a warning reports the
- warning.
- """
- warning = ModelTargetGenerationWarning()
- with MockVWS(
- processing_time_seconds=0.2,
- model_target_generation_warning=warning,
- ):
- client = ModelTargetService(
- client_id=_CLIENT_ID,
- client_secret=_CLIENT_CREDENTIALS[1],
- )
- dataset_uuid = client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[model_target_model],
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- report = client.wait_for_dataset_generated(
- dataset_uuid=dataset_uuid,
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- assert report.status == ModelTargetDatasetStatuses.DONE
- assert report.error is None
- assert report.warning is not None
- assert report.warning.message == warning.message
- assert report.warning.target == dataset_uuid
- (detail,) = report.warning.details
- assert detail.code == "LOW_RECOGNITION_QUALITY"
-
- assert bool(
- client.download_dataset(
- dataset_uuid=dataset_uuid,
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
- )
-
-
-class TestWaitForDatasetGenerated:
- """Tests for waiting for a dataset to be generated."""
-
- @staticmethod
- def test_timeout(*, model_target_model: ModelTargetModel) -> None:
- """An exception is raised when the wait times out."""
- with MockVWS(processing_time_seconds=60):
- client = ModelTargetService(
- client_id=_CLIENT_ID,
- client_secret=_CLIENT_CREDENTIALS[1],
- )
- dataset_uuid = client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[model_target_model],
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- with pytest.raises(
- expected_exception=ModelTargetDatasetTimeoutError,
- ):
- _ = client.wait_for_dataset_generated(
- dataset_uuid=dataset_uuid,
- dataset_type=ModelTargetDatasetType.STANDARD,
- seconds_between_requests=0.01,
- timeout_seconds=0.05,
- )
-
-
-class TestErrorEnvelope:
- """Tests for reading responses which are not shaped like Model Target
- Web API errors.
- """
-
- @staticmethod
- @pytest.mark.parametrize(
- argnames="text",
- argvalues=[
- "",
- "Not JSON",
- "[]",
- "{}",
- '{"error": "not-an-object"}',
- '{"transaction_id": "abc", "result_code": "Fail"}',
- ],
- )
- def test_unknown_error_shape(*, text: str) -> None:
- """An error without a Model Target error object gives empty
- values.
- """
- error = ModelTargetError(response=_response(text=text))
-
- assert not bool(error.code)
- assert not bool(error.message)
- assert not bool(error.target)
- assert not bool(error.details)
-
- @staticmethod
- def test_error_without_details() -> None:
- """An error which gives no details has no details."""
- text = json.dumps(obj={"error": {"code": "ERROR", "message": "No"}})
- error = ModelTargetError(response=_response(text=text))
-
- assert error.code == "ERROR"
- assert error.message == "No"
- assert not bool(error.target)
- assert not bool(error.details)
-
- @staticmethod
- @pytest.mark.parametrize(
- argnames="text",
- argvalues=["Not JSON", "[]", "{}"],
- )
- def test_unknown_oauth2_error_shape(*, text: str) -> None:
- """An OAuth2 error without an error code gives empty values."""
- error = ModelTargetOAuth2Error(response=_response(text=text))
-
- assert not bool(error.error)
- assert not bool(error.error_description)
-
- @staticmethod
- def test_oauth2_error_description() -> None:
- """An OAuth2 error description is given when Vuforia gives one."""
- description = "Missing or invalid authorization header"
- text = json.dumps(
- obj={
- "error": "invalid_request",
- "error_description": description,
- },
- )
- error = ModelTargetOAuth2Error(response=_response(text=text))
-
- assert error.error == "invalid_request"
- assert error.error_description == description
-
-
-@pytest.mark.parametrize(
- argnames="payload",
- argvalues=[
- {"access_token": 1, "expires_in": 3600},
- {"access_token": "token", "expires_in": []},
- ],
-)
-def test_invalid_oauth2_token_response_values(
- *, payload: dict[str, object]
-) -> None:
- """OAuth token responses must contain correctly typed values."""
- response = _response_with_status(
- text=json.dumps(obj=payload),
- status_code=HTTPStatus.OK,
- )
-
- with pytest.raises(expected_exception=ModelTargetOAuth2Error):
- _ = access_token_from_response(response=response)
-
-
-class TestBaseVWSURL:
- """Tests for using a custom base URL."""
-
- @staticmethod
- def test_custom_base_url(
- *,
- model_target_model: ModelTargetModel,
- ) -> None:
- """The Model Target Web API can be served from a URL with a
- path.
- """
- base_vws_url = "https://example.com/vws"
- with MockVWS(
- base_vws_url=base_vws_url,
- processing_time_seconds=0.2,
- ):
- client = ModelTargetService(
- client_id=_CLIENT_ID,
- client_secret=_CLIENT_CREDENTIALS[1],
- base_vws_url=base_vws_url,
- )
- dataset_uuid = client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[model_target_model],
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- report = client.get_dataset_status(
- dataset_uuid=dataset_uuid,
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- assert report.dataset_uuid == dataset_uuid
diff --git a/tests/test_query.py b/tests/test_query.py
index 9d0ac2d73..e0a4309bd 100644
--- a/tests/test_query.py
+++ b/tests/test_query.py
@@ -63,455 +63,431 @@ def __call__(
)
-class TestQuery:
- """Tests for making image queries."""
-
- @staticmethod
- def test_no_matches(
- *,
- cloud_reco_client: CloudRecoService,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """An empty list is returned if there are no matches."""
- result = cloud_reco_client.query(image=image)
- assert result == []
-
- @staticmethod
- def test_match(
- *,
- vws_client: VWS,
- cloud_reco_client: CloudRecoService,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """Details of matching targets are returned."""
- 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
-
- @staticmethod
- def test_invalid_results(*, image: io.BytesIO | BinaryIO) -> None:
- """Query results in responses must be a list of objects."""
- 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,
- )
+def test_no_matches(
+ *,
+ cloud_reco_client: CloudRecoService,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """An empty list is returned if there are no matches."""
+ result = cloud_reco_client.query(image=image)
+ assert result == []
+
+
+def test_match(
+ *,
+ vws_client: VWS,
+ cloud_reco_client: CloudRecoService,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """Details of matching targets are returned."""
+ 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
- with pytest.raises(expected_exception=TypeError):
- _ = client.query(image=image)
- @staticmethod
- def test_server_error(*, image: io.BytesIO | BinaryIO) -> None:
- """Server errors are exposed through the public query client."""
- 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)
+def test_invalid_results(*, image: io.BytesIO | BinaryIO) -> None:
+ """Query results in responses must be a list of objects."""
+ 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)
-class TestDefaultRequestTimeout:
- """Tests for the default request timeout."""
- @staticmethod
- @pytest.mark.parametrize(
- argnames=("response_delay_seconds", "expect_timeout"),
- argvalues=[(29, False), (31, True)],
+def test_server_error(*, image: io.BytesIO | BinaryIO) -> None:
+ """Server errors are exposed through the public query client."""
+ transport = _JSONResponseTransport(
+ body={},
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
)
- def test_default_timeout(
- *,
- image: io.BytesIO | BinaryIO,
- response_delay_seconds: int,
- expect_timeout: bool,
- ) -> None:
- """At 29 seconds there is no error; at 31 seconds there is a
- timeout.
- """
- with (
- freeze_time() as frozen_datetime,
- MockVWS(
- response_delay_seconds=response_delay_seconds,
- sleep_fn=lambda seconds: (
- frozen_datetime.tick(
- delta=datetime.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)
-
-
-class TestCustomRequestTimeout:
- """Tests for custom request timeout values."""
-
- @staticmethod
- @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),
- ],
+ client = CloudRecoService(
+ client_access_key="access-key",
+ client_secret_key=secrets.token_hex(),
+ transport=transport,
)
- def test_custom_timeout(
- *,
- image: io.BytesIO | BinaryIO,
- custom_timeout: float | tuple[float, float],
- response_delay_seconds: float,
- expect_timeout: bool,
- ) -> None:
- """Custom timeouts are honored for both float and tuple forms."""
- with (
- freeze_time() as frozen_datetime,
- MockVWS(
- response_delay_seconds=response_delay_seconds,
- sleep_fn=lambda seconds: (
- frozen_datetime.tick(
- delta=datetime.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)
-
-
-class TestCustomBaseVWQURL:
- """Tests for using a custom base VWQ URL."""
-
- @staticmethod
- def test_custom_base_url(image: io.BytesIO | BinaryIO) -> None:
- """
- It is possible to use query a target to a database under a
- custom
- VWQ
- URL.
- """
- 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
- @staticmethod
- def test_custom_base_url_with_path_prefix(
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """
- A base VWQ URL with a path prefix is used as-is, without the
- prefix being dropped.
- """
- 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,
- )
+ with pytest.raises(expected_exception=ServerError):
+ _ = client.query(image=image)
+
+
+@pytest.mark.parametrize(
+ argnames=("response_delay_seconds", "expect_timeout"),
+ argvalues=[(29, False), (31, True)],
+)
+def test_default_timeout(
+ *,
+ image: io.BytesIO | BinaryIO,
+ response_delay_seconds: int,
+ expect_timeout: bool,
+) -> None:
+ """At 29 seconds there is no error; at 31 seconds there is a
+ timeout.
+ """
+ with (
+ freeze_time() as frozen_datetime,
+ MockVWS(
+ response_delay_seconds=response_delay_seconds,
+ sleep_fn=lambda seconds: (
+ frozen_datetime.tick(
+ delta=datetime.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 len(matches) == 1
-
-
-class TestMaxNumResults:
- """Tests for the ``max_num_results`` parameter of ``query``."""
-
- @staticmethod
- def test_default(
- *,
- vws_client: VWS,
- cloud_reco_client: CloudRecoService,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """By default the maximum number of results is 1."""
- 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,
+ 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),
+ ],
+)
+def test_custom_timeout(
+ *,
+ image: io.BytesIO | BinaryIO,
+ custom_timeout: float | tuple[float, float],
+ response_delay_seconds: float,
+ expect_timeout: bool,
+) -> None:
+ """Custom timeouts are honored for both float and tuple forms."""
+ with (
+ freeze_time() as frozen_datetime,
+ MockVWS(
+ response_delay_seconds=response_delay_seconds,
+ sleep_fn=lambda seconds: (
+ frozen_datetime.tick(
+ delta=datetime.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,
)
- 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
- @staticmethod
- def test_custom(
- *,
- vws_client: VWS,
- cloud_reco_client: CloudRecoService,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """It is possible to set a custom ``max_num_results``."""
- 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,
+ 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)
+
+
+def test_custom_base_url(image: io.BytesIO | BinaryIO) -> None:
+ """
+ It is possible to use query a target to a database under a
+ custom
+ VWQ
+ URL.
+ """
+ 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,
)
- assert len(matches) == max_num_results
-
-class TestIncludeTargetData:
- """Tests for the ``include_target_data`` parameter of ``query``."""
-
- @staticmethod
- def test_default(
- *,
- vws_client: VWS,
- cloud_reco_client: CloudRecoService,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """By default, target data is only returned in the top match."""
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,
+ name="x",
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
- @staticmethod
- def test_top(
- *,
- vws_client: VWS,
- cloud_reco_client: CloudRecoService,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """
- When ``CloudRecoIncludeTargetData.TOP`` is given, target data is
- only
- returned in the top match.
- """
- 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
- @staticmethod
- def test_none(
- *,
- vws_client: VWS,
- cloud_reco_client: CloudRecoService,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """
- When ``CloudRecoIncludeTargetData.NONE`` is given, target data
- is
- not
- returned in any match.
- """
- target_id = vws_client.add_target(
- name=uuid.uuid4().hex,
- width=1,
- image=image,
- active_flag=True,
- application_metadata=None,
+ cloud_reco_client = CloudRecoService(
+ client_access_key=database.client_access_key,
+ client_secret_key=database.client_secret_key,
+ base_vwq_url=base_vwq_url,
)
- 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
- @staticmethod
- def test_all(
- *,
- vws_client: VWS,
- cloud_reco_client: CloudRecoService,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """
- When ``CloudRecoIncludeTargetData.ALL`` is given, target data is
- returned in all matches.
- """
- target_id = vws_client.add_target(
- name=uuid.uuid4().hex,
- width=1,
- image=image,
- active_flag=True,
- application_metadata=None,
+ matches = cloud_reco_client.query(image=image)
+ assert len(matches) == 1
+ match = matches[0]
+ assert match.target_id == target_id
+
+
+def test_custom_base_url_with_path_prefix(
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """
+ A base VWQ URL with a path prefix is used as-is, without the
+ prefix being dropped.
+ """
+ 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_2 = vws_client.add_target(
- name=uuid.uuid4().hex,
+ 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.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,
+ cloud_reco_client = CloudRecoService(
+ client_access_key=database.client_access_key,
+ client_secret_key=database.client_secret_key,
+ base_vwq_url=base_vwq_url,
)
- assert top_match.target_data is not None
- assert second_match.target_data is not None
+
+ matches = cloud_reco_client.query(image=image)
+ assert len(matches) == 1
+
+
+def test_max_num_results_default(
+ *,
+ vws_client: VWS,
+ cloud_reco_client: CloudRecoService,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """By default the maximum number of results is 1."""
+ 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
+
+
+def test_custom(
+ *,
+ vws_client: VWS,
+ cloud_reco_client: CloudRecoService,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """It is possible to set a custom ``max_num_results``."""
+ 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
+
+
+def test_include_target_data_default(
+ *,
+ vws_client: VWS,
+ cloud_reco_client: CloudRecoService,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """By default, target data is only returned in the top match."""
+ 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
+
+
+def test_top(
+ *,
+ vws_client: VWS,
+ cloud_reco_client: CloudRecoService,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """
+ When ``CloudRecoIncludeTargetData.TOP`` is given, target data is
+ only
+ returned in the top match.
+ """
+ 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
+
+
+def test_none(
+ *,
+ vws_client: VWS,
+ cloud_reco_client: CloudRecoService,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """
+ When ``CloudRecoIncludeTargetData.NONE`` is given, target data
+ is
+ not
+ returned in any match.
+ """
+ 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
+
+
+def test_all(
+ *,
+ vws_client: VWS,
+ cloud_reco_client: CloudRecoService,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """
+ When ``CloudRecoIncludeTargetData.ALL`` is given, target data is
+ returned in all matches.
+ """
+ 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_transports.py b/tests/test_transports.py
deleted file mode 100644
index 82b9db76d..000000000
--- a/tests/test_transports.py
+++ /dev/null
@@ -1,1232 +0,0 @@
-"""Tests for HTTP transport implementations."""
-
-from __future__ import annotations
-
-import uuid
-from http import HTTPStatus
-from typing import TYPE_CHECKING
-
-import httpx
-import httpx2
-import pytest
-import respx
-from mock_vws import MockVWS
-from mock_vws.database import CloudDatabase, VuMarkDatabase
-from mock_vws.target import VuMarkTarget
-
-from vws import (
- VWS,
- AsyncCloudRecoService,
- AsyncModelTargetService,
- AsyncVuMarkService,
- AsyncVWS,
- CloudRecoService,
- ModelTargetService,
- VuMarkService,
-)
-from vws.exceptions.custom_exceptions import ServerError
-from vws.exceptions.vws_exceptions import TooManyRequestsError
-from vws.model_target_datasets import (
- ModelTargetDatasetType,
- ModelTargetModel,
-)
-from vws.reports import ModelTargetDatasetStatuses, TargetStatuses
-from vws.response import Response
-from vws.transports import (
- AsyncHTTPX2Transport,
- AsyncHTTPXTransport,
- HTTPX2Transport,
- HTTPXTransport,
-)
-from vws.vumark_accept import VuMarkAccept
-
-if TYPE_CHECKING:
- import io
-
-
-class TestHTTPXTransport:
- """Tests for ``HTTPXTransport``."""
-
- @staticmethod
- @respx.mock
- def test_float_timeout() -> None:
- """``HTTPXTransport`` works with a float timeout."""
- route = respx.post(url="https://example.com/test").mock(
- return_value=httpx.Response(
- status_code=HTTPStatus.OK,
- text="OK",
- ),
- )
- transport = HTTPXTransport()
- response = transport(
- method="POST",
- url="https://example.com/test",
- headers={"Content-Type": "text/plain"},
- data=b"hello",
- request_timeout=30.0,
- )
- assert route.called
- assert isinstance(response, Response)
- assert response.status_code == HTTPStatus.OK
- assert response.text == "OK"
- assert response.tell_position == len(b"OK")
-
- @staticmethod
- @respx.mock
- def test_tuple_timeout() -> None:
- """``HTTPXTransport`` works with a (connect, read) timeout
- tuple.
- """
- route = respx.post(url="https://example.com/test").mock(
- return_value=httpx.Response(
- status_code=HTTPStatus.OK,
- text="OK",
- ),
- )
- transport = HTTPXTransport()
- response = transport(
- method="POST",
- url="https://example.com/test",
- headers={"Content-Type": "text/plain"},
- data=b"hello",
- request_timeout=(5.0, 30.0),
- )
- assert route.called
- assert isinstance(response, Response)
- assert response.status_code == HTTPStatus.OK
-
- @staticmethod
- @respx.mock
- def test_int_timeout() -> None:
- """``HTTPXTransport`` works with an int timeout."""
- route = respx.post(url="https://example.com/test").mock(
- return_value=httpx.Response(
- status_code=HTTPStatus.OK,
- text="OK",
- ),
- )
- transport = HTTPXTransport()
- response = transport(
- method="POST",
- url="https://example.com/test",
- headers={"Content-Type": "text/plain"},
- data=b"hello",
- request_timeout=30,
- )
- assert route.called
- assert isinstance(response, Response)
- assert response.status_code == HTTPStatus.OK
-
- @staticmethod
- @respx.mock
- def test_context_manager() -> None:
- """``HTTPXTransport`` can be used as a context manager."""
- route = respx.post(url="https://example.com/test").mock(
- return_value=httpx.Response(
- status_code=HTTPStatus.OK,
- text="OK",
- ),
- )
- with HTTPXTransport() as transport:
- response = transport(
- method="POST",
- url="https://example.com/test",
- headers={"Content-Type": "text/plain"},
- data=b"hello",
- request_timeout=30.0,
- )
- assert route.called
- assert isinstance(response, Response)
- assert response.status_code == HTTPStatus.OK
-
-
-class TestAsyncHTTPXTransport:
- """Tests for ``AsyncHTTPXTransport``."""
-
- @staticmethod
- @pytest.mark.asyncio
- @respx.mock
- async def test_float_timeout() -> None:
- """``AsyncHTTPXTransport`` works with a float timeout."""
- route = respx.post(url="https://example.com/test").mock(
- return_value=httpx.Response(
- status_code=HTTPStatus.OK,
- text="OK",
- ),
- )
- transport = AsyncHTTPXTransport()
- response = await transport(
- method="POST",
- url="https://example.com/test",
- headers={"Content-Type": "text/plain"},
- data=b"hello",
- request_timeout=30.0,
- )
- assert route.called
- assert isinstance(response, Response)
- assert response.status_code == HTTPStatus.OK
- assert response.text == "OK"
- assert response.tell_position == len(b"OK")
-
- @staticmethod
- @pytest.mark.asyncio
- @respx.mock
- async def test_tuple_timeout() -> None:
- """``AsyncHTTPXTransport`` works with a (connect, read)
- timeout tuple.
- """
- route = respx.post(url="https://example.com/test").mock(
- return_value=httpx.Response(
- status_code=HTTPStatus.OK,
- text="OK",
- ),
- )
- transport = AsyncHTTPXTransport()
- response = await transport(
- method="POST",
- url="https://example.com/test",
- headers={"Content-Type": "text/plain"},
- data=b"hello",
- request_timeout=(5.0, 30.0),
- )
- assert route.called
- assert isinstance(response, Response)
- assert response.status_code == HTTPStatus.OK
-
- @staticmethod
- @pytest.mark.asyncio
- @respx.mock
- async def test_int_timeout() -> None:
- """``AsyncHTTPXTransport`` works with an int timeout."""
- route = respx.post(url="https://example.com/test").mock(
- return_value=httpx.Response(
- status_code=HTTPStatus.OK,
- text="OK",
- ),
- )
- transport = AsyncHTTPXTransport()
- response = await transport(
- method="POST",
- url="https://example.com/test",
- headers={"Content-Type": "text/plain"},
- data=b"hello",
- request_timeout=30,
- )
- assert route.called
- assert isinstance(response, Response)
- assert response.status_code == HTTPStatus.OK
-
- @staticmethod
- @pytest.mark.asyncio
- @respx.mock
- async def test_context_manager() -> None:
- """``AsyncHTTPXTransport`` can be used as an async context
- manager.
- """
- route = respx.post(url="https://example.com/test").mock(
- return_value=httpx.Response(
- status_code=HTTPStatus.OK,
- text="OK",
- ),
- )
- async with AsyncHTTPXTransport() as transport:
- response = await transport(
- method="POST",
- url="https://example.com/test",
- headers={"Content-Type": "text/plain"},
- data=b"hello",
- request_timeout=30.0,
- )
- assert route.called
- assert isinstance(response, Response)
- assert response.status_code == HTTPStatus.OK
-
-
-class _FalsyTransport:
- """A sync transport that is falsy but protocol-conforming."""
-
- def __bool__(self) -> bool:
- """Return ``False`` so truthiness checks would skip this
- transport.
- """
- return False
-
- 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 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,
- url=url,
- status_code=HTTPStatus.OK,
- headers={},
- request_body=data,
- tell_position=0,
- content=body.encode(),
- )
-
-
-class _FalsyAsyncTransport:
- """An async transport that is falsy but protocol-conforming."""
-
- def __bool__(self) -> bool:
- """Return ``False`` so truthiness checks would skip this
- transport.
- """
- return False
-
- 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 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,
- url=url,
- status_code=HTTPStatus.OK,
- headers={},
- request_body=data,
- tell_position=0,
- content=body.encode(),
- )
-
-
-def test_falsy_sync_transport_is_retained(
- high_quality_image: io.BytesIO,
-) -> 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,
- transport=transport,
- ).generate_vumark_instance(
- target_id="target",
- instance_id="instance",
- accept=VuMarkAccept.PNG,
- )
- assert vumark_bytes == b"vumark-bytes"
-
-
-@pytest.mark.asyncio
-async def test_falsy_async_transport_is_retained(
- high_quality_image: io.BytesIO,
-) -> 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,
- transport=transport,
- ) as vumark_client:
- assert (
- await vumark_client.generate_vumark_instance(
- target_id="target",
- instance_id="instance",
- accept=VuMarkAccept.PNG,
- )
- == b"vumark-bytes"
- )
-
-
-@pytest.mark.parametrize(
- argnames=("status_code", "exception_type"),
- argvalues=[
- (HTTPStatus.TOO_MANY_REQUESTS, TooManyRequestsError),
- (HTTPStatus.INTERNAL_SERVER_ERROR, ServerError),
- ],
-)
-@respx.mock
-def test_vumark_service_handles_bodyless_http_errors(
- *,
- status_code: HTTPStatus,
- exception_type: type[ServerError | TooManyRequestsError],
-) -> None:
- """VuMark HTTP errors which have no JSON body remain meaningful."""
- route = respx.post(
- url="https://example.com/targets/target/instances"
- ).mock(return_value=httpx.Response(status_code=status_code))
-
- with HTTPXTransport() as transport:
- service = VuMarkService(
- server_access_key="access-key",
- server_secret_key=uuid.uuid4().hex,
- base_vws_url="https://example.com",
- transport=transport,
- )
- with pytest.raises(expected_exception=exception_type):
- _ = service.generate_vumark_instance(
- target_id="target",
- instance_id="instance",
- accept=VuMarkAccept.PNG,
- )
-
- 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"),
- argvalues=[
- (HTTPStatus.TOO_MANY_REQUESTS, TooManyRequestsError),
- (HTTPStatus.INTERNAL_SERVER_ERROR, ServerError),
- ],
-)
-@respx.mock
-async def test_async_vumark_service_handles_bodyless_http_errors(
- *,
- status_code: HTTPStatus,
- exception_type: type[ServerError | TooManyRequestsError],
-) -> None:
- """Async VuMark HTTP errors without JSON remain meaningful."""
- route = respx.post(
- url="https://example.com/targets/target/instances"
- ).mock(return_value=httpx.Response(status_code=status_code))
-
- async with AsyncHTTPXTransport() as transport:
- service = AsyncVuMarkService(
- server_access_key="access-key",
- server_secret_key=uuid.uuid4().hex,
- base_vws_url="https://example.com",
- transport=transport,
- )
- with pytest.raises(expected_exception=exception_type):
- _ = await service.generate_vumark_instance(
- target_id="target",
- instance_id="instance",
- accept=VuMarkAccept.PNG,
- )
-
- 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
-
-
-# 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"
-_MODEL_TARGET_CLIENT_CREDENTIALS = ("client-id", "client-secret")
-
-_HTTPX2_URL = "https://example.com/test"
-_HTTPX2_REFUSED_URL = "https://example.com/refused"
-
-
-@pytest.fixture(name="httpx2_requests")
-def fixture_httpx2_requests(
- *,
- monkeypatch: pytest.MonkeyPatch,
-) -> list[httpx2.Request]:
- """Answer every ``httpx2`` request with a fake server, and collect the
- requests which it sees.
-
- The fake server answers ``OK`` to every request except those for
- ``_HTTPX2_REFUSED_URL``, which it refuses to connect to.
- """
- requests_seen: list[httpx2.Request] = []
-
- def handler(request: httpx2.Request) -> httpx2.Response:
- """Answer an ``httpx2`` request.
-
- Args:
- request: The request to answer.
-
- Returns:
- An ``OK`` response.
-
- Raises:
- httpx2.ConnectError: The request is for the refused URL.
- """
- requests_seen.append(request)
- if str(object=request.url) == _HTTPX2_REFUSED_URL:
- raise httpx2.ConnectError(
- message="Connection refused",
- request=request,
- )
- return httpx2.Response(
- status_code=HTTPStatus.OK,
- text="OK",
- headers={"X-Example": "example"},
- )
-
- mock_transport = httpx2.MockTransport(handler=handler)
-
- class _Client(httpx2.Client):
- """A synchronous client which uses the fake server."""
-
- def __init__(self) -> None:
- """Create a client which uses the fake server."""
- super().__init__(transport=mock_transport)
-
- class _AsyncClient(httpx2.AsyncClient):
- """An asynchronous client which uses the fake server."""
-
- def __init__(self) -> None:
- """Create a client which uses the fake server."""
- super().__init__(transport=mock_transport)
-
- monkeypatch.setattr(target=httpx2, name="Client", value=_Client)
- monkeypatch.setattr(target=httpx2, name="AsyncClient", value=_AsyncClient)
- return requests_seen
-
-
-class TestHTTPX2Transport:
- """Tests for ``HTTPX2Transport``."""
-
- @staticmethod
- def test_float_timeout(httpx2_requests: list[httpx2.Request]) -> None:
- """``HTTPX2Transport`` works with a float timeout.
-
- A float sets both the connect and read timeouts, and the response
- is converted in full.
- """
- transport = HTTPX2Transport()
- response = transport(
- method="POST",
- url=_HTTPX2_URL,
- headers={"Content-Type": "text/plain"},
- data=b"hello",
- request_timeout=30.0,
- )
- (request,) = httpx2_requests
- assert request.extensions["timeout"] == {
- "connect": 30.0,
- "read": 30.0,
- "write": None,
- "pool": None,
- }
- assert request.headers["Content-Type"] == "text/plain"
- assert isinstance(response, Response)
- assert response.status_code == HTTPStatus.OK
- assert response.text == "OK"
- assert response.url == _HTTPX2_URL
- assert response.headers["x-example"] == "example"
- assert response.request_body == b"hello"
- assert response.content == b"OK"
- assert response.tell_position == len(b"OK")
-
- @staticmethod
- def test_tuple_timeout(httpx2_requests: list[httpx2.Request]) -> None:
- """``HTTPX2Transport`` works with a (connect, read) timeout
- tuple.
- """
- transport = HTTPX2Transport()
- response = transport(
- method="POST",
- url=_HTTPX2_URL,
- headers={"Content-Type": "text/plain"},
- data=b"hello",
- request_timeout=(5.0, 30.0),
- )
- (request,) = httpx2_requests
- assert request.extensions["timeout"] == {
- "connect": 5.0,
- "read": 30.0,
- "write": None,
- "pool": None,
- }
- assert isinstance(response, Response)
- assert response.status_code == HTTPStatus.OK
-
- @staticmethod
- def test_int_timeout(httpx2_requests: list[httpx2.Request]) -> None:
- """``HTTPX2Transport`` works with an int timeout."""
- transport = HTTPX2Transport()
- response = transport(
- method="POST",
- url=_HTTPX2_URL,
- headers={"Content-Type": "text/plain"},
- data=b"hello",
- request_timeout=30,
- )
- (request,) = httpx2_requests
- assert request.extensions["timeout"] == {
- "connect": 30,
- "read": 30,
- "write": None,
- "pool": None,
- }
- assert isinstance(response, Response)
- assert response.status_code == HTTPStatus.OK
-
- @staticmethod
- def test_empty_body(httpx2_requests: list[httpx2.Request]) -> None:
- """An empty request body is reported as ``None``, as it is for
- the ``requests`` and ``httpx`` transports.
- """
- transport = HTTPX2Transport()
- response = transport(
- method="GET",
- url=_HTTPX2_URL,
- headers={},
- data=b"",
- request_timeout=30.0,
- )
- assert len(httpx2_requests) == 1
- assert response.request_body is None
-
- @staticmethod
- def test_context_manager(httpx2_requests: list[httpx2.Request]) -> None:
- """``HTTPX2Transport`` can be used as a context manager, and
- leaving the context closes the client.
- """
- with HTTPX2Transport() as transport:
- response = transport(
- method="POST",
- url=_HTTPX2_URL,
- headers={"Content-Type": "text/plain"},
- data=b"hello",
- request_timeout=30.0,
- )
- assert len(httpx2_requests) == 1
- assert isinstance(response, Response)
- assert response.status_code == HTTPStatus.OK
-
- with pytest.raises(
- expected_exception=RuntimeError,
- match="client has been closed",
- ):
- _ = transport(
- method="POST",
- url=_HTTPX2_URL,
- headers={"Content-Type": "text/plain"},
- data=b"hello",
- request_timeout=30.0,
- )
-
- @staticmethod
- def test_close(httpx2_requests: list[httpx2.Request]) -> None:
- """Closing the transport closes the client."""
- transport = HTTPX2Transport()
- transport.close()
- with pytest.raises(
- expected_exception=RuntimeError,
- match="client has been closed",
- ):
- _ = transport(
- method="POST",
- url=_HTTPX2_URL,
- headers={"Content-Type": "text/plain"},
- data=b"hello",
- request_timeout=30.0,
- )
- assert not bool(httpx2_requests)
-
- @staticmethod
- def test_httpx2_exceptions(httpx2_requests: list[httpx2.Request]) -> None:
- """Errors are raised as ``httpx2`` exceptions, which are not
- ``httpx`` exceptions.
- """
- transport = HTTPX2Transport()
- with pytest.raises(expected_exception=httpx2.ConnectError) as exc:
- _ = transport(
- method="GET",
- url=_HTTPX2_REFUSED_URL,
- headers={},
- data=b"",
- request_timeout=30.0,
- )
- assert len(httpx2_requests) == 1
- assert not isinstance(exc.value, httpx.HTTPError)
-
-
-class TestAsyncHTTPX2Transport:
- """Tests for ``AsyncHTTPX2Transport``."""
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_float_timeout(
- httpx2_requests: list[httpx2.Request],
- ) -> None:
- """``AsyncHTTPX2Transport`` works with a float timeout.
-
- A float sets both the connect and read timeouts, and the response
- is converted in full.
- """
- transport = AsyncHTTPX2Transport()
- response = await transport(
- method="POST",
- url=_HTTPX2_URL,
- headers={"Content-Type": "text/plain"},
- data=b"hello",
- request_timeout=30.0,
- )
- (request,) = httpx2_requests
- assert request.extensions["timeout"] == {
- "connect": 30.0,
- "read": 30.0,
- "write": None,
- "pool": None,
- }
- assert request.headers["Content-Type"] == "text/plain"
- assert isinstance(response, Response)
- assert response.status_code == HTTPStatus.OK
- assert response.text == "OK"
- assert response.url == _HTTPX2_URL
- assert response.headers["x-example"] == "example"
- assert response.request_body == b"hello"
- assert response.content == b"OK"
- assert response.tell_position == len(b"OK")
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_tuple_timeout(
- httpx2_requests: list[httpx2.Request],
- ) -> None:
- """``AsyncHTTPX2Transport`` works with a (connect, read)
- timeout tuple.
- """
- transport = AsyncHTTPX2Transport()
- response = await transport(
- method="POST",
- url=_HTTPX2_URL,
- headers={"Content-Type": "text/plain"},
- data=b"hello",
- request_timeout=(5.0, 30.0),
- )
- (request,) = httpx2_requests
- assert request.extensions["timeout"] == {
- "connect": 5.0,
- "read": 30.0,
- "write": None,
- "pool": None,
- }
- assert isinstance(response, Response)
- assert response.status_code == HTTPStatus.OK
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_int_timeout(
- httpx2_requests: list[httpx2.Request],
- ) -> None:
- """``AsyncHTTPX2Transport`` works with an int timeout."""
- transport = AsyncHTTPX2Transport()
- response = await transport(
- method="POST",
- url=_HTTPX2_URL,
- headers={"Content-Type": "text/plain"},
- data=b"hello",
- request_timeout=30,
- )
- (request,) = httpx2_requests
- assert request.extensions["timeout"] == {
- "connect": 30,
- "read": 30,
- "write": None,
- "pool": None,
- }
- assert isinstance(response, Response)
- assert response.status_code == HTTPStatus.OK
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_empty_body(
- httpx2_requests: list[httpx2.Request],
- ) -> None:
- """An empty request body is reported as ``None``, as it is for
- the ``requests`` and ``httpx`` transports.
- """
- transport = AsyncHTTPX2Transport()
- response = await transport(
- method="GET",
- url=_HTTPX2_URL,
- headers={},
- data=b"",
- request_timeout=30.0,
- )
- assert len(httpx2_requests) == 1
- assert response.request_body is None
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_context_manager(
- httpx2_requests: list[httpx2.Request],
- ) -> None:
- """``AsyncHTTPX2Transport`` can be used as an async context
- manager, and leaving the context closes the client.
- """
- async with AsyncHTTPX2Transport() as transport:
- response = await transport(
- method="POST",
- url=_HTTPX2_URL,
- headers={"Content-Type": "text/plain"},
- data=b"hello",
- request_timeout=30.0,
- )
- assert len(httpx2_requests) == 1
- assert isinstance(response, Response)
- assert response.status_code == HTTPStatus.OK
-
- with pytest.raises(
- expected_exception=RuntimeError,
- match="client has been closed",
- ):
- await transport(
- method="POST",
- url=_HTTPX2_URL,
- headers={"Content-Type": "text/plain"},
- data=b"hello",
- request_timeout=30.0,
- )
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_aclose(httpx2_requests: list[httpx2.Request]) -> None:
- """Closing the transport closes the client."""
- transport = AsyncHTTPX2Transport()
- await transport.aclose()
- with pytest.raises(
- expected_exception=RuntimeError,
- match="client has been closed",
- ):
- await transport(
- method="POST",
- url=_HTTPX2_URL,
- headers={"Content-Type": "text/plain"},
- data=b"hello",
- request_timeout=30.0,
- )
- assert not bool(httpx2_requests)
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_httpx2_exceptions(
- httpx2_requests: list[httpx2.Request],
- ) -> None:
- """Errors are raised as ``httpx2`` exceptions, which are not
- ``httpx`` exceptions.
- """
- transport = AsyncHTTPX2Transport()
- with pytest.raises(expected_exception=httpx2.ConnectError) as exc:
- await transport(
- method="GET",
- url=_HTTPX2_REFUSED_URL,
- headers={},
- data=b"",
- request_timeout=30.0,
- )
- assert len(httpx2_requests) == 1
- assert not isinstance(exc.value, httpx.HTTPError)
-
-
-class TestHTTPX2TransportWithMock:
- """Tests for synchronous clients using ``HTTPX2Transport`` against
- the mock.
- """
-
- @staticmethod
- def test_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
-
- @staticmethod
- def test_vumark() -> None:
- """A VuMark instance can be generated with ``VuMarkService``."""
- vumark_target = VuMarkTarget(name="vumark-template")
- database = VuMarkDatabase(vumark_targets={vumark_target})
- with MockVWS() as mock, HTTPX2Transport() as transport:
- mock.add_vumark_database(vumark_database=database)
- vumark_client = VuMarkService(
- server_access_key=database.server_access_key,
- server_secret_key=database.server_secret_key,
- transport=transport,
- )
- vumark_bytes = vumark_client.generate_vumark_instance(
- target_id=vumark_target.target_id,
- instance_id="instance",
- accept=VuMarkAccept.PNG,
- )
- assert vumark_bytes.startswith(b"\x89PNG")
-
- @staticmethod
- def test_model_targets(model_target_model: ModelTargetModel) -> None:
- """A Model Target dataset can be generated with
- ``ModelTargetService``.
- """
- with (
- MockVWS(processing_time_seconds=0.2),
- HTTPX2Transport() as transport,
- ):
- model_target_client = ModelTargetService(
- client_id=_MODEL_TARGET_CLIENT_ID,
- client_secret=_MODEL_TARGET_CLIENT_CREDENTIALS[1],
- transport=transport,
- )
- dataset_uuid = model_target_client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[model_target_model],
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
- report = model_target_client.wait_for_dataset_generated(
- dataset_uuid=dataset_uuid,
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
- assert report.status == ModelTargetDatasetStatuses.DONE
- model_target_client.delete_dataset(
- dataset_uuid=dataset_uuid,
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
- @staticmethod
- @pytest.mark.parametrize(
- argnames="custom_timeout",
- argvalues=[0.1, (5.0, 0.1)],
- ids=["float", "tuple"],
- )
- def test_timeout(custom_timeout: float | tuple[float, float]) -> None:
- """A response which takes longer than the read timeout raises an
- ``httpx2`` timeout.
- """
- database = CloudDatabase()
- sleeps: list[float] = []
- with MockVWS(
- response_delay_seconds=0.11,
- sleep_fn=sleeps.append,
- ) as mock:
- mock.add_cloud_database(cloud_database=database)
- with HTTPX2Transport() as transport:
- vws_client = VWS(
- 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()
- # The mock sleeps for the read timeout before raising.
- assert sleeps == [0.1]
-
-
-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,
- model_target_model: ModelTargetModel,
-) -> None:
- """Generate, wait for and delete a Model Target dataset with
- ``AsyncModelTargetService``.
-
- Args:
- transport: The transport for the client to use.
- model_target_model: The model to generate a dataset from.
- """
- model_target_client = AsyncModelTargetService(
- client_id=_MODEL_TARGET_CLIENT_ID,
- client_secret=_MODEL_TARGET_CLIENT_CREDENTIALS[1],
- transport=transport,
- )
- dataset_uuid = await model_target_client.create_dataset(
- name="dataset",
- target_sdk="11.0",
- models=[model_target_model],
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
- report = await model_target_client.wait_for_dataset_generated(
- dataset_uuid=dataset_uuid,
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
- assert report.status == ModelTargetDatasetStatuses.DONE
- await model_target_client.delete_dataset(
- dataset_uuid=dataset_uuid,
- dataset_type=ModelTargetDatasetType.STANDARD,
- )
-
-
-class TestAsyncHTTPX2TransportWithMock:
- """Tests for asynchronous clients using ``AsyncHTTPX2Transport``
- against the mock.
- """
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_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,
- )
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_vumark() -> None:
- """A VuMark instance can be generated with
- ``AsyncVuMarkService``.
- """
- vumark_target = VuMarkTarget(name="vumark-template")
- database = VuMarkDatabase(vumark_targets={vumark_target})
- with MockVWS() as mock:
- mock.add_vumark_database(vumark_database=database)
- async with AsyncHTTPX2Transport() as transport:
- vumark_client = AsyncVuMarkService(
- server_access_key=database.server_access_key,
- server_secret_key=database.server_secret_key,
- transport=transport,
- )
- vumark_bytes = await vumark_client.generate_vumark_instance(
- target_id=vumark_target.target_id,
- instance_id="instance",
- accept=VuMarkAccept.PNG,
- )
- assert vumark_bytes.startswith(b"\x89PNG")
-
- @staticmethod
- @pytest.mark.asyncio
- async def test_model_targets(model_target_model: ModelTargetModel) -> None:
- """A Model Target dataset can be generated with
- ``AsyncModelTargetService``.
- """
- with MockVWS(processing_time_seconds=0.2):
- async with AsyncHTTPX2Transport() as transport:
- await _generate_dataset(
- transport=transport,
- model_target_model=model_target_model,
- )
-
- @staticmethod
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- argnames="custom_timeout",
- argvalues=[0.1, (5.0, 0.1)],
- ids=["float", "tuple"],
- )
- async def test_timeout(
- custom_timeout: float | tuple[float, float],
- ) -> None:
- """A response which takes longer than the read timeout raises an
- ``httpx2`` timeout.
- """
- database = CloudDatabase()
- sleeps: list[float] = []
- with MockVWS(
- response_delay_seconds=0.11,
- sleep_fn=sleeps.append,
- ) as mock:
- mock.add_cloud_database(cloud_database=database)
- async with AsyncHTTPX2Transport() as transport:
- vws_client = AsyncVWS(
- 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()
- # The mock sleeps for the read timeout before raising.
- assert sleeps == [0.1]
diff --git a/tests/test_vws.py b/tests/test_vws.py
deleted file mode 100644
index 06a17cd90..000000000
--- a/tests/test_vws.py
+++ /dev/null
@@ -1,1124 +0,0 @@
-"""Tests for helper functions for managing a Vuforia database."""
-
-from __future__ import annotations
-
-import base64
-import calendar
-import datetime
-import json
-import secrets
-import time
-import uuid
-from http import HTTPStatus
-from typing import TYPE_CHECKING, BinaryIO
-
-import pytest
-import requests
-from freezegun import freeze_time
-from mock_vws import MockVWS
-from mock_vws.database import CloudDatabase
-
-from vws import VWS, CloudRecoService, VuMarkService
-from vws.exceptions.custom_exceptions import (
- DatabaseIdNotSetError,
- RecoCountsReportDownloadError,
- RecoCountsReportNotReadyError,
- RecoCountsReportTimeoutError,
- TargetProcessingTimeoutError,
-)
-from vws.exceptions.vws_exceptions import (
- AuthenticationFailureError,
- FailError,
-)
-from vws.reports import (
- DatabaseSummaryReport,
- RecoCount,
- RecoCountsReport,
- TargetRecord,
- TargetStatuses,
- TargetSummaryReport,
-)
-from vws.response import Response
-from vws.vumark_accept import VuMarkAccept
-
-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,
- )
-
-
-class TestAddTarget:
- """Tests for adding a target."""
-
- @staticmethod
- @pytest.mark.parametrize(
- argnames="application_metadata",
- argvalues=[None, b"a"],
- )
- @pytest.mark.parametrize(argnames="active_flag", argvalues=[True, False])
- def test_add_target(
- *,
- vws_client: VWS,
- image: io.BytesIO | BinaryIO,
- application_metadata: bytes | None,
- cloud_reco_client: CloudRecoService,
- active_flag: bool,
- ) -> None:
- """No exception is raised when adding one target."""
- 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 == []
-
- @staticmethod
- def test_add_two_targets(
- *,
- vws_client: VWS,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """No exception is raised when adding two targets with different
- names.
-
- This demonstrates that the image seek position is not changed.
- """
- for name in ("a", "b"):
- _ = vws_client.add_target(
- name=name,
- width=1,
- image=image,
- active_flag=True,
- application_metadata=None,
- )
-
-
-class TestDefaultRequestTimeout:
- """Tests for the default request timeout."""
-
- @staticmethod
- @pytest.mark.parametrize(
- argnames=("response_delay_seconds", "expect_timeout"),
- argvalues=[(29, False), (31, True)],
- )
- def test_default_timeout(
- *,
- image: io.BytesIO | BinaryIO,
- response_delay_seconds: int,
- expect_timeout: bool,
- ) -> None:
- """At 29 seconds there is no error; at 31 seconds there is a
- timeout.
- """
- with (
- freeze_time() as frozen_datetime,
- MockVWS(
- response_delay_seconds=response_delay_seconds,
- sleep_fn=lambda seconds: (
- frozen_datetime.tick(
- delta=datetime.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,
- )
-
-
-class TestCustomRequestTimeout:
- """Tests for custom request timeout values."""
-
- @staticmethod
- @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),
- ],
- )
- def test_custom_timeout(
- *,
- image: io.BytesIO | BinaryIO,
- custom_timeout: float | tuple[float, float],
- response_delay_seconds: float,
- expect_timeout: bool,
- ) -> None:
- """Custom timeouts are honored for both float and tuple forms."""
- with (
- freeze_time() as frozen_datetime,
- MockVWS(
- response_delay_seconds=response_delay_seconds,
- sleep_fn=lambda seconds: (
- frozen_datetime.tick(
- delta=datetime.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,
- )
-
-
-class TestCustomBaseVWSURL:
- """Tests for using a custom base VWS URL."""
-
- @staticmethod
- def test_custom_base_url(image: io.BytesIO | BinaryIO) -> None:
- """
- It is possible to use add a target to a database under a custom
- VWS
- URL.
- """
- 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,
- )
-
- @staticmethod
- 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())
-
-
-class TestListTargets:
- """Tests for listing targets."""
-
- @staticmethod
- def test_list_targets(
- *,
- vws_client: VWS,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """It is possible to get a list of target IDs."""
- 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])
-
-
-class TestDelete:
- """Test for deleting a target."""
-
- @staticmethod
- def test_delete_target(
- *,
- vws_client: VWS,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """It is possible to delete a target."""
- 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() == []
-
-
-class TestGetTargetSummaryReport:
- """Tests for getting a summary report for a target."""
-
- @staticmethod
- def test_get_target_summary_report(
- *,
- vws_client: VWS,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """Details of a target are returned by
- ``get_target_summary_report``.
- """
- 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=datetime.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
-
-
-class TestGetDatabaseSummaryReport:
- """Tests for getting a summary report for a database."""
-
- @staticmethod
- 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
-
-
-class TestGetTargetRecord:
- """Tests for getting a record of a target."""
-
- @staticmethod
- def test_get_target_record(
- *,
- vws_client: VWS,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """Details of a target are returned by ``get_target_record``."""
- 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
-
- @staticmethod
- 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
-
-
-class TestWaitForTargetProcessed:
- """Tests for waiting for a target to be processed."""
-
- @staticmethod
- def test_wait_for_target_processed(
- *,
- vws_client: VWS,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """It is possible to wait until a target is processed."""
- 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
-
- @staticmethod
- def test_default_seconds_between_requests(
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """By default, 0.2 seconds are waited between polling requests."""
- 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
-
- @staticmethod
- def test_custom_seconds_between_requests(
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """It is possible to customize the time waited between polling
- requests.
- """
- 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
-
- @staticmethod
- def test_custom_timeout(image: io.BytesIO | BinaryIO) -> None:
- """It is possible to set a maximum timeout."""
- 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
-
-
-class TestGetDuplicateTargets:
- """Tests for getting duplicate targets."""
-
- @staticmethod
- def test_get_duplicate_targets(
- *,
- vws_client: VWS,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """It is possible to get the IDs of similar targets."""
- 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]
-
- @staticmethod
- @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")
-
-
-class TestUpdateTarget:
- """Tests for updating a target."""
-
- @staticmethod
- def test_update_target(
- *,
- vws_client: VWS,
- image: io.BytesIO | BinaryIO,
- different_high_quality_image: io.BytesIO,
- cloud_reco_client: CloudRecoService,
- ) -> None:
- """It is possible to update a target."""
- 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
-
- @staticmethod
- def test_no_fields_given(
- *,
- vws_client: VWS,
- image: io.BytesIO | BinaryIO,
- ) -> None:
- """It is possible to give no update fields."""
- 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)
-
-
-class _ForbiddenDownloadTransport:
- """A transport which refuses to serve a report, as an expired URL
- would.
- """
-
- def close(self) -> None:
- """Close the transport."""
-
- def __call__(
- self,
- *,
- method: str,
- url: str,
- headers: dict[str, str],
- data: bytes,
- request_timeout: float | tuple[float, float],
- ) -> Response:
- """Return a "forbidden" response."""
- del method, headers, request_timeout
- body = "AccessDenied"
- return Response(
- text=body,
- url=url,
- status_code=HTTPStatus.FORBIDDEN,
- headers={},
- request_body=data,
- tell_position=0,
- content=body.encode(encoding="utf-8"),
- )
-
-
-class TestRecoCountsReport:
- """Tests for database reco counts reports."""
-
- @staticmethod
- def test_reco_counts_report(
- *,
- vws_client: VWS,
- report_month: datetime.date,
- ) -> None:
- """A report can be requested, waited for and downloaded."""
- report_request = vws_client.request_database_reco_counts_report(
- year=report_month.year,
- month=calendar.Month(value=report_month.month),
- )
- assert 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")
-
- @staticmethod
- def test_not_ready(*, current_month: datetime.date) -> None:
- """Downloading a report before Vuforia has generated it raises an
- error.
- """
- with MockVWS(processing_time_seconds=60) as mock:
- database = CloudDatabase()
- mock.add_cloud_database(cloud_database=database)
- vws_client = VWS(
- server_access_key=database.server_access_key,
- server_secret_key=database.server_secret_key,
- database_id=database.database_id,
- )
- report_request = vws_client.request_database_reco_counts_report(
- year=current_month.year,
- month=calendar.Month(value=current_month.month),
- )
-
- with pytest.raises(
- expected_exception=RecoCountsReportNotReadyError,
- ) as exc:
- _ = vws_client.download_reco_counts_report(
- presigned_url=report_request.presigned_url,
- )
-
- assert exc.value.response.status_code == HTTPStatus.NOT_FOUND
-
- @staticmethod
- def test_wait_timeout(*, current_month: datetime.date) -> None:
- """Waiting for a report which is not generated in time raises an
- error.
- """
- with MockVWS(processing_time_seconds=60) as mock:
- database = CloudDatabase()
- mock.add_cloud_database(cloud_database=database)
- vws_client = VWS(
- server_access_key=database.server_access_key,
- server_secret_key=database.server_secret_key,
- database_id=database.database_id,
- )
- report_request = vws_client.request_database_reco_counts_report(
- year=current_month.year,
- month=calendar.Month(value=current_month.month),
- )
-
- maximum_wait_seconds = 5
- start_time = time.monotonic()
-
- with pytest.raises(
- expected_exception=RecoCountsReportTimeoutError,
- ):
- _ = vws_client.wait_for_reco_counts_report(
- presigned_url=report_request.presigned_url,
- seconds_between_requests=0.01,
- timeout_seconds=0.05,
- )
-
- elapsed_time = time.monotonic() - start_time
- assert elapsed_time < maximum_wait_seconds
-
- @staticmethod
- @pytest.mark.parametrize(
- argnames=("year", "month"),
- argvalues=[
- pytest.param(1999, calendar.Month.JANUARY, id="year-in-the-past"),
- pytest.param(
- 1999,
- calendar.Month.DECEMBER,
- id="year-in-the-past-december",
- ),
- ],
- )
- def test_month_not_accepted(
- *,
- vws_client: VWS,
- year: int,
- month: calendar.Month,
- ) -> None:
- """Months other than the current and previous month are
- rejected.
- """
- with pytest.raises(expected_exception=FailError) as exc:
- _ = vws_client.request_database_reco_counts_report(
- year=year,
- month=month,
- )
-
- assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST
-
- @staticmethod
- def test_database_id_does_not_match_keys(
- *,
- current_month: datetime.date,
- ) -> None:
- """A database ID which does not match the given keys is
- rejected.
- """
- with MockVWS() as mock:
- database = CloudDatabase()
- mock.add_cloud_database(cloud_database=database)
- vws_client = VWS(
- server_access_key=database.server_access_key,
- server_secret_key=database.server_secret_key,
- database_id=uuid.uuid4().hex,
- )
-
- with pytest.raises(
- expected_exception=AuthenticationFailureError,
- ) as exc:
- _ = vws_client.request_database_reco_counts_report(
- year=current_month.year,
- month=calendar.Month(value=current_month.month),
- )
-
- assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED
-
- @staticmethod
- def test_download_error() -> None:
- """An error response from the report's URL raises an error."""
- vws_client = VWS(
- server_access_key=uuid.uuid4().hex,
- server_secret_key=uuid.uuid4().hex,
- transport=_ForbiddenDownloadTransport(),
- )
-
- with pytest.raises(
- expected_exception=RecoCountsReportDownloadError,
- ) as exc:
- _ = vws_client.download_reco_counts_report(
- presigned_url="https://example.com/reports/recoCounts/x",
- )
-
- assert exc.value.response.status_code == HTTPStatus.FORBIDDEN
-
- @staticmethod
- def test_no_database_id(*, current_month: datetime.date) -> None:
- """A client which was given no database ID cannot request a
- report.
- """
- vws_client = VWS(
- server_access_key=uuid.uuid4().hex,
- server_secret_key=uuid.uuid4().hex,
- )
-
- with pytest.raises(expected_exception=DatabaseIdNotSetError):
- _ = vws_client.request_database_reco_counts_report(
- year=current_month.year,
- month=calendar.Month(value=current_month.month),
- )
-
-
-class TestRecoCountsReportParsing:
- """Tests for parsing downloaded reco counts reports."""
-
- @staticmethod
- def test_rows() -> None:
- """Each row of the CSV becomes a ``RecoCount``."""
- csv_bytes = b"target_id,reco_count\r\nabc,3\r\ndef,0\r\n"
-
- report = RecoCountsReport.from_csv(csv_bytes=csv_bytes)
-
- assert report.reco_counts == [
- RecoCount(target_id="abc", reco_count=3),
- RecoCount(target_id="def", reco_count=0),
- ]
- assert report.raw_csv == csv_bytes
-
- @staticmethod
- def test_unknown_columns_ignored() -> None:
- """Columns which are not known are not exposed in
- ``reco_counts``.
- """
- expected_reco_count = 3
- header = "target_id,reco_count,new_column\r\n"
- csv_text = f"{header}abc,{expected_reco_count},x\r\n"
-
- report = RecoCountsReport.from_csv(
- csv_bytes=csv_text.encode(encoding="utf-8"),
- )
-
- (item,) = report.reco_counts
- assert item.target_id == "abc"
- assert item.reco_count == expected_reco_count
-
-
-class TestGenerateVumarkInstance:
- """Tests for generating VuMark instances."""
-
- @staticmethod
- @pytest.mark.parametrize(
- argnames=("accept", "expected_prefix"),
- argvalues=[
- pytest.param(VuMarkAccept.PNG, b"\x89PNG\r\n\x1a\n", id="png"),
- pytest.param(VuMarkAccept.SVG, b"<", id="svg"),
- pytest.param(VuMarkAccept.PDF, b"%PDF", id="pdf"),
- ],
- )
- def test_generate_vumark_instance(
- *,
- vumark_service_client: VuMarkService,
- vumark_target_id: str,
- accept: VuMarkAccept,
- expected_prefix: bytes,
- ) -> None:
- """The returned bytes match the requested format."""
- result = vumark_service_client.generate_vumark_instance(
- target_id=vumark_target_id,
- instance_id="12345",
- accept=accept,
- )
- assert result.startswith(expected_prefix)
diff --git a/tests/transports/__init__.py b/tests/transports/__init__.py
new file mode 100644
index 000000000..a4b58aceb
--- /dev/null
+++ b/tests/transports/__init__.py
@@ -0,0 +1 @@
+"""Transports tests."""
diff --git a/tests/transports/test_httpx.py b/tests/transports/test_httpx.py
new file mode 100644
index 000000000..8088daca0
--- /dev/null
+++ b/tests/transports/test_httpx.py
@@ -0,0 +1,523 @@
+"""Tests for `httpx` transports."""
+
+from __future__ import annotations
+
+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
+from vws.exceptions.vws_exceptions import TooManyRequestsError
+from vws.response import Response
+from vws.transports import (
+ AsyncHTTPXTransport,
+ HTTPXTransport,
+)
+from vws.vumark_accept import VuMarkAccept
+
+if TYPE_CHECKING:
+ import io
+
+
+class _FalsyTransport:
+ """A sync transport that is falsy but protocol-conforming."""
+
+ def __bool__(self) -> bool:
+ """Return ``False`` so truthiness checks would skip this
+ transport.
+ """
+ return False
+
+ 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 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,
+ url=url,
+ status_code=HTTPStatus.OK,
+ headers={},
+ request_body=data,
+ tell_position=0,
+ content=body.encode(),
+ )
+
+
+class _FalsyAsyncTransport:
+ """An async transport that is falsy but protocol-conforming."""
+
+ def __bool__(self) -> bool:
+ """Return ``False`` so truthiness checks would skip this
+ transport.
+ """
+ return False
+
+ 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 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,
+ url=url,
+ status_code=HTTPStatus.OK,
+ headers={},
+ request_body=data,
+ tell_position=0,
+ content=body.encode(),
+ )
+
+
+@respx.mock
+def test_httpx_transport_float_timeout() -> None:
+ """``HTTPXTransport`` works with a float timeout."""
+ route = respx.post(url="https://example.com/test").mock(
+ return_value=httpx.Response(
+ status_code=HTTPStatus.OK,
+ text="OK",
+ ),
+ )
+ transport = HTTPXTransport()
+ response = transport(
+ method="POST",
+ url="https://example.com/test",
+ headers={"Content-Type": "text/plain"},
+ data=b"hello",
+ request_timeout=30.0,
+ )
+ assert route.called
+ assert isinstance(response, Response)
+ assert response.status_code == HTTPStatus.OK
+ assert response.text == "OK"
+ assert response.tell_position == len(b"OK")
+
+
+@respx.mock
+def test_httpx_transport_tuple_timeout() -> None:
+ """``HTTPXTransport`` works with a (connect, read) timeout
+ tuple.
+ """
+ route = respx.post(url="https://example.com/test").mock(
+ return_value=httpx.Response(
+ status_code=HTTPStatus.OK,
+ text="OK",
+ ),
+ )
+ transport = HTTPXTransport()
+ response = transport(
+ method="POST",
+ url="https://example.com/test",
+ headers={"Content-Type": "text/plain"},
+ data=b"hello",
+ request_timeout=(5.0, 30.0),
+ )
+ assert route.called
+ assert isinstance(response, Response)
+ assert response.status_code == HTTPStatus.OK
+
+
+@respx.mock
+def test_httpx_transport_int_timeout() -> None:
+ """``HTTPXTransport`` works with an int timeout."""
+ route = respx.post(url="https://example.com/test").mock(
+ return_value=httpx.Response(
+ status_code=HTTPStatus.OK,
+ text="OK",
+ ),
+ )
+ transport = HTTPXTransport()
+ response = transport(
+ method="POST",
+ url="https://example.com/test",
+ headers={"Content-Type": "text/plain"},
+ data=b"hello",
+ request_timeout=30,
+ )
+ assert route.called
+ assert isinstance(response, Response)
+ assert response.status_code == HTTPStatus.OK
+
+
+@respx.mock
+def test_httpx_transport_context_manager() -> None:
+ """``HTTPXTransport`` can be used as a context manager."""
+ route = respx.post(url="https://example.com/test").mock(
+ return_value=httpx.Response(
+ status_code=HTTPStatus.OK,
+ text="OK",
+ ),
+ )
+ with HTTPXTransport() as transport:
+ response = transport(
+ method="POST",
+ url="https://example.com/test",
+ headers={"Content-Type": "text/plain"},
+ data=b"hello",
+ request_timeout=30.0,
+ )
+ assert route.called
+ assert isinstance(response, Response)
+ assert response.status_code == HTTPStatus.OK
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_async_httpx_transport_float_timeout() -> None:
+ """``AsyncHTTPXTransport`` works with a float timeout."""
+ route = respx.post(url="https://example.com/test").mock(
+ return_value=httpx.Response(
+ status_code=HTTPStatus.OK,
+ text="OK",
+ ),
+ )
+ transport = AsyncHTTPXTransport()
+ response = await transport(
+ method="POST",
+ url="https://example.com/test",
+ headers={"Content-Type": "text/plain"},
+ data=b"hello",
+ request_timeout=30.0,
+ )
+ assert route.called
+ assert isinstance(response, Response)
+ assert response.status_code == HTTPStatus.OK
+ assert response.text == "OK"
+ assert response.tell_position == len(b"OK")
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_async_httpx_transport_tuple_timeout() -> None:
+ """``AsyncHTTPXTransport`` works with a (connect, read)
+ timeout tuple.
+ """
+ route = respx.post(url="https://example.com/test").mock(
+ return_value=httpx.Response(
+ status_code=HTTPStatus.OK,
+ text="OK",
+ ),
+ )
+ transport = AsyncHTTPXTransport()
+ response = await transport(
+ method="POST",
+ url="https://example.com/test",
+ headers={"Content-Type": "text/plain"},
+ data=b"hello",
+ request_timeout=(5.0, 30.0),
+ )
+ assert route.called
+ assert isinstance(response, Response)
+ assert response.status_code == HTTPStatus.OK
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_async_httpx_transport_int_timeout() -> None:
+ """``AsyncHTTPXTransport`` works with an int timeout."""
+ route = respx.post(url="https://example.com/test").mock(
+ return_value=httpx.Response(
+ status_code=HTTPStatus.OK,
+ text="OK",
+ ),
+ )
+ transport = AsyncHTTPXTransport()
+ response = await transport(
+ method="POST",
+ url="https://example.com/test",
+ headers={"Content-Type": "text/plain"},
+ data=b"hello",
+ request_timeout=30,
+ )
+ assert route.called
+ assert isinstance(response, Response)
+ assert response.status_code == HTTPStatus.OK
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_async_httpx_transport_context_manager() -> None:
+ """``AsyncHTTPXTransport`` can be used as an async context
+ manager.
+ """
+ route = respx.post(url="https://example.com/test").mock(
+ return_value=httpx.Response(
+ status_code=HTTPStatus.OK,
+ text="OK",
+ ),
+ )
+ async with AsyncHTTPXTransport() as transport:
+ response = await transport(
+ method="POST",
+ url="https://example.com/test",
+ headers={"Content-Type": "text/plain"},
+ data=b"hello",
+ request_timeout=30.0,
+ )
+ assert route.called
+ assert isinstance(response, Response)
+ assert response.status_code == HTTPStatus.OK
+
+
+def test_falsy_sync_transport_is_retained(
+ high_quality_image: io.BytesIO,
+) -> 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,
+ transport=transport,
+ ).generate_vumark_instance(
+ target_id="target",
+ instance_id="instance",
+ accept=VuMarkAccept.PNG,
+ )
+ assert vumark_bytes == b"vumark-bytes"
+
+
+@pytest.mark.asyncio
+async def test_falsy_async_transport_is_retained(
+ high_quality_image: io.BytesIO,
+) -> 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,
+ transport=transport,
+ ) as vumark_client:
+ assert (
+ await vumark_client.generate_vumark_instance(
+ target_id="target",
+ instance_id="instance",
+ accept=VuMarkAccept.PNG,
+ )
+ == b"vumark-bytes"
+ )
+
+
+@pytest.mark.parametrize(
+ argnames=("status_code", "exception_type"),
+ argvalues=[
+ (HTTPStatus.TOO_MANY_REQUESTS, TooManyRequestsError),
+ (HTTPStatus.INTERNAL_SERVER_ERROR, ServerError),
+ ],
+)
+@respx.mock
+def test_vumark_service_handles_bodyless_http_errors(
+ *,
+ status_code: HTTPStatus,
+ exception_type: type[ServerError | TooManyRequestsError],
+) -> None:
+ """VuMark HTTP errors which have no JSON body remain meaningful."""
+ route = respx.post(
+ url="https://example.com/targets/target/instances"
+ ).mock(return_value=httpx.Response(status_code=status_code))
+
+ with HTTPXTransport() as transport:
+ service = VuMarkService(
+ server_access_key="access-key",
+ server_secret_key=uuid.uuid4().hex,
+ base_vws_url="https://example.com",
+ transport=transport,
+ )
+ with pytest.raises(expected_exception=exception_type):
+ _ = service.generate_vumark_instance(
+ target_id="target",
+ instance_id="instance",
+ accept=VuMarkAccept.PNG,
+ )
+
+ 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"),
+ argvalues=[
+ (HTTPStatus.TOO_MANY_REQUESTS, TooManyRequestsError),
+ (HTTPStatus.INTERNAL_SERVER_ERROR, ServerError),
+ ],
+)
+@respx.mock
+async def test_async_vumark_service_handles_bodyless_http_errors(
+ *,
+ status_code: HTTPStatus,
+ exception_type: type[ServerError | TooManyRequestsError],
+) -> None:
+ """Async VuMark HTTP errors without JSON remain meaningful."""
+ route = respx.post(
+ url="https://example.com/targets/target/instances"
+ ).mock(return_value=httpx.Response(status_code=status_code))
+
+ async with AsyncHTTPXTransport() as transport:
+ service = AsyncVuMarkService(
+ server_access_key="access-key",
+ server_secret_key=uuid.uuid4().hex,
+ base_vws_url="https://example.com",
+ transport=transport,
+ )
+ with pytest.raises(expected_exception=exception_type):
+ _ = await service.generate_vumark_instance(
+ target_id="target",
+ instance_id="instance",
+ accept=VuMarkAccept.PNG,
+ )
+
+ 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
new file mode 100644
index 000000000..57ebde9ac
--- /dev/null
+++ b/tests/transports/test_httpx2.py
@@ -0,0 +1,728 @@
+"""Tests for `httpx2` transports."""
+
+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.target import VuMarkTarget
+
+from vws import (
+ VWS,
+ AsyncCloudRecoService,
+ AsyncModelTargetService,
+ AsyncVuMarkService,
+ AsyncVWS,
+ CloudRecoService,
+ ModelTargetService,
+ VuMarkService,
+)
+from vws.model_target_datasets import (
+ ModelTargetDatasetType,
+ ModelTargetModel,
+)
+from vws.reports import ModelTargetDatasetStatuses, TargetStatuses
+from vws.response import Response
+from vws.transports import (
+ AsyncHTTPX2Transport,
+ HTTPX2Transport,
+)
+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"
+_MODEL_TARGET_CLIENT_CREDENTIALS = ("client-id", "client-secret")
+
+_HTTPX2_URL = "https://example.com/test"
+_HTTPX2_REFUSED_URL = "https://example.com/refused"
+
+
+@pytest.fixture(name="httpx2_requests")
+def fixture_httpx2_requests(
+ *,
+ monkeypatch: pytest.MonkeyPatch,
+) -> list[httpx2.Request]:
+ """Answer every ``httpx2`` request with a fake server, and collect the
+ requests which it sees.
+
+ The fake server answers ``OK`` to every request except those for
+ ``_HTTPX2_REFUSED_URL``, which it refuses to connect to.
+ """
+ requests_seen: list[httpx2.Request] = []
+
+ def handler(request: httpx2.Request) -> httpx2.Response:
+ """Answer an ``httpx2`` request.
+
+ Args:
+ request: The request to answer.
+
+ Returns:
+ An ``OK`` response.
+
+ Raises:
+ httpx2.ConnectError: The request is for the refused URL.
+ """
+ requests_seen.append(request)
+ if str(object=request.url) == _HTTPX2_REFUSED_URL:
+ raise httpx2.ConnectError(
+ message="Connection refused",
+ request=request,
+ )
+ return httpx2.Response(
+ status_code=HTTPStatus.OK,
+ text="OK",
+ headers={"X-Example": "example"},
+ )
+
+ mock_transport = httpx2.MockTransport(handler=handler)
+
+ class _Client(httpx2.Client):
+ """A synchronous client which uses the fake server."""
+
+ def __init__(self) -> None:
+ """Create a client which uses the fake server."""
+ super().__init__(transport=mock_transport)
+
+ class _AsyncClient(httpx2.AsyncClient):
+ """An asynchronous client which uses the fake server."""
+
+ def __init__(self) -> None:
+ """Create a client which uses the fake server."""
+ super().__init__(transport=mock_transport)
+
+ monkeypatch.setattr(target=httpx2, name="Client", value=_Client)
+ monkeypatch.setattr(target=httpx2, name="AsyncClient", value=_AsyncClient)
+ 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,
+ model_target_model: ModelTargetModel,
+) -> None:
+ """Generate, wait for and delete a Model Target dataset with
+ ``AsyncModelTargetService``.
+
+ Args:
+ transport: The transport for the client to use.
+ model_target_model: The model to generate a dataset from.
+ """
+ model_target_client = AsyncModelTargetService(
+ client_id=_MODEL_TARGET_CLIENT_ID,
+ client_secret=_MODEL_TARGET_CLIENT_CREDENTIALS[1],
+ transport=transport,
+ )
+ dataset_uuid = await model_target_client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[model_target_model],
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+ report = await model_target_client.wait_for_dataset_generated(
+ dataset_uuid=dataset_uuid,
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+ assert report.status == ModelTargetDatasetStatuses.DONE
+ await model_target_client.delete_dataset(
+ dataset_uuid=dataset_uuid,
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+
+def test_sync_float_timeout(
+ httpx2_requests: list[httpx2.Request],
+) -> None:
+ """``HTTPX2Transport`` works with a float timeout.
+
+ A float sets both the connect and read timeouts, and the response
+ is converted in full.
+ """
+ transport = HTTPX2Transport()
+ response = transport(
+ method="POST",
+ url=_HTTPX2_URL,
+ headers={"Content-Type": "text/plain"},
+ data=b"hello",
+ request_timeout=30.0,
+ )
+ (request,) = httpx2_requests
+ assert request.extensions["timeout"] == {
+ "connect": 30.0,
+ "read": 30.0,
+ "write": None,
+ "pool": None,
+ }
+ assert request.headers["Content-Type"] == "text/plain"
+ assert isinstance(response, Response)
+ assert response.status_code == HTTPStatus.OK
+ assert response.text == "OK"
+ assert response.url == _HTTPX2_URL
+ assert response.headers["x-example"] == "example"
+ assert response.request_body == b"hello"
+ assert response.content == b"OK"
+ assert response.tell_position == len(b"OK")
+
+
+def test_sync_tuple_timeout(
+ httpx2_requests: list[httpx2.Request],
+) -> None:
+ """``HTTPX2Transport`` works with a (connect, read) timeout
+ tuple.
+ """
+ transport = HTTPX2Transport()
+ response = transport(
+ method="POST",
+ url=_HTTPX2_URL,
+ headers={"Content-Type": "text/plain"},
+ data=b"hello",
+ request_timeout=(5.0, 30.0),
+ )
+ (request,) = httpx2_requests
+ assert request.extensions["timeout"] == {
+ "connect": 5.0,
+ "read": 30.0,
+ "write": None,
+ "pool": None,
+ }
+ assert isinstance(response, Response)
+ assert response.status_code == HTTPStatus.OK
+
+
+def test_sync_int_timeout(
+ httpx2_requests: list[httpx2.Request],
+) -> None:
+ """``HTTPX2Transport`` works with an int timeout."""
+ transport = HTTPX2Transport()
+ response = transport(
+ method="POST",
+ url=_HTTPX2_URL,
+ headers={"Content-Type": "text/plain"},
+ data=b"hello",
+ request_timeout=30,
+ )
+ (request,) = httpx2_requests
+ assert request.extensions["timeout"] == {
+ "connect": 30,
+ "read": 30,
+ "write": None,
+ "pool": None,
+ }
+ assert isinstance(response, Response)
+ assert response.status_code == HTTPStatus.OK
+
+
+def test_sync_empty_body(
+ httpx2_requests: list[httpx2.Request],
+) -> None:
+ """An empty request body is reported as ``None``, as it is for
+ the ``requests`` and ``httpx`` transports.
+ """
+ transport = HTTPX2Transport()
+ response = transport(
+ method="GET",
+ url=_HTTPX2_URL,
+ headers={},
+ data=b"",
+ request_timeout=30.0,
+ )
+ assert len(httpx2_requests) == 1
+ assert response.request_body is None
+
+
+def test_sync_context_manager(
+ httpx2_requests: list[httpx2.Request],
+) -> None:
+ """``HTTPX2Transport`` can be used as a context manager, and
+ leaving the context closes the client.
+ """
+ with HTTPX2Transport() as transport:
+ response = transport(
+ method="POST",
+ url=_HTTPX2_URL,
+ headers={"Content-Type": "text/plain"},
+ data=b"hello",
+ request_timeout=30.0,
+ )
+ assert len(httpx2_requests) == 1
+ assert isinstance(response, Response)
+ assert response.status_code == HTTPStatus.OK
+
+ with pytest.raises(
+ expected_exception=RuntimeError,
+ match="client has been closed",
+ ):
+ _ = transport(
+ method="POST",
+ url=_HTTPX2_URL,
+ headers={"Content-Type": "text/plain"},
+ data=b"hello",
+ request_timeout=30.0,
+ )
+
+
+def test_close(httpx2_requests: list[httpx2.Request]) -> None:
+ """Closing the transport closes the client."""
+ transport = HTTPX2Transport()
+ transport.close()
+ with pytest.raises(
+ expected_exception=RuntimeError,
+ match="client has been closed",
+ ):
+ _ = transport(
+ method="POST",
+ url=_HTTPX2_URL,
+ headers={"Content-Type": "text/plain"},
+ data=b"hello",
+ request_timeout=30.0,
+ )
+ assert not bool(httpx2_requests)
+
+
+def test_sync_httpx2_exceptions(
+ httpx2_requests: list[httpx2.Request],
+) -> None:
+ """Errors are raised as ``httpx2`` exceptions, which are not
+ ``httpx`` exceptions.
+ """
+ transport = HTTPX2Transport()
+ with pytest.raises(expected_exception=httpx2.ConnectError) as exc:
+ _ = transport(
+ method="GET",
+ url=_HTTPX2_REFUSED_URL,
+ headers={},
+ data=b"",
+ request_timeout=30.0,
+ )
+ assert len(httpx2_requests) == 1
+ assert not isinstance(exc.value, httpx.HTTPError)
+
+
+@pytest.mark.asyncio
+async def test_async_float_timeout(
+ httpx2_requests: list[httpx2.Request],
+) -> None:
+ """``AsyncHTTPX2Transport`` works with a float timeout.
+
+ A float sets both the connect and read timeouts, and the response
+ is converted in full.
+ """
+ transport = AsyncHTTPX2Transport()
+ response = await transport(
+ method="POST",
+ url=_HTTPX2_URL,
+ headers={"Content-Type": "text/plain"},
+ data=b"hello",
+ request_timeout=30.0,
+ )
+ (request,) = httpx2_requests
+ assert request.extensions["timeout"] == {
+ "connect": 30.0,
+ "read": 30.0,
+ "write": None,
+ "pool": None,
+ }
+ assert request.headers["Content-Type"] == "text/plain"
+ assert isinstance(response, Response)
+ assert response.status_code == HTTPStatus.OK
+ assert response.text == "OK"
+ assert response.url == _HTTPX2_URL
+ assert response.headers["x-example"] == "example"
+ assert response.request_body == b"hello"
+ assert response.content == b"OK"
+ assert response.tell_position == len(b"OK")
+
+
+@pytest.mark.asyncio
+async def test_async_tuple_timeout(
+ httpx2_requests: list[httpx2.Request],
+) -> None:
+ """``AsyncHTTPX2Transport`` works with a (connect, read)
+ timeout tuple.
+ """
+ transport = AsyncHTTPX2Transport()
+ response = await transport(
+ method="POST",
+ url=_HTTPX2_URL,
+ headers={"Content-Type": "text/plain"},
+ data=b"hello",
+ request_timeout=(5.0, 30.0),
+ )
+ (request,) = httpx2_requests
+ assert request.extensions["timeout"] == {
+ "connect": 5.0,
+ "read": 30.0,
+ "write": None,
+ "pool": None,
+ }
+ assert isinstance(response, Response)
+ assert response.status_code == HTTPStatus.OK
+
+
+@pytest.mark.asyncio
+async def test_async_int_timeout(
+ httpx2_requests: list[httpx2.Request],
+) -> None:
+ """``AsyncHTTPX2Transport`` works with an int timeout."""
+ transport = AsyncHTTPX2Transport()
+ response = await transport(
+ method="POST",
+ url=_HTTPX2_URL,
+ headers={"Content-Type": "text/plain"},
+ data=b"hello",
+ request_timeout=30,
+ )
+ (request,) = httpx2_requests
+ assert request.extensions["timeout"] == {
+ "connect": 30,
+ "read": 30,
+ "write": None,
+ "pool": None,
+ }
+ assert isinstance(response, Response)
+ assert response.status_code == HTTPStatus.OK
+
+
+@pytest.mark.asyncio
+async def test_async_empty_body(
+ httpx2_requests: list[httpx2.Request],
+) -> None:
+ """An empty request body is reported as ``None``, as it is for
+ the ``requests`` and ``httpx`` transports.
+ """
+ transport = AsyncHTTPX2Transport()
+ response = await transport(
+ method="GET",
+ url=_HTTPX2_URL,
+ headers={},
+ data=b"",
+ request_timeout=30.0,
+ )
+ assert len(httpx2_requests) == 1
+ assert response.request_body is None
+
+
+@pytest.mark.asyncio
+async def test_async_context_manager(
+ httpx2_requests: list[httpx2.Request],
+) -> None:
+ """``AsyncHTTPX2Transport`` can be used as an async context
+ manager, and leaving the context closes the client.
+ """
+ async with AsyncHTTPX2Transport() as transport:
+ response = await transport(
+ method="POST",
+ url=_HTTPX2_URL,
+ headers={"Content-Type": "text/plain"},
+ data=b"hello",
+ request_timeout=30.0,
+ )
+ assert len(httpx2_requests) == 1
+ assert isinstance(response, Response)
+ assert response.status_code == HTTPStatus.OK
+
+ with pytest.raises(
+ expected_exception=RuntimeError,
+ match="client has been closed",
+ ):
+ await transport(
+ method="POST",
+ url=_HTTPX2_URL,
+ headers={"Content-Type": "text/plain"},
+ data=b"hello",
+ request_timeout=30.0,
+ )
+
+
+@pytest.mark.asyncio
+async def test_aclose(httpx2_requests: list[httpx2.Request]) -> None:
+ """Closing the transport closes the client."""
+ transport = AsyncHTTPX2Transport()
+ await transport.aclose()
+ with pytest.raises(
+ expected_exception=RuntimeError,
+ match="client has been closed",
+ ):
+ await transport(
+ method="POST",
+ url=_HTTPX2_URL,
+ headers={"Content-Type": "text/plain"},
+ data=b"hello",
+ request_timeout=30.0,
+ )
+ assert not bool(httpx2_requests)
+
+
+@pytest.mark.asyncio
+async def test_async_httpx2_exceptions(
+ httpx2_requests: list[httpx2.Request],
+) -> None:
+ """Errors are raised as ``httpx2`` exceptions, which are not
+ ``httpx`` exceptions.
+ """
+ transport = AsyncHTTPX2Transport()
+ with pytest.raises(expected_exception=httpx2.ConnectError) as exc:
+ await transport(
+ method="GET",
+ url=_HTTPX2_REFUSED_URL,
+ headers={},
+ data=b"",
+ request_timeout=30.0,
+ )
+ assert len(httpx2_requests) == 1
+ 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")
+ database = VuMarkDatabase(vumark_targets={vumark_target})
+ with MockVWS() as mock, HTTPX2Transport() as transport:
+ mock.add_vumark_database(vumark_database=database)
+ vumark_client = VuMarkService(
+ server_access_key=database.server_access_key,
+ server_secret_key=database.server_secret_key,
+ transport=transport,
+ )
+ vumark_bytes = vumark_client.generate_vumark_instance(
+ target_id=vumark_target.target_id,
+ instance_id="instance",
+ accept=VuMarkAccept.PNG,
+ )
+ assert vumark_bytes.startswith(b"\x89PNG")
+
+
+def test_sync_mock_model_targets(
+ model_target_model: ModelTargetModel,
+) -> None:
+ """A Model Target dataset can be generated with
+ ``ModelTargetService``.
+ """
+ with (
+ MockVWS(processing_time_seconds=0.2),
+ HTTPX2Transport() as transport,
+ ):
+ model_target_client = ModelTargetService(
+ client_id=_MODEL_TARGET_CLIENT_ID,
+ client_secret=_MODEL_TARGET_CLIENT_CREDENTIALS[1],
+ transport=transport,
+ )
+ dataset_uuid = model_target_client.create_dataset(
+ name="dataset",
+ target_sdk="11.0",
+ models=[model_target_model],
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+ report = model_target_client.wait_for_dataset_generated(
+ dataset_uuid=dataset_uuid,
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+ assert report.status == ModelTargetDatasetStatuses.DONE
+ model_target_client.delete_dataset(
+ dataset_uuid=dataset_uuid,
+ dataset_type=ModelTargetDatasetType.STANDARD,
+ )
+
+
+@pytest.mark.parametrize(
+ argnames="custom_timeout",
+ argvalues=[0.1, (5.0, 0.1)],
+ ids=["float", "tuple"],
+)
+def test_sync_mock_timeout(
+ custom_timeout: float | tuple[float, float],
+) -> None:
+ """A response which takes longer than the read timeout raises an
+ ``httpx2`` timeout.
+ """
+ database = CloudDatabase()
+ sleeps: list[float] = []
+ with MockVWS(
+ response_delay_seconds=0.11,
+ sleep_fn=sleeps.append,
+ ) as mock:
+ mock.add_cloud_database(cloud_database=database)
+ with HTTPX2Transport() as transport:
+ vws_client = VWS(
+ 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()
+ # 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
+ ``AsyncVuMarkService``.
+ """
+ vumark_target = VuMarkTarget(name="vumark-template")
+ database = VuMarkDatabase(vumark_targets={vumark_target})
+ with MockVWS() as mock:
+ mock.add_vumark_database(vumark_database=database)
+ async with AsyncHTTPX2Transport() as transport:
+ vumark_client = AsyncVuMarkService(
+ server_access_key=database.server_access_key,
+ server_secret_key=database.server_secret_key,
+ transport=transport,
+ )
+ vumark_bytes = await vumark_client.generate_vumark_instance(
+ target_id=vumark_target.target_id,
+ instance_id="instance",
+ accept=VuMarkAccept.PNG,
+ )
+ assert vumark_bytes.startswith(b"\x89PNG")
+
+
+@pytest.mark.asyncio
+async def test_async_mock_model_targets(
+ model_target_model: ModelTargetModel,
+) -> None:
+ """A Model Target dataset can be generated with
+ ``AsyncModelTargetService``.
+ """
+ with MockVWS(processing_time_seconds=0.2):
+ async with AsyncHTTPX2Transport() as transport:
+ await _generate_dataset(
+ transport=transport,
+ model_target_model=model_target_model,
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ argnames="custom_timeout",
+ argvalues=[0.1, (5.0, 0.1)],
+ ids=["float", "tuple"],
+)
+async def test_async_mock_timeout(
+ custom_timeout: float | tuple[float, float],
+) -> None:
+ """A response which takes longer than the read timeout raises an
+ ``httpx2`` timeout.
+ """
+ database = CloudDatabase()
+ sleeps: list[float] = []
+ with MockVWS(
+ response_delay_seconds=0.11,
+ sleep_fn=sleeps.append,
+ ) as mock:
+ mock.add_cloud_database(cloud_database=database)
+ async with AsyncHTTPX2Transport() as transport:
+ vws_client = AsyncVWS(
+ 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()
+ # The mock sleeps for the read timeout before raising.
+ assert sleeps == [0.1]
diff --git a/tests/vws/__init__.py b/tests/vws/__init__.py
new file mode 100644
index 000000000..d56bfb0c6
--- /dev/null
+++ b/tests/vws/__init__.py
@@ -0,0 +1 @@
+"""Vws tests."""
diff --git a/tests/vws/test_configuration.py b/tests/vws/test_configuration.py
new file mode 100644
index 000000000..b510e61da
--- /dev/null
+++ b/tests/vws/test_configuration.py
@@ -0,0 +1,175 @@
+"""Tests for `VWS` configuration."""
+
+from __future__ import annotations
+
+import datetime
+from typing import TYPE_CHECKING, BinaryIO
+
+import pytest
+import requests
+from freezegun import freeze_time
+from mock_vws import MockVWS
+from mock_vws.database import CloudDatabase
+
+from vws import VWS
+
+if TYPE_CHECKING:
+ import io
+
+
+@pytest.mark.parametrize(
+ argnames=("response_delay_seconds", "expect_timeout"),
+ argvalues=[(29, False), (31, True)],
+)
+def test_default_timeout(
+ *,
+ image: io.BytesIO | BinaryIO,
+ response_delay_seconds: int,
+ expect_timeout: bool,
+) -> None:
+ """At 29 seconds there is no error; at 31 seconds there is a
+ timeout.
+ """
+ with (
+ freeze_time() as frozen_datetime,
+ MockVWS(
+ response_delay_seconds=response_delay_seconds,
+ sleep_fn=lambda seconds: (
+ frozen_datetime.tick(
+ delta=datetime.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),
+ ],
+)
+def test_custom_timeout(
+ *,
+ image: io.BytesIO | BinaryIO,
+ custom_timeout: float | tuple[float, float],
+ response_delay_seconds: float,
+ expect_timeout: bool,
+) -> None:
+ """Custom timeouts are honored for both float and tuple forms."""
+ with (
+ freeze_time() as frozen_datetime,
+ MockVWS(
+ response_delay_seconds=response_delay_seconds,
+ sleep_fn=lambda seconds: (
+ frozen_datetime.tick(
+ delta=datetime.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,
+ )
+
+
+def test_custom_base_url(image: io.BytesIO | BinaryIO) -> None:
+ """
+ It is possible to use add a target to a database under a custom
+ VWS
+ URL.
+ """
+ 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
new file mode 100644
index 000000000..0c30fdc1d
--- /dev/null
+++ b/tests/vws/test_processing.py
@@ -0,0 +1,176 @@
+"""Tests for `VWS` processing."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, BinaryIO
+
+import pytest
+from mock_vws import MockVWS
+from mock_vws.database import CloudDatabase
+
+from vws import VWS
+from vws.exceptions.custom_exceptions import (
+ TargetProcessingTimeoutError,
+)
+from vws.reports import (
+ TargetStatuses,
+)
+
+if TYPE_CHECKING:
+ import io
+
+
+def test_wait_for_target_processed(
+ *,
+ vws_client: VWS,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """It is possible to wait until a target is processed."""
+ 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
+
+
+def test_default_seconds_between_requests(
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """By default, 0.2 seconds are waited between polling requests."""
+ 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
+
+
+def test_custom_seconds_between_requests(
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """It is possible to customize the time waited between polling
+ requests.
+ """
+ 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
+
+
+def test_custom_timeout(image: io.BytesIO | BinaryIO) -> None:
+ """It is possible to set a maximum timeout."""
+ 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
new file mode 100644
index 000000000..87885efa5
--- /dev/null
+++ b/tests/vws/test_reports.py
@@ -0,0 +1,354 @@
+"""Tests for `VWS` reports."""
+
+from __future__ import annotations
+
+import calendar
+import datetime
+import time
+import uuid
+from http import HTTPStatus
+from typing import TYPE_CHECKING, BinaryIO
+
+import pytest
+from freezegun import freeze_time
+from mock_vws import MockVWS
+from mock_vws.database import CloudDatabase
+
+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"),
+ )
+
+
+def test_get_target_summary_report(
+ *,
+ vws_client: VWS,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """Details of a target are returned by
+ ``get_target_summary_report``.
+ """
+ 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=datetime.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
+
+
+def test_reco_counts_report(
+ *,
+ vws_client: VWS,
+ report_month: datetime.date,
+) -> None:
+ """A report can be requested, waited for and downloaded."""
+ report_request = vws_client.request_database_reco_counts_report(
+ year=report_month.year,
+ month=calendar.Month(value=report_month.month),
+ )
+ assert 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: datetime.date) -> None:
+ """Downloading a report before Vuforia has generated it raises an
+ error.
+ """
+ with MockVWS(processing_time_seconds=60) as mock:
+ database = CloudDatabase()
+ mock.add_cloud_database(cloud_database=database)
+ vws_client = VWS(
+ server_access_key=database.server_access_key,
+ server_secret_key=database.server_secret_key,
+ database_id=database.database_id,
+ )
+ report_request = vws_client.request_database_reco_counts_report(
+ year=current_month.year,
+ month=calendar.Month(value=current_month.month),
+ )
+
+ with pytest.raises(
+ expected_exception=RecoCountsReportNotReadyError,
+ ) as exc:
+ _ = vws_client.download_reco_counts_report(
+ presigned_url=report_request.presigned_url,
+ )
+
+ assert exc.value.response.status_code == HTTPStatus.NOT_FOUND
+
+
+def test_wait_timeout(*, current_month: datetime.date) -> None:
+ """Waiting for a report which is not generated in time raises an
+ error.
+ """
+ with MockVWS(processing_time_seconds=60) as mock:
+ database = CloudDatabase()
+ mock.add_cloud_database(cloud_database=database)
+ vws_client = VWS(
+ server_access_key=database.server_access_key,
+ server_secret_key=database.server_secret_key,
+ database_id=database.database_id,
+ )
+ report_request = vws_client.request_database_reco_counts_report(
+ year=current_month.year,
+ month=calendar.Month(value=current_month.month),
+ )
+
+ maximum_wait_seconds = 5
+ start_time = time.monotonic()
+
+ with pytest.raises(
+ expected_exception=RecoCountsReportTimeoutError,
+ ):
+ _ = vws_client.wait_for_reco_counts_report(
+ presigned_url=report_request.presigned_url,
+ seconds_between_requests=0.01,
+ timeout_seconds=0.05,
+ )
+
+ elapsed_time = time.monotonic() - start_time
+ assert elapsed_time < maximum_wait_seconds
+
+
+@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: datetime.date,
+) -> None:
+ """A database ID which does not match the given keys is
+ rejected.
+ """
+ with MockVWS() as mock:
+ database = CloudDatabase()
+ mock.add_cloud_database(cloud_database=database)
+ vws_client = VWS(
+ server_access_key=database.server_access_key,
+ server_secret_key=database.server_secret_key,
+ database_id=uuid.uuid4().hex,
+ )
+
+ with pytest.raises(
+ expected_exception=AuthenticationFailureError,
+ ) as exc:
+ _ = vws_client.request_database_reco_counts_report(
+ year=current_month.year,
+ month=calendar.Month(value=current_month.month),
+ )
+
+ assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED
+
+
+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: datetime.date) -> None:
+ """A client which was given no database ID cannot request a
+ report.
+ """
+ vws_client = VWS(
+ server_access_key=uuid.uuid4().hex,
+ server_secret_key=uuid.uuid4().hex,
+ )
+
+ with pytest.raises(expected_exception=DatabaseIdNotSetError):
+ _ = vws_client.request_database_reco_counts_report(
+ year=current_month.year,
+ month=calendar.Month(value=current_month.month),
+ )
+
+
+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
new file mode 100644
index 000000000..58190c65d
--- /dev/null
+++ b/tests/vws/test_targets.py
@@ -0,0 +1,374 @@
+"""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
+
+import pytest
+
+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])
+def test_add_target(
+ *,
+ vws_client: VWS,
+ image: io.BytesIO | BinaryIO,
+ application_metadata: bytes | None,
+ cloud_reco_client: CloudRecoService,
+ active_flag: bool,
+) -> None:
+ """No exception is raised when adding one target."""
+ 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 == []
+
+
+def test_add_two_targets(
+ *,
+ vws_client: VWS,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """No exception is raised when adding two targets with different
+ names.
+
+ This demonstrates that the image seek position is not changed.
+ """
+ for name in ("a", "b"):
+ _ = vws_client.add_target(
+ name=name,
+ width=1,
+ image=image,
+ active_flag=True,
+ application_metadata=None,
+ )
+
+
+def test_list_targets(
+ *,
+ vws_client: VWS,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """It is possible to get a list of target IDs."""
+ 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])
+
+
+def test_delete_target(
+ *,
+ vws_client: VWS,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """It is possible to delete a target."""
+ 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() == []
+
+
+def test_get_target_record(
+ *,
+ vws_client: VWS,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """Details of a target are returned by ``get_target_record``."""
+ 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
+
+
+def test_get_duplicate_targets(
+ *,
+ vws_client: VWS,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """It is possible to get the IDs of similar targets."""
+ 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")
+
+
+def test_update_target(
+ *,
+ vws_client: VWS,
+ image: io.BytesIO | BinaryIO,
+ different_high_quality_image: io.BytesIO,
+ cloud_reco_client: CloudRecoService,
+) -> None:
+ """It is possible to update a target."""
+ 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
+
+
+def test_no_fields_given(
+ *,
+ vws_client: VWS,
+ image: io.BytesIO | BinaryIO,
+) -> None:
+ """It is possible to give no update fields."""
+ 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)
diff --git a/tests/vws/test_vumark.py b/tests/vws/test_vumark.py
new file mode 100644
index 000000000..c1e08a915
--- /dev/null
+++ b/tests/vws/test_vumark.py
@@ -0,0 +1,33 @@
+"""Tests for `VWS` vumark."""
+
+from __future__ import annotations
+
+import pytest
+
+# pytest-beartype resolves fixture annotations at runtime.
+from vws import VuMarkService # noqa: TC001
+from vws.vumark_accept import VuMarkAccept
+
+
+@pytest.mark.parametrize(
+ argnames=("accept", "expected_prefix"),
+ argvalues=[
+ pytest.param(VuMarkAccept.PNG, b"\x89PNG\r\n\x1a\n", id="png"),
+ pytest.param(VuMarkAccept.SVG, b"<", id="svg"),
+ pytest.param(VuMarkAccept.PDF, b"%PDF", id="pdf"),
+ ],
+)
+def test_generate_vumark_instance(
+ *,
+ vumark_service_client: VuMarkService,
+ vumark_target_id: str,
+ accept: VuMarkAccept,
+ expected_prefix: bytes,
+) -> None:
+ """The returned bytes match the requested format."""
+ result = vumark_service_client.generate_vumark_instance(
+ target_id=vumark_target_id,
+ instance_id="12345",
+ accept=accept,
+ )
+ assert result.startswith(expected_prefix)