diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 500f21bb..b5f5ae62 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,6 +8,35 @@ Unreleased **BREAKING CHANGES**: Public API surface trimmed and dead code removed. These changes require a major version bump. +Modernization (Python 3.14) +--------------------------- +- **Removed** ``from __future__ import annotations`` **project-wide** + (57 files): redundant on a Python >=3.14-only package where lazy + annotation evaluation (PEP 649) is the default. +- **Adopted additional ruff rule sets**: ``RUF`` (Ruff-specific), + ``DTZ`` (naive datetime), ``PTH`` (pathlib), ``ASYNC``, and ``PERF``, + with documented ignores for intentional patterns (grouped ``__all__`` + lists, unicode degree signs, public ``timeout`` parameters). Fixes + applied for all resulting findings, including a timezone-naive + timestamp in CSV exports (now timezone-aware local time), + ``Path.open()`` usage, ``itertools.pairwise()`` in the CLI range + collapser, a ``ClassVar`` annotation on the capability map, and + dangling ``asyncio.create_task`` references in tests. +- **PeriodicRequestType is now a StrEnum** (was a plain ``Enum`` with + string values), matching the enum style used elsewhere. +- **Event payload dataclasses use** ``slots=True``: the frozen event + dataclasses in ``mqtt_events.py`` and ``events.EventListener`` are + created per state change; slots reduce their memory footprint. +- **match statement** for the periodic request-type dispatch. + +Testing +------- +- New unit tests for previously untested modules: + ``topic_builder.py`` (full topic schema), ``field_factory.py`` + (metadata defaults, overrides, merge semantics), and + ``models/_converters.py`` (unit-preference conversions and + round-trips). + Removed ------- - **Deprecated ``.control`` property**: removed diff --git a/examples/advanced/mqtt_diagnostics.py b/examples/advanced/mqtt_diagnostics.py index 5a96d8b9..cd4eed69 100755 --- a/examples/advanced/mqtt_diagnostics.py +++ b/examples/advanced/mqtt_diagnostics.py @@ -84,7 +84,7 @@ async def export_diagnostics(self, interval: float = 300.0) -> None: output_file = self.output_dir / f"diagnostics_{timestamp}.json" json_data = self.diagnostics.export_json() - with open(output_file, "w") as f: + with output_file.open("w") as f: f.write(json_data) _logger.info(f"Exported diagnostics to {output_file}") @@ -276,7 +276,7 @@ async def run_example( _logger.info("Exporting final diagnostics...") timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") final_file = self.output_dir / f"diagnostics_final_{timestamp}.json" - with open(final_file, "w") as f: + with final_file.open("w") as f: f.write(self.diagnostics.export_json()) _logger.info(f"Final diagnostics saved to {final_file}") diff --git a/examples/mask.py b/examples/mask.py index 94628f57..60685469 100644 --- a/examples/mask.py +++ b/examples/mask.py @@ -4,8 +4,6 @@ these helpers; if that import fails we leave a small fallback in each script. """ -from __future__ import annotations - import re diff --git a/pyproject.toml b/pyproject.toml index 5f056eab..37c1052f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,11 +57,30 @@ select = [ "C4", # flake8-comprehensions "SIM", # flake8-simplify "S", # flake8-bandit (security) + "RUF", # Ruff-specific rules (incl. unused noqa) + "DTZ", # flake8-datetimez (naive datetime usage) + "PTH", # flake8-use-pathlib + "ASYNC", # flake8-async + "PERF", # perflint ] ignore = [ "E203", # whitespace before ':' (conflicts with black/ruff format) "B904", # raise from - will be addressed in a future update + # Async functions taking a `timeout:` parameter are a deliberate, + # documented public API of this library (e.g. wait_for, command + # helpers); switching callers to asyncio.timeout() would be a + # breaking change with no behavioral benefit. + "ASYNC109", + # Degree signs, en-dashes, and multiplication signs in docstrings + # and user-facing strings are intentional (temperature units, + # ranges), not typos of ASCII look-alikes. + "RUF001", + "RUF002", + "RUF003", + # __all__ lists are deliberately grouped by category with comments, + # not sorted alphabetically. + "RUF022", ] # Allow autofix for all enabled rules (when `--fix` is provided) diff --git a/src/nwp500/__init__.py b/src/nwp500/__init__.py index 01ce3925..0a0b0812 100644 --- a/src/nwp500/__init__.py +++ b/src/nwp500/__init__.py @@ -4,8 +4,6 @@ communication for NWP500 heat pump water heaters. """ -from __future__ import annotations - from importlib.metadata import ( PackageNotFoundError, version, diff --git a/src/nwp500/_base.py b/src/nwp500/_base.py index faa06742..298aea28 100644 --- a/src/nwp500/_base.py +++ b/src/nwp500/_base.py @@ -5,8 +5,6 @@ protocol models share a single base class. """ -from __future__ import annotations - from typing import Any from pydantic import BaseModel, ConfigDict diff --git a/src/nwp500/api_client.py b/src/nwp500/api_client.py index b55c187e..c48f7651 100644 --- a/src/nwp500/api_client.py +++ b/src/nwp500/api_client.py @@ -4,8 +4,6 @@ This module provides an async HTTP client for device management and control. """ -from __future__ import annotations - import logging from typing import Any, Self @@ -231,7 +229,7 @@ async def _make_request( except aiohttp.ClientError as e: _logger.error(f"Network error: {e}") - raise APIError(f"Network error: {str(e)}") from e + raise APIError(f"Network error: {e!s}") from e # Device Management Endpoints diff --git a/src/nwp500/auth.py b/src/nwp500/auth.py index 84dab355..e38916c8 100644 --- a/src/nwp500/auth.py +++ b/src/nwp500/auth.py @@ -11,8 +11,6 @@ 4. Refresh tokens when accessToken expires """ -from __future__ import annotations - import asyncio import json import logging @@ -506,14 +504,12 @@ async def sign_in( except aiohttp.ClientError as e: _logger.error(f"Network error during sign-in: {e}") raise AuthenticationError( - f"Network error: {str(e)}", + f"Network error: {e!s}", retriable=True, ) from e except (KeyError, ValueError, json.JSONDecodeError) as e: _logger.error(f"Failed to parse authentication response: {e}") - raise AuthenticationError( - f"Invalid response format: {str(e)}" - ) from e + raise AuthenticationError(f"Invalid response format: {e!s}") from e async def refresh_token( self, refresh_token: str | None = None @@ -641,12 +637,12 @@ async def _refresh_token_unlocked( except aiohttp.ClientError as e: _logger.error(f"Network error during token refresh: {e}") raise TokenRefreshError( - f"Network error: {str(e)}", + f"Network error: {e!s}", retriable=True, ) from e except (KeyError, ValueError, json.JSONDecodeError) as e: _logger.error(f"Failed to parse refresh response: {e}") - raise TokenRefreshError(f"Invalid response format: {str(e)}") from e + raise TokenRefreshError(f"Invalid response format: {e!s}") from e async def re_authenticate(self) -> AuthenticationResponse: """ diff --git a/src/nwp500/cli/__init__.py b/src/nwp500/cli/__init__.py index 62ad37c2..ca5d6239 100644 --- a/src/nwp500/cli/__init__.py +++ b/src/nwp500/cli/__init__.py @@ -1,7 +1,5 @@ """CLI package for nwp500-python.""" -from __future__ import annotations - from .__main__ import run from .handlers import ( handle_device_info_request, diff --git a/src/nwp500/cli/__main__.py b/src/nwp500/cli/__main__.py index 21c584ba..7d6c7fae 100644 --- a/src/nwp500/cli/__main__.py +++ b/src/nwp500/cli/__main__.py @@ -1,7 +1,5 @@ """Navien Water Heater Control CLI - Main Entry Point.""" -from __future__ import annotations - import asyncio import functools import logging diff --git a/src/nwp500/cli/handlers.py b/src/nwp500/cli/handlers.py index c94b0449..3bcc2e3d 100644 --- a/src/nwp500/cli/handlers.py +++ b/src/nwp500/cli/handlers.py @@ -1,7 +1,5 @@ """Command handlers for CLI operations.""" -from __future__ import annotations - import asyncio import json import logging diff --git a/src/nwp500/cli/monitoring.py b/src/nwp500/cli/monitoring.py index 51c90868..37128197 100644 --- a/src/nwp500/cli/monitoring.py +++ b/src/nwp500/cli/monitoring.py @@ -1,7 +1,5 @@ """Monitoring and periodic status polling.""" -from __future__ import annotations - import asyncio import logging diff --git a/src/nwp500/cli/output_formatters.py b/src/nwp500/cli/output_formatters.py index 5652fd7f..bb5fde53 100644 --- a/src/nwp500/cli/output_formatters.py +++ b/src/nwp500/cli/output_formatters.py @@ -1,7 +1,5 @@ """Output formatting utilities for CLI (CSV, JSON).""" -from __future__ import annotations - import csv import json import logging @@ -369,13 +367,14 @@ def write_status_to_csv(file_path: str, status: DeviceStatus) -> None: # Convert status to dict (enums are already converted to names) status_dict = status.model_dump() - # Add a timestamp to the beginning of the data - status_dict["timestamp"] = datetime.now().isoformat() + # Add a timestamp to the beginning of the data (timezone-aware, + # in the local timezone) + status_dict["timestamp"] = datetime.now().astimezone().isoformat() # Check if file exists to determine if we need to write the header file_exists = Path(file_path).exists() - with open(file_path, "a", newline="") as csvfile: + with Path(file_path).open("a", newline="") as csvfile: # Get the field names from the dict keys fieldnames = list(status_dict.keys()) writer = csv.DictWriter(csvfile, fieldnames=fieldnames) diff --git a/src/nwp500/cli/rich_output.py b/src/nwp500/cli/rich_output.py index c9099f3c..fed9d21c 100644 --- a/src/nwp500/cli/rich_output.py +++ b/src/nwp500/cli/rich_output.py @@ -1,7 +1,6 @@ """Rich-enhanced output formatting with graceful fallback.""" -from __future__ import annotations - +import itertools import json import logging import os @@ -142,7 +141,7 @@ def _collapse_ranges( # Build groups of consecutive items groups: list[list[Any]] = [[items[0]]] - for prev, curr in zip(items, items[1:], strict=False): + for prev, curr in itertools.pairwise(items): if isinstance(prev, int): consecutive = (curr - prev) == 1 or ( prev == cycle_size and curr == 1 diff --git a/src/nwp500/cli/token_storage.py b/src/nwp500/cli/token_storage.py index 6bb2a489..7bdfcaad 100644 --- a/src/nwp500/cli/token_storage.py +++ b/src/nwp500/cli/token_storage.py @@ -1,7 +1,5 @@ """Token storage and management for CLI authentication.""" -from __future__ import annotations - import json import logging import os @@ -47,7 +45,7 @@ def load_tokens() -> tuple[AuthTokens | None, str | None]: if not TOKEN_FILE.exists(): return None, None try: - with open(TOKEN_FILE) as f: + with TOKEN_FILE.open() as f: data = json.load(f) email = data.get("email") if not email: diff --git a/src/nwp500/command_decorators.py b/src/nwp500/command_decorators.py index 56ee072b..fbc28843 100644 --- a/src/nwp500/command_decorators.py +++ b/src/nwp500/command_decorators.py @@ -4,8 +4,6 @@ before command execution, preventing unsupported commands from being sent. """ -from __future__ import annotations - import functools import inspect import logging @@ -84,7 +82,7 @@ async def async_wrapper( # Wrap other errors (timeouts, connection issues, etc) raise DeviceCapabilityError( feature, - f"Cannot execute {func.__name__}: {str(e)}", + f"Cannot execute {func.__name__}: {e!s}", ) from e if cached_features is None: diff --git a/src/nwp500/config.py b/src/nwp500/config.py index fa22ad77..97d825da 100644 --- a/src/nwp500/config.py +++ b/src/nwp500/config.py @@ -1,7 +1,5 @@ """Configuration for the Navien API client.""" -from __future__ import annotations - API_BASE_URL = "https://nlus.naviensmartcontrol.com/api/v2.1" SIGN_IN_ENDPOINT = "/user/sign-in" REFRESH_ENDPOINT = "/auth/refresh" diff --git a/src/nwp500/converters.py b/src/nwp500/converters.py index d9ec1167..01dec4ee 100644 --- a/src/nwp500/converters.py +++ b/src/nwp500/converters.py @@ -7,8 +7,6 @@ See docs/protocol/quick_reference.rst for comprehensive protocol details. """ -from __future__ import annotations - from collections.abc import Callable from typing import Any diff --git a/src/nwp500/device_capabilities.py b/src/nwp500/device_capabilities.py index d907ddf1..088a8204 100644 --- a/src/nwp500/device_capabilities.py +++ b/src/nwp500/device_capabilities.py @@ -6,10 +6,8 @@ individual checker functions. """ -from __future__ import annotations - from collections.abc import Callable -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar from .exceptions import DeviceCapabilityError @@ -34,7 +32,7 @@ class MqttDeviceCapabilityChecker: # Map of controllable features to their check functions # Capability names MUST match DeviceFeature attribute names exactly # for traceability: capability name -> DeviceFeature.{name} - _CAPABILITY_MAP: dict[str, CapabilityCheckFn] = { + _CAPABILITY_MAP: ClassVar[dict[str, CapabilityCheckFn]] = { "power_use": lambda f: bool(f.power_use), "dhw_use": lambda f: bool(f.dhw_use), "dhw_temperature_setting_use": lambda f: _check_dhw_temperature_control( diff --git a/src/nwp500/device_info_cache.py b/src/nwp500/device_info_cache.py index a1f5865c..4d3e3b43 100644 --- a/src/nwp500/device_info_cache.py +++ b/src/nwp500/device_info_cache.py @@ -4,8 +4,6 @@ with automatic periodic updates to keep data synchronized with the device. """ -from __future__ import annotations - import asyncio import logging from datetime import UTC, datetime, timedelta diff --git a/src/nwp500/encoding.py b/src/nwp500/encoding.py index 85203270..c7f75165 100644 --- a/src/nwp500/encoding.py +++ b/src/nwp500/encoding.py @@ -6,8 +6,6 @@ These utilities are used by both the API client and MQTT client. """ -from __future__ import annotations - import logging from collections.abc import Iterable from decimal import ROUND_HALF_UP, Decimal diff --git a/src/nwp500/enums.py b/src/nwp500/enums.py index 7899f074..ae726672 100644 --- a/src/nwp500/enums.py +++ b/src/nwp500/enums.py @@ -7,8 +7,6 @@ See docs/protocol/quick_reference.rst for comprehensive protocol details. """ -from __future__ import annotations - from enum import IntEnum, StrEnum # ============================================================================ diff --git a/src/nwp500/events.py b/src/nwp500/events.py index c061f39f..b4d0ed43 100644 --- a/src/nwp500/events.py +++ b/src/nwp500/events.py @@ -6,8 +6,6 @@ detection. """ -from __future__ import annotations - import asyncio import inspect import logging @@ -23,7 +21,7 @@ _logger = logging.getLogger(__name__) -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class EventListener: """Represents a registered event listener.""" diff --git a/src/nwp500/exceptions.py b/src/nwp500/exceptions.py index ab861efd..c6a57d91 100644 --- a/src/nwp500/exceptions.py +++ b/src/nwp500/exceptions.py @@ -60,8 +60,6 @@ # handle other validation errors """ -from __future__ import annotations - from typing import Any __author__ = "Emmanuel Levijarvi" diff --git a/src/nwp500/factory.py b/src/nwp500/factory.py index 48526262..378b2bd2 100644 --- a/src/nwp500/factory.py +++ b/src/nwp500/factory.py @@ -18,8 +18,6 @@ ... devices = await api.list_devices() """ -from __future__ import annotations - import asyncio from .api_client import NavienAPIClient diff --git a/src/nwp500/field_factory.py b/src/nwp500/field_factory.py index 234e9b3f..51e9498c 100644 --- a/src/nwp500/field_factory.py +++ b/src/nwp500/field_factory.py @@ -20,8 +20,6 @@ ... temp: float = temperature_field("DHW Temperature", unit="°F") """ -from __future__ import annotations - from typing import Any, cast from pydantic import Field diff --git a/src/nwp500/models/__init__.py b/src/nwp500/models/__init__.py index e23c9cac..2311709e 100644 --- a/src/nwp500/models/__init__.py +++ b/src/nwp500/models/__init__.py @@ -6,8 +6,6 @@ These models are based on the MQTT message formats and API responses. """ -from __future__ import annotations - from .._base import NavienBaseModel from ._converters import ( fahrenheit_to_half_celsius, diff --git a/src/nwp500/models/_converters.py b/src/nwp500/models/_converters.py index b24b572f..c19eae97 100644 --- a/src/nwp500/models/_converters.py +++ b/src/nwp500/models/_converters.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from ..temperature import HalfCelsius from ..unit_system import get_unit_system diff --git a/src/nwp500/models/device.py b/src/nwp500/models/device.py index ae7a501f..3538743f 100644 --- a/src/nwp500/models/device.py +++ b/src/nwp500/models/device.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Annotated, Self from pydantic import BeforeValidator diff --git a/src/nwp500/models/energy.py b/src/nwp500/models/energy.py index d2410bfa..9d3801a7 100644 --- a/src/nwp500/models/energy.py +++ b/src/nwp500/models/energy.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from pydantic import Field from .._base import NavienBaseModel diff --git a/src/nwp500/models/feature.py b/src/nwp500/models/feature.py index 0dc56094..36bd7d7a 100644 --- a/src/nwp500/models/feature.py +++ b/src/nwp500/models/feature.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Annotated from pydantic import BeforeValidator, Field, computed_field diff --git a/src/nwp500/models/mqtt_models.py b/src/nwp500/models/mqtt_models.py index 151d38f2..50068b97 100644 --- a/src/nwp500/models/mqtt_models.py +++ b/src/nwp500/models/mqtt_models.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Any from pydantic import Field diff --git a/src/nwp500/models/schedule.py b/src/nwp500/models/schedule.py index 7666d7f4..6483dcb7 100644 --- a/src/nwp500/models/schedule.py +++ b/src/nwp500/models/schedule.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Any, cast from pydantic import ConfigDict, Field, computed_field, model_validator diff --git a/src/nwp500/models/status.py b/src/nwp500/models/status.py index d436d608..7865693f 100644 --- a/src/nwp500/models/status.py +++ b/src/nwp500/models/status.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Annotated from pydantic import BeforeValidator, Field, computed_field diff --git a/src/nwp500/models/tou.py b/src/nwp500/models/tou.py index 5f858856..65009e66 100644 --- a/src/nwp500/models/tou.py +++ b/src/nwp500/models/tou.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Any, cast from pydantic import ConfigDict, Field, computed_field, model_validator diff --git a/src/nwp500/mqtt/__init__.py b/src/nwp500/mqtt/__init__.py index 2a82fd3a..2a0d2058 100644 --- a/src/nwp500/mqtt/__init__.py +++ b/src/nwp500/mqtt/__init__.py @@ -11,8 +11,6 @@ - MqttMetrics, ConnectionDropEvent, ConnectionEvent: Diagnostic types """ -from __future__ import annotations - from .client import NavienMqttClient from .diagnostics import ( ConnectionDropEvent, diff --git a/src/nwp500/mqtt/client.py b/src/nwp500/mqtt/client.py index 938bac51..218bdf8a 100644 --- a/src/nwp500/mqtt/client.py +++ b/src/nwp500/mqtt/client.py @@ -9,8 +9,6 @@ the authentication flow. """ -from __future__ import annotations - import asyncio import concurrent.futures import logging diff --git a/src/nwp500/mqtt/command_queue.py b/src/nwp500/mqtt/command_queue.py index cf88eca5..cd918759 100644 --- a/src/nwp500/mqtt/command_queue.py +++ b/src/nwp500/mqtt/command_queue.py @@ -5,8 +5,6 @@ and automatically sends them when the connection is restored. """ -from __future__ import annotations - import logging from collections import deque from collections.abc import Callable diff --git a/src/nwp500/mqtt/connection.py b/src/nwp500/mqtt/connection.py index 61d2af5d..2bc3f4f4 100644 --- a/src/nwp500/mqtt/connection.py +++ b/src/nwp500/mqtt/connection.py @@ -6,8 +6,6 @@ including credential management and connection state tracking. """ -from __future__ import annotations - import asyncio import concurrent.futures import json diff --git a/src/nwp500/mqtt/control.py b/src/nwp500/mqtt/control.py index 038abdd7..79d82ff3 100644 --- a/src/nwp500/mqtt/control.py +++ b/src/nwp500/mqtt/control.py @@ -17,8 +17,6 @@ - Recirculation pump control and scheduling """ -from __future__ import annotations - import logging from collections.abc import Awaitable, Callable, Sequence from datetime import UTC, datetime diff --git a/src/nwp500/mqtt/diagnostics.py b/src/nwp500/mqtt/diagnostics.py index 4df6a328..dc0c0c55 100644 --- a/src/nwp500/mqtt/diagnostics.py +++ b/src/nwp500/mqtt/diagnostics.py @@ -8,8 +8,6 @@ - Client-side configuration issues (insufficient keep-alive, poor backoff) """ -from __future__ import annotations - import json import logging import time diff --git a/src/nwp500/mqtt/periodic.py b/src/nwp500/mqtt/periodic.py index 7ead5186..537eacfd 100644 --- a/src/nwp500/mqtt/periodic.py +++ b/src/nwp500/mqtt/periodic.py @@ -9,8 +9,6 @@ - Per-device, per-type task management """ -from __future__ import annotations - import asyncio import contextlib import logging @@ -170,10 +168,11 @@ async def periodic_request() -> None: consecutive_skips = 0 # Send appropriate request type - if request_type == PeriodicRequestType.DEVICE_INFO: - await self._request_device_info(device) - elif request_type == PeriodicRequestType.DEVICE_STATUS: - await self._request_device_status(device) + match request_type: + case PeriodicRequestType.DEVICE_INFO: + await self._request_device_info(device) + case PeriodicRequestType.DEVICE_STATUS: + await self._request_device_status(device) _logger.debug( "Sent periodic %s request for %s", diff --git a/src/nwp500/mqtt/reconnection.py b/src/nwp500/mqtt/reconnection.py index 732902ee..89919b1c 100644 --- a/src/nwp500/mqtt/reconnection.py +++ b/src/nwp500/mqtt/reconnection.py @@ -5,8 +5,6 @@ the MQTT connection is interrupted. """ -from __future__ import annotations - import asyncio import contextlib import logging diff --git a/src/nwp500/mqtt/state_tracker.py b/src/nwp500/mqtt/state_tracker.py index ea7e8a40..f0446d3f 100644 --- a/src/nwp500/mqtt/state_tracker.py +++ b/src/nwp500/mqtt/state_tracker.py @@ -5,8 +5,6 @@ errors). """ -from __future__ import annotations - import logging from ..events import EventEmitter diff --git a/src/nwp500/mqtt/subscriptions.py b/src/nwp500/mqtt/subscriptions.py index 6ab0cb38..b64ce165 100644 --- a/src/nwp500/mqtt/subscriptions.py +++ b/src/nwp500/mqtt/subscriptions.py @@ -9,8 +9,6 @@ - State change detection and event emission """ -from __future__ import annotations - import asyncio import json import logging diff --git a/src/nwp500/mqtt/utils.py b/src/nwp500/mqtt/utils.py index 12f75698..91fc46a0 100644 --- a/src/nwp500/mqtt/utils.py +++ b/src/nwp500/mqtt/utils.py @@ -5,13 +5,11 @@ configuration classes, and common data structures used across MQTT modules. """ -from __future__ import annotations - import re import uuid from dataclasses import dataclass from datetime import datetime -from enum import Enum +from enum import StrEnum from typing import Any from awscrt import mqtt @@ -292,7 +290,7 @@ class QueuedCommand: timestamp: datetime -class PeriodicRequestType(Enum): +class PeriodicRequestType(StrEnum): """Types of periodic requests that can be sent. Attributes: diff --git a/src/nwp500/mqtt_events.py b/src/nwp500/mqtt_events.py index bdd6257c..53c3e735 100644 --- a/src/nwp500/mqtt_events.py +++ b/src/nwp500/mqtt_events.py @@ -28,8 +28,6 @@ def on_temperature_changed(event): print(event_name) """ -from __future__ import annotations - from dataclasses import dataclass from typing import TYPE_CHECKING, cast @@ -38,7 +36,7 @@ def on_temperature_changed(event): from .models import DeviceFeature, DeviceStatus -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class ConnectionInterruptedEvent: """Emitted when MQTT connection is interrupted. @@ -49,7 +47,7 @@ class ConnectionInterruptedEvent: error: Exception -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class ConnectionResumedEvent: """Emitted when MQTT connection is resumed after interruption. @@ -62,7 +60,7 @@ class ConnectionResumedEvent: session_present: bool -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class StatusReceivedEvent: """Emitted when a device status message is received. @@ -75,7 +73,7 @@ class StatusReceivedEvent: status: DeviceStatus -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class TemperatureChangedEvent: """Emitted when the DHW temperature changes. @@ -92,7 +90,7 @@ class TemperatureChangedEvent: new_temperature: float -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class ModeChangedEvent: """Emitted when the device operation mode changes. @@ -107,7 +105,7 @@ class ModeChangedEvent: new_mode: CurrentOperationMode -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class PowerChangedEvent: """Emitted when instantaneous power consumption changes. @@ -122,7 +120,7 @@ class PowerChangedEvent: new_power: float -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class HeatingStartedEvent: """Emitted when device transitions from idle to heating. @@ -135,7 +133,7 @@ class HeatingStartedEvent: status: DeviceStatus -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class HeatingStoppedEvent: """Emitted when device transitions from heating to idle. @@ -148,7 +146,7 @@ class HeatingStoppedEvent: status: DeviceStatus -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class ErrorDetectedEvent: """Emitted when a device error is first detected. @@ -163,7 +161,7 @@ class ErrorDetectedEvent: status: DeviceStatus -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class ErrorClearedEvent: """Emitted when a device error is resolved. @@ -176,7 +174,7 @@ class ErrorClearedEvent: error_code: ErrorCode -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class FeatureReceivedEvent: """Emitted when device feature information is received. diff --git a/src/nwp500/openei.py b/src/nwp500/openei.py index 827af8dc..d7485553 100644 --- a/src/nwp500/openei.py +++ b/src/nwp500/openei.py @@ -8,8 +8,6 @@ API key can be obtained for free at https://openei.org/services/api/signup/ """ -from __future__ import annotations - import logging import os from typing import Any diff --git a/src/nwp500/reservations.py b/src/nwp500/reservations.py index 5dd6172e..28bbe455 100644 --- a/src/nwp500/reservations.py +++ b/src/nwp500/reservations.py @@ -9,8 +9,6 @@ All functions are ``async`` and require a connected :class:`NavienMqttClient`. """ -from __future__ import annotations - import asyncio import logging from collections.abc import Sequence diff --git a/src/nwp500/temperature.py b/src/nwp500/temperature.py index d7276cac..0e043f5e 100644 --- a/src/nwp500/temperature.py +++ b/src/nwp500/temperature.py @@ -7,8 +7,6 @@ All values are converted to preferred unit based on device preference. """ -from __future__ import annotations - import math from typing import ClassVar, Self diff --git a/src/nwp500/topic_builder.py b/src/nwp500/topic_builder.py index 41d92596..b9a5677a 100644 --- a/src/nwp500/topic_builder.py +++ b/src/nwp500/topic_builder.py @@ -12,8 +12,6 @@ Event: evt/{device_type}/navilink-{mac}/{suffix} """ -from __future__ import annotations - class MqttTopicBuilder: """Helper to construct standard MQTT topics for Navien devices.""" diff --git a/src/nwp500/unit_system.py b/src/nwp500/unit_system.py index 721311cf..0e0770ea 100644 --- a/src/nwp500/unit_system.py +++ b/src/nwp500/unit_system.py @@ -11,8 +11,6 @@ run outside the task that configured it. """ -from __future__ import annotations - import logging from typing import Literal diff --git a/src/nwp500/utils.py b/src/nwp500/utils.py index 964db2e1..5f1be094 100644 --- a/src/nwp500/utils.py +++ b/src/nwp500/utils.py @@ -5,8 +5,6 @@ including performance monitoring decorators and helper functions. """ -from __future__ import annotations - import functools import inspect import logging diff --git a/tests/test_bug_fixes.py b/tests/test_bug_fixes.py index 08765eae..656cdfbe 100644 --- a/tests/test_bug_fixes.py +++ b/tests/test_bug_fixes.py @@ -1,7 +1,5 @@ """Tests for bug fixes: diagnostics, config validation, encoding, cache.""" -from __future__ import annotations - import asyncio import json from unittest.mock import patch @@ -97,8 +95,9 @@ async def emit_soon(): await asyncio.sleep(0.01) await emitter.emit("test_event", "data") - asyncio.create_task(emit_soon()) + task = asyncio.create_task(emit_soon()) result = await emitter.wait_for("test_event", timeout=1.0) + await task assert result == ("data",) diff --git a/tests/test_events.py b/tests/test_events.py index ca50e2e9..03bd1ee1 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -245,10 +245,11 @@ async def emit_later(): await emitter.emit("test_event", "test_value") # Start emitting in background - asyncio.create_task(emit_later()) + task = asyncio.create_task(emit_later()) # Wait for event result = await emitter.wait_for("test_event", timeout=1.0) + await task assert result == ("test_value",) diff --git a/tests/test_mqtt_clean_session_resume.py b/tests/test_mqtt_clean_session_resume.py index cb92a714..80df6016 100644 --- a/tests/test_mqtt_clean_session_resume.py +++ b/tests/test_mqtt_clean_session_resume.py @@ -1,7 +1,5 @@ """Tests for MQTT client clean session reconnection handling.""" -from __future__ import annotations - from unittest.mock import AsyncMock, MagicMock, patch import pytest diff --git a/tests/test_mqtt_reconnection.py b/tests/test_mqtt_reconnection.py index d5f0382f..45ca4eee 100644 --- a/tests/test_mqtt_reconnection.py +++ b/tests/test_mqtt_reconnection.py @@ -1,7 +1,5 @@ """Tests for MQTT reconnection: old connection cleanup.""" -from __future__ import annotations - import concurrent.futures from unittest.mock import MagicMock diff --git a/tests/test_mqtt_reconnection_storm.py b/tests/test_mqtt_reconnection_storm.py index 3b734825..ae4cb026 100644 --- a/tests/test_mqtt_reconnection_storm.py +++ b/tests/test_mqtt_reconnection_storm.py @@ -35,8 +35,6 @@ Task operations are safe. """ -from __future__ import annotations - import asyncio from unittest.mock import AsyncMock, MagicMock, patch @@ -397,7 +395,7 @@ def test_actively_reconnecting_initialises_false(self): assert client._actively_reconnecting is False @pytest.mark.asyncio(loop_scope="function") - async def test_interrupted_internal_skips_handler_when_flag_set( # noqa: E501 + async def test_interrupted_internal_skips_handler_when_flag_set( self, ): """ diff --git a/tests/test_multi_device.py b/tests/test_multi_device.py index f48101cd..e681a74b 100644 --- a/tests/test_multi_device.py +++ b/tests/test_multi_device.py @@ -75,7 +75,7 @@ async def test_state_tracker_emits_with_mac(): await tracker.process(mac1, status1_v2) assert emitter.emit.call_count == 1 - args, kwargs = emitter.emit.call_args + args, _kwargs = emitter.emit.call_args assert args[0] == "temperature_changed" event = args[1] assert isinstance(event, TemperatureChangedEvent) @@ -100,7 +100,7 @@ async def test_state_tracker_emits_with_mac(): # Should have emitted another event for mac2 assert emitter.emit.call_count == 2 - args, kwargs = emitter.emit.call_args + args, _kwargs = emitter.emit.call_args event = args[1] assert event.device_mac == mac2 diff --git a/tests/test_protocol_correctness.py b/tests/test_protocol_correctness.py index 3c8c67b3..b5b89143 100644 --- a/tests/test_protocol_correctness.py +++ b/tests/test_protocol_correctness.py @@ -10,8 +10,6 @@ - TOU price encoding (half-up rounding, bool rejection) """ -from __future__ import annotations - from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock, MagicMock diff --git a/tests/test_public_api.py b/tests/test_public_api.py index e6438f9e..6dd02b6d 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -1,7 +1,5 @@ """Tests for the public API surface of the nwp500 package.""" -from __future__ import annotations - import nwp500 diff --git a/tests/test_reservations.py b/tests/test_reservations.py index de155012..45bc5b76 100644 --- a/tests/test_reservations.py +++ b/tests/test_reservations.py @@ -1,7 +1,5 @@ """Tests for the nwp500.reservations public helpers.""" -from __future__ import annotations - from typing import Any from unittest.mock import ANY, AsyncMock, MagicMock, patch diff --git a/tests/test_utility_modules.py b/tests/test_utility_modules.py new file mode 100644 index 00000000..f5e699e5 --- /dev/null +++ b/tests/test_utility_modules.py @@ -0,0 +1,149 @@ +"""Tests for previously untested utility modules. + +Covers topic_builder, field_factory, and models/_converters. +""" + +import pytest +from pydantic import BaseModel + +from nwp500.field_factory import ( + energy_field, + power_field, + signal_strength_field, + temperature_field, +) +from nwp500.models._converters import ( + fahrenheit_to_half_celsius, + preferred_to_half_celsius, + reservation_param_to_preferred, +) +from nwp500.topic_builder import MqttTopicBuilder +from nwp500.unit_system import reset_unit_system, set_unit_system + +MAC = "aa:bb:cc:dd:ee:ff" + + +@pytest.fixture(autouse=True) +def _reset_units(): + yield + reset_unit_system() + + +class TestMqttTopicBuilder: + def test_device_topic(self): + assert MqttTopicBuilder.device_topic(MAC) == f"navilink-{MAC}" + + def test_command_topic_default_suffix(self): + assert ( + MqttTopicBuilder.command_topic("52", MAC) + == f"cmd/52/navilink-{MAC}/ctrl" + ) + + def test_command_topic_custom_suffix(self): + assert ( + MqttTopicBuilder.command_topic("52", MAC, "ctrl/rsv/rd") + == f"cmd/52/navilink-{MAC}/ctrl/rsv/rd" + ) + + def test_command_topic_wildcard(self): + assert ( + MqttTopicBuilder.command_topic("52", MAC, "#") + == f"cmd/52/navilink-{MAC}/#" + ) + + def test_response_ack_topic(self): + assert ( + MqttTopicBuilder.response_ack_topic("52", MAC, "client-1") + == f"cmd/52/navilink-{MAC}/client-1/res" + ) + + def test_response_topic(self): + assert ( + MqttTopicBuilder.response_topic("52", "client-1", "rsv/rd") + == "cmd/52/client-1/res/rsv/rd" + ) + + def test_event_topic(self): + assert ( + MqttTopicBuilder.event_topic("52", MAC, "st") + == f"evt/52/navilink-{MAC}/st" + ) + + +class TestFieldFactory: + def test_temperature_field_metadata(self): + class Model(BaseModel): + temp: float = temperature_field("DHW temperature", default=0.0) + + extra = Model.model_fields["temp"].json_schema_extra + assert extra == { + "unit_of_measurement": "°F", + "device_class": "temperature", + "suggested_display_precision": 1, + } + assert Model.model_fields["temp"].description == "DHW temperature" + + def test_temperature_field_custom_unit(self): + class Model(BaseModel): + temp: float = temperature_field("t", unit="°C", default=0.0) + + extra = Model.model_fields["temp"].json_schema_extra + assert extra["unit_of_measurement"] == "°C" + + def test_caller_json_schema_extra_merged(self): + class Model(BaseModel): + temp: float = temperature_field( + "t", + default=0.0, + json_schema_extra={"custom": True, "device_class": "override"}, + ) + + extra = Model.model_fields["temp"].json_schema_extra + assert extra["custom"] is True + assert extra["device_class"] == "override" # caller wins + assert extra["unit_of_measurement"] == "°F" # base preserved + + @pytest.mark.parametrize( + ("factory", "device_class", "unit"), + [ + (signal_strength_field, "signal_strength", "dBm"), + (energy_field, "energy", "kWh"), + (power_field, "power", "W"), + ], + ) + def test_other_factories_metadata(self, factory, device_class, unit): + class Model(BaseModel): + value: float = factory("desc", default=0.0) + + extra = Model.model_fields["value"].json_schema_extra + assert extra["device_class"] == device_class + assert extra["unit_of_measurement"] == unit + + +class TestModelConverters: + def test_fahrenheit_to_half_celsius(self): + assert fahrenheit_to_half_celsius(140.0) == 120 + + def test_preferred_to_half_celsius_us_customary(self): + set_unit_system("us_customary") + assert preferred_to_half_celsius(140.0) == 120 + + def test_preferred_to_half_celsius_metric(self): + set_unit_system("metric") + assert preferred_to_half_celsius(60.0) == 120 + + def test_reservation_param_to_preferred_metric(self): + set_unit_system("metric") + assert reservation_param_to_preferred(120) == 60.0 + + def test_reservation_param_to_preferred_us_customary(self): + set_unit_system("us_customary") + assert reservation_param_to_preferred(120) == 140.0 + + def test_roundtrip_us_customary(self): + set_unit_system("us_customary") + for temp in (100.0, 120.0, 130.0, 140.0, 150.0): + param = preferred_to_half_celsius(temp) + assert reservation_param_to_preferred(param) == pytest.approx( + temp, abs=1.0 + )