Logging

Xclif wires Python’s standard logging to its built-in -v / --verbose flag. You write ordinary log calls; Xclif decides which records are shown and renders them with Rich.

Note

Xclif’s logging is not a separate logging system — it is a thin convenience layer over Python’s standard logging. log forwards to logging.getLogger, configure_logging attaches a handler to the ordinary root logger, and every record flows through the normal logging tree. Anything you already know about the standard library — levels, handlers, filters, propagation, logging.getLogger(__name__) — applies unchanged. Xclif only adds the verbosity mapping, a Rich handler, and a couple of ergonomic helpers on top.

Basic usage

Import the bundled log and call it. During a command run, Xclif has already set the level from --verbose and attached a Rich handler:

from xclif import command, log

@command()
def _(target: str) -> None:
    """Deploy to a target."""
    log.info("Connecting to %s...", target)    # shown at -v
    log.debug("Resolving %s...", target)        # shown at -vv

Run it:

myapp deploy prod          # warnings and errors only
myapp deploy prod -v       # + info
myapp deploy prod -vv      # + debug, with file:line locations
myapp deploy prod -vvv     # + timestamps (verbose formatter)

log provides the usual methods — debug, info, warning, error, critical, exception, and log — accepting the same %-style arguments as the standard library. For a more modern, brace-style API see modern-style logging below.

It is not a logger of its own: on each call it resolves the calling module’s __name__ and forwards to logging.getLogger(<that module>). So records carry the right source name and file:line no matter which module imported log, and everything still flows through the standard logging tree.

Modern-style logging

The standard library’s %-style placeholders date back to 2003, before str.format (Python 2.6) and long before f-strings (3.6) existed — the logging API was simply built around the only formatting Python had at the time. An advantage of this approach over f-strings is that string formatting becomes lazy: the message is only interpolated if the record clears the active level, so a filtered-out log.debug("%s", value) costs nothing to format.

log.info("connecting to %s on port %d", host, port)   # stdlib, lazy

But because str.format exists now, if you prefer modern brace formatting, log offers f-prefixed variants — fdebug, finfo, fwarning, ferror, fcritical, fexception, and flog. They take str.format ({} / {name}) placeholders and, as a bonus, call any callable argument. Both the formatting and the call are deferred until the record is actually emitted:

log.finfo("connecting to {} on port {}", host, port)
log.fdebug("state: {}", expensive_dump)            # called only at -vv
log.fdebug("state: {}", lambda: obj.to_json())     # lambda, same deal
log.finfo("{user} from {host}", user=u, host=get_host)

The callable support solves a problem %-style cannot: % defers the formatting but not the arguments, so log.debug("%s", expensive_dump()) runs expensive_dump() every time, even when filtered out.

With the f-variants you pass the callable itself (expensive_dump) — never its result (expensive_dump()) — and it is invoked only when needed. To log a callable’s own repr, stringify it at the call site: log.fdebug("fn is {}", repr(fn)).

The same functions are available as f for code that prefers a free function over the log object — f.debug(...) is exactly log.fdebug(...):

from xclif import f

f.debug("state: {}", expensive_dump)

Tip

f-strings work too — log.info(f"port {port}") — but they format eagerly, every time the line runs, even when the message is discarded. The f-variants keep brace syntax and stay lazy.

When to use which

  • log.info / log.debug / … — the default. Byte-for-byte compatible with the standard library, so existing code and logging linters keep working. Reach for these unless you have a reason not to.

  • log.finfo / log.fdebug / … — when you want brace formatting, or when an argument is expensive to compute and should only run if the record is emitted.

Skipping the ergonomic layer

You do not have to use log or f at all. If you prefer plain standard loggers — for an explicitly named logger, a per-component level, or simply to avoid Xclif-specific imports — just use logging.getLogger() directly:

import logging
logger = logging.getLogger(__name__)   # the standard way

logger.info("Connecting to %s...", target)
logger.debug("Resolving %s...", target)

This still gets Xclif’s verbosity level and Rich rendering for free: your named logger propagates up to the root logger, which is where configure_logging() sets the level and installs the handler. There is no extra wiring to do.

get_logger is a thin convenience wrapper for readers who would rather import everything from xclif; it returns the exact same standard logger:

from xclif import get_logger
logger = get_logger(__name__)          # identical to logging.getLogger

Note

This is also the path with the least overhead. The log object inspects the call stack on every call (via sys._getframe) to recover the calling module’s name and keep file:line accurate — the cost of being a single shared object. A logger you name yourself with getLogger(__name__) already knows its module, so it skips that work entirely.

Verbosity-to-level mapping

Each -v raises the verbosity count, which maps to a standard logging level and a progressively more detailed Rich formatter:

Flag

Level shown

Formatter detail

(none)

WARNING

message only

-v

INFO

message only

-vv

DEBUG

  • file/line locations

-vvv

DEBUG (NOTSET)

  • timestamps, traceback locals

You can resolve the level yourself with level_from_verbosity(), or read it off the dispatch context:

from xclif import get_context, level_from_verbosity

get_context().verbosity     # raw count: 0–3
get_context().log_level     # the implied logging level
level_from_verbosity(2)     # logging.DEBUG

Integrating with Rich and the standard library

Because Xclif only attaches a handler to the root logger, the two systems compose rather than compete:

  • Any standard-library logger flows through Rich — log is built on top of logging, not beside it. Third-party libraries that log via logging are rendered through the same handler.

  • The handler is lazy: Rich is only imported the first time a record actually passes the level filter, keeping the startup hot path cheap.

  • Output goes to stderr, so it never pollutes a command’s stdout.

Cooperating with application-owned logging

If your application has already configured its own handlers (for example, a file handler set up in __main__), Xclif leaves them in place and only updates the logger level — it will not hijack your output. To force Xclif’s Rich handler to replace existing handlers, call configure_logging() yourself with force=True:

from xclif import configure_logging

configure_logging(verbosity=2, colors="never", force=True)

Note that even when existing handlers are respected, Xclif still sets the root logger level from --verbose on every dispatch. If you want to own the level too, opt out entirely (below) and set it yourself.

Turning Xclif logging off entirely

To stop Xclif from configuring logging at all — no level changes, no Rich handler — pass logging=False to from_routes() (or the Cli constructor). Logging is then 100% yours; the verbosity count is still available through get_context():

import logging
from xclif import Cli, get_context

cli = Cli.from_routes(routes, logging=False)

# In a command, wire up logging however you like:
def run() -> None:
    level = logging.DEBUG if get_context().verbosity >= 2 else logging.INFO
    logging.getLogger().setLevel(level)
    ...

Manual configuration

You rarely need to call configure_logging() directly — Xclif invokes it during dispatch. It is exposed for tests, scripts, and apps that manage their own startup. Common overrides:

from xclif import configure_logging

# Pin an explicit level regardless of verbosity:
configure_logging(level="ERROR")

# Target a specific logger instead of the root:
configure_logging(verbosity=1, logger="myapp")

# Force timestamps off even at -vvv:
configure_logging(verbosity=3, show_time=False)

The colors argument mirrors the --colors flag ("auto", "always", "never") so log output honors the same color preference as the rest of the CLI.

See the API Reference reference for the full signatures of configure_logging(), get_logger(), and level_from_verbosity().