diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index e84263f0336c..740f6456f76b 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -187,7 +187,7 @@ async def async_process_advertisements( ) stack.callback(unload) - if mode == BluetoothScanningMode.ACTIVE: + if mode is BluetoothScanningMode.ACTIVE: task = hass.async_create_task(manager.async_request_active_scan(timeout)) stack.callback(task.cancel) diff --git a/homeassistant/components/homeassistant_hardware/util.py b/homeassistant/components/homeassistant_hardware/util.py index aa903eb8a21d..e0197037fc8a 100644 --- a/homeassistant/components/homeassistant_hardware/util.py +++ b/homeassistant/components/homeassistant_hardware/util.py @@ -88,7 +88,7 @@ class WaitingAddonManager(AddonManager): info = None # Do not try to uninstall an addon if it is already uninstalled - if info is not None and info.state == AddonState.NOT_INSTALLED: + if info is not None and info.state is AddonState.NOT_INSTALLED: return await self.async_uninstall_addon() diff --git a/homeassistant/components/kiosker/services.py b/homeassistant/components/kiosker/services.py index cd86775e3d74..307026c70767 100644 --- a/homeassistant/components/kiosker/services.py +++ b/homeassistant/components/kiosker/services.py @@ -114,7 +114,7 @@ async def _get_coordinator( for entry_id in device.config_entries: entry = call.hass.config_entries.async_get_entry(entry_id) if entry and entry.domain == DOMAIN: - if entry.state != ConfigEntryState.LOADED: + if entry.state is not ConfigEntryState.LOADED: raise HomeAssistantError(f"{entry.title} is not loaded") return entry.runtime_data diff --git a/homeassistant/components/ovhcloud_ai_endpoints/config_flow.py b/homeassistant/components/ovhcloud_ai_endpoints/config_flow.py index 86196019b390..29a5424c223d 100644 --- a/homeassistant/components/ovhcloud_ai_endpoints/config_flow.py +++ b/homeassistant/components/ovhcloud_ai_endpoints/config_flow.py @@ -217,7 +217,7 @@ class ConversationFlowHandler(ConfigSubentryFlow): self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """Manage conversation agent configuration.""" - if self._get_entry().state != ConfigEntryState.LOADED: + if self._get_entry().state is not ConfigEntryState.LOADED: return self.async_abort(reason="entry_not_loaded") if user_input is not None: diff --git a/homeassistant/components/pooldose/__init__.py b/homeassistant/components/pooldose/__init__.py index b9d83c52fd5e..964d4b983848 100644 --- a/homeassistant/components/pooldose/__init__.py +++ b/homeassistant/components/pooldose/__init__.py @@ -74,7 +74,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PooldoseConfigEntry) -> translation_key="connect_failed", ) from err - if client_status != RequestStatus.SUCCESS: + if client_status is not RequestStatus.SUCCESS: raise ConfigEntryNotReady( translation_domain=entry.domain, translation_key="client_init_failed", diff --git a/homeassistant/components/pooldose/coordinator.py b/homeassistant/components/pooldose/coordinator.py index ac7757fc1e08..b3454fa7eab4 100644 --- a/homeassistant/components/pooldose/coordinator.py +++ b/homeassistant/components/pooldose/coordinator.py @@ -62,7 +62,7 @@ class PooldoseCoordinator(DataUpdateCoordinator[StructuredValuesDict]): translation_key="update_connect_failed", ) from err - if status != RequestStatus.SUCCESS: + if status is not RequestStatus.SUCCESS: raise UpdateFailed( translation_domain=self.config_entry.domain, translation_key="api_status_error", diff --git a/homeassistant/components/victron_gx/entity.py b/homeassistant/components/victron_gx/entity.py index c7eaf42b4521..7413e8c62ecf 100644 --- a/homeassistant/components/victron_gx/entity.py +++ b/homeassistant/components/victron_gx/entity.py @@ -75,7 +75,7 @@ class VictronBaseEntity(Entity): # 3. Dynamic units come from user-configured MQTT topics (e.g. # SwitchableOutput Settings/Unit) and have no translation file # entry, so we must set the unit programmatically. - or self._metric.metric_type == MetricType.DYNAMIC + or self._metric.metric_type is MetricType.DYNAMIC ): return unit_of_measurement diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index d17a02222d2b..59f96db1906e 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -490,11 +490,11 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): ) if flow.flow_id not in self._progress: - # The flow was removed during the step, raise UnknownFlow - # unless the result is an abort. Uses `!=` (not `is not`) because - # this runs before the legacy-string normalization below, and - # out-of-tree flow handlers may still return raw "abort". - if result["type"] != FlowResultType.ABORT: # type: ignore[ha-enum-identity-compare,unused-ignore] + # The flow was removed during the step, raise UnknownFlow unless + # the result is an abort. Compares against the string value + # because this runs before the legacy-string normalization + # below, and out-of-tree flow handlers may still return raw "abort". + if result["type"] != FlowResultType.ABORT.value: raise UnknownFlow return result diff --git a/mypy.ini b/mypy.ini index 5b8c3d01004b..519bd1cb4c6b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5,7 +5,7 @@ [mypy] python_version = 3.14 platform = linux -plugins = pydantic.mypy +plugins = pydantic.mypy, mypy_plugins/enum_identity_compare.py show_error_codes = true follow_imports = normal native_parser = true diff --git a/mypy_plugins/__init__.py b/mypy_plugins/__init__.py new file mode 100644 index 000000000000..36eaf2eff0ed --- /dev/null +++ b/mypy_plugins/__init__.py @@ -0,0 +1 @@ +"""Home Assistant mypy plugins.""" diff --git a/mypy_plugins/enum_identity_compare.py b/mypy_plugins/enum_identity_compare.py new file mode 100644 index 000000000000..a9a3664206d8 --- /dev/null +++ b/mypy_plugins/enum_identity_compare.py @@ -0,0 +1,184 @@ +"""Mypy plugin: flag ``==``/``!=`` between two operands of the same enum class. + +Scope is intentionally narrow: only **plain ``enum.Enum`` subclasses** are +flagged by default, because Python's ``Enum.__eq__`` is identity-based — +``a == b`` and ``a is b`` produce the same result there. + +Any enum with a base outside the ``Enum`` hierarchy is **skipped**, because +such a mixin typically gives it a value-based ``__eq__``: a primitive +(``StrEnum``/``IntEnum`` or the legacy ``class X(str, Enum)`` / +``class X(int, Enum)`` / ``class X(float, Enum)`` form), a ``@dataclass``, or a +``NamedTuple``. Their ``==`` compares by value and accepts raw operands: +callers routinely pass ``"on"`` where a ``HVACMode`` parameter is annotated, +and ``==`` silently makes that work while ``is`` silently breaks it. Switching +those sites to ``is`` is a runtime-behavior change, not a refactor. + +The check is deliberately conservative — it is decided from the MRO shape, not +from where ``__eq__`` is defined (mypy does not model the ``__eq__`` a +``@dataclass`` synthesizes). So an enum whose only non-``Enum`` base is a plain +helper class that does not override ``__eq__`` is still skipped even though it +is really identity-based. That under-flags such enums rather than risk an +unsafe ``==``→``is`` rewrite — the safe direction. + +The ``_FRAMEWORK_GUARANTEED_ENUMS`` set carves back in +``StrEnum``/``IntEnum`` classes where the HA framework itself controls +every callsite and guarantees the value is the enum instance — currently +just ``homeassistant.data_entry_flow.FlowResultType``. + +``enum.Flag``/``enum.IntFlag`` are always exempt — bitwise ``==`` is +idiomatic there. + +""" + +from collections.abc import Callable + +from mypy.errorcodes import ErrorCode +from mypy.nodes import TypeInfo +from mypy.plugin import MethodContext, Plugin +from mypy.types import Instance, LiteralType, Type, UnionType, get_proper_type + +ENUM_IDENTITY = ErrorCode( + "home-assistant-enum-identity-compare", + "Use `is`/`is not` to compare two operands of the same enum class.", + "Home Assistant", +) + +_PLAIN_ENUM_BASE = "enum.Enum" +_FLAG_BASES = frozenset({"enum.Flag", "enum.IntFlag"}) + + +def _is_value_mixin(base: TypeInfo) -> bool: + """True if ``base`` gives an enum value-based ``__eq__``. + + The plugin should fire only when ``==`` is identity-based, which holds iff + the enum has no mixin outside the ``Enum`` hierarchy. Any non-``Enum`` base + — a primitive (``str``/``int``/``float``/…), a ``@dataclass``, or a + ``NamedTuple`` — provides value comparison, so ``is`` is not equivalent to + ``==``. Decided structurally from the MRO (independent of how ``__eq__`` is + synthesized): a base is value-mixing unless it is ``object`` or is itself + part of the ``Enum`` hierarchy (e.g. an intermediate ``class Base(Enum)``, + which keeps the enum identity-based and therefore still flaggable). + """ + if base.fullname == "builtins.object": + return False + return not any(b.fullname == _PLAIN_ENUM_BASE for b in base.mro) + + +# StrEnum/IntEnum classes where every callsite assigning the value is +# framework-controlled, so the runtime value is guaranteed to be the +# enum instance (never a raw string/int). Audited additions only. +_FRAMEWORK_GUARANTEED_ENUMS = frozenset( + { + "homeassistant.data_entry_flow.FlowResultType", + } +) + + +def _enum_class(t: Type | None) -> TypeInfo | None: + """Return the enum TypeInfo if t resolves to a tracked enum class. + + Handles three shapes: + - ``Instance``: the direct case, e.g. ``source: SourceCodes``. + - ``LiteralType``: a single literal enum member, e.g. ``Literal[E.A]``. + Peeled to its enum-class ``fallback``. + - ``UnionType``: if all variants resolve to the same enum class, that + class is passed on. + + Returns ``None`` for: + - ``Flag``/``IntFlag`` (bitwise ``==`` is idiomatic) + - value-based enums not in ``_FRAMEWORK_GUARANTEED_ENUMS`` + - Anything else (``Any``, ``None``, mixed unions, etc.) + """ + if t is None: + return None + pt = get_proper_type(t) + if isinstance(pt, UnionType): + common: TypeInfo | None = None + for variant in pt.items: + v_info = _enum_class(variant) + if v_info is None: + return None + if common is None: + common = v_info + elif common.fullname != v_info.fullname: + return None + return common + if isinstance(pt, LiteralType): + pt = pt.fallback + if not isinstance(pt, Instance): + return None + info = pt.type + has_enum_base = False + has_value_based_base = False + for base in info.mro: + fn = base.fullname + if fn in _FLAG_BASES: + return None + if fn == _PLAIN_ENUM_BASE: + has_enum_base = True + continue + if _is_value_mixin(base): + has_value_based_base = True + if not has_enum_base: + return None + if has_value_based_base and info.fullname not in _FRAMEWORK_GUARANTEED_ENUMS: + # Value-based enum without explicit trust — `is` may diverge from + # `==` when callers pass the underlying primitive value. + return None + return info + + +def _emit(ctx: MethodContext, op: str, enum_cls: TypeInfo) -> Type: + """Emit the warning and return the default return type.""" + replacement = "is" if op == "==" else "is not" + ctx.api.fail( + f"Use `{replacement}` instead of `{op}` to compare " + f"`{enum_cls.name}` enum instances", + ctx.context, + code=ENUM_IDENTITY, + ) + return ctx.default_return_type + + +def _make_hook(op: str) -> Callable[[MethodContext], Type]: + """Return a method-hook callback for ``__eq__`` (``==``) or ``__ne__``.""" + + def hook(ctx: MethodContext) -> Type: + left_enum = _enum_class(ctx.type) + if left_enum is None: + return ctx.default_return_type + right_type = ctx.arg_types[0][0] if ctx.arg_types and ctx.arg_types[0] else None + right_enum = _enum_class(right_type) + if right_enum is None: + return ctx.default_return_type + if left_enum.fullname != right_enum.fullname: + return ctx.default_return_type + return _emit(ctx, op, left_enum) + + return hook + + +_EQ_HOOK = _make_hook("==") +_NE_HOOK = _make_hook("!=") + + +class HassEnumIdentityPlugin(Plugin): + """Mypy plugin entry point.""" + + def get_method_hook(self, fullname: str) -> Callable[[MethodContext], Type] | None: + """Return a hook for ``__eq__``/``__ne__`` calls, else ``None``. + + ``a == b`` desugars to ``a.__eq__(b)``; ``a != b`` to ``__ne__``. + Mypy reports the method's fullname, which we use to tell which + operator triggered the call. + """ + if fullname.endswith(".__eq__"): + return _EQ_HOOK + if fullname.endswith(".__ne__"): + return _NE_HOOK + return None + + +def plugin(version: str) -> type[Plugin]: + """Mypy plugin entry point.""" + return HassEnumIdentityPlugin diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 3079830fa721..ab0e344f3c36 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -34,6 +34,7 @@ GENERAL_SETTINGS: Final[dict[str, str]] = { "plugins": ", ".join( # noqa: FLY002 [ "pydantic.mypy", + "mypy_plugins/enum_identity_compare.py", ] ), "show_error_codes": "true", diff --git a/tests/mypy_plugins/__init__.py b/tests/mypy_plugins/__init__.py new file mode 100644 index 000000000000..57c6189062eb --- /dev/null +++ b/tests/mypy_plugins/__init__.py @@ -0,0 +1 @@ +"""Tests for HA mypy plugins.""" diff --git a/tests/mypy_plugins/test_enum_identity_compare.py b/tests/mypy_plugins/test_enum_identity_compare.py new file mode 100644 index 000000000000..5707124aa022 --- /dev/null +++ b/tests/mypy_plugins/test_enum_identity_compare.py @@ -0,0 +1,444 @@ +"""Tests for the enum_identity_compare mypy plugin. + +Each test snippet is run through mypy's API with the plugin enabled. +Tests assert the number of ``home-assistant-enum-identity-compare`` errors emitted +and the relevant message content (operator pair and enum class name). + +The plugin is intentionally narrow: it fires only on plain ``enum.Enum`` +subclasses (where ``__eq__`` is identity-based) plus a small set of +framework-guaranteed ``StrEnum`` classes. Generic StrEnum/IntEnum are +deliberately skipped — see the plugin module docstring. +""" + +import os +from pathlib import Path +import sys +import textwrap + +from mypy import api as mypy_api +import pytest + +_IS_EQ = ("`is`", "`==`") +_IS_NOT_NE = ("`is not`", "`!=`") + +_PROJECT_ROOT = Path(__file__).resolve().parents[2] +_PLUGINS_ROOT = _PROJECT_ROOT # mypy_plugins/ lives under the worktree root + + +def _run_mypy(code: str, tmp_path: Path, mypy_path: str | None = None) -> list[str]: + """Run mypy with the plugin and return home-assistant-enum-identity-compare errors. + + Each error is normalized to ``LINE: MESSAGE`` form. ``mypy_path``, if + given, is written into ``mypy.ini`` so tests can supply stub modules + that resolve to specific fullnames (used for the framework-guaranteed set). + """ + src = tmp_path / "case.py" + src.write_text(textwrap.dedent(code)) + cache = tmp_path / "mypy_cache" + config = tmp_path / "mypy.ini" + config_body = ( + "[mypy]\n" + "plugins = mypy_plugins.enum_identity_compare\n" + "show_error_codes = true\n" + "strict_equality = true\n" + ) + if mypy_path is not None: + config_body += f"mypy_path = {mypy_path}\n" + config.write_text(config_body) + + env_pythonpath = os.environ.get("PYTHONPATH", "") + os.environ["PYTHONPATH"] = f"{_PLUGINS_ROOT}{os.pathsep}{env_pythonpath}" + # Make sure mypy can import the plugin from the current process. + sys.path.insert(0, str(_PLUGINS_ROOT)) + try: + # mypy ships as a compiled extension; pylint can't introspect it. + stdout, _stderr, _rc = mypy_api.run( # pylint: disable=c-extension-no-member + [ + "--no-incremental", + f"--cache-dir={cache}", + "--config-file", + str(config), + str(src), + ] + ) + finally: + os.environ["PYTHONPATH"] = env_pythonpath + sys.path.pop(0) + + errors: list[str] = [] + for line in stdout.splitlines(): + if "[home-assistant-enum-identity-compare]" not in line: + continue + # Format: ":: error: [code]" + prefix, _, msg = line.partition(": error: ") + line_no = prefix.rsplit(":", 1)[-1].strip() + msg_clean = msg.split(" [home-assistant-enum-identity-compare]", 1)[0].strip() + errors.append(f"{line_no}: {msg_clean}") + return errors + + +_PRELUDE = """ +import dataclasses +from enum import Enum, IntEnum, IntFlag, StrEnum +from typing import NamedTuple + +class ConfigEntryState(Enum): + LOADED = "loaded" + NOT_LOADED = "not_loaded" + +class SourceCodes(Enum): + DAB = "dab" + FM = "fm" + AUX = "aux" + +class MediaType(StrEnum): + CHANNEL = "channel" + APP = "app" + +class HTTPStatus(IntEnum): + OK = 200 + +class ClimateFeature(IntFlag): + SWING_MODE = 32 + +class AudioBitRates(int, Enum): + BITRATE_8 = 8 + BITRATE_16 = 16 + +class LegacyStr(str, Enum): + A = "a" + B = "b" + +class HomeeCoverState(float, Enum): + OPEN = 0.0 + CLOSED = 1.0 + +class LegacyBytes(bytes, Enum): + A = b"a" + B = b"b" + +@dataclasses.dataclass(frozen=True) +class _VariantInfo: + label: str + +class HardwareVariant(_VariantInfo, Enum): + A = ("a",) + B = ("b",) + +class _NamedVariant(NamedTuple): + label: str + +class NamedTupleEnum(_NamedVariant, Enum): + A = ("a",) + B = ("b",) + +class _BaseStates(Enum): + pass + +class DerivedStates(_BaseStates): + ON = 1 + OFF = 2 +""" + + +@pytest.mark.parametrize( + ("snippet", "enum_name", "op_substrings"), + [ + pytest.param( + """ +def fn(s: ConfigEntryState) -> bool: + return s == ConfigEntryState.LOADED +""", + "ConfigEntryState", + _IS_EQ, + id="plain_enum_eq", + ), + pytest.param( + """ +def fn(s: ConfigEntryState) -> bool: + return s != ConfigEntryState.NOT_LOADED +""", + "ConfigEntryState", + _IS_NOT_NE, + id="plain_enum_ne", + ), + pytest.param( + """ +def fn(s: ConfigEntryState) -> bool: + return ConfigEntryState.LOADED == s +""", + "ConfigEntryState", + _IS_EQ, + id="plain_enum_lhs", + ), + # An ``elif`` after an ``is`` check narrows the LHS to a literal + # union. Without union/literal handling, the plugin would silently + # skip the ``==`` even though both operands resolve to ``SourceCodes``. + pytest.param( + """ +def fn(source: SourceCodes) -> str: + if source is SourceCodes.DAB: + return "dab" + elif source == SourceCodes.FM: + return "fm" + return "other" +""", + "SourceCodes", + _IS_EQ, + id="narrowed_elif", + ), + pytest.param( + """ +from typing import Literal + +def fn(s: Literal[ConfigEntryState.LOADED]) -> bool: + return s == ConfigEntryState.LOADED +""", + "ConfigEntryState", + _IS_EQ, + id="literal_annotation", + ), + # ``Enum | None == Enum``: the plugin conservatively rejects any union + # containing ``None``, but under ``strict_equality`` mypy narrows the + # LHS to the enum class before invoking ``__eq__``, so this call site + # never reaches the union path and is correctly flagged. HA's + # ``mypy.ini`` sets ``strict_equality``. + pytest.param( + """ +def fn(source: SourceCodes | None) -> bool: + return source == SourceCodes.DAB +""", + "SourceCodes", + _IS_EQ, + id="optional_enum_under_strict_equality", + ), + # An enum deriving from an intermediate ``Enum`` base (no data mixin) + # is still identity-based and must be flagged — the structural check + # must not mistake the intermediate base for a value mixin. + pytest.param( + """ +def fn(s: DerivedStates) -> bool: + return s == DerivedStates.ON +""", + "DerivedStates", + _IS_EQ, + id="derived_from_intermediate_enum_base", + ), + ], +) +def test_bad_plain_enum( + tmp_path: Path, + snippet: str, + enum_name: str, + op_substrings: tuple[str, str], +) -> None: + """Comparisons on plain ``Enum`` operands must flag a single error.""" + errors = _run_mypy(_PRELUDE + snippet, tmp_path) + assert len(errors) == 1 + assert enum_name in errors[0] + assert all(op in errors[0] for op in op_substrings) + + +@pytest.mark.parametrize( + "snippet", + [ + # A StrEnum defined in user code is NOT flagged: the plugin can't tell + # whether callers pass the enum instance or the underlying string + # (StrEnum's whole point is making both work). It must be added to + # ``_FRAMEWORK_GUARANTEED_ENUMS`` to be checked. + pytest.param( + """ +def fn(m: MediaType) -> bool: + return m == MediaType.CHANNEL +""", + id="strenum_not_framework_guaranteed", + ), + # An IntEnum defined in user code is NOT flagged for the same reason. + pytest.param( + """ +def fn(code: HTTPStatus) -> bool: + return code == HTTPStatus.OK +""", + id="intenum_not_framework_guaranteed", + ), + # The legacy ``(int, Enum)`` mixin inherits a value-based ``__eq__`` + # from ``int`` (no ``enum.IntEnum`` base), so it is NOT flagged either. + pytest.param( + """ +def fn(rate: AudioBitRates) -> bool: + return rate == AudioBitRates.BITRATE_8 +""", + id="int_enum_mixin_not_framework_guaranteed", + ), + # And the same for the legacy ``(str, Enum)`` mixin. + pytest.param( + """ +def fn(v: LegacyStr) -> bool: + return v == LegacyStr.A +""", + id="str_enum_mixin_not_framework_guaranteed", + ), + # A ``(float, Enum)`` mixin is value-based too (``__eq__`` from float). + pytest.param( + """ +def fn(s: HomeeCoverState) -> bool: + return s == HomeeCoverState.OPEN +""", + id="float_enum_mixin_not_framework_guaranteed", + ), + # And a ``(bytes, Enum)`` mixin likewise. + pytest.param( + """ +def fn(v: LegacyBytes) -> bool: + return v == LegacyBytes.A +""", + id="bytes_enum_mixin_not_framework_guaranteed", + ), + # A ``@dataclass`` mixin is value-based (generated ``__eq__`` compares + # by value), even though the mixin is not a builtin primitive. + pytest.param( + """ +def fn(v: HardwareVariant) -> bool: + return v == HardwareVariant.A +""", + id="dataclass_mixin_not_flagged", + ), + # A ``NamedTuple`` mixin is value-based too (tuple ``__eq__``). + pytest.param( + """ +def fn(v: NamedTupleEnum) -> bool: + return v == NamedTupleEnum.A +""", + id="namedtuple_mixin_not_flagged", + ), + # Comparing a raw ``str`` against a ``StrEnum`` member is legitimate. + pytest.param( + """ +def fn(raw: str) -> bool: + return raw == MediaType.CHANNEL +""", + id="str_vs_strenum", + ), + # Comparing a raw ``int`` against an ``IntEnum`` member is legitimate. + pytest.param( + """ +def fn(code: int) -> bool: + return code == HTTPStatus.OK +""", + id="int_vs_intenum", + ), + # A union LHS (e.g. ``MediaType | str``) is NOT flagged: even if + # MediaType were framework-guaranteed, runtime callers can pass either form, so + # switching to ``is`` would break the str arm. + pytest.param( + """ +def fn(m: MediaType | str) -> bool: + return m == MediaType.CHANNEL +""", + id="union_with_str", + ), + # ``IntFlag`` bitwise ``==`` is the standard pattern. + pytest.param( + """ +def fn(features: ClimateFeature) -> bool: + return features & ClimateFeature.SWING_MODE == ClimateFeature.SWING_MODE +""", + id="intflag_bitwise", + ), + # ``is`` is the recommended form — must not fire on itself. + pytest.param( + """ +def fn(s: ConfigEntryState) -> bool: + return s is ConfigEntryState.LOADED +""", + id="is_already", + ), + # ``is not`` is the recommended negative form — must not fire on itself. + pytest.param( + """ +def fn(s: ConfigEntryState) -> bool: + return s is not ConfigEntryState.LOADED +""", + id="is_not_already", + ), + # Plain ``int`` ``==`` ``int`` (no enum involved) must not flag. + pytest.param( + "\nif 1 == 2:\n pass\n", + id="unrelated_compare", + ), + # Ordering comparison (``>``) on an enum must not flag. + pytest.param( + """ +def fn(s: HTTPStatus) -> bool: + return s > HTTPStatus.OK +""", + id="ordering_op", + ), + ], +) +def test_good_no_flag(tmp_path: Path, snippet: str) -> None: + """Legitimate comparisons must not emit any error.""" + errors = _run_mypy(_PRELUDE + snippet, tmp_path) + assert errors == [] + + +def _write_flow_result_type_stub(tmp_path: Path) -> None: + """Write a fake ``homeassistant.data_entry_flow`` module under tmp_path. + + ``_FRAMEWORK_GUARANTEED_ENUMS`` matches by fullname + (``homeassistant.data_entry_flow.FlowResultType``), so we synthesize a + package at that path rather than depending on the real HA tree being + importable from the test environment. + """ + pkg = tmp_path / "homeassistant" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "data_entry_flow.py").write_text( + "from enum import StrEnum\n" + "class FlowResultType(StrEnum):\n" + ' FORM = "form"\n' + ' ABORT = "abort"\n' + ) + + +@pytest.mark.parametrize( + ("snippet", "op_substrings"), + [ + # ``FlowResultType`` is on ``_FRAMEWORK_GUARANTEED_ENUMS`` and must + # flag. StrEnum normally escapes the plugin, but this class is + # explicitly included because HA's framework controls every + # value-assigning callsite — see the plugin module docstring. + pytest.param( + """ +from homeassistant.data_entry_flow import FlowResultType + +def fn(r: FlowResultType) -> bool: + return r == FlowResultType.FORM +""", + _IS_EQ, + id="eq", + ), + # And the same for ``!=`` against the framework-guaranteed ``FlowResultType``. + pytest.param( + """ +from homeassistant.data_entry_flow import FlowResultType + +def fn(r: FlowResultType) -> bool: + return r != FlowResultType.ABORT +""", + _IS_NOT_NE, + id="ne", + ), + ], +) +def test_bad_framework_guaranteed( + tmp_path: Path, + snippet: str, + op_substrings: tuple[str, str], +) -> None: + """The framework-guaranteed ``FlowResultType`` StrEnum must flag a single error.""" + _write_flow_result_type_stub(tmp_path) + errors = _run_mypy(snippet, tmp_path, mypy_path=str(tmp_path)) + assert len(errors) == 1 + assert "FlowResultType" in errors[0] + assert all(op in errors[0] for op in op_substrings)