Compare commits

...

6 Commits

Author SHA1 Message Date
Martin Hjelmare 6ff21b70f2 Disable instead of fixing in lg_thinq 2026-06-08 13:56:14 +02:00
Martin Hjelmare d01903cd59 Fix lg_thinq 2026-06-08 13:10:54 +02:00
Martin Hjelmare 0d17f3062c Add tests for dt_util.UTC 2026-06-08 11:59:51 +02:00
Martin Hjelmare c706c83337 Update readme 2026-06-08 11:49:54 +02:00
Martin Hjelmare 20f30f76d1 Deduplicate checker id 2026-06-08 11:45:50 +02:00
Martin Hjelmare 966b89cc14 Add pylint enforce dt.now 2026-06-08 11:27:59 +02:00
12 changed files with 466 additions and 121 deletions
@@ -735,6 +735,7 @@ class ThinQSensorEntity(ThinQEntity, SensorEntity):
value = self.data.value
if isinstance(value, time):
# pylint: disable-next=home-assistant-enforce-now
local_now = datetime.now(
tz=dt_util.get_time_zone(self.coordinator.hass.config.time_zone)
)
@@ -847,6 +848,7 @@ class ThinQEnergySensorEntity(ThinQEntity, SensorEntity):
async def _async_update_and_schedule(self) -> None:
"""Update the state of the sensor."""
# pylint: disable-next=home-assistant-enforce-now
local_now = datetime.now(
dt_util.get_time_zone(self.coordinator.hass.config.time_zone)
)
@@ -264,9 +264,9 @@ class MetOfficeWeather(
self.forecast_coordinators["daily"],
)
timesteps = coordinator.data.timesteps
start_datetime = datetime.now(tz=timesteps[0]["time"].tzinfo).replace(
hour=0, minute=0, second=0, microsecond=0
)
start_datetime = datetime.now( # pylint: disable=home-assistant-enforce-now
tz=timesteps[0]["time"].tzinfo
).replace(hour=0, minute=0, second=0, microsecond=0)
return [
_build_daily_forecast_data(timestep)
for timestep in timesteps
@@ -282,9 +282,9 @@ class MetOfficeWeather(
)
timesteps = coordinator.data.timesteps
start_datetime = datetime.now(tz=timesteps[0]["time"].tzinfo).replace(
minute=0, second=0, microsecond=0
)
start_datetime = datetime.now( # pylint: disable=home-assistant-enforce-now
tz=timesteps[0]["time"].tzinfo
).replace(minute=0, second=0, microsecond=0)
return [
_build_hourly_forecast_data(timestep)
for timestep in timesteps
@@ -299,9 +299,9 @@ class MetOfficeWeather(
self.forecast_coordinators["twice_daily"],
)
timesteps = coordinator.data.timesteps
start_datetime = datetime.now(tz=timesteps[0]["time"].tzinfo).replace(
hour=0, minute=0, second=0, microsecond=0
)
start_datetime = datetime.now( # pylint: disable=home-assistant-enforce-now
tz=timesteps[0]["time"].tzinfo
).replace(hour=0, minute=0, second=0, microsecond=0)
return [
_build_twice_daily_forecast_data(timestep)
for timestep in timesteps
@@ -95,7 +95,9 @@ class PowerfoxReportDataUpdateCoordinator(PowerfoxBaseCoordinator[DeviceReport])
async def _async_fetch_data(self) -> DeviceReport:
"""Fetch report data from the Powerfox API."""
local_now = datetime.now(tz=dt_util.get_time_zone(self.hass.config.time_zone))
local_now = datetime.now( # pylint: disable=home-assistant-enforce-now
tz=dt_util.get_time_zone(self.hass.config.time_zone)
)
return await self.client.report(
device_id=self.device.id,
year=local_now.year,
+3 -3
View File
@@ -74,9 +74,9 @@ def _utc_minutes_to_time(utc_minutes: int, timezone: tzinfo) -> time:
def _time_to_utc_minutes(t: time, timezone: tzinfo) -> int:
try:
zoned_time = datetime.now(timezone).replace(
hour=t.hour, minute=t.minute, second=0, microsecond=0
)
zoned_time = datetime.now( # pylint: disable=home-assistant-enforce-now
timezone
).replace(hour=t.hour, minute=t.minute, second=0, microsecond=0)
except ValueError as exc:
raise HomeAssistantError from exc
utc_time = zoned_time.astimezone(UTC).time()
+3 -1
View File
@@ -155,7 +155,9 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Tado resets somewhere between 12:00 and 13:00, Berlin time
# So let's pretend we're in Berlin...
reset_time = datetime.now(ZoneInfo("Europe/Berlin"))
reset_time = datetime.now( # pylint: disable=home-assistant-enforce-now
ZoneInfo("Europe/Berlin")
)
today_reset = datetime.combine(
reset_time.date(),
+17
View File
@@ -105,6 +105,7 @@ Every check has a code following the
| `W7421` | [`home-assistant-tests-direct-async-migrate-entry`](#w7421-home-assistant-tests-direct-async-migrate-entry) | Tests should not call an integration's `async_migrate_entry` directly |
| `W7422` | [`home-assistant-tests-direct-async-setup`](#w7422-home-assistant-tests-direct-async-setup) | Tests should not call an integration's `async_setup` directly |
| `C7414` | [`home-assistant-enforce-utcnow`](#c7414-home-assistant-enforce-utcnow) | Use `homeassistant.util.dt.utcnow` instead of `datetime.now(UTC)` |
| `C7425` | [`home-assistant-enforce-now`](#c7425-home-assistant-enforce-now) | Use `homeassistant.util.dt.now` instead of `datetime.now(<tz>)` |
| `W7423` | [`home-assistant-missing-entity-unique-id`](#w7423-home-assistant-missing-entity-unique-id) | Entity class does not statically guarantee a non-None unique id |
| `W7424` | [`home-assistant-entity-unique-id-static`](#w7424-home-assistant-entity-unique-id-static) | Entity class sets `_attr_unique_id` to a static string at class level |
| `C7412` | [`home-assistant-entity-description-redundant-default`](#c7412-home-assistant-entity-description-redundant-default) | Setting an EntityDescription field to its default value is redundant |
@@ -458,6 +459,22 @@ lookup of `UTC` on every call, while keeping the codebase consistent in
how the current UTC time is obtained.
## `home_assistant_enforce_now` checker
Ensures the Home Assistant helper is used to get the current local time.
### `C7425`: `home-assistant-enforce-now`
Use `homeassistant.util.dt.now()` instead of `datetime.datetime.now(<tz>)`
when called with a non-UTC time zone to create an aware `datetime`. The
helper returns an aware `datetime` in the given time zone (defaulting to
`DEFAULT_TIME_ZONE`), keeping the codebase consistent in how the current
local time is obtained. The UTC case (`datetime.now(UTC)`) is handled by
the [`home-assistant-enforce-utcnow`](#c7414-home-assistant-enforce-utcnow)
checker, and `datetime.now()` with no argument is not flagged since it
returns a naive local `datetime`.
## `home_assistant_entity_unique_id` checker
Quality-scale-gated checker for the [`entity-unique-id`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-unique-id)
@@ -0,0 +1,42 @@
"""Checker that enforces ``homeassistant.util.dt.now`` over ``datetime.now(tz)``.
Home Assistant exposes ``homeassistant.util.dt.now`` -- a helper that returns an
aware ``datetime`` in the given time zone (defaulting to ``DEFAULT_TIME_ZONE``).
Calling ``datetime.datetime.now(tz)`` directly with a time zone argument does the
same thing but bypasses the helper. Using ``dt_util.now`` keeps the codebase
consistent in how the current local time is obtained.
The UTC special case (``datetime.now(UTC)``) is intentionally left to the
``home-assistant-enforce-utcnow`` checker, which steers it to the faster
``dt_util.utcnow`` partial. ``datetime.now()`` with no argument returns a naive
local ``datetime`` and is therefore not equivalent to ``dt_util.now()``; it is not
flagged.
"""
from pylint.lint import PyLinter
from pylint_home_assistant.helpers.datetime_now import HassEnforceDatetimeNowChecker
class HassEnforceNowChecker(HassEnforceDatetimeNowChecker):
"""Checker that flags ``datetime.now(tz)`` calls with a non-UTC time zone."""
name = "home_assistant_enforce_now"
msgs = {
"C7425": (
"Use `homeassistant.util.dt.now()` instead of `datetime.now(<tz>)`",
"home-assistant-enforce-now",
"Used when ``datetime.datetime.now(<tz>)`` is called with a non-UTC "
"time zone to create an aware ``datetime``. Use the "
"``homeassistant.util.dt.now`` helper instead. The UTC case is "
"handled by the ``home-assistant-enforce-utcnow`` checker.",
),
}
message = "home-assistant-enforce-now"
flags_utc = False
def register(linter: PyLinter) -> None:
"""Register the checker."""
linter.register_checker(HassEnforceNowChecker(linter))
@@ -6,44 +6,15 @@ helper avoids the per-call global lookup of ``UTC`` and keeps the codebase
consistent in how the current UTC time is obtained.
"""
from astroid import nodes
from pylint.checkers import BaseChecker
from pylint.lint import PyLinter
# ``homeassistant.util.dt`` defines ``utcnow`` itself, so it must call
# ``datetime.datetime.now(UTC)`` directly.
_SKIP_MODULES = frozenset({"homeassistant.util.dt"})
from pylint_home_assistant.helpers.datetime_now import HassEnforceDatetimeNowChecker
def _attribute_path(node: nodes.NodeNG) -> tuple[str, ...] | None:
"""Return the dotted-name path of an Attribute/Name chain, or ``None``."""
parts: list[str] = []
while isinstance(node, nodes.Attribute):
parts.append(node.attrname)
node = node.expr
if not isinstance(node, nodes.Name):
return None
parts.append(node.name)
return tuple(reversed(parts))
def _is_zoneinfo_utc(node: nodes.NodeNG) -> bool:
"""Return True if *node* is ``ZoneInfo("UTC")`` or ``*.ZoneInfo("UTC")``."""
match node:
case nodes.Call(
func=nodes.Name(name="ZoneInfo") | nodes.Attribute(attrname="ZoneInfo"),
args=[nodes.Const(value="UTC")],
keywords=[],
):
return True
return False
class HassEnforceUtcnowChecker(BaseChecker):
class HassEnforceUtcnowChecker(HassEnforceDatetimeNowChecker):
"""Checker that flags ``datetime.now(UTC)`` calls."""
name = "home_assistant_enforce_utcnow"
priority = -1
msgs = {
"C7414": (
"Use `homeassistant.util.dt.utcnow()` instead of `datetime.now(UTC)`",
@@ -54,81 +25,9 @@ class HassEnforceUtcnowChecker(BaseChecker):
"and avoids the global lookup of ``UTC`` on every call.",
),
}
options = ()
_enabled: bool
_datetime_class_paths: set[tuple[str, ...]]
_utc_paths: set[tuple[str, ...]]
def visit_module(self, node: nodes.Module) -> None:
"""Collect ``datetime`` bindings introduced by module-level imports."""
self._datetime_class_paths = set()
self._utc_paths = set()
self._enabled = node.name not in _SKIP_MODULES
if not self._enabled:
return
for stmt in node.body:
match stmt:
case nodes.ImportFrom(modname="datetime", names=names):
for name, alias in names:
local = alias or name
match name:
case "datetime":
self._datetime_class_paths.add((local,))
case "UTC":
self._utc_paths.add((local,))
case "timezone":
self._utc_paths.add((local, "utc"))
case nodes.ImportFrom(modname="homeassistant.util", names=names):
# ``homeassistant.util.dt`` re-exports ``UTC`` from
# ``datetime``, so ``dt_util.UTC`` must be flagged too.
for name, alias in names:
if name == "dt":
local = alias or name
self._utc_paths.add((local, "UTC"))
case nodes.ImportFrom(modname="homeassistant.util.dt", names=names):
for name, alias in names:
if name == "UTC":
self._utc_paths.add((alias or name,))
case nodes.Import(names=names):
for name, alias in names:
match name:
case "datetime":
local = alias or name
self._datetime_class_paths.add((local, "datetime"))
self._utc_paths.add((local, "UTC"))
self._utc_paths.add((local, "timezone", "utc"))
case "homeassistant.util.dt" if alias:
self._utc_paths.add((alias, "UTC"))
def visit_call(self, node: nodes.Call) -> None:
"""Check for ``datetime.now(UTC)`` calls."""
if not self._enabled:
return
match node:
case nodes.Call(
func=nodes.Attribute(attrname="now", expr=expr),
args=[arg],
keywords=[],
):
pass
case nodes.Call(
func=nodes.Attribute(attrname="now", expr=expr),
args=[],
keywords=[nodes.Keyword(arg="tz", value=arg)],
):
pass
case _:
return
if _attribute_path(expr) not in self._datetime_class_paths:
return
if _attribute_path(arg) not in self._utc_paths and not _is_zoneinfo_utc(arg):
return
self.add_message("home-assistant-enforce-utcnow", node=node)
message = "home-assistant-enforce-utcnow"
flags_utc = True
def register(linter: PyLinter) -> None:
@@ -0,0 +1,130 @@
"""Shared logic for the ``datetime.now`` enforcement checkers.
Both the ``home-assistant-enforce-now`` and ``home-assistant-enforce-utcnow``
checkers look for ``datetime.datetime.now(<tz>)`` calls and differ only in which
time zone argument they care about. The common detection lives here; each checker
module just declares its message and whether it fires on the UTC case.
"""
from astroid import nodes
from pylint.checkers import BaseChecker
# ``homeassistant.util.dt`` defines ``now``/``utcnow`` itself, so it must call
# ``datetime.datetime.now(...)`` directly.
SKIP_MODULES = frozenset({"homeassistant.util.dt"})
def attribute_path(node: nodes.NodeNG) -> tuple[str, ...] | None:
"""Return the dotted-name path of an Attribute/Name chain, or ``None``."""
parts: list[str] = []
while isinstance(node, nodes.Attribute):
parts.append(node.attrname)
node = node.expr
if not isinstance(node, nodes.Name):
return None
parts.append(node.name)
return tuple(reversed(parts))
def is_zoneinfo_utc(node: nodes.NodeNG) -> bool:
"""Return True if *node* is ``ZoneInfo("UTC")`` or ``*.ZoneInfo("UTC")``."""
match node:
case nodes.Call(
func=nodes.Name(name="ZoneInfo") | nodes.Attribute(attrname="ZoneInfo"),
args=[nodes.Const(value="UTC")],
keywords=[],
):
return True
return False
class HassEnforceDatetimeNowChecker(BaseChecker):
"""Base checker for ``datetime.datetime.now(<tz>)`` calls.
Subclasses must define ``name`` and ``msgs`` and set:
- ``message``: the message symbol to emit.
- ``flags_utc``: ``True`` to fire on the UTC case, ``False`` to fire on every
other (non-UTC) time zone.
"""
priority = -1
options = ()
message: str
flags_utc: bool
_enabled: bool
_datetime_class_paths: set[tuple[str, ...]]
_utc_paths: set[tuple[str, ...]]
def visit_module(self, node: nodes.Module) -> None:
"""Collect ``datetime`` bindings introduced by module-level imports."""
self._datetime_class_paths = set()
self._utc_paths = set()
self._enabled = node.name not in SKIP_MODULES
if not self._enabled:
return
for stmt in node.body:
match stmt:
case nodes.ImportFrom(modname="datetime", names=names):
for name, alias in names:
local = alias or name
match name:
case "datetime":
self._datetime_class_paths.add((local,))
case "UTC":
self._utc_paths.add((local,))
case "timezone":
self._utc_paths.add((local, "utc"))
case nodes.ImportFrom(modname="homeassistant.util", names=names):
# ``homeassistant.util.dt`` re-exports ``UTC`` from
# ``datetime``, so ``dt_util.UTC`` must be flagged too.
for name, alias in names:
if name == "dt":
local = alias or name
self._utc_paths.add((local, "UTC"))
case nodes.ImportFrom(modname="homeassistant.util.dt", names=names):
for name, alias in names:
if name == "UTC":
self._utc_paths.add((alias or name,))
case nodes.Import(names=names):
for name, alias in names:
match name:
case "datetime":
local = alias or name
self._datetime_class_paths.add((local, "datetime"))
self._utc_paths.add((local, "UTC"))
self._utc_paths.add((local, "timezone", "utc"))
case "homeassistant.util.dt" if alias:
self._utc_paths.add((alias, "UTC"))
def visit_call(self, node: nodes.Call) -> None:
"""Check for ``datetime.now(<tz>)`` calls matching the configured case."""
if not self._enabled:
return
match node:
case nodes.Call(
func=nodes.Attribute(attrname="now", expr=expr),
args=[arg],
keywords=[],
):
pass
case nodes.Call(
func=nodes.Attribute(attrname="now", expr=expr),
args=[],
keywords=[nodes.Keyword(arg="tz", value=arg)],
):
pass
case _:
return
if attribute_path(expr) not in self._datetime_class_paths:
return
is_utc = attribute_path(arg) in self._utc_paths or is_zoneinfo_utc(arg)
if is_utc is not self.flags_utc:
return
self.add_message(self.message, node=node)
+6 -2
View File
@@ -537,7 +537,9 @@ async def test_add_event_date_time(
mock_events_list({})
assert await component_setup()
start_datetime = datetime.datetime.now(tz=zoneinfo.ZoneInfo("America/Regina"))
start_datetime = datetime.datetime.now( # pylint: disable=home-assistant-enforce-now
tz=zoneinfo.ZoneInfo("America/Regina")
)
delta = datetime.timedelta(days=3, hours=3)
end_datetime = start_datetime + delta
@@ -600,7 +602,9 @@ async def test_unsupported_create_event(
mock_events_list({})
assert await component_setup()
start_datetime = datetime.datetime.now(tz=zoneinfo.ZoneInfo("America/Regina"))
start_datetime = datetime.datetime.now( # pylint: disable=home-assistant-enforce-now
tz=zoneinfo.ZoneInfo("America/Regina")
)
delta = datetime.timedelta(days=3, hours=3)
end_datetime = start_datetime + delta
entity_id = "calendar.backyard_light"
+9
View File
@@ -17,6 +17,7 @@ from pylint_home_assistant.checkers.greek_micro_char import (
HassEnforceGreekMicroCharChecker,
)
from pylint_home_assistant.checkers.imports import HassImportsFormatChecker
from pylint_home_assistant.checkers.now import HassEnforceNowChecker
from pylint_home_assistant.checkers.runtime_data import HassEnforceRuntimeDataChecker
from pylint_home_assistant.checkers.sorted_platforms import (
HassEnforceSortedPlatformsChecker,
@@ -131,6 +132,14 @@ def enforce_greek_micro_char_checker_fixture(linter: UnittestLinter) -> BaseChec
return enforce_greek_micro_char_checker
@pytest.fixture(name="enforce_now_checker")
def enforce_now_checker_fixture(linter: UnittestLinter) -> BaseChecker:
"""Fixture to provide a now checker."""
enforce_now_checker = HassEnforceNowChecker(linter)
enforce_now_checker.module = "homeassistant.components.pylint_test"
return enforce_now_checker
@pytest.fixture(name="enforce_utcnow_checker")
def enforce_utcnow_checker_fixture(linter: UnittestLinter) -> BaseChecker:
"""Fixture to provide a utcnow checker."""
+238
View File
@@ -0,0 +1,238 @@
"""Tests for the home-assistant-enforce-now checker."""
import astroid
from pylint.checkers import BaseChecker
from pylint.testutils.unittest_linter import UnittestLinter
from pylint.utils.ast_walker import ASTWalker
import pytest
from . import assert_no_messages
@pytest.mark.parametrize(
"code",
[
pytest.param(
"""
from homeassistant.util import dt as dt_util
now = dt_util.now()
""",
id="now_helper",
),
pytest.param(
# Calling ``datetime.now()`` with no argument returns naive local
# time, which is not equivalent to ``dt_util.now()``.
"""
from datetime import datetime
now = datetime.now()
""",
id="now_no_args",
),
pytest.param(
# The UTC case is handled by the ``enforce-utcnow`` checker.
"""
from datetime import datetime, UTC
now = datetime.now(UTC)
""",
id="now_with_utc",
),
pytest.param(
"""
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
""",
id="now_with_timezone_utc",
),
pytest.param(
"""
import datetime
now = datetime.datetime.now(datetime.UTC)
""",
id="qualified_now_with_utc",
),
pytest.param(
"""
from datetime import datetime
from zoneinfo import ZoneInfo
now = datetime.now(ZoneInfo("UTC"))
""",
id="now_with_zoneinfo_utc",
),
pytest.param(
"""
from datetime import datetime, UTC
now = datetime.now(tz=UTC)
""",
id="kwarg_now_with_utc",
),
pytest.param(
# ``UTC`` re-exported from ``homeassistant.util.dt`` is still the
# UTC case, handled by the ``enforce-utcnow`` checker.
"""
import datetime
from homeassistant.util import dt as dt_util
now = datetime.datetime.now(dt_util.UTC)
""",
id="dt_util_utc",
),
pytest.param(
"""
import datetime
from homeassistant.util import dt as dt_util
now = datetime.datetime.now(tz=dt_util.UTC)
""",
id="kwarg_dt_util_utc",
),
pytest.param(
"""
from datetime import datetime
from homeassistant.util.dt import UTC
now = datetime.now(UTC)
""",
id="from_util_dt_import_utc",
),
pytest.param(
"""
import datetime
import homeassistant.util.dt as dt_util
now = datetime.datetime.now(dt_util.UTC)
""",
id="import_util_dt_as_dt_util_utc",
),
pytest.param(
# Calling ``.now`` on something that is not ``datetime.datetime``
# must not be flagged.
"""
from zoneinfo import ZoneInfo
class Counter:
def now(self, tz):
return 0
Counter().now(ZoneInfo("Europe/Stockholm"))
""",
id="other_now_method",
),
],
)
def test_enforce_now_good(
linter: UnittestLinter,
enforce_now_checker: BaseChecker,
code: str,
) -> None:
"""Good test cases -- no message expected."""
root_node = astroid.parse(code, "homeassistant.components.pylint_test")
walker = ASTWalker(linter)
walker.add_checker(enforce_now_checker)
with assert_no_messages(linter):
walker.walk(root_node)
@pytest.mark.parametrize(
"code",
[
pytest.param(
"""
from datetime import datetime
from zoneinfo import ZoneInfo
now = datetime.now(ZoneInfo("Europe/Stockholm"))
""",
id="from_import_zoneinfo",
),
pytest.param(
"""
from datetime import datetime
from zoneinfo import ZoneInfo
now = datetime.now(tz=ZoneInfo("Europe/Stockholm"))
""",
id="kwarg_zoneinfo",
),
pytest.param(
"""
import datetime
import zoneinfo
now = datetime.datetime.now(zoneinfo.ZoneInfo("Europe/Stockholm"))
""",
id="qualified_datetime",
),
pytest.param(
"""
import datetime as dt
from zoneinfo import ZoneInfo
now = dt.datetime.now(ZoneInfo("Europe/Stockholm"))
""",
id="aliased_datetime",
),
pytest.param(
# A time zone passed in as a variable is still flagged.
"""
from datetime import datetime, tzinfo
def get_now(time_zone: tzinfo) -> datetime:
return datetime.now(time_zone)
""",
id="variable_tz",
),
pytest.param(
"""
from datetime import datetime, tzinfo
def get_now(time_zone: tzinfo) -> datetime:
return datetime.now(tz=time_zone)
""",
id="kwarg_variable_tz",
),
],
)
def test_enforce_now_bad(
linter: UnittestLinter,
enforce_now_checker: BaseChecker,
code: str,
) -> None:
"""Bad test cases -- one message expected per call."""
root_node = astroid.parse(code, "homeassistant.components.pylint_test")
walker = ASTWalker(linter)
walker.add_checker(enforce_now_checker)
walker.walk(root_node)
messages = linter.release_messages()
assert len(messages) == 1
assert messages[0].msg_id == "home-assistant-enforce-now"
def test_enforce_now_skips_util_dt(
linter: UnittestLinter,
enforce_now_checker: BaseChecker,
) -> None:
"""``homeassistant.util.dt`` defines ``now`` itself, so it is skipped."""
code = """
from datetime import datetime
from zoneinfo import ZoneInfo
now = datetime.now(ZoneInfo("Europe/Stockholm"))
"""
root_node = astroid.parse(code, "homeassistant.util.dt")
walker = ASTWalker(linter)
walker.add_checker(enforce_now_checker)
with assert_no_messages(linter):
walker.walk(root_node)