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