Adds a __repr__ to graph.
[pyutils.git] / src / pyutils / datetimes / datetime_utils.py
1 #!/usr/bin/env python3
2
3 # © Copyright 2021-2023, Scott Gasch
4
5 """Utilities related to dates, times, and datetimes."""
6
7 import datetime
8 import enum
9 import logging
10 import re
11 from typing import Any, NewType, Optional, Tuple
12
13 import holidays  # type: ignore
14 import pytz
15
16 from pyutils.datetimes import constants
17
18 logger = logging.getLogger(__name__)
19
20
21 def is_timezone_aware(dt: datetime.datetime) -> bool:
22     """
23     Checks whether a datetime is timezone aware or naive.
24     See: https://docs.python.org/3/library/datetime.html#determining-if-an-object-is-aware-or-naive
25
26     Args:
27         dt: the datetime to check for timezone awareness
28
29     Returns:
30         True if the datetime argument is timezone aware or
31         False if not.
32
33     >>> is_timezone_aware(datetime.datetime.now())
34     False
35
36     >>> is_timezone_aware(now_pacific())
37     True
38
39     """
40     return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
41
42
43 def is_timezone_naive(dt: datetime.datetime) -> bool:
44     """Inverse of :meth:`is_timezone_aware`.
45     See: https://docs.python.org/3/library/datetime.html#determining-if-an-object-is-aware-or-naive
46
47     Args:
48         dt: the datetime to check
49
50     Returns:
51         True if the dt argument is timezone naive, False otherwise
52
53     >>> is_timezone_naive(datetime.datetime.now())
54     True
55
56     >>> is_timezone_naive(now_pacific())
57     False
58
59     """
60     return not is_timezone_aware(dt)
61
62
63 def strip_timezone(dt: datetime.datetime) -> datetime.datetime:
64     """
65     Remove the timezone from a datetime.  Silently ignores datetimes
66     which are already timezone naive.
67
68     Args:
69         dt: the datetime to remove timezone from
70
71     Returns:
72         A datetime identical to dt, the input argument, except for
73         that the timezone has been removed.
74
75     See also :meth:`add_timezone`, :meth:`replace_timezone`, :meth:`translate_timezone`.
76
77     .. warning::
78
79         This does not change the hours, minutes, seconds,
80         months, days, years, etc... Thus, the instant to which this
81         timestamp refers will change when the timezone is added.
82         See examples.
83
84     >>> now = now_pacific()
85     >>> now.tzinfo == None
86     False
87
88     >>> "US/Pacific" in now.tzinfo.__repr__()
89     True
90
91     >>> dt = strip_timezone(now)
92     >>> dt == now
93     False
94
95     >>> dt.tzinfo == None
96     True
97
98     >>> dt.hour == now.hour
99     True
100     """
101     if is_timezone_naive(dt):
102         return dt
103     return replace_timezone(dt, None)
104
105
106 def add_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.datetime:
107     """
108     Adds a timezone to a timezone naive datetime.
109
110     Args:
111         dt: the datetime to insert a timezone onto
112         tz: the timezone to insert
113
114     See also :meth:`replace_timezone`, :meth:`strip_timezone`, :meth:`translate_timezone`.
115
116     Returns:
117         A datetime identical to dt, the input datetime, except for
118         that a timezone has been added.
119
120     Raises:
121         ValueError: if dt is already a timezone aware datetime.
122
123     .. warning::
124
125         This doesn't change the hour, minute, second, day, month, etc...
126         of the input timezone.  It simply adds a timezone to it.  Adding
127         a timezone this way will likely change the instant to which the
128         datetime refers.  See examples.
129
130     >>> now = datetime.datetime.now()
131     >>> is_timezone_aware(now)
132     False
133
134     >>> now_pacific = add_timezone(now, pytz.timezone('US/Pacific'))
135     >>> is_timezone_aware(now_pacific)
136     True
137
138     >>> "US/Pacific" in now_pacific.tzinfo.__repr__()
139     True
140
141     >>> now.hour == now_pacific.hour
142     True
143     >>> now.minute == now_pacific.minute
144     True
145
146     """
147
148     # This doesn't work, tz requires a timezone naive dt.  Two options
149     # here:
150     #     1. Use strip_timezone and try again.
151     #     2. Replace the timezone on your dt object via replace_timezone.
152     #        Be aware that this changes the instant to which the dt refers
153     #        and, further, can introduce weirdness like UTC offsets that
154     #        are weird (e.g. not an even multiple of an hour, etc...)
155     if is_timezone_aware(dt):
156         if dt.tzinfo == tz:
157             return dt
158         raise ValueError(
159             f"{dt} is already timezone aware; use replace_timezone or translate_timezone "
160             + "depending on the semantics you want.  See the pydocs / code."
161         )
162     return dt.replace(tzinfo=tz)
163
164
165 def replace_timezone(
166     dt: datetime.datetime, tz: Optional[datetime.tzinfo]
167 ) -> datetime.datetime:
168     """
169     Replaces the timezone on a timezone aware datetime object directly
170     (leaving the year, month, day, hour, minute, second, micro,
171     etc... alone).  The same as calling :meth:`strip_timezone` followed
172     by :meth:`add_timezone`.
173
174     Works with timezone aware and timezone naive dts but for the
175     latter it is probably better to use :meth:`add_timezone` or just
176     create it with a `tz` parameter.  Using this can have weird side
177     effects like UTC offsets that are not an even multiple of an hour,
178     etc...
179
180     Args:
181         dt: the datetime whose timezone should be changed
182         tz: the new timezone
183
184     Returns:
185         The resulting datetime.  Hour, minute, second, etc... are unmodified.
186         See warning below.
187
188     See also :meth:`add_timezone`, :meth:`strip_timezone`, :meth:`translate_timezone`.
189
190     .. warning::
191
192         This code isn't changing the hour, minute, second, day, month, etc...
193         of the datetime.  It's just messing with the timezone.  Changing
194         the timezone without changing the time causes the instant to which
195         the datetime refers to change.  For example, if passed 7:01pm PST
196         and asked to make it EST, the result will be 7:01pm EST.  See
197         examples.
198
199     >>> from pytz import UTC
200     >>> d = now_pacific()
201     >>> d.tzinfo.tzname(d)[0]     # Note: could be PST or PDT
202     'P'
203     >>> h = d.hour
204     >>> o = replace_timezone(d, UTC)
205     >>> o.tzinfo.tzname(o)
206     'UTC'
207     >>> o.hour == h
208     True
209     """
210     if is_timezone_aware(dt):
211         logger.warning(
212             "%s already has a timezone; klobbering it anyway.\n  Be aware that this operation changed the instant to which the object refers.",
213             dt,
214         )
215         return datetime.datetime(
216             dt.year,
217             dt.month,
218             dt.day,
219             dt.hour,
220             dt.minute,
221             dt.second,
222             dt.microsecond,
223             tzinfo=tz,
224         )
225     else:
226         if tz:
227             return add_timezone(dt, tz)
228         else:
229             return dt
230
231
232 def replace_time_timezone(t: datetime.time, tz: datetime.tzinfo) -> datetime.time:
233     """Replaces the timezone on a datetime.time directly without performing
234     any translation.
235
236     Args:
237         t: the time to change the timezone on
238         tz: the new timezone desired
239
240     Returns:
241         A time with hour, minute, second, etc... identical to the input
242         time but with timezone replaced.
243
244     .. warning::
245
246         This code isn't changing the hour, minute, second, etc...
247         of the time.  It's just messing with the timezone.  Changing
248         the timezone without changing the time causes the instant to which
249         the datetime refers to change.  For example, if passed 7:01pm PST
250         and asked to make it EST, the result will be 7:01pm EST.  See
251         examples.
252
253     >>> t = datetime.time(8, 15, 12, 0, pytz.UTC)
254     >>> t.tzname()
255     'UTC'
256
257     >>> t = replace_time_timezone(t, pytz.timezone('US/Pacific'))
258     >>> t.tzname()
259     'US/Pacific'
260     """
261     return t.replace(tzinfo=tz)
262
263
264 def translate_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.datetime:
265     """
266     Translates dt into a different timezone by adjusting the year, month,
267     day, hour, minute, second, micro, etc... appropriately.  The returned
268     dt is the same instant in another timezone.
269
270     Args:
271         dt: the datetime whose timezone should be translated.
272         tz: the desired timezone
273
274     Returns:
275         A new datetime object that represents the same instant as the
276         input datetime but in the desired timezone.  Modifies hour, minute,
277         seconds, day, etc... as necessary for the instant to be preserved.
278         For example, if you pass 11:01pm PST in and ask for it to be
279         translated to EST you would get 2:01am the next day EST back
280         out.
281
282     See also :meth:`replace_timezone`, :meth:`strip_timezone`.
283
284     >>> import pytz
285     >>> d = now_pacific()
286     >>> d.tzinfo.tzname(d)[0]     # Note: could be PST or PDT
287     'P'
288     >>> h = d.hour
289     >>> o = translate_timezone(d, pytz.timezone('US/Eastern'))
290     >>> o.tzinfo.tzname(o)[0]     # Again, could be EST or EDT
291     'E'
292     >>> o.hour == h
293     False
294     >>> expected = h + 3          # Three hours later in E?T than P?T
295     >>> expected = expected % 24  # Handle edge case
296     >>> expected == o.hour
297     True
298     """
299     return dt.replace().astimezone(tz=tz)
300
301
302 def now() -> datetime.datetime:
303     """
304     What time is it?  Result is a timezone naive datetime.
305     """
306     return datetime.datetime.now()
307
308
309 def now_pacific() -> datetime.datetime:
310     """
311     What time is it?  Result in US/Pacific time (PST/PDT)
312     """
313     return datetime.datetime.now(pytz.timezone("US/Pacific"))
314
315
316 def date_to_datetime(date: datetime.date) -> datetime.datetime:
317     """
318     Given a date, return a datetime with hour/min/sec zero (midnight)
319
320     Arg:
321         date: the date desired
322
323     Returns:
324         A datetime with the same month, day, and year as the input
325         date and hours, minutes, seconds set to 12:00:00am.
326
327     >>> import datetime
328     >>> date_to_datetime(datetime.date(2021, 12, 25))
329     datetime.datetime(2021, 12, 25, 0, 0)
330     """
331     return datetime.datetime(date.year, date.month, date.day, 0, 0, 0, 0)
332
333
334 def time_to_datetime_today(time: datetime.time) -> datetime.datetime:
335     """
336     Given a time, returns that time as a datetime with a date component
337     set based on the current date.  If the time passed is timezone aware,
338     the resulting datetime will also be (and will use the same tzinfo).
339     If the time is timezone naive, the datetime returned will be too.
340
341     Args:
342         time: the time desired
343
344     Returns:
345         datetime with hour, minute, second, timezone set to time and
346         day, month, year set to "today".
347
348     >>> t = datetime.time(13, 14, 0)
349     >>> d = now_pacific().date()
350     >>> dt = time_to_datetime_today(t)
351     >>> dt.date() == d
352     True
353
354     >>> dt.time() == t
355     True
356
357     >>> dt.tzinfo == t.tzinfo
358     True
359
360     >>> dt.tzinfo == None
361     True
362
363     >>> t = datetime.time(8, 15, 12, 0, pytz.UTC)
364     >>> t.tzinfo == None
365     False
366
367     >>> dt = time_to_datetime_today(t)
368     >>> dt.tzinfo == None
369     False
370
371     """
372     tz = time.tzinfo
373     return datetime.datetime.combine(now_pacific(), time, tz)
374
375
376 def date_and_time_to_datetime(
377     date: datetime.date, time: datetime.time
378 ) -> datetime.datetime:
379     """
380     Given a date and time, merge them and return a datetime.
381
382     Args:
383         date: the date component
384         time: the time component
385
386     Returns:
387         A datetime with the time component set from time and the date
388         component set from date.
389
390     >>> import datetime
391     >>> d = datetime.date(2021, 12, 25)
392     >>> t = datetime.time(12, 30, 0, 0)
393     >>> date_and_time_to_datetime(d, t)
394     datetime.datetime(2021, 12, 25, 12, 30)
395     """
396     return datetime.datetime(
397         date.year,
398         date.month,
399         date.day,
400         time.hour,
401         time.minute,
402         time.second,
403         time.microsecond,
404     )
405
406
407 def datetime_to_date_and_time(
408     dt: datetime.datetime,
409 ) -> Tuple[datetime.date, datetime.time]:
410     """Return the component date and time objects of a datetime in a
411     Tuple given a datetime.
412
413     Args:
414         dt: the datetime to decompose
415
416     Returns:
417         A tuple whose first element contains a datetime.date that holds
418         the day, month, year, etc... from the input dt and whose second
419         element contains a datetime.time with hour, minute, second, micros,
420         and timezone set from the input dt.
421
422     >>> import datetime
423     >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
424     >>> (d, t) = datetime_to_date_and_time(dt)
425     >>> d
426     datetime.date(2021, 12, 25)
427     >>> t
428     datetime.time(12, 30)
429     """
430     return (dt.date(), dt.timetz())
431
432
433 def datetime_to_date(dt: datetime.datetime) -> datetime.date:
434     """Return just the date part of a datetime.
435
436     Args:
437         dt: the datetime
438
439     Returns:
440         A datetime.date with month, day and year set from input dt.
441
442     >>> import datetime
443     >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
444     >>> datetime_to_date(dt)
445     datetime.date(2021, 12, 25)
446     """
447     return datetime_to_date_and_time(dt)[0]
448
449
450 def datetime_to_time(dt: datetime.datetime) -> datetime.time:
451     """Return just the time part of a datetime.
452
453     Args:
454         dt: the datetime
455
456     Returns:
457         A datetime.time with hour, minute, second, micros, and
458         timezone set from the input dt.
459
460     >>> import datetime
461     >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
462     >>> datetime_to_time(dt)
463     datetime.time(12, 30)
464     """
465     return datetime_to_date_and_time(dt)[1]
466
467
468 class TimeUnit(enum.IntEnum):
469     """An enum to represent units with which we can compute deltas."""
470
471     MONDAYS = 0
472     TUESDAYS = 1
473     WEDNESDAYS = 2
474     THURSDAYS = 3
475     FRIDAYS = 4
476     SATURDAYS = 5
477     SUNDAYS = 6
478     SECONDS = 7
479     MINUTES = 8
480     HOURS = 9
481     DAYS = 10
482     WORKDAYS = 11
483     WEEKS = 12
484     MONTHS = 13
485     YEARS = 14
486
487     @classmethod
488     def is_valid(cls, value: Any):
489         """
490         Args:
491             value: a value to be checked
492
493         Returns:
494             True is input value is a valid TimeUnit, False otherwise.
495         """
496         if isinstance(value, int):
497             return cls(value) is not None
498         elif isinstance(value, TimeUnit):
499             return cls(value.value) is not None
500         elif isinstance(value, str):
501             return cls.__members__[value] is not None
502         else:
503             print(type(value))
504             return False
505
506
507 def n_timeunits_from_base(
508     count: int, unit: TimeUnit, base: datetime.datetime
509 ) -> datetime.datetime:
510     """Return a datetime that is N units before/after a base datetime.
511     For example:
512
513         - 3 Wednesdays from base datetime,
514         - 2 weeks from base date,
515         - 10 years before base datetime,
516         - 13 minutes after base datetime, etc...
517
518     Args:
519         count: signed number that indicates N units before/after the base.
520         unit: the timeunit that we are counting by.
521         base: a datetime representing the base date the result should be
522             relative to.
523
524     Raises:
525         ValueError: unit is invalid
526
527     Returns:
528         A datetime that is count units before of after the base datetime.
529
530     .. note::
531
532         To indicate before/after the base date, use a positive or
533         negative count.
534
535     >>> base = string_to_datetime("2021/09/10 11:24:51AM-0700")[0]
536
537     The next (1) Monday from the base datetime:
538
539     >>> n_timeunits_from_base(+1, TimeUnit.MONDAYS, base)
540     datetime.datetime(2021, 9, 13, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
541
542     Ten (10) years after the base datetime:
543
544     >>> n_timeunits_from_base(10, TimeUnit.YEARS, base)
545     datetime.datetime(2031, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
546
547     Fifty (50) working days (M..F, not counting holidays) after base datetime:
548
549     >>> n_timeunits_from_base(50, TimeUnit.WORKDAYS, base)
550     datetime.datetime(2021, 11, 23, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
551
552     Fifty (50) days (including weekends and holidays) after base datetime:
553
554     >>> n_timeunits_from_base(50, TimeUnit.DAYS, base)
555     datetime.datetime(2021, 10, 30, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
556
557     Fifty (50) months before (note negative count) base datetime:
558
559     >>> n_timeunits_from_base(-50, TimeUnit.MONTHS, base)
560     datetime.datetime(2017, 7, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
561
562     Fifty (50) hours after base datetime:
563
564     >>> n_timeunits_from_base(50, TimeUnit.HOURS, base)
565     datetime.datetime(2021, 9, 12, 13, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
566
567     Fifty (50) minutes before base datetime:
568
569     >>> n_timeunits_from_base(-50, TimeUnit.MINUTES, base)
570     datetime.datetime(2021, 9, 10, 10, 34, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
571
572     Fifty (50) seconds from base datetime:
573
574     >>> n_timeunits_from_base(50, TimeUnit.SECONDS, base)
575     datetime.datetime(2021, 9, 10, 11, 25, 41, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
576
577     Next month corner case -- it will try to make Feb 31, 2022 then count
578     backwards.
579
580     >>> base = string_to_datetime("2022/01/31 11:24:51AM-0700")[0]
581     >>> n_timeunits_from_base(1, TimeUnit.MONTHS, base)
582     datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
583
584     Last month with the same corner case
585
586     >>> base = string_to_datetime("2022/03/31 11:24:51AM-0700")[0]
587     >>> n_timeunits_from_base(-1, TimeUnit.MONTHS, base)
588     datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
589     """
590     assert TimeUnit.is_valid(unit)
591     if count == 0:
592         return base
593
594     # N days from base
595     if unit == TimeUnit.DAYS:
596         timedelta = datetime.timedelta(days=count)
597         return base + timedelta
598
599     # N hours from base
600     elif unit == TimeUnit.HOURS:
601         timedelta = datetime.timedelta(hours=count)
602         return base + timedelta
603
604     # N minutes from base
605     elif unit == TimeUnit.MINUTES:
606         timedelta = datetime.timedelta(minutes=count)
607         return base + timedelta
608
609     # N seconds from base
610     elif unit == TimeUnit.SECONDS:
611         timedelta = datetime.timedelta(seconds=count)
612         return base + timedelta
613
614     # N workdays from base
615     elif unit == TimeUnit.WORKDAYS:
616         if count < 0:
617             count = abs(count)
618             timedelta = datetime.timedelta(days=-1)
619         else:
620             timedelta = datetime.timedelta(days=1)
621         skips = holidays.US(years=base.year).keys()
622         while count > 0:
623             old_year = base.year
624             base += timedelta
625             if base.year != old_year:
626                 skips = holidays.US(years=base.year).keys()
627             if (
628                 base.weekday() < 5
629                 and datetime.date(base.year, base.month, base.day) not in skips
630             ):
631                 count -= 1
632         return base
633
634     # N weeks from base
635     elif unit == TimeUnit.WEEKS:
636         timedelta = datetime.timedelta(weeks=count)
637         base = base + timedelta
638         return base
639
640     # N months from base
641     elif unit == TimeUnit.MONTHS:
642         month_term = count % 12
643         year_term = count // 12
644         new_month = base.month + month_term
645         if new_month > 12:
646             new_month %= 12
647             year_term += 1
648         new_year = base.year + year_term
649         day = base.day
650         while True:
651             try:
652                 ret = datetime.datetime(
653                     new_year,
654                     new_month,
655                     day,
656                     base.hour,
657                     base.minute,
658                     base.second,
659                     base.microsecond,
660                     base.tzinfo,
661                 )
662                 break
663             except ValueError:
664                 day -= 1
665         return ret
666
667     # N years from base
668     elif unit == TimeUnit.YEARS:
669         new_year = base.year + count
670         return datetime.datetime(
671             new_year,
672             base.month,
673             base.day,
674             base.hour,
675             base.minute,
676             base.second,
677             base.microsecond,
678             base.tzinfo,
679         )
680
681     if unit not in set(
682         [
683             TimeUnit.MONDAYS,
684             TimeUnit.TUESDAYS,
685             TimeUnit.WEDNESDAYS,
686             TimeUnit.THURSDAYS,
687             TimeUnit.FRIDAYS,
688             TimeUnit.SATURDAYS,
689             TimeUnit.SUNDAYS,
690         ]
691     ):
692         raise ValueError(unit)
693
694     # N weekdays from base (e.g. 4 wednesdays from today)
695     direction = 1 if count > 0 else -1
696     count = abs(count)
697     timedelta = datetime.timedelta(days=direction)
698     start = base
699     while True:
700         dow = base.weekday()
701         if dow == unit.value and start != base:
702             count -= 1
703             if count == 0:
704                 return base
705         base = base + timedelta
706
707
708 def get_format_string(
709     *,
710     date_time_separator: str = " ",
711     include_timezone: bool = True,
712     include_dayname: bool = False,
713     use_month_abbrevs: bool = False,
714     include_seconds: bool = True,
715     include_fractional: bool = False,
716     twelve_hour: bool = True,
717 ) -> str:
718     """
719     Helper to return a format string without looking up the documentation
720     for strftime.
721
722     Args:
723         date_time_separator: character or string to use between the date
724             and time outputs.
725         include_timezone: whether or not the result should include a timezone
726         include_dayname: whether or not the result should incude the dayname
727             (e.g. Monday, Wednesday, etc...)
728         use_month_abbrevs: whether or not to abbreviate (e.g. Jan) or spell out
729             (e.g. January) month names.
730         include_seconds: whether or not to include seconds in time.
731         include_fractional: whether or not to include micros in time output.
732         twelve_hour: use twelve hour (with am/pm) or twenty four hour time format?
733
734     Returns:
735         The format string for use with strftime that follows the given
736         requirements.
737
738     >>> get_format_string()
739     '%Y/%m/%d %I:%M:%S%p%z'
740
741     >>> get_format_string(date_time_separator='@')
742     '%Y/%m/%d@%I:%M:%S%p%z'
743
744     >>> get_format_string(include_dayname=True)
745     '%a/%Y/%m/%d %I:%M:%S%p%z'
746
747     >>> get_format_string(include_dayname=True, twelve_hour=False)
748     '%a/%Y/%m/%d %H:%M:%S%z'
749
750     """
751     fstring = ""
752     if include_dayname:
753         fstring += "%a/"
754
755     if use_month_abbrevs:
756         fstring = f"{fstring}%Y/%b/%d{date_time_separator}"
757     else:
758         fstring = f"{fstring}%Y/%m/%d{date_time_separator}"
759     if twelve_hour:
760         fstring += "%I:%M"
761         if include_seconds:
762             fstring += ":%S"
763         fstring += "%p"
764     else:
765         fstring += "%H:%M"
766         if include_seconds:
767             fstring += ":%S"
768     if include_fractional:
769         fstring += ".%f"
770     if include_timezone:
771         fstring += "%z"
772     return fstring
773
774
775 def datetime_to_string(
776     dt: datetime.datetime,
777     *,
778     date_time_separator: str = " ",
779     include_timezone: bool = True,
780     include_dayname: bool = False,
781     use_month_abbrevs: bool = False,
782     include_seconds: bool = True,
783     include_fractional: bool = False,
784     twelve_hour: bool = True,
785 ) -> str:
786     """
787     A nice way to convert a datetime into a string; arguably better than
788     just printing it and relying on it __repr__().
789
790     Args:
791         dt: the datetime to represent
792         date_time_separator: the character or string to separate the date and time
793             pieces of the representation.
794         include_timezone: should we include a timezone in the representation?
795         include_dayname: should we include the dayname (e.g. Mon) in
796             the representation or omit it?
797         use_month_abbrevs: should we name the month briefly (e.g. Jan) or spell
798             it out fully (e.g. January) in the representation?
799         include_seconds: should we include seconds in the time?
800         include_fractional: should we include micros in the time?
801         twelve_hour: should we use twelve or twenty-four hour time format?
802
803     >>> d = string_to_datetime(
804     ...                        "2021/09/10 11:24:51AM-0700",
805     ...                       )[0]
806     >>> d
807     datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
808     >>> datetime_to_string(d)
809     '2021/09/10 11:24:51AM-0700'
810     >>> datetime_to_string(d, include_dayname=True, include_seconds=False)
811     'Fri/2021/09/10 11:24AM-0700'
812     """
813     fstring = get_format_string(
814         date_time_separator=date_time_separator,
815         include_timezone=include_timezone,
816         include_dayname=include_dayname,
817         use_month_abbrevs=use_month_abbrevs,
818         include_seconds=include_seconds,
819         include_fractional=include_fractional,
820         twelve_hour=twelve_hour,
821     )
822     return dt.strftime(fstring).strip()
823
824
825 def string_to_datetime(
826     txt: str,
827     *,
828     date_time_separator: str = " ",
829     include_timezone: bool = True,
830     include_dayname: bool = False,
831     use_month_abbrevs: bool = False,
832     include_seconds: bool = True,
833     include_fractional: bool = False,
834     twelve_hour: bool = True,
835 ) -> Tuple[datetime.datetime, str]:
836     """A nice way to convert a string into a datetime.  Returns both the
837     datetime and the format string used to parse it.  Also consider
838     :mod:`pyutils.datetimes.dateparse_utils` for a full parser alternative.
839
840     Args:
841         txt: the string to be converted into a datetime
842         date_time_separator: the character or string between the time and date
843             portions.
844         include_timezone: does the string include a timezone?
845         include_dayname: does the string include a dayname?
846         use_month_abbrevs: is the month abbreviated in the string (e.g. Feb)
847             or spelled out completely (e.g. February)?
848         include_seconds: does the string's time include seconds?
849         include_fractional: does the string's time include micros?
850         twelve_hour: is the string's time in twelve or twenty-four hour format?
851
852     Returns:
853         A tuple containing the datetime parsed from string and the formatting
854         string used to parse it.
855
856     >>> d = string_to_datetime(
857     ...                        "2021/09/10 11:24:51AM-0700",
858     ...                       )
859     >>> d
860     (datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200))), '%Y/%m/%d %I:%M:%S%p%z')
861
862     """
863     fstring = get_format_string(
864         date_time_separator=date_time_separator,
865         include_timezone=include_timezone,
866         include_dayname=include_dayname,
867         use_month_abbrevs=use_month_abbrevs,
868         include_seconds=include_seconds,
869         include_fractional=include_fractional,
870         twelve_hour=twelve_hour,
871     )
872     return (datetime.datetime.strptime(txt, fstring), fstring)
873
874
875 def timestamp() -> str:
876     """
877     Returns:
878         A timestamp for right now in Pacific timezone.
879     """
880     ts = datetime.datetime.now(tz=pytz.timezone("US/Pacific"))
881     return datetime_to_string(ts, include_timezone=True)
882
883
884 def time_to_string(
885     dt: datetime.datetime,
886     *,
887     include_seconds: bool = True,
888     include_fractional: bool = False,
889     include_timezone: bool = False,
890     twelve_hour: bool = True,
891 ) -> str:
892     """A nice way to convert a datetime into a time (only) string.
893     This ignores the date part of the datetime completely.
894
895     Args:
896         dt: the datetime whose time to represent
897         include_seconds: should seconds be included in the output?
898         include_fractional: should micros be included in the output?
899         include_timezone: should timezone be included in the output?
900         twelve_hour: use twelve or twenty-four hour format?
901
902     Returns:
903         A string representing the time of the input datetime.
904
905     >>> d = string_to_datetime(
906     ...                        "2021/09/10 11:24:51AM-0700",
907     ...                       )[0]
908     >>> d
909     datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
910
911     >>> time_to_string(d)
912     '11:24:51AM'
913
914     >>> time_to_string(d, include_seconds=False)
915     '11:24AM'
916
917     >>> time_to_string(d, include_seconds=False, include_timezone=True)
918     '11:24AM-0700'
919
920     """
921     fstring = ""
922     if twelve_hour:
923         fstring += "%l:%M"
924         if include_seconds:
925             fstring += ":%S"
926         fstring += "%p"
927     else:
928         fstring += "%H:%M"
929         if include_seconds:
930             fstring += ":%S"
931     if include_fractional:
932         fstring += ".%f"
933     if include_timezone:
934         fstring += "%z"
935     return dt.strftime(fstring).strip()
936
937
938 def seconds_to_timedelta(seconds: int) -> datetime.timedelta:
939     """
940     Args:
941         seconds: a count of seconds
942
943     Returns:
944         A datetime.timedelta representing that count of seconds.
945     """
946     return datetime.timedelta(seconds=seconds)
947
948
949 MinuteOfDay = NewType("MinuteOfDay", int)
950
951
952 def minute_number(hour: int, minute: int) -> MinuteOfDay:
953     """
954     Convert hour:minute into minute number from start of day.  That is,
955     if you imagine a day as a sequence of minutes from minute #0 up
956     to minute #1439, what minute number is, e.g., 6:52am?
957
958     Args:
959         hour: the hour to convert (0 <= hour <= 23)
960         minute: the minute to convert (0 <= minute <= 59)
961
962     Returns:
963         The minute number requested.  Raises `ValueError` on bad input.
964
965     Raises:
966         ValueError: invalid hour or minute input argument
967
968     >>> minute_number(0, 0)
969     0
970
971     >>> minute_number(9, 15)
972     555
973
974     >>> minute_number(23, 59)
975     1439
976     """
977     if hour < 0 or hour > 23:
978         raise ValueError(f"Bad hour: {hour}.  Expected 0 <= hour <= 23")
979     if minute < 0 or minute > 59:
980         raise ValueError(f"Bad minute: {minute}.  Expected 0 <= minute <= 59")
981     return MinuteOfDay(hour * 60 + minute)
982
983
984 def datetime_to_minute_number(dt: datetime.datetime) -> MinuteOfDay:
985     """
986     Convert a datetime's time component into a minute number (of
987     the day).  Note that this ignores the date part of the datetime
988     and only uses the time part.
989
990     Args:
991         dt: the datetime whose time is to be converted
992
993     Returns:
994         The minute number (of the day) that represents the input datetime's
995         time.
996
997     >>> d = string_to_datetime(
998     ...                        "2021/09/10 11:24:51AM-0700",
999     ...                       )[0]
1000
1001     >>> datetime_to_minute_number(d)
1002     684
1003     """
1004     return minute_number(dt.hour, dt.minute)
1005
1006
1007 def time_to_minute_number(t: datetime.time) -> MinuteOfDay:
1008     """
1009     Convert a datetime.time into a minute number.
1010
1011     Args:
1012         t: a datetime.time to convert into a minute number.
1013
1014     Returns:
1015         The minute number (of the day) of the input time.
1016
1017     >>> t = datetime.time(5, 15)
1018     >>> time_to_minute_number(t)
1019     315
1020     """
1021     return minute_number(t.hour, t.minute)
1022
1023
1024 def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
1025     """
1026     Convert minute number from start of day into hour:minute am/pm
1027     string.
1028
1029     Args:
1030         minute_num: the minute number to convert into a string
1031
1032     Returns:
1033         A string of the format "HH:MM[a|p]" that represents the time
1034         that the input minute_num refers to.
1035
1036     >>> minute_number_to_time_string(315)
1037     ' 5:15a'
1038
1039     >>> minute_number_to_time_string(684)
1040     '11:24a'
1041     """
1042     hour = minute_num // 60
1043     minute = minute_num % 60
1044     ampm = "a"
1045     if hour > 12:
1046         hour -= 12
1047         ampm = "p"
1048     if hour == 12:
1049         ampm = "p"
1050     if hour == 0:
1051         hour = 12
1052     return f"{hour:2}:{minute:02}{ampm}"
1053
1054
1055 def parse_duration(duration: str, raise_on_error: bool = False) -> int:
1056     """
1057     Parse a duration in string form into a delta seconds.
1058
1059     Args:
1060         duration: a string form duration, see examples.
1061         raise_on_error: should we raise on invalid input or just
1062             return a zero duration?
1063
1064     Returns:
1065         A count of seconds represented by the input string.
1066
1067     Raises:
1068         ValueError: bad duration and raise_on_error is set.
1069
1070     >>> parse_duration('15 days, 2 hours')
1071     1303200
1072
1073     >>> parse_duration('15d 2h')
1074     1303200
1075
1076     >>> parse_duration('100s')
1077     100
1078
1079     >>> parse_duration('3min 2sec')
1080     182
1081
1082     >>> parse_duration('recent')
1083     0
1084
1085     >>> parse_duration('recent', raise_on_error=True)
1086     Traceback (most recent call last):
1087     ...
1088     ValueError: recent is not a valid duration.
1089     """
1090     if duration.isdigit():
1091         return int(duration)
1092
1093     m = re.match(
1094         r"(\d+ *d[ays]*)* *(\d+ *h[ours]*)* *(\d+ *m[inutes]*)* *(\d+ *[seconds]*)",
1095         duration,
1096     )
1097     if not m and raise_on_error:
1098         raise ValueError(f"{duration} is not a valid duration.")
1099
1100     seconds = 0
1101     m = re.search(r"(\d+) *d[ays]*", duration)
1102     if m is not None:
1103         seconds += int(m.group(1)) * 60 * 60 * 24
1104     m = re.search(r"(\d+) *h[ours]*", duration)
1105     if m is not None:
1106         seconds += int(m.group(1)) * 60 * 60
1107     m = re.search(r"(\d+) *m[inutes]*", duration)
1108     if m is not None:
1109         seconds += int(m.group(1)) * 60
1110     m = re.search(r"(\d+) *s[econds]*", duration)
1111     if m is not None:
1112         seconds += int(m.group(1))
1113     return seconds
1114
1115
1116 def describe_duration(seconds: int, *, include_seconds: bool = False) -> str:
1117     """
1118     Describe a duration represented as a count of seconds nicely.
1119
1120     Args:
1121         seconds: the number of seconds in the duration to be represented.
1122         include_seconds: should we include or drop the seconds part in
1123             the representation?
1124
1125     .. note::
1126
1127         Of course if we drop the seconds part the result is not precise.
1128         See examples.
1129
1130     >>> describe_duration(182)
1131     '3 minutes'
1132
1133     >>> describe_duration(182, include_seconds=True)
1134     '3 minutes, and 2 seconds'
1135
1136     >>> describe_duration(100, include_seconds=True)
1137     '1 minute, and 40 seconds'
1138
1139     describe_duration(1303200)
1140     '15 days, 2 hours'
1141     """
1142     days = divmod(seconds, constants.SECONDS_PER_DAY)
1143     hours = divmod(days[1], constants.SECONDS_PER_HOUR)
1144     minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
1145
1146     descr = ""
1147     if days[0] > 1:
1148         descr = f"{int(days[0])} days, "
1149     elif days[0] == 1:
1150         descr = "1 day, "
1151
1152     if hours[0] > 1:
1153         descr = descr + f"{int(hours[0])} hours, "
1154     elif hours[0] == 1:
1155         descr = descr + "1 hour, "
1156
1157     if not include_seconds and len(descr) > 0:
1158         descr = descr + "and "
1159
1160     if minutes[0] == 1:
1161         descr = descr + "1 minute"
1162     else:
1163         descr = descr + f"{int(minutes[0])} minutes"
1164
1165     if include_seconds:
1166         descr = descr + ", "
1167         if len(descr) > 0:
1168             descr = descr + "and "
1169         s = minutes[1]
1170         if s == 1:
1171             descr = descr + "1 second"
1172         else:
1173             descr = descr + f"{s} seconds"
1174     return descr
1175
1176
1177 def describe_timedelta(delta: datetime.timedelta) -> str:
1178     """
1179     Describe a duration represented by a timedelta object.
1180
1181     Args:
1182         delta: the timedelta object that represents the duration to describe.
1183
1184     Returns:
1185         A string representation of the input duration.
1186
1187     .. warning::
1188
1189         Milliseconds are never included in the string representation of
1190         durations even through they may be represented by an input
1191         `datetime.timedelta`.  Not for use when this level of precision
1192         is needed.
1193
1194     >>> d = datetime.timedelta(1, 600)
1195     >>> describe_timedelta(d)
1196     '1 day, and 10 minutes'
1197     """
1198     return describe_duration(int(delta.total_seconds()))  # Note: drops milliseconds
1199
1200
1201 def describe_duration_briefly(seconds: int, *, include_seconds: bool = False) -> str:
1202     """
1203     Describe a duration briefly.
1204
1205     Args:
1206         seconds: the number of seconds in the duration to describe.
1207         include_seconds: should we include seconds in our description or omit?
1208
1209     Returns:
1210         A string describing the duration represented by the input seconds briefly.
1211
1212     .. note::
1213
1214         Of course if we drop the seconds part the result is not precise.
1215         See examples.
1216
1217     >>> describe_duration_briefly(182)
1218     '3m'
1219
1220     >>> describe_duration_briefly(182, include_seconds=True)
1221     '3m 2s'
1222
1223     >>> describe_duration_briefly(100, include_seconds=True)
1224     '1m 40s'
1225
1226     describe_duration_briefly(1303200)
1227     '15d 2h'
1228
1229     """
1230     days = divmod(seconds, constants.SECONDS_PER_DAY)
1231     hours = divmod(days[1], constants.SECONDS_PER_HOUR)
1232     minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
1233
1234     descr = ""
1235     if days[0] > 0:
1236         descr = f"{int(days[0])}d "
1237     if hours[0] > 0:
1238         descr = descr + f"{int(hours[0])}h "
1239     if minutes[0] > 0 or (len(descr) == 0 and not include_seconds):
1240         descr = descr + f"{int(minutes[0])}m "
1241     if minutes[1] > 0 and include_seconds:
1242         descr = descr + f"{int(minutes[1])}s"
1243     return descr.strip()
1244
1245
1246 def describe_timedelta_briefly(
1247     delta: datetime.timedelta, *, include_seconds: bool = False
1248 ) -> str:
1249     """
1250     Describe a duration represented by a timedelta object.
1251
1252     Args:
1253         delta: the timedelta to describe briefly
1254         include_seconds: should we include the second delta?
1255
1256     Returns:
1257         A string description of the input timedelta object.
1258
1259     .. warning::
1260
1261         Milliseconds are never included in the string representation of
1262         durations even through they may be represented by an input
1263         `datetime.timedelta`.  Not for use when this level of precision
1264         is needed.
1265
1266     >>> d = datetime.timedelta(1, 600)
1267     >>> describe_timedelta_briefly(d)
1268     '1d 10m'
1269     """
1270     return describe_duration_briefly(
1271         int(delta.total_seconds()),
1272         include_seconds=include_seconds,
1273     )  # Note: drops milliseconds
1274
1275
1276 # The code to compute easter on a given year was copied from dateutil (pip
1277 # install python-dateutil) and dumped in here to avoid a dependency.  Dateutil
1278 # is an Apache 2.0 LICENSE open source project:
1279
1280 # Copyright 2017- Paul Ganssle <[email protected]>
1281 # Copyright 2017- dateutil contributors (see AUTHORS file)
1282
1283 #    Licensed under the Apache License, Version 2.0 (the "License");
1284 #    you may not use this file except in compliance with the License.
1285 #    You may obtain a copy of the License at
1286
1287 #        http://www.apache.org/licenses/LICENSE-2.0
1288
1289 #    Unless required by applicable law or agreed to in writing, software
1290 #    distributed under the License is distributed on an "AS IS" BASIS,
1291 #    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1292 #    See the License for the specific language governing permissions and
1293 #    limitations under the License.
1294
1295 # The above license applies to all contributions after 2017-12-01, as well as
1296 # all contributions that have been re-licensed (see AUTHORS file for the list of
1297 # contributors who have re-licensed their code).
1298 # --------------------------------------------------------------------------------
1299 # dateutil - Extensions to the standard Python datetime module.
1300
1301 # Copyright (c) 2003-2011 - Gustavo Niemeyer <[email protected]>
1302 # Copyright (c) 2012-2014 - Tomi Pieviläinen <[email protected]>
1303 # Copyright (c) 2014-2016 - Yaron de Leeuw <[email protected]>
1304 # Copyright (c) 2015-     - Paul Ganssle <[email protected]>
1305 # Copyright (c) 2015-     - dateutil contributors (see AUTHORS file)
1306
1307 # All rights reserved.
1308
1309 # Redistribution and use in source and binary forms, with or without
1310 # modification, are permitted provided that the following conditions are met:
1311
1312 #     * Redistributions of source code must retain the above copyright notice,
1313 #       this list of conditions and the following disclaimer.
1314 #     * Redistributions in binary form must reproduce the above copyright notice,
1315 #       this list of conditions and the following disclaimer in the documentation
1316 #       and/or other materials provided with the distribution.
1317 #     * Neither the name of the copyright holder nor the names of its
1318 #       contributors may be used to endorse or promote products derived from
1319 #       this software without specific prior written permission.
1320
1321 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
1322 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
1323 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
1324 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
1325 # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
1326 # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
1327 # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
1328 # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
1329 # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
1330 # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
1331 # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
1332
1333 # The above BSD License Applies to all code, even that also covered by Apache 2.0.
1334
1335 EASTER_JULIAN = 1
1336 EASTER_ORTHODOX = 2
1337 EASTER_WESTERN = 3
1338
1339
1340 def easter(year: int, method: int = EASTER_WESTERN):
1341     """
1342     This method was ported from the work done by GM Arts,
1343     on top of the algorithm by Claus Tondering, which was
1344     based in part on the algorithm of Ouding (1940), as
1345     quoted in "Explanatory Supplement to the Astronomical
1346     Almanac", P.  Kenneth Seidelmann, editor.
1347
1348     This algorithm implements three different Easter
1349     calculation methods:
1350
1351     1. Original calculation in Julian calendar, valid in
1352        dates after 326 AD
1353     2. Original method, with date converted to Gregorian
1354        calendar, valid in years 1583 to 4099
1355     3. Revised method, in Gregorian calendar, valid in
1356        years 1583 to 4099 as well
1357
1358     These methods are represented by the constants:
1359
1360     * ``EASTER_JULIAN   = 1``
1361     * ``EASTER_ORTHODOX = 2``
1362     * ``EASTER_WESTERN  = 3``
1363
1364     The default method is method 3.
1365
1366     More about the algorithm may be found at:
1367
1368     `GM Arts: Easter Algorithms <http://www.gmarts.org/index.php?go=415>`_
1369
1370     and
1371
1372     `The Calendar FAQ: Easter <https://www.tondering.dk/claus/cal/easter.php>`_
1373
1374     Raises:
1375         ValueError if method argument is invalid
1376
1377     """
1378
1379     if not (1 <= method <= 3):
1380         raise ValueError("invalid method")
1381
1382     # g - Golden year - 1
1383     # c - Century
1384     # h - (23 - Epact) mod 30
1385     # i - Number of days from March 21 to Paschal Full Moon
1386     # j - Weekday for PFM (0=Sunday, etc)
1387     # p - Number of days from March 21 to Sunday on or before PFM
1388     #     (-6 to 28 methods 1 & 3, to 56 for method 2)
1389     # e - Extra days to add for method 2 (converting Julian
1390     #     date to Gregorian date)
1391
1392     y = year
1393     g = y % 19
1394     e = 0
1395     if method < 3:
1396         # Old method
1397         i = (19 * g + 15) % 30
1398         j = (y + y // 4 + i) % 7
1399         if method == 2:
1400             # Extra dates to convert Julian to Gregorian date
1401             e = 10
1402             if y > 1600:
1403                 e = e + y // 100 - 16 - (y // 100 - 16) // 4
1404     else:
1405         # New method
1406         c = y // 100
1407         h = (c - c // 4 - (8 * c + 13) // 25 + 19 * g + 15) % 30
1408         i = h - (h // 28) * (1 - (h // 28) * (29 // (h + 1)) * ((21 - g) // 11))
1409         j = (y + y // 4 + i + 2 - c + c // 4) % 7
1410
1411     # p can be from -6 to 56 corresponding to dates 22 March to 23 May
1412     # (later dates apply to method 2, although 23 May never actually occurs)
1413     p = i - j + e
1414     d = 1 + (p + 27 + (p + 6) // 40) % 31
1415     m = 3 + (p + 26) // 30
1416     return datetime.date(int(y), int(m), int(d))
1417
1418
1419 if __name__ == "__main__":
1420     import doctest
1421
1422     doctest.testmod()