From 787bbe3887c8742a467f9df4df6c3c86d98f9c4e Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 20 Feb 2026 16:12:57 +0000 Subject: [PATCH 01/13] Create VuMarkTarget type for semantically correct VuMark fixture Previously, _vumark_database used ImageTarget for VuMark targets, which was semantically incorrect since VuMark targets don't have image data. Now VuMarkTarget is a proper type that reflects the differences: no image_value, no processing_time_seconds, always SUCCESS status, and hardcoded tracking_rating. Changes: - Add VuMarkTarget and VuMarkTargetDict to target.py - Add vumark_targets field to CloudDatabase (separate from image targets) - Update not_deleted_targets to include both types for validator lookups - Keep active_targets, inactive_targets, etc. as image-only for clarity - Add POST /databases/{name}/vumark_targets Flask endpoint - Update _vumark_database fixture to use VuMarkTarget - Update _enable_use_docker_in_memory to use new endpoint - Document VuMarkTarget in API reference Co-Authored-By: Claude Haiku 4.5 --- docs/source/mock-api-reference.rst | 2 + src/mock_vws/_flask_server/target_manager.py | 24 ++++- src/mock_vws/database.py | 68 +++++++++---- src/mock_vws/target.py | 101 +++++++++++++++++++ tests/mock_vws/fixtures/vuforia_backends.py | 29 ++---- 5 files changed, 183 insertions(+), 41 deletions(-) diff --git a/docs/source/mock-api-reference.rst b/docs/source/mock-api-reference.rst index ee0215fe0..7b0430675 100644 --- a/docs/source/mock-api-reference.rst +++ b/docs/source/mock-api-reference.rst @@ -26,6 +26,8 @@ API Reference .. autoclass:: mock_vws.target.ImageTarget +.. autoclass:: mock_vws.target.VuMarkTarget + Image matchers -------------- diff --git a/src/mock_vws/_flask_server/target_manager.py b/src/mock_vws/_flask_server/target_manager.py index 06948fd5c..d7f3cae80 100644 --- a/src/mock_vws/_flask_server/target_manager.py +++ b/src/mock_vws/_flask_server/target_manager.py @@ -14,7 +14,7 @@ from mock_vws.database import CloudDatabase 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, @@ -220,6 +220,28 @@ def create_target(database_name: str) -> Response: ) +@TARGET_MANAGER_FLASK_APP.route( + rule="/databases//vumark_targets", + methods=[HTTPMethod.POST], +) +@beartype +def create_vumark_target(database_name: str) -> Response: + """Create a new VuMark target in a given database.""" + (database,) = ( + database + for database in TARGET_MANAGER.databases + if database.database_name == database_name + ) + request_json = json.loads(s=request.data) + target = VuMarkTarget.from_dict(target_dict=request_json) + database.vumark_targets.add(target) + + return Response( + response=json.dumps(obj=target.to_dict()), + status=HTTPStatus.CREATED, + ) + + @TARGET_MANAGER_FLASK_APP.route( rule="/databases//targets/", methods={HTTPMethod.DELETE}, diff --git a/src/mock_vws/database.py b/src/mock_vws/database.py index 0fb6876bc..193f364cf 100644 --- a/src/mock_vws/database.py +++ b/src/mock_vws/database.py @@ -9,7 +9,12 @@ from mock_vws._constants import TargetStatuses from mock_vws.states import States -from mock_vws.target import ImageTarget, ImageTargetDict +from mock_vws.target import ( + ImageTarget, + ImageTargetDict, + VuMarkTarget, + VuMarkTargetDict, +) @beartype @@ -23,6 +28,7 @@ class CloudDatabaseDict(TypedDict): client_secret_key: str state_name: str targets: Iterable[ImageTargetDict] + vumark_targets: Iterable[VuMarkTargetDict] @beartype @@ -57,14 +63,18 @@ class CloudDatabase: server_secret_key: str = field(default_factory=_random_hex, repr=False) client_access_key: str = field(default_factory=_random_hex, repr=False) client_secret_key: str = field(default_factory=_random_hex, repr=False) - # We have ``targets`` as ``hash=False`` so that we can have the class as - # ``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. + # We have ``targets`` and ``vumark_targets`` as ``hash=False`` so that we + # can have the class as ``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], hash=False, ) + vumark_targets: set[VuMarkTarget] = field( + default_factory=set[VuMarkTarget], + hash=False, + ) state: States = States.WORKING request_quota: int = 100000 @@ -77,6 +87,7 @@ 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] + vumark_targets = [target.to_dict() for target in self.vumark_targets] return { "database_name": self.database_name, "server_access_key": self.server_access_key, @@ -85,12 +96,15 @@ def to_dict(self) -> CloudDatabaseDict: "client_secret_key": self.client_secret_key, "state_name": self.state.name, "targets": targets, + "vumark_targets": vumark_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 + target + for target in {*self.targets, *self.vumark_targets} + if target.target_id == target_id ) return target @@ -108,47 +122,59 @@ def from_dict(cls, database_dict: CloudDatabaseDict) -> Self: ImageTarget.from_dict(target_dict=target_dict) for target_dict in database_dict["targets"] }, + vumark_targets={ + VuMarkTarget.from_dict(target_dict=target_dict) + for target_dict in database_dict["vumark_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} + return { + target + for target in {*self.targets, *self.vumark_targets} + if not target.delete_date + } @property def active_targets(self) -> set[ImageTarget]: - """All active targets.""" + """All active image targets.""" return { target - for target in self.not_deleted_targets - if target.status == TargetStatuses.SUCCESS.value + for target in self.targets + if not target.delete_date + and target.status == TargetStatuses.SUCCESS.value and target.active_flag } @property def inactive_targets(self) -> set[ImageTarget]: - """All inactive targets.""" + """All inactive image targets.""" return { target - for target in self.not_deleted_targets - if target.status == TargetStatuses.SUCCESS.value + for target in self.targets + if not target.delete_date + and target.status == TargetStatuses.SUCCESS.value and not target.active_flag } @property def failed_targets(self) -> set[ImageTarget]: - """All failed targets.""" + """All failed image targets.""" return { target - for target in self.not_deleted_targets - if target.status == TargetStatuses.FAILED.value + for target in self.targets + if not target.delete_date + and target.status == TargetStatuses.FAILED.value } @property def processing_targets(self) -> set[ImageTarget]: - """All processing targets.""" + """All processing image targets.""" return { target - for target in self.not_deleted_targets - if target.status == TargetStatuses.PROCESSING.value + for target in self.targets + if not target.delete_date + and target.status == TargetStatuses.PROCESSING.value } diff --git a/src/mock_vws/target.py b/src/mock_vws/target.py index a17e8c140..af97373ae 100644 --- a/src/mock_vws/target.py +++ b/src/mock_vws/target.py @@ -19,6 +19,23 @@ ) +class VuMarkTargetDict(TypedDict): + """A dictionary type which represents a VuMark target.""" + + target_id: str + name: str + active_flag: bool + width: float + reco_rating: str + total_recos: int + current_month_recos: int + previous_month_recos: int + delete_date_optional: str | None + last_modified_date: str + upload_date: str + tracking_rating: int + + class ImageTargetDict(TypedDict): """A dictionary type which represents a target.""" @@ -208,3 +225,87 @@ def to_dict(self) -> ImageTargetDict: "upload_date": self.upload_date.isoformat(), "tracking_rating": self.tracking_rating, } + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +@dataclass(frozen=True, eq=True) +class VuMarkTarget: + """ + A VuMark target as managed in + https://developer.vuforia.com/target-manager. + + Unlike ImageTarget, VuMark targets do not require an image — they use a + VuMark template and are always in SUCCESS status. + """ + + name: str + target_id: str = field(default_factory=_random_hex) + active_flag: bool = True + width: float = 1.0 + reco_rating: str = "" + total_recos: int = 0 + current_month_recos: int = 0 + previous_month_recos: int = 0 + tracking_rating: int = 5 + delete_date: datetime.datetime | None = None + last_modified_date: datetime.datetime = field(default_factory=_time_now) + upload_date: datetime.datetime = field(default_factory=_time_now) + + @property + def status(self) -> str: + """VuMark targets are always in SUCCESS status.""" + 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( + target_id=target_dict["target_id"], + name=target_dict["name"], + active_flag=target_dict["active_flag"], + width=target_dict["width"], + reco_rating=target_dict["reco_rating"], + total_recos=target_dict["total_recos"], + current_month_recos=target_dict["current_month_recos"], + previous_month_recos=target_dict["previous_month_recos"], + delete_date=delete_date, + last_modified_date=last_modified_date, + upload_date=upload_date, + tracking_rating=target_dict["tracking_rating"], + ) + + 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 { + "target_id": self.target_id, + "name": self.name, + "active_flag": self.active_flag, + "width": self.width, + "reco_rating": self.reco_rating, + "total_recos": self.total_recos, + "current_month_recos": self.current_month_recos, + "previous_month_recos": self.previous_month_recos, + "delete_date_optional": delete_date, + "last_modified_date": self.last_modified_date.isoformat(), + "upload_date": self.upload_date.isoformat(), + "tracking_rating": self.tracking_rating, + } diff --git a/tests/mock_vws/fixtures/vuforia_backends.py b/tests/mock_vws/fixtures/vuforia_backends.py index 694af4b67..8d159c395 100644 --- a/tests/mock_vws/fixtures/vuforia_backends.py +++ b/tests/mock_vws/fixtures/vuforia_backends.py @@ -21,10 +21,8 @@ from mock_vws._flask_server.vws import VWS_FLASK_APP from mock_vws.database import CloudDatabase 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__) @@ -66,27 +64,18 @@ def _vumark_database( *, vumark_vuforia_database: VuMarkCloudDatabase, ) -> CloudDatabase: - """Return a database with a target for VuMark instance generation.""" - vumark_target = ImageTarget( - active_flag=True, - application_metadata=None, - image_value=make_image_file( - file_format="PNG", - color_space="RGB", - width=8, - height=8, - ).getvalue(), + """Return a database with a VuMark target for VuMark instance + generation. + """ + vumark_target = VuMarkTarget( 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( database_name=vumark_vuforia_database.target_manager_database_name, server_access_key=vumark_vuforia_database.server_access_key, server_secret_key=vumark_vuforia_database.server_secret_key, - targets={vumark_target}, + vumark_targets={vumark_target}, ) @@ -175,7 +164,7 @@ def _enable_use_docker_in_memory( vumark_database = _vumark_database( vumark_vuforia_database=vumark_vuforia_database, ) - (vumark_target,) = vumark_database.targets + (vumark_target,) = vumark_database.vumark_targets with responses.RequestsMock(assert_all_requests_are_fired=False) as mock: add_flask_app_to_mock( @@ -221,7 +210,9 @@ def _enable_use_docker_in_memory( timeout=30, ) requests.post( - url=(f"{databases_url}/{vumark_database.database_name}/targets"), + url=( + f"{databases_url}/{vumark_database.database_name}/vumark_targets" + ), json=vumark_target.to_dict(), timeout=30, ) From 25460711a9c8547ecef9719a64b3a0120c454c82 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 20 Feb 2026 16:14:50 +0000 Subject: [PATCH 02/13] Fix type issues with get_target returning image targets only The validator uses not_deleted_targets (which returns union type) so it can find both image and VuMark targets. But get_target() is only called for image targets in the Flask and requests mock servers, so return ImageTarget only. --- src/mock_vws/database.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/mock_vws/database.py b/src/mock_vws/database.py index 193f364cf..203556a54 100644 --- a/src/mock_vws/database.py +++ b/src/mock_vws/database.py @@ -99,12 +99,10 @@ def to_dict(self) -> CloudDatabaseDict: "vumark_targets": vumark_targets, } - def get_target(self, target_id: str) -> ImageTarget | VuMarkTarget: - """Return a target from the database with the given ID.""" + def get_target(self, target_id: str) -> ImageTarget: + """Return an image target from the database with the given ID.""" (target,) = ( - target - for target in {*self.targets, *self.vumark_targets} - if target.target_id == target_id + target for target in self.targets if target.target_id == target_id ) return target From 2c9bb9062f7b8c6a3cd9b04e927958ec71ea05af Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 20 Feb 2026 16:15:47 +0000 Subject: [PATCH 03/13] Fix mypy type hint for not_deleted_targets union --- src/mock_vws/database.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/mock_vws/database.py b/src/mock_vws/database.py index 203556a54..334677e0e 100644 --- a/src/mock_vws/database.py +++ b/src/mock_vws/database.py @@ -129,11 +129,13 @@ def from_dict(cls, database_dict: CloudDatabaseDict) -> Self: @property def not_deleted_targets(self) -> set[ImageTarget | VuMarkTarget]: """All targets which have not been deleted.""" - return { - target - for target in {*self.targets, *self.vumark_targets} - if not target.delete_date + not_deleted_image = { + target for target in self.targets if not target.delete_date + } + not_deleted_vumark = { + target for target in self.vumark_targets if not target.delete_date } + return not_deleted_image | not_deleted_vumark @property def active_targets(self) -> set[ImageTarget]: From 32d40f5016006a2077c5bf1229c4624e62b6ef89 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 20 Feb 2026 17:07:07 +0000 Subject: [PATCH 04/13] Separate VuMark database from CloudDatabase and minimize VuMarkTarget Introduce VuMarkDatabase and VuMarkDatabaseDict as distinct types from CloudDatabase, since VuMark databases don't have client keys, state, or quota fields. Minimize VuMarkTarget/VuMarkTargetDict to only the fields actually used (target_id, name, delete_date). Update all validators, Flask endpoints, and request mock servers to handle the AnyDatabase union type. Split target manager endpoints by database type: /databases for cloud, /vumark_databases for VuMark. Add typed lookup helpers instead of isinstance guards in endpoint functions. Co-Authored-By: Claude Opus 4.6 --- pyproject.toml | 2 + src/mock_vws/_database_matchers.py | 9 +- src/mock_vws/_flask_server/target_manager.py | 176 ++++++++++++------ src/mock_vws/_flask_server/vwq.py | 6 +- src/mock_vws/_flask_server/vws.py | 35 +++- .../_requests_mock_server/decorators.py | 7 +- .../mock_web_query_api.py | 10 +- .../mock_web_services_api.py | 15 ++ src/mock_vws/_services_validators/__init__.py | 4 +- .../_services_validators/auth_validators.py | 6 +- .../_services_validators/name_validators.py | 6 +- .../project_state_validators.py | 6 +- .../_services_validators/target_validators.py | 4 +- src/mock_vws/database.py | 95 +++++++--- src/mock_vws/target.py | 49 +---- src/mock_vws/target_manager.py | 51 +++-- tests/mock_vws/fixtures/vuforia_backends.py | 23 +-- 17 files changed, 319 insertions(+), 185 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c5a2f5685..de9643bb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -452,6 +452,8 @@ ignore_names = [ # Used in TYPE_CHECKING for type hints "CloudDatabaseDict", "VuMarkDatabaseDict", + # TypedDict fields + "state_name", ] # Duplicate some of .gitignore exclude = [ ".venv" ] diff --git a/src/mock_vws/_database_matchers.py b/src/mock_vws/_database_matchers.py index 0e6a8c76d..b83e9a18b 100644 --- a/src/mock_vws/_database_matchers.py +++ b/src/mock_vws/_database_matchers.py @@ -6,6 +6,7 @@ from vws_auth_tools import authorization_header from mock_vws.database import CloudDatabase +from mock_vws.target_manager import AnyDatabase @beartype @@ -15,7 +16,7 @@ def get_database_matching_client_keys( request_body: bytes | None, request_method: str, request_path: str, - databases: Iterable[CloudDatabase], + databases: Iterable[AnyDatabase], ) -> CloudDatabase: """Return the first of the given databases which is being accessed by the @@ -42,6 +43,8 @@ def get_database_matching_client_keys( date = request_headers_dict.get("Date", "") for database in databases: + if not isinstance(database, CloudDatabase): + continue expected_authorization_header = authorization_header( access_key=database.client_access_key, secret_key=database.client_secret_key, @@ -64,8 +67,8 @@ def get_database_matching_server_keys( request_body: bytes | None, request_method: str, request_path: str, - databases: Iterable[CloudDatabase], -) -> CloudDatabase: + databases: Iterable[AnyDatabase], +) -> AnyDatabase: """Return the first of the given databases which is being accessed by the given server request. diff --git a/src/mock_vws/_flask_server/target_manager.py b/src/mock_vws/_flask_server/target_manager.py index d7f3cae80..3444987e7 100644 --- a/src/mock_vws/_flask_server/target_manager.py +++ b/src/mock_vws/_flask_server/target_manager.py @@ -12,7 +12,7 @@ from flask import Flask, Response, request from pydantic_settings import BaseSettings -from mock_vws.database import CloudDatabase +from mock_vws.database import CloudDatabase, VuMarkDatabase from mock_vws.states import States from mock_vws.target import ImageTarget, VuMarkTarget from mock_vws.target_manager import TargetManager @@ -57,6 +57,30 @@ class TargetManagerSettings(BaseSettings): target_rater: _TargetRaterChoice = _TargetRaterChoice.BRISQUE +@beartype +def _get_cloud_database(database_name: str) -> CloudDatabase: + """Get a cloud database by name.""" + (database,) = ( + database + for database in TARGET_MANAGER.databases + if isinstance(database, CloudDatabase) + and database.database_name == database_name + ) + return database + + +@beartype +def _get_vumark_database(database_name: str) -> VuMarkDatabase: + """Get a VuMark database by name.""" + (database,) = ( + database + for database in TARGET_MANAGER.databases + if isinstance(database, VuMarkDatabase) + and database.database_name == database_name + ) + return database + + @TARGET_MANAGER_FLASK_APP.route( rule="/databases/", methods=[HTTPMethod.DELETE], @@ -82,9 +106,31 @@ def delete_database(database_name: str) -> Response: @TARGET_MANAGER_FLASK_APP.route(rule="/databases", methods=[HTTPMethod.GET]) @beartype -def get_databases() -> Response: - """Return a list of all databases.""" - databases = [database.to_dict() for database in TARGET_MANAGER.databases] +def get_cloud_databases() -> Response: + """Return a list of all cloud databases.""" + databases = [ + database.to_dict() + for database in TARGET_MANAGER.databases + if isinstance(database, CloudDatabase) + ] + return Response( + response=json.dumps(obj=databases), + status=HTTPStatus.OK, + ) + + +@TARGET_MANAGER_FLASK_APP.route( + rule="/vumark_databases", + methods=[HTTPMethod.GET], +) +@beartype +def get_vumark_databases() -> Response: + """Return a list of all VuMark databases.""" + databases = [ + database.to_dict() + for database in TARGET_MANAGER.databases + if isinstance(database, VuMarkDatabase) + ] return Response( response=json.dumps(obj=databases), status=HTTPStatus.OK, @@ -93,8 +139,8 @@ def get_databases() -> Response: @TARGET_MANAGER_FLASK_APP.route(rule="/databases", methods=[HTTPMethod.POST]) @beartype -def create_database() -> Response: - """Create a new database. +def create_cloud_database() -> Response: + """Create a new cloud database. :reqheader Content-Type: application/json :resheader Content-Type: application/json @@ -133,43 +179,75 @@ def create_database() -> Response: :status 201: The database has been successfully created. """ - random_database = CloudDatabase() request_json = json.loads(s=request.data) - server_access_key = request_json.get( - "server_access_key", - random_database.server_access_key, - ) - server_secret_key = request_json.get( - "server_secret_key", - random_database.server_secret_key, - ) - client_access_key = request_json.get( - "client_access_key", - random_database.client_access_key, - ) - client_secret_key = request_json.get( - "client_secret_key", - random_database.client_secret_key, - ) - database_name = request_json.get( - "database_name", - random_database.database_name, + random_database = CloudDatabase() + database = CloudDatabase( + server_access_key=request_json.get( + "server_access_key", + random_database.server_access_key, + ), + server_secret_key=request_json.get( + "server_secret_key", + random_database.server_secret_key, + ), + client_access_key=request_json.get( + "client_access_key", + random_database.client_access_key, + ), + client_secret_key=request_json.get( + "client_secret_key", + random_database.client_secret_key, + ), + database_name=request_json.get( + "database_name", + random_database.database_name, + ), + state=States[ + request_json.get("state_name", random_database.state.name) + ], ) - state_name = request_json.get( - "state_name", - random_database.state.name, + + try: + TARGET_MANAGER.add_database(database=database) + except ValueError as exc: + return Response( + response=str(object=exc), + status=HTTPStatus.CONFLICT, + ) + + return Response( + response=json.dumps(obj=database.to_dict()), + status=HTTPStatus.CREATED, ) - state = States[state_name] - database = CloudDatabase( - server_access_key=server_access_key, - server_secret_key=server_secret_key, - client_access_key=client_access_key, - client_secret_key=client_secret_key, - database_name=database_name, - state=state, +@TARGET_MANAGER_FLASK_APP.route( + rule="/vumark_databases", + methods=[HTTPMethod.POST], +) +@beartype +def create_vumark_database() -> Response: + """Create a new VuMark database. + + :status 201: The database has been successfully created. + """ + request_json = json.loads(s=request.data) + random_vumark_database = VuMarkDatabase() + database = VuMarkDatabase( + server_access_key=request_json.get( + "server_access_key", + random_vumark_database.server_access_key, + ), + server_secret_key=request_json.get( + "server_secret_key", + random_vumark_database.server_secret_key, + ), + database_name=request_json.get( + "database_name", + random_vumark_database.database_name, + ), ) + try: TARGET_MANAGER.add_database(database=database) except ValueError as exc: @@ -191,11 +269,7 @@ def create_database() -> Response: @beartype def create_target(database_name: str) -> Response: """Create a new target in a given database.""" - (database,) = ( - database - for database in TARGET_MANAGER.databases - if database.database_name == database_name - ) + database = _get_cloud_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) @@ -227,11 +301,7 @@ def create_target(database_name: str) -> Response: @beartype def create_vumark_target(database_name: str) -> Response: """Create a new VuMark target in a given database.""" - (database,) = ( - database - for database in TARGET_MANAGER.databases - if database.database_name == database_name - ) + database = _get_vumark_database(database_name=database_name) request_json = json.loads(s=request.data) target = VuMarkTarget.from_dict(target_dict=request_json) database.vumark_targets.add(target) @@ -249,11 +319,7 @@ def create_vumark_target(database_name: str) -> Response: @beartype def delete_target(database_name: str, target_id: str) -> Response: """Delete a target.""" - (database,) = ( - database - for database in TARGET_MANAGER.databases - if database.database_name == database_name - ) + database = _get_cloud_database(database_name=database_name) 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 @@ -275,11 +341,7 @@ def delete_target(database_name: str, target_id: str) -> Response: ) def update_target(database_name: str, target_id: str) -> Response: """Update a target.""" - (database,) = ( - database - for database in TARGET_MANAGER.databases - if database.database_name == database_name - ) + database = _get_cloud_database(database_name=database_name) target = database.get_target(target_id=target_id) request_json = json.loads(s=request.data) diff --git a/src/mock_vws/_flask_server/vwq.py b/src/mock_vws/_flask_server/vwq.py index d9eb1fc43..808ce860e 100644 --- a/src/mock_vws/_flask_server/vwq.py +++ b/src/mock_vws/_flask_server/vwq.py @@ -64,7 +64,11 @@ class VWQSettings(BaseSettings): @beartype def get_all_databases() -> set[CloudDatabase]: - """Get all database objects from the target manager back-end.""" + """Get all cloud database objects from the target manager back-end. + + Only cloud databases are returned because the query API uses client + keys for authentication, which VuMark databases do not have. + """ settings = VWQSettings.model_validate(obj={}) response = requests.get( url=f"{settings.target_manager_base_url}/databases", diff --git a/src/mock_vws/_flask_server/vws.py b/src/mock_vws/_flask_server/vws.py index a6e516842..fd63f9119 100644 --- a/src/mock_vws/_flask_server/vws.py +++ b/src/mock_vws/_flask_server/vws.py @@ -36,13 +36,14 @@ TargetStatusProcessingError, ValidatorError, ) -from mock_vws.database import CloudDatabase +from mock_vws.database import CloudDatabase, VuMarkDatabase from mock_vws.image_matchers import ( ExactMatcher, ImageMatcher, StructuralSimilarityMatcher, ) from mock_vws.target import ImageTarget +from mock_vws.target_manager import AnyDatabase from mock_vws.target_raters import ( HardcodedTargetTrackingRater, ) @@ -86,18 +87,24 @@ class VWSSettings(BaseSettings): @beartype -def get_all_databases() -> set[CloudDatabase]: +def get_all_databases() -> set[AnyDatabase]: """Get all database objects from the task manager back-end.""" settings = VWSSettings.model_validate(obj={}) timeout_seconds = 30 - response = requests.get( + databases: set[AnyDatabase] = set() + cloud_response = requests.get( url=f"{settings.target_manager_base_url}/databases", timeout=timeout_seconds, ) - return { - CloudDatabase.from_dict(database_dict=database_dict) - for database_dict in response.json() - } + for database_dict in cloud_response.json(): + databases.add(CloudDatabase.from_dict(database_dict=database_dict)) + vumark_response = requests.get( + url=f"{settings.target_manager_base_url}/vumark_databases", + timeout=timeout_seconds, + ) + for database_dict in vumark_response.json(): + databases.add(VuMarkDatabase.from_dict(database_dict=database_dict)) + return databases @VWS_FLASK_APP.before_request @@ -180,6 +187,8 @@ def add_target() -> Response: request_path=request.path, databases=databases, ) + if not isinstance(database, CloudDatabase): + raise TypeError # We do not use ``request.get_json(force=True)`` because this only works # when the content type is given as ``application/json``. @@ -253,6 +262,8 @@ def get_target(target_id: str) -> Response: request_path=request.path, databases=databases, ) + if not isinstance(database, CloudDatabase): + raise TypeError (target,) = ( target for target in database.targets if target.target_id == target_id @@ -310,6 +321,8 @@ def delete_target(target_id: str) -> Response: request_path=request.path, databases=databases, ) + if not isinstance(database, CloudDatabase): + raise TypeError (target,) = ( target for target in database.targets if target.target_id == target_id @@ -410,6 +423,8 @@ def database_summary() -> Response: request_path=request.path, databases=databases, ) + if not isinstance(database, CloudDatabase): + raise TypeError body = { "result_code": ResultCodes.SUCCESS.value, @@ -465,6 +480,8 @@ def target_summary(target_id: str) -> Response: request_path=request.path, databases=databases, ) + if not isinstance(database, CloudDatabase): + raise TypeError (target,) = ( target for target in database.targets if target.target_id == target_id @@ -520,6 +537,8 @@ def get_duplicates(target_id: str) -> Response: request_path=request.path, databases=databases, ) + if not isinstance(database, CloudDatabase): + raise TypeError image_match_checker = settings.duplicates_image_matcher.to_image_matcher() (target,) = ( @@ -624,6 +643,8 @@ def update_target(target_id: str) -> Response: request_path=request.path, databases=databases, ) + if not isinstance(database, CloudDatabase): + raise TypeError (target,) = ( target for target in database.targets if target.target_id == target_id diff --git a/src/mock_vws/_requests_mock_server/decorators.py b/src/mock_vws/_requests_mock_server/decorators.py index e13ed84dd..73fb3cff3 100644 --- a/src/mock_vws/_requests_mock_server/decorators.py +++ b/src/mock_vws/_requests_mock_server/decorators.py @@ -12,12 +12,11 @@ from requests import PreparedRequest from responses import RequestsMock -from mock_vws.database import CloudDatabase from mock_vws.image_matchers import ( ImageMatcher, StructuralSimilarityMatcher, ) -from mock_vws.target_manager import TargetManager +from mock_vws.target_manager import AnyDatabase, TargetManager from mock_vws.target_raters import ( BrisqueTargetTrackingRater, TargetTrackingRater, @@ -126,8 +125,8 @@ def __init__( query_match_checker=query_match_checker, ) - def add_database(self, database: CloudDatabase) -> None: - """Add a cloud database. + def add_database(self, database: AnyDatabase) -> None: + """Add a database. Args: database: The database to add. diff --git a/src/mock_vws/_requests_mock_server/mock_web_query_api.py b/src/mock_vws/_requests_mock_server/mock_web_query_api.py index 8206a6d10..d597c8c71 100644 --- a/src/mock_vws/_requests_mock_server/mock_web_query_api.py +++ b/src/mock_vws/_requests_mock_server/mock_web_query_api.py @@ -20,6 +20,7 @@ from mock_vws._query_validators.exceptions import ( ValidatorError, ) +from mock_vws.database import CloudDatabase from mock_vws.image_matchers import ImageMatcher from mock_vws.target_manager import TargetManager @@ -116,13 +117,18 @@ def __init__( @route(path_pattern="/v1/query", http_methods={HTTPMethod.POST}) def query(self, request: PreparedRequest) -> _ResponseType: """Perform an image recognition query.""" + cloud_databases = { + db + for db in self._target_manager.databases + if isinstance(db, CloudDatabase) + } try: run_query_validators( request_path=request.path_url, request_headers=request.headers, request_body=_body_bytes(request=request), request_method=request.method or "", - databases=self._target_manager.databases, + databases=cloud_databases, ) except ValidatorError as exc: return exc.status_code, exc.headers, exc.response_text @@ -132,7 +138,7 @@ def query(self, request: PreparedRequest) -> _ResponseType: request_body=_body_bytes(request=request), request_method=request.method or "", request_path=request.path_url, - databases=self._target_manager.databases, + databases=cloud_databases, query_match_checker=self._query_match_checker, ) 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..239214abd 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 @@ -36,6 +36,7 @@ TargetStatusProcessingError, ValidatorError, ) +from mock_vws.database import CloudDatabase from mock_vws.image_matchers import ImageMatcher from mock_vws.target import ImageTarget from mock_vws.target_manager import TargetManager @@ -176,6 +177,8 @@ def add_target(self, request: PreparedRequest) -> _ResponseType: request_path=request.path_url, databases=self._target_manager.databases, ) + if not isinstance(database, CloudDatabase): + raise TypeError request_json: dict[str, Any] = json.loads(s=request.body or b"") given_active_flag = request_json.get("active_flag") @@ -251,6 +254,8 @@ def delete_target(self, request: PreparedRequest) -> _ResponseType: request_path=request.path_url, databases=self._target_manager.databases, ) + if not isinstance(database, CloudDatabase): + raise TypeError target_id = request.path_url.split(sep="/")[-1] target = database.get_target(target_id=target_id) @@ -372,6 +377,8 @@ def database_summary(self, request: PreparedRequest) -> _ResponseType: request_path=request.path_url, databases=self._target_manager.databases, ) + if not isinstance(database, CloudDatabase): + raise TypeError date = email.utils.formatdate( timeval=None, @@ -490,6 +497,8 @@ def get_target(self, request: PreparedRequest) -> _ResponseType: request_path=request.path_url, databases=self._target_manager.databases, ) + if not isinstance(database, CloudDatabase): + raise TypeError target_id = request.path_url.split(sep="/")[-1] target = database.get_target(target_id=target_id) @@ -556,6 +565,8 @@ def get_duplicates(self, request: PreparedRequest) -> _ResponseType: request_path=request.path_url, databases=self._target_manager.databases, ) + if not isinstance(database, CloudDatabase): + raise TypeError target_id = request.path_url.split(sep="/")[-1] target = database.get_target(target_id=target_id) @@ -627,6 +638,8 @@ def update_target(self, request: PreparedRequest) -> _ResponseType: request_path=request.path_url, databases=self._target_manager.databases, ) + if not isinstance(database, CloudDatabase): + raise TypeError target_id = request.path_url.split(sep="/")[-1] target = database.get_target(target_id=target_id) @@ -740,6 +753,8 @@ def target_summary(self, request: PreparedRequest) -> _ResponseType: request_path=request.path_url, databases=self._target_manager.databases, ) + if not isinstance(database, CloudDatabase): + raise TypeError target_id = request.path_url.split(sep="/")[-1] target = database.get_target(target_id=target_id) diff --git a/src/mock_vws/_services_validators/__init__.py b/src/mock_vws/_services_validators/__init__.py index e37c28d84..4e579f2f1 100644 --- a/src/mock_vws/_services_validators/__init__.py +++ b/src/mock_vws/_services_validators/__init__.py @@ -2,7 +2,7 @@ from collections.abc import Iterable, Mapping -from mock_vws.database import CloudDatabase +from mock_vws.target_manager import AnyDatabase from .active_flag_validators import validate_active_flag from .auth_validators import ( @@ -55,7 +55,7 @@ def run_services_validators( request_headers: Mapping[str, str], request_body: bytes, request_method: str, - databases: Iterable[CloudDatabase], + databases: Iterable[AnyDatabase], ) -> None: """Run all validators. diff --git a/src/mock_vws/_services_validators/auth_validators.py b/src/mock_vws/_services_validators/auth_validators.py index a5922b7ae..8fff81b15 100644 --- a/src/mock_vws/_services_validators/auth_validators.py +++ b/src/mock_vws/_services_validators/auth_validators.py @@ -11,7 +11,7 @@ AuthenticationFailureError, FailError, ) -from mock_vws.database import CloudDatabase +from mock_vws.target_manager import AnyDatabase _LOGGER = logging.getLogger(name=__name__) @@ -36,7 +36,7 @@ def validate_auth_header_exists(*, request_headers: Mapping[str, str]) -> None: def validate_access_key_exists( *, request_headers: Mapping[str, str], - databases: Iterable[CloudDatabase], + databases: Iterable[AnyDatabase], ) -> None: """Validate the authorization header includes an access key for a database. @@ -92,7 +92,7 @@ def validate_authorization( request_headers: Mapping[str, str], request_body: bytes, request_method: str, - databases: Iterable[CloudDatabase], + databases: Iterable[AnyDatabase], ) -> None: """Validate the authorization header given to a VWS endpoint. diff --git a/src/mock_vws/_services_validators/name_validators.py b/src/mock_vws/_services_validators/name_validators.py index 04db14721..7a983e79a 100644 --- a/src/mock_vws/_services_validators/name_validators.py +++ b/src/mock_vws/_services_validators/name_validators.py @@ -12,7 +12,7 @@ FailError, TargetNameExistError, ) -from mock_vws.database import CloudDatabase +from mock_vws.target_manager import AnyDatabase _LOGGER = logging.getLogger(name=__name__) @@ -116,7 +116,7 @@ def validate_name_length(*, request_body: bytes) -> None: @beartype def validate_name_does_not_exist_new_target( *, - databases: Iterable[CloudDatabase], + databases: Iterable[AnyDatabase], request_body: bytes, request_headers: Mapping[str, str], request_method: str, @@ -176,7 +176,7 @@ def validate_name_does_not_exist_existing_target( request_body: bytes, request_method: str, request_path: str, - databases: Iterable[CloudDatabase], + databases: Iterable[AnyDatabase], ) -> None: """Validate that the name does not exist for any existing target apart from diff --git a/src/mock_vws/_services_validators/project_state_validators.py b/src/mock_vws/_services_validators/project_state_validators.py index d4b263392..6f86cdbf1 100644 --- a/src/mock_vws/_services_validators/project_state_validators.py +++ b/src/mock_vws/_services_validators/project_state_validators.py @@ -10,6 +10,7 @@ from mock_vws._services_validators.exceptions import ProjectInactiveError from mock_vws.database import CloudDatabase from mock_vws.states import States +from mock_vws.target_manager import AnyDatabase _LOGGER = logging.getLogger(name=__name__) @@ -21,7 +22,7 @@ def validate_project_state( request_headers: Mapping[str, str], request_body: bytes, request_method: str, - databases: Iterable[CloudDatabase], + databases: Iterable[AnyDatabase], ) -> None: """Validate the state of the project. @@ -44,6 +45,9 @@ def validate_project_state( databases=databases, ) + if not isinstance(database, CloudDatabase): + return + if database.state != States.PROJECT_INACTIVE: return diff --git a/src/mock_vws/_services_validators/target_validators.py b/src/mock_vws/_services_validators/target_validators.py index ca5077ef2..3dae4703c 100644 --- a/src/mock_vws/_services_validators/target_validators.py +++ b/src/mock_vws/_services_validators/target_validators.py @@ -7,7 +7,7 @@ from mock_vws._database_matchers import get_database_matching_server_keys from mock_vws._services_validators.exceptions import UnknownTargetError -from mock_vws.database import CloudDatabase +from mock_vws.target_manager import AnyDatabase _LOGGER = logging.getLogger(name=__name__) _TARGETS_WITH_INSTANCE_PATH_LENGTH = 4 @@ -20,7 +20,7 @@ def validate_target_id_exists( request_headers: Mapping[str, str], request_body: bytes, request_method: str, - databases: Iterable[CloudDatabase], + databases: Iterable[AnyDatabase], ) -> None: """Validate that if a target ID is given, it exists in the database matching the request. diff --git a/src/mock_vws/database.py b/src/mock_vws/database.py index 334677e0e..3b39022d3 100644 --- a/src/mock_vws/database.py +++ b/src/mock_vws/database.py @@ -28,6 +28,15 @@ class CloudDatabaseDict(TypedDict): client_secret_key: str state_name: str targets: Iterable[ImageTargetDict] + + +@beartype +class VuMarkDatabaseDict(TypedDict): + """A dictionary type which represents a VuMark database.""" + + database_name: str + server_access_key: str + server_secret_key: str vumark_targets: Iterable[VuMarkTargetDict] @@ -63,18 +72,14 @@ class CloudDatabase: server_secret_key: str = field(default_factory=_random_hex, repr=False) client_access_key: str = field(default_factory=_random_hex, repr=False) client_secret_key: str = field(default_factory=_random_hex, repr=False) - # We have ``targets`` and ``vumark_targets`` as ``hash=False`` so that we - # can have the class as ``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. + # We have ``targets`` as ``hash=False`` so that we can have the class as + # ``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], hash=False, ) - vumark_targets: set[VuMarkTarget] = field( - default_factory=set[VuMarkTarget], - hash=False, - ) state: States = States.WORKING request_quota: int = 100000 @@ -87,7 +92,6 @@ 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] - vumark_targets = [target.to_dict() for target in self.vumark_targets] return { "database_name": self.database_name, "server_access_key": self.server_access_key, @@ -96,7 +100,6 @@ def to_dict(self) -> CloudDatabaseDict: "client_secret_key": self.client_secret_key, "state_name": self.state.name, "targets": targets, - "vumark_targets": vumark_targets, } def get_target(self, target_id: str) -> ImageTarget: @@ -120,22 +123,12 @@ def from_dict(cls, database_dict: CloudDatabaseDict) -> Self: ImageTarget.from_dict(target_dict=target_dict) for target_dict in database_dict["targets"] }, - vumark_targets={ - VuMarkTarget.from_dict(target_dict=target_dict) - for target_dict in database_dict["vumark_targets"] - }, ) @property - def not_deleted_targets(self) -> set[ImageTarget | VuMarkTarget]: + def not_deleted_targets(self) -> set[ImageTarget]: """All targets which have not been deleted.""" - not_deleted_image = { - target for target in self.targets if not target.delete_date - } - not_deleted_vumark = { - target for target in self.vumark_targets if not target.delete_date - } - return not_deleted_image | not_deleted_vumark + return {target for target in self.targets if not target.delete_date} @property def active_targets(self) -> set[ImageTarget]: @@ -178,3 +171,61 @@ def processing_targets(self) -> set[ImageTarget]: if not target.delete_date and target.status == TargetStatuses.PROCESSING.value } + + +@beartype +@dataclass(eq=True, frozen=True) +class VuMarkDatabase: + """Credentials for the VuMark generation API. + + Args: + database_name: The name of a VWS target manager database name. Defaults + to a random string. + server_access_key: A VWS server access key. Defaults to a random + string. + server_secret_key: A VWS server secret key. Defaults to a random + string. + """ + + database_name: str = field(default_factory=_random_hex, repr=False) + server_access_key: str = field(default_factory=_random_hex, repr=False) + server_secret_key: str = field(default_factory=_random_hex, repr=False) + # We have ``vumark_targets`` as ``hash=False`` so that we can have the + # class as ``frozen=True`` while still being able to keep the interface + # we want. + vumark_targets: set[VuMarkTarget] = field( + default_factory=set[VuMarkTarget], + hash=False, + ) + + def to_dict(self) -> VuMarkDatabaseDict: + """Dump a VuMark database to a dictionary which can be loaded as + JSON. + """ + vumark_targets = [target.to_dict() for target in self.vumark_targets] + return { + "database_name": self.database_name, + "server_access_key": self.server_access_key, + "server_secret_key": self.server_secret_key, + "vumark_targets": vumark_targets, + } + + @classmethod + def from_dict(cls, database_dict: VuMarkDatabaseDict) -> Self: + """Load a VuMark database from a dictionary.""" + return cls( + database_name=database_dict["database_name"], + server_access_key=database_dict["server_access_key"], + server_secret_key=database_dict["server_secret_key"], + vumark_targets={ + VuMarkTarget.from_dict(target_dict=target_dict) + for target_dict in database_dict["vumark_targets"] + }, + ) + + @property + def not_deleted_targets(self) -> set[VuMarkTarget]: + """All VuMark targets which have not been deleted.""" + return { + target for target in self.vumark_targets if not target.delete_date + } diff --git a/src/mock_vws/target.py b/src/mock_vws/target.py index af97373ae..1f4f110c1 100644 --- a/src/mock_vws/target.py +++ b/src/mock_vws/target.py @@ -24,16 +24,7 @@ class VuMarkTargetDict(TypedDict): target_id: str name: str - active_flag: bool - width: float - reco_rating: str - total_recos: int - current_month_recos: int - previous_month_recos: int delete_date_optional: str | None - last_modified_date: str - upload_date: str - tracking_rating: int class ImageTargetDict(TypedDict): @@ -235,26 +226,12 @@ class VuMarkTarget: https://developer.vuforia.com/target-manager. Unlike ImageTarget, VuMark targets do not require an image — they use a - VuMark template and are always in SUCCESS status. + VuMark template. """ name: str target_id: str = field(default_factory=_random_hex) - active_flag: bool = True - width: float = 1.0 - reco_rating: str = "" - total_recos: int = 0 - current_month_recos: int = 0 - previous_month_recos: int = 0 - tracking_rating: int = 5 delete_date: datetime.datetime | None = None - last_modified_date: datetime.datetime = field(default_factory=_time_now) - upload_date: datetime.datetime = field(default_factory=_time_now) - - @property - def status(self) -> str: - """VuMark targets are always in SUCCESS status.""" - return TargetStatuses.SUCCESS.value @classmethod def from_dict(cls, target_dict: VuMarkTargetDict) -> Self: @@ -267,25 +244,10 @@ def from_dict(cls, target_dict: VuMarkTargetDict) -> Self: 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( target_id=target_dict["target_id"], name=target_dict["name"], - active_flag=target_dict["active_flag"], - width=target_dict["width"], - reco_rating=target_dict["reco_rating"], - total_recos=target_dict["total_recos"], - current_month_recos=target_dict["current_month_recos"], - previous_month_recos=target_dict["previous_month_recos"], delete_date=delete_date, - last_modified_date=last_modified_date, - upload_date=upload_date, - tracking_rating=target_dict["tracking_rating"], ) def to_dict(self) -> VuMarkTargetDict: @@ -298,14 +260,5 @@ def to_dict(self) -> VuMarkTargetDict: return { "target_id": self.target_id, "name": self.name, - "active_flag": self.active_flag, - "width": self.width, - "reco_rating": self.reco_rating, - "total_recos": self.total_recos, - "current_month_recos": self.current_month_recos, - "previous_month_recos": self.previous_month_recos, "delete_date_optional": delete_date, - "last_modified_date": self.last_modified_date.isoformat(), - "upload_date": self.upload_date.isoformat(), - "tracking_rating": self.tracking_rating, } diff --git a/src/mock_vws/target_manager.py b/src/mock_vws/target_manager.py index 620564ba6..def83ac61 100644 --- a/src/mock_vws/target_manager.py +++ b/src/mock_vws/target_manager.py @@ -4,11 +4,13 @@ from beartype import beartype -from mock_vws.database import CloudDatabase +from mock_vws.database import CloudDatabase, VuMarkDatabase if TYPE_CHECKING: from collections.abc import Iterable +AnyDatabase = CloudDatabase | VuMarkDatabase + @beartype class TargetManager: @@ -20,21 +22,21 @@ class TargetManager: def __init__(self) -> None: """Create a target manager with no databases.""" - self._databases: Iterable[CloudDatabase] = set() + self._databases: Iterable[AnyDatabase] = set() - def remove_database(self, database: CloudDatabase) -> None: - """Remove a cloud database. + def remove_database(self, database: AnyDatabase) -> None: + """Remove a database. Args: - database: The database to add. + database: The database to remove. Raises: KeyError: The database is not in the target manager. """ self._databases = {db for db in self._databases if db != database} - def add_database(self, database: CloudDatabase) -> None: - """Add a cloud database. + def add_database(self, database: AnyDatabase) -> None: + """Add a database. Args: database: The database to add. @@ -59,16 +61,6 @@ def add_database(self, database: CloudDatabase) -> None: database.server_secret_key, "server secret key", ), - ( - existing_db.client_access_key, - database.client_access_key, - "client access key", - ), - ( - existing_db.client_secret_key, - database.client_secret_key, - "client secret key", - ), ( existing_db.database_name, database.database_name, @@ -79,9 +71,30 @@ def add_database(self, database: CloudDatabase) -> None: message = message_fmt.format(key_name=key_name, value=new) raise ValueError(message) + if isinstance(existing_db, CloudDatabase) and isinstance( + database, CloudDatabase + ): + for existing, new, key_name in ( + ( + existing_db.client_access_key, + database.client_access_key, + "client access key", + ), + ( + existing_db.client_secret_key, + database.client_secret_key, + "client secret key", + ), + ): + if existing == new: + message = message_fmt.format( + key_name=key_name, value=new + ) + raise ValueError(message) + self._databases = {*self._databases, database} @property - def databases(self) -> set[CloudDatabase]: - """All cloud databases.""" + def databases(self) -> set[AnyDatabase]: + """All databases.""" return set(self._databases) diff --git a/tests/mock_vws/fixtures/vuforia_backends.py b/tests/mock_vws/fixtures/vuforia_backends.py index 8d159c395..2b191281b 100644 --- a/tests/mock_vws/fixtures/vuforia_backends.py +++ b/tests/mock_vws/fixtures/vuforia_backends.py @@ -19,7 +19,7 @@ from mock_vws._flask_server.target_manager import TARGET_MANAGER_FLASK_APP 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 import CloudDatabase, VuMarkDatabase from mock_vws.states import States from mock_vws.target import VuMarkTarget from tests.mock_vws.fixtures.credentials import VuMarkCloudDatabase @@ -63,7 +63,7 @@ def _delete_all_targets(*, database_keys: CloudDatabase) -> None: def _vumark_database( *, vumark_vuforia_database: VuMarkCloudDatabase, -) -> CloudDatabase: +) -> VuMarkDatabase: """Return a database with a VuMark target for VuMark instance generation. """ @@ -71,7 +71,7 @@ def _vumark_database( name="mock-vumark-target", target_id=vumark_vuforia_database.target_id, ) - return CloudDatabase( + return VuMarkDatabase( database_name=vumark_vuforia_database.target_manager_database_name, server_access_key=vumark_vuforia_database.server_access_key, server_secret_key=vumark_vuforia_database.server_secret_key, @@ -186,13 +186,14 @@ def _enable_use_docker_in_memory( ) databases_url = target_manager_base_url + "/databases" - databases = requests.get(url=databases_url, timeout=30).json() - for database in databases: - database_name = database["database_name"] - requests.delete( - url=databases_url + "/" + database_name, - timeout=30, - ) + vumark_databases_url = target_manager_base_url + "/vumark_databases" + + for url in (databases_url, vumark_databases_url): + for database in requests.get(url=url, timeout=30).json(): + requests.delete( + url=databases_url + "/" + database["database_name"], + timeout=30, + ) requests.post( url=databases_url, @@ -205,7 +206,7 @@ def _enable_use_docker_in_memory( timeout=30, ) requests.post( - url=databases_url, + url=vumark_databases_url, json=vumark_database.to_dict(), timeout=30, ) From 949599f141121ef5a70c8d410fd3bcc0a1e06b7d Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 20 Feb 2026 17:15:28 +0000 Subject: [PATCH 05/13] Fix Sphinx docs reference to renamed endpoint The create_database endpoint was renamed to create_cloud_database but the docs/source/docker.rst autoflask directive was not updated. Co-Authored-By: Claude Opus 4.6 --- docs/source/docker.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/docker.rst b/docs/source/docker.rst index e46c5d146..f77a811d6 100644 --- a/docs/source/docker.rst +++ b/docs/source/docker.rst @@ -54,7 +54,7 @@ To mimic this functionality, this mock provides a target manager container which To add a database, make a request to the following endpoint against the target manager container: .. autoflask:: mock_vws._flask_server.target_manager:TARGET_MANAGER_FLASK_APP - :endpoints: create_database + :endpoints: create_cloud_database For example, with the containers set up as in :ref:`creating-containers`, use ``curl``: From c00b1c29f48d7b23d8082250a709b1d6cb354678 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 20 Feb 2026 17:58:29 +0000 Subject: [PATCH 06/13] Split TargetManager.databases into typed cloud_databases and vumark_databases This eliminates 22 isinstance checks by storing each database type separately and narrowing all downstream signatures to the specific type they need (CloudDatabase for VWS/VWQ APIs and validators). Co-Authored-By: Claude Opus 4.6 --- src/mock_vws/_database_matchers.py | 9 +-- src/mock_vws/_flask_server/target_manager.py | 28 +++++----- src/mock_vws/_flask_server/vws.py | 40 +++----------- .../mock_web_query_api.py | 7 +-- .../mock_web_services_api.py | 49 ++++++----------- src/mock_vws/_services_validators/__init__.py | 4 +- .../_services_validators/auth_validators.py | 6 +- .../_services_validators/name_validators.py | 6 +- .../project_state_validators.py | 6 +- .../_services_validators/target_validators.py | 4 +- src/mock_vws/target_manager.py | 55 +++++++++++-------- 11 files changed, 86 insertions(+), 128 deletions(-) diff --git a/src/mock_vws/_database_matchers.py b/src/mock_vws/_database_matchers.py index b83e9a18b..0e6a8c76d 100644 --- a/src/mock_vws/_database_matchers.py +++ b/src/mock_vws/_database_matchers.py @@ -6,7 +6,6 @@ from vws_auth_tools import authorization_header from mock_vws.database import CloudDatabase -from mock_vws.target_manager import AnyDatabase @beartype @@ -16,7 +15,7 @@ def get_database_matching_client_keys( request_body: bytes | None, request_method: str, request_path: str, - databases: Iterable[AnyDatabase], + databases: Iterable[CloudDatabase], ) -> CloudDatabase: """Return the first of the given databases which is being accessed by the @@ -43,8 +42,6 @@ def get_database_matching_client_keys( date = request_headers_dict.get("Date", "") for database in databases: - if not isinstance(database, CloudDatabase): - continue expected_authorization_header = authorization_header( access_key=database.client_access_key, secret_key=database.client_secret_key, @@ -67,8 +64,8 @@ def get_database_matching_server_keys( request_body: bytes | None, request_method: str, request_path: str, - databases: Iterable[AnyDatabase], -) -> AnyDatabase: + databases: Iterable[CloudDatabase], +) -> CloudDatabase: """Return the first of the given databases which is being accessed by the given server request. diff --git a/src/mock_vws/_flask_server/target_manager.py b/src/mock_vws/_flask_server/target_manager.py index 3444987e7..47f423ba3 100644 --- a/src/mock_vws/_flask_server/target_manager.py +++ b/src/mock_vws/_flask_server/target_manager.py @@ -62,9 +62,8 @@ def _get_cloud_database(database_name: str) -> CloudDatabase: """Get a cloud database by name.""" (database,) = ( database - for database in TARGET_MANAGER.databases - if isinstance(database, CloudDatabase) - and database.database_name == database_name + for database in TARGET_MANAGER.cloud_databases + if database.database_name == database_name ) return database @@ -74,9 +73,8 @@ def _get_vumark_database(database_name: str) -> VuMarkDatabase: """Get a VuMark database by name.""" (database,) = ( database - for database in TARGET_MANAGER.databases - if isinstance(database, VuMarkDatabase) - and database.database_name == database_name + for database in TARGET_MANAGER.vumark_databases + if database.database_name == database_name ) return database @@ -91,12 +89,16 @@ def delete_database(database_name: str) -> Response: :status 200: The database has been deleted. """ + all_databases: list[CloudDatabase | VuMarkDatabase] = [ + *TARGET_MANAGER.cloud_databases, + *TARGET_MANAGER.vumark_databases, + ] try: - (matching_database,) = { + (matching_database,) = [ database - for database in TARGET_MANAGER.databases + for database in all_databases if database_name == database.database_name - } + ] except ValueError: return Response(response="", status=HTTPStatus.NOT_FOUND) @@ -109,9 +111,7 @@ def delete_database(database_name: str) -> Response: def get_cloud_databases() -> Response: """Return a list of all cloud databases.""" databases = [ - database.to_dict() - for database in TARGET_MANAGER.databases - if isinstance(database, CloudDatabase) + database.to_dict() for database in TARGET_MANAGER.cloud_databases ] return Response( response=json.dumps(obj=databases), @@ -127,9 +127,7 @@ def get_cloud_databases() -> Response: def get_vumark_databases() -> Response: """Return a list of all VuMark databases.""" databases = [ - database.to_dict() - for database in TARGET_MANAGER.databases - if isinstance(database, VuMarkDatabase) + database.to_dict() for database in TARGET_MANAGER.vumark_databases ] return Response( response=json.dumps(obj=databases), diff --git a/src/mock_vws/_flask_server/vws.py b/src/mock_vws/_flask_server/vws.py index fd63f9119..f1ad9fc61 100644 --- a/src/mock_vws/_flask_server/vws.py +++ b/src/mock_vws/_flask_server/vws.py @@ -36,14 +36,13 @@ TargetStatusProcessingError, ValidatorError, ) -from mock_vws.database import CloudDatabase, VuMarkDatabase +from mock_vws.database import CloudDatabase from mock_vws.image_matchers import ( ExactMatcher, ImageMatcher, StructuralSimilarityMatcher, ) from mock_vws.target import ImageTarget -from mock_vws.target_manager import AnyDatabase from mock_vws.target_raters import ( HardcodedTargetTrackingRater, ) @@ -87,24 +86,17 @@ class VWSSettings(BaseSettings): @beartype -def get_all_databases() -> set[AnyDatabase]: - """Get all database objects from the task manager back-end.""" +def get_all_databases() -> set[CloudDatabase]: + """Get all cloud database objects from the target manager back-end.""" settings = VWSSettings.model_validate(obj={}) - timeout_seconds = 30 - databases: set[AnyDatabase] = set() - cloud_response = requests.get( + response = requests.get( url=f"{settings.target_manager_base_url}/databases", - timeout=timeout_seconds, - ) - for database_dict in cloud_response.json(): - databases.add(CloudDatabase.from_dict(database_dict=database_dict)) - vumark_response = requests.get( - url=f"{settings.target_manager_base_url}/vumark_databases", - timeout=timeout_seconds, + timeout=30, ) - for database_dict in vumark_response.json(): - databases.add(VuMarkDatabase.from_dict(database_dict=database_dict)) - return databases + return { + CloudDatabase.from_dict(database_dict=database_dict) + for database_dict in response.json() + } @VWS_FLASK_APP.before_request @@ -187,8 +179,6 @@ def add_target() -> Response: request_path=request.path, databases=databases, ) - if not isinstance(database, CloudDatabase): - raise TypeError # We do not use ``request.get_json(force=True)`` because this only works # when the content type is given as ``application/json``. @@ -262,8 +252,6 @@ def get_target(target_id: str) -> Response: request_path=request.path, databases=databases, ) - if not isinstance(database, CloudDatabase): - raise TypeError (target,) = ( target for target in database.targets if target.target_id == target_id @@ -321,8 +309,6 @@ def delete_target(target_id: str) -> Response: request_path=request.path, databases=databases, ) - if not isinstance(database, CloudDatabase): - raise TypeError (target,) = ( target for target in database.targets if target.target_id == target_id @@ -423,8 +409,6 @@ def database_summary() -> Response: request_path=request.path, databases=databases, ) - if not isinstance(database, CloudDatabase): - raise TypeError body = { "result_code": ResultCodes.SUCCESS.value, @@ -480,8 +464,6 @@ def target_summary(target_id: str) -> Response: request_path=request.path, databases=databases, ) - if not isinstance(database, CloudDatabase): - raise TypeError (target,) = ( target for target in database.targets if target.target_id == target_id @@ -537,8 +519,6 @@ def get_duplicates(target_id: str) -> Response: request_path=request.path, databases=databases, ) - if not isinstance(database, CloudDatabase): - raise TypeError image_match_checker = settings.duplicates_image_matcher.to_image_matcher() (target,) = ( @@ -643,8 +623,6 @@ def update_target(target_id: str) -> Response: request_path=request.path, databases=databases, ) - if not isinstance(database, CloudDatabase): - raise TypeError (target,) = ( target for target in database.targets if target.target_id == target_id diff --git a/src/mock_vws/_requests_mock_server/mock_web_query_api.py b/src/mock_vws/_requests_mock_server/mock_web_query_api.py index d597c8c71..4fecd1da2 100644 --- a/src/mock_vws/_requests_mock_server/mock_web_query_api.py +++ b/src/mock_vws/_requests_mock_server/mock_web_query_api.py @@ -20,7 +20,6 @@ from mock_vws._query_validators.exceptions import ( ValidatorError, ) -from mock_vws.database import CloudDatabase from mock_vws.image_matchers import ImageMatcher from mock_vws.target_manager import TargetManager @@ -117,11 +116,7 @@ def __init__( @route(path_pattern="/v1/query", http_methods={HTTPMethod.POST}) def query(self, request: PreparedRequest) -> _ResponseType: """Perform an image recognition query.""" - cloud_databases = { - db - for db in self._target_manager.databases - if isinstance(db, CloudDatabase) - } + cloud_databases = self._target_manager.cloud_databases try: run_query_validators( request_path=request.path_url, 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 239214abd..bbd782c24 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 @@ -36,7 +36,6 @@ TargetStatusProcessingError, ValidatorError, ) -from mock_vws.database import CloudDatabase from mock_vws.image_matchers import ImageMatcher from mock_vws.target import ImageTarget from mock_vws.target_manager import TargetManager @@ -165,7 +164,7 @@ def add_target(self, request: PreparedRequest) -> _ResponseType: request_body=_body_bytes(request=request), request_method=request.method or "", request_path=request.path_url, - databases=self._target_manager.databases, + databases=self._target_manager.cloud_databases, ) except ValidatorError as exc: return exc.status_code, exc.headers, exc.response_text @@ -175,10 +174,8 @@ def add_target(self, request: PreparedRequest) -> _ResponseType: request_body=_body_bytes(request=request), request_method=request.method or "", request_path=request.path_url, - databases=self._target_manager.databases, + databases=self._target_manager.cloud_databases, ) - if not isinstance(database, CloudDatabase): - raise TypeError request_json: dict[str, Any] = json.loads(s=request.body or b"") given_active_flag = request_json.get("active_flag") @@ -242,7 +239,7 @@ def delete_target(self, request: PreparedRequest) -> _ResponseType: request_body=_body_bytes(request=request), request_method=request.method or "", request_path=request.path_url, - databases=self._target_manager.databases, + databases=self._target_manager.cloud_databases, ) except ValidatorError as exc: return exc.status_code, exc.headers, exc.response_text @@ -252,10 +249,8 @@ def delete_target(self, request: PreparedRequest) -> _ResponseType: request_body=_body_bytes(request=request), request_method=request.method or "", request_path=request.path_url, - databases=self._target_manager.databases, + databases=self._target_manager.cloud_databases, ) - if not isinstance(database, CloudDatabase): - raise TypeError target_id = request.path_url.split(sep="/")[-1] target = database.get_target(target_id=target_id) @@ -319,7 +314,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=self._target_manager.cloud_databases, ) accept = dict(request.headers).get("Accept", "") @@ -365,7 +360,7 @@ def database_summary(self, request: PreparedRequest) -> _ResponseType: request_body=_body_bytes(request=request), request_method=request.method or "", request_path=request.path_url, - databases=self._target_manager.databases, + databases=self._target_manager.cloud_databases, ) except ValidatorError as exc: return exc.status_code, exc.headers, exc.response_text @@ -375,10 +370,8 @@ def database_summary(self, request: PreparedRequest) -> _ResponseType: request_body=_body_bytes(request=request), request_method=request.method or "", request_path=request.path_url, - databases=self._target_manager.databases, + databases=self._target_manager.cloud_databases, ) - if not isinstance(database, CloudDatabase): - raise TypeError date = email.utils.formatdate( timeval=None, @@ -428,7 +421,7 @@ def target_list(self, request: PreparedRequest) -> _ResponseType: request_body=_body_bytes(request=request), request_method=request.method or "", request_path=request.path_url, - databases=self._target_manager.databases, + databases=self._target_manager.cloud_databases, ) except ValidatorError as exc: return exc.status_code, exc.headers, exc.response_text @@ -438,7 +431,7 @@ def target_list(self, request: PreparedRequest) -> _ResponseType: request_body=_body_bytes(request=request), request_method=request.method or "", request_path=request.path_url, - databases=self._target_manager.databases, + databases=self._target_manager.cloud_databases, ) date = email.utils.formatdate( @@ -485,7 +478,7 @@ def get_target(self, request: PreparedRequest) -> _ResponseType: request_body=_body_bytes(request=request), request_method=request.method or "", request_path=request.path_url, - databases=self._target_manager.databases, + databases=self._target_manager.cloud_databases, ) except ValidatorError as exc: return exc.status_code, exc.headers, exc.response_text @@ -495,10 +488,8 @@ def get_target(self, request: PreparedRequest) -> _ResponseType: request_body=_body_bytes(request=request), request_method=request.method or "", request_path=request.path_url, - databases=self._target_manager.databases, + databases=self._target_manager.cloud_databases, ) - if not isinstance(database, CloudDatabase): - raise TypeError target_id = request.path_url.split(sep="/")[-1] target = database.get_target(target_id=target_id) @@ -553,7 +544,7 @@ def get_duplicates(self, request: PreparedRequest) -> _ResponseType: request_body=_body_bytes(request=request), request_method=request.method or "", request_path=request.path_url, - databases=self._target_manager.databases, + databases=self._target_manager.cloud_databases, ) except ValidatorError as exc: return exc.status_code, exc.headers, exc.response_text @@ -563,10 +554,8 @@ def get_duplicates(self, request: PreparedRequest) -> _ResponseType: request_body=_body_bytes(request=request), request_method=request.method or "", request_path=request.path_url, - databases=self._target_manager.databases, + databases=self._target_manager.cloud_databases, ) - if not isinstance(database, CloudDatabase): - raise TypeError target_id = request.path_url.split(sep="/")[-1] target = database.get_target(target_id=target_id) @@ -626,7 +615,7 @@ def update_target(self, request: PreparedRequest) -> _ResponseType: request_body=_body_bytes(request=request), request_method=request.method or "", request_path=request.path_url, - databases=self._target_manager.databases, + databases=self._target_manager.cloud_databases, ) except ValidatorError as exc: return exc.status_code, exc.headers, exc.response_text @@ -636,10 +625,8 @@ def update_target(self, request: PreparedRequest) -> _ResponseType: request_body=_body_bytes(request=request), request_method=request.method or "", request_path=request.path_url, - databases=self._target_manager.databases, + databases=self._target_manager.cloud_databases, ) - if not isinstance(database, CloudDatabase): - raise TypeError target_id = request.path_url.split(sep="/")[-1] target = database.get_target(target_id=target_id) @@ -741,7 +728,7 @@ def target_summary(self, request: PreparedRequest) -> _ResponseType: request_body=_body_bytes(request=request), request_method=request.method or "", request_path=request.path_url, - databases=self._target_manager.databases, + databases=self._target_manager.cloud_databases, ) except ValidatorError as exc: return exc.status_code, exc.headers, exc.response_text @@ -751,10 +738,8 @@ def target_summary(self, request: PreparedRequest) -> _ResponseType: request_body=_body_bytes(request=request), request_method=request.method or "", request_path=request.path_url, - databases=self._target_manager.databases, + databases=self._target_manager.cloud_databases, ) - if not isinstance(database, CloudDatabase): - raise TypeError target_id = request.path_url.split(sep="/")[-1] target = database.get_target(target_id=target_id) diff --git a/src/mock_vws/_services_validators/__init__.py b/src/mock_vws/_services_validators/__init__.py index 4e579f2f1..e37c28d84 100644 --- a/src/mock_vws/_services_validators/__init__.py +++ b/src/mock_vws/_services_validators/__init__.py @@ -2,7 +2,7 @@ from collections.abc import Iterable, Mapping -from mock_vws.target_manager import AnyDatabase +from mock_vws.database import CloudDatabase from .active_flag_validators import validate_active_flag from .auth_validators import ( @@ -55,7 +55,7 @@ def run_services_validators( request_headers: Mapping[str, str], request_body: bytes, request_method: str, - databases: Iterable[AnyDatabase], + databases: Iterable[CloudDatabase], ) -> None: """Run all validators. diff --git a/src/mock_vws/_services_validators/auth_validators.py b/src/mock_vws/_services_validators/auth_validators.py index 8fff81b15..a5922b7ae 100644 --- a/src/mock_vws/_services_validators/auth_validators.py +++ b/src/mock_vws/_services_validators/auth_validators.py @@ -11,7 +11,7 @@ AuthenticationFailureError, FailError, ) -from mock_vws.target_manager import AnyDatabase +from mock_vws.database import CloudDatabase _LOGGER = logging.getLogger(name=__name__) @@ -36,7 +36,7 @@ def validate_auth_header_exists(*, request_headers: Mapping[str, str]) -> None: def validate_access_key_exists( *, request_headers: Mapping[str, str], - databases: Iterable[AnyDatabase], + databases: Iterable[CloudDatabase], ) -> None: """Validate the authorization header includes an access key for a database. @@ -92,7 +92,7 @@ def validate_authorization( request_headers: Mapping[str, str], request_body: bytes, request_method: str, - databases: Iterable[AnyDatabase], + databases: Iterable[CloudDatabase], ) -> None: """Validate the authorization header given to a VWS endpoint. diff --git a/src/mock_vws/_services_validators/name_validators.py b/src/mock_vws/_services_validators/name_validators.py index 7a983e79a..04db14721 100644 --- a/src/mock_vws/_services_validators/name_validators.py +++ b/src/mock_vws/_services_validators/name_validators.py @@ -12,7 +12,7 @@ FailError, TargetNameExistError, ) -from mock_vws.target_manager import AnyDatabase +from mock_vws.database import CloudDatabase _LOGGER = logging.getLogger(name=__name__) @@ -116,7 +116,7 @@ def validate_name_length(*, request_body: bytes) -> None: @beartype def validate_name_does_not_exist_new_target( *, - databases: Iterable[AnyDatabase], + databases: Iterable[CloudDatabase], request_body: bytes, request_headers: Mapping[str, str], request_method: str, @@ -176,7 +176,7 @@ def validate_name_does_not_exist_existing_target( request_body: bytes, request_method: str, request_path: str, - databases: Iterable[AnyDatabase], + databases: Iterable[CloudDatabase], ) -> None: """Validate that the name does not exist for any existing target apart from diff --git a/src/mock_vws/_services_validators/project_state_validators.py b/src/mock_vws/_services_validators/project_state_validators.py index 6f86cdbf1..d4b263392 100644 --- a/src/mock_vws/_services_validators/project_state_validators.py +++ b/src/mock_vws/_services_validators/project_state_validators.py @@ -10,7 +10,6 @@ from mock_vws._services_validators.exceptions import ProjectInactiveError from mock_vws.database import CloudDatabase from mock_vws.states import States -from mock_vws.target_manager import AnyDatabase _LOGGER = logging.getLogger(name=__name__) @@ -22,7 +21,7 @@ def validate_project_state( request_headers: Mapping[str, str], request_body: bytes, request_method: str, - databases: Iterable[AnyDatabase], + databases: Iterable[CloudDatabase], ) -> None: """Validate the state of the project. @@ -45,9 +44,6 @@ def validate_project_state( databases=databases, ) - if not isinstance(database, CloudDatabase): - return - if database.state != States.PROJECT_INACTIVE: return diff --git a/src/mock_vws/_services_validators/target_validators.py b/src/mock_vws/_services_validators/target_validators.py index 3dae4703c..ca5077ef2 100644 --- a/src/mock_vws/_services_validators/target_validators.py +++ b/src/mock_vws/_services_validators/target_validators.py @@ -7,7 +7,7 @@ from mock_vws._database_matchers import get_database_matching_server_keys from mock_vws._services_validators.exceptions import UnknownTargetError -from mock_vws.target_manager import AnyDatabase +from mock_vws.database import CloudDatabase _LOGGER = logging.getLogger(name=__name__) _TARGETS_WITH_INSTANCE_PATH_LENGTH = 4 @@ -20,7 +20,7 @@ def validate_target_id_exists( request_headers: Mapping[str, str], request_body: bytes, request_method: str, - databases: Iterable[AnyDatabase], + databases: Iterable[CloudDatabase], ) -> None: """Validate that if a target ID is given, it exists in the database matching the request. diff --git a/src/mock_vws/target_manager.py b/src/mock_vws/target_manager.py index def83ac61..facaf20fd 100644 --- a/src/mock_vws/target_manager.py +++ b/src/mock_vws/target_manager.py @@ -1,14 +1,9 @@ """A fake implementation of a Vuforia target manager.""" -from typing import TYPE_CHECKING - from beartype import beartype from mock_vws.database import CloudDatabase, VuMarkDatabase -if TYPE_CHECKING: - from collections.abc import Iterable - AnyDatabase = CloudDatabase | VuMarkDatabase @@ -22,18 +17,33 @@ class TargetManager: def __init__(self) -> None: """Create a target manager with no databases.""" - self._databases: Iterable[AnyDatabase] = set() + self._cloud_databases: set[CloudDatabase] = set() + self._vumark_databases: set[VuMarkDatabase] = set() + + @property + def cloud_databases(self) -> set[CloudDatabase]: + """All cloud databases.""" + return set(self._cloud_databases) + + @property + def vumark_databases(self) -> set[VuMarkDatabase]: + """All VuMark databases.""" + return set(self._vumark_databases) def remove_database(self, database: AnyDatabase) -> None: """Remove a database. Args: database: The database to remove. - - Raises: - KeyError: The database is not in the target manager. """ - self._databases = {db for db in self._databases if db != database} + if isinstance(database, CloudDatabase): + self._cloud_databases = { + db for db in self._cloud_databases if db != database + } + else: + self._vumark_databases = { + db for db in self._vumark_databases if db != database + } def add_database(self, database: AnyDatabase) -> None: """Add a database. @@ -49,7 +59,11 @@ def add_database(self, database: AnyDatabase) -> None: "All {key_name}s must be unique. " 'There is already a database with the {key_name} "{value}".' ) - for existing_db in self.databases: + all_databases: list[AnyDatabase] = [ + *self._cloud_databases, + *self._vumark_databases, + ] + for existing_db in all_databases: for existing, new, key_name in ( ( existing_db.server_access_key, @@ -71,17 +85,16 @@ def add_database(self, database: AnyDatabase) -> None: message = message_fmt.format(key_name=key_name, value=new) raise ValueError(message) - if isinstance(existing_db, CloudDatabase) and isinstance( - database, CloudDatabase - ): + if isinstance(database, CloudDatabase): + for existing_cloud_db in self._cloud_databases: for existing, new, key_name in ( ( - existing_db.client_access_key, + existing_cloud_db.client_access_key, database.client_access_key, "client access key", ), ( - existing_db.client_secret_key, + existing_cloud_db.client_secret_key, database.client_secret_key, "client secret key", ), @@ -91,10 +104,6 @@ def add_database(self, database: AnyDatabase) -> None: key_name=key_name, value=new ) raise ValueError(message) - - self._databases = {*self._databases, database} - - @property - def databases(self) -> set[AnyDatabase]: - """All databases.""" - return set(self._databases) + self._cloud_databases = {*self._cloud_databases, database} + else: + self._vumark_databases = {*self._vumark_databases, database} From 1e110ebd94c39937b8bc4eb2349ecdd6742a2049 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 20 Feb 2026 18:46:22 +0000 Subject: [PATCH 07/13] Revert unnecessary changes to cloud-specific functions Restore cloud functions to match main exactly - only VuMark additions remain. Co-Authored-By: Claude Opus 4.6 --- src/mock_vws/_flask_server/target_manager.py | 121 ++++++++++-------- src/mock_vws/_flask_server/vws.py | 5 +- .../mock_web_query_api.py | 5 +- 3 files changed, 71 insertions(+), 60 deletions(-) diff --git a/src/mock_vws/_flask_server/target_manager.py b/src/mock_vws/_flask_server/target_manager.py index d3fe46bdc..98aafe464 100644 --- a/src/mock_vws/_flask_server/target_manager.py +++ b/src/mock_vws/_flask_server/target_manager.py @@ -57,28 +57,6 @@ class TargetManagerSettings(BaseSettings): target_rater: _TargetRaterChoice = _TargetRaterChoice.BRISQUE -@beartype -def _get_cloud_database(database_name: str) -> CloudDatabase: - """Get a cloud database by name.""" - (database,) = ( - database - for database in TARGET_MANAGER.cloud_databases - if database.database_name == database_name - ) - return database - - -@beartype -def _get_vumark_database(database_name: str) -> VuMarkDatabase: - """Get a VuMark database by name.""" - (database,) = ( - database - for database in TARGET_MANAGER.vumark_databases - if database.database_name == database_name - ) - return database - - @TARGET_MANAGER_FLASK_APP.route( rule="/cloud_databases/", methods=[HTTPMethod.DELETE], @@ -90,11 +68,15 @@ def delete_cloud_database(database_name: str) -> Response: :status 200: The cloud database has been deleted. """ try: - database = _get_cloud_database(database_name=database_name) + (matching_database,) = { + database + for database in TARGET_MANAGER.cloud_databases + if database_name == database.database_name + } except ValueError: return Response(response="", status=HTTPStatus.NOT_FOUND) - TARGET_MANAGER.remove_cloud_database(cloud_database=database) + TARGET_MANAGER.remove_cloud_database(cloud_database=matching_database) return Response(response="", status=HTTPStatus.OK) @@ -109,11 +91,15 @@ def delete_vumark_database(database_name: str) -> Response: :status 200: The VuMark database has been deleted. """ try: - database = _get_vumark_database(database_name=database_name) + (matching_database,) = { + database + for database in TARGET_MANAGER.vumark_databases + if database_name == database.database_name + } except ValueError: return Response(response="", status=HTTPStatus.NOT_FOUND) - TARGET_MANAGER.remove_vumark_database(vumark_database=database) + TARGET_MANAGER.remove_vumark_database(vumark_database=matching_database) return Response(response="", status=HTTPStatus.OK) @@ -196,34 +182,43 @@ def create_cloud_database() -> Response: :status 201: The cloud database has been successfully created. """ - request_json = json.loads(s=request.data) random_database = CloudDatabase() - database = CloudDatabase( - server_access_key=request_json.get( - "server_access_key", - random_database.server_access_key, - ), - server_secret_key=request_json.get( - "server_secret_key", - random_database.server_secret_key, - ), - client_access_key=request_json.get( - "client_access_key", - random_database.client_access_key, - ), - client_secret_key=request_json.get( - "client_secret_key", - random_database.client_secret_key, - ), - database_name=request_json.get( - "database_name", - random_database.database_name, - ), - state=States[ - request_json.get("state_name", random_database.state.name) - ], + request_json = json.loads(s=request.data) + server_access_key = request_json.get( + "server_access_key", + random_database.server_access_key, + ) + server_secret_key = request_json.get( + "server_secret_key", + random_database.server_secret_key, + ) + client_access_key = request_json.get( + "client_access_key", + random_database.client_access_key, + ) + client_secret_key = request_json.get( + "client_secret_key", + random_database.client_secret_key, + ) + database_name = request_json.get( + "database_name", + random_database.database_name, + ) + state_name = request_json.get( + "state_name", + random_database.state.name, ) + state = States[state_name] + + database = CloudDatabase( + server_access_key=server_access_key, + server_secret_key=server_secret_key, + client_access_key=client_access_key, + client_secret_key=client_secret_key, + database_name=database_name, + state=state, + ) try: TARGET_MANAGER.add_cloud_database(cloud_database=database) except ValueError as exc: @@ -286,7 +281,11 @@ def create_vumark_database() -> Response: @beartype def create_target(database_name: str) -> Response: """Create a new target in a given cloud database.""" - database = _get_cloud_database(database_name=database_name) + (database,) = ( + database + for database in TARGET_MANAGER.cloud_databases + 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) @@ -318,7 +317,11 @@ def create_target(database_name: str) -> Response: @beartype def create_vumark_target(database_name: str) -> Response: """Create a new VuMark target in a given database.""" - database = _get_vumark_database(database_name=database_name) + (database,) = ( + database + for database in TARGET_MANAGER.vumark_databases + if database.database_name == database_name + ) request_json = json.loads(s=request.data) target = VuMarkTarget.from_dict(target_dict=request_json) database.vumark_targets.add(target) @@ -336,7 +339,11 @@ def create_vumark_target(database_name: str) -> Response: @beartype def delete_target(database_name: str, target_id: str) -> Response: """Delete a target.""" - database = _get_cloud_database(database_name=database_name) + (database,) = ( + database + for database in TARGET_MANAGER.cloud_databases + if database.database_name == database_name + ) 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 @@ -358,7 +365,11 @@ def delete_target(database_name: str, target_id: str) -> Response: ) def update_target(database_name: str, target_id: str) -> Response: """Update a target.""" - database = _get_cloud_database(database_name=database_name) + (database,) = ( + database + for database in TARGET_MANAGER.cloud_databases + if database.database_name == database_name + ) target = database.get_target(target_id=target_id) request_json = json.loads(s=request.data) diff --git a/src/mock_vws/_flask_server/vws.py b/src/mock_vws/_flask_server/vws.py index 620dd3e91..a11cc5a69 100644 --- a/src/mock_vws/_flask_server/vws.py +++ b/src/mock_vws/_flask_server/vws.py @@ -87,11 +87,12 @@ class VWSSettings(BaseSettings): @beartype def get_all_cloud_databases() -> set[CloudDatabase]: - """Get all cloud database objects from the target manager back-end.""" + """Get all database objects from the task manager back-end.""" settings = VWSSettings.model_validate(obj={}) + timeout_seconds = 30 response = requests.get( url=f"{settings.target_manager_base_url}/cloud_databases", - timeout=30, + timeout=timeout_seconds, ) return { CloudDatabase.from_dict(database_dict=database_dict) diff --git a/src/mock_vws/_requests_mock_server/mock_web_query_api.py b/src/mock_vws/_requests_mock_server/mock_web_query_api.py index 4fecd1da2..e2626a25a 100644 --- a/src/mock_vws/_requests_mock_server/mock_web_query_api.py +++ b/src/mock_vws/_requests_mock_server/mock_web_query_api.py @@ -116,14 +116,13 @@ def __init__( @route(path_pattern="/v1/query", http_methods={HTTPMethod.POST}) def query(self, request: PreparedRequest) -> _ResponseType: """Perform an image recognition query.""" - cloud_databases = self._target_manager.cloud_databases try: run_query_validators( request_path=request.path_url, request_headers=request.headers, request_body=_body_bytes(request=request), request_method=request.method or "", - databases=cloud_databases, + databases=self._target_manager.cloud_databases, ) except ValidatorError as exc: return exc.status_code, exc.headers, exc.response_text @@ -133,7 +132,7 @@ def query(self, request: PreparedRequest) -> _ResponseType: request_body=_body_bytes(request=request), request_method=request.method or "", request_path=request.path_url, - databases=cloud_databases, + databases=self._target_manager.cloud_databases, query_match_checker=self._query_match_checker, ) From bc3e1073b2e0a1bced05f3b15af1207c44bdbac5 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 20 Feb 2026 19:06:44 +0000 Subject: [PATCH 08/13] Revert cosmetic changes to existing code Remove unnecessary docstring tweaks, comment rewraps, and refactors to CloudDatabase that are unrelated to VuMark support. Co-Authored-By: Claude Opus 4.6 --- pyproject.toml | 2 -- src/mock_vws/_flask_server/vwq.py | 2 +- src/mock_vws/database.py | 34 ++++++++++++++----------------- src/mock_vws/target_manager.py | 3 +++ 4 files changed, 19 insertions(+), 22 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index de9643bb9..c5a2f5685 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -452,8 +452,6 @@ ignore_names = [ # Used in TYPE_CHECKING for type hints "CloudDatabaseDict", "VuMarkDatabaseDict", - # TypedDict fields - "state_name", ] # Duplicate some of .gitignore exclude = [ ".venv" ] diff --git a/src/mock_vws/_flask_server/vwq.py b/src/mock_vws/_flask_server/vwq.py index af251886f..a421b1b66 100644 --- a/src/mock_vws/_flask_server/vwq.py +++ b/src/mock_vws/_flask_server/vwq.py @@ -64,7 +64,7 @@ class VWQSettings(BaseSettings): @beartype def get_all_cloud_databases() -> set[CloudDatabase]: - """Get all cloud database objects from the target manager back-end.""" + """Get all database objects from the target manager back-end.""" settings = VWQSettings.model_validate(obj={}) response = requests.get( url=f"{settings.target_manager_base_url}/cloud_databases", diff --git a/src/mock_vws/database.py b/src/mock_vws/database.py index 3b39022d3..619cfc16f 100644 --- a/src/mock_vws/database.py +++ b/src/mock_vws/database.py @@ -74,8 +74,8 @@ class CloudDatabase: client_secret_key: str = field(default_factory=_random_hex, repr=False) # We have ``targets`` as ``hash=False`` so that we can have the class as # ``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. + # 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], hash=False, @@ -103,7 +103,7 @@ def to_dict(self) -> CloudDatabaseDict: } def get_target(self, target_id: str) -> ImageTarget: - """Return an image target from the database with the given ID.""" + """Return a target from the database with the given ID.""" (target,) = ( target for target in self.targets if target.target_id == target_id ) @@ -132,44 +132,40 @@ def not_deleted_targets(self) -> set[ImageTarget]: @property def active_targets(self) -> set[ImageTarget]: - """All active image targets.""" + """All active targets.""" return { target - for target in self.targets - if not target.delete_date - and target.status == TargetStatuses.SUCCESS.value + for target in self.not_deleted_targets + if target.status == TargetStatuses.SUCCESS.value and target.active_flag } @property def inactive_targets(self) -> set[ImageTarget]: - """All inactive image targets.""" + """All inactive targets.""" return { target - for target in self.targets - if not target.delete_date - and target.status == TargetStatuses.SUCCESS.value + for target in self.not_deleted_targets + if target.status == TargetStatuses.SUCCESS.value and not target.active_flag } @property def failed_targets(self) -> set[ImageTarget]: - """All failed image targets.""" + """All failed targets.""" return { target - for target in self.targets - if not target.delete_date - and target.status == TargetStatuses.FAILED.value + for target in self.not_deleted_targets + if target.status == TargetStatuses.FAILED.value } @property def processing_targets(self) -> set[ImageTarget]: - """All processing image targets.""" + """All processing targets.""" return { target - for target in self.targets - if not target.delete_date - and target.status == TargetStatuses.PROCESSING.value + for target in self.not_deleted_targets + if target.status == TargetStatuses.PROCESSING.value } diff --git a/src/mock_vws/target_manager.py b/src/mock_vws/target_manager.py index 387fa6767..7f039b4b7 100644 --- a/src/mock_vws/target_manager.py +++ b/src/mock_vws/target_manager.py @@ -35,6 +35,9 @@ def remove_cloud_database(self, cloud_database: CloudDatabase) -> None: Args: cloud_database: The cloud database to remove. + + Raises: + KeyError: The cloud database is not in the target manager. """ self._cloud_databases = { db for db in self._cloud_databases if db != cloud_database From ec58b55e488562d18be191013989922df4847024 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 20 Feb 2026 19:34:27 +0000 Subject: [PATCH 09/13] Fix VuMark auth by widening validators to accept both database types VuMark database credentials were not found during VWS authentication because validators only searched cloud databases. Widen all service validator signatures to accept AnyDatabase (CloudDatabase | VuMarkDatabase), pass both database types from the Flask and requests-mock servers, and add VuMarkDatabase to the Sphinx API docs. Co-Authored-By: Claude Opus 4.6 --- docs/source/mock-api-reference.rst | 5 +++++ src/mock_vws/_database_matchers.py | 10 +++++---- src/mock_vws/_flask_server/vws.py | 22 ++++++++++++++++--- .../mock_web_services_api.py | 11 ++++++++-- src/mock_vws/_services_validators/__init__.py | 4 ++-- .../_services_validators/auth_validators.py | 10 +++++---- .../_services_validators/name_validators.py | 10 +++++---- .../project_state_validators.py | 10 +++++++-- .../_services_validators/target_validators.py | 8 ++++--- 9 files changed, 66 insertions(+), 24 deletions(-) diff --git a/docs/source/mock-api-reference.rst b/docs/source/mock-api-reference.rst index 7b0430675..338855d2c 100644 --- a/docs/source/mock-api-reference.rst +++ b/docs/source/mock-api-reference.rst @@ -20,6 +20,11 @@ API Reference :undoc-members: :exclude-members: to_dict, get_target, from_dict, not_deleted_targets, active_targets, inactive_targets, failed_targets, processing_targets +.. autoclass:: mock_vws.database.VuMarkDatabase + :members: + :undoc-members: + :exclude-members: to_dict, from_dict, not_deleted_targets + .. autoenum:: mock_vws.states.States :members: :undoc-members: diff --git a/src/mock_vws/_database_matchers.py b/src/mock_vws/_database_matchers.py index 0e6a8c76d..dae0253a7 100644 --- a/src/mock_vws/_database_matchers.py +++ b/src/mock_vws/_database_matchers.py @@ -5,7 +5,9 @@ from beartype import beartype from vws_auth_tools import authorization_header -from mock_vws.database import CloudDatabase +from mock_vws.database import CloudDatabase, VuMarkDatabase + +AnyDatabase = CloudDatabase | VuMarkDatabase @beartype @@ -58,14 +60,14 @@ def get_database_matching_client_keys( @beartype -def get_database_matching_server_keys( +def get_database_matching_server_keys[DatabaseT: AnyDatabase]( *, request_headers: Mapping[str, str], request_body: bytes | None, request_method: str, request_path: str, - databases: Iterable[CloudDatabase], -) -> CloudDatabase: + databases: Iterable[DatabaseT], +) -> DatabaseT: """Return the first of the given databases which is being accessed by the given server request. diff --git a/src/mock_vws/_flask_server/vws.py b/src/mock_vws/_flask_server/vws.py index a11cc5a69..c487ba5af 100644 --- a/src/mock_vws/_flask_server/vws.py +++ b/src/mock_vws/_flask_server/vws.py @@ -36,7 +36,7 @@ TargetStatusProcessingError, ValidatorError, ) -from mock_vws.database import CloudDatabase +from mock_vws.database import CloudDatabase, VuMarkDatabase from mock_vws.image_matchers import ( ExactMatcher, ImageMatcher, @@ -100,6 +100,21 @@ def get_all_cloud_databases() -> set[CloudDatabase]: } +@beartype +def get_all_vumark_databases() -> set[VuMarkDatabase]: + """Get all VuMark database objects from the task manager back-end.""" + settings = VWSSettings.model_validate(obj={}) + timeout_seconds = 30 + response = requests.get( + url=f"{settings.target_manager_base_url}/vumark_databases", + timeout=timeout_seconds, + ) + return { + VuMarkDatabase.from_dict(database_dict=database_dict) + for database_dict in response.json() + } + + @VWS_FLASK_APP.before_request def set_terminate_wsgi_input() -> None: """We set ``wsgi.input_terminated`` to ``True`` when going through @@ -130,13 +145,14 @@ def set_terminate_wsgi_input() -> None: @beartype def validate_request() -> None: """Run validators on the request.""" - databases = get_all_cloud_databases() + cloud_databases = get_all_cloud_databases() + vumark_databases = get_all_vumark_databases() run_services_validators( request_headers=dict(request.headers), request_body=request.data, request_method=request.method, request_path=request.path, - databases=databases, + databases=[*cloud_databases, *vumark_databases], ) 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 bbd782c24..5a5b7da96 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 @@ -12,7 +12,7 @@ import uuid from collections.abc import Callable, Iterable, Mapping from http import HTTPMethod, HTTPStatus -from typing import Any, ParamSpec, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Any, ParamSpec, Protocol, runtime_checkable from zoneinfo import ZoneInfo from beartype import BeartypeConf, beartype @@ -41,6 +41,9 @@ from mock_vws.target_manager import TargetManager from mock_vws.target_raters import TargetTrackingRater +if TYPE_CHECKING: + from mock_vws.database import CloudDatabase, VuMarkDatabase + _TARGET_ID_PATTERN = "[A-Za-z0-9]+" @@ -309,12 +312,16 @@ def generate_vumark_instance( "application/pdf": VUMARK_PDF, } try: + all_databases: list[CloudDatabase | VuMarkDatabase] = [ + *self._target_manager.cloud_databases, + *self._target_manager.vumark_databases, + ] run_services_validators( request_headers=request.headers, request_body=_body_bytes(request=request), request_method=request.method or "", request_path=request.path_url, - databases=self._target_manager.cloud_databases, + databases=all_databases, ) accept = dict(request.headers).get("Accept", "") diff --git a/src/mock_vws/_services_validators/__init__.py b/src/mock_vws/_services_validators/__init__.py index e37c28d84..7a2e742a3 100644 --- a/src/mock_vws/_services_validators/__init__.py +++ b/src/mock_vws/_services_validators/__init__.py @@ -2,7 +2,7 @@ from collections.abc import Iterable, Mapping -from mock_vws.database import CloudDatabase +from mock_vws._database_matchers import AnyDatabase from .active_flag_validators import validate_active_flag from .auth_validators import ( @@ -55,7 +55,7 @@ def run_services_validators( request_headers: Mapping[str, str], request_body: bytes, request_method: str, - databases: Iterable[CloudDatabase], + databases: Iterable[AnyDatabase], ) -> None: """Run all validators. diff --git a/src/mock_vws/_services_validators/auth_validators.py b/src/mock_vws/_services_validators/auth_validators.py index a5922b7ae..1117b4636 100644 --- a/src/mock_vws/_services_validators/auth_validators.py +++ b/src/mock_vws/_services_validators/auth_validators.py @@ -6,12 +6,14 @@ from beartype import beartype -from mock_vws._database_matchers import get_database_matching_server_keys +from mock_vws._database_matchers import ( + AnyDatabase, + get_database_matching_server_keys, +) from mock_vws._services_validators.exceptions import ( AuthenticationFailureError, FailError, ) -from mock_vws.database import CloudDatabase _LOGGER = logging.getLogger(name=__name__) @@ -36,7 +38,7 @@ def validate_auth_header_exists(*, request_headers: Mapping[str, str]) -> None: def validate_access_key_exists( *, request_headers: Mapping[str, str], - databases: Iterable[CloudDatabase], + databases: Iterable[AnyDatabase], ) -> None: """Validate the authorization header includes an access key for a database. @@ -92,7 +94,7 @@ def validate_authorization( request_headers: Mapping[str, str], request_body: bytes, request_method: str, - databases: Iterable[CloudDatabase], + databases: Iterable[AnyDatabase], ) -> None: """Validate the authorization header given to a VWS endpoint. diff --git a/src/mock_vws/_services_validators/name_validators.py b/src/mock_vws/_services_validators/name_validators.py index 04db14721..abf1532c0 100644 --- a/src/mock_vws/_services_validators/name_validators.py +++ b/src/mock_vws/_services_validators/name_validators.py @@ -7,12 +7,14 @@ from beartype import beartype -from mock_vws._database_matchers import get_database_matching_server_keys +from mock_vws._database_matchers import ( + AnyDatabase, + get_database_matching_server_keys, +) from mock_vws._services_validators.exceptions import ( FailError, TargetNameExistError, ) -from mock_vws.database import CloudDatabase _LOGGER = logging.getLogger(name=__name__) @@ -116,7 +118,7 @@ def validate_name_length(*, request_body: bytes) -> None: @beartype def validate_name_does_not_exist_new_target( *, - databases: Iterable[CloudDatabase], + databases: Iterable[AnyDatabase], request_body: bytes, request_headers: Mapping[str, str], request_method: str, @@ -176,7 +178,7 @@ def validate_name_does_not_exist_existing_target( request_body: bytes, request_method: str, request_path: str, - databases: Iterable[CloudDatabase], + databases: Iterable[AnyDatabase], ) -> None: """Validate that the name does not exist for any existing target apart from diff --git a/src/mock_vws/_services_validators/project_state_validators.py b/src/mock_vws/_services_validators/project_state_validators.py index d4b263392..ac1fe97d2 100644 --- a/src/mock_vws/_services_validators/project_state_validators.py +++ b/src/mock_vws/_services_validators/project_state_validators.py @@ -6,7 +6,10 @@ from beartype import beartype -from mock_vws._database_matchers import get_database_matching_server_keys +from mock_vws._database_matchers import ( + AnyDatabase, + get_database_matching_server_keys, +) from mock_vws._services_validators.exceptions import ProjectInactiveError from mock_vws.database import CloudDatabase from mock_vws.states import States @@ -21,7 +24,7 @@ def validate_project_state( request_headers: Mapping[str, str], request_body: bytes, request_method: str, - databases: Iterable[CloudDatabase], + databases: Iterable[AnyDatabase], ) -> None: """Validate the state of the project. @@ -44,6 +47,9 @@ def validate_project_state( databases=databases, ) + if not isinstance(database, CloudDatabase): + return + if database.state != States.PROJECT_INACTIVE: return diff --git a/src/mock_vws/_services_validators/target_validators.py b/src/mock_vws/_services_validators/target_validators.py index ca5077ef2..58f1da0d7 100644 --- a/src/mock_vws/_services_validators/target_validators.py +++ b/src/mock_vws/_services_validators/target_validators.py @@ -5,9 +5,11 @@ from beartype import beartype -from mock_vws._database_matchers import get_database_matching_server_keys +from mock_vws._database_matchers import ( + AnyDatabase, + get_database_matching_server_keys, +) from mock_vws._services_validators.exceptions import UnknownTargetError -from mock_vws.database import CloudDatabase _LOGGER = logging.getLogger(name=__name__) _TARGETS_WITH_INSTANCE_PATH_LENGTH = 4 @@ -20,7 +22,7 @@ def validate_target_id_exists( request_headers: Mapping[str, str], request_body: bytes, request_method: str, - databases: Iterable[CloudDatabase], + databases: Iterable[AnyDatabase], ) -> None: """Validate that if a target ID is given, it exists in the database matching the request. From a762cd99acc5dc9fb2c88b60aab5442216b5999b Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 20 Feb 2026 20:32:50 +0000 Subject: [PATCH 10/13] Address bugbot review: scope validate_request to cloud-only databases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Skip validate_request for VuMark endpoint; it does its own validation with both database types (fixes 500 when VuMark creds hit cloud endpoints) - Fix misleading error message in add_cloud_database ("cloud database" → "database") - Deduplicate AnyDatabase alias: import from _database_matchers in target_manager Co-Authored-By: Claude Opus 4.6 --- src/mock_vws/_flask_server/vws.py | 26 ++++++++++++++++++++++---- src/mock_vws/target_manager.py | 7 +++++-- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/mock_vws/_flask_server/vws.py b/src/mock_vws/_flask_server/vws.py index c487ba5af..1c98d1336 100644 --- a/src/mock_vws/_flask_server/vws.py +++ b/src/mock_vws/_flask_server/vws.py @@ -144,15 +144,19 @@ def set_terminate_wsgi_input() -> None: @VWS_FLASK_APP.before_request @beartype def validate_request() -> None: - """Run validators on the request.""" - cloud_databases = get_all_cloud_databases() - vumark_databases = get_all_vumark_databases() + """Run validators on the request. + + The VuMark endpoint does its own validation because it needs to + authenticate against both cloud and VuMark databases. + """ + if request.endpoint == "generate_vumark_instance": + return run_services_validators( request_headers=dict(request.headers), request_body=request.data, request_method=request.method, request_path=request.path, - databases=[*cloud_databases, *vumark_databases], + databases=get_all_cloud_databases(), ) @@ -373,6 +377,20 @@ def generate_vumark_instance(target_id: str) -> Response: Fake implementation of https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#generate-instance """ + cloud_databases = get_all_cloud_databases() + vumark_databases = get_all_vumark_databases() + all_databases: list[CloudDatabase | VuMarkDatabase] = [ + *cloud_databases, + *vumark_databases, + ] + run_services_validators( + request_headers=dict(request.headers), + request_body=request.data, + request_method=request.method, + request_path=request.path, + databases=all_databases, + ) + # ``target_id`` is validated by request validators. del target_id diff --git a/src/mock_vws/target_manager.py b/src/mock_vws/target_manager.py index 7f039b4b7..14850df8e 100644 --- a/src/mock_vws/target_manager.py +++ b/src/mock_vws/target_manager.py @@ -1,10 +1,13 @@ """A fake implementation of a Vuforia target manager.""" +from typing import TYPE_CHECKING + from beartype import beartype from mock_vws.database import CloudDatabase, VuMarkDatabase -AnyDatabase = CloudDatabase | VuMarkDatabase +if TYPE_CHECKING: + from mock_vws._database_matchers import AnyDatabase @beartype @@ -65,7 +68,7 @@ def add_cloud_database(self, cloud_database: CloudDatabase) -> None: """ message_fmt = ( "All {key_name}s must be unique. " - 'There is already a cloud database with the {key_name} "{value}".' + 'There is already a database with the {key_name} "{value}".' ) all_databases: list[AnyDatabase] = [ *self._cloud_databases, From dfbb55f4a2c753c2324613516fe7058f27ad3196 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 20 Feb 2026 20:36:25 +0000 Subject: [PATCH 11/13] Update tests to match new database conflict error message Co-Authored-By: Claude Opus 4.6 --- tests/mock_vws/test_flask_app_usage.py | 10 +++++----- tests/mock_vws/test_requests_mock_usage.py | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/mock_vws/test_flask_app_usage.py b/tests/mock_vws/test_flask_app_usage.py index 49578e0b1..b297142de 100644 --- a/tests/mock_vws/test_flask_app_usage.py +++ b/tests/mock_vws/test_flask_app_usage.py @@ -131,23 +131,23 @@ def test_duplicate_keys() -> None: server_access_key_conflict_error = ( "All server access keys must be unique. " - 'There is already a cloud database with the server access key "1".' + 'There is already a database with the server access key "1".' ) server_secret_key_conflict_error = ( "All server secret keys must be unique. " - 'There is already a cloud database with the server secret key "2".' + 'There is already a database with the server secret key "2".' ) client_access_key_conflict_error = ( "All client access keys must be unique. " - 'There is already a cloud database with the client access key "3".' + 'There is already a database with the client access key "3".' ) client_secret_key_conflict_error = ( "All client secret keys must be unique. " - 'There is already a cloud database with the client secret key "4".' + 'There is already a database with the client secret key "4".' ) database_name_conflict_error = ( "All names must be unique. " - 'There is already a cloud database with the name "5".' + 'There is already a database with the name "5".' ) databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" diff --git a/tests/mock_vws/test_requests_mock_usage.py b/tests/mock_vws/test_requests_mock_usage.py index 37365bb49..0a107ce1e 100644 --- a/tests/mock_vws/test_requests_mock_usage.py +++ b/tests/mock_vws/test_requests_mock_usage.py @@ -528,23 +528,23 @@ def test_duplicate_keys() -> None: server_access_key_conflict_error = ( "All server access keys must be unique. " - 'There is already a cloud database with the server access key "1".' + 'There is already a database with the server access key "1".' ) server_secret_key_conflict_error = ( "All server secret keys must be unique. " - 'There is already a cloud database with the server secret key "2".' + 'There is already a database with the server secret key "2".' ) client_access_key_conflict_error = ( "All client access keys must be unique. " - 'There is already a cloud database with the client access key "3".' + 'There is already a database with the client access key "3".' ) client_secret_key_conflict_error = ( "All client secret keys must be unique. " - 'There is already a cloud database with the client secret key "4".' + 'There is already a database with the client secret key "4".' ) database_name_conflict_error = ( "All names must be unique. " - 'There is already a cloud database with the name "5".' + 'There is already a database with the name "5".' ) with MockVWS() as mock: From 92650f88f6b07619ecab950d2332abb340e504fb Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 20 Feb 2026 21:24:42 +0000 Subject: [PATCH 12/13] Fix coverage: remove dead VuMark branches, add duplicate key tests - Remove delete_date field from VuMarkTarget (VuMark targets can't be deleted) - Simplify VuMarkDatabase.not_deleted_targets (no filtering needed) - Remove NOT_FOUND branch from delete_vumark_database (internal API) - Add test_duplicate_vumark_keys to both Flask and requests-mock test suites Co-Authored-By: Claude Opus 4.6 --- src/mock_vws/_flask_server/target_manager.py | 14 ++---- src/mock_vws/database.py | 6 +-- src/mock_vws/target.py | 15 ------ tests/mock_vws/test_flask_app_usage.py | 48 +++++++++++++++++++- tests/mock_vws/test_requests_mock_usage.py | 44 +++++++++++++++++- 5 files changed, 97 insertions(+), 30 deletions(-) diff --git a/src/mock_vws/_flask_server/target_manager.py b/src/mock_vws/_flask_server/target_manager.py index 98aafe464..1a308e4dd 100644 --- a/src/mock_vws/_flask_server/target_manager.py +++ b/src/mock_vws/_flask_server/target_manager.py @@ -90,15 +90,11 @@ def delete_vumark_database(database_name: str) -> Response: :status 200: The VuMark database has been deleted. """ - try: - (matching_database,) = { - database - for database in TARGET_MANAGER.vumark_databases - if database_name == database.database_name - } - except ValueError: - return Response(response="", status=HTTPStatus.NOT_FOUND) - + (matching_database,) = { + database + for database in TARGET_MANAGER.vumark_databases + if database_name == database.database_name + } TARGET_MANAGER.remove_vumark_database(vumark_database=matching_database) return Response(response="", status=HTTPStatus.OK) diff --git a/src/mock_vws/database.py b/src/mock_vws/database.py index 619cfc16f..b030a9e4e 100644 --- a/src/mock_vws/database.py +++ b/src/mock_vws/database.py @@ -221,7 +221,5 @@ def from_dict(cls, database_dict: VuMarkDatabaseDict) -> Self: @property def not_deleted_targets(self) -> set[VuMarkTarget]: - """All VuMark targets which have not been deleted.""" - return { - target for target in self.vumark_targets if not target.delete_date - } + """All VuMark targets.""" + return set(self.vumark_targets) diff --git a/src/mock_vws/target.py b/src/mock_vws/target.py index 1f4f110c1..d381884d8 100644 --- a/src/mock_vws/target.py +++ b/src/mock_vws/target.py @@ -24,7 +24,6 @@ class VuMarkTargetDict(TypedDict): target_id: str name: str - delete_date_optional: str | None class ImageTargetDict(TypedDict): @@ -231,34 +230,20 @@ class VuMarkTarget: name: str target_id: str = field(default_factory=_random_hex) - delete_date: datetime.datetime | None = None @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) return cls( target_id=target_dict["target_id"], name=target_dict["name"], - delete_date=delete_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 { "target_id": self.target_id, "name": self.name, - "delete_date_optional": delete_date, } diff --git a/tests/mock_vws/test_flask_app_usage.py b/tests/mock_vws/test_flask_app_usage.py index b297142de..246eecf36 100644 --- a/tests/mock_vws/test_flask_app_usage.py +++ b/tests/mock_vws/test_flask_app_usage.py @@ -18,7 +18,7 @@ from mock_vws._flask_server.target_manager import TARGET_MANAGER_FLASK_APP 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 import CloudDatabase, VuMarkDatabase from tests.mock_vws.utils.usage_test_helpers import ( processing_time_seconds, ) @@ -169,6 +169,52 @@ def test_duplicate_keys() -> None: assert response.status_code == HTTPStatus.CONFLICT assert response.text == expected_message + @staticmethod + def test_duplicate_vumark_keys() -> None: + """ + It is not possible to have multiple databases with matching + keys, including VuMark databases. + """ + database = VuMarkDatabase( + server_access_key="1", + server_secret_key="2", + database_name="3", + ) + + bad_server_access_key_db = VuMarkDatabase(server_access_key="1") + bad_server_secret_key_db = VuMarkDatabase(server_secret_key="2") + bad_database_name_db = VuMarkDatabase(database_name="3") + + server_access_key_conflict_error = ( + "All server access keys must be unique. " + 'There is already a database with the server access key "1".' + ) + server_secret_key_conflict_error = ( + "All server secret keys must be unique. " + 'There is already a database with the server secret key "2".' + ) + database_name_conflict_error = ( + "All names must be unique. " + 'There is already a database with the name "3".' + ) + + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/vumark_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) + + for bad_database, expected_message in ( + (bad_server_access_key_db, server_access_key_conflict_error), + (bad_server_secret_key_db, server_secret_key_conflict_error), + (bad_database_name_db, database_name_conflict_error), + ): + response = requests.post( + url=databases_url, + json=bad_database.to_dict(), + timeout=30, + ) + + assert response.status_code == HTTPStatus.CONFLICT + assert response.text == expected_message + @staticmethod def test_give_no_details(high_quality_image: io.BytesIO) -> None: """It is possible to create a database without giving any data.""" diff --git a/tests/mock_vws/test_requests_mock_usage.py b/tests/mock_vws/test_requests_mock_usage.py index 0a107ce1e..cc27c8bf7 100644 --- a/tests/mock_vws/test_requests_mock_usage.py +++ b/tests/mock_vws/test_requests_mock_usage.py @@ -16,7 +16,7 @@ from vws_auth_tools import rfc_1123_date from mock_vws import MissingSchemeError, MockVWS -from mock_vws.database import CloudDatabase +from mock_vws.database import CloudDatabase, VuMarkDatabase from mock_vws.image_matchers import ExactMatcher, StructuralSimilarityMatcher from mock_vws.target import ImageTarget from tests.mock_vws.utils import Endpoint @@ -562,6 +562,48 @@ def test_duplicate_keys() -> None: ): mock.add_cloud_database(cloud_database=bad_database) + @staticmethod + def test_duplicate_vumark_keys() -> None: + """ + It is not possible to have multiple databases with matching + keys, including VuMark databases. + """ + database = VuMarkDatabase( + server_access_key="1", + server_secret_key="2", + database_name="3", + ) + + bad_server_access_key_db = VuMarkDatabase(server_access_key="1") + bad_server_secret_key_db = VuMarkDatabase(server_secret_key="2") + bad_database_name_db = VuMarkDatabase(database_name="3") + + server_access_key_conflict_error = ( + "All server access keys must be unique. " + 'There is already a database with the server access key "1".' + ) + server_secret_key_conflict_error = ( + "All server secret keys must be unique. " + 'There is already a database with the server secret key "2".' + ) + database_name_conflict_error = ( + "All names must be unique. " + 'There is already a database with the name "3".' + ) + + with MockVWS() as mock: + mock.add_vumark_database(vumark_database=database) + for bad_database, expected_message in ( + (bad_server_access_key_db, server_access_key_conflict_error), + (bad_server_secret_key_db, server_secret_key_conflict_error), + (bad_database_name_db, database_name_conflict_error), + ): + with pytest.raises( + expected_exception=ValueError, + match=expected_message + "$", + ): + mock.add_vumark_database(vumark_database=bad_database) + class TestQueryImageMatchers: """Tests for query image matchers.""" From ac4bcb28cdd35d35776b18dbc64f35201d899a1d Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 20 Feb 2026 21:36:00 +0000 Subject: [PATCH 13/13] Fix VuMark duplicate keys test isolation in Flask tests Use distinct keys ("v1", "v2", "v3") for VuMark database tests to avoid conflicts with the TARGET_MANAGER singleton state from cloud database tests. Co-Authored-By: Claude Opus 4.6 --- tests/mock_vws/test_flask_app_usage.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/mock_vws/test_flask_app_usage.py b/tests/mock_vws/test_flask_app_usage.py index 246eecf36..5ce6ad1aa 100644 --- a/tests/mock_vws/test_flask_app_usage.py +++ b/tests/mock_vws/test_flask_app_usage.py @@ -176,26 +176,26 @@ def test_duplicate_vumark_keys() -> None: keys, including VuMark databases. """ database = VuMarkDatabase( - server_access_key="1", - server_secret_key="2", - database_name="3", + server_access_key="v1", + server_secret_key="v2", + database_name="v3", ) - bad_server_access_key_db = VuMarkDatabase(server_access_key="1") - bad_server_secret_key_db = VuMarkDatabase(server_secret_key="2") - bad_database_name_db = VuMarkDatabase(database_name="3") + bad_server_access_key_db = VuMarkDatabase(server_access_key="v1") + bad_server_secret_key_db = VuMarkDatabase(server_secret_key="v2") + bad_database_name_db = VuMarkDatabase(database_name="v3") server_access_key_conflict_error = ( "All server access keys must be unique. " - 'There is already a database with the server access key "1".' + 'There is already a database with the server access key "v1".' ) server_secret_key_conflict_error = ( "All server secret keys must be unique. " - 'There is already a database with the server secret key "2".' + 'There is already a database with the server secret key "v2".' ) database_name_conflict_error = ( "All names must be unique. " - 'There is already a database with the name "3".' + 'There is already a database with the name "v3".' ) databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/vumark_databases"