userver: /data/code/service_template/third_party/userver/testsuite/pytest_plugins/pytest_userver/metrics.py Source File
Loading...
Searching...
No Matches
metrics.py
1"""
2Python module that provides helpers for functional testing of metrics with
3testsuite; see
4@ref scripts/docs/en/userver/functional_testing.md for an introduction.
5
6@ingroup userver_testsuite
7"""
8
9import dataclasses
10import json
11import typing
12
13
14@dataclasses.dataclass(frozen=True)
15class Metric:
16 """
17 Metric type that contains the `labels: typing.Dict[str, str]` and
18 `value: int`.
19
20 The type is hashable and comparable:
21 @snippet testsuite/tests/test_metrics.py values set
22
23 @ingroup userver_testsuite
24 """
25
26 labels: typing.Dict[str, str]
27 value: float
28
29 def __hash__(self) -> int:
30 return hash(self.get_labels_tuple())
31
32 def get_labels_tuple(self) -> typing.Tuple:
33 """ Returns labels as a tuple of sorted items """
34 return tuple(sorted(self.labels.items()))
35
36
37class _MetricsJSONEncoder(json.JSONEncoder):
38 def default(self, o): # pylint: disable=method-hidden
39 if isinstance(o, Metric):
40 return dataclasses.asdict(o)
41 if isinstance(o, set):
42 return list(o)
43 return super().default(o)
44
45
47 """
48 Snapshot of captured metrics that mimics the dict interface. Metrics have
49 the 'Dict[str(path), Set[Metric]]' format.
50
51 @snippet samples/testsuite-support/tests/test_metrics.py metrics labels
52
53 @ingroup userver_testsuite
54 """
55
56 def __init__(self, values: typing.Mapping[str, typing.Set[Metric]]):
57 self._values = values
58
59 def __getitem__(self, path: str) -> typing.Set[Metric]:
60 """ Returns a list of metrics by specified path """
61 return self._values[path]
62
63 def __len__(self) -> int:
64 """ Returns count of metrics paths """
65 return len(self._values)
66
67 def __iter__(self):
68 """ Returns a (path, list) iterable over the metrics """
69 return self._values.__iter__()
70
71 def __contains__(self, path: str) -> bool:
72 """
73 Returns True if metric with specified path is in the snapshot,
74 False otherwise.
75 """
76 return path in self._values
77
78 def __eq__(self, other: object) -> bool:
79 """
80 Compares the snapshot with a dict of metrics or with
81 another snapshot
82 """
83 return self._values == other
84
85 def __repr__(self) -> str:
86 return self._values.__repr__()
87
88 def get(self, path: str, default=None):
89 """
90 Returns an list of metrics by path or default if there's no
91 such path
92 """
93 return self._values.get(path, default)
94
95 def items(self):
96 """ Returns a (path, list) iterable over the metrics """
97 return self._values.items()
98
99 def keys(self):
100 """ Returns an iterable over paths of metrics """
101 return self._values.keys()
102
103 def values(self):
104 """ Returns an iterable over lists of metrics """
105 return self._values.values()
106
108 self,
109 path: str,
110 labels: typing.Optional[typing.Dict] = None,
111 *,
112 default: typing.Optional[float] = None,
113 ) -> float:
114 """
115 Returns a single metric value at specified path. If a dict of labels
116 is provided, does en exact match of labels (i.e. {} stands for no
117 labels; {'a': 'b', 'c': 'd'} matches only {'a': 'b', 'c': 'd'} or
118 {'c': 'd', 'a': 'b'} but neither match {'a': 'b'} nor
119 {'a': 'b', 'c': 'd', 'e': 'f'}).
120
121 @throws AssertionError if not one metric by path
122 """
123 entry = self.get(path, set())
124 assert (
125 entry or default is not None
126 ), f'No metrics found by path "{path}"'
127
128 if labels is not None:
129 entry = {x for x in entry if x.labels == labels}
130 assert (
131 entry or default is not None
132 ), f'No metrics found by path "{path}" and labels {labels}'
133 assert len(entry) <= 1, (
134 f'Multiple metrics found by path "{path}" and labels {labels}:'
135 f' {entry}'
136 )
137 else:
138 assert (
139 len(entry) <= 1
140 ), f'Multiple metrics found by path "{path}": {entry}'
141
142 if default is not None and not entry:
143 return default
144 return next(iter(entry)).value
145
147 self,
148 other: typing.Mapping[str, typing.Set[Metric]],
149 *,
150 ignore_zeros: bool = False,
151 ) -> None:
152 """
153 Compares the snapshot with a dict of metrics or with
154 another snapshot, displaying a nice diff on mismatch
155 """
156 lhs = _flatten_snapshot(self, ignore_zeros=ignore_zeros)
157 rhs = _flatten_snapshot(other, ignore_zeros=ignore_zeros)
158 assert lhs == rhs, _diff_metric_snapshots(lhs, rhs, ignore_zeros)
159
160 @staticmethod
161 def from_json(json_str: str) -> 'MetricsSnapshot':
162 """
163 Construct MetricsSnapshot from a JSON string
164 """
165 json_data = {
166 str(path): {
167 Metric(labels=element['labels'], value=element['value'])
168 for element in metrics_list
169 }
170 for path, metrics_list in json.loads(json_str).items()
171 }
172 return MetricsSnapshot(json_data)
173
174 def to_json(self) -> str:
175 """
176 Serialize to a JSON string
177 """
178 return json.dumps(self._values, cls=_MetricsJSONEncoder)
179
180
181_FlattenedSnapshot = typing.Set[typing.Tuple[str, Metric]]
182
183
184def _flatten_snapshot(values, ignore_zeros: bool) -> _FlattenedSnapshot:
185 return set(
186 (path, metric)
187 for path, metrics in values.items()
188 for metric in metrics
189 if metric.value != 0 or not ignore_zeros
190 )
191
192
193def _diff_metric_snapshots(
194 lhs: _FlattenedSnapshot, rhs: _FlattenedSnapshot, ignore_zeros: bool,
195) -> str:
196 def extra_metrics_message(extra, base):
197 return [
198 f' path={path!r} labels={metric.labels!r} value={metric.value}'
199 for path, metric in sorted(extra, key=lambda pair: pair[0])
200 if (path, metric) not in base
201 ]
202
203 if ignore_zeros:
204 lines = ['left.assert_equals(right, ignore_zeros=True) failed']
205 else:
206 lines = ['left.assert_equals(right) failed']
207 actual_extra = extra_metrics_message(lhs, rhs)
208 if actual_extra:
209 lines.append(' extra in left:')
210 lines += actual_extra
211
212 actual_gt = extra_metrics_message(rhs, lhs)
213 if actual_gt:
214 lines.append(' missing in left:')
215 lines += actual_gt
216
217 return '\n'.join(lines)