From 76dd68e97b8969365b85dcd7504ff5508eb56723 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 18 Feb 2026 13:30:02 +0000 Subject: [PATCH 01/10] Add comprehensive VuMark instance generation tests and mock support - Add tests for PNG, SVG, and PDF output formats (parametrized) - Add tests for invalid Accept header (returns InvalidAcceptHeader 400) - Add tests for empty instance_id (returns InvalidInstanceId 422) - Organise tests into a class with verify_mock_vuforia on the class - Implement Accept header validation and multi-format responses in mock - Add InvalidAcceptHeader and InvalidInstanceId result codes and exceptions - Add minimal SVG and PDF mock response content - Document VuMark image simplification in differences-to-vws.rst Co-Authored-By: Claude Sonnet 4.6 --- docs/source/differences-to-vws.rst | 7 ++ src/mock_vws/_constants.py | 17 +++ src/mock_vws/_flask_server/vws.py | 30 +++++- .../mock_web_services_api.py | 46 ++++++-- .../_services_validators/exceptions.py | 76 +++++++++++++ tests/mock_vws/test_vumark_generation_api.py | 101 +++++++++++++++--- 6 files changed, 252 insertions(+), 25 deletions(-) diff --git a/docs/source/differences-to-vws.rst b/docs/source/differences-to-vws.rst index 86218f4eb..3ea982727 100644 --- a/docs/source/differences-to-vws.rst +++ b/docs/source/differences-to-vws.rst @@ -107,3 +107,10 @@ Header cases ------------ The mock does not necessarily match Vuforia for all header cases. + +VuMark instance images +---------------------- + +The mock returns a fixed minimal image in the requested format (PNG, SVG, or PDF). +The ``instance_id`` value is not encoded into the response image. +Real Vuforia encodes the instance ID into the VuMark pattern. diff --git a/src/mock_vws/_constants.py b/src/mock_vws/_constants.py index d9af993b5..68bf9375f 100644 --- a/src/mock_vws/_constants.py +++ b/src/mock_vws/_constants.py @@ -11,6 +11,21 @@ b"\xaeB`\x82" ) +VUMARK_SVG = ( + b'' +) + +VUMARK_PDF = ( + b"%PDF-1.4\n" + b"1 0 obj<>endobj\n" + b"2 0 obj<>endobj\n" + b"3 0 obj<>endobj\n" + b"xref\n0 4\n" + b"0000000000 65535 f \n" + b"trailer<>\n" + b"startxref\n9\n%%EOF" +) + @beartype @unique @@ -45,6 +60,8 @@ class ResultCodes(Enum): PROJECT_INACTIVE = "ProjectInactive" INACTIVE_PROJECT = "InactiveProject" TOO_MANY_REQUESTS = "TooManyRequests" + INVALID_ACCEPT_HEADER = "InvalidAcceptHeader" + INVALID_INSTANCE_ID = "InvalidInstanceId" @beartype diff --git a/src/mock_vws/_flask_server/vws.py b/src/mock_vws/_flask_server/vws.py index 99b8c3b8e..13be8b40a 100644 --- a/src/mock_vws/_flask_server/vws.py +++ b/src/mock_vws/_flask_server/vws.py @@ -18,12 +18,20 @@ from flask import Flask, Response, request from pydantic_settings import BaseSettings -from mock_vws._constants import VUMARK_PNG, ResultCodes, TargetStatuses +from mock_vws._constants import ( + VUMARK_PDF, + VUMARK_PNG, + VUMARK_SVG, + ResultCodes, + TargetStatuses, +) from mock_vws._database_matchers import get_database_matching_server_keys from mock_vws._mock_common import json_dump from mock_vws._services_validators import run_services_validators from mock_vws._services_validators.exceptions import ( FailError, + InvalidAcceptHeaderError, + InvalidInstanceIdError, TargetStatusNotSuccessError, TargetStatusProcessingError, ValidatorError, @@ -351,10 +359,26 @@ def generate_vumark_instance(target_id: str) -> Response: """ # ``target_id`` is validated by request validators. del target_id + + accept = request.headers.get("Accept", "") + valid_accept_types = { + "image/png": (VUMARK_PNG, "image/png"), + "image/svg+xml": (VUMARK_SVG, "image/svg+xml"), + "application/pdf": (VUMARK_PDF, "application/pdf"), + } + if accept not in valid_accept_types: + raise InvalidAcceptHeaderError + + request_json = json.loads(s=request.data) + instance_id = request_json.get("instance_id", "") + if not instance_id: + raise InvalidInstanceIdError + + response_body, content_type = valid_accept_types[accept] date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) headers = { "Connection": "keep-alive", - "Content-Type": "image/png", + "Content-Type": content_type, "server": "envoy", "Date": date, "x-envoy-upstream-service-time": "5", @@ -364,7 +388,7 @@ def generate_vumark_instance(target_id: str) -> Response: } return Response( status=HTTPStatus.OK, - response=VUMARK_PNG, + response=response_body, headers=headers, ) diff --git a/src/mock_vws/_requests_mock_server/mock_web_services_api.py b/src/mock_vws/_requests_mock_server/mock_web_services_api.py index 303d4a2b4..70afce4d6 100644 --- a/src/mock_vws/_requests_mock_server/mock_web_services_api.py +++ b/src/mock_vws/_requests_mock_server/mock_web_services_api.py @@ -18,12 +18,20 @@ from beartype import BeartypeConf, beartype from requests.models import PreparedRequest -from mock_vws._constants import VUMARK_PNG, ResultCodes, TargetStatuses +from mock_vws._constants import ( + VUMARK_PDF, + VUMARK_PNG, + VUMARK_SVG, + ResultCodes, + TargetStatuses, +) from mock_vws._database_matchers import get_database_matching_server_keys from mock_vws._mock_common import Route, json_dump from mock_vws._services_validators import run_services_validators from mock_vws._services_validators.exceptions import ( FailError, + InvalidAcceptHeaderError, + InvalidInstanceIdError, TargetStatusNotSuccessError, TargetStatusProcessingError, ValidatorError, @@ -295,14 +303,32 @@ def generate_vumark_instance( self, request: PreparedRequest ) -> _ResponseType: """Generate a VuMark instance.""" - run_services_validators( - request_headers=request.headers, - request_body=_body_bytes(request=request), - request_method=request.method or "", - request_path=request.path_url, - databases=self._target_manager.databases, - ) + valid_accept_types: dict[str, tuple[bytes, str]] = { + "image/png": (VUMARK_PNG, "image/png"), + "image/svg+xml": (VUMARK_SVG, "image/svg+xml"), + "application/pdf": (VUMARK_PDF, "application/pdf"), + } + try: + run_services_validators( + request_headers=request.headers, + request_body=_body_bytes(request=request), + request_method=request.method or "", + request_path=request.path_url, + databases=self._target_manager.databases, + ) + + accept = dict(request.headers).get("Accept", "") + if accept not in valid_accept_types: + raise InvalidAcceptHeaderError + + request_json = json.loads(s=_body_bytes(request=request)) + instance_id = request_json.get("instance_id", "") + if not instance_id: + raise InvalidInstanceIdError + except ValidatorError as exc: + return exc.status_code, exc.headers, exc.response_text + response_body, content_type = valid_accept_types[accept] date = email.utils.formatdate( timeval=None, localtime=False, @@ -310,7 +336,7 @@ def generate_vumark_instance( ) headers = { "Connection": "keep-alive", - "Content-Type": "image/png", + "Content-Type": content_type, "Date": date, "server": "envoy", "x-envoy-upstream-service-time": "5", @@ -318,7 +344,7 @@ def generate_vumark_instance( "x-aws-region": "us-east-2, us-west-2", "x-content-type-options": "nosniff", } - return HTTPStatus.OK, headers, VUMARK_PNG + return HTTPStatus.OK, headers, response_body @route(path_pattern="/summary", http_methods={HTTPMethod.GET}) def database_summary(self, request: PreparedRequest) -> _ResponseType: diff --git a/src/mock_vws/_services_validators/exceptions.py b/src/mock_vws/_services_validators/exceptions.py index 4bbc5dab8..f7cbe2439 100644 --- a/src/mock_vws/_services_validators/exceptions.py +++ b/src/mock_vws/_services_validators/exceptions.py @@ -530,6 +530,82 @@ def __init__(self) -> None: } +@beartype +class InvalidAcceptHeaderError(ValidatorError): + """Exception raised when an unsupported Accept header is given.""" + + def __init__(self) -> None: + """ + Attributes: + status_code: The status code to use in a response if this is + raised. + response_text: The response text to use in a response if this + is + raised. + """ + super().__init__() + self.status_code = HTTPStatus.BAD_REQUEST + body = { + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.INVALID_ACCEPT_HEADER.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + + +@beartype +class InvalidInstanceIdError(ValidatorError): + """Exception raised when an invalid instance_id is given.""" + + def __init__(self) -> None: + """ + Attributes: + status_code: The status code to use in a response if this is + raised. + response_text: The response text to use in a response if this + is + raised. + """ + super().__init__() + self.status_code = HTTPStatus.UNPROCESSABLE_ENTITY + body = { + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.INVALID_INSTANCE_ID.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + + @beartype class TargetStatusProcessingError(ValidatorError): """Exception raised when trying to delete a target which is processing.""" diff --git a/tests/mock_vws/test_vumark_generation_api.py b/tests/mock_vws/test_vumark_generation_api.py index ac7b2634a..eda5e5c36 100644 --- a/tests/mock_vws/test_vumark_generation_api.py +++ b/tests/mock_vws/test_vumark_generation_api.py @@ -12,17 +12,22 @@ _VWS_HOST = "https://vws.vuforia.com" _PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" +_PDF_SIGNATURE = b"%PDF" +_SVG_START = b"<" -@pytest.mark.usefixtures("verify_mock_vuforia") -def test_generate_instance_success( +def _make_vumark_request( + *, vumark_vuforia_database: VuMarkVuforiaDatabase, -) -> None: - """A VuMark instance can be generated with valid template settings.""" + instance_id: str, + accept: str, +) -> requests.Response: + """Send a VuMark instance generation request and return the + response. + """ request_path = f"/targets/{vumark_vuforia_database.target_id}/instances" content_type = "application/json" - generated_instance_id = uuid4().hex - content = json.dumps(obj={"instance_id": generated_instance_id}).encode( + content = json.dumps(obj={"instance_id": instance_id}).encode( encoding="utf-8" ) date = rfc_1123_date() @@ -36,10 +41,10 @@ def test_generate_instance_success( request_path=request_path, ) - response = requests.post( + return requests.post( url=_VWS_HOST + request_path, headers={ - "Accept": "image/png", + "Accept": accept, "Authorization": authorization_string, "Content-Length": str(object=len(content)), "Content-Type": content_type, @@ -49,7 +54,79 @@ def test_generate_instance_success( timeout=30, ) - assert response.status_code == HTTPStatus.OK - assert response.headers["Content-Type"].split(sep=";")[0] == "image/png" - assert response.content.startswith(_PNG_SIGNATURE) - assert len(response.content) > len(_PNG_SIGNATURE) + +@pytest.mark.usefixtures("verify_mock_vuforia") +class TestGenerateInstance: + """Tests for the VuMark instance generation endpoint.""" + + @pytest.mark.parametrize( + ("accept", "expected_content_type", "expected_signature"), + [ + pytest.param("image/png", "image/png", _PNG_SIGNATURE, id="png"), + pytest.param( + "image/svg+xml", + "image/svg+xml", + _SVG_START, + id="svg", + ), + pytest.param( + "application/pdf", + "application/pdf", + _PDF_SIGNATURE, + id="pdf", + ), + ], + ) + @staticmethod + def test_generate_instance_format( + accept: str, + expected_content_type: str, + expected_signature: bytes, + vumark_vuforia_database: VuMarkVuforiaDatabase, + ) -> None: + """A VuMark instance can be generated in PNG, SVG, or PDF + format. + """ + response = _make_vumark_request( + vumark_vuforia_database=vumark_vuforia_database, + instance_id=uuid4().hex, + accept=accept, + ) + + assert response.status_code == HTTPStatus.OK + assert ( + response.headers["Content-Type"].split(sep=";")[0] + == expected_content_type + ) + assert response.content.strip().startswith(expected_signature) + assert len(response.content) > len(expected_signature) + + @staticmethod + def test_invalid_accept_header( + vumark_vuforia_database: VuMarkVuforiaDatabase, + ) -> None: + """An unsupported Accept header returns an error.""" + response = _make_vumark_request( + vumark_vuforia_database=vumark_vuforia_database, + instance_id=uuid4().hex, + accept="text/plain", + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + response_json = response.json() + assert response_json["result_code"] == "InvalidAcceptHeader" + + @staticmethod + def test_empty_instance_id( + vumark_vuforia_database: VuMarkVuforiaDatabase, + ) -> None: + """An empty instance_id returns InvalidInstanceId.""" + response = _make_vumark_request( + vumark_vuforia_database=vumark_vuforia_database, + instance_id="", + accept="image/png", + ) + + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + response_json = response.json() + assert response_json["result_code"] == "InvalidInstanceId" From f65532d1a46d52e44bc9f4dd056554d31c4adece Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 18 Feb 2026 13:31:59 +0000 Subject: [PATCH 02/10] Fix mypy errors in VuMark tests and Flask handler - Use argnames=/argvalues= keyword arguments in pytest.mark.parametrize - Use keyword argument key= for Flask Headers.get() call Co-Authored-By: Claude Sonnet 4.6 --- src/mock_vws/_flask_server/vws.py | 2 +- tests/mock_vws/test_vumark_generation_api.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/mock_vws/_flask_server/vws.py b/src/mock_vws/_flask_server/vws.py index 13be8b40a..5ab02b68c 100644 --- a/src/mock_vws/_flask_server/vws.py +++ b/src/mock_vws/_flask_server/vws.py @@ -360,7 +360,7 @@ def generate_vumark_instance(target_id: str) -> Response: # ``target_id`` is validated by request validators. del target_id - accept = request.headers.get("Accept", "") + accept = request.headers.get(key="Accept", default="") valid_accept_types = { "image/png": (VUMARK_PNG, "image/png"), "image/svg+xml": (VUMARK_SVG, "image/svg+xml"), diff --git a/tests/mock_vws/test_vumark_generation_api.py b/tests/mock_vws/test_vumark_generation_api.py index eda5e5c36..ac9d9099f 100644 --- a/tests/mock_vws/test_vumark_generation_api.py +++ b/tests/mock_vws/test_vumark_generation_api.py @@ -60,8 +60,8 @@ class TestGenerateInstance: """Tests for the VuMark instance generation endpoint.""" @pytest.mark.parametrize( - ("accept", "expected_content_type", "expected_signature"), - [ + argnames=("accept", "expected_content_type", "expected_signature"), + argvalues=[ pytest.param("image/png", "image/png", _PNG_SIGNATURE, id="png"), pytest.param( "image/svg+xml", From 5b19f9055eda3951994d449b6638808d855de60b Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 18 Feb 2026 13:35:39 +0000 Subject: [PATCH 03/10] Add svg and pdf to spelling private dictionary Co-Authored-By: Claude Sonnet 4.6 --- spelling_private_dict.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/spelling_private_dict.txt b/spelling_private_dict.txt index 365309073..b41ee20a8 100644 --- a/spelling_private_dict.txt +++ b/spelling_private_dict.txt @@ -69,6 +69,7 @@ nat noqa outerboundary overridable +pdf pdict plugins png @@ -100,6 +101,7 @@ resjsonarr rfc rgb str +svg timestamp todo travis From 179450fe2fc7919fbf9e0c39a0f9f8cd4db71a5e Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 18 Feb 2026 14:26:23 +0000 Subject: [PATCH 04/10] Retry delete_target if TargetStatusProcessingError after update_target Vuforia has a race condition where delete_target can raise TargetStatusProcessingError immediately after wait_for_target_processed returns, because update_target(active_flag=False) triggers a brief reprocessing cycle that may not have started by the time the wait completes. Retry once with another wait if this occurs. Co-Authored-By: Claude Sonnet 4.6 --- tests/mock_vws/fixtures/vuforia_backends.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/mock_vws/fixtures/vuforia_backends.py b/tests/mock_vws/fixtures/vuforia_backends.py index c0e95b08c..2b60f5e6d 100644 --- a/tests/mock_vws/fixtures/vuforia_backends.py +++ b/tests/mock_vws/fixtures/vuforia_backends.py @@ -13,6 +13,7 @@ from vws import VWS from vws.exceptions.vws_exceptions import ( TargetStatusNotSuccessError, + TargetStatusProcessingError, ) from mock_vws import MockVWS @@ -58,7 +59,14 @@ def _delete_all_targets(*, database_keys: VuforiaDatabase) -> None: with contextlib.suppress(TargetStatusNotSuccessError): vws_client.update_target(target_id=target, active_flag=False) vws_client.wait_for_target_processed(target_id=target) - vws_client.delete_target(target_id=target) + # Vuforia may briefly return TargetStatusProcessing immediately + # after wait_for_target_processed returns (race condition after + # update_target triggers reprocessing), so retry once if needed. + try: + vws_client.delete_target(target_id=target) + except TargetStatusProcessingError: + vws_client.wait_for_target_processed(target_id=target) + vws_client.delete_target(target_id=target) @beartype From a0df2cb89d6ac89a74fbd50107c1342725adf8c6 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 18 Feb 2026 14:41:16 +0000 Subject: [PATCH 05/10] Complete remaining todos: ResultCodes constants, spelling revert, retry - Use ResultCodes.INVALID_ACCEPT_HEADER.value and ResultCodes.INVALID_INSTANCE_ID.value in test assertions instead of raw strings - Reword docstrings/docs to avoid acronyms that trigger the spell checker, and revert spelling_private_dict.txt to its pre-PR state - Replace one-off try/except retry in _delete_all_targets with a proper tenacity-based _delete_target_when_processed helper function Co-Authored-By: Claude Sonnet 4.6 --- docs/source/differences-to-vws.rst | 2 +- spelling_private_dict.txt | 2 -- tests/mock_vws/fixtures/vuforia_backends.py | 32 ++++++++++++++------ tests/mock_vws/test_vumark_generation_api.py | 15 ++++++--- 4 files changed, 34 insertions(+), 17 deletions(-) diff --git a/docs/source/differences-to-vws.rst b/docs/source/differences-to-vws.rst index 3ea982727..e2fdac37a 100644 --- a/docs/source/differences-to-vws.rst +++ b/docs/source/differences-to-vws.rst @@ -111,6 +111,6 @@ The mock does not necessarily match Vuforia for all header cases. VuMark instance images ---------------------- -The mock returns a fixed minimal image in the requested format (PNG, SVG, or PDF). +The mock returns a fixed minimal image in the requested format. The ``instance_id`` value is not encoded into the response image. Real Vuforia encodes the instance ID into the VuMark pattern. diff --git a/spelling_private_dict.txt b/spelling_private_dict.txt index b41ee20a8..365309073 100644 --- a/spelling_private_dict.txt +++ b/spelling_private_dict.txt @@ -69,7 +69,6 @@ nat noqa outerboundary overridable -pdf pdict plugins png @@ -101,7 +100,6 @@ resjsonarr rfc rgb str -svg timestamp todo travis diff --git a/tests/mock_vws/fixtures/vuforia_backends.py b/tests/mock_vws/fixtures/vuforia_backends.py index 2b60f5e6d..39c9f1742 100644 --- a/tests/mock_vws/fixtures/vuforia_backends.py +++ b/tests/mock_vws/fixtures/vuforia_backends.py @@ -10,6 +10,9 @@ import responses from beartype import beartype from requests_mock_flask import add_flask_app_to_mock +from tenacity import retry +from tenacity.retry import retry_if_exception_type +from tenacity.wait import wait_fixed from vws import VWS from vws.exceptions.vws_exceptions import ( TargetStatusNotSuccessError, @@ -32,6 +35,25 @@ LOGGER.setLevel(level=logging.DEBUG) +@retry( + retry=retry_if_exception_type(exception_types=TargetStatusProcessingError), + wait=wait_fixed(wait=2), + reraise=True, +) +def _delete_target_when_processed(*, vws_client: VWS, target_id: str) -> None: + """Wait for a target to finish processing, then delete it. + + Retries if Vuforia briefly returns a processing state immediately + after the prior wait completes (race condition after update_target). + + Args: + vws_client: The VWS client to use. + target_id: The target to delete. + """ + vws_client.wait_for_target_processed(target_id=target_id) + vws_client.delete_target(target_id=target_id) + + @RETRY_ON_TOO_MANY_REQUESTS def _delete_all_targets(*, database_keys: VuforiaDatabase) -> None: """Delete all targets. @@ -58,15 +80,7 @@ def _delete_all_targets(*, database_keys: VuforiaDatabase) -> None: # we change the target to inactive before deleting it. with contextlib.suppress(TargetStatusNotSuccessError): vws_client.update_target(target_id=target, active_flag=False) - vws_client.wait_for_target_processed(target_id=target) - # Vuforia may briefly return TargetStatusProcessing immediately - # after wait_for_target_processed returns (race condition after - # update_target triggers reprocessing), so retry once if needed. - try: - vws_client.delete_target(target_id=target) - except TargetStatusProcessingError: - vws_client.wait_for_target_processed(target_id=target) - vws_client.delete_target(target_id=target) + _delete_target_when_processed(vws_client=vws_client, target_id=target) @beartype diff --git a/tests/mock_vws/test_vumark_generation_api.py b/tests/mock_vws/test_vumark_generation_api.py index ac9d9099f..2b589ebd6 100644 --- a/tests/mock_vws/test_vumark_generation_api.py +++ b/tests/mock_vws/test_vumark_generation_api.py @@ -8,6 +8,7 @@ import requests from vws_auth_tools import authorization_header, rfc_1123_date +from mock_vws._constants import ResultCodes from tests.mock_vws.fixtures.credentials import VuMarkVuforiaDatabase _VWS_HOST = "https://vws.vuforia.com" @@ -84,9 +85,7 @@ def test_generate_instance_format( expected_signature: bytes, vumark_vuforia_database: VuMarkVuforiaDatabase, ) -> None: - """A VuMark instance can be generated in PNG, SVG, or PDF - format. - """ + """A VuMark instance can be generated in the requested format.""" response = _make_vumark_request( vumark_vuforia_database=vumark_vuforia_database, instance_id=uuid4().hex, @@ -114,7 +113,10 @@ def test_invalid_accept_header( assert response.status_code == HTTPStatus.BAD_REQUEST response_json = response.json() - assert response_json["result_code"] == "InvalidAcceptHeader" + assert ( + response_json["result_code"] + == ResultCodes.INVALID_ACCEPT_HEADER.value + ) @staticmethod def test_empty_instance_id( @@ -129,4 +131,7 @@ def test_empty_instance_id( assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY response_json = response.json() - assert response_json["result_code"] == "InvalidInstanceId" + assert ( + response_json["result_code"] + == ResultCodes.INVALID_INSTANCE_ID.value + ) From 38f577e54796cb90bb89341a033c442621671b94 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 18 Feb 2026 15:01:38 +0000 Subject: [PATCH 06/10] Extract multipart parsing helper; revert retry changes to separate issue - Extract repeated multipart parsing logic in image_validators.py into _parse_multipart_files helper, eliminating four copies of the same six-line pattern - Revert _delete_target_when_processed retry changes from vuforia_backends.py; tracked separately as https://github.com/VWS-Python/vws-python-mock/issues/2955 Co-Authored-By: Claude Sonnet 4.6 --- .../_query_validators/image_validators.py | 81 ++++++++++--------- tests/mock_vws/fixtures/vuforia_backends.py | 26 +----- 2 files changed, 43 insertions(+), 64 deletions(-) diff --git a/src/mock_vws/_query_validators/image_validators.py b/src/mock_vws/_query_validators/image_validators.py index 827c636d2..63a646ab3 100644 --- a/src/mock_vws/_query_validators/image_validators.py +++ b/src/mock_vws/_query_validators/image_validators.py @@ -7,6 +7,7 @@ from beartype import beartype from PIL import Image +from werkzeug.datastructures import FileStorage, MultiDict from werkzeug.formparser import MultiPartParser from mock_vws._query_validators.exceptions import ( @@ -18,20 +19,19 @@ _LOGGER = logging.getLogger(name=__name__) -@beartype -def validate_image_field_given( +def _parse_multipart_files( *, request_headers: Mapping[str, str], request_body: bytes, -) -> None: - """Validate that the image field is given. +) -> MultiDict[str, FileStorage]: + """Parse the multipart body and return the files section. Args: request_headers: The headers sent with the request. request_body: The body of the request. - Raises: - ImageNotGivenError: The image field is not given. + Returns: + The files parsed from the multipart body. """ email_message = EmailMessage() email_message["Content-Type"] = request_headers["Content-Type"] @@ -42,6 +42,28 @@ def validate_image_field_given( boundary=boundary.encode(encoding="utf-8"), content_length=len(request_body), ) + return files + + +@beartype +def validate_image_field_given( + *, + request_headers: Mapping[str, str], + request_body: bytes, +) -> None: + """Validate that the image field is given. + + Args: + request_headers: The headers sent with the request. + request_body: The body of the request. + + Raises: + ImageNotGivenError: The image field is not given. + """ + files = _parse_multipart_files( + request_headers=request_headers, + request_body=request_body, + ) if files.get(key="image") is not None: return @@ -64,14 +86,9 @@ def validate_image_file_size( Raises: RequestEntityTooLargeError: The image file size is too large. """ - email_message = EmailMessage() - email_message["Content-Type"] = request_headers["Content-Type"] - boundary = email_message.get_boundary(failobj="") - parser = MultiPartParser() - _, files = parser.parse( - stream=io.BytesIO(initial_bytes=request_body), - boundary=boundary.encode(encoding="utf-8"), - content_length=len(request_body), + files = _parse_multipart_files( + request_headers=request_headers, + request_body=request_body, ) image_part = files["image"] image_value = image_part.stream.read() @@ -105,14 +122,9 @@ def validate_image_dimensions( BadImageError: The image is given and is not within the maximum width and height limits. """ - email_message = EmailMessage() - email_message["Content-Type"] = request_headers["Content-Type"] - boundary = email_message.get_boundary(failobj="") - parser = MultiPartParser() - _, files = parser.parse( - stream=io.BytesIO(initial_bytes=request_body), - boundary=boundary.encode(encoding="utf-8"), - content_length=len(request_body), + files = _parse_multipart_files( + request_headers=request_headers, + request_body=request_body, ) image_part = files["image"] image_value = image_part.stream.read() @@ -142,14 +154,9 @@ def validate_image_format( Raises: BadImageError: The image is given and is not either a PNG or a JPEG. """ - email_message = EmailMessage() - email_message["Content-Type"] = request_headers["Content-Type"] - boundary = email_message.get_boundary(failobj="") - parser = MultiPartParser() - _, files = parser.parse( - stream=io.BytesIO(initial_bytes=request_body), - boundary=boundary.encode(encoding="utf-8"), - content_length=len(request_body), + files = _parse_multipart_files( + request_headers=request_headers, + request_body=request_body, ) image_part = files["image"] pil_image = Image.open(fp=image_part.stream) @@ -175,17 +182,11 @@ def validate_image_is_image( Raises: BadImageError: Image data is given and it is not an image file. """ - email_message = EmailMessage() - email_message["Content-Type"] = request_headers["Content-Type"] - boundary = email_message.get_boundary(failobj="") - parser = MultiPartParser() - _, files = parser.parse( - stream=io.BytesIO(initial_bytes=request_body), - boundary=boundary.encode(encoding="utf-8"), - content_length=len(request_body), + files = _parse_multipart_files( + request_headers=request_headers, + request_body=request_body, ) - image_part = files["image"] - image_file = image_part.stream + image_file = files["image"].stream try: Image.open(fp=image_file) diff --git a/tests/mock_vws/fixtures/vuforia_backends.py b/tests/mock_vws/fixtures/vuforia_backends.py index 39c9f1742..c0e95b08c 100644 --- a/tests/mock_vws/fixtures/vuforia_backends.py +++ b/tests/mock_vws/fixtures/vuforia_backends.py @@ -10,13 +10,9 @@ import responses from beartype import beartype from requests_mock_flask import add_flask_app_to_mock -from tenacity import retry -from tenacity.retry import retry_if_exception_type -from tenacity.wait import wait_fixed from vws import VWS from vws.exceptions.vws_exceptions import ( TargetStatusNotSuccessError, - TargetStatusProcessingError, ) from mock_vws import MockVWS @@ -35,25 +31,6 @@ LOGGER.setLevel(level=logging.DEBUG) -@retry( - retry=retry_if_exception_type(exception_types=TargetStatusProcessingError), - wait=wait_fixed(wait=2), - reraise=True, -) -def _delete_target_when_processed(*, vws_client: VWS, target_id: str) -> None: - """Wait for a target to finish processing, then delete it. - - Retries if Vuforia briefly returns a processing state immediately - after the prior wait completes (race condition after update_target). - - Args: - vws_client: The VWS client to use. - target_id: The target to delete. - """ - vws_client.wait_for_target_processed(target_id=target_id) - vws_client.delete_target(target_id=target_id) - - @RETRY_ON_TOO_MANY_REQUESTS def _delete_all_targets(*, database_keys: VuforiaDatabase) -> None: """Delete all targets. @@ -80,7 +57,8 @@ def _delete_all_targets(*, database_keys: VuforiaDatabase) -> None: # we change the target to inactive before deleting it. with contextlib.suppress(TargetStatusNotSuccessError): vws_client.update_target(target_id=target, active_flag=False) - _delete_target_when_processed(vws_client=vws_client, target_id=target) + vws_client.wait_for_target_processed(target_id=target) + vws_client.delete_target(target_id=target) @beartype From 9915a0e5319a18aef940af3882d53b41372af09f Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 18 Feb 2026 15:13:52 +0000 Subject: [PATCH 07/10] Simplify valid_accept_types: content type equals accept key The dict values were (bytes, str) tuples where the str was always identical to the dict key (the Accept MIME type). Use a plain dict[str, bytes] and derive the content type directly from `accept`. Co-Authored-By: Claude Sonnet 4.6 --- src/mock_vws/_flask_server/vws.py | 11 ++++++----- .../_requests_mock_server/mock_web_services_api.py | 11 ++++++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/mock_vws/_flask_server/vws.py b/src/mock_vws/_flask_server/vws.py index 5ab02b68c..664571b6f 100644 --- a/src/mock_vws/_flask_server/vws.py +++ b/src/mock_vws/_flask_server/vws.py @@ -361,10 +361,10 @@ def generate_vumark_instance(target_id: str) -> Response: del target_id accept = request.headers.get(key="Accept", default="") - valid_accept_types = { - "image/png": (VUMARK_PNG, "image/png"), - "image/svg+xml": (VUMARK_SVG, "image/svg+xml"), - "application/pdf": (VUMARK_PDF, "application/pdf"), + valid_accept_types: dict[str, bytes] = { + "image/png": VUMARK_PNG, + "image/svg+xml": VUMARK_SVG, + "application/pdf": VUMARK_PDF, } if accept not in valid_accept_types: raise InvalidAcceptHeaderError @@ -374,7 +374,8 @@ def generate_vumark_instance(target_id: str) -> Response: if not instance_id: raise InvalidInstanceIdError - response_body, content_type = valid_accept_types[accept] + response_body = valid_accept_types[accept] + content_type = accept date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) headers = { "Connection": "keep-alive", diff --git a/src/mock_vws/_requests_mock_server/mock_web_services_api.py b/src/mock_vws/_requests_mock_server/mock_web_services_api.py index 70afce4d6..320b776ef 100644 --- a/src/mock_vws/_requests_mock_server/mock_web_services_api.py +++ b/src/mock_vws/_requests_mock_server/mock_web_services_api.py @@ -303,10 +303,10 @@ def generate_vumark_instance( self, request: PreparedRequest ) -> _ResponseType: """Generate a VuMark instance.""" - valid_accept_types: dict[str, tuple[bytes, str]] = { - "image/png": (VUMARK_PNG, "image/png"), - "image/svg+xml": (VUMARK_SVG, "image/svg+xml"), - "application/pdf": (VUMARK_PDF, "application/pdf"), + valid_accept_types: dict[str, bytes] = { + "image/png": VUMARK_PNG, + "image/svg+xml": VUMARK_SVG, + "application/pdf": VUMARK_PDF, } try: run_services_validators( @@ -328,7 +328,8 @@ def generate_vumark_instance( except ValidatorError as exc: return exc.status_code, exc.headers, exc.response_text - response_body, content_type = valid_accept_types[accept] + response_body = valid_accept_types[accept] + content_type = accept date = email.utils.formatdate( timeval=None, localtime=False, From 672a0ae74edf06b1bc6940966261d3507efe745b Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 18 Feb 2026 15:52:11 +0000 Subject: [PATCH 08/10] Remove duplicate _parse_multipart_files introduced by merge Both branches independently added the same helper; keep the @beartype-decorated version from main. Co-Authored-By: Claude Sonnet 4.6 --- .../_query_validators/image_validators.py | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/src/mock_vws/_query_validators/image_validators.py b/src/mock_vws/_query_validators/image_validators.py index b796ab2e4..e2f8f498f 100644 --- a/src/mock_vws/_query_validators/image_validators.py +++ b/src/mock_vws/_query_validators/image_validators.py @@ -19,32 +19,6 @@ _LOGGER = logging.getLogger(name=__name__) -def _parse_multipart_files( - *, - request_headers: Mapping[str, str], - request_body: bytes, -) -> MultiDict[str, FileStorage]: - """Parse the multipart body and return the files section. - - Args: - request_headers: The headers sent with the request. - request_body: The body of the request. - - Returns: - The files parsed from the multipart body. - """ - email_message = EmailMessage() - email_message["Content-Type"] = request_headers["Content-Type"] - boundary = email_message.get_boundary(failobj="") - parser = MultiPartParser() - _, files = parser.parse( - stream=io.BytesIO(initial_bytes=request_body), - boundary=boundary.encode(encoding="utf-8"), - content_length=len(request_body), - ) - return files - - @beartype def _parse_multipart_files( *, From 9128b430e3ce38223b71ee64c72bd36abf06004f Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 18 Feb 2026 15:58:27 +0000 Subject: [PATCH 09/10] Fix Docker build on PRs; remove duplicate rst section - Pass files: docker-bake.hcl explicitly to docker/bake-action so it uses the local checked-out file rather than fetching from the PR merge ref (refs/pull/*/merge) via HTTPS, which requires auth not available to the bake action - Remove duplicate 'VuMark instance images' section in differences-to-vws.rst introduced by the merge Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/docker-build.yml | 2 ++ docs/source/differences-to-vws.rst | 7 ------- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index b7828a7dc..c8d917630 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -38,8 +38,10 @@ jobs: uses: docker/bake-action@v6.10.0 with: call: check + files: docker-bake.hcl - name: Build Docker images uses: docker/bake-action@v6.10.0 with: + files: docker-bake.hcl push: false diff --git a/docs/source/differences-to-vws.rst b/docs/source/differences-to-vws.rst index b23a2722b..1f4876ea9 100644 --- a/docs/source/differences-to-vws.rst +++ b/docs/source/differences-to-vws.rst @@ -114,10 +114,3 @@ Header cases ------------ The mock does not necessarily match Vuforia for all header cases. - -VuMark instance images ----------------------- - -The mock returns a fixed minimal image in the requested format. -The ``instance_id`` value is not encoded into the response image. -Real Vuforia encodes the instance ID into the VuMark pattern. From f5a8243a3d3eac147931fe852b9016f87b1dc14e Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 18 Feb 2026 16:08:32 +0000 Subject: [PATCH 10/10] Revert docker-build.yml change; Docker failure was transient The Docker build failures are intermittent infrastructure issues, not a systematic problem. The files: docker-bake.hcl change was unnecessary. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/docker-build.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index c8d917630..b7828a7dc 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -38,10 +38,8 @@ jobs: uses: docker/bake-action@v6.10.0 with: call: check - files: docker-bake.hcl - name: Build Docker images uses: docker/bake-action@v6.10.0 with: - files: docker-bake.hcl push: false