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.
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