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