Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
14 changes: 14 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,17 @@ jobs:
run: tox
env:
TOXENV: pypy${{ env.PYTHON_VERSION }}

test_windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: '3.12'
- name: Install dependencies
run: pip install tox "virtualenv<20.22.0"
- name: Run tests
run: python -m tox -e py3.12

4 changes: 2 additions & 2 deletions prometheus_client/openmetrics/exposition.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@
def _is_valid_exemplar_metric(metric, sample):
if metric.type == 'counter' and sample.name.endswith('_total'):
return True
if metric.type in ('gaugehistogram') and sample.name.endswith('_bucket'):
if metric.type == 'gaugehistogram' and sample.name.endswith('_bucket'):
return True
if metric.type in ('histogram') and sample.name.endswith('_bucket') or sample.name == metric.name:
if metric.type == 'histogram' and (sample.name.endswith('_bucket') or sample.name == metric.name):
return True
return False

Expand Down
2 changes: 1 addition & 1 deletion prometheus_client/openmetrics/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ def _parse_nh_struct(text):
deltas = dict(re_deltas.findall(text))

count_value = int(items['count'])
sum_value = int(items['sum'])
sum_value = float(items['sum'])
schema = int(items['schema'])
zero_threshold = float(items['zero_threshold'])
zero_count = int(items['zero_count'])
Expand Down
2 changes: 2 additions & 0 deletions prometheus_client/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ def unregister(self, collector: Collector) -> None:
for name in self._collector_to_names[collector]:
del self._names_to_collectors[name]
del self._collector_to_names[collector]
if collector in self._collectors_without_names:
self._collectors_without_names.remove(collector)

def _get_names(self, collector):
"""Get names of timeseries the collector produces and clashes with."""
Expand Down
24 changes: 24 additions & 0 deletions prometheus_client/values.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import os
from threading import Lock
from typing import Callable, List
import warnings

from .mmap_dict import mmap_key, MmapedDict

_multi_process_cleanups: List[Callable[[], None]] = []


def close_all_multiprocess_files():
for cleanup in _multi_process_cleanups:
cleanup()
_multi_process_cleanups.clear()


class MutexValue:
"""A float protected by a mutex."""
Expand Down Expand Up @@ -52,6 +61,13 @@ def MultiProcessValue(process_identifier=os.getpid):
# This avoids the need to also have mutexes in __MmapDict.
lock = Lock()

def cleanup():
for f in files.values():
f.close()
files.clear()
values.clear()
_multi_process_cleanups.append(cleanup)

class MmapedValue:
"""A float protected by a mutex backed by a per-process mmaped file."""

Expand Down Expand Up @@ -122,6 +138,14 @@ def get_exemplar(self):
# TODO: Implement exemplars for multiprocess mode.
return None

@classmethod
def close_all_files(cls):
with lock:
for f in files.values():
f.close()
files.clear()
values.clear()

return MmapedValue


Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "prometheus_client"
version = "0.25.0"
version = "0.26.0"
description = "Python client for the Prometheus monitoring system."
readme = "README.md"
license = "Apache-2.0 AND BSD-2-Clause"
Expand Down
15 changes: 15 additions & 0 deletions tests/openmetrics/test_exposition.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,21 @@ def collect(self):
with self.assertRaises(ValueError):
generate_latest(self.registry)

def test_gauge_exemplar(self) -> None:
class MyCollector:
def collect(self):
metric = Metric("gg", "A gauge", 'gauge')
# A sample whose name equals the metric name must not be
# treated as exemplar-eligible just because it matches;
# only histogram/gaugehistogram buckets, counter _total, and
# native histograms may carry exemplars.
metric.add_sample("gg", {}, 1, None, Exemplar({'a': 'b'}, 0.5))
yield metric

self.registry.register(MyCollector())
with self.assertRaises(ValueError):
generate_latest(self.registry)

def test_gaugehistogram(self) -> None:
self.custom_collector(
GaugeHistogramMetricFamily('gh', 'help', buckets=[('1.0', 4), ('+Inf', (5))], gsum_value=7))
Expand Down
12 changes: 12 additions & 0 deletions tests/openmetrics/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,18 @@ def test_native_histogram(self):
hfm.add_sample("nativehistogram", None, 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_float_sum(self):
families = text_string_to_metric_families("""# TYPE nativehistogram histogram
# HELP nativehistogram Is a basic example of a native histogram
nativehistogram {count:24,sum:100.5,schema:0,zero_threshold:0.001,zero_count:4,positive_spans:[0:2,1:2],negative_spans:[0:2,1:2],positive_deltas:[2,1,-3,3],negative_deltas:[2,1,-2,3]}
# EOF
""")
families = list(families)

hfm = HistogramMetricFamily("nativehistogram", "Is a basic example of a native histogram")
hfm.add_sample("nativehistogram", None, None, None, None, NativeHistogram(24, 100.5, 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_utf8(self):
families = text_string_to_metric_families("""# TYPE "native{histogram" histogram
# HELP "native{histogram" Is a basic example of a native histogram
Expand Down
27 changes: 16 additions & 11 deletions tests/test_asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,27 +32,32 @@ def setUp(self):
# Setup ASGI scope
self.scope = {}
setup_testing_defaults(self.scope)
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
self.communicator = None

def tearDown(self):
if self.communicator:
asyncio.new_event_loop().run_until_complete(
self.loop.run_until_complete(
self.communicator.wait()
)
self.loop.close()

def seed_app(self, app):
self.communicator = ApplicationCommunicator(app, self.scope)
async def _init():
self.communicator = ApplicationCommunicator(app, self.scope)
self.loop.run_until_complete(_init())

def send_input(self, payload):
asyncio.new_event_loop().run_until_complete(
self.loop.run_until_complete(
self.communicator.send_input(payload)
)

def send_default_request(self):
self.send_input({"type": "http.request", "body": b""})

def get_output(self):
output = asyncio.new_event_loop().run_until_complete(
output = self.loop.run_until_complete(
self.communicator.receive_output(0)
)
return output
Expand Down Expand Up @@ -148,9 +153,9 @@ def test_gzip(self):
increments = 2
self.increment_metrics(metric_name, help_text, increments)
app = make_asgi_app(self.registry)
self.seed_app(app)
# Send input with gzip header.
self.scope["headers"] = [(b"accept-encoding", b"gzip")]
self.seed_app(app)
self.send_input({"type": "http.request", "body": b""})
# Assert outputs are compressed.
outputs = self.get_all_output()
Expand All @@ -164,9 +169,9 @@ def test_gzip_disabled(self):
self.increment_metrics(metric_name, help_text, increments)
# Disable compression explicitly.
app = make_asgi_app(self.registry, disable_compression=True)
self.seed_app(app)
# Send input with gzip header.
self.scope["headers"] = [(b"accept-encoding", b"gzip")]
self.seed_app(app)
self.send_input({"type": "http.request", "body": b""})
# Assert outputs are not compressed.
outputs = self.get_all_output()
Expand All @@ -175,8 +180,8 @@ def test_gzip_disabled(self):
def test_openmetrics_encoding(self):
"""Response content type is application/openmetrics-text when appropriate Accept header is in request"""
app = make_asgi_app(self.registry)
self.seed_app(app)
self.scope["headers"] = [(b"Accept", b"application/openmetrics-text; version=1.0.0")]
self.seed_app(app)
self.send_input({"type": "http.request", "body": b""})

content_type = self.get_response_header_value('Content-Type').split(";")[0]
Expand Down Expand Up @@ -204,8 +209,8 @@ def test_qs_parsing(self):
self.increment_metrics(*m)

for i_1 in range(len(metrics)):
self.seed_app(app)
self.scope['query_string'] = f"name[]={metrics[i_1][0]}_total".encode("utf-8")
self.seed_app(app)
self.send_default_request()

outputs = self.get_all_output()
Expand All @@ -220,7 +225,7 @@ def test_qs_parsing(self):

self.assert_not_metrics(output, *metrics[i_2])

asyncio.new_event_loop().run_until_complete(
self.loop.run_until_complete(
self.communicator.wait()
)

Expand All @@ -237,8 +242,8 @@ def test_qs_parsing_multi(self):
for m in metrics:
self.increment_metrics(*m)

self.seed_app(app)
self.scope['query_string'] = "&".join([f"name[]={m[0]}_total" for m in metrics[0:2]]).encode("utf-8")
self.seed_app(app)
self.send_default_request()

outputs = self.get_all_output()
Expand All @@ -249,6 +254,6 @@ def test_qs_parsing_multi(self):
self.assert_metrics(output, *metrics[1])
self.assert_not_metrics(output, *metrics[2])

asyncio.new_event_loop().run_until_complete(
self.loop.run_until_complete(
self.communicator.wait()
)
15 changes: 15 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,21 @@ def test_unregister_works(self):
registry.unregister(s)
Gauge('s_count', 'help', registry=registry)

def test_unregister_removes_no_names_collector(self):
registry = CollectorRegistry(support_collectors_without_names=True)

class NamelessCollector:
def collect(self):
return [GaugeMetricFamily('foo', 'help', value=42)]

collector = NamelessCollector()
registry.register(collector)
registry.unregister(collector)
# A nameless collector must be removed from the collectors-without-names
# list too, otherwise a restricted registry keeps collecting it after it
# was unregistered.
self.assertEqual([], list(registry.restricted_registry(['foo']).collect()))

def custom_collector(self, metric_family, registry):
class CustomCollector:
def collect(self):
Expand Down
22 changes: 18 additions & 4 deletions tests/test_multiprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,26 @@ def setUp(self):
def tearDown(self):
os.environ.pop('prometheus_multiproc_dir', None)
os.environ.pop('PROMETHEUS_MULTIPROC_DIR', None)
values.close_all_multiprocess_files()
values.ValueClass = MutexValue
shutil.rmtree(self.tempdir)

def test_deprecation_warning(self):
os.environ['prometheus_multiproc_dir'] = self.tempdir
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
values.ValueClass = get_value_class()
registry = CollectorRegistry()
collector = MultiProcessCollector(registry)
Counter('c', 'help', registry=None)

assert os.environ['PROMETHEUS_MULTIPROC_DIR'] == self.tempdir
assert len(w) == 1
assert issubclass(w[-1].category, DeprecationWarning)
assert "PROMETHEUS_MULTIPROC_DIR" in str(w[-1].message)
if os.name != 'nt':
assert len(w) == 1
assert issubclass(w[-1].category, DeprecationWarning)
assert "PROMETHEUS_MULTIPROC_DIR" in str(w[-1].message)
else:
assert len(w) == 0

def test_mark_process_dead_respects_lowercase(self):
os.environ['prometheus_multiproc_dir'] = self.tempdir
Expand All @@ -61,8 +66,9 @@ def _value_class(self):

def tearDown(self):
del os.environ['PROMETHEUS_MULTIPROC_DIR']
shutil.rmtree(self.tempdir)
values.close_all_multiprocess_files()
values.ValueClass = MutexValue
shutil.rmtree(self.tempdir)

def test_counter_adds(self):
c1 = Counter('c', 'help', registry=None)
Expand Down Expand Up @@ -119,6 +125,7 @@ def test_gauge_liveall(self):
g2.set(2)
self.assertEqual(1, self.registry.get_sample_value('g', {'pid': '123'}))
self.assertEqual(2, self.registry.get_sample_value('g', {'pid': '456'}))
values.close_all_multiprocess_files()
mark_process_dead(123, os.environ['PROMETHEUS_MULTIPROC_DIR'])
self.assertEqual(None, self.registry.get_sample_value('g', {'pid': '123'}))
self.assertEqual(2, self.registry.get_sample_value('g', {'pid': '456'}))
Expand All @@ -140,6 +147,7 @@ def test_gauge_livemin(self):
g1.set(1)
g2.set(2)
self.assertEqual(1, self.registry.get_sample_value('g'))
values.close_all_multiprocess_files()
mark_process_dead(123, os.environ['PROMETHEUS_MULTIPROC_DIR'])
self.assertEqual(2, self.registry.get_sample_value('g'))

Expand All @@ -160,6 +168,7 @@ def test_gauge_livemax(self):
g1.set(2)
g2.set(1)
self.assertEqual(2, self.registry.get_sample_value('g'))
values.close_all_multiprocess_files()
mark_process_dead(123, os.environ['PROMETHEUS_MULTIPROC_DIR'])
self.assertEqual(1, self.registry.get_sample_value('g'))

Expand All @@ -171,6 +180,7 @@ def test_gauge_sum(self):
g1.set(1)
g2.set(2)
self.assertEqual(3, self.registry.get_sample_value('g'))
values.close_all_multiprocess_files()
mark_process_dead(123, os.environ['PROMETHEUS_MULTIPROC_DIR'])
self.assertEqual(3, self.registry.get_sample_value('g'))

Expand All @@ -182,6 +192,7 @@ def test_gauge_livesum(self):
g1.set(1)
g2.set(2)
self.assertEqual(3, self.registry.get_sample_value('g'))
values.close_all_multiprocess_files()
mark_process_dead(123, os.environ['PROMETHEUS_MULTIPROC_DIR'])
self.assertEqual(2, self.registry.get_sample_value('g'))

Expand All @@ -192,6 +203,7 @@ def test_gauge_mostrecent(self):
g2.set(2)
g1.set(1)
self.assertEqual(1, self.registry.get_sample_value('g'))
values.close_all_multiprocess_files()
mark_process_dead(123, os.environ['PROMETHEUS_MULTIPROC_DIR'])
self.assertEqual(1, self.registry.get_sample_value('g'))

Expand All @@ -202,6 +214,7 @@ def test_gauge_livemostrecent(self):
g2.set(2)
g1.set(1)
self.assertEqual(1, self.registry.get_sample_value('g'))
values.close_all_multiprocess_files()
mark_process_dead(123, os.environ['PROMETHEUS_MULTIPROC_DIR'])
self.assertEqual(2, self.registry.get_sample_value('g'))

Expand Down Expand Up @@ -626,6 +639,7 @@ def test_corruption_detected(self):
list(self.d.read_all_values())

def tearDown(self):
self.d.close()
os.unlink(self.tempfile)


Expand Down
Loading
Loading