From 294661069a3b72f5b6d55baf987f9b5872db3d35 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 22 Feb 2026 03:27:13 +0000 Subject: [PATCH 1/8] Replace urljoin with string concatenation for base URL construction urljoin(base, path) silently drops any path prefix from the base URL when path starts with '/', so base_vws_url values like 'http://localhost/mock' would have '/mock' ignored. String concatenation preserves the full base URL path as intended. Co-Authored-By: Claude Sonnet 4.6 --- src/vws/_vws_request.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/vws/_vws_request.py b/src/vws/_vws_request.py index 0ea91438..2fc7bd3b 100644 --- a/src/vws/_vws_request.py +++ b/src/vws/_vws_request.py @@ -2,8 +2,6 @@ API. """ -from urllib.parse import urljoin - import requests from beartype import BeartypeConf, beartype from vws_auth_tools import authorization_header, rfc_1123_date @@ -64,7 +62,7 @@ def target_api_request( **extra_headers, } - url = urljoin(base=base_vws_url, url=request_path) + url = base_vws_url.rstrip("/") + request_path requests_response = requests.request( method=method, From 365c501d0305aff903afbba474ab7c4c8b588914 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 22 Feb 2026 09:42:25 +0000 Subject: [PATCH 2/8] Fix urljoin in query.py and add path-prefix base URL tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Apply the same urljoin → string concatenation fix to query.py (CloudRecoService had the identical bug) - Add TestCustomBaseVWSURL::test_custom_base_url_with_path_prefix and TestCustomBaseVWQURL::test_custom_base_url_with_path_prefix to assert that a base URL containing a path prefix is preserved in the outgoing request URL; these tests would fail with the old urljoin approach Co-Authored-By: Claude Sonnet 4.6 --- src/vws/query.py | 3 +-- tests/test_query.py | 36 ++++++++++++++++++++++++++++++++++++ tests/test_vws.py | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/vws/query.py b/src/vws/query.py index 9c997177..f5046bd1 100644 --- a/src/vws/query.py +++ b/src/vws/query.py @@ -5,7 +5,6 @@ import json from http import HTTPMethod, HTTPStatus from typing import Any, BinaryIO -from urllib.parse import urljoin import requests from beartype import BeartypeConf, beartype @@ -146,7 +145,7 @@ def query( requests_response = requests.request( method=method, - url=urljoin(base=self._base_vwq_url, url=request_path), + url=self._base_vwq_url.rstrip("/") + request_path, headers=headers, data=content, timeout=self._request_timeout_seconds, diff --git a/tests/test_query.py b/tests/test_query.py index b7f9687d..d296f47b 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -2,11 +2,13 @@ import datetime import io +import secrets import uuid from typing import BinaryIO import pytest import requests +import responses as responses_mock from freezegun import freeze_time from mock_vws import MockVWS from mock_vws.database import CloudDatabase @@ -190,6 +192,40 @@ def test_custom_base_url(image: io.BytesIO | BinaryIO) -> None: match = matches[0] assert match.target_id == target_id + @staticmethod + @responses_mock.activate + def test_custom_base_url_with_path_prefix( + image: io.BytesIO | BinaryIO, + ) -> None: + """ + A base VWQ URL with a path prefix is used as-is, without the + prefix + being dropped. + """ + base_vwq_url = "http://example.com/prefix" + responses_mock.add( + method=responses_mock.POST, + url="http://example.com/prefix/v1/query", + json={ + "result_code": "Success", + "results": [], + "query_id": "abc", + }, + status=200, + ) + cloud_reco_client = CloudRecoService( + client_access_key=secrets.token_hex(), + client_secret_key=secrets.token_hex(), + base_vwq_url=base_vwq_url, + ) + + cloud_reco_client.query(image=image) + + assert len(responses_mock.calls) == 1 + assert responses_mock.calls[0].request.url == ( + "http://example.com/prefix/v1/query" + ) + class TestMaxNumResults: """Tests for the ``max_num_results`` parameter of ``query``.""" diff --git a/tests/test_vws.py b/tests/test_vws.py index 0b435503..cc6038eb 100644 --- a/tests/test_vws.py +++ b/tests/test_vws.py @@ -9,6 +9,7 @@ import pytest import requests +import responses as responses_mock from freezegun import freeze_time from mock_vws import MockVWS from mock_vws.database import CloudDatabase @@ -246,6 +247,38 @@ def test_custom_base_url(image: io.BytesIO | BinaryIO) -> None: application_metadata=None, ) + @staticmethod + @responses_mock.activate + def test_custom_base_url_with_path_prefix() -> None: + """ + A base VWS URL with a path prefix is used as-is, without the + prefix + being dropped. + """ + base_vws_url = "http://example.com/prefix" + responses_mock.add( + method=responses_mock.GET, + url="http://example.com/prefix/targets", + json={ + "result_code": "Success", + "results": [], + "transaction_id": "abc", + }, + status=200, + ) + vws_client = VWS( + server_access_key=secrets.token_hex(), + server_secret_key=secrets.token_hex(), + base_vws_url=base_vws_url, + ) + + vws_client.list_targets() + + assert len(responses_mock.calls) == 1 + assert responses_mock.calls[0].request.url == ( + "http://example.com/prefix/targets" + ) + class TestListTargets: """Tests for listing targets.""" From b4ecf17b5884aedf16394d16cdb7cfd43df2786f Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 22 Feb 2026 09:50:33 +0000 Subject: [PATCH 3/8] Simplify path-prefix tests: use MockVWS + ConnectionError Instead of using responses directly, set up MockVWS at the bare URL and assert ConnectionError when the client uses a path-prefix URL. With the old urljoin code the prefix would be silently dropped so the request would succeed; with string concatenation the prefix is preserved, the mock has no handler for it, and ConnectionError is raised as expected. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_query.py | 38 +++++++++++--------------------------- tests/test_vws.py | 37 +++++++++++-------------------------- 2 files changed, 22 insertions(+), 53 deletions(-) diff --git a/tests/test_query.py b/tests/test_query.py index d296f47b..4ca315ac 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -2,13 +2,11 @@ import datetime import io -import secrets import uuid from typing import BinaryIO import pytest import requests -import responses as responses_mock from freezegun import freeze_time from mock_vws import MockVWS from mock_vws.database import CloudDatabase @@ -193,38 +191,24 @@ def test_custom_base_url(image: io.BytesIO | BinaryIO) -> None: assert match.target_id == target_id @staticmethod - @responses_mock.activate def test_custom_base_url_with_path_prefix( image: io.BytesIO | BinaryIO, ) -> None: """ A base VWQ URL with a path prefix is used as-is, without the - prefix - being dropped. + prefix being dropped. """ - base_vwq_url = "http://example.com/prefix" - responses_mock.add( - method=responses_mock.POST, - url="http://example.com/prefix/v1/query", - json={ - "result_code": "Success", - "results": [], - "query_id": "abc", - }, - status=200, - ) - cloud_reco_client = CloudRecoService( - client_access_key=secrets.token_hex(), - client_secret_key=secrets.token_hex(), - base_vwq_url=base_vwq_url, - ) - - cloud_reco_client.query(image=image) + with MockVWS(base_vwq_url="http://example.com") as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + cloud_reco_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + base_vwq_url="http://example.com/prefix", + ) - assert len(responses_mock.calls) == 1 - assert responses_mock.calls[0].request.url == ( - "http://example.com/prefix/v1/query" - ) + with pytest.raises(requests.exceptions.ConnectionError): + cloud_reco_client.query(image=image) class TestMaxNumResults: diff --git a/tests/test_vws.py b/tests/test_vws.py index cc6038eb..e2d43ecd 100644 --- a/tests/test_vws.py +++ b/tests/test_vws.py @@ -9,7 +9,6 @@ import pytest import requests -import responses as responses_mock from freezegun import freeze_time from mock_vws import MockVWS from mock_vws.database import CloudDatabase @@ -248,36 +247,22 @@ def test_custom_base_url(image: io.BytesIO | BinaryIO) -> None: ) @staticmethod - @responses_mock.activate def test_custom_base_url_with_path_prefix() -> None: """ A base VWS URL with a path prefix is used as-is, without the - prefix - being dropped. + prefix being dropped. """ - base_vws_url = "http://example.com/prefix" - responses_mock.add( - method=responses_mock.GET, - url="http://example.com/prefix/targets", - json={ - "result_code": "Success", - "results": [], - "transaction_id": "abc", - }, - status=200, - ) - vws_client = VWS( - server_access_key=secrets.token_hex(), - server_secret_key=secrets.token_hex(), - base_vws_url=base_vws_url, - ) - - vws_client.list_targets() + with MockVWS(base_vws_url="http://example.com") as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + base_vws_url="http://example.com/prefix", + ) - assert len(responses_mock.calls) == 1 - assert responses_mock.calls[0].request.url == ( - "http://example.com/prefix/targets" - ) + with pytest.raises(requests.exceptions.ConnectionError): + vws_client.list_targets() class TestListTargets: From da7546394b4f398b4081fca04b812470354df0cb Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 22 Feb 2026 09:51:43 +0000 Subject: [PATCH 4/8] Use keyword argument for pytest.raises Co-Authored-By: Claude Sonnet 4.6 --- tests/test_query.py | 4 +++- tests/test_vws.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_query.py b/tests/test_query.py index 4ca315ac..3b9546cc 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -207,7 +207,9 @@ def test_custom_base_url_with_path_prefix( base_vwq_url="http://example.com/prefix", ) - with pytest.raises(requests.exceptions.ConnectionError): + with pytest.raises( + expected_exception=requests.exceptions.ConnectionError, + ): cloud_reco_client.query(image=image) diff --git a/tests/test_vws.py b/tests/test_vws.py index e2d43ecd..5f3e7d27 100644 --- a/tests/test_vws.py +++ b/tests/test_vws.py @@ -261,7 +261,9 @@ def test_custom_base_url_with_path_prefix() -> None: base_vws_url="http://example.com/prefix", ) - with pytest.raises(requests.exceptions.ConnectionError): + with pytest.raises( + expected_exception=requests.exceptions.ConnectionError, + ): vws_client.list_targets() From 115bd8dbd071806f6ace4bceda92b187a2352317 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 22 Feb 2026 12:06:35 +0000 Subject: [PATCH 5/8] Bump vws-python-mock to 2026.2.22.1 The new release fixes the urljoin bug in MockVWS so it correctly registers handlers at path-prefixed base URLs. The path-prefix tests stay as ConnectionError tests: MockVWS listens at the bare URL while the client uses the prefixed URL, so the prefix being preserved causes a ConnectionError -- the inverse of the urljoin behaviour where the prefix would be silently dropped and the mock would respond. (A fully positive round-trip test is not yet possible because the HMAC signing also needs to incorporate the base URL path prefix, which is a separate issue.) Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8a13280e..18c09b91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ optional-dependencies.dev = [ "ty==0.0.17", "types-requests==2.32.4.20260107", "vulture==2.14", - "vws-python-mock==2026.2.21", + "vws-python-mock==2026.2.22.1", "vws-test-fixtures==2023.3.5", "yamlfix==1.19.1", "zizmor==1.22.0", From ec3c9683490d07face823c795c52fc38e94de810 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 22 Feb 2026 12:52:24 +0000 Subject: [PATCH 6/8] Add positive path-prefix tests and fix auth signing for prefixed base URLs - Replace urljoin with string concatenation in both _vws_request.py and query.py so a base URL path prefix is preserved when constructing request URLs. - Fix HMAC signing to include the base URL path component so MockVWS auth validation succeeds with prefixed base URLs. - Add test_custom_base_url_with_path_prefix to TestCustomBaseVWQURL: full end-to-end test that adds a target and queries it through a prefixed VWQ base URL. - Add test_custom_base_url_with_path_prefix to TestCustomBaseVWSURL: verifies the prefixed URL reaches MockVWS; suppresses UnknownTargetError pending https://github.com/VWS-Python/vws-python-mock/issues/2995. Co-Authored-By: Claude Sonnet 4.6 --- src/vws/_vws_request.py | 7 ++++++- src/vws/query.py | 6 +++++- tests/test_query.py | 23 +++++++++++++++++------ tests/test_vws.py | 20 ++++++++++++++------ 4 files changed, 42 insertions(+), 14 deletions(-) diff --git a/src/vws/_vws_request.py b/src/vws/_vws_request.py index 2fc7bd3b..5d2f4ea6 100644 --- a/src/vws/_vws_request.py +++ b/src/vws/_vws_request.py @@ -2,6 +2,8 @@ API. """ +from urllib.parse import urlparse + import requests from beartype import BeartypeConf, beartype from vws_auth_tools import authorization_header, rfc_1123_date @@ -45,6 +47,9 @@ def target_api_request( """ date_string = rfc_1123_date() + base_path = urlparse(url=base_vws_url).path.rstrip("/") + full_request_path = base_path + request_path + signature_string = authorization_header( access_key=server_access_key, secret_key=server_secret_key, @@ -52,7 +57,7 @@ def target_api_request( content=data, content_type=content_type, date=date_string, - request_path=request_path, + request_path=full_request_path, ) headers = { diff --git a/src/vws/query.py b/src/vws/query.py index f5046bd1..38775c56 100644 --- a/src/vws/query.py +++ b/src/vws/query.py @@ -5,6 +5,7 @@ import json from http import HTTPMethod, HTTPStatus from typing import Any, BinaryIO +from urllib.parse import urlparse import requests from beartype import BeartypeConf, beartype @@ -126,6 +127,9 @@ def query( content, content_type_header = encode_multipart_formdata(fields=body) method = HTTPMethod.POST + base_path = urlparse(url=self._base_vwq_url).path.rstrip("/") + full_request_path = base_path + request_path + authorization_string = authorization_header( access_key=self._client_access_key, secret_key=self._client_secret_key, @@ -134,7 +138,7 @@ def query( # Note that this is not the actual Content-Type header value sent. content_type="multipart/form-data", date=date, - request_path=request_path, + request_path=full_request_path, ) headers = { diff --git a/tests/test_query.py b/tests/test_query.py index 3b9546cc..66d59611 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -198,19 +198,30 @@ def test_custom_base_url_with_path_prefix( A base VWQ URL with a path prefix is used as-is, without the prefix being dropped. """ - with MockVWS(base_vwq_url="http://example.com") as mock: + base_vwq_url = "http://example.com/prefix" + with MockVWS(base_vwq_url=base_vwq_url) as mock: database = CloudDatabase() mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + target_id = vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + vws_client.wait_for_target_processed(target_id=target_id) cloud_reco_client = CloudRecoService( client_access_key=database.client_access_key, client_secret_key=database.client_secret_key, - base_vwq_url="http://example.com/prefix", + base_vwq_url=base_vwq_url, ) - with pytest.raises( - expected_exception=requests.exceptions.ConnectionError, - ): - cloud_reco_client.query(image=image) + matches = cloud_reco_client.query(image=image) + assert len(matches) == 1 class TestMaxNumResults: diff --git a/tests/test_vws.py b/tests/test_vws.py index 5f3e7d27..0960a5d7 100644 --- a/tests/test_vws.py +++ b/tests/test_vws.py @@ -1,6 +1,7 @@ """Tests for helper functions for managing a Vuforia database.""" import base64 +import contextlib import datetime import io import secrets @@ -15,6 +16,7 @@ from vws import VWS, CloudRecoService, VuMarkService from vws.exceptions.custom_exceptions import TargetProcessingTimeoutError +from vws.exceptions.vws_exceptions import UnknownTargetError from vws.reports import ( DatabaseSummaryReport, TargetRecord, @@ -252,19 +254,25 @@ def test_custom_base_url_with_path_prefix() -> None: A base VWS URL with a path prefix is used as-is, without the prefix being dropped. """ - with MockVWS(base_vws_url="http://example.com") as mock: + base_vws_url = "http://example.com/prefix" + with MockVWS(base_vws_url=base_vws_url) as mock: database = CloudDatabase() mock.add_cloud_database(cloud_database=database) vws_client = VWS( server_access_key=database.server_access_key, server_secret_key=database.server_secret_key, - base_vws_url="http://example.com/prefix", + base_vws_url=base_vws_url, ) - with pytest.raises( - expected_exception=requests.exceptions.ConnectionError, - ): - vws_client.list_targets() + # MockVWS's path-length check in validate_target_id_exists + # does not account for a base URL path prefix, so it + # incorrectly treats the last path segment ("targets") as a + # target ID and raises UnknownTargetError. + # See https://github.com/VWS-Python/vws-python-mock/issues/2995 + # The request did reach MockVWS (proving the prefix was + # preserved in the URL), so this exception is expected for now. + with contextlib.suppress(UnknownTargetError): + assert vws_client.list_targets() == [] class TestListTargets: From 165903879c68741e0c4bfb279f6b86fe0033dd0d Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 22 Feb 2026 20:26:46 +0000 Subject: [PATCH 7/8] Bump vws-python-mock to 2026.2.22.2 and clean up path-prefix handling Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 2 +- src/vws/_vws_request.py | 7 +------ src/vws/query.py | 6 +----- tests/test_vws.py | 12 +----------- 4 files changed, 4 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 18c09b91..f1baeabe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ optional-dependencies.dev = [ "ty==0.0.17", "types-requests==2.32.4.20260107", "vulture==2.14", - "vws-python-mock==2026.2.22.1", + "vws-python-mock==2026.2.22.2", "vws-test-fixtures==2023.3.5", "yamlfix==1.19.1", "zizmor==1.22.0", diff --git a/src/vws/_vws_request.py b/src/vws/_vws_request.py index 5d2f4ea6..2fc7bd3b 100644 --- a/src/vws/_vws_request.py +++ b/src/vws/_vws_request.py @@ -2,8 +2,6 @@ API. """ -from urllib.parse import urlparse - import requests from beartype import BeartypeConf, beartype from vws_auth_tools import authorization_header, rfc_1123_date @@ -47,9 +45,6 @@ def target_api_request( """ date_string = rfc_1123_date() - base_path = urlparse(url=base_vws_url).path.rstrip("/") - full_request_path = base_path + request_path - signature_string = authorization_header( access_key=server_access_key, secret_key=server_secret_key, @@ -57,7 +52,7 @@ def target_api_request( content=data, content_type=content_type, date=date_string, - request_path=full_request_path, + request_path=request_path, ) headers = { diff --git a/src/vws/query.py b/src/vws/query.py index 38775c56..f5046bd1 100644 --- a/src/vws/query.py +++ b/src/vws/query.py @@ -5,7 +5,6 @@ import json from http import HTTPMethod, HTTPStatus from typing import Any, BinaryIO -from urllib.parse import urlparse import requests from beartype import BeartypeConf, beartype @@ -127,9 +126,6 @@ def query( content, content_type_header = encode_multipart_formdata(fields=body) method = HTTPMethod.POST - base_path = urlparse(url=self._base_vwq_url).path.rstrip("/") - full_request_path = base_path + request_path - authorization_string = authorization_header( access_key=self._client_access_key, secret_key=self._client_secret_key, @@ -138,7 +134,7 @@ def query( # Note that this is not the actual Content-Type header value sent. content_type="multipart/form-data", date=date, - request_path=full_request_path, + request_path=request_path, ) headers = { diff --git a/tests/test_vws.py b/tests/test_vws.py index 0960a5d7..b98a7332 100644 --- a/tests/test_vws.py +++ b/tests/test_vws.py @@ -1,7 +1,6 @@ """Tests for helper functions for managing a Vuforia database.""" import base64 -import contextlib import datetime import io import secrets @@ -16,7 +15,6 @@ from vws import VWS, CloudRecoService, VuMarkService from vws.exceptions.custom_exceptions import TargetProcessingTimeoutError -from vws.exceptions.vws_exceptions import UnknownTargetError from vws.reports import ( DatabaseSummaryReport, TargetRecord, @@ -264,15 +262,7 @@ def test_custom_base_url_with_path_prefix() -> None: base_vws_url=base_vws_url, ) - # MockVWS's path-length check in validate_target_id_exists - # does not account for a base URL path prefix, so it - # incorrectly treats the last path segment ("targets") as a - # target ID and raises UnknownTargetError. - # See https://github.com/VWS-Python/vws-python-mock/issues/2995 - # The request did reach MockVWS (proving the prefix was - # preserved in the URL), so this exception is expected for now. - with contextlib.suppress(UnknownTargetError): - assert vws_client.list_targets() == [] + assert vws_client.list_targets() == [] class TestListTargets: From 13a0047ed0a2562e0400b4347403b3c98bef1f4f Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 22 Feb 2026 20:32:49 +0000 Subject: [PATCH 8/8] Use implicit booleaness check in path-prefix test Co-Authored-By: Claude Sonnet 4.6 --- tests/test_vws.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_vws.py b/tests/test_vws.py index b98a7332..29bd3aa3 100644 --- a/tests/test_vws.py +++ b/tests/test_vws.py @@ -262,7 +262,7 @@ def test_custom_base_url_with_path_prefix() -> None: base_vws_url=base_vws_url, ) - assert vws_client.list_targets() == [] + assert not vws_client.list_targets() class TestListTargets: