diff --git a/.strict-typing b/.strict-typing index 6ab9736b144..87d5a853add 100644 --- a/.strict-typing +++ b/.strict-typing @@ -373,6 +373,7 @@ homeassistant.components.tibber.* homeassistant.components.tile.* homeassistant.components.tilt_ble.* homeassistant.components.time.* +homeassistant.components.time_date.* homeassistant.components.todo.* homeassistant.components.tolo.* homeassistant.components.tplink.* diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index 5646c7a7018..eb0f291ad3f 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -1,14 +1,14 @@ """Support for showing the date and the time.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import logging import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import CONF_DISPLAY_OPTIONS -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time @@ -47,7 +47,7 @@ async def async_setup_platform( ) -> None: """Set up the Time and Date sensor.""" if hass.config.time_zone is None: - _LOGGER.error("Timezone is not set in Home Assistant configuration") + _LOGGER.error("Timezone is not set in Home Assistant configuration") # type: ignore[unreachable] return False async_add_entities( @@ -58,28 +58,28 @@ async def async_setup_platform( class TimeDateSensor(SensorEntity): """Implementation of a Time and Date sensor.""" - def __init__(self, hass, option_type): + def __init__(self, hass: HomeAssistant, option_type: str) -> None: """Initialize the sensor.""" self._name = OPTION_TYPES[option_type] self.type = option_type - self._state = None + self._state: str | None = None self.hass = hass - self.unsub = None + self.unsub: CALLBACK_TYPE | None = None self._update_internal_state(dt_util.utcnow()) @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return self._name @property - def native_value(self): + def native_value(self) -> str | None: """Return the state of the sensor.""" return self._state @property - def icon(self): + def icon(self) -> str: """Icon to use in the frontend, if any.""" if "date" in self.type and "time" in self.type: return "mdi:calendar-clock" @@ -99,7 +99,7 @@ class TimeDateSensor(SensorEntity): self.unsub() self.unsub = None - def get_next_interval(self): + def get_next_interval(self) -> datetime: """Compute next time an update should occur.""" now = dt_util.utcnow() @@ -121,7 +121,7 @@ class TimeDateSensor(SensorEntity): return next_interval - def _update_internal_state(self, time_date): + def _update_internal_state(self, time_date: datetime) -> None: time = dt_util.as_local(time_date).strftime(TIME_STR_FORMAT) time_utc = time_date.strftime(TIME_STR_FORMAT) date = dt_util.as_local(time_date).date().isoformat() @@ -155,10 +155,12 @@ class TimeDateSensor(SensorEntity): self._state = f"@{beat:03d}" elif self.type == "date_time_iso": - self._state = dt_util.parse_datetime(f"{date} {time}").isoformat() + self._state = dt_util.parse_datetime( + f"{date} {time}", raise_on_error=True + ).isoformat() @callback - def point_in_time_listener(self, time_date): + def point_in_time_listener(self, time_date: datetime) -> None: """Get the latest data and update state.""" self._update_internal_state(time_date) self.async_write_ha_state() diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 4859c5c85dd..81237e1eca6 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -6,7 +6,7 @@ from contextlib import suppress import datetime as dt from functools import partial import re -from typing import Any +from typing import Any, Literal, overload import zoneinfo import ciso8601 @@ -177,18 +177,41 @@ def start_of_local_day(dt_or_d: dt.date | dt.datetime | None = None) -> dt.datet # Copyright (c) Django Software Foundation and individual contributors. # All rights reserved. # https://github.com/django/django/blob/main/LICENSE +@overload def parse_datetime(dt_str: str) -> dt.datetime | None: + ... + + +@overload +def parse_datetime(dt_str: str, *, raise_on_error: Literal[True]) -> dt.datetime: + ... + + +@overload +def parse_datetime( + dt_str: str, *, raise_on_error: Literal[False] | bool +) -> dt.datetime | None: + ... + + +def parse_datetime(dt_str: str, *, raise_on_error: bool = False) -> dt.datetime | None: """Parse a string and return a datetime.datetime. This function supports time zone offsets. When the input contains one, the output uses a timezone with a fixed offset from UTC. Raises ValueError if the input is well formatted but not a valid datetime. - Returns None if the input isn't well formatted. + + If the input isn't well formatted, returns None if raise_on_error is False + or raises ValueError if it's True. """ + # First try if the string can be parsed by the fast ciso8601 library with suppress(ValueError, IndexError): return ciso8601.parse_datetime(dt_str) + # ciso8601 failed to parse the string, fall back to regex if not (match := DATETIME_RE.match(dt_str)): + if raise_on_error: + raise ValueError return None kws: dict[str, Any] = match.groupdict() if kws["microsecond"]: diff --git a/mypy.ini b/mypy.ini index 55ec39de449..6f3ca7ce54e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3492,6 +3492,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.time_date.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.todo.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index a973135d831..3b6293d7c17 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -147,6 +147,12 @@ def test_parse_datetime_returns_none_for_incorrect_format() -> None: assert dt_util.parse_datetime("not a datetime string") is None +def test_parse_datetime_raises_for_incorrect_format() -> None: + """Test parse_datetime raises ValueError if raise_on_error is set with an incorrect format.""" + with pytest.raises(ValueError): + dt_util.parse_datetime("not a datetime string", raise_on_error=True) + + @pytest.mark.parametrize( ("duration_string", "expected_result"), [