CLI picocli

Aplikasi baris perintah berbasis anotasi picocli dengan pelengkapan otomatis.

Daftar isi

picocli adalah pustaka CLI (command line interface) berbasis anotasi untuk JVM. Satu kelas beranotasi sudah cukup menjadi aplikasi baris perintah yang lengkap, karena picocli mengurus penguraian argumen, konversi tipe, pesan bantuan, sampai pelengkapan otomatis (auto-completion). Topik ini membangun utilitas baris perintah dengan picocli 4.7.6 yang diunduh lewat @Grab, mencakup kelas perintah pertama, opsi beserta nilai default dan arity, argumen posisi, subperintah (subcommand), kode kembali (exit code), serta pembuatan skrip pelengkapan bash. Seluruh skrip contoh mengoper argumennya secara eksplisit sehingga keluarannya dapat direproduksi apa adanya.

Kelas perintah pertama#

Kelas perintah picocli adalah satu kelas biasa yang dianotasi @Command dan mengimplementasikan Runnable atau Callable. Anotasi @Command menetapkan nama program beserta deskripsinya, sedangkan logikanya ditulis pada metode run() atau call() sesuai antarmuka yang dipilih. Eksekusinya dilakukan lewat CommandLine(perintah).execute(argumen), yaitu metode yang menguraikan argumen, mengonversinya ke tipe setiap field, menjalankan metode tersebut, lalu mengembalikan kode kembali dalam bentuk int. Opsi dianotasi @Option dengan names sebagai bentuk opsi yang sah dan defaultValue sebagai nilai ketika opsi tidak dikirim. Argumen posisi tanpa tanda hubung ditampung field beranotasi @Parameters.

Contoh berikut membangun CLI penghitung kata. Opsi --panjang-min menyaring kata yang lebih pendek dari ambangnya, sedangkan seluruh kata dikirim sebagai argumen posisi.

groovy hitung-kata.groovy
// hitung-kata.groovy: CLI penghitung kata dengan anotasi picocli
@Grab('info.picocli:picocli:4.7.6')
import picocli.CommandLine
import picocli.CommandLine.Command
import picocli.CommandLine.Option
import picocli.CommandLine.Parameters

import java.util.concurrent.Callable

@Command(name = 'hitung-kata', mixinStandardHelpOptions = true,
        description = 'Menghitung jumlah kata dan total huruf dari argumen.')
class HitungKata implements Callable<Integer> {

    @Option(names = ['--panjang-min'], defaultValue = '0',
            description = 'Melewati kata yang lebih pendek dari nilai ini.')
    int panjangMin

    @Parameters(description = 'Kata-kata yang diperiksa.')
    List<String> kata

    Integer call() {
        def lolos = kata.findAll { it.length() >= panjangMin }
        println "Jumlah kata     : ${kata.size()}"
        println "Kata lolos      : ${lolos.size()}"
        println "Total huruf     : ${lolos*.length().sum()}"
        println "Kata terpanjang : ${lolos.max { it.length() }}"
        return 0
    }
}

int kode = new CommandLine(new HitungKata()).execute('--panjang-min', '3', 'ali', 'budi', 'du', 'citra')
System.exit(kode)
text Keluaran
Jumlah kata     : 4
Kata lolos      : 3
Total huruf     : 12
Kata terpanjang : citra

Kata du panjangnya dua sehingga tersaring oleh ambang tiga, dan hitungan sisanya hanya memperhatikan kata yang lolos. Nilai kembalian call() berupa 0 karena data masukannya sah, kemudian System.exit meneruskan nilai itu ke shell sebagai kode kembali prosesnya. Pola argumen eksplisit pada execute dipakai seluruh skrip di topik ini agar keluarannya tetap sama pada setiap eksekusi.

Opsi wajib, arity, dan nilai default#

Anotasi @Option dapat disetel lebih jauh dengan required dan arity, yaitu banyaknya nilai yang boleh ditampung satu kemunculan opsi. Opsi required = true wajib muncul pada argumen, sedangkan arity '1..*' berarti minimal satu nilai tanpa batas atas, sehingga cocok untuk opsi koleksi semacam deret nilai ujian. Seluruh angka setelah --nilai masuk ke List<Integer>, dan konversi String ke Integer berlangsung otomatis. Opsi yang tidak wajib dapat diberi defaultValue agar field tetap terisi meskipun opsinya tidak dikirim. Atribut split melengkapi arity dengan memecah satu teks opsi menjadi banyak nilai, misalnya '85,78,91' menjadi tiga angka.

groovy Contoh: rekap statistik nilai ujian
@Grab('info.picocli:picocli:4.7.6')
import picocli.CommandLine
import picocli.CommandLine.Command
import picocli.CommandLine.Option

import java.util.concurrent.Callable

@Command(name = 'rekap-nilai', mixinStandardHelpOptions = true,
        description = 'Merekap statistik nilai ujian dari beberapa peserta.')
class RekapNilai implements Callable<Integer> {

    @Option(names = ['--nilai'], required = true, arity = '1..*',
            description = 'Deret nilai ujian, minimal satu angka.')
    List<Integer> nilai

    @Option(names = ['--judul'], defaultValue = 'Rekap nilai ujian',
            description = 'Judul yang dicetak pada baris pertama.')
    String judul

    Integer call() {
        println judul
        println "Peserta   : ${nilai.size()}"
        println "Rata-rata : ${nilai.sum() / nilai.size()}"
        println "Tertinggi : ${nilai.max()}"
        println "Terendah  : ${nilai.min()}"
        return 0
    }
}

int kode = new CommandLine(new RekapNilai()).execute('--nilai', '85', '78', '91', '70', '88')
System.exit(kode)
text Keluaran
Rekap nilai ujian
Peserta   : 5
Rata-rata : 82.4
Tertinggi : 91
Terendah  : 70

Lima nilai masuk melalui satu kemunculan --nilai, dan rekapnya dihitung dari daftar itu. Rata-rata 82.4 berasal dari total 412 yang dibagi lima peserta. Baris pertama memakai --judul, dan karena opsinya tidak dikirim, field judul kembali ke nilai defaultnya. Tanpa arity, satu kemunculan opsi hanya menampung satu nilai dan kelebihan argumennya ditolak, seperti dibedah pada bagian Kesalahan umum.

Parameter posisi dan konversi tipe#

Argumen posisi (positional) adalah argumen yang tidak diawali tanda hubung, misalnya nama berkas pada perintah ls nilai.txt. Field beranotasi @Parameters menampungnya secara berurutan, dan atribut index menetapkan urutannya bila ada lebih dari satu field posisi. Konversi tipenya otomatis, yaitu teks masukan diubah ke tipe field tanpa kode tambahan, termasuk ke tipe angka dan File. Field posisi bertipe List menampung seluruh sisa argumen posisi, seperti List<String> kata pada kelas perintah pertama. Bila teks masukan tidak dapat dikonversi ke tipe field, penguraian berhenti sebagai error, bukan berlanjut dengan nilai kosong. Berkat konversi File, perintah yang membaca berkas cukup menerima satu argumen posisi berupa jalur.

groovy Contoh: argumen posisi berupa berkas
@Grab('info.picocli:picocli:4.7.6')
import picocli.CommandLine
import picocli.CommandLine.Command
import picocli.CommandLine.Parameters

@Command(name = 'rekap-berkas', mixinStandardHelpOptions = true,
        description = 'Merekap isi sebuah berkas teks.')
class RekapBerkas implements Runnable {

    @Parameters(index = '0', description = 'Berkas teks yang direkap.')
    File berkas

    void run() {
        def baris = berkas.readLines('UTF-8').findAll { it.trim() }
        println "Baris isi   : ${baris.size()}"
        println "Karakter    : ${berkas.text.length()}"
        println "Ukuran byte : ${berkas.length()}"
    }
}

def berkas = File.createTempFile('nilai', '.txt')
berkas.deleteOnExit()
berkas.text = '85\n78\n91\n'

int kode = new CommandLine(new RekapBerkas()).execute(berkas.absolutePath)
System.exit(kode)
text Keluaran
Baris isi   : 3
Karakter    : 9
Ukuran byte : 9

Berkas uji dibuat lewat File.createTempFile di folder sementara, lalu jalurnya dikirim sebagai satu-satunya argumen posisi. picocli mengubah jalur itu menjadi objek File sebelum run() dijalankan, sehingga pembacaan isinya dapat dilakukan langsung. Isinya tiga baris nilai dengan sembilan karakter dan sembilan byte, angka yang saling konsisten dan mudah dihitung ulang.

Subperintah dan keluaran bantuan#

Program dengan banyak fungsi biasanya memilah kemampuannya menjadi subperintah, semacam git yang memiliki git commit dan git push. Pada picocli, kelas induk mendaftarkan kelas-kelas anaknya melalui atribut subcommands pada @Command, lalu execute memilih subperintah berdasarkan argumen pertamanya. Setiap subperintah berdiri sendiri dengan opsi dan logikanya masing-masing, sedangkan bantuan kelas induk mendaftar seluruh subperintah yang tersedia pada bagian Commands. Opsi kelas induk diletakkan sebelum nama subperintah, sedangkan opsi subperintah menyusul setelah namanya. Kelas anak pun dapat mendaftar subcommands lagi di bawahnya, sehingga struktur perintah bertingkat tersusun dari pola yang sama.

groovy alat-konversi.groovy
// alat-konversi.groovy: subperintah suhu dan jarak beserta keluaran --help
@Grab('info.picocli:picocli:4.7.6')
import picocli.CommandLine
import picocli.CommandLine.Command
import picocli.CommandLine.Option

@Command(name = 'suhu', description = 'Mengonversi suhu dari Celcius ke Fahrenheit.')
class Suhu implements Runnable {

    @Option(names = ['--celcius'], required = true,
            description = 'Suhu sumber dalam Celcius.')
    double celcius

    void run() {
        println "suhu  : $celcius C = ${celcius * 9 / 5 + 32} F"
    }
}

@Command(name = 'jarak', description = 'Mengonversi jarak dari kilometer ke mil.')
class Jarak implements Runnable {

    @Option(names = ['--km'], required = true,
            description = 'Jarak sumber dalam kilometer.')
    double km

    void run() {
        println "jarak : $km km = ${km * 0.6213712} mil"
    }
}

@Command(name = 'alat', mixinStandardHelpOptions = true,
        description = 'Kumpulan konversi satuan.', subcommands = [Suhu, Jarak])
class Alat { }

def cmd = new CommandLine(new Alat())
cmd.execute('suhu', '--celcius', '100')
cmd.execute('jarak', '--km', '10')

// keluaran --help dijalankan dua kali, ditangkap, lalu dibandingkan
def tangkap = { Closure aksi ->
    def penyangga = new ByteArrayOutputStream()
    def keluaranLama = System.out
    System.setOut(new PrintStream(penyangga, true, 'UTF-8'))
    try {
        aksi()
    } finally {
        System.setOut(keluaranLama)
    }
    penyangga.toString('UTF-8')
}
def pertama = tangkap { new CommandLine(new Alat()).execute('--help') }
def kedua = tangkap { new CommandLine(new Alat()).execute('--help') }
println "Bantuan identik dua kali: ${pertama == kedua}"
print pertama
text Keluaran
suhu  : 100.0 C = 212.0 F
jarak : 10.0 km = 6.213712 mil
Bantuan identik dua kali: true
Usage: alat [-hV] [COMMAND]
Kumpulan konversi satuan.
  -h, --help      Show this help message and exit.
  -V, --version   Print version information and exit.
Commands:
  suhu   Mengonversi suhu dari Celcius ke Fahrenheit.
  jarak  Mengonversi jarak dari kilometer ke mil.

Subperintah suhu mengonversi 100 Celcius menjadi 212 Fahrenheit, sedangkan jarak mengonversi 10 kilometer menjadi 6.213712 mil. Bagian akhir skrip menjalankan execute('--help') dua kali di dalam penangkap keluaran lalu membandingkan hasilnya, dan pembanding itu mencetak true, artinya transkrip Usage di atas sama persis pada dua eksekusi. Baris Show this help message and exit dan Print version information and exit adalah teks bawaan picocli untuk opsi yang ditambahkan mixinStandardHelpOptions.

Kode kembali#

Metode execute mengembalikan kode kembali yang lazim diperiksa shell untuk memutuskan langkah berikutnya. Nilai kembalian call() pada Callable diteruskan utuh, sehingga keberhasilan ditentukan program sendiri, misalnya 0 untuk sukses dan 1 untuk data yang ditolak. Runnable tidak mengembalikan nilai, dan perintah yang berhasil dijalankan menghasilkan kode 0 secara default, sedangkan nilai bukan nol diteruskan tanpa diubah. Error penguraian diperlakukan berbeda, yaitu picocli mencetak pesannya beserta bantuan ke keluaran stderr lalu execute mengembalikan 2. Exception yang tidak tertangani di dalam run() atau call() dicetak ke stderr dan membuat execute mengembalikan 1. Nilai yang dikembalikan execute selanjutnya diteruskan ke sistem melalui System.exit.

groovy Contoh: kode kembali dari callable
@Grab('info.picocli:picocli:4.7.6')
import picocli.CommandLine
import picocli.CommandLine.Command
import picocli.CommandLine.Option

import java.util.concurrent.Callable

@Command(name = 'validasi-nilai', description = 'Memeriksa satu nilai ujian.')
class ValidasiNilai implements Callable<Integer> {

    @Option(names = ['--nilai'], required = true, description = 'Nilai yang diperiksa.')
    int nilai

    Integer call() {
        if (nilai < 0 || nilai > 100) {
            println "Nilai $nilai di luar rentang 0 sampai 100"
            return 1
        }
        println "Nilai $nilai diterima"
        return 0
    }
}

int kodePertama = new CommandLine(new ValidasiNilai()).execute('--nilai', '85')
int kodeKedua = new CommandLine(new ValidasiNilai()).execute('--nilai', '150')
println "Kode kembali pertama : $kodePertama"
println "Kode kembali kedua   : $kodeKedua"
text Keluaran
Nilai 85 diterima
Nilai 150 di luar rentang 0 sampai 100
Kode kembali pertama : 0
Kode kembali kedua   : 1

Nilai 85 berada di dalam rentang sehingga diterima dengan kode 0, sedangkan 150 ditolak karena berada di luar rentang, dengan kode 1. Skrip shell yang membungkus CLI ini dapat memercayai kedua kode tersebut, misalnya untuk menghentikan rangkaian perintah ketika datanya ditolak. Aturan yang sama berlaku pada subperintah, karena nilai kembalian subperintah yang dijalankan menjadi nilai kembalian execute.

Pelengkapan otomatis#

picocli menghasilkan skrip pelengkapan otomatis untuk bash dari kelas perintah yang sama, sehingga opsi maupun subperintah baru langsung ikut terlengkapi setelah skripnya dibuat ulang. Generatornya adalah kelas picocli.AutoComplete yang berada di dalam jar inti picocli, bukan modul tambahan. Generator ini membaca anotasi kelas perintah yang sudah terkompilasi, lalu menulis berkas hitung-kata_completion yang berisi fungsi complete untuk seluruh opsinya. Kelas perintah pada skrip Groovy terlebih dahulu dikompilasi dengan groovyc, dan jar picocli untuk classpath dapat disalin dari cache Grape pada folder ~/.groovy/grapes/info.picocli/picocli/jars/.

bash
# tidak-dijalankan
# mengompilasi kelas perintah agar dapat dibaca generator pelengkapan
groovyc -cp picocli-4.7.6.jar hitung-kata.groovy

# menghasilkan berkas pelengkapan hitung-kata_completion pada folder kerja
groovy -cp picocli-4.7.6.jar -e "picocli.AutoComplete.main('HitungKata')"

# memuat pelengkapan pada sesi bash saat ini
source hitung-kata_completion

Berkas yang dihasilkan diawali komentar cara pemasangannya, yaitu dimuat lewat source pada .bash_profile atau diletakkan pada folder bash-completion.d. Setelah dimuat, bash melengkapi ketikan opsi ketika tombol Tab ditekan setelah nama perintahnya.

Kesalahan umum#

Dua kegagalan penguraian paling sering terjadi saat @Option bertipe koleksi dipakai tanpa arity dan saat opsi wajib tidak dikirim. Keduanya dilaporkan sebagai ParameterException. Contoh berikut memakai parseArgs, yaitu metode yang hanya menguraikan argumen tanpa menjalankan perintahnya, sehingga exception yang dilemparkannya dapat ditangkap dan pesannya dicetak.

Kesalahan pertama adalah mengirim banyak nilai ke @Option yang arity-nya tidak disetel. Satu kemunculan --nilai hanya menampung satu nilai, sehingga angka kedua dan ketiganya dianggap argumen posisi. Kelas ini tidak memiliki field @Parameters, sehingga picocli menolak sisa argumennya.

groovy Contoh: opsi koleksi tanpa arity
@Grab('info.picocli:picocli:4.7.6')
import picocli.CommandLine
import picocli.CommandLine.Command
import picocli.CommandLine.Option
import picocli.CommandLine.ParameterException

@Command(name = 'rekap-nilai', description = 'Merekap statistik nilai ujian.')
class RekapNilai implements Runnable {

    // tanpa arity: satu kemunculan --nilai hanya menampung satu nilai
    @Option(names = ['--nilai'], required = true, description = 'Deret nilai ujian.')
    List<Integer> nilai

    void run() {
        println "Rata-rata: ${nilai.sum() / nilai.size()}"
    }
}

def cmd = new CommandLine(new RekapNilai())
try {
    cmd.parseArgs('--nilai', '85', '78', '91')
} catch (ParameterException e) {
    println "Kelas : ${e.getClass().simpleName}"
    println "Pesan : ${e.message}"
}
text Keluaran
Kelas : UnmatchedArgumentException
Pesan : Unmatched arguments from index 2: '78', '91'

Pesan Unmatched arguments from index 2 menunjuk argumen pertama yang tidak tertangani, yaitu 78 pada indeks kedua deret argumen. Menambahkan arity = '1..*' pada anotasinya menyelesaikan kasus ini.

Kesalahan kedua adalah melupakan opsi wajib. Penguraian berhenti sebelum run() sempat dijalankan, dan pesannya menyebut opsi yang hilang beserta bentuk nilainya.

groovy Contoh: opsi wajib tidak dikirim
@Grab('info.picocli:picocli:4.7.6')
import picocli.CommandLine
import picocli.CommandLine.Command
import picocli.CommandLine.Option
import picocli.CommandLine.ParameterException

@Command(name = 'rekap-nilai', description = 'Merekap statistik nilai ujian.')
class RekapNilai implements Runnable {

    @Option(names = ['--nilai'], required = true, arity = '1..*',
            description = 'Deret nilai ujian, minimal satu angka.')
    List<Integer> nilai

    void run() {
        println "Rata-rata: ${nilai.sum() / nilai.size()}"
    }
}

def cmd = new CommandLine(new RekapNilai())
try {
    cmd.parseArgs()
} catch (ParameterException e) {
    println "Kelas : ${e.getClass().simpleName}"
    println "Pesan : ${e.message}"
}
text Keluaran
Kelas : MissingParameterException
Pesan : Missing required option: '--nilai=<nilai>'

Bila pemanggilannya memakai execute dan bukan parseArgs, kedua kegagalan ini tidak dilempar, melainkan dicetak oleh picocli ke stderr bersama bantuan penggunaan, lalu execute mengembalikan kode 2. Kelas perintah yang tidak mengimplementasikan Runnable maupun Callable juga gagal saat dieksekusi dengan ExecutionException, sehingga salah satu padanannya wajib ada sejak kelas perintah dibuat.

Ringkasan#

  • Satu kelas beranotasi @Command yang mengimplementasikan Runnable atau Callable sudah menjadi CLI lengkap, dan CommandLine(perintah).execute(argumen) menguraikan argumen, mengonversi tipe, menjalankannya, lalu mengembalikan kode kembali.
  • @Option menampung opsi dengan names, defaultValue, required, dan arity, sedangkan @Parameters menampung argumen posisi dengan konversi otomatis, termasuk ke tipe File.
  • execute mengembalikan nilai kembalian call(), kode 2 untuk error penguraian, dan 1 untuk exception yang tidak tertangani, lalu System.exit meneruskannya ke shell.
  • Subperintah didaftarkan pada atribut subcommands kelas induk dan dipilih dari argumen pertama, sedangkan skrip pelengkapan bash dihasilkan oleh picocli.AutoComplete dari jar inti.

Referensi#