Repository: prometheus/client_python
Pull request #1082 (2025-01-08 10:22:58)

Correct nh sample span structure and parsing
While working on the exposition part of the issue https://github.com/prometheus/OpenMetrics/issues/279, I realized I made a mistake in the code relative to the NH spans and deltas parsing, in that I was not taking into account that span lists have no fixed length, span lists can be absent and that deltas can consequently also not be there. I thought I’d get this right before continuing on the rest of the work.


Patch:
diff --git a/prometheus_client/openmetrics/parser.py b/prometheus_client/openmetrics/parser.py
index 1d27091..d967e83 100644
--- a/prometheus_client/openmetrics/parser.py
+++ b/prometheus_client/openmetrics/parser.py
@@ -307,12 +307,11 @@ def _parse_nh_sample(text, suffixes):
 
 def _parse_nh_struct(text):
     pattern = r'(\w+):\s*([^,}]+)'
-    
-    re_spans = re.compile(r'(positive_spans|negative_spans):\[(\d+:\d+,\d+:\d+)\]')
+    re_spans = re.compile(r'(positive_spans|negative_spans):\[(\d+:\d+(,\d+:\d+)*)\]')
     re_deltas = re.compile(r'(positive_deltas|negative_deltas):\[(-?\d+(?:,-?\d+)*)\]')
 
     items = dict(re.findall(pattern, text))
-    spans = dict(re_spans.findall(text))
+    span_matches = re_spans.findall(text)
     deltas = dict(re_deltas.findall(text))
 
     count_value = int(items['count'])
@@ -321,38 +320,11 @@ def _parse_nh_struct(text):
     zero_threshold = float(items['zero_threshold'])
     zero_count = int(items['zero_count'])
 
-    try:
-        pos_spans_text = spans['positive_spans']
-        elems = pos_spans_text.split(',')
-        arg1 = [int(x) for x in elems[0].split(':')]
-        arg2 = [int(x) for x in elems[1].split(':')]
-        pos_spans = (BucketSpan(arg1[0], arg1[1]), BucketSpan(arg2[0], arg2[1]))
-    except KeyError:
-        pos_spans = None
-       
-    try:
-        neg_spans_text = spans['negative_spans']
-        elems = neg_spans_text.split(',')
-        arg1 = [int(x) for x in elems[0].split(':')]
-        arg2 = [int(x) for x in elems[1].split(':')]
-        neg_spans = (BucketSpan(arg1[0], arg1[1]), BucketSpan(arg2[0], arg2[1]))
-    except KeyError:
-        neg_spans = None
-       
-    try:
-        pos_deltas_text = deltas['positive_deltas']
-        elems = pos_deltas_text.split(',')
-        pos_deltas = tuple([int(x) for x in elems])
-    except KeyError:
-        pos_deltas = None
-       
-    try:
-        neg_deltas_text = deltas['negative_deltas']
-        elems = neg_deltas_text.split(',')
-        neg_deltas = tuple([int(x) for x in elems])
-    except KeyError:
-        neg_deltas = None
-       
+    pos_spans = _compose_spans(span_matches, 'positive_spans')
+    neg_spans = _compose_spans(span_matches, 'negative_spans')
+    pos_deltas = _compose_deltas(deltas, 'positive_deltas')
+    neg_deltas = _compose_deltas(deltas, 'negative_deltas')
+      
     return NativeHistogram(
         count_value=count_value,
         sum_value=sum_value,
@@ -364,6 +336,47 @@ def _parse_nh_struct(text):
         pos_deltas=pos_deltas,
         neg_deltas=neg_deltas
     )
+  
+
+def _compose_spans(span_matches, spans_name):
+    """Takes a list of span matches (expected to be a list of tuples) and a string 
+    (the expected span list name) and processes the list so that the values extracted 
+    from the span matches can be used to compose a tuple of BucketSpan objects"""
+    spans = {}
+    for match in span_matches:
+        # Extract the key from the match (first element of the tuple).
+        key = match[0]
+        # Extract the value from the match (second element of the tuple).
+        # Split the value string by commas to get individual pairs, 
+        # split each pair by ':' to get start and end, and convert them to integers.
+        value = [tuple(map(int, pair.split(':'))) for pair in match[1].split(',')]
+        # Store the processed value in the spans dictionary with the key.
+        spans[key] = value
+    if spans_name not in spans:
+        return None
+    out_spans = []
+    # Iterate over each start and end tuple in the list of tuples for the specified spans_name.
+    for start, end in spans[spans_name]:
+        # Compose a BucketSpan object with the start and end values 
+        # and append it to the out_spans list.
+        out_spans.append(BucketSpan(start, end))
+        # Convert to tuple
+    out_spans_tuple = tuple(out_spans)
+    return out_spans_tuple
+
+
+def _compose_deltas(deltas, deltas_name):
+    """Takes a list of deltas matches (a dictionary) and a string (the expected delta list name),
+    and processes its elements to compose a tuple of integers representing the deltas"""
+    if deltas_name not in deltas:
+        return None
+    out_deltas = deltas.get(deltas_name)
+    if out_deltas is not None and out_deltas.strip():
+        elems = out_deltas.split(',')
+    # Convert each element in the list elems to an integer 
+    # after stripping whitespace and create a tuple from these integers.
+    out_deltas_tuple = tuple(int(x.strip()) for x in elems)
+    return out_deltas_tuple
         
 
 def _group_for_sample(sample, name, typ):
diff --git a/prometheus_client/samples.py b/prometheus_client/samples.py
index b57a5d4..16e03c0 100644
--- a/prometheus_client/samples.py
+++ b/prometheus_client/samples.py
@@ -1,4 +1,4 @@
-from typing import Dict, NamedTuple, Optional, Sequence, Tuple, Union
+from typing import Dict, NamedTuple, Optional, Sequence, Union
 
 
 class Timestamp:
@@ -47,8 +47,8 @@ class NativeHistogram(NamedTuple):
     schema: int
     zero_threshold: float
     zero_count: float
-    pos_spans: Optional[Tuple[BucketSpan, BucketSpan]] = None
-    neg_spans: Optional[Tuple[BucketSpan, BucketSpan]] = None
+    pos_spans: Optional[Sequence[BucketSpan]] = None
+    neg_spans: Optional[Sequence[BucketSpan]] = None
     pos_deltas: Optional[Sequence[int]] = None
     neg_deltas: Optional[Sequence[int]] = None
 


Test patch:
diff --git a/tests/openmetrics/test_parser.py b/tests/openmetrics/test_parser.py
index 019929e..aeaa6ed 100644
--- a/tests/openmetrics/test_parser.py
+++ b/tests/openmetrics/test_parser.py
@@ -242,6 +242,18 @@ nativehistogram {count:24,sum:100,schema:0,zero_threshold:0.001,zero_count:4,pos
         hfm.add_sample("native{histogram", {'xx{} # {}': ' EOF # {}}}'}, None, None, None, NativeHistogram(24, 100, 0, 0.001, 4, (BucketSpan(0, 2), BucketSpan(1, 2)), (BucketSpan(0, 2), BucketSpan(1, 2)), (2, 1, -3, 3), (2, 1, -2, 3)))
         self.assertEqual([hfm], families)
 
+    def test_native_histogram_three_pos_spans_no_neg_spans_or_deltas(self):
+        families = text_string_to_metric_families("""# TYPE nhsp histogram
+# HELP nhsp Is a basic example of a native histogram with three spans
+nhsp {count:4,sum:6,schema:3,zero_threshold:2.938735877055719e-39,zero_count:1,positive_spans:[0:1,7:1,4:1],positive_deltas:[1,0,0]}
+# EOF
+""")
+        families = list(families)
+       
+        hfm = HistogramMetricFamily("nhsp", "Is a basic example of a native histogram with three spans")
+        hfm.add_sample("nhsp", None, None, None, None, NativeHistogram(4, 6, 3, 2.938735877055719e-39, 1, (BucketSpan(0, 1), BucketSpan(7, 1), BucketSpan(4, 1)), None, (1, 0, 0), None))
+        self.assertEqual([hfm], families)
+
     def test_native_histogram_with_labels(self):
         families = text_string_to_metric_families("""# TYPE hist_w_labels histogram
 # HELP hist_w_labels Is a basic example of a native histogram with labels