dycw-utilities 0.131.18__py3-none-any.whl → 0.131.20__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dycw_utilities-0.131.18.dist-info → dycw_utilities-0.131.20.dist-info}/METADATA +1 -1
- {dycw_utilities-0.131.18.dist-info → dycw_utilities-0.131.20.dist-info}/RECORD +16 -17
- utilities/__init__.py +1 -1
- utilities/datetime.py +4 -924
- utilities/fastapi.py +5 -7
- utilities/functions.py +78 -45
- utilities/hypothesis.py +20 -269
- utilities/platform.py +18 -0
- utilities/polars.py +6 -3
- utilities/pytest.py +83 -63
- utilities/types.py +3 -24
- utilities/typing.py +2 -15
- utilities/whenever2.py +149 -5
- utilities/zoneinfo.py +4 -0
- utilities/whenever.py +0 -230
- {dycw_utilities-0.131.18.dist-info → dycw_utilities-0.131.20.dist-info}/WHEEL +0 -0
- {dycw_utilities-0.131.18.dist-info → dycw_utilities-0.131.20.dist-info}/licenses/LICENSE +0 -0
utilities/datetime.py
CHANGED
@@ -1,324 +1,23 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
3
|
import datetime as dt
|
4
|
-
from collections.abc import Callable, Iterable, Sequence
|
5
4
|
from dataclasses import dataclass, replace
|
6
|
-
from re import search
|
7
|
-
from
|
8
|
-
from typing import (
|
9
|
-
TYPE_CHECKING,
|
10
|
-
Any,
|
11
|
-
Literal,
|
12
|
-
Self,
|
13
|
-
SupportsFloat,
|
14
|
-
assert_never,
|
15
|
-
overload,
|
16
|
-
override,
|
17
|
-
)
|
5
|
+
from re import search
|
6
|
+
from typing import Any, Self, assert_never, overload, override
|
18
7
|
|
19
8
|
from utilities.iterables import OneEmptyError, one
|
20
|
-
from utilities.
|
21
|
-
from utilities.
|
22
|
-
from utilities.sentinel import Sentinel, sentinel
|
23
|
-
from utilities.types import MaybeCallablePyDate, MaybeCallablePyDateTime, MaybeStr
|
24
|
-
from utilities.typing import is_instance_gen
|
25
|
-
from utilities.zoneinfo import UTC, ensure_time_zone
|
26
|
-
|
27
|
-
if TYPE_CHECKING:
|
28
|
-
from collections.abc import Iterator
|
29
|
-
|
30
|
-
from utilities.types import DateOrDateTime, Duration, MathRoundMode, TimeZoneLike
|
31
|
-
|
32
|
-
|
33
|
-
_DAYS_PER_YEAR = 365.25
|
34
|
-
_MICROSECONDS_PER_MILLISECOND = int(1e3)
|
35
|
-
_MICROSECONDS_PER_SECOND = int(1e6)
|
36
|
-
_SECONDS_PER_DAY = 24 * 60 * 60
|
37
|
-
_MICROSECONDS_PER_DAY = _MICROSECONDS_PER_SECOND * _SECONDS_PER_DAY
|
38
|
-
DATETIME_MIN_UTC = dt.datetime.min.replace(tzinfo=UTC)
|
39
|
-
DATETIME_MAX_UTC = dt.datetime.max.replace(tzinfo=UTC)
|
40
|
-
DATETIME_MIN_NAIVE = DATETIME_MIN_UTC.replace(tzinfo=None)
|
41
|
-
DATETIME_MAX_NAIVE = DATETIME_MAX_UTC.replace(tzinfo=None)
|
42
|
-
EPOCH_UTC = dt.datetime.fromtimestamp(0, tz=UTC)
|
43
|
-
EPOCH_DATE = EPOCH_UTC.date()
|
44
|
-
EPOCH_NAIVE = EPOCH_UTC.replace(tzinfo=None)
|
45
|
-
ZERO_TIME = dt.timedelta(0)
|
46
|
-
MICROSECOND = dt.timedelta(microseconds=1)
|
47
|
-
MILLISECOND = dt.timedelta(milliseconds=1)
|
48
|
-
SECOND = dt.timedelta(seconds=1)
|
49
|
-
MINUTE = dt.timedelta(minutes=1)
|
50
|
-
HOUR = dt.timedelta(hours=1)
|
51
|
-
DAY = dt.timedelta(days=1)
|
52
|
-
WEEK = dt.timedelta(weeks=1)
|
53
|
-
|
54
|
-
|
55
|
-
##
|
56
|
-
|
57
|
-
|
58
|
-
@overload
|
59
|
-
def add_duration(
|
60
|
-
date: dt.datetime, /, *, duration: Duration | None = ...
|
61
|
-
) -> dt.datetime: ...
|
62
|
-
@overload
|
63
|
-
def add_duration(date: dt.date, /, *, duration: Duration | None = ...) -> dt.date: ...
|
64
|
-
def add_duration(
|
65
|
-
date: DateOrDateTime, /, *, duration: Duration | None = None
|
66
|
-
) -> dt.date:
|
67
|
-
"""Add a duration to a date/datetime."""
|
68
|
-
if duration is None:
|
69
|
-
return date
|
70
|
-
if isinstance(date, dt.datetime):
|
71
|
-
return date + datetime_duration_to_timedelta(duration)
|
72
|
-
try:
|
73
|
-
timedelta = date_duration_to_timedelta(duration)
|
74
|
-
except DateDurationToTimeDeltaError:
|
75
|
-
raise AddDurationError(date=date, duration=duration) from None
|
76
|
-
return date + timedelta
|
77
|
-
|
78
|
-
|
79
|
-
@dataclass(kw_only=True, slots=True)
|
80
|
-
class AddDurationError(Exception):
|
81
|
-
date: dt.date
|
82
|
-
duration: Duration
|
83
|
-
|
84
|
-
@override
|
85
|
-
def __str__(self) -> str:
|
86
|
-
return f"Date {self.date} must be paired with an integral duration; got {self.duration}"
|
87
|
-
|
88
|
-
|
89
|
-
##
|
90
|
-
|
91
|
-
|
92
|
-
def add_weekdays(date: dt.date, /, *, n: int = 1) -> dt.date:
|
93
|
-
"""Add a number of a weekdays to a given date.
|
94
|
-
|
95
|
-
If the initial date is a weekend, then moving to the adjacent weekday
|
96
|
-
counts as 1 move.
|
97
|
-
"""
|
98
|
-
check_date_not_datetime(date)
|
99
|
-
if n == 0 and not is_weekday(date):
|
100
|
-
raise AddWeekdaysError(date)
|
101
|
-
if n >= 1:
|
102
|
-
for _ in range(n):
|
103
|
-
date = round_to_next_weekday(date + DAY)
|
104
|
-
elif n <= -1:
|
105
|
-
for _ in range(-n):
|
106
|
-
date = round_to_prev_weekday(date - DAY)
|
107
|
-
return date
|
108
|
-
|
109
|
-
|
110
|
-
class AddWeekdaysError(Exception): ...
|
111
|
-
|
112
|
-
|
113
|
-
##
|
114
|
-
|
115
|
-
|
116
|
-
def are_equal_datetimes(
|
117
|
-
x: dt.datetime, y: dt.datetime, /, *, strict: bool = False
|
118
|
-
) -> bool:
|
119
|
-
"""Check if x == y for datetimes."""
|
120
|
-
match x.tzinfo is None, y.tzinfo is None:
|
121
|
-
case True, True:
|
122
|
-
return x == y
|
123
|
-
case False, False if x == y:
|
124
|
-
return (x.tzinfo is y.tzinfo) or not strict
|
125
|
-
case False, False if x != y:
|
126
|
-
return False
|
127
|
-
case _:
|
128
|
-
raise AreEqualDateTimesError(x=x, y=y)
|
129
|
-
|
130
|
-
|
131
|
-
@dataclass(kw_only=True, slots=True)
|
132
|
-
class AreEqualDateTimesError(Exception):
|
133
|
-
x: dt.datetime
|
134
|
-
y: dt.datetime
|
135
|
-
|
136
|
-
@override
|
137
|
-
def __str__(self) -> str:
|
138
|
-
return f"Cannot compare local and zoned datetimes ({self.x}, {self.y})"
|
139
|
-
|
140
|
-
|
141
|
-
##
|
142
|
-
|
143
|
-
|
144
|
-
def check_date_not_datetime(date: dt.date, /) -> None:
|
145
|
-
"""Check if a date is not a datetime."""
|
146
|
-
if not is_instance_gen(date, dt.date):
|
147
|
-
raise CheckDateNotDateTimeError(date=date)
|
148
|
-
|
149
|
-
|
150
|
-
@dataclass(kw_only=True, slots=True)
|
151
|
-
class CheckDateNotDateTimeError(Exception):
|
152
|
-
date: dt.date
|
153
|
-
|
154
|
-
@override
|
155
|
-
def __str__(self) -> str:
|
156
|
-
return f"Date must not be a datetime; got {self.date}"
|
157
|
-
|
158
|
-
|
159
|
-
##
|
9
|
+
from utilities.types import MaybeStr
|
10
|
+
from utilities.zoneinfo import UTC
|
160
11
|
|
161
12
|
|
162
13
|
def date_to_month(date: dt.date, /) -> Month:
|
163
14
|
"""Collapse a date into a month."""
|
164
|
-
check_date_not_datetime(date)
|
165
15
|
return Month(year=date.year, month=date.month)
|
166
16
|
|
167
17
|
|
168
18
|
##
|
169
19
|
|
170
20
|
|
171
|
-
def date_duration_to_int(duration: Duration, /) -> int:
|
172
|
-
"""Ensure a date duration is a float."""
|
173
|
-
match duration:
|
174
|
-
case int():
|
175
|
-
return duration
|
176
|
-
case float():
|
177
|
-
try:
|
178
|
-
return safe_round(duration)
|
179
|
-
except SafeRoundError:
|
180
|
-
raise _DateDurationToIntFloatError(duration=duration) from None
|
181
|
-
case dt.timedelta():
|
182
|
-
if is_integral_timedelta(duration):
|
183
|
-
return duration.days
|
184
|
-
raise _DateDurationToIntTimeDeltaError(duration=duration) from None
|
185
|
-
case _ as never:
|
186
|
-
assert_never(never)
|
187
|
-
|
188
|
-
|
189
|
-
@dataclass(kw_only=True, slots=True)
|
190
|
-
class DateDurationToIntError(Exception):
|
191
|
-
duration: Duration
|
192
|
-
|
193
|
-
|
194
|
-
@dataclass(kw_only=True, slots=True)
|
195
|
-
class _DateDurationToIntFloatError(DateDurationToIntError):
|
196
|
-
@override
|
197
|
-
def __str__(self) -> str:
|
198
|
-
return f"Float duration must be integral; got {self.duration}"
|
199
|
-
|
200
|
-
|
201
|
-
@dataclass(kw_only=True, slots=True)
|
202
|
-
class _DateDurationToIntTimeDeltaError(DateDurationToIntError):
|
203
|
-
@override
|
204
|
-
def __str__(self) -> str:
|
205
|
-
return f"Timedelta duration must be integral; got {self.duration}"
|
206
|
-
|
207
|
-
|
208
|
-
def date_duration_to_timedelta(duration: Duration, /) -> dt.timedelta:
|
209
|
-
"""Ensure a date duration is a timedelta."""
|
210
|
-
match duration:
|
211
|
-
case int():
|
212
|
-
return dt.timedelta(days=duration)
|
213
|
-
case float():
|
214
|
-
try:
|
215
|
-
as_int = safe_round(duration)
|
216
|
-
except SafeRoundError:
|
217
|
-
raise _DateDurationToTimeDeltaFloatError(duration=duration) from None
|
218
|
-
return dt.timedelta(days=as_int)
|
219
|
-
case dt.timedelta():
|
220
|
-
if is_integral_timedelta(duration):
|
221
|
-
return duration
|
222
|
-
raise _DateDurationToTimeDeltaTimeDeltaError(duration=duration) from None
|
223
|
-
case _ as never:
|
224
|
-
assert_never(never)
|
225
|
-
|
226
|
-
|
227
|
-
@dataclass(kw_only=True, slots=True)
|
228
|
-
class DateDurationToTimeDeltaError(Exception):
|
229
|
-
duration: Duration
|
230
|
-
|
231
|
-
|
232
|
-
@dataclass(kw_only=True, slots=True)
|
233
|
-
class _DateDurationToTimeDeltaFloatError(DateDurationToTimeDeltaError):
|
234
|
-
@override
|
235
|
-
def __str__(self) -> str:
|
236
|
-
return f"Float duration must be integral; got {self.duration}"
|
237
|
-
|
238
|
-
|
239
|
-
@dataclass(kw_only=True, slots=True)
|
240
|
-
class _DateDurationToTimeDeltaTimeDeltaError(DateDurationToTimeDeltaError):
|
241
|
-
@override
|
242
|
-
def __str__(self) -> str:
|
243
|
-
return f"Timedelta duration must be integral; got {self.duration}"
|
244
|
-
|
245
|
-
|
246
|
-
##
|
247
|
-
|
248
|
-
|
249
|
-
def datetime_duration_to_float(duration: Duration, /) -> float:
|
250
|
-
"""Ensure a datetime duration is a float."""
|
251
|
-
match duration:
|
252
|
-
case int():
|
253
|
-
return float(duration)
|
254
|
-
case float():
|
255
|
-
return duration
|
256
|
-
case dt.timedelta():
|
257
|
-
return duration.total_seconds()
|
258
|
-
case _ as never:
|
259
|
-
assert_never(never)
|
260
|
-
|
261
|
-
|
262
|
-
def datetime_duration_to_microseconds(duration: Duration, /) -> int:
|
263
|
-
"""Compute the number of microseconds in a datetime duration."""
|
264
|
-
timedelta = datetime_duration_to_timedelta(duration)
|
265
|
-
return (
|
266
|
-
_MICROSECONDS_PER_DAY * timedelta.days
|
267
|
-
+ _MICROSECONDS_PER_SECOND * timedelta.seconds
|
268
|
-
+ timedelta.microseconds
|
269
|
-
)
|
270
|
-
|
271
|
-
|
272
|
-
@dataclass(kw_only=True, slots=True)
|
273
|
-
class TimedeltaToMillisecondsError(Exception):
|
274
|
-
duration: Duration
|
275
|
-
remainder: int
|
276
|
-
|
277
|
-
@override
|
278
|
-
def __str__(self) -> str:
|
279
|
-
return f"Unable to convert {self.duration} to milliseconds; got {self.remainder} microsecond(s)" # pragma: no cover
|
280
|
-
|
281
|
-
|
282
|
-
def datetime_duration_to_timedelta(duration: Duration, /) -> dt.timedelta:
|
283
|
-
"""Ensure a datetime duration is a timedelta."""
|
284
|
-
match duration:
|
285
|
-
case int() | float():
|
286
|
-
return dt.timedelta(seconds=duration)
|
287
|
-
case dt.timedelta():
|
288
|
-
return duration
|
289
|
-
case _ as never:
|
290
|
-
assert_never(never)
|
291
|
-
|
292
|
-
|
293
|
-
##
|
294
|
-
|
295
|
-
|
296
|
-
def datetime_utc(
|
297
|
-
year: int,
|
298
|
-
month: int,
|
299
|
-
day: int,
|
300
|
-
/,
|
301
|
-
hour: int = 0,
|
302
|
-
minute: int = 0,
|
303
|
-
second: int = 0,
|
304
|
-
microsecond: int = 0,
|
305
|
-
) -> dt.datetime:
|
306
|
-
"""Create a UTC-zoned datetime."""
|
307
|
-
return dt.datetime(
|
308
|
-
year,
|
309
|
-
month,
|
310
|
-
day,
|
311
|
-
hour=hour,
|
312
|
-
minute=minute,
|
313
|
-
second=second,
|
314
|
-
microsecond=microsecond,
|
315
|
-
tzinfo=UTC,
|
316
|
-
)
|
317
|
-
|
318
|
-
|
319
|
-
##
|
320
|
-
|
321
|
-
|
322
21
|
def ensure_month(month: MonthLike, /) -> Month:
|
323
22
|
"""Ensure the object is a month."""
|
324
23
|
if isinstance(month, Month):
|
@@ -341,361 +40,6 @@ class EnsureMonthError(Exception):
|
|
341
40
|
##
|
342
41
|
|
343
42
|
|
344
|
-
@overload
|
345
|
-
def get_date(*, date: MaybeCallablePyDate) -> dt.date: ...
|
346
|
-
@overload
|
347
|
-
def get_date(*, date: None) -> None: ...
|
348
|
-
@overload
|
349
|
-
def get_date(*, date: Sentinel) -> Sentinel: ...
|
350
|
-
@overload
|
351
|
-
def get_date(*, date: MaybeCallablePyDate | Sentinel) -> dt.date | Sentinel: ...
|
352
|
-
@overload
|
353
|
-
def get_date(
|
354
|
-
*, date: MaybeCallablePyDate | None | Sentinel = sentinel
|
355
|
-
) -> dt.date | None | Sentinel: ...
|
356
|
-
def get_date(
|
357
|
-
*, date: MaybeCallablePyDate | None | Sentinel = sentinel
|
358
|
-
) -> dt.date | None | Sentinel:
|
359
|
-
"""Get the date."""
|
360
|
-
match date:
|
361
|
-
case dt.date() | None | Sentinel():
|
362
|
-
return date
|
363
|
-
case Callable() as func:
|
364
|
-
return get_date(date=func())
|
365
|
-
case _ as never:
|
366
|
-
assert_never(never)
|
367
|
-
|
368
|
-
|
369
|
-
##
|
370
|
-
|
371
|
-
|
372
|
-
@overload
|
373
|
-
def get_datetime(*, datetime: MaybeCallablePyDateTime) -> dt.datetime: ...
|
374
|
-
@overload
|
375
|
-
def get_datetime(*, datetime: None) -> None: ...
|
376
|
-
@overload
|
377
|
-
def get_datetime(*, datetime: Sentinel) -> Sentinel: ...
|
378
|
-
def get_datetime(
|
379
|
-
*, datetime: MaybeCallablePyDateTime | None | Sentinel = sentinel
|
380
|
-
) -> dt.datetime | None | Sentinel:
|
381
|
-
"""Get the datetime."""
|
382
|
-
match datetime:
|
383
|
-
case dt.datetime() | None | Sentinel():
|
384
|
-
return datetime
|
385
|
-
case Callable() as func:
|
386
|
-
return get_datetime(datetime=func())
|
387
|
-
case _ as never:
|
388
|
-
assert_never(never)
|
389
|
-
|
390
|
-
|
391
|
-
##
|
392
|
-
|
393
|
-
|
394
|
-
def get_half_years(*, n: int = 1) -> dt.timedelta:
|
395
|
-
"""Get a number of half-years as a timedelta."""
|
396
|
-
days_per_half_year = _DAYS_PER_YEAR / 2
|
397
|
-
return dt.timedelta(days=round(n * days_per_half_year))
|
398
|
-
|
399
|
-
|
400
|
-
HALF_YEAR = get_half_years(n=1)
|
401
|
-
|
402
|
-
##
|
403
|
-
|
404
|
-
|
405
|
-
def get_min_max_date(
|
406
|
-
*,
|
407
|
-
min_date: dt.date | None = None,
|
408
|
-
max_date: dt.date | None = None,
|
409
|
-
min_age: Duration | None = None,
|
410
|
-
max_age: Duration | None = None,
|
411
|
-
time_zone: TimeZoneLike = UTC,
|
412
|
-
) -> tuple[dt.date | None, dt.date | None]:
|
413
|
-
"""Get the min/max date given a combination of dates/ages."""
|
414
|
-
today = get_today(time_zone=time_zone)
|
415
|
-
min_parts: Sequence[dt.date] = []
|
416
|
-
if min_date is not None:
|
417
|
-
if min_date > today:
|
418
|
-
raise _GetMinMaxDateMinDateError(min_date=min_date, today=today)
|
419
|
-
min_parts.append(min_date)
|
420
|
-
if max_age is not None:
|
421
|
-
if date_duration_to_timedelta(max_age) < ZERO_TIME:
|
422
|
-
raise _GetMinMaxDateMaxAgeError(max_age=max_age)
|
423
|
-
min_parts.append(sub_duration(today, duration=max_age))
|
424
|
-
min_date_use = max(min_parts, default=None)
|
425
|
-
max_parts: Sequence[dt.date] = []
|
426
|
-
if max_date is not None:
|
427
|
-
if max_date > today:
|
428
|
-
raise _GetMinMaxDateMaxDateError(max_date=max_date, today=today)
|
429
|
-
max_parts.append(max_date)
|
430
|
-
if min_age is not None:
|
431
|
-
if date_duration_to_timedelta(min_age) < ZERO_TIME:
|
432
|
-
raise _GetMinMaxDateMinAgeError(min_age=min_age)
|
433
|
-
max_parts.append(sub_duration(today, duration=min_age))
|
434
|
-
max_date_use = min(max_parts, default=None)
|
435
|
-
if (
|
436
|
-
(min_date_use is not None)
|
437
|
-
and (max_date_use is not None)
|
438
|
-
and (min_date_use > max_date_use)
|
439
|
-
):
|
440
|
-
raise _GetMinMaxDatePeriodError(min_date=min_date_use, max_date=max_date_use)
|
441
|
-
return min_date_use, max_date_use
|
442
|
-
|
443
|
-
|
444
|
-
@dataclass(kw_only=True, slots=True)
|
445
|
-
class GetMinMaxDateError(Exception): ...
|
446
|
-
|
447
|
-
|
448
|
-
@dataclass(kw_only=True, slots=True)
|
449
|
-
class _GetMinMaxDateMinDateError(GetMinMaxDateError):
|
450
|
-
min_date: dt.date
|
451
|
-
today: dt.date
|
452
|
-
|
453
|
-
@override
|
454
|
-
def __str__(self) -> str:
|
455
|
-
return f"Min date must be at most today; got {self.min_date} > {self.today}"
|
456
|
-
|
457
|
-
|
458
|
-
@dataclass(kw_only=True, slots=True)
|
459
|
-
class _GetMinMaxDateMinAgeError(GetMinMaxDateError):
|
460
|
-
min_age: Duration
|
461
|
-
|
462
|
-
@override
|
463
|
-
def __str__(self) -> str:
|
464
|
-
return f"Min age must be non-negative; got {self.min_age}"
|
465
|
-
|
466
|
-
|
467
|
-
@dataclass(kw_only=True, slots=True)
|
468
|
-
class _GetMinMaxDateMaxDateError(GetMinMaxDateError):
|
469
|
-
max_date: dt.date
|
470
|
-
today: dt.date
|
471
|
-
|
472
|
-
@override
|
473
|
-
def __str__(self) -> str:
|
474
|
-
return f"Max date must be at most today; got {self.max_date} > {self.today}"
|
475
|
-
|
476
|
-
|
477
|
-
@dataclass(kw_only=True, slots=True)
|
478
|
-
class _GetMinMaxDateMaxAgeError(GetMinMaxDateError):
|
479
|
-
max_age: Duration
|
480
|
-
|
481
|
-
@override
|
482
|
-
def __str__(self) -> str:
|
483
|
-
return f"Max age must be non-negative; got {self.max_age}"
|
484
|
-
|
485
|
-
|
486
|
-
@dataclass(kw_only=True, slots=True)
|
487
|
-
class _GetMinMaxDatePeriodError(GetMinMaxDateError):
|
488
|
-
min_date: dt.date
|
489
|
-
max_date: dt.date
|
490
|
-
|
491
|
-
@override
|
492
|
-
def __str__(self) -> str:
|
493
|
-
return (
|
494
|
-
f"Min date must be at most max date; got {self.min_date} > {self.max_date}"
|
495
|
-
)
|
496
|
-
|
497
|
-
|
498
|
-
##
|
499
|
-
|
500
|
-
|
501
|
-
def get_months(*, n: int = 1) -> dt.timedelta:
|
502
|
-
"""Get a number of months as a timedelta."""
|
503
|
-
days_per_month = _DAYS_PER_YEAR / 12
|
504
|
-
return dt.timedelta(days=round(n * days_per_month))
|
505
|
-
|
506
|
-
|
507
|
-
MONTH = get_months(n=1)
|
508
|
-
|
509
|
-
|
510
|
-
##
|
511
|
-
|
512
|
-
|
513
|
-
def get_now(*, time_zone: TimeZoneLike = UTC) -> dt.datetime:
|
514
|
-
"""Get the current, timezone-aware time."""
|
515
|
-
return dt.datetime.now(tz=ensure_time_zone(time_zone))
|
516
|
-
|
517
|
-
|
518
|
-
NOW_UTC = get_now(time_zone=UTC)
|
519
|
-
|
520
|
-
|
521
|
-
##
|
522
|
-
|
523
|
-
|
524
|
-
def get_quarters(*, n: int = 1) -> dt.timedelta:
|
525
|
-
"""Get a number of quarters as a timedelta."""
|
526
|
-
days_per_quarter = _DAYS_PER_YEAR / 4
|
527
|
-
return dt.timedelta(days=round(n * days_per_quarter))
|
528
|
-
|
529
|
-
|
530
|
-
QUARTER = get_quarters(n=1)
|
531
|
-
|
532
|
-
|
533
|
-
##
|
534
|
-
|
535
|
-
|
536
|
-
def get_today(*, time_zone: TimeZoneLike = UTC) -> dt.date:
|
537
|
-
"""Get the current, timezone-aware date."""
|
538
|
-
return get_now(time_zone=time_zone).date()
|
539
|
-
|
540
|
-
|
541
|
-
TODAY_UTC = get_today(time_zone=UTC)
|
542
|
-
|
543
|
-
|
544
|
-
##
|
545
|
-
|
546
|
-
|
547
|
-
def get_years(*, n: int = 1) -> dt.timedelta:
|
548
|
-
"""Get a number of years as a timedelta."""
|
549
|
-
return dt.timedelta(days=round(n * _DAYS_PER_YEAR))
|
550
|
-
|
551
|
-
|
552
|
-
YEAR = get_years(n=1)
|
553
|
-
|
554
|
-
|
555
|
-
##
|
556
|
-
|
557
|
-
|
558
|
-
def is_integral_timedelta(duration: Duration, /) -> bool:
|
559
|
-
"""Check if a duration is integral."""
|
560
|
-
timedelta = datetime_duration_to_timedelta(duration)
|
561
|
-
return (timedelta.seconds == 0) and (timedelta.microseconds == 0)
|
562
|
-
|
563
|
-
|
564
|
-
##
|
565
|
-
|
566
|
-
|
567
|
-
_FRIDAY = 5
|
568
|
-
|
569
|
-
|
570
|
-
def is_weekday(date: dt.date, /) -> bool:
|
571
|
-
"""Check if a date is a weekday."""
|
572
|
-
check_date_not_datetime(date)
|
573
|
-
return date.isoweekday() <= _FRIDAY
|
574
|
-
|
575
|
-
|
576
|
-
##
|
577
|
-
|
578
|
-
|
579
|
-
def is_zero_time(duration: Duration, /) -> bool:
|
580
|
-
"""Check if a timedelta is 0."""
|
581
|
-
return datetime_duration_to_timedelta(duration) == ZERO_TIME
|
582
|
-
|
583
|
-
|
584
|
-
##
|
585
|
-
|
586
|
-
|
587
|
-
def maybe_sub_pct_y(text: str, /) -> str:
|
588
|
-
"""Substitute the `%Y' token with '%4Y' if necessary."""
|
589
|
-
match SYSTEM:
|
590
|
-
case "windows": # skipif-not-windows
|
591
|
-
return text
|
592
|
-
case "mac": # skipif-not-macos
|
593
|
-
return text
|
594
|
-
case "linux": # skipif-not-linux
|
595
|
-
return sub("%Y", "%4Y", text)
|
596
|
-
case _ as never:
|
597
|
-
assert_never(never)
|
598
|
-
|
599
|
-
|
600
|
-
##
|
601
|
-
|
602
|
-
|
603
|
-
def mean_datetime(
|
604
|
-
datetimes: Iterable[dt.datetime],
|
605
|
-
/,
|
606
|
-
*,
|
607
|
-
weights: Iterable[SupportsFloat] | None = None,
|
608
|
-
mode: MathRoundMode = "standard",
|
609
|
-
rel_tol: float | None = None,
|
610
|
-
abs_tol: float | None = None,
|
611
|
-
) -> dt.datetime:
|
612
|
-
"""Compute the mean of a set of datetimes."""
|
613
|
-
datetimes = list(datetimes)
|
614
|
-
match len(datetimes):
|
615
|
-
case 0:
|
616
|
-
raise MeanDateTimeError from None
|
617
|
-
case 1:
|
618
|
-
return one(datetimes)
|
619
|
-
case _:
|
620
|
-
microseconds = list(map(microseconds_since_epoch, datetimes))
|
621
|
-
mean_float = fmean(microseconds, weights=weights)
|
622
|
-
mean_int = round_(mean_float, mode=mode, rel_tol=rel_tol, abs_tol=abs_tol)
|
623
|
-
return microseconds_since_epoch_to_datetime(
|
624
|
-
mean_int, time_zone=datetimes[0].tzinfo
|
625
|
-
)
|
626
|
-
|
627
|
-
|
628
|
-
@dataclass(kw_only=True, slots=True)
|
629
|
-
class MeanDateTimeError(Exception):
|
630
|
-
@override
|
631
|
-
def __str__(self) -> str:
|
632
|
-
return "Mean requires at least 1 datetime"
|
633
|
-
|
634
|
-
|
635
|
-
##
|
636
|
-
|
637
|
-
|
638
|
-
def mean_timedelta(
|
639
|
-
timedeltas: Iterable[dt.timedelta],
|
640
|
-
/,
|
641
|
-
*,
|
642
|
-
weights: Iterable[SupportsFloat] | None = None,
|
643
|
-
mode: MathRoundMode = "standard",
|
644
|
-
rel_tol: float | None = None,
|
645
|
-
abs_tol: float | None = None,
|
646
|
-
) -> dt.timedelta:
|
647
|
-
"""Compute the mean of a set of timedeltas."""
|
648
|
-
timedeltas = list(timedeltas)
|
649
|
-
match len(timedeltas):
|
650
|
-
case 0:
|
651
|
-
raise MeanTimeDeltaError from None
|
652
|
-
case 1:
|
653
|
-
return one(timedeltas)
|
654
|
-
case _:
|
655
|
-
microseconds = list(map(datetime_duration_to_microseconds, timedeltas))
|
656
|
-
mean_float = fmean(microseconds, weights=weights)
|
657
|
-
mean_int = round_(mean_float, mode=mode, rel_tol=rel_tol, abs_tol=abs_tol)
|
658
|
-
return microseconds_to_timedelta(mean_int)
|
659
|
-
|
660
|
-
|
661
|
-
@dataclass(kw_only=True, slots=True)
|
662
|
-
class MeanTimeDeltaError(Exception):
|
663
|
-
@override
|
664
|
-
def __str__(self) -> str:
|
665
|
-
return "Mean requires at least 1 timedelta"
|
666
|
-
|
667
|
-
|
668
|
-
##
|
669
|
-
|
670
|
-
|
671
|
-
def microseconds_since_epoch(datetime: dt.datetime, /) -> int:
|
672
|
-
"""Compute the number of microseconds since the epoch."""
|
673
|
-
return datetime_duration_to_microseconds(timedelta_since_epoch(datetime))
|
674
|
-
|
675
|
-
|
676
|
-
def microseconds_to_timedelta(microseconds: int, /) -> dt.timedelta:
|
677
|
-
"""Compute a timedelta given a number of microseconds."""
|
678
|
-
if microseconds == 0:
|
679
|
-
return ZERO_TIME
|
680
|
-
if microseconds >= 1:
|
681
|
-
days, remainder = divmod(microseconds, _MICROSECONDS_PER_DAY)
|
682
|
-
seconds, micros = divmod(remainder, _MICROSECONDS_PER_SECOND)
|
683
|
-
return dt.timedelta(days=days, seconds=seconds, microseconds=micros)
|
684
|
-
return -microseconds_to_timedelta(-microseconds)
|
685
|
-
|
686
|
-
|
687
|
-
def microseconds_since_epoch_to_datetime(
|
688
|
-
microseconds: int, /, *, time_zone: dt.tzinfo | None = None
|
689
|
-
) -> dt.datetime:
|
690
|
-
"""Convert a number of microseconds since the epoch to a datetime."""
|
691
|
-
epoch = EPOCH_NAIVE if time_zone is None else EPOCH_UTC
|
692
|
-
timedelta = microseconds_to_timedelta(microseconds)
|
693
|
-
return epoch + timedelta
|
694
|
-
|
695
|
-
|
696
|
-
##
|
697
|
-
|
698
|
-
|
699
43
|
@dataclass(order=True, unsafe_hash=True, slots=True)
|
700
44
|
class Month:
|
701
45
|
"""Represents a month in time."""
|
@@ -740,7 +84,6 @@ class Month:
|
|
740
84
|
|
741
85
|
@classmethod
|
742
86
|
def from_date(cls, date: dt.date, /) -> Self:
|
743
|
-
check_date_not_datetime(date)
|
744
87
|
return cls(year=date.year, month=date.month)
|
745
88
|
|
746
89
|
def to_date(self, /, *, day: int = 1) -> dt.date:
|
@@ -819,66 +162,6 @@ class _ParseTwoDigitYearInvalidStringError(Exception):
|
|
819
162
|
##
|
820
163
|
|
821
164
|
|
822
|
-
def round_datetime(
|
823
|
-
datetime: dt.datetime,
|
824
|
-
duration: Duration,
|
825
|
-
/,
|
826
|
-
*,
|
827
|
-
mode: MathRoundMode = "standard",
|
828
|
-
rel_tol: float | None = None,
|
829
|
-
abs_tol: float | None = None,
|
830
|
-
) -> dt.datetime:
|
831
|
-
"""Round a datetime to a timedelta."""
|
832
|
-
if datetime.tzinfo is None:
|
833
|
-
dividend = microseconds_since_epoch(datetime)
|
834
|
-
divisor = datetime_duration_to_microseconds(duration)
|
835
|
-
quotient, remainder = divmod(dividend, divisor)
|
836
|
-
rnd_remainder = round_(
|
837
|
-
remainder / divisor, mode=mode, rel_tol=rel_tol, abs_tol=abs_tol
|
838
|
-
)
|
839
|
-
rnd_quotient = quotient + rnd_remainder
|
840
|
-
microseconds = rnd_quotient * divisor
|
841
|
-
return microseconds_since_epoch_to_datetime(microseconds)
|
842
|
-
local = datetime.replace(tzinfo=None)
|
843
|
-
rounded = round_datetime(
|
844
|
-
local, duration, mode=mode, rel_tol=rel_tol, abs_tol=abs_tol
|
845
|
-
)
|
846
|
-
return rounded.replace(tzinfo=datetime.tzinfo)
|
847
|
-
|
848
|
-
|
849
|
-
##
|
850
|
-
|
851
|
-
|
852
|
-
def round_to_next_weekday(date: dt.date, /) -> dt.date:
|
853
|
-
"""Round a date to the next weekday."""
|
854
|
-
return _round_to_weekday(date, prev_or_next="next")
|
855
|
-
|
856
|
-
|
857
|
-
def round_to_prev_weekday(date: dt.date, /) -> dt.date:
|
858
|
-
"""Round a date to the previous weekday."""
|
859
|
-
return _round_to_weekday(date, prev_or_next="prev")
|
860
|
-
|
861
|
-
|
862
|
-
def _round_to_weekday(
|
863
|
-
date: dt.date, /, *, prev_or_next: Literal["prev", "next"]
|
864
|
-
) -> dt.date:
|
865
|
-
"""Round a date to the previous weekday."""
|
866
|
-
check_date_not_datetime(date)
|
867
|
-
match prev_or_next:
|
868
|
-
case "prev":
|
869
|
-
n = -1
|
870
|
-
case "next":
|
871
|
-
n = 1
|
872
|
-
case _ as never:
|
873
|
-
assert_never(never)
|
874
|
-
while not is_weekday(date):
|
875
|
-
date = add_weekdays(date, n=n)
|
876
|
-
return date
|
877
|
-
|
878
|
-
|
879
|
-
##
|
880
|
-
|
881
|
-
|
882
165
|
def serialize_month(month: Month, /) -> str:
|
883
166
|
"""Serialize a month."""
|
884
167
|
return f"{month.year:04}-{month.month:02}"
|
@@ -905,221 +188,18 @@ class ParseMonthError(Exception):
|
|
905
188
|
return f"Unable to parse month; got {self.month!r}"
|
906
189
|
|
907
190
|
|
908
|
-
##
|
909
|
-
|
910
|
-
|
911
|
-
@overload
|
912
|
-
def sub_duration(
|
913
|
-
date: dt.datetime, /, *, duration: Duration | None = ...
|
914
|
-
) -> dt.datetime: ...
|
915
|
-
@overload
|
916
|
-
def sub_duration(date: dt.date, /, *, duration: Duration | None = ...) -> dt.date: ...
|
917
|
-
def sub_duration(
|
918
|
-
date: DateOrDateTime, /, *, duration: Duration | None = None
|
919
|
-
) -> dt.date:
|
920
|
-
"""Subtract a duration from a date/datetime."""
|
921
|
-
if duration is None:
|
922
|
-
return date
|
923
|
-
try:
|
924
|
-
return add_duration(date, duration=-duration)
|
925
|
-
except AddDurationError:
|
926
|
-
raise SubDurationError(date=date, duration=duration) from None
|
927
|
-
|
928
|
-
|
929
|
-
@dataclass(kw_only=True, slots=True)
|
930
|
-
class SubDurationError(Exception):
|
931
|
-
date: dt.date
|
932
|
-
duration: Duration
|
933
|
-
|
934
|
-
@override
|
935
|
-
def __str__(self) -> str:
|
936
|
-
return f"Date {self.date} must be paired with an integral duration; got {self.duration}"
|
937
|
-
|
938
|
-
|
939
|
-
##
|
940
|
-
|
941
|
-
|
942
|
-
def timedelta_since_epoch(date_or_datetime: DateOrDateTime, /) -> dt.timedelta:
|
943
|
-
"""Compute the timedelta since the epoch."""
|
944
|
-
match date_or_datetime:
|
945
|
-
case dt.datetime() as datetime:
|
946
|
-
if datetime.tzinfo is None:
|
947
|
-
return datetime - EPOCH_NAIVE
|
948
|
-
return datetime.astimezone(UTC) - EPOCH_UTC
|
949
|
-
case dt.date() as date:
|
950
|
-
return date - EPOCH_DATE
|
951
|
-
case _ as never:
|
952
|
-
assert_never(never)
|
953
|
-
|
954
|
-
|
955
|
-
##
|
956
|
-
|
957
|
-
|
958
|
-
def yield_days(
|
959
|
-
*, start: dt.date | None = None, end: dt.date | None = None, days: int | None = None
|
960
|
-
) -> Iterator[dt.date]:
|
961
|
-
"""Yield the days in a range."""
|
962
|
-
match start, end, days:
|
963
|
-
case dt.date(), dt.date(), None:
|
964
|
-
check_date_not_datetime(start)
|
965
|
-
check_date_not_datetime(end)
|
966
|
-
date = start
|
967
|
-
while date <= end:
|
968
|
-
yield date
|
969
|
-
date += DAY
|
970
|
-
case dt.date(), None, int():
|
971
|
-
check_date_not_datetime(start)
|
972
|
-
date = start
|
973
|
-
for _ in range(days):
|
974
|
-
yield date
|
975
|
-
date += DAY
|
976
|
-
case None, dt.date(), int():
|
977
|
-
check_date_not_datetime(end)
|
978
|
-
date = end
|
979
|
-
for _ in range(days):
|
980
|
-
yield date
|
981
|
-
date -= DAY
|
982
|
-
case _:
|
983
|
-
raise YieldDaysError(start=start, end=end, days=days)
|
984
|
-
|
985
|
-
|
986
|
-
@dataclass(kw_only=True, slots=True)
|
987
|
-
class YieldDaysError(Exception):
|
988
|
-
start: dt.date | None
|
989
|
-
end: dt.date | None
|
990
|
-
days: int | None
|
991
|
-
|
992
|
-
@override
|
993
|
-
def __str__(self) -> str:
|
994
|
-
return (
|
995
|
-
f"Invalid arguments: start={self.start}, end={self.end}, days={self.days}"
|
996
|
-
)
|
997
|
-
|
998
|
-
|
999
|
-
##
|
1000
|
-
|
1001
|
-
|
1002
|
-
def yield_weekdays(
|
1003
|
-
*, start: dt.date | None = None, end: dt.date | None = None, days: int | None = None
|
1004
|
-
) -> Iterator[dt.date]:
|
1005
|
-
"""Yield the weekdays in a range."""
|
1006
|
-
match start, end, days:
|
1007
|
-
case dt.date(), dt.date(), None:
|
1008
|
-
check_date_not_datetime(start)
|
1009
|
-
check_date_not_datetime(end)
|
1010
|
-
date = round_to_next_weekday(start)
|
1011
|
-
while date <= end:
|
1012
|
-
yield date
|
1013
|
-
date = round_to_next_weekday(date + DAY)
|
1014
|
-
case dt.date(), None, int():
|
1015
|
-
check_date_not_datetime(start)
|
1016
|
-
date = round_to_next_weekday(start)
|
1017
|
-
for _ in range(days):
|
1018
|
-
yield date
|
1019
|
-
date = round_to_next_weekday(date + DAY)
|
1020
|
-
case None, dt.date(), int():
|
1021
|
-
check_date_not_datetime(end)
|
1022
|
-
date = round_to_prev_weekday(end)
|
1023
|
-
for _ in range(days):
|
1024
|
-
yield date
|
1025
|
-
date = round_to_prev_weekday(date - DAY)
|
1026
|
-
case _:
|
1027
|
-
raise YieldWeekdaysError(start=start, end=end, days=days)
|
1028
|
-
|
1029
|
-
|
1030
|
-
@dataclass(kw_only=True, slots=True)
|
1031
|
-
class YieldWeekdaysError(Exception):
|
1032
|
-
start: dt.date | None
|
1033
|
-
end: dt.date | None
|
1034
|
-
days: int | None
|
1035
|
-
|
1036
|
-
@override
|
1037
|
-
def __str__(self) -> str:
|
1038
|
-
return (
|
1039
|
-
f"Invalid arguments: start={self.start}, end={self.end}, days={self.days}"
|
1040
|
-
)
|
1041
|
-
|
1042
|
-
|
1043
191
|
__all__ = [
|
1044
|
-
"DATETIME_MAX_NAIVE",
|
1045
|
-
"DATETIME_MAX_UTC",
|
1046
|
-
"DATETIME_MIN_NAIVE",
|
1047
|
-
"DATETIME_MIN_UTC",
|
1048
|
-
"DAY",
|
1049
|
-
"EPOCH_DATE",
|
1050
|
-
"EPOCH_NAIVE",
|
1051
|
-
"EPOCH_UTC",
|
1052
|
-
"HALF_YEAR",
|
1053
|
-
"HOUR",
|
1054
192
|
"MAX_DATE_TWO_DIGIT_YEAR",
|
1055
193
|
"MAX_MONTH",
|
1056
|
-
"MILLISECOND",
|
1057
|
-
"MINUTE",
|
1058
194
|
"MIN_DATE_TWO_DIGIT_YEAR",
|
1059
195
|
"MIN_MONTH",
|
1060
|
-
"MONTH",
|
1061
|
-
"NOW_UTC",
|
1062
|
-
"QUARTER",
|
1063
|
-
"SECOND",
|
1064
|
-
"TODAY_UTC",
|
1065
|
-
"WEEK",
|
1066
|
-
"YEAR",
|
1067
|
-
"ZERO_TIME",
|
1068
|
-
"AddDurationError",
|
1069
|
-
"AddWeekdaysError",
|
1070
|
-
"AreEqualDateTimesError",
|
1071
|
-
"CheckDateNotDateTimeError",
|
1072
196
|
"DateOrMonth",
|
1073
197
|
"EnsureMonthError",
|
1074
|
-
"GetMinMaxDateError",
|
1075
|
-
"MeanDateTimeError",
|
1076
|
-
"MeanTimeDeltaError",
|
1077
198
|
"Month",
|
1078
199
|
"MonthError",
|
1079
200
|
"MonthLike",
|
1080
201
|
"ParseMonthError",
|
1081
|
-
"SubDurationError",
|
1082
|
-
"TimedeltaToMillisecondsError",
|
1083
|
-
"YieldDaysError",
|
1084
|
-
"YieldWeekdaysError",
|
1085
|
-
"add_duration",
|
1086
|
-
"add_weekdays",
|
1087
|
-
"are_equal_datetimes",
|
1088
|
-
"check_date_not_datetime",
|
1089
|
-
"date_duration_to_int",
|
1090
|
-
"date_duration_to_timedelta",
|
1091
202
|
"date_to_month",
|
1092
|
-
"datetime_duration_to_float",
|
1093
|
-
"datetime_duration_to_microseconds",
|
1094
|
-
"datetime_duration_to_timedelta",
|
1095
|
-
"datetime_utc",
|
1096
203
|
"ensure_month",
|
1097
|
-
"get_date",
|
1098
|
-
"get_datetime",
|
1099
|
-
"get_half_years",
|
1100
|
-
"get_min_max_date",
|
1101
|
-
"get_months",
|
1102
|
-
"get_now",
|
1103
|
-
"get_quarters",
|
1104
|
-
"get_today",
|
1105
|
-
"get_years",
|
1106
|
-
"is_integral_timedelta",
|
1107
|
-
"is_weekday",
|
1108
|
-
"is_zero_time",
|
1109
|
-
"maybe_sub_pct_y",
|
1110
|
-
"mean_datetime",
|
1111
|
-
"mean_timedelta",
|
1112
|
-
"microseconds_since_epoch",
|
1113
|
-
"microseconds_since_epoch_to_datetime",
|
1114
|
-
"microseconds_to_timedelta",
|
1115
|
-
"parse_month",
|
1116
204
|
"parse_two_digit_year",
|
1117
|
-
"round_datetime",
|
1118
|
-
"round_to_next_weekday",
|
1119
|
-
"round_to_prev_weekday",
|
1120
|
-
"serialize_month",
|
1121
|
-
"sub_duration",
|
1122
|
-
"timedelta_since_epoch",
|
1123
|
-
"yield_days",
|
1124
|
-
"yield_weekdays",
|
1125
205
|
]
|