Python argparse Subcommands: Required Operations, Bounded Options, and Dash-Prefixed Filenames
On this page
A script starts with one job, then grows preview, counting, and a pile of optional arguments. Omitting the operation becomes ambiguous, a mistyped abbreviation is accepted, and a filename starting with - looks like an option. Explicit subcommands catch these mistakes before opening the input.
This beginner-to-intermediate tutorial uses standard-library argparse to build a read-only text utility: count counts lines and preview shows the first few. It does not modify inputs, install packages, or edit system configuration.
1. Method: separate operations from arguments
- Require either
countorpreview; bind each operation to a function. - Accept
--linesonly forpreview, with a range of 1–1000 and a default of 5. Reject it forcount. - Disable long-option abbreviations on the main parser and both subparsers, so
--linis not accepted as--lines. - Open files only after parsing succeeds. Return 2 for argument errors, 1 for read/decode failures, and 0 for success. Successful stdout contains one JSON document.
The official subcommands documentation demonstrates add_subparsers() and set_defaults(). Here build_parser() owns the CLI contract; business functions no longer index sys.argv directly.
If your script already reads defaults from a configuration file, combine this with the tomllib task configuration tutorial, taking care not to overwrite explicit configuration values with CLI defaults.
2. Setup: create an isolated demo directory
Use an existing Python 3.9 or newer interpreter; no pip installation is needed. These commands were tested on Linux with Python 3.11.4 and work in bash or zsh.
- Run these commands in one terminal. Pick a fresh directory name when repeating the experiment to avoid overwriting old files:
python3 --version
mkdir text-cli-demo
cd text-cli-demo
python3 - <<'PY_DATA'
from pathlib import Path
Path("sample.txt").write_text("alpha\n你好\nlast", encoding="utf-8")
Path("-sample.txt").write_bytes(Path("sample.txt").read_bytes())
PY_DATA
- Save the next section as
text_tool.pyin the current directory. The demo input has no newline after its final line, which should still count as a line.
3. Implementation: required subcommands, bounds, and dispatch
"""Read a local UTF-8 text file using count or preview. Python 3.9+."""
import argparse
from itertools import islice
import json
from pathlib import Path
import sys
def line_limit(raw):
try:
value = int(raw)
except ValueError as exc:
raise argparse.ArgumentTypeError("lines must be an integer in 1..1000") from exc
if not 1 <= value <= 1000:
raise argparse.ArgumentTypeError("lines must be an integer in 1..1000")
return value
def count_file(args):
with args.path.open(encoding="utf-8") as stream:
total = sum(1 for _ in stream)
return {"command": "count", "path": str(args.path), "lines": total}
def preview_file(args):
with args.path.open(encoding="utf-8") as stream:
lines = [line.removesuffix("\n") for line in islice(stream, args.lines)]
return {
"command": "preview", "path": str(args.path),
"lines": lines, "limit": args.lines,
}
def build_parser():
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
commands = parser.add_subparsers(dest="command", required=True)
count = commands.add_parser("count", help="count text lines", allow_abbrev=False)
count.add_argument("path", type=Path, help="local UTF-8 text file")
count.set_defaults(func=count_file)
preview = commands.add_parser("preview", help="preview first lines", allow_abbrev=False)
preview.add_argument("path", type=Path, help="local UTF-8 text file")
preview.add_argument("--lines", type=line_limit, default=5, help="1..1000 (default: 5)")
preview.set_defaults(func=preview_file)
return parser
def main():
args = build_parser().parse_args()
try:
result = args.func(args)
except (OSError, UnicodeError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
print(json.dumps(result, ensure_ascii=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
type=Path converts a string into a path object; it does not check existence. Business functions open the file, and a successful report is produced only after the with block ends, so read failures do not emit partial JSON.
The type parameter accepts a conversion function. Our function turns invalid integers and out-of-range values into ArgumentTypeError, resulting in argument-error status 2 rather than a file error. The main parser’s settings are not global subparser settings, so each add_parser() explicitly uses allow_abbrev=False.
Files are decoded strictly as UTF-8. Text mode normalizes CRLF, CR, and LF line endings to \n; see the official open() documentation. count counts iterated text lines, not newline bytes. An empty file has zero lines; the last line needs no terminator.
preview uses islice() to limit lines and removes only one trailing newline, preserving blank lines and other whitespace. A line limit is not a byte or memory limit: an enormous line can still consume substantial memory, and decoding may read ahead. Preview is not whole-file encoding validation. Use this example with trusted, ordinary local text files, not devices, pipes, or unbounded sources.
4. Usage: help, success output, and special filenames
- Inspect help, then run both operations:
python3 text_tool.py --help
python3 text_tool.py preview --help
python3 text_tool.py count sample.txt
python3 text_tool.py preview --lines 2 sample.txt
The last two commands should print, respectively:
{"command": "count", "path": "sample.txt", "lines": 3}
{"command": "preview", "path": "sample.txt", "lines": ["alpha", "\u4f60\u597d"], "limit": 2}
ensure_ascii=True represents Chinese text with JSON escapes to avoid terminal-encoding problems. json.loads() restores the original characters; no content is lost.
- For a filename beginning with
-, put--after the subcommand options and before the filename:
python3 text_tool.py preview --lines 2 -- -sample.txt
The reported path should be -sample.txt; lines still contains alpha and 你好. Everything after -- is treated as positional input, so do not put --lines after it. Passing ./-sample.txt also avoids the ambiguity.
- Check both failure classes manually. Run this in an interactive terminal; a script using
set -emay stop at the first failed command:
python3 text_tool.py preview --lines 0 sample.txt
echo "exit=$?"
python3 text_tool.py count missing.txt
echo "exit=$?"
The first pair prints usage and lines must be an integer in 1..1000, then exit=2. The second prints ERROR:, then exit=1. Automation should check status rather than merely checking whether a report file exists. If scheduled jobs see different paths, use the cron Python path and working-directory guide to fix the interpreter and working directory.
5. Validation: assert successful and failing branches
Save the following as test_text_tool.py beside text_tool.py. It creates an isolated temporary directory beneath the current directory and removes it automatically without overwriting demo inputs:
"""Run beside text_tool.py; only temporary fixture files are created."""
import json
from pathlib import Path
import subprocess
import sys
import tempfile
script = Path("text_tool.py").resolve()
def run(folder, *args):
return subprocess.run(
[sys.executable, str(script), *args], cwd=folder,
capture_output=True, text=True, check=False,
)
with tempfile.TemporaryDirectory(prefix="cli-test-", dir=".") as name:
folder = Path(name).resolve()
sample = folder / "sample.txt"
sample.write_bytes("alpha\n你好\nlast".encode("utf-8"))
(folder / "-sample.txt").write_bytes(sample.read_bytes())
(folder / "empty.txt").write_bytes(b"")
(folder / "crlf.txt").write_bytes(b"one\r\ntwo\r\n")
(folder / "bad.txt").write_bytes(b"\xff")
before = {p.name: p.read_bytes() for p in folder.iterdir()}
for filename, expected in [("sample.txt", 3), ("empty.txt", 0), ("crlf.txt", 2)]:
result = run(folder, "count", filename)
assert result.returncode == 0 and result.stderr == "", result
assert json.loads(result.stdout)["lines"] == expected
result = run(folder, "preview", "--lines", "2", "--", "-sample.txt")
assert result.returncode == 0 and result.stderr == "", result
assert json.loads(result.stdout) == {
"command": "preview", "path": "-sample.txt",
"lines": ["alpha", "你好"], "limit": 2,
}
result = run(folder, "preview", "sample.txt")
assert result.returncode == 0 and json.loads(result.stdout)["limit"] == 5
for args in [[], ["unknown"], ["count"], ["count", "--lines", "2", "sample.txt"],
["preview", "--lin", "2", "sample.txt"],
*[["preview", "--lines", value, "sample.txt"]
for value in ["0", "-1", "1001", "abc"]]]:
result = run(folder, *args)
assert result.returncode == 2 and result.stdout == "" and result.stderr, result
for filename in ["missing.txt", "bad.txt"]:
result = run(folder, "count", filename)
assert result.returncode == 1 and result.stdout == "", result
assert result.stderr.startswith("ERROR:"), result
for args in [["--help"], ["preview", "--help"]]:
result = run(folder, *args)
assert result.returncode == 0 and "usage:" in result.stdout and not result.stderr
assert before == {p.name: p.read_bytes() for p in folder.iterdir()}
print("PASS: subcommands, limits, --, UTF-8, newlines, errors, help, unchanged inputs")
Run:
python3 test_text_tool.py
Expected output:
PASS: subcommands, limits, --, UTF-8, newlines, errors, help, unchanged inputs
The checks cover Chinese text, an unterminated final line, empty files, CRLF, help, required subcommands, bounds, rejected abbreviations, error statuses, and unchanged input bytes. They are not a benchmark and do not simulate permission changes or concurrent input modification.
6. Troubleshooting
| Symptom | Concrete fix |
|---|---|
Missing command error |
Add count or preview after the script name; do not remove required=True to hide missing input. |
count rejects --lines |
Use preview --lines 2 sample.txt; each operation accepts only its own options. |
--lin fails |
Spell out --lines and keep abbreviations disabled. |
-sample.txt looks like an option |
Use preview --lines 2 -- -sample.txt, or pass ./-sample.txt. |
A decoding error appears after ERROR: |
Determine the source encoding and convert to UTF-8; do not silently discard characters with errors="ignore". |
| You wanted the end of a log | This utility previews the start; use the deque log-tail approach. |
7. Summary
Use subcommands to express operations, conversion functions to enforce types and bounds, and separate functions for business logic. Validate arguments before reading files, and distinguish argument failures from runtime failures through exit statuses. Add a dedicated subparser and tests for each new operation instead of accumulating ambiguous switches.
- Author: yanzhi
- Original article: https://www.bobobk.com/en/python-argparse-subcommands-validation.html
- License: This work is licensed under CC BY-NC-ND 4.0. See the license for attribution, noncommercial use and no-derivatives terms.
Related articles
- Check Executable Paths with Python shutil.which Before Automation Runs
- Fix Python BrokenPipeError with head and Verify Pipeline Exit Codes
- Fix Linux Argument list too long with find and Python
- Fix Python SQLite Foreign Keys: Connection Checks, Batch Rollback, and Orphan Audits
- Diagnose CSV Delimiters with Python: Sniffer, Explicit Formats, and Full Validation