prombed 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- prombed-0.1.0/.gitignore +7 -0
- prombed-0.1.0/LICENSE +19 -0
- prombed-0.1.0/Makefile +15 -0
- prombed-0.1.0/NOTICE +8 -0
- prombed-0.1.0/PKG-INFO +66 -0
- prombed-0.1.0/README.md +52 -0
- prombed-0.1.0/pyproject.toml +33 -0
- prombed-0.1.0/src/prombed/__init__.py +50 -0
- prombed-0.1.0/src/prombed/errors.py +20 -0
- prombed-0.1.0/src/prombed/exposition.py +192 -0
- prombed-0.1.0/src/prombed/prombed.py +435 -0
- prombed-0.1.0/src/prombed/promql/__init__.py +3 -0
- prombed-0.1.0/src/prombed/promql/evaluator.py +577 -0
- prombed-0.1.0/src/prombed/promql/parser.py +400 -0
- prombed-0.1.0/src/prombed/storage.py +196 -0
- prombed-0.1.0/src/prombed/types.py +85 -0
- prombed-0.1.0/src/prombed/value.py +3 -0
- prombed-0.1.0/tests/test_conformance.py +16 -0
- prombed-0.1.0/tests/test_exposition.py +37 -0
- prombed-0.1.0/tests/test_prombed.py +161 -0
- prombed-0.1.0/tests/test_promql.py +119 -0
- prombed-0.1.0/tests/test_storage.py +66 -0
- prombed-0.1.0/uv.lock +209 -0
prombed-0.1.0/.gitignore
ADDED
prombed-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
Copyright 2026 Prombed Authors
|
|
8
|
+
|
|
9
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
10
|
+
you may not use this file except in compliance with the License.
|
|
11
|
+
You may obtain a copy of the License at
|
|
12
|
+
|
|
13
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
14
|
+
|
|
15
|
+
Unless required by applicable law or agreed to in writing, software
|
|
16
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
17
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
18
|
+
See the License for the specific language governing permissions and
|
|
19
|
+
limitations under the License.
|
prombed-0.1.0/Makefile
ADDED
prombed-0.1.0/NOTICE
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Prombed
|
|
2
|
+
Copyright 2026 Prombed Authors
|
|
3
|
+
|
|
4
|
+
Prombed includes algorithms adapted from Prometheus, which is licensed under
|
|
5
|
+
the Apache License 2.0. Adapted behavior includes rate extrapolation, classic
|
|
6
|
+
histogram quantiles, scrape label mutation and staleness, and evaluator sample
|
|
7
|
+
accounting. Corresponding source files retain Prometheus Authors copyright
|
|
8
|
+
notices.
|
prombed-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: prombed
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A small, embeddable Prometheus subset for short-lived metric analysis.
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
License-File: NOTICE
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest-asyncio<2.0,>=0.23; extra == 'dev'
|
|
11
|
+
Requires-Dist: pytest<10.0,>=8.0; extra == 'dev'
|
|
12
|
+
Requires-Dist: ruff<1.0,>=0.12; extra == 'dev'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# Prombed Python
|
|
16
|
+
|
|
17
|
+
The Python implementation provides zero-runtime-dependency Prometheus text exposition parsing,
|
|
18
|
+
bounded in-memory storage, static-target scraping, and PromQL queries. It requires Python 3.10+.
|
|
19
|
+
|
|
20
|
+
## Development
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
cd <repo_root>/python
|
|
24
|
+
uv sync --extra dev
|
|
25
|
+
uv run pytest -q
|
|
26
|
+
uv run ruff format .
|
|
27
|
+
uv run ruff check .
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## API
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from prombed import Prombed, ScrapeTarget
|
|
34
|
+
|
|
35
|
+
metrics = Prombed(
|
|
36
|
+
targets=[
|
|
37
|
+
ScrapeTarget(
|
|
38
|
+
"http://example:8080/metrics",
|
|
39
|
+
labels={"service": "example"},
|
|
40
|
+
metric_names=("example_requests_total", "example_duration_seconds"),
|
|
41
|
+
)
|
|
42
|
+
],
|
|
43
|
+
retention_ms=10 * 60_000,
|
|
44
|
+
scrape_interval_ms=5_000,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
await metrics.start()
|
|
48
|
+
success_rate = metrics.query(
|
|
49
|
+
'sum(rate(example_requests_total{status="success"}[1m])) '
|
|
50
|
+
"/ sum(rate(example_requests_total[1m]))"
|
|
51
|
+
)
|
|
52
|
+
latency = metrics.query(
|
|
53
|
+
"histogram_quantile(0.95, sum by (le) (rate(example_duration_seconds_bucket[1m])))"
|
|
54
|
+
)
|
|
55
|
+
metrics.stop()
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- `await scrape_once()` scrapes configured targets immediately.
|
|
59
|
+
- `await start()` performs an initial scrape and starts periodic collection; `stop()` stops it.
|
|
60
|
+
- `ingest()` accepts a snapshot when the caller owns the sampling lifecycle.
|
|
61
|
+
- `targets()` returns target health and the latest scrape error.
|
|
62
|
+
- `query()` and `query_range()` return Prometheus HTTP API-style dictionaries.
|
|
63
|
+
|
|
64
|
+
Storage, queries, and external requests have explicit capacity and timeout limits. See the
|
|
65
|
+
[PromQL compatibility contract](../docs/promql-compatibility.md) for supported behavior and
|
|
66
|
+
deliberate omissions.
|
prombed-0.1.0/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Prombed Python
|
|
2
|
+
|
|
3
|
+
The Python implementation provides zero-runtime-dependency Prometheus text exposition parsing,
|
|
4
|
+
bounded in-memory storage, static-target scraping, and PromQL queries. It requires Python 3.10+.
|
|
5
|
+
|
|
6
|
+
## Development
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
cd <repo_root>/python
|
|
10
|
+
uv sync --extra dev
|
|
11
|
+
uv run pytest -q
|
|
12
|
+
uv run ruff format .
|
|
13
|
+
uv run ruff check .
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## API
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from prombed import Prombed, ScrapeTarget
|
|
20
|
+
|
|
21
|
+
metrics = Prombed(
|
|
22
|
+
targets=[
|
|
23
|
+
ScrapeTarget(
|
|
24
|
+
"http://example:8080/metrics",
|
|
25
|
+
labels={"service": "example"},
|
|
26
|
+
metric_names=("example_requests_total", "example_duration_seconds"),
|
|
27
|
+
)
|
|
28
|
+
],
|
|
29
|
+
retention_ms=10 * 60_000,
|
|
30
|
+
scrape_interval_ms=5_000,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
await metrics.start()
|
|
34
|
+
success_rate = metrics.query(
|
|
35
|
+
'sum(rate(example_requests_total{status="success"}[1m])) '
|
|
36
|
+
"/ sum(rate(example_requests_total[1m]))"
|
|
37
|
+
)
|
|
38
|
+
latency = metrics.query(
|
|
39
|
+
"histogram_quantile(0.95, sum by (le) (rate(example_duration_seconds_bucket[1m])))"
|
|
40
|
+
)
|
|
41
|
+
metrics.stop()
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- `await scrape_once()` scrapes configured targets immediately.
|
|
45
|
+
- `await start()` performs an initial scrape and starts periodic collection; `stop()` stops it.
|
|
46
|
+
- `ingest()` accepts a snapshot when the caller owns the sampling lifecycle.
|
|
47
|
+
- `targets()` returns target health and the latest scrape error.
|
|
48
|
+
- `query()` and `query_range()` return Prometheus HTTP API-style dictionaries.
|
|
49
|
+
|
|
50
|
+
Storage, queries, and external requests have explicit capacity and timeout limits. See the
|
|
51
|
+
[PromQL compatibility contract](../docs/promql-compatibility.md) for supported behavior and
|
|
52
|
+
deliberate omissions.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "prombed"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A small, embeddable Prometheus subset for short-lived metric analysis."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "Apache-2.0" }
|
|
8
|
+
dependencies = []
|
|
9
|
+
|
|
10
|
+
[project.optional-dependencies]
|
|
11
|
+
dev = [
|
|
12
|
+
"pytest>=8.0,<10.0",
|
|
13
|
+
"pytest-asyncio>=0.23,<2.0",
|
|
14
|
+
"ruff>=0.12,<1.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["hatchling"]
|
|
19
|
+
build-backend = "hatchling.build"
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["src/prombed"]
|
|
23
|
+
|
|
24
|
+
[tool.pytest.ini_options]
|
|
25
|
+
asyncio_mode = "auto"
|
|
26
|
+
testpaths = ["tests"]
|
|
27
|
+
|
|
28
|
+
[tool.ruff]
|
|
29
|
+
line-length = 100
|
|
30
|
+
target-version = "py310"
|
|
31
|
+
|
|
32
|
+
[tool.ruff.lint]
|
|
33
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from .errors import PrombedError as PrombedError
|
|
2
|
+
from .errors import PrombedErrorCode as PrombedErrorCode
|
|
3
|
+
from .exposition import parse_exposition as parse_exposition
|
|
4
|
+
from .prombed import Fetch as Fetch
|
|
5
|
+
from .prombed import Prombed as Prombed
|
|
6
|
+
from .promql import PromQLEngine as PromQLEngine
|
|
7
|
+
from .promql import parse_duration as parse_duration
|
|
8
|
+
from .promql import parse_promql as parse_promql
|
|
9
|
+
from .storage import AppendResult as AppendResult
|
|
10
|
+
from .storage import InMemoryStorage as InMemoryStorage
|
|
11
|
+
from .storage import StorageAppender as StorageAppender
|
|
12
|
+
from .storage import StoredSeries as StoredSeries
|
|
13
|
+
from .storage import label_key as label_key
|
|
14
|
+
from .types import (
|
|
15
|
+
ExpositionSample as ExpositionSample,
|
|
16
|
+
)
|
|
17
|
+
from .types import (
|
|
18
|
+
LabelMatcher as LabelMatcher,
|
|
19
|
+
)
|
|
20
|
+
from .types import (
|
|
21
|
+
LabelMatcherOperator as LabelMatcherOperator,
|
|
22
|
+
)
|
|
23
|
+
from .types import (
|
|
24
|
+
LabelSet as LabelSet,
|
|
25
|
+
)
|
|
26
|
+
from .types import (
|
|
27
|
+
MetricMetadata as MetricMetadata,
|
|
28
|
+
)
|
|
29
|
+
from .types import (
|
|
30
|
+
MetricType as MetricType,
|
|
31
|
+
)
|
|
32
|
+
from .types import (
|
|
33
|
+
ParsedExposition as ParsedExposition,
|
|
34
|
+
)
|
|
35
|
+
from .types import (
|
|
36
|
+
Sample as Sample,
|
|
37
|
+
)
|
|
38
|
+
from .types import (
|
|
39
|
+
ScrapeResult as ScrapeResult,
|
|
40
|
+
)
|
|
41
|
+
from .types import (
|
|
42
|
+
ScrapeTarget as ScrapeTarget,
|
|
43
|
+
)
|
|
44
|
+
from .types import (
|
|
45
|
+
ScrapeTargetHealth as ScrapeTargetHealth,
|
|
46
|
+
)
|
|
47
|
+
from .types import (
|
|
48
|
+
ScrapeTargetStatus as ScrapeTargetStatus,
|
|
49
|
+
)
|
|
50
|
+
from .value import STALE_NAN_BITS as STALE_NAN_BITS
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
PrombedErrorCode = Literal[
|
|
4
|
+
"invalid_exposition",
|
|
5
|
+
"invalid_query",
|
|
6
|
+
"unsupported_promql_feature",
|
|
7
|
+
"query_execution_error",
|
|
8
|
+
"out_of_order_sample",
|
|
9
|
+
"duplicate_sample",
|
|
10
|
+
"storage_capacity_exceeded",
|
|
11
|
+
"scrape_failed",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PrombedError(Exception):
|
|
16
|
+
"""A stable, machine-readable failure from Prombed."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, code: PrombedErrorCode, message: str) -> None:
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.code = code
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from .errors import PrombedError
|
|
7
|
+
from .types import ExpositionSample, MetricMetadata, ParsedExposition
|
|
8
|
+
|
|
9
|
+
_METRIC_NAME = re.compile(r"^[A-Za-z_:][A-Za-z0-9_:]*$")
|
|
10
|
+
_LABEL_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
11
|
+
_FLOAT = re.compile(r"^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$")
|
|
12
|
+
_METRIC_TYPES: set[str] = {
|
|
13
|
+
"counter",
|
|
14
|
+
"gauge",
|
|
15
|
+
"histogram",
|
|
16
|
+
"summary",
|
|
17
|
+
"untyped",
|
|
18
|
+
"info",
|
|
19
|
+
"stateset",
|
|
20
|
+
"gaugehistogram",
|
|
21
|
+
"unknown",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _invalid(line_number: int, message: str) -> None:
|
|
26
|
+
raise PrombedError(
|
|
27
|
+
"invalid_exposition",
|
|
28
|
+
f"invalid Prometheus exposition at line {line_number}: {message}",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _skip_whitespace(value: str, start: int) -> int:
|
|
33
|
+
index = start
|
|
34
|
+
while index < len(value) and value[index].isspace():
|
|
35
|
+
index += 1
|
|
36
|
+
return index
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parse_escaped_string(value: str, start: int, line_number: int) -> tuple[str, int]:
|
|
40
|
+
if start >= len(value) or value[start] != '"':
|
|
41
|
+
_invalid(line_number, "expected quoted label value")
|
|
42
|
+
output: list[str] = []
|
|
43
|
+
index = start + 1
|
|
44
|
+
while index < len(value):
|
|
45
|
+
char = value[index]
|
|
46
|
+
if char == '"':
|
|
47
|
+
return "".join(output), index + 1
|
|
48
|
+
if char != "\\":
|
|
49
|
+
output.append(char)
|
|
50
|
+
index += 1
|
|
51
|
+
continue
|
|
52
|
+
index += 1
|
|
53
|
+
if index >= len(value):
|
|
54
|
+
_invalid(line_number, "unterminated quoted label value")
|
|
55
|
+
escaped = value[index]
|
|
56
|
+
replacement = {"\\": "\\", '"': '"', "n": "\n"}.get(escaped)
|
|
57
|
+
if replacement is None:
|
|
58
|
+
_invalid(line_number, f"unsupported escape sequence \\{escaped}")
|
|
59
|
+
output.append(replacement)
|
|
60
|
+
index += 1
|
|
61
|
+
_invalid(line_number, "unterminated quoted label value")
|
|
62
|
+
raise AssertionError("unreachable")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _parse_labels(value: str, start: int, line_number: int) -> tuple[dict[str, str], int]:
|
|
66
|
+
labels: dict[str, str] = {}
|
|
67
|
+
index = start + 1
|
|
68
|
+
while True:
|
|
69
|
+
index = _skip_whitespace(value, index)
|
|
70
|
+
if index < len(value) and value[index] == "}":
|
|
71
|
+
return labels, index + 1
|
|
72
|
+
name_start = index
|
|
73
|
+
while index < len(value) and (value[index].isalnum() or value[index] == "_"):
|
|
74
|
+
index += 1
|
|
75
|
+
name = value[name_start:index]
|
|
76
|
+
if not _LABEL_NAME.fullmatch(name):
|
|
77
|
+
_invalid(line_number, f"invalid label name {name!r}")
|
|
78
|
+
if name in labels:
|
|
79
|
+
_invalid(line_number, f"duplicate label {name!r}")
|
|
80
|
+
index = _skip_whitespace(value, index)
|
|
81
|
+
if index >= len(value) or value[index] != "=":
|
|
82
|
+
_invalid(line_number, f"expected '=' after label {name!r}")
|
|
83
|
+
index = _skip_whitespace(value, index + 1)
|
|
84
|
+
parsed, index = _parse_escaped_string(value, index, line_number)
|
|
85
|
+
labels[name] = parsed
|
|
86
|
+
index = _skip_whitespace(value, index)
|
|
87
|
+
if index < len(value) and value[index] == ",":
|
|
88
|
+
index += 1
|
|
89
|
+
continue
|
|
90
|
+
if index < len(value) and value[index] == "}":
|
|
91
|
+
return labels, index + 1
|
|
92
|
+
_invalid(line_number, "expected ',' or '}' after label value")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _parse_float(token: str, line_number: int) -> float:
|
|
96
|
+
if token == "NaN":
|
|
97
|
+
return math.nan
|
|
98
|
+
if token in {"+Inf", "Inf"}:
|
|
99
|
+
return math.inf
|
|
100
|
+
if token == "-Inf":
|
|
101
|
+
return -math.inf
|
|
102
|
+
if not _FLOAT.fullmatch(token):
|
|
103
|
+
_invalid(line_number, f"invalid sample value {token!r}")
|
|
104
|
+
return float(token)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _parse_sample_line(line: str, line_number: int) -> ExpositionSample:
|
|
108
|
+
index = 0
|
|
109
|
+
while index < len(line) and not line[index].isspace() and line[index] != "{":
|
|
110
|
+
index += 1
|
|
111
|
+
name = line[:index]
|
|
112
|
+
if not _METRIC_NAME.fullmatch(name):
|
|
113
|
+
_invalid(line_number, f"invalid metric name {name!r}")
|
|
114
|
+
labels: dict[str, str] = {}
|
|
115
|
+
if index < len(line) and line[index] == "{":
|
|
116
|
+
labels, index = _parse_labels(line, index, line_number)
|
|
117
|
+
index = _skip_whitespace(line, index)
|
|
118
|
+
value_start = index
|
|
119
|
+
while index < len(line) and not line[index].isspace():
|
|
120
|
+
index += 1
|
|
121
|
+
sample_value = _parse_float(line[value_start:index], line_number)
|
|
122
|
+
index = _skip_whitespace(line, index)
|
|
123
|
+
timestamp: int | None = None
|
|
124
|
+
if index < len(line) and line[index] != "#":
|
|
125
|
+
timestamp_start = index
|
|
126
|
+
while index < len(line) and not line[index].isspace():
|
|
127
|
+
index += 1
|
|
128
|
+
token = line[timestamp_start:index]
|
|
129
|
+
if not re.fullmatch(r"-?\d+", token):
|
|
130
|
+
_invalid(line_number, f"invalid sample timestamp {token!r}")
|
|
131
|
+
timestamp = int(token)
|
|
132
|
+
if abs(timestamp) > 2**53 - 1:
|
|
133
|
+
_invalid(line_number, f"sample timestamp is outside the safe integer range: {token}")
|
|
134
|
+
index = _skip_whitespace(line, index)
|
|
135
|
+
if index < len(line) and line[index] != "#":
|
|
136
|
+
_invalid(line_number, f"unexpected trailing content {line[index:]!r}")
|
|
137
|
+
return ExpositionSample(name, labels, sample_value, timestamp)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _unescape_help(value: str, line_number: int) -> str:
|
|
141
|
+
output: list[str] = []
|
|
142
|
+
index = 0
|
|
143
|
+
while index < len(value):
|
|
144
|
+
char = value[index]
|
|
145
|
+
if char != "\\":
|
|
146
|
+
output.append(char)
|
|
147
|
+
index += 1
|
|
148
|
+
continue
|
|
149
|
+
index += 1
|
|
150
|
+
if index >= len(value):
|
|
151
|
+
_invalid(line_number, "unsupported HELP escape sequence \\")
|
|
152
|
+
escaped = value[index]
|
|
153
|
+
if escaped == "\\":
|
|
154
|
+
output.append("\\")
|
|
155
|
+
elif escaped == "n":
|
|
156
|
+
output.append("\n")
|
|
157
|
+
else:
|
|
158
|
+
_invalid(line_number, f"unsupported HELP escape sequence \\{escaped}")
|
|
159
|
+
index += 1
|
|
160
|
+
return "".join(output)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def parse_exposition(value: str) -> ParsedExposition:
|
|
164
|
+
"""Parse Prometheus text format 0.0.4 and its classic OpenMetrics subset."""
|
|
165
|
+
|
|
166
|
+
samples: list[ExpositionSample] = []
|
|
167
|
+
metadata: dict[str, MetricMetadata] = {}
|
|
168
|
+
descriptor_pattern = re.compile(r"^#\s*(HELP|TYPE|UNIT)\s+([^\s]+)\s+(.+)$")
|
|
169
|
+
for line_number, raw in enumerate(value.splitlines(), 1):
|
|
170
|
+
line = raw.strip()
|
|
171
|
+
if not line or line == "# EOF":
|
|
172
|
+
continue
|
|
173
|
+
if not line.startswith("#"):
|
|
174
|
+
samples.append(_parse_sample_line(line, line_number))
|
|
175
|
+
continue
|
|
176
|
+
match = descriptor_pattern.fullmatch(line)
|
|
177
|
+
if match is None:
|
|
178
|
+
continue
|
|
179
|
+
kind, name, raw_value = match.groups()
|
|
180
|
+
if not _METRIC_NAME.fullmatch(name):
|
|
181
|
+
_invalid(line_number, f"invalid metadata metric name {name!r}")
|
|
182
|
+
entry = metadata.setdefault(name, MetricMetadata())
|
|
183
|
+
if kind == "TYPE":
|
|
184
|
+
metric_type = raw_value.lower()
|
|
185
|
+
if metric_type not in _METRIC_TYPES:
|
|
186
|
+
_invalid(line_number, f"unsupported metric type {raw_value!r}")
|
|
187
|
+
entry.type = metric_type # type: ignore[assignment]
|
|
188
|
+
elif kind == "HELP":
|
|
189
|
+
entry.help = _unescape_help(raw_value, line_number)
|
|
190
|
+
else:
|
|
191
|
+
entry.unit = raw_value
|
|
192
|
+
return ParsedExposition(samples, metadata)
|