userver: /data/code/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
9from __future__ import annotations
10
11from collections.abc import Mapping
12from collections.abc import Set
13import dataclasses
14import enum
15import itertools
16import json
17import math
18import random
19from typing import Any
20from typing import overload
21from typing import TypeAlias
22from typing import TypeVar
23
24
25# @cond
26class MetricType(str, enum.Enum):
27 """
28 The type of individual metric.
29
30 `UNSPECIFIED` compares equal to all `MetricType`s.
31 To disable this behavior, use `is` for comparisons.
32 """
33
34 UNSPECIFIED = 'UNSPECIFIED'
35 GAUGE = 'GAUGE'
36 RATE = 'RATE'
37 HIST_RATE = 'HIST_RATE'
38 # @endcond
39
40
41@dataclasses.dataclass
43 """
44 Represents the value of a HIST_RATE (a.k.a. Histogram) metric.
45
46 Usage example:
47 @snippet testsuite/tests/metrics/test_metrics.py histogram
48
49 Normally obtained from MetricsSnapshot
50 """
51
52 bounds: list[float]
53 buckets: list[int]
54 inf: int
55
56 def count(self) -> int:
57 return sum(self.buckets) + self.inf
58
59 def percentile(self, percent: float) -> float:
60 return _do_compute_percentile(self, percent)
61
62 # @cond
63 def __post_init__(self):
64 assert len(self.bounds) == len(self.buckets)
65 assert sorted(self.bounds) == self.bounds
66 if self.bounds:
67 assert self.bounds[0] > 0
68 assert self.bounds[-1] != math.inf
69
70 # @endcond
71
72
73MetricValue: TypeAlias = float | Histogram
74
75T = TypeVar('T')
76_MISSING: Any = object()
77
78
79@dataclasses.dataclass(frozen=True)
80class Metric:
81 """
82 Metric type that contains the `labels: dict[str, str]` and
83 `value: int`.
84
85 The type is hashable and comparable:
86 @snippet testsuite/tests/metrics/test_metrics.py values set
87
88 @ingroup userver_testsuite
89 """
90
91 labels: dict[str, str]
92 value: MetricValue
93
94 # @cond
95 # Should not be specified explicitly, for internal use only.
96 _type: MetricType = MetricType.UNSPECIFIED
97 # @endcond
98
99 def __eq__(self, other: object) -> bool:
100 if not isinstance(other, Metric):
101 return NotImplemented
102 return self.labels == other.labels and self.value == other.value and _type_eq(self._type, other._type)
103
104 def __hash__(self) -> int:
105 return hash(_get_labels_tuple(self))
106
107 # @cond
108 def __post_init__(self):
109 if isinstance(self.value, Histogram):
110 assert self._type in (MetricType.HIST_RATE, MetricType.UNSPECIFIED)
111 else:
112 assert self._type is not MetricType.HIST_RATE
113
114 # For internal use only.
115 def type(self) -> MetricType:
116 return self._type
117
118 # @endcond
119
120
121class _MetricsJSONEncoder(json.JSONEncoder):
122 def default(self, o): # pylint: disable=method-hidden
123 if isinstance(o, Metric):
124 result = {'labels': o.labels, 'value': o.value}
125 if o.type() is not MetricType.UNSPECIFIED:
126 result['type'] = o.type()
127 return result
128 elif isinstance(o, Histogram):
129 return dataclasses.asdict(o)
130 if isinstance(o, set):
131 return list(o)
132 return super().default(o)
133
134
136 """
137 Snapshot of captured metrics that mimics the dict interface. Metrics have
138 the 'dict[str(path), Set[Metric]]' format.
139
140 Example with @ref pytest_userver.client.ClientMonitor.metrics "await monitor_client.metrics(path_prefix, labels)":
141 @snippet samples/testsuite-support/tests/test_metrics.py metrics metrics
142
143 There are 3 ways to construct a `MetricsSnapshot`:
144
145 1. The constructor itself, taking a ready `dict[str(path), Set[Metric]]`.
146 Useful when dealing with just a few simple metrics, or when generating metrics programmatically,
147 or when combining or transforming snapshots. This format is also well-suited for individual metrics
148 with a large number of labels.
149 2. `from_dict` / `from_json`, taking the flat `json` userver metrics format
150 (a list of `{"labels": ..., "value": ...}` per path).
151 This is an alternative to the constructor for loading from a JSON file.
152 Note that `from_layered_dict` is often more terse and more readable.
153 3. `from_layered_dict`, taking a layered dict format that avoids repeating labels for every metric.
154 Recommended in tests that have many metrics sharing a label structure.
155 Can be written out in code or loaded from a JSON file using `load_json` fixture.
156
157 @ingroup userver_testsuite
158 """
159
161 self,
162 values: Mapping[str, Set[Metric]],
163 *,
164 common_prefix: str = '',
165 common_labels: Mapping[str, str] | None = None,
166 ):
167 """
168 @param values Metrics keyed by path, as a `dict[str(path), Set[Metric]]`
169 (the same format `MetricsSnapshot` itself exposes via `items()`).
170 @param common_prefix If provided, prepended to each path (separated by a dot).
171 @param common_labels If provided, these labels are added to every metric,
172 merged with (and overridden by) that metric's own labels.
173 """
174 self._values = _apply_common_prefix_labels(values, common_prefix, common_labels)
175 self._sliced_prefix: str | None = None
176 self._sliced_labels: Mapping[str, str] = {}
177
178 def __getitem__(self, path: str) -> Set[Metric]:
179 """Returns a list of metrics by specified path"""
180 return self._values[path]
181
182 def __len__(self) -> int:
183 """Returns count of metrics paths"""
184 return len(self._values)
185
186 def __iter__(self):
187 """Returns a (path, list) iterable over the metrics"""
188 return self._values.__iter__()
189
190 def __contains__(self, path: str) -> bool:
191 """
192 Returns True if metric with specified path is in the snapshot, False otherwise.
193 """
194 return path in self._values
195
196 def __eq__(self, other: object) -> bool:
197 """
198 Compares the snapshot with a dict of metrics or with another
199 snapshot. A path mapped to an empty set of metrics is treated the
200 same as an absent path.
201 """
202 if isinstance(other, MetricsSnapshot):
203 other_values: Mapping[str, Set[Metric]] = other._values
204 elif isinstance(other, Mapping):
205 other_values = other
206 else:
207 return NotImplemented
208 return _drop_empty_paths(self._values) == _drop_empty_paths(other_values)
209
210 def __repr__(self) -> str:
211 return self._values.__repr__()
212
213 def __str__(self) -> str:
214 return self.pretty_print()
215
216 def get(self, path: str, default=None):
217 """
218 Returns an list of metrics by path or default if there's no such path
219 """
220 return self._values.get(path, default)
221
222 def items(self):
223 """Returns a (path, list) iterable over the metrics"""
224 return self._values.items()
225
226 def keys(self):
227 """Returns an iterable over paths of metrics"""
228 return self._values.keys()
229
230 def values(self):
231 """Returns an iterable over lists of metrics"""
232 return self._values.values()
233
235 self,
236 prefix: str | None,
237 labels: dict[str, str] | None = None,
238 /,
239 ) -> MetricsSnapshot:
240 """
241 Returns a new MetricsSnapshot restricted to the metrics whose path starts with `prefix` as a whole
242 '.'-separated segment, and, if `labels` is given, whose labels are a superset of `labels` (same
243 subset-match semantics as `require_labels` in `metrics_at`).
244
245 `prefix` may be:
246
247 * A non-empty string: matches paths equal to `prefix`, or starting with `prefix` followed by a whole
248 '.'-separated segment boundary (`prefix='a.b'` matches paths `'a.b'` and `'a.b.c'`, but not `'a.bc'`).
249 The matched `prefix` (and the following '.', if any) is stripped from the start of every surviving
250 metric's path, so e.g. slicing `'a.b.c'` by `prefix='a.b'` makes it accessible as `'c'`, and slicing
251 `'a.b'` by `prefix='a.b'` makes it accessible as `''`.
252 * `''`: matches paths equal to `''`, or starting with a literal leading `'.'`; the leading `'.'` is
253 stripped. Only relevant for the rare case of a metric path that itself starts with a dot.
254 * `None`: matches every path unconditionally. Use this to filter by `labels` only.
255
256 Calling `sliced()` several times in a row composes: each `prefix` is matched against the
257 already-stripped paths of the previous `sliced()` call, and `labels` requirements accumulate.
258
259 Slicing only ever affects *filtering* (which metrics are visible, and under which path): it never
260 touches the `Metric` objects themselves. `metrics_at()`, `value_at()` and iteration over a sliced
261 snapshot all keep returning the exact same, untouched `Metric` objects (same labels, value, identity),
262 just possibly under a shorter path and/or a smaller surrounding set.
263
264 Intended use: carve out a small, closed slice of a snapshot (e.g. one metric path with a handful of
265 varying labels) to compare it with `==` against a compact expected snapshot, or to look up several
266 label combinations with `value_at`/`metrics_at` without repeating the common prefix and labels in every call.
267
268 @throws AssertionError if stripping `prefix` from a metric path would leave an empty remainder,
269 e.g. path 'a.b.' sliced by prefix='a.b', which would be indistinguishable from path 'a.b'.
270
271 @snippet testsuite/tests/metrics/test_sliced.py sliced snippet
272 """
273 result: dict[str, set[Metric]] = {}
274 for path, metric_set in self._values.items():
275 if prefix is None:
276 remainder = path
277 else:
278 remainder = _strip_prefix_segment(path, prefix)
279 if remainder is None:
280 continue
281
282 if labels:
283 metric_set = {metric for metric in metric_set if labels.items() <= metric.labels.items()}
284 if not metric_set:
285 continue
286
287 result.setdefault(remainder, set())
288 result[remainder] |= metric_set
289
290 sliced_snapshot = MetricsSnapshot(result)
291 if prefix is None:
292 sliced_snapshot._sliced_prefix = self._sliced_prefix
293 elif self._sliced_prefix is None:
294 sliced_snapshot._sliced_prefix = prefix
295 else:
296 sliced_snapshot._sliced_prefix = f'{self._sliced_prefix}.{prefix}'
297 sliced_snapshot._sliced_labels = {**self._sliced_labels, **labels} if labels else self._sliced_labels
298 return sliced_snapshot
299
300 def unsliced(self) -> MetricsSnapshot:
301 """
302 Returns a new MetricsSnapshot with the `prefix` accumulated from the preceding (possibly chained)
303 `sliced()` call(s) prepended back to every surviving metric's path. Does not mutate `self`.
304
305 Metrics that were filtered out by `sliced()` (because their path did not match `prefix`, or their
306 labels did not match `labels`) do NOT come back: `unsliced()` only restores the *path* of what
307 remains in the snapshot, it does not undo the filtering itself.
308
309 If this snapshot was never `sliced()` (i.e. `self` is the original snapshot, or the result of
310 operations other than `sliced()`), returns an equivalent snapshot unchanged.
311 """
312 if self._sliced_prefix is None:
313 return MetricsSnapshot(self._values)
314 prefix = self._sliced_prefix
315 result = {(f'{prefix}.{path}' if path else prefix): metric_set for path, metric_set in self._values.items()}
316 return MetricsSnapshot(result)
317
318 @overload
319 def value_at(
320 self,
321 path: str,
322 labels: dict[str, str] | None = None,
323 ) -> MetricValue: ...
324
325 @overload
326 def value_at(
327 self,
328 path: str,
329 labels: dict[str, str] | None,
330 *,
331 default: T,
332 ) -> MetricValue | T: ...
333
334 def value_at(
335 self,
336 path: str,
337 labels: dict[str, str] | None = None,
338 *,
339 default: Any = _MISSING,
340 ) -> MetricValue | Any:
341 """
342 Returns a single metric value at specified path. If a dict of labels
343 is provided, does en exact match of labels (i.e. {} stands for no
344 labels; {'a': 'b', 'c': 'd'} matches only {'a': 'b', 'c': 'd'} or
345 {'c': 'd', 'a': 'b'} but neither match {'a': 'b'} nor {'a': 'b', 'c': 'd', 'e': 'f'}).
346
347 If `default` is provided, it is returned instead of asserting when
348 the metric is not found.
349
350 @throws AssertionError if not one metric by path and no `default` is given
351
352 @snippet samples/testsuite-support/tests/test_metrics.py metrics metrics
353 """
354 entry = self.get(path, set())
355 assert entry or default is not _MISSING, f'No metrics found by path "{path}"' + (
356 f' after slicing "{self._sliced_prefix}"' if self._sliced_prefix is not None else ''
357 )
358
359 if labels is not None:
360 full_labels = {**self._sliced_labels, **labels} if self._sliced_labels else labels
361 filtered_entries = {x for x in entry if x.labels == full_labels}
362 assert filtered_entries or default is not _MISSING, (
363 f'No metrics found by path "{path}" and labels {full_labels}. Possible values: {entry}'
364 )
365 assert len(filtered_entries) <= 1, (
366 f'Multiple metrics found by path "{path}" and labels {full_labels}: {filtered_entries}'
367 )
368 entry = filtered_entries
369 else:
370 assert len(entry) <= 1, f'Multiple metrics found by path "{path}": {entry}'
371
372 if default is not _MISSING and not entry:
373 return default
374 return next(iter(entry)).value
375
377 self,
378 path: str,
379 require_labels: dict[str, str] | None = None,
380 ) -> list[Metric]:
381 """
382 Metrics path must exactly equal the given `path`.
383 A required subset of labels is specified by `require_labels`
384 Example:
385 require_labels={'a':'b', 'c':'d'}
386 { 'a':'b', 'c':'d'} - exact match
387 { 'a':'b', 'c':'d', 'e': 'f', 'h':'k'} - match
388 { 'a':'x', 'c':'d'} - no match, incorrect value for label 'a'
389 { 'a' : 'b'} - required label not found
390
391 @snippet samples/testsuite-support/tests/test_metrics.py metrics metrics
392 """
393 entry = self.get(path, set())
394 full_require_labels = (
395 {**self._sliced_labels, **require_labels} if require_labels is not None else self._sliced_labels or None
396 )
397
398 if full_require_labels is not None:
399 return [metric for metric in entry if full_require_labels.items() <= metric.labels.items()]
400 else:
401 return list(entry)
402
403 def has_metrics_at(
404 self,
405 path: str,
406 require_labels: dict[str, str] | None = None,
407 ) -> bool:
408 # metrics_with_labels returns list, and pythonic way to check if list
409 # is empty is like this:
410 return bool(self.metrics_at(path, require_labels))
411
413 self,
414 other: Mapping[str, Set[Metric]],
415 *,
416 ignore_zeros: bool = False,
417 ) -> None:
418 """
419 @deprecated Use `==` operator instead, which produces a nice diff
420 automatically via `pytest_assertrepr_compare`. To ignore zero-rate
421 metrics, use `without_zero_rates()` on the snapshots before comparing.
422 """
423 lhs = _flatten_snapshot(self, ignore_zeros=ignore_zeros)
424 rhs = _flatten_snapshot(other, ignore_zeros=ignore_zeros)
425 assert lhs == rhs, _diff_metric_snapshots(lhs, rhs, ignore_zeros)
426
427 def without_zero_rates(self) -> MetricsSnapshot:
428 """
429 Returns a new snapshot with "empty" RATE and HIST_RATE metrics
430 removed: a RATE metric is removed if its value is zero, a HIST_RATE
431 metric is removed if its histogram has zero count in every bucket
432 and in `inf`. GAUGE (and untyped) metrics are kept as-is, because a
433 zero GAUGE value can be meaningful.
434 """
435 return MetricsSnapshot(
436 _drop_empty_paths({
437 path: {metric for metric in metric_set if not _is_zero_rate_or_histogram(metric)}
438 for path, metric_set in self._values.items()
439 }),
440 )
441
442 def pretty_print(self) -> str:
443 """
444 Multiline linear print:
445 path: (label=value),(label=value) TYPE VALUE
446 path: (label=value),(label=value) TYPE VALUE
447 Usage:
448 @code
449 assert 'some.thing.sensor' in metric, metric.pretty_print()
450 @endcode
451 """
452
453 def _iterate_over_mset(path, mset):
454 """print (pretty) one metrics set - for given path"""
455 result = []
456 for metric in sorted(mset, key=lambda x: _get_labels_tuple(x)):
457 result.append(_format_metric_line(path, metric))
458 return result
459
460 # list of lists [ [ string1, string2, string3],
461 # [string4, string5, string6] ]
462 data_for_every_path = [_iterate_over_mset(path, mset) for path, mset in self._values.items()]
463 # use itertools.chain to flatten list
464 # [ string1, string2, string3, string4, string5, string6 ]
465 # and join to convert it to one multiline string
466 return '\n'.join(itertools.chain(*data_for_every_path))
467
468 @staticmethod
469 def from_dict(data: Mapping[str, Any]) -> MetricsSnapshot:
470 """
471 Construct MetricsSnapshot from a JSON dict in the `json` userver metrics format.
472 """
473 json_data = {
474 str(path): {
475 Metric(
476 labels=element['labels'],
477 value=_parse_metric_value(element['value']),
478 _type=MetricType[element.get('type', 'UNSPECIFIED')],
479 )
480 for element in metrics_list
481 }
482 for path, metrics_list in data.items()
483 }
484 return MetricsSnapshot(json_data)
485
486 @staticmethod
487 def from_json(json_str: str) -> MetricsSnapshot:
488 """
489 Construct MetricsSnapshot from a JSON string in the `json` userver metrics format.
490 """
491 return MetricsSnapshot.from_dict(json.loads(json_str))
492
493 @staticmethod
495 data: Mapping[str, Any],
496 *,
497 common_prefix: str = '',
498 common_labels: Mapping[str, str] | None = None,
499 ) -> MetricsSnapshot:
500 """
501 Construct MetricsSnapshot from a layered dict format that avoids
502 repeating a label's name for every metric that only differs by
503 that label's value.
504
505 Top-level keys of `data` are metric paths, used as-is. Within a
506 path's value, each dict key names a label as `'name = value'`
507 (with exactly one space on each side of `=`: everything before is
508 the label name, everything after is its value); the corresponding
509 child value is interpreted the same way recursively, so several
510 labels can be layered one inside another. A dict with `bounds` and
511 `buckets` keys is a leaf value instead of being recursed into,
512 parsed as a `Histogram`; any other non-dict value is a plain leaf
513 metric value.
514
515 If `common_prefix` is provided, it is prepended to each path
516 (separated by a dot) so that paths in `data` can omit a shared prefix.
517
518 If `common_labels` is provided, these labels are added to every
519 metric in the snapshot, merged with any labels from the layered dict
520 structure.
521
522 Example: `{'a': {'x = foo': 1, 'x = bar': 2}}` is equivalent to
523 `MetricsSnapshot({'a': {Metric({'x': 'foo'}, 1), Metric({'x': 'bar'}, 2)}})`.
524 """
525 return MetricsSnapshot(
526 {path: _collect_layered_metrics(node, {}) for path, node in data.items()},
527 common_prefix=common_prefix,
528 common_labels=common_labels,
529 )
530
531 def to_json(self) -> str:
532 """
533 Serialize to a JSON string
534 """
535 return json.dumps(
536 # Shuffle to disallow depending on the received metrics order.
537 {path: random.sample(list(metrics), len(metrics)) for path, metrics in self._values.items()},
538 cls=_MetricsJSONEncoder,
539 )
540
541
542def _apply_common_prefix_labels(
543 values: Mapping[str, Set[Metric]],
544 common_prefix: str,
545 common_labels: Mapping[str, str] | None,
546) -> Mapping[str, Set[Metric]]:
547 if common_labels:
548 prefix = f'{common_prefix}.' if common_prefix else ''
549 return {
550 f'{prefix}{path}': {
551 dataclasses.replace(metric, labels={**common_labels, **metric.labels}) for metric in metric_set
552 }
553 for path, metric_set in values.items()
554 }
555 if common_prefix:
556 return {f'{common_prefix}.{path}': metric_set for path, metric_set in values.items()}
557 return values
558
559
560def _drop_empty_paths(values: Mapping[str, Set[Metric]]) -> dict[str, Set[Metric]]:
561 return {path: metric_set for path, metric_set in values.items() if metric_set}
562
563
564def _strip_prefix_segment(path: str, prefix: str) -> str | None:
565 assert not prefix.endswith('.'), f'prefix "{prefix}" must not end with "."'
566 if path == prefix:
567 return ''
568 dotted_prefix = f'{prefix}.'
569 if path.startswith(dotted_prefix):
570 remainder = path.removeprefix(dotted_prefix)
571 # An empty remainder here would be indistinguishable from the `path == prefix` case above
572 # (e.g. path 'a.' sliced by prefix 'a' would otherwise collide with path 'a'). A remainder
573 # starting with '.' is fine, though: it just means `path` had an empty '.'-segment right
574 # after `prefix` (e.g. path 'a..b' sliced by prefix 'a' yields '.b', still distinguishable
575 # from path 'a.b' sliced by prefix 'a', which yields 'b').
576 assert remainder, (
577 f'Metric path "{path}" becomes empty right after prefix "{prefix}", '
578 f'which would make prefix matching ambiguous. This looks like a malformed metric path.'
579 )
580 return remainder
581 return None
582
583
584def _is_zero_rate_or_histogram(metric: Metric) -> bool:
585 if isinstance(metric.value, Histogram):
586 return metric.value.count() == 0
587 return metric.type() == MetricType.RATE and metric.value == 0
588
589
590def _type_eq(lhs: MetricType, rhs: MetricType) -> bool:
591 return lhs == rhs or lhs == MetricType.UNSPECIFIED or rhs == MetricType.UNSPECIFIED # noqa: PLR1714
592
593
594def _get_labels_tuple(metric: Metric) -> tuple[tuple[str, str], ...]:
595 """Returns labels as a tuple of sorted items"""
596 return tuple(sorted(metric.labels.items()))
597
598
599def _format_metric_line(path: str, metric: Metric, *, forced_type: MetricType | None = None) -> str:
600 """Formats a single metric line as "path: (labels) TYPE VALUE", skipping the labels part (and the extra
601 space) entirely if there are no labels. `forced_type` overrides `metric`'s own type, if given"""
602 labels_str = ','.join(f'({key}={label_value})' for key, label_value in _get_labels_tuple(metric))
603 metric_type = forced_type if forced_type is not None else metric.type()
604 parts = [f'{path}:', labels_str, metric_type.value, str(metric.value)]
605 return ' '.join(part for part in parts if part)
606
607
609 values: Mapping[str, Set[Metric]],
610 /,
611 *,
612 other: Mapping[str, Set[Metric]],
613) -> set[str]:
614 """
615 Renders a snapshot as a set of "path: (labels) TYPE VALUE" strings
616 (the same format as `MetricsSnapshot.pretty_print`), suitable for use
617 with a generic set-based diff (e.g. testsuite's `CompareVisitor`
618 machinery).
619
620 A metric with an UNSPECIFIED type has its type resolved from the
621 matching (by path and labels) metric in `other`, if any, mirroring the
622 wildcard-matching behavior of `Metric.__eq__`.
623 """
624 other_snapshot = other if isinstance(other, MetricsSnapshot) else MetricsSnapshot(other)
625 return {
626 _format_metric_line(path, metric, forced_type=_resolve_type(path, metric, other_snapshot))
627 for path, metric_set in values.items()
628 for metric in metric_set
629 }
630
631
632def _resolve_type(path: str, metric: Metric, other_snapshot: MetricsSnapshot) -> MetricType:
633 if metric.type() != MetricType.UNSPECIFIED:
634 return metric.type()
635 for other_metric in other_snapshot.metrics_at(path, require_labels=metric.labels):
636 if other_metric.labels == metric.labels and other_metric.type() != MetricType.UNSPECIFIED:
637 return other_metric.type()
638 return metric.type()
639
640
641def _do_compute_percentile(hist: Histogram, percent: float) -> float:
642 # This implementation is O(hist.count()), which is less than perfect.
643 # So far, this was not a big enough pain to rewrite it.
644 value_lists = [[bound] * bucket for (bucket, bound) in zip(hist.buckets, hist.bounds, strict=True)] + [
645 [math.inf] * hist.inf
646 ]
647 values = [item for sublist in value_lists for item in sublist]
648
649 # Implementation taken from:
650 # https://stackoverflow.com/a/2753343/5173839
651 if not values:
652 return 0
653 pivot = (len(values) - 1) * percent
654 floor = math.floor(pivot)
655 ceil = math.ceil(pivot)
656 if floor == ceil:
657 return values[int(pivot)]
658 part1 = values[int(floor)] * (ceil - pivot)
659 part2 = values[int(ceil)] * (pivot - floor)
660 return part1 + part2
661
662
663def _is_histogram_dict(node: Any) -> bool:
664 return isinstance(node, dict) and 'bounds' in node and 'buckets' in node
665
666
667_LABEL_SEPARATOR = ' = '
668
669
670def _collect_layered_metrics(node: Any, labels: dict[str, str]) -> set[Metric]:
671 if isinstance(node, dict) and not _is_histogram_dict(node):
672 result = set()
673 for key, child in node.items():
674 assert _LABEL_SEPARATOR in key, f"Expected a label key like 'name = value', got '{key}'"
675 label_name, label_value = key.split(_LABEL_SEPARATOR, 1)
676 assert not label_name.endswith(' ') and not label_value.startswith(' '), (
677 f"Expected exactly one space on each side of '=' in a label key, got '{key}'"
678 )
679 result |= _collect_layered_metrics(child, {**labels, label_name: label_value})
680 return result
681 return {Metric(dict(labels), _parse_metric_value(node))}
682
683
684def _parse_metric_value(value: Any) -> MetricValue:
685 if isinstance(value, dict):
686 return Histogram(
687 bounds=value['bounds'],
688 buckets=value['buckets'],
689 inf=value['inf'],
690 )
691 elif isinstance(value, float):
692 return value
693 elif isinstance(value, int):
694 return value
695 else:
696 raise Exception(f'Failed to parse metric value from {value!r}')
697
698
699_FlattenedSnapshot: TypeAlias = Set[tuple[str, Metric]]
700
701
702def _flatten_snapshot(values, ignore_zeros: bool) -> _FlattenedSnapshot:
703 return {
704 (path, metric)
705 for path, metrics in values.items()
706 for metric in metrics
707 if metric.value != 0 or not ignore_zeros
708 }
709
710
711def _diff_metric_snapshots(
712 lhs: _FlattenedSnapshot,
713 rhs: _FlattenedSnapshot,
714 ignore_zeros: bool,
715) -> str:
716 def extra_metrics_message(extra, base):
717 return [
718 f' path={path!r} labels={metric.labels!r} value={metric.value}'
719 for path, metric in sorted(extra, key=lambda pair: pair[0])
720 if (path, metric) not in base
721 ]
722
723 if ignore_zeros:
724 lines = ['left.assert_equals(right, ignore_zeros=True) failed']
725 else:
726 lines = ['left.assert_equals(right) failed']
727 actual_extra = extra_metrics_message(lhs, rhs)
728 if actual_extra:
729 lines.append(' extra in left:')
730 lines += actual_extra
731
732 actual_gt = extra_metrics_message(rhs, lhs)
733 if actual_gt:
734 lines.append(' missing in left:')
735 lines += actual_gt
736
737 return '\n'.join(lines)