diff --git a/.circleci/config.yml b/.circleci/config.yml index f3712da..812bc16 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -30,7 +30,7 @@ jobs: python: type: string docker: - - image: circleci/python:<< parameters.python >> + - image: cimg/python:<< parameters.python >> environment: TOXENV: "py<< parameters.python >>" steps: @@ -80,6 +80,7 @@ workflows: - "3.8" - "3.9" - "3.10" + - "3.11" - test_nooptionals: matrix: parameters: diff --git a/README.md b/README.md index bfc45d5..08d5b42 100644 --- a/README.md +++ b/README.md @@ -487,7 +487,7 @@ TLS Auth is also supported when using the push gateway with a special handler. ```python from prometheus_client import CollectorRegistry, Gauge, push_to_gateway -from prometheus_client.exposition import tls_handler +from prometheus_client.exposition import tls_auth_handler def my_auth_handler(url, method, timeout, headers, data): diff --git a/prometheus_client/asgi.py b/prometheus_client/asgi.py index 6f72838..e1864b8 100644 --- a/prometheus_client/asgi.py +++ b/prometheus_client/asgi.py @@ -12,7 +12,7 @@ async def prometheus_app(scope, receive, send): assert scope.get("type") == "http" # Prepare parameters params = parse_qs(scope.get('query_string', b'')) - accept_header = "Accept: " + ",".join([ + accept_header = ",".join([ value.decode("utf8") for (name, value) in scope.get('headers') if name.decode("utf8").lower() == 'accept' ]) diff --git a/prometheus_client/context_managers.py b/prometheus_client/context_managers.py index b229b17..ef4db17 100644 --- a/prometheus_client/context_managers.py +++ b/prometheus_client/context_managers.py @@ -1,7 +1,9 @@ import sys from timeit import default_timer from types import TracebackType -from typing import Any, Callable, Optional, Type, TYPE_CHECKING, TypeVar +from typing import ( + Any, Callable, Optional, Tuple, Type, TYPE_CHECKING, TypeVar, Union, +) if sys.version_info >= (3, 8, 0): from typing import Literal @@ -14,7 +16,7 @@ class ExceptionCounter: - def __init__(self, counter: "Counter", exception: Type[BaseException]) -> None: + def __init__(self, counter: "Counter", exception: Union[Type[BaseException], Tuple[Type[BaseException], ...]]) -> None: self._counter = counter self._exception = exception diff --git a/prometheus_client/exposition.py b/prometheus_client/exposition.py index c592af1..deaa6ed 100644 --- a/prometheus_client/exposition.py +++ b/prometheus_client/exposition.py @@ -370,8 +370,8 @@ def basic_auth_handler( timeout: Optional[float], headers: List[Tuple[str, str]], data: bytes, - username: str = None, - password: str = None, + username: Optional[str] = None, + password: Optional[str] = None, ) -> Callable[[], None]: """Handler that implements HTTP/HTTPS connections with Basic Auth. diff --git a/prometheus_client/metrics.py b/prometheus_client/metrics.py index 8878fb8..392e1e4 100644 --- a/prometheus_client/metrics.py +++ b/prometheus_client/metrics.py @@ -3,8 +3,8 @@ import time import types from typing import ( - Any, Callable, Dict, Iterable, List, Optional, Sequence, Type, TypeVar, - Union, + Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple, Type, + TypeVar, Union, ) from . import values # retain this import style for testability @@ -275,7 +275,7 @@ def f(): def _metric_init(self) -> None: self._value = values.ValueClass(self._type, self._name, self._name + '_total', self._labelnames, - self._labelvalues) + self._labelvalues, self._documentation) self._created = time.time() def inc(self, amount: float = 1, exemplar: Optional[Dict[str, str]] = None) -> None: @@ -288,7 +288,7 @@ def inc(self, amount: float = 1, exemplar: Optional[Dict[str, str]] = None) -> N _validate_exemplar(exemplar) self._value.set_exemplar(Exemplar(exemplar, amount, time.time())) - def count_exceptions(self, exception: Type[BaseException] = Exception) -> ExceptionCounter: + def count_exceptions(self, exception: Union[Type[BaseException], Tuple[Type[BaseException], ...]] = Exception) -> ExceptionCounter: """Count exceptions in a block of code or function. Can be used as a function decorator or context manager. @@ -377,7 +377,7 @@ def __init__(self, def _metric_init(self) -> None: self._value = values.ValueClass( self._type, self._name, self._name, self._labelnames, self._labelvalues, - multiprocess_mode=self._multiprocess_mode + self._documentation, multiprocess_mode=self._multiprocess_mode ) def inc(self, amount: float = 1) -> None: @@ -469,8 +469,8 @@ def create_response(request): def _metric_init(self) -> None: self._count = values.ValueClass(self._type, self._name, self._name + '_count', self._labelnames, - self._labelvalues) - self._sum = values.ValueClass(self._type, self._name, self._name + '_sum', self._labelnames, self._labelvalues) + self._labelvalues, self._documentation) + self._sum = values.ValueClass(self._type, self._name, self._name + '_sum', self._labelnames, self._labelvalues, self._documentation) self._created = time.time() def observe(self, amount: float) -> None: @@ -583,14 +583,15 @@ def _metric_init(self) -> None: self._buckets: List[values.ValueClass] = [] self._created = time.time() bucket_labelnames = self._labelnames + ('le',) - self._sum = values.ValueClass(self._type, self._name, self._name + '_sum', self._labelnames, self._labelvalues) + self._sum = values.ValueClass(self._type, self._name, self._name + '_sum', self._labelnames, self._labelvalues, self._documentation) for b in self._upper_bounds: self._buckets.append(values.ValueClass( self._type, self._name, self._name + '_bucket', bucket_labelnames, - self._labelvalues + (floatToGoString(b),)) + self._labelvalues + (floatToGoString(b),), + self._documentation) ) def observe(self, amount: float, exemplar: Optional[Dict[str, str]] = None) -> None: diff --git a/prometheus_client/metrics_core.py b/prometheus_client/metrics_core.py index 77b3e44..7226d92 100644 --- a/prometheus_client/metrics_core.py +++ b/prometheus_client/metrics_core.py @@ -113,7 +113,7 @@ def __init__(self, name: str, documentation: str, value: Optional[float] = None, - labels: Sequence[str] = None, + labels: Optional[Sequence[str]] = None, created: Optional[float] = None, unit: str = '', ): diff --git a/prometheus_client/mmap_dict.py b/prometheus_client/mmap_dict.py index e8b2df9..c3de38f 100644 --- a/prometheus_client/mmap_dict.py +++ b/prometheus_client/mmap_dict.py @@ -2,8 +2,9 @@ import mmap import os import struct +from typing import List -_INITIAL_MMAP_SIZE = 1 << 20 +_INITIAL_MMAP_SIZE = 1 << 16 _pack_integer_func = struct.Struct(b'i').pack _pack_double_func = struct.Struct(b'd').pack _unpack_integer = struct.Struct(b'i').unpack_from @@ -137,8 +138,8 @@ def close(self): self._f = None -def mmap_key(metric_name, name, labelnames, labelvalues): +def mmap_key(metric_name: str, name: str, labelnames: List[str], labelvalues: List[str], help_text: str) -> str: """Format a key for use in the mmap file.""" # ensure labels are in consistent order for identity labels = dict(zip(labelnames, labelvalues)) - return json.dumps([metric_name, name, labels], sort_keys=True) + return json.dumps([metric_name, name, labels, help_text], sort_keys=True) diff --git a/prometheus_client/multiprocess.py b/prometheus_client/multiprocess.py index 5a23c48..dd34391 100644 --- a/prometheus_client/multiprocess.py +++ b/prometheus_client/multiprocess.py @@ -15,8 +15,6 @@ except NameError: # Python >= 2.5 FileNotFoundError = IOError -MP_METRIC_HELP = 'Multiprocess metric' - class MultiProcessCollector: """Collector for files for multi-process mode.""" @@ -53,9 +51,9 @@ def _read_metrics(files): def _parse_key(key): val = key_cache.get(key) if not val: - metric_name, name, labels = json.loads(key) + metric_name, name, labels, help_text = json.loads(key) labels_key = tuple(sorted(labels.items())) - val = key_cache[key] = (metric_name, name, labels, labels_key) + val = key_cache[key] = (metric_name, name, labels, labels_key, help_text) return val for f in files: @@ -71,11 +69,11 @@ def _parse_key(key): continue raise for key, value, _ in file_values: - metric_name, name, labels, labels_key = _parse_key(key) + metric_name, name, labels, labels_key, help_text = _parse_key(key) metric = metrics.get(metric_name) if metric is None: - metric = Metric(metric_name, MP_METRIC_HELP, typ) + metric = Metric(metric_name, help_text, typ) metrics[metric_name] = metric if typ == 'gauge': diff --git a/prometheus_client/parser.py b/prometheus_client/parser.py index ad3e1c6..7135bc8 100644 --- a/prometheus_client/parser.py +++ b/prometheus_client/parser.py @@ -116,8 +116,8 @@ def _parse_sample(text): name = text[:label_start].strip() # We ignore the starting curly brace label = text[label_start + 1:label_end] - # The value is after the label end (ignoring curly brace and space) - value, timestamp = _parse_value_and_timestamp(text[label_end + 2:]) + # The value is after the label end (ignoring curly brace) + value, timestamp = _parse_value_and_timestamp(text[label_end + 1:]) return Sample(name, _parse_labels(label), value, timestamp) # We don't have labels diff --git a/prometheus_client/values.py b/prometheus_client/values.py index 03b203b..3373379 100644 --- a/prometheus_client/values.py +++ b/prometheus_client/values.py @@ -10,7 +10,7 @@ class MutexValue: _multiprocess = False - def __init__(self, typ, metric_name, name, labelnames, labelvalues, **kwargs): + def __init__(self, typ, metric_name, name, labelnames, labelvalues, help_text, **kwargs): self._value = 0.0 self._exemplar = None self._lock = Lock() @@ -57,8 +57,8 @@ class MmapedValue: _multiprocess = True - def __init__(self, typ, metric_name, name, labelnames, labelvalues, multiprocess_mode='', **kwargs): - self._params = typ, metric_name, name, labelnames, labelvalues, multiprocess_mode + def __init__(self, typ, metric_name, name, labelnames, labelvalues, help_text, multiprocess_mode='', **kwargs): + self._params = typ, metric_name, name, labelnames, labelvalues, help_text, multiprocess_mode # This deprecation warning can go away in a few releases when removing the compatibility if 'prometheus_multiproc_dir' in os.environ and 'PROMETHEUS_MULTIPROC_DIR' not in os.environ: os.environ['PROMETHEUS_MULTIPROC_DIR'] = os.environ['prometheus_multiproc_dir'] @@ -69,7 +69,7 @@ def __init__(self, typ, metric_name, name, labelnames, labelvalues, multiprocess values.append(self) def __reset(self): - typ, metric_name, name, labelnames, labelvalues, multiprocess_mode = self._params + typ, metric_name, name, labelnames, labelvalues, help_text, multiprocess_mode = self._params if typ == 'gauge': file_prefix = typ + '_' + multiprocess_mode else: @@ -81,7 +81,7 @@ def __reset(self): files[file_prefix] = MmapedDict(filename) self._file = files[file_prefix] - self._key = mmap_key(metric_name, name, labelnames, labelvalues) + self._key = mmap_key(metric_name, name, labelnames, labelvalues, help_text) self._value = self._file.read_value(self._key) def __check_for_pid_change(self): diff --git a/setup.py b/setup.py index 1b25067..fd9bf41 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ setup( name="prometheus_client", - version="0.15.0", + version="0.16.0", author="Brian Brazil", author_email="brian.brazil@robustperception.io", description="Python client for the Prometheus monitoring system.", @@ -43,6 +43,7 @@ "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: System :: Monitoring", diff --git a/tests/openmetrics/test_parser.py b/tests/openmetrics/test_parser.py index 4b4aecd..937aef5 100644 --- a/tests/openmetrics/test_parser.py +++ b/tests/openmetrics/test_parser.py @@ -642,6 +642,8 @@ def test_invalid_input(self): ('a{a""} 1\n# EOF\n'), ('a{a=} 1\n# EOF\n'), ('a{a="} 1\n# EOF\n'), + # Missing delimiters. + ('a{a="1"}1\n# EOF\n'), # Missing or extra commas. ('a{a="1"b="2"} 1\n# EOF\n'), ('a{a="1",,b="2"} 1\n# EOF\n'), diff --git a/tests/test_asgi.py b/tests/test_asgi.py index 50d76d6..78e2419 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -75,6 +75,19 @@ def get_all_output(self): break return outputs + def get_all_response_headers(self): + outputs = self.get_all_output() + response_start = next(o for o in outputs if o["type"] == "http.response.start") + return response_start["headers"] + + def get_response_header_value(self, header_name): + response_headers = self.get_all_response_headers() + return next( + value.decode("utf-8") + for name, value in response_headers + if name.decode("utf-8") == header_name + ) + def increment_metrics(self, metric_name, help_text, increments): c = Counter(metric_name, help_text, registry=self.registry) for _ in range(increments): @@ -158,3 +171,22 @@ def test_gzip_disabled(self): # Assert outputs are not compressed. outputs = self.get_all_output() self.assert_outputs(outputs, metric_name, help_text, increments, compressed=False) + + 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")] + self.send_input({"type": "http.request", "body": b""}) + + content_type = self.get_response_header_value('Content-Type').split(";")[0] + assert content_type == "application/openmetrics-text" + + def test_plaintext_encoding(self): + """Response content type is text/plain when Accept header is missing in request""" + app = make_asgi_app(self.registry) + self.seed_app(app) + self.send_input({"type": "http.request", "body": b""}) + + content_type = self.get_response_header_value('Content-Type').split(";")[0] + assert content_type == "text/plain" diff --git a/tests/test_multiprocess.py b/tests/test_multiprocess.py index a41903a..10990ad 100644 --- a/tests/test_multiprocess.py +++ b/tests/test_multiprocess.py @@ -281,6 +281,31 @@ def add_label(key, value): self.assertEqual(metrics['h'].samples, expected_histogram) + def test_collect_preserves_help(self): + pid = 0 + values.ValueClass = MultiProcessValue(lambda: pid) + labels = {i: i for i in 'abcd'} + + c = Counter('c', 'c help', labelnames=labels.keys(), registry=None) + g = Gauge('g', 'g help', labelnames=labels.keys(), registry=None) + h = Histogram('h', 'h help', labelnames=labels.keys(), registry=None) + + c.labels(**labels).inc(1) + g.labels(**labels).set(1) + h.labels(**labels).observe(1) + + pid = 1 + + c.labels(**labels).inc(1) + g.labels(**labels).set(1) + h.labels(**labels).observe(5) + + metrics = {m.name: m for m in self.collector.collect()} + + self.assertEqual(metrics['c'].documentation, 'c help') + self.assertEqual(metrics['g'].documentation, 'g help') + self.assertEqual(metrics['h'].documentation, 'h help') + def test_merge_no_accumulate(self): pid = 0 values.ValueClass = MultiProcessValue(lambda: pid) diff --git a/tests/test_parser.py b/tests/test_parser.py index 1d20d27..61b3c8a 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -146,6 +146,7 @@ def test_spaces(self): a { foo = "buz" } 3 a\t { \t foo\t = "biz"\t } \t 4 a \t{\t foo = "boz"\t}\t 5 +a{foo="bez"}6 """) metric_family = CounterMetricFamily("a", "help", labels=["foo"]) metric_family.add_metric(["bar"], 1) @@ -153,6 +154,7 @@ def test_spaces(self): metric_family.add_metric(["buz"], 3) metric_family.add_metric(["biz"], 4) metric_family.add_metric(["boz"], 5) + metric_family.add_metric(["bez"], 6) self.assertEqualMetrics([metric_family], list(families)) def test_commas(self): diff --git a/tox.ini b/tox.ini index 25a7a00..f1d4d1c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,5 @@ [tox] -envlist = coverage-clean,py3.6,py3.7,py3.8,py3.9,py3.10,pypy3.7,py3.9-nooptionals,coverage-report,flake8,isort,mypy - +envlist = coverage-clean,py3.6,py3.7,py3.8,py3.9,py3.10,py3.11,pypy3.7,py3.9-nooptionals,coverage-report,flake8,isort,mypy [base] deps = @@ -23,7 +22,6 @@ deps = coverage skip_install = true commands = coverage erase - [testenv:coverage-report] deps = coverage skip_install = true @@ -31,19 +29,18 @@ commands = coverage combine coverage report - [testenv:flake8] deps = - flake8==3.7.8 - flake8-docstrings==1.5.0 - flake8-import-order==0.18.1 + flake8==6.0.0 + flake8-docstrings==1.6.0 + flake8-import-order==0.18.2 skip_install = true commands = flake8 prometheus_client/ tests/ setup.py [testenv:isort] deps = - isort==5.5.4 + isort==5.10.1 skip_install = true commands = isort --check prometheus_client/ tests/ setup.py @@ -52,7 +49,7 @@ commands = deps = pytest asgiref - mypy==0.910 + mypy==0.991 skip_install = true commands = mypy --install-types --non-interactive prometheus_client/ tests/ @@ -77,7 +74,6 @@ per-file-ignores = prometheus_client/__init__.py:F401 import-order-style = google application-import-names = prometheus_client - [isort] force_alphabetical_sort_within_sections = True force_sort_within_sections = True