[config_validation] Add unbounded percentage validators (#15500)

This commit is contained in:
Jesse Hills
2026-04-08 11:44:52 +12:00
committed by GitHub
parent aad898503d
commit b307c7c74c
2 changed files with 197 additions and 17 deletions

View File

@@ -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):

View File

@@ -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)