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/interval.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import calendar
|
|
4
|
+
import operator
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
import pendulum as _pendulum
|
|
10
|
+
|
|
11
|
+
import opendate as _date
|
|
12
|
+
from opendate.decorators import expect_date_or_datetime
|
|
13
|
+
from opendate.decorators import normalize_date_datetime_pairs, reset_business
|
|
14
|
+
|
|
15
|
+
if sys.version_info >= (3, 11):
|
|
16
|
+
from typing import Self
|
|
17
|
+
else:
|
|
18
|
+
from typing_extensions import Self
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from opendate.calendars import Calendar
|
|
22
|
+
from opendate.date_ import Date
|
|
23
|
+
from opendate.datetime_ import DateTime
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Interval(_pendulum.Interval):
|
|
27
|
+
"""Interval class extending pendulum.Interval with business day awareness.
|
|
28
|
+
|
|
29
|
+
This class represents the difference between two dates or datetimes with
|
|
30
|
+
additional support for business day calculations, calendar awareness, and
|
|
31
|
+
financial period calculations.
|
|
32
|
+
|
|
33
|
+
Unlike pendulum.Interval:
|
|
34
|
+
- Has business day mode that only counts business days
|
|
35
|
+
- Preserves calendar association (e.g., NYSE, LSE)
|
|
36
|
+
- Additional financial methods like yearfrac()
|
|
37
|
+
- Support for range operations that respect business days
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
_business: bool = False
|
|
41
|
+
_calendar: Calendar | None = None
|
|
42
|
+
|
|
43
|
+
@expect_date_or_datetime
|
|
44
|
+
@normalize_date_datetime_pairs
|
|
45
|
+
def __new__(cls, begdate: Date | DateTime, enddate: Date | DateTime) -> Self:
|
|
46
|
+
assert begdate and enddate, 'Interval dates cannot be None'
|
|
47
|
+
instance = super().__new__(cls, begdate, enddate, False)
|
|
48
|
+
return instance
|
|
49
|
+
|
|
50
|
+
@expect_date_or_datetime
|
|
51
|
+
@normalize_date_datetime_pairs
|
|
52
|
+
def __init__(self, begdate: Date | DateTime, enddate: Date | DateTime) -> None:
|
|
53
|
+
super().__init__(begdate, enddate, False)
|
|
54
|
+
self._direction = 1 if begdate <= enddate else -1
|
|
55
|
+
if begdate <= enddate:
|
|
56
|
+
self._start = begdate
|
|
57
|
+
self._end = enddate
|
|
58
|
+
else:
|
|
59
|
+
self._start = enddate
|
|
60
|
+
self._end = begdate
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _get_quarter_start(date: Date | DateTime) -> Date | DateTime:
|
|
64
|
+
"""Get the start date of the quarter containing the given date.
|
|
65
|
+
"""
|
|
66
|
+
quarter_month = ((date.month - 1) // 3) * 3 + 1
|
|
67
|
+
return date.replace(month=quarter_month, day=1)
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _get_quarter_end(date: Date | DateTime) -> Date | DateTime:
|
|
71
|
+
"""Get the end date of the quarter containing the given date.
|
|
72
|
+
"""
|
|
73
|
+
quarter_month = ((date.month - 1) // 3) * 3 + 3
|
|
74
|
+
return date.replace(month=quarter_month).end_of('month')
|
|
75
|
+
|
|
76
|
+
def _get_unit_handlers(self, unit: str) -> dict:
|
|
77
|
+
"""Get handlers for the specified time unit.
|
|
78
|
+
|
|
79
|
+
Returns a dict with:
|
|
80
|
+
get_start: Function to get start of period containing date
|
|
81
|
+
get_end: Function to get end of period containing date
|
|
82
|
+
advance: Function to advance to next period start
|
|
83
|
+
"""
|
|
84
|
+
if unit == 'quarter':
|
|
85
|
+
return {
|
|
86
|
+
'get_start': self._get_quarter_start,
|
|
87
|
+
'get_end': self._get_quarter_end,
|
|
88
|
+
'advance': lambda date: self._get_quarter_start(date.add(months=3)),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if unit == 'decade':
|
|
92
|
+
return {
|
|
93
|
+
'get_start': lambda date: date.start_of('decade'),
|
|
94
|
+
'get_end': lambda date: date.end_of('decade'),
|
|
95
|
+
'advance': lambda date: date.add(years=10).start_of('decade'),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if unit == 'century':
|
|
99
|
+
return {
|
|
100
|
+
'get_start': lambda date: date.start_of('century'),
|
|
101
|
+
'get_end': lambda date: date.end_of('century'),
|
|
102
|
+
'advance': lambda date: date.add(years=100).start_of('century'),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
'get_start': lambda date: date.start_of(unit),
|
|
107
|
+
'get_end': lambda date: date.end_of(unit),
|
|
108
|
+
'advance': lambda date: date.add(**{f'{unit}s': 1}).start_of(unit),
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
def business(self) -> Self:
|
|
112
|
+
self._business = True
|
|
113
|
+
self._start.business()
|
|
114
|
+
self._end.business()
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def b(self) -> Self:
|
|
119
|
+
return self.business()
|
|
120
|
+
|
|
121
|
+
def calendar(self, cal: str | Calendar | None = None) -> Self:
|
|
122
|
+
"""Set the calendar for business day calculations.
|
|
123
|
+
|
|
124
|
+
Parameters
|
|
125
|
+
cal: Calendar name (str), Calendar instance, or None for default
|
|
126
|
+
"""
|
|
127
|
+
from opendate.calendars import get_calendar, get_default_calendar
|
|
128
|
+
|
|
129
|
+
if cal is None:
|
|
130
|
+
cal = get_default_calendar()
|
|
131
|
+
if isinstance(cal, str):
|
|
132
|
+
cal = get_calendar(cal)
|
|
133
|
+
self._calendar = cal
|
|
134
|
+
if self._start:
|
|
135
|
+
self._start._calendar = cal
|
|
136
|
+
if self._end:
|
|
137
|
+
self._end._calendar = cal
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
def is_business_day_range(self) -> Iterator[bool]:
|
|
141
|
+
"""Generate boolean values indicating whether each day in the range is a business day.
|
|
142
|
+
"""
|
|
143
|
+
self._business = False
|
|
144
|
+
for thedate in self.range('days'):
|
|
145
|
+
yield thedate.is_business_day()
|
|
146
|
+
|
|
147
|
+
@reset_business
|
|
148
|
+
def range(self, unit: str = 'days', amount: int = 1) -> Iterator[DateTime | Date]:
|
|
149
|
+
"""Generate dates/datetimes over the interval.
|
|
150
|
+
|
|
151
|
+
Parameters
|
|
152
|
+
unit: Time unit ('days', 'weeks', 'months', 'years')
|
|
153
|
+
amount: Step size (e.g., every N units)
|
|
154
|
+
|
|
155
|
+
In business mode (for 'days' only), skips non-business days.
|
|
156
|
+
"""
|
|
157
|
+
_business = self._business
|
|
158
|
+
parent_range = _pendulum.Interval.range
|
|
159
|
+
|
|
160
|
+
def _range_generator():
|
|
161
|
+
if unit != 'days':
|
|
162
|
+
yield from (type(d).instance(d) for d in parent_range(self, unit, amount))
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
if self._direction == 1:
|
|
166
|
+
op = operator.le
|
|
167
|
+
this = self._start
|
|
168
|
+
thru = self._end
|
|
169
|
+
else:
|
|
170
|
+
op = operator.ge
|
|
171
|
+
this = self._end
|
|
172
|
+
thru = self._start
|
|
173
|
+
|
|
174
|
+
while op(this, thru):
|
|
175
|
+
if _business:
|
|
176
|
+
if this.is_business_day():
|
|
177
|
+
yield this
|
|
178
|
+
else:
|
|
179
|
+
yield this
|
|
180
|
+
this = this.add(days=self._direction * amount)
|
|
181
|
+
|
|
182
|
+
return _range_generator()
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
@reset_business
|
|
186
|
+
def days(self) -> int:
|
|
187
|
+
"""Get number of days in the interval (respects business mode and sign).
|
|
188
|
+
"""
|
|
189
|
+
if not self._business:
|
|
190
|
+
return self._direction * (self._end - self._start).days
|
|
191
|
+
return self._direction * len(tuple(self.range('days'))) - self._direction
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def months(self) -> float:
|
|
195
|
+
"""Get number of months in the interval including fractional parts.
|
|
196
|
+
|
|
197
|
+
Overrides pendulum's months property to return a float instead of an integer.
|
|
198
|
+
Calculates fractional months based on actual day counts within partial months.
|
|
199
|
+
"""
|
|
200
|
+
year_diff = self._end.year - self._start.year
|
|
201
|
+
month_diff = self._end.month - self._start.month
|
|
202
|
+
total_months = year_diff * 12 + month_diff
|
|
203
|
+
|
|
204
|
+
if self._end.day >= self._start.day:
|
|
205
|
+
day_diff = self._end.day - self._start.day
|
|
206
|
+
days_in_month = calendar.monthrange(self._start.year, self._start.month)[1]
|
|
207
|
+
fraction = day_diff / days_in_month
|
|
208
|
+
else:
|
|
209
|
+
total_months -= 1
|
|
210
|
+
days_in_start_month = calendar.monthrange(self._start.year, self._start.month)[1]
|
|
211
|
+
day_diff = (days_in_start_month - self._start.day) + self._end.day
|
|
212
|
+
fraction = day_diff / days_in_start_month
|
|
213
|
+
|
|
214
|
+
return self._direction * (total_months + fraction)
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def quarters(self) -> float:
|
|
218
|
+
"""Get approximate number of quarters in the interval.
|
|
219
|
+
|
|
220
|
+
Note: This is an approximation using day count / 365 * 4.
|
|
221
|
+
"""
|
|
222
|
+
return self._direction * 4 * self.days / 365.0
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def years(self) -> int:
|
|
226
|
+
"""Get number of complete years in the interval (always floors).
|
|
227
|
+
"""
|
|
228
|
+
year_diff = self._end.year - self._start.year
|
|
229
|
+
if self._end.month < self._start.month or \
|
|
230
|
+
(self._end.month == self._start.month and self._end.day < self._start.day):
|
|
231
|
+
year_diff -= 1
|
|
232
|
+
return self._direction * year_diff
|
|
233
|
+
|
|
234
|
+
def yearfrac(self, basis: int = 0) -> float:
|
|
235
|
+
"""Calculate the fraction of years between two dates (Excel-compatible).
|
|
236
|
+
|
|
237
|
+
This method provides precise calculation using various day count conventions
|
|
238
|
+
used in finance. Results are tested against Excel for compatibility.
|
|
239
|
+
|
|
240
|
+
Parameters
|
|
241
|
+
basis: Day count convention to use:
|
|
242
|
+
0 = US (NASD) 30/360 (default)
|
|
243
|
+
1 = Actual/actual
|
|
244
|
+
2 = Actual/360
|
|
245
|
+
3 = Actual/365
|
|
246
|
+
4 = European 30/360
|
|
247
|
+
|
|
248
|
+
Note: Excel has a known leap year bug for year 1900 which is intentionally
|
|
249
|
+
replicated for compatibility (1900 is treated as a leap year even though it wasn't).
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
def average_year_length(date1, date2):
|
|
253
|
+
"""Algorithm for average year length"""
|
|
254
|
+
days = (_date.Date(date2.year + 1, 1, 1) - _date.Date(date1.year, 1, 1)).days
|
|
255
|
+
years = (date2.year - date1.year) + 1
|
|
256
|
+
return days / years
|
|
257
|
+
|
|
258
|
+
def feb29_between(date1, date2):
|
|
259
|
+
"""Requires date2.year = (date1.year + 1) or date2.year = date1.year.
|
|
260
|
+
|
|
261
|
+
Returns True if "Feb 29" is between the two dates (date1 may be Feb29).
|
|
262
|
+
Two possibilities: date1.year is a leap year, and date1 <= Feb 29 y1,
|
|
263
|
+
or date2.year is a leap year, and date2 > Feb 29 y2.
|
|
264
|
+
"""
|
|
265
|
+
mar1_date1_year = _date.Date(date1.year, 3, 1)
|
|
266
|
+
if calendar.isleap(date1.year) and (date1 < mar1_date1_year) and (date2 >= mar1_date1_year):
|
|
267
|
+
return True
|
|
268
|
+
mar1_date2_year = _date.Date(date2.year, 3, 1)
|
|
269
|
+
return bool(calendar.isleap(date2.year) and date2 >= mar1_date2_year and date1 < mar1_date2_year)
|
|
270
|
+
|
|
271
|
+
def appears_lte_one_year(date1, date2):
|
|
272
|
+
"""Returns True if date1 and date2 "appear" to be 1 year or less apart.
|
|
273
|
+
|
|
274
|
+
This compares the values of year, month, and day directly to each other.
|
|
275
|
+
Requires date1 <= date2; returns boolean. Used by basis 1.
|
|
276
|
+
"""
|
|
277
|
+
if date1.year == date2.year:
|
|
278
|
+
return True
|
|
279
|
+
return bool(date1.year + 1 == date2.year and (date1.month > date2.month or date1.month == date2.month and date1.day >= date2.day))
|
|
280
|
+
|
|
281
|
+
def basis0(date1, date2):
|
|
282
|
+
# change day-of-month for purposes of calculation.
|
|
283
|
+
date1day, date1month, date1year = date1.day, date1.month, date1.year
|
|
284
|
+
date2day, date2month, date2year = date2.day, date2.month, date2.year
|
|
285
|
+
if date1day == 31 and date2day == 31:
|
|
286
|
+
date1day = 30
|
|
287
|
+
date2day = 30
|
|
288
|
+
elif date1day == 31:
|
|
289
|
+
date1day = 30
|
|
290
|
+
elif date1day == 30 and date2day == 31:
|
|
291
|
+
date2day = 30
|
|
292
|
+
# Note: If date2day==31, it STAYS 31 if date1day < 30.
|
|
293
|
+
# Special fixes for February:
|
|
294
|
+
elif date1month == 2 and date2month == 2 and date1 == date1.end_of('month') \
|
|
295
|
+
and date2 == date2.end_of('month'):
|
|
296
|
+
date1day = 30 # Set the day values to be equal
|
|
297
|
+
date2day = 30
|
|
298
|
+
elif date1month == 2 and date1 == date1.end_of('month'):
|
|
299
|
+
date1day = 30 # "Illegal" Feb 30 date.
|
|
300
|
+
daydiff360 = (date2day + date2month * 30 + date2year * 360) \
|
|
301
|
+
- (date1day + date1month * 30 + date1year * 360)
|
|
302
|
+
return daydiff360 / 360
|
|
303
|
+
|
|
304
|
+
def basis1(date1, date2):
|
|
305
|
+
if appears_lte_one_year(date1, date2):
|
|
306
|
+
if date1.year == date2.year and calendar.isleap(date1.year):
|
|
307
|
+
year_length = 366.0
|
|
308
|
+
elif feb29_between(date1, date2) or (date2.month == 2 and date2.day == 29):
|
|
309
|
+
year_length = 366.0
|
|
310
|
+
else:
|
|
311
|
+
year_length = 365.0
|
|
312
|
+
return (date2 - date1).days / year_length
|
|
313
|
+
return (date2 - date1).days / average_year_length(date1, date2)
|
|
314
|
+
|
|
315
|
+
def basis2(date1, date2):
|
|
316
|
+
return (date2 - date1).days / 360.0
|
|
317
|
+
|
|
318
|
+
def basis3(date1, date2):
|
|
319
|
+
return (date2 - date1).days / 365.0
|
|
320
|
+
|
|
321
|
+
def basis4(date1, date2):
|
|
322
|
+
# change day-of-month for purposes of calculation.
|
|
323
|
+
date1day, date1month, date1year = date1.day, date1.month, date1.year
|
|
324
|
+
date2day, date2month, date2year = date2.day, date2.month, date2.year
|
|
325
|
+
if date1day == 31:
|
|
326
|
+
date1day = 30
|
|
327
|
+
if date2day == 31:
|
|
328
|
+
date2day = 30
|
|
329
|
+
# Remarkably, do NOT change Feb. 28 or 29 at ALL.
|
|
330
|
+
daydiff360 = (date2day + date2month * 30 + date2year * 360) - \
|
|
331
|
+
(date1day + date1month * 30 + date1year * 360)
|
|
332
|
+
return daydiff360 / 360
|
|
333
|
+
|
|
334
|
+
if self._start == self._end:
|
|
335
|
+
return 0.0
|
|
336
|
+
if basis == 0:
|
|
337
|
+
return basis0(self._start, self._end) * self._direction
|
|
338
|
+
if basis == 1:
|
|
339
|
+
return basis1(self._start, self._end) * self._direction
|
|
340
|
+
if basis == 2:
|
|
341
|
+
return basis2(self._start, self._end) * self._direction
|
|
342
|
+
if basis == 3:
|
|
343
|
+
return basis3(self._start, self._end) * self._direction
|
|
344
|
+
if basis == 4:
|
|
345
|
+
return basis4(self._start, self._end) * self._direction
|
|
346
|
+
|
|
347
|
+
raise ValueError(f'Basis range [0, 4]. Unknown basis {basis}.')
|
|
348
|
+
|
|
349
|
+
@reset_business
|
|
350
|
+
def start_of(self, unit: str = 'month') -> list[Date | DateTime]:
|
|
351
|
+
"""Return the start of each unit within the interval.
|
|
352
|
+
|
|
353
|
+
Parameters
|
|
354
|
+
unit: Time unit ('month', 'week', 'year', 'quarter')
|
|
355
|
+
|
|
356
|
+
Returns
|
|
357
|
+
List of Date or DateTime objects representing start of each unit
|
|
358
|
+
|
|
359
|
+
In business mode, each start date is adjusted to the next business day
|
|
360
|
+
if it falls on a non-business day.
|
|
361
|
+
"""
|
|
362
|
+
handlers = self._get_unit_handlers(unit)
|
|
363
|
+
result = []
|
|
364
|
+
|
|
365
|
+
current = handlers['get_start'](self._start)
|
|
366
|
+
|
|
367
|
+
if self._business:
|
|
368
|
+
current._calendar = self._calendar
|
|
369
|
+
|
|
370
|
+
while current <= self._end:
|
|
371
|
+
if self._business:
|
|
372
|
+
current = current._business_or_next()
|
|
373
|
+
result.append(current)
|
|
374
|
+
current = handlers['advance'](current)
|
|
375
|
+
|
|
376
|
+
return result
|
|
377
|
+
|
|
378
|
+
@reset_business
|
|
379
|
+
def end_of(self, unit: str = 'month') -> list[Date | DateTime]:
|
|
380
|
+
"""Return the end of each unit within the interval.
|
|
381
|
+
|
|
382
|
+
Parameters
|
|
383
|
+
unit: Time unit ('month', 'week', 'year', 'quarter')
|
|
384
|
+
|
|
385
|
+
Returns
|
|
386
|
+
List of Date or DateTime objects representing end of each unit
|
|
387
|
+
|
|
388
|
+
In business mode, each end date is adjusted to the previous business day
|
|
389
|
+
if it falls on a non-business day.
|
|
390
|
+
"""
|
|
391
|
+
handlers = self._get_unit_handlers(unit)
|
|
392
|
+
result = []
|
|
393
|
+
|
|
394
|
+
current = handlers['get_start'](self._start)
|
|
395
|
+
|
|
396
|
+
if self._business:
|
|
397
|
+
current._calendar = self._calendar
|
|
398
|
+
|
|
399
|
+
while current <= self._end:
|
|
400
|
+
end_date = handlers['get_end'](current)
|
|
401
|
+
|
|
402
|
+
if self._business:
|
|
403
|
+
end_date = end_date._business_or_previous()
|
|
404
|
+
result.append(end_date)
|
|
405
|
+
|
|
406
|
+
current = handlers['advance'](current)
|
|
407
|
+
|
|
408
|
+
return result
|
opendate/metaclass.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Metaclass for automatic context preservation on pendulum methods.
|
|
2
|
+
|
|
3
|
+
This module provides a metaclass that automatically wraps pendulum methods
|
|
4
|
+
to preserve the _calendar attribute when they return new Date/DateTime objects.
|
|
5
|
+
This ensures users don't accidentally lose business context when calling
|
|
6
|
+
pendulum methods that aren't explicitly overridden.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from functools import wraps
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
import pendulum as _pendulum
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from opendate.calendars import Calendar
|
|
17
|
+
|
|
18
|
+
DATE_METHODS_RETURNING_DATE = {
|
|
19
|
+
'add',
|
|
20
|
+
'subtract',
|
|
21
|
+
'replace',
|
|
22
|
+
'set',
|
|
23
|
+
'average',
|
|
24
|
+
'closest',
|
|
25
|
+
'farthest',
|
|
26
|
+
'end_of',
|
|
27
|
+
'start_of',
|
|
28
|
+
'first_of',
|
|
29
|
+
'last_of',
|
|
30
|
+
'next',
|
|
31
|
+
'previous',
|
|
32
|
+
'nth_of',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
DATETIME_METHODS_RETURNING_DATETIME = DATE_METHODS_RETURNING_DATE | {
|
|
36
|
+
'at',
|
|
37
|
+
'on',
|
|
38
|
+
'naive',
|
|
39
|
+
'astimezone',
|
|
40
|
+
'in_timezone',
|
|
41
|
+
'in_tz',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _make_context_preserver(original_method, target_cls):
|
|
46
|
+
"""Create a wrapper that preserves _calendar context.
|
|
47
|
+
|
|
48
|
+
Parameters
|
|
49
|
+
original_method: The original pendulum method
|
|
50
|
+
target_cls: The target class (Date or DateTime) for instance creation
|
|
51
|
+
"""
|
|
52
|
+
@wraps(original_method)
|
|
53
|
+
def wrapper(self, *args, **kwargs):
|
|
54
|
+
_calendar: Calendar | None = getattr(self, '_calendar', None)
|
|
55
|
+
result = original_method(self, *args, **kwargs)
|
|
56
|
+
|
|
57
|
+
if isinstance(result, (_pendulum.Date, _pendulum.DateTime)):
|
|
58
|
+
if not isinstance(result, target_cls):
|
|
59
|
+
result = target_cls.instance(result)
|
|
60
|
+
if hasattr(result, '_calendar'):
|
|
61
|
+
result._calendar = _calendar
|
|
62
|
+
return result
|
|
63
|
+
return wrapper
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class DateContextMeta(type):
|
|
67
|
+
"""Metaclass that auto-wraps pendulum methods to preserve context.
|
|
68
|
+
|
|
69
|
+
When a class is created with this metaclass, it automatically wraps
|
|
70
|
+
specified pendulum methods to preserve the _calendar attribute.
|
|
71
|
+
|
|
72
|
+
Usage:
|
|
73
|
+
class Date(
|
|
74
|
+
DateBusinessMixin,
|
|
75
|
+
_pendulum.Date,
|
|
76
|
+
metaclass=DateContextMeta,
|
|
77
|
+
methods_to_wrap=DATE_METHODS_RETURNING_DATE
|
|
78
|
+
):
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
The metaclass will NOT wrap methods that are already defined in the
|
|
82
|
+
class namespace - explicit overrides (like those in DateBusinessMixin)
|
|
83
|
+
take precedence.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __new__(mcs, name, bases, namespace, methods_to_wrap=None, **kwargs):
|
|
87
|
+
cls = super().__new__(mcs, name, bases, namespace, **kwargs)
|
|
88
|
+
|
|
89
|
+
if methods_to_wrap:
|
|
90
|
+
# Identify which bases are pendulum classes (we want to wrap their methods)
|
|
91
|
+
pendulum_bases = tuple(
|
|
92
|
+
base for base in bases
|
|
93
|
+
if issubclass(base, (_pendulum.Date, _pendulum.DateTime))
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
for method_name in methods_to_wrap:
|
|
97
|
+
# Skip if method is already explicitly defined in namespace
|
|
98
|
+
if method_name in namespace:
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
# Check if any NON-pendulum base class has this method defined
|
|
102
|
+
# (i.e., our mixins like DateBusinessMixin have explicit overrides)
|
|
103
|
+
explicitly_defined = False
|
|
104
|
+
for base in bases:
|
|
105
|
+
if base in pendulum_bases:
|
|
106
|
+
continue # Skip pendulum classes
|
|
107
|
+
if method_name in base.__dict__:
|
|
108
|
+
explicitly_defined = True
|
|
109
|
+
break
|
|
110
|
+
|
|
111
|
+
if explicitly_defined:
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
# Find the method in pendulum base classes and wrap it
|
|
115
|
+
for base in pendulum_bases:
|
|
116
|
+
if hasattr(base, method_name):
|
|
117
|
+
original = getattr(base, method_name)
|
|
118
|
+
if callable(original):
|
|
119
|
+
wrapped = _make_context_preserver(original, cls)
|
|
120
|
+
setattr(cls, method_name, wrapped)
|
|
121
|
+
break
|
|
122
|
+
|
|
123
|
+
return cls
|