Bobobk

Practical Python, Linux and automation guides

Python argparse Subcommands: Required Operations, Bounded Options, and Dash-Prefixed Filenames

2026-11-01 Technology
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

  1. Require either count or preview; bind each operation to a function.
  2. Accept --lines only for preview, with a range of 1–1000 and a default of 5. Reject it for count.
  3. Disable long-option abbreviations on the main parser and both subparsers, so --lin is not accepted as --lines.
  4. 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.

  1. 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
  1. Save the next section as text_tool.py in 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

  1. 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.

  1. 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.

  1. Check both failure classes manually. Run this in an interactive terminal; a script using set -e may 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.

Tags

1024 2fa ablang adsense agents.md ai ai-agent ai-agents ai-seo algorithm amp antibodies apparmor argparse automation background backup bash batch-processing binarycif bioinformatics biopython blockchain boltz bootstrapping boxes bubblewrap c-index cca cdn checkpoint cli cloudflare codex cofoldarena concurrency configparser configuration copy cprofile cpu monitoring cron csv cuda curl data-leakage data-processing data-quality data-validation datascience datavisualization datetime debugging decimal deployment desktop-app devtools difflib disk-space disown dns docker dovecot download economics electron encoding esm esm2 esm3 esmc esmfold2 etag evaluation fable faceswap fasta fastmcp faulthandler ffmpeg file-descriptors file-io file-management filecmp filenames files flashppi flask folium frontend game generator git github-actions google google-research gpt-6.1-sol grep gzip harness heapq hls html http hugo imports indexnow ipaddress jailbreak javascript jev json jsonl just k-means kaggle langfuse leecode linux list litellm llm llms.txt logging logs lollipop lsof m3u8 machine-learning macos manacher matplotlib mcp memory mirror mmcif model-evaluation monitoring mp3 mp4 mpnn multiomics multiprocessing mutation mysql nanobert nanobodies networking networkx nginx normalize numpy ollama omegatherm pandas password pathlib pdb pep-723 performance permissions phaser pillow pip postfix preprocessing print probability prompt-engineering protein-alignment protein-design protein-embeddings protein-interactions protein-language-models protein-stability protein-structure proxy pydantic pyecharts pyqt python python3 r raincloud reproducibility requests reservoir-sampling rfoptimization rg ripgrep rosettafold3 roundcube rrsi rsi rsync s-tui sampling scale scikit-learn scrapy screen seaborn security seo sequence-identity sha256 shell signals sitemap sklearn socket solana somaticsignatures sorting spl sql sqlite ssh standardize static-site subprocess symlink sysadmin system-one tarfile tcp tempfile tensorflow tkinter tls toml totp tracemalloc trade tron tronpy troubleshooting turtle typesafe-ai ubuntu unicode upsert urllib usdt utf-8 uv venv vhhbert vite wallet web-tools webp wordcloud wordpress workflow xml yaml zipfile zoneinfo 迅雷解析

友情链接