77
88import calendar
99import enum
10- from datetime import datetime , timedelta
10+ import re
11+ from datetime import date , datetime , timedelta , timezone
1112from typing import (
1213 Literal ,
1314 Optional ,
1617from zoneinfo import ZoneInfo
1718
1819try :
20+ from dateutil .parser import parse
1921 from dateutil .relativedelta import relativedelta
2022except ImportError : # pragma: no cove
2123 relativedelta = None
24+ parse = None
2225
2326from . import first
2427
4346 "microsecond" ,
4447)
4548
49+ FrequencyMode = Literal ["1T" , "1H" , "1D" , "1M" , "1Y" ]
50+ FREQUENCY_SET : tuple [str , ...] = ("1T" , "1H" , "1D" , "1M" , "1Y" )
51+ _TIMEZONE_CACHE : dict [int , timezone ] = {0 : timezone .utc }
52+ # Pre-compiled regex for better performance
53+ _DATETIME_PATTERN : re .Pattern [str ] = re .compile (
54+ r"^(\d{4})-(\d{2})-(\d{2})" # YYYY-MM-DD
55+ r"(?:[T\s](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?)?" # Optional time with microseconds
56+ r"(?:(Z)|([+-])(\d{2}):?(\d{2}))?$" # Optional timezone
57+ )
58+
59+
60+ def _get_timezone (offset_minutes : int ) -> timezone :
61+ """Get timezone object from cache or create new one."""
62+ if offset_minutes not in _TIMEZONE_CACHE :
63+ _TIMEZONE_CACHE [offset_minutes ] = timezone (
64+ timedelta (minutes = offset_minutes )
65+ )
66+ return _TIMEZONE_CACHE [offset_minutes ]
67+
68+
69+ def parse_dt_default (dt : Union [datetime , str ]) -> datetime :
70+ """Parse datetime string using only Python built-in packages.
71+
72+ Supports formats:
73+ - YYYY-MM-DD
74+ - YYYY-MM-DD HH:MM:SS[.ffffff]
75+ - YYYY-MM-DD HH:MM:SS[.ffffff]Z
76+ - YYYY-MM-DD HH:MM:SS[.ffffff]±HH:MM
77+
78+ Args:
79+ dt: A datetime object or string to parse
80+
81+ Returns:
82+ datetime: Parsed datetime object with appropriate timezone
83+
84+ Raises:
85+ ValueError: If the input cannot be parsed
86+ """
87+ # Handle datetime objects directly
88+ if isinstance (dt , datetime ):
89+ return dt
90+
91+ # Handle date objects (convert to datetime at midnight)
92+ if isinstance (dt , date ):
93+ return datetime (dt .year , dt .month , dt .day )
94+
95+ # Handle string parsing
96+ if not isinstance (dt , str ):
97+ raise ValueError ("Input must be datetime, date, or string" )
98+
99+ match = _DATETIME_PATTERN .match (dt .strip ())
100+ if not match :
101+ raise ValueError (f"Unable to parse datetime string: { dt } " )
102+
103+ (
104+ year ,
105+ month ,
106+ day ,
107+ hour ,
108+ minute ,
109+ second ,
110+ microsecond_str ,
111+ utc_z ,
112+ tz_sign ,
113+ tz_hour ,
114+ tz_minute ,
115+ ) = match .groups ()
116+
117+ # Convert required components
118+ year , month , day = int (year ), int (month ), int (day )
119+ hour = int (hour ) if hour else 0
120+ minute = int (minute ) if minute else 0
121+ second = int (second ) if second else 0
122+
123+ # Handle microseconds efficiently
124+ microsecond = 0
125+ if microsecond_str :
126+ # Normalize to 6 digits and convert
127+ microsecond_str = microsecond_str .ljust (6 , "0" )[:6 ]
128+ microsecond = int (microsecond_str )
129+
130+ # Handle timezone
131+ tz = None
132+ if utc_z :
133+ tz = timezone .utc
134+ elif tz_sign and tz_hour and tz_minute :
135+ offset_minutes = int (tz_hour ) * 60 + int (tz_minute )
136+ if tz_sign == "-" :
137+ offset_minutes = - offset_minutes
138+ tz = _get_timezone (offset_minutes )
139+
140+ return datetime (year , month , day , hour , minute , second , microsecond , tz )
141+
142+
143+ def parse_dt (dt : Union [datetime , str ], ** kwargs ) -> datetime :
144+ """Parse datetime string using dateutil and return datetime object.
145+
146+ :param dt: A datetime value that want to parse.
147+ """
148+ try :
149+ return parse_dt_default (dt )
150+ except ValueError :
151+ # Fallback to dateutil for complex formats
152+ try :
153+ from dateutil .parser import parse
154+ from dateutil .tz import tzoffset
155+ except ImportError as e :
156+ raise ValueError (
157+ f"Unable to parse '{ dt } ' with built-in parser. "
158+ "Install `dateutil` for complex datetime parsing: pip install "
159+ "`python-dateutil`."
160+ ) from e
161+
162+ if isinstance (dt , str ):
163+ dt = parse (dt , ** kwargs )
164+
165+ if isinstance (dt , date ) and not isinstance (dt , datetime ):
166+ return datetime (dt .year , dt .month , dt .day )
167+
168+ if isinstance (dt , datetime ):
169+ # Convert dateutil timezone to built-in timezone
170+ if dt .tzinfo and isinstance (dt .tzinfo , tzoffset ):
171+ offset_seconds = int (dt .utcoffset ().total_seconds ())
172+ if offset_seconds == 0 :
173+ return dt .replace (tzinfo = timezone .utc )
174+ else :
175+ offset_minutes = offset_seconds // 60
176+ return dt .replace (tzinfo = _get_timezone (offset_minutes ))
177+ return dt
178+
179+ raise ValueError (f"Unable to parse datetime: { dt } " ) from None
180+
46181
47182def get_datetime_replace (
48183 year : Optional [int ] = None ,
@@ -65,13 +200,13 @@ def get_datetime_replace(
65200class DatetimeDim (enum .IntEnum ):
66201 """Datetime dimension enum object."""
67202
68- MICROSECOND : int = 0
69- SECOND : int = 1
70- MINUTE : int = 2
71- HOUR : int = 3
72- DAY : int = 4
73- MONTH : int = 5
74- YEAR : int = 6
203+ MICROSECOND = 0
204+ SECOND = 1
205+ MINUTE = 2
206+ HOUR = 3
207+ DAY = 4
208+ MONTH = 5
209+ YEAR = 6
75210
76211 @classmethod
77212 def get_dim (cls , value : str ) -> int :
@@ -293,7 +428,7 @@ def next_date_freq(dt: datetime, freq: str, prev: bool = False) -> datetime:
293428 """
294429 if relativedelta is None :
295430 raise ImportError (
296- "This function require relativedelta from the dateutil package, "
431+ "This function require ` relativedelta` from the dateutil package, "
297432 "you should install with `pip install ddeutil[dateutil]`"
298433 )
299434 assert freq in ("D" , "W" , "M" , "Q" , "Y" )
@@ -359,3 +494,183 @@ def calc_date_freq(dt: datetime, freq: str) -> datetime:
359494 return dt .replace (month = 12 , day = 31 ) - relativedelta (years = 1 )
360495 return dt
361496 return dt
497+
498+
499+ def calc_time_units (
500+ start_dt : datetime , end_dt : datetime , binding_days : bool = True
501+ ) -> tuple [Optional [str ], Union [float , int ]]:
502+ """Calculate time difference and return the primary unit type and value.
503+
504+ :param start_dt: (datetime)
505+ :param end_dt: (datetime)
506+ :param binding_days: (bool)
507+ """
508+ rdelta = relativedelta (end_dt , start_dt )
509+
510+ # NOTE: Check in order of precedence: years, months, hours, minutes
511+ if rdelta .years != 0 :
512+ return "years" , rdelta .years
513+ elif rdelta .months != 0 or rdelta .years != 0 :
514+ return "months" , rdelta .years * 12 + rdelta .months
515+ else :
516+ # NOTE: For days, hours and minutes, use total seconds for accuracy
517+ total_seconds : float = (end_dt - start_dt ).total_seconds ()
518+
519+ # NOTE: 86400 seconds in a day
520+ total_days = int (total_seconds // 86400 )
521+ total_hours = int (total_seconds // 3600 )
522+ total_minutes = int (total_seconds // 60 )
523+
524+ if total_days != 0 and not binding_days :
525+ return "days" , total_days
526+ elif total_hours != 0 :
527+ return "hours" , total_hours
528+ elif total_minutes != 0 :
529+ return "minutes" , total_minutes
530+ else :
531+ return None , 0
532+
533+
534+ def gen_date_range (
535+ start_dt : datetime ,
536+ end_dt : datetime ,
537+ freq : FrequencyMode ,
538+ ) -> list [datetime ]:
539+ """Generate a list of datetime objects between start and end with given frequency"""
540+ if start_dt > end_dt :
541+ return []
542+
543+ dates : list [datetime ] = []
544+ current = start_dt
545+
546+ if freq == "1D" :
547+ delta = timedelta (days = 1 )
548+ elif freq == "1H" :
549+ delta = timedelta (hours = 1 )
550+ elif freq == "1T" :
551+ delta = timedelta (minutes = 1 )
552+ else :
553+ raise ValueError (f"Unsupported frequency: { freq } " )
554+
555+ while current <= end_dt :
556+ dates .append (current )
557+ current += delta
558+
559+ return dates
560+
561+
562+ def get_date_range (
563+ start : Union [str , datetime ],
564+ end : Union [str , datetime ],
565+ execution_step : int = 1 ,
566+ execution_offset : int = 0 ,
567+ freq : Optional [FrequencyMode ] = None ,
568+ binding_days : bool = True ,
569+ ) -> list [datetime ]:
570+ """Get datetime range with date intervals
571+
572+ :param start: (str | datetime)
573+ :param end: (str | datetime)
574+ :param execution_step: (int)
575+ :param execution_offset: (int)
576+ :param freq:
577+ :param binding_days:
578+
579+ Note:
580+ The date range will force to daily for the maximum generator.
581+ """
582+ start_dt : datetime = parse_dt (start )
583+ end_dt : datetime = parse_dt (end )
584+ if freq :
585+ if freq not in FREQUENCY_SET :
586+ raise ValueError (f"Frequency, { freq !r} , does not support." )
587+
588+ range_freq : FrequencyMode = "1D" if freq in ["1Y" , "1M" ] else freq
589+ min_dt , max_dt = min (start_dt , end_dt ), max (start_dt , end_dt )
590+ return gen_date_range (min_dt , max_dt , range_freq )
591+
592+ # Calculate new start and end based on unit type
593+ time_unit , time_value = calc_time_units (
594+ start_dt , end_dt , binding_days = binding_days
595+ )
596+ if time_unit is None :
597+ raise ValueError (f"Cannot find time difference between { start } , { end } " )
598+ elif time_unit == "years" :
599+ freq = "1Y"
600+ start_dt += relativedelta (years = time_value * execution_offset )
601+ end_dt = start_dt + relativedelta (years = time_value * execution_step )
602+ elif time_unit == "months" :
603+ freq = "1M"
604+ start_dt += relativedelta (months = time_value * execution_offset )
605+ end_dt = start_dt + relativedelta (months = time_value * execution_step )
606+ elif time_unit == "days" :
607+ freq = "1D"
608+ start_dt += relativedelta (days = time_value * execution_offset )
609+ end_dt = start_dt + relativedelta (days = time_value * execution_step )
610+ elif time_unit == "hours" :
611+ freq = "1H"
612+ start_dt += relativedelta (hours = time_value * execution_offset )
613+ end_dt = start_dt + relativedelta (hours = time_value * execution_step )
614+ elif time_unit == "minutes" :
615+ freq = "1T"
616+ start_dt += relativedelta (minutes = time_value * execution_offset )
617+ end_dt = start_dt + relativedelta (minutes = time_value * execution_step )
618+ else :
619+ raise ValueError (f"Time unit, { time_unit !r} does not support." )
620+
621+ # NOTE: Convert frequency for date range generation
622+ # (years/months use daily frequency).
623+ range_freq : FrequencyMode = "1D" if freq in ["1Y" , "1M" ] else freq
624+ min_dt , max_dt = min (start_dt , end_dt ), max (start_dt , end_dt )
625+ return gen_date_range (min_dt , max_dt , range_freq )
626+
627+
628+ def get_date_interval (
629+ start : Union [str , datetime ],
630+ end : Union [str , datetime ],
631+ execution_step : int = 1 ,
632+ execution_offset : int = 0 ,
633+ start_add_hours : int = 0 ,
634+ end_add_hours : int = 0 ,
635+ binding_days : bool = True ,
636+ ) -> tuple [datetime , datetime ]:
637+ """Get datetime interval with optional hour adjustments.
638+
639+ :param start: (str | datetime)
640+ :param end: (str | datetime)
641+ :param execution_step:
642+ :param execution_offset:
643+ :param start_add_hours:
644+ :param end_add_hours:
645+ :param binding_days:
646+ """
647+ start_dt : datetime = parse_dt (start )
648+ end_dt : datetime = parse_dt (end )
649+ time_unit , time_value = calc_time_units (
650+ start_dt , end_dt , binding_days = binding_days
651+ )
652+
653+ if time_unit is None :
654+ raise ValueError (f"Cannot find time difference between { start } , { end } " )
655+
656+ # NOTE: Calculate new start and end based on unit type
657+ if time_unit == "years" :
658+ start_dt += relativedelta (years = time_value * execution_offset )
659+ end_dt = start_dt + relativedelta (years = time_value * execution_step )
660+ elif time_unit == "months" :
661+ start_dt += relativedelta (months = time_value * execution_offset )
662+ end_dt = start_dt + relativedelta (months = time_value * execution_step )
663+ elif time_unit == "days" :
664+ start_dt += relativedelta (days = time_value * execution_offset )
665+ end_dt = start_dt + relativedelta (days = time_value * execution_step )
666+ elif time_unit == "hours" :
667+ start_dt += relativedelta (hours = time_value * execution_offset )
668+ end_dt = start_dt + relativedelta (hours = time_value * execution_step )
669+ elif time_unit == "minutes" :
670+ start_dt += relativedelta (minutes = time_value * execution_offset )
671+ end_dt = start_dt + relativedelta (minutes = time_value * execution_step )
672+
673+ # NOTE: Apply hour adjustments
674+ start_dt += relativedelta (hours = start_add_hours )
675+ end_dt += relativedelta (hours = end_add_hours )
676+ return min (start_dt , end_dt ), max (start_dt , end_dt )
0 commit comments