diff --git a/.circleci/config.yml b/.circleci/config.yml index 624e4ea..2605a50 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -75,8 +75,8 @@ workflows: matrix: parameters: python: - - "3.8" - - "3.9" + - "3.8.18" + - "3.9.18" - "3.10" - "3.11" - "3.12" diff --git a/debian/patches/0001-import-unvendorized-decorator.patch b/debian/patches/0001-import-unvendorized-decorator.patch index 9b2da8a..7740c35 100644 --- a/debian/patches/0001-import-unvendorized-decorator.patch +++ b/debian/patches/0001-import-unvendorized-decorator.patch @@ -38,5 +38,5 @@ Index: python3-prometheus-client/tests/test_core.py + return spec.args, spec.varargs, spec.varkw, spec.defaults + - def assert_not_observable(fn, *args, **kwargs): - """ + def is_locked(lock): + "Tries to obtain a lock, returns True on success, False on failure." diff --git a/docs/content/exporting/http/_index.md b/docs/content/exporting/http/_index.md index 71edc7e..dc1b8f2 100644 --- a/docs/content/exporting/http/_index.md +++ b/docs/content/exporting/http/_index.md @@ -52,4 +52,23 @@ chain is used (see Python [ssl.SSLContext.load_default_certs()](https://docs.pyt from prometheus_client import start_http_server start_http_server(8000, certfile="server.crt", keyfile="server.key") -``` \ No newline at end of file +``` + +# Supported HTTP methods + +The prometheus client will handle the following HTTP methods and resources: + +* `OPTIONS (any)` - returns HTTP status 200 and an 'Allow' header indicating the + allowed methods (OPTIONS, GET) +* `GET (any)` - returns HTTP status 200 and the metrics data +* `GET /favicon.ico` - returns HTTP status 200 and an empty response body. Some + browsers support this to display the returned icon in the browser tab. + +Other HTTP methods than these are rejected with HTTP status 405 "Method Not Allowed" +and an 'Allow' header indicating the allowed methods (OPTIONS, GET). + +Any returned HTTP errors are also displayed in the response body after a hash +sign and with a brief hint. Example: +``` +# HTTP 405 Method Not Allowed: XXX; use OPTIONS or GET +``` diff --git a/docs/content/exporting/http/asgi.md b/docs/content/exporting/http/asgi.md index 4ff115e..5b9d543 100644 --- a/docs/content/exporting/http/asgi.md +++ b/docs/content/exporting/http/asgi.md @@ -14,10 +14,10 @@ app = make_asgi_app() Such an application can be useful when integrating Prometheus metrics with ASGI apps. -By default, the WSGI application will respect `Accept-Encoding:gzip` headers used by Prometheus +By default, the ASGI application will respect `Accept-Encoding:gzip` headers used by Prometheus and compress the response if such a header is present. This behaviour can be disabled by passing `disable_compression=True` when creating the app, like this: ```python app = make_asgi_app(disable_compression=True) -``` \ No newline at end of file +``` diff --git a/docs/content/exporting/http/fastapi-gunicorn.md b/docs/content/exporting/http/fastapi-gunicorn.md index 9ce1238..148a36d 100644 --- a/docs/content/exporting/http/fastapi-gunicorn.md +++ b/docs/content/exporting/http/fastapi-gunicorn.md @@ -19,7 +19,7 @@ metrics_app = make_asgi_app() app.mount("/metrics", metrics_app) ``` -For Multiprocessing support, use this modified code snippet. Full multiprocessing instructions are provided [here](https://github.com/prometheus/client_python#multiprocess-mode-eg-gunicorn). +For Multiprocessing support, use this modified code snippet. Full multiprocessing instructions are provided [here]({{< ref "/multiprocess" >}}). ```python from fastapi import FastAPI @@ -47,4 +47,4 @@ pip install gunicorn gunicorn -b 127.0.0.1:8000 myapp:app -k uvicorn.workers.UvicornWorker ``` -Visit http://localhost:8000/metrics to see the metrics \ No newline at end of file +Visit http://localhost:8000/metrics to see the metrics diff --git a/prometheus_client/exposition.py b/prometheus_client/exposition.py index 3a47917..fab139d 100644 --- a/prometheus_client/exposition.py +++ b/prometheus_client/exposition.py @@ -118,12 +118,24 @@ def prometheus_app(environ, start_response): accept_header = environ.get('HTTP_ACCEPT') accept_encoding_header = environ.get('HTTP_ACCEPT_ENCODING') params = parse_qs(environ.get('QUERY_STRING', '')) - if environ['PATH_INFO'] == '/favicon.ico': + method = environ['REQUEST_METHOD'] + + if method == 'OPTIONS': + status = '200 OK' + headers = [('Allow', 'OPTIONS,GET')] + output = b'' + elif method != 'GET': + status = '405 Method Not Allowed' + headers = [('Allow', 'OPTIONS,GET')] + output = '# HTTP {}: {}; use OPTIONS or GET\n'.format(status, method).encode() + elif environ['PATH_INFO'] == '/favicon.ico': # Serve empty response for browsers status = '200 OK' headers = [('', '')] output = b'' else: + # Note: For backwards compatibility, the URI path for GET is not + # constrained to the documented /metrics, but any path is allowed. # Bake output status, headers, output = _bake_output(registry, accept_header, accept_encoding_header, params, disable_compression) # Return output diff --git a/prometheus_client/metrics.py b/prometheus_client/metrics.py index 91cd9ec..3bda92c 100644 --- a/prometheus_client/metrics.py +++ b/prometheus_client/metrics.py @@ -1,5 +1,5 @@ import os -from threading import Lock +from threading import RLock import time import types from typing import ( @@ -144,7 +144,7 @@ def __init__(self: T, if self._is_parent(): # Prepare the fields needed for child metrics. - self._lock = Lock() + self._lock = RLock() self._metrics: Dict[Sequence[str], T] = {} if self._is_observable(): @@ -697,7 +697,7 @@ class Info(MetricWrapperBase): def _metric_init(self): self._labelname_set = set(self._labelnames) - self._lock = Lock() + self._lock = RLock() self._value = {} def info(self, val: Dict[str, str]) -> None: @@ -705,6 +705,8 @@ def info(self, val: Dict[str, str]) -> None: if self._labelname_set.intersection(val.keys()): raise ValueError('Overlapping labels for Info metric, metric: {} child: {}'.format( self._labelnames, val)) + if any(i is None for i in val.values()): + raise ValueError('Label value cannot be None') with self._lock: self._value = dict(val) @@ -757,7 +759,7 @@ def __init__(self, def _metric_init(self) -> None: self._value = 0 - self._lock = Lock() + self._lock = RLock() def state(self, state: str) -> None: """Set enum metric state.""" diff --git a/prometheus_client/registry.py b/prometheus_client/registry.py index 694e4bd..4326b39 100644 --- a/prometheus_client/registry.py +++ b/prometheus_client/registry.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod import copy -from threading import Lock +from threading import RLock from typing import Dict, Iterable, List, Optional from .metrics_core import Metric @@ -30,7 +30,7 @@ def __init__(self, auto_describe: bool = False, target_info: Optional[Dict[str, self._collector_to_names: Dict[Collector, List[str]] = {} self._names_to_collectors: Dict[str, Collector] = {} self._auto_describe = auto_describe - self._lock = Lock() + self._lock = RLock() self._target_info: Optional[Dict[str, str]] = {} self.set_target_info(target_info) diff --git a/prometheus_client/samples.py b/prometheus_client/samples.py index 8735fed..53c4726 100644 --- a/prometheus_client/samples.py +++ b/prometheus_client/samples.py @@ -28,10 +28,10 @@ def __ne__(self, other: object) -> bool: return not self == other def __gt__(self, other: "Timestamp") -> bool: - return self.sec > other.sec or self.nsec > other.nsec + return self.nsec > other.nsec if self.sec == other.sec else self.sec > other.sec def __lt__(self, other: "Timestamp") -> bool: - return self.sec < other.sec or self.nsec < other.nsec + return self.nsec < other.nsec if self.sec == other.sec else self.sec < other.sec # Timestamp and exemplar are optional. diff --git a/prometheus_client/values.py b/prometheus_client/values.py index 6ff85e3..05331f8 100644 --- a/prometheus_client/values.py +++ b/prometheus_client/values.py @@ -1,5 +1,5 @@ import os -from threading import Lock +from threading import RLock import warnings from .mmap_dict import mmap_key, MmapedDict @@ -13,7 +13,7 @@ class MutexValue: def __init__(self, typ, metric_name, name, labelnames, labelvalues, help_text, **kwargs): self._value = 0.0 self._exemplar = None - self._lock = Lock() + self._lock = RLock() def inc(self, amount): with self._lock: @@ -50,7 +50,7 @@ def MultiProcessValue(process_identifier=os.getpid): # Use a single global lock when in multi-processing mode # as we presume this means there is no threading going on. # This avoids the need to also have mutexes in __MmapDict. - lock = Lock() + lock = RLock() class MmapedValue: """A float protected by a mutex backed by a per-process mmaped file.""" diff --git a/setup.py b/setup.py index 595e595..438f643 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ setup( name="prometheus_client", - version="0.20.0", + version="0.21.0", author="Brian Brazil", author_email="brian.brazil@robustperception.io", description="Python client for the Prometheus monitoring system.", diff --git a/tests/test_core.py b/tests/test_core.py index 30f9e0a..f80fb88 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -16,6 +16,14 @@ from prometheus_client.metrics import _get_use_created +def is_locked(lock): + "Tries to obtain a lock, returns True on success, False on failure." + locked = lock.acquire(blocking=False) + if locked: + lock.release() + return not locked + + def assert_not_observable(fn, *args, **kwargs): """ Assert that a function call falls with a ValueError exception containing @@ -534,6 +542,7 @@ def test_info(self): def test_labels(self): self.assertRaises(ValueError, self.labels.labels('a').info, {'l': ''}) + self.assertRaises(ValueError, self.labels.labels('a').info, {'il': None}) self.labels.labels('a').info({'foo': 'bar'}) self.assertEqual(1, self.registry.get_sample_value('il_info', {'l': 'a', 'foo': 'bar'})) @@ -962,7 +971,7 @@ def test_restricted_registry_does_not_yield_while_locked(self): m = Metric('target', 'Target metadata', 'info') m.samples = [Sample('target_info', {'foo': 'bar'}, 1)] for _ in registry.restricted_registry(['target_info', 's_sum']).collect(): - self.assertFalse(registry._lock.locked()) + self.assertFalse(is_locked(registry._lock)) if __name__ == '__main__': diff --git a/tests/test_samples.py b/tests/test_samples.py index 796afe7..7b59218 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -12,6 +12,8 @@ def test_gt(self): self.assertEqual(samples.Timestamp(1, 2) > samples.Timestamp(1, 1), True) self.assertEqual(samples.Timestamp(2, 1) > samples.Timestamp(1, 1), True) self.assertEqual(samples.Timestamp(2, 2) > samples.Timestamp(1, 1), True) + self.assertEqual(samples.Timestamp(0, 2) > samples.Timestamp(1, 1), False) + self.assertEqual(samples.Timestamp(2, 0) > samples.Timestamp(1, 1), True) def test_lt(self): self.assertEqual(samples.Timestamp(1, 1) < samples.Timestamp(1, 1), False) @@ -21,6 +23,8 @@ def test_lt(self): self.assertEqual(samples.Timestamp(1, 2) < samples.Timestamp(1, 1), False) self.assertEqual(samples.Timestamp(2, 1) < samples.Timestamp(1, 1), False) self.assertEqual(samples.Timestamp(2, 2) < samples.Timestamp(1, 1), False) + self.assertEqual(samples.Timestamp(0, 2) < samples.Timestamp(1, 1), True) + self.assertEqual(samples.Timestamp(2, 0) < samples.Timestamp(1, 1), False) if __name__ == '__main__':