Compare commits

..

5 Commits

88 changed files with 380 additions and 435 deletions
+1 -1
View File
@@ -820,7 +820,7 @@ jobs:
run: echo ${{ matrix.components }}
- name: Cache apt packages
uses: awalsh128/cache-apt-pkgs-action@5513791f75b039e2a79653b1a92238d3fb8d99b4 # v1.6.2
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3
with:
packages: libsdl2-dev ccache
version: 1.1
+12
View File
@@ -0,0 +1,12 @@
"""ESPHome.
ESPHome validates configuration with probatio. External (third-party) components
still ``import voluptuous`` directly, so probatio's compatibility shim is installed
here, at package import, before any submodule or component imports voluptuous. This
aliases ``voluptuous`` (and the submodules dependencies reach into) to probatio in
``sys.modules`` for the lifetime of the process.
"""
from probatio.compat import install_as_voluptuous
install_as_voluptuous()
+9 -3
View File
@@ -198,7 +198,13 @@ def validate_automation(extra_schema=None, extra_validators=None, single=False):
try:
return cv.Schema([schema])(value)
except cv.Invalid as err2:
if "extra keys not allowed" in str(err2) and len(err2.path) == 2:
err2_errors = (
err2.errors if isinstance(err2, cv.MultipleInvalid) else [err2]
)
if (
any(isinstance(e, cv.ExtraKeysInvalid) for e in err2_errors)
and len(err2.path) == 2
):
raise err from None
if "Unable to find action" in str(err):
raise err2 from None
@@ -414,8 +420,8 @@ async def delay_action_to_code(
cv.Optional(CONF_THEN): validate_action_list,
cv.Optional(CONF_ELSE): validate_action_list,
},
cv.has_at_least_one_key(CONF_THEN, CONF_ELSE),
cv.has_at_least_one_key(CONF_CONDITION, CONF_ANY, CONF_ALL),
cv.AtLeastOne(CONF_THEN, CONF_ELSE),
cv.AtLeastOne(CONF_CONDITION, CONF_ANY, CONF_ALL),
),
synchronous=True,
)
@@ -34,7 +34,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_PIXEL_MAPPER): cv.returning_lambda,
}
),
cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA),
cv.AtMostOne(CONF_PAGES, CONF_LAMBDA),
)
+2 -2
View File
@@ -221,7 +221,7 @@ ACTIONS_SCHEMA = automation.validate_automation(
),
},
cv.All(
cv.has_exactly_one_key(CONF_SERVICE, CONF_ACTION),
cv.ExactlyOne(CONF_SERVICE, CONF_ACTION),
cv.rename_key(CONF_SERVICE, CONF_ACTION),
_auto_detect_supports_response,
# Re-validate supports_response after auto-detection sets it
@@ -534,7 +534,7 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All(
cv.Optional(CONF_ON_ERROR): automation.validate_automation(single=True),
}
),
cv.has_exactly_one_key(CONF_SERVICE, CONF_ACTION),
cv.ExactlyOne(CONF_SERVICE, CONF_ACTION),
cv.rename_key(CONF_SERVICE, CONF_ACTION),
_validate_response_config,
)
+1 -1
View File
@@ -198,7 +198,7 @@ CONFIG_SCHEMA = cv.All(
.extend(cv.COMPONENT_SCHEMA)
.extend(i2c.i2c_device_schema(0x36)),
# ensure end_position and range are mutually exclusive
cv.has_at_most_one_key(CONF_END_POSITION, CONF_RANGE),
cv.AtMostOne(CONF_END_POSITION, CONF_RANGE),
has_valid_range_config(),
)
+1 -1
View File
@@ -149,7 +149,7 @@ RADAR_SETTINGS_SCHEMA = cv.Schema(
),
}
).add_extra(
cv.has_at_least_one_key(
cv.AtLeastOne(
CONF_HW_FRONTEND_RESET,
CONF_FREQUENCY,
CONF_SENSING_DISTANCE,
+1 -1
View File
@@ -37,7 +37,7 @@ CONFIG_SCHEMA = cv.All(
}
)
.extend(cv.COMPONENT_SCHEMA),
cv.has_at_least_one_key(CONF_COOL_ACTION, CONF_HEAT_ACTION),
cv.AtLeastOne(CONF_COOL_ACTION, CONF_HEAT_ACTION),
)
@@ -50,9 +50,7 @@ CONFIG_SCHEMA = cv.All(
)
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA),
cv.has_exactly_one_key(
CONF_MAC_ADDRESS, CONF_IRK, CONF_SERVICE_UUID, CONF_IBEACON_UUID
),
cv.ExactlyOne(CONF_MAC_ADDRESS, CONF_IRK, CONF_SERVICE_UUID, CONF_IBEACON_UUID),
_validate,
)
+1 -3
View File
@@ -50,9 +50,7 @@ CONFIG_SCHEMA = cv.All(
)
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA),
cv.has_exactly_one_key(
CONF_MAC_ADDRESS, CONF_IRK, CONF_SERVICE_UUID, CONF_IBEACON_UUID
),
cv.ExactlyOne(CONF_MAC_ADDRESS, CONF_IRK, CONF_SERVICE_UUID, CONF_IBEACON_UUID),
_validate,
)
+1 -1
View File
@@ -115,7 +115,7 @@ CONFIG_SCHEMA = (
),
}
),
cv.has_at_least_one_key(CONF_TEMPERATURE, CONF_DURATION),
cv.AtLeastOne(CONF_TEMPERATURE, CONF_DURATION),
),
),
}
+1 -1
View File
@@ -24,7 +24,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_CONSTANT_BRIGHTNESS, default=False): cv.boolean,
}
),
cv.has_none_or_all_keys(
cv.AllOrNone(
[CONF_COLD_WHITE_COLOR_TEMPERATURE, CONF_WARM_WHITE_COLOR_TEMPERATURE]
),
light.validate_color_temperature_channels,
+1 -1
View File
@@ -406,7 +406,7 @@ DEEP_SLEEP_ENTER_SCHEMA = cv.All(
)
)
),
cv.has_none_or_all_keys(CONF_UNTIL, CONF_TIME_ID),
cv.AllOrNone(CONF_UNTIL, CONF_TIME_ID),
)
@@ -139,7 +139,7 @@ MMWAVE_SETTINGS_SCHEMA = cv.Schema(
cv.Optional(CONF_SENSITIVITY): cv.templatable(cv.int_range(min=0, max=9)),
}
).add_extra(
cv.has_at_least_one_key(
cv.AtLeastOne(
CONF_FACTORY_RESET,
CONF_DETECTION_SEGMENTS,
CONF_OUTPUT_LATENCY,
+1 -1
View File
@@ -62,7 +62,7 @@ CONFIG_SCHEMA = cv.All(
}
)
.extend(cv.polling_component_schema("60s")),
cv.has_at_most_one_key(CONF_SENSOR, CONF_LAMBDA),
cv.AtMostOne(CONF_SENSOR, CONF_LAMBDA),
)
+1 -1
View File
@@ -64,7 +64,7 @@ CONFIG_SCHEMA = cv.All(
)
.extend(cv.COMPONENT_SCHEMA)
.extend(i2c.i2c_device_schema(0x4C)),
cv.has_exactly_one_key(CONF_PWM, CONF_DAC),
cv.ExactlyOne(CONF_PWM, CONF_DAC),
)
+2 -2
View File
@@ -1550,7 +1550,7 @@ FRAMEWORK_SCHEMA = cv.Schema(
): cv.one_of(*SIGNING_SCHEMES, lower=True),
}
),
cv.has_exactly_one_key(CONF_SIGNING_KEY, CONF_VERIFICATION_KEY),
cv.ExactlyOne(CONF_SIGNING_KEY, CONF_VERIFICATION_KEY),
),
cv.Optional(
CONF_USE_FULL_CERTIFICATE_BUNDLE, default=False
@@ -1708,7 +1708,7 @@ CONFIG_SCHEMA = cv.All(
_set_default_framework,
_check_versions,
set_core_data,
cv.has_at_least_one_key(CONF_BOARD, CONF_VARIANT),
cv.AtLeastOne(CONF_BOARD, CONF_VARIANT),
)
+1 -1
View File
@@ -306,7 +306,7 @@ CONFIG_SCHEMA = cv.All(
}
).extend(cv.COMPONENT_SCHEMA),
validate_jpeg_quality,
cv.has_exactly_one_key(CONF_I2C_PINS, CONF_I2C_ID),
cv.ExactlyOne(CONF_I2C_PINS, CONF_I2C_ID),
)
@@ -129,7 +129,7 @@ CONFIG_SCHEMA = cv.All(
): cv.positive_time_period_nanoseconds,
}
).extend(cv.COMPONENT_SCHEMA),
cv.has_exactly_one_key(CONF_CHIPSET, CONF_BIT0_HIGH),
cv.ExactlyOne(CONF_CHIPSET, CONF_BIT0_HIGH),
)
+3 -3
View File
@@ -279,15 +279,15 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_WATERPROOF_SHIELD_DRIVER): cv.int_range(min=0, max=7),
}
).extend(cv.COMPONENT_SCHEMA),
cv.has_none_or_all_keys(CONF_DENOISE_GRADE, CONF_DENOISE_CAP_LEVEL),
cv.has_none_or_all_keys(
cv.AllOrNone(CONF_DENOISE_GRADE, CONF_DENOISE_CAP_LEVEL),
cv.AllOrNone(
CONF_DEBOUNCE_COUNT,
CONF_FILTER_MODE,
CONF_NOISE_THRESHOLD,
CONF_JITTER_STEP,
CONF_SMOOTH_MODE,
),
cv.has_none_or_all_keys(CONF_WATERPROOF_GUARD_RING, CONF_WATERPROOF_SHIELD_DRIVER),
cv.AllOrNone(CONF_WATERPROOF_GUARD_RING, CONF_WATERPROOF_SHIELD_DRIVER),
esp32.only_on_variant(
supported=[
esp32.VARIANT_ESP32,
+1 -1
View File
@@ -90,7 +90,7 @@ CONFIG_FEEDBACK_COVER_BASE_SCHEMA = (
CONFIG_SCHEMA = cv.All(
CONFIG_FEEDBACK_COVER_BASE_SCHEMA,
cv.has_none_or_all_keys(CONF_OPEN_SENSOR, CONF_CLOSE_SENSOR),
cv.AllOrNone(CONF_OPEN_SENSOR, CONF_CLOSE_SENSOR),
validate_infer_endstop,
)
+1 -1
View File
@@ -39,7 +39,7 @@ CONFIG_SCHEMA = (
)
.extend(cv.polling_component_schema("60s"))
.extend(i2c.i2c_device_schema(0x40))
.add_extra(cv.has_at_least_one_key(CONF_TEMPERATURE, CONF_HUMIDITY))
.add_extra(cv.AtLeastOne(CONF_TEMPERATURE, CONF_HUMIDITY))
)
@@ -62,7 +62,7 @@ OTA_HTTP_REQUEST_FLASH_ACTION_SCHEMA = cv.All(
cv.Required(CONF_URL): cv.templatable(cv.url),
}
),
cv.has_exactly_one_key(CONF_MD5, CONF_MD5_URL),
cv.ExactlyOne(CONF_MD5, CONF_MD5_URL),
)
+1 -1
View File
@@ -191,7 +191,7 @@ CONFIG_SCHEMA = cv.All(
)
.extend(cv.polling_component_schema("1s"))
.extend(spi.spi_device_schema(False, "40MHz")),
cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA),
cv.AtMostOne(CONF_PAGES, CONF_LAMBDA),
_validate,
)
+1 -1
View File
@@ -141,7 +141,7 @@ CONFIG_SCHEMA = cv.All(
)
.extend(cv.polling_component_schema("5s"))
.extend(i2c.i2c_device_schema(0x48)),
cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA),
cv.AtMostOne(CONF_PAGES, CONF_LAMBDA),
_validate_custom_waveform,
)
+1 -1
View File
@@ -29,7 +29,7 @@ CONFIG_SCHEMA = cv.All(
),
}
),
cv.has_exactly_one_key(CONF_REMOTE_RECEIVER_ID, CONF_REMOTE_TRANSMITTER_ID),
cv.ExactlyOne(CONF_REMOTE_RECEIVER_ID, CONF_REMOTE_TRANSMITTER_ID),
)
@@ -26,7 +26,7 @@ CONFIG_SCHEMA = cv.All(
),
}
),
cv.has_exactly_one_key(CONF_REMOTE_RECEIVER_ID, CONF_REMOTE_TRANSMITTER_ID),
cv.ExactlyOne(CONF_REMOTE_RECEIVER_ID, CONF_REMOTE_TRANSMITTER_ID),
)
+1 -1
View File
@@ -86,7 +86,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean,
}
),
cv.has_at_least_one_key(CONF_END_KEYS, CONF_MAX_LENGTH),
cv.AtLeastOne(CONF_END_KEYS, CONF_MAX_LENGTH),
)
+1 -1
View File
@@ -302,7 +302,7 @@ async def random_effect_to_code(config, effect_id):
): cv.positive_time_period_milliseconds,
}
),
cv.has_at_least_one_key(
cv.AtLeastOne(
CONF_STATE,
CONF_BRIGHTNESS,
CONF_COLOR_MODE,
+1 -1
View File
@@ -106,7 +106,7 @@ CONFIG_SCHEMA = cv.All(
)
.extend(cv.polling_component_schema("60s"))
.extend(i2c.i2c_device_schema(0x53)),
cv.has_at_least_one_key(CONF_LIGHT, CONF_AMBIENT_LIGHT, CONF_UV_INDEX, CONF_UV),
cv.AtLeastOne(CONF_LIGHT, CONF_AMBIENT_LIGHT, CONF_UV_INDEX, CONF_UV),
)
TYPES = {
+1 -1
View File
@@ -650,7 +650,7 @@ LVGL_TOP_LEVEL_SCHEMA = (
LVGL_SCHEMA = cv.All(
container_schema(obj_spec, LVGL_TOP_LEVEL_SCHEMA),
cv.has_at_most_one_key(CONF_PAGES, df.CONF_LAYOUT),
cv.AtMostOne(CONF_PAGES, df.CONF_LAYOUT),
add_hello_world,
)
+4 -4
View File
@@ -146,7 +146,7 @@ INDICATOR_LINE_SCHEMA = cv.Schema(
cv.Optional(CONF_VALUE, default=0.0): lv_float,
cv.Optional(CONF_OPA, default=1.0): opacity,
}
).add_extra(cv.has_at_most_one_key(CONF_R_MOD, CONF_LENGTH))
).add_extra(cv.AtMostOne(CONF_R_MOD, CONF_LENGTH))
class ScaleType(WidgetType):
@@ -186,7 +186,7 @@ INDICATOR_ARC_SCHEMA = cv.Schema(
cv.Optional(CONF_OPA, default=1.0): opacity,
cv.Optional(CONF_ROUNDED, default=False): cv.boolean,
}
).add_extra(cv.has_at_most_one_key(CONF_VALUE, CONF_START_VALUE))
).add_extra(cv.AtMostOne(CONF_VALUE, CONF_START_VALUE))
INDICATOR_TICKS_SCHEMA = cv.Schema(
{
@@ -198,7 +198,7 @@ INDICATOR_TICKS_SCHEMA = cv.Schema(
cv.Optional(CONF_END_VALUE): lv_float,
cv.Optional(CONF_LOCAL, default=False): lv_bool,
}
).add_extra(cv.has_at_most_one_key(CONF_VALUE, CONF_START_VALUE))
).add_extra(cv.AtMostOne(CONF_VALUE, CONF_START_VALUE))
INDICATOR_SCHEMA = cv.Schema(
{
@@ -355,7 +355,7 @@ class MeterType(WidgetType):
return CONF_SCALE, CONF_LINE, CONF_IMAGE, CONF_LABEL
def validate(self, value):
return cv.has_at_most_one_key(CONF_INDICATOR, CONF_PIVOT)(value)
return cv.AtMostOne(CONF_INDICATOR, CONF_PIVOT)(value)
async def on_create(self, var: MockObj, config: dict):
# Remove theme styling from outer container
+1 -1
View File
@@ -122,7 +122,7 @@ tabview_spec = TabviewType()
cv.Optional(CONF_ANIMATED, default=False): animated,
cv.Required(CONF_INDEX): lv_int,
},
).add_extra(cv.has_at_least_one_key(CONF_INDEX, CONF_TAB_ID)),
).add_extra(cv.AtLeastOne(CONF_INDEX, CONF_TAB_ID)),
synchronous=True,
)
async def tabview_select(config, action_id, template_arg, args):
+4 -4
View File
@@ -6,7 +6,7 @@ from collections.abc import Callable
import functools
from typing import Any, Self
import voluptuous as vol
import probatio
from esphome.components.const import CONF_COLOR_DEPTH
from esphome.components.display import CONF_SHOW_TEST_CARD, display_ns
@@ -280,12 +280,12 @@ def model_schema_extractor(
names = sorted(models)
representative = next((n for n in names if n != _CUSTOM_MODEL), names[0])
schema = model_schema({CONF_MODEL: representative, **(extra or {})})
if isinstance(schema, vol.All):
if isinstance(schema, probatio.All):
schema = next(
(v for v in schema.validators if isinstance(v, vol.Schema)),
(v for v in schema.validators if isinstance(v, probatio.Schema)),
schema,
)
if isinstance(schema, vol.Schema):
if isinstance(schema, probatio.Schema):
# The resolved schema pins ``model`` to the representative; expose
# the full model list so the dumped enum offers every model.
schema = schema.extend(
@@ -32,7 +32,7 @@ CONFIG_SCHEMA = cv.All(
)
.extend(CONFIG_BINARY_SENSOR_SCHEMA)
.extend(cv.polling_component_schema("never")),
cv.has_at_least_one_key(
cv.AtLeastOne(
CONF_PAGE_ID,
CONF_COMPONENT_ID,
CONF_COMPONENT_NAME,
@@ -63,7 +63,7 @@ CONFIG_SCHEMA = cv.All(
)
.extend(CONFIG_SENSOR_COMPONENT_SCHEMA)
.extend(cv.polling_component_schema("never")),
cv.has_exactly_one_key(CONF_COMPONENT_ID, CONF_COMPONENT_NAME, CONF_VARIABLE_NAME),
cv.ExactlyOne(CONF_COMPONENT_ID, CONF_COMPONENT_NAME, CONF_VARIABLE_NAME),
_validate,
)
@@ -24,7 +24,7 @@ CONFIG_SCHEMA = cv.All(
switch.switch_schema(NextionSwitch)
.extend(CONFIG_SWITCH_COMPONENT_SCHEMA)
.extend(cv.polling_component_schema("never")),
cv.has_exactly_one_key(CONF_COMPONENT_NAME, CONF_VARIABLE_NAME),
cv.ExactlyOne(CONF_COMPONENT_NAME, CONF_VARIABLE_NAME),
)
@@ -52,7 +52,7 @@ CONFIG_SCHEMA = cv.All(
}
)
.extend(cv.COMPONENT_SCHEMA),
cv.has_exactly_one_key(CONF_NDEF_CONTAINS, CONF_TAG_ID, CONF_UID),
cv.ExactlyOne(CONF_NDEF_CONTAINS, CONF_TAG_ID, CONF_UID),
)
+2 -2
View File
@@ -208,7 +208,7 @@ _NUMBER_SCHEMA = (
cv.Optional(CONF_ABOVE): cv.templatable(cv.float_),
cv.Optional(CONF_BELOW): cv.templatable(cv.float_),
},
cv.has_at_least_one_key(CONF_ABOVE, CONF_BELOW),
cv.AtLeastOne(CONF_ABOVE, CONF_BELOW),
),
cv.Optional(CONF_UNIT_OF_MEASUREMENT): validate_unit_of_measurement,
cv.Optional(CONF_MODE, default="AUTO"): cv.enum(NUMBER_MODES, upper=True),
@@ -323,7 +323,7 @@ NUMBER_IN_RANGE_CONDITION_SCHEMA = cv.All(
cv.Optional(CONF_ABOVE): cv.float_,
cv.Optional(CONF_BELOW): cv.float_,
},
cv.has_at_least_one_key(CONF_ABOVE, CONF_BELOW),
cv.AtLeastOne(CONF_ABOVE, CONF_BELOW),
)
+2 -4
View File
@@ -1,7 +1,5 @@
from collections.abc import Callable
from voluptuous import Schema
import esphome.config_validation as cv
from . import const, generate, schema
@@ -11,7 +9,7 @@ from .schema import TSchema
def create_entities_schema(
entities: dict[str, TSchema],
get_entity_validation_schema: Callable[[TSchema], cv.Schema],
) -> Schema:
) -> cv.Schema:
entity_schema = {}
for key, entity in entities.items():
schema_key = (
@@ -26,7 +24,7 @@ def create_entities_schema(
def create_component_schema(
entities: dict[str, schema.EntitySchema],
get_entity_validation_schema: Callable[[TSchema], cv.Schema],
) -> Schema:
) -> cv.Schema:
return (
cv.Schema(
{cv.GenerateID(const.CONF_OPENTHERM_ID): cv.use_id(generate.OpenthermHub)}
+1 -1
View File
@@ -234,7 +234,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_POLL_PERIOD): cv.positive_time_period_milliseconds,
}
).extend(_CONNECTION_SCHEMA),
cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV),
cv.ExactlyOne(CONF_NETWORK_KEY, CONF_TLV),
_validate_platform,
_validate,
_require_vfs_select,
+1 -1
View File
@@ -149,7 +149,7 @@ REMOTE_PACKAGE_SCHEMA = cv.All(
),
}
),
cv.has_at_least_one_key(CONF_FILE, CONF_FILES),
cv.AtLeastOne(CONF_FILE, CONF_FILES),
expand_file_to_files,
)
@@ -147,7 +147,7 @@ def packet_transport_sensor_schema(base_schema):
cv.Required(CONF_PROVIDER): provider_name_validate,
}
),
cv.has_at_least_one_key(CONF_ID, CONF_REMOTE_ID),
cv.AtLeastOne(CONF_ID, CONF_REMOTE_ID),
validate_packet_transport_sensor,
)
+1 -1
View File
@@ -32,7 +32,7 @@ CONFIG_SCHEMA = cv.All(
)
.extend(cv.polling_component_schema("1s"))
.extend(spi.spi_device_schema()),
cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA),
cv.AtMostOne(CONF_PAGES, CONF_LAMBDA),
)
FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema(
+1 -1
View File
@@ -78,7 +78,7 @@ CONFIG_SCHEMA = cv.All(
),
}
),
cv.has_at_least_one_key(CONF_COOL_OUTPUT, CONF_HEAT_OUTPUT),
cv.AtLeastOne(CONF_COOL_OUTPUT, CONF_HEAT_OUTPUT),
)
+1 -1
View File
@@ -17,7 +17,7 @@ CUSTOMIZED_ENTITY = cv.All(
cv.Optional(CONF_NAME): cv.string_strict,
},
),
cv.has_at_least_one_key(CONF_ID, CONF_NAME),
cv.AtLeastOne(CONF_ID, CONF_NAME),
)
CONFIG_SCHEMA = cv.Schema(
+1 -1
View File
@@ -33,7 +33,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_COLOR_INTERLOCK, default=False): cv.boolean,
}
),
cv.has_none_or_all_keys(
cv.AllOrNone(
[CONF_COLD_WHITE_COLOR_TEMPERATURE, CONF_WARM_WHITE_COLOR_TEMPERATURE]
),
light.validate_color_temperature_channels,
+2 -2
View File
@@ -251,7 +251,7 @@ def _detect_variant(value: ConfigType) -> ConfigType:
variant: str | None = value.get(CONF_VARIANT)
if board is None:
# `cv.has_at_least_one_key` guarantees variant is set here.
# `cv.AtLeastOne` guarantees variant is set here.
board = STANDARD_BOARDS[variant]
value[CONF_BOARD] = board
@@ -298,7 +298,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean,
}
),
cv.has_at_least_one_key(CONF_BOARD, CONF_VARIANT),
cv.AtLeastOne(CONF_BOARD, CONF_VARIANT),
_detect_variant,
set_core_data,
)
@@ -221,7 +221,7 @@ CONFIG_SCHEMA = cv.All(
): _validate_timing,
}
),
cv.has_exactly_one_key(CONF_CHIPSET, CONF_BIT0_HIGH),
cv.ExactlyOne(CONF_CHIPSET, CONF_BIT0_HIGH),
)
+1 -1
View File
@@ -33,7 +33,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_ON_FINISHED_PLAYBACK): automation.validate_automation({}),
}
).extend(cv.COMPONENT_SCHEMA),
cv.has_exactly_one_key(CONF_OUTPUT, CONF_SPEAKER),
cv.ExactlyOne(CONF_OUTPUT, CONF_SPEAKER),
)
+1 -1
View File
@@ -186,7 +186,7 @@ async def select_set_index_to_code(config, action_id, template_arg, args):
),
cv.Optional(CONF_LAMBDA): cv.returning_lambda,
}
).add_extra(cv.has_exactly_one_key(CONF_OPTIONS, CONF_LAMBDA)),
).add_extra(cv.ExactlyOne(CONF_OPTIONS, CONF_LAMBDA)),
)
async def select_is_to_code(config, condition_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
+3 -3
View File
@@ -338,7 +338,7 @@ _SENSOR_SCHEMA = (
cv.Optional(CONF_ABOVE): cv.templatable(cv.float_),
cv.Optional(CONF_BELOW): cv.templatable(cv.float_),
},
cv.has_at_least_one_key(CONF_ABOVE, CONF_BELOW),
cv.AtLeastOne(CONF_ABOVE, CONF_BELOW),
),
}
)
@@ -608,7 +608,7 @@ DELTA_SCHEMA = cv.Any(
cv.Optional(CONF_MIN_VALUE, default="0.0"): validate_delta_value,
cv.Optional(CONF_BASELINE): cv.templatable(cv.float_),
},
cv.has_at_least_one_key(CONF_MAX_VALUE, CONF_MIN_VALUE),
cv.AtLeastOne(CONF_MAX_VALUE, CONF_MIN_VALUE),
),
validate_delta_value,
)
@@ -1003,7 +1003,7 @@ SENSOR_IN_RANGE_CONDITION_SCHEMA = cv.All(
cv.Optional(CONF_ABOVE): cv.float_,
cv.Optional(CONF_BELOW): cv.float_,
},
cv.has_at_least_one_key(CONF_ABOVE, CONF_BELOW),
cv.AtLeastOne(CONF_ABOVE, CONF_BELOW),
)
@@ -146,7 +146,7 @@ CONFIG_SCHEMA = cv.All(
.extend(cv.COMPONENT_SCHEMA)
.extend(media_player.media_player_schema(SpeakerSourceMediaPlayer)),
cv.only_on_esp32,
cv.has_at_least_one_key(CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE),
cv.AtLeastOne(CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE),
_validate_no_shared_resources,
_validate_volume_settings,
)
+1 -1
View File
@@ -348,7 +348,7 @@ SPI_SINGLE_SCHEMA = cv.All(
),
}
),
cv.has_at_least_one_key(CONF_MISO_PIN, CONF_MOSI_PIN),
cv.AtLeastOne(CONF_MISO_PIN, CONF_MOSI_PIN),
cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040]),
)
+1 -1
View File
@@ -18,7 +18,7 @@ CONFIG_SCHEMA = cv.All(
)
.extend(cv.COMPONENT_SCHEMA)
.extend(i2c.i2c_device_schema(0x3C)),
cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA),
cv.AtMostOne(CONF_PAGES, CONF_LAMBDA),
_validate,
)
+1 -1
View File
@@ -20,7 +20,7 @@ CONFIG_SCHEMA = cv.All(
)
.extend(cv.COMPONENT_SCHEMA)
.extend(spi.spi_device_schema()),
cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA),
cv.AtMostOne(CONF_PAGES, CONF_LAMBDA),
_validate,
)
+1 -1
View File
@@ -21,7 +21,7 @@ CONFIG_SCHEMA = cv.All(
)
.extend(cv.COMPONENT_SCHEMA)
.extend(spi.spi_device_schema(cs_pin_required=False)),
cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA),
cv.AtMostOne(CONF_PAGES, CONF_LAMBDA),
)
FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema(
+1 -1
View File
@@ -21,7 +21,7 @@ CONFIG_SCHEMA = cv.All(
)
.extend(cv.COMPONENT_SCHEMA)
.extend(spi.spi_device_schema(cs_pin_required=False)),
cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA),
cv.AtMostOne(CONF_PAGES, CONF_LAMBDA),
)
FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema(
+1 -1
View File
@@ -19,7 +19,7 @@ CONFIG_SCHEMA = cv.All(
)
.extend(cv.COMPONENT_SCHEMA)
.extend(i2c.i2c_device_schema(0x3D)),
cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA),
cv.AtMostOne(CONF_PAGES, CONF_LAMBDA),
)
+1 -1
View File
@@ -21,7 +21,7 @@ CONFIG_SCHEMA = cv.All(
)
.extend(cv.COMPONENT_SCHEMA)
.extend(spi.spi_device_schema(cs_pin_required=False)),
cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA),
cv.AtMostOne(CONF_PAGES, CONF_LAMBDA),
)
FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema(
+1 -1
View File
@@ -21,7 +21,7 @@ CONFIG_SCHEMA = cv.All(
)
.extend(cv.COMPONENT_SCHEMA)
.extend(spi.spi_device_schema()),
cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA),
cv.AtMostOne(CONF_PAGES, CONF_LAMBDA),
)
FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema(
+1 -1
View File
@@ -21,7 +21,7 @@ CONFIG_SCHEMA = cv.All(
)
.extend(cv.COMPONENT_SCHEMA)
.extend(spi.spi_device_schema()),
cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA),
cv.AtMostOne(CONF_PAGES, CONF_LAMBDA),
)
FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema(
+1 -1
View File
@@ -19,7 +19,7 @@ CONFIG_SCHEMA = cv.All(
)
.extend(cv.COMPONENT_SCHEMA)
.extend(i2c.i2c_device_schema(0x3F)),
cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA),
cv.AtMostOne(CONF_PAGES, CONF_LAMBDA),
)
+1 -1
View File
@@ -21,7 +21,7 @@ CONFIG_SCHEMA = cv.All(
)
.extend(cv.COMPONENT_SCHEMA)
.extend(spi.spi_device_schema()),
cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA),
cv.AtMostOne(CONF_PAGES, CONF_LAMBDA),
)
FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema(
+1 -1
View File
@@ -67,7 +67,7 @@ CONFIG_SCHEMA = cv.All(
)
.extend(cv.COMPONENT_SCHEMA)
.extend(spi.spi_device_schema()),
cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA),
cv.AtMostOne(CONF_PAGES, CONF_LAMBDA),
)
@@ -20,7 +20,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_OUTPUT): cv.use_id(output.BinaryOutput),
}
),
cv.has_at_least_one_key(CONF_PIN, CONF_OUTPUT),
cv.AtLeastOne(CONF_PIN, CONF_OUTPUT),
)
+1 -1
View File
@@ -674,7 +674,7 @@ CONFIG_SCHEMA = cv.All(
}
)
.extend(cv.COMPONENT_SCHEMA),
cv.has_at_least_one_key(
cv.AtLeastOne(
CONF_COOL_ACTION, CONF_DRY_ACTION, CONF_FAN_ONLY_ACTION, CONF_HEAT_ACTION
),
validate_thermostat,
+1 -1
View File
@@ -306,7 +306,7 @@ def validate_cron_keys(value):
value = {x: value[x] for x in value if x != CONF_AT}
value.update(at_)
return value
return cv.has_at_least_one_key(*CRON_KEYS)(value)
return cv.AtLeastOne(*CRON_KEYS)(value)
def validate_tz(value: str) -> str:
+1 -1
View File
@@ -186,7 +186,7 @@ CONFIG_SCHEMA = cv.All(
}
)
.extend(cv.COMPONENT_SCHEMA),
cv.has_at_least_one_key(CONF_TARGET_TEMPERATURE_DATAPOINT, CONF_SWITCH_DATAPOINT),
cv.AtLeastOne(CONF_TARGET_TEMPERATURE_DATAPOINT, CONF_SWITCH_DATAPOINT),
validate_temperature_multipliers,
validate_cooling_values,
)
+1 -1
View File
@@ -26,7 +26,7 @@ CONFIG_SCHEMA = cv.All(
}
)
.extend(cv.COMPONENT_SCHEMA),
cv.has_at_least_one_key(CONF_SPEED_DATAPOINT, CONF_SWITCH_DATAPOINT),
cv.AtLeastOne(CONF_SPEED_DATAPOINT, CONF_SWITCH_DATAPOINT),
)
+1 -1
View File
@@ -71,7 +71,7 @@ CONFIG_SCHEMA = cv.All(
): cv.positive_time_period_milliseconds,
}
).extend(cv.COMPONENT_SCHEMA),
cv.has_at_least_one_key(
cv.AtLeastOne(
CONF_DIMMER_DATAPOINT,
CONF_SWITCH_DATAPOINT,
CONF_COLOR_DATAPOINT,
+1 -1
View File
@@ -43,7 +43,7 @@ CONFIG_SCHEMA = cv.All(
}
)
.extend(cv.COMPONENT_SCHEMA),
cv.has_exactly_one_key(CONF_ENUM_DATAPOINT, CONF_INT_DATAPOINT),
cv.ExactlyOne(CONF_ENUM_DATAPOINT, CONF_INT_DATAPOINT),
)
+1 -1
View File
@@ -268,7 +268,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_DEBUG): maybe_empty_debug,
}
).extend(cv.COMPONENT_SCHEMA),
cv.has_at_least_one_key(CONF_TX_PIN, CONF_RX_PIN, CONF_PORT),
cv.AtLeastOne(CONF_TX_PIN, CONF_RX_PIN, CONF_PORT),
validate_host_config,
validate_rx_buffer_size,
)
@@ -219,7 +219,7 @@ CONFIG_SCHEMA = cv.All(
.extend(spi.spi_device_schema()),
validate_full_update_every_only_types_ac,
validate_reset_pin_required,
cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA),
cv.AtMostOne(CONF_PAGES, CONF_LAMBDA),
)
FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema(
+1 -1
View File
@@ -272,7 +272,7 @@ EAP_AUTH_SCHEMA = cv.All(
}
),
wpa2_eap.validate_eap,
cv.has_at_least_one_key(CONF_IDENTITY, CONF_CERTIFICATE),
cv.AtLeastOne(CONF_IDENTITY, CONF_CERTIFICATE),
)
WIFI_NETWORK_BASE = cv.Schema(
+19 -19
View File
@@ -10,7 +10,7 @@ import logging
import re
from typing import Any
import voluptuous as vol
import probatio
from esphome import core, loader, pins, yaml_util
from esphome.components.substitutions import do_substitution_pass
@@ -185,7 +185,7 @@ def _resolve_component_aliases(config: dict[str, Any]) -> None:
if canonical in config:
# The canonical key and (at least) one deprecated alias are both
# present.
raise vol.Invalid(
raise probatio.Invalid(
f"Both '{legacies[0]}:' (deprecated alias of '{canonical}:') "
f"and '{canonical}:' are present in the configuration. Remove "
f"the deprecated '{legacies[0]}:' key.",
@@ -194,7 +194,7 @@ def _resolve_component_aliases(config: dict[str, Any]) -> None:
if len(legacies) > 1:
# Several different deprecated aliases of the same component.
listed = ", ".join(f"'{alias}:'" for alias in legacies)
raise vol.Invalid(
raise probatio.Invalid(
f"Multiple deprecated aliases of '{canonical}:' are present "
f"({listed}). Use only '{canonical}:'.",
path=[legacies[0]],
@@ -253,7 +253,7 @@ class Config(OrderedDict, fv.FinalValidateConfig):
def __init__(self):
super().__init__()
# A list of voluptuous errors
self.errors: list[vol.Invalid] = []
self.errors: list[probatio.Invalid] = []
# A list of paths that should be fully outputted
# The values will be the paths to all "domain", for example (['logger'], 'logger')
# or (['sensor', 'ultrasonic'], 'sensor.ultrasonic')
@@ -271,8 +271,8 @@ class Config(OrderedDict, fv.FinalValidateConfig):
# ID to ensure stable order for keys with equal priority
self._validation_tasks_id = 0
def add_error(self, error: vol.Invalid) -> None:
if isinstance(error, vol.MultipleInvalid):
def add_error(self, error: probatio.Invalid) -> None:
if isinstance(error, probatio.MultipleInvalid):
for err in error.errors:
self.add_error(err)
return
@@ -282,7 +282,7 @@ class Config(OrderedDict, fv.FinalValidateConfig):
i for i, v in enumerate(error.path) if v is cv.ROOT_CONFIG_PATH
)
# can't change the path so re-create the error
error = vol.Invalid(
error = probatio.Invalid(
message=error.error_message,
path=error.path[last_root + 1 :],
error_type=error.error_type,
@@ -308,12 +308,12 @@ class Config(OrderedDict, fv.FinalValidateConfig):
yield
except cv.FinalExternalInvalid as e:
self.add_error(e)
except vol.Invalid as e:
except probatio.Invalid as e:
e.prepend(path)
self.add_error(e)
def add_str_error(self, message: str, path: ConfigPath) -> None:
self.add_error(vol.Invalid(message, path))
self.add_error(probatio.Invalid(message, path))
def add_output_path(self, path: ConfigPath, domain: str) -> None:
self.output_paths.append((path, domain))
@@ -330,7 +330,7 @@ class Config(OrderedDict, fv.FinalValidateConfig):
conf = conf[key]
conf[path[-1]] = value
def get_error_for_path(self, path: ConfigPath) -> vol.Invalid | None:
def get_error_for_path(self, path: ConfigPath) -> probatio.Invalid | None:
for err in self.errors:
if self.get_deepest_path(err.path) == path:
self.errors.remove(err)
@@ -1113,7 +1113,7 @@ def validate_config(
config,
command_line_substitutions=command_line_substitutions,
)
except vol.Invalid as err:
except probatio.Invalid as err:
result.update(config)
result.add_error(err)
return result
@@ -1123,7 +1123,7 @@ def validate_config(
result.add_output_path([CONF_SUBSTITUTIONS], CONF_SUBSTITUTIONS)
try:
config = do_substitution_pass(config, command_line_substitutions)
except vol.Invalid as err:
except probatio.Invalid as err:
CORE.raw_config = config
result.add_error(err)
return result
@@ -1146,7 +1146,7 @@ def validate_config(
# plain config errors and abort further validation.
try:
_resolve_component_aliases(config)
except vol.Invalid as err:
except probatio.Invalid as err:
result.update(config)
result.add_error(err)
return result
@@ -1155,7 +1155,7 @@ def validate_config(
# After this step, there will not be any Extend or Remove values in the config anymore
try:
resolve_extend_remove(config)
except vol.Invalid as err:
except probatio.Invalid as err:
result.add_error(err)
# 1.3. Load external_components
@@ -1165,7 +1165,7 @@ def validate_config(
result.add_output_path([CONF_EXTERNAL_COMPONENTS], CONF_EXTERNAL_COMPONENTS)
try:
do_external_components_pass(config)
except vol.Invalid as err:
except probatio.Invalid as err:
result.update(config)
result.add_error(err)
return result
@@ -1219,7 +1219,7 @@ def validate_config(
result.add_output_path([CONF_ESPHOME], CONF_ESPHOME)
try:
target_platform = core_config.preload_core_config(config, result)
except vol.Invalid as err:
except probatio.Invalid as err:
result.add_error(err)
return result
# Remove temporary esphome config path again, it will be reloaded later
@@ -1287,7 +1287,7 @@ def _get_parent_name(path, config):
return path[-1]
def _format_vol_invalid(ex: vol.Invalid, config: Config) -> str:
def _format_vol_invalid(ex: probatio.Invalid, config: Config) -> str:
message = ""
paren = _get_parent_name(ex.path[:-1], config)
@@ -1299,7 +1299,7 @@ def _format_vol_invalid(ex: vol.Invalid, config: Config) -> str:
message += f"[{ex.path[-1]}] is an invalid option for [{paren}]. Please check the indentation."
elif "extra keys not allowed" in str(ex):
message += f"[{ex.path[-1]}] is an invalid option for [{paren}]."
elif isinstance(ex, vol.RequiredFieldInvalid):
elif isinstance(ex, probatio.RequiredFieldInvalid):
if ex.msg == "required key not provided":
message += f"'{ex.path[-1]}' is a required option for [{paren}]."
else:
@@ -1345,7 +1345,7 @@ def load_config(
) -> Config:
try:
return _load_config(command_line_substitutions, skip_external_update)
except vol.Invalid as err:
except probatio.Invalid as err:
raise EsphomeError(f"Error while parsing config: {err}") from err
+94 -108
View File
@@ -22,7 +22,7 @@ from string import ascii_letters, digits
import typing
import uuid as uuid_
import voluptuous as vol
import probatio
from esphome import core
import esphome.codegen as cg
@@ -100,7 +100,12 @@ from esphome.schema_extractors import (
schema_extractor_typed,
)
from esphome.util import parse_esphome_version
from esphome.voluptuous_schema import _Schema
# ExtraKeysInvalid is re-exported here so it is reachable as cv.ExtraKeysInvalid.
from esphome.voluptuous_schema import ( # noqa: F401 pylint: disable=unused-import
ExtraKeysInvalid,
_Schema,
)
from esphome.yaml_util import SensitiveStr, make_data_base
_LOGGER = logging.getLogger(__name__)
@@ -108,20 +113,24 @@ _LOGGER = logging.getLogger(__name__)
# pylint: disable=invalid-name
Schema = _Schema
All = vol.All
Coerce = vol.Coerce
Range = vol.Range
Invalid = vol.Invalid
MultipleInvalid = vol.MultipleInvalid
Any = vol.Any
Lower = vol.Lower
Upper = vol.Upper
Length = vol.Length
Exclusive = vol.Exclusive
Inclusive = vol.Inclusive
ALLOW_EXTRA = vol.ALLOW_EXTRA
UNDEFINED = vol.UNDEFINED
RequiredFieldInvalid = vol.RequiredFieldInvalid
All = probatio.All
Coerce = probatio.Coerce
Range = probatio.Range
Invalid = probatio.Invalid
MultipleInvalid = probatio.MultipleInvalid
Any = probatio.Any
Lower = probatio.Lower
Upper = probatio.Upper
Length = probatio.Length
Exclusive = probatio.Exclusive
Inclusive = probatio.Inclusive
AtLeastOne = probatio.AtLeastOne
ExactlyOne = probatio.ExactlyOne
AtMostOne = probatio.AtMostOne
AllOrNone = probatio.AllOrNone
ALLOW_EXTRA = probatio.ALLOW_EXTRA
UNDEFINED = probatio.UNDEFINED
RequiredFieldInvalid = probatio.RequiredFieldInvalid
# this sentinel object can be placed in an 'Invalid' path to say
# the rest of the error path is relative to the root config path
ROOT_CONFIG_PATH = object()
@@ -330,7 +339,7 @@ class Visibility(StrEnum):
YAML_ONLY = "yaml_only"
class Optional(vol.Optional):
class Optional(probatio.Optional):
"""Mark a field as optional and optionally define a default for the field.
When no default is defined, the validated config will not contain the key.
@@ -360,7 +369,7 @@ class Optional(vol.Optional):
self.visibility: Visibility | None = visibility
class Required(vol.Required):
class Required(probatio.Required):
"""Define a field to be required to be set. The validated configuration is guaranteed
to contain this key.
@@ -865,67 +874,13 @@ only_with_arduino = only_with_framework(Framework.ARDUINO)
# Adapted from:
# https://github.com/alecthomas/voluptuous/issues/115#issuecomment-144464666
def has_at_least_one_key(*keys):
"""Validate that at least one of the given keys exist in the config."""
def validate(obj):
"""Test keys exist in dict."""
if not isinstance(obj, dict):
raise Invalid("expected dictionary")
if not any(k in keys for k in obj):
raise Invalid(f"Must contain at least one of {', '.join(keys)}.")
return obj
return validate
def has_exactly_one_key(*keys):
"""Validate that exactly one of the given keys exist in the config."""
def validate(obj):
if not isinstance(obj, dict):
raise Invalid("expected dictionary")
number = sum(k in keys for k in obj)
if number > 1:
raise Invalid(f"Cannot specify more than one of {', '.join(keys)}.")
if number < 1:
raise Invalid(f"Must contain exactly one of {', '.join(keys)}.")
return obj
return validate
def has_at_most_one_key(*keys):
"""Validate that at most one of the given keys exist in the config."""
def validate(obj):
if not isinstance(obj, dict):
raise Invalid("expected dictionary")
used = set(obj) & set(keys)
if len(used) > 1:
msg = "Cannot specify more than one of '" + "', '".join(used) + "'."
raise MultipleInvalid([Invalid(msg, path=[k]) for k in used])
return obj
return validate
def has_none_or_all_keys(*keys):
"""Validate that none or all of the given keys exist in the config."""
def validate(obj):
if not isinstance(obj, dict):
raise Invalid("expected dictionary")
number = sum(k in keys for k in obj)
if number != 0 and number != len(keys):
raise Invalid(f"Must specify either none or all of {', '.join(keys)}.")
return obj
return validate
# Kept as aliases for backwards compatibility with external components that use
# these names (they also mirror Home Assistant's cv helpers). Internally, prefer the
# probatio names (cv.AtLeastOne, ...).
has_at_least_one_key = AtLeastOne
has_exactly_one_key = ExactlyOne
has_at_most_one_key = AtMostOne
has_none_or_all_keys = AllOrNone
TIME_PERIOD_ERROR = (
@@ -1440,9 +1395,24 @@ def hostname(value):
raise Invalid(f"Invalid hostname: {value}")
# Domain/IP matcher (ported from voluptuous, which ESPHome used as vol.DOMAIN_REGEX).
_DOMAIN_REGEX = re.compile(
"(?:"
# domain
r"(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+"
# tld
r"(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?$)"
# literal form, ipv4 address (SMTP 4.1.3)
r"|^\[(25[0-5]|2[0-4]\d|[0-1]?\d?\d)"
r"(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}\]$"
r")\Z",
re.IGNORECASE,
)
def domain(value):
value = string(value)
if re.match(vol.DOMAIN_REGEX, value) is not None:
if _DOMAIN_REGEX.match(value) is not None:
return value
try:
return str(ipaddress(value))
@@ -1730,7 +1700,7 @@ def prepend_path(path):
path = [path]
try:
yield
except vol.Invalid as e:
except probatio.Invalid as e:
e.prepend(path)
raise e
@@ -1742,7 +1712,7 @@ def remove_prepend_path(path):
path = [path]
try:
yield
except vol.Invalid as e:
except probatio.Invalid as e:
if list_starts_with(e.path, path):
# Can't set e.path (namedtuple
for _ in range(len(path)):
@@ -1941,7 +1911,7 @@ def extract_keys(schema):
for skey in list(schema.keys()):
if isinstance(skey, str):
keys.append(skey)
elif isinstance(skey, vol.Marker) and isinstance(skey.schema, str):
elif isinstance(skey, probatio.Marker) and isinstance(skey.schema, str):
keys.append(skey.schema)
else:
raise ValueError
@@ -1998,10 +1968,22 @@ class SplitDefault(Optional):
self._defaults = {}
for platform_key, value in kwargs.items():
self._defaults[platform_key] = vol.default_factory(value)
self._defaults[platform_key] = probatio.default_factory(value)
@property
def default(self):
# Return a stable factory. probatio captures it once when the schema is
# compiled and calls it at validation time, when the target platform is
# known. The factory yields UNDEFINED when no value is configured for the
# active platform, which probatio treats as "no default" (key stays absent).
return self._resolve_default
@default.setter
def default(self, value):
# Ignore default set from probatio.Optional
pass
def _resolve_default(self):
keys = []
if CORE.is_esp32:
from esphome.components.esp32 import VARIANT_ESP32, get_esp32_variant
@@ -2014,14 +1996,10 @@ class SplitDefault(Optional):
keys += _get_default_key(framework)
keys += _get_default_key()
for key in keys:
if self._defaults.get(key) is not None:
return self._defaults[key]
return vol.default_factory(vol.UNDEFINED)
@default.setter
def default(self, value):
# Ignore default set from vol.Optional
pass
factory = self._defaults.get(key)
if factory is not None and not isinstance(factory, probatio.Undefined):
return factory()
return probatio.UNDEFINED
class OnlyWith(Optional):
@@ -2048,22 +2026,26 @@ class OnlyWith(Optional):
def __init__(self, key, component: str | list[str], default=None) -> None:
super().__init__(key)
self._component = component
self._default = vol.default_factory(default)
self._default = probatio.default_factory(default)
@property
def default(self) -> Callable[[], typing.Any] | vol.Undefined:
if isinstance(self._component, list):
if all(c in CORE.loaded_integrations for c in self._component):
return self._default
elif self._component in CORE.loaded_integrations:
return self._default
return vol.UNDEFINED
def default(self) -> Callable[[], typing.Any]:
# Stable factory resolved at validation time (see SplitDefault.default).
return self._resolve_default
@default.setter
def default(self, value):
# Ignore default set from vol.Optional
# Ignore default set from probatio.Optional
pass
def _resolve_default(self) -> typing.Any:
if isinstance(self._component, list):
if all(c in CORE.loaded_integrations for c in self._component):
return self._default()
elif self._component in CORE.loaded_integrations:
return self._default()
return probatio.UNDEFINED
class OnlyWithout(Optional):
"""Set the default value only if the given component is NOT loaded."""
@@ -2071,19 +2053,23 @@ class OnlyWithout(Optional):
def __init__(self, key, component, default=None):
super().__init__(key)
self._component = component
self._default = vol.default_factory(default)
self._default = probatio.default_factory(default)
@property
def default(self):
if self._component not in CORE.loaded_integrations:
return self._default
return vol.UNDEFINED
# Stable factory resolved at validation time (see SplitDefault.default).
return self._resolve_default
@default.setter
def default(self, value):
# Ignore default set from vol.Optional
# Ignore default set from probatio.Optional
pass
def _resolve_default(self):
if self._component not in CORE.loaded_integrations:
return self._default()
return probatio.UNDEFINED
def _entity_base_validator(config):
if CONF_NAME not in config and CONF_ID not in config:
@@ -2101,7 +2087,7 @@ def _entity_base_validator(config):
def ensure_schema(schema):
if not isinstance(schema, vol.Schema):
if not isinstance(schema, probatio.Schema):
return Schema(schema)
return schema
@@ -2495,7 +2481,7 @@ def require_esphome_version(year, month, patch):
@contextmanager
def suppress_invalid():
with suppress(vol.Invalid):
with suppress(probatio.Invalid):
yield
+66 -182
View File
@@ -1,211 +1,90 @@
import difflib
import itertools
"""ESPHome's ``cv.Schema``, built on probatio.
import voluptuous as vol
probatio validates the mappings; this module only layers on the few behaviors
ESPHome needs on top:
- every schema key must be wrapped in ``cv.Required`` or ``cv.Optional``;
- an undeclared ``id`` key is dropped silently, so ``!extend``/``!remove`` work on
any list-based config without each component declaring an id in its schema;
- ``extra_schemas``: post-validation transforms applied once the mapping validates.
probatio provides the rest natively: the "did you mean?" close matches on an unknown
key (``ExtraKeysInvalid.candidates``), ``dictionary value`` error tagging, and
required/default handling.
"""
import probatio
from probatio import error as probatio_error
from esphome.const import CONF_ID
from esphome.schema_extractors import schema_extractor_extended
# Re-exported so existing imports keep working. probatio's ExtraKeysInvalid already
# carries the ``candidates`` close-match list that ESPHome renders in its errors.
ExtraKeysInvalid = probatio_error.ExtraKeysInvalid
class ExtraKeysInvalid(vol.Invalid):
def __init__(self, *arg, **kwargs):
self.candidates = kwargs.pop("candidates")
vol.Invalid.__init__(self, *arg, **kwargs)
# A bare value of one of these types is not a valid schema key in ESPHome; keys must
# be wrapped in Required/Optional. Mirrors voluptuous' ``primitive_types``.
_PRIMITIVE_TYPES = (str, int, float, bool, bytes, complex)
# A single shared marker for the silent ``id`` drop, reused across every compiled
# mapping instead of constructing a new one each time.
_REMOVE_ID = probatio.Remove(CONF_ID)
def ensure_multiple_invalid(err):
if isinstance(err, vol.MultipleInvalid):
if isinstance(err, probatio.MultipleInvalid):
return err
if isinstance(err, list):
return vol.MultipleInvalid(err)
return vol.MultipleInvalid([err])
return probatio.MultipleInvalid(err)
return probatio.MultipleInvalid([err])
# pylint: disable=protected-access, unidiomatic-typecheck
class _Schema(vol.Schema):
"""Custom cv.Schema that prints similar keys on error."""
class _Schema(probatio.Schema):
"""Custom cv.Schema: ESPHome key rules, id-dropping, and extra schemas."""
def __init__(
self, schema, required=False, extra=vol.PREVENT_EXTRA, extra_schemas=None
self, schema, required=False, extra=probatio.PREVENT_EXTRA, extra_schemas=None
):
super().__init__(schema, required=required, extra=extra)
# List of extra schemas to apply after validation
# Should be used sparingly, as it's not a very voluptuous-way/clean way of
# doing things.
# List of extra schemas to apply after validation. Should be used sparingly,
# as it's not a very probatio-way/clean way of doing things.
self._extra_schemas = extra_schemas or []
def _compile_dict(self, schema):
# Check some things that ESPHome's schemas do not allow, mostly to keep the
# schemas sane (these may be re-added if ever needed).
for key in schema:
if key is probatio.Extra:
raise ValueError("ESPHome does not allow vol.Extra")
if isinstance(key, probatio.Remove):
raise ValueError("ESPHome does not allow vol.Remove")
if isinstance(key, _PRIMITIVE_TYPES):
raise ValueError(
"All schema keys must be wrapped in cv.Required or cv.Optional"
)
# Silently drop an undeclared 'id' on any dict so that !extend / !remove work
# on every list-based config without requiring each component to declare an id
# in its schema. With ALLOW_EXTRA the key is kept like any other extra; with
# REMOVE_EXTRA it is dropped anyway, so only inject the Remove when it matters.
# The injected key lives in the compiled candidates only, never in .schema, so
# schema introspection (docs, schema dumping) stays clean.
if self.extra != probatio.ALLOW_EXTRA and CONF_ID not in schema:
schema = {**schema, _REMOVE_ID: object}
return super()._compile_dict(schema)
def __call__(self, data):
res = super().__call__(data)
for extra in self._extra_schemas:
try:
res = extra(res)
except vol.Invalid as err:
except probatio.Invalid as err:
raise ensure_multiple_invalid(err) from err
return res
def _compile_mapping(self, schema, invalid_msg=None):
invalid_msg = invalid_msg or "mapping value"
# Check some things that ESPHome's schemas do not allow
# mostly to keep the logic in this method sane (so these may be re-added if needed).
for key in schema:
if key is vol.Extra:
raise ValueError("ESPHome does not allow vol.Extra")
if isinstance(key, vol.Remove):
raise ValueError("ESPHome does not allow vol.Remove")
if isinstance(key, vol.primitive_types):
raise ValueError(
"All schema keys must be wrapped in cv.Required or cv.Optional"
)
# Keys that may be required
all_required_keys = {key for key in schema if isinstance(key, vol.Required)}
# Keys that may have defaults
# This is a list because sets do not guarantee insertion order
all_default_keys = [key for key in schema if isinstance(key, vol.Optional)]
# Recursively compile schema
_compiled_schema = {}
for skey, svalue in schema.items():
new_key = self._compile(skey)
new_value = self._compile(svalue)
_compiled_schema[skey] = (new_key, new_value)
# Sort compiled schema (probably not necessary for esphome, but leave it here just in case)
candidates = list(
vol.schema_builder._iterate_mapping_candidates(_compiled_schema)
)
# After we have the list of candidates in the correct order, we want to apply some
# optimization so that each
# key in the data being validated will be matched against the relevant schema keys only.
# No point in matching against different keys
additional_candidates = []
candidates_by_key = {}
for skey, (ckey, cvalue) in candidates:
if type(skey) in vol.primitive_types:
candidates_by_key.setdefault(skey, []).append((skey, (ckey, cvalue)))
elif (
isinstance(skey, vol.Marker)
and type(skey.schema) in vol.primitive_types
):
candidates_by_key.setdefault(skey.schema, []).append(
(skey, (ckey, cvalue))
)
else:
# These are wildcards such as 'int', 'str', 'Remove' and others which should be
# applied to all keys
additional_candidates.append((skey, (ckey, cvalue)))
key_names = []
for skey in schema:
if isinstance(skey, str):
key_names.append(skey)
elif isinstance(skey, vol.Marker) and isinstance(skey.schema, str):
key_names.append(skey.schema)
def validate_mapping(path, iterable, out):
required_keys = all_required_keys.copy()
# Build a map of all provided key-value pairs.
# The type(out) is used to retain ordering in case a ordered
# map type is provided as input.
key_value_map = type(out)()
for key, value in iterable:
key_value_map[key] = value
# Insert default values for non-existing keys.
for key in all_default_keys:
if (
not isinstance(key.default, vol.Undefined)
and key.schema not in key_value_map
):
# A default value has been specified for this missing key, insert it.
key_value_map[key.schema] = key.default()
error = None
errors = []
for key, value in key_value_map.items():
key_path = path + [key]
# Optimization. Validate against the matching key first, then fallback to the rest
relevant_candidates = itertools.chain(
candidates_by_key.get(key, []), additional_candidates
)
# compare each given key/value against all compiled key/values
# schema key, (compiled key, compiled value)
for skey, (ckey, cvalue) in relevant_candidates:
try:
new_key = ckey(key_path, key)
except vol.Invalid as e:
if len(e.path) > len(key_path):
raise
if not error or len(e.path) > len(error.path):
error = e
continue
# Backtracking is not performed once a key is selected, so if
# the value is invalid we immediately throw an exception.
exception_errors = []
try:
cval = cvalue(key_path, value)
out[new_key] = cval
except vol.MultipleInvalid as e:
exception_errors.extend(e.errors)
except vol.Invalid as e:
exception_errors.append(e)
if exception_errors:
for err in exception_errors:
if len(err.path) <= len(key_path):
err.error_type = invalid_msg
errors.append(err)
# If there is a validation error for a required
# key, this means that the key was provided.
# Discard the required key so it does not
# create an additional, noisy exception.
required_keys.discard(skey)
break
# Key and value okay, mark as found in case it was
# a Required() field.
required_keys.discard(skey)
break
else:
if self.extra == vol.ALLOW_EXTRA:
out[key] = value
elif key == "id":
# Silently drop 'id' on any dict so that
# !extend / !remove work on every list-based
# config without requiring each component to
# declare an id in its schema.
pass
elif self.extra != vol.REMOVE_EXTRA:
if isinstance(key, str) and key_names:
matches = difflib.get_close_matches(key, key_names)
errors.append(
ExtraKeysInvalid(
"extra keys not allowed",
key_path,
candidates=matches,
)
)
else:
errors.append(
vol.Invalid("extra keys not allowed", key_path)
)
# for any required keys left that weren't found and don't have defaults:
for key in required_keys:
msg = getattr(key, "msg", None) or "required key not provided"
errors.append(vol.RequiredFieldInvalid(msg, path + [key]))
if errors:
raise vol.MultipleInvalid(errors)
return out
return validate_mapping
def add_extra(self, validator):
validator = _Schema(validator)
self._extra_schemas.append(validator)
@@ -233,7 +112,12 @@ class _Schema(vol.Schema):
extra_schemas = self._extra_schemas.copy()
if isinstance(schema, _Schema):
extra_schemas.extend(schema._extra_schemas)
if isinstance(schema, vol.Schema):
if isinstance(schema, probatio.Schema):
schema = schema.schema
ret = super().extend(schema, extra=extra)
# probatio's extend already returns a fully compiled _Schema (type(self)).
# Only rewrap (recompile) when there are extra_schemas to carry over;
# otherwise return it as-is to avoid compiling the merged mapping twice.
if not extra_schemas:
return ret
return _Schema(ret.schema, extra=ret.extra, extra_schemas=extra_schemas)
+1 -1
View File
@@ -14,7 +14,7 @@ from esphome.yaml_util import parse_yaml
def _get_invalid_range(res: Config, invalid: cv.Invalid) -> DocumentRange | None:
return res.get_deepest_document_range_for_path(
invalid.path, invalid.error_message == "extra keys not allowed"
invalid.path, isinstance(invalid, cv.ExtraKeysInvalid)
)
+9 -7
View File
@@ -5,7 +5,7 @@ import string
from typing import Literal, NotRequired, TypedDict, Unpack
import unicodedata
import voluptuous as vol
import probatio
import esphome.config_validation as cv
from esphome.const import ALLOWED_NAME_CHARS, ENV_QUICKWIZARD
@@ -348,7 +348,7 @@ def wizard(path: Path) -> int:
try:
name = cv.valid_name(name)
break
except vol.Invalid:
except probatio.Invalid:
safe_print(
color(
AnsiFore.RED,
@@ -385,9 +385,11 @@ def wizard(path: Path) -> int:
color(AnsiFore.BOLD_WHITE, f"({'/'.join(wizard_platforms)}): ")
)
try:
platform = vol.All(vol.Upper, vol.Any(*wizard_platforms))(platform.upper())
platform = probatio.All(probatio.Upper, probatio.Any(*wizard_platforms))(
platform.upper()
)
break
except vol.Invalid:
except probatio.Invalid:
safe_print(
f'Unfortunately, I can\'t find an espressif microcontroller called "{platform}". Please try again.'
)
@@ -452,9 +454,9 @@ def wizard(path: Path) -> int:
while True:
board = safe_input(color(AnsiFore.BOLD_WHITE, "(board): "))
try:
board = vol.All(vol.Lower, vol.Any(*boards))(board)
board = probatio.All(probatio.Lower, probatio.Any(*boards))(board)
break
except vol.Invalid:
except probatio.Invalid:
safe_print(
color(
AnsiFore.RED, f'Sorry, I don\'t think the board "{board}" exists.'
@@ -484,7 +486,7 @@ def wizard(path: Path) -> int:
try:
ssid = cv.ssid(ssid)
break
except vol.Invalid:
except probatio.Invalid:
safe_print(
color(
AnsiFore.RED,
+4 -4
View File
@@ -1,17 +1,17 @@
cryptography==49.0.0
voluptuous==0.16.0
probatio==0.5.1
PyYAML==6.0.3
paho-mqtt==1.6.1
colorama==0.4.6
tzlocal==5.4.4 # from time
tzlocal==5.4.3 # from time
tzdata>=2026.2 # from time
pyserial==3.5
platformio==6.1.19
esptool==5.3.1
esptool==5.3.0
click==8.3.3
aioesphomeapi==45.5.2
zeroconf==0.150.0
puremagic==2.2.0
puremagic==1.30
ruamel.yaml==0.19.1 # dashboard_import
ruamel.yaml.clib==0.2.15 # dashboard_import
esphome-glyphsets==0.2.0
+6 -4
View File
@@ -6,7 +6,7 @@ import os
from pathlib import Path
import re
import voluptuous as vol
import probatio
# NOTE: Cannot import other esphome components globally as a modification in vol_schema
# is needed before modules are loaded
@@ -929,7 +929,7 @@ def convert(schema, config_var, path):
elif schema in (cv.string, cv.string_strict, cv.valid_name):
config_var[S_TYPE] = "string"
elif isinstance(schema, vol.Schema):
elif isinstance(schema, probatio.Schema):
# test: esphome/project
config_var[S_TYPE] = "schema"
config_var["schema"] = convert_config(schema.schema, path + "/s")["schema"]
@@ -1162,9 +1162,11 @@ def convert_keys(converted, schema, path):
"value": str(default_value),
"components": components,
}
elif hasattr(k, "default") and str(k.default) != "...":
elif hasattr(k, "default") and not isinstance(k.default, probatio.Undefined):
default_value = k.default()
if default_value is not None:
if default_value is not None and not isinstance(
default_value, probatio.Undefined
):
result["default"] = str(default_value)
# UI hint from ``cv.Optional`` / ``cv.Required`` — surfaced
+1 -1
View File
@@ -1,5 +1,5 @@
{
"target_module": "esphome.__main__",
"margin_pct": 15,
"cumulative_us": 91000
"cumulative_us": 127000
}
@@ -45,7 +45,12 @@ def test_configuration_errors(set_core_config: SetCoreConfigCallable) -> None:
match=r"string value is None for dictionary value @ data\['lane_bit_rate'\]",
):
CONFIG_SCHEMA(
{"id": "display_id", "model": "custom", "init_sequence": [[0x36, 0x01]]}
{
"id": "display_id",
"model": "custom",
"init_sequence": [[0x36, 0x01]],
"dimensions": {"width": 320, "height": 240},
}
)
with pytest.raises(
+1 -1
View File
@@ -201,7 +201,7 @@ def test_transform_and_init_sequence_errors(
),
pytest.param(
{"model": "wt32-sc01-plus", "brightness": 128},
r"extra keys not allowed @ data\['brightness'\]",
r"not a valid option @ data\['brightness'\]",
id="brightness_not_supported",
),
],
+7 -7
View File
@@ -1460,7 +1460,7 @@ def test_only_with_framework_suggestion_without_docs_path() -> None:
def test_has_at_least_one_key_not_dict() -> None:
with pytest.raises(Invalid, match="expected dictionary"):
with pytest.raises(Invalid, match="expected a dictionary"):
cv.has_at_least_one_key("a", "b")([])
@@ -1475,17 +1475,17 @@ def test_has_at_least_one_key_ok() -> None:
def test_has_exactly_one_key_not_dict() -> None:
with pytest.raises(Invalid, match="expected dictionary"):
with pytest.raises(Invalid, match="expected a dictionary"):
cv.has_exactly_one_key("a", "b")("notdict")
def test_has_exactly_one_key_too_many() -> None:
with pytest.raises(Invalid, match="Cannot specify more than one"):
with pytest.raises(Invalid, match="exactly one of"):
cv.has_exactly_one_key("a", "b")({"a": 1, "b": 2})
def test_has_exactly_one_key_too_few() -> None:
with pytest.raises(Invalid, match="Must contain exactly one"):
with pytest.raises(Invalid, match="exactly one of"):
cv.has_exactly_one_key("a", "b")({"c": 1})
@@ -1495,12 +1495,12 @@ def test_has_exactly_one_key_ok() -> None:
def test_has_at_most_one_key_not_dict() -> None:
with pytest.raises(Invalid, match="expected dictionary"):
with pytest.raises(Invalid, match="expected a dictionary"):
cv.has_at_most_one_key("a", "b")(5)
def test_has_at_most_one_key_too_many() -> None:
with pytest.raises(vol.MultipleInvalid, match="Cannot specify more than one"):
with pytest.raises(vol.MultipleInvalid, match="at most one of"):
cv.has_at_most_one_key("a", "b")({"a": 1, "b": 2})
@@ -1510,7 +1510,7 @@ def test_has_at_most_one_key_ok() -> None:
def test_has_none_or_all_keys_not_dict() -> None:
with pytest.raises(Invalid, match="expected dictionary"):
with pytest.raises(Invalid, match="expected a dictionary"):
cv.has_none_or_all_keys("a", "b")(5)
+51
View File
@@ -0,0 +1,51 @@
"""The voluptuous compatibility shim for third-party (external) components.
External components import ``voluptuous`` directly. ESPHome installs probatio's
shim at package import (``esphome/__init__.py``), so those imports resolve to
probatio without the components needing any change. These tests pin that contract.
"""
import sys
import probatio
import esphome # noqa: F401 (importing installs the shim)
def test_voluptuous_resolves_to_probatio_shim() -> None:
"""``import voluptuous`` resolves to probatio's shim, not a real voluptuous."""
voluptuous = sys.modules.get("voluptuous")
assert voluptuous is not None
assert voluptuous.__name__ == "probatio._vol_shim"
# Submodules dependencies reach into are aliased too.
assert "voluptuous.schema_builder" in sys.modules
assert hasattr(sys.modules["voluptuous.schema_builder"], "_compile_scalar")
def test_external_component_style_schema_validates() -> None:
"""A schema built the way a third-party component would, through voluptuous."""
import voluptuous as vol # noqa: PLC0415 (mimics an external component)
# Markers imported from voluptuous are probatio markers via the shim.
assert issubclass(vol.Required, probatio.Marker)
schema = vol.Schema(
{
vol.Required("name"): str,
vol.Optional("count", default=1): int,
}
)
assert schema({"name": "x"}) == {"name": "x", "count": 1}
def test_shim_extra_key_is_probatio_error() -> None:
"""An extra key raises probatio's MultipleInvalid through the shim."""
import voluptuous as vol # noqa: PLC0415
schema = vol.Schema({vol.Required("name"): str})
try:
schema({"name": "x", "bogus": 1})
except vol.MultipleInvalid as err:
assert isinstance(err, probatio.MultipleInvalid)
else:
raise AssertionError("expected MultipleInvalid for an extra key")
+6 -3
View File
@@ -1,9 +1,9 @@
"""Tests for voluptuous_schema.py."""
import probatio as vol
import pytest
import voluptuous as vol
from esphome.voluptuous_schema import _Schema
from esphome.voluptuous_schema import ExtraKeysInvalid, _Schema
class TestIdKeyDropping:
@@ -38,8 +38,11 @@ class TestIdKeyDropping:
vol.Required("name"): str,
}
)
with pytest.raises(vol.MultipleInvalid, match="extra keys not allowed"):
with pytest.raises(vol.MultipleInvalid) as exc_info:
schema({"name": "test", "unknown_key": "value"})
# The extra key is reported as an ExtraKeysInvalid (probatio carries the
# close-match candidates on it); assert the type, not the exact wording.
assert any(isinstance(err, ExtraKeysInvalid) for err in exc_info.value.errors)
def test_id_key_not_dropped_when_in_schema(self):
"""When 'id' is declared in the schema, it should be validated normally."""