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