From b87b0e6f50e313fb2853b49ffaa01ba13eee50b6 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 18 Feb 2026 23:27:43 +0000 Subject: [PATCH 01/19] Add VuMark generation support Implement VuMark instance generation API with support for multiple image formats (PNG, SVG, PDF). Add InvalidAcceptHeaderError and InvalidInstanceIdError exceptions for proper error handling. Includes comprehensive tests for all VuMark formats. Co-Authored-By: Claude Haiku 4.5 --- docs/source/api-reference.rst | 4 + pyproject.toml | 3 + src/vws/exceptions/vws_exceptions.py | 16 ++++ src/vws/vumark_accept.py | 18 +++++ src/vws/vws.py | 116 +++++++++++++++++++++++++++ tests/test_vws.py | 53 ++++++++++++ tests/test_vws_exceptions.py | 29 +++++++ 7 files changed, 239 insertions(+) create mode 100644 src/vws/vumark_accept.py diff --git a/docs/source/api-reference.rst b/docs/source/api-reference.rst index 01bba09b2..c94d810e1 100644 --- a/docs/source/api-reference.rst +++ b/docs/source/api-reference.rst @@ -13,6 +13,10 @@ API Reference :undoc-members: :members: +.. automodule:: vws.vumark_accept + :undoc-members: + :members: + .. automodule:: vws.response :undoc-members: :members: diff --git a/pyproject.toml b/pyproject.toml index 510f2c80b..a00c706fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -363,6 +363,9 @@ ignore_names = [ "pytest_plugins", # pytest fixtures - we name fixtures like this for this purpose "fixture_*", + # Enum members used dynamically + "PDF", + "SVG", # Sphinx "autoclass_content", "autoclass_content", diff --git a/src/vws/exceptions/vws_exceptions.py b/src/vws/exceptions/vws_exceptions.py index 6b1447577..6143f7397 100644 --- a/src/vws/exceptions/vws_exceptions.py +++ b/src/vws/exceptions/vws_exceptions.py @@ -167,3 +167,19 @@ class TooManyRequestsError(VWSError): # pragma: no cover """Exception raised when Vuforia returns a response with a result code 'TooManyRequests'. """ + + +# This is not simulated by client code because the accept parameter uses +# the VuMarkAccept enum, which only allows valid values. +@beartype +class InvalidAcceptHeaderError(VWSError): # pragma: no cover + """Exception raised when Vuforia returns a response with a result code + 'InvalidAcceptHeader'. + """ + + +@beartype +class InvalidInstanceIdError(VWSError): + """Exception raised when Vuforia returns a response with a result code + 'InvalidInstanceId'. + """ diff --git a/src/vws/vumark_accept.py b/src/vws/vumark_accept.py new file mode 100644 index 000000000..5dd21ab08 --- /dev/null +++ b/src/vws/vumark_accept.py @@ -0,0 +1,18 @@ +"""Tools for managing ``VWS.generate_vumark_instance``'s ``accept``.""" + +from enum import StrEnum, unique + +from beartype import beartype + + +@beartype +@unique +class VuMarkAccept(StrEnum): + """ + Options for the ``accept`` parameter of + ``VWS.generate_vumark_instance``. + """ + + PNG = "image/png" + SVG = "image/svg+xml" + PDF = "application/pdf" diff --git a/src/vws/vws.py b/src/vws/vws.py index 4cb75ca32..7b026a3c3 100644 --- a/src/vws/vws.py +++ b/src/vws/vws.py @@ -23,6 +23,8 @@ DateRangeError, FailError, ImageTooLargeError, + InvalidAcceptHeaderError, + InvalidInstanceIdError, MetadataTooLargeError, ProjectHasNoAPIAccessError, ProjectInactiveError, @@ -44,6 +46,7 @@ TargetSummaryReport, ) from vws.response import Response +from vws.vumark_accept import VuMarkAccept _ImageType = io.BytesIO | BinaryIO @@ -700,3 +703,116 @@ def update_target( expected_result_code="Success", content_type="application/json", ) + + def generate_vumark_instance( + self, + *, + target_id: str, + instance_id: str, + accept: VuMarkAccept = VuMarkAccept.PNG, + ) -> bytes: + """Generate a VuMark instance image. + + See + https://developer.vuforia.com/library/vuforia-engine/web-api/vumark-generation-web-api/ + for parameter details. + + Args: + target_id: The ID of the VuMark target. + instance_id: The instance ID to encode in the VuMark. + accept: The image format to return. + + Returns: + The VuMark instance image bytes. + + Raises: + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a + known database. + ~vws.exceptions.vws_exceptions.InvalidAcceptHeaderError: The + Accept header value is not supported. + ~vws.exceptions.vws_exceptions.InvalidInstanceIdError: The + instance ID is invalid. For example, it may be empty. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.vws_exceptions.TargetStatusNotSuccessError: The + target is not in the success state. + ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target + ID does not match a target in the database. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. + """ + request_path = f"/targets/{target_id}/instances" + content_type = "application/json" + request_data = json.dumps(obj={"instance_id": instance_id}).encode( + encoding="utf-8", + ) + date_string = rfc_1123_date() + + signature_string = authorization_header( + access_key=self._server_access_key, + secret_key=self._server_secret_key, + method=HTTPMethod.POST, + content=request_data, + content_type=content_type, + date=date_string, + request_path=request_path, + ) + + headers = { + "Authorization": signature_string, + "Date": date_string, + "Content-Type": content_type, + "Accept": accept, + } + + url = urljoin(base=self._base_vws_url, url=request_path) + + requests_response = requests.request( + method=HTTPMethod.POST, + url=url, + headers=headers, + data=request_data, + timeout=self._request_timeout_seconds, + ) + + if requests_response.status_code == HTTPStatus.OK: + return bytes(requests_response.content) + + response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + ) + + if ( + requests_response.status_code == HTTPStatus.TOO_MANY_REQUESTS + ): # pragma: no cover + raise TooManyRequestsError(response=response) + + if ( + requests_response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR + ): # pragma: no cover + raise ServerError(response=response) + + result_code = json.loads(s=response.text)["result_code"] + + exception = { + "AuthenticationFailure": AuthenticationFailureError, + "DateRangeError": DateRangeError, + "Fail": FailError, + "InvalidAcceptHeader": InvalidAcceptHeaderError, + "InvalidInstanceId": InvalidInstanceIdError, + "RequestTimeTooSkewed": RequestTimeTooSkewedError, + "TargetStatusNotSuccess": TargetStatusNotSuccessError, + "UnknownTarget": UnknownTargetError, + }[result_code] + + raise exception(response=response) diff --git a/tests/test_vws.py b/tests/test_vws.py index 87cdbf8f0..1e077e16a 100644 --- a/tests/test_vws.py +++ b/tests/test_vws.py @@ -21,6 +21,7 @@ TargetStatuses, TargetSummaryReport, ) +from vws.vumark_accept import VuMarkAccept class TestAddTarget: @@ -729,3 +730,55 @@ def test_no_fields_given( ) vws_client.wait_for_target_processed(target_id=target_id) vws_client.update_target(target_id=target_id) + + +class TestGenerateVumarkInstance: + """Tests for generating VuMark instances.""" + + @staticmethod + @pytest.mark.parametrize( + argnames="accept", + argvalues=list(VuMarkAccept), + ) + def test_generate_vumark_instance( + vws_client: VWS, + high_quality_image: io.BytesIO, + accept: VuMarkAccept, + ) -> None: + """Bytes are returned when generating a VuMark instance.""" + target_id = vws_client.add_target( + name="x", + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + vws_client.wait_for_target_processed(target_id=target_id) + result = vws_client.generate_vumark_instance( + target_id=target_id, + instance_id="12345", + accept=accept, + ) + assert isinstance(result, bytes) + assert len(result) > 0 + + @staticmethod + def test_generate_vumark_default_accept( + vws_client: VWS, + high_quality_image: io.BytesIO, + ) -> None: + """By default, PNG is returned.""" + target_id = vws_client.add_target( + name="x", + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + vws_client.wait_for_target_processed(target_id=target_id) + result = vws_client.generate_vumark_instance( + target_id=target_id, + instance_id="12345", + ) + assert isinstance(result, bytes) + assert len(result) > 0 diff --git a/tests/test_vws_exceptions.py b/tests/test_vws_exceptions.py index 74374e3d0..4a9f012de 100644 --- a/tests/test_vws_exceptions.py +++ b/tests/test_vws_exceptions.py @@ -21,6 +21,8 @@ DateRangeError, FailError, ImageTooLargeError, + InvalidAcceptHeaderError, + InvalidInstanceIdError, MetadataTooLargeError, ProjectHasNoAPIAccessError, ProjectInactiveError, @@ -342,6 +344,8 @@ def test_vwsexception_inheritance() -> None: DateRangeError, FailError, ImageTooLargeError, + InvalidAcceptHeaderError, + InvalidInstanceIdError, MetadataTooLargeError, ProjectInactiveError, ProjectHasNoAPIAccessError, @@ -358,6 +362,31 @@ def test_vwsexception_inheritance() -> None: assert issubclass(subclass, VWSError) +def test_invalid_instance_id( + vws_client: VWS, + high_quality_image: io.BytesIO, +) -> None: + """ + An ``InvalidInstanceId`` exception is raised when an empty instance + ID is given. + """ + target_id = vws_client.add_target( + name="x", + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + vws_client.wait_for_target_processed(target_id=target_id) + with pytest.raises(expected_exception=InvalidInstanceIdError) as exc: + vws_client.generate_vumark_instance( + target_id=target_id, + instance_id="", + ) + + assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + def test_base_exception( vws_client: VWS, high_quality_image: io.BytesIO, From 6373daedff9452bc6eaf65bc8546f4a997bd06ef Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 18 Feb 2026 23:34:16 +0000 Subject: [PATCH 02/19] Add BadRequestError and bump vws-python-mock to 2026.2.18.2 Add BadRequestError exception for the BadRequest result code returned by the VuMark endpoint when invalid JSON is sent. Map it in generate_vumark_instance and include it in the exception inheritance test. Bump vws-python-mock to 2026.2.18.2 which adds full VuMark auth endpoint testing support. Co-Authored-By: Claude Haiku 4.5 --- src/vws/exceptions/vws_exceptions.py | 9 +++++++++ src/vws/vws.py | 2 ++ tests/test_vws_exceptions.py | 2 ++ 3 files changed, 13 insertions(+) diff --git a/src/vws/exceptions/vws_exceptions.py b/src/vws/exceptions/vws_exceptions.py index 6143f7397..6baac80c6 100644 --- a/src/vws/exceptions/vws_exceptions.py +++ b/src/vws/exceptions/vws_exceptions.py @@ -183,3 +183,12 @@ class InvalidInstanceIdError(VWSError): """Exception raised when Vuforia returns a response with a result code 'InvalidInstanceId'. """ + + +# This is not simulated by client code because the request body +# is always valid JSON when using this client. +@beartype +class BadRequestError(VWSError): # pragma: no cover + """Exception raised when Vuforia returns a response with a result code + 'BadRequest'. + """ diff --git a/src/vws/vws.py b/src/vws/vws.py index 7b026a3c3..ccc5f0056 100644 --- a/src/vws/vws.py +++ b/src/vws/vws.py @@ -20,6 +20,7 @@ from vws.exceptions.vws_exceptions import ( AuthenticationFailureError, BadImageError, + BadRequestError, DateRangeError, FailError, ImageTooLargeError, @@ -806,6 +807,7 @@ def generate_vumark_instance( exception = { "AuthenticationFailure": AuthenticationFailureError, + "BadRequest": BadRequestError, "DateRangeError": DateRangeError, "Fail": FailError, "InvalidAcceptHeader": InvalidAcceptHeaderError, diff --git a/tests/test_vws_exceptions.py b/tests/test_vws_exceptions.py index 4a9f012de..4f5c3eded 100644 --- a/tests/test_vws_exceptions.py +++ b/tests/test_vws_exceptions.py @@ -18,6 +18,7 @@ from vws.exceptions.vws_exceptions import ( AuthenticationFailureError, BadImageError, + BadRequestError, DateRangeError, FailError, ImageTooLargeError, @@ -341,6 +342,7 @@ def test_vwsexception_inheritance() -> None: subclasses = [ AuthenticationFailureError, BadImageError, + BadRequestError, DateRangeError, FailError, ImageTooLargeError, From 64c18695c3d9f729472611b39b2d27712504bccf Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Thu, 19 Feb 2026 09:03:20 +0000 Subject: [PATCH 03/19] Add 'enum' to spelling private dict Fix pylint wrong-spelling-in-comment (C0401) for the word 'enum' in the VuMark exception comments. Co-Authored-By: Claude Haiku 4.5 --- spelling_private_dict.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/spelling_private_dict.txt b/spelling_private_dict.txt index ce782a4af..d5d5d3e99 100644 --- a/spelling_private_dict.txt +++ b/spelling_private_dict.txt @@ -42,6 +42,7 @@ decodable dev dict docstring +enum filename foo formdata From 913a4498321f76dc0fd6bfdd00541cf3503bade1 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Thu, 19 Feb 2026 09:25:22 +0000 Subject: [PATCH 04/19] Assert format-specific content in VuMark tests Check magic bytes/prefix for each format (PNG, SVG, PDF) rather than just asserting non-empty bytes are returned. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_vws.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/test_vws.py b/tests/test_vws.py index 1e077e16a..575612ca8 100644 --- a/tests/test_vws.py +++ b/tests/test_vws.py @@ -737,15 +737,20 @@ class TestGenerateVumarkInstance: @staticmethod @pytest.mark.parametrize( - argnames="accept", - argvalues=list(VuMarkAccept), + 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( vws_client: VWS, high_quality_image: io.BytesIO, accept: VuMarkAccept, + expected_prefix: bytes, ) -> None: - """Bytes are returned when generating a VuMark instance.""" + """The returned bytes match the requested format.""" target_id = vws_client.add_target( name="x", width=1, @@ -759,8 +764,7 @@ def test_generate_vumark_instance( instance_id="12345", accept=accept, ) - assert isinstance(result, bytes) - assert len(result) > 0 + assert result.startswith(expected_prefix) @staticmethod def test_generate_vumark_default_accept( @@ -780,5 +784,4 @@ def test_generate_vumark_default_accept( target_id=target_id, instance_id="12345", ) - assert isinstance(result, bytes) - assert len(result) > 0 + assert result.startswith(b"\x89PNG\r\n\x1a\n") From ee84b968d2a6925977b826689daff9b784068be9 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Thu, 19 Feb 2026 09:26:48 +0000 Subject: [PATCH 05/19] Remove default for accept parameter in generate_vumark_instance Co-Authored-By: Claude Sonnet 4.6 --- src/vws/vws.py | 2 +- tests/test_vws.py | 20 -------------------- tests/test_vws_exceptions.py | 2 ++ 3 files changed, 3 insertions(+), 21 deletions(-) diff --git a/src/vws/vws.py b/src/vws/vws.py index ccc5f0056..e0035ffcc 100644 --- a/src/vws/vws.py +++ b/src/vws/vws.py @@ -710,7 +710,7 @@ def generate_vumark_instance( *, target_id: str, instance_id: str, - accept: VuMarkAccept = VuMarkAccept.PNG, + accept: VuMarkAccept, ) -> bytes: """Generate a VuMark instance image. diff --git a/tests/test_vws.py b/tests/test_vws.py index 575612ca8..8ac09a3e1 100644 --- a/tests/test_vws.py +++ b/tests/test_vws.py @@ -765,23 +765,3 @@ def test_generate_vumark_instance( accept=accept, ) assert result.startswith(expected_prefix) - - @staticmethod - def test_generate_vumark_default_accept( - vws_client: VWS, - high_quality_image: io.BytesIO, - ) -> None: - """By default, PNG is returned.""" - target_id = vws_client.add_target( - name="x", - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - result = vws_client.generate_vumark_instance( - target_id=target_id, - instance_id="12345", - ) - assert result.startswith(b"\x89PNG\r\n\x1a\n") diff --git a/tests/test_vws_exceptions.py b/tests/test_vws_exceptions.py index 4f5c3eded..b55f35fed 100644 --- a/tests/test_vws_exceptions.py +++ b/tests/test_vws_exceptions.py @@ -36,6 +36,7 @@ TargetStatusProcessingError, UnknownTargetError, ) +from vws.vumark_accept import VuMarkAccept def test_image_too_large( @@ -384,6 +385,7 @@ def test_invalid_instance_id( vws_client.generate_vumark_instance( target_id=target_id, instance_id="", + accept=VuMarkAccept.PNG, ) assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY From cb175d403a70d80497da1a1a1cb03e364cd0775c Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Thu, 19 Feb 2026 09:27:57 +0000 Subject: [PATCH 06/19] Remove now-unnecessary vulture ignore for SVG and PDF enum members They are now referenced explicitly in the parametrized test. Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a00c706fb..510f2c80b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -363,9 +363,6 @@ ignore_names = [ "pytest_plugins", # pytest fixtures - we name fixtures like this for this purpose "fixture_*", - # Enum members used dynamically - "PDF", - "SVG", # Sphinx "autoclass_content", "autoclass_content", From 44445d1e4b46e9e31c9bf376d2e9c10a3f02b3c9 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Thu, 19 Feb 2026 09:29:34 +0000 Subject: [PATCH 07/19] Add BadRequest, InvalidAcceptHeader, InvalidInstanceId to spelling dict Fix Sphinx spell checker failures for the result code names used in the new exception docstrings. Co-Authored-By: Claude Sonnet 4.6 --- spelling_private_dict.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spelling_private_dict.txt b/spelling_private_dict.txt index d5d5d3e99..5d095eabd 100644 --- a/spelling_private_dict.txt +++ b/spelling_private_dict.txt @@ -1,9 +1,12 @@ AuthenticationFailure BadImage +BadRequest ConnectionErrorPossiblyImageTooLarge DateRangeError ImageTooLarge InactiveProject +InvalidAcceptHeader +InvalidInstanceId JSONDecodeError MatchProcessing MaxNumResultsOutOfRange From e7552e144d21f35e5ef7c862b5f1bd872e781bf7 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Thu, 19 Feb 2026 09:31:17 +0000 Subject: [PATCH 08/19] Use double backticks for result codes in exception docstrings Replace single-quoted result code names with double backticks so the Sphinx spell checker treats them as inline code rather than words. Co-Authored-By: Claude Sonnet 4.6 --- spelling_private_dict.txt | 3 --- src/vws/exceptions/vws_exceptions.py | 6 +++--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/spelling_private_dict.txt b/spelling_private_dict.txt index 5d095eabd..d5d5d3e99 100644 --- a/spelling_private_dict.txt +++ b/spelling_private_dict.txt @@ -1,12 +1,9 @@ AuthenticationFailure BadImage -BadRequest ConnectionErrorPossiblyImageTooLarge DateRangeError ImageTooLarge InactiveProject -InvalidAcceptHeader -InvalidInstanceId JSONDecodeError MatchProcessing MaxNumResultsOutOfRange diff --git a/src/vws/exceptions/vws_exceptions.py b/src/vws/exceptions/vws_exceptions.py index 6baac80c6..e2d44ec35 100644 --- a/src/vws/exceptions/vws_exceptions.py +++ b/src/vws/exceptions/vws_exceptions.py @@ -174,14 +174,14 @@ class TooManyRequestsError(VWSError): # pragma: no cover @beartype class InvalidAcceptHeaderError(VWSError): # pragma: no cover """Exception raised when Vuforia returns a response with a result code - 'InvalidAcceptHeader'. + ``InvalidAcceptHeader``. """ @beartype class InvalidInstanceIdError(VWSError): """Exception raised when Vuforia returns a response with a result code - 'InvalidInstanceId'. + ``InvalidInstanceId``. """ @@ -190,5 +190,5 @@ class InvalidInstanceIdError(VWSError): @beartype class BadRequestError(VWSError): # pragma: no cover """Exception raised when Vuforia returns a response with a result code - 'BadRequest'. + ``BadRequest``. """ From 7bae5a5e0f913b0eb37550262ed3b27ff9cf0d9b Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Thu, 19 Feb 2026 09:36:57 +0000 Subject: [PATCH 09/19] Refactor generate_vumark_instance to use _target_api_request Adds extra_headers parameter to _target_api_request and content field to Response, allowing generate_vumark_instance to reuse shared auth/signing logic instead of duplicating it. Co-Authored-By: Claude Sonnet 4.6 --- src/vws/query.py | 1 + src/vws/response.py | 1 + src/vws/vws.py | 51 +++++++++++++-------------------------------- 3 files changed, 17 insertions(+), 36 deletions(-) diff --git a/src/vws/query.py b/src/vws/query.py index dd36e8f21..ad55d8a47 100644 --- a/src/vws/query.py +++ b/src/vws/query.py @@ -156,6 +156,7 @@ def query( headers=dict(requests_response.headers), request_body=requests_response.request.body, tell_position=requests_response.raw.tell(), + content=bytes(requests_response.content), ) if response.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE: diff --git a/src/vws/response.py b/src/vws/response.py index e2060cb9e..71d1b9a48 100644 --- a/src/vws/response.py +++ b/src/vws/response.py @@ -16,3 +16,4 @@ class Response: headers: dict[str, str] request_body: bytes | str | None tell_position: int + content: bytes diff --git a/src/vws/vws.py b/src/vws/vws.py index e0035ffcc..f3455b809 100644 --- a/src/vws/vws.py +++ b/src/vws/vws.py @@ -73,6 +73,7 @@ def _target_api_request( request_path: str, base_vws_url: str, request_timeout_seconds: float | tuple[float, float], + extra_headers: dict[str, str] | None = None, ) -> Response: """Make a request to the Vuforia Target API. @@ -90,6 +91,7 @@ def _target_api_request( request_timeout_seconds: The timeout for the request, as used by ``requests.request``. This can be a float to set both the connect and read timeouts, or a (connect, read) tuple. + extra_headers: Additional headers to include in the request. Returns: The response to the request made by `requests`. @@ -110,6 +112,7 @@ def _target_api_request( "Authorization": signature_string, "Date": date_string, "Content-Type": content_type, + **(extra_headers or {}), } url = urljoin(base=base_vws_url, url=request_path) @@ -129,6 +132,7 @@ def _target_api_request( headers=dict(requests_response.headers), request_body=requests_response.request.body, tell_position=requests_response.raw.tell(), + content=bytes(requests_response.content), ) @@ -752,54 +756,29 @@ def generate_vumark_instance( request_data = json.dumps(obj={"instance_id": instance_id}).encode( encoding="utf-8", ) - date_string = rfc_1123_date() - signature_string = authorization_header( - access_key=self._server_access_key, - secret_key=self._server_secret_key, - method=HTTPMethod.POST, - content=request_data, + response = _target_api_request( content_type=content_type, - date=date_string, - request_path=request_path, - ) - - headers = { - "Authorization": signature_string, - "Date": date_string, - "Content-Type": content_type, - "Accept": accept, - } - - url = urljoin(base=self._base_vws_url, url=request_path) - - requests_response = requests.request( + server_access_key=self._server_access_key, + server_secret_key=self._server_secret_key, method=HTTPMethod.POST, - url=url, - headers=headers, data=request_data, - timeout=self._request_timeout_seconds, + request_path=request_path, + base_vws_url=self._base_vws_url, + request_timeout_seconds=self._request_timeout_seconds, + extra_headers={"Accept": accept}, ) - if requests_response.status_code == HTTPStatus.OK: - return bytes(requests_response.content) - - response = Response( - text=requests_response.text, - url=requests_response.url, - status_code=requests_response.status_code, - headers=dict(requests_response.headers), - request_body=requests_response.request.body, - tell_position=requests_response.raw.tell(), - ) + if response.status_code == HTTPStatus.OK: + return response.content if ( - requests_response.status_code == HTTPStatus.TOO_MANY_REQUESTS + response.status_code == HTTPStatus.TOO_MANY_REQUESTS ): # pragma: no cover raise TooManyRequestsError(response=response) if ( - requests_response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR + response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR ): # pragma: no cover raise ServerError(response=response) From 064f482dd9c3e3f4bae5db9c31e2105329970647 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Thu, 19 Feb 2026 09:39:29 +0000 Subject: [PATCH 10/19] Move 429/5xx handling into _target_api_request Centralises TooManyRequestsError and ServerError raising so callers (make_request, generate_vumark_instance) no longer duplicate the logic. Co-Authored-By: Claude Sonnet 4.6 --- src/vws/vws.py | 42 ++++++++++++++++++++---------------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/src/vws/vws.py b/src/vws/vws.py index f3455b809..e1d2eea46 100644 --- a/src/vws/vws.py +++ b/src/vws/vws.py @@ -95,6 +95,12 @@ def _target_api_request( Returns: The response to the request made by `requests`. + + Raises: + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. """ date_string = rfc_1123_date() @@ -125,7 +131,7 @@ def _target_api_request( timeout=request_timeout_seconds, ) - return Response( + response = Response( text=requests_response.text, url=requests_response.url, status_code=requests_response.status_code, @@ -135,6 +141,19 @@ def _target_api_request( content=bytes(requests_response.content), ) + if ( + response.status_code == HTTPStatus.TOO_MANY_REQUESTS + ): # pragma: no cover + # The Vuforia API returns a 429 response with no JSON body. + raise TooManyRequestsError(response=response) + + if ( + response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR + ): # pragma: no cover + raise ServerError(response=response) + + return response + @beartype(conf=BeartypeConf(is_pep484_tower=True)) class VWS: @@ -207,17 +226,6 @@ def make_request( request_timeout_seconds=self._request_timeout_seconds, ) - if ( - response.status_code == HTTPStatus.TOO_MANY_REQUESTS - ): # pragma: no cover - # The Vuforia API returns a 429 response with no JSON body. - raise TooManyRequestsError(response=response) - - if ( - response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR - ): # pragma: no cover - raise ServerError(response=response) - result_code = json.loads(s=response.text)["result_code"] if result_code == expected_result_code: @@ -772,16 +780,6 @@ def generate_vumark_instance( if response.status_code == HTTPStatus.OK: return response.content - if ( - response.status_code == HTTPStatus.TOO_MANY_REQUESTS - ): # pragma: no cover - raise TooManyRequestsError(response=response) - - if ( - response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR - ): # pragma: no cover - raise ServerError(response=response) - result_code = json.loads(s=response.text)["result_code"] exception = { From e8453c42b06257b01420d0690d5b35fbd7b77e6a Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Thu, 19 Feb 2026 09:45:47 +0000 Subject: [PATCH 11/19] Use make_request in generate_vumark_instance Adds extra_headers and optional expected_result_code (None = binary/HTTP-200 success) to make_request, and adds VuMark-specific result codes to its error dispatch dict, so generate_vumark_instance no longer needs to call _target_api_request directly. Co-Authored-By: Claude Sonnet 4.6 --- src/vws/vws.py | 45 ++++++++++++++++++++------------------------- 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/src/vws/vws.py b/src/vws/vws.py index e1d2eea46..c121e081d 100644 --- a/src/vws/vws.py +++ b/src/vws/vws.py @@ -187,8 +187,9 @@ def make_request( method: str, data: bytes, request_path: str, - expected_result_code: str, + expected_result_code: str | None, content_type: str, + extra_headers: dict[str, str] | None = None, ) -> Response: """Make a request to the Vuforia Target API. @@ -201,7 +202,11 @@ def make_request( request. expected_result_code: See "VWS API Result Codes" on https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api. + Pass ``None`` for endpoints that return a non-JSON success + response (e.g. binary data); success is then determined by an + HTTP 200 status code. content_type: The content type of the request. + extra_headers: Additional headers to include in the request. Returns: The response to the request made by `requests`. @@ -224,8 +229,15 @@ def make_request( request_path=request_path, base_vws_url=self._base_vws_url, request_timeout_seconds=self._request_timeout_seconds, + extra_headers=extra_headers, ) + if ( + response.status_code == HTTPStatus.OK + and expected_result_code is None + ): + return response + result_code = json.loads(s=response.text)["result_code"] if result_code == expected_result_code: @@ -234,9 +246,12 @@ def make_request( exception = { "AuthenticationFailure": AuthenticationFailureError, "BadImage": BadImageError, + "BadRequest": BadRequestError, "DateRangeError": DateRangeError, "Fail": FailError, "ImageTooLarge": ImageTooLargeError, + "InvalidAcceptHeader": InvalidAcceptHeaderError, + "InvalidInstanceId": InvalidInstanceIdError, "MetadataTooLarge": MetadataTooLargeError, "ProjectHasNoAPIAccess": ProjectHasNoAPIAccessError, "ProjectInactive": ProjectInactiveError, @@ -765,33 +780,13 @@ def generate_vumark_instance( encoding="utf-8", ) - response = _target_api_request( - content_type=content_type, - server_access_key=self._server_access_key, - server_secret_key=self._server_secret_key, + response = self.make_request( method=HTTPMethod.POST, data=request_data, request_path=request_path, - base_vws_url=self._base_vws_url, - request_timeout_seconds=self._request_timeout_seconds, + expected_result_code=None, + content_type=content_type, extra_headers={"Accept": accept}, ) - if response.status_code == HTTPStatus.OK: - return response.content - - result_code = json.loads(s=response.text)["result_code"] - - exception = { - "AuthenticationFailure": AuthenticationFailureError, - "BadRequest": BadRequestError, - "DateRangeError": DateRangeError, - "Fail": FailError, - "InvalidAcceptHeader": InvalidAcceptHeaderError, - "InvalidInstanceId": InvalidInstanceIdError, - "RequestTimeTooSkewed": RequestTimeTooSkewedError, - "TargetStatusNotSuccess": TargetStatusNotSuccessError, - "UnknownTarget": UnknownTargetError, - }[result_code] - - raise exception(response=response) + return response.content From c553cd1c3849aa242e44d4b1ffdc7e8e0b454e16 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Thu, 19 Feb 2026 09:48:13 +0000 Subject: [PATCH 12/19] Move 429/5xx handling back to make_request Now that generate_vumark_instance uses make_request, _target_api_request can be a pure sign-and-send function again. Co-Authored-By: Claude Sonnet 4.6 --- src/vws/vws.py | 32 ++++++++++++-------------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/src/vws/vws.py b/src/vws/vws.py index c121e081d..23d5dbcbc 100644 --- a/src/vws/vws.py +++ b/src/vws/vws.py @@ -95,12 +95,6 @@ def _target_api_request( Returns: The response to the request made by `requests`. - - Raises: - ~vws.exceptions.custom_exceptions.ServerError: There is an error - with Vuforia's servers. - ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is - rate limiting access. """ date_string = rfc_1123_date() @@ -131,7 +125,7 @@ def _target_api_request( timeout=request_timeout_seconds, ) - response = Response( + return Response( text=requests_response.text, url=requests_response.url, status_code=requests_response.status_code, @@ -141,19 +135,6 @@ def _target_api_request( content=bytes(requests_response.content), ) - if ( - response.status_code == HTTPStatus.TOO_MANY_REQUESTS - ): # pragma: no cover - # The Vuforia API returns a 429 response with no JSON body. - raise TooManyRequestsError(response=response) - - if ( - response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR - ): # pragma: no cover - raise ServerError(response=response) - - return response - @beartype(conf=BeartypeConf(is_pep484_tower=True)) class VWS: @@ -232,6 +213,17 @@ def make_request( extra_headers=extra_headers, ) + if ( + response.status_code == HTTPStatus.TOO_MANY_REQUESTS + ): # pragma: no cover + # The Vuforia API returns a 429 response with no JSON body. + raise TooManyRequestsError(response=response) + + if ( + response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR + ): # pragma: no cover + raise ServerError(response=response) + if ( response.status_code == HTTPStatus.OK and expected_result_code is None From ff0f22fdba81e710ed54b3ea837bc55c1744b78a Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Thu, 19 Feb 2026 09:49:31 +0000 Subject: [PATCH 13/19] Make extra_headers required in _target_api_request Co-Authored-By: Claude Sonnet 4.6 --- src/vws/vws.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vws/vws.py b/src/vws/vws.py index 23d5dbcbc..679d964bb 100644 --- a/src/vws/vws.py +++ b/src/vws/vws.py @@ -73,7 +73,7 @@ def _target_api_request( request_path: str, base_vws_url: str, request_timeout_seconds: float | tuple[float, float], - extra_headers: dict[str, str] | None = None, + extra_headers: dict[str, str], ) -> Response: """Make a request to the Vuforia Target API. @@ -112,7 +112,7 @@ def _target_api_request( "Authorization": signature_string, "Date": date_string, "Content-Type": content_type, - **(extra_headers or {}), + **extra_headers, } url = urljoin(base=base_vws_url, url=request_path) @@ -210,7 +210,7 @@ def make_request( request_path=request_path, base_vws_url=self._base_vws_url, request_timeout_seconds=self._request_timeout_seconds, - extra_headers=extra_headers, + extra_headers=extra_headers or {}, ) if ( From a2b6332e0430c18857f5783bf2d67cc104922eda Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Thu, 19 Feb 2026 10:28:54 +0000 Subject: [PATCH 14/19] Address PR comments: VuMark target type and target_id fix - Fix target_id property extraction for /targets/{id}/instances URL pattern - Add InvalidTargetTypeError exception - Add vumark_vws_client and vumark_target_id fixtures using a pre-populated VuMark template target (requires vws-python-mock#2962) - Update tests to use VuMark target instead of a standard cloud target - Add test_invalid_target_type test (requires vws-python-mock#2961) Co-Authored-By: Claude Sonnet 4.6 --- src/vws/exceptions/vws_exceptions.py | 28 +++++++++++++++++--------- src/vws/vws.py | 4 ++++ tests/conftest.py | 28 +++++++++++++++++++++++++- tests/test_vws.py | 16 ++++----------- tests/test_vws_exceptions.py | 30 +++++++++++++++++++++++----- 5 files changed, 79 insertions(+), 27 deletions(-) diff --git a/src/vws/exceptions/vws_exceptions.py b/src/vws/exceptions/vws_exceptions.py index e2d44ec35..865d2372c 100644 --- a/src/vws/exceptions/vws_exceptions.py +++ b/src/vws/exceptions/vws_exceptions.py @@ -24,9 +24,10 @@ class UnknownTargetError(VWSError): def target_id(self) -> str: """The unknown target ID.""" path = urlparse(url=self.response.url).path - # Every HTTP path which can raise this error is in the format - # `/something/{target_id}`. - return path.split(sep="/", maxsplit=2)[-1] + # Every HTTP path which can raise this error has the target ID as the + # second path segment, e.g. `/something/{target_id}` or + # `/something/{target_id}/more`. + return path.split(sep="/")[2] @beartype @@ -68,9 +69,10 @@ class TargetStatusProcessingError(VWSError): def target_id(self) -> str: """The processing target ID.""" path = urlparse(url=self.response.url).path - # Every HTTP path which can raise this error is in the format - # `/something/{target_id}`. - return path.split(sep="/", maxsplit=2)[-1] + # Every HTTP path which can raise this error has the target ID as the + # second path segment, e.g. `/something/{target_id}` or + # `/something/{target_id}/more`. + return path.split(sep="/")[2] # This is not simulated by the mock. @@ -157,9 +159,10 @@ class TargetStatusNotSuccessError(VWSError): def target_id(self) -> str: """The unknown target ID.""" path = urlparse(url=self.response.url).path - # Every HTTP path which can raise this error is in the format - # `/something/{target_id}`. - return path.split(sep="/", maxsplit=2)[-1] + # Every HTTP path which can raise this error has the target ID as the + # second path segment, e.g. `/something/{target_id}` or + # `/something/{target_id}/more`. + return path.split(sep="/")[2] @beartype @@ -192,3 +195,10 @@ class BadRequestError(VWSError): # pragma: no cover """Exception raised when Vuforia returns a response with a result code ``BadRequest``. """ + + +@beartype +class InvalidTargetTypeError(VWSError): + """Exception raised when Vuforia returns a response with a result code + ``InvalidTargetType``. + """ diff --git a/src/vws/vws.py b/src/vws/vws.py index 679d964bb..329ee5425 100644 --- a/src/vws/vws.py +++ b/src/vws/vws.py @@ -26,6 +26,7 @@ ImageTooLargeError, InvalidAcceptHeaderError, InvalidInstanceIdError, + InvalidTargetTypeError, MetadataTooLargeError, ProjectHasNoAPIAccessError, ProjectInactiveError, @@ -244,6 +245,7 @@ def make_request( "ImageTooLarge": ImageTooLargeError, "InvalidAcceptHeader": InvalidAcceptHeaderError, "InvalidInstanceId": InvalidInstanceIdError, + "InvalidTargetType": InvalidTargetTypeError, "MetadataTooLarge": MetadataTooLargeError, "ProjectHasNoAPIAccess": ProjectHasNoAPIAccessError, "ProjectInactive": ProjectInactiveError, @@ -755,6 +757,8 @@ def generate_vumark_instance( Accept header value is not supported. ~vws.exceptions.vws_exceptions.InvalidInstanceIdError: The instance ID is invalid. For example, it may be empty. + ~vws.exceptions.vws_exceptions.InvalidTargetTypeError: The target + is not a VuMark template target. ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is an error with the time sent to Vuforia. ~vws.exceptions.vws_exceptions.TargetStatusNotSuccessError: The diff --git a/tests/conftest.py b/tests/conftest.py index 9073a8c03..bca6ff6c1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,7 +7,7 @@ import pytest from mock_vws import MockVWS -from mock_vws.database import CloudDatabase +from mock_vws.database import CloudDatabase, VuMarkDatabase, VuMarkTarget from vws import VWS, CloudRecoService @@ -22,6 +22,32 @@ def fixture_mock_database() -> Generator[CloudDatabase]: yield database +@pytest.fixture(name="_mock_vumark_database") +def fixture_mock_vumark_database() -> Generator[VuMarkDatabase]: + """Yield a mock ``VuMarkDatabase`` with a template target.""" + vumark_target = VuMarkTarget(name="vumark-template") + with MockVWS() as mock: + database = VuMarkDatabase(vumark_targets={vumark_target}) + mock.add_vumark_database(vumark_database=database) + yield database + + +@pytest.fixture +def vumark_vws_client(_mock_vumark_database: VuMarkDatabase) -> VWS: + """A VWS client which connects to a mock VuMark database.""" + return VWS( + server_access_key=_mock_vumark_database.server_access_key, + server_secret_key=_mock_vumark_database.server_secret_key, + ) + + +@pytest.fixture +def vumark_target_id(_mock_vumark_database: VuMarkDatabase) -> str: + """The ID of the VuMark template target.""" + (target,) = _mock_vumark_database.vumark_targets + return target.target_id + + @pytest.fixture def vws_client(_mock_database: CloudDatabase) -> VWS: """A VWS client which connects to a mock database.""" diff --git a/tests/test_vws.py b/tests/test_vws.py index 8ac09a3e1..0926f3c85 100644 --- a/tests/test_vws.py +++ b/tests/test_vws.py @@ -745,22 +745,14 @@ class TestGenerateVumarkInstance: ], ) def test_generate_vumark_instance( - vws_client: VWS, - high_quality_image: io.BytesIO, + vumark_vws_client: VWS, + vumark_target_id: str, accept: VuMarkAccept, expected_prefix: bytes, ) -> None: """The returned bytes match the requested format.""" - target_id = vws_client.add_target( - name="x", - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - result = vws_client.generate_vumark_instance( - target_id=target_id, + result = vumark_vws_client.generate_vumark_instance( + target_id=vumark_target_id, instance_id="12345", accept=accept, ) diff --git a/tests/test_vws_exceptions.py b/tests/test_vws_exceptions.py index b55f35fed..0c34d928a 100644 --- a/tests/test_vws_exceptions.py +++ b/tests/test_vws_exceptions.py @@ -24,6 +24,7 @@ ImageTooLargeError, InvalidAcceptHeaderError, InvalidInstanceIdError, + InvalidTargetTypeError, MetadataTooLargeError, ProjectHasNoAPIAccessError, ProjectInactiveError, @@ -349,6 +350,7 @@ def test_vwsexception_inheritance() -> None: ImageTooLargeError, InvalidAcceptHeaderError, InvalidInstanceIdError, + InvalidTargetTypeError, MetadataTooLargeError, ProjectInactiveError, ProjectHasNoAPIAccessError, @@ -366,13 +368,31 @@ def test_vwsexception_inheritance() -> None: def test_invalid_instance_id( - vws_client: VWS, - high_quality_image: io.BytesIO, + vumark_vws_client: VWS, + vumark_target_id: str, ) -> None: """ An ``InvalidInstanceId`` exception is raised when an empty instance ID is given. """ + with pytest.raises(expected_exception=InvalidInstanceIdError) as exc: + vumark_vws_client.generate_vumark_instance( + target_id=vumark_target_id, + instance_id="", + accept=VuMarkAccept.PNG, + ) + + assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + +def test_invalid_target_type( + vws_client: VWS, + high_quality_image: io.BytesIO, +) -> None: + """ + An ``InvalidTargetType`` exception is raised when trying to generate + a VuMark instance from a non-VuMark target. + """ target_id = vws_client.add_target( name="x", width=1, @@ -381,14 +401,14 @@ def test_invalid_instance_id( application_metadata=None, ) vws_client.wait_for_target_processed(target_id=target_id) - with pytest.raises(expected_exception=InvalidInstanceIdError) as exc: + with pytest.raises(expected_exception=InvalidTargetTypeError) as exc: vws_client.generate_vumark_instance( target_id=target_id, - instance_id="", + instance_id="12345", accept=VuMarkAccept.PNG, ) - assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert exc.value.response.status_code == HTTPStatus.FORBIDDEN def test_base_exception( From 0fc38aaab8baa16e615e3917960227a701bcb0fd Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Thu, 19 Feb 2026 14:51:14 +0000 Subject: [PATCH 15/19] Move generate_vumark_instance to a new VuMarkService class VuMark generation targets a different database type (VuMark vs Cloud Reco), so it belongs in its own class rather than VWS, mirroring how CloudRecoService is separate from VWS. Co-Authored-By: Claude Sonnet 4.6 --- src/vws/__init__.py | 2 + src/vws/vumark_service.py | 143 +++++++++++++++++++++++++++++++++++ src/vws/vws.py | 68 ----------------- tests/conftest.py | 10 ++- tests/test_vws.py | 6 +- tests/test_vws_exceptions.py | 44 ++++++----- 6 files changed, 181 insertions(+), 92 deletions(-) create mode 100644 src/vws/vumark_service.py diff --git a/src/vws/__init__.py b/src/vws/__init__.py index bfd96d6c2..a091641f9 100644 --- a/src/vws/__init__.py +++ b/src/vws/__init__.py @@ -1,9 +1,11 @@ """A library for Vuforia Web Services.""" from .query import CloudRecoService +from .vumark_service import VuMarkService from .vws import VWS __all__ = [ "VWS", "CloudRecoService", + "VuMarkService", ] diff --git a/src/vws/vumark_service.py b/src/vws/vumark_service.py new file mode 100644 index 000000000..d4ad771b8 --- /dev/null +++ b/src/vws/vumark_service.py @@ -0,0 +1,143 @@ +"""Interface to the Vuforia VuMark Generation Web API.""" + +import json +from http import HTTPMethod, HTTPStatus + +from beartype import BeartypeConf, beartype + +from vws.exceptions.custom_exceptions import ServerError +from vws.exceptions.vws_exceptions import ( + AuthenticationFailureError, + BadRequestError, + DateRangeError, + FailError, + InvalidAcceptHeaderError, + InvalidInstanceIdError, + InvalidTargetTypeError, + RequestTimeTooSkewedError, + TargetStatusNotSuccessError, + TooManyRequestsError, + UnknownTargetError, +) +from vws.vumark_accept import VuMarkAccept +from vws.vws import _target_api_request + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +class VuMarkService: + """An interface to the Vuforia VuMark Generation Web API.""" + + def __init__( + self, + server_access_key: str, + server_secret_key: str, + base_vws_url: str = "https://vws.vuforia.com", + request_timeout_seconds: float | tuple[float, float] = 30.0, + ) -> None: + """ + Args: + server_access_key: A VWS server access key. + server_secret_key: A VWS server secret key. + base_vws_url: The base URL for the VWS API. + request_timeout_seconds: The timeout for each HTTP request, as + used by ``requests.request``. This can be a float to set + both the connect and read timeouts, or a (connect, read) + tuple. + """ + self._server_access_key = server_access_key + self._server_secret_key = server_secret_key + self._base_vws_url = base_vws_url + self._request_timeout_seconds = request_timeout_seconds + + def generate_vumark_instance( + self, + *, + target_id: str, + instance_id: str, + accept: VuMarkAccept, + ) -> bytes: + """Generate a VuMark instance image. + + See + https://developer.vuforia.com/library/vuforia-engine/web-api/vumark-generation-web-api/ + for parameter details. + + Args: + target_id: The ID of the VuMark target. + instance_id: The instance ID to encode in the VuMark. + accept: The image format to return. + + Returns: + The VuMark instance image bytes. + + Raises: + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a + known database. + ~vws.exceptions.vws_exceptions.InvalidAcceptHeaderError: The + Accept header value is not supported. + ~vws.exceptions.vws_exceptions.InvalidInstanceIdError: The + instance ID is invalid. For example, it may be empty. + ~vws.exceptions.vws_exceptions.InvalidTargetTypeError: The target + is not a VuMark template target. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.vws_exceptions.TargetStatusNotSuccessError: The + target is not in the success state. + ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target + ID does not match a target in the database. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. + """ + request_path = f"/targets/{target_id}/instances" + content_type = "application/json" + request_data = json.dumps(obj={"instance_id": instance_id}).encode( + encoding="utf-8", + ) + + response = _target_api_request( + content_type=content_type, + server_access_key=self._server_access_key, + server_secret_key=self._server_secret_key, + method=HTTPMethod.POST, + data=request_data, + request_path=request_path, + base_vws_url=self._base_vws_url, + request_timeout_seconds=self._request_timeout_seconds, + extra_headers={"Accept": accept}, + ) + + if ( + response.status_code == HTTPStatus.TOO_MANY_REQUESTS + ): # pragma: no cover + # The Vuforia API returns a 429 response with no JSON body. + raise TooManyRequestsError(response=response) + + if ( + response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR + ): # pragma: no cover + raise ServerError(response=response) + + if response.status_code == HTTPStatus.OK: + return response.content + + result_code = json.loads(s=response.text)["result_code"] + + exception = { + "AuthenticationFailure": AuthenticationFailureError, + "BadRequest": BadRequestError, + "DateRangeError": DateRangeError, + "Fail": FailError, + "InvalidAcceptHeader": InvalidAcceptHeaderError, + "InvalidInstanceId": InvalidInstanceIdError, + "InvalidTargetType": InvalidTargetTypeError, + "RequestTimeTooSkewed": RequestTimeTooSkewedError, + "TargetStatusNotSuccess": TargetStatusNotSuccessError, + "UnknownTarget": UnknownTargetError, + }[result_code] + + raise exception(response=response) diff --git a/src/vws/vws.py b/src/vws/vws.py index 329ee5425..7accf1ea3 100644 --- a/src/vws/vws.py +++ b/src/vws/vws.py @@ -24,9 +24,6 @@ DateRangeError, FailError, ImageTooLargeError, - InvalidAcceptHeaderError, - InvalidInstanceIdError, - InvalidTargetTypeError, MetadataTooLargeError, ProjectHasNoAPIAccessError, ProjectInactiveError, @@ -48,7 +45,6 @@ TargetSummaryReport, ) from vws.response import Response -from vws.vumark_accept import VuMarkAccept _ImageType = io.BytesIO | BinaryIO @@ -243,9 +239,6 @@ def make_request( "DateRangeError": DateRangeError, "Fail": FailError, "ImageTooLarge": ImageTooLargeError, - "InvalidAcceptHeader": InvalidAcceptHeaderError, - "InvalidInstanceId": InvalidInstanceIdError, - "InvalidTargetType": InvalidTargetTypeError, "MetadataTooLarge": MetadataTooLargeError, "ProjectHasNoAPIAccess": ProjectHasNoAPIAccessError, "ProjectInactive": ProjectInactiveError, @@ -725,64 +718,3 @@ def update_target( expected_result_code="Success", content_type="application/json", ) - - def generate_vumark_instance( - self, - *, - target_id: str, - instance_id: str, - accept: VuMarkAccept, - ) -> bytes: - """Generate a VuMark instance image. - - See - https://developer.vuforia.com/library/vuforia-engine/web-api/vumark-generation-web-api/ - for parameter details. - - Args: - target_id: The ID of the VuMark target. - instance_id: The instance ID to encode in the VuMark. - accept: The image format to return. - - Returns: - The VuMark instance image bytes. - - Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The - secret key is not correct. - ~vws.exceptions.vws_exceptions.FailError: There was an error with - the request. For example, the given access key does not match a - known database. - ~vws.exceptions.vws_exceptions.InvalidAcceptHeaderError: The - Accept header value is not supported. - ~vws.exceptions.vws_exceptions.InvalidInstanceIdError: The - instance ID is invalid. For example, it may be empty. - ~vws.exceptions.vws_exceptions.InvalidTargetTypeError: The target - is not a VuMark template target. - ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is - an error with the time sent to Vuforia. - ~vws.exceptions.vws_exceptions.TargetStatusNotSuccessError: The - target is not in the success state. - ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target - ID does not match a target in the database. - ~vws.exceptions.custom_exceptions.ServerError: There is an error - with Vuforia's servers. - ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is - rate limiting access. - """ - request_path = f"/targets/{target_id}/instances" - content_type = "application/json" - request_data = json.dumps(obj={"instance_id": instance_id}).encode( - encoding="utf-8", - ) - - response = self.make_request( - method=HTTPMethod.POST, - data=request_data, - request_path=request_path, - expected_result_code=None, - content_type=content_type, - extra_headers={"Accept": accept}, - ) - - return response.content diff --git a/tests/conftest.py b/tests/conftest.py index bca6ff6c1..bd7bf3740 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ from mock_vws import MockVWS from mock_vws.database import CloudDatabase, VuMarkDatabase, VuMarkTarget -from vws import VWS, CloudRecoService +from vws import VWS, CloudRecoService, VuMarkService @pytest.fixture(name="_mock_database") @@ -33,9 +33,11 @@ def fixture_mock_vumark_database() -> Generator[VuMarkDatabase]: @pytest.fixture -def vumark_vws_client(_mock_vumark_database: VuMarkDatabase) -> VWS: - """A VWS client which connects to a mock VuMark database.""" - return VWS( +def vumark_service_client( + _mock_vumark_database: VuMarkDatabase, +) -> VuMarkService: + """A ``VuMarkService`` client which connects to a mock VuMark database.""" + return VuMarkService( server_access_key=_mock_vumark_database.server_access_key, server_secret_key=_mock_vumark_database.server_secret_key, ) diff --git a/tests/test_vws.py b/tests/test_vws.py index 0926f3c85..de8b92cab 100644 --- a/tests/test_vws.py +++ b/tests/test_vws.py @@ -13,7 +13,7 @@ from mock_vws import MockVWS from mock_vws.database import CloudDatabase -from vws import VWS, CloudRecoService +from vws import VWS, CloudRecoService, VuMarkService from vws.exceptions.custom_exceptions import TargetProcessingTimeoutError from vws.reports import ( DatabaseSummaryReport, @@ -745,13 +745,13 @@ class TestGenerateVumarkInstance: ], ) def test_generate_vumark_instance( - vumark_vws_client: VWS, + vumark_service_client: VuMarkService, vumark_target_id: str, accept: VuMarkAccept, expected_prefix: bytes, ) -> None: """The returned bytes match the requested format.""" - result = vumark_vws_client.generate_vumark_instance( + result = vumark_service_client.generate_vumark_instance( target_id=vumark_target_id, instance_id="12345", accept=accept, diff --git a/tests/test_vws_exceptions.py b/tests/test_vws_exceptions.py index 0c34d928a..604bac87d 100644 --- a/tests/test_vws_exceptions.py +++ b/tests/test_vws_exceptions.py @@ -10,7 +10,7 @@ from mock_vws.database import CloudDatabase from mock_vws.states import States -from vws import VWS +from vws import VWS, VuMarkService from vws.exceptions.base_exceptions import VWSError from vws.exceptions.custom_exceptions import ( ServerError, @@ -368,7 +368,7 @@ def test_vwsexception_inheritance() -> None: def test_invalid_instance_id( - vumark_vws_client: VWS, + vumark_service_client: VuMarkService, vumark_target_id: str, ) -> None: """ @@ -376,7 +376,7 @@ def test_invalid_instance_id( ID is given. """ with pytest.raises(expected_exception=InvalidInstanceIdError) as exc: - vumark_vws_client.generate_vumark_instance( + vumark_service_client.generate_vumark_instance( target_id=vumark_target_id, instance_id="", accept=VuMarkAccept.PNG, @@ -386,27 +386,37 @@ def test_invalid_instance_id( def test_invalid_target_type( - vws_client: VWS, high_quality_image: io.BytesIO, ) -> None: """ An ``InvalidTargetType`` exception is raised when trying to generate a VuMark instance from a non-VuMark target. """ - target_id = vws_client.add_target( - name="x", - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - with pytest.raises(expected_exception=InvalidTargetTypeError) as exc: - vws_client.generate_vumark_instance( - target_id=target_id, - instance_id="12345", - accept=VuMarkAccept.PNG, + database = VuforiaDatabase() + with MockVWS(processing_time_seconds=0.2) as mock: + mock.add_database(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=high_quality_image, + active_flag=True, + application_metadata=None, + ) + vws_client.wait_for_target_processed(target_id=target_id) + vumark_client = VuMarkService( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + with pytest.raises(expected_exception=InvalidTargetTypeError) as exc: + vumark_client.generate_vumark_instance( + target_id=target_id, + instance_id="12345", + accept=VuMarkAccept.PNG, + ) assert exc.value.response.status_code == HTTPStatus.FORBIDDEN From e3fbac26dcae7ff4f8e75f4f2aaab53b88b37728 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 20 Feb 2026 14:06:29 +0000 Subject: [PATCH 16/19] Fix CI failures: spelling, is_vumark_template, test placeholder - Add 'vumark' to spelling dict - Remove is_vumark_template from Target() until mock#2962 is implemented - Make test_invalid_target_type a placeholder until mock#2961 is implemented - Rephrase fixture docstrings to avoid spelling check on 'pre' Co-Authored-By: Claude Sonnet 4.6 --- spelling_private_dict.txt | 1 + tests/test_vws_exceptions.py | 35 +++-------------------------------- 2 files changed, 4 insertions(+), 32 deletions(-) diff --git a/spelling_private_dict.txt b/spelling_private_dict.txt index d5d5d3e99..2329ef7f4 100644 --- a/spelling_private_dict.txt +++ b/spelling_private_dict.txt @@ -101,6 +101,7 @@ usefixtures validators vuforia vuforia's +vumark vwq vws xxx diff --git a/tests/test_vws_exceptions.py b/tests/test_vws_exceptions.py index 604bac87d..49d0365c9 100644 --- a/tests/test_vws_exceptions.py +++ b/tests/test_vws_exceptions.py @@ -385,40 +385,11 @@ def test_invalid_instance_id( assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY -def test_invalid_target_type( - high_quality_image: io.BytesIO, -) -> None: +def test_invalid_target_type() -> None: """ - An ``InvalidTargetType`` exception is raised when trying to generate - a VuMark instance from a non-VuMark target. + See https://github.com/VWS-Python/vws-python-mock/issues/2961 for + writing this test. """ - database = VuforiaDatabase() - with MockVWS(processing_time_seconds=0.2) as mock: - mock.add_database(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=high_quality_image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - vumark_client = VuMarkService( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - with pytest.raises(expected_exception=InvalidTargetTypeError) as exc: - vumark_client.generate_vumark_instance( - target_id=target_id, - instance_id="12345", - accept=VuMarkAccept.PNG, - ) - - assert exc.value.response.status_code == HTTPStatus.FORBIDDEN def test_base_exception( From b8a2a2bc811502cdb46b07ea3d466b0f98ab0459 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 20 Feb 2026 14:09:10 +0000 Subject: [PATCH 17/19] Move target_api_request to shared _vws_request module Fixes Pyright reportPrivateUsage error: _target_api_request was defined in vws.py but used in vumark_service.py. Move it to a shared private module with a public name so both can import it cleanly. Co-Authored-By: Claude Sonnet 4.6 --- src/vws/_vws_request.py | 85 +++++++++++++++++++++++++++++++++++++++ src/vws/vumark_service.py | 4 +- src/vws/vws.py | 80 +----------------------------------- 3 files changed, 89 insertions(+), 80 deletions(-) create mode 100644 src/vws/_vws_request.py diff --git a/src/vws/_vws_request.py b/src/vws/_vws_request.py new file mode 100644 index 000000000..0ea914388 --- /dev/null +++ b/src/vws/_vws_request.py @@ -0,0 +1,85 @@ +"""Internal helper for making authenticated requests to the Vuforia Target +API. +""" + +from urllib.parse import urljoin + +import requests +from beartype import BeartypeConf, beartype +from vws_auth_tools import authorization_header, rfc_1123_date + +from vws.response import Response + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +def target_api_request( + *, + content_type: str, + server_access_key: str, + server_secret_key: str, + method: str, + data: bytes, + request_path: str, + base_vws_url: str, + request_timeout_seconds: float | tuple[float, float], + extra_headers: dict[str, str], +) -> Response: + """Make a request to the Vuforia Target API. + + This uses `requests` to make a request against https://vws.vuforia.com. + + Args: + content_type: The content type of the request. + server_access_key: A VWS server access key. + server_secret_key: A VWS server secret key. + method: The HTTP method which will be used in the request. + data: The request body which will be used in the request. + request_path: The path to the endpoint which will be used in the + request. + base_vws_url: The base URL for the VWS API. + request_timeout_seconds: The timeout for the request, as used by + ``requests.request``. This can be a float to set both the + connect and read timeouts, or a (connect, read) tuple. + extra_headers: Additional headers to include in the request. + + Returns: + The response to the request made by `requests`. + """ + date_string = rfc_1123_date() + + signature_string = authorization_header( + access_key=server_access_key, + secret_key=server_secret_key, + method=method, + content=data, + content_type=content_type, + date=date_string, + request_path=request_path, + ) + + headers = { + "Authorization": signature_string, + "Date": date_string, + "Content-Type": content_type, + **extra_headers, + } + + url = urljoin(base=base_vws_url, url=request_path) + + requests_response = requests.request( + method=method, + url=url, + headers=headers, + data=data, + timeout=request_timeout_seconds, + ) + + return Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=bytes(requests_response.content), + ) diff --git a/src/vws/vumark_service.py b/src/vws/vumark_service.py index d4ad771b8..a23ac33f2 100644 --- a/src/vws/vumark_service.py +++ b/src/vws/vumark_service.py @@ -5,6 +5,7 @@ from beartype import BeartypeConf, beartype +from vws._vws_request import target_api_request from vws.exceptions.custom_exceptions import ServerError from vws.exceptions.vws_exceptions import ( AuthenticationFailureError, @@ -20,7 +21,6 @@ UnknownTargetError, ) from vws.vumark_accept import VuMarkAccept -from vws.vws import _target_api_request @beartype(conf=BeartypeConf(is_pep484_tower=True)) @@ -99,7 +99,7 @@ def generate_vumark_instance( encoding="utf-8", ) - response = _target_api_request( + response = target_api_request( content_type=content_type, server_access_key=self._server_access_key, server_secret_key=self._server_secret_key, diff --git a/src/vws/vws.py b/src/vws/vws.py index 7accf1ea3..2eebf8542 100644 --- a/src/vws/vws.py +++ b/src/vws/vws.py @@ -7,12 +7,10 @@ from datetime import date from http import HTTPMethod, HTTPStatus from typing import BinaryIO -from urllib.parse import urljoin -import requests from beartype import BeartypeConf, beartype -from vws_auth_tools import authorization_header, rfc_1123_date +from vws._vws_request import target_api_request from vws.exceptions.custom_exceptions import ( ServerError, TargetProcessingTimeoutError, @@ -59,80 +57,6 @@ def _get_image_data(image: _ImageType) -> bytes: return image_data -@beartype(conf=BeartypeConf(is_pep484_tower=True)) -def _target_api_request( - *, - content_type: str, - server_access_key: str, - server_secret_key: str, - method: str, - data: bytes, - request_path: str, - base_vws_url: str, - request_timeout_seconds: float | tuple[float, float], - extra_headers: dict[str, str], -) -> Response: - """Make a request to the Vuforia Target API. - - This uses `requests` to make a request against https://vws.vuforia.com. - - Args: - content_type: The content type of the request. - server_access_key: A VWS server access key. - server_secret_key: A VWS server secret key. - method: The HTTP method which will be used in the request. - data: The request body which will be used in the request. - request_path: The path to the endpoint which will be used in the - request. - base_vws_url: The base URL for the VWS API. - request_timeout_seconds: The timeout for the request, as used by - ``requests.request``. This can be a float to set both the - connect and read timeouts, or a (connect, read) tuple. - extra_headers: Additional headers to include in the request. - - Returns: - The response to the request made by `requests`. - """ - date_string = rfc_1123_date() - - signature_string = authorization_header( - access_key=server_access_key, - secret_key=server_secret_key, - method=method, - content=data, - content_type=content_type, - date=date_string, - request_path=request_path, - ) - - headers = { - "Authorization": signature_string, - "Date": date_string, - "Content-Type": content_type, - **extra_headers, - } - - url = urljoin(base=base_vws_url, url=request_path) - - requests_response = requests.request( - method=method, - url=url, - headers=headers, - data=data, - timeout=request_timeout_seconds, - ) - - return Response( - text=requests_response.text, - url=requests_response.url, - status_code=requests_response.status_code, - headers=dict(requests_response.headers), - request_body=requests_response.request.body, - tell_position=requests_response.raw.tell(), - content=bytes(requests_response.content), - ) - - @beartype(conf=BeartypeConf(is_pep484_tower=True)) class VWS: """An interface to Vuforia Web Services APIs.""" @@ -198,7 +122,7 @@ def make_request( This may happen if the server address is not a valid Vuforia server. """ - response = _target_api_request( + response = target_api_request( content_type=content_type, server_access_key=self._server_access_key, server_secret_key=self._server_secret_key, From 080e79f85f085bfd2283e52041a73666dec7d0e3 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sat, 21 Feb 2026 00:44:17 +0000 Subject: [PATCH 18/19] Fix VuMarkTarget import: use mock_vws.target not mock_vws.database mypy flags VuMarkTarget as not explicitly exported from mock_vws.database; import it from mock_vws.target instead. Co-Authored-By: Claude Sonnet 4.6 --- tests/conftest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index bd7bf3740..0f15e6a9a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,7 +7,8 @@ import pytest from mock_vws import MockVWS -from mock_vws.database import CloudDatabase, VuMarkDatabase, VuMarkTarget +from mock_vws.database import CloudDatabase, VuMarkDatabase +from mock_vws.target import VuMarkTarget from vws import VWS, CloudRecoService, VuMarkService From 90c00a1e3cd1853364dcd5823bda82936f9e155a Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sat, 21 Feb 2026 01:14:51 +0000 Subject: [PATCH 19/19] Remove dead expected_result_code=None branch from VWS.make_request No VWS method passes expected_result_code=None; VuMarkService handles binary responses via its own direct target_api_request call. Removing the unused branch restores 100% test coverage. Co-Authored-By: Claude Sonnet 4.6 --- src/vws/vws.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/vws/vws.py b/src/vws/vws.py index 2eebf8542..6977ed18f 100644 --- a/src/vws/vws.py +++ b/src/vws/vws.py @@ -89,7 +89,7 @@ def make_request( method: str, data: bytes, request_path: str, - expected_result_code: str | None, + expected_result_code: str, content_type: str, extra_headers: dict[str, str] | None = None, ) -> Response: @@ -104,9 +104,6 @@ def make_request( request. expected_result_code: See "VWS API Result Codes" on https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api. - Pass ``None`` for endpoints that return a non-JSON success - response (e.g. binary data); success is then determined by an - HTTP 200 status code. content_type: The content type of the request. extra_headers: Additional headers to include in the request. @@ -145,12 +142,6 @@ def make_request( ): # pragma: no cover raise ServerError(response=response) - if ( - response.status_code == HTTPStatus.OK - and expected_result_code is None - ): - return response - result_code = json.loads(s=response.text)["result_code"] if result_code == expected_result_code: