Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions examples/advanced/mqtt_diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down Expand Up @@ -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}")

Expand Down
2 changes: 0 additions & 2 deletions examples/mask.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
these helpers; if that import fails we leave a small fallback in each script.
"""

from __future__ import annotations

import re


Expand Down
19 changes: 19 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 0 additions & 2 deletions src/nwp500/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
communication for NWP500 heat pump water heaters.
"""

from __future__ import annotations

from importlib.metadata import (
PackageNotFoundError,
version,
Expand Down
2 changes: 0 additions & 2 deletions src/nwp500/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
protocol models share a single base class.
"""

from __future__ import annotations

from typing import Any

from pydantic import BaseModel, ConfigDict
Expand Down
4 changes: 1 addition & 3 deletions src/nwp500/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
12 changes: 4 additions & 8 deletions src/nwp500/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@
4. Refresh tokens when accessToken expires
"""

from __future__ import annotations

import asyncio
import json
import logging
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
"""
Expand Down
2 changes: 0 additions & 2 deletions src/nwp500/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"""CLI package for nwp500-python."""

from __future__ import annotations

from .__main__ import run
from .handlers import (
handle_device_info_request,
Expand Down
2 changes: 0 additions & 2 deletions src/nwp500/cli/__main__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"""Navien Water Heater Control CLI - Main Entry Point."""

from __future__ import annotations

import asyncio
import functools
import logging
Expand Down
2 changes: 0 additions & 2 deletions src/nwp500/cli/handlers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"""Command handlers for CLI operations."""

from __future__ import annotations

import asyncio
import json
import logging
Expand Down
2 changes: 0 additions & 2 deletions src/nwp500/cli/monitoring.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"""Monitoring and periodic status polling."""

from __future__ import annotations

import asyncio
import logging

Expand Down
9 changes: 4 additions & 5 deletions src/nwp500/cli/output_formatters.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"""Output formatting utilities for CLI (CSV, JSON)."""

from __future__ import annotations

import csv
import json
import logging
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 2 additions & 3 deletions src/nwp500/cli/rich_output.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Rich-enhanced output formatting with graceful fallback."""

from __future__ import annotations

import itertools
import json
import logging
import os
Expand Down Expand Up @@ -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
Expand Down
4 changes: 1 addition & 3 deletions src/nwp500/cli/token_storage.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"""Token storage and management for CLI authentication."""

from __future__ import annotations

import json
import logging
import os
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 1 addition & 3 deletions src/nwp500/command_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
before command execution, preventing unsupported commands from being sent.
"""

from __future__ import annotations

import functools
import inspect
import logging
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 0 additions & 2 deletions src/nwp500/config.py
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 0 additions & 2 deletions src/nwp500/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 2 additions & 4 deletions src/nwp500/device_capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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(
Expand Down
2 changes: 0 additions & 2 deletions src/nwp500/device_info_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions src/nwp500/encoding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions src/nwp500/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
See docs/protocol/quick_reference.rst for comprehensive protocol details.
"""

from __future__ import annotations

from enum import IntEnum, StrEnum

# ============================================================================
Expand Down
4 changes: 1 addition & 3 deletions src/nwp500/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
detection.
"""

from __future__ import annotations

import asyncio
import inspect
import logging
Expand All @@ -23,7 +21,7 @@
_logger = logging.getLogger(__name__)


@dataclass(frozen=True)
@dataclass(frozen=True, slots=True)
class EventListener:
"""Represents a registered event listener."""

Expand Down
2 changes: 0 additions & 2 deletions src/nwp500/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,6 @@
# handle other validation errors
"""

from __future__ import annotations

from typing import Any

__author__ = "Emmanuel Levijarvi"
Expand Down
2 changes: 0 additions & 2 deletions src/nwp500/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@
... devices = await api.list_devices()
"""

from __future__ import annotations

import asyncio

from .api_client import NavienAPIClient
Expand Down
2 changes: 0 additions & 2 deletions src/nwp500/field_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading