Skip to content

Commit 7bace48

Browse files
authored
add metricrangedf and tests (4n4nd#137)
* add feature to convert range vectors into pandas dataframe; add metricrangedf class and tests; closes 4n4nd#130 * Update __init__.py remove extra empty line * Update metric_range_df.py remove extra empty line
1 parent 23efa93 commit 7bace48

File tree

3 files changed

+175
-0
lines changed

3 files changed

+175
-0
lines changed

prometheus_api_client/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@
77
from .metric import Metric
88
from .metrics_list import MetricsList
99
from .metric_snapshot_df import MetricSnapshotDataFrame
10+
from .metric_range_df import MetricRangeDataFrame
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""A pandas.DataFrame subclass for Prometheus range vector responses."""
2+
from pandas import DataFrame
3+
from pandas._typing import Axes, Dtype
4+
from typing import Optional, Sequence
5+
6+
7+
class MetricRangeDataFrame(DataFrame):
8+
"""Subclass to format and represent Prometheus query response as pandas.DataFrame.
9+
10+
Assumes response is either a json or sequence of jsons.
11+
12+
This class should be used specifically to instantiate a query response,
13+
where the query response has several timestamp values per series.
14+
That is, a range vector is expected.
15+
If the data is an instant vector, use MetricSnapshotDataFrame instead.
16+
17+
Some argument descriptions in this docstring were copied from pandas.core.frame.DataFrame.
18+
19+
:param data: (list|json) A single metric (json with keys "metric" and "values"/"value")
20+
or list of such metrics received from Prometheus as a response to query
21+
:param index: (pandas.Index|array-like) Index to use for resulting dataframe. Will default to
22+
pandas.RangeIndex if no indexing information part of input data and no index provided.
23+
:param columns: (pandas.Index|array-like) Column labels to use for resulting dataframe. Will
24+
default to list of labels + "timestamp" + "value" if not provided.
25+
:param dtype: (dtype) default None. Data type to force. Only a single dtype is allowed. If None, infer.
26+
:param copy: (bool) default False. Copy data from inputs. Only affects DataFrame / 2d ndarray input.
27+
28+
Example Usage:
29+
.. code-block:: python
30+
31+
prom = PrometheusConnect()
32+
metric_data = prom.get_current_metric_value(metric_name='up', label_config=my_label_config)
33+
metric_df = MetricRangeDataFrame(metric_data)
34+
metric_df.head()
35+
'''
36+
+------------+------------+-----------------+--------------------+-------+
37+
| | __name__ | cluster | label_2 | value |
38+
+-------------------------+-----------------+--------------------+-------+
39+
| timestamp | | | | |
40+
+============+============+=================+====================+=======+
41+
| 1577836800 | __up__ | cluster_id_0 | label_2_value_2 | 0 |
42+
+-------------------------+-----------------+--------------------+-------+
43+
| 1577836801 | __up__ | cluster_id_1 | label_2_value_3 | 1 |
44+
+-------------------------+-----------------+------------=-------+-------+
45+
'''
46+
"""
47+
48+
def __init__(
49+
self,
50+
data=None,
51+
index: Optional[Axes] = None,
52+
columns: Optional[Axes] = None,
53+
dtype: Optional[Dtype] = None,
54+
copy: bool = False,
55+
):
56+
"""Constructor for MetricRangeDataFrame class."""
57+
if data is not None:
58+
# if just a single json instead of list/set/other sequence of jsons,
59+
# treat as list with single entry
60+
if not isinstance(data, Sequence):
61+
data = [data]
62+
63+
row_data = []
64+
for v in data:
65+
if "value" in v:
66+
raise TypeError(
67+
"data must be a range vector. Expected range vector, got instant vector"
68+
)
69+
for t in v["values"]:
70+
row_data.append({**v["metric"], "timestamp": t[0], "value": t[1]})
71+
72+
# init df normally now
73+
super(MetricRangeDataFrame, self).__init__(
74+
data=row_data, index=index, columns=columns, dtype=dtype, copy=copy
75+
)
76+
77+
self.set_index(["timestamp"], inplace=True)

tests/test_metric_range_df.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import unittest
2+
import json
3+
import os
4+
from prometheus_api_client import MetricRangeDataFrame
5+
6+
7+
class TestMetricRangeDataFrame(unittest.TestCase):
8+
def setUp(self):
9+
"""
10+
read metrics stored as jsons in './tests/metrics'
11+
"""
12+
self.raw_metrics_list = list()
13+
self.raw_metrics_labels = list()
14+
for (dir_path, _, file_names) in os.walk("./tests/metrics"):
15+
for fname in file_names:
16+
with open(os.path.join(dir_path, fname), "rb") as f:
17+
metric_jsons = json.load(f)
18+
19+
# save json list
20+
self.raw_metrics_list.extend([metric_jsons])
21+
22+
# save label configs
23+
labels = set()
24+
for i in metric_jsons:
25+
labels.update(set(i["metric"].keys()))
26+
self.raw_metrics_labels.append(labels)
27+
28+
def test_setup(self):
29+
"""
30+
Check if setup was done correctly
31+
"""
32+
self.assertEqual(
33+
8, len(self.raw_metrics_list), "incorrect number json files read (incorrect test setup)"
34+
)
35+
36+
def test_init_shape(self):
37+
"""
38+
Test if dataframe initialized is of correct shape
39+
"""
40+
# check shape
41+
# each metric json contains number of timestamps equal to number entries * number of timestamps in each series
42+
# we're assuming each series has the same number of timestamps
43+
# 3 labels
44+
for current_metric_list in self.raw_metrics_list:
45+
df = MetricRangeDataFrame(current_metric_list)
46+
num_values = sum([len(v["values"]) for v in current_metric_list])
47+
self.assertEqual(
48+
(len(df.index.values), df.shape[1]), # shape[1] = 4xlabels + value
49+
(num_values, 5),
50+
"incorrect dataframe shape",
51+
)
52+
53+
def test_init_timestamps(self):
54+
"""
55+
Test if dataframe contains the correct timestamp indices
56+
"""
57+
# check that the timestamp indices in each series are the same
58+
for curr_metric_list in self.raw_metrics_list:
59+
self.assertEqual(
60+
set(MetricRangeDataFrame(curr_metric_list).index.values),
61+
set([v[0] for s in curr_metric_list for v in s["values"] ])
62+
)
63+
64+
def test_init_columns(self):
65+
"""
66+
Test if dataframe initialized has correct columns
67+
"""
68+
for curr_metric_labels, curr_metric_list in zip(self.raw_metrics_labels, self.raw_metrics_list):
69+
self.assertEqual(
70+
curr_metric_labels.union({"value"}),
71+
set(MetricRangeDataFrame(curr_metric_list).columns),
72+
"incorrect dataframe columns",
73+
)
74+
75+
def test_init_single_metric(self):
76+
"""
77+
Test if dataframe initialized is of correct shape when
78+
1. json object is passed as data
79+
2. list with single json object is passed as data
80+
"""
81+
# check shape when single json passed
82+
num_vals = len(self.raw_metrics_list[0][0]["values"])
83+
self.assertEqual(
84+
(num_vals, 5),
85+
MetricRangeDataFrame(self.raw_metrics_list[0][0]).shape,
86+
"incorrect dataframe shape when initialized with json",
87+
)
88+
# check shape when list with single json passed
89+
self.assertEqual(
90+
(num_vals, 5),
91+
MetricRangeDataFrame([self.raw_metrics_list[0][0]]).shape,
92+
"incorrect dataframe shape when initialized with single json list",
93+
)
94+
95+
96+
if __name__ == "__main__":
97+
unittest.main()

0 commit comments

Comments
 (0)