mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 14:34:49 +00:00
[config_validation] Add unbounded percentage validators (#15500)
This commit is contained in:
@@ -1468,17 +1468,53 @@ hex_uint64_t = hex_int_range(min=0, max=18446744073709551615)
|
||||
i2c_address = hex_uint8_t
|
||||
|
||||
|
||||
def percentage(value):
|
||||
def percentage(value: object) -> float:
|
||||
"""Validate that the value is a percentage.
|
||||
|
||||
The resulting value is an integer in the range 0.0 to 1.0.
|
||||
The resulting value is a float in the range 0.0 to 1.0.
|
||||
"""
|
||||
value = possibly_negative_percentage(value)
|
||||
value = _parse_percentage(value)
|
||||
return zero_to_one_float(value)
|
||||
|
||||
|
||||
def possibly_negative_percentage(value):
|
||||
has_percent_sign = False
|
||||
def possibly_negative_percentage(value: object) -> float:
|
||||
"""Validate that the value is a possibly negative percentage.
|
||||
|
||||
The resulting value is a float in the range -1.0 to 1.0.
|
||||
"""
|
||||
value = _parse_percentage(value)
|
||||
return negative_one_to_one_float(value)
|
||||
|
||||
|
||||
def unbounded_percentage(value: object) -> float:
|
||||
"""Validate that the value is a percentage, allowing values above 100%.
|
||||
|
||||
The resulting value is a non-negative float with no upper bound.
|
||||
For example, "150%" returns 1.5 and "50%" returns 0.5.
|
||||
"""
|
||||
value = _parse_percentage(value)
|
||||
if value < 0:
|
||||
raise Invalid("Percentage must not be negative")
|
||||
return value
|
||||
|
||||
|
||||
def unbounded_possibly_negative_percentage(value: object) -> float:
|
||||
"""Validate that the value is a possibly negative percentage without bounds.
|
||||
|
||||
The resulting value is an unbounded float.
|
||||
For example, "200%" returns 2.0 and "-150%" returns -1.5.
|
||||
"""
|
||||
return _parse_percentage(value)
|
||||
|
||||
|
||||
def _parse_percentage(value: object) -> float:
|
||||
"""Parse a percentage string or number into a float.
|
||||
|
||||
Handles both "50%" style strings and raw float values.
|
||||
Values without a percent sign above 1.0 or below -1.0 are rejected
|
||||
to prevent user mistakes (e.g. writing 50 instead of 50%).
|
||||
"""
|
||||
has_percent_sign: bool = False
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
if value.endswith("%"):
|
||||
@@ -1490,21 +1526,16 @@ def possibly_negative_percentage(value):
|
||||
# pylint: disable=raise-missing-from
|
||||
raise Invalid("invalid number")
|
||||
try:
|
||||
if value > 1:
|
||||
msg = "Percentage must not be higher than 100%."
|
||||
if not has_percent_sign:
|
||||
msg += " Please put a percent sign after the number!"
|
||||
raise Invalid(msg)
|
||||
if value < -1:
|
||||
msg = "Percentage must not be smaller than -100%."
|
||||
if not has_percent_sign:
|
||||
msg += " Please put a percent sign after the number!"
|
||||
raise Invalid(msg)
|
||||
if not has_percent_sign and (value > 1 or value < -1):
|
||||
raise Invalid(
|
||||
"Percentage value must use a percent sign for values "
|
||||
"outside -1.0 to 1.0. Please put a percent sign after the number!"
|
||||
)
|
||||
except TypeError:
|
||||
raise Invalid( # pylint: disable=raise-missing-from
|
||||
"Expected percentage or float between -1.0 and 1.0"
|
||||
"Expected percentage or float"
|
||||
)
|
||||
return negative_one_to_one_float(value)
|
||||
return float(value)
|
||||
|
||||
|
||||
def percentage_int(value):
|
||||
|
||||
@@ -616,3 +616,152 @@ def test_validate_entity_name__none_with_friendly_name() -> None:
|
||||
result = config_validation._validate_entity_name("None")
|
||||
assert result is None
|
||||
CORE.friendly_name = None # Reset
|
||||
|
||||
|
||||
# --- percentage validators ---
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
(
|
||||
("0%", 0.0),
|
||||
("50%", 0.5),
|
||||
("100%", 1.0),
|
||||
(0.0, 0.0),
|
||||
(0.5, 0.5),
|
||||
(1.0, 1.0),
|
||||
("0.0", 0.0),
|
||||
("0.5", 0.5),
|
||||
("1.0", 1.0),
|
||||
),
|
||||
)
|
||||
def test_percentage__valid(value: object, expected: float) -> None:
|
||||
assert config_validation.percentage(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
(
|
||||
"150%",
|
||||
"-10%",
|
||||
"-0.1",
|
||||
"1.1",
|
||||
2,
|
||||
-1,
|
||||
"foo",
|
||||
None,
|
||||
),
|
||||
)
|
||||
def test_percentage__invalid(value: object) -> None:
|
||||
with pytest.raises(Invalid):
|
||||
config_validation.percentage(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
(
|
||||
("0%", 0.0),
|
||||
("50%", 0.5),
|
||||
("100%", 1.0),
|
||||
("-50%", -0.5),
|
||||
("-100%", -1.0),
|
||||
(0.0, 0.0),
|
||||
(0.5, 0.5),
|
||||
(-0.5, -0.5),
|
||||
(1.0, 1.0),
|
||||
(-1.0, -1.0),
|
||||
),
|
||||
)
|
||||
def test_possibly_negative_percentage__valid(value: object, expected: float) -> None:
|
||||
assert config_validation.possibly_negative_percentage(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
(
|
||||
"150%",
|
||||
"-150%",
|
||||
2,
|
||||
-2,
|
||||
"foo",
|
||||
None,
|
||||
),
|
||||
)
|
||||
def test_possibly_negative_percentage__invalid(value: object) -> None:
|
||||
with pytest.raises(Invalid):
|
||||
config_validation.possibly_negative_percentage(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
(
|
||||
("0%", 0.0),
|
||||
("50%", 0.5),
|
||||
("100%", 1.0),
|
||||
("150%", 1.5),
|
||||
("200%", 2.0),
|
||||
(0.0, 0.0),
|
||||
(0.5, 0.5),
|
||||
(1.0, 1.0),
|
||||
),
|
||||
)
|
||||
def test_unbounded_percentage__valid(value: object, expected: float) -> None:
|
||||
assert config_validation.unbounded_percentage(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
(
|
||||
"-10%",
|
||||
"-0.5",
|
||||
-1,
|
||||
"foo",
|
||||
None,
|
||||
),
|
||||
)
|
||||
def test_unbounded_percentage__invalid(value: object) -> None:
|
||||
with pytest.raises(Invalid):
|
||||
config_validation.unbounded_percentage(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
(
|
||||
("0%", 0.0),
|
||||
("50%", 0.5),
|
||||
("150%", 1.5),
|
||||
("-50%", -0.5),
|
||||
("-150%", -1.5),
|
||||
("200%", 2.0),
|
||||
("-200%", -2.0),
|
||||
(0.0, 0.0),
|
||||
(0.5, 0.5),
|
||||
(-0.5, -0.5),
|
||||
(1.0, 1.0),
|
||||
(-1.0, -1.0),
|
||||
),
|
||||
)
|
||||
def test_unbounded_possibly_negative_percentage__valid(
|
||||
value: object, expected: float
|
||||
) -> None:
|
||||
assert config_validation.unbounded_possibly_negative_percentage(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ("foo", None))
|
||||
def test_unbounded_possibly_negative_percentage__invalid(value: object) -> None:
|
||||
with pytest.raises(Invalid):
|
||||
config_validation.unbounded_possibly_negative_percentage(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
(50, -50, 2, -2),
|
||||
)
|
||||
def test_percentage_validators__raw_number_above_one_without_percent_sign(
|
||||
value: object,
|
||||
) -> None:
|
||||
"""Raw numeric values outside [-1, 1] must use a percent sign."""
|
||||
with pytest.raises(Invalid, match="percent sign"):
|
||||
config_validation.unbounded_percentage(value)
|
||||
with pytest.raises(Invalid, match="percent sign"):
|
||||
config_validation.unbounded_possibly_negative_percentage(value)
|
||||
|
||||
Reference in New Issue
Block a user