Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
30e9e71
ci: cover all consumer fixtures in style and strict typing gates
codeforester Oct 2, 2026
710208b
ci: keep Markdown examples under the documentation gate
codeforester Oct 2, 2026
8bb0562
fix: preserve consumer logging ownership and routing
codeforester Oct 2, 2026
d352e43
Merge remote-tracking branch 'origin/main' into ci/393-20261003-ci-ru…
codeforester Oct 2, 2026
6ee5cc9
Merge branch 'ci/393-20261003-ci-ruff-check-and-mypy-do-not-cover-the…
codeforester Oct 2, 2026
73770a5
ci: separate sustained persistence cost from hosted filesystem tails
codeforester Oct 2, 2026
bdf26d9
Merge branch 'ci/393-20261003-ci-ruff-check-and-mypy-do-not-cover-the…
codeforester Oct 2, 2026
8ca0027
docs: regenerate public API signatures for configuration trust options
codeforester Oct 2, 2026
857e52e
Merge main for branch maintenance (#426)
codeforester Oct 4, 2026
b48e444
Merge updated PR #414 for branch maintenance (#426)
codeforester Oct 4, 2026
9db3464
fix: preserve logger routing and levels
codeforester Oct 5, 2026
72336ec
ci: expose consumer typing source coverage
codeforester Oct 5, 2026
ae935d1
fix: serialize logger reconfiguration
codeforester Oct 5, 2026
990cb66
style: format typing gate test
codeforester Oct 5, 2026
0f35aae
Merge remote-tracking branch 'origin/main' into ci/393-20261003-ci-ru…
codeforester Oct 5, 2026
6efcb60
test: cover debug logging with consumer handlers
codeforester Oct 5, 2026
3f58457
Merge remote-tracking branch 'origin/ci/393-20261003-ci-ruff-check-an…
codeforester Oct 5, 2026
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,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.
Expand Down
2 changes: 1 addition & 1 deletion compatibility/consumers/atlas_click/src/atlas_click/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
1 change: 0 additions & 1 deletion compatibility/consumers/atlas_click/tests/test_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from pathlib import Path

import base_cli

from atlas_click.cli import command


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import base_cli
import typer


cli = typer.Typer(help="Deploy Beacon services with typed parameters.")


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from pathlib import Path

import base_cli

from beacon_typer.cli import command


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from pathlib import Path

import base_cli

from cinder_automation.cli import app


Expand Down
2 changes: 1 addition & 1 deletion docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
15 changes: 15 additions & 0 deletions docs/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,21 @@ exceptions. Interrupts and other non-success outcomes are therefore visible as
errors while retaining the existing `base_cli.outcome` attribute for detailed
dashboard filtering.

## Logger ownership

The lifecycle uses `base_cli.<cli_name>` 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.

## Log timestamp environment variable

Set `BASE_CLI_LOG_UTC=1` to make the default text formatter use UTC
Expand Down
19 changes: 19 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,22 @@ 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.

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
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.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

from typing import Any

import base_cli
import click

Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion examples/minimal_cli/src/minimal_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

from typing import Any

import base_cli

app = base_cli.App(
Expand All @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions lib/python/base_cli/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 46 additions & 25 deletions lib/python/base_cli/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -43,6 +44,7 @@
"error": logging.ERROR,
"critical": logging.CRITICAL,
}
_CONFIGURE_LOGGER_LOCK = RLock()


# pylint: disable=too-many-arguments
Expand All @@ -57,12 +59,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:
Expand All @@ -74,37 +79,53 @@ 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
for handler in list(logger.handlers):
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,
)
)
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(
foreign_handlers = any(not getattr(handler, "_base_cli_owned", False) for handler in logger.handlers)
Comment thread
codeforester marked this conversation as resolved.
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
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
else:
logger.propagate = externally_routed
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,
)
)
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


Expand Down
48 changes: 48 additions & 0 deletions scripts/validate_consumer_typing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""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")
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,
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())
7 changes: 5 additions & 2 deletions tests/full_validate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,15 @@ 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
# Markdown examples are validated by the dedicated documentation gate.
ruff format --check --exclude "*.md" .
ruff check .
}

run_contracts() {
Expand Down
4 changes: 4 additions & 0 deletions tests/test_app_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@


class _BrokenHandler(logging.Handler):
_base_cli_owned = True

def emit(self, record: logging.LogRecord) -> None:
del record

Expand Down Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion tests/test_cleanup_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading