More cleanup, yey!
[python_utils.git] / datetime_utils.py
1 #!/usr/bin/env python3
2
3 """Utilities related to dates and times and datetimes."""
4
5 import datetime
6 import enum
7 import logging
8 import re
9 from typing import Any, NewType, Tuple
10
11 import holidays  # type: ignore
12 import pytz
13
14 import constants
15
16 logger = logging.getLogger(__name__)
17
18
19 def is_timezone_aware(dt: datetime.datetime) -> bool:
20     """See: https://docs.python.org/3/library/datetime.html
21                                #determining-if-an-object-is-aware-or-naive
22
23     >>> is_timezone_aware(datetime.datetime.now())
24     False
25
26     >>> is_timezone_aware(now_pacific())
27     True
28
29     """
30     return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
31
32
33 def is_timezone_naive(dt: datetime.datetime) -> bool:
34     return not is_timezone_aware(dt)
35
36
37 def replace_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.datetime:
38     """
39     Replaces the timezone on a datetime object directly (leaving
40     the year, month, day, hour, minute, second, micro, etc... alone).
41     Note: this changes the instant to which this dt refers.
42
43     >>> from pytz import UTC
44     >>> d = now_pacific()
45     >>> d.tzinfo.tzname(d)[0]     # Note: could be PST or PDT
46     'P'
47     >>> h = d.hour
48     >>> o = replace_timezone(d, UTC)
49     >>> o.tzinfo.tzname(o)
50     'UTC'
51     >>> o.hour == h
52     True
53
54     """
55     return datetime.datetime(
56         dt.year,
57         dt.month,
58         dt.day,
59         dt.hour,
60         dt.minute,
61         dt.second,
62         dt.microsecond,
63         tzinfo=tz,
64     )
65
66
67 def replace_time_timezone(t: datetime.time, tz: datetime.tzinfo) -> datetime.time:
68     """
69     Replaces the timezone on a datetime.time directly without performing
70     any translation.
71
72     >>> t = datetime.time(8, 15, 12, 0, pytz.UTC)
73     >>> t.tzname()
74     'UTC'
75
76     >>> t = replace_time_timezone(t, pytz.timezone('US/Pacific'))
77     >>> t.tzname()
78     'US/Pacific'
79
80     """
81     return t.replace(tzinfo=tz)
82
83
84 def translate_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.datetime:
85     """
86     Translates dt into a different timezone by adjusting the year, month,
87     day, hour, minute, second, micro, etc... appropriately.  The returned
88     dt is the same instant in another timezone.
89
90     >>> from pytz import UTC
91     >>> d = now_pacific()
92     >>> d.tzinfo.tzname(d)[0]     # Note: could be PST or PDT
93     'P'
94     >>> h = d.hour
95     >>> o = translate_timezone(d, UTC)
96     >>> o.tzinfo.tzname(o)
97     'UTC'
98     >>> o.hour == h
99     False
100
101     """
102     return dt.replace(tzinfo=None).astimezone(tz=tz)
103
104
105 def now() -> datetime.datetime:
106     """
107     What time is it?  Result returned in UTC
108     """
109     return datetime.datetime.now()
110
111
112 def now_pacific() -> datetime.datetime:
113     """
114     What time is it?  Result in US/Pacific time (PST/PDT)
115     """
116     return datetime.datetime.now(pytz.timezone("US/Pacific"))
117
118
119 def date_to_datetime(date: datetime.date) -> datetime.datetime:
120     """
121     Given a date, return a datetime with hour/min/sec zero (midnight)
122
123     >>> import datetime
124     >>> date_to_datetime(datetime.date(2021, 12, 25))
125     datetime.datetime(2021, 12, 25, 0, 0)
126
127     """
128     return datetime.datetime(date.year, date.month, date.day, 0, 0, 0, 0)
129
130
131 def time_to_datetime_today(time: datetime.time) -> datetime.datetime:
132     """
133     Given a time, returns that time as a datetime with a date component
134     set based on the current date.  If the time passed is timezone aware,
135     the resulting datetime will also be (and will use the same tzinfo).
136     If the time is timezone naive, the datetime returned will be too.
137
138     >>> t = datetime.time(13, 14, 0)
139     >>> d = now_pacific().date()
140     >>> dt = time_to_datetime_today(t)
141     >>> dt.date() == d
142     True
143
144     >>> dt.time() == t
145     True
146
147     >>> dt.tzinfo == t.tzinfo
148     True
149
150     >>> dt.tzinfo == None
151     True
152
153     >>> t = datetime.time(8, 15, 12, 0, pytz.UTC)
154     >>> t.tzinfo == None
155     False
156
157     >>> dt = time_to_datetime_today(t)
158     >>> dt.tzinfo == None
159     False
160
161     """
162     tz = time.tzinfo
163     return datetime.datetime.combine(now_pacific(), time, tz)
164
165
166 def date_and_time_to_datetime(date: datetime.date, time: datetime.time) -> datetime.datetime:
167     """
168     Given a date and time, merge them and return a datetime.
169
170     >>> import datetime
171     >>> d = datetime.date(2021, 12, 25)
172     >>> t = datetime.time(12, 30, 0, 0)
173     >>> date_and_time_to_datetime(d, t)
174     datetime.datetime(2021, 12, 25, 12, 30)
175
176     """
177     return datetime.datetime(
178         date.year,
179         date.month,
180         date.day,
181         time.hour,
182         time.minute,
183         time.second,
184         time.microsecond,
185     )
186
187
188 def datetime_to_date_and_time(
189     dt: datetime.datetime,
190 ) -> Tuple[datetime.date, datetime.time]:
191     """Return the component date and time objects of a datetime.
192
193     >>> import datetime
194     >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
195     >>> (d, t) = datetime_to_date_and_time(dt)
196     >>> d
197     datetime.date(2021, 12, 25)
198     >>> t
199     datetime.time(12, 30)
200
201     """
202     return (dt.date(), dt.timetz())
203
204
205 def datetime_to_date(dt: datetime.datetime) -> datetime.date:
206     """Return the date part of a datetime.
207
208     >>> import datetime
209     >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
210     >>> datetime_to_date(dt)
211     datetime.date(2021, 12, 25)
212
213     """
214     return datetime_to_date_and_time(dt)[0]
215
216
217 def datetime_to_time(dt: datetime.datetime) -> datetime.time:
218     """Return the time part of a datetime.
219
220     >>> import datetime
221     >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
222     >>> datetime_to_time(dt)
223     datetime.time(12, 30)
224
225     """
226     return datetime_to_date_and_time(dt)[1]
227
228
229 class TimeUnit(enum.IntEnum):
230     """An enum to represent units with which we can compute deltas."""
231
232     MONDAYS = 0
233     TUESDAYS = 1
234     WEDNESDAYS = 2
235     THURSDAYS = 3
236     FRIDAYS = 4
237     SATURDAYS = 5
238     SUNDAYS = 6
239     SECONDS = 7
240     MINUTES = 8
241     HOURS = 9
242     DAYS = 10
243     WORKDAYS = 11
244     WEEKS = 12
245     MONTHS = 13
246     YEARS = 14
247
248     @classmethod
249     def is_valid(cls, value: Any):
250         if isinstance(value, int):
251             return cls(value) is not None
252         elif isinstance(value, TimeUnit):
253             return cls(value.value) is not None
254         elif isinstance(value, str):
255             return cls.__members__[value] is not None
256         else:
257             print(type(value))
258             return False
259
260
261 def n_timeunits_from_base(count: int, unit: TimeUnit, base: datetime.datetime) -> datetime.datetime:
262     """Return a datetime that is N units before/after a base datetime.
263     e.g.  3 Wednesdays from base datetime, 2 weeks from base date, 10
264     years before base datetime, 13 minutes after base datetime, etc...
265     Note: to indicate before/after the base date, use a positive or
266     negative count.
267
268     >>> base = string_to_datetime("2021/09/10 11:24:51AM-0700")[0]
269
270     The next (1) Monday from the base datetime:
271     >>> n_timeunits_from_base(+1, TimeUnit.MONDAYS, base)
272     datetime.datetime(2021, 9, 13, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
273
274     Ten (10) years after the base datetime:
275     >>> n_timeunits_from_base(10, TimeUnit.YEARS, base)
276     datetime.datetime(2031, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
277
278     Fifty (50) working days (M..F, not counting holidays) after base datetime:
279     >>> n_timeunits_from_base(50, TimeUnit.WORKDAYS, base)
280     datetime.datetime(2021, 11, 23, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
281
282     Fifty (50) days (including weekends and holidays) after base datetime:
283     >>> n_timeunits_from_base(50, TimeUnit.DAYS, base)
284     datetime.datetime(2021, 10, 30, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
285
286     Fifty (50) months before (note negative count) base datetime:
287     >>> n_timeunits_from_base(-50, TimeUnit.MONTHS, base)
288     datetime.datetime(2017, 7, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
289
290     Fifty (50) hours after base datetime:
291     >>> n_timeunits_from_base(50, TimeUnit.HOURS, base)
292     datetime.datetime(2021, 9, 12, 13, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
293
294     Fifty (50) minutes before base datetime:
295     >>> n_timeunits_from_base(-50, TimeUnit.MINUTES, base)
296     datetime.datetime(2021, 9, 10, 10, 34, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
297
298     Fifty (50) seconds from base datetime:
299     >>> n_timeunits_from_base(50, TimeUnit.SECONDS, base)
300     datetime.datetime(2021, 9, 10, 11, 25, 41, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
301
302     Next month corner case -- it will try to make Feb 31, 2022 then count
303     backwards.
304     >>> base = string_to_datetime("2022/01/31 11:24:51AM-0700")[0]
305     >>> n_timeunits_from_base(1, TimeUnit.MONTHS, base)
306     datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
307
308     Last month with the same corner case
309     >>> base = string_to_datetime("2022/03/31 11:24:51AM-0700")[0]
310     >>> n_timeunits_from_base(-1, TimeUnit.MONTHS, base)
311     datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
312
313     """
314     assert TimeUnit.is_valid(unit)
315     if count == 0:
316         return base
317
318     # N days from base
319     if unit == TimeUnit.DAYS:
320         timedelta = datetime.timedelta(days=count)
321         return base + timedelta
322
323     # N hours from base
324     elif unit == TimeUnit.HOURS:
325         timedelta = datetime.timedelta(hours=count)
326         return base + timedelta
327
328     # N minutes from base
329     elif unit == TimeUnit.MINUTES:
330         timedelta = datetime.timedelta(minutes=count)
331         return base + timedelta
332
333     # N seconds from base
334     elif unit == TimeUnit.SECONDS:
335         timedelta = datetime.timedelta(seconds=count)
336         return base + timedelta
337
338     # N workdays from base
339     elif unit == TimeUnit.WORKDAYS:
340         if count < 0:
341             count = abs(count)
342             timedelta = datetime.timedelta(days=-1)
343         else:
344             timedelta = datetime.timedelta(days=1)
345         skips = holidays.US(years=base.year).keys()
346         while count > 0:
347             old_year = base.year
348             base += timedelta
349             if base.year != old_year:
350                 skips = holidays.US(years=base.year).keys()
351             if base.weekday() < 5 and datetime.date(base.year, base.month, base.day) not in skips:
352                 count -= 1
353         return base
354
355     # N weeks from base
356     elif unit == TimeUnit.WEEKS:
357         timedelta = datetime.timedelta(weeks=count)
358         base = base + timedelta
359         return base
360
361     # N months from base
362     elif unit == TimeUnit.MONTHS:
363         month_term = count % 12
364         year_term = count // 12
365         new_month = base.month + month_term
366         if new_month > 12:
367             new_month %= 12
368             year_term += 1
369         new_year = base.year + year_term
370         day = base.day
371         while True:
372             try:
373                 ret = datetime.datetime(
374                     new_year,
375                     new_month,
376                     day,
377                     base.hour,
378                     base.minute,
379                     base.second,
380                     base.microsecond,
381                     base.tzinfo,
382                 )
383                 break
384             except ValueError:
385                 day -= 1
386         return ret
387
388     # N years from base
389     elif unit == TimeUnit.YEARS:
390         new_year = base.year + count
391         return datetime.datetime(
392             new_year,
393             base.month,
394             base.day,
395             base.hour,
396             base.minute,
397             base.second,
398             base.microsecond,
399             base.tzinfo,
400         )
401
402     if unit not in set(
403         [
404             TimeUnit.MONDAYS,
405             TimeUnit.TUESDAYS,
406             TimeUnit.WEDNESDAYS,
407             TimeUnit.THURSDAYS,
408             TimeUnit.FRIDAYS,
409             TimeUnit.SATURDAYS,
410             TimeUnit.SUNDAYS,
411         ]
412     ):
413         raise ValueError(unit)
414
415     # N weekdays from base (e.g. 4 wednesdays from today)
416     direction = 1 if count > 0 else -1
417     count = abs(count)
418     timedelta = datetime.timedelta(days=direction)
419     start = base
420     while True:
421         dow = base.weekday()
422         if dow == unit.value and start != base:
423             count -= 1
424             if count == 0:
425                 return base
426         base = base + timedelta
427
428
429 def get_format_string(
430     *,
431     date_time_separator=" ",
432     include_timezone=True,
433     include_dayname=False,
434     use_month_abbrevs=False,
435     include_seconds=True,
436     include_fractional=False,
437     twelve_hour=True,
438 ) -> str:
439     """
440     Helper to return a format string without looking up the documentation
441     for strftime.
442
443     >>> get_format_string()
444     '%Y/%m/%d %I:%M:%S%p%z'
445
446     >>> get_format_string(date_time_separator='@')
447     '%Y/%m/%d@%I:%M:%S%p%z'
448
449     >>> get_format_string(include_dayname=True)
450     '%a/%Y/%m/%d %I:%M:%S%p%z'
451
452     >>> get_format_string(include_dayname=True, twelve_hour=False)
453     '%a/%Y/%m/%d %H:%M:%S%z'
454
455     """
456     fstring = ""
457     if include_dayname:
458         fstring += "%a/"
459
460     if use_month_abbrevs:
461         fstring = f"{fstring}%Y/%b/%d{date_time_separator}"
462     else:
463         fstring = f"{fstring}%Y/%m/%d{date_time_separator}"
464     if twelve_hour:
465         fstring += "%I:%M"
466         if include_seconds:
467             fstring += ":%S"
468         fstring += "%p"
469     else:
470         fstring += "%H:%M"
471         if include_seconds:
472             fstring += ":%S"
473     if include_fractional:
474         fstring += ".%f"
475     if include_timezone:
476         fstring += "%z"
477     return fstring
478
479
480 def datetime_to_string(
481     dt: datetime.datetime,
482     *,
483     date_time_separator=" ",
484     include_timezone=True,
485     include_dayname=False,
486     use_month_abbrevs=False,
487     include_seconds=True,
488     include_fractional=False,
489     twelve_hour=True,
490 ) -> str:
491     """
492     A nice way to convert a datetime into a string; arguably better than
493     just printing it and relying on it __repr__().
494
495     >>> d = string_to_datetime(
496     ...                        "2021/09/10 11:24:51AM-0700",
497     ...                       )[0]
498     >>> d
499     datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
500     >>> datetime_to_string(d)
501     '2021/09/10 11:24:51AM-0700'
502     >>> datetime_to_string(d, include_dayname=True, include_seconds=False)
503     'Fri/2021/09/10 11:24AM-0700'
504
505     """
506     fstring = get_format_string(
507         date_time_separator=date_time_separator,
508         include_timezone=include_timezone,
509         include_dayname=include_dayname,
510         use_month_abbrevs=use_month_abbrevs,
511         include_seconds=include_seconds,
512         include_fractional=include_fractional,
513         twelve_hour=twelve_hour,
514     )
515     return dt.strftime(fstring).strip()
516
517
518 def string_to_datetime(
519     txt: str,
520     *,
521     date_time_separator=" ",
522     include_timezone=True,
523     include_dayname=False,
524     use_month_abbrevs=False,
525     include_seconds=True,
526     include_fractional=False,
527     twelve_hour=True,
528 ) -> Tuple[datetime.datetime, str]:
529     """A nice way to convert a string into a datetime.  Returns both the
530     datetime and the format string used to parse it.  Also consider
531     dateparse.dateparse_utils for a full parser alternative.
532
533     >>> d = string_to_datetime(
534     ...                        "2021/09/10 11:24:51AM-0700",
535     ...                       )
536     >>> d
537     (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')
538
539     """
540     fstring = get_format_string(
541         date_time_separator=date_time_separator,
542         include_timezone=include_timezone,
543         include_dayname=include_dayname,
544         use_month_abbrevs=use_month_abbrevs,
545         include_seconds=include_seconds,
546         include_fractional=include_fractional,
547         twelve_hour=twelve_hour,
548     )
549     return (datetime.datetime.strptime(txt, fstring), fstring)
550
551
552 def timestamp() -> str:
553     """Return a timestamp for right now in Pacific timezone."""
554     ts = datetime.datetime.now(tz=pytz.timezone("US/Pacific"))
555     return datetime_to_string(ts, include_timezone=True)
556
557
558 def time_to_string(
559     dt: datetime.datetime,
560     *,
561     include_seconds=True,
562     include_fractional=False,
563     include_timezone=False,
564     twelve_hour=True,
565 ) -> str:
566     """A nice way to convert a datetime into a time (only) string.
567     This ignores the date part of the datetime.
568
569     >>> d = string_to_datetime(
570     ...                        "2021/09/10 11:24:51AM-0700",
571     ...                       )[0]
572     >>> d
573     datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
574
575     >>> time_to_string(d)
576     '11:24:51AM'
577
578     >>> time_to_string(d, include_seconds=False)
579     '11:24AM'
580
581     >>> time_to_string(d, include_seconds=False, include_timezone=True)
582     '11:24AM-0700'
583
584     """
585     fstring = ""
586     if twelve_hour:
587         fstring += "%l:%M"
588         if include_seconds:
589             fstring += ":%S"
590         fstring += "%p"
591     else:
592         fstring += "%H:%M"
593         if include_seconds:
594             fstring += ":%S"
595     if include_fractional:
596         fstring += ".%f"
597     if include_timezone:
598         fstring += "%z"
599     return dt.strftime(fstring).strip()
600
601
602 def seconds_to_timedelta(seconds: int) -> datetime.timedelta:
603     """Convert a delta in seconds into a timedelta."""
604     return datetime.timedelta(seconds=seconds)
605
606
607 MinuteOfDay = NewType("MinuteOfDay", int)
608
609
610 def minute_number(hour: int, minute: int) -> MinuteOfDay:
611     """
612     Convert hour:minute into minute number from start of day.
613
614     >>> minute_number(0, 0)
615     0
616
617     >>> minute_number(9, 15)
618     555
619
620     >>> minute_number(23, 59)
621     1439
622
623     """
624     return MinuteOfDay(hour * 60 + minute)
625
626
627 def datetime_to_minute_number(dt: datetime.datetime) -> MinuteOfDay:
628     """
629     Convert a datetime into a minute number (of the day).  Note that
630     this ignores the date part of the datetime and only uses the time
631     part.
632
633     >>> d = string_to_datetime(
634     ...                        "2021/09/10 11:24:51AM-0700",
635     ...                       )[0]
636
637     >>> datetime_to_minute_number(d)
638     684
639
640     """
641     return minute_number(dt.hour, dt.minute)
642
643
644 def time_to_minute_number(t: datetime.time) -> MinuteOfDay:
645     """
646     Convert a datetime.time into a minute number.
647
648     >>> t = datetime.time(5, 15)
649     >>> time_to_minute_number(t)
650     315
651
652     """
653     return minute_number(t.hour, t.minute)
654
655
656 def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
657     """
658     Convert minute number from start of day into hour:minute am/pm
659     string.
660
661     >>> minute_number_to_time_string(315)
662     ' 5:15a'
663
664     >>> minute_number_to_time_string(684)
665     '11:24a'
666
667     """
668     hour = minute_num // 60
669     minute = minute_num % 60
670     ampm = "a"
671     if hour > 12:
672         hour -= 12
673         ampm = "p"
674     if hour == 12:
675         ampm = "p"
676     if hour == 0:
677         hour = 12
678     return f"{hour:2}:{minute:02}{ampm}"
679
680
681 def parse_duration(duration: str) -> int:
682     """
683     Parse a duration in string form into a delta seconds.
684
685     >>> parse_duration('15 days, 2 hours')
686     1303200
687
688     >>> parse_duration('15d 2h')
689     1303200
690
691     >>> parse_duration('100s')
692     100
693
694     >>> parse_duration('3min 2sec')
695     182
696
697     """
698     if duration.isdigit():
699         return int(duration)
700     seconds = 0
701     m = re.search(r'(\d+) *d[ays]*', duration)
702     if m is not None:
703         seconds += int(m.group(1)) * 60 * 60 * 24
704     m = re.search(r'(\d+) *h[ours]*', duration)
705     if m is not None:
706         seconds += int(m.group(1)) * 60 * 60
707     m = re.search(r'(\d+) *m[inutes]*', duration)
708     if m is not None:
709         seconds += int(m.group(1)) * 60
710     m = re.search(r'(\d+) *s[econds]*', duration)
711     if m is not None:
712         seconds += int(m.group(1))
713     return seconds
714
715
716 def describe_duration(seconds: int, *, include_seconds=False) -> str:
717     """
718     Describe a duration represented as a count of seconds nicely.
719
720     >>> describe_duration(182)
721     '3 minutes'
722
723     >>> describe_duration(182, include_seconds=True)
724     '3 minutes, and 2 seconds'
725
726     >>> describe_duration(100, include_seconds=True)
727     '1 minute, and 40 seconds'
728
729     describe_duration(1303200)
730     '15 days, 2 hours'
731
732     """
733     days = divmod(seconds, constants.SECONDS_PER_DAY)
734     hours = divmod(days[1], constants.SECONDS_PER_HOUR)
735     minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
736
737     descr = ""
738     if days[0] > 1:
739         descr = f"{int(days[0])} days, "
740     elif days[0] == 1:
741         descr = "1 day, "
742
743     if hours[0] > 1:
744         descr = descr + f"{int(hours[0])} hours, "
745     elif hours[0] == 1:
746         descr = descr + "1 hour, "
747
748     if not include_seconds and len(descr) > 0:
749         descr = descr + "and "
750
751     if minutes[0] == 1:
752         descr = descr + "1 minute"
753     else:
754         descr = descr + f"{int(minutes[0])} minutes"
755
756     if include_seconds:
757         descr = descr + ', '
758         if len(descr) > 0:
759             descr = descr + 'and '
760         s = minutes[1]
761         if s == 1:
762             descr = descr + '1 second'
763         else:
764             descr = descr + f'{s} seconds'
765     return descr
766
767
768 def describe_timedelta(delta: datetime.timedelta) -> str:
769     """
770     Describe a duration represented by a timedelta object.
771
772     >>> d = datetime.timedelta(1, 600)
773     >>> describe_timedelta(d)
774     '1 day, and 10 minutes'
775
776     """
777     return describe_duration(int(delta.total_seconds()))  # Note: drops milliseconds
778
779
780 def describe_duration_briefly(seconds: int, *, include_seconds=False) -> str:
781     """
782     Describe a duration briefly.
783
784     >>> describe_duration_briefly(182)
785     '3m'
786
787     >>> describe_duration_briefly(182, include_seconds=True)
788     '3m 2s'
789
790     >>> describe_duration_briefly(100, include_seconds=True)
791     '1m 40s'
792
793     describe_duration_briefly(1303200)
794     '15d 2h'
795
796     """
797     days = divmod(seconds, constants.SECONDS_PER_DAY)
798     hours = divmod(days[1], constants.SECONDS_PER_HOUR)
799     minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
800
801     descr = ''
802     if days[0] > 0:
803         descr = f'{int(days[0])}d '
804     if hours[0] > 0:
805         descr = descr + f'{int(hours[0])}h '
806     if minutes[0] > 0 or (len(descr) == 0 and not include_seconds):
807         descr = descr + f'{int(minutes[0])}m '
808     if minutes[1] > 0 and include_seconds:
809         descr = descr + f'{int(minutes[1])}s'
810     return descr.strip()
811
812
813 def describe_timedelta_briefly(delta: datetime.timedelta) -> str:
814     """
815     Describe a duration represented by a timedelta object.
816
817     >>> d = datetime.timedelta(1, 600)
818     >>> describe_timedelta_briefly(d)
819     '1d 10m'
820
821     """
822     return describe_duration_briefly(int(delta.total_seconds()))  # Note: drops milliseconds
823
824
825 if __name__ == '__main__':
826     import doctest
827
828     doctest.testmod()