From 7bd7dd50c7adb156ce5fff27bafff64fdef99a73 Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 1 Jun 2026 19:42:03 -0400 Subject: [PATCH] Add `add_log_level()` factory + register `IO`=21 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to f595acc7 (`supervise_run_process`) which called `log.io(...)` for std-stream relay assuming an `IO=21` level existed. Add the registration via a new factory + tests covering both the factory and the new level. `add_log_level()` factory, - One call wires the four (otherwise hand-synced) pieces: - `CUSTOM_LEVELS[NAME]` — drives the `stacklevel` bump in `StackLevelAdapter.log()` + `get_logger()`'s per-level audit. - `logging.addLevelName()` — stdlib name registration. - `STD_PALETTE[NAME]` + `BOLD_PALETTE['bold'][NAME]` — color entries consumed by `get_console_log()`'s `ColoredFormatter` build. - Same-named (lowercase) emit method bound on `StackLevelAdapter` so `log.('msg')` works + `get_logger()`'s per-level method audit passes. - Idempotent: re-registering an existing name is a no-op-ish refresh that won't clobber an already-bound method. - Method binding uses a default-arg `_level=value` so the level int is captured (not late-bound across multiple registrations). `IO=21` level (first user), - Purple. Used by `tractor.trionics._subproc`'s std-stream relay (see f595acc7). - Value 21 picked to sit just ABOVE stdlib `INFO`=20 so it's SHOWN BY DEFAULT at usual `info`/`devx` console levels — a `runtime`=15 relay would be silently filtered (footgun for daemon supervisors whose whole point is visibility). Still distinctly labeled + filterable. Tests (`tests/test_log_sys.py`), - `test_io_custom_level_registered`: validates the IO level is fully wired (`CUSTOM_LEVELS`, `addLevelName`, both palettes, `StackLevelAdapter.io()` callable); emits a record + sanity-asserts `21 >= INFO(20)`. - `test_add_log_level_pluggable`: registers a fresh `XLVL=19` (cyan) via `add_log_level()`, asserts all four wires + the bound `xlog.xlvl()` emit, then try/finally cleans up the module-global mutations so later `get_logger()` audits don't trip on a half-removed level. (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tests/test_log_sys.py | 60 +++++++++++++++++++++++++++++++++++++++++++ tractor/log.py | 57 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/tests/test_log_sys.py b/tests/test_log_sys.py index 1c74ba1e..3870e825 100644 --- a/tests/test_log_sys.py +++ b/tests/test_log_sys.py @@ -162,6 +162,66 @@ def test_implicit_mod_name_applied_for_child( assert submod.log.logger in sub_logs +def test_io_custom_level_registered(): + ''' + The `IO`(21) level (registered via `add_log_level()` at + import, for `tractor.trionics._subproc`'s std-stream relay) + is fully wired and SHOWN BY DEFAULT at `info`-level consoles + since `21 >= INFO(20)`. + + ''' + import logging + assert log.CUSTOM_LEVELS.get('IO') == 21 + assert logging.getLevelName(21) == 'IO' + assert log.STD_PALETTE.get('IO') + assert log.BOLD_PALETTE['bold'].get('IO') + + iolog = log.get_logger('io_lvl_test') + assert callable(getattr(iolog, 'io', None)) + # emit must not raise + iolog.io('hello from the IO level') + + # 21 >= INFO(20) -> shown when console set to `info` + assert 21 >= logging.INFO + + +def test_add_log_level_pluggable(): + ''' + `add_log_level()` is the single pluggable entry-point: one + call wires `CUSTOM_LEVELS` + `addLevelName` + both palettes + + a same-named `StackLevelAdapter` emit method (so + `get_logger()`'s per-level audit passes). + + ''' + import logging + name: str = 'XLVL' + val: int = 19 + try: + log.add_log_level(name, val, 'cyan') + + assert log.CUSTOM_LEVELS[name] == val + assert logging.getLevelName(val) == name + assert log.STD_PALETTE[name] == 'cyan' + assert log.BOLD_PALETTE['bold'][name] == 'bold_cyan' + + # the audit in `get_logger()` (asserts a method per + # `CUSTOM_LEVELS` entry) must still pass. + xlog = log.get_logger('xlvl_test') + emit = getattr(xlog, name.lower(), None) + assert callable(emit) + emit('hello from a plugged-in level') + + finally: + # best-effort cleanup of our module-global mutations so + # later `get_logger()` audits don't see a half-removed + # level. + log.CUSTOM_LEVELS.pop(name, None) + log.STD_PALETTE.pop(name, None) + log.BOLD_PALETTE['bold'].pop(name, None) + if hasattr(log.StackLevelAdapter, name.lower()): + delattr(log.StackLevelAdapter, name.lower()) + + # TODO, moar tests against existing feats: # ------ - ------ # - [ ] color settings? diff --git a/tractor/log.py b/tractor/log.py index 95f313a4..2c6e6b71 100644 --- a/tractor/log.py +++ b/tractor/log.py @@ -262,6 +262,63 @@ class StackLevelAdapter(LoggerAdapter): ) +def add_log_level( + name: str, + value: int, + color: str = 'white', +) -> None: + ''' + Register a new custom log level with `tractor`'s logging + machinery in ONE call — the single pluggable entry-point that + keeps the (otherwise hand-synced) pieces consistent: + + - `CUSTOM_LEVELS[name]` (drives the `stacklevel` bump in + `StackLevelAdapter.log()` + the `get_logger()` audit). + - `logging.addLevelName()` registration. + - `STD_PALETTE`/`BOLD_PALETTE` color entries (consumed when + `get_console_log()` builds its `ColoredFormatter`). + - a same-named (lowercase) emit method bound on + `StackLevelAdapter` so `log.('msg')` works (and so + `get_logger()`'s per-level method audit passes). + + Idempotent: re-registering an existing name is a no-op-ish + refresh (won't clobber an already-bound method). + + ''' + name_up: str = name.upper() + name_lo: str = name.lower() + + CUSTOM_LEVELS[name_up] = value + logging.addLevelName(value, name_up) + STD_PALETTE[name_up] = color + BOLD_PALETTE['bold'][name_up] = f'bold_{color}' + + if not hasattr(StackLevelAdapter, name_lo): + # bind via default-arg so `value` is captured (not + # late-bound); delegates to `.log()` exactly like the + # hand-written level methods above. + def _emit( + self, + msg: str, + *, + _level: int = value, + ) -> None: + return self.log(_level, msg) + + _emit.__name__ = name_lo + _emit.__qualname__ = f'StackLevelAdapter.{name_lo}' + setattr(StackLevelAdapter, name_lo, _emit) + + +# `IO`: child-subproc std-stream relay (see +# `tractor.trionics._subproc`). Value 21 sits just ABOVE +# `INFO`(20) so it's SHOWN BY DEFAULT at the usual `info`/`devx` +# console levels (a `runtime`(15) relay would be silently +# filtered) yet still distinctly labelled/colored + separately +# filterable. +add_log_level('IO', 21, 'purple') + + # TODO IDEAs: # -[ ] move to `.devx.pformat`? # -[ ] do per task-name and actor-name color coding