From 42f2f0d5b80711b5e73001ce7c19141341e55f72 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Tue, 6 Oct 2026 02:43:43 +0100 Subject: [PATCH 1/3] Use module-level test functions for Karva preparation --- tests/test_async_model_targets.py | 714 ++++++------- tests/test_async_query.py | 396 ++++--- tests/test_async_vws.py | 1148 ++++++++++---------- tests/test_model_targets.py | 1115 ++++++++++---------- tests/test_query.py | 828 ++++++++------- tests/test_transports.py | 1330 +++++++++++------------ tests/test_vws.py | 1643 ++++++++++++++--------------- 7 files changed, 3553 insertions(+), 3621 deletions(-) diff --git a/tests/test_async_model_targets.py b/tests/test_async_model_targets.py index ca4662694..40feb8305 100644 --- a/tests/test_async_model_targets.py +++ b/tests/test_async_model_targets.py @@ -78,431 +78,421 @@ async def _assert_dataset_error_response( 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] +# Tests for getting an access token. + + +@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", ), - ) -> None: - """Dataset failures map to exceptions through the mock.""" - failure = ModelTargetFailureResponse( + 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, ) - 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, - ) +# Tests for the dataset lifecycle. -class TestDatasetLifecycle: - """Tests for the dataset lifecycle.""" - @staticmethod - @pytest.mark.asyncio - @pytest.mark.parametrize( - argnames="dataset_type", - argvalues=_DATASET_TYPES, +@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, ) - 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, - ) + 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 + 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 = 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, ) - 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( +@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=dataset_type, + dataset_type=ModelTargetDatasetType.STANDARD, ) - with pytest.raises(expected_exception=UnknownModelTargetDatasetError): - await async_model_target_client.get_dataset_status( - dataset_uuid=dataset_uuid, - dataset_type=dataset_type, - ) + 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=[], + ) - @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( + assert bool( + await async_model_target_client.create_dataset( name="dataset", target_sdk="11.0", - models=[model_target_model], - dataset_type=ModelTargetDatasetType.STANDARD, + models=[model_target_model, other_model], + dataset_type=ModelTargetDatasetType.ADVANCED, ) + ) + + +# Tests for requests for datasets which do not exist. + - report = await async_model_target_client.get_dataset_status( +@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 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], + 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, ) - 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 +@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, ) - 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( + + +# Tests for requests which Vuforia rejects. + + +@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=[model_target_model], - dataset_type=ModelTargetDatasetType.ADVANCED, - ) - - report = await async_model_target_client.get_dataset_status( - dataset_uuid=dataset_uuid, + models=[ModelTargetModel(name="model")], 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 exc.value.response.status_code == HTTPStatus.BAD_REQUEST + (detail,) = exc.value.details + assert detail.code == "VALIDATION_ERROR" + + +# Tests for datasets which Vuforia does not generate cleanly. - assert bool( - await async_model_target_client.create_dataset( + +@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, other_model], - dataset_type=ModelTargetDatasetType.ADVANCED, + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, ) - ) - - -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( + report = await client.wait_for_dataset_generated( 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, + 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, ) - @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, + 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 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( +# Tests for waiting for a dataset to be generated. + + +@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=[ModelTargetModel(name="model")], + models=[model_target_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( + 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, ) - 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, - ) + report = await client.get_dataset_status( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) - assert report.status == ModelTargetDatasetStatuses.PROCESSING + assert report.status == ModelTargetDatasetStatuses.PROCESSING diff --git a/tests/test_async_query.py b/tests/test_async_query.py index 9d9e1dbdc..d5fca18b3 100644 --- a/tests/test_async_query.py +++ b/tests/test_async_query.py @@ -52,227 +52,221 @@ async def __call__( import io -class TestQuery: - """Tests for making async image queries.""" +# 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(), - ) +@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 == [] + + +@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 + + +@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.""" - - @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, - ) - - await async_vws_client.wait_for_target_processed( - target_id=target_id, - ) - - async_cloud_reco_client = AsyncCloudRecoService( - client_access_key=database.client_access_key, - client_secret_key=database.client_secret_key, - base_vwq_url=base_vwq_url, - ) - - matches = await async_cloud_reco_client.query( - image=image, - ) - assert len(matches) == 1 - match = matches[0] - assert match.target_id == target_id - - -class TestMaxNumResults: - """Tests for the ``max_num_results`` parameter of - ``query``. + with pytest.raises(expected_exception=ServerError): + _ = await client.query(image=image) + + +# Tests for using a custom base VWQ URL. + + +@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 + +# Tests for the ``max_num_results`` parameter of +# ``query``. -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 +# Tests for the ``include_target_data`` parameter of +# ``query``. + + +@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 index 7b4e4fbb5..3ff74f9a2 100644 --- a/tests/test_async_vws.py +++ b/tests/test_async_vws.py @@ -38,340 +38,304 @@ import io -class TestAddTarget: - """Tests for adding a target.""" +# 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( +@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, ) - matching_targets = await async_cloud_reco_client.query( + ).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, ) - 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, - ) +# Tests for using a custom base VWS URL. -class TestListTargets: - """Tests for listing targets.""" +@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, + ) - @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( + 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.""" +# Tests for listing targets. - @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 == [] +@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]) + + +# Test for deleting a target. + + +@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, - ) +# Tests for getting a summary report for a target. - 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_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, + ) -class TestGetDatabaseSummaryReport: - """Tests for getting a summary report for a database.""" + assert report.target_name == target_name + assert report.active_flag is True + assert report.total_recos == 0 - @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 +# Tests for getting a summary report for a database. -class TestGetTargetRecord: - """Tests for getting a record of a target.""" +@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() - @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, - ) + assert isinstance(report, DatabaseSummaryReport) + assert report.active_images == 0 - 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 +# Tests for getting a record of a target. -class TestWaitForTargetProcessed: - """Tests for waiting for a target to be processed.""" +@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, + ) - @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, - ) + 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="", + ) - target_id = await async_vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) + assert result.target_record == expected_target_record + assert result.status == TargetStatuses.PROCESSING - with pytest.raises( - expected_exception=(TargetProcessingTimeoutError), - ): - await async_vws_client.wait_for_target_processed( - target_id=target_id, - timeout_seconds=0.1, - ) +# Tests for waiting for a target to be processed. -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.""" +@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, @@ -379,101 +343,127 @@ async def test_get_duplicate_targets( 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] + with pytest.raises( + expected_exception=(TargetProcessingTimeoutError), + ): + await async_vws_client.wait_for_target_processed( + target_id=target_id, + timeout_seconds=0.1, + ) -class TestUpdateTarget: - """Tests for updating a target.""" +# Tests for getting duplicate targets. - @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 +@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, + ) - @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, - ) + 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] + + +# Tests for updating a target. + + +@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, + ) class _ForbiddenDownloadTransport: @@ -507,229 +497,221 @@ async def __call__( ) -class TestRecoCountsReport: - """Tests for database reco counts reports.""" +# 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, - ) +@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) - # 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), - ) - ) + report = await client.wait_for_reco_counts_report( + presigned_url=report_request.presigned_url, + ) - 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), - ) - ) + # 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") - 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 +@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), + ) - @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 + with pytest.raises( + expected_exception=RecoCountsReportNotReadyError, + ) as exc: + await client.download_reco_counts_report( + presigned_url=report_request.presigned_url, ) - @staticmethod - @pytest.mark.asyncio - async def test_download_error() -> None: - """An error response from the report's URL raises an error.""" + 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=uuid.uuid4().hex, - server_secret_key=uuid.uuid4().hex, - transport=_ForbiddenDownloadTransport(), + 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=RecoCountsReportDownloadError, - ) as exc: - await client.download_reco_counts_report( - presigned_url="https://example.com/reports/recoCounts/x", + 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, ) - assert exc.value.response.status_code == HTTPStatus.FORBIDDEN + 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, + ) - @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. - """ + 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=uuid.uuid4().hex, - server_secret_key=uuid.uuid4().hex, + 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=DatabaseIdNotSetError): + 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", + ) -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) + 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), + ) + + +# Tests for generating VuMark instances. + + +@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 index 4c55e40dc..e3446a51c 100644 --- a/tests/test_model_targets.py +++ b/tests/test_model_targets.py @@ -136,643 +136,631 @@ def __call__( ) -class TestAccessToken: - """Tests for getting an access token.""" +# 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()) +@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], + ) - @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, - ) + assert bool(client.get_access_token()) - 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_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, + ) - @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, + for _ in range(2): + _ = client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, ) - 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] + assert len(token_urls) == 1 + transport.close() - 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", - ), - ], +@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, ) - 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 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, ) - client = ModelTargetService( - client_id=_CLIENT_ID, - client_secret=_CLIENT_CREDENTIALS[1], + # 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, ) - 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, - ) + 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 + - assert isinstance(exc.value, expected_exception) - assert exc.value.response.status_code == status_code - assert exc.value.response.text == body +@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) -class TestDatasetLifecycle: - """Tests for the dataset lifecycle.""" - @staticmethod - @pytest.mark.parametrize( - argnames="dataset_type", - argvalues=_DATASET_TYPES, +@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, ) - 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( + 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=dataset_type, + dataset_type=ModelTargetDatasetType.STANDARD, ) - report = model_target_client.wait_for_dataset_generated( - dataset_uuid=dataset_uuid, - dataset_type=dataset_type, - ) + assert isinstance(exc.value, expected_exception) + assert exc.value.response.status_code == status_code + assert exc.value.response.text == body - 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( +# Tests for the dataset lifecycle. + + +@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, ) - with zipfile.ZipFile( - file=io.BytesIO(initial_bytes=dataset) - ) as archive: - assert archive.namelist() == ["MTDataset.dat", "MTDataset.xml"] - model_target_client.delete_dataset( +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=dataset_type, + dataset_type=ModelTargetDatasetType.STANDARD, ) - with pytest.raises(expected_exception=UnknownModelTargetDatasetError): - _ = model_target_client.get_dataset_status( - dataset_uuid=dataset_uuid, - dataset_type=dataset_type, - ) + assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert exc.value.code == "UNSUPPORTED_STATE" + assert exc.value.target == dataset_uuid - @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( + +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_target_model], + models=[model], dataset_type=ModelTargetDatasetType.STANDARD, ) + ) + - report = model_target_client.get_dataset_status( +# Tests for requests for datasets which do not exist. + + +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 report.status == ModelTargetDatasetStatuses.PROCESSING - assert report.eta is not None - assert report.eta >= report.created_at - assert report.completed_at is None + 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_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], + +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, ) - 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 +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, ) - 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( + +# Tests for requests which Vuforia rejects. + + +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=[model_target_model], + models=[ModelTargetModel(name="model", views=[])], dataset_type=ModelTargetDatasetType.STANDARD, ) - report = model_target_client.get_dataset_status( - dataset_uuid=dataset_uuid, - dataset_type=ModelTargetDatasetType.ADVANCED, - ) + 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 - 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=[], - ) +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==", + ) - dataset_uuid = model_target_client.create_dataset( + with pytest.raises(expected_exception=ModelTargetValidationError): + _ = model_target_client.create_dataset( name="dataset", target_sdk="11.0", - models=[model_target_model, other_model], - dataset_type=ModelTargetDatasetType.ADVANCED, + models=[model], + dataset_type=ModelTargetDatasetType.STANDARD, ) - 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, - ) +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 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, - ) +# Tests for datasets which Vuforia does not generate cleanly. - 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, - ) +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, + ) -class TestValidation: - """Tests for requests which Vuforia rejects.""" + assert report.status == ModelTargetDatasetStatuses.FAILED + assert report.error is not None + assert report.error.message == message + assert report.warning is None - @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=[])], + expected_exception=ModelTargetDatasetNotDoneError, + ): + _ = client.download_dataset( + dataset_uuid=dataset_uuid, 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==", +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, ) - with pytest.raises(expected_exception=ModelTargetValidationError): - _ = model_target_client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model], - dataset_type=ModelTargetDatasetType.STANDARD, - ) + report = client.wait_for_dataset_generated( + dataset_uuid=dataset_uuid, + 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], + 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, ) + ) - (detail,) = exc.value.details - assert detail.message == "exactly one model should be provided" +# Tests for waiting for a dataset to be generated. -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, - ) +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, + ) - report = client.wait_for_dataset_generated( + 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, ) - 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, - ) +# Tests for reading responses which are not shaped like Model Target +# Web API errors. - 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, - ) - ) +@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)) -class TestWaitForDatasetGenerated: - """Tests for waiting for a dataset to be generated.""" + assert not bool(error.code) + assert not bool(error.message) + assert not bool(error.target) + assert not bool(error.details) - @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, - ) +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) -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", "[]", "{}"], +@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, + }, ) - 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)) + error = ModelTargetOAuth2Error(response=_response(text=text)) - assert error.error == "invalid_request" - assert error.error_description == description + assert error.error == "invalid_request" + assert error.error_description == description @pytest.mark.parametrize( @@ -795,37 +783,36 @@ def test_invalid_oauth2_token_response_values( _ = access_token_from_response(response=response) -class TestBaseVWSURL: - """Tests for using a custom base URL.""" +# 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( + +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, - 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, - ) + ) + 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, - ) + report = client.get_dataset_status( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) - assert report.dataset_uuid == dataset_uuid + assert report.dataset_uuid == dataset_uuid diff --git a/tests/test_query.py b/tests/test_query.py index 9d0ac2d73..716c98bbe 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -63,455 +63,449 @@ 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, - ) - - with pytest.raises(expected_exception=TypeError): - _ = client.query(image=image) +# Tests for making image queries. + + +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 - @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) + + +# Tests for the default request timeout. + + +@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 - + assert not bool(matches) + + +# Tests for custom request timeout values. + + +@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, + ) -class TestMaxNumResults: - """Tests for the ``max_num_results`` parameter of ``query``.""" + 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) + + +# Tests for using a custom base VWQ URL. + + +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, + ) - @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, + 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) - 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, + 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, + + 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_3 = 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) - 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, + 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 len(matches) == max_num_results + matches = cloud_reco_client.query(image=image) + assert len(matches) == 1 -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, - 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 +# Tests for the ``max_num_results`` parameter of ``query``. - @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, - ) - 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_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 - @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, - ) - 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 + +# Tests for the ``include_target_data`` parameter of ``query``. + + +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 index 82b9db76d..e4fc4ad08 100644 --- a/tests/test_transports.py +++ b/tests/test_transports.py @@ -44,20 +44,89 @@ 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() +# Tests for ``HTTPXTransport``. + + +@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", @@ -65,96 +134,100 @@ def test_float_timeout() -> None: 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 + assert route.called + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK -class TestAsyncHTTPXTransport: - """Tests for ``AsyncHTTPXTransport``.""" +# 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() + +@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", @@ -162,84 +235,9 @@ async def test_float_timeout() -> None: 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 + assert route.called + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK class _FalsyTransport: @@ -611,17 +609,116 @@ def __init__(self) -> None: return requests_seen -class TestHTTPX2Transport: - """Tests for ``HTTPX2Transport``.""" +# 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() +def test_httpx2_transport_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_httpx2_transport_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_httpx2_transport_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_httpx2_transport_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_httpx2_transport_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, @@ -629,162 +726,175 @@ def test_float_timeout(httpx2_requests: list[httpx2.Request]) -> None: 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( + 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=(5.0, 30.0), + request_timeout=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( + + +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, + request_timeout=30.0, ) - (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( + assert not bool(httpx2_requests) + + +def test_httpx2_transport_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_URL, + url=_HTTPX2_REFUSED_URL, headers={}, data=b"", request_timeout=30.0, ) - assert len(httpx2_requests) == 1 - assert response.request_body is None + assert len(httpx2_requests) == 1 + assert not isinstance(exc.value, httpx.HTTPError) - @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) +# Tests for ``AsyncHTTPX2Transport``. - @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) +@pytest.mark.asyncio +async def test_async_httpx2_transport_float_timeout( + httpx2_requests: list[httpx2.Request], +) -> None: + """``AsyncHTTPX2Transport`` works with a float timeout. -class TestAsyncHTTPX2Transport: - """Tests for ``AsyncHTTPX2Transport``.""" + 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_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() +@pytest.mark.asyncio +async def test_async_httpx2_transport_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_httpx2_transport_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_httpx2_transport_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_httpx2_transport_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, @@ -792,281 +902,183 @@ async def test_float_timeout( 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( + 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=(5.0, 30.0), + request_timeout=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( + + +@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, + request_timeout=30.0, ) - (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( + assert not bool(httpx2_requests) + + +@pytest.mark.asyncio +async def test_async_httpx2_transport_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_URL, + url=_HTTPX2_REFUSED_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, - ) + assert len(httpx2_requests) == 1 + assert not isinstance(exc.value, httpx.HTTPError) - @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) + +# Tests for synchronous clients using ``HTTPX2Transport`` against +# the mock. + + +def test_httpx2_transport_with_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_httpx2_transport_with_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") -class TestHTTPX2TransportWithMock: - """Tests for synchronous clients using ``HTTPX2Transport`` against - the mock. +def test_httpx2_transport_with_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, + ) - @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) + +@pytest.mark.parametrize( + argnames="custom_timeout", + argvalues=[0.1, (5.0, 0.1)], + ids=["float", "tuple"], +) +def test_httpx2_transport_with_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, ) - 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] + 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( @@ -1142,91 +1154,93 @@ async def _generate_dataset( ) -class TestAsyncHTTPX2TransportWithMock: - """Tests for asynchronous clients using ``AsyncHTTPX2Transport`` - against the mock. +# Tests for asynchronous clients using ``AsyncHTTPX2Transport`` +# against the mock. + + +@pytest.mark.asyncio +async def test_async_httpx2_transport_with_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, + ) - @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] + +@pytest.mark.asyncio +async def test_async_httpx2_transport_with_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_httpx2_transport_with_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_httpx2_transport_with_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/test_vws.py b/tests/test_vws.py index 06a17cd90..9cafd69b5 100644 --- a/tests/test_vws.py +++ b/tests/test_vws.py @@ -78,125 +78,115 @@ def __call__( ) -class TestAddTarget: - """Tests for adding a target.""" +# 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( +@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=width, + width=1, image=image, - application_metadata=encoded_metadata, - active_flag=active_flag, + active_flag=True, + application_metadata=None, ) - 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, - ) +# Tests for the default request timeout. -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, - ) +@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: + if expect_timeout: + with pytest.raises( + expected_exception=requests.exceptions.Timeout, + ): _ = vws_client.add_target( name="x", width=1, @@ -204,65 +194,64 @@ def test_default_timeout( 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.""" +# 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: +@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_request_timeout_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, @@ -270,28 +259,7 @@ def test_custom_timeout( 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, - ) - + else: _ = vws_client.add_target( name="x", width=1, @@ -300,185 +268,292 @@ def test_custom_base_url(image: io.BytesIO | BinaryIO) -> None: 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()) +# Tests for using a custom base VWS URL. -class TestListTargets: - """Tests for listing targets.""" +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, + ) - @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( + _ = 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, + + +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 sorted(vws_client.list_targets()) == sorted([id_1, id_2]) + assert not bool(vws_client.list_targets()) -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.""" +# Tests for listing targets. + + +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]) + + +# Test for deleting a target. + + +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() == [] + + +# Tests for getting a summary report for a target. + + +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="x", + name=target_name, 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() == [] + 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 -class TestGetTargetSummaryReport: - """Tests for getting a summary report for a target.""" + assert report == expected_report - @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) +# Tests for getting a summary report for a database. - 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 - ) +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 == expected_report + 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 + + +# Tests for getting a record of a target. + + +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="", + ) -class TestGetDatabaseSummaryReport: - """Tests for getting a summary report for a database.""" + assert result.target_record == expected_target_record - @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() + 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 + ) - 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 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, + ) - 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 + vws_client.wait_for_target_processed(target_id=target_id) + result = vws_client.get_target_record(target_id=target_id) - assert report == expected_report + assert result.status == TargetStatuses.FAILED -class TestGetTargetRecord: - """Tests for getting a record of a target.""" +# Tests for waiting for a target to be processed. + + +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, + ) - @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, @@ -487,69 +562,100 @@ def test_get_target_record( 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="", + 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 - 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 +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, ) - 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, + image=image, 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.""" + 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_wait_for_target_processed_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, + ) - @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, @@ -557,204 +663,73 @@ def test_wait_for_target_processed( 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, - ) - + with pytest.raises(expected_exception=TargetProcessingTimeoutError): 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, + timeout_seconds=0.1, ) - 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, + timeout_seconds=0.5, + ) + report = vws_client.get_target_summary_report(target_id=target_id) + assert report.status != TargetStatuses.PROCESSING - 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 +# Tests for getting duplicate targets. -class TestGetDuplicateTargets: - """Tests for getting duplicate targets.""" +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, + ) - @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] - 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]], + +@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, ) - 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") + with pytest.raises(expected_exception=TypeError): + _ = client.get_duplicate_targets(target_id="target-id") @pytest.mark.parametrize( @@ -774,85 +749,84 @@ def test_invalid_vws_response_envelope(*, body: object) -> None: 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, - ) +# Tests for updating a target. + + +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, + ) - 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 + 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, + ) - @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) + 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) class _ForbiddenDownloadTransport: @@ -886,239 +860,236 @@ def __call__( ) -class TestRecoCountsReport: - """Tests for database reco counts reports.""" +# 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, - ) +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) - # 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), - ) + report = vws_client.wait_for_reco_counts_report( + presigned_url=report_request.presigned_url, + ) - with pytest.raises( - expected_exception=RecoCountsReportNotReadyError, - ) as exc: - _ = vws_client.download_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") - 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), + +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, ) - maximum_wait_seconds = 5 - start_time = time.monotonic() + assert exc.value.response.status_code == HTTPStatus.NOT_FOUND - 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, - ) +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), + ) - assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST + maximum_wait_seconds = 5 + start_time = time.monotonic() - @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=RecoCountsReportTimeoutError, + ): + _ = vws_client.wait_for_reco_counts_report( + presigned_url=report_request.presigned_url, + seconds_between_requests=0.01, + timeout_seconds=0.05, ) - 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), - ) + elapsed_time = time.monotonic() - start_time + assert elapsed_time < maximum_wait_seconds - 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(), +@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, ) - 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.BAD_REQUEST - 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. - """ +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=uuid.uuid4().hex, - server_secret_key=uuid.uuid4().hex, + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + database_id=uuid.uuid4().hex, ) - with pytest.raises(expected_exception=DatabaseIdNotSetError): + 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 + -class TestRecoCountsReportParsing: - """Tests for parsing downloaded reco counts reports.""" +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(), + ) - @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" + with pytest.raises( + expected_exception=RecoCountsReportDownloadError, + ) as exc: + _ = vws_client.download_reco_counts_report( + presigned_url="https://example.com/reports/recoCounts/x", + ) - report = RecoCountsReport.from_csv(csv_bytes=csv_bytes) + assert exc.value.response.status_code == HTTPStatus.FORBIDDEN - 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" +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, + ) - report = RecoCountsReport.from_csv( - csv_bytes=csv_text.encode(encoding="utf-8"), + with pytest.raises(expected_exception=DatabaseIdNotSetError): + _ = vws_client.request_database_reco_counts_report( + year=current_month.year, + month=calendar.Month(value=current_month.month), ) - (item,) = report.reco_counts - assert item.target_id == "abc" - assert item.reco_count == expected_reco_count + +# Tests for parsing downloaded reco counts reports. + + +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 -class TestGenerateVumarkInstance: - """Tests for generating VuMark instances.""" +# 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"), - ], + +@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, ) - 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) + assert result.startswith(expected_prefix) From 473a2f87599be89416dddc349eb885eec7c112e7 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Tue, 6 Oct 2026 08:47:26 +0100 Subject: [PATCH 2/3] Organize test functions into files by behavior --- pyproject.toml | 3 +- tests/async_model_targets/__init__.py | 1 + tests/async_model_targets/helpers.py | 6 + .../test_authentication.py | 143 +++ .../test_datasets.py} | 164 +-- tests/async_vws/__init__.py | 1 + tests/async_vws/test_configuration.py | 40 + tests/async_vws/test_processing.py | 77 ++ tests/async_vws/test_reports.py | 281 +++++ tests/async_vws/test_targets.py | 297 +++++ tests/async_vws/test_vumark.py | 46 + tests/model_targets/__init__.py | 1 + tests/model_targets/helpers.py | 39 + tests/model_targets/test_authentication.py | 236 ++++ tests/model_targets/test_configuration.py | 44 + .../test_datasets.py} | 396 +----- tests/model_targets/test_errors.py | 84 ++ tests/test_async_query.py | 21 +- tests/test_async_vws.py | 717 ----------- tests/test_query.py | 18 - tests/test_vws.py | 1095 ----------------- tests/transports/__init__.py | 1 + tests/transports/test_httpx.py | 523 ++++++++ .../test_httpx2.py} | 702 ++--------- tests/vws/__init__.py | 1 + tests/vws/test_configuration.py | 175 +++ tests/vws/test_processing.py | 176 +++ tests/vws/test_reports.py | 354 ++++++ tests/vws/test_targets.py | 374 ++++++ tests/vws/test_vumark.py | 33 + 30 files changed, 3051 insertions(+), 2998 deletions(-) create mode 100644 tests/async_model_targets/__init__.py create mode 100644 tests/async_model_targets/helpers.py create mode 100644 tests/async_model_targets/test_authentication.py rename tests/{test_async_model_targets.py => async_model_targets/test_datasets.py} (69%) create mode 100644 tests/async_vws/__init__.py create mode 100644 tests/async_vws/test_configuration.py create mode 100644 tests/async_vws/test_processing.py create mode 100644 tests/async_vws/test_reports.py create mode 100644 tests/async_vws/test_targets.py create mode 100644 tests/async_vws/test_vumark.py create mode 100644 tests/model_targets/__init__.py create mode 100644 tests/model_targets/helpers.py create mode 100644 tests/model_targets/test_authentication.py create mode 100644 tests/model_targets/test_configuration.py rename tests/{test_model_targets.py => model_targets/test_datasets.py} (55%) create mode 100644 tests/model_targets/test_errors.py delete mode 100644 tests/test_async_vws.py delete mode 100644 tests/test_vws.py create mode 100644 tests/transports/__init__.py create mode 100644 tests/transports/test_httpx.py rename tests/{test_transports.py => transports/test_httpx2.py} (56%) create mode 100644 tests/vws/__init__.py create mode 100644 tests/vws/test_configuration.py create mode 100644 tests/vws/test_processing.py create mode 100644 tests/vws/test_reports.py create mode 100644 tests/vws/test_targets.py create mode 100644 tests/vws/test_vumark.py 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/helpers.py b/tests/async_model_targets/helpers.py new file mode 100644 index 000000000..088475131 --- /dev/null +++ b/tests/async_model_targets/helpers.py @@ -0,0 +1,6 @@ +"""Shared helpers for async model targets tests.""" + +# 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/async_model_targets/test_authentication.py b/tests/async_model_targets/test_authentication.py new file mode 100644 index 000000000..e8eb2ab26 --- /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.async_model_targets.helpers 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/test_async_model_targets.py b/tests/async_model_targets/test_datasets.py similarity index 69% rename from tests/test_async_model_targets.py rename to tests/async_model_targets/test_datasets.py index 40feb8305..135d2e588 100644 --- a/tests/test_async_model_targets.py +++ b/tests/async_model_targets/test_datasets.py @@ -1,7 +1,6 @@ -"""Tests for the async Model Target Web API client.""" +"""Tests for asynchronous Model Target datasets.""" import io -import secrets import uuid import zipfile from http import HTTPStatus @@ -9,23 +8,18 @@ import pytest from mock_vws import ( MockVWS, - ModelTargetFailureResponse, ModelTargetGenerationFailure, ModelTargetGenerationWarning, ) +from tests.async_model_targets.helpers import CLIENT_CREDENTIALS, CLIENT_ID 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, @@ -34,142 +28,12 @@ ) 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 - - -# Tests for getting an access token. - - -@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, - ) - - -# Tests for the dataset lifecycle. - - @pytest.mark.asyncio @pytest.mark.parametrize( argnames="dataset_type", @@ -318,9 +182,6 @@ async def test_advanced_dataset_takes_multiple_models( ) -# Tests for requests for datasets which do not exist. - - @pytest.mark.asyncio async def test_get_status( *, @@ -366,9 +227,6 @@ async def test_delete( ) -# Tests for requests which Vuforia rejects. - - @pytest.mark.asyncio async def test_no_cad_data( *, @@ -390,9 +248,6 @@ async def test_no_cad_data( assert detail.code == "VALIDATION_ERROR" -# Tests for datasets which Vuforia does not generate cleanly. - - @pytest.mark.asyncio async def test_generation_failure( *, @@ -406,8 +261,8 @@ async def test_generation_failure( model_target_generation_failure=failure, ): async with AsyncModelTargetService( - client_id=_CLIENT_ID, - client_secret=_CLIENT_CREDENTIALS[1], + client_id=CLIENT_ID, + client_secret=CLIENT_CREDENTIALS[1], ) as client: dataset_uuid = await client.create_dataset( name="dataset", @@ -440,8 +295,8 @@ async def test_generation_warning( model_target_generation_warning=warning, ): async with AsyncModelTargetService( - client_id=_CLIENT_ID, - client_secret=_CLIENT_CREDENTIALS[1], + client_id=CLIENT_ID, + client_secret=CLIENT_CREDENTIALS[1], ) as client: dataset_uuid = await client.create_dataset( name="dataset", @@ -462,16 +317,13 @@ async def test_generation_warning( assert detail.code == "LOW_RECOGNITION_QUALITY" -# Tests for waiting for a dataset to be generated. - - @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], + client_id=CLIENT_ID, + client_secret=CLIENT_CREDENTIALS[1], ) as client: dataset_uuid = await client.create_dataset( name="dataset", 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_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/helpers.py b/tests/model_targets/helpers.py new file mode 100644 index 000000000..2676a8173 --- /dev/null +++ b/tests/model_targets/helpers.py @@ -0,0 +1,39 @@ +"""Shared helpers for model targets tests.""" + +from http import HTTPStatus + +from beartype import beartype + +from vws.response import Response + +# 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") + + +@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..83da975c1 --- /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_targets.helpers 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..3c8fd2278 --- /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_targets.helpers 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/test_model_targets.py b/tests/model_targets/test_datasets.py similarity index 55% rename from tests/test_model_targets.py rename to tests/model_targets/test_datasets.py index e3446a51c..897c60978 100644 --- a/tests/test_model_targets.py +++ b/tests/model_targets/test_datasets.py @@ -1,35 +1,32 @@ -"""Tests for the Model Target Web API client.""" +"""Tests for Model Target datasets.""" 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 tests.model_targets.helpers import ( + CLIENT_CREDENTIALS, + CLIENT_ID, + response_with_status, +) 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, @@ -39,13 +36,6 @@ 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, @@ -53,254 +43,6 @@ ] -@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, - ) - - -# Tests for getting an access token. - - -@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 - - -# Tests for the dataset lifecycle. - - @pytest.mark.parametrize( argnames="dataset_type", argvalues=_DATASET_TYPES, @@ -480,9 +222,6 @@ def test_state_based_model( ) -# Tests for requests for datasets which do not exist. - - def test_get_status(*, model_target_client: ModelTargetService) -> None: """An exception is raised for an unknown dataset.""" dataset_uuid = uuid.uuid4().hex @@ -517,9 +256,6 @@ def test_delete(*, model_target_client: ModelTargetService) -> None: ) -# Tests for requests which Vuforia rejects. - - def test_no_cad_data( *, model_target_client: ModelTargetService, @@ -582,9 +318,6 @@ def test_two_models_in_a_standard_dataset( assert detail.message == "exactly one model should be provided" -# Tests for datasets which Vuforia does not generate cleanly. - - def test_generation_failure( *, model_target_model: ModelTargetModel, @@ -597,8 +330,8 @@ def test_generation_failure( model_target_generation_failure=failure, ): client = ModelTargetService( - client_id=_CLIENT_ID, - client_secret=_CLIENT_CREDENTIALS[1], + client_id=CLIENT_ID, + client_secret=CLIENT_CREDENTIALS[1], ) dataset_uuid = client.create_dataset( name="dataset", @@ -639,8 +372,8 @@ def test_generation_warning( model_target_generation_warning=warning, ): client = ModelTargetService( - client_id=_CLIENT_ID, - client_secret=_CLIENT_CREDENTIALS[1], + client_id=CLIENT_ID, + client_secret=CLIENT_CREDENTIALS[1], ) dataset_uuid = client.create_dataset( name="dataset", @@ -670,15 +403,12 @@ def test_generation_warning( ) -# Tests for waiting for a dataset to be generated. - - 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], + client_id=CLIENT_ID, + client_secret=CLIENT_CREDENTIALS[1], ) dataset_uuid = client.create_dataset( name="dataset", @@ -698,71 +428,6 @@ def test_timeout(*, model_target_model: ModelTargetModel) -> None: ) -# Tests for reading responses which are not shaped like Model Target -# Web API errors. - - -@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 - - @pytest.mark.parametrize( argnames="payload", argvalues=[ @@ -774,45 +439,10 @@ def test_invalid_oauth2_token_response_values( *, payload: dict[str, object] ) -> None: """OAuth token responses must contain correctly typed values.""" - response = _response_with_status( + 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) - - -# Tests for using a custom base URL. - - -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_errors.py b/tests/model_targets/test_errors.py new file mode 100644 index 000000000..088f46c0b --- /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.helpers 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_query.py b/tests/test_async_query.py index d5fca18b3..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,13 +51,6 @@ async def __call__( ) -if TYPE_CHECKING: - import io - - -# Tests for making async image queries. - - @pytest.mark.asyncio async def test_no_matches( *, @@ -105,9 +101,6 @@ async def test_server_error(*, image: io.BytesIO | BinaryIO) -> None: _ = await client.query(image=image) -# Tests for using a custom base VWQ URL. - - @pytest.mark.asyncio async def test_custom_base_url( image: io.BytesIO | BinaryIO, @@ -150,10 +143,6 @@ async def test_custom_base_url( assert match.target_id == target_id -# Tests for the ``max_num_results`` parameter of -# ``query``. - - @pytest.mark.asyncio async def test_custom( *, @@ -202,10 +191,6 @@ async def test_custom( assert len(matches) == max_num_results -# Tests for the ``include_target_data`` parameter of -# ``query``. - - @pytest.mark.asyncio async def test_none( *, diff --git a/tests/test_async_vws.py b/tests/test_async_vws.py deleted file mode 100644 index 3ff74f9a2..000000000 --- a/tests/test_async_vws.py +++ /dev/null @@ -1,717 +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 - - -# Tests for adding a target. - - -@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, - ) - - -# Tests for using a custom base VWS URL. - - -@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, - ) - - -# Tests for listing targets. - - -@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]) - - -# Test for deleting a target. - - -@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 == [] - - -# Tests for getting a summary report for a target. - - -@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 - - -# Tests for getting a summary report for a database. - - -@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 - - -# Tests for getting a record of a target. - - -@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 - - -# Tests for waiting for a target to be processed. - - -@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, - ) - - -# Tests for getting duplicate targets. - - -@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] - - -# Tests for updating a target. - - -@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, - ) - - -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"), - ) - - -# Tests for database reco counts reports. - - -@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), - ) - - -# Tests for generating VuMark instances. - - -@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_query.py b/tests/test_query.py index 716c98bbe..e0a4309bd 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -63,9 +63,6 @@ def __call__( ) -# Tests for making image queries. - - def test_no_matches( *, cloud_reco_client: CloudRecoService, @@ -127,9 +124,6 @@ def test_server_error(*, image: io.BytesIO | BinaryIO) -> None: _ = client.query(image=image) -# Tests for the default request timeout. - - @pytest.mark.parametrize( argnames=("response_delay_seconds", "expect_timeout"), argvalues=[(29, False), (31, True)], @@ -172,9 +166,6 @@ def test_default_timeout( assert not bool(matches) -# Tests for custom request timeout values. - - @pytest.mark.parametrize( argnames=( "custom_timeout", @@ -226,9 +217,6 @@ def test_custom_timeout( assert not bool(matches) -# Tests for using a custom base VWQ URL. - - def test_custom_base_url(image: io.BytesIO | BinaryIO) -> None: """ It is possible to use query a target to a database under a @@ -300,9 +288,6 @@ def test_custom_base_url_with_path_prefix( assert len(matches) == 1 -# Tests for the ``max_num_results`` parameter of ``query``. - - def test_max_num_results_default( *, vws_client: VWS, @@ -369,9 +354,6 @@ def test_custom( assert len(matches) == max_num_results -# Tests for the ``include_target_data`` parameter of ``query``. - - def test_include_target_data_default( *, vws_client: VWS, diff --git a/tests/test_vws.py b/tests/test_vws.py deleted file mode 100644 index 9cafd69b5..000000000 --- a/tests/test_vws.py +++ /dev/null @@ -1,1095 +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, - ) - - -# Tests for adding a target. - - -@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, - ) - - -# Tests for the default request timeout. - - -@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, - ) - - -# Tests for custom request timeout values. - - -@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_request_timeout_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, - ) - - -# Tests for using a custom base VWS URL. - - -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()) - - -# Tests for listing targets. - - -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]) - - -# Test for deleting a target. - - -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() == [] - - -# Tests for getting a summary report for a target. - - -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 - - -# Tests for getting a summary report for a database. - - -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 - - -# Tests for getting a record of a target. - - -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 - - -# Tests for waiting for a target to be processed. - - -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_wait_for_target_processed_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 - - -# Tests for getting duplicate targets. - - -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") - - -# Tests for updating a target. - - -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) - - -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"), - ) - - -# Tests for database reco counts reports. - - -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), - ) - - -# Tests for parsing downloaded reco counts reports. - - -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 - - -# Tests for generating VuMark instances. - - -@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/test_transports.py b/tests/transports/test_httpx2.py similarity index 56% rename from tests/test_transports.py rename to tests/transports/test_httpx2.py index e4fc4ad08..57ebde9ac 100644 --- a/tests/test_transports.py +++ b/tests/transports/test_httpx2.py @@ -1,15 +1,13 @@ -"""Tests for HTTP transport implementations.""" +"""Tests for `httpx2` transports.""" 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 @@ -24,8 +22,6 @@ ModelTargetService, VuMarkService, ) -from vws.exceptions.custom_exceptions import ServerError -from vws.exceptions.vws_exceptions import TooManyRequestsError from vws.model_target_datasets import ( ModelTargetDatasetType, ModelTargetModel, @@ -34,9 +30,7 @@ from vws.response import Response from vws.transports import ( AsyncHTTPX2Transport, - AsyncHTTPXTransport, HTTPX2Transport, - HTTPXTransport, ) from vws.vumark_accept import VuMarkAccept @@ -44,504 +38,6 @@ import io -# Tests for ``HTTPXTransport``. - - -@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 - - -# Tests for ``AsyncHTTPXTransport``. - - -@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 - - -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" @@ -609,10 +105,80 @@ def __init__(self) -> None: return requests_seen -# Tests for ``HTTPX2Transport``. +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``. -def test_httpx2_transport_float_timeout( + 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. @@ -646,7 +212,7 @@ def test_httpx2_transport_float_timeout( assert response.tell_position == len(b"OK") -def test_httpx2_transport_tuple_timeout( +def test_sync_tuple_timeout( httpx2_requests: list[httpx2.Request], ) -> None: """``HTTPX2Transport`` works with a (connect, read) timeout @@ -671,7 +237,7 @@ def test_httpx2_transport_tuple_timeout( assert response.status_code == HTTPStatus.OK -def test_httpx2_transport_int_timeout( +def test_sync_int_timeout( httpx2_requests: list[httpx2.Request], ) -> None: """``HTTPX2Transport`` works with an int timeout.""" @@ -694,7 +260,7 @@ def test_httpx2_transport_int_timeout( assert response.status_code == HTTPStatus.OK -def test_httpx2_transport_empty_body( +def test_sync_empty_body( httpx2_requests: list[httpx2.Request], ) -> None: """An empty request body is reported as ``None``, as it is for @@ -712,7 +278,7 @@ def test_httpx2_transport_empty_body( assert response.request_body is None -def test_httpx2_transport_context_manager( +def test_sync_context_manager( httpx2_requests: list[httpx2.Request], ) -> None: """``HTTPX2Transport`` can be used as a context manager, and @@ -761,7 +327,7 @@ def test_close(httpx2_requests: list[httpx2.Request]) -> None: assert not bool(httpx2_requests) -def test_httpx2_transport_httpx2_exceptions( +def test_sync_httpx2_exceptions( httpx2_requests: list[httpx2.Request], ) -> None: """Errors are raised as ``httpx2`` exceptions, which are not @@ -780,11 +346,8 @@ def test_httpx2_transport_httpx2_exceptions( assert not isinstance(exc.value, httpx.HTTPError) -# Tests for ``AsyncHTTPX2Transport``. - - @pytest.mark.asyncio -async def test_async_httpx2_transport_float_timeout( +async def test_async_float_timeout( httpx2_requests: list[httpx2.Request], ) -> None: """``AsyncHTTPX2Transport`` works with a float timeout. @@ -819,7 +382,7 @@ async def test_async_httpx2_transport_float_timeout( @pytest.mark.asyncio -async def test_async_httpx2_transport_tuple_timeout( +async def test_async_tuple_timeout( httpx2_requests: list[httpx2.Request], ) -> None: """``AsyncHTTPX2Transport`` works with a (connect, read) @@ -845,7 +408,7 @@ async def test_async_httpx2_transport_tuple_timeout( @pytest.mark.asyncio -async def test_async_httpx2_transport_int_timeout( +async def test_async_int_timeout( httpx2_requests: list[httpx2.Request], ) -> None: """``AsyncHTTPX2Transport`` works with an int timeout.""" @@ -869,7 +432,7 @@ async def test_async_httpx2_transport_int_timeout( @pytest.mark.asyncio -async def test_async_httpx2_transport_empty_body( +async def test_async_empty_body( httpx2_requests: list[httpx2.Request], ) -> None: """An empty request body is reported as ``None``, as it is for @@ -888,7 +451,7 @@ async def test_async_httpx2_transport_empty_body( @pytest.mark.asyncio -async def test_async_httpx2_transport_context_manager( +async def test_async_context_manager( httpx2_requests: list[httpx2.Request], ) -> None: """``AsyncHTTPX2Transport`` can be used as an async context @@ -939,7 +502,7 @@ async def test_aclose(httpx2_requests: list[httpx2.Request]) -> None: @pytest.mark.asyncio -async def test_async_httpx2_transport_httpx2_exceptions( +async def test_async_httpx2_exceptions( httpx2_requests: list[httpx2.Request], ) -> None: """Errors are raised as ``httpx2`` exceptions, which are not @@ -958,11 +521,7 @@ async def test_async_httpx2_transport_httpx2_exceptions( assert not isinstance(exc.value, httpx.HTTPError) -# Tests for synchronous clients using ``HTTPX2Transport`` against -# the mock. - - -def test_httpx2_transport_with_mock_vws_and_cloud_reco( +def test_sync_mock_vws_and_cloud_reco( high_quality_image: io.BytesIO, ) -> None: """A target can be added with ``VWS`` and found with @@ -999,7 +558,7 @@ def test_httpx2_transport_with_mock_vws_and_cloud_reco( assert match.target_id == target_id -def test_httpx2_transport_with_mock_vumark() -> None: +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}) @@ -1018,7 +577,7 @@ def test_httpx2_transport_with_mock_vumark() -> None: assert vumark_bytes.startswith(b"\x89PNG") -def test_httpx2_transport_with_mock_model_targets( +def test_sync_mock_model_targets( model_target_model: ModelTargetModel, ) -> None: """A Model Target dataset can be generated with @@ -1055,7 +614,7 @@ def test_httpx2_transport_with_mock_model_targets( argvalues=[0.1, (5.0, 0.1)], ids=["float", "tuple"], ) -def test_httpx2_transport_with_mock_timeout( +def test_sync_mock_timeout( custom_timeout: float | tuple[float, float], ) -> None: """A response which takes longer than the read timeout raises an @@ -1081,85 +640,8 @@ def test_httpx2_transport_with_mock_timeout( 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, - ) - - -# Tests for asynchronous clients using ``AsyncHTTPX2Transport`` -# against the mock. - - @pytest.mark.asyncio -async def test_async_httpx2_transport_with_mock_vws_and_cloud_reco( +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 @@ -1177,7 +659,7 @@ async def test_async_httpx2_transport_with_mock_vws_and_cloud_reco( @pytest.mark.asyncio -async def test_async_httpx2_transport_with_mock_vumark() -> None: +async def test_async_mock_vumark() -> None: """A VuMark instance can be generated with ``AsyncVuMarkService``. """ @@ -1200,7 +682,7 @@ async def test_async_httpx2_transport_with_mock_vumark() -> None: @pytest.mark.asyncio -async def test_async_httpx2_transport_with_mock_model_targets( +async def test_async_mock_model_targets( model_target_model: ModelTargetModel, ) -> None: """A Model Target dataset can be generated with @@ -1220,7 +702,7 @@ async def test_async_httpx2_transport_with_mock_model_targets( argvalues=[0.1, (5.0, 0.1)], ids=["float", "tuple"], ) -async def test_async_httpx2_transport_with_mock_timeout( +async def test_async_mock_timeout( custom_timeout: float | tuple[float, float], ) -> None: """A response which takes longer than the read timeout raises an 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) From e361a85a47add93022695b59a4f8a11141b091cc Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Tue, 6 Oct 2026 10:55:10 +0100 Subject: [PATCH 3/3] Replace generic test helpers with focused modules --- tests/async_model_targets/test_authentication.py | 2 +- tests/async_model_targets/test_datasets.py | 2 +- .../helpers.py => model_target_credentials.py} | 2 +- tests/model_targets/{helpers.py => responses.py} | 7 +------ tests/model_targets/test_authentication.py | 2 +- tests/model_targets/test_configuration.py | 2 +- tests/model_targets/test_datasets.py | 7 ++----- tests/model_targets/test_errors.py | 2 +- 8 files changed, 9 insertions(+), 17 deletions(-) rename tests/{async_model_targets/helpers.py => model_target_credentials.py} (75%) rename tests/model_targets/{helpers.py => responses.py} (74%) diff --git a/tests/async_model_targets/test_authentication.py b/tests/async_model_targets/test_authentication.py index e8eb2ab26..a580a7923 100644 --- a/tests/async_model_targets/test_authentication.py +++ b/tests/async_model_targets/test_authentication.py @@ -9,7 +9,7 @@ ModelTargetFailureResponse, ) -from tests.async_model_targets.helpers import CLIENT_CREDENTIALS, CLIENT_ID +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 ( diff --git a/tests/async_model_targets/test_datasets.py b/tests/async_model_targets/test_datasets.py index 135d2e588..5c4a18bc7 100644 --- a/tests/async_model_targets/test_datasets.py +++ b/tests/async_model_targets/test_datasets.py @@ -12,7 +12,7 @@ ModelTargetGenerationWarning, ) -from tests.async_model_targets.helpers import CLIENT_CREDENTIALS, CLIENT_ID +from tests.model_target_credentials import CLIENT_CREDENTIALS, CLIENT_ID from vws import AsyncModelTargetService from vws.exceptions.model_target_exceptions import ( ModelTargetDatasetNotDoneError, diff --git a/tests/async_model_targets/helpers.py b/tests/model_target_credentials.py similarity index 75% rename from tests/async_model_targets/helpers.py rename to tests/model_target_credentials.py index 088475131..6e42b5a39 100644 --- a/tests/async_model_targets/helpers.py +++ b/tests/model_target_credentials.py @@ -1,4 +1,4 @@ -"""Shared helpers for async model targets tests.""" +"""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. diff --git a/tests/model_targets/helpers.py b/tests/model_targets/responses.py similarity index 74% rename from tests/model_targets/helpers.py rename to tests/model_targets/responses.py index 2676a8173..fa7c8c334 100644 --- a/tests/model_targets/helpers.py +++ b/tests/model_targets/responses.py @@ -1,4 +1,4 @@ -"""Shared helpers for model targets tests.""" +"""Response builders for Model Target API tests.""" from http import HTTPStatus @@ -6,11 +6,6 @@ from vws.response import Response -# 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") - @beartype def response_with_status( diff --git a/tests/model_targets/test_authentication.py b/tests/model_targets/test_authentication.py index 83da975c1..c410ada7f 100644 --- a/tests/model_targets/test_authentication.py +++ b/tests/model_targets/test_authentication.py @@ -11,7 +11,7 @@ ModelTargetFailureResponse, ) -from tests.model_targets.helpers import CLIENT_CREDENTIALS, CLIENT_ID +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 ( diff --git a/tests/model_targets/test_configuration.py b/tests/model_targets/test_configuration.py index 3c8fd2278..00a0736a9 100644 --- a/tests/model_targets/test_configuration.py +++ b/tests/model_targets/test_configuration.py @@ -4,7 +4,7 @@ MockVWS, ) -from tests.model_targets.helpers import CLIENT_CREDENTIALS, CLIENT_ID +from tests.model_target_credentials import CLIENT_CREDENTIALS, CLIENT_ID from vws import ModelTargetService from vws.model_target_datasets import ( ModelTargetDatasetType, diff --git a/tests/model_targets/test_datasets.py b/tests/model_targets/test_datasets.py index 897c60978..8cdae58e3 100644 --- a/tests/model_targets/test_datasets.py +++ b/tests/model_targets/test_datasets.py @@ -13,11 +13,8 @@ ModelTargetGenerationWarning, ) -from tests.model_targets.helpers import ( - CLIENT_CREDENTIALS, - CLIENT_ID, - response_with_status, -) +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 ( diff --git a/tests/model_targets/test_errors.py b/tests/model_targets/test_errors.py index 088f46c0b..d50ad68fa 100644 --- a/tests/model_targets/test_errors.py +++ b/tests/model_targets/test_errors.py @@ -6,7 +6,7 @@ import pytest from beartype import beartype -from tests.model_targets.helpers import response_with_status +from tests.model_targets.responses import response_with_status from vws.exceptions.model_target_exceptions import ( ModelTargetError, ModelTargetOAuth2Error,