opendate 0.1.34__cp39-cp39-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- date/__init__.py +3 -0
- opendate/__init__.py +150 -0
- opendate/_opendate.cp39-win_amd64.pyd +0 -0
- opendate/calendars.py +350 -0
- opendate/constants.py +58 -0
- opendate/date_.py +255 -0
- opendate/datetime_.py +339 -0
- opendate/decorators.py +198 -0
- opendate/extras.py +88 -0
- opendate/helpers.py +169 -0
- opendate/interval.py +408 -0
- opendate/metaclass.py +123 -0
- opendate/mixins/__init__.py +4 -0
- opendate/mixins/business.py +295 -0
- opendate/mixins/extras_.py +98 -0
- opendate/time_.py +139 -0
- opendate-0.1.34.dist-info/METADATA +469 -0
- opendate-0.1.34.dist-info/RECORD +20 -0
- opendate-0.1.34.dist-info/WHEEL +4 -0
- opendate-0.1.34.dist-info/licenses/LICENSE +23 -0
opendate/decorators.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime as _datetime
|
|
4
|
+
from collections.abc import Callable, Sequence
|
|
5
|
+
from functools import partial, wraps
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import pendulum as _pendulum
|
|
11
|
+
|
|
12
|
+
from opendate.constants import LCL, UTC
|
|
13
|
+
from opendate.helpers import isdateish
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_arg(typ: type | str, arg: Any) -> Any:
|
|
17
|
+
"""Parse argument to specified type or 'smart' to preserve Date/DateTime.
|
|
18
|
+
"""
|
|
19
|
+
import opendate
|
|
20
|
+
|
|
21
|
+
if not isdateish(arg):
|
|
22
|
+
return arg
|
|
23
|
+
|
|
24
|
+
if typ == 'smart':
|
|
25
|
+
if isinstance(arg, (opendate.Date, opendate.DateTime)):
|
|
26
|
+
return arg
|
|
27
|
+
if isinstance(arg, (_datetime.datetime, _pendulum.DateTime)):
|
|
28
|
+
return opendate.DateTime.instance(arg)
|
|
29
|
+
if isinstance(arg, pd.Timestamp):
|
|
30
|
+
if pd.isna(arg):
|
|
31
|
+
return None
|
|
32
|
+
return opendate.DateTime.instance(arg)
|
|
33
|
+
if isinstance(arg, np.datetime64):
|
|
34
|
+
if np.isnat(arg):
|
|
35
|
+
return None
|
|
36
|
+
return opendate.DateTime.instance(arg)
|
|
37
|
+
if isinstance(arg, _datetime.date):
|
|
38
|
+
return opendate.Date.instance(arg)
|
|
39
|
+
if isinstance(arg, _datetime.time):
|
|
40
|
+
return opendate.Time.instance(arg)
|
|
41
|
+
return arg
|
|
42
|
+
|
|
43
|
+
if typ == _datetime.datetime:
|
|
44
|
+
return opendate.DateTime.instance(arg)
|
|
45
|
+
if typ == _datetime.date:
|
|
46
|
+
return opendate.Date.instance(arg)
|
|
47
|
+
if typ == _datetime.time:
|
|
48
|
+
return opendate.Time.instance(arg)
|
|
49
|
+
return arg
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_args(typ: type | str, *args: Any) -> list[Any]:
|
|
53
|
+
"""Parse args to specified type or 'smart' mode.
|
|
54
|
+
"""
|
|
55
|
+
this = []
|
|
56
|
+
for a in args:
|
|
57
|
+
if isinstance(a, Sequence) and not isinstance(a, str):
|
|
58
|
+
this.append(parse_args(typ, *a))
|
|
59
|
+
else:
|
|
60
|
+
this.append(parse_arg(typ, a))
|
|
61
|
+
return this
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def expect(func=None, *, typ: type[_datetime.date] | str = None, exclkw: bool = False) -> Callable:
|
|
65
|
+
"""Decorator to force input type of date/datetime inputs.
|
|
66
|
+
|
|
67
|
+
typ can be _datetime.date, _datetime.datetime, _datetime.time, or 'smart'
|
|
68
|
+
"""
|
|
69
|
+
def decorator(func):
|
|
70
|
+
@wraps(func)
|
|
71
|
+
def wrapper(*args, **kwargs):
|
|
72
|
+
args = parse_args(typ, *args)
|
|
73
|
+
if not exclkw:
|
|
74
|
+
for k, v in kwargs.items():
|
|
75
|
+
if isdateish(v):
|
|
76
|
+
kwargs[k] = parse_arg(typ, v)
|
|
77
|
+
return func(*args, **kwargs)
|
|
78
|
+
return wrapper
|
|
79
|
+
|
|
80
|
+
if func is None:
|
|
81
|
+
return decorator
|
|
82
|
+
return decorator(func)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
expect_date = partial(expect, typ=_datetime.date)
|
|
86
|
+
expect_datetime = partial(expect, typ=_datetime.datetime)
|
|
87
|
+
expect_time = partial(expect, typ=_datetime.time)
|
|
88
|
+
expect_date_or_datetime = partial(expect, typ='smart')
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def type_class(typ, obj):
|
|
92
|
+
"""Get the appropriate class for the type/object combination."""
|
|
93
|
+
import opendate
|
|
94
|
+
|
|
95
|
+
if isinstance(typ, str):
|
|
96
|
+
if typ == 'Date':
|
|
97
|
+
return opendate.Date
|
|
98
|
+
if typ == 'DateTime':
|
|
99
|
+
return opendate.DateTime
|
|
100
|
+
if typ == 'Interval':
|
|
101
|
+
return opendate.Interval
|
|
102
|
+
if typ:
|
|
103
|
+
return typ
|
|
104
|
+
if obj.__class__.__name__ == 'Interval':
|
|
105
|
+
return opendate.Interval
|
|
106
|
+
if obj.__class__ in {_datetime.datetime, _pendulum.DateTime} or obj.__class__.__name__ == 'DateTime':
|
|
107
|
+
return opendate.DateTime
|
|
108
|
+
if obj.__class__ in {_datetime.date, _pendulum.Date} or obj.__class__.__name__ == 'Date':
|
|
109
|
+
return opendate.Date
|
|
110
|
+
raise ValueError(f'Unknown type {typ}')
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def store_calendar(func=None, *, typ=None):
|
|
114
|
+
@wraps(func)
|
|
115
|
+
def wrapper(self, *args, **kwargs):
|
|
116
|
+
_calendar = self._calendar
|
|
117
|
+
d = type_class(typ, self).instance(func(self, *args, **kwargs))
|
|
118
|
+
d._calendar = _calendar
|
|
119
|
+
return d
|
|
120
|
+
if func is None:
|
|
121
|
+
return partial(store_calendar, typ=typ)
|
|
122
|
+
return wrapper
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def reset_business(func):
|
|
126
|
+
"""Decorator to reset business mode after function execution.
|
|
127
|
+
"""
|
|
128
|
+
@wraps(func)
|
|
129
|
+
def wrapper(self, *args, **kwargs):
|
|
130
|
+
try:
|
|
131
|
+
return func(self, *args, **kwargs)
|
|
132
|
+
finally:
|
|
133
|
+
self._business = False
|
|
134
|
+
self._start._business = False
|
|
135
|
+
self._end._business = False
|
|
136
|
+
return wrapper
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def normalize_date_datetime_pairs(func):
|
|
140
|
+
"""Decorator to normalize mixed Date/DateTime pairs to DateTime.
|
|
141
|
+
"""
|
|
142
|
+
@wraps(func)
|
|
143
|
+
def wrapper(*args, **kwargs):
|
|
144
|
+
import opendate
|
|
145
|
+
|
|
146
|
+
if len(args) >= 3:
|
|
147
|
+
cls_or_self, begdate, enddate = args[0], args[1], args[2]
|
|
148
|
+
rest_args = args[3:]
|
|
149
|
+
|
|
150
|
+
tz = UTC
|
|
151
|
+
if isinstance(begdate, opendate.DateTime) and begdate.tzinfo:
|
|
152
|
+
tz = begdate.tzinfo
|
|
153
|
+
elif isinstance(enddate, opendate.DateTime) and enddate.tzinfo:
|
|
154
|
+
tz = enddate.tzinfo
|
|
155
|
+
|
|
156
|
+
if isinstance(begdate, opendate.Date) and not isinstance(begdate, opendate.DateTime):
|
|
157
|
+
if isinstance(enddate, opendate.DateTime):
|
|
158
|
+
begdate = opendate.DateTime(begdate.year, begdate.month, begdate.day, tzinfo=tz)
|
|
159
|
+
elif isinstance(enddate, opendate.Date) and not isinstance(enddate, opendate.DateTime):
|
|
160
|
+
if isinstance(begdate, opendate.DateTime):
|
|
161
|
+
enddate = opendate.DateTime(enddate.year, enddate.month, enddate.day, tzinfo=tz)
|
|
162
|
+
|
|
163
|
+
args = (cls_or_self, begdate, enddate) + rest_args
|
|
164
|
+
|
|
165
|
+
return func(*args, **kwargs)
|
|
166
|
+
return wrapper
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def prefer_utc_timezone(func, force: bool = False) -> Callable:
|
|
170
|
+
"""Return datetime as UTC.
|
|
171
|
+
"""
|
|
172
|
+
@wraps(func)
|
|
173
|
+
def wrapper(*args, **kwargs):
|
|
174
|
+
d = func(*args, **kwargs)
|
|
175
|
+
if not d:
|
|
176
|
+
return
|
|
177
|
+
if not force and d.tzinfo:
|
|
178
|
+
return d
|
|
179
|
+
return d.replace(tzinfo=UTC)
|
|
180
|
+
return wrapper
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def prefer_native_timezone(func, force: bool = False) -> Callable:
|
|
184
|
+
"""Return datetime as native.
|
|
185
|
+
"""
|
|
186
|
+
@wraps(func)
|
|
187
|
+
def wrapper(*args, **kwargs):
|
|
188
|
+
d = func(*args, **kwargs)
|
|
189
|
+
if not d:
|
|
190
|
+
return
|
|
191
|
+
if not force and d.tzinfo:
|
|
192
|
+
return d
|
|
193
|
+
return d.replace(tzinfo=LCL)
|
|
194
|
+
return wrapper
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
expect_native_timezone = partial(prefer_native_timezone, force=True)
|
|
198
|
+
expect_utc_timezone = partial(prefer_utc_timezone, force=True)
|
opendate/extras.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Legacy compatibility functions for OpenDate.
|
|
4
|
+
|
|
5
|
+
This module contains functions that exist primarily for backward compatibility
|
|
6
|
+
with older codebases. These functions provide alternative interfaces to
|
|
7
|
+
functionality that may be available through other means in the core Date,
|
|
8
|
+
DateTime, and Interval classes.
|
|
9
|
+
|
|
10
|
+
New code should prefer using the built-in methods on Date, DateTime, and
|
|
11
|
+
Interval objects where applicable.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from opendate.calendars import Calendar, get_calendar, get_default_calendar
|
|
15
|
+
from opendate.date_ import Date
|
|
16
|
+
from opendate.datetime_ import DateTime
|
|
17
|
+
from opendate.interval import Interval
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
'is_within_business_hours',
|
|
21
|
+
'is_business_day',
|
|
22
|
+
'overlap_days',
|
|
23
|
+
'create_ics',
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_within_business_hours(calendar: str | Calendar | None = None) -> bool:
|
|
28
|
+
"""Return whether the current native datetime is between open and close of business hours.
|
|
29
|
+
"""
|
|
30
|
+
if calendar is None:
|
|
31
|
+
calendar = get_default_calendar()
|
|
32
|
+
if isinstance(calendar, str):
|
|
33
|
+
calendar = get_calendar(calendar)
|
|
34
|
+
this = DateTime.now()
|
|
35
|
+
this_cal = this.in_tz(calendar.tz).calendar(calendar)
|
|
36
|
+
bounds = this_cal.business_hours()
|
|
37
|
+
return this_cal.business_open() and (bounds[0] <= this.astimezone(calendar.tz) <= bounds[1])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_business_day(calendar: str | Calendar | None = None) -> bool:
|
|
41
|
+
"""Return whether the current native datetime is a business day.
|
|
42
|
+
"""
|
|
43
|
+
if calendar is None:
|
|
44
|
+
calendar = get_default_calendar()
|
|
45
|
+
if isinstance(calendar, str):
|
|
46
|
+
calendar = get_calendar(calendar)
|
|
47
|
+
return DateTime.now(tz=calendar.tz).calendar(calendar).is_business_day()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def overlap_days(
|
|
51
|
+
interval_one: Interval | tuple[Date | DateTime, Date | DateTime],
|
|
52
|
+
interval_two: Interval | tuple[Date | DateTime, Date | DateTime],
|
|
53
|
+
days: bool = False,
|
|
54
|
+
) -> bool | int:
|
|
55
|
+
"""Calculate how much two date intervals overlap.
|
|
56
|
+
|
|
57
|
+
When days=False, returns True/False indicating whether intervals overlap.
|
|
58
|
+
When days=True, returns the actual day count (negative if non-overlapping).
|
|
59
|
+
|
|
60
|
+
Algorithm adapted from Raymond Hettinger: http://stackoverflow.com/a/9044111
|
|
61
|
+
"""
|
|
62
|
+
if not isinstance(interval_one, Interval):
|
|
63
|
+
interval_one = Interval(*interval_one)
|
|
64
|
+
if not isinstance(interval_two, Interval):
|
|
65
|
+
interval_two = Interval(*interval_two)
|
|
66
|
+
|
|
67
|
+
latest_start = max(interval_one.start, interval_two.start)
|
|
68
|
+
earliest_end = min(interval_one.end, interval_two.end)
|
|
69
|
+
overlap = (earliest_end - latest_start).days + 1
|
|
70
|
+
if days:
|
|
71
|
+
return overlap
|
|
72
|
+
return overlap >= 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def create_ics(begdate: Date | DateTime, enddate: Date | DateTime, summary: str, location: str) -> str:
|
|
76
|
+
"""Create a simple .ics file per RFC 5545 guidelines."""
|
|
77
|
+
|
|
78
|
+
return f"""BEGIN:VCALENDAR
|
|
79
|
+
VERSION:2.0
|
|
80
|
+
PRODID:-//hacksw/handcal//NONSGML v1.0//EN
|
|
81
|
+
BEGIN:VEVENT
|
|
82
|
+
DTSTART;TZID=America/New_York:{begdate:%Y%m%dT%H%M%S}
|
|
83
|
+
DTEND;TZID=America/New_York:{enddate:%Y%m%dT%H%M%S}
|
|
84
|
+
SUMMARY:{summary}
|
|
85
|
+
LOCATION:{location}
|
|
86
|
+
END:VEVENT
|
|
87
|
+
END:VCALENDAR
|
|
88
|
+
"""
|
opendate/helpers.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime as _datetime
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from opendate.constants import MAX_YEAR, MIN_YEAR
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from opendate._opendate import BusinessCalendar as _BusinessCalendar
|
|
14
|
+
from opendate._opendate import IsoParser as _RustIsoParser
|
|
15
|
+
from opendate._opendate import Parser as _RustParser
|
|
16
|
+
from opendate._opendate import TimeParser as _RustTimeParser
|
|
17
|
+
except ImportError:
|
|
18
|
+
try:
|
|
19
|
+
from _opendate import BusinessCalendar as _BusinessCalendar
|
|
20
|
+
from _opendate import IsoParser as _RustIsoParser
|
|
21
|
+
from _opendate import Parser as _RustParser
|
|
22
|
+
from _opendate import TimeParser as _RustTimeParser
|
|
23
|
+
except ImportError:
|
|
24
|
+
_BusinessCalendar = None
|
|
25
|
+
_RustParser = None
|
|
26
|
+
_RustIsoParser = None
|
|
27
|
+
_RustTimeParser = None
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
_cached_parser: _RustParser | None = None
|
|
32
|
+
_cached_iso_parser: _RustIsoParser | None = None
|
|
33
|
+
_cached_time_parser: _RustTimeParser | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get_parser() -> _RustParser | None:
|
|
37
|
+
"""Get cached Parser instance.
|
|
38
|
+
"""
|
|
39
|
+
global _cached_parser
|
|
40
|
+
if _RustParser is None:
|
|
41
|
+
return None
|
|
42
|
+
if _cached_parser is None:
|
|
43
|
+
_cached_parser = _RustParser(False, False)
|
|
44
|
+
return _cached_parser
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _get_iso_parser() -> _RustIsoParser | None:
|
|
48
|
+
"""Get cached IsoParser instance.
|
|
49
|
+
"""
|
|
50
|
+
global _cached_iso_parser
|
|
51
|
+
if _RustIsoParser is None:
|
|
52
|
+
return None
|
|
53
|
+
if _cached_iso_parser is None:
|
|
54
|
+
_cached_iso_parser = _RustIsoParser()
|
|
55
|
+
return _cached_iso_parser
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _get_time_parser() -> _RustTimeParser | None:
|
|
59
|
+
"""Get cached TimeParser instance.
|
|
60
|
+
"""
|
|
61
|
+
global _cached_time_parser
|
|
62
|
+
if _RustTimeParser is None:
|
|
63
|
+
return None
|
|
64
|
+
if _cached_time_parser is None:
|
|
65
|
+
_cached_time_parser = _RustTimeParser()
|
|
66
|
+
return _cached_time_parser
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def isdateish(x: Any) -> bool:
|
|
70
|
+
return isinstance(x, (_datetime.date, _datetime.datetime, _datetime.time, pd.Timestamp, np.datetime64))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _rust_parse_datetime(s: str, dayfirst: bool = False, yearfirst: bool = False, fuzzy: bool = True) -> _datetime.datetime | None:
|
|
74
|
+
"""Parse datetime string using Rust parser, return Python datetime or None.
|
|
75
|
+
|
|
76
|
+
This is an internal helper that bridges the Rust parser to Python datetime objects.
|
|
77
|
+
Returns None if parsing fails or no meaningful components are found.
|
|
78
|
+
Uses current year as default when year is missing but month/day are present.
|
|
79
|
+
"""
|
|
80
|
+
iso_parser = _get_iso_parser()
|
|
81
|
+
if iso_parser is not None:
|
|
82
|
+
try:
|
|
83
|
+
result = iso_parser.isoparse(s)
|
|
84
|
+
if result is not None:
|
|
85
|
+
tzinfo = None
|
|
86
|
+
if result.tzoffset is not None:
|
|
87
|
+
tzinfo = _datetime.timezone(_datetime.timedelta(seconds=result.tzoffset))
|
|
88
|
+
return _datetime.datetime(
|
|
89
|
+
result.year, result.month, result.day,
|
|
90
|
+
result.hour or 0, result.minute or 0, result.second or 0,
|
|
91
|
+
result.microsecond or 0, tzinfo=tzinfo,
|
|
92
|
+
)
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
parser = _get_parser()
|
|
97
|
+
if parser is None:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
result = parser.parse(s, dayfirst=dayfirst, yearfirst=yearfirst, fuzzy=fuzzy)
|
|
102
|
+
|
|
103
|
+
if isinstance(result, tuple):
|
|
104
|
+
result = result[0]
|
|
105
|
+
|
|
106
|
+
if result is None:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
has_date = result.year is not None or result.month is not None or result.day is not None
|
|
110
|
+
has_time = result.hour is not None or result.minute is not None or result.second is not None
|
|
111
|
+
|
|
112
|
+
if not has_date and not has_time:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
year = result.year
|
|
116
|
+
month = result.month
|
|
117
|
+
day = result.day
|
|
118
|
+
|
|
119
|
+
if year is None or (has_time and not has_date and (month is None or day is None)):
|
|
120
|
+
now = _datetime.datetime.now()
|
|
121
|
+
year = year if year is not None else now.year
|
|
122
|
+
month = month if month is not None else (now.month if has_time and not has_date else 1)
|
|
123
|
+
day = day if day is not None else (now.day if has_time and not has_date else 1)
|
|
124
|
+
else:
|
|
125
|
+
month = month if month is not None else 1
|
|
126
|
+
day = day if day is not None else 1
|
|
127
|
+
|
|
128
|
+
tzinfo = None
|
|
129
|
+
if result.tzoffset is not None:
|
|
130
|
+
tzinfo = _datetime.timezone(_datetime.timedelta(seconds=result.tzoffset))
|
|
131
|
+
|
|
132
|
+
return _datetime.datetime(
|
|
133
|
+
year, month, day,
|
|
134
|
+
result.hour or 0, result.minute or 0, result.second or 0,
|
|
135
|
+
result.microsecond or 0, tzinfo=tzinfo,
|
|
136
|
+
)
|
|
137
|
+
except Exception as e:
|
|
138
|
+
logger.debug(f'Rust parser failed: {e}')
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _rust_parse_time(s: str) -> tuple[int, int, int, int] | None:
|
|
143
|
+
"""Parse time string using Rust TimeParser, return (h, m, s, us) or None.
|
|
144
|
+
|
|
145
|
+
This is an internal helper that bridges the Rust TimeParser to Python.
|
|
146
|
+
Returns None if parsing fails.
|
|
147
|
+
"""
|
|
148
|
+
time_parser = _get_time_parser()
|
|
149
|
+
if time_parser is None:
|
|
150
|
+
return None
|
|
151
|
+
try:
|
|
152
|
+
result = time_parser.parse(s)
|
|
153
|
+
hour = result.hour if result.hour is not None else 0
|
|
154
|
+
minute = result.minute if result.minute is not None else 0
|
|
155
|
+
second = result.second if result.second is not None else 0
|
|
156
|
+
microsecond = result.microsecond if result.microsecond is not None else 0
|
|
157
|
+
return (hour, minute, second, microsecond)
|
|
158
|
+
except Exception:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _get_decade_bounds(year: int) -> tuple[_datetime.date, _datetime.date] | None:
|
|
163
|
+
"""Get decade start/end dates for caching. Returns None if outside valid range."""
|
|
164
|
+
if year > MAX_YEAR or year < MIN_YEAR:
|
|
165
|
+
return None
|
|
166
|
+
decade_start = _datetime.date(year // 10 * 10, 1, 1)
|
|
167
|
+
next_decade_year = (year // 10 + 1) * 10
|
|
168
|
+
decade_end = _datetime.date(MAX_YEAR, 12, 31) if next_decade_year > MAX_YEAR else _datetime.date(next_decade_year, 1, 1)
|
|
169
|
+
return decade_start, decade_end
|