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
@@ -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