Скрипты и CLI: argparse, subprocess
Точка входа
Скрипт — обычный модуль. Код, который должен выполняться только при прямом запуске, прячут под проверку имени модуля:
# 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
Парсер аргументов. Напишите 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} должны вызывать ошибку")
import argparse
def build_parser():
parser = argparse.ArgumentParser(prog="resize", description="Уменьшает картинки")
parser.add_argument("files", nargs="+", help="файлы для обработки")
parser.add_argument("-w", "--width", type=int, required=True, help="ширина в пикселях")
parser.add_argument("--format", choices=["jpg", "png", "webp"], default="jpg")
parser.add_argument("--dry-run", action="store_true", help="только показать, что будет сделано")
parser.add_argument("-v", "--verbose", action="count", default=0)
return parser
Утилита как функция. Напишите 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:")
def main(argv, stdin, stdout, stderr):
ignore_case = "-i" in argv
rest = [a for a in argv if a != "-i"]
if not rest:
stderr.write("usage: grep [-i] PATTERN\n")
return 2
pattern = rest[0].lower() if ignore_case else rest[0]
found = False
for line in stdin:
haystack = line.lower() if ignore_case else line
if pattern in haystack:
stdout.write(line)
found = True
return 0 if found else 1
Почему subprocess.run(f"convert {filename} out.png", shell=True) опасно?
Какой код возврата вернёт программа?
| 0 | 1 | 2 | |
|---|---|---|---|
sys.exit() без аргументов |
|||
sys.exit("ошибка: нет файла") |
|||
| Необработанное исключение | |||
| argparse не смог разобрать аргументы |
Войдите, чтобы отмечать пройденные уроки.