Command-line tools: argparse and logging

argparse subcommands and flags, meaningful exit codes, the logging module's levels and handlers, and configuration from environment variables and files.

argparse and exit codes

argparse parses arguments, generates help, and exits with status 2 on invalid input — which is exactly what a shell script expects from a misuse of arguments.

import argparse, sys

def build_parser():
    parser = argparse.ArgumentParser(
        prog="shop-tools",
        description="Report on shop data.",
    )
    parser.add_argument("--verbose", "-v", action="count", default=0)
    sub = parser.add_subparsers(dest="command", required=True)

    report = sub.add_parser("report", help="print a summary")
    report.add_argument("path", type=argparse.FileType("r", encoding="utf-8"))
    report.add_argument("--format", choices=["text", "json"], default="text")
    report.add_argument("--limit", type=int, default=100)

    sync = sub.add_parser("sync", help="push to the remote")
    sync.add_argument("--dry-run", action="store_true")
    sync.set_defaults(func=do_sync)
    report.set_defaults(func=do_report)

    return parser

def main(argv=None):
    args = build_parser().parse_args(argv)
    try:
        args.func(args)
    except Exception as err:
        print(f"error: {err}", file=sys.stderr)     # errors go to stderr
        return 1
    return 0                                        # 0 means success

if __name__ == "__main__":
    raise SystemExit(main())
  • Exit codes: 0 success, 1 a runtime failure, 2 bad usage. Returning an integer from main keeps the tool composable in pipelines.
  • argparse.FileType opens files and reports a clean error when they are missing.
  • Keep main(argv=None) so tests can call it with a list of arguments instead of editing sys.argv.
  • Write results to stdout and diagnostics to stderr, so redirecting output never hides the reason for a failure.

Levels, handlers and formatting

Use logging instead of print for anything that is not a deliberate program output. Levels let the operator choose how much detail they see without a code change.

import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
)
log = logging.getLogger(__name__)      # one logger per module

log.debug("cache keys: %s", keys)      # lazy: formatted only if emitted
log.info("processed %d rows in %.2fs", count, elapsed)
log.warning("retrying attempt %d of %d", n, total)
log.error("failed to reach %s", host)
log.exception("unhandled error")       # includes the traceback

# a file handler for the audit trail alongside console output
handler = logging.FileHandler("shop.log", encoding="utf-8")
handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
log.addHandler(handler)

# configure a whole application tree from a dict
from logging.config import dictConfig

dictConfig({
    "version": 1,
    "disable_existing_loggers": False,
    "handlers": {"console": {"class": "logging.StreamHandler"}},
    "root": {"level": "WARNING", "handlers": ["console"]},
    "loggers": {"shop": {"level": "DEBUG", "propagate": True}},
})
LevelUse forSeen by default
DEBUGDiagnostics while developingNo
INFONormal progress worth recordingNo
WARNINGSomething unexpected but handledYes
ERRORAn operation failedYes
CRITICALThe process cannot continueYes
⚠️
Log with parameters (log.info("%s", value)) rather than an f-string. The message is only formatted when the level is enabled, and passing a string that already contains a traceback hides where the exception came from. Never log secrets, tokens or full personal records.

Configuration and precedence

A command-line tool reads configuration from several places. Pick a fixed order of precedence and document it, so an operator can predict which value wins.

import os, json
from dataclasses import dataclass

DEFAULTS = {"host": "localhost", "port": 8000, "retries": 3}

def load_config(path=None, overrides=None):
    """Lowest precedence first: file, then environment, then CLI flags."""
    config = dict(DEFAULTS)
    if path:
        config.update(json.loads(open(path, encoding="utf-8").read()))
    if value := os.environ.get("SHOP_PORT"):
        config["port"] = int(value)
    if value := os.environ.get("SHOP_HOST"):
        config["host"] = value
    config.update({k: v for k, v in (overrides or {}).items() if v is not None})
    return config

# flags default to None so you can tell "not supplied" from "set to the default"
parser.add_argument("--port", type=int, default=None)

@dataclass
class Settings:
    host: str
    port: int
    retries: int = 3

    def __post_init__(self):
        if not 0 < self.port < 65536:
            raise ValueError(f"port out of range: {self.port}")
  • Precedence: built-in defaults, then a config file, then environment variables, then command-line flags.
  • Validate once, at start-up, and fail fast with a clear message rather than at the first request.
  • Never commit real credentials; ship an example file and read the secret from the environment.
  • Map verbosity flags onto the root logger level, for example -v to INFO and -vv to DEBUG.

FAQ

print or logging?
print for the data the program is meant to produce and that a user might pipe somewhere. logging for everything about how it ran: warnings, retries, timings and failures. Mixing them makes log filtering useless.
Why do my log lines appear twice?
The logger has a handler and also propagates to the root logger, which has its own handler. Either stop propagation with logger.propagate = False or configure handlers only on the root.

Regular expressions and text processing Packaging, project layout and pyproject.toml

Last refreshed 2026-09-18.