alt-python-logger 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+
2
+ # ── GSD baseline (auto-generated) ──
3
+ .gsd
4
+ .DS_Store
5
+ Thumbs.db
6
+ *.swp
7
+ *.swo
8
+ *~
9
+ .idea/
10
+ .vscode/
11
+ *.code-workspace
12
+ .env
13
+ .env.*
14
+ !.env.example
15
+ node_modules/
16
+ .next/
17
+ dist/
18
+ build/
19
+ __pycache__/
20
+ *.pyc
21
+ .venv/
22
+ venv/
23
+ target/
24
+ vendor/
25
+ *.log
26
+ coverage/
27
+ .cache/
28
+ tmp/
29
+ .bg_shell
30
+ /.bg-shell/
@@ -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,427 @@
1
+ # logger
2
+
3
+ [![Language](https://img.shields.io/badge/language-Python-3776ab.svg)](https://www.python.org/)
4
+ [![Python](https://img.shields.io/badge/python-3.12%2B-blue.svg)](https://www.python.org/downloads/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ Config-driven, category-based structured logging for Python. Log levels are set
8
+ in config under `logging.level` using a dot-hierarchy that mirrors Python's
9
+ `logging` namespace. The library wraps Python's stdlib `logging` as the emit
10
+ backend, so all existing stdlib handlers (file, rotating, socket, syslog) work
11
+ without any extra configuration.
12
+
13
+ Port of [`@alt-javascript/logger`](https://github.com/alt-javascript/boot/tree/main/packages/logger)
14
+ to Python.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ uv add logger # or: pip install logger
20
+ ```
21
+
22
+ Requires Python 3.12+ and the `config` package (workspace dependency).
23
+
24
+ ## Quick Start
25
+
26
+ ```python
27
+ from logger import logger_factory
28
+
29
+ log = logger_factory.get_logger("com.example.MyService")
30
+
31
+ log.fatal("Service crashed")
32
+ log.error("Request failed", {"status": 500, "path": "/api/users"})
33
+ log.warn("Retry attempt 3")
34
+ log.info("Application started")
35
+ log.verbose("Processing record 42 of 1000")
36
+ log.debug("SQL: SELECT * FROM users WHERE id = ?")
37
+ ```
38
+
39
+ Zero setup — `logger_factory` reads from the default `config` singleton, which
40
+ discovers `application.yaml` (or `.json`, `.properties`, `.env`) from the
41
+ current working directory.
42
+
43
+ ## Log Levels
44
+
45
+ From least to most verbose, with the internal severity integer used for
46
+ `is_*_enabled()` comparisons:
47
+
48
+ | Level | Severity | Python stdlib int | Method |
49
+ |---|---|---|---|
50
+ | `fatal` | 0 (most severe) | 50 (CRITICAL) | `log.fatal(msg, meta=None)` |
51
+ | `error` | 1 | 40 (ERROR) | `log.error(msg, meta=None)` |
52
+ | `warn` | 2 | 30 (WARNING) | `log.warn(msg, meta=None)` |
53
+ | `info` | 3 | 20 (INFO) | `log.info(msg, meta=None)` |
54
+ | `verbose` | 4 | 15 (custom) | `log.verbose(msg, meta=None)` |
55
+ | `debug` | 5 (least severe) | 10 (DEBUG) | `log.debug(msg, meta=None)` |
56
+
57
+ A logger set to level `info` enables `fatal`, `error`, `warn`, and `info`. It
58
+ suppresses `verbose` and `debug`. See
59
+ [ADR-007](../../docs/decisions/ADR-007-logger-level-ordering.md) for why the
60
+ ordering is preserved from the JS source.
61
+
62
+ ## Config-Driven Levels
63
+
64
+ Set levels in config under `logging.level`. The hierarchy uses nested dicts —
65
+ each node corresponds to a dot segment of the category name.
66
+
67
+ ```yaml
68
+ # application.yaml
69
+ logging:
70
+ level:
71
+ /: warn # root level — applies to all loggers
72
+ com:
73
+ example: debug # com.example.* → debug
74
+ noisy:
75
+ handler: warn # com.example.noisy.handler → warn
76
+ format: text # text or json (default: json)
77
+ ```
78
+
79
+ The lookup walks the category's dot segments from left to right, applying the
80
+ most-specific match found:
81
+
82
+ ```python
83
+ logger_factory.get_logger("com.example.MyService") # → debug (from com.example)
84
+ logger_factory.get_logger("com.example.noisy.handler") # → warn (from com.example.noisy.handler)
85
+ logger_factory.get_logger("other.pkg.Handler") # → warn (from root /)
86
+ ```
87
+
88
+ **Config key format:** Level keys must be **nested dicts** — flat dotted keys
89
+ like `"com.example": debug` are not recognised by the segment walker. See
90
+ [ADR-008](../../docs/decisions/ADR-008-config-level-keys.md).
91
+
92
+ ## Level Guards
93
+
94
+ Use guards before constructing expensive log arguments:
95
+
96
+ ```python
97
+ if log.is_debug_enabled():
98
+ log.debug(f"Query plan: {explain_query(sql)}")
99
+ ```
100
+
101
+ | Method | Returns `True` when... |
102
+ |---|---|
103
+ | `is_fatal_enabled()` | level is `fatal` |
104
+ | `is_error_enabled()` | level is `fatal` or `error` |
105
+ | `is_warn_enabled()` | level is `fatal`, `error`, or `warn` |
106
+ | `is_info_enabled()` | level is `fatal` through `info` |
107
+ | `is_verbose_enabled()` | level is `fatal` through `verbose` |
108
+ | `is_debug_enabled()` | any level (level is `debug`) |
109
+
110
+ ## Log Formats
111
+
112
+ ### JSON (default)
113
+
114
+ ```python
115
+ {"level": "info", "message": "Started", "timestamp": "2026-01-15T12:00:00+00:00", "category": "com.example.MyService"}
116
+ ```
117
+
118
+ Pass a plain dict as `meta` to merge fields into the JSON object:
119
+
120
+ ```python
121
+ log.info("Request complete", {"status": 200, "duration_ms": 42})
122
+ # => {"level":"info","message":"Request complete","timestamp":"...","category":"...","status":200,"duration_ms":42}
123
+ ```
124
+
125
+ Pass any other value as `meta` to include it under a `"meta"` key:
126
+
127
+ ```python
128
+ log.error("Unexpected value", "some_string")
129
+ # => {"level":"error","message":"Unexpected value","timestamp":"...","category":"...","meta":"some_string"}
130
+ ```
131
+
132
+ ### Plain text
133
+
134
+ ```
135
+ 2026-01-15T12:00:00+00:00:com.example.MyService:info:Application started
136
+ ```
137
+
138
+ Set `logging.format: text` in config to enable plain text output.
139
+
140
+ ## API Reference
141
+
142
+ ### `LoggerFactory`
143
+
144
+ Main factory class. Creates `ConfigurableLogger` instances wired to a config
145
+ source.
146
+
147
+ #### Constructor
148
+
149
+ ```python
150
+ LoggerFactory(config=None, cache=None, config_path=None)
151
+ ```
152
+
153
+ | Parameter | Type | Description |
154
+ |---|---|---|
155
+ | `config` | config-like | Config source. Default: module-level `config` singleton. |
156
+ | `cache` | `LoggerCategoryCache \| None` | Level cache. Default: fresh per-instance cache (see [ADR-009](../../docs/decisions/ADR-009-per-instance-logger-cache.md)). |
157
+ | `config_path` | `str` | Root path for level lookup. Default: `"logging.level"`. |
158
+
159
+ #### `factory.get_logger(category)`
160
+
161
+ Returns a `ConfigurableLogger` for the given category.
162
+
163
+ `category` may be:
164
+ - A string: `"com.example.MyService"`
165
+ - A class instance with a `qualifier` attribute
166
+ - A class instance (uses `type(instance).__name__`)
167
+ - `None` (uses `"ROOT"`)
168
+
169
+ ```python
170
+ log = factory.get_logger("com.example.MyService")
171
+ log = factory.get_logger(my_service_instance)
172
+ ```
173
+
174
+ #### `LoggerFactory.get_logger_static(category, config, config_path, cache)`
175
+
176
+ Static convenience method equivalent to constructing a factory and calling
177
+ `get_logger()`.
178
+
179
+ ---
180
+
181
+ ### `ConfigurableLogger`
182
+
183
+ A `DelegatingLogger` whose level is set from config at construction time.
184
+
185
+ #### `ConfigurableLogger.get_logger_level(category, config_path, config, cache)`
186
+
187
+ Static method. Walks the category's dot segments and returns the most-specific
188
+ level found in config. Falls back to `"info"` if nothing is found.
189
+
190
+ The root level is read from `{config_path}./` (e.g. `logging.level./`).
191
+ Category segment levels are read from `{config_path}.{segment}` (e.g.
192
+ `logging.level.com`, `logging.level.com.example`).
193
+
194
+ ---
195
+
196
+ ### `Logger`
197
+
198
+ Base class. Stores the severity level and provides `is_*_enabled()` guards.
199
+
200
+ #### `Logger(category=None, level=None)`
201
+
202
+ | Parameter | Default | Description |
203
+ |---|---|---|
204
+ | `category` | `"ROOT"` | Logger category name |
205
+ | `level` | `"info"` | Initial log level |
206
+
207
+ #### `logger.set_level(level)`
208
+
209
+ Change the level at runtime. Accepts any key from `LoggerLevel.ENUMS`.
210
+
211
+ ---
212
+
213
+ ### `ConsoleLogger`
214
+
215
+ Logger that emits via a stdlib `logging.Logger`. Extends `Logger`.
216
+
217
+ ```python
218
+ ConsoleLogger(category=None, level=None, formatter=None, stdlib_logger=None)
219
+ ```
220
+
221
+ | Parameter | Description |
222
+ |---|---|
223
+ | `category` | Logger category name |
224
+ | `level` | Initial level |
225
+ | `formatter` | `JSONFormatter` (default) or `PlainTextFormatter` |
226
+ | `stdlib_logger` | stdlib `logging.Logger` instance, or `CachingConsole` for tests |
227
+
228
+ ---
229
+
230
+ ### `DelegatingLogger`
231
+
232
+ Wraps a `provider` logger and forwards all calls to it.
233
+
234
+ ```python
235
+ DelegatingLogger(provider)
236
+ ```
237
+
238
+ Raises `ValueError` if `provider` is `None`.
239
+
240
+ ---
241
+
242
+ ### `MultiLogger`
243
+
244
+ Fans out log calls to multiple child loggers.
245
+
246
+ ```python
247
+ MultiLogger(loggers=None, category=None, level=None)
248
+ ```
249
+
250
+ `set_level()` propagates to all child loggers.
251
+
252
+ ```python
253
+ from logger import MultiLogger, ConsoleLogger
254
+
255
+ ml = MultiLogger([console_logger, file_logger], level="info")
256
+ ml.info("Written to both")
257
+ ```
258
+
259
+ ---
260
+
261
+ ### `JSONFormatter`
262
+
263
+ ```python
264
+ formatter.format(timestamp, category, level, message, meta=None)
265
+ ```
266
+
267
+ Returns a JSON string. Dict `meta` is merged into the top-level object; other
268
+ types are stored under `"meta"`.
269
+
270
+ ---
271
+
272
+ ### `PlainTextFormatter`
273
+
274
+ ```python
275
+ formatter.format(timestamp, category, level, message, meta=None)
276
+ ```
277
+
278
+ Returns `"{timestamp}:{category}:{level}:{message}{meta}"`.
279
+
280
+ ---
281
+
282
+ ### `LoggerCategoryCache`
283
+
284
+ Simple dict cache for resolved level strings.
285
+
286
+ | Method | Description |
287
+ |---|---|
288
+ | `get(key)` | Returns the cached level string or `None`. |
289
+ | `put(key, level)` | Stores a level string. |
290
+
291
+ ---
292
+
293
+ ### `LoggerLevel`
294
+
295
+ Level constants and mappings.
296
+
297
+ ```python
298
+ from logger import LoggerLevel
299
+
300
+ LoggerLevel.FATAL # "fatal"
301
+ LoggerLevel.ERROR # "error"
302
+ LoggerLevel.WARN # "warn"
303
+ LoggerLevel.INFO # "info"
304
+ LoggerLevel.VERBOSE # "verbose"
305
+ LoggerLevel.DEBUG # "debug"
306
+
307
+ LoggerLevel.ENUMS # {"fatal": 0, ..., "debug": 5}
308
+ LoggerLevel.STDLIB # {"fatal": 50, ..., "debug": 10}
309
+ ```
310
+
311
+ ---
312
+
313
+ ### `CachingConsole`
314
+
315
+ In-memory log sink for test fixtures. Pass as `stdlib_logger` to
316
+ `ConsoleLogger`.
317
+
318
+ ```python
319
+ from logger import ConsoleLogger, CachingConsole, PlainTextFormatter
320
+
321
+ sink = CachingConsole()
322
+ log = ConsoleLogger(
323
+ category="test",
324
+ level="debug",
325
+ formatter=PlainTextFormatter(),
326
+ stdlib_logger=sink,
327
+ )
328
+
329
+ log.info("captured")
330
+ assert "captured" in sink.messages[0][1]
331
+
332
+ sink.clear()
333
+ ```
334
+
335
+ `sink.messages` is a list of `(level_int, formatted_string)` tuples.
336
+
337
+ ---
338
+
339
+ ## All Exports
340
+
341
+ ```python
342
+ from logger import (
343
+ LoggerLevel,
344
+ Logger,
345
+ ConsoleLogger,
346
+ DelegatingLogger,
347
+ ConfigurableLogger,
348
+ LoggerCategoryCache,
349
+ LoggerFactory,
350
+ JSONFormatter,
351
+ PlainTextFormatter,
352
+ CachingConsole,
353
+ MultiLogger,
354
+ logger_factory, # module-level singleton
355
+ )
356
+ ```
357
+
358
+ ## Testing
359
+
360
+ Use `CachingConsole` to capture log output in tests without writing to stdout:
361
+
362
+ ```python
363
+ from config import EphemeralConfig
364
+ from logger import (
365
+ LoggerFactory, ConsoleLogger, CachingConsole, PlainTextFormatter
366
+ )
367
+
368
+ def test_logs_at_correct_level():
369
+ cfg = EphemeralConfig({"logging": {"level": {"/": "debug"}}})
370
+ sink = CachingConsole()
371
+ provider = ConsoleLogger(
372
+ category="test",
373
+ formatter=PlainTextFormatter(),
374
+ stdlib_logger=sink,
375
+ )
376
+ from logger import ConfigurableLogger, LoggerCategoryCache
377
+ log = ConfigurableLogger(
378
+ config=cfg,
379
+ provider=provider,
380
+ category="test",
381
+ cache=LoggerCategoryCache(),
382
+ )
383
+
384
+ log.info("hello")
385
+ assert any("hello" in msg for _, msg in sink.messages)
386
+ ```
387
+
388
+ Alternatively, create a `LoggerFactory` with an `EphemeralConfig` and call
389
+ `get_logger()` — the factory wires `CachingConsole` is not needed for level
390
+ assertions:
391
+
392
+ ```python
393
+ def test_level_from_config():
394
+ cfg = EphemeralConfig({"logging": {"level": {"/": "warn"}}})
395
+ factory = LoggerFactory(config=cfg)
396
+ log = factory.get_logger("my.service")
397
+
398
+ assert log.is_warn_enabled() is True
399
+ assert log.is_info_enabled() is False
400
+ ```
401
+
402
+ ## Troubleshooting
403
+
404
+ **All log messages appear regardless of configured level**
405
+ Check that `logging.level` in your config file uses nested dicts, not flat
406
+ dotted keys. `{"com.example": "debug"}` is not recognised — use
407
+ `{"com": {"example": "debug"}}`. See
408
+ [ADR-008](../../docs/decisions/ADR-008-config-level-keys.md).
409
+
410
+ **`is_debug_enabled()` returns `True` at `info` level**
411
+ This should not happen with the current implementation. If you observe this,
412
+ check whether a stale `LoggerCategoryCache` from a previous factory is being
413
+ passed explicitly. The default per-instance cache is always fresh.
414
+
415
+ **Logger emits to the wrong stdlib handler**
416
+ `ConsoleLogger` creates a stdlib logger named after the category
417
+ (`logging.getLogger(category)`). If your application calls
418
+ `logging.basicConfig()` or attaches handlers to the root logger, those handlers
419
+ will also receive these messages via propagation. Use
420
+ `logging.getLogger("com.example").propagate = False` to suppress if needed.
421
+
422
+ **`logger_factory.get_logger()` always returns `info` level**
423
+ The module-level `logger_factory` uses the module-level `config` singleton, which
424
+ reads from the current working directory. If no `application.yaml` is present and
425
+ `PY_ACTIVE_PROFILES` is not set, the level defaults to `info` (the fallback when
426
+ no `logging.level./` key is found). Create a config file or pass an explicit
427
+ `config` to `LoggerFactory`.
@@ -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