vws-python-mock 2026.8.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. mock_vws/__init__.py +9 -0
  2. mock_vws/_base64_decoding.py +35 -0
  3. mock_vws/_constants.py +84 -0
  4. mock_vws/_database_matchers.py +107 -0
  5. mock_vws/_flask_server/Dockerfile +32 -0
  6. mock_vws/_flask_server/__init__.py +1 -0
  7. mock_vws/_flask_server/healthcheck.py +31 -0
  8. mock_vws/_flask_server/target_manager.py +447 -0
  9. mock_vws/_flask_server/vwq.py +173 -0
  10. mock_vws/_flask_server/vws.py +954 -0
  11. mock_vws/_mock_common.py +75 -0
  12. mock_vws/_model_target_web_api.py +486 -0
  13. mock_vws/_query_tools.py +136 -0
  14. mock_vws/_query_validators/__init__.py +128 -0
  15. mock_vws/_query_validators/accept_header_validators.py +31 -0
  16. mock_vws/_query_validators/auth_validators.py +143 -0
  17. mock_vws/_query_validators/content_length_validators.py +90 -0
  18. mock_vws/_query_validators/content_type_validators.py +65 -0
  19. mock_vws/_query_validators/date_validators.py +110 -0
  20. mock_vws/_query_validators/exceptions.py +769 -0
  21. mock_vws/_query_validators/fields_validators.py +47 -0
  22. mock_vws/_query_validators/image_validators.py +197 -0
  23. mock_vws/_query_validators/include_target_data_validators.py +51 -0
  24. mock_vws/_query_validators/num_results_validators.py +64 -0
  25. mock_vws/_query_validators/project_state_validators.py +49 -0
  26. mock_vws/_requests_mock_server/__init__.py +1 -0
  27. mock_vws/_requests_mock_server/decorators.py +275 -0
  28. mock_vws/_requests_mock_server/mock_web_query_api.py +139 -0
  29. mock_vws/_requests_mock_server/mock_web_services_api.py +954 -0
  30. mock_vws/_respx_mock_server/__init__.py +1 -0
  31. mock_vws/_respx_mock_server/decorators.py +186 -0
  32. mock_vws/_services_validators/__init__.py +186 -0
  33. mock_vws/_services_validators/active_flag_validators.py +43 -0
  34. mock_vws/_services_validators/auth_validators.py +124 -0
  35. mock_vws/_services_validators/content_length_validators.py +102 -0
  36. mock_vws/_services_validators/content_type_validators.py +42 -0
  37. mock_vws/_services_validators/date_validators.py +78 -0
  38. mock_vws/_services_validators/exceptions.py +858 -0
  39. mock_vws/_services_validators/image_validators.py +220 -0
  40. mock_vws/_services_validators/json_validators.py +69 -0
  41. mock_vws/_services_validators/key_validators.py +171 -0
  42. mock_vws/_services_validators/metadata_validators.py +106 -0
  43. mock_vws/_services_validators/name_validators.py +235 -0
  44. mock_vws/_services_validators/project_state_validators.py +76 -0
  45. mock_vws/_services_validators/request_quota_validators.py +42 -0
  46. mock_vws/_services_validators/target_quota_validators.py +41 -0
  47. mock_vws/_services_validators/target_validators.py +69 -0
  48. mock_vws/_services_validators/width_validators.py +38 -0
  49. mock_vws/database.py +257 -0
  50. mock_vws/database_type.py +13 -0
  51. mock_vws/image_matchers.py +124 -0
  52. mock_vws/model_target.py +79 -0
  53. mock_vws/py.typed +0 -0
  54. mock_vws/states.py +20 -0
  55. mock_vws/target.py +285 -0
  56. mock_vws/target_manager.py +178 -0
  57. mock_vws/target_raters.py +109 -0
  58. vws_python_mock-2026.8.4.dist-info/METADATA +177 -0
  59. vws_python_mock-2026.8.4.dist-info/RECORD +62 -0
  60. vws_python_mock-2026.8.4.dist-info/WHEEL +5 -0
  61. vws_python_mock-2026.8.4.dist-info/licenses/LICENSE +21 -0
  62. vws_python_mock-2026.8.4.dist-info/top_level.txt +1 -0
mock_vws/target.py ADDED
@@ -0,0 +1,285 @@
1
+ """A fake implementation of a target for the Vuforia Web Services API."""
2
+
3
+ import base64
4
+ import datetime
5
+ import io
6
+ import statistics
7
+ import uuid
8
+ from dataclasses import dataclass, field
9
+ from typing import Self, TypedDict
10
+ from zoneinfo import ZoneInfo
11
+
12
+ from beartype import BeartypeConf, beartype
13
+ from PIL import Image, ImageStat
14
+
15
+ from mock_vws._constants import TargetStatuses
16
+ from mock_vws.target_raters import (
17
+ HardcodedTargetTrackingRater,
18
+ TargetTrackingRater,
19
+ )
20
+
21
+
22
+ class VuMarkTargetDict(TypedDict):
23
+ """A dictionary type which represents a VuMark target."""
24
+
25
+ target_id: str
26
+ name: str
27
+ processing_time_seconds: float
28
+ last_modified_date: str
29
+ upload_date: str
30
+
31
+
32
+ class ImageTargetDict(TypedDict):
33
+ """A dictionary type which represents an image target."""
34
+
35
+ name: str
36
+ width: float
37
+ image_base64: str
38
+ active_flag: bool
39
+ processing_time_seconds: float
40
+ application_metadata: str | None
41
+ target_id: str
42
+ last_modified_date: str
43
+ delete_date_optional: str | None
44
+ upload_date: str
45
+ tracking_rating: int
46
+
47
+
48
+ @beartype
49
+ def _random_hex() -> str:
50
+ """Return a random hex value."""
51
+ return uuid.uuid4().hex
52
+
53
+
54
+ @beartype
55
+ def _time_now() -> datetime.datetime:
56
+ """Return the current time in the GMT time zone."""
57
+ gmt = ZoneInfo(key="GMT")
58
+ return datetime.datetime.now(tz=gmt)
59
+
60
+
61
+ @beartype(conf=BeartypeConf(is_pep484_tower=True))
62
+ @dataclass(frozen=True, eq=True, kw_only=True)
63
+ class ImageTarget:
64
+ """A Vuforia image target as managed in
65
+ https://developer.vuforia.com/target-manager.
66
+ """
67
+
68
+ active_flag: bool
69
+ application_metadata: str | None
70
+ image_value: bytes
71
+ name: str
72
+ processing_time_seconds: float
73
+ width: float
74
+ target_tracking_rater: TargetTrackingRater = field(compare=False)
75
+ current_month_recos: int = 0
76
+ delete_date: datetime.datetime | None = None
77
+ last_modified_date: datetime.datetime = field(default_factory=_time_now)
78
+ previous_month_recos: int = 0
79
+ reco_rating: str = ""
80
+ target_id: str = field(default_factory=_random_hex)
81
+ total_recos: int = 0
82
+ upload_date: datetime.datetime = field(default_factory=_time_now)
83
+
84
+ @property
85
+ def _post_processing_status(self) -> TargetStatuses:
86
+ """Return the status of the target, or what it will be when
87
+ processing
88
+ is finished.
89
+
90
+ The status depends on the standard deviation of the color bands.
91
+ How VWS determines this is unknown, but it relates to how
92
+ suitable the target is for detection.
93
+ """
94
+ image_file = io.BytesIO(initial_bytes=self.image_value)
95
+ with Image.open(fp=image_file) as image:
96
+ image_stat = ImageStat.Stat(image_or_list=image)
97
+ average_std_dev = statistics.mean(data=image_stat.stddev)
98
+
99
+ success_threshold = 5
100
+
101
+ if average_std_dev > success_threshold:
102
+ return TargetStatuses.SUCCESS
103
+
104
+ return TargetStatuses.FAILED
105
+
106
+ @property
107
+ def status(self) -> str:
108
+ """Return the status of the target.
109
+
110
+ For now this waits half a second (arbitrary) before changing the
111
+ status from 'processing' to 'failed' or 'success'.
112
+
113
+ The status depends on the standard deviation of the color bands.
114
+ How VWS determines this is unknown, but it relates to how
115
+ suitable the target is for detection.
116
+ """
117
+ processing_time = datetime.timedelta(
118
+ seconds=float(self.processing_time_seconds),
119
+ )
120
+
121
+ timezone = self.upload_date.tzinfo
122
+ now = datetime.datetime.now(tz=timezone)
123
+ time_since_change = now - self.last_modified_date
124
+
125
+ if time_since_change <= processing_time:
126
+ return TargetStatuses.PROCESSING.value
127
+
128
+ return self._post_processing_status.value
129
+
130
+ @property
131
+ def _post_processing_target_rating(self) -> int:
132
+ """The rating of the target after processing."""
133
+ return self.target_tracking_rater(image_content=self.image_value)
134
+
135
+ @property
136
+ def tracking_rating(self) -> int:
137
+ """Return the tracking rating of the target recognition image."""
138
+ pre_rating_time = datetime.timedelta(
139
+ # That this is half of the total processing time is unrealistic.
140
+ # In VWS it is not a constant percentage.
141
+ seconds=float(self.processing_time_seconds) / 2,
142
+ )
143
+
144
+ timezone = self.upload_date.tzinfo
145
+ now = datetime.datetime.now(tz=timezone)
146
+ time_since_upload = now - self.upload_date
147
+
148
+ # The real VWS seems to give -1 for a short time while processing, then
149
+ # the real rating, even while it is still processing.
150
+ if time_since_upload <= pre_rating_time:
151
+ return -1
152
+
153
+ return self._post_processing_target_rating
154
+
155
+ @classmethod
156
+ def from_dict(cls, target_dict: ImageTargetDict) -> Self:
157
+ """Load a target from a dictionary."""
158
+ timezone = ZoneInfo(key="GMT")
159
+ name = target_dict["name"]
160
+ active_flag = target_dict["active_flag"]
161
+ width = target_dict["width"]
162
+ image_base64 = target_dict["image_base64"]
163
+ image_value = base64.b64decode(s=image_base64)
164
+ processing_time_seconds = target_dict["processing_time_seconds"]
165
+ application_metadata = target_dict["application_metadata"]
166
+ target_id = target_dict["target_id"]
167
+ delete_date_optional = target_dict["delete_date_optional"]
168
+ if delete_date_optional is None:
169
+ delete_date = None
170
+ else:
171
+ delete_date = datetime.datetime.fromisoformat(delete_date_optional)
172
+ delete_date = delete_date.replace(tzinfo=timezone)
173
+
174
+ last_modified_date = datetime.datetime.fromisoformat(
175
+ target_dict["last_modified_date"],
176
+ ).replace(tzinfo=timezone)
177
+ upload_date = datetime.datetime.fromisoformat(
178
+ target_dict["upload_date"],
179
+ ).replace(tzinfo=timezone)
180
+
181
+ target_tracking_rater = HardcodedTargetTrackingRater(
182
+ rating=target_dict["tracking_rating"],
183
+ )
184
+ return cls(
185
+ target_id=target_id,
186
+ name=name,
187
+ active_flag=active_flag,
188
+ width=width,
189
+ image_value=image_value,
190
+ processing_time_seconds=processing_time_seconds,
191
+ application_metadata=application_metadata,
192
+ delete_date=delete_date,
193
+ last_modified_date=last_modified_date,
194
+ upload_date=upload_date,
195
+ target_tracking_rater=target_tracking_rater,
196
+ )
197
+
198
+ def to_dict(self) -> ImageTargetDict:
199
+ """Dump a target to a dictionary which can be loaded as JSON."""
200
+ delete_date: str | None = None
201
+ if self.delete_date:
202
+ delete_date = self.delete_date.isoformat()
203
+
204
+ image_base64 = base64.encodebytes(s=self.image_value).decode()
205
+
206
+ return {
207
+ "name": self.name,
208
+ "width": self.width,
209
+ "image_base64": image_base64,
210
+ "active_flag": self.active_flag,
211
+ "processing_time_seconds": float(self.processing_time_seconds),
212
+ "application_metadata": self.application_metadata,
213
+ "target_id": self.target_id,
214
+ "last_modified_date": self.last_modified_date.isoformat(),
215
+ "delete_date_optional": delete_date,
216
+ "upload_date": self.upload_date.isoformat(),
217
+ "tracking_rating": self.tracking_rating,
218
+ }
219
+
220
+
221
+ @beartype(conf=BeartypeConf(is_pep484_tower=True))
222
+ @dataclass(frozen=True, eq=True, kw_only=True)
223
+ class VuMarkTarget:
224
+ """
225
+ A VuMark target as managed in
226
+ https://developer.vuforia.com/target-manager.
227
+
228
+ Unlike ImageTarget, VuMark targets do not require an image — they use a
229
+ VuMark template.
230
+ """
231
+
232
+ name: str
233
+ processing_time_seconds: float = 0.0
234
+ target_id: str = field(default_factory=_random_hex)
235
+ last_modified_date: datetime.datetime = field(default_factory=_time_now)
236
+ upload_date: datetime.datetime = field(default_factory=_time_now)
237
+
238
+ @property
239
+ def status(self) -> str:
240
+ """Return the status of the target.
241
+
242
+ VuMark targets always succeed after processing.
243
+ """
244
+ processing_time = datetime.timedelta(
245
+ seconds=float(self.processing_time_seconds),
246
+ )
247
+
248
+ timezone = self.upload_date.tzinfo
249
+ now = datetime.datetime.now(tz=timezone)
250
+ time_since_change = now - self.last_modified_date
251
+
252
+ if time_since_change <= processing_time:
253
+ return TargetStatuses.PROCESSING.value
254
+
255
+ return TargetStatuses.SUCCESS.value
256
+
257
+ @classmethod
258
+ def from_dict(cls, target_dict: VuMarkTargetDict) -> Self:
259
+ """Load a VuMark target from a dictionary."""
260
+ timezone = ZoneInfo(key="GMT")
261
+ last_modified_date = datetime.datetime.fromisoformat(
262
+ target_dict["last_modified_date"],
263
+ ).replace(tzinfo=timezone)
264
+ upload_date = datetime.datetime.fromisoformat(
265
+ target_dict["upload_date"],
266
+ ).replace(tzinfo=timezone)
267
+ return cls(
268
+ target_id=target_dict["target_id"],
269
+ name=target_dict["name"],
270
+ processing_time_seconds=target_dict["processing_time_seconds"],
271
+ last_modified_date=last_modified_date,
272
+ upload_date=upload_date,
273
+ )
274
+
275
+ def to_dict(self) -> VuMarkTargetDict:
276
+ """Dump a VuMark target to a dictionary which can be loaded as
277
+ JSON.
278
+ """
279
+ return {
280
+ "target_id": self.target_id,
281
+ "name": self.name,
282
+ "processing_time_seconds": float(self.processing_time_seconds),
283
+ "last_modified_date": self.last_modified_date.isoformat(),
284
+ "upload_date": self.upload_date.isoformat(),
285
+ }
@@ -0,0 +1,178 @@
1
+ """A fake implementation of a Vuforia target manager."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from beartype import beartype
6
+
7
+ from mock_vws.database import CloudDatabase, VuMarkDatabase
8
+ from mock_vws.model_target import ModelTargetDataset
9
+
10
+ if TYPE_CHECKING:
11
+ from mock_vws._database_matchers import AnyDatabase
12
+
13
+
14
+ @beartype
15
+ class TargetManager:
16
+ """
17
+ A target manager.
18
+
19
+ See https://developer.vuforia.com/target-manager.
20
+ """
21
+
22
+ def __init__(self) -> None:
23
+ """Create a target manager with no databases."""
24
+ self._cloud_databases: set[CloudDatabase] = set()
25
+ self._vumark_databases: set[VuMarkDatabase] = set()
26
+ self._model_target_datasets: dict[str, ModelTargetDataset] = {}
27
+
28
+ @property
29
+ def cloud_databases(self) -> set[CloudDatabase]:
30
+ """All cloud databases."""
31
+ return set(self._cloud_databases)
32
+
33
+ @property
34
+ def vumark_databases(self) -> set[VuMarkDatabase]:
35
+ """All VuMark databases."""
36
+ return set(self._vumark_databases)
37
+
38
+ @property
39
+ def model_target_datasets(self) -> dict[str, ModelTargetDataset]:
40
+ """All Model Target datasets, keyed by UUID."""
41
+ return dict(self._model_target_datasets)
42
+
43
+ def remove_cloud_database(self, cloud_database: CloudDatabase) -> None:
44
+ """Remove a cloud database.
45
+
46
+ Args:
47
+ cloud_database: The cloud database to remove.
48
+
49
+ Raises:
50
+ KeyError: The cloud database is not in the target manager.
51
+ """
52
+ self._cloud_databases = {
53
+ db for db in self._cloud_databases if db != cloud_database
54
+ }
55
+
56
+ def remove_vumark_database(self, vumark_database: VuMarkDatabase) -> None:
57
+ """Remove a VuMark database.
58
+
59
+ Args:
60
+ vumark_database: The VuMark database to remove.
61
+ """
62
+ self._vumark_databases = {
63
+ db for db in self._vumark_databases if db != vumark_database
64
+ }
65
+
66
+ def add_model_target_dataset(
67
+ self,
68
+ model_target_dataset: ModelTargetDataset,
69
+ ) -> None:
70
+ """Add a Model Target dataset."""
71
+ self._model_target_datasets[model_target_dataset.uuid_] = (
72
+ model_target_dataset
73
+ )
74
+
75
+ def remove_model_target_dataset(self, dataset_uuid: str) -> None:
76
+ """Remove a Model Target dataset."""
77
+ del self._model_target_datasets[dataset_uuid]
78
+
79
+ def add_cloud_database(self, cloud_database: CloudDatabase) -> None:
80
+ """Add a cloud database.
81
+
82
+ Args:
83
+ cloud_database: The cloud database to add.
84
+
85
+ Raises:
86
+ ValueError: One of the given cloud database keys matches a key for
87
+ an existing cloud database.
88
+ """
89
+ message_fmt = (
90
+ "All {key_name}s must be unique. "
91
+ 'There is already a database with the {key_name} "{value}".'
92
+ )
93
+ all_databases: list[AnyDatabase] = [
94
+ *self._cloud_databases,
95
+ *self._vumark_databases,
96
+ ]
97
+ for existing_db in all_databases:
98
+ for existing, new, key_name in (
99
+ (
100
+ existing_db.server_access_key,
101
+ cloud_database.server_access_key,
102
+ "server access key",
103
+ ),
104
+ (
105
+ existing_db.server_secret_key,
106
+ cloud_database.server_secret_key,
107
+ "server secret key",
108
+ ),
109
+ (
110
+ existing_db.database_name,
111
+ cloud_database.database_name,
112
+ "name",
113
+ ),
114
+ ):
115
+ if existing == new:
116
+ message = message_fmt.format(key_name=key_name, value=new)
117
+ raise ValueError(message)
118
+
119
+ for existing_cloud_db in self._cloud_databases:
120
+ for existing, new, key_name in (
121
+ (
122
+ existing_cloud_db.client_access_key,
123
+ cloud_database.client_access_key,
124
+ "client access key",
125
+ ),
126
+ (
127
+ existing_cloud_db.client_secret_key,
128
+ cloud_database.client_secret_key,
129
+ "client secret key",
130
+ ),
131
+ ):
132
+ if existing == new:
133
+ message = message_fmt.format(key_name=key_name, value=new)
134
+ raise ValueError(message)
135
+
136
+ self._cloud_databases = {*self._cloud_databases, cloud_database}
137
+
138
+ def add_vumark_database(self, vumark_database: VuMarkDatabase) -> None:
139
+ """Add a VuMark database.
140
+
141
+ Args:
142
+ vumark_database: The VuMark database to add.
143
+
144
+ Raises:
145
+ ValueError: One of the given database keys matches a key for
146
+ an existing database.
147
+ """
148
+ message_fmt = (
149
+ "All {key_name}s must be unique. "
150
+ 'There is already a database with the {key_name} "{value}".'
151
+ )
152
+ all_databases: list[AnyDatabase] = [
153
+ *self._cloud_databases,
154
+ *self._vumark_databases,
155
+ ]
156
+ for existing_db in all_databases:
157
+ for existing, new, key_name in (
158
+ (
159
+ existing_db.server_access_key,
160
+ vumark_database.server_access_key,
161
+ "server access key",
162
+ ),
163
+ (
164
+ existing_db.server_secret_key,
165
+ vumark_database.server_secret_key,
166
+ "server secret key",
167
+ ),
168
+ (
169
+ existing_db.database_name,
170
+ vumark_database.database_name,
171
+ "name",
172
+ ),
173
+ ):
174
+ if existing == new:
175
+ message = message_fmt.format(key_name=key_name, value=new)
176
+ raise ValueError(message)
177
+
178
+ self._vumark_databases = {*self._vumark_databases, vumark_database}
@@ -0,0 +1,109 @@
1
+ """Raters for target quality."""
2
+
3
+ import functools
4
+ import io
5
+ import math
6
+ import secrets
7
+ from typing import Protocol, runtime_checkable
8
+
9
+ import numpy as np
10
+ import torch
11
+ from beartype import beartype
12
+ from PIL import Image
13
+ from piq.brisque import brisque # pyright: ignore[reportMissingTypeStubs]
14
+
15
+
16
+ @functools.cache
17
+ @beartype
18
+ def _get_brisque_target_tracking_rating(*, image_content: bytes) -> int:
19
+ """Get a target tracking rating based on a BRISQUE score.
20
+
21
+ This is a rough approximation of the quality score used by Vuforia, but is
22
+ not accurate. For example, our "corrupted_image" rating is based on a
23
+ BRISQUE score of 0, but Vuforia's is 1.
24
+
25
+ Args:
26
+ image_content: A target's image's content.
27
+ """
28
+ image_file = io.BytesIO(initial_bytes=image_content)
29
+ with Image.open(fp=image_file) as image:
30
+ image_np = np.array(object=image, dtype=np.float32)
31
+ image_tensor = torch.tensor(data=image_np).float() / 255
32
+ image_tensor = image_tensor.view(
33
+ image.size[1],
34
+ image.size[0],
35
+ len(image.getbands()),
36
+ )
37
+ image_tensor = image_tensor.permute(2, 0, 1).unsqueeze(dim=0)
38
+ try:
39
+ brisque_score = brisque(x=image_tensor, data_range=255)
40
+ except AssertionError, IndexError:
41
+ return 0
42
+ return math.ceil(int(brisque_score.item()) / 20)
43
+
44
+
45
+ @runtime_checkable
46
+ class TargetTrackingRater(Protocol):
47
+ """Protocol for a rater of target quality."""
48
+
49
+ def __call__(self, image_content: bytes) -> int:
50
+ """The target tracking rating.
51
+
52
+ Args:
53
+ image_content: A target's image's content.
54
+ """
55
+ # We disable a pylint warning here because the ellipsis is required
56
+ # for pyright to recognize this as a protocol.
57
+ ... # pylint: disable=unnecessary-ellipsis
58
+
59
+
60
+ @beartype
61
+ class RandomTargetTrackingRater:
62
+ """A rater which returns a random number."""
63
+
64
+ def __call__(self, image_content: bytes) -> int:
65
+ """A random target tracking rating.
66
+
67
+ Args:
68
+ image_content: A target's image's content.
69
+ """
70
+ del image_content
71
+ return secrets.randbelow(exclusive_upper_bound=6)
72
+
73
+
74
+ @beartype
75
+ class HardcodedTargetTrackingRater:
76
+ """A rater which returns a hardcoded number."""
77
+
78
+ def __init__(self, rating: int) -> None:
79
+ """
80
+ Args:
81
+ rating: The rating to return.
82
+ """
83
+ self._rating = rating
84
+
85
+ def __call__(self, image_content: bytes) -> int:
86
+ """A random target tracking rating.
87
+
88
+ Args:
89
+ image_content: A target's image's content.
90
+ """
91
+ del image_content
92
+ return self._rating
93
+
94
+
95
+ @beartype
96
+ class BrisqueTargetTrackingRater:
97
+ """A rater which returns a rating based on a BRISQUE score."""
98
+
99
+ def __call__(self, image_content: bytes) -> int:
100
+ """A rating based on a BRISQUE score.
101
+
102
+ This is a rough approximation of the quality score used by Vuforia, but
103
+ is not accurate. For example, our "corrupted_image" fixture is rated as
104
+ -2 by Vuforia, but is rated as 0 by this function.
105
+
106
+ Args:
107
+ image_content: A target's image's content.
108
+ """
109
+ return _get_brisque_target_tracking_rating(image_content=image_content)