Repository: prometheus/client_python
Pull request #1083 (2025-01-08 16:14:21)

Changed pushgateway.md
I tried to make the documentation on the Pushgateway slightly more readable, and added a small note about what the Pushgateway is supposed to do. I'm still quite new to Prometheus, so I hope this will be helpful.


Patch:
diff --git a/MANIFEST.in b/MANIFEST.in
deleted file mode 100644
index cad821d..0000000
--- a/MANIFEST.in
+++ /dev/null
@@ -1,4 +0,0 @@
-graft tests
-global-exclude *.py[cod]
-prune __pycache__
-prune */__pycache__
diff --git a/docs/content/exporting/pushgateway.md b/docs/content/exporting/pushgateway.md
index f1ea5e4..bf5eb11 100644
--- a/docs/content/exporting/pushgateway.md
+++ b/docs/content/exporting/pushgateway.md
@@ -5,6 +5,9 @@ weight: 3
 
 The [Pushgateway](https://github.com/prometheus/pushgateway)
 allows ephemeral and batch jobs to expose their metrics to Prometheus.
+Since Prometheus may not be able to scrape such a target, the targets can
+push their metrics to a separate instance of the Pushgateway,
+which then exposes these metrics to Prometheus.
 
 ```python
 from prometheus_client import CollectorRegistry, Gauge, push_to_gateway
@@ -18,16 +21,20 @@ push_to_gateway('localhost:9091', job='batchA', registry=registry)
 A separate registry is used, as the default registry may contain other metrics
 such as those from the Process Collector.
 
-Pushgateway functions take a grouping key. `push_to_gateway` replaces metrics
-with the same grouping key, `pushadd_to_gateway` only replaces metrics with the
-same name and grouping key and `delete_from_gateway` deletes metrics with the
-given job and grouping key. See the
+Pushgateway functions take a grouping key.
+1. `push_to_gateway` replaces metrics
+with the same grouping key.
+2. `pushadd_to_gateway` only replaces metrics with the
+same name and grouping key.
+3. `delete_from_gateway` deletes metrics with the
+given job and grouping key.
+4. `instance_ip_grouping_key` returns a grouping key with the instance label set
+to the host's IP address.
+
+See the
 [Pushgateway documentation](https://github.com/prometheus/pushgateway/blob/master/README.md)
 for more information.
 
-`instance_ip_grouping_key` returns a grouping key with the instance label set
-to the host's IP address.
-
 # Handlers for authentication
 
 If the push gateway you are connecting to is protected with HTTP Basic Auth,
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
 
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..05b1755
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,49 @@
+[build-system]
+requires = ["setuptools"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "prometheus_client"
+version = "0.21.1"
+description = "Python client for the Prometheus monitoring system."
+readme = "README.md"
+license = { file = "LICENSE" }
+requires-python = ">=3.9"
+authors = [
+    { name = "The Prometheus Authors", email = "prometheus-developers@googlegroups.com" },
+]
+keywords = [
+    "prometheus",
+    "monitoring",
+    "instrumentation",
+    "client",
+]
+classifiers = [
+    "Development Status :: 4 - Beta",
+    "Intended Audience :: Developers",
+    "Intended Audience :: Information Technology",
+    "Intended Audience :: System Administrators",
+    "Programming Language :: Python",
+    "Programming Language :: Python :: 3",
+    "Programming Language :: Python :: 3.9",
+    "Programming Language :: Python :: 3.10",
+    "Programming Language :: Python :: 3.11",
+    "Programming Language :: Python :: 3.12",
+    "Programming Language :: Python :: 3.13",
+    "Programming Language :: Python :: Implementation :: CPython",
+    "Programming Language :: Python :: Implementation :: PyPy",
+    "Topic :: System :: Monitoring",
+    "License :: OSI Approved :: Apache Software License",
+]
+
+[project.optional-dependencies]
+twisted = [
+    "twisted",
+]
+
+[project.urls]
+Homepage = "https://github.com/prometheus/client_python"
+Documentation = "https://prometheus.github.io/client_python/"
+
+[tool.setuptools.package-data]
+prometheus_client = ['py.typed']
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 0d5d887..0000000
--- a/setup.py
+++ /dev/null
@@ -1,51 +0,0 @@
-from os import path
-
-from setuptools import setup
-
-with open(path.join(path.abspath(path.dirname(__file__)), 'README.md')) as f:
-    long_description = f.read()
-
-
-setup(
-    name="prometheus_client",
-    version="0.21.0",
-    author="Brian Brazil",
-    author_email="brian.brazil@robustperception.io",
-    description="Python client for the Prometheus monitoring system.",
-    long_description=long_description,
-    long_description_content_type='text/markdown',
-    license="Apache Software License 2.0",
-    keywords="prometheus monitoring instrumentation client",
-    url="https://github.com/prometheus/client_python",
-    packages=[
-        'prometheus_client',
-        'prometheus_client.bridge',
-        'prometheus_client.openmetrics',
-        'prometheus_client.twisted',
-    ],
-    package_data={
-        'prometheus_client': ['py.typed']
-    },
-    extras_require={
-        'twisted': ['twisted'],
-    },
-    test_suite="tests",
-    python_requires=">=3.9",
-    classifiers=[
-        "Development Status :: 4 - Beta",
-        "Intended Audience :: Developers",
-        "Intended Audience :: Information Technology",
-        "Intended Audience :: System Administrators",
-        "Programming Language :: Python",
-        "Programming Language :: Python :: 3",
-        "Programming Language :: Python :: 3.9",
-        "Programming Language :: Python :: 3.10",
-        "Programming Language :: Python :: 3.11",
-        "Programming Language :: Python :: 3.12",
-        "Programming Language :: Python :: 3.13",
-        "Programming Language :: Python :: Implementation :: CPython",
-        "Programming Language :: Python :: Implementation :: PyPy",
-        "Topic :: System :: Monitoring",
-        "License :: OSI Approved :: Apache Software License",
-    ],
-)
diff --git a/tox.ini b/tox.ini
index deb74e1..157a8bb 100644
--- a/tox.ini
+++ b/tox.ini
@@ -34,14 +34,14 @@ deps =
     flake8-import-order==0.18.2
 skip_install = true
 commands =
-    flake8 prometheus_client/ tests/ setup.py
+    flake8 prometheus_client/ tests/
 
 [testenv:isort]
 deps =
     isort==5.10.1
 skip_install = true
 commands =
-    isort --check prometheus_client/ tests/ setup.py
+    isort --check prometheus_client/ tests/
 
 [testenv:mypy]
 deps =


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