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.
- dycw_utilities-0.178.1.dist-info/METADATA +34 -0
- dycw_utilities-0.178.1.dist-info/RECORD +105 -0
- dycw_utilities-0.178.1.dist-info/WHEEL +4 -0
- dycw_utilities-0.178.1.dist-info/entry_points.txt +4 -0
- utilities/__init__.py +1 -1
- utilities/altair.py +13 -10
- utilities/asyncio.py +312 -787
- utilities/atomicwrites.py +18 -6
- utilities/atools.py +64 -4
- utilities/cachetools.py +9 -6
- utilities/click.py +195 -77
- utilities/concurrent.py +1 -1
- utilities/contextlib.py +216 -17
- utilities/contextvars.py +20 -1
- utilities/cryptography.py +3 -3
- utilities/dataclasses.py +15 -28
- utilities/docker.py +387 -0
- utilities/enum.py +2 -2
- utilities/errors.py +17 -3
- utilities/fastapi.py +28 -59
- utilities/fpdf2.py +2 -2
- utilities/functions.py +24 -269
- utilities/git.py +9 -30
- utilities/grp.py +28 -0
- utilities/gzip.py +31 -0
- utilities/http.py +3 -2
- utilities/hypothesis.py +513 -159
- utilities/importlib.py +17 -1
- utilities/inflect.py +12 -4
- utilities/iterables.py +33 -58
- utilities/jinja2.py +148 -0
- utilities/json.py +70 -0
- utilities/libcst.py +38 -17
- utilities/lightweight_charts.py +4 -7
- utilities/logging.py +136 -93
- utilities/math.py +8 -4
- utilities/more_itertools.py +43 -45
- utilities/operator.py +27 -27
- utilities/orjson.py +189 -36
- utilities/os.py +61 -4
- utilities/packaging.py +115 -0
- utilities/parse.py +8 -5
- utilities/pathlib.py +269 -40
- utilities/permissions.py +298 -0
- utilities/platform.py +7 -6
- utilities/polars.py +1205 -413
- utilities/polars_ols.py +1 -1
- utilities/postgres.py +408 -0
- utilities/pottery.py +43 -19
- utilities/pqdm.py +3 -3
- utilities/psutil.py +5 -57
- utilities/pwd.py +28 -0
- utilities/pydantic.py +4 -52
- utilities/pydantic_settings.py +240 -0
- utilities/pydantic_settings_sops.py +76 -0
- utilities/pyinstrument.py +7 -7
- utilities/pytest.py +104 -143
- utilities/pytest_plugins/__init__.py +1 -0
- utilities/pytest_plugins/pytest_randomly.py +23 -0
- utilities/pytest_plugins/pytest_regressions.py +56 -0
- utilities/pytest_regressions.py +26 -46
- utilities/random.py +11 -6
- utilities/re.py +1 -1
- utilities/redis.py +220 -343
- utilities/sentinel.py +10 -0
- utilities/shelve.py +4 -1
- utilities/shutil.py +25 -0
- utilities/slack_sdk.py +35 -104
- utilities/sqlalchemy.py +496 -471
- utilities/sqlalchemy_polars.py +29 -54
- utilities/string.py +2 -3
- utilities/subprocess.py +1977 -0
- utilities/tempfile.py +112 -4
- utilities/testbook.py +50 -0
- utilities/text.py +174 -42
- utilities/throttle.py +158 -0
- utilities/timer.py +2 -2
- utilities/traceback.py +70 -35
- utilities/types.py +102 -30
- utilities/typing.py +479 -19
- utilities/uuid.py +42 -5
- utilities/version.py +27 -26
- utilities/whenever.py +1559 -361
- utilities/zoneinfo.py +80 -22
- dycw_utilities-0.135.0.dist-info/METADATA +0 -39
- dycw_utilities-0.135.0.dist-info/RECORD +0 -96
- dycw_utilities-0.135.0.dist-info/WHEEL +0 -4
- dycw_utilities-0.135.0.dist-info/licenses/LICENSE +0 -21
- utilities/aiolimiter.py +0 -25
- utilities/arq.py +0 -216
- utilities/eventkit.py +0 -388
- utilities/luigi.py +0 -183
- utilities/period.py +0 -152
- utilities/pudb.py +0 -62
- utilities/python_dotenv.py +0 -101
- utilities/streamlit.py +0 -105
- utilities/typed_settings.py +0 -123
utilities/whenever.py
CHANGED
|
@@ -1,63 +1,63 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
from
|
|
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,
|
|
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
|
-
|
|
43
|
-
|
|
45
|
+
DateOrDateTimeDelta,
|
|
46
|
+
DateTimeRoundMode,
|
|
47
|
+
Delta,
|
|
48
|
+
MaybeCallableDateLike,
|
|
49
|
+
MaybeCallableTimeLike,
|
|
50
|
+
MaybeCallableZonedDateTimeLike,
|
|
51
|
+
TimeOrDateTimeDelta,
|
|
44
52
|
TimeZoneLike,
|
|
45
53
|
)
|
|
46
54
|
|
|
47
55
|
|
|
48
|
-
|
|
56
|
+
# bounds
|
|
49
57
|
|
|
50
58
|
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
299
|
-
"""Get
|
|
300
|
-
return
|
|
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
|
-
|
|
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
|
|
312
|
-
"""Get the current
|
|
313
|
-
return
|
|
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
|
-
|
|
403
|
+
TIME_UTC = get_time(UTC)
|
|
317
404
|
|
|
318
405
|
|
|
319
|
-
def
|
|
320
|
-
"""Get the current local time."""
|
|
321
|
-
return
|
|
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
|
-
|
|
411
|
+
TIME_LOCAL = get_time_local()
|
|
325
412
|
|
|
326
413
|
|
|
327
414
|
##
|
|
328
415
|
|
|
329
416
|
|
|
330
|
-
def get_today(
|
|
417
|
+
def get_today(time_zone: TimeZoneLike = UTC, /) -> Date:
|
|
331
418
|
"""Get the current, timezone-aware local date."""
|
|
332
|
-
return get_now(time_zone
|
|
419
|
+
return get_now(time_zone).date()
|
|
333
420
|
|
|
334
421
|
|
|
335
|
-
TODAY_UTC = get_today(
|
|
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(
|
|
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
|
-
"""
|
|
387
|
-
today = get_today(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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
457
|
-
|
|
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
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
521
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
|
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
|
|
544
|
-
|
|
545
|
-
|
|
809
|
+
class _RoundDateOrDateTimeDateWithIntradayDeltaError(RoundDateOrDateTimeError):
|
|
810
|
+
date: Date
|
|
811
|
+
delta: Delta
|
|
546
812
|
|
|
547
813
|
@override
|
|
548
814
|
def __str__(self) -> str:
|
|
549
|
-
return f"
|
|
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
|
|
554
|
-
|
|
819
|
+
class _RoundDateOrDateTimeDateWithWeekdayError(RoundDateOrDateTimeError):
|
|
820
|
+
weekday: Weekday
|
|
555
821
|
|
|
556
822
|
@override
|
|
557
823
|
def __str__(self) -> str:
|
|
558
|
-
return f"
|
|
824
|
+
return f"Daily rounding must not be given a weekday; got {self.weekday}"
|
|
559
825
|
|
|
560
826
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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(*,
|
|
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
|
-
|
|
581
|
-
|
|
907
|
+
date: MaybeCallableDateLike | None | dt.date = get_today,
|
|
908
|
+
/,
|
|
909
|
+
*,
|
|
910
|
+
time_zone: TimeZoneLike = UTC,
|
|
911
|
+
) -> Date: ...
|
|
582
912
|
def to_date(
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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() |
|
|
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(
|
|
591
|
-
case
|
|
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
|
|
656
|
-
"""Compute the number of
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
|
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"
|
|
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
|
|
676
|
-
"""
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
|
690
|
-
|
|
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(
|
|
755
|
-
|
|
756
|
-
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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()
|
|
765
|
-
|
|
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(
|
|
768
|
-
case
|
|
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().
|
|
798
|
-
|
|
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.
|
|
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
|
-
"
|
|
848
|
-
"
|
|
2017
|
+
"NOW_LOCAL_PLAIN",
|
|
2018
|
+
"NOW_PLAIN",
|
|
849
2019
|
"SECOND",
|
|
850
2020
|
"TIME_DELTA_MAX",
|
|
851
2021
|
"TIME_DELTA_MIN",
|
|
852
|
-
"
|
|
853
|
-
"
|
|
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
|
-
"
|
|
863
|
-
"
|
|
864
|
-
"FreqError",
|
|
865
|
-
"FreqLike",
|
|
2032
|
+
"DatePeriod",
|
|
2033
|
+
"DatePeriodError",
|
|
866
2034
|
"MeanDateTimeError",
|
|
867
2035
|
"MinMaxDateError",
|
|
868
|
-
"
|
|
869
|
-
"
|
|
870
|
-
"
|
|
2036
|
+
"PeriodDict",
|
|
2037
|
+
"RoundDateOrDateTimeError",
|
|
2038
|
+
"TimePeriod",
|
|
871
2039
|
"ToDaysError",
|
|
872
|
-
"
|
|
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
|
-
"
|
|
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
|
]
|