Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions prometheus_client/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,41 @@ def remove(self, *labelvalues: Any) -> None:
if labelvalues in self._metrics:
del self._metrics[labelvalues]

def remove_matching(self, partial: dict[str, str]) -> int:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would you think of remove_partial_match for the name of this function? When I first see matching I think that all labelnames/values need to match to be removed not a partial. The name of the variable helps, but could also then be labels instead of partial which helps with confusion around thinking it could be a partial function.

Copy link
Author

@hazel-shen hazel-shen Aug 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion! I’ve renamed the method to remove_by_labels and the argument to labels.
I also added a short docstring to clarify that this removes series where the provided (key, value) pairs are a partial match of the labelset.
This should reduce confusion with full-match semantics.

if 'prometheus_multiproc_dir' in os.environ or 'PROMETHEUS_MULTIPROC_DIR' in os.environ:
warnings.warn(
"Removal of labels has not been implemented in multi-process mode yet.",
UserWarning
)

if not self._labelnames:
raise ValueError('No label names were set when constructing %s' % self)

if not isinstance(partial, dict):
raise TypeError("partial must be a dict of {label_name: label_value}")

if not partial:
return 0

invalid = [k for k in partial.keys() if k not in self._labelnames]
if invalid:
raise ValueError(
'Unknown label names: %s; expected %s' % (invalid, self._labelnames)
)

pos_filter = {self._labelnames.index(k): str(v) for k, v in partial.items()}

deleted = 0
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the reasoning for returning the number of deleted items? We don't do that in remove today, but if there is a good use case I am not opposed.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point on consistency. The initial version returned the number of deleted series, but I’ve updated it to return None, matching the behavior of remove(): idempotent and a no-op if nothing matches.
If there’s a strong use case for counts in the future, I’m happy to follow up with a separate helper that reports the number of removed series, without changing the main API.

with self._lock:
for lv in list(self._metrics.keys()):
if all(lv[pos] == want for pos, want in pos_filter.items()):
if lv in self._metrics:
del self._metrics[lv]
deleted += 1

return deleted


def clear(self) -> None:
"""Remove all labelsets from the metric"""
if 'prometheus_multiproc_dir' in os.environ or 'PROMETHEUS_MULTIPROC_DIR' in os.environ:
Expand Down
30 changes: 30 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,36 @@ def test_labels_coerced_to_string(self):
self.counter.remove(None)
self.assertEqual(None, self.registry.get_sample_value('c_total', {'l': 'None'}))

def test_remove_matching(self):
from prometheus_client import Counter

c = Counter('c2', 'help', ['tenant', 'endpoint'], registry=self.registry)
c.labels('acme', '/').inc()
c.labels('acme', '/checkout').inc()
c.labels('globex', '/').inc()


deleted = c.remove_matching({'tenant': 'acme'})
self.assertEqual(2, deleted)

self.assertIsNone(self.registry.get_sample_value('c2_total', {'tenant': 'acme', 'endpoint': '/'}))
self.assertIsNone(self.registry.get_sample_value('c2_total', {'tenant': 'acme', 'endpoint': '/checkout'}))
self.assertEqual(1, self.registry.get_sample_value('c2_total', {'tenant': 'globex', 'endpoint': '/'}))

def test_remove_matching_invalid_label_name(self):
from prometheus_client import Counter
c = Counter('c3', 'help', ['tenant', 'endpoint'], registry=self.registry)
c.labels('acme', '/').inc()
with self.assertRaises(ValueError):
c.remove_matching({'badkey': 'x'})

def test_remove_matching_empty_is_noop(self):
from prometheus_client import Counter
c = Counter('c4', 'help', ['tenant', 'endpoint'], registry=self.registry)
c.labels('acme', '/').inc()
self.assertEqual(0, c.remove_matching({}))
self.assertEqual(1, self.registry.get_sample_value('c4_total', {'tenant': 'acme', 'endpoint': '/'}))

def test_non_string_labels_raises(self):
class Test:
__str__ = None
Expand Down