11"""Decorators for using the mock."""
22
3+ import functools
34import re
45import 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
79from typing import TYPE_CHECKING , Any , Literal , Self
810from urllib .parse import urlparse
911
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