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