dycw-utilities 0.146.2__py3-none-any.whl → 0.178.1__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.

Potentially problematic release.


This version of dycw-utilities might be problematic. Click here for more details.

Files changed (89) hide show
  1. dycw_utilities-0.178.1.dist-info/METADATA +34 -0
  2. dycw_utilities-0.178.1.dist-info/RECORD +105 -0
  3. dycw_utilities-0.178.1.dist-info/WHEEL +4 -0
  4. {dycw_utilities-0.146.2.dist-info → dycw_utilities-0.178.1.dist-info}/entry_points.txt +1 -0
  5. utilities/__init__.py +1 -1
  6. utilities/altair.py +10 -7
  7. utilities/asyncio.py +129 -50
  8. utilities/atomicwrites.py +1 -1
  9. utilities/atools.py +64 -4
  10. utilities/cachetools.py +9 -6
  11. utilities/click.py +144 -49
  12. utilities/concurrent.py +1 -1
  13. utilities/contextlib.py +4 -2
  14. utilities/contextvars.py +20 -1
  15. utilities/cryptography.py +3 -3
  16. utilities/dataclasses.py +15 -28
  17. utilities/docker.py +387 -0
  18. utilities/enum.py +2 -2
  19. utilities/errors.py +17 -3
  20. utilities/fastapi.py +8 -3
  21. utilities/fpdf2.py +2 -2
  22. utilities/functions.py +20 -297
  23. utilities/git.py +19 -0
  24. utilities/grp.py +28 -0
  25. utilities/hypothesis.py +361 -79
  26. utilities/importlib.py +17 -1
  27. utilities/inflect.py +1 -1
  28. utilities/iterables.py +33 -58
  29. utilities/jinja2.py +148 -0
  30. utilities/json.py +1 -1
  31. utilities/libcst.py +7 -7
  32. utilities/logging.py +131 -93
  33. utilities/math.py +8 -4
  34. utilities/more_itertools.py +4 -6
  35. utilities/operator.py +1 -1
  36. utilities/orjson.py +86 -34
  37. utilities/os.py +49 -2
  38. utilities/packaging.py +115 -0
  39. utilities/parse.py +2 -2
  40. utilities/pathlib.py +66 -34
  41. utilities/permissions.py +298 -0
  42. utilities/platform.py +5 -4
  43. utilities/polars.py +934 -420
  44. utilities/polars_ols.py +1 -1
  45. utilities/postgres.py +317 -153
  46. utilities/pottery.py +10 -86
  47. utilities/pqdm.py +3 -3
  48. utilities/pwd.py +28 -0
  49. utilities/pydantic.py +4 -51
  50. utilities/pydantic_settings.py +240 -0
  51. utilities/pydantic_settings_sops.py +76 -0
  52. utilities/pyinstrument.py +5 -5
  53. utilities/pytest.py +100 -126
  54. utilities/pytest_plugins/pytest_randomly.py +1 -1
  55. utilities/pytest_plugins/pytest_regressions.py +7 -3
  56. utilities/pytest_regressions.py +27 -8
  57. utilities/random.py +11 -6
  58. utilities/re.py +1 -1
  59. utilities/redis.py +101 -64
  60. utilities/sentinel.py +10 -0
  61. utilities/shelve.py +4 -1
  62. utilities/shutil.py +25 -0
  63. utilities/slack_sdk.py +9 -4
  64. utilities/sqlalchemy.py +422 -352
  65. utilities/sqlalchemy_polars.py +28 -52
  66. utilities/string.py +1 -1
  67. utilities/subprocess.py +1977 -0
  68. utilities/tempfile.py +112 -4
  69. utilities/testbook.py +50 -0
  70. utilities/text.py +174 -42
  71. utilities/throttle.py +158 -0
  72. utilities/timer.py +2 -2
  73. utilities/traceback.py +59 -38
  74. utilities/types.py +68 -22
  75. utilities/typing.py +479 -19
  76. utilities/uuid.py +42 -5
  77. utilities/version.py +27 -26
  78. utilities/whenever.py +663 -178
  79. utilities/zoneinfo.py +80 -22
  80. dycw_utilities-0.146.2.dist-info/METADATA +0 -41
  81. dycw_utilities-0.146.2.dist-info/RECORD +0 -99
  82. dycw_utilities-0.146.2.dist-info/WHEEL +0 -4
  83. dycw_utilities-0.146.2.dist-info/licenses/LICENSE +0 -21
  84. utilities/aiolimiter.py +0 -25
  85. utilities/eventkit.py +0 -388
  86. utilities/period.py +0 -237
  87. utilities/python_dotenv.py +0 -101
  88. utilities/streamlit.py +0 -105
  89. utilities/typed_settings.py +0 -144
utilities/whenever.py CHANGED
@@ -10,12 +10,15 @@ from typing import (
10
10
  TYPE_CHECKING,
11
11
  Any,
12
12
  Literal,
13
+ Self,
13
14
  SupportsFloat,
15
+ TypedDict,
14
16
  assert_never,
15
17
  cast,
16
18
  overload,
17
19
  override,
18
20
  )
21
+ from zoneinfo import ZoneInfo
19
22
 
20
23
  from whenever import (
21
24
  Date,
@@ -29,27 +32,28 @@ from whenever import (
29
32
  ZonedDateTime,
30
33
  )
31
34
 
35
+ from utilities.dataclasses import replace_non_sentinel
36
+ from utilities.functions import get_class_name
32
37
  from utilities.math import sign
33
38
  from utilities.platform import get_strftime
34
39
  from utilities.sentinel import Sentinel, sentinel
35
40
  from utilities.tzlocal import LOCAL_TIME_ZONE, LOCAL_TIME_ZONE_NAME
36
- from utilities.zoneinfo import UTC, get_time_zone_name
41
+ from utilities.zoneinfo import UTC, to_time_zone_name
37
42
 
38
43
  if TYPE_CHECKING:
39
- from zoneinfo import ZoneInfo
40
-
41
44
  from utilities.types import (
42
45
  DateOrDateTimeDelta,
43
46
  DateTimeRoundMode,
44
47
  Delta,
45
- MaybeCallableDate,
46
- MaybeCallableZonedDateTime,
48
+ MaybeCallableDateLike,
49
+ MaybeCallableTimeLike,
50
+ MaybeCallableZonedDateTimeLike,
47
51
  TimeOrDateTimeDelta,
48
52
  TimeZoneLike,
49
53
  )
50
54
 
51
55
 
52
- ## bounds
56
+ # bounds
53
57
 
54
58
 
55
59
  ZONED_DATE_TIME_MIN = PlainDateTime.MIN.assume_tz(UTC.key)
@@ -142,6 +146,113 @@ def sub_year_month(x: YearMonth, /, *, years: int = 0, months: int = 0) -> YearM
142
146
  ##
143
147
 
144
148
 
149
+ @dataclass(repr=False, order=True, unsafe_hash=True, kw_only=False)
150
+ class DatePeriod:
151
+ """A period of dates."""
152
+
153
+ start: Date
154
+ end: Date
155
+
156
+ def __post_init__(self) -> None:
157
+ if self.start > self.end:
158
+ raise DatePeriodError(start=self.start, end=self.end)
159
+
160
+ def __add__(self, other: DateDelta, /) -> Self:
161
+ """Offset the period."""
162
+ return self.replace(start=self.start + other, end=self.end + other)
163
+
164
+ def __contains__(self, other: Date, /) -> bool:
165
+ """Check if a date/datetime lies in the period."""
166
+ return self.start <= other <= self.end
167
+
168
+ @override
169
+ def __repr__(self) -> str:
170
+ cls = get_class_name(self)
171
+ return f"{cls}({self.start}, {self.end})"
172
+
173
+ def __sub__(self, other: DateDelta, /) -> Self:
174
+ """Offset the period."""
175
+ return self.replace(start=self.start - other, end=self.end - other)
176
+
177
+ def at(
178
+ self, obj: Time | tuple[Time, Time], /, *, time_zone: TimeZoneLike = UTC
179
+ ) -> ZonedDateTimePeriod:
180
+ """Combine a date with a time to create a datetime."""
181
+ match obj:
182
+ case Time() as time:
183
+ start = end = time
184
+ case Time() as start, Time() as end:
185
+ ...
186
+ case never:
187
+ assert_never(never)
188
+ tz = to_time_zone_name(time_zone)
189
+ return ZonedDateTimePeriod(
190
+ self.start.at(start).assume_tz(tz), self.end.at(end).assume_tz(tz)
191
+ )
192
+
193
+ @property
194
+ def delta(self) -> DateDelta:
195
+ """The delta of the period."""
196
+ return self.end - self.start
197
+
198
+ def format_compact(self) -> str:
199
+ """Format the period in a compact fashion."""
200
+ fc, start, end = format_compact, self.start, self.end
201
+ if self.start == self.end:
202
+ return f"{fc(start)}="
203
+ if self.start.year_month() == self.end.year_month():
204
+ return f"{fc(start)}-{fc(end, fmt='%d')}"
205
+ if self.start.year == self.end.year:
206
+ return f"{fc(start)}-{fc(end, fmt='%m%d')}"
207
+ return f"{fc(start)}-{fc(end)}"
208
+
209
+ @classmethod
210
+ def from_dict(cls, mapping: PeriodDict[Date] | PeriodDict[dt.date], /) -> Self:
211
+ """Convert the dictionary to a period."""
212
+ match mapping["start"]:
213
+ case Date() as start:
214
+ ...
215
+ case dt.date() as py_date:
216
+ start = Date.from_py_date(py_date)
217
+ case never:
218
+ assert_never(never)
219
+ match mapping["end"]:
220
+ case Date() as end:
221
+ ...
222
+ case dt.date() as py_date:
223
+ end = Date.from_py_date(py_date)
224
+ case never:
225
+ assert_never(never)
226
+ return cls(start=start, end=end)
227
+
228
+ def replace(
229
+ self, *, start: Date | Sentinel = sentinel, end: Date | Sentinel = sentinel
230
+ ) -> Self:
231
+ """Replace elements of the period."""
232
+ return replace_non_sentinel(self, start=start, end=end)
233
+
234
+ def to_dict(self) -> PeriodDict[Date]:
235
+ """Convert the period to a dictionary."""
236
+ return PeriodDict(start=self.start, end=self.end)
237
+
238
+ def to_py_dict(self) -> PeriodDict[dt.date]:
239
+ """Convert the period to a dictionary."""
240
+ return PeriodDict(start=self.start.py_date(), end=self.end.py_date())
241
+
242
+
243
+ @dataclass(kw_only=True, slots=True)
244
+ class DatePeriodError(Exception):
245
+ start: Date
246
+ end: Date
247
+
248
+ @override
249
+ def __str__(self) -> str:
250
+ return f"Invalid period; got {self.start} > {self.end}"
251
+
252
+
253
+ ##
254
+
255
+
145
256
  def datetime_utc(
146
257
  year: int,
147
258
  month: int,
@@ -200,7 +311,11 @@ def diff_year_month(
200
311
 
201
312
 
202
313
  def format_compact(
203
- obj: Date | Time | PlainDateTime | ZonedDateTime, /, *, fmt: str | None = None
314
+ obj: Date | Time | PlainDateTime | ZonedDateTime,
315
+ /,
316
+ *,
317
+ fmt: str | None = None,
318
+ path: bool = False,
204
319
  ) -> str:
205
320
  """Format the date/datetime in a compact fashion."""
206
321
  match obj:
@@ -210,12 +325,16 @@ def format_compact(
210
325
  case Time() as time:
211
326
  obj_use = time.round().py_time()
212
327
  fmt_use = "%H%M%S" if fmt is None else fmt
213
- case PlainDateTime() as datetime:
214
- obj_use = datetime.round().py_datetime()
328
+ case PlainDateTime() as date_time:
329
+ obj_use = date_time.round().py_datetime()
215
330
  fmt_use = "%Y%m%dT%H%M%S" if fmt is None else fmt
216
- case ZonedDateTime() as datetime:
217
- return f"{format_compact(datetime.to_plain(), fmt=fmt)}[{datetime.tz}]"
218
- case _ as never:
331
+ case ZonedDateTime() as date_time:
332
+ plain = format_compact(date_time.to_plain(), fmt=fmt)
333
+ tz = date_time.tz
334
+ if path:
335
+ tz = tz.replace("/", "~")
336
+ return f"{plain}[{tz}]"
337
+ case never:
219
338
  assert_never(never)
220
339
  return obj_use.strftime(get_strftime(fmt_use))
221
340
 
@@ -225,52 +344,87 @@ def format_compact(
225
344
 
226
345
  def from_timestamp(i: float, /, *, time_zone: TimeZoneLike = UTC) -> ZonedDateTime:
227
346
  """Get a zoned datetime from a timestamp."""
228
- return ZonedDateTime.from_timestamp(i, tz=get_time_zone_name(time_zone))
347
+ return ZonedDateTime.from_timestamp(i, tz=to_time_zone_name(time_zone))
229
348
 
230
349
 
231
350
  def from_timestamp_millis(i: int, /, *, time_zone: TimeZoneLike = UTC) -> ZonedDateTime:
232
351
  """Get a zoned datetime from a timestamp (in milliseconds)."""
233
- return ZonedDateTime.from_timestamp_millis(i, tz=get_time_zone_name(time_zone))
352
+ return ZonedDateTime.from_timestamp_millis(i, tz=to_time_zone_name(time_zone))
234
353
 
235
354
 
236
355
  def from_timestamp_nanos(i: int, /, *, time_zone: TimeZoneLike = UTC) -> ZonedDateTime:
237
356
  """Get a zoned datetime from a timestamp (in nanoseconds)."""
238
- return ZonedDateTime.from_timestamp_nanos(i, tz=get_time_zone_name(time_zone))
357
+ return ZonedDateTime.from_timestamp_nanos(i, tz=to_time_zone_name(time_zone))
239
358
 
240
359
 
241
360
  ##
242
361
 
243
362
 
244
- def get_now(*, time_zone: TimeZoneLike = UTC) -> ZonedDateTime:
245
- """Get the current zoned datetime."""
246
- return ZonedDateTime.now(get_time_zone_name(time_zone))
363
+ def get_now(time_zone: TimeZoneLike = UTC, /) -> ZonedDateTime:
364
+ """Get the current zoned date-time."""
365
+ return ZonedDateTime.now(to_time_zone_name(time_zone))
247
366
 
248
367
 
249
- NOW_UTC = get_now(time_zone=UTC)
368
+ NOW_UTC = get_now(UTC)
250
369
 
251
370
 
252
371
  def get_now_local() -> ZonedDateTime:
253
- """Get the current local time."""
254
- return get_now(time_zone=LOCAL_TIME_ZONE)
372
+ """Get the current zoned date-time in the local time-zone."""
373
+ return get_now(LOCAL_TIME_ZONE)
255
374
 
256
375
 
257
376
  NOW_LOCAL = get_now_local()
258
377
 
259
378
 
379
+ def get_now_plain(time_zone: TimeZoneLike = UTC, /) -> PlainDateTime:
380
+ """Get the current plain date-time."""
381
+ return get_now(time_zone).to_plain()
382
+
383
+
384
+ NOW_PLAIN = get_now_plain()
385
+
386
+
387
+ def get_now_local_plain() -> PlainDateTime:
388
+ """Get the current plain date-time in the local time-zone."""
389
+ return get_now_local().to_plain()
390
+
391
+
392
+ NOW_LOCAL_PLAIN = get_now_local_plain()
393
+
394
+
260
395
  ##
261
396
 
262
397
 
263
- def get_today(*, time_zone: TimeZoneLike = UTC) -> Date:
398
+ def get_time(time_zone: TimeZoneLike = UTC, /) -> Time:
399
+ """Get the current time."""
400
+ return get_now(time_zone).time()
401
+
402
+
403
+ TIME_UTC = get_time(UTC)
404
+
405
+
406
+ def get_time_local() -> Time:
407
+ """Get the current time in the local time-zone."""
408
+ return get_time(LOCAL_TIME_ZONE)
409
+
410
+
411
+ TIME_LOCAL = get_time_local()
412
+
413
+
414
+ ##
415
+
416
+
417
+ def get_today(time_zone: TimeZoneLike = UTC, /) -> Date:
264
418
  """Get the current, timezone-aware local date."""
265
- return get_now(time_zone=time_zone).date()
419
+ return get_now(time_zone).date()
266
420
 
267
421
 
268
- TODAY_UTC = get_today(time_zone=UTC)
422
+ TODAY_UTC = get_today(UTC)
269
423
 
270
424
 
271
425
  def get_today_local() -> Date:
272
426
  """Get the current, timezone-aware local date."""
273
- return get_today(time_zone=LOCAL_TIME_ZONE)
427
+ return get_today(LOCAL_TIME_ZONE)
274
428
 
275
429
 
276
430
  TODAY_LOCAL = get_today_local()
@@ -279,6 +433,36 @@ TODAY_LOCAL = get_today_local()
279
433
  ##
280
434
 
281
435
 
436
+ def is_weekend(
437
+ date_time: ZonedDateTime,
438
+ /,
439
+ *,
440
+ start: tuple[Weekday, Time] = (Weekday.SATURDAY, Time.MIN),
441
+ end: tuple[Weekday, Time] = (Weekday.SUNDAY, Time.MAX),
442
+ ) -> bool:
443
+ """Check if a datetime is in the weekend."""
444
+ weekday, time = date_time.date().day_of_week(), date_time.time()
445
+ start_weekday, start_time = start
446
+ end_weekday, end_time = end
447
+ if start_weekday.value == end_weekday.value:
448
+ return start_time <= time <= end_time
449
+ if start_weekday.value < end_weekday.value:
450
+ return (
451
+ ((weekday == start_weekday) and (time >= start_time))
452
+ or (start_weekday.value < weekday.value < end_weekday.value)
453
+ or ((weekday == end_weekday) and (time <= end_time))
454
+ )
455
+ return (
456
+ ((weekday == start_weekday) and (time >= start_time))
457
+ or (weekday.value > start_weekday.value)
458
+ or (weekday.value < end_weekday.value)
459
+ or ((weekday == end_weekday) and (time <= end_time))
460
+ )
461
+
462
+
463
+ ##
464
+
465
+
282
466
  def mean_datetime(
283
467
  datetimes: Iterable[ZonedDateTime],
284
468
  /,
@@ -316,20 +500,16 @@ def min_max_date(
316
500
  max_age: DateDelta | None = None,
317
501
  time_zone: TimeZoneLike = UTC,
318
502
  ) -> tuple[Date | None, Date | None]:
319
- """Ucompute the min/max date given a combination of dates/ages."""
320
- today = get_today(time_zone=time_zone)
503
+ """Compute the min/max date given a combination of dates/ages."""
504
+ today = get_today(time_zone)
321
505
  min_parts: list[Date] = []
322
506
  if min_date is not None:
323
- if min_date > today:
324
- raise _MinMaxDateMinDateError(min_date=min_date, today=today)
325
507
  min_parts.append(min_date)
326
508
  if max_age is not None:
327
509
  min_parts.append(today - max_age)
328
510
  min_date_use = max(min_parts, default=None)
329
511
  max_parts: list[Date] = []
330
512
  if max_date is not None:
331
- if max_date > today:
332
- raise _MinMaxDateMaxDateError(max_date=max_date, today=today)
333
513
  max_parts.append(max_date)
334
514
  if min_age is not None:
335
515
  max_parts.append(today - min_age)
@@ -344,34 +524,13 @@ def min_max_date(
344
524
 
345
525
 
346
526
  @dataclass(kw_only=True, slots=True)
347
- class MinMaxDateError(Exception): ...
348
-
349
-
350
- @dataclass(kw_only=True, slots=True)
351
- class _MinMaxDateMinDateError(MinMaxDateError):
527
+ class MinMaxDateError(Exception):
352
528
  min_date: Date
353
- today: Date
354
-
355
- @override
356
- def __str__(self) -> str:
357
- return f"Min date must be at most today; got {self.min_date} > {self.today}"
358
-
359
-
360
- @dataclass(kw_only=True, slots=True)
361
- class _MinMaxDateMaxDateError(MinMaxDateError):
362
529
  max_date: Date
363
- today: Date
364
-
365
- @override
366
- def __str__(self) -> str:
367
- return f"Max date must be at most today; got {self.max_date} > {self.today}"
368
530
 
369
531
 
370
532
  @dataclass(kw_only=True, slots=True)
371
533
  class _MinMaxDatePeriodError(MinMaxDateError):
372
- min_date: Date
373
- max_date: Date
374
-
375
534
  @override
376
535
  def __str__(self) -> str:
377
536
  return (
@@ -382,6 +541,18 @@ class _MinMaxDatePeriodError(MinMaxDateError):
382
541
  ##
383
542
 
384
543
 
544
+ class PeriodDict[T: Date | Time | ZonedDateTime | dt.date | dt.time | dt.datetime](
545
+ TypedDict
546
+ ):
547
+ """A period as a dictionary."""
548
+
549
+ start: T
550
+ end: T
551
+
552
+
553
+ ##
554
+
555
+
385
556
  type _RoundDateDailyUnit = Literal["W", "D"]
386
557
  type _RoundDateTimeUnit = Literal["H", "M", "S", "ms", "us", "ns"]
387
558
  type _RoundDateOrDateTimeUnit = _RoundDateDailyUnit | _RoundDateTimeUnit
@@ -422,7 +593,7 @@ def round_date_or_date_time[T: Date | PlainDateTime | ZonedDateTime](
422
593
  raise _RoundDateOrDateTimeDateTimeIntraDayWithWeekdayError(
423
594
  date_time=date_time, delta=delta, weekday=weekday
424
595
  )
425
- case _ as never:
596
+ case never:
426
597
  assert_never(never)
427
598
 
428
599
 
@@ -516,7 +687,7 @@ def _round_date_weekly_or_daily(
516
687
  return _round_date_daily(date, increment, mode=mode)
517
688
  case "D", Weekday():
518
689
  raise _RoundDateOrDateTimeDateWithWeekdayError(weekday=weekday)
519
- case _ as never:
690
+ case never:
520
691
  assert_never(never)
521
692
 
522
693
 
@@ -562,7 +733,7 @@ def _round_date_daily(
562
733
  threshold = increment // 2 + 1
563
734
  case "half_ceil":
564
735
  threshold = increment // 2 or 1
565
- case _ as never:
736
+ case never:
566
737
  assert_never(never)
567
738
  round_up = remainder >= threshold
568
739
  return base.add(days=(quotient + round_up) * increment)
@@ -589,7 +760,7 @@ def _round_date_time_intraday[T: PlainDateTime | ZonedDateTime](
589
760
  unit_use = "microsecond"
590
761
  case "ns":
591
762
  unit_use = "nanosecond"
592
- case _ as never:
763
+ case never:
593
764
  assert_never(never)
594
765
  return date_time.round(unit_use, increment=increment, mode=mode)
595
766
 
@@ -667,28 +838,96 @@ class _RoundDateOrDateTimeDateTimeIntraDayWithWeekdayError(RoundDateOrDateTimeEr
667
838
  ##
668
839
 
669
840
 
841
+ @dataclass(repr=False, order=True, unsafe_hash=True, kw_only=False)
842
+ class TimePeriod:
843
+ """A period of times."""
844
+
845
+ start: Time
846
+ end: Time
847
+
848
+ @override
849
+ def __repr__(self) -> str:
850
+ cls = get_class_name(self)
851
+ return f"{cls}({self.start}, {self.end})"
852
+
853
+ def at(
854
+ self, obj: Date | tuple[Date, Date], /, *, time_zone: TimeZoneLike = UTC
855
+ ) -> ZonedDateTimePeriod:
856
+ """Combine a date with a time to create a datetime."""
857
+ match obj:
858
+ case Date() as date:
859
+ start = end = date
860
+ case Date() as start, Date() as end:
861
+ ...
862
+ case never:
863
+ assert_never(never)
864
+ return DatePeriod(start, end).at((self.start, self.end), time_zone=time_zone)
865
+
866
+ @classmethod
867
+ def from_dict(cls, mapping: PeriodDict[Time] | PeriodDict[dt.time], /) -> Self:
868
+ """Convert the dictionary to a period."""
869
+ match mapping["start"]:
870
+ case Time() as start:
871
+ ...
872
+ case dt.time() as py_time:
873
+ start = Time.from_py_time(py_time)
874
+ case never:
875
+ assert_never(never)
876
+ match mapping["end"]:
877
+ case Time() as end:
878
+ ...
879
+ case dt.time() as py_time:
880
+ end = Time.from_py_time(py_time)
881
+ case never:
882
+ assert_never(never)
883
+ return cls(start=start, end=end)
884
+
885
+ def replace(
886
+ self, *, start: Time | Sentinel = sentinel, end: Time | Sentinel = sentinel
887
+ ) -> Self:
888
+ """Replace elements of the period."""
889
+ return replace_non_sentinel(self, start=start, end=end)
890
+
891
+ def to_dict(self) -> PeriodDict[Time]:
892
+ """Convert the period to a dictionary."""
893
+ return PeriodDict(start=self.start, end=self.end)
894
+
895
+ def to_py_dict(self) -> PeriodDict[dt.time]:
896
+ """Convert the period to a dictionary."""
897
+ return PeriodDict(start=self.start.py_time(), end=self.end.py_time())
898
+
899
+
900
+ ##
901
+
902
+
670
903
  @overload
671
- def to_date(*, date: MaybeCallableDate) -> Date: ...
672
- @overload
673
- def to_date(*, date: None) -> None: ...
674
- @overload
675
- def to_date(*, date: Sentinel) -> Sentinel: ...
676
- @overload
677
- def to_date(*, date: MaybeCallableDate | Sentinel) -> Date | Sentinel: ...
904
+ def to_date(date: Sentinel, /, *, time_zone: TimeZoneLike = UTC) -> Sentinel: ...
678
905
  @overload
679
906
  def to_date(
680
- *, date: MaybeCallableDate | None | Sentinel = sentinel
681
- ) -> Date | None | Sentinel: ...
907
+ date: MaybeCallableDateLike | None | dt.date = get_today,
908
+ /,
909
+ *,
910
+ time_zone: TimeZoneLike = UTC,
911
+ ) -> Date: ...
682
912
  def to_date(
683
- *, date: MaybeCallableDate | None | Sentinel = sentinel
684
- ) -> Date | None | Sentinel:
685
- """Get the date."""
913
+ date: MaybeCallableDateLike | dt.date | None | Sentinel = get_today,
914
+ /,
915
+ *,
916
+ time_zone: TimeZoneLike = UTC,
917
+ ) -> Date | Sentinel:
918
+ """Convert to a date."""
686
919
  match date:
687
- case Date() | None | Sentinel():
920
+ case Date() | Sentinel():
688
921
  return date
922
+ case None:
923
+ return get_today(time_zone)
924
+ case str():
925
+ return Date.parse_iso(date)
926
+ case dt.date():
927
+ return Date.from_py_date(date)
689
928
  case Callable() as func:
690
- return to_date(date=func())
691
- case _ as never:
929
+ return to_date(func(), time_zone=time_zone)
930
+ case never:
692
931
  assert_never(never)
693
932
 
694
933
 
@@ -755,7 +994,7 @@ def to_days(delta: Delta, /) -> int:
755
994
  raise _ToDaysNanosecondsError(
756
995
  delta=delta, nanoseconds=error.nanoseconds
757
996
  ) from None
758
- case _ as never:
997
+ case never:
759
998
  assert_never(never)
760
999
 
761
1000
 
@@ -811,7 +1050,7 @@ def to_hours(delta: Delta, /) -> int:
811
1050
  raise _ToHoursNanosecondsError(
812
1051
  delta=delta, nanoseconds=error.nanoseconds
813
1052
  ) from None
814
- case _ as never:
1053
+ case never:
815
1054
  assert_never(never)
816
1055
 
817
1056
 
@@ -842,14 +1081,6 @@ class _ToHoursNanosecondsError(ToHoursError):
842
1081
  ##
843
1082
 
844
1083
 
845
- def to_local_plain(date_time: ZonedDateTime, /) -> PlainDateTime:
846
- """Convert a datetime to its local/plain variant."""
847
- return date_time.to_tz(LOCAL_TIME_ZONE_NAME).to_plain()
848
-
849
-
850
- ##
851
-
852
-
853
1084
  def to_microseconds(delta: Delta, /) -> int:
854
1085
  """Compute the number of microseconds in a delta."""
855
1086
  match delta:
@@ -882,7 +1113,7 @@ def to_microseconds(delta: Delta, /) -> int:
882
1113
  raise _ToMicrosecondsNanosecondsError(
883
1114
  delta=delta, nanoseconds=error.nanoseconds
884
1115
  ) from None
885
- case _ as never:
1116
+ case never:
886
1117
  assert_never(never)
887
1118
 
888
1119
 
@@ -945,7 +1176,7 @@ def to_milliseconds(delta: Delta, /) -> int:
945
1176
  raise _ToMillisecondsNanosecondsError(
946
1177
  delta=delta, nanoseconds=error.nanoseconds
947
1178
  ) from None
948
- case _ as never:
1179
+ case never:
949
1180
  assert_never(never)
950
1181
 
951
1182
 
@@ -1000,7 +1231,7 @@ def to_minutes(delta: Delta, /) -> int:
1000
1231
  raise _ToMinutesNanosecondsError(
1001
1232
  delta=delta, nanoseconds=error.nanoseconds
1002
1233
  ) from None
1003
- case _ as never:
1234
+ case never:
1004
1235
  assert_never(never)
1005
1236
 
1006
1237
 
@@ -1046,7 +1277,7 @@ def to_months(delta: DateOrDateTimeDelta, /) -> int:
1046
1277
  return to_months(delta.date_part())
1047
1278
  except _ToMonthsDaysError as error:
1048
1279
  raise _ToMonthsDaysError(delta=delta, days=error.days) from None
1049
- case _ as never:
1280
+ case never:
1050
1281
  assert_never(never)
1051
1282
 
1052
1283
 
@@ -1085,7 +1316,7 @@ def to_months_and_days(delta: DateOrDateTimeDelta, /) -> tuple[int, int]:
1085
1316
  if delta.time_part() != TimeDelta():
1086
1317
  raise ToMonthsAndDaysError(delta=delta)
1087
1318
  return to_months_and_days(delta.date_part())
1088
- case _ as never:
1319
+ case never:
1089
1320
  assert_never(never)
1090
1321
 
1091
1322
 
@@ -1119,7 +1350,7 @@ def to_nanoseconds(delta: Delta, /) -> int:
1119
1350
  )
1120
1351
  except ToNanosecondsError as error:
1121
1352
  raise ToNanosecondsError(delta=delta, months=error.months) from None
1122
- case _ as never:
1353
+ case never:
1123
1354
  assert_never(never)
1124
1355
 
1125
1356
 
@@ -1153,7 +1384,7 @@ def to_py_date_or_date_time(
1153
1384
  return date_time.py_datetime()
1154
1385
  case None:
1155
1386
  return None
1156
- case _ as never:
1387
+ case never:
1157
1388
  assert_never(never)
1158
1389
 
1159
1390
 
@@ -1181,7 +1412,7 @@ def to_py_time_delta(delta: Delta | None, /) -> dt.timedelta | None:
1181
1412
  )
1182
1413
  case None:
1183
1414
  return None
1184
- case _ as never:
1415
+ case never:
1185
1416
  assert_never(never)
1186
1417
 
1187
1418
 
@@ -1197,6 +1428,95 @@ class ToPyTimeDeltaError(Exception):
1197
1428
  ##
1198
1429
 
1199
1430
 
1431
+ def to_seconds(delta: Delta, /) -> int:
1432
+ """Compute the number of seconds in a delta."""
1433
+ match delta:
1434
+ case DateDelta():
1435
+ try:
1436
+ days = to_days(delta)
1437
+ except _ToDaysMonthsError as error:
1438
+ raise _ToSecondsMonthsError(delta=delta, months=error.months) from None
1439
+ return 24 * 60 * 60 * days
1440
+ case TimeDelta():
1441
+ nanos = to_nanoseconds(delta)
1442
+ seconds, remainder = divmod(nanos, int(1e9))
1443
+ if remainder != 0:
1444
+ raise _ToSecondsNanosecondsError(delta=delta, nanoseconds=remainder)
1445
+ return seconds
1446
+ case DateTimeDelta():
1447
+ try:
1448
+ return to_seconds(delta.date_part()) + to_seconds(delta.time_part())
1449
+ except _ToSecondsMonthsError as error:
1450
+ raise _ToSecondsMonthsError(delta=delta, months=error.months) from None
1451
+ except _ToSecondsNanosecondsError as error:
1452
+ raise _ToSecondsNanosecondsError(
1453
+ delta=delta, nanoseconds=error.nanoseconds
1454
+ ) from None
1455
+ case never:
1456
+ assert_never(never)
1457
+
1458
+
1459
+ @dataclass(kw_only=True, slots=True)
1460
+ class ToSecondsError(Exception): ...
1461
+
1462
+
1463
+ @dataclass(kw_only=True, slots=True)
1464
+ class _ToSecondsMonthsError(ToSecondsError):
1465
+ delta: DateOrDateTimeDelta
1466
+ months: int
1467
+
1468
+ @override
1469
+ def __str__(self) -> str:
1470
+ return f"Delta must not contain months; got {self.months}"
1471
+
1472
+
1473
+ @dataclass(kw_only=True, slots=True)
1474
+ class _ToSecondsNanosecondsError(ToSecondsError):
1475
+ delta: TimeOrDateTimeDelta
1476
+ nanoseconds: int
1477
+
1478
+ @override
1479
+ def __str__(self) -> str:
1480
+ return f"Delta must not contain extra nanoseconds; got {self.nanoseconds}"
1481
+
1482
+
1483
+ ##
1484
+
1485
+
1486
+ @overload
1487
+ def to_time(time: Sentinel, /, *, time_zone: TimeZoneLike = UTC) -> Sentinel: ...
1488
+ @overload
1489
+ def to_time(
1490
+ time: MaybeCallableTimeLike | None | dt.time = get_time,
1491
+ /,
1492
+ *,
1493
+ time_zone: TimeZoneLike = UTC,
1494
+ ) -> Time: ...
1495
+ def to_time(
1496
+ time: MaybeCallableTimeLike | dt.time | None | Sentinel = get_time,
1497
+ /,
1498
+ *,
1499
+ time_zone: TimeZoneLike = UTC,
1500
+ ) -> Time | Sentinel:
1501
+ """Convert to a time."""
1502
+ match time:
1503
+ case Time() | Sentinel():
1504
+ return time
1505
+ case None:
1506
+ return get_time(time_zone)
1507
+ case str():
1508
+ return Time.parse_iso(time)
1509
+ case dt.time():
1510
+ return Time.from_py_time(time)
1511
+ case Callable() as func:
1512
+ return to_time(func(), time_zone=time_zone)
1513
+ case never:
1514
+ assert_never(never)
1515
+
1516
+
1517
+ ##
1518
+
1519
+
1200
1520
  def to_time_delta(nanos: int, /) -> TimeDelta:
1201
1521
  """Construct a time delta."""
1202
1522
  components = _to_time_delta_components(nanos)
@@ -1275,61 +1595,6 @@ def _to_time_delta_components(nanos: int, /) -> _TimeDeltaComponents:
1275
1595
  ##
1276
1596
 
1277
1597
 
1278
- def to_seconds(delta: Delta, /) -> int:
1279
- """Compute the number of seconds in a delta."""
1280
- match delta:
1281
- case DateDelta():
1282
- try:
1283
- days = to_days(delta)
1284
- except _ToDaysMonthsError as error:
1285
- raise _ToSecondsMonthsError(delta=delta, months=error.months) from None
1286
- return 24 * 60 * 60 * days
1287
- case TimeDelta():
1288
- nanos = to_nanoseconds(delta)
1289
- seconds, remainder = divmod(nanos, int(1e9))
1290
- if remainder != 0:
1291
- raise _ToSecondsNanosecondsError(delta=delta, nanoseconds=remainder)
1292
- return seconds
1293
- case DateTimeDelta():
1294
- try:
1295
- return to_seconds(delta.date_part()) + to_seconds(delta.time_part())
1296
- except _ToSecondsMonthsError as error:
1297
- raise _ToSecondsMonthsError(delta=delta, months=error.months) from None
1298
- except _ToSecondsNanosecondsError as error:
1299
- raise _ToSecondsNanosecondsError(
1300
- delta=delta, nanoseconds=error.nanoseconds
1301
- ) from None
1302
- case _ as never:
1303
- assert_never(never)
1304
-
1305
-
1306
- @dataclass(kw_only=True, slots=True)
1307
- class ToSecondsError(Exception): ...
1308
-
1309
-
1310
- @dataclass(kw_only=True, slots=True)
1311
- class _ToSecondsMonthsError(ToSecondsError):
1312
- delta: DateOrDateTimeDelta
1313
- months: int
1314
-
1315
- @override
1316
- def __str__(self) -> str:
1317
- return f"Delta must not contain months; got {self.months}"
1318
-
1319
-
1320
- @dataclass(kw_only=True, slots=True)
1321
- class _ToSecondsNanosecondsError(ToSecondsError):
1322
- delta: TimeOrDateTimeDelta
1323
- nanoseconds: int
1324
-
1325
- @override
1326
- def __str__(self) -> str:
1327
- return f"Delta must not contain extra nanoseconds; got {self.nanoseconds}"
1328
-
1329
-
1330
- ##
1331
-
1332
-
1333
1598
  def to_weeks(delta: Delta, /) -> int:
1334
1599
  """Compute the number of weeks in a delta."""
1335
1600
  try:
@@ -1402,7 +1667,7 @@ def to_years(delta: DateOrDateTimeDelta, /) -> int:
1402
1667
  raise _ToYearsMonthsError(delta=delta, months=error.months) from None
1403
1668
  except _ToYearsDaysError as error:
1404
1669
  raise _ToYearsDaysError(delta=delta, days=error.days) from None
1405
- case _ as never:
1670
+ case never:
1406
1671
  assert_never(never)
1407
1672
 
1408
1673
 
@@ -1443,22 +1708,56 @@ class _ToYearsTimeError(ToYearsError):
1443
1708
 
1444
1709
 
1445
1710
  @overload
1446
- def to_zoned_date_time(*, date_time: MaybeCallableZonedDateTime) -> ZonedDateTime: ...
1447
- @overload
1448
- def to_zoned_date_time(*, date_time: None) -> None: ...
1711
+ def to_zoned_date_time(
1712
+ date_time: Sentinel, /, *, time_zone: TimeZoneLike | None = None
1713
+ ) -> Sentinel: ...
1449
1714
  @overload
1450
- def to_zoned_date_time(*, date_time: Sentinel) -> Sentinel: ...
1451
1715
  def to_zoned_date_time(
1452
- *, date_time: MaybeCallableZonedDateTime | None | Sentinel = sentinel
1453
- ) -> ZonedDateTime | None | Sentinel:
1454
- """Resolve into a zoned date_time."""
1716
+ date_time: MaybeCallableZonedDateTimeLike | dt.datetime | None = get_now,
1717
+ /,
1718
+ *,
1719
+ time_zone: TimeZoneLike | None = None,
1720
+ ) -> ZonedDateTime: ...
1721
+ def to_zoned_date_time(
1722
+ date_time: MaybeCallableZonedDateTimeLike | dt.datetime | None | Sentinel = get_now,
1723
+ /,
1724
+ *,
1725
+ time_zone: TimeZoneLike | None = None,
1726
+ ) -> ZonedDateTime | Sentinel:
1727
+ """Convert to a zoned date-time."""
1455
1728
  match date_time:
1456
- case ZonedDateTime() | None | Sentinel():
1457
- return date_time
1729
+ case ZonedDateTime() as date_time_use:
1730
+ ...
1731
+ case Sentinel():
1732
+ return sentinel
1733
+ case None:
1734
+ return get_now(UTC if time_zone is None else time_zone)
1735
+ case str() as text:
1736
+ date_time_use = ZonedDateTime.parse_iso(text.replace("~", "/"))
1737
+ case dt.datetime() as py_date_time:
1738
+ if isinstance(date_time.tzinfo, ZoneInfo):
1739
+ py_date_time_use = py_date_time
1740
+ elif date_time.tzinfo is dt.UTC:
1741
+ py_date_time_use = py_date_time.astimezone(UTC)
1742
+ else:
1743
+ raise ToZonedDateTimeError(date_time=date_time)
1744
+ date_time_use = ZonedDateTime.from_py_datetime(py_date_time_use)
1458
1745
  case Callable() as func:
1459
- return to_zoned_date_time(date_time=func())
1460
- case _ as never:
1746
+ return to_zoned_date_time(func(), time_zone=time_zone)
1747
+ case never:
1461
1748
  assert_never(never)
1749
+ if time_zone is None:
1750
+ return date_time_use
1751
+ return date_time_use.to_tz(to_time_zone_name(time_zone))
1752
+
1753
+
1754
+ @dataclass(kw_only=True, slots=True)
1755
+ class ToZonedDateTimeError(Exception):
1756
+ date_time: dt.datetime
1757
+
1758
+ @override
1759
+ def __str__(self) -> str:
1760
+ return f"Expected date-time to have a `ZoneInfo` or `dt.UTC` as its timezone; got {self.date_time.tzinfo}"
1462
1761
 
1463
1762
 
1464
1763
  ##
@@ -1498,32 +1797,203 @@ class WheneverLogRecord(LogRecord):
1498
1797
  name, level, pathname, lineno, msg, args, exc_info, func, sinfo
1499
1798
  )
1500
1799
  length = self._get_length()
1501
- plain = format(get_now_local().to_plain().format_common_iso(), f"{length}s")
1502
- time_zone = self._get_time_zone_key()
1503
- self.zoned_datetime = f"{plain}[{time_zone}]"
1504
-
1505
- @classmethod
1506
- @cache
1507
- def _get_time_zone(cls) -> ZoneInfo:
1508
- """Get the local timezone."""
1509
- try:
1510
- from utilities.tzlocal import get_local_time_zone
1511
- except ModuleNotFoundError: # pragma: no cover
1512
- return UTC
1513
- return get_local_time_zone()
1514
-
1515
- @classmethod
1516
- @cache
1517
- def _get_time_zone_key(cls) -> str:
1518
- """Get the local timezone as a string."""
1519
- return cls._get_time_zone().key
1800
+ plain = format(get_now_local().to_plain().format_iso(), f"{length}s")
1801
+ self.zoned_datetime = f"{plain}[{LOCAL_TIME_ZONE_NAME}]"
1520
1802
 
1521
1803
  @classmethod
1522
1804
  @cache
1523
1805
  def _get_length(cls) -> int:
1524
1806
  """Get maximum length of a formatted string."""
1525
1807
  now = get_now_local().replace(nanosecond=1000).to_plain()
1526
- return len(now.format_common_iso())
1808
+ return len(now.format_iso())
1809
+
1810
+
1811
+ ##
1812
+
1813
+
1814
+ @dataclass(repr=False, order=True, unsafe_hash=True, kw_only=False)
1815
+ class ZonedDateTimePeriod:
1816
+ """A period of time."""
1817
+
1818
+ start: ZonedDateTime
1819
+ end: ZonedDateTime
1820
+
1821
+ def __post_init__(self) -> None:
1822
+ if self.start > self.end:
1823
+ raise _ZonedDateTimePeriodInvalidError(start=self.start, end=self.end)
1824
+ if self.start.tz != self.end.tz:
1825
+ raise _ZonedDateTimePeriodTimeZoneError(
1826
+ start=ZoneInfo(self.start.tz), end=ZoneInfo(self.end.tz)
1827
+ )
1828
+
1829
+ def __add__(self, other: TimeDelta, /) -> Self:
1830
+ """Offset the period."""
1831
+ return self.replace(start=self.start + other, end=self.end + other)
1832
+
1833
+ def __contains__(self, other: ZonedDateTime, /) -> bool:
1834
+ """Check if a date/datetime lies in the period."""
1835
+ return self.start <= other <= self.end
1836
+
1837
+ @override
1838
+ def __repr__(self) -> str:
1839
+ cls = get_class_name(self)
1840
+ return f"{cls}({self.start.to_plain()}, {self.end.to_plain()}[{self.time_zone.key}])"
1841
+
1842
+ def __sub__(self, other: TimeDelta, /) -> Self:
1843
+ """Offset the period."""
1844
+ return self.replace(start=self.start - other, end=self.end - other)
1845
+
1846
+ @property
1847
+ def delta(self) -> TimeDelta:
1848
+ """The duration of the period."""
1849
+ return self.end - self.start
1850
+
1851
+ @overload
1852
+ def exact_eq(self, period: ZonedDateTimePeriod, /) -> bool: ...
1853
+ @overload
1854
+ def exact_eq(self, start: ZonedDateTime, end: ZonedDateTime, /) -> bool: ...
1855
+ @overload
1856
+ def exact_eq(
1857
+ self, start: PlainDateTime, end: PlainDateTime, time_zone: ZoneInfo, /
1858
+ ) -> bool: ...
1859
+ def exact_eq(self, *args: Any) -> bool:
1860
+ """Check if a period is exactly equal to another."""
1861
+ if (len(args) == 1) and isinstance(args[0], ZonedDateTimePeriod):
1862
+ return self.start.exact_eq(args[0].start) and self.end.exact_eq(args[0].end)
1863
+ if (
1864
+ (len(args) == 2)
1865
+ and isinstance(args[0], ZonedDateTime)
1866
+ and isinstance(args[1], ZonedDateTime)
1867
+ ):
1868
+ return self.exact_eq(ZonedDateTimePeriod(args[0], args[1]))
1869
+ if (
1870
+ (len(args) == 3)
1871
+ and isinstance(args[0], PlainDateTime)
1872
+ and isinstance(args[1], PlainDateTime)
1873
+ and isinstance(args[2], ZoneInfo)
1874
+ ):
1875
+ return self.exact_eq(
1876
+ ZonedDateTimePeriod(
1877
+ args[0].assume_tz(args[2].key), args[1].assume_tz(args[2].key)
1878
+ )
1879
+ )
1880
+ raise _ZonedDateTimePeriodExactEqError(args=args)
1881
+
1882
+ def format_compact(self) -> str:
1883
+ """Format the period in a compact fashion."""
1884
+ fc, start, end = format_compact, self.start, self.end
1885
+ if start == end:
1886
+ if end.second != 0:
1887
+ return f"{fc(start)}="
1888
+ if end.minute != 0:
1889
+ return f"{fc(start, fmt='%Y%m%dT%H%M')}="
1890
+ return f"{fc(start, fmt='%Y%m%dT%H')}="
1891
+ if start.date() == end.date():
1892
+ if end.second != 0:
1893
+ return f"{fc(start.to_plain())}-{fc(end, fmt='%H%M%S')}"
1894
+ if end.minute != 0:
1895
+ return f"{fc(start.to_plain())}-{fc(end, fmt='%H%M')}"
1896
+ return f"{fc(start.to_plain())}-{fc(end, fmt='%H')}"
1897
+ if start.date().year_month() == end.date().year_month():
1898
+ if end.second != 0:
1899
+ return f"{fc(start.to_plain())}-{fc(end, fmt='%dT%H%M%S')}"
1900
+ if end.minute != 0:
1901
+ return f"{fc(start.to_plain())}-{fc(end, fmt='%dT%H%M')}"
1902
+ return f"{fc(start.to_plain())}-{fc(end, fmt='%dT%H')}"
1903
+ if start.year == end.year:
1904
+ if end.second != 0:
1905
+ return f"{fc(start.to_plain())}-{fc(end, fmt='%m%dT%H%M%S')}"
1906
+ if end.minute != 0:
1907
+ return f"{fc(start.to_plain())}-{fc(end, fmt='%m%dT%H%M')}"
1908
+ return f"{fc(start.to_plain())}-{fc(end, fmt='%m%dT%H')}"
1909
+ if end.second != 0:
1910
+ return f"{fc(start.to_plain())}-{fc(end)}"
1911
+ if end.minute != 0:
1912
+ return f"{fc(start.to_plain())}-{fc(end, fmt='%Y%m%dT%H%M')}"
1913
+ return f"{fc(start.to_plain())}-{fc(end, fmt='%Y%m%dT%H')}"
1914
+
1915
+ @classmethod
1916
+ def from_dict(
1917
+ cls, mapping: PeriodDict[ZonedDateTime] | PeriodDict[dt.datetime], /
1918
+ ) -> Self:
1919
+ """Convert the dictionary to a period."""
1920
+ match mapping["start"]:
1921
+ case ZonedDateTime() as start:
1922
+ ...
1923
+ case dt.date() as py_datetime:
1924
+ start = ZonedDateTime.from_py_datetime(py_datetime)
1925
+ case never:
1926
+ assert_never(never)
1927
+ match mapping["end"]:
1928
+ case ZonedDateTime() as end:
1929
+ ...
1930
+ case dt.date() as py_datetime:
1931
+ end = ZonedDateTime.from_py_datetime(py_datetime)
1932
+ case never:
1933
+ assert_never(never)
1934
+ return cls(start=start, end=end)
1935
+
1936
+ def replace(
1937
+ self,
1938
+ *,
1939
+ start: ZonedDateTime | Sentinel = sentinel,
1940
+ end: ZonedDateTime | Sentinel = sentinel,
1941
+ ) -> Self:
1942
+ """Replace elements of the period."""
1943
+ return replace_non_sentinel(self, start=start, end=end)
1944
+
1945
+ @property
1946
+ def time_zone(self) -> ZoneInfo:
1947
+ """The time zone of the period."""
1948
+ return ZoneInfo(self.start.tz)
1949
+
1950
+ def to_dict(self) -> PeriodDict[ZonedDateTime]:
1951
+ """Convert the period to a dictionary."""
1952
+ return PeriodDict(start=self.start, end=self.end)
1953
+
1954
+ def to_py_dict(self) -> PeriodDict[dt.datetime]:
1955
+ """Convert the period to a dictionary."""
1956
+ return PeriodDict(start=self.start.py_datetime(), end=self.end.py_datetime())
1957
+
1958
+ def to_tz(self, time_zone: TimeZoneLike, /) -> Self:
1959
+ """Convert the time zone."""
1960
+ tz = to_time_zone_name(time_zone)
1961
+ return self.replace(start=self.start.to_tz(tz), end=self.end.to_tz(tz))
1962
+
1963
+
1964
+ @dataclass(kw_only=True, slots=True)
1965
+ class ZonedDateTimePeriodError(Exception): ...
1966
+
1967
+
1968
+ @dataclass(kw_only=True, slots=True)
1969
+ class _ZonedDateTimePeriodInvalidError[T: Date | ZonedDateTime](
1970
+ ZonedDateTimePeriodError
1971
+ ):
1972
+ start: T
1973
+ end: T
1974
+
1975
+ @override
1976
+ def __str__(self) -> str:
1977
+ return f"Invalid period; got {self.start} > {self.end}"
1978
+
1979
+
1980
+ @dataclass(kw_only=True, slots=True)
1981
+ class _ZonedDateTimePeriodTimeZoneError(ZonedDateTimePeriodError):
1982
+ start: ZoneInfo
1983
+ end: ZoneInfo
1984
+
1985
+ @override
1986
+ def __str__(self) -> str:
1987
+ return f"Period must contain exactly one time zone; got {self.start} and {self.end}"
1988
+
1989
+
1990
+ @dataclass(kw_only=True, slots=True)
1991
+ class _ZonedDateTimePeriodExactEqError(ZonedDateTimePeriodError):
1992
+ args: tuple[Any, ...]
1993
+
1994
+ @override
1995
+ def __str__(self) -> str:
1996
+ return f"Invalid arguments; got {self.args}"
1527
1997
 
1528
1998
 
1529
1999
  __all__ = [
@@ -1544,9 +2014,13 @@ __all__ = [
1544
2014
  "MINUTE",
1545
2015
  "MONTH",
1546
2016
  "NOW_LOCAL",
2017
+ "NOW_LOCAL_PLAIN",
2018
+ "NOW_PLAIN",
1547
2019
  "SECOND",
1548
2020
  "TIME_DELTA_MAX",
1549
2021
  "TIME_DELTA_MIN",
2022
+ "TIME_LOCAL",
2023
+ "TIME_UTC",
1550
2024
  "TODAY_LOCAL",
1551
2025
  "TODAY_UTC",
1552
2026
  "WEEK",
@@ -1555,9 +2029,13 @@ __all__ = [
1555
2029
  "ZERO_TIME",
1556
2030
  "ZONED_DATE_TIME_MAX",
1557
2031
  "ZONED_DATE_TIME_MIN",
2032
+ "DatePeriod",
2033
+ "DatePeriodError",
1558
2034
  "MeanDateTimeError",
1559
2035
  "MinMaxDateError",
2036
+ "PeriodDict",
1560
2037
  "RoundDateOrDateTimeError",
2038
+ "TimePeriod",
1561
2039
  "ToDaysError",
1562
2040
  "ToMinutesError",
1563
2041
  "ToMonthsAndDaysError",
@@ -1568,6 +2046,8 @@ __all__ = [
1568
2046
  "ToWeeksError",
1569
2047
  "ToYearsError",
1570
2048
  "WheneverLogRecord",
2049
+ "ZonedDateTimePeriod",
2050
+ "ZonedDateTimePeriodError",
1571
2051
  "add_year_month",
1572
2052
  "datetime_utc",
1573
2053
  "diff_year_month",
@@ -1577,8 +2057,13 @@ __all__ = [
1577
2057
  "from_timestamp_nanos",
1578
2058
  "get_now",
1579
2059
  "get_now_local",
2060
+ "get_now_local_plain",
2061
+ "get_now_plain",
2062
+ "get_time",
2063
+ "get_time_local",
1580
2064
  "get_today",
1581
2065
  "get_today_local",
2066
+ "is_weekend",
1582
2067
  "mean_datetime",
1583
2068
  "min_max_date",
1584
2069
  "round_date_or_date_time",
@@ -1586,7 +2071,6 @@ __all__ = [
1586
2071
  "to_date",
1587
2072
  "to_date_time_delta",
1588
2073
  "to_days",
1589
- "to_local_plain",
1590
2074
  "to_microseconds",
1591
2075
  "to_milliseconds",
1592
2076
  "to_minutes",
@@ -1596,6 +2080,7 @@ __all__ = [
1596
2080
  "to_py_date_or_date_time",
1597
2081
  "to_py_time_delta",
1598
2082
  "to_seconds",
2083
+ "to_time",
1599
2084
  "to_weeks",
1600
2085
  "to_years",
1601
2086
  "to_zoned_date_time",