Build a Python CLI Tool in 10 Steps: A Practical Guide Using Click
Build a Python CLI Tool in 10 Steps: A Practical Guide Using Click
Command-line tools are some of the most useful things you can build as a Python developer. They automate repetitive tasks, wrap complex workflows into single commands, and are easy to share with teammates. This guide walks through building a practical CLI tool using Click, one of the most widely used Python libraries for creating command-line interfaces.
By the end, you will have a working CLI tool you can install and run from your terminal, structured in a way that scales as you add more commands.
Why Click Instead of argparse?
Python's standard library includes argparse, which works fine for simple scripts. Click has several practical advantages:
- Decorators make command definitions clean and readable
- Built-in support for subcommands, option types, and prompts
- Automatic help text generation
- Easy to test with
CliRunner
For any tool beyond a single flag or two, Click is worth reaching for.
Step 1: Set Up a Virtual Environment
Start with a clean environment to keep dependencies isolated.
mkdir mycli && cd mycli
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
Step 2: Install Click
pip install click
Pin your version in a requirements.txt or pyproject.toml once you know what you need.
Step 3: Create the Project Structure
A clean layout makes your tool easier to maintain and package later.
mycli/
├── mycli/
│ ├── __init__.py
│ └── cli.py
├── pyproject.toml
└── README.md
The outer mycli/ is the project root. The inner mycli/ is the Python package.
Step 4: Write Your First Command
Open mycli/cli.py and create a basic entry point.
import click
@click.group()
def cli():
"""A simple CLI tool built with Click."""
pass
@cli.command()
@click.argument('name')
def greet(name):
"""Greet a user by name."""
click.echo(f"Hello, {name}!")
if __name__ == '__main__':
cli()
The @click.group() decorator turns cli into a command group, which lets you add subcommands. The greet command takes a positional argument called name.
Step 5: Add Options and Flags
Options differ from arguments in that they are named and optional.
@cli.command()
@click.argument('name')
@click.option('--count', default=1, help='Number of times to greet.')
@click.option('--uppercase', is_flag=True, help='Print greeting in uppercase.')
def greet(name, count, uppercase):
"""Greet a user by name."""
message = f"Hello, {name}!"
if uppercase:
message = message.upper()
for _ in range(count):
click.echo(message)
Now a user can run:
python -m mycli.cli greet Jake --count 3 --uppercase
Step 6: Use Click Types for Validation
Click can validate input types automatically, saving you from writing boilerplate checks.
@cli.command()
@click.option('--age', type=click.IntRange(0, 120), required=True, help='Your age.')
def profile(age):
"""Display a user profile."""
click.echo(f"Age recorded: {age}")
If a user passes a string or a number outside the range, Click reports a helpful error before your function ever runs.
Useful built-in types:
- click.INT, click.FLOAT, click.BOOL
- click.Path(exists=True) — validates a file path
- click.Choice(['a', 'b', 'c']) — restricts to a set of values
- click.IntRange(min, max) — numeric bounds
Step 7: Read from Files or stdin
Many CLI tools need to process files. Click makes this straightforward.
@cli.command()
@click.argument('filepath', type=click.Path(exists=True))
def count_lines(filepath):
"""Count the number of lines in a file."""
with open(filepath) as f:
lines = f.readlines()
click.echo(f"{filepath} has {len(lines)} lines.")
For stdin support, use click.get_text_stream('stdin') or the click.File('-') type, which accepts either a filename or - for piped input.
Step 8: Add Prompts for Interactive Input
Sometimes you want to ask users for input interactively, such as for passwords or confirmations.
@cli.command()
def setup():
"""Run interactive setup."""
username = click.prompt('Enter your username')
password = click.prompt('Enter your password', hide_input=True, confirmation_prompt=True)
click.echo(f"Setup complete for user: {username}")
The hide_input=True flag suppresses echo so passwords are not visible in the terminal.
Step 9: Package the Tool with pyproject.toml
To make your CLI installable with pip install ., add a pyproject.toml.
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.backends.legacy:build"
[project]
name = "mycli"
version = "0.1.0"
dependencies = ["click>=8.0"]
[project.scripts]
mycli = "mycli.cli:cli"
The [project.scripts] section is what creates the mycli command when the package is installed.
Install it in development mode:
pip install -e .
Now you can run mycli greet Jake directly from your terminal.
Step 10: Test Your Commands with CliRunner
Click ships with a test utility called CliRunner that lets you invoke commands in isolation without spawning a subprocess.
from click.testing import CliRunner
from mycli.cli import greet
def test_greet_basic():
runner = CliRunner()
result = runner.invoke(greet, ['Jake'])
assert result.exit_code == 0
assert 'Hello, Jake!' in result.output
def test_greet_uppercase():
runner = CliRunner()
result = runner.invoke(greet, ['Jake', '--uppercase'])
assert 'HELLO, JAKE!' in result.output
Run with pytest. No real terminal interaction required.
Putting It All Together
Here is a summary of the full cli.py with everything combined:
import click
@click.group()
def cli():
"""A simple CLI tool built with Click."""
pass
@cli.command()
@click.argument('name')
@click.option('--count', default=1, help='Number of times to greet.')
@click.option('--uppercase', is_flag=True, help='Print greeting in uppercase.')
def greet(name, count, uppercase):
"""Greet a user by name."""
message = f"Hello, {name}!"
if uppercase:
message = message.upper()
for _ in range(count):
click.echo(message)
@cli.command()
@click.argument('filepath', type=click.Path(exists=True))
def count_lines(filepath):
"""Count the number of lines in a file."""
with open(filepath) as f:
lines = f.readlines()
click.echo(f"{filepath} has {len(lines)} lines.")
if __name__ == '__main__':
cli()
Key Takeaways
- Use
@click.group()to organize related subcommands under one entry point - Prefer
@click.option()for named, optional parameters and@click.argument()for positional ones - Click's built-in types handle input validation before your code runs
pyproject.tomlwith[project.scripts]makes your tool installable like any other packageCliRunnergives you fast, isolated tests without subprocess overhead
CLI tools built this way are clean to maintain, easy to extend, and straightforward to distribute. Once you have the pattern down, building more commands on top of the same group takes only a few lines.