Building CLI Tools
Reviewed & published by Brayan K
Build real command-line tools the same way professionals build developer utilities, automated scripts, data pipelines, AI model runners, and deployment tools. Master both argparse (standard, built-in) and Typer (modern, FastAPI-style) for creating production-grade CLI applications.
Part of the free Python course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
🧰 What You'll Learn
This lesson teaches how to build real command-line tools, the same way professionals build:
You will learn two approaches:
1. argparse (standard, low-level, built into Python) — Perfect for: small scripts, simple tools, full control.
2. Typer (modern, high-level, super-fast development) — Perfect for: professional apps, developer tools, banners, sub-commands, autocomplete.
Part 1: CLI Fundamentals — argparse Basics & Introduction to Typer
🔥 1. Why Command-Line Tools Matter
Before GUIs, everything ran in the terminal. But even today:
- developers use CLI tools daily (git, pip, docker)
- servers run scripts using CLI arguments
- automations depend on commands and flags
- real engineers build internal CLI tools to improve workflows
- cloud deployment (AWS/GCP/Azure) relies heavily on CLI automation
| CLI Tool | What It Does | Why It's Fast |
|---|---|---|
| git push | Upload code to server | 1 command vs. clicking through UI |
| pip install | Add a library | Instant, no browser needed |
| docker run | Start a container | Scriptable, automatable |
| python script.py --help | Show tool options | Self-documenting |
Having CLI-building skills makes you 10× more valuable as a developer.
⚙️ 2. argparse Basics — The Standard Python CLI Option
argparse ships with Python. No installation needed.
import argparse
parser = argparse.ArgumentParser(description="Simple greeting tool")
parser.add_argument("name", help="The name of the person to greet")
# For demo purposes, we'll parse known args
# In real CLI: args = parser.parse_args()
args = parser.parse_args(["Boopie"])
print(f"Hello, {args.name}!")
# Run in terminal: python app.py Boopie
# Output: Hello, Boopie!
# ✅ Expected output:
# Hello, Boopie!🧠 3. Positional vs Optional Arguments
| Type | Syntax | Example | Required? |
|---|---|---|---|
| Positional | filename | python tool.py data.csv | Yes |
| Optional | --verbose or -v | python tool.py --verbose | No |
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("path", help="File path")
# Demo: parse with sample argument
args = parser.parse_args(["myfile.txt"])
print(f"Path: {args.path}")
# ✅ Expected output:
# Path: myfile.txtOptional — Flags that start with - or --:
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--verbose", "-v", action="store_true")
# Demo: parse with verbose flag
args = parser.parse_args(["--verbose"])
print(f"Verbose mode: {args.verbose}")
# ✅ Expected output:
# Verbose mode: TrueUsage: python tool.py file.txt --verbose
Optional flags improve usability and mirror real CLI tools like pip, git, and docker.
📝 4. Typed Arguments: int, float, file paths
argparse automatically converts types:
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--count", type=int, default=1)
parser.add_argument("--rate", type=float, default=1.0)
# Demo: parse with typed arguments
args = parser.parse_args(["--count", "5", "--rate", "2.5"])
print(f"Count: {args.count}, Rate: {args.rate}")
# Or accept file objects:
# parser.add_argument("--file", type=argparse.FileType("r"))
# ✅ Expected output:
# Count: 5, Rate: 2.5This is extremely useful for real tools.
🧩 5. Sub-Commands (Like Git)
Many tools behave like: git add, git push, git commit. You can create the same pattern:
import argparse
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="command")
add_cmd = sub.add_parser("add")
add_cmd.add_argument("numbers", nargs="+", type=int)
show_cmd = sub.add_parser("show")
# Demo: parse "add" subcommand
args = parser.parse_args(["add", "1", "2", "3"])
if args.command == "add":
print(f"Sum: {sum(args.numbers)}")
# ✅ Expected output:
# Sum: 6This transforms your script into a multi-command CLI application.
⚡ 6. Real Project Example — File Organizer
import argparse
import os
import shutil
def main():
parser = argparse.ArgumentParser(description="Organize files by extension")
parser.add_argument("folder")
# Demo: simulate with current directory info
print("File Organizer Tool")
print("Usage: python organize.py <folder>")
print("This tool organizes files into subfolders by extension.")
# Example of what the tool would do:
sample_files = ["report.pdf", "image.jpg", "script.py"]
for file in sample_files:
ext = file.split(".")[-1]
print(f" {file} -> {ext}/{file}")
if __name__ == "__main__":
main()
# ✅ Expected output:
# File Organizer Tool
# Usage: python organize.py <folder>
# This tool organizes files into subfolders by extension.
# report.pdf -> pdf/report.pdf
# image.jpg -> jpg/image.jpg
# script.py -> py/script.pyRun: python organize.py downloads/
Automatically sorts files into subfolders.
🚀 7. Introduction to Typer (The Modern CLI Framework)
Typer is built by the creator of FastAPI. It offers:
| Feature | argparse | Typer |
|---|---|---|
| Setup Code | 10+ lines | 2-3 lines |
| Type Validation | Manual | Automatic |
| Help Generation | Basic | Beautiful |
| Colors/Formatting | Manual | Built-in |
| Autocompletion | Complex | One command |
Install: pip install typer[all]
🧠 8. Basic Typer Example
# Note: Typer requires installation: pip install typer[all]
# This is a conceptual example
# import typer
# app = typer.Typer()
# @app.command()
# def greet(name: str):
# typer.echo(f"Hello {name}!")
# if __name__ == "__main__":
# app()
# Simulated output:
print("Typer CLI Example")
print("Command: greet Boopie")
print("Output: Hello Boopie!")
print()
print("Typer generates:")
print(" ✔ automatic help screen")
print(" ✔ validation")
print(" ✔ color output")
print(" ✔ autocomplete suggestions")
# ✅ Expected output:
# Typer CLI Example
# Command: greet Boopie
# Output: Hello Boopie!
#
# Typer generates:
# ✔ automatic help screen
# ✔ validation
# ✔ color output
# ✔ autocomplete suggestionsRun: python app.py greet Boopie
🎛 9. Optional Arguments in Typer
It's cleaner than argparse:
# Typer optional arguments example
# @app.command()
# def download(url: str, retries: int = typer.Option(3), verbose: bool = False):
# typer.echo(f"Downloading {url} with {retries} retries, verbose={verbose}")
# Simulated output:
url = "https://example.com"
retries = 5
verbose = True
print(f"Downloading {url} with {retries} retries, verbose={verbose}")
# Usage: python tool.py download https://example.com --retries 5 --verbose
# ✅ Expected output:
# Downloading https://example.com with 5 retries, verbose=TrueUsage: python tool.py download https://example.com --retries 5 --verbose
🧩 10. Typed Options & Defaults
# Typer typed options example
# @app.command()
# def math(x: int, y: int = 10):
# typer.echo(x + y)
# Simulated:
x = 5
y = 10
print(f"x + y = {x + y}")
# Typer automatically:
# ✔ validates integers
# ✔ uses type hints
# ✔ generates help text
# ✅ Expected output:
# x + y = 15This makes code cleaner and more maintainable.
🏗 11. Subcommands (Typer's strongest feature)
# Typer subcommands example
# import typer
# app = typer.Typer()
# files = typer.Typer()
# users = typer.Typer()
# app.add_typer(files, name="files")
# app.add_typer(users, name="users")
# @files.command()
# def clean():
# typer.echo("Cleaning files...")
# @users.command()
# def add(name: str):
# typer.echo(f"Added user: {name}")
# Simulated output:
print("Command: python tool.py files clean")
print("Output: Cleaning files...")
print()
print("Command: python tool.py users add Boopie")
print("Output: Added user: Boopie")
# ✅ Expected output:
# Command: python tool.py files clean
# Output: Cleaning files...
#
# Command: python tool.py users add Boopie
# Output: Added user: BoopieYou now have a full CLI program with modules.
🧠 12. Real Project Example — AI Assistant CLI
# AI Assistant CLI example
# import typer
# import openai
# app = typer.Typer()
# @app.command()
# def ask(prompt: str):
# typer.echo("Thinking...")
# response = "Pretend AI answer"
# typer.echo(response)
# Simulated:
prompt = "What is Python?"
print("Thinking...")
print("Python is a high-level programming language known for its readability.")
print()
print("Typer is fantastic for developer tools, automation scripts, and utilities.")
# ✅ Expected output:
# Thinking...
# Python is a high-level programming language known for its readability.
#
# Typer is fantastic for developer tools, automation scripts, and utilities.Part 2: Advanced Patterns — Real Engineering Features
In this part, we go deeper into real engineering patterns, advanced argument features, error handling, output design, color/UI improvements, and structuring multi-file CLI apps the same way professionals build tools like pip, docker, aws-cli, git, uv, and fastapi-cli.
⚡ 1. Advanced argparse Features You MUST Know
Sometimes a user should only choose one option:
import argparse
parser = argparse.ArgumentParser()
group = parser.add_mutually_exclusive_group()
group.add_argument("--verbose", action="store_true")
group.add_argument("--quiet", action="store_true")
# Demo: can only use one
args = parser.parse_args(["--verbose"])
print(f"Verbose: {args.verbose}, Quiet: {args.quiet}")
# Using both would cause an error:
# python tool.py --verbose --quiet # ERROR!
# ✅ Expected output:
# Verbose: True, Quiet: FalseNow the user cannot do: python tool.py --verbose --quiet
This is used for logging mode, compression types, environment switches, etc.
✔ nargs — Accepting multiple values
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--files", nargs="+")
# Demo: multiple files
args = parser.parse_args(["--files", "a.txt", "b.txt", "c.txt"])
print(f"Files: {args.files}")
# Usage: python tool.py --files a.txt b.txt c.txt
# ✅ Expected output:
# Files: ['a.txt', 'b.txt', 'c.txt']Useful for batch operations.
✔ Choices — Restricting allowed values
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--level", choices=["low", "medium", "high"])
# Demo: valid choice
args = parser.parse_args(["--level", "high"])
print(f"Level: {args.level}")
# Invalid choice would cause error automatically
# ✅ Expected output:
# Level: highStops invalid inputs before they crash your program.
✔ Default values + required flags
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--mode", required=True)
parser.add_argument("--count", type=int, default=1)
# Demo
args = parser.parse_args(["--mode", "production"])
print(f"Mode: {args.mode}, Count: {args.count}")
# argparse handles missing required flags automatically
# ✅ Expected output:
# Mode: production, Count: 1🧠 2. Using Config Files + CLI Arguments Together
Professional CLI tools combine: environment variables, config files, command-line arguments.
Example: python deploy.py --config config.yaml --env production
argparse supports this pattern through: FileType, custom loaders, layered parsing logic
🧰 3. Advanced Typer Patterns (Modern CLI Engineering)
# Typer interactive prompts
# @app.command()
# def register():
# name = typer.prompt("Enter your name")
# password = typer.prompt("Password", hide_input=True)
# typer.echo(f"Welcome, {name}!")
# Simulated:
print("Enter your name: Boopie")
print("Password: ********")
print("Welcome, Boopie!")
print()
print("This is real UX - great for account creation scripts,"
" secure flows, admin utilities.")
# ✅ Expected output:
# Enter your name: Boopie
# Password: ********
# Welcome, Boopie!
#
# This is real UX - great for account creation scripts, secure flows, admin utilities.# Typer confirmation dialogs
# @app.command()
# def delete_user(name: str):
# if typer.confirm(f"Delete user {name}?"):
# typer.echo("Deleted.")
# else:
# typer.echo("Cancelled.")
# Simulated:
name = "Boopie"
print(f"Delete user {name}? [y/N]: y")
print("Deleted.")
# ✅ Expected output:
# Delete user Boopie? [y/N]: y
# Deleted.# Typer progress bars
# with typer.progressbar(range(100)) as bar:
# for i in bar:
# time.sleep(0.01)
# Simulated:
print("Processing: [████████████████████████████████] 100%")
print("Done!")
print()
print("Progress bars make tools feel professional instantly.")
# ✅ Expected output:
# Processing: [████████████████████████████████] 100%
# Done!
#
# Progress bars make tools feel professional instantly.# Rich console output
# from rich.console import Console
# console = Console()
# @app.command()
# def info():
# console.print("[bold green]Server Running[/bold green]")
# Simulated:
print("\033[1;32mServer Running\033[0m") # Bold green
print()
print("Used in: FastAPI CLI, Poetry, Modern developer tools")
# ✅ Expected output:
# ␛[1;32mServer Running␛[0m
#
# Used in: FastAPI CLI, Poetry, Modern developer tools⚙️ 4. Handling Errors Gracefully
Typer supports structured exceptions:
# Typer error handling
# class NotFound(Exception):
# pass
# @app.command()
# def get_user(user_id: int):
# if user_id != 1:
# raise NotFound("User not found")
# @app.error_handler(NotFound)
# def handle_not_found(e):
# typer.echo(f"Error: {e}")
# Simulated:
user_id = 99
if user_id != 1:
print(f"Error: User {user_id} not found")
print()
print("This makes CLI tools 'feel' polished.")
# ✅ Expected output:
# Error: User 99 not found
#
# This makes CLI tools 'feel' polished.🧩 5. Building Multi-Command Apps (Professional Structure)
Real CLIs use folder architectures. For Typer:
# main.py
# import typer
# import users, files
# app = typer.Typer()
# app.add_typer(users.app, name="users")
# app.add_typer(files.app, name="files")
# if __name__ == "__main__":
# app()
print("Multi-command CLI structure:")
print(" python tool.py users add Boopie")
print(" python tool.py files clean")
print(" python tool.py config set key value")
# ✅ Expected output:
# Multi-command CLI structure:
# python tool.py users add Boopie
# python tool.py files clean
# python tool.py config set key value# users.py
# import typer
# app = typer.Typer()
# @app.command()
# def add(name: str):
# typer.echo(f"Added user: {name}")
# Simulated:
name = "Boopie"
print(f"Added user: {name}")
print()
print("This is identical to how enterprise tools structure their commands.")
# ✅ Expected output:
# Added user: Boopie
#
# This is identical to how enterprise tools structure their commands.🧠 6. Environment-Aware Commands
import os
# @app.command()
# def upload(file: str):
# key = os.getenv("API_KEY")
# if not key:
# typer.echo("Missing API_KEY")
# raise typer.Exit()
# Simulated:
key = os.getenv("API_KEY")
if not key:
print("Missing API_KEY")
print("Set it with: export API_KEY='your-key'")
else:
print(f"Using API key: {key[:4]}...")
print()
print("Used heavily in: cloud automation, CI/CD tools, backend deployment flows")
# ✅ Expected output:
# Missing API_KEY
# Set it with: export API_KEY='your-key'
#
# Used heavily in: cloud automation, CI/CD tools, backend deployment flows📦 7. Creating "Plugins" for CLI Tools
Typer supports loading commands dynamically:
# Plugin loading pattern
# def load_plugins(app):
# for plugin in discover_plugins():
# app.add_typer(plugin.typer_app)
# Simulated plugin discovery:
plugins = ["analytics", "backup", "export"]
for plugin in plugins:
print(f"Loaded plugin: {plugin}")
print()
print("This pattern is how tools like pytest, aws-cli, docker")
print("extend functionality through plugins.")
# ✅ Expected output:
# Loaded plugin: analytics
# Loaded plugin: backup
# Loaded plugin: export
#
# This pattern is how tools like pytest, aws-cli, docker
# extend functionality through plugins.🔍 8. Auto-generated Help Screens
Typer help output looks like:
This UI alone saves hours of documentation. argparse also generates help automatically, but Typer's formatting is noticeably cleaner.
🧱 9. Testing CLI Tools
You can test argparse & Typer apps with pytest using CliRunner. Example for Typer:
# Testing CLI with pytest
# from typer.testing import CliRunner
# from main import app
# runner = CliRunner()
# def test_greet():
# result = runner.invoke(app, ["greet", "Boopie"])
# assert "Hello Boopie" in result.stdout
# Simulated test:
def test_greet():
# Simulated result
stdout = "Hello Boopie!"
assert "Hello Boopie" in stdout
print("✔ test_greet PASSED")
test_greet()
print()
print("Testing CLIs makes them reliable.")
# ✅ Expected output:
# ✔ test_greet PASSED
#
# Testing CLIs makes them reliable.🌐 10. Packaging Your CLI as a PIP Installable Tool
You can turn a Typer/argparse script into a real installable command:
Users can now install: pip install learnfast
And then run: learnfast
Exactly how uv, pip, and fastapi work.
Part 3: Enterprise-Grade CLI Engineering — Production Deployment
This final section focuses on enterprise-grade CLI engineering, including structured logging, configuration layers, packaging/distribution, versioning, interactive shell modes, autocomplete, performance optimisation, security best practices, and production deployment patterns.
⚡ 1. Enterprise-Style Configuration Layers
Professional CLI apps often support configuration from multiple layers:
- Default settings
- Config file (YAML/JSON/TOML)
- Environment variables
- Command-line arguments (highest priority)
import os
# Configuration layer pattern
def load_defaults():
return {"debug": False, "port": 8000}
def load_yaml(path):
# Would use yaml.safe_load() in real code
return {"port": 3000}
def read_env():
return {"debug": os.getenv("DEBUG", "false") == "true"}
# Build config with priority
config = load_defaults()
print(f"1. Defaults: {config}")
# if os.path.exists("config.yaml"):
config.update(load_yaml("config.yaml"))
print(f"2. After YAML: {config}")
config.update(read_env())
print(f"3. After ENV: {config}")
# CLI args would override all
# config.update(vars(cli_args))
print()
print("CLI arguments have highest priority")
# ✅ Expected output:
# 1. Defaults: {'debug': False, 'port': 8000}
# 2. After YAML: {'debug': False, 'port': 3000}
# 3. After ENV: {'debug': False, 'port': 3000}
#
# CLI arguments have highest priorityThis allows flexible behaviour: users can override defaults, automation pipelines can use environment variables, developers can test using temporary config files. Typer-based tools commonly use pydantic or dynaconf to manage all layers cleanly.
🧠 2. CLI Autocompletion (Bash, Zsh, Fish)
Modern CLI tools include autocomplete for: commands, options, file paths, arguments.
Typer makes this extremely simple:
Under the hood, it generates shell-compatible scripts. This dramatically improves user experience and is a key part of professional developer tools.
📦 3. Packaging as a Standalone Executable (Windows, Mac, Linux)
Not every user has Python installed. You can turn your CLI into one executable file using:
Nuitka (fastest) — Compiles Python to C.
Briefcase — Creates full OS-native installers.
This is how tools become "real" apps that run anywhere.
🧩 4. Versioning & Release Flows
Every CLI should have a version:
# Versioning your CLI
__version__ = "1.2.0"
# @app.command()
# def version():
# typer.echo("LearnCodingFast CLI v1.2.0")
# Or use Typer's built-in:
# app = typer.Typer(context_settings={"help_option_names": ["--help"]})
print(f"LearnCodingFast CLI v{__version__}")
print()
print("Release strategy:")
print(" 1. increment version")
print(" 2. build wheel")
print(" 3. publish to PyPI")
print(" 4. update docs")
print(" 5. tag release in GitHub")
# ✅ Expected output:
# LearnCodingFast CLI v1.2.0
#
# Release strategy:
# 1. increment version
# 2. build wheel
# 3. publish to PyPI
# 4. update docs
# 5. tag release in GitHub⚙️ 5. Creating Subcommands Like Professional Tools (docker, git, aws-cli)
Large CLI tools organise commands into modules:
# Professional subcommand structure
# app = typer.Typer()
# users_app = typer.Typer()
# files_app = typer.Typer()
# app.add_typer(users_app, name="users")
# app.add_typer(files_app, name="files")
print("Professional CLI structure:")
print(" tool users add <name>")
print(" tool users delete <id>")
print(" tool files upload <path>")
print(" tool files download <url>")
print(" tool system info")
print()
print("Each subcommand can have its own flags,")
print("callbacks, error handlers, and help screens.")
print("This structure is scalable to hundreds of commands.")
# ✅ Expected output:
# Professional CLI structure:
# tool users add <name>
# tool users delete <id>
# tool files upload <path>
# tool files download <url>
# tool system info
#
# Each subcommand can have its own flags,
# callbacks, error handlers, and help screens.
# This structure is scalable to hundreds of commands.🧵 6. Integrating Your CLI With Other Tools (Pipes & Redirects)
CLI tools should work with Linux/Windows pipes:
import sys
# Reading from stdin for piped input
# data = sys.stdin.read()
# Simulated pipe input
simulated_input = "Hello from pipe!"
print(f"Received: {simulated_input}")
print()
print("Your CLI can now be used inside:")
print(" ✔ shells")
print(" ✔ automation scripts")
print(" ✔ cron jobs")
print(" ✔ CI/CD pipelines")
print()
print("This elevates it to real engineering level.")
# ✅ Expected output:
# Received: Hello from pipe!
#
# Your CLI can now be used inside:
# ✔ shells
# ✔ automation scripts
# ✔ cron jobs
# ✔ CI/CD pipelines
#
# This elevates it to real engineering level.🔒 7. Authentication & Secrets Management
If your CLI interacts with APIs, databases, or cloud services, it must handle secrets safely. Best practices:
- ✔ Use environment variables: export API_KEY="123"
- ✔ Never store secrets in code
- ✔ Use a .env loader (python-dotenv)
- ✔ Hide passwords in Typer prompts
# Secure password input
# password = typer.prompt("Password", hide_input=True)
import getpass
# Simulated secure input
print("Password: ", end="")
# password = getpass.getpass("") # Would hide input
print("********")
print()
print("Best practices:")
print(" ✔ Use environment variables")
print(" ✔ Never store secrets in code")
print(" ✔ Use python-dotenv for .env files")
print(" ✔ Hide passwords in prompts")
print(" ✔ Encrypt stored tokens")
# ✅ Expected output:
# Password: ********
#
# Best practices:
# ✔ Use environment variables
# ✔ Never store secrets in code
# ✔ Use python-dotenv for .env files
# ✔ Hide passwords in prompts
# ✔ Encrypt stored tokens🧪 8. Writing Realistic Tests for CLI Behaviour
Use Typer's testing tools:
# Comprehensive CLI testing
# from typer.testing import CliRunner
# runner = CliRunner()
# def test_delete():
# result = runner.invoke(app, ["users", "delete", "123"])
# assert result.exit_code == 0
# assert "Deleted" in result.stdout
# Simulated tests:
def test_delete():
exit_code = 0
stdout = "Deleted user 123"
assert exit_code == 0
assert "Deleted" in stdout
print("✔ test_delete PASSED")
def test_invalid_command():
exit_code = 2
assert exit_code != 0
print("✔ test_invalid_command PASSED")
test_delete()
test_invalid_command()
print()
print("Tests let you safely refactor your CLI without breaking features.")
# ✅ Expected output:
# ✔ test_delete PASSED
# ✔ test_invalid_command PASSED
#
# Tests let you safely refactor your CLI without breaking features.🌐 9. Color, Formatting & Tabular Output (Rich + Typer)
Real-world tools display: tables, panels, logs, status values, colour-coded results.
# Rich table output
# from rich.table import Table
# from rich.console import Console
# @app.command()
# def stats():
# table = Table(title="User Stats")
# table.add_column("Name")
# table.add_column("Uploads")
# table.add_row("Boopie", "22")
# table.add_row("Josh", "5")
# Console().print(table)
# Simulated table output:
print("┌───────────────────────┐")
print("│ User Stats │")
print("├──────────┬────────────┤")
print("│ Name │ Uploads │")
print("├──────────┼────────────┤")
print("│ Boopie │ 22 │")
print("│ Josh │ 5 │")
print("└──────────┴────────────┘")
print()
print("This turns your CLI into a visually appealing, professional tool.")
# ✅ Expected output:
# ┌───────────────────────┐
# │ User Stats │
# ├──────────┬────────────┤
# │ Name │ Uploads │
# ├──────────┼────────────┤
# │ Boopie │ 22 │
# │ Josh │ 5 │
# └──────────┴────────────┘
#
# This turns your CLI into a visually appealing, professional tool.# 🎯 YOUR TURN — replace each ___ using the hint beside it.
import argparse
parser = argparse.ArgumentParser(prog="tally", description="Count things")
# 1) A positional argument. nargs="+" means one or more of them.
parser.___("items", nargs="+", help="what to count") # 👉 replace ___ with add_argument
# 2) Everything off the command line arrives as text, so say what it is.
parser.add_argument("--times", ___=int, default=1, help="repeat each item") # 👉 replace ___ with type
# 3) A flag that is simply present or absent — no value after it.
parser.add_argument("--loud", action="___", help="shout the result") # 👉 replace ___ with store_true
# Passing a list here instead of reading sys.argv is how you TEST a CLI.
args = parser.parse_args(["apples", "pears", "--times", "2", "--loud"])
print("Items:", args.items)
print("Times:", args.times)
print("Loud: ", args.loud)
for item in args.items:
line = f"{item} x{args.times}"
# 4) Shout only when the flag was given.
print(line.upper() ___ args.loud else line) # 👉 replace ___ with if
# 5) Left out, the defaults apply: times is 1 and the flag is False.
defaults = parser.___(["figs"]) # 👉 replace ___ with parse_args
print("Default times:", defaults.times, "loud:", defaults.loud)
# ✅ Expected output:
# Items: ['apples', 'pears']
# Times: 2
# Loud: True
# APPLES X2
# PEARS X2
# Default times: 1 loud: False🧱 10. Performance & Concurrency Inside CLI Tools
CLI commands often do: API requests, file scanning, JSON parsing, image processing, database queries.
- ✔ ThreadPoolExecutor (I/O tasks)
- ✔ ProcessPoolExecutor (CPU tasks)
- ✔ asyncio (many lightweight tasks)
- ✔ caching (functools.lru_cache)
- ✔ chunk streaming
Your CLI becomes fast and scalable.
🚀 11. Real Engineering Pattern — Modular "Actions" Layer
For large CLIs, never put logic inside the command functions. Instead:
Command → calls → action function
This allows: rewiring logic, sharing helpers, unit testing functions directly, keeping CLI layer clean. This is how professional tools stay maintainable.
📦 12. Distribution: PyPI, Brew, Winget, and GitHub Releases
You can distribute your tool via:
- ✔ PyPI (pip install)
- ✔ Homebrew (Mac)
- ✔ Winget (Windows)
- ✔ Snap/Apt (Linux)
- ✔ GitHub Releases (binaries)
This is the path for turning your CLI into a global developer tool.
🎉 Final Summary
After completing all 3 parts, you can build CLI tools with:
You now have the skills to build production-grade CLI tools!
📋 Quick Reference — CLI Tools
| Syntax / Tool | What it does |
|---|---|
| argparse.ArgumentParser() | Parse CLI arguments (stdlib) |
| parser.add_argument('--name') | Add a named flag argument |
| @app.command() (Typer) | Define a Typer CLI command |
| typer.Option(..., help=) | Add option with help text |
| rich.print("[bold]text[/bold]") | Pretty terminal output |
🎉 Great work! You've completed this lesson.
You can now build polished CLI tools with argument parsing, progress bars, coloured output, and bash completions.
Practice quiz
Which CLI library ships with Python and needs no installation?
- Typer
- Click
- argparse
- Rich
Answer: argparse. argparse is part of the standard library, so it is built into Python with no extra install.
In argparse, what is the difference between a positional and an optional argument?
- Positionals are required by name; optionals start with - or -- and are not required
- Positionals start with --, optionals do not
- They are identical
- Optionals must come first
Answer: Positionals are required by name; optionals start with - or -- and are not required. Positional arguments (like a filename) are required; optional flags such as --verbose start with - or -- and are not required.
How do you make an argparse flag like --verbose a simple on/off switch?
- type=bool
- nargs=0
- choices=[True, False]
- action="store_true"
Answer: action="store_true". action='store_true' makes the flag store True when present and False otherwise.
Which add_argument option restricts a value to a fixed set like low/medium/high?
- nargs
- choices
- default
- metavar
Answer: choices. choices=['low','medium','high'] makes argparse reject any value outside that list automatically.
What does nargs="+" do for an argument?
- Accepts one or more values into a list
- Makes it optional
- Adds a default of 1
- Allows only one value
Answer: Accepts one or more values into a list. nargs='+' collects one or more values, useful for batch operations like --files a.txt b.txt c.txt.
How does argparse let a tool behave like git with add/commit/push subcommands?
- parser.add_argument('sub')
- parser.subcommand()
- parser.add_subparsers() with sub.add_parser('add')
- It cannot do subcommands
Answer: parser.add_subparsers() with sub.add_parser('add'). add_subparsers() creates a subcommand dispatcher, and sub.add_parser('add') defines each subcommand.
Who created Typer, and what framework is it modeled after?
- Guido van Rossum, modeled on Django
- The creator of FastAPI, sharing its type-hint style
- The Flask team
- The pytest team
Answer: The creator of FastAPI, sharing its type-hint style. Typer is built by the creator of FastAPI and uses the same type-hint-driven, modern style.
How does Typer know an argument's type and validate it?
- You write manual isinstance checks
- It guesses from the value
- You pass a type= argument like argparse
- From the function's type hints (e.g. name: str, count: int)
Answer: From the function's type hints (e.g. name: str, count: int). Typer reads the command function's type hints to validate and convert arguments automatically.
Which is the recommended way to keep secrets like API keys out of a CLI tool?
- Hard-code them in the script
- Read them from environment variables (e.g. os.getenv)
- Store them in the --help text
- Commit them to Git
Answer: Read them from environment variables (e.g. os.getenv). The lesson stresses using environment variables / .env loaders and never hard-coding secrets in code.
In pyproject.toml, what makes a Typer/argparse script installable as a real command?
- [tool.black]
- [build-system]
- [project.scripts] mapping a name to module:app
- [tool.pytest]
Answer: [project.scripts] mapping a name to module:app. A [project.scripts] entry like learnfast = 'app.main:app' exposes the app as an installable console command.
Continue this course
- Previous: Testing with pytest: Fixtures, Parametrisation & Mocks
- Next: Virtual Environments & Dependency Management Best Practices — Isolate projects with venv and manage packages with pip and Poetry
- Quick reference: Python cheat sheet