Membuat CLI Tool dengan Python: dari Script Sederhana hingga Aplikasi Command Line Profesional
Pernah nggak sih, kamu punya script Python yang selalu kamu jalankan berulang kali setiap hari? Misalnya script untuk rename file bulk, script untuk fetch data dari API, atau script untuk generate laporan dari database. Setiap kali mau jalankan, harus ketik python script.py --arg1 value1 --arg2 value2 lama-lama capek juga. Apalagi kalau argument-nya banyak, pasti sering typo dan harus ulang lagi.
Kabar baiknya, Python punya solusi keren untuk masalah ini: CLI tool. Dengan CLI tool, kamu bisa bikin command yang clean, reusable, dan bahkan share ke tim kamu. Di artikel ini, saya bakal tunjukin cara bikin CLI tool pakai Python mulai dari argparse (yang sudah built-in), pakai library Click yang powerful, sampai Typer yang super modern. Terakhir, kita juga bakal packaging supaya bisa di-install via pip install. Yuk mulai!
Kenapa Harus Bikin CLI Tool?
Sebelum masuk ke kode, saya mau jelasin dulu kenapa CLI tool itu penting. Dalam pekerjaan sehari-hari sebagai developer, kita sering banget nulis script yang cuma sekali jalan. Tapi seiring waktu, script itu jadi penting dan dipakai orang lain juga.
Bayangkan kalau script kamu cuma punya sys.argv untuk handle argument. Nggak ada help message, nggak ada validasi, user harus baca source code dulu buat tau cara pakai. Itu buruk dari sisi UX.
CLI tool yang baik itu punya:
- Help message otomatis user tinggal ketik
--help - Validasi input error yang jelas kalau argument salah
- Auto-completion bisa auto-complete di terminal
- Nested commands seperti
git commitataudocker compose up - Package yang bisa di-install tinggal
pip installdan langsung pakai
Seru kan? Let's build one!
Cara Pertama: Menggunakan argparse (Built-in)
argparse itu sudah ada di Python standard library. Nggak perlu install apapun. Cocok banget untuk CLI tool sederhana. Saya mulai dari sini karena ini fondasi yang harus dipahami dulu.
Misalnya saya mau bikin tool sederhana untuk konversi ukuran file dari bytes ke berbagai satuan lain:
import argparse
def convert_size(size_bytes, unit):
"""Convert bytes ke unit yang dipilih."""
units = {
"kb": 1024,
"mb": 1024 ** 2,
"gb": 1024 ** 3,
"tb": 1024 ** 4,
}
if unit not in units:
raise ValueError(f"Unit '{unit}' tidak didukung. Pilih: {', '.join(units.keys())}")
return size_bytes / units[unit]
def main():
parser = argparse.ArgumentParser(
description="Konversi ukuran file dari bytes ke satuan lain.",
epilog="Contoh: python filesize.py 1048576 --unit mb"
)
parser.add_argument(
"size",
type=int,
help="Ukuran file dalam bytes"
)
parser.add_argument(
"--unit", "-u",
type=str,
default="mb",
choices=["kb", "mb", "gb", "tb"],
help="Satuan output (default: mb)"
)
parser.add_argument(
"--precision", "-p",
type=int,
default=2,
help="Jumlah angka di belakang koma (default: 2)"
)
args = parser.parse_args()
result = convert_size(args.size, args.unit)
print(f"{args.size} bytes = {result:.{args.precision}f} {args.unit.upper()}")
if __name__ == "__main__":
main()
Sekarang kalau saya jalankan:
$ python filesize.py 1048576 --unit mb
1048576 bytes = 1.00 MB
$ python filesize.py 1073741824 --unit gb --precision 4
1073741824 bytes = 1.0000 GB
$ python filesize.py --help
usage: filesize.py [-h] [--unit {kb,mb,gb,tb}] [--precision PRECISION] size
Konversi ukuran file dari bytes ke satuan lain.
positional arguments:
size Ukuran file dalam bytes
optional arguments:
-h, --help show this help message and exit
--unit {kb,mb,gb,tb}, -u {kb,mb,gb,tb}
Satuan output (default: mb)
--precision PRECISION, -p PRECISION
Jumlah angka di belakang koma (default: 2)
Nah, argparse udah bisa handle semua itu auto generate help message, validasi argument, bahkan choices supaya user nggak bisa masukin unit yang salah. Tapi kalau CLI tool-nya makin kompleks, misalnya butuh subcommand kayak tool init, tool build, tool deploy, maka argparse mulai terasa ribet.
Makanya, saya lanjut ke alternatif yang lebih modern.
Cara Kedua: Click Powerful dan Flexible
Click adalah library yang sangat populer untuk bikin CLI tool di Python. Banyak project besar pakai Click misalnya Flask, Pipenv, dan Poetry. Click pakai decorator-based approach, jadi code-nya lebih clean dan readable.
Install Click dulu:
$ pip install click
Sekarang saya bikin tool yang sama tapi pakai Click. Perhatikan betapa lebih ringkasnya:
import click
@click.command()
@click.argument("size", type=int)
@click.option("--unit", "-u", default="mb", type=click.Choice(["kb", "mb", "gb", "tb"]), help="Satuan output")
@click.option("--precision", "-p", default=2, help="Jumlah angka di belakang koma")
def filesize(size, unit, precision):
"""Konversi ukuran file dari bytes ke satuan lain."""
units = {"kb": 1024, "mb": 1024 ** 2, "gb": 1024 ** 3, "tb": 1024 ** 4}
result = size / units[unit]
click.echo(f"{size} bytes = {result:.{precision}f} {unit.upper()}")
if __name__ == "__main__":
filesize()
Clean banget kan? Dan kalau saya tambahkan --help, Click otomatis generate help message dari docstring dan parameter. Sekarang saya mau tunjukin kekuatan Click yang sebenarnya: subcommand.
Misalnya saya mau bikin tool bernama mytools yang punya beberapa subcommand:
import click
import os
@click.group()
def cli():
"""MyTools toolkit untuk developer."""
pass
@cli.command()
@click.argument("filename")
def info(filename):
"""Tampilkan informasi file."""
if not os.path.exists(filename):
click.echo(click.style(f"File '{filename}' tidak ditemukan!", fg="red"), err=True)
raise SystemExit(1)
size = os.path.getsize(filename)
units = {"B": 1, "KB": 1024, "MB": 1024 ** 2}
unit = "B"
display_size = size
for u, mult in reversed(units.items()):
if size >= mult:
unit = u
display_size = size / mult
break
click.echo(f" File : {filename}")
click.echo(f" Size : {display_size:.2f} {unit}")
click.echo(f" Dir : {os.path.abspath(filename)}")
@cli.command()
@click.argument("path", default=".")
@click.option("--extension", "-e", help="Filter berdasarkan extension")
def ls(path, extension):
"""List files di direktori."""
files = os.listdir(path)
if extension:
extension = extension.lstrip(".")
files = [f for f in files if f.endswith(f".{extension}")]
for f in sorted(files):
click.echo(f" {f}")
@cli.command()
@click.argument("name")
@click.option("--create-dir/--no-create-dir", default=True, help="Buat directory juga?")
def init(name, create_dir):
"""Inisialisasi project baru."""
os.makedirs(name, exist_ok=True)
if create_dir:
dirs = ["src", "tests", "docs"]
for d in dirs:
os.makedirs(os.path.join(name, d), exist_ok=True)
click.echo(f" Project '{name}' berhasil dibuat dengan struktur: src/, tests/, docs/")
else:
click.echo(f" Project '{name}' berhasil dibuat!")
if __name__ == "__main__":
cli()
Sekarang saya bisa jalankan tool ini seperti ini:
$ python mytools.py info myfile.txt
File : myfile.txt
Size : 1.05 KB
Dir : /home/user/myfile.txt
$ python mytools.py ls --extension py
main.py
mytools.py
utils.py
$ python mytools.py init myproject --no-create-dir
Project 'myproject' berhasil dibuat!
Keren kan? Click handle semua help message, validasi, type conversion, colored output. Tapi kalau kamu suka type hints dan lebih suka approach yang mirip FastAPI, ada opsi yang lebih baru lagi.
Cara Ketiga: Typer Modern, Type-Safe, dan Keren
Typer dibuat oleh creator FastAPI, Sebasti n Ram rez. Typer pakai type hints Python sebagai dasar deklarasi CLI argument. Hasilnya? Code-nya super clean, ada auto-completion support, dan error handling yang lebih baik.
Install Typer:
$ pip install typer
Mari kita rebuild tool konversi file size tadi dengan Typer:
import typer
from typing import Optional
from enum import Enum
app = typer.Typer(help="FileSize konversi ukuran file dari bytes.")
class Unit(str, Enum):
KB = "kb"
MB = "mb"
GB = "gb"
TB = "tb"
@app.command()
def convert(
size: int = typer.Argument(..., help="Ukuran file dalam bytes"),
unit: Unit = typer.Option(Unit.MB, "--unit", "-u", help="Satuan output"),
precision: int = typer.Option(2, "--precision", "-p", help="Angka desimal"),
):
"""Konversi ukuran file dari bytes ke satuan lain."""
units = {
Unit.KB: 1024,
Unit.MB: 1024 ** 2,
Unit.GB: 1024 ** 3,
Unit.TB: 1024 ** 4,
}
result = size / units[unit]
typer.echo(f"{size} bytes = {result:.{precision}f} {unit.value.upper()}")
if __name__ == "__main__":
app()
Notice yang menarik: saya pakai enum Enum untuk validasi unit. Typer otomatis generate validasi dan pilihan yang benar. Kalau user masukin unit yang salah, Typer langsung kasih error message yang jelas.
Sekarang saya mau tunjukin sesuatu yang lebih real-world. Misalnya saya mau bikin CLI tool untuk generate boilerplate Python project yang benar-benar bisa saya pakai sehari-hari.
Building a Real CLI Tool: Project Scaffolder
Mari kita bikin tool yang berguna: pyscaffold CLI tool untuk generate boilerplate Python project yang sudah siap develop.
"""pyscaffold CLI tool untuk generate Python project boilerplate."""
import typer
import os
from pathlib import Path
from typing import Optional
app = typer.Typer(help=" PyScaffold generate Python project boilerplate dalam hitungan detik.")
@app.command()
def create(
name: str = typer.Argument(..., help="Nama project"),
author: Optional[str] = typer.Option(None, "--author", "-a", help="Nama author"),
license: str = typer.Option("MIT", "--license", "-l", help="Jenis license"),
with_docker: bool = typer.Option(False, "--docker", "-d", help="Sertakan Dockerfile"),
with_ci: bool = typer.Option(False, "--github-actions", "-g", help="Sertakan GitHub Actions workflow"),
):
"""Buat Python project baru dengan struktur yang sudah di-setup."""
project_path = Path(name)
if project_path.exists():
typer.echo(f" Folder '{name}' sudah ada!", err=True)
raise typer.Exit(1)
typer.echo(f" Membuat project '{name}'...")
# Buat direktori utama
dirs = [f"{name}", f"{name}/src/{name}", f"{name}/tests"]
if with_docker:
dirs.append(f"{name}/docker")
if with_ci:
dirs.append(f"{name}/.github/workflows")
for d in dirs:
Path(d).mkdir(parents=True, exist_ok=True)
# Generate pyproject.toml
pyproject = f"""[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "{name}"
version = "0.1.0"
description = ""
readme = "README.md"
authors = [
{{"name" = "{author or 'Your Name'}"}},
]
requires-python = ">=3.9"
license = "{license}"
[tool.pytest.ini_options]
testpaths = ["tests"]
"""
(project_path / "pyproject.toml").write_text(pyproject)
# Generate __init__.py
init_content = f'"""Top-level package for {name}."""
__version__ = "0.1.0"
'
(project_path / f"src/{name}/__init__.py").write_text(init_content)
# Generate README.md
readme = f"""# {name}
{''}
## Installation
```bash
pip install -e .
```
## Usage
```bash
python -m {name}
```
"""
(project_path / "README.md").write_text(readme)
# Generate test file
test_content = f"""def test_version():
import {name}
assert {name}.__version__ == "0.1.0"
"""
(project_path / f"tests/test_{name}.py").write_text(test_content)
(project_path / "tests/__init__.py").write_text("")
# Generate Dockerfile kalau diminta
if with_docker:
dockerfile = """FROM python:3.11-slim
WORKDIR /app
COPY . .
RUN pip install -e .
CMD ["python", "-m", package_name]
"""
(project_path / "docker/Dockerfile").write_text(dockerfile)
# Generate GitHub Actions kalau diminta
if with_ci:
workflow = f"""name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{{{ matrix.python-version }}}}
- run: pip install -e .
- run: python -m pytest
"""
(project_path / ".github/workflows/ci.yml").write_text(workflow)
# Summary
typer.echo("")
typer.echo(typer.style(" Project berhasil dibuat!", fg=typer.colors.GREEN, bold=True))
typer.echo("")
typer.echo(" Struktur:")
for root, dirs_list, files in os.walk(project_path):
level = root.replace(str(project_path), "").count(os.sep)
indent = " " * level
typer.echo(f"{indent} {os.path.basename(root)}/")
for f in files:
typer.echo(f"{indent} {f}")
typer.echo("")
typer.echo(typer.style("Selanjutnya:", fg=typer.colors.CYAN))
typer.echo(f" cd {name}")
typer.echo(" pip install -e .")
typer.echo(" python -m pytest")
@app.command()
def list_licenses():
"""Tampilkan daftar license yang tersedia."""
licenses = ["MIT", "Apache-2.0", "GPL-3.0", "BSD-3-Clause", "ISC"]
typer.echo(" Available licenses:")
for lic in licenses:
typer.echo(f" {lic}")
if __name__ == "__main__":
app()
Test run tool-nya:
$ python pyscaffold.py create myawesome --author "Budi" --docker --github-actions
Membuat project 'myawesome'...
Project berhasil dibuat!
Struktur:
myawesome/
pyproject.toml
README.md
src/
myawesome/
__init__.py
tests/
test_myawesome.py
docker/
Dockerfile
.github/
workflows/
ci.yml
Selanjutnya:
cd myawesome
pip install -e .
python -m pytest
Sekarang tool-nya udah berfungsi. Tapi bagaimana cara bikin orang lain bisa install tool ini tanpa harus clone repo dulu? Jawabannya: packaging.
Packaging CLI Tool dengan pyproject.toml
Supaya CLI tool kita bisa di-install via pip install dan langsung dipakai dari terminal, kita perlu packaging yang benar. Gunakan pyproject.toml dan fitur [project.scripts].
Struktur folder yang dibutuhkan:
pyscaffold/
pyproject.toml
README.md
src/
pyscaffold/
__init__.py
cli.py
Isi pyproject.toml:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "pyscaffold-cli"
version = "1.0.0"
description = "CLI tool untuk generate Python project boilerplate"
readme = "README.md"
authors = [{ name = "Developer", email = "[email protected]" }]
requires-python = ">=3.9"
license = "MIT"
dependencies = [
"typer[all]>=0.9.0",
]
[project.scripts]
pyscaffold = "pyscaffold.cli:app"
[project.optional-dependencies]
dev = ["pytest>=7.0", "ruff>=0.1.0"]
Bagian kuncinya ada di [project.scripts]. Ini yang bikin setelah pip install, command pyscaffold langsung bisa dipanggil dari terminal tanpa perlu python di depannya.
Cara install dalam mode development:
$ pip install -e ".[dev]"
$ pyscaffold create myproject --author "Budi" --docker
Atau kalau mau publish ke PyPI:
$ pip install build twine
$ python -m build
$ twine upload dist/*
Setelah publish, siapapun bisa install tool kamu pakai:
$ pip install pyscaffold-cli
$ pyscaffold create newproject
Gila, bayangin CLI tool buatan kamu bisa dipakai developer di seluruh dunia. That's the power of proper packaging.
Tips Tambahan yang Saya Pelajari dari Lapangan
Beberapa hal yang saya pelajari setelah bikin beberapa CLI tool untuk tim:
- Selalu pakai
--verbose/--quietflag. Biar user bisa kontrol output. Click dan Typer keduanya support ini dengan mudah. - Log to stderr, output ke stdout. Biar user bisa pipe output ke command lain tanpa kena log message. Di Click tinggal pakai
click.echo(..., err=True). - Handle Ctrl+C gracefully. Jangan biarkan error traceback muncul kalau user cancel. Bungkus main function dengan try-except.
- Tambahin version flag. Biar user bisa cek versi tool-nya. Di Typer tinggal
typer.__version__. - Test CLI tool kamu. Pakai
click.testing.CliRunneratautyper.testing.CliRunneruntuk testing command kamu secara unit-test.
# Contoh testing CLI tool pakai Typer
from typer.testing import CliRunner
from myproject.cli import app
runner = CliRunner()
def test_create_project():
result = runner.invoke(app, ["create", "test-project"])
assert result.exit_code == 0
assert "Project berhasil dibuat" in result.output
def test_version_flag():
result = runner.invoke(app, ["--version"])
assert result.exit_code == 0
assert "1.0.0" in result.output
Testing CLI tool itu sering dilupain, padahal penting banget. Saya pernah punya tool yang broken di Python 3.12 karena library click-nya update. Kalau ada test, pasti langsung ketahuan sebelum user complain.
Perbandingan: argparse vs Click vs Typer
Kalau masih bingung mau pakai yang mana, ini rangkuman singkat saya:
- argparse Cocok untuk script sederhana yang cuma butuh beberapa argument. Gratis, built-in, tapi verbose untuk project yang kompleks.
- Click Pilihan solid untuk project yang butuh subcommand, plugin system, atau custom workflows. Banyak battle-tested project pakai ini.
- Typer Paling modern. Type hints native, auto-completion support, dan berbasis Click di baliknya. Kalau kamu suka FastAPI, pasti langsung nyaman.
Saya pribadi sekarang pakai Typer untuk semua CLI tool baru. Type hints-nya bikin code jauh lebih readable, dan auto-complete support-nya bikin user experience jauh lebih baik.
Tapi kalau project yang sudah pakai Click dan kamu mau konsisten, Stick with Click juga nggak masalah keduanya pada dasarnya sama kuat.
Penutup
Nah, itu dia perjalanan dari script Python biasa sampai CLI tool yang profesional. Kita udah lihat gimana argparse bisa jadi fondasi, Click kasih power untuk subcommand dan structured CLI, dan Typer bawa pendekatan modern berbasis type hints.
Yang paling penting: jangan cuma nulis script yang cuma kamu sendiri yang bisa pakai. Bikin CLI tool-nya, package-nya, share ke tim kamu. Productivity naik, error berkurang, dan yang pasti kamu keliatan keren di terminal.
Satu hal lagi packaging dengan pyproject.toml itu sekarang udah gampang banget. Nggak perlu setup.py yang ribet lagi. Hatchling atau setuptools modern udah handle semuanya dengan clean.
Sekarang giliran kamu: Cara apa yang kamu pakai untuk bikin CLI tool di Python? Click, Typer, atau masih setia sama argparse? Atau mungkin ada library lain yang saya belum sebut? Tulis di kolom komentar ya saya penasaran mau denger pengalaman kalian!