sefkhet 0.1a0__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.
- sefkhet/__init__.py +72 -0
- sefkhet/_color.py +266 -0
- sefkhet/_constants.py +85 -0
- sefkhet/_csv.py +202 -0
- sefkhet/_functional.py +1807 -0
- sefkhet/_json.py +168 -0
- sefkhet/_logfmt.py +197 -0
- sefkhet/_object_oriented.py +1008 -0
- sefkhet/_one_step.py +424 -0
- sefkhet/_typing.py +471 -0
- sefkhet/_version.py +3 -0
- sefkhet/py.typed +0 -0
- sefkhet-0.1a0.dist-info/METADATA +122 -0
- sefkhet-0.1a0.dist-info/RECORD +17 -0
- sefkhet-0.1a0.dist-info/WHEEL +5 -0
- sefkhet-0.1a0.dist-info/licenses/LICENSE.md +21 -0
- sefkhet-0.1a0.dist-info/top_level.txt +1 -0
sefkhet/__init__.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Complex logging setup made simple.
|
|
3
|
+
|
|
4
|
+
The `sefkhet` package aims to enable one-step configuration of the
|
|
5
|
+
built-in `logging` package, making complex logging setup easy.
|
|
6
|
+
|
|
7
|
+
The main utilities are the `record`/`rec` function
|
|
8
|
+
and the `Scribe` class.
|
|
9
|
+
|
|
10
|
+
Getting started is as easy as importing and calling `record`:
|
|
11
|
+
```
|
|
12
|
+
from sefkhet import record
|
|
13
|
+
|
|
14
|
+
record("I <3 sefkhet!")
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
You can also use the `Scribe` class for more fine-grained control:
|
|
18
|
+
```
|
|
19
|
+
import sefkhet
|
|
20
|
+
|
|
21
|
+
scribe = sefkhet.Scribe(handlers="example.log")
|
|
22
|
+
scribe.log("I <3 sefkhet!")
|
|
23
|
+
```
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
# Make top-level utilities available
|
|
27
|
+
from sefkhet._color import ColorFormatter, get_level_color
|
|
28
|
+
from sefkhet._csv import CsvFormatter
|
|
29
|
+
from sefkhet._functional import (
|
|
30
|
+
add_filters_to_target,
|
|
31
|
+
add_handlers_to_logger,
|
|
32
|
+
get_filter,
|
|
33
|
+
get_formatter,
|
|
34
|
+
get_handler,
|
|
35
|
+
get_log_level_map,
|
|
36
|
+
get_log_levels,
|
|
37
|
+
get_logger,
|
|
38
|
+
)
|
|
39
|
+
from sefkhet._json import JsonFormatter
|
|
40
|
+
from sefkhet._logfmt import LogfmtFormatter
|
|
41
|
+
from sefkhet._object_oriented import Scribe
|
|
42
|
+
from sefkhet._one_step import configure_default_logger, log, rec, record
|
|
43
|
+
|
|
44
|
+
# Make version number available
|
|
45
|
+
from sefkhet._version import __version__
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Define public API
|
|
49
|
+
__all__ = [ # ruff: ignore[unsorted-dunder-all]
|
|
50
|
+
# Metadata
|
|
51
|
+
"__version__",
|
|
52
|
+
# Classes
|
|
53
|
+
"ColorFormatter",
|
|
54
|
+
"CsvFormatter",
|
|
55
|
+
"JsonFormatter",
|
|
56
|
+
"LogfmtFormatter",
|
|
57
|
+
"Scribe",
|
|
58
|
+
# Functions
|
|
59
|
+
"add_filters_to_target",
|
|
60
|
+
"add_handlers_to_logger",
|
|
61
|
+
"configure_default_logger",
|
|
62
|
+
"get_filter",
|
|
63
|
+
"get_formatter",
|
|
64
|
+
"get_handler",
|
|
65
|
+
"get_level_color",
|
|
66
|
+
"get_log_level_map",
|
|
67
|
+
"get_log_levels",
|
|
68
|
+
"get_logger",
|
|
69
|
+
"log",
|
|
70
|
+
"rec",
|
|
71
|
+
"record",
|
|
72
|
+
]
|
sefkhet/_color.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Color formatting utilities for `sefkhet`."""
|
|
2
|
+
|
|
3
|
+
import logging as _logging
|
|
4
|
+
from typing import TYPE_CHECKING as _TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
if _TYPE_CHECKING: # pragma: no cover
|
|
8
|
+
import logging
|
|
9
|
+
from collections.abc import Callable, Mapping
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from sefkhet._typing import ColorMode, ColorSpec, FormatStyle
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Define ANSI color strings
|
|
16
|
+
_ANSI_RESET = "\033[0m"
|
|
17
|
+
_PINK = "\033[38;5;212m"
|
|
18
|
+
_BLUE = "\033[94m"
|
|
19
|
+
_GREEN = "\033[32m"
|
|
20
|
+
_CYAN = "\033[38;5;123m"
|
|
21
|
+
_YELLOW = "\033[93m"
|
|
22
|
+
_ORANGE = "\033[38;5;214m"
|
|
23
|
+
_RED = "\033[91m"
|
|
24
|
+
_MAGENTA = "\033[35m"
|
|
25
|
+
|
|
26
|
+
# Define thresholds for the color mappings (The first number
|
|
27
|
+
# for which the number <= threshold is the color that is used)
|
|
28
|
+
_COLOR_THRESHOLDS = {
|
|
29
|
+
0: _PINK,
|
|
30
|
+
9: _BLUE,
|
|
31
|
+
19: _GREEN,
|
|
32
|
+
29: _CYAN,
|
|
33
|
+
39: _YELLOW,
|
|
34
|
+
49: _ORANGE,
|
|
35
|
+
59: _RED,
|
|
36
|
+
100: _MAGENTA,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Define width to pad level names to
|
|
41
|
+
_LEVELNAME_WIDTH = 8
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _get_display_levelname(levelname: str) -> str:
|
|
45
|
+
"""
|
|
46
|
+
Returns the name to display in the log for `levelname`.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
levelname (str): Original log level name
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
str: Formatted log level name to display, which is `levelname`
|
|
53
|
+
left-padded to `_LEVELNAME_WIDTH` characters, with the
|
|
54
|
+
prefix `"Level "` replaced by `"LEVEL_"` if `levelname`
|
|
55
|
+
starts with `"Level "` followed by a digit (e.g.,
|
|
56
|
+
`"Level 1"` becomes `"LEVEL_1"`)
|
|
57
|
+
"""
|
|
58
|
+
if levelname.startswith("Level ") and levelname[6:].isdigit():
|
|
59
|
+
levelname = "LEVEL_" + levelname[6:]
|
|
60
|
+
return levelname.ljust(_LEVELNAME_WIDTH)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_level_color(level: int) -> str:
|
|
64
|
+
"""
|
|
65
|
+
Returns the ANSI escape code string for the given integer log level.
|
|
66
|
+
|
|
67
|
+
The mapping from level to color is:
|
|
68
|
+
|
|
69
|
+
- `<= 0` : pink
|
|
70
|
+
- `1-9` : blue
|
|
71
|
+
- `10-19`: green
|
|
72
|
+
- `20-29`: cyan
|
|
73
|
+
- `30-39`: yellow
|
|
74
|
+
- `40-49`: orange
|
|
75
|
+
- `50-59`: red
|
|
76
|
+
- `60-100`: magenta
|
|
77
|
+
- `> 100`: none
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
level (int): Integer log level
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
str: ANSI escape code string for the given level
|
|
84
|
+
"""
|
|
85
|
+
# The threshold dictionary is ordered from lowest to highest,
|
|
86
|
+
# so we iterate through until we find level <= threshold
|
|
87
|
+
for threshold, color in _COLOR_THRESHOLDS.items():
|
|
88
|
+
if level <= threshold:
|
|
89
|
+
return color
|
|
90
|
+
# If no threshold matched, return an empty string
|
|
91
|
+
return ""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _parse_color_spec(
|
|
95
|
+
color: "ColorSpec",
|
|
96
|
+
) -> "tuple[ColorMode, Callable[[int], str]]":
|
|
97
|
+
"""
|
|
98
|
+
Parses a `ColorSpec` into a color mode and a color function.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
color (ColorSpec): Either a bare `ColorMode` string or a
|
|
102
|
+
tuple of `(ColorMode, colormap)` where `colormap` is a
|
|
103
|
+
`Callable[[int], str]` or a `Mapping[int, str]`.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
tuple[ColorMode, Callable[[int], str]]: The color mode and a
|
|
107
|
+
callable that maps an integer log level to an ANSI color
|
|
108
|
+
escape code string
|
|
109
|
+
"""
|
|
110
|
+
from collections.abc import Mapping
|
|
111
|
+
|
|
112
|
+
if not isinstance(color, tuple):
|
|
113
|
+
return color, get_level_color
|
|
114
|
+
mode, colormap = color
|
|
115
|
+
if isinstance(colormap, Mapping):
|
|
116
|
+
|
|
117
|
+
def _map_fn(level: int) -> str:
|
|
118
|
+
"""
|
|
119
|
+
Returns the custom ANSI color code escape string for
|
|
120
|
+
`level` if `level` is in `colormap`; otherwise,
|
|
121
|
+
returns the default ANSI color code escape string
|
|
122
|
+
for `level` from `get_level_color`.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
level (int): Integer log level
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
str: ANSI escape code string for the given level
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
return colormap[level] # pyright: ignore[reportUnknownVariableType]
|
|
132
|
+
except KeyError:
|
|
133
|
+
return get_level_color(level)
|
|
134
|
+
|
|
135
|
+
return mode, _map_fn
|
|
136
|
+
return mode, colormap
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ColorFormatter(_logging.Formatter):
|
|
140
|
+
"""
|
|
141
|
+
A `logging.Formatter` subclass that applies ANSI color codes
|
|
142
|
+
to log output based on the log record's level.
|
|
143
|
+
|
|
144
|
+
The scope of colorization is controlled by the `color` mode:
|
|
145
|
+
|
|
146
|
+
- `"off"`: no colorization; output is identical to
|
|
147
|
+
`logging.Formatter`
|
|
148
|
+
- `"full"`: the entire formatted line is wrapped in color
|
|
149
|
+
- `"level"`: only the level name is wrapped in color
|
|
150
|
+
- `"msg"`: only the message is wrapped in color
|
|
151
|
+
- `"partial"`: everything except the message is colored
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
def __init__( # ruff: ignore[too-many-arguments]
|
|
155
|
+
self,
|
|
156
|
+
fmt: str | None = None,
|
|
157
|
+
datefmt: str | None = None,
|
|
158
|
+
style: "FormatStyle" = "%",
|
|
159
|
+
*,
|
|
160
|
+
validate: bool = True,
|
|
161
|
+
defaults: "Mapping[str, Any] | None" = None,
|
|
162
|
+
color: "ColorSpec",
|
|
163
|
+
) -> None:
|
|
164
|
+
"""
|
|
165
|
+
A `logging.Formatter` subclass that applies ANSI color codes
|
|
166
|
+
to log output based on the log record's level.
|
|
167
|
+
|
|
168
|
+
The scope of colorization is controlled by the `color` mode:
|
|
169
|
+
|
|
170
|
+
- `"off"`: no colorization; output is identical to
|
|
171
|
+
`logging.Formatter`
|
|
172
|
+
- `"full"`: the entire formatted line is wrapped in color
|
|
173
|
+
- `"level"`: only the level name is wrapped in color
|
|
174
|
+
- `"msg"`: only the message is wrapped in color
|
|
175
|
+
- `"partial"`: everything except the message is colored
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
fmt (str | None, default=None): Format string.
|
|
179
|
+
Defaults to `None`.
|
|
180
|
+
datefmt (str | None, default=None): Date format string.
|
|
181
|
+
Defaults to `None`.
|
|
182
|
+
style (FormatStyle, default="%"): Format style.
|
|
183
|
+
Defaults to `"%"`.
|
|
184
|
+
validate (bool, default=True): If `True`, validates the
|
|
185
|
+
format string. Defaults to `True`.
|
|
186
|
+
defaults (Mapping[str, Any] | None, default=None):
|
|
187
|
+
Default values for string interpolation.
|
|
188
|
+
Defaults to `None`.
|
|
189
|
+
color (ColorSpec): Color mode controlling the scope of
|
|
190
|
+
colorization. Either a bare `ColorMode` string
|
|
191
|
+
(`"off"`, `"full"`, `"level"`, `"msg"`, or
|
|
192
|
+
`"partial"`) or a tuple of `(ColorMode, colormap)`
|
|
193
|
+
where `colormap` is a `Callable[[int], str]` or a
|
|
194
|
+
`Mapping[int, str]` for per-level color overrides.
|
|
195
|
+
""" # noqa: DOC105
|
|
196
|
+
super().__init__(
|
|
197
|
+
fmt=fmt,
|
|
198
|
+
datefmt=datefmt,
|
|
199
|
+
style=style,
|
|
200
|
+
validate=validate,
|
|
201
|
+
defaults=defaults,
|
|
202
|
+
)
|
|
203
|
+
self._color_mode, self._color_fn = _parse_color_spec(color)
|
|
204
|
+
|
|
205
|
+
def format(self, record: "logging.LogRecord") -> str:
|
|
206
|
+
"""
|
|
207
|
+
Formats the log record with ANSI color codes applied
|
|
208
|
+
according to `self._color_mode`.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
record (logging.LogRecord): Log record to format
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
str: Formatted log record string
|
|
215
|
+
"""
|
|
216
|
+
color = self._color_fn(record.levelno)
|
|
217
|
+
reset = _ANSI_RESET
|
|
218
|
+
orig_levelname = record.levelname
|
|
219
|
+
display_levelname = _get_display_levelname(orig_levelname)
|
|
220
|
+
|
|
221
|
+
if self._color_mode == "off":
|
|
222
|
+
record.levelname = display_levelname
|
|
223
|
+
try:
|
|
224
|
+
return super().format(record)
|
|
225
|
+
finally:
|
|
226
|
+
record.levelname = orig_levelname
|
|
227
|
+
|
|
228
|
+
if self._color_mode == "full":
|
|
229
|
+
record.levelname = display_levelname
|
|
230
|
+
try:
|
|
231
|
+
return f"{color}{super().format(record)}{reset}"
|
|
232
|
+
finally:
|
|
233
|
+
record.levelname = orig_levelname
|
|
234
|
+
|
|
235
|
+
if self._color_mode == "level":
|
|
236
|
+
record.levelname = f"{color}{display_levelname}{reset}"
|
|
237
|
+
try:
|
|
238
|
+
return super().format(record)
|
|
239
|
+
finally:
|
|
240
|
+
record.levelname = orig_levelname
|
|
241
|
+
|
|
242
|
+
if self._color_mode == "msg":
|
|
243
|
+
orig_msg = record.msg
|
|
244
|
+
orig_args = record.args
|
|
245
|
+
record.levelname = display_levelname
|
|
246
|
+
record.msg = f"{color}{record.getMessage()}{reset}"
|
|
247
|
+
record.args = None
|
|
248
|
+
try:
|
|
249
|
+
return super().format(record)
|
|
250
|
+
finally:
|
|
251
|
+
record.msg = orig_msg
|
|
252
|
+
record.args = orig_args
|
|
253
|
+
record.levelname = orig_levelname
|
|
254
|
+
|
|
255
|
+
# "partial": color everything except the message
|
|
256
|
+
orig_msg = record.msg
|
|
257
|
+
orig_args = record.args
|
|
258
|
+
record.levelname = display_levelname
|
|
259
|
+
record.msg = f"{reset}{record.getMessage()}{color}"
|
|
260
|
+
record.args = None
|
|
261
|
+
try:
|
|
262
|
+
return f"{color}{super().format(record)}{reset}"
|
|
263
|
+
finally:
|
|
264
|
+
record.msg = orig_msg
|
|
265
|
+
record.args = orig_args
|
|
266
|
+
record.levelname = orig_levelname
|
sefkhet/_constants.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Defines constants used in `sefkhet`."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING as _TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
if _TYPE_CHECKING: # pragma: no cover
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Define fields with existing formatter interpolations
|
|
11
|
+
_KNOWN_FIELDS: frozenset[str] = frozenset(
|
|
12
|
+
{
|
|
13
|
+
"timestamp",
|
|
14
|
+
"level",
|
|
15
|
+
"levelno",
|
|
16
|
+
"message",
|
|
17
|
+
"logger",
|
|
18
|
+
"module",
|
|
19
|
+
"funcName",
|
|
20
|
+
"lineno",
|
|
21
|
+
"pathname",
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Attributes that are standard on every LogRecord and should
|
|
26
|
+
# not be treated as "extra" fields.
|
|
27
|
+
_STANDARD_RECORD_ATTRS: frozenset[str] = frozenset(
|
|
28
|
+
{
|
|
29
|
+
"args",
|
|
30
|
+
"created",
|
|
31
|
+
"exc_info",
|
|
32
|
+
"exc_text",
|
|
33
|
+
"filename",
|
|
34
|
+
"funcName",
|
|
35
|
+
"levelname",
|
|
36
|
+
"levelno",
|
|
37
|
+
"lineno",
|
|
38
|
+
"message",
|
|
39
|
+
"module",
|
|
40
|
+
"msecs",
|
|
41
|
+
"msg",
|
|
42
|
+
"name",
|
|
43
|
+
"pathname",
|
|
44
|
+
"process",
|
|
45
|
+
"processName",
|
|
46
|
+
"relativeCreated",
|
|
47
|
+
"stack_info",
|
|
48
|
+
"taskName",
|
|
49
|
+
"thread",
|
|
50
|
+
"threadName",
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _extract_known_field( # pyright: ignore[reportUnusedFunction]
|
|
56
|
+
field: str, record: "logging.LogRecord", formatter: "logging.Formatter"
|
|
57
|
+
) -> object:
|
|
58
|
+
"""
|
|
59
|
+
Extracts a known field value from a `LogRecord`.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
field (str): The field name to extract
|
|
63
|
+
record (logging.LogRecord): The log record
|
|
64
|
+
formatter (logging.Formatter): The formatter instance,
|
|
65
|
+
used for `formatTime`
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
object: The extracted field value
|
|
69
|
+
"""
|
|
70
|
+
# Handle special time and message cases
|
|
71
|
+
if field == "timestamp":
|
|
72
|
+
return formatter.formatTime(record, formatter.datefmt)
|
|
73
|
+
if field == "message":
|
|
74
|
+
return record.getMessage()
|
|
75
|
+
# All remaining known fields are simple record attributes
|
|
76
|
+
attr_map: dict[str, str] = {
|
|
77
|
+
"level": "levelname",
|
|
78
|
+
"levelno": "levelno",
|
|
79
|
+
"logger": "name",
|
|
80
|
+
"module": "module",
|
|
81
|
+
"funcName": "funcName",
|
|
82
|
+
"lineno": "lineno",
|
|
83
|
+
"pathname": "pathname",
|
|
84
|
+
}
|
|
85
|
+
return getattr(record, attr_map[field])
|
sefkhet/_csv.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""CSV formatting utilities for `sefkhet`."""
|
|
2
|
+
|
|
3
|
+
import logging as _logging
|
|
4
|
+
from typing import TYPE_CHECKING as _TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
if _TYPE_CHECKING: # pragma: no cover
|
|
8
|
+
import logging
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
from sefkhet._typing import CsvSpec, FormatStyle
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Define fields to include for a default CSV formatter
|
|
16
|
+
_DEFAULT_CSV_FIELDS: tuple[str, ...] = (
|
|
17
|
+
"timestamp",
|
|
18
|
+
"level",
|
|
19
|
+
"levelno",
|
|
20
|
+
"message",
|
|
21
|
+
"logger",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parse_csv_spec(
|
|
26
|
+
spec: "Literal[True] | CsvSpec",
|
|
27
|
+
) -> "tuple[tuple[str, ...], str, int, bool]":
|
|
28
|
+
"""
|
|
29
|
+
Returns the result of parsing a CSV spec
|
|
30
|
+
into resolved configuration values.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
spec (Literal[True] | CsvSpec): Either `True` for
|
|
34
|
+
defaults or a `CsvSpec` dict with optional keys
|
|
35
|
+
`fields`, `delimiter`, `quoting`, and `header`.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
tuple[tuple[str, ...], str, int, bool]: A tuple of
|
|
39
|
+
`(fields, delimiter, quoting, header)`
|
|
40
|
+
"""
|
|
41
|
+
import csv
|
|
42
|
+
|
|
43
|
+
# Handle boolean case
|
|
44
|
+
if isinstance(spec, bool):
|
|
45
|
+
return _DEFAULT_CSV_FIELDS, ",", csv.QUOTE_MINIMAL, False
|
|
46
|
+
# Use values from spec if given, otherwise use defaults
|
|
47
|
+
fields = tuple(spec.get("fields", _DEFAULT_CSV_FIELDS))
|
|
48
|
+
delimiter = spec.get("delimiter", ",")
|
|
49
|
+
quoting = spec.get("quoting", csv.QUOTE_MINIMAL)
|
|
50
|
+
header = spec.get("header", False)
|
|
51
|
+
return fields, delimiter, quoting, header
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CsvFormatter(_logging.Formatter):
|
|
55
|
+
"""
|
|
56
|
+
A `logging.Formatter` subclass that
|
|
57
|
+
outputs log records as CSV rows.
|
|
58
|
+
|
|
59
|
+
Each log line is a single CSV row with configurable
|
|
60
|
+
fields. Extra attributes passed via `extra={...}` are
|
|
61
|
+
automatically included. Exception and stack info are
|
|
62
|
+
serialised as strings when present.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__( # ruff: ignore[too-many-arguments]
|
|
66
|
+
self,
|
|
67
|
+
fmt: str | None = None,
|
|
68
|
+
datefmt: str | None = None,
|
|
69
|
+
style: "FormatStyle" = "%",
|
|
70
|
+
*,
|
|
71
|
+
validate: bool = True,
|
|
72
|
+
defaults: "Mapping[str, Any] | None" = None,
|
|
73
|
+
csv: "Literal[True] | CsvSpec",
|
|
74
|
+
) -> None:
|
|
75
|
+
"""
|
|
76
|
+
A `logging.Formatter` subclass that
|
|
77
|
+
outputs log records as CSV rows.
|
|
78
|
+
|
|
79
|
+
Each log line is a single CSV row with configurable
|
|
80
|
+
fields. Extra attributes passed via `extra={...}` are
|
|
81
|
+
automatically included. Exception and stack info are
|
|
82
|
+
serialised as strings when present.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
fmt (str | None, default=None): Format string (unused
|
|
86
|
+
in CSV output but accepted for interface
|
|
87
|
+
compatibility). Defaults to `None`.
|
|
88
|
+
datefmt (str | None, default=None): Date format string
|
|
89
|
+
used by `formatTime`. Defaults to `None`.
|
|
90
|
+
style (FormatStyle, default="%"): Format style.
|
|
91
|
+
Defaults to `"%"`.
|
|
92
|
+
validate (bool, default=True): If `True`, validates
|
|
93
|
+
the format string. Defaults to `True`.
|
|
94
|
+
defaults (Mapping[str, Any] | None, default=None):
|
|
95
|
+
Default values for string interpolation.
|
|
96
|
+
Defaults to `None`.
|
|
97
|
+
csv (Literal[True] | CsvSpec): CSV configuration.
|
|
98
|
+
`True` for defaults, or a `CsvSpec` dict with
|
|
99
|
+
optional keys `fields`, `delimiter`, `quoting`,
|
|
100
|
+
and `header`.
|
|
101
|
+
""" # noqa: DOC105
|
|
102
|
+
super().__init__(
|
|
103
|
+
fmt=fmt,
|
|
104
|
+
datefmt=datefmt,
|
|
105
|
+
style=style,
|
|
106
|
+
validate=validate,
|
|
107
|
+
defaults=defaults,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Parse the CSV-specific options
|
|
111
|
+
(
|
|
112
|
+
self._csv_fields,
|
|
113
|
+
self._csv_delimiter,
|
|
114
|
+
self._csv_quoting,
|
|
115
|
+
self._csv_header,
|
|
116
|
+
) = _parse_csv_spec(csv)
|
|
117
|
+
|
|
118
|
+
# Track if the header has been written
|
|
119
|
+
self._csv_header_written = False
|
|
120
|
+
|
|
121
|
+
def format(self, record: "logging.LogRecord") -> str:
|
|
122
|
+
"""
|
|
123
|
+
Formats the log record as a CSV string.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
record (logging.LogRecord): Log record to format
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
str: CSV-formatted log record string
|
|
130
|
+
"""
|
|
131
|
+
import csv
|
|
132
|
+
import io
|
|
133
|
+
|
|
134
|
+
from sefkhet._constants import (
|
|
135
|
+
_KNOWN_FIELDS,
|
|
136
|
+
_STANDARD_RECORD_ATTRS,
|
|
137
|
+
_extract_known_field,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
values: list[str] = []
|
|
141
|
+
|
|
142
|
+
# Build values from configured fields
|
|
143
|
+
for field in self._csv_fields:
|
|
144
|
+
if field in _KNOWN_FIELDS:
|
|
145
|
+
val = _extract_known_field(field, record, self)
|
|
146
|
+
else:
|
|
147
|
+
val = getattr(record, field, None)
|
|
148
|
+
values.append(str(val))
|
|
149
|
+
|
|
150
|
+
# Handle exc_info
|
|
151
|
+
if record.exc_info and record.exc_info[0] is not None:
|
|
152
|
+
values.append(self.formatException(record.exc_info))
|
|
153
|
+
|
|
154
|
+
# Handle stack_info
|
|
155
|
+
if record.stack_info:
|
|
156
|
+
values.append(self.formatStack(record.stack_info))
|
|
157
|
+
|
|
158
|
+
# Append extra fields
|
|
159
|
+
existing_fields = set(self._csv_fields) | _STANDARD_RECORD_ATTRS
|
|
160
|
+
existing_fields.add("exc_info")
|
|
161
|
+
existing_fields.add("stack_info")
|
|
162
|
+
for k, v in record.__dict__.items():
|
|
163
|
+
if k not in existing_fields:
|
|
164
|
+
values.append(str(v))
|
|
165
|
+
|
|
166
|
+
# Write CSV row
|
|
167
|
+
output = io.StringIO()
|
|
168
|
+
writer = csv.writer(
|
|
169
|
+
output,
|
|
170
|
+
delimiter=self._csv_delimiter,
|
|
171
|
+
quoting=self._csv_quoting, # type: ignore[arg-type] # pyright: ignore[reportArgumentType]
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Prepend header row on first call if requested
|
|
175
|
+
result_lines: list[str] = []
|
|
176
|
+
if self._csv_header and not self._csv_header_written:
|
|
177
|
+
header_fields = list(self._csv_fields)
|
|
178
|
+
# Add extra column names for exc_info/stack_info
|
|
179
|
+
if record.exc_info and record.exc_info[0] is not None:
|
|
180
|
+
header_fields.append("exc_info")
|
|
181
|
+
if record.stack_info:
|
|
182
|
+
header_fields.append("stack_info")
|
|
183
|
+
# Add any extra field columns defined by user
|
|
184
|
+
extras = [k for k in record.__dict__ if k not in existing_fields]
|
|
185
|
+
header_fields.extend(extras)
|
|
186
|
+
# Write header to buffer
|
|
187
|
+
writer.writerow(header_fields)
|
|
188
|
+
# Add header to CSV output
|
|
189
|
+
result_lines.append(output.getvalue().rstrip("\r\n"))
|
|
190
|
+
# Reset the buffer to beginning to prepare for actual log
|
|
191
|
+
output.seek(0)
|
|
192
|
+
output.truncate()
|
|
193
|
+
# Mark header as written
|
|
194
|
+
self._csv_header_written = True
|
|
195
|
+
|
|
196
|
+
# Write logged values to buffer
|
|
197
|
+
writer.writerow(values)
|
|
198
|
+
# Add log row to CSV output
|
|
199
|
+
result_lines.append(output.getvalue().rstrip("\r\n"))
|
|
200
|
+
|
|
201
|
+
# Return CSV output row with possible header above
|
|
202
|
+
return "\n".join(result_lines)
|