Tworzenie narzędzi CLI w Pythonie – od argparse do click i Typer

Kacper Sieradziński
Kacper Sieradziński
27 marca 2025Edukacja5 min czytania

Dobrze zaprojektowane narzędzie CLI to coś więcej niż tylko „skrypt, który działa z terminala”. To przemyślany interfejs, który jest szybki, przewidywalny i łatwy w automatyzacji. Dobry CLI nie wymaga instrukcji za każdym razem, gdy z niego korzystasz — sam sposób pisania komend podpowiada, jak się z nim obchodzić. Ma sensowne nazwy, przejrzyste opcje i komunikaty błędów, które pomagają zamiast irytować. Takie narzędzie można wpleść w dowolny proces: od prostych zadań developerskich po złożone pipeline’y CI/CD. I właśnie ta przewidywalność jest jego największą zaletą — każde wywołanie komendy ma jasno określony efekt, który można powtórzyć i zautomatyzować bez obaw o niespodzianki.

Obraz główny Tworzenie narzędzi CLI w Pythonie – od argparse do click i Typer

W tym przewodniku pokażę Ci, jak tworzyć takie CLI w Pythonie, krok po kroku. Zaczniemy od klasycznego podejścia przy użyciu argparse, które daje pełną kontrolę nad składnią i zachowaniem narzędzia. Potem przejdziemy do click — biblioteki, która upraszcza kod dzięki dekoratorom i poprawia UX (kolory, prompty, grupy komend). Na końcu wejdziemy w nowoczesne rozwiązanie, czyli Typer, które łączy wygodę click z siłą adnotacji typów znanych z FastAPI. Po drodze pokażę też, jak pisać testy CLI, jak dodawać autouzupełnianie w terminalu, jak pakować narzędzie do instalacji przez pipx, a także jak zaprojektować UX, który naprawdę ma sens dla użytkownika. Ten tekst nie będzie tylko o kodzie — będzie o tworzeniu narzędzi, które po prostu działają i które chce się uruchamiać codziennie.

Dlaczego CLI?

  • Szybkość i powtarzalność: jedno polecenie = ten sam efekt.
  • Automatyzacja: łatwe skrypty, crony, CI/CD.
  • Przewidywalność: stałe interfejsy i kody wyjścia.
  • Integracja: stdin/stdout, rury, pliki, JSON.

Minimalne wymagania środowiska

  • Python 3.10+
  • Wirtualne środowisko (venv lub pipx dla narzędzi)
  • Na produkcję: pyproject.toml i entry pointy

Część I: argparse – standardowa biblioteka, pełna kontrola

1. Najprostszy przykład

Python
1 2 3 4 5 6 7 8 9 10 11 12 13 # greet.py import argparse def main(): parser = argparse.ArgumentParser(prog="greet", description="Wita użytkownika") parser.add_argument("name") parser.add_argument("-t", "--times", type=int, default=1) args = parser.parse_args() for _ in range(args.times): print(f"Hello, {args.name}!") if __name__ == "__main__": main()

Uruchomienie:

Bash
1 python greet.py Kacper --times 3

2. Podkomendy

Python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 # tool.py import argparse def cmd_sum(args): print(sum(args.numbers)) def cmd_echo(args): print(args.text.upper() if args.upper else args.text) def build_parser(): parser = argparse.ArgumentParser(prog="tool") sub = parser.add_subparsers(dest="cmd", required=True) p_sum = sub.add_parser("sum") p_sum.add_argument("numbers", type=float, nargs="+") p_sum.set_defaults(func=cmd_sum) p_echo = sub.add_parser("echo") p_echo.add_argument("text") p_echo.add_argument("-u", "--upper", action="store_true") p_echo.set_defaults(func=cmd_echo) return parser def main(): parser = build_parser() args = parser.parse_args() args.func(args) if __name__ == "__main__": main()

3. Walidacje, wybory, wielowartościowe opcje

Python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # convert.py import argparse from pathlib import Path def main(): parser = argparse.ArgumentParser() parser.add_argument("-i", "--input", type=Path, required=True) parser.add_argument("-o", "--output", type=Path, required=True) parser.add_argument("-f", "--format", choices=["json", "csv", "parquet"], required=True) parser.add_argument("-c", "--columns", nargs="+", default=[]) args = parser.parse_args() print(args.input, args.output, args.format, args.columns) if __name__ == "__main__": main()

4. Błędy i kody wyjścia

Złe użycie: argparse wypisze komunikat i zwróci kod 2.

Błędy wykonania: zwracaj 1 lub inny uzgodniony kod.

Python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # exitcodes.py import sys import argparse def main(): parser = argparse.ArgumentParser() parser.add_argument("x", type=int) parser.add_argument("y", type=int) args = parser.parse_args() if args.y == 0: print("Division by zero", file=sys.stderr) sys.exit(1) print(args.x / args.y) if __name__ == "__main__": main()

Kiedy argparse?

Zero zależności, pełna kontrola, klasyczny wygląd helpa. Dobre do wbudowanych narzędzi devopsowych.

Część II: click – ergonomia dekoratorów

Instalacja:

Bash
1 pip install click

1. Podstawy

Python
1 2 3 4 5 6 7 8 9 10 11 12 # app_click.py import click @click.command() @click.argument("name") @click.option("-t", "--times", default=1, type=int) def main(name, times): for _ in range(times): click.echo(f"Hello, {name}!") if __name__ == "__main__": main()

2. Grupy i podkomendy

Python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # tool_click.py import click @click.group() def cli(): pass @cli.command() @click.argument("numbers", type=float, nargs=-1) def sum(numbers): click.echo(sum(numbers)) @cli.command() @click.argument("text") @click.option("-u", "--upper", is_flag=True) def echo(text, upper): click.echo(text.upper() if upper else text) if __name__ == "__main__": cli()

3. Prompty, hasła, paski postępu

Python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # ux_click.py import time import click @click.command() @click.option("--username", prompt=True) @click.option("--password", prompt=True, hide_input=True) def login(username, password): with click.progressbar(range(3)) as bar: for _ in bar: time.sleep(0.3) click.secho(f"Welcome {username}", fg="green") if __name__ == "__main__": login()

Kiedy click?

Szybka budowa rozbudowanych CLI, świetny UX, kolory, prompty, łatwe testy.

Część III: Typer – type hints na serio

Instalacja:

Bash
1 pip install typer[all]

1. Podstawy z type hints

Python
1 2 3 4 5 6 7 8 9 10 11 12 # app_typer.py import typer app = typer.Typer() @app.command() def greet(name: str, times: int = 1): for _ in range(times): print(f"Hello, {name}!") if __name__ == "__main__": app()

2. Podkomendy i callbacki globalne

Python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 # tool_typer.py import typer app = typer.Typer() @app.callback() def main(verbose: bool = typer.Option(False, "--verbose", "-v")): pass @app.command() def sum(numbers: list[float]): print(sum(numbers)) @app.command() def echo(text: str, upper: bool = False): print(text.upper() if upper else text) if __name__ == "__main__": app()

Uruchomienie:

Bash
1 python tool_typer.py sum --numbers 1 2 3

3. Walidacja typami i wartościami domyślnymi

Python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 # constraints_typer.py import typer from enum import Enum from pathlib import Path class Format(str, Enum): json = "json" csv = "csv" parquet = "parquet" app = typer.Typer() @app.command() def convert(input: Path, output: Path, format: Format, columns: list[str] = typer.Option(None)): print(input, output, format, columns) if __name__ == "__main__": app()

Kiedy Typer?

Chcesz nowoczesny DX, autouzupełnianie, świetny help i minimalny kod dzięki adnotacjom typów. Idealny do większych narzędzi.

Wejście/wyjście, potoki, pliki, JSON

Czytanie ze stdin i pisanie na stdout

Python
1 2 3 4 5 6 7 8 9 10 11 # io.py import sys import json def main(): data = sys.stdin.read() obj = json.loads(data) print(json.dumps(obj, ensure_ascii=False)) if __name__ == "__main__": main()

Przykład:

Bash
1 cat data.json | python io.py > out.json

Tryby ciche i gadatliwe

  • -q/--quiet tłumi logi.
  • -v/--verbose zwiększa szczegółowość.

Dla logowania używaj logging, a nie print.

Python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # logging_cli.py import logging import argparse def main(): parser = argparse.ArgumentParser() parser.add_argument("-v", "--verbose", action="count", default=0) args = parser.parse_args() level = logging.WARNING - 10 * min(args.verbose, 2) logging.basicConfig(level=level, format="%(levelname)s %(message)s") logging.info("Start") print("OK") if __name__ == "__main__": main()

Pliki konfiguracyjne i zmienne środowiskowe

Priorytet sensowny: CLI > ENV > config.

Python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 # config_env.py import os import argparse import json from pathlib import Path def load_config(path: Path): if path.exists(): return json.loads(path.read_text(encoding="utf-8")) return {} def main(): parser = argparse.ArgumentParser() parser.add_argument("--api-key") parser.add_argument("--config", type=Path, default=Path("config.json")) args = parser.parse_args() cfg = load_config(args.config) api_key = args.api_key or os.getenv("API_KEY") or cfg.get("api_key") if not api_key: raise SystemExit(1) print(api_key[:4] + "****") if __name__ == "__main__": main()

Kolory, tabele, ładne wyjście

Z click i Typer masz kolory od ręki. Do bogatszego formatowania użyj rich.

Bash
1 pip install rich
Python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # pretty.py from rich.console import Console from rich.table import Table def main(): console = Console() table = Table(title="Users") table.add_column("ID") table.add_column("Name") table.add_row("1", "Kacper") table.add_row("2", "Ala") console.print(table) if __name__ == "__main__": main()

Autouzupełnianie w powłoce

click i Typer mają wsparcie dla completion.

W Typer: app = typer.Typer() i użycie --install-completion oraz --show-completion.

Bash
1 python app_typer.py --install-completion

Pakowanie i instalacja jako polecenie systemowe

Użyj pyproject.toml z entry pointem.

TOML
1 2 3 4 5 6 7 8 9 10 11 12 13 # pyproject.toml [build-system] requires = ["setuptools>=68", "wheel"] build-backend = "setuptools.build_meta" [project] name = "dokodu-cli" version = "0.1.0" requires-python = ">=3.10" dependencies = ["typer[all]"] [project.scripts] dokodu = "dokodu_cli.app:app"

Struktura:

dokodu_cli/
__init__.py
app.py
pyproject.toml

Instalacja lokalna:

Bash
1 2 pip install -e . dokodu --help

Dystrybucja dla użytkowników:

pipx install dokodu-cli dla izolacji.

Dodaj python -m build i publikację do PyPI, jeśli potrzebujesz.

Testowanie CLI

argparse: subprocess lub wywołanie main z capsys

Python
1 2 3 4 5 6 7 8 # test_argparse.py import subprocess import sys def test_greet(): r = subprocess.run([sys.executable, "greet.py", "Ala", "--times", "2"], capture_output=True, text=True) assert r.returncode == 0 assert r.stdout.strip().splitlines() == ["Hello, Ala!", "Hello, Ala!"]

click: CliRunner

Python
1 2 3 4 5 6 7 8 9 # test_click.py from click.testing import CliRunner from tool_click import cli def test_sum(): runner = CliRunner() result = runner.invoke(cli, ["sum", "1", "2", "3"]) assert result.exit_code == 0 assert result.output.strip() == "6.0"

Typer: CliRunner z wrapperem Typera

Python
1 2 3 4 5 6 7 8 9 # test_typer.py from typer.testing import CliRunner from app_typer import app def test_greet(): runner = CliRunner() result = runner.invoke(app, ["greet", "Ala", "--times", "2"]) assert result.exit_code == 0 assert result.output.strip().splitlines() == ["Hello, Ala!", "Hello, Ala!"]

Asynchroniczność i równoległość

Typer dobrze współgra z asyncio.

Python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 # async_typer.py import asyncio import typer app = typer.Typer() async def fetch(n: int): await asyncio.sleep(0.1) return n @app.command() def run(count: int = 5): async def main(): results = await asyncio.gather(*(fetch(i) for i in range(count))) print(sum(results)) asyncio.run(main()) if __name__ == "__main__": app()

Dla zadań CPU użyj concurrent.futures.ProcessPoolExecutor. Nie mieszaj I/O i CPU w jednym event loopie bez potrzeby.

Obsługa sygnałów i timeouts

Python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # signals.py import signal import sys import time def handler(signum, frame): print("Interrupted", file=sys.stderr) sys.exit(130) def main(): signal.signal(signal.SIGINT, handler) time.sleep(10) print("Done") if __name__ == "__main__": main()

Exit 130 jest standardem dla Ctrl+C.

Stabilne interfejsy: wersja, semantyka, deprecjacje

Dodaj --version. W click i Typer zrobisz to jednym parametrem lub przez dedykowaną komendę. Planując zmiany, wprowadź ostrzeżenia i aliasy, nie łam interfejsu z dnia na dzień.

UX, które robi różnicę

  • Spójne nazewnictwo i kolejność opcji.
  • Przykłady w --help.
  • --dry-run--yes/--force.
  • --output--format json|table|yaml.
  • --config, --profile, --env.
  • Sensowne kody wyjścia i czytelne komunikaty błędów.
  • Brak gadatliwości domyślnie, szczegóły dopiero po -v lub --debug.

Format wyjścia dla ludzi i maszyn

Python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # json_out.py import argparse import json def main(): parser = argparse.ArgumentParser() parser.add_argument("--format", choices=["json", "text"], default="text") args = parser.parse_args() data = {"status": "ok", "items": [1, 2, 3]} if args.format == "json": print(json.dumps(data, ensure_ascii=False)) else: print("Status: ok") print("Items: 1, 2, 3") if __name__ == "__main__": main()

Pluginy i rozszerzalność

Możesz ładować podkomendy dynamicznie z entry pointów. To pozwala budować ekosystem wtyczek, gdzie zewnętrzne paczki dokładają subkomendy bez modyfikacji core'u.

Bezpieczeństwo i sekrety

  • Nie loguj sekretów.
  • Czytaj klucze z ENV lub menedżerów sekretów.
  • Pliki tymczasowe twórz w bezpiecznych katalogach.
  • Minimalne uprawnienia, brak poleceń shellowych bez walidacji.

Porównanie narzędzi

CechaargparseclickTyper
Zależnościbrak1 paczka1 paczka
APIimperatywnedekoratorydekoratory + type hints
UX helpklasycznybogatybardzo bogaty
Autocompletemanualniewbudowanewbudowane
Złożone projektytaktaktak, najwygodniej
Onboardingniski prógszybki startnajszybszy, jeśli znasz type hints

Wniosek: do małych narzędzi systemowych użyj argparse. Do codziennej pracy i bogatszego UX weź click. Do większych, typowanych projektów – Typer.

Szkielety do skopiowania

Szkielet Typer z podkomendami

Python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 # cli/__init__.py # cli/__main__.py import typer from .commands import data, util app = typer.Typer() app.add_typer(data.app, name="data") app.add_typer(util.app, name="util") def main(): app() if __name__ == "__main__": main() # cli/commands/data.py import typer app = typer.Typer() @app.command() def load(path: str, dry_run: bool = False): print(path, dry_run) # cli/commands/util.py import typer app = typer.Typer() @app.command() def ping(host: str = "localhost"): print(host)

pyproject.toml dla powyższego:

TOML
1 2 3 4 5 6 7 [project] name = "mycli" version = "0.1.0" dependencies = ["typer[all]"] [project.scripts] mycli = "cli.__main__:main"

Checklist wdrożeniowy

  • Czytelny --help z przykładami.
  • --version, -v/--verbose, -q/--quiet.
  • Stabilne kody wyjścia.
  • Obsługa stdin/stdout, potoków i plików.
  • Format wyjścia dla ludzi i maszyn.
  • Konfiguracja: CLI > ENV > plik.
  • Testy z CliRunner lub subprocess.
  • Autouzupełnianie zainstalowane.
  • Pakiet z entry pointem i pipx w instrukcji.
  • Sensowne komunikaty błędów i brak szumu domyślnie.

Podsumowanie

Zacznij od jednego małego polecenia, dodaj podkomendy i testy, a potem ustabilizuj interfejs. Jeśli chcesz, przygotuję pod Twoją domenę przykład narzędzia w Typer z autouzupełnianiem, testami i publikacją pipx. Wystarczy, że podasz nazwę i cel CLI.