diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 17234e811aa..4c0c330a191 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -25,7 +25,7 @@ jobs: - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: - python-version: "3.11" + python-version: "3.12" - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 9678831b501..d6ad28dffe4 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -65,7 +65,7 @@ jobs: - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: - python-version: "3.11" + python-version: "3.12" - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 @@ -149,7 +149,7 @@ jobs: - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: - python-version: "3.11" + python-version: "3.12" - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 diff --git a/.github/workflows/ci-memory-impact-comment.yml b/.github/workflows/ci-memory-impact-comment.yml index 4bef082aab0..ac0322e2fa0 100644 --- a/.github/workflows/ci-memory-impact-comment.yml +++ b/.github/workflows/ci-memory-impact-comment.yml @@ -60,7 +60,7 @@ jobs: if: steps.pr.outputs.skip != 'true' uses: ./.github/actions/restore-python with: - python-version: "3.11" + python-version: "3.12" cache-key: ${{ hashFiles('.cache-key') }} - name: Download memory analysis artifacts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72519e421ab..28174ebb6ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ permissions: contents: read # actions/checkout for all jobs; individual jobs add their own scopes when they need to write env: - DEFAULT_PYTHON: "3.11" + DEFAULT_PYTHON: "3.12" PYUPGRADE_TARGET: "--py311-plus" concurrency: @@ -203,7 +203,7 @@ jobs: fail-fast: false matrix: python-version: - - "3.11" + - "3.12" - "3.13" - "3.14" os: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b23b561bd6..20a77b152d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,7 +96,7 @@ jobs: - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: - python-version: "3.11" + python-version: "3.12" - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 diff --git a/esphome/__init__.py b/esphome/__init__.py index e69de29bb2d..8fe69af88b1 100644 --- a/esphome/__init__.py +++ b/esphome/__init__.py @@ -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() diff --git a/esphome/automation.py b/esphome/automation.py index 1689d29c42f..11a0706e948 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -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, ) diff --git a/esphome/components/addressable_light/display.py b/esphome/components/addressable_light/display.py index 929d45121c3..a8fc85ea839 100644 --- a/esphome/components/addressable_light/display.py +++ b/esphome/components/addressable_light/display.py @@ -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), ) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 0f5cd936f54..6a6d4b32a3e 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -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, ) diff --git a/esphome/components/as5600/__init__.py b/esphome/components/as5600/__init__.py index c05e5563762..0efeb814efb 100644 --- a/esphome/components/as5600/__init__.py +++ b/esphome/components/as5600/__init__.py @@ -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(), ) diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index 5031b72cceb..90dc7061a6c 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -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, diff --git a/esphome/components/bang_bang/climate.py b/esphome/components/bang_bang/climate.py index bfdb12278fc..51451454889 100644 --- a/esphome/components/bang_bang/climate.py +++ b/esphome/components/bang_bang/climate.py @@ -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), ) diff --git a/esphome/components/ble_presence/binary_sensor.py b/esphome/components/ble_presence/binary_sensor.py index 3a0f1ade985..e42b05df1cc 100644 --- a/esphome/components/ble_presence/binary_sensor.py +++ b/esphome/components/ble_presence/binary_sensor.py @@ -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, ) diff --git a/esphome/components/ble_rssi/sensor.py b/esphome/components/ble_rssi/sensor.py index c4e767aa21a..db8b40a55da 100644 --- a/esphome/components/ble_rssi/sensor.py +++ b/esphome/components/ble_rssi/sensor.py @@ -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, ) diff --git a/esphome/components/bme680/sensor.py b/esphome/components/bme680/sensor.py index f41aefcec3f..d775a380426 100644 --- a/esphome/components/bme680/sensor.py +++ b/esphome/components/bme680/sensor.py @@ -115,7 +115,7 @@ CONFIG_SCHEMA = ( ), } ), - cv.has_at_least_one_key(CONF_TEMPERATURE, CONF_DURATION), + cv.AtLeastOne(CONF_TEMPERATURE, CONF_DURATION), ), ), } diff --git a/esphome/components/cwww/light.py b/esphome/components/cwww/light.py index 50d84a582db..825c48bd6f4 100644 --- a/esphome/components/cwww/light.py +++ b/esphome/components/cwww/light.py @@ -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, diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 9666c8e5071..40a48d3a02b 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -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), ) diff --git a/esphome/components/dfrobot_sen0395/__init__.py b/esphome/components/dfrobot_sen0395/__init__.py index 943c510279e..e33a951e5fa 100644 --- a/esphome/components/dfrobot_sen0395/__init__.py +++ b/esphome/components/dfrobot_sen0395/__init__.py @@ -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, diff --git a/esphome/components/duty_time/sensor.py b/esphome/components/duty_time/sensor.py index 456859f8e49..c4f76a1384a 100644 --- a/esphome/components/duty_time/sensor.py +++ b/esphome/components/duty_time/sensor.py @@ -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), ) diff --git a/esphome/components/emc2101/__init__.py b/esphome/components/emc2101/__init__.py index 323195e99af..9a865050b2d 100644 --- a/esphome/components/emc2101/__init__.py +++ b/esphome/components/emc2101/__init__.py @@ -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), ) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index a5528da6727..4a2323ee596 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -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), ) diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index c3b35a8279b..684a9341a34 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -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), ) diff --git a/esphome/components/esp32_rmt_led_strip/light.py b/esphome/components/esp32_rmt_led_strip/light.py index 1c6943b0037..3c5c2422cdf 100644 --- a/esphome/components/esp32_rmt_led_strip/light.py +++ b/esphome/components/esp32_rmt_led_strip/light.py @@ -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), ) diff --git a/esphome/components/esp32_touch/__init__.py b/esphome/components/esp32_touch/__init__.py index 10ad339b127..e49f22eca57 100644 --- a/esphome/components/esp32_touch/__init__.py +++ b/esphome/components/esp32_touch/__init__.py @@ -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, diff --git a/esphome/components/feedback/cover.py b/esphome/components/feedback/cover.py index 856818280f1..055c71bbace 100644 --- a/esphome/components/feedback/cover.py +++ b/esphome/components/feedback/cover.py @@ -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, ) diff --git a/esphome/components/hdc2080/sensor.py b/esphome/components/hdc2080/sensor.py index 777fc51cbaf..245affcc56f 100644 --- a/esphome/components/hdc2080/sensor.py +++ b/esphome/components/hdc2080/sensor.py @@ -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)) ) diff --git a/esphome/components/http_request/ota/__init__.py b/esphome/components/http_request/ota/__init__.py index 1bb54599dcc..0f4d6ec4a2b 100644 --- a/esphome/components/http_request/ota/__init__.py +++ b/esphome/components/http_request/ota/__init__.py @@ -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), ) diff --git a/esphome/components/ili9xxx/display.py b/esphome/components/ili9xxx/display.py index b1d332c1e59..d5a736f09f4 100644 --- a/esphome/components/ili9xxx/display.py +++ b/esphome/components/ili9xxx/display.py @@ -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, ) diff --git a/esphome/components/inkplate/display.py b/esphome/components/inkplate/display.py index 47c8c898e54..57530aecf21 100644 --- a/esphome/components/inkplate/display.py +++ b/esphome/components/inkplate/display.py @@ -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, ) diff --git a/esphome/components/ir_rf_proxy/infrared.py b/esphome/components/ir_rf_proxy/infrared.py index 3218889721c..a2a48d8a9aa 100644 --- a/esphome/components/ir_rf_proxy/infrared.py +++ b/esphome/components/ir_rf_proxy/infrared.py @@ -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), ) diff --git a/esphome/components/ir_rf_proxy/radio_frequency.py b/esphome/components/ir_rf_proxy/radio_frequency.py index a243909837f..a2585cdf78d 100644 --- a/esphome/components/ir_rf_proxy/radio_frequency.py +++ b/esphome/components/ir_rf_proxy/radio_frequency.py @@ -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), ) diff --git a/esphome/components/key_collector/__init__.py b/esphome/components/key_collector/__init__.py index 1f4519df2d0..3b816016f7c 100644 --- a/esphome/components/key_collector/__init__.py +++ b/esphome/components/key_collector/__init__.py @@ -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), ) diff --git a/esphome/components/light/effects.py b/esphome/components/light/effects.py index 3ae15f9ee5d..68ca1d8f92f 100644 --- a/esphome/components/light/effects.py +++ b/esphome/components/light/effects.py @@ -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, diff --git a/esphome/components/ltr390/sensor.py b/esphome/components/ltr390/sensor.py index 37fceaf9844..121cb9d660b 100644 --- a/esphome/components/ltr390/sensor.py +++ b/esphome/components/ltr390/sensor.py @@ -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 = { diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 9137412abe5..699e2bdb6b0 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -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, ) diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index 166e88f382a..17219796aad 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -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 diff --git a/esphome/components/lvgl/widgets/tabview.py b/esphome/components/lvgl/widgets/tabview.py index ee252ecf0b2..8388a57e009 100644 --- a/esphome/components/lvgl/widgets/tabview.py +++ b/esphome/components/lvgl/widgets/tabview.py @@ -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): diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 1d6c8277e8e..4f5b50cbea3 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -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( diff --git a/esphome/components/nextion/binary_sensor/__init__.py b/esphome/components/nextion/binary_sensor/__init__.py index 29f5bdaea78..c333aae782b 100644 --- a/esphome/components/nextion/binary_sensor/__init__.py +++ b/esphome/components/nextion/binary_sensor/__init__.py @@ -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, diff --git a/esphome/components/nextion/sensor/__init__.py b/esphome/components/nextion/sensor/__init__.py index 61cb42e62cb..2acc1913fdd 100644 --- a/esphome/components/nextion/sensor/__init__.py +++ b/esphome/components/nextion/sensor/__init__.py @@ -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, ) diff --git a/esphome/components/nextion/switch/__init__.py b/esphome/components/nextion/switch/__init__.py index 29749ecab02..75ca6ba2364 100644 --- a/esphome/components/nextion/switch/__init__.py +++ b/esphome/components/nextion/switch/__init__.py @@ -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), ) diff --git a/esphome/components/nfc/binary_sensor/__init__.py b/esphome/components/nfc/binary_sensor/__init__.py index 47cf0145502..ce84f29a75f 100644 --- a/esphome/components/nfc/binary_sensor/__init__.py +++ b/esphome/components/nfc/binary_sensor/__init__.py @@ -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), ) diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index ee2d53c65a4..6df655bb0d3 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -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), ) diff --git a/esphome/components/opentherm/validate.py b/esphome/components/opentherm/validate.py index 998bcde57ff..032cbaeac56 100644 --- a/esphome/components/opentherm/validate.py +++ b/esphome/components/opentherm/validate.py @@ -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)} diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index b54fe2b2180..a582869e8ee 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -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, diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 44a1ebf36e7..f92adc90a73 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -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, ) diff --git a/esphome/components/packet_transport/__init__.py b/esphome/components/packet_transport/__init__.py index 4293dffb151..444e5ed965e 100644 --- a/esphome/components/packet_transport/__init__.py +++ b/esphome/components/packet_transport/__init__.py @@ -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, ) diff --git a/esphome/components/pcd8544/display.py b/esphome/components/pcd8544/display.py index 2f6dcc56ed3..432045e8d8a 100644 --- a/esphome/components/pcd8544/display.py +++ b/esphome/components/pcd8544/display.py @@ -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( diff --git a/esphome/components/pid/climate.py b/esphome/components/pid/climate.py index 3e4ff754c95..dfd1fef9361 100644 --- a/esphome/components/pid/climate.py +++ b/esphome/components/pid/climate.py @@ -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), ) diff --git a/esphome/components/prometheus/__init__.py b/esphome/components/prometheus/__init__.py index cc1541ce804..76396e43137 100644 --- a/esphome/components/prometheus/__init__.py +++ b/esphome/components/prometheus/__init__.py @@ -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( diff --git a/esphome/components/rgbww/light.py b/esphome/components/rgbww/light.py index 882ab0cdbcd..58d2674b3f9 100644 --- a/esphome/components/rgbww/light.py +++ b/esphome/components/rgbww/light.py @@ -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, diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index e76ce6def88..65e6657e3d4 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -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, ) diff --git a/esphome/components/rp2040_pio_led_strip/light.py b/esphome/components/rp2040_pio_led_strip/light.py index 274f059bd5c..ca5f1a15d3f 100644 --- a/esphome/components/rp2040_pio_led_strip/light.py +++ b/esphome/components/rp2040_pio_led_strip/light.py @@ -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), ) diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index 4880f9ac41a..90bfa63dea4 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -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), ) diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index f561c030a49..90f14582279 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -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]) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 5a2ebf03c0d..eec6ecde61b 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -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), ) diff --git a/esphome/components/speaker_source/media_player.py b/esphome/components/speaker_source/media_player.py index b6653fe5433..db6c176789a 100644 --- a/esphome/components/speaker_source/media_player.py +++ b/esphome/components/speaker_source/media_player.py @@ -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, ) diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index d1961cec59c..8f634ae3f6c 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -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]), ) diff --git a/esphome/components/ssd1306_i2c/display.py b/esphome/components/ssd1306_i2c/display.py index 2ac0093ef12..0cef53b1f36 100644 --- a/esphome/components/ssd1306_i2c/display.py +++ b/esphome/components/ssd1306_i2c/display.py @@ -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, ) diff --git a/esphome/components/ssd1306_spi/display.py b/esphome/components/ssd1306_spi/display.py index 26953b4f397..016af7ddef1 100644 --- a/esphome/components/ssd1306_spi/display.py +++ b/esphome/components/ssd1306_spi/display.py @@ -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, ) diff --git a/esphome/components/ssd1322_spi/display.py b/esphome/components/ssd1322_spi/display.py index 3d01caf8742..1db004374f4 100644 --- a/esphome/components/ssd1322_spi/display.py +++ b/esphome/components/ssd1322_spi/display.py @@ -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( diff --git a/esphome/components/ssd1325_spi/display.py b/esphome/components/ssd1325_spi/display.py index dbb9a14ac2b..14427675557 100644 --- a/esphome/components/ssd1325_spi/display.py +++ b/esphome/components/ssd1325_spi/display.py @@ -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( diff --git a/esphome/components/ssd1327_i2c/display.py b/esphome/components/ssd1327_i2c/display.py index 95de1c29799..9b65c539fef 100644 --- a/esphome/components/ssd1327_i2c/display.py +++ b/esphome/components/ssd1327_i2c/display.py @@ -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), ) diff --git a/esphome/components/ssd1327_spi/display.py b/esphome/components/ssd1327_spi/display.py index f052764a91a..4bbb23b4a5b 100644 --- a/esphome/components/ssd1327_spi/display.py +++ b/esphome/components/ssd1327_spi/display.py @@ -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( diff --git a/esphome/components/ssd1331_spi/display.py b/esphome/components/ssd1331_spi/display.py index c16780302f4..dbb6b19a827 100644 --- a/esphome/components/ssd1331_spi/display.py +++ b/esphome/components/ssd1331_spi/display.py @@ -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( diff --git a/esphome/components/ssd1351_spi/display.py b/esphome/components/ssd1351_spi/display.py index 2a6e984029b..00ac043524f 100644 --- a/esphome/components/ssd1351_spi/display.py +++ b/esphome/components/ssd1351_spi/display.py @@ -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( diff --git a/esphome/components/st7567_i2c/display.py b/esphome/components/st7567_i2c/display.py index bd62b3b3824..4e4fd7699cc 100644 --- a/esphome/components/st7567_i2c/display.py +++ b/esphome/components/st7567_i2c/display.py @@ -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), ) diff --git a/esphome/components/st7567_spi/display.py b/esphome/components/st7567_spi/display.py index 02cd2c105cb..ee35868ae59 100644 --- a/esphome/components/st7567_spi/display.py +++ b/esphome/components/st7567_spi/display.py @@ -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( diff --git a/esphome/components/st7735/display.py b/esphome/components/st7735/display.py index 766370c21f2..a957fc3f01d 100644 --- a/esphome/components/st7735/display.py +++ b/esphome/components/st7735/display.py @@ -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), ) diff --git a/esphome/components/status_led/light/__init__.py b/esphome/components/status_led/light/__init__.py index f8d03a3b4f6..f6a97592b41 100644 --- a/esphome/components/status_led/light/__init__.py +++ b/esphome/components/status_led/light/__init__.py @@ -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), ) diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index d609e22ac26..b394b519364 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -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, diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 35fad0a4509..a377738c789 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -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: diff --git a/esphome/components/tuya/climate/__init__.py b/esphome/components/tuya/climate/__init__.py index cefe5d5a4b4..786291f03fa 100644 --- a/esphome/components/tuya/climate/__init__.py +++ b/esphome/components/tuya/climate/__init__.py @@ -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, ) diff --git a/esphome/components/tuya/fan/__init__.py b/esphome/components/tuya/fan/__init__.py index de95888b6b8..f50440093ca 100644 --- a/esphome/components/tuya/fan/__init__.py +++ b/esphome/components/tuya/fan/__init__.py @@ -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), ) diff --git a/esphome/components/tuya/light/__init__.py b/esphome/components/tuya/light/__init__.py index bf2d3daf981..ee41c73ac13 100644 --- a/esphome/components/tuya/light/__init__.py +++ b/esphome/components/tuya/light/__init__.py @@ -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, diff --git a/esphome/components/tuya/select/__init__.py b/esphome/components/tuya/select/__init__.py index 9f2b6f1e12a..a3ad63b4b01 100644 --- a/esphome/components/tuya/select/__init__.py +++ b/esphome/components/tuya/select/__init__.py @@ -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), ) diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 4ea32e26a31..718910ca4d9 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -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, ) diff --git a/esphome/components/waveshare_epaper/display.py b/esphome/components/waveshare_epaper/display.py index 7ecc3b4a874..f415bf4638a 100644 --- a/esphome/components/waveshare_epaper/display.py +++ b/esphome/components/waveshare_epaper/display.py @@ -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( diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 512fd63e125..4e3860240b8 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -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( diff --git a/esphome/config.py b/esphome/config.py index 33e687137f2..cfe2c52e724 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -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 @@ -184,7 +184,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.", @@ -193,7 +193,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]], @@ -252,7 +252,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') @@ -270,8 +270,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 @@ -281,7 +281,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, @@ -307,12 +307,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)) @@ -329,7 +329,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) @@ -1112,7 +1112,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 @@ -1122,7 +1122,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 @@ -1145,7 +1145,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 @@ -1154,7 +1154,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 @@ -1164,7 +1164,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 @@ -1200,7 +1200,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 @@ -1268,7 +1268,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) @@ -1280,7 +1280,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: @@ -1326,7 +1326,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 diff --git a/esphome/config_validation.py b/esphome/config_validation.py index b77e22a6fb4..a2a8b0793b2 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -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 diff --git a/esphome/voluptuous_schema.py b/esphome/voluptuous_schema.py index 904963ba4e8..145c10c0e9c 100644 --- a/esphome/voluptuous_schema.py +++ b/esphome/voluptuous_schema.py @@ -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) diff --git a/esphome/vscode.py b/esphome/vscode.py index f404f02f003..231d049ccfd 100644 --- a/esphome/vscode.py +++ b/esphome/vscode.py @@ -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) ) diff --git a/esphome/wizard.py b/esphome/wizard.py index f83342cc6a5..09b609d6f9c 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -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, diff --git a/pyproject.toml b/pyproject.toml index a2923778356..e9595785539 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ "Topic :: Home Automation", ] -requires-python = ">=3.11.0,<3.15" +requires-python = ">=3.12.0,<3.15" dynamic = ["dependencies", "optional-dependencies", "version"] @@ -62,7 +62,7 @@ addopts = [ ] [tool.pylint.MAIN] -py-version = "3.11" +py-version = "3.12" ignore = [ "api_pb2.py", ] @@ -106,7 +106,7 @@ expected-line-ending-format = "LF" [tool.ruff] required-version = ">=0.5.0" -target-version = "py311" +target-version = "py312" exclude = ['generated'] [tool.ruff.lint] diff --git a/requirements.txt b/requirements.txt index 956f3633dcd..d9fb83bcc58 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ 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 diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 974957245a7..95eebdd7c54 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -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"] diff --git a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py index c14abdb4fd3..e119c6b4ab0 100644 --- a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py +++ b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py @@ -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( diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index dbd8e15702e..783ed2a69da 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -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", ), ], diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index ea3a4ecb532..2863f8aedda 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -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) diff --git a/tests/unit_tests/test_probatio_compat.py b/tests/unit_tests/test_probatio_compat.py new file mode 100644 index 00000000000..ff63c42ddbc --- /dev/null +++ b/tests/unit_tests/test_probatio_compat.py @@ -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") diff --git a/tests/unit_tests/test_voluptuous_schema.py b/tests/unit_tests/test_voluptuous_schema.py index 21c7decede1..39ac880f6f9 100644 --- a/tests/unit_tests/test_voluptuous_schema.py +++ b/tests/unit_tests/test_voluptuous_schema.py @@ -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."""