Files
bmspy/tests/test_prometheus.py
T
2026-05-02 23:12:29 +02:00

278 lines
11 KiB
Python

import pytest
pytest.importorskip("prometheus_client", reason="prometheus-client not installed")
import os
import tempfile
from unittest.mock import patch, MagicMock
from prometheus_client import CollectorRegistry
from bmspy.classes import UPS, BMSScalarField, BMSMultiField, BMSInfoField
from bmspy.prometheus import (
prometheus_create_metric,
prometheus_populate_metric,
prometheus_export,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _scalar_ups(**fields):
"""Build a UPS with one scalar field per kwarg (name -> raw_value)."""
field_dicts = {
name: {"help": name, "raw_value": val, "value": str(val), "units": "V"}
for name, val in fields.items()
}
return UPS.from_dict(field_dicts)
def _make_ups_data():
"""Build a dict[str, UPS] with scalar, multi, and info fields."""
return {
"testups": UPS.from_dict({
"bms_voltage": {
"help": "Total Voltage",
"raw_value": 52.0,
"value": "52.00",
"units": "V",
},
"bms_cells": {
"help": "Cell Voltages",
"label": "cell",
"raw_values": {1: 3.6, 2: 3.61},
"values": {1: "3.600", 2: "3.610"},
"units": "V",
},
"bms_date": {
"help": "Manufacture Date",
"info": "2023-01-15",
},
})
}
# ---------------------------------------------------------------------------
# prometheus_create_metric
# ---------------------------------------------------------------------------
class TestPrometheusCreateMetric:
def test_scalar_creates_gauge(self):
registry = CollectorRegistry(auto_describe=True)
ups_data = _scalar_ups(bms_voltage=52.0)
metric = prometheus_create_metric(registry, {"myups": ups_data})
from prometheus_client import Gauge
assert isinstance(metric["bms_voltage"], Gauge)
def test_multi_creates_gauge_with_label(self):
registry = CollectorRegistry(auto_describe=True)
ups_data = {"myups": UPS.from_dict({
"bms_cells": {
"help": "Cells",
"label": "cell",
"raw_values": {1: 3.6},
"values": {1: "3.600"},
"units": "V",
},
})}
metric = prometheus_create_metric(registry, ups_data)
from prometheus_client import Gauge
assert isinstance(metric["bms_cells"], Gauge)
def test_info_creates_info_metric(self):
registry = CollectorRegistry(auto_describe=True)
ups_data = {"myups": UPS.from_dict({
"bms_date": {"help": "Date", "info": "2023-01-15"},
})}
metric = prometheus_create_metric(registry, ups_data)
from prometheus_client import Info
assert isinstance(metric["bms_date"], Info)
def test_skips_duplicate_across_ups_devices(self):
registry = CollectorRegistry(auto_describe=True)
field = {"help": "V", "raw_value": 52.0, "value": "52.00", "units": "V"}
ups_data = {
"ups1": UPS.from_dict({"bms_voltage": dict(field)}),
"ups2": UPS.from_dict({"bms_voltage": dict(field)}),
}
metric = prometheus_create_metric(registry, ups_data)
# Should only have one entry, not raise on duplicate registration
assert "bms_voltage" in metric
def test_all_field_types_in_one_call(self):
registry = CollectorRegistry(auto_describe=True)
metric = prometheus_create_metric(registry, _make_ups_data())
assert "bms_voltage" in metric
assert "bms_cells" in metric
assert "bms_date" in metric
def test_empty_ups_data_returns_empty_dict(self):
registry = CollectorRegistry(auto_describe=True)
metric = prometheus_create_metric(registry, {"myups": UPS.from_dict({})})
assert metric == {}
# ---------------------------------------------------------------------------
# prometheus_populate_metric
# ---------------------------------------------------------------------------
class TestPrometheusPopulateMetric:
def test_scalar_value_set(self):
registry = CollectorRegistry(auto_describe=True)
ups_data = {"testups": _scalar_ups(bms_voltage=52.0)}
metric = prometheus_create_metric(registry, ups_data)
prometheus_populate_metric(metric, ups_data)
# Verify via registry output
from prometheus_client import generate_latest
output = generate_latest(registry).decode()
assert "52.0" in output
def test_multi_values_set(self):
registry = CollectorRegistry(auto_describe=True)
ups_data = {"testups": UPS.from_dict({
"bms_cells": {
"help": "Cells",
"label": "cell",
"raw_values": {1: 3.6, 2: 3.61},
"values": {1: "3.600", 2: "3.610"},
"units": "V",
},
})}
metric = prometheus_create_metric(registry, ups_data)
prometheus_populate_metric(metric, ups_data)
from prometheus_client import generate_latest
output = generate_latest(registry).decode()
assert "3.6" in output
def test_info_value_set(self):
registry = CollectorRegistry(auto_describe=True)
ups_data = {"testups": UPS.from_dict({
"bms_date": {"help": "Date", "info": "2023-01-15"},
})}
metric = prometheus_create_metric(registry, ups_data)
prometheus_populate_metric(metric, ups_data)
from prometheus_client import generate_latest
output = generate_latest(registry).decode()
assert "2023-01-15" in output
def test_missing_metric_key_is_skipped(self):
"""populate should not crash if a field name is not in metric dict."""
registry = CollectorRegistry(auto_describe=True)
ups_data = {"testups": _scalar_ups(bms_voltage=52.0)}
# Create metrics for a different field name
other_metric = {}
prometheus_populate_metric(other_metric, ups_data) # should not raise
# ---------------------------------------------------------------------------
# prometheus_export daemonize=True (start_http_server path)
# ---------------------------------------------------------------------------
class TestPrometheusExportDaemonize:
def test_daemonize_calls_start_http_server(self):
"""When daemonize=True, start_http_server is called and loop runs once then exits."""
ups_data = {"testups": _scalar_ups(bms_voltage=52.0)}
call_count = 0
def _read_data(*args, **kwargs):
nonlocal call_count
call_count += 1
return ups_data
# Make time.sleep raise StopIteration after first call to exit the daemon loop
with patch("bmspy.prometheus.client.read_data", side_effect=_read_data), \
patch("bmspy.prometheus.prometheus_client.start_http_server") as mock_start, \
patch("bmspy.prometheus.time.sleep", side_effect=StopIteration("stop")):
with pytest.raises(StopIteration):
prometheus_export(daemonize=True)
mock_start.assert_called_once()
# ---------------------------------------------------------------------------
# prometheus_export
# ---------------------------------------------------------------------------
class TestPrometheusExport:
def test_export_to_textfile(self, tmp_path):
filename = str(tmp_path / "metrics.prom")
ups_data = {"testups": _scalar_ups(bms_voltage=52.0)}
with patch("bmspy.prometheus.client.read_data", return_value=ups_data):
result = prometheus_export(daemonize=False, filename=filename)
assert result is True
assert os.path.exists(filename)
def test_export_no_filename_returns_false(self):
ups_data = {"testups": _scalar_ups(bms_voltage=52.0)}
with patch("bmspy.prometheus.client.read_data", return_value=ups_data):
result = prometheus_export(daemonize=False, filename=None)
assert result is False
def test_export_waits_for_data(self, tmp_path):
"""When read_data returns empty first, then real data, export should work."""
filename = str(tmp_path / "metrics.prom")
ups_data = {"testups": _scalar_ups(bms_voltage=52.0)}
call_count = 0
def _read_data(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count < 2:
return {}
return ups_data
with patch("bmspy.prometheus.client.read_data", side_effect=_read_data), \
patch("bmspy.prometheus.time.sleep"):
result = prometheus_export(daemonize=False, filename=filename)
assert result is True
assert call_count == 2
def test_export_with_all_field_types(self, tmp_path):
filename = str(tmp_path / "metrics.prom")
ups_data = _make_ups_data()
with patch("bmspy.prometheus.client.read_data", return_value=ups_data):
result = prometheus_export(daemonize=False, filename=filename)
assert result is True
content = open(filename).read()
assert "bms_voltage" in content
assert "bms_cells" in content
assert "bms_date" in content
# ---------------------------------------------------------------------------
# prometheus main()
# ---------------------------------------------------------------------------
class TestPrometheusMain:
def _ups_dict(self):
return {"testups": _scalar_ups(bms_voltage=52.0)}
def test_main_with_file_arg(self, tmp_path):
from bmspy.prometheus import main
filename = str(tmp_path / "metrics.prom")
with patch("sys.argv", ["bmspy-prometheus", "--file", filename]), \
patch("bmspy.prometheus.client.read_data", return_value=self._ups_dict()):
main()
assert os.path.exists(filename)
def test_main_with_socket_arg(self, tmp_path):
from bmspy.prometheus import main
filename = str(tmp_path / "metrics.prom")
with patch("sys.argv", ["bmspy-prometheus", "--file", filename, "--socket", "/tmp/test.sock"]), \
patch("bmspy.prometheus.client.read_data", return_value=self._ups_dict()):
main()
def test_main_with_ups_arg(self, tmp_path):
from bmspy.prometheus import main
filename = str(tmp_path / "metrics.prom")
with patch("sys.argv", ["bmspy-prometheus", "--file", filename, "--ups", "myups"]), \
patch("bmspy.prometheus.client.read_data", return_value=self._ups_dict()):
main()
def test_main_with_verbose(self, tmp_path):
from bmspy.prometheus import main
filename = str(tmp_path / "metrics.prom")
with patch("sys.argv", ["bmspy-prometheus", "--file", filename, "-v"]), \
patch("bmspy.prometheus.client.read_data", return_value=self._ups_dict()):
main()