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.
- lfp_logging-0.0.3/PKG-INFO +86 -0
- lfp_logging-0.0.3/README.md +79 -0
- lfp_logging-0.0.3/pyproject.toml +38 -0
- lfp_logging-0.0.3/src/lfp_logging/__init__.py +0 -0
- lfp_logging-0.0.3/src/lfp_logging/config.py +148 -0
- lfp_logging-0.0.3/src/lfp_logging/log_level.py +63 -0
- lfp_logging-0.0.3/src/lfp_logging/logs.py +224 -0
|
@@ -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
|