opendate 0.1.1__py3-none-any.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.

Potentially problematic release.


This version of opendate might be problematic. Click here for more details.

date/date.py ADDED
@@ -0,0 +1,1702 @@
1
+ import calendar
2
+ import contextlib
3
+ import datetime as _datetime
4
+ import logging
5
+ import os
6
+ import re
7
+ import time
8
+ import warnings
9
+ import zoneinfo as _zoneinfo
10
+ from abc import ABC, abstractmethod
11
+ from collections import namedtuple
12
+ from collections.abc import Callable, Sequence
13
+ from enum import IntEnum
14
+ from functools import lru_cache, partial, wraps
15
+ from typing import Self
16
+
17
+ import dateutil as _dateutil
18
+ import numpy as np
19
+ import pandas as pd
20
+ import pandas_market_calendars as mcal
21
+ import pendulum as _pendulum
22
+
23
+ warnings.simplefilter(action='ignore', category=DeprecationWarning)
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ __all__ = [
28
+ 'Date',
29
+ 'DateTime',
30
+ 'Interval',
31
+ 'IntervalError',
32
+ 'Time',
33
+ 'Timezone',
34
+ 'WeekDay',
35
+ 'WEEKDAY_SHORTNAME',
36
+ 'EST',
37
+ 'UTC',
38
+ 'GMT',
39
+ 'LCL',
40
+ 'expect_native_timezone',
41
+ 'expect_utc_timezone',
42
+ 'prefer_native_timezone',
43
+ 'prefer_utc_timezone',
44
+ 'expect_date',
45
+ 'expect_datetime',
46
+ 'Entity',
47
+ 'NYSE'
48
+ ]
49
+
50
+
51
+ def Timezone(name:str = 'US/Eastern') -> _zoneinfo.ZoneInfo:
52
+ """Simple wrapper around Pendulum `Timezone`
53
+ """
54
+ return _pendulum.tz.Timezone(name)
55
+
56
+
57
+ UTC = Timezone('UTC')
58
+ GMT = Timezone('GMT')
59
+ EST = Timezone('US/Eastern')
60
+ LCL = _pendulum.tz.Timezone(_pendulum.tz.get_local_timezone().name)
61
+
62
+
63
+ class WeekDay(IntEnum):
64
+ MONDAY = 0
65
+ TUESDAY = 1
66
+ WEDNESDAY = 2
67
+ THURSDAY = 3
68
+ FRIDAY = 4
69
+ SATURDAY = 5
70
+ SUNDAY = 6
71
+
72
+
73
+ WEEKDAY_SHORTNAME = {
74
+ 'MO': WeekDay.MONDAY,
75
+ 'TU': WeekDay.TUESDAY,
76
+ 'WE': WeekDay.WEDNESDAY,
77
+ 'TH': WeekDay.THURSDAY,
78
+ 'FR': WeekDay.FRIDAY,
79
+ 'SA': WeekDay.SATURDAY,
80
+ 'SU': WeekDay.SUNDAY
81
+ }
82
+
83
+
84
+ MONTH_SHORTNAME = {
85
+ 'jan': 1,
86
+ 'feb': 2,
87
+ 'mar': 3,
88
+ 'apr': 4,
89
+ 'may': 5,
90
+ 'jun': 6,
91
+ 'jul': 7,
92
+ 'aug': 8,
93
+ 'sep': 9,
94
+ 'oct': 10,
95
+ 'nov': 11,
96
+ 'dec': 12,
97
+ }
98
+
99
+ DATEMATCH = re.compile(r'^(?P<d>N|T|Y|P|M)(?P<n>[-+]?\d+)?(?P<b>b?)?$')
100
+
101
+
102
+ # def caller_entity(func):
103
+ # """Helper to get current entity from function"""
104
+ # # general frame args inspect
105
+ # import inspect
106
+ # frame = inspect.currentframe()
107
+ # outer_frames = inspect.getouterframes(frame)
108
+ # caller_frame = outer_frames[1][0]
109
+ # args = inspect.getargvalues(caller_frame)
110
+ # # find our entity
111
+ # param = inspect.signature(func).parameters.get('entity')
112
+ # default = param.default if param else NYSE
113
+ # entity = args.locals['kwargs'].get('entity', default)
114
+ # return entity
115
+
116
+
117
+ def isdateish(x):
118
+ return isinstance(x, _datetime.date | _datetime.datetime | pd.Timestamp | np.datetime64)
119
+
120
+
121
+ def parse_arg(typ, arg):
122
+ if isdateish(arg):
123
+ if typ == _datetime.datetime:
124
+ return DateTime.instance(arg)
125
+ if typ == _datetime.date:
126
+ return Date.instance(arg)
127
+ if typ == _datetime.time:
128
+ return Time.instance(arg)
129
+ return arg
130
+
131
+
132
+ def parse_args(typ, *args):
133
+ this = []
134
+ for a in args:
135
+ if isinstance(a, Sequence) and not isinstance(a, str):
136
+ this.append(parse_args(typ, *a))
137
+ else:
138
+ this.append(parse_arg(typ, a))
139
+ return this
140
+
141
+
142
+ def expect(func, typ: type[_datetime.date], exclkw: bool = False) -> Callable:
143
+ """Decorator to force input type of date/datetime inputs
144
+ """
145
+ @wraps(func)
146
+ def wrapper(*args, **kwargs):
147
+ args = parse_args(typ, *args)
148
+ if not exclkw:
149
+ for k, v in kwargs.items():
150
+ if isdateish(v):
151
+ if typ == _datetime.datetime:
152
+ kwargs[k] = DateTime.instance(v)
153
+ continue
154
+ if typ == _datetime.date:
155
+ kwargs[k] = Date.instance(v)
156
+ return func(*args, **kwargs)
157
+ return wrapper
158
+
159
+
160
+ expect_date = partial(expect, typ=_datetime.date)
161
+ expect_datetime = partial(expect, typ=_datetime.datetime)
162
+ expect_time = partial(expect, typ=_datetime.time)
163
+
164
+
165
+ def type_class(typ, obj):
166
+ if typ:
167
+ return typ
168
+ if obj.__class__ in {_datetime.datetime, _pendulum.DateTime, DateTime}:
169
+ return DateTime
170
+ if obj.__class__ in {_datetime.date, _pendulum.Date, Date}:
171
+ return Date
172
+ raise ValueError(f'Unknown type {typ}')
173
+
174
+
175
+ def store_entity(func=None, *, typ=None):
176
+ @wraps(func)
177
+ def wrapper(self, *args, **kwargs):
178
+ _entity = self._entity
179
+ d = type_class(typ, self).instance(func(self, *args, **kwargs))
180
+ d._entity = _entity
181
+ return d
182
+ if func is None:
183
+ return partial(store_entity, typ=typ)
184
+ return wrapper
185
+
186
+
187
+ def store_both(func=None, *, typ=None):
188
+ @wraps(func)
189
+ def wrapper(self, *args, **kwargs):
190
+ _entity = self._entity
191
+ _business = self._business
192
+ d = type_class(typ, self).instance(func(self, *args, **kwargs))
193
+ d._entity = _entity
194
+ d._business = _business
195
+ return d
196
+ if func is None:
197
+ return partial(store_both, typ=typ)
198
+ return wrapper
199
+
200
+
201
+ def prefer_utc_timezone(func, force:bool = False):
202
+ """Return datetime as UTC.
203
+ """
204
+ @wraps(func)
205
+ def wrapper(*args, **kwargs):
206
+ d = func(*args, **kwargs)
207
+ if not d:
208
+ return
209
+ if not force and d.tzinfo:
210
+ return d
211
+ return d.replace(tzinfo=UTC)
212
+ return wrapper
213
+
214
+
215
+ def prefer_native_timezone(func, force:bool = False):
216
+ """Return datetime as native.
217
+ """
218
+ @wraps(func)
219
+ def wrapper(*args, **kwargs):
220
+ d = func(*args, **kwargs)
221
+ if not d:
222
+ return
223
+ if not force and d.tzinfo:
224
+ return d
225
+ return d.replace(tzinfo=LCL)
226
+ return wrapper
227
+
228
+
229
+ expect_native_timezone = partial(prefer_native_timezone, force=True)
230
+ expect_utc_timezone = partial(prefer_utc_timezone, force=True)
231
+
232
+
233
+ class Entity(ABC):
234
+ """ABC for named entity types"""
235
+
236
+ tz = UTC
237
+
238
+ @staticmethod
239
+ @abstractmethod
240
+ def business_days(begdate: _datetime.date, enddate: _datetime.date):
241
+ """Returns all business days over a range"""
242
+
243
+ @staticmethod
244
+ @abstractmethod
245
+ def business_hours(begdate: _datetime.date, enddate: _datetime.date):
246
+ """Returns all business open and close times over a range"""
247
+
248
+ @staticmethod
249
+ @abstractmethod
250
+ def business_holidays(begdate: _datetime.date, enddate: _datetime.date):
251
+ """Returns only holidays over a range"""
252
+
253
+
254
+ class NYSE(Entity):
255
+ """New York Stock Exchange"""
256
+
257
+ BEGDATE = _datetime.date(1900, 1, 1)
258
+ ENDDATE = _datetime.date(2200, 1, 1)
259
+ calendar = mcal.get_calendar('NYSE')
260
+
261
+ tz = EST
262
+
263
+ @staticmethod
264
+ @lru_cache
265
+ def business_days(begdate=BEGDATE, enddate=ENDDATE) -> set:
266
+ return {d.date() for d in NYSE.calendar.valid_days(begdate, enddate)}
267
+
268
+ @staticmethod
269
+ @lru_cache
270
+ def business_hours(begdate=BEGDATE, enddate=ENDDATE) -> dict:
271
+ df = NYSE.calendar.schedule(begdate, enddate, tz=EST)
272
+ open_close = [(o.to_pydatetime(), c.to_pydatetime())
273
+ for o, c in zip(df.market_open, df.market_close)]
274
+ return dict(zip(df.index.date, open_close))
275
+
276
+ @staticmethod
277
+ @lru_cache
278
+ def business_holidays(begdate=BEGDATE, enddate=ENDDATE) -> set:
279
+ return {d.date()
280
+ for d in map(pd.to_datetime, NYSE.calendar.holidays().holidays)
281
+ if begdate <= d <= enddate}
282
+
283
+
284
+ class PendulumBusinessDateMixin:
285
+
286
+ _entity: type[NYSE] = NYSE
287
+ _business: bool = False
288
+
289
+ def business(self) -> Self:
290
+ self._business = True
291
+ return self
292
+
293
+ @property
294
+ def b(self) -> Self:
295
+ return self.business()
296
+
297
+ def entity(self, entity: type[NYSE] = NYSE) -> Self:
298
+ self._entity = entity
299
+ return self
300
+
301
+ @store_entity
302
+ def add(self, years: int = 0, months: int = 0, weeks: int = 0, days: int = 0, **kwargs) -> Self:
303
+ """Add wrapper
304
+ If not business use Pendulum
305
+ If business assume only days (for now) and use local logic
306
+ """
307
+ _business = self._business
308
+ self._business = False
309
+ if _business:
310
+ if days == 0:
311
+ return self._business_or_next()
312
+ if days < 0:
313
+ return self.business().subtract(days=abs(days))
314
+ while days > 0:
315
+ self = self._business_next(days=1)
316
+ days -= 1
317
+ return self
318
+ return super().add(years, months, weeks, days, **kwargs)
319
+
320
+ @store_entity
321
+ def subtract(self, years: int = 0, months: int = 0, weeks: int = 0, days: int = 0, **kwargs) -> Self:
322
+ """Subtract wrapper
323
+ If not business use Pendulum
324
+ If business assume only days (for now) and use local logic
325
+ """
326
+ _business = self._business
327
+ self._business = False
328
+ if _business:
329
+ if days == 0:
330
+ return self._business_or_previous()
331
+ if days < 0:
332
+ return self.business().add(days=abs(days))
333
+ while days > 0:
334
+ self = self._business_previous(days=1)
335
+ days -= 1
336
+ return self
337
+ kwargs = {k: -1*v for k,v in kwargs.items()}
338
+ return super().add(-years, -months, -weeks, -days, **kwargs)
339
+
340
+ @store_entity
341
+ def first_of(self, unit: str, day_of_week: WeekDay | None = None) -> Self:
342
+ """Returns an instance set to the first occurrence
343
+ of a given day of the week in the current unit.
344
+ """
345
+ _business = self._business
346
+ self._business = False
347
+ self = super().first_of(unit, day_of_week)
348
+ if _business:
349
+ self = self._business_or_next()
350
+ return self
351
+
352
+ @store_entity
353
+ def last_of(self, unit: str, day_of_week: WeekDay | None = None) -> Self:
354
+ """Returns an instance set to the last occurrence
355
+ of a given day of the week in the current unit.
356
+ """
357
+ _business = self._business
358
+ self._business = False
359
+ self = super().last_of(unit, day_of_week)
360
+ if _business:
361
+ self = self._business_or_previous()
362
+ return self
363
+
364
+ @store_entity
365
+ def start_of(self, unit: str) -> Self:
366
+ """Returns a copy of the instance with the time reset
367
+ """
368
+ _business = self._business
369
+ self._business = False
370
+ self = super().start_of(unit)
371
+ if _business:
372
+ self = self._business_or_next()
373
+ return self
374
+
375
+ @store_entity
376
+ def end_of(self, unit: str) -> Self:
377
+ """Returns a copy of the instance with the time reset
378
+ """
379
+ _business = self._business
380
+ self._business = False
381
+ self = super().end_of(unit)
382
+ if _business:
383
+ self = self._business_or_previous()
384
+ return self
385
+
386
+ @store_entity
387
+ def previous(self, day_of_week: WeekDay | None = None) -> Self:
388
+ """Modify to the previous occurrence of a given day of the week.
389
+ """
390
+ _business = self._business
391
+ self._business = False
392
+ self = super().previous(day_of_week)
393
+ if _business:
394
+ self = self._business_or_next()
395
+ return self
396
+
397
+ @store_entity
398
+ def next(self, day_of_week: WeekDay | None = None) -> Self:
399
+ """Modify to the next occurrence of a given day of the week.
400
+ """
401
+ _business = self._business
402
+ self._business = False
403
+ self = super().next(day_of_week)
404
+ if _business:
405
+ self = self._business_or_previous()
406
+ return self
407
+
408
+ @expect_date
409
+ def business_open(self) -> bool:
410
+ """Business open
411
+
412
+ >>> thedate = Date(2021, 4, 19) # Monday
413
+ >>> thedate.business_open()
414
+ True
415
+ >>> thedate = Date(2021, 4, 17) # Saturday
416
+ >>> thedate.business_open()
417
+ False
418
+ >>> thedate = Date(2021, 1, 18) # MLK Day
419
+ >>> thedate.business_open()
420
+ False
421
+ """
422
+ return self.is_business_day()
423
+
424
+ @expect_date
425
+ def is_business_day(self) -> bool:
426
+ """Is business date.
427
+
428
+ >>> thedate = Date(2021, 4, 19) # Monday
429
+ >>> thedate.is_business_day()
430
+ True
431
+ >>> thedate = Date(2021, 4, 17) # Saturday
432
+ >>> thedate.is_business_day()
433
+ False
434
+ >>> thedate = Date(2021, 1, 18) # MLK Day
435
+ >>> thedate.is_business_day()
436
+ False
437
+ >>> thedate = Date(2021, 11, 25) # Thanksgiving
438
+ >>> thedate.is_business_day()
439
+ False
440
+ >>> thedate = Date(2021, 11, 26) # Day after ^
441
+ >>> thedate.is_business_day()
442
+ True
443
+ """
444
+ return self in self._entity.business_days()
445
+
446
+ @expect_date
447
+ def business_hours(self) -> 'tuple[DateTime, DateTime]':
448
+ """Business hours
449
+
450
+ Returns (None, None) if not a business day
451
+
452
+ >>> thedate = Date(2023, 1, 5)
453
+ >>> thedate.business_hours()
454
+ (... 9, 30, ... 16, 0, ...)
455
+
456
+ >>> thedate = Date(2023, 7, 3)
457
+ >>> thedate.business_hours()
458
+ (... 9, 30, ... 13, 0, ...)
459
+
460
+ >>> thedate = Date(2023, 11, 24)
461
+ >>> thedate.business_hours()
462
+ (... 9, 30, ... 13, 0, ...)
463
+
464
+ >>> thedate = Date(2024, 5, 27) # memorial day
465
+ >>> thedate.business_hours()
466
+ (None, None)
467
+ """
468
+ return self._entity.business_hours(self, self)\
469
+ .get(self, (None, None))
470
+
471
+ @store_both
472
+ def _business_next(self, days=0):
473
+ """Helper for cycling through N business day"""
474
+ days = abs(days)
475
+ while days > 0:
476
+ try:
477
+ self = super().add(days=1)
478
+ except OverflowError:
479
+ break
480
+ if self.is_business_day():
481
+ days -= 1
482
+ return self
483
+
484
+ @store_both
485
+ def _business_previous(self, days=0):
486
+ """Helper for cycling through N business day"""
487
+ days = abs(days)
488
+ while days > 0:
489
+ try:
490
+ self = super().add(days=-1)
491
+ except OverflowError:
492
+ break
493
+ if self.is_business_day():
494
+ days -= 1
495
+ return self
496
+
497
+ @store_entity
498
+ def _business_or_next(self):
499
+ self._business = False
500
+ self = super().subtract(days=1)
501
+ self = self._business_next(days=1)
502
+ return self
503
+
504
+ @store_entity
505
+ def _business_or_previous(self):
506
+ self._business = False
507
+ self = super().add(days=1)
508
+ self = self._business_previous(days=1)
509
+ return self
510
+
511
+
512
+ class Date(PendulumBusinessDateMixin, _pendulum.Date):
513
+ """Inherits and wraps pendulum.Date
514
+ """
515
+
516
+ def to_string(self, fmt: str) -> str:
517
+ """Format cleaner https://stackoverflow.com/a/2073189.
518
+
519
+ >>> Date(2022, 1, 5).to_string('%-m/%-d/%Y')
520
+ '1/5/2022'
521
+ """
522
+ return self.strftime(fmt.replace('%-', '%#') if os.name == 'nt' else fmt)
523
+
524
+ @classmethod
525
+ def parse(
526
+ cls,
527
+ s: str | None,
528
+ fmt: str = None,
529
+ entity: Entity = NYSE,
530
+ raise_err: bool = False,
531
+ ) -> Self | None:
532
+ """Convert a string to a date handling many different formats.
533
+
534
+ creating a new Date object
535
+ >>> Date.parse('2022/1/1')
536
+ Date(2022, 1, 1)
537
+
538
+ previous business day accessed with 'P'
539
+ >>> Date.parse('P')==Date.today().b.subtract(days=1)
540
+ True
541
+ >>> Date.parse('T-3b')==Date.today().b.subtract(days=3)
542
+ True
543
+ >>> Date.parse('T-3b')==Date.today().b.add(days=-3)
544
+ True
545
+ >>> Date.parse('T+3b')==Date.today().b.subtract(days=-3)
546
+ True
547
+ >>> Date.parse('T+3b')==Date.today().b.add(days=3)
548
+ True
549
+ >>> Date.parse('M')==Date.today().start_of('month').subtract(days=1)
550
+ True
551
+
552
+ m[/-]d[/-]yyyy 6-23-2006
553
+ >>> Date.parse('6-23-2006')
554
+ Date(2006, 6, 23)
555
+
556
+ m[/-]d[/-]yy 6/23/06
557
+ >>> Date.parse('6/23/06')
558
+ Date(2006, 6, 23)
559
+
560
+ m[/-]d 6/23
561
+ >>> Date.parse('6/23') == Date(Date.today().year, 6, 23)
562
+ True
563
+
564
+ yyyy-mm-dd 2006-6-23
565
+ >>> Date.parse('2006-6-23')
566
+ Date(2006, 6, 23)
567
+
568
+ yyyymmdd 20060623
569
+ >>> Date.parse('20060623')
570
+ Date(2006, 6, 23)
571
+
572
+ dd-mon-yyyy 23-JUN-2006
573
+ >>> Date.parse('23-JUN-2006')
574
+ Date(2006, 6, 23)
575
+
576
+ mon-dd-yyyy JUN-23-2006
577
+ >>> Date.parse('20 Jan 2009')
578
+ Date(2009, 1, 20)
579
+
580
+ month dd, yyyy June 23, 2006
581
+ >>> Date.parse('June 23, 2006')
582
+ Date(2006, 6, 23)
583
+
584
+ dd-mon-yy
585
+ >>> Date.parse('23-May-12')
586
+ Date(2012, 5, 23)
587
+
588
+ ddmonyyyy
589
+ >>> Date.parse('23May2012')
590
+ Date(2012, 5, 23)
591
+
592
+ >>> Date.parse('Oct. 24, 2007', fmt='%b. %d, %Y')
593
+ Date(2007, 10, 24)
594
+
595
+ >>> Date.parse('Yesterday') == DateTime.now().subtract(days=1).date()
596
+ True
597
+ >>> Date.parse('TODAY') == Date.today()
598
+ True
599
+ >>> Date.parse('Jan. 13, 2014')
600
+ Date(2014, 1, 13)
601
+
602
+ >>> Date.parse('March') == Date(Date.today().year, 3, Date.today().day)
603
+ True
604
+
605
+ only raise error when we explicitly say so
606
+ >>> Date.parse('bad date') is None
607
+ True
608
+ >>> Date.parse('bad date', raise_err=True)
609
+ Traceback (most recent call last):
610
+ ...
611
+ ValueError: Failed to parse date: bad date
612
+ """
613
+
614
+ def date_for_symbol(s):
615
+ if s == 'N':
616
+ return cls.today()
617
+ if s == 'T':
618
+ return cls.today()
619
+ if s == 'Y':
620
+ return cls.today().subtract(days=1)
621
+ if s == 'P':
622
+ return cls.today().entity(entity).business().subtract(days=1)
623
+ if s == 'M':
624
+ return cls.today().start_of('month').subtract(days=1)
625
+
626
+ def year(m):
627
+ try:
628
+ yy = int(m.group('y'))
629
+ if yy < 100:
630
+ yy += 2000
631
+ except IndexError:
632
+ logger.debug('Using default this year')
633
+ yy = cls.today().year
634
+ return yy
635
+
636
+ if not s:
637
+ if raise_err:
638
+ raise ValueError('Empty value')
639
+ return
640
+
641
+ if not isinstance(s, str):
642
+ raise TypeError(f'Invalid type for date parse: {s.__class__}')
643
+
644
+ if fmt:
645
+ with contextlib.suppress(ValueError):
646
+ return cls(*time.strptime(s, fmt)[:3])
647
+
648
+ # special shortcode symbolic values: T, Y-2, P-1b
649
+ if m := DATEMATCH.match(s):
650
+ d = date_for_symbol(m.groupdict().get('d'))
651
+ n = m.groupdict().get('n')
652
+ if not n:
653
+ return d
654
+ n = int(n)
655
+ b = m.groupdict().get('b')
656
+ if b:
657
+ assert b == 'b'
658
+ d = d.entity(entity).business().add(days=n)
659
+ else:
660
+ d = d.add(days=n)
661
+ return d
662
+ if 'today' in s.lower():
663
+ return cls.today()
664
+ if 'yester' in s.lower():
665
+ return cls.today().subtract(days=1)
666
+
667
+ with contextlib.suppress(TypeError, ValueError):
668
+ return cls.instance(_dateutil.parser.parse(s).date())
669
+
670
+ # Regex with Month Numbers
671
+ exps = (
672
+ r'^(?P<m>\d{1,2})[/-](?P<d>\d{1,2})[/-](?P<y>\d{4})$',
673
+ r'^(?P<m>\d{1,2})[/-](?P<d>\d{1,2})[/-](?P<y>\d{1,2})$',
674
+ r'^(?P<m>\d{1,2})[/-](?P<d>\d{1,2})$',
675
+ r'^(?P<y>\d{4})-(?P<m>\d{1,2})-(?P<d>\d{1,2})$',
676
+ r'^(?P<y>\d{4})(?P<m>\d{2})(?P<d>\d{2})$',
677
+ )
678
+ for exp in exps:
679
+ if m := re.match(exp, s):
680
+ mm = int(m.group('m'))
681
+ dd = int(m.group('d'))
682
+ yy = year(m)
683
+ return cls(yy, mm, dd)
684
+
685
+ # Regex with Month Name
686
+ exps = (
687
+ r'^(?P<d>\d{1,2})[- ](?P<m>[A-Za-z]{3,})[- ](?P<y>\d{4})$',
688
+ r'^(?P<m>[A-Za-z]{3,})[- ](?P<d>\d{1,2})[- ](?P<y>\d{4})$',
689
+ r'^(?P<m>[A-Za-z]{3,}) (?P<d>\d{1,2}), (?P<y>\d{4})$',
690
+ r'^(?P<d>\d{2})(?P<m>[A-Z][a-z]{2})(?P<y>\d{4})$',
691
+ r'^(?P<d>\d{1,2})-(?P<m>[A-Z][a-z][a-z])-(?P<y>\d{2})$',
692
+ r'^(?P<d>\d{1,2})-(?P<m>[A-Z]{3})-(?P<y>\d{2})$',
693
+ )
694
+ for exp in exps:
695
+ if m := re.match(exp, s):
696
+ try:
697
+ mm = MONTH_SHORTNAME[m.group('m').lower()[:3]]
698
+ except KeyError:
699
+ logger.debug('Month name did not match MONTH_SHORTNAME')
700
+ continue
701
+ dd = int(m.group('d'))
702
+ yy = year(m)
703
+ return cls(yy, mm, dd)
704
+
705
+ if raise_err:
706
+ raise ValueError('Failed to parse date: %s', s)
707
+
708
+ @classmethod
709
+ def instance(
710
+ cls,
711
+ obj: _datetime.date
712
+ | _datetime.datetime
713
+ | _datetime.time
714
+ | pd.Timestamp
715
+ | np.datetime64
716
+ | Self
717
+ | None,
718
+ raise_err: bool = False,
719
+ ) -> Self | None:
720
+ """From datetime.date like object
721
+
722
+ >>> Date.instance(_datetime.date(2022, 1, 1))
723
+ Date(2022, 1, 1)
724
+ >>> Date.instance(Date(2022, 1, 1))
725
+ Date(2022, 1, 1)
726
+ >>> Date.instance(_pendulum.Date(2022, 1, 1))
727
+ Date(2022, 1, 1)
728
+ >>> Date.instance(Date(2022, 1, 1))
729
+ Date(2022, 1, 1)
730
+ >>> Date.instance(np.datetime64('2000-01', 'D'))
731
+ Date(2000, 1, 1)
732
+ >>> Date.instance(None)
733
+
734
+ """
735
+ if pd.isna(obj):
736
+ if raise_err:
737
+ raise ValueError('Empty value')
738
+ return
739
+
740
+ if obj.__class__ == cls:
741
+ return obj
742
+
743
+ if isinstance(obj, np.datetime64 | pd.Timestamp):
744
+ obj = DateTime.instance(obj)
745
+
746
+ return cls(obj.year, obj.month, obj.day)
747
+
748
+ @classmethod
749
+ def today(cls):
750
+ d = _datetime.date.today()
751
+ return cls(d.year, d.month, d.day)
752
+
753
+ def isoweek(self):
754
+ """Week number 1-52 following ISO week-numbering
755
+
756
+ Standard weeks
757
+ >>> Date(2023, 1, 2).isoweek()
758
+ 1
759
+ >>> Date(2023, 4, 27).isoweek()
760
+ 17
761
+ >>> Date(2023, 12, 31).isoweek()
762
+ 52
763
+
764
+ Belongs to week of previous year
765
+ >>> Date(2023, 1, 1).isoweek()
766
+ 52
767
+ """
768
+ with contextlib.suppress(Exception):
769
+ return self.isocalendar()[1]
770
+
771
+ """
772
+ See how pendulum does end_of and next_ with getattr
773
+
774
+ Create a nearest [start_of, end_of] [week, day, month, quarter, year]
775
+
776
+ combo that accounts for whatever prefix and unit is passed in
777
+ """
778
+
779
+ def nearest_start_of_month(self):
780
+ """Get `nearest` start of month
781
+
782
+ 1/1/2015 -> Thursday (New Year's Day)
783
+ 2/1/2015 -> Sunday
784
+
785
+ >>> Date(2015, 1, 1).nearest_start_of_month()
786
+ Date(2015, 1, 1)
787
+ >>> Date(2015, 1, 15).nearest_start_of_month()
788
+ Date(2015, 1, 1)
789
+ >>> Date(2015, 1, 15).b.nearest_start_of_month()
790
+ Date(2015, 1, 2)
791
+ >>> Date(2015, 1, 16).nearest_start_of_month()
792
+ Date(2015, 2, 1)
793
+ >>> Date(2015, 1, 31).nearest_start_of_month()
794
+ Date(2015, 2, 1)
795
+ >>> Date(2015, 1, 31).b.nearest_start_of_month()
796
+ Date(2015, 2, 2)
797
+ """
798
+ _business = self._business
799
+ self._business = False
800
+ if self.day > 15:
801
+ d = self.end_of('month')
802
+ if _business:
803
+ return d.business().add(days=1)
804
+ return d.add(days=1)
805
+ d = self.start_of('month')
806
+ if _business:
807
+ return d.business().add(days=1)
808
+ return d
809
+
810
+ def nearest_end_of_month(self):
811
+ """Get `nearest` end of month
812
+
813
+ 12/31/2014 -> Wednesday
814
+ 1/31/2015 -> Saturday
815
+
816
+ >>> Date(2015, 1, 1).nearest_end_of_month()
817
+ Date(2014, 12, 31)
818
+ >>> Date(2015, 1, 15).nearest_end_of_month()
819
+ Date(2014, 12, 31)
820
+ >>> Date(2015, 1, 15).b.nearest_end_of_month()
821
+ Date(2014, 12, 31)
822
+ >>> Date(2015, 1, 16).nearest_end_of_month()
823
+ Date(2015, 1, 31)
824
+ >>> Date(2015, 1, 31).nearest_end_of_month()
825
+ Date(2015, 1, 31)
826
+ >>> Date(2015, 1, 31).b.nearest_end_of_month()
827
+ Date(2015, 1, 30)
828
+ """
829
+ _business = self._business
830
+ self._business = False
831
+ if self.day <= 15:
832
+ d = self.start_of('month')
833
+ if _business:
834
+ return d.business().subtract(days=1)
835
+ return d.subtract(days=1)
836
+ d = self.end_of('month')
837
+ if _business:
838
+ return d.business().subtract(days=1)
839
+ return d
840
+
841
+ def next_relative_date_of_week_by_day(self, day='MO'):
842
+ """Get next relative day of week by relativedelta code
843
+
844
+ >>> Date(2020, 5, 18).next_relative_date_of_week_by_day('SU')
845
+ Date(2020, 5, 24)
846
+ >>> Date(2020, 5, 24).next_relative_date_of_week_by_day('SU')
847
+ Date(2020, 5, 24)
848
+ """
849
+ if self.weekday() == WEEKDAY_SHORTNAME.get(day):
850
+ return self
851
+ return self.next(WEEKDAY_SHORTNAME.get(day))
852
+
853
+ def weekday_or_previous_friday(self):
854
+ """Return the date if it is a weekday, else previous Friday
855
+
856
+ >>> Date(2019, 10, 6).weekday_or_previous_friday() # Sunday
857
+ Date(2019, 10, 4)
858
+ >>> Date(2019, 10, 5).weekday_or_previous_friday() # Saturday
859
+ Date(2019, 10, 4)
860
+ >>> Date(2019, 10, 4).weekday_or_previous_friday() # Friday
861
+ Date(2019, 10, 4)
862
+ >>> Date(2019, 10, 3).weekday_or_previous_friday() # Thursday
863
+ Date(2019, 10, 3)
864
+ """
865
+ dnum = self.weekday()
866
+ if dnum in {WeekDay.SATURDAY, WeekDay.SUNDAY}:
867
+ return self.subtract(days=dnum - 4)
868
+ return self
869
+
870
+ def lookback(self, unit='last') -> Self:
871
+ """Date back based on lookback string, ie last, week, month.
872
+
873
+ >>> Date(2018, 12, 7).b.lookback('last')
874
+ Date(2018, 12, 6)
875
+ >>> Date(2018, 12, 7).b.lookback('day')
876
+ Date(2018, 12, 6)
877
+ >>> Date(2018, 12, 7).b.lookback('week')
878
+ Date(2018, 11, 30)
879
+ >>> Date(2018, 12, 7).b.lookback('month')
880
+ Date(2018, 11, 7)
881
+ """
882
+ def _lookback(years=0, months=0, weeks=0, days=0):
883
+ _business = self._business
884
+ self._business = False
885
+ d = self\
886
+ .subtract(years=years, months=months, weeks=weeks, days=days)
887
+ if _business:
888
+ return d._business_or_previous()
889
+ return d
890
+
891
+ return {
892
+ 'day': _lookback(days=1),
893
+ 'last': _lookback(days=1),
894
+ 'week': _lookback(weeks=1),
895
+ 'month': _lookback(months=1),
896
+ 'quarter': _lookback(months=3),
897
+ 'year': _lookback(years=1),
898
+ }.get(unit)
899
+
900
+ """
901
+ create a simple nth weekday function that accounts for
902
+ [1,2,3,4] and weekday as options
903
+ or weekday, [1,2,3,4]
904
+
905
+ """
906
+
907
+ @staticmethod
908
+ def third_wednesday(year, month):
909
+ """Third Wednesday date of a given month/year
910
+
911
+ >>> Date.third_wednesday(2022, 6)
912
+ Date(2022, 6, 15)
913
+ >>> Date.third_wednesday(2023, 3)
914
+ Date(2023, 3, 15)
915
+ >>> Date.third_wednesday(2022, 12)
916
+ Date(2022, 12, 21)
917
+ >>> Date.third_wednesday(2023, 6)
918
+ Date(2023, 6, 21)
919
+ """
920
+ third = Date(year, month, 15) # lowest 3rd day
921
+ w = third.weekday()
922
+ if w != WeekDay.WEDNESDAY:
923
+ third = third.replace(day=(15 + (WeekDay.WEDNESDAY - w) % 7))
924
+ return third
925
+
926
+
927
+ class Time(_pendulum.Time):
928
+
929
+ @classmethod
930
+ @prefer_utc_timezone
931
+ def parse(cls, s: str | None, fmt: str | None = None, raise_err: bool = False) -> Self | None:
932
+ """Convert a string to a time handling many formats::
933
+
934
+ handle many time formats:
935
+ hh[:.]mm
936
+ hh[:.]mm am/pm
937
+ hh[:.]mm[:.]ss
938
+ hh[:.]mm[:.]ss[.,]uuu am/pm
939
+ hhmmss[.,]uuu
940
+ hhmmss[.,]uuu am/pm
941
+
942
+ >>> Time.parse('9:30')
943
+ Time(9, 30, 0, tzinfo=Timezone('UTC'))
944
+ >>> Time.parse('9:30:15')
945
+ Time(9, 30, 15, tzinfo=Timezone('UTC'))
946
+ >>> Time.parse('9:30:15.751')
947
+ Time(9, 30, 15, 751000, tzinfo=Timezone('UTC'))
948
+ >>> Time.parse('9:30 AM')
949
+ Time(9, 30, 0, tzinfo=Timezone('UTC'))
950
+ >>> Time.parse('9:30 pm')
951
+ Time(21, 30, 0, tzinfo=Timezone('UTC'))
952
+ >>> Time.parse('9:30:15.751 PM')
953
+ Time(21, 30, 15, 751000, tzinfo=Timezone('UTC'))
954
+ >>> Time.parse('0930') # Date treats this as a date, careful!!
955
+ Time(9, 30, 0, tzinfo=Timezone('UTC'))
956
+ >>> Time.parse('093015')
957
+ Time(9, 30, 15, tzinfo=Timezone('UTC'))
958
+ >>> Time.parse('093015,751')
959
+ Time(9, 30, 15, 751000, tzinfo=Timezone('UTC'))
960
+ >>> Time.parse('0930 pm')
961
+ Time(21, 30, 0, tzinfo=Timezone('UTC'))
962
+ >>> Time.parse('093015,751 PM')
963
+ Time(21, 30, 15, 751000, tzinfo=Timezone('UTC'))
964
+ """
965
+
966
+ def seconds(m):
967
+ try:
968
+ return int(m.group('s'))
969
+ except Exception:
970
+ return 0
971
+
972
+ def micros(m):
973
+ try:
974
+ return int(m.group('u'))
975
+ except Exception:
976
+ return 0
977
+
978
+ def is_pm(m):
979
+ try:
980
+ return m.group('ap').lower() == 'pm'
981
+ except Exception:
982
+ return False
983
+
984
+ if not s:
985
+ if raise_err:
986
+ raise ValueError('Empty value')
987
+ return
988
+
989
+ if not isinstance(s, str):
990
+ raise TypeError(f'Invalid type for time parse: {s.__class__}')
991
+
992
+ if fmt:
993
+ return cls(*time.strptime(s, fmt)[3:6])
994
+
995
+ exps = (
996
+ r'^(?P<h>\d{1,2})[:.](?P<m>\d{2})([:.](?P<s>\d{2})([.,](?P<u>\d+))?)?( +(?P<ap>[aApP][mM]))?$',
997
+ r'^(?P<h>\d{2})(?P<m>\d{2})((?P<s>\d{2})([.,](?P<u>\d+))?)?( +(?P<ap>[aApP][mM]))?$',
998
+ )
999
+
1000
+ for exp in exps:
1001
+ if m := re.match(exp, s):
1002
+ hh = int(m.group('h'))
1003
+ mm = int(m.group('m'))
1004
+ ss = seconds(m)
1005
+ uu = micros(m)
1006
+ if is_pm(m) and hh < 12:
1007
+ hh += 12
1008
+ return cls(hh, mm, ss, uu * 1000)
1009
+
1010
+ with contextlib.suppress(TypeError, ValueError):
1011
+ return cls.instance(_dateutil.parser.parse(s).time())
1012
+
1013
+ if raise_err:
1014
+ raise ValueError('Failed to parse time: %s', s)
1015
+
1016
+ @classmethod
1017
+ def instance(
1018
+ cls,
1019
+ obj: _datetime.time
1020
+ | _datetime.datetime
1021
+ | pd.Timestamp
1022
+ | np.datetime64
1023
+ | Self
1024
+ | None,
1025
+ tz: str | _zoneinfo.ZoneInfo | _datetime.tzinfo | None = UTC,
1026
+ raise_err: bool = False,
1027
+ ) -> Self | None:
1028
+ """From datetime-like object
1029
+
1030
+ >>> Time.instance(_datetime.time(12, 30, 1))
1031
+ Time(12, 30, 1, tzinfo=Timezone('UTC'))
1032
+ >>> Time.instance(_pendulum.Time(12, 30, 1))
1033
+ Time(12, 30, 1, tzinfo=Timezone('UTC'))
1034
+ >>> Time.instance(None)
1035
+
1036
+ like Pendulum, do not add timzone if no timezone and Time object
1037
+ >>> Time.instance(Time(12, 30, 1))
1038
+ Time(12, 30, 1)
1039
+
1040
+ """
1041
+ if pd.isna(obj):
1042
+ if raise_err:
1043
+ raise ValueError('Empty value')
1044
+ return
1045
+
1046
+ if obj.__class__ == cls:
1047
+ return obj
1048
+
1049
+ return cls(obj.hour, obj.minute, obj.second, obj.microsecond,
1050
+ tzinfo=obj.tzinfo or tz)
1051
+
1052
+
1053
+ class DateTime(PendulumBusinessDateMixin, _pendulum.DateTime):
1054
+ """Inherits and wraps pendulum.DateTime
1055
+ """
1056
+
1057
+ def epoch(self):
1058
+ """Translate a datetime object into unix seconds since epoch
1059
+ """
1060
+ return self.timestamp()
1061
+
1062
+ @classmethod
1063
+ def now(cls, tz: str | _zoneinfo.ZoneInfo | _datetime.tzinfo | None = None) -> Self:
1064
+ """Get a DateTime instance for the current date and time.
1065
+ """
1066
+ if tz is None or tz == 'local':
1067
+ d = _datetime.datetime.now(LCL)
1068
+ elif tz is UTC or tz == 'UTC':
1069
+ d = _datetime.datetime.now(UTC)
1070
+ else:
1071
+ d = _datetime.datetime.now(UTC)
1072
+ tz = _pendulum._safe_timezone(tz)
1073
+ d = d.astimezone(tz)
1074
+ return cls(d.year, d.month, d.day, d.hour, d.minute, d.second,
1075
+ d.microsecond, tzinfo=d.tzinfo, fold=d.fold)
1076
+
1077
+ @classmethod
1078
+ def today(cls, tz: str | _zoneinfo.ZoneInfo | None = None):
1079
+ """Unlike Pendulum, returns DateTime object at start of day
1080
+ """
1081
+ return DateTime.now(tz).start_of('day')
1082
+
1083
+ def date(self):
1084
+ return Date(self.year, self.month, self.day)
1085
+
1086
+ @classmethod
1087
+ def combine(
1088
+ cls,
1089
+ date: _datetime.date,
1090
+ time: _datetime.time,
1091
+ tzinfo: _zoneinfo.ZoneInfo | None = None,
1092
+ ) -> Self:
1093
+ """Combine date and time (*behaves differently from Pendulum `combine`*).
1094
+ """
1095
+ _tzinfo = tzinfo or time.tzinfo
1096
+ return DateTime.instance(_datetime.datetime.combine(date, time, tzinfo=_tzinfo))
1097
+
1098
+ def rfc3339(self):
1099
+ """
1100
+ >>> DateTime.parse('Fri, 31 Oct 2014 10:55:00')
1101
+ DateTime(2014, 10, 31, 10, 55, 0, tzinfo=Timezone('UTC'))
1102
+ >>> DateTime.parse('Fri, 31 Oct 2014 10:55:00').rfc3339()
1103
+ '2014-10-31T10:55:00+00:00'
1104
+ """
1105
+ return self.isoformat()
1106
+
1107
+ def time(self):
1108
+ """Extract time from self (preserve timezone)
1109
+
1110
+ >>> d = DateTime(2022, 1, 1, 12, 30, 15, tzinfo=EST)
1111
+ >>> d.time()
1112
+ Time(12, 30, 15, tzinfo=Timezone('US/Eastern'))
1113
+
1114
+ >>> d = DateTime(2022, 1, 1, 12, 30, 15, tzinfo=UTC)
1115
+ >>> d.time()
1116
+ Time(12, 30, 15, tzinfo=Timezone('UTC'))
1117
+ """
1118
+ return Time.instance(self)
1119
+
1120
+ @classmethod
1121
+ def parse(cls, s: str | int | None, raise_err: bool = False) -> Self | None:
1122
+ """Thin layer on Date parser and our custom `Date.parse``
1123
+
1124
+ >>> DateTime.parse('2022/1/1')
1125
+ DateTime(2022, 1, 1, 0, 0, 0, tzinfo=Timezone('...'))
1126
+
1127
+ Assume UTC, convert to EST
1128
+ >>> this_est1 = DateTime.parse('Fri, 31 Oct 2014 18:55:00').in_timezone(EST)
1129
+ >>> this_est1
1130
+ DateTime(2014, 10, 31, 14, 55, 0, tzinfo=Timezone('US/Eastern'))
1131
+
1132
+ This is actually 18:55 UTC with -4 hours applied = EST
1133
+ >>> this_est2 = DateTime.parse('Fri, 31 Oct 2014 14:55:00 -0400')
1134
+ >>> this_est2
1135
+ DateTime(2014, 10, 31, 14, 55, 0, tzinfo=...)
1136
+
1137
+ UTC time technically equals GMT
1138
+ >>> this_utc = DateTime.parse('Fri, 31 Oct 2014 18:55:00 GMT')
1139
+ >>> this_utc
1140
+ DateTime(2014, 10, 31, 18, 55, 0, tzinfo=Timezone('UTC'))
1141
+
1142
+ We can freely compare time zones
1143
+ >>> this_est1==this_est2==this_utc
1144
+ True
1145
+
1146
+ Format tests
1147
+ >>> DateTime.parse(1707856982).replace(tzinfo=UTC).epoch()
1148
+ 1707856982.0
1149
+ >>> DateTime.parse('Jan 29 2010')
1150
+ DateTime(2010, 1, 29, 0, 0, 0, tzinfo=Timezone('UTC'))
1151
+ >>> _ = DateTime.parse('Sep 27 17:11')
1152
+ >>> _.month, _.day, _.hour, _.minute
1153
+ (9, 27, 17, 11)
1154
+ """
1155
+ if not s:
1156
+ if raise_err:
1157
+ raise ValueError('Empty value')
1158
+ return
1159
+
1160
+ if not isinstance(s, str | int | float):
1161
+ raise TypeError(f'Invalid type for datetime parse: {s.__class__}')
1162
+
1163
+ if isinstance(s, int | float):
1164
+ iso = _datetime.datetime.fromtimestamp(s).isoformat()
1165
+ return cls.parse(iso).replace(tzinfo=LCL)
1166
+
1167
+ with contextlib.suppress(ValueError, TypeError):
1168
+ obj = _dateutil.parser.parse(s)
1169
+ return cls.instance(_pendulum.instance(obj))
1170
+
1171
+ for delim in (' ', ':'):
1172
+ bits = s.split(delim, 1)
1173
+ if len(bits) == 2:
1174
+ d = Date.parse(bits[0])
1175
+ t = Time.parse(bits[1])
1176
+ if d is not None and t is not None:
1177
+ return DateTime.combine(d, t, LCL)
1178
+
1179
+ d = Date.parse(s)
1180
+ if d is not None:
1181
+ return cls(d.year, d.month, d.day, 0, 0, 0)
1182
+
1183
+ current = Date.today()
1184
+ t = Time.parse(s)
1185
+ if t is not None:
1186
+ return cls.combine(current, t, LCL)
1187
+
1188
+ if raise_err:
1189
+ raise ValueError('Invalid date-time format: %s', s)
1190
+
1191
+ @classmethod
1192
+ def instance(
1193
+ cls,
1194
+ obj: _datetime.date
1195
+ | _datetime.time
1196
+ | pd.Timestamp
1197
+ | np.datetime64
1198
+ | Self
1199
+ | None,
1200
+ tz: str | _zoneinfo.ZoneInfo | _datetime.tzinfo | None = UTC,
1201
+ raise_err: bool = False,
1202
+ ) -> Self | None:
1203
+ """From datetime-like object
1204
+
1205
+ >>> DateTime.instance(_datetime.date(2022, 1, 1))
1206
+ DateTime(2022, 1, 1, 0, 0, 0, tzinfo=Timezone('...'))
1207
+ >>> DateTime.instance(Date(2022, 1, 1))
1208
+ DateTime(2022, 1, 1, 0, 0, 0, tzinfo=Timezone('...'))
1209
+ >>> DateTime.instance(_datetime.datetime(2022, 1, 1, 0, 0, 0))
1210
+ DateTime(2022, 1, 1, 0, 0, 0, tzinfo=Timezone('...'))
1211
+ >>> DateTime.instance(_pendulum.DateTime(2022, 1, 1, 0, 0, 0))
1212
+ DateTime(2022, 1, 1, 0, 0, 0, tzinfo=Timezone('...'))
1213
+ >>> DateTime.instance(None)
1214
+
1215
+ like Pendulum, do not add timzone if no timezone and DateTime object
1216
+ >>> DateTime.instance(DateTime(2022, 1, 1, 0, 0, 0))
1217
+ DateTime(2022, 1, 1, 0, 0, 0)
1218
+ >>> DateTime.instance(DateTime(2000, 1, 1))
1219
+ DateTime(2000, 1, 1, 0, 0, 0)
1220
+
1221
+ no tz -> UTC
1222
+ >>> DateTime.instance(Time(4, 4, 21))
1223
+ DateTime(..., 4, 4, 21, tzinfo=Timezone('UTC'))
1224
+
1225
+ tzinfo on time -> time tzinfo (precedence)
1226
+ >>> DateTime.instance(Time(4, 4, 21, tzinfo=UTC))
1227
+ DateTime(..., 4, 4, 21, tzinfo=Timezone('UTC'))
1228
+ >>> DateTime.instance(Time(4, 4, 21, tzinfo=LCL))
1229
+ DateTime(..., 4, 4, 21, tzinfo=Timezone('...'))
1230
+
1231
+ >>> DateTime.instance(np.datetime64('2000-01', 'D'))
1232
+ DateTime(2000, 1, 1, 0, 0, 0, tzinfo=Timezone('UTC'))
1233
+
1234
+ Convert date to datetime (will use native time zone)
1235
+ >>> DateTime.instance(_datetime.date(2000, 1, 1))
1236
+ DateTime(2000, 1, 1, 0, 0, 0, tzinfo=Timezone('...'))
1237
+
1238
+ """
1239
+ if pd.isna(obj):
1240
+ if raise_err:
1241
+ raise ValueError('Empty value')
1242
+ return
1243
+
1244
+ if obj.__class__ == cls:
1245
+ return obj
1246
+
1247
+ if isinstance(obj, pd.Timestamp):
1248
+ obj = obj.to_pydatetime()
1249
+ return cls.instance(obj, tz=tz)
1250
+ if isinstance(obj, np.datetime64):
1251
+ obj = np.datetime64(obj, 'us').astype(_datetime.datetime)
1252
+ return cls.instance(obj, tz=tz)
1253
+
1254
+ if obj.__class__ == Time:
1255
+ return cls.combine(Date.today(), obj, tzinfo=tz)
1256
+ if isinstance(obj, _datetime.time):
1257
+ return cls.combine(Date.today(), obj, tzinfo=tz)
1258
+
1259
+ if obj.__class__ == Date:
1260
+ return cls(obj.year, obj.month, obj.day, tzinfo=tz)
1261
+ if isinstance(obj, _datetime.date) and not isinstance(obj, _datetime.datetime):
1262
+ return cls(obj.year, obj.month, obj.day, tzinfo=tz)
1263
+
1264
+ return cls(obj.year, obj.month, obj.day, obj.hour, obj.minute,
1265
+ obj.second, obj.microsecond, obj.tzinfo or tz)
1266
+
1267
+
1268
+ class IntervalError(AttributeError):
1269
+ pass
1270
+
1271
+
1272
+ class Interval:
1273
+
1274
+ _business: bool = False
1275
+ _entity: type[NYSE] = NYSE
1276
+
1277
+ def __init__(self, begdate: str | Date | None = None, enddate: str | Date | None = None):
1278
+ self.begdate = Date.parse(begdate) if isinstance(begdate, str) else Date.instance(begdate)
1279
+ self.enddate = Date.parse(enddate) if isinstance(enddate, str) else Date.instance(enddate)
1280
+
1281
+ def business(self) -> Self:
1282
+ self._business = True
1283
+ if self.begdate:
1284
+ self.begdate.business()
1285
+ if self.enddate:
1286
+ self.enddate.business()
1287
+ return self
1288
+
1289
+ @property
1290
+ def b(self) -> Self:
1291
+ return self.business()
1292
+
1293
+ def entity(self, e: type[NYSE] = NYSE) -> Self:
1294
+ self._entity = e
1295
+ if self.begdate:
1296
+ self.enddate._entity = e
1297
+ if self.enddate:
1298
+ self.enddate._entity = e
1299
+ return self
1300
+
1301
+ def range(self, window=0) -> tuple[_datetime.date, _datetime.date]:
1302
+ """Set date ranges based on begdate, enddate and window.
1303
+
1304
+ The combinations are as follows:
1305
+
1306
+ beg end num action
1307
+ --- --- --- ---------------------
1308
+ - - - Error, underspecified
1309
+ set set set Error, overspecified
1310
+ set set -
1311
+ set - - end=max date
1312
+ - set - beg=min date
1313
+ - - set end=max date, beg=end - num
1314
+ set - set end=beg + num
1315
+ - set set beg=end - num
1316
+
1317
+ >>> Interval('4/3/2014', None).b.range(3)
1318
+ (Date(2014, 4, 3), Date(2014, 4, 8))
1319
+ >>> Interval(None, Date(2014, 7, 27)).range(20)
1320
+ (Date(2014, 7, 7), Date(2014, 7, 27))
1321
+ >>> Interval(None, '2014/7/27').b.range(20)
1322
+ (Date(2014, 6, 27), Date(2014, 7, 27))
1323
+ """
1324
+ begdate, enddate = self.begdate, self.enddate
1325
+
1326
+ window = abs(int(window or 0))
1327
+
1328
+ if begdate and enddate and window:
1329
+ raise IntervalError('Window requested and begdate and enddate provided')
1330
+ if not begdate and not enddate and not window:
1331
+ raise IntervalError('Missing begdate, enddate, and window')
1332
+ if not begdate and not enddate and window:
1333
+ raise IntervalError('Missing begdate and enddate, window specified')
1334
+
1335
+ if begdate and enddate:
1336
+ return (begdate.business() if begdate._business else
1337
+ begdate).add(days=window), \
1338
+ (enddate.business() if enddate._business else
1339
+ enddate).subtract(days=0)
1340
+
1341
+ if (not begdate and not enddate) or enddate:
1342
+ begdate = (enddate.business() if enddate._business else
1343
+ enddate).subtract(days=window)
1344
+ else:
1345
+ enddate = (begdate.business() if begdate._business else
1346
+ begdate).add(days=window)
1347
+
1348
+ return begdate, enddate
1349
+
1350
+ def is_business_day_series(self) -> list[bool]:
1351
+ """Is business date range.
1352
+
1353
+ >>> list(Interval(Date(2018, 11, 19), Date(2018, 11, 25)).is_business_day_series())
1354
+ [True, True, True, False, True, False, False]
1355
+ >>> list(Interval(Date(2021, 11, 22),Date(2021, 11, 28)).is_business_day_series())
1356
+ [True, True, True, False, True, False, False]
1357
+ """
1358
+ for thedate in self.series():
1359
+ yield thedate.is_business_day()
1360
+
1361
+ def series(self, window=0):
1362
+ """Get a series of datetime.date objects.
1363
+
1364
+ give the function since and until wherever possible (more explicit)
1365
+ else pass in a window to back out since or until
1366
+ - Window gives window=N additional days. So `until`-`window`=1
1367
+ defaults to include ALL days (not just business days)
1368
+
1369
+ >>> next(Interval(Date(2014,7,16), Date(2014,7,16)).series())
1370
+ Date(2014, 7, 16)
1371
+ >>> next(Interval(Date(2014,7,12), Date(2014,7,16)).series())
1372
+ Date(2014, 7, 12)
1373
+ >>> len(list(Interval(Date(2014,7,12), Date(2014,7,16)).series()))
1374
+ 5
1375
+ >>> len(list(Interval(Date(2014,7,12), None).series(window=4)))
1376
+ 5
1377
+ >>> len(list(Interval(Date(2014,7,16)).series(window=4)))
1378
+ 5
1379
+
1380
+ Weekend and a holiday
1381
+ >>> len(list(Interval(Date(2014,7,3), Date(2014,7,5)).b.series()))
1382
+ 1
1383
+ >>> len(list(Interval(Date(2014,7,17), Date(2014,7,16)).series()))
1384
+ Traceback (most recent call last):
1385
+ ...
1386
+ AssertionError: Begdate must be earlier or equal to Enddate
1387
+
1388
+ since != business day and want business days
1389
+ 1/[3,10]/2015 is a Saturday, 1/7/2015 is a Wednesday
1390
+ >>> len(list(Interval(Date(2015,1,3), Date(2015,1,7)).b.series()))
1391
+ 3
1392
+ >>> len(list(Interval(Date(2015,1,3), None).b.series(window=3)))
1393
+ 3
1394
+ >>> len(list(Interval(Date(2015,1,3), Date(2015,1,10)).b.series()))
1395
+ 5
1396
+ >>> len(list(Interval(Date(2015,1,3), None).b.series(window=5)))
1397
+ 5
1398
+ """
1399
+ window = abs(int(window))
1400
+ since, until = self.begdate, self.enddate
1401
+ _business = self._business
1402
+ assert until or since, 'Since or until is required'
1403
+ if not since and until:
1404
+ since = (until.business() if _business else
1405
+ until).subtract(days=window)
1406
+ elif since and not until:
1407
+ until = (since.business() if _business else
1408
+ since).add(days=window)
1409
+ assert since <= until, 'Since date must be earlier or equal to Until date'
1410
+ thedate = since
1411
+ while thedate <= until:
1412
+ if _business:
1413
+ if thedate.is_business_day():
1414
+ yield thedate
1415
+ else:
1416
+ yield thedate
1417
+ thedate = thedate.add(days=1)
1418
+
1419
+ def end_of_series(self, unit='month') -> list[Date]:
1420
+ """Return a series between and inclusive of begdate and enddate.
1421
+
1422
+ >>> Interval(Date(2018, 1, 5), Date(2018, 4, 5)).end_of_series('month')
1423
+ [Date(2018, 1, 31), Date(2018, 2, 28), Date(2018, 3, 31), Date(2018, 4, 30)]
1424
+ >>> Interval(Date(2018, 1, 5), Date(2018, 4, 5)).end_of_series('week')
1425
+ [Date(2018, 1, 7), Date(2018, 1, 14), ..., Date(2018, 4, 8)]
1426
+ """
1427
+ begdate = self.begdate.end_of(unit)
1428
+ enddate = self.enddate.end_of(unit)
1429
+ interval = _pendulum.interval(begdate, enddate)
1430
+ return [Date.instance(d) for d in interval.range(f'{unit}s')]
1431
+
1432
+ def days(self) -> int:
1433
+ """Return days between (begdate, enddate] or negative (enddate, begdate].
1434
+
1435
+ >>> Interval(Date.parse('2018/9/6'), Date.parse('2018/9/10')).days()
1436
+ 4
1437
+ >>> Interval(Date.parse('2018/9/10'), Date.parse('2018/9/6')).days()
1438
+ -4
1439
+ >>> Interval(Date.parse('2018/9/6'), Date.parse('2018/9/10')).b.days()
1440
+ 2
1441
+ >>> Interval(Date.parse('2018/9/10'), Date.parse('2018/9/6')).b.days()
1442
+ -2
1443
+ """
1444
+ assert self.begdate
1445
+ assert self.enddate
1446
+ if self.begdate == self.enddate:
1447
+ return 0
1448
+ if not self._business:
1449
+ return (self.enddate - self.begdate).days
1450
+ if self.begdate < self.enddate:
1451
+ return len(list(self.series())) - 1
1452
+ _reverse = Interval(self.enddate, self.begdate)
1453
+ _reverse._entity = self._entity
1454
+ _reverse._business = self._business
1455
+ return -len(list(_reverse.series())) + 1
1456
+
1457
+ def quarters(self):
1458
+ """Return the number of quarters between two dates
1459
+ TODO: good enough implementation; refine rules to be heuristically precise
1460
+
1461
+ >>> round(Interval(Date(2020, 1, 1), Date(2020, 2, 16)).quarters(), 2)
1462
+ 0.5
1463
+ >>> round(Interval(Date(2020, 1, 1), Date(2020, 4, 1)).quarters(), 2)
1464
+ 1.0
1465
+ >>> round(Interval(Date(2020, 1, 1), Date(2020, 7, 1)).quarters(), 2)
1466
+ 1.99
1467
+ >>> round(Interval(Date(2020, 1, 1), Date(2020, 8, 1)).quarters(), 2)
1468
+ 2.33
1469
+ """
1470
+ return 4 * self.days() / 365.0
1471
+
1472
+ def years(self, basis: int = 0):
1473
+ """Years with Fractions (matches Excel YEARFRAC)
1474
+
1475
+ Adapted from https://web.archive.org/web/20200915094905/https://dwheeler.com/yearfrac/calc_yearfrac.py
1476
+
1477
+ Basis:
1478
+ 0 = US (NASD) 30/360
1479
+ 1 = Actual/actual
1480
+ 2 = Actual/360
1481
+ 3 = Actual/365
1482
+ 4 = European 30/360
1483
+
1484
+ >>> begdate = Date(1978, 2, 28)
1485
+ >>> enddate = Date(2020, 5, 17)
1486
+
1487
+ Tested Against Excel
1488
+ >>> "{:.4f}".format(Interval(begdate, enddate).years(0))
1489
+ '42.2139'
1490
+ >>> '{:.4f}'.format(Interval(begdate, enddate).years(1))
1491
+ '42.2142'
1492
+ >>> '{:.4f}'.format(Interval(begdate, enddate).years(2))
1493
+ '42.8306'
1494
+ >>> '{:.4f}'.format(Interval(begdate, enddate).years(3))
1495
+ '42.2438'
1496
+ >>> '{:.4f}'.format(Interval(begdate, enddate).years(4))
1497
+ '42.2194'
1498
+ >>> '{:.4f}'.format(Interval(enddate, begdate).years(4))
1499
+ '-42.2194'
1500
+
1501
+ Excel has a known leap year bug when year == 1900 (=YEARFRAC("1900-1-1", "1900-12-1", 1) -> 0.9178)
1502
+ The bug originated from Lotus 1-2-3, and was purposely implemented in Excel for the purpose of backward compatibility.
1503
+ >>> begdate = Date(1900, 1, 1)
1504
+ >>> enddate = Date(1900, 12, 1)
1505
+ >>> '{:.4f}'.format(Interval(begdate, enddate).years(4))
1506
+ '0.9167'
1507
+ """
1508
+
1509
+ def average_year_length(date1, date2):
1510
+ """Algorithm for average year length"""
1511
+ days = (Date(date2.year + 1, 1, 1) - Date(date1.year, 1, 1)).days
1512
+ years = (date2.year - date1.year) + 1
1513
+ return days / years
1514
+
1515
+ def feb29_between(date1, date2):
1516
+ """Requires date2.year = (date1.year + 1) or date2.year = date1.year.
1517
+
1518
+ Returns True if "Feb 29" is between the two dates (date1 may be Feb29).
1519
+ Two possibilities: date1.year is a leap year, and date1 <= Feb 29 y1,
1520
+ or date2.year is a leap year, and date2 > Feb 29 y2.
1521
+ """
1522
+ mar1_date1_year = Date(date1.year, 3, 1)
1523
+ if calendar.isleap(date1.year) and (date1 < mar1_date1_year) and (date2 >= mar1_date1_year):
1524
+ return True
1525
+ mar1_date2_year = Date(date2.year, 3, 1)
1526
+ return bool(calendar.isleap(date2.year) and date2 >= mar1_date2_year and date1 < mar1_date2_year)
1527
+
1528
+ def appears_lte_one_year(date1, date2):
1529
+ """Returns True if date1 and date2 "appear" to be 1 year or less apart.
1530
+
1531
+ This compares the values of year, month, and day directly to each other.
1532
+ Requires date1 <= date2; returns boolean. Used by basis 1.
1533
+ """
1534
+ if date1.year == date2.year:
1535
+ return True
1536
+ return bool(date1.year + 1 == date2.year and (date1.month > date2.month or date1.month == date2.month and date1.day >= date2.day))
1537
+
1538
+ def basis0(date1, date2):
1539
+ # change day-of-month for purposes of calculation.
1540
+ date1day, date1month, date1year = date1.day, date1.month, date1.year
1541
+ date2day, date2month, date2year = date2.day, date2.month, date2.year
1542
+ if date1day == 31 and date2day == 31:
1543
+ date1day = 30
1544
+ date2day = 30
1545
+ elif date1day == 31:
1546
+ date1day = 30
1547
+ elif date1day == 30 and date2day == 31:
1548
+ date2day = 30
1549
+ # Note: If date2day==31, it STAYS 31 if date1day < 30.
1550
+ # Special fixes for February:
1551
+ elif date1month == 2 and date2month == 2 and date1 == date1.end_of('month') \
1552
+ and date2 == date2.end_of('month'):
1553
+ date1day = 30 # Set the day values to be equal
1554
+ date2day = 30
1555
+ elif date1month == 2 and date1 == date1.end_of('month'):
1556
+ date1day = 30 # "Illegal" Feb 30 date.
1557
+ daydiff360 = (date2day + date2month * 30 + date2year * 360) \
1558
+ - (date1day + date1month * 30 + date1year * 360)
1559
+ return daydiff360 / 360
1560
+
1561
+ def basis1(date1, date2):
1562
+ if appears_lte_one_year(date1, date2):
1563
+ if date1.year == date2.year and calendar.isleap(date1.year):
1564
+ year_length = 366.0
1565
+ elif feb29_between(date1, date2) or (date2.month == 2 and date2.day == 29):
1566
+ year_length = 366.0
1567
+ else:
1568
+ year_length = 365.0
1569
+ return (date2 - date1).days / year_length
1570
+ return (date2 - date1).days / average_year_length(date1, date2)
1571
+
1572
+ def basis2(date1, date2):
1573
+ return (date2 - date1).days / 360.0
1574
+
1575
+ def basis3(date1, date2):
1576
+ return (date2 - date1).days / 365.0
1577
+
1578
+ def basis4(date1, date2):
1579
+ # change day-of-month for purposes of calculation.
1580
+ date1day, date1month, date1year = date1.day, date1.month, date1.year
1581
+ date2day, date2month, date2year = date2.day, date2.month, date2.year
1582
+ if date1day == 31:
1583
+ date1day = 30
1584
+ if date2day == 31:
1585
+ date2day = 30
1586
+ # Remarkably, do NOT change Feb. 28 or 29 at ALL.
1587
+ daydiff360 = (date2day + date2month * 30 + date2year * 360) - \
1588
+ (date1day + date1month * 30 + date1year * 360)
1589
+ return daydiff360 / 360
1590
+
1591
+ begdate, enddate = self.begdate, self.enddate
1592
+ if enddate is None:
1593
+ return
1594
+
1595
+ sign = 1
1596
+ if begdate > enddate:
1597
+ begdate, enddate = enddate, begdate
1598
+ sign = -1
1599
+ if begdate == enddate:
1600
+ return 0.0
1601
+
1602
+ if basis == 0:
1603
+ return basis0(begdate, enddate) * sign
1604
+ if basis == 1:
1605
+ return basis1(begdate, enddate) * sign
1606
+ if basis == 2:
1607
+ return basis2(begdate, enddate) * sign
1608
+ if basis == 3:
1609
+ return basis3(begdate, enddate) * sign
1610
+ if basis == 4:
1611
+ return basis4(begdate, enddate) * sign
1612
+
1613
+ raise ValueError('Basis range [0, 4]. Unknown basis {basis}.')
1614
+
1615
+
1616
+ Range = namedtuple('Range', ['start', 'end'])
1617
+
1618
+
1619
+ def overlap_days(range_one, range_two, days=False):
1620
+ """Test by how much two date ranges overlap
1621
+ if `days=True`, we return an actual day count,
1622
+ otherwise we just return if it overlaps True/False
1623
+ poached from Raymond Hettinger http://stackoverflow.com/a/9044111
1624
+
1625
+ >>> date1 = Date(2016, 3, 1)
1626
+ >>> date2 = Date(2016, 3, 2)
1627
+ >>> date3 = Date(2016, 3, 29)
1628
+ >>> date4 = Date(2016, 3, 30)
1629
+
1630
+ >>> assert overlap_days((date1, date3), (date2, date4))
1631
+ >>> assert overlap_days((date2, date4), (date1, date3))
1632
+ >>> assert not overlap_days((date1, date2), (date3, date4))
1633
+
1634
+ >>> assert overlap_days((date1, date4), (date1, date4))
1635
+ >>> assert overlap_days((date1, date4), (date2, date3))
1636
+ >>> overlap_days((date1, date4), (date1, date4), True)
1637
+ 30
1638
+
1639
+ >>> assert overlap_days((date2, date3), (date1, date4))
1640
+ >>> overlap_days((date2, date3), (date1, date4), True)
1641
+ 28
1642
+
1643
+ >>> assert not overlap_days((date3, date4), (date1, date2))
1644
+ >>> overlap_days((date3, date4), (date1, date2), True)
1645
+ -26
1646
+ """
1647
+ r1 = Range(*range_one)
1648
+ r2 = Range(*range_two)
1649
+ latest_start = max(r1.start, r2.start)
1650
+ earliest_end = min(r1.end, r2.end)
1651
+ overlap = (earliest_end - latest_start).days + 1
1652
+ if days:
1653
+ return overlap
1654
+ return overlap >= 0
1655
+
1656
+
1657
+ def create_ics(begdate, enddate, summary, location):
1658
+ """Create a simple .ics file per RFC 5545 guidelines."""
1659
+
1660
+ return f"""BEGIN:VCALENDAR
1661
+ VERSION:2.0
1662
+ PRODID:-//hacksw/handcal//NONSGML v1.0//EN
1663
+ BEGIN:VEVENT
1664
+ DTSTART;TZID=America/New_York:{begdate:%Y%m%dT%H%M%S}
1665
+ DTEND;TZID=America/New_York:{enddate:%Y%m%dT%H%M%S}
1666
+ SUMMARY:{summary}
1667
+ LOCATION:{location}
1668
+ END:VEVENT
1669
+ END:VCALENDAR
1670
+ """
1671
+
1672
+
1673
+ # apply any missing Date functions
1674
+ for func in (
1675
+ 'average',
1676
+ 'closest',
1677
+ 'farthest',
1678
+ 'fromordinal',
1679
+ 'fromtimestamp',
1680
+ 'nth_of',
1681
+ 'replace',
1682
+ ):
1683
+ setattr(Date, func, store_entity(getattr(_pendulum.Date, func), typ=Date))
1684
+
1685
+ # apply any missing DateTime functions
1686
+ for func in (
1687
+ 'astimezone',
1688
+ 'date',
1689
+ 'fromordinal',
1690
+ 'fromtimestamp',
1691
+ 'in_timezone',
1692
+ 'in_tz',
1693
+ 'replace',
1694
+ 'strptime',
1695
+ 'utcfromtimestamp',
1696
+ 'utcnow',
1697
+ ):
1698
+ setattr(DateTime, func, store_entity(getattr(_pendulum.DateTime, func), typ=DateTime))
1699
+
1700
+
1701
+ if __name__ == '__main__':
1702
+ __import__('doctest').testmod(optionflags=4 | 8 | 32)