Sunday, 31 August 2025

Creating Your Own Command-Line Tools with Python

Turn tiny scripts into polished CLIs you can install, share, and run from anywhere.

Contents
  1. Why build a CLI?
  2. Prerequisites
  3. A 3-minute CLI with argparse
  4. Recommended project structure
  5. A real app with Typer (Click) + Rich
  6. Package & install with entry points
  7. Testing CLIs
  8. Distribute with pipx & PyPI
  9. Polish: logging, config, shell completion
  10. Common gotchas

1) Why build a CLI?

  • Speed: One command beats 10 clicks.
  • Automation: Scriptable in cron, CI, or other tools.
  • Shareable: Pack once, install anywhere.
  • Composability: Works well with pipes (|) and redirects.

2) Prerequisites

  • Python 3.9+ recommended
  • pip (and optionally pipx)
  • Basic terminal familiarity
Tip: Use a virtual environment (python -m venv .venv then source .venv/bin/activate or .venv\Scripts\activate on Windows) while developing.

3) A 3-minute CLI with argparse

Start simple with the standard library:

# greet/__main__.py
import argparse

def main():
    parser = argparse.ArgumentParser(prog="greet", description="Say hello from your CLI.")
    parser.add_argument("name", help="Who to greet")
    parser.add_argument("--shout", action="store_true", help="Uppercase the greeting")
    args = parser.parse_args()

    msg = f"Hello, {args.name}!"
    if args.shout:
        msg = msg.upper()
    print(msg)

if __name__ == "__main__":
    main()

Run it straight from the module:

python -m greet Remo
python -m greet Remo --shout

Next, we’ll package it so you can just type greet anywhere.

4) Recommended project structure

greet/
├─ greet/
│  ├─ __init__.py
│  └─ __main__.py
├─ pyproject.toml
└─ README.md

5) A real app with Typer (Click) + Rich

Typer (built on Click) gives you subcommands, type hints, smart help, and shell completion with almost no boilerplate. We’ll build a tiny to-do manager called tasker that stores data in ~/.tasker.json and prints pretty tables using Rich.

# tasker_cli/__main__.py
from __future__ import annotations
import json, os
from pathlib import Path
from typing import List, Dict
import typer
from rich.console import Console
from rich.table import Table

app = typer.Typer(add_completion=True)
console = Console()
DATA_FILE = Path(os.getenv("TASKER_FILE", Path.home() / ".tasker.json"))

def load_tasks() -> List[Dict]:
    if DATA_FILE.exists():
        return json.loads(DATA_FILE.read_text())
    return []

def save_tasks(tasks: List[Dict]) -> None:
    DATA_FILE.write_text(json.dumps(tasks, indent=2))

@app.command()
def add(text: str = typer.Argument(..., help="Task description")):
    """Add a new task."""
    tasks = load_tasks()
    next_id = (max([t["id"] for t in tasks]) + 1) if tasks else 1
    tasks.append({"id": next_id, "text": text, "done": False})
    save_tasks(tasks)
    console.print(f"✅ Added task [bold]{text}[/] (id={next_id})")

@app.command("list")
def list_tasks(show_done: bool = typer.Option(True, help="Include completed tasks")):
    """List tasks."""
    tasks = load_tasks()
    table = Table(title="Tasker")
    table.add_column("ID", justify="right")
    table.add_column("Status")
    table.add_column("Task")

    for t in tasks:
        if not show_done and t["done"]:
            continue
        status = "✅" if t["done"] else "•"
        table.add_row(str(t["id"]), status, t["text"])
    console.print(table)

@app.command()
def done(task_id: int = typer.Argument(..., help="Task ID to mark as done")):
    """Mark a task as done."""
    tasks = load_tasks()
    for t in tasks:
        if t["id"] == task_id:
            t["done"] = True
            save_tasks(tasks)
            console.print(f"๐ŸŽ‰ Marked task {task_id} as done")
            return
    raise typer.Exit(code=1)

@app.command()
def clear(confirm: bool = typer.Option(False, "--yes", help="Confirm clearing all tasks")):
    """Clear all tasks."""
    if not confirm:
        console.print("Pass --yes to confirm clearing all tasks."); raise typer.Exit(1)
    save_tasks([]); console.print("๐Ÿงน Cleared tasks.")

def app_main():
    app()

if __name__ == "__main__":
    app_main()

Try it locally:

python -m pip install typer[all] rich
python -m tasker_cli add "Buy milk"
python -m tasker_cli list
python -m tasker_cli done 1
python -m tasker_cli list --no-show-done

6) Package & install with entry points

Use a modern pyproject.toml with PEP 621 metadata and a console script entry. We’ll use hatchling as a lightweight build backend:

# pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "tasker-cli"
version = "0.1.0"
description = "Tiny to-do manager CLI built with Typer and Rich"
readme = "README.md"
requires-python = ">=3.9"
authors = [{ name = "Your Name" }]
dependencies = ["typer>=0.9", "rich>=13.0"]

[project.scripts]
tasker = "tasker_cli.__main__:app_main"

Install in editable mode while developing:

python -m pip install -e .
# Now just type:
tasker add "Write blog post"
tasker list
How it works: [project.scripts] creates a platform-specific launcher named tasker that calls tasker_cli.__main__:app_main. No manual shebangs or PATH hacks needed.

7) Testing CLIs

Use pytest plus Typer’s test runner to simulate commands and capture output:

# tests/test_tasker.py
from pathlib import Path
from typer.testing import CliRunner
import os
from tasker_cli.__main__ import app

runner = CliRunner()

def test_add_and_list(tmp_path: Path, monkeypatch):
    monkeypatch.setenv("TASKER_FILE", str(tmp_path / "tasks.json"))
    r = runner.invoke(app, ["add", "Buy milk"])
    assert r.exit_code == 0
    r2 = runner.invoke(app, ["list", "--no-show-done"])
    assert "Buy milk" in r2.stdout
python -m pip install pytest
pytest -q

8) Distribute with pipx & PyPI

Install & run globally with pipx

python -m pip install pipx
pipx install .
# or from a git URL:
pipx install git+https://github.com/you/tasker-cli

Publish to PyPI

python -m pip install build twine
python -m build                # creates dist/*.tar.gz and *.whl
python -m twine upload dist/*  # needs PyPI credentials

After publishing, users can install with:

pipx install tasker-cli
# or:
python -m pip install --user tasker-cli

9) Polish: logging, config, shell completion

Logging

import logging, os
level = os.getenv("TASKER_LOG", "WARNING").upper()
logging.basicConfig(level=getattr(logging, level, logging.WARNING))
logging.getLogger("tasker").info("starting up…")

Configuration

Prefer environment variables (12-factor style). For simple files, read from ~/.config/tasker/config.toml on Linux/macOS or %APPDATA%\tasker\config.toml on Windows.

Shell completion

Typer/Click can generate completions:

# Show completion script:
tasker --show-completion

# Install for your shell (Typer adds helpers):
tasker --install-completion

10) Common gotchas

  • Windows paths: Use pathlib.Path, not hardcoded separators.
  • Unicode: Always read/write text files with UTF-8 (Path(...).read_text()/write_text()).
  • State location: Default to a user-writable path (~), not the current directory.
  • Long-running tasks: Show progress or logs; don’t print nothing for minutes.
  • Exit codes: Return non-zero on failure (raise typer.Exit(1)) so scripts can react.

Wrap-up

You’ve seen the full path from a tiny argparse script to a polished, installable CLI with Typer/Click, Rich tables, tests, and packaging. Start small, wire in entry points, then add ergonomics as you go. Happy hacking!

No comments:

Post a Comment