Change settings in flake8 and black.
[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     now = now_pacific()
163     tz = time.tzinfo
164     return datetime.datetime.combine(now, time, tz)
165
166
167 def date_and_time_to_datetime(date: datetime.date, time: datetime.time) -> datetime.datetime:
168     """
169     Given a date and time, merge them and return a datetime.
170
171     >>> import datetime
172     >>> d = datetime.date(2021, 12, 25)
173     >>> t = datetime.time(12, 30, 0, 0)
174     >>> date_and_time_to_datetime(d, t)
175     datetime.datetime(2021, 12, 25, 12, 30)
176
177     """
178     return datetime.datetime(
179         date.year,
180         date.month,
181         date.day,
182         time.hour,
183         time.minute,
184         time.second,
185         time.microsecond,
186     )
187
188
189 def datetime_to_date_and_time(
190     dt: datetime.datetime,
191 ) -> Tuple[datetime.date, datetime.time]:
192     """Return the component date and time objects of a datetime.
193
194     >>> import datetime
195     >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
196     >>> (d, t) = datetime_to_date_and_time(dt)
197     >>> d
198     datetime.date(2021, 12, 25)
199     >>> t
200     datetime.time(12, 30)
201
202     """
203     return (dt.date(), dt.timetz())
204
205
206 def datetime_to_date(dt: datetime.datetime) -> datetime.date:
207     """Return the date part of a datetime.
208
209     >>> import datetime
210     >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
211     >>> datetime_to_date(dt)
212     datetime.date(2021, 12, 25)
213
214     """
215     return datetime_to_date_and_time(dt)[0]
216
217
218 def datetime_to_time(dt: datetime.datetime) -> datetime.time:
219     """Return the time part of a datetime.
220
221     >>> import datetime
222     >>> dt = datetime.datetime(2021, 12, 25, 12, 30)
223     >>> datetime_to_time(dt)
224     datetime.time(12, 30)
225
226     """
227     return datetime_to_date_and_time(dt)[1]
228
229
230 class TimeUnit(enum.IntEnum):
231     """An enum to represent units with which we can compute deltas."""
232
233     MONDAYS = 0
234     TUESDAYS = 1
235     WEDNESDAYS = 2
236     THURSDAYS = 3
237     FRIDAYS = 4
238     SATURDAYS = 5
239     SUNDAYS = 6
240     SECONDS = 7
241     MINUTES = 8
242     HOURS = 9
243     DAYS = 10
244     WORKDAYS = 11
245     WEEKS = 12
246     MONTHS = 13
247     YEARS = 14
248
249     @classmethod
250     def is_valid(cls, value: Any):
251         if type(value) is int:
252             return value in cls._value2member_map_
253         elif type(value) is TimeUnit:
254             return value.value in cls._value2member_map_
255         elif type(value) is str:
256             return value in cls._member_names_
257         else:
258             print(type(value))
259             return False
260
261
262 def n_timeunits_from_base(count: int, unit: TimeUnit, base: datetime.datetime) -> datetime.datetime:
263     """Return a datetime that is N units before/after a base datetime.
264     e.g.  3 Wednesdays from base datetime, 2 weeks from base date, 10
265     years before base datetime, 13 minutes after base datetime, etc...
266     Note: to indicate before/after the base date, use a positive or
267     negative count.
268
269     >>> base = string_to_datetime("2021/09/10 11:24:51AM-0700")[0]
270
271     The next (1) Monday from the base datetime:
272     >>> n_timeunits_from_base(+1, TimeUnit.MONDAYS, base)
273     datetime.datetime(2021, 9, 13, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
274
275     Ten (10) years after the base datetime:
276     >>> n_timeunits_from_base(10, TimeUnit.YEARS, base)
277     datetime.datetime(2031, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
278
279     Fifty (50) working days (M..F, not counting holidays) after base datetime:
280     >>> n_timeunits_from_base(50, TimeUnit.WORKDAYS, base)
281     datetime.datetime(2021, 11, 23, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
282
283     Fifty (50) days (including weekends and holidays) after base datetime:
284     >>> n_timeunits_from_base(50, TimeUnit.DAYS, base)
285     datetime.datetime(2021, 10, 30, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
286
287     Fifty (50) months before (note negative count) base datetime:
288     >>> n_timeunits_from_base(-50, TimeUnit.MONTHS, base)
289     datetime.datetime(2017, 7, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
290
291     Fifty (50) hours after base datetime:
292     >>> n_timeunits_from_base(50, TimeUnit.HOURS, base)
293     datetime.datetime(2021, 9, 12, 13, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
294
295     Fifty (50) minutes before base datetime:
296     >>> n_timeunits_from_base(-50, TimeUnit.MINUTES, base)
297     datetime.datetime(2021, 9, 10, 10, 34, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
298
299     Fifty (50) seconds from base datetime:
300     >>> n_timeunits_from_base(50, TimeUnit.SECONDS, base)
301     datetime.datetime(2021, 9, 10, 11, 25, 41, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
302
303     Next month corner case -- it will try to make Feb 31, 2022 then count
304     backwards.
305     >>> base = string_to_datetime("2022/01/31 11:24:51AM-0700")[0]
306     >>> n_timeunits_from_base(1, TimeUnit.MONTHS, base)
307     datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
308
309     Last month with the same corner case
310     >>> base = string_to_datetime("2022/03/31 11:24:51AM-0700")[0]
311     >>> n_timeunits_from_base(-1, TimeUnit.MONTHS, base)
312     datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
313
314     """
315     assert TimeUnit.is_valid(unit)
316     if count == 0:
317         return base
318
319     # N days from base
320     if unit == TimeUnit.DAYS:
321         timedelta = datetime.timedelta(days=count)
322         return base + timedelta
323
324     # N hours from base
325     elif unit == TimeUnit.HOURS:
326         timedelta = datetime.timedelta(hours=count)
327         return base + timedelta
328
329     # N minutes from base
330     elif unit == TimeUnit.MINUTES:
331         timedelta = datetime.timedelta(minutes=count)
332         return base + timedelta
333
334     # N seconds from base
335     elif unit == TimeUnit.SECONDS:
336         timedelta = datetime.timedelta(seconds=count)
337         return base + timedelta
338
339     # N workdays from base
340     elif unit == TimeUnit.WORKDAYS:
341         if count < 0:
342             count = abs(count)
343             timedelta = datetime.timedelta(days=-1)
344         else:
345             timedelta = datetime.timedelta(days=1)
346         skips = holidays.US(years=base.year).keys()
347         while count > 0:
348             old_year = base.year
349             base += timedelta
350             if base.year != old_year:
351                 skips = holidays.US(years=base.year).keys()
352             if base.weekday() < 5 and datetime.date(base.year, base.month, base.day) not in skips:
353                 count -= 1
354         return base
355
356     # N weeks from base
357     elif unit == TimeUnit.WEEKS:
358         timedelta = datetime.timedelta(weeks=count)
359         base = base + timedelta
360         return base
361
362     # N months from base
363     elif unit == TimeUnit.MONTHS:
364         month_term = count % 12
365         year_term = count // 12
366         new_month = base.month + month_term
367         if new_month > 12:
368             new_month %= 12
369             year_term += 1
370         new_year = base.year + year_term
371         day = base.day
372         while True:
373             try:
374                 ret = datetime.datetime(
375                     new_year,
376                     new_month,
377                     day,
378                     base.hour,
379                     base.minute,
380                     base.second,
381                     base.microsecond,
382                     base.tzinfo,
383                 )
384                 break
385             except ValueError:
386                 day -= 1
387         return ret
388
389     # N years from base
390     elif unit == TimeUnit.YEARS:
391         new_year = base.year + count
392         return datetime.datetime(
393             new_year,
394             base.month,
395             base.day,
396             base.hour,
397             base.minute,
398             base.second,
399             base.microsecond,
400             base.tzinfo,
401         )
402
403     if unit not in set(
404         [
405             TimeUnit.MONDAYS,
406             TimeUnit.TUESDAYS,
407             TimeUnit.WEDNESDAYS,
408             TimeUnit.THURSDAYS,
409             TimeUnit.FRIDAYS,
410             TimeUnit.SATURDAYS,
411             TimeUnit.SUNDAYS,
412         ]
413     ):
414         raise ValueError(unit)
415
416     # N weekdays from base (e.g. 4 wednesdays from today)
417     direction = 1 if count > 0 else -1
418     count = abs(count)
419     timedelta = datetime.timedelta(days=direction)
420     start = base
421     while True:
422         dow = base.weekday()
423         if dow == unit.value and start != base:
424             count -= 1
425             if count == 0:
426                 return base
427         base = base + timedelta
428
429
430 def get_format_string(
431     *,
432     date_time_separator=" ",
433     include_timezone=True,
434     include_dayname=False,
435     use_month_abbrevs=False,
436     include_seconds=True,
437     include_fractional=False,
438     twelve_hour=True,
439 ) -> str:
440     """
441     Helper to return a format string without looking up the documentation
442     for strftime.
443
444     >>> get_format_string()
445     '%Y/%m/%d %I:%M:%S%p%z'
446
447     >>> get_format_string(date_time_separator='@')
448     '%Y/%m/%d@%I:%M:%S%p%z'
449
450     >>> get_format_string(include_dayname=True)
451     '%a/%Y/%m/%d %I:%M:%S%p%z'
452
453     >>> get_format_string(include_dayname=True, twelve_hour=False)
454     '%a/%Y/%m/%d %H:%M:%S%z'
455
456     """
457     fstring = ""
458     if include_dayname:
459         fstring += "%a/"
460
461     if use_month_abbrevs:
462         fstring = f"{fstring}%Y/%b/%d{date_time_separator}"
463     else:
464         fstring = f"{fstring}%Y/%m/%d{date_time_separator}"
465     if twelve_hour:
466         fstring += "%I:%M"
467         if include_seconds:
468             fstring += ":%S"
469         fstring += "%p"
470     else:
471         fstring += "%H:%M"
472         if include_seconds:
473             fstring += ":%S"
474     if include_fractional:
475         fstring += ".%f"
476     if include_timezone:
477         fstring += "%z"
478     return fstring
479
480
481 def datetime_to_string(
482     dt: datetime.datetime,
483     *,
484     date_time_separator=" ",
485     include_timezone=True,
486     include_dayname=False,
487     use_month_abbrevs=False,
488     include_seconds=True,
489     include_fractional=False,
490     twelve_hour=True,
491 ) -> str:
492     """
493     A nice way to convert a datetime into a string; arguably better than
494     just printing it and relying on it __repr__().
495
496     >>> d = string_to_datetime(
497     ...                        "2021/09/10 11:24:51AM-0700",
498     ...                       )[0]
499     >>> d
500     datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
501     >>> datetime_to_string(d)
502     '2021/09/10 11:24:51AM-0700'
503     >>> datetime_to_string(d, include_dayname=True, include_seconds=False)
504     'Fri/2021/09/10 11:24AM-0700'
505
506     """
507     fstring = get_format_string(
508         date_time_separator=date_time_separator,
509         include_timezone=include_timezone,
510         include_dayname=include_dayname,
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         include_seconds=include_seconds,
545         include_fractional=include_fractional,
546         twelve_hour=twelve_hour,
547     )
548     return (datetime.datetime.strptime(txt, fstring), fstring)
549
550
551 def timestamp() -> str:
552     """Return a timestamp for right now in Pacific timezone."""
553     ts = datetime.datetime.now(tz=pytz.timezone("US/Pacific"))
554     return datetime_to_string(ts, include_timezone=True)
555
556
557 def time_to_string(
558     dt: datetime.datetime,
559     *,
560     include_seconds=True,
561     include_fractional=False,
562     include_timezone=False,
563     twelve_hour=True,
564 ) -> str:
565     """A nice way to convert a datetime into a time (only) string.
566     This ignores the date part of the datetime.
567
568     >>> d = string_to_datetime(
569     ...                        "2021/09/10 11:24:51AM-0700",
570     ...                       )[0]
571     >>> d
572     datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
573
574     >>> time_to_string(d)
575     '11:24:51AM'
576
577     >>> time_to_string(d, include_seconds=False)
578     '11:24AM'
579
580     >>> time_to_string(d, include_seconds=False, include_timezone=True)
581     '11:24AM-0700'
582
583     """
584     fstring = ""
585     if twelve_hour:
586         fstring += "%l:%M"
587         if include_seconds:
588             fstring += ":%S"
589         fstring += "%p"
590     else:
591         fstring += "%H:%M"
592         if include_seconds:
593             fstring += ":%S"
594     if include_fractional:
595         fstring += ".%f"
596     if include_timezone:
597         fstring += "%z"
598     return dt.strftime(fstring).strip()
599
600
601 def seconds_to_timedelta(seconds: int) -> datetime.timedelta:
602     """Convert a delta in seconds into a timedelta."""
603     return datetime.timedelta(seconds=seconds)
604
605
606 MinuteOfDay = NewType("MinuteOfDay", int)
607
608
609 def minute_number(hour: int, minute: int) -> MinuteOfDay:
610     """
611     Convert hour:minute into minute number from start of day.
612
613     >>> minute_number(0, 0)
614     0
615
616     >>> minute_number(9, 15)
617     555
618
619     >>> minute_number(23, 59)
620     1439
621
622     """
623     return MinuteOfDay(hour * 60 + minute)
624
625
626 def datetime_to_minute_number(dt: datetime.datetime) -> MinuteOfDay:
627     """
628     Convert a datetime into a minute number (of the day).  Note that
629     this ignores the date part of the datetime and only uses the time
630     part.
631
632     >>> d = string_to_datetime(
633     ...                        "2021/09/10 11:24:51AM-0700",
634     ...                       )[0]
635
636     >>> datetime_to_minute_number(d)
637     684
638
639     """
640     return minute_number(dt.hour, dt.minute)
641
642
643 def time_to_minute_number(t: datetime.time) -> MinuteOfDay:
644     """
645     Convert a datetime.time into a minute number.
646
647     >>> t = datetime.time(5, 15)
648     >>> time_to_minute_number(t)
649     315
650
651     """
652     return minute_number(t.hour, t.minute)
653
654
655 def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
656     """
657     Convert minute number from start of day into hour:minute am/pm
658     string.
659
660     >>> minute_number_to_time_string(315)
661     ' 5:15a'
662
663     >>> minute_number_to_time_string(684)
664     '11:24a'
665
666     """
667     hour = minute_num // 60
668     minute = minute_num % 60
669     ampm = "a"
670     if hour > 12:
671         hour -= 12
672         ampm = "p"
673     if hour == 12:
674         ampm = "p"
675     if hour == 0:
676         hour = 12
677     return f"{hour:2}:{minute:02}{ampm}"
678
679
680 def parse_duration(duration: str) -> int:
681     """
682     Parse a duration in string form into a delta seconds.
683
684     >>> parse_duration('15 days, 2 hours')
685     1303200
686
687     >>> parse_duration('15d 2h')
688     1303200
689
690     >>> parse_duration('100s')
691     100
692
693     >>> parse_duration('3min 2sec')
694     182
695
696     """
697     if duration.isdigit():
698         return int(duration)
699     seconds = 0
700     m = re.search(r'(\d+) *d[ays]*', duration)
701     if m is not None:
702         seconds += int(m.group(1)) * 60 * 60 * 24
703     m = re.search(r'(\d+) *h[ours]*', duration)
704     if m is not None:
705         seconds += int(m.group(1)) * 60 * 60
706     m = re.search(r'(\d+) *m[inutes]*', duration)
707     if m is not None:
708         seconds += int(m.group(1)) * 60
709     m = re.search(r'(\d+) *s[econds]*', duration)
710     if m is not None:
711         seconds += int(m.group(1))
712     return seconds
713
714
715 def describe_duration(seconds: int, *, include_seconds=False) -> str:
716     """
717     Describe a duration represented as a count of seconds nicely.
718
719     >>> describe_duration(182)
720     '3 minutes'
721
722     >>> describe_duration(182, include_seconds=True)
723     '3 minutes, and 2 seconds'
724
725     >>> describe_duration(100, include_seconds=True)
726     '1 minute, and 40 seconds'
727
728     describe_duration(1303200)
729     '15 days, 2 hours'
730
731     """
732     days = divmod(seconds, constants.SECONDS_PER_DAY)
733     hours = divmod(days[1], constants.SECONDS_PER_HOUR)
734     minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
735
736     descr = ""
737     if days[0] > 1:
738         descr = f"{int(days[0])} days, "
739     elif days[0] == 1:
740         descr = "1 day, "
741
742     if hours[0] > 1:
743         descr = descr + f"{int(hours[0])} hours, "
744     elif hours[0] == 1:
745         descr = descr + "1 hour, "
746
747     if not include_seconds and len(descr) > 0:
748         descr = descr + "and "
749
750     if minutes[0] == 1:
751         descr = descr + "1 minute"
752     else:
753         descr = descr + f"{int(minutes[0])} minutes"
754
755     if include_seconds:
756         descr = descr + ', '
757         if len(descr) > 0:
758             descr = descr + 'and '
759         s = minutes[1]
760         if s == 1:
761             descr = descr + '1 second'
762         else:
763             descr = descr + f'{s} seconds'
764     return descr
765
766
767 def describe_timedelta(delta: datetime.timedelta) -> str:
768     """
769     Describe a duration represented by a timedelta object.
770
771     >>> d = datetime.timedelta(1, 600)
772     >>> describe_timedelta(d)
773     '1 day, and 10 minutes'
774
775     """
776     return describe_duration(int(delta.total_seconds()))  # Note: drops milliseconds
777
778
779 def describe_duration_briefly(seconds: int, *, include_seconds=False) -> str:
780     """
781     Describe a duration briefly.
782
783     >>> describe_duration_briefly(182)
784     '3m'
785
786     >>> describe_duration_briefly(182, include_seconds=True)
787     '3m 2s'
788
789     >>> describe_duration_briefly(100, include_seconds=True)
790     '1m 40s'
791
792     describe_duration_briefly(1303200)
793     '15d 2h'
794
795     """
796     days = divmod(seconds, constants.SECONDS_PER_DAY)
797     hours = divmod(days[1], constants.SECONDS_PER_HOUR)
798     minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
799
800     descr = ''
801     if days[0] > 0:
802         descr = f'{int(days[0])}d '
803     if hours[0] > 0:
804         descr = descr + f'{int(hours[0])}h '
805     if minutes[0] > 0 or (len(descr) == 0 and not include_seconds):
806         descr = descr + f'{int(minutes[0])}m '
807     if minutes[1] > 0 and include_seconds:
808         descr = descr + f'{int(minutes[1])}s'
809     return descr.strip()
810
811
812 def describe_timedelta_briefly(delta: datetime.timedelta) -> str:
813     """
814     Describe a duration represented by a timedelta object.
815
816     >>> d = datetime.timedelta(1, 600)
817     >>> describe_timedelta_briefly(d)
818     '1d 10m'
819
820     """
821     return describe_duration_briefly(int(delta.total_seconds()))  # Note: drops milliseconds
822
823
824 if __name__ == '__main__':
825     import doctest
826
827     doctest.testmod()