Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ and versions are tracked in the repo-root `VERSION` file.

### Fixed

- Honor all five validated framework `log_level` values on native and attached
user-facing streams while preserving DEBUG-level persistent diagnostics.
- Let explicitly supplied lifecycle values, including negative boolean flags,
environment variables, and Click `default_map` entries, override validated
file configuration while keeping config ahead of Click defaults.
- Preserve explicit application identities losslessly while using
collision-resistant, path-safe runtime namespace components.
- Give `BatteriesIncludedConfigLoader.cli_name` a documented identity role by
Expand Down
23 changes: 15 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -824,15 +824,21 @@ def helper() -> None:
`base_cli` configures two handlers:

- a user-facing stderr handler at INFO by default, DEBUG with `--debug`, or
WARNING with `--quiet` / `-q`
WARNING with `--quiet` / `-q`; a batteries-included profile's configured
`log_level` (`debug`, `info`, `warning`, `error`, or `critical`) sets this
handler's threshold when no higher-precedence lifecycle option is supplied
- a persistent file handler that records DEBUG logs when persistent logging is
enabled

`--quiet` suppresses INFO output on the user-facing stream but still shows
warnings and errors. `--debug` and `--quiet` cannot be used together. Persistent
log files still receive DEBUG-level detail, including INFO messages suppressed
from stderr. User-facing logs use colors automatically on interactive terminals;
persistent log files remain plain text. Set `NO_COLOR=1` or
from stderr, regardless of the configured user-stream threshold. Explicit
`--debug` enables DEBUG output; an explicit negative form such as
`--no-debug` cancels a configured `log_level: debug` and returns to INFO unless
a more restrictive configured level applies. `--quiet` raises the user-stream
threshold to at least WARNING. User-facing logs use colors automatically on
interactive terminals; persistent log files remain plain text. Set `NO_COLOR=1` or
`BASE_CLI_COLOR=0` to disable colors. A consumer wrapper may add its own color
option and map it to the environment variable.

Expand All @@ -842,11 +848,12 @@ with `zsh` or `fish` as needed. `base_cli` leaves installation to the caller so
shell startup files remain under user control.

Advanced tests and CI wrappers can call `base_cli.configure_logger(...,
stream=..., formatter=...)` to capture user-facing logs or apply a custom
formatter. Leave those arguments as `None` to keep the default stderr stream
and formatter. Log timestamps use the host's local timezone and include its
numeric offset by default. A consumer can set `LOG_UTC=1` to use UTC and
include an explicit `UTC` marker.
stream=..., formatter=..., log_level="warning")` to capture user-facing logs,
apply a custom formatter, or select a stream threshold. Omit `log_level` to
retain the existing `debug`/`quiet` behavior; leave `stream` and `formatter` as
`None` to keep the default stderr stream and formatter. Log timestamps use the
host's local timezone and include its numeric offset by default. A consumer can
set `LOG_UTC=1` to use UTC and include an explicit `UTC` marker.

This setting affects log presentation only. Run metadata, history records, and
run IDs retain their canonical UTC representation.
Expand Down
4 changes: 2 additions & 2 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1030,9 +1030,9 @@ base_cli.command(...)

### `configure_logger`
**Kind:** function
**Signature:** `configure_logger(cli_name: 'str', log_file: 'Path | None', debug: 'bool', *, quiet: 'bool' = False, stream: 'TextIO | None' = None, formatter: 'logging.Formatter | None' = None, json_logs: 'bool' = False, run_id: 'str | None' = None) -> 'logging.Logger'`
**Signature:** `configure_logger(cli_name: 'str', log_file: 'Path | None', debug: 'bool', *, quiet: 'bool' = False, stream: 'TextIO | None' = None, formatter: 'logging.Formatter | None' = None, json_logs: 'bool' = False, run_id: 'str | None' = None, log_level: 'str | None' = None) -> 'logging.Logger'`

**Behavior:** Public facade symbol; see the linked contract and source annotations for details.
**Behavior:** Configure user-facing and persistent handlers for a CLI logger.

**Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release.

Expand Down
17 changes: 17 additions & 0 deletions docs/consumer-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,23 @@ validated into `Context.framework_config` and are excluded from the consumer
configuration dictionary. All other keys remain consumer-owned and are exposed
through `Context.config`.

For lifecycle flags such as `debug` and `keep_temp`, an explicitly supplied
Click value takes precedence over validated file configuration. Click sources
rank as command line or prompt, environment variable, then `default_map`; a
file-configured value in turn takes precedence over a Click-declared default or
callable default. Thus a declared default of `False` does not erase
`keep_temp: true`, while an explicit `--no-keep-temp`, false environment value,
or false `default_map` value can turn it off. When the same lifecycle flag is
present on a native root command and a leaf, the stronger Click source wins and
the leaf wins ties. Attached Click/Typer trees follow the same source policy.

The configured `log_level` controls the user-facing log stream at all five
accepted levels. Explicit `--debug` selects DEBUG; an explicit negative debug
flag cancels a configured `debug` level and falls back to INFO unless a more
restrictive configured level applies. `--quiet` raises the stream threshold to
at least WARNING. Persistent diagnostic logs remain at DEBUG independently of
the user-facing threshold.

Custom `ConfigLoader` callbacks that return a plain mapping do not opt into
those lifecycle settings: every mapping key, including names that resemble
framework keys, remains consumer data. Return a `ConfigSnapshot` to supply
Expand Down
64 changes: 60 additions & 4 deletions lib/python/base_cli/_app_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import stat
import sys
import time
from collections.abc import Awaitable, Callable, Iterable
from collections.abc import Awaitable, Callable, Iterable, Mapping
from contextvars import ContextVar, Token
from dataclasses import dataclass
from datetime import datetime
Expand Down Expand Up @@ -276,6 +276,41 @@ def _default_log_file(layout: Any, configured_log_file: Path | None) -> Path:
return configured_log_file or layout.log_dir / "primary.log"


def _parameter_source_was_supplied(source: Any) -> bool:
"""Return whether Click resolved an option from an explicit input source."""

return getattr(source, "name", None) in {"COMMANDLINE", "PROMPT", "ENVIRONMENT", "DEFAULT_MAP"}


def _configured_stream_level(
configured: str | None,
*,
debug: bool,
quiet: bool,
debug_source: Any,
quiet_source: Any,
) -> str | None:
"""Merge explicit flag modifiers with the configured user-stream level."""

level = configured
if _parameter_source_was_supplied(debug_source):
if debug:
level = "debug"
elif level == "debug":
level = "info"
if _parameter_source_was_supplied(quiet_source) and quiet:
rank = {
"debug": logging.DEBUG,
"info": logging.INFO,
"warning": logging.WARNING,
"error": logging.ERROR,
"critical": logging.CRITICAL,
}
if level is None or rank.get(level, logging.INFO) < logging.WARNING:
level = "warning"
return level


def _warn_lifecycle_failure(context: Context[Any, Any, Any], message: str, exc: BaseException) -> None:
"""Report a secondary lifecycle failure without breaking teardown."""
try:
Expand Down Expand Up @@ -1015,6 +1050,7 @@ def wrapper(**kwargs: Any) -> Any:
context = self._create_context(
standard,
dry_run=resolution.values.dry_run,
option_sources={key: value.source for key, value in resolution.raw.items()},
)
except ConfigurationError as exc:
raise click.UsageError(str(exc)) from exc
Expand Down Expand Up @@ -1110,6 +1146,7 @@ def _create_context(
self,
standard: dict[str, Any],
dry_run: bool = False,
option_sources: Mapping[str, Any] | None = None,
) -> Context[dict[str, Any], Any, Any]:
project = self.profile.discover_project(current_working_dir())
manifest_path = project.manifest if project is not None else None
Expand Down Expand Up @@ -1141,10 +1178,28 @@ def _create_context(
or "dev"
)
log_level = framework_config.log_level if framework_config is not None else None
debug = bool(standard.get("debug") or log_level == "debug")
sources = option_sources or {}
debug_source = sources.get("debug")
quiet_source = sources.get("quiet")
keep_temp_source = sources.get("keep_temp")
debug = (
bool(standard.get("debug"))
if _parameter_source_was_supplied(debug_source) or log_level is None
else log_level == "debug"
)
quiet = bool(standard.get("quiet"))
keep_temp = bool(
standard.get("keep_temp") or (framework_config.keep_temp if framework_config is not None else None)
if framework_config is None or _parameter_source_was_supplied(keep_temp_source):
keep_temp = bool(standard.get("keep_temp"))
elif "keep_temp" in config_provenance or framework_config.keep_temp:
keep_temp = framework_config.keep_temp
else:
keep_temp = bool(standard.get("keep_temp"))
stream_log_level = _configured_stream_level(
log_level,
debug=debug,
quiet=quiet,
debug_source=debug_source,
quiet_source=quiet_source,
)
_capture_effective_output_options(
owner_app=self,
Expand Down Expand Up @@ -1235,6 +1290,7 @@ def _create_context(
quiet=quiet,
json_logs=context.json_output,
run_id=context.run_id,
log_level=stream_log_level,
)
except OSError as exc:
target = f"persistent log file '{log_file}'" if log_file is not None else "stderr logging"
Expand Down
4 changes: 4 additions & 0 deletions lib/python/base_cli/_attach.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,13 @@ def __init__(
attachment: _ClickAttachment[Any],
click_context: Any,
lifecycle_values: LifecycleValues,
lifecycle_sources: dict[str, Any],
) -> None:
self.click = click
self.attachment = attachment
self.click_context = click_context
self.lifecycle_values = lifecycle_values
self.lifecycle_sources = lifecycle_sources
self.standard = _standard_options_from_values(lifecycle_values)
self.started_at = utc_now()
self.started_monotonic_ns = time.monotonic_ns()
Expand All @@ -82,6 +84,7 @@ def __enter__(self) -> _AttachedLifecycleResource:
context = self.attachment.app._create_context( # pylint: disable=protected-access
self.standard,
dry_run=self.lifecycle_values.dry_run,
option_sources=self.lifecycle_sources,
)
except ConfigurationError as exc:
raise self.click.UsageError(str(exc)) from exc
Expand Down Expand Up @@ -434,6 +437,7 @@ def invoke(click_context: Any) -> Any:
attachment,
click_context,
resolution.values,
{key: value.source for key, value in resolution.raw.items()},
)
_with_attached_lifecycle_resource(click_context, resource)
if not _click_command_has_pending_children(click_context, command):
Expand Down
20 changes: 19 additions & 1 deletion lib/python/base_cli/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@
logging.ERROR: "\033[0;31m",
logging.CRITICAL: "\033[0;31m",
}
_CONFIGURED_LOG_LEVELS = {
"debug": logging.DEBUG,
"info": logging.INFO,
"warning": logging.WARNING,
"error": logging.ERROR,
"critical": logging.CRITICAL,
}


# pylint: disable=too-many-arguments
Expand All @@ -47,7 +54,18 @@ def configure_logger(
formatter: logging.Formatter | None = None,
json_logs: bool = False,
run_id: str | None = None,
log_level: str | None = None,
) -> logging.Logger:
"""Configure user-facing and persistent handlers for a CLI logger.

``log_level`` optionally selects the user-stream threshold from DEBUG,
INFO, WARNING, ERROR, or CRITICAL. The persistent file handler remains at
DEBUG. When omitted, the existing ``debug`` and ``quiet`` policy applies.
"""
if log_level is not None and log_level not in _CONFIGURED_LOG_LEVELS:
supported = ", ".join(_CONFIGURED_LOG_LEVELS)
raise ValueError(f"log_level must be one of: {supported}.")
stream_level = _user_stream_level(debug, quiet) if log_level is None else _CONFIGURED_LOG_LEVELS[log_level]
logger = logging.getLogger(f"base_cli.{cli_name}")
logger.setLevel(logging.DEBUG)
logger.propagate = False
Expand All @@ -57,7 +75,7 @@ def configure_logger(

user_stream = stream if stream is not None else sys.stderr
user_handler = logging.StreamHandler(user_stream)
user_handler.setLevel(_user_stream_level(debug, quiet))
user_handler.setLevel(stream_level)
user_handler.setFormatter(
_handler_formatter(
formatter,
Expand Down
10 changes: 7 additions & 3 deletions tests/test_click_tree_attachment.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import os
import tempfile
import unittest
from collections.abc import Mapping
from dataclasses import replace
from pathlib import Path
from typing import Any
Expand Down Expand Up @@ -45,9 +46,10 @@ def _create_context(
self,
standard: dict[str, Any],
dry_run: bool = False,
option_sources: Mapping[str, Any] | None = None,
) -> base_cli.Context:
self.context_create_count += 1
context = super()._create_context(standard, dry_run=dry_run)
context = super()._create_context(standard, dry_run=dry_run, option_sources=option_sources)
self.created_contexts.append(context)
original_cleanup = context.cleanup

Expand Down Expand Up @@ -682,9 +684,10 @@ def _create_context(
self,
standard: dict[str, Any],
dry_run: bool = False,
option_sources: Mapping[str, Any] | None = None,
) -> base_cli.Context:
events.append("lifecycle-enter")
context = super()._create_context(standard, dry_run=dry_run)
context = super()._create_context(standard, dry_run=dry_run, option_sources=option_sources)
original_cleanup = context.cleanup

def ordered_cleanup() -> None:
Expand Down Expand Up @@ -1321,9 +1324,10 @@ def _create_context(
self,
standard: dict[str, Any],
dry_run: bool = False,
option_sources: Mapping[str, Any] | None = None,
) -> base_cli.Context:
events.append("lifecycle-enter")
context = super()._create_context(standard, dry_run=dry_run)
context = super()._create_context(standard, dry_run=dry_run, option_sources=option_sources)
original_cleanup = context.cleanup

def ordered_cleanup() -> None:
Expand Down
Loading
Loading