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.

Python 3.13

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 is True, absent is False.
  • 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 8000 gives you the string "8000".
  • choices restricts 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, choices and subcommands.
  • Structure the entry point as main() -> int and call sys.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.