local_time.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  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 date, 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; none is declared
  43. ``DateTime(timezone=True)`` (test_code_quality.py enforces it). 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 day_zone(name: str | None) -> tzinfo:
  81. """The zone a caller's calendar dates are in: the IANA ``name`` it sent, or
  82. the server's own (``TZ``) when it sent none or one that is not a zone.
  83. The Stats page sends the browser's zone with its dates; read in the
  84. server's instead, a browser away from it saw every day shifted -- for
  85. one in Tokyo on a UTC server, "Today" stayed empty until 09:00 (DB-C12).
  86. """
  87. if name:
  88. try:
  89. return ZoneInfo(name)
  90. # OSError too: on some Python builds a directory name ("America")
  91. # raises PermissionError instead of ZoneInfoNotFoundError.
  92. except (ZoneInfoNotFoundError, ValueError, OSError):
  93. logger.debug("Unknown zone %r for a date filter; using the server's", name)
  94. return local_zone()
  95. def local_date_start(day: date, zone: tzinfo | None = None) -> datetime:
  96. """The start of a calendar date in ``zone`` (the server's by default), as a UTC instant.
  97. What a date filter means: "16 October" is the user's 16 October, not the
  98. UTC one, which in Berlin starts at 02:00 (DB-C12). Built from the date
  99. itself, so a DST change day is its true 23 or 25 hours.
  100. """
  101. try:
  102. return datetime(day.year, day.month, day.day, tzinfo=zone or local_zone()).astimezone(timezone.utc)
  103. except OverflowError:
  104. # 0001-01-01 in a zone ahead of UTC starts before the first datetime:
  105. # an open-ended range must not 500.
  106. return datetime.min.replace(tzinfo=timezone.utc)
  107. def local_date_end(day: date, zone: tzinfo | None = None) -> datetime:
  108. """The last instant of a calendar date in ``zone``, as a UTC instant."""
  109. try:
  110. return local_date_start(day + timedelta(days=1), zone) - timedelta(microseconds=1)
  111. except OverflowError:
  112. # 9999-12-31 has no next day to stop before.
  113. return datetime.max.replace(tzinfo=timezone.utc)
  114. def next_local_hour(now_utc: datetime) -> datetime:
  115. """Return the next top-of-the-hour *local* time, as a UTC instant.
  116. Aligning to the local hour rather than the UTC hour is deliberate: it
  117. guarantees a tick lands exactly on local midnight in every timezone,
  118. including the half- and quarter-hour offsets (India, Nepal, Chatham) where
  119. local midnight is not on a UTC hour boundary at all.
  120. """
  121. tz = local_zone()
  122. local_now = now_utc.astimezone(tz)
  123. local_next = (local_now + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0, fold=0)
  124. return local_next.astimezone(timezone.utc)