CLI-проект на Python: пакет, argparse, JSON и logging

Небольшой CLI становится поддерживаемым проектом, когда разбор аргументов, бизнес-логика, хранение данных и точка входа разделены. argparse проверяет интерфейс командной строки, json задаёт простой переносимый формат, logging сообщает о ходе работы, а пакетная структура делает модули импортируемыми и тестируемыми. Важно различать пользовательский результат в стандартном выводе и диагностические сообщения, которые журналирование по умолчанию направляет в стандартный поток ошибок.

Структура пакета и точка входа

Минимальная раскладка может содержать pyproject.toml, каталог src/taskcli, файлы __init__.py, __main__.py и cli.py. Запуск python -m taskcli выполняет taskcli/__main__.py. Для установленного проекта в pyproject.toml можно объявить консольный скрипт, связывающий имя команды с функцией, но конкретные метаданные и backend сборки должны соответствовать выбранному инструменту упаковки.

task-project/
├── pyproject.toml
└── src/
    └── taskcli/
        ├── __init__.py
        ├── __main__.py
        └── cli.py

Разбор аргументов отдельно от выполнения

Функцию build_parser удобно тестировать независимо, передавая parse_args явный список. Функция main(argv=None) возвращает код завершения, а небольшой __main__.py передаёт его в SystemExit. Так импорт модуля не запускает команду самопроизвольно.

# src/taskcli/cli.py
import argparse
import json
import logging
from pathlib import Path
from typing import Sequence

logger = logging.getLogger(__name__)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog='taskcli')
    parser.add_argument('file', type=Path)
    parser.add_argument('--title', required=True)
    parser.add_argument('--verbose', action='store_true')
    return parser


def main(argv: Sequence[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    logging.basicConfig(
        level=logging.DEBUG if args.verbose else logging.WARNING,
        format='%(levelname)s: %(message)s',
    )
    tasks = []
    if args.file.exists():
        tasks = json.loads(args.file.read_text(encoding='utf-8'))
    tasks.append({'title': args.title, 'done': False})
    args.file.write_text(
        json.dumps(tasks, ensure_ascii=False, indent=2) + '\n',
        encoding='utf-8',
    )
    logger.debug('Сохранено задач: %d', len(tasks))
    print(f'Добавлена задача: {args.title}')
    return 0

Файл src/taskcli/__main__.py содержит:

from .cli import main

raise SystemExit(main())

Ожидаемый запуск и данные

После установки пакета в окружение команда может выглядеть так:

python -m taskcli tasks.json --title "Проверить отчёт" --verbose

Стандартный вывод содержит:

Добавлена задача: Проверить отчёт

Журнал дополнительно покажет DEBUG: Сохранено задач: 1, а tasks.json получит массив с объектом, где done равен false. JSON поддерживает строки, числа, логические значения, null, массивы и объекты; произвольный экземпляр класса без преобразования сериализовать нельзя.

Надёжная работа с JSON и журналом

ensure_ascii=False сохраняет читаемый русский текст, а indent=2 форматирует файл. При повреждённом JSON возникает json.JSONDecodeError; CLI должен сообщить понятную ошибку и вернуть ненулевой код, а не затирать исходные данные пустым списком. Для особенно важных файлов применяют запись во временный файл и атомарную замену, но это отдельное улучшение.

Логгер модуля получают через getLogger(__name__), а общую конфигурацию выполняют в точке входа один раз. Аргументы журналирования передают отдельно, как в logger.debug('... %d', count): форматирование тогда откладывается до фактической записи сообщения.

Как разбирать сбои CLI по слоям

Если запуск выдаёт No module named taskcli, проверьте, установлен ли проект в активное виртуальное окружение и действительно ли пакет расположен под src/taskcli. Выведите путь интерпретатора и не пытайтесь лечить проблему случайным изменением sys.path.

Если json.loads сообщает JSONDecodeError, в ошибке есть номер строки и столбца. Откройте указанный файл, проверьте двойные кавычки, запятые и отсутствие постороннего текста. Отдельно обработайте OSError для проблем чтения и записи: это другой тип сбоя. argparse сам печатает usage и завершает разбор с кодом 2, если пропущен обязательный --title.

Практикум: подкоманды add и list

Расширьте команду подкомандами add и list. Вынесите загрузку и сохранение в функции, а main оставьте координатором. Для list выведите нумерованные задачи, для add не изменяйте файл при ошибке декодирования. Самостоятельная проверка: вызов без обязательных аргументов даёт справку и ненулевой код; две команды add сохраняют две записи; новый процесс list читает их из JSON; без --verbose отладочный журнал не виден; импорт taskcli.cli ничего не запускает.

Контракты поддерживаемой CLI-команды

CLI — это несколько контрактов: аргументы, код завершения, стандартный вывод, журнал и формат файла. Разделение функций делает каждый контракт проверяемым. Точка входа запускает main, argparse валидирует ввод, JSON хранит только поддерживаемые данные, а logging отделяет диагностику от результата команды.

Вопросы о тестировании и структуре CLI

Зачем main принимает argv?

Явный список позволяет тестировать разные команды без запуска дочернего процесса. При None argparse использует настоящие аргументы процесса.

Можно ли писать все сообщения через print?

Можно для простого результата, но уровни и направление диагностических сообщений тогда придётся реализовывать самостоятельно. Logging уже предоставляет уровни и обработчики.

Обязательно ли использовать src-layout?

Нет, но каталог src помогает не спутать импорт установленного пакета с импортом прямо из корня репозитория. Это полезная, а не единственно допустимая структура.

Источники