Enable strict typing of date_time (#106868)

* Enable strict typing of date_time

* Fix parse_datetime

* Add test

* Add comments

* Update tests/util/test_dt.py

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

---------

Co-authored-by: G Johansson <goran.johansson@shiftit.se>
This commit is contained in:
Erik Montnemery 2024-01-02 13:57:25 +01:00 committed by GitHub
parent 15cdd42c99
commit 8f9bd75a36
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 57 additions and 15 deletions

View File

@ -373,6 +373,7 @@ homeassistant.components.tibber.*
homeassistant.components.tile.* homeassistant.components.tile.*
homeassistant.components.tilt_ble.* homeassistant.components.tilt_ble.*
homeassistant.components.time.* homeassistant.components.time.*
homeassistant.components.time_date.*
homeassistant.components.todo.* homeassistant.components.todo.*
homeassistant.components.tolo.* homeassistant.components.tolo.*
homeassistant.components.tplink.* homeassistant.components.tplink.*

View File

@ -1,14 +1,14 @@
"""Support for showing the date and the time.""" """Support for showing the date and the time."""
from __future__ import annotations from __future__ import annotations
from datetime import timedelta from datetime import datetime, timedelta
import logging import logging
import voluptuous as vol import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity
from homeassistant.const import CONF_DISPLAY_OPTIONS 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 import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.event import async_track_point_in_utc_time
@ -47,7 +47,7 @@ async def async_setup_platform(
) -> None: ) -> None:
"""Set up the Time and Date sensor.""" """Set up the Time and Date sensor."""
if hass.config.time_zone is None: 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 return False
async_add_entities( async_add_entities(
@ -58,28 +58,28 @@ async def async_setup_platform(
class TimeDateSensor(SensorEntity): class TimeDateSensor(SensorEntity):
"""Implementation of a Time and Date sensor.""" """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.""" """Initialize the sensor."""
self._name = OPTION_TYPES[option_type] self._name = OPTION_TYPES[option_type]
self.type = option_type self.type = option_type
self._state = None self._state: str | None = None
self.hass = hass self.hass = hass
self.unsub = None self.unsub: CALLBACK_TYPE | None = None
self._update_internal_state(dt_util.utcnow()) self._update_internal_state(dt_util.utcnow())
@property @property
def name(self): def name(self) -> str:
"""Return the name of the sensor.""" """Return the name of the sensor."""
return self._name return self._name
@property @property
def native_value(self): def native_value(self) -> str | None:
"""Return the state of the sensor.""" """Return the state of the sensor."""
return self._state return self._state
@property @property
def icon(self): def icon(self) -> str:
"""Icon to use in the frontend, if any.""" """Icon to use in the frontend, if any."""
if "date" in self.type and "time" in self.type: if "date" in self.type and "time" in self.type:
return "mdi:calendar-clock" return "mdi:calendar-clock"
@ -99,7 +99,7 @@ class TimeDateSensor(SensorEntity):
self.unsub() self.unsub()
self.unsub = None self.unsub = None
def get_next_interval(self): def get_next_interval(self) -> datetime:
"""Compute next time an update should occur.""" """Compute next time an update should occur."""
now = dt_util.utcnow() now = dt_util.utcnow()
@ -121,7 +121,7 @@ class TimeDateSensor(SensorEntity):
return next_interval 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 = dt_util.as_local(time_date).strftime(TIME_STR_FORMAT)
time_utc = time_date.strftime(TIME_STR_FORMAT) time_utc = time_date.strftime(TIME_STR_FORMAT)
date = dt_util.as_local(time_date).date().isoformat() date = dt_util.as_local(time_date).date().isoformat()
@ -155,10 +155,12 @@ class TimeDateSensor(SensorEntity):
self._state = f"@{beat:03d}" self._state = f"@{beat:03d}"
elif self.type == "date_time_iso": 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 @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.""" """Get the latest data and update state."""
self._update_internal_state(time_date) self._update_internal_state(time_date)
self.async_write_ha_state() self.async_write_ha_state()

View File

@ -6,7 +6,7 @@ from contextlib import suppress
import datetime as dt import datetime as dt
from functools import partial from functools import partial
import re import re
from typing import Any from typing import Any, Literal, overload
import zoneinfo import zoneinfo
import ciso8601 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. # Copyright (c) Django Software Foundation and individual contributors.
# All rights reserved. # All rights reserved.
# https://github.com/django/django/blob/main/LICENSE # https://github.com/django/django/blob/main/LICENSE
@overload
def parse_datetime(dt_str: str) -> dt.datetime | None: 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. """Parse a string and return a datetime.datetime.
This function supports time zone offsets. When the input contains one, This function supports time zone offsets. When the input contains one,
the output uses a timezone with a fixed offset from UTC. the output uses a timezone with a fixed offset from UTC.
Raises ValueError if the input is well formatted but not a valid datetime. 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): with suppress(ValueError, IndexError):
return ciso8601.parse_datetime(dt_str) 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 not (match := DATETIME_RE.match(dt_str)):
if raise_on_error:
raise ValueError
return None return None
kws: dict[str, Any] = match.groupdict() kws: dict[str, Any] = match.groupdict()
if kws["microsecond"]: if kws["microsecond"]:

View File

@ -3492,6 +3492,16 @@ disallow_untyped_defs = true
warn_return_any = true warn_return_any = true
warn_unreachable = 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.*] [mypy-homeassistant.components.todo.*]
check_untyped_defs = true check_untyped_defs = true
disallow_incomplete_defs = true disallow_incomplete_defs = true

View File

@ -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 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( @pytest.mark.parametrize(
("duration_string", "expected_result"), ("duration_string", "expected_result"),
[ [