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
Python1 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:
Bash1python greet.py Kacper --times 3
2. Podkomendy
Python1 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
Python1 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.
Python1 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:
Bash1pip install click
1. Podstawy
Python1 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
Python1 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
Python1 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:
Bash1pip install typer[all]
1. Podstawy z type hints
Python1 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
Python1 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:
Bash1python tool_typer.py sum --numbers 1 2 3
3. Walidacja typami i wartościami domyślnymi
Python1 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
Python1 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:
Bash1cat data.json | python io.py > out.json
Tryby ciche i gadatliwe
-q/--quiettłumi logi.-v/--verbosezwiększa szczegółowość.
Dla logowania używaj logging, a nie print.
Python1 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.
Python1 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.
Bash1pip install rich
Python1 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.
Bash1python app_typer.py --install-completion
Pakowanie i instalacja jako polecenie systemowe
Użyj pyproject.toml z entry pointem.
TOML1 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:
Bash1 2pip 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
Python1 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
Python1 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
Python1 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.
Python1 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
Python1 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-runi--yes/--force.--outputi--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
-vlub--debug.
Format wyjścia dla ludzi i maszyn
Python1 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
| Cecha | argparse | click | Typer |
|---|---|---|---|
| Zależności | brak | 1 paczka | 1 paczka |
| API | imperatywne | dekoratory | dekoratory + type hints |
| UX help | klasyczny | bogaty | bardzo bogaty |
| Autocomplete | manualnie | wbudowane | wbudowane |
| Złożone projekty | tak | tak | tak, najwygodniej |
| Onboarding | niski próg | szybki start | najszybszy, 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
Python1 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:
TOML1 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
--helpz 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.



