dycw-utilities 0.132.4__py3-none-any.whl → 0.133.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dycw-utilities
3
- Version: 0.132.4
3
+ Version: 0.133.1
4
4
  Author-email: Derek Wan <d.wan@icloud.com>
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -1,11 +1,12 @@
1
- utilities/__init__.py,sha256=XNt4jtceKkZ5SzTlJ0nWpgaWnxz3PNjWlzmzu1iXOtI,60
1
+ utilities/__init__.py,sha256=Z40RN6IDVR495cIDu4FY4lG1K8hcbWIcPkM_PM_rASQ,60
2
2
  utilities/aiolimiter.py,sha256=mD0wEiqMgwpty4XTbawFpnkkmJS6R4JRsVXFUaoitSU,628
3
3
  utilities/altair.py,sha256=HeZBVUocjkrTNwwKrClppsIqgNFF-ykv05HfZSoHYno,9104
4
+ utilities/arq.py,sha256=YkwvWoL930hgeU9VP8iuP3RhMf0t8sm7O8qsD9TiyWo,4688
4
5
  utilities/asyncio.py,sha256=USWMMrHqPVRr20vlIn_n5JLimyqa-5xLhuqDYWJed8A,37586
5
6
  utilities/atomicwrites.py,sha256=geFjn9Pwn-tTrtoGjDDxWli9NqbYfy3gGL6ZBctiqSo,5393
6
7
  utilities/atools.py,sha256=-bFGIrwYMFR7xl39j02DZMsO_u5x5_Ph7bRlBUFVYyw,1048
7
8
  utilities/cachetools.py,sha256=uBtEv4hD-TuCPX_cQy1lOpLF-QqfwnYGSf0o4Soqydc,2826
8
- utilities/click.py,sha256=DI8yJFlpBpRvnc90Xc0kfLKGQRpFCvj797oOJiaE4k8,14998
9
+ utilities/click.py,sha256=2k7Ss2qKwYb1JCDB5IWpNf-B2WTyjKR1GxDW-Y6anPs,15736
9
10
  utilities/concurrent.py,sha256=s2scTEd2AhXVTW4hpASU2qxV_DiVLALfms55cCQzCvM,2886
10
11
  utilities/contextlib.py,sha256=lpaLJBy3X0UGLWjM98jkQZZq8so4fRmoK-Bheq0uOW4,1027
11
12
  utilities/contextvars.py,sha256=RsSGGrbQqqZ67rOydnM7WWIsM2lIE31UHJLejnHJPWY,505
@@ -23,7 +24,7 @@ utilities/getpass.py,sha256=DfN5UgMAtFCqS3dSfFHUfqIMZX2shXvwphOz_6J6f6A,103
23
24
  utilities/git.py,sha256=oi7-_l5e9haSANSCvQw25ufYGoNahuUPHAZ6114s3JQ,1191
24
25
  utilities/hashlib.py,sha256=SVTgtguur0P4elppvzOBbLEjVM3Pea0eWB61yg2ilxo,309
25
26
  utilities/http.py,sha256=WcahTcKYRtZ04WXQoWt5EGCgFPcyHD3EJdlMfxvDt-0,946
26
- utilities/hypothesis.py,sha256=MS0UgZjevC9QuJAUlGa8ozcbAhlq1qZnSRiSk_1KsXg,35204
27
+ utilities/hypothesis.py,sha256=LWEcjL0ip0cwawcIHhnWPVnB1OEH1GJnYEwYU6xtFpo,36259
27
28
  utilities/importlib.py,sha256=mV1xT_O_zt_GnZZ36tl3xOmMaN_3jErDWY54fX39F6Y,429
28
29
  utilities/inflect.py,sha256=DbqB5Q9FbRGJ1NbvEiZBirRMxCxgrz91zy5jCO9ZIs0,347
29
30
  utilities/ipython.py,sha256=V2oMYHvEKvlNBzxDXdLvKi48oUq2SclRg5xasjaXStw,763
@@ -77,18 +78,18 @@ utilities/text.py,sha256=ymBFlP_cA8OgNnZRVNs7FAh7OG8HxE6YkiLEMZv5g_A,11297
77
78
  utilities/threading.py,sha256=GvBOp4CyhHfN90wGXZuA2VKe9fGzMaEa7oCl4f3nnPU,1009
78
79
  utilities/timer.py,sha256=oYqRQ-G-DMOOHB6a4yP5-PJDVimLnbNkMnkOj_jUmFg,2474
79
80
  utilities/traceback.py,sha256=i-790AQbTrDA8MiYyOcYPFpm48I558VR_kL_7x4ypfY,8503
80
- utilities/typed_settings.py,sha256=DqJsJjSit9wd_OA3KyMDpx2zatKIi5QhuARI9TPl3rk,3701
81
- utilities/types.py,sha256=ZkTndROqNbpgUa_MX4pYlkfmU9E8prMqr4UvASruhsE,19013
81
+ utilities/typed_settings.py,sha256=io3bhnglxO5FRNuTz1vpgbbGgvyj6VGJ5pytPRUeJo4,3769
82
+ utilities/types.py,sha256=ZvD16TobtB47IgMo2CK_CCdJsvhrTqAZgqgqbCME2T0,19223
82
83
  utilities/typing.py,sha256=kVWK6ciV8T0MKxnFQcMSEr_XlRisspH5aBTTosMUh30,13872
83
84
  utilities/tzdata.py,sha256=fgNVj66yUbCSI_-vrRVzSD3gtf-L_8IEJEPjP_Jel5Y,266
84
85
  utilities/tzlocal.py,sha256=KyCXEgCTjqGFx-389JdTuhMRUaT06U1RCMdWoED-qro,728
85
86
  utilities/uuid.py,sha256=jJTFxz-CWgltqNuzmythB7iEQ-Q1mCwPevUfKthZT3c,611
86
87
  utilities/version.py,sha256=ufhJMmI6KPs1-3wBI71aj5wCukd3sP_m11usLe88DNA,5117
87
88
  utilities/warnings.py,sha256=un1LvHv70PU-LLv8RxPVmugTzDJkkGXRMZTE2-fTQHw,1771
88
- utilities/whenever.py,sha256=tArX9unVEKhRYdvbUFa83e4hrzdtMKKCEN4QWTaYd8c,19524
89
+ utilities/whenever.py,sha256=A-yoOqBqrcVD1yDINDsTFDw7dq9-zgUGn_f8CxVUQJs,23332
89
90
  utilities/zipfile.py,sha256=24lQc9ATcJxHXBPc_tBDiJk48pWyRrlxO2fIsFxU0A8,699
90
91
  utilities/zoneinfo.py,sha256=oEH-nL3t4h9uawyZqWDtNtDAl6M-CLpLYGI_nI6DulM,1971
91
- dycw_utilities-0.132.4.dist-info/METADATA,sha256=_AReviYgS7sJRlfoEv0VV19ABmMgXnFz2uWE0Rq3JNI,1522
92
- dycw_utilities-0.132.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
93
- dycw_utilities-0.132.4.dist-info/licenses/LICENSE,sha256=gppZp16M6nSVpBbUBrNL6JuYfvKwZiKgV7XoKKsHzqo,1066
94
- dycw_utilities-0.132.4.dist-info/RECORD,,
92
+ dycw_utilities-0.133.1.dist-info/METADATA,sha256=fZj3XXeQfb7y2obavSG6zSthc-oG_Zt-I33T3RVsHOE,1522
93
+ dycw_utilities-0.133.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
94
+ dycw_utilities-0.133.1.dist-info/licenses/LICENSE,sha256=gppZp16M6nSVpBbUBrNL6JuYfvKwZiKgV7XoKKsHzqo,1066
95
+ dycw_utilities-0.133.1.dist-info/RECORD,,
utilities/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.132.4"
3
+ __version__ = "0.133.1"
utilities/arq.py ADDED
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from functools import wraps
5
+ from itertools import chain
6
+ from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, cast, override
7
+
8
+ from arq.constants import default_queue_name, expires_extra_ms
9
+ from arq.cron import cron
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Callable, Iterable, Sequence
13
+ from datetime import timezone
14
+
15
+ from arq.connections import ArqRedis, RedisSettings
16
+ from arq.cron import CronJob
17
+ from arq.jobs import Deserializer, Serializer
18
+ from arq.typing import (
19
+ OptionType,
20
+ SecondsTimedelta,
21
+ StartupShutdown,
22
+ WeekdayOptionType,
23
+ WorkerCoroutine,
24
+ )
25
+ from arq.worker import Function
26
+
27
+ from utilities.types import CallableCoroutine1, Coroutine1, StrMapping
28
+
29
+ _P = ParamSpec("_P")
30
+ _T = TypeVar("_T")
31
+
32
+
33
+ ##
34
+
35
+
36
+ def cron_raw(
37
+ coroutine: CallableCoroutine1[Any],
38
+ /,
39
+ *,
40
+ name: str | None = None,
41
+ month: OptionType = None,
42
+ day: OptionType = None,
43
+ weekday: WeekdayOptionType = None,
44
+ hour: OptionType = None,
45
+ minute: OptionType = None,
46
+ second: OptionType = 0,
47
+ microsecond: int = 123_456,
48
+ run_at_startup: bool = False,
49
+ unique: bool = True,
50
+ job_id: str | None = None,
51
+ timeout: SecondsTimedelta | None = None,
52
+ keep_result: float | None = 0,
53
+ keep_result_forever: bool | None = False,
54
+ max_tries: int | None = 1,
55
+ args: Iterable[Any] | None = None,
56
+ kwargs: StrMapping | None = None,
57
+ ) -> CronJob:
58
+ """Create a cron job with a raw coroutine function."""
59
+ lifted = _lift_cron(
60
+ coroutine, *(() if args is None else args), **({} if kwargs is None else kwargs)
61
+ )
62
+ return cron(
63
+ lifted,
64
+ name=name,
65
+ month=month,
66
+ day=day,
67
+ weekday=weekday,
68
+ hour=hour,
69
+ minute=minute,
70
+ second=second,
71
+ microsecond=microsecond,
72
+ run_at_startup=run_at_startup,
73
+ unique=unique,
74
+ job_id=job_id,
75
+ timeout=timeout,
76
+ keep_result=keep_result,
77
+ keep_result_forever=keep_result_forever,
78
+ max_tries=max_tries,
79
+ )
80
+
81
+
82
+ def _lift_cron(
83
+ func: Callable[_P, Coroutine1[_T]], *args: _P.args, **kwargs: _P.kwargs
84
+ ) -> WorkerCoroutine:
85
+ """Lift a coroutine function & call arg/kwargs for `cron`."""
86
+
87
+ @wraps(func)
88
+ async def wrapped(ctx: StrMapping, /) -> _T:
89
+ _ = ctx
90
+ return await func(*args, **kwargs)
91
+
92
+ return cast("Any", wrapped)
93
+
94
+
95
+ ##
96
+
97
+
98
+ class _WorkerMeta(type):
99
+ @override
100
+ def __new__(
101
+ mcs: type[_WorkerMeta],
102
+ name: str,
103
+ bases: tuple[type, ...],
104
+ namespace: dict[str, Any],
105
+ /,
106
+ ) -> type[Worker]:
107
+ cls = cast("type[Worker]", super().__new__(mcs, name, bases, namespace))
108
+ cls.functions = tuple(chain(cls.functions, map(cls._lift, cls.functions_raw)))
109
+ return cls
110
+
111
+ @classmethod
112
+ def _lift(cls, func: Callable[_P, Coroutine1[_T]]) -> WorkerCoroutine:
113
+ """Lift a coroutine function to accept the required `ctx` argument."""
114
+
115
+ @wraps(func)
116
+ async def wrapped(ctx: StrMapping, *args: _P.args, **kwargs: _P.kwargs) -> _T:
117
+ _ = ctx
118
+ return await func(*args, **kwargs)
119
+
120
+ return cast("Any", wrapped)
121
+
122
+
123
+ @dataclass(kw_only=True)
124
+ class Worker(metaclass=_WorkerMeta):
125
+ """Base class for all workers."""
126
+
127
+ functions: Sequence[Function | WorkerCoroutine] = ()
128
+ functions_raw: Sequence[CallableCoroutine1[Any]] = ()
129
+ queue_name: str | None = default_queue_name
130
+ cron_jobs: Sequence[CronJob] | None = None
131
+ redis_settings: RedisSettings | None = None
132
+ redis_pool: ArqRedis | None = None
133
+ burst: bool = False
134
+ on_startup: StartupShutdown | None = None
135
+ on_shutdown: StartupShutdown | None = None
136
+ on_job_start: StartupShutdown | None = None
137
+ on_job_end: StartupShutdown | None = None
138
+ after_job_end: StartupShutdown | None = None
139
+ handle_signals: bool = True
140
+ job_completion_wait: int = 0
141
+ max_jobs: int = 10
142
+ job_timeout: SecondsTimedelta = 300
143
+ keep_result: SecondsTimedelta = 3600
144
+ keep_result_forever: bool = False
145
+ poll_delay: SecondsTimedelta = 0.5
146
+ queue_read_limit: int | None = None
147
+ max_tries: int = 5
148
+ health_check_interval: SecondsTimedelta = 3600
149
+ health_check_key: str | None = None
150
+ ctx: dict[Any, Any] | None = None
151
+ retry_jobs: bool = True
152
+ allow_abort_jobs: bool = False
153
+ max_burst_jobs: int = -1
154
+ job_serializer: Serializer | None = None
155
+ job_deserializer: Deserializer | None = None
156
+ expires_extra_ms: int = expires_extra_ms
157
+ timezone: timezone | None = None
158
+ log_results: bool = True
159
+
160
+
161
+ __all__ = ["Worker", "cron"]
utilities/click.py CHANGED
@@ -29,7 +29,7 @@ from utilities.types import (
29
29
  TimeLike,
30
30
  ZonedDateTimeLike,
31
31
  )
32
- from utilities.whenever import _MonthParseCommonISOError
32
+ from utilities.whenever import FreqLike, _FreqParseError, _MonthParseCommonISOError
33
33
 
34
34
  if TYPE_CHECKING:
35
35
  from collections.abc import Iterable, Sequence
@@ -177,6 +177,30 @@ class Enum(ParamType, Generic[TEnum]):
177
177
  return _make_metavar(param, desc)
178
178
 
179
179
 
180
+ class Freq(ParamType):
181
+ """An frequency-valued parameter."""
182
+
183
+ @override
184
+ def __repr__(self) -> str:
185
+ return "FREQ"
186
+
187
+ @override
188
+ def convert(
189
+ self, value: FreqLike, param: Parameter | None, ctx: Context | None
190
+ ) -> utilities.whenever.Freq:
191
+ """Convert a value into the `Freq` type."""
192
+ match value:
193
+ case utilities.whenever.Freq():
194
+ return value
195
+ case str():
196
+ try:
197
+ return utilities.whenever.Freq.parse(value)
198
+ except _FreqParseError as error:
199
+ self.fail(str(error), param, ctx)
200
+ case _ as never:
201
+ assert_never(never)
202
+
203
+
180
204
  class IPv4Address(ParamType):
181
205
  """An IPv4 address-valued parameter."""
182
206
 
@@ -519,6 +543,7 @@ __all__ = [
519
543
  "ExistingDirPath",
520
544
  "ExistingFilePath",
521
545
  "FilePath",
546
+ "Freq",
522
547
  "FrozenSetChoices",
523
548
  "FrozenSetEnums",
524
549
  "FrozenSetParameter",
utilities/hypothesis.py CHANGED
@@ -77,6 +77,8 @@ from utilities.pathlib import temp_cwd
77
77
  from utilities.platform import IS_WINDOWS
78
78
  from utilities.sentinel import Sentinel, sentinel
79
79
  from utilities.tempfile import TEMP_DIR, TemporaryDirectory
80
+ from utilities.types import DateTimeRoundUnit
81
+ from utilities.typing import get_literal_elements
80
82
  from utilities.version import Version
81
83
  from utilities.whenever import (
82
84
  DATE_DELTA_MAX,
@@ -100,6 +102,7 @@ from utilities.whenever import (
100
102
  TIME_DELTA_MIN,
101
103
  TIME_MAX,
102
104
  TIME_MIN,
105
+ Freq,
103
106
  Month,
104
107
  to_date_time_delta,
105
108
  to_days,
@@ -502,6 +505,38 @@ def floats_extra(
502
505
  ##
503
506
 
504
507
 
508
+ @composite
509
+ def freqs(
510
+ draw: DrawFn, /, *, unit: MaybeSearchStrategy[DateTimeRoundUnit | None] = None
511
+ ) -> Freq:
512
+ unit_ = draw2(draw, unit, _freq_units())
513
+ match unit_:
514
+ case "day":
515
+ return Freq(unit=unit_)
516
+ case "hour":
517
+ return Freq(unit=unit_, increment=draw(_freq_increments(24)))
518
+ case "minute" | "second":
519
+ return Freq(unit=unit_, increment=draw(_freq_increments(60)))
520
+ case "millisecond" | "microsecond" | "nanosecond":
521
+ return Freq(unit=unit_, increment=draw(_freq_increments(1000)))
522
+ case _ as never:
523
+ assert_never(never)
524
+
525
+
526
+ @composite
527
+ def _freq_units(draw: DrawFn, /) -> DateTimeRoundUnit:
528
+ return draw(sampled_from(get_literal_elements(DateTimeRoundUnit)))
529
+
530
+
531
+ @composite
532
+ def _freq_increments(draw: DrawFn, n: int, /) -> int:
533
+ divisors = [i for i in range(1, n) if n % i == 0]
534
+ return draw(sampled_from(divisors))
535
+
536
+
537
+ ##
538
+
539
+
505
540
  @composite
506
541
  def git_repos(draw: DrawFn, /) -> Path:
507
542
  path = draw(temp_paths())
@@ -1264,6 +1299,7 @@ __all__ = [
1264
1299
  "float64s",
1265
1300
  "float_arrays",
1266
1301
  "floats_extra",
1302
+ "freqs",
1267
1303
  "git_repos",
1268
1304
  "hashables",
1269
1305
  "int32s",
@@ -21,6 +21,7 @@ from whenever import (
21
21
  )
22
22
 
23
23
  from utilities.iterables import always_iterable
24
+ from utilities.whenever import Freq
24
25
 
25
26
  if TYPE_CHECKING:
26
27
  from collections.abc import Callable
@@ -52,6 +53,7 @@ class ExtendedTSConverter(TSConverter):
52
53
  (Date, Date.parse_common_iso),
53
54
  (DateDelta, DateDelta.parse_common_iso),
54
55
  (DateTimeDelta, DateTimeDelta.parse_common_iso),
56
+ (Freq, Freq.parse),
55
57
  (IPv4Address, IPv4Address),
56
58
  (IPv6Address, IPv6Address),
57
59
  (PlainDateTime, PlainDateTime.parse_common_iso),
utilities/types.py CHANGED
@@ -72,12 +72,16 @@ type Coroutine1[_T] = Coroutine[Any, Any, _T]
72
72
  type MaybeAwaitable[_T] = _T | Awaitable[_T]
73
73
  type MaybeCallableEvent = MaybeCallable[Event]
74
74
  type MaybeCoroutine1[_T] = _T | Coroutine1[_T]
75
+ type CallableCoroutine1[_T] = Callable[..., Coroutine1[_T]]
75
76
 
76
77
 
77
78
  # callable
78
79
  TCallable = TypeVar("TCallable", bound=Callable[..., Any])
79
80
  TCallable1 = TypeVar("TCallable1", bound=Callable[..., Any])
80
81
  TCallable2 = TypeVar("TCallable2", bound=Callable[..., Any])
82
+ TCallableCoroutine1 = TypeVar(
83
+ "TCallableCoroutine1", bound=Callable[..., Coroutine1[Any]]
84
+ )
81
85
  TCallableMaybeCoroutine1None = TypeVar(
82
86
  "TCallableMaybeCoroutine1None", bound=Callable[..., MaybeCoroutine1[None]]
83
87
  )
@@ -292,6 +296,7 @@ type TimeZoneLike = (
292
296
 
293
297
 
294
298
  __all__ = [
299
+ "CallableCoroutine1",
295
300
  "Coroutine1",
296
301
  "Dataclass",
297
302
  "DateDeltaLike",
@@ -346,6 +351,7 @@ __all__ = [
346
351
  "TCallable",
347
352
  "TCallable1",
348
353
  "TCallable2",
354
+ "TCallableCoroutine1",
349
355
  "TCallableMaybeCoroutine1None",
350
356
  "TDataclass",
351
357
  "TEnum",
utilities/whenever.py CHANGED
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from collections.abc import Callable, Iterable
3
+ from collections.abc import Callable, Iterable, Mapping
4
4
  from dataclasses import dataclass, replace
5
5
  from functools import cache
6
6
  from logging import LogRecord
@@ -8,9 +8,12 @@ from statistics import fmean
8
8
  from typing import (
9
9
  TYPE_CHECKING,
10
10
  Any,
11
+ ClassVar,
12
+ Literal,
11
13
  Self,
12
14
  SupportsFloat,
13
15
  assert_never,
16
+ cast,
14
17
  overload,
15
18
  override,
16
19
  )
@@ -28,7 +31,7 @@ from utilities.math import sign
28
31
  from utilities.platform import get_strftime
29
32
  from utilities.re import ExtractGroupsError, extract_groups
30
33
  from utilities.sentinel import Sentinel, sentinel
31
- from utilities.types import MaybeStr
34
+ from utilities.types import DateTimeRoundUnit, MaybeStr
32
35
  from utilities.tzlocal import LOCAL_TIME_ZONE, LOCAL_TIME_ZONE_NAME
33
36
  from utilities.zoneinfo import UTC, get_time_zone_name
34
37
 
@@ -166,6 +169,127 @@ def format_compact(datetime: ZonedDateTime, /) -> str:
166
169
  ##
167
170
 
168
171
 
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})"
219
+
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
+
231
+ def serialize(self) -> str:
232
+ if self.increment == 1:
233
+ return self._abbreviation
234
+ return f"{self.increment}{self._abbreviation}"
235
+
236
+ @classmethod
237
+ def _abbreviate(cls, unit: DateTimeRoundUnit, /) -> _DateTimeRoundUnitAbbrev:
238
+ return cls._mapping[unit]
239
+
240
+ @property
241
+ def _abbreviation(self) -> _DateTimeRoundUnitAbbrev:
242
+ return self._mapping[self.unit]
243
+
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
+
252
+
253
+ type FreqLike = MaybeStr[Freq]
254
+ type _DateTimeRoundUnitAbbrev = Literal["D", "H", "M", "S", "ms", "us", "ns"]
255
+
256
+
257
+ @dataclass(kw_only=True, slots=True)
258
+ class FreqError(Exception): ...
259
+
260
+
261
+ @dataclass(kw_only=True, slots=True)
262
+ class _FreqDayIncrementError(FreqError):
263
+ increment: int
264
+
265
+ @override
266
+ def __str__(self) -> str:
267
+ return f"Increment must be 1 for the 'day' unit; got {self.increment}"
268
+
269
+
270
+ @dataclass(kw_only=True, slots=True)
271
+ class _FreqIncrementError(FreqError):
272
+ unit: DateTimeRoundUnit
273
+ increment: int
274
+ divisor: int
275
+
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
+
280
+
281
+ @dataclass(kw_only=True, slots=True)
282
+ class _FreqParseError(FreqError):
283
+ text: str
284
+
285
+ @override
286
+ def __str__(self) -> str:
287
+ return f"Unable to parse frequency; got {self.text!r}"
288
+
289
+
290
+ ##
291
+
292
+
169
293
  def from_timestamp(i: float, /, *, time_zone: TimeZoneLike = UTC) -> ZonedDateTime:
170
294
  """Get a zoned datetime from a timestamp."""
171
295
  return ZonedDateTime.from_timestamp(i, tz=get_time_zone_name(time_zone))
@@ -736,6 +860,9 @@ __all__ = [
736
860
  "ZONED_DATE_TIME_MAX",
737
861
  "ZONED_DATE_TIME_MIN",
738
862
  "DateOrMonth",
863
+ "Freq",
864
+ "FreqError",
865
+ "FreqLike",
739
866
  "MeanDateTimeError",
740
867
  "MinMaxDateError",
741
868
  "Month",