dycw-utilities 0.129.7__py3-none-any.whl → 0.129.9__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.129.7
3
+ Version: 0.129.9
4
4
  Author-email: Derek Wan <d.wan@icloud.com>
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -98,7 +98,6 @@ Requires-Dist: polars-lts-cpu<1.31,>=1.30.0; extra == 'zzz-test-jupyter'
98
98
  Provides-Extra: zzz-test-logging
99
99
  Requires-Dist: atomicwrites<1.5,>=1.4.1; extra == 'zzz-test-logging'
100
100
  Requires-Dist: coloredlogs<15.1,>=15.0.1; extra == 'zzz-test-logging'
101
- Requires-Dist: concurrent-log-handler<0.10,>=0.9.26; extra == 'zzz-test-logging'
102
101
  Requires-Dist: rich<14.1,>=14.0.0; extra == 'zzz-test-logging'
103
102
  Requires-Dist: tomlkit<0.14,>=0.13.2; extra == 'zzz-test-logging'
104
103
  Requires-Dist: tzlocal<5.4,>=5.3.1; extra == 'zzz-test-logging'
@@ -1,4 +1,4 @@
1
- utilities/__init__.py,sha256=V6BzEBm23oBvaBdsKWGFjnWWR1qGDDjldECqyydnokk,60
1
+ utilities/__init__.py,sha256=uFYFepkZNArEi_sHiw7tlOn88WgLGjxnMihyD4GzfOQ,60
2
2
  utilities/altair.py,sha256=Gpja-flOo-Db0PIPJLJsgzAlXWoKUjPU1qY-DQ829ek,9156
3
3
  utilities/asyncio.py,sha256=3n5EIcSq2xtEF1i4oR0oY2JmBq3NyugeHKFK39Mt22s,37987
4
4
  utilities/atomicwrites.py,sha256=geFjn9Pwn-tTrtoGjDDxWli9NqbYfy3gGL6ZBctiqSo,5393
@@ -29,7 +29,7 @@ utilities/iterables.py,sha256=mDqw2_0MUVp-P8FklgcaVTi2TXduH0MxbhTDzzhSBho,44915
29
29
  utilities/jupyter.py,sha256=ft5JA7fBxXKzP-L9W8f2-wbF0QeYc_2uLQNFDVk4Z-M,2917
30
30
  utilities/libcst.py,sha256=Jto5ppzRzsxn4AD32IS8n0lbgLYXwsVJB6EY8giNZyY,4974
31
31
  utilities/lightweight_charts.py,sha256=0xNfcsrgFI0R9xL25LtSm-W5yhfBI93qQNT6HyaXAhg,2769
32
- utilities/logging.py,sha256=9vo6vz4sDMoF2nkc8eT-eWpymBb5QboIu0kMhEwPiqk,25206
32
+ utilities/logging.py,sha256=dA54i2gmULBLuEJ2roGYWt9pW2NvNBmx0YxlMns347M,26126
33
33
  utilities/loguru.py,sha256=MEMQVWrdECxk1e3FxGzmOf21vWT9j8CAir98SEXFKPA,3809
34
34
  utilities/luigi.py,sha256=fpH9MbxJDuo6-k9iCXRayFRtiVbUtibCJKugf7ygpv0,5988
35
35
  utilities/math.py,sha256=-mQgbah-dPJwOEWf3SonrFoVZ2AVxMgpeQ3dfVa-oJA,26764
@@ -78,7 +78,7 @@ utilities/tenacity.py,sha256=1PUvODiBVgeqIh7G5TRt5WWMSqjLYkEqP53itT97WQc,4914
78
78
  utilities/text.py,sha256=ymBFlP_cA8OgNnZRVNs7FAh7OG8HxE6YkiLEMZv5g_A,11297
79
79
  utilities/threading.py,sha256=GvBOp4CyhHfN90wGXZuA2VKe9fGzMaEa7oCl4f3nnPU,1009
80
80
  utilities/timer.py,sha256=Rkc49KSpHuC8s7vUxGO9DU55U9I6yDKnchsQqrUCVBs,4075
81
- utilities/traceback.py,sha256=Rf_4XIz6AQaBcTRr7Tiw7RCIv_O_bN7Hd-Cnr8SPXN4,28920
81
+ utilities/traceback.py,sha256=EUm-5jhH1RICBmsG_H77-CXvD-IVErfkzGRSqnvR0dI,35782
82
82
  utilities/types.py,sha256=gP04CcCOyFrG7BgblVCsrrChiuO2x842NDVW-GF7odo,18370
83
83
  utilities/typing.py,sha256=H6ysJkI830aRwLsMKz0SZIw4cpcsm7d6KhQOwr-SDh0,13817
84
84
  utilities/tzdata.py,sha256=yCf70NICwAeazN3_JcXhWvRqCy06XJNQ42j7r6gw3HY,1217
@@ -86,10 +86,10 @@ utilities/tzlocal.py,sha256=3upDNFBvGh1l9njmLR2z2S6K6VxQSb7QizYGUbAH3JU,960
86
86
  utilities/uuid.py,sha256=jJTFxz-CWgltqNuzmythB7iEQ-Q1mCwPevUfKthZT3c,611
87
87
  utilities/version.py,sha256=ufhJMmI6KPs1-3wBI71aj5wCukd3sP_m11usLe88DNA,5117
88
88
  utilities/warnings.py,sha256=un1LvHv70PU-LLv8RxPVmugTzDJkkGXRMZTE2-fTQHw,1771
89
- utilities/whenever.py,sha256=jS31ZAY5OMxFxLja_Yo5Fidi87Pd-GoVZ7Vi_teqVDA,16743
89
+ utilities/whenever.py,sha256=QbXgFAPuUL7PCp2hajmIP-FFIfIR1J6Y0TxJbeoj60I,18434
90
90
  utilities/zipfile.py,sha256=24lQc9ATcJxHXBPc_tBDiJk48pWyRrlxO2fIsFxU0A8,699
91
91
  utilities/zoneinfo.py,sha256=-5j7IQ9nb7gR43rdgA7ms05im-XuqhAk9EJnQBXxCoQ,1874
92
- dycw_utilities-0.129.7.dist-info/METADATA,sha256=SnavcALe7H7k-aZL5l6A8TunsE1JBzlrep0hD7Y6fkw,12804
93
- dycw_utilities-0.129.7.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
94
- dycw_utilities-0.129.7.dist-info/licenses/LICENSE,sha256=gppZp16M6nSVpBbUBrNL6JuYfvKwZiKgV7XoKKsHzqo,1066
95
- dycw_utilities-0.129.7.dist-info/RECORD,,
92
+ dycw_utilities-0.129.9.dist-info/METADATA,sha256=c0KdCG0ORHKUnqbJ4eaGnDoZNUDbRtkjupd_8Oqg22c,12723
93
+ dycw_utilities-0.129.9.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
94
+ dycw_utilities-0.129.9.dist-info/licenses/LICENSE,sha256=gppZp16M6nSVpBbUBrNL6JuYfvKwZiKgV7XoKKsHzqo,1066
95
+ dycw_utilities-0.129.9.dist-info/RECORD,,
utilities/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.129.7"
3
+ __version__ = "0.129.9"
utilities/logging.py CHANGED
@@ -107,8 +107,9 @@ class SizeAndTimeRotatingFileHandler(BaseRotatingHandler):
107
107
  utc: bool = False,
108
108
  atTime: dt.time | None = None,
109
109
  ) -> None:
110
- filename = str(Path(filename))
111
- super().__init__(filename, mode, encoding=encoding, delay=delay, errors=errors)
110
+ path = Path(filename)
111
+ path.parent.mkdir(parents=True, exist_ok=True)
112
+ super().__init__(path, mode, encoding=encoding, delay=delay, errors=errors)
112
113
  self._max_bytes = maxBytes if maxBytes >= 1 else None
113
114
  self._backup_count = backupCount if backupCount >= 1 else None
114
115
  self._filename = Path(self.baseFilename)
@@ -117,7 +118,7 @@ class SizeAndTimeRotatingFileHandler(BaseRotatingHandler):
117
118
  self._suffix = self._filename.suffix
118
119
  self._patterns = _compute_rollover_patterns(self._stem, self._suffix)
119
120
  self._time_handler = TimedRotatingFileHandler(
120
- filename,
121
+ path,
121
122
  when=when,
122
123
  interval=interval,
123
124
  backupCount=backupCount,
@@ -415,26 +416,53 @@ def add_filters(handler: Handler, /, *filters: _FilterType) -> None:
415
416
 
416
417
  def basic_config(
417
418
  *,
418
- logger: LoggerOrName | None = None,
419
+ obj: LoggerOrName | Handler | None = None,
419
420
  format_: str = "{asctime} | {name} | {levelname:8} | {message}",
421
+ whenever: bool = False,
420
422
  level: LogLevel = "INFO",
423
+ plain: bool = False,
421
424
  ) -> None:
422
425
  """Do the basic config."""
426
+ if whenever:
427
+ format_ = format_.replace("{asctime}", "{zoned_datetime}")
423
428
  datefmt = maybe_sub_pct_y("%Y-%m-%d %H:%M:%S")
424
- if logger is None:
425
- basicConfig(format=format_, datefmt=datefmt, style="{", level=level)
426
- else:
427
- logger_use = get_logger(logger=logger)
428
- logger_use.setLevel(level)
429
- logger_use.addHandler(handler := StreamHandler())
430
- handler.setLevel(level)
431
- try:
432
- from coloredlogs import ColoredFormatter
433
- except ModuleNotFoundError: # pragma: no cover
434
- formatter = Formatter(fmt=format_, datefmt=datefmt, style="{")
435
- else:
436
- formatter = ColoredFormatter(fmt=format_, datefmt=datefmt, style="{")
437
- handler.setFormatter(formatter)
429
+ match obj:
430
+ case None:
431
+ basicConfig(format=format_, datefmt=datefmt, style="{", level=level)
432
+ case Logger() as logger:
433
+ logger.setLevel(level)
434
+ logger.addHandler(handler := StreamHandler())
435
+ basic_config(
436
+ obj=handler,
437
+ format_=format_,
438
+ whenever=whenever,
439
+ level=level,
440
+ plain=plain,
441
+ )
442
+ case str() as name:
443
+ basic_config(
444
+ obj=get_logger(logger=name),
445
+ format_=format_,
446
+ whenever=whenever,
447
+ level=level,
448
+ plain=plain,
449
+ )
450
+ case Handler() as handler:
451
+ handler.setLevel(level)
452
+ if plain:
453
+ formatter = Formatter(fmt=format_, datefmt=datefmt, style="{")
454
+ else:
455
+ try:
456
+ from coloredlogs import ColoredFormatter
457
+ except ModuleNotFoundError: # pragma: no cover
458
+ formatter = Formatter(fmt=format_, datefmt=datefmt, style="{")
459
+ else:
460
+ formatter = ColoredFormatter(
461
+ fmt=format_, datefmt=datefmt, style="{"
462
+ )
463
+ handler.setFormatter(formatter)
464
+ case _ as never:
465
+ assert_never(never)
438
466
 
439
467
 
440
468
  ##
utilities/traceback.py CHANGED
@@ -1,10 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import re
4
+ import sys
5
+ from asyncio import run
3
6
  from collections.abc import Callable, Iterable
4
7
  from dataclasses import dataclass, field, replace
5
- from functools import wraps
8
+ from functools import partial, wraps
6
9
  from getpass import getuser
7
10
  from inspect import iscoroutinefunction, signature
11
+ from itertools import repeat
8
12
  from logging import Formatter, Handler, LogRecord
9
13
  from pathlib import Path
10
14
  from socket import gethostname
@@ -26,8 +30,8 @@ from typing import (
26
30
  runtime_checkable,
27
31
  )
28
32
 
29
- from utilities.datetime import get_datetime, get_now
30
- from utilities.errors import ImpossibleCaseError
33
+ from utilities.datetime import get_datetime, get_now, serialize_compact
34
+ from utilities.errors import ImpossibleCaseError, repr_error
31
35
  from utilities.functions import (
32
36
  ensure_not_none,
33
37
  ensure_str,
@@ -35,7 +39,8 @@ from utilities.functions import (
35
39
  get_func_name,
36
40
  get_func_qualname,
37
41
  )
38
- from utilities.iterables import always_iterable, one
42
+ from utilities.iterables import OneEmptyError, always_iterable, one
43
+ from utilities.pathlib import get_path
39
44
  from utilities.reprlib import (
40
45
  RICH_EXPAND_ALL,
41
46
  RICH_INDENT_SIZE,
@@ -46,12 +51,18 @@ from utilities.reprlib import (
46
51
  yield_call_args_repr,
47
52
  yield_mapping_repr,
48
53
  )
49
- from utilities.types import MaybeCallableDateTime, TBaseException, TCallable
54
+ from utilities.types import (
55
+ MaybeCallableDateTime,
56
+ MaybeCallablePathLike,
57
+ PathLike,
58
+ TBaseException,
59
+ TCallable,
60
+ )
50
61
  from utilities.version import get_version
51
62
  from utilities.whenever import serialize_duration
52
63
 
53
64
  if TYPE_CHECKING:
54
- from collections.abc import Callable, Iterable, Iterator
65
+ from collections.abc import Callable, Iterable, Iterator, Sequence
55
66
  from logging import _FormatStyle
56
67
  from types import FrameType, TracebackType
57
68
 
@@ -68,6 +79,131 @@ _START = get_now()
68
79
  ##
69
80
 
70
81
 
82
+ def format_exception_stack(
83
+ error: BaseException,
84
+ /,
85
+ *,
86
+ header: bool = False,
87
+ start: MaybeCallableDateTime | None = _START,
88
+ version: MaybeCallableVersionLike | None = None,
89
+ capture_locals: bool = False,
90
+ max_width: int = RICH_MAX_WIDTH,
91
+ indent_size: int = RICH_INDENT_SIZE,
92
+ max_length: int | None = RICH_MAX_LENGTH,
93
+ max_string: int | None = RICH_MAX_STRING,
94
+ max_depth: int | None = RICH_MAX_DEPTH,
95
+ expand_all: bool = RICH_EXPAND_ALL,
96
+ ) -> str:
97
+ """Format an exception stack."""
98
+ lines: Sequence[str] = []
99
+ if header:
100
+ lines.extend(_yield_header_lines(start=start, version=version))
101
+ lines.extend(
102
+ _yield_formatted_frame_summary(
103
+ error,
104
+ capture_locals=capture_locals,
105
+ max_width=max_width,
106
+ indent_size=indent_size,
107
+ max_length=max_length,
108
+ max_string=max_string,
109
+ max_depth=max_depth,
110
+ expand_all=expand_all,
111
+ )
112
+ )
113
+ return "\n".join(lines)
114
+
115
+
116
+ ##
117
+
118
+
119
+ def make_except_hook(
120
+ *,
121
+ start: MaybeCallableDateTime | None = _START,
122
+ version: MaybeCallableVersionLike | None = None,
123
+ path: MaybeCallablePathLike | None = None,
124
+ max_width: int = RICH_MAX_WIDTH,
125
+ indent_size: int = RICH_INDENT_SIZE,
126
+ max_length: int | None = RICH_MAX_LENGTH,
127
+ max_string: int | None = RICH_MAX_STRING,
128
+ max_depth: int | None = RICH_MAX_DEPTH,
129
+ expand_all: bool = RICH_EXPAND_ALL,
130
+ slack_url: str | None = None,
131
+ ) -> Callable[
132
+ [type[BaseException] | None, BaseException | None, TracebackType | None], None
133
+ ]:
134
+ """Exception hook to log the traceback."""
135
+ return partial(
136
+ _make_except_hook_inner,
137
+ start=start,
138
+ version=version,
139
+ path=path,
140
+ max_width=max_width,
141
+ indent_size=indent_size,
142
+ max_length=max_length,
143
+ max_string=max_string,
144
+ max_depth=max_depth,
145
+ expand_all=expand_all,
146
+ slack_url=slack_url,
147
+ )
148
+
149
+
150
+ def _make_except_hook_inner(
151
+ exc_type: type[BaseException] | None,
152
+ exc_val: BaseException | None,
153
+ traceback: TracebackType | None,
154
+ /,
155
+ *,
156
+ start: MaybeCallableDateTime | None = _START,
157
+ version: MaybeCallableVersionLike | None = None,
158
+ path: MaybeCallablePathLike | None = None,
159
+ max_width: int = RICH_MAX_WIDTH,
160
+ indent_size: int = RICH_INDENT_SIZE,
161
+ max_length: int | None = RICH_MAX_LENGTH,
162
+ max_string: int | None = RICH_MAX_STRING,
163
+ max_depth: int | None = RICH_MAX_DEPTH,
164
+ expand_all: bool = RICH_EXPAND_ALL,
165
+ slack_url: str | None = None,
166
+ ) -> None:
167
+ """Exception hook to log the traceback."""
168
+ _ = (exc_type, traceback)
169
+ if exc_val is None:
170
+ raise MakeExceptHookError
171
+ slim = format_exception_stack(exc_val, header=True, start=start, version=version)
172
+ _ = sys.stderr.write(f"{slim}\n") # don't 'from sys import stderr'
173
+ if path is not None:
174
+ from utilities.atomicwrites import writer
175
+ from utilities.tzlocal import get_now_local
176
+
177
+ path = (
178
+ get_path(path=path)
179
+ .joinpath(serialize_compact(get_now_local()))
180
+ .with_suffix(".txt")
181
+ )
182
+ full = format_exception_stack(
183
+ exc_val,
184
+ header=True,
185
+ start=start,
186
+ version=version,
187
+ capture_locals=True,
188
+ max_width=max_width,
189
+ indent_size=indent_size,
190
+ max_length=max_length,
191
+ max_string=max_string,
192
+ max_depth=max_depth,
193
+ expand_all=expand_all,
194
+ )
195
+ with writer(path, overwrite=True) as temp:
196
+ _ = temp.write_text(full)
197
+ if slack_url is not None: # pragma: no cover
198
+ from utilities.slack_sdk import send_to_slack
199
+
200
+ send = f"```{slim}```"
201
+ run(send_to_slack(slack_url, send))
202
+
203
+
204
+ ##
205
+
206
+
71
207
  class RichTracebackFormatter(Formatter):
72
208
  """Formatter for rich tracebacks."""
73
209
 
@@ -811,6 +947,9 @@ def _merge_frames(
811
947
  return values[::-1]
812
948
 
813
949
 
950
+ ##
951
+
952
+
814
953
  def _yield_header_lines(
815
954
  *,
816
955
  start: MaybeCallableDateTime | None = _START,
@@ -838,12 +977,108 @@ def _yield_header_lines(
838
977
  yield ""
839
978
 
840
979
 
980
+ ##
981
+
982
+
983
+ def _yield_formatted_frame_summary(
984
+ error: BaseException,
985
+ /,
986
+ *,
987
+ capture_locals: bool = False,
988
+ max_width: int = RICH_MAX_WIDTH,
989
+ indent_size: int = RICH_INDENT_SIZE,
990
+ max_length: int | None = RICH_MAX_LENGTH,
991
+ max_string: int | None = RICH_MAX_STRING,
992
+ max_depth: int | None = RICH_MAX_DEPTH,
993
+ expand_all: bool = RICH_EXPAND_ALL,
994
+ ) -> Iterator[str]:
995
+ """Yield the formatted frame summary lines."""
996
+ stack = TracebackException.from_exception(
997
+ error, capture_locals=capture_locals
998
+ ).stack
999
+ n = len(stack)
1000
+ for i, frame in enumerate(stack, start=1):
1001
+ num = f"{i}/{n}"
1002
+ first, *rest = _yield_frame_summary_lines(
1003
+ frame,
1004
+ max_width=max_width,
1005
+ indent_size=indent_size,
1006
+ max_length=max_length,
1007
+ max_string=max_string,
1008
+ max_depth=max_depth,
1009
+ expand_all=expand_all,
1010
+ )
1011
+ yield f"{num} | {first}"
1012
+ blank = "".join(repeat(" ", len(num)))
1013
+ for rest_i in rest:
1014
+ yield f"{blank} | {rest_i}"
1015
+ yield repr_error(error)
1016
+
1017
+
1018
+ def _yield_frame_summary_lines(
1019
+ frame: FrameSummary,
1020
+ /,
1021
+ *,
1022
+ max_width: int = RICH_MAX_WIDTH,
1023
+ indent_size: int = RICH_INDENT_SIZE,
1024
+ max_length: int | None = RICH_MAX_LENGTH,
1025
+ max_string: int | None = RICH_MAX_STRING,
1026
+ max_depth: int | None = RICH_MAX_DEPTH,
1027
+ expand_all: bool = RICH_EXPAND_ALL,
1028
+ ) -> Iterator[str]:
1029
+ module = _path_to_dots(frame.filename)
1030
+ yield f"{module}:{frame.lineno} | {frame.name} | {frame.line}"
1031
+ if frame.locals is not None:
1032
+ yield from yield_mapping_repr(
1033
+ frame.locals,
1034
+ _max_width=max_width,
1035
+ _indent_size=indent_size,
1036
+ _max_length=max_length,
1037
+ _max_string=max_string,
1038
+ _max_depth=max_depth,
1039
+ _expand_all=expand_all,
1040
+ )
1041
+
1042
+
1043
+ def _path_to_dots(path: PathLike, /) -> str:
1044
+ new_path: Path | None = None
1045
+ for pattern in [
1046
+ "site-packages",
1047
+ ".venv", # after site-packages
1048
+ "src",
1049
+ r"python\d+\.\d+",
1050
+ ]:
1051
+ if (new_path := _trim_path(path, pattern)) is not None:
1052
+ break
1053
+ path_use = Path(path) if new_path is None else new_path
1054
+ return ".".join(path_use.with_suffix("").parts)
1055
+
1056
+
1057
+ def _trim_path(path: PathLike, pattern: str, /) -> Path | None:
1058
+ parts = Path(path).parts
1059
+ compiled = re.compile(f"^{pattern}$")
1060
+ try:
1061
+ i = one(i for i, p in enumerate(parts) if compiled.search(p))
1062
+ except OneEmptyError:
1063
+ return None
1064
+ return Path(*parts[i + 1 :])
1065
+
1066
+
1067
+ @dataclass(kw_only=True, slots=True)
1068
+ class MakeExceptHookError(Exception):
1069
+ @override
1070
+ def __str__(self) -> str:
1071
+ return "No exception to log"
1072
+
1073
+
841
1074
  __all__ = [
842
1075
  "ExcChainTB",
843
1076
  "ExcGroupTB",
844
1077
  "ExcTB",
845
1078
  "RichTracebackFormatter",
1079
+ "format_exception_stack",
846
1080
  "get_rich_traceback",
1081
+ "make_except_hook",
847
1082
  "trace",
848
1083
  "yield_exceptions",
849
1084
  "yield_extended_frame_summaries",
utilities/whenever.py CHANGED
@@ -4,7 +4,9 @@ import datetime as dt
4
4
  import re
5
5
  from contextlib import suppress
6
6
  from dataclasses import dataclass
7
- from typing import TYPE_CHECKING, override
7
+ from functools import cache
8
+ from logging import LogRecord
9
+ from typing import TYPE_CHECKING, Any, override
8
10
 
9
11
  from whenever import (
10
12
  Date,
@@ -33,6 +35,8 @@ from utilities.re import (
33
35
  from utilities.zoneinfo import UTC, ensure_time_zone, get_time_zone_name
34
36
 
35
37
  if TYPE_CHECKING:
38
+ from zoneinfo import ZoneInfo
39
+
36
40
  from utilities.types import (
37
41
  DateLike,
38
42
  DateTimeLike,
@@ -561,6 +565,64 @@ class SerializeZonedDateTimeError(Exception):
561
565
  ##
562
566
 
563
567
 
568
+ class WheneverLogRecord(LogRecord):
569
+ """Log record powered by `whenever`."""
570
+
571
+ zoned_datetime: str
572
+
573
+ @override
574
+ def __init__(
575
+ self,
576
+ name: str,
577
+ level: int,
578
+ pathname: str,
579
+ lineno: int,
580
+ msg: object,
581
+ args: Any,
582
+ exc_info: Any,
583
+ func: str | None = None,
584
+ sinfo: str | None = None,
585
+ ) -> None:
586
+ super().__init__(
587
+ name, level, pathname, lineno, msg, args, exc_info, func, sinfo
588
+ )
589
+ length = self._get_length()
590
+ plain = format(self._get_now().to_plain().format_common_iso(), f"{length}s")
591
+ time_zone = self._get_time_zone_key()
592
+ self.zoned_datetime = f"{plain}[{time_zone}]"
593
+
594
+ @classmethod
595
+ @cache
596
+ def _get_time_zone(cls) -> ZoneInfo:
597
+ """Get the local timezone."""
598
+ try:
599
+ from utilities.tzlocal import get_local_time_zone
600
+ except ModuleNotFoundError: # pragma: no cover
601
+ return UTC
602
+ return get_local_time_zone()
603
+
604
+ @classmethod
605
+ @cache
606
+ def _get_time_zone_key(cls) -> str:
607
+ """Get the local timezone as a string."""
608
+ return cls._get_time_zone().key
609
+
610
+ @classmethod
611
+ @cache
612
+ def _get_length(cls) -> int:
613
+ """Get maximum length of a formatted string."""
614
+ now = cls._get_now().replace(nanosecond=1000).to_plain()
615
+ return len(now.format_common_iso())
616
+
617
+ @classmethod
618
+ def _get_now(cls) -> ZonedDateTime:
619
+ """Get the current zoned datetime."""
620
+ return ZonedDateTime.now(cls._get_time_zone().key)
621
+
622
+
623
+ ##
624
+
625
+
564
626
  def _to_datetime_delta(timedelta: dt.timedelta, /) -> DateTimeDelta:
565
627
  """Serialize a timedelta."""
566
628
  total_microseconds = datetime_duration_to_microseconds(timedelta)
@@ -610,6 +672,7 @@ __all__ = [
610
672
  "SerializePlainDateTimeError",
611
673
  "SerializeTimeDeltaError",
612
674
  "SerializeZonedDateTimeError",
675
+ "WheneverLogRecord",
613
676
  "check_valid_zoned_datetime",
614
677
  "ensure_date",
615
678
  "ensure_datetime",