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 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)