dycw-utilities 0.135.0__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 (97) 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.178.1.dist-info/entry_points.txt +4 -0
  5. utilities/__init__.py +1 -1
  6. utilities/altair.py +13 -10
  7. utilities/asyncio.py +312 -787
  8. utilities/atomicwrites.py +18 -6
  9. utilities/atools.py +64 -4
  10. utilities/cachetools.py +9 -6
  11. utilities/click.py +195 -77
  12. utilities/concurrent.py +1 -1
  13. utilities/contextlib.py +216 -17
  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 +28 -59
  21. utilities/fpdf2.py +2 -2
  22. utilities/functions.py +24 -269
  23. utilities/git.py +9 -30
  24. utilities/grp.py +28 -0
  25. utilities/gzip.py +31 -0
  26. utilities/http.py +3 -2
  27. utilities/hypothesis.py +513 -159
  28. utilities/importlib.py +17 -1
  29. utilities/inflect.py +12 -4
  30. utilities/iterables.py +33 -58
  31. utilities/jinja2.py +148 -0
  32. utilities/json.py +70 -0
  33. utilities/libcst.py +38 -17
  34. utilities/lightweight_charts.py +4 -7
  35. utilities/logging.py +136 -93
  36. utilities/math.py +8 -4
  37. utilities/more_itertools.py +43 -45
  38. utilities/operator.py +27 -27
  39. utilities/orjson.py +189 -36
  40. utilities/os.py +61 -4
  41. utilities/packaging.py +115 -0
  42. utilities/parse.py +8 -5
  43. utilities/pathlib.py +269 -40
  44. utilities/permissions.py +298 -0
  45. utilities/platform.py +7 -6
  46. utilities/polars.py +1205 -413
  47. utilities/polars_ols.py +1 -1
  48. utilities/postgres.py +408 -0
  49. utilities/pottery.py +43 -19
  50. utilities/pqdm.py +3 -3
  51. utilities/psutil.py +5 -57
  52. utilities/pwd.py +28 -0
  53. utilities/pydantic.py +4 -52
  54. utilities/pydantic_settings.py +240 -0
  55. utilities/pydantic_settings_sops.py +76 -0
  56. utilities/pyinstrument.py +7 -7
  57. utilities/pytest.py +104 -143
  58. utilities/pytest_plugins/__init__.py +1 -0
  59. utilities/pytest_plugins/pytest_randomly.py +23 -0
  60. utilities/pytest_plugins/pytest_regressions.py +56 -0
  61. utilities/pytest_regressions.py +26 -46
  62. utilities/random.py +11 -6
  63. utilities/re.py +1 -1
  64. utilities/redis.py +220 -343
  65. utilities/sentinel.py +10 -0
  66. utilities/shelve.py +4 -1
  67. utilities/shutil.py +25 -0
  68. utilities/slack_sdk.py +35 -104
  69. utilities/sqlalchemy.py +496 -471
  70. utilities/sqlalchemy_polars.py +29 -54
  71. utilities/string.py +2 -3
  72. utilities/subprocess.py +1977 -0
  73. utilities/tempfile.py +112 -4
  74. utilities/testbook.py +50 -0
  75. utilities/text.py +174 -42
  76. utilities/throttle.py +158 -0
  77. utilities/timer.py +2 -2
  78. utilities/traceback.py +70 -35
  79. utilities/types.py +102 -30
  80. utilities/typing.py +479 -19
  81. utilities/uuid.py +42 -5
  82. utilities/version.py +27 -26
  83. utilities/whenever.py +1559 -361
  84. utilities/zoneinfo.py +80 -22
  85. dycw_utilities-0.135.0.dist-info/METADATA +0 -39
  86. dycw_utilities-0.135.0.dist-info/RECORD +0 -96
  87. dycw_utilities-0.135.0.dist-info/WHEEL +0 -4
  88. dycw_utilities-0.135.0.dist-info/licenses/LICENSE +0 -21
  89. utilities/aiolimiter.py +0 -25
  90. utilities/arq.py +0 -216
  91. utilities/eventkit.py +0 -388
  92. utilities/luigi.py +0 -183
  93. utilities/period.py +0 -152
  94. utilities/pudb.py +0 -62
  95. utilities/python_dotenv.py +0 -101
  96. utilities/streamlit.py +0 -105
  97. utilities/typed_settings.py +0 -123
utilities/whenever.py CHANGED
@@ -1,63 +1,63 @@
1
1
  from __future__ import annotations
2
2
 
3
- from collections.abc import Callable, Iterable, Mapping
4
- from dataclasses import dataclass, replace
3
+ import datetime as dt
4
+ from collections.abc import Callable, Iterable
5
+ from dataclasses import dataclass
5
6
  from functools import cache
6
7
  from logging import LogRecord
7
8
  from statistics import fmean
8
9
  from typing import (
9
10
  TYPE_CHECKING,
10
11
  Any,
11
- ClassVar,
12
12
  Literal,
13
13
  Self,
14
14
  SupportsFloat,
15
+ TypedDict,
15
16
  assert_never,
16
17
  cast,
17
18
  overload,
18
19
  override,
19
20
  )
21
+ from zoneinfo import ZoneInfo
20
22
 
21
23
  from whenever import (
22
24
  Date,
23
25
  DateDelta,
24
26
  DateTimeDelta,
25
27
  PlainDateTime,
28
+ Time,
26
29
  TimeDelta,
30
+ Weekday,
31
+ YearMonth,
27
32
  ZonedDateTime,
28
33
  )
29
34
 
35
+ from utilities.dataclasses import replace_non_sentinel
36
+ from utilities.functions import get_class_name
30
37
  from utilities.math import sign
31
38
  from utilities.platform import get_strftime
32
- from utilities.re import ExtractGroupsError, extract_groups
33
39
  from utilities.sentinel import Sentinel, sentinel
34
- from utilities.types import DateTimeRoundUnit, MaybeStr
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
- MaybeCallableDate,
43
- MaybeCallableZonedDateTime,
45
+ DateOrDateTimeDelta,
46
+ DateTimeRoundMode,
47
+ Delta,
48
+ MaybeCallableDateLike,
49
+ MaybeCallableTimeLike,
50
+ MaybeCallableZonedDateTimeLike,
51
+ TimeOrDateTimeDelta,
44
52
  TimeZoneLike,
45
53
  )
46
54
 
47
55
 
48
- ## bounds
56
+ # bounds
49
57
 
50
58
 
51
- PLAIN_DATE_TIME_MIN = PlainDateTime(1, 1, 1)
52
- PLAIN_DATE_TIME_MAX = PlainDateTime(
53
- 9999, 12, 31, hour=23, minute=59, second=59, nanosecond=999999999
54
- )
55
- DATE_MIN = PLAIN_DATE_TIME_MIN.date()
56
- DATE_MAX = PLAIN_DATE_TIME_MAX.date()
57
- TIME_MIN = PLAIN_DATE_TIME_MIN.time()
58
- TIME_MAX = PLAIN_DATE_TIME_MIN.time()
59
- ZONED_DATE_TIME_MIN = PLAIN_DATE_TIME_MIN.assume_tz(UTC.key)
60
- ZONED_DATE_TIME_MAX = PLAIN_DATE_TIME_MAX.assume_tz(UTC.key)
59
+ ZONED_DATE_TIME_MIN = PlainDateTime.MIN.assume_tz(UTC.key)
60
+ ZONED_DATE_TIME_MAX = PlainDateTime.MAX.assume_tz(UTC.key)
61
61
 
62
62
 
63
63
  DATE_TIME_DELTA_MIN = DateTimeDelta(
@@ -131,6 +131,128 @@ YEAR = DateDelta(years=1)
131
131
  ##
132
132
 
133
133
 
134
+ def add_year_month(x: YearMonth, /, *, years: int = 0, months: int = 0) -> YearMonth:
135
+ """Add to a year-month."""
136
+ y = x.on_day(1) + DateDelta(years=years, months=months)
137
+ return y.year_month()
138
+
139
+
140
+ def sub_year_month(x: YearMonth, /, *, years: int = 0, months: int = 0) -> YearMonth:
141
+ """Subtract from a year-month."""
142
+ y = x.on_day(1) - DateDelta(years=years, months=months)
143
+ return y.year_month()
144
+
145
+
146
+ ##
147
+
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
+
134
256
  def datetime_utc(
135
257
  year: int,
136
258
  month: int,
@@ -160,184 +282,149 @@ def datetime_utc(
160
282
  ##
161
283
 
162
284
 
163
- def format_compact(datetime: ZonedDateTime, /) -> str:
164
- """Convert a zoned datetime to the local time zone, then format."""
165
- py_datetime = datetime.round().to_tz(LOCAL_TIME_ZONE_NAME).to_plain().py_datetime()
166
- return py_datetime.strftime(get_strftime("%Y%m%dT%H%M%S"))
285
+ @overload
286
+ def diff_year_month(
287
+ x: YearMonth, y: YearMonth, /, *, years: Literal[True]
288
+ ) -> tuple[int, int]: ...
289
+ @overload
290
+ def diff_year_month(
291
+ x: YearMonth, y: YearMonth, /, *, years: Literal[False] = False
292
+ ) -> int: ...
293
+ @overload
294
+ def diff_year_month(
295
+ x: YearMonth, y: YearMonth, /, *, years: bool = False
296
+ ) -> int | tuple[int, int]: ...
297
+ def diff_year_month(
298
+ x: YearMonth, y: YearMonth, /, *, years: bool = False
299
+ ) -> int | tuple[int, int]:
300
+ """Compute the difference between two year-months."""
301
+ x_date, y_date = x.on_day(1), y.on_day(1)
302
+ diff = x_date - y_date
303
+ if years:
304
+ yrs, mth, _ = diff.in_years_months_days()
305
+ return yrs, mth
306
+ mth, _ = diff.in_months_days()
307
+ return mth
167
308
 
168
309
 
169
310
  ##
170
311
 
171
312
 
172
- class Freq:
173
- """A rounding frequency."""
174
-
175
- unit: DateTimeRoundUnit
176
- increment: int
177
- _mapping: ClassVar[Mapping[DateTimeRoundUnit, _DateTimeRoundUnitAbbrev]] = {
178
- "day": "D",
179
- "hour": "H",
180
- "minute": "M",
181
- "second": "S",
182
- "millisecond": "ms",
183
- "microsecond": "us",
184
- "nanosecond": "ns",
185
- }
186
-
187
- def __init__(
188
- self, *, unit: DateTimeRoundUnit = "second", increment: int = 1
189
- ) -> None:
190
- super().__init__()
191
- if (unit == "day") and (increment != 1):
192
- raise _FreqDayIncrementError(increment=increment)
193
- if (unit == "hour") and not ((0 < increment < 24) and (24 % increment == 0)):
194
- raise _FreqIncrementError(unit=unit, increment=increment, divisor=24)
195
- if (unit in {"minute", "second"}) and not (
196
- (0 < increment < 60) and (60 % increment == 0)
197
- ):
198
- raise _FreqIncrementError(unit=unit, increment=increment, divisor=60)
199
- if (unit in {"millisecond", "microsecond", "nanosecond"}) and not (
200
- (0 < increment < 1000) and (1000 % increment == 0)
201
- ):
202
- raise _FreqIncrementError(unit=unit, increment=increment, divisor=1000)
203
- self.unit = unit
204
- self.increment = increment
205
-
206
- @override
207
- def __eq__(self, other: object, /) -> bool:
208
- if not isinstance(other, Freq):
209
- return NotImplemented
210
- return (self.unit == other.unit) and (self.increment == other.increment)
211
-
212
- @override
213
- def __hash__(self) -> int:
214
- return hash((self.unit, self.increment))
215
-
216
- @override
217
- def __repr__(self) -> str:
218
- return f"{type(self).__name__}(unit={self.unit!r}, increment={self.increment})"
313
+ def format_compact(
314
+ obj: Date | Time | PlainDateTime | ZonedDateTime,
315
+ /,
316
+ *,
317
+ fmt: str | None = None,
318
+ path: bool = False,
319
+ ) -> str:
320
+ """Format the date/datetime in a compact fashion."""
321
+ match obj:
322
+ case Date() as date:
323
+ obj_use = date.py_date()
324
+ fmt_use = "%Y%m%d" if fmt is None else fmt
325
+ case Time() as time:
326
+ obj_use = time.round().py_time()
327
+ fmt_use = "%H%M%S" if fmt is None else fmt
328
+ case PlainDateTime() as date_time:
329
+ obj_use = date_time.round().py_datetime()
330
+ fmt_use = "%Y%m%dT%H%M%S" if fmt is None else fmt
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:
338
+ assert_never(never)
339
+ return obj_use.strftime(get_strftime(fmt_use))
219
340
 
220
- @classmethod
221
- def parse(cls, text: str, /) -> Self:
222
- try:
223
- increment, abbrev = extract_groups(r"^(\d*)(D|H|M|S|ms|us|ns)$", text)
224
- except ExtractGroupsError:
225
- raise _FreqParseError(text=text) from None
226
- return cls(
227
- unit=cls._expand(cast("_DateTimeRoundUnitAbbrev", abbrev)),
228
- increment=int(increment) if len(increment) >= 1 else 1,
229
- )
230
341
 
231
- def serialize(self) -> str:
232
- if self.increment == 1:
233
- return self._abbreviation
234
- return f"{self.increment}{self._abbreviation}"
342
+ ##
235
343
 
236
- @classmethod
237
- def _abbreviate(cls, unit: DateTimeRoundUnit, /) -> _DateTimeRoundUnitAbbrev:
238
- return cls._mapping[unit]
239
344
 
240
- @property
241
- def _abbreviation(self) -> _DateTimeRoundUnitAbbrev:
242
- return self._mapping[self.unit]
345
+ def from_timestamp(i: float, /, *, time_zone: TimeZoneLike = UTC) -> ZonedDateTime:
346
+ """Get a zoned datetime from a timestamp."""
347
+ return ZonedDateTime.from_timestamp(i, tz=to_time_zone_name(time_zone))
243
348
 
244
- @classmethod
245
- def _expand(cls, unit: _DateTimeRoundUnitAbbrev, /) -> DateTimeRoundUnit:
246
- values: set[DateTimeRoundUnit] = {
247
- k for k, v in cls._mapping.items() if v == unit
248
- }
249
- (value,) = values
250
- return value
251
349
 
350
+ def from_timestamp_millis(i: int, /, *, time_zone: TimeZoneLike = UTC) -> ZonedDateTime:
351
+ """Get a zoned datetime from a timestamp (in milliseconds)."""
352
+ return ZonedDateTime.from_timestamp_millis(i, tz=to_time_zone_name(time_zone))
252
353
 
253
- type FreqLike = MaybeStr[Freq]
254
- type _DateTimeRoundUnitAbbrev = Literal["D", "H", "M", "S", "ms", "us", "ns"]
255
354
 
355
+ def from_timestamp_nanos(i: int, /, *, time_zone: TimeZoneLike = UTC) -> ZonedDateTime:
356
+ """Get a zoned datetime from a timestamp (in nanoseconds)."""
357
+ return ZonedDateTime.from_timestamp_nanos(i, tz=to_time_zone_name(time_zone))
256
358
 
257
- @dataclass(kw_only=True, slots=True)
258
- class FreqError(Exception): ...
259
359
 
360
+ ##
260
361
 
261
- @dataclass(kw_only=True, slots=True)
262
- class _FreqDayIncrementError(FreqError):
263
- increment: int
264
362
 
265
- @override
266
- def __str__(self) -> str:
267
- return f"Increment must be 1 for the 'day' unit; got {self.increment}"
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))
268
366
 
269
367
 
270
- @dataclass(kw_only=True, slots=True)
271
- class _FreqIncrementError(FreqError):
272
- unit: DateTimeRoundUnit
273
- increment: int
274
- divisor: int
368
+ NOW_UTC = get_now(UTC)
275
369
 
276
- @override
277
- def __str__(self) -> str:
278
- return f"Increment must be a proper divisor of {self.divisor} for the {self.unit!r} unit; got {self.increment}"
279
370
 
371
+ def get_now_local() -> ZonedDateTime:
372
+ """Get the current zoned date-time in the local time-zone."""
373
+ return get_now(LOCAL_TIME_ZONE)
280
374
 
281
- @dataclass(kw_only=True, slots=True)
282
- class _FreqParseError(FreqError):
283
- text: str
284
375
 
285
- @override
286
- def __str__(self) -> str:
287
- return f"Unable to parse frequency; got {self.text!r}"
376
+ NOW_LOCAL = get_now_local()
288
377
 
289
378
 
290
- ##
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()
291
382
 
292
383
 
293
- def from_timestamp(i: float, /, *, time_zone: TimeZoneLike = UTC) -> ZonedDateTime:
294
- """Get a zoned datetime from a timestamp."""
295
- return ZonedDateTime.from_timestamp(i, tz=get_time_zone_name(time_zone))
384
+ NOW_PLAIN = get_now_plain()
296
385
 
297
386
 
298
- def from_timestamp_millis(i: int, /, *, time_zone: TimeZoneLike = UTC) -> ZonedDateTime:
299
- """Get a zoned datetime from a timestamp (in milliseconds)."""
300
- return ZonedDateTime.from_timestamp_millis(i, tz=get_time_zone_name(time_zone))
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()
301
390
 
302
391
 
303
- def from_timestamp_nanos(i: int, /, *, time_zone: TimeZoneLike = UTC) -> ZonedDateTime:
304
- """Get a zoned datetime from a timestamp (in nanoseconds)."""
305
- return ZonedDateTime.from_timestamp_nanos(i, tz=get_time_zone_name(time_zone))
392
+ NOW_LOCAL_PLAIN = get_now_local_plain()
306
393
 
307
394
 
308
395
  ##
309
396
 
310
397
 
311
- def get_now(*, time_zone: TimeZoneLike = UTC) -> ZonedDateTime:
312
- """Get the current zoned datetime."""
313
- return ZonedDateTime.now(get_time_zone_name(time_zone))
398
+ def get_time(time_zone: TimeZoneLike = UTC, /) -> Time:
399
+ """Get the current time."""
400
+ return get_now(time_zone).time()
314
401
 
315
402
 
316
- NOW_UTC = get_now(time_zone=UTC)
403
+ TIME_UTC = get_time(UTC)
317
404
 
318
405
 
319
- def get_now_local() -> ZonedDateTime:
320
- """Get the current local time."""
321
- return get_now(time_zone=LOCAL_TIME_ZONE)
406
+ def get_time_local() -> Time:
407
+ """Get the current time in the local time-zone."""
408
+ return get_time(LOCAL_TIME_ZONE)
322
409
 
323
410
 
324
- NOW_LOCAL = get_now_local()
411
+ TIME_LOCAL = get_time_local()
325
412
 
326
413
 
327
414
  ##
328
415
 
329
416
 
330
- def get_today(*, time_zone: TimeZoneLike = UTC) -> Date:
417
+ def get_today(time_zone: TimeZoneLike = UTC, /) -> Date:
331
418
  """Get the current, timezone-aware local date."""
332
- return get_now(time_zone=time_zone).date()
419
+ return get_now(time_zone).date()
333
420
 
334
421
 
335
- TODAY_UTC = get_today(time_zone=UTC)
422
+ TODAY_UTC = get_today(UTC)
336
423
 
337
424
 
338
425
  def get_today_local() -> Date:
339
426
  """Get the current, timezone-aware local date."""
340
- return get_today(time_zone=LOCAL_TIME_ZONE)
427
+ return get_today(LOCAL_TIME_ZONE)
341
428
 
342
429
 
343
430
  TODAY_LOCAL = get_today_local()
@@ -346,6 +433,36 @@ TODAY_LOCAL = get_today_local()
346
433
  ##
347
434
 
348
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
+
349
466
  def mean_datetime(
350
467
  datetimes: Iterable[ZonedDateTime],
351
468
  /,
@@ -383,20 +500,16 @@ def min_max_date(
383
500
  max_age: DateDelta | None = None,
384
501
  time_zone: TimeZoneLike = UTC,
385
502
  ) -> tuple[Date | None, Date | None]:
386
- """Ucompute the min/max date given a combination of dates/ages."""
387
- 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)
388
505
  min_parts: list[Date] = []
389
506
  if min_date is not None:
390
- if min_date > today:
391
- raise _MinMaxDateMinDateError(min_date=min_date, today=today)
392
507
  min_parts.append(min_date)
393
508
  if max_age is not None:
394
509
  min_parts.append(today - max_age)
395
510
  min_date_use = max(min_parts, default=None)
396
511
  max_parts: list[Date] = []
397
512
  if max_date is not None:
398
- if max_date > today:
399
- raise _MinMaxDateMaxDateError(max_date=max_date, today=today)
400
513
  max_parts.append(max_date)
401
514
  if min_age is not None:
402
515
  max_parts.append(today - min_age)
@@ -411,34 +524,13 @@ def min_max_date(
411
524
 
412
525
 
413
526
  @dataclass(kw_only=True, slots=True)
414
- class MinMaxDateError(Exception): ...
415
-
416
-
417
- @dataclass(kw_only=True, slots=True)
418
- class _MinMaxDateMinDateError(MinMaxDateError):
527
+ class MinMaxDateError(Exception):
419
528
  min_date: Date
420
- today: Date
421
-
422
- @override
423
- def __str__(self) -> str:
424
- return f"Min date must be at most today; got {self.min_date} > {self.today}"
425
-
426
-
427
- @dataclass(kw_only=True, slots=True)
428
- class _MinMaxDateMaxDateError(MinMaxDateError):
429
529
  max_date: Date
430
- today: Date
431
-
432
- @override
433
- def __str__(self) -> str:
434
- return f"Max date must be at most today; got {self.max_date} > {self.today}"
435
530
 
436
531
 
437
532
  @dataclass(kw_only=True, slots=True)
438
533
  class _MinMaxDatePeriodError(MinMaxDateError):
439
- min_date: Date
440
- max_date: Date
441
-
442
534
  @override
443
535
  def __str__(self) -> str:
444
536
  return (
@@ -449,172 +541,399 @@ class _MinMaxDatePeriodError(MinMaxDateError):
449
541
  ##
450
542
 
451
543
 
452
- @dataclass(order=True, unsafe_hash=True, slots=True)
453
- class Month:
454
- """Represents a month in time."""
544
+ class PeriodDict[T: Date | Time | ZonedDateTime | dt.date | dt.time | dt.datetime](
545
+ TypedDict
546
+ ):
547
+ """A period as a dictionary."""
455
548
 
456
- year: int
457
- month: int
549
+ start: T
550
+ end: T
458
551
 
459
- def __post_init__(self) -> None:
460
- try:
461
- _ = Date(self.year, self.month, 1)
462
- except ValueError:
463
- raise _MonthInvalidError(year=self.year, month=self.month) from None
464
552
 
465
- @override
466
- def __repr__(self) -> str:
467
- return self.format_common_iso()
553
+ ##
468
554
 
469
- @override
470
- def __str__(self) -> str:
471
- return repr(self)
472
555
 
473
- def __add__(self, other: Any, /) -> Self:
474
- if not isinstance(other, int): # pragma: no cover
475
- return NotImplemented
476
- years, month = divmod(self.month + other - 1, 12)
477
- month += 1
478
- year = self.year + years
479
- return replace(self, year=year, month=month)
556
+ type _RoundDateDailyUnit = Literal["W", "D"]
557
+ type _RoundDateTimeUnit = Literal["H", "M", "S", "ms", "us", "ns"]
558
+ type _RoundDateOrDateTimeUnit = _RoundDateDailyUnit | _RoundDateTimeUnit
480
559
 
481
- @overload
482
- def __sub__(self, other: Self, /) -> int: ...
483
- @overload
484
- def __sub__(self, other: int, /) -> Self: ...
485
- def __sub__(self, other: Self | int, /) -> Self | int:
486
- if isinstance(other, int): # pragma: no cover
487
- return self + (-other)
488
- if isinstance(other, type(self)):
489
- self_as_int = 12 * self.year + self.month
490
- other_as_int = 12 * other.year + other.month
491
- return self_as_int - other_as_int
492
- return NotImplemented # pragma: no cover
493
560
 
494
- @classmethod
495
- def ensure(cls, obj: MonthLike, /) -> Month:
496
- """Ensure the object is a month."""
497
- match obj:
498
- case Month() as month:
499
- return month
500
- case str() as text:
501
- return cls.parse_common_iso(text)
502
- case _ as never:
503
- assert_never(never)
561
+ def round_date_or_date_time[T: Date | PlainDateTime | ZonedDateTime](
562
+ date_or_date_time: T,
563
+ delta: Delta,
564
+ /,
565
+ *,
566
+ mode: DateTimeRoundMode = "half_even",
567
+ weekday: Weekday | None = None,
568
+ ) -> T:
569
+ """Round a datetime."""
570
+ increment, unit = _round_datetime_decompose(delta)
571
+ match date_or_date_time, unit, weekday:
572
+ case Date() as date, "W" | "D", _:
573
+ return _round_date_weekly_or_daily(
574
+ date, increment, unit, mode=mode, weekday=weekday
575
+ )
576
+ case Date() as date, "H" | "M" | "S" | "ms" | "us" | "ns", _:
577
+ raise _RoundDateOrDateTimeDateWithIntradayDeltaError(date=date, delta=delta)
578
+ case (PlainDateTime() | ZonedDateTime() as date_time, "W" | "D", _):
579
+ return _round_date_time_weekly_or_daily(
580
+ date_time, increment, unit, mode=mode, weekday=weekday
581
+ )
582
+ case (
583
+ PlainDateTime() | ZonedDateTime() as date_time,
584
+ "H" | "M" | "S" | "ms" | "us" | "ns",
585
+ None,
586
+ ):
587
+ return _round_date_time_intraday(date_time, increment, unit, mode=mode)
588
+ case (
589
+ PlainDateTime() | ZonedDateTime() as date_time,
590
+ "H" | "M" | "S" | "ms" | "us" | "ns",
591
+ Weekday(),
592
+ ):
593
+ raise _RoundDateOrDateTimeDateTimeIntraDayWithWeekdayError(
594
+ date_time=date_time, delta=delta, weekday=weekday
595
+ )
596
+ case never:
597
+ assert_never(never)
504
598
 
505
- def format_common_iso(self) -> str:
506
- return f"{self.year:04}-{self.month:02}"
507
599
 
508
- @classmethod
509
- def from_date(cls, date: Date, /) -> Self:
510
- return cls(year=date.year, month=date.month)
600
+ def _round_datetime_decompose(delta: Delta, /) -> tuple[int, _RoundDateOrDateTimeUnit]:
601
+ try:
602
+ weeks = to_weeks(delta)
603
+ except ToWeeksError:
604
+ pass
605
+ else:
606
+ return weeks, "W"
607
+ try:
608
+ days = to_days(delta)
609
+ except ToDaysError:
610
+ pass
611
+ else:
612
+ return days, "D"
613
+ try:
614
+ hours = to_hours(delta)
615
+ except ToHoursError:
616
+ pass
617
+ else:
618
+ if (0 < hours < 24) and (24 % hours == 0):
619
+ return hours, "H"
620
+ raise _RoundDateOrDateTimeIncrementError(
621
+ duration=delta, increment=hours, divisor=24
622
+ )
623
+ try:
624
+ minutes = to_minutes(delta)
625
+ except ToMinutesError:
626
+ pass
627
+ else:
628
+ if (0 < minutes < 60) and (60 % minutes == 0):
629
+ return minutes, "M"
630
+ raise _RoundDateOrDateTimeIncrementError(
631
+ duration=delta, increment=minutes, divisor=60
632
+ )
633
+ try:
634
+ seconds = to_seconds(delta)
635
+ except ToSecondsError:
636
+ pass
637
+ else:
638
+ if (0 < seconds < 60) and (60 % seconds == 0):
639
+ return seconds, "S"
640
+ raise _RoundDateOrDateTimeIncrementError(
641
+ duration=delta, increment=seconds, divisor=60
642
+ )
643
+ try:
644
+ milliseconds = to_milliseconds(delta)
645
+ except ToMillisecondsError:
646
+ pass
647
+ else:
648
+ if (0 < milliseconds < 1000) and (1000 % milliseconds == 0):
649
+ return milliseconds, "ms"
650
+ raise _RoundDateOrDateTimeIncrementError(
651
+ duration=delta, increment=milliseconds, divisor=1000
652
+ )
653
+ try:
654
+ microseconds = to_microseconds(delta)
655
+ except ToMicrosecondsError:
656
+ pass
657
+ else:
658
+ if (0 < microseconds < 1000) and (1000 % microseconds == 0):
659
+ return microseconds, "us"
660
+ raise _RoundDateOrDateTimeIncrementError(
661
+ duration=delta, increment=microseconds, divisor=1000
662
+ )
663
+ try:
664
+ nanoseconds = to_nanoseconds(delta)
665
+ except ToNanosecondsError:
666
+ raise _RoundDateOrDateTimeInvalidDurationError(duration=delta) from None
667
+ if (0 < nanoseconds < 1000) and (1000 % nanoseconds == 0):
668
+ return nanoseconds, "ns"
669
+ raise _RoundDateOrDateTimeIncrementError(
670
+ duration=delta, increment=nanoseconds, divisor=1000
671
+ )
511
672
 
512
- @classmethod
513
- def parse_common_iso(cls, text: str, /) -> Self:
514
- try:
515
- year, month = extract_groups(r"^(\d{2,4})[\-\. ]?(\d{2})$", text)
516
- except ExtractGroupsError:
517
- raise _MonthParseCommonISOError(text=text) from None
518
- return cls(year=cls._parse_year(year), month=int(month))
519
673
 
520
- def to_date(self, /, *, day: int = 1) -> Date:
521
- return Date(self.year, self.month, day)
674
+ def _round_date_weekly_or_daily(
675
+ date: Date,
676
+ increment: int,
677
+ unit: _RoundDateDailyUnit,
678
+ /,
679
+ *,
680
+ mode: DateTimeRoundMode = "half_even",
681
+ weekday: Weekday | None = None,
682
+ ) -> Date:
683
+ match unit, weekday:
684
+ case "W", _:
685
+ return _round_date_weekly(date, increment, mode=mode, weekday=weekday)
686
+ case "D", None:
687
+ return _round_date_daily(date, increment, mode=mode)
688
+ case "D", Weekday():
689
+ raise _RoundDateOrDateTimeDateWithWeekdayError(weekday=weekday)
690
+ case never:
691
+ assert_never(never)
692
+
693
+
694
+ def _round_date_weekly(
695
+ date: Date,
696
+ increment: int,
697
+ /,
698
+ *,
699
+ mode: DateTimeRoundMode = "half_even",
700
+ weekday: Weekday | None = None,
701
+ ) -> Date:
702
+ mapping = {
703
+ None: 0,
704
+ Weekday.MONDAY: 0,
705
+ Weekday.TUESDAY: 1,
706
+ Weekday.WEDNESDAY: 2,
707
+ Weekday.THURSDAY: 3,
708
+ Weekday.FRIDAY: 4,
709
+ Weekday.SATURDAY: 5,
710
+ Weekday.SUNDAY: 6,
711
+ }
712
+ base = Date.MIN.add(days=mapping[weekday])
713
+ return _round_date_daily(date, 7 * increment, mode=mode, base=base)
714
+
715
+
716
+ def _round_date_daily(
717
+ date: Date,
718
+ increment: int,
719
+ /,
720
+ *,
721
+ mode: DateTimeRoundMode = "half_even",
722
+ base: Date = Date.MIN,
723
+ ) -> Date:
724
+ quotient, remainder = divmod(date.days_since(base), increment)
725
+ match mode:
726
+ case "half_even":
727
+ threshold = increment // 2 + (quotient % 2 == 0) or 1
728
+ case "ceil":
729
+ threshold = 1
730
+ case "floor":
731
+ threshold = increment + 1
732
+ case "half_floor":
733
+ threshold = increment // 2 + 1
734
+ case "half_ceil":
735
+ threshold = increment // 2 or 1
736
+ case never:
737
+ assert_never(never)
738
+ round_up = remainder >= threshold
739
+ return base.add(days=(quotient + round_up) * increment)
740
+
741
+
742
+ def _round_date_time_intraday[T: PlainDateTime | ZonedDateTime](
743
+ date_time: T,
744
+ increment: int,
745
+ unit: _RoundDateTimeUnit,
746
+ /,
747
+ *,
748
+ mode: DateTimeRoundMode = "half_even",
749
+ ) -> T:
750
+ match unit:
751
+ case "H":
752
+ unit_use = "hour"
753
+ case "M":
754
+ unit_use = "minute"
755
+ case "S":
756
+ unit_use = "second"
757
+ case "ms":
758
+ unit_use = "millisecond"
759
+ case "us":
760
+ unit_use = "microsecond"
761
+ case "ns":
762
+ unit_use = "nanosecond"
763
+ case never:
764
+ assert_never(never)
765
+ return date_time.round(unit_use, increment=increment, mode=mode)
766
+
767
+
768
+ def _round_date_time_weekly_or_daily[T: PlainDateTime | ZonedDateTime](
769
+ date_time: T,
770
+ increment: int,
771
+ unit: _RoundDateDailyUnit,
772
+ /,
773
+ *,
774
+ mode: DateTimeRoundMode = "half_even",
775
+ weekday: Weekday | None = None,
776
+ ) -> T:
777
+ rounded = cast("T", date_time.round("day", mode=mode))
778
+ new_date = _round_date_weekly_or_daily(
779
+ rounded.date(), increment, unit, mode=mode, weekday=weekday
780
+ )
781
+ return date_time.replace_date(new_date).replace_time(Time())
782
+
783
+
784
+ @dataclass(kw_only=True, slots=True)
785
+ class RoundDateOrDateTimeError(Exception): ...
522
786
 
523
- @classmethod
524
- def _parse_year(cls, year: str, /) -> int:
525
- match len(year):
526
- case 4:
527
- return int(year)
528
- case 2:
529
- min_year = DATE_TWO_DIGIT_YEAR_MIN.year
530
- max_year = DATE_TWO_DIGIT_YEAR_MAX.year
531
- years = range(min_year, max_year + 1)
532
- (result,) = (y for y in years if y % 100 == int(year))
533
- return result
534
- case _:
535
- raise _MonthParseCommonISOError(text=year) from None
787
+
788
+ @dataclass(kw_only=True, slots=True)
789
+ class _RoundDateOrDateTimeIncrementError(RoundDateOrDateTimeError):
790
+ duration: Delta
791
+ increment: int
792
+ divisor: int
793
+
794
+ @override
795
+ def __str__(self) -> str:
796
+ return f"Duration {self.duration} increment must be a proper divisor of {self.divisor}; got {self.increment}"
536
797
 
537
798
 
538
799
  @dataclass(kw_only=True, slots=True)
539
- class MonthError(Exception): ...
800
+ class _RoundDateOrDateTimeInvalidDurationError(RoundDateOrDateTimeError):
801
+ duration: Delta
802
+
803
+ @override
804
+ def __str__(self) -> str:
805
+ return f"Duration must be valid; got {self.duration}"
540
806
 
541
807
 
542
808
  @dataclass(kw_only=True, slots=True)
543
- class _MonthInvalidError(MonthError):
544
- year: int
545
- month: int
809
+ class _RoundDateOrDateTimeDateWithIntradayDeltaError(RoundDateOrDateTimeError):
810
+ date: Date
811
+ delta: Delta
546
812
 
547
813
  @override
548
814
  def __str__(self) -> str:
549
- return f"Invalid year and month: {self.year}, {self.month}"
815
+ return f"Dates must not be given intraday durations; got {self.date} and {self.delta}"
550
816
 
551
817
 
552
818
  @dataclass(kw_only=True, slots=True)
553
- class _MonthParseCommonISOError(MonthError):
554
- text: str
819
+ class _RoundDateOrDateTimeDateWithWeekdayError(RoundDateOrDateTimeError):
820
+ weekday: Weekday
555
821
 
556
822
  @override
557
823
  def __str__(self) -> str:
558
- return f"Unable to parse month; got {self.text!r}"
824
+ return f"Daily rounding must not be given a weekday; got {self.weekday}"
559
825
 
560
826
 
561
- type DateOrMonth = Date | Month
562
- type MonthLike = MaybeStr[Month]
563
- MONTH_MIN = Month.from_date(DATE_MIN)
564
- MONTH_MAX = Month.from_date(DATE_MAX)
827
+ @dataclass(kw_only=True, slots=True)
828
+ class _RoundDateOrDateTimeDateTimeIntraDayWithWeekdayError(RoundDateOrDateTimeError):
829
+ date_time: PlainDateTime | ZonedDateTime
830
+ delta: Delta
831
+ weekday: Weekday
832
+
833
+ @override
834
+ def __str__(self) -> str:
835
+ return f"Date-times and intraday rounding must not be given a weekday; got {self.date_time}, {self.delta} and {self.weekday}"
836
+
837
+
838
+ ##
839
+
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())
565
898
 
566
899
 
567
900
  ##
568
901
 
569
902
 
570
903
  @overload
571
- def to_date(*, date: MaybeCallableDate) -> Date: ...
572
- @overload
573
- def to_date(*, date: None) -> None: ...
574
- @overload
575
- def to_date(*, date: Sentinel) -> Sentinel: ...
576
- @overload
577
- def to_date(*, date: MaybeCallableDate | Sentinel) -> Date | Sentinel: ...
904
+ def to_date(date: Sentinel, /, *, time_zone: TimeZoneLike = UTC) -> Sentinel: ...
578
905
  @overload
579
906
  def to_date(
580
- *, date: MaybeCallableDate | None | Sentinel = sentinel
581
- ) -> Date | None | Sentinel: ...
907
+ date: MaybeCallableDateLike | None | dt.date = get_today,
908
+ /,
909
+ *,
910
+ time_zone: TimeZoneLike = UTC,
911
+ ) -> Date: ...
582
912
  def to_date(
583
- *, date: MaybeCallableDate | None | Sentinel = sentinel
584
- ) -> Date | None | Sentinel:
585
- """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."""
586
919
  match date:
587
- case Date() | None | Sentinel():
920
+ case Date() | Sentinel():
588
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)
589
928
  case Callable() as func:
590
- return to_date(date=func())
591
- case _ as never:
929
+ return to_date(func(), time_zone=time_zone)
930
+ case never:
592
931
  assert_never(never)
593
932
 
594
933
 
595
934
  ##
596
935
 
597
936
 
598
- def to_days(delta: DateDelta, /) -> int:
599
- """Compute the number of days in a date delta."""
600
- months, days = delta.in_months_days()
601
- if months != 0:
602
- raise ToDaysError(months=months)
603
- return days
604
-
605
-
606
- @dataclass(kw_only=True, slots=True)
607
- class ToDaysError(Exception):
608
- months: int
609
-
610
- @override
611
- def __str__(self) -> str:
612
- return f"Date delta must not contain months; got {self.months}"
613
-
614
-
615
- ##
616
-
617
-
618
937
  def to_date_time_delta(nanos: int, /) -> DateTimeDelta:
619
938
  """Construct a date-time delta."""
620
939
  components = _to_time_delta_components(nanos)
@@ -652,42 +971,568 @@ def to_date_time_delta(nanos: int, /) -> DateTimeDelta:
652
971
  ##
653
972
 
654
973
 
655
- def to_nanos(delta: DateTimeDelta, /) -> int:
656
- """Compute the number of nanoseconds in a date-time delta."""
657
- months, days, _, _ = delta.in_months_days_secs_nanos()
658
- if months != 0:
659
- raise ToNanosError(months=months)
660
- return 24 * 60 * 60 * int(1e9) * days + delta.time_part().in_nanoseconds()
974
+ def to_days(delta: Delta, /) -> int:
975
+ """Compute the number of days in a delta."""
976
+ match delta:
977
+ case DateDelta():
978
+ months, days = delta.in_months_days()
979
+ if months != 0:
980
+ raise _ToDaysMonthsError(delta=delta, months=months)
981
+ return days
982
+ case TimeDelta():
983
+ nanos = to_nanoseconds(delta)
984
+ days, remainder = divmod(nanos, 24 * 60 * 60 * int(1e9))
985
+ if remainder != 0:
986
+ raise _ToDaysNanosecondsError(delta=delta, nanoseconds=remainder)
987
+ return days
988
+ case DateTimeDelta():
989
+ try:
990
+ return to_days(delta.date_part()) + to_days(delta.time_part())
991
+ except _ToDaysMonthsError as error:
992
+ raise _ToDaysMonthsError(delta=delta, months=error.months) from None
993
+ except _ToDaysNanosecondsError as error:
994
+ raise _ToDaysNanosecondsError(
995
+ delta=delta, nanoseconds=error.nanoseconds
996
+ ) from None
997
+ case never:
998
+ assert_never(never)
661
999
 
662
1000
 
663
1001
  @dataclass(kw_only=True, slots=True)
664
- class ToNanosError(Exception):
1002
+ class ToDaysError(Exception): ...
1003
+
1004
+
1005
+ @dataclass(kw_only=True, slots=True)
1006
+ class _ToDaysMonthsError(ToDaysError):
1007
+ delta: DateOrDateTimeDelta
665
1008
  months: int
666
1009
 
667
1010
  @override
668
1011
  def __str__(self) -> str:
669
- return f"Date-time delta must not contain months; got {self.months}"
1012
+ return f"Delta must not contain months; got {self.months}"
1013
+
1014
+
1015
+ @dataclass(kw_only=True, slots=True)
1016
+ class _ToDaysNanosecondsError(ToDaysError):
1017
+ delta: TimeOrDateTimeDelta
1018
+ nanoseconds: int
1019
+
1020
+ @override
1021
+ def __str__(self) -> str:
1022
+ return f"Delta must not contain extra nanoseconds; got {self.nanoseconds}"
670
1023
 
671
1024
 
672
1025
  ##
673
1026
 
674
1027
 
675
- def to_time_delta(nanos: int, /) -> TimeDelta:
676
- """Construct a time delta."""
677
- components = _to_time_delta_components(nanos)
678
- return TimeDelta(
679
- hours=components.hours,
680
- minutes=components.minutes,
681
- seconds=components.seconds,
682
- microseconds=components.microseconds,
683
- milliseconds=components.milliseconds,
684
- nanoseconds=components.nanoseconds,
685
- )
1028
+ def to_hours(delta: Delta, /) -> int:
1029
+ """Compute the number of hours in a delta."""
1030
+ match delta:
1031
+ case DateDelta():
1032
+ try:
1033
+ days = to_days(delta)
1034
+ except _ToDaysMonthsError as error:
1035
+ raise _ToHoursMonthsError(delta=delta, months=error.months) from None
1036
+ return 24 * days
1037
+ case TimeDelta():
1038
+ nanos = to_nanoseconds(delta)
1039
+ divisor = 60 * 60 * int(1e9)
1040
+ hours, remainder = divmod(nanos, divisor)
1041
+ if remainder != 0:
1042
+ raise _ToHoursNanosecondsError(delta=delta, nanoseconds=remainder)
1043
+ return hours
1044
+ case DateTimeDelta():
1045
+ try:
1046
+ return to_hours(delta.date_part()) + to_hours(delta.time_part())
1047
+ except _ToHoursMonthsError as error:
1048
+ raise _ToHoursMonthsError(delta=delta, months=error.months) from None
1049
+ except _ToHoursNanosecondsError as error:
1050
+ raise _ToHoursNanosecondsError(
1051
+ delta=delta, nanoseconds=error.nanoseconds
1052
+ ) from None
1053
+ case never:
1054
+ assert_never(never)
686
1055
 
687
1056
 
688
1057
  @dataclass(kw_only=True, slots=True)
689
- class _TimeDeltaComponents:
690
- hours: int
1058
+ class ToHoursError(Exception): ...
1059
+
1060
+
1061
+ @dataclass(kw_only=True, slots=True)
1062
+ class _ToHoursMonthsError(ToHoursError):
1063
+ delta: DateOrDateTimeDelta
1064
+ months: int
1065
+
1066
+ @override
1067
+ def __str__(self) -> str:
1068
+ return f"Delta must not contain months; got {self.months}"
1069
+
1070
+
1071
+ @dataclass(kw_only=True, slots=True)
1072
+ class _ToHoursNanosecondsError(ToHoursError):
1073
+ delta: TimeOrDateTimeDelta
1074
+ nanoseconds: int
1075
+
1076
+ @override
1077
+ def __str__(self) -> str:
1078
+ return f"Delta must not contain extra nanoseconds; got {self.nanoseconds}"
1079
+
1080
+
1081
+ ##
1082
+
1083
+
1084
+ def to_microseconds(delta: Delta, /) -> int:
1085
+ """Compute the number of microseconds in a delta."""
1086
+ match delta:
1087
+ case DateDelta():
1088
+ try:
1089
+ days = to_days(delta)
1090
+ except _ToDaysMonthsError as error:
1091
+ raise _ToMicrosecondsMonthsError(
1092
+ delta=delta, months=error.months
1093
+ ) from None
1094
+ return 24 * 60 * 60 * int(1e6) * days
1095
+ case TimeDelta():
1096
+ nanos = to_nanoseconds(delta)
1097
+ microseconds, remainder = divmod(nanos, int(1e3))
1098
+ if remainder != 0:
1099
+ raise _ToMicrosecondsNanosecondsError(
1100
+ delta=delta, nanoseconds=remainder
1101
+ )
1102
+ return microseconds
1103
+ case DateTimeDelta():
1104
+ try:
1105
+ return to_microseconds(delta.date_part()) + to_microseconds(
1106
+ delta.time_part()
1107
+ )
1108
+ except _ToMicrosecondsMonthsError as error:
1109
+ raise _ToMicrosecondsMonthsError(
1110
+ delta=delta, months=error.months
1111
+ ) from None
1112
+ except _ToMicrosecondsNanosecondsError as error:
1113
+ raise _ToMicrosecondsNanosecondsError(
1114
+ delta=delta, nanoseconds=error.nanoseconds
1115
+ ) from None
1116
+ case never:
1117
+ assert_never(never)
1118
+
1119
+
1120
+ @dataclass(kw_only=True, slots=True)
1121
+ class ToMicrosecondsError(Exception): ...
1122
+
1123
+
1124
+ @dataclass(kw_only=True, slots=True)
1125
+ class _ToMicrosecondsMonthsError(ToMicrosecondsError):
1126
+ delta: DateOrDateTimeDelta
1127
+ months: int
1128
+
1129
+ @override
1130
+ def __str__(self) -> str:
1131
+ return f"Delta must not contain months; got {self.months}"
1132
+
1133
+
1134
+ @dataclass(kw_only=True, slots=True)
1135
+ class _ToMicrosecondsNanosecondsError(ToMicrosecondsError):
1136
+ delta: TimeOrDateTimeDelta
1137
+ nanoseconds: int
1138
+
1139
+ @override
1140
+ def __str__(self) -> str:
1141
+ return f"Delta must not contain extra nanoseconds; got {self.nanoseconds}"
1142
+
1143
+
1144
+ ##
1145
+
1146
+
1147
+ def to_milliseconds(delta: Delta, /) -> int:
1148
+ """Compute the number of milliseconds in a delta."""
1149
+ match delta:
1150
+ case DateDelta():
1151
+ try:
1152
+ days = to_days(delta)
1153
+ except _ToDaysMonthsError as error:
1154
+ raise _ToMillisecondsMonthsError(
1155
+ delta=delta, months=error.months
1156
+ ) from None
1157
+ return 24 * 60 * 60 * int(1e3) * days
1158
+ case TimeDelta():
1159
+ nanos = to_nanoseconds(delta)
1160
+ milliseconds, remainder = divmod(nanos, int(1e6))
1161
+ if remainder != 0:
1162
+ raise _ToMillisecondsNanosecondsError(
1163
+ delta=delta, nanoseconds=remainder
1164
+ )
1165
+ return milliseconds
1166
+ case DateTimeDelta():
1167
+ try:
1168
+ return to_milliseconds(delta.date_part()) + to_milliseconds(
1169
+ delta.time_part()
1170
+ )
1171
+ except _ToMillisecondsMonthsError as error:
1172
+ raise _ToMillisecondsMonthsError(
1173
+ delta=delta, months=error.months
1174
+ ) from None
1175
+ except _ToMillisecondsNanosecondsError as error:
1176
+ raise _ToMillisecondsNanosecondsError(
1177
+ delta=delta, nanoseconds=error.nanoseconds
1178
+ ) from None
1179
+ case never:
1180
+ assert_never(never)
1181
+
1182
+
1183
+ @dataclass(kw_only=True, slots=True)
1184
+ class ToMillisecondsError(Exception): ...
1185
+
1186
+
1187
+ @dataclass(kw_only=True, slots=True)
1188
+ class _ToMillisecondsMonthsError(ToMillisecondsError):
1189
+ delta: DateOrDateTimeDelta
1190
+ months: int
1191
+
1192
+ @override
1193
+ def __str__(self) -> str:
1194
+ return f"Delta must not contain months; got {self.months}"
1195
+
1196
+
1197
+ @dataclass(kw_only=True, slots=True)
1198
+ class _ToMillisecondsNanosecondsError(ToMillisecondsError):
1199
+ delta: TimeOrDateTimeDelta
1200
+ nanoseconds: int
1201
+
1202
+ @override
1203
+ def __str__(self) -> str:
1204
+ return f"Delta must not contain extra nanoseconds; got {self.nanoseconds}"
1205
+
1206
+
1207
+ ##
1208
+
1209
+
1210
+ def to_minutes(delta: Delta, /) -> int:
1211
+ """Compute the number of minutes in a delta."""
1212
+ match delta:
1213
+ case DateDelta():
1214
+ try:
1215
+ days = to_days(delta)
1216
+ except _ToDaysMonthsError as error:
1217
+ raise _ToMinutesMonthsError(delta=delta, months=error.months) from None
1218
+ return 24 * 60 * days
1219
+ case TimeDelta():
1220
+ nanos = to_nanoseconds(delta)
1221
+ minutes, remainder = divmod(nanos, 60 * int(1e9))
1222
+ if remainder != 0:
1223
+ raise _ToMinutesNanosecondsError(delta=delta, nanoseconds=remainder)
1224
+ return minutes
1225
+ case DateTimeDelta():
1226
+ try:
1227
+ return to_minutes(delta.date_part()) + to_minutes(delta.time_part())
1228
+ except _ToMinutesMonthsError as error:
1229
+ raise _ToMinutesMonthsError(delta=delta, months=error.months) from None
1230
+ except _ToMinutesNanosecondsError as error:
1231
+ raise _ToMinutesNanosecondsError(
1232
+ delta=delta, nanoseconds=error.nanoseconds
1233
+ ) from None
1234
+ case never:
1235
+ assert_never(never)
1236
+
1237
+
1238
+ @dataclass(kw_only=True, slots=True)
1239
+ class ToMinutesError(Exception): ...
1240
+
1241
+
1242
+ @dataclass(kw_only=True, slots=True)
1243
+ class _ToMinutesMonthsError(ToMinutesError):
1244
+ delta: DateOrDateTimeDelta
1245
+ months: int
1246
+
1247
+ @override
1248
+ def __str__(self) -> str:
1249
+ return f"Delta must not contain months; got {self.months}"
1250
+
1251
+
1252
+ @dataclass(kw_only=True, slots=True)
1253
+ class _ToMinutesNanosecondsError(ToMinutesError):
1254
+ delta: TimeOrDateTimeDelta
1255
+ nanoseconds: int
1256
+
1257
+ @override
1258
+ def __str__(self) -> str:
1259
+ return f"Delta must not contain extra nanoseconds; got {self.nanoseconds}"
1260
+
1261
+
1262
+ ##
1263
+
1264
+
1265
+ def to_months(delta: DateOrDateTimeDelta, /) -> int:
1266
+ """Compute the number of months in a delta."""
1267
+ match delta:
1268
+ case DateDelta():
1269
+ months, days = delta.in_months_days()
1270
+ if days != 0:
1271
+ raise _ToMonthsDaysError(delta=delta, days=days)
1272
+ return months
1273
+ case DateTimeDelta():
1274
+ if delta.time_part() != TimeDelta():
1275
+ raise _ToMonthsTimeError(delta=delta)
1276
+ try:
1277
+ return to_months(delta.date_part())
1278
+ except _ToMonthsDaysError as error:
1279
+ raise _ToMonthsDaysError(delta=delta, days=error.days) from None
1280
+ case never:
1281
+ assert_never(never)
1282
+
1283
+
1284
+ @dataclass(kw_only=True, slots=True)
1285
+ class ToMonthsError(Exception): ...
1286
+
1287
+
1288
+ @dataclass(kw_only=True, slots=True)
1289
+ class _ToMonthsDaysError(ToMonthsError):
1290
+ delta: DateOrDateTimeDelta
1291
+ days: int
1292
+
1293
+ @override
1294
+ def __str__(self) -> str:
1295
+ return f"Delta must not contain days; got {self.days}"
1296
+
1297
+
1298
+ @dataclass(kw_only=True, slots=True)
1299
+ class _ToMonthsTimeError(ToMonthsError):
1300
+ delta: DateTimeDelta
1301
+
1302
+ @override
1303
+ def __str__(self) -> str:
1304
+ return f"Delta must not contain a time part; got {self.delta.time_part()}"
1305
+
1306
+
1307
+ ##
1308
+
1309
+
1310
+ def to_months_and_days(delta: DateOrDateTimeDelta, /) -> tuple[int, int]:
1311
+ """Compute the number of months & days in a delta."""
1312
+ match delta:
1313
+ case DateDelta():
1314
+ return delta.in_months_days()
1315
+ case DateTimeDelta():
1316
+ if delta.time_part() != TimeDelta():
1317
+ raise ToMonthsAndDaysError(delta=delta)
1318
+ return to_months_and_days(delta.date_part())
1319
+ case never:
1320
+ assert_never(never)
1321
+
1322
+
1323
+ @dataclass(kw_only=True, slots=True)
1324
+ class ToMonthsAndDaysError(Exception):
1325
+ delta: DateTimeDelta
1326
+
1327
+ @override
1328
+ def __str__(self) -> str:
1329
+ return f"Delta must not contain a time part; got {self.delta.time_part()}"
1330
+
1331
+
1332
+ ##
1333
+
1334
+
1335
+ def to_nanoseconds(delta: Delta, /) -> int:
1336
+ """Compute the number of nanoseconds in a date-time delta."""
1337
+ match delta:
1338
+ case DateDelta():
1339
+ try:
1340
+ days = to_days(delta)
1341
+ except _ToDaysMonthsError as error:
1342
+ raise ToNanosecondsError(delta=delta, months=error.months) from None
1343
+ return 24 * 60 * 60 * int(1e9) * days
1344
+ case TimeDelta():
1345
+ return delta.in_nanoseconds()
1346
+ case DateTimeDelta():
1347
+ try:
1348
+ return to_nanoseconds(delta.date_part()) + to_nanoseconds(
1349
+ delta.time_part()
1350
+ )
1351
+ except ToNanosecondsError as error:
1352
+ raise ToNanosecondsError(delta=delta, months=error.months) from None
1353
+ case never:
1354
+ assert_never(never)
1355
+
1356
+
1357
+ @dataclass(kw_only=True, slots=True)
1358
+ class ToNanosecondsError(Exception):
1359
+ delta: DateOrDateTimeDelta
1360
+ months: int
1361
+
1362
+ @override
1363
+ def __str__(self) -> str:
1364
+ return f"Delta must not contain months; got {self.months}"
1365
+
1366
+
1367
+ ##
1368
+
1369
+
1370
+ @overload
1371
+ def to_py_date_or_date_time(date_or_date_time: Date, /) -> dt.date: ...
1372
+ @overload
1373
+ def to_py_date_or_date_time(date_or_date_time: ZonedDateTime, /) -> dt.datetime: ...
1374
+ @overload
1375
+ def to_py_date_or_date_time(date_or_date_time: None, /) -> None: ...
1376
+ def to_py_date_or_date_time(
1377
+ date_or_date_time: Date | ZonedDateTime | None, /
1378
+ ) -> dt.date | None:
1379
+ """Convert a Date or ZonedDateTime into a standard library equivalent."""
1380
+ match date_or_date_time:
1381
+ case Date() as date:
1382
+ return date.py_date()
1383
+ case ZonedDateTime() as date_time:
1384
+ return date_time.py_datetime()
1385
+ case None:
1386
+ return None
1387
+ case never:
1388
+ assert_never(never)
1389
+
1390
+
1391
+ ##
1392
+
1393
+
1394
+ @overload
1395
+ def to_py_time_delta(delta: Delta, /) -> dt.timedelta: ...
1396
+ @overload
1397
+ def to_py_time_delta(delta: None, /) -> None: ...
1398
+ def to_py_time_delta(delta: Delta | None, /) -> dt.timedelta | None:
1399
+ """Try convert a DateDelta to a standard library timedelta."""
1400
+ match delta:
1401
+ case DateDelta():
1402
+ return dt.timedelta(days=to_days(delta))
1403
+ case TimeDelta():
1404
+ nanos = delta.in_nanoseconds()
1405
+ micros, remainder = divmod(nanos, 1000)
1406
+ if remainder != 0:
1407
+ raise ToPyTimeDeltaError(nanoseconds=remainder)
1408
+ return dt.timedelta(microseconds=micros)
1409
+ case DateTimeDelta():
1410
+ return to_py_time_delta(delta.date_part()) + to_py_time_delta(
1411
+ delta.time_part()
1412
+ )
1413
+ case None:
1414
+ return None
1415
+ case never:
1416
+ assert_never(never)
1417
+
1418
+
1419
+ @dataclass(kw_only=True, slots=True)
1420
+ class ToPyTimeDeltaError(Exception):
1421
+ nanoseconds: int
1422
+
1423
+ @override
1424
+ def __str__(self) -> str:
1425
+ return f"Time delta must not contain nanoseconds; got {self.nanoseconds}"
1426
+
1427
+
1428
+ ##
1429
+
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
+
1520
+ def to_time_delta(nanos: int, /) -> TimeDelta:
1521
+ """Construct a time delta."""
1522
+ components = _to_time_delta_components(nanos)
1523
+ return TimeDelta(
1524
+ hours=components.hours,
1525
+ minutes=components.minutes,
1526
+ seconds=components.seconds,
1527
+ microseconds=components.microseconds,
1528
+ milliseconds=components.milliseconds,
1529
+ nanoseconds=components.nanoseconds,
1530
+ )
1531
+
1532
+
1533
+ @dataclass(kw_only=True, slots=True)
1534
+ class _TimeDeltaComponents:
1535
+ hours: int
691
1536
  minutes: int
692
1537
  seconds: int
693
1538
  microseconds: int
@@ -750,23 +1595,181 @@ def _to_time_delta_components(nanos: int, /) -> _TimeDeltaComponents:
750
1595
  ##
751
1596
 
752
1597
 
1598
+ def to_weeks(delta: Delta, /) -> int:
1599
+ """Compute the number of weeks in a delta."""
1600
+ try:
1601
+ days = to_days(delta)
1602
+ except _ToDaysMonthsError as error:
1603
+ raise _ToWeeksMonthsError(delta=error.delta, months=error.months) from None
1604
+ except _ToDaysNanosecondsError as error:
1605
+ raise _ToWeeksNanosecondsError(
1606
+ delta=error.delta, nanoseconds=error.nanoseconds
1607
+ ) from None
1608
+ weeks, remainder = divmod(days, 7)
1609
+ if remainder != 0:
1610
+ raise _ToWeeksDaysError(delta=delta, days=remainder) from None
1611
+ return weeks
1612
+
1613
+
1614
+ @dataclass(kw_only=True, slots=True)
1615
+ class ToWeeksError(Exception): ...
1616
+
1617
+
1618
+ @dataclass(kw_only=True, slots=True)
1619
+ class _ToWeeksMonthsError(ToWeeksError):
1620
+ delta: DateOrDateTimeDelta
1621
+ months: int
1622
+
1623
+ @override
1624
+ def __str__(self) -> str:
1625
+ return f"Delta must not contain months; got {self.months}"
1626
+
1627
+
1628
+ @dataclass(kw_only=True, slots=True)
1629
+ class _ToWeeksNanosecondsError(ToWeeksError):
1630
+ delta: TimeOrDateTimeDelta
1631
+ nanoseconds: int
1632
+
1633
+ @override
1634
+ def __str__(self) -> str:
1635
+ return f"Delta must not contain extra nanoseconds; got {self.nanoseconds}"
1636
+
1637
+
1638
+ @dataclass(kw_only=True, slots=True)
1639
+ class _ToWeeksDaysError(ToWeeksError):
1640
+ delta: Delta
1641
+ days: int
1642
+
1643
+ @override
1644
+ def __str__(self) -> str:
1645
+ return f"Delta must not contain extra days; got {self.days}"
1646
+
1647
+
1648
+ ##
1649
+
1650
+
1651
+ def to_years(delta: DateOrDateTimeDelta, /) -> int:
1652
+ """Compute the number of years in a delta."""
1653
+ match delta:
1654
+ case DateDelta():
1655
+ years, months, days = delta.in_years_months_days()
1656
+ if months != 0:
1657
+ raise _ToYearsMonthsError(delta=delta, months=months)
1658
+ if days != 0:
1659
+ raise _ToYearsDaysError(delta=delta, days=days)
1660
+ return years
1661
+ case DateTimeDelta():
1662
+ if delta.time_part() != TimeDelta():
1663
+ raise _ToYearsTimeError(delta=delta)
1664
+ try:
1665
+ return to_years(delta.date_part())
1666
+ except _ToYearsMonthsError as error:
1667
+ raise _ToYearsMonthsError(delta=delta, months=error.months) from None
1668
+ except _ToYearsDaysError as error:
1669
+ raise _ToYearsDaysError(delta=delta, days=error.days) from None
1670
+ case never:
1671
+ assert_never(never)
1672
+
1673
+
1674
+ @dataclass(kw_only=True, slots=True)
1675
+ class ToYearsError(Exception): ...
1676
+
1677
+
1678
+ @dataclass(kw_only=True, slots=True)
1679
+ class _ToYearsMonthsError(ToYearsError):
1680
+ delta: DateOrDateTimeDelta
1681
+ months: int
1682
+
1683
+ @override
1684
+ def __str__(self) -> str:
1685
+ return f"Delta must not contain months; got {self.months}"
1686
+
1687
+
1688
+ @dataclass(kw_only=True, slots=True)
1689
+ class _ToYearsDaysError(ToYearsError):
1690
+ delta: DateOrDateTimeDelta
1691
+ days: int
1692
+
1693
+ @override
1694
+ def __str__(self) -> str:
1695
+ return f"Delta must not contain days; got {self.days}"
1696
+
1697
+
1698
+ @dataclass(kw_only=True, slots=True)
1699
+ class _ToYearsTimeError(ToYearsError):
1700
+ delta: DateTimeDelta
1701
+
1702
+ @override
1703
+ def __str__(self) -> str:
1704
+ return f"Delta must not contain a time part; got {self.delta.time_part()}"
1705
+
1706
+
1707
+ ##
1708
+
1709
+
753
1710
  @overload
754
- def to_zoned_date_time(*, date_time: MaybeCallableZonedDateTime) -> ZonedDateTime: ...
755
- @overload
756
- 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: ...
757
1714
  @overload
758
- def to_zoned_date_time(*, date_time: Sentinel) -> Sentinel: ...
759
1715
  def to_zoned_date_time(
760
- *, date_time: MaybeCallableZonedDateTime | None | Sentinel = sentinel
761
- ) -> ZonedDateTime | None | Sentinel:
762
- """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."""
763
1728
  match date_time:
764
- case ZonedDateTime() | None | Sentinel():
765
- 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)
766
1745
  case Callable() as func:
767
- return to_zoned_date_time(date_time=func())
768
- case _ as never:
1746
+ return to_zoned_date_time(func(), time_zone=time_zone)
1747
+ case never:
769
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}"
1761
+
1762
+
1763
+ ##
1764
+
1765
+
1766
+ def two_digit_year_month(year: int, month: int, /) -> YearMonth:
1767
+ """Construct a year-month from a 2-digit year."""
1768
+ min_year = DATE_TWO_DIGIT_YEAR_MIN.year
1769
+ max_year = DATE_TWO_DIGIT_YEAR_MAX.year
1770
+ years = range(min_year, max_year + 1)
1771
+ (year_use,) = (y for y in years if y % 100 == year)
1772
+ return YearMonth(year_use, month)
770
1773
 
771
1774
 
772
1775
  ##
@@ -794,32 +1797,203 @@ class WheneverLogRecord(LogRecord):
794
1797
  name, level, pathname, lineno, msg, args, exc_info, func, sinfo
795
1798
  )
796
1799
  length = self._get_length()
797
- plain = format(get_now_local().to_plain().format_common_iso(), f"{length}s")
798
- time_zone = self._get_time_zone_key()
799
- self.zoned_datetime = f"{plain}[{time_zone}]"
800
-
801
- @classmethod
802
- @cache
803
- def _get_time_zone(cls) -> ZoneInfo:
804
- """Get the local timezone."""
805
- try:
806
- from utilities.tzlocal import get_local_time_zone
807
- except ModuleNotFoundError: # pragma: no cover
808
- return UTC
809
- return get_local_time_zone()
810
-
811
- @classmethod
812
- @cache
813
- def _get_time_zone_key(cls) -> str:
814
- """Get the local timezone as a string."""
815
- 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}]"
816
1802
 
817
1803
  @classmethod
818
1804
  @cache
819
1805
  def _get_length(cls) -> int:
820
1806
  """Get maximum length of a formatted string."""
821
1807
  now = get_now_local().replace(nanosecond=1000).to_plain()
822
- 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}"
823
1997
 
824
1998
 
825
1999
  __all__ = [
@@ -827,8 +2001,6 @@ __all__ = [
827
2001
  "DATE_DELTA_MIN",
828
2002
  "DATE_DELTA_PARSABLE_MAX",
829
2003
  "DATE_DELTA_PARSABLE_MIN",
830
- "DATE_MAX",
831
- "DATE_MIN",
832
2004
  "DATE_TIME_DELTA_MAX",
833
2005
  "DATE_TIME_DELTA_MIN",
834
2006
  "DATE_TIME_DELTA_PARSABLE_MAX",
@@ -841,16 +2013,14 @@ __all__ = [
841
2013
  "MILLISECOND",
842
2014
  "MINUTE",
843
2015
  "MONTH",
844
- "MONTH_MAX",
845
- "MONTH_MIN",
846
2016
  "NOW_LOCAL",
847
- "PLAIN_DATE_TIME_MAX",
848
- "PLAIN_DATE_TIME_MIN",
2017
+ "NOW_LOCAL_PLAIN",
2018
+ "NOW_PLAIN",
849
2019
  "SECOND",
850
2020
  "TIME_DELTA_MAX",
851
2021
  "TIME_DELTA_MIN",
852
- "TIME_MAX",
853
- "TIME_MIN",
2022
+ "TIME_LOCAL",
2023
+ "TIME_UTC",
854
2024
  "TODAY_LOCAL",
855
2025
  "TODAY_UTC",
856
2026
  "WEEK",
@@ -859,32 +2029,60 @@ __all__ = [
859
2029
  "ZERO_TIME",
860
2030
  "ZONED_DATE_TIME_MAX",
861
2031
  "ZONED_DATE_TIME_MIN",
862
- "DateOrMonth",
863
- "Freq",
864
- "FreqError",
865
- "FreqLike",
2032
+ "DatePeriod",
2033
+ "DatePeriodError",
866
2034
  "MeanDateTimeError",
867
2035
  "MinMaxDateError",
868
- "Month",
869
- "MonthError",
870
- "MonthLike",
2036
+ "PeriodDict",
2037
+ "RoundDateOrDateTimeError",
2038
+ "TimePeriod",
871
2039
  "ToDaysError",
872
- "ToNanosError",
2040
+ "ToMinutesError",
2041
+ "ToMonthsAndDaysError",
2042
+ "ToMonthsError",
2043
+ "ToNanosecondsError",
2044
+ "ToPyTimeDeltaError",
2045
+ "ToSecondsError",
2046
+ "ToWeeksError",
2047
+ "ToYearsError",
873
2048
  "WheneverLogRecord",
2049
+ "ZonedDateTimePeriod",
2050
+ "ZonedDateTimePeriodError",
2051
+ "add_year_month",
874
2052
  "datetime_utc",
2053
+ "diff_year_month",
875
2054
  "format_compact",
876
2055
  "from_timestamp",
877
2056
  "from_timestamp_millis",
878
2057
  "from_timestamp_nanos",
879
2058
  "get_now",
880
2059
  "get_now_local",
2060
+ "get_now_local_plain",
2061
+ "get_now_plain",
2062
+ "get_time",
2063
+ "get_time_local",
881
2064
  "get_today",
882
2065
  "get_today_local",
2066
+ "is_weekend",
883
2067
  "mean_datetime",
884
2068
  "min_max_date",
2069
+ "round_date_or_date_time",
2070
+ "sub_year_month",
885
2071
  "to_date",
886
2072
  "to_date_time_delta",
887
2073
  "to_days",
888
- "to_nanos",
2074
+ "to_microseconds",
2075
+ "to_milliseconds",
2076
+ "to_minutes",
2077
+ "to_months",
2078
+ "to_months_and_days",
2079
+ "to_nanoseconds",
2080
+ "to_py_date_or_date_time",
2081
+ "to_py_time_delta",
2082
+ "to_seconds",
2083
+ "to_time",
2084
+ "to_weeks",
2085
+ "to_years",
889
2086
  "to_zoned_date_time",
2087
+ "two_digit_year_month",
890
2088
  ]