Skip to content

Commit 2a5b6e9

Browse files
Merge pull request #3453 from VWS-Python/adamtheturtle/issue-3390
Isolate each call of a function decorated with a MockVWS instance
2 parents 9fefe13 + dbbd04f commit 2a5b6e9

4 files changed

Lines changed: 418 additions & 49 deletions

File tree

docs/source/basic-example.rst

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,32 @@
1717
1818
By default, an exception will be raised if any requests to unmocked addresses are made.
1919

20+
A ``MockVWS`` instance can also decorate a function:
21+
22+
.. code-block:: python
23+
24+
"""Make a request to the Vuforia mock from a decorated function."""
25+
26+
import requests
27+
28+
from mock_vws import MockVWS
29+
from mock_vws.database import CloudDatabase
30+
31+
mock = MockVWS()
32+
mock.add_cloud_database(cloud_database=CloudDatabase())
33+
34+
35+
@mock
36+
def get_summary() -> None:
37+
"""Make a request which uses the Vuforia mock."""
38+
requests.get(url="https://vws.vuforia.com/summary", timeout=30)
39+
40+
41+
get_summary()
42+
43+
Each call of a decorated function gets its own databases and targets, so decorated functions do not affect each other.
44+
A ``with`` block, by contrast, shares one set of databases and targets with every other use of the same instance.
45+
2046
See :ref:`mock-api-reference` for details of what can be changed and how.
2147

2248
.. _requests: https://pypi.org/project/requests/
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Give each call of a function decorated with a ``MockVWS`` instance its own databases and targets.
2+
Previously, every use of an instance shared one set of databases and targets, so decorating two test functions with one instance made them affect each other.
3+
A database can still be inspected during a call, and its targets are what they were before the call again once it returns.
4+
Using an instance as a context manager is unchanged: a ``with`` block still shares its state with every other use of the same instance.

src/mock_vws/decorators.py

Lines changed: 209 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""Decorators for using the mock."""
22

3+
import functools
34
import re
45
import time
5-
from collections.abc import Callable, Mapping
6-
from contextlib import ContextDecorator
6+
from collections.abc import Callable, Generator, Mapping
7+
from contextlib import contextmanager
8+
from dataclasses import dataclass
79
from typing import TYPE_CHECKING, Any, Literal, Self
810
from urllib.parse import urlparse
911

@@ -49,10 +51,46 @@
4951

5052

5153
@beartype(conf=BeartypeConf(is_pep484_tower=True))
52-
class MockVWS(ContextDecorator):
54+
@dataclass(eq=True, frozen=True, kw_only=True)
55+
class _MockVWSOptions:
56+
"""The options which configure a mock.
57+
58+
These are everything a mock is given when it is created, as opposed to
59+
the databases and targets which it accumulates as it is used.
60+
"""
61+
62+
base_vws_url: str
63+
base_vwq_url: str
64+
cloud_query_failure_response: CloudQueryFailureResponse | None
65+
duplicate_match_checker: ImageMatcher
66+
query_match_checker: ImageMatcher
67+
processing_time_seconds: float
68+
model_target_generation_failure: ModelTargetGenerationFailure | None
69+
model_target_generation_warning: ModelTargetGenerationWarning | None
70+
model_target_training_allowance_exceeded: bool
71+
target_tracking_rater: TargetTrackingRater
72+
real_http: bool
73+
response_delay_seconds: float
74+
sleep_fn: Callable[[float], None]
75+
vumark_generation_failure: VuMarkGenerationFailure | None
76+
77+
78+
@beartype(conf=BeartypeConf(is_pep484_tower=True))
79+
class MockVWS:
5380
"""Route requests to Vuforia's Web Service APIs to fakes of those APIs.
5481
5582
Works with both ``requests`` and ``httpx``.
83+
84+
An instance is usable as a context manager and as a decorator.
85+
86+
A context manager block shares one set of databases and targets with
87+
every other use of the same instance, so state created in one ``with``
88+
block is still there in the next one.
89+
90+
A decorated function instead gets its own databases and targets for the
91+
duration of each call. The databases added to the instance are available
92+
inside the call, and the targets created during the call are discarded
93+
when it returns, so decorated functions do not affect each other.
5694
"""
5795

5896
def __init__(
@@ -128,7 +166,6 @@ def __init__(
128166
ValueError: Both a Model Target generation failure and warning are
129167
configured.
130168
"""
131-
super().__init__()
132169
if (
133170
model_target_generation_failure is not None
134171
and model_target_generation_warning is not None
@@ -138,39 +175,115 @@ def __init__(
138175
"are mutually exclusive"
139176
)
140177
raise ValueError(msg)
141-
self._real_http = real_http
142-
self._response_delay_seconds = response_delay_seconds
143-
self._sleep_fn = sleep_fn
144-
self._mock: RequestsMock
145-
self._router: respx.MockRouter
146-
self._target_manager = TargetManager()
147178

148-
self._base_vws_url = base_vws_url
149-
self._base_vwq_url = base_vwq_url
150179
for url in (base_vwq_url, base_vws_url):
151180
parse_result = urlparse(url=url)
152181
if not parse_result.scheme:
153182
raise MissingSchemeError(url=url)
154183

155-
self._mock_vws_api = MockVuforiaWebServicesAPI(
156-
target_manager=self._target_manager,
184+
# The options are kept so that decorating a function can build an
185+
# equivalently configured set of fakes, with their own databases and
186+
# targets, for each call of that function.
187+
self._options = _MockVWSOptions(
157188
base_vws_url=base_vws_url,
189+
base_vwq_url=base_vwq_url,
190+
cloud_query_failure_response=cloud_query_failure_response,
191+
duplicate_match_checker=duplicate_match_checker,
192+
query_match_checker=query_match_checker,
158193
processing_time_seconds=float(processing_time_seconds),
159194
model_target_generation_failure=model_target_generation_failure,
160195
model_target_generation_warning=model_target_generation_warning,
161196
model_target_training_allowance_exceeded=(
162197
model_target_training_allowance_exceeded
163198
),
164-
duplicate_match_checker=duplicate_match_checker,
165199
target_tracking_rater=target_tracking_rater,
200+
real_http=real_http,
201+
response_delay_seconds=response_delay_seconds,
202+
sleep_fn=sleep_fn,
166203
vumark_generation_failure=vumark_generation_failure,
167204
)
205+
# A mock can be started while it is already started, for example
206+
# when a decorated function calls another decorated function, so the
207+
# started mocks are kept as a stack.
208+
self._started: list[tuple[RequestsMock, respx.MockRouter]] = []
209+
self._added_cloud_databases: list[CloudDatabase] = []
210+
self._added_vumark_databases: list[VuMarkDatabase] = []
211+
self._target_manager = TargetManager()
212+
self._mock_vws_api, self._mock_vwq_api = self._build_apis(
213+
target_manager=self._target_manager,
214+
)
215+
216+
def _build_apis(
217+
self,
218+
*,
219+
target_manager: TargetManager,
220+
) -> tuple[MockVuforiaWebServicesAPI, MockVuforiaWebQueryAPI]:
221+
"""Build fakes of the Vuforia APIs, backed by a target manager.
222+
223+
Args:
224+
target_manager: The target manager which the fakes use.
168225
169-
self._mock_vwq_api = MockVuforiaWebQueryAPI(
226+
Returns:
227+
A fake of the VWS API and a fake of the VWQ API.
228+
"""
229+
options = self._options
230+
mock_vws_api = MockVuforiaWebServicesAPI(
231+
target_manager=target_manager,
232+
base_vws_url=options.base_vws_url,
233+
processing_time_seconds=options.processing_time_seconds,
234+
model_target_generation_failure=(
235+
options.model_target_generation_failure
236+
),
237+
model_target_generation_warning=(
238+
options.model_target_generation_warning
239+
),
240+
model_target_training_allowance_exceeded=(
241+
options.model_target_training_allowance_exceeded
242+
),
243+
duplicate_match_checker=options.duplicate_match_checker,
244+
target_tracking_rater=options.target_tracking_rater,
245+
vumark_generation_failure=options.vumark_generation_failure,
246+
)
247+
mock_vwq_api = MockVuforiaWebQueryAPI(
248+
target_manager=target_manager,
249+
query_match_checker=options.query_match_checker,
250+
failure_response=options.cloud_query_failure_response,
251+
)
252+
return mock_vws_api, mock_vwq_api
253+
254+
@contextmanager
255+
def _fresh_state(self) -> Generator[None]:
256+
"""Swap in databases and targets which are used only in this block.
257+
258+
The databases added to this instance are added to the new state, and
259+
the state which was there before the block is back once it ends.
260+
261+
Yields:
262+
``None``.
263+
"""
264+
original_target_manager = self._target_manager
265+
original_mock_vws_api = self._mock_vws_api
266+
original_mock_vwq_api = self._mock_vwq_api
267+
268+
self._target_manager = TargetManager()
269+
self._mock_vws_api, self._mock_vwq_api = self._build_apis(
170270
target_manager=self._target_manager,
171-
query_match_checker=query_match_checker,
172-
failure_response=cloud_query_failure_response,
173271
)
272+
for cloud_database in self._added_cloud_databases:
273+
self._target_manager.add_cloud_database(
274+
cloud_database=cloud_database,
275+
)
276+
for vumark_database in self._added_vumark_databases:
277+
self._target_manager.add_vumark_database(
278+
vumark_database=vumark_database,
279+
)
280+
281+
try:
282+
yield
283+
finally:
284+
self._target_manager = original_target_manager
285+
self._mock_vws_api = original_mock_vws_api
286+
self._mock_vwq_api = original_mock_vwq_api
174287

175288
def add_cloud_database(self, cloud_database: CloudDatabase) -> None:
176289
"""Add a cloud database.
@@ -185,6 +298,7 @@ def add_cloud_database(self, cloud_database: CloudDatabase) -> None:
185298
self._target_manager.add_cloud_database(
186299
cloud_database=cloud_database,
187300
)
301+
self._added_cloud_databases.append(cloud_database)
188302

189303
def add_vumark_database(self, vumark_database: VuMarkDatabase) -> None:
190304
"""Add a VuMark database.
@@ -199,6 +313,67 @@ def add_vumark_database(self, vumark_database: VuMarkDatabase) -> None:
199313
self._target_manager.add_vumark_database(
200314
vumark_database=vumark_database,
201315
)
316+
self._added_vumark_databases.append(vumark_database)
317+
318+
def __call__[**P, T](
319+
self,
320+
function: Callable[P, T],
321+
) -> Callable[P, T]:
322+
"""Wrap a function so that each call of it runs against the mock.
323+
324+
Each call gets its own databases and targets, so that decorated
325+
functions do not affect each other. The databases added to this
326+
instance are available inside the call, and their targets are what
327+
they were before the call again once it returns.
328+
329+
Args:
330+
function: The function to wrap.
331+
332+
Returns:
333+
The wrapped function.
334+
"""
335+
336+
@functools.wraps(wrapped=function)
337+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
338+
"""Run the given function against a mock of its own.
339+
340+
Returns:
341+
The return value of the given function.
342+
"""
343+
# The targets of a database are stored on the database object
344+
# itself, and that object belongs to the caller, so a new target
345+
# manager is not enough to isolate one call from the next. We
346+
# therefore put the targets back as they were afterwards.
347+
#
348+
# ``CloudDatabase`` equality includes the targets, which change
349+
# during the call, so the snapshots are held in a list rather
350+
# than in a dictionary keyed by database.
351+
#
352+
# Reading and writing the targets in a database is guarded by
353+
# the target manager's lock, as documented on that lock.
354+
with self._target_manager.lock:
355+
cloud_snapshots = [
356+
(database, set(database.targets))
357+
for database in self._added_cloud_databases
358+
]
359+
vumark_snapshots = [
360+
(database, set(database.vumark_targets))
361+
for database in self._added_vumark_databases
362+
]
363+
364+
try:
365+
with self._fresh_state(), self:
366+
return function(*args, **kwargs)
367+
finally:
368+
with self._target_manager.lock:
369+
for cloud_database, cloud_targets in cloud_snapshots:
370+
cloud_database.targets.clear()
371+
cloud_database.targets.update(cloud_targets)
372+
for vumark_database, vumark_targets in vumark_snapshots:
373+
vumark_database.vumark_targets.clear()
374+
vumark_database.vumark_targets.update(vumark_targets)
375+
376+
return wrapper
202377

203378
@staticmethod
204379
def _wrap_callback(
@@ -272,8 +447,8 @@ def __enter__(self) -> Self:
272447
mock = RequestsMock(assert_all_requests_are_fired=False)
273448

274449
for api, base_url in (
275-
(self._mock_vws_api, self._base_vws_url),
276-
(self._mock_vwq_api, self._base_vwq_url),
450+
(self._mock_vws_api, self._options.base_vws_url),
451+
(self._mock_vwq_api, self._options.base_vwq_url),
277452
):
278453
base_path = urlparse(url=base_url).path.rstrip("/")
279454
for route in api.routes:
@@ -290,30 +465,30 @@ def __enter__(self) -> Self:
290465
url=compiled_url_pattern,
291466
callback=self._wrap_callback(
292467
callback=original_callback,
293-
delay_seconds=self._response_delay_seconds,
294-
sleep_fn=self._sleep_fn,
468+
delay_seconds=self._options.response_delay_seconds,
469+
sleep_fn=self._options.sleep_fn,
295470
base_path=base_path,
296471
),
297472
content_type=None,
298473
)
299474

300-
if self._real_http:
475+
if self._options.real_http:
301476
all_requests_pattern = re.compile(pattern=".*")
302477
mock.add_passthru(prefix=all_requests_pattern)
303478

304-
self._mock = mock
305-
self._mock.start()
479+
mock.start()
306480

307-
self._router = start_respx_router(
481+
router = start_respx_router(
308482
mock_vws_api=self._mock_vws_api,
309483
mock_vwq_api=self._mock_vwq_api,
310-
base_vws_url=self._base_vws_url,
311-
base_vwq_url=self._base_vwq_url,
312-
response_delay_seconds=self._response_delay_seconds,
313-
sleep_fn=self._sleep_fn,
314-
real_http=self._real_http,
484+
base_vws_url=self._options.base_vws_url,
485+
base_vwq_url=self._options.base_vwq_url,
486+
response_delay_seconds=self._options.response_delay_seconds,
487+
sleep_fn=self._options.sleep_fn,
488+
real_http=self._options.real_http,
315489
)
316490

491+
self._started.append((mock, router))
317492
return self
318493

319494
def __exit__(self, *exc: object) -> Literal[False]:
@@ -326,6 +501,7 @@ def __exit__(self, *exc: object) -> Literal[False]:
326501
# unused, so we "use" it here.
327502
del exc
328503

329-
self._mock.stop()
330-
self._router.stop()
504+
mock, router = self._started.pop()
505+
mock.stop()
506+
router.stop()
331507
return False

0 commit comments

Comments
 (0)