11import re
22from abc import ABC , abstractmethod
33from dataclasses import dataclass , field
4- from datetime import datetime , time , timedelta
4+ from datetime import datetime , time , timedelta , tzinfo
55from functools import partial
66from typing import ClassVar , Literal
7+ from zoneinfo import ZoneInfo
78
89import astral
10+ import pytz
911from 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+
1154CONVERTERS = {
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