Kursus Python / Proyek akhir aplikasi pengeluaran SQLite dengan CLI

Proyek akhir ini adalah aplikasi command line yang menyimpan tanggal, kategori, dan jumlah pengeluaran dalam SQLite, lalu menampilkan daftar atau total per kategori. Program menghubungkan inisialisasi, validasi, transaksi, dan pemilihan perintah yang sudah dipelajari. Setiap perintah add menambahkan satu pengeluaran baru, sehingga eksekusi ulang memang menambah record.

Menetapkan ruang lingkup dan aturan data

Jumlah adalah bilangan bulat nonnegatif dalam satuan won penuh, dengan batas 1.000.000.000.000 per pengeluaran. Tanggal harus benar-benar ada dan ditulis persis YYYY-MM-DD; kategori tidak boleh kosong setelah spasi tepi dibuang. Program ini tidak menangani pembayaran, koneksi bank, konversi mata uang, atau autentikasi banyak pengguna.

Gunakan Python 3.12 atau lebih baru dengan dukungan sqlite3 dan pustaka standar. Buat folder latihan tersendiri, simpan file dengan UTF-8, dan aktifkan lingkungan virtual proyek. Database bawaan ledger.db berada relatif terhadap folder kerja saat perintah dijalankan, bukan otomatis di lokasi file program. Jangan menunjuk file bisnis atau database latihan lain.

Opsi global --db memilih database terpisah dan harus ditempatkan sebelum subperintah. Misalnya, python ledger.py --db other-ledger.db init membuat atau menyiapkan database lain. Jika nama jalur mengandung spasi, apit jalur dengan tanda kutip. Parent folder harus sudah ada; opsi ini tidak membuat semua folder yang hilang.

Menyimpan program lengkap

Simpan seluruh kode berikut sebagai ledger.py:

import argparse
import sqlite3
from datetime import date
from pathlib import Path

MAX_AMOUNT = 1_000_000_000_000

def amount_value(text):
    try:
        value = int(text)
    except ValueError as error:
        raise argparse.ArgumentTypeError("expected an integer") from error
    if not 0 <= value <= MAX_AMOUNT:
        raise argparse.ArgumentTypeError("amount must be between 0 and 1000000000000")
    return value

def date_value(text):
    try:
        parsed = date.fromisoformat(text)
    except ValueError as error:
        raise argparse.ArgumentTypeError("invalid date") from error
    if parsed.isoformat() != text:
        raise argparse.ArgumentTypeError("use YYYY-MM-DD")
    return text

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--db", type=Path, default=Path("ledger.db"))
    commands = parser.add_subparsers(dest="command", required=True)
    commands.add_parser("init")
    add = commands.add_parser("add")
    add.add_argument("day", type=date_value)
    add.add_argument("category")
    add.add_argument("amount", type=amount_value)
    commands.add_parser("list")
    commands.add_parser("summary")
    args = parser.parse_args()
    if args.command != "init" and not args.db.is_file():
        parser.error("database not initialized")
    if args.command == "add" and not args.category.strip():
        parser.error("category is required")
    con = sqlite3.connect(args.db, autocommit=False)
    try:
        if args.command == "init":
            with con:
                con.execute("CREATE TABLE IF NOT EXISTS expenses (id INTEGER PRIMARY KEY, day TEXT NOT NULL, category TEXT NOT NULL CHECK(length(trim(category))>0), amount INTEGER NOT NULL CHECK(amount BETWEEN 0 AND 1000000000000))")
            print("initialized")
        elif args.command == "add":
            with con:
                con.execute("INSERT INTO expenses(day,category,amount) VALUES(?,?,?)", (args.day, args.category.strip(), args.amount))
            print("added")
        elif args.command == "list":
            for row in con.execute("SELECT id,day,category,amount FROM expenses ORDER BY day,id"):
                print(*row)
        else:
            totals = {}
            for category, amount in con.execute("SELECT category,amount FROM expenses"):
                totals[category] = totals.get(category, 0) + amount
            for category, total in sorted(totals.items()):
                print(category, total)
    finally:
        con.close()

if __name__ == "__main__":
    main()

amount_value mengonversi teks menjadi integer dan memeriksa rentangnya. date_value membaca tanggal lalu membandingkan kembali bentuk ISO agar format ringkas atau format minggu tidak diterima sebagai pengganti YYYY-MM-DD. main mengatur parser, memeriksa database, dan membatasi umur koneksi.

Subperintah init membuat tabel jika belum ada; add menyimpan satu record; list menampilkan data menurut tanggal lalu ID; summary menjumlahkan per kategori. Kategori dibersihkan dengan strip() sebelum disimpan. Berbeda dari kolektor API berdasarkan ID eksternal, aplikasi ini tidak memiliki identitas transaksi dari luar untuk menolak pengulangan perintah.

Melakukan inisialisasi, masukan, dan pembacaan

Buka terminal pada folder latihan dan jalankan perintah berikut satu per satu dengan interpreter lingkungan proyek:

python ledger.py init
python ledger.py add 2026-10-17 food 3500
python ledger.py add 2026-10-17 food 8000
python ledger.py add 2026-10-17 transport 2500
python ledger.py list
python ledger.py summary

init mencetak initialized dan setiap masukan berhasil mencetak added. Pada database baru, daftar menampilkan tiga record dengan ID 1, 2, dan 3. Ringkasan terakhir harus sama dengan:

food 11500
transport 2500

init tidak menghapus isi tabel yang sudah ada. Menjalankan kembali tiga perintah add menghasilkan enam record dan menggandakan total. Untuk mengulangi contoh dari keadaan kosong, gunakan folder atau jalur --db baru. Jangan menghapus database yang belum diketahui isinya hanya untuk mendapatkan keluaran awal.

Perintah list dan summary pada database yang belum diinisialisasi ditolak. File SQLite sembarang yang sudah ada juga belum tentu memiliki tabel yang benar. Periksa pemilihan jalur dan skema, bukan sekadar keberadaan file. CREATE TABLE IF NOT EXISTS tidak memperbarui struktur atau constraint tabel lama.

Memeriksa penolakan dan penyimpanan permanen

  1. Tambahkan tanggal 2026-02-30 dan pastikan program menolaknya sebelum penyimpanan.
  2. Coba jumlah negatif, jumlah di atas batas, dan kategori yang hanya berisi spasi. Periksa status gagal dan pastikan jumlah record tidak bertambah.
  3. Jalankan list melalui proses baru. Data harus tetap ada setelah proses sebelumnya selesai.
  4. Ulangi tiga masukan normal pada database baru dan cocokkan daftar serta totalnya dengan contoh.

Masukan yang tidak sesuai kontrak parser biasanya menghasilkan status 2. Pesan kesalahan database yang tidak ditangani dapat muncul sebagai traceback. Baca status tepat setelah perintah dengan $LASTEXITCODE pada PowerShell atau echo $? pada shell macOS/Ubuntu. Jangan melanjutkan pemrosesan otomatis dengan menganggap semua keluaran sebagai sukses.

Nilai masuk ke SQL melalui parameter ?, sehingga kategori yang memiliki tanda kutip tetap data, bukan bagian sintaks SQL. Constraint database memperkuat batas jumlah dan kategori; validasi Python tetap penting, termasuk tanggal yang hanya disimpan sebagai teks. Blok koneksi melakukan rollback ketika exception keluar dari transaksi, lalu finally menutup koneksi. Lihat parameter dan transaksi SQLite untuk batas validasi penyimpanan.

Menjalankan pemeriksaan integrasi dan memisahkan tanggung jawab

Simpan kode lengkap berikut sebagai check_ledger.py di folder yang sama dengan ledger.py, lalu jalankan python check_ledger.py. Gunakan eksekusi biasa tanpa -O karena skrip pemeriksa memakai pernyataan assert.

import subprocess
import sys
import tempfile
from pathlib import Path
program = Path("ledger.py").resolve()
with tempfile.TemporaryDirectory() as folder:
    db = Path(folder) / "test.db"
    def run(*args):
        return subprocess.run([sys.executable, str(program), "--db", str(db), *args], capture_output=True, text=True)
    assert run("init").returncode == 0
    assert run("add", "2026-10-17", "food", "8000").returncode == 0
    assert run("add", "2026-02-30", "food", "8000").returncode != 0
    assert run("add", "2026-10-17", "food", "-1").returncode != 0
    assert run("add", "2026-10-17", "food", "1000000000001").returncode != 0
    result = run("summary")
    assert result.returncode == 0 and result.stdout.strip() == "food 8000"
    assert len(run("list").stdout.splitlines()) == 1
print("integration checks passed")

Hasil yang diharapkan:

integration checks passed

Skrip membuat database sementara, menjalankan beberapa proses CLI, lalu memeriksa tanggal salah, jumlah negatif, batas atas, ringkasan, dan jumlah record. File proyek tidak digunakan sebagai database pengujian. Jika sebuah assertion gagal, baca kasus terkait dan periksa stderr proses yang gagal; pesan sukses hanya dicetak setelah seluruh pemeriksaan terpenuhi. Dasar rancangan pengujian dijelaskan dalam unittest dan pemeriksaan integrasi.

Ringkasan memakai integer Python untuk menghindari batas akumulasi integer SQLite SUM. Pembacaan record berlangsung melalui iterator, sedangkan dictionary total tumbuh menurut jumlah kategori. Jangan mengganti nilai uang tepat dengan floating point hanya untuk menghindari overflow; aturan SUM dan total SQLite menjelaskan perbedaannya.

Satu file lengkap membantu melihat alur program. Ketika aplikasi berkembang, pisahkan parser, validator, akses database, dan perhitungan ke modul sambil mempertahankan tes perilaku. Pertahankan main guard agar impor tidak membuat database. Untuk data pribadi nyata, rancang izin file, lokasi berbagi, backup, pemulihan, migrasi skema, dan akses bersamaan sebagai pekerjaan tersendiri. Rincian API yang dipakai tersedia pada date.fromisoformat dan sqlite3.