1 왜 Python CLI를 알아야 하는가
데이터 과학자·엔지니어에게 CLI는 빠르게 잊히는 영역이다 — Jupyter·VSCode 인터페이스만 쓰면 충분하기 때문이다. 그러나 실제 프로젝트가 운영 단계에 들어가면 CLI 도구가 폭발적으로 늘어난다.
| MINERVA 운영 도구 | 역할 |
|---|---|
python -m scripts.manage user create ... |
사용자 관리 |
python -m scripts.manage docs ingest <path> |
문서 벡터 인덱싱 |
python -m scripts.manage agent eval --suite gold |
A/B 평가 실행 |
alembic upgrade head |
DB 마이그레이션 |
pytest tests/ -k "snapshot" |
테스트 필터 실행 |
pre-commit run --all-files |
린트·포맷 |
운영 자동화·배포 스크립트·일회성 데이터 작업이 모두 CLI 도구다. 본 글은 Python에서 CLI를 만드는 세 가지 길과 선택 기준, 그리고 MINERVA 11-0 환경변수 운영·12-0 테스트 같은 운영 도구가 의존하는 패턴을 정리한다.
2 세 가지 라이브러리 비교
| 라이브러리 | 특징 | 적합한 경우 |
|---|---|---|
| argparse (표준) | Python 내장, 외부 의존 없음 | 단순한 스크립트, 외부 패키지 추가 부담 |
| click | Decorator 기반, 풍부한 기능 | 서브커맨드 많은 도구 (git 같은) |
| typer | Type hints 기반, FastAPI 스타일 | 현대적 type-driven 코드, 빠른 prototype |
대부분의 경우 typer 권장 — type hints만 쓰면 자동 처리, FastAPI와 일관된 모양. 외부 의존 회피·표준 라이브러리 고집이 강하면 argparse.
3 argparse — 표준 라이브러리
# scripts/simple_cli.py
import argparse
def main():
parser = argparse.ArgumentParser(description="문서 인덱싱 스크립트")
parser.add_argument("path", help="인덱싱할 디렉토리")
parser.add_argument("--collection", default="default", help="대상 컬렉션 이름")
parser.add_argument("--batch-size", type=int, default=32, help="임베딩 배치 크기")
parser.add_argument("-v", "--verbose", action="store_true", help="상세 로그")
args = parser.parse_args()
print(f"Indexing {args.path} → {args.collection} (batch={args.batch_size})")
if args.verbose:
print("verbose mode")
if __name__ == "__main__":
main()강점: - 표준 라이브러리 — 외부 의존 없음 - 거의 모든 Python 환경에서 동작 (도커 슬림 이미지·구버전 Python 포함) - 타입 변환 (type=int)·choices·required 모두 표준
약점: - 서브커맨드 (git처럼 git add·git commit)는 boilerplate가 많음 - 도움말 디자인이 단조로움 - 자동완성 지원 직접 구현 필요
3.1 argparse 서브커맨드
import argparse
def cmd_user_create(args):
print(f"Creating user: {args.email}")
def cmd_docs_ingest(args):
print(f"Ingesting: {args.path}")
def main():
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)
# user 그룹
user = subparsers.add_parser("user")
user_sub = user.add_subparsers(dest="action", required=True)
user_create = user_sub.add_parser("create")
user_create.add_argument("--email", required=True)
user_create.set_defaults(func=cmd_user_create)
# docs 그룹
docs = subparsers.add_parser("docs")
docs_sub = docs.add_subparsers(dest="action", required=True)
docs_ingest = docs_sub.add_parser("ingest")
docs_ingest.add_argument("path")
docs_ingest.set_defaults(func=cmd_docs_ingest)
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()python scripts/manage.py user create --email user@example.com
python scripts/manage.py docs ingest ./data서브커맨드 깊이가 깊어질수록 click·typer로 전환할 가치가 커진다.
4 click — Decorator 기반
# scripts/manage.py (click 버전)
import click
@click.group()
def cli():
"""MINERVA 운영 도구."""
pass
@cli.group()
def user():
"""사용자 관리."""
pass
@user.command("create")
@click.option("--email", required=True, help="사용자 이메일")
@click.option("--role", type=click.Choice(["admin", "user", "guest"]), default="user")
def user_create(email: str, role: str):
"""새 사용자 생성."""
click.echo(f"Creating: {email} ({role})")
@cli.group()
def docs():
"""문서 인덱싱·관리."""
pass
@docs.command("ingest")
@click.argument("path", type=click.Path(exists=True))
@click.option("--collection", default="default")
@click.option("--batch-size", default=32, type=int)
def docs_ingest(path: str, collection: str, batch_size: int):
"""디렉토리를 벡터 컬렉션에 인덱싱."""
click.echo(f"Ingesting {path} → {collection} (batch={batch_size})")
if __name__ == "__main__":
cli()python -m scripts.manage user create --email u@x.com
python -m scripts.manage docs ingest ./data --batch-size 64
python -m scripts.manage --help강점: - 서브커맨드가 자연 — @cli.group() + @user.command() 패턴 - 풍부한 helper — click.echo·click.confirm·click.progressbar·click.prompt - 자동완성 — pip install click-completion으로 bash·zsh·fish 지원 - 타입 시스템 — click.Path(exists=True)·click.DateTime()·click.Choice()
약점: - decorator 기반이라 type hints만으로 자동 처리는 안 됨 — 명시적 @click.option - 함수 시그니처와 decorator 정의가 중복
4.1 click 진행률·확인 prompt
import click
import time
@click.command()
@click.argument("path")
@click.option("--force", is_flag=True, help="확인 없이 진행")
def ingest(path: str, force: bool):
if not force:
if not click.confirm(f"{path}를 인덱싱할까요?"):
click.echo("취소")
return
items = list(range(100))
with click.progressbar(items, label="Indexing") as bar:
for item in bar:
time.sleep(0.05)
click.secho("완료", fg="green", bold=True)
if __name__ == "__main__":
ingest()click.secho는 색·스타일 지원, terminal에서만 색상 출력하고 redirect 시 자동 평문.
5 typer — Type Hints 기반
# scripts/manage.py (typer 버전)
import typer
from enum import Enum
from pathlib import Path
from typing import Annotated
app = typer.Typer(help="MINERVA 운영 도구")
user_app = typer.Typer(help="사용자 관리")
docs_app = typer.Typer(help="문서 인덱싱·관리")
app.add_typer(user_app, name="user")
app.add_typer(docs_app, name="docs")
class Role(str, Enum):
admin = "admin"
user = "user"
guest = "guest"
@user_app.command("create")
def user_create(
email: Annotated[str, typer.Option(help="사용자 이메일")],
role: Annotated[Role, typer.Option()] = Role.user,
):
"""새 사용자 생성."""
typer.echo(f"Creating: {email} ({role.value})")
@docs_app.command("ingest")
def docs_ingest(
path: Annotated[Path, typer.Argument(exists=True)],
collection: Annotated[str, typer.Option()] = "default",
batch_size: Annotated[int, typer.Option()] = 32,
):
"""디렉토리를 벡터 컬렉션에 인덱싱."""
typer.echo(f"Ingesting {path} → {collection} (batch={batch_size})")
if __name__ == "__main__":
app()python -m scripts.manage user create --email u@x.com --role admin
python -m scripts.manage docs ingest ./data --batch-size 64강점: - Type hints 자체가 schema — 타입 변환·검증·도움말 모두 자동 - FastAPI와 동일한 패턴 — Pydantic·BaseModel 학습이 그대로 전이 - decorator 1개 + 함수 시그니처만으로 정의 - Rich 통합 — 컬러 도움말·테이블·트레이스백 자동 - click 위에 빌드 — click의 모든 기능 사용 가능
약점: - 외부 의존 (click + typer) - Annotated 패턴이 Python 3.9+ 필요 (3.8 호환성) - type hints에 익숙하지 않은 팀원 적응 필요
5.1 typer + Rich 통합
import typer
from rich.console import Console
from rich.table import Table
from rich.progress import track
app = typer.Typer()
console = Console()
@app.command()
def list_users():
table = Table(title="Users")
table.add_column("ID", style="cyan")
table.add_column("Email", style="green")
table.add_column("Role")
for row in [("1", "alice@x.com", "admin"), ("2", "bob@x.com", "user")]:
table.add_row(*row)
console.print(table)
@app.command()
def reindex():
for _ in track(range(100), description="Reindexing..."):
time.sleep(0.05)
console.print("[bold green]done[/bold green]")Rich가 자동으로 색상·진행률·테이블을 처리. typer는 Rich와 자연스럽게 결합.
6 환경변수·설정 파일 통합
CLI 옵션과 환경변수를 같이 받는 패턴 — --db-url 또는 DB_URL 환경변수.
6.1 click
6.2 typer
6.3 Pydantic Settings 통합 (운영 권장)
환경변수·dotenv 글에서 다룬 Pydantic Settings를 CLI에서 그대로 사용:
# config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
db_url: str
openai_api_key: str
azure_blob_url: str
model_config = SettingsConfigDict(env_file=".env", env_prefix="MINERVA_")
# scripts/manage.py
import typer
from config import Settings
app = typer.Typer()
def get_settings():
return Settings() # 환경변수 + .env 자동
@app.command()
def migrate():
settings = get_settings()
typer.echo(f"Migrating: {settings.db_url}")CLI는 명시적 옵션을 받는 데 집중하고, 인프라 구성(DB·API key)은 Pydantic Settings에 위임 — 이 분리가 12-factor app 패턴.
7 에러 처리와 exit code
CLI는 정상 종료 시 exit 0, 오류는 비-0 — bash·CI가 이걸로 분기한다.
# argparse
import sys
def main():
try:
...
except FileNotFoundError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
except KeyboardInterrupt:
sys.exit(130) # SIGINT 표준# click
import click
@click.command()
def cmd():
if not Path("config.yaml").exists():
raise click.ClickException("config.yaml not found") # exit 1, stderr 출력
if missing_data:
click.echo("data missing", err=True)
raise click.exceptions.Exit(2)# typer
import typer
@app.command()
def cmd():
if not Path("config.yaml").exists():
typer.echo("config.yaml not found", err=True)
raise typer.Exit(code=1)bash에서 활용:
python -m scripts.manage docs ingest ./data
if [ $? -ne 0 ]; then
echo "ingest failed"
exit 1
fi
# 또는 chain
python -m scripts.manage docs ingest ./data && python -m scripts.manage agent evalshell scripting 글에서 다룬 set -e·exit code chain이 정확히 이 토대 위.
8 테스트 — CliRunner
CLI 도구를 함수처럼 테스트한다.
8.1 click
from click.testing import CliRunner
from scripts.manage import cli
def test_user_create():
runner = CliRunner()
result = runner.invoke(cli, ["user", "create", "--email", "a@x.com"])
assert result.exit_code == 0
assert "Creating: a@x.com" in result.output
def test_docs_ingest_missing_path():
runner = CliRunner()
result = runner.invoke(cli, ["docs", "ingest", "/nonexistent"])
assert result.exit_code != 08.2 typer
from typer.testing import CliRunner
from scripts.manage import app
def test_user_create():
runner = CliRunner()
result = runner.invoke(app, ["user", "create", "--email", "a@x.com"])
assert result.exit_code == 0
assert "Creating: a@x.com" in result.stdoutpytest 글의 fixture·parametrize와 결합하면 CLI 회귀 테스트가 자연스럽다.
9 MINERVA scripts/manage.py 패턴
MINERVA 11-0편 운영 도구가 사용하는 패턴 (typer 기반).
# scripts/manage.py (개요)
import typer
from app.config import settings # Pydantic Settings
from app.db import session_factory
from app.indexing import vectorize_directory
app = typer.Typer()
user_app = typer.Typer()
docs_app = typer.Typer()
agent_app = typer.Typer()
app.add_typer(user_app, name="user")
app.add_typer(docs_app, name="docs")
app.add_typer(agent_app, name="agent")
@docs_app.command("ingest")
def docs_ingest(
path: Path,
collection: str = "minerva",
batch_size: int = 32,
):
"""디렉토리 → 벡터 컬렉션."""
with session_factory() as session:
result = vectorize_directory(
session, path, collection=collection, batch_size=batch_size
)
typer.echo(f"Indexed {result.count} docs")
@agent_app.command("eval")
def agent_eval(
suite: str = typer.Option("gold", help="평가 suite 이름"),
out: Path = typer.Option("eval_results.json"),
):
"""A/B 평가 실행."""
from app.evaluation import run_eval
summary = run_eval(suite=suite)
out.write_text(summary.model_dump_json(indent=2))
typer.echo(f"Saved: {out}")
if __name__ == "__main__":
app()이 도구가 운영에서 하는 역할: - 새 환경 셋업 (사용자·DB 마이그레이션·초기 인덱싱) - 운영 중 데이터 보강 (docs ingest) - 평가 회귀 (agent eval --suite gold) - 디버깅 (agent debug-trace --run-id ...)
CLI 진입점이 pyproject.toml에 등록되면 pip install -e . 후 minerva docs ingest ...로 실행:
10 자주 발생하는 오류 패턴
parser.add_argument("--verbose", type=bool, default=False)
# ↑ argparse는 type=bool을 다르게 해석한다
# --verbose=False도 True로 평가됨 (비어있지 않은 string)CORRECT:
argparse에서 boolean 플래그는 action="store_true". type=bool은 함정.
@app.command()
def run(name: str = typer.Option("default", help="...")):
...
# ↑ typer 0.9 이전 패턴, deprecatedCORRECT:
typer 0.9+에서는 Annotated 패턴이 권장. 기본값은 시그니처에 그대로, 메타데이터는 Annotated 안에.
@app.command()
def migrate():
if not check_db():
typer.echo("DB unreachable")
return # exit code 0 — bash가 성공으로 판단CORRECT:
@app.command()
def migrate():
if not check_db():
typer.echo("DB unreachable", err=True)
raise typer.Exit(code=1) # exit 1로 명확히 실패 표시CI·bash 스크립트가 exit code로 분기. 실패는 반드시 raise typer.Exit(code=1) 또는 sys.exit(1).
@app.command()
def export():
typer.echo("Starting export...") # 진행 메시지
print(json.dumps(data)) # 데이터 출력
# ↑ 둘 다 stdout — pipe로 받으면 메시지가 데이터에 섞임CORRECT:
@app.command()
def export():
typer.echo("Starting export...", err=True) # 진행은 stderr
print(json.dumps(data)) # 데이터만 stdout“진행 메시지는 stderr, 결과 데이터는 stdout” — Unix 철학.
CORRECT:
@app.command()
def reindex(
force: Annotated[bool, typer.Option(help="확인 prompt 없이 진행")] = False,
):
"""벡터 인덱스 전체 재구축.""" # docstring → 명령 도움말
...docstring·help 옵션이 곧 사용자 매뉴얼. 6개월 후의 본인이 읽는다.
11 정리
| 영역 | argparse | click | typer |
|---|---|---|---|
| 의존성 | 표준 | 외부 | 외부 (click 위) |
| 정의 방식 | imperative parser | decorator | type hints + decorator |
| 서브커맨드 | 가능 (boilerplate ↑) | 자연 | 자연 |
| 환경변수 통합 | 직접 처리 | envvar= |
typer.Option(envvar=) |
| 진행률·prompt | 직접 구현 | click.progressbar·click.prompt |
typer or Rich |
| 자동완성 | 직접 구현 | click-completion | typer 내장 |
| 테스트 | subprocess + 직접 호출 | CliRunner |
CliRunner |
| 추천 | 의존 회피 시 | 서브커맨드 많은 도구 | 신규 프로젝트 (현대적) |
12 응용 분야
| 시나리오 | 추천 |
|---|---|
| 일회성 데이터 변환 스크립트 | argparse (단일 파일) |
| 운영 관리 도구 (CRUD 다양) | typer |
| 배포 자동화 (서브커맨드 깊음) | click 또는 typer |
| ML 실험 launcher (config 많음) | typer + Pydantic Settings |
| MINERVA scripts/manage.py | typer + Rich + Pydantic Settings |
| pre-commit hook | argparse (의존 최소) |
13 관련 주제
선행 학습
- 환경변수와 dotenv – Pydantic Settings 통합
- Python typing 심화 – typer가 의존하는 토대
- pytest 기초 – CliRunner 테스트
MINERVA 시리즈 응용
- MINERVA 환경변수 운영 (11-0) – scripts/manage.py가 Pydantic Settings 사용
- MINERVA 테스트 fixture (12-0) – CLI 도구 테스트 패턴
다른 카테고리 연결
- shell scripting 기초 – exit code 활용 chain
- Docker compose 기초 – 컨테이너 안 CLI 실행 (
docker compose run app python -m scripts.manage ...)