1from __future__ import annotations
2
3import operator
4
5from datetime import date
6from datetime import datetime
7from datetime import timedelta
8from typing import TYPE_CHECKING
9from typing import Iterator
10from typing import Union
11from typing import cast
12from typing import overload
13
14import pendulum
15
16from pendulum.constants import MONTHS_PER_YEAR
17from pendulum.duration import Duration
18from pendulum.helpers import precise_diff
19
20
21if TYPE_CHECKING:
22 from typing_extensions import Self
23 from typing_extensions import SupportsIndex
24
25 from pendulum.helpers import PreciseDiff
26 from pendulum.locales.locale import Locale
27
28
29class Interval(Duration):
30 """
31 An interval of time between two datetimes.
32 """
33
34 @overload
35 def __new__(
36 cls,
37 start: pendulum.DateTime | datetime,
38 end: pendulum.DateTime | datetime,
39 absolute: bool = False,
40 ) -> Self:
41 ...
42
43 @overload
44 def __new__(
45 cls,
46 start: pendulum.Date | date,
47 end: pendulum.Date | date,
48 absolute: bool = False,
49 ) -> Self:
50 ...
51
52 def __new__(
53 cls,
54 start: pendulum.DateTime | pendulum.Date | datetime | date,
55 end: pendulum.DateTime | pendulum.Date | datetime | date,
56 absolute: bool = False,
57 ) -> Self:
58 if (
59 isinstance(start, datetime)
60 and not isinstance(end, datetime)
61 or not isinstance(start, datetime)
62 and isinstance(end, datetime)
63 ):
64 raise ValueError(
65 "Both start and end of an Interval must have the same type"
66 )
67
68 if (
69 isinstance(start, datetime)
70 and isinstance(end, datetime)
71 and (
72 start.tzinfo is None
73 and end.tzinfo is not None
74 or start.tzinfo is not None
75 and end.tzinfo is None
76 )
77 ):
78 raise TypeError("can't compare offset-naive and offset-aware datetimes")
79
80 if absolute and start > end:
81 end, start = start, end
82
83 _start = start
84 _end = end
85 if isinstance(start, pendulum.DateTime):
86 _start = datetime(
87 start.year,
88 start.month,
89 start.day,
90 start.hour,
91 start.minute,
92 start.second,
93 start.microsecond,
94 tzinfo=start.tzinfo,
95 fold=start.fold,
96 )
97 elif isinstance(start, pendulum.Date):
98 _start = date(start.year, start.month, start.day)
99
100 if isinstance(end, pendulum.DateTime):
101 _end = datetime(
102 end.year,
103 end.month,
104 end.day,
105 end.hour,
106 end.minute,
107 end.second,
108 end.microsecond,
109 tzinfo=end.tzinfo,
110 fold=end.fold,
111 )
112 elif isinstance(end, pendulum.Date):
113 _end = date(end.year, end.month, end.day)
114
115 # Fixing issues with datetime.__sub__()
116 # not handling offsets if the tzinfo is the same
117 if (
118 isinstance(_start, datetime)
119 and isinstance(_end, datetime)
120 and _start.tzinfo is _end.tzinfo
121 ):
122 if _start.tzinfo is not None:
123 offset = cast(timedelta, cast(datetime, start).utcoffset())
124 _start = (_start - offset).replace(tzinfo=None)
125
126 if isinstance(end, datetime) and _end.tzinfo is not None:
127 offset = cast(timedelta, end.utcoffset())
128 _end = (_end - offset).replace(tzinfo=None)
129
130 delta: timedelta = _end - _start # type: ignore[operator]
131
132 return super().__new__(cls, seconds=delta.total_seconds())
133
134 def __init__(
135 self,
136 start: pendulum.DateTime | pendulum.Date | datetime | date,
137 end: pendulum.DateTime | pendulum.Date | datetime | date,
138 absolute: bool = False,
139 ) -> None:
140 super().__init__()
141
142 _start: pendulum.DateTime | pendulum.Date | datetime | date
143 if not isinstance(start, pendulum.Date):
144 if isinstance(start, datetime):
145 start = pendulum.instance(start)
146 else:
147 start = pendulum.date(start.year, start.month, start.day)
148
149 _start = start
150 else:
151 if isinstance(start, pendulum.DateTime):
152 _start = datetime(
153 start.year,
154 start.month,
155 start.day,
156 start.hour,
157 start.minute,
158 start.second,
159 start.microsecond,
160 tzinfo=start.tzinfo,
161 )
162 else:
163 _start = date(start.year, start.month, start.day)
164
165 _end: pendulum.DateTime | pendulum.Date | datetime | date
166 if not isinstance(end, pendulum.Date):
167 if isinstance(end, datetime):
168 end = pendulum.instance(end)
169 else:
170 end = pendulum.date(end.year, end.month, end.day)
171
172 _end = end
173 else:
174 if isinstance(end, pendulum.DateTime):
175 _end = datetime(
176 end.year,
177 end.month,
178 end.day,
179 end.hour,
180 end.minute,
181 end.second,
182 end.microsecond,
183 tzinfo=end.tzinfo,
184 )
185 else:
186 _end = date(end.year, end.month, end.day)
187
188 self._invert = False
189 if start > end:
190 self._invert = True
191
192 if absolute:
193 end, start = start, end
194 _end, _start = _start, _end
195
196 self._absolute = absolute
197 self._start: pendulum.DateTime | pendulum.Date = start
198 self._end: pendulum.DateTime | pendulum.Date = end
199 self._delta: PreciseDiff = precise_diff(_start, _end)
200
201 @property
202 def years(self) -> int:
203 return self._delta.years
204
205 @property
206 def months(self) -> int:
207 return self._delta.months
208
209 @property
210 def weeks(self) -> int:
211 return abs(self._delta.days) // 7 * self._sign(self._delta.days)
212
213 @property
214 def days(self) -> int:
215 return self._days
216
217 @property
218 def remaining_days(self) -> int:
219 return abs(self._delta.days) % 7 * self._sign(self._days)
220
221 @property
222 def hours(self) -> int:
223 return self._delta.hours
224
225 @property
226 def minutes(self) -> int:
227 return self._delta.minutes
228
229 @property
230 def start(self) -> pendulum.DateTime | pendulum.Date | datetime | date:
231 return self._start
232
233 @property
234 def end(self) -> pendulum.DateTime | pendulum.Date | datetime | date:
235 return self._end
236
237 def in_years(self) -> int:
238 """
239 Gives the duration of the Interval in full years.
240 """
241 return self.years
242
243 def in_months(self) -> int:
244 """
245 Gives the duration of the Interval in full months.
246 """
247 return self.years * MONTHS_PER_YEAR + self.months
248
249 def in_weeks(self) -> int:
250 days = self.in_days()
251 sign = 1
252
253 if days < 0:
254 sign = -1
255
256 return sign * (abs(days) // 7)
257
258 def in_days(self) -> int:
259 return self._delta.total_days
260
261 def in_words(self, locale: str | None = None, separator: str = " ") -> str:
262 """
263 Get the current interval in words in the current locale.
264
265 Ex: 6 jours 23 heures 58 minutes
266
267 :param locale: The locale to use. Defaults to current locale.
268 :param separator: The separator to use between each unit
269 """
270 from pendulum.locales.locale import Locale
271
272 intervals = [
273 ("year", self.years),
274 ("month", self.months),
275 ("week", self.weeks),
276 ("day", self.remaining_days),
277 ("hour", self.hours),
278 ("minute", self.minutes),
279 ("second", self.remaining_seconds),
280 ]
281 loaded_locale: Locale = Locale.load(locale or pendulum.get_locale())
282 parts = []
283 for interval in intervals:
284 unit, interval_count = interval
285 if abs(interval_count) > 0:
286 translation = loaded_locale.translation(
287 f"units.{unit}.{loaded_locale.plural(abs(interval_count))}"
288 )
289 parts.append(translation.format(interval_count))
290
291 if not parts:
292 count: str | int = 0
293 if abs(self.microseconds) > 0:
294 unit = f"units.second.{loaded_locale.plural(1)}"
295 count = f"{abs(self.microseconds) / 1e6:.2f}"
296 else:
297 unit = f"units.microsecond.{loaded_locale.plural(0)}"
298
299 translation = loaded_locale.translation(unit)
300 parts.append(translation.format(count))
301
302 return separator.join(parts)
303
304 def range(
305 self, unit: str, amount: int = 1
306 ) -> Iterator[pendulum.DateTime | pendulum.Date]:
307 method = "add"
308 op = operator.le
309 if not self._absolute and self.invert:
310 method = "subtract"
311 op = operator.ge
312
313 start, end = self.start, self.end
314
315 i = amount
316 while op(start, end):
317 yield cast(Union[pendulum.DateTime, pendulum.Date], start)
318
319 start = getattr(self.start, method)(**{unit: i})
320
321 i += amount
322
323 def as_duration(self) -> Duration:
324 """
325 Return the Interval as a Duration.
326 """
327 return Duration(seconds=self.total_seconds())
328
329 def __iter__(self) -> Iterator[pendulum.DateTime | pendulum.Date]:
330 return self.range("days")
331
332 def __contains__(
333 self, item: datetime | date | pendulum.DateTime | pendulum.Date
334 ) -> bool:
335 return self.start <= item <= self.end
336
337 def __add__(self, other: timedelta) -> Duration: # type: ignore[override]
338 return self.as_duration().__add__(other)
339
340 __radd__ = __add__ # type: ignore[assignment]
341
342 def __sub__(self, other: timedelta) -> Duration: # type: ignore[override]
343 return self.as_duration().__sub__(other)
344
345 def __neg__(self) -> Self:
346 return self.__class__(self.end, self.start, self._absolute)
347
348 def __mul__(self, other: int | float) -> Duration: # type: ignore[override]
349 return self.as_duration().__mul__(other)
350
351 __rmul__ = __mul__ # type: ignore[assignment]
352
353 @overload # type: ignore[override]
354 def __floordiv__(self, other: timedelta) -> int:
355 ...
356
357 @overload
358 def __floordiv__(self, other: int) -> Duration:
359 ...
360
361 def __floordiv__(self, other: int | timedelta) -> int | Duration:
362 return self.as_duration().__floordiv__(other)
363
364 __div__ = __floordiv__ # type: ignore[assignment]
365
366 @overload # type: ignore[override]
367 def __truediv__(self, other: timedelta) -> float:
368 ...
369
370 @overload
371 def __truediv__(self, other: float) -> Duration:
372 ...
373
374 def __truediv__(self, other: float | timedelta) -> Duration | float:
375 return self.as_duration().__truediv__(other)
376
377 def __mod__(self, other: timedelta) -> Duration: # type: ignore[override]
378 return self.as_duration().__mod__(other)
379
380 def __divmod__(self, other: timedelta) -> tuple[int, Duration]:
381 return self.as_duration().__divmod__(other)
382
383 def __abs__(self) -> Self:
384 return self.__class__(self.start, self.end, absolute=True)
385
386 def __repr__(self) -> str:
387 return f"<Interval [{self._start} -> {self._end}]>"
388
389 def __str__(self) -> str:
390 return self.__repr__()
391
392 def _cmp(self, other: timedelta) -> int:
393 # Only needed for PyPy
394 assert isinstance(other, timedelta)
395
396 if isinstance(other, Interval):
397 other = other.as_timedelta()
398
399 td = self.as_timedelta()
400
401 return 0 if td == other else 1 if td > other else -1
402
403 def _getstate(
404 self, protocol: SupportsIndex = 3
405 ) -> tuple[
406 pendulum.DateTime | pendulum.Date | datetime | date,
407 pendulum.DateTime | pendulum.Date | datetime | date,
408 bool,
409 ]:
410 start, end = self.start, self.end
411
412 if self._invert and self._absolute:
413 end, start = start, end
414
415 return start, end, self._absolute
416
417 def __reduce__(
418 self,
419 ) -> tuple[
420 type[Self],
421 tuple[
422 pendulum.DateTime | pendulum.Date | datetime | date,
423 pendulum.DateTime | pendulum.Date | datetime | date,
424 bool,
425 ],
426 ]:
427 return self.__reduce_ex__(2)
428
429 def __reduce_ex__(
430 self, protocol: SupportsIndex
431 ) -> tuple[
432 type[Self],
433 tuple[
434 pendulum.DateTime | pendulum.Date | datetime | date,
435 pendulum.DateTime | pendulum.Date | datetime | date,
436 bool,
437 ],
438 ]:
439 return self.__class__, self._getstate(protocol)
440
441 def __hash__(self) -> int:
442 return hash((self.start, self.end, self._absolute))
443
444 def __eq__(self, other: object) -> bool:
445 if isinstance(other, Interval):
446 return (self.start, self.end, self._absolute) == (
447 other.start,
448 other.end,
449 other._absolute,
450 )
451 else:
452 return self.as_duration() == other
453
454 def __ne__(self, other: object) -> bool:
455 return not self.__eq__(other)