CLI 도구 만들기 (Typer)
argparse가 아니라 Typer로 타입힌트 우선 CLI를 만드는 법. 서브커맨드, 자동 완성, Rich 출력 통합.
본 책의 마지막 챕터입니다. 5부 (운영·패키징·테스트)의 마무리로, 타입힌트 우선으로 CLI를 만드는 법을 다룹니다. 본 챕터의 결과물을 32장의 흐름으로 PyPI에 출시하면 CLI 제작부터 배포까지의 과정이 완성됩니다.
도구는 Typer입니다 — FastAPI와 같은 저자가 만든 CLI 프레임워크로, 함수의 시그니처가 곧 CLI 인터페이스라는 같은 철학을 공유합니다.
argparse의 한계 #
표준 라이브러리 argparse가 있는데도 Typer를 선택하는 이유는 다음과 같습니다.
import argparse
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("name", type=str)
parser.add_argument("--age", type=int, default=0)
parser.add_argument("--greet", action="store_true")
args = parser.parse_args()
if args.greet:
print(f"Hello, {args.name}! You are {args.age}.")문제는 셋입니다.
- 타입힌트가 없음 —
args.name의 타입을 IDE가 모릅니다. - boilerplate 많음 — 함수가 하는 일보다 파서 설정 코드가 길어집니다.
- 검증을 직접 — Enum, Path, 범위 등은 추가 코드.
Typer는 같은 코드를 다음처럼 줄입니다.
import typer
def main(name: str, age: int = 0, greet: bool = False) -> None:
if greet:
print(f"Hello, {name}! You are {age}.")
if __name__ == "__main__":
typer.run(main)타입힌트가 곧 검증이고, default가 곧 옵션 기본값이고, bool이 곧 플래그입니다.
설치와 첫 실행 #
uv add typerimport typer
def main(name: str) -> None:
print(f"Hello, {name}!")
if __name__ == "__main__":
typer.run(main)$ uv run python hello.py World
Hello, World!
$ uv run python hello.py --help
Usage: hello.py [OPTIONS] NAME
╭─ Arguments ────────────────────────────────────────────────────────────────╮
│ * name TEXT [default: None] [required] │
╰────────────────────────────────────────────────────────────────────────────╯--help도 자동 생성됩니다.
옵션 vs 인자 #
규칙은 간단합니다.
- 기본값이 없으면 인자 (argument) — 호출 시 위치로 전달.
mycli hello World. - 기본값이 있으면 옵션 (option) —
--name=World또는--name World.
def main(name: str, age: int = 0) -> None:
...name→ required positional argument.age→--age=30optional.
bool의 default는 False가 자연스럽고, 자동으로 --flag / --no-flag 두 형태가 생깁니다.
def main(verbose: bool = False) -> None: ...$ mycli --verbose
$ mycli --no-verboseAnnotated 패턴 — 메타데이터 분리 #
기본형은 깔끔하지만 도움말, 짧은 이름, 값 검증 등을 더하려면 Annotated가 표준 패턴입니다.
from typing import Annotated
import typer
def main(
name: Annotated[str, typer.Argument(help="이름을 알려주세요.")],
age: Annotated[int, typer.Option("--age", "-a", min=0, max=150)] = 0,
verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
) -> None:
if verbose:
print(f"name={name}, age={age}")
print(f"Hello, {name}!")$ uv run python annotated_example.py --help
...
-a, --age INTEGER RANGE [0<=x<=150]
-v, --verbose / --no-verboseAnnotated[X, typer.Option(...)]로 타입은 그대로 두고 메타데이터만 추가 합니다. 이 패턴이 본 책 전체에서 본 — Pydantic, FastAPI, Typer가 공유하는 — 일관된 스타일입니다.
서브커맨드 — Typer 앱 트리 #
git처럼 mycli init, mycli build, mycli publish 같은 서브커맨드를 만들려면 typer.Typer 인스턴스를 만들고 @app.command()를 붙입니다.
import typer
app = typer.Typer(help="Schoolofweb sample CLI")
@app.command()
def init(path: str = ".") -> None:
"""프로젝트 초기화."""
typer.echo(f"init at {path}")
@app.command()
def build() -> None:
"""프로젝트 빌드."""
typer.echo("building...")
@app.command()
def publish(prod: bool = False) -> None:
"""배포."""
target = "PRODUCTION" if prod else "STAGING"
typer.echo(f"publishing to {target}")
if __name__ == "__main__":
app()$ swcli --help
Commands
init 프로젝트 초기화.
build 프로젝트 빌드.
publish 배포.
$ swcli publish --prod
publishing to PRODUCTION중첩 — 서브-서브커맨드 #
auth_app = typer.Typer(help="인증 관련 명령.")
app.add_typer(auth_app, name="auth")
@auth_app.command()
def login(email: str) -> None:
typer.echo(f"logging in as {email}")
@auth_app.command()
def logout() -> None:
typer.echo("logged out")$ swcli auth login me@example.com
logging in as me@example.comgit의 git remote add, kubectl의 kubectl config set-context처럼 깊은 트리를 만들 수 있습니다.
Rich와 통합 — 색·진행률·테이블 #
Typer는 내부적으로 Rich를 씁니다 — typer.echo 대신 직접 Rich의 Console, Progress, Table을 쓰면 UX가 더 풍부해집니다.
Console #
from rich.console import Console
console = Console()
console.print("[bold green]Success![/]")
console.print("[red]Failed:[/] connection refused")Progress #
import time
from rich.progress import Progress
@app.command()
def crawl(pages: int = 100) -> None:
with Progress() as progress:
task = progress.add_task("[cyan]Crawling...", total=pages)
for _ in range(pages):
time.sleep(0.05)
progress.advance(task)Progress가 자동으로 백분율·ETA·스피너를 그려줍니다.
Table #
from rich.table import Table
@app.command()
def users() -> None:
table = Table(title="Users")
table.add_column("ID", justify="right", style="cyan")
table.add_column("Email", style="magenta")
table.add_column("Active", justify="center")
table.add_row("1", "alice@example.com", "✔")
table.add_row("2", "bob@example.com", "✘")
console.print(table)콘솔이 색을 지원 안 하는 환경 (CI 로그, 파이프)에서는 Rich가 자동으로 색을 끕니다.
환경변수 자동 매핑 #
옵션에 envvar=를 주면 CLI 인자가 없을 때 환경변수에서 자동으로 읽습니다.
@app.command()
def deploy(
api_key: Annotated[
str,
typer.Option(envvar="SWCLI_API_KEY", help="API key"),
],
) -> None:
typer.echo(f"deploying with {api_key[:4]}***")$ export SWCLI_API_KEY=secret123
$ swcli deploy
deploying with secr***CI 환경에서 secret을 환경변수로 주는 표준 흐름과 자연스럽게 맞습니다.
자동 완성 (bash · zsh · fish) #
Typer는 한 명령으로 셸 자동 완성을 설치합니다.
swcli --install-completion설치 후 swcli p<TAB> 하면 publish가 자동 완성됩니다. 서브커맨드의 옵션 (--prod)도 자동 완성됩니다 — 사용자의 학습 비용을 크게 줄입니다.
종료 코드와 에러 처리 #
CLI의 관습은 0 = 성공, 비-0 = 실패입니다. Typer에서는 typer.Exit를 던집니다.
@app.command()
def lint(path: str) -> None:
errors = run_lint(path)
if errors:
typer.secho(f"{len(errors)} errors", fg="red", err=True)
raise typer.Exit(code=1)
typer.secho("OK", fg="green")err=True→ stderr로 출력 (사용자가 stdout를 파일로 redirect 해도 에러 메시지는 보임).raise typer.Exit(code=1)→ 종료 코드 설정.
스크립트와 조합되는 도구라면 stdout와 stderr의 분리가 중요합니다.
테스트 — CliRunner #
Typer는 click의 CliRunner를 그대로 노출합니다. 실제 프로세스를 띄우지 않고 in-process로 테스트합니다.
from typer.testing import CliRunner
from swcli.cli import app
runner = CliRunner()
def test_hello() -> None:
result = runner.invoke(app, ["init", "/tmp/myproj"])
assert result.exit_code == 0
assert "init at /tmp/myproj" in result.stdout
def test_publish_prod() -> None:
result = runner.invoke(app, ["publish", "--prod"])
assert result.exit_code == 0
assert "PRODUCTION" in result.stdout
def test_missing_required_arg() -> None:
result = runner.invoke(app, ["init"])
assert result.exit_code == 0 # init 의 path 는 기본값이 있어 OKmix_stderr=False로 만들면 stderr를 별도로 검증할 수 있습니다.
Click과의 관계 #
Typer는 내부적으로 Click을 씁니다 — Typer = Click의 타입힌트 우선 wrapper입니다. Click의 풍부한 기능 (Context, pass_context, callback)이 그대로 Typer에서도 동작합니다.
기존 Click 코드베이스에 새 명령을 Typer로 추가하거나, Typer로 만든 도구의 일부에 Click의 고급 기능을 섞는 것도 가능합니다.
흔한 함정 #
typer.Option의 default 위치
#
Annotated를 쓸 때 default는 함수 시그니처의 = 뒤에 옵니다.
# OK
def f(x: Annotated[int, typer.Option()] = 0) -> None: ...
# 비권장 (구식 스타일)
def f(x: int = typer.Option(0)) -> None: ...구식 스타일은 동작하지만, pyright의 strict 모드에서 default의 타입 추론이 불편해집니다.
모듈을 entry-point로 안 노출 #
32장에서 본 project.scripts의 우항이 잘못되면 설치 후 명령이 안 잡힙니다.
[project.scripts]
swcli = "swcli.cli:app" # OK — src/swcli/cli.py 의 app 객체설치 후 which swcli로 확인.
bool 옵션의 --no- prefix가 의도와 다름
#
use_cache: bool = True라고 쓰면 자동으로 --use-cache / --no-use-cache가 생깁니다. --no-use-cache가 어색하면 명시적으로 옵션 이름을 줍니다.
def f(
no_cache: Annotated[bool, typer.Option("--no-cache")] = False,
) -> None: ...print vs typer.echo vs console.print
#
print— 가장 일반적. 보통 OK.typer.echo— click의 echo. Windows의 colorama 호환을 자동 처리.console.print— Rich의 마크업 ([bold red]) 지원.
복잡한 출력이면 console.print, 단순 메시지면 print로 충분합니다. 한 프로젝트 안에서는 하나로 통일.
큰 명령 트리에서 import 비용 #
@app.command()가 붙은 모듈을 모두 import 하면 CLI 시작이 느려집니다. 자주 안 쓰는 명령은 lazy import 하거나, click의 LazyGroup 패턴을 적용합니다.
32장과 묶기 — 출시 과정까지 연결 #
pyproject.toml에 다음만 더하면 본 챕터의 CLI가 패키지로 설치됩니다.
[project]
name = "swcli"
version = "0.1.0"
dependencies = [
"typer>=0.12,<1",
"rich>=13,<15",
]
[project.scripts]
swcli = "swcli.cli:app"32장의 흐름대로 uv build && uv publish 하면 사용자가 pip install swcli 후 swcli init, swcli publish --prod 같은 명령을 받을 수 있습니다.
본 책의 끝에서 #
여기까지 오신 분께 한 번 정리합니다.
- **1부 (1-7장)**에서 변수 · 타입힌트 · 흐름 · 컬렉션 · 함수 · 에러 · pyproject를 봤습니다.
- **2부 (8-14장)**에서 dataclass · Generic · Protocol · context manager · generator · decorator · pattern matching · asyncio 입문을 다뤘습니다.
- **3부 (15-21장)**에서 매직 메서드 · descriptor · metaclass · asyncio 깊이 · GIL · typing 고급 · 성능까지 들어갔습니다.
- **4부 (22-29장)**에서 FastAPI · Pydantic · SQLAlchemy · 인증 · 백그라운드 잡 · 테스트와 배포 · 종합 실습까지 한 웹 서비스를 처음부터 끝까지 만들었습니다.
- **5부 (30-33장)**에서 정적 검증 · 관측성 · 라이브러리 출시 · CLI까지 운영 가능한 도구로 다듬는 법을 봤습니다.
이 모든 게 한 언어 — Python — 안에서 일관된 멘탈 모델로 연결됩니다. 타입힌트는 검증·문서·자동 완성이 되고, 함수 시그니처는 라우트가 되고 CLI가 되고, async는 웹·백그라운드·DB를 같은 패턴으로 묶고, 도구 체인은 한 pyproject.toml에 모입니다.
연습문제 #
swcli출시 과정 직접 해보기 — 본 챕터의swcli를 그대로 만들어 32장의 흐름으로 TestPyPI에 0.0.1로 출시해 보세요. 설치한 다음swcli --install-completion까지 동작하는지 확인하세요. 어디가 막혔나요?- 하위 명령 트리 확장 — 본인의 일상 작업 하나 (예: “git repo 정리”, “사진 파일 정리”, “API 응답 캐시 비우기”)를 골라 Typer로 서브커맨드 트리가 있는 CLI로 만들어 보세요. 자동 완성과 환경변수 매핑을 활용하면 일상 명령이 얼마나 짧아지나요?
- CliRunner의 한계 —
CliRunner가 in-process 라os.exit같은 일부 동작은 잡지 못합니다. 본인의 CLI 중 진짜 프로세스를 띄워야 검증되는 동작 (signal 처리, subprocess 호출)이 있다면subprocess.run(["swcli", ...])기반 테스트와CliRunner기반 테스트를 어떻게 나누시겠나요?
본 책의 마지막 챕터입니다. 5부의 첫 챕터 30장 타입체커 설정과 CI 통합부터 본 챕터까지 한 줄로 이어집니다 — 코드의 정확성을 자동화하고 (30장), 운영 중 무슨 일이 일어나는지 보이게 만들고 (31장), 만든 코드를 세상에 출시하고 (32장), 사용자가 손에 잡고 쓰는 인터페이스를 만든다 (33장). 책의 모든 장이 이 한 줄에서 만납니다.
다음은 본인의 프로젝트입니다. 본 책이 그 출발점이 됐길 바랍니다.