dycw-utilities 0.153.14__py3-none-any.whl → 0.154.0__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.153.14
3
+ Version: 0.154.0
4
4
  Author-email: Derek Wan <d.wan@icloud.com>
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -1,4 +1,4 @@
1
- utilities/__init__.py,sha256=MYECZv1L2rJqbmPy0SVYYlk_XxG4DUd-x0oU565JHOg,61
1
+ utilities/__init__.py,sha256=AggdQNmHiytWszaBPt8g3NE_WaSx4IoGrhZ8f-NLdAI,60
2
2
  utilities/altair.py,sha256=92E2lCdyHY4Zb-vCw6rEJIsWdKipuu-Tu2ab1ufUfAk,9079
3
3
  utilities/asyncio.py,sha256=QXkTtugXkqtYt7Do23zgYErqzdp6jwzPpV_SP9fJ1gI,16780
4
4
  utilities/atomicwrites.py,sha256=tPo6r-Rypd9u99u66B9z86YBPpnLrlHtwox_8Z7T34Y,5790
@@ -15,7 +15,7 @@ utilities/enum.py,sha256=5l6pwZD1cjSlVW4ss-zBPspWvrbrYrdtJWcg6f5_J5w,5781
15
15
  utilities/errors.py,sha256=mFlDGSM0LI1jZ1pbqwLAH3ttLZ2JVIxyZLojw8tGVZU,1479
16
16
  utilities/eventkit.py,sha256=ddoleSwW9zdc2tjX5Ge0pMKtYwV_JMxhHYOxnWX2AGM,12609
17
17
  utilities/fastapi.py,sha256=3wpd63Tw9paSyy7STpAD7GGe8fLkLaRC6TPCwIGm1BU,1361
18
- utilities/fpdf2.py,sha256=776PkEX5xEK-whFOzqaVaQVHPy1Xf01kCSyj7TEp80g,1886
18
+ utilities/fpdf2.py,sha256=HgM8JSvoioDXrjC0UR3HVLjnMnnb_mML7nL2EmkTwGI,1854
19
19
  utilities/functions.py,sha256=0mmeZ8op3QkAooYRAyRZhpi3TgaJCiMnqbJtZl-myug,28266
20
20
  utilities/functools.py,sha256=I00ru2gQPakZw2SHVeKIKXfTv741655s6HI0lUoE0D4,1552
21
21
  utilities/getpass.py,sha256=DfN5UgMAtFCqS3dSfFHUfqIMZX2shXvwphOz_6J6f6A,103
@@ -26,12 +26,12 @@ utilities/hypothesis.py,sha256=m44niSfuzuhgn7IQ1UOwUGgiu68xz4a6LHxB0IE6fNE,40341
26
26
  utilities/importlib.py,sha256=mV1xT_O_zt_GnZZ36tl3xOmMaN_3jErDWY54fX39F6Y,429
27
27
  utilities/inflect.py,sha256=v7YkOWSu8NAmVghPcf4F3YBZQoJCS47_DLf9jbfWIs0,581
28
28
  utilities/ipython.py,sha256=V2oMYHvEKvlNBzxDXdLvKi48oUq2SclRg5xasjaXStw,763
29
- utilities/iterables.py,sha256=VGqnVk-Wq13WHZZQ8L6BCCwFzEOoKGRpd7xkiQaXbiY,43642
29
+ utilities/iterables.py,sha256=ZmXBSk_Rio-aqLwTaoX69HD81YVcndeLYQwjv0P64JM,43009
30
30
  utilities/json.py,sha256=-WcGtSsCr9Y42wHZzAMnfvU6ihAfVftylFfRUORaDFo,2102
31
31
  utilities/jupyter.py,sha256=ft5JA7fBxXKzP-L9W8f2-wbF0QeYc_2uLQNFDVk4Z-M,2917
32
32
  utilities/libcst.py,sha256=TKgKN4bNmtBNEE-TUfhTyd1BrTncfsl_7tTuhpesGYY,5585
33
33
  utilities/lightweight_charts.py,sha256=YM3ojBvJxuCSUBu_KrhFBmaMCvRPvupKC3qkm-UVZq4,2751
34
- utilities/logging.py,sha256=z2wIraTaTfQw-MjhhgIKeyWSyW5gCRX04gsdlknkTTs,19361
34
+ utilities/logging.py,sha256=ihbfQJgjc7t3Pds0oPvF_J1eigiqFKzxNOijzoee8U4,18064
35
35
  utilities/math.py,sha256=7ve4RxX3g-FGGVnWV0K9bBeGnKUEjnTbH13VxdvFtGE,26847
36
36
  utilities/memory_profiler.py,sha256=XzN56jDCa5aqXS_DxEjb_K4L6aIWh_5zyKi6OhcIxw0,853
37
37
  utilities/modules.py,sha256=iuvLluJya-hvl1Q25-Jk3dLgx2Es3ck4SjJiEkAlVTs,3195
@@ -45,14 +45,14 @@ utilities/parse.py,sha256=JcJn5yXKhIWXBCwgBdPsyu7Hvcuw6kyEdqvaebCaI9k,17951
45
45
  utilities/pathlib.py,sha256=qGuU8XPmdgGpy8tOMUgelfXx3kxI8h9IaV3TI_06QGE,8428
46
46
  utilities/pickle.py,sha256=MBT2xZCsv0pH868IXLGKnlcqNx2IRVKYNpRcqiQQqxw,653
47
47
  utilities/platform.py,sha256=pTn7gw6N4T6LdKrf0virwarof_mze9WtoQlrGMzhGVI,2798
48
- utilities/polars.py,sha256=wuZy0Mgn754b695oNsP0VQDTMwmS-b38wfCyn5aeNi4,78384
48
+ utilities/polars.py,sha256=I07Bk_Vp2T434qXkCKxSVQIkFJc1d8YkOH48fprypB0,78436
49
49
  utilities/polars_ols.py,sha256=Uc9V5kvlWZ5cU93lKZ-cfAKdVFFw81tqwLW9PxtUvMs,5618
50
50
  utilities/postgres.py,sha256=ynCTTaF-bVEOSW-KEAR-dlLh_hYjeVVjm__-4pEU8Zk,12269
51
51
  utilities/pottery.py,sha256=HJ96oLRarTP37Vhg0WTyB3yAu2hETeg6HgRmpDIqyUs,6581
52
52
  utilities/pqdm.py,sha256=z8bSMS7QJmWun65FQZruAqT-R3wqPAzNzhWcX9Nvr0A,3087
53
53
  utilities/psutil.py,sha256=KUlu4lrUw9Zg1V7ZGetpWpGb9DB8l_SSDWGbANFNCPU,2104
54
54
  utilities/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
55
- utilities/pyinstrument.py,sha256=crJeEGOLxVt-tBGETb4E9v33kpaWmKrgXqL4PlFGRtk,850
55
+ utilities/pyinstrument.py,sha256=NZCZz2nBo0BLJ9DTf7H_Q_KGxvsf2S2M3h0qYoYh2kw,804
56
56
  utilities/pytest.py,sha256=2HHfAWkzZeK2OAzL2F49EDKooMkfDoGqg8Ev4cHC_N8,7869
57
57
  utilities/pytest_regressions.py,sha256=ocjHTtfOeiGfQAKIei8pKNd61sxN9dawrJJ9gPt2wzA,4097
58
58
  utilities/random.py,sha256=hZlH4gnAtoaofWswuJYjcygejrY8db4CzP-z_adO2Mo,4165
@@ -72,7 +72,7 @@ utilities/tempfile.py,sha256=HxB2BF28CcecDJLQ3Bx2Ej-Pb6RJc6W9ngSpB9CnP4k,2018
72
72
  utilities/text.py,sha256=uwCDgpEunYruyh6sKMfNWK3Rp5H3ndpKRAkq86CBNys,13043
73
73
  utilities/threading.py,sha256=GvBOp4CyhHfN90wGXZuA2VKe9fGzMaEa7oCl4f3nnPU,1009
74
74
  utilities/timer.py,sha256=oXfTii6ymu57niP0BDGZjFD55LEHi2a19kqZKiTgaFQ,2588
75
- utilities/traceback.py,sha256=e0BpxNMybVmELHGsYM5N6LVbfmn0jLhefLoa81NjZBg,9100
75
+ utilities/traceback.py,sha256=TjO7em98FDFLvROZ7gi2UJftFWNuSTkbCrf7mk-fg28,9416
76
76
  utilities/typed_settings.py,sha256=SFWqS3lAzV7IfNRwqFcTk0YynTcQ7BmrcW2mr_KUnos,4466
77
77
  utilities/types.py,sha256=L4cjFPyFZX58Urfw0S_i-XRywPIFyuSLOieewj0qqsM,18516
78
78
  utilities/typing.py,sha256=Z-_XDaWyT_6wIo3qfNK-hvRlzxP2Jxa9PgXzm5rDYRA,13790
@@ -81,14 +81,14 @@ utilities/tzlocal.py,sha256=KyCXEgCTjqGFx-389JdTuhMRUaT06U1RCMdWoED-qro,728
81
81
  utilities/uuid.py,sha256=nQZs6tFX4mqtc2Ku3KqjloYCqwpTKeTj8eKwQwh3FQI,1572
82
82
  utilities/version.py,sha256=ipBj5-WYY_nelp2uwFlApfWWCzTLzPwpovUi9x_OBMs,5085
83
83
  utilities/warnings.py,sha256=un1LvHv70PU-LLv8RxPVmugTzDJkkGXRMZTE2-fTQHw,1771
84
- utilities/whenever.py,sha256=_kMiXvmfuVud2i_2X3lO8h0U3KR7HrYqd-vwnShBAIg,57337
84
+ utilities/whenever.py,sha256=gPnFKWws4_tjiHPLzX1AukSwDjfMIO9Iim0DDNQyAqY,57532
85
85
  utilities/zipfile.py,sha256=24lQc9ATcJxHXBPc_tBDiJk48pWyRrlxO2fIsFxU0A8,699
86
86
  utilities/zoneinfo.py,sha256=FBMcUQ4662Aq8SsuCL1OAhDQiyANmVjtb-C30DRrWoE,1966
87
87
  utilities/pytest_plugins/__init__.py,sha256=U4S_2y3zgLZVfMenHRaJFBW8yqh2mUBuI291LGQVOJ8,35
88
88
  utilities/pytest_plugins/pytest_randomly.py,sha256=B1qYVlExGOxTywq2r1SMi5o7btHLk2PNdY_b1p98dkE,409
89
89
  utilities/pytest_plugins/pytest_regressions.py,sha256=9v8kAXDM2ycIXJBimoiF4EgrwbUvxTycFWJiGR_GHhM,1466
90
- dycw_utilities-0.153.14.dist-info/METADATA,sha256=TETCneLEnNiEag5V6PMzXec3R0PGt_ipnZJm3dzqUIM,1697
91
- dycw_utilities-0.153.14.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
92
- dycw_utilities-0.153.14.dist-info/entry_points.txt,sha256=BOD_SoDxwsfJYOLxhrSXhHP_T7iw-HXI9f2WVkzYxvQ,135
93
- dycw_utilities-0.153.14.dist-info/licenses/LICENSE,sha256=gppZp16M6nSVpBbUBrNL6JuYfvKwZiKgV7XoKKsHzqo,1066
94
- dycw_utilities-0.153.14.dist-info/RECORD,,
90
+ dycw_utilities-0.154.0.dist-info/METADATA,sha256=34hYZf8Cdia0JBt2zwn4AaQY6m0S7qY5VfKXDDjw6OY,1696
91
+ dycw_utilities-0.154.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
92
+ dycw_utilities-0.154.0.dist-info/entry_points.txt,sha256=BOD_SoDxwsfJYOLxhrSXhHP_T7iw-HXI9f2WVkzYxvQ,135
93
+ dycw_utilities-0.154.0.dist-info/licenses/LICENSE,sha256=gppZp16M6nSVpBbUBrNL6JuYfvKwZiKgV7XoKKsHzqo,1066
94
+ dycw_utilities-0.154.0.dist-info/RECORD,,
utilities/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.153.14"
3
+ __version__ = "0.154.0"
utilities/fpdf2.py CHANGED
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, override
6
6
  from fpdf import FPDF
7
7
  from fpdf.enums import XPos, YPos
8
8
 
9
- from utilities.whenever import format_compact, get_now, to_local_plain
9
+ from utilities.whenever import get_now, to_local_plain
10
10
 
11
11
  if TYPE_CHECKING:
12
12
  from collections.abc import Iterator
@@ -47,7 +47,7 @@ def yield_pdf(*, header: str | None = None) -> Iterator[_BasePDF]:
47
47
  def footer(self) -> None:
48
48
  self.set_y(-15)
49
49
  self.set_font(family="Helvetica", style="I", size=8)
50
- page_no, now = (self.page_no(), format_compact(to_local_plain(get_now())))
50
+ page_no, now = (self.page_no(), to_local_plain(get_now()))
51
51
  text = f"page {page_no}/{{}}; {now}"
52
52
  _ = self.cell(
53
53
  w=0,
utilities/iterables.py CHANGED
@@ -18,7 +18,7 @@ from enum import Enum
18
18
  from functools import cmp_to_key, partial, reduce
19
19
  from itertools import accumulate, chain, groupby, islice, pairwise, product
20
20
  from math import isnan
21
- from operator import add, itemgetter, or_
21
+ from operator import add, or_
22
22
  from typing import (
23
23
  TYPE_CHECKING,
24
24
  Any,
@@ -821,24 +821,6 @@ def filter_include_and_exclude[T, U](
821
821
  ##
822
822
 
823
823
 
824
- def group_consecutive_integers(iterable: Iterable[int], /) -> Iterable[tuple[int, int]]:
825
- """Group consecutive integers."""
826
- integers = sorted(iterable)
827
- for _, group in groupby(enumerate(integers), key=lambda x: x[1] - x[0]):
828
- as_list = list(map(itemgetter(1), group))
829
- yield as_list[0], as_list[-1]
830
-
831
-
832
- def ungroup_consecutive_integers(
833
- iterable: Iterable[tuple[int, int]], /
834
- ) -> Iterable[int]:
835
- """Ungroup consecutive integers."""
836
- return chain.from_iterable(range(start, end + 1) for start, end in iterable)
837
-
838
-
839
- ##
840
-
841
-
842
824
  @overload
843
825
  def groupby_lists[T](
844
826
  iterable: Iterable[T], /, *, key: None = None
@@ -1504,7 +1486,6 @@ __all__ = [
1504
1486
  "enumerate_with_edge",
1505
1487
  "expanding_window",
1506
1488
  "filter_include_and_exclude",
1507
- "group_consecutive_integers",
1508
1489
  "groupby_lists",
1509
1490
  "hashable_to_iterable",
1510
1491
  "is_iterable",
@@ -1527,6 +1508,5 @@ __all__ = [
1527
1508
  "sum_mappings",
1528
1509
  "take",
1529
1510
  "transpose",
1530
- "ungroup_consecutive_integers",
1531
1511
  "unique_everseen",
1532
1512
  ]
utilities/logging.py CHANGED
@@ -31,7 +31,7 @@ from typing import (
31
31
  override,
32
32
  )
33
33
 
34
- from whenever import PlainDateTime, ZonedDateTime
34
+ from whenever import ZonedDateTime
35
35
 
36
36
  from utilities.atomicwrites import move_many
37
37
  from utilities.dataclasses import replace_non_sentinel
@@ -45,16 +45,15 @@ from utilities.re import (
45
45
  extract_groups,
46
46
  )
47
47
  from utilities.sentinel import Sentinel, sentinel
48
- from utilities.tzlocal import LOCAL_TIME_ZONE_NAME
49
48
  from utilities.whenever import (
50
49
  WheneverLogRecord,
51
- format_compact,
52
50
  get_now_local,
51
+ parse_plain_local,
53
52
  to_local_plain,
54
53
  )
55
54
 
56
55
  if TYPE_CHECKING:
57
- from collections.abc import Callable, Iterable, Mapping
56
+ from collections.abc import Iterable, Mapping
58
57
  from datetime import time
59
58
  from logging import _FilterType
60
59
 
@@ -151,42 +150,6 @@ def basic_config(
151
150
  ##
152
151
 
153
152
 
154
- def filter_for_key(
155
- key: str, /, *, default: bool = False
156
- ) -> Callable[[LogRecord], bool]:
157
- """Make a filter for a given attribute."""
158
- if (key in _FILTER_FOR_KEY_BLACKLIST) or key.startswith("__"):
159
- raise FilterForKeyError(key=key)
160
-
161
- def filter_(record: LogRecord, /) -> bool:
162
- try:
163
- value = getattr(record, key)
164
- except AttributeError:
165
- return default
166
- return bool(value)
167
-
168
- return filter_
169
-
170
-
171
- # fmt: off
172
- _FILTER_FOR_KEY_BLACKLIST = {
173
- "args", "created", "exc_info", "exc_text", "filename", "funcName", "getMessage", "levelname", "levelno", "lineno", "module", "msecs", "msg", "name", "pathname", "process", "processName", "relativeCreated", "stack_info", "taskName", "thread", "threadName"
174
- }
175
- # fmt: on
176
-
177
-
178
- @dataclass(kw_only=True, slots=True)
179
- class FilterForKeyError(Exception):
180
- key: str
181
-
182
- @override
183
- def __str__(self) -> str:
184
- return f"Invalid key: {self.key!r}"
185
-
186
-
187
- ##
188
-
189
-
190
153
  def get_format_str(*, prefix: str | None = None, hostname: bool = False) -> str:
191
154
  """Generate a format string."""
192
155
  parts: list[str] = [
@@ -535,10 +498,8 @@ class _RotatingLogFile:
535
498
  stem=stem,
536
499
  suffix=suffix,
537
500
  index=int(index),
538
- start=PlainDateTime.parse_common_iso(start).assume_tz(
539
- LOCAL_TIME_ZONE_NAME
540
- ),
541
- end=PlainDateTime.parse_common_iso(end).assume_tz(LOCAL_TIME_ZONE_NAME),
501
+ start=parse_plain_local(start),
502
+ end=parse_plain_local(end),
542
503
  )
543
504
  try:
544
505
  index, end = extract_groups(patterns.pattern2, path.name)
@@ -550,7 +511,7 @@ class _RotatingLogFile:
550
511
  stem=stem,
551
512
  suffix=suffix,
552
513
  index=int(index),
553
- end=PlainDateTime.parse_common_iso(end).assume_tz(LOCAL_TIME_ZONE_NAME),
514
+ end=parse_plain_local(end),
554
515
  )
555
516
  try:
556
517
  index = extract_group(patterns.pattern1, path.name)
@@ -571,9 +532,9 @@ class _RotatingLogFile:
571
532
  case int() as index, None, None:
572
533
  tail = str(index)
573
534
  case int() as index, None, ZonedDateTime() as end:
574
- tail = f"{index}__{format_compact(to_local_plain(end))}"
535
+ tail = f"{index}__{to_local_plain(end)}"
575
536
  case int() as index, ZonedDateTime() as start, ZonedDateTime() as end:
576
- tail = f"{index}__{format_compact(to_local_plain(start))}__{format_compact(to_local_plain(end))}"
537
+ tail = f"{index}__{to_local_plain(start)}__{to_local_plain(end)}"
577
538
  case _: # pragma: no cover
578
539
  raise ImpossibleCaseError(
579
540
  case=[f"{self.index=}", f"{self.start=}", f"{self.end=}"]
@@ -626,12 +587,10 @@ def to_logger(logger: LoggerLike | None = None, /) -> Logger:
626
587
 
627
588
 
628
589
  __all__ = [
629
- "FilterForKeyError",
630
590
  "GetLoggingLevelNumberError",
631
591
  "SizeAndTimeRotatingFileHandler",
632
592
  "add_filters",
633
593
  "basic_config",
634
- "filter_for_key",
635
594
  "get_format_str",
636
595
  "get_logging_level_number",
637
596
  "setup_logging",
utilities/polars.py CHANGED
@@ -2383,7 +2383,8 @@ def round_to_float(
2383
2383
  ) -> ExprOrSeries:
2384
2384
  """Round a column to the nearest multiple of another float."""
2385
2385
  x = ensure_expr_or_series(x)
2386
- return (x / y).round(mode=mode) * y
2386
+ z = (x / y).round(mode=mode) * y
2387
+ return z.round(decimals=number_of_decimals(y) + 1)
2387
2388
 
2388
2389
 
2389
2390
  ##
utilities/pyinstrument.py CHANGED
@@ -8,7 +8,7 @@ from pyinstrument.profiler import Profiler
8
8
 
9
9
  from utilities.atomicwrites import writer
10
10
  from utilities.pathlib import to_path
11
- from utilities.whenever import format_compact, get_now, to_local_plain
11
+ from utilities.whenever import get_now, to_local_plain
12
12
 
13
13
  if TYPE_CHECKING:
14
14
  from collections.abc import Iterator
@@ -21,9 +21,7 @@ def profile(path: MaybeCallablePathLike = Path.cwd, /) -> Iterator[None]:
21
21
  """Profile the contents of a block."""
22
22
  with Profiler() as profiler:
23
23
  yield
24
- filename = to_path(path).joinpath(
25
- f"profile__{format_compact(to_local_plain(get_now()))}.html"
26
- )
24
+ filename = to_path(path).joinpath(f"profile__{to_local_plain(get_now())}.html")
27
25
  with writer(filename) as temp:
28
26
  _ = temp.write_text(profiler.output_html())
29
27
 
utilities/traceback.py CHANGED
@@ -33,6 +33,7 @@ from utilities.whenever import (
33
33
  format_compact,
34
34
  get_now,
35
35
  get_now_local,
36
+ parse_plain_local,
36
37
  to_local_plain,
37
38
  to_zoned_date_time,
38
39
  )
@@ -43,6 +44,7 @@ if TYPE_CHECKING:
43
44
  from types import TracebackType
44
45
 
45
46
  from utilities.types import (
47
+ Delta,
46
48
  MaybeCallableBoolLike,
47
49
  MaybeCallablePathLike,
48
50
  MaybeCallableZonedDateTimeLike,
@@ -95,16 +97,10 @@ def _yield_header_lines(
95
97
  ) -> Iterator[str]:
96
98
  """Yield the header lines."""
97
99
  now = get_now_local()
98
- start_use = to_zoned_date_time(start)
99
100
  yield f"Date/time | {format_compact(now)}"
100
- if start_use is None:
101
- start_str = ""
102
- else:
103
- start_str = format_compact(start_use.to_tz(LOCAL_TIME_ZONE_NAME))
104
- yield f"Started | {start_str}"
105
- delta = None if start_use is None else (now - start_use)
106
- delta_str = "" if delta is None else delta.format_common_iso()
107
- yield f"Duration | {delta_str}"
101
+ start_use = to_zoned_date_time(start).to_tz(LOCAL_TIME_ZONE_NAME)
102
+ yield f"Started | {format_compact(start_use)}"
103
+ yield f"Duration | {(now - start_use).format_common_iso()}"
108
104
  yield f"User | {getuser()}"
109
105
  yield f"Host | {gethostname()}"
110
106
  yield f"Process ID | {getpid()}"
@@ -205,6 +201,7 @@ def make_except_hook(
205
201
  start: MaybeCallableZonedDateTimeLike = get_now,
206
202
  version: MaybeCallableVersionLike | None = None,
207
203
  path: MaybeCallablePathLike | None = None,
204
+ path_max_age: Delta | None = None,
208
205
  max_width: int = RICH_MAX_WIDTH,
209
206
  indent_size: int = RICH_INDENT_SIZE,
210
207
  max_length: int | None = RICH_MAX_LENGTH,
@@ -222,6 +219,7 @@ def make_except_hook(
222
219
  start=start,
223
220
  version=version,
224
221
  path=path,
222
+ path_max_age=path_max_age,
225
223
  max_width=max_width,
226
224
  indent_size=indent_size,
227
225
  max_length=max_length,
@@ -242,6 +240,7 @@ def _make_except_hook_inner(
242
240
  start: MaybeCallableZonedDateTimeLike = get_now,
243
241
  version: MaybeCallableVersionLike | None = None,
244
242
  path: MaybeCallablePathLike | None = None,
243
+ path_max_age: Delta | None = None,
245
244
  max_width: int = RICH_MAX_WIDTH,
246
245
  indent_size: int = RICH_INDENT_SIZE,
247
246
  max_length: int | None = RICH_MAX_LENGTH,
@@ -258,11 +257,8 @@ def _make_except_hook_inner(
258
257
  slim = format_exception_stack(exc_val, header=True, start=start, version=version)
259
258
  _ = sys.stderr.write(f"{slim}\n") # don't 'from sys import stderr'
260
259
  if path is not None:
261
- path = (
262
- to_path(path)
263
- .joinpath(format_compact(to_local_plain(get_now())))
264
- .with_suffix(".txt")
265
- )
260
+ path = to_path(path)
261
+ path_log = path.joinpath(to_local_plain(get_now())).with_suffix(".txt")
266
262
  full = format_exception_stack(
267
263
  exc_val,
268
264
  header=True,
@@ -276,8 +272,10 @@ def _make_except_hook_inner(
276
272
  max_depth=max_depth,
277
273
  expand_all=expand_all,
278
274
  )
279
- with writer(path, overwrite=True) as temp:
275
+ with writer(path_log, overwrite=True) as temp:
280
276
  _ = temp.write_text(full)
277
+ if path_max_age is not None:
278
+ _make_except_hook_purge(path, path_max_age)
281
279
  if slack_url is not None: # pragma: no cover
282
280
  from utilities.slack_sdk import SendToSlackError, send_to_slack
283
281
 
@@ -285,13 +283,23 @@ def _make_except_hook_inner(
285
283
  send_to_slack(slack_url, f"```{slim}```")
286
284
  except SendToSlackError as error:
287
285
  _ = stderr.write(f"{error}\n")
288
-
289
286
  if to_bool(pudb): # pragma: no cover
290
287
  from pudb import post_mortem
291
288
 
292
289
  post_mortem(tb=traceback, e_type=exc_type, e_value=exc_val)
293
290
 
294
291
 
292
+ def _make_except_hook_purge(path: PathLike, max_age: Delta, /) -> None:
293
+ threshold = get_now() - max_age
294
+ paths = {
295
+ p
296
+ for p in Path(path).iterdir()
297
+ if p.is_file() and (parse_plain_local(p.stem) <= threshold)
298
+ }
299
+ for p in paths:
300
+ p.unlink(missing_ok=True)
301
+
302
+
295
303
  @dataclass(kw_only=True, slots=True)
296
304
  class MakeExceptHookError(Exception):
297
305
  @override
utilities/whenever.py CHANGED
@@ -52,8 +52,6 @@ if TYPE_CHECKING:
52
52
  TimeZoneLike,
53
53
  )
54
54
 
55
- # type vars
56
-
57
55
 
58
56
  # bounds
59
57
 
@@ -1008,9 +1006,14 @@ class _ToHoursNanosecondsError(ToHoursError):
1008
1006
  ##
1009
1007
 
1010
1008
 
1011
- def to_local_plain(date_time: ZonedDateTime, /) -> PlainDateTime:
1009
+ def to_local_plain(date_time: ZonedDateTime, /) -> str:
1012
1010
  """Convert a datetime to its local/plain variant."""
1013
- return date_time.to_tz(LOCAL_TIME_ZONE_NAME).to_plain()
1011
+ return format_compact(date_time.to_tz(LOCAL_TIME_ZONE_NAME).to_plain())
1012
+
1013
+
1014
+ def parse_plain_local(text: str, /) -> ZonedDateTime:
1015
+ """Parse a plain, local datetime."""
1016
+ return PlainDateTime.parse_common_iso(text).assume_tz(LOCAL_TIME_ZONE_NAME)
1014
1017
 
1015
1018
 
1016
1019
  ##
@@ -1967,6 +1970,7 @@ __all__ = [
1967
1970
  "get_today_local",
1968
1971
  "mean_datetime",
1969
1972
  "min_max_date",
1973
+ "parse_plain_local",
1970
1974
  "round_date_or_date_time",
1971
1975
  "sub_year_month",
1972
1976
  "to_date",