You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
configure_logger() takes unconditional ownership of the process-global logger base_cli.<cli_name>: it closes and removes every pre-existing handler, forces the level to DEBUG, and sets propagate = False (lib/python/base_cli/logging.py:51-56):
Context._cleanup_resources() then closes and removes all handlers again at teardown
(lib/python/base_cli/context.py:158-174). Neither site distinguishes invocation-owned handlers
from handlers a host application or consumer attached, because no ownership is recorded.
This breaks three legitimate integrations:
an application that embeds a base-cli CLI and routes base_cli.* records to its own aggregator;
a consumer that attaches a handler to ship lifecycle logs to syslog/journald/OTLP;
any logging.config.dictConfig() setup that configures base_cli loggers, since propagate = False also cuts off root-logger handlers.
Closing a handler the framework did not open is the sharper half: the consumer's stream is closed
out from under it, not merely detached.
#341 ("preserve invocation-owned log handlers across nested runs") fixed the nested-run_app case.
This is the adjacent, still-open case of handlers base-cli never owned; there is no ownership
marking in the code today.
Verified evidence
Reviewed 2026-09-30 at a58ec109349fa3f3d03eae5b0de078b39ea361a2 (macOS, Python 3.14.6).
Confirmed there is no ownership tracking at either removal site:
$ grep -rn "removeHandler\|handlers" lib/python/base_cli/logging.py lib/python/base_cli/context.py
logging.py:54: for handler in list(logger.handlers):
logging.py:56: logger.removeHandler(handler)
context.py:158: for handler in list(self.log.handlers):
context.py:168: self.log.removeHandler(handler)
context.py:172: self.log.handlers.remove(handler)
Proposal
Mark handlers the lifecycle creates (an attribute or a _BaseCliHandler mixin) and have both configure_logger() and Context._cleanup_resources() remove and close only marked
handlers. Leave foreign handlers attached and open.
Stop forcing propagate = False unconditionally. Make it a documented, overridable policy —
the reason it exists (avoiding duplicate output through the root logger) is real, but it should
be a choice the consumer can make. A configure_logger(..., propagate=None) tri-state, or an App(logger_policy=...) option.
Do not call setLevel() on a logger a consumer configured; set levels on base-cli's own
handlers, which is where the debug/quiet policy already lives.
Document the logger-ownership contract in docs/integrations.md: which logger name base-cli
owns, what it does to it, and how to attach a handler that survives.
Acceptance criteria
A handler attached to base_cli.<name> before an invocation is still attached and still open
after the invocation completes.
logging.config.dictConfig()-configured base_cli handlers continue to receive records.
No duplicate records reach stderr in the default configuration (the behaviour propagate = False
protects today) — verified by test.
Problem
configure_logger()takes unconditional ownership of the process-global loggerbase_cli.<cli_name>: it closes and removes every pre-existing handler, forces the level toDEBUG, and setspropagate = False(lib/python/base_cli/logging.py:51-56):Context._cleanup_resources()then closes and removes all handlers again at teardown(
lib/python/base_cli/context.py:158-174). Neither site distinguishes invocation-owned handlersfrom handlers a host application or consumer attached, because no ownership is recorded.
This breaks three legitimate integrations:
base_cli.*records to its own aggregator;logging.config.dictConfig()setup that configuresbase_cliloggers, sincepropagate = Falsealso cuts off root-logger handlers.Closing a handler the framework did not open is the sharper half: the consumer's stream is closed
out from under it, not merely detached.
#341 ("preserve invocation-owned log handlers across nested runs") fixed the nested-
run_appcase.This is the adjacent, still-open case of handlers base-cli never owned; there is no ownership
marking in the code today.
Verified evidence
Reviewed 2026-09-30 at
a58ec109349fa3f3d03eae5b0de078b39ea361a2(macOS, Python 3.14.6).Confirmed there is no ownership tracking at either removal site:
Proposal
_BaseCliHandlermixin) and have bothconfigure_logger()andContext._cleanup_resources()remove and close only markedhandlers. Leave foreign handlers attached and open.
propagate = Falseunconditionally. Make it a documented, overridable policy —the reason it exists (avoiding duplicate output through the root logger) is real, but it should
be a choice the consumer can make. A
configure_logger(..., propagate=None)tri-state, or anApp(logger_policy=...)option.setLevel()on a logger a consumer configured; set levels on base-cli's ownhandlers, which is where the debug/quiet policy already lives.
docs/integrations.md: which logger name base-cliowns, what it does to it, and how to attach a handler that survives.
Acceptance criteria
base_cli.<name>before an invocation is still attached and still openafter the invocation completes.
logging.config.dictConfig()-configuredbase_clihandlers continue to receive records.propagate = Falseprotects today) — verified by test.
run_appguarantee from bug: preserve invocation-owned log handlers across nested runs #341 still holds.docs/integrations.mddocuments the ownership contract.Non-goals
base_cli.<cli_name>logger.