# Python Logging Usage

Practical examples for using `pytools.logging`.

## Why handler config uses import paths, not instances

`bootstrap` and `LoggingState` describe logging the same way `logging.config.dictConfig` does: **nested dicts** whose values are JSON-serializable types (strings, numbers, dicts, lists). Handlers are described by entries such as:

- `"()": "package.module.factory_fn"` — import `factory_fn` and call it (with optional extra keys passed as keyword arguments to that callable per dictConfig rules), and use the returned `logging.Handler`.
- Or `"class": "logging.StreamHandler"` with constructor kwargs, as documented in the Python logging cookbook.

Passing a pre-built `RichHandler(...)` or `StreamHandler(...)` object into `bootstrap(handlers=...)` does not work, because those objects are not part of the dictConfig schema and cannot be round-tripped through `export_config` for worker processes.

**Pattern for Rich or other custom handlers:** implement a module-level function that constructs and returns the handler, and reference it by dotted path:

```python
# myapp/logging_handlers.py
import logging

from rich.console import Console
from rich.logging import RichHandler


def create_app_console_handler() -> logging.Handler:
    console = Console(force_terminal=True)
    h = RichHandler(console=console, rich_tracebacks=True, show_path=True)
    h.setFormatter(logging.Formatter("(%(name)s) %(message)s"))
    return h
```

```python
state = bootstrap(
    root_level=logging.INFO,
    handlers={
        "default": {"()": "myapp.logging_handlers.create_app_console_handler"},
    },
)
```

The factory may close over module-level constants; it must be importable under the same environment as the process that runs `apply_state` (or Uvicorn workers that receive `export_config(state)`).

## Example 1: Typer CLI (Single Process)

```python
import logging
from pathlib import Path

import typer

from pytools.logging import bootstrap, merge_logger_levels, set_root_level
from pytools.logging.apply import apply_state
from pytools.logging.resolve import resolve_logging_env_path
from pytools.logging.utils import load_logging_env, parse_logger_level_specs

app = typer.Typer()
log = logging.getLogger(__name__)


@app.command()
def run(
    log_level: str = "info",
    logger: list[str] | None = None,
    logger_file: str | None = None,
):
    # Define baseline state
    state = bootstrap(root_level=logging.WARNING)

    # Resolve optional env file path
    config_path = resolve_logging_env_path(logger_file)
    file_levels = (
        load_logging_env(config_path)
        if config_path and Path(config_path).expanduser().is_file()
        else {}
    )

    # App-level additive precedence: file first, then CLI per-key override
    cli_levels = parse_logger_level_specs(logger or [])
    state = merge_logger_levels(state, {**file_levels, **cli_levels})
    state = set_root_level(state, getattr(logging, log_level.upper()))

    # Activate runtime logging
    apply_state(state)
    log.info("CLI started")
```

## Example 2: Uvicorn / Multiprocess Parity

Apply locally and pass the same config artifact to Uvicorn workers.

```python
import logging
import uvicorn

from pytools.logging import bootstrap, export_config, merge_logger_levels, set_root_level
from pytools.logging.apply import apply_state

state = bootstrap(root_level=logging.WARNING)
state = merge_logger_levels(state, {"ai_server": logging.INFO})
state = set_root_level(state, logging.INFO)

apply_state(state)  # local process

uvicorn.run(
    "ai_server.app:app",
    host="0.0.0.0",
    port=8000,
    log_config=export_config(state),  # worker process parity
)
```

Workers receive a plain dict; every handler must still be reconstructible from strings (import paths and class names), which is why factory functions are the right abstraction.

## Example 3: Minimal Script

```python
import logging
from pytools.logging import bootstrap
from pytools.logging.apply import apply_state

state = bootstrap(
    root_level=logging.INFO,
    logger_levels={"my.task": logging.DEBUG},
)
apply_state(state)
logging.getLogger("my.task").debug("ready")
```

## Example 4: Default Rich console from bootstrap

Use the built-in Rich factory instead of a custom one:

```python
import logging
from pytools.logging import bootstrap
from pytools.logging.apply import apply_state

state = bootstrap(root_level=logging.INFO, rich=True)
apply_state(state)
```

## Notes

- `bootstrap`, `resolve`, `mutate`, and `export` are data-oriented stages.
- `apply_state` / `apply_config` are the explicit side-effect boundary.
- Keep app-specific precedence rules in the application layer (for example `ai_server/cli.py`).
- Default file discovery uses `{project_root}/.env.pytools_logging` when no explicit path or env var is provided.

## Example 5: Inherited or Base Class `this.log`

```python
import logging
from abc import ABC


class BaseABC(ABC):  # noqa: B024
    """
    Base abstract class with automatic logger initialization.

    All ABC classes (SingletonABC, ServiceABC, etc.) should inherit from this
    to get consistent logger functionality with proper environment detection.
    """

    _id: str = ""

    def __init__(
        self,
        **kwargs,
    ):
        """Initialize base class with automatic logger setup"""
        super().__init__(**kwargs)

    @property
    def log(self):
        return logging.getLogger(f"{self.__module__}.{self.__class__.__name__}")
```

## If you truly need a one-off instance

Attach it **after** dictConfig, outside this state machine, for example `logging.getLogger().addHandler(my_handler)`. That bypasses `export_config` parity for whatever you attach manually—use factories when workers must match the main process.
