Make subdirs type clean too.
[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.IntEnum):
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     Next month corner case -- it will try to make Feb 31, 2022 then count
308     backwards.
309     >>> base = string_to_datetime("2022/01/31 11:24:51AM-0700")[0]
310     >>> n_timeunits_from_base(1, TimeUnit.MONTHS, base)
311     datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
312
313     Last month with the same corner case
314     >>> base = string_to_datetime("2022/03/31 11:24:51AM-0700")[0]
315     >>> n_timeunits_from_base(-1, TimeUnit.MONTHS, base)
316     datetime.datetime(2022, 2, 28, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
317
318     """
319     assert TimeUnit.is_valid(unit)
320     if count == 0:
321         return base
322
323     # N days from base
324     if unit == TimeUnit.DAYS:
325         timedelta = datetime.timedelta(days=count)
326         return base + timedelta
327
328     # N hours from base
329     elif unit == TimeUnit.HOURS:
330         timedelta = datetime.timedelta(hours=count)
331         return base + timedelta
332
333     # N minutes from base
334     elif unit == TimeUnit.MINUTES:
335         timedelta = datetime.timedelta(minutes=count)
336         return base + timedelta
337
338     # N seconds from base
339     elif unit == TimeUnit.SECONDS:
340         timedelta = datetime.timedelta(seconds=count)
341         return base + timedelta
342
343     # N workdays from base
344     elif unit == TimeUnit.WORKDAYS:
345         if count < 0:
346             count = abs(count)
347             timedelta = datetime.timedelta(days=-1)
348         else:
349             timedelta = datetime.timedelta(days=1)
350         skips = holidays.US(years=base.year).keys()
351         while count > 0:
352             old_year = base.year
353             base += timedelta
354             if base.year != old_year:
355                 skips = holidays.US(years=base.year).keys()
356             if (
357                 base.weekday() < 5
358                 and datetime.date(base.year, base.month, base.day) not in skips
359             ):
360                 count -= 1
361         return base
362
363     # N weeks from base
364     elif unit == TimeUnit.WEEKS:
365         timedelta = datetime.timedelta(weeks=count)
366         base = base + timedelta
367         return base
368
369     # N months from base
370     elif unit == TimeUnit.MONTHS:
371         month_term = count % 12
372         year_term = count // 12
373         new_month = base.month + month_term
374         if new_month > 12:
375             new_month %= 12
376             year_term += 1
377         new_year = base.year + year_term
378         day = base.day
379         while True:
380             try:
381                 ret = datetime.datetime(
382                     new_year,
383                     new_month,
384                     day,
385                     base.hour,
386                     base.minute,
387                     base.second,
388                     base.microsecond,
389                     base.tzinfo,
390                 )
391                 break
392             except ValueError:
393                 day -= 1
394         return ret
395
396     # N years from base
397     elif unit == TimeUnit.YEARS:
398         new_year = base.year + count
399         return datetime.datetime(
400             new_year,
401             base.month,
402             base.day,
403             base.hour,
404             base.minute,
405             base.second,
406             base.microsecond,
407             base.tzinfo,
408         )
409
410     if unit not in set(
411         [
412             TimeUnit.MONDAYS,
413             TimeUnit.TUESDAYS,
414             TimeUnit.WEDNESDAYS,
415             TimeUnit.THURSDAYS,
416             TimeUnit.FRIDAYS,
417             TimeUnit.SATURDAYS,
418             TimeUnit.SUNDAYS,
419         ]
420     ):
421         raise ValueError(unit)
422
423     # N weekdays from base (e.g. 4 wednesdays from today)
424     direction = 1 if count > 0 else -1
425     count = abs(count)
426     timedelta = datetime.timedelta(days=direction)
427     start = base
428     while True:
429         dow = base.weekday()
430         if dow == unit.value and start != base:
431             count -= 1
432             if count == 0:
433                 return base
434         base = base + timedelta
435
436
437 def get_format_string(
438     *,
439     date_time_separator=" ",
440     include_timezone=True,
441     include_dayname=False,
442     use_month_abbrevs=False,
443     include_seconds=True,
444     include_fractional=False,
445     twelve_hour=True,
446 ) -> str:
447     """
448     Helper to return a format string without looking up the documentation
449     for strftime.
450
451     >>> get_format_string()
452     '%Y/%m/%d %I:%M:%S%p%z'
453
454     >>> get_format_string(date_time_separator='@')
455     '%Y/%m/%d@%I:%M:%S%p%z'
456
457     >>> get_format_string(include_dayname=True)
458     '%a/%Y/%m/%d %I:%M:%S%p%z'
459
460     >>> get_format_string(include_dayname=True, twelve_hour=False)
461     '%a/%Y/%m/%d %H:%M:%S%z'
462
463     """
464     fstring = ""
465     if include_dayname:
466         fstring += "%a/"
467
468     if use_month_abbrevs:
469         fstring = f"{fstring}%Y/%b/%d{date_time_separator}"
470     else:
471         fstring = f"{fstring}%Y/%m/%d{date_time_separator}"
472     if twelve_hour:
473         fstring += "%I:%M"
474         if include_seconds:
475             fstring += ":%S"
476         fstring += "%p"
477     else:
478         fstring += "%H:%M"
479         if include_seconds:
480             fstring += ":%S"
481     if include_fractional:
482         fstring += ".%f"
483     if include_timezone:
484         fstring += "%z"
485     return fstring
486
487
488 def datetime_to_string(
489     dt: datetime.datetime,
490     *,
491     date_time_separator=" ",
492     include_timezone=True,
493     include_dayname=False,
494     use_month_abbrevs=False,
495     include_seconds=True,
496     include_fractional=False,
497     twelve_hour=True,
498 ) -> str:
499     """
500     A nice way to convert a datetime into a string; arguably better than
501     just printing it and relying on it __repr__().
502
503     >>> d = string_to_datetime(
504     ...                        "2021/09/10 11:24:51AM-0700",
505     ...                       )[0]
506     >>> d
507     datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
508     >>> datetime_to_string(d)
509     '2021/09/10 11:24:51AM-0700'
510     >>> datetime_to_string(d, include_dayname=True, include_seconds=False)
511     'Fri/2021/09/10 11:24AM-0700'
512
513     """
514     fstring = get_format_string(
515         date_time_separator=date_time_separator,
516         include_timezone=include_timezone,
517         include_dayname=include_dayname,
518         include_seconds=include_seconds,
519         include_fractional=include_fractional,
520         twelve_hour=twelve_hour,
521     )
522     return dt.strftime(fstring).strip()
523
524
525 def string_to_datetime(
526     txt: str,
527     *,
528     date_time_separator=" ",
529     include_timezone=True,
530     include_dayname=False,
531     use_month_abbrevs=False,
532     include_seconds=True,
533     include_fractional=False,
534     twelve_hour=True,
535 ) -> Tuple[datetime.datetime, str]:
536     """A nice way to convert a string into a datetime.  Returns both the
537     datetime and the format string used to parse it.  Also consider
538     dateparse.dateparse_utils for a full parser alternative.
539
540     >>> d = string_to_datetime(
541     ...                        "2021/09/10 11:24:51AM-0700",
542     ...                       )
543     >>> d
544     (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')
545
546     """
547     fstring = get_format_string(
548         date_time_separator=date_time_separator,
549         include_timezone=include_timezone,
550         include_dayname=include_dayname,
551         include_seconds=include_seconds,
552         include_fractional=include_fractional,
553         twelve_hour=twelve_hour,
554     )
555     return (datetime.datetime.strptime(txt, fstring), fstring)
556
557
558 def timestamp() -> str:
559     """Return a timestamp for right now in Pacific timezone."""
560     ts = datetime.datetime.now(tz=pytz.timezone("US/Pacific"))
561     return datetime_to_string(ts, include_timezone=True)
562
563
564 def time_to_string(
565     dt: datetime.datetime,
566     *,
567     include_seconds=True,
568     include_fractional=False,
569     include_timezone=False,
570     twelve_hour=True,
571 ) -> str:
572     """A nice way to convert a datetime into a time (only) string.
573     This ignores the date part of the datetime.
574
575     >>> d = string_to_datetime(
576     ...                        "2021/09/10 11:24:51AM-0700",
577     ...                       )[0]
578     >>> d
579     datetime.datetime(2021, 9, 10, 11, 24, 51, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
580
581     >>> time_to_string(d)
582     '11:24:51AM'
583
584     >>> time_to_string(d, include_seconds=False)
585     '11:24AM'
586
587     >>> time_to_string(d, include_seconds=False, include_timezone=True)
588     '11:24AM-0700'
589
590     """
591     fstring = ""
592     if twelve_hour:
593         fstring += "%l:%M"
594         if include_seconds:
595             fstring += ":%S"
596         fstring += "%p"
597     else:
598         fstring += "%H:%M"
599         if include_seconds:
600             fstring += ":%S"
601     if include_fractional:
602         fstring += ".%f"
603     if include_timezone:
604         fstring += "%z"
605     return dt.strftime(fstring).strip()
606
607
608 def seconds_to_timedelta(seconds: int) -> datetime.timedelta:
609     """Convert a delta in seconds into a timedelta."""
610     return datetime.timedelta(seconds=seconds)
611
612
613 MinuteOfDay = NewType("MinuteOfDay", int)
614
615
616 def minute_number(hour: int, minute: int) -> MinuteOfDay:
617     """
618     Convert hour:minute into minute number from start of day.
619
620     >>> minute_number(0, 0)
621     0
622
623     >>> minute_number(9, 15)
624     555
625
626     >>> minute_number(23, 59)
627     1439
628
629     """
630     return MinuteOfDay(hour * 60 + minute)
631
632
633 def datetime_to_minute_number(dt: datetime.datetime) -> MinuteOfDay:
634     """
635     Convert a datetime into a minute number (of the day).  Note that
636     this ignores the date part of the datetime and only uses the time
637     part.
638
639     >>> d = string_to_datetime(
640     ...                        "2021/09/10 11:24:51AM-0700",
641     ...                       )[0]
642
643     >>> datetime_to_minute_number(d)
644     684
645
646     """
647     return minute_number(dt.hour, dt.minute)
648
649
650 def time_to_minute_number(t: datetime.time) -> MinuteOfDay:
651     """
652     Convert a datetime.time into a minute number.
653
654     >>> t = datetime.time(5, 15)
655     >>> time_to_minute_number(t)
656     315
657
658     """
659     return minute_number(t.hour, t.minute)
660
661
662 def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
663     """
664     Convert minute number from start of day into hour:minute am/pm
665     string.
666
667     >>> minute_number_to_time_string(315)
668     ' 5:15a'
669
670     >>> minute_number_to_time_string(684)
671     '11:24a'
672
673     """
674     hour = minute_num // 60
675     minute = minute_num % 60
676     ampm = "a"
677     if hour > 12:
678         hour -= 12
679         ampm = "p"
680     if hour == 12:
681         ampm = "p"
682     if hour == 0:
683         hour = 12
684     return f"{hour:2}:{minute:02}{ampm}"
685
686
687 def parse_duration(duration: str) -> int:
688     """
689     Parse a duration in string form into a delta seconds.
690
691     >>> parse_duration('15 days, 2 hours')
692     1303200
693
694     >>> parse_duration('15d 2h')
695     1303200
696
697     >>> parse_duration('100s')
698     100
699
700     >>> parse_duration('3min 2sec')
701     182
702
703     """
704     if duration.isdigit():
705         return int(duration)
706     seconds = 0
707     m = re.search(r'(\d+) *d[ays]*', duration)
708     if m is not None:
709         seconds += int(m.group(1)) * 60 * 60 * 24
710     m = re.search(r'(\d+) *h[ours]*', duration)
711     if m is not None:
712         seconds += int(m.group(1)) * 60 * 60
713     m = re.search(r'(\d+) *m[inutes]*', duration)
714     if m is not None:
715         seconds += int(m.group(1)) * 60
716     m = re.search(r'(\d+) *s[econds]*', duration)
717     if m is not None:
718         seconds += int(m.group(1))
719     return seconds
720
721
722 def describe_duration(seconds: int, *, include_seconds=False) -> str:
723     """
724     Describe a duration represented as a count of seconds nicely.
725
726     >>> describe_duration(182)
727     '3 minutes'
728
729     >>> describe_duration(182, include_seconds=True)
730     '3 minutes, and 2 seconds'
731
732     >>> describe_duration(100, include_seconds=True)
733     '1 minute, and 40 seconds'
734
735     describe_duration(1303200)
736     '15 days, 2 hours'
737
738     """
739     days = divmod(seconds, constants.SECONDS_PER_DAY)
740     hours = divmod(days[1], constants.SECONDS_PER_HOUR)
741     minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
742
743     descr = ""
744     if days[0] > 1:
745         descr = f"{int(days[0])} days, "
746     elif days[0] == 1:
747         descr = "1 day, "
748
749     if hours[0] > 1:
750         descr = descr + f"{int(hours[0])} hours, "
751     elif hours[0] == 1:
752         descr = descr + "1 hour, "
753
754     if not include_seconds and len(descr) > 0:
755         descr = descr + "and "
756
757     if minutes[0] == 1:
758         descr = descr + "1 minute"
759     else:
760         descr = descr + f"{int(minutes[0])} minutes"
761
762     if include_seconds:
763         descr = descr + ', '
764         if len(descr) > 0:
765             descr = descr + 'and '
766         s = minutes[1]
767         if s == 1:
768             descr = descr + '1 second'
769         else:
770             descr = descr + f'{s} seconds'
771     return descr
772
773
774 def describe_timedelta(delta: datetime.timedelta) -> str:
775     """
776     Describe a duration represented by a timedelta object.
777
778     >>> d = datetime.timedelta(1, 600)
779     >>> describe_timedelta(d)
780     '1 day, and 10 minutes'
781
782     """
783     return describe_duration(int(delta.total_seconds()))  # Note: drops milliseconds
784
785
786 def describe_duration_briefly(seconds: int, *, include_seconds=False) -> str:
787     """
788     Describe a duration briefly.
789
790     >>> describe_duration_briefly(182)
791     '3m'
792
793     >>> describe_duration_briefly(182, include_seconds=True)
794     '3m 2s'
795
796     >>> describe_duration_briefly(100, include_seconds=True)
797     '1m 40s'
798
799     describe_duration_briefly(1303200)
800     '15d 2h'
801
802     """
803     days = divmod(seconds, constants.SECONDS_PER_DAY)
804     hours = divmod(days[1], constants.SECONDS_PER_HOUR)
805     minutes = divmod(hours[1], constants.SECONDS_PER_MINUTE)
806
807     descr = ''
808     if days[0] > 0:
809         descr = f'{int(days[0])}d '
810     if hours[0] > 0:
811         descr = descr + f'{int(hours[0])}h '
812     if minutes[0] > 0 or (len(descr) == 0 and not include_seconds):
813         descr = descr + f'{int(minutes[0])}m '
814     if minutes[1] > 0 and include_seconds:
815         descr = descr + f'{int(minutes[1])}s'
816     return descr.strip()
817
818
819 def describe_timedelta_briefly(delta: datetime.timedelta) -> str:
820     """
821     Describe a duration represented by a timedelta object.
822
823     >>> d = datetime.timedelta(1, 600)
824     >>> describe_timedelta_briefly(d)
825     '1d 10m'
826
827     """
828     return describe_duration_briefly(
829         int(delta.total_seconds())
830     )  # Note: drops milliseconds
831
832
833 if __name__ == '__main__':
834     import doctest
835
836     doctest.testmod()