Building a CLI Tool
A complete, real command-line tool built from scratch using argparse — start to finish, project-style.
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.
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 helpPositional 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.
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()$ 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: filenameA 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.
--help Is Generated Automatically, Not Written By Hand
$ 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 wordssys.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.Reading the File and Computing Statistics
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.
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.
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"])$ 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 subcommandPlaying 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.
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 -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()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.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.
[project]
name = "wordstat"
version = "0.1.0"
[project.scripts]
wordstat = "wordstat.cli:main" # package.module:functionpip install -e .
wordstat report notes.txt --top 5 # no more "python wordstat.py" neededThe [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.
An Internal Deploy Tool at a Salt Lake City DevOps Team
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.
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.2Why 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."
Four Misconceptions About Building CLI Tools
5 Interview Questions — With Complete Answers
CLI Tool Mistakes Beginners Make Constantly
Errors You Will Hit Building CLI Tools — And Exactly Why
🎯 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 CodeDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.