Kahibaro
Discord Login Register

Python command-line tools

Why Python Is Great for CLI Tools

Python hits a sweet spot for Linux command-line tools:

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:

This structure makes your tool easy to:

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:

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:

Making Python Tools Feel Native on Linux

Shebangs

To run a script directly (./tool), add a shebang as the first line:

text
  #!/usr/bin/env python3
text
  #!/usr/bin/python3

Then:

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:

  1. 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
  1. Use a virtual environment and a console_scripts entry point (covered later under packaging).
  2. 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:

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

  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 0

Key options:

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.returncode

This 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 0

Usage:

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 0

Logging 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:

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 .
mytool

Notes specific to Linux:

Creating Friendly, Discoverable Tools

These points affect usability more than correctness, but they matter a lot for Linux tools.

Help and Usage

  parser = argparse.ArgumentParser(
      description="Sync local dir to remote",
      formatter_class=argparse.ArgumentDefaultsHelpFormatter,
  )
  parser.add_argument(
      "--version",
      action="version",
      version="mytool 0.1.0",
  )

Configuration Files and Environment Variables

It’s common for CLI tools to:

  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:

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:

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.stdout

This checks your packaging, entry points, and environment integration.

Performance and Distribution Considerations

For heavy workloads, consider:

Distribution options:

Putting It Together: Design Guidelines

When building serious Python command-line tools on Linux, aim for:

With these practices, your Python tools will integrate naturally into Linux workflows and remain maintainable as they grow.

Views: 31

Comments

Please login to add a comment.

Don't have an account? Register now!