"""Server local-timezone helpers. All time is stored as UTC (no change). This module provides a single, monkeypatch-friendly entry point for converting UTC moments to the server's local timezone — used for business-day boundary calculations (standing charges, effective-from date interpretation, etc.). Design goals ------------ - Zero third-party dependencies (no tzlocal, no pytz). - Testable without depending on CI host timezone: monkeypatch ``local_tz`` to a fixed ``ZoneInfo`` in unit tests. - DST-correct: conversions use ``astimezone(local_tz())`` per instant, so each moment is independently correct even during DST transitions. - Consistent with the existing ``.astimezone()`` pattern already used in ``homeassistant_inbound.py`` and ``poo.py``. Priority for resolving the local timezone ----------------------------------------- 1. ``TZ`` environment variable — ``ZoneInfo(os.environ["TZ"])``. Set ``TZ=Europe/Amsterdam`` in the deployment env for correct NL handling. 2. System local timezone fallback: ``datetime.now().astimezone().tzinfo``. This matches the behaviour callers already relied on implicitly. """ from __future__ import annotations import os from datetime import date, datetime, timezone from typing import TYPE_CHECKING from zoneinfo import ZoneInfo if TYPE_CHECKING: from datetime import tzinfo def local_tz() -> "tzinfo": """Return the server's local timezone. Resolution order: 1. ``TZ`` environment variable (``ZoneInfo(TZ)``). Set ``TZ=Europe/Amsterdam`` in production for correct NL/DST handling. 2. System local timezone via ``datetime.now().astimezone().tzinfo``. **Monkeypatch this function in tests** to get deterministic timezone behaviour regardless of CI host configuration:: monkeypatch.setattr("app.services.timezone.local_tz", lambda: ZoneInfo("Europe/Amsterdam")) """ tz_env = os.environ.get("TZ", "").strip() if tz_env: return ZoneInfo(tz_env) # System fallback — identical to the .astimezone() pattern already used # in homeassistant_inbound.py and poo.py. return datetime.now().astimezone().tzinfo # type: ignore[return-value] def to_local(dt: datetime) -> datetime: """Convert *dt* to the server local timezone. Handles three input cases: - timezone-aware (any zone): converted via ``astimezone``. - timezone-naive: assumed UTC (SQLite read-back convention), then converted. Returns a timezone-aware datetime in ``local_tz()``. """ if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt.astimezone(local_tz()) def local_now() -> datetime: """Return the current moment as a timezone-aware local datetime.""" return datetime.now(local_tz()) def local_date(dt: datetime) -> date: """Return the local calendar date for *dt*. Equivalent to ``to_local(dt).date()``. A distinct helper to make call sites readable. """ return to_local(dt).date() def local_midnight_utc(d: date) -> datetime: """Return the UTC instant that corresponds to local midnight on *d*. Example (Europe/Amsterdam, CEST=UTC+2): local_midnight_utc(date(2026, 6, 25)) → datetime(2026, 6, 24, 22, 0, 0, tzinfo=UTC) This is used to convert a local calendar date back to a UTC boundary, e.g. to compute the start/end of a local day for DB queries. """ tz = local_tz() # Build a naive local datetime at midnight, then localize and convert to UTC. local_midnight = datetime(d.year, d.month, d.day, 0, 0, 0, tzinfo=tz) return local_midnight.astimezone(timezone.utc)