diff --git a/CHANGELOG.md b/CHANGELOG.md index 98a37713..363b6784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ 1. [#706](https://github.com/influxdata/influxdb-client-python/pull/706): Use logger instead logging. 1. [#686](https://github.com/influxdata/influxdb-client-python/issues/686): Stop `default_tags` from one client leaking into other clients' `WriteApi` (shared mutable default `PointSettings`). +1. [#681](https://github.com/influxdata/influxdb-client-python/issues/681): Accept numeric zero for `RangeThreshold.min`/`max` and greater/lesser `value` (identity checks vs `None`, not truthiness); cover create-check serialization. ## 1.50.0 [2026-01-23] diff --git a/influxdb_client/domain/greater_threshold.py b/influxdb_client/domain/greater_threshold.py index b857f132..956e1b5f 100644 --- a/influxdb_client/domain/greater_threshold.py +++ b/influxdb_client/domain/greater_threshold.py @@ -92,6 +92,7 @@ def value(self, value): :param value: The value of this GreaterThreshold. :type: float """ # noqa: E501 + # Identity check so numeric zero (0 / 0.0) is accepted; only None is invalid (#681). if value is None: raise ValueError("Invalid value for `value`, must not be `None`") # noqa: E501 self._value = value diff --git a/influxdb_client/domain/lesser_threshold.py b/influxdb_client/domain/lesser_threshold.py index a75a19e2..76cccf03 100644 --- a/influxdb_client/domain/lesser_threshold.py +++ b/influxdb_client/domain/lesser_threshold.py @@ -92,6 +92,7 @@ def value(self, value): :param value: The value of this LesserThreshold. :type: float """ # noqa: E501 + # Identity check so numeric zero (0 / 0.0) is accepted; only None is invalid (#681). if value is None: raise ValueError("Invalid value for `value`, must not be `None`") # noqa: E501 self._value = value diff --git a/influxdb_client/domain/range_threshold.py b/influxdb_client/domain/range_threshold.py index 68e19860..c437aea4 100644 --- a/influxdb_client/domain/range_threshold.py +++ b/influxdb_client/domain/range_threshold.py @@ -94,12 +94,13 @@ def min(self): return self._min @min.setter - def min(self, min): + def min(self, min): # noqa: A002 - OpenAPI property name """Set the min of this RangeThreshold. :param min: The min of this RangeThreshold. :type: float """ # noqa: E501 + # Identity check so numeric zero (0 / 0.0) is accepted; only None is invalid (#681). if min is None: raise ValueError("Invalid value for `min`, must not be `None`") # noqa: E501 self._min = min @@ -114,12 +115,13 @@ def max(self): return self._max @max.setter - def max(self, max): + def max(self, max): # noqa: A002 - OpenAPI property name """Set the max of this RangeThreshold. :param max: The max of this RangeThreshold. :type: float """ # noqa: E501 + # Identity check so numeric zero (0 / 0.0) is accepted; only None is invalid (#681). if max is None: raise ValueError("Invalid value for `max`, must not be `None`") # noqa: E501 self._max = max @@ -140,6 +142,7 @@ def within(self, within): :param within: The within of this RangeThreshold. :type: bool """ # noqa: E501 + # Identity check so False is accepted; only None is invalid. if within is None: raise ValueError("Invalid value for `within`, must not be `None`") # noqa: E501 self._within = within diff --git a/tests/test_Thresholds.py b/tests/test_Thresholds.py index 5dd28685..7af28679 100644 --- a/tests/test_Thresholds.py +++ b/tests/test_Thresholds.py @@ -1,8 +1,17 @@ import json +import unittest import httpretty from influxdb_client import InfluxDBClient, ChecksService +from influxdb_client.domain.check_status_level import CheckStatusLevel +from influxdb_client.domain.dashboard_query import DashboardQuery +from influxdb_client.domain.greater_threshold import GreaterThreshold +from influxdb_client.domain.lesser_threshold import LesserThreshold +from influxdb_client.domain.query_edit_mode import QueryEditMode +from influxdb_client.domain.range_threshold import RangeThreshold +from influxdb_client.domain.task_status_type import TaskStatusType +from influxdb_client.domain.threshold_check import ThresholdCheck from tests.base_test import BaseTest @@ -43,3 +52,140 @@ def test_threshold(self): self.assertEqual(10.5, check.thresholds[0].value) self.assertEqual("CRIT", check.thresholds[0].level) self.assertEqual("greater", check.thresholds[0].type) + + +class ThresholdZeroValueTest(unittest.TestCase): + """Unit tests: threshold bounds must distinguish 0 from None (#681).""" + + def test_range_threshold_accepts_int_zero_min_and_max(self): + threshold = RangeThreshold( + level=CheckStatusLevel.OK, min=0, max=0, within=True + ) + self.assertEqual(0, threshold.min) + self.assertEqual(0, threshold.max) + self.assertIs(True, threshold.within) + + def test_range_threshold_accepts_float_zero_min(self): + threshold = RangeThreshold( + level=CheckStatusLevel.CRIT, min=0.0, max=10.0, within=False + ) + self.assertEqual(0.0, threshold.min) + self.assertEqual(10.0, threshold.max) + self.assertIs(False, threshold.within) + + def test_range_threshold_rejects_none_min(self): + with self.assertRaises(ValueError) as ctx: + RangeThreshold(level=CheckStatusLevel.OK, min=None, max=10, within=True) + self.assertIn("`min`", str(ctx.exception)) + self.assertIn("None", str(ctx.exception)) + + def test_range_threshold_rejects_none_max(self): + with self.assertRaises(ValueError) as ctx: + RangeThreshold(level=CheckStatusLevel.OK, min=0, max=None, within=True) + self.assertIn("`max`", str(ctx.exception)) + + def test_range_threshold_rejects_none_within(self): + with self.assertRaises(ValueError) as ctx: + RangeThreshold(level=CheckStatusLevel.OK, min=0, max=10, within=None) + self.assertIn("`within`", str(ctx.exception)) + + def test_range_threshold_serialization_keeps_zero(self): + from influxdb_client._sync.api_client import ApiClient + + threshold = RangeThreshold( + level=CheckStatusLevel.OK, min=0, max=10, within=False, all_values=False + ) + payload = ApiClient().sanitize_for_serialization(threshold) + self.assertEqual(0, payload["min"]) + self.assertEqual(10, payload["max"]) + self.assertIs(False, payload["within"]) + self.assertIs(False, payload["allValues"]) + self.assertIn("min", payload) + + def test_range_threshold_deserialization_keeps_zero(self): + from influxdb_client._sync.api_client import ApiClient + + data = { + "type": "range", + "min": 0, + "max": 10, + "within": True, + "level": "OK", + } + obj = ApiClient()._ApiClient__deserialize(data, "RangeThreshold") + self.assertIsInstance(obj, RangeThreshold) + self.assertEqual(0.0, obj.min) + self.assertEqual(10.0, obj.max) + + def test_greater_and_lesser_accept_zero_value(self): + greater = GreaterThreshold(level=CheckStatusLevel.CRIT, value=0) + lesser = LesserThreshold(level=CheckStatusLevel.WARN, value=0.0) + self.assertEqual(0, greater.value) + self.assertEqual(0.0, lesser.value) + + from influxdb_client._sync.api_client import ApiClient + + ac = ApiClient() + self.assertEqual(0, ac.sanitize_for_serialization(greater)["value"]) + self.assertEqual(0.0, ac.sanitize_for_serialization(lesser)["value"]) + + def test_greater_rejects_none_value(self): + with self.assertRaises(ValueError) as ctx: + GreaterThreshold(level=CheckStatusLevel.CRIT, value=None) + self.assertIn("`value`", str(ctx.exception)) + + def test_create_check_serializes_zero_min(self): + """Issue #681 reproduction: create_check payload must keep min=0.""" + httpretty.enable() + httpretty.reset() + try: + created = { + "id": "check-01", + "orgID": "org_id", + "name": "zero-bound-check", + "type": "threshold", + "query": {"text": "from(bucket:\"b\")", "editMode": "advanced"}, + "thresholds": [{ + "type": "range", + "min": 0, + "max": 10, + "within": True, + "level": "OK", + }], + "status": "active", + } + httpretty.register_uri( + httpretty.POST, + uri="http://localhost/api/v2/checks", + status=201, + body=json.dumps(created), + adding_headers={"Content-Type": "application/json"}, + ) + client = InfluxDBClient("http://localhost", "my-token", org="my-org", debug=False) + try: + threshold = RangeThreshold( + level=CheckStatusLevel.OK, min=0, max=10, within=True + ) + check = ThresholdCheck( + name="zero-bound-check", + status_message_template="The value is on: ${ r._level } level!", + every="5s", + offset="0s", + query=DashboardQuery( + edit_mode=QueryEditMode.ADVANCED, text='from(bucket:"b")' + ), + thresholds=[threshold], + org_id="org_id", + status=TaskStatusType.ACTIVE, + ) + result = ChecksService(api_client=client.api_client).create_check(check) + self.assertEqual("check-01", result.id) + request_body = json.loads(httpretty.last_request().body.decode("utf-8")) + self.assertEqual(0, request_body["thresholds"][0]["min"]) + self.assertEqual(10, request_body["thresholds"][0]["max"]) + self.assertTrue(request_body["thresholds"][0]["within"]) + self.assertIn("min", request_body["thresholds"][0]) + finally: + client.close() + finally: + httpretty.disable()