diff --git a/CHANGELOG.md b/CHANGELOG.md index ec20c2e..4ac4f2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 23dc8dd..fff0264 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. diff --git a/docs/api-reference.md b/docs/api-reference.md index fc6abc8..56abbd1 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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. diff --git a/docs/consumer-profiles.md b/docs/consumer-profiles.md index 983ef4b..68be69a 100644 --- a/docs/consumer-profiles.md +++ b/docs/consumer-profiles.md @@ -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 diff --git a/lib/python/base_cli/_app_core.py b/lib/python/base_cli/_app_core.py index 54f088b..b65a8c0 100644 --- a/lib/python/base_cli/_app_core.py +++ b/lib/python/base_cli/_app_core.py @@ -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 @@ -42,7 +42,7 @@ LifecycleOptions, LifecycleValues, ) -from .logging import configure_logger, log_invocation +from .logging import _CONFIGURED_LOG_LEVELS, configure_logger, log_invocation from .paths import ( current_working_dir, normalize_cli_name, @@ -276,6 +276,34 @@ 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 _parameter_source_rank(source) >= 2 + + +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: + if level is None or _CONFIGURED_LOG_LEVELS.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: @@ -1015,6 +1043,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 @@ -1110,6 +1139,8 @@ def _create_context( self, standard: dict[str, Any], dry_run: bool = False, + *, + option_sources: Mapping[str, Any], ) -> 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 @@ -1141,10 +1172,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 + 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: + 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, @@ -1235,6 +1284,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" diff --git a/lib/python/base_cli/_attach.py b/lib/python/base_cli/_attach.py index 80a1c56..cf55e6c 100644 --- a/lib/python/base_cli/_attach.py +++ b/lib/python/base_cli/_attach.py @@ -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() @@ -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 @@ -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): diff --git a/lib/python/base_cli/logging.py b/lib/python/base_cli/logging.py index d05692d..35aa824 100644 --- a/lib/python/base_cli/logging.py +++ b/lib/python/base_cli/logging.py @@ -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 @@ -47,7 +54,23 @@ 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. + """ + normalized_log_level = log_level.lower() if log_level is not None else None + if normalized_log_level is not None and normalized_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 normalized_log_level is None + else _CONFIGURED_LOG_LEVELS[normalized_log_level] + ) logger = logging.getLogger(f"base_cli.{cli_name}") logger.setLevel(logging.DEBUG) logger.propagate = False @@ -57,7 +80,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, diff --git a/tests/test_click_tree_attachment.py b/tests/test_click_tree_attachment.py index f1b6be4..2392fde 100644 --- a/tests/test_click_tree_attachment.py +++ b/tests/test_click_tree_attachment.py @@ -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 @@ -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 @@ -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: @@ -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: diff --git a/tests/test_framework_config_runtime.py b/tests/test_framework_config_runtime.py new file mode 100644 index 0000000..ee674bf --- /dev/null +++ b/tests/test_framework_config_runtime.py @@ -0,0 +1,316 @@ +from __future__ import annotations + +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path +from typing import Any + +import base_cli +from base_cli.testing import invoke + +_LEVELS = ("debug", "info", "warning", "error", "critical") +_EXPECTED_LEVELS = { + "debug": ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"), + "info": ("INFO", "WARNING", "ERROR", "CRITICAL"), + "warning": ("WARNING", "ERROR", "CRITICAL"), + "error": ("ERROR", "CRITICAL"), + "critical": ("CRITICAL",), +} + + +def _config_profile(root: Path, *, log_level: str, keep_temp: bool = False) -> base_cli.CliProfile: + config_dir = root / "config" / "tool" + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "config.yaml").write_text( + f"log_level: {log_level}\nkeep_temp: {str(keep_temp).lower()}\n", + encoding="utf-8", + ) + return base_cli.CliProfile.batteries_included("tool", user_config_dir=config_dir) + + +def _emit_test_logs(context: base_cli.Context[Any, Any, Any]) -> None: + for level in _LEVELS: + getattr(context.log, level)(f"{level}-marker") + + +def _native_log_callback(seen: list[bool]) -> Any: + def main(ctx: base_cli.Context[Any, Any, Any]) -> None: + seen.append(ctx.debug) + _emit_test_logs(ctx) + + return main + + +def _attached_log_callback(seen: list[bool]) -> Any: + def main() -> None: + context = base_cli.get_current_context() + seen.append(context.debug) + _emit_test_logs(context) + + return main + + +def _retention_callback(observed: dict[str, Any]) -> Any: + def status(ctx: base_cli.Context[Any, Any, Any]) -> None: + observed["debug"] = ctx.debug + observed["keep_temp"] = ctx.keep_temp + observed["temp_dir"] = ctx.temp_dir + (ctx.temp_dir / "marker").write_text("retention marker", encoding="utf-8") + + return status + + +def _json_marker_levels(stderr: str) -> list[str]: + return [ + str(payload["level"]) + for line in stderr.splitlines() + if line.strip() + for payload in (json.loads(line),) + if str(payload.get("message", "")).endswith("-marker") + ] + + +@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") +class FrameworkConfigRuntimeTests(unittest.TestCase): + def test_all_configured_levels_filter_native_text_and_json_logs(self) -> None: + for configured_level in _LEVELS: + for json_mode in (False, True): + with self.subTest(level=configured_level, json=json_mode), tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + seen: list[bool] = [] + options = base_cli.LifecycleOptions( + json=base_cli.LifecycleOption("--json") if json_mode else None, + ) + app = base_cli.App( + name="tool", + profile=_config_profile(root, log_level=configured_level), + lifecycle_options=options, + log_to_file=False, + ) + + app.command()(_native_log_callback(seen)) + + args = ["--json"] if json_mode else [] + result = invoke(app, args, home=root / "home") + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(seen, [configured_level == "debug"]) + if json_mode: + self.assertEqual(_json_marker_levels(result.stderr), list(_EXPECTED_LEVELS[configured_level])) + else: + for level in _LEVELS: + self.assertEqual( + f"{level}-marker" in result.stderr, + level.upper() in _EXPECTED_LEVELS[configured_level], + result.stderr, + ) + + @unittest.skipUnless(importlib.util.find_spec("typer"), "Typer is not installed") + def test_all_configured_levels_filter_attached_typer_text_and_json_logs(self) -> None: + import typer + + for configured_level in _LEVELS: + for json_mode in (False, True): + with self.subTest(level=configured_level, json=json_mode), tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + seen: list[bool] = [] + typer_app = typer.Typer() + + typer_app.command()(_attached_log_callback(seen)) + + command = base_cli.attach_typer( + typer_app, + name="tool", + profile=_config_profile(root, log_level=configured_level), + lifecycle_options=base_cli.LifecycleOptions( + json=base_cli.LifecycleOption("--json") if json_mode else None, + ), + log_to_file=False, + ) + args = ["--json"] if json_mode else [] + result = invoke(command, args, home=root / "home") + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(seen, [configured_level == "debug"]) + if json_mode: + self.assertEqual(_json_marker_levels(result.stderr), list(_EXPECTED_LEVELS[configured_level])) + else: + for level in _LEVELS: + self.assertEqual( + f"{level}-marker" in result.stderr, + level.upper() in _EXPECTED_LEVELS[configured_level], + result.stderr, + ) + + def test_configured_stream_filter_does_not_reduce_persistent_debug_log(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + seen: dict[str, Any] = {} + app = base_cli.App( + name="tool", + profile=_config_profile(root, log_level="critical"), + ) + + @app.command() + def main(ctx: base_cli.Context[Any, Any, Any]) -> None: + seen["log_file"] = ctx.log_file + ctx.log.debug("persistent-debug-marker") + ctx.log.critical("critical-stream-marker") + + result = invoke(app, home=root / "home") + log_text = Path(seen["log_file"]).read_text(encoding="utf-8") + + self.assertEqual(result.exit_code, 0, result.output) + self.assertNotIn("persistent-debug-marker", result.stderr) + self.assertIn("critical-stream-marker", result.stderr) + self.assertIn("persistent-debug-marker", log_text) + + def test_quiet_raises_configured_debug_stream_to_warning(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + seen: list[tuple[bool, bool]] = [] + app = base_cli.App( + name="tool", + profile=_config_profile(root, log_level="debug"), + log_to_file=False, + ) + + def quiet_callback(ctx: base_cli.Context[Any, Any, Any]) -> None: + seen.append((ctx.debug, ctx.quiet)) + _emit_test_logs(ctx) + + app.command()(quiet_callback) + + result = invoke(app, ["--quiet"], home=root / "home") + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(seen, [(True, True)]) + for level in _LEVELS: + self.assertEqual( + f"{level}-marker" in result.stderr, + level.upper() in _EXPECTED_LEVELS["warning"], + result.stderr, + ) + + def test_explicit_negative_flags_override_config_and_cleanup_metadata(self) -> None: + for case, args, expected_debug, expected_keep in ( + ( + "root negative values override true config", + ["--no-debug", "--no-keep-temp", "status"], + False, + False, + ), + ( + "leaf positive values override root negative values", + ["--no-debug", "--no-keep-temp", "status", "--debug", "--keep-temp"], + True, + True, + ), + ): + with self.subTest(case=case), tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + observed: dict[str, Any] = {} + app = base_cli.App( + name="tool", + profile=_config_profile(root, log_level="debug", keep_temp=True), + lifecycle_options=base_cli.LifecycleOptions( + debug=base_cli.LifecycleOption("--debug/--no-debug"), + keep_temp=base_cli.LifecycleOption("--keep-temp/--no-keep-temp"), + ), + ) + + app.subcommand("status")(_retention_callback(observed)) + + result = invoke(app, args, home=root / "home") + metadata_files = list((root / "home" / ".cache").glob("**/run.json")) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(observed["debug"], expected_debug) + self.assertEqual(observed["keep_temp"], expected_keep) + self.assertEqual(len(metadata_files), 1) + metadata = json.loads(metadata_files[0].read_text(encoding="utf-8")) + self.assertEqual(metadata["preserve"], expected_keep) + temp_dir = Path(observed["temp_dir"]) + marker = temp_dir / "marker" + if expected_keep: + self.assertTrue(marker.exists()) + elif marker.exists(): + # Platforms without safe directory-handle cleanup fail closed, + # so a false keep-temp value is recorded but cleanup is warned. + self.assertIn("Temp directory cleanup failed", result.stderr) + + def test_config_overrides_callable_defaults_but_environment_and_default_map_override_config(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + observed: list[tuple[bool, bool]] = [] + options = base_cli.LifecycleOptions( + debug=base_cli.LifecycleOption("--debug/--no-debug", envvar="TOOL_DEBUG", default=lambda: True), + keep_temp=base_cli.LifecycleOption( + "--keep-temp/--no-keep-temp", + envvar="TOOL_KEEP_TEMP", + default=lambda: True, + ), + ) + app = base_cli.App( + name="tool", + profile=_config_profile(root, log_level="error", keep_temp=False), + lifecycle_options=options, + log_to_file=False, + ) + + @app.command() + def main(ctx: base_cli.Context[Any, Any, Any]) -> None: + observed.append((ctx.debug, ctx.keep_temp)) + + default_result = invoke(app, home=root / "default-home") + self.assertEqual(default_result.exit_code, 0, default_result.output) + self.assertEqual(observed, [(False, False)]) + + env_result = invoke( + app, + home=root / "env-home", + env={"TOOL_DEBUG": "1", "TOOL_KEEP_TEMP": "1"}, + ) + self.assertEqual(env_result.exit_code, 0, env_result.output) + self.assertEqual(observed[-1], (True, True)) + + command = app.click_command + command.context_settings["default_map"] = {"debug": True, "keep_temp": True} + map_result = invoke(app, home=root / "map-home") + self.assertEqual(map_result.exit_code, 0, map_result.output) + self.assertEqual(observed[-1], (True, True)) + + config_file = root / "config" / "tool" / "config.yaml" + config_file.write_text("log_level: debug\nkeep_temp: true\n", encoding="utf-8") + false_env_result = invoke( + app, + home=root / "false-env-home", + env={"TOOL_DEBUG": "0", "TOOL_KEEP_TEMP": "0"}, + ) + self.assertEqual(false_env_result.exit_code, 0, false_env_result.output) + self.assertEqual(observed[-1], (False, False)) + + map_app = base_cli.App( + name="tool", + profile=_config_profile(root, log_level="debug", keep_temp=True), + lifecycle_options=base_cli.LifecycleOptions( + debug=base_cli.LifecycleOption("--debug/--no-debug"), + keep_temp=base_cli.LifecycleOption("--keep-temp/--no-keep-temp"), + ), + log_to_file=False, + ) + + @map_app.command() + def map_main(ctx: base_cli.Context[Any, Any, Any]) -> None: + observed.append((ctx.debug, ctx.keep_temp)) + + map_app.click_command.context_settings["default_map"] = {"debug": False, "keep_temp": False} + false_map_result = invoke(map_app, home=root / "false-map-home") + self.assertEqual(false_map_result.exit_code, 0, false_map_result.output) + self.assertEqual(observed[-1], (False, False)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_logging.py b/tests/test_logging.py index e097b2c..6f85b75 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -37,6 +37,55 @@ def test_configure_logger_accepts_custom_formatter(self) -> None: self.assertEqual(stream.getvalue().strip(), "INFO:hello formatter") + def test_configure_logger_accepts_and_validates_explicit_stream_threshold(self) -> None: + cases = ( + ("debug", ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")), + ("info", ("INFO", "WARNING", "ERROR", "CRITICAL")), + ("warning", ("WARNING", "ERROR", "CRITICAL")), + ("error", ("ERROR", "CRITICAL")), + ("critical", ("CRITICAL",)), + ) + for level, expected in cases: + with self.subTest(level=level): + stream = io.StringIO() + logger = base_cli.configure_logger( + f"configured-level-{level}", + None, + debug=False, + stream=stream, + log_level=level, + ) + for log_level in ("debug", "info", "warning", "error", "critical"): + getattr(logger, log_level)(f"{log_level}-message") + + output = stream.getvalue() + for candidate in ("debug", "info", "warning", "error", "critical"): + self.assertEqual( + f"{candidate}-message" in output, + candidate.upper() in expected, + output, + ) + for handler in list(logger.handlers): + handler.close() + logger.removeHandler(handler) + + with self.assertRaisesRegex(ValueError, "log_level must be one of"): + base_cli.configure_logger("configured-level-invalid", None, debug=False, log_level="verbose") + + def test_configure_logger_accepts_uppercase_stream_threshold(self) -> None: + stream = io.StringIO() + logger = base_cli.configure_logger( + "configured-level-uppercase", + None, + debug=False, + stream=stream, + log_level="WARNING", + ) + logger.info("hidden") + logger.warning("visible") + self.assertNotIn("hidden", stream.getvalue()) + self.assertIn("visible", stream.getvalue()) + def test_base_formatter_includes_exception_tracebacks(self) -> None: stream = io.StringIO() logger = base_cli.configure_logger("exception-traceback", None, debug=True, stream=stream)