$ 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 мин·88
Как написать свой первый 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 – для начинающих!

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

Перейти к курсу
Solidity DLC - поймай, если сможешь

Solidity DLC - поймай, если сможешь

Аудит смарт-контрактов на реальных взломах. The DAO, Parity, Wormhole, Ronin, Euler и ещё 7 разборов: что пошло не так, сколько потеряли и что бы спасло. Атаки и защиты на Foundry, план входа в профессию аудитора.

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

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

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

Перейти к курсу
Solidity pro - всё или ничего

Solidity pro - всё или ничего

Как пишут контракты, которые работают в основной сети. Наследование и OpenZeppelin, паттерны продакшена, оптимизация газа, ABI, delegatecall и подписи. 7 модулей, 33 урока, а в финале собственное dApp: мини-DAO с токеном, голосованием и казной.

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

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

Какой первый проект выбрать новичку, чтобы не бросить обучение
первый

Какой первый проект выбрать новичку, чтобы не бросить обучение

Подберём 5–7 идей под уровень “с нуля”, объясним, что делать по шагам и как довести проект до результата без перегруза. В конце — как оформить мини-портфолио и что показать, даже если проект маленький.

23 сентября 2026 г.
1820
Python для аналитики на практике: как строить признаки и проверять их воспроизводимость
python

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

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

17 сентября 2026 г.
1790
Как выучить программирование самому и бесплатно: с чего начать и в каком порядке
выучить

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

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

21 сентября 2026 г.
1970
Как начать с HTML и сделать первую страницу: пошагово и без сложных слов
html

Как начать с HTML и сделать первую страницу: пошагово и без сложных слов

Разберём, что нужно новичку, чтобы за 1–2 вечера собрать свою первую веб-страницу: структура HTML, базовые теги, ссылки и картинки, плюс простой чек-лист, как понять, что страница работает правильно.

2 ноября 2026 г.
20
Cargo для продакшена: workspaces, фичи и стратегия версий зависимостей
cargo

Cargo для продакшена: workspaces, фичи и стратегия версий зависимостей

Разберём, как организовать Rust workspace, управлять optional features, фиксировать версии библиотек и уменьшать “дрейф” зависимостей при командной разработке и CI.

18 сентября 2026 г.
1960
Что такое HTML и чем он отличается от CSS — объясню простыми словами и на примерах
такое

Что такое HTML и чем он отличается от CSS — объясню простыми словами и на примерах

Разберём, из чего состоит веб-страница: что делает HTML (смысл и структура) и что делает CSS (оформление). В конце соберёте мини-страницу и поймёте, что нужно учить в каком порядке.

30 октября 2026 г.
130

Комментарии

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

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

Содержание

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