DEV Community

Cover image for How to Build Python CLI Tools That People Actually Enjoy Using
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

How to Build Python CLI Tools That People Actually Enjoy Using

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

I remember the first time I tried to build a command-line tool. I typed python my_script.py and nothing happened. I had no arguments, no help, no color. Just a blank terminal. That day I learned that a good CLI is not about writing code that works. It is about writing code that people enjoy running. Over the years, I collected a set of Python techniques that turn a raw script into a polished terminal application. Let me walk you through them, one by one, with code you can copy and adapt.

Let’s start with the one that ships with Python. The argparse module is part of the standard library. That means you do not need to install anything else. You write a parser, define arguments, and argparse handles the rest. It creates --help messages, checks types, and gives error feedback. I use it for small tools because it keeps dependencies low. Here is an example.

import argparse

def main():
    parser = argparse.ArgumentParser(
        description="Copy files with optional compression."
    )
    parser.add_argument("source", help="Source file path")
    parser.add_argument("destination", help="Destination file path")
    parser.add_argument("--compress", "-c", action="store_true", help="Enable compression")
    parser.add_argument("--level", type=int, default=5, choices=range(1,10), help="Compression level 1-9")
    args = parser.parse_args()
    print(f"Copying {args.source} to {args.destination}")
    if args.compress:
        print(f"Compression level: {args.level}")
Enter fullscreen mode Exit fullscreen mode

You run python script.py file.txt output.txt --compress --level 7 and it works. argparse also supports subcommands. For example, a tool that does both encode and decode can have encode and decode as subcommands. I find it useful when I want a clear separation of tasks. The only downside is that the code can become verbose for many arguments. But for a script that you write once and run often, it is perfect.

Now, if you want less typing and more features, click is the next step. I switched to click when I needed to build a tool with dozens of options and subcommands. It uses decorators. You decorate a function with @click.command(), and every parameter becomes an option. click generates help pages automatically, handles environment variables, and supports input prompts. Here is a simple version.

import click

@click.command()
@click.argument("name")
@click.option("--count", default=1, help="How many times to greet")
def greet(name, count):
    for _ in range(count):
        click.echo(f"Hello, {name}!")

if __name__ == "__main__":
    greet()
Enter fullscreen mode Exit fullscreen mode

Run python greet.py Alice --count 3. The output appears three times. The magic is that click converts function parameters into command-line arguments. You can also group commands together. I once built a deployment tool with commands like deploy init, deploy push, deploy rollback. With click.group, it becomes clean and readable. The documentation is excellent, and it is widely used in projects like Flask and Celery.

But even the best arguments are useless if the output is hard to read. That is where rich comes in. rich makes your terminal beautiful. You can print colored text, draw tables, show progress bars, and even render markdown. I use rich in every CLI I build now because it reduces user confusion. For example, when I print a list of results, I put them in a table.

from rich.console import Console
from rich.table import Table

console = Console()
table = Table(title="Server Status")
table.add_column("Host", style="cyan")
table.add_column("Status", style="green")
table.add_column("Uptime", style="magenta")
table.add_row("web01", "running", "12d")
table.add_row("db01", "stopped", "0d")
console.print(table)
Enter fullscreen mode Exit fullscreen mode

It looks clean and professional. You can also use rich.progress to show a spinner or a progress bar during a long operation. I often wrap a loop like this.

from rich.progress import track
import time

for _ in track(range(10), description="Installing packages"):
    time.sleep(0.5)
Enter fullscreen mode Exit fullscreen mode

The user sees a moving bar and knows the program is alive. No more staring at a frozen cursor. rich integrates with argparse and click because it just replaces print. You can even log with rich.logging. I keep a console object at the top of my script and use it everywhere.

Sometimes you need more than a one-shot command. You need an interactive session. A REPL. Or a prompt that fills in suggestions as the user types. prompt_toolkit does exactly that. It is the engine behind ipython and ptpython. I use it when I build configuration wizards or search tools. Here is a basic loop.

from prompt_toolkit import prompt
from prompt_toolkit.completion import WordCompleter

tasks = WordCompleter(["start", "stop", "status", "exit"], ignore_case=True)

while True:
    text = prompt("> ", completer=tasks)
    if text == "exit":
        break
    print(f"You typed: {text}")
Enter fullscreen mode Exit fullscreen mode

When you run it and start typing s, it suggests start, stop, status. Press Tab to complete. That feels like a real application. prompt_toolkit also supports multi-line editing, key bindings, and validation. I once built a tool that asked for a date in a specific format, and prompt_toolkit validated it on each keystroke. The code is longer than a simple input(), but the user experience is far better.

Now, your CLI will often need to call other programs. Maybe it runs git, ffmpeg, or a shell command. The subprocess module is the standard way to do that. I use it to start external processes, pass arguments, capture output, and handle errors. Here is a function I keep in my toolbox.

import subprocess

def run(cmd):
    try:
        result = subprocess.run(cmd, shell=True, check=True, capture_output=True, text=True)
        return result.stdout
    except subprocess.CalledProcessError as e:
        print(f"Error: {e.stderr}")
        raise
Enter fullscreen mode Exit fullscreen mode

I use shell=True with caution. Only when the command is safe. For user‑supplied strings, I split them into a list and run without shell. But for known commands like ls -la, shell is convenient. The check=True raises an exception if the command fails, so I can handle it gracefully. I also stream output in real time for long processes using Popen and iterating over stdout. That way the user sees progress instead of waiting for the whole result.

When your CLI performs network requests or file I/O that takes time, you want to stay responsive. Python’s asyncio lets you do several things at once without threads. I write async functions for tasks like downloading multiple files. Here is a snippet that downloads two pages concurrently.

import asyncio
import aiohttp

async def fetch(url):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:
            return await response.text()

async def main():
    urls = ["https://httpbin.org/get", "https://example.com"]
    results = await asyncio.gather(*(fetch(u) for u in urls))
    for r in results:
        print(len(r))

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

If I embed this in a CLI, I can show a progress bar while downloading. The key is that the terminal remains interactive. I can also run subprocesses asynchronously with asyncio.create_subprocess_exec. That lets me watch the output of a long‑running process line by line without blocking. I once built a log tailer that followed a file and printed new lines with rich highlighting. It used asyncio to watch multiple files at once.

Now, if you like the simplicity of click but want even less code, typer is your friend. It uses Python type hints to define arguments. No decorators for each option. You write a function with typed parameters, and typer turns them into a CLI. Here is an example.

import typer

def main(name: str, greeting: str = "Hello", count: int = 1):
    for _ in range(count):
        typer.echo(f"{greeting}, {name}!")

if __name__ == "__main__":
    typer.run(main)
Enter fullscreen mode Exit fullscreen mode

Run python script.py Alice --greeting Hi --count 3. That is it. The type hints tell typer whether an argument is required (no default) or optional (with default). It also generates a help page, supports environment variables, and handles installation. I use typer for quick scripts that I do not want to maintain with argparse or click. It is built on top of click, so it inherits all the good parts. The documentation claims it is “the easiest way to write a CLI”, and I agree.

Finally, your CLI almost always needs configuration. Database URLs, API keys, secret tokens. Hardcoding them is bad practice. python-dotenv loads variables from a .env file into os.environ. I place a .env file in the project root, and my application reads it on startup.

from dotenv import load_dotenv
import os

load_dotenv()
database_url = os.getenv("DATABASE_URL")
if not database_url:
    raise SystemExit("Missing DATABASE_URL")
Enter fullscreen mode Exit fullscreen mode

Now my CLI can be used by different people with different settings. They just create a .env file. The library is tiny, and it integrates with any framework. I also combine it with click by using the @click.option with an environment variable fallback. So if someone passes --db-url, it overrides the .env file. But if they don't, it uses the environment.

I have used these eight techniques in dozens of projects. They are not the only ones, but they cover the most common needs: argument parsing, output formatting, interactive input, process management, async operations, simplified CLI creation, and configuration management. Each one fills a gap. When I start a new terminal tool, I think about what the user will do. Will they type options, or will they enter a dialog? Will they watch a progress bar, or do they need a table of results? Then I pick the right combination.

For example, I once built a database migration tool. I used click for the command‑line interface, rich to show migration status in a table, prompt_toolkit for a confirmation prompt before running dangerous migrations, subprocess to execute SQL files via psql, and python-dotenv to read the database URL. The result was a tool that felt like a polished commercial product, but it was just Python code.

You can build something similar. Start small. Pick one technique and try it. Add another when you need it. The code examples here are simple, but they are the foundation. Change the strings, add logic, and you have a real application.

The terminal does not have to be boring. With rich, it can be beautiful. With prompt_toolkit, it can be intelligent. With asyncio, it can be fast. And with argparse or typer, it can be easy to use.

I hope these examples help you see that building a CLI is not mysterious. It is just writing Python functions and decorating them correctly. The hardest part is knowing what your users need. Once you know that, the code follows.

So go ahead. Open your editor. Write a small script with argparse. Then add a table from rich. Then wrap it in a click group. Before you know it, you will have a terminal application that people actually look forward to typing.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)