diff --git a/LEDGER.md b/LEDGER.md index e36f126..68bc720 100644 --- a/LEDGER.md +++ b/LEDGER.md @@ -205,8 +205,15 @@ der Wetterprognose, optional sprachlich schön formuliert, ausgespielt über Das (`ARCHITECTURE-SPINE.md` final, 24 ADs; `.memlog.md` 43 Einträge; `reviews/` 6 Linsen). Paradigma: Pipes-and-Filters-Kern (`logic/`, hass-frei) in Ports-and-Adapters-Schale. -## Stories -- (noch keine — folgen aus Phase 4 Epics/Stories) +## Stories (Phase 5 — Story-Loop; TDD, luna-pro-Review je Story, ein Commit je Story) +> qwen offline → Story-Reviews mit luna-pro (dokumentierter Ersatz). Umgebung: `.venv` Py3.13 + HA 2025.3.4. + +- **1.1 Skeleton/Model/const/Guard** ✅ — 19 Tests grün. Dateien: `logic/model.py`, `const.py`, + `__init__.py`, `logic/__init__.py`, `manifest.json`, `hacs.json`, `tests/{test_layering, + test_const_schema, logic/test_model}.py`. **luna-pro-Review:** 6 Findings, 4 übernommen + (default_options()-Deepcopy F3; condition eigenes Feld F4; gaps_count/alert abgeleitet statt + gespeichert F5; Guard auf AST F6), 2 verworfen mit Evidenz (F1 RawForecast transient/nicht + signiert → keine Tief-Immutabilität nötig; F2 Schema-Validierung ist Story 3.1/3.2-Scope). ## Offene Punkte / nächste Schritte - Phase 4: Epics & Stories aus dem Spine + Party-Mode + Readiness-Check. diff --git a/custom_components/what_to_wear/__init__.py b/custom_components/what_to_wear/__init__.py new file mode 100644 index 0000000..2f27ecb --- /dev/null +++ b/custom_components/what_to_wear/__init__.py @@ -0,0 +1,10 @@ +"""What to Wear — a Home Assistant custom integration. + +Story 1.1 provides only the package skeleton and the pure-core model/constants. +The config-entry lifecycle (``async_setup_entry`` etc.) arrives in Story 1.8. +""" +from __future__ import annotations + +from .const import DOMAIN + +__all__ = ["DOMAIN"] diff --git a/custom_components/what_to_wear/const.py b/custom_components/what_to_wear/const.py new file mode 100644 index 0000000..71ad1a6 --- /dev/null +++ b/custom_components/what_to_wear/const.py @@ -0,0 +1,87 @@ +"""Constants and the single normative options schema for What to Wear (AD-23). + +This is the one place the flat, English options schema, its defaults, the +diagnostics redaction set, timeouts and provider URLs are defined. Every later +story wires against these constants rather than rebuilding the schema. +""" +from __future__ import annotations + +import copy +from typing import Any, Final + +DOMAIN: Final = "what_to_wear" + +# Config-entry schema version (AD-8 / NFR-10). +CONFIG_VERSION: Final = 1 +CONFIG_MINOR_VERSION: Final = 1 + +# entry.data key (AD-8). +CONF_WEATHER_ENTITY: Final = "weather_entity_id" + +# Subentry type for wardrobe items (AD-9/AD-22). +SUBENTRY_TYPE_ITEM: Final = "item" + +# Canonical entities/events/services. +SENSOR_ENTITY_ID: Final = "sensor.what_to_wear" +SENSOR_UNIQUE_ID: Final = "what_to_wear_recommendation" +EVENT_RECOMMENDATION: Final = "what_to_wear_recommendation" +SERVICE_RECOMMEND: Final = "recommend" + +# --- Normative options schema (flat, English) — AD-23 ----------------------- +# Base threshold values WITHOUT the offset; rules.py applies the offset once. +OPTIONS_DEFAULTS: Final[dict[str, Any]] = { + # Strictly monotone lower band edges in °C: bands are + # (-inf,0) [0,8) [8,15) [15,22) [>=22). Stored as the four inner edges. + "warmth_band_limits": [0.0, 8.0, 15.0, 22.0], + "heat_threshold": 28.0, + "rain_prob_should": 40, # % + "rain_prob_must": 70, # % + "rain_amount_should": 1.0, # mm + "rain_amount_must": 5.0, # mm + "gust_should": 40.0, # km/h + "wind_proxy_should": 30.0, # km/h (proxy when gusts missing) + "uv_should": 6, + "cold_sensitivity_offset": 0, # -2..+2 + "switchover_time": "10:00:00", # HH:MM:SS (TimeSelector format) + "llm_enabled": False, + "llm_provider": "openai", # "openai" | "anthropic" + "llm_api_key": "", + "llm_model": "", +} + + +def default_options() -> dict[str, Any]: + """Return a fresh deep copy of the option defaults (AD-23). + + ``OPTIONS_DEFAULTS`` is a template; consumers must never mutate it in place + (the nested ``warmth_band_limits`` list would otherwise leak across entries + and tests). Always start from this copy. + """ + return copy.deepcopy(OPTIONS_DEFAULTS) + + +# Diagnostics redaction — exact stored key names (AD-5). +TO_REDACT: Final = {"llm_api_key", CONF_WEATHER_ENTITY} + +# --- Timeouts (seconds) ----------------------------------------------------- +FORECAST_TIMEOUT_S: Final = 10 # coordinator forecast fetch (AD-7/AD-17) +CONFIG_FLOW_TEST_TIMEOUT_S: Final = 10 # config-flow test call (FR-1.2) +LLM_TIMEOUT_S: Final = 18 # AD-6, budget 10 + 18 + overhead < 30 s (NFR-7) +LLM_MAX_RESPONSE_BYTES: Final = 64 * 1024 # AD-6 body cap + +# Recompute cadence / staleness. +UPDATE_INTERVAL_HOURS: Final = 1 +STALE_AFTER_HOURS: Final = 6 # FR-7.5 + +# Output size limits (AD-14). +MAX_STATE_LEN: Final = 255 +MAX_ATTRS_BYTES: Final = 15 * 1024 # buffer under the 16 KiB recorder limit +MAX_EVENT_BYTES: Final = 31 * 1024 # buffer under the 32 KiB event limit +MAX_ITEMS_IN_ATTRS: Final = 30 + +# --- LLM provider endpoints (AD-6) ------------------------------------------ +OPENAI_URL: Final = "https://api.openai.com/v1/chat/completions" +ANTHROPIC_URL: Final = "https://api.anthropic.com/v1/messages" +ANTHROPIC_VERSION: Final = "2023-06-01" +# Model defaults (as of 2026-07; verify against the live model list on wiring). +DEFAULT_MODEL: Final = {"openai": "gpt-5.4-mini", "anthropic": "claude-haiku-4-5"} diff --git a/custom_components/what_to_wear/logic/__init__.py b/custom_components/what_to_wear/logic/__init__.py new file mode 100644 index 0000000..c061fdc --- /dev/null +++ b/custom_components/what_to_wear/logic/__init__.py @@ -0,0 +1,6 @@ +"""Pure, hass-free recommendation core for What to Wear (AD-1). + +Modules in this package import only the standard library. The layering guard +test (``tests/test_layering.py``) enforces that no ``homeassistant`` or +``dt_util`` import ever appears here. +""" diff --git a/custom_components/what_to_wear/logic/model.py b/custom_components/what_to_wear/logic/model.py new file mode 100644 index 0000000..7cf2c3c --- /dev/null +++ b/custom_components/what_to_wear/logic/model.py @@ -0,0 +1,199 @@ +"""Pure-core data model for What to Wear (Story 1.1). + +This module is part of the hass-free ``logic/`` core (AD-1): it imports only the +standard library. All shared shapes between the pipeline stages live here as +frozen dataclasses (AD-14), and the requirement/category vocabularies are the +single normative source of keys (AD-21/AD-22). +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum, IntEnum + + +class Priority(IntEnum): + """Requirement priority. Ordered so ``max(...)`` yields the strongest (AD-21).""" + + MAY = 1 + SHOULD = 2 + MUST = 3 + + +class RequirementKey(str, Enum): + """The single normative source of requirement keys (AD-21).""" + + BASE_TOP = "base_top" + BASE_BOTTOM = "base_bottom" + BASE_SHOES = "base_shoes" + WARMTH = "warmth" + WATERPROOF_OUTER = "waterproof_outer" + WINDPROOF_OUTER = "windproof_outer" + STURDY_SHOES = "sturdy_shoes" + HAT = "hat" + GLOVES = "gloves" + SCARF = "scarf" + SUN_PROTECTION = "sun_protection" + HINT_HEAT = "hint_heat" + HINT_THUNDERSTORM = "hint_thunderstorm" + HINT_LAYERING = "hint_layering" + + +class Category(str, Enum): + """The single normative source of wardrobe categories (AD-22).""" + + TOP = "top" + SWEATER = "sweater" + JACKET = "jacket" + BOTTOM = "bottom" + SHOES = "shoes" + HEAD = "head" + HANDS = "hands" + NECK = "neck" + ACCESSORY = "accessory" + + +@dataclass(frozen=True, slots=True) +class NormField: + """One normalized forecast field. ``value=None`` means missing, never 0 (AD-4).""" + + value: float | None + source: str | None # "daily" | "hourly" | "derived" | None + note: str | None = None + + +@dataclass(frozen=True, slots=True) +class RawForecast: + """Adapter output: timezone-aware, still in the source entity's units (AD-24). + + ``daily``/``hourly`` are tuples of plain dicts whose datetime values are + already ``datetime`` objects (tz-aware). ``units`` carries the source + entity's unit attributes so ``normalize`` can convert to SI (AD-4). + + This is a transient carrier consumed once by ``normalize`` and then + discarded; it is never cached or fed into the signature (which is computed + over the composed outfit, not the raw forecast). Deep-freezing the nested + dicts is therefore deliberately omitted. + """ + + time_zone: str + units: dict[str, str] = field(default_factory=dict) + daily: tuple[dict, ...] = () + hourly: tuple[dict, ...] = () + + +@dataclass(frozen=True, slots=True) +class NormForecast: + """SI-normalized forecast for the target date, one NormField per feature.""" + + feels_like_morning: NormField + temp_morning: NormField + temp_min: NormField + temp_max: NormField + feels_like_min: NormField + feels_like_max: NormField + rain_probability: NormField + rain_amount: NormField + wind: NormField + gust: NormField + uv_index: NormField + # Weather condition is categorical, not numeric: a mapped code (e.g. "regen", + # "schnee", "gewitter") or None when missing/unmapped (AD-17). + condition: str | None = None + + +@dataclass(frozen=True, slots=True) +class Requirement: + """A weather-derived requirement (AD-21). ``level`` carries N for warmth.""" + + key: RequirementKey + priority: Priority + level: int | None = None + + +@dataclass(frozen=True, slots=True) +class Item: + """A wardrobe item (subentry). Keys are English per AD-22.""" + + id: str + name: str + category: Category + warmth: int + waterproof: bool = False + windproof: bool = False + sun_protection: bool = False + formality: str = "casual" # "casual" | "business" + temp_min: float | None = None + temp_max: float | None = None + active: bool = True + + +@dataclass(frozen=True, slots=True) +class ChosenItem: + """An item selected into the outfit, with the requirement it satisfies.""" + + item: Item + reason_key: RequirementKey + + +@dataclass(frozen=True, slots=True) +class Gap: + """A must/should requirement with no matching active item (FR-5.2).""" + + key: RequirementKey + priority: Priority + cause: str + + +@dataclass(frozen=True, slots=True) +class Outfit: + """The composed outfit plus named gaps and text hints.""" + + items: tuple[ChosenItem, ...] = () + gaps: tuple[Gap, ...] = () + hints: tuple[RequirementKey, ...] = () + + +@dataclass(frozen=True, slots=True) +class Recommendation: + """The immutable end-to-end recommendation (AD-14). + + Enumerates every stored field. ``changed`` and ``stale`` are placeholders + the HA-side owners (coordinator / sensor) populate; ``signature`` is computed + in the pure core. The remaining AD-19 payload keys ``gaps_count`` and + ``alert`` are *derived* in ``to_payload()`` (Story 1.5) from ``gaps`` / + ``requirements`` so they can never desync — they are not stored fields. + """ + + status: str # "ok" | "fehler_prognose" + target_date: str # local calendar date, YYYY-MM-DD + language: str + target_label: str = "" + created_at: str | None = None + forecast_fetched_at: str | None = None + tone: str = "rules" # "rules" | "llm" + short_text: str = "" + full_text: str = "" + llm_text: str | None = None + items: tuple[ChosenItem, ...] = () + items_truncated: int = 0 + requirements: tuple[Requirement, ...] = () + gaps: tuple[Gap, ...] = () + metrics: NormForecast | None = None + data_notes: tuple[str, ...] = () + source: str | None = None + signature: str = "" + changed: bool = False + stale: bool = False + + @property + def gaps_count(self) -> int: + """Derived payload key (AD-19): never desyncs from ``gaps``.""" + return len(self.gaps) + + @property + def alert(self) -> bool: + """Derived payload key (AD-19): a must-requirement beyond the base outfit.""" + base = {RequirementKey.BASE_TOP, RequirementKey.BASE_BOTTOM, RequirementKey.BASE_SHOES} + return any( + r.priority is Priority.MUST and r.key not in base for r in self.requirements + ) diff --git a/custom_components/what_to_wear/manifest.json b/custom_components/what_to_wear/manifest.json new file mode 100644 index 0000000..594f2b6 --- /dev/null +++ b/custom_components/what_to_wear/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "what_to_wear", + "name": "What to Wear", + "version": "0.1.0", + "codeowners": ["@kenearos"], + "config_flow": true, + "dependencies": ["http", "frontend", "lovelace"], + "documentation": "https://github.com/kenearos/what_to_wear", + "integration_type": "service", + "iot_class": "calculated", + "issue_tracker": "https://github.com/kenearos/what_to_wear/issues", + "requirements": [], + "single_config_entry": true +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..3f88274 --- /dev/null +++ b/hacs.json @@ -0,0 +1,5 @@ +{ + "name": "What to Wear", + "homeassistant": "2025.3.0", + "render_readme": true +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4188646 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "what-to-wear-dev" +version = "0.0.0" +description = "Dev/test tooling for the What to Wear Home Assistant integration" +requires-python = ">=3.13" + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +addopts = "-q" +filterwarnings = ["ignore::DeprecationWarning"] + +[tool.ruff] +target-version = "py313" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/logic/__init__.py b/tests/logic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/logic/test_model.py b/tests/logic/test_model.py new file mode 100644 index 0000000..40211b7 --- /dev/null +++ b/tests/logic/test_model.py @@ -0,0 +1,157 @@ +"""Story 1.1 — pure-core data model and enums (no Home Assistant harness).""" +from __future__ import annotations + +import dataclasses + +import pytest + +from custom_components.what_to_wear.logic import model as m + + +def test_requirement_key_enum_complete() -> None: + expected = { + "base_top", + "base_bottom", + "base_shoes", + "warmth", + "waterproof_outer", + "windproof_outer", + "sturdy_shoes", + "hat", + "gloves", + "scarf", + "sun_protection", + "hint_heat", + "hint_thunderstorm", + "hint_layering", + } + assert {k.value for k in m.RequirementKey} == expected + + +def test_category_enum_complete() -> None: + expected = { + "top", + "sweater", + "jacket", + "bottom", + "shoes", + "head", + "hands", + "neck", + "accessory", + } + assert {c.value for c in m.Category} == expected + + +def test_priority_ordering_for_merge() -> None: + # max(priority) must pick MUST over SHOULD over MAY (AD-21 merge semantics) + assert m.Priority.MUST > m.Priority.SHOULD > m.Priority.MAY + assert max(m.Priority.SHOULD, m.Priority.MUST) is m.Priority.MUST + + +def test_normfield_missing_is_none_not_zero() -> None: + missing = m.NormField(value=None, source=None, note="missing") + present = m.NormField(value=0.0, source="daily", note=None) + assert missing.value is None + assert present.value == 0.0 + # "fehlend != 0": the two must be distinguishable + assert missing != present + + +def test_core_dataclasses_exist_and_are_frozen() -> None: + for name in ( + "NormField", + "RawForecast", + "NormForecast", + "Requirement", + "Item", + "Outfit", + "Recommendation", + ): + cls = getattr(m, name) + assert dataclasses.is_dataclass(cls), f"{name} must be a dataclass" + params = cls.__dataclass_params__ + assert params.frozen, f"{name} must be frozen (AD-14 immutability)" + + +def test_recommendation_is_immutable() -> None: + rec = m.Recommendation(status="ok", target_date="2026-07-13", language="de") + with pytest.raises(dataclasses.FrozenInstanceError): + rec.status = "fehler_prognose" # type: ignore[misc] + + +def test_recommendation_enumerates_payload_fields() -> None: + # AD-19: the stored fields, incl. HA-populated placeholders changed/stale and + # the core-computed signature. gaps_count/alert are derived (see below). + field_names = {f.name for f in dataclasses.fields(m.Recommendation)} + required = { + "status", + "target_date", + "target_label", + "created_at", + "forecast_fetched_at", + "language", + "tone", + "short_text", + "full_text", + "llm_text", + "items", + "items_truncated", + "requirements", + "gaps", + "metrics", + "data_notes", + "source", + "signature", + "changed", + "stale", + } + assert required <= field_names, f"missing: {required - field_names}" + + +def test_gaps_count_and_alert_are_derived_not_stored() -> None: + # AD-19: derived at projection time so they can never desync (Story 1.5 uses them). + stored = {f.name for f in dataclasses.fields(m.Recommendation)} + assert "gaps_count" not in stored + assert "alert" not in stored + + no_gaps = m.Recommendation(status="ok", target_date="2026-07-13", language="de") + assert no_gaps.gaps_count == 0 + assert no_gaps.alert is False + + gap = m.Gap(key=m.RequirementKey.GLOVES, priority=m.Priority.SHOULD, cause="none") + must_req = m.Requirement(key=m.RequirementKey.WATERPROOF_OUTER, priority=m.Priority.MUST) + base_req = m.Requirement(key=m.RequirementKey.BASE_TOP, priority=m.Priority.MUST) + rec = m.Recommendation( + status="ok", + target_date="2026-07-13", + language="de", + gaps=(gap,), + requirements=(base_req, must_req), + ) + assert rec.gaps_count == 1 + assert rec.alert is True # a MUST beyond the base outfit + + only_base = m.Recommendation( + status="ok", target_date="2026-07-13", language="de", requirements=(base_req,) + ) + assert only_base.alert is False # base-only MUSTs do not raise alert + + +def test_recommendation_defaults_for_ha_populated_fields() -> None: + rec = m.Recommendation(status="ok", target_date="2026-07-13", language="en") + # changed/stale are placeholders the coordinator/sensor fill later (AD-19) + assert rec.changed is False + assert rec.stale is False + assert rec.tone == "rules" + + +def test_item_defaults() -> None: + item = m.Item(id="abc", name="Jeans", category=m.Category.BOTTOM, warmth=3) + assert item.active is True + assert item.waterproof is False + assert item.windproof is False + assert item.sun_protection is False + assert item.formality == "casual" + assert item.temp_min is None + assert item.temp_max is None diff --git a/tests/test_const_schema.py b/tests/test_const_schema.py new file mode 100644 index 0000000..cd43aba --- /dev/null +++ b/tests/test_const_schema.py @@ -0,0 +1,69 @@ +"""Story 1.1 — the normative options schema lives once in const.py (AD-23).""" +from __future__ import annotations + +from custom_components.what_to_wear import const + + +def test_domain() -> None: + assert const.DOMAIN == "what_to_wear" + + +def test_options_schema_keys_flat_english() -> None: + keys = set(const.OPTIONS_DEFAULTS) + expected = { + "warmth_band_limits", + "heat_threshold", + "rain_prob_should", + "rain_prob_must", + "rain_amount_should", + "rain_amount_must", + "gust_should", + "wind_proxy_should", + "uv_should", + "cold_sensitivity_offset", + "switchover_time", + "llm_enabled", + "llm_provider", + "llm_api_key", + "llm_model", + } + assert expected <= keys, f"missing option keys: {expected - keys}" + + +def test_warmth_band_limits_strictly_monotone_length_four() -> None: + limits = const.OPTIONS_DEFAULTS["warmth_band_limits"] + assert len(limits) == 4 + assert all(a < b for a, b in zip(limits, limits[1:])), "must be strictly monotone" + + +def test_defaults_sane() -> None: + d = const.OPTIONS_DEFAULTS + assert d["cold_sensitivity_offset"] == 0 + assert d["llm_enabled"] is False + assert d["switchover_time"] == "10:00:00" + assert d["heat_threshold"] == 28.0 + + +def test_default_options_returns_independent_deep_copy() -> None: + a = const.default_options() + b = const.default_options() + a["warmth_band_limits"].append(99.0) + a["cold_sensitivity_offset"] = 2 + # Mutating one copy must not affect another copy nor the template. + assert b["warmth_band_limits"] == [0.0, 8.0, 15.0, 22.0] + assert b["cold_sensitivity_offset"] == 0 + assert const.OPTIONS_DEFAULTS["warmth_band_limits"] == [0.0, 8.0, 15.0, 22.0] + + +def test_to_redact_uses_real_key_names() -> None: + # AD-5: redaction constant must match the real stored key names. + assert const.TO_REDACT == {"llm_api_key", "weather_entity_id"} + + +def test_timeouts_and_urls_present() -> None: + assert const.FORECAST_TIMEOUT_S == 10 + assert const.LLM_TIMEOUT_S == 18 + assert const.LLM_MAX_RESPONSE_BYTES == 64 * 1024 + assert const.OPENAI_URL.startswith("https://api.openai.com/") + assert const.ANTHROPIC_URL.startswith("https://api.anthropic.com/") + assert const.ANTHROPIC_VERSION == "2023-06-01" diff --git a/tests/test_layering.py b/tests/test_layering.py new file mode 100644 index 0000000..a9e2dd5 --- /dev/null +++ b/tests/test_layering.py @@ -0,0 +1,62 @@ +"""Story 1.1 — architectural layering guard (AD-1). + +The pure core under ``logic/`` must never import Home Assistant (nor ``dt_util``). +This is a static AST scan of import statements, so it holds even though the test +environment has Home Assistant installed, and it does not false-positive on the +strings "import homeassistant" appearing inside docstrings or data. +""" +from __future__ import annotations + +import ast +import pathlib + +LOGIC_DIR = ( + pathlib.Path(__file__).parent.parent + / "custom_components" + / "what_to_wear" + / "logic" +) + + +def _forbidden_imports(source: str) -> list[str]: + """Return the offending imported module names in ``source`` (AST-based).""" + offenders: list[str] = [] + tree = ast.parse(source) + for node in ast.walk(tree): + names: list[str] = [] + if isinstance(node, ast.Import): + names = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom): + # module is None for "from . import x"; treat as empty + names = [node.module or ""] + names += [alias.name for alias in node.names] + for name in names: + root = name.split(".")[0] + if root == "homeassistant" or name == "dt_util" or "dt_util" in name: + offenders.append(name) + return offenders + + +def test_logic_has_no_homeassistant_imports() -> None: + py_files = list(LOGIC_DIR.rglob("*.py")) + assert py_files, "logic/ must contain Python modules" + offenders: dict[str, list[str]] = {} + for path in py_files: + found = _forbidden_imports(path.read_text(encoding="utf-8")) + if found: + offenders[str(path.relative_to(LOGIC_DIR.parent))] = found + assert not offenders, f"logic/ must not import homeassistant/dt_util: {offenders}" + + +def test_guard_catches_violations_and_ignores_strings() -> None: + # Real imports are caught. + assert _forbidden_imports("import homeassistant\n") + assert _forbidden_imports("from homeassistant.core import HomeAssistant\n") + assert _forbidden_imports("from homeassistant.util import dt as dt_util\n") + assert _forbidden_imports("import homeassistant.util.dt as dt_util\n") + # Stdlib imports are fine. + assert not _forbidden_imports("import datetime\n") + assert not _forbidden_imports("from zoneinfo import ZoneInfo\n") + # A docstring merely *mentioning* the words is NOT a violation (no false positive). + assert not _forbidden_imports('"""We must never import homeassistant here."""\n') + assert not _forbidden_imports('X = "from homeassistant.core import HomeAssistant"\n')