Как написать свой первый CLI-инструмент на Python с Typer: от идеи до публикации в PyPI
Создаём полноценный CLI-инструмент с командами, флагами, автодополнением и красивым выводом через Rich. Разбираем структуру проекта и публикуем пакет в PyPI шаг за шагом.
Содержание
Как написать свой первый CLI-инструмент на Python с Typer: от идеи до публикации в PyPI
Командная строка — это не артефакт прошлого. Для разработчика хорошо спроектированный CLI-инструмент зачастую удобнее любого GUI: он компонуется с другими утилитами, встраивается в скрипты и CI/CD-пайплайны, не требует браузера и работает по SSH. Неудивительно, что такие инструменты, как git, docker, kubectl или ruff, давно стали стандартом рабочего окружения.
Python исторически был хорошим языком для написания CLI — argparse есть в стандартной библиотеке, click долгое время был де-факто стандартом. Но в последние годы на первый план вышел Typer — библиотека, построенная поверх Click и использующая аннотации типов Python для описания интерфейса. Результат: минимум бойлерплейта, автоматическая документация, встроенная валидация и отличная интеграция с Rich для красивого вывода.
В этой статье мы пройдём полный путь: от пустой папки до опубликованного пакета на PyPI. По ходу разберём структуру проекта, типичные ошибки и нюансы, которые обычно не попадают в официальную документацию.
Что мы будем строить
Для демонстрации создадим утилиту projector — инструмент для быстрой инициализации структуры Python-проекта. Он умеет:
- создавать директории и файлы по шаблону;
- принимать флаги (например,
--with-tests,--license); - выводить красивый прогресс и итоговую структуру через Rich;
- поддерживать автодополнение в bash/zsh.
Это достаточно реалистичный пример, чтобы показать все ключевые концепции.
Структура проекта
Прежде чем писать код, важно правильно организовать файловую систему. Многие начинают с одного файла cli.py — и это нормально для прототипа, но не для публикуемого пакета.
Вот структура, которую мы будем использовать:
projector/
├── src/
│ └── projector/
│ ├── __init__.py
│ ├── cli.py # точка входа, определение команд
│ ├── commands/
│ │ ├── __init__.py
│ │ ├── init.py # команда init
│ │ └── info.py # команда info
│ └── utils/
│ ├── __init__.py
│ └── fs.py # работа с файловой системой
├── tests/
│ └── test_cli.py
├── pyproject.toml
├── README.md
└── .gitignore
Обратите внимание на src/-layout. Это не просто вкусовщина: такая структура предотвращает случайный импорт пакета из корня проекта во время разработки и заставляет вас работать с установленной версией. Это важно, когда вы тестируете публикацию.
Настройка окружения и зависимостей
Создаём виртуальное окружение и устанавливаем нужные пакеты:
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install typer[all] rich
pip install build twine # для публикации
typer[all] устанавливает Typer вместе с Rich и shellingham — последний нужен для автоматического определения оболочки при установке автодополнения.
pyproject.toml: современный способ описания пакета
Файл setup.py ушёл в прошлое. Современный стандарт — pyproject.toml. Вот минимальная конфигурация для нашего проекта:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "projector-cli"
version = "0.1.0"
description = "A CLI tool for scaffolding Python projects"
readme = "README.md"
requires-python = ">=3.9"
license = { text = "MIT" }
authors = [
{ name = "Your Name", email = "you@example.com" }
]
dependencies = [
"typer[all]>=0.9.0",
"rich>=13.0.0",
]
[project.scripts]
projector = "projector.cli:app"
[tool.hatch.build.targets.wheel]
packages = ["src/projector"]
Ключевая секция — [project.scripts]. Она говорит pip: «при установке пакета создай исполняемый файл projector, который вызывает функцию app из модуля projector.cli». Именно так работают все консольные команды, которые вы устанавливаете через pip.
Пишем приложение
Точка входа: cli.py
# src/projector/cli.py
import typer
from projector.commands import init, info
app = typer.Typer(
name="projector",
help="Scaffold Python projects with a single command.",
add_completion=True,
)
app.add_typer(init.app, name="init")
app.add_typer(info.app, name="info")
def main():
app()
if __name__ == "__main__":
main()
Здесь Typer() создаёт корневое приложение. add_typer позволяет подключать вложенные группы команд — это аналог click.Group. Параметр add_completion=True автоматически добавляет команды --install-completion и --show-completion в ваш CLI.
Команда init
# src/projector/commands/init.py
from pathlib import Path
from typing import Optional
import typer
from rich.console import Console
from rich.tree import Tree
from rich import print as rprint
app = typer.Typer(help="Initialize a new project structure.")
console = Console()
DEFAULT_DIRS = ["src", "tests", "docs"]
DEFAULT_FILES = {
"README.md": "# {name}\n\nProject description here.\n",
"pyproject.toml": '[project]\nname = "{name}"\nversion = "0.1.0"\n',
".gitignore": "__pycache__/\n.venv/\ndist/\n*.egg-info/\n",
}
@app.command()
def new(
name: str = typer.Argument(..., help="Name of the project to create"),
path: Path = typer.Option(
Path("."),
"--path", "-p",
help="Directory where the project will be created",
exists=True,
file_okay=False,
),
with_tests: bool = typer.Option(True, help="Include tests/ directory"),
license: Optional[str] = typer.Option(
None,
"--license", "-l",
help="License type: mit, apache, gpl",
),
):
"""Create a new Python project scaffold."""
project_root = path / name
if project_root.exists():
rprint(f"[red]Error:[/red] Directory '{project_root}' already exists.")
raise typer.Exit(code=1)
with console.status(f"[bold green]Creating project '{name}'..."):
project_root.mkdir(parents=True)
dirs_to_create = DEFAULT_DIRS.copy()
if not with_tests and "tests" in dirs_to_create:
dirs_to_create.remove("tests")
for d in dirs_to_create:
(project_root / d).mkdir()
for filename, template in DEFAULT_FILES.items():
(project_root / filename).write_text(template.format(name=name))
if license:
_write_license(project_root, license, name)
_print_tree(project_root, name)
rprint(f"\n[bold green]✓[/bold green] Project [cyan]{name}[/cyan] created successfully.")
def _write_license(root: Path, license_type: str, project_name: str):
licenses = {
"mit": f"MIT License\n\nCopyright (c) 2024 {project_name}\n",
"apache": f"Apache License 2.0\n\nCopyright (c) 2024 {project_name}\n",
"gpl": f"GNU General Public License v3.0\n",
}
content = licenses.get(license_type.lower())
if content:
(root / "LICENSE").write_text(content)
else:
rprint(f"[yellow]Warning:[/yellow] Unknown license '{license_type}', skipping.")
def _print_tree(root: Path, name: str):
tree = Tree(f"[bold]{name}/[/bold]")
for item in sorted(root.iterdir()):
if item.is_dir():
tree.add(f"[blue]{item.name}/[/blue]")
else:
tree.add(item.name)
console.print(tree)
Разберём ключевые моменты:
typer.Argument vs typer.Option — принципиальное различие. Argument — позиционный параметр (обязательный по умолчанию), Option — именованный флаг (--name value). Это не просто синтаксис: они формируют UX вашего CLI.
exists=True, file_okay=False в определении path — Typer автоматически валидирует, что переданный путь существует и является директорией. Если нет — пользователь получит понятное сообщение об ошибке ещё до выполнения вашего кода.
raise typer.Exit(code=1) — правильный способ завершить команду с ненулевым кодом выхода. Не используйте sys.exit() напрямую — это нарушает тестируемость.
Команда info
# src/projector/commands/info.py
import typer
from rich.console import Console
from rich.table import Table
app = typer.Typer(help="Show information about projector.")
console = Console()
@app.command()
def version():
"""Show the current version."""
from projector import __version__
console.print(f"projector version: [bold cyan]{__version__}[/bold cyan]")
@app.command()
def templates():
"""List available project templates."""
table = Table(title="Available Templates")
table.add_column("Name", style="cyan", no_wrap=True)
table.add_column("Description", style="white")
table.add_column("Includes", style="green")
table.add_row("default", "Basic Python project", "src/, tests/, README.md")
table.add_row("library", "Reusable library scaffold", "src/, tests/, docs/")
table.add_row("cli", "CLI application template", "src/, tests/, cli entry point")
console.print(table)
Rich делает вывод таблиц тривиальным. Обратите внимание: Table, Tree, Console.status — это не просто красота, это читаемость. Пользователь за секунду понимает структуру вывода.
Автодополнение: как это работает
Typer (через shellingham и click) поддерживает автодополнение для bash, zsh, fish и PowerShell. После установки пакета пользователь запускает:
projector --install-completion
Это автоматически определяет текущую оболочку и добавляет нужную строку в .bashrc или .zshrc. После перезапуска терминала работает Tab-автодополнение для команд и опций.
Если хотите кастомизировать список значений для автодополнения конкретной опции — используйте typer.Completion или передавайте Enum как тип:
from enum import Enum
class LicenseType(str, Enum):
mit = "mit"
apache = "apache"
gpl = "gpl"
# В сигнатуре команды:
license: Optional[LicenseType] = typer.Option(None, "--license", "-l")
Теперь при наборе projector init new myproject --license и нажатии Tab пользователь увидит mit, apache, gpl.
Тестирование CLI
Typer предоставляет CliRunner (унаследованный от Click) для тестирования команд без запуска реального процесса:
# tests/test_cli.py
from typer.testing import CliRunner
from projector.cli import app
import tempfile
from pathlib import Path
runner = CliRunner()
def test_init_creates_project():
with tempfile.TemporaryDirectory() as tmpdir:
result = runner.invoke(app, ["init", "new", "myproject", "--path", tmpdir])
assert result.exit_code == 0
assert "myproject" in result.output
assert (Path(tmpdir) / "myproject" / "README.md").exists()
def test_init_fails_if_exists():
with tempfile.TemporaryDirectory() as tmpdir:
Path(tmpdir, "existing").mkdir()
result = runner.invoke(app, ["init", "new", "existing", "--path", tmpdir])
assert result.exit_code == 1
assert "already exists" in result.output
def test_info_version():
result = runner.invoke(app, ["info", "version"])
assert result.exit_code == 0
Публикация в PyPI
Это тот этап, где многие останавливаются — кажется сложным. На самом деле процесс линейный.
Шаг 1: Регистрация на PyPI
Создайте аккаунт на pypi.org. Для тестирования используйте test.pypi.org — отдельный реестр, где можно экспериментировать без риска засорить основной.
Создайте API-токен в настройках аккаунта и сохраните его — он понадобится при загрузке.
Шаг 2: Сборка пакета
python -m build
Эта команда создаст папку dist/ с двумя файлами:
projector_cli-0.1.0.tar.gz— source distributionprojector_cli-0.1.0-py3-none-any.whl— wheel (бинарный дистрибутив)
Всегда публикуйте оба. Wheel устанавливается быстрее, source distribution — запасной вариант.
Шаг 3: Проверка перед публикацией
twine check dist/*
Это проверит корректность метаданных и README. Частая ошибка — README в формате, который PyPI не может отрендерить (например, RST с нестандартными директивами).
Шаг 4: Публикация на Test PyPI
twine upload --repository testpypi dist/*
Проверьте, что пакет корректно устанавливается:
pip install --index-url https://test.pypi.org/simple/ projector-cli
projector --help
Шаг 5: Публикация на основной PyPI
twine upload dist/*
После этого любой может установить ваш инструмент:
pip install projector-cli
Версионирование и обновления
Придерживайтесь Semantic Versioning: MAJOR.MINOR.PATCH. Перед каждой публикацией обновляйте версию в pyproject.toml. Автоматизировать это можно с помощью bump2version или hatch version patch.
Типичные ошибки и подводные камни
Конфликт имён на PyPI. Прежде чем называть пакет, проверьте, не занято ли имя. Наш projector — гипотетический пример, в реальности такое имя может быть занято. Используйте pip search или просто откройте pypi.org.
Забытые файлы в дистрибутиве. Если в пакете не оказалось каких-то файлов (например, шаблонов), проверьте секцию [tool.hatch.build] или добавьте MANIFEST.in.
Зависимости без верхней границы версий. Указывать typer>=0.9.0 без верхней границы — нормально для инструментов, но рискованно для библиотек. Для CLI-утилит это обычно приемлемо.
Отсутствие __version__ в пакете. Добавьте в src/projector/__init__.py:
__version__ = "0.1.0"
И синхронизируйте это значение с pyproject.toml. Можно автоматизировать через hatchling с dynamic versioning.
Не тестировать установку из dist. Многие публикуют пакет и обнаруживают, что он не работает — потому что тестировали только из исходников. Всегда проверяйте установку из dist/ в чистом окружении.
Куда двигаться дальше
Мы рассмотрели базовый, но полноценный цикл разработки CLI-инструмента. За рамками статьи остались более продвинутые темы: конфигурационные файлы (интеграция с ~/.config), интерактивные промпты (typer.prompt, typer.confirm), прогресс-бары для длительных операций, плагинная архитектура через entry points, а также автоматизация публикации через GitHub Actions.
Если вы хотите разобраться с Typer системно — от базовых концепций до продвинутых паттернов — стоит обратить внимание на курс «Typer PRO»: там эти темы разобраны последовательно, с практическими заданиями и реальными проектами.
Итог
CLI-инструмент на Python с Typer — это не сложно, если понимать структуру. Ключевые вещи, которые стоит запомнить:
- Используйте
src/-layout с самого начала — это избавит от проблем при публикации. - Разделяйте команды по модулям:
app.add_typer()делает это естественным. - Аннотации типов — это не просто документация, это валидация и автодополнение.
- Rich превращает скучный вывод в читаемый интерфейс без лишних усилий.
- Публикация в PyPI линейна:
build→check→upload.
Хороший CLI — это инструмент, который не мешает думать. Typer позволяет сосредоточиться на логике, а не на разборе аргументов. Попробуйте взять любую рутинную задачу из своего рабочего процесса и обернуть её в команду — это лучший способ освоить тему на практике.
Комментарии
Пока нет комментариев