From eeb3355c6eec84a545b9d9c0cdb38370e3648004 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Thu, 19 Feb 2026 10:32:05 +0000 Subject: [PATCH 1/8] Add database type and target type support with VuMark validation (#2962) Add DatabaseType enum (CLOUD_RECO, VUMARK) to distinguish database types and TargetType enum (IMAGE, VUMARK_TEMPLATE) for target classification. Implement InvalidTargetTypeError in VuMark generation endpoints to validate that VuMark instance generation only works on VUMARK-type databases. Update database and target serialization to include type information, and allow pre-population of VuMark targets in VuMark-type databases. Co-Authored-By: Claude Haiku 4.5 --- src/mock_vws/_constants.py | 1 + src/mock_vws/_flask_server/target_manager.py | 12 ++++ src/mock_vws/_flask_server/vws.py | 13 ++++ .../mock_web_services_api.py | 12 ++++ .../_services_validators/exceptions.py | 40 ++++++++++++ src/mock_vws/database.py | 5 ++ src/mock_vws/database_type.py | 14 +++++ src/mock_vws/target.py | 6 ++ src/mock_vws/target_type.py | 14 +++++ tests/mock_vws/fixtures/vuforia_backends.py | 2 + tests/mock_vws/test_vumark_generation_api.py | 62 ++++++++++++++++--- 11 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 src/mock_vws/database_type.py create mode 100644 src/mock_vws/target_type.py diff --git a/src/mock_vws/_constants.py b/src/mock_vws/_constants.py index 52c88bb9b..c6077c62a 100644 --- a/src/mock_vws/_constants.py +++ b/src/mock_vws/_constants.py @@ -63,6 +63,7 @@ class ResultCodes(Enum): INVALID_ACCEPT_HEADER = "InvalidAcceptHeader" INVALID_INSTANCE_ID = "InvalidInstanceId" BAD_REQUEST = "BadRequest" + INVALID_TARGET_TYPE = "InvalidTargetType" @beartype diff --git a/src/mock_vws/_flask_server/target_manager.py b/src/mock_vws/_flask_server/target_manager.py index 06948fd5c..b61154b68 100644 --- a/src/mock_vws/_flask_server/target_manager.py +++ b/src/mock_vws/_flask_server/target_manager.py @@ -13,8 +13,10 @@ from pydantic_settings import BaseSettings from mock_vws.database import CloudDatabase +from mock_vws.database_type import DatabaseType from mock_vws.states import States from mock_vws.target import ImageTarget +from mock_vws.target_type import TargetType from mock_vws.target_manager import TargetManager from mock_vws.target_raters import ( BrisqueTargetTrackingRater, @@ -159,8 +161,13 @@ def create_database() -> Response: "state_name", random_database.state.name, ) + database_type_name = request_json.get( + "database_type_name", + random_database.database_type.name, + ) state = States[state_name] + database_type = DatabaseType[database_type_name] database = CloudDatabase( server_access_key=server_access_key, @@ -169,6 +176,7 @@ def create_database() -> Response: client_secret_key=client_secret_key, database_name=database_name, state=state, + database_type=database_type, ) try: TARGET_MANAGER.add_database(database=database) @@ -202,6 +210,9 @@ def create_target(database_name: str) -> Response: settings = TargetManagerSettings.model_validate(obj={}) target_tracking_rater = settings.target_rater.to_target_rater() + target_type = TargetType[ + request_json.get("target_type_name", TargetType.IMAGE.name) + ] target = ImageTarget( name=request_json["name"], width=request_json["width"], @@ -211,6 +222,7 @@ def create_target(database_name: str) -> Response: application_metadata=request_json["application_metadata"], target_id=request_json["target_id"], target_tracking_rater=target_tracking_rater, + target_type=target_type, ) database.targets.add(target) diff --git a/src/mock_vws/_flask_server/vws.py b/src/mock_vws/_flask_server/vws.py index a6e516842..7049c3c21 100644 --- a/src/mock_vws/_flask_server/vws.py +++ b/src/mock_vws/_flask_server/vws.py @@ -32,11 +32,13 @@ FailError, InvalidAcceptHeaderError, InvalidInstanceIdError, + InvalidTargetTypeError, TargetStatusNotSuccessError, TargetStatusProcessingError, ValidatorError, ) from mock_vws.database import CloudDatabase +from mock_vws.database_type import DatabaseType from mock_vws.image_matchers import ( ExactMatcher, ImageMatcher, @@ -360,6 +362,17 @@ def generate_vumark_instance(target_id: str) -> Response: # ``target_id`` is validated by request validators. del target_id + databases = get_all_databases() + database = get_database_matching_server_keys( + request_headers=dict(request.headers), + request_body=request.data, + request_method=request.method, + request_path=request.path, + databases=databases, + ) + if database.database_type != DatabaseType.VUMARK: + raise InvalidTargetTypeError + accept = request.headers.get(key="Accept", default="") valid_accept_types: dict[str, bytes] = { "image/png": VUMARK_PNG, diff --git a/src/mock_vws/_requests_mock_server/mock_web_services_api.py b/src/mock_vws/_requests_mock_server/mock_web_services_api.py index 8c0c770d6..b66378e91 100644 --- a/src/mock_vws/_requests_mock_server/mock_web_services_api.py +++ b/src/mock_vws/_requests_mock_server/mock_web_services_api.py @@ -32,10 +32,12 @@ FailError, InvalidAcceptHeaderError, InvalidInstanceIdError, + InvalidTargetTypeError, TargetStatusNotSuccessError, TargetStatusProcessingError, ValidatorError, ) +from mock_vws.database_type import DatabaseType from mock_vws.image_matchers import ImageMatcher from mock_vws.target import ImageTarget from mock_vws.target_manager import TargetManager @@ -317,6 +319,16 @@ def generate_vumark_instance( databases=self._target_manager.databases, ) + database = get_database_matching_server_keys( + request_headers=request.headers, + request_body=_body_bytes(request=request), + request_method=request.method or "", + request_path=request.path_url, + databases=self._target_manager.databases, + ) + if database.database_type != DatabaseType.VUMARK: + raise InvalidTargetTypeError + accept = dict(request.headers).get("Accept", "") if accept not in valid_accept_types: raise InvalidAcceptHeaderError diff --git a/src/mock_vws/_services_validators/exceptions.py b/src/mock_vws/_services_validators/exceptions.py index a722f29ab..e09a2785a 100644 --- a/src/mock_vws/_services_validators/exceptions.py +++ b/src/mock_vws/_services_validators/exceptions.py @@ -646,6 +646,46 @@ def __init__(self) -> None: } +@beartype +class InvalidTargetTypeError(ValidatorError): + """Exception raised when the target type is not valid for the + operation. + """ + + def __init__(self) -> None: + """ + Attributes: + status_code: The status code to use in a response if this is + raised. + response_text: The response text to use in a response if this + is + raised. + """ + super().__init__() + self.status_code = HTTPStatus.FORBIDDEN + body = { + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.INVALID_TARGET_TYPE.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + + @beartype class TargetStatusProcessingError(ValidatorError): """Exception raised when trying to delete a target which is processing.""" diff --git a/src/mock_vws/database.py b/src/mock_vws/database.py index 0fb6876bc..9aa3cf585 100644 --- a/src/mock_vws/database.py +++ b/src/mock_vws/database.py @@ -8,6 +8,7 @@ from beartype import beartype from mock_vws._constants import TargetStatuses +from mock_vws.database_type import DatabaseType from mock_vws.states import States from mock_vws.target import ImageTarget, ImageTargetDict @@ -22,6 +23,7 @@ class CloudDatabaseDict(TypedDict): client_access_key: str client_secret_key: str state_name: str + database_type_name: str targets: Iterable[ImageTargetDict] @@ -66,6 +68,7 @@ class CloudDatabase: hash=False, ) state: States = States.WORKING + database_type: DatabaseType = DatabaseType.CLOUD_RECO request_quota: int = 100000 reco_threshold: int = 1000 @@ -84,6 +87,7 @@ def to_dict(self) -> CloudDatabaseDict: "client_access_key": self.client_access_key, "client_secret_key": self.client_secret_key, "state_name": self.state.name, + "database_type_name": self.database_type.name, "targets": targets, } @@ -104,6 +108,7 @@ def from_dict(cls, database_dict: CloudDatabaseDict) -> Self: client_access_key=database_dict["client_access_key"], client_secret_key=database_dict["client_secret_key"], state=States[database_dict["state_name"]], + database_type=DatabaseType[database_dict["database_type_name"]], targets={ ImageTarget.from_dict(target_dict=target_dict) for target_dict in database_dict["targets"] diff --git a/src/mock_vws/database_type.py b/src/mock_vws/database_type.py new file mode 100644 index 000000000..a491eb62f --- /dev/null +++ b/src/mock_vws/database_type.py @@ -0,0 +1,14 @@ +"""Vuforia database types.""" + +from enum import StrEnum, auto, unique + +from beartype import beartype + + +@beartype +@unique +class DatabaseType(StrEnum): + """Constants representing various database types.""" + + CLOUD_RECO = auto() + VUMARK = auto() diff --git a/src/mock_vws/target.py b/src/mock_vws/target.py index a17e8c140..1a4ee7473 100644 --- a/src/mock_vws/target.py +++ b/src/mock_vws/target.py @@ -17,6 +17,7 @@ HardcodedTargetTrackingRater, TargetTrackingRater, ) +from mock_vws.target_type import TargetType class ImageTargetDict(TypedDict): @@ -33,6 +34,7 @@ class ImageTargetDict(TypedDict): delete_date_optional: str | None upload_date: str tracking_rating: int + target_type_name: str @beartype @@ -69,6 +71,7 @@ class ImageTarget: previous_month_recos: int = 0 reco_rating: str = "" target_id: str = field(default_factory=_random_hex) + target_type: TargetType = TargetType.IMAGE total_recos: int = 0 upload_date: datetime.datetime = field(default_factory=_time_now) @@ -173,6 +176,7 @@ def from_dict(cls, target_dict: ImageTargetDict) -> Self: target_tracking_rater = HardcodedTargetTrackingRater( rating=target_dict["tracking_rating"], ) + target_type = TargetType[target_dict["target_type_name"]] return cls( target_id=target_id, name=name, @@ -185,6 +189,7 @@ def from_dict(cls, target_dict: ImageTargetDict) -> Self: last_modified_date=last_modified_date, upload_date=upload_date, target_tracking_rater=target_tracking_rater, + target_type=target_type, ) def to_dict(self) -> ImageTargetDict: @@ -207,4 +212,5 @@ def to_dict(self) -> ImageTargetDict: "delete_date_optional": delete_date, "upload_date": self.upload_date.isoformat(), "tracking_rating": self.tracking_rating, + "target_type_name": self.target_type.name, } diff --git a/src/mock_vws/target_type.py b/src/mock_vws/target_type.py new file mode 100644 index 000000000..eb65d964a --- /dev/null +++ b/src/mock_vws/target_type.py @@ -0,0 +1,14 @@ +"""Vuforia target types.""" + +from enum import StrEnum, auto, unique + +from beartype import beartype + + +@beartype +@unique +class TargetType(StrEnum): + """Constants representing various target types.""" + + IMAGE = auto() + VUMARK_TEMPLATE = auto() diff --git a/tests/mock_vws/fixtures/vuforia_backends.py b/tests/mock_vws/fixtures/vuforia_backends.py index 694af4b67..49a22bb96 100644 --- a/tests/mock_vws/fixtures/vuforia_backends.py +++ b/tests/mock_vws/fixtures/vuforia_backends.py @@ -20,6 +20,7 @@ from mock_vws._flask_server.vwq import CLOUDRECO_FLASK_APP from mock_vws._flask_server.vws import VWS_FLASK_APP from mock_vws.database import CloudDatabase +from mock_vws.database_type import DatabaseType from mock_vws.states import States from mock_vws.target import ImageTarget from mock_vws.target_raters import HardcodedTargetTrackingRater @@ -87,6 +88,7 @@ def _vumark_database( server_access_key=vumark_vuforia_database.server_access_key, server_secret_key=vumark_vuforia_database.server_secret_key, targets={vumark_target}, + database_type=DatabaseType.VUMARK, ) diff --git a/tests/mock_vws/test_vumark_generation_api.py b/tests/mock_vws/test_vumark_generation_api.py index 219c27440..9e3e36aa2 100644 --- a/tests/mock_vws/test_vumark_generation_api.py +++ b/tests/mock_vws/test_vumark_generation_api.py @@ -6,10 +6,13 @@ import pytest import requests +from vws import VWS from vws_auth_tools import authorization_header, rfc_1123_date from mock_vws._constants import ResultCodes +from mock_vws.database import CloudDatabase from tests.mock_vws.fixtures.credentials import VuMarkCloudDatabase +from tests.mock_vws.utils import make_image_file _VWS_HOST = "https://vws.vuforia.com" _PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" @@ -19,22 +22,24 @@ def _make_vumark_request( *, - vumark_vuforia_database: VuMarkCloudDatabase, + server_access_key: str, + server_secret_key: str, + target_id: str, instance_id: str, accept: str, ) -> requests.Response: """Send a VuMark instance generation request and return the response. """ - request_path = f"/targets/{vumark_vuforia_database.target_id}/instances" + request_path = f"/targets/{target_id}/instances" content_type = "application/json" content = json.dumps(obj={"instance_id": instance_id}).encode( encoding="utf-8" ) date = rfc_1123_date() authorization_string = authorization_header( - access_key=vumark_vuforia_database.server_access_key, - secret_key=vumark_vuforia_database.server_secret_key, + access_key=server_access_key, + secret_key=server_secret_key, method=HTTPMethod.POST, content=content, content_type=content_type, @@ -87,7 +92,9 @@ def test_generate_instance_format( ) -> None: """A VuMark instance can be generated in the requested format.""" response = _make_vumark_request( - vumark_vuforia_database=vumark_vuforia_database, + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + target_id=vumark_vuforia_database.target_id, instance_id=uuid4().hex, accept=accept, ) @@ -106,7 +113,9 @@ def test_invalid_accept_header( ) -> None: """An unsupported Accept header returns an error.""" response = _make_vumark_request( - vumark_vuforia_database=vumark_vuforia_database, + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + target_id=vumark_vuforia_database.target_id, instance_id=uuid4().hex, accept="text/plain", ) @@ -124,7 +133,9 @@ def test_empty_instance_id( ) -> None: """An empty instance_id returns InvalidInstanceId.""" response = _make_vumark_request( - vumark_vuforia_database=vumark_vuforia_database, + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + target_id=vumark_vuforia_database.target_id, instance_id="", accept="image/png", ) @@ -135,3 +146,40 @@ def test_empty_instance_id( response_json["result_code"] == ResultCodes.INVALID_INSTANCE_ID.value ) + + @staticmethod + def test_non_vumark_database( + vuforia_database: VuforiaDatabase, + ) -> None: + """Generating a VuMark instance for a target in a non-VuMark + database returns InvalidTargetType. + """ + vws_client = VWS( + server_access_key=vuforia_database.server_access_key, + server_secret_key=vuforia_database.server_secret_key, + ) + image = make_image_file( + file_format="PNG", + color_space="RGB", + width=8, + height=8, + ) + target_id = vws_client.add_target( + name="test", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + response = _make_vumark_request( + server_access_key=vuforia_database.server_access_key, + server_secret_key=vuforia_database.server_secret_key, + target_id=target_id, + instance_id=uuid4().hex, + accept="image/png", + ) + assert response.status_code == HTTPStatus.FORBIDDEN + response_json = response.json() + assert ( + response_json["result_code"] == ResultCodes.INVALID_TARGET_TYPE.value + ) From bb152cf7c0da8f22a810529e4aa379f2957df3b9 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Thu, 19 Feb 2026 10:50:40 +0000 Subject: [PATCH 2/8] Document DatabaseType and TargetType in API reference; clarify Target class Add autoenum entries for DatabaseType and TargetType to the API reference docs. Add a docstring note to Target clarifying that some attributes are primarily meaningful for image targets rather than VuMark template targets. Add vulture whitelist entries for the new TypedDict field and enum value. Co-Authored-By: Claude Haiku 4.5 --- docs/source/mock-api-reference.rst | 8 ++++++++ pyproject.toml | 4 ++++ src/mock_vws/target.py | 10 ++++++++-- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/source/mock-api-reference.rst b/docs/source/mock-api-reference.rst index ee0215fe0..0ae1d5b52 100644 --- a/docs/source/mock-api-reference.rst +++ b/docs/source/mock-api-reference.rst @@ -24,8 +24,16 @@ API Reference :members: :undoc-members: +.. autoenum:: mock_vws.database_type.DatabaseType + :members: + :undoc-members: + .. autoclass:: mock_vws.target.ImageTarget +.. autoenum:: mock_vws.target_type.TargetType + :members: + :undoc-members: + Image matchers -------------- diff --git a/pyproject.toml b/pyproject.toml index c5a2f5685..538ac9b54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -452,6 +452,10 @@ ignore_names = [ # Used in TYPE_CHECKING for type hints "CloudDatabaseDict", "VuMarkDatabaseDict", + # TypedDict field names accessed via dict subscript + "target_type_name", + # Public enum values for library consumers + "VUMARK_TEMPLATE", ] # Duplicate some of .gitignore exclude = [ ".venv" ] diff --git a/src/mock_vws/target.py b/src/mock_vws/target.py index 1a4ee7473..a0035144c 100644 --- a/src/mock_vws/target.py +++ b/src/mock_vws/target.py @@ -53,9 +53,15 @@ def _time_now() -> datetime.datetime: @beartype(conf=BeartypeConf(is_pep484_tower=True)) @dataclass(frozen=True, eq=True) class ImageTarget: - """ - A Vuforia Target as managed in + """A Vuforia Target as managed in https://developer.vuforia.com/target-manager. + + The :attr:`target_type` field controls whether this represents an image + target or a VuMark template target. Note that some attributes (such as + :attr:`image_value`, :attr:`reco_rating`, and recognition counts) are + primarily meaningful for image targets. For VuMark template targets, these + fields are still present but may not be used by the mock or reflected in + the real Vuforia Web Services. """ active_flag: bool From 21818bfec33a0b77d9793bdc0439052606ab7e06 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:52:02 +0000 Subject: [PATCH 3/8] [pre-commit.ci lite] apply automatic fixes --- src/mock_vws/_flask_server/target_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mock_vws/_flask_server/target_manager.py b/src/mock_vws/_flask_server/target_manager.py index b61154b68..a58cb0f1c 100644 --- a/src/mock_vws/_flask_server/target_manager.py +++ b/src/mock_vws/_flask_server/target_manager.py @@ -16,7 +16,6 @@ from mock_vws.database_type import DatabaseType from mock_vws.states import States from mock_vws.target import ImageTarget -from mock_vws.target_type import TargetType from mock_vws.target_manager import TargetManager from mock_vws.target_raters import ( BrisqueTargetTrackingRater, @@ -24,6 +23,7 @@ RandomTargetTrackingRater, TargetTrackingRater, ) +from mock_vws.target_type import TargetType TARGET_MANAGER_FLASK_APP = Flask(import_name=__name__, static_folder=None) From 4d36bc47d7f827d56607fad2f8d1abe392820467 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 20 Feb 2026 13:20:51 +0000 Subject: [PATCH 4/8] Refactor Target into ImageTarget and VuMarkTarget classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename Target → ImageTarget and TargetDict → ImageTargetDict - Add VuMarkTarget dataclass for VuMark template targets (name, active_flag, processing_time_seconds, target_id, dates; status always succeeds after processing) - Remove TargetType enum (target_type.py deleted); class type is now the discriminator - VuforiaDatabase.targets holds set[ImageTarget | VuMarkTarget] - Image-only operations (duplicates, query matching, width/reco fields) guarded with isinstance(target, ImageTarget) checks - Update docs API reference and CHANGELOG Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.rst | 4 + docs/source/mock-api-reference.rst | 4 +- pyproject.toml | 5 +- src/mock_vws/_flask_server/target_manager.py | 92 ++++++------ src/mock_vws/_flask_server/vws.py | 58 +++++--- src/mock_vws/_query_tools.py | 4 +- .../mock_web_services_api.py | 133 +++++++++++------- src/mock_vws/database.py | 51 ++++--- src/mock_vws/target.py | 113 +++++++++++++-- src/mock_vws/target_type.py | 14 -- tests/mock_vws/fixtures/vuforia_backends.py | 15 +- tests/mock_vws/test_requests_mock_usage.py | 2 + 12 files changed, 320 insertions(+), 175 deletions(-) delete mode 100644 src/mock_vws/target_type.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6146edbec..86542874d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,10 @@ Changelog Next ---- +- Add ``VuMarkTarget`` class for VuMark template targets, alongside the renamed ``ImageTarget`` class (previously ``Target``). + ``ImageTarget`` is for image-based targets and ``VuMarkTarget`` is for VuMark template targets. + Both can be stored in a ``VuforiaDatabase``. + 2026.02.18.2 ------------ diff --git a/docs/source/mock-api-reference.rst b/docs/source/mock-api-reference.rst index 0ae1d5b52..40f4391ae 100644 --- a/docs/source/mock-api-reference.rst +++ b/docs/source/mock-api-reference.rst @@ -30,9 +30,7 @@ API Reference .. autoclass:: mock_vws.target.ImageTarget -.. autoenum:: mock_vws.target_type.TargetType - :members: - :undoc-members: +.. autoclass:: mock_vws.target.VuMarkTarget Image matchers -------------- diff --git a/pyproject.toml b/pyproject.toml index 538ac9b54..df251f8d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -452,10 +452,7 @@ ignore_names = [ # Used in TYPE_CHECKING for type hints "CloudDatabaseDict", "VuMarkDatabaseDict", - # TypedDict field names accessed via dict subscript - "target_type_name", - # Public enum values for library consumers - "VUMARK_TEMPLATE", + "VuMarkTargetDict", ] # Duplicate some of .gitignore exclude = [ ".venv" ] diff --git a/src/mock_vws/_flask_server/target_manager.py b/src/mock_vws/_flask_server/target_manager.py index a58cb0f1c..787a7bd10 100644 --- a/src/mock_vws/_flask_server/target_manager.py +++ b/src/mock_vws/_flask_server/target_manager.py @@ -15,7 +15,7 @@ from mock_vws.database import CloudDatabase from mock_vws.database_type import DatabaseType from mock_vws.states import States -from mock_vws.target import ImageTarget +from mock_vws.target import ImageTarget, VuMarkTarget from mock_vws.target_manager import TargetManager from mock_vws.target_raters import ( BrisqueTargetTrackingRater, @@ -23,7 +23,6 @@ RandomTargetTrackingRater, TargetTrackingRater, ) -from mock_vws.target_type import TargetType TARGET_MANAGER_FLASK_APP = Flask(import_name=__name__, static_folder=None) @@ -205,25 +204,29 @@ def create_target(database_name: str) -> Response: if database.database_name == database_name ) request_json = json.loads(s=request.data) - image_base64 = request_json["image_base64"] - image_bytes = base64.b64decode(s=image_base64) settings = TargetManagerSettings.model_validate(obj={}) - target_tracking_rater = settings.target_rater.to_target_rater() - - target_type = TargetType[ - request_json.get("target_type_name", TargetType.IMAGE.name) - ] - target = ImageTarget( - name=request_json["name"], - width=request_json["width"], - image_value=image_bytes, - active_flag=request_json["active_flag"], - processing_time_seconds=request_json["processing_time_seconds"], - application_metadata=request_json["application_metadata"], - target_id=request_json["target_id"], - target_tracking_rater=target_tracking_rater, - target_type=target_type, - ) + + target_type_name = request_json.get("target_type_name", "IMAGE") + if target_type_name == "VUMARK_TEMPLATE": + target: ImageTarget | VuMarkTarget = VuMarkTarget( + name=request_json["name"], + active_flag=request_json["active_flag"], + processing_time_seconds=request_json["processing_time_seconds"], + target_id=request_json["target_id"], + ) + else: + image_bytes = base64.b64decode(s=request_json["image_base64"]) + target_tracking_rater = settings.target_rater.to_target_rater() + target = ImageTarget( + name=request_json["name"], + width=request_json["width"], + image_value=image_bytes, + active_flag=request_json["active_flag"], + processing_time_seconds=request_json["processing_time_seconds"], + application_metadata=request_json["application_metadata"], + target_id=request_json["target_id"], + target_tracking_rater=target_tracking_rater, + ) database.targets.add(target) return Response( @@ -247,7 +250,7 @@ def delete_target(database_name: str, target_id: str) -> Response: target = database.get_target(target_id=target_id) now = datetime.datetime.now(tz=target.upload_date.tzinfo) # See https://github.com/facebook/pyrefly/issues/1897 - new_target = copy.replace( + new_target: ImageTarget | VuMarkTarget = copy.replace( # type: ignore[assignment] target, # pyrefly: ignore[bad-argument-type] delete_date=now, ) @@ -273,32 +276,39 @@ def update_target(database_name: str, target_id: str) -> Response: target = database.get_target(target_id=target_id) request_json = json.loads(s=request.data) - width = request_json.get("width", target.width) name = request_json.get("name", target.name) active_flag = request_json.get("active_flag", target.active_flag) - application_metadata = request_json.get( - "application_metadata", - target.application_metadata, - ) - - image_value = target.image_value - request_json = json.loads(s=request.data) - if "image" in request_json: - image_value = base64.b64decode(s=request_json["image"]) gmt = ZoneInfo(key="GMT") last_modified_date = datetime.datetime.now(tz=gmt) - # See https://github.com/facebook/pyrefly/issues/1897 - new_target = copy.replace( - target, # pyrefly: ignore[bad-argument-type] - name=name, - width=width, - active_flag=active_flag, - application_metadata=application_metadata, - image_value=image_value, - last_modified_date=last_modified_date, - ) + if isinstance(target, ImageTarget): + width = request_json.get("width", target.width) + application_metadata = request_json.get( + "application_metadata", + target.application_metadata, + ) + image_value = target.image_value + if "image" in request_json: + image_value = base64.b64decode(s=request_json["image"]) + # See https://github.com/facebook/pyrefly/issues/1897 + new_target: ImageTarget | VuMarkTarget = copy.replace( + target, # pyrefly: ignore[bad-argument-type] + name=name, + width=width, + active_flag=active_flag, + application_metadata=application_metadata, + image_value=image_value, + last_modified_date=last_modified_date, + ) + else: + # See https://github.com/facebook/pyrefly/issues/1897 + new_target = copy.replace( + target, # pyrefly: ignore[bad-argument-type] + name=name, + active_flag=active_flag, + last_modified_date=last_modified_date, + ) database.targets.remove(target) database.targets.add(new_target) diff --git a/src/mock_vws/_flask_server/vws.py b/src/mock_vws/_flask_server/vws.py index 7049c3c21..9f2b6e468 100644 --- a/src/mock_vws/_flask_server/vws.py +++ b/src/mock_vws/_flask_server/vws.py @@ -260,13 +260,21 @@ def get_target(target_id: str) -> Response: target for target in database.targets if target.target_id == target_id ) + if isinstance(target, ImageTarget): + width = target.width + tracking_rating = target.tracking_rating + reco_rating = target.reco_rating + else: + width = 0.0 + tracking_rating = -1 + reco_rating = "" target_record = { "target_id": target.target_id, "active_flag": target.active_flag, "name": target.name, - "width": target.width, - "tracking_rating": target.tracking_rating, - "reco_rating": target.reco_rating, + "width": width, + "tracking_rating": tracking_rating, + "reco_rating": reco_rating, } date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) @@ -482,6 +490,16 @@ def target_summary(target_id: str) -> Response: (target,) = ( target for target in database.targets if target.target_id == target_id ) + if isinstance(target, ImageTarget): + tracking_rating = target.tracking_rating + total_recos = target.total_recos + current_month_recos = target.current_month_recos + previous_month_recos = target.previous_month_recos + else: + tracking_rating = -1 + total_recos = 0 + current_month_recos = 0 + previous_month_recos = 0 body = { "status": target.status, "transaction_id": uuid.uuid4().hex, @@ -490,10 +508,10 @@ def target_summary(target_id: str) -> Response: "target_name": target.name, "upload_date": target.upload_date.strftime(format="%Y-%m-%d"), "active_flag": target.active_flag, - "tracking_rating": target.tracking_rating, - "total_recos": target.total_recos, - "current_month_recos": target.current_month_recos, - "previous_month_recos": target.previous_month_recos, + "tracking_rating": tracking_rating, + "total_recos": total_recos, + "current_month_recos": current_month_recos, + "previous_month_recos": previous_month_recos, } date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) headers = { @@ -540,17 +558,21 @@ def get_duplicates(target_id: str) -> Response: ) other_targets = database.targets - {target} - similar_targets = [ - other.target_id - for other in other_targets - if image_match_checker( - first_image_content=target.image_value, - second_image_content=other.image_value, - ) - and TargetStatuses.FAILED.value not in {target.status, other.status} - and TargetStatuses.PROCESSING.value != other.status - and other.active_flag - ] + similar_targets = [] + if isinstance(target, ImageTarget): + similar_targets = [ + other.target_id + for other in other_targets + if isinstance(other, ImageTarget) + and image_match_checker( + first_image_content=target.image_value, + second_image_content=other.image_value, + ) + and TargetStatuses.FAILED.value + not in {target.status, other.status} + and TargetStatuses.PROCESSING.value != other.status + and other.active_flag + ] body = { "transaction_id": uuid.uuid4().hex, diff --git a/src/mock_vws/_query_tools.py b/src/mock_vws/_query_tools.py index 3c030844e..58ae40364 100644 --- a/src/mock_vws/_query_tools.py +++ b/src/mock_vws/_query_tools.py @@ -16,6 +16,7 @@ from mock_vws._mock_common import json_dump from mock_vws.database import CloudDatabase from mock_vws.image_matchers import ImageMatcher +from mock_vws.target import ImageTarget @beartype @@ -72,7 +73,8 @@ def get_query_match_response_text( matching_targets = [ target for target in database.targets - if query_match_checker( + if isinstance(target, ImageTarget) + and query_match_checker( first_image_content=target.image_value, second_image_content=image_value, ) diff --git a/src/mock_vws/_requests_mock_server/mock_web_services_api.py b/src/mock_vws/_requests_mock_server/mock_web_services_api.py index b66378e91..b9c7f193d 100644 --- a/src/mock_vws/_requests_mock_server/mock_web_services_api.py +++ b/src/mock_vws/_requests_mock_server/mock_web_services_api.py @@ -39,7 +39,7 @@ ) from mock_vws.database_type import DatabaseType from mock_vws.image_matchers import ImageMatcher -from mock_vws.target import ImageTarget +from mock_vws.target import ImageTarget, VuMarkTarget from mock_vws.target_manager import TargetManager from mock_vws.target_raters import TargetTrackingRater @@ -267,7 +267,7 @@ def delete_target(self, request: PreparedRequest) -> _ResponseType: now = datetime.datetime.now(tz=target.upload_date.tzinfo) # See https://github.com/facebook/pyrefly/issues/1897 - new_target = copy.replace( + new_target: ImageTarget | VuMarkTarget = copy.replace( # type: ignore[assignment] target, # pyrefly: ignore[bad-argument-type] delete_date=now, ) @@ -505,13 +505,21 @@ def get_target(self, request: PreparedRequest) -> _ResponseType: target_id = request.path_url.split(sep="/")[-1] target = database.get_target(target_id=target_id) + if isinstance(target, ImageTarget): + width = target.width + tracking_rating = target.tracking_rating + reco_rating = target.reco_rating + else: + width = 0.0 + tracking_rating = -1 + reco_rating = "" target_record = { "target_id": target.target_id, "active_flag": target.active_flag, "name": target.name, - "width": target.width, - "tracking_rating": target.tracking_rating, - "reco_rating": target.reco_rating, + "width": width, + "tracking_rating": tracking_rating, + "reco_rating": reco_rating, } date = email.utils.formatdate( timeval=None, @@ -573,18 +581,21 @@ def get_duplicates(self, request: PreparedRequest) -> _ResponseType: other_targets = database.targets - {target} - similar_targets = [ - other.target_id - for other in other_targets - if self._duplicate_match_checker( - first_image_content=target.image_value, - second_image_content=other.image_value, - ) - and TargetStatuses.FAILED.value - not in {target.status, other.status} - and TargetStatuses.PROCESSING.value != other.status - and other.active_flag - ] + similar_targets: list[str] = [] + if isinstance(target, ImageTarget): + similar_targets = [ + other.target_id + for other in other_targets + if isinstance(other, ImageTarget) + and self._duplicate_match_checker( + first_image_content=target.image_value, + second_image_content=other.image_value, + ) + and TargetStatuses.FAILED.value + not in {target.status, other.status} + and TargetStatuses.PROCESSING.value != other.status + and other.active_flag + ] date = email.utils.formatdate( timeval=None, @@ -658,17 +669,8 @@ def update_target(self, request: PreparedRequest) -> _ResponseType: ) request_json: dict[str, Any] = json.loads(s=request.body or b"") - width = request_json.get("width", target.width) name = request_json.get("name", target.name) active_flag = request_json.get("active_flag", target.active_flag) - application_metadata = request_json.get( - "application_metadata", - target.application_metadata, - ) - - image_value = target.image_value - if "image" in request_json: - image_value = base64.b64decode(s=request_json["image"]) if "active_flag" in request_json and active_flag is None: fail_exception = FailError(status_code=HTTPStatus.BAD_REQUEST) @@ -678,30 +680,49 @@ def update_target(self, request: PreparedRequest) -> _ResponseType: fail_exception.response_text, ) - if ( - "application_metadata" in request_json - and application_metadata is None - ): - fail_exception = FailError(status_code=HTTPStatus.BAD_REQUEST) - return ( - fail_exception.status_code, - fail_exception.headers, - fail_exception.response_text, - ) - gmt = ZoneInfo(key="GMT") last_modified_date = datetime.datetime.now(tz=gmt) - # See https://github.com/facebook/pyrefly/issues/1897 - new_target = copy.replace( - target, # pyrefly: ignore[bad-argument-type] - name=name, - width=width, - active_flag=active_flag, - application_metadata=application_metadata, - image_value=image_value, - last_modified_date=last_modified_date, - ) + if isinstance(target, ImageTarget): + width = request_json.get("width", target.width) + application_metadata = request_json.get( + "application_metadata", + target.application_metadata, + ) + + image_value = target.image_value + if "image" in request_json: + image_value = base64.b64decode(s=request_json["image"]) + + if ( + "application_metadata" in request_json + and application_metadata is None + ): + fail_exception = FailError(status_code=HTTPStatus.BAD_REQUEST) + return ( + fail_exception.status_code, + fail_exception.headers, + fail_exception.response_text, + ) + + # See https://github.com/facebook/pyrefly/issues/1897 + new_target: ImageTarget | VuMarkTarget = copy.replace( + target, # pyrefly: ignore[bad-argument-type] + name=name, + width=width, + active_flag=active_flag, + application_metadata=application_metadata, + image_value=image_value, + last_modified_date=last_modified_date, + ) + else: + # See https://github.com/facebook/pyrefly/issues/1897 + new_target = copy.replace( + target, # pyrefly: ignore[bad-argument-type] + name=name, + active_flag=active_flag, + last_modified_date=last_modified_date, + ) database.targets.remove(target) database.targets.add(new_target) @@ -760,6 +781,16 @@ def target_summary(self, request: PreparedRequest) -> _ResponseType: localtime=False, usegmt=True, ) + if isinstance(target, ImageTarget): + tracking_rating = target.tracking_rating + total_recos = target.total_recos + current_month_recos = target.current_month_recos + previous_month_recos = target.previous_month_recos + else: + tracking_rating = -1 + total_recos = 0 + current_month_recos = 0 + previous_month_recos = 0 body = { "status": target.status, "transaction_id": uuid.uuid4().hex, @@ -768,10 +799,10 @@ def target_summary(self, request: PreparedRequest) -> _ResponseType: "target_name": target.name, "upload_date": target.upload_date.strftime(format="%Y-%m-%d"), "active_flag": target.active_flag, - "tracking_rating": target.tracking_rating, - "total_recos": target.total_recos, - "current_month_recos": target.current_month_recos, - "previous_month_recos": target.previous_month_recos, + "tracking_rating": tracking_rating, + "total_recos": total_recos, + "current_month_recos": current_month_recos, + "previous_month_recos": previous_month_recos, } body_json = json_dump(body=body) headers = { diff --git a/src/mock_vws/database.py b/src/mock_vws/database.py index 9aa3cf585..02d684e68 100644 --- a/src/mock_vws/database.py +++ b/src/mock_vws/database.py @@ -3,14 +3,19 @@ import uuid from collections.abc import Iterable from dataclasses import dataclass, field -from typing import Self, TypedDict +from typing import Self, TypedDict, cast from beartype import beartype from mock_vws._constants import TargetStatuses from mock_vws.database_type import DatabaseType from mock_vws.states import States -from mock_vws.target import ImageTarget, ImageTargetDict +from mock_vws.target import ( + ImageTarget, + ImageTargetDict, + VuMarkTarget, + VuMarkTargetDict, +) @beartype @@ -24,7 +29,7 @@ class CloudDatabaseDict(TypedDict): client_secret_key: str state_name: str database_type_name: str - targets: Iterable[ImageTargetDict] + targets: Iterable[ImageTargetDict | VuMarkTargetDict] @beartype @@ -63,8 +68,8 @@ class CloudDatabase: # ``frozen=True`` while still being able to keep the interface we want. # In particular, we might want to inspect the ``database`` object's targets # as they change via API requests. - targets: set[ImageTarget] = field( - default_factory=set[ImageTarget], + targets: set[ImageTarget | VuMarkTarget] = field( + default_factory=set[ImageTarget | VuMarkTarget], hash=False, ) state: States = States.WORKING @@ -79,7 +84,9 @@ class CloudDatabase: def to_dict(self) -> CloudDatabaseDict: """Dump a target to a dictionary which can be loaded as JSON.""" - targets = [target.to_dict() for target in self.targets] + targets: list[ImageTargetDict | VuMarkTargetDict] = [ + target.to_dict() for target in self.targets + ] return { "database_name": self.database_name, "server_access_key": self.server_access_key, @@ -91,7 +98,7 @@ def to_dict(self) -> CloudDatabaseDict: "targets": targets, } - def get_target(self, target_id: str) -> ImageTarget: + def get_target(self, target_id: str) -> ImageTarget | VuMarkTarget: """Return a target from the database with the given ID.""" (target,) = ( target for target in self.targets if target.target_id == target_id @@ -101,6 +108,21 @@ def get_target(self, target_id: str) -> ImageTarget: @classmethod def from_dict(cls, database_dict: CloudDatabaseDict) -> Self: """Load a database from a dictionary.""" + targets: set[ImageTarget | VuMarkTarget] = set() + for target_dict in database_dict["targets"]: + if target_dict["target_type_name"] == "VUMARK_TEMPLATE": + targets.add( + VuMarkTarget.from_dict( + target_dict=target_dict, + ) + ) + else: + targets.add( + ImageTarget.from_dict( + target_dict=cast("ImageTargetDict", target_dict), + ) + ) + return cls( database_name=database_dict["database_name"], server_access_key=database_dict["server_access_key"], @@ -109,19 +131,16 @@ def from_dict(cls, database_dict: CloudDatabaseDict) -> Self: client_secret_key=database_dict["client_secret_key"], state=States[database_dict["state_name"]], database_type=DatabaseType[database_dict["database_type_name"]], - targets={ - ImageTarget.from_dict(target_dict=target_dict) - for target_dict in database_dict["targets"] - }, + targets=targets, ) @property - def not_deleted_targets(self) -> set[ImageTarget]: + def not_deleted_targets(self) -> set[ImageTarget | VuMarkTarget]: """All targets which have not been deleted.""" return {target for target in self.targets if not target.delete_date} @property - def active_targets(self) -> set[ImageTarget]: + def active_targets(self) -> set[ImageTarget | VuMarkTarget]: """All active targets.""" return { target @@ -131,7 +150,7 @@ def active_targets(self) -> set[ImageTarget]: } @property - def inactive_targets(self) -> set[ImageTarget]: + def inactive_targets(self) -> set[ImageTarget | VuMarkTarget]: """All inactive targets.""" return { target @@ -141,7 +160,7 @@ def inactive_targets(self) -> set[ImageTarget]: } @property - def failed_targets(self) -> set[ImageTarget]: + def failed_targets(self) -> set[ImageTarget | VuMarkTarget]: """All failed targets.""" return { target @@ -150,7 +169,7 @@ def failed_targets(self) -> set[ImageTarget]: } @property - def processing_targets(self) -> set[ImageTarget]: + def processing_targets(self) -> set[ImageTarget | VuMarkTarget]: """All processing targets.""" return { target diff --git a/src/mock_vws/target.py b/src/mock_vws/target.py index a0035144c..ac504b950 100644 --- a/src/mock_vws/target.py +++ b/src/mock_vws/target.py @@ -17,11 +17,10 @@ HardcodedTargetTrackingRater, TargetTrackingRater, ) -from mock_vws.target_type import TargetType class ImageTargetDict(TypedDict): - """A dictionary type which represents a target.""" + """A dictionary type which represents an image target.""" name: str width: float @@ -37,6 +36,19 @@ class ImageTargetDict(TypedDict): target_type_name: str +class VuMarkTargetDict(TypedDict): + """A dictionary type which represents a VuMark template target.""" + + name: str + active_flag: bool + processing_time_seconds: float + target_id: str + last_modified_date: str + delete_date_optional: str | None + upload_date: str + target_type_name: str + + @beartype def _random_hex() -> str: """Return a random hex value.""" @@ -53,15 +65,8 @@ def _time_now() -> datetime.datetime: @beartype(conf=BeartypeConf(is_pep484_tower=True)) @dataclass(frozen=True, eq=True) class ImageTarget: - """A Vuforia Target as managed in + """A Vuforia image target as managed in https://developer.vuforia.com/target-manager. - - The :attr:`target_type` field controls whether this represents an image - target or a VuMark template target. Note that some attributes (such as - :attr:`image_value`, :attr:`reco_rating`, and recognition counts) are - primarily meaningful for image targets. For VuMark template targets, these - fields are still present but may not be used by the mock or reflected in - the real Vuforia Web Services. """ active_flag: bool @@ -77,7 +82,6 @@ class ImageTarget: previous_month_recos: int = 0 reco_rating: str = "" target_id: str = field(default_factory=_random_hex) - target_type: TargetType = TargetType.IMAGE total_recos: int = 0 upload_date: datetime.datetime = field(default_factory=_time_now) @@ -182,7 +186,6 @@ def from_dict(cls, target_dict: ImageTargetDict) -> Self: target_tracking_rater = HardcodedTargetTrackingRater( rating=target_dict["tracking_rating"], ) - target_type = TargetType[target_dict["target_type_name"]] return cls( target_id=target_id, name=name, @@ -195,7 +198,6 @@ def from_dict(cls, target_dict: ImageTargetDict) -> Self: last_modified_date=last_modified_date, upload_date=upload_date, target_tracking_rater=target_tracking_rater, - target_type=target_type, ) def to_dict(self) -> ImageTargetDict: @@ -218,5 +220,88 @@ def to_dict(self) -> ImageTargetDict: "delete_date_optional": delete_date, "upload_date": self.upload_date.isoformat(), "tracking_rating": self.tracking_rating, - "target_type_name": self.target_type.name, + "target_type_name": "IMAGE", + } + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +@dataclass(frozen=True, eq=True) +class VuMarkTarget: + """A VuMark template target as managed in + https://developer.vuforia.com/target-manager. + """ + + name: str + active_flag: bool + processing_time_seconds: float + delete_date: datetime.datetime | None = None + last_modified_date: datetime.datetime = field(default_factory=_time_now) + target_id: str = field(default_factory=_random_hex) + upload_date: datetime.datetime = field(default_factory=_time_now) + + @property + def status(self) -> str: + """Return the status of the VuMark template target. + + VuMark templates always succeed after the processing time. + """ + processing_time = datetime.timedelta( + seconds=float(self.processing_time_seconds), + ) + + timezone = self.upload_date.tzinfo + now = datetime.datetime.now(tz=timezone) + time_since_change = now - self.last_modified_date + + if time_since_change <= processing_time: + return TargetStatuses.PROCESSING.value + + return TargetStatuses.SUCCESS.value + + @classmethod + def from_dict(cls, target_dict: VuMarkTargetDict) -> Self: + """Load a VuMark target from a dictionary.""" + timezone = ZoneInfo(key="GMT") + delete_date_optional = target_dict["delete_date_optional"] + if delete_date_optional is None: + delete_date = None + else: + delete_date = datetime.datetime.fromisoformat( + delete_date_optional, + ).replace(tzinfo=timezone) + + last_modified_date = datetime.datetime.fromisoformat( + target_dict["last_modified_date"], + ).replace(tzinfo=timezone) + upload_date = datetime.datetime.fromisoformat( + target_dict["upload_date"], + ).replace(tzinfo=timezone) + + return cls( + name=target_dict["name"], + active_flag=target_dict["active_flag"], + target_id=target_dict["target_id"], + processing_time_seconds=target_dict["processing_time_seconds"], + delete_date=delete_date, + last_modified_date=last_modified_date, + upload_date=upload_date, + ) + + def to_dict(self) -> VuMarkTargetDict: + """Dump a VuMark target to a dictionary which can be loaded as + JSON. + """ + delete_date: str | None = None + if self.delete_date: + delete_date = self.delete_date.isoformat() + + return { + "name": self.name, + "active_flag": self.active_flag, + "target_id": self.target_id, + "processing_time_seconds": float(self.processing_time_seconds), + "delete_date_optional": delete_date, + "last_modified_date": self.last_modified_date.isoformat(), + "upload_date": self.upload_date.isoformat(), + "target_type_name": "VUMARK_TEMPLATE", } diff --git a/src/mock_vws/target_type.py b/src/mock_vws/target_type.py deleted file mode 100644 index eb65d964a..000000000 --- a/src/mock_vws/target_type.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Vuforia target types.""" - -from enum import StrEnum, auto, unique - -from beartype import beartype - - -@beartype -@unique -class TargetType(StrEnum): - """Constants representing various target types.""" - - IMAGE = auto() - VUMARK_TEMPLATE = auto() diff --git a/tests/mock_vws/fixtures/vuforia_backends.py b/tests/mock_vws/fixtures/vuforia_backends.py index 49a22bb96..c1240e4ea 100644 --- a/tests/mock_vws/fixtures/vuforia_backends.py +++ b/tests/mock_vws/fixtures/vuforia_backends.py @@ -22,10 +22,8 @@ from mock_vws.database import CloudDatabase from mock_vws.database_type import DatabaseType from mock_vws.states import States -from mock_vws.target import ImageTarget -from mock_vws.target_raters import HardcodedTargetTrackingRater +from mock_vws.target import VuMarkTarget from tests.mock_vws.fixtures.credentials import VuMarkCloudDatabase -from tests.mock_vws.utils import make_image_file from tests.mock_vws.utils.retries import RETRY_ON_TOO_MANY_REQUESTS LOGGER = logging.getLogger(name=__name__) @@ -68,19 +66,10 @@ def _vumark_database( vumark_vuforia_database: VuMarkCloudDatabase, ) -> CloudDatabase: """Return a database with a target for VuMark instance generation.""" - vumark_target = ImageTarget( + vumark_target = VuMarkTarget( active_flag=True, - application_metadata=None, - image_value=make_image_file( - file_format="PNG", - color_space="RGB", - width=8, - height=8, - ).getvalue(), name="mock-vumark-target", processing_time_seconds=0, - width=1, - target_tracking_rater=HardcodedTargetTrackingRater(rating=5), target_id=vumark_vuforia_database.target_id, ) return CloudDatabase( diff --git a/tests/mock_vws/test_requests_mock_usage.py b/tests/mock_vws/test_requests_mock_usage.py index cc79f716f..a47cac764 100644 --- a/tests/mock_vws/test_requests_mock_usage.py +++ b/tests/mock_vws/test_requests_mock_usage.py @@ -395,6 +395,7 @@ def test_to_dict(high_quality_image: io.BytesIO) -> None: assert len(database.targets) == 1 target = next(iter(database.targets)) + assert isinstance(target, ImageTarget) target_dict = target.to_dict() # The dictionary is JSON dump-able @@ -431,6 +432,7 @@ def test_to_dict_deleted(high_quality_image: io.BytesIO) -> None: assert len(database.targets) == 1 target = next(iter(database.targets)) + assert isinstance(target, ImageTarget) target_dict = target.to_dict() # The dictionary is JSON dump-able From 5409f860943efaebea6f12fa65492e55cb429be0 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 20 Feb 2026 15:14:32 +0000 Subject: [PATCH 5/8] Add VuMark to spelling dictionary Co-Authored-By: Claude Sonnet 4.6 --- spelling_private_dict.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/spelling_private_dict.txt b/spelling_private_dict.txt index 365309073..0b053e3bb 100644 --- a/spelling_private_dict.txt +++ b/spelling_private_dict.txt @@ -3,6 +3,7 @@ MPixel MiB MissingSchema Ubuntu +VuMark admin another's api From 2347de7825b6016922b0d88bfcecbb0797051ef0 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 20 Feb 2026 16:26:37 +0000 Subject: [PATCH 6/8] Fix InvalidTargetType response status code to 422 Real Vuforia returns 422 Unprocessable Entity (not 403 Forbidden) when attempting VuMark generation on a non-VuMark database. Co-Authored-By: Claude Sonnet 4.6 --- src/mock_vws/_services_validators/exceptions.py | 2 +- tests/mock_vws/test_vumark_generation_api.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/mock_vws/_services_validators/exceptions.py b/src/mock_vws/_services_validators/exceptions.py index e09a2785a..da058422d 100644 --- a/src/mock_vws/_services_validators/exceptions.py +++ b/src/mock_vws/_services_validators/exceptions.py @@ -662,7 +662,7 @@ def __init__(self) -> None: raised. """ super().__init__() - self.status_code = HTTPStatus.FORBIDDEN + self.status_code = HTTPStatus.UNPROCESSABLE_ENTITY body = { "transaction_id": uuid.uuid4().hex, "result_code": ResultCodes.INVALID_TARGET_TYPE.value, diff --git a/tests/mock_vws/test_vumark_generation_api.py b/tests/mock_vws/test_vumark_generation_api.py index 9e3e36aa2..2258988a6 100644 --- a/tests/mock_vws/test_vumark_generation_api.py +++ b/tests/mock_vws/test_vumark_generation_api.py @@ -149,7 +149,7 @@ def test_empty_instance_id( @staticmethod def test_non_vumark_database( - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, ) -> None: """Generating a VuMark instance for a target in a non-VuMark database returns InvalidTargetType. @@ -178,8 +178,9 @@ def test_non_vumark_database( instance_id=uuid4().hex, accept="image/png", ) - assert response.status_code == HTTPStatus.FORBIDDEN + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY response_json = response.json() assert ( - response_json["result_code"] == ResultCodes.INVALID_TARGET_TYPE.value + response_json["result_code"] + == ResultCodes.INVALID_TARGET_TYPE.value ) From 807257a2a7ddb6cd38f7a4e391db5df522daf324 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 20 Feb 2026 23:05:37 +0000 Subject: [PATCH 7/8] Fix mypy errors and stale 'databases' attribute reference - Change new_target type annotations from ImageTarget | VuMarkTarget to ImageTarget in delete_target and update_target, since CloudDatabase.targets is set[ImageTarget] - Remove dead else branches in update_target (target is always ImageTarget in cloud databases) - Remove unused type: ignore[assignment] comments - Fix generate_vumark_instance to use all_databases instead of stale self._target_manager.databases attribute - Remove unused VuMarkTarget import from mock_web_services_api Co-Authored-By: Claude Sonnet 4.6 --- src/mock_vws/_flask_server/target_manager.py | 47 +++++------- .../mock_web_services_api.py | 71 ++++++++----------- 2 files changed, 50 insertions(+), 68 deletions(-) diff --git a/src/mock_vws/_flask_server/target_manager.py b/src/mock_vws/_flask_server/target_manager.py index aa44d7d82..3c5b6fd51 100644 --- a/src/mock_vws/_flask_server/target_manager.py +++ b/src/mock_vws/_flask_server/target_manager.py @@ -349,7 +349,7 @@ def delete_target(database_name: str, target_id: str) -> Response: target = database.get_target(target_id=target_id) now = datetime.datetime.now(tz=target.upload_date.tzinfo) # See https://github.com/facebook/pyrefly/issues/1897 - new_target: ImageTarget | VuMarkTarget = copy.replace( # type: ignore[assignment] + new_target: ImageTarget = copy.replace( target, # pyrefly: ignore[bad-argument-type] delete_date=now, ) @@ -381,33 +381,24 @@ def update_target(database_name: str, target_id: str) -> Response: gmt = ZoneInfo(key="GMT") last_modified_date = datetime.datetime.now(tz=gmt) - if isinstance(target, ImageTarget): - width = request_json.get("width", target.width) - application_metadata = request_json.get( - "application_metadata", - target.application_metadata, - ) - image_value = target.image_value - if "image" in request_json: - image_value = base64.b64decode(s=request_json["image"]) - # See https://github.com/facebook/pyrefly/issues/1897 - new_target: ImageTarget | VuMarkTarget = copy.replace( - target, # pyrefly: ignore[bad-argument-type] - name=name, - width=width, - active_flag=active_flag, - application_metadata=application_metadata, - image_value=image_value, - last_modified_date=last_modified_date, - ) - else: - # See https://github.com/facebook/pyrefly/issues/1897 - new_target = copy.replace( - target, # pyrefly: ignore[bad-argument-type] - name=name, - active_flag=active_flag, - last_modified_date=last_modified_date, - ) + width = request_json.get("width", target.width) + application_metadata = request_json.get( + "application_metadata", + target.application_metadata, + ) + image_value = target.image_value + if "image" in request_json: + image_value = base64.b64decode(s=request_json["image"]) + # See https://github.com/facebook/pyrefly/issues/1897 + new_target: ImageTarget = copy.replace( + target, # pyrefly: ignore[bad-argument-type] + name=name, + width=width, + active_flag=active_flag, + application_metadata=application_metadata, + image_value=image_value, + last_modified_date=last_modified_date, + ) database.targets.remove(target) database.targets.add(new_target) diff --git a/src/mock_vws/_requests_mock_server/mock_web_services_api.py b/src/mock_vws/_requests_mock_server/mock_web_services_api.py index 28ca846e1..34aef4686 100644 --- a/src/mock_vws/_requests_mock_server/mock_web_services_api.py +++ b/src/mock_vws/_requests_mock_server/mock_web_services_api.py @@ -39,7 +39,7 @@ ) from mock_vws.database import VuMarkDatabase from mock_vws.image_matchers import ImageMatcher -from mock_vws.target import ImageTarget, VuMarkTarget +from mock_vws.target import ImageTarget from mock_vws.target_manager import TargetManager from mock_vws.target_raters import TargetTrackingRater @@ -270,7 +270,7 @@ def delete_target(self, request: PreparedRequest) -> _ResponseType: now = datetime.datetime.now(tz=target.upload_date.tzinfo) # See https://github.com/facebook/pyrefly/issues/1897 - new_target: ImageTarget | VuMarkTarget = copy.replace( # type: ignore[assignment] + new_target: ImageTarget = copy.replace( target, # pyrefly: ignore[bad-argument-type] delete_date=now, ) @@ -331,7 +331,7 @@ def generate_vumark_instance( request_body=_body_bytes(request=request), request_method=request.method or "", request_path=request.path_url, - databases=self._target_manager.databases, + databases=all_databases, ) if not isinstance(database, VuMarkDatabase): raise InvalidTargetTypeError @@ -690,47 +690,38 @@ def update_target(self, request: PreparedRequest) -> _ResponseType: gmt = ZoneInfo(key="GMT") last_modified_date = datetime.datetime.now(tz=gmt) - if isinstance(target, ImageTarget): - width = request_json.get("width", target.width) - application_metadata = request_json.get( - "application_metadata", - target.application_metadata, - ) + width = request_json.get("width", target.width) + application_metadata = request_json.get( + "application_metadata", + target.application_metadata, + ) - image_value = target.image_value - if "image" in request_json: - image_value = base64.b64decode(s=request_json["image"]) - - if ( - "application_metadata" in request_json - and application_metadata is None - ): - fail_exception = FailError(status_code=HTTPStatus.BAD_REQUEST) - return ( - fail_exception.status_code, - fail_exception.headers, - fail_exception.response_text, - ) + image_value = target.image_value + if "image" in request_json: + image_value = base64.b64decode(s=request_json["image"]) - # See https://github.com/facebook/pyrefly/issues/1897 - new_target: ImageTarget | VuMarkTarget = copy.replace( - target, # pyrefly: ignore[bad-argument-type] - name=name, - width=width, - active_flag=active_flag, - application_metadata=application_metadata, - image_value=image_value, - last_modified_date=last_modified_date, - ) - else: - # See https://github.com/facebook/pyrefly/issues/1897 - new_target = copy.replace( - target, # pyrefly: ignore[bad-argument-type] - name=name, - active_flag=active_flag, - last_modified_date=last_modified_date, + if ( + "application_metadata" in request_json + and application_metadata is None + ): + fail_exception = FailError(status_code=HTTPStatus.BAD_REQUEST) + return ( + fail_exception.status_code, + fail_exception.headers, + fail_exception.response_text, ) + # See https://github.com/facebook/pyrefly/issues/1897 + new_target: ImageTarget = copy.replace( + target, # pyrefly: ignore[bad-argument-type] + name=name, + width=width, + active_flag=active_flag, + application_metadata=application_metadata, + image_value=image_value, + last_modified_date=last_modified_date, + ) + database.targets.remove(target) database.targets.add(new_target) From ce7003d6044900fb5912d356774e0234f63b0790 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 20 Feb 2026 23:07:56 +0000 Subject: [PATCH 8/8] Remove unnecessary isinstance checks for ImageTarget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since CloudDatabase.targets is set[ImageTarget], targets are always ImageTarget — isinstance checks are redundant and pyright flags them. Remove all unnecessary isinstance(target, ImageTarget) guards and their dead else branches. Co-Authored-By: Claude Sonnet 4.6 --- src/mock_vws/_flask_server/vws.py | 51 +++++++----------- src/mock_vws/_query_tools.py | 4 +- .../mock_web_services_api.py | 52 +++++++------------ 3 files changed, 38 insertions(+), 69 deletions(-) diff --git a/src/mock_vws/_flask_server/vws.py b/src/mock_vws/_flask_server/vws.py index 623f0a931..fc9bb8a84 100644 --- a/src/mock_vws/_flask_server/vws.py +++ b/src/mock_vws/_flask_server/vws.py @@ -279,14 +279,9 @@ def get_target(target_id: str) -> Response: target for target in database.targets if target.target_id == target_id ) - if isinstance(target, ImageTarget): - width = target.width - tracking_rating = target.tracking_rating - reco_rating = target.reco_rating - else: - width = 0.0 - tracking_rating = -1 - reco_rating = "" + width = target.width + tracking_rating = target.tracking_rating + reco_rating = target.reco_rating target_record = { "target_id": target.target_id, "active_flag": target.active_flag, @@ -522,16 +517,10 @@ def target_summary(target_id: str) -> Response: (target,) = ( target for target in database.targets if target.target_id == target_id ) - if isinstance(target, ImageTarget): - tracking_rating = target.tracking_rating - total_recos = target.total_recos - current_month_recos = target.current_month_recos - previous_month_recos = target.previous_month_recos - else: - tracking_rating = -1 - total_recos = 0 - current_month_recos = 0 - previous_month_recos = 0 + tracking_rating = target.tracking_rating + total_recos = target.total_recos + current_month_recos = target.current_month_recos + previous_month_recos = target.previous_month_recos body = { "status": target.status, "transaction_id": uuid.uuid4().hex, @@ -590,21 +579,17 @@ def get_duplicates(target_id: str) -> Response: ) other_targets = database.targets - {target} - similar_targets = [] - if isinstance(target, ImageTarget): - similar_targets = [ - other.target_id - for other in other_targets - if isinstance(other, ImageTarget) - and image_match_checker( - first_image_content=target.image_value, - second_image_content=other.image_value, - ) - and TargetStatuses.FAILED.value - not in {target.status, other.status} - and TargetStatuses.PROCESSING.value != other.status - and other.active_flag - ] + similar_targets = [ + other.target_id + for other in other_targets + if image_match_checker( + first_image_content=target.image_value, + second_image_content=other.image_value, + ) + and TargetStatuses.FAILED.value not in {target.status, other.status} + and TargetStatuses.PROCESSING.value != other.status + and other.active_flag + ] body = { "transaction_id": uuid.uuid4().hex, diff --git a/src/mock_vws/_query_tools.py b/src/mock_vws/_query_tools.py index 58ae40364..3c030844e 100644 --- a/src/mock_vws/_query_tools.py +++ b/src/mock_vws/_query_tools.py @@ -16,7 +16,6 @@ from mock_vws._mock_common import json_dump from mock_vws.database import CloudDatabase from mock_vws.image_matchers import ImageMatcher -from mock_vws.target import ImageTarget @beartype @@ -73,8 +72,7 @@ def get_query_match_response_text( matching_targets = [ target for target in database.targets - if isinstance(target, ImageTarget) - and query_match_checker( + if query_match_checker( first_image_content=target.image_value, second_image_content=image_value, ) diff --git a/src/mock_vws/_requests_mock_server/mock_web_services_api.py b/src/mock_vws/_requests_mock_server/mock_web_services_api.py index 34aef4686..deac884bf 100644 --- a/src/mock_vws/_requests_mock_server/mock_web_services_api.py +++ b/src/mock_vws/_requests_mock_server/mock_web_services_api.py @@ -512,14 +512,9 @@ def get_target(self, request: PreparedRequest) -> _ResponseType: target_id = request.path_url.split(sep="/")[-1] target = database.get_target(target_id=target_id) - if isinstance(target, ImageTarget): - width = target.width - tracking_rating = target.tracking_rating - reco_rating = target.reco_rating - else: - width = 0.0 - tracking_rating = -1 - reco_rating = "" + width = target.width + tracking_rating = target.tracking_rating + reco_rating = target.reco_rating target_record = { "target_id": target.target_id, "active_flag": target.active_flag, @@ -588,21 +583,18 @@ def get_duplicates(self, request: PreparedRequest) -> _ResponseType: other_targets = database.targets - {target} - similar_targets: list[str] = [] - if isinstance(target, ImageTarget): - similar_targets = [ - other.target_id - for other in other_targets - if isinstance(other, ImageTarget) - and self._duplicate_match_checker( - first_image_content=target.image_value, - second_image_content=other.image_value, - ) - and TargetStatuses.FAILED.value - not in {target.status, other.status} - and TargetStatuses.PROCESSING.value != other.status - and other.active_flag - ] + similar_targets = [ + other.target_id + for other in other_targets + if self._duplicate_match_checker( + first_image_content=target.image_value, + second_image_content=other.image_value, + ) + and TargetStatuses.FAILED.value + not in {target.status, other.status} + and TargetStatuses.PROCESSING.value != other.status + and other.active_flag + ] date = email.utils.formatdate( timeval=None, @@ -779,16 +771,10 @@ def target_summary(self, request: PreparedRequest) -> _ResponseType: localtime=False, usegmt=True, ) - if isinstance(target, ImageTarget): - tracking_rating = target.tracking_rating - total_recos = target.total_recos - current_month_recos = target.current_month_recos - previous_month_recos = target.previous_month_recos - else: - tracking_rating = -1 - total_recos = 0 - current_month_recos = 0 - previous_month_recos = 0 + tracking_rating = target.tracking_rating + total_recos = target.total_recos + current_month_recos = target.current_month_recos + previous_month_recos = target.previous_month_recos body = { "status": target.status, "transaction_id": uuid.uuid4().hex,