lfp-logging 0.0.3__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.
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.3
2
+ Name: lfp-logging
3
+ Version: 0.0.3
4
+ Summary: A simple, zero-dependency lazy-initialization logging utility
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+
8
+ # lfp-logging
9
+
10
+ A simple, zero-dependency logging utility for Python that provides lazy-initialization and automatic configuration.
11
+
12
+ ## Features
13
+
14
+ - **Zero Dependencies**: Built entirely on the Python standard library.
15
+ - **Lazy Initialization**: Logging is only configured when the first log message is actually handled. It uses a patching mechanism that stays out of the way until a log is emitted.
16
+ - **Automatic Name Discovery**: Automatically determines logger names based on the caller's class, module, or file name.
17
+ - **Smart Default Handlers**:
18
+ - `INFO` messages are sent to `stderr` (along with all other levels) by default.
19
+ - Detailed formatting including timestamps, levels, and line numbers.
20
+ - **ANSI Colors**: Automatic color support for terminals, with overrides for popular IDEs (VSCode, PyCharm) and CI environments.
21
+ - **Explicit Override Support**: If you call `logging.basicConfig()` yourself, `lfp-logging` will automatically back off and let your configuration take priority.
22
+ - **Flexible Configuration**: Supports configuration via environment variables.
23
+ - **Multi-platform Support**: Supports macOS (ARM/x64), Linux (ARM/x64), and Windows (x64/ARM).
24
+
25
+ ## Installation
26
+
27
+ You can install `lfp-logging` directly from GitHub using `pip`:
28
+
29
+ ```bash
30
+ pip install git+https://github.com/regbo/lfp-logging-py.git
31
+ ```
32
+
33
+ Or add it to your `pyproject.toml` dependencies:
34
+
35
+ ```toml
36
+ dependencies = [
37
+ "lfp_logging @ git+https://github.com/regbo/lfp-logging-py.git"
38
+ ]
39
+ ```
40
+
41
+ ## Quick Start
42
+
43
+ ```python
44
+ from lfp_logging import logger
45
+
46
+ # The logger name is automatically discovered as the class name "MyService"
47
+ class MyService:
48
+ def __init__(self):
49
+ self.log = logger()
50
+
51
+ def do_something(self):
52
+ self.log.info("Starting task...")
53
+ self.log.warning("Something might be wrong.")
54
+
55
+ # You can specify one or more potential names.
56
+ # The first valid name (non-empty, not "__main__") will be used.
57
+ log = logger(None, "__main__", "my_app")
58
+ log.info("Hello World!") # Uses "my_app"
59
+ ```
60
+
61
+ ## Configuration
62
+
63
+ The logging level and formats can be controlled using environment variables.
64
+
65
+ ### Environment Variables
66
+
67
+ - `LOG_LEVEL`: Set the global log level. Accepts names (e.g., `DEBUG`, `INFO`) or numeric values (e.g., `10`, `20`).
68
+ - `LOG_FORMAT`: Custom log format string (standard Python logging format).
69
+ - `LOG_FORMAT_DATE`: Custom date format (default: `%Y-%m-%d %H:%M:%S`).
70
+ - `LOG_FORMAT_COLOR`: Global ANSI color code for all levels.
71
+ - `LOG_FORMAT_COLOR_<LEVEL>`: Level-specific ANSI color code (e.g., `LOG_FORMAT_COLOR_DEBUG`).
72
+ - `LOG_CONFIG_LAZY`: Defer logging configuration until the first log message is emitted (default: `false`). Set to `true`, `1`, `yes`, or `on` to enable.
73
+
74
+ ### System Arguments
75
+
76
+ The `--log-level` argument is no longer supported directly by the core configuration, but can be implemented by the user by setting the `LOG_LEVEL` environment variable before the first log call.
77
+
78
+ ## Development
79
+
80
+ This project uses `uv` for dependency management and `pytest` for testing.
81
+
82
+ ### Running Tests
83
+
84
+ ```bash
85
+ uv run pytest
86
+ ```
@@ -0,0 +1,79 @@
1
+ # lfp-logging
2
+
3
+ A simple, zero-dependency logging utility for Python that provides lazy-initialization and automatic configuration.
4
+
5
+ ## Features
6
+
7
+ - **Zero Dependencies**: Built entirely on the Python standard library.
8
+ - **Lazy Initialization**: Logging is only configured when the first log message is actually handled. It uses a patching mechanism that stays out of the way until a log is emitted.
9
+ - **Automatic Name Discovery**: Automatically determines logger names based on the caller's class, module, or file name.
10
+ - **Smart Default Handlers**:
11
+ - `INFO` messages are sent to `stderr` (along with all other levels) by default.
12
+ - Detailed formatting including timestamps, levels, and line numbers.
13
+ - **ANSI Colors**: Automatic color support for terminals, with overrides for popular IDEs (VSCode, PyCharm) and CI environments.
14
+ - **Explicit Override Support**: If you call `logging.basicConfig()` yourself, `lfp-logging` will automatically back off and let your configuration take priority.
15
+ - **Flexible Configuration**: Supports configuration via environment variables.
16
+ - **Multi-platform Support**: Supports macOS (ARM/x64), Linux (ARM/x64), and Windows (x64/ARM).
17
+
18
+ ## Installation
19
+
20
+ You can install `lfp-logging` directly from GitHub using `pip`:
21
+
22
+ ```bash
23
+ pip install git+https://github.com/regbo/lfp-logging-py.git
24
+ ```
25
+
26
+ Or add it to your `pyproject.toml` dependencies:
27
+
28
+ ```toml
29
+ dependencies = [
30
+ "lfp_logging @ git+https://github.com/regbo/lfp-logging-py.git"
31
+ ]
32
+ ```
33
+
34
+ ## Quick Start
35
+
36
+ ```python
37
+ from lfp_logging import logger
38
+
39
+ # The logger name is automatically discovered as the class name "MyService"
40
+ class MyService:
41
+ def __init__(self):
42
+ self.log = logger()
43
+
44
+ def do_something(self):
45
+ self.log.info("Starting task...")
46
+ self.log.warning("Something might be wrong.")
47
+
48
+ # You can specify one or more potential names.
49
+ # The first valid name (non-empty, not "__main__") will be used.
50
+ log = logger(None, "__main__", "my_app")
51
+ log.info("Hello World!") # Uses "my_app"
52
+ ```
53
+
54
+ ## Configuration
55
+
56
+ The logging level and formats can be controlled using environment variables.
57
+
58
+ ### Environment Variables
59
+
60
+ - `LOG_LEVEL`: Set the global log level. Accepts names (e.g., `DEBUG`, `INFO`) or numeric values (e.g., `10`, `20`).
61
+ - `LOG_FORMAT`: Custom log format string (standard Python logging format).
62
+ - `LOG_FORMAT_DATE`: Custom date format (default: `%Y-%m-%d %H:%M:%S`).
63
+ - `LOG_FORMAT_COLOR`: Global ANSI color code for all levels.
64
+ - `LOG_FORMAT_COLOR_<LEVEL>`: Level-specific ANSI color code (e.g., `LOG_FORMAT_COLOR_DEBUG`).
65
+ - `LOG_CONFIG_LAZY`: Defer logging configuration until the first log message is emitted (default: `false`). Set to `true`, `1`, `yes`, or `on` to enable.
66
+
67
+ ### System Arguments
68
+
69
+ The `--log-level` argument is no longer supported directly by the core configuration, but can be implemented by the user by setting the `LOG_LEVEL` environment variable before the first log call.
70
+
71
+ ## Development
72
+
73
+ This project uses `uv` for dependency management and `pytest` for testing.
74
+
75
+ ### Running Tests
76
+
77
+ ```bash
78
+ uv run pytest
79
+ ```
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.9"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "lfp-logging"
7
+ version = "0.0.3"
8
+ description = "A simple, zero-dependency lazy-initialization logging utility"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ dependencies = []
12
+
13
+ [dependency-groups]
14
+ dev = ["pytest", "ruff", "bumpversion"]
15
+
16
+ [tool.ruff]
17
+ target-version = "py39"
18
+ line-length = 100
19
+
20
+ [tool.ruff.lint]
21
+ select = ["E", "F", "UP", "B", "SIM", "I"]
22
+ ignore = []
23
+
24
+ [tool.pixi.workspace]
25
+ channels = ["conda-forge"]
26
+ platforms = ["osx-arm64", "osx-64", "linux-64", "linux-aarch64", "win-64", "win-arm64"]
27
+
28
+ [tool.pixi.pypi-dependencies]
29
+ lfp-logging = { path = ".", editable = true }
30
+
31
+ [tool.pixi.environments]
32
+ default = { solve-group = "default", features = ["dev"] }
33
+ dev = { features = ["dev"], solve-group = "default" }
34
+
35
+
36
+ [tool.pixi.tasks]
37
+ publish = { cmd = "git add . && (git diff --staged --quiet || git commit -m \"pre-release: staging changes\") && bumpversion {{ part }} --message \"{{ message }}\" && git push origin HEAD --tags", args = [{ arg = "part", default = "patch" }, { arg = "message", default = "incrementing version to {new_version}" }] }
38
+
File without changes
@@ -0,0 +1,148 @@
1
+ import logging
2
+ import os
3
+ from dataclasses import dataclass
4
+ from functools import cache
5
+ from typing import IO, Any, Callable, Optional
6
+
7
+ from lfp_logging import log_level
8
+
9
+ """
10
+ This module manages the logging configuration by reading from environment
11
+ variables. It provides defaults that can be overridden by the user via
12
+ the standard environment variable names.
13
+
14
+ Configuration is handled via `_Config` objects which lazily parse environment
15
+ values when requested.
16
+ """
17
+
18
+
19
+ @dataclass
20
+ class _Config:
21
+ """
22
+ A generic configuration container that maps an environment variable
23
+ to a parsed value.
24
+ """
25
+
26
+ env_name: str
27
+ parser: Callable[[Optional[str]], Any]
28
+
29
+ def get(self) -> Any:
30
+ return self.parser(_env_value(self.env_name))
31
+
32
+
33
+ LOG_LEVEL = _Config("LOG_LEVEL", lambda v: log_level.get(v, logging.INFO))
34
+ LOG_CONFIG_LAZY = _Config("LOG_CONFIG_LAZY", lambda v: _parse_bool(v))
35
+ LOG_FORMAT = _Config(
36
+ "LOG_FORMAT",
37
+ lambda v: v or "%(asctime)s %(levelname)s %(name)s:%(lineno)d %(message)s",
38
+ )
39
+ LOG_FORMAT_DATE = _Config("LOG_FORMAT_DATE", lambda v: v or "%Y-%m-%d %H:%M:%S")
40
+
41
+ _LOG_FORMAT_COLOR_ENV_NAME = "LOG_FORMAT_COLOR"
42
+ _LOG_LEVEL_COLORS = {
43
+ "TRACE": "\x1b[90m",
44
+ log_level.get(logging.DEBUG).name: "\x1b[36m",
45
+ log_level.get(logging.INFO).name: "\x1b[38;5;244m",
46
+ log_level.get(logging.WARNING).name: "\x1b[33m",
47
+ log_level.get(logging.ERROR).name: "\x1b[31m",
48
+ log_level.get(logging.CRITICAL).name: "\x1b[1;31m",
49
+ }
50
+
51
+
52
+ def color(stream: IO, record: logging.LogRecord) -> Optional[str]:
53
+ """
54
+ Returns the ANSI color code for a given log record if the stream supports it.
55
+
56
+ Color selection order:
57
+ 1. `LOG_FORMAT_COLOR_<LEVEL>` environment variable.
58
+ 2. `LOG_FORMAT_COLOR` environment variable.
59
+ 3. Default level-specific colors.
60
+ """
61
+ if not _supports_color(stream):
62
+ return None
63
+ level_obj = log_level.get(record, None)
64
+ if level_obj is None:
65
+ return None
66
+ ansi_color = _env_value(_LOG_FORMAT_COLOR_ENV_NAME + "_" + level_obj.name.upper())
67
+ if ansi_color is None:
68
+ ansi_color = _env_value(_LOG_FORMAT_COLOR_ENV_NAME)
69
+ if ansi_color is None:
70
+ ansi_color = _LOG_LEVEL_COLORS.get(level_obj.name, None)
71
+ return ansi_color
72
+
73
+
74
+ def _supports_color(stream: IO) -> bool:
75
+ """
76
+ Determines if the given stream supports ANSI color output.
77
+ Checks for TTY status, IDE-specific environment variables, and OS capabilities.
78
+ """
79
+ isatty = False
80
+ try:
81
+ if hasattr(stream, "isatty") and stream.isatty():
82
+ isatty = True
83
+ except Exception:
84
+ pass
85
+ if not isatty:
86
+ for k, v in {
87
+ "TERM_PROGRAM": "vscode",
88
+ "PYCHARM_HOSTED": "1",
89
+ "CODESPACES": "true",
90
+ }.items():
91
+ if _env_value(k) == v:
92
+ return True
93
+
94
+ return False
95
+
96
+ return _os_supports_color()
97
+
98
+
99
+ @cache
100
+ def _os_supports_color() -> bool:
101
+ term = _env_value("TERM")
102
+ if term is None or term == "dumb":
103
+ return False
104
+
105
+ ci = _env_value("CI")
106
+ if ci is not None and ci in ("GITHUB_ACTIONS", "GITLAB_CI", "COLORTERM"):
107
+ return False
108
+
109
+ if os.name == "nt":
110
+ env = os.environ
111
+ return any(k in env for k in ("WT_SESSION", "ANSICON", "ConEmuANSI", "TERM_PROGRAM"))
112
+
113
+ if _env_value("COLORTERM") is not None:
114
+ return True
115
+
116
+ # Known color capable TERM values
117
+ color_terms = (
118
+ "xterm",
119
+ "xterm-color",
120
+ "xterm-256color",
121
+ "screen",
122
+ "screen-256color",
123
+ "tmux",
124
+ "tmux-256color",
125
+ "rxvt-unicode",
126
+ "rxvt-unicode-256color",
127
+ "linux",
128
+ )
129
+
130
+ return any(term.startswith(t) for t in color_terms)
131
+
132
+
133
+ def _env_value(name: Any) -> Optional[str]:
134
+ value = os.environ.get(name, None)
135
+ if value:
136
+ value = str(value).strip()
137
+ return value or None
138
+
139
+
140
+ def _parse_bool(value: Any, default: Optional[bool] = False) -> Optional[bool]:
141
+ if value is not None:
142
+ value = str(value).lower().strip()
143
+ if value:
144
+ if value in ("true", "1", "yes", "on"):
145
+ return True
146
+ elif value in ("false", "0", "no", "off"):
147
+ return False
148
+ return default
@@ -0,0 +1,63 @@
1
+ import logging
2
+ from typing import Any, Union
3
+
4
+ """
5
+ This module provides utilities for parsing and representing logging levels.
6
+ It includes a LogLevel container and a robust parsing function that handles
7
+ integers, strings, and numeric strings with support for defaults.
8
+ """
9
+
10
+ _UNSET = object()
11
+
12
+
13
+ class LogLevel:
14
+ """
15
+ A container for logging level information, mapping a human-readable name
16
+ to its corresponding integer value.
17
+ """
18
+
19
+ def __init__(self, name: str, level: int) -> None:
20
+ self.name = name
21
+ self.level = level
22
+
23
+ def __repr__(self) -> str:
24
+ return f"{self.__class__.__name__}(name={self.name!r}, level={self.level!r})"
25
+
26
+ def __str__(self) -> str:
27
+ return self.name
28
+
29
+
30
+ def get(value: Any, default_value: Union[str, int, None] = _UNSET) -> Union[LogLevel, None]:
31
+ """
32
+ Converts a given value into a LogLevel object.
33
+
34
+ Args:
35
+ value: The value to convert. Can be an integer, a string name, or
36
+ a numeric string.
37
+ default_value: The value to return if parsing fails. If _UNSET (default),
38
+ raises a ValueError on failure.
39
+
40
+ Returns:
41
+ A LogLevel instance if conversion is successful.
42
+ """
43
+ if value is not None:
44
+ if isinstance(value, logging.LogRecord):
45
+ return get(value.levelno, default_value)
46
+ elif isinstance(value, int):
47
+ level_no = value
48
+ level_name = logging.getLevelName(level_no)
49
+ if isinstance(level_name, str) and level_name and not level_name.startswith("Level "):
50
+ return LogLevel(level_name, level_no)
51
+ else:
52
+ if level_name := str(value):
53
+ level_name = level_name.upper()
54
+ level_no = logging.getLevelName(level_name)
55
+ if isinstance(level_no, int):
56
+ return LogLevel(level_name, level_no)
57
+ elif level_name.isdigit():
58
+ return get(int(level_name), default_value)
59
+ if default_value is None:
60
+ return None
61
+ elif default_value is _UNSET:
62
+ raise ValueError(f"{LogLevel.__name__} not found: {value}")
63
+ return get(default_value)
@@ -0,0 +1,224 @@
1
+ import contextlib
2
+ import inspect
3
+ import logging
4
+ import pathlib
5
+ import sys
6
+ import threading
7
+ import types
8
+ from typing import IO, Any, Callable, Optional
9
+
10
+ from lfp_logging import config
11
+
12
+ """
13
+ This module provides a lazy-initialization logging utility that automatically
14
+ configures logging handlers with ANSI color support.
15
+
16
+ The core design allows for "zero-config" logging that stays out of the way of
17
+ other configuration attempts. It achieves this by patching loggers on creation:
18
+ 1. `logger()` returns a standard `logging.Logger` but patches its `isEnabledFor`
19
+ method.
20
+ 2. The first time a log level check occurs, `logging.basicConfig` is called
21
+ automatically with a default handler (stderr) and a custom color formatter.
22
+ 3. `logging.basicConfig` is also temporarily patched; if the user calls it
23
+ later, it will remove the default handler to ensure the user's explicit
24
+ configuration always wins.
25
+
26
+ It includes functionality for:
27
+ - Automatic logger name discovery from caller frames (classes, modules, filenames).
28
+ - ANSI color support for terminals and IDEs (VSCode, PyCharm, etc.).
29
+ - Transparent lazy initialization that supports multi-threaded environments.
30
+ """
31
+
32
+ _HANDLE_PATCH_MARKER = ("_lfp_logging_handle_patch", object())
33
+ _PYTHON_FILE_EXTENSION = ".py"
34
+
35
+
36
+ class _InitContext(threading.Event):
37
+ def __init__(self) -> None:
38
+ super().__init__()
39
+ self._lock = threading.Lock()
40
+
41
+ def call(self, fn: Callable, *args, set: bool = True) -> bool:
42
+ if not self.is_set():
43
+ with self._lock:
44
+ if not self.is_set():
45
+ fn(*args)
46
+ if set:
47
+ self.set()
48
+ return True
49
+ return False
50
+
51
+
52
+ _HANDLE_PATCH_CTX = _InitContext()
53
+ _BASIC_CONFIG_PATCH_CTX = _InitContext()
54
+ _BASIC_CONFIG_UNPATCH_CTX = _InitContext()
55
+
56
+
57
+ class _Formatter(logging.Formatter):
58
+ """
59
+ Custom logging formatter that adds ANSI color codes to log messages
60
+ based on the log level and terminal support.
61
+ """
62
+
63
+ def __init__(self, stream: IO):
64
+ super().__init__(config.LOG_FORMAT.get(), config.LOG_FORMAT_DATE.get())
65
+ self.stream = stream
66
+
67
+ def format(self, record: logging.LogRecord) -> str:
68
+ message = super().format(record)
69
+ color = config.color(self.stream, record)
70
+ if color is None:
71
+ return message
72
+ return f"{color}{message}" + "\x1b[0m"
73
+
74
+
75
+ def logger(*names: Any) -> logging.Logger:
76
+ """
77
+ Returns a standard logging.Logger instance, patched to trigger lazy
78
+ initialization of the default logging configuration on first use.
79
+
80
+ If names are provided, it attempts to use the first valid name. If no name
81
+ is provided or none are valid, it attempts to automatically determine a
82
+ suitable name from the caller's stack frame.
83
+
84
+ Args:
85
+ *names: Potential names for the logger. The first valid name found
86
+ (not None, not "__main__") will be used.
87
+
88
+ Returns:
89
+ A logging.Logger instance patched for lazy initialization.
90
+ """
91
+ name: Optional[str] = None
92
+ if names:
93
+ for n in names:
94
+ if name := _logger_name(n):
95
+ break
96
+ if not name:
97
+ current_frame = inspect.currentframe()
98
+ caller_frame = current_frame.f_back if current_frame else None
99
+ try:
100
+ if caller_frame:
101
+ if (instance := caller_frame.f_locals.get("self", None)) is not None:
102
+ with contextlib.suppress(Exception):
103
+ name = _logger_name(instance.__class__.__name__)
104
+ if not name and (cls := caller_frame.f_locals.get("cls", None)) is not None:
105
+ with contextlib.suppress(Exception):
106
+ name = _logger_name(cls.__name__)
107
+ if not name and (co_filename := caller_frame.f_code.co_filename) is not None:
108
+ name = _logger_name(co_filename)
109
+ finally:
110
+ # Clean up frames to avoid reference cycles
111
+ del current_frame
112
+ del caller_frame
113
+ if not name:
114
+ name = __name__
115
+ logger_obj = logging.getLogger(name)
116
+ if config.LOG_CONFIG_LAZY.get():
117
+ _HANDLE_PATCH_CTX.call(_logger_handle_patch, logger_obj, set=False)
118
+ else:
119
+ _BASIC_CONFIG_PATCH_CTX.call(_logging_basic_config_patch)
120
+ return logger_obj
121
+
122
+
123
+ def _logger_handle_patch(logger_obj: logging.Logger):
124
+ """
125
+ Patches a logger's isEnabledFor method to trigger basic configuration.
126
+
127
+ This is the entry point for the lazy initialization. It marks the logger
128
+ as patched and replaces its isEnabledFor method with a wrapper that
129
+ will call _logging_basic_config_patch once.
130
+ """
131
+ marker_name, marker_value = _HANDLE_PATCH_MARKER
132
+ if getattr(logger_obj, marker_name, None) is marker_value:
133
+ return
134
+ setattr(logger_obj, marker_name, marker_value)
135
+
136
+ _orig_is_enabled_for: Callable = logger_obj.isEnabledFor
137
+
138
+ def _is_enabled_for(self: logging.Logger, level) -> bool:
139
+ _BASIC_CONFIG_PATCH_CTX.call(_logging_basic_config_patch)
140
+ _HANDLE_PATCH_CTX.set()
141
+ self.isEnabledFor = _orig_is_enabled_for
142
+
143
+ return _orig_is_enabled_for(level)
144
+
145
+ logger_obj.isEnabledFor = types.MethodType(_is_enabled_for, logger_obj)
146
+
147
+
148
+ def _logging_basic_config_patch():
149
+ """
150
+ Initializes default logging handlers and patches logging.basicConfig.
151
+
152
+ This function is called exactly once when the first log message (or check)
153
+ is processed. It sets up stdout/stderr handlers and replaces
154
+ logging.basicConfig with a wrapper that ensures user-provided configuration
155
+ can override these defaults.
156
+ """
157
+ log_level_no = config.LOG_LEVEL.get().level
158
+ handler = _create_logging_handler()
159
+
160
+ logging.basicConfig(
161
+ level=log_level_no,
162
+ handlers=[handler],
163
+ )
164
+
165
+ # ensure that all handlers added
166
+ if handler not in logging.root.handlers:
167
+ return
168
+
169
+ _orig_basic_config: Callable = logging.basicConfig
170
+
171
+ def _basic_config_unpatch():
172
+ root = logging.root
173
+ for h in root.handlers[:]:
174
+ if h is handler:
175
+ root.removeHandler(h)
176
+ h.close()
177
+
178
+ def _basic_config(**kwargs):
179
+ _BASIC_CONFIG_UNPATCH_CTX.call(_basic_config_unpatch)
180
+ logging.basicConfig = _orig_basic_config
181
+ _orig_basic_config(**kwargs)
182
+
183
+ logging.basicConfig = _basic_config
184
+
185
+
186
+ def _create_logging_handler() -> logging.Handler:
187
+ """
188
+ Creates the default StreamHandler (stderr) with the custom color formatter.
189
+ """
190
+ stream = sys.stderr
191
+ handler = logging.StreamHandler(stream)
192
+ handler.setFormatter(_Formatter(stream))
193
+ return handler
194
+
195
+
196
+ def _logger_name(value: Any) -> Optional[str]:
197
+ """
198
+ Parses and cleans a potential logger name.
199
+
200
+ It ignores "__main__", converts file paths ending in ".py" to a
201
+ "parent.stem" format, and replaces spaces with underscores.
202
+
203
+ Args:
204
+ value: The value to parse as a logger name.
205
+
206
+ Returns:
207
+ A cleaned string name if valid, otherwise None.
208
+ """
209
+ if value is None or value == "__main__":
210
+ return None
211
+
212
+ name = str(value) if value else None
213
+ if not name or name == "__main__":
214
+ return None
215
+
216
+ if name.lower().endswith(_PYTHON_FILE_EXTENSION):
217
+ with contextlib.suppress(Exception):
218
+ path = pathlib.Path(name)
219
+ if path_name := path.stem:
220
+ if parent_name := path.parent.name if path.parent else None:
221
+ path_name = f"{parent_name}.{path_name}"
222
+ if path_name := "".join(c if c.isalnum() or c == "." else "_" for c in path_name):
223
+ return path_name
224
+ return name