$ sudo teach|
    $ sudo teach|
    IT school
  • Telegram
  • Партнёрам
  • Все курсы
$ sudo teach IT
OOO "SALEPROFIT"Контакты и реквизитыIT-Park Logo

Школа

  • Блог
  • Проверить сертификат

Сотрудничество

  • Стать учителем
  • Партнёрская программа
  • О проекте

Право

  • Оферта
  • Политика конфиденциальности

© 2023–2026 $ sudo teach IT™. All Rights Reserved. Public user contributions licensed under CC BY-SA 4.0 license with attribution required
TelegramGitHubYouTube
ГлавнаяБлогКак написать свой первый CLI-инструмент на Python с Typer: от идеи до публикации в PyPI

Как написать свой первый CLI-инструмент на Python с Typer: от идеи до публикации в PyPI

$ sudo teach IT
·18 июля 2026 г.·9 мин·62
Как написать свой первый CLI-инструмент на Python с Typer: от идеи до публикации в PyPI

Создаём полноценный CLI-инструмент с командами, флагами, автодополнением и красивым выводом через Rich. Разбираем структуру проекта и публикуем пакет в PyPI шаг за шагом.

Содержание
Что мы будем строитьСтруктура проектаНастройка окружения и зависимостейpyproject.toml: современный способ описания пакетаПишем приложениеТочка входа: cli.pyКоманда initКоманда infoАвтодополнение: как это работаетТестирование CLIПубликация в PyPIШаг 1: Регистрация на PyPIШаг 2: Сборка пакетаШаг 3: Проверка перед публикациейШаг 4: Публикация на Test PyPIШаг 5: Публикация на основной 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 — и это нормально для прототипа, но не для публикуемого пакета.

Вот структура, которую мы будем использовать:

code
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. Это не просто вкусовщина: такая структура предотвращает случайный импорт пакета из корня проекта во время разработки и заставляет вас работать с установленной версией. Это важно, когда вы тестируете публикацию.


Настройка окружения и зависимостей

Создаём виртуальное окружение и устанавливаем нужные пакеты:

code
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. Вот минимальная конфигурация для нашего проекта:

code
[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

code
# 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

code
# 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

code
# 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. После установки пакета пользователь запускает:

code
projector --install-completion

Это автоматически определяет текущую оболочку и добавляет нужную строку в .bashrc или .zshrc. После перезапуска терминала работает Tab-автодополнение для команд и опций.

Если хотите кастомизировать список значений для автодополнения конкретной опции — используйте typer.Completion или передавайте Enum как тип:

code
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) для тестирования команд без запуска реального процесса:

code
# 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: Сборка пакета

code
python -m build

Эта команда создаст папку dist/ с двумя файлами:

  • projector_cli-0.1.0.tar.gz — source distribution
  • projector_cli-0.1.0-py3-none-any.whl — wheel (бинарный дистрибутив)

Всегда публикуйте оба. Wheel устанавливается быстрее, source distribution — запасной вариант.

Шаг 3: Проверка перед публикацией

code
twine check dist/*

Это проверит корректность метаданных и README. Частая ошибка — README в формате, который PyPI не может отрендерить (например, RST с нестандартными директивами).

Шаг 4: Публикация на Test PyPI

code
twine upload --repository testpypi dist/*

Проверьте, что пакет корректно устанавливается:

code
pip install --index-url https://test.pypi.org/simple/ projector-cli
projector --help

Шаг 5: Публикация на основной PyPI

code
twine upload dist/*

После этого любой может установить ваш инструмент:

code
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:

code
__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 позволяет сосредоточиться на логике, а не на разборе аргументов. Попробуйте взять любую рутинную задачу из своего рабочего процесса и обернуть её в команду — это лучший способ освоить тему на практике.

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

Автор

$ sudo teach IT

Typer PRO

Курс по теме

Typer PRO

С нуля до уверенной работы с CLI-проектами на Python. Подходит для начинающих и тех, кто уже пишет на Python. Разберитесь с Typer раз и навсегда — и перестаньте копировать команды вслепую.

Открыть курс

Продолжите обучение

Все курсы
Python – для начинающих!

Python – для начинающих!

С нуля до профессионального уровня. Подходит для всех. Учитесь каждый день и овладейте самым популярным языком программирования.

Перейти к курсу
Приложения для iPhone и Apple Watch на SwiftUI

Приложения для iPhone и Apple Watch на SwiftUI

Разработка приложений для iPhone и Apple Watch на SwiftUI: навигация, SwiftData, виджеты, часы, выпуск. Нужен Mac с Xcode 27, сами устройства не нужны.

Перейти к курсу
Ботостроение Telegram

Ботостроение Telegram

Лёгкий, быстрый и доступный способ познакомиться с миром ботостроения в Telegram. Видео, конспекты, практика и помощь – всё у нас на курсе.

Перейти к курсу
Приложения для macOS на SwiftUI

Приложения для macOS на SwiftUI

Разработка приложений для Mac на SwiftUI: окна и меню, Liquid Glass, SwiftData, сеть, выпуск. Нужен Mac с macOS 27 и Xcode 27.

Перейти к курсу

Другие статьи

Python для аналитики на практике: как строить признаки и проверять их воспроизводимость
python

Python для аналитики на практике: как строить признаки и проверять их воспроизводимость

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

17 сентября 2026 г.
1040
Системный дебаг памяти и производительности в Python: где искать утечки и как проверять гипотезы
системный

Системный дебаг памяти и производительности в Python: где искать утечки и как проверять гипотезы

Пошагово разберём типовые причины роста памяти (кэширование, циклические ссылки, удержание объектов в замыканиях, неверная работа с ресурсами) и как подтверждать догадки трейсами, профилировщиками и метриками. В конце — чек-лист расследования для задач из

15 сентября 2026 г.
1030
Создание Telegram бота в 2026 легко и просто! Полный курсы!
создание

Создание Telegram бота в 2026 легко и просто! Полный курсы!

19 июня 2026 г.
1421
Как выбрать, куда идти в IT: веб, боты, данные — без хаоса
выбрать

Как выбрать, куда идти в IT: веб, боты, данные — без хаоса

Разберём самые понятные направления для новичка (веб, боты, аналитика/данные) и чем они отличаются по шагам обучения. В конце вы получите простой план, как проверить, что вам подходит, и какой стартовый путь выбрать.

5 октября 2026 г.
580
Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть
путь

Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть

Разберём структуру репозитория, как упаковать проект в понятную историю (проблема → решение → запуск → метрики качества) и какие артефакты повышают доверие: скринкасты, архитектурная схема, чек-лист продакшн-готовности.

17 сентября 2026 г.
1070
Как понять, какой язык программирования учить первым: простой тест и выбор
понять

Как понять, какой язык программирования учить первым: простой тест и выбор

Помогу новичку выбрать первый язык без стресса: объясню варианты по целям (веб, боты, анализ данных), что учить в первую очередь и как не потратить месяцы впустую. В конце — краткий план старта на ближайшие недели.

2 октября 2026 г.
620

Комментарии

Станьте первым в обсуждении

Поделитесь мнением о статье или задайте вопрос автору

Содержание

Что мы будем строитьСтруктура проектаНастройка окружения и зависимостейpyproject.toml: современный способ описания пакетаПишем приложениеТочка входа: cli.pyКоманда initКоманда infoАвтодополнение: как это работаетТестирование CLIПубликация в PyPIШаг 1: Регистрация на PyPIШаг 2: Сборка пакетаШаг 3: Проверка перед публикациейШаг 4: Публикация на Test PyPIШаг 5: Публикация на основной PyPIВерсионирование и обновленияТипичные ошибки и подводные камниКуда двигаться дальшеИтог