dycw-utilities 0.131.18__py3-none-any.whl → 0.131.19__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
utilities/pytest.py CHANGED
@@ -8,10 +8,11 @@ from pathlib import Path
8
8
  from typing import TYPE_CHECKING, Any, ParamSpec, assert_never, cast, override
9
9
 
10
10
  from pytest import fixture
11
+ from whenever import ZonedDateTime
11
12
 
12
13
  from utilities.atomicwrites import writer
13
- from utilities.datetime import datetime_duration_to_float, get_now
14
14
  from utilities.functools import cache
15
+ from utilities.git import get_repo_root
15
16
  from utilities.hashlib import md5_hash
16
17
  from utilities.pathlib import ensure_suffix
17
18
  from utilities.platform import (
@@ -23,17 +24,15 @@ from utilities.platform import (
23
24
  IS_WINDOWS,
24
25
  )
25
26
  from utilities.random import get_state
27
+ from utilities.whenever2 import SECOND, get_now_local
26
28
 
27
29
  if TYPE_CHECKING:
28
30
  from collections.abc import Callable, Iterable, Sequence
29
31
  from random import Random
30
32
 
31
- from utilities.types import (
32
- Coroutine1,
33
- Duration,
34
- PathLike,
35
- TCallableMaybeCoroutine1None,
36
- )
33
+ from whenever import TimeDelta
34
+
35
+ from utilities.types import Coroutine1, PathLike, TCallableMaybeCoroutine1None
37
36
 
38
37
  try: # WARNING: this package cannot use unguarded `pytest` imports
39
38
  from _pytest.config import Config
@@ -169,88 +168,109 @@ def random_state(*, seed: int) -> Random:
169
168
 
170
169
 
171
170
  def throttle(
172
- *, root: PathLike | None = None, duration: Duration = 1.0, on_try: bool = False
171
+ *, root: PathLike | None = None, delta: TimeDelta = SECOND, on_try: bool = False
173
172
  ) -> Callable[[TCallableMaybeCoroutine1None], TCallableMaybeCoroutine1None]:
174
173
  """Throttle a test. On success by default, on try otherwise."""
175
- root_use = Path(".pytest_cache", "throttle") if root is None else Path(root)
176
- return cast(
177
- "Any", partial(_throttle_inner, root=root_use, duration=duration, on_try=on_try)
178
- )
174
+ return cast("Any", partial(_throttle_inner, root=root, delta=delta, on_try=on_try))
179
175
 
180
176
 
181
177
  def _throttle_inner(
182
178
  func: TCallableMaybeCoroutine1None,
183
179
  /,
184
180
  *,
185
- root: Path,
186
- duration: Duration = 1.0,
181
+ root: PathLike | None = None,
182
+ delta: TimeDelta = SECOND,
187
183
  on_try: bool = False,
188
184
  ) -> TCallableMaybeCoroutine1None:
189
185
  """Throttle a test function/method."""
190
- match bool(iscoroutinefunction(func)):
191
- case False:
192
- func_typed = cast("Callable[..., None]", func)
186
+ match bool(iscoroutinefunction(func)), on_try:
187
+ case False, False:
188
+
189
+ @wraps(func)
190
+ def throttle_sync_on_pass(*args: _P.args, **kwargs: _P.kwargs) -> None:
191
+ _skipif_recent(root=root, delta=delta)
192
+ cast("Callable[..., None]", func)(*args, **kwargs)
193
+ _write(root=root)
194
+
195
+ return cast("Any", throttle_sync_on_pass)
196
+
197
+ case False, True:
198
+
199
+ @wraps(func)
200
+ def throttle_sync_on_try(*args: _P.args, **kwargs: _P.kwargs) -> None:
201
+ _skipif_recent(root=root, delta=delta)
202
+ _write(root=root)
203
+ cast("Callable[..., None]", func)(*args, **kwargs)
204
+
205
+ return cast("Any", throttle_sync_on_try)
206
+
207
+ case True, False:
193
208
 
194
209
  @wraps(func)
195
- def throttle_sync(*args: _P.args, **kwargs: _P.kwargs) -> None:
196
- """Call the throttled sync test function/method."""
197
- path, now = _throttle_path_and_now(root, duration=duration)
198
- if on_try:
199
- _throttle_write(path, now)
200
- return func_typed(*args, **kwargs)
201
- func_typed(*args, **kwargs)
202
- _throttle_write(path, now)
203
- return None
204
-
205
- return cast("TCallableMaybeCoroutine1None", throttle_sync)
206
- case True:
207
- func_typed = cast("Callable[..., Coroutine1[None]]", func)
210
+ async def throttle_async_on_pass(
211
+ *args: _P.args, **kwargs: _P.kwargs
212
+ ) -> None:
213
+ _skipif_recent(root=root, delta=delta)
214
+ await cast("Callable[..., Coroutine1[None]]", func)(*args, **kwargs)
215
+ _write(root=root)
216
+
217
+ return cast("Any", throttle_async_on_pass)
218
+
219
+ case True, True:
208
220
 
209
221
  @wraps(func)
210
- async def throttle_async(*args: _P.args, **kwargs: _P.kwargs) -> None:
211
- """Call the throttled async test function/method."""
212
- path, now = _throttle_path_and_now(root, duration=duration)
213
- if on_try:
214
- _throttle_write(path, now)
215
- return await func_typed(*args, **kwargs)
216
- await func_typed(*args, **kwargs)
217
- _throttle_write(path, now)
218
- return None
219
-
220
- return cast("TCallableMaybeCoroutine1None", throttle_async)
222
+ async def throttle_async_on_try(
223
+ *args: _P.args, **kwargs: _P.kwargs
224
+ ) -> None:
225
+ _skipif_recent(root=root, delta=delta)
226
+ _write(root=root)
227
+ await cast("Callable[..., Coroutine1[None]]", func)(*args, **kwargs)
228
+
229
+ return cast("Any", throttle_async_on_try)
230
+
221
231
  case _ as never:
222
232
  assert_never(never)
223
233
 
224
234
 
225
- def _throttle_path_and_now(
226
- root: Path, /, *, duration: Duration = 1.0
227
- ) -> tuple[Path, float]:
228
- test = environ["PYTEST_CURRENT_TEST"]
229
- path = Path(root, _throttle_md5_hash(test))
230
- if path.exists():
231
- with path.open(mode="r") as fh:
232
- contents = fh.read()
233
- prev = float(contents)
235
+ def _skipif_recent(*, root: PathLike | None = None, delta: TimeDelta = SECOND) -> None:
236
+ if skip is None:
237
+ return # pragma: no cover
238
+ path = _get_path(root=root)
239
+ try:
240
+ contents = path.read_text()
241
+ except FileNotFoundError:
242
+ return
243
+ try:
244
+ last = ZonedDateTime.parse_common_iso(contents)
245
+ except ValueError:
246
+ return
247
+ if (age := (get_now_local() - last)) < delta:
248
+ _ = skip(reason=f"{_get_name()} throttled (age {age})")
249
+
250
+
251
+ def _get_path(*, root: PathLike | None = None) -> Path:
252
+ if root is None:
253
+ root_use = get_repo_root().joinpath( # pragma: no cover
254
+ ".pytest_cache", "throttle"
255
+ )
234
256
  else:
235
- prev = None
236
- now = get_now().timestamp()
237
- if (
238
- (skip is not None)
239
- and (prev is not None)
240
- and ((now - prev) < datetime_duration_to_float(duration))
241
- ):
242
- _ = skip(reason=f"{test} throttled")
243
- return path, now
257
+ root_use = root
258
+ return Path(root_use, _md5_hash_cached(_get_name()))
244
259
 
245
260
 
246
261
  @cache
247
- def _throttle_md5_hash(text: str, /) -> str:
262
+ def _md5_hash_cached(text: str, /) -> str:
248
263
  return md5_hash(text)
249
264
 
250
265
 
251
- def _throttle_write(path: Path, now: float, /) -> None:
252
- with writer(path, overwrite=True) as temp, temp.open(mode="w") as fh:
253
- _ = fh.write(str(now))
266
+ def _get_name() -> str:
267
+ return environ["PYTEST_CURRENT_TEST"]
268
+
269
+
270
+ def _write(*, root: PathLike | None = None) -> None:
271
+ path = _get_path(root=root)
272
+ with writer(path, overwrite=True) as temp:
273
+ _ = temp.write_text(get_now_local().format_common_iso())
254
274
 
255
275
 
256
276
  __all__ = [
utilities/types.py CHANGED
@@ -97,19 +97,6 @@ class Dataclass(Protocol):
97
97
  TDataclass = TypeVar("TDataclass", bound=Dataclass)
98
98
 
99
99
 
100
- # datetime
101
- type DateOrDateTime = dt.date | dt.datetime
102
- type DateTimeLike = MaybeStr[dt.datetime]
103
- type Duration = Number | dt.timedelta
104
- type DurationLike = MaybeStr[Duration]
105
- type DurationOrEveryDuration = Duration | tuple[Literal["every"], Duration]
106
- type MaybeCallablePyDate = MaybeCallable[dt.date]
107
- type MaybeCallablePyDateTime = MaybeCallable[dt.datetime]
108
- type PyDateLike = MaybeStr[dt.date]
109
- type PyTimeDeltaLike = MaybeStr[dt.timedelta]
110
- type PyTimeLike = MaybeStr[dt.time]
111
-
112
-
113
100
  # enum
114
101
  type EnumLike[_TEnum: Enum] = MaybeStr[_TEnum]
115
102
  TEnum = TypeVar("TEnum", bound=Enum)
@@ -293,7 +280,9 @@ type TimeZone = Literal[
293
280
  "Africa/Abidjan", "Africa/Accra", "Africa/Addis_Ababa", "Africa/Algiers", "Africa/Asmara", "Africa/Asmera", "Africa/Bamako", "Africa/Bangui", "Africa/Banjul", "Africa/Bissau", "Africa/Blantyre", "Africa/Brazzaville", "Africa/Bujumbura", "Africa/Cairo", "Africa/Casablanca", "Africa/Ceuta", "Africa/Conakry", "Africa/Dakar", "Africa/Dar_es_Salaam", "Africa/Djibouti", "Africa/Douala", "Africa/El_Aaiun", "Africa/Freetown", "Africa/Gaborone", "Africa/Harare", "Africa/Johannesburg", "Africa/Juba", "Africa/Kampala", "Africa/Khartoum", "Africa/Kigali", "Africa/Kinshasa", "Africa/Lagos", "Africa/Libreville", "Africa/Lome", "Africa/Luanda", "Africa/Lubumbashi", "Africa/Lusaka", "Africa/Malabo", "Africa/Maputo", "Africa/Maseru", "Africa/Mbabane", "Africa/Mogadishu", "Africa/Monrovia", "Africa/Nairobi", "Africa/Ndjamena", "Africa/Niamey", "Africa/Nouakchott", "Africa/Ouagadougou", "Africa/Porto-Novo", "Africa/Sao_Tome", "Africa/Timbuktu", "Africa/Tripoli", "Africa/Tunis", "Africa/Windhoek", "America/Adak", "America/Anchorage", "America/Anguilla", "America/Antigua", "America/Araguaina", "America/Argentina/Buenos_Aires", "America/Argentina/Catamarca", "America/Argentina/ComodRivadavia", "America/Argentina/Cordoba", "America/Argentina/Jujuy", "America/Argentina/La_Rioja", "America/Argentina/Mendoza", "America/Argentina/Rio_Gallegos", "America/Argentina/Salta", "America/Argentina/San_Juan", "America/Argentina/San_Luis", "America/Argentina/Tucuman", "America/Argentina/Ushuaia", "America/Aruba", "America/Asuncion", "America/Atikokan", "America/Atka", "America/Bahia", "America/Bahia_Banderas", "America/Barbados", "America/Belem", "America/Belize", "America/Blanc-Sablon", "America/Boa_Vista", "America/Bogota", "America/Boise", "America/Buenos_Aires", "America/Cambridge_Bay", "America/Campo_Grande", "America/Cancun", "America/Caracas", "America/Catamarca", "America/Cayenne", "America/Cayman", "America/Chicago", "America/Chihuahua", "America/Ciudad_Juarez", "America/Coral_Harbour", "America/Cordoba", "America/Costa_Rica", "America/Coyhaique", "America/Creston", "America/Cuiaba", "America/Curacao", "America/Danmarkshavn", "America/Dawson", "America/Dawson_Creek", "America/Denver", "America/Detroit", "America/Dominica", "America/Edmonton", "America/Eirunepe", "America/El_Salvador", "America/Ensenada", "America/Fort_Nelson", "America/Fort_Wayne", "America/Fortaleza", "America/Glace_Bay", "America/Godthab", "America/Goose_Bay", "America/Grand_Turk", "America/Grenada", "America/Guadeloupe", "America/Guatemala", "America/Guayaquil", "America/Guyana", "America/Halifax", "America/Havana", "America/Hermosillo", "America/Indiana/Indianapolis", "America/Indiana/Knox", "America/Indiana/Marengo", "America/Indiana/Petersburg", "America/Indiana/Tell_City", "America/Indiana/Vevay", "America/Indiana/Vincennes", "America/Indiana/Winamac", "America/Indianapolis", "America/Inuvik", "America/Iqaluit", "America/Jamaica", "America/Jujuy", "America/Juneau", "America/Kentucky/Louisville", "America/Kentucky/Monticello", "America/Knox_IN", "America/Kralendijk", "America/La_Paz", "America/Lima", "America/Los_Angeles", "America/Louisville", "America/Lower_Princes", "America/Maceio", "America/Managua", "America/Manaus", "America/Marigot", "America/Martinique", "America/Matamoros", "America/Mazatlan", "America/Mendoza", "America/Menominee", "America/Merida", "America/Metlakatla", "America/Mexico_City", "America/Miquelon", "America/Moncton", "America/Monterrey", "America/Montevideo", "America/Montreal", "America/Montserrat", "America/Nassau", "America/New_York", "America/Nipigon", "America/Nome", "America/Noronha", "America/North_Dakota/Beulah", "America/North_Dakota/Center", "America/North_Dakota/New_Salem", "America/Nuuk", "America/Ojinaga", "America/Panama", "America/Pangnirtung", "America/Paramaribo", "America/Phoenix", "America/Port-au-Prince", "America/Port_of_Spain", "America/Porto_Acre", "America/Porto_Velho", "America/Puerto_Rico", "America/Punta_Arenas", "America/Rainy_River", "America/Rankin_Inlet", "America/Recife", "America/Regina", "America/Resolute", "America/Rio_Branco", "America/Rosario", "America/Santa_Isabel", "America/Santarem", "America/Santiago", "America/Santo_Domingo", "America/Sao_Paulo", "America/Scoresbysund", "America/Shiprock", "America/Sitka", "America/St_Barthelemy", "America/St_Johns", "America/St_Kitts", "America/St_Lucia", "America/St_Thomas", "America/St_Vincent", "America/Swift_Current", "America/Tegucigalpa", "America/Thule", "America/Thunder_Bay", "America/Tijuana", "America/Toronto", "America/Tortola", "America/Vancouver", "America/Virgin", "America/Whitehorse", "America/Winnipeg", "America/Yakutat", "America/Yellowknife", "Antarctica/Casey", "Antarctica/Davis", "Antarctica/DumontDUrville", "Antarctica/Macquarie", "Antarctica/Mawson", "Antarctica/McMurdo", "Antarctica/Palmer", "Antarctica/Rothera", "Antarctica/South_Pole", "Antarctica/Syowa", "Antarctica/Troll", "Antarctica/Vostok", "Arctic/Longyearbyen", "Asia/Aden", "Asia/Almaty", "Asia/Amman", "Asia/Anadyr", "Asia/Aqtau", "Asia/Aqtobe", "Asia/Ashgabat", "Asia/Ashkhabad", "Asia/Atyrau", "Asia/Baghdad", "Asia/Bahrain", "Asia/Baku", "Asia/Bangkok", "Asia/Barnaul", "Asia/Beirut", "Asia/Bishkek", "Asia/Brunei", "Asia/Calcutta", "Asia/Chita", "Asia/Choibalsan", "Asia/Chongqing", "Asia/Chungking", "Asia/Colombo", "Asia/Dacca", "Asia/Damascus", "Asia/Dhaka", "Asia/Dili", "Asia/Dubai", "Asia/Dushanbe", "Asia/Famagusta", "Asia/Gaza", "Asia/Harbin", "Asia/Hebron", "Asia/Ho_Chi_Minh", "Asia/Hong_Kong", "Asia/Hovd", "Asia/Irkutsk", "Asia/Istanbul", "Asia/Jakarta", "Asia/Jayapura", "Asia/Jerusalem", "Asia/Kabul", "Asia/Kamchatka", "Asia/Karachi", "Asia/Kashgar", "Asia/Kathmandu", "Asia/Katmandu", "Asia/Khandyga", "Asia/Kolkata", "Asia/Krasnoyarsk", "Asia/Kuala_Lumpur", "Asia/Kuching", "Asia/Kuwait", "Asia/Macao", "Asia/Macau", "Asia/Magadan", "Asia/Makassar", "Asia/Manila", "Asia/Muscat", "Asia/Nicosia", "Asia/Novokuznetsk", "Asia/Novosibirsk", "Asia/Omsk", "Asia/Oral", "Asia/Phnom_Penh", "Asia/Pontianak", "Asia/Pyongyang", "Asia/Qatar", "Asia/Qostanay", "Asia/Qyzylorda", "Asia/Rangoon", "Asia/Riyadh", "Asia/Saigon", "Asia/Sakhalin", "Asia/Samarkand", "Asia/Seoul", "Asia/Shanghai", "Asia/Singapore", "Asia/Srednekolymsk", "Asia/Taipei", "Asia/Tashkent", "Asia/Tbilisi", "Asia/Tehran", "Asia/Tel_Aviv", "Asia/Thimbu", "Asia/Thimphu", "Asia/Tokyo", "Asia/Tomsk", "Asia/Ujung_Pandang", "Asia/Ulaanbaatar", "Asia/Ulan_Bator", "Asia/Urumqi", "Asia/Ust-Nera", "Asia/Vientiane", "Asia/Vladivostok", "Asia/Yakutsk", "Asia/Yangon", "Asia/Yekaterinburg", "Asia/Yerevan", "Atlantic/Azores", "Atlantic/Bermuda", "Atlantic/Canary", "Atlantic/Cape_Verde", "Atlantic/Faeroe", "Atlantic/Faroe", "Atlantic/Jan_Mayen", "Atlantic/Madeira", "Atlantic/Reykjavik", "Atlantic/South_Georgia", "Atlantic/St_Helena", "Atlantic/Stanley", "Australia/ACT", "Australia/Adelaide", "Australia/Brisbane", "Australia/Broken_Hill", "Australia/Canberra", "Australia/Currie", "Australia/Darwin", "Australia/Eucla", "Australia/Hobart", "Australia/LHI", "Australia/Lindeman", "Australia/Lord_Howe", "Australia/Melbourne", "Australia/NSW", "Australia/North", "Australia/Perth", "Australia/Queensland", "Australia/South", "Australia/Sydney", "Australia/Tasmania", "Australia/Victoria", "Australia/West", "Australia/Yancowinna", "Brazil/Acre", "Brazil/DeNoronha", "Brazil/East", "Brazil/West", "CET", "CST6CDT", "Canada/Atlantic", "Canada/Central", "Canada/Eastern", "Canada/Mountain", "Canada/Newfoundland", "Canada/Pacific", "Canada/Saskatchewan", "Canada/Yukon", "Chile/Continental", "Chile/EasterIsland", "Cuba", "EET", "EST", "EST5EDT", "Egypt", "Eire", "Etc/GMT", "Etc/GMT+0", "Etc/GMT+1", "Etc/GMT+10", "Etc/GMT+11", "Etc/GMT+12", "Etc/GMT+2", "Etc/GMT+3", "Etc/GMT+4", "Etc/GMT+5", "Etc/GMT+6", "Etc/GMT+7", "Etc/GMT+8", "Etc/GMT+9", "Etc/GMT-0", "Etc/GMT-1", "Etc/GMT-10", "Etc/GMT-11", "Etc/GMT-12", "Etc/GMT-13", "Etc/GMT-14", "Etc/GMT-2", "Etc/GMT-3", "Etc/GMT-4", "Etc/GMT-5", "Etc/GMT-6", "Etc/GMT-7", "Etc/GMT-8", "Etc/GMT-9", "Etc/GMT0", "Etc/Greenwich", "Etc/UCT", "Etc/UTC", "Etc/Universal", "Etc/Zulu", "Europe/Amsterdam", "Europe/Andorra", "Europe/Astrakhan", "Europe/Athens", "Europe/Belfast", "Europe/Belgrade", "Europe/Berlin", "Europe/Bratislava", "Europe/Brussels", "Europe/Bucharest", "Europe/Budapest", "Europe/Busingen", "Europe/Chisinau", "Europe/Copenhagen", "Europe/Dublin", "Europe/Gibraltar", "Europe/Guernsey", "Europe/Helsinki", "Europe/Isle_of_Man", "Europe/Istanbul", "Europe/Jersey", "Europe/Kaliningrad", "Europe/Kiev", "Europe/Kirov", "Europe/Kyiv", "Europe/Lisbon", "Europe/Ljubljana", "Europe/London", "Europe/Luxembourg", "Europe/Madrid", "Europe/Malta", "Europe/Mariehamn", "Europe/Minsk", "Europe/Monaco", "Europe/Moscow", "Europe/Nicosia", "Europe/Oslo", "Europe/Paris", "Europe/Podgorica", "Europe/Prague", "Europe/Riga", "Europe/Rome", "Europe/Samara", "Europe/San_Marino", "Europe/Sarajevo", "Europe/Saratov", "Europe/Simferopol", "Europe/Skopje", "Europe/Sofia", "Europe/Stockholm", "Europe/Tallinn", "Europe/Tirane", "Europe/Tiraspol", "Europe/Ulyanovsk", "Europe/Uzhgorod", "Europe/Vaduz", "Europe/Vatican", "Europe/Vienna", "Europe/Vilnius", "Europe/Volgograd", "Europe/Warsaw", "Europe/Zagreb", "Europe/Zaporozhye", "Europe/Zurich", "Factory", "GB", "GB-Eire", "GMT", "GMT+0", "GMT-0", "GMT0", "Greenwich", "HST", "Hongkong", "Iceland", "Indian/Antananarivo", "Indian/Chagos", "Indian/Christmas", "Indian/Cocos", "Indian/Comoro", "Indian/Kerguelen", "Indian/Mahe", "Indian/Maldives", "Indian/Mauritius", "Indian/Mayotte", "Indian/Reunion", "Iran", "Israel", "Jamaica", "Japan", "Kwajalein", "Libya", "MET", "MST", "MST7MDT", "Mexico/BajaNorte", "Mexico/BajaSur", "Mexico/General", "NZ", "NZ-CHAT", "Navajo", "PRC", "PST8PDT", "Pacific/Apia", "Pacific/Auckland", "Pacific/Bougainville", "Pacific/Chatham", "Pacific/Chuuk", "Pacific/Easter", "Pacific/Efate", "Pacific/Enderbury", "Pacific/Fakaofo", "Pacific/Fiji", "Pacific/Funafuti", "Pacific/Galapagos", "Pacific/Gambier", "Pacific/Guadalcanal", "Pacific/Guam", "Pacific/Honolulu", "Pacific/Johnston", "Pacific/Kanton", "Pacific/Kiritimati", "Pacific/Kosrae", "Pacific/Kwajalein", "Pacific/Majuro", "Pacific/Marquesas", "Pacific/Midway", "Pacific/Nauru", "Pacific/Niue", "Pacific/Norfolk", "Pacific/Noumea", "Pacific/Pago_Pago", "Pacific/Palau", "Pacific/Pitcairn", "Pacific/Pohnpei", "Pacific/Ponape", "Pacific/Port_Moresby", "Pacific/Rarotonga", "Pacific/Saipan", "Pacific/Samoa", "Pacific/Tahiti", "Pacific/Tarawa", "Pacific/Tongatapu", "Pacific/Truk", "Pacific/Wake", "Pacific/Wallis", "Pacific/Yap", "Poland", "Portugal", "ROC", "ROK", "Singapore", "Turkey", "UCT", "US/Alaska", "US/Aleutian", "US/Arizona", "US/Central", "US/East-Indiana", "US/Eastern", "US/Hawaii", "US/Indiana-Starke", "US/Michigan", "US/Mountain", "US/Pacific", "US/Samoa", "UTC", "Universal", "W-SU", "WET", "Zulu"
294
281
  ]
295
282
  # fmt: on
296
- type TimeZoneLike = ZoneInfo | Literal["local"] | TimeZone | dt.tzinfo | dt.datetime
283
+ type TimeZoneLike = (
284
+ ZoneInfo | ZonedDateTime | Literal["local"] | TimeZone | dt.tzinfo | dt.datetime
285
+ )
297
286
 
298
287
 
299
288
  __all__ = [
@@ -301,14 +290,9 @@ __all__ = [
301
290
  "Dataclass",
302
291
  "DateDeltaLike",
303
292
  "DateLike",
304
- "DateOrDateTime",
305
293
  "DateTimeDeltaLike",
306
- "DateTimeLike",
307
294
  "DateTimeRoundMode",
308
295
  "DateTimeRoundUnit",
309
- "Duration",
310
- "DurationLike",
311
- "DurationOrEveryDuration",
312
296
  "EnumLike",
313
297
  "ExcInfo",
314
298
  "IterableHashable",
@@ -320,8 +304,6 @@ __all__ = [
320
304
  "MaybeCallableDate",
321
305
  "MaybeCallableEvent",
322
306
  "MaybeCallablePathLike",
323
- "MaybeCallablePyDate",
324
- "MaybeCallablePyDateTime",
325
307
  "MaybeCallableZonedDateTime",
326
308
  "MaybeCoroutine1",
327
309
  "MaybeIterable",
@@ -336,9 +318,6 @@ __all__ = [
336
318
  "PathLike",
337
319
  "PatternLike",
338
320
  "PlainDateTimeLike",
339
- "PyDateLike",
340
- "PyTimeDeltaLike",
341
- "PyTimeLike",
342
321
  "Seed",
343
322
  "SerializeObjectExtra",
344
323
  "Sign",
utilities/typing.py CHANGED
@@ -11,7 +11,6 @@ from typing import (
11
11
  Literal,
12
12
  NamedTuple,
13
13
  Optional, # pyright: ignore[reportDeprecated]
14
- Self,
15
14
  TypeAliasType,
16
15
  TypeGuard,
17
16
  TypeVar,
@@ -50,14 +49,6 @@ _T5 = TypeVar("_T5")
50
49
  ##
51
50
 
52
51
 
53
- def contains_self(obj: Any, /) -> bool:
54
- """Check if an annotation contains `Self`."""
55
- return (obj is Self) or any(map(contains_self, get_args(obj)))
56
-
57
-
58
- ##
59
-
60
-
61
52
  def get_args(obj: Any, /, *, optional_drop_none: bool = False) -> tuple[Any, ...]:
62
53
  """Get the arguments of an annotation."""
63
54
  if isinstance(obj, TypeAliasType):
@@ -183,12 +174,9 @@ def get_union_type_classes(obj: Any, /) -> tuple[type[Any], ...]:
183
174
  raise _GetUnionTypeClassesUnionTypeError(obj=obj)
184
175
  types_: Sequence[type[Any]] = []
185
176
  for arg in get_args(obj):
186
- if isinstance(arg, type):
187
- types_.append(arg)
188
- elif is_union_type(arg):
189
- types_.extend(get_union_type_classes(arg))
190
- else:
177
+ if not isinstance(arg, type):
191
178
  raise _GetUnionTypeClassesInternalTypeError(obj=obj, inner=arg)
179
+ types_.append(arg)
192
180
  return tuple(types_)
193
181
 
194
182
 
@@ -495,7 +483,6 @@ __all__ = [
495
483
  "GetUnionTypeClassesError",
496
484
  "IsInstanceGenError",
497
485
  "IsSubclassGenError",
498
- "contains_self",
499
486
  "get_literal_elements",
500
487
  "get_type_classes",
501
488
  "get_type_hints",
utilities/whenever2.py CHANGED
@@ -1,11 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import datetime as dt
4
- from collections.abc import Callable
4
+ from collections.abc import Callable, Iterable
5
5
  from dataclasses import dataclass
6
6
  from functools import cache
7
7
  from logging import LogRecord
8
- from typing import TYPE_CHECKING, Any, assert_never, overload, override
8
+ from statistics import fmean
9
+ from typing import TYPE_CHECKING, Any, SupportsFloat, assert_never, overload, override
9
10
 
10
11
  from whenever import (
11
12
  Date,
@@ -99,6 +100,7 @@ DATE_DELTA_PARSABLE_MAX = DateDelta(days=999999)
99
100
  ## common constants
100
101
 
101
102
 
103
+ ZERO_DAYS = DateDelta()
102
104
  ZERO_TIME = TimeDelta()
103
105
  MICROSECOND = TimeDelta(microseconds=1)
104
106
  MILLISECOND = TimeDelta(milliseconds=1)
@@ -107,6 +109,37 @@ MINUTE = TimeDelta(minutes=1)
107
109
  HOUR = TimeDelta(hours=1)
108
110
  DAY = DateDelta(days=1)
109
111
  WEEK = DateDelta(weeks=1)
112
+ MONTH = DateDelta(months=1)
113
+ YEAR = DateDelta(years=1)
114
+
115
+
116
+ ##
117
+
118
+
119
+ def datetime_utc(
120
+ year: int,
121
+ month: int,
122
+ day: int,
123
+ /,
124
+ hour: int = 0,
125
+ minute: int = 0,
126
+ second: int = 0,
127
+ millisecond: int = 0,
128
+ microsecond: int = 0,
129
+ nanosecond: int = 0,
130
+ ) -> ZonedDateTime:
131
+ """Create a UTC-zoned datetime."""
132
+ nanos = int(1e6) * millisecond + int(1e3) * microsecond + nanosecond
133
+ return ZonedDateTime(
134
+ year,
135
+ month,
136
+ day,
137
+ hour=hour,
138
+ minute=minute,
139
+ second=second,
140
+ nanosecond=nanos,
141
+ tz=UTC.key,
142
+ )
110
143
 
111
144
 
112
145
  ##
@@ -173,6 +206,110 @@ def get_today_local() -> Date:
173
206
 
174
207
  TODAY_LOCAL = get_today_local()
175
208
 
209
+
210
+ ##
211
+
212
+
213
+ def mean_datetime(
214
+ datetimes: Iterable[ZonedDateTime],
215
+ /,
216
+ *,
217
+ weights: Iterable[SupportsFloat] | None = None,
218
+ ) -> ZonedDateTime:
219
+ """Compute the mean of a set of datetimes."""
220
+ datetimes = list(datetimes)
221
+ match len(datetimes):
222
+ case 0:
223
+ raise MeanDateTimeError from None
224
+ case 1:
225
+ return datetimes[0]
226
+ case _:
227
+ timestamps = [d.timestamp_nanos() for d in datetimes]
228
+ timestamp = round(fmean(timestamps, weights=weights))
229
+ return ZonedDateTime.from_timestamp_nanos(timestamp, tz=datetimes[0].tz)
230
+
231
+
232
+ @dataclass(kw_only=True, slots=True)
233
+ class MeanDateTimeError(Exception):
234
+ @override
235
+ def __str__(self) -> str:
236
+ return "Mean requires at least 1 datetime"
237
+
238
+
239
+ ##
240
+
241
+
242
+ def min_max_date(
243
+ *,
244
+ min_date: Date | None = None,
245
+ max_date: Date | None = None,
246
+ min_age: DateDelta | None = None,
247
+ max_age: DateDelta | None = None,
248
+ time_zone: TimeZoneLike = UTC,
249
+ ) -> tuple[Date | None, Date | None]:
250
+ """Ucompute the min/max date given a combination of dates/ages."""
251
+ today = get_today(time_zone=time_zone)
252
+ min_parts: list[Date] = []
253
+ if min_date is not None:
254
+ if min_date > today:
255
+ raise _MinMaxDateMinDateError(min_date=min_date, today=today)
256
+ min_parts.append(min_date)
257
+ if max_age is not None:
258
+ min_parts.append(today - max_age)
259
+ min_date_use = max(min_parts, default=None)
260
+ max_parts: list[Date] = []
261
+ if max_date is not None:
262
+ if max_date > today:
263
+ raise _MinMaxDateMaxDateError(max_date=max_date, today=today)
264
+ max_parts.append(max_date)
265
+ if min_age is not None:
266
+ max_parts.append(today - min_age)
267
+ max_date_use = min(max_parts, default=None)
268
+ if (
269
+ (min_date_use is not None)
270
+ and (max_date_use is not None)
271
+ and (min_date_use > max_date_use)
272
+ ):
273
+ raise _MinMaxDatePeriodError(min_date=min_date_use, max_date=max_date_use)
274
+ return min_date_use, max_date_use
275
+
276
+
277
+ @dataclass(kw_only=True, slots=True)
278
+ class MinMaxDateError(Exception): ...
279
+
280
+
281
+ @dataclass(kw_only=True, slots=True)
282
+ class _MinMaxDateMinDateError(MinMaxDateError):
283
+ min_date: Date
284
+ today: Date
285
+
286
+ @override
287
+ def __str__(self) -> str:
288
+ return f"Min date must be at most today; got {self.min_date} > {self.today}"
289
+
290
+
291
+ @dataclass(kw_only=True, slots=True)
292
+ class _MinMaxDateMaxDateError(MinMaxDateError):
293
+ max_date: Date
294
+ today: Date
295
+
296
+ @override
297
+ def __str__(self) -> str:
298
+ return f"Max date must be at most today; got {self.max_date} > {self.today}"
299
+
300
+
301
+ @dataclass(kw_only=True, slots=True)
302
+ class _MinMaxDatePeriodError(MinMaxDateError):
303
+ min_date: Date
304
+ max_date: Date
305
+
306
+ @override
307
+ def __str__(self) -> str:
308
+ return (
309
+ f"Min date must be at most max date; got {self.min_date} > {self.max_date}"
310
+ )
311
+
312
+
176
313
  ##
177
314
 
178
315
 
@@ -447,6 +584,7 @@ __all__ = [
447
584
  "MICROSECOND",
448
585
  "MILLISECOND",
449
586
  "MINUTE",
587
+ "MONTH",
450
588
  "NOW_LOCAL",
451
589
  "PLAIN_DATE_TIME_MAX",
452
590
  "PLAIN_DATE_TIME_MIN",
@@ -458,13 +596,17 @@ __all__ = [
458
596
  "TODAY_LOCAL",
459
597
  "TODAY_UTC",
460
598
  "WEEK",
599
+ "YEAR",
600
+ "ZERO_DAYS",
461
601
  "ZERO_TIME",
462
602
  "ZONED_DATE_TIME_MAX",
463
603
  "ZONED_DATE_TIME_MIN",
604
+ "MeanDateTimeError",
605
+ "MinMaxDateError",
464
606
  "ToDaysError",
465
607
  "ToNanosError",
466
608
  "WheneverLogRecord",
467
- "format_compact",
609
+ "datetime_utc",
468
610
  "format_compact",
469
611
  "from_timestamp",
470
612
  "from_timestamp_millis",
@@ -473,6 +615,8 @@ __all__ = [
473
615
  "get_now_local",
474
616
  "get_today",
475
617
  "get_today_local",
618
+ "mean_datetime",
619
+ "min_max_date",
476
620
  "to_date",
477
621
  "to_date_time_delta",
478
622
  "to_days",
utilities/zoneinfo.py CHANGED
@@ -5,6 +5,8 @@ from dataclasses import dataclass
5
5
  from typing import TYPE_CHECKING, assert_never, cast, override
6
6
  from zoneinfo import ZoneInfo
7
7
 
8
+ from whenever import ZonedDateTime
9
+
8
10
  from utilities.tzlocal import LOCAL_TIME_ZONE
9
11
 
10
12
  if TYPE_CHECKING:
@@ -22,6 +24,8 @@ def ensure_time_zone(obj: TimeZoneLike, /) -> ZoneInfo:
22
24
  match obj:
23
25
  case ZoneInfo() as zone_info:
24
26
  return zone_info
27
+ case ZonedDateTime() as datetime:
28
+ return ZoneInfo(datetime.tz)
25
29
  case "local":
26
30
  return LOCAL_TIME_ZONE
27
31
  case str() as key: