From bb0b2ffa3ced84cf6be0a0d5482a384411d0eb00 Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 1 Jun 2026 19:42:03 -0400 Subject: [PATCH 1/6] 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 (cherry picked from commit 7bd7dd50c7adb156ce5fff27bafff64fdef99a73) (cherry picked from commit 93558fe3c91cf7d0570453f8b9b53c3173320f83) --- 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 From eb7ef3f0bfb81f60cb9391b6e248617ee6eb877e Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 1 Jun 2026 20:26:22 -0400 Subject: [PATCH 2/6] Hoist proc-title prefix to `_def_prefix` const MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the sub-actor proc-title prefix a single authoritative constant (`_proctitle._def_prefix`) so the reap-recognition markers and `xontrib` banner pick it up automatically — one place to flip the prefix shape going fwd. Deats, - `_proctitle._def_prefix: str = '_subactor'`. New module-level const consumed by everything that needs to know the prefix. - `set_actor_proctitle(actor, prefix=_def_prefix)`: takes an explicit `prefix` arg (default = the const) so callers can override per-spawn if they want. - Default proc-title format: `'tractor[]'` → `f'{prefix}[]'` i.e. `_subactor[]` by default. - `_testing/_reap.py`: cmdline + comm markers source the prefix from `_proctitle._def_prefix` instead of the hardcoded `'tractor['`. So `_is_tractor_subactor()` tracks the const automatically. - `xontrib/tractor_diag.xsh`: `acli.reap` orphan-mode banner now interpolates the `_TRACTOR_PROC_CMDLINE_MARKERS` tuple directly so the human-readable mode line stays in sync if the prefix shape changes again. (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code (cherry picked from commit 3a45dbd5039aa4559ea38293952415332b795ac9) (cherry picked from commit fd8d39c0ce3a854910c881300b7d2d0f6ac5f210) --- tests/devx/test_proctitle.py | 44 +++++++++++++++++++++--------------- tractor/_testing/_reap.py | 8 ++++--- tractor/devx/_proctitle.py | 15 +++++++++--- xontrib/tractor_diag.xsh | 6 ++--- 4 files changed, 46 insertions(+), 27 deletions(-) diff --git a/tests/devx/test_proctitle.py b/tests/devx/test_proctitle.py index a3478cf3..6004757b 100644 --- a/tests/devx/test_proctitle.py +++ b/tests/devx/test_proctitle.py @@ -8,9 +8,9 @@ after `Actor` construction, so any spawned sub-actor process should: - have `argv[0]` (== `/proc//cmdline`) start with - `tractor[]` - - have `/proc//comm` start with `tractor[` (kernel - truncates to ~15 bytes) + `<_def_prefix>[]` (currently `_subactor[…]`) + - have `/proc//comm` start with `<_def_prefix>[` + (kernel truncates to ~15 bytes) - be detected as a tractor sub-actor by `_is_tractor_subactor(pid)` via the cmdline marker. @@ -27,7 +27,10 @@ import trio import tractor from tractor.runtime._runtime import Actor -from tractor.devx._proctitle import set_actor_proctitle +from tractor.devx._proctitle import ( + set_actor_proctitle, + _def_prefix, +) from tractor._testing._reap import ( _is_tractor_subactor, _read_cmdline, @@ -41,8 +44,9 @@ _non_linux: bool = platform.system() != 'Linux' def test_set_actor_proctitle_format(): ''' `set_actor_proctitle()` returns the canonical - `tractor[]` form and actually mutates - the running proc's title. + `<_def_prefix>[]` form (currently + `_subactor[…]`) and actually mutates the running + proc's title. ''' pytest.importorskip( @@ -60,12 +64,14 @@ def test_set_actor_proctitle_format(): ) title: str = set_actor_proctitle(actor) - # canonical wrapping: `tractor[]`. We - # compare against the runtime-computed `reprol()` - # rather than a hard-coded value so the test stays - # decoupled from `Aid.reprol()`'s internal format - # (currently `@`, but could evolve). - expected: str = f'tractor[{actor.aid.reprol()}]' + # canonical wrapping: `<_def_prefix>[]`. + # We source BOTH the prefix (`_def_prefix`) and the + # runtime-computed `reprol()` rather than hard-coding, + # so the test stays decoupled from the prefix shape + # (flipped to `_subactor` in `3a45dbd5`) AND from + # `Aid.reprol()`'s internal format (currently + # `@`, but could evolve). + expected: str = f'{_def_prefix}[{actor.aid.reprol()}]' assert title == expected # sanity: the actor's name must be in the title # somewhere (so a future `reprol()` change that @@ -140,15 +146,17 @@ def test_subactor_proctitle_visible_via_proc(): ) pid, info = matched[0] - # canonical proctitle prefix in cmdline (full form) - assert info['cmdline'].startswith('tractor[proctitle_boi@'), ( - f'cmdline missing `tractor[proctitle_boi@…]` prefix: ' + # canonical proctitle prefix in cmdline (full form); + # prefix sourced from `_def_prefix` so it tracks the + # `3a45dbd5` flip (`tractor[` -> `_subactor[`). + assert info['cmdline'].startswith(f'{_def_prefix}[proctitle_boi@'), ( + f'cmdline missing `{_def_prefix}[proctitle_boi@…]` prefix: ' f'{info["cmdline"]!r}' ) # comm is kernel-truncated to ~15 bytes — just check the - # `tractor[` prefix made it. - assert info['comm'].startswith('tractor['), ( - f'comm missing `tractor[` prefix: {info["comm"]!r}' + # `<_def_prefix>[` prefix made it. + assert info['comm'].startswith(f'{_def_prefix}['), ( + f'comm missing `{_def_prefix}[` prefix: {info["comm"]!r}' ) # intrinsic-signal detector should match. assert info['is_tractor'] is True diff --git a/tractor/_testing/_reap.py b/tractor/_testing/_reap.py index 96ce3c70..fa4df944 100644 --- a/tractor/_testing/_reap.py +++ b/tractor/_testing/_reap.py @@ -90,7 +90,6 @@ keys are caller-defined). ''' from __future__ import annotations - import os import pathlib import re @@ -99,6 +98,9 @@ import stat import sys import time + +from tractor.devx import _proctitle + # `/dev/shm` is the POSIX-shm filesystem on Linux + FreeBSD. # macOS uses `shm_open` syscalls without a fs-visible path, # so the shm helpers refuse to run there. @@ -230,9 +232,9 @@ def _read_comm(pid: int) -> str: # while `cmdline` for zombies often reads as empty. _TRACTOR_PROC_CMDLINE_MARKERS: tuple[str, ...] = ( 'tractor._child', - 'tractor[', + _proctitle._def_prefix, ) -_TRACTOR_PROC_COMM_MARKER: str = 'tractor[' +_TRACTOR_PROC_COMM_MARKER: str = _proctitle._def_prefix def _is_tractor_subactor(pid: int) -> bool: diff --git a/tractor/devx/_proctitle.py b/tractor/devx/_proctitle.py index d52f860e..c2052850 100644 --- a/tractor/devx/_proctitle.py +++ b/tractor/devx/_proctitle.py @@ -24,7 +24,10 @@ which" at a glance without needing to read full `/proc//cmdline`. Format: - ``tractor[]`` e.g. ``tractor[doggy@1027301b]`` + ``<_def_prefix>[]`` e.g. ``_subactor[doggy@1027301b]`` +(prefix from the `_def_prefix` const, flipped `tractor` -> +`_subactor` so sub-actor procs are visually distinct from the +root in `ps`/`htop` and the reap-recognition markers.) Uses the canonical `Aid.reprol()` form (``@``) so the proc-title matches the @@ -52,7 +55,13 @@ except ImportError: _stp = None -def set_actor_proctitle(actor: 'Actor') -> str | None: +_def_prefix: str = '_subactor' + + +def set_actor_proctitle( + actor: 'Actor', + prefix: str = _def_prefix, +) -> str | None: ''' Set the calling process's proc-title to identify it as a tractor sub-actor. @@ -69,6 +78,6 @@ def set_actor_proctitle(actor: 'Actor') -> str | None: if _stp is None: return None - title: str = f'tractor[{actor.aid.reprol()}]' + title: str = f'{prefix}[{actor.aid.reprol()}]' _stp.setproctitle(title) return title diff --git a/xontrib/tractor_diag.xsh b/xontrib/tractor_diag.xsh index 987fd0b4..37f97a7c 100644 --- a/xontrib/tractor_diag.xsh +++ b/xontrib/tractor_diag.xsh @@ -488,6 +488,7 @@ def _tractor_reap(args): reap, reap_shm, reap_uds, + _TRACTOR_PROC_CMDLINE_MARKERS, ) rc: int = 0 @@ -500,9 +501,8 @@ def _tractor_reap(args): else: pids = find_orphans() mode = ( - 'orphans (PPid==1, intrinsic ' - 'cmdline/comm match — `tractor[…]` or ' - '`tractor._child`)' + f'orphans (PPid==1, intrinsic ' + f'cmdline/comm match — {_TRACTOR_PROC_CMDLINE_MARKERS}' ) if not pids: From 6db796ed837cde1fe61bf1d300779d4b7ad1010e Mon Sep 17 00:00:00 2001 From: goodboy Date: Wed, 3 Jun 2026 13:06:45 -0400 Subject: [PATCH 3/6] Pin to latest `xonsh` release (cherry picked from commit c4cad921b98bd074dd8650b8b0b97db446185c39) (cherry picked from commit ade15b42047ee0a02ecdcf0926d7d5b152fb1673) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index af67752d..47430ed6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,7 +104,7 @@ testing = [ repl = [ "pyperclip>=1.9.0", "prompt-toolkit>=3.0.50", - "xonsh>=0.23.0", + "xonsh>=0.23.8", "psutil>=7.0.0", ] lint = [ From 90c316f90c2025c1fbc2674bb692f6369c60f071 Mon Sep 17 00:00:00 2001 From: goodboy Date: Sun, 7 Jun 2026 20:21:56 -0400 Subject: [PATCH 4/6] Code-style, couple newline/ws tweaks (cherry picked from commit 8526985c972d6f47bcb9414907c76fbc2ac561d2) (cherry picked from commit 0952b33a9e6d06ec8f68340a014c4ae90c44d214) --- tractor/_root.py | 2 +- tractor/runtime/_state.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tractor/_root.py b/tractor/_root.py index 6e31d0a1..c123631b 100644 --- a/tractor/_root.py +++ b/tractor/_root.py @@ -155,7 +155,6 @@ async def maybe_block_bp( os.environ.pop('PYTHONBREAKPOINT', None) - @acm async def open_root_actor( *, @@ -186,6 +185,7 @@ async def open_root_actor( # enables the multi-process debugger support debug_mode: bool = False, maybe_enable_greenback: bool = False, # `.pause_from_sync()/breakpoint()` support + # ^XXX NOTE^ the perf implications of use, # https://greenback.readthedocs.io/en/latest/principle.html#performance enable_stack_on_sig: bool = False, diff --git a/tractor/runtime/_state.py b/tractor/runtime/_state.py index 17641f99..0d9a4435 100644 --- a/tractor/runtime/_state.py +++ b/tractor/runtime/_state.py @@ -139,7 +139,7 @@ _RUNTIME_VARS_DEFAULTS: dict[str, Any] = { # `debug_mode: bool` settings '_debug_mode': False, # bool 'repl_fixture': False, # |AbstractContextManager[bool] - + 'use_greenback': False, # `.pause_from_sync()`/`breakpoint()` 'use_stackscope': False, # trio-task-stack dumps on SIGUSR1 From 3727ce9e6f14151038c0ea22ce86a10c5e9403fe Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 22 Jun 2026 14:51:18 -0400 Subject: [PATCH 5/6] Bump lockfile for `xonsh>=0.23.8` release --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index af6fff38..292f0c29 100644 --- a/uv.lock +++ b/uv.lock @@ -797,7 +797,7 @@ dev = [ { name = "pytest-timeout", specifier = ">=2.3" }, { name = "stackscope", specifier = ">=0.2.2,<0.3" }, { name = "typing-extensions", specifier = ">=4.14.1" }, - { name = "xonsh", specifier = ">=0.23.0" }, + { name = "xonsh", specifier = ">=0.23.8" }, ] devx = [ { name = "stackscope", specifier = ">=0.2.2,<0.3" }, @@ -809,7 +809,7 @@ repl = [ { name = "prompt-toolkit", specifier = ">=3.0.50" }, { name = "psutil", specifier = ">=7.0.0" }, { name = "pyperclip", specifier = ">=1.9.0" }, - { name = "xonsh", specifier = ">=0.23.0" }, + { name = "xonsh", specifier = ">=0.23.8" }, ] subints = [{ name = "msgspec", marker = "python_full_version >= '3.14'", specifier = ">=0.21.0" }] sync-pause = [{ name = "greenback", marker = "python_full_version == '3.13.*'", specifier = ">=1.2.1,<2" }] From 88cc68edd3e5ae7da5f7c4c271982080f668b41b Mon Sep 17 00:00:00 2001 From: goodboy Date: Wed, 24 Jun 2026 18:03:28 -0400 Subject: [PATCH 6/6] Fix `add_log_level()` re-registration value drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bound `StackLevelAdapter.` emit method captured its level via a `_level: int = value` default-arg at bind time, so a later `add_log_level(name, )` re-registration left the method emitting at the *stale* original level. Read `CUSTOM_LEVELS[name_up]` at call time instead — the method binds once but always tracks the current registered value. Also, - correct the `add_log_level()` docstring's "Idempotent" note to describe the call-time value lookup (was the misleading "no-op-ish refresh (won't clobber an already-bound method)"). - sync stale `_reap.py` doc/comment markers `tractor[` -> `_subactor[` to match the actual `_proctitle._def_prefix` proc-title prefix (doc-only drift; the code markers already referenced `_proctitle._def_prefix`). Review: PR #467 (copilot-pull-request-reviewer) https://github.com/goodboy/tractor/pull/467#pullrequestreview-4562945515 (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tractor/_testing/_reap.py | 14 +++++++------- tractor/log.py | 21 +++++++++++++-------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/tractor/_testing/_reap.py b/tractor/_testing/_reap.py index fa4df944..e9511e96 100644 --- a/tractor/_testing/_reap.py +++ b/tractor/_testing/_reap.py @@ -216,18 +216,18 @@ def _read_comm(pid: int) -> str: # regardless of cwd / venv path / launch context. Used by # `_is_tractor_subactor()` below. # -# - cmdline `tractor[`: matches the `setproctitle`-set form -# (`tractor[]`) — set in -# `_actor_child_main` for ALL backends, mutates argv via -# libc so visible in `/proc//cmdline`. +# - cmdline `_subactor[` (`_proctitle._def_prefix`): matches +# the `setproctitle`-set form (`_subactor[]`) +# — set in `_actor_child_main` for ALL backends, mutates +# argv via libc so visible in `/proc//cmdline`. # - cmdline `tractor._child`: matches the legacy # `python -m tractor._child --uid (...)` form. Catches # procs that died before `_actor_child_main` got to call # `setproctitle()` — argv from exec is still kernel- # visible at that point. -# - comm `tractor[`: same proctitle-set form, but visible +# - comm `_subactor[`: same proctitle-set form, but visible # via `/proc//comm` (kernel-truncated to ~15 bytes, -# `tractor[doggy:`). Critical for ZOMBIES — kernel +# `_subactor[doggy:`). Critical for ZOMBIES — kernel # preserves `comm` past task-exit until parent reaps, # while `cmdline` for zombies often reads as empty. _TRACTOR_PROC_CMDLINE_MARKERS: tuple[str, ...] = ( @@ -253,7 +253,7 @@ def _is_tractor_subactor(pid: int) -> bool: ''' # 1. cmdline match — catches both `setproctitle`-set - # `tractor[]` (live) AND legacy `python -m + # `_subactor[]` (live) AND legacy `python -m # tractor._child` (any) form. cmdline: str = _read_cmdline(pid) if any(m in cmdline for m in _TRACTOR_PROC_CMDLINE_MARKERS): diff --git a/tractor/log.py b/tractor/log.py index 2c6e6b71..6c8c9bc8 100644 --- a/tractor/log.py +++ b/tractor/log.py @@ -281,8 +281,11 @@ def add_log_level( `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). + Idempotent: re-registering an existing name refreshes its + value + color in place. The bound `StackLevelAdapter.` + method reads the current `CUSTOM_LEVELS` value at call time, so + it tracks the new level across re-registration (the method + object is bound once, never re-bound/clobbered). ''' name_up: str = name.upper() @@ -294,16 +297,18 @@ def add_log_level( 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. + # read the level from `CUSTOM_LEVELS[name_up]` at CALL time + # (NOT captured at bind time) so a later + # `add_log_level(name, )` re-registration stays + # consistent with this once-bound method. The closure binds + # *this* call's `name_up` local (stable, never reassigned), + # so there's no late-binding hazard. 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) + return self.log(CUSTOM_LEVELS[name_up], msg) _emit.__name__ = name_lo _emit.__qualname__ = f'StackLevelAdapter.{name_lo}'