Skip to content

Commit a49d10e

Browse files
authored
Merge pull request #2515 from cebtenzzre/fix-time-parsing
parse: fix time zone handling in scheduler time parsing
2 parents 82b18bb + e1b4d0c commit a49d10e

File tree

1 file changed

+54
-8
lines changed

1 file changed

+54
-8
lines changed

appdaemon/parse.py

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,56 @@
11
import re
22
from abc import ABC, abstractmethod
33
from dataclasses import dataclass, field
4-
from datetime import datetime, time, timedelta
4+
from datetime import datetime, time, timedelta, tzinfo
55
from functools import partial
66
from typing import ClassVar, Literal
7+
from zoneinfo import ZoneInfo
78

89
import astral
10+
import pytz
911
from astral.location import Location
1012

13+
14+
def normalize_tz(tz: tzinfo) -> tzinfo:
15+
"""Convert pytz timezone to ZoneInfo for clean stdlib-compatible handling.
16+
17+
pytz timezones don't behave like normal tzinfo implementations and require
18+
special localize()/normalize() calls. By converting to ZoneInfo at the boundary,
19+
we can use standard replace(tzinfo=...) and astimezone() everywhere else.
20+
21+
Args:
22+
tz: Any tzinfo object (pytz, ZoneInfo, or fixed-offset)
23+
24+
Returns:
25+
ZoneInfo if input was pytz with an IANA zone name, otherwise unchanged
26+
"""
27+
if isinstance(tz, pytz.tzinfo.BaseTzInfo) and tz.zone is not None:
28+
return ZoneInfo(tz.zone)
29+
return tz
30+
31+
32+
def localize_naive(naive_dt: datetime, tz: tzinfo) -> datetime:
33+
"""Interpret a naive datetime as wall-clock time in the given timezone.
34+
35+
This normalizes pytz timezones to ZoneInfo first, so we can use standard
36+
replace(tzinfo=...) semantics instead of pytz's localize().
37+
38+
Args:
39+
naive_dt: A naive datetime (no tzinfo)
40+
tz: The timezone to interpret the datetime in
41+
42+
Returns:
43+
A timezone-aware datetime
44+
45+
Raises:
46+
ValueError: If naive_dt already has tzinfo
47+
"""
48+
if naive_dt.tzinfo is not None:
49+
raise ValueError("expected naive datetime")
50+
tz = normalize_tz(tz)
51+
return naive_dt.replace(tzinfo=tz)
52+
53+
1154
CONVERTERS = {
1255
"hour": lambda v: timedelta(hours=v),
1356
"hr": lambda v: timedelta(hours=v),
@@ -267,14 +310,16 @@ def _parse(input_string: str) -> ParsedTimeString | time | datetime:
267310
else:
268311
raise ValueError(f"Invalid time string: {input_string}")
269312

313+
tz = normalize_tz(now.tzinfo)
314+
270315
offset = timedelta()
271316
match _parse(time_str):
272317
case time() as parsed_time:
273-
result = datetime.combine(
318+
naive_dt = datetime.combine(
274319
(now + timedelta(days=days_offset)).date(),
275320
parsed_time,
276-
tzinfo=now.tzinfo
277321
)
322+
result = localize_naive(naive_dt, tz)
278323
case datetime() as result:
279324
pass
280325
case Now(offset=offset):
@@ -338,14 +383,16 @@ def parse_datetime(
338383

339384
assert isinstance(now, datetime) and now.tzinfo is not None, "Now must be a timezone-aware datetime"
340385

386+
tz = normalize_tz(now.tzinfo)
387+
341388
offset = timedelta()
342389
match input_:
343390
case time() as input_time:
344-
result = datetime.combine(
391+
naive_dt = datetime.combine(
345392
(now + timedelta(days=days_offset)).date(),
346393
input_time,
347-
tzinfo=now.tzinfo
348394
)
395+
result = localize_naive(naive_dt, tz)
349396
case datetime() as result:
350397
result += timedelta(days=days_offset)
351398
case str() as time_str:
@@ -364,10 +411,9 @@ def parse_datetime(
364411

365412
# Make the timezones match for the comparison below
366413
if result.tzinfo is None:
367-
# Just adds the timezone without changing the time values
368-
result = result.replace(tzinfo=now.tzinfo)
414+
result = localize_naive(result, tz)
369415
else:
370-
result = result.astimezone(now.tzinfo)
416+
result = result.astimezone(tz)
371417

372418
# The the days offset is negative, the result can't be forced to today, so set today to False
373419
if days_offset < 0:

0 commit comments

Comments
 (0)