userver: /data/code/service_template/third_party/userver/testsuite/pytest_plugins/pytest_userver/metrics.py Source File
⚠️ This is the documentation for an old userver version. Click here to switch to the latest version.
All Classes Namespaces Files Functions Variables Typedefs Enumerations Enumerator Friends Macros Modules Pages Concepts
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)