1import datetime
2
3from plain.utils.html import avoid_wrapping
4from plain.utils.text import pluralize_lazy
5from plain.utils.timezone import is_aware
6
7
8def timesince(
9 d: datetime.datetime,
10 *,
11 now: datetime.datetime | None = None,
12 reversed: bool = False,
13 format: str | dict[str, str] = "verbose",
14 depth: int = 2,
15) -> str:
16 """
17 Take two datetime objects and return the time between d and now as a nicely
18 formatted string, e.g., "10 minutes" or "10m" (depending on the format).
19
20 `format` can be:
21 - "verbose": e.g., "1 year, 2 months"
22 - "short": e.g., "1y 2m"
23 - A custom dictionary defining time unit formats.
24
25 Units used are years, months, weeks, days, hours, and minutes.
26 Seconds and microseconds are ignored.
27
28 The algorithm takes into account the varying duration of years and months.
29 For example, there is exactly "1 year, 1 month" between 2013/02/10 and
30 2014/03/10, but also between 2007/08/10 and 2008/09/10 despite the delta
31 being 393 days in the former case and 397 in the latter.
32
33 Up to `depth` adjacent units will be displayed. For example,
34 "2 weeks, 3 days" and "1 year, 3 months" are possible outputs, but
35 "2 weeks, 3 hours" and "1 year, 5 days" are not.
36
37 Arguments:
38 d: A datetime object representing the starting time.
39 now: A datetime object representing the current time. Defaults to the
40 current time if not provided.
41 reversed: If True, calculates time until `d` rather than since `d`.
42 format: The output format, either "verbose", "short", or a custom
43 dictionary of time unit formats.
44 depth: An integer specifying the number of adjacent time units to display.
45
46 Returns:
47 A string representing the time difference, formatted according to the
48 specified format.
49
50 Raises:
51 ValueError: If depth is less than 1 or if format is invalid.
52 """
53 TIME_CHUNKS = [
54 60 * 60 * 24 * 7, # week
55 60 * 60 * 24, # day
56 60 * 60, # hour
57 60, # minute
58 ]
59 MONTHS_DAYS = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
60 TIME_STRINGS_KEYS = ["year", "month", "week", "day", "hour", "minute"]
61
62 VERBOSE_TIME_STRINGS = {
63 "year": pluralize_lazy("%(num)d year", "%(num)d years", "num"),
64 "month": pluralize_lazy("%(num)d month", "%(num)d months", "num"),
65 "week": pluralize_lazy("%(num)d week", "%(num)d weeks", "num"),
66 "day": pluralize_lazy("%(num)d day", "%(num)d days", "num"),
67 "hour": pluralize_lazy("%(num)d hour", "%(num)d hours", "num"),
68 "minute": pluralize_lazy("%(num)d minute", "%(num)d minutes", "num"),
69 }
70 SHORT_TIME_STRINGS = {
71 "year": "%(num)dy",
72 "month": "%(num)dmo",
73 "week": "%(num)dw",
74 "day": "%(num)dd",
75 "hour": "%(num)dh",
76 "minute": "%(num)dm",
77 }
78
79 # Determine time_strings based on format
80 if format == "verbose":
81 time_strings = VERBOSE_TIME_STRINGS
82 elif format == "short":
83 time_strings = SHORT_TIME_STRINGS
84 elif isinstance(format, dict):
85 time_strings = format
86 else:
87 raise ValueError(
88 "format must be 'verbose', 'short', or a custom dictionary of formats."
89 )
90
91 if depth <= 0:
92 raise ValueError("depth must be greater than 0.")
93
94 # Convert datetime.date to datetime.datetime for comparison.
95 if not isinstance(d, datetime.datetime):
96 d = datetime.datetime(d.year, d.month, d.day)
97 if now and not isinstance(now, datetime.datetime):
98 now = datetime.datetime(now.year, now.month, now.day)
99
100 now = now or datetime.datetime.now(datetime.UTC if is_aware(d) else None)
101
102 if reversed:
103 d, now = now, d
104 delta = now - d
105
106 # Ignore microseconds.
107 since = delta.days * 24 * 60 * 60 + delta.seconds
108 if since <= 0:
109 # d is in the future compared to now, stop processing.
110 return avoid_wrapping(time_strings["minute"] % {"num": 0})
111
112 # Get years and months.
113 total_months = (now.year - d.year) * 12 + (now.month - d.month)
114 if d.day > now.day or (d.day == now.day and d.time() > now.time()):
115 total_months -= 1
116 years, months = divmod(total_months, 12)
117
118 # Calculate the remaining time.
119 if years or months:
120 pivot_year = d.year + years
121 pivot_month = d.month + months
122 if pivot_month > 12:
123 pivot_month -= 12
124 pivot_year += 1
125 pivot = datetime.datetime(
126 pivot_year,
127 pivot_month,
128 min(MONTHS_DAYS[pivot_month - 1], d.day),
129 d.hour,
130 d.minute,
131 d.second,
132 tzinfo=d.tzinfo,
133 )
134 else:
135 pivot = d
136 remaining_time = (now - pivot).total_seconds()
137 partials = [years, months]
138 for chunk in TIME_CHUNKS:
139 count = int(remaining_time // chunk)
140 partials.append(count)
141 remaining_time -= chunk * count
142
143 # Find the first non-zero part (if any) and then build the result, until
144 # depth.
145 i = 0
146 for i, value in enumerate(partials):
147 if value != 0:
148 break
149 else:
150 return avoid_wrapping(time_strings["minute"] % {"num": 0})
151
152 result = []
153 current_depth = 0
154 while i < len(TIME_STRINGS_KEYS) and current_depth < depth:
155 value = partials[i]
156 if value == 0:
157 break
158 name = TIME_STRINGS_KEYS[i]
159 result.append(avoid_wrapping(time_strings[name] % {"num": value}))
160 current_depth += 1
161 i += 1
162
163 return ", ".join(result)
164
165
166def timeuntil(
167 d: datetime.datetime,
168 now: datetime.datetime | None = None,
169 format: str | dict[str, str] = "verbose",
170 depth: int = 2,
171) -> str:
172 """
173 Like timesince, but return a string measuring the time until the given time.
174 """
175 return timesince(d, now=now, reversed=True, format=format, depth=depth)