From 147c9d1ac01ae0c36446d1dee79aae85aaf98a6e Mon Sep 17 00:00:00 2001 From: Chris Marchbanks Date: Mon, 22 Jan 2024 17:00:28 -0700 Subject: [PATCH 01/32] Update documentation for disabling _created metrics (#992) Signed-off-by: Chris Marchbanks --- docs/content/instrumenting/_index.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/content/instrumenting/_index.md b/docs/content/instrumenting/_index.md index 7f282737..13bbc6b6 100644 --- a/docs/content/instrumenting/_index.md +++ b/docs/content/instrumenting/_index.md @@ -13,4 +13,8 @@ on how to use them. By default counters, histograms, and summaries export an additional series suffixed with `_created` and a value of the unix timestamp for when the metric was created. If this information is not helpful, it can be disabled by setting -the environment variable `PROMETHEUS_DISABLE_CREATED_SERIES=True`. \ No newline at end of file +the environment variable `PROMETHEUS_DISABLE_CREATED_SERIES=True` or in code: +```python +from prometheus_client import disable_created_metrics +disable_created_metrics() +``` From 998d8afa78b31fd2ddc3acf11bbc14c2f30fc363 Mon Sep 17 00:00:00 2001 From: Thomas W <69774548+yctomwang@users.noreply.github.com> Date: Fri, 2 Feb 2024 10:30:06 +1100 Subject: [PATCH 02/32] Update documentation and code warning for remove and clear in multi-process mode (#1003) Signed-off-by: Yuanchen Wang --- docs/content/multiprocess/_index.md | 1 + prometheus_client/metrics.py | 10 ++++++++++ tests/test_multiprocess.py | 16 ++++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/docs/content/multiprocess/_index.md b/docs/content/multiprocess/_index.md index c69cad8f..bf57d359 100644 --- a/docs/content/multiprocess/_index.md +++ b/docs/content/multiprocess/_index.md @@ -18,6 +18,7 @@ This comes with a number of limitations: - The pushgateway cannot be used - Gauges cannot use the `pid` label - Exemplars are not supported +- Remove and Clear of labels are currently not supported in multiprocess mode. There's several steps to getting this working: diff --git a/prometheus_client/metrics.py b/prometheus_client/metrics.py index 7e5b030a..34305a17 100644 --- a/prometheus_client/metrics.py +++ b/prometheus_client/metrics.py @@ -6,6 +6,7 @@ Any, Callable, Dict, Iterable, List, Literal, Optional, Sequence, Tuple, Type, TypeVar, Union, ) +import warnings from . import values # retain this import style for testability from .context_managers import ExceptionCounter, InprogressTracker, Timer @@ -210,6 +211,11 @@ def labels(self: T, *labelvalues: Any, **labelkwargs: Any) -> T: return self._metrics[labelvalues] def remove(self, *labelvalues: Any) -> None: + 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) @@ -222,6 +228,10 @@ def remove(self, *labelvalues: Any) -> None: def clear(self) -> None: """Remove all labelsets from the metric""" + if 'prometheus_multiproc_dir' in os.environ or 'PROMETHEUS_MULTIPROC_DIR' in os.environ: + warnings.warn( + "Clearing labels has not been implemented in multi-process mode yet", + UserWarning) with self._lock: self._metrics = {} diff --git a/tests/test_multiprocess.py b/tests/test_multiprocess.py index 6e188e51..77fd3d81 100644 --- a/tests/test_multiprocess.py +++ b/tests/test_multiprocess.py @@ -381,6 +381,22 @@ def test_missing_gauge_file_during_merge(self): os.path.join(self.tempdir, 'gauge_livesum_9999999.db'), ])) + def test_remove_clear_warning(self): + os.environ['PROMETHEUS_MULTIPROC_DIR'] = self.tempdir + with warnings.catch_warnings(record=True) as w: + values.ValueClass = get_value_class() + registry = CollectorRegistry() + collector = MultiProcessCollector(registry) + counter = Counter('c', 'help', labelnames=['label'], registry=None) + counter.labels('label').inc() + counter.remove('label') + counter.clear() + assert os.environ['PROMETHEUS_MULTIPROC_DIR'] == self.tempdir + assert issubclass(w[0].category, UserWarning) + assert "Removal of labels has not been implemented" in str(w[0].message) + assert issubclass(w[-1].category, UserWarning) + assert "Clearing labels has not been implemented" in str(w[-1].message) + class TestMmapedDict(unittest.TestCase): def setUp(self): From 9dd6b0d7ee9d7cf6ce2e820fad6a7ec9a6b167a7 Mon Sep 17 00:00:00 2001 From: Chris Marchbanks Date: Thu, 1 Feb 2024 16:30:38 -0700 Subject: [PATCH 03/32] Update OpenMetrics Content Type to 1.0.0 (#997) OpenMetrics has been released and we support 1.0.0 not 0.0.1. Signed-off-by: Chris Marchbanks --- prometheus_client/openmetrics/exposition.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prometheus_client/openmetrics/exposition.py b/prometheus_client/openmetrics/exposition.py index b0190301..26f3109f 100644 --- a/prometheus_client/openmetrics/exposition.py +++ b/prometheus_client/openmetrics/exposition.py @@ -3,7 +3,7 @@ from ..utils import floatToGoString -CONTENT_TYPE_LATEST = 'application/openmetrics-text; version=0.0.1; charset=utf-8' +CONTENT_TYPE_LATEST = 'application/openmetrics-text; version=1.0.0; charset=utf-8' """Content type of the latest OpenMetrics text format""" From b9edc43221101cad593c64d3fe9853760bef135e Mon Sep 17 00:00:00 2001 From: Antti Rasinen Date: Fri, 2 Feb 2024 01:31:13 +0200 Subject: [PATCH 04/32] Enable graceful shutdown for start_{http,wsgi}_server (#999) Signed-off-by: Antti Rasinen --- docs/content/exporting/http/_index.md | 9 +++++++++ prometheus_client/exposition.py | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/content/exporting/http/_index.md b/docs/content/exporting/http/_index.md index b074a3bf..71edc7e3 100644 --- a/docs/content/exporting/http/_index.md +++ b/docs/content/exporting/http/_index.md @@ -18,6 +18,15 @@ start_http_server(8000) Visit [http://localhost:8000/](http://localhost:8000/) to view the metrics. +The function will return the HTTP server and thread objects, which can be used +to shutdown the server gracefully: + +```python +server, t = start_http_server(8000) +server.shutdown() +t.join() +``` + To add Prometheus exposition to an existing HTTP server, see the `MetricsHandler` class which provides a `BaseHTTPRequestHandler`. It also serves as a simple example of how to write a custom endpoint. diff --git a/prometheus_client/exposition.py b/prometheus_client/exposition.py index f2b7442b..30194dd8 100644 --- a/prometheus_client/exposition.py +++ b/prometheus_client/exposition.py @@ -210,7 +210,7 @@ def start_wsgi_server( client_capath: Optional[str] = None, protocol: int = ssl.PROTOCOL_TLS_SERVER, client_auth_required: bool = False, -) -> None: +) -> Tuple[WSGIServer, threading.Thread]: """Starts a WSGI server for prometheus metrics as a daemon thread.""" class TmpServer(ThreadingWSGIServer): @@ -226,6 +226,8 @@ class TmpServer(ThreadingWSGIServer): t.daemon = True t.start() + return httpd, t + start_http_server = start_wsgi_server From 1f8ceb79f0c96c8983df4fea2bafbf54a859a11b Mon Sep 17 00:00:00 2001 From: Paul Melnikov Date: Tue, 13 Feb 2024 23:33:13 +0700 Subject: [PATCH 05/32] Reset counter (#1005) * Add .reset method to Counter metric * Update method docstring * Add a test for .reset() method Signed-off-by: Paul Melnikov Co-authored-by: Chris Marchbanks --- prometheus_client/metrics.py | 11 +++++++++++ tests/test_core.py | 10 ++++++++++ 2 files changed, 21 insertions(+) diff --git a/prometheus_client/metrics.py b/prometheus_client/metrics.py index 34305a17..91cd9ecf 100644 --- a/prometheus_client/metrics.py +++ b/prometheus_client/metrics.py @@ -292,6 +292,12 @@ def f(): # Count only one type of exception with c.count_exceptions(ValueError): pass + + You can also reset the counter to zero in case your logical "process" restarts + without restarting the actual python process. + + c.reset() + """ _type = 'counter' @@ -310,6 +316,11 @@ 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 reset(self) -> None: + """Reset the counter to zero. Use this when a logical process restarts without restarting the actual python process.""" + self._value.set(0) + self._created = time.time() + def count_exceptions(self, exception: Union[Type[BaseException], Tuple[Type[BaseException], ...]] = Exception) -> ExceptionCounter: """Count exceptions in a block of code or function. diff --git a/tests/test_core.py b/tests/test_core.py index 6f7c9d1c..30f9e0ad 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -43,6 +43,16 @@ def test_increment(self): self.counter.inc(7) self.assertEqual(8, self.registry.get_sample_value('c_total')) + def test_reset(self): + self.counter.inc() + self.assertNotEqual(0, self.registry.get_sample_value('c_total')) + created = self.registry.get_sample_value('c_created') + time.sleep(0.05) + self.counter.reset() + self.assertEqual(0, self.registry.get_sample_value('c_total')) + created_after_reset = self.registry.get_sample_value('c_created') + self.assertLess(created, created_after_reset) + def test_repr(self): self.assertEqual(repr(self.counter), "prometheus_client.metrics.Counter(c)") From 6ae7737ea46b496dceb621c6bcc4340583af2a85 Mon Sep 17 00:00:00 2001 From: "Joshua M. Clulow" Date: Tue, 13 Feb 2024 08:37:26 -0800 Subject: [PATCH 06/32] wsgi server: address family discovery is not quite right (#1006) The code introduced to improve binding to an IPv6 address is based on similar code in Python itself, but is missing some critical arguments to the socket.getaddrinfo() call: in particular, the socket type must be set to SOCK_STREAM, because we want a TCP connection; the AI_PASSIVE flag should also be passed because we intend to use the result for binding a listen socket rather than making an outbound connection. Signed-off-by: Joshua M. Clulow --- prometheus_client/exposition.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prometheus_client/exposition.py b/prometheus_client/exposition.py index 30194dd8..3a47917c 100644 --- a/prometheus_client/exposition.py +++ b/prometheus_client/exposition.py @@ -154,7 +154,7 @@ def _get_best_family(address, port): # binding an ipv6 address is requested. # This function is based on what upstream python did for http.server # in https://github.com/python/cpython/pull/11767 - infos = socket.getaddrinfo(address, port) + infos = socket.getaddrinfo(address, port, type=socket.SOCK_STREAM, flags=socket.AI_PASSIVE) family, _, _, _, sockaddr = next(iter(infos)) return family, sockaddr[0] From 7a80f001237fe881d3607861947292abc85bf205 Mon Sep 17 00:00:00 2001 From: Chris Marchbanks Date: Wed, 14 Feb 2024 08:43:45 -0700 Subject: [PATCH 07/32] Release 0.20.0 Signed-off-by: Chris Marchbanks --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index f25dd24d..595e5954 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ setup( name="prometheus_client", - version="0.19.0", + version="0.20.0", author="Brian Brazil", author_email="brian.brazil@robustperception.io", description="Python client for the Prometheus monitoring system.", From 4535ce0f43097aa48e44a65747d82064f2aadaf5 Mon Sep 17 00:00:00 2001 From: Andrew Date: Fri, 8 Mar 2024 21:12:24 -0500 Subject: [PATCH 08/32] Add sanity check for label value (#1012) Signed-off-by: Pengfei Zhang --- prometheus_client/metrics.py | 2 ++ tests/test_core.py | 1 + 2 files changed, 3 insertions(+) diff --git a/prometheus_client/metrics.py b/prometheus_client/metrics.py index 91cd9ecf..af512115 100644 --- a/prometheus_client/metrics.py +++ b/prometheus_client/metrics.py @@ -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) diff --git a/tests/test_core.py b/tests/test_core.py index 30f9e0ad..8a54a02d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -534,6 +534,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'})) From 7bc8cddfbbc9b72c98725a879d9b94a675a6c7da Mon Sep 17 00:00:00 2001 From: Jason Mobarak Date: Mon, 15 Apr 2024 14:57:30 -0700 Subject: [PATCH 09/32] docs: correct link to multiprocessing docs (#1023) * docs: correct link to multiprocessing docs Signed-off-by: Jason Mobarak * Update docs/content/exporting/http/fastapi-gunicorn.md Co-authored-by: Chris Marchbanks Signed-off-by: Jason Mobarak --------- Signed-off-by: Jason Mobarak Co-authored-by: Chris Marchbanks --- docs/content/exporting/http/fastapi-gunicorn.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/content/exporting/http/fastapi-gunicorn.md b/docs/content/exporting/http/fastapi-gunicorn.md index 9ce12381..148a36d7 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 From eeec421b2f489d2c465bb8ca419b772829b7b16c Mon Sep 17 00:00:00 2001 From: Chris Marchbanks Date: Tue, 16 Apr 2024 08:17:42 -0600 Subject: [PATCH 10/32] Pin python 3.8 and 3.9 at patch level (#1024) Signed-off-by: Chris Marchbanks --- .circleci/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 624e4eae..2605a505 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" From e364a96f506bbb70ae744e0b3307e4b693e28258 Mon Sep 17 00:00:00 2001 From: Eden Yemini Date: Tue, 28 May 2024 17:55:39 +0300 Subject: [PATCH 11/32] Fix a typo in ASGI docs (#1036) Signed-off-by: Eden Yemini --- docs/content/exporting/http/asgi.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/content/exporting/http/asgi.md b/docs/content/exporting/http/asgi.md index 4ff115ea..5b9d5430 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 +``` From 09a5ae30602a7a81f6174dae4ba08b93ee7feed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel=20Garc=C3=ADa?= Date: Tue, 28 May 2024 22:11:34 +0200 Subject: [PATCH 12/32] Fix timestamp comparison (#1038) Signed-off-by: Miguel Angel Garcia --- prometheus_client/samples.py | 4 ++-- tests/test_samples.py | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/prometheus_client/samples.py b/prometheus_client/samples.py index 8735fed9..53c47264 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/tests/test_samples.py b/tests/test_samples.py index 796afe7e..7b59218b 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__': From 7c45f84e5e3d2e0a75b3946408fec1a4d5c72841 Mon Sep 17 00:00:00 2001 From: Andreas Maier Date: Fri, 2 Aug 2024 19:28:35 +0200 Subject: [PATCH 13/32] Reject invalid HTTP methods and resources (#1019) This change addresses the issue that currently, any HTTP method is handled by returning success and metrics data, which causes network scanners to report issues. Details: * This change rejects any HTTP methods and resources other than the following: OPTIONS (any) - returns 200 and an 'Allow' header indicating allowed methods GET (any) - returns 200 and metrics GET /favicon.ico - returns 200 and no body (this is no change) Other HTTP methods than these are rejected with 405 "Method Not Allowed" and an 'Allow' header indicating the allowed HTTP methods. Any returned HTTP errors are also displayed in the response body after a hash sign and with a brief hint, e.g. "# HTTP 405 Method Not Allowed: XXX; use OPTIONS or GET". Signed-off-by: Andreas Maier --- docs/content/exporting/http/_index.md | 21 ++++++++++++++++++++- prometheus_client/exposition.py | 14 +++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/docs/content/exporting/http/_index.md b/docs/content/exporting/http/_index.md index 71edc7e3..dc1b8f2c 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/prometheus_client/exposition.py b/prometheus_client/exposition.py index 3a47917c..fab139df 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 From 0014e9776350a252930671ed170edee464f9b428 Mon Sep 17 00:00:00 2001 From: Ben Timby Date: Tue, 17 Sep 2024 16:07:17 -0400 Subject: [PATCH 14/32] Use re-entrant lock. (#1014) * Use re-entrant lock. --------- Signed-off-by: Ben Timby --- prometheus_client/metrics.py | 8 ++++---- prometheus_client/registry.py | 4 ++-- prometheus_client/values.py | 6 +++--- tests/test_core.py | 10 +++++++++- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/prometheus_client/metrics.py b/prometheus_client/metrics.py index af512115..3bda92c4 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: @@ -759,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 694e4bd8..4326b39a 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/values.py b/prometheus_client/values.py index 6ff85e3b..05331f82 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/tests/test_core.py b/tests/test_core.py index 8a54a02d..f80fb882 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 @@ -963,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__': From 3b183b44994454be226c208037e1fe4b9a89dfc5 Mon Sep 17 00:00:00 2001 From: Chris Marchbanks Date: Fri, 20 Sep 2024 09:12:21 -0600 Subject: [PATCH 15/32] Release 0.21.0 Signed-off-by: Chris Marchbanks --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 595e5954..438f643a 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.", From d7c9cd88c7f50097cd86869974301df7615bc9c0 Mon Sep 17 00:00:00 2001 From: Arianna Vespri <36129782+vesari@users.noreply.github.com> Date: Fri, 20 Sep 2024 19:04:20 +0200 Subject: [PATCH 16/32] Add support for native histograms in OM parser (#1040) * Start on native histogram parser * Fix regex for nh sample * Get nh sample appended * Complete parsing for simple native histogram * Add parsing for native histograms with labels, fix linting * Mitigate type and style errors * Add test for parsing coexisting native and classic hist with simple label set * Solve error in Python 3.9 tests * Add test for native + classic histograms with more than a label set and adapt logic accordigly * Separate native histogram from value field, improve conditional/try blocks * Clean up debug lines, add warnings, delete unnecessary lines Signed-off-by: Arianna Vespri --- prometheus_client/core.py | 4 +- prometheus_client/metrics.py | 8 +- prometheus_client/metrics_core.py | 7 +- prometheus_client/multiprocess.py | 2 +- prometheus_client/openmetrics/exposition.py | 4 +- prometheus_client/openmetrics/parser.py | 156 +++++++++++++++++--- prometheus_client/samples.py | 22 ++- tests/openmetrics/test_parser.py | 79 +++++++++- 8 files changed, 245 insertions(+), 37 deletions(-) diff --git a/prometheus_client/core.py b/prometheus_client/core.py index ad3a4542..60f93ce1 100644 --- a/prometheus_client/core.py +++ b/prometheus_client/core.py @@ -5,9 +5,10 @@ SummaryMetricFamily, UnknownMetricFamily, UntypedMetricFamily, ) from .registry import CollectorRegistry, REGISTRY -from .samples import Exemplar, Sample, Timestamp +from .samples import BucketSpan, Exemplar, NativeHistogram, Sample, Timestamp __all__ = ( + 'BucketSpan', 'CollectorRegistry', 'Counter', 'CounterMetricFamily', @@ -21,6 +22,7 @@ 'Info', 'InfoMetricFamily', 'Metric', + 'NativeHistogram', 'REGISTRY', 'Sample', 'StateSetMetricFamily', diff --git a/prometheus_client/metrics.py b/prometheus_client/metrics.py index 3bda92c4..cceaafda 100644 --- a/prometheus_client/metrics.py +++ b/prometheus_client/metrics.py @@ -111,8 +111,8 @@ def describe(self) -> Iterable[Metric]: def collect(self) -> Iterable[Metric]: metric = self._get_metric() - for suffix, labels, value, timestamp, exemplar in self._samples(): - metric.add_sample(self._name + suffix, labels, value, timestamp, exemplar) + for suffix, labels, value, timestamp, exemplar, native_histogram_value in self._samples(): + metric.add_sample(self._name + suffix, labels, value, timestamp, exemplar, native_histogram_value) return [metric] def __str__(self) -> str: @@ -246,8 +246,8 @@ def _multi_samples(self) -> Iterable[Sample]: metrics = self._metrics.copy() for labels, metric in metrics.items(): series_labels = list(zip(self._labelnames, labels)) - for suffix, sample_labels, value, timestamp, exemplar in metric._samples(): - yield Sample(suffix, dict(series_labels + list(sample_labels.items())), value, timestamp, exemplar) + for suffix, sample_labels, value, timestamp, exemplar, native_histogram_value in metric._samples(): + yield Sample(suffix, dict(series_labels + list(sample_labels.items())), value, timestamp, exemplar, native_histogram_value) def _child_samples(self) -> Iterable[Sample]: # pragma: no cover raise NotImplementedError('_child_samples() must be implemented by %r' % self) diff --git a/prometheus_client/metrics_core.py b/prometheus_client/metrics_core.py index 7226d920..19166e1d 100644 --- a/prometheus_client/metrics_core.py +++ b/prometheus_client/metrics_core.py @@ -1,7 +1,7 @@ import re from typing import Dict, List, Optional, Sequence, Tuple, Union -from .samples import Exemplar, Sample, Timestamp +from .samples import Exemplar, NativeHistogram, Sample, Timestamp METRIC_TYPES = ( 'counter', 'gauge', 'summary', 'histogram', @@ -36,11 +36,11 @@ def __init__(self, name: str, documentation: str, typ: str, unit: str = ''): self.type: str = typ self.samples: List[Sample] = [] - def add_sample(self, name: str, labels: Dict[str, str], value: float, timestamp: Optional[Union[Timestamp, float]] = None, exemplar: Optional[Exemplar] = None) -> None: + def add_sample(self, name: str, labels: Dict[str, str], value: float, timestamp: Optional[Union[Timestamp, float]] = None, exemplar: Optional[Exemplar] = None, native_histogram: Optional[NativeHistogram] = None) -> None: """Add a sample to the metric. Internal-only, do not use.""" - self.samples.append(Sample(name, labels, value, timestamp, exemplar)) + self.samples.append(Sample(name, labels, value, timestamp, exemplar, native_histogram)) def __eq__(self, other: object) -> bool: return (isinstance(other, Metric) @@ -284,7 +284,6 @@ def add_metric(self, Sample(self.name + '_sum', dict(zip(self._labelnames, labels)), sum_value, timestamp)) - class GaugeHistogramMetricFamily(Metric): """A single gauge histogram and its samples. diff --git a/prometheus_client/multiprocess.py b/prometheus_client/multiprocess.py index 7021b49a..2682190a 100644 --- a/prometheus_client/multiprocess.py +++ b/prometheus_client/multiprocess.py @@ -93,7 +93,7 @@ def _accumulate_metrics(metrics, accumulate): buckets = defaultdict(lambda: defaultdict(float)) samples_setdefault = samples.setdefault for s in metric.samples: - name, labels, value, timestamp, exemplar = s + name, labels, value, timestamp, exemplar, native_histogram_value = s if metric.type == 'gauge': without_pid_key = (name, tuple(l for l in labels if l[0] != 'pid')) if metric._multiprocess_mode in ('min', 'livemin'): diff --git a/prometheus_client/openmetrics/exposition.py b/prometheus_client/openmetrics/exposition.py index 26f3109f..1959847b 100644 --- a/prometheus_client/openmetrics/exposition.py +++ b/prometheus_client/openmetrics/exposition.py @@ -10,7 +10,9 @@ def _is_valid_exemplar_metric(metric, sample): if metric.type == 'counter' and sample.name.endswith('_total'): return True - if metric.type in ('histogram', 'gaugehistogram') and sample.name.endswith('_bucket'): + if metric.type in ('gaugehistogram') and sample.name.endswith('_bucket'): + return True + if metric.type in ('histogram') and sample.name.endswith('_bucket') or sample.name == metric.name: return True return False diff --git a/prometheus_client/openmetrics/parser.py b/prometheus_client/openmetrics/parser.py index 6128a0d3..39a44dc2 100644 --- a/prometheus_client/openmetrics/parser.py +++ b/prometheus_client/openmetrics/parser.py @@ -6,7 +6,7 @@ import re from ..metrics_core import Metric, METRIC_LABEL_NAME_RE -from ..samples import Exemplar, Sample, Timestamp +from ..samples import BucketSpan, Exemplar, NativeHistogram, Sample, Timestamp from ..utils import floatToGoString @@ -364,6 +364,99 @@ def _parse_remaining_text(text): return val, ts, exemplar +def _parse_nh_sample(text, suffixes): + labels_start = text.find("{") + # check if it's a native histogram with labels + re_nh_without_labels = re.compile(r'^[^{} ]+ {[^{}]+}$') + re_nh_with_labels = re.compile(r'[^{} ]+{[^{}]+} {[^{}]+}$') + if re_nh_with_labels.match(text): + nh_value_start = text.rindex("{") + labels_end = nh_value_start - 2 + labelstext = text[labels_start + 1:labels_end] + labels = _parse_labels(labelstext) + name_end = labels_start + name = text[:name_end] + if name.endswith(suffixes): + raise ValueError("the sample name of a native histogram with labels should have no suffixes", name) + nh_value = text[nh_value_start:] + nat_hist_value = _parse_nh_struct(nh_value) + return Sample(name, labels, None, None, None, nat_hist_value) + # check if it's a native histogram + if re_nh_without_labels.match(text): + nh_value_start = labels_start + nh_value = text[nh_value_start:] + name_end = nh_value_start - 1 + name = text[:name_end] + if name.endswith(suffixes): + raise ValueError("the sample name of a native histogram should have no suffixes", name) + nat_hist_value = _parse_nh_struct(nh_value) + return Sample(name, None, None, None, None, nat_hist_value) + else: + # it's not a native histogram + return + + +def _parse_nh_struct(text): + pattern = r'(\w+):\s*([^,}]+)' + + re_spans = re.compile(r'(positive_spans|negative_spans):\[(\d+:\d+,\d+:\d+)\]') + re_deltas = re.compile(r'(positive_deltas|negative_deltas):\[(-?\d+(?:,-?\d+)*)\]') + + items = dict(re.findall(pattern, text)) + spans = dict(re_spans.findall(text)) + deltas = dict(re_deltas.findall(text)) + + count_value = int(items['count']) + sum_value = int(items['sum']) + schema = int(items['schema']) + zero_threshold = float(items['zero_threshold']) + zero_count = int(items['zero_count']) + + try: + pos_spans_text = spans['positive_spans'] + elems = pos_spans_text.split(',') + arg1 = [int(x) for x in elems[0].split(':')] + arg2 = [int(x) for x in elems[1].split(':')] + pos_spans = (BucketSpan(arg1[0], arg1[1]), BucketSpan(arg2[0], arg2[1])) + except KeyError: + pos_spans = None + + try: + neg_spans_text = spans['negative_spans'] + elems = neg_spans_text.split(',') + arg1 = [int(x) for x in elems[0].split(':')] + arg2 = [int(x) for x in elems[1].split(':')] + neg_spans = (BucketSpan(arg1[0], arg1[1]), BucketSpan(arg2[0], arg2[1])) + except KeyError: + neg_spans = None + + try: + pos_deltas_text = deltas['positive_deltas'] + elems = pos_deltas_text.split(',') + pos_deltas = tuple([int(x) for x in elems]) + except KeyError: + pos_deltas = None + + try: + neg_deltas_text = deltas['negative_deltas'] + elems = neg_deltas_text.split(',') + neg_deltas = tuple([int(x) for x in elems]) + except KeyError: + neg_deltas = None + + return NativeHistogram( + count_value=count_value, + sum_value=sum_value, + schema=schema, + zero_threshold=zero_threshold, + zero_count=zero_count, + pos_spans=pos_spans, + neg_spans=neg_spans, + pos_deltas=pos_deltas, + neg_deltas=neg_deltas + ) + + def _group_for_sample(sample, name, typ): if typ == 'info': # We can't distinguish between groups for info metrics. @@ -406,6 +499,8 @@ def do_checks(): for s in samples: suffix = s.name[len(name):] g = _group_for_sample(s, name, 'histogram') + if len(suffix) == 0: + continue if g != group or s.timestamp != timestamp: if group is not None: do_checks() @@ -486,6 +581,8 @@ def build_metric(name, documentation, typ, unit, samples): metric.samples = samples return metric + is_nh = False + typ = None for line in fd: if line[-1] == '\n': line = line[:-1] @@ -518,7 +615,7 @@ def build_metric(name, documentation, typ, unit, samples): group_timestamp_samples = set() samples = [] allowed_names = [parts[2]] - + if parts[1] == 'HELP': if documentation is not None: raise ValueError("More than one HELP for metric: " + line) @@ -537,8 +634,18 @@ def build_metric(name, documentation, typ, unit, samples): else: raise ValueError("Invalid line: " + line) else: - sample = _parse_sample(line) - if sample.name not in allowed_names: + if typ == 'histogram': + # set to true to account for native histograms naming exceptions/sanitizing differences + is_nh = True + sample = _parse_nh_sample(line, tuple(type_suffixes['histogram'])) + # It's not a native histogram + if sample is None: + is_nh = False + sample = _parse_sample(line) + else: + is_nh = False + sample = _parse_sample(line) + if sample.name not in allowed_names and not is_nh: if name is not None: yield build_metric(name, documentation, typ, unit, samples) # Start an unknown metric. @@ -570,26 +677,29 @@ def build_metric(name, documentation, typ, unit, samples): or _isUncanonicalNumber(sample.labels['quantile']))): raise ValueError("Invalid quantile label: " + line) - g = tuple(sorted(_group_for_sample(sample, name, typ).items())) - if group is not None and g != group and g in seen_groups: - raise ValueError("Invalid metric grouping: " + line) - if group is not None and g == group: - if (sample.timestamp is None) != (group_timestamp is None): - raise ValueError("Mix of timestamp presence within a group: " + line) - if group_timestamp is not None and group_timestamp > sample.timestamp and typ != 'info': - raise ValueError("Timestamps went backwards within a group: " + line) + if not is_nh: + g = tuple(sorted(_group_for_sample(sample, name, typ).items())) + if group is not None and g != group and g in seen_groups: + raise ValueError("Invalid metric grouping: " + line) + if group is not None and g == group: + if (sample.timestamp is None) != (group_timestamp is None): + raise ValueError("Mix of timestamp presence within a group: " + line) + if group_timestamp is not None and group_timestamp > sample.timestamp and typ != 'info': + raise ValueError("Timestamps went backwards within a group: " + line) + else: + group_timestamp_samples = set() + + series_id = (sample.name, tuple(sorted(sample.labels.items()))) + if sample.timestamp != group_timestamp or series_id not in group_timestamp_samples: + # Not a duplicate due to timestamp truncation. + samples.append(sample) + group_timestamp_samples.add(series_id) + + group = g + group_timestamp = sample.timestamp + seen_groups.add(g) else: - group_timestamp_samples = set() - - series_id = (sample.name, tuple(sorted(sample.labels.items()))) - if sample.timestamp != group_timestamp or series_id not in group_timestamp_samples: - # Not a duplicate due to timestamp truncation. samples.append(sample) - group_timestamp_samples.add(series_id) - - group = g - group_timestamp = sample.timestamp - seen_groups.add(g) if typ == 'stateset' and sample.value not in [0, 1]: raise ValueError("Stateset samples can only have values zero and one: " + line) @@ -606,7 +716,7 @@ def build_metric(name, documentation, typ, unit, samples): (typ in ['histogram', 'gaugehistogram'] and sample.name.endswith('_bucket')) or (typ in ['counter'] and sample.name.endswith('_total'))): raise ValueError("Invalid line only histogram/gaugehistogram buckets and counters can have exemplars: " + line) - + if name is not None: yield build_metric(name, documentation, typ, unit, samples) diff --git a/prometheus_client/samples.py b/prometheus_client/samples.py index 53c47264..b57a5d48 100644 --- a/prometheus_client/samples.py +++ b/prometheus_client/samples.py @@ -1,4 +1,4 @@ -from typing import Dict, NamedTuple, Optional, Union +from typing import Dict, NamedTuple, Optional, Sequence, Tuple, Union class Timestamp: @@ -34,6 +34,25 @@ def __lt__(self, other: "Timestamp") -> bool: return self.nsec < other.nsec if self.sec == other.sec else self.sec < other.sec +# BucketSpan is experimental and subject to change at any time. +class BucketSpan(NamedTuple): + offset: int + length: int + + +# NativeHistogram is experimental and subject to change at any time. +class NativeHistogram(NamedTuple): + count_value: float + sum_value: float + schema: int + zero_threshold: float + zero_count: float + pos_spans: Optional[Tuple[BucketSpan, BucketSpan]] = None + neg_spans: Optional[Tuple[BucketSpan, BucketSpan]] = None + pos_deltas: Optional[Sequence[int]] = None + neg_deltas: Optional[Sequence[int]] = None + + # Timestamp and exemplar are optional. # Value can be an int or a float. # Timestamp can be a float containing a unixtime in seconds, @@ -51,3 +70,4 @@ class Sample(NamedTuple): value: float timestamp: Optional[Union[float, Timestamp]] = None exemplar: Optional[Exemplar] = None + native_histogram: Optional[NativeHistogram] = None diff --git a/tests/openmetrics/test_parser.py b/tests/openmetrics/test_parser.py index 937aef5c..dc5e9916 100644 --- a/tests/openmetrics/test_parser.py +++ b/tests/openmetrics/test_parser.py @@ -2,9 +2,9 @@ import unittest from prometheus_client.core import ( - CollectorRegistry, CounterMetricFamily, Exemplar, + BucketSpan, CollectorRegistry, CounterMetricFamily, Exemplar, GaugeHistogramMetricFamily, GaugeMetricFamily, HistogramMetricFamily, - InfoMetricFamily, Metric, Sample, StateSetMetricFamily, + InfoMetricFamily, Metric, NativeHistogram, Sample, StateSetMetricFamily, SummaryMetricFamily, Timestamp, ) from prometheus_client.openmetrics.exposition import generate_latest @@ -175,6 +175,80 @@ def test_histogram_exemplars(self): Exemplar({"a": "2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678"}, 4, Timestamp(123, 0))) self.assertEqual([hfm], list(families)) + + def test_native_histogram(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,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, 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_with_labels(self): + families = text_string_to_metric_families("""# TYPE hist_w_labels histogram +# HELP hist_w_labels Is a basic example of a native histogram with labels +hist_w_labels{foo="bar",baz="qux"} {count:24,sum:100,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("hist_w_labels", "Is a basic example of a native histogram with labels") + hfm.add_sample("hist_w_labels", {"foo": "bar", "baz": "qux"}, 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_with_classic_histogram(self): + families = text_string_to_metric_families("""# TYPE hist_w_classic histogram +# HELP hist_w_classic Is a basic example of a native histogram coexisting with a classic histogram +hist_w_classic{foo="bar"} {count:24,sum:100,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]} +hist_w_classic_bucket{foo="bar",le="0.001"} 4 +hist_w_classic_bucket{foo="bar",le="+Inf"} 24 +hist_w_classic_count{foo="bar"} 24 +hist_w_classic_sum{foo="bar"} 100 +# EOF +""") + families = list(families) + + hfm = HistogramMetricFamily("hist_w_classic", "Is a basic example of a native histogram coexisting with a classic histogram") + hfm.add_sample("hist_w_classic", {"foo": "bar"}, 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))) + hfm.add_sample("hist_w_classic_bucket", {"foo": "bar", "le": "0.001"}, 4.0, None, None, None) + hfm.add_sample("hist_w_classic_bucket", {"foo": "bar", "le": "+Inf"}, 24.0, None, None, None) + hfm.add_sample("hist_w_classic_count", {"foo": "bar"}, 24.0, None, None, None) + hfm.add_sample("hist_w_classic_sum", {"foo": "bar"}, 100.0, None, None, None) + self.assertEqual([hfm], families) + + def test_native_plus_classic_histogram_two_labelsets(self): + families = text_string_to_metric_families("""# TYPE hist_w_classic_two_sets histogram +# HELP hist_w_classic_two_sets Is an example of a native histogram plus a classic histogram with two label sets +hist_w_classic_two_sets{foo="bar"} {count:24,sum:100,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]} +hist_w_classic_two_sets_bucket{foo="bar",le="0.001"} 4 +hist_w_classic_two_sets_bucket{foo="bar",le="+Inf"} 24 +hist_w_classic_two_sets_count{foo="bar"} 24 +hist_w_classic_two_sets_sum{foo="bar"} 100 +hist_w_classic_two_sets{foo="baz"} {count:24,sum:100,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]} +hist_w_classic_two_sets_bucket{foo="baz",le="0.001"} 4 +hist_w_classic_two_sets_bucket{foo="baz",le="+Inf"} 24 +hist_w_classic_two_sets_count{foo="baz"} 24 +hist_w_classic_two_sets_sum{foo="baz"} 100 +# EOF +""") + families = list(families) + + hfm = HistogramMetricFamily("hist_w_classic_two_sets", "Is an example of a native histogram plus a classic histogram with two label sets") + hfm.add_sample("hist_w_classic_two_sets", {"foo": "bar"}, 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))) + hfm.add_sample("hist_w_classic_two_sets_bucket", {"foo": "bar", "le": "0.001"}, 4.0, None, None, None) + hfm.add_sample("hist_w_classic_two_sets_bucket", {"foo": "bar", "le": "+Inf"}, 24.0, None, None, None) + hfm.add_sample("hist_w_classic_two_sets_count", {"foo": "bar"}, 24.0, None, None, None) + hfm.add_sample("hist_w_classic_two_sets_sum", {"foo": "bar"}, 100.0, None, None, None) + hfm.add_sample("hist_w_classic_two_sets", {"foo": "baz"}, 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))) + hfm.add_sample("hist_w_classic_two_sets_bucket", {"foo": "baz", "le": "0.001"}, 4.0, None, None, None) + hfm.add_sample("hist_w_classic_two_sets_bucket", {"foo": "baz", "le": "+Inf"}, 24.0, None, None, None) + hfm.add_sample("hist_w_classic_two_sets_count", {"foo": "baz"}, 24.0, None, None, None) + hfm.add_sample("hist_w_classic_two_sets_sum", {"foo": "baz"}, 100.0, None, None, None) + self.assertEqual([hfm], families) def test_simple_gaugehistogram(self): families = text_string_to_metric_families("""# TYPE a gaugehistogram @@ -805,6 +879,7 @@ def test_invalid_input(self): ('# TYPE a histogram\na_bucket{le="+INF"} 0\n# EOF\n'), ('# TYPE a histogram\na_bucket{le="2"} 0\na_bucket{le="1"} 0\na_bucket{le="+Inf"} 0\n# EOF\n'), ('# TYPE a histogram\na_bucket{le="1"} 1\na_bucket{le="2"} 1\na_bucket{le="+Inf"} 0\n# EOF\n'), + ('# TYPE a histogram\na_bucket {} {}'), # Bad grouping or ordering. ('# TYPE a histogram\na_sum{a="1"} 0\na_sum{a="2"} 0\na_count{a="1"} 0\n# EOF\n'), ('# TYPE a histogram\na_bucket{a="1",le="1"} 0\na_bucket{a="2",le="+Inf""} ' From 37cd8735709249c671a4f74770c29f790da67fe7 Mon Sep 17 00:00:00 2001 From: David Tulloh Date: Tue, 15 Oct 2024 01:56:18 +1100 Subject: [PATCH 17/32] Add exemplar support to CounterMetricFamily (#1063) Fixes #1062 Signed-off-by: David Tulloh --- prometheus_client/metrics_core.py | 6 ++++-- tests/test_core.py | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/prometheus_client/metrics_core.py b/prometheus_client/metrics_core.py index 19166e1d..b09cea04 100644 --- a/prometheus_client/metrics_core.py +++ b/prometheus_client/metrics_core.py @@ -116,6 +116,7 @@ def __init__(self, labels: Optional[Sequence[str]] = None, created: Optional[float] = None, unit: str = '', + exemplar: Optional[Exemplar] = None, ): # Glue code for pre-OpenMetrics metrics. if name.endswith('_total'): @@ -127,13 +128,14 @@ def __init__(self, labels = [] self._labelnames = tuple(labels) if value is not None: - self.add_metric([], value, created) + self.add_metric([], value, created, exemplar=exemplar) def add_metric(self, labels: Sequence[str], value: float, created: Optional[float] = None, timestamp: Optional[Union[Timestamp, float]] = None, + exemplar: Optional[Exemplar] = None, ) -> None: """Add a metric to the metric family. @@ -142,7 +144,7 @@ def add_metric(self, value: The value of the metric created: Optional unix timestamp the child was created at. """ - self.samples.append(Sample(self.name + '_total', dict(zip(self._labelnames, labels)), value, timestamp)) + self.samples.append(Sample(self.name + '_total', dict(zip(self._labelnames, labels)), value, timestamp, exemplar)) if created is not None: self.samples.append(Sample(self.name + '_created', dict(zip(self._labelnames, labels)), created, timestamp)) diff --git a/tests/test_core.py b/tests/test_core.py index f80fb882..056d8e58 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -724,6 +724,21 @@ def test_counter_labels(self): self.custom_collector(cmf) self.assertEqual(2, self.registry.get_sample_value('c_total', {'a': 'b', 'c_total': 'd'})) + def test_counter_exemplars_oneline(self): + cmf = CounterMetricFamily('c_total', 'help', value=23, exemplar={"bob": "osbourne"}) + self.custom_collector(cmf) + sample = [c.samples for c in self.registry.collect()][0][0] + self.assertDictEqual({"bob": "osbourne"}, sample.exemplar) + + def test_counter_exemplars_add(self): + cmf = CounterMetricFamily('c_total', 'help') + cmf.add_metric([], 12, exemplar={"bob": "osbourne"}, created=23) + self.custom_collector(cmf) + total_sample, created_sample = [c.samples for c in self.registry.collect()][0] + self.assertEqual("c_created", created_sample.name) + self.assertDictEqual({"bob": "osbourne"}, total_sample.exemplar) + self.assertIsNone(created_sample.exemplar) + def test_gauge(self): self.custom_collector(GaugeMetricFamily('g', 'help', value=1)) self.assertEqual(1, self.registry.get_sample_value('g', {})) From c89624f784c344803699d3bdcfb5c24b5e63307b Mon Sep 17 00:00:00 2001 From: "Ethan S. Chen" Date: Sat, 26 Oct 2024 04:03:38 +0800 Subject: [PATCH 18/32] Fix write_to_textfile leaves back temp files on errors (#1044) (#1066) Signed-off-by: Ethan S. Chen --- prometheus_client/exposition.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/prometheus_client/exposition.py b/prometheus_client/exposition.py index fab139df..4bcf1c70 100644 --- a/prometheus_client/exposition.py +++ b/prometheus_client/exposition.py @@ -367,14 +367,19 @@ def write_to_textfile(path: str, registry: CollectorRegistry) -> None: This is intended for use with the Node exporter textfile collector. The path must end in .prom for the textfile collector to process it.""" tmppath = f'{path}.{os.getpid()}.{threading.current_thread().ident}' - with open(tmppath, 'wb') as f: - f.write(generate_latest(registry)) + try: + with open(tmppath, 'wb') as f: + f.write(generate_latest(registry)) - # rename(2) is atomic but fails on Windows if the destination file exists - if os.name == 'nt': - os.replace(tmppath, path) - else: - os.rename(tmppath, path) + # rename(2) is atomic but fails on Windows if the destination file exists + if os.name == 'nt': + os.replace(tmppath, path) + else: + os.rename(tmppath, path) + except Exception: + if os.path.exists(tmppath): + os.remove(tmppath) + raise def _make_handler( From 33e682846b2d8f60ea34fc60c41b448b22405c4a Mon Sep 17 00:00:00 2001 From: Owen Williams Date: Mon, 2 Dec 2024 13:18:53 -0500 Subject: [PATCH 19/32] Support UTF-8 in metric creation, parsing, and exposition (#1070) part of https://github.com/prometheus/client_python/issues/1013 Signed-off-by: Owen Williams --- prometheus_client/exposition.py | 34 ++- prometheus_client/metrics.py | 38 +-- prometheus_client/metrics_core.py | 8 +- prometheus_client/openmetrics/exposition.py | 73 +++-- prometheus_client/openmetrics/parser.py | 278 +++++++------------- prometheus_client/parser.py | 268 ++++++++++++++----- prometheus_client/validation.py | 123 +++++++++ tests/openmetrics/test_exposition.py | 6 + tests/openmetrics/test_parser.py | 162 +++++++----- tests/test_core.py | 30 ++- tests/test_exposition.py | 11 + tests/test_parser.py | 25 ++ 12 files changed, 675 insertions(+), 381 deletions(-) create mode 100644 prometheus_client/validation.py diff --git a/prometheus_client/exposition.py b/prometheus_client/exposition.py index 4bcf1c70..7427cf93 100644 --- a/prometheus_client/exposition.py +++ b/prometheus_client/exposition.py @@ -20,6 +20,7 @@ from .openmetrics import exposition as openmetrics from .registry import CollectorRegistry, REGISTRY from .utils import floatToGoString +from .validation import _is_valid_legacy_metric_name __all__ = ( 'CONTENT_TYPE_LATEST', @@ -247,19 +248,26 @@ class TmpServer(ThreadingWSGIServer): def generate_latest(registry: CollectorRegistry = REGISTRY) -> bytes: """Returns the metrics from the registry in latest text format as a string.""" - def sample_line(line): - if line.labels: - labelstr = '{{{0}}}'.format(','.join( + def sample_line(samples): + if samples.labels: + labelstr = '{0}'.format(','.join( ['{}="{}"'.format( - k, v.replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"')) - for k, v in sorted(line.labels.items())])) + openmetrics.escape_label_name(k), openmetrics._escape(v)) + for k, v in sorted(samples.labels.items())])) else: labelstr = '' timestamp = '' - if line.timestamp is not None: + if samples.timestamp is not None: # Convert to milliseconds. - timestamp = f' {int(float(line.timestamp) * 1000):d}' - return f'{line.name}{labelstr} {floatToGoString(line.value)}{timestamp}\n' + timestamp = f' {int(float(samples.timestamp) * 1000):d}' + if _is_valid_legacy_metric_name(samples.name): + if labelstr: + labelstr = '{{{0}}}'.format(labelstr) + return f'{samples.name}{labelstr} {floatToGoString(samples.value)}{timestamp}\n' + maybe_comma = '' + if labelstr: + maybe_comma = ',' + return f'{{{openmetrics.escape_metric_name(samples.name)}{maybe_comma}{labelstr}}} {floatToGoString(samples.value)}{timestamp}\n' output = [] for metric in registry.collect(): @@ -282,8 +290,8 @@ def sample_line(line): mtype = 'untyped' output.append('# HELP {} {}\n'.format( - mname, metric.documentation.replace('\\', r'\\').replace('\n', r'\n'))) - output.append(f'# TYPE {mname} {mtype}\n') + openmetrics.escape_metric_name(mname), metric.documentation.replace('\\', r'\\').replace('\n', r'\n'))) + output.append(f'# TYPE {openmetrics.escape_metric_name(mname)} {mtype}\n') om_samples: Dict[str, List[str]] = {} for s in metric.samples: @@ -299,9 +307,9 @@ def sample_line(line): raise for suffix, lines in sorted(om_samples.items()): - output.append('# HELP {}{} {}\n'.format(metric.name, suffix, - metric.documentation.replace('\\', r'\\').replace('\n', r'\n'))) - output.append(f'# TYPE {metric.name}{suffix} gauge\n') + output.append('# HELP {} {}\n'.format(openmetrics.escape_metric_name(metric.name + suffix), + metric.documentation.replace('\\', r'\\').replace('\n', r'\n'))) + output.append(f'# TYPE {openmetrics.escape_metric_name(metric.name + suffix)} gauge\n') output.extend(lines) return ''.join(output).encode('utf-8') diff --git a/prometheus_client/metrics.py b/prometheus_client/metrics.py index cceaafda..46175860 100644 --- a/prometheus_client/metrics.py +++ b/prometheus_client/metrics.py @@ -10,19 +10,21 @@ from . import values # retain this import style for testability from .context_managers import ExceptionCounter, InprogressTracker, Timer -from .metrics_core import ( - Metric, METRIC_LABEL_NAME_RE, METRIC_NAME_RE, - RESERVED_METRIC_LABEL_NAME_RE, -) +from .metrics_core import Metric from .registry import Collector, CollectorRegistry, REGISTRY from .samples import Exemplar, Sample from .utils import floatToGoString, INF +from .validation import ( + _validate_exemplar, _validate_labelnames, _validate_metric_name, +) T = TypeVar('T', bound='MetricWrapperBase') F = TypeVar("F", bound=Callable[..., Any]) def _build_full_name(metric_type, name, namespace, subsystem, unit): + if not name: + raise ValueError('Metric name should not be empty') full_name = '' if namespace: full_name += namespace + '_' @@ -38,31 +40,6 @@ def _build_full_name(metric_type, name, namespace, subsystem, unit): return full_name -def _validate_labelname(l): - if not METRIC_LABEL_NAME_RE.match(l): - raise ValueError('Invalid label metric name: ' + l) - if RESERVED_METRIC_LABEL_NAME_RE.match(l): - raise ValueError('Reserved label metric name: ' + l) - - -def _validate_labelnames(cls, labelnames): - labelnames = tuple(labelnames) - for l in labelnames: - _validate_labelname(l) - if l in cls._reserved_labelnames: - raise ValueError('Reserved label metric name: ' + l) - return labelnames - - -def _validate_exemplar(exemplar): - runes = 0 - for k, v in exemplar.items(): - _validate_labelname(k) - runes += len(k) - runes += len(v) - if runes > 128: - raise ValueError('Exemplar labels have %d UTF-8 characters, exceeding the limit of 128') - def _get_use_created() -> bool: return os.environ.get("PROMETHEUS_DISABLE_CREATED_SERIES", 'False').lower() not in ('true', '1', 't') @@ -139,8 +116,7 @@ def __init__(self: T, self._documentation = documentation self._unit = unit - if not METRIC_NAME_RE.match(self._name): - raise ValueError('Invalid metric name: ' + self._name) + _validate_metric_name(self._name) if self._is_parent(): # Prepare the fields needed for child metrics. diff --git a/prometheus_client/metrics_core.py b/prometheus_client/metrics_core.py index b09cea04..27d1712d 100644 --- a/prometheus_client/metrics_core.py +++ b/prometheus_client/metrics_core.py @@ -1,15 +1,12 @@ -import re from typing import Dict, List, Optional, Sequence, Tuple, Union from .samples import Exemplar, NativeHistogram, Sample, Timestamp +from .validation import _validate_metric_name METRIC_TYPES = ( 'counter', 'gauge', 'summary', 'histogram', 'gaugehistogram', 'unknown', 'info', 'stateset', ) -METRIC_NAME_RE = re.compile(r'^[a-zA-Z_:][a-zA-Z0-9_:]*$') -METRIC_LABEL_NAME_RE = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*$') -RESERVED_METRIC_LABEL_NAME_RE = re.compile(r'^__.*$') class Metric: @@ -24,8 +21,7 @@ class Metric: def __init__(self, name: str, documentation: str, typ: str, unit: str = ''): if unit and not name.endswith("_" + unit): name += "_" + unit - if not METRIC_NAME_RE.match(name): - raise ValueError('Invalid metric name: ' + name) + _validate_metric_name(name) self.name: str = name self.documentation: str = documentation self.unit: str = unit diff --git a/prometheus_client/openmetrics/exposition.py b/prometheus_client/openmetrics/exposition.py index 1959847b..84600605 100644 --- a/prometheus_client/openmetrics/exposition.py +++ b/prometheus_client/openmetrics/exposition.py @@ -2,6 +2,9 @@ from ..utils import floatToGoString +from ..validation import ( + _is_valid_legacy_labelname, _is_valid_legacy_metric_name, +) CONTENT_TYPE_LATEST = 'application/openmetrics-text; version=1.0.0; charset=utf-8' """Content type of the latest OpenMetrics text format""" @@ -24,18 +27,27 @@ def generate_latest(registry): try: mname = metric.name output.append('# HELP {} {}\n'.format( - mname, metric.documentation.replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"'))) - output.append(f'# TYPE {mname} {metric.type}\n') + escape_metric_name(mname), _escape(metric.documentation))) + output.append(f'# TYPE {escape_metric_name(mname)} {metric.type}\n') if metric.unit: - output.append(f'# UNIT {mname} {metric.unit}\n') + output.append(f'# UNIT {escape_metric_name(mname)} {metric.unit}\n') for s in metric.samples: - if s.labels: - labelstr = '{{{0}}}'.format(','.join( - ['{}="{}"'.format( - k, v.replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"')) - for k, v in sorted(s.labels.items())])) + if not _is_valid_legacy_metric_name(s.name): + labelstr = escape_metric_name(s.name) + if s.labels: + labelstr += ', ' else: labelstr = '' + + if s.labels: + items = sorted(s.labels.items()) + labelstr += ','.join( + ['{}="{}"'.format( + escape_label_name(k), _escape(v)) + for k, v in items]) + if labelstr: + labelstr = "{" + labelstr + "}" + if s.exemplar: if not _is_valid_exemplar_metric(metric, s): raise ValueError(f"Metric {metric.name} has exemplars, but is not a histogram bucket or counter") @@ -59,16 +71,47 @@ def generate_latest(registry): timestamp = '' if s.timestamp is not None: timestamp = f' {s.timestamp}' - output.append('{}{} {}{}{}\n'.format( - s.name, - labelstr, - floatToGoString(s.value), - timestamp, - exemplarstr, - )) + if _is_valid_legacy_metric_name(s.name): + output.append('{}{} {}{}{}\n'.format( + s.name, + labelstr, + floatToGoString(s.value), + timestamp, + exemplarstr, + )) + else: + output.append('{} {}{}{}\n'.format( + labelstr, + floatToGoString(s.value), + timestamp, + exemplarstr, + )) except Exception as exception: exception.args = (exception.args or ('',)) + (metric,) raise output.append('# EOF\n') return ''.join(output).encode('utf-8') + + +def escape_metric_name(s: str) -> str: + """Escapes the metric name and puts it in quotes iff the name does not + conform to the legacy Prometheus character set. + """ + if _is_valid_legacy_metric_name(s): + return s + return '"{}"'.format(_escape(s)) + + +def escape_label_name(s: str) -> str: + """Escapes the label name and puts it in quotes iff the name does not + conform to the legacy Prometheus character set. + """ + if _is_valid_legacy_labelname(s): + return s + return '"{}"'.format(_escape(s)) + + +def _escape(s: str) -> str: + """Performs backslash escaping on backslash, newline, and double-quote characters.""" + return s.replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"') diff --git a/prometheus_client/openmetrics/parser.py b/prometheus_client/openmetrics/parser.py index 39a44dc2..1d270915 100644 --- a/prometheus_client/openmetrics/parser.py +++ b/prometheus_client/openmetrics/parser.py @@ -5,9 +5,14 @@ import math import re -from ..metrics_core import Metric, METRIC_LABEL_NAME_RE +from ..metrics_core import Metric +from ..parser import ( + _last_unquoted_char, _next_unquoted_char, _parse_value, _split_quoted, + _unquote_unescape, parse_labels, +) from ..samples import BucketSpan, Exemplar, NativeHistogram, Sample, Timestamp from ..utils import floatToGoString +from ..validation import _is_valid_legacy_metric_name, _validate_metric_name def text_string_to_metric_families(text): @@ -73,16 +78,6 @@ def _unescape_help(text): return ''.join(result) -def _parse_value(value): - value = ''.join(value) - if value != value.strip() or '_' in value: - raise ValueError(f"Invalid value: {value!r}") - try: - return int(value) - except ValueError: - return float(value) - - def _parse_timestamp(timestamp): timestamp = ''.join(timestamp) if not timestamp: @@ -113,165 +108,31 @@ def _is_character_escaped(s, charpos): return num_bslashes % 2 == 1 -def _parse_labels_with_state_machine(text): - # The { has already been parsed. - state = 'startoflabelname' - labelname = [] - labelvalue = [] - labels = {} - labels_len = 0 - - for char in text: - if state == 'startoflabelname': - if char == '}': - state = 'endoflabels' - else: - state = 'labelname' - labelname.append(char) - elif state == 'labelname': - if char == '=': - state = 'labelvaluequote' - else: - labelname.append(char) - elif state == 'labelvaluequote': - if char == '"': - state = 'labelvalue' - else: - raise ValueError("Invalid line: " + text) - elif state == 'labelvalue': - if char == '\\': - state = 'labelvalueslash' - elif char == '"': - ln = ''.join(labelname) - if not METRIC_LABEL_NAME_RE.match(ln): - raise ValueError("Invalid line, bad label name: " + text) - if ln in labels: - raise ValueError("Invalid line, duplicate label name: " + text) - labels[ln] = ''.join(labelvalue) - labelname = [] - labelvalue = [] - state = 'endoflabelvalue' - else: - labelvalue.append(char) - elif state == 'endoflabelvalue': - if char == ',': - state = 'labelname' - elif char == '}': - state = 'endoflabels' - else: - raise ValueError("Invalid line: " + text) - elif state == 'labelvalueslash': - state = 'labelvalue' - if char == '\\': - labelvalue.append('\\') - elif char == 'n': - labelvalue.append('\n') - elif char == '"': - labelvalue.append('"') - else: - labelvalue.append('\\' + char) - elif state == 'endoflabels': - if char == ' ': - break - else: - raise ValueError("Invalid line: " + text) - labels_len += 1 - return labels, labels_len - - -def _parse_labels(text): - labels = {} - - # Raise error if we don't have valid labels - if text and "=" not in text: - raise ValueError - - # Copy original labels - sub_labels = text - try: - # Process one label at a time - while sub_labels: - # The label name is before the equal - value_start = sub_labels.index("=") - label_name = sub_labels[:value_start] - sub_labels = sub_labels[value_start + 1:] - - # Check for missing quotes - if not sub_labels or sub_labels[0] != '"': - raise ValueError - - # The first quote is guaranteed to be after the equal - value_substr = sub_labels[1:] - - # Check for extra commas - if not label_name or label_name[0] == ',': - raise ValueError - if not value_substr or value_substr[-1] == ',': - raise ValueError - - # Find the last unescaped quote - i = 0 - while i < len(value_substr): - i = value_substr.index('"', i) - if not _is_character_escaped(value_substr[:i], i): - break - i += 1 - - # The label value is between the first and last quote - quote_end = i + 1 - label_value = sub_labels[1:quote_end] - # Replace escaping if needed - if "\\" in label_value: - label_value = _replace_escaping(label_value) - if not METRIC_LABEL_NAME_RE.match(label_name): - raise ValueError("invalid line, bad label name: " + text) - if label_name in labels: - raise ValueError("invalid line, duplicate label name: " + text) - labels[label_name] = label_value - - # Remove the processed label from the sub-slice for next iteration - sub_labels = sub_labels[quote_end + 1:] - if sub_labels.startswith(","): - next_comma = 1 - else: - next_comma = 0 - sub_labels = sub_labels[next_comma:] - - # Check for missing commas - if sub_labels and next_comma == 0: - raise ValueError - - return labels - - except ValueError: - raise ValueError("Invalid labels: " + text) - - def _parse_sample(text): separator = " # " # Detect the labels in the text - label_start = text.find("{") + label_start = _next_unquoted_char(text, '{') if label_start == -1 or separator in text[:label_start]: # We don't have labels, but there could be an exemplar. - name_end = text.index(" ") + name_end = _next_unquoted_char(text, ' ') name = text[:name_end] + if not _is_valid_legacy_metric_name(name): + raise ValueError("invalid metric name:" + text) # Parse the remaining text after the name remaining_text = text[name_end + 1:] value, timestamp, exemplar = _parse_remaining_text(remaining_text) return Sample(name, {}, value, timestamp, exemplar) - # The name is before the labels name = text[:label_start] - if separator not in text: - # Line doesn't contain an exemplar - # We can use `rindex` to find `label_end` - label_end = text.rindex("}") - label = text[label_start + 1:label_end] - labels = _parse_labels(label) - else: - # Line potentially contains an exemplar - # Fallback to parsing labels with a state machine - labels, labels_len = _parse_labels_with_state_machine(text[label_start + 1:]) - label_end = labels_len + len(name) + label_end = _next_unquoted_char(text, '}') + labels = parse_labels(text[label_start + 1:label_end], True) + if not name: + # Name might be in the labels + if '__name__' not in labels: + raise ValueError + name = labels['__name__'] + del labels['__name__'] + elif '__name__' in labels: + raise ValueError("metric name specified more than once") # Parsing labels succeeded, continue parsing the remaining text remaining_text = text[label_end + 2:] value, timestamp, exemplar = _parse_remaining_text(remaining_text) @@ -294,7 +155,12 @@ def _parse_remaining_text(text): text = split_text[1] it = iter(text) + in_quotes = False for char in it: + if char == '"': + in_quotes = not in_quotes + if in_quotes: + continue if state == 'timestamp': if char == '#' and not timestamp: state = 'exemplarspace' @@ -314,8 +180,9 @@ def _parse_remaining_text(text): raise ValueError("Invalid line: " + text) elif state == 'exemplarstartoflabels': if char == '{': - label_start, label_end = text.index("{"), text.rindex("}") - exemplar_labels = _parse_labels(text[label_start + 1:label_end]) + label_start = _next_unquoted_char(text, '{') + label_end = _last_unquoted_char(text, '}') + exemplar_labels = parse_labels(text[label_start + 1:label_end], True) state = 'exemplarparsedlabels' else: raise ValueError("Invalid line: " + text) @@ -365,35 +232,77 @@ def _parse_remaining_text(text): def _parse_nh_sample(text, suffixes): - labels_start = text.find("{") - # check if it's a native histogram with labels - re_nh_without_labels = re.compile(r'^[^{} ]+ {[^{}]+}$') - re_nh_with_labels = re.compile(r'[^{} ]+{[^{}]+} {[^{}]+}$') - if re_nh_with_labels.match(text): - nh_value_start = text.rindex("{") - labels_end = nh_value_start - 2 + """Determines if the line has a native histogram sample, and parses it if so.""" + labels_start = _next_unquoted_char(text, '{') + labels_end = -1 + + # Finding a native histogram sample requires careful parsing of + # possibly-quoted text, which can appear in metric names, label names, and + # values. + # + # First, we need to determine if there are metric labels. Find the space + # between the metric definition and the rest of the line. Look for unquoted + # space or {. + i = 0 + has_metric_labels = False + i = _next_unquoted_char(text, ' {') + if i == -1: + return + + # If the first unquoted char was a {, then that is the metric labels (which + # could contain a UTF-8 metric name). + if text[i] == '{': + has_metric_labels = True + # Consume the labels -- jump ahead to the close bracket. + labels_end = i = _next_unquoted_char(text, '}', i) + if labels_end == -1: + raise ValueError + + # If there is no subsequent unquoted {, then it's definitely not a nh. + nh_value_start = _next_unquoted_char(text, '{', i + 1) + if nh_value_start == -1: + return + + # Edge case: if there is an unquoted # between the metric definition and the {, + # then this is actually an exemplar + exemplar = _next_unquoted_char(text, '#', i + 1) + if exemplar != -1 and exemplar < nh_value_start: + return + + nh_value_end = _next_unquoted_char(text, '}', nh_value_start) + if nh_value_end == -1: + raise ValueError + + if has_metric_labels: labelstext = text[labels_start + 1:labels_end] - labels = _parse_labels(labelstext) + labels = parse_labels(labelstext, True) name_end = labels_start name = text[:name_end] if name.endswith(suffixes): - raise ValueError("the sample name of a native histogram with labels should have no suffixes", name) + raise ValueError("the sample name of a native histogram with labels should have no suffixes", name) + if not name: + # Name might be in the labels + if '__name__' not in labels: + raise ValueError + name = labels['__name__'] + del labels['__name__'] + # Edge case: the only "label" is the name definition. + if not labels: + labels = None + nh_value = text[nh_value_start:] nat_hist_value = _parse_nh_struct(nh_value) return Sample(name, labels, None, None, None, nat_hist_value) # check if it's a native histogram - if re_nh_without_labels.match(text): - nh_value_start = labels_start + else: nh_value = text[nh_value_start:] name_end = nh_value_start - 1 name = text[:name_end] if name.endswith(suffixes): raise ValueError("the sample name of a native histogram should have no suffixes", name) + # Not possible for UTF-8 name here, that would have been caught as having a labelset. nat_hist_value = _parse_nh_struct(nh_value) return Sample(name, None, None, None, None, nat_hist_value) - else: - # it's not a native histogram - return def _parse_nh_struct(text): @@ -576,6 +485,7 @@ def build_metric(name, documentation, typ, unit, samples): raise ValueError("Units not allowed for this metric type: " + name) if typ in ['histogram', 'gaugehistogram']: _check_histogram(samples, name) + _validate_metric_name(name) metric = Metric(name, documentation, typ, unit) # TODO: check labelvalues are valid utf8 metric.samples = samples @@ -596,16 +506,19 @@ def build_metric(name, documentation, typ, unit, samples): if line == '# EOF': eof = True elif line.startswith('#'): - parts = line.split(' ', 3) + parts = _split_quoted(line, ' ', 3) if len(parts) < 4: raise ValueError("Invalid line: " + line) - if parts[2] == name and samples: + candidate_name, quoted = _unquote_unescape(parts[2]) + if not quoted and not _is_valid_legacy_metric_name(candidate_name): + raise ValueError + if candidate_name == name and samples: raise ValueError("Received metadata after samples: " + line) - if parts[2] != name: + if candidate_name != name: if name is not None: yield build_metric(name, documentation, typ, unit, samples) # New metric - name = parts[2] + name = candidate_name unit = None typ = None documentation = None @@ -614,7 +527,7 @@ def build_metric(name, documentation, typ, unit, samples): group_timestamp = None group_timestamp_samples = set() samples = [] - allowed_names = [parts[2]] + allowed_names = [candidate_name] if parts[1] == 'HELP': if documentation is not None: @@ -649,7 +562,10 @@ def build_metric(name, documentation, typ, unit, samples): if name is not None: yield build_metric(name, documentation, typ, unit, samples) # Start an unknown metric. - name = sample.name + candidate_name, quoted = _unquote_unescape(sample.name) + if not quoted and not _is_valid_legacy_metric_name(candidate_name): + raise ValueError + name = candidate_name documentation = None unit = None typ = 'unknown' diff --git a/prometheus_client/parser.py b/prometheus_client/parser.py index dc8e30df..92d66723 100644 --- a/prometheus_client/parser.py +++ b/prometheus_client/parser.py @@ -1,9 +1,13 @@ import io as StringIO import re +import string from typing import Dict, Iterable, List, Match, Optional, TextIO, Tuple from .metrics_core import Metric from .samples import Sample +from .validation import ( + _is_valid_legacy_metric_name, _validate_labelname, _validate_metric_name, +) def text_string_to_metric_families(text: str) -> Iterable[Metric]: @@ -45,54 +49,169 @@ def _is_character_escaped(s: str, charpos: int) -> bool: return num_bslashes % 2 == 1 -def _parse_labels(labels_string: str) -> Dict[str, str]: +def parse_labels(labels_string: str, openmetrics: bool = False) -> Dict[str, str]: labels: Dict[str, str] = {} - # Return if we don't have valid labels - if "=" not in labels_string: - return labels - - escaping = False - if "\\" in labels_string: - escaping = True # Copy original labels - sub_labels = labels_string + sub_labels = labels_string.strip() + if openmetrics and sub_labels and sub_labels[0] == ',': + raise ValueError("leading comma: " + labels_string) try: # Process one label at a time while sub_labels: - # The label name is before the equal - value_start = sub_labels.index("=") - label_name = sub_labels[:value_start] - sub_labels = sub_labels[value_start + 1:].lstrip() - # Find the first quote after the equal - quote_start = sub_labels.index('"') + 1 - value_substr = sub_labels[quote_start:] - - # Find the last unescaped quote - i = 0 - while i < len(value_substr): - i = value_substr.index('"', i) - if not _is_character_escaped(value_substr, i): + # The label name is before the equal, or if there's no equal, that's the + # metric name. + + term, sub_labels = _next_term(sub_labels, openmetrics) + if not term: + if openmetrics: + raise ValueError("empty term in line: " + labels_string) + continue + + quoted_name = False + operator_pos = _next_unquoted_char(term, '=') + if operator_pos == -1: + quoted_name = True + label_name = "__name__" + else: + value_start = _next_unquoted_char(term, '=') + label_name, quoted_name = _unquote_unescape(term[:value_start]) + term = term[value_start + 1:] + + if not quoted_name and not _is_valid_legacy_metric_name(label_name): + raise ValueError("unquoted UTF-8 metric name") + + # Check for missing quotes + term = term.strip() + if not term or term[0] != '"': + raise ValueError + + # The first quote is guaranteed to be after the equal. + # Find the last unescaped quote. + i = 1 + while i < len(term): + i = term.index('"', i) + if not _is_character_escaped(term[:i], i): break i += 1 # The label value is between the first and last quote quote_end = i + 1 - label_value = sub_labels[quote_start:quote_end] - # Replace escaping if needed - if escaping: - label_value = _replace_escaping(label_value) - labels[label_name.strip()] = label_value - - # Remove the processed label from the sub-slice for next iteration - sub_labels = sub_labels[quote_end + 1:] - next_comma = sub_labels.find(",") + 1 - sub_labels = sub_labels[next_comma:].lstrip() - + if quote_end != len(term): + raise ValueError("unexpected text after quote: " + labels_string) + label_value, _ = _unquote_unescape(term[:quote_end]) + if label_name == '__name__': + _validate_metric_name(label_name) + else: + _validate_labelname(label_name) + if label_name in labels: + raise ValueError("invalid line, duplicate label name: " + labels_string) + labels[label_name] = label_value return labels - except ValueError: - raise ValueError("Invalid labels: %s" % labels_string) + raise ValueError("Invalid labels: " + labels_string) + + +def _next_term(text: str, openmetrics: bool) -> Tuple[str, str]: + """Extract the next comma-separated label term from the text. + + Returns the stripped term and the stripped remainder of the string, + including the comma. + + Raises ValueError if the term is empty and we're in openmetrics mode. + """ + + # There may be a leading comma, which is fine here. + if text[0] == ',': + text = text[1:] + if not text: + return "", "" + if text[0] == ',': + raise ValueError("multiple commas") + splitpos = _next_unquoted_char(text, ',}') + if splitpos == -1: + splitpos = len(text) + term = text[:splitpos] + if not term and openmetrics: + raise ValueError("empty term:", term) + + sublabels = text[splitpos:] + return term.strip(), sublabels.strip() + + +def _next_unquoted_char(text: str, chs: str, startidx: int = 0) -> int: + """Return position of next unquoted character in tuple, or -1 if not found. + + It is always assumed that the first character being checked is not already + inside quotes. + """ + i = startidx + in_quotes = False + if chs is None: + chs = string.whitespace + while i < len(text): + if text[i] == '"' and not _is_character_escaped(text, i): + in_quotes = not in_quotes + if not in_quotes: + if text[i] in chs: + return i + i += 1 + return -1 + + +def _last_unquoted_char(text: str, chs: str) -> int: + """Return position of last unquoted character in list, or -1 if not found.""" + i = len(text) - 1 + in_quotes = False + if chs is None: + chs = string.whitespace + while i > 0: + if text[i] == '"' and not _is_character_escaped(text, i): + in_quotes = not in_quotes + + if not in_quotes: + if text[i] in chs: + return i + i -= 1 + return -1 + + +def _split_quoted(text, separator, maxsplit=0): + """Splits on split_ch similarly to strings.split, skipping separators if + they are inside quotes. + """ + + tokens = [''] + x = 0 + while x < len(text): + split_pos = _next_unquoted_char(text, separator, x) + if split_pos == -1: + tokens[-1] = text[x:] + x = len(text) + continue + if maxsplit > 0 and len(tokens) > maxsplit: + tokens[-1] = text[x:] + break + tokens[-1] = text[x:split_pos] + x = split_pos + 1 + tokens.append('') + return tokens + + +def _unquote_unescape(text): + """Returns the string, and true if it was quoted.""" + if not text: + return text, False + quoted = False + text = text.strip() + if text[0] == '"': + if len(text) == 1 or text[-1] != '"': + raise ValueError("missing close quote") + text = text[1:-1] + quoted = True + if "\\" in text: + text = _replace_escaping(text) + return text, quoted # If we have multiple values only consider the first @@ -104,34 +223,50 @@ def _parse_value_and_timestamp(s: str) -> Tuple[float, Optional[float]]: values = [value.strip() for value in s.split(separator) if value.strip()] if not values: return float(s), None - value = float(values[0]) - timestamp = (float(values[-1]) / 1000) if len(values) > 1 else None + value = _parse_value(values[0]) + timestamp = (_parse_value(values[-1]) / 1000) if len(values) > 1 else None return value, timestamp -def _parse_sample(text: str) -> Sample: - # Detect the labels in the text +def _parse_value(value): + value = ''.join(value) + if value != value.strip() or '_' in value: + raise ValueError(f"Invalid value: {value!r}") try: - label_start, label_end = text.index("{"), text.rindex("}") - # The name is before the labels - 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) - value, timestamp = _parse_value_and_timestamp(text[label_end + 1:]) - return Sample(name, _parse_labels(label), value, timestamp) - - # We don't have labels + return int(value) except ValueError: - # Detect what separator is used - separator = " " - if separator not in text: - separator = "\t" - name_end = text.index(separator) - name = text[:name_end] - # The value is after the name - value, timestamp = _parse_value_and_timestamp(text[name_end:]) + return float(value) + + +def _parse_sample(text): + separator = " # " + # Detect the labels in the text + label_start = _next_unquoted_char(text, '{') + if label_start == -1 or separator in text[:label_start]: + # We don't have labels, but there could be an exemplar. + name_end = _next_unquoted_char(text, ' \t') + name = text[:name_end].strip() + if not _is_valid_legacy_metric_name(name): + raise ValueError("invalid metric name:" + text) + # Parse the remaining text after the name + remaining_text = text[name_end + 1:] + value, timestamp = _parse_value_and_timestamp(remaining_text) return Sample(name, {}, value, timestamp) + name = text[:label_start].strip() + label_end = _next_unquoted_char(text, '}') + labels = parse_labels(text[label_start + 1:label_end], False) + if not name: + # Name might be in the labels + if '__name__' not in labels: + raise ValueError + name = labels['__name__'] + del labels['__name__'] + elif '__name__' in labels: + raise ValueError("metric name specified more than once") + # Parsing labels succeeded, continue parsing the remaining text + remaining_text = text[label_end + 1:] + value, timestamp = _parse_value_and_timestamp(remaining_text) + return Sample(name, labels, value, timestamp) def text_fd_to_metric_families(fd: TextIO) -> Iterable[Metric]: @@ -168,28 +303,35 @@ def build_metric(name: str, documentation: str, typ: str, samples: List[Sample]) line = line.strip() if line.startswith('#'): - parts = line.split(None, 3) + parts = _split_quoted(line, None, 3) if len(parts) < 2: continue + candidate_name, quoted = '', False + if len(parts) > 2: + candidate_name, quoted = _unquote_unescape(parts[2]) + if not quoted and not _is_valid_legacy_metric_name(candidate_name): + raise ValueError if parts[1] == 'HELP': - if parts[2] != name: + if candidate_name != name: if name != '': yield build_metric(name, documentation, typ, samples) # New metric - name = parts[2] + name = candidate_name typ = 'untyped' samples = [] - allowed_names = [parts[2]] + allowed_names = [candidate_name] if len(parts) == 4: documentation = _replace_help_escaping(parts[3]) else: documentation = '' elif parts[1] == 'TYPE': - if parts[2] != name: + if len(parts) < 4: + raise ValueError + if candidate_name != name: if name != '': yield build_metric(name, documentation, typ, samples) # New metric - name = parts[2] + name = candidate_name documentation = '' samples = [] typ = parts[3] diff --git a/prometheus_client/validation.py b/prometheus_client/validation.py new file mode 100644 index 00000000..bf19fc75 --- /dev/null +++ b/prometheus_client/validation.py @@ -0,0 +1,123 @@ +import os +import re + +METRIC_NAME_RE = re.compile(r'^[a-zA-Z_:][a-zA-Z0-9_:]*$') +METRIC_LABEL_NAME_RE = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*$') +RESERVED_METRIC_LABEL_NAME_RE = re.compile(r'^__.*$') + + +def _init_legacy_validation() -> bool: + """Retrieve name validation setting from environment.""" + return os.environ.get("PROMETHEUS_LEGACY_NAME_VALIDATION", 'False').lower() in ('true', '1', 't') + + +_legacy_validation = _init_legacy_validation() + + +def get_legacy_validation() -> bool: + """Return the current status of the legacy validation setting.""" + global _legacy_validation + return _legacy_validation + + +def disable_legacy_validation(): + """Disable legacy name validation, instead allowing all UTF8 characters.""" + global _legacy_validation + _legacy_validation = False + + +def enable_legacy_validation(): + """Enable legacy name validation instead of allowing all UTF8 characters.""" + global _legacy_validation + _legacy_validation = True + + +def _validate_metric_name(name: str) -> None: + """Raises ValueError if the provided name is not a valid metric name. + + This check uses the global legacy validation setting to determine the validation scheme. + """ + if not name: + raise ValueError("metric name cannot be empty") + global _legacy_validation + if _legacy_validation: + if not METRIC_NAME_RE.match(name): + raise ValueError("invalid metric name " + name) + try: + name.encode('utf-8') + except UnicodeDecodeError: + raise ValueError("invalid metric name " + name) + + +def _is_valid_legacy_metric_name(name: str) -> bool: + """Returns true if the provided metric name conforms to the legacy validation scheme.""" + return METRIC_NAME_RE.match(name) is not None + + +def _validate_metric_label_name_token(tok: str) -> None: + """Raises ValueError if a parsed label name token is invalid. + + UTF-8 names must be quoted. + """ + if not tok: + raise ValueError("invalid label name token " + tok) + global _legacy_validation + quoted = tok[0] == '"' and tok[-1] == '"' + if not quoted or _legacy_validation: + if not METRIC_LABEL_NAME_RE.match(tok): + raise ValueError("invalid label name token " + tok) + return + try: + tok.encode('utf-8') + except UnicodeDecodeError: + raise ValueError("invalid label name token " + tok) + + +def _validate_labelname(l): + """Raises ValueError if the provided name is not a valid label name. + + This check uses the global legacy validation setting to determine the validation scheme. + """ + if get_legacy_validation(): + if not METRIC_LABEL_NAME_RE.match(l): + raise ValueError('Invalid label metric name: ' + l) + if RESERVED_METRIC_LABEL_NAME_RE.match(l): + raise ValueError('Reserved label metric name: ' + l) + else: + try: + l.encode('utf-8') + except UnicodeDecodeError: + raise ValueError('Invalid label metric name: ' + l) + if RESERVED_METRIC_LABEL_NAME_RE.match(l): + raise ValueError('Reserved label metric name: ' + l) + + +def _is_valid_legacy_labelname(l: str) -> bool: + """Returns true if the provided label name conforms to the legacy validation scheme.""" + if METRIC_LABEL_NAME_RE.match(l) is None: + return False + return RESERVED_METRIC_LABEL_NAME_RE.match(l) is None + + +def _validate_labelnames(cls, labelnames): + """Raises ValueError if any of the provided names is not a valid label name. + + This check uses the global legacy validation setting to determine the validation scheme. + """ + labelnames = tuple(labelnames) + for l in labelnames: + _validate_labelname(l) + if l in cls._reserved_labelnames: + raise ValueError('Reserved label methe fric name: ' + l) + return labelnames + + +def _validate_exemplar(exemplar): + """Raises ValueError if the exemplar is invalid.""" + runes = 0 + for k, v in exemplar.items(): + _validate_labelname(k) + runes += len(k) + runes += len(v) + if runes > 128: + raise ValueError('Exemplar labels have %d UTF-8 characters, exceeding the limit of 128') diff --git a/tests/openmetrics/test_exposition.py b/tests/openmetrics/test_exposition.py index 28a90838..124e55e9 100644 --- a/tests/openmetrics/test_exposition.py +++ b/tests/openmetrics/test_exposition.py @@ -33,6 +33,12 @@ def test_counter(self): c.inc() self.assertEqual(b'# HELP cc A counter\n# TYPE cc counter\ncc_total 1.0\ncc_created 123.456\n# EOF\n', generate_latest(self.registry)) + + def test_counter_utf8(self): + c = Counter('cc.with.dots', 'A counter', registry=self.registry) + c.inc() + self.assertEqual(b'# HELP "cc.with.dots" A counter\n# TYPE "cc.with.dots" counter\n{"cc.with.dots_total"} 1.0\n{"cc.with.dots_created"} 123.456\n# EOF\n', + generate_latest(self.registry)) def test_counter_total(self): c = Counter('cc_total', 'A counter', registry=self.registry) diff --git a/tests/openmetrics/test_parser.py b/tests/openmetrics/test_parser.py index dc5e9916..019929e6 100644 --- a/tests/openmetrics/test_parser.py +++ b/tests/openmetrics/test_parser.py @@ -29,6 +29,24 @@ def test_uint64_counter(self): """) self.assertEqual([CounterMetricFamily("a", "help", value=9223372036854775808)], list(families)) + def test_utf8_counter(self): + families = text_string_to_metric_families("""# TYPE "my.counter" counter +# HELP "my.counter" help +{"my.counter_total"} 1 +# EOF +""") + self.assertEqual([CounterMetricFamily("my.counter", "help", value=1)], list(families)) + + def test_complex_name_counter(self): + families = text_string_to_metric_families("""# TYPE "my.counter{} # = \\" \\n" counter +# HELP "my.counter{} # = \\" \\n" help +{"my.counter{} # = \\" \\n_total", "awful. }}{{ # HELP EOF name"="\\n yikes } \\" value"} 1 +# EOF +""") + metric = CounterMetricFamily("my.counter{} # = \" \n", "help", labels={'awful. }}{{ # HELP EOF name': '\n yikes } " value'}) + metric.add_sample("my.counter{} # = \" \n_total", {'awful. }}{{ # HELP EOF name': '\n yikes } " value'}, 1) + self.assertEqual([metric], list(families)) + def test_simple_gauge(self): families = text_string_to_metric_families("""# TYPE a gauge # HELP a help @@ -128,6 +146,18 @@ def test_simple_histogram_float_values(self): self.assertEqual([HistogramMetricFamily("a", "help", sum_value=2, buckets=[("1.0", 0.0), ("+Inf", 3.0)])], list(families)) + def test_utf8_histogram_float_values(self): + families = text_string_to_metric_families("""# TYPE "a.b" histogram +# HELP "a.b" help +{"a.b_bucket", le="1.0"} 0.0 +{"a.b_bucket", le="+Inf"} 3.0 +{"a.b_count"} 3.0 +{"a.b_sum"} 2.0 +# EOF +""") + self.assertEqual([HistogramMetricFamily("a.b", "help", sum_value=2, buckets=[("1.0", 0.0), ("+Inf", 3.0)])], + list(families)) + def test_histogram_noncanonical(self): families = text_string_to_metric_families("""# TYPE a histogram # HELP a help @@ -175,7 +205,7 @@ def test_histogram_exemplars(self): Exemplar({"a": "2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678"}, 4, Timestamp(123, 0))) self.assertEqual([hfm], list(families)) - + def test_native_histogram(self): families = text_string_to_metric_families("""# TYPE nativehistogram histogram # HELP nativehistogram Is a basic example of a native histogram @@ -183,11 +213,35 @@ def test_native_histogram(self): # 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, 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 +{"native{histogram"} {count:24,sum:100,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("native{histogram", "Is a basic example of a native histogram") + hfm.add_sample("native{histogram", 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_utf8_stress(self): + families = text_string_to_metric_families("""# TYPE "native{histogram" histogram +# HELP "native{histogram" Is a basic example of a native histogram +{"native{histogram", "xx{} # {}"=" EOF # {}}}"} {count:24,sum:100,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("native{histogram", "Is a basic example of a native histogram") + hfm.add_sample("native{histogram", {'xx{} # {}': ' EOF # {}}}'}, 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_with_labels(self): families = text_string_to_metric_families("""# TYPE hist_w_labels histogram # HELP hist_w_labels Is a basic example of a native histogram with labels @@ -195,11 +249,23 @@ def test_native_histogram_with_labels(self): # EOF """) families = list(families) - + hfm = HistogramMetricFamily("hist_w_labels", "Is a basic example of a native histogram with labels") hfm.add_sample("hist_w_labels", {"foo": "bar", "baz": "qux"}, 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_with_labels_utf8(self): + families = text_string_to_metric_families("""# TYPE "hist.w.labels" histogram +# HELP "hist.w.labels" Is a basic example of a native histogram with labels +{"hist.w.labels", foo="bar",baz="qux"} {count:24,sum:100,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("hist.w.labels", "Is a basic example of a native histogram with labels") + hfm.add_sample("hist.w.labels", {"foo": "bar", "baz": "qux"}, 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_with_classic_histogram(self): families = text_string_to_metric_families("""# TYPE hist_w_classic histogram # HELP hist_w_classic Is a basic example of a native histogram coexisting with a classic histogram @@ -211,7 +277,7 @@ def test_native_histogram_with_classic_histogram(self): # EOF """) families = list(families) - + hfm = HistogramMetricFamily("hist_w_classic", "Is a basic example of a native histogram coexisting with a classic histogram") hfm.add_sample("hist_w_classic", {"foo": "bar"}, 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))) hfm.add_sample("hist_w_classic_bucket", {"foo": "bar", "le": "0.001"}, 4.0, None, None, None) @@ -219,7 +285,7 @@ def test_native_histogram_with_classic_histogram(self): hfm.add_sample("hist_w_classic_count", {"foo": "bar"}, 24.0, None, None, None) hfm.add_sample("hist_w_classic_sum", {"foo": "bar"}, 100.0, None, None, None) self.assertEqual([hfm], families) - + def test_native_plus_classic_histogram_two_labelsets(self): families = text_string_to_metric_families("""# TYPE hist_w_classic_two_sets histogram # HELP hist_w_classic_two_sets Is an example of a native histogram plus a classic histogram with two label sets @@ -236,7 +302,7 @@ def test_native_plus_classic_histogram_two_labelsets(self): # EOF """) families = list(families) - + hfm = HistogramMetricFamily("hist_w_classic_two_sets", "Is an example of a native histogram plus a classic histogram with two label sets") hfm.add_sample("hist_w_classic_two_sets", {"foo": "bar"}, 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))) hfm.add_sample("hist_w_classic_two_sets_bucket", {"foo": "bar", "le": "0.001"}, 4.0, None, None, None) @@ -299,6 +365,16 @@ def test_counter_exemplars(self): cfm.add_sample("a_total", {}, 0.0, Timestamp(123, 0), Exemplar({"a": "b"}, 0.5)) self.assertEqual([cfm], list(families)) + def test_counter_exemplars_utf8(self): + families = text_string_to_metric_families("""# TYPE "a.b" counter +# HELP "a.b" help +{"a.b_total"} 0 123 # {"c{}d"="b"} 0.5 +# EOF +""") + cfm = CounterMetricFamily("a.b", "help") + cfm.add_sample("a.b_total", {}, 0.0, Timestamp(123, 0), Exemplar({"c{}d": "b"}, 0.5)) + self.assertEqual([cfm], list(families)) + def test_counter_exemplars_empty_brackets(self): families = text_string_to_metric_families("""# TYPE a counter # HELP a help @@ -495,10 +571,10 @@ def test_help_escaping(self): def test_escaping(self): families = text_string_to_metric_families("""# TYPE a counter # HELP a he\\n\\\\l\\tp -a_total{foo="b\\"a\\nr"} 1 -a_total{foo="b\\\\a\\z"} 2 -a_total{foo="b\\"a\\nr # "} 3 -a_total{foo="b\\\\a\\z # "} 4 +{"a_total", foo="b\\"a\\nr"} 1 +{"a_total", foo="b\\\\a\\z"} 2 +{"a_total", foo="b\\"a\\nr # "} 3 +{"a_total", foo="b\\\\a\\z # "} 4 # EOF """) metric_family = CounterMetricFamily("a", "he\n\\l\\tp", labels=["foo"]) @@ -565,66 +641,6 @@ def test_exemplars_with_hash_in_label_values(self): hfm.add_sample("a_bucket", {"le": "+Inf", "foo": "bar # "}, 3.0, None, Exemplar({"a": "d", "foo": "bar # bar"}, 4)) self.assertEqual([hfm], list(families)) - def test_fallback_to_state_machine_label_parsing(self): - from unittest.mock import patch - - from prometheus_client.openmetrics.parser import _parse_sample - - parse_sample_function = "prometheus_client.openmetrics.parser._parse_sample" - parse_labels_function = "prometheus_client.openmetrics.parser._parse_labels" - parse_remaining_function = "prometheus_client.openmetrics.parser._parse_remaining_text" - state_machine_function = "prometheus_client.openmetrics.parser._parse_labels_with_state_machine" - - parse_sample_return_value = Sample("a_total", {"foo": "foo # bar"}, 1) - with patch(parse_sample_function, return_value=parse_sample_return_value) as mock: - families = text_string_to_metric_families("""# TYPE a counter -# HELP a help -a_total{foo="foo # bar"} 1 -# EOF -""") - a = CounterMetricFamily("a", "help", labels=["foo"]) - a.add_metric(["foo # bar"], 1) - self.assertEqual([a], list(families)) - mock.assert_called_once_with('a_total{foo="foo # bar"} 1') - - # First fallback case - state_machine_return_values = [{"foo": "foo # bar"}, len('foo="foo # bar"}')] - parse_remaining_values = [1, None, None] - with patch(parse_labels_function) as mock1: - with patch(state_machine_function, return_value=state_machine_return_values) as mock2: - with patch(parse_remaining_function, return_value=parse_remaining_values) as mock3: - sample = _parse_sample('a_total{foo="foo # bar"} 1') - s = Sample("a_total", {"foo": "foo # bar"}, 1) - self.assertEqual(s, sample) - mock1.assert_not_called() - mock2.assert_called_once_with('foo="foo # bar"} 1') - mock3.assert_called_once_with('1') - - # Second fallback case - state_machine_return_values = [{"le": "1.0"}, len('le="1.0"}')] - parse_remaining_values = [0.0, Timestamp(123, 0), Exemplar({"a": "b"}, 0.5)] - with patch(parse_labels_function) as mock1: - with patch(state_machine_function, return_value=state_machine_return_values) as mock2: - with patch(parse_remaining_function, return_value=parse_remaining_values) as mock3: - sample = _parse_sample('a_bucket{le="1.0"} 0 123 # {a="b"} 0.5') - s = Sample("a_bucket", {"le": "1.0"}, 0.0, Timestamp(123, 0), Exemplar({"a": "b"}, 0.5)) - self.assertEqual(s, sample) - mock1.assert_not_called() - mock2.assert_called_once_with('le="1.0"} 0 123 # {a="b"} 0.5') - mock3.assert_called_once_with('0 123 # {a="b"} 0.5') - - # No need to fallback case - parse_labels_return_values = {"foo": "foo#bar"} - parse_remaining_values = [1, None, None] - with patch(parse_labels_function, return_value=parse_labels_return_values) as mock1: - with patch(state_machine_function) as mock2: - with patch(parse_remaining_function, return_value=parse_remaining_values) as mock3: - sample = _parse_sample('a_total{foo="foo#bar"} 1') - s = Sample("a_total", {"foo": "foo#bar"}, 1) - self.assertEqual(s, sample) - mock1.assert_called_once_with('foo="foo#bar"') - mock2.assert_not_called() - mock3.assert_called_once_with('1') def test_roundtrip(self): text = """# HELP go_gc_duration_seconds A summary of the GC invocation durations. @@ -710,8 +726,10 @@ def test_invalid_input(self): ('a{a=1} 1\n# EOF\n'), ('a{a="1} 1\n# EOF\n'), ('a{a=\'1\'} 1\n# EOF\n'), + ('"a" 1\n# EOF\n'), # Missing equal or label value. ('a{a} 1\n# EOF\n'), + ('a{"a} 1\n# EOF\n'), ('a{a"value"} 1\n# EOF\n'), ('a{a""} 1\n# EOF\n'), ('a{a=} 1\n# EOF\n'), @@ -897,6 +915,10 @@ def test_invalid_input(self): ('# TYPE a counter\n# TYPE a counter\n# EOF\n'), ('# TYPE a info\n# TYPE a counter\n# EOF\n'), ('# TYPE a_created gauge\n# TYPE a counter\n# EOF\n'), + # Bad native histograms. + ('# TYPE nh histogram\nnh {count:24\n# EOF\n'), + ('# TYPE nh histogram\nnh{} # {count:24\n# EOF\n'), + ]: with self.assertRaises(ValueError, msg=case): list(text_string_to_metric_families(case)) diff --git a/tests/test_core.py b/tests/test_core.py index 056d8e58..4e99ca33 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -14,6 +14,9 @@ ) from prometheus_client.decorator import getargspec from prometheus_client.metrics import _get_use_created +from prometheus_client.validation import ( + disable_legacy_validation, enable_legacy_validation, +) def is_locked(lock): @@ -114,8 +117,12 @@ def test_inc_not_observable(self): assert_not_observable(counter.inc) def test_exemplar_invalid_label_name(self): + enable_legacy_validation() self.assertRaises(ValueError, self.counter.inc, exemplar={':o)': 'smile'}) self.assertRaises(ValueError, self.counter.inc, exemplar={'1': 'number'}) + disable_legacy_validation() + self.counter.inc(exemplar={':o)': 'smile'}) + self.counter.inc(exemplar={'1': 'number'}) def test_exemplar_unicode(self): # 128 characters should not raise, even using characters larger than 1 byte. @@ -510,10 +517,16 @@ def test_block_decorator_with_label(self): self.assertEqual(1, value('hl_count', {'l': 'a'})) self.assertEqual(1, value('hl_bucket', {'le': '+Inf', 'l': 'a'})) - def test_exemplar_invalid_label_name(self): + def test_exemplar_invalid_legacy_label_name(self): + enable_legacy_validation() self.assertRaises(ValueError, self.histogram.observe, 3.0, exemplar={':o)': 'smile'}) self.assertRaises(ValueError, self.histogram.observe, 3.0, exemplar={'1': 'number'}) + def test_exemplar_invalid_label_name(self): + disable_legacy_validation() + self.histogram.observe(3.0, exemplar={':o)': 'smile'}) + self.histogram.observe(3.0, exemplar={'1': 'number'}) + def test_exemplar_too_long(self): # 129 characters in total should fail. self.assertRaises(ValueError, self.histogram.observe, 1.0, exemplar={ @@ -654,7 +667,8 @@ def test_labels_by_kwarg(self): self.assertRaises(ValueError, self.two_labels.labels) self.assertRaises(ValueError, self.two_labels.labels, {'a': 'x'}, b='y') - def test_invalid_names_raise(self): + def test_invalid_legacy_names_raise(self): + enable_legacy_validation() self.assertRaises(ValueError, Counter, '', 'help') self.assertRaises(ValueError, Counter, '^', 'help') self.assertRaises(ValueError, Counter, '', 'help', namespace='&') @@ -664,6 +678,14 @@ def test_invalid_names_raise(self): self.assertRaises(ValueError, Counter, 'c_total', '', labelnames=['__reserved']) self.assertRaises(ValueError, Summary, 'c_total', '', labelnames=['quantile']) + def test_invalid_names_raise(self): + disable_legacy_validation() + self.assertRaises(ValueError, Counter, '', 'help') + self.assertRaises(ValueError, Counter, '', 'help', namespace='&') + self.assertRaises(ValueError, Counter, '', 'help', subsystem='(') + self.assertRaises(ValueError, Counter, 'c_total', '', labelnames=['__reserved']) + self.assertRaises(ValueError, Summary, 'c_total', '', labelnames=['quantile']) + def test_empty_labels_list(self): Histogram('h', 'help', [], registry=self.registry) self.assertEqual(0, self.registry.get_sample_value('h_sum')) @@ -714,6 +736,10 @@ def test_counter(self): self.custom_collector(CounterMetricFamily('c_total', 'help', value=1)) self.assertEqual(1, self.registry.get_sample_value('c_total', {})) + def test_counter_utf8(self): + self.custom_collector(CounterMetricFamily('my.metric', 'help', value=1)) + self.assertEqual(1, self.registry.get_sample_value('my.metric_total', {})) + def test_counter_total(self): self.custom_collector(CounterMetricFamily('c_total', 'help', value=1)) self.assertEqual(1, self.registry.get_sample_value('c_total', {})) diff --git a/tests/test_exposition.py b/tests/test_exposition.py index 54bdaa98..2a3f08cb 100644 --- a/tests/test_exposition.py +++ b/tests/test_exposition.py @@ -46,6 +46,17 @@ def test_counter(self): # HELP cc_created A counter # TYPE cc_created gauge cc_created 123.456 +""", generate_latest(self.registry)) + + def test_counter_utf8(self): + c = Counter('utf8.cc', 'A counter', registry=self.registry) + c.inc() + self.assertEqual(b"""# HELP "utf8.cc_total" A counter +# TYPE "utf8.cc_total" counter +{"utf8.cc_total"} 1.0 +# HELP "utf8.cc_created" A counter +# TYPE "utf8.cc_created" gauge +{"utf8.cc_created"} 123.456 """, generate_latest(self.registry)) def test_counter_name_unit_append(self): diff --git a/tests/test_parser.py b/tests/test_parser.py index 61b3c8ae..10a2fc90 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -25,6 +25,22 @@ def test_simple_counter(self): """) self.assertEqualMetrics([CounterMetricFamily("a", "help", value=1)], list(families)) + def test_utf8_counter(self): + families = text_string_to_metric_families("""# TYPE "a.b" counter +# HELP "a.b" help +{"a.b"} 1 +""") + self.assertEqualMetrics([CounterMetricFamily("a.b", "help", value=1)], list(families)) + + def test_complex_name_counter(self): + families = text_string_to_metric_families("""# TYPE "my.counter{} # = \\" \\n" counter +# HELP "my.counter{} # = \\" \\n" help +{"my.counter{} # = \\" \\n", "awful. }}{{ # HELP EOF name"="\\n yikes } \\" value"} 1 +""") + metric = CounterMetricFamily("my.counter{} # = \" \n", "help", labels={'awful. }}{{ # HELP EOF name': '\n yikes } " value'}) + metric.add_sample("my.counter{} # = \" \n_total", {'awful. }}{{ # HELP EOF name': '\n yikes } " value'}, 1) + self.assertEqual([metric], list(families)) + def test_simple_gauge(self): families = text_string_to_metric_families("""# TYPE a gauge # HELP a help @@ -322,6 +338,15 @@ def test_roundtrip(self): prometheus_local_storage_chunk_ops_total{type="pin"} 32662.0 prometheus_local_storage_chunk_ops_total{type="transcode"} 980180.0 prometheus_local_storage_chunk_ops_total{type="unpin"} 32662.0 +# HELP "my.utf8.metric.#{}=" A fancy metric with dots. +# TYPE "my.utf8.metric.#{}=" summary +{"my.utf8.metric.#{}=",quantile="0"} 0.013300656000000001 +{"my.utf8.metric.#{}=",quantile="0.25"} 0.013638736 +{"my.utf8.metric.#{}=",quantile="0.5"} 0.013759906 +{"my.utf8.metric.#{}=",quantile="0.75"} 0.013962066 +{"my.utf8.metric.#{}=",quantile="1"} 0.021383540000000003 +{"my.utf8.metric.#{}=_sum"} 56.12904785 +{"my.utf8.metric.#{}=_count"} 7476.0 """ families = list(text_string_to_metric_families(text)) From 00f21e98c64f5f8477bcda39c9abf69b1f473969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Suliga?= <1270737+suligap@users.noreply.github.com> Date: Tue, 3 Dec 2024 15:53:16 +0100 Subject: [PATCH 20/32] Drop incorrect use of reentrant locks (#1076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This fixes a correctness bug introduced in 0014e9776350a252930671ed170edee464f9b428 resulting in lost updates during some scenarios. The code being locked is not reentrant safe. It's preferable to deadlock in these situations instead of silently loosing updates for example. Signed-off-by: PrzemysƂaw Suliga --- prometheus_client/metrics.py | 8 ++++---- prometheus_client/registry.py | 4 ++-- prometheus_client/values.py | 6 +++--- tests/test_core.py | 10 +--------- 4 files changed, 10 insertions(+), 18 deletions(-) diff --git a/prometheus_client/metrics.py b/prometheus_client/metrics.py index 46175860..9b251274 100644 --- a/prometheus_client/metrics.py +++ b/prometheus_client/metrics.py @@ -1,5 +1,5 @@ import os -from threading import RLock +from threading import Lock import time import types from typing import ( @@ -120,7 +120,7 @@ def __init__(self: T, if self._is_parent(): # Prepare the fields needed for child metrics. - self._lock = RLock() + self._lock = Lock() self._metrics: Dict[Sequence[str], T] = {} if self._is_observable(): @@ -673,7 +673,7 @@ class Info(MetricWrapperBase): def _metric_init(self): self._labelname_set = set(self._labelnames) - self._lock = RLock() + self._lock = Lock() self._value = {} def info(self, val: Dict[str, str]) -> None: @@ -735,7 +735,7 @@ def __init__(self, def _metric_init(self) -> None: self._value = 0 - self._lock = RLock() + self._lock = Lock() def state(self, state: str) -> None: """Set enum metric state.""" diff --git a/prometheus_client/registry.py b/prometheus_client/registry.py index 4326b39a..694e4bd8 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 RLock +from threading import Lock 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 = RLock() + self._lock = Lock() self._target_info: Optional[Dict[str, str]] = {} self.set_target_info(target_info) diff --git a/prometheus_client/values.py b/prometheus_client/values.py index 05331f82..6ff85e3b 100644 --- a/prometheus_client/values.py +++ b/prometheus_client/values.py @@ -1,5 +1,5 @@ import os -from threading import RLock +from threading import Lock 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 = RLock() + self._lock = Lock() 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 = RLock() + lock = Lock() class MmapedValue: """A float protected by a mutex backed by a per-process mmaped file.""" diff --git a/tests/test_core.py b/tests/test_core.py index 4e99ca33..f28c9abc 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -19,14 +19,6 @@ ) -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 @@ -1012,7 +1004,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(is_locked(registry._lock)) + self.assertFalse(registry._lock.locked()) if __name__ == '__main__': From ef95c4b972cd67659b1f760d22d89be54dbf975b Mon Sep 17 00:00:00 2001 From: Kajinami Takashi Date: Wed, 4 Dec 2024 00:11:33 +0900 Subject: [PATCH 21/32] Remove Python 3.8 support (#1075) Python 3.8 already reached its EOL last month. Remove support and testing of Python 3.8 . Signed-off-by: Takashi Kajinami --- .circleci/config.yml | 3 +-- setup.py | 3 +-- tox.ini | 10 +++++----- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 2605a505..26109fcd 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -75,7 +75,6 @@ workflows: matrix: parameters: python: - - "3.8.18" - "3.9.18" - "3.10" - "3.11" @@ -89,4 +88,4 @@ workflows: matrix: parameters: python: - - "3.8" + - "3.9" diff --git a/setup.py b/setup.py index 438f643a..1665ed7c 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ 'twisted': ['twisted'], }, test_suite="tests", - python_requires=">=3.8", + python_requires=">=3.9", classifiers=[ "Development Status :: 4 - Beta", "Intended Audience :: Developers", @@ -38,7 +38,6 @@ "Intended Audience :: System Administrators", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", diff --git a/tox.ini b/tox.ini index 6d6b756c..354ad69c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,15 +1,15 @@ [tox] -envlist = coverage-clean,py{3.8,3.9,3.10,3.11,3.12,py3.8,3.9-nooptionals},coverage-report,flake8,isort,mypy +envlist = coverage-clean,py{3.9,3.10,3.11,3.12,py3.9,3.9-nooptionals},coverage-report,flake8,isort,mypy [testenv] deps = coverage pytest attrs - {py3.8,pypy3.8}: twisted - py3.8: asgiref - # See https://github.com/django/asgiref/issues/393 for why we need to pin asgiref for pypy - pypy3.8: asgiref==3.6.0 + {py3.9,pypy3.9}: twisted + # NOTE: Pinned due to https://github.com/prometheus/client_python/issues/1020 + py3.9: asgiref==3.7 + pypy3.9: asgiref==3.7 commands = coverage run --parallel -m pytest {posargs} [testenv:py3.9-nooptionals] From 92b23970f032cbc990aa0e501708c425708e51ea Mon Sep 17 00:00:00 2001 From: GlorifiedPig Date: Fri, 6 Dec 2024 17:25:05 +0200 Subject: [PATCH 22/32] if check before deleting in remove() (#1077) Signed-off-by: GlorifiedPig --- prometheus_client/metrics.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/prometheus_client/metrics.py b/prometheus_client/metrics.py index 9b251274..b9f25ffc 100644 --- a/prometheus_client/metrics.py +++ b/prometheus_client/metrics.py @@ -200,7 +200,8 @@ def remove(self, *labelvalues: Any) -> None: raise ValueError('Incorrect label count (expected %d, got %s)' % (len(self._labelnames), labelvalues)) labelvalues = tuple(str(l) for l in labelvalues) with self._lock: - del self._metrics[labelvalues] + if labelvalues in self._metrics: + del self._metrics[labelvalues] def clear(self) -> None: """Remove all labelsets from the metric""" From 5926a7c0ddb8755a5e504e6f077e081f5f4c7674 Mon Sep 17 00:00:00 2001 From: Iurii Pliner Date: Mon, 23 Dec 2024 18:51:52 +0000 Subject: [PATCH 23/32] Add support for Python 3.13 (#1080) Signed-off-by: Iurii Pliner --- .circleci/config.yml | 1 + setup.py | 1 + tox.ini | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 26109fcd..4eaf808f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -79,6 +79,7 @@ workflows: - "3.10" - "3.11" - "3.12" + - "3.13" - test_nooptionals: matrix: parameters: diff --git a/setup.py b/setup.py index 1665ed7c..0d5d887f 100644 --- a/setup.py +++ b/setup.py @@ -42,6 +42,7 @@ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: System :: Monitoring", diff --git a/tox.ini b/tox.ini index 354ad69c..deb74e14 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = coverage-clean,py{3.9,3.10,3.11,3.12,py3.9,3.9-nooptionals},coverage-report,flake8,isort,mypy +envlist = coverage-clean,py{3.9,3.10,3.11,3.12,3.13,py3.9,3.9-nooptionals},coverage-report,flake8,isort,mypy [testenv] deps = From ecf344b0bfacadcc0f255916bbf7f10b68c7e537 Mon Sep 17 00:00:00 2001 From: Arianna Vespri <36129782+vesari@users.noreply.github.com> Date: Fri, 17 Jan 2025 19:10:32 +0100 Subject: [PATCH 24/32] Correct nh sample span structure and parsing (#1082) * Add test for nh with more spans * Allow for span arrays to be of whatever length and for delta lists to be None * Allow for spans to be None, condense spans and deltas composition Signed-off-by: Arianna Vespri --- prometheus_client/openmetrics/parser.py | 83 ++++++++++++++----------- prometheus_client/samples.py | 6 +- tests/openmetrics/test_parser.py | 12 ++++ 3 files changed, 63 insertions(+), 38 deletions(-) diff --git a/prometheus_client/openmetrics/parser.py b/prometheus_client/openmetrics/parser.py index 1d270915..d967e83b 100644 --- a/prometheus_client/openmetrics/parser.py +++ b/prometheus_client/openmetrics/parser.py @@ -307,12 +307,11 @@ def _parse_nh_sample(text, suffixes): def _parse_nh_struct(text): pattern = r'(\w+):\s*([^,}]+)' - - re_spans = re.compile(r'(positive_spans|negative_spans):\[(\d+:\d+,\d+:\d+)\]') + re_spans = re.compile(r'(positive_spans|negative_spans):\[(\d+:\d+(,\d+:\d+)*)\]') re_deltas = re.compile(r'(positive_deltas|negative_deltas):\[(-?\d+(?:,-?\d+)*)\]') items = dict(re.findall(pattern, text)) - spans = dict(re_spans.findall(text)) + span_matches = re_spans.findall(text) deltas = dict(re_deltas.findall(text)) count_value = int(items['count']) @@ -321,38 +320,11 @@ def _parse_nh_struct(text): zero_threshold = float(items['zero_threshold']) zero_count = int(items['zero_count']) - try: - pos_spans_text = spans['positive_spans'] - elems = pos_spans_text.split(',') - arg1 = [int(x) for x in elems[0].split(':')] - arg2 = [int(x) for x in elems[1].split(':')] - pos_spans = (BucketSpan(arg1[0], arg1[1]), BucketSpan(arg2[0], arg2[1])) - except KeyError: - pos_spans = None - - try: - neg_spans_text = spans['negative_spans'] - elems = neg_spans_text.split(',') - arg1 = [int(x) for x in elems[0].split(':')] - arg2 = [int(x) for x in elems[1].split(':')] - neg_spans = (BucketSpan(arg1[0], arg1[1]), BucketSpan(arg2[0], arg2[1])) - except KeyError: - neg_spans = None - - try: - pos_deltas_text = deltas['positive_deltas'] - elems = pos_deltas_text.split(',') - pos_deltas = tuple([int(x) for x in elems]) - except KeyError: - pos_deltas = None - - try: - neg_deltas_text = deltas['negative_deltas'] - elems = neg_deltas_text.split(',') - neg_deltas = tuple([int(x) for x in elems]) - except KeyError: - neg_deltas = None - + pos_spans = _compose_spans(span_matches, 'positive_spans') + neg_spans = _compose_spans(span_matches, 'negative_spans') + pos_deltas = _compose_deltas(deltas, 'positive_deltas') + neg_deltas = _compose_deltas(deltas, 'negative_deltas') + return NativeHistogram( count_value=count_value, sum_value=sum_value, @@ -364,6 +336,47 @@ def _parse_nh_struct(text): pos_deltas=pos_deltas, neg_deltas=neg_deltas ) + + +def _compose_spans(span_matches, spans_name): + """Takes a list of span matches (expected to be a list of tuples) and a string + (the expected span list name) and processes the list so that the values extracted + from the span matches can be used to compose a tuple of BucketSpan objects""" + spans = {} + for match in span_matches: + # Extract the key from the match (first element of the tuple). + key = match[0] + # Extract the value from the match (second element of the tuple). + # Split the value string by commas to get individual pairs, + # split each pair by ':' to get start and end, and convert them to integers. + value = [tuple(map(int, pair.split(':'))) for pair in match[1].split(',')] + # Store the processed value in the spans dictionary with the key. + spans[key] = value + if spans_name not in spans: + return None + out_spans = [] + # Iterate over each start and end tuple in the list of tuples for the specified spans_name. + for start, end in spans[spans_name]: + # Compose a BucketSpan object with the start and end values + # and append it to the out_spans list. + out_spans.append(BucketSpan(start, end)) + # Convert to tuple + out_spans_tuple = tuple(out_spans) + return out_spans_tuple + + +def _compose_deltas(deltas, deltas_name): + """Takes a list of deltas matches (a dictionary) and a string (the expected delta list name), + and processes its elements to compose a tuple of integers representing the deltas""" + if deltas_name not in deltas: + return None + out_deltas = deltas.get(deltas_name) + if out_deltas is not None and out_deltas.strip(): + elems = out_deltas.split(',') + # Convert each element in the list elems to an integer + # after stripping whitespace and create a tuple from these integers. + out_deltas_tuple = tuple(int(x.strip()) for x in elems) + return out_deltas_tuple def _group_for_sample(sample, name, typ): diff --git a/prometheus_client/samples.py b/prometheus_client/samples.py index b57a5d48..16e03c04 100644 --- a/prometheus_client/samples.py +++ b/prometheus_client/samples.py @@ -1,4 +1,4 @@ -from typing import Dict, NamedTuple, Optional, Sequence, Tuple, Union +from typing import Dict, NamedTuple, Optional, Sequence, Union class Timestamp: @@ -47,8 +47,8 @@ class NativeHistogram(NamedTuple): schema: int zero_threshold: float zero_count: float - pos_spans: Optional[Tuple[BucketSpan, BucketSpan]] = None - neg_spans: Optional[Tuple[BucketSpan, BucketSpan]] = None + pos_spans: Optional[Sequence[BucketSpan]] = None + neg_spans: Optional[Sequence[BucketSpan]] = None pos_deltas: Optional[Sequence[int]] = None neg_deltas: Optional[Sequence[int]] = None diff --git a/tests/openmetrics/test_parser.py b/tests/openmetrics/test_parser.py index 019929e6..aeaa6ed6 100644 --- a/tests/openmetrics/test_parser.py +++ b/tests/openmetrics/test_parser.py @@ -242,6 +242,18 @@ def test_native_histogram_utf8_stress(self): hfm.add_sample("native{histogram", {'xx{} # {}': ' EOF # {}}}'}, 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_three_pos_spans_no_neg_spans_or_deltas(self): + families = text_string_to_metric_families("""# TYPE nhsp histogram +# HELP nhsp Is a basic example of a native histogram with three spans +nhsp {count:4,sum:6,schema:3,zero_threshold:2.938735877055719e-39,zero_count:1,positive_spans:[0:1,7:1,4:1],positive_deltas:[1,0,0]} +# EOF +""") + families = list(families) + + hfm = HistogramMetricFamily("nhsp", "Is a basic example of a native histogram with three spans") + hfm.add_sample("nhsp", None, None, None, None, NativeHistogram(4, 6, 3, 2.938735877055719e-39, 1, (BucketSpan(0, 1), BucketSpan(7, 1), BucketSpan(4, 1)), None, (1, 0, 0), None)) + self.assertEqual([hfm], families) + def test_native_histogram_with_labels(self): families = text_string_to_metric_families("""# TYPE hist_w_labels histogram # HELP hist_w_labels Is a basic example of a native histogram with labels From b0a6f1202308e77784c8614c834aad5a73c5a256 Mon Sep 17 00:00:00 2001 From: Chris Marchbanks Date: Tue, 21 Jan 2025 10:20:03 -0700 Subject: [PATCH 25/32] Migrate from setup.py to pyproject.toml (#1084) Update the project configuration to use a pyproject.toml file instead of setup.py. This is the preferred tool and will allow easier integration with other tools in the future. We can also get rid of MANIFEST.in as the cache and compiled files are automatically excluded. Signed-off-by: Chris Marchbanks --- MANIFEST.in | 4 ---- pyproject.toml | 49 ++++++++++++++++++++++++++++++++++++++++++++++++ setup.py | 51 -------------------------------------------------- tox.ini | 4 ++-- 4 files changed, 51 insertions(+), 57 deletions(-) delete mode 100644 MANIFEST.in create mode 100644 pyproject.toml delete mode 100644 setup.py diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index cad821dd..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,4 +0,0 @@ -graft tests -global-exclude *.py[cod] -prune __pycache__ -prune */__pycache__ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..05b17551 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,49 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "prometheus_client" +version = "0.21.1" +description = "Python client for the Prometheus monitoring system." +readme = "README.md" +license = { file = "LICENSE" } +requires-python = ">=3.9" +authors = [ + { name = "The Prometheus Authors", email = "prometheus-developers@googlegroups.com" }, +] +keywords = [ + "prometheus", + "monitoring", + "instrumentation", + "client", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "Intended Audience :: System Administrators", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: System :: Monitoring", + "License :: OSI Approved :: Apache Software License", +] + +[project.optional-dependencies] +twisted = [ + "twisted", +] + +[project.urls] +Homepage = "https://github.com/prometheus/client_python" +Documentation = "https://prometheus.github.io/client_python/" + +[tool.setuptools.package-data] +prometheus_client = ['py.typed'] diff --git a/setup.py b/setup.py deleted file mode 100644 index 0d5d887f..00000000 --- a/setup.py +++ /dev/null @@ -1,51 +0,0 @@ -from os import path - -from setuptools import setup - -with open(path.join(path.abspath(path.dirname(__file__)), 'README.md')) as f: - long_description = f.read() - - -setup( - name="prometheus_client", - version="0.21.0", - author="Brian Brazil", - author_email="brian.brazil@robustperception.io", - description="Python client for the Prometheus monitoring system.", - long_description=long_description, - long_description_content_type='text/markdown', - license="Apache Software License 2.0", - keywords="prometheus monitoring instrumentation client", - url="https://github.com/prometheus/client_python", - packages=[ - 'prometheus_client', - 'prometheus_client.bridge', - 'prometheus_client.openmetrics', - 'prometheus_client.twisted', - ], - package_data={ - 'prometheus_client': ['py.typed'] - }, - extras_require={ - 'twisted': ['twisted'], - }, - test_suite="tests", - python_requires=">=3.9", - classifiers=[ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "Intended Audience :: Information Technology", - "Intended Audience :: System Administrators", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: Implementation :: PyPy", - "Topic :: System :: Monitoring", - "License :: OSI Approved :: Apache Software License", - ], -) diff --git a/tox.ini b/tox.ini index deb74e14..157a8bb2 100644 --- a/tox.ini +++ b/tox.ini @@ -34,14 +34,14 @@ deps = flake8-import-order==0.18.2 skip_install = true commands = - flake8 prometheus_client/ tests/ setup.py + flake8 prometheus_client/ tests/ [testenv:isort] deps = isort==5.10.1 skip_install = true commands = - isort --check prometheus_client/ tests/ setup.py + isort --check prometheus_client/ tests/ [testenv:mypy] deps = From 46eae7bae88f76951f7246d9f359f2dd5eeff110 Mon Sep 17 00:00:00 2001 From: Mallika Muralidharan <179001939+mallika-mur@users.noreply.github.com> Date: Tue, 28 Jan 2025 20:49:46 +0100 Subject: [PATCH 26/32] Changed pushgateway.md (#1083) * Changed pushgateway.md --------- Signed-off-by: Mallika Muralidharan Signed-off-by: Mallika Muralidharan Signed-off-by: Mallika Muralidharan <179001939+mallika-mur@users.noreply.github.com> Co-authored-by: Mallika Muralidharan --- docs/content/exporting/pushgateway.md | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/content/exporting/pushgateway.md b/docs/content/exporting/pushgateway.md index f1ea5e43..bf5eb112 100644 --- a/docs/content/exporting/pushgateway.md +++ b/docs/content/exporting/pushgateway.md @@ -5,6 +5,9 @@ weight: 3 The [Pushgateway](https://github.com/prometheus/pushgateway) allows ephemeral and batch jobs to expose their metrics to Prometheus. +Since Prometheus may not be able to scrape such a target, the targets can +push their metrics to a separate instance of the Pushgateway, +which then exposes these metrics to Prometheus. ```python from prometheus_client import CollectorRegistry, Gauge, push_to_gateway @@ -18,16 +21,20 @@ push_to_gateway('localhost:9091', job='batchA', registry=registry) A separate registry is used, as the default registry may contain other metrics such as those from the Process Collector. -Pushgateway functions take a grouping key. `push_to_gateway` replaces metrics -with the same grouping key, `pushadd_to_gateway` only replaces metrics with the -same name and grouping key and `delete_from_gateway` deletes metrics with the -given job and grouping key. See the +Pushgateway functions take a grouping key. +1. `push_to_gateway` replaces metrics +with the same grouping key. +2. `pushadd_to_gateway` only replaces metrics with the +same name and grouping key. +3. `delete_from_gateway` deletes metrics with the +given job and grouping key. +4. `instance_ip_grouping_key` returns a grouping key with the instance label set +to the host's IP address. + +See the [Pushgateway documentation](https://github.com/prometheus/pushgateway/blob/master/README.md) for more information. -`instance_ip_grouping_key` returns a grouping key with the instance label set -to the host's IP address. - # Handlers for authentication If the push gateway you are connecting to is protected with HTTP Basic Auth, From de8bb4adf7ebbb73eb50ed4ae9e941ed2f961d0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dennis=20G=C3=A4bler?= <34601603+dg98@users.noreply.github.com> Date: Mon, 3 Mar 2025 17:05:56 +0100 Subject: [PATCH 27/32] Fix order-dependent flaky tests related to UTF-8 support (#1093) * Fix order-dependent flaky tests related to UTF-8 support * Add context-manager for legacy validation --------- Signed-off-by: Dennis Gaebler --- tests/test_core.py | 43 ++++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index f28c9abc..284bce09 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -109,10 +109,9 @@ def test_inc_not_observable(self): assert_not_observable(counter.inc) def test_exemplar_invalid_label_name(self): - enable_legacy_validation() - self.assertRaises(ValueError, self.counter.inc, exemplar={':o)': 'smile'}) - self.assertRaises(ValueError, self.counter.inc, exemplar={'1': 'number'}) - disable_legacy_validation() + with LegacyValidationContextManager(): + self.assertRaises(ValueError, self.counter.inc, exemplar={':o)': 'smile'}) + self.assertRaises(ValueError, self.counter.inc, exemplar={'1': 'number'}) self.counter.inc(exemplar={':o)': 'smile'}) self.counter.inc(exemplar={'1': 'number'}) @@ -510,12 +509,11 @@ def test_block_decorator_with_label(self): self.assertEqual(1, value('hl_bucket', {'le': '+Inf', 'l': 'a'})) def test_exemplar_invalid_legacy_label_name(self): - enable_legacy_validation() - self.assertRaises(ValueError, self.histogram.observe, 3.0, exemplar={':o)': 'smile'}) - self.assertRaises(ValueError, self.histogram.observe, 3.0, exemplar={'1': 'number'}) - + with LegacyValidationContextManager(): + self.assertRaises(ValueError, self.histogram.observe, 3.0, exemplar={':o)': 'smile'}) + self.assertRaises(ValueError, self.histogram.observe, 3.0, exemplar={'1': 'number'}) + def test_exemplar_invalid_label_name(self): - disable_legacy_validation() self.histogram.observe(3.0, exemplar={':o)': 'smile'}) self.histogram.observe(3.0, exemplar={'1': 'number'}) @@ -660,18 +658,17 @@ def test_labels_by_kwarg(self): self.assertRaises(ValueError, self.two_labels.labels, {'a': 'x'}, b='y') def test_invalid_legacy_names_raise(self): - enable_legacy_validation() - self.assertRaises(ValueError, Counter, '', 'help') - self.assertRaises(ValueError, Counter, '^', 'help') - self.assertRaises(ValueError, Counter, '', 'help', namespace='&') - self.assertRaises(ValueError, Counter, '', 'help', subsystem='(') - self.assertRaises(ValueError, Counter, 'c_total', '', labelnames=['^']) - self.assertRaises(ValueError, Counter, 'c_total', '', labelnames=['a:b']) - self.assertRaises(ValueError, Counter, 'c_total', '', labelnames=['__reserved']) - self.assertRaises(ValueError, Summary, 'c_total', '', labelnames=['quantile']) + with LegacyValidationContextManager(): + self.assertRaises(ValueError, Counter, '', 'help') + self.assertRaises(ValueError, Counter, '^', 'help') + self.assertRaises(ValueError, Counter, '', 'help', namespace='&') + self.assertRaises(ValueError, Counter, '', 'help', subsystem='(') + self.assertRaises(ValueError, Counter, 'c_total', '', labelnames=['^']) + self.assertRaises(ValueError, Counter, 'c_total', '', labelnames=['a:b']) + self.assertRaises(ValueError, Counter, 'c_total', '', labelnames=['__reserved']) + self.assertRaises(ValueError, Summary, 'c_total', '', labelnames=['quantile']) def test_invalid_names_raise(self): - disable_legacy_validation() self.assertRaises(ValueError, Counter, '', 'help') self.assertRaises(ValueError, Counter, '', 'help', namespace='&') self.assertRaises(ValueError, Counter, '', 'help', subsystem='(') @@ -1007,5 +1004,13 @@ def test_restricted_registry_does_not_yield_while_locked(self): self.assertFalse(registry._lock.locked()) +class LegacyValidationContextManager: + def __enter__(self): + enable_legacy_validation() + + def __exit__(self, exc_type, exc_value, exc_tb): + disable_legacy_validation() + + if __name__ == '__main__': unittest.main() From e3bfa1f10195b6959c5f49503762d07a47e1654c Mon Sep 17 00:00:00 2001 From: Chris Marchbanks Date: Mon, 3 Mar 2025 09:22:16 -0700 Subject: [PATCH 28/32] Update versions for docs Github actions (#1096) With the deprecation of artifact@v3 the docs pipeline is no longer working. Upgrade all of the versions while in the file. Signed-off-by: Chris Marchbanks --- .github/workflows/github-pages.yaml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/github-pages.yaml b/.github/workflows/github-pages.yaml index 4302b706..f7b864ab 100644 --- a/.github/workflows/github-pages.yaml +++ b/.github/workflows/github-pages.yaml @@ -13,6 +13,7 @@ permissions: contents: read pages: write id-token: write + actions: read # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. @@ -39,13 +40,13 @@ jobs: #- name: Install Dart Sass # run: sudo snap install dart-sass - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 0 - name: Setup Pages id: pages - uses: actions/configure-pages@v3 + uses: actions/configure-pages@v5 - name: Install Node.js dependencies run: "[[ -f package-lock.json || -f npm-shrinkwrap.json ]] && npm ci || true" working-directory: ./docs @@ -61,7 +62,7 @@ jobs: --baseURL "${{ steps.pages.outputs.base_url }}/" working-directory: ./docs - name: Upload artifact - uses: actions/upload-pages-artifact@v1 + uses: actions/upload-pages-artifact@v3 with: path: ./docs/public @@ -75,4 +76,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v2 + uses: actions/deploy-pages@v4 From c1ff3b28d32ff78a1a6ec0ddd8f81b70ca365b3f Mon Sep 17 00:00:00 2001 From: "Ethan S. Chen" Date: Tue, 18 Mar 2025 22:50:01 +0800 Subject: [PATCH 29/32] Update docs (#1097) Signed-off-by: Ethan S. Chen --- .github/workflows/github-pages.yaml | 2 +- docs/.gitignore | 2 +- docs/README.md | 39 +- docs/content/_index.md | 48 +- docs/content/getting-started/_index.md | 4 - .../getting-started/three-step-demo.md | 48 -- docs/hugo.toml | 101 +++- docs/themes/hugo-geekdoc/.nvmrc | 1 + docs/themes/hugo-geekdoc/README.md | 2 +- docs/themes/hugo-geekdoc/VERSION | 2 +- docs/themes/hugo-geekdoc/data/assets.json | 502 ++++++++++++++---- docs/themes/hugo-geekdoc/eslint.config.js | 22 + docs/themes/hugo-geekdoc/i18n/am.yaml | 52 ++ docs/themes/hugo-geekdoc/i18n/da.yaml | 53 ++ docs/themes/hugo-geekdoc/i18n/fr.yaml | 53 ++ docs/themes/hugo-geekdoc/i18n/oc.yaml | 53 ++ docs/themes/hugo-geekdoc/layouts/404.html | 2 +- .../hugo-geekdoc/layouts/_default/baseof.html | 13 +- .../hugo-geekdoc/layouts/_default/list.html | 1 + .../hugo-geekdoc/layouts/_default/single.html | 2 + .../layouts/_default/taxonomy.html | 2 + .../hugo-geekdoc/layouts/_default/terms.html | 2 + .../layouts/partials/head/others.html | 2 +- .../layouts/partials/language.html | 2 +- ...menu-nextprev.html => menu-bundle-np.html} | 57 +- .../layouts/partials/menu-bundle.html | 7 +- .../layouts/partials/menu-extra.html | 9 +- .../layouts/partials/menu-filetree-np.html | 107 ++++ .../partials/microformats/opengraph.html | 2 +- .../layouts/partials/microformats/schema.html | 4 +- .../partials/microformats/twitter_cards.html | 2 +- .../layouts/partials/page-metadata.html | 13 + .../hugo-geekdoc/layouts/partials/search.html | 2 +- .../layouts/partials/site-header.html | 4 +- .../hugo-geekdoc/layouts/posts/list.html | 2 + .../hugo-geekdoc/layouts/posts/single.html | 2 +- .../layouts/shortcodes/audio.html | 23 + .../layouts/shortcodes/avatar.html | 57 ++ .../layouts/shortcodes/columns.html | 2 +- .../hugo-geekdoc/layouts/shortcodes/gist.html | 1 + .../hugo-geekdoc/layouts/shortcodes/hint.html | 11 +- .../hugo-geekdoc/layouts/shortcodes/img.html | 50 +- .../layouts/shortcodes/progress.html | 3 +- .../layouts/shortcodes/propertylist.html | 5 +- .../layouts/shortcodes/toc-tree.html | 36 +- .../static/favicon/android-chrome-144x144.png | Bin 4506 -> 2246 bytes .../static/favicon/android-chrome-192x192.png | Bin 6247 -> 3210 bytes .../static/favicon/android-chrome-256x256.png | Bin 8700 -> 4149 bytes .../static/favicon/android-chrome-36x36.png | Bin 1185 -> 682 bytes .../static/favicon/android-chrome-384x384.png | Bin 14691 -> 7255 bytes .../static/favicon/android-chrome-48x48.png | Bin 1454 -> 785 bytes .../static/favicon/android-chrome-512x512.png | Bin 21745 -> 10167 bytes .../static/favicon/android-chrome-72x72.png | Bin 2173 -> 1228 bytes .../static/favicon/android-chrome-96x96.png | Bin 2902 -> 1455 bytes .../favicon/apple-touch-icon-1024x1024.png | Bin 44655 -> 28555 bytes .../favicon/apple-touch-icon-114x114.png | Bin 3939 -> 1817 bytes .../favicon/apple-touch-icon-120x120.png | Bin 3848 -> 1708 bytes .../favicon/apple-touch-icon-144x144.png | Bin 4651 -> 2446 bytes .../favicon/apple-touch-icon-152x152.png | Bin 4904 -> 2581 bytes .../favicon/apple-touch-icon-167x167.png | Bin 5418 -> 2881 bytes .../favicon/apple-touch-icon-180x180.png | Bin 5488 -> 2714 bytes .../static/favicon/apple-touch-icon-57x57.png | Bin 1935 -> 982 bytes .../static/favicon/apple-touch-icon-60x60.png | Bin 1986 -> 932 bytes .../static/favicon/apple-touch-icon-72x72.png | Bin 2173 -> 1273 bytes .../static/favicon/apple-touch-icon-76x76.png | Bin 2307 -> 1257 bytes .../favicon/apple-touch-icon-precomposed.png | Bin 5488 -> 2714 bytes .../static/favicon/apple-touch-icon.png | Bin 5488 -> 2714 bytes .../apple-touch-startup-image-1125x2436.png | Bin 90067 -> 34421 bytes .../apple-touch-startup-image-1136x640.png | Bin 35771 -> 14264 bytes .../apple-touch-startup-image-1170x2532.png | Bin 96814 -> 35593 bytes .../apple-touch-startup-image-1179x2556.png | Bin 0 -> 36122 bytes .../apple-touch-startup-image-1242x2208.png | Bin 99127 -> 38178 bytes .../apple-touch-startup-image-1242x2688.png | Bin 105836 -> 38449 bytes .../apple-touch-startup-image-1284x2778.png | Bin 109562 -> 40415 bytes .../apple-touch-startup-image-1290x2796.png | Bin 0 -> 38496 bytes .../apple-touch-startup-image-1334x750.png | Bin 45112 -> 17849 bytes .../apple-touch-startup-image-1488x2266.png | Bin 0 -> 46150 bytes .../apple-touch-startup-image-1536x2048.png | Bin 126468 -> 48063 bytes .../apple-touch-startup-image-1620x2160.png | Bin 139509 -> 50761 bytes .../apple-touch-startup-image-1640x2160.png | Bin 0 -> 51512 bytes .../apple-touch-startup-image-1668x2224.png | Bin 143774 -> 52584 bytes .../apple-touch-startup-image-1668x2388.png | Bin 145114 -> 53231 bytes .../apple-touch-startup-image-1792x828.png | Bin 53255 -> 19743 bytes .../apple-touch-startup-image-2048x1536.png | Bin 129401 -> 47501 bytes .../apple-touch-startup-image-2048x2732.png | Bin 204315 -> 68870 bytes .../apple-touch-startup-image-2160x1620.png | Bin 142250 -> 50168 bytes .../apple-touch-startup-image-2160x1640.png | Bin 0 -> 50719 bytes .../apple-touch-startup-image-2208x1242.png | Bin 100183 -> 36319 bytes .../apple-touch-startup-image-2224x1668.png | Bin 145637 -> 52053 bytes .../apple-touch-startup-image-2266x1488.png | Bin 0 -> 45351 bytes .../apple-touch-startup-image-2388x1668.png | Bin 146825 -> 52019 bytes .../apple-touch-startup-image-2436x1125.png | Bin 88281 -> 31694 bytes .../apple-touch-startup-image-2532x1170.png | Bin 94235 -> 32612 bytes .../apple-touch-startup-image-2556x1179.png | Bin 0 -> 33061 bytes .../apple-touch-startup-image-2688x1242.png | Bin 102979 -> 35333 bytes .../apple-touch-startup-image-2732x2048.png | Bin 205972 -> 67100 bytes .../apple-touch-startup-image-2778x1284.png | Bin 111486 -> 37048 bytes .../apple-touch-startup-image-2796x1290.png | Bin 0 -> 37351 bytes .../apple-touch-startup-image-640x1136.png | Bin 35116 -> 15332 bytes .../apple-touch-startup-image-750x1334.png | Bin 45375 -> 19095 bytes .../apple-touch-startup-image-828x1792.png | Bin 54274 -> 22015 bytes .../static/favicon/browserconfig.xml | 8 +- .../static/favicon/favicon-16x16.png | Bin 616 -> 422 bytes .../static/favicon/favicon-32x32.png | Bin 1140 -> 699 bytes .../static/favicon/favicon-48x48.png | Bin 1900 -> 846 bytes .../hugo-geekdoc/static/favicon/favicon.ico | Bin 33310 -> 33310 bytes .../{manifest.json => manifest.webmanifest} | 25 +- .../static/favicon/mstile-144x144.png | Bin 4506 -> 2246 bytes .../static/favicon/mstile-150x150.png | Bin 4412 -> 2104 bytes .../static/favicon/mstile-310x150.png | Bin 4257 -> 2127 bytes .../static/favicon/mstile-310x310.png | Bin 10949 -> 5496 bytes .../static/favicon/mstile-70x70.png | Bin 2125 -> 1113 bytes .../static/fonts/GeekdocIcons.woff | Bin 6140 -> 6140 bytes .../static/fonts/GeekdocIcons.woff2 | Bin 5084 -> 5084 bytes .../static/js/110-f4b990d9.chunk.min.js | 1 + .../static/js/116-341f79d9.chunk.min.js | 1 - .../static/js/118-f1de6a20.chunk.min.js | 1 - .../static/js/12-0b8427d1.chunk.min.js | 1 + .../static/js/130-3b252fb9.chunk.min.js | 1 + .../static/js/164-f339d58d.chunk.min.js | 1 + .../static/js/165-06872da1.chunk.min.js | 2 + ... => 165-06872da1.chunk.min.js.LICENSE.txt} | 8 +- .../static/js/175-405e6b1c.chunk.min.js | 1 + .../static/js/19-86f47ecd.chunk.min.js | 1 - .../static/js/237-c0a3f3fe.chunk.min.js | 1 + .../static/js/240-cd383fa4.chunk.min.js | 1 + .../static/js/244-c41eb325.chunk.min.js | 1 + .../static/js/354-5c1850f7.chunk.min.js | 1 + .../static/js/355-ef4f96e9.chunk.min.js | 1 + .../static/js/357-e9bfa102.chunk.min.js | 1 + .../static/js/361-f7cd601a.chunk.min.js | 1 - .../static/js/383-e450e912.chunk.min.js | 1 + .../static/js/387-3546ecdc.chunk.min.js | 1 + .../static/js/391-549a9d24.chunk.min.js | 1 + .../static/js/410-ec3f5ed1.chunk.min.js | 1 + .../static/js/413-c02a8543.chunk.min.js | 1 + .../static/js/417-65958f5a.chunk.min.js | 1 + .../static/js/423-897d7f17.chunk.min.js | 1 - .../static/js/430-cc171d93.chunk.min.js | 1 - .../static/js/433-f2655a46.chunk.min.js | 1 - .../static/js/438-760c9ed3.chunk.min.js | 1 - .../static/js/452-e65d6d68.chunk.min.js | 1 + .../static/js/476-86e5cf96.chunk.min.js | 1 - .../static/js/485-3b9fa0c4.chunk.min.js | 1 + .../static/js/506-6950d52c.chunk.min.js | 1 - .../static/js/519-8d0cec7f.chunk.min.js | 1 - .../static/js/535-dcead599.chunk.min.js | 1 - .../static/js/540-ae28fd42.chunk.min.js | 1 + .../static/js/545-8e970b03.chunk.min.js | 1 - .../static/js/545-bfa2b46e.chunk.min.js | 1 + .../static/js/546-560b35c2.chunk.min.js | 1 - .../static/js/56-09931933.chunk.min.js | 1 + .../static/js/567-6c3220fd.chunk.min.js | 1 + .../static/js/579-9222afff.chunk.min.js | 1 - .../static/js/626-1706197a.chunk.min.js | 1 - .../static/js/632-7a25d3c6.chunk.min.js | 1 + .../static/js/637-86fbbecd.chunk.min.js | 2 - .../js/637-86fbbecd.chunk.min.js.LICENSE.txt | 9 - .../static/js/639-88c6538a.chunk.min.js | 1 - .../static/js/642-12e7dea2.chunk.min.js | 1 - .../static/js/648-b5ba4bb4.chunk.min.js | 1 + .../static/js/662-17acb8f4.chunk.min.js | 2 - .../static/js/664-ed5252a5.chunk.min.js | 1 + .../static/js/691-2a6930fd.chunk.min.js | 1 + .../static/js/720-970f726e.chunk.min.js | 1 + .../static/js/723-47eb515a.chunk.min.js | 1 + .../static/js/728-5df4a5e5.chunk.min.js | 1 - .../static/js/729-32b017b3.chunk.min.js | 1 - .../static/js/731-6d56a3c0.chunk.min.js | 1 + .../static/js/732-8e5770e7.chunk.min.js | 1 + .../static/js/747-b55f0f97.chunk.min.js | 1 - .../static/js/758-5696af5a.chunk.min.js | 1 + .../static/js/76-732e78f1.chunk.min.js | 1 - .../static/js/771-942a62df.chunk.min.js | 1 - .../static/js/773-8f0c4fb8.chunk.min.js | 1 - .../static/js/81-4e653aac.chunk.min.js | 1 - .../static/js/813-0d3c16f5.chunk.min.js | 1 - .../static/js/825-445b5bd5.chunk.min.js | 1 + .../static/js/890-c9907c95.chunk.min.js | 1 + .../static/js/940-25dfc794.chunk.min.js | 1 - .../static/js/978-382bed37.chunk.min.js | 1 + .../js/colortheme-01ea3db1.bundle.min.js | 1 + .../js/colortheme-d3e4d351.bundle.min.js | 1 - .../static/js/katex-13a419d8.bundle.min.js | 1 + .../static/js/katex-d4d5881d.bundle.min.js | 1 - .../static/js/main-2e274343.bundle.min.js | 2 + ...> main-2e274343.bundle.min.js.LICENSE.txt} | 0 .../static/js/main-924a1933.bundle.min.js | 2 - .../static/js/mermaid-c274c389.bundle.min.js | 2 + ...mermaid-c274c389.bundle.min.js.LICENSE.txt | 7 + .../static/js/mermaid-d305d450.bundle.min.js | 1 - .../static/js/search-16a110ff.bundle.min.js | 2 + .../search-16a110ff.bundle.min.js.LICENSE.txt | 7 + .../static/js/search-9719be99.bundle.min.js | 2 - .../search-9719be99.bundle.min.js.LICENSE.txt | 7 - .../static/katex-66092164.min.css | 1 - .../static/katex-a0da2a32.min.css | 1 + .../hugo-geekdoc/static/main-252d384c.min.css | 1 - .../hugo-geekdoc/static/main-b53472e8.min.css | 1 + ...35ccc12.min.css => print-72068949.min.css} | 2 +- docs/themes/hugo-geekdoc/theme.toml | 2 +- 201 files changed, 1216 insertions(+), 355 deletions(-) delete mode 100644 docs/content/getting-started/_index.md delete mode 100644 docs/content/getting-started/three-step-demo.md create mode 100644 docs/themes/hugo-geekdoc/.nvmrc create mode 100644 docs/themes/hugo-geekdoc/eslint.config.js create mode 100644 docs/themes/hugo-geekdoc/i18n/am.yaml create mode 100644 docs/themes/hugo-geekdoc/i18n/da.yaml create mode 100644 docs/themes/hugo-geekdoc/i18n/fr.yaml create mode 100644 docs/themes/hugo-geekdoc/i18n/oc.yaml rename docs/themes/hugo-geekdoc/layouts/partials/{menu-nextprev.html => menu-bundle-np.html} (57%) create mode 100644 docs/themes/hugo-geekdoc/layouts/partials/menu-filetree-np.html create mode 100644 docs/themes/hugo-geekdoc/layouts/partials/page-metadata.html create mode 100644 docs/themes/hugo-geekdoc/layouts/shortcodes/audio.html create mode 100644 docs/themes/hugo-geekdoc/layouts/shortcodes/avatar.html create mode 100644 docs/themes/hugo-geekdoc/layouts/shortcodes/gist.html create mode 100644 docs/themes/hugo-geekdoc/static/favicon/apple-touch-startup-image-1179x2556.png create mode 100644 docs/themes/hugo-geekdoc/static/favicon/apple-touch-startup-image-1290x2796.png create mode 100644 docs/themes/hugo-geekdoc/static/favicon/apple-touch-startup-image-1488x2266.png create mode 100644 docs/themes/hugo-geekdoc/static/favicon/apple-touch-startup-image-1640x2160.png create mode 100644 docs/themes/hugo-geekdoc/static/favicon/apple-touch-startup-image-2160x1640.png create mode 100644 docs/themes/hugo-geekdoc/static/favicon/apple-touch-startup-image-2266x1488.png create mode 100644 docs/themes/hugo-geekdoc/static/favicon/apple-touch-startup-image-2556x1179.png create mode 100644 docs/themes/hugo-geekdoc/static/favicon/apple-touch-startup-image-2796x1290.png rename docs/themes/hugo-geekdoc/static/favicon/{manifest.json => manifest.webmanifest} (64%) create mode 100644 docs/themes/hugo-geekdoc/static/js/110-f4b990d9.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/116-341f79d9.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/118-f1de6a20.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/12-0b8427d1.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/130-3b252fb9.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/164-f339d58d.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/165-06872da1.chunk.min.js rename docs/themes/hugo-geekdoc/static/js/{662-17acb8f4.chunk.min.js.LICENSE.txt => 165-06872da1.chunk.min.js.LICENSE.txt} (56%) create mode 100644 docs/themes/hugo-geekdoc/static/js/175-405e6b1c.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/19-86f47ecd.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/237-c0a3f3fe.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/240-cd383fa4.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/244-c41eb325.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/354-5c1850f7.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/355-ef4f96e9.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/357-e9bfa102.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/361-f7cd601a.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/383-e450e912.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/387-3546ecdc.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/391-549a9d24.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/410-ec3f5ed1.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/413-c02a8543.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/417-65958f5a.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/423-897d7f17.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/430-cc171d93.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/433-f2655a46.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/438-760c9ed3.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/452-e65d6d68.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/476-86e5cf96.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/485-3b9fa0c4.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/506-6950d52c.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/519-8d0cec7f.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/535-dcead599.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/540-ae28fd42.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/545-8e970b03.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/545-bfa2b46e.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/546-560b35c2.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/56-09931933.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/567-6c3220fd.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/579-9222afff.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/626-1706197a.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/632-7a25d3c6.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/637-86fbbecd.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/637-86fbbecd.chunk.min.js.LICENSE.txt delete mode 100644 docs/themes/hugo-geekdoc/static/js/639-88c6538a.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/642-12e7dea2.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/648-b5ba4bb4.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/662-17acb8f4.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/664-ed5252a5.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/691-2a6930fd.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/720-970f726e.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/723-47eb515a.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/728-5df4a5e5.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/729-32b017b3.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/731-6d56a3c0.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/732-8e5770e7.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/747-b55f0f97.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/758-5696af5a.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/76-732e78f1.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/771-942a62df.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/773-8f0c4fb8.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/81-4e653aac.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/813-0d3c16f5.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/825-445b5bd5.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/890-c9907c95.chunk.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/940-25dfc794.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/978-382bed37.chunk.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/colortheme-01ea3db1.bundle.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/colortheme-d3e4d351.bundle.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/katex-13a419d8.bundle.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/katex-d4d5881d.bundle.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/main-2e274343.bundle.min.js rename docs/themes/hugo-geekdoc/static/js/{main-924a1933.bundle.min.js.LICENSE.txt => main-2e274343.bundle.min.js.LICENSE.txt} (100%) delete mode 100644 docs/themes/hugo-geekdoc/static/js/main-924a1933.bundle.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/mermaid-c274c389.bundle.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/mermaid-c274c389.bundle.min.js.LICENSE.txt delete mode 100644 docs/themes/hugo-geekdoc/static/js/mermaid-d305d450.bundle.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/search-16a110ff.bundle.min.js create mode 100644 docs/themes/hugo-geekdoc/static/js/search-16a110ff.bundle.min.js.LICENSE.txt delete mode 100644 docs/themes/hugo-geekdoc/static/js/search-9719be99.bundle.min.js delete mode 100644 docs/themes/hugo-geekdoc/static/js/search-9719be99.bundle.min.js.LICENSE.txt delete mode 100644 docs/themes/hugo-geekdoc/static/katex-66092164.min.css create mode 100644 docs/themes/hugo-geekdoc/static/katex-a0da2a32.min.css delete mode 100644 docs/themes/hugo-geekdoc/static/main-252d384c.min.css create mode 100644 docs/themes/hugo-geekdoc/static/main-b53472e8.min.css rename docs/themes/hugo-geekdoc/static/{print-735ccc12.min.css => print-72068949.min.css} (75%) diff --git a/.github/workflows/github-pages.yaml b/.github/workflows/github-pages.yaml index f7b864ab..621f2d73 100644 --- a/.github/workflows/github-pages.yaml +++ b/.github/workflows/github-pages.yaml @@ -31,7 +31,7 @@ jobs: build: runs-on: ubuntu-latest env: - HUGO_VERSION: 0.115.4 + HUGO_VERSION: 0.145.0 steps: - name: Install Hugo CLI run: | diff --git a/docs/.gitignore b/docs/.gitignore index 2a8645fe..5c41f011 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -1 +1 @@ -.hugo_build.lock +.hugo_build.lock \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index e9248d5d..a9e5c37a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,37 +1,32 @@ -Docs ----- +# Docs This directory contains [hugo](https://gohugo.io) documentation to be published in Github pages. -Run Locally ------------ +## Dependencies -``` +- [Geekdocs v1.5.0](https://github.com/thegeeklab/hugo-geekdoc/releases/tag/v1.5.0) +- [Hugo v0.145.0](https://github.com/gohugoio/hugo/releases/tag/v0.145.0) + +## Run Locally + +To serve the documentation locally, run the following command: + +```shell hugo server -D ``` This will serve the docs on [http://localhost:1313](http://localhost:1313). -Deploy to Github Pages ----------------------- - -Changes to the `main` branch will be deployed automatically with Github actions. - -Update Geekdocs ---------------- +## Update Geekdocs The docs use the [Geekdocs](https://geekdocs.de/) theme. The theme is checked in to Github in the `./docs/themes/hugo-geekdoc/` folder. To update [Geekdocs](https://geekdocs.de/), remove the current folder and create a new one with the latest [release](https://github.com/thegeeklab/hugo-geekdoc/releases). There are no local modifications in `./docs/themes/hugo-geekdoc/`. -Notes ------ - -Here's how the initial `docs/` folder was set up: - -``` -hugo new site docs -cd docs/ +```shell +rm -rf ./docs/themes/hugo-geekdoc mkdir -p themes/hugo-geekdoc/ -curl -L https://github.com/thegeeklab/hugo-geekdoc/releases/download/v0.41.1/hugo-geekdoc.tar.gz | tar -xz -C themes/hugo-geekdoc/ --strip-components=1 +curl -L https://github.com/thegeeklab/hugo-geekdoc/releases/latest/download/hugo-geekdoc.tar.gz | tar -xz -C themes/hugo-geekdoc/ --strip-components=1 ``` -Create the initial `hugo.toml` file as described in [https://geekdocs.de/usage/getting-started/](https://geekdocs.de/usage/getting-started/). +## Deploy to Github Pages + +Changes to the `master` branch will be deployed automatically with Github actions. diff --git a/docs/content/_index.md b/docs/content/_index.md index e8c571d2..1be1b90c 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -1,5 +1,49 @@ --- -title: "client_python" +title: client_python +weight: 1 --- -This is the documentation for the [Prometheus Python client library](https://github.com/prometheus/client_python). +This tutorial shows the quickest way to get started with the Prometheus Python library. + +**One**: Install the client: + +```shell +pip install prometheus-client +``` + +**Two**: Paste the following into a Python interpreter: + +```python +from prometheus_client import start_http_server, Summary +import random +import time + +# Create a metric to track time spent and requests made. +REQUEST_TIME = Summary('request_processing_seconds', 'Time spent processing request') + +# Decorate function with metric. +@REQUEST_TIME.time() +def process_request(t): + """A dummy function that takes some time.""" + time.sleep(t) + +if __name__ == '__main__': + # Start up the server to expose the metrics. + start_http_server(8000) + # Generate some requests. + while True: + process_request(random.random()) +``` + +**Three**: Visit [http://localhost:8000/](http://localhost:8000/) to view the metrics. + +From one easy to use decorator you get: + +* `request_processing_seconds_count`: Number of times this function was called. +* `request_processing_seconds_sum`: Total amount of time spent in this function. + +Prometheus's `rate` function allows calculation of both requests per second, +and latency over time from this data. + +In addition if you're on Linux the `process` metrics expose CPU, memory and +other information about the process for free! diff --git a/docs/content/getting-started/_index.md b/docs/content/getting-started/_index.md deleted file mode 100644 index 42726911..00000000 --- a/docs/content/getting-started/_index.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Getting Started -weight: 1 ---- diff --git a/docs/content/getting-started/three-step-demo.md b/docs/content/getting-started/three-step-demo.md deleted file mode 100644 index bf06e39f..00000000 --- a/docs/content/getting-started/three-step-demo.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -title: Three Step Demo -weight: 1 ---- - -This tutorial shows the quickest way to get started with the Prometheus Python library. - -# Three Step Demo - -**One**: Install the client: -``` -pip install prometheus-client -``` - -**Two**: Paste the following into a Python interpreter: -```python -from prometheus_client import start_http_server, Summary -import random -import time - -# Create a metric to track time spent and requests made. -REQUEST_TIME = Summary('request_processing_seconds', 'Time spent processing request') - -# Decorate function with metric. -@REQUEST_TIME.time() -def process_request(t): - """A dummy function that takes some time.""" - time.sleep(t) - -if __name__ == '__main__': - # Start up the server to expose the metrics. - start_http_server(8000) - # Generate some requests. - while True: - process_request(random.random()) -``` - -**Three**: Visit [http://localhost:8000/](http://localhost:8000/) to view the metrics. - -From one easy to use decorator you get: - * `request_processing_seconds_count`: Number of times this function was called. - * `request_processing_seconds_sum`: Total amount of time spent in this function. - -Prometheus's `rate` function allows calculation of both requests per second, -and latency over time from this data. - -In addition if you're on Linux the `process` metrics expose CPU, memory and -other information about the process for free! diff --git a/docs/hugo.toml b/docs/hugo.toml index 08885493..9e3e6037 100644 --- a/docs/hugo.toml +++ b/docs/hugo.toml @@ -5,20 +5,19 @@ theme = "hugo-geekdoc" pluralizeListTitles = false -# Geekdoc required configuration -#pygmentsUseClasses = true -pygmentsUseClasses = false +# Required to get well formatted code blocks +pygmentsUseClasses = true pygmentsCodeFences = true disablePathToLower = true -# geekdocFileTreeSortBy = "linkTitle" +enableGitInfo = true # Required if you want to render robots.txt template enableRobotsTXT = true -# Needed for mermaid shortcodes [markup] [markup.goldmark.renderer] - # Needed for mermaid shortcode + # Needed for mermaid shortcode or when nesting shortcodes (e.g. img within + # columns or tabs) unsafe = true [markup.tableOfContents] startLevel = 1 @@ -28,3 +27,93 @@ enableRobotsTXT = true [taxonomies] tag = "tags" + +[params] +# (Optional, default 6) Set how many table of contents levels to be showed on page. +# Use false to hide ToC, note that 0 will default to 6 (https://gohugo.io/functions/default/) +# You can also specify this parameter per page in front matter. +geekdocToC = 3 + +# (Optional, default static/brand.svg) Set the path to a logo for the Geekdoc +# relative to your 'static/' folder. +geekdocLogo = "brand.svg" + +# (Optional, default false) Render menu from data file in 'data/menu/main.yaml'. +# See also https://geekdocs.de/usage/menus/#bundle-menu. +geekdocMenuBundle = false + +# (Optional, default false) Collapse all menu entries, can not be overwritten +# per page if enabled. Can be enabled per page via 'geekdocCollapseSection'. +geekdocCollapseAllSections = false + +# (Optional, default true) Show page navigation links at the bottom of each docs page. +geekdocNextPrev = true + +# (Optional, default true) Show a breadcrumb navigation bar at the top of each docs page. +# You can also specify this parameter per page in front matter. +geekdocBreadcrumb = true + +# (Optional, default none) Set source repository location. Used for 'Edit page' links. +# You can also specify this parameter per page in front matter. +geekdocRepo = "https://github.com/prometheus/client_python" + +# (Optional, default none) Enable 'Edit page' links. Requires 'geekdocRepo' param +# and the path must point to the parent directory of the 'content' folder. +# You can also specify this parameter per page in front matter. +geekdocEditPath = "edit/master/docs" + +# (Optional, default false) Show last modification date of the page in the header. +# Keep in mind that last modification date works best if `enableGitInfo` is set to true. +geekdocPageLastmod = true + +# (Optional, default true) Enables search function with flexsearch. +# Index is built on the fly and might slow down your website. +geekdocSearch = true + +# (Optional, default false) Display search results with the parent folder as prefix. This +# option allows you to distinguish between files with the same name in different folders. +# NOTE: This parameter only applies when 'geekdocSearch = true'. +geekdocSearchShowParent = true + +# (Optional, default true) Add an anchor link to headlines. +geekdocAnchor = true + +# (Optional, default true) Copy anchor url to clipboard on click. +geekdocAnchorCopy = true + +# (Optional, default true) Enable or disable image lazy loading for images rendered +# by the 'img' shortcode. +geekdocImageLazyLoading = true + +# (Optional, default false) Set HTMl to .Site.Home.Permalink if enabled. It might be required +# if a subdirectory is used within Hugos BaseURL. +# See https://developer.mozilla.org/de/docs/Web/HTML/Element/base. +geekdocOverwriteHTMLBase = false + +# (Optional, default true) Enable or disable the JavaScript based color theme toggle switch. The CSS based +# user preference mode still works. +geekdocDarkModeToggle = true + +# (Optional, default false) Auto-decrease brightness of images and add a slightly grayscale to avoid +# bright spots while using the dark mode. +geekdocDarkModeDim = false + +# (Optional, default false) Enforce code blocks to always use the dark color theme. +geekdocDarkModeCode = false + +# (Optional, default true) Display a "Back to top" link in the site footer. +geekdocBackToTop = true + +# (Optional, default false) Enable or disable adding tags for post pages automatically to the navigation sidebar. +geekdocTagsToMenu = true + +# (Optional, default 'title') Configure how to sort file-tree menu entries. Possible options are 'title', 'linktitle', +# 'date', 'publishdate', 'expirydate' or 'lastmod'. Every option can be used with a reverse modifier as well +# e.g. 'title_reverse'. +geekdocFileTreeSortBy = "title" + +# (Optional, default none) Adds a "Content licensed under " line to the footer. +# Could be used if you want to define a default license for your content. +[params.geekdocContentLicense] + name = "Apache License 2.0" + link = "https://github.com/prometheus/client_python/blob/master/LICENSE" diff --git a/docs/themes/hugo-geekdoc/.nvmrc b/docs/themes/hugo-geekdoc/.nvmrc new file mode 100644 index 00000000..b009dfb9 --- /dev/null +++ b/docs/themes/hugo-geekdoc/.nvmrc @@ -0,0 +1 @@ +lts/* diff --git a/docs/themes/hugo-geekdoc/README.md b/docs/themes/hugo-geekdoc/README.md index 99358d83..b03365fb 100644 --- a/docs/themes/hugo-geekdoc/README.md +++ b/docs/themes/hugo-geekdoc/README.md @@ -1,7 +1,7 @@ # Geekdoc [![Build Status](https://ci.thegeeklab.de/api/badges/thegeeklab/hugo-geekdoc/status.svg)](https://ci.thegeeklab.de/repos/thegeeklab/hugo-geekdoc) -[![Hugo Version](https://img.shields.io/badge/hugo-0.112-blue.svg)](https://gohugo.io) +[![Hugo Version](https://img.shields.io/badge/hugo-0.124-blue.svg)](https://gohugo.io) [![GitHub release](https://img.shields.io/github/v/release/thegeeklab/hugo-geekdoc)](https://github.com/thegeeklab/hugo-geekdoc/releases/latest) [![GitHub contributors](https://img.shields.io/github/contributors/thegeeklab/hugo-geekdoc)](https://github.com/thegeeklab/hugo-geekdoc/graphs/contributors) [![License: MIT](https://img.shields.io/github/license/thegeeklab/hugo-geekdoc)](https://github.com/thegeeklab/hugo-geekdoc/blob/main/LICENSE) diff --git a/docs/themes/hugo-geekdoc/VERSION b/docs/themes/hugo-geekdoc/VERSION index d0cca40a..2e7bd910 100644 --- a/docs/themes/hugo-geekdoc/VERSION +++ b/docs/themes/hugo-geekdoc/VERSION @@ -1 +1 @@ -v0.41.1 +v1.5.0 diff --git a/docs/themes/hugo-geekdoc/data/assets.json b/docs/themes/hugo-geekdoc/data/assets.json index 9a34ed54..29e02b0d 100644 --- a/docs/themes/hugo-geekdoc/data/assets.json +++ b/docs/themes/hugo-geekdoc/data/assets.json @@ -1,155 +1,451 @@ { "main.js": { - "src": "js/main-924a1933.bundle.min.js", - "integrity": "sha512-0QF6awwW0WbBo491yytmULiHrc9gx94bloJ9MSXIvdJh3YHWw7CWyeX2YXu0rzOQefJp4jW/I6ZjUDYpNVFhdA==" + "src": "js/main-2e274343.bundle.min.js", + "integrity": "sha512-Atj/tzetkQMROhw9Vq9Eg5cDA5Zw0j04fDL3AZNrqod5fQqakcgtNBSMvmEIOLPq6YiWX5Qu9x89m71mc/YjmA==" }, "colortheme.js": { - "src": "js/colortheme-d3e4d351.bundle.min.js", - "integrity": "sha512-HpQogL/VeKqG/v1qYOfJOgFUzBnQvW4yO4tAJO+54IiwbLbB9feROdeaYf7dpO6o5tSHsSZhaYLhtLMRlEgpJQ==" + "src": "js/colortheme-01ea3db1.bundle.min.js", + "integrity": "sha512-HC/hlLHETnfrQ4/mC/m71pSucEoWaEohD68gPOYu1LV31pQJoTNAYrmugK/FE9cHB9S9k+lL6yMJY1wd9/KGaQ==" }, "mermaid.js": { - "src": "js/mermaid-d305d450.bundle.min.js", - "integrity": "sha512-TASG03QptoVv1mkfOL47vm5A5kvmyOrnsi8PXhc82j1+FuHZuMOHXc2x5/jGEkOxbKi7mum0h/W7qYhrV29raw==" + "src": "js/mermaid-c274c389.bundle.min.js", + "integrity": "sha512-EXY62vn1+kNduAXY+yQZi81u98IWTkjuvEj55Ge8rAIKWEUG/dddkwfkT+Nc0yoje7w27Ff1WJ9Wy8DYctmq4A==" }, "katex.js": { - "src": "js/katex-d4d5881d.bundle.min.js", - "integrity": "sha512-M8CLtMTu/HVXo11Et+lv3OqPanLf5Bl+GljNAn2yQuLclg/ZpZK1KUpHDRsZJhmkhCcCH90+bVj5CW3lLlmBgg==" + "src": "js/katex-13a419d8.bundle.min.js", + "integrity": "sha512-6MAwLoWro9jZYvZmrGTfM5oe46Ycf/V5eXGyEAIwvuxxxmbqJttAxkkJE/00mOO11Fb21BmI1udcGRb8CbFpWw==" }, "search.js": { - "src": "js/search-9719be99.bundle.min.js", - "integrity": "sha512-/7NZxFUEbalC/8RKDgfAsHFDI42/Ydp33uJmCLckZgnO+kuz9LrTfmPFfVJxPJ31StMxa3MTQ5Jq049CmNK4pw==" + "src": "js/search-16a110ff.bundle.min.js", + "integrity": "sha512-3GlBrQ51hc6WxYIDcSvYztzli9Qk6DwulVxXhZHNP/oSaK0kulRkdrMSunRTd1eb1SJE08VI/YLylmxZ5SV0hw==" }, - "js/637-86fbbecd.chunk.min.js": { - "src": "js/637-86fbbecd.chunk.min.js", - "integrity": "sha512-vD1y0C4MPPV/JhEKmNVAye9SQg7mB5v87nLf63keSALdnM7P+L0ybjEn2MzYzVTzs6JnOCryM7A6/t0TkYucDA==" + "js/664-ed5252a5.chunk.min.js": { + "src": "js/664-ed5252a5.chunk.min.js", + "integrity": "sha512-9omg+hspX9YOzPdRzN6S5swqg1DLRMxHRUyDd6e6ECkkn6h7YrLzLrdRbbtIk0gAsnpzT9luk7XFvAO2wsrXZg==" }, - "js/116-341f79d9.chunk.min.js": { - "src": "js/116-341f79d9.chunk.min.js", - "integrity": "sha512-F7tq1KsF5mnJl0AAA6x2jZcx8x69kEZrUIZJJM4RZ1KlEO79yrrFHf4CZRvhNrYOxmkBpkQ84U9J0vFtRPKjTw==" + "js/485-3b9fa0c4.chunk.min.js": { + "src": "js/485-3b9fa0c4.chunk.min.js", + "integrity": "sha512-ho38krMSQfeNoX46V7dFr28Hnn2bOpO9QWLB0sxZuX2GP6xwemk7cxLiGE9dM3qYGOlrU0eLqRvzPeoxFsF2Jw==" }, - "js/545-8e970b03.chunk.min.js": { - "src": "js/545-8e970b03.chunk.min.js", - "integrity": "sha512-vDOXX1FstnT8UMkRIAMn6z4ucL8LVqI5kZw+T7LrD8pGC9xtKwwhWcmNeqnngc7FHc5Ogt7ppXBNp+uFPUgrJg==" + "js/417-65958f5a.chunk.min.js": { + "src": "js/417-65958f5a.chunk.min.js", + "integrity": "sha512-bjVOwri6TCc8gl4cHEshCj9dWqVYQis5TVOaQHyWntIeK1WbiB1Q2/AcHlnTT4ROpK2uh5IiyRjUkVJvXB/PVw==" }, - "js/728-5df4a5e5.chunk.min.js": { - "src": "js/728-5df4a5e5.chunk.min.js", - "integrity": "sha512-vX2dPV1VjOgv8DP4XbZ9xk9ZzHS9hPAUwPfHM8UG42efxXxH/qCqTpyavqob98onxR2FuiF+j1Vn56d3wqsgaw==" + "js/978-382bed37.chunk.min.js": { + "src": "js/978-382bed37.chunk.min.js", + "integrity": "sha512-+2O8x3RNKRlmtGca+J3x+K/62n6rKSS3qIPwtm2vWRKIccYHA5LT9ENlSyvyh4LpaHwd2Dojq6DVGZIAT6Wnhg==" }, - "js/81-4e653aac.chunk.min.js": { - "src": "js/81-4e653aac.chunk.min.js", - "integrity": "sha512-a80h3DpDlMG6HhYXv9n9Q7r1M+rQX5kfJ7sFhfmPHlDRVimult9nn7vvTHFzTzmMFM+tLcfg4pZGd+AkxPcjEw==" + "js/244-c41eb325.chunk.min.js": { + "src": "js/244-c41eb325.chunk.min.js", + "integrity": "sha512-2A6PJtt6+TmkDMr7/jIJ07MIFy2ipT58aSUfZAeIeCi6dXTeoG/5fTT1zLZecZh5GsXGMpNyqKkqZqVwbWzc0A==" }, - "js/430-cc171d93.chunk.min.js": { - "src": "js/430-cc171d93.chunk.min.js", - "integrity": "sha512-cqQyiIE22ZPo2PIPR4eb0DPSu1TWjxgJUKrIIyfVF48gc2vdLKnbHzWBZg6MpiOWYmUvJb5Ki+n5U6qEvNp2KQ==" + "js/354-5c1850f7.chunk.min.js": { + "src": "js/354-5c1850f7.chunk.min.js", + "integrity": "sha512-NvmJwSFujfF+S1n5AbVS+8QvypWYyMyQdui3bP/cMi1XUl+5AB1ou0O4yMC85o0JtZMDImZieFUYNoM8yTHKXw==" }, - "js/729-32b017b3.chunk.min.js": { - "src": "js/729-32b017b3.chunk.min.js", - "integrity": "sha512-KAW7lnN0NHmIzfD6aIwVaU3TcpUkO8ladLrbOE83zq80NOJH/MGS4Ep+2rIfQZTvZP+a7nqZLHkmezfs27c2pw==" + "js/825-445b5bd5.chunk.min.js": { + "src": "js/825-445b5bd5.chunk.min.js", + "integrity": "sha512-NOqFursH5QZiDD7gx0AtWI1DckXYyBkJgt1Gv347PB5otVKbJWQ+s+xdx0GK3pVI2VK6mRCyjqapm3tnxxfEYg==" }, - "js/773-8f0c4fb8.chunk.min.js": { - "src": "js/773-8f0c4fb8.chunk.min.js", - "integrity": "sha512-HxtbZvs0J28pB9fImN8n82aprG/GW0QenIBzC7BHWhEUX6IhmxTeBqG4IZFbbAURG17VegOr2UlJg6w0qaX9gw==" + "js/632-7a25d3c6.chunk.min.js": { + "src": "js/632-7a25d3c6.chunk.min.js", + "integrity": "sha512-PFbvM8X5viF9mjaWocBoQgeM+KSuznQEEXUtaqbdQSEedobqaHx0Q3Dn9ZYj2d20GGy/HZ9wRSppFw5yTdzC1w==" }, - "js/433-f2655a46.chunk.min.js": { - "src": "js/433-f2655a46.chunk.min.js", - "integrity": "sha512-/yMUz6rxhVpvCPpQG+f28jFgdJK+X/5/3XWVsrAE2FHC57jxnHaL7SxZluZ4klUl0YsRCrhxAQj8maNspwwH1Q==" + "js/545-bfa2b46e.chunk.min.js": { + "src": "js/545-bfa2b46e.chunk.min.js", + "integrity": "sha512-OnH4AMYVIBwonc5gh4Uzp45VbP5cDn/9zhXxDDSXHVNWrO3sHG1lA7yhXFEJ1mll+GGihSe1ev81h+SlQmg5Gg==" }, - "js/546-560b35c2.chunk.min.js": { - "src": "js/546-560b35c2.chunk.min.js", - "integrity": "sha512-am1/hYno7/cFQ8reHZqnbsth2KcphvKqLfkueVIm8I/i/6f9u+bbc0Z6zpYTLysl3oWddYXqyeO58zXoJoDVIA==" + "js/413-c02a8543.chunk.min.js": { + "src": "js/413-c02a8543.chunk.min.js", + "integrity": "sha512-Aa//KQ7fjdgdywKY9oeq2Uf6OHJp/pZ4B0qCFfF+YIuY2+3p4fVXVZN/gvxKle9CDDtfnqhZH2ni9o+Mgnv0Sg==" }, - "js/118-f1de6a20.chunk.min.js": { - "src": "js/118-f1de6a20.chunk.min.js", - "integrity": "sha512-tikydCOmBT1keN0AlCqvkpvbV1gB9U8lVXX8wmrS6fQ2faNc8DnH1QV9dzAlLtGeA1p8HAXnh+AevnVKxhXVbg==" + "js/540-ae28fd42.chunk.min.js": { + "src": "js/540-ae28fd42.chunk.min.js", + "integrity": "sha512-0i637ntjWOeUhb3hduyWJNcs/m8Fqa1BHgX++swq8/3SspywUV77sZy4NmnazHVXoMg4BLu1RMr7caAb9iJu6A==" }, - "js/19-86f47ecd.chunk.min.js": { - "src": "js/19-86f47ecd.chunk.min.js", - "integrity": "sha512-qRG0UrV25Kr/36tJTPZ49QobR6a/zv2BRAMDzSZwjlPgqSwse1HtgP9EEZtn59b1Vq7ayB1LoWfB9MZ9Gcm7Gw==" + "js/391-549a9d24.chunk.min.js": { + "src": "js/391-549a9d24.chunk.min.js", + "integrity": "sha512-aty4EWAqEyrg6iKWtLcl+5hBkQVP8ZffyeP1XlKEqRswjATpDiRpzpy+5sSTaMo+ZQuim88zQtIFcQQbrteoYA==" }, - "js/361-f7cd601a.chunk.min.js": { - "src": "js/361-f7cd601a.chunk.min.js", - "integrity": "sha512-7kwaFQhXUyiM/v2v0n6vI9wz6nSAu7sz2236r+MbwT0r4aBxYqeOxij+PkGnTUqR2n1UExnbWKjuruDi9V/H5g==" + "js/56-09931933.chunk.min.js": { + "src": "js/56-09931933.chunk.min.js", + "integrity": "sha512-RI+ajv2yYUjfck1pGhd31E+MmsEYcTuA7TGRp4vO9lC43SnWaNgSSwQnETy0SAX3oDBJai8sPcMX6+Mivtn6kw==" }, - "js/519-8d0cec7f.chunk.min.js": { - "src": "js/519-8d0cec7f.chunk.min.js", - "integrity": "sha512-tFsZN3iyUMIMeB/b4E1PZNOFDKqMM4Fes63RGNkHNhtRTL/AIUpqPcTKZ+Fi2ZTdyYvPSTtjc5urnzLUi196Wg==" + "js/732-8e5770e7.chunk.min.js": { + "src": "js/732-8e5770e7.chunk.min.js", + "integrity": "sha512-02TlRFXaL+tKrGpT07d9ebyABzylckAdWZmay3gSfuF5RfN3sfwh7C/ljNbaPXqbfn0/GKOmdP81Mce4xPOp7g==" }, - "js/747-b55f0f97.chunk.min.js": { - "src": "js/747-b55f0f97.chunk.min.js", - "integrity": "sha512-hoyvC5SSJcX9NGij9J9l4Ov1JAFNBX0UxlFXyiB5TC7TGW3lgIvm41zyfKhLyJudVGftY/qKxIO2EYtYD2pqOQ==" + "js/110-f4b990d9.chunk.min.js": { + "src": "js/110-f4b990d9.chunk.min.js", + "integrity": "sha512-W8OJfGeXIVhZ5HUkL/9zwaRBePcRIgqWFZSii2C6BOTBcO7rx8RyvwnGynRIB1fQ+xg5sqbuPpzDE1K0S4VjrQ==" }, - "js/642-12e7dea2.chunk.min.js": { - "src": "js/642-12e7dea2.chunk.min.js", - "integrity": "sha512-ZVUj7NYSa8mMHdWaztAf3DCg7qznXTbMWWwqJaS2nfaqh0lVDOf5kVExPy6SGkXCeNu/B9gGbWLtDUa7kHFF6A==" + "js/237-c0a3f3fe.chunk.min.js": { + "src": "js/237-c0a3f3fe.chunk.min.js", + "integrity": "sha512-VBL69Cyj7saW+K5+F8gL24kjjjyp6l9BUV7Ombfmk7+sw3uIA3W2rmbkO4loKkrcTA3HyR5+6AZ6nAArWjFyfg==" }, - "js/626-1706197a.chunk.min.js": { - "src": "js/626-1706197a.chunk.min.js", - "integrity": "sha512-OlpbPXiGmQJR/ISfBSsHU2UGATggZDuHbopvAhhfVpw7ObMZgc/UvE6pK1FmGCjgI9iS+qmPhQyvf9SIbLFyxQ==" + "js/691-2a6930fd.chunk.min.js": { + "src": "js/691-2a6930fd.chunk.min.js", + "integrity": "sha512-HMvZTv5pI+P3wyGu4E2zcg01INdohp+ttXix+3UUxm70Ejf/kUPqppdJ2gQrdlt/Woor96vt6cFGs7lPQlZagg==" }, - "js/438-760c9ed3.chunk.min.js": { - "src": "js/438-760c9ed3.chunk.min.js", - "integrity": "sha512-Wo2DxS59Y8DVBTWNWDUmg6V+UCyLoiDd4sPs2xc7TkflQy7reGWPt/oHZCANXeGjZPpqcR3qfKYidNofUyIWEA==" + "js/383-e450e912.chunk.min.js": { + "src": "js/383-e450e912.chunk.min.js", + "integrity": "sha512-YTYkszF4ql46H1hY2ZixoxMez9Ep6Bh2KV8MHnkYJgeCpOq5lcrY8lITtc/6rTcn/6wppwPK/suPlLGqdCmLWg==" }, - "js/639-88c6538a.chunk.min.js": { - "src": "js/639-88c6538a.chunk.min.js", - "integrity": "sha512-KbTKHxx+/Xwv+GH8jQsIJ9X1CFaGSsqgeSXnR8pW27YZpFz4ly8R6K7h+yq6P2b2AzQdW7krradZzyNo7Vz26w==" + "js/355-ef4f96e9.chunk.min.js": { + "src": "js/355-ef4f96e9.chunk.min.js", + "integrity": "sha512-JUcjY4KCeyRs+OWDes22CmbbqSU9uefYdcb0CpcCsYoU9Pw97xtYl137OV5RR2Vr+/6eGGQ2yiIX8BOp2tWDzw==" }, - "js/940-25dfc794.chunk.min.js": { - "src": "js/940-25dfc794.chunk.min.js", - "integrity": "sha512-qst5aejItmhzMvZ3CsAXyJe2F3FtLkyZwBqj422/8ViyQptcQFgP3x8bPsLwJEfiWFJVrLJkk4VhwflQuIyDtw==" + "js/648-b5ba4bb4.chunk.min.js": { + "src": "js/648-b5ba4bb4.chunk.min.js", + "integrity": "sha512-mckLNYEm3NMnZ1DnAafEbWgCshyuvNssKAU9sxBM5E/EXCeCwWmnP7RQYujTFA35AXIRq0d9Q7Ux2AsfWhSDMQ==" }, - "js/662-17acb8f4.chunk.min.js": { - "src": "js/662-17acb8f4.chunk.min.js", - "integrity": "sha512-S/UlqDqwt++RzVZMVqjsdCNyhe1xNQ9/Qm38yIphmXfn9VBHzGqobIQTuhloYZVfTE4/GezrH+1T7mdrUqpAKQ==" + "js/357-e9bfa102.chunk.min.js": { + "src": "js/357-e9bfa102.chunk.min.js", + "integrity": "sha512-9aMlQY3ZQwKrMFgRIOGDPHHVFYMCHJbsybK0ZjghpkwKxiP7+ETshuuf0ggeaGus907OqGANF9qTHX+zT15NhA==" }, - "js/579-9222afff.chunk.min.js": { - "src": "js/579-9222afff.chunk.min.js", - "integrity": "sha512-rl3bxxl/uhUFYlIuoHfVQE+VkmxfJr7TAuC/fxOBJXBCCMpdxP0XCPzms1zjEjOVjIs4bi4SUwn8r4STSl09Lg==" + "js/410-ec3f5ed1.chunk.min.js": { + "src": "js/410-ec3f5ed1.chunk.min.js", + "integrity": "sha512-Z1s/OadNKjsxftNbZ9+MLokSdlRkWBTcJwi+jQd9Q87f3ZHocWohb8XXQCER07Fxqq8Sxy/2eVso8UKcuX6CIw==" }, - "js/771-942a62df.chunk.min.js": { - "src": "js/771-942a62df.chunk.min.js", - "integrity": "sha512-8WfA8U1Udlfa6uWAYbdNKJzjlJ91qZ0ZhC+ldKdhghUgilxqA6UmZxHFKGRDQydjOFDk828O28XVmZU2IEvckA==" + "js/175-405e6b1c.chunk.min.js": { + "src": "js/175-405e6b1c.chunk.min.js", + "integrity": "sha512-ddZGV8HI0FH+Y7AnWdgDELXsIiAnkk488LCSdmnHsGqq65JPy0HLT6eXudW766K0XKTUmn8uGog6NvH1FCd4JQ==" }, - "js/506-6950d52c.chunk.min.js": { - "src": "js/506-6950d52c.chunk.min.js", - "integrity": "sha512-h2po0SsM4N3IXiBbNWlIbasxX7zSm5XVDpgYfmsEmcfQkMcFwJtTJGppek085Mxi1XZmrhjfxq2AUtnUs03LJg==" + "js/130-3b252fb9.chunk.min.js": { + "src": "js/130-3b252fb9.chunk.min.js", + "integrity": "sha512-JSvuAP7SZ8ndEm18NeuU27acjLOIOmZXB4TG79Sk0JdFIyn2YgidieBBcbIjn85udp4o4IjANY161KygULGiHQ==" }, - "js/76-732e78f1.chunk.min.js": { - "src": "js/76-732e78f1.chunk.min.js", - "integrity": "sha512-ZjF2oB76jiCtdQNJZ9v1MUJSPaBcZCXmTA2T3qDBuU260uVA99wGeprrNQ3WdHQeK+VYXCq26dOE9w+I3b6Q4w==" + "js/12-0b8427d1.chunk.min.js": { + "src": "js/12-0b8427d1.chunk.min.js", + "integrity": "sha512-EpyKr8i98wqi4fpxW5ux156KszFLW9Iusw7MUMPf26kso3TPEKJYn28wY/giEeqsRL7y7EoX5xQR5O94VU1s8Q==" }, - "js/476-86e5cf96.chunk.min.js": { - "src": "js/476-86e5cf96.chunk.min.js", - "integrity": "sha512-siq24cNKFc1tXGACAQlpbXOb2gRKDnncf39INGAPlnJSiAsYewhNusq1UxhMDFA836eucVq7NzE1TqEYskI0ug==" + "js/890-c9907c95.chunk.min.js": { + "src": "js/890-c9907c95.chunk.min.js", + "integrity": "sha512-gD2gqeomVVlkJ6wgB1VcUPizRgyG4JdQJ0t98yt9pVb07uzkhAAhKSddzxP/OF3tUA2bYZHrUYcgEkDAX5JOjQ==" }, - "js/813-0d3c16f5.chunk.min.js": { - "src": "js/813-0d3c16f5.chunk.min.js", - "integrity": "sha512-gDVyQtM781xlTfyZzuEJ1tnQWyasbFKLRPwgGUF5lpdS3QpW6KTIwCnMuVn2b5XF2qKSxpei9YNIushpBI4ILA==" + "js/452-e65d6d68.chunk.min.js": { + "src": "js/452-e65d6d68.chunk.min.js", + "integrity": "sha512-oOJ9nLMs4Ih5X9kyj5828RYSUg+7Wzcz4QEhURKPZWO1F1dSFNfmih2LJcFvjSdNp8wDepvAUQcQLDz3F7MX9g==" }, - "js/423-897d7f17.chunk.min.js": { - "src": "js/423-897d7f17.chunk.min.js", - "integrity": "sha512-ERAmXYsLT59PDGFPLTHNgaNw5CsaTOK88orlaXr+7SOxf+Yjf5fvDmpXCNJe1odvU6OF4cajtlVM1qO9hzOqWw==" + "js/723-47eb515a.chunk.min.js": { + "src": "js/723-47eb515a.chunk.min.js", + "integrity": "sha512-W5+LIxRrc4yIVvFTgX3mx/Wd1K/HPhtr1j6IanCDprpeNAl2if5eMlCDZDhUJYZSm7ta4s4lb+IkdGaSf7EEKg==" }, - "js/535-dcead599.chunk.min.js": { - "src": "js/535-dcead599.chunk.min.js", - "integrity": "sha512-3gB2l6iJbt94EMd1Xh6udlMXjdHlAbuRKkyl87X/LSuG1fGbfTe11O5ma+un13BBX1wZ1xnHtUv6Fyc3pgbgDg==" + "js/720-970f726e.chunk.min.js": { + "src": "js/720-970f726e.chunk.min.js", + "integrity": "sha512-KZoim0oHUzo3JWb5J9AV6RNVm43jnQJyRBbV8gYTS6te6+h4VYg62lbjrapFwBQmHOMkcyLCp1dH2PqHvL36Qg==" + }, + "js/387-3546ecdc.chunk.min.js": { + "src": "js/387-3546ecdc.chunk.min.js", + "integrity": "sha512-XA2Opiddehmv/Po1naDCYg2seMBBqYOzJbDT1WTvT8gLNVuQaI61Fw1hbCxIIOz2t/5LtnqErZc+tond4WuO5Q==" + }, + "js/164-f339d58d.chunk.min.js": { + "src": "js/164-f339d58d.chunk.min.js", + "integrity": "sha512-oXaJMX/nm2r1p0EZhWyKp58KR6VwF06WafroIHmEiTMD7tDT+KfXf1Ryk+RopXvHx8swAt+DmOcWvsYjBc8+DQ==" + }, + "js/731-6d56a3c0.chunk.min.js": { + "src": "js/731-6d56a3c0.chunk.min.js", + "integrity": "sha512-qWHhPkXehCCi7T5iVy0ZcC3CcJ848ZMAWDEYV13j2Izy4DJpnO4J0P1JBA8XawQ3Xxu0UVDzBjvm35MKadF6Zw==" + }, + "js/567-6c3220fd.chunk.min.js": { + "src": "js/567-6c3220fd.chunk.min.js", + "integrity": "sha512-U8xZDwmMJQAUM8v4ZXJf1167FWhTvq8vrp1GagOOoivFXNw2IdO0mWRhb9NnohVouf+dooRurCBM+YUYfJEjfg==" + }, + "js/165-06872da1.chunk.min.js": { + "src": "js/165-06872da1.chunk.min.js", + "integrity": "sha512-pgEr6k7AOnuHgCibSGCs8oB66vecF5ww5apTymYFD+z1YYCLgtHzS7uacJsMPfrCxheMOg/ng2bfGzc9cS2C3A==" + }, + "js/240-cd383fa4.chunk.min.js": { + "src": "js/240-cd383fa4.chunk.min.js", + "integrity": "sha512-BUDkwZXONurj953rNzKsuDb6BEIFBQqoJ6FvfHZzPvnJpeMeRnxRrxAIAWAf1+5OXH29pUledg7xoZeImUIZKQ==" + }, + "js/758-5696af5a.chunk.min.js": { + "src": "js/758-5696af5a.chunk.min.js", + "integrity": "sha512-Uqo6/Ld+ah7lbiY+GR1vMzOFsAaJ+CVyEgzkRICSKa7PwMvyWQ/pjXZEs0k+yrl3bOpX5ma4TOa7MVn7vtOMLg==" + }, + "favicon/apple-touch-startup-image-2048x2732.png": { + "src": "favicon/apple-touch-startup-image-2048x2732.png", + "integrity": "sha512-pp/8QkfwltmJfJZv6lzhl9bbE+0ltO1lcpXR3432kiV2VCl1SXOiTiJYzU/lVmTO1wMrdyFwHdk0C0ZPauVmUg==" }, "main.scss": { - "src": "main-252d384c.min.css", - "integrity": "sha512-WiV7BVk76Yp0EACJrwdWDk7+WNa+Jyiupi9aCKFrzZyiKkXk7BH+PL2IJcuDQpCMtMBFJEgen2fpKu9ExjjrUQ==" + "src": "main-b53472e8.min.css", + "integrity": "sha512-OdpB6Sg1KAfyLTE+HfAyWywLvzCU8lrsfVurFgj+rCZ3fwMevRyqu6WsHRHmoh3+OJv8RCuy2xbdqFZtSP7OLA==" + }, + "favicon/apple-touch-startup-image-2732x2048.png": { + "src": "favicon/apple-touch-startup-image-2732x2048.png", + "integrity": "sha512-DOw5FcezHTkJ2dDT8agLZlIfrNZoxc0/OTlrkmuYgpRJiIkJykxAYQed0Ysu/MBkfwe6lWDydhlpV8oomWMKgw==" + }, + "favicon/apple-touch-startup-image-1668x2388.png": { + "src": "favicon/apple-touch-startup-image-1668x2388.png", + "integrity": "sha512-Stx19Yj7N6TXbMiFMq03kLQYs1X+ft6zmpwVa/+06q8I48P+8dG64MnC8zvl0PqzYWGwcBtCa8m+/qy5JQHzmw==" + }, + "favicon/apple-touch-startup-image-1668x2224.png": { + "src": "favicon/apple-touch-startup-image-1668x2224.png", + "integrity": "sha512-OJnVL7cFjpYgoqph0ZAAZ0bQMeHZHyYzeasV314vTyarpeyVDZuw0j/U2F/7ldxgFVP+Z67RNfLGfSr6SKqujw==" + }, + "favicon/apple-touch-startup-image-2224x1668.png": { + "src": "favicon/apple-touch-startup-image-2224x1668.png", + "integrity": "sha512-h86d25uMsQo1wqWrc0Bm7hwQPx1/WMpIcuFXq6TV4v7QLix8jaBeXjCz6d/JG9dQVqp0rJj2L2Koh9KR4iLlbQ==" + }, + "favicon/apple-touch-startup-image-2388x1668.png": { + "src": "favicon/apple-touch-startup-image-2388x1668.png", + "integrity": "sha512-HrLClFRnn0TKngyeMONGPw8WFltiAd/+456Z2w+/tRYlhblrxfNxddoacMhAfywJuZL2bnMrDFxgIeisKV7UZg==" + }, + "favicon/apple-touch-startup-image-1640x2160.png": { + "src": "favicon/apple-touch-startup-image-1640x2160.png", + "integrity": "sha512-bkGRXPNafzTvHm7iqK90kmtvdUIg1davqSECk72QWcc8KQhB58+j6Y/Lsv4PNhuki/3CafltGYPwq5DC/uFwLg==" + }, + "favicon/apple-touch-startup-image-1620x2160.png": { + "src": "favicon/apple-touch-startup-image-1620x2160.png", + "integrity": "sha512-a52rXNm6ZAK3hBxTW9ySrYEX76I11+P20QU4eS1spuSHH9byqr82n2C2vWsB3ASOvJgF6L9X2m1gTfcezcWa2Q==" + }, + "favicon/apple-touch-startup-image-2160x1640.png": { + "src": "favicon/apple-touch-startup-image-2160x1640.png", + "integrity": "sha512-lAMwiXWTpWy3R8WXVK0Pxyfzh+nVf6TWxB1CS28nckPIvoJZ01UDW7MX15R6VJH4hC6b9yBwRFqgiWI3ey7XIg==" + }, + "favicon/apple-touch-startup-image-2160x1620.png": { + "src": "favicon/apple-touch-startup-image-2160x1620.png", + "integrity": "sha512-q4BwNvR4nA/lX+O3hw5SAhDnyOAsxK2QbaUt0J2rBVr9nhewmvgyvPEQTt/rI2+v5Obt8ofbB1nKKTUKpCPpTQ==" + }, + "favicon/apple-touch-startup-image-1536x2048.png": { + "src": "favicon/apple-touch-startup-image-1536x2048.png", + "integrity": "sha512-gvsMZlTvNSZUJ52q80FFfNk+oLaAw2w8EEcX3ns9QYdNJAhn51+VHnceIw49xiQpMZxu8djiEDhmGAbrnBc8Aw==" + }, + "favicon/apple-touch-startup-image-2048x1536.png": { + "src": "favicon/apple-touch-startup-image-2048x1536.png", + "integrity": "sha512-HddG543jHxr+S6DljYFOj+mOrh5xQfIv+Ca2aCDuY+AU15vXWvuMeRAaNB5eGaXUA5ngSrGkPSR6cZItcipmFg==" + }, + "favicon/apple-touch-startup-image-1488x2266.png": { + "src": "favicon/apple-touch-startup-image-1488x2266.png", + "integrity": "sha512-M+iU7dAuzTuuhlkFLwLOnkC/hsN6pFEuwngs+PmKEQeHnWw/nzIsfovwEjQTm5Bz7h/bbwaF8szZFHGh2lNl5A==" + }, + "favicon/apple-touch-startup-image-2266x1488.png": { + "src": "favicon/apple-touch-startup-image-2266x1488.png", + "integrity": "sha512-SOCJUsMcfWiGiQFMdQ7lhUZrjio+/jwrHidpBmMZqxQL8TESi0ODeU3F1ARleaPF+rvjcWmpFpmFN7kn9tkaAA==" + }, + "favicon/apple-touch-startup-image-1284x2778.png": { + "src": "favicon/apple-touch-startup-image-1284x2778.png", + "integrity": "sha512-HytWl/niNY0h8Z2g+lCOn7O9/fpBS+oPU73GnBNCd7CDwHs+IpzZ0duuRlKmfdH8x80y2bsK5DHcRDQo8TJOPQ==" + }, + "favicon/apple-touch-startup-image-1290x2796.png": { + "src": "favicon/apple-touch-startup-image-1290x2796.png", + "integrity": "sha512-uE8D0pZL30x6zd3sq8tPPcmC6Q8g2dSrnypzZGllIkfSGVoj+tSEKcYrS+/L6DPM3jMuF69TNScufJtVA+Qupg==" + }, + "favicon/apple-touch-startup-image-1242x2688.png": { + "src": "favicon/apple-touch-startup-image-1242x2688.png", + "integrity": "sha512-IR0rOpZn1Vs2fT7UavU7MA8D/PDGS7XmaTwkiPxLi3207GPDxZdQHIKA0vIJSodDGJT/ajON/zxDciq/6Jd00Q==" + }, + "favicon/apple-touch-startup-image-1242x2208.png": { + "src": "favicon/apple-touch-startup-image-1242x2208.png", + "integrity": "sha512-V2CpCg23Xb5d0wHJS0dDPjXs9Mk2CxMOn2cx/b9zC2RWBR9QF/F33zI+MioRQ9RPqCZwt093erdAiEiOonDS3Q==" + }, + "favicon/apple-touch-startup-image-2796x1290.png": { + "src": "favicon/apple-touch-startup-image-2796x1290.png", + "integrity": "sha512-Hn5Bsg7wYJhZhE+UmIMBS0lg+lHWjcrNjY/23Qxvk8keWq/D+LEz8UBA8+b9xaCF+HXo39l41keoix9bvg4zyg==" + }, + "favicon/apple-touch-startup-image-2778x1284.png": { + "src": "favicon/apple-touch-startup-image-2778x1284.png", + "integrity": "sha512-CF8j/XPdlQUQHNjxGO59cS2GVyskflUEPnCqKOWellvVq+RdRa7r3952bNVlUrfzdCoaeszmZS4n71qn2ZTyTA==" + }, + "favicon/apple-touch-startup-image-2208x1242.png": { + "src": "favicon/apple-touch-startup-image-2208x1242.png", + "integrity": "sha512-Ime4TqPHk2qrjA8eHM50as6Sgnlvn3pCkLlI1B/yBDvZ4CPWxDidSmWeJHeV//3dThozo95VllD1bvz/cw8gQA==" + }, + "favicon/apple-touch-startup-image-1179x2556.png": { + "src": "favicon/apple-touch-startup-image-1179x2556.png", + "integrity": "sha512-CGw2nqsLTTrX3YjpHGuJD18Mv8tHySni96E6Z6pTGwfAKK1l6UCqFtbRlUZQ2MlN8vudm4aFifKtPDlFyyAOzw==" + }, + "favicon/apple-touch-startup-image-1170x2532.png": { + "src": "favicon/apple-touch-startup-image-1170x2532.png", + "integrity": "sha512-Bctz35gi47GseEkA5EmsAVmtS60Vhlrc0czWW4UY0cQqIGO0VfoGvSXaccCNesY8VMgVWoZayLxcwrUWbUKK9A==" + }, + "favicon/apple-touch-startup-image-2688x1242.png": { + "src": "favicon/apple-touch-startup-image-2688x1242.png", + "integrity": "sha512-ZamHO4IC0SZ5XhNCI0HaeGaKiDgLhuwWZ12z9Rt0auKt9bvtVucJgI74iAmRXE9zZNE5nmZwMuhajd+dzmZamg==" + }, + "favicon/apple-touch-startup-image-1125x2436.png": { + "src": "favicon/apple-touch-startup-image-1125x2436.png", + "integrity": "sha512-FNQGGCfYgeFjeFzLFNmqcB9bcWaEX6rGk1bUS+oetvVQBU9iZ/YYp9go1A5oeifV1MMX290mlcDwG4i/mg2I0g==" + }, + "favicon/favicon.ico": { + "src": "favicon/favicon.ico", + "integrity": "sha512-oyLtFbxhoEnH/aFDXDWkC+S1LT5M7VHeH+f+FOLsy8JzsswzGR0VkLu/BFvzyVQTzexmfNjP4ZFm6QJYW1/7hw==" + }, + "favicon/apple-touch-startup-image-2556x1179.png": { + "src": "favicon/apple-touch-startup-image-2556x1179.png", + "integrity": "sha512-Jtknw0tI9ryKINVqgtOWLR8dZgc6cPhrh1XrDwQHRGvfdwTcU2/AGVr1w9mj59RZNnMZZgikpdW0ebZuUe4YjA==" + }, + "favicon/apple-touch-startup-image-2532x1170.png": { + "src": "favicon/apple-touch-startup-image-2532x1170.png", + "integrity": "sha512-vAjXBduB/PLTvOwTsCf+VvkRq5PNhxCjDMJ408ul3wFjUb7owqU/LKspOtkNuxOE2H9u2aXqJhdcR61AUdeP8Q==" + }, + "favicon/apple-touch-startup-image-2436x1125.png": { + "src": "favicon/apple-touch-startup-image-2436x1125.png", + "integrity": "sha512-yW+pbc/y6e4ZtL/PfbA77bs++nyHDjt2LewdNSgHoFytdO/0IzCi2th64HrqjkXAnwieqnqBIHOmfQDb6ntOxw==" + }, + "favicon/apple-touch-icon-1024x1024.png": { + "src": "favicon/apple-touch-icon-1024x1024.png", + "integrity": "sha512-uNxs8UKFz57bkfl4uezhkIl4VfZIuSOV6lcaE/0VIYbx8hFZ7SJTShz9wiIzPMZsCSHKMY5P7uhr0FigLGD+3w==" }, "katex.css": { - "src": "katex-66092164.min.css", - "integrity": "sha512-ng+uY3bZP0IENn+fO0T+jdk1v1r7HQJjsVRJgzU+UiJJadAevmo0gVNrpVxrBFGpRQqSz42q20uTre1C1Qrnow==" + "src": "katex-a0da2a32.min.css", + "integrity": "sha512-dsM9rZ31dli/kG9VZShrbuMaNaj6t/aVT6/ZjfTuSGNp1r1EonVHHESDrKKHGbmYqs0HIUcnpWIOEqsoDlpdyw==" + }, + "favicon/apple-touch-startup-image-828x1792.png": { + "src": "favicon/apple-touch-startup-image-828x1792.png", + "integrity": "sha512-lOKELuDZcqdtCvvU+wU4XbRSGVx4j5fXOViEIy8vJ/H/vad9Nb1HjXA517Mo2X3KE+xWpKBa7iaRKONe2NR77A==" + }, + "favicon/apple-touch-startup-image-1792x828.png": { + "src": "favicon/apple-touch-startup-image-1792x828.png", + "integrity": "sha512-Q0rPW22UcOSrAk1Cc+VJElqo1FUOxN6M5yk6rr19l15aDfwMmlWVLVCEEuYr7YN9Yd7P6oFIP5krWpBwP8XevA==" + }, + "favicon/apple-touch-startup-image-750x1334.png": { + "src": "favicon/apple-touch-startup-image-750x1334.png", + "integrity": "sha512-zFiwOUbcWZ5ZT6WIoo5JH5sBgNRKgaw+38nZ4INvrJksTXVYiTSNK+HI+g/fpjATMD3oIy3zRD1QD5MF0xcI+A==" + }, + "favicon/apple-touch-startup-image-1334x750.png": { + "src": "favicon/apple-touch-startup-image-1334x750.png", + "integrity": "sha512-wS3VX86WIIMYLFcu6PTWwilPBtW2/eQgoFC4nUPbxOhA6tDCv0jXfLhpFBk0kEPvtFGqIzdMIwkhB3Q9z2WuEQ==" + }, + "favicon/apple-touch-startup-image-640x1136.png": { + "src": "favicon/apple-touch-startup-image-640x1136.png", + "integrity": "sha512-Ol0z2NW7PjFrVwo5GQ0IolK6IsFJyji9biOIE7BW9wuid/H8VhMW6/j4Sxh9SZ/v0NEtQqaA5VOjvLT7hcpxVA==" + }, + "favicon/apple-touch-startup-image-1136x640.png": { + "src": "favicon/apple-touch-startup-image-1136x640.png", + "integrity": "sha512-l7AF6JJHQNpeEOT32Tj+sZsyigN+FIer/RLxKqwLzXZ3cPMizSjmL5FjfoyZ7waJfDpxV448BWJcpObDEp2f0Q==" + }, + "favicon/android-chrome-512x512.png": { + "src": "favicon/android-chrome-512x512.png", + "integrity": "sha512-XmRxXro8tWSW9pyhfNcuoIVqHqOHH051Lh8NpsR0bMMILrx4QSIGI+IOKo2DYafyJ32rRXQ9XapCUigUoU9lVA==" + }, + "favicon/android-chrome-384x384.png": { + "src": "favicon/android-chrome-384x384.png", + "integrity": "sha512-aaWWtDDKoURtcZjVjuEygWnAX3JmiMIkzG2gw0e90QU2BBiMEFRh+Dq5lONs3NKviyhKrWjYXktnLzbBDgwYqw==" + }, + "favicon/mstile-310x310.png": { + "src": "favicon/mstile-310x310.png", + "integrity": "sha512-0cJZvExwO4YX9shSiRIio61MHiRYzmd1ZKJcIuurb30a85VAebz64fGkg5WgaljhDufbzQV8juSMSMdjVU1PaQ==" + }, + "favicon/android-chrome-256x256.png": { + "src": "favicon/android-chrome-256x256.png", + "integrity": "sha512-7K6tC2Nt0G4xGWOnXI0eHTnflCfBnmoZI+41wRXubcINCVj9zfE1urbpRvWXu+JEkyoD+/1i/SHKJvlj0V8Qng==" + }, + "favicon/android-chrome-192x192.png": { + "src": "favicon/android-chrome-192x192.png", + "integrity": "sha512-vFuJFgoHAo1gYkmVDylyiAHTUEAzZWmusNxCf4BKZucXjB1O5WSNrnaDHd/P1U3If7pTDG3zM3R8xll9qn/TFw==" + }, + "favicon/apple-touch-icon-167x167.png": { + "src": "favicon/apple-touch-icon-167x167.png", + "integrity": "sha512-n9IE0XrWkdUJCWDP+BXWGZ3f8YPWUt0j1YbpOql6ECHbBv94MqBZsCNgAAZcz2nlngn6B/VsLquKPF+C73uAaA==" + }, + "favicon/apple-touch-icon-180x180.png": { + "src": "favicon/apple-touch-icon-180x180.png", + "integrity": "sha512-MOwxPnc3afecYk/ITIQPavTxfNlk68gSBXzbhrf+cYuXaXx+OKApfhsfT0MwS0RjFsi50lirbvtJyyWUce+AnA==" + }, + "favicon/apple-touch-icon-precomposed.png": { + "src": "favicon/apple-touch-icon-precomposed.png", + "integrity": "sha512-MOwxPnc3afecYk/ITIQPavTxfNlk68gSBXzbhrf+cYuXaXx+OKApfhsfT0MwS0RjFsi50lirbvtJyyWUce+AnA==" + }, + "favicon/apple-touch-icon.png": { + "src": "favicon/apple-touch-icon.png", + "integrity": "sha512-MOwxPnc3afecYk/ITIQPavTxfNlk68gSBXzbhrf+cYuXaXx+OKApfhsfT0MwS0RjFsi50lirbvtJyyWUce+AnA==" + }, + "favicon/apple-touch-icon-152x152.png": { + "src": "favicon/apple-touch-icon-152x152.png", + "integrity": "sha512-Tl7OztU9EPEmqAB5g1fZbDfJILIFGGRYoXVRLmBli4G/kDRcZMhsZPEpwjcaElSsZ6Vf+GOBX5w+y/37wcLNmA==" + }, + "favicon/apple-touch-icon-144x144.png": { + "src": "favicon/apple-touch-icon-144x144.png", + "integrity": "sha512-RcXaoNQ/5TvDfRK3B16Xmbool22kaq9anaZ/+bxz6T4IkXly6Ss4V7E7sjAHY0z9VdBi8RlOXmCf1QVF/bO1UQ==" + }, + "favicon/android-chrome-144x144.png": { + "src": "favicon/android-chrome-144x144.png", + "integrity": "sha512-MwJ9846H56kKjlblEn11IvX5wwgw8thJRda/Oz17yUs75jussMZX4XX5CFgp+Fgcj00FydeEm2x5QX4aay2H4w==" + }, + "favicon/mstile-144x144.png": { + "src": "favicon/mstile-144x144.png", + "integrity": "sha512-MwJ9846H56kKjlblEn11IvX5wwgw8thJRda/Oz17yUs75jussMZX4XX5CFgp+Fgcj00FydeEm2x5QX4aay2H4w==" + }, + "favicon/mstile-310x150.png": { + "src": "favicon/mstile-310x150.png", + "integrity": "sha512-533u9y8NEHRs6GP6+n7s7h296T50Y8dwB8FcS5htN7k+V9hWfurx6zfeqw6nDA9r9viOcKQXlJ/XfZLEpaMGMA==" + }, + "favicon/mstile-150x150.png": { + "src": "favicon/mstile-150x150.png", + "integrity": "sha512-jm3Ncpm56VyOSvOsiKRMhX/AYl6vbZr9n80if2QsEyx/Rk9/+owriCEhlKkQ0krUrlEvvAh4Yy40JIiB7GHZYw==" + }, + "favicon/apple-touch-icon-114x114.png": { + "src": "favicon/apple-touch-icon-114x114.png", + "integrity": "sha512-ZiGvyFWIDPl9YZ+NOn93b/7EpDtrw97agCizkuDdFRLr9I2u9FFZTnoik7LJapL3dnDGYD0E8qTJULOwMAthzA==" + }, + "favicon/apple-touch-icon-120x120.png": { + "src": "favicon/apple-touch-icon-120x120.png", + "integrity": "sha512-0PVV+vO18IoVIOgedCOGdzRv6DF/71ygDGR7ijVJOT06xOsACnKooiS25YcXg6sVYjSBNO9omRGqYS+icunJCw==" + }, + "favicon/manifest.webmanifest": { + "src": "favicon/manifest.webmanifest", + "integrity": "sha512-jWI8l1WzeZTVACRS28IeRRCxVue3FSmpky9ou90cG6sc7e9kmJtfQ9NfoFMYyOZ0xIqiA6N2FFD1e/Sx7VXK4g==" + }, + "favicon/android-chrome-96x96.png": { + "src": "favicon/android-chrome-96x96.png", + "integrity": "sha512-Ml8MN6tFQcvVu1M9uFZyZxrtkJwcQv1i/VBs+6YDFvfNkGkvAMGmD3xmvS6qPbc6zazvpncQoAwihcwDYQ1DdQ==" }, "mobile.scss": { "src": "mobile-79ddc617.min.css", "integrity": "sha512-dzw2wMOouDwhSgstQKLbXD/vIqS48Ttc2IV6DeG7yam9yvKUuChJVaworzL8s2UoGMX4x2jEm50PjFJE4R4QWw==" }, + "favicon/apple-touch-icon-72x72.png": { + "src": "favicon/apple-touch-icon-72x72.png", + "integrity": "sha512-xtDi3mPErMdQnOCAF36WY9+Yb9IEgFiWZxcwfI8ZyzLM+zSVXieiTNgvMp3Q7FKbYzuO/YbcY34aSpDeNbwSkw==" + }, + "favicon/apple-touch-icon-76x76.png": { + "src": "favicon/apple-touch-icon-76x76.png", + "integrity": "sha512-5mXpJ0SOGLyJhM+1sKauzI78IZ2e3KX0Ut6bakTWECIS+GMtGU9i4YX2kCgIlf6MYD8NlHhNjgjTXguCQvpOkQ==" + }, + "favicon/android-chrome-72x72.png": { + "src": "favicon/android-chrome-72x72.png", + "integrity": "sha512-yRiTvAL7S+LN+QqFT20OKvlUxy76dWOdYDt/oYrlvlITmWTB+IT3zscjYV3a+eQK0aaBnI3BYvyPpP0Jl0cp/w==" + }, + "favicon/mstile-70x70.png": { + "src": "favicon/mstile-70x70.png", + "integrity": "sha512-YR17fb3y2Mop9r3sGULUWVS08KBsjl541ijD4NfjH9B7MHXot+bKNm+xtqlYSrTNnh1Q5swG1pE8ilH8aT77kA==" + }, + "favicon/apple-touch-icon-57x57.png": { + "src": "favicon/apple-touch-icon-57x57.png", + "integrity": "sha512-3QaWN6DLuPtw8MP7aduHbuO1xiPEJlWE5WCckCnbLThBoYUOB1RV8flSAFAE11UpmqefMB4r2sWwuGRuHFSCtg==" + }, + "favicon/apple-touch-icon-60x60.png": { + "src": "favicon/apple-touch-icon-60x60.png", + "integrity": "sha512-tHDTnMw35Ydrn4aUvkaXwVUsqBjboI2vqm3n2lL5jf21t6SMoekze+YFNC0MBNWEG08ajVQ9L7Qljf9Z2evhBA==" + }, + "favicon/favicon-48x48.png": { + "src": "favicon/favicon-48x48.png", + "integrity": "sha512-Yp178+WA3ntd5AMrdskywuc8ubmWN9qqghWXAyyzbpBBMhKplIP2BveCOP6R16ZUGOcyzPnzjSRY3yESXjcZCQ==" + }, + "favicon/android-chrome-48x48.png": { + "src": "favicon/android-chrome-48x48.png", + "integrity": "sha512-pPHYffX13GvEmTZMLvEocQDWE7rdp0KIM7cdY3w24+3H37j5vbo7K2xsCR92GpzBNXkw0hzcJcdyktaT+E1sag==" + }, + "favicon/favicon-32x32.png": { + "src": "favicon/favicon-32x32.png", + "integrity": "sha512-5elFUf6p+aWoJI3WIS3dhk3MIAqMMM1XFsVZpzG63sITcr1I8iAfjsCIYTJ3fTvSSoFlFRKZ9djMVSNDEK6DqA==" + }, + "favicon/android-chrome-36x36.png": { + "src": "favicon/android-chrome-36x36.png", + "integrity": "sha512-+cyRuV3w4FEq8DVZRGZ9CTiVja2RtOd9PmAIRciFDEpBX3KhdWS8sbLVl7FQ/yX5IkB8xmPla4VJjcgpcftO8w==" + }, "print.scss": { - "src": "print-735ccc12.min.css", - "integrity": "sha512-c28KLNtBnKDW1+/bNWFhwuGBLw9octTXA2wnuaS2qlvpNFL0DytCapui9VM4YYkZg6e9TVp5LyuRQc2lTougDw==" + "src": "print-72068949.min.css", + "integrity": "sha512-uuCwn+/RdwIo3i0FQEJpU2BX38diEpzBQD6eDEePbDmzjYTil/TI9ijRDEUGSqnXSL9pX+YPNzsQJDxPlBG92g==" + }, + "favicon/favicon-16x16.png": { + "src": "favicon/favicon-16x16.png", + "integrity": "sha512-w2lU/rHj2Yf/yb5QMLW9CMSVv8jCr2kBqvqekSINDI7K7oga1RSeCPEtgcSy9n6zQzdFOmswybhPtNJhPcD9TA==" + }, + "favicon/browserconfig.xml": { + "src": "favicon/browserconfig.xml", + "integrity": "sha512-cUHMy43WEDyWiiDTIcOab69HpATbZfoMFHJTYFx3SiU+vXLMHqo3w3mgQnrvdfs42gp37T+bw05l1qLFxlGwoA==" }, "custom.css": { "src": "custom.css", diff --git a/docs/themes/hugo-geekdoc/eslint.config.js b/docs/themes/hugo-geekdoc/eslint.config.js new file mode 100644 index 00000000..42e87cbc --- /dev/null +++ b/docs/themes/hugo-geekdoc/eslint.config.js @@ -0,0 +1,22 @@ +import eslint from "@eslint/js"; +import globals from "globals"; +import babelParser from "@babel/eslint-parser"; +import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; + +export default [ + eslint.configs.recommended, + { + languageOptions: { + globals: { + ...globals.browser, + }, + parser: babelParser, + ecmaVersion: 2022, + sourceType: "module", + parserOptions: { + requireConfigFile: false, + }, + }, + }, + eslintPluginPrettierRecommended, +]; diff --git a/docs/themes/hugo-geekdoc/i18n/am.yaml b/docs/themes/hugo-geekdoc/i18n/am.yaml new file mode 100644 index 00000000..b9db5dc8 --- /dev/null +++ b/docs/themes/hugo-geekdoc/i18n/am.yaml @@ -0,0 +1,52 @@ +--- +edit_page: ገáŒčን ማሔተካኚያ + +nav_navigation: መሄጃ +nav_tags: መለያዎቜ +nav_more: ተጹማáˆȘ +nav_top: ወደ ላይ ተመለሔ + +form_placeholder_search: ፈልግ + +error_page_title: ጠፋቄዎቔ? አይጹነቁ፱ +error_message_title: ጠፋቄዎቔ? +error_message_code: አልተገኘም +error_message_text: > + ገáŒčን ማግኘቔ አልተቻለምፀ ነገር ግን አይጚነቁፀ በዚህ ገጜ መመለሔ á‹­á‰œáˆ‹áˆ‰áą + +button_toggle_dark: ቄሩህ/ጹለማ መቀያዚáˆȘያ +button_nav_open: መሄጃውን ክፈቔ +button_nav_close: መሄጃውን ዝጋ +button_menu_open: ምርጫዎቜን ክፈቔ +button_menu_close: ምርጫዎቜን ዝጋ +button_homepage: ወደ መጀመáˆȘያ ገጜ ተመለሔ + +title_anchor_prefix: "áˆ›á‹«á‹«á‹Ł ወደ:" + +posts_read_more: ሙሉውን ያንቄብ +posts_read_time: + one: "ለማንበቄ አንዔ ደቂቃ" + other: "{{ . }} ደቂቃዎቜ ለማንበቄ" +posts_update_prefix: መጚሚሻ ዹዘመነው +posts_count: + one: "አንዔ ጜሑፍ" + other: "{{ . }} ጜሑፎቜ" +posts_tagged_with: ኹ '{{ . }}' ጋር ዚተዛመዱ ጜሑፎቜ በሙሉ + +footer_build_with: > + በ Hugo ዹተገነባ ኹ + ጋር +footer_legal_notice: ሕጋዊ መሚጃዎቜ +footer_privacy_policy: ሔለ መሹጃዎ አያያዝ ያለን አቋም +footer_content_license_prefix: > + ሔለ ይዘቱ á‰Łáˆˆáˆ˜á‰„á‰”áŠá‰” መሹጃ + +language_switch_no_tranlation_prefix: "ያልተተሚጐመ ገጜ:" + +propertylist_required: ግዔ ዚሚያሔፈልግ +propertylist_optional: ግዔ ያልሆነ +propertylist_default: በባዶ ፈንታ + +pagination_page_prev: ያለፈው +pagination_page_next: ቀጣይ +pagination_page_state: "{{ .PageNumber }}/{{ .TotalPages }}" diff --git a/docs/themes/hugo-geekdoc/i18n/da.yaml b/docs/themes/hugo-geekdoc/i18n/da.yaml new file mode 100644 index 00000000..2ba96eaf --- /dev/null +++ b/docs/themes/hugo-geekdoc/i18n/da.yaml @@ -0,0 +1,53 @@ +--- +edit_page: Rediger side + +nav_navigation: Navigation +nav_tags: Tags +nav_more: Mere +nav_top: Til toppen + +form_placeholder_search: SĂžg + +error_page_title: Faret vild? Bare rolig +error_message_title: Lost? +error_message_code: Fejl 404 +error_message_text: > + Det du leder efter kan ikke findes. Bare rolig, du kan komme tilbage til + forsiden. + +button_toggle_dark: Skift Dark/Light/Auto mode +button_nav_open: Åben navigation +button_nav_close: Luk navigation +button_menu_open: Åben menubar +button_menu_close: Luk menubar +button_homepage: Tilbage til forsiden + +title_anchor_prefix: "Link til:" + +posts_read_more: LĂŠs fulde indlĂŠg +posts_read_time: + one: "Et minut at gennemlĂŠse" + other: "{{ . }} minutter at gennemlĂŠse" +posts_update_prefix: Opdateret den +posts_count: + one: "Et indlĂŠg" + other: "{{ . }} indlĂŠg" +posts_tagged_with: Alle indslag tagget med '{{ . }}' + +footer_build_with: > + Bygget med Hugo og + +footer_legal_notice: Forretningsbetingelser +footer_privacy_policy: Privatlivspolitik +footer_content_license_prefix: > + Indhold licenseret under + +language_switch_no_tranlation_prefix: "IndlĂŠg ikke oversat:" + +propertylist_required: pĂ„krĂŠvet +propertylist_optional: valgfri +propertylist_default: udgangspunkt + +pagination_page_prev: forrige +pagination_page_next: nĂŠste +pagination_page_state: "{{ .PageNumber }}/{{ .TotalPages }}" diff --git a/docs/themes/hugo-geekdoc/i18n/fr.yaml b/docs/themes/hugo-geekdoc/i18n/fr.yaml new file mode 100644 index 00000000..bbded857 --- /dev/null +++ b/docs/themes/hugo-geekdoc/i18n/fr.yaml @@ -0,0 +1,53 @@ +--- +edit_page: Editer la page + +nav_navigation: Navigation +nav_tags: Tags +nav_more: Plus +nav_top: Retour au haut de page + +form_placeholder_search: Chercher + +error_page_title: Perdu? Ne t'inquiĂšte pas +error_message_title: Perdu? +error_message_code: Error 404 +error_message_text: > + On dirait que ce que vous cherchez est introuvable. Ne vous inquiĂ©tez pas, nous pouvons + vous ramĂšner Ă  la page d'accueil. + +button_toggle_dark: Basculer le mode Sombre/Clair/Auto +button_nav_open: Ouvrir la navigation +button_nav_close: Fermer la navigation +button_menu_open: Ouvrir la barre de menus +button_menu_close: Fermer la barre de menus +button_homepage: retour Ă  la page d'accueil + +title_anchor_prefix: "Ancrer à :" + +posts_read_more: Lire l'article complet +posts_read_time: + one: "Une minute pour lire" + other: "{{ . }} minutes Ă  lire" +posts_update_prefix: Mis Ă  jour le +posts_count: + one: "Un billet" + other: "{{ . }} billets" +posts_tagged_with: Tous les articles marquĂ©s avec '{{ . }}' + +footer_build_with: > + Construit avec Hugo et + +footer_legal_notice: Mentions lĂ©gales +footer_privacy_policy: Politique de confidentialitĂ© +footer_content_license_prefix: > + Contenu sous licence + +language_switch_no_tranlation_prefix: "Page non traduite:" + +propertylist_required: requis +propertylist_optional: facultatif +propertylist_default: dĂ©faut + +pagination_page_prev: prĂ©cĂ©dent +pagination_page_next: suivant +pagination_page_state: "{{ .PageNumber }}/{{ .TotalPages }}" diff --git a/docs/themes/hugo-geekdoc/i18n/oc.yaml b/docs/themes/hugo-geekdoc/i18n/oc.yaml new file mode 100644 index 00000000..a68685f3 --- /dev/null +++ b/docs/themes/hugo-geekdoc/i18n/oc.yaml @@ -0,0 +1,53 @@ +--- +edit_page: Modificar la pagina + +nav_navigation: Navegacion +nav_tags: Etiquetas +nav_more: Mai +nav_top: Tornar ennaut + +form_placeholder_search: Cercar + +error_page_title: Perdut ? Cap de problĂšma +error_message_title: Perdut ? +error_message_code: Error 404 +error_message_text: > + Sembla que cercatz quicĂČm que se pĂČt pas trobat. Vos’n fagatz pas vos podĂšm + tornar a la pagina d’acuĂšlh. + +button_toggle_dark: Alternar lo mĂČde escur/clar/auto +button_nav_open: Dobrir la navegacion +button_nav_close: Tampar la navegacion +button_menu_open: Dobrir la barra de menĂș +button_menu_close: Tampar la barra de menĂș +button_homepage: Tornar a la pagina d’acuĂšlh + +title_anchor_prefix: "Ancorar a:" + +posts_read_more: Legir la publicacion complĂšta +posts_read_time: + one: "Una minuta de lectura" + other: "{{ . }} minutas de lectura" +posts_update_prefix: Actualizada lo +posts_count: + one: "Una publicacion" + other: "{{ . }} publicacions" +posts_tagged_with: Totas las publicacions amb '{{ . }}' + +footer_build_with: > + Construch amb Hugo e + +footer_legal_notice: Mencions legalas +footer_privacy_policy: politica de confidencialitat +footer_content_license_prefix: > + Contengut sota licĂ©ncia + +language_switch_no_tranlation_prefix: "Pagina non traducha :" + +propertylist_required: requerit +propertylist_optional: opcional +propertylist_default: per defaut + +pagination_page_prev: prec. +pagination_page_next: seg. +pagination_page_state: "{{ .PageNumber }}/{{ .TotalPages }}" diff --git a/docs/themes/hugo-geekdoc/layouts/404.html b/docs/themes/hugo-geekdoc/layouts/404.html index f8a61bb5..ee7ba2d5 100644 --- a/docs/themes/hugo-geekdoc/layouts/404.html +++ b/docs/themes/hugo-geekdoc/layouts/404.html @@ -27,7 +27,7 @@
{{ i18n "error_message_title" }}
{{ i18n "error_message_code" }}
- {{ i18n "error_message_text" .Site.BaseURL | safeHTML }} + {{ i18n "error_message_text" .Site.Home.Permalink | safeHTML }}
diff --git a/docs/themes/hugo-geekdoc/layouts/_default/baseof.html b/docs/themes/hugo-geekdoc/layouts/_default/baseof.html index ebc39cfc..98807795 100644 --- a/docs/themes/hugo-geekdoc/layouts/_default/baseof.html +++ b/docs/themes/hugo-geekdoc/layouts/_default/baseof.html @@ -22,6 +22,10 @@ + + {{ partial "svg-icon-symbols" . }} @@ -46,9 +50,16 @@ {{ template "main" . }} + {{ $showPrevNext := (default true .Site.Params.geekdocNextPrev) }} + {{ if $showPrevNext }} + {{ end }} diff --git a/docs/themes/hugo-geekdoc/layouts/_default/list.html b/docs/themes/hugo-geekdoc/layouts/_default/list.html index 94172f65..e78b2745 100644 --- a/docs/themes/hugo-geekdoc/layouts/_default/list.html +++ b/docs/themes/hugo-geekdoc/layouts/_default/list.html @@ -4,6 +4,7 @@

{{ partial "utils/title" . }}

{{ partial "utils/content" . }} diff --git a/docs/themes/hugo-geekdoc/layouts/_default/single.html b/docs/themes/hugo-geekdoc/layouts/_default/single.html index 94172f65..adcc333a 100644 --- a/docs/themes/hugo-geekdoc/layouts/_default/single.html +++ b/docs/themes/hugo-geekdoc/layouts/_default/single.html @@ -4,8 +4,10 @@

{{ partial "utils/title" . }}

+ {{ partial "page-metadata" . }} {{ partial "utils/content" . }}
{{ end }} diff --git a/docs/themes/hugo-geekdoc/layouts/_default/taxonomy.html b/docs/themes/hugo-geekdoc/layouts/_default/taxonomy.html index bb97e8ed..8f4de6fd 100644 --- a/docs/themes/hugo-geekdoc/layouts/_default/taxonomy.html +++ b/docs/themes/hugo-geekdoc/layouts/_default/taxonomy.html @@ -1,4 +1,5 @@ {{ define "main" }} +
{{ range .Paginator.Pages }}
@@ -31,6 +32,7 @@

{{ end }} +
{{ partial "pagination.html" . }} {{ end }} diff --git a/docs/themes/hugo-geekdoc/layouts/_default/terms.html b/docs/themes/hugo-geekdoc/layouts/_default/terms.html index 2316ef56..aa7aa56e 100644 --- a/docs/themes/hugo-geekdoc/layouts/_default/terms.html +++ b/docs/themes/hugo-geekdoc/layouts/_default/terms.html @@ -1,4 +1,5 @@ {{ define "main" }} +
{{ range .Paginator.Pages.ByTitle }}
@@ -28,5 +29,6 @@

{{ end }} +
{{ partial "pagination.html" . }} {{ end }} diff --git a/docs/themes/hugo-geekdoc/layouts/partials/head/others.html b/docs/themes/hugo-geekdoc/layouts/partials/head/others.html index 537c2ff8..06f346dd 100644 --- a/docs/themes/hugo-geekdoc/layouts/partials/head/others.html +++ b/docs/themes/hugo-geekdoc/layouts/partials/head/others.html @@ -67,7 +67,7 @@ {{- end }} {{- if (default false $.Site.Params.geekdocOverwriteHTMLBase) }} - + {{- end }} {{ printf "" "Made with Geekdoc theme https://github.com/thegeeklab/hugo-geekdoc" | safeHTML }} diff --git a/docs/themes/hugo-geekdoc/layouts/partials/language.html b/docs/themes/hugo-geekdoc/layouts/partials/language.html index fdcafd2b..b796cd61 100644 --- a/docs/themes/hugo-geekdoc/layouts/partials/language.html +++ b/docs/themes/hugo-geekdoc/layouts/partials/language.html @@ -1,4 +1,4 @@ -{{ if .Site.IsMultiLingual }} +{{ if hugo.IsMultilingual }}