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.
- alt_python_logger-1.0.0/.gitignore +30 -0
- alt_python_logger-1.0.0/PKG-INFO +7 -0
- alt_python_logger-1.0.0/README.md +427 -0
- alt_python_logger-1.0.0/logger/__init__.py +60 -0
- alt_python_logger-1.0.0/logger/caching_console.py +35 -0
- alt_python_logger-1.0.0/logger/configurable_logger.py +118 -0
- alt_python_logger-1.0.0/logger/console_logger.py +87 -0
- alt_python_logger-1.0.0/logger/delegating_logger.py +73 -0
- alt_python_logger-1.0.0/logger/json_formatter.py +44 -0
- alt_python_logger-1.0.0/logger/logger.py +55 -0
- alt_python_logger-1.0.0/logger/logger_category_cache.py +27 -0
- alt_python_logger-1.0.0/logger/logger_factory.py +111 -0
- alt_python_logger-1.0.0/logger/logger_level.py +73 -0
- alt_python_logger-1.0.0/logger/multi_logger.py +60 -0
- alt_python_logger-1.0.0/logger/plain_text_formatter.py +32 -0
- alt_python_logger-1.0.0/pyproject.toml +23 -0
- alt_python_logger-1.0.0/tests/test_logger_full.py +345 -0
- alt_python_logger-1.0.0/tests/test_logger_primitives.py +164 -0
|
@@ -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,427 @@
|
|
|
1
|
+
# logger
|
|
2
|
+
|
|
3
|
+
[](https://www.python.org/)
|
|
4
|
+
[](https://www.python.org/downloads/)
|
|
5
|
+
[](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
|