dycw-utilities 0.131.17__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.17.dist-info → dycw_utilities-0.131.19.dist-info}/METADATA +1 -1
- {dycw_utilities-0.131.17.dist-info → dycw_utilities-0.131.19.dist-info}/RECORD +19 -20
- utilities/__init__.py +1 -1
- utilities/datetime.py +3 -1183
- utilities/fastapi.py +5 -7
- utilities/functions.py +78 -45
- utilities/hypothesis.py +20 -269
- utilities/iterables.py +1 -30
- utilities/operator.py +21 -15
- utilities/orjson.py +67 -84
- utilities/parse.py +24 -53
- 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 -444
- {dycw_utilities-0.131.17.dist-info → dycw_utilities-0.131.19.dist-info}/WHEEL +0 -0
- {dycw_utilities-0.131.17.dist-info → dycw_utilities-0.131.19.dist-info}/licenses/LICENSE +0 -0
utilities/datetime.py
CHANGED
@@ -1,432 +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
|
-
TypeGuard,
|
15
|
-
assert_never,
|
16
|
-
overload,
|
17
|
-
override,
|
18
|
-
)
|
6
|
+
from typing import Any, Self, assert_never, overload, override
|
19
7
|
|
20
8
|
from utilities.iterables import OneEmptyError, one
|
21
|
-
from utilities.math import SafeRoundError, round_, safe_round
|
22
9
|
from utilities.platform import SYSTEM
|
23
|
-
from utilities.
|
24
|
-
from utilities.
|
25
|
-
from utilities.typing import is_instance_gen
|
26
|
-
from utilities.zoneinfo import UTC, ensure_time_zone, get_time_zone_name
|
27
|
-
|
28
|
-
if TYPE_CHECKING:
|
29
|
-
from collections.abc import Iterator
|
30
|
-
|
31
|
-
from utilities.types import DateOrDateTime, Duration, MathRoundMode, TimeZoneLike
|
32
|
-
|
33
|
-
|
34
|
-
_DAYS_PER_YEAR = 365.25
|
35
|
-
_MICROSECONDS_PER_MILLISECOND = int(1e3)
|
36
|
-
_MICROSECONDS_PER_SECOND = int(1e6)
|
37
|
-
_SECONDS_PER_DAY = 24 * 60 * 60
|
38
|
-
_MICROSECONDS_PER_DAY = _MICROSECONDS_PER_SECOND * _SECONDS_PER_DAY
|
39
|
-
DATETIME_MIN_UTC = dt.datetime.min.replace(tzinfo=UTC)
|
40
|
-
DATETIME_MAX_UTC = dt.datetime.max.replace(tzinfo=UTC)
|
41
|
-
DATETIME_MIN_NAIVE = DATETIME_MIN_UTC.replace(tzinfo=None)
|
42
|
-
DATETIME_MAX_NAIVE = DATETIME_MAX_UTC.replace(tzinfo=None)
|
43
|
-
EPOCH_UTC = dt.datetime.fromtimestamp(0, tz=UTC)
|
44
|
-
EPOCH_DATE = EPOCH_UTC.date()
|
45
|
-
EPOCH_NAIVE = EPOCH_UTC.replace(tzinfo=None)
|
46
|
-
ZERO_TIME = dt.timedelta(0)
|
47
|
-
MICROSECOND = dt.timedelta(microseconds=1)
|
48
|
-
MILLISECOND = dt.timedelta(milliseconds=1)
|
49
|
-
SECOND = dt.timedelta(seconds=1)
|
50
|
-
MINUTE = dt.timedelta(minutes=1)
|
51
|
-
HOUR = dt.timedelta(hours=1)
|
52
|
-
DAY = dt.timedelta(days=1)
|
53
|
-
WEEK = dt.timedelta(weeks=1)
|
54
|
-
|
55
|
-
|
56
|
-
##
|
57
|
-
|
58
|
-
|
59
|
-
@overload
|
60
|
-
def add_duration(
|
61
|
-
date: dt.datetime, /, *, duration: Duration | None = ...
|
62
|
-
) -> dt.datetime: ...
|
63
|
-
@overload
|
64
|
-
def add_duration(date: dt.date, /, *, duration: Duration | None = ...) -> dt.date: ...
|
65
|
-
def add_duration(
|
66
|
-
date: DateOrDateTime, /, *, duration: Duration | None = None
|
67
|
-
) -> dt.date:
|
68
|
-
"""Add a duration to a date/datetime."""
|
69
|
-
if duration is None:
|
70
|
-
return date
|
71
|
-
if isinstance(date, dt.datetime):
|
72
|
-
return date + datetime_duration_to_timedelta(duration)
|
73
|
-
try:
|
74
|
-
timedelta = date_duration_to_timedelta(duration)
|
75
|
-
except DateDurationToTimeDeltaError:
|
76
|
-
raise AddDurationError(date=date, duration=duration) from None
|
77
|
-
return date + timedelta
|
78
|
-
|
79
|
-
|
80
|
-
@dataclass(kw_only=True, slots=True)
|
81
|
-
class AddDurationError(Exception):
|
82
|
-
date: dt.date
|
83
|
-
duration: Duration
|
84
|
-
|
85
|
-
@override
|
86
|
-
def __str__(self) -> str:
|
87
|
-
return f"Date {self.date} must be paired with an integral duration; got {self.duration}"
|
88
|
-
|
89
|
-
|
90
|
-
##
|
91
|
-
|
92
|
-
|
93
|
-
def add_weekdays(date: dt.date, /, *, n: int = 1) -> dt.date:
|
94
|
-
"""Add a number of a weekdays to a given date.
|
95
|
-
|
96
|
-
If the initial date is a weekend, then moving to the adjacent weekday
|
97
|
-
counts as 1 move.
|
98
|
-
"""
|
99
|
-
check_date_not_datetime(date)
|
100
|
-
if n == 0 and not is_weekday(date):
|
101
|
-
raise AddWeekdaysError(date)
|
102
|
-
if n >= 1:
|
103
|
-
for _ in range(n):
|
104
|
-
date = round_to_next_weekday(date + DAY)
|
105
|
-
elif n <= -1:
|
106
|
-
for _ in range(-n):
|
107
|
-
date = round_to_prev_weekday(date - DAY)
|
108
|
-
return date
|
109
|
-
|
110
|
-
|
111
|
-
class AddWeekdaysError(Exception): ...
|
112
|
-
|
113
|
-
|
114
|
-
##
|
115
|
-
|
116
|
-
|
117
|
-
def are_equal_date_durations(x: Duration, y: Duration, /) -> bool:
|
118
|
-
"""Check if x == y for durations."""
|
119
|
-
x_timedelta = date_duration_to_timedelta(x)
|
120
|
-
y_timedelta = date_duration_to_timedelta(y)
|
121
|
-
return x_timedelta == y_timedelta
|
122
|
-
|
123
|
-
|
124
|
-
##
|
125
|
-
|
126
|
-
|
127
|
-
def are_equal_dates_or_datetimes(
|
128
|
-
x: DateOrDateTime, y: DateOrDateTime, /, *, strict: bool = False
|
129
|
-
) -> bool:
|
130
|
-
"""Check if x == y for dates/datetimes."""
|
131
|
-
if is_instance_gen(x, dt.date) and is_instance_gen(y, dt.date):
|
132
|
-
return x == y
|
133
|
-
if is_instance_gen(x, dt.datetime) and is_instance_gen(y, dt.datetime):
|
134
|
-
return are_equal_datetimes(x, y, strict=strict)
|
135
|
-
raise AreEqualDatesOrDateTimesError(x=x, y=y)
|
136
|
-
|
137
|
-
|
138
|
-
@dataclass(kw_only=True, slots=True)
|
139
|
-
class AreEqualDatesOrDateTimesError(Exception):
|
140
|
-
x: DateOrDateTime
|
141
|
-
y: DateOrDateTime
|
142
|
-
|
143
|
-
@override
|
144
|
-
def __str__(self) -> str:
|
145
|
-
return f"Cannot compare date and datetime ({self.x}, {self.y})"
|
146
|
-
|
147
|
-
|
148
|
-
##
|
149
|
-
|
150
|
-
|
151
|
-
def are_equal_datetime_durations(x: Duration, y: Duration, /) -> bool:
|
152
|
-
"""Check if x == y for durations."""
|
153
|
-
x_timedelta = datetime_duration_to_timedelta(x)
|
154
|
-
y_timedelta = datetime_duration_to_timedelta(y)
|
155
|
-
return x_timedelta == y_timedelta
|
156
|
-
|
157
|
-
|
158
|
-
##
|
159
|
-
|
160
|
-
|
161
|
-
def are_equal_datetimes(
|
162
|
-
x: dt.datetime, y: dt.datetime, /, *, strict: bool = False
|
163
|
-
) -> bool:
|
164
|
-
"""Check if x == y for datetimes."""
|
165
|
-
match x.tzinfo is None, y.tzinfo is None:
|
166
|
-
case True, True:
|
167
|
-
return x == y
|
168
|
-
case False, False if x == y:
|
169
|
-
return (x.tzinfo is y.tzinfo) or not strict
|
170
|
-
case False, False if x != y:
|
171
|
-
return False
|
172
|
-
case _:
|
173
|
-
raise AreEqualDateTimesError(x=x, y=y)
|
174
|
-
|
175
|
-
|
176
|
-
@dataclass(kw_only=True, slots=True)
|
177
|
-
class AreEqualDateTimesError(Exception):
|
178
|
-
x: dt.datetime
|
179
|
-
y: dt.datetime
|
180
|
-
|
181
|
-
@override
|
182
|
-
def __str__(self) -> str:
|
183
|
-
return f"Cannot compare local and zoned datetimes ({self.x}, {self.y})"
|
184
|
-
|
185
|
-
|
186
|
-
##
|
187
|
-
|
188
|
-
|
189
|
-
def are_equal_months(x: DateOrMonth, y: DateOrMonth, /) -> bool:
|
190
|
-
"""Check if x == y as months."""
|
191
|
-
x_month = Month.from_date(x) if isinstance(x, dt.date) else x
|
192
|
-
y_month = Month.from_date(y) if isinstance(y, dt.date) else y
|
193
|
-
return x_month == y_month
|
194
|
-
|
195
|
-
|
196
|
-
##
|
197
|
-
|
198
|
-
|
199
|
-
def check_date_not_datetime(date: dt.date, /) -> None:
|
200
|
-
"""Check if a date is not a datetime."""
|
201
|
-
if not is_instance_gen(date, dt.date):
|
202
|
-
raise CheckDateNotDateTimeError(date=date)
|
203
|
-
|
204
|
-
|
205
|
-
@dataclass(kw_only=True, slots=True)
|
206
|
-
class CheckDateNotDateTimeError(Exception):
|
207
|
-
date: dt.date
|
208
|
-
|
209
|
-
@override
|
210
|
-
def __str__(self) -> str:
|
211
|
-
return f"Date must not be a datetime; got {self.date}"
|
212
|
-
|
213
|
-
|
214
|
-
##
|
215
|
-
|
216
|
-
|
217
|
-
def date_to_datetime(
|
218
|
-
date: dt.date, /, *, time: dt.time | None = None, time_zone: TimeZoneLike = UTC
|
219
|
-
) -> dt.datetime:
|
220
|
-
"""Expand a date into a datetime."""
|
221
|
-
check_date_not_datetime(date)
|
222
|
-
time_use = dt.time(0) if time is None else time
|
223
|
-
time_zone_use = ensure_time_zone(time_zone)
|
224
|
-
return dt.datetime.combine(date, time_use, tzinfo=time_zone_use)
|
225
|
-
|
226
|
-
|
227
|
-
##
|
10
|
+
from utilities.types import MaybeStr
|
11
|
+
from utilities.zoneinfo import UTC
|
228
12
|
|
229
13
|
|
230
14
|
def date_to_month(date: dt.date, /) -> Month:
|
231
15
|
"""Collapse a date into a month."""
|
232
|
-
check_date_not_datetime(date)
|
233
16
|
return Month(year=date.year, month=date.month)
|
234
17
|
|
235
18
|
|
236
19
|
##
|
237
20
|
|
238
21
|
|
239
|
-
def date_duration_to_int(duration: Duration, /) -> int:
|
240
|
-
"""Ensure a date duration is a float."""
|
241
|
-
match duration:
|
242
|
-
case int():
|
243
|
-
return duration
|
244
|
-
case float():
|
245
|
-
try:
|
246
|
-
return safe_round(duration)
|
247
|
-
except SafeRoundError:
|
248
|
-
raise _DateDurationToIntFloatError(duration=duration) from None
|
249
|
-
case dt.timedelta():
|
250
|
-
if is_integral_timedelta(duration):
|
251
|
-
return duration.days
|
252
|
-
raise _DateDurationToIntTimeDeltaError(duration=duration) from None
|
253
|
-
case _ as never:
|
254
|
-
assert_never(never)
|
255
|
-
|
256
|
-
|
257
|
-
@dataclass(kw_only=True, slots=True)
|
258
|
-
class DateDurationToIntError(Exception):
|
259
|
-
duration: Duration
|
260
|
-
|
261
|
-
|
262
|
-
@dataclass(kw_only=True, slots=True)
|
263
|
-
class _DateDurationToIntFloatError(DateDurationToIntError):
|
264
|
-
@override
|
265
|
-
def __str__(self) -> str:
|
266
|
-
return f"Float duration must be integral; got {self.duration}"
|
267
|
-
|
268
|
-
|
269
|
-
@dataclass(kw_only=True, slots=True)
|
270
|
-
class _DateDurationToIntTimeDeltaError(DateDurationToIntError):
|
271
|
-
@override
|
272
|
-
def __str__(self) -> str:
|
273
|
-
return f"Timedelta duration must be integral; got {self.duration}"
|
274
|
-
|
275
|
-
|
276
|
-
def date_duration_to_timedelta(duration: Duration, /) -> dt.timedelta:
|
277
|
-
"""Ensure a date duration is a timedelta."""
|
278
|
-
match duration:
|
279
|
-
case int():
|
280
|
-
return dt.timedelta(days=duration)
|
281
|
-
case float():
|
282
|
-
try:
|
283
|
-
as_int = safe_round(duration)
|
284
|
-
except SafeRoundError:
|
285
|
-
raise _DateDurationToTimeDeltaFloatError(duration=duration) from None
|
286
|
-
return dt.timedelta(days=as_int)
|
287
|
-
case dt.timedelta():
|
288
|
-
if is_integral_timedelta(duration):
|
289
|
-
return duration
|
290
|
-
raise _DateDurationToTimeDeltaTimeDeltaError(duration=duration) from None
|
291
|
-
case _ as never:
|
292
|
-
assert_never(never)
|
293
|
-
|
294
|
-
|
295
|
-
@dataclass(kw_only=True, slots=True)
|
296
|
-
class DateDurationToTimeDeltaError(Exception):
|
297
|
-
duration: Duration
|
298
|
-
|
299
|
-
|
300
|
-
@dataclass(kw_only=True, slots=True)
|
301
|
-
class _DateDurationToTimeDeltaFloatError(DateDurationToTimeDeltaError):
|
302
|
-
@override
|
303
|
-
def __str__(self) -> str:
|
304
|
-
return f"Float duration must be integral; got {self.duration}"
|
305
|
-
|
306
|
-
|
307
|
-
@dataclass(kw_only=True, slots=True)
|
308
|
-
class _DateDurationToTimeDeltaTimeDeltaError(DateDurationToTimeDeltaError):
|
309
|
-
@override
|
310
|
-
def __str__(self) -> str:
|
311
|
-
return f"Timedelta duration must be integral; got {self.duration}"
|
312
|
-
|
313
|
-
|
314
|
-
##
|
315
|
-
|
316
|
-
|
317
|
-
def datetime_duration_to_float(duration: Duration, /) -> float:
|
318
|
-
"""Ensure a datetime duration is a float."""
|
319
|
-
match duration:
|
320
|
-
case int():
|
321
|
-
return float(duration)
|
322
|
-
case float():
|
323
|
-
return duration
|
324
|
-
case dt.timedelta():
|
325
|
-
return duration.total_seconds()
|
326
|
-
case _ as never:
|
327
|
-
assert_never(never)
|
328
|
-
|
329
|
-
|
330
|
-
def datetime_duration_to_microseconds(duration: Duration, /) -> int:
|
331
|
-
"""Compute the number of microseconds in a datetime duration."""
|
332
|
-
timedelta = datetime_duration_to_timedelta(duration)
|
333
|
-
return (
|
334
|
-
_MICROSECONDS_PER_DAY * timedelta.days
|
335
|
-
+ _MICROSECONDS_PER_SECOND * timedelta.seconds
|
336
|
-
+ timedelta.microseconds
|
337
|
-
)
|
338
|
-
|
339
|
-
|
340
|
-
@overload
|
341
|
-
def datetime_duration_to_milliseconds(
|
342
|
-
duration: Duration, /, *, strict: Literal[True]
|
343
|
-
) -> int: ...
|
344
|
-
@overload
|
345
|
-
def datetime_duration_to_milliseconds(
|
346
|
-
duration: Duration, /, *, strict: bool = False
|
347
|
-
) -> float: ...
|
348
|
-
def datetime_duration_to_milliseconds(
|
349
|
-
duration: Duration, /, *, strict: bool = False
|
350
|
-
) -> int | float:
|
351
|
-
"""Compute the number of milliseconds in a datetime duration."""
|
352
|
-
timedelta = datetime_duration_to_timedelta(duration)
|
353
|
-
microseconds = datetime_duration_to_microseconds(timedelta)
|
354
|
-
milliseconds, remainder = divmod(microseconds, _MICROSECONDS_PER_MILLISECOND)
|
355
|
-
match remainder, strict:
|
356
|
-
case 0, _:
|
357
|
-
return milliseconds
|
358
|
-
case _, True:
|
359
|
-
raise TimedeltaToMillisecondsError(duration=duration, remainder=remainder)
|
360
|
-
case _, False:
|
361
|
-
return milliseconds + remainder / _MICROSECONDS_PER_MILLISECOND
|
362
|
-
case _ as never:
|
363
|
-
assert_never(never)
|
364
|
-
|
365
|
-
|
366
|
-
@dataclass(kw_only=True, slots=True)
|
367
|
-
class TimedeltaToMillisecondsError(Exception):
|
368
|
-
duration: Duration
|
369
|
-
remainder: int
|
370
|
-
|
371
|
-
@override
|
372
|
-
def __str__(self) -> str:
|
373
|
-
return f"Unable to convert {self.duration} to milliseconds; got {self.remainder} microsecond(s)"
|
374
|
-
|
375
|
-
|
376
|
-
def datetime_duration_to_timedelta(duration: Duration, /) -> dt.timedelta:
|
377
|
-
"""Ensure a datetime duration is a timedelta."""
|
378
|
-
match duration:
|
379
|
-
case int() | float():
|
380
|
-
return dt.timedelta(seconds=duration)
|
381
|
-
case dt.timedelta():
|
382
|
-
return duration
|
383
|
-
case _ as never:
|
384
|
-
assert_never(never)
|
385
|
-
|
386
|
-
|
387
|
-
##
|
388
|
-
|
389
|
-
|
390
|
-
def datetime_utc(
|
391
|
-
year: int,
|
392
|
-
month: int,
|
393
|
-
day: int,
|
394
|
-
/,
|
395
|
-
hour: int = 0,
|
396
|
-
minute: int = 0,
|
397
|
-
second: int = 0,
|
398
|
-
microsecond: int = 0,
|
399
|
-
) -> dt.datetime:
|
400
|
-
"""Create a UTC-zoned datetime."""
|
401
|
-
return dt.datetime(
|
402
|
-
year,
|
403
|
-
month,
|
404
|
-
day,
|
405
|
-
hour=hour,
|
406
|
-
minute=minute,
|
407
|
-
second=second,
|
408
|
-
microsecond=microsecond,
|
409
|
-
tzinfo=UTC,
|
410
|
-
)
|
411
|
-
|
412
|
-
|
413
|
-
##
|
414
|
-
|
415
|
-
|
416
|
-
def days_since_epoch(date: dt.date, /) -> int:
|
417
|
-
"""Compute the number of days since the epoch."""
|
418
|
-
check_date_not_datetime(date)
|
419
|
-
return timedelta_since_epoch(date).days
|
420
|
-
|
421
|
-
|
422
|
-
def days_since_epoch_to_date(days: int, /) -> dt.date:
|
423
|
-
"""Convert a number of days since the epoch to a date."""
|
424
|
-
return EPOCH_DATE + days * DAY
|
425
|
-
|
426
|
-
|
427
|
-
##
|
428
|
-
|
429
|
-
|
430
22
|
def ensure_month(month: MonthLike, /) -> Month:
|
431
23
|
"""Ensure the object is a month."""
|
432
24
|
if isinstance(month, Month):
|
@@ -449,284 +41,6 @@ class EnsureMonthError(Exception):
|
|
449
41
|
##
|
450
42
|
|
451
43
|
|
452
|
-
def format_datetime_local_and_utc(datetime: dt.datetime, /) -> str:
|
453
|
-
"""Format a plain datetime locally & in UTC."""
|
454
|
-
time_zone = ensure_time_zone(datetime)
|
455
|
-
if time_zone is UTC:
|
456
|
-
return datetime.strftime("%Y-%m-%d %H:%M:%S (%a, UTC)")
|
457
|
-
as_utc = datetime.astimezone(UTC)
|
458
|
-
local = get_time_zone_name(time_zone)
|
459
|
-
if datetime.year != as_utc.year:
|
460
|
-
return f"{datetime:%Y-%m-%d %H:%M:%S (%a}, {local}, {as_utc:%Y-%m-%d %H:%M:%S} UTC)"
|
461
|
-
if (datetime.month != as_utc.month) or (datetime.day != as_utc.day):
|
462
|
-
return (
|
463
|
-
f"{datetime:%Y-%m-%d %H:%M:%S (%a}, {local}, {as_utc:%m-%d %H:%M:%S} UTC)"
|
464
|
-
)
|
465
|
-
return f"{datetime:%Y-%m-%d %H:%M:%S (%a}, {local}, {as_utc:%H:%M:%S} UTC)"
|
466
|
-
|
467
|
-
|
468
|
-
##
|
469
|
-
|
470
|
-
|
471
|
-
@overload
|
472
|
-
def get_date(*, date: MaybeCallablePyDate) -> dt.date: ...
|
473
|
-
@overload
|
474
|
-
def get_date(*, date: None) -> None: ...
|
475
|
-
@overload
|
476
|
-
def get_date(*, date: Sentinel) -> Sentinel: ...
|
477
|
-
@overload
|
478
|
-
def get_date(*, date: MaybeCallablePyDate | Sentinel) -> dt.date | Sentinel: ...
|
479
|
-
@overload
|
480
|
-
def get_date(
|
481
|
-
*, date: MaybeCallablePyDate | None | Sentinel = sentinel
|
482
|
-
) -> dt.date | None | Sentinel: ...
|
483
|
-
def get_date(
|
484
|
-
*, date: MaybeCallablePyDate | None | Sentinel = sentinel
|
485
|
-
) -> dt.date | None | Sentinel:
|
486
|
-
"""Get the date."""
|
487
|
-
match date:
|
488
|
-
case dt.date() | None | Sentinel():
|
489
|
-
return date
|
490
|
-
case Callable() as func:
|
491
|
-
return get_date(date=func())
|
492
|
-
case _ as never:
|
493
|
-
assert_never(never)
|
494
|
-
|
495
|
-
|
496
|
-
##
|
497
|
-
|
498
|
-
|
499
|
-
@overload
|
500
|
-
def get_datetime(*, datetime: MaybeCallablePyDateTime) -> dt.datetime: ...
|
501
|
-
@overload
|
502
|
-
def get_datetime(*, datetime: None) -> None: ...
|
503
|
-
@overload
|
504
|
-
def get_datetime(*, datetime: Sentinel) -> Sentinel: ...
|
505
|
-
def get_datetime(
|
506
|
-
*, datetime: MaybeCallablePyDateTime | None | Sentinel = sentinel
|
507
|
-
) -> dt.datetime | None | Sentinel:
|
508
|
-
"""Get the datetime."""
|
509
|
-
match datetime:
|
510
|
-
case dt.datetime() | None | Sentinel():
|
511
|
-
return datetime
|
512
|
-
case Callable() as func:
|
513
|
-
return get_datetime(datetime=func())
|
514
|
-
case _ as never:
|
515
|
-
assert_never(never)
|
516
|
-
|
517
|
-
|
518
|
-
##
|
519
|
-
|
520
|
-
|
521
|
-
def get_half_years(*, n: int = 1) -> dt.timedelta:
|
522
|
-
"""Get a number of half-years as a timedelta."""
|
523
|
-
days_per_half_year = _DAYS_PER_YEAR / 2
|
524
|
-
return dt.timedelta(days=round(n * days_per_half_year))
|
525
|
-
|
526
|
-
|
527
|
-
HALF_YEAR = get_half_years(n=1)
|
528
|
-
|
529
|
-
##
|
530
|
-
|
531
|
-
|
532
|
-
def get_min_max_date(
|
533
|
-
*,
|
534
|
-
min_date: dt.date | None = None,
|
535
|
-
max_date: dt.date | None = None,
|
536
|
-
min_age: Duration | None = None,
|
537
|
-
max_age: Duration | None = None,
|
538
|
-
time_zone: TimeZoneLike = UTC,
|
539
|
-
) -> tuple[dt.date | None, dt.date | None]:
|
540
|
-
"""Get the min/max date given a combination of dates/ages."""
|
541
|
-
today = get_today(time_zone=time_zone)
|
542
|
-
min_parts: Sequence[dt.date] = []
|
543
|
-
if min_date is not None:
|
544
|
-
if min_date > today:
|
545
|
-
raise _GetMinMaxDateMinDateError(min_date=min_date, today=today)
|
546
|
-
min_parts.append(min_date)
|
547
|
-
if max_age is not None:
|
548
|
-
if date_duration_to_timedelta(max_age) < ZERO_TIME:
|
549
|
-
raise _GetMinMaxDateMaxAgeError(max_age=max_age)
|
550
|
-
min_parts.append(sub_duration(today, duration=max_age))
|
551
|
-
min_date_use = max(min_parts, default=None)
|
552
|
-
max_parts: Sequence[dt.date] = []
|
553
|
-
if max_date is not None:
|
554
|
-
if max_date > today:
|
555
|
-
raise _GetMinMaxDateMaxDateError(max_date=max_date, today=today)
|
556
|
-
max_parts.append(max_date)
|
557
|
-
if min_age is not None:
|
558
|
-
if date_duration_to_timedelta(min_age) < ZERO_TIME:
|
559
|
-
raise _GetMinMaxDateMinAgeError(min_age=min_age)
|
560
|
-
max_parts.append(sub_duration(today, duration=min_age))
|
561
|
-
max_date_use = min(max_parts, default=None)
|
562
|
-
if (
|
563
|
-
(min_date_use is not None)
|
564
|
-
and (max_date_use is not None)
|
565
|
-
and (min_date_use > max_date_use)
|
566
|
-
):
|
567
|
-
raise _GetMinMaxDatePeriodError(min_date=min_date_use, max_date=max_date_use)
|
568
|
-
return min_date_use, max_date_use
|
569
|
-
|
570
|
-
|
571
|
-
@dataclass(kw_only=True, slots=True)
|
572
|
-
class GetMinMaxDateError(Exception): ...
|
573
|
-
|
574
|
-
|
575
|
-
@dataclass(kw_only=True, slots=True)
|
576
|
-
class _GetMinMaxDateMinDateError(GetMinMaxDateError):
|
577
|
-
min_date: dt.date
|
578
|
-
today: dt.date
|
579
|
-
|
580
|
-
@override
|
581
|
-
def __str__(self) -> str:
|
582
|
-
return f"Min date must be at most today; got {self.min_date} > {self.today}"
|
583
|
-
|
584
|
-
|
585
|
-
@dataclass(kw_only=True, slots=True)
|
586
|
-
class _GetMinMaxDateMinAgeError(GetMinMaxDateError):
|
587
|
-
min_age: Duration
|
588
|
-
|
589
|
-
@override
|
590
|
-
def __str__(self) -> str:
|
591
|
-
return f"Min age must be non-negative; got {self.min_age}"
|
592
|
-
|
593
|
-
|
594
|
-
@dataclass(kw_only=True, slots=True)
|
595
|
-
class _GetMinMaxDateMaxDateError(GetMinMaxDateError):
|
596
|
-
max_date: dt.date
|
597
|
-
today: dt.date
|
598
|
-
|
599
|
-
@override
|
600
|
-
def __str__(self) -> str:
|
601
|
-
return f"Max date must be at most today; got {self.max_date} > {self.today}"
|
602
|
-
|
603
|
-
|
604
|
-
@dataclass(kw_only=True, slots=True)
|
605
|
-
class _GetMinMaxDateMaxAgeError(GetMinMaxDateError):
|
606
|
-
max_age: Duration
|
607
|
-
|
608
|
-
@override
|
609
|
-
def __str__(self) -> str:
|
610
|
-
return f"Max age must be non-negative; got {self.max_age}"
|
611
|
-
|
612
|
-
|
613
|
-
@dataclass(kw_only=True, slots=True)
|
614
|
-
class _GetMinMaxDatePeriodError(GetMinMaxDateError):
|
615
|
-
min_date: dt.date
|
616
|
-
max_date: dt.date
|
617
|
-
|
618
|
-
@override
|
619
|
-
def __str__(self) -> str:
|
620
|
-
return (
|
621
|
-
f"Min date must be at most max date; got {self.min_date} > {self.max_date}"
|
622
|
-
)
|
623
|
-
|
624
|
-
|
625
|
-
##
|
626
|
-
|
627
|
-
|
628
|
-
def get_months(*, n: int = 1) -> dt.timedelta:
|
629
|
-
"""Get a number of months as a timedelta."""
|
630
|
-
days_per_month = _DAYS_PER_YEAR / 12
|
631
|
-
return dt.timedelta(days=round(n * days_per_month))
|
632
|
-
|
633
|
-
|
634
|
-
MONTH = get_months(n=1)
|
635
|
-
|
636
|
-
|
637
|
-
##
|
638
|
-
|
639
|
-
|
640
|
-
def get_now(*, time_zone: TimeZoneLike = UTC) -> dt.datetime:
|
641
|
-
"""Get the current, timezone-aware time."""
|
642
|
-
return dt.datetime.now(tz=ensure_time_zone(time_zone))
|
643
|
-
|
644
|
-
|
645
|
-
NOW_UTC = get_now(time_zone=UTC)
|
646
|
-
|
647
|
-
|
648
|
-
##
|
649
|
-
|
650
|
-
|
651
|
-
def get_quarters(*, n: int = 1) -> dt.timedelta:
|
652
|
-
"""Get a number of quarters as a timedelta."""
|
653
|
-
days_per_quarter = _DAYS_PER_YEAR / 4
|
654
|
-
return dt.timedelta(days=round(n * days_per_quarter))
|
655
|
-
|
656
|
-
|
657
|
-
QUARTER = get_quarters(n=1)
|
658
|
-
|
659
|
-
|
660
|
-
##
|
661
|
-
|
662
|
-
|
663
|
-
def get_today(*, time_zone: TimeZoneLike = UTC) -> dt.date:
|
664
|
-
"""Get the current, timezone-aware date."""
|
665
|
-
return get_now(time_zone=time_zone).date()
|
666
|
-
|
667
|
-
|
668
|
-
TODAY_UTC = get_today(time_zone=UTC)
|
669
|
-
|
670
|
-
|
671
|
-
##
|
672
|
-
|
673
|
-
|
674
|
-
def get_years(*, n: int = 1) -> dt.timedelta:
|
675
|
-
"""Get a number of years as a timedelta."""
|
676
|
-
return dt.timedelta(days=round(n * _DAYS_PER_YEAR))
|
677
|
-
|
678
|
-
|
679
|
-
YEAR = get_years(n=1)
|
680
|
-
|
681
|
-
|
682
|
-
##
|
683
|
-
|
684
|
-
|
685
|
-
def is_integral_timedelta(duration: Duration, /) -> bool:
|
686
|
-
"""Check if a duration is integral."""
|
687
|
-
timedelta = datetime_duration_to_timedelta(duration)
|
688
|
-
return (timedelta.seconds == 0) and (timedelta.microseconds == 0)
|
689
|
-
|
690
|
-
|
691
|
-
##
|
692
|
-
|
693
|
-
|
694
|
-
def is_plain_datetime(obj: Any, /) -> TypeGuard[dt.datetime]:
|
695
|
-
"""Check if an object is a plain datetime."""
|
696
|
-
return isinstance(obj, dt.datetime) and (obj.tzinfo is None)
|
697
|
-
|
698
|
-
|
699
|
-
##
|
700
|
-
|
701
|
-
|
702
|
-
_FRIDAY = 5
|
703
|
-
|
704
|
-
|
705
|
-
def is_weekday(date: dt.date, /) -> bool:
|
706
|
-
"""Check if a date is a weekday."""
|
707
|
-
check_date_not_datetime(date)
|
708
|
-
return date.isoweekday() <= _FRIDAY
|
709
|
-
|
710
|
-
|
711
|
-
##
|
712
|
-
|
713
|
-
|
714
|
-
def is_zero_time(duration: Duration, /) -> bool:
|
715
|
-
"""Check if a timedelta is 0."""
|
716
|
-
return datetime_duration_to_timedelta(duration) == ZERO_TIME
|
717
|
-
|
718
|
-
|
719
|
-
##
|
720
|
-
|
721
|
-
|
722
|
-
def is_zoned_datetime(obj: Any, /) -> TypeGuard[dt.datetime]:
|
723
|
-
"""Check if an object is a zoned datetime."""
|
724
|
-
return isinstance(obj, dt.datetime) and (obj.tzinfo is not None)
|
725
|
-
|
726
|
-
|
727
|
-
##
|
728
|
-
|
729
|
-
|
730
44
|
def maybe_sub_pct_y(text: str, /) -> str:
|
731
45
|
"""Substitute the `%Y' token with '%4Y' if necessary."""
|
732
46
|
match SYSTEM:
|
@@ -743,150 +57,6 @@ def maybe_sub_pct_y(text: str, /) -> str:
|
|
743
57
|
##
|
744
58
|
|
745
59
|
|
746
|
-
def mean_datetime(
|
747
|
-
datetimes: Iterable[dt.datetime],
|
748
|
-
/,
|
749
|
-
*,
|
750
|
-
weights: Iterable[SupportsFloat] | None = None,
|
751
|
-
mode: MathRoundMode = "standard",
|
752
|
-
rel_tol: float | None = None,
|
753
|
-
abs_tol: float | None = None,
|
754
|
-
) -> dt.datetime:
|
755
|
-
"""Compute the mean of a set of datetimes."""
|
756
|
-
datetimes = list(datetimes)
|
757
|
-
match len(datetimes):
|
758
|
-
case 0:
|
759
|
-
raise MeanDateTimeError from None
|
760
|
-
case 1:
|
761
|
-
return one(datetimes)
|
762
|
-
case _:
|
763
|
-
microseconds = list(map(microseconds_since_epoch, datetimes))
|
764
|
-
mean_float = fmean(microseconds, weights=weights)
|
765
|
-
mean_int = round_(mean_float, mode=mode, rel_tol=rel_tol, abs_tol=abs_tol)
|
766
|
-
return microseconds_since_epoch_to_datetime(
|
767
|
-
mean_int, time_zone=datetimes[0].tzinfo
|
768
|
-
)
|
769
|
-
|
770
|
-
|
771
|
-
@dataclass(kw_only=True, slots=True)
|
772
|
-
class MeanDateTimeError(Exception):
|
773
|
-
@override
|
774
|
-
def __str__(self) -> str:
|
775
|
-
return "Mean requires at least 1 datetime"
|
776
|
-
|
777
|
-
|
778
|
-
##
|
779
|
-
|
780
|
-
|
781
|
-
def mean_timedelta(
|
782
|
-
timedeltas: Iterable[dt.timedelta],
|
783
|
-
/,
|
784
|
-
*,
|
785
|
-
weights: Iterable[SupportsFloat] | None = None,
|
786
|
-
mode: MathRoundMode = "standard",
|
787
|
-
rel_tol: float | None = None,
|
788
|
-
abs_tol: float | None = None,
|
789
|
-
) -> dt.timedelta:
|
790
|
-
"""Compute the mean of a set of timedeltas."""
|
791
|
-
timedeltas = list(timedeltas)
|
792
|
-
match len(timedeltas):
|
793
|
-
case 0:
|
794
|
-
raise MeanTimeDeltaError from None
|
795
|
-
case 1:
|
796
|
-
return one(timedeltas)
|
797
|
-
case _:
|
798
|
-
microseconds = list(map(datetime_duration_to_microseconds, timedeltas))
|
799
|
-
mean_float = fmean(microseconds, weights=weights)
|
800
|
-
mean_int = round_(mean_float, mode=mode, rel_tol=rel_tol, abs_tol=abs_tol)
|
801
|
-
return microseconds_to_timedelta(mean_int)
|
802
|
-
|
803
|
-
|
804
|
-
@dataclass(kw_only=True, slots=True)
|
805
|
-
class MeanTimeDeltaError(Exception):
|
806
|
-
@override
|
807
|
-
def __str__(self) -> str:
|
808
|
-
return "Mean requires at least 1 timedelta"
|
809
|
-
|
810
|
-
|
811
|
-
##
|
812
|
-
|
813
|
-
|
814
|
-
def microseconds_since_epoch(datetime: dt.datetime, /) -> int:
|
815
|
-
"""Compute the number of microseconds since the epoch."""
|
816
|
-
return datetime_duration_to_microseconds(timedelta_since_epoch(datetime))
|
817
|
-
|
818
|
-
|
819
|
-
def microseconds_to_timedelta(microseconds: int, /) -> dt.timedelta:
|
820
|
-
"""Compute a timedelta given a number of microseconds."""
|
821
|
-
if microseconds == 0:
|
822
|
-
return ZERO_TIME
|
823
|
-
if microseconds >= 1:
|
824
|
-
days, remainder = divmod(microseconds, _MICROSECONDS_PER_DAY)
|
825
|
-
seconds, micros = divmod(remainder, _MICROSECONDS_PER_SECOND)
|
826
|
-
return dt.timedelta(days=days, seconds=seconds, microseconds=micros)
|
827
|
-
return -microseconds_to_timedelta(-microseconds)
|
828
|
-
|
829
|
-
|
830
|
-
def microseconds_since_epoch_to_datetime(
|
831
|
-
microseconds: int, /, *, time_zone: dt.tzinfo | None = None
|
832
|
-
) -> dt.datetime:
|
833
|
-
"""Convert a number of microseconds since the epoch to a datetime."""
|
834
|
-
epoch = EPOCH_NAIVE if time_zone is None else EPOCH_UTC
|
835
|
-
timedelta = microseconds_to_timedelta(microseconds)
|
836
|
-
return epoch + timedelta
|
837
|
-
|
838
|
-
|
839
|
-
##
|
840
|
-
|
841
|
-
|
842
|
-
@overload
|
843
|
-
def milliseconds_since_epoch(
|
844
|
-
datetime: dt.datetime, /, *, strict: Literal[True]
|
845
|
-
) -> int: ...
|
846
|
-
@overload
|
847
|
-
def milliseconds_since_epoch(
|
848
|
-
datetime: dt.datetime, /, *, strict: bool = False
|
849
|
-
) -> float: ...
|
850
|
-
def milliseconds_since_epoch(
|
851
|
-
datetime: dt.datetime, /, *, strict: bool = False
|
852
|
-
) -> float:
|
853
|
-
"""Compute the number of milliseconds since the epoch."""
|
854
|
-
microseconds = microseconds_since_epoch(datetime)
|
855
|
-
milliseconds, remainder = divmod(microseconds, _MICROSECONDS_PER_MILLISECOND)
|
856
|
-
if strict:
|
857
|
-
if remainder == 0:
|
858
|
-
return milliseconds
|
859
|
-
raise MillisecondsSinceEpochError(datetime=datetime, remainder=remainder)
|
860
|
-
return milliseconds + remainder / _MICROSECONDS_PER_MILLISECOND
|
861
|
-
|
862
|
-
|
863
|
-
@dataclass(kw_only=True, slots=True)
|
864
|
-
class MillisecondsSinceEpochError(Exception):
|
865
|
-
datetime: dt.datetime
|
866
|
-
remainder: int
|
867
|
-
|
868
|
-
@override
|
869
|
-
def __str__(self) -> str:
|
870
|
-
return f"Unable to convert {self.datetime} to milliseconds since epoch; got {self.remainder} microsecond(s)"
|
871
|
-
|
872
|
-
|
873
|
-
def milliseconds_since_epoch_to_datetime(
|
874
|
-
milliseconds: int, /, *, time_zone: dt.tzinfo | None = None
|
875
|
-
) -> dt.datetime:
|
876
|
-
"""Convert a number of milliseconds since the epoch to a datetime."""
|
877
|
-
epoch = EPOCH_NAIVE if time_zone is None else EPOCH_UTC
|
878
|
-
timedelta = milliseconds_to_timedelta(milliseconds)
|
879
|
-
return epoch + timedelta
|
880
|
-
|
881
|
-
|
882
|
-
def milliseconds_to_timedelta(milliseconds: int, /) -> dt.timedelta:
|
883
|
-
"""Compute a timedelta given a number of milliseconds."""
|
884
|
-
return microseconds_to_timedelta(_MICROSECONDS_PER_MILLISECOND * milliseconds)
|
885
|
-
|
886
|
-
|
887
|
-
##
|
888
|
-
|
889
|
-
|
890
60
|
@dataclass(order=True, unsafe_hash=True, slots=True)
|
891
61
|
class Month:
|
892
62
|
"""Represents a month in time."""
|
@@ -931,7 +101,6 @@ class Month:
|
|
931
101
|
|
932
102
|
@classmethod
|
933
103
|
def from_date(cls, date: dt.date, /) -> Self:
|
934
|
-
check_date_not_datetime(date)
|
935
104
|
return cls(year=date.year, month=date.month)
|
936
105
|
|
937
106
|
def to_date(self, /, *, day: int = 1) -> dt.date:
|
@@ -1010,130 +179,6 @@ class _ParseTwoDigitYearInvalidStringError(Exception):
|
|
1010
179
|
##
|
1011
180
|
|
1012
181
|
|
1013
|
-
def round_datetime(
|
1014
|
-
datetime: dt.datetime,
|
1015
|
-
duration: Duration,
|
1016
|
-
/,
|
1017
|
-
*,
|
1018
|
-
mode: MathRoundMode = "standard",
|
1019
|
-
rel_tol: float | None = None,
|
1020
|
-
abs_tol: float | None = None,
|
1021
|
-
) -> dt.datetime:
|
1022
|
-
"""Round a datetime to a timedelta."""
|
1023
|
-
if datetime.tzinfo is None:
|
1024
|
-
dividend = microseconds_since_epoch(datetime)
|
1025
|
-
divisor = datetime_duration_to_microseconds(duration)
|
1026
|
-
quotient, remainder = divmod(dividend, divisor)
|
1027
|
-
rnd_remainder = round_(
|
1028
|
-
remainder / divisor, mode=mode, rel_tol=rel_tol, abs_tol=abs_tol
|
1029
|
-
)
|
1030
|
-
rnd_quotient = quotient + rnd_remainder
|
1031
|
-
microseconds = rnd_quotient * divisor
|
1032
|
-
return microseconds_since_epoch_to_datetime(microseconds)
|
1033
|
-
local = datetime.replace(tzinfo=None)
|
1034
|
-
rounded = round_datetime(
|
1035
|
-
local, duration, mode=mode, rel_tol=rel_tol, abs_tol=abs_tol
|
1036
|
-
)
|
1037
|
-
return rounded.replace(tzinfo=datetime.tzinfo)
|
1038
|
-
|
1039
|
-
|
1040
|
-
##
|
1041
|
-
|
1042
|
-
|
1043
|
-
def round_to_next_weekday(date: dt.date, /) -> dt.date:
|
1044
|
-
"""Round a date to the next weekday."""
|
1045
|
-
return _round_to_weekday(date, prev_or_next="next")
|
1046
|
-
|
1047
|
-
|
1048
|
-
def round_to_prev_weekday(date: dt.date, /) -> dt.date:
|
1049
|
-
"""Round a date to the previous weekday."""
|
1050
|
-
return _round_to_weekday(date, prev_or_next="prev")
|
1051
|
-
|
1052
|
-
|
1053
|
-
def _round_to_weekday(
|
1054
|
-
date: dt.date, /, *, prev_or_next: Literal["prev", "next"]
|
1055
|
-
) -> dt.date:
|
1056
|
-
"""Round a date to the previous weekday."""
|
1057
|
-
check_date_not_datetime(date)
|
1058
|
-
match prev_or_next:
|
1059
|
-
case "prev":
|
1060
|
-
n = -1
|
1061
|
-
case "next":
|
1062
|
-
n = 1
|
1063
|
-
case _ as never:
|
1064
|
-
assert_never(never)
|
1065
|
-
while not is_weekday(date):
|
1066
|
-
date = add_weekdays(date, n=n)
|
1067
|
-
return date
|
1068
|
-
|
1069
|
-
|
1070
|
-
##
|
1071
|
-
|
1072
|
-
|
1073
|
-
def serialize_compact(date_or_datetime: DateOrDateTime, /) -> str:
|
1074
|
-
"""Serialize a date/datetime using a compact format."""
|
1075
|
-
match date_or_datetime:
|
1076
|
-
case dt.datetime() as datetime:
|
1077
|
-
if datetime.tzinfo is None:
|
1078
|
-
raise SerializeCompactError(datetime=datetime)
|
1079
|
-
format_ = "%Y%m%dT%H%M%S"
|
1080
|
-
case dt.date():
|
1081
|
-
format_ = "%Y%m%d"
|
1082
|
-
case _ as never:
|
1083
|
-
assert_never(never)
|
1084
|
-
return date_or_datetime.strftime(maybe_sub_pct_y(format_))
|
1085
|
-
|
1086
|
-
|
1087
|
-
@dataclass(kw_only=True, slots=True)
|
1088
|
-
class SerializeCompactError(Exception):
|
1089
|
-
datetime: dt.datetime
|
1090
|
-
|
1091
|
-
@override
|
1092
|
-
def __str__(self) -> str:
|
1093
|
-
return f"Unable to serialize plain datetime {self.datetime}"
|
1094
|
-
|
1095
|
-
|
1096
|
-
def parse_date_compact(text: str, /) -> dt.date:
|
1097
|
-
"""Parse a compact string into a date."""
|
1098
|
-
try:
|
1099
|
-
datetime = dt.datetime.strptime(text, "%Y%m%d").replace(tzinfo=UTC)
|
1100
|
-
except ValueError:
|
1101
|
-
raise ParseDateCompactError(text=text) from None
|
1102
|
-
return datetime.date()
|
1103
|
-
|
1104
|
-
|
1105
|
-
@dataclass(kw_only=True, slots=True)
|
1106
|
-
class ParseDateCompactError(Exception):
|
1107
|
-
text: str
|
1108
|
-
|
1109
|
-
@override
|
1110
|
-
def __str__(self) -> str:
|
1111
|
-
return f"Unable to parse {self.text!r} into a date"
|
1112
|
-
|
1113
|
-
|
1114
|
-
def parse_datetime_compact(
|
1115
|
-
text: str, /, *, time_zone: TimeZoneLike = UTC
|
1116
|
-
) -> dt.datetime:
|
1117
|
-
"""Parse a compact string into a datetime."""
|
1118
|
-
time_zone = ensure_time_zone(time_zone)
|
1119
|
-
try:
|
1120
|
-
return dt.datetime.strptime(text, "%Y%m%dT%H%M%S").replace(tzinfo=time_zone)
|
1121
|
-
except ValueError:
|
1122
|
-
raise ParseDateTimeCompactError(text=text) from None
|
1123
|
-
|
1124
|
-
|
1125
|
-
@dataclass(kw_only=True, slots=True)
|
1126
|
-
class ParseDateTimeCompactError(Exception):
|
1127
|
-
text: str
|
1128
|
-
|
1129
|
-
@override
|
1130
|
-
def __str__(self) -> str:
|
1131
|
-
return f"Unable to parse {self.text!r} into a datetime"
|
1132
|
-
|
1133
|
-
|
1134
|
-
##
|
1135
|
-
|
1136
|
-
|
1137
182
|
def serialize_month(month: Month, /) -> str:
|
1138
183
|
"""Serialize a month."""
|
1139
184
|
return f"{month.year:04}-{month.month:02}"
|
@@ -1160,243 +205,18 @@ class ParseMonthError(Exception):
|
|
1160
205
|
return f"Unable to parse month; got {self.month!r}"
|
1161
206
|
|
1162
207
|
|
1163
|
-
##
|
1164
|
-
|
1165
|
-
|
1166
|
-
@overload
|
1167
|
-
def sub_duration(
|
1168
|
-
date: dt.datetime, /, *, duration: Duration | None = ...
|
1169
|
-
) -> dt.datetime: ...
|
1170
|
-
@overload
|
1171
|
-
def sub_duration(date: dt.date, /, *, duration: Duration | None = ...) -> dt.date: ...
|
1172
|
-
def sub_duration(
|
1173
|
-
date: DateOrDateTime, /, *, duration: Duration | None = None
|
1174
|
-
) -> dt.date:
|
1175
|
-
"""Subtract a duration from a date/datetime."""
|
1176
|
-
if duration is None:
|
1177
|
-
return date
|
1178
|
-
try:
|
1179
|
-
return add_duration(date, duration=-duration)
|
1180
|
-
except AddDurationError:
|
1181
|
-
raise SubDurationError(date=date, duration=duration) from None
|
1182
|
-
|
1183
|
-
|
1184
|
-
@dataclass(kw_only=True, slots=True)
|
1185
|
-
class SubDurationError(Exception):
|
1186
|
-
date: dt.date
|
1187
|
-
duration: Duration
|
1188
|
-
|
1189
|
-
@override
|
1190
|
-
def __str__(self) -> str:
|
1191
|
-
return f"Date {self.date} must be paired with an integral duration; got {self.duration}"
|
1192
|
-
|
1193
|
-
|
1194
|
-
##
|
1195
|
-
|
1196
|
-
|
1197
|
-
def timedelta_since_epoch(date_or_datetime: DateOrDateTime, /) -> dt.timedelta:
|
1198
|
-
"""Compute the timedelta since the epoch."""
|
1199
|
-
match date_or_datetime:
|
1200
|
-
case dt.datetime() as datetime:
|
1201
|
-
if datetime.tzinfo is None:
|
1202
|
-
return datetime - EPOCH_NAIVE
|
1203
|
-
return datetime.astimezone(UTC) - EPOCH_UTC
|
1204
|
-
case dt.date() as date:
|
1205
|
-
return date - EPOCH_DATE
|
1206
|
-
case _ as never:
|
1207
|
-
assert_never(never)
|
1208
|
-
|
1209
|
-
|
1210
|
-
##
|
1211
|
-
|
1212
|
-
|
1213
|
-
def yield_days(
|
1214
|
-
*, start: dt.date | None = None, end: dt.date | None = None, days: int | None = None
|
1215
|
-
) -> Iterator[dt.date]:
|
1216
|
-
"""Yield the days in a range."""
|
1217
|
-
match start, end, days:
|
1218
|
-
case dt.date(), dt.date(), None:
|
1219
|
-
check_date_not_datetime(start)
|
1220
|
-
check_date_not_datetime(end)
|
1221
|
-
date = start
|
1222
|
-
while date <= end:
|
1223
|
-
yield date
|
1224
|
-
date += DAY
|
1225
|
-
case dt.date(), None, int():
|
1226
|
-
check_date_not_datetime(start)
|
1227
|
-
date = start
|
1228
|
-
for _ in range(days):
|
1229
|
-
yield date
|
1230
|
-
date += DAY
|
1231
|
-
case None, dt.date(), int():
|
1232
|
-
check_date_not_datetime(end)
|
1233
|
-
date = end
|
1234
|
-
for _ in range(days):
|
1235
|
-
yield date
|
1236
|
-
date -= DAY
|
1237
|
-
case _:
|
1238
|
-
raise YieldDaysError(start=start, end=end, days=days)
|
1239
|
-
|
1240
|
-
|
1241
|
-
@dataclass(kw_only=True, slots=True)
|
1242
|
-
class YieldDaysError(Exception):
|
1243
|
-
start: dt.date | None
|
1244
|
-
end: dt.date | None
|
1245
|
-
days: int | None
|
1246
|
-
|
1247
|
-
@override
|
1248
|
-
def __str__(self) -> str:
|
1249
|
-
return (
|
1250
|
-
f"Invalid arguments: start={self.start}, end={self.end}, days={self.days}"
|
1251
|
-
)
|
1252
|
-
|
1253
|
-
|
1254
|
-
##
|
1255
|
-
|
1256
|
-
|
1257
|
-
def yield_weekdays(
|
1258
|
-
*, start: dt.date | None = None, end: dt.date | None = None, days: int | None = None
|
1259
|
-
) -> Iterator[dt.date]:
|
1260
|
-
"""Yield the weekdays in a range."""
|
1261
|
-
match start, end, days:
|
1262
|
-
case dt.date(), dt.date(), None:
|
1263
|
-
check_date_not_datetime(start)
|
1264
|
-
check_date_not_datetime(end)
|
1265
|
-
date = round_to_next_weekday(start)
|
1266
|
-
while date <= end:
|
1267
|
-
yield date
|
1268
|
-
date = round_to_next_weekday(date + DAY)
|
1269
|
-
case dt.date(), None, int():
|
1270
|
-
check_date_not_datetime(start)
|
1271
|
-
date = round_to_next_weekday(start)
|
1272
|
-
for _ in range(days):
|
1273
|
-
yield date
|
1274
|
-
date = round_to_next_weekday(date + DAY)
|
1275
|
-
case None, dt.date(), int():
|
1276
|
-
check_date_not_datetime(end)
|
1277
|
-
date = round_to_prev_weekday(end)
|
1278
|
-
for _ in range(days):
|
1279
|
-
yield date
|
1280
|
-
date = round_to_prev_weekday(date - DAY)
|
1281
|
-
case _:
|
1282
|
-
raise YieldWeekdaysError(start=start, end=end, days=days)
|
1283
|
-
|
1284
|
-
|
1285
|
-
@dataclass(kw_only=True, slots=True)
|
1286
|
-
class YieldWeekdaysError(Exception):
|
1287
|
-
start: dt.date | None
|
1288
|
-
end: dt.date | None
|
1289
|
-
days: int | None
|
1290
|
-
|
1291
|
-
@override
|
1292
|
-
def __str__(self) -> str:
|
1293
|
-
return (
|
1294
|
-
f"Invalid arguments: start={self.start}, end={self.end}, days={self.days}"
|
1295
|
-
)
|
1296
|
-
|
1297
|
-
|
1298
208
|
__all__ = [
|
1299
|
-
"DATETIME_MAX_NAIVE",
|
1300
|
-
"DATETIME_MAX_UTC",
|
1301
|
-
"DATETIME_MIN_NAIVE",
|
1302
|
-
"DATETIME_MIN_UTC",
|
1303
|
-
"DAY",
|
1304
|
-
"EPOCH_DATE",
|
1305
|
-
"EPOCH_NAIVE",
|
1306
|
-
"EPOCH_UTC",
|
1307
|
-
"HALF_YEAR",
|
1308
|
-
"HOUR",
|
1309
209
|
"MAX_DATE_TWO_DIGIT_YEAR",
|
1310
210
|
"MAX_MONTH",
|
1311
|
-
"MILLISECOND",
|
1312
|
-
"MINUTE",
|
1313
211
|
"MIN_DATE_TWO_DIGIT_YEAR",
|
1314
212
|
"MIN_MONTH",
|
1315
|
-
"MONTH",
|
1316
|
-
"NOW_UTC",
|
1317
|
-
"QUARTER",
|
1318
|
-
"SECOND",
|
1319
|
-
"TODAY_UTC",
|
1320
|
-
"WEEK",
|
1321
|
-
"YEAR",
|
1322
|
-
"ZERO_TIME",
|
1323
|
-
"AddDurationError",
|
1324
|
-
"AddWeekdaysError",
|
1325
|
-
"AreEqualDateTimesError",
|
1326
|
-
"AreEqualDatesOrDateTimesError",
|
1327
|
-
"CheckDateNotDateTimeError",
|
1328
213
|
"DateOrMonth",
|
1329
214
|
"EnsureMonthError",
|
1330
|
-
"GetMinMaxDateError",
|
1331
|
-
"MeanDateTimeError",
|
1332
|
-
"MeanTimeDeltaError",
|
1333
|
-
"MillisecondsSinceEpochError",
|
1334
215
|
"Month",
|
1335
216
|
"MonthError",
|
1336
217
|
"MonthLike",
|
1337
|
-
"ParseDateCompactError",
|
1338
|
-
"ParseDateTimeCompactError",
|
1339
218
|
"ParseMonthError",
|
1340
|
-
"SerializeCompactError",
|
1341
|
-
"SubDurationError",
|
1342
|
-
"TimedeltaToMillisecondsError",
|
1343
|
-
"YieldDaysError",
|
1344
|
-
"YieldWeekdaysError",
|
1345
|
-
"add_duration",
|
1346
|
-
"add_weekdays",
|
1347
|
-
"are_equal_date_durations",
|
1348
|
-
"are_equal_dates_or_datetimes",
|
1349
|
-
"are_equal_datetime_durations",
|
1350
|
-
"are_equal_datetimes",
|
1351
|
-
"are_equal_months",
|
1352
|
-
"check_date_not_datetime",
|
1353
|
-
"date_duration_to_int",
|
1354
|
-
"date_duration_to_timedelta",
|
1355
|
-
"date_to_datetime",
|
1356
219
|
"date_to_month",
|
1357
|
-
"datetime_duration_to_float",
|
1358
|
-
"datetime_duration_to_microseconds",
|
1359
|
-
"datetime_duration_to_milliseconds",
|
1360
|
-
"datetime_duration_to_timedelta",
|
1361
|
-
"datetime_utc",
|
1362
|
-
"days_since_epoch",
|
1363
|
-
"days_since_epoch_to_date",
|
1364
220
|
"ensure_month",
|
1365
|
-
"format_datetime_local_and_utc",
|
1366
|
-
"get_date",
|
1367
|
-
"get_datetime",
|
1368
|
-
"get_half_years",
|
1369
|
-
"get_min_max_date",
|
1370
|
-
"get_months",
|
1371
|
-
"get_now",
|
1372
|
-
"get_quarters",
|
1373
|
-
"get_today",
|
1374
|
-
"get_years",
|
1375
|
-
"is_integral_timedelta",
|
1376
|
-
"is_plain_datetime",
|
1377
|
-
"is_weekday",
|
1378
|
-
"is_zero_time",
|
1379
|
-
"is_zoned_datetime",
|
1380
|
-
"maybe_sub_pct_y",
|
1381
|
-
"mean_datetime",
|
1382
|
-
"mean_timedelta",
|
1383
|
-
"microseconds_since_epoch",
|
1384
|
-
"microseconds_since_epoch_to_datetime",
|
1385
|
-
"microseconds_to_timedelta",
|
1386
|
-
"milliseconds_since_epoch",
|
1387
|
-
"milliseconds_since_epoch_to_datetime",
|
1388
|
-
"milliseconds_to_timedelta",
|
1389
|
-
"parse_date_compact",
|
1390
|
-
"parse_datetime_compact",
|
1391
|
-
"parse_month",
|
1392
221
|
"parse_two_digit_year",
|
1393
|
-
"round_datetime",
|
1394
|
-
"round_to_next_weekday",
|
1395
|
-
"round_to_prev_weekday",
|
1396
|
-
"serialize_compact",
|
1397
|
-
"serialize_month",
|
1398
|
-
"sub_duration",
|
1399
|
-
"timedelta_since_epoch",
|
1400
|
-
"yield_days",
|
1401
|
-
"yield_weekdays",
|
1402
222
|
]
|