Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Advanced+250 XP

Building a CLI Tool

A complete, real command-line tool built from scratch using argparse — start to finish, project-style.

50 min August 2026
// Part 01 — What We're Building

wordstat — a Small, Real Text-Analysis CLI

This module builds one complete tool end to end, rather than covering isolated snippets — a command-line utility called wordstat that reads a text file and reports word counts, the most frequent words, and basic statistics. It deliberately pulls together file I/O, the collections module, and — the actual focus of this module — argparse, the standard library's tool for building a real command-line interface.

What the finished tool will support
wordstat report myfile.txt                  # basic word/line/char counts
wordstat report myfile.txt --top 5           # the 5 most common words
wordstat report myfile.txt --min-length 4    # ignore words shorter than 4 characters
wordstat --help                               # auto-generated usage help
// Part 02 — argparse Basics

Positional and Optional Arguments

Without argparse, reading command-line arguments means manually parsing sys.argv — a raw list of strings with no validation, no help text, and no error handling. argparse replaces all of that with a declarative description of what arguments a script accepts.

wordstat.py — a first, minimal version
import argparse

def main():
    parser = argparse.ArgumentParser(description="Analyse word statistics in a text file")
    parser.add_argument("filename", help="Path to the text file to analyse")   # positional — required
    parser.add_argument("--top", type=int, default=10, help="Show the N most common words")

    args = parser.parse_args()

    print(f"Analysing {args.filename}, showing top {args.top} words")

if __name__ == "__main__":
    main()
Running it
$ python wordstat.py notes.txt
Analysing notes.txt, showing top 10 words

$ python wordstat.py notes.txt --top 5
Analysing notes.txt, showing top 5 words

$ python wordstat.py
usage: wordstat.py [-h] [--top TOP] filename
wordstat.py: error: the following arguments are required: filename

A positional argument (filename) is required and identified by its position, not a flag. An optional argument (--top, note the leading dashes) has a default value and can be omitted — type=int tells argparse to convert the raw string input and automatically reject non-numeric input with a clear error, before your own code ever runs.

// Part 03 — Free Help Text

--help Is Generated Automatically, Not Written By Hand

What --help produces, entirely from the add_argument() calls above
$ python wordstat.py --help
usage: wordstat.py [-h] [--top TOP] filename

Analyse word statistics in a text file

positional arguments:
  filename    Path to the text file to analyse

options:
  -h, --help  show this help message and exit
  --top TOP   Show the N most common words
🎯 Pro Tip
This is one of argparse's biggest practical wins over hand-parsing sys.argv. The help= string passed to each add_argument() call is the single source of truth for both validation and documentation — there is no separate help text to keep in sync manually, and it can never drift out of date the way a hand-written usage comment can.
// Part 04 — Building the Real Logic

Reading the File and Computing Statistics

Adding the actual word-counting logic
import argparse
from collections import Counter

def analyse_file(filename, min_length=1):
    with open(filename, encoding="utf-8") as f:
        text = f.read()

    words = [w.strip(".,!?;:\"'()").lower() for w in text.split()]
    words = [w for w in words if len(w) >= min_length and w]

    return {
        "line_count": text.count("\n") + 1,
        "word_count": len(words),
        "char_count": len(text),
        "most_common": Counter(words).most_common(),
    }

def main():
    parser = argparse.ArgumentParser(description="Analyse word statistics in a text file")
    parser.add_argument("filename", help="Path to the text file to analyse")
    parser.add_argument("--top", type=int, default=10, help="Show the N most common words")
    parser.add_argument("--min-length", type=int, default=1, help="Ignore words shorter than this")

    args = parser.parse_args()

    stats = analyse_file(args.filename, min_length=args.min_length)

    print(f"Lines: {stats['line_count']}")
    print(f"Words: {stats['word_count']}")
    print(f"Characters: {stats['char_count']}")
    print(f"\nTop {args.top} words:")
    for word, count in stats["most_common"][:args.top]:
        print(f"  {word}: {count}")

if __name__ == "__main__":
    main()

Notice --min-length on the command line automatically becomes args.min_length in code — argparse converts dashes to underscores automatically, since a dash is not a legal character in a Python identifier.

// Part 05 — Subcommands

Structuring a Tool With Multiple Distinct Actions

A real CLI often supports several genuinely different actions — think git commit vs git push. argparse supports this through subparsers, each with its own independent set of arguments.

Adding a second subcommand, 'wordstat count', alongside 'wordstat report'
def main():
    parser = argparse.ArgumentParser(description="wordstat — a small text analysis tool")
    subparsers = parser.add_subparsers(dest="command", required=True)

    report_parser = subparsers.add_parser("report", help="Full statistics report")
    report_parser.add_argument("filename")
    report_parser.add_argument("--top", type=int, default=10)
    report_parser.add_argument("--min-length", type=int, default=1)

    count_parser = subparsers.add_parser("count", help="Just the total word count")
    count_parser.add_argument("filename")

    args = parser.parse_args()

    if args.command == "report":
        run_report(args)
    elif args.command == "count":
        stats = analyse_file(args.filename)
        print(stats["word_count"])
Now the tool has two distinct commands, each with its own help
$ python wordstat.py report notes.txt --top 5
$ python wordstat.py count notes.txt
$ python wordstat.py report --help      # help scoped to just the report subcommand
// Part 06 — Reading From stdin, and Exit Codes

Playing Well With the Rest of the Command Line

A well-behaved CLI tool should support reading from stdin, so it can be chained with other command-line tools using a pipe — and should return a meaningful exit code, so scripts calling it can detect success or failure.

Supporting stdin as an alternative to a filename
import sys

def get_text(filename):
    if filename == "-":                # the conventional way to mean "read from stdin"
        return sys.stdin.read()
    with open(filename, encoding="utf-8") as f:
        return f.read()

# Now this works:
# cat notes.txt | python wordstat.py report -
Returning proper exit codes
def main():
    parser = argparse.ArgumentParser(...)
    args = parser.parse_args()

    try:
        stats = analyse_file(args.filename)
    except FileNotFoundError:
        print(f"Error: {args.filename} not found", file=sys.stderr)
        sys.exit(1)          # non-zero exit code signals failure to the calling shell/script

    print(f"Words: {stats['word_count']}")
    sys.exit(0)               # explicit, though 0 is also the default if the script just ends normally

if __name__ == "__main__":
    main()
🎯 Pro Tip
Error messages should go to stderr, not stdout print(..., file=sys.stderr) — so that a script consuming this tool's real output via a pipe (wordstat report notes.txt | some_other_tool) never accidentally receives error text mixed into the data it is trying to process.
// Part 07 — Making It Installable

From "a script you run with python" to a Real Command

Right now, running the tool requires python wordstat.py .... A properly packaged CLI tool (using the packaging concepts from the earlier Packaging & Distribution module, and the project-structure conventions from the Modules & Virtual Environments module) can be installed so it runs as a plain command: wordstat report notes.txt.

pyproject.toml — registering an entry point
[project]
name = "wordstat"
version = "0.1.0"

[project.scripts]
wordstat = "wordstat.cli:main"     # package.module:function
Installing it locally in editable mode, and using it as a real command
pip install -e .

wordstat report notes.txt --top 5    # no more "python wordstat.py" needed

The [project.scripts] entry tells pip to generate a small executable wrapper during installation that calls main() inside wordstat/cli.py — this is exactly the mechanism behind real command-line tools you already use, like pytest or black, which are themselves just Python packages installed with an entry point defined the same way.

// Part 08 — Real World
💼 What This Looks Like at Work

An Internal Deploy Tool at a Salt Lake City DevOps Team

Scenario — DevOps team, Salt Lake City · Internal tooling

A team's deployment process starts as a shared page of copy-pasted shell commands, each engineer running a slightly different variation, with several production incidents traced back to a step run out of order or a flag forgotten entirely. They consolidate the whole process into a single internal CLI tool, built exactly the way this module builds wordstat.

The resulting tool's shape
deploytool plan --env staging          # shows what WOULD happen, changes nothing
deploytool apply --env staging          # actually deploys
deploytool apply --env prod --confirm   # --confirm required for prod, on purpose
deploytool rollback --env prod --to v1.4.2

Why this mattered beyond convenience

Every deployment now runs through the exact same validated code path, with argparse itself rejecting malformed invocations before any real action happens — a missing --env, or an attempt to deploy to prod without the deliberately required --confirm flag, fails immediately with a clear error instead of half-executing a copy-pasted shell command with the wrong environment silently baked in. The team's own framing: "the CLI's validation IS the safety mechanism — it is not possible to accidentally deploy to the wrong environment anymore, because the tool simply won't let you."

// Part 09 — Misconceptions

Four Misconceptions About Building CLI Tools

✕ ""Parsing sys.argv manually is simpler than learning argparse for a small script""
It looks simpler for the very first few lines, but quickly loses out — argparse gives type conversion, validation, auto-generated help text, and clear error messages essentially for free, all of which have to be hand-built and hand-maintained with manual sys.argv parsing.
✕ ""A CLI tool always needs to be installed/packaged to be considered real""
A single well-structured script run with "python tool.py ..." is a completely legitimate, common form for an internal or personal tool. Packaging with an entry point (Part 07) is valuable specifically when the tool needs to be run as a plain command across a team or distributed more broadly.
✕ ""Error messages and normal output can both just use print()""
Regular output should go to stdout; errors should go to stderr (print(..., file=sys.stderr)) — this distinction matters the moment the tool is used in a pipe with other commands, so error text never gets mixed into data another tool is trying to process.
✕ ""Subcommands (like git commit/git push) are unnecessary complexity for most tools""
They are exactly the right structure the moment a tool needs to support more than one genuinely distinct action, each with different arguments — trying to cram several unrelated behaviours behind one flat set of flags becomes confusing far faster than a small number of clearly named subcommands.
// Part 10 — Interview Prep

5 Interview Questions — With Complete Answers

What is the difference between a positional and an optional argument in argparse?
A positional argument (like filename) is required and identified by its position in the command line, with no leading dashes. An optional argument (like --top) is identified by its flag name, can have a default value, and can be omitted entirely if a default is provided.
What genuinely valuable behaviour does argparse provide beyond just reading sys.argv?
Automatic type conversion and validation (type=int rejects non-numeric input with a clear error before your code runs), auto-generated --help text sourced directly from each add_argument() call, and clear, consistent error messages for missing or malformed arguments — none of which have to be hand-built.
When would you reach for argparse subparsers instead of a flat set of flags?
When a tool needs to support multiple genuinely distinct actions with their own independent sets of arguments — similar to git commit vs git push. Subparsers give each subcommand its own scoped arguments and help text, which stays clearer than trying to encode several unrelated behaviours into one flat flag set.
Why should error output go to stderr rather than stdout?
So a script or pipe consuming the tool's real output (e.g. wordstat report file.txt | grep something) never accidentally receives error text mixed into the data stream it is processing — stdout is for the tool's actual output, stderr is for diagnostics and errors.
How does a Python CLI tool become runnable as a plain command (like "wordstat ...") instead of "python wordstat.py ..."?
By defining an entry point in pyproject.toml under [project.scripts], mapping a command name to a package.module:function target, then installing the package (e.g. with pip install -e . for local development) — pip generates a small executable wrapper that calls that function directly.
// Common Mistakes

CLI Tool Mistakes Beginners Make Constantly

Forgetting that argparse converts dashes to underscores in attribute names
--min-length on the command line becomes args.min_length in code, not args.min-length (which would not even be a legal Python identifier) — a common source of AttributeError for anyone new to argparse.
Mixing error output into stdout instead of stderr
Breaks any downstream pipe or script that expects the tool's stdout to contain only its real output — error diagnostics belong on stderr specifically so they can be separated cleanly.
Not setting required=True on add_subparsers when every invocation must pick one
Without it, running the tool with no subcommand at all silently does nothing (args.command is simply None) instead of showing a clear "you must choose a command" error.
Never testing the packaged, installed version of the tool, only the raw script
A tool that works fine as "python tool.py" can still fail once packaged and installed via its entry point, if the pyproject.toml target path is wrong — always verify "pip install -e ." followed by running the actual installed command name.
// Error Library

Errors You Will Hit Building CLI Tools — And Exactly Why

error: the following arguments are required: filename
Cause: A required positional argument was not provided on the command line.
Fix: Provide the missing argument, or run the tool with --help to see the exact expected usage.
error: argument --top: invalid int value: 'five'
Cause: A value was passed to an argument declared with type=int, but it cannot be converted to an integer.
Fix: Pass a genuinely numeric value, or reconsider whether the argument should actually accept a string.
AttributeError: 'Namespace' object has no attribute 'min_length'
Cause: Code refers to an attribute name that does not match what argparse actually generated — often because the dash-to-underscore conversion was not accounted for, or the argument name has a typo.
Fix: Double-check the exact attribute name argparse produces (dashes become underscores) by printing args or checking vars(args).
PermissionError: [Errno 13] Permission denied: 'notes.txt'
Cause: The file exists but the current user lacks read permission, or the path actually points to a directory rather than a file.
Fix: Check the file's permissions and confirm the path is correct — this is a genuine environment/filesystem issue, not something argparse itself is responsible for.

🎯 Key Takeaways

  • argparse turns a raw sys.argv list into validated, typed, documented arguments — positional (required, by position) and optional (flagged, with defaults).
  • --help is generated automatically from the help= strings passed to add_argument(), staying perpetually in sync with the actual accepted arguments.
  • Subparsers (add_subparsers()) structure a tool around multiple distinct actions, each with its own independently scoped arguments and help text.
  • A well-behaved CLI tool reads from stdin when given "-" as a filename convention, sends errors to stderr (not stdout), and returns meaningful exit codes via sys.exit().
  • A [project.scripts] entry in pyproject.toml (from the Packaging module) turns a Python function into a plain installable command, exactly how tools like pytest and black work.
  • A CLI's argument validation can double as a genuine safety mechanism in production tooling — rejecting malformed or dangerous invocations before any real action happens.

What comes next

Module 45 covers Python best practices — PEP 8, naming conventions, and the conventions that separate readable, maintainable code from code that merely works.

Module 45 → Python Best Practices — PEP 8, Clean Code
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...