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.
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, sub
7
- from statistics import fmean
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.math import SafeRoundError, round_, safe_round
21
- from utilities.platform import SYSTEM
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
  ]