Command-Line Tools and Project Layout in Python
AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.
A script becomes a tool when it has clear commands, real argument handling, generated help, and a layout someone else can install. argparse and a standard project structure give you all four.
Environment
argparse ships with Python 3.13 — nothing to install. Start from a clean environment:
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
First CLI
# cli.py
import argparse
def main() -> None:
parser = argparse.ArgumentParser(description="Count lines in a file")
parser.add_argument("path", help="path to the file")
parser.add_argument("--encoding", default="utf-8", help="file encoding, default utf-8")
parser.add_argument("-v", "--verbose", action="store_true", help="show details")
args = parser.parse_args()
with open(args.path, encoding=args.encoding) as f:
lines = sum(1 for _ in f)
if args.verbose:
print(f"{args.path}: {lines} lines")
else:
print(lines)
if __name__ == "__main__":
main()
Run it:
python cli.py data.txt
python cli.py data.txt --encoding gbk -v
python cli.py --help # help is generated for you
The parts that matter:
- Positional arguments have no prefix; optional ones use
-/--. action="store_true"means present isTrue, absent isFalse.default=sets the fallback value.help=shows up in--help.
Types and validation
parser.add_argument("--port", type=int, default=8000)
parser.add_argument("--ratio", type=float, default=1.0)
parser.add_argument("--level", choices=["debug", "info", "error"], default="info")
parser.add_argument("--tags", nargs="*", default=[], help="zero or more tags")
parser.add_argument("--retry", type=int, default=0)
- Without
type=int,--port 8000gives you the string"8000". choicesrestricts the value; an invalid choice prints an error and exits with code 2.nargs="*"/"+"collect several values into a list.
For anything more specific, pass a callable to type:
def positive_int(value: str) -> int:
n = int(value)
if n <= 0:
raise argparse.ArgumentTypeError("must be a positive integer")
return n
parser.add_argument("--workers", type=positive_int, default=1)
Subcommands
Once a tool has more than one job, give it git-style subcommands:
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="mytool", description="Example tool")
sub = parser.add_subparsers(dest="command", required=True)
count = sub.add_parser("count", help="count lines")
count.add_argument("path")
find = sub.add_parser("find", help="find a keyword")
find.add_argument("path")
find.add_argument("keyword")
find.add_argument("-i", "--ignore-case", action="store_true")
return parser
def main() -> int:
args = build_parser().parse_args()
if args.command == "count":
...
elif args.command == "find":
...
return 0
python cli.py count data.txt
python cli.py find data.txt error -i
Exit codes and streams
Convention: 0 on success, non-zero on failure (use sys.exit(1) or raise SystemExit(1)); argparse returns 2 for bad arguments.
import sys
def main() -> int:
try:
run()
except FileNotFoundError as e:
print(f"error: {e}", file=sys.stderr) # errors go to stderr
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
Keep normal output on stdout and errors on stderr so that mytool count a.txt > out.txt never mixes diagnostics into the result.
Project layout
A structure you can copy as-is — the src layout prevents tests from importing the package from the working directory instead of the installed one:
mytool/
├── pyproject.toml
├── README.md
├── src/
│ └── mytool/
│ ├── __init__.py
│ ├── cli.py
│ └── core.py
└── tests/
└── test_cli.py
pyproject.toml:
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "mytool"
version = "0.1.0"
description = "Example command-line tool"
requires-python = ">=3.11"
dependencies = []
[project.scripts]
mytool = "mytool.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
Install it in editable mode and the command name works everywhere:
python -m pip install -e .
mytool count data.txt
mytool --help
Without installing, python -m mytool.cli runs the same code.
Split responsibilities: core.py holds pure logic that is easy to test; cli.py only parses arguments, calls core, and prints. Test core thoroughly and keep CLI tests as a thin smoke test.
Common mistakes
1. Parsing sys.argv by hand
path = sys.argv[1] # no --help, no validation, breaks when the order changes
argparse gives you help text, errors, and exit codes for free.
2. Forgetting type=int
--port arrives as a string; comparisons and arithmetic fail in confusing ways.
3. Giving store_true a default of True
parser.add_argument("--force", action="store_true", default=True) # always true
4. Omitting required=True on add_subparsers
On older Pythons, running with no subcommand leaves args.command as None and you have to check for it yourself.
5. Putting logic in cli.py so it cannot be tested
Move it to core.py and keep the CLI layer thin.
6. Skipping README and help= text
Help is the interface. Every argument gets a help=.
7. Printing errors to stdout
Redirection then mixes errors into the data. Diagnostics belong on stderr.
Summary
- argparse handles positional arguments, options, types,
choicesand subcommands. - Structure the entry point as
main() -> intand callsys.exit(main()). - Normal output to stdout, errors to stderr.
- Use src layout plus
pyproject.toml, and separate logic (core.py) from the CLI so tests stay easy.
Next: put the pieces together — read a messy CSV, clean it, and write a JSON report.