stdlogkit 0.1.0__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.
- stdlogkit/__init__.py +44 -0
- stdlogkit/access_log_filter.py +89 -0
- stdlogkit/env.py +46 -0
- stdlogkit/formatter.py +89 -0
- stdlogkit/lazy.py +21 -0
- stdlogkit/logger.py +298 -0
- stdlogkit/py.typed +1 -0
- stdlogkit/timing.py +34 -0
- stdlogkit-0.1.0.dist-info/METADATA +158 -0
- stdlogkit-0.1.0.dist-info/RECORD +12 -0
- stdlogkit-0.1.0.dist-info/WHEEL +5 -0
- stdlogkit-0.1.0.dist-info/top_level.txt +1 -0
stdlogkit/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Auto-configuring helpers for Python's standard :mod:`logging` package.
|
|
2
|
+
|
|
3
|
+
Importing :mod:`stdlogkit` configures standard-library logging immediately
|
|
4
|
+
unless ``STDLOGKIT_CONFIGURE_LOGGING=0`` is set.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from stdlogkit.access_log_filter import (
|
|
8
|
+
UvicornAccessLogFilter,
|
|
9
|
+
create_uvicorn_log_config,
|
|
10
|
+
)
|
|
11
|
+
from stdlogkit.formatter import ColoredFormatter, NewLineFormatter
|
|
12
|
+
from stdlogkit.lazy import lazy
|
|
13
|
+
from stdlogkit.logger import (
|
|
14
|
+
LogScope,
|
|
15
|
+
configure_logging,
|
|
16
|
+
current_formatter_type,
|
|
17
|
+
get_logger,
|
|
18
|
+
init_logger,
|
|
19
|
+
install_logger_methods,
|
|
20
|
+
reset_once_cache,
|
|
21
|
+
suppress_logging,
|
|
22
|
+
)
|
|
23
|
+
from stdlogkit.timing import logtime
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
|
|
27
|
+
configure_logging()
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"ColoredFormatter",
|
|
31
|
+
"LogScope",
|
|
32
|
+
"NewLineFormatter",
|
|
33
|
+
"UvicornAccessLogFilter",
|
|
34
|
+
"configure_logging",
|
|
35
|
+
"create_uvicorn_log_config",
|
|
36
|
+
"current_formatter_type",
|
|
37
|
+
"get_logger",
|
|
38
|
+
"init_logger",
|
|
39
|
+
"install_logger_methods",
|
|
40
|
+
"lazy",
|
|
41
|
+
"logtime",
|
|
42
|
+
"reset_once_cache",
|
|
43
|
+
"suppress_logging",
|
|
44
|
+
]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Optional uvicorn access-log filtering helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class UvicornAccessLogFilter(logging.Filter):
|
|
10
|
+
"""Filter uvicorn access logs for exact endpoint paths."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, excluded_paths: list[str] | None = None):
|
|
13
|
+
super().__init__()
|
|
14
|
+
self.excluded_paths = set(excluded_paths or [])
|
|
15
|
+
|
|
16
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
17
|
+
if not self.excluded_paths or record.name != "uvicorn.access":
|
|
18
|
+
return True
|
|
19
|
+
|
|
20
|
+
log_args = record.args
|
|
21
|
+
if isinstance(log_args, tuple) and len(log_args) >= 3:
|
|
22
|
+
path_with_query = log_args[2]
|
|
23
|
+
if isinstance(path_with_query, str):
|
|
24
|
+
path = urlparse(path_with_query).path
|
|
25
|
+
return path not in self.excluded_paths
|
|
26
|
+
|
|
27
|
+
return True
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def create_uvicorn_log_config(
|
|
31
|
+
excluded_paths: list[str] | None = None,
|
|
32
|
+
log_level: str = "info",
|
|
33
|
+
) -> dict:
|
|
34
|
+
"""Create a uvicorn ``log_config`` dictionary with access-log filtering."""
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
"version": 1,
|
|
38
|
+
"disable_existing_loggers": False,
|
|
39
|
+
"filters": {
|
|
40
|
+
"access_log_filter": {
|
|
41
|
+
"()": UvicornAccessLogFilter,
|
|
42
|
+
"excluded_paths": excluded_paths or [],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
"formatters": {
|
|
46
|
+
"default": {
|
|
47
|
+
"()": "uvicorn.logging.DefaultFormatter",
|
|
48
|
+
"fmt": "%(levelprefix)s %(message)s",
|
|
49
|
+
"use_colors": None,
|
|
50
|
+
},
|
|
51
|
+
"access": {
|
|
52
|
+
"()": "uvicorn.logging.AccessFormatter",
|
|
53
|
+
"fmt": (
|
|
54
|
+
'%(levelprefix)s %(client_addr)s - '
|
|
55
|
+
'"%(request_line)s" %(status_code)s'
|
|
56
|
+
),
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
"handlers": {
|
|
60
|
+
"default": {
|
|
61
|
+
"formatter": "default",
|
|
62
|
+
"class": "logging.StreamHandler",
|
|
63
|
+
"stream": "ext://sys.stderr",
|
|
64
|
+
},
|
|
65
|
+
"access": {
|
|
66
|
+
"formatter": "access",
|
|
67
|
+
"class": "logging.StreamHandler",
|
|
68
|
+
"stream": "ext://sys.stdout",
|
|
69
|
+
"filters": ["access_log_filter"],
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
"loggers": {
|
|
73
|
+
"uvicorn": {
|
|
74
|
+
"handlers": ["default"],
|
|
75
|
+
"level": log_level.upper(),
|
|
76
|
+
"propagate": False,
|
|
77
|
+
},
|
|
78
|
+
"uvicorn.error": {
|
|
79
|
+
"handlers": ["default"],
|
|
80
|
+
"level": log_level.upper(),
|
|
81
|
+
"propagate": False,
|
|
82
|
+
},
|
|
83
|
+
"uvicorn.access": {
|
|
84
|
+
"handlers": ["access"],
|
|
85
|
+
"level": log_level.upper(),
|
|
86
|
+
"propagate": False,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
}
|
stdlogkit/env.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Environment-backed settings for stdlogkit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _bool_from_env(name: str, default: bool) -> bool:
|
|
10
|
+
raw = os.getenv(name)
|
|
11
|
+
if raw is None:
|
|
12
|
+
return default
|
|
13
|
+
return raw.strip().lower() not in {"0", "false", "no", "off", ""}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Settings:
|
|
18
|
+
"""Logging settings loaded from environment variables."""
|
|
19
|
+
|
|
20
|
+
configure_logging: bool = True
|
|
21
|
+
logging_level: str = "INFO"
|
|
22
|
+
logging_prefix: str = ""
|
|
23
|
+
logging_stream: str = "ext://sys.stdout"
|
|
24
|
+
logging_config_path: str | None = None
|
|
25
|
+
logging_color: str = "auto"
|
|
26
|
+
logger_name: str = ""
|
|
27
|
+
root_dir: str | None = None
|
|
28
|
+
show_rel_path: str = "debug"
|
|
29
|
+
no_color: bool = False
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_env(cls) -> "Settings":
|
|
33
|
+
"""Build settings from ``STDLOGKIT_*`` environment variables."""
|
|
34
|
+
|
|
35
|
+
return cls(
|
|
36
|
+
configure_logging=_bool_from_env("STDLOGKIT_CONFIGURE_LOGGING", True),
|
|
37
|
+
logging_level=os.getenv("STDLOGKIT_LOGGING_LEVEL", "INFO").upper(),
|
|
38
|
+
logging_prefix=os.getenv("STDLOGKIT_LOGGING_PREFIX", ""),
|
|
39
|
+
logging_stream=os.getenv("STDLOGKIT_LOGGING_STREAM", "ext://sys.stdout"),
|
|
40
|
+
logging_config_path=os.getenv("STDLOGKIT_LOGGING_CONFIG_PATH"),
|
|
41
|
+
logging_color=os.getenv("STDLOGKIT_LOGGING_COLOR", "auto").lower(),
|
|
42
|
+
logger_name=os.getenv("STDLOGKIT_LOGGER_NAME", ""),
|
|
43
|
+
root_dir=os.getenv("STDLOGKIT_ROOT_DIR"),
|
|
44
|
+
show_rel_path=os.getenv("STDLOGKIT_SHOW_REL_PATH", "debug").lower(),
|
|
45
|
+
no_color=_bool_from_env("NO_COLOR", False),
|
|
46
|
+
)
|
stdlogkit/formatter.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Formatters used by stdlogkit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from stdlogkit.env import Settings
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _shrink_path(relpath: Path) -> str:
|
|
13
|
+
parts = list(relpath.parts)
|
|
14
|
+
if len(parts) <= 4:
|
|
15
|
+
return "/".join(parts)
|
|
16
|
+
return "/".join(parts[:1] + ["..."] + parts[-2:])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NewLineFormatter(logging.Formatter):
|
|
20
|
+
"""Formatter that aligns every line in a multiline log message."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, fmt: str, datefmt: str | None = None, style: str = "%"):
|
|
23
|
+
super().__init__(fmt, datefmt, style)
|
|
24
|
+
settings = Settings.from_env()
|
|
25
|
+
self.show_rel_path = settings.show_rel_path
|
|
26
|
+
self.root_dir = Path(settings.root_dir or os.getcwd()).resolve()
|
|
27
|
+
|
|
28
|
+
def _use_relpath(self, record: logging.LogRecord) -> bool:
|
|
29
|
+
if self.show_rel_path == "always":
|
|
30
|
+
return True
|
|
31
|
+
if self.show_rel_path == "never":
|
|
32
|
+
return False
|
|
33
|
+
return record.levelno <= logging.DEBUG
|
|
34
|
+
|
|
35
|
+
def _fileinfo(self, record: logging.LogRecord) -> str:
|
|
36
|
+
if not self._use_relpath(record):
|
|
37
|
+
return record.filename
|
|
38
|
+
|
|
39
|
+
pathname = getattr(record, "pathname", None)
|
|
40
|
+
if not pathname:
|
|
41
|
+
return record.filename
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
relpath = Path(pathname).resolve().relative_to(self.root_dir)
|
|
45
|
+
except Exception:
|
|
46
|
+
relpath = Path(record.filename)
|
|
47
|
+
return _shrink_path(relpath)
|
|
48
|
+
|
|
49
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
50
|
+
record.fileinfo = self._fileinfo(record)
|
|
51
|
+
msg = super().format(record)
|
|
52
|
+
message = record.getMessage()
|
|
53
|
+
if message:
|
|
54
|
+
prefix = msg.partition(message)[0]
|
|
55
|
+
msg = msg.replace("\n", "\r\n" + prefix)
|
|
56
|
+
return msg
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ColoredFormatter(NewLineFormatter):
|
|
60
|
+
"""Formatter that colors level names and static metadata with ANSI codes."""
|
|
61
|
+
|
|
62
|
+
COLORS = {
|
|
63
|
+
"DEBUG": "\033[37m",
|
|
64
|
+
"INFO": "\033[32m",
|
|
65
|
+
"WARNING": "\033[33m",
|
|
66
|
+
"ERROR": "\033[31m",
|
|
67
|
+
"CRITICAL": "\033[35m",
|
|
68
|
+
}
|
|
69
|
+
GREY = "\033[90m"
|
|
70
|
+
RESET = "\033[0m"
|
|
71
|
+
|
|
72
|
+
def __init__(self, fmt: str, datefmt: str | None = None, style: str = "%"):
|
|
73
|
+
if fmt:
|
|
74
|
+
fmt = fmt.replace("%(asctime)s", f"{self.GREY}%(asctime)s{self.RESET}")
|
|
75
|
+
fmt = fmt.replace(
|
|
76
|
+
"[%(fileinfo)s:%(lineno)d]",
|
|
77
|
+
f"{self.GREY}[%(fileinfo)s:%(lineno)d]{self.RESET}",
|
|
78
|
+
)
|
|
79
|
+
super().__init__(fmt, datefmt, style)
|
|
80
|
+
|
|
81
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
82
|
+
original_levelname = record.levelname
|
|
83
|
+
color = self.COLORS.get(original_levelname)
|
|
84
|
+
if color is not None:
|
|
85
|
+
record.levelname = f"{color}{original_levelname}{self.RESET}"
|
|
86
|
+
try:
|
|
87
|
+
return super().format(record)
|
|
88
|
+
finally:
|
|
89
|
+
record.levelname = original_levelname
|
stdlogkit/lazy.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Lazy log message arguments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class lazy:
|
|
10
|
+
"""Wrap a zero-argument callable evaluated only during log formatting."""
|
|
11
|
+
|
|
12
|
+
__slots__ = ("_factory",)
|
|
13
|
+
|
|
14
|
+
def __init__(self, factory: Callable[[], Any]) -> None:
|
|
15
|
+
self._factory = factory
|
|
16
|
+
|
|
17
|
+
def __str__(self) -> str:
|
|
18
|
+
return str(self._factory())
|
|
19
|
+
|
|
20
|
+
def __repr__(self) -> str:
|
|
21
|
+
return str(self)
|
stdlogkit/logger.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Configuration and extensions for Python's standard logging package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
from collections.abc import Generator
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from logging import Logger
|
|
13
|
+
from logging.config import dictConfig
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Literal, TypeVar, cast
|
|
16
|
+
|
|
17
|
+
from stdlogkit.env import Settings
|
|
18
|
+
from stdlogkit.formatter import ColoredFormatter, NewLineFormatter
|
|
19
|
+
|
|
20
|
+
_FORMAT_TEMPLATE = (
|
|
21
|
+
"{prefix}%(levelname)s %(asctime)s "
|
|
22
|
+
"[%(fileinfo)s:%(lineno)d] %(message)s"
|
|
23
|
+
)
|
|
24
|
+
_DATE_FORMAT = "%m-%d %H:%M:%S"
|
|
25
|
+
|
|
26
|
+
LogScope = Literal["process", "global", "local"]
|
|
27
|
+
_LoggerT = TypeVar("_LoggerT", bound=Logger)
|
|
28
|
+
_CONFIGURED = False
|
|
29
|
+
_ONCE_LOCK = threading.RLock()
|
|
30
|
+
_ONCE_KEYS: set[tuple[str, int, str, tuple[str, ...], str]] = set()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _env_rank(names: tuple[str, ...]) -> int:
|
|
34
|
+
for name in names:
|
|
35
|
+
raw = os.getenv(name)
|
|
36
|
+
if raw is None:
|
|
37
|
+
continue
|
|
38
|
+
try:
|
|
39
|
+
return int(raw)
|
|
40
|
+
except ValueError:
|
|
41
|
+
return 0
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _should_log_with_scope(scope: LogScope) -> bool:
|
|
46
|
+
if scope == "process":
|
|
47
|
+
return True
|
|
48
|
+
if scope == "global":
|
|
49
|
+
return _env_rank(("RANK", "WORLD_RANK", "SLURM_PROCID")) == 0
|
|
50
|
+
if scope == "local":
|
|
51
|
+
return (
|
|
52
|
+
_env_rank(
|
|
53
|
+
("LOCAL_RANK", "MPI_LOCALRANKID", "OMPI_COMM_WORLD_LOCAL_RANK")
|
|
54
|
+
)
|
|
55
|
+
== 0
|
|
56
|
+
)
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _safe_key(value: Any) -> str:
|
|
61
|
+
try:
|
|
62
|
+
return repr(value)
|
|
63
|
+
except Exception:
|
|
64
|
+
return f"<unrepresentable {type(value).__name__}>"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _log_once(
|
|
68
|
+
logger: Logger,
|
|
69
|
+
level: int,
|
|
70
|
+
msg: str,
|
|
71
|
+
args: tuple[Any, ...],
|
|
72
|
+
scope: LogScope,
|
|
73
|
+
kwargs: dict[str, Any],
|
|
74
|
+
) -> None:
|
|
75
|
+
if not _should_log_with_scope(scope):
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
key = (logger.name, level, msg, tuple(_safe_key(arg) for arg in args), scope)
|
|
79
|
+
with _ONCE_LOCK:
|
|
80
|
+
if key in _ONCE_KEYS:
|
|
81
|
+
return
|
|
82
|
+
_ONCE_KEYS.add(key)
|
|
83
|
+
|
|
84
|
+
kwargs.setdefault("stacklevel", 3)
|
|
85
|
+
logger.log(level, msg, *args, **kwargs)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class StdLogKitLogger(Logger):
|
|
89
|
+
"""Logger type used for static typing of stdlogkit helper methods."""
|
|
90
|
+
|
|
91
|
+
def debug_once(
|
|
92
|
+
self,
|
|
93
|
+
msg: str,
|
|
94
|
+
*args: Any,
|
|
95
|
+
scope: LogScope = "local",
|
|
96
|
+
**kwargs: Any,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Log a DEBUG message once per logger/message/argument tuple."""
|
|
99
|
+
|
|
100
|
+
_log_once(self, logging.DEBUG, msg, args, scope, kwargs)
|
|
101
|
+
|
|
102
|
+
def info_once(
|
|
103
|
+
self,
|
|
104
|
+
msg: str,
|
|
105
|
+
*args: Any,
|
|
106
|
+
scope: LogScope = "local",
|
|
107
|
+
**kwargs: Any,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Log an INFO message once per logger/message/argument tuple."""
|
|
110
|
+
|
|
111
|
+
_log_once(self, logging.INFO, msg, args, scope, kwargs)
|
|
112
|
+
|
|
113
|
+
def warning_once(
|
|
114
|
+
self,
|
|
115
|
+
msg: str,
|
|
116
|
+
*args: Any,
|
|
117
|
+
scope: LogScope = "local",
|
|
118
|
+
**kwargs: Any,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Log a WARNING message once per logger/message/argument tuple."""
|
|
121
|
+
|
|
122
|
+
_log_once(self, logging.WARNING, msg, args, scope, kwargs)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
_METHODS_TO_PATCH = {
|
|
126
|
+
"debug_once": StdLogKitLogger.debug_once,
|
|
127
|
+
"info_once": StdLogKitLogger.info_once,
|
|
128
|
+
"warning_once": StdLogKitLogger.warning_once,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def install_logger_methods(*, overwrite: bool = False) -> None:
|
|
133
|
+
"""Install ``*_once`` methods onto standard-library logger classes."""
|
|
134
|
+
|
|
135
|
+
for target in (logging.Logger, logging.RootLogger):
|
|
136
|
+
for name, method in _METHODS_TO_PATCH.items():
|
|
137
|
+
if overwrite or not hasattr(target, name):
|
|
138
|
+
setattr(target, name, method)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _use_color(settings: Settings) -> bool:
|
|
142
|
+
if settings.no_color or settings.logging_color == "0":
|
|
143
|
+
return False
|
|
144
|
+
if settings.logging_color == "1":
|
|
145
|
+
return True
|
|
146
|
+
if settings.logging_color != "auto":
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
if settings.logging_stream == "ext://sys.stdout":
|
|
150
|
+
return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
|
|
151
|
+
if settings.logging_stream == "ext://sys.stderr":
|
|
152
|
+
return hasattr(sys.stderr, "isatty") and sys.stderr.isatty()
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _default_logging_config(settings: Settings) -> dict[str, Any]:
|
|
157
|
+
formatter_name = "stdlogkit_color" if _use_color(settings) else "stdlogkit"
|
|
158
|
+
config: dict[str, Any] = {
|
|
159
|
+
"version": 1,
|
|
160
|
+
"disable_existing_loggers": False,
|
|
161
|
+
"formatters": {
|
|
162
|
+
"stdlogkit": {
|
|
163
|
+
"class": "stdlogkit.formatter.NewLineFormatter",
|
|
164
|
+
"datefmt": _DATE_FORMAT,
|
|
165
|
+
"format": _FORMAT_TEMPLATE.format(prefix=settings.logging_prefix),
|
|
166
|
+
},
|
|
167
|
+
"stdlogkit_color": {
|
|
168
|
+
"class": "stdlogkit.formatter.ColoredFormatter",
|
|
169
|
+
"datefmt": _DATE_FORMAT,
|
|
170
|
+
"format": _FORMAT_TEMPLATE.format(prefix=settings.logging_prefix),
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
"handlers": {
|
|
174
|
+
"stdlogkit": {
|
|
175
|
+
"class": "logging.StreamHandler",
|
|
176
|
+
"formatter": formatter_name,
|
|
177
|
+
"level": settings.logging_level,
|
|
178
|
+
"stream": settings.logging_stream,
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if settings.logger_name:
|
|
184
|
+
config["loggers"] = {
|
|
185
|
+
settings.logger_name: {
|
|
186
|
+
"handlers": ["stdlogkit"],
|
|
187
|
+
"level": settings.logging_level,
|
|
188
|
+
"propagate": False,
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
else:
|
|
192
|
+
config["root"] = {
|
|
193
|
+
"handlers": ["stdlogkit"],
|
|
194
|
+
"level": settings.logging_level,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return config
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _load_custom_config(path: str) -> dict[str, Any]:
|
|
201
|
+
config_path = Path(path)
|
|
202
|
+
if not config_path.exists():
|
|
203
|
+
raise RuntimeError(
|
|
204
|
+
"Could not load logging config. File does not exist: "
|
|
205
|
+
f"{path}"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
with config_path.open(encoding="utf-8") as file:
|
|
209
|
+
custom_config = json.loads(file.read())
|
|
210
|
+
|
|
211
|
+
if not isinstance(custom_config, dict):
|
|
212
|
+
raise ValueError(
|
|
213
|
+
"Invalid logging config. Expected dict, got "
|
|
214
|
+
f"{type(custom_config).__name__}."
|
|
215
|
+
)
|
|
216
|
+
return custom_config
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def configure_logging(*, force: bool = False, settings: Settings | None = None) -> None:
|
|
220
|
+
"""Configure standard-library logging from environment-backed settings.
|
|
221
|
+
|
|
222
|
+
Importing :mod:`stdlogkit` calls this once automatically. Call it manually
|
|
223
|
+
with ``force=True`` after changing environment variables in tests or tools.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
global _CONFIGURED
|
|
227
|
+
|
|
228
|
+
install_logger_methods()
|
|
229
|
+
if _CONFIGURED and not force:
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
settings = settings or Settings.from_env()
|
|
233
|
+
if not settings.configure_logging and settings.logging_config_path:
|
|
234
|
+
raise RuntimeError(
|
|
235
|
+
"STDLOGKIT_CONFIGURE_LOGGING evaluated to false, but "
|
|
236
|
+
"STDLOGKIT_LOGGING_CONFIG_PATH was given. Enable "
|
|
237
|
+
"STDLOGKIT_CONFIGURE_LOGGING or unset STDLOGKIT_LOGGING_CONFIG_PATH."
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
if not settings.configure_logging:
|
|
241
|
+
_CONFIGURED = True
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
logging_config = (
|
|
245
|
+
_load_custom_config(settings.logging_config_path)
|
|
246
|
+
if settings.logging_config_path
|
|
247
|
+
else _default_logging_config(settings)
|
|
248
|
+
)
|
|
249
|
+
dictConfig(logging_config)
|
|
250
|
+
_CONFIGURED = True
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def init_logger(name: str) -> StdLogKitLogger:
|
|
254
|
+
"""Return a logger with stdlogkit's ``*_once`` helper methods available."""
|
|
255
|
+
|
|
256
|
+
install_logger_methods()
|
|
257
|
+
return cast(StdLogKitLogger, logging.getLogger(name))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def get_logger(name: str) -> StdLogKitLogger:
|
|
261
|
+
"""Alias for :func:`init_logger`."""
|
|
262
|
+
|
|
263
|
+
return init_logger(name)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def reset_once_cache() -> None:
|
|
267
|
+
"""Clear the de-duplication cache used by ``*_once`` methods."""
|
|
268
|
+
|
|
269
|
+
with _ONCE_LOCK:
|
|
270
|
+
_ONCE_KEYS.clear()
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@contextmanager
|
|
274
|
+
def suppress_logging(level: int = logging.INFO) -> Generator[None, Any, None]:
|
|
275
|
+
"""Temporarily suppress logging up to ``level``."""
|
|
276
|
+
|
|
277
|
+
current_level = logging.root.manager.disable
|
|
278
|
+
logging.disable(level)
|
|
279
|
+
try:
|
|
280
|
+
yield
|
|
281
|
+
finally:
|
|
282
|
+
logging.disable(current_level)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def current_formatter_type(logger: Logger) -> Literal["color", "newline", None]:
|
|
286
|
+
"""Return the stdlogkit formatter type found on ``logger`` or its parents."""
|
|
287
|
+
|
|
288
|
+
current: Logger | None = logger
|
|
289
|
+
while current is not None:
|
|
290
|
+
for handler in current.handlers:
|
|
291
|
+
if handler.name == "stdlogkit":
|
|
292
|
+
formatter = handler.formatter
|
|
293
|
+
if isinstance(formatter, ColoredFormatter):
|
|
294
|
+
return "color"
|
|
295
|
+
if isinstance(formatter, NewLineFormatter):
|
|
296
|
+
return "newline"
|
|
297
|
+
current = current.parent
|
|
298
|
+
return None
|
stdlogkit/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
stdlogkit/timing.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Timing helpers for logging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import ParamSpec, TypeVar
|
|
9
|
+
|
|
10
|
+
P = ParamSpec("P")
|
|
11
|
+
R = TypeVar("R")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def logtime(logger, msg: str | None = None):
|
|
15
|
+
"""Log the execution time of the decorated function at DEBUG level."""
|
|
16
|
+
|
|
17
|
+
def _inner(func: Callable[P, R]) -> Callable[P, R]:
|
|
18
|
+
@functools.wraps(func)
|
|
19
|
+
def _wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
20
|
+
start = time.perf_counter()
|
|
21
|
+
result = func(*args, **kwargs)
|
|
22
|
+
elapsed = time.perf_counter() - start
|
|
23
|
+
|
|
24
|
+
prefix = (
|
|
25
|
+
f"Function '{func.__module__}.{func.__qualname__}'"
|
|
26
|
+
if msg is None
|
|
27
|
+
else msg
|
|
28
|
+
)
|
|
29
|
+
logger.debug("%s: Elapsed time %.7f secs", prefix, elapsed)
|
|
30
|
+
return result
|
|
31
|
+
|
|
32
|
+
return _wrapper
|
|
33
|
+
|
|
34
|
+
return _inner
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stdlogkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A small stdlib logging auto-configuration kit with colored multiline logs and log-once helpers.
|
|
5
|
+
Author-email: Your Name <you@example.com>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/your-org/stdlogkit
|
|
8
|
+
Project-URL: Repository, https://github.com/your-org/stdlogkit
|
|
9
|
+
Project-URL: Issues, https://github.com/your-org/stdlogkit/issues
|
|
10
|
+
Keywords: logging,stdlib,colored-logs,python-logging
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Topic :: System :: Logging
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
26
|
+
Requires-Dist: twine>=5; extra == "dev"
|
|
27
|
+
|
|
28
|
+
# stdlogkit
|
|
29
|
+
|
|
30
|
+
`stdlogkit` is a tiny, vLLM-independent wrapper around Python's standard
|
|
31
|
+
`logging` package.
|
|
32
|
+
|
|
33
|
+
Importing it configures standard logging immediately:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import logging
|
|
37
|
+
import stdlogkit # noqa: F401
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
logger.info("hello")
|
|
41
|
+
logger.warning_once("this warning appears once")
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Default output:
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
INFO 06-24 12:00:01 [example.py:5] hello
|
|
48
|
+
WARNING 06-24 12:00:01 [example.py:6] this warning appears once
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Features
|
|
52
|
+
|
|
53
|
+
- Uses only Python's standard `logging` machinery.
|
|
54
|
+
- Auto-configures logging on `import stdlogkit`.
|
|
55
|
+
- Configures the root logger by default, so normal `logging.getLogger(...)`
|
|
56
|
+
calls work immediately.
|
|
57
|
+
- Adds `debug_once`, `info_once`, and `warning_once` to standard logger
|
|
58
|
+
instances.
|
|
59
|
+
- Supports multiline log alignment.
|
|
60
|
+
- Supports ANSI colors with `auto`, forced-on, and forced-off modes.
|
|
61
|
+
- Supports custom JSON config through `logging.config.dictConfig`.
|
|
62
|
+
- Includes optional uvicorn access-log filtering helpers.
|
|
63
|
+
- Has no runtime dependency on vLLM or any other third-party package.
|
|
64
|
+
|
|
65
|
+
## Environment Variables
|
|
66
|
+
|
|
67
|
+
| Variable | Default | Description |
|
|
68
|
+
| --- | --- | --- |
|
|
69
|
+
| `STDLOGKIT_CONFIGURE_LOGGING` | `1` | Set `0` to disable auto configuration. |
|
|
70
|
+
| `STDLOGKIT_LOGGING_LEVEL` | `INFO` | Root or named logger level. |
|
|
71
|
+
| `STDLOGKIT_LOGGING_STREAM` | `ext://sys.stdout` | Handler stream. |
|
|
72
|
+
| `STDLOGKIT_LOGGING_PREFIX` | empty | Prefix prepended to every log line. |
|
|
73
|
+
| `STDLOGKIT_LOGGING_COLOR` | `auto` | `auto`, `1`, or `0`. |
|
|
74
|
+
| `STDLOGKIT_LOGGING_CONFIG_PATH` | unset | Path to a JSON `dictConfig` file. |
|
|
75
|
+
| `STDLOGKIT_LOGGER_NAME` | empty | Configure a named logger instead of root. |
|
|
76
|
+
| `STDLOGKIT_ROOT_DIR` | current working directory | Base path for relative file display. |
|
|
77
|
+
| `STDLOGKIT_SHOW_REL_PATH` | `debug` | `debug`, `always`, or `never`. |
|
|
78
|
+
| `NO_COLOR` | `0` | Standard flag to disable ANSI colors. |
|
|
79
|
+
|
|
80
|
+
## Custom JSON Config
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"version": 1,
|
|
85
|
+
"disable_existing_loggers": false,
|
|
86
|
+
"formatters": {
|
|
87
|
+
"plain": {
|
|
88
|
+
"class": "stdlogkit.formatter.NewLineFormatter",
|
|
89
|
+
"format": "%(levelname)s %(asctime)s [%(fileinfo)s:%(lineno)d] %(message)s",
|
|
90
|
+
"datefmt": "%m-%d %H:%M:%S"
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
"handlers": {
|
|
94
|
+
"console": {
|
|
95
|
+
"class": "logging.StreamHandler",
|
|
96
|
+
"formatter": "plain",
|
|
97
|
+
"level": "INFO",
|
|
98
|
+
"stream": "ext://sys.stdout"
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
"root": {
|
|
102
|
+
"handlers": ["console"],
|
|
103
|
+
"level": "INFO"
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Run with:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
STDLOGKIT_LOGGING_CONFIG_PATH=/path/to/logging.json python app.py
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Named Logger Mode
|
|
115
|
+
|
|
116
|
+
By default, stdlogkit configures the root logger. If you want behavior closer
|
|
117
|
+
to a framework-specific logger, configure only a named logger:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
STDLOGKIT_LOGGER_NAME=my_app python app.py
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Then loggers under `my_app.*` propagate to that logger:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
import logging
|
|
127
|
+
import stdlogkit # noqa: F401
|
|
128
|
+
|
|
129
|
+
logger = logging.getLogger("my_app.service")
|
|
130
|
+
logger.info("hello")
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Development
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
cd standalone_logging_pkg
|
|
137
|
+
uv venv --python 3.12
|
|
138
|
+
uv pip install -e ".[dev]"
|
|
139
|
+
.venv/bin/python -m pytest -q
|
|
140
|
+
uv build
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Publish to PyPI
|
|
144
|
+
|
|
145
|
+
Before publishing, make sure the project name in `pyproject.toml` is available
|
|
146
|
+
on PyPI and replace the placeholder author/repository metadata.
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
cd standalone_logging_pkg
|
|
150
|
+
uv build
|
|
151
|
+
uv publish --token "$PYPI_TOKEN"
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
For TestPyPI:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
uv publish --publish-url https://test.pypi.org/legacy/ --token "$TEST_PYPI_TOKEN"
|
|
158
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
stdlogkit/__init__.py,sha256=mvZx0HZ0BUERIxylPdjs7KT_iHLaNUd-hEjiqILjJH4,1029
|
|
2
|
+
stdlogkit/access_log_filter.py,sha256=vxjtbnVZEU_IsZXo4jAc1USG4pf8UW8yTebfUfLrIKE,2772
|
|
3
|
+
stdlogkit/env.py,sha256=gwjlOyuAJ6J-ZwFnq5zU6iPZ9gXzW406Ifz4800R7HI,1653
|
|
4
|
+
stdlogkit/formatter.py,sha256=004yCc5f24QTJpavqcIQbUtv0dRPUac9LcZQGuTWB-M,2867
|
|
5
|
+
stdlogkit/lazy.py,sha256=EXf0nO50E0yMD2DNeY8J9fTCtpH7C-DMRCzMurkXYKo,471
|
|
6
|
+
stdlogkit/logger.py,sha256=l-yY79NqwSGsGly7P2m9W52EcS2eoNtDT55zBLrOnz4,8621
|
|
7
|
+
stdlogkit/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
8
|
+
stdlogkit/timing.py,sha256=mBSmAbMpbScRmb6ie8dcKKSZt1PTHVGT1pRuvD_kiS4,921
|
|
9
|
+
stdlogkit-0.1.0.dist-info/METADATA,sha256=xOLojyZ-j_mnCw3KEI8ZIxQ3TAKKCaz9whHpIni5lBg,4619
|
|
10
|
+
stdlogkit-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
11
|
+
stdlogkit-0.1.0.dist-info/top_level.txt,sha256=3xP_hFyWZBuhl7bfMMxPGY9ubjIukESA7OkZr6HT1CI,10
|
|
12
|
+
stdlogkit-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
stdlogkit
|