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:
0success,1a runtime failure,2bad usage. Returning an integer frommainkeeps the tool composable in pipelines. argparse.FileTypeopens 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 editingsys.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}},
})| Level | Use for | Seen by default |
|---|---|---|
| DEBUG | Diagnostics while developing | No |
| INFO | Normal progress worth recording | No |
| WARNING | Something unexpected but handled | Yes |
| ERROR | An operation failed | Yes |
| CRITICAL | The process cannot continue | Yes |
⚠️
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
-vto INFO and-vvto 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.Related
Regular expressions and text processing Packaging, project layout and pyproject.toml
Last refreshed 2026-09-18.