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.
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 statistics import fmean
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.sentinel import Sentinel, sentinel
24
- from utilities.types import MaybeCallablePyDate, MaybeCallablePyDateTime, MaybeStr
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
  ]