libsh 0.2.0__tar.gz → 0.3.0__tar.gz

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.3
2
2
  Name: libsh
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Scott Hyndman's reusable common package for Python projects. Provides opinionated structured logging with custom rendering (24-bit colors, relative timestamps, compact levels) and type-safe value formatting utilities (time units, ranges, pretty-printing).
5
5
  Author: shyndman
6
6
  Author-email: shyndman <scotty.hyndman@gmail.com>
@@ -9,5 +9,5 @@ Requires-Dist: pydantic>=2
9
9
  Requires-Dist: rich>=14
10
10
  Requires-Dist: structlog>=25
11
11
  Requires-Python: >=3.12
12
- Project-URL: Bug Tracker, https://github.com/shyndman/libsh-python/issues
13
12
  Project-URL: repository, https://github.com/shyndman/libsh-python
13
+ Project-URL: Bug Tracker, https://github.com/shyndman/libsh-python/issues
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "libsh"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Scott Hyndman's reusable common package for Python projects. Provides opinionated structured logging with custom rendering (24-bit colors, relative timestamps, compact levels) and type-safe value formatting utilities (time units, ranges, pretty-printing)."
5
5
  authors = [{name = "shyndman", email = "scotty.hyndman@gmail.com"}]
6
6
  requires-python = ">=3.12"
@@ -48,3 +48,9 @@ known-first-party = ["libsh"]
48
48
  quote-style = "double"
49
49
  indent-style = "space"
50
50
  line-ending = "auto"
51
+
52
+ [dependency-groups]
53
+ dev = [
54
+ "basedpyright>=1.39.6",
55
+ "pytest>=9.0.3",
56
+ ]
@@ -11,7 +11,7 @@ from .format import (
11
11
  Seconds,
12
12
  Unit,
13
13
  )
14
- from .logs import get_logger, setup_logging, setup_logging_from_env
14
+ from .logging import get_logger, setup_logging, setup_logging_from_env
15
15
 
16
16
  __all__ = [
17
17
  "get_logger",
@@ -0,0 +1,5 @@
1
+ """Centralized logging configuration using structlog."""
2
+
3
+ from .config import get_logger, setup_logging, setup_logging_from_env
4
+
5
+ __all__ = ["get_logger", "setup_logging", "setup_logging_from_env"]
@@ -0,0 +1,190 @@
1
+ import logging
2
+ import os
3
+ from pathlib import Path
4
+ from typing import Final, TextIO, cast
5
+
6
+ import structlog
7
+ from structlog.typing import Processor
8
+
9
+ from .processors import (
10
+ FloatPrecisionProcessor,
11
+ LoggerFilterProcessor,
12
+ compact_level_processor,
13
+ debug_event_colorer,
14
+ relative_time_processor,
15
+ relative_time_processor_plain,
16
+ )
17
+ from .render import build_console_renderer
18
+
19
+
20
+ class _ManagedStreamHandler(logging.StreamHandler[TextIO]):
21
+ """Root console handler installed by libsh; identified by type so repeated
22
+ setup_logging calls replace prior libsh handlers without touching foreign ones."""
23
+
24
+
25
+ class _ManagedFileHandler(logging.FileHandler):
26
+ """Root JSON file handler installed by libsh; identified by type like its
27
+ stream sibling so setup_logging stays idempotent across calls."""
28
+
29
+
30
+ _MANAGED_ROOT_HANDLER_TYPES: Final = (_ManagedStreamHandler, _ManagedFileHandler)
31
+ _JSON_ENV_TRUTHY: Final = frozenset({"true", "1", "yes", "on"})
32
+ _PROPAGATING_LIBRARY_LOGGERS: Final = ("websockets",)
33
+
34
+
35
+ def setup_logging(
36
+ level: str = "INFO",
37
+ json_output: bool = False,
38
+ correlation_id: str | None = None,
39
+ filter_to_logger: str | None = None,
40
+ log_file: Path | None = None,
41
+ ) -> None:
42
+ """Configure structured logging for the application."""
43
+ processors: list[Processor] = []
44
+ _ = structlog.contextvars.unbind_contextvars("correlation_id")
45
+ if correlation_id:
46
+ processors.append(structlog.contextvars.merge_contextvars)
47
+ _ = structlog.contextvars.bind_contextvars(correlation_id=correlation_id)
48
+
49
+ processors.append(structlog.stdlib.filter_by_level)
50
+ if filter_to_logger:
51
+ processors.append(LoggerFilterProcessor(filter_to_logger))
52
+
53
+ processors.extend(
54
+ [
55
+ structlog.stdlib.add_logger_name,
56
+ structlog.stdlib.add_log_level,
57
+ ]
58
+ )
59
+
60
+ if json_output:
61
+ processors.extend(
62
+ [
63
+ structlog.stdlib.PositionalArgumentsFormatter(),
64
+ structlog.stdlib.ExtraAdder(),
65
+ FloatPrecisionProcessor(digits=3),
66
+ relative_time_processor_plain,
67
+ structlog.processors.StackInfoRenderer(),
68
+ ]
69
+ )
70
+ else:
71
+ processors.extend(
72
+ [
73
+ debug_event_colorer,
74
+ compact_level_processor,
75
+ structlog.stdlib.PositionalArgumentsFormatter(),
76
+ structlog.stdlib.ExtraAdder(),
77
+ FloatPrecisionProcessor(digits=3),
78
+ relative_time_processor,
79
+ structlog.processors.StackInfoRenderer(),
80
+ ]
81
+ )
82
+
83
+ log_renderer: Processor
84
+ if json_output:
85
+ log_renderer = structlog.processors.JSONRenderer()
86
+ else:
87
+ log_renderer = build_console_renderer()
88
+
89
+ formatter_pre_chain: list[Processor] = [
90
+ proc
91
+ for proc in processors
92
+ if proc is not structlog.stdlib.filter_by_level and not isinstance(proc, LoggerFilterProcessor)
93
+ ]
94
+
95
+ structlog.configure(
96
+ processors=processors + [structlog.stdlib.ProcessorFormatter.wrap_for_formatter],
97
+ wrapper_class=structlog.stdlib.BoundLogger,
98
+ logger_factory=structlog.stdlib.LoggerFactory(),
99
+ cache_logger_on_first_use=True,
100
+ )
101
+
102
+ formatter = structlog.stdlib.ProcessorFormatter(
103
+ foreign_pre_chain=formatter_pre_chain,
104
+ processors=[
105
+ structlog.stdlib.ProcessorFormatter.remove_processors_meta,
106
+ log_renderer,
107
+ ],
108
+ )
109
+
110
+ # A file sink always renders JSON regardless of console format, so persisted
111
+ # logs stay machine-parseable. It shares the same pre-chain, so structured
112
+ # fields (including any ANSI baked into the console pipeline) are serialized
113
+ # verbatim and JSON-escaped.
114
+ file_formatter: logging.Formatter | None = None
115
+ if log_file is not None:
116
+ file_formatter = structlog.stdlib.ProcessorFormatter(
117
+ foreign_pre_chain=formatter_pre_chain,
118
+ processors=[
119
+ structlog.stdlib.ProcessorFormatter.remove_processors_meta,
120
+ structlog.processors.JSONRenderer(),
121
+ ],
122
+ )
123
+
124
+ root_logger = logging.getLogger()
125
+ _replace_managed_root_handler(
126
+ root_logger, formatter, level, filter_to_logger, log_file, file_formatter
127
+ )
128
+ _configure_library_loggers()
129
+
130
+
131
+ def get_logger(
132
+ name: str | None = None, *args: object, **initial_values: object
133
+ ) -> structlog.stdlib.BoundLogger:
134
+ """Get a structured logger instance."""
135
+ if name is None:
136
+ return cast(structlog.stdlib.BoundLogger, structlog.get_logger(*args, **initial_values))
137
+
138
+ return cast(
139
+ structlog.stdlib.BoundLogger,
140
+ structlog.get_logger(name, *args, **initial_values),
141
+ )
142
+
143
+
144
+ def setup_logging_from_env() -> None:
145
+ """Setup logging using environment variables."""
146
+ log_level = os.getenv("LOG_LEVEL", "INFO").upper()
147
+ json_output = os.getenv("JSON_LOGS", "false").lower() in _JSON_ENV_TRUTHY
148
+ correlation_id = os.getenv("CORRELATION_ID")
149
+
150
+ setup_logging(level=log_level, json_output=json_output, correlation_id=correlation_id)
151
+
152
+
153
+ def _replace_managed_root_handler(
154
+ root_logger: logging.Logger,
155
+ formatter: logging.Formatter,
156
+ level: str,
157
+ filter_to_logger: str | None,
158
+ log_file: Path | None,
159
+ file_formatter: logging.Formatter | None,
160
+ ) -> None:
161
+ managed_handlers = [
162
+ handler for handler in root_logger.handlers if isinstance(handler, _MANAGED_ROOT_HANDLER_TYPES)
163
+ ]
164
+ for handler in managed_handlers:
165
+ root_logger.removeHandler(handler)
166
+ handler.close()
167
+
168
+ stream_handler = _ManagedStreamHandler()
169
+ stream_handler.setFormatter(formatter)
170
+ if filter_to_logger:
171
+ stream_handler.addFilter(logging.Filter(filter_to_logger))
172
+ root_logger.addHandler(stream_handler)
173
+
174
+ if log_file is not None and file_formatter is not None:
175
+ log_file.parent.mkdir(parents=True, exist_ok=True)
176
+ file_handler = _ManagedFileHandler(log_file, encoding="utf-8")
177
+ file_handler.setFormatter(file_formatter)
178
+ if filter_to_logger:
179
+ file_handler.addFilter(logging.Filter(filter_to_logger))
180
+ root_logger.addHandler(file_handler)
181
+
182
+ root_logger.setLevel(level)
183
+
184
+
185
+ def _configure_library_loggers() -> None:
186
+ for liblog_name in _PROPAGATING_LIBRARY_LOGGERS:
187
+ liblog = logging.getLogger(liblog_name)
188
+ liblog.handlers.clear()
189
+ liblog.setLevel(logging.WARNING)
190
+ liblog.propagate = True
@@ -0,0 +1,191 @@
1
+ import time
2
+ from typing import Any, Final, Protocol, runtime_checkable
3
+
4
+ import numpy as np
5
+ import structlog
6
+ from structlog import DropEvent
7
+ from structlog.typing import EventDict, WrappedLogger
8
+
9
+ from .render import hex_to_ansi_fg
10
+
11
+ _PROGRAM_START_TIME = time.time()
12
+ _LEVEL_STYLES: Final = {
13
+ "debug": (0x908CAA, 0x827E99, "dbug"),
14
+ "info": (0x9CCFD8, 0x8CBAC2, "info"),
15
+ "warning": (0xF6C177, 0xDDAE6B, "warn"),
16
+ "error": (0xEB6F92, 0xD46483, "eror"),
17
+ "exception": (0xEB6F92, 0xD46483, "exc!"),
18
+ }
19
+
20
+ __all__ = [
21
+ "FloatPrecisionProcessor",
22
+ "LoggerFilterProcessor",
23
+ "compact_level_processor",
24
+ "debug_event_colorer",
25
+ "relative_time_processor",
26
+ "relative_time_processor_plain",
27
+ ]
28
+
29
+
30
+ class FloatPrecisionProcessor:
31
+ digits: int
32
+ np_array_to_list: bool
33
+ only_fields: set[str]
34
+ not_fields: set[str]
35
+
36
+ def __init__(
37
+ self,
38
+ digits: int = 3,
39
+ only_fields: set[str] | None = None,
40
+ not_fields: set[str] | None = None,
41
+ np_array_to_list: bool = True,
42
+ ):
43
+ self.digits = digits
44
+ self.np_array_to_list = np_array_to_list
45
+ self.only_fields = set[str]() if only_fields is None else only_fields
46
+ self.not_fields = set[str]() if not_fields is None else not_fields
47
+
48
+ def _round(self, value: Any) -> Any:
49
+ if isinstance(value, float):
50
+ return round(value, self.digits)
51
+ if self.np_array_to_list and isinstance(value, np.ndarray):
52
+ return self._round(list(value))
53
+ if isinstance(value, list):
54
+ for idx, item in enumerate(value):
55
+ value[idx] = self._round(item)
56
+ return value
57
+ if isinstance(value, dict):
58
+ for key, nested_value in value.items():
59
+ value[key] = self._round(nested_value)
60
+ return value
61
+ return value
62
+
63
+ def __call__(self, _logger: WrappedLogger, _method_name: str, event_dict: EventDict) -> EventDict:
64
+ for key, value in event_dict.items():
65
+ if self.only_fields and key not in self.only_fields:
66
+ continue
67
+ if self.not_fields and key in self.not_fields:
68
+ continue
69
+ if isinstance(value, bool):
70
+ continue
71
+ event_dict[key] = self._round(value)
72
+ return event_dict
73
+
74
+
75
+ @runtime_checkable
76
+ class NamedLogger(Protocol):
77
+ name: str
78
+
79
+
80
+ def _logger_name_from_event(logger: object, event_dict: EventDict) -> str:
81
+ if isinstance(logger, NamedLogger):
82
+ return logger.name
83
+
84
+ record = event_dict.get("_record")
85
+ if isinstance(record, NamedLogger):
86
+ return record.name
87
+
88
+ event_logger_name = event_dict.get("logger_name") or event_dict.get("logger")
89
+ return event_logger_name if isinstance(event_logger_name, str) else ""
90
+
91
+
92
+ class LoggerFilterProcessor:
93
+ logger_name: str
94
+ _child_logger_prefix: str
95
+
96
+ def __init__(self, logger_name: str):
97
+ self.logger_name = logger_name
98
+ self._child_logger_prefix = f"{logger_name}."
99
+
100
+ def __call__(self, logger: object, _method_name: str, event_dict: EventDict) -> EventDict:
101
+ logger_name = _logger_name_from_event(logger, event_dict)
102
+ if logger_name == self.logger_name or logger_name.startswith(self._child_logger_prefix):
103
+ return event_dict
104
+ raise DropEvent
105
+
106
+
107
+ def relative_time_processor(
108
+ _logger: structlog.stdlib.BoundLogger, _method_name: str, event_dict: EventDict
109
+ ) -> EventDict:
110
+ """Add relative timestamp since program start with hours:minutes:seconds.milliseconds format."""
111
+ hours, minutes, seconds = _relative_time_parts()
112
+
113
+ gray = "\x1b[2m"
114
+ dark_gray = "\x1b[90m"
115
+ reset = "\x1b[0m"
116
+
117
+ separator = f"{gray}:{reset}"
118
+ hours_str = f"{gray}{hours:02d}{reset}{separator}" if hours != 0 else ""
119
+ minutes_str = f"{gray}{minutes:02d}{reset}{separator}" if minutes != 0 and hours != 0 else ""
120
+ seconds_str = f"{gray}{seconds:06.3f}{reset}"
121
+
122
+ event_dict["timestamp"] = f"{dark_gray}+{reset}{hours_str}{minutes_str}{seconds_str}"
123
+ return event_dict
124
+
125
+
126
+ def relative_time_processor_plain(
127
+ _logger: structlog.stdlib.BoundLogger, _method_name: str, event_dict: EventDict
128
+ ) -> EventDict:
129
+ """Add relative timestamp without ANSI styling for machine-consumable outputs."""
130
+ hours, minutes, seconds = _relative_time_parts()
131
+
132
+ separator = ":"
133
+ hours_str = f"{hours:02d}{separator}" if hours != 0 else ""
134
+ minutes_str = f"{minutes:02d}{separator}" if minutes != 0 and hours != 0 else ""
135
+ seconds_str = f"{seconds:06.3f}"
136
+
137
+ event_dict["timestamp"] = f"+{hours_str}{minutes_str}{seconds_str}"
138
+ return event_dict
139
+
140
+
141
+ def compact_level_processor(
142
+ _logger: structlog.stdlib.BoundLogger, _method_name: str, event_dict: EventDict
143
+ ) -> EventDict:
144
+ """Convert log levels to compact 4-character format with 24-bit colors and darker brackets."""
145
+ reset = "\x1b[0m"
146
+
147
+ original_level = event_dict.get("level")
148
+ if not isinstance(original_level, str):
149
+ return event_dict
150
+
151
+ if original_level == "critical":
152
+ event_dict["level"] = (
153
+ f"{hex_to_ansi_fg(0xD46483)}[{reset}"
154
+ f"\x1b[48;2;235;111;146;38;2;33;32;46mcrit{reset}"
155
+ f"{hex_to_ansi_fg(0xD46483)}]{reset}"
156
+ )
157
+ return event_dict
158
+
159
+ style = _LEVEL_STYLES.get(original_level)
160
+ if style is None:
161
+ return event_dict
162
+
163
+ text_color, bracket_color, text = style
164
+ event_dict["level"] = (
165
+ f"{hex_to_ansi_fg(bracket_color)}[{reset}"
166
+ f"{hex_to_ansi_fg(text_color)}{text}{reset}"
167
+ f"{hex_to_ansi_fg(bracket_color)}]{reset}"
168
+ )
169
+ return event_dict
170
+
171
+
172
+ def debug_event_colorer(
173
+ _logger: structlog.stdlib.BoundLogger, _method_name: str, event_dict: EventDict
174
+ ) -> EventDict:
175
+ """Color the event text purple for debug level messages."""
176
+ level = event_dict.get("level")
177
+ if isinstance(level, str) and "debug" in level.lower() and "event" in event_dict:
178
+ debug_color = hex_to_ansi_fg(0x908CAA)
179
+ event = event_dict.get("event")
180
+ event_dict["event"] = f"{debug_color}{event}\x1b[0m"
181
+
182
+ return event_dict
183
+
184
+
185
+ def _relative_time_parts() -> tuple[int, int, float]:
186
+ """Return elapsed (hours, minutes, seconds) since program start."""
187
+ elapsed = time.time() - _PROGRAM_START_TIME
188
+ hours = int(elapsed // 3600)
189
+ minutes = int((elapsed % 3600) // 60)
190
+ seconds = elapsed % 60
191
+ return hours, minutes, seconds
@@ -0,0 +1,158 @@
1
+ import re
2
+ from collections.abc import Callable
3
+ from dataclasses import dataclass, field
4
+ from io import StringIO
5
+
6
+ from structlog.dev import BRIGHT, DIM, RESET_ALL, Column, ConsoleRenderer, KeyValueColumnFormatter
7
+
8
+
9
+ def _write(sio: StringIO, value: str) -> None:
10
+ _ = sio.write(value)
11
+
12
+
13
+ def _pad_value(value: str, width: int) -> str:
14
+ if width <= 0:
15
+ return value
16
+
17
+ return value.ljust(width)
18
+
19
+
20
+ def hex_to_ansi_fg(hex_color: int) -> str:
21
+ """Convert hex color (e.g., 0xad8a89) to ANSI 24-bit foreground escape code."""
22
+ r = (hex_color >> 16) & 0xFF
23
+ g = (hex_color >> 8) & 0xFF
24
+ b = hex_color & 0xFF
25
+ return f"\x1b[38;2;{r};{g};{b}m"
26
+
27
+
28
+ @dataclass
29
+ class RegexValueColumnFormatter:
30
+ """
31
+ Format a key-value pair with regex-based value styling.
32
+
33
+ Like KeyValueColumnFormatter, but allows mapping values to styles based on
34
+ regular expression patterns.
35
+
36
+ :param key_style: The style to apply to the key. If None, the key is omitted.
37
+ :param value_style_map: A list of (regex_pattern, style) tuples. The first
38
+ pattern that matches the value will determine its style.
39
+ :param default_value_style: The style to use if no regex patterns match.
40
+ :param reset_style: The style to apply whenever a style is no longer needed.
41
+ :param value_repr: A callable that returns the string representation of the value.
42
+ :param width: The width to pad the value to. If 0, no padding is done.
43
+ :param prefix: A string to prepend to the formatted key-value pair. May contain
44
+ styles.
45
+ :param postfix: A string to append to the formatted key-value pair. May contain
46
+ styles.
47
+ """
48
+
49
+ key_style: str | None
50
+ value_style_map: list[tuple[str, str]]
51
+ default_value_style: str
52
+ reset_style: str
53
+ value_repr: Callable[[object], str]
54
+ width: int = 0
55
+ prefix: str = ""
56
+ postfix: str = ""
57
+ _compiled_patterns: list[tuple[re.Pattern[str], str]] = field(init=False, repr=False)
58
+
59
+ def __post_init__(self) -> None:
60
+ """Compile regex patterns for efficiency."""
61
+ self._compiled_patterns = [
62
+ (re.compile(pattern), style) for pattern, style in self.value_style_map
63
+ ]
64
+
65
+ def __call__(self, key: str, value: object) -> str:
66
+ sio = StringIO()
67
+
68
+ if self.prefix:
69
+ _write(sio, self.prefix)
70
+ _write(sio, self.reset_style)
71
+
72
+ if self.key_style is not None:
73
+ _write(sio, self.key_style)
74
+ _write(sio, key)
75
+ _write(sio, self.reset_style)
76
+ _write(sio, "=")
77
+
78
+ value_str = self.value_repr(value)
79
+ value_style = self.default_value_style
80
+
81
+ for pattern, style in self._compiled_patterns:
82
+ if pattern.search(value_str):
83
+ value_style = style
84
+ break
85
+
86
+ _write(sio, value_style)
87
+ _write(sio, _pad_value(value_str, self.width))
88
+ _write(sio, self.reset_style)
89
+
90
+ if self.postfix:
91
+ _write(sio, self.postfix)
92
+ _write(sio, self.reset_style)
93
+
94
+ return sio.getvalue()
95
+
96
+
97
+ def build_console_renderer() -> ConsoleRenderer:
98
+ event_key = "event"
99
+ timestamp_key = "timestamp"
100
+ logger_name_formatter = KeyValueColumnFormatter(
101
+ key_style=None,
102
+ value_style=hex_to_ansi_fg(0x7D6B95),
103
+ reset_style=RESET_ALL,
104
+ value_repr=str,
105
+ prefix="[",
106
+ postfix="]",
107
+ )
108
+
109
+ return ConsoleRenderer(
110
+ colors=True,
111
+ columns=[
112
+ Column(
113
+ "",
114
+ RegexValueColumnFormatter(
115
+ key_style=hex_to_ansi_fg(0x6E6A86),
116
+ value_style_map=[
117
+ (r"^(True|False)$", hex_to_ansi_fg(0x6E6A86)),
118
+ (r"^-?\d+$", hex_to_ansi_fg(0xF6C177)),
119
+ (r"^-?\d*\.\d+$", hex_to_ansi_fg(0xF6C177)),
120
+ (r"^-?\d*\.?\d+(?:h|m|s|ms|us|µs)$", hex_to_ansi_fg(0x9CCFD8)),
121
+ ],
122
+ default_value_style="",
123
+ reset_style=RESET_ALL,
124
+ value_repr=str,
125
+ ),
126
+ ),
127
+ Column(
128
+ timestamp_key,
129
+ KeyValueColumnFormatter(
130
+ key_style=None,
131
+ value_style=DIM,
132
+ reset_style=RESET_ALL,
133
+ value_repr=str,
134
+ ),
135
+ ),
136
+ Column(
137
+ "level",
138
+ KeyValueColumnFormatter(
139
+ key_style=None,
140
+ value_style="",
141
+ reset_style=RESET_ALL,
142
+ value_repr=str,
143
+ ),
144
+ ),
145
+ Column("logger_name", logger_name_formatter),
146
+ Column("logger", logger_name_formatter),
147
+ Column(
148
+ event_key,
149
+ KeyValueColumnFormatter(
150
+ key_style=None,
151
+ value_style=BRIGHT,
152
+ reset_style=RESET_ALL,
153
+ value_repr=str,
154
+ width=30,
155
+ ),
156
+ ),
157
+ ],
158
+ )
@@ -0,0 +1,5 @@
1
+ """Compatibility wrapper for centralized logging configuration."""
2
+
3
+ from .logging import get_logger, setup_logging, setup_logging_from_env
4
+
5
+ __all__ = ["get_logger", "setup_logging", "setup_logging_from_env"]
@@ -0,0 +1,6 @@
1
+ from libsh.logging.processors import FloatPrecisionProcessor, LoggerFilterProcessor
2
+
3
+ __all__ = [
4
+ "FloatPrecisionProcessor",
5
+ "LoggerFilterProcessor",
6
+ ]
@@ -0,0 +1,5 @@
1
+ """Compatibility wrapper for the float precision logging processor."""
2
+
3
+ from ..logging.processors import FloatPrecisionProcessor
4
+
5
+ __all__ = ["FloatPrecisionProcessor"]
@@ -0,0 +1,5 @@
1
+ """Compatibility wrapper for the logger-name filter processor."""
2
+
3
+ from ..logging.processors import LoggerFilterProcessor
4
+
5
+ __all__ = ["LoggerFilterProcessor"]
@@ -1,430 +0,0 @@
1
- """Centralized logging configuration using structlog."""
2
-
3
- import logging
4
- import os
5
- import re
6
- import time
7
- from collections.abc import Callable
8
- from dataclasses import dataclass
9
- from io import StringIO
10
- from typing import Any
11
-
12
- import structlog
13
- from structlog.dev import (
14
- BLUE,
15
- BRIGHT,
16
- CYAN,
17
- DIM,
18
- GREEN,
19
- MAGENTA,
20
- RED,
21
- RED_BACK,
22
- RESET_ALL,
23
- YELLOW,
24
- Column,
25
- ConsoleRenderer,
26
- KeyValueColumnFormatter,
27
- _pad,
28
- )
29
- from structlog.typing import EventDict, Processor
30
-
31
- from .proc import FloatPrecisionProcessor, LoggerFilterProcessor
32
-
33
- # Store program start time for relative timestamps
34
- _PROGRAM_START_TIME = time.time()
35
-
36
-
37
- def hex_to_ansi_fg(hex_color: int) -> str:
38
- """Convert hex color (e.g., 0xad8a89) to ANSI 24-bit foreground escape code."""
39
- r = (hex_color >> 16) & 0xFF
40
- g = (hex_color >> 8) & 0xFF
41
- b = hex_color & 0xFF
42
- return f"\x1b[38;2;{r};{g};{b}m"
43
-
44
-
45
- @dataclass
46
- class RegexValueColumnFormatter:
47
- """
48
- Format a key-value pair with regex-based value styling.
49
-
50
- Like KeyValueColumnFormatter, but allows mapping values to styles based on
51
- regular expression patterns.
52
-
53
- :param key_style: The style to apply to the key. If None, the key is omitted.
54
- :param value_style_map: A list of (regex_pattern, style) tuples. The first
55
- pattern that matches the value will determine its style.
56
- :param default_value_style: The style to use if no regex patterns match.
57
- :param reset_style: The style to apply whenever a style is no longer needed.
58
- :param value_repr: A callable that returns the string representation of the value.
59
- :param width: The width to pad the value to. If 0, no padding is done.
60
- :param prefix: A string to prepend to the formatted key-value pair. May contain
61
- styles.
62
- :param postfix: A string to append to the formatted key-value pair. May contain
63
- styles.
64
- """
65
-
66
- key_style: str | None
67
- value_style_map: list[tuple[str, str]]
68
- default_value_style: str
69
- reset_style: str
70
- value_repr: Callable[[object], str]
71
- width: int = 0
72
- prefix: str = ""
73
- postfix: str = ""
74
-
75
- def __post_init__(self) -> None:
76
- """Compile regex patterns for efficiency."""
77
- self._compiled_patterns = [
78
- (re.compile(pattern), style) for pattern, style in self.value_style_map
79
- ]
80
-
81
- def __call__(self, key: str, value: object) -> str:
82
- sio = StringIO()
83
-
84
- if self.prefix:
85
- sio.write(self.prefix)
86
- sio.write(self.reset_style)
87
-
88
- if self.key_style is not None:
89
- sio.write(self.key_style)
90
- sio.write(key)
91
- sio.write(self.reset_style)
92
- sio.write("=")
93
-
94
- # Determine value style based on regex matching
95
- value_str = self.value_repr(value)
96
- value_style = self.default_value_style
97
-
98
- for pattern, style in self._compiled_patterns:
99
- if pattern.search(value_str):
100
- value_style = style
101
- break
102
-
103
- sio.write(value_style)
104
- sio.write(_pad(value_str, self.width))
105
- sio.write(self.reset_style)
106
-
107
- if self.postfix:
108
- sio.write(self.postfix)
109
- sio.write(self.reset_style)
110
-
111
- return sio.getvalue()
112
-
113
-
114
- class NerdStyles:
115
- reset = RESET_ALL
116
- bright = BRIGHT
117
-
118
- level_critical = RED
119
- level_exception = RED
120
- level_error = RED
121
- level_warn = YELLOW
122
- level_info = GREEN
123
- level_debug = GREEN
124
- level_notset = RED_BACK
125
-
126
- timestamp = DIM
127
- logger_name = BLUE
128
- kv_key = CYAN
129
- kv_value = MAGENTA
130
-
131
-
132
- def _relative_time_processor(
133
- _logger: structlog.stdlib.BoundLogger, _method_name: str, event_dict: EventDict
134
- ) -> EventDict:
135
- """Add relative timestamp since program start with hours:minutes:seconds.milliseconds format."""
136
- hours, minutes, seconds = _relative_time_parts()
137
-
138
- # ANSI color codes: \x1b[2m for dim/gray, \x1b[90m for darker gray, \x1b[0m to reset
139
- gray = "\x1b[2m" # Normal gray (like original timestamp)
140
- dark_gray = "\x1b[90m" # Darker gray for zeros
141
- reset = "\x1b[0m"
142
-
143
- separator = f"{gray}:{reset}"
144
-
145
- # Format hours with darker gray if zero, normal gray otherwise
146
- hours_str = f"{gray}{hours:02d}{reset}{separator}" if hours != 0 else ""
147
-
148
- # Format minutes with darker gray if zero, normal gray otherwise
149
- minutes_str = f"{gray}{minutes:02d}{reset}{separator}" if minutes != 0 and hours != 0 else ""
150
-
151
- # Always show seconds in normal gray
152
- seconds_str = f"{gray}{seconds:06.3f}{reset}"
153
-
154
- # Colons in normal gray too
155
- time_str = f"{dark_gray}+{reset}{hours_str}{minutes_str}{seconds_str}"
156
-
157
- event_dict["timestamp"] = time_str
158
- return event_dict
159
-
160
-
161
- def _relative_time_processor_plain(
162
- _logger: structlog.stdlib.BoundLogger, _method_name: str, event_dict: EventDict
163
- ) -> EventDict:
164
- """Add relative timestamp without ANSI styling for machine-consumable outputs."""
165
- hours, minutes, seconds = _relative_time_parts()
166
-
167
- separator = ":"
168
- hours_str = f"{hours:02d}{separator}" if hours != 0 else ""
169
- minutes_str = f"{minutes:02d}{separator}" if minutes != 0 and hours != 0 else ""
170
- seconds_str = f"{seconds:06.3f}"
171
-
172
- event_dict["timestamp"] = f"+{hours_str}{minutes_str}{seconds_str}"
173
- return event_dict
174
-
175
-
176
- def _relative_time_parts() -> tuple[int, int, float]:
177
- """Return elapsed (hours, minutes, seconds) since program start."""
178
- elapsed = time.time() - _PROGRAM_START_TIME
179
- hours = int(elapsed // 3600)
180
- minutes = int((elapsed % 3600) // 60)
181
- seconds = elapsed % 60
182
- return hours, minutes, seconds
183
-
184
-
185
- def _compact_level_processor(
186
- _logger: structlog.stdlib.BoundLogger, _method_name: str, event_dict: EventDict
187
- ) -> EventDict:
188
- """Convert log levels to compact 4-character format with 24-bit colors and darker brackets."""
189
- # 24-bit color codes: \x1b[38;2;R;G;Bm for foreground, \x1b[48;2;R;G;Bm for background
190
- reset = "\x1b[0m"
191
-
192
- # Original colors and their 10% darkened versions for brackets
193
- level_mapping = {
194
- "debug": {
195
- "text": f"{hex_to_ansi_fg(0x908CAA)}dbug{reset}", # fg #908caa
196
- "bracket": hex_to_ansi_fg(0x827E99), # 10% darker
197
- },
198
- "info": {
199
- "text": f"{hex_to_ansi_fg(0x9CCFD8)}info{reset}", # fg #9ccfd8
200
- "bracket": hex_to_ansi_fg(0x8CBAC2), # 10% darker
201
- },
202
- "warning": {
203
- "text": f"{hex_to_ansi_fg(0xF6C177)}warn{reset}", # fg #f6c177
204
- "bracket": hex_to_ansi_fg(0xDDAE6B), # 10% darker
205
- },
206
- "error": {
207
- "text": f"{hex_to_ansi_fg(0xEB6F92)}eror{reset}", # fg #eb6f92
208
- "bracket": hex_to_ansi_fg(0xD46483), # 10% darker
209
- },
210
- "exception": {
211
- "text": f"{hex_to_ansi_fg(0xEB6F92)}exc!{reset}", # fg #eb6f92
212
- "bracket": hex_to_ansi_fg(0xD46483), # 10% darker
213
- },
214
- "critical": {
215
- "text": f"\x1b[48;2;235;111;146;38;2;33;32;46mcrit{reset}", # bg #eb6f92, fg #21202e
216
- "bracket": hex_to_ansi_fg(0xD46483), # 10% darker
217
- },
218
- }
219
-
220
- if "level" in event_dict:
221
- original_level = event_dict["level"]
222
- if original_level in level_mapping:
223
- colors = level_mapping[original_level]
224
- event_dict["level"] = (
225
- f"{colors['bracket']}[{reset}{colors['text']}{colors['bracket']}]{reset}"
226
- )
227
- else:
228
- event_dict["level"] = original_level
229
-
230
- return event_dict
231
-
232
-
233
- def _debug_event_colorer(
234
- _logger: structlog.stdlib.BoundLogger, _method_name: str, event_dict: EventDict
235
- ) -> EventDict:
236
- """Color the event text purple for debug level messages."""
237
- # Check both the original level and processed level
238
- level = event_dict.get("level", "")
239
- if ("debug" in str(level).lower() or level == "debug") and "event" in event_dict:
240
- # Color debug event text with #908caa
241
- debug_color = hex_to_ansi_fg(0x908CAA)
242
- reset = "\x1b[0m"
243
- event_dict["event"] = f"{debug_color}{event_dict['event']}{reset}"
244
-
245
- return event_dict
246
-
247
-
248
- def setup_logging(
249
- level: str = "INFO",
250
- json_output: bool = False,
251
- correlation_id: str | None = None,
252
- filter_to_logger: str | None = None,
253
- ) -> None:
254
- """Configure structured logging for the application."""
255
- filters = [structlog.stdlib.filter_by_level]
256
- if filter_to_logger:
257
- filters += [LoggerFilterProcessor(filter_to_logger)]
258
-
259
- # Configure processors
260
- processors: list[Processor] = []
261
-
262
- # Add correlation ID if provided
263
- if correlation_id:
264
- processors.append(structlog.contextvars.merge_contextvars)
265
- structlog.contextvars.bind_contextvars(correlation_id=correlation_id)
266
-
267
- # Level filtering should happen before any expensive processors run
268
- processors.extend(filters)
269
-
270
- processors += [
271
- structlog.stdlib.add_logger_name,
272
- structlog.stdlib.add_log_level,
273
- ]
274
-
275
- if json_output:
276
- processors += [
277
- structlog.stdlib.PositionalArgumentsFormatter(),
278
- structlog.stdlib.ExtraAdder(),
279
- FloatPrecisionProcessor(digits=3),
280
- _relative_time_processor_plain,
281
- structlog.processors.StackInfoRenderer(),
282
- # structlog.processors.format_exc_info,
283
- ]
284
- else:
285
- processors += [
286
- _debug_event_colorer, # Run before level processing
287
- _compact_level_processor,
288
- structlog.stdlib.PositionalArgumentsFormatter(),
289
- structlog.stdlib.ExtraAdder(),
290
- FloatPrecisionProcessor(digits=3),
291
- _relative_time_processor,
292
- structlog.processors.StackInfoRenderer(),
293
- # structlog.processors.format_exc_info,
294
- ]
295
-
296
- # Configure output format
297
- if json_output:
298
- log_renderer = structlog.processors.JSONRenderer()
299
- else:
300
- event_key: str = "event"
301
- timestamp_key: str = "timestamp"
302
- logger_name_formatter = KeyValueColumnFormatter(
303
- key_style=None,
304
- value_style=hex_to_ansi_fg(0x7D6B95),
305
- reset_style=RESET_ALL,
306
- value_repr=str,
307
- prefix="[",
308
- postfix="]",
309
- )
310
-
311
- log_renderer = ConsoleRenderer(
312
- colors=True,
313
- columns=[
314
- # Default formatter
315
- Column(
316
- "",
317
- RegexValueColumnFormatter(
318
- key_style=hex_to_ansi_fg(0x6E6A86),
319
- value_style_map=[
320
- (
321
- # Integers (including negative) get bright green styling
322
- r"^(True|False)$",
323
- hex_to_ansi_fg(0x6E6A86),
324
- ),
325
- (
326
- # Integers (including negative) get bright green styling
327
- r"^-?\d+$",
328
- hex_to_ansi_fg(0xF6C177),
329
- ),
330
- (
331
- # Floats (including negative) get bright yellow styling
332
- r"^-?\d*\.\d+$",
333
- hex_to_ansi_fg(0xF6C177),
334
- ),
335
- (
336
- # Numeric durations (123ms, 45.67s, 12h, etc.)
337
- r"^-?\d*\.?\d+(?:h|m|s|ms|us|µs)$",
338
- hex_to_ansi_fg(0x9CCFD8),
339
- ),
340
- ],
341
- default_value_style="",
342
- reset_style=RESET_ALL,
343
- value_repr=str,
344
- ),
345
- ),
346
- Column(
347
- timestamp_key,
348
- KeyValueColumnFormatter(
349
- key_style=None,
350
- value_style=DIM,
351
- reset_style=RESET_ALL,
352
- value_repr=str,
353
- ),
354
- ),
355
- Column(
356
- "level",
357
- KeyValueColumnFormatter(
358
- key_style=None,
359
- value_style="",
360
- reset_style=RESET_ALL,
361
- value_repr=str,
362
- ),
363
- ),
364
- Column("logger_name", logger_name_formatter),
365
- Column("logger", logger_name_formatter),
366
- Column(
367
- event_key,
368
- KeyValueColumnFormatter(
369
- key_style=None,
370
- value_style=BRIGHT,
371
- reset_style=RESET_ALL,
372
- value_repr=str,
373
- width=30,
374
- ),
375
- ),
376
- ],
377
- )
378
-
379
- # Omit level filters when processing stdlib log records to avoid double filtering
380
- formatter_pre_chain: list[Processor] = [proc for proc in processors if proc not in filters]
381
-
382
- # Configure structlog
383
- structlog.configure(
384
- processors=processors + [structlog.stdlib.ProcessorFormatter.wrap_for_formatter],
385
- wrapper_class=structlog.stdlib.BoundLogger,
386
- logger_factory=structlog.stdlib.LoggerFactory(),
387
- cache_logger_on_first_use=True,
388
- )
389
-
390
- formatter = structlog.stdlib.ProcessorFormatter(
391
- foreign_pre_chain=formatter_pre_chain,
392
- processors=[
393
- structlog.stdlib.ProcessorFormatter.remove_processors_meta,
394
- log_renderer,
395
- ],
396
- )
397
-
398
- # setup logging
399
- handler = logging.StreamHandler()
400
- handler.setFormatter(formatter)
401
- root_logger = logging.getLogger()
402
- root_logger.addHandler(handler)
403
- root_logger.setLevel(level)
404
-
405
- # Propogate the logs of some libraries
406
- for liblog in [logging.getLogger(_liblog) for _liblog in ["websockets"]]:
407
- liblog.handlers.clear()
408
- liblog.setLevel(logging.WARNING)
409
- liblog.propagate = True
410
-
411
- # And suppress the logs of others
412
- # for liblog in [logging.getLogger(_liblog) for _liblog in ["websockets"]]:
413
- # liblog.handlers.clear()
414
- # liblog.propagate = False
415
-
416
-
417
- def get_logger(
418
- name: str | None = None, *args: list[Any], **initial_values: Any
419
- ) -> structlog.stdlib.BoundLogger:
420
- """Get a structured logger instance."""
421
- return structlog.get_logger(*([name] + list(args)), **initial_values)
422
-
423
-
424
- def setup_logging_from_env() -> None:
425
- """Setup logging using environment variables."""
426
- log_level = os.getenv("LOG_LEVEL", "INFO").upper()
427
- json_output = os.getenv("JSON_LOGS", "false").lower() in ("true", "1", "yes", "on")
428
- correlation_id = os.getenv("CORRELATION_ID")
429
-
430
- setup_logging(level=log_level, json_output=json_output, correlation_id=correlation_id)
@@ -1,7 +0,0 @@
1
- from .float import FloatPrecisionProcessor
2
- from .logger_filter import LoggerFilterProcessor
3
-
4
- __all__ = [
5
- "FloatPrecisionProcessor",
6
- "LoggerFilterProcessor",
7
- ]
@@ -1,76 +0,0 @@
1
- from typing import Any
2
-
3
- import numpy as np
4
- from structlog.typing import EventDict, WrappedLogger
5
-
6
-
7
- class FloatPrecisionProcessor:
8
- """
9
- A structlog processor for rounding floats. Both as single numbers or in data structures like
10
- (nested) lists, dicts, or numpy arrays.
11
-
12
- Inspired by https://github.com/underyx/structlog-pretty/blob/master/structlog_pretty/processors.py
13
-
14
- NOTE: It seems that if a processor logs internally, like for debugging purposes, it will be
15
- detected and removed from the processing stack.
16
- """
17
-
18
- def __init__(
19
- self,
20
- digits: int = 3,
21
- only_fields: set[str] = set(),
22
- not_fields: set[str] = set(),
23
- np_array_to_list: bool = True,
24
- ):
25
- """
26
- Create a FloatRounder processor. That rounds floats to the given number of digits.
27
-
28
- :param digits: The number of digits to round to
29
- :param only_fields: A set specifying the fields to round (None = round all fields except
30
- not_fields)
31
- :param not_fields: A set specifying fields not to round
32
- :param np_array_to_list: Whether to cast np.array to list for nicer printing
33
- """
34
- self.digits = digits
35
- self.np_array_to_list = np_array_to_list
36
- self.only_fields = only_fields
37
- self.not_fields = not_fields
38
-
39
- def _round(self, value: Any):
40
- """
41
- Round floats, unpack lists, convert np.arrays to lists
42
-
43
- :param value: The value/data structure to round
44
- :returns: The rounded value
45
- """
46
- # round floats
47
- if isinstance(value, float):
48
- return round(value, self.digits)
49
- # convert np.array to list
50
- if self.np_array_to_list:
51
- if isinstance(value, np.ndarray):
52
- return self._round(list(value))
53
- # round values in lists recursively (to handle lists of lists)
54
- if isinstance(value, list):
55
- for idx, item in enumerate(value):
56
- value[idx] = self._round(item)
57
- return value
58
- # similarly, round values in dicts recursively
59
- if isinstance(value, dict):
60
- for k, v in value.items():
61
- value[k] = self._round(v)
62
- return value
63
- # return any other values as they are
64
- return value
65
-
66
- def __call__(self, _: WrappedLogger, __: str, event_dict: EventDict):
67
- for key, value in event_dict.items():
68
- if len(self.only_fields) > 0 and key not in self.only_fields:
69
- continue
70
- if len(self.not_fields) > 0 and key in self.not_fields:
71
- continue
72
- if isinstance(value, bool):
73
- continue # don't convert True to 1.0
74
-
75
- event_dict[key] = self._round(value)
76
- return event_dict
@@ -1,47 +0,0 @@
1
- from structlog import DropEvent
2
- from structlog.typing import EventDict, WrappedLogger
3
-
4
-
5
- class LoggerFilterProcessor:
6
- """
7
- A structlog processor that filters log events based on the logger name.
8
-
9
- This processor allows you to specify a logger name, and only log events
10
- originating from that logger (or its children) will be processed. All other
11
- log events will be ignored.
12
-
13
- Example usage:
14
- import structlog
15
-
16
- structlog.configure(
17
- processors=[
18
- LoggerFilterProcessor("my_logger"),
19
- structlog.processors.JSONRenderer()
20
- ]
21
- )
22
- logger = structlog.get_logger("my_logger")
23
- logger.info("This will be logged")
24
- """
25
-
26
- def __init__(self, logger_name: str):
27
- """
28
- Initialize the LoggerFilterProcessor.
29
-
30
- :param logger_name: The name of the logger to filter on. Only log events
31
- from this logger or its children will be processed.
32
- """
33
- self.logger_name = logger_name
34
-
35
- def __call__(self, logger: WrappedLogger, __: str, event_dict: EventDict):
36
- """
37
- Process a log event.
38
-
39
- :param logger: The logger instance that generated the event.
40
- :param method_name: The logging method name (e.g., "info", "error").
41
- :param event_dict: The log event dictionary.
42
- :return: The event_dict if the event should be processed, or None to ignore it.
43
- """
44
- if logger.name == self.logger_name or logger.name.startswith(f"{self.logger_name}."):
45
- return event_dict
46
- else:
47
- raise DropEvent(f"Filtered out by LoggerFilterProcessor, logger={logger.name}")
File without changes
File without changes