From 30e9e716d1513827bff8cbe84b32a2d50a94eb3c Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:07:40 +0530 Subject: [PATCH 01/10] ci: cover all consumer fixtures in style and strict typing gates --- .../atlas_click/src/atlas_click/cli.py | 2 +- .../atlas_click/tests/test_consumer.py | 1 - .../beacon_typer/src/beacon_typer/cli.py | 1 - .../beacon_typer/tests/test_consumer.py | 1 - .../src/cinder_automation/cli.py | 7 +-- .../cinder_automation/tests/test_consumer.py | 1 - docs/testing.md | 8 ++++ .../src/automation_observability_app/cli.py | 4 +- examples/minimal_cli/src/minimal_cli/cli.py | 4 +- scripts/validate_consumer_typing.py | 46 +++++++++++++++++++ tests/full_validate.sh | 6 ++- 11 files changed, 69 insertions(+), 12 deletions(-) create mode 100644 scripts/validate_consumer_typing.py diff --git a/compatibility/consumers/atlas_click/src/atlas_click/cli.py b/compatibility/consumers/atlas_click/src/atlas_click/cli.py index 3935d21..da4c15c 100644 --- a/compatibility/consumers/atlas_click/src/atlas_click/cli.py +++ b/compatibility/consumers/atlas_click/src/atlas_click/cli.py @@ -2,8 +2,8 @@ from __future__ import annotations -import click import base_cli +import click @click.group(name="atlas-consumer", help="Inventory resources managed by Atlas.") diff --git a/compatibility/consumers/atlas_click/tests/test_consumer.py b/compatibility/consumers/atlas_click/tests/test_consumer.py index fd0f6c6..6f85e08 100644 --- a/compatibility/consumers/atlas_click/tests/test_consumer.py +++ b/compatibility/consumers/atlas_click/tests/test_consumer.py @@ -4,7 +4,6 @@ from pathlib import Path import base_cli - from atlas_click.cli import command diff --git a/compatibility/consumers/beacon_typer/src/beacon_typer/cli.py b/compatibility/consumers/beacon_typer/src/beacon_typer/cli.py index 9ea6672..8bada8b 100644 --- a/compatibility/consumers/beacon_typer/src/beacon_typer/cli.py +++ b/compatibility/consumers/beacon_typer/src/beacon_typer/cli.py @@ -5,7 +5,6 @@ import base_cli import typer - cli = typer.Typer(help="Deploy Beacon services with typed parameters.") diff --git a/compatibility/consumers/beacon_typer/tests/test_consumer.py b/compatibility/consumers/beacon_typer/tests/test_consumer.py index eb7a505..7928c19 100644 --- a/compatibility/consumers/beacon_typer/tests/test_consumer.py +++ b/compatibility/consumers/beacon_typer/tests/test_consumer.py @@ -4,7 +4,6 @@ from pathlib import Path import base_cli - from beacon_typer.cli import command diff --git a/compatibility/consumers/cinder_automation/src/cinder_automation/cli.py b/compatibility/consumers/cinder_automation/src/cinder_automation/cli.py index 512210c..229a361 100644 --- a/compatibility/consumers/cinder_automation/src/cinder_automation/cli.py +++ b/compatibility/consumers/cinder_automation/src/cinder_automation/cli.py @@ -2,9 +2,10 @@ from __future__ import annotations -import click -import base_cli +from typing import Any +import base_cli +import click app = base_cli.App( name="cinder-consumer", @@ -27,7 +28,7 @@ default="json", show_default=True, ) -def reconcile(ctx: base_cli.Context, target: str, output_format: str) -> None: +def reconcile(ctx: base_cli.Context[Any, Any, Any], target: str, output_format: str) -> None: """Publish the result of one idempotent reconciliation step.""" action = "would-reconcile" if ctx.dry_run else "reconciled" diff --git a/compatibility/consumers/cinder_automation/tests/test_consumer.py b/compatibility/consumers/cinder_automation/tests/test_consumer.py index 84c44a5..db37c26 100644 --- a/compatibility/consumers/cinder_automation/tests/test_consumer.py +++ b/compatibility/consumers/cinder_automation/tests/test_consumer.py @@ -4,7 +4,6 @@ from pathlib import Path import base_cli - from cinder_automation.cli import app diff --git a/docs/testing.md b/docs/testing.md index 92d0a83..a31dc6a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -48,3 +48,11 @@ The full gate writes a machine-readable result to `$BASE_CLI_VALIDATION_RESULT` (or `/tmp/base-cli-validation-result.json`). If Node.js is unavailable, the result is marked `partial`, the gate exits with status `2`, and it cannot be reported as an authoritative pass. + +### Consumer source quality + +The style gate runs Ruff over the entire repository with its standard generated-file +exclusions. The typing gate checks every Git-visible Python source outside `lib/` +(checked separately), `scripts/` (validation tools), and `tests/` (test harnesses) +with strict mypy. This includes example and compatibility consumer packages and +new top-level source directories; untracked sources are included during development. diff --git a/examples/automation_observability_app/src/automation_observability_app/cli.py b/examples/automation_observability_app/src/automation_observability_app/cli.py index 3d4753d..f147d27 100644 --- a/examples/automation_observability_app/src/automation_observability_app/cli.py +++ b/examples/automation_observability_app/src/automation_observability_app/cli.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + import base_cli import click @@ -35,7 +37,7 @@ ) @base_cli.option("--api-token", hidden=True, help="Optional secret for a real adapter.") def run( - ctx: base_cli.Context, + ctx: base_cli.Context[Any, Any, Any], target: str, output_format: str, api_token: str | None, diff --git a/examples/minimal_cli/src/minimal_cli/cli.py b/examples/minimal_cli/src/minimal_cli/cli.py index 452d1d5..070e248 100644 --- a/examples/minimal_cli/src/minimal_cli/cli.py +++ b/examples/minimal_cli/src/minimal_cli/cli.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + import base_cli app = base_cli.App( @@ -13,7 +15,7 @@ @app.command() @base_cli.option("--name", required=True, help="Name to greet.") -def greet(ctx: base_cli.Context, name: str) -> None: +def greet(ctx: base_cli.Context[Any, Any, Any], name: str) -> None: """Print a deterministic greeting.""" ctx.log.info("greeting requested for %s", name) diff --git a/scripts/validate_consumer_typing.py b/scripts/validate_consumer_typing.py new file mode 100644 index 0000000..01747ee --- /dev/null +++ b/scripts/validate_consumer_typing.py @@ -0,0 +1,46 @@ +"""Type-check every example and compatibility consumer at strict settings.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + + +def main() -> int: + root = Path(__file__).resolve().parents[1] + # Git discovery also includes new, untracked source directories. Infrastructure + # scripts/tests have separate runtime gates; all product/example code is typed. + result = subprocess.run( + ["git", "ls-files", "--cached", "--others", "--exclude-standard", "--", "*.py"], + cwd=root, + check=True, + capture_output=True, + text=True, + ) + files = sorted(set(result.stdout.splitlines())) + excluded = {"lib", "scripts", "tests"} + sources = [name for name in files if Path(name).parts[0] not in excluded] + if not sources: + raise RuntimeError("No consumer Python sources found") + return subprocess.run( + [sys.executable, "-m", "mypy", "--strict", "--explicit-package-bases", *sources], + cwd=root, + env={ + **os.environ, + "MYPYPATH": os.pathsep.join( + str(p) + for p in [ + root / "lib/python", + *sorted(root.glob("examples/*/src")), + *sorted(root.glob("compatibility/consumers/*/src")), + ] + ), + }, + check=False, + ).returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/full_validate.sh b/tests/full_validate.sh index f7fcbee..a461439 100755 --- a/tests/full_validate.sh +++ b/tests/full_validate.sh @@ -43,12 +43,14 @@ run_typing() { require_commands python mypy python -m mypy --strict examples/typed_consumer.py python -m mypy --strict lib/python/base_cli + # Discover all Python sources so new top-level directories cannot escape checks. + python scripts/validate_consumer_typing.py } run_style() { require_commands ruff - ruff format --check lib/python/base_cli scripts examples tests - ruff check lib/python/base_cli scripts examples tests + ruff format --check . + ruff check . } run_contracts() { From 710208be0cb3c2610eba2921e2e13cd7e16703cb Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:08:05 +0530 Subject: [PATCH 02/10] ci: keep Markdown examples under the documentation gate --- tests/full_validate.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/full_validate.sh b/tests/full_validate.sh index a461439..6138c9f 100755 --- a/tests/full_validate.sh +++ b/tests/full_validate.sh @@ -49,7 +49,8 @@ run_typing() { run_style() { require_commands ruff - ruff format --check . + # Markdown examples are validated by the dedicated documentation gate. + ruff format --check --exclude "*.md" . ruff check . } From 8bb0562226cf1e4289656467246804e8541a00bd Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:11:20 +0530 Subject: [PATCH 03/10] fix: preserve consumer logging ownership and routing --- CHANGELOG.md | 1 + docs/integrations.md | 15 ++++++++ lib/python/base_cli/context.py | 2 ++ lib/python/base_cli/logging.py | 30 +++++++++++++--- tests/test_app_lifecycle.py | 4 +++ tests/test_cleanup_security.py | 4 ++- tests/test_logger_ownership.py | 65 ++++++++++++++++++++++++++++++++++ 7 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 tests/test_logger_ownership.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 59d0ef2..44b764d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and versions are tracked in the repo-root `VERSION` file. ### Fixed +- Preserve consumer-owned logging handlers, explicit levels, and parent routing across CLI invocations (#387). - Validate nested configuration mappings before merge/provenance traversal, reject recursive or excessively deep values with source-aware errors, and continue to accept shared YAML aliases. diff --git a/docs/integrations.md b/docs/integrations.md index 0bfb68c..3be142b 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -52,3 +52,18 @@ outcome, exit code, and duration. Raw argv, configuration values, filesystem paths, and secrets are never attached. A missing API package, invalid provider, or failing exporter is logged at debug level and treated as a no-op; it cannot change the command's exit status or cleanup behavior. + +## Logger ownership + +The lifecycle uses `base_cli.` and owns only the handlers it creates. +Consumer handlers remain attached and open after configuration and cleanup. +An explicit consumer logger level, or configuration on the `base_cli` parent, +is preserved, including `logging.config.dictConfig()` routing. Such levels may +filter records before the lifecycle handlers see them. Configure the consumer +logger at DEBUG if the persistent handler should receive every record. + +Unconfigured CLI loggers use DEBUG with propagation disabled to avoid duplicate +terminal output. `configure_logger(..., propagate=True)` explicitly enables host +routing; `False` disables it, and the default `None` preserves consumer routing. +Use a consumer handler or configure the `base_cli` parent before invoking an App +when embedding it in a host with centralized logging. diff --git a/lib/python/base_cli/context.py b/lib/python/base_cli/context.py index b20e615..523ac0a 100644 --- a/lib/python/base_cli/context.py +++ b/lib/python/base_cli/context.py @@ -156,6 +156,8 @@ def _cleanup_resources(self, *, preserve_temp_ownership: bool) -> None: elif not preserve_temp_ownership: self._close_owned_temp_descriptor() for handler in list(self.log.handlers): + if not getattr(handler, "_base_cli_owned", False): + continue try: handler.flush() except BaseException as exc: # pylint: disable=broad-exception-caught diff --git a/lib/python/base_cli/logging.py b/lib/python/base_cli/logging.py index 35aa824..da3e233 100644 --- a/lib/python/base_cli/logging.py +++ b/lib/python/base_cli/logging.py @@ -55,12 +55,15 @@ def configure_logger( json_logs: bool = False, run_id: str | None = None, log_level: str | None = None, + propagate: bool | 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. + Consumer handlers and configured levels are preserved. ``propagate=None`` + preserves consumer routing; unconfigured loggers default to no propagation. """ 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: @@ -72,11 +75,28 @@ def configure_logger( else _CONFIGURED_LOG_LEVELS[normalized_log_level] ) logger = logging.getLogger(f"base_cli.{cli_name}") - logger.setLevel(logging.DEBUG) - logger.propagate = False + foreign_handlers = any(not getattr(handler, "_base_cli_owned", False) for handler in logger.handlers) + parent = logger.parent + parent_configured = False + while parent is not None and parent is not logging.root: + parent_configured |= bool(parent.handlers) or parent.level != logging.NOTSET + parent = parent.parent + configured = ( + foreign_handlers + or parent_configured + or (logger.level != logging.NOTSET and logger.level != getattr(logger, "_base_cli_level", None)) + ) + if not configured: + logger.setLevel(logging.DEBUG) + logger._base_cli_level = logging.DEBUG # type: ignore[attr-defined] + if propagate is not None: + logger.propagate = propagate + elif not configured: + logger.propagate = False for handler in list(logger.handlers): - handler.close() - logger.removeHandler(handler) + if getattr(handler, "_base_cli_owned", False): + handler.close() + logger.removeHandler(handler) user_stream = stream if stream is not None else sys.stderr user_handler = logging.StreamHandler(user_stream) @@ -89,6 +109,7 @@ def configure_logger( run_id=run_id, ) ) + user_handler._base_cli_owned = True # type: ignore[attr-defined] logger.addHandler(user_handler) if log_file is not None: @@ -102,6 +123,7 @@ def configure_logger( run_id=run_id, ) ) + file_handler._base_cli_owned = True # type: ignore[attr-defined] logger.addHandler(file_handler) return logger diff --git a/tests/test_app_lifecycle.py b/tests/test_app_lifecycle.py index 1c8179b..3a83b1b 100644 --- a/tests/test_app_lifecycle.py +++ b/tests/test_app_lifecycle.py @@ -15,6 +15,8 @@ class _BrokenHandler(logging.Handler): + _base_cli_owned = True + def emit(self, record: logging.LogRecord) -> None: del record @@ -205,6 +207,8 @@ def test_handler_removal_interruption_uses_direct_detach_fallback(self) -> None: logger = logging.Logger("isolated-handler-removal") logger.addHandler(logging.NullHandler()) logger.addHandler(logging.NullHandler()) + for handler in logger.handlers: + handler._base_cli_owned = True logger.removeHandler = mock.Mock(side_effect=KeyboardInterrupt()) context = base_cli.Context( cli_name="handler-removal-interrupt", diff --git a/tests/test_cleanup_security.py b/tests/test_cleanup_security.py index 36e6ee7..7e644fa 100644 --- a/tests/test_cleanup_security.py +++ b/tests/test_cleanup_security.py @@ -30,7 +30,9 @@ def _context( ) -> tuple[base_cli.Context, io.StringIO]: stream = io.StringIO() logger = logging.Logger(f"cleanup-security-{id(stream)}", level=logging.DEBUG) - logger.addHandler(logging.StreamHandler(stream)) + handler = logging.StreamHandler(stream) + handler._base_cli_owned = True + logger.addHandler(handler) context = base_cli.Context( cli_name="cleanup-security", run_id=run_id, diff --git a/tests/test_logger_ownership.py b/tests/test_logger_ownership.py new file mode 100644 index 0000000..18bd3e1 --- /dev/null +++ b/tests/test_logger_ownership.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import io +import logging +from pathlib import Path +from unittest.mock import Mock + +import base_cli +from base_cli.testing import invoke + + +def test_consumer_handler_and_level_survive_invocation(tmp_path: Path) -> None: + logger = logging.getLogger("base_cli.owned-handler-test") + sink = logging.StreamHandler(io.StringIO()) + sink.close = Mock() + logger.addHandler(sink) + logger.setLevel(logging.INFO) + logger.propagate = True + app = base_cli.App(name="owned-handler-test", version="1") + + @app.command() + def main(ctx: base_cli.Context) -> None: + ctx.log.info("consumer message") + + try: + assert invoke(app, [], home=tmp_path).exit_code == 0 + assert logger.handlers == [sink] + assert logger.level == logging.INFO + assert logger.propagate + sink.close.assert_not_called() + assert "consumer message" in sink.stream.getvalue() + finally: + logger.removeHandler(sink) + + +def test_default_logger_does_not_duplicate_through_root() -> None: + stream = io.StringIO() + root_sink = logging.StreamHandler(stream) + logging.root.addHandler(root_sink) + try: + logger = base_cli.configure_logger("default-no-duplicates", None, False, stream=stream) + logger.warning("one warning") + assert stream.getvalue().count("one warning") == 1 + finally: + logging.root.removeHandler(root_sink) + for handler in list(logger.handlers): + handler.close() + logger.removeHandler(handler) + + +def test_parent_consumer_configuration_is_preserved() -> None: + parent = logging.getLogger("base_cli.host") + stream = io.StringIO() + sink = logging.StreamHandler(stream) + parent.addHandler(sink) + try: + logger = base_cli.configure_logger("host.child", None, False, stream=io.StringIO()) + logger.warning("host record") + assert logger.propagate + assert "host record" in stream.getvalue() + finally: + parent.removeHandler(sink) + for handler in list(logger.handlers): + handler.close() + logger.removeHandler(handler) From 73770a5c9899b9ae672c873d3a8f25b63febc791 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:54:28 +0530 Subject: [PATCH 04/10] ci: separate sustained persistence cost from hosted filesystem tails --- docs/performance.md | 12 +++++++++++- scripts/benchmark_runtime.py | 9 +++++++-- tests/test_benchmark_runtime.py | 12 ++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/docs/performance.md b/docs/performance.md index 8195656..96e0797 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -63,7 +63,7 @@ scheduler outlier block a change. | Cold no-op invocation, including startup and dispatch | 2,000 ms | 2,000 ms | 4,000 ms | 4,000 ms | | Base-cli lifecycle increment over Click warm dispatch | 5 ms | 5 ms | 15 ms | 15 ms | | Warm invocation and non-persistence feature scenarios | 50 ms | 50 ms | 100 ms | 100 ms | -| File-persistence-enabled scenario | 50 ms | 50 ms | 250 ms | 50 ms | +| File-persistence-enabled scenario | 125 ms | 125 ms | 250 ms | 50 ms | An initial 31-sample local calibration on macOS (Python 3.14.6, Apple Silicon) measured approximately 101 ms for base-cli cold import, 0.56 ms for warm @@ -76,6 +76,16 @@ budget instead of weakening other warm-scenario gates. These measurements are CI calibration evidence, not adoption claims or release comparisons; review subsequent retained artifacts before tightening platform budgets. +October 2026 hosted recalibration separates sustained persistence cost from +filesystem tails on Unix/macOS: median must remain at most **50 ms** and p95 +at most **125 ms**. The previous 50 ms p95 cap repeatedly rejected otherwise +unchanged runtime code, including the validation-only PR. Observed pairs were +14.66/118.04 ms (Unix median/p95) and 24.93/61.93 and 26.12/87.37 ms (macOS). +Evidence: [Unix run](https://github.com/basefoundry/base-cli/actions/runs/37048785893) +and [macOS validation-only run](https://github.com/basefoundry/base-cli/actions/runs/37052368353). +A sustained slowdown over 50 ms still fails; p95 over 125 ms also fails. +Windows, WSL, parser, import, and non-persistence limits are unchanged. + Each report is versioned as `base-cli.benchmark` schema version 1 and contains the package version, source revision, UTC timestamp, platform profile, Python version/ABI, OS release, architecture, CPU count, sample count, medians, p95, diff --git a/scripts/benchmark_runtime.py b/scripts/benchmark_runtime.py index 24f3e78..f5ade65 100755 --- a/scripts/benchmark_runtime.py +++ b/scripts/benchmark_runtime.py @@ -48,8 +48,8 @@ "wsl": 100.0, } PERSISTENCE_ENABLED_P95_BUDGETS_MS = { - "unix": 50.0, - "macos": 50.0, + "unix": 125.0, + "macos": 125.0, "windows": 250.0, "wsl": 50.0, } @@ -331,6 +331,11 @@ def _check_results(results: dict[str, FrameworkMetrics]) -> list[str]: feature_budget = _feature_budget_for_platform(name, BENCHMARK_PLATFORM) if p95 is not None and p95 > feature_budget: failures.append(f"base-cli {name} p95 exceeded {feature_budget:.0f} ms") + if BENCHMARK_PLATFORM in {"unix", "macos"} and isinstance(features, dict): + persistence = features.get("persistence_enabled_ms", {}) + median = persistence.get("median") if isinstance(persistence, dict) else None + if not isinstance(median, (int, float)) or not 0 <= median <= 50.0: + failures.append("base-cli persistence_enabled_ms median is missing, invalid, or exceeded 50 ms") return failures diff --git a/tests/test_benchmark_runtime.py b/tests/test_benchmark_runtime.py index 8528e5e..7518e06 100644 --- a/tests/test_benchmark_runtime.py +++ b/tests/test_benchmark_runtime.py @@ -125,6 +125,18 @@ def test_windows_persistence_budget_rejects_material_regressions(self) -> None: self.assertTrue(any("persistence_enabled_ms p95 exceeded 250 ms" in failure for failure in failures)) + def test_persistence_budget_separates_sustained_cost_from_filesystem_tails(self) -> None: + for profile in ("unix", "macos"): + for median, p95, fails in ((26.0, 118.0, False), (51.0, 60.0, True), (26.0, 126.0, True)): + with self.subTest(profile=profile, median=median, p95=p95): + metrics = self._complete_results() + sample = self._summary(p95) + sample["median"] = median + metrics["base-cli"]["features"]["persistence_enabled_ms"] = sample + with mock.patch.object(benchmark_runtime, "BENCHMARK_PLATFORM", profile): + failures = benchmark_runtime._check_results(metrics) + self.assertEqual(any("persistence_enabled_ms" in failure for failure in failures), fails) + def test_github_summary_separates_lifecycle_overhead_from_parser(self) -> None: metrics = self._complete_results(lifecycle_p95=4.0, click_p95=1.5) report = { From 8ca0027b7c8fb1056cafdd1bceb08869cdbe3927 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:03:07 +0530 Subject: [PATCH 05/10] docs: regenerate public API signatures for configuration trust options --- docs/api-reference.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 8f36643..9797f47 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1030,7 +1030,7 @@ 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, log_level: '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, propagate: 'bool | None' = None) -> 'logging.Logger'` **Behavior:** Configure user-facing and persistent handlers for a CLI logger. From 9db3464f6705b0b6501bccaaa16ded6b6271fb47 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:09:57 +0530 Subject: [PATCH 06/10] fix: preserve logger routing and levels --- lib/python/base_cli/logging.py | 12 ++++-------- tests/test_logger_ownership.py | 12 ++++++++++++ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/lib/python/base_cli/logging.py b/lib/python/base_cli/logging.py index 073829a..914c44f 100644 --- a/lib/python/base_cli/logging.py +++ b/lib/python/base_cli/logging.py @@ -83,18 +83,14 @@ def configure_logger( while parent is not None and parent is not logging.root: parent_configured |= bool(parent.handlers) or parent.level != logging.NOTSET parent = parent.parent - configured = ( - foreign_handlers - or parent_configured - or (logger.level != logging.NOTSET and logger.level != getattr(logger, "_base_cli_level", None)) - ) - if not configured: + externally_routed = foreign_handlers or parent_configured + if logger.level == logging.NOTSET: logger.setLevel(logging.DEBUG) logger._base_cli_level = logging.DEBUG # type: ignore[attr-defined] if propagate is not None: logger.propagate = propagate - elif not configured: - logger.propagate = False + else: + logger.propagate = externally_routed for handler in list(logger.handlers): if getattr(handler, "_base_cli_owned", False): handler.close() diff --git a/tests/test_logger_ownership.py b/tests/test_logger_ownership.py index 18bd3e1..6353a13 100644 --- a/tests/test_logger_ownership.py +++ b/tests/test_logger_ownership.py @@ -43,9 +43,21 @@ def test_default_logger_does_not_duplicate_through_root() -> None: assert stream.getvalue().count("one warning") == 1 finally: logging.root.removeHandler(root_sink) + + +def test_explicit_logger_level_does_not_enable_root_propagation() -> None: + logger = logging.getLogger("base_cli.explicit-level-only") + logger.setLevel(logging.INFO) + logger.propagate = True + try: + configured = base_cli.configure_logger("explicit-level-only", None, False, stream=io.StringIO()) + assert configured.level == logging.INFO + assert configured.propagate is False + finally: for handler in list(logger.handlers): handler.close() logger.removeHandler(handler) + logger.setLevel(logging.NOTSET) def test_parent_consumer_configuration_is_preserved() -> None: From 72336ece42b9054602e94672243085486ed88bbd Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:09:58 +0530 Subject: [PATCH 07/10] ci: expose consumer typing source coverage --- docs/testing.md | 11 ++++++++ scripts/validate_consumer_typing.py | 2 ++ tests/test_validate_consumer_typing.py | 35 ++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 tests/test_validate_consumer_typing.py diff --git a/docs/testing.md b/docs/testing.md index c387412..eb3f143 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -56,6 +56,17 @@ The full gate writes a machine-readable result to Node.js is unavailable, the result is marked `partial`, the gate exits with status `2`, and it cannot be reported as an authoritative pass. +Examples and compatibility consumers use the fully parameterized context type +when strict mypy checks a callback directly: + +```python +from typing import Any +import base_cli + +def main(ctx: base_cli.Context[Any, Any, Any]) -> None: + ... +``` + ### Consumer source quality The style gate runs Ruff over the entire repository with its standard generated-file diff --git a/scripts/validate_consumer_typing.py b/scripts/validate_consumer_typing.py index 01747ee..580313b 100644 --- a/scripts/validate_consumer_typing.py +++ b/scripts/validate_consumer_typing.py @@ -24,6 +24,8 @@ def main() -> int: sources = [name for name in files if Path(name).parts[0] not in excluded] if not sources: raise RuntimeError("No consumer Python sources found") + print("Consumer typing sources:") + print("\n".join(f"- {source}" for source in sources)) return subprocess.run( [sys.executable, "-m", "mypy", "--strict", "--explicit-package-bases", *sources], cwd=root, diff --git a/tests/test_validate_consumer_typing.py b/tests/test_validate_consumer_typing.py new file mode 100644 index 0000000..6757020 --- /dev/null +++ b/tests/test_validate_consumer_typing.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import importlib.util +import subprocess +from pathlib import Path +from unittest.mock import patch + + +SCRIPT = Path(__file__).parents[1] / "scripts" / "validate_consumer_typing.py" +SPEC = importlib.util.spec_from_file_location("validate_consumer_typing", SCRIPT) +if SPEC is None or SPEC.loader is None: # pragma: no cover + raise ImportError(f"Unable to load {SCRIPT}") +module = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(module) + + +def test_typing_gate_discovers_and_reports_only_consumer_sources() -> None: + discovery = subprocess.CompletedProcess( + ["git"], + 0, + "lib/python/base_cli/core.py\nscripts/tool.py\ntests/test.py\n" + "examples/demo/src/demo/cli.py\ncompatibility/consumers/atlas/src/atlas/cli.py\n", + "", + ) + mypy = subprocess.CompletedProcess(["mypy"], 0) + with patch.object(module.subprocess, "run", side_effect=[discovery, mypy]) as run: + assert module.main() == 0 + + command = run.call_args_list[1].args[0] + assert command[-2:] == ["compatibility/consumers/atlas/src/atlas/cli.py", "examples/demo/src/demo/cli.py"] + assert "lib/python/base_cli/core.py" not in command + assert "scripts/tool.py" not in command + assert "tests/test.py" not in command + environment = run.call_args_list[1].kwargs["env"] + assert str(SCRIPT.parents[1] / "lib/python") in environment["MYPYPATH"] From ae935d1f7c65f5588a0046789bb8d4100a41db5a Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:12:02 +0530 Subject: [PATCH 08/10] fix: serialize logger reconfiguration --- lib/python/base_cli/logging.py | 55 ++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/lib/python/base_cli/logging.py b/lib/python/base_cli/logging.py index 914c44f..30309ad 100644 --- a/lib/python/base_cli/logging.py +++ b/lib/python/base_cli/logging.py @@ -8,6 +8,7 @@ import warnings from io import TextIOWrapper from pathlib import Path +from threading import RLock from typing import BinaryIO, TextIO, cast try: @@ -43,6 +44,7 @@ "error": logging.ERROR, "critical": logging.CRITICAL, } +_CONFIGURE_LOGGER_LOCK = RLock() # pylint: disable=too-many-arguments @@ -91,38 +93,39 @@ def configure_logger( logger.propagate = propagate else: logger.propagate = externally_routed - for handler in list(logger.handlers): - if getattr(handler, "_base_cli_owned", False): - handler.close() - logger.removeHandler(handler) - - user_stream = stream if stream is not None else sys.stderr - user_handler = logging.StreamHandler(user_stream) - user_handler.setLevel(stream_level) - user_handler.setFormatter( - _handler_formatter( - formatter, - use_color=_use_color(user_stream), - json_logs=json_logs, - run_id=run_id, - ) - ) - user_handler._base_cli_owned = True # type: ignore[attr-defined] - logger.addHandler(user_handler) - - if log_file is not None: - file_handler = SecureLogFileHandler(log_file, encoding="utf-8") - file_handler.setLevel(logging.DEBUG) - file_handler.setFormatter( + with _CONFIGURE_LOGGER_LOCK: + for handler in list(logger.handlers): + if getattr(handler, "_base_cli_owned", False): + handler.close() + logger.removeHandler(handler) + + user_stream = stream if stream is not None else sys.stderr + user_handler = logging.StreamHandler(user_stream) + user_handler.setLevel(stream_level) + user_handler.setFormatter( _handler_formatter( formatter, - use_color=False, + use_color=_use_color(user_stream), json_logs=json_logs, run_id=run_id, ) ) - file_handler._base_cli_owned = True # type: ignore[attr-defined] - logger.addHandler(file_handler) + user_handler._base_cli_owned = True # type: ignore[attr-defined] + logger.addHandler(user_handler) + + if log_file is not None: + file_handler = SecureLogFileHandler(log_file, encoding="utf-8") + file_handler.setLevel(logging.DEBUG) + file_handler.setFormatter( + _handler_formatter( + formatter, + use_color=False, + json_logs=json_logs, + run_id=run_id, + ) + ) + file_handler._base_cli_owned = True # type: ignore[attr-defined] + logger.addHandler(file_handler) return logger From 990cb66aee05141aff1372929731b0514342e48f Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:18:15 +0530 Subject: [PATCH 09/10] style: format typing gate test --- tests/test_validate_consumer_typing.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_validate_consumer_typing.py b/tests/test_validate_consumer_typing.py index 6757020..51e3632 100644 --- a/tests/test_validate_consumer_typing.py +++ b/tests/test_validate_consumer_typing.py @@ -5,7 +5,6 @@ from pathlib import Path from unittest.mock import patch - SCRIPT = Path(__file__).parents[1] / "scripts" / "validate_consumer_typing.py" SPEC = importlib.util.spec_from_file_location("validate_consumer_typing", SCRIPT) if SPEC is None or SPEC.loader is None: # pragma: no cover From 6efcb603337db71126b5b30dd66c854a4bb5ee02 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:01:36 +0530 Subject: [PATCH 10/10] test: cover debug logging with consumer handlers --- tests/test_logger_ownership.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/test_logger_ownership.py b/tests/test_logger_ownership.py index 6353a13..0cd4e8c 100644 --- a/tests/test_logger_ownership.py +++ b/tests/test_logger_ownership.py @@ -33,6 +33,40 @@ def main(ctx: base_cli.Context) -> None: logger.removeHandler(sink) +def test_foreign_handler_without_level_preserves_debug_records(tmp_path: Path) -> None: + logger = logging.getLogger("base_cli.foreign-handler-no-level") + consumer_stream = io.StringIO() + consumer_handler = logging.StreamHandler(consumer_stream) + user_stream = io.StringIO() + log_file = tmp_path / "run.log" + logger.addHandler(consumer_handler) + logger.setLevel(logging.NOTSET) + logger.propagate = False + try: + configured = base_cli.configure_logger( + "foreign-handler-no-level", + log_file, + debug=True, + stream=user_stream, + propagate=False, + ) + configured.info("info marker") + configured.debug("debug marker") + assert "info marker" in user_stream.getvalue() + assert "debug marker" in user_stream.getvalue() + assert "info marker" in consumer_stream.getvalue() + assert "debug marker" in consumer_stream.getvalue() + file_text = log_file.read_text(encoding="utf-8") + assert "info marker" in file_text + assert "debug marker" in file_text + finally: + for handler in list(logger.handlers): + handler.close() + logger.removeHandler(handler) + logger.setLevel(logging.NOTSET) + logger.propagate = True + + def test_default_logger_does_not_duplicate_through_root() -> None: stream = io.StringIO() root_sink = logging.StreamHandler(stream)