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.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: alt-python-logger
3
+ Version: 1.0.0
4
+ Summary: Spring-inspired config-driven logger for Python
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: alt-python-common
7
+ Requires-Dist: alt-python-config
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
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
@@ -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)
@@ -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
@@ -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}"