From 4e02693cc7c093d66f5defbdd33be9b9816bcfe0 Mon Sep 17 00:00:00 2001 From: Sonica-B Date: Sun, 8 Nov 2026 17:16:07 -0800 Subject: [PATCH] Reject non-string Info keys and values in info() Info.info() accepted any key or value except None. A non-string such as {'build_number': 42} was stored, and every later scrape of the registry then failed with AttributeError ("'int' object has no attribute 'replace'"), so one bad call hid all metrics on the endpoint. Check the types where the value is set, as was done for None in #1012, so the caller gets a ValueError and the registry stays scrapeable. The docs already say both keys and values must be strings. Refs #1046 Signed-off-by: Sonica-B --- prometheus_client/metrics.py | 3 +++ tests/test_core.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/prometheus_client/metrics.py b/prometheus_client/metrics.py index 79c3438c..46cddd55 100644 --- a/prometheus_client/metrics.py +++ b/prometheus_client/metrics.py @@ -749,6 +749,9 @@ def info(self, val: Dict[str, str]) -> None: self._labelnames, val)) if any(i is None for i in val.values()): raise ValueError('Label value cannot be None') + for k, v in val.items(): + if not isinstance(k, str) or not isinstance(v, str): + raise ValueError(f'Info keys and values must be strings, got {k!r}: {v!r}') with self._lock: self._value = dict(val) diff --git a/tests/test_core.py b/tests/test_core.py index 2ed2da90..3e1b12d0 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -17,6 +17,9 @@ from prometheus_client.decorator import getargspec from prometheus_client.exposition import generate_latest from prometheus_client.metrics import _get_use_created +from prometheus_client.openmetrics.exposition import ( + generate_latest as openmetrics_generate_latest, +) from prometheus_client.validation import ( disable_legacy_validation, enable_legacy_validation, ) @@ -587,6 +590,17 @@ def test_labels(self): self.labels.labels('a').info({'foo': 'bar'}) self.assertEqual(1, self.registry.get_sample_value('il_info', {'l': 'a', 'foo': 'bar'})) + def test_non_string_keys_and_values(self): + self.info.info({'version': '1.2.3'}) + for val in ({'version': 1}, {'temperature': 49.0}, {1: 'x'}, {'tags': ['a', 'b']}): + with self.subTest(val=val): + self.assertRaises(ValueError, self.info.info, val) + self.assertRaises(ValueError, self.labels.labels('a').info, val) + # The rejected calls keep the previous value and the registry stays scrapeable. + self.assertEqual(1, self.registry.get_sample_value('i_info', {'version': '1.2.3'})) + self.assertIn(b'i_info{version="1.2.3"} 1.0', generate_latest(self.registry)) + self.assertIn(b'i_info{version="1.2.3"} 1.0', openmetrics_generate_latest(self.registry)) + class TestEnum(unittest.TestCase): def setUp(self):