userver: /data/code/userver/testsuite/pytest_plugins/pytest_userver/client.py Source File
Loading...
Searching...
No Matches
client.py
1"""
2Python module that provides clients for functional tests with
3testsuite; see
4@ref scripts/docs/en/userver/functional_testing.md for an introduction.
5
6@ingroup userver_testsuite
7"""
8
9# pylint: disable=too-many-lines
10
11from __future__ import annotations
12
13from collections.abc import Awaitable
14from collections.abc import Iterator
15import contextlib
16import copy
17import dataclasses
18import json
19import logging
20import typing
21from typing import Any
22from typing import overload
23from typing import TypeAlias
24from typing import TypeVar
25import warnings
26
27import aiohttp
28
29from testsuite import logcapture
30from testsuite import utils
31from testsuite.daemons import service_client
32from testsuite.utils import approx
33from testsuite.utils import http
34
35from pytest_userver import userver_warnings
37from pytest_userver.plugins import caches
38
39# @cond
40logger = logging.getLogger(__name__)
41# @endcond
42
43JsonAny: TypeAlias = int | float | str | list | dict
44JsonAnyOptional: TypeAlias = JsonAny | None
45
46T = TypeVar('T')
47_MISSING: Any = object()
48
49_UNKNOWN_STATE = '__UNKNOWN__'
50
51CACHE_INVALIDATION_MESSAGE = (
52 'Direct cache invalidation is deprecated.\n'
53 '\n'
54 ' - Use client.update_server_state() to synchronize service state\n'
55 ' - Explicitly pass cache names to invalidate, e.g.: '
56 'invalidate_caches(cache_names=[...]).'
57)
58
59
60class BaseError(Exception):
61 """Base class for exceptions of this module."""
62
63
65 pass
66
67
69 pass
70
71
81 pass
82
83
85 pass
86
87
89 def __init__(self) -> None:
90 self.suspended_tasks: set[str] = set()
91 self.tasks_to_suspend: set[str] = set()
92
93
95 def __init__(self, name, reason):
96 self.name = name
97 self.reason = reason
98 super().__init__(f'Testsuite task {name!r} failed: {reason}')
99
100
101@dataclasses.dataclass(frozen=True)
103 testsuite_action_path: str | None = None
104 server_monitor_path: str | None = None
105
106
107Metric: TypeAlias = pytest_userver.metrics.Metric
108
109
111 """
112 Base asyncio userver client that implements HTTP requests to service.
113
114 Compatible with werkzeug interface.
115
116 @ingroup userver_testsuite
117 """
118
119 def __init__(self, client):
120 self._client = client
121
122 async def post(
123 self,
124 path: str,
125 # pylint: disable=redefined-outer-name
126 json: JsonAnyOptional = None,
127 data: Any = None,
128 params: dict[str, str] | None = None,
129 bearer: str | None = None,
130 x_real_ip: str | None = None,
131 headers: dict[str, str] | None = None,
132 **kwargs,
133 ) -> http.ClientResponse:
134 """
135 Make a HTTP POST request
136 """
137 response = await self._client.post(
138 path,
139 json=json,
140 data=data,
141 params=params,
142 headers=headers,
143 bearer=bearer,
144 x_real_ip=x_real_ip,
145 **kwargs,
146 )
147 return await self._wrap_client_response(response)
148
149 async def put(
150 self,
151 path,
152 # pylint: disable=redefined-outer-name
153 json: JsonAnyOptional = None,
154 data: Any = None,
155 params: dict[str, str] | None = None,
156 bearer: str | None = None,
157 x_real_ip: str | None = None,
158 headers: dict[str, str] | None = None,
159 **kwargs,
160 ) -> http.ClientResponse:
161 """
162 Make a HTTP PUT request
163 """
164 response = await self._client.put(
165 path,
166 json=json,
167 data=data,
168 params=params,
169 headers=headers,
170 bearer=bearer,
171 x_real_ip=x_real_ip,
172 **kwargs,
173 )
174 return await self._wrap_client_response(response)
175
176 async def patch(
177 self,
178 path,
179 # pylint: disable=redefined-outer-name
180 json: JsonAnyOptional = None,
181 data: Any = None,
182 params: dict[str, str] | None = None,
183 bearer: str | None = None,
184 x_real_ip: str | None = None,
185 headers: dict[str, str] | None = None,
186 **kwargs,
187 ) -> http.ClientResponse:
188 """
189 Make a HTTP PATCH request
190 """
191 response = await self._client.patch(
192 path,
193 json=json,
194 data=data,
195 params=params,
196 headers=headers,
197 bearer=bearer,
198 x_real_ip=x_real_ip,
199 **kwargs,
200 )
201 return await self._wrap_client_response(response)
202
203 async def get(
204 self,
205 path: str,
206 headers: dict[str, str] | None = None,
207 bearer: str | None = None,
208 x_real_ip: str | None = None,
209 **kwargs,
210 ) -> http.ClientResponse:
211 """
212 Make a HTTP GET request
213 """
214 response = await self._client.get(
215 path,
216 headers=headers,
217 bearer=bearer,
218 x_real_ip=x_real_ip,
219 **kwargs,
220 )
221 return await self._wrap_client_response(response)
222
223 async def delete(
224 self,
225 path: str,
226 headers: dict[str, str] | None = None,
227 bearer: str | None = None,
228 x_real_ip: str | None = None,
229 **kwargs,
230 ) -> http.ClientResponse:
231 """
232 Make a HTTP DELETE request
233 """
234 response = await self._client.delete(
235 path,
236 headers=headers,
237 bearer=bearer,
238 x_real_ip=x_real_ip,
239 **kwargs,
240 )
241 return await self._wrap_client_response(response)
242
243 async def options(
244 self,
245 path: str,
246 headers: dict[str, str] | None = None,
247 bearer: str | None = None,
248 x_real_ip: str | None = None,
249 **kwargs,
250 ) -> http.ClientResponse:
251 """
252 Make a HTTP OPTIONS request
253 """
254 response = await self._client.options(
255 path,
256 headers=headers,
257 bearer=bearer,
258 x_real_ip=x_real_ip,
259 **kwargs,
260 )
261 return await self._wrap_client_response(response)
262
263 async def request(
264 self,
265 http_method: str,
266 path: str,
267 **kwargs,
268 ) -> http.ClientResponse:
269 """
270 Make a HTTP request with the specified method
271 """
272 response = await self._client.request(http_method, path, **kwargs)
273 return await self._wrap_client_response(response)
274
275 @property
277 """
278 @deprecated Use pytest_userver.client.Client directly instead.
279 """
280 return self._client
281
282 def _wrap_client_response(self, response: aiohttp.ClientResponse) -> Awaitable[http.ClientResponse]:
283 return http.wrap_client_response(response)
284
285
286# @cond
287
288
289def _wrap_client_error(func):
290 async def _wrapper(*args, **kwargs):
291 try:
292 return await func(*args, **kwargs)
293 except aiohttp.client_exceptions.ClientResponseError as exc:
294 raise http.HttpResponseError(
295 url=exc.request_info.url,
296 status=exc.status,
297 )
298
299 return _wrapper
300
301
302class AiohttpClientMonitor(service_client.AiohttpClient):
303 _config: TestsuiteClientConfig
304
305 def __init__(self, base_url, *, config: TestsuiteClientConfig, **kwargs):
306 super().__init__(base_url, **kwargs)
307 self._config = config
308
309 async def metrics_raw(
310 self,
311 output_format,
312 *,
313 path: str | None = None,
314 prefix: str | None = None,
315 labels: dict[str, str] | None = None,
316 ) -> str:
317 if not self._config.server_monitor_path:
318 raise ConfigurationError(
319 'handler-server-monitor component is not configured',
320 )
321
322 params = {'format': output_format}
323 if prefix:
324 params['prefix'] = prefix
325
326 if path:
327 params['path'] = path
328
329 if labels:
330 params['labels'] = json.dumps(labels)
331
332 response = await self.get(
333 self._config.server_monitor_path,
334 params=params,
335 )
336 async with response:
337 response.raise_for_status()
338 return await response.text()
339
340 async def metrics(
341 self,
342 *,
343 path: str | None = None,
344 prefix: str | None = None,
345 labels: dict[str, str] | None = None,
346 sliced: bool = False,
348 assert (path is not None) + (prefix is not None) <= 1, 'path and prefix are mutually exclusive'
349 response = await self.metrics_raw(
350 output_format='json',
351 path=path,
352 prefix=prefix,
353 labels=labels,
354 )
356 if sliced:
357 sliced_prefix = prefix or path
358 sliced_prefix = sliced_prefix.rstrip('.') if sliced_prefix is not None else None
359 snapshot = snapshot.sliced(sliced_prefix, labels)
360 return snapshot
361
362 async def single_metric_optional(
363 self,
364 path: str,
365 *,
366 labels: dict[str, str] | None = None,
368 response = await self.metrics(path=path, labels=labels)
369 metrics_list = response.get(path, [])
370
371 assert len(metrics_list) <= 1, (f'More than one metric found for path {path} and labels {labels}: {response}',)
372
373 if not metrics_list:
374 return None
375
376 return next(iter(metrics_list))
377
378 async def single_metric(
379 self,
380 path: str,
381 *,
382 labels: dict[str, str] | None = None,
384 value = await self.single_metric_optional(path, labels=labels)
385 assert value is not None, (f'No metric was found for path {path} and labels {labels}',)
386 return value
387
388
389# @endcond
390
391
393 """
394 Asyncio userver client for monitor listeners, typically retrieved from
395 plugins.service_client.monitor_client fixture.
396
397 Compatible with werkzeug interface.
398
399 @ingroup userver_testsuite
400 """
401
403 self,
404 *,
405 path: str | None = None,
406 prefix: str | None = None,
407 labels: dict[str, str] | None = None,
408 sliced: bool = True,
409 diff_gauge: bool = False,
410 ) -> MetricsDiffer:
411 """
412 Creates a `MetricsDiffer` that fetches metrics using this client.
413 It's recommended to use this method over `metrics` to make sure
414 the tests don't affect each other.
415
416 With `diff_gauge` off, only `RATE` metrics are differentiated.
417 With `diff_gauge` on, `GAUGE` metrics are differentiated as well,
418 which may lead to nonsensical results for those.
419
420 @param path Optional full metric path
421 @param prefix Optional prefix on which the metric paths should start
422 @param labels Optional dictionary of labels that must be in the metric
423 @param sliced If `True` (the default), then `differ.value_at`, `differ.baseline`, `differ.current` and
424 `differ.diff` have `path`/`prefix` (and `labels`, if given) stripped via
425 @ref pytest_userver.metrics.MetricsSnapshot.sliced, same as `metrics(sliced=True)`.
426 @param diff_gauge Whether to differentiate GAUGE metrics
427
428 @snippet samples/testsuite-support/tests/test_metrics.py metrics diff
429 """
430 return MetricsDiffer(
431 _client=self,
432 _path=path,
433 _prefix=prefix,
434 _labels=labels,
435 _sliced=sliced,
436 _diff_gauge=diff_gauge,
437 )
438
439 @_wrap_client_error
440 async def metrics(
441 self,
442 *,
443 path: str | None = None,
444 prefix: str | None = None,
445 labels: dict[str, str] | None = None,
446 sliced: bool = False,
448 """
449 Returns a dict of metric names to Metric.
450
451 @param path Optional full metric path
452 @param prefix Optional prefix on which the metric paths should start
453 @param labels Optional dictionary of labels that must be in the metric
454 @param sliced If True, the returned snapshot is additionally passed through
455 @ref pytest_userver.metrics.MetricsSnapshot.sliced using `path` or `prefix` (one of which must be set)
456 and `labels` (if set), stripping them from the result paths and labels
457
458 @snippet samples/testsuite-support/tests/test_metrics.py metrics metrics
459
460 Example of `sliced=True` avoiding repetition of a common prefix and label:
461 @snippet core/functional_tests/metrics/tests/test_sliced.py sliced functional test
462 """
463 return await self._client.metrics(
464 path=path,
465 prefix=prefix,
466 labels=labels,
467 sliced=sliced,
468 )
469
470 @_wrap_client_error
472 self,
473 path: str,
474 *,
475 labels: dict[str, str] | None = None,
477 """
478 Either return a pytest_userver.metrics.Metric or None if there's no such metric.
479
480 @param path Full metric path
481 @param labels Optional dictionary of labels that must be in the metric
482
483 @throws AssertionError if more than one metric returned
484
485 @snippet samples/testsuite-support/tests/test_metrics.py metrics single_metric_optional
486 """
487 return await self._client.single_metric_optional(path, labels=labels)
488
489 @_wrap_client_error
490 async def single_metric(
491 self,
492 path: str,
493 *,
494 labels: dict[str, str] | None = None,
496 """
497 Returns the pytest_userver.metrics.Metric.
498
499 @param path Full metric path
500 @param labels Optional dictionary of labels that must be in the metric
501
502 @throws AssertionError if more than one metric or no metric found
503
504 @snippet samples/testsuite-support/tests/test_metrics.py metrics single_metric
505 """
506 return await self._client.single_metric(path, labels=labels)
507
508 @_wrap_client_error
509 async def metrics_raw(
510 self,
511 output_format: str,
512 *,
513 path: str | None = None,
514 prefix: str | None = None,
515 labels: dict[str, str] | None = None,
516 ) -> dict[str, pytest_userver.metrics.Metric]:
517 """
518 Low level function that returns metrics in a specific format.
519 Use `metrics` and `single_metric` instead if possible.
520
521 @param output_format pytest_userver.metrics.Metric output format. See
522 @ref server::handlers::ServerMonitor for a list of supported formats.
523 @param path Optional full metric path
524 @param prefix Optional prefix on which the metric paths should start
525 @param labels Optional dictionary of labels that must be in the metric
526 """
527 return await self._client.metrics_raw(
528 output_format=output_format,
529 path=path,
530 prefix=prefix,
531 labels=labels,
532 )
533
534 @_wrap_client_error
535 async def fired_alerts(self):
536 response = await self._client.get('/service/fired-alerts')
537 assert response.status == 200
538 return (await response.json())['alerts']
539
540
542 """
543 A helper class for computing metric differences.
544
545 @see ClientMonitor.metrics_diff
546
547 Example with @ref pytest_userver.client.ClientMonitor.metrics_diff "await monitor_client.metrics_diff()":
548 @snippet samples/testsuite-support/tests/test_metrics.py metrics diff
549
550 @ingroup userver_testsuite
551 """
552
553 # @cond
554 def __init__(
555 self,
556 _client: ClientMonitor,
557 _path: str | None,
558 _prefix: str | None,
559 _labels: dict[str, str] | None,
560 _sliced: bool,
561 _diff_gauge: bool,
562 ):
563 self._client = _client
564 self._path = _path
565 self._prefix = _prefix
566 self._labels = _labels
567 self._sliced = _sliced
568 self._diff_gauge = _diff_gauge
572
573 # @endcond
574
575 @property
576 def baseline(self) -> pytest_userver.metrics.MetricsSnapshot:
577 assert self._baseline is not None
578 return self._baseline
579
580 @baseline.setter
581 def baseline(self, value: pytest_userver.metrics.MetricsSnapshot) -> None:
582 self._baseline = value
583 if self._current is not None:
584 self._diff = _subtract_metrics_snapshots(
585 self._current,
586 self._baseline,
587 self._diff_gauge,
588 )
589
590 @property
591 def current(self) -> pytest_userver.metrics.MetricsSnapshot:
592 assert self._current is not None, 'Set self.current first'
593 return self._current
594
595 @current.setter
596 def current(self, value: pytest_userver.metrics.MetricsSnapshot) -> None:
597 self._current = value
598 assert self._baseline is not None, 'Set self.baseline first'
599 self._diff = _subtract_metrics_snapshots(
600 self._current,
601 self._baseline,
602 self._diff_gauge,
603 )
604
605 @property
606 def diff(self) -> pytest_userver.metrics.MetricsSnapshot:
607 assert self._diff is not None, 'Set self.current first'
608 return self._diff
609
610 @overload
611 def value_at(
612 self,
613 subpath: str | None = None,
614 add_labels: dict[str, str] | None = None,
615 ) -> pytest_userver.metrics.MetricValue: ...
616
617 @overload
618 def value_at(
619 self,
620 subpath: str | None,
621 add_labels: dict[str, str] | None,
622 *,
623 default: T,
624 ) -> pytest_userver.metrics.MetricValue | T: ...
625
626 def value_at(
627 self,
628 subpath: str | None = None,
629 add_labels: dict[str, str] | None = None,
630 *,
631 default: Any = _MISSING,
632 ) -> pytest_userver.metrics.MetricValue | Any:
633 """
634 Returns a single metric value at the specified path, prepending
635 the path provided at construction. If a dict of labels is provided,
636 does an exact match of labels, prepending the labels provided at construction.
637
638 If `default` is provided, it is returned instead of asserting when
639 the metric is not found.
640
641 @param subpath Suffix of the metric path; the path provided at construction is prepended
642 @param add_labels Labels that the metric must have in addition to the labels provided at construction
643 @param default An optional default value in case the metric is missing
644 @throws AssertionError if not one metric by path and no `default` is given
645 """
646 path = subpath or ''
647 if default is _MISSING:
648 return self.diff.value_at(path, add_labels)
649 return self.diff.value_at(path, add_labels, default=default)
650
651 async def fetch(self) -> pytest_userver.metrics.MetricsSnapshot:
652 """
653 Returns metric values from the service without mutating the differ.
654 """
655 return await self._client.metrics(
656 path=self._path,
657 prefix=self._prefix,
658 labels=self._labels,
659 sliced=self._sliced,
660 )
661
662 async def fetch_baseline(self) -> None:
663 """
664 Fetches metric values from the service and stores them as `self.baseline`.
665
666 Useful as an alternative to `async with monitor_client.metrics_diff(...)`
667 when the diffing scope doesn't map cleanly onto a single `with` block.
668 """
669 self.baseline = await self.fetch()
670
671 async def fetch_current(self) -> None:
672 """
673 Fetches metric values from the service and stores them as `self.current`,
674 updating `self.diff` accordingly.
675
676 @throws AssertionError if `self.baseline` was not set first
677 """
678 self.current = await self.fetch()
679
680 async def __aenter__(self) -> MetricsDiffer:
681 self._current = None
682 await self.fetch_baseline()
683 return self
684
685 async def __aexit__(self, exc_type, exc, exc_tb) -> None:
686 await self.fetch_current()
687
688
689# @cond
690
691
692def _subtract_metrics_snapshots(
693 current: pytest_userver.metrics.MetricsSnapshot,
695 diff_gauge: bool,
697 assert current._sliced_prefix == initial._sliced_prefix
698 assert current._sliced_labels == initial._sliced_labels
700 path: {_subtract_metrics(path, current_metric, initial, diff_gauge) for current_metric in current_group}
701 for path, current_group in current.items()
702 })
703 diff._sliced_prefix = current._sliced_prefix
704 diff._sliced_labels = current._sliced_labels
705 return diff
706
707
708def _subtract_metrics(
709 path: str,
710 current_metric: pytest_userver.metrics.Metric,
712 diff_gauge: bool,
714 initial_group = initial.get(path, None)
715 if initial_group is None:
716 return current_metric
717 initial_metric = next(
718 (x for x in initial_group if x.labels == current_metric.labels),
719 None,
720 )
721 if initial_metric is None:
722 return current_metric
723
725 labels=current_metric.labels,
726 value=_subtract_metric_values(
727 current=current_metric,
728 initial=initial_metric,
729 diff_gauge=diff_gauge,
730 ),
731 _type=current_metric.type(),
732 )
733
734
735def _subtract_metric_values(
736 current: pytest_userver.metrics.Metric,
738 diff_gauge: bool,
739) -> pytest_userver.metrics.MetricValue:
740 assert current.type() is not pytest_userver.metrics.MetricType.UNSPECIFIED
741 assert initial.type() is not pytest_userver.metrics.MetricType.UNSPECIFIED
742 assert current.type() == initial.type()
743
744 if isinstance(current.value, pytest_userver.metrics.Histogram):
745 assert isinstance(initial.value, pytest_userver.metrics.Histogram)
746 return _subtract_metric_values_hist(current=current, initial=initial)
747 else:
748 assert not isinstance(initial.value, pytest_userver.metrics.Histogram)
749 return _subtract_metric_values_num(
750 current=current,
751 initial=initial,
752 diff_gauge=diff_gauge,
753 )
754
755
756def _subtract_metric_values_num(
757 current: pytest_userver.metrics.Metric,
759 diff_gauge: bool,
760) -> float:
761 current_value = typing.cast(float, current.value)
762 initial_value = typing.cast(float, initial.value)
763 should_diff = (
764 current.type() is pytest_userver.metrics.MetricType.RATE
765 or initial.type() is pytest_userver.metrics.MetricType.RATE
766 or diff_gauge
767 )
768 return current_value - initial_value if should_diff else current_value
769
770
771def _subtract_metric_values_hist(
772 current: pytest_userver.metrics.Metric,
775 current_value = typing.cast(pytest_userver.metrics.Histogram, current.value)
776 initial_value = typing.cast(pytest_userver.metrics.Histogram, initial.value)
777 assert current_value.bounds == initial_value.bounds
779 bounds=current_value.bounds,
780 buckets=[t[0] - t[1] for t in zip(current_value.buckets, initial_value.buckets, strict=True)],
781 inf=current_value.inf - initial_value.inf,
782 )
783
784
785class AiohttpClient(service_client.AiohttpClient):
786 PeriodicTaskFailed = PeriodicTaskFailed
787 TestsuiteActionFailed = TestsuiteActionFailed
788 TestsuiteTaskNotFound = TestsuiteTaskNotFound
789 TestsuiteTaskConflict = TestsuiteTaskConflict
790 TestsuiteTaskFailed = TestsuiteTaskFailed
791
792 def __init__(
793 self,
794 base_url: str,
795 *,
796 config: TestsuiteClientConfig,
797 mocked_time,
798 log_capture_fixture: logcapture.CaptureServer,
799 testpoint,
800 testpoint_control,
801 cache_invalidation_state,
802 span_id_header=None,
803 api_coverage_report=None,
804 periodic_tasks_state: PeriodicTasksState | None = None,
805 allow_all_caches_invalidation: bool = True,
806 cache_control: caches.CacheControl | None = None,
807 asyncexc_check=None,
808 **kwargs,
809 ):
810 super().__init__(base_url, span_id_header=span_id_header, **kwargs)
811 self._config = config
812 self._periodic_tasks = periodic_tasks_state
813 self._testpoint = testpoint
814 self._log_capture_fixture = log_capture_fixture
815 self._state_manager = _StateManager(
816 mocked_time=mocked_time,
817 testpoint=self._testpoint,
818 testpoint_control=testpoint_control,
819 invalidation_state=cache_invalidation_state,
820 cache_control=cache_control,
821 )
822 self._api_coverage_report = api_coverage_report
823 self._allow_all_caches_invalidation = allow_all_caches_invalidation
824 self._asyncexc_check = asyncexc_check
825
826 async def run_periodic(self, name) -> None:
827 await self.run_task(f'periodic/{name}')
828
829 async def run_periodic_task(self, name):
830 warnings.warn(userver_warnings.WARN_PERIODIC_DEPRECATION, DeprecationWarning)
831 response = await self._testsuite_action('run_periodic_task', name=name)
832 if not response['status']:
833 raise self.PeriodicTaskFailed(f'Periodic task {name} failed')
834
835 async def suspend_periodic_tasks(self, names: list[str]) -> None:
836 if not self._periodic_tasks:
837 raise ConfigurationError('No periodic_tasks_state given')
838 self._periodic_tasks.tasks_to_suspend.update(names)
839 await self._suspend_periodic_tasks()
840
841 async def resume_periodic_tasks(self, names: list[str]) -> None:
842 warnings.warn(userver_warnings.WARN_PERIODIC_DEPRECATION, DeprecationWarning)
843 if not self._periodic_tasks:
844 raise ConfigurationError('No periodic_tasks_state given')
845 self._periodic_tasks.tasks_to_suspend.difference_update(names)
846 await self._suspend_periodic_tasks()
847
848 async def resume_all_periodic_tasks(self) -> None:
849 if not self._periodic_tasks:
850 raise ConfigurationError('No periodic_tasks_state given')
851 self._periodic_tasks.tasks_to_suspend.clear()
852 await self._suspend_periodic_tasks()
853
854 async def write_cache_dumps(
855 self,
856 names: list[str],
857 *,
858 testsuite_skip_prepare=False,
859 ) -> None:
860 await self._testsuite_action(
861 'write_cache_dumps',
862 names=names,
863 testsuite_skip_prepare=testsuite_skip_prepare,
864 )
865
866 async def read_cache_dumps(
867 self,
868 names: list[str],
869 *,
870 testsuite_skip_prepare=False,
871 ) -> None:
872 await self._testsuite_action(
873 'read_cache_dumps',
874 names=names,
875 testsuite_skip_prepare=testsuite_skip_prepare,
876 )
877
878 async def run_distlock_task(self, name: str) -> None:
879 await self.run_task(f'distlock/{name}')
880
881 async def reset_metrics(self) -> None:
882 await self._testsuite_action('reset_metrics')
883
884 async def metrics_portability(
885 self,
886 *,
887 prefix: str | None = None,
888 ) -> dict[str, list[dict[str, str]]]:
889 return await self._testsuite_action(
890 'metrics_portability',
891 prefix=prefix,
892 )
893
894 async def list_tasks(self) -> list[str]:
895 response = await self._do_testsuite_action('tasks_list')
896 async with response:
897 response.raise_for_status()
898 body = await response.json(content_type=None)
899 return body['tasks']
900
901 async def run_task(self, name: str) -> None:
902 response = await self._do_testsuite_action(
903 'task_run',
904 json={'name': name},
905 )
906 await _task_check_response(name, response)
907
908 @contextlib.asynccontextmanager
909 async def spawn_task(self, name: str):
910 task_id = await self._task_spawn(name)
911 try:
912 yield
913 finally:
914 await self._task_stop_spawned(task_id)
915
916 async def _task_spawn(self, name: str) -> str:
917 response = await self._do_testsuite_action(
918 'task_spawn',
919 json={'name': name},
920 )
921 data = await _task_check_response(name, response)
922 return data['task_id']
923
924 async def _task_stop_spawned(self, task_id: str) -> None:
925 response = await self._do_testsuite_action(
926 'task_stop',
927 json={'task_id': task_id},
928 )
929 await _task_check_response(task_id, response)
930
931 async def http_allowed_urls_extra(
932 self,
933 http_allowed_urls_extra: list[str],
934 ) -> None:
935 await self._do_testsuite_action(
936 'http_allowed_urls_extra',
937 json={'allowed_urls_extra': http_allowed_urls_extra},
938 testsuite_skip_prepare=True,
939 )
940
941 @contextlib.asynccontextmanager
942 async def capture_logs(
943 self,
944 *,
945 log_level: str = 'DEBUG',
946 testsuite_skip_prepare: bool = False,
947 ):
948 async with self._log_capture_fixture.capture(
949 log_level=logcapture.LogLevel.from_string(log_level),
950 ) as capture:
951 logger.debug('Starting logcapture')
952 await self._testsuite_action(
953 'log_capture',
954 log_level=log_level,
955 socket_logging_duplication=True,
956 testsuite_skip_prepare=testsuite_skip_prepare,
957 )
958
959 try:
960 await self._log_capture_fixture.wait_for_client()
961 yield capture
962 finally:
963 await self._testsuite_action(
964 'log_capture',
965 log_level=self._log_capture_fixture.default_log_level.name,
966 socket_logging_duplication=False,
967 testsuite_skip_prepare=testsuite_skip_prepare,
968 )
969
970 async def log_flush(self, logger_name: str | None = None):
971 await self._testsuite_action(
972 'log_flush',
973 logger_name=logger_name,
974 testsuite_skip_prepare=True,
975 )
976
977 async def invalidate_caches(
978 self,
979 *,
980 clean_update: bool = True,
981 cache_names: list[str] | None = None,
982 testsuite_skip_prepare: bool = False,
983 ) -> None:
984 if cache_names is None and clean_update:
985 if self._allow_all_caches_invalidation:
986 warnings.warn(CACHE_INVALIDATION_MESSAGE, DeprecationWarning, stacklevel=2)
987 else:
988 __tracebackhide__ = True
989 raise RuntimeError(CACHE_INVALIDATION_MESSAGE)
990
991 if testsuite_skip_prepare:
992 await self._tests_control({
993 'invalidate_caches': {
994 'update_type': ('full' if clean_update else 'incremental'),
995 **({'names': cache_names} if cache_names else {}),
996 },
997 })
998 else:
999 await self.tests_control(
1000 invalidate_caches=True,
1001 clean_update=clean_update,
1002 cache_names=cache_names,
1003 )
1004
1005 async def tests_control(
1006 self,
1007 *,
1008 invalidate_caches: bool = True,
1009 clean_update: bool = True,
1010 cache_names: list[str] | None = None,
1011 http_allowed_urls_extra: list[str] | None = None,
1012 ) -> dict[str, Any]:
1013 body: dict[str, Any] = self._state_manager.get_pending_update()
1014
1015 if 'invalidate_caches' in body and invalidate_caches:
1016 if not clean_update or cache_names:
1017 logger.warning(
1018 'Manual cache invalidation leads to indirect initial full cache invalidation',
1019 )
1020 await self._prepare()
1021 body = {}
1022
1023 if invalidate_caches:
1024 body['invalidate_caches'] = {
1025 'update_type': ('full' if clean_update else 'incremental'),
1026 }
1027 if cache_names:
1028 body['invalidate_caches']['names'] = cache_names
1029
1030 if http_allowed_urls_extra is not None:
1031 await self.http_allowed_urls_extra(http_allowed_urls_extra)
1032
1033 return await self._tests_control(body)
1034
1035 async def update_server_state(self) -> None:
1036 await self._prepare()
1037
1038 async def enable_testpoints(self, *, no_auto_cache_cleanup=False) -> None:
1039 if not self._testpoint:
1040 return
1041 if no_auto_cache_cleanup:
1042 await self._tests_control({
1043 'testpoints': sorted(self._testpoint.keys()),
1044 })
1045 else:
1046 await self.update_server_state()
1047
1048 async def get_dynamic_config_defaults(
1049 self,
1050 ) -> dict[str, Any]:
1051 return await self._testsuite_action(
1052 'get_dynamic_config_defaults',
1053 testsuite_skip_prepare=True,
1054 )
1055
1056 async def _tests_control(self, body: dict) -> dict[str, Any]:
1057 with self._state_manager.updating_state(body):
1058 async with await self._do_testsuite_action(
1059 'control',
1060 json=body,
1061 testsuite_skip_prepare=True,
1062 ) as response:
1063 if response.status == 404:
1064 raise ConfigurationError(
1065 'It seems that testsuite support is not enabled for your service',
1066 )
1067 response.raise_for_status()
1068 return await response.json(content_type=None)
1069
1070 async def _suspend_periodic_tasks(self):
1071 if self._periodic_tasks.tasks_to_suspend != self._periodic_tasks.suspended_tasks:
1072 await self._testsuite_action(
1073 'suspend_periodic_tasks',
1074 names=sorted(self._periodic_tasks.tasks_to_suspend),
1075 )
1076 self._periodic_tasks.suspended_tasks = set(
1077 self._periodic_tasks.tasks_to_suspend,
1078 )
1079
1080 def _do_testsuite_action(self, action, **kwargs):
1081 if not self._config.testsuite_action_path:
1082 raise ConfigurationError(
1083 'tests-control component is not properly configured',
1084 )
1085 path = self._config.testsuite_action_path.format(action=action)
1086 return self.post(path, **kwargs)
1087
1088 async def _testsuite_action(
1089 self,
1090 action,
1091 *,
1092 testsuite_skip_prepare=False,
1093 **kwargs,
1094 ):
1095 async with await self._do_testsuite_action(
1096 action,
1097 json=kwargs,
1098 testsuite_skip_prepare=testsuite_skip_prepare,
1099 ) as response:
1100 if response.status == 500:
1101 raise TestsuiteActionFailed
1102 response.raise_for_status()
1103 return await response.json(content_type=None)
1104
1105 async def _prepare(self) -> None:
1106 with self._state_manager.cache_control_update() as pending_update:
1107 if pending_update:
1108 await self._tests_control(pending_update)
1109
1110 async def _request( # pylint: disable=arguments-differ
1111 self,
1112 http_method: str,
1113 path: str,
1114 headers: dict[str, str] | None = None,
1115 bearer: str | None = None,
1116 x_real_ip: str | None = None,
1117 *,
1118 testsuite_skip_prepare: bool = False,
1119 **kwargs,
1120 ) -> aiohttp.ClientResponse:
1121 if self._asyncexc_check:
1122 # Check for pending background exceptions before call.
1123 self._asyncexc_check()
1124
1125 if not testsuite_skip_prepare:
1126 await self._prepare()
1127
1128 response = await super()._request(
1129 http_method,
1130 path,
1131 headers,
1132 bearer,
1133 x_real_ip,
1134 **kwargs,
1135 )
1136 if self._api_coverage_report:
1137 self._api_coverage_report.update_usage_stat(
1138 path,
1139 http_method,
1140 response.status,
1141 response.content_type,
1142 )
1143
1144 if self._asyncexc_check:
1145 # Check for pending background exceptions after call.
1146 self._asyncexc_check()
1147
1148 return response
1149
1150
1151# @endcond
1152
1153
1155 """
1156 Asyncio userver client, typically retrieved from
1157 @ref service_client "plugins.service_client.service_client"
1158 fixture.
1159
1160 Compatible with werkzeug interface.
1161
1162 @ingroup userver_testsuite
1163 """
1164
1165 PeriodicTaskFailed = PeriodicTaskFailed
1166 TestsuiteActionFailed = TestsuiteActionFailed
1167 TestsuiteTaskNotFound = TestsuiteTaskNotFound
1168 TestsuiteTaskConflict = TestsuiteTaskConflict
1169 TestsuiteTaskFailed = TestsuiteTaskFailed
1170
1171 def _wrap_client_response(self, response: aiohttp.ClientResponse) -> Awaitable[http.ClientResponse]:
1172 return http.wrap_client_response(
1173 response,
1174 json_loads=approx.json_loads,
1175 )
1176
1177 @_wrap_client_error
1178 async def run_periodic(self, name) -> None:
1179 await self._client.run_periodic(name)
1180
1181 @_wrap_client_error
1182 async def run_periodic_task(self, name):
1183 warnings.warn(userver_warnings.WARN_PERIODIC_DEPRECATION, DeprecationWarning)
1184 await self._client.run_periodic_task(name)
1185
1186 @_wrap_client_error
1187 async def suspend_periodic_tasks(self, names: list[str]) -> None:
1188 await self._client.suspend_periodic_tasks(names)
1189
1190 @_wrap_client_error
1191 async def resume_periodic_tasks(self, names: list[str]) -> None:
1192 warnings.warn(userver_warnings.WARN_PERIODIC_DEPRECATION, DeprecationWarning)
1193 await self._client.resume_periodic_tasks(names)
1194
1195 @_wrap_client_error
1196 async def resume_all_periodic_tasks(self) -> None:
1197 await self._client.resume_all_periodic_tasks()
1198
1199 @_wrap_client_error
1200 async def write_cache_dumps(
1201 self,
1202 names: list[str],
1203 *,
1204 testsuite_skip_prepare=False,
1205 ) -> None:
1206 await self._client.write_cache_dumps(
1207 names=names,
1208 testsuite_skip_prepare=testsuite_skip_prepare,
1209 )
1210
1211 @_wrap_client_error
1212 async def read_cache_dumps(
1213 self,
1214 names: list[str],
1215 *,
1216 testsuite_skip_prepare=False,
1217 ) -> None:
1218 await self._client.read_cache_dumps(
1219 names=names,
1220 testsuite_skip_prepare=testsuite_skip_prepare,
1221 )
1222
1223 async def run_task(self, name: str) -> None:
1224 await self._client.run_task(name)
1225
1226 async def run_distlock_task(self, name: str) -> None:
1227 await self._client.run_distlock_task(name)
1228
1229 async def reset_metrics(self) -> None:
1230 """
1231 Calls `ResetMetric(metric);` for each metric that has such C++ function.
1232
1233 Note that using `reset_metrics()` is discouraged, prefer using a more reliable
1234 @ref pytest_userver.client.ClientMonitor.metrics_diff "await monitor_client.metrics_diff()".
1235
1236 @snippet samples/testsuite-support/tests/test_metrics.py metrics reset
1237 """
1238 await self._client.reset_metrics()
1239
1241 self,
1242 *,
1243 prefix: str | None = None,
1244 ) -> dict[str, list[dict[str, str]]]:
1245 """
1246 Reports metrics related issues that could be encountered on
1247 different monitoring systems.
1248
1249 @sa @ref utils::statistics::GetPortabilityWarnings
1250 """
1251 return await self._client.metrics_portability(prefix=prefix)
1252
1253 def list_tasks(self) -> list[str]:
1254 return self._client.list_tasks()
1255
1256 def spawn_task(self, name: str):
1257 return self._client.spawn_task(name)
1258
1260 self,
1261 *,
1262 log_level: str = 'DEBUG',
1263 testsuite_skip_prepare: bool = False,
1264 ):
1265 """
1266 Captures logs from the service.
1267
1268 @param log_level Do not capture logs below this level.
1269 @param testsuite_skip_prepare An advanced parameter to skip auto-`update_server_state`.
1270
1271 Example — filter captured logs after a request:
1272
1273 @snippet samples/testsuite-support/tests/test_logcapture.py select
1274
1275 Example — subscribe to log events as they arrive:
1276
1277 @snippet samples/testsuite-support/tests/test_logcapture.py subscribe
1278
1279 @see @ref testsuite_logs_capture
1280 """
1281 return self._client.capture_logs(
1282 log_level=log_level,
1283 testsuite_skip_prepare=testsuite_skip_prepare,
1284 )
1285
1286 def log_flush(self, logger_name: str | None = None):
1287 """
1288 Flush service logs.
1289 """
1290 return self._client.log_flush(logger_name=logger_name)
1291
1292 @_wrap_client_error
1294 self,
1295 *,
1296 clean_update: bool = True,
1297 cache_names: list[str] | None = None,
1298 testsuite_skip_prepare: bool = False,
1299 ) -> None:
1300 """
1301 Send request to service to update caches.
1302
1303 @param clean_update if False, service will do a faster incremental
1304 update of caches whenever possible.
1305 @param cache_names which caches specifically should be updated;
1306 update all if None.
1307 @param testsuite_skip_prepare if False, service will automatically do
1308 update_server_state().
1309 """
1310 __tracebackhide__ = True
1311 await self._client.invalidate_caches(
1312 clean_update=clean_update,
1313 cache_names=cache_names,
1314 testsuite_skip_prepare=testsuite_skip_prepare,
1315 )
1316
1317 @_wrap_client_error
1318 async def tests_control(
1319 self,
1320 invalidate_caches: bool = True,
1321 clean_update: bool = True,
1322 cache_names: list[str] | None = None,
1323 http_allowed_urls_extra: list[str] | None = None,
1324 ) -> dict[str, Any]:
1325 return await self._client.tests_control(
1326 invalidate_caches=invalidate_caches,
1327 clean_update=clean_update,
1328 cache_names=cache_names,
1329 http_allowed_urls_extra=http_allowed_urls_extra,
1330 )
1331
1332 @_wrap_client_error
1333 async def update_server_state(self) -> None:
1334 """
1335 Update service-side state through http call to 'tests/control':
1336 - clear dirty (from other tests) caches
1337 - set service-side mocked time,
1338 - resume / suspend periodic tasks
1339 - enable testpoints
1340 If service is up-to-date, does nothing.
1341 """
1342 await self._client.update_server_state()
1343
1344 @_wrap_client_error
1345 async def enable_testpoints(self, no_auto_cache_cleanup: bool = False) -> None:
1346 """
1347 Send list of handled testpoint pats to service. For these paths service
1348 will no more skip http calls from TESTPOINT(...) macro.
1349
1350 @param no_auto_cache_cleanup prevent automatic cache cleanup.
1351 When calling service client first time in scope of current test, client
1352 makes additional http call to `tests/control` to update caches, to get
1353 rid of data from previous test.
1354 """
1355 await self._client.enable_testpoints(no_auto_cache_cleanup=no_auto_cache_cleanup)
1356
1357 @_wrap_client_error
1358 async def get_dynamic_config_defaults(
1359 self,
1360 ) -> dict[str, Any]:
1361 return await self._client.get_dynamic_config_defaults()
1362
1363
1364@dataclasses.dataclass
1366 """Reflects the (supposed) current service state."""
1367
1368 invalidation_state: caches.InvalidationState
1369 now: str | None = _UNKNOWN_STATE
1370 testpoints: frozenset[str] = frozenset([_UNKNOWN_STATE])
1371
1372
1374 """
1375 Used for computing the requests that we need to automatically align
1376 the service state with the test fixtures state.
1377 """
1378
1379 def __init__(
1380 self,
1381 *,
1382 mocked_time,
1383 testpoint,
1384 testpoint_control,
1385 invalidation_state: caches.InvalidationState,
1386 cache_control: caches.CacheControl | None,
1387 ):
1388 self._state = _State(
1389 invalidation_state=copy.deepcopy(invalidation_state),
1390 )
1391 self._mocked_time = mocked_time
1392 self._testpoint = testpoint
1393 self._testpoint_control = testpoint_control
1394 self._invalidation_state = invalidation_state
1395 self._cache_control = cache_control
1396
1397 @contextlib.contextmanager
1398 def updating_state(self, body: dict[str, Any]):
1399 """
1400 Whenever `tests_control` handler is invoked
1401 (by the client itself during `prepare` or manually by the user),
1402 we need to synchronize `_state` with the (supposed) service state.
1403 The state update is decoded from the request body.
1404 """
1405 saved_state = copy.deepcopy(self._state)
1406 try:
1407 self._update_state(body)
1408 self._apply_new_state()
1409 yield
1410 except Exception: # noqa
1411 self._state = saved_state
1412 self._apply_new_state()
1413 raise
1414
1415 def get_pending_update(self) -> dict[str, Any]:
1416 """
1417 Compose the body of the `tests_control` request required to completely
1418 synchronize the service state with the state of test fixtures.
1419 """
1420 body: dict[str, Any] = {}
1421
1422 if self._invalidation_state.has_caches_to_update:
1423 body['invalidate_caches'] = {'update_type': 'full'}
1424 if not self._invalidation_state.should_update_all_caches:
1425 body['invalidate_caches']['names'] = list(
1426 self._invalidation_state.caches_to_update,
1427 )
1428
1429 desired_testpoints = self._testpoint.keys()
1430 if self._state.testpoints != frozenset(desired_testpoints):
1431 body['testpoints'] = sorted(desired_testpoints)
1432
1433 desired_now = self._get_desired_now()
1434 if self._state.now != desired_now:
1435 body['mock_now'] = desired_now
1436
1437 return body
1438
1439 @contextlib.contextmanager
1440 def cache_control_update(self) -> Iterator[dict[Any, Any]]:
1441 pending_update = self.get_pending_update()
1442 invalidate_caches = pending_update.get('invalidate_caches')
1443 if not invalidate_caches or not self._cache_control:
1444 yield pending_update
1445 else:
1446 cache_names = invalidate_caches.get('names')
1447 staged, actions = self._cache_control.query_caches(cache_names)
1448 self._apply_cache_control_actions(invalidate_caches, actions)
1449 yield pending_update
1450 self._cache_control.commit_staged(staged)
1451
1452 @staticmethod
1453 def _apply_cache_control_actions(
1454 invalidate_caches: dict[Any, Any],
1455 actions: list[tuple[str, caches.CacheControlAction]],
1456 ) -> None:
1457 cache_names = invalidate_caches.get('names')
1458 exclude_names = invalidate_caches.setdefault('exclude_names', [])
1459 force_incremental_names = invalidate_caches.setdefault(
1460 'force_incremental_names',
1461 [],
1462 )
1463 for cache_name, action in actions:
1464 match action:
1465 case caches.CacheControlAction.FULL:
1466 pass
1467 case caches.CacheControlAction.INCREMENTAL:
1468 force_incremental_names.append(cache_name)
1469 case caches.CacheControlAction.EXCLUDE:
1470 if cache_names is not None:
1471 cache_names.remove(cache_name)
1472 else:
1473 exclude_names.append(cache_name)
1474
1475 def _update_state(self, body: dict[str, Any]) -> None:
1476 body_invalidate_caches = body.get('invalidate_caches', {})
1477 update_type = body_invalidate_caches.get('update_type', 'full')
1478 body_cache_names = body_invalidate_caches.get('names', None)
1479 # An incremental update is considered insufficient to bring a cache
1480 # to a known state.
1481 if body_invalidate_caches and update_type == 'full':
1482 if body_cache_names is None:
1483 self._state.invalidation_state.on_all_caches_updated()
1484 else:
1485 self._state.invalidation_state.on_caches_updated(
1486 body_cache_names,
1487 )
1488
1489 if 'mock_now' in body:
1490 self._state.now = body['mock_now']
1491
1492 testpoints: list[str] | None = body.get('testpoints')
1493 if testpoints is not None:
1494 self._state.testpoints = frozenset(testpoints)
1495
1497 """Apply new state to related components."""
1498 self._testpoint_control.enabled_testpoints = self._state.testpoints
1499 self._invalidation_state.assign_copy(self._state.invalidation_state)
1500
1501 def _get_desired_now(self) -> str | None:
1502 if self._mocked_time.is_enabled:
1503 return utils.timestring(self._mocked_time.now())
1504 return None
1505
1506
1507async def _task_check_response(name: str, response) -> dict:
1508 async with response:
1509 if response.status == 404:
1510 raise TestsuiteTaskNotFound(f'Testsuite task {name!r} not found')
1511 if response.status == 409:
1512 raise TestsuiteTaskConflict(f'Testsuite task {name!r} conflict')
1513 assert response.status == 200
1514 data = await response.json()
1515 if not data.get('status', True):
1516 raise TestsuiteTaskFailed(name, data['reason'])
1517 return data