Skip to content
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 6 additions & 3 deletions src/vws/_vws_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
API.
"""

from urllib.parse import urljoin
from urllib.parse import urlparse

import requests
from beartype import BeartypeConf, beartype
Expand Down Expand Up @@ -47,14 +47,17 @@ 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,
method=method,
content=data,
content_type=content_type,
date=date_string,
request_path=request_path,
request_path=full_request_path,
)

headers = {
Expand All @@ -64,7 +67,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,
Expand Down
9 changes: 6 additions & 3 deletions src/vws/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import json
from http import HTTPMethod, HTTPStatus
from typing import Any, BinaryIO
from urllib.parse import urljoin
from urllib.parse import urlparse

import requests
from beartype import BeartypeConf, beartype
Expand Down Expand Up @@ -127,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,
Expand All @@ -135,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 = {
Expand All @@ -146,7 +149,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,
Expand Down
33 changes: 33 additions & 0 deletions tests/test_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,39 @@ def test_custom_base_url(image: io.BytesIO | BinaryIO) -> None:
match = matches[0]
assert match.target_id == target_id

@staticmethod
def test_custom_base_url_with_path_prefix(
image: io.BytesIO | BinaryIO,
) -> None:
"""
A base VWQ URL with a path prefix is used as-is, without the
prefix being dropped.
"""
base_vwq_url = "http://example.com/prefix"
with MockVWS(base_vwq_url=base_vwq_url) as mock:
database = CloudDatabase()
mock.add_cloud_database(cloud_database=database)
vws_client = VWS(
server_access_key=database.server_access_key,
server_secret_key=database.server_secret_key,
)
target_id = vws_client.add_target(
name="x",
width=1,
image=image,
active_flag=True,
application_metadata=None,
)
vws_client.wait_for_target_processed(target_id=target_id)
cloud_reco_client = CloudRecoService(
client_access_key=database.client_access_key,
client_secret_key=database.client_secret_key,
base_vwq_url=base_vwq_url,
)

matches = cloud_reco_client.query(image=image)
assert len(matches) == 1


class TestMaxNumResults:
"""Tests for the ``max_num_results`` parameter of ``query``."""
Expand Down
28 changes: 28 additions & 0 deletions tests/test_vws.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for helper functions for managing a Vuforia database."""

import base64
import contextlib
import datetime
import io
import secrets
Expand All @@ -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,
Expand Down Expand Up @@ -246,6 +248,32 @@ def test_custom_base_url(image: io.BytesIO | BinaryIO) -> None:
application_metadata=None,
)

@staticmethod
def test_custom_base_url_with_path_prefix() -> None:
"""
A base VWS URL with a path prefix is used as-is, without the
prefix being dropped.
"""
base_vws_url = "http://example.com/prefix"
with MockVWS(base_vws_url=base_vws_url) as mock:
database = CloudDatabase()
mock.add_cloud_database(cloud_database=database)
vws_client = VWS(
server_access_key=database.server_access_key,
server_secret_key=database.server_secret_key,
base_vws_url=base_vws_url,
)

# 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:
"""Tests for listing targets."""
Expand Down
Loading