Table of Contents
Why Python Is Great for CLI Tools
Python hits a sweet spot for Linux command-line tools:
- Readable, concise syntax
- Huge standard library (arg parsing, logging, subprocess, etc.)
- Easy to glue other tools together
- Portable across distributions
- Simple packaging and distribution options
In this chapter, the focus is on building robust, “real” Python CLI tools that feel native to Linux, not on learning Python basics.
Basic Command-Line Tool Structure in Python
A minimal, idiomatic CLI script:
#!/usr/bin/env python3
"""
Example: greet a user from the command line.
"""
import sys
def main(argv=None):
if argv is None:
argv = sys.argv[1:] # everything after the script name
if not argv:
print("Usage: greet.py NAME", file=sys.stderr)
return 1
name = argv[0]
print(f"Hello, {name}!")
return 0
if __name__ == "__main__":
raise SystemExit(main())Key patterns:
- Use
if __name__ == "__main__":as the entry-point. - Put your real logic in
main(), return an exit code. - Exit via
SystemExit(code)(returningcodefrommainand wrapping it is a clean pattern). - Read arguments from
sys.argv[1:](or pass them in for testing).
This structure makes your tool easy to:
- Import in other Python code
- Test (call
main(["arg1", "arg2"])) - Reuse logic outside the CLI wrapper
Using argparse for Robust CLI Interfaces
sys.argv is fine for trivial scripts. For real tools, use argparse:
#!/usr/bin/env python3
import argparse
def build_parser():
parser = argparse.ArgumentParser(
description="Count lines, words, and bytes in a file (like wc)."
)
parser.add_argument("path", help="File to analyze")
parser.add_argument(
"-w", "--words",
action="store_true",
help="Show word count only"
)
parser.add_argument(
"-l", "--lines",
action="store_true",
help="Show line count only"
)
parser.add_argument(
"-c", "--bytes",
action="store_true",
help="Show byte count only"
)
return parser
def main(argv=None):
parser = build_parser()
args = parser.parse_args(argv)
with open(args.path, "rb") as f:
data = f.read()
text = data.decode(errors="replace")
line_count = text.count("\n")
word_count = len(text.split())
byte_count = len(data)
# Decide what to show
if not (args.lines or args.words or args.bytes):
# default: show all, like wc
print(f"{line_count}\t{word_count}\t{byte_count}\t{args.path}")
else:
parts = []
if args.lines:
parts.append(str(line_count))
if args.words:
parts.append(str(word_count))
if args.bytes:
parts.append(str(byte_count))
print("\t".join(parts))
return 0
if __name__ == "__main__":
raise SystemExit(main())
Key argparse ideas specific to CLI tools:
ArgumentParser(description=...)→ shows up in-h/--help.add_argument("name")→ positional arguments.add_argument("-f", "--flag", action="store_true")→ boolean flags.choices=...,type=...,default=...help validate inputs.
Subcommands (git-style)
For multi-tool commands (git commit, git status, etc.), use subparsers:
#!/usr/bin/env python3
"""
Example multi-command tool: todo add/list/done
"""
import argparse
from pathlib import Path
import json
import sys
TODO_FILE = Path.home() / ".todo.json"
def load_tasks():
if TODO_FILE.exists():
return json.loads(TODO_FILE.read_text())
return []
def save_tasks(tasks):
TODO_FILE.write_text(json.dumps(tasks, indent=2))
def cmd_add(args):
tasks = load_tasks()
tasks.append({"text": args.text, "done": False})
save_tasks(tasks)
print(f"Added: {args.text}")
return 0
def cmd_list(args):
tasks = load_tasks()
for i, t in enumerate(tasks, start=1):
status = "x" if t["done"] else " "
print(f"{i}. [{status}] {t['text']}")
return 0
def cmd_done(args):
tasks = load_tasks()
idx = args.index - 1
if not (0 <= idx < len(tasks)):
print("Invalid index", file=sys.stderr)
return 1
tasks[idx]["done"] = True
save_tasks(tasks)
print(f"Marked done: {tasks[idx]['text']}")
return 0
def build_parser():
parser = argparse.ArgumentParser(prog="todo", description="Simple todo manager")
subparsers = parser.add_subparsers(dest="command", required=True)
p_add = subparsers.add_parser("add", help="Add a new task")
p_add.add_argument("text", help="Task description")
p_add.set_defaults(func=cmd_add)
p_list = subparsers.add_parser("list", help="List tasks")
p_list.set_defaults(func=cmd_list)
p_done = subparsers.add_parser("done", help="Mark task as done")
p_done.add_argument("index", type=int, help="Task number")
p_done.set_defaults(func=cmd_done)
return parser
def main(argv=None):
parser = build_parser()
args = parser.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())Patterns worth copying:
- Use subparsers for each logical operation.
- Attach a function with
set_defaults(func=...). main()just dispatches:return args.func(args).
Making Python Tools Feel Native on Linux
Shebangs
To run a script directly (./tool), add a shebang as the first line:
- Portable, env-based:
#!/usr/bin/env python3- Or explicit interpreter (less portable):
#!/usr/bin/python3Then:
chmod +x mytool.py
./mytool.py
Often you’ll drop the .py extension and install to a directory on $PATH.
Installing Into PATH
Common approaches:
- Copy/link script into a directory on
PATH, e.g.:
sudo cp mytool.py /usr/local/bin/mytool
sudo chmod +x /usr/local/bin/mytool- Use a virtual environment and a
console_scriptsentry point (covered later under packaging). - For user-local install:
mkdir -p ~/.local/bin
cp mytool.py ~/.local/bin/mytool
chmod +x ~/.local/bin/mytool
# ensure ~/.local/bin is in PATH (in your shell config)Exit Codes
Exit codes are essential for scripting:
0→ success- Non-zero → error (conventionally small integers)
In Python:
def main(argv=None):
# ...
if error_condition:
print("Something went wrong", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())
A shell script can then check $? or use set -e to react to failure.
Writing to stdout vs stderr
- Normal output → stdout (default
print()). - Errors / diagnostics → stderr:
print("error: file not found", file=sys.stderr)This matters when your tool’s output is piped into other programs.
Integrating with Other Linux Tools
Using subprocess Instead of os.system
Avoid os.system; use subprocess:
import subprocess
def run_ls(path="."):
# Capture output
result = subprocess.run(
["ls", "-l", path],
check=False,
text=True,
capture_output=True
)
if result.returncode != 0:
print(result.stderr, end="", file=sys.stderr)
return result.returncode
print(result.stdout, end="")
return 0Key options:
text=True→ decode bytes tostrusing locale.capture_output=True→ capture stdout/stderr.check=True→ raiseCalledProcessErroron non-zero exit.
For simple filters, you can stream directly:
def grep_python(files):
# cat files | grep python
p1 = subprocess.Popen(["cat", *files], stdout=subprocess.PIPE)
p2 = subprocess.Popen(["grep", "python"], stdin=p1.stdout)
p1.stdout.close()
p2.communicate()
return p2.returncodeThis lets your Python tool orchestrate other CLI programs.
Reading from stdin and Writing to stdout
To behave like a proper Unix filter, use sys.stdin / sys.stdout:
import sys
def main(argv=None):
# Example: uppercase filter
for line in sys.stdin:
sys.stdout.write(line.upper())
return 0Usage:
cat file.txt | myfilter > out.txt
You can also open - as a convention for stdin/stdout:
from pathlib import Path
import sys
def open_input(path):
if path == "-":
return sys.stdin
return open(path, "r", encoding="utf-8")
def main(argv=None):
path = argv[0] if argv else "-"
with open_input(path) as f:
for line in f:
sys.stdout.write(line.upper())
return 0Logging for Command-Line Tools
For anything beyond trivial scripts, prefer logging to scattered print() calls. This lets users adjust verbosity and keeps logs structured.
import argparse
import logging
import sys
log = logging.getLogger("mytool")
def setup_logging(verbosity):
level = logging.WARNING # default
if verbosity == 1:
level = logging.INFO
elif verbosity >= 2:
level = logging.DEBUG
logging.basicConfig(
level=level,
format="%(levelname)s: %(message)s",
stream=sys.stderr,
)
def main(argv=None):
parser = argparse.ArgumentParser()
parser.add_argument(
"-v", "--verbose",
action="count",
default=0,
help="Increase verbosity (-v, -vv)"
)
args = parser.parse_args(argv)
setup_logging(args.verbose)
log.debug("Debug info")
log.info("Starting work")
try:
# do work...
pass
except Exception as e:
log.error("Unhandled error: %s", e)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())Typical usage:
- Default: only warnings and errors.
-v: info, warnings, errors.-vv: debug + everything else.
Packaging Python CLI Tools Properly
For tools you want to install cleanly, use setuptools and console_scripts entry points so users get a real executable in $PATH.
Minimal structure:
mytool/
pyproject.toml
src/
mytool/
__init__.py
cli.py
pyproject.toml example:
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project]
name = "mytool"
version = "0.1.0"
description = "My awesome CLI tool"
requires-python = ">=3.8"
dependencies = []
[project.scripts]
mytool = "mytool.cli:main"
mytool/cli.py:
def main():
print("Hello from packaged tool")Installation in a virtualenv (or system-wide, if appropriate):
pip install .
mytoolNotes specific to Linux:
pip install --user .installs scripts into~/.local/binby default.- Many distros ship
pipxto install isolated CLI apps:pipx install ..
Creating Friendly, Discoverable Tools
These points affect usability more than correctness, but they matter a lot for Linux tools.
Help and Usage
- Fill in
description,helpinargparseso-his meaningful. - Use
ArgumentDefaultsHelpFormatterto show defaults automatically:
parser = argparse.ArgumentParser(
description="Sync local dir to remote",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)- Provide
--version:
parser.add_argument(
"--version",
action="version",
version="mytool 0.1.0",
)Configuration Files and Environment Variables
It’s common for CLI tools to:
- Read config files from:
$XDG_CONFIG_HOME/mytool/config.tomlor~/.config/mytool/config.toml- Allow env vars to override defaults:
import os
def get_default_url():
return os.environ.get("MYTOOL_URL", "https://example.com")
You can then expose a --url flag that defaults to get_default_url().
Tab Completion
argcomplete or libraries like click/typer can generate bash/zsh completions. Even with plain argparse, you can support completions via extra tools, but that’s an advanced topic; know that it exists, and that “native” feeling tools often provide it.
Using Higher-Level CLI Libraries (Click, Typer)
While argparse is in the standard library and is enough for most tools, larger CLIs often use higher-level libraries.
Click
Example:
#!/usr/bin/env python3
import click
@click.group()
def cli():
"""A multi-command example with Click."""
pass
@cli.command()
@click.argument("name")
@click.option("--shout/--no-shout", default=False, help="Shout the greeting")
def greet(name, shout):
"""Greet someone by NAME."""
msg = f"Hello, {name}"
if shout:
msg = msg.upper() + "!"
click.echo(msg)
if __name__ == "__main__":
cli()
click features:
- Declarative arguments/options (
@click.argument,@click.option). - Automatic help and validation.
- Nice support for colors, prompts, environment variables, etc.
Typer
Typer builds on Click, aiming for type-hinted, FastAPI-style CLIs:
#!/usr/bin/env python3
import typer
app = typer.Typer()
@app.command()
def add(a: int, b: int):
"""Add two integers."""
typer.echo(a + b)
if __name__ == "__main__":
app()
This uses type hints (a: int) for argument parsing and documentation.
Use these when:
- You’re building complex CLIs.
- You want quick, well-documented subcommands and options.
- You prefer decorators and type hints over manual
argparseconfiguration.
Testing Python Command-Line Tools
For master-level tools, automated tests are essential.
Testing main() Directly
If main() accepts argv, you can unit-test it:
from mytool.cli import main
def test_help(capsys):
code = main(["-h"])
assert code == 0
out, err = capsys.readouterr()
assert "usage" in out.lower()Testing via subprocess
To test the “real” installed tool:
import subprocess
def run_cmd(*args):
result = subprocess.run(
args,
text=True,
capture_output=True,
check=False,
)
return result
def test_basic_usage():
r = run_cmd("mytool", "--version")
assert r.returncode == 0
assert "mytool" in r.stdoutThis checks your packaging, entry points, and environment integration.
Performance and Distribution Considerations
For heavy workloads, consider:
- Avoid re-spawning many subprocesses in tight loops where Python can do the job directly.
- Use streaming I/O for large files (iterate line-by-line instead of
read()). - If startup time matters, avoid heavy imports or large frameworks in your CLI entry point.
Distribution options:
- pip / PyPI for Python-native users.
- System packages (DEB/RPM, handled in other chapters) for distribution-wide installation.
- Standalone executables via tools like
PyInstallerorshivif you really need “no Python required” binaries, at the cost of size and complexity.
Putting It Together: Design Guidelines
When building serious Python command-line tools on Linux, aim for:
- Clear, script-friendly behavior:
- Stable exit codes
- Clean stdout/stderr separation
- Reasonable defaults and flags
- A small, testable core:
- Business logic in functions/modules
- Thin CLI wrapper in
main() - Native feel:
- Shebang + executable bit
- Installed into
$PATH - Proper
-h/--helpand--version - Logging with adjustable verbosity
- Good citizenship in the ecosystem:
- Uses existing tools via
subprocesswhen appropriate - Plays well in pipelines (stdin/stdout)
- Respects standard paths (XDG config dirs, etc.)
With these practices, your Python tools will integrate naturally into Linux workflows and remain maintainable as they grow.