Скрипты и CLI: argparse, subprocess

⏱ 40 мин · 🧩 заданий: 4

Точка входа

Скрипт — обычный модуль. Код, который должен выполняться только при прямом запуске, прячут под проверку имени модуля:

# tool.py
import sys

def main(argv: list[str]) -> int:
    print(f"аргументы: {argv}")
    return 0

if __name__ == "__main__":        # True при `python tool.py`, False при `import tool`
    sys.exit(main(sys.argv[1:]))

Функция main(argv) -> int с явным списком аргументов — хорошая привычка: её можно вызвать из тестов, не трогая sys.argv.

sys.argv и argparse

sys.argv — список строк: ['tool.py', 'a.txt', '--force']. Разбирать его вручную утомительно, поэтому есть argparse: типы, значения по умолчанию, проверки и автоматическая справка --help.

import argparse

parser = argparse.ArgumentParser(prog="backup", description="Резервное копирование папки")
parser.add_argument("source", help="что копировать")
parser.add_argument("dest", nargs="?", default="backup/", help="куда (по умолчанию backup/)")
parser.add_argument("-n", "--keep", type=int, default=7, help="сколько копий хранить")
parser.add_argument("--compress", choices=["none", "gzip", "zstd"], default="gzip")
parser.add_argument("-f", "--force", action="store_true")
parser.add_argument("-v", "--verbose", action="count", default=0)
parser.add_argument("--exclude", action="append", default=[], help="можно указать несколько раз")

args = parser.parse_args(["photos", "-n", "3", "--exclude", "*.tmp", "--exclude", "cache", "-vv"])
print(args)
print(args.keep * 2, args.exclude)
print()
parser.print_help()

Дефис превращается в подчёркивание

--dry-run доступен как args.dry_run. Короткие флаги можно склеивать: -vf = -v -f.

При ошибке argparse печатает сообщение в stderr и завершает программу кодом 2:

import argparse, contextlib, io

parser = argparse.ArgumentParser(prog="backup")
parser.add_argument("--keep", type=int)

err = io.StringIO()
try:
    with contextlib.redirect_stderr(err):
        parser.parse_args(["--keep", "много"])
except SystemExit as e:
    print("код выхода:", e.code)
print(err.getvalue())

Подкоманды

Как у git commit / git push: отдельный набор аргументов для каждой команды.

import argparse

def cmd_add(args):
    print(f"добавляю задачу {args.title!r} с приоритетом {args.priority}")

def cmd_list(args):
    print(f"показываю задачи, только открытые: {args.open}")

parser = argparse.ArgumentParser(prog="todo")
sub = parser.add_subparsers(dest="command", required=True)

p_add = sub.add_parser("add", help="добавить задачу")
p_add.add_argument("title")
p_add.add_argument("-p", "--priority", type=int, default=3)
p_add.set_defaults(func=cmd_add)

p_list = sub.add_parser("list", help="список задач")
p_list.add_argument("--open", action="store_true")
p_list.set_defaults(func=cmd_list)

for argv in (["add", "Купить кофе", "-p", "1"], ["list", "--open"]):
    args = parser.parse_args(argv)
    args.func(args)

Коды возврата и потоки

Каждая программа возвращает код: 0 — успех, всё остальное — ошибка. На этом построены && в shell, CI и cron.

  • sys.exit(0) / sys.exit() — успех; sys.exit(1) — ошибка; sys.exit("текст") печатает текст в stderr и возвращает 1.
  • Необработанное исключение — код 1.
  • stdout — для результата (его перенаправляют в файл или следующую программу), stderr — для ошибок и диагностики.
import sys

print("результат", file=sys.stdout)
print("предупреждение: файл пуст", file=sys.stderr)

Утилита, которая читает stdin и пишет в stdout, встраивается в конвейеры:

cat access.log | python top_ips.py --limit 5 | tee report.txt
python export.py > data.csv 2> errors.log
python check.py && echo "всё хорошо" || echo "код возврата: $?"
# top_ips.py
import sys
from collections import Counter

counts = Counter(line.split()[0] for line in sys.stdin if line.strip())
for ip, n in counts.most_common(5):
    print(f"{n}\t{ip}")

Тестируемый ввод-вывод

Передавайте потоки параметрами (main(argv, stdin=sys.stdin, stdout=sys.stdout)), тогда в тестах можно подставить io.StringIO — как в задании к уроку.

Файлы: pathlib и shutil

from pathlib import Path
import shutil

root = Path(__file__).resolve().parent         # папка скрипта, а не текущая директория!
out = root / "reports"
out.mkdir(parents=True, exist_ok=True)

for path in Path("logs").rglob("*.log"):       # рекурсивно
    if path.stat().st_size > 10_000_000:
        shutil.move(path, out / path.name)

shutil.copytree("static", "build/static", dirs_exist_ok=True)
shutil.make_archive("backup", "zip", "data")   # backup.zip
shutil.rmtree("build")                         # осторожно: удаляет без корзины
print(shutil.which("git"))                     # путь к программе или None

Внешние программы: subprocess

import subprocess

result = subprocess.run(
    ["git", "log", "--oneline", "-5"],     # список аргументов — без оболочки
    capture_output=True,                   # собрать stdout и stderr
    text=True,                             # строки, а не bytes
    check=True,                            # ненулевой код → CalledProcessError
    timeout=30,                            # зависла → TimeoutExpired
    cwd="/path/to/repo",
)
print(result.returncode, result.stdout)

try:
    subprocess.run(["pytest", "-q"], check=True)
except subprocess.CalledProcessError as e:
    print("тесты упали, код", e.returncode)
except FileNotFoundError:
    print("pytest не установлен")

shell=True и инъекции

subprocess.run(f"rm {name}", shell=True) при name = "x; rm -rf ~" выполнит обе команды. Передавайте список аргументов. Если без оболочки никак, экранируйте значения через shlex.quote.

subprocess.Popen нужен, когда процесс должен работать параллельно с вашим кодом или вы читаете его вывод построчно по мере появления.

Переменные окружения

import os

token = os.environ["API_TOKEN"]               # KeyError, если нет — хорошо для обязательных
debug = os.getenv("DEBUG", "0") == "1"        # с значением по умолчанию
env = {**os.environ, "LANG": "C"}
subprocess.run(["sort", "data.txt"], env=env)

Подробнее о конфигурации через окружение — в уроке про конфигурацию приложения.

click и typer

Для больших утилит удобнее библиотеки: click (декораторы) и typer (аргументы из аннотаций типов).

import typer

app = typer.Typer()

@app.command()
def resize(files: list[str], width: int = typer.Option(..., "-w"), dry_run: bool = False):
    """Уменьшает картинки."""
    for f in files:
        typer.echo(f"{f} → {width}px" + (" (dry run)" if dry_run else ""))

if __name__ == "__main__":
    app()

Установка утилиты командой

В pyproject.toml объявляют точку входа — после pip install . появится команда resize:

[project.scripts]
resize = "imagetools.cli:main"

Ещё один способ запуска — python -m package: он выполняет package/__main__.py. Так работают python -m pip, python -m venv, python -m http.server.

Итог

  • main(argv) -> int + if __name__ == "__main__": sys.exit(main(sys.argv[1:])).
  • argparse: позиционные, --опции с type/choices/default, флаги store_true, count, подкоманды.
  • Результат в stdout, ошибки в stderr, код возврата 0 — успех.
  • subprocess.run([...], check=True, capture_output=True, text=True, timeout=...), без shell=True.
  • pathlib и shutil вместо ручной склейки путей и os.system.

Практика решено 0 из 4

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Программа на Python

Парсер аргументов. Напишите build_parser(), который возвращает argparse.ArgumentParser для утилиты resize:

  • позиционный files — один или больше путей (nargs="+");
  • --width / -w — целое число, обязательно;
  • --format — одно из jpg, png, webp, по умолчанию jpg;
  • --dry-run — флаг (по умолчанию False);
  • --verbose / -v — счётчик (-vv → 2), по умолчанию 0.
Какие тесты проверяют код (3)
args = build_parser().parse_args(["a.png", "b.png", "-w", "800"])
assert args.files == ["a.png", "b.png"] and args.width == 800 and args.format == "jpg"
assert args.dry_run is False and args.verbose == 0

args = build_parser().parse_args(["--width=300", "--format", "webp", "--dry-run", "-vv", "x.jpg"])
assert (args.width, args.format, args.dry_run, args.verbose, args.files) == (300, "webp", True, 2, ["x.jpg"])

import contextlib, io
for bad in (["a.png"], ["-w", "10"], ["a.png", "-w", "ten"], ["a.png", "-w", "1", "--format", "gif"]):
    try:
        with contextlib.redirect_stderr(io.StringIO()):
            build_parser().parse_args(bad)
    except SystemExit as e:
        assert e.code == 2
    else:
        raise AssertionError(f"Аргументы {bad} должны вызывать ошибку")

Задание 2 Программа на Python

Утилита как функция. Напишите main(argv, stdin, stdout, stderr) — упрощённый grep:

  • argv — список аргументов: шаблон (подстрока) и необязательный флаг -i (без учёта регистра) в любом месте;
  • читает строки из stdin, печатает в stdout подходящие (без изменений);
  • возвращает код: 0 — что-то нашлось, 1 — ничего, 2 — не передан шаблон (тогда пишет в stderr сообщение, начинающееся с usage:).

Такую функцию легко тестировать: в тестах вместо настоящих потоков — io.StringIO.

Какие тесты проверяют код (3)
import io
out, err = io.StringIO(), io.StringIO()
code = main(["err"], io.StringIO("ok\nerror: disk\nERROR: net\n"), out, err)
assert code == 0 and out.getvalue() == "error: disk\n"

import io
out = io.StringIO()
assert main(["-i", "err"], io.StringIO("ok\nerror: disk\nERROR: net\n"), out, io.StringIO()) == 0
assert out.getvalue() == "error: disk\nERROR: net\n"
assert main(["zzz"], io.StringIO("a\nb\n"), io.StringIO(), io.StringIO()) == 1

import io
err = io.StringIO()
assert main([], io.StringIO("a\n"), io.StringIO(), err) == 2 and err.getvalue().startswith("usage:")

Задание 3 Один вариант ответа

Почему subprocess.run(f"convert {filename} out.png", shell=True) опасно?

Задание 4 Заполнить таблицу

Какой код возврата вернёт программа?

012
sys.exit() без аргументов
sys.exit("ошибка: нет файла")
Необработанное исключение
argparse не смог разобрать аргументы
Отметьте ячейку — «+», пустая — «−».

Войдите, чтобы отмечать пройденные уроки.