alt-python-logger 1.0.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.
- alt_python_logger-1.0.0.dist-info/METADATA +7 -0
- alt_python_logger-1.0.0.dist-info/RECORD +15 -0
- alt_python_logger-1.0.0.dist-info/WHEEL +4 -0
- logger/__init__.py +60 -0
- logger/caching_console.py +35 -0
- logger/configurable_logger.py +118 -0
- logger/console_logger.py +87 -0
- logger/delegating_logger.py +73 -0
- logger/json_formatter.py +44 -0
- logger/logger.py +55 -0
- logger/logger_category_cache.py +27 -0
- logger/logger_factory.py +111 -0
- logger/logger_level.py +73 -0
- logger/multi_logger.py +60 -0
- logger/plain_text_formatter.py +32 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
logger/__init__.py,sha256=decdLYHfkgQ2Se_VcKEIWJgnZ0G2bvZea0eic7I445g,1917
|
|
2
|
+
logger/caching_console.py,sha256=AcZwrwuugO3ievCYNdZVfatmmjJZUxkSe1gEdqG7EvA,948
|
|
3
|
+
logger/configurable_logger.py,sha256=a3Xvga7szv1R_hkMAhHew5bmgPtiUz1M1Y2BoV3PHXw,4190
|
|
4
|
+
logger/console_logger.py,sha256=PEUGpL3OuXm7Z6tRtZlEsaYRYpnFRCDT11SWEjVohQM,3129
|
|
5
|
+
logger/delegating_logger.py,sha256=IH62HpazfBb9Q2wOoushhARNFERCKb0ouA6zdnYkxa4,2218
|
|
6
|
+
logger/json_formatter.py,sha256=BD6yIbVcasoZbaavDn6BwxK9FZUFcagat1lrU6LkgWg,1018
|
|
7
|
+
logger/logger.py,sha256=oZ_k-t1PWxA5e8mTApD0lr6INEkRSHY_fXujkNvbCs0,1664
|
|
8
|
+
logger/logger_category_cache.py,sha256=Zy5EhiaF6KuHDf88Nv2vSrqZeJMk55n0n9Y5ydr32Tg,618
|
|
9
|
+
logger/logger_factory.py,sha256=R8USeMqXAOmChBiSh51xbKIoDgMXFoLeM1Igqkk3q0c,3808
|
|
10
|
+
logger/logger_level.py,sha256=tpT9uiPP41gJukMUGT2IzqwXV9Exqh6yr6xg-STDBIc,2098
|
|
11
|
+
logger/multi_logger.py,sha256=MagghSGdn9IuXQrqhS5dZALUNOeiGQtMWKpya5fTiAk,1892
|
|
12
|
+
logger/plain_text_formatter.py,sha256=1jYqOdnRYgobNYN0yQ3YEc8UPTp1wpHvVCaYT2vNiFo,722
|
|
13
|
+
alt_python_logger-1.0.0.dist-info/METADATA,sha256=BC9fe02M5AkzAJvMzaZbFmr8QITSd1nCV22O0wbn0wo,208
|
|
14
|
+
alt_python_logger-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
15
|
+
alt_python_logger-1.0.0.dist-info/RECORD,,
|
logger/__init__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
logger — Spring-inspired config-driven logger for Python.
|
|
3
|
+
|
|
4
|
+
Quick start::
|
|
5
|
+
|
|
6
|
+
from logger import logger_factory
|
|
7
|
+
|
|
8
|
+
log = logger_factory.get_logger("com.example.MyService")
|
|
9
|
+
log.info("Application started")
|
|
10
|
+
log.debug("Debug detail") # suppressed unless logging.level.com.example = debug
|
|
11
|
+
|
|
12
|
+
# Or with ConfigFactory-backed config for full Spring-style setup:
|
|
13
|
+
from config import ConfigFactory
|
|
14
|
+
from logger import LoggerFactory
|
|
15
|
+
|
|
16
|
+
factory = LoggerFactory(config=ConfigFactory.get_config())
|
|
17
|
+
log = factory.get_logger("com.example.MyService")
|
|
18
|
+
|
|
19
|
+
Level hierarchy (config keys):
|
|
20
|
+
logging.level./ → root level (default: info)
|
|
21
|
+
logging.level.com → level for all 'com.*' loggers
|
|
22
|
+
logging.level.com.example → level for 'com.example.*' loggers
|
|
23
|
+
|
|
24
|
+
Log format (config key):
|
|
25
|
+
logging.format=text → PlainTextFormatter
|
|
26
|
+
logging.format=json → JSONFormatter (default)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
__author__ = "Craig Parravicini"
|
|
30
|
+
__collaborators__ = ["Claude (Anthropic)"]
|
|
31
|
+
|
|
32
|
+
from logger.logger_level import LoggerLevel
|
|
33
|
+
from logger.logger import Logger
|
|
34
|
+
from logger.console_logger import ConsoleLogger
|
|
35
|
+
from logger.delegating_logger import DelegatingLogger
|
|
36
|
+
from logger.configurable_logger import ConfigurableLogger
|
|
37
|
+
from logger.logger_category_cache import LoggerCategoryCache
|
|
38
|
+
from logger.logger_factory import LoggerFactory
|
|
39
|
+
from logger.json_formatter import JSONFormatter
|
|
40
|
+
from logger.plain_text_formatter import PlainTextFormatter
|
|
41
|
+
from logger.caching_console import CachingConsole
|
|
42
|
+
from logger.multi_logger import MultiLogger
|
|
43
|
+
|
|
44
|
+
# Module-level singleton — zero setup required.
|
|
45
|
+
logger_factory = LoggerFactory()
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"LoggerLevel",
|
|
49
|
+
"Logger",
|
|
50
|
+
"ConsoleLogger",
|
|
51
|
+
"DelegatingLogger",
|
|
52
|
+
"ConfigurableLogger",
|
|
53
|
+
"LoggerCategoryCache",
|
|
54
|
+
"LoggerFactory",
|
|
55
|
+
"JSONFormatter",
|
|
56
|
+
"PlainTextFormatter",
|
|
57
|
+
"CachingConsole",
|
|
58
|
+
"MultiLogger",
|
|
59
|
+
"logger_factory",
|
|
60
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
logger.caching_console — In-memory log capture for tests.
|
|
3
|
+
|
|
4
|
+
Replaces the stdlib logger with a list accumulator so test code can
|
|
5
|
+
assert on what was logged without depending on stdout.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__author__ = "Craig Parravicini"
|
|
11
|
+
__collaborators__ = ["Claude (Anthropic)"]
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CachingConsole:
|
|
17
|
+
"""
|
|
18
|
+
In-memory log store. Passed to ConsoleLogger in place of a stdlib logger.
|
|
19
|
+
|
|
20
|
+
Exposes .messages for inspection in tests.
|
|
21
|
+
|
|
22
|
+
Mirrors the JS CachingConsole class.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self.messages: list[tuple[int, str]] = []
|
|
27
|
+
|
|
28
|
+
def isEnabledFor(self, level: int) -> bool: # noqa: N802 — matches stdlib Logger API
|
|
29
|
+
return True
|
|
30
|
+
|
|
31
|
+
def log(self, level: int, message: str, *args: Any, **kwargs: Any) -> None: # type: ignore[override]
|
|
32
|
+
self.messages.append((level, message))
|
|
33
|
+
|
|
34
|
+
def clear(self) -> None:
|
|
35
|
+
self.messages.clear()
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
logger.configurable_logger — Logger that reads its level from config.
|
|
3
|
+
|
|
4
|
+
Config path convention (dot-separated, Spring-aligned):
|
|
5
|
+
logging.level./ → root logger level (equivalent to JS 'logging.level./')
|
|
6
|
+
logging.level.com.example → level for 'com.example' category prefix
|
|
7
|
+
logging.level.com.example.MyService → level for exact category
|
|
8
|
+
|
|
9
|
+
The lookup walks the category's dot-separated segments, taking the most-specific
|
|
10
|
+
level found. Results are cached in LoggerCategoryCache.
|
|
11
|
+
|
|
12
|
+
Key design difference from JS:
|
|
13
|
+
JS uses slash-separated category names (com/example/MyService) and a path-style
|
|
14
|
+
config key (logging.level./com/example).
|
|
15
|
+
Python uses dot-separated names (com.example.MyService) and config key
|
|
16
|
+
logging.level.com.example.MyService.
|
|
17
|
+
|
|
18
|
+
Root level is stored at config key: logging.level./ (slash = root marker,
|
|
19
|
+
same as JS convention, kept for config-file compatibility).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
__author__ = "Craig Parravicini"
|
|
25
|
+
__collaborators__ = ["Claude (Anthropic)"]
|
|
26
|
+
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from logger.delegating_logger import DelegatingLogger
|
|
30
|
+
from logger.logger import Logger
|
|
31
|
+
from logger.logger_category_cache import LoggerCategoryCache
|
|
32
|
+
from logger.logger_level import LoggerLevel
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ConfigurableLogger(DelegatingLogger):
|
|
36
|
+
"""
|
|
37
|
+
Logger whose level is driven by config.
|
|
38
|
+
|
|
39
|
+
Mirrors the JS ConfigurableLogger class.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
DEFAULT_CONFIG_PATH = "logging.level"
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
config: Any,
|
|
47
|
+
provider: Logger,
|
|
48
|
+
category: str | None = None,
|
|
49
|
+
config_path: str | None = None,
|
|
50
|
+
cache: LoggerCategoryCache | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
super().__init__(provider)
|
|
53
|
+
if config is None:
|
|
54
|
+
raise ValueError("config is required")
|
|
55
|
+
if cache is None:
|
|
56
|
+
raise ValueError("cache is required")
|
|
57
|
+
self.config = config
|
|
58
|
+
self.category = category or Logger.DEFAULT_CATEGORY
|
|
59
|
+
self.provider.category = self.category
|
|
60
|
+
self.config_path = config_path or self.DEFAULT_CONFIG_PATH
|
|
61
|
+
self.cache = cache
|
|
62
|
+
|
|
63
|
+
# Apply level from config immediately
|
|
64
|
+
level = self.get_logger_level(
|
|
65
|
+
self.category, self.config_path, self.config, self.cache
|
|
66
|
+
)
|
|
67
|
+
self.provider.set_level(level)
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def get_logger_level(
|
|
71
|
+
category: str,
|
|
72
|
+
config_path: str,
|
|
73
|
+
config: Any,
|
|
74
|
+
cache: LoggerCategoryCache,
|
|
75
|
+
) -> str:
|
|
76
|
+
"""
|
|
77
|
+
Walk the category's dot-segments looking for the most-specific level in config.
|
|
78
|
+
|
|
79
|
+
Config key structure:
|
|
80
|
+
{config_path}./ → root level (e.g. logging.level./)
|
|
81
|
+
{config_path}.com → level for top-level 'com' prefix
|
|
82
|
+
{config_path}.com.example → level for 'com.example' prefix
|
|
83
|
+
|
|
84
|
+
The root slash marker keeps parity with the JS config file convention.
|
|
85
|
+
"""
|
|
86
|
+
path = config_path or ConfigurableLogger.DEFAULT_CONFIG_PATH
|
|
87
|
+
level = LoggerLevel.INFO
|
|
88
|
+
|
|
89
|
+
# Check root level: e.g. logging.level./
|
|
90
|
+
root_key = f"{path}./"
|
|
91
|
+
cached = cache.get(root_key)
|
|
92
|
+
if cached:
|
|
93
|
+
level = cached
|
|
94
|
+
elif config.has(root_key):
|
|
95
|
+
val = config.get(root_key)
|
|
96
|
+
if isinstance(val, str) and val in LoggerLevel.ENUMS:
|
|
97
|
+
level = val
|
|
98
|
+
cache.put(root_key, level)
|
|
99
|
+
|
|
100
|
+
# Walk category segments
|
|
101
|
+
segments = (category or "").split(".")
|
|
102
|
+
path_step = path
|
|
103
|
+
for i, seg in enumerate(segments):
|
|
104
|
+
if not seg:
|
|
105
|
+
continue
|
|
106
|
+
path_step = f"{path_step}.{seg}" if i > 0 or path_step == path else f"{path}.{seg}"
|
|
107
|
+
cached = cache.get(path_step)
|
|
108
|
+
if cached:
|
|
109
|
+
level = cached
|
|
110
|
+
elif config.has(path_step):
|
|
111
|
+
val = config.get(path_step)
|
|
112
|
+
# Only apply string values — nested dicts mean there are more-specific
|
|
113
|
+
# level entries under this prefix; the loop will eventually reach them.
|
|
114
|
+
if isinstance(val, str) and val in LoggerLevel.ENUMS:
|
|
115
|
+
level = val
|
|
116
|
+
cache.put(path_step, level)
|
|
117
|
+
|
|
118
|
+
return level
|
logger/console_logger.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
logger.console_logger — Logger that writes formatted records to stdout.
|
|
3
|
+
|
|
4
|
+
The formatter (JSONFormatter or PlainTextFormatter) produces the final string.
|
|
5
|
+
Output goes to sys.stdout so all levels appear on stdout — error and fatal
|
|
6
|
+
included. Callers that want stderr routing can pass a custom sink.
|
|
7
|
+
|
|
8
|
+
The optional stdlib_logger parameter accepts a CachingConsole (or any object
|
|
9
|
+
with isEnabledFor(int) and log(int, str)) for use in tests.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
__author__ = "Craig Parravicini"
|
|
15
|
+
__collaborators__ = ["Claude (Anthropic)"]
|
|
16
|
+
|
|
17
|
+
import sys
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from logger.logger import Logger
|
|
22
|
+
from logger.logger_level import LoggerLevel
|
|
23
|
+
from logger.json_formatter import JSONFormatter
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConsoleLogger(Logger):
|
|
27
|
+
"""
|
|
28
|
+
Logger implementation that emits formatted records to stdout.
|
|
29
|
+
|
|
30
|
+
Mirrors the JS ConsoleLogger class.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
category: str | None = None,
|
|
36
|
+
level: str | None = None,
|
|
37
|
+
formatter: Any = None,
|
|
38
|
+
stdlib_logger: Any = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
super().__init__(category, level)
|
|
41
|
+
self.formatter = formatter or JSONFormatter()
|
|
42
|
+
# stdlib_logger is kept for test injection (CachingConsole).
|
|
43
|
+
# When None we write directly to sys.stdout — no stdlib handler chain.
|
|
44
|
+
self._sink = stdlib_logger # None means use sys.stdout
|
|
45
|
+
|
|
46
|
+
# ------------------------------------------------------------------
|
|
47
|
+
# Emit
|
|
48
|
+
# ------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
def _emit(self, level_name: str, message: str, meta: Any = None) -> None:
|
|
51
|
+
formatted = self.formatter.format(
|
|
52
|
+
datetime.now(timezone.utc), self.category, level_name, message, meta
|
|
53
|
+
)
|
|
54
|
+
if self._sink is not None:
|
|
55
|
+
stdlib_level = LoggerLevel.STDLIB[level_name]
|
|
56
|
+
if self._sink.isEnabledFor(stdlib_level):
|
|
57
|
+
self._sink.log(stdlib_level, formatted)
|
|
58
|
+
else:
|
|
59
|
+
print(formatted, file=sys.stdout)
|
|
60
|
+
|
|
61
|
+
def log(self, level: str, message: str, meta: Any = None) -> None:
|
|
62
|
+
if self.is_level_enabled(level):
|
|
63
|
+
self._emit(level, message, meta)
|
|
64
|
+
|
|
65
|
+
def debug(self, message: str, meta: Any = None) -> None:
|
|
66
|
+
if self.is_debug_enabled():
|
|
67
|
+
self._emit(LoggerLevel.DEBUG, message, meta)
|
|
68
|
+
|
|
69
|
+
def verbose(self, message: str, meta: Any = None) -> None:
|
|
70
|
+
if self.is_verbose_enabled():
|
|
71
|
+
self._emit(LoggerLevel.VERBOSE, message, meta)
|
|
72
|
+
|
|
73
|
+
def info(self, message: str, meta: Any = None) -> None:
|
|
74
|
+
if self.is_info_enabled():
|
|
75
|
+
self._emit(LoggerLevel.INFO, message, meta)
|
|
76
|
+
|
|
77
|
+
def warn(self, message: str, meta: Any = None) -> None:
|
|
78
|
+
if self.is_warn_enabled():
|
|
79
|
+
self._emit(LoggerLevel.WARN, message, meta)
|
|
80
|
+
|
|
81
|
+
def error(self, message: str, meta: Any = None) -> None:
|
|
82
|
+
if self.is_error_enabled():
|
|
83
|
+
self._emit(LoggerLevel.ERROR, message, meta)
|
|
84
|
+
|
|
85
|
+
def fatal(self, message: str, meta: Any = None) -> None:
|
|
86
|
+
if self.is_fatal_enabled():
|
|
87
|
+
self._emit(LoggerLevel.FATAL, message, meta)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
logger.delegating_logger — Logger that delegates all calls to a provider.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
__author__ = "Craig Parravicini"
|
|
8
|
+
__collaborators__ = ["Claude (Anthropic)"]
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from logger.logger import Logger
|
|
13
|
+
from logger.logger_level import LoggerLevel
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DelegatingLogger(Logger):
|
|
17
|
+
"""
|
|
18
|
+
Wraps a provider logger, forwarding all calls to it.
|
|
19
|
+
|
|
20
|
+
Mirrors the JS DelegatingLogger class.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, provider: Logger) -> None:
|
|
24
|
+
if provider is None:
|
|
25
|
+
raise ValueError("provider is required")
|
|
26
|
+
# Don't call super().__init__() with a level — level lives on the provider
|
|
27
|
+
self.provider = provider
|
|
28
|
+
self.category = provider.category
|
|
29
|
+
|
|
30
|
+
def set_level(self, level: str) -> None:
|
|
31
|
+
self.provider.set_level(level)
|
|
32
|
+
|
|
33
|
+
def is_level_enabled(self, level: str) -> bool:
|
|
34
|
+
return self.provider.is_level_enabled(level)
|
|
35
|
+
|
|
36
|
+
def is_fatal_enabled(self) -> bool:
|
|
37
|
+
return self.provider.is_fatal_enabled()
|
|
38
|
+
|
|
39
|
+
def is_error_enabled(self) -> bool:
|
|
40
|
+
return self.provider.is_error_enabled()
|
|
41
|
+
|
|
42
|
+
def is_warn_enabled(self) -> bool:
|
|
43
|
+
return self.provider.is_warn_enabled()
|
|
44
|
+
|
|
45
|
+
def is_info_enabled(self) -> bool:
|
|
46
|
+
return self.provider.is_info_enabled()
|
|
47
|
+
|
|
48
|
+
def is_verbose_enabled(self) -> bool:
|
|
49
|
+
return self.provider.is_verbose_enabled()
|
|
50
|
+
|
|
51
|
+
def is_debug_enabled(self) -> bool:
|
|
52
|
+
return self.provider.is_debug_enabled()
|
|
53
|
+
|
|
54
|
+
def log(self, level: str, message: str, meta: Any = None) -> None:
|
|
55
|
+
self.provider.log(level, message, meta)
|
|
56
|
+
|
|
57
|
+
def debug(self, message: str, meta: Any = None) -> None:
|
|
58
|
+
self.provider.debug(message, meta)
|
|
59
|
+
|
|
60
|
+
def verbose(self, message: str, meta: Any = None) -> None:
|
|
61
|
+
self.provider.verbose(message, meta)
|
|
62
|
+
|
|
63
|
+
def info(self, message: str, meta: Any = None) -> None:
|
|
64
|
+
self.provider.info(message, meta)
|
|
65
|
+
|
|
66
|
+
def warn(self, message: str, meta: Any = None) -> None:
|
|
67
|
+
self.provider.warn(message, meta)
|
|
68
|
+
|
|
69
|
+
def error(self, message: str, meta: Any = None) -> None:
|
|
70
|
+
self.provider.error(message, meta)
|
|
71
|
+
|
|
72
|
+
def fatal(self, message: str, meta: Any = None) -> None:
|
|
73
|
+
self.provider.fatal(message, meta)
|
logger/json_formatter.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
logger.json_formatter — Formats log entries as JSON strings.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
__author__ = "Craig Parravicini"
|
|
8
|
+
__collaborators__ = ["Claude (Anthropic)"]
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from common import is_plain_object
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JSONFormatter:
|
|
18
|
+
"""
|
|
19
|
+
Formats log entries as JSON strings.
|
|
20
|
+
|
|
21
|
+
Output: {"level": ..., "message": ..., "timestamp": ..., "category": ..., [meta fields]}
|
|
22
|
+
|
|
23
|
+
Mirrors the JS JSONFormatter class.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def format(
|
|
27
|
+
self,
|
|
28
|
+
timestamp: datetime,
|
|
29
|
+
category: str,
|
|
30
|
+
level: str,
|
|
31
|
+
message: str,
|
|
32
|
+
meta: Any = None,
|
|
33
|
+
) -> str:
|
|
34
|
+
record: dict[str, Any] = {
|
|
35
|
+
"level": level,
|
|
36
|
+
"message": message,
|
|
37
|
+
"timestamp": timestamp.isoformat(),
|
|
38
|
+
"category": category,
|
|
39
|
+
}
|
|
40
|
+
if is_plain_object(meta):
|
|
41
|
+
record.update(meta)
|
|
42
|
+
elif meta is not None:
|
|
43
|
+
record["meta"] = meta
|
|
44
|
+
return json.dumps(record)
|
logger/logger.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
logger.logger — Base logger with level-gated methods.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
__author__ = "Craig Parravicini"
|
|
8
|
+
__collaborators__ = ["Claude (Anthropic)"]
|
|
9
|
+
|
|
10
|
+
from logger.logger_level import LoggerLevel
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Logger:
|
|
14
|
+
"""
|
|
15
|
+
Base logger. Stores severity level and provides is_*_enabled() guards.
|
|
16
|
+
|
|
17
|
+
severity_level: int from LoggerLevel.ENUMS (fatal=0, debug=5).
|
|
18
|
+
A logger with level X enables all methods whose ENUMS value is <= X.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
DEFAULT_CATEGORY = "ROOT"
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
category: str | None = None,
|
|
26
|
+
level: str | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.category: str = category or Logger.DEFAULT_CATEGORY
|
|
29
|
+
self._levels = LoggerLevel.ENUMS
|
|
30
|
+
self.set_level(level or LoggerLevel.INFO)
|
|
31
|
+
|
|
32
|
+
def set_level(self, level: str) -> None:
|
|
33
|
+
self._level_name = level or LoggerLevel.INFO
|
|
34
|
+
self._severity = self._levels.get(self._level_name, self._levels[LoggerLevel.INFO])
|
|
35
|
+
|
|
36
|
+
def is_level_enabled(self, level: str) -> bool:
|
|
37
|
+
return self._levels.get(level, -1) <= self._severity
|
|
38
|
+
|
|
39
|
+
def is_fatal_enabled(self) -> bool:
|
|
40
|
+
return self.is_level_enabled(LoggerLevel.FATAL)
|
|
41
|
+
|
|
42
|
+
def is_error_enabled(self) -> bool:
|
|
43
|
+
return self.is_level_enabled(LoggerLevel.ERROR)
|
|
44
|
+
|
|
45
|
+
def is_warn_enabled(self) -> bool:
|
|
46
|
+
return self.is_level_enabled(LoggerLevel.WARN)
|
|
47
|
+
|
|
48
|
+
def is_info_enabled(self) -> bool:
|
|
49
|
+
return self.is_level_enabled(LoggerLevel.INFO)
|
|
50
|
+
|
|
51
|
+
def is_verbose_enabled(self) -> bool:
|
|
52
|
+
return self.is_level_enabled(LoggerLevel.VERBOSE)
|
|
53
|
+
|
|
54
|
+
def is_debug_enabled(self) -> bool:
|
|
55
|
+
return self.is_level_enabled(LoggerLevel.DEBUG)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
logger.logger_category_cache — Simple dict cache for resolved logger levels.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
__author__ = "Craig Parravicini"
|
|
8
|
+
__collaborators__ = ["Claude (Anthropic)"]
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LoggerCategoryCache:
|
|
14
|
+
"""
|
|
15
|
+
Caches the resolved log level string for each category path.
|
|
16
|
+
|
|
17
|
+
Mirrors the JS LoggerCategoryCache class.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._cache: dict[str, str] = {}
|
|
22
|
+
|
|
23
|
+
def get(self, key: str) -> str | None:
|
|
24
|
+
return self._cache.get(key)
|
|
25
|
+
|
|
26
|
+
def put(self, key: str, level: str) -> None:
|
|
27
|
+
self._cache[key] = level
|
logger/logger_factory.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
logger.logger_factory — Factory for creating ConfigurableLoggers.
|
|
3
|
+
|
|
4
|
+
LoggerFactory.get_logger(category) creates a logger whose level is read from
|
|
5
|
+
the active config at logging.level.<category> (dot hierarchy).
|
|
6
|
+
|
|
7
|
+
The module-level loggerFactory singleton is usable with no setup — it reads
|
|
8
|
+
from the config module's default config.
|
|
9
|
+
|
|
10
|
+
Format selection:
|
|
11
|
+
logging.format=json → JSONFormatter (default in non-browser contexts)
|
|
12
|
+
logging.format=text → PlainTextFormatter
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
__author__ = "Craig Parravicini"
|
|
18
|
+
__collaborators__ = ["Claude (Anthropic)"]
|
|
19
|
+
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from logger.configurable_logger import ConfigurableLogger
|
|
23
|
+
from logger.console_logger import ConsoleLogger
|
|
24
|
+
from logger.logger_category_cache import LoggerCategoryCache
|
|
25
|
+
from logger.json_formatter import JSONFormatter
|
|
26
|
+
from logger.plain_text_formatter import PlainTextFormatter
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_shared_cache = LoggerCategoryCache()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LoggerFactory:
|
|
33
|
+
"""
|
|
34
|
+
Creates ConfigurableLogger instances wired to a config source.
|
|
35
|
+
|
|
36
|
+
Mirrors the JS LoggerFactory class.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
config: Any = None,
|
|
42
|
+
cache: LoggerCategoryCache | None = None,
|
|
43
|
+
config_path: str | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
if config is None:
|
|
46
|
+
from config import config as _default_config
|
|
47
|
+
config = _default_config
|
|
48
|
+
self.config = config
|
|
49
|
+
# Each factory gets its own cache by default to avoid cross-instance pollution.
|
|
50
|
+
# Pass a shared cache explicitly when you want level caching across factories.
|
|
51
|
+
self.cache = cache if cache is not None else LoggerCategoryCache()
|
|
52
|
+
self.config_path = config_path or ConfigurableLogger.DEFAULT_CONFIG_PATH
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------------
|
|
55
|
+
# Instance method
|
|
56
|
+
# ------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def get_logger(self, category: Any = None) -> ConfigurableLogger:
|
|
59
|
+
"""
|
|
60
|
+
Create a ConfigurableLogger for the given category.
|
|
61
|
+
|
|
62
|
+
category may be:
|
|
63
|
+
- a string (used directly)
|
|
64
|
+
- an object with a .qualifier, .name, or .__class__.__name__ attribute
|
|
65
|
+
- None (uses ROOT category)
|
|
66
|
+
"""
|
|
67
|
+
cat = self._resolve_category(category)
|
|
68
|
+
formatter = self._get_formatter()
|
|
69
|
+
provider = ConsoleLogger(category=cat, formatter=formatter)
|
|
70
|
+
return ConfigurableLogger(
|
|
71
|
+
config=self.config,
|
|
72
|
+
provider=provider,
|
|
73
|
+
category=cat,
|
|
74
|
+
config_path=self.config_path,
|
|
75
|
+
cache=self.cache,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def _get_formatter(self) -> Any:
|
|
79
|
+
fmt = "json"
|
|
80
|
+
if self.config.has("logging.format"):
|
|
81
|
+
fmt = self.config.get("logging.format")
|
|
82
|
+
return PlainTextFormatter() if fmt.lower() == "text" else JSONFormatter()
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _resolve_category(category: Any) -> str:
|
|
86
|
+
if isinstance(category, str):
|
|
87
|
+
return category
|
|
88
|
+
if category is None:
|
|
89
|
+
return ""
|
|
90
|
+
return (
|
|
91
|
+
getattr(category, "qualifier", None)
|
|
92
|
+
or getattr(category, "name", None)
|
|
93
|
+
or type(category).__name__
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
# Static convenience
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def get_logger_static(
|
|
102
|
+
category: Any = None,
|
|
103
|
+
config: Any = None,
|
|
104
|
+
config_path: str | None = None,
|
|
105
|
+
cache: LoggerCategoryCache | None = None,
|
|
106
|
+
) -> ConfigurableLogger:
|
|
107
|
+
"""
|
|
108
|
+
Static factory method — matches JS LoggerFactory.getLogger() signature.
|
|
109
|
+
"""
|
|
110
|
+
factory = LoggerFactory(config=config, cache=cache, config_path=config_path)
|
|
111
|
+
return factory.get_logger(category)
|
logger/logger_level.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
logger.logger_level — Log level constants.
|
|
3
|
+
|
|
4
|
+
Level ordering mirrors the JS LoggerLevel (fatal=0, debug=5, higher = more verbose).
|
|
5
|
+
Python's stdlib uses the inverse (DEBUG=10, CRITICAL=50, higher = more severe).
|
|
6
|
+
|
|
7
|
+
We define our own ENUMS dict for the is_*_enabled() comparisons (lower = more
|
|
8
|
+
severe, same as JS), AND map each level to the corresponding stdlib int so
|
|
9
|
+
ConsoleLogger can pass the right level to the underlying stdlib logger.
|
|
10
|
+
|
|
11
|
+
JS → Python stdlib mapping:
|
|
12
|
+
fatal → CRITICAL (50)
|
|
13
|
+
error → ERROR (40)
|
|
14
|
+
warn → WARNING (30)
|
|
15
|
+
info → INFO (20)
|
|
16
|
+
verbose → 15 (custom, between DEBUG and INFO)
|
|
17
|
+
debug → DEBUG (10)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
__author__ = "Craig Parravicini"
|
|
23
|
+
__collaborators__ = ["Claude (Anthropic)"]
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
|
|
27
|
+
# Register the custom VERBOSE level so stdlib logging knows about it
|
|
28
|
+
VERBOSE_INT = 15
|
|
29
|
+
logging.addLevelName(VERBOSE_INT, "VERBOSE")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LoggerLevel:
|
|
33
|
+
"""
|
|
34
|
+
Level constants for the alt-python logger.
|
|
35
|
+
|
|
36
|
+
ENUMS: name → severity int (fatal=0, debug=5) — used for is_*_enabled() comparisons.
|
|
37
|
+
STDLIB: name → Python stdlib logging int — used when emitting via stdlib.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
FATAL = "fatal"
|
|
41
|
+
ERROR = "error"
|
|
42
|
+
WARN = "warn"
|
|
43
|
+
INFO = "info"
|
|
44
|
+
VERBOSE = "verbose"
|
|
45
|
+
DEBUG = "debug"
|
|
46
|
+
|
|
47
|
+
# Severity order: lower number = more severe (same as JS)
|
|
48
|
+
ENUMS: dict[str, int] = {
|
|
49
|
+
"fatal": 0,
|
|
50
|
+
"error": 1,
|
|
51
|
+
"warn": 2,
|
|
52
|
+
"info": 3,
|
|
53
|
+
"verbose": 4,
|
|
54
|
+
"debug": 5,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Maps to Python stdlib logging level ints
|
|
58
|
+
STDLIB: dict[str, int] = {
|
|
59
|
+
"fatal": logging.CRITICAL,
|
|
60
|
+
"error": logging.ERROR,
|
|
61
|
+
"warn": logging.WARNING,
|
|
62
|
+
"info": logging.INFO,
|
|
63
|
+
"verbose": VERBOSE_INT,
|
|
64
|
+
"debug": logging.DEBUG,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def from_stdlib(stdlib_level: int) -> str:
|
|
69
|
+
"""Convert a stdlib logging int back to a LoggerLevel name."""
|
|
70
|
+
for name, val in LoggerLevel.STDLIB.items():
|
|
71
|
+
if val == stdlib_level:
|
|
72
|
+
return name
|
|
73
|
+
return LoggerLevel.INFO
|
logger/multi_logger.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
logger.multi_logger — Fan-out logger that writes to multiple loggers simultaneously.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
__author__ = "Craig Parravicini"
|
|
8
|
+
__collaborators__ = ["Claude (Anthropic)"]
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from logger.logger import Logger
|
|
13
|
+
from logger.logger_level import LoggerLevel
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MultiLogger(Logger):
|
|
17
|
+
"""
|
|
18
|
+
Fans out log calls to multiple child loggers.
|
|
19
|
+
|
|
20
|
+
Mirrors the JS MultiLogger class.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
loggers: list[Logger] | None = None,
|
|
26
|
+
category: str | None = None,
|
|
27
|
+
level: str | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
# Assign loggers BEFORE calling super().__init__() because set_level()
|
|
30
|
+
# iterates self.loggers and is called during base class construction.
|
|
31
|
+
self.loggers: list[Logger] = list(loggers) if loggers else []
|
|
32
|
+
super().__init__(category, level)
|
|
33
|
+
|
|
34
|
+
def set_level(self, level: str) -> None:
|
|
35
|
+
super().set_level(level)
|
|
36
|
+
for lg in self.loggers:
|
|
37
|
+
lg.set_level(level)
|
|
38
|
+
|
|
39
|
+
def log(self, level: str, message: str, meta: Any = None) -> None:
|
|
40
|
+
if self.is_level_enabled(level):
|
|
41
|
+
for lg in self.loggers:
|
|
42
|
+
lg.log(level, message, meta)
|
|
43
|
+
|
|
44
|
+
def debug(self, message: str, meta: Any = None) -> None:
|
|
45
|
+
self.log(LoggerLevel.DEBUG, message, meta)
|
|
46
|
+
|
|
47
|
+
def verbose(self, message: str, meta: Any = None) -> None:
|
|
48
|
+
self.log(LoggerLevel.VERBOSE, message, meta)
|
|
49
|
+
|
|
50
|
+
def info(self, message: str, meta: Any = None) -> None:
|
|
51
|
+
self.log(LoggerLevel.INFO, message, meta)
|
|
52
|
+
|
|
53
|
+
def warn(self, message: str, meta: Any = None) -> None:
|
|
54
|
+
self.log(LoggerLevel.WARN, message, meta)
|
|
55
|
+
|
|
56
|
+
def error(self, message: str, meta: Any = None) -> None:
|
|
57
|
+
self.log(LoggerLevel.ERROR, message, meta)
|
|
58
|
+
|
|
59
|
+
def fatal(self, message: str, meta: Any = None) -> None:
|
|
60
|
+
self.log(LoggerLevel.FATAL, message, meta)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
logger.plain_text_formatter — Formats log entries as plain text.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
__author__ = "Craig Parravicini"
|
|
8
|
+
__collaborators__ = ["Claude (Anthropic)"]
|
|
9
|
+
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PlainTextFormatter:
|
|
15
|
+
"""
|
|
16
|
+
Formats log entries as plain text.
|
|
17
|
+
|
|
18
|
+
Output: timestamp:category:level:message[meta]
|
|
19
|
+
|
|
20
|
+
Mirrors the JS PlainTextFormatter class.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def format(
|
|
24
|
+
self,
|
|
25
|
+
timestamp: datetime,
|
|
26
|
+
category: str,
|
|
27
|
+
level: str,
|
|
28
|
+
message: str,
|
|
29
|
+
meta: Any = None,
|
|
30
|
+
) -> str:
|
|
31
|
+
suffix = str(meta) if meta is not None else ""
|
|
32
|
+
return f"{timestamp.isoformat()}:{category}:{level}:{message}{suffix}"
|