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
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Validators for the fields given."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from email.message import EmailMessage
|
|
7
|
+
|
|
8
|
+
from beartype import beartype
|
|
9
|
+
from werkzeug.formparser import MultiPartParser
|
|
10
|
+
|
|
11
|
+
from mock_vws._query_validators.exceptions import UnknownParametersError
|
|
12
|
+
|
|
13
|
+
_LOGGER = logging.getLogger(name=__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@beartype
|
|
17
|
+
def validate_extra_fields(
|
|
18
|
+
*,
|
|
19
|
+
request_headers: Mapping[str, str],
|
|
20
|
+
request_body: bytes,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Validate that the no unknown fields are given.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
request_headers: The headers sent with the request.
|
|
26
|
+
request_body: The body of the request.
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
UnknownParametersError: Extra fields are given.
|
|
30
|
+
"""
|
|
31
|
+
email_message = EmailMessage()
|
|
32
|
+
email_message["Content-Type"] = request_headers["Content-Type"]
|
|
33
|
+
boundary = email_message.get_boundary(failobj="")
|
|
34
|
+
parser = MultiPartParser()
|
|
35
|
+
fields, files = parser.parse(
|
|
36
|
+
stream=io.BytesIO(initial_bytes=request_body),
|
|
37
|
+
boundary=boundary.encode(encoding="utf-8"),
|
|
38
|
+
content_length=len(request_body),
|
|
39
|
+
)
|
|
40
|
+
parsed_keys = fields.keys() | files.keys()
|
|
41
|
+
known_parameters = {"image", "max_num_results", "include_target_data"}
|
|
42
|
+
|
|
43
|
+
if not parsed_keys - known_parameters:
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
_LOGGER.warning(msg="Unknown parameters are given.")
|
|
47
|
+
raise UnknownParametersError
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Input validators for the image field use in the mock query API."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from email.message import EmailMessage
|
|
7
|
+
|
|
8
|
+
from beartype import beartype
|
|
9
|
+
from PIL import Image
|
|
10
|
+
from werkzeug.datastructures import FileStorage, MultiDict
|
|
11
|
+
from werkzeug.formparser import MultiPartParser
|
|
12
|
+
|
|
13
|
+
from mock_vws._query_validators.exceptions import (
|
|
14
|
+
BadImageError,
|
|
15
|
+
ImageNotGivenError,
|
|
16
|
+
RequestEntityTooLargeError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_LOGGER = logging.getLogger(name=__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@beartype
|
|
23
|
+
def _parse_multipart_files(
|
|
24
|
+
*,
|
|
25
|
+
request_headers: Mapping[str, str],
|
|
26
|
+
request_body: bytes,
|
|
27
|
+
) -> MultiDict[str, FileStorage]:
|
|
28
|
+
"""Parse the multipart body and return the files section.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
request_headers: The headers sent with the request.
|
|
32
|
+
request_body: The body of the request.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
The files parsed from the multipart body.
|
|
36
|
+
"""
|
|
37
|
+
email_message = EmailMessage()
|
|
38
|
+
email_message["Content-Type"] = request_headers["Content-Type"]
|
|
39
|
+
boundary = email_message.get_boundary(failobj="")
|
|
40
|
+
parser = MultiPartParser()
|
|
41
|
+
_, files = parser.parse(
|
|
42
|
+
stream=io.BytesIO(initial_bytes=request_body),
|
|
43
|
+
boundary=boundary.encode(encoding="utf-8"),
|
|
44
|
+
content_length=len(request_body),
|
|
45
|
+
)
|
|
46
|
+
return files
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@beartype
|
|
50
|
+
def validate_image_field_given(
|
|
51
|
+
*,
|
|
52
|
+
request_headers: Mapping[str, str],
|
|
53
|
+
request_body: bytes,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Validate that the image field is given.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
request_headers: The headers sent with the request.
|
|
59
|
+
request_body: The body of the request.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
ImageNotGivenError: The image field is not given.
|
|
63
|
+
"""
|
|
64
|
+
files = _parse_multipart_files(
|
|
65
|
+
request_headers=request_headers,
|
|
66
|
+
request_body=request_body,
|
|
67
|
+
)
|
|
68
|
+
if files.get(key="image") is not None:
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
_LOGGER.warning(msg="The image field is not given.")
|
|
72
|
+
raise ImageNotGivenError
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@beartype
|
|
76
|
+
def validate_image_file_size(
|
|
77
|
+
*,
|
|
78
|
+
request_headers: Mapping[str, str],
|
|
79
|
+
request_body: bytes,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Validate the file size of the image given to the query endpoint.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
request_headers: The headers sent with the request.
|
|
85
|
+
request_body: The body of the request.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
RequestEntityTooLargeError: The image file size is too large.
|
|
89
|
+
"""
|
|
90
|
+
files = _parse_multipart_files(
|
|
91
|
+
request_headers=request_headers,
|
|
92
|
+
request_body=request_body,
|
|
93
|
+
)
|
|
94
|
+
image_part = files["image"]
|
|
95
|
+
image_value = image_part.stream.read()
|
|
96
|
+
|
|
97
|
+
# This is the documented maximum size of a PNG as per.
|
|
98
|
+
# https://developer.vuforia.com/library/web-api/vuforia-query-web-api.
|
|
99
|
+
# However, the tests show that this maximum size also applies to JPEG
|
|
100
|
+
# files.
|
|
101
|
+
max_bytes = 2 * 1024 * 1024
|
|
102
|
+
# Ignore coverage on this as there is a bug in urllib3 which means that we
|
|
103
|
+
# do not trigger this exception.
|
|
104
|
+
# See https://github.com/urllib3/urllib3/issues/2733.
|
|
105
|
+
if len(image_value) > max_bytes: # pragma: no cover
|
|
106
|
+
_LOGGER.warning(msg="The image file size is too large.")
|
|
107
|
+
raise RequestEntityTooLargeError
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@beartype
|
|
111
|
+
def validate_image_dimensions(
|
|
112
|
+
*,
|
|
113
|
+
request_headers: Mapping[str, str],
|
|
114
|
+
request_body: bytes,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Validate the dimensions the image given to the query endpoint.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
request_headers: The headers sent with the request.
|
|
120
|
+
request_body: The body of the request.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
BadImageError: The image is given and is not within the maximum width
|
|
124
|
+
and height limits.
|
|
125
|
+
"""
|
|
126
|
+
files = _parse_multipart_files(
|
|
127
|
+
request_headers=request_headers,
|
|
128
|
+
request_body=request_body,
|
|
129
|
+
)
|
|
130
|
+
image_part = files["image"]
|
|
131
|
+
image_value = image_part.stream.read()
|
|
132
|
+
image_file = io.BytesIO(initial_bytes=image_value)
|
|
133
|
+
with Image.open(fp=image_file) as pil_image:
|
|
134
|
+
max_width = 30000
|
|
135
|
+
max_height = 30000
|
|
136
|
+
if pil_image.height <= max_height and pil_image.width <= max_width:
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
_LOGGER.warning(msg="The image dimensions are too large.")
|
|
140
|
+
raise BadImageError
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@beartype
|
|
144
|
+
def validate_image_format(
|
|
145
|
+
*,
|
|
146
|
+
request_headers: Mapping[str, str],
|
|
147
|
+
request_body: bytes,
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Validate the format of the image given to the query endpoint.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
request_headers: The headers sent with the request.
|
|
153
|
+
request_body: The body of the request.
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
BadImageError: The image is given and is not either a PNG or a JPEG.
|
|
157
|
+
"""
|
|
158
|
+
files = _parse_multipart_files(
|
|
159
|
+
request_headers=request_headers,
|
|
160
|
+
request_body=request_body,
|
|
161
|
+
)
|
|
162
|
+
image_part = files["image"]
|
|
163
|
+
with Image.open(fp=image_part.stream) as pil_image:
|
|
164
|
+
if pil_image.format in {"PNG", "JPEG"}:
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
_LOGGER.warning(msg="The image format is not PNG or JPEG.")
|
|
168
|
+
raise BadImageError
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@beartype
|
|
172
|
+
def validate_image_is_image(
|
|
173
|
+
*,
|
|
174
|
+
request_headers: Mapping[str, str],
|
|
175
|
+
request_body: bytes,
|
|
176
|
+
) -> None:
|
|
177
|
+
"""Validate that the given image data is actually an image file.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
request_headers: The headers sent with the request.
|
|
181
|
+
request_body: The body of the request.
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
BadImageError: Image data is given and it is not an image file.
|
|
185
|
+
"""
|
|
186
|
+
files = _parse_multipart_files(
|
|
187
|
+
request_headers=request_headers,
|
|
188
|
+
request_body=request_body,
|
|
189
|
+
)
|
|
190
|
+
image_file = files["image"].stream
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
with Image.open(fp=image_file) as _:
|
|
194
|
+
pass
|
|
195
|
+
except OSError as exc:
|
|
196
|
+
_LOGGER.warning(msg="The image is not an image file.")
|
|
197
|
+
raise BadImageError from exc
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Validators for the ``include_target_data`` field."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from email.message import EmailMessage
|
|
7
|
+
|
|
8
|
+
from beartype import beartype
|
|
9
|
+
from werkzeug.formparser import MultiPartParser
|
|
10
|
+
|
|
11
|
+
from mock_vws._query_validators.exceptions import InvalidIncludeTargetDataError
|
|
12
|
+
|
|
13
|
+
_LOGGER = logging.getLogger(name=__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@beartype
|
|
17
|
+
def validate_include_target_data(
|
|
18
|
+
*,
|
|
19
|
+
request_headers: Mapping[str, str],
|
|
20
|
+
request_body: bytes,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Validate the ``include_target_data`` field is either an accepted
|
|
23
|
+
value
|
|
24
|
+
or not given.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
request_headers: The headers sent with the request.
|
|
28
|
+
request_body: The body of the request.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
InvalidIncludeTargetDataError: The ``include_target_data`` field is not
|
|
32
|
+
an accepted value.
|
|
33
|
+
"""
|
|
34
|
+
email_message = EmailMessage()
|
|
35
|
+
email_message["Content-Type"] = request_headers["Content-Type"]
|
|
36
|
+
boundary = email_message.get_boundary(failobj="")
|
|
37
|
+
parser = MultiPartParser()
|
|
38
|
+
fields, _ = parser.parse(
|
|
39
|
+
stream=io.BytesIO(initial_bytes=request_body),
|
|
40
|
+
boundary=boundary.encode(encoding="utf-8"),
|
|
41
|
+
content_length=len(request_body),
|
|
42
|
+
)
|
|
43
|
+
include_target_data = fields.get(key="include_target_data", default="top")
|
|
44
|
+
allowed_included_target_data = {"top", "all", "none"}
|
|
45
|
+
if include_target_data.lower() in allowed_included_target_data:
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
_LOGGER.warning(
|
|
49
|
+
msg="The include_target_data field is not an accepted value.",
|
|
50
|
+
)
|
|
51
|
+
raise InvalidIncludeTargetDataError(given_value=include_target_data)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Validators for the ``max_num_results`` fields."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from email.message import EmailMessage
|
|
7
|
+
|
|
8
|
+
from beartype import beartype
|
|
9
|
+
from werkzeug.formparser import MultiPartParser
|
|
10
|
+
|
|
11
|
+
from mock_vws._query_validators.exceptions import (
|
|
12
|
+
InvalidMaxNumResultsError,
|
|
13
|
+
MaxNumResultsOutOfRangeError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
_LOGGER = logging.getLogger(name=__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@beartype
|
|
20
|
+
def validate_max_num_results(
|
|
21
|
+
*,
|
|
22
|
+
request_headers: Mapping[str, str],
|
|
23
|
+
request_body: bytes,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Validate the ``max_num_results`` field is either an integer within
|
|
26
|
+
range
|
|
27
|
+
or not given.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
request_headers: The headers sent with the request.
|
|
31
|
+
request_body: The body of the request.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
InvalidMaxNumResultsError: The ``max_num_results`` given is not an
|
|
35
|
+
integer less than or equal to the max integer in Java.
|
|
36
|
+
MaxNumResultsOutOfRangeError: The ``max_num_results`` given is not in
|
|
37
|
+
range.
|
|
38
|
+
"""
|
|
39
|
+
email_message = EmailMessage()
|
|
40
|
+
email_message["Content-Type"] = request_headers["Content-Type"]
|
|
41
|
+
boundary = email_message.get_boundary(failobj="")
|
|
42
|
+
parser = MultiPartParser()
|
|
43
|
+
fields, _ = parser.parse(
|
|
44
|
+
stream=io.BytesIO(initial_bytes=request_body),
|
|
45
|
+
boundary=boundary.encode(encoding="utf-8"),
|
|
46
|
+
content_length=len(request_body),
|
|
47
|
+
)
|
|
48
|
+
max_num_results = fields.get(key="max_num_results", default="1")
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
max_num_results_int = int(max_num_results)
|
|
52
|
+
except ValueError as exc:
|
|
53
|
+
_LOGGER.warning(msg="The max_num_results field is not an integer.")
|
|
54
|
+
raise InvalidMaxNumResultsError(given_value=max_num_results) from exc
|
|
55
|
+
|
|
56
|
+
java_max_int = 2147483647
|
|
57
|
+
if max_num_results_int > java_max_int:
|
|
58
|
+
_LOGGER.warning(msg="The max_num_results field is too large.")
|
|
59
|
+
raise InvalidMaxNumResultsError(given_value=max_num_results)
|
|
60
|
+
|
|
61
|
+
max_allowed_results = 50
|
|
62
|
+
if max_num_results_int < 1 or max_num_results_int > max_allowed_results:
|
|
63
|
+
_LOGGER.warning(msg="The max_num_results field is out of range.")
|
|
64
|
+
raise MaxNumResultsOutOfRangeError(given_value=max_num_results)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Validators for the project state."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Iterable, Mapping
|
|
5
|
+
|
|
6
|
+
from beartype import beartype
|
|
7
|
+
|
|
8
|
+
from mock_vws._database_matchers import get_database_matching_client_keys
|
|
9
|
+
from mock_vws._query_validators.exceptions import InactiveProjectError
|
|
10
|
+
from mock_vws.database import CloudDatabase
|
|
11
|
+
from mock_vws.states import States
|
|
12
|
+
|
|
13
|
+
_LOGGER = logging.getLogger(name=__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@beartype
|
|
17
|
+
def validate_project_state(
|
|
18
|
+
*,
|
|
19
|
+
request_path: str,
|
|
20
|
+
request_headers: Mapping[str, str],
|
|
21
|
+
request_body: bytes,
|
|
22
|
+
request_method: str,
|
|
23
|
+
databases: Iterable[CloudDatabase],
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Validate the state of the project.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
request_path: The path of the request.
|
|
29
|
+
request_headers: The headers sent with the request.
|
|
30
|
+
request_body: The body of the request.
|
|
31
|
+
request_method: The HTTP method of the request.
|
|
32
|
+
databases: All Vuforia databases.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
InactiveProjectError: The project is inactive.
|
|
36
|
+
"""
|
|
37
|
+
database = get_database_matching_client_keys(
|
|
38
|
+
request_headers=request_headers,
|
|
39
|
+
request_body=request_body,
|
|
40
|
+
request_method=request_method,
|
|
41
|
+
request_path=request_path,
|
|
42
|
+
databases=databases,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if database.state != States.PROJECT_INACTIVE:
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
_LOGGER.warning(msg="The project is inactive.")
|
|
49
|
+
raise InactiveProjectError
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""An interface to the mock Vuforia which uses ``responses``."""
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Decorators for using the mock."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from contextlib import ContextDecorator
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Literal, Self
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
from beartype import BeartypeConf, beartype
|
|
12
|
+
from requests import PreparedRequest
|
|
13
|
+
from responses import RequestsMock
|
|
14
|
+
|
|
15
|
+
from mock_vws._mock_common import MissingSchemeError, RequestData
|
|
16
|
+
from mock_vws._respx_mock_server.decorators import start_respx_router
|
|
17
|
+
from mock_vws.database import CloudDatabase, VuMarkDatabase
|
|
18
|
+
from mock_vws.image_matchers import (
|
|
19
|
+
ImageMatcher,
|
|
20
|
+
StructuralSimilarityMatcher,
|
|
21
|
+
)
|
|
22
|
+
from mock_vws.target_manager import TargetManager
|
|
23
|
+
from mock_vws.target_raters import (
|
|
24
|
+
BrisqueTargetTrackingRater,
|
|
25
|
+
TargetTrackingRater,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from .mock_web_query_api import MockVuforiaWebQueryAPI
|
|
29
|
+
from .mock_web_services_api import MockVuforiaWebServicesAPI
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
import respx
|
|
33
|
+
|
|
34
|
+
_ResponseType = tuple[int, Mapping[str, str], str | bytes]
|
|
35
|
+
_MockCallback = Callable[[RequestData], _ResponseType]
|
|
36
|
+
_ResponsesCallback = Callable[[PreparedRequest], _ResponseType]
|
|
37
|
+
|
|
38
|
+
_STRUCTURAL_SIMILARITY_MATCHER = StructuralSimilarityMatcher()
|
|
39
|
+
_BRISQUE_TRACKING_RATER = BrisqueTargetTrackingRater()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@beartype(conf=BeartypeConf(is_pep484_tower=True))
|
|
43
|
+
class MockVWS(ContextDecorator):
|
|
44
|
+
"""Route requests to Vuforia's Web Service APIs to fakes of those APIs.
|
|
45
|
+
|
|
46
|
+
Works with both ``requests`` and ``httpx``.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
*,
|
|
52
|
+
base_vws_url: str = "https://vws.vuforia.com",
|
|
53
|
+
base_vwq_url: str = "https://cloudreco.vuforia.com",
|
|
54
|
+
duplicate_match_checker: ImageMatcher = _STRUCTURAL_SIMILARITY_MATCHER,
|
|
55
|
+
query_match_checker: ImageMatcher = _STRUCTURAL_SIMILARITY_MATCHER,
|
|
56
|
+
processing_time_seconds: float = 2.0,
|
|
57
|
+
target_tracking_rater: TargetTrackingRater = _BRISQUE_TRACKING_RATER,
|
|
58
|
+
real_http: bool = False,
|
|
59
|
+
response_delay_seconds: float = 0.0,
|
|
60
|
+
sleep_fn: Callable[[float], None] = time.sleep,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Route requests to Vuforia's Web Service APIs to fakes of those
|
|
63
|
+
APIs.
|
|
64
|
+
|
|
65
|
+
Works with both ``requests`` and ``httpx``.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
real_http: Whether or not to forward requests to the real
|
|
69
|
+
server if they are not handled by the mock.
|
|
70
|
+
See
|
|
71
|
+
https://requests-mock.readthedocs.io/en/latest/mocker.html#real-http-requests.
|
|
72
|
+
processing_time_seconds: The number of seconds to process each
|
|
73
|
+
image for.
|
|
74
|
+
In the real Vuforia Web Services, this is not deterministic.
|
|
75
|
+
base_vwq_url: The base URL for the VWQ API.
|
|
76
|
+
base_vws_url: The base URL for the VWS API.
|
|
77
|
+
query_match_checker: A callable which takes two image values and
|
|
78
|
+
returns whether they will match in a query request.
|
|
79
|
+
duplicate_match_checker: A callable which takes two image values
|
|
80
|
+
and returns whether they are duplicates.
|
|
81
|
+
target_tracking_rater: A callable for rating targets for tracking.
|
|
82
|
+
response_delay_seconds: The number of seconds to delay each
|
|
83
|
+
response by. This can be used to test timeout handling.
|
|
84
|
+
sleep_fn: The function to use for sleeping during response
|
|
85
|
+
delays. Defaults to ``time.sleep``. Inject a custom
|
|
86
|
+
function to control virtual time in tests without
|
|
87
|
+
monkey-patching.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
MissingSchemeError: There is no scheme in a given URL.
|
|
91
|
+
"""
|
|
92
|
+
super().__init__()
|
|
93
|
+
self._real_http = real_http
|
|
94
|
+
self._response_delay_seconds = response_delay_seconds
|
|
95
|
+
self._sleep_fn = sleep_fn
|
|
96
|
+
self._mock: RequestsMock
|
|
97
|
+
self._router: respx.MockRouter
|
|
98
|
+
self._target_manager = TargetManager()
|
|
99
|
+
|
|
100
|
+
self._base_vws_url = base_vws_url
|
|
101
|
+
self._base_vwq_url = base_vwq_url
|
|
102
|
+
for url in (base_vwq_url, base_vws_url):
|
|
103
|
+
parse_result = urlparse(url=url)
|
|
104
|
+
if not parse_result.scheme:
|
|
105
|
+
raise MissingSchemeError(url=url)
|
|
106
|
+
|
|
107
|
+
self._mock_vws_api = MockVuforiaWebServicesAPI(
|
|
108
|
+
target_manager=self._target_manager,
|
|
109
|
+
processing_time_seconds=float(processing_time_seconds),
|
|
110
|
+
duplicate_match_checker=duplicate_match_checker,
|
|
111
|
+
target_tracking_rater=target_tracking_rater,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
self._mock_vwq_api = MockVuforiaWebQueryAPI(
|
|
115
|
+
target_manager=self._target_manager,
|
|
116
|
+
query_match_checker=query_match_checker,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def add_cloud_database(self, cloud_database: CloudDatabase) -> None:
|
|
120
|
+
"""Add a cloud database.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
cloud_database: The cloud database to add.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
ValueError: One of the given cloud database keys matches a key for
|
|
127
|
+
an existing cloud database.
|
|
128
|
+
"""
|
|
129
|
+
self._target_manager.add_cloud_database(
|
|
130
|
+
cloud_database=cloud_database,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def add_vumark_database(self, vumark_database: VuMarkDatabase) -> None:
|
|
134
|
+
"""Add a VuMark database.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
vumark_database: The VuMark database to add.
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
ValueError: One of the given database keys matches a key for
|
|
141
|
+
an existing database.
|
|
142
|
+
"""
|
|
143
|
+
self._target_manager.add_vumark_database(
|
|
144
|
+
vumark_database=vumark_database,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def _wrap_callback(
|
|
149
|
+
callback: _MockCallback,
|
|
150
|
+
delay_seconds: float,
|
|
151
|
+
sleep_fn: Callable[[float], None],
|
|
152
|
+
base_path: str,
|
|
153
|
+
) -> _ResponsesCallback:
|
|
154
|
+
"""Wrap a callback to add a response delay."""
|
|
155
|
+
|
|
156
|
+
def wrapped(
|
|
157
|
+
request: PreparedRequest,
|
|
158
|
+
) -> _ResponseType:
|
|
159
|
+
"""Handle the response delay and timeout logic."""
|
|
160
|
+
# req_kwargs is added dynamically by the responses
|
|
161
|
+
# library onto PreparedRequest objects - it is not
|
|
162
|
+
# in the requests type stubs.
|
|
163
|
+
req_kwargs: dict[str, Any] = getattr( # pylint: disable=bad-builtin
|
|
164
|
+
request,
|
|
165
|
+
"req_kwargs",
|
|
166
|
+
{},
|
|
167
|
+
)
|
|
168
|
+
timeout: tuple[float, float] | float | int | None = req_kwargs.get(
|
|
169
|
+
"timeout"
|
|
170
|
+
)
|
|
171
|
+
# requests allows timeout as a (connect, read)
|
|
172
|
+
# tuple. The delay simulates server response
|
|
173
|
+
# time, so compare against the read timeout.
|
|
174
|
+
match timeout:
|
|
175
|
+
case (_, int() | float() as read_timeout):
|
|
176
|
+
effective: float | None = float(read_timeout)
|
|
177
|
+
case int() | float():
|
|
178
|
+
effective = float(timeout)
|
|
179
|
+
case _:
|
|
180
|
+
effective = None
|
|
181
|
+
|
|
182
|
+
if effective is not None and delay_seconds > effective:
|
|
183
|
+
sleep_fn(effective)
|
|
184
|
+
raise requests.exceptions.Timeout
|
|
185
|
+
|
|
186
|
+
match request.body:
|
|
187
|
+
case None:
|
|
188
|
+
body_bytes = b""
|
|
189
|
+
case str() as raw_body:
|
|
190
|
+
body_bytes = raw_body.encode(encoding="utf-8")
|
|
191
|
+
case _:
|
|
192
|
+
body_bytes = request.body
|
|
193
|
+
|
|
194
|
+
path = request.path_url
|
|
195
|
+
if base_path and path.startswith(base_path):
|
|
196
|
+
path = path[len(base_path) :]
|
|
197
|
+
|
|
198
|
+
request_data = RequestData(
|
|
199
|
+
method=request.method or "",
|
|
200
|
+
path=path,
|
|
201
|
+
headers=dict(request.headers),
|
|
202
|
+
body=body_bytes,
|
|
203
|
+
)
|
|
204
|
+
result = callback(request_data)
|
|
205
|
+
sleep_fn(delay_seconds)
|
|
206
|
+
return result
|
|
207
|
+
|
|
208
|
+
return wrapped
|
|
209
|
+
|
|
210
|
+
def __enter__(self) -> Self:
|
|
211
|
+
"""Start an instance of a Vuforia mock.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
``self``.
|
|
215
|
+
"""
|
|
216
|
+
mock = RequestsMock(assert_all_requests_are_fired=False)
|
|
217
|
+
|
|
218
|
+
for api, base_url in (
|
|
219
|
+
(self._mock_vws_api, self._base_vws_url),
|
|
220
|
+
(self._mock_vwq_api, self._base_vwq_url),
|
|
221
|
+
):
|
|
222
|
+
base_path = urlparse(url=base_url).path.rstrip("/")
|
|
223
|
+
for route in api.routes:
|
|
224
|
+
url_pattern = base_url.rstrip("/") + route.path_pattern + "$"
|
|
225
|
+
compiled_url_pattern = re.compile(pattern=url_pattern)
|
|
226
|
+
|
|
227
|
+
for http_method in route.http_methods:
|
|
228
|
+
original_callback = getattr( # pylint: disable=bad-builtin
|
|
229
|
+
api,
|
|
230
|
+
route.route_name,
|
|
231
|
+
)
|
|
232
|
+
mock.add_callback(
|
|
233
|
+
method=http_method,
|
|
234
|
+
url=compiled_url_pattern,
|
|
235
|
+
callback=self._wrap_callback(
|
|
236
|
+
callback=original_callback,
|
|
237
|
+
delay_seconds=self._response_delay_seconds,
|
|
238
|
+
sleep_fn=self._sleep_fn,
|
|
239
|
+
base_path=base_path,
|
|
240
|
+
),
|
|
241
|
+
content_type=None,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if self._real_http:
|
|
245
|
+
all_requests_pattern = re.compile(pattern=".*")
|
|
246
|
+
mock.add_passthru(prefix=all_requests_pattern)
|
|
247
|
+
|
|
248
|
+
self._mock = mock
|
|
249
|
+
self._mock.start()
|
|
250
|
+
|
|
251
|
+
self._router = start_respx_router(
|
|
252
|
+
mock_vws_api=self._mock_vws_api,
|
|
253
|
+
mock_vwq_api=self._mock_vwq_api,
|
|
254
|
+
base_vws_url=self._base_vws_url,
|
|
255
|
+
base_vwq_url=self._base_vwq_url,
|
|
256
|
+
response_delay_seconds=self._response_delay_seconds,
|
|
257
|
+
sleep_fn=self._sleep_fn,
|
|
258
|
+
real_http=self._real_http,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
return self
|
|
262
|
+
|
|
263
|
+
def __exit__(self, *exc: object) -> Literal[False]:
|
|
264
|
+
"""Stop the Vuforia mock.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
False
|
|
268
|
+
"""
|
|
269
|
+
# __exit__ needs this to be passed in but vulture thinks that it is
|
|
270
|
+
# unused, so we "use" it here.
|
|
271
|
+
del exc
|
|
272
|
+
|
|
273
|
+
self._mock.stop()
|
|
274
|
+
self._router.stop()
|
|
275
|
+
return False
|