dycw-utilities 0.131.18__py3-none-any.whl → 0.131.19__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.19.dist-info}/METADATA +1 -1
- {dycw_utilities-0.131.18.dist-info → dycw_utilities-0.131.19.dist-info}/RECORD +15 -16
- utilities/__init__.py +1 -1
- utilities/datetime.py +3 -906
- utilities/fastapi.py +5 -7
- utilities/functions.py +78 -45
- utilities/hypothesis.py +20 -269
- utilities/polars.py +6 -3
- utilities/pytest.py +83 -63
- utilities/types.py +3 -24
- utilities/typing.py +2 -15
- utilities/whenever2.py +147 -3
- utilities/zoneinfo.py +4 -0
- utilities/whenever.py +0 -230
- {dycw_utilities-0.131.18.dist-info → dycw_utilities-0.131.19.dist-info}/WHEEL +0 -0
- {dycw_utilities-0.131.18.dist-info → dycw_utilities-0.131.19.dist-info}/licenses/LICENSE +0 -0
utilities/datetime.py
CHANGED
@@ -1,324 +1,24 @@
|
|
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
5
|
from re import search, sub
|
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
|
-
)
|
6
|
+
from typing import Any, Self, assert_never, overload, override
|
18
7
|
|
19
8
|
from utilities.iterables import OneEmptyError, one
|
20
|
-
from utilities.math import SafeRoundError, round_, safe_round
|
21
9
|
from utilities.platform import SYSTEM
|
22
|
-
from utilities.
|
23
|
-
from utilities.
|
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
|
-
##
|
10
|
+
from utilities.types import MaybeStr
|
11
|
+
from utilities.zoneinfo import UTC
|
160
12
|
|
161
13
|
|
162
14
|
def date_to_month(date: dt.date, /) -> Month:
|
163
15
|
"""Collapse a date into a month."""
|
164
|
-
check_date_not_datetime(date)
|
165
16
|
return Month(year=date.year, month=date.month)
|
166
17
|
|
167
18
|
|
168
19
|
##
|
169
20
|
|
170
21
|
|
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
22
|
def ensure_month(month: MonthLike, /) -> Month:
|
323
23
|
"""Ensure the object is a month."""
|
324
24
|
if isinstance(month, Month):
|
@@ -341,249 +41,6 @@ class EnsureMonthError(Exception):
|
|
341
41
|
##
|
342
42
|
|
343
43
|
|
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
44
|
def maybe_sub_pct_y(text: str, /) -> str:
|
588
45
|
"""Substitute the `%Y' token with '%4Y' if necessary."""
|
589
46
|
match SYSTEM:
|
@@ -600,102 +57,6 @@ def maybe_sub_pct_y(text: str, /) -> str:
|
|
600
57
|
##
|
601
58
|
|
602
59
|
|
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
60
|
@dataclass(order=True, unsafe_hash=True, slots=True)
|
700
61
|
class Month:
|
701
62
|
"""Represents a month in time."""
|
@@ -740,7 +101,6 @@ class Month:
|
|
740
101
|
|
741
102
|
@classmethod
|
742
103
|
def from_date(cls, date: dt.date, /) -> Self:
|
743
|
-
check_date_not_datetime(date)
|
744
104
|
return cls(year=date.year, month=date.month)
|
745
105
|
|
746
106
|
def to_date(self, /, *, day: int = 1) -> dt.date:
|
@@ -819,66 +179,6 @@ class _ParseTwoDigitYearInvalidStringError(Exception):
|
|
819
179
|
##
|
820
180
|
|
821
181
|
|
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
182
|
def serialize_month(month: Month, /) -> str:
|
883
183
|
"""Serialize a month."""
|
884
184
|
return f"{month.year:04}-{month.month:02}"
|
@@ -905,221 +205,18 @@ class ParseMonthError(Exception):
|
|
905
205
|
return f"Unable to parse month; got {self.month!r}"
|
906
206
|
|
907
207
|
|
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
208
|
__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
209
|
"MAX_DATE_TWO_DIGIT_YEAR",
|
1055
210
|
"MAX_MONTH",
|
1056
|
-
"MILLISECOND",
|
1057
|
-
"MINUTE",
|
1058
211
|
"MIN_DATE_TWO_DIGIT_YEAR",
|
1059
212
|
"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
213
|
"DateOrMonth",
|
1073
214
|
"EnsureMonthError",
|
1074
|
-
"GetMinMaxDateError",
|
1075
|
-
"MeanDateTimeError",
|
1076
|
-
"MeanTimeDeltaError",
|
1077
215
|
"Month",
|
1078
216
|
"MonthError",
|
1079
217
|
"MonthLike",
|
1080
218
|
"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
219
|
"date_to_month",
|
1092
|
-
"datetime_duration_to_float",
|
1093
|
-
"datetime_duration_to_microseconds",
|
1094
|
-
"datetime_duration_to_timedelta",
|
1095
|
-
"datetime_utc",
|
1096
220
|
"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
221
|
"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
222
|
]
|