local_time.py 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  1. """Local-timezone helpers.
  2. Bambuddy has no timezone *setting* — it takes the container's ``TZ`` env var,
  3. the same value the support package reports. Anything that has to reason about a
  4. calendar day ("today", "yesterday", "run the backup at 03:00") needs this,
  5. because a day boundary computed in UTC rolls over at 01:00 or 02:00 wall-clock
  6. for most of Europe, which is neither what the user sees nor what their smart
  7. plug's own daily counter does.
  8. Lived in ``services/local_backup`` until #2539, when the smart-plug energy
  9. history needed the same day boundary and reaching into another service's private
  10. helper stopped being defensible.
  11. """
  12. from __future__ import annotations
  13. import logging
  14. import os
  15. from datetime import datetime, timedelta, timezone, tzinfo
  16. from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
  17. logger = logging.getLogger(__name__)
  18. def local_zone() -> tzinfo:
  19. """Resolve the local timezone from the ``TZ`` env var.
  20. Falls back to UTC when ``TZ`` is unset or unrecognised, so a missing value
  21. degrades to the legacy behaviour rather than crashing.
  22. On Windows the embedded Python in our installer doesn't carry an IANA tz
  23. database, so ``ZoneInfo(...)`` — including ``ZoneInfo("UTC")`` — raises
  24. ``ZoneInfoNotFoundError`` unless the ``tzdata`` PyPI package is installed.
  25. requirements.txt pins ``tzdata`` on win32, but to stay resilient on installs
  26. that haven't refreshed deps we fall through to the stdlib
  27. ``datetime.timezone.utc`` as a last resort; it satisfies every
  28. ``astimezone`` / ``str()`` call site without needing the IANA DB.
  29. """
  30. tz_name = os.environ.get("TZ", "").strip()
  31. if tz_name:
  32. try:
  33. return ZoneInfo(tz_name)
  34. except ZoneInfoNotFoundError:
  35. logger.warning("Unrecognised TZ env value %r, falling back to UTC", tz_name)
  36. try:
  37. return ZoneInfo("UTC")
  38. except ZoneInfoNotFoundError:
  39. return timezone.utc
  40. def utcnow_naive() -> datetime:
  41. """Current UTC time, tzinfo stripped.
  42. Bambuddy's ``DateTime`` columns are naive and hold UTC; only the few that
  43. genuinely need an offset are declared ``DateTime(timezone=True)``. SQLite
  44. silently tolerates an aware value written to a naive column (its bind
  45. processor reads the fields and drops the offset), which is why aware writes
  46. survived here for so long — but **asyncpg rejects them outright** with
  47. ``DataError: invalid input for query argument``, so on Postgres the write
  48. raises. Use this for anything destined for a naive column.
  49. """
  50. return datetime.now(timezone.utc).replace(tzinfo=None)
  51. def to_naive_utc(dt: datetime | None) -> datetime | None:
  52. """Normalise a datetime to naive UTC for binding against a naive column.
  53. Accepts naive (assumed already UTC) or aware; returns None unchanged.
  54. """
  55. if dt is None:
  56. return None
  57. if dt.tzinfo is None:
  58. return dt
  59. return dt.astimezone(timezone.utc).replace(tzinfo=None)
  60. def local_day_start(now_utc: datetime, *, days_ago: int = 0) -> datetime:
  61. """Return midnight local time, ``days_ago`` days back, as a UTC instant.
  62. ``days_ago=0`` is the midnight that began the current local day; ``1`` is the
  63. one before it. Subtracting whole days from the *local* wall clock rather than
  64. from the UTC instant is what keeps this correct across a DST transition, where
  65. a calendar day is 23 or 25 hours long, not 24.
  66. ``fold=0`` resolves the ambiguous wall-clock hour at DST fall-back to the
  67. earlier instance. The spring-forward gap cannot bite here: the synthesized
  68. time is always midnight, and no timezone in the IANA database skips it.
  69. """
  70. tz = local_zone()
  71. local_now = now_utc.astimezone(tz)
  72. local_midnight = local_now.replace(hour=0, minute=0, second=0, microsecond=0, fold=0)
  73. if days_ago:
  74. # Step back in local days, then re-pin to midnight: (midnight - 24h) can
  75. # land at 23:00 or 01:00 of the previous day across a DST change.
  76. local_midnight = (local_midnight - timedelta(days=days_ago)).replace(
  77. hour=0, minute=0, second=0, microsecond=0, fold=0
  78. )
  79. return local_midnight.astimezone(timezone.utc)
  80. def next_local_hour(now_utc: datetime) -> datetime:
  81. """Return the next top-of-the-hour *local* time, as a UTC instant.
  82. Aligning to the local hour rather than the UTC hour is deliberate: it
  83. guarantees a tick lands exactly on local midnight in every timezone,
  84. including the half- and quarter-hour offsets (India, Nepal, Chatham) where
  85. local midnight is not on a UTC hour boundary at all.
  86. """
  87. tz = local_zone()
  88. local_now = now_utc.astimezone(tz)
  89. local_next = (local_now + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0, fold=0)
  90. return local_next.astimezone(timezone.utc)