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.
- mock_vws/__init__.py +9 -0
- mock_vws/_base64_decoding.py +35 -0
- mock_vws/_constants.py +84 -0
- mock_vws/_database_matchers.py +107 -0
- mock_vws/_flask_server/Dockerfile +32 -0
- mock_vws/_flask_server/__init__.py +1 -0
- mock_vws/_flask_server/healthcheck.py +31 -0
- mock_vws/_flask_server/target_manager.py +447 -0
- mock_vws/_flask_server/vwq.py +173 -0
- mock_vws/_flask_server/vws.py +954 -0
- mock_vws/_mock_common.py +75 -0
- mock_vws/_model_target_web_api.py +486 -0
- mock_vws/_query_tools.py +136 -0
- mock_vws/_query_validators/__init__.py +128 -0
- mock_vws/_query_validators/accept_header_validators.py +31 -0
- mock_vws/_query_validators/auth_validators.py +143 -0
- mock_vws/_query_validators/content_length_validators.py +90 -0
- mock_vws/_query_validators/content_type_validators.py +65 -0
- mock_vws/_query_validators/date_validators.py +110 -0
- mock_vws/_query_validators/exceptions.py +769 -0
- mock_vws/_query_validators/fields_validators.py +47 -0
- mock_vws/_query_validators/image_validators.py +197 -0
- mock_vws/_query_validators/include_target_data_validators.py +51 -0
- mock_vws/_query_validators/num_results_validators.py +64 -0
- mock_vws/_query_validators/project_state_validators.py +49 -0
- mock_vws/_requests_mock_server/__init__.py +1 -0
- mock_vws/_requests_mock_server/decorators.py +275 -0
- mock_vws/_requests_mock_server/mock_web_query_api.py +139 -0
- mock_vws/_requests_mock_server/mock_web_services_api.py +954 -0
- mock_vws/_respx_mock_server/__init__.py +1 -0
- mock_vws/_respx_mock_server/decorators.py +186 -0
- mock_vws/_services_validators/__init__.py +186 -0
- mock_vws/_services_validators/active_flag_validators.py +43 -0
- mock_vws/_services_validators/auth_validators.py +124 -0
- mock_vws/_services_validators/content_length_validators.py +102 -0
- mock_vws/_services_validators/content_type_validators.py +42 -0
- mock_vws/_services_validators/date_validators.py +78 -0
- mock_vws/_services_validators/exceptions.py +858 -0
- mock_vws/_services_validators/image_validators.py +220 -0
- mock_vws/_services_validators/json_validators.py +69 -0
- mock_vws/_services_validators/key_validators.py +171 -0
- mock_vws/_services_validators/metadata_validators.py +106 -0
- mock_vws/_services_validators/name_validators.py +235 -0
- mock_vws/_services_validators/project_state_validators.py +76 -0
- mock_vws/_services_validators/request_quota_validators.py +42 -0
- mock_vws/_services_validators/target_quota_validators.py +41 -0
- mock_vws/_services_validators/target_validators.py +69 -0
- mock_vws/_services_validators/width_validators.py +38 -0
- mock_vws/database.py +257 -0
- mock_vws/database_type.py +13 -0
- mock_vws/image_matchers.py +124 -0
- mock_vws/model_target.py +79 -0
- mock_vws/py.typed +0 -0
- mock_vws/states.py +20 -0
- mock_vws/target.py +285 -0
- mock_vws/target_manager.py +178 -0
- mock_vws/target_raters.py +109 -0
- vws_python_mock-2026.8.4.dist-info/METADATA +177 -0
- vws_python_mock-2026.8.4.dist-info/RECORD +62 -0
- vws_python_mock-2026.8.4.dist-info/WHEEL +5 -0
- vws_python_mock-2026.8.4.dist-info/licenses/LICENSE +21 -0
- vws_python_mock-2026.8.4.dist-info/top_level.txt +1 -0
mock_vws/_mock_common.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Common utilities for creating mock routes."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Iterable, Mapping
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from beartype import beartype
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@beartype
|
|
12
|
+
class MissingSchemeError(Exception):
|
|
13
|
+
"""Raised when a URL is missing a schema."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, url: str) -> None:
|
|
16
|
+
"""
|
|
17
|
+
Args:
|
|
18
|
+
url: The URL which is missing a scheme.
|
|
19
|
+
"""
|
|
20
|
+
super().__init__()
|
|
21
|
+
self.url = url
|
|
22
|
+
|
|
23
|
+
def __str__(self) -> str:
|
|
24
|
+
"""
|
|
25
|
+
Give a string representation of this error with a
|
|
26
|
+
suggestion.
|
|
27
|
+
"""
|
|
28
|
+
return (
|
|
29
|
+
f'Invalid URL "{self.url}": No scheme supplied. '
|
|
30
|
+
f'Perhaps you meant "https://{self.url}".'
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@beartype
|
|
35
|
+
@dataclass(frozen=True, kw_only=True)
|
|
36
|
+
class RequestData:
|
|
37
|
+
"""A library-agnostic representation of an HTTP request.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
method: The HTTP method of the request.
|
|
41
|
+
path: The path of the request.
|
|
42
|
+
headers: The headers sent with the request.
|
|
43
|
+
body: The body of the request.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
method: str
|
|
47
|
+
path: str
|
|
48
|
+
headers: Mapping[str, str]
|
|
49
|
+
body: bytes
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@beartype
|
|
53
|
+
@dataclass(frozen=True, kw_only=True)
|
|
54
|
+
class Route:
|
|
55
|
+
"""A representation of a VWS route.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
route_name: The name of the method.
|
|
59
|
+
path_pattern: The end part of a URL pattern. E.g. `/targets` or
|
|
60
|
+
`/targets/.+`.
|
|
61
|
+
http_methods: HTTP methods that map to the route function.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
route_name: str
|
|
65
|
+
path_pattern: str
|
|
66
|
+
http_methods: Iterable[str]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@beartype
|
|
70
|
+
def json_dump(*, body: dict[str, Any]) -> str:
|
|
71
|
+
"""
|
|
72
|
+
Returns:
|
|
73
|
+
JSON dump of data in the same way that Vuforia dumps data.
|
|
74
|
+
"""
|
|
75
|
+
return json.dumps(obj=body, separators=(",", ":"))
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"""A fake implementation of the Model Target Web API."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
import zipfile
|
|
8
|
+
from http import HTTPStatus
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import parse_qs
|
|
11
|
+
|
|
12
|
+
from beartype import beartype
|
|
13
|
+
|
|
14
|
+
from mock_vws._mock_common import RequestData, json_dump
|
|
15
|
+
from mock_vws.model_target import ModelTargetDataset, ModelTargetDatasetType
|
|
16
|
+
from mock_vws.target_manager import TargetManager
|
|
17
|
+
|
|
18
|
+
_ResponseType = tuple[int, dict[str, str], str | bytes]
|
|
19
|
+
_MAX_ADVANCED_MODEL_COUNT = 20
|
|
20
|
+
_JWT_DOT_COUNT = 2
|
|
21
|
+
_ZIP_EPOCH = (1980, 1, 1, 0, 0, 0)
|
|
22
|
+
_MOCK_MODEL_TARGET_CLIENT_ID = "client-id"
|
|
23
|
+
_MOCK_MODEL_TARGET_CLIENT_SECRET = "client-secret" # noqa: S105
|
|
24
|
+
# A stable mock value standing in for the user-id segment that real
|
|
25
|
+
# Vuforia embeds in some Model Target error targets such as
|
|
26
|
+
# ``userId:7635391``. The numeric portion is per-account in real Vuforia;
|
|
27
|
+
# the mock uses a fixed placeholder.
|
|
28
|
+
_MOCK_USER_TARGET = "userId:mock"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@beartype
|
|
32
|
+
def _json_response(
|
|
33
|
+
*,
|
|
34
|
+
status_code: HTTPStatus,
|
|
35
|
+
body: dict[str, Any],
|
|
36
|
+
) -> _ResponseType:
|
|
37
|
+
"""Return a JSON response."""
|
|
38
|
+
body_json = json_dump(body=body)
|
|
39
|
+
return (
|
|
40
|
+
status_code,
|
|
41
|
+
{
|
|
42
|
+
"Content-Length": str(object=len(body_json)),
|
|
43
|
+
"Content-Type": "application/json",
|
|
44
|
+
},
|
|
45
|
+
body_json,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@beartype
|
|
50
|
+
def _error_response(
|
|
51
|
+
*,
|
|
52
|
+
status_code: HTTPStatus,
|
|
53
|
+
code: str,
|
|
54
|
+
message: str,
|
|
55
|
+
target: str | None,
|
|
56
|
+
details: list[dict[str, str]] | None,
|
|
57
|
+
) -> _ResponseType:
|
|
58
|
+
"""Return an error response shaped like the Model Target Web API."""
|
|
59
|
+
error: dict[str, Any] = {"code": code, "message": message}
|
|
60
|
+
if target is not None:
|
|
61
|
+
error["target"] = target
|
|
62
|
+
if details is not None:
|
|
63
|
+
error["details"] = details
|
|
64
|
+
return _json_response(status_code=status_code, body={"error": error})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@beartype
|
|
68
|
+
def _validation_error_response(
|
|
69
|
+
*,
|
|
70
|
+
details: list[dict[str, str]],
|
|
71
|
+
) -> _ResponseType:
|
|
72
|
+
"""Return a Vuforia-style validation error.
|
|
73
|
+
|
|
74
|
+
Real Vuforia tags each validation error with a per-request UUID that
|
|
75
|
+
appears in both ``message`` and ``target``. The mock generates a fresh
|
|
76
|
+
UUID so the shape matches.
|
|
77
|
+
"""
|
|
78
|
+
request_uuid = uuid.uuid4().hex
|
|
79
|
+
return _error_response(
|
|
80
|
+
status_code=HTTPStatus.BAD_REQUEST,
|
|
81
|
+
code="BAD_REQUEST",
|
|
82
|
+
message=f"Validation error for request {request_uuid}",
|
|
83
|
+
target=request_uuid,
|
|
84
|
+
details=details,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@beartype
|
|
89
|
+
def _oauth2_error_response(
|
|
90
|
+
*,
|
|
91
|
+
status_code: HTTPStatus,
|
|
92
|
+
body: dict[str, str],
|
|
93
|
+
) -> _ResponseType:
|
|
94
|
+
"""Return an OAuth2 error response."""
|
|
95
|
+
return _json_response(status_code=status_code, body=body)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@beartype
|
|
99
|
+
def _get_header(request: RequestData, name: str) -> str | None:
|
|
100
|
+
"""Return a request header, case-insensitively."""
|
|
101
|
+
lower_name = name.casefold()
|
|
102
|
+
for key, value in request.headers.items():
|
|
103
|
+
if key.casefold() == lower_name:
|
|
104
|
+
return value
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@beartype
|
|
109
|
+
def _basic_auth_credentials(auth_header: str | None) -> tuple[str, str] | None:
|
|
110
|
+
"""Return HTTP Basic credentials from an authorization header."""
|
|
111
|
+
if auth_header is None or not auth_header.startswith("Basic "):
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
encoded_credentials = auth_header.removeprefix("Basic ").strip()
|
|
115
|
+
try:
|
|
116
|
+
decoded_credentials = base64.b64decode(
|
|
117
|
+
s=encoded_credentials,
|
|
118
|
+
validate=True,
|
|
119
|
+
).decode(encoding="utf-8")
|
|
120
|
+
except ValueError:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
client_id, separator, client_secret = decoded_credentials.partition(":")
|
|
124
|
+
if not separator:
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
return client_id, client_secret
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@beartype
|
|
131
|
+
def _require_bearer_token(request: RequestData) -> _ResponseType | None:
|
|
132
|
+
"""Return an error response if the request has no bearer token."""
|
|
133
|
+
auth_header = _get_header(request=request, name="Authorization")
|
|
134
|
+
if auth_header is None or not auth_header.startswith("Bearer "):
|
|
135
|
+
return _error_response(
|
|
136
|
+
status_code=HTTPStatus.UNAUTHORIZED,
|
|
137
|
+
code="401",
|
|
138
|
+
message="no Bearer token",
|
|
139
|
+
target="jwt",
|
|
140
|
+
details=None,
|
|
141
|
+
)
|
|
142
|
+
bearer_token = auth_header.removeprefix("Bearer ").strip()
|
|
143
|
+
if not bearer_token:
|
|
144
|
+
return _error_response(
|
|
145
|
+
status_code=HTTPStatus.UNAUTHORIZED,
|
|
146
|
+
code="401",
|
|
147
|
+
message="no Bearer token",
|
|
148
|
+
target="jwt",
|
|
149
|
+
details=None,
|
|
150
|
+
)
|
|
151
|
+
if bearer_token.count(".") != _JWT_DOT_COUNT:
|
|
152
|
+
return _error_response(
|
|
153
|
+
status_code=HTTPStatus.UNAUTHORIZED,
|
|
154
|
+
code="401",
|
|
155
|
+
message="Invalid JWT serialization: Missing dot delimiter(s)",
|
|
156
|
+
target="jwt",
|
|
157
|
+
details=None,
|
|
158
|
+
)
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@beartype
|
|
163
|
+
def _fake_jwt(*, token_source: bytes) -> str:
|
|
164
|
+
"""Return a deterministic bearer token for the mock."""
|
|
165
|
+
|
|
166
|
+
def encode_part(value: dict[str, Any]) -> str:
|
|
167
|
+
"""Return a base64url-encoded token part."""
|
|
168
|
+
raw_part = json.dumps(
|
|
169
|
+
obj=value,
|
|
170
|
+
sort_keys=True,
|
|
171
|
+
separators=(",", ":"),
|
|
172
|
+
).encode(encoding="utf-8")
|
|
173
|
+
return (
|
|
174
|
+
base64.urlsafe_b64encode(s=raw_part)
|
|
175
|
+
.decode(
|
|
176
|
+
encoding="ascii",
|
|
177
|
+
)
|
|
178
|
+
.rstrip("=")
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
header = encode_part(value={"alg": "mock", "typ": "JWT"})
|
|
182
|
+
payload = encode_part(
|
|
183
|
+
value={
|
|
184
|
+
"aud": "vuforia-model-target",
|
|
185
|
+
"src": base64.urlsafe_b64encode(s=token_source)
|
|
186
|
+
.decode(
|
|
187
|
+
encoding="ascii",
|
|
188
|
+
)
|
|
189
|
+
.rstrip("="),
|
|
190
|
+
},
|
|
191
|
+
)
|
|
192
|
+
return f"{header}.{payload}.mock-signature"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@beartype
|
|
196
|
+
def oauth2_token(request: RequestData) -> _ResponseType:
|
|
197
|
+
"""Return a fake OAuth2 access token."""
|
|
198
|
+
auth_header = _get_header(request=request, name="Authorization")
|
|
199
|
+
form = parse_qs(qs=request.body.decode(encoding="utf-8"))
|
|
200
|
+
grant_type = form.get("grant_type", ["client_credentials"])[0]
|
|
201
|
+
if grant_type != "client_credentials":
|
|
202
|
+
return _oauth2_error_response(
|
|
203
|
+
status_code=HTTPStatus.BAD_REQUEST,
|
|
204
|
+
body={"error": "unsupported_grant_type"},
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
basic_credentials = _basic_auth_credentials(auth_header=auth_header)
|
|
208
|
+
if basic_credentials is None:
|
|
209
|
+
return _oauth2_error_response(
|
|
210
|
+
status_code=HTTPStatus.UNAUTHORIZED,
|
|
211
|
+
body={
|
|
212
|
+
"error": "invalid_request",
|
|
213
|
+
"error_description": (
|
|
214
|
+
"Missing or invalid authorization header"
|
|
215
|
+
),
|
|
216
|
+
},
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
if basic_credentials != (
|
|
220
|
+
_MOCK_MODEL_TARGET_CLIENT_ID,
|
|
221
|
+
_MOCK_MODEL_TARGET_CLIENT_SECRET,
|
|
222
|
+
):
|
|
223
|
+
return _oauth2_error_response(
|
|
224
|
+
status_code=HTTPStatus.UNAUTHORIZED,
|
|
225
|
+
body={"error": "invalid_client"},
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
token_source = request.body or (auth_header or "").encode()
|
|
229
|
+
return _json_response(
|
|
230
|
+
status_code=HTTPStatus.OK,
|
|
231
|
+
body={
|
|
232
|
+
"access_token": _fake_jwt(token_source=token_source),
|
|
233
|
+
"token_type": "bearer",
|
|
234
|
+
"expires_in": 3600,
|
|
235
|
+
},
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@beartype
|
|
240
|
+
def _load_request_json(request: RequestData) -> dict[str, Any] | _ResponseType:
|
|
241
|
+
"""Load a Model Target dataset creation request body."""
|
|
242
|
+
content_type = _get_header(request=request, name="Content-Type") or ""
|
|
243
|
+
if "application/json" not in content_type:
|
|
244
|
+
return _error_response(
|
|
245
|
+
status_code=HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
|
|
246
|
+
code="ERROR",
|
|
247
|
+
message="Expecting text/json or application/json body",
|
|
248
|
+
target=None,
|
|
249
|
+
details=None,
|
|
250
|
+
)
|
|
251
|
+
try:
|
|
252
|
+
request_json: dict[str, Any] = json.loads(s=request.body)
|
|
253
|
+
except json.JSONDecodeError as exc:
|
|
254
|
+
return _error_response(
|
|
255
|
+
status_code=HTTPStatus.BAD_REQUEST,
|
|
256
|
+
code="ERROR",
|
|
257
|
+
message=f"Invalid Json: {exc}",
|
|
258
|
+
target=None,
|
|
259
|
+
details=None,
|
|
260
|
+
)
|
|
261
|
+
return request_json
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@beartype
|
|
265
|
+
def _validate_dataset_request(
|
|
266
|
+
*,
|
|
267
|
+
request_json: dict[str, Any],
|
|
268
|
+
dataset_type: ModelTargetDatasetType,
|
|
269
|
+
) -> _ResponseType | None:
|
|
270
|
+
"""Validate the dataset request enough for useful mock feedback."""
|
|
271
|
+
missing_details = [
|
|
272
|
+
{
|
|
273
|
+
"code": "VALIDATION_ERROR",
|
|
274
|
+
"message": f"/{field}: element is required",
|
|
275
|
+
}
|
|
276
|
+
for field in ("models", "name", "targetSdk")
|
|
277
|
+
if field not in request_json
|
|
278
|
+
]
|
|
279
|
+
if missing_details:
|
|
280
|
+
return _validation_error_response(details=missing_details)
|
|
281
|
+
|
|
282
|
+
models_value = request_json["models"]
|
|
283
|
+
if not isinstance(models_value, list):
|
|
284
|
+
return _validation_error_response(
|
|
285
|
+
details=[
|
|
286
|
+
{
|
|
287
|
+
"code": "VALIDATION_ERROR",
|
|
288
|
+
"message": "/models: error.expected.jsarray",
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
models: list[Any] = [*models_value]
|
|
294
|
+
model_count = len(models)
|
|
295
|
+
|
|
296
|
+
if dataset_type == ModelTargetDatasetType.STANDARD and model_count != 1:
|
|
297
|
+
return _validation_error_response(
|
|
298
|
+
details=[
|
|
299
|
+
{
|
|
300
|
+
"code": "VALIDATION_ERROR",
|
|
301
|
+
"message": "exactly one model should be provided",
|
|
302
|
+
},
|
|
303
|
+
],
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
if (
|
|
307
|
+
dataset_type == ModelTargetDatasetType.ADVANCED
|
|
308
|
+
and not 1 <= model_count <= _MAX_ADVANCED_MODEL_COUNT
|
|
309
|
+
):
|
|
310
|
+
return _validation_error_response(
|
|
311
|
+
details=[
|
|
312
|
+
{
|
|
313
|
+
"code": "VALIDATION_ERROR",
|
|
314
|
+
"message": (
|
|
315
|
+
"models must contain between 1 and "
|
|
316
|
+
f"{_MAX_ADVANCED_MODEL_COUNT} entries"
|
|
317
|
+
),
|
|
318
|
+
},
|
|
319
|
+
],
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@beartype
|
|
326
|
+
def create_model_target_dataset(
|
|
327
|
+
*,
|
|
328
|
+
request: RequestData,
|
|
329
|
+
target_manager: TargetManager,
|
|
330
|
+
processing_time_seconds: float,
|
|
331
|
+
dataset_type: ModelTargetDatasetType,
|
|
332
|
+
) -> _ResponseType:
|
|
333
|
+
"""Create a standard or advanced Model Target dataset."""
|
|
334
|
+
auth_error = _require_bearer_token(request=request)
|
|
335
|
+
if auth_error is not None:
|
|
336
|
+
return auth_error
|
|
337
|
+
|
|
338
|
+
request_json_or_error = _load_request_json(request=request)
|
|
339
|
+
if not isinstance(request_json_or_error, dict):
|
|
340
|
+
return request_json_or_error
|
|
341
|
+
|
|
342
|
+
validation_error = _validate_dataset_request(
|
|
343
|
+
request_json=request_json_or_error,
|
|
344
|
+
dataset_type=dataset_type,
|
|
345
|
+
)
|
|
346
|
+
if validation_error is not None:
|
|
347
|
+
return validation_error
|
|
348
|
+
|
|
349
|
+
dataset = ModelTargetDataset(
|
|
350
|
+
request_body=request_json_or_error,
|
|
351
|
+
dataset_type=dataset_type,
|
|
352
|
+
processing_time_seconds=processing_time_seconds,
|
|
353
|
+
)
|
|
354
|
+
target_manager.add_model_target_dataset(model_target_dataset=dataset)
|
|
355
|
+
return _json_response(
|
|
356
|
+
status_code=HTTPStatus.CREATED,
|
|
357
|
+
body={"uuid": dataset.uuid_},
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@beartype
|
|
362
|
+
def get_model_target_dataset_status(
|
|
363
|
+
*,
|
|
364
|
+
request: RequestData,
|
|
365
|
+
target_manager: TargetManager,
|
|
366
|
+
dataset_uuid: str,
|
|
367
|
+
) -> _ResponseType:
|
|
368
|
+
"""Return the status of a Model Target dataset."""
|
|
369
|
+
auth_error = _require_bearer_token(request=request)
|
|
370
|
+
if auth_error is not None:
|
|
371
|
+
return auth_error
|
|
372
|
+
try:
|
|
373
|
+
dataset = target_manager.model_target_datasets[dataset_uuid]
|
|
374
|
+
except KeyError:
|
|
375
|
+
return _error_response(
|
|
376
|
+
status_code=HTTPStatus.NOT_FOUND,
|
|
377
|
+
code="NOT_FOUND",
|
|
378
|
+
message=(
|
|
379
|
+
"Could not find a model-view database with uuid "
|
|
380
|
+
f"{dataset_uuid}"
|
|
381
|
+
),
|
|
382
|
+
target=_MOCK_USER_TARGET,
|
|
383
|
+
details=None,
|
|
384
|
+
)
|
|
385
|
+
return _json_response(
|
|
386
|
+
status_code=HTTPStatus.OK,
|
|
387
|
+
body=dataset.status_body(),
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@beartype
|
|
392
|
+
def _dataset_zip_bytes(dataset: ModelTargetDataset) -> bytes:
|
|
393
|
+
"""Return a small valid zip file for a generated dataset."""
|
|
394
|
+
zip_buffer = io.BytesIO()
|
|
395
|
+
with zipfile.ZipFile(file=zip_buffer, mode="w") as zip_file:
|
|
396
|
+
dataset_file = zipfile.ZipInfo(
|
|
397
|
+
filename="dataset.json",
|
|
398
|
+
date_time=_ZIP_EPOCH,
|
|
399
|
+
)
|
|
400
|
+
zip_file.writestr(
|
|
401
|
+
zinfo_or_arcname=dataset_file,
|
|
402
|
+
data=json.dumps(
|
|
403
|
+
obj={
|
|
404
|
+
"uuid": dataset.uuid_,
|
|
405
|
+
"type": dataset.dataset_type.value,
|
|
406
|
+
"request": dataset.request_body,
|
|
407
|
+
},
|
|
408
|
+
separators=(",", ":"),
|
|
409
|
+
sort_keys=True,
|
|
410
|
+
),
|
|
411
|
+
)
|
|
412
|
+
return zip_buffer.getvalue()
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@beartype
|
|
416
|
+
def download_model_target_dataset(
|
|
417
|
+
*,
|
|
418
|
+
request: RequestData,
|
|
419
|
+
target_manager: TargetManager,
|
|
420
|
+
dataset_uuid: str,
|
|
421
|
+
) -> _ResponseType:
|
|
422
|
+
"""Download a generated Model Target dataset."""
|
|
423
|
+
auth_error = _require_bearer_token(request=request)
|
|
424
|
+
if auth_error is not None:
|
|
425
|
+
return auth_error
|
|
426
|
+
try:
|
|
427
|
+
dataset = target_manager.model_target_datasets[dataset_uuid]
|
|
428
|
+
except KeyError:
|
|
429
|
+
return _error_response(
|
|
430
|
+
status_code=HTTPStatus.NOT_FOUND,
|
|
431
|
+
code="NOT_FOUND",
|
|
432
|
+
message=(
|
|
433
|
+
"Could not find a model-view database with uuid "
|
|
434
|
+
f"{dataset_uuid}"
|
|
435
|
+
),
|
|
436
|
+
target=_MOCK_USER_TARGET,
|
|
437
|
+
details=None,
|
|
438
|
+
)
|
|
439
|
+
if dataset.status != "done":
|
|
440
|
+
return _error_response(
|
|
441
|
+
status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
|
|
442
|
+
code="UNSUPPORTED_STATE",
|
|
443
|
+
message=(
|
|
444
|
+
f"Training status for dataset {dataset_uuid} is "
|
|
445
|
+
"not-started != done"
|
|
446
|
+
),
|
|
447
|
+
target=dataset_uuid,
|
|
448
|
+
details=None,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
body = _dataset_zip_bytes(dataset=dataset)
|
|
452
|
+
return (
|
|
453
|
+
HTTPStatus.OK,
|
|
454
|
+
{
|
|
455
|
+
"Content-Length": str(object=len(body)),
|
|
456
|
+
"Content-Type": "application/zip",
|
|
457
|
+
},
|
|
458
|
+
body,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
@beartype
|
|
463
|
+
def delete_model_target_dataset(
|
|
464
|
+
*,
|
|
465
|
+
request: RequestData,
|
|
466
|
+
target_manager: TargetManager,
|
|
467
|
+
dataset_uuid: str,
|
|
468
|
+
) -> _ResponseType:
|
|
469
|
+
"""Delete a Model Target dataset."""
|
|
470
|
+
auth_error = _require_bearer_token(request=request)
|
|
471
|
+
if auth_error is not None:
|
|
472
|
+
return auth_error
|
|
473
|
+
try:
|
|
474
|
+
target_manager.remove_model_target_dataset(dataset_uuid=dataset_uuid)
|
|
475
|
+
except KeyError:
|
|
476
|
+
return _error_response(
|
|
477
|
+
status_code=HTTPStatus.NOT_FOUND,
|
|
478
|
+
code="NOT_FOUND",
|
|
479
|
+
message=(
|
|
480
|
+
"Could not find a model-view database with uuid "
|
|
481
|
+
f"{dataset_uuid}"
|
|
482
|
+
),
|
|
483
|
+
target=_MOCK_USER_TARGET,
|
|
484
|
+
details=None,
|
|
485
|
+
)
|
|
486
|
+
return HTTPStatus.OK, {"Content-Length": "0"}, ""
|
mock_vws/_query_tools.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Tools for making Vuforia queries."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import io
|
|
5
|
+
import uuid
|
|
6
|
+
from collections.abc import Iterable, Mapping
|
|
7
|
+
from email.message import EmailMessage
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from beartype import beartype
|
|
11
|
+
from werkzeug.formparser import MultiPartParser
|
|
12
|
+
|
|
13
|
+
from mock_vws._base64_decoding import decode_base64
|
|
14
|
+
from mock_vws._constants import ResultCodes, TargetStatuses
|
|
15
|
+
from mock_vws._database_matchers import get_database_matching_client_keys
|
|
16
|
+
from mock_vws._mock_common import json_dump
|
|
17
|
+
from mock_vws.database import CloudDatabase
|
|
18
|
+
from mock_vws.image_matchers import ImageMatcher
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@beartype
|
|
22
|
+
def get_query_match_response_text(
|
|
23
|
+
*,
|
|
24
|
+
request_headers: Mapping[str, str],
|
|
25
|
+
request_body: bytes,
|
|
26
|
+
request_method: str,
|
|
27
|
+
request_path: str,
|
|
28
|
+
databases: Iterable[CloudDatabase],
|
|
29
|
+
query_match_checker: ImageMatcher,
|
|
30
|
+
) -> str:
|
|
31
|
+
"""
|
|
32
|
+
Args:
|
|
33
|
+
request_path: The path of the request.
|
|
34
|
+
request_headers: The headers sent with the request.
|
|
35
|
+
request_body: The body of the request.
|
|
36
|
+
request_method: The HTTP method of the request.
|
|
37
|
+
databases: All Vuforia databases.
|
|
38
|
+
query_match_checker: A callable which takes two image values and
|
|
39
|
+
returns whether they match.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
The response text for a query endpoint request.
|
|
43
|
+
"""
|
|
44
|
+
email_message = EmailMessage()
|
|
45
|
+
email_message["Content-Type"] = request_headers["Content-Type"]
|
|
46
|
+
boundary = email_message.get_boundary(failobj="")
|
|
47
|
+
|
|
48
|
+
parser = MultiPartParser()
|
|
49
|
+
fields, files = parser.parse(
|
|
50
|
+
stream=io.BytesIO(initial_bytes=request_body),
|
|
51
|
+
boundary=boundary.encode(encoding="utf-8"),
|
|
52
|
+
content_length=len(request_body),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
max_num_results = fields.get(key="max_num_results", default="1")
|
|
56
|
+
include_target_data = fields.get(
|
|
57
|
+
key="include_target_data",
|
|
58
|
+
default="top",
|
|
59
|
+
).lower()
|
|
60
|
+
|
|
61
|
+
image_part = files["image"]
|
|
62
|
+
image_value = image_part.stream.read()
|
|
63
|
+
|
|
64
|
+
database = get_database_matching_client_keys(
|
|
65
|
+
request_headers=request_headers,
|
|
66
|
+
request_body=request_body,
|
|
67
|
+
request_method=request_method,
|
|
68
|
+
request_path=request_path,
|
|
69
|
+
databases=databases,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
matching_targets = [
|
|
73
|
+
target
|
|
74
|
+
for target in database.targets
|
|
75
|
+
if query_match_checker(
|
|
76
|
+
first_image_content=target.image_value,
|
|
77
|
+
second_image_content=image_value,
|
|
78
|
+
)
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
not_deleted_matches = [
|
|
82
|
+
target
|
|
83
|
+
for target in matching_targets
|
|
84
|
+
if target.active_flag
|
|
85
|
+
# In the real Vuforia, targets which have just
|
|
86
|
+
# been deleted may still get recognized.
|
|
87
|
+
# We document this difference in ``differences-to-vws.rst``.
|
|
88
|
+
and not target.delete_date
|
|
89
|
+
and target.status == TargetStatuses.SUCCESS.value
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
all_quality_matches = not_deleted_matches
|
|
93
|
+
minimum_rating = 0
|
|
94
|
+
matches = [
|
|
95
|
+
match
|
|
96
|
+
for match in all_quality_matches
|
|
97
|
+
if match.tracking_rating > minimum_rating
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
results: list[dict[str, Any]] = []
|
|
101
|
+
for target in matches:
|
|
102
|
+
target_timestamp = target.last_modified_date.timestamp()
|
|
103
|
+
if target.application_metadata is None:
|
|
104
|
+
application_metadata = None
|
|
105
|
+
else:
|
|
106
|
+
application_metadata = base64.b64encode(
|
|
107
|
+
s=decode_base64(encoded_data=target.application_metadata),
|
|
108
|
+
).decode(encoding="ascii")
|
|
109
|
+
target_data = {
|
|
110
|
+
"target_timestamp": int(target_timestamp),
|
|
111
|
+
"name": target.name,
|
|
112
|
+
"application_metadata": application_metadata,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if include_target_data == "all" or (
|
|
116
|
+
include_target_data == "top" and not results
|
|
117
|
+
):
|
|
118
|
+
result = {
|
|
119
|
+
"target_id": target.target_id,
|
|
120
|
+
"target_data": target_data,
|
|
121
|
+
}
|
|
122
|
+
else:
|
|
123
|
+
result = {
|
|
124
|
+
"target_id": target.target_id,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
results.append(result)
|
|
128
|
+
|
|
129
|
+
results = results[: int(max_num_results)]
|
|
130
|
+
body = {
|
|
131
|
+
"result_code": ResultCodes.SUCCESS.value,
|
|
132
|
+
"results": results,
|
|
133
|
+
"query_id": uuid.uuid4().hex,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return json_dump(body=body)
|